From 8eedd0a5c4e5914089090706fedd9e57c91b3693 Mon Sep 17 00:00:00 2001 From: andrewnakas Date: Sat, 3 Oct 2026 09:45:21 -0600 Subject: [PATCH 001/109] Add the ReSkate Trainer: a TRAINER page with live physics tuning, presets, practice tools and jump telemetry All new code is in Extension/Trainer. The game thread owns the trainer's state; the menu page only queues `trainer ...` console commands and reads two snapshots. - physics_tuning_model keeps the field and curve names the game's EBX carries, so the editable table is built at run time from the player's own data. - physics_tuning exports game_tuning / read_live / write_live / refresh_skater, with the same field whitelist and curve rules as the session sync. - no_bail publishes the physics state the local skater's selector last chose (air time, wipeouts). - A guest under host tuning cannot edit; teleports follow the host's noclip permission. Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 10 +- Extension/Console/commands.cpp | 1 + Extension/Console/commands.h | 1 + Extension/Skater/no_bail.cpp | 34 +- Extension/Skater/no_bail.h | 9 + Extension/Skater/physics_tuning.cpp | 36 + Extension/Skater/physics_tuning.h | 17 + Extension/Skater/physics_tuning_model.cpp | 21 +- Extension/Skater/physics_tuning_model.h | 4 + Extension/Trainer/CHANGELOG.md | 15 + Extension/Trainer/README.md | 91 ++ Extension/Trainer/Test/tuning_dump.cpp | 53 + Extension/Trainer/trainer.cpp | 1250 ++++++++++++++++++++ Extension/Trainer/trainer.h | 101 ++ Extension/Trainer/trainer_commands.cpp | 75 ++ Extension/Trainer/trainer_page.cpp | 486 ++++++++ Extension/Trainer/trainer_page.h | 18 + Extension/Trainer/trainer_presets.cpp | 98 ++ Extension/Trainer/trainer_presets.h | 23 + Extension/Trainer/trainer_view.cpp | 39 + Extension/UI/Overlay/overlay_render.cpp | 5 +- Extension/UI/Overlay/skate_menu.cpp | 8 +- Extension/UI/Overlay/skate_menu_internal.h | 3 + Runtime/client_tick.cpp | 6 + cmake/Runtime.cmake | 3 + cmake/UI.cmake | 2 + 26 files changed, 2399 insertions(+), 10 deletions(-) create mode 100644 Extension/Trainer/CHANGELOG.md create mode 100644 Extension/Trainer/README.md create mode 100644 Extension/Trainer/Test/tuning_dump.cpp create mode 100644 Extension/Trainer/trainer.cpp create mode 100644 Extension/Trainer/trainer.h create mode 100644 Extension/Trainer/trainer_commands.cpp create mode 100644 Extension/Trainer/trainer_page.cpp create mode 100644 Extension/Trainer/trainer_page.h create mode 100644 Extension/Trainer/trainer_presets.cpp create mode 100644 Extension/Trainer/trainer_presets.h create mode 100644 Extension/Trainer/trainer_view.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index bc900352..a8f2cc3f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,6 +16,7 @@ include(cmake/Backtrace.cmake) option(DINGOSDK_BUILD_TESTS "Build logging regression tests" OFF) option(DINGOSDK_BUILD_PARK_EDITOR_TESTS "Build park editor regression tests" OFF) +option(DINGOSDK_BUILD_TRAINER_TESTS "Build the trainer's tools and regression tests" OFF) if(DINGOSDK_BUILD_PARK_EDITOR_TESTS) enable_testing() add_executable(dingosdk_park_editor_tests Extension/Objects/ParkEditor/Test/park_editor_tests.cpp) @@ -156,10 +157,17 @@ endif() # Header-only game contracts belong to the runtime's subsystem tree. file(GLOB_RECURSE game_headers CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/Engine/Game/*.h") target_sources(dingosdk_runtime PRIVATE ${game_headers}) +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}") +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() - diff --git a/Extension/Console/commands.cpp b/Extension/Console/commands.cpp index abbc2706..e79ae795 100644 --- a/Extension/Console/commands.cpp +++ b/Extension/Console/commands.cpp @@ -222,6 +222,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; diff --git a/Extension/Console/commands.h b/Extension/Console/commands.h index 153b4952..dde960be 100644 --- a/Extension/Console/commands.h +++ b/Extension/Console/commands.h @@ -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); diff --git a/Extension/Skater/no_bail.cpp b/Extension/Skater/no_bail.cpp index 4c6a27bb..828d1401 100644 --- a/Extension/Skater/no_bail.cpp +++ b/Extension/Skater/no_bail.cpp @@ -61,6 +61,12 @@ struct Protection { Lease lease; }; Protection& protection() { static auto* value = new Protection; return *value; } +struct StateWatch { + std::atomic selector{}; + std::atomic until{}, changes{}, wipeouts{}; + std::atomic state{}; +}; +StateWatch& state_watch() { static auto* value = new StateWatch; return *value; } // Recheck live local ownership at use time. A retained physics address alone // must never protect another skater after a respawn or level change. @@ -189,7 +195,15 @@ std::uint32_t choose_state(std::uintptr_t selector, std::uint32_t current) { const bool filtered = filter_requests(selector, &Owner::selector); const auto next = protection().choose_original(selector, current); LastError error; - return filtered && next == wipeout_physics_state && protected_owner(selector, &Owner::selector) ? current : next; + const auto chosen = filtered && next == wipeout_physics_state && protected_owner(selector, &Owner::selector) ? current : next; + auto& w = state_watch(); + if (selector == w.selector.load(std::memory_order_acquire) && GetTickCount64() < w.until.load(std::memory_order_acquire)) { + if (w.state.exchange(chosen, std::memory_order_acq_rel) != chosen) { + w.changes.fetch_add(1, std::memory_order_relaxed); + if (chosen == wipeout_physics_state) w.wipeouts.fetch_add(1, std::memory_order_relaxed); + } + } + return chosen; } void skeleton_response(std::uintptr_t rig, float seconds, bool wipeout) { // The state post-update can raise another request after the selector ran. @@ -372,6 +386,24 @@ void clear_no_bail() noexcept { p.lease = {}; ReleaseSRWLockExclusive(&p.lock); } +void watch_physics_state(std::uintptr_t client, std::uintptr_t entity) noexcept { + auto& w = state_watch(); + Owner owner; + if (!protection().ready.load(std::memory_order_acquire) || !resolve(client, entity, owner)) return; + if (w.selector.exchange(owner.selector, std::memory_order_acq_rel) != owner.selector) + w.state.store(0, std::memory_order_release); + w.until.store(GetTickCount64() + 500, std::memory_order_release); +} +PhysicsStateWatch watched_physics_state() noexcept { + auto& w = state_watch(); + PhysicsStateWatch result; + result.valid = protection().ready.load(std::memory_order_acquire) && w.selector.load(std::memory_order_acquire) && + GetTickCount64() < w.until.load(std::memory_order_acquire); + result.state = w.state.load(std::memory_order_acquire); + result.changes = w.changes.load(std::memory_order_relaxed); + result.wipeouts = w.wipeouts.load(std::memory_order_relaxed); + return result; +} void clear_no_bail_flight() noexcept { auto& p = protection(); AcquireSRWLockExclusive(&p.lock); diff --git a/Extension/Skater/no_bail.h b/Extension/Skater/no_bail.h index 5d437eb6..2297f3a7 100644 --- a/Extension/Skater/no_bail.h +++ b/Extension/Skater/no_bail.h @@ -14,6 +14,15 @@ void clear_no_bail() noexcept; // Releasing it also leaves the skater's teleport option on the board again, since a turn's // teleport may have set it off (skater component +0xc0) and the SDK's own teleports keep it. void update_board_lock(std::uintptr_t client, std::uintptr_t entity, bool locked) noexcept; +// The physics state the local skater's selector last chose, for the trainer (air time, bail +// markers). Publish the skater to watch from the client tick; it expires if ticks stop. +struct PhysicsStateWatch { + bool valid{}; + std::uint32_t state{}; + std::uint64_t changes{}, wipeouts{}; // counted since the process started +}; +void watch_physics_state(std::uintptr_t client, std::uintptr_t entity) noexcept; +PhysicsStateWatch watched_physics_state() noexcept; // Stopping flight must not discard the independent manual preference. void clear_no_bail_flight() noexcept; } diff --git a/Extension/Skater/physics_tuning.cpp b/Extension/Skater/physics_tuning.cpp index c05f57f7..27c3c627 100644 --- a/Extension/Skater/physics_tuning.cpp +++ b/Extension/Skater/physics_tuning.cpp @@ -298,4 +298,40 @@ void release(std::uintptr_t base) noexcept { } std::string status() { return state().status; } + +std::shared_ptr game_tuning(std::string *error) { return model(error); } + +bool refresh_skater(std::uintptr_t base, std::uintptr_t entity) noexcept { + if (!base || !contracts_match(base)) return false; + const auto block = skater_block(base, entity); + return block && refresh(base, block); +} + +bool read_live(std::uintptr_t base, Values &out, std::uintptr_t *asset_out) { + if (!base || !contracts_match(base)) return false; + const auto m = model(); + const auto asset = live_asset(base); + if (!m || !asset || !read_values(asset, *m, out)) return false; + if (asset_out) *asset_out = asset; + return true; +} + +LiveWrite write_live(std::uintptr_t base, std::uintptr_t entity, const Values &target) noexcept { + LiveWrite result; + try { + const auto m = model(); + const auto asset = base && contracts_match(base) ? live_asset(base) : 0; + Values live; + if (!m || !asset || target.image.size() != asset_size || !read_values(asset, *m, live)) { + result.failed = true; + return result; + } + const auto written = write_values(asset, *m, live, target); + result = {written.values, written.curves, written.mismatched, written.failed, false}; + if (!written.failed && (written.values || written.curves)) result.refreshed = refresh_skater(base, entity); + } catch (...) { + result.failed = true; + } + return result; +} } // namespace dingosdk::physics_tuning diff --git a/Extension/Skater/physics_tuning.h b/Extension/Skater/physics_tuning.h index db1f3856..3a9492f0 100644 --- a/Extension/Skater/physics_tuning.h +++ b/Extension/Skater/physics_tuning.h @@ -6,6 +6,7 @@ // with the game's. Client thread only. #include "physics_tuning_model.h" #include +#include #include #include #include @@ -25,4 +26,20 @@ void enforce(std::uintptr_t base, std::uintptr_t entity, std::span game_tuning(std::string *error = nullptr); +// The running game's values, and the asset they were read from (it changes at a level load). +bool read_live(std::uintptr_t base, Values &out, std::uintptr_t *asset = nullptr); +struct LiveWrite { + std::size_t values{}, curves{}, mismatched{}; + bool failed{}; + bool refreshed{}; // the local skater's cached block was found and refreshed +}; +// Refreshes the local skater's cached copy from the asset; false when the skater has none yet. +bool refresh_skater(std::uintptr_t base, std::uintptr_t entity) noexcept; +// Writes `target` wherever the running game differs (fields and same-sized curves only) and +// refreshes the local skater's cached copy when anything changed. +LiveWrite write_live(std::uintptr_t base, std::uintptr_t entity, const Values &target) noexcept; } // namespace dingosdk::physics_tuning diff --git a/Extension/Skater/physics_tuning_model.cpp b/Extension/Skater/physics_tuning_model.cpp index f0bafec8..b9f247d0 100644 --- a/Extension/Skater/physics_tuning_model.cpp +++ b/Extension/Skater/physics_tuning_model.cpp @@ -35,7 +35,9 @@ std::uint16_t value_size(ebx::FieldType type) noexcept { } } // Every plain value and curve pointer of `type` placed at `start`, nested structures included. -void walk(const ebx::Document &document, const ebx::TypeDescriptor &type, std::uint32_t start, Model &model, int depth) { +using Names = std::vector>; +void walk(const ebx::Document &document, const ebx::TypeDescriptor &type, std::uint32_t start, Model &model, int depth, + Names &names, const std::string &path) { if (depth > 16) throw std::runtime_error("the tuning's types nest too deeply"); for (std::size_t i = 0; i < type.fieldCount; ++i) { const auto index = static_cast(type.fieldIndex) + i; @@ -44,13 +46,16 @@ void walk(const ebx::Document &document, const ebx::TypeDescriptor &type, std::u const auto kind = field.type(); if (kind == ebx::FieldType::inherited || (kind == ebx::FieldType::structure && field.category() != ebx::FieldCategory::array)) { if (field.classRef >= document.types.size()) throw std::runtime_error("a tuning structure type is missing"); - walk(document, document.types[field.classRef], kind == ebx::FieldType::inherited ? start : start + field.dataOffset, - model, depth + 1); + const bool inherited = kind == ebx::FieldType::inherited; + walk(document, document.types[field.classRef], inherited ? start : start + field.dataOffset, model, depth + 1, + names, inherited || field.name.empty() ? path : path.empty() ? field.name : path + "." + field.name); continue; } if (field.category() == ebx::FieldCategory::array) continue; const auto offset = start + field.dataOffset; if (offset < asset_fields_start) continue; // the object header and the asset's name + if (!field.name.empty() && offset < asset_size) + names.emplace_back(static_cast(offset), path.empty() ? field.name : path + "." + field.name); if (kind == ebx::FieldType::pointer) { if (offset + 8 <= asset_size) model.curve_slots.push_back(static_cast(offset)); continue; @@ -176,7 +181,13 @@ Model read_game_tuning(const std::filesystem::path &game_root) { Model model; model.image.resize(asset_size); std::memcpy(model.image.data(), root->rawImage.data(), asset_size); - walk(document, document.types[static_cast(root->descriptor)], 0, model, 0); + Names names; + walk(document, document.types[static_cast(root->descriptor)], 0, model, 0, names, {}); + std::ranges::stable_sort(names, {}, &Names::value_type::first); + const auto name_at = [&](std::uint16_t offset) { + const auto found = std::ranges::lower_bound(names, offset, {}, &Names::value_type::first); + return found != names.end() && found->first == offset ? found->second : std::string{}; + }; std::ranges::sort(model.fields, {}, &Field::offset); for (std::size_t i = 1; i < model.fields.size(); ++i) if (model.fields[i].offset < model.fields[i - 1].offset + model.fields[i - 1].size) @@ -195,6 +206,8 @@ Model read_game_tuning(const std::filesystem::path &game_root) { } } model.curve_slots = std::move(curve_slots); + for (const auto &field : model.fields) model.field_names.push_back(name_at(field.offset)); + for (const auto slot : model.curve_slots) model.curve_names.push_back(name_at(slot)); return model; } diff --git a/Extension/Skater/physics_tuning_model.h b/Extension/Skater/physics_tuning_model.h index 7108a103..181a25cf 100644 --- a/Extension/Skater/physics_tuning_model.h +++ b/Extension/Skater/physics_tuning_model.h @@ -7,6 +7,7 @@ #include #include #include +#include #include namespace dingosdk::physics_tuning { @@ -28,6 +29,9 @@ struct Model { std::vector fields; // by offset, not overlapping std::vector curve_slots; // offsets of the curve pointers, ascending std::vector> curves; // per slot: the game's own curve + // What the game's data calls each field and curve ("Jump.MaxHeight"), in the order of + // `fields` and `curve_slots`; empty where the data carries no name. + std::vector field_names, curve_names; }; // One copy of the asset's values: the game's, a player's, or what a guest should have. struct Values { diff --git a/Extension/Trainer/CHANGELOG.md b/Extension/Trainer/CHANGELOG.md new file mode 100644 index 00000000..2d966ee6 --- /dev/null +++ b/Extension/Trainer/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## v0.1.0 - 2026-10-03 + +First release, built on ReSkate 1.0.3. + +- TRAINER page in the ReSkate menu: Tune, Presets, Practice, Map & HUD. +- Live editing of the game's physics tuning (3,801 values, 89 curves, 80 graphs), named from the + player's own game data; search, groups, an Essentials list, freeze, reset. +- Quick switches: super high ollie, fast flips (front and back flips), fast spins, never bail. +- Stackable presets, user presets, a preset per map. +- Game speed and pause, five marker slots per map, return after a bail, teleport. +- Speed / air HUD and a read-out after every jump; telemetry recording to CSV. +- `trainer.json` in a mod folder: a map author's spots and recommended preset. +- Every action is a `trainer ...` console command; `trainer selftest` checks a build in game. diff --git a/Extension/Trainer/README.md b/Extension/Trainer/README.md new file mode 100644 index 00000000..e2a55e7c --- /dev/null +++ b/Extension/Trainer/README.md @@ -0,0 +1,91 @@ +# ReSkate Trainer + +A TRAINER page in the ReSkate menu (Insert): live physics tuning, presets, practice markers and a +telemetry HUD. It ships no game data: the list of values is built at run time from the player's own +`Gameplay/SkatePhysicsTuning`. + +## What it does + +| Tab | What you get | +|---|---| +| **TUNE** | An Essentials list of 26 plainly named values (ollie height, top speed, flip and spin speed, grind lock-on, bail limits...), then every plain value of the game's physics tuning (about 760), plus one multiplier for each of its 169 curves and graphs. Search, filter by group, "only what I changed", freeze, reset. Changes apply while you skate. | +| **PRESETS** | Quick switches for super high ollie, fast flips, fast spins and never bail. Built-in presets that stack (Super Ollie, Fast Flips, Fast Spins, Mega Pop, Fast, Hard To Bail, Sticky Grinds, ...), your own saved presets, and a preset a map applies every time it loads. | +| **PRACTICE** | Game speed and pause, five marker slots per map (save / go / clear), return to the marker after a bail, teleport to coordinates, copy your position (game or Blender axes). | +| **MAP & HUD** | Speed and air-time HUD, a read-out after every jump (takeoff speed and angle, height, distance, drop, landing speed), telemetry recording to CSV, and whatever the map's author ships for the trainer. | + +Controller: hold **LB + RB**, then D-pad **up** saves the marker, **down** goes to it, **left / right** +pick the slot. + +Everything is also a console command (`~`): `trainer open [tune|presets|practice|map]`, `trainer status`, `trainer set `, +`trainer find `, `trainer preset apply|remove `, `trainer marker save|go|clear [slot]`, +`trainer tp `, `trainer where`, `trainer jumps`, `trainer dump`, `trainer selftest`. + +## For map makers: `trainer.json` + +Put a `trainer.json` in your mod folder (beside `manifest.json`). Stock ReSkate ignores it; with the +trainer, players get your spots and your recommended tuning on the MAP & HUD tab. + +```json +{ + "schema": 1, + "note": "Built for about 60 km/h off the first lip.", + "preset": { + "name": "Gravy Train", + "values": { "PhysicsPush.MaxPushableSpeed": 12.0 } + }, + "spots": [ + { "name": "Start deck", "position": [0.0, 790.0, 0.0] }, + { "name": "Jump 3", "position": [0.0, 700.0, 310.0] } + ] +} +``` + +`"level"` (a level asset) is optional: without it the file stands for every level the mod's +`reskate-levels.json` adds. Value ids are the ones `trainer dump` lists. + +## Multiplayer + +The trainer goes through ReSkate's own session rules instead of around them: + +- A guest whose host sets the physics tuning cannot edit it; the page says so. +- Teleports and markers follow the host's noclip / teleport permission. +- Game speed is ReSkate's `SimulationTime.TimeScale` setting, which ReSkate locks in a session. +- A host's tuning changes reach guests through ReSkate's existing host-tuning sync, and servers' + `score_check` / `enforce_tuning` see them like any other tuning mod. + +It unlocks no cosmetics or entitlements. + +## How it works + +- `physics_tuning_model` keeps the field names the game's EBX carries, so `trainer.cpp` can build its + table from `read_game_tuning()`. +- Edits go into a target copy of the asset; `physics_tuning::write_live()` writes the differences and + refreshes the local skater's cached block. Values are re-applied after a level load. +- The game thread owns all state. The menu only queues `trainer ...` console commands and reads two + snapshots (`trainer_view.cpp`). +- Jumps are measured from the skater's position each client tick: the game's own physics state says when the skater is in the air. + Wipeouts come from the same state, which the no-bail hook already sees. +- Settings, presets, markers: `%LOCALAPPDATA%\ReSkate\trainer\trainer.json`. + +## Checking a build + +``` +RESKATE_STARTUP_COMMANDS="load ;wait 25;trainer selftest" +ReSkateLauncher.exe --no-gui --no-update +``` + +then read the `trainer selftest:` lines in `logs\ReSkate.log`. `dingosdk_trainer_tuning_dump ` (CMake option `DINGOSDK_BUILD_TRAINER_TESTS`) lists the tuning values without the game. + +## Known limits + +- The jump read-out uses real time, so it reads low while game speed is not 1x. +- Masses and collision sizes (deck, trucks, wheels) only change on the next respawn. +- Curve and graph multipliers scale outputs only; a curve whose point count a mod changed keeps the + mod's points. +- Not every tuning value is used by the game. The quick switches were built from what the game was + seen to read during tricks: ollie height comes from the `PhysicsJump` height graphs (the + `PhysicsMode` jump heights are never read), body flips from `PhysicsReckoning.FlipScalar` and + `FlipMaxSpeed` (`PerfectBodyFlips` forces exactly one rotation and ignores them), body spins from + the `PhysicsBodyspin` graphs. +- Built for one game build (the one ReSkate 1.0.3 supports). A game update needs a new build. diff --git a/Extension/Trainer/Test/tuning_dump.cpp b/Extension/Trainer/Test/tuning_dump.cpp new file mode 100644 index 00000000..c1c6087c --- /dev/null +++ b/Extension/Trainer/Test/tuning_dump.cpp @@ -0,0 +1,53 @@ +// Lists every value of Gameplay/SkatePhysicsTuning the trainer can edit, as the game's own +// data names it. +// dingosdk_trainer_tuning_dump +#include "Extension/Skater/physics_tuning_model.h" +#include +#include + +int main(int argc, char **argv) { + if (argc < 2) { + std::cerr << "usage: dingosdk_trainer_tuning_dump \n"; + return 2; + } + try { + const auto model = dingosdk::physics_tuning::read_game_tuning(argv[1]); + std::size_t unnamed{}; + for (std::size_t i = 0; i < model.fields.size(); ++i) { + const auto &f = model.fields[i]; + const auto &name = model.field_names[i]; + if (name.empty()) ++unnamed; + std::cout << (name.empty() ? "?" : name) << '\t' << std::hex << "0x" << f.offset << std::dec << '\t'; + if (f.real) { + float value; + std::memcpy(&value, model.image.data() + f.offset, 4); + std::cout << "real\t" << value; + } else if (f.flag) { + std::cout << "flag\t" << static_cast(model.image[f.offset]); + } else { + std::int64_t value{}; + std::memcpy(&value, model.image.data() + f.offset, f.size); + std::cout << "int" << f.size * 8 << '\t' << value; + } + std::cout << '\n'; + } + for (std::size_t i = 0; i < model.curve_slots.size(); ++i) { + const auto &curve = model.curves[i]; + std::cout << (model.curve_names[i].empty() ? "?" : model.curve_names[i]) << '\t' << std::hex << "0x" << model.curve_slots[i] + << std::dec << "\tcurve\t" << (curve ? curve->points.size() / 0x1c : 0) << " points"; + if (curve) + for (std::size_t p = 0; p + 0x1c <= curve->points.size(); p += 0x1c) { + float x, y; + std::memcpy(&x, curve->points.data() + p + 0xc, 4); + std::memcpy(&y, curve->points.data() + p + 0x14, 4); + std::cout << " (" << x << ", " << y << ")"; + } + std::cout << '\n'; + } + std::cerr << model.fields.size() << " values (" << unnamed << " unnamed), " << model.curve_slots.size() << " curves\n"; + return unnamed ? 1 : 0; + } catch (const std::exception &error) { + std::cerr << "error: " << error.what() << '\n'; + return 1; + } +} diff --git a/Extension/Trainer/trainer.cpp b/Extension/Trainer/trainer.cpp new file mode 100644 index 00000000..258069a7 --- /dev/null +++ b/Extension/Trainer/trainer.cpp @@ -0,0 +1,1250 @@ +#include "trainer.h" +#include "trainer_presets.h" +#include "Engine/Core/Json/json.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/no_bail.h" +#include "Engine/Game/Build/20260929/physics_tuning.h" +#include "Engine/Game/Multiplayer/session_tools.h" +#include "Extension/Profile/local_profile_runtime.h" +#include "Extension/Skater/client_source_spawn_internal.h" +#include "Extension/Skater/no_bail.h" +#include "Extension/Skater/physics_tuning.h" +#include "Extension/UI/Overlay/overlay.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Game thread only. See trainer.h for how the menu reaches this. +namespace dingosdk::trainer { +namespace { +namespace tuning = dingosdk::physics_tuning; +using Vec3 = std::array; +constexpr float max_coordinate = 1.0e6f; +constexpr std::uint32_t air_states_first = 200, air_states_end = 300; +constexpr std::uint32_t xinput_up = 0x1, xinput_down = 0x2, xinput_left = 0x4, xinput_right = 0x8, + xinput_lb = 0x100, xinput_rb = 0x200; + +struct Entry { + std::string id, key, label, group; // key: lower-case id + Kind kind{}; + std::uint16_t offset{}, size{}; + std::size_t curve{}; + double value{}, stock{}; + bool touched{}, frozen{}; + bool detail{}; + std::vector members; // graph: its outputs (Y0.., Min_Y, Max_Y) +}; +struct MapData { + std::array markers{}; + std::string preset; +}; +struct MapFile { // Mods//trainer.json + std::string note, preset_name; + std::vector> preset; + std::vector spots; +}; +struct Saved { + double value{}; + bool frozen{}; +}; +struct SelfTest { + int step{}; // 0: idle + std::uint64_t until{}; + std::size_t entry{}; + double before{}, target{}; + Vec3 origin{}; + int failures{}; +}; +struct Motion { + bool valid{}; + Vec3 position{}, velocity{}; + std::int64_t stamp{}; + bool airborne{}; + Vec3 takeoff{}, takeoff_velocity{}; + std::int64_t takeoff_stamp{}; + float apex{}; +}; +struct State { + bool loaded{}; // the store was read + std::shared_ptr model; + std::vector entries; + std::map> index; + std::uintptr_t base{}, asset{}, entity{}; + tuning::Values own, want; + bool write_due{}, view_due{true}, save_due{}; + bool profile_due{}; // the loaded map's preset is still to be applied + bool refresh_due{}; // values were written while the skater had no cached copy to refresh + std::uint64_t next_hold{}, next_save{}, next_skater{}, revision{}; + std::string status = "Waiting for the game.", last; + bool ready{}; + std::map> saved; // by key + std::map>, std::less<>> user; // presets + std::vector active; // presets applied since Stock + std::map> maps; + std::string map; + MapFile map_file; + int slot{}; + bool auto_return{}, pad_shortcuts{true}, hud{}, hud_jump{true}, logging{}; + float return_delay{1.5f}; + std::uint64_t seen_wipeouts{}, return_at{}, open_serial{}; + int open_tab{}; + std::uint32_t pad_previous{}; + Motion motion; + Telemetry telemetry; + std::ofstream log; + std::int64_t log_start{}; + std::map states; // physics state -> ticks seen + SelfTest test; +}; +State &state() { + static auto *value = new State; + return *value; +} + +std::string lower(std::string_view text) { + std::string result(text); + std::ranges::transform(result, result.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return result; +} +// "MaxPushSpeed" -> "Max Push Speed" +std::string spaced(std::string_view text) { + std::string result; + for (std::size_t i = 0; i < text.size(); ++i) { + const auto c = static_cast(text[i]); + const bool upper = std::isupper(c) != 0; + if (i && upper) { + const auto before = static_cast(text[i - 1]); + const bool next_lower = i + 1 < text.size() && std::islower(static_cast(text[i + 1])); + if (std::islower(before) || std::isdigit(before) || (std::isupper(before) && next_lower)) result += ' '; + } + result += text[i] == '.' ? ' ' : text[i]; + if (text[i] == '.') result += "/ "; + } + return result; +} +std::optional number(std::string_view text) { + double value{}; + const auto *last = text.data() + text.size(); + const auto parsed = std::from_chars(text.data(), last, value); + if (parsed.ec != std::errc{} || parsed.ptr != last || !std::isfinite(value)) return std::nullopt; + return value; +} +void say(logging::Level level, const std::string &text) { logging::write(level, logging::Channel::skater, text); } + +// ---- storage --------------------------------------------------------------------------- +std::filesystem::path data_directory() { + PWSTR raw{}; + std::filesystem::path result; + if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &raw)) && raw) + result = std::filesystem::path(raw) / L"ReSkate" / L"trainer"; + if (raw) CoTaskMemFree(raw); + return result; +} +std::filesystem::path game_directory() { + std::vector exe(32768); + const auto length = GetModuleFileNameW(nullptr, exe.data(), static_cast(exe.size())); + if (!length || length >= exe.size()) return {}; + return std::filesystem::path(exe.data()).parent_path(); +} +std::optional read_json(const std::filesystem::path &path) { + try { + std::ifstream file(path, std::ios::binary); + if (!file) return std::nullopt; + const std::string text((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + if (text.empty() || text.size() > 1024 * 1024) return std::nullopt; + return Json::parse(text); + } catch (...) { + return std::nullopt; + } +} +std::optional read_position(const Json &value) { + if (!value.is_array() || value.size() != 3) return std::nullopt; + Vec3 result{}; + for (std::size_t i = 0; i < 3; ++i) { + if (!value.at(i).is_number()) return std::nullopt; + result[i] = value.at(i).get(); + if (!std::isfinite(result[i]) || std::abs(result[i]) > max_coordinate) return std::nullopt; + } + return result; +} +Json write_position(const Vec3 &p) { return Json::array({Json(p[0]), Json(p[1]), Json(p[2])}); } + +void load_store() { + auto &s = state(); + s.loaded = true; + const auto json = read_json(data_directory() / L"trainer.json"); + if (!json || !json->is_object()) return; + try { + if (json->contains("options") && json->at("options").is_object()) { + const auto &o = json->at("options"); + s.auto_return = o.value("auto_return", false); + s.pad_shortcuts = o.value("pad_shortcuts", true); + s.hud = o.value("hud", false); + s.hud_jump = o.value("hud_jump", true); + s.return_delay = std::clamp(o.value("return_delay", 1.5f), 0.0f, 10.0f); + s.slot = std::clamp(o.value("slot", 0), 0, static_cast(marker_slots) - 1); + } + if (json->contains("values") && json->at("values").is_object()) + for (const auto &[key, row] : json->at("values").items()) + if (row.is_object() && row.contains("value") && row.at("value").is_number()) + s.saved[lower(key)] = {row.at("value").get(), row.value("frozen", false)}; + if (json->contains("presets") && json->at("presets").is_object()) + for (const auto &[name, values] : json->at("presets").items()) { + if (!values.is_object() || name.empty() || name.size() > 48) continue; + auto &preset = s.user[name]; + for (const auto &[key, value] : values.items()) + if (value.is_number()) preset[lower(key)] = value.get(); + } + if (json->contains("active") && json->at("active").is_array()) + for (const auto &name : json->at("active")) + if (name.is_string()) s.active.push_back(name.string()); + if (json->contains("maps") && json->at("maps").is_object()) + for (const auto &[level, row] : json->at("maps").items()) { + if (!row.is_object()) continue; + auto &map = s.maps[lower(level)]; + map.preset = row.value("preset", ""); + if (row.contains("markers") && row.at("markers").is_array()) + for (std::size_t i = 0; i < marker_slots && i < row.at("markers").size(); ++i) + if (const auto position = read_position(row.at("markers").at(i))) map.markers[i] = {true, *position}; + } + } catch (...) { + say(logging::Level::warning, "Trainer: trainer.json is damaged; starting from what could be read."); + } +} +void save_store() { + auto &s = state(); + s.save_due = false; + try { + Json json = Json::object(); + json["schema"] = 1; + Json options = Json::object(); + options["auto_return"] = s.auto_return; + options["pad_shortcuts"] = s.pad_shortcuts; + options["hud"] = s.hud; + options["hud_jump"] = s.hud_jump; + options["return_delay"] = s.return_delay; + options["slot"] = s.slot; + json["options"] = std::move(options); + Json values = Json::object(); + // Values of fields this build's tuning does not have are kept as they were read. + for (const auto &[key, saved] : s.saved) { + Json row = Json::object(); + row["value"] = saved.value; + row["frozen"] = saved.frozen; + values[key] = std::move(row); + } + json["values"] = std::move(values); + Json presets = Json::object(); + for (const auto &[name, preset] : s.user) { + Json row = Json::object(); + for (const auto &[key, value] : preset) row[key] = value; + presets[name] = std::move(row); + } + json["presets"] = std::move(presets); + Json active = Json::array(); + for (const auto &name : s.active) active.push_back(name); + json["active"] = std::move(active); + Json maps = Json::object(); + for (const auto &[level, map] : s.maps) { + if (level.empty()) continue; + Json row = Json::object(); + Json markers = Json::array(); + for (const auto &marker : map.markers) markers.push_back(marker.set ? write_position(marker.position) : Json(nullptr)); + row["markers"] = std::move(markers); + row["preset"] = map.preset; + maps[level] = std::move(row); + } + json["maps"] = std::move(maps); + const auto directory = data_directory(); + if (directory.empty()) return; + std::error_code error; + std::filesystem::create_directories(directory, error); + const auto target = directory / L"trainer.json", temporary = directory / L"trainer.json.tmp"; + { + std::ofstream file(temporary, std::ios::binary | std::ios::trunc); + file << json.dump(2); + if (!file) return; + } + std::filesystem::rename(temporary, target, error); + if (error) { + std::filesystem::remove(target, error); + std::filesystem::rename(temporary, target, error); + } + } catch (...) { + say(logging::Level::warning, "Trainer: could not save trainer.json."); + } +} +void changed(bool save = true) { + auto &s = state(); + s.view_due = true; + if (save) { + s.save_due = true; + s.next_save = GetTickCount64() + 1500; + } +} + +// ---- values ---------------------------------------------------------------------------- +double read_field(const std::vector &image, const Entry &e) { + const auto *at = image.data() + e.offset; + if (e.kind == Kind::real) { + float value; + std::memcpy(&value, at, 4); + return value; + } + if (e.kind == Kind::flag) return *at ? 1 : 0; + switch (e.size) { + case 1: { std::int8_t v; std::memcpy(&v, at, 1); return v; } + case 2: { std::int16_t v; std::memcpy(&v, at, 2); return v; } + case 4: { std::int32_t v; std::memcpy(&v, at, 4); return v; } + default: { std::int64_t v; std::memcpy(&v, at, 8); return static_cast(v); } + } +} +void write_field(std::vector &image, const Entry &e, double value) { + auto *at = image.data() + e.offset; + if (e.kind == Kind::real) { + const auto v = static_cast(value); + std::memcpy(at, &v, 4); + } else if (e.kind == Kind::flag) { + *at = value != 0 ? 1 : 0; + } else { + switch (e.size) { + case 1: { const auto v = static_cast(std::clamp(value, -128.0, 127.0)); std::memcpy(at, &v, 1); break; } + case 2: { const auto v = static_cast(std::clamp(value, -32768.0, 32767.0)); std::memcpy(at, &v, 2); break; } + case 4: { const auto v = static_cast(std::clamp(value, -2147483648.0, 2147483647.0)); std::memcpy(at, &v, 4); break; } + default: { const auto v = static_cast(value); std::memcpy(at, &v, 8); break; } + } + } +} +// A curve's outputs times `factor`: a point is 0x1c bytes with its output at +0x14. +tuning::Curve scaled_curve(const tuning::Curve &source, double factor) { + auto result = source; + for (std::size_t p = 0; p + addr::physics_tuning::curve_point_size <= result.points.size(); p += addr::physics_tuning::curve_point_size) { + float y; + std::memcpy(&y, result.points.data() + p + 0x14, 4); + y = static_cast(y * factor); + if (std::isfinite(y)) std::memcpy(result.points.data() + p + 0x14, &y, 4); + } + return result; +} +double sane(const Entry &e, double value) { + if (!std::isfinite(value)) return e.stock; + if (e.kind == Kind::flag) return value != 0 ? 1 : 0; + if (e.kind == Kind::integer) return std::round(value); + if (e.kind == Kind::curve || e.kind == Kind::graph) return std::clamp(value, 0.0, 100.0); + return std::clamp(value, -1.0e6, 1.0e6); +} +void apply_entry(Entry &e) { + auto &s = state(); + if (e.kind == Kind::graph) { + // A point the player set by hand keeps their value. + for (const auto index : e.members) { + const auto &member = s.entries[index]; + if (!member.touched && static_cast(member.offset) + member.size <= s.want.image.size()) + write_field(s.want.image, member, e.touched ? member.stock * e.value : member.stock); + } + } else if (e.kind == Kind::curve) { + if (e.curve < s.own.curves.size() && s.own.curves[e.curve] && e.curve < s.want.curves.size()) + s.want.curves[e.curve] = e.touched ? scaled_curve(*s.own.curves[e.curve], e.value) : *s.own.curves[e.curve]; + } else if (static_cast(e.offset) + e.size <= s.want.image.size()) { + write_field(s.want.image, e, e.touched ? e.value : e.stock); + } + s.write_due = true; +} +void remember(const Entry &e) { + auto &s = state(); + if (e.touched || e.frozen) s.saved[e.key] = {e.value, e.frozen}; + else s.saved.erase(e.key); +} +void set_entry(Entry &e, double value) { + e.value = sane(e, value); + e.touched = e.value != e.stock; + apply_entry(e); + remember(e); +} +Entry *find_entry(std::string_view id) { + auto &s = state(); + const auto found = s.index.find(lower(id)); + return found == s.index.end() ? nullptr : &s.entries[found->second]; +} +void build_entries() { + auto &s = state(); + s.entries.clear(); + s.index.clear(); + const auto &m = *s.model; + const auto add = [&](std::string name, Kind kind, std::uint16_t offset, std::uint16_t size, std::size_t curve) { + if (name.empty()) name = std::format("Unnamed.0x{:x}", offset); + Entry e; + e.id = kind == Kind::curve || kind == Kind::graph ? name + " x" : name; + e.key = lower(e.id); + if (s.index.contains(e.key)) { // arrays of structures repeat names + e.id += std::format(" @0x{:x}", offset); + e.key = lower(e.id); + } + const auto dot = name.find('.'); + auto group = dot == std::string::npos ? std::string("General") : name.substr(0, dot); + for (const std::string_view prefix : {"SkatePhysics", "Physics"}) + if (group.size() > prefix.size() && group.starts_with(prefix)) { + group.erase(0, prefix.size()); + break; + } + e.group = spaced(group); + e.label = spaced(dot == std::string::npos ? name : name.substr(dot + 1)); + if (kind == Kind::curve || kind == Kind::graph) e.label += " (x)"; + e.kind = kind; + e.offset = offset; + e.size = size; + e.curve = curve; + s.index.emplace(e.key, s.entries.size()); + s.entries.push_back(std::move(e)); + }; + for (std::size_t i = 0; i < m.fields.size(); ++i) { + const auto &f = m.fields[i]; + add(i < m.field_names.size() ? m.field_names[i] : std::string{}, f.real ? Kind::real : f.flag ? Kind::flag : Kind::integer, + f.offset, f.size, 0); + } + for (std::size_t i = 0; i < m.curve_slots.size(); ++i) + add(i < m.curve_names.size() ? m.curve_names[i] : std::string{}, Kind::curve, m.curve_slots[i], 8, i); + // Graphs stored in the asset: sixteen X and Y values, a point count and bounds. Each + // becomes one multiplier row; its points stay editable but out of the way. + const auto plain = s.entries.size(); + for (std::size_t i = 0; i < plain; ++i) { + if (!s.entries[i].id.ends_with(".Y0") || s.entries[i].kind != Kind::real) continue; + const auto parent = s.entries[i].id.substr(0, s.entries[i].id.size() - 3); + const auto offset = s.entries[i].offset; + std::vector outputs; + for (std::size_t j = 0; j < plain; ++j) { + auto &other = s.entries[j]; + if (other.id.size() <= parent.size() + 1 || !other.id.starts_with(parent) || other.id[parent.size()] != '.') continue; + const std::string_view leaf(other.id.c_str() + parent.size() + 1); + if (leaf.find('.') != std::string_view::npos) continue; + other.detail = true; + const bool output = (leaf.starts_with("Y") && leaf.size() <= 3) || leaf == "Min_Y" || leaf == "Max_Y"; + if (output && other.kind == Kind::real) outputs.push_back(j); + } + add(parent, Kind::graph, offset, 0, 0); + s.entries.back().members = std::move(outputs); + } + say(logging::Level::info, std::format("Trainer: {} tuning values and {} curves are editable.", m.fields.size(), m.curve_slots.size())); +} +// A new copy of the asset (first sight, or a level load): its values are the stock ones. +void adopt(const tuning::Values &live) { + auto &s = state(); + s.own = live; + s.want = live; + for (auto &e : s.entries) { + e.stock = e.kind == Kind::curve || e.kind == Kind::graph ? 1.0 : read_field(s.own.image, e); + const auto found = s.saved.find(e.key); + if (found != s.saved.end()) { + e.frozen = found->second.frozen; + e.value = sane(e, found->second.value); + e.touched = e.value != e.stock; + } else if (!e.touched) { + e.value = e.stock; + } + if (e.touched) apply_entry(e); + } + s.write_due = true; + changed(false); +} +std::string apply_preset(std::string_view name); +bool editable(std::string *why = nullptr) { + // A guest's tuning is the host's (physics_tuning::enforce); writing over it would only + // fight the session, so the trainer stands down while that is in force. + if (multiplayer_session_active() && !tuning::status().empty()) { + if (why) *why = "This session's host sets the physics tuning."; + return false; + } + return true; +} +void hold(std::uint64_t now) { + auto &s = state(); + if (!s.base) return; + if (!s.model) { + tuning::prepare(); + std::string error; + s.model = tuning::game_tuning(&error); + if (!s.model) { + const auto next = error.empty() ? std::string("Reading the game's physics tuning...") : "Physics tuning is unavailable: " + error + "."; + if (next != s.status) { s.status = next; changed(false); } + return; + } + build_entries(); + } + if (!s.write_due && now < s.next_hold) return; + s.next_hold = now + 500; + tuning::Values live; + std::uintptr_t asset{}; + if (!tuning::read_live(s.base, live, &asset)) { + if (s.ready) { s.ready = false; s.status = "Waiting for a level."; changed(false); } + return; + } + if (asset != s.asset || !s.ready) { + s.asset = asset; + adopt(live); + s.ready = true; + s.status = "Ready."; + } + if (!editable()) return; + if (s.profile_due) { + s.profile_due = false; + if (const auto found = s.maps.find(s.map); found != s.maps.end() && !found->second.preset.empty()) + say(logging::Level::info, "Trainer: this map's preset - " + apply_preset(found->second.preset)); + } + const bool pending = s.write_due || std::ranges::any_of(s.entries, [](const Entry &e) { return e.touched; }); + s.write_due = false; + if (!pending) return; + const auto written = tuning::write_live(s.base, s.entity, s.want); + if (written.failed) { + s.status = "The physics tuning could not be written."; + changed(false); + } + // The skater skates from its own copy of part of the tuning: keep asking until it took it. + if (written.values || written.curves) { + s.refresh_due = !written.refreshed; + say(logging::Level::info, std::format("Trainer: wrote {} values and {} curves; skater copy refreshed: {}.", written.values, written.curves, + written.refreshed ? "yes" : "not yet")); + } else if (s.refresh_due && tuning::refresh_skater(s.base, s.entity)) { + s.refresh_due = false; + say(logging::Level::info, "Trainer: skater copy refreshed."); + } +} + +// ---- presets --------------------------------------------------------------------------- +bool matches(const std::string &key, std::string_view pattern) { + std::size_t start = 0; + while (start < pattern.size()) { + auto end = pattern.find(' ', start); + if (end == std::string_view::npos) end = pattern.size(); + const auto token = pattern.substr(start, end - start); + if (!token.empty()) { + const bool exclude = token.front() == '!'; + const auto word = exclude ? token.substr(1) : token; + if ((key.find(word) != std::string::npos) == exclude) return false; + } + start = end + 1; + } + return true; +} +std::string remove_preset(std::string_view name) { + auto &s = state(); + const auto wanted = lower(name); + std::size_t count{}; + std::string removed; + const auto put_back = [&](Entry &e) { + if (e.frozen || !e.touched) return; + set_entry(e, e.stock); + ++count; + }; + for (const auto &[user_name, values] : s.user) { + if (lower(user_name) != wanted) continue; + for (const auto &saved : values) + if (auto *e = find_entry(saved.first)) put_back(*e); + removed = user_name; + break; + } + if (removed.empty()) + for (const auto &preset : builtin_presets()) { + if (lower(preset.name) != wanted) continue; + for (const auto &rule : preset.rules) + for (auto &e : s.entries) { + const bool scale = e.kind == Kind::curve || e.kind == Kind::graph; + if (e.detail || scale != rule.curves || !matches(e.key, rule.pattern)) continue; + put_back(e); + } + removed = preset.name; + break; + } + if (removed.empty()) return "error: no preset is called \"" + std::string(name) + "\"."; + std::erase(s.active, removed); + changed(); + return std::format("{} off: {} values put back.", removed, count); +} +std::size_t reset_all() { + auto &s = state(); + std::size_t count{}; + for (auto &e : s.entries) { + if (e.frozen || !e.touched) continue; + set_entry(e, e.stock); + ++count; + } + s.active.clear(); + return count; +} +std::string apply_preset(std::string_view name) { + auto &s = state(); + const auto wanted = lower(name); + if (wanted == "stock") { + const auto count = reset_all(); + changed(); + return std::format("Stock: {} values put back (frozen values kept).", count); + } + std::size_t count{}; + std::string applied; + if (wanted == "map") { + if (s.map_file.preset.empty()) return "error: this map ships no trainer preset."; + for (const auto &[id, value] : s.map_file.preset) + if (auto *e = find_entry(id); e && !e->frozen) { set_entry(*e, value); ++count; } + applied = s.map_file.preset_name.empty() ? "Map preset" : s.map_file.preset_name; + } + if (applied.empty()) + for (const auto &[user_name, values] : s.user) { + if (lower(user_name) != wanted) continue; + for (const auto &[key, value] : values) + if (auto *e = find_entry(key); e && !e->frozen) { set_entry(*e, value); ++count; } + applied = user_name; + break; + } + if (applied.empty()) + for (const auto &preset : builtin_presets()) { + if (lower(preset.name) != wanted) continue; + for (const auto &rule : preset.rules) + for (auto &e : s.entries) { + const bool scale = e.kind == Kind::curve || e.kind == Kind::graph; + if (e.frozen || e.detail || scale != rule.curves || !matches(e.key, rule.pattern)) continue; + if (e.kind == Kind::flag && rule.multiply) continue; + set_entry(e, rule.multiply ? e.stock * rule.amount : rule.amount); + ++count; + } + applied = preset.name; + break; + } + if (applied.empty()) return "error: no preset is called \"" + std::string(name) + "\"."; + if (std::ranges::find(s.active, applied) == s.active.end()) s.active.push_back(applied); + changed(); + return std::format("{}: {} values set.", applied, count); +} + +// ---- maps ------------------------------------------------------------------------------ +MapFile read_map_file(const std::string &level) { + MapFile result; + const auto mods = game_directory() / L"Mods"; + std::error_code error; + for (std::filesystem::directory_iterator it(mods, error), end; it != end && !error; it.increment(error)) { + if (!it->is_directory(error)) continue; + const auto json = read_json(it->path() / L"trainer.json"); + if (!json || !json->is_object()) continue; + try { + // The file names its level, or it stands for every level its mod adds. + bool mine = false; + if (json->contains("level") && json->at("level").is_string()) mine = lower(json->at("level").string()) == level; + else if (const auto levels = read_json(it->path() / L"reskate-levels.json"); levels && levels->contains("levels")) + for (const auto &row : levels->at("levels")) + if (row.is_object() && lower(row.value("asset", "")) == level) mine = true; + if (!mine) continue; + result.note = json->value("note", ""); + if (json->contains("preset") && json->at("preset").is_object()) { + const auto &preset = json->at("preset"); + result.preset_name = preset.value("name", ""); + if (preset.contains("values") && preset.at("values").is_object()) + for (const auto &[id, value] : preset.at("values").items()) + if (value.is_number() && result.preset.size() < 512) result.preset.emplace_back(id, value.get()); + } + if (json->contains("spots") && json->at("spots").is_array()) + for (const auto &row : json->at("spots")) { + if (!row.is_object() || !row.contains("position") || result.spots.size() >= 64) continue; + if (const auto position = read_position(row.at("position"))) + result.spots.push_back({row.value("name", "Spot"), *position}); + } + return result; + } catch (...) {} + } + return result; +} +void enter_map(const std::string &level) { + auto &s = state(); + s.map = level; + s.map_file = level.empty() ? MapFile{} : read_map_file(level); + s.motion = {}; + s.telemetry.last = {}; + s.telemetry.best = {}; + s.telemetry.top_speed = 0; + s.return_at = 0; + s.profile_due = false; + if (!level.empty()) { + const auto found = s.maps.find(level); + s.profile_due = found != s.maps.end() && !found->second.preset.empty(); + if (!s.map_file.spots.empty() || !s.map_file.preset.empty()) + say(logging::Level::info, std::format("Trainer: the map ships {} spots and {} preset values.", s.map_file.spots.size(), + s.map_file.preset.size())); + } + changed(false); +} +MapData ¤t_map() { return state().maps[state().map]; } +bool teleport_allowed(std::string &why) { + if (multiplayer_session_active() && !session_noclip_allowed()) { + why = "The host has turned off teleporting in this session."; + return false; + } + return true; +} +std::string go_to(const Vec3 &position, const std::string &what) { + std::string why; + if (!teleport_allowed(why)) return "error: " + why; + if (!teleport_local_skater(position)) return "error: teleporting is unavailable right now."; + state().motion.valid = false; + return "Teleporting to " + what + "."; +} + +// ---- telemetry ------------------------------------------------------------------------- +std::int64_t clock_now() { + LARGE_INTEGER value; + QueryPerformanceCounter(&value); + return value.QuadPart; +} +double clock_seconds(std::int64_t ticks) { + static const double frequency = [] { + LARGE_INTEGER value; + QueryPerformanceFrequency(&value); + return static_cast(value.QuadPart); + }(); + return static_cast(ticks) / frequency; +} +float horizontal(const Vec3 &v) { return std::sqrt(v[0] * v[0] + v[2] * v[2]); } +void finish_jump(const Vec3 &landing, float landing_speed, std::int64_t landed_at) { + auto &s = state(); + auto &m = s.motion; + const auto air = static_cast(clock_seconds(landed_at - m.takeoff_stamp)); + m.airborne = false; + s.telemetry.airborne = false; + if (air < 0.25f) return; + Jump jump; + jump.serial = s.telemetry.last.serial + 1; + jump.air_time = air; + jump.takeoff = m.takeoff; + jump.landing = landing; + jump.takeoff_speed = std::sqrt(m.takeoff_velocity[0] * m.takeoff_velocity[0] + m.takeoff_velocity[1] * m.takeoff_velocity[1] + + m.takeoff_velocity[2] * m.takeoff_velocity[2]); + jump.takeoff_angle = std::atan2(m.takeoff_velocity[1], std::max(horizontal(m.takeoff_velocity), 0.01f)) * 180.0f / std::numbers::pi_v; + jump.height = m.apex - m.takeoff[1]; + jump.distance = horizontal({landing[0] - m.takeoff[0], 0, landing[2] - m.takeoff[2]}); + jump.drop = m.takeoff[1] - landing[1]; + jump.landing_speed = landing_speed; + s.telemetry.last = jump; + if (jump.distance > s.telemetry.best.distance) s.telemetry.best = jump; + say(logging::Level::info, + std::format("Trainer jump {}: takeoff {:.1f} km/h at {:.1f} deg, air {:.2f} s, height {:.2f} m, distance {:.2f} m, drop {:.2f} m, " + "landing {:.1f} km/h at ({:.1f}, {:.1f}, {:.1f}).", + jump.serial, jump.takeoff_speed * 3.6f, jump.takeoff_angle, jump.air_time, jump.height, jump.distance, jump.drop, + jump.landing_speed * 3.6f, landing[0], landing[1], landing[2])); +} +void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) { + auto &s = state(); + auto &t = s.telemetry; + auto &m = s.motion; + overlay::DebugModel skater; + std::array matrix{}; + bool found = false; + if (now >= s.next_skater) { + try { + matrix = client_source::detail::debug_skater(base, client, skater); + found = skater.skater_position_valid; + } catch (...) { + s.next_skater = now + 250; // no skater: menus, loading, a respawn + } + } + if (!found) { + if (t.skater) { t.skater = false; m.valid = false; s.entity = 0; } + return; + } + s.entity = skater.skater_identity; + watch_physics_state(client, s.entity); + const auto watch = watched_physics_state(); + const auto stamp = clock_now(); + const Vec3 position = skater.skater_position; + t.skater = true; + t.position = position; + // Rows: right, up, forward, position. + t.heading = std::atan2(matrix[8], matrix[10]) * 180.0f / std::numbers::pi_v; + if (t.heading < 0) t.heading += 360.0f; + t.physics_state = watch.valid ? watch.state : 0; + if (watch.valid) ++s.states[watch.state]; + const auto seconds = m.valid ? clock_seconds(stamp - m.stamp) : 0.0; + const Vec3 step{position[0] - m.position[0], position[1] - m.position[1], position[2] - m.position[2]}; + const bool jumped = std::sqrt(step[0] * step[0] + step[1] * step[1] + step[2] * step[2]) > 25.0f; + if (!m.valid || seconds <= 0.0005 || seconds > 0.25 || jumped) { + // First sample, a hitch, or a teleport: nothing can be measured across it. + m = {}; + m.valid = true; + m.position = position; + m.stamp = stamp; + t.airborne = false; + t.air_time = t.height = 0; + return; + } + const auto dt = static_cast(seconds); + const Vec3 raw{step[0] / dt, step[1] / dt, step[2] / dt}; + const Vec3 velocity{m.velocity[0] * 0.5f + raw[0] * 0.5f, m.velocity[1] * 0.5f + raw[1] * 0.5f, m.velocity[2] * 0.5f + raw[2] * 0.5f}; + // The skater's physics says when it is in the air: its states 200-299 are the pop and + // the flight (100s ride the ground, 300 is a wipeout, 500s are off the board). + const bool flying = watch.valid && watch.state >= air_states_first && watch.state < air_states_end; + if (flying && !m.airborne) { + m.airborne = true; + m.takeoff = m.position; + m.takeoff_velocity = velocity; + m.takeoff_stamp = m.stamp; + m.apex = std::max(m.position[1], position[1]); + } + if (m.airborne) { + m.apex = std::max(m.apex, position[1]); + t.air_time = static_cast(clock_seconds(stamp - m.takeoff_stamp)); + t.height = m.apex - m.takeoff[1]; + if (!flying) + finish_jump(position, std::sqrt(m.velocity[0] * m.velocity[0] + m.velocity[1] * m.velocity[1] + m.velocity[2] * m.velocity[2]), + stamp); + } + t.airborne = m.airborne; + t.speed = horizontal(velocity); + t.vertical = velocity[1]; + t.top_speed = std::max(t.top_speed, t.speed); + m.velocity = velocity; + m.position = position; + m.stamp = stamp; + if (s.logging && s.log) { + s.log << std::format("{:.4f},{:.3f},{:.3f},{:.3f},{:.3f},{:.3f},{:.1f},{},{}\n", clock_seconds(stamp - s.log_start), position[0], + position[1], position[2], t.speed, t.vertical, t.heading, t.airborne ? 1 : 0, t.physics_state); + } + // A bail: back to the selected marker once the skater has had time to fall. + if (watch.valid && watch.wipeouts != s.seen_wipeouts) { + if (s.seen_wipeouts && s.auto_return && current_map().markers[static_cast(s.slot)].set) + s.return_at = now + static_cast(s.return_delay * 1000.0f); + s.seen_wipeouts = watch.wipeouts; + } +} +void set_logging(bool on) { + auto &s = state(); + if (s.log.is_open()) s.log.close(); + s.logging = false; + if (!on) return; + const auto directory = data_directory() / L"telemetry"; + std::error_code error; + std::filesystem::create_directories(directory, error); + std::string name = s.map.empty() ? "unknown" : s.map.substr(s.map.find_last_of('/') + 1); + for (auto &c : name) + if (!std::isalnum(static_cast(c)) && c != '_' && c != '-') c = '_'; + SYSTEMTIME time; + GetLocalTime(&time); + const auto file = directory / std::format("{}-{:04}{:02}{:02}-{:02}{:02}{:02}.csv", name, time.wYear, time.wMonth, time.wDay, time.wHour, + time.wMinute, time.wSecond); + s.log.open(file, std::ios::binary | std::ios::trunc); + if (!s.log) return; + s.log << "seconds,x,y,z,speed_mps,vertical_mps,heading_deg,airborne,physics_state\n"; + s.log_start = clock_now(); + s.logging = true; +} +void shortcuts() { + auto &s = state(); + ControllerInput input; + DingoSDKOverlayReadControllerInput(&input); + const auto buttons = input.available ? input.buttons : 0; + const auto pressed = buttons & ~s.pad_previous; + s.pad_previous = buttons; + if (!s.pad_shortcuts || (buttons & (xinput_lb | xinput_rb)) != (xinput_lb | xinput_rb)) return; + std::string result; + if (pressed & xinput_up) result = command("marker", {"save"}); + else if (pressed & xinput_down) result = command("marker", {"go"}); + else if (pressed & xinput_left) result = command("slot", {std::to_string((s.slot + static_cast(marker_slots) - 1) % static_cast(marker_slots) + 1)}); + else if (pressed & xinput_right) result = command("slot", {std::to_string((s.slot + 1) % static_cast(marker_slots) + 1)}); + if (!result.empty()) overlay::notify(overlay::NoticeLevel::info, "TRAINER", result); +} + +// ---- self test ------------------------------------------------------------------------- +void test_line(bool ok, const std::string &text) { + auto &s = state(); + if (!ok) ++s.test.failures; + say(ok ? logging::Level::info : logging::Level::error, std::format("trainer selftest: {} {}", ok ? "OK" : "FAIL", text)); +} +void run_selftest(std::uint64_t now) { + auto &s = state(); + auto &t = s.test; + if (!t.step || now < t.until) return; + tuning::Values live; + switch (t.step) { + case 1: { + test_line(s.model != nullptr, std::format("game tuning read ({} values, {} curves)", s.model ? s.model->fields.size() : 0, + s.model ? s.model->curve_slots.size() : 0)); + std::size_t named{}; + if (s.model) named = static_cast(std::ranges::count_if(s.model->field_names, [](const std::string &n) { return !n.empty(); })); + test_line(s.model && named == s.model->fields.size(), std::format("field names ({} of {})", named, s.model ? s.model->fields.size() : 0)); + test_line(s.ready && s.asset, "running tuning asset found"); + test_line(s.telemetry.skater, "local skater found"); + // The first plain real that is not zero and that the player has not touched. + t.entry = s.entries.size(); + for (std::size_t i = 0; i < s.entries.size(); ++i) + if (s.entries[i].kind == Kind::real && !s.entries[i].detail && !s.entries[i].touched && std::abs(s.entries[i].stock) > 0.01) { + t.entry = i; + break; + } + if (!s.ready || t.entry == s.entries.size() || !editable()) { + test_line(false, "no value to write (tuning not ready or locked by a session)"); + t.step = 4; + break; + } + auto &e = s.entries[t.entry]; + t.before = e.stock; + t.target = e.stock * 1.01; + set_entry(e, t.target); + t.step = 2; + t.until = now + 1200; + break; + } + case 2: { + auto &e = s.entries[t.entry]; + const bool read = tuning::read_live(s.base, live); + test_line(read && std::abs(read_field(live.image, e) - static_cast(t.target)) < 1e-4 * std::max(1.0, std::abs(t.target)), + std::format("write and hold {} = {:.5g}", e.id, t.target)); + set_entry(e, t.before); + t.step = 3; + t.until = now + 1200; + break; + } + case 3: { + auto &e = s.entries[t.entry]; + const bool read = tuning::read_live(s.base, live); + test_line(read && read_field(live.image, e) == static_cast(t.before) && !e.touched, std::format("restore {} = {:.5g}", e.id, t.before)); + t.step = 4; + break; + } + case 4: { + if (!s.telemetry.skater) { + test_line(false, "teleport (no skater)"); + t.step = 6; + break; + } + t.origin = s.telemetry.position; + const Vec3 target{t.origin[0] + 3.0f, t.origin[1] + 1.0f, t.origin[2]}; + const auto result = go_to(target, "a test point"); + test_line(!result.starts_with("error"), "teleport request: " + result); + t.origin = target; + t.step = result.starts_with("error") ? 6 : 5; + t.until = now + 700; + break; + } + case 5: { + const auto &p = s.telemetry.position; + // The skater may already be rolling away from where it was put. + const float away = horizontal({p[0] - t.origin[0], 0, p[2] - t.origin[2]}); + test_line(s.telemetry.skater && away < 25.0f, std::format("teleport put the skater {:.2f} m from the target", away)); + t.step = 6; + t.until = now + 1500; + break; + } + default: + say(t.failures ? logging::Level::error : logging::Level::info, + std::format("trainer selftest: DONE {} failures", t.failures)); + t = {}; + changed(false); + break; + } +} + +void build_view() { + auto &s = state(); + s.view_due = false; + auto next = std::make_shared(); + next->revision = ++s.revision; + next->ready = s.ready; + next->status = s.status; + next->last = s.last; + next->editable = editable(&next->blocked); + next->rows.reserve(s.entries.size()); + for (const auto &e : s.entries) { + int rank{}; + const auto friendly = essential_name(e.key, &rank); + next->rows.push_back({e.id, e.label, e.group, e.kind, e.value, e.stock, e.touched, e.frozen, e.detail, std::string(friendly), rank}); + if (e.touched) ++next->touched; + if (std::ranges::find(next->groups, e.group) == next->groups.end()) next->groups.push_back(e.group); + } + const auto is_active = [&](std::string_view name) { return std::ranges::find(s.active, name) != s.active.end(); }; + next->presets.push_back({"Stock", "Everything back to how this map loaded (frozen values stay).", true, s.active.empty() && !next->touched}); + for (const auto &preset : builtin_presets()) + next->presets.push_back({std::string(preset.name), std::string(preset.note), true, is_active(preset.name)}); + for (const auto &[name, values] : s.user) next->presets.push_back({name, std::format("{} values", values.size()), false, is_active(name)}); + next->slot = s.slot; + next->auto_return = s.auto_return; + next->return_delay = s.return_delay; + next->pad_shortcuts = s.pad_shortcuts; + next->hud = s.hud; + next->hud_jump = s.hud_jump; + next->logging = s.logging; + next->map = s.map; + next->map_note = s.map_file.note; + next->map_preset = s.map_file.preset.empty() ? std::string{} : s.map_file.preset_name.empty() ? "Map preset" : s.map_file.preset_name; + next->spots = s.map_file.spots; + next->open_serial = s.open_serial; + next->open_tab = s.open_tab; + if (const auto found = s.maps.find(s.map); found != s.maps.end()) { + next->markers = found->second.markers; + next->profile_preset = found->second.preset; + } + publish(std::move(next)); +} +bool flag(const std::string &text, bool &out) { + const auto value = lower(text); + if (value == "1" || value == "on" || value == "true") { out = true; return true; } + if (value == "0" || value == "off" || value == "false") { out = false; return true; } + return false; +} +std::optional slot_argument(const std::vector &arguments, std::size_t index) { + auto &s = state(); + if (arguments.size() <= index) return s.slot; + const auto value = number(arguments[index]); + if (!value || *value < 1 || *value > static_cast(marker_slots) || *value != std::floor(*value)) return std::nullopt; + return static_cast(*value) - 1; +} +} // namespace + +void tick(std::uintptr_t base, std::uintptr_t client, bool playing, const std::string &level) noexcept { + try { + auto &s = state(); + const auto now = GetTickCount64(); + if (!s.loaded) load_store(); + s.base = base; + if (const auto key = lower(playing ? level : std::string{}); key != s.map) enter_map(key); + if (playing) { + observe(base, client, now); + shortcuts(); + if (s.return_at && now >= s.return_at) { + s.return_at = 0; + const auto &marker = current_map().markers[static_cast(s.slot)]; + if (marker.set) (void)go_to(marker.position, "your marker"); + } + } else if (s.telemetry.skater) { + s.telemetry.skater = false; + s.motion.valid = false; + s.entity = 0; + } + hold(now); + run_selftest(now); + publish(s.telemetry); + if (s.view_due) build_view(); + if (s.save_due && now >= s.next_save) save_store(); + } catch (...) {} +} + +namespace { +std::string run(std::string_view verb, const std::vector &a) { + auto &s = state(); + const auto v = lower(verb); + const auto arg = [&](std::size_t i) { return i < a.size() ? a[i] : std::string{}; }; + if (v == "status") { + std::size_t touched{}, frozen{}; + for (const auto &e : s.entries) { touched += e.touched; frozen += e.frozen; } + return std::format("{} {} values ({} changed, {} frozen). Map: {}. Presets: {}.", s.status, s.entries.size(), touched, frozen, + s.map.empty() ? "none" : s.map, s.active.empty() ? "stock" : std::to_string(s.active.size()) + " applied"); + } + if (v == "set" || v == "freeze" || v == "reset") { + if (v == "reset" && lower(arg(0)) == "all") { + const auto count = reset_all(); + changed(); + return std::format("{} values put back.", count); + } + auto *e = find_entry(arg(0)); + if (!e) return "error: no tuning value is called \"" + arg(0) + "\". Try: trainer find "; + std::string why; + if (v != "freeze" && !editable(&why)) return "error: " + why; + if (v == "set") { + const auto value = number(arg(1)); + if (!value) return "error: " + e->id + " needs a number."; + set_entry(*e, *value); + } else if (v == "freeze") { + if (!flag(arg(1), e->frozen)) return "error: freeze needs 0 or 1."; + remember(*e); + } else { + set_entry(*e, e->stock); + } + changed(); + return std::format("{} = {:.6g}{}{}", e->id, e->value, e->touched ? std::format(" (stock {:.6g})", e->stock) : "", e->frozen ? ", frozen" : ""); + } + if (v == "find") { + const auto pattern = lower(arg(0)); + std::string result; + std::size_t count{}; + for (const auto &e : s.entries) + if (matches(e.key, pattern) && ++count <= 12) result += std::format("{}{} = {:.6g}", result.empty() ? "" : "; ", e.id, e.value); + return count ? std::format("{} match: {}{}", count, result, count > 12 ? "; ..." : "") : "Nothing matches."; + } + if (v == "preset") { + const auto action = lower(arg(0)); + std::string name; + for (std::size_t i = 1; i < a.size(); ++i) name += (i > 1 ? " " : "") + a[i]; + if (name.empty()) return "error: usage: trainer preset apply|remove|save|delete "; + if (action == "remove") { + std::string why; + if (!editable(&why)) return "error: " + why; + return remove_preset(name); + } + if (action == "apply") { + std::string why; + if (!editable(&why)) return "error: " + why; + return apply_preset(name); + } + if (action == "save") { + if (name.size() > 48) return "error: that name is too long."; + for (const auto &preset : builtin_presets()) + if (lower(preset.name) == lower(name)) return "error: that name belongs to a built-in preset."; + if (lower(name) == "stock" || lower(name) == "map") return "error: that name is reserved."; + std::map> values; + for (const auto &e : s.entries) + if (e.touched) values[e.key] = e.value; + if (values.empty()) return "error: nothing is changed, so there is nothing to save."; + const auto count = values.size(); + s.user[name] = std::move(values); + changed(); + return std::format("Saved \"{}\" ({} values).", name, count); + } + if (action == "delete") { + if (!s.user.erase(name)) return "error: no preset of yours is called \"" + name + "\"."; + std::erase(s.active, name); + changed(); + return "Deleted \"" + name + "\"."; + } + return "error: usage: trainer preset apply|save|delete "; + } + if (v == "slot") { + const auto slot = slot_argument(a, 0); + if (!slot) return std::format("error: slots are 1 to {}.", marker_slots); + s.slot = *slot; + changed(); + return std::format("Marker slot {}{}.", s.slot + 1, current_map().markers[static_cast(s.slot)].set ? "" : " (empty)"); + } + if (v == "marker") { + const auto action = lower(arg(0)); + const auto slot = slot_argument(a, 1); + if (!slot) return std::format("error: slots are 1 to {}.", marker_slots); + if (s.map.empty()) return "error: load a level first."; + auto &marker = current_map().markers[static_cast(*slot)]; + if (action == "save") { + if (!s.telemetry.skater) return "error: no skater to mark."; + marker = {true, s.telemetry.position}; + changed(); + return std::format("Marker {} saved at ({:.1f}, {:.1f}, {:.1f}).", *slot + 1, marker.position[0], marker.position[1], marker.position[2]); + } + if (action == "go") { + if (!marker.set) return std::format("error: marker {} is empty.", *slot + 1); + return go_to(marker.position, std::format("marker {}", *slot + 1)); + } + if (action == "clear") { + marker = {}; + changed(); + return std::format("Marker {} cleared.", *slot + 1); + } + return "error: usage: trainer marker save|go|clear [slot]"; + } + if (v == "tp") { + const auto x = number(arg(0)), y = number(arg(1)), z = number(arg(2)); + if (!x || !y || !z) return "error: usage: trainer tp "; + return go_to({static_cast(*x), static_cast(*y), static_cast(*z)}, "that point"); + } + if (v == "spot") { + const auto index = number(arg(0)); + if (!index || *index < 1 || *index > static_cast(s.map_file.spots.size())) return "error: this map has no such spot."; + const auto &spot = s.map_file.spots[static_cast(*index) - 1]; + return go_to(spot.position, spot.name); + } + if (v == "option") { + const auto name = lower(arg(0)); + bool on{}; + if (name == "return_delay") { + const auto value = number(arg(1)); + if (!value) return "error: return_delay needs seconds."; + s.return_delay = std::clamp(static_cast(*value), 0.0f, 10.0f); + } else if (!flag(arg(1), on)) { + return "error: usage: trainer option hud|hud_jump|auto_return|pad|log 0|1"; + } else if (name == "hud") s.hud = on; + else if (name == "hud_jump") s.hud_jump = on; + else if (name == "auto_return") s.auto_return = on; + else if (name == "pad") s.pad_shortcuts = on; + else if (name == "log") { + set_logging(on); + if (on && !s.logging) return "error: the telemetry file could not be created."; + } else return "error: no option is called \"" + name + "\"."; + changed(); + return name + " = " + arg(1); + } + if (v == "profile") { + const auto action = lower(arg(0)); + if (s.map.empty()) return "error: load a level first."; + if (action == "clear") { + current_map().preset.clear(); + changed(); + return "This map no longer applies a preset on load."; + } + std::string name; + for (std::size_t i = 1; i < a.size(); ++i) name += (i > 1 ? " " : "") + a[i]; + if (action != "set" || name.empty()) return "error: usage: trainer profile set | clear"; + current_map().preset = name; + changed(); + return "\"" + name + "\" will be applied whenever this map loads."; + } + if (v == "open") { + const auto tab = lower(arg(0)); + const std::array tabs{"tune", "presets", "practice", "map"}; + const auto found = std::ranges::find(tabs, tab); + if (!tab.empty() && found == tabs.end()) return "error: usage: trainer open [tune|presets|practice|map]"; + s.open_tab = tab.empty() ? 1 : static_cast(found - tabs.begin()); + ++s.open_serial; + s.view_due = true; + return "Opening the trainer."; + } + if (v == "jumps") { + const auto &j = s.telemetry.last; + if (!j.serial) return "No jump measured yet."; + return std::format("Jump {}: takeoff {:.1f} km/h at {:.1f} deg, air {:.2f} s, height {:.2f} m, distance {:.2f} m, drop {:.2f} m, landing {:.1f} km/h.", + j.serial, j.takeoff_speed * 3.6f, j.takeoff_angle, j.air_time, j.height, j.distance, j.drop, j.landing_speed * 3.6f); + } + if (v == "where") { + if (!s.telemetry.skater) return "error: no skater."; + const auto &p = s.telemetry.position; + return std::format("({:.2f}, {:.2f}, {:.2f}) heading {:.0f}, {:.1f} km/h. Blender: ({:.2f}, {:.2f}, {:.2f})", p[0], p[1], p[2], + s.telemetry.heading, s.telemetry.speed * 3.6f, p[0], -p[2], p[1]); + } + if (v == "refresh") return tuning::refresh_skater(s.base, s.entity) ? "Skater copy refreshed." : "error: the skater has no cached copy right now."; + if (v == "states") { + std::string result; + for (const auto &[id, ticks] : s.states) result += std::format("{}{}: {}", result.empty() ? "" : ", ", id, ticks); + return result.empty() ? "No physics state seen yet." : "Physics states seen (id: ticks): " + result; + } + if (v == "dump") { + const auto directory = data_directory(); + std::error_code error; + std::filesystem::create_directories(directory, error); + std::ofstream file(directory / L"tuning-values.txt", std::ios::binary | std::ios::trunc); + if (!file) return "error: could not write the list."; + for (const auto &e : s.entries) + file << std::format("{}\t0x{:x}\t{}\t{:.6g}\t{:.6g}\n", e.id, e.offset, + e.kind == Kind::real ? "real" : e.kind == Kind::flag ? "flag" : e.kind == Kind::curve ? "curve" : + e.kind == Kind::graph ? "graph" : "int", e.stock, e.value); + return std::format("Wrote {} values to {}.", s.entries.size(), (directory / L"tuning-values.txt").string()); + } + if (v == "selftest") { + if (s.test.step) return "The self test is already running."; + s.test = {}; + s.test.step = 1; + s.test.until = GetTickCount64() + 500; + return "Self test started; see the log for \"trainer selftest:\" lines."; + } + return "error: trainer status|open|set|freeze|reset|find|preset|slot|marker|tp|spot|option|profile|jumps|where|states|dump|selftest"; +} +} // namespace + +std::string command(std::string_view verb, const std::vector &arguments) { + auto result = run(verb, arguments); + // The menu shows the last answer, except for the ones a dragged slider sends every frame. + if (verb != "set" || result.starts_with("error")) { + auto &s = state(); + s.last = result; + s.view_due = true; + } + return result; +} +} // namespace dingosdk::trainer diff --git a/Extension/Trainer/trainer.h b/Extension/Trainer/trainer.h new file mode 100644 index 00000000..d78192f9 --- /dev/null +++ b/Extension/Trainer/trainer.h @@ -0,0 +1,101 @@ +#pragma once +// The ReSkate Trainer: live physics tuning, presets, practice markers and a telemetry HUD. +// +// The game thread owns the trainer's state (trainer.cpp). Every change is a `trainer ...` +// console command, so the menu page (presentation thread) only queues commands and reads +// the two snapshots below; nothing here touches the game from the UI. +#include +#include +#include +#include +#include +#include + +namespace dingosdk::trainer { +// curve: a FloatCurve the asset points at; graph: a table of points stored in the asset +// itself (X0..X15, Y0..Y15). Both are edited as one multiplier on their outputs. +enum class Kind : std::uint8_t { real, integer, flag, curve, graph }; + +// One editable value of Gameplay/SkatePhysicsTuning. `id` is the name the game's data gives +// it ("PhysicsMode.JumpMaxHeight"). +struct Row { + std::string id, label, group; + Kind kind{}; + double value{}, stock{}; + bool touched{}, frozen{}; + bool detail{}; // a single point or bound of a graph: hidden unless asked for + std::string friendly; // a plain name, for the handful of values on the Essentials list + int rank{}; // its place on that list, from 1; 0: not on it +}; +struct PresetRow { + std::string name, note; + bool builtin{}, active{}; +}; +struct Marker { + bool set{}; + std::array position{}; +}; +struct Spot { + std::string name; + std::array position{}; +}; +inline constexpr std::size_t marker_slots = 5; + +// Changes rarely: rebuilt when a command or a level load changes something. +struct View { + std::uint64_t revision{}; + bool ready{}; // the game's tuning is read and the running game's copy was found + std::string status; // why not + std::string last; // what the last command answered + bool editable{}; // false while a session's host tuning is enforced + std::string blocked; // the reason shown on locked rows + std::vector rows; + std::vector groups; + std::vector presets; + std::size_t touched{}; + // Practice + int slot{}; + std::array markers{}; + bool auto_return{}; + float return_delay{1.5f}; + bool pad_shortcuts{true}; + // HUD + bool hud{}, hud_jump{true}, logging{}; + // The loaded map and what its author ships for the trainer (Mods//trainer.json). + std::string map, map_note, map_preset, profile_preset; + std::vector spots; + // `trainer open `: each new serial opens the menu on the trainer page at that tab. + std::uint64_t open_serial{}; + int open_tab{}; +}; + +struct Jump { + std::uint64_t serial{}; // 0: none yet + float takeoff_speed{}, takeoff_angle{}, air_time{}, height{}, distance{}, drop{}, landing_speed{}; + std::array takeoff{}, landing{}; +}; +// Changes every client tick. +struct Telemetry { + bool skater{}; + std::array position{}; + float heading{}; // degrees, 0 = +Z, clockwise seen from above + float speed{}, vertical{}; // metres per second: over the ground, and upward + bool airborne{}; + float air_time{}, height{}; // of the jump in progress + std::uint32_t physics_state{}; + Jump last, best; + float top_speed{}; +}; + +// Thread-safe snapshots (trainer_view.cpp, part of the overlay). +std::shared_ptr view() noexcept; +Telemetry telemetry() noexcept; +void publish(std::shared_ptr) noexcept; +void publish(const Telemetry &) noexcept; + +// Game thread (trainer.cpp). +// Each client tick; `playing` while a local skater can exist, `level` the loaded level asset. +void tick(std::uintptr_t base, std::uintptr_t client, bool playing, const std::string &level) noexcept; +// Runs one `trainer` command and returns the line to print. Only from the console dispatcher. +std::string command(std::string_view verb, const std::vector &arguments); +} // namespace dingosdk::trainer diff --git a/Extension/Trainer/trainer_commands.cpp b/Extension/Trainer/trainer_commands.cpp new file mode 100644 index 00000000..ab6cc000 --- /dev/null +++ b/Extension/Trainer/trainer_commands.cpp @@ -0,0 +1,75 @@ +#include "trainer.h" +#include "Extension/Console/commands.h" + +// The `trainer` console commands: the only way anything reaches the trainer, the menu included. +namespace dingosdk::console { +void register_trainer_commands(Commands ®istry) { + struct Verb { + const char *name; + const char *description; + const char *usage; + bool quiet; // sent on every slider movement: not echoed into the console + }; + const Verb verbs[]{ + {"status", "What the trainer is doing", nullptr, false}, + {"open", "Open the menu on the trainer page", "[tune|presets|practice|map]", false}, + {"set", "Set a physics tuning value live", " ", true}, + {"freeze", "Keep a value through presets and resets", " 0|1", true}, + {"reset", "Put a value (or all) back to stock", "|all", false}, + {"find", "List tuning values whose id contains the words", "", false}, + {"preset", "Apply, remove, save or delete a preset", "apply|remove|save|delete ", false}, + {"slot", "Select a marker slot", "<1-5>", false}, + {"marker", "Save, go to or clear a marker", "save|go|clear [slot]", false}, + {"tp", "Teleport the skater", " ", false}, + {"spot", "Go to one of the map author's spots", "", false}, + {"option", "Trainer options", "hud|hud_jump|auto_return|pad|log 0|1, return_delay ", true}, + {"profile", "The preset this map applies on load", "set |clear", false}, + {"jumps", "The last measured jump", nullptr, false}, + {"where", "The skater's position, heading and speed", nullptr, false}, + {"states", "Physics states the skater has been in (diagnostic)", nullptr, false}, + {"refresh", "Refresh the skater's cached copy of the tuning (diagnostic)", nullptr, false}, + {"dump", "Write every tuning value to a text file", nullptr, false}, + {"selftest", "Check the trainer against the running game", nullptr, false}, + }; + for (const auto &verb : verbs) { + std::vector arguments; + if (verb.usage) { + auto rest = argument(verb.usage, Type::text, true); + rest.rest = true; + arguments.push_back(std::move(rest)); + } + auto entry = action(std::string("trainer ") + verb.name, verb.description, Group::gameplay, std::move(arguments)); + entry.echo_input = !verb.quiet; + entry.run = [name = std::string(verb.name)](const Model &, const Values &values, const Output &out) { + std::vector words; + if (!values.empty()) + if (const auto *text = std::get_if(&values[0])) { + // Split on spaces; a preset name keeps its spaces (the trainer joins the rest). + std::size_t start = 0; + while (start < text->size()) { + auto end = text->find(' ', start); + if (end == std::string::npos) end = text->size(); + if (end > start) words.push_back(text->substr(start, end - start)); + start = end + 1; + } + } + // Value ids contain spaces ("Push.Speed x"): the last word is the number, the rest the id. + if ((name == "set" || name == "freeze") && words.size() > 2) { + std::string id; + for (std::size_t i = 0; i + 1 < words.size(); ++i) id += (i ? " " : "") + words[i]; + words = {id, words.back()}; + } else if (name == "reset" && words.size() > 1) { + std::string id; + for (std::size_t i = 0; i < words.size(); ++i) id += (i ? " " : "") + words[i]; + words = {id}; + } else if (name == "find" && words.size() > 1) { + std::string all; + for (std::size_t i = 0; i < words.size(); ++i) all += (i ? " " : "") + words[i]; + words = {all}; + } + out(trainer::command(name, words)); + }; + registry.add(std::move(entry)); + } +} +} // namespace dingosdk::console diff --git a/Extension/Trainer/trainer_page.cpp b/Extension/Trainer/trainer_page.cpp new file mode 100644 index 00000000..672b0670 --- /dev/null +++ b/Extension/Trainer/trainer_page.cpp @@ -0,0 +1,486 @@ +#include "trainer_page.h" +#include "trainer.h" +#include "Extension/UI/Overlay/skate_menu_internal.h" +#include "Extension/UI/skate_theme.h" +#include +#include +#include +#include +#include +#include + +// The TRAINER page and the skating HUD. Presentation thread: reads the trainer's snapshots +// and queues `trainer ...` console commands; it never touches the game. +namespace dingosdk::overlay::menu { +namespace { +struct Page { + int tab{1}; // opens on PRESETS: the quick switches + std::array search{}; + int group{}; // 0: the Essentials list, 1: every group, 2..: one of the view's groups + bool only_changed{}, graph_points{}; + // The rows the filters leave, rebuilt when the snapshot or a filter changes. + std::vector shown; + std::uint64_t shown_revision{}; + std::string shown_key; + // The value under the pointer while it is dragged: the snapshot lags a frame or two. + std::string active; + double active_value{}; + std::array preset_name{}; + std::array teleport{}; + double speed_edit{-1}, speed_until{}; + std::uint64_t open_serial{}; // the last `trainer open` acted on + bool show_page{}; +}; +Page &page() { + static auto *value = new Page; + return *value; +} +std::string lower(std::string_view text) { + std::string result(text); + std::ranges::transform(result, result.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return result; +} +bool contains_words(const std::string &haystack, const std::string &words) { + std::size_t start = 0; + while (start < words.size()) { + auto end = words.find(' ', start); + if (end == std::string::npos) end = words.size(); + if (end > start && haystack.find(words.substr(start, end - start)) == std::string::npos) return false; + start = end + 1; + } + return true; +} +std::string number(double value) { return std::format("{:.6g}", value); } +void trainer_command(SkateMenu &menu, const CallbacksV3 &callbacks, const std::string &text) { + send_console(menu, callbacks, "trainer " + text); +} + +void filter_rows(Page &p, const trainer::View &view) { + const auto words = lower(p.search.data()); + const auto key = std::format("{}|{}|{}|{}", words, p.group, p.only_changed, p.graph_points); + if (p.shown_revision == view.revision && p.shown_key == key) return; + p.shown_revision = view.revision; + p.shown_key = key; + p.shown.clear(); + const std::string *group = p.group > 1 && static_cast(p.group) <= view.groups.size() + 1 + ? &view.groups[static_cast(p.group) - 2] : nullptr; + const bool essentials = p.group == 0 && words.empty() && !p.only_changed; + for (std::size_t i = 0; i < view.rows.size(); ++i) { + const auto &row = view.rows[i]; + if (essentials) { + if (!row.friendly.empty()) p.shown.push_back(i); + continue; + } + if (row.detail && !p.graph_points && !row.touched) continue; + if (p.only_changed && !row.touched && !row.frozen) continue; + // Searching looks through every group. + if (words.empty() ? (group && row.group != *group) : !contains_words(lower(row.id + " " + row.label + " " + row.friendly), words)) + continue; + p.shown.push_back(i); + } + if (essentials) std::ranges::sort(p.shown, {}, [&](std::size_t i) { return view.rows[i].rank; }); +} + +void value_row(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::View &view, const trainer::Row &row, + bool with_group, bool friendly) { + ImGui::PushID(row.id.c_str()); + bool frozen = row.frozen; + if (ImGui::Checkbox("##freeze", &frozen)) trainer_command(menu, callbacks, std::format("freeze {} {}", row.id, frozen ? 1 : 0)); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Freeze: presets and Reset all leave this value alone."); + ImGui::SameLine(); + const float column = ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x * 0.52f; + const auto label = friendly && !row.friendly.empty() ? row.friendly : with_group ? row.group + " / " + row.label : row.label; + if (row.touched) ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::blue); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label.c_str()); + if (row.touched) ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s\nStock: %s", row.id.c_str(), number(row.stock).c_str()); + ImGui::SameLine(column); + const float reset = ImGui::CalcTextSize("Reset").x + ImGui::GetStyle().FramePadding.x * 2; + ImGui::BeginDisabled(!view.editable || !callbacks.queue_console_command); + double value = p.active == row.id ? p.active_value : row.value; + bool edited = false; + if (row.kind == trainer::Kind::flag) { + bool on = value != 0; + if (ImGui::Checkbox("##value", &on)) { + value = on ? 1 : 0; + edited = true; + } + } else { + ImGui::SetNextItemWidth(std::max(px(90), ImGui::GetContentRegionAvail().x - reset - ImGui::GetStyle().ItemSpacing.x)); + const bool scale = row.kind == trainer::Kind::curve || row.kind == trainer::Kind::graph; + const bool whole = row.kind == trainer::Kind::integer; + const double low = scale ? 0.0 : -1.0e6, high = scale ? 100.0 : 1.0e6; + const auto speed = whole ? 0.1f : scale ? 0.01f + : static_cast(std::max({std::abs(row.stock), std::abs(value), 0.01}) * 0.004); + edited = ImGui::DragScalar("##value", ImGuiDataType_Double, &value, speed, &low, &high, whole ? "%.0f" : scale ? "x %.2f" : "%.4g", + ImGuiSliderFlags_AlwaysClamp); + if (ImGui::IsItemActive()) { + p.active = row.id; + p.active_value = value; + } else if (p.active == row.id) { + p.active.clear(); + } + } + if (edited) trainer_command(menu, callbacks, std::format("set {} {}", row.id, number(value))); + if (row.touched && row.kind != trainer::Kind::flag) { + ImGui::SameLine(); + if (ImGui::Button("Reset", ImVec2(reset, 0))) trainer_command(menu, callbacks, "reset " + row.id); + } + ImGui::EndDisabled(); + ImGui::PopID(); +} + +void tune_tab(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) { + if (!view.ready) { + begin_card(menu, "tune-wait", "PHYSICS TUNING"); + note(view.status.c_str()); + note("The values appear once a level is loaded."); + end_card(); + return; + } + if (!view.editable) warn(view.blocked.c_str()); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.42f); + ImGui::InputTextWithHint("##search", "Search every value...", p.search.data(), p.search.size()); + ImGui::SameLine(); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.42f); + const char *current = p.group == 0 ? "Essentials" : p.group > 1 && static_cast(p.group) <= view.groups.size() + 1 + ? view.groups[static_cast(p.group) - 2].c_str() : "Every group"; + if (ImGui::BeginCombo("##group", current)) { + if (ImGui::Selectable("Essentials", p.group == 0)) p.group = 0; + if (ImGui::Selectable("Every group", p.group == 1)) p.group = 1; + for (std::size_t i = 0; i < view.groups.size(); ++i) + if (ImGui::Selectable(view.groups[i].c_str(), p.group == static_cast(i) + 2)) p.group = static_cast(i) + 2; + ImGui::EndCombo(); + } + ImGui::SameLine(); + ImGui::BeginDisabled(!view.touched || !view.editable); + if (ImGui::Button("Reset all")) trainer_command(menu, callbacks, "reset all"); + ImGui::EndDisabled(); + ImGui::Checkbox("Only what I changed", &p.only_changed); + ImGui::SameLine(); + ImGui::Checkbox("Graph points", &p.graph_points); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Show every point of the tuning graphs, not just one multiplier per graph."); + filter_rows(p, view); + ImGui::SameLine(); + ImGui::TextDisabled("%zu shown, %zu changed", p.shown.size(), view.touched); + const bool with_group = p.search[0] != 0 || p.group <= 1 || p.only_changed; + const bool friendly = p.group == 0 && p.search[0] == 0 && !p.only_changed; + ImGui::BeginChild("tune-rows", ImVec2(0, 0), ImGuiChildFlags_None); + ImGuiListClipper clipper; + clipper.Begin(static_cast(p.shown.size())); + while (clipper.Step()) + for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) + value_row(menu, callbacks, p, view, view.rows[p.shown[static_cast(i)]], with_group, friendly); + if (p.shown.empty()) note("Nothing matches."); + ImGui::EndChild(); +} + +void presets_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) { + if (!view.editable && !view.blocked.empty()) warn(view.blocked.c_str()); + begin_card(menu, "quick", "QUICK", "The ones everybody wants"); + { + const auto active = [&](std::string_view name) { + for (const auto &preset : view.presets) + if (preset.name == name) return preset.active; + return false; + }; + const auto quick = [&](const char *name, const char *label, const char *hint) { + bool on = active(name); + if (toggle_row(menu, label, hint, on, view.ready && view.editable && callbacks.queue_console_command != nullptr)) + trainer_command(menu, callbacks, std::string(on ? "preset apply " : "preset remove ") + name); + }; + quick("Super Ollie", "Super high ollie", "About four times the pop, off the ground and out of grinds."); + quick("Fast Flips", "Fast flips", "Flip tricks spin three times as fast."); + quick("Fast Spins", "Fast spins", "Spin three times as fast."); + // The game's own bail protection, the same switch as Skater > Movement. + const auto &debug = model.debug; + bool no_bail = debug.no_bail; + if (toggle_row(menu, "Never bail", "No wipeouts at all. Recover from a bail before switching it on.", no_bail, + (debug.no_bail_available || debug.no_bail) && callbacks.queue_debug != nullptr)) + debug_request(menu, callbacks, {DebugAction::set_no_bail, no_bail}); + } + end_card(); + begin_card(menu, "presets", "PRESETS", "They stack; Stock clears them"); + ImGui::BeginDisabled(!view.ready || !view.editable); + const float apply = ImGui::CalcTextSize("Apply").x + ImGui::GetStyle().FramePadding.x * 2; + for (const auto &preset : view.presets) { + ImGui::PushID(preset.name.c_str()); + if (ImGui::Button("Apply", ImVec2(apply, 0))) trainer_command(menu, callbacks, "preset apply " + preset.name); + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(preset.name.c_str()); + if (preset.active) { + ImGui::SameLine(); + tag(menu, "ON", skate_theme::blue); + } + ImGui::SameLine(); + ImGui::TextDisabled("%s", preset.note.c_str()); + if (!preset.builtin) { + ImGui::SameLine(ImGui::GetContentRegionAvail().x + ImGui::GetCursorPosX() - px(120)); + if (ImGui::SmallButton("This map")) trainer_command(menu, callbacks, "profile set " + preset.name); + ImGui::SameLine(); + if (ImGui::SmallButton("Delete")) trainer_command(menu, callbacks, "preset delete " + preset.name); + } + ImGui::PopID(); + } + ImGui::EndDisabled(); + end_card(); + + begin_card(menu, "preset-save", "YOUR PRESETS", "Save what is changed right now"); + field(menu, "Name"); + const float save = ImGui::CalcTextSize("Save").x + ImGui::GetStyle().FramePadding.x * 2; + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - save - ImGui::GetStyle().ItemSpacing.x); + ImGui::InputText("##preset-name", p.preset_name.data(), p.preset_name.size()); + ImGui::SameLine(); + ImGui::BeginDisabled(!p.preset_name[0] || !view.touched); + if (ImGui::Button("Save", ImVec2(save, 0))) trainer_command(menu, callbacks, std::string("preset save ") + p.preset_name.data()); + ImGui::EndDisabled(); + info(menu, "Changed values", std::to_string(view.touched)); + if (!view.map.empty()) { + info(menu, "This map applies", view.profile_preset.empty() ? "nothing" : view.profile_preset); + if (!view.profile_preset.empty() && ImGui::Button("Stop applying it on this map")) trainer_command(menu, callbacks, "profile clear"); + note("\"This map\" beside one of your presets applies it every time this map loads."); + } + end_card(); +} + +void practice_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) { + const auto telemetry = trainer::telemetry(); + begin_card(menu, "speed", "GAME SPEED", "Slow motion for learning a line"); + { + const NamedSettingModel *setting = nullptr; + for (const auto &row : model.engine_settings) + if (row.name == "SimulationTime.TimeScale") { + setting = &row; + break; + } + if (!setting || !setting->available) { + note(setting && !setting->reason.empty() ? setting->reason.c_str() : "Game speed is unavailable right now."); + // The engine resolves a setting the first time it is asked for: ask. + if (setting && callbacks.queue_console_command && ImGui::GetTime() >= p.speed_until) { + p.speed_until = ImGui::GetTime() + 2.0; + p.speed_edit = -1; + std::array ignored{}; + callbacks.queue_console_command(callbacks.user, "reset SimulationTime.TimeScale", ignored.data(), ignored.size()); + } + } else { + double current = 1; + try { + const auto first = setting->value.find_first_of("-.0123456789"); + if (first != std::string::npos) current = std::stod(setting->value.substr(first)); + } catch (...) {} + if (p.speed_edit >= 0 && ImGui::GetTime() < p.speed_until) current = p.speed_edit; + const auto set = [&](double speed) { + p.speed_edit = speed; + p.speed_until = ImGui::GetTime() + 1.0; + send_console(menu, callbacks, speed == 1.0 ? "reset SimulationTime.TimeScale" : "SimulationTime.TimeScale " + number(speed)); + }; + field(menu, "Speed"); + auto value = static_cast(current); + if (ImGui::SliderFloat("##game-speed", &value, 0.05f, 2.0f, current == 0.0 ? "paused" : "%.2fx", ImGuiSliderFlags_AlwaysClamp)) + set(value); + if (ImGui::Button(current == 0.0 ? "Resume" : "Pause")) set(current == 0.0 ? 1.0 : 0.0); + for (const auto quick : {0.1, 0.25, 0.5, 0.75, 1.0}) { + ImGui::SameLine(); + if (ImGui::Button(std::format("{}x", number(quick)).c_str())) set(quick); + } + note("The jump read-out measures real time, so it reads low while the game is slowed."); + } + } + end_card(); + + begin_card(menu, "markers", "MARKERS", "Saved for each map"); + int slot = view.slot; + if (choice(menu, "marker-slot", slot, {"1", "2", "3", "4", "5"})) trainer_command(menu, callbacks, std::format("slot {}", slot + 1)); + const auto &marker = view.markers[static_cast(std::clamp(view.slot, 0, static_cast(trainer::marker_slots) - 1))]; + info(menu, "Selected", marker.set ? std::format("{:.1f}, {:.1f}, {:.1f}", marker.position[0], marker.position[1], marker.position[2]) : "empty"); + ImGui::BeginDisabled(view.map.empty()); + ImGui::BeginDisabled(!telemetry.skater); + if (ImGui::Button("Save here")) trainer_command(menu, callbacks, "marker save"); + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(!marker.set); + if (ImGui::Button("Go")) trainer_command(menu, callbacks, "marker go"); + ImGui::SameLine(); + if (ImGui::Button("Clear")) trainer_command(menu, callbacks, "marker clear"); + ImGui::EndDisabled(); + ImGui::EndDisabled(); + bool auto_return = view.auto_return; + if (toggle_row(menu, "Return after a bail", "Go back to the selected marker when the skater wipes out.", auto_return)) + trainer_command(menu, callbacks, std::format("option auto_return {}", auto_return ? 1 : 0)); + field(menu, "Return delay"); + float delay = view.return_delay; + ImGui::SliderFloat("##return-delay", &delay, 0.0f, 10.0f, "%.1f s", ImGuiSliderFlags_AlwaysClamp); + if (ImGui::IsItemDeactivatedAfterEdit()) trainer_command(menu, callbacks, std::format("option return_delay {:.1f}", delay)); + bool pad = view.pad_shortcuts; + if (toggle_row(menu, "Controller shortcuts", "Hold LB + RB, then D-pad: up saves, down goes, left / right pick the slot.", pad)) + trainer_command(menu, callbacks, std::format("option pad {}", pad ? 1 : 0)); + end_card(); + + begin_card(menu, "teleport", "TELEPORT"); + if (telemetry.skater) + info(menu, "You are at", std::format("{:.2f}, {:.2f}, {:.2f}", telemetry.position[0], telemetry.position[1], telemetry.position[2])); + field(menu, "X, Y, Z"); + ImGui::InputFloat3("##teleport", p.teleport.data(), "%.2f"); + ImGui::BeginDisabled(!telemetry.skater); + if (ImGui::Button("Here")) p.teleport = telemetry.position; + ImGui::SameLine(); + if (ImGui::Button("Go##teleport")) + trainer_command(menu, callbacks, std::format("tp {:.3f} {:.3f} {:.3f}", p.teleport[0], p.teleport[1], p.teleport[2])); + ImGui::SameLine(); + if (ImGui::Button("Copy position")) + ImGui::SetClipboardText(std::format("{:.3f}, {:.3f}, {:.3f}", telemetry.position[0], telemetry.position[1], telemetry.position[2]).c_str()); + ImGui::SameLine(); + if (ImGui::Button("Copy for Blender")) + ImGui::SetClipboardText(std::format("{:.3f}, {:.3f}, {:.3f}", telemetry.position[0], -telemetry.position[2], telemetry.position[1]).c_str()); + ImGui::EndDisabled(); + end_card(); +} + +void jump_lines(SkateMenu &menu, const trainer::Jump &jump) { + info(menu, "Takeoff", std::format("{:.1f} km/h at {:.1f} degrees", jump.takeoff_speed * 3.6f, jump.takeoff_angle)); + info(menu, "Air time", std::format("{:.2f} s", jump.air_time)); + info(menu, "Height", std::format("{:.2f} m", jump.height)); + info(menu, "Distance", std::format("{:.2f} m, {:.2f} m drop", jump.distance, jump.drop)); + info(menu, "Landing", std::format("{:.1f} km/h at {:.1f}, {:.1f}, {:.1f}", jump.landing_speed * 3.6f, jump.landing[0], jump.landing[1], + jump.landing[2])); +} +void map_tab(SkateMenu &menu, const CallbacksV3 &callbacks, const trainer::View &view) { + const auto telemetry = trainer::telemetry(); + begin_card(menu, "hud", "HUD"); + bool hud = view.hud, jump = view.hud_jump, logging = view.logging; + if (toggle_row(menu, "Speed and air HUD", "Speed, air time and height in the corner while you skate.", hud)) + trainer_command(menu, callbacks, std::format("option hud {}", hud ? 1 : 0)); + if (toggle_row(menu, "Jump read-out", "After each landing: takeoff speed and angle, height, distance, landing speed.", jump)) + trainer_command(menu, callbacks, std::format("option hud_jump {}", jump ? 1 : 0)); + if (toggle_row(menu, "Record telemetry", "Write position and speed every frame to a CSV in %LOCALAPPDATA%\\ReSkate\\trainer\\telemetry.", + logging)) + trainer_command(menu, callbacks, std::format("option log {}", logging ? 1 : 0)); + end_card(); + + begin_card(menu, "now", "RIGHT NOW"); + if (!telemetry.skater) { + note("No skater yet."); + } else { + info(menu, "Speed", std::format("{:.1f} km/h ({:.2f} m/s), top {:.1f} km/h", telemetry.speed * 3.6f, telemetry.speed, telemetry.top_speed * 3.6f)); + info(menu, "Position", std::format("{:.2f}, {:.2f}, {:.2f}", telemetry.position[0], telemetry.position[1], telemetry.position[2])); + info(menu, "Heading", std::format("{:.0f} degrees", telemetry.heading)); + } + end_card(); + + if (telemetry.last.serial) { + begin_card(menu, "last-jump", "LAST JUMP", std::format("Jump {}", telemetry.last.serial).c_str()); + jump_lines(menu, telemetry.last); + end_card(); + } + if (telemetry.best.serial && telemetry.best.serial != telemetry.last.serial) { + begin_card(menu, "best-jump", "LONGEST JUMP ON THIS MAP"); + jump_lines(menu, telemetry.best); + end_card(); + } + + begin_card(menu, "map", "THIS MAP", view.map.empty() ? "No level loaded" : view.map.c_str()); + if (!view.map_note.empty()) note(view.map_note.c_str()); + if (!view.map_preset.empty()) { + info(menu, "Author's preset", view.map_preset); + ImGui::BeginDisabled(!view.editable); + if (ImGui::Button("Apply the author's preset")) trainer_command(menu, callbacks, "preset apply map"); + ImGui::EndDisabled(); + } + for (std::size_t i = 0; i < view.spots.size(); ++i) { + ImGui::PushID(static_cast(i)); + if (ImGui::Button("Go")) trainer_command(menu, callbacks, std::format("spot {}", i + 1)); + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(view.spots[i].name.c_str()); + ImGui::PopID(); + } + if (view.map_note.empty() && view.map_preset.empty() && view.spots.empty()) + note("Map makers can ship a trainer.json in their mod folder with spots and a recommended preset."); + end_card(); +} +} // namespace + +bool trainer_take_open() { + const auto view = trainer::view(); + auto &p = page(); + if (view->open_serial == p.open_serial) return false; + p.open_serial = view->open_serial; + p.tab = std::clamp(view->open_tab, 0, 3); + p.show_page = true; + return true; +} +bool trainer_page_wanted() { + auto &p = page(); + const bool wanted = p.show_page; + p.show_page = false; + return wanted; +} +void trainer_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks) { + auto &p = page(); + const auto view = trainer::view(); + category_tabs(menu, p.tab, {"TUNE", "PRESETS", "PRACTICE", "MAP & HUD"}, "trainer-tabs"); + ImGui::PushID(p.tab); + ImGui::BeginChild("trainer-tab", ImVec2(0, page_body_height(menu))); + switch (p.tab) { + case 0: tune_tab(menu, callbacks, p, *view); break; + case 1: presets_tab(menu, model, callbacks, p, *view); break; + case 2: practice_tab(menu, model, callbacks, p, *view); break; + default: map_tab(menu, callbacks, *view); break; + } + ImGui::EndChild(); + ImGui::PopID(); +} +} // namespace dingosdk::overlay::menu + +namespace dingosdk::overlay { +bool trainer_open_requested() { return menu::trainer_take_open(); } +bool trainer_hud_pending() { + const auto view = trainer::view(); + return (view->hud || view->hud_jump) && trainer::telemetry().skater; +} +void draw_trainer_hud() { + const auto view = trainer::view(); + if (!view->hud && !view->hud_jump) return; + const auto telemetry = trainer::telemetry(); + if (!telemetry.skater) return; + static std::uint64_t shown_jump{}; + static double jump_until{}; + if (telemetry.last.serial != shown_jump) { + shown_jump = telemetry.last.serial; + jump_until = shown_jump ? ImGui::GetTime() + 7.0 : 0.0; + } + std::vector lines; + if (view->hud) { + lines.push_back(std::format("{:.0f} km/h", telemetry.speed * 3.6f)); + if (telemetry.airborne) lines.push_back(std::format("air {:.2f} s {:.2f} m", telemetry.air_time, telemetry.height)); + } + if (view->hud_jump && telemetry.last.serial && ImGui::GetTime() < jump_until) { + const auto &jump = telemetry.last; + lines.push_back(std::format("jump {}: {:.1f} km/h at {:.0f} deg", jump.serial, jump.takeoff_speed * 3.6f, jump.takeoff_angle)); + lines.push_back(std::format("{:.2f} s {:.2f} m high {:.2f} m far", jump.air_time, jump.height, jump.distance)); + lines.push_back(std::format("landed at {:.1f} km/h, {:.2f} m lower", jump.landing_speed * 3.6f, jump.drop)); + } + if (lines.empty()) return; + auto *draw = ImGui::GetForegroundDrawList(); + const auto &display = ImGui::GetIO().DisplaySize; + const float scale = std::clamp(display.y / 1080.0f, 0.75f, 2.5f); + auto *font = ImGui::GetFont(); + const float big = 34.0f * scale, small = 18.0f * scale, pad = 10.0f * scale; + float width = 0, height = pad * 2; + for (std::size_t i = 0; i < lines.size(); ++i) { + const float size = i == 0 && view->hud ? big : small; + width = std::max(width, font->CalcTextSizeA(size, FLT_MAX, 0, lines[i].c_str()).x); + height += size + 4.0f * scale; + } + const ImVec2 origin(display.x - width - pad * 2 - 24.0f * scale, 24.0f * scale); + draw->AddRectFilled(origin, ImVec2(origin.x + width + pad * 2, origin.y + height), IM_COL32(12, 14, 18, 170), 6.0f * scale); + float y = origin.y + pad; + for (std::size_t i = 0; i < lines.size(); ++i) { + const float size = i == 0 && view->hud ? big : small; + draw->AddText(font, size, ImVec2(origin.x + pad, y), IM_COL32(245, 245, 240, 255), lines[i].c_str()); + y += size + 4.0f * scale; + } +} +} // namespace dingosdk::overlay diff --git a/Extension/Trainer/trainer_page.h b/Extension/Trainer/trainer_page.h new file mode 100644 index 00000000..00fa8339 --- /dev/null +++ b/Extension/Trainer/trainer_page.h @@ -0,0 +1,18 @@ +#pragma once +// The overlay's side of the trainer: its menu page and the HUD drawn while skating. +#include "Extension/UI/Overlay/skate_menu.h" + +namespace dingosdk::overlay::menu { +void trainer_page(SkateMenu &, const Model &, const CallbacksV3 &); +// `trainer open`: true once per request, after selecting its tab. +bool trainer_take_open(); +// True once after a request: the menu should switch to the trainer page. +bool trainer_page_wanted(); +} +namespace dingosdk::overlay { +// True while the HUD has something to draw, so the overlay renders with the menu closed. +bool trainer_hud_pending(); +// True once per `trainer open`: the overlay should show the menu. +bool trainer_open_requested(); +void draw_trainer_hud(); +} // namespace dingosdk::overlay diff --git a/Extension/Trainer/trainer_presets.cpp b/Extension/Trainer/trainer_presets.cpp new file mode 100644 index 00000000..ed7b4989 --- /dev/null +++ b/Extension/Trainer/trainer_presets.cpp @@ -0,0 +1,98 @@ +#include "trainer_presets.h" + +#include +#include +#include + +namespace dingosdk::trainer { +std::string_view essential_name(std::string_view key, int *rank) { + static constexpr std::array, 26> names{{ + {"physicsmode.jumpmaxheight", "Ollie height (max)"}, + {"physicsmode.jumpminheight", "Ollie height (min)"}, + {"physicsmode.grindjumpcommonmax", "Pop out of grinds (max)"}, + {"physicsjump.hippyjumpmaxheight", "Hippy jump height"}, + {"physicspush.maxpushablespeed", "Top pushing speed (m/s)"}, + {"physicsmode.maxpushdvstart", "Push strength"}, + {"physicsmode.autopushenabled", "Auto push"}, + {"physicsmode.pumpmaxacceleration", "Pump acceleration"}, + {"physicsmode.speedwobblestartspeed", "Speed wobble starts at (m/s)"}, + {"physicsmode.speedwobbleeffectscalar", "Speed wobble strength"}, + {"physicsreckoning.flipscalar", "Flip speed"}, + {"physicsreckoning.flipmaxspeed", "Flip speed limit"}, + {"physicsmode.perfectbodyflips", "Perfect body flips"}, + {"physicsairstates.maxspinspeed", "Spin speed limit"}, + {"physicsmode.maxautobodyspinspeed", "Body spin speed"}, + {"physicsmode.easybodyspins", "Easy body spins"}, + {"physicsmode.grindlockdist", "Grind lock-on distance"}, + {"physicsgrind.commonfrictionscalar", "Grind friction"}, + {"physicsmode.makesurfacessmooth", "Smooth surfaces"}, + {"physicsmode.wipeoutcheckforbadlanding", "Bail on bad landings"}, + {"physicsmode.wipeout_upsidedown", "Bail when upside down"}, + {"physicswipeout.wipeout_airmaxspeedintoground", "Bail: max speed into the ground"}, + {"physicswipeout.maxspeedlandingonboard", "Max landing speed on the board"}, + {"physicstrucks.truckzposfront", "Front truck position (next respawn)"}, + {"physicstrucks.truckzposback", "Back truck position (next respawn)"}, + {"physicswheels.wheeldynamicfriction", "Wheel grip (next respawn)"}, + }}; + const auto found = std::ranges::find(names, key, &std::pair::first); + if (rank) *rank = found == names.end() ? 0 : static_cast(found - names.begin()) + 1; + return found == names.end() ? std::string_view{} : found->second; +} +// Patterns are matched against the lower-case ids the game's own data gives its tuning +// (run `trainer dump` for the list), so a preset reaches every value a pattern names in +// whatever build is running and simply skips the ones that build lacks. +const std::vector &builtin_presets() { + static const std::vector presets{ + // What the game was seen to read while a player ollied, flipped and spun: jump height + // comes from the PhysicsJump height graphs (the Mode jump heights are never read), + // body flips from FlipScalar and FlipMaxSpeed unless PerfectBodyFlips forces exactly + // one rotation, body spins from the PhysicsBodyspin graphs and MaxAutoBodySpinSpeed. + {"Super Ollie", "Huge ollies: about three times the height at any speed.", + {{"physicsjump.maxheightvsspeed", true, 3.0, true}, {"physicsjump.minheightvsspeed", true, 3.0, true}, + {"physicsjump.absoluteminheight", true, 2.0}, {"physicsjump.jumpybonusmax", true, 3.0}, + {"physicsjump.hippyjump", true, 3.0}, {"physicsmode.jumpmaxheight", true, 3.0}, + {"physicsmode.jumpminheight", true, 3.0}, {"physicsmode.grindjump", true, 2.5}}}, + {"Fast Flips", "Front flips and back flips rotate three times as fast.", + {{"physicsreckoning.flipscalar", true, 3.0}, {"physicsreckoning.flipmaxspeed", true, 3.0}, + {"physicsmode.perfectbodyflips", false, 0.0}}}, + {"Fast Spins", "Body spins rotate three times as fast.", + {{"physicsbodyspin.propbodyspinvstime", true, 3.0, true}, {"physicsbodyspin.pbsvst_easy", true, 3.0, true}, + {"physicsbodyspin.maxdeltavstime", true, 3.0, true}, {"physicsbodyspin.maxdeltaforautovstime", true, 3.0, true}, + {"physicsreckoning.spinspeedmaxdelta", true, 3.0, true}, {"physicsmode.maxautobodyspinspeed", true, 3.0}, + {"physicsairstates.maxspinspeed", true, 3.0}}}, + {"Mega Pop", "Ollies, nollies and grind pops go about twice as high.", + {{"physicsmode.jumpmaxheight", true, 2.2}, {"physicsmode.jumpminheight", true, 1.8}, + {"physicsmode.jumpdelta", true, 2.0}, {"physicsmode.grindjump", true, 1.6}, + {"physicsjump.absoluteminheight", true, 1.5}, {"physicsjump.hippyjump", true, 1.8}, + {"physicsjump.jumpybonusmax", true, 2.0}}}, + {"Big Boneless", "Bonelesses and footplants launch higher.", + {{"physicsboneless.jumpheightvsplanarspeed", true, 2.0, true}, {"physicsboneless.gravityassist", true, 1.5}}}, + {"Fast", "Push to a higher top speed and get there sooner.", + {{"physicspush.maxpushablespeed !camera", true, 1.8}, {"physicspush.maxspeedforautopush", true, 1.8}, + {"physicsmode.maxpushdv", true, 1.6}, {"physicsmode.pumpmaxacceleration", true, 1.5}, + {"physicsmode.speedwobblestartspeed", true, 3.0}}}, + {"No Speed Wobble", "The board stays steady at any speed.", + {{"physicsmode.speedwobbleeffectscalar", false, 0.0}, {"physicsmode.speedwobblestartspeed", true, 10.0}}}, + {"Auto Push", "The skater keeps pushing without input.", {{"physicsmode.autopushenabled", false, 1.0}}}, + {"Hard To Bail", "Much larger impacts are needed before a wipeout.", + {{"physicswipeout.wipeout_ force", true, 3.0}, {"physicswipeout.wipeout_ acceleration", true, 3.0}, + {"physicswipeout.wipeout_ maxspeed", true, 2.5}, {"physicswipeout.wipeout_ maxcontact", true, 3.0}, + {"physicswipeout.wipeout_ maxdisp", true, 3.0}, {"physicswipeout.wipeout_ relativevel", true, 3.0}, + {"physicswipeout.maxspeedlandingonboard", true, 2.5}, {"physicswipeout.maxchangeinspeed", true, 2.5}, + {"wipeout.on forcelimits", true, 3.0}, {"wipeout.runoutwipeoutforcelimits", true, 3.0}, + {"physicsmode.wipeout_ acceleration", true, 3.0}, {"wipeout.groundxzaccelerationthreshold", true, 3.0}, + {"physicsmode.wipeoutcheckforbadlanding", false, 0.0}, {"physicsmode.wipeout_upsidedown", false, 0.0}}}, + {"Sticky Grinds", "Grinds lock on from further away, faster and at steeper angles.", + {{"physicsmode.grindlockdist", true, 2.0}, {"physicstrajectory.grindmaxspeedsqr", true, 4.0}, + {"physicstrajectory.grindmaxspeeddownontogrind", true, 2.0}, {"physicstrajectory.grindlandingmaxangle", true, 2.0}, + {"physicstrajectory.grindadjustmaxangle", true, 2.0}}}, + {"Slick Grinds", "Grinds and slides keep their speed.", + {{"physicsgrind.commonfrictionscalar", true, 0.4}, {"physicsgrind.curbfrictionscalar", true, 0.4}, + {"physicsgrind.genericgrindfrictionscalar", true, 0.4}}}, + {"Smooth Surfaces", "Rough ground rides like polished concrete.", {{"physicsmode.makesurfacessmooth", false, 1.0}}}, + {"Long Wheelbase", "Trucks 10 cm further out at both ends. Takes effect on the next respawn.", + {{"physicstrucks.truckzposfront", false, 0.0143}, {"physicstrucks.truckzposback", false, 0.0143}}}, + }; + return presets; +} +} // namespace dingosdk::trainer diff --git a/Extension/Trainer/trainer_presets.h b/Extension/Trainer/trainer_presets.h new file mode 100644 index 00000000..a41e8d54 --- /dev/null +++ b/Extension/Trainer/trainer_presets.h @@ -0,0 +1,23 @@ +#pragma once +#include +#include + +namespace dingosdk::trainer { +// One rule of a built-in preset. `pattern` is lower-case words that a value's id must all +// contain ("physicsmode. jump height"); a word starting with '!' must be absent. A rule +// multiplies the stock value, or sets it. Single graph points are never matched. +struct PresetRule { + std::string_view pattern; + bool multiply{true}; + double amount{1}; + bool curves{}; // the rule is for curve and graph multipliers instead of plain values +}; +struct BuiltinPreset { + std::string_view name, note; + std::vector rules; +}; +// The plain name of a value on the Essentials list, by its lower-case id; empty for the rest. +std::string_view essential_name(std::string_view key, int *rank = nullptr); +// Presets stack: each applies on top of what is already changed; Stock clears them. +const std::vector &builtin_presets(); +} // namespace dingosdk::trainer diff --git a/Extension/Trainer/trainer_view.cpp b/Extension/Trainer/trainer_view.cpp new file mode 100644 index 00000000..c7d4e15c --- /dev/null +++ b/Extension/Trainer/trainer_view.cpp @@ -0,0 +1,39 @@ +#include "trainer.h" +#include + +// The snapshots the menu and HUD read. Kept apart from trainer.cpp so the overlay library +// links without the game-facing half. +namespace dingosdk::trainer { +namespace { +struct Feed { + std::mutex mutex; + std::shared_ptr view = std::make_shared(); + Telemetry telemetry; +}; +Feed &feed() { + static auto *value = new Feed; + return *value; +} +} // namespace +std::shared_ptr view() noexcept { + auto &f = feed(); + std::lock_guard lock(f.mutex); + return f.view; +} +Telemetry telemetry() noexcept { + auto &f = feed(); + std::lock_guard lock(f.mutex); + return f.telemetry; +} +void publish(std::shared_ptr next) noexcept { + if (!next) return; + auto &f = feed(); + std::lock_guard lock(f.mutex); + f.view = std::move(next); +} +void publish(const Telemetry &next) noexcept { + auto &f = feed(); + std::lock_guard lock(f.mutex); + f.telemetry = next; +} +} // namespace dingosdk::trainer diff --git a/Extension/UI/Overlay/overlay_render.cpp b/Extension/UI/Overlay/overlay_render.cpp index 0084e67d..894e7423 100644 --- a/Extension/UI/Overlay/overlay_render.cpp +++ b/Extension/UI/Overlay/overlay_render.cpp @@ -2,6 +2,7 @@ #include "Engine/Core/Log/logging.h" #include "Engine/Core/Profiling/profiler.h" #include "overlay_internal.h" +#include "Extension/Trainer/trainer_page.h" #include "park_previews.h" #include "chat_emotes.h" #include "input_capture.h" @@ -452,7 +453,8 @@ void render(IDXGISwapChain* presented, UINT flags) { const bool game_text_frame = game_text_pending(); const bool skate_hud_frame = skate_hud_pending(); const bool nametag_frame = nametags_pending(); - const bool perf_frame = perf_hud_pending(); + const bool perf_frame = perf_hud_pending() || trainer_hud_pending(); + if (trainer_open_requested()) s.visible.store(true); const bool menu_frame = interactive_visible(s); if (!menu_frame) { if (s.ui_was_interactive) { @@ -519,6 +521,7 @@ void render(IDXGISwapChain* presented, UINT flags) { draw_game_text(); draw_skate_hud(); draw_perf_hud(); + draw_trainer_hud(); draw_notices(); draw_chat(); sync_menu_cursor(); // close buttons also change visibility, without a key message diff --git a/Extension/UI/Overlay/skate_menu.cpp b/Extension/UI/Overlay/skate_menu.cpp index 039d8f2b..f794292b 100644 --- a/Extension/UI/Overlay/skate_menu.cpp +++ b/Extension/UI/Overlay/skate_menu.cpp @@ -276,11 +276,11 @@ ImFont* embedded_font(const wchar_t* name, float size, const ImWchar* ranges) { &config, ranges); } -enum Page { map, world, build, skater, multiplayer, progress, mods, settings, developer, page_count }; +enum Page { map, world, build, skater, training, multiplayer, progress, mods, settings, developer, page_count }; constexpr std::array page_names{ - "MAP", "WORLD", "BUILD", "SKATER", "MULTIPLAYER", "PROGRESS", "MODS", "SETTINGS", "DEVELOPER"}; + "MAP", "WORLD", "BUILD", "SKATER", "TRAINER", "MULTIPLAYER", "PROGRESS", "MODS", "SETTINGS", "DEVELOPER"}; constexpr std::array page_subtitles{ - "Pick your spot.", "Set the vibe.", "Make the park yours.", "Ride it your way.", + "Pick your spot.", "Set the vibe.", "Make the park yours.", "Ride it your way.", "Tune it. Drill it. Measure it.", "Bring your crew.", "Pick up where you want.", "Bring your own.", "Your controls, your screen.", "Under the hood."}; } @@ -366,6 +366,7 @@ void draw_skate_menu(SkateMenu& menu, const Model& model, const CallbacksV3& cal skate_theme::rotate_since(draw, start, -4.0f, ImVec2(at.x + extent.x * .5f, at.y + extent.y * .5f)); } draw->AddText(menu.body, px(12), ImVec2(origin.x + px(18), origin.y + px(66)), muted, "YOUR SESSION. YOUR RULES."); + if (trainer_page_wanted()) menu.page = training; menu.page = std::clamp(menu.page, 0, static_cast(page_count) - 1); // Developer (the network view) is hidden for now; offline there is no Multiplayer either. if (menu.page == developer || (model.steam_offline && menu.page == multiplayer)) menu.page = map; @@ -419,6 +420,7 @@ void draw_skate_menu(SkateMenu& menu, const Model& model, const CallbacksV3& cal case world: world_page(menu, model, callbacks); break; case build: build_page(menu, model, callbacks); break; case skater: skater_page(menu, model, callbacks); break; + case training: trainer_page(menu, model, callbacks); break; case multiplayer: multiplayer_page(menu, model, callbacks); break; case progress: progression_page(menu, model, callbacks); break; case mods: diff --git a/Extension/UI/Overlay/skate_menu_internal.h b/Extension/UI/Overlay/skate_menu_internal.h index 63b33789..9f07eeae 100644 --- a/Extension/UI/Overlay/skate_menu_internal.h +++ b/Extension/UI/Overlay/skate_menu_internal.h @@ -55,4 +55,7 @@ void settings_page(SkateMenu&, const Model&, const CallbacksV3&); // Settings > Post FX (skate_menu_world.cpp). void graphics_page(SkateMenu&, const Model&, const CallbacksV3&); void developer_page(SkateMenu&, const Model&, const CallbacksV3&); +// Extension/Trainer/trainer_page.cpp +void trainer_page(SkateMenu&, const Model&, const CallbacksV3&); +bool trainer_page_wanted(); } diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index af780d64..7384db61 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -23,6 +23,7 @@ #include "Extension/Skater/client_source_spawn.h" #include "Extension/Skater/skater_slot_override.h" #include "Extension/Throwdowns/native_throwdowns.h" +#include "Extension/Trainer/trainer.h" #include "Extension/World/level_loading.h" #include "Extension/World/loading_screen.h" #include @@ -1010,6 +1011,11 @@ void tick(std::uintptr_t client, std::uintptr_t update) { DINGO_PROFILE_ZONE("tick/AI skaters"); dingosdk::ai_skaters::tick(r.base,client,multiplayer_ready); } + { + DINGO_PROFILE_ZONE("tick/trainer"); + // A custom map is a sublevel of the root level: that is the map the player means. + dingosdk::trainer::tick(r.base, client, multiplayer_ready, r.catalog_level.empty() ? r.last_level : r.catalog_level); + } { DINGO_PROFILE_ZONE("tick/Steam friend join"); dingosdk::multiplayer::tick_steam_friend_join(); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 190c429a..b58cc254 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -104,6 +104,9 @@ add_library(dingosdk_runtime SHARED Extension/Skater/physics_tuning.cpp Extension/Multiplayer/Remote/remote_collision.cpp Extension/Skater/physics_tuning_model.cpp + Extension/Trainer/trainer.cpp + Extension/Trainer/trainer_presets.cpp + Extension/Trainer/trainer_commands.cpp Extension/Skater/offboard_flight.cpp Extension/Skater/camera_observer.cpp Extension/Boot/offline_boot.cpp diff --git a/cmake/UI.cmake b/cmake/UI.cmake index 6342b5ab..4679520a 100644 --- a/cmake/UI.cmake +++ b/cmake/UI.cmake @@ -31,6 +31,8 @@ add_library(dingosdk_overlay STATIC Extension/UI/Overlay/multiplayer_menu.cpp Extension/UI/Overlay/multiplayer_lobbies.cpp Extension/UI/Overlay/multiplayer_session.cpp + Extension/Trainer/trainer_page.cpp + Extension/Trainer/trainer_view.cpp ) target_link_libraries(dingosdk_overlay PUBLIC dingosdk_logging dingosdk_profiler dingosdk_imgui dingosdk_hooks dingosdk_console_core dxguid PRIVATE hid cfgmgr32 shell32 dingosdk_initfs dingosdk_custom_scripts dingosdk_game_archives dingosdk_mods dingosdk_json windowscodecs ole32) From 705cd0537fe24d8cbc659570f8b36a3737202bd8 Mon Sep 17 00:00:00 2001 From: andrewnakas Date: Sat, 3 Oct 2026 11:23:45 -0600 Subject: [PATCH 002/109] Trainer 0.1.1: hide tuning values the game does not read; presets switch off individually Players changed values such as the Mode ollie heights and saw nothing happen: no game code reads them. trainer_used.inc lists the asset offsets the code was found to read (the asset pointer, the per-group accessors and the skater's mode pointer were followed); rows without evidence are hidden by default and marked when shown. The Essentials list and the built-in presets now only use values with evidence. Switching one preset off keeps the values of the presets that are still on. Co-Authored-By: Claude Opus 5.5 (1M context) --- Extension/Trainer/CHANGELOG.md | 12 +++ Extension/Trainer/README.md | 15 ++-- Extension/Trainer/trainer.cpp | 8 +- Extension/Trainer/trainer.h | 1 + Extension/Trainer/trainer_page.cpp | 49 +++++++++--- Extension/Trainer/trainer_presets.cpp | 81 +++++++++---------- Extension/Trainer/trainer_presets.h | 4 + Extension/Trainer/trainer_used.inc | 111 ++++++++++++++++++++++++++ 8 files changed, 216 insertions(+), 65 deletions(-) create mode 100644 Extension/Trainer/trainer_used.inc diff --git a/Extension/Trainer/CHANGELOG.md b/Extension/Trainer/CHANGELOG.md index 2d966ee6..ba372dec 100644 --- a/Extension/Trainer/CHANGELOG.md +++ b/Extension/Trainer/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v0.1.1 - 2026-10-03 + +- **Tune no longer offers values that do nothing.** A pass over the game's code found which tuning + values it reads (527 of the 930 rows). The rest, including the Mode ollie heights and the hippy + jump heights people tried, are hidden unless you tick "Values with no use found", and are marked. +- The Essentials list now only holds values the game reads: ollie height is the jump-height graph + multiplier, body flip and body spin speed are the ones the quick switches use. +- Every preset can be switched off again by itself; presets that are still on keep their values. +- Presets no longer contain rules for values the game does not read. Mega Pop and No Speed Wobble + were rebuilt on the ones it does. +- The Tune tab says that changes apply as you drag and that the box only locks a value. + ## v0.1.0 - 2026-10-03 First release, built on ReSkate 1.0.3. diff --git a/Extension/Trainer/README.md b/Extension/Trainer/README.md index e2a55e7c..64089ab1 100644 --- a/Extension/Trainer/README.md +++ b/Extension/Trainer/README.md @@ -8,8 +8,8 @@ telemetry HUD. It ships no game data: the list of values is built at run time fr | Tab | What you get | |---|---| -| **TUNE** | An Essentials list of 26 plainly named values (ollie height, top speed, flip and spin speed, grind lock-on, bail limits...), then every plain value of the game's physics tuning (about 760), plus one multiplier for each of its 169 curves and graphs. Search, filter by group, "only what I changed", freeze, reset. Changes apply while you skate. | -| **PRESETS** | Quick switches for super high ollie, fast flips, fast spins and never bail. Built-in presets that stack (Super Ollie, Fast Flips, Fast Spins, Mega Pop, Fast, Hard To Bail, Sticky Grinds, ...), your own saved presets, and a preset a map applies every time it loads. | +| **TUNE** | An Essentials list of 22 plainly named values (ollie height, top speed, body flip and spin speed, grind lock-on...), then every plain value of the game's physics tuning (about 760), plus one multiplier for each of its 169 curves and graphs. Search, filter by group, "only what I changed", lock, reset. Changes apply while you skate. Values that no game code was found reading are hidden unless you ask for them. | +| **PRESETS** | Quick switches for super high ollie, fast flips, fast spins and never bail. Built-in presets you switch on and off, and that stack (Super Ollie, Fast Flips, Fast Spins, Mega Pop, Fast, Hard To Bail, Sticky Grinds, ...), your own saved presets, and a preset a map applies every time it loads. | | **PRACTICE** | Game speed and pause, five marker slots per map (save / go / clear), return to the marker after a bail, teleport to coordinates, copy your position (game or Blender axes). | | **MAP & HUD** | Speed and air-time HUD, a read-out after every jump (takeoff speed and angle, height, distance, drop, landing speed), telemetry recording to CSV, and whatever the map's author ships for the trainer. | @@ -83,9 +83,10 @@ folder>` (CMake option `DINGOSDK_BUILD_TRAINER_TESTS`) lists the tuning values w - Masses and collision sizes (deck, trucks, wheels) only change on the next respawn. - Curve and graph multipliers scale outputs only; a curve whose point count a mod changed keeps the mod's points. -- Not every tuning value is used by the game. The quick switches were built from what the game was - seen to read during tricks: ollie height comes from the `PhysicsJump` height graphs (the - `PhysicsMode` jump heights are never read), body flips from `PhysicsReckoning.FlipScalar` and - `FlipMaxSpeed` (`PerfectBodyFlips` forces exactly one rotation and ignores them), body spins from - the `PhysicsBodyspin` graphs. +- Not every tuning value is used by the game: about 4 in 10 rows have no code reading them + (`trainer_used.inc`, found by a static pass over the game's code for this build). Ollie height + comes from the `PhysicsJump` height graphs (the `PhysicsMode` jump heights are never read), body + flips from `PhysicsReckoning.FlipScalar` and `FlipMaxSpeed` (`PerfectBodyFlips` forces exactly one + rotation and ignores them), body spins from the `PhysicsBodyspin` graphs. "No use found" is not + proof: the pass can miss a use. - Built for one game build (the one ReSkate 1.0.3 supports). A game update needs a new build. diff --git a/Extension/Trainer/trainer.cpp b/Extension/Trainer/trainer.cpp index 258069a7..0644e69d 100644 --- a/Extension/Trainer/trainer.cpp +++ b/Extension/Trainer/trainer.cpp @@ -42,6 +42,7 @@ struct Entry { double value{}, stock{}; bool touched{}, frozen{}; bool detail{}; + bool used{}; // the game's code was found to read it (trainer_used.inc) std::vector members; // graph: its outputs (Y0.., Min_Y, Max_Y) }; struct MapData { @@ -404,6 +405,7 @@ void build_entries() { e.offset = offset; e.size = size; e.curve = curve; + e.used = value_used(offset); s.index.emplace(e.key, s.entries.size()); s.entries.push_back(std::move(e)); }; @@ -566,6 +568,9 @@ std::string remove_preset(std::string_view name) { } if (removed.empty()) return "error: no preset is called \"" + std::string(name) + "\"."; std::erase(s.active, removed); + // Presets that are still on may share values with the one switched off: set theirs again. + const auto still_on = s.active; + for (const auto &other : still_on) (void)apply_preset(other); changed(); return std::format("{} off: {} values put back.", removed, count); } @@ -960,7 +965,8 @@ void build_view() { for (const auto &e : s.entries) { int rank{}; const auto friendly = essential_name(e.key, &rank); - next->rows.push_back({e.id, e.label, e.group, e.kind, e.value, e.stock, e.touched, e.frozen, e.detail, std::string(friendly), rank}); + next->rows.push_back({e.id, e.label, e.group, e.kind, e.value, e.stock, e.touched, e.frozen, e.detail, std::string(friendly), rank, + e.used}); if (e.touched) ++next->touched; if (std::ranges::find(next->groups, e.group) == next->groups.end()) next->groups.push_back(e.group); } diff --git a/Extension/Trainer/trainer.h b/Extension/Trainer/trainer.h index d78192f9..cb9416da 100644 --- a/Extension/Trainer/trainer.h +++ b/Extension/Trainer/trainer.h @@ -26,6 +26,7 @@ struct Row { bool detail{}; // a single point or bound of a graph: hidden unless asked for std::string friendly; // a plain name, for the handful of values on the Essentials list int rank{}; // its place on that list, from 1; 0: not on it + bool used{}; // the game's code was found to read it }; struct PresetRow { std::string name, note; diff --git a/Extension/Trainer/trainer_page.cpp b/Extension/Trainer/trainer_page.cpp index 672b0670..1d2ffc56 100644 --- a/Extension/Trainer/trainer_page.cpp +++ b/Extension/Trainer/trainer_page.cpp @@ -17,7 +17,8 @@ struct Page { int tab{1}; // opens on PRESETS: the quick switches std::array search{}; int group{}; // 0: the Essentials list, 1: every group, 2..: one of the view's groups - bool only_changed{}, graph_points{}; + bool only_changed{}, graph_points{}, show_unused{}; + std::size_t hidden_unused{}; // rows the filters would show but for "no use found" // The rows the filters leave, rebuilt when the snapshot or a filter changes. std::vector shown; std::uint64_t shown_revision{}; @@ -57,11 +58,12 @@ void trainer_command(SkateMenu &menu, const CallbacksV3 &callbacks, const std::s void filter_rows(Page &p, const trainer::View &view) { const auto words = lower(p.search.data()); - const auto key = std::format("{}|{}|{}|{}", words, p.group, p.only_changed, p.graph_points); + const auto key = std::format("{}|{}|{}|{}|{}", words, p.group, p.only_changed, p.graph_points, p.show_unused); if (p.shown_revision == view.revision && p.shown_key == key) return; p.shown_revision = view.revision; p.shown_key = key; p.shown.clear(); + p.hidden_unused = 0; const std::string *group = p.group > 1 && static_cast(p.group) <= view.groups.size() + 1 ? &view.groups[static_cast(p.group) - 2] : nullptr; const bool essentials = p.group == 0 && words.empty() && !p.only_changed; @@ -76,6 +78,11 @@ void filter_rows(Page &p, const trainer::View &view) { // Searching looks through every group. if (words.empty() ? (group && row.group != *group) : !contains_words(lower(row.id + " " + row.label + " " + row.friendly), words)) continue; + // A value nothing in the game reads would be a slider that does nothing. + if (!row.used && !p.show_unused && !row.touched && !row.frozen) { + ++p.hidden_unused; + continue; + } p.shown.push_back(i); } if (essentials) std::ranges::sort(p.shown, {}, [&](std::size_t i) { return view.rows[i].rank; }); @@ -86,15 +93,18 @@ void value_row(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const tra ImGui::PushID(row.id.c_str()); bool frozen = row.frozen; if (ImGui::Checkbox("##freeze", &frozen)) trainer_command(menu, callbacks, std::format("freeze {} {}", row.id, frozen ? 1 : 0)); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Freeze: presets and Reset all leave this value alone."); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Lock: presets and Reset all leave this value alone.\nYou do not need it for a change to apply."); ImGui::SameLine(); const float column = ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x * 0.52f; const auto label = friendly && !row.friendly.empty() ? row.friendly : with_group ? row.group + " / " + row.label : row.label; - if (row.touched) ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::blue); + const bool tinted = row.touched || !row.used; + if (tinted) ImGui::PushStyleColor(ImGuiCol_Text, row.used ? skate_theme::blue : IM_COL32(150, 150, 150, 255)); ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(label.c_str()); - if (row.touched) ImGui::PopStyleColor(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s\nStock: %s", row.id.c_str(), number(row.stock).c_str()); + ImGui::TextUnformatted(row.used ? label.c_str() : (label + " (no use found)").c_str()); + if (tinted) ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s\nStock: %s%s", row.id.c_str(), number(row.stock).c_str(), + row.used ? "" : "\nNo game code was found reading this value, so changing it will probably do nothing."); ImGui::SameLine(column); const float reset = ImGui::CalcTextSize("Reset").x + ImGui::GetStyle().FramePadding.x * 2; ImGui::BeginDisabled(!view.editable || !callbacks.queue_console_command); @@ -157,13 +167,19 @@ void tune_tab(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trai ImGui::BeginDisabled(!view.touched || !view.editable); if (ImGui::Button("Reset all")) trainer_command(menu, callbacks, "reset all"); ImGui::EndDisabled(); + note("Changes apply as you drag. The box on the left only locks a value against presets and Reset all."); ImGui::Checkbox("Only what I changed", &p.only_changed); ImGui::SameLine(); ImGui::Checkbox("Graph points", &p.graph_points); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Show every point of the tuning graphs, not just one multiplier per graph."); + ImGui::SameLine(); + ImGui::Checkbox("Values with no use found", &p.show_unused); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Also list the tuning values that no game code was found reading.\nChanging those will probably do nothing."); filter_rows(p, view); ImGui::SameLine(); - ImGui::TextDisabled("%zu shown, %zu changed", p.shown.size(), view.touched); + if (p.hidden_unused) ImGui::TextDisabled("%zu shown, %zu changed, %zu hidden", p.shown.size(), view.touched, p.hidden_unused); + else ImGui::TextDisabled("%zu shown, %zu changed", p.shown.size(), view.touched); const bool with_group = p.search[0] != 0 || p.group <= 1 || p.only_changed; const bool friendly = p.group == 0 && p.search[0] == 0 && !p.only_changed; ImGui::BeginChild("tune-rows", ImVec2(0, 0), ImGuiChildFlags_None); @@ -201,16 +217,25 @@ void presets_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbac debug_request(menu, callbacks, {DebugAction::set_no_bail, no_bail}); } end_card(); - begin_card(menu, "presets", "PRESETS", "They stack; Stock clears them"); + begin_card(menu, "presets", "PRESETS", "Switch any of them on and off; they stack"); ImGui::BeginDisabled(!view.ready || !view.editable); - const float apply = ImGui::CalcTextSize("Apply").x + ImGui::GetStyle().FramePadding.x * 2; + const float button = ImGui::CalcTextSize("Turn off").x + ImGui::GetStyle().FramePadding.x * 2; for (const auto &preset : view.presets) { ImGui::PushID(preset.name.c_str()); - if (ImGui::Button("Apply", ImVec2(apply, 0))) trainer_command(menu, callbacks, "preset apply " + preset.name); + if (preset.name == "Stock") { + // Not a preset to switch: everything back to how the map loaded. + if (ImGui::Button("Reset", ImVec2(button, 0))) trainer_command(menu, callbacks, "preset apply Stock"); + } else if (preset.active) { + ImGui::PushStyleColor(ImGuiCol_Button, skate_theme::blue); + if (ImGui::Button("Turn off", ImVec2(button, 0))) trainer_command(menu, callbacks, "preset remove " + preset.name); + ImGui::PopStyleColor(); + } else if (ImGui::Button("Turn on", ImVec2(button, 0))) { + trainer_command(menu, callbacks, "preset apply " + preset.name); + } ImGui::SameLine(); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(preset.name.c_str()); - if (preset.active) { + if (preset.active && preset.name != "Stock") { ImGui::SameLine(); tag(menu, "ON", skate_theme::blue); } diff --git a/Extension/Trainer/trainer_presets.cpp b/Extension/Trainer/trainer_presets.cpp index ed7b4989..d721b876 100644 --- a/Extension/Trainer/trainer_presets.cpp +++ b/Extension/Trainer/trainer_presets.cpp @@ -2,93 +2,84 @@ #include #include +#include #include namespace dingosdk::trainer { +bool value_used(std::uint16_t offset) { + static constexpr std::uint16_t used[]{ +#include "trainer_used.inc" + }; + return std::binary_search(std::begin(used), std::end(used), offset); +} +// Only values the game was found to read: a slider that does nothing is worse than none. std::string_view essential_name(std::string_view key, int *rank) { - static constexpr std::array, 26> names{{ - {"physicsmode.jumpmaxheight", "Ollie height (max)"}, - {"physicsmode.jumpminheight", "Ollie height (min)"}, + static constexpr std::array, 22> names{{ + {"physicsjump.maxheightvsspeed x", "Ollie height (x)"}, + {"physicsjump.minheightvsspeed x", "Ollie height, light pop (x)"}, {"physicsmode.grindjumpcommonmax", "Pop out of grinds (max)"}, - {"physicsjump.hippyjumpmaxheight", "Hippy jump height"}, + {"physicsjump.jumpybonusmax", "Jump bonus (max)"}, {"physicspush.maxpushablespeed", "Top pushing speed (m/s)"}, {"physicsmode.maxpushdvstart", "Push strength"}, {"physicsmode.autopushenabled", "Auto push"}, - {"physicsmode.pumpmaxacceleration", "Pump acceleration"}, {"physicsmode.speedwobblestartspeed", "Speed wobble starts at (m/s)"}, - {"physicsmode.speedwobbleeffectscalar", "Speed wobble strength"}, - {"physicsreckoning.flipscalar", "Flip speed"}, - {"physicsreckoning.flipmaxspeed", "Flip speed limit"}, - {"physicsmode.perfectbodyflips", "Perfect body flips"}, - {"physicsairstates.maxspinspeed", "Spin speed limit"}, - {"physicsmode.maxautobodyspinspeed", "Body spin speed"}, + {"physicsreckoning.flipscalar", "Body flip speed"}, + {"physicsreckoning.flipmaxspeed", "Body flip speed limit"}, + {"physicsmode.perfectbodyflips", "Perfect body flips (exactly one rotation)"}, + {"physicsbodyspin.propbodyspinvstime x", "Body spin speed (x)"}, + {"physicsbodyspin.maxdeltavstime x", "Body spin acceleration (x)"}, + {"physicsmode.maxautobodyspinspeed", "Auto body spin speed"}, {"physicsmode.easybodyspins", "Easy body spins"}, {"physicsmode.grindlockdist", "Grind lock-on distance"}, {"physicsgrind.commonfrictionscalar", "Grind friction"}, {"physicsmode.makesurfacessmooth", "Smooth surfaces"}, {"physicsmode.wipeoutcheckforbadlanding", "Bail on bad landings"}, - {"physicsmode.wipeout_upsidedown", "Bail when upside down"}, - {"physicswipeout.wipeout_airmaxspeedintoground", "Bail: max speed into the ground"}, - {"physicswipeout.maxspeedlandingonboard", "Max landing speed on the board"}, + {"physicsmode.wipeout_groundxzacceleration", "Bail: sideways hit limit"}, {"physicstrucks.truckzposfront", "Front truck position (next respawn)"}, {"physicstrucks.truckzposback", "Back truck position (next respawn)"}, - {"physicswheels.wheeldynamicfriction", "Wheel grip (next respawn)"}, }}; const auto found = std::ranges::find(names, key, &std::pair::first); if (rank) *rank = found == names.end() ? 0 : static_cast(found - names.begin()) + 1; return found == names.end() ? std::string_view{} : found->second; } + // Patterns are matched against the lower-case ids the game's own data gives its tuning // (run `trainer dump` for the list), so a preset reaches every value a pattern names in // whatever build is running and simply skips the ones that build lacks. const std::vector &builtin_presets() { static const std::vector presets{ - // What the game was seen to read while a player ollied, flipped and spun: jump height - // comes from the PhysicsJump height graphs (the Mode jump heights are never read), - // body flips from FlipScalar and FlipMaxSpeed unless PerfectBodyFlips forces exactly - // one rotation, body spins from the PhysicsBodyspin graphs and MaxAutoBodySpinSpeed. + // Rules name values the game was found to read (trainer_used.inc). Jump height comes + // from the PhysicsJump height graphs (the Mode jump heights are never read); body flips + // from FlipScalar and FlipMaxSpeed unless PerfectBodyFlips forces exactly one rotation; + // body spins from the PhysicsBodyspin graphs and MaxAutoBodySpinSpeed. {"Super Ollie", "Huge ollies: about three times the height at any speed.", {{"physicsjump.maxheightvsspeed", true, 3.0, true}, {"physicsjump.minheightvsspeed", true, 3.0, true}, {"physicsjump.absoluteminheight", true, 2.0}, {"physicsjump.jumpybonusmax", true, 3.0}, - {"physicsjump.hippyjump", true, 3.0}, {"physicsmode.jumpmaxheight", true, 3.0}, - {"physicsmode.jumpminheight", true, 3.0}, {"physicsmode.grindjump", true, 2.5}}}, + {"physicsmode.grindjump", true, 2.5}}}, {"Fast Flips", "Front flips and back flips rotate three times as fast.", {{"physicsreckoning.flipscalar", true, 3.0}, {"physicsreckoning.flipmaxspeed", true, 3.0}, {"physicsmode.perfectbodyflips", false, 0.0}}}, {"Fast Spins", "Body spins rotate three times as fast.", {{"physicsbodyspin.propbodyspinvstime", true, 3.0, true}, {"physicsbodyspin.pbsvst_easy", true, 3.0, true}, - {"physicsbodyspin.maxdeltavstime", true, 3.0, true}, {"physicsbodyspin.maxdeltaforautovstime", true, 3.0, true}, - {"physicsreckoning.spinspeedmaxdelta", true, 3.0, true}, {"physicsmode.maxautobodyspinspeed", true, 3.0}, - {"physicsairstates.maxspinspeed", true, 3.0}}}, - {"Mega Pop", "Ollies, nollies and grind pops go about twice as high.", - {{"physicsmode.jumpmaxheight", true, 2.2}, {"physicsmode.jumpminheight", true, 1.8}, - {"physicsmode.jumpdelta", true, 2.0}, {"physicsmode.grindjump", true, 1.6}, - {"physicsjump.absoluteminheight", true, 1.5}, {"physicsjump.hippyjump", true, 1.8}, - {"physicsjump.jumpybonusmax", true, 2.0}}}, - {"Big Boneless", "Bonelesses and footplants launch higher.", - {{"physicsboneless.jumpheightvsplanarspeed", true, 2.0, true}, {"physicsboneless.gravityassist", true, 1.5}}}, + {"physicsbodyspin.maxdeltavstime", true, 3.0, true}, {"physicsmode.maxautobodyspinspeed", true, 3.0}}}, + {"Mega Pop", "Ollies and grind pops go about twice as high.", + {{"physicsjump.maxheightvsspeed", true, 2.0, true}, {"physicsjump.minheightvsspeed", true, 1.6, true}, + {"physicsjump.jumpybonusmax", true, 2.0}, {"physicsmode.grindjump", true, 1.6}}}, {"Fast", "Push to a higher top speed and get there sooner.", {{"physicspush.maxpushablespeed !camera", true, 1.8}, {"physicspush.maxspeedforautopush", true, 1.8}, - {"physicsmode.maxpushdv", true, 1.6}, {"physicsmode.pumpmaxacceleration", true, 1.5}, - {"physicsmode.speedwobblestartspeed", true, 3.0}}}, - {"No Speed Wobble", "The board stays steady at any speed.", - {{"physicsmode.speedwobbleeffectscalar", false, 0.0}, {"physicsmode.speedwobblestartspeed", true, 10.0}}}, + {"physicsmode.maxpushdv", true, 1.6}, {"physicsmode.speedwobblestartspeed", true, 3.0}}}, + {"No Speed Wobble", "The board stays steady at any speed.", {{"physicsmode.speedwobblestartspeed", true, 20.0}}}, {"Auto Push", "The skater keeps pushing without input.", {{"physicsmode.autopushenabled", false, 1.0}}}, {"Hard To Bail", "Much larger impacts are needed before a wipeout.", {{"physicswipeout.wipeout_ force", true, 3.0}, {"physicswipeout.wipeout_ acceleration", true, 3.0}, - {"physicswipeout.wipeout_ maxspeed", true, 2.5}, {"physicswipeout.wipeout_ maxcontact", true, 3.0}, - {"physicswipeout.wipeout_ maxdisp", true, 3.0}, {"physicswipeout.wipeout_ relativevel", true, 3.0}, - {"physicswipeout.maxspeedlandingonboard", true, 2.5}, {"physicswipeout.maxchangeinspeed", true, 2.5}, + {"physicswipeout.wipeout_ maxspeed", true, 2.5}, {"physicswipeout.wipeout_ maxdisp", true, 3.0}, {"physicswipeout.wipeout_ relativevel", true, 3.0}, {"wipeout.on forcelimits", true, 3.0}, {"wipeout.runoutwipeoutforcelimits", true, 3.0}, - {"physicsmode.wipeout_ acceleration", true, 3.0}, {"wipeout.groundxzaccelerationthreshold", true, 3.0}, - {"physicsmode.wipeoutcheckforbadlanding", false, 0.0}, {"physicsmode.wipeout_upsidedown", false, 0.0}}}, - {"Sticky Grinds", "Grinds lock on from further away, faster and at steeper angles.", + {"physicsmode.wipeout_ acceleration", true, 3.0}, {"physicsmode.wipeoutcheckforbadlanding", false, 0.0}}}, + {"Sticky Grinds", "Grinds lock on from further away and at higher speeds.", {{"physicsmode.grindlockdist", true, 2.0}, {"physicstrajectory.grindmaxspeedsqr", true, 4.0}, - {"physicstrajectory.grindmaxspeeddownontogrind", true, 2.0}, {"physicstrajectory.grindlandingmaxangle", true, 2.0}, - {"physicstrajectory.grindadjustmaxangle", true, 2.0}}}, + {"physicstrajectory.grindmaxspeeddownontogrind", true, 2.0}, {"physicstrajectory.grindlandingmaxangle", true, 2.0}}}, {"Slick Grinds", "Grinds and slides keep their speed.", - {{"physicsgrind.commonfrictionscalar", true, 0.4}, {"physicsgrind.curbfrictionscalar", true, 0.4}, - {"physicsgrind.genericgrindfrictionscalar", true, 0.4}}}, + {{"physicsgrind.commonfrictionscalar", true, 0.4}, {"physicsgrind.curbfrictionscalar", true, 0.4}}}, {"Smooth Surfaces", "Rough ground rides like polished concrete.", {{"physicsmode.makesurfacessmooth", false, 1.0}}}, {"Long Wheelbase", "Trucks 10 cm further out at both ends. Takes effect on the next respawn.", {{"physicstrucks.truckzposfront", false, 0.0143}, {"physicstrucks.truckzposback", false, 0.0143}}}, diff --git a/Extension/Trainer/trainer_presets.h b/Extension/Trainer/trainer_presets.h index a41e8d54..c998564e 100644 --- a/Extension/Trainer/trainer_presets.h +++ b/Extension/Trainer/trainer_presets.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include @@ -18,6 +19,9 @@ struct BuiltinPreset { }; // The plain name of a value on the Essentials list, by its lower-case id; empty for the rest. std::string_view essential_name(std::string_view key, int *rank = nullptr); +// Whether the game's code was found to read the tuning value at this offset of the asset. +// False means "no use found": changing it will probably do nothing. +bool value_used(std::uint16_t offset); // Presets stack: each applies on top of what is already changed; Stock clears them. const std::vector &builtin_presets(); } // namespace dingosdk::trainer diff --git a/Extension/Trainer/trainer_used.inc b/Extension/Trainer/trainer_used.inc new file mode 100644 index 00000000..344a4706 --- /dev/null +++ b/Extension/Trainer/trainer_used.inc @@ -0,0 +1,111 @@ +// Offsets of Gameplay/SkatePhysicsTuning that the game's code was found to read (game build +// 25414733): a static pass over the code that follows the asset pointer, the per-group accessor +// functions and the skater's mode pointer. A value not listed may still be used through a path +// the pass does not follow, so the menu says "no use found", not "unused". +0x178, 0x17c, 0x180, 0x184, 0x188, 0x18c, 0x190, 0x194, 0x198, 0x19c, 0x1a0, 0x1a4, 0x1a8, 0x1ac, 0x1b0, 0x1b4, +0x1b8, 0x1bc, 0x1c0, 0x1c4, 0x1c8, 0x1cc, 0x1d0, 0x1d4, 0x1d8, 0x1dc, 0x1e0, 0x1e4, 0x1e8, 0x1ec, 0x1f0, 0x1f4, +0x1f8, 0x1fc, 0x200, 0x204, 0x208, 0x20c, 0x210, 0x218, 0x220, 0x228, 0x230, 0x23c, 0x240, 0x248, 0x24c, 0x254, +0x258, 0x268, 0x270, 0x278, 0x280, 0x284, 0x28c, 0x294, 0x298, 0x2a0, 0x2b0, 0x2b8, 0x2c0, 0x2c8, 0x2d0, 0x2d4, +0x2d8, 0x2dc, 0x2e0, 0x2e4, 0x2e8, 0x2f4, 0x2f8, 0x2fc, 0x6d8, 0x6dc, 0x6e0, 0x6e4, 0x6e8, 0x6ec, 0x6f0, 0x6f4, +0x6f8, 0x6fc, 0x700, 0x704, 0x708, 0x70c, 0x710, 0x714, 0x718, 0x71c, 0x720, 0x724, 0x728, 0x72c, 0x730, 0x734, +0x738, 0x73c, 0x740, 0x744, 0x748, 0x74c, 0x750, 0x754, 0x758, 0x75c, 0x760, 0x764, 0x768, 0x76c, 0x818, 0x81c, +0x820, 0x824, 0x828, 0x82c, 0x830, 0x834, 0x838, 0x83c, 0x840, 0x844, 0x848, 0x84c, 0x850, 0x854, 0x858, 0x85c, +0x860, 0x864, 0x868, 0x86c, 0x870, 0x874, 0x878, 0x87c, 0x880, 0x884, 0x888, 0x88c, 0x890, 0x894, 0x898, 0x89c, +0x8a0, 0x8a4, 0x8a8, 0x8ac, 0xb38, 0xb3c, 0xb40, 0xb44, 0xb48, 0xb4c, 0xb50, 0xb54, 0xb58, 0xb5c, 0xb60, 0xb64, +0xb68, 0xb6c, 0xb70, 0xb74, 0xb78, 0xb7c, 0xb80, 0xb84, 0xb88, 0xb8c, 0xb90, 0xb94, 0xb98, 0xb9c, 0xba0, 0xba4, +0xba8, 0xbac, 0xbb0, 0xbb4, 0xbb8, 0xbbc, 0xbc0, 0xbc4, 0xbc8, 0xbcc, 0xbd8, 0xbdc, 0xbe0, 0xbe4, 0xbe8, 0xbec, +0xbf0, 0xbf4, 0xbf8, 0xbfc, 0xc00, 0xc04, 0xc08, 0xc0c, 0xc10, 0xc14, 0xc18, 0xc1c, 0xc20, 0xc24, 0xc28, 0xc2c, +0xc30, 0xc34, 0xc38, 0xc3c, 0xc40, 0xc44, 0xc48, 0xc4c, 0xc50, 0xc54, 0xc58, 0xc5c, 0xc60, 0xc64, 0xc68, 0xc6c, +0xe50, 0xe5c, 0xe64, 0xe70, 0xe74, 0xe78, 0xe90, 0xe98, 0xe9c, 0xea4, 0xea8, 0xeb0, 0xeb4, 0xeb8, 0xebc, 0xec0, +0xfd8, 0x15b0, 0x1660, 0x1664, 0x1668, 0x166c, 0x1670, 0x1674, 0x1678, 0x167c, 0x1680, 0x1684, 0x1688, 0x168c, 0x1690, 0x1694, +0x1698, 0x169c, 0x16a0, 0x16a4, 0x16a8, 0x16ac, 0x16b0, 0x16b4, 0x16b8, 0x16bc, 0x16c0, 0x16c4, 0x16c8, 0x16cc, 0x16d0, 0x16d4, +0x16d8, 0x16dc, 0x16e0, 0x16e4, 0x16e8, 0x16ec, 0x16f0, 0x16f4, 0x1700, 0x1704, 0x1708, 0x170c, 0x1710, 0x1714, 0x1718, 0x171c, +0x1720, 0x1724, 0x1728, 0x172c, 0x1730, 0x1734, 0x1738, 0x173c, 0x1740, 0x1744, 0x1748, 0x174c, 0x1750, 0x1754, 0x1758, 0x175c, +0x1760, 0x1764, 0x1768, 0x176c, 0x1770, 0x1774, 0x1778, 0x177c, 0x1780, 0x1784, 0x1788, 0x178c, 0x1790, 0x1794, 0x17a0, 0x17a4, +0x17a8, 0x17ac, 0x17b0, 0x17b4, 0x17b8, 0x17bc, 0x17c0, 0x17c4, 0x17c8, 0x17cc, 0x17d0, 0x17d4, 0x17d8, 0x17dc, 0x17e0, 0x17e4, +0x17e8, 0x17ec, 0x17f0, 0x17f4, 0x17f8, 0x17fc, 0x1800, 0x1804, 0x1808, 0x180c, 0x1810, 0x1814, 0x1818, 0x181c, 0x1820, 0x1824, +0x1828, 0x182c, 0x1830, 0x1834, 0x18e0, 0x18e4, 0x18e8, 0x18ec, 0x18f0, 0x18f4, 0x18f8, 0x18fc, 0x1900, 0x1904, 0x1908, 0x190c, +0x1910, 0x1914, 0x1918, 0x191c, 0x1920, 0x1924, 0x1928, 0x192c, 0x1930, 0x1934, 0x1938, 0x193c, 0x1940, 0x1944, 0x1948, 0x194c, +0x1950, 0x1954, 0x1958, 0x195c, 0x1960, 0x1964, 0x1968, 0x196c, 0x1970, 0x1974, 0x1980, 0x1984, 0x1988, 0x198c, 0x1990, 0x1994, +0x1998, 0x199c, 0x19a0, 0x19a4, 0x19a8, 0x19ac, 0x19b0, 0x19b4, 0x19b8, 0x19bc, 0x19c0, 0x19c4, 0x19c8, 0x19cc, 0x19d0, 0x19d4, +0x19d8, 0x19dc, 0x19e0, 0x19e4, 0x19e8, 0x19ec, 0x19f0, 0x19f4, 0x19f8, 0x19fc, 0x1a00, 0x1a04, 0x1a08, 0x1a0c, 0x1a10, 0x1a14, +0x1a18, 0x1a28, 0x1a2c, 0x1a30, 0x1a34, 0x1a3c, 0x1a40, 0x1a44, 0x1a48, 0x1a4c, 0x1a50, 0x1a54, 0x1a58, 0x1a5c, 0x1a60, 0x1a68, +0x1a6c, 0x1a70, 0x1a74, 0x1a78, 0x1a7c, 0x1a84, 0x1a88, 0x1a8c, 0x1a90, 0x1a94, 0x1a98, 0x1a9c, 0x1aa0, 0x1aa4, 0x1aa8, 0x1aac, +0x1ab0, 0x1ab4, 0x1ab8, 0x1abc, 0x1ac0, 0x1acc, 0x1ad0, 0x1ad4, 0x1ad8, 0x1adc, 0x1ae0, 0x1ae4, 0x1ae8, 0x1aec, 0x1af0, 0x1af8, +0x1afc, 0x1b00, 0x1b08, 0x1b0c, 0x1b10, 0x1b13, 0x1d00, 0x1d04, 0x1d08, 0x1d0c, 0x1d10, 0x1d14, 0x1d18, 0x1d1c, 0x1d20, 0x1d24, +0x1d28, 0x1d2c, 0x1d30, 0x1d34, 0x1d38, 0x1d3c, 0x1d40, 0x1d44, 0x1d48, 0x1d4c, 0x1d50, 0x1d54, 0x1d58, 0x1d5c, 0x1d60, 0x1d64, +0x1d68, 0x1d6c, 0x1d70, 0x1d74, 0x1d78, 0x1d7c, 0x1d80, 0x1d84, 0x1d88, 0x1d8c, 0x1d90, 0x1d94, 0x1f84, 0x1fa8, 0x1fac, 0x2068, +0x206c, 0x2070, 0x2074, 0x2078, 0x207c, 0x2080, 0x2084, 0x2088, 0x208c, 0x2090, 0x2094, 0x2098, 0x209c, 0x20a0, 0x20a4, 0x20a8, +0x20ac, 0x20b0, 0x20b4, 0x20b8, 0x20bc, 0x20c0, 0x20c4, 0x20c8, 0x20cc, 0x20d0, 0x20d4, 0x20d8, 0x20dc, 0x20e0, 0x20e4, 0x20e8, +0x20ec, 0x20f0, 0x20f4, 0x20f8, 0x20fc, 0x2108, 0x210c, 0x2110, 0x2114, 0x2118, 0x211c, 0x2120, 0x2124, 0x2128, 0x212c, 0x2130, +0x2134, 0x2138, 0x213c, 0x2140, 0x2144, 0x2148, 0x214c, 0x2150, 0x2154, 0x2158, 0x215c, 0x2160, 0x2164, 0x2168, 0x216c, 0x2170, +0x2174, 0x2178, 0x217c, 0x2180, 0x2184, 0x2188, 0x218c, 0x2190, 0x2194, 0x2198, 0x219c, 0x21a8, 0x21ac, 0x21b0, 0x21b4, 0x21b8, +0x21bc, 0x21c0, 0x21c4, 0x21c8, 0x21cc, 0x21d0, 0x21d4, 0x21d8, 0x21dc, 0x21e0, 0x21e4, 0x21e8, 0x21ec, 0x21f0, 0x21f4, 0x21f8, +0x21fc, 0x2200, 0x2204, 0x2208, 0x220c, 0x2210, 0x2214, 0x2218, 0x221c, 0x2220, 0x2224, 0x2228, 0x222c, 0x2230, 0x2234, 0x2238, +0x223c, 0x2248, 0x224c, 0x2250, 0x2254, 0x2258, 0x225c, 0x2260, 0x2264, 0x2268, 0x226c, 0x2270, 0x2274, 0x2278, 0x227c, 0x2280, +0x2284, 0x2288, 0x228c, 0x2290, 0x2294, 0x2298, 0x229c, 0x22a0, 0x22a4, 0x22a8, 0x22ac, 0x22b0, 0x22b4, 0x22b8, 0x22bc, 0x22c0, +0x22c4, 0x22c8, 0x22cc, 0x22d0, 0x22d4, 0x22d8, 0x22dc, 0x22e8, 0x22ec, 0x22f0, 0x22f4, 0x22f8, 0x22fc, 0x2300, 0x2304, 0x2308, +0x230c, 0x2310, 0x2314, 0x2318, 0x231c, 0x2320, 0x2324, 0x2328, 0x232c, 0x2330, 0x2334, 0x2338, 0x233c, 0x2340, 0x2344, 0x2348, +0x234c, 0x2350, 0x2354, 0x2358, 0x235c, 0x2360, 0x2364, 0x2368, 0x236c, 0x2370, 0x2374, 0x2378, 0x237c, 0x2388, 0x238c, 0x2390, +0x2394, 0x2398, 0x239c, 0x23a0, 0x23a4, 0x23a8, 0x23ac, 0x23b0, 0x23b4, 0x23b8, 0x23bc, 0x23c0, 0x23c4, 0x23c8, 0x23cc, 0x23d0, +0x23d4, 0x23d8, 0x23dc, 0x23e0, 0x23e4, 0x23e8, 0x23ec, 0x23f0, 0x23f4, 0x23f8, 0x23fc, 0x2400, 0x2404, 0x2408, 0x240c, 0x2410, +0x2414, 0x2418, 0x241c, 0x2420, 0x2424, 0x24d8, 0x24dc, 0x24e0, 0x24e4, 0x24e8, 0x24ec, 0x24f0, 0x24f4, 0x24f8, 0x24fc, 0x2500, +0x2504, 0x2508, 0x250c, 0x2510, 0x2514, 0x2518, 0x251c, 0x2520, 0x2524, 0x2528, 0x252c, 0x2530, 0x2534, 0x2538, 0x253c, 0x2540, +0x2544, 0x2548, 0x254c, 0x2550, 0x2554, 0x2558, 0x255c, 0x2560, 0x2564, 0x2568, 0x256c, 0x2570, 0x2578, 0x257c, 0x2580, 0x2584, +0x2588, 0x258c, 0x2590, 0x2594, 0x2598, 0x259c, 0x25a0, 0x25a4, 0x25a8, 0x25ac, 0x25b0, 0x25b4, 0x25b8, 0x25bc, 0x25c0, 0x25c4, +0x25c8, 0x25cc, 0x25d0, 0x25d4, 0x25d8, 0x25dc, 0x25e0, 0x25e4, 0x25e8, 0x25ec, 0x25f0, 0x25f4, 0x25f8, 0x25fc, 0x2600, 0x2604, +0x2608, 0x260c, 0x2618, 0x261c, 0x2620, 0x2624, 0x2628, 0x262c, 0x2630, 0x2634, 0x2638, 0x263c, 0x2640, 0x2644, 0x2648, 0x264c, +0x2650, 0x2654, 0x2658, 0x265c, 0x2660, 0x2664, 0x2668, 0x266c, 0x2670, 0x2674, 0x2678, 0x267c, 0x2680, 0x2684, 0x2688, 0x268c, +0x2690, 0x2694, 0x2698, 0x269c, 0x26a0, 0x26a4, 0x26a8, 0x26ac, 0x26b0, 0x26b8, 0x26bc, 0x26c0, 0x26c4, 0x26c8, 0x26cc, 0x26d0, +0x26d4, 0x26d8, 0x26dc, 0x26e0, 0x26e4, 0x26e8, 0x26ec, 0x26f0, 0x26f4, 0x26f8, 0x26fc, 0x2700, 0x2704, 0x2708, 0x270c, 0x2710, +0x2714, 0x2718, 0x271c, 0x2720, 0x2724, 0x2728, 0x272c, 0x2730, 0x2734, 0x2738, 0x273c, 0x2740, 0x2744, 0x2748, 0x274c, 0x2758, +0x275c, 0x2760, 0x2764, 0x2768, 0x2774, 0x2778, 0x277c, 0x2aa8, 0x2ab0, 0x2ab4, 0x2ab8, 0x2abc, 0x2ac0, 0x2ac4, 0x2ac8, 0x2acc, +0x2ad0, 0x2c18, 0x2c20, 0x2c24, 0x2c28, 0x2c2c, 0x2c30, 0x2c34, 0x2c38, 0x2c3c, 0x2c40, 0x2c44, 0x2c48, 0x2c4c, 0x2c50, 0x2c54, +0x2c58, 0x2c5c, 0x2c60, 0x2c64, 0x2c68, 0x2c6c, 0x2c70, 0x2c74, 0x2c78, 0x2c7c, 0x2c80, 0x2c84, 0x2c88, 0x2c8c, 0x2c90, 0x2c94, +0x2c98, 0x2c9c, 0x2ca0, 0x2ca4, 0x2ca8, 0x2cac, 0x2cb0, 0x2cb4, 0x2e00, 0x2e04, 0x2e08, 0x2eb8, 0x2ebc, 0x2ec0, 0x2ec4, 0x2ec8, +0x2ecc, 0x2ed0, 0x2ed4, 0x2ed8, 0x2edc, 0x2ee0, 0x2ee4, 0x2ee8, 0x2eec, 0x2ef0, 0x2ef4, 0x2ef8, 0x2efc, 0x2f00, 0x2f04, 0x2f08, +0x2f0c, 0x2f10, 0x2f14, 0x2f18, 0x2f1c, 0x2f20, 0x2f24, 0x2f28, 0x2f2c, 0x2f30, 0x2f34, 0x2f38, 0x2f3c, 0x2f40, 0x2f44, 0x2f48, +0x2f4c, 0x2f58, 0x2f5c, 0x2f60, 0x2f64, 0x2f68, 0x2f6c, 0x2f70, 0x2f74, 0x2f78, 0x2f7c, 0x2f80, 0x2f84, 0x2f88, 0x2f8c, 0x2f90, +0x2f94, 0x2f98, 0x2f9c, 0x2fa0, 0x2fa4, 0x2fa8, 0x2fac, 0x2fb0, 0x2fb4, 0x2fb8, 0x2fbc, 0x2fc0, 0x2fc4, 0x2fc8, 0x2fcc, 0x2fd0, +0x2fd4, 0x2fd8, 0x2fdc, 0x2fe0, 0x2fe4, 0x2fe8, 0x2fec, 0x2ff8, 0x2ffc, 0x3000, 0x3004, 0x3008, 0x300c, 0x3010, 0x3014, 0x3018, +0x301c, 0x3020, 0x3024, 0x3028, 0x302c, 0x3030, 0x3034, 0x3038, 0x303c, 0x3040, 0x3044, 0x3048, 0x304c, 0x3050, 0x3054, 0x3058, +0x305c, 0x3060, 0x3064, 0x3068, 0x306c, 0x3070, 0x3074, 0x3078, 0x307c, 0x3080, 0x3084, 0x3088, 0x308c, 0x3098, 0x30a0, 0x30a8, +0x30ac, 0x30b0, 0x30bc, 0x30c0, 0x3170, 0x3174, 0x3178, 0x317c, 0x3180, 0x3184, 0x3188, 0x318c, 0x3190, 0x3194, 0x3198, 0x319c, +0x31a0, 0x31a4, 0x31a8, 0x31ac, 0x31b0, 0x31b4, 0x31b8, 0x31bc, 0x31c0, 0x31c4, 0x31c8, 0x31cc, 0x31d0, 0x31d4, 0x31d8, 0x31dc, +0x31e0, 0x31e4, 0x31e8, 0x31ec, 0x31f0, 0x31f4, 0x31f8, 0x31fc, 0x3200, 0x3204, 0x3348, 0x3350, 0x3408, 0x340c, 0x3410, 0x3414, +0x3418, 0x341c, 0x3420, 0x3424, 0x3428, 0x342c, 0x3430, 0x3434, 0x3438, 0x343c, 0x3440, 0x3444, 0x3448, 0x344c, 0x3450, 0x3454, +0x3458, 0x345c, 0x3460, 0x3464, 0x3468, 0x346c, 0x3470, 0x3474, 0x3478, 0x347c, 0x3480, 0x3484, 0x3488, 0x348c, 0x3490, 0x3494, +0x3498, 0x349c, 0x34a8, 0x34ac, 0x34b0, 0x34b4, 0x34b8, 0x34bc, 0x34c0, 0x34c4, 0x34c8, 0x34cc, 0x34d0, 0x34d4, 0x34d8, 0x34dc, +0x34e0, 0x34e4, 0x34e8, 0x34ec, 0x34f0, 0x34f4, 0x34f8, 0x34fc, 0x3500, 0x3504, 0x3508, 0x350c, 0x3510, 0x3514, 0x3518, 0x351c, +0x3520, 0x3524, 0x3528, 0x352c, 0x3530, 0x3534, 0x3538, 0x353c, 0x3544, 0x3548, 0x354c, 0x3550, 0x355c, 0x3560, 0x3564, 0x3568, +0x356c, 0x3570, 0x3574, 0x3578, 0x357c, 0x3580, 0x3584, 0x3588, 0x3630, 0x3638, 0x363c, 0x3640, 0x3644, 0x3648, 0x364c, 0x3650, +0x3654, 0x3658, 0x365c, 0x3660, 0x3664, 0x3668, 0x366c, 0x3670, 0x3674, 0x3678, 0x367c, 0x3680, 0x3684, 0x3688, 0x368c, 0x3690, +0x3694, 0x3698, 0x369c, 0x36a0, 0x36a4, 0x36a8, 0x36ac, 0x36b0, 0x36b4, 0x36b8, 0x36bc, 0x36c0, 0x36c4, 0x36c8, 0x36cc, 0x36d0, +0x36d8, 0x36dc, 0x36e0, 0x36e4, 0x36e8, 0x36ec, 0x36f0, 0x36f4, 0x36f8, 0x36fc, 0x3700, 0x3704, 0x3708, 0x370c, 0x3710, 0x3714, +0x3718, 0x371c, 0x3720, 0x3724, 0x3728, 0x372c, 0x3730, 0x3734, 0x3738, 0x373c, 0x3740, 0x3744, 0x3748, 0x374c, 0x3750, 0x3754, +0x3758, 0x375c, 0x3760, 0x3764, 0x3768, 0x376c, 0x3770, 0x3774, 0x3778, 0x377c, 0x3780, 0x3784, 0x3788, 0x378c, 0x3790, 0x3794, +0x3798, 0x379c, 0x37a0, 0x37a4, 0x37a8, 0x37ac, 0x3858, 0x385c, 0x3860, 0x3864, 0x3868, 0x386c, 0x3870, 0x3874, 0x3878, 0x387c, +0x3880, 0x3884, 0x3888, 0x388c, 0x3890, 0x3894, 0x3898, 0x389c, 0x38a0, 0x38a4, 0x38a8, 0x38ac, 0x38b0, 0x38b4, 0x38b8, 0x38bc, +0x38c0, 0x38c4, 0x38c8, 0x38cc, 0x38d0, 0x38d4, 0x38d8, 0x38dc, 0x38e0, 0x38e4, 0x38e8, 0x38ec, 0x38f8, 0x38fc, 0x3900, 0x3904, +0x3908, 0x390c, 0x3910, 0x3914, 0x3918, 0x391c, 0x3920, 0x3924, 0x3928, 0x392c, 0x3930, 0x3934, 0x3938, 0x393c, 0x3940, 0x3944, +0x3948, 0x394c, 0x3950, 0x3954, 0x3958, 0x395c, 0x3960, 0x3964, 0x3968, 0x396c, 0x3970, 0x3974, 0x3978, 0x397c, 0x3980, 0x3984, +0x3988, 0x398c, 0x3994, 0x3998, 0x399c, 0x39a0, 0x39a8, 0x39ac, 0x39b0, 0x3c64, 0x3c6c, 0x3c70, 0x3c74, 0x3c78, 0x3c7c, 0x3c80, +0x3c84, 0x3c88, 0x3c8c, 0x3c94, 0x3c98, 0x3ca0, 0x3ca4, 0x3cac, 0x3cb0, 0x3cb4, 0x3cb8, 0x3cbc, 0x3cc0, 0x3cc4, 0x3cd0, 0x3cd8, +0x3cdc, 0x3ce8, 0x3cf4, 0x3cf8, 0x3d00, 0x3d08, 0x3d0c, 0x3d10, 0x3d18, 0x3d1c, 0x3d24, 0x3d2c, 0x3d30, 0x3d34, 0x3d38, 0x3d3c, +0x3d44, 0x3d54, 0x3d60, 0x3d68, 0x3d70, 0x3d74, 0x3d78, 0x3d7c, 0x3d80, 0x3d88, 0x3d8c, 0x3d90, 0x3d98, 0x3ee8, 0x3ef4, 0x3ef8, +0x3efc, 0x3f04, 0x3f0c, 0x3f24, 0x3f30, 0x3f38, 0x3f3c, 0x3f40, 0x3f44, 0x3f50, 0x3f90, 0x3f98, 0x3fb8, 0x3fc8, 0x3fcc, 0x3fd0, +0x3fd4, 0x3fd8, 0x3fdc, 0x3fe0, 0x3fe4, 0x3fec, 0x3ffc, 0x4000, 0x4004, 0x4008, 0x400c, 0x4010, 0x4014, 0x4018, 0x401c, 0x4020, +0x4024, 0x4028, 0x4034, 0x4038, 0x4040, 0x4044, 0x4050, 0x4058, 0x405c, 0x4060, 0x4064, 0x4068, 0x406c, 0x4070, 0x4074, 0x4078, +0x407c, 0x4080, 0x4084, 0x4088, 0x408c, 0x4090, 0x4094, 0x4098, 0x409c, 0x40a0, 0x40a4, 0x40a8, 0x40ac, 0x40b0, 0x40b4, 0x40b8, +0x40bc, 0x40c0, 0x40c4, 0x40cc, 0x40d0, 0x40d4, 0x40d8, 0x40dc, 0x40e0, 0x40e4, 0x40e8, 0x40ec, 0x40f0, 0x40f4, 0x40f8, 0x40fc, +0x4100, 0x4104, 0x4108, 0x4138, 0x413c, 0x4140, 0x4148, 0x414c, 0x4150, 0x4154, 0x415c, 0x4160, 0x4170, 0x4174, 0x4178, 0x417c, +0x4180, 0x4184, 0x4188, 0x4190, 0x4198, 0x41a8, 0x41b0, 0x41b8, 0x41c0, 0x41c4, 0x41c8, 0x41cc, 0x41d0, 0x41d4, 0x41d8, 0x41dc, +0x41e0, 0x41e8, 0x41f0, 0x41f4, 0x41f8, 0x41fc, 0x4200, 0x4204, 0x4208, 0x4210, 0x4218, 0x421c, 0x4220, 0x4224, 0x4228, 0x422c, +0x4230, 0x4238, 0x423c, 0x4240, 0x4248, 0x4258, 0x425c, 0x426c, 0x4270, 0x4280, 0x4284, 0x4294, 0x4298, 0x42a8, 0x42d4, 0x42e4, +0x42fc, 0x430c, 0x4310, 0x4320, 0x4324, 0x4334, 0x434c, 0x4350, 0x4354, 0x435c, 0x4364, 0x436c, 0x4370, 0x4378, 0x437c, 0x4380, +0x4384, 0x438c, 0x4394, 0x4398, 0x439c, 0x43a8, 0x43ac, 0x43b4, 0x43b8, 0x43c0, 0x43cc, 0x43cd, 0x43ce, 0x43cf, 0x43d0, 0x43d1, +0x43d2, 0x43d3, 0x43d8, 0x43dc, 0x43e0, 0x43e4, 0x43e8, 0x43f8, 0x43fc, 0x4414, 0x4418, 0x441c, 0x4420, 0x4430, 0x4438, 0x443c, +0x4440, 0x4450, 0x4454, 0x4458, 0x445c, 0x4460, 0x4464, 0x4468, 0x446c, 0x4470, 0x4474, 0x4478, 0x447c, 0x4480, 0x4484, 0x4488, +0x448c, 0x4490, 0x4494, 0x4498, 0x449c, 0x44a0, 0x44a4, 0x44a8, 0x44b0, 0x44b4, 0x44b8, 0x44bc, 0x44c0, 0x44c4, 0x44c8, 0x44d8, +0x44dc, 0x44e0, 0x44e4, 0x44e8, 0x44ec, 0x44f0, 0x44f4, 0x44f8, 0x44fc, 0x4500, 0x4504, 0x4508, 0x450c, 0x4510, 0x4514, 0x4518, +0x451c, 0x4520, 0x4524, 0x4528, 0x4530, 0x4534, 0x4538, 0x4544, 0x4548, 0x454c, 0x4554, 0x4560, 0x4564, 0x456c, 0x4570, From a7415f63bf09eddc71ad04e01d9269437039ef70 Mon Sep 17 00:00:00 2001 From: andrewnakas Date: Sat, 3 Oct 2026 11:24:11 -0600 Subject: [PATCH 003/109] Trainer: number this release 0.1.2 (0.1.1 was a packaging-only upload) Co-Authored-By: Claude Opus 5.5 (1M context) --- Extension/Trainer/CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Extension/Trainer/CHANGELOG.md b/Extension/Trainer/CHANGELOG.md index ba372dec..92f194a7 100644 --- a/Extension/Trainer/CHANGELOG.md +++ b/Extension/Trainer/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## v0.1.1 - 2026-10-03 +## v0.1.2 - 2026-10-03 - **Tune no longer offers values that do nothing.** A pass over the game's code found which tuning values it reads (527 of the 930 rows). The rest, including the Mode ollie heights and the hippy @@ -12,6 +12,10 @@ were rebuilt on the ones it does. - The Tune tab says that changes apply as you drag and that the box only locks a value. +## v0.1.1 - 2026-10-03 + +Packaging only (Thunderstore): `Install.bat` and `Uninstall.bat`. The trainer was unchanged. + ## v0.1.0 - 2026-10-03 First release, built on ReSkate 1.0.3. From a4cc112d98ba8b2368316a528e439e6650f9a767 Mon Sep 17 00:00:00 2001 From: andrewnakas Date: Sat, 3 Oct 2026 11:34:29 -0600 Subject: [PATCH 004/109] Trainer 0.1.2: link the ollie height and spin speed values to the graphs the game reads JumpMaxHeight, JumpMinHeight and MaxSpinSpeed are not read by the game. Editing one now scales the jump-height or body-spin graphs by the same ratio, so they work as plain values; presets go through them. A preset reports the locked values it left alone. Co-Authored-By: Claude Opus 5.5 (1M context) --- Extension/Trainer/CHANGELOG.md | 8 +++- Extension/Trainer/README.md | 5 ++- Extension/Trainer/trainer.cpp | 58 +++++++++++++++++++++++---- Extension/Trainer/trainer_presets.cpp | 45 +++++++++++++-------- Extension/Trainer/trainer_presets.h | 7 ++++ 5 files changed, 95 insertions(+), 28 deletions(-) diff --git a/Extension/Trainer/CHANGELOG.md b/Extension/Trainer/CHANGELOG.md index 92f194a7..3b01d864 100644 --- a/Extension/Trainer/CHANGELOG.md +++ b/Extension/Trainer/CHANGELOG.md @@ -5,8 +5,12 @@ - **Tune no longer offers values that do nothing.** A pass over the game's code found which tuning values it reads (527 of the 930 rows). The rest, including the Mode ollie heights and the hippy jump heights people tried, are hidden unless you tick "Values with no use found", and are marked. -- The Essentials list now only holds values the game reads: ollie height is the jump-height graph - multiplier, body flip and body spin speed are the ones the quick switches use. +- **Ollie height and body spin speed are plain values again.** The game ignores its own + `JumpMaxHeight`, `JumpMinHeight` and `MaxSpinSpeed`; the trainer now links them to the graphs the + game does read, so setting ollie height to twice its stock value doubles the jump graphs. +- The Essentials list only holds values that do something: ollie height, body flip speed, body + spin speed, pushing speed, grind lock-on and more. +- A preset says when it skipped values you locked. - Every preset can be switched off again by itself; presets that are still on keep their values. - Presets no longer contain rules for values the game does not read. Mega Pop and No Speed Wobble were rebuilt on the ones it does. diff --git a/Extension/Trainer/README.md b/Extension/Trainer/README.md index 64089ab1..0bd92baf 100644 --- a/Extension/Trainer/README.md +++ b/Extension/Trainer/README.md @@ -8,7 +8,7 @@ telemetry HUD. It ships no game data: the list of values is built at run time fr | Tab | What you get | |---|---| -| **TUNE** | An Essentials list of 22 plainly named values (ollie height, top speed, body flip and spin speed, grind lock-on...), then every plain value of the game's physics tuning (about 760), plus one multiplier for each of its 169 curves and graphs. Search, filter by group, "only what I changed", lock, reset. Changes apply while you skate. Values that no game code was found reading are hidden unless you ask for them. | +| **TUNE** | An Essentials list of 21 plainly named values (ollie height, top speed, body flip and spin speed, grind lock-on...), then every plain value of the game's physics tuning (about 760), plus one multiplier for each of its 169 curves and graphs. Search, filter by group, "only what I changed", lock, reset. Changes apply while you skate. Values that no game code was found reading are hidden unless you ask for them. | | **PRESETS** | Quick switches for super high ollie, fast flips, fast spins and never bail. Built-in presets you switch on and off, and that stack (Super Ollie, Fast Flips, Fast Spins, Mega Pop, Fast, Hard To Bail, Sticky Grinds, ...), your own saved presets, and a preset a map applies every time it loads. | | **PRACTICE** | Game speed and pause, five marker slots per map (save / go / clear), return to the marker after a bail, teleport to coordinates, copy your position (game or Blender axes). | | **MAP & HUD** | Speed and air-time HUD, a read-out after every jump (takeoff speed and angle, height, distance, drop, landing speed), telemetry recording to CSV, and whatever the map's author ships for the trainer. | @@ -85,7 +85,8 @@ folder>` (CMake option `DINGOSDK_BUILD_TRAINER_TESTS`) lists the tuning values w mod's points. - Not every tuning value is used by the game: about 4 in 10 rows have no code reading them (`trainer_used.inc`, found by a static pass over the game's code for this build). Ollie height - comes from the `PhysicsJump` height graphs (the `PhysicsMode` jump heights are never read), body + comes from the `PhysicsJump` height graphs (the `PhysicsMode` jump heights are never read, so the + trainer links them to those graphs: `value_links` in `trainer_presets.cpp`), body flips from `PhysicsReckoning.FlipScalar` and `FlipMaxSpeed` (`PerfectBodyFlips` forces exactly one rotation and ignores them), body spins from the `PhysicsBodyspin` graphs. "No use found" is not proof: the pass can miss a use. diff --git a/Extension/Trainer/trainer.cpp b/Extension/Trainer/trainer.cpp index 0644e69d..fc84196b 100644 --- a/Extension/Trainer/trainer.cpp +++ b/Extension/Trainer/trainer.cpp @@ -42,7 +42,8 @@ struct Entry { double value{}, stock{}; bool touched{}, frozen{}; bool detail{}; - bool used{}; // the game's code was found to read it (trainer_used.inc) + bool used{}; // the game's code was found to read it (trainer_used.inc), or it is linked + std::vector drives; // graph multipliers this value scales (value_links) std::vector members; // graph: its outputs (Y0.., Min_Y, Max_Y) }; struct MapData { @@ -371,6 +372,14 @@ void set_entry(Entry &e, double value) { e.touched = e.value != e.stock; apply_entry(e); remember(e); + // A linked value scales its graphs by the same ratio (the graphs are what the game reads). + if (!e.drives.empty() && e.stock != 0) { + const auto ratio = e.value / e.stock; + const auto targets = e.drives; // set_entry may not touch `e` again, but keep the list stable + auto &s = state(); + for (const auto index : targets) + if (index < s.entries.size() && !s.entries[index].frozen) set_entry(s.entries[index], ratio); + } } Entry *find_entry(std::string_view id) { auto &s = state(); @@ -436,6 +445,12 @@ void build_entries() { add(parent, Kind::graph, offset, 0, 0); s.entries.back().members = std::move(outputs); } + for (const auto &link : value_links()) { + const auto from = s.index.find(link.value), to = s.index.find(link.drives); + if (from == s.index.end() || to == s.index.end()) continue; + s.entries[from->second].drives.push_back(to->second); + s.entries[from->second].used = true; + } say(logging::Level::info, std::format("Trainer: {} tuning values and {} curves are editable.", m.fields.size(), m.curve_slots.size())); } // A new copy of the asset (first sight, or a level load): its values are the stock ones. @@ -455,6 +470,21 @@ void adopt(const tuning::Values &live) { } if (e.touched) apply_entry(e); } + // A linked value saved by itself (before links existed, or edited in the file) still + // drives its graphs. + for (auto &e : s.entries) { + if (e.drives.empty() || !e.touched || e.stock == 0) continue; + for (const auto index : e.drives) { + auto &target = s.entries[index]; + if (target.touched || target.frozen) continue; + target.value = sane(target, e.value / e.stock); + target.touched = target.value != target.stock; + if (target.touched) { + apply_entry(target); + remember(target); + } + } + } s.write_due = true; changed(false); } @@ -543,9 +573,18 @@ std::string remove_preset(std::string_view name) { std::size_t count{}; std::string removed; const auto put_back = [&](Entry &e) { - if (e.frozen || !e.touched) return; - set_entry(e, e.stock); - ++count; + if (e.frozen) return; + if (e.touched) { + set_entry(e, e.stock); + ++count; + return; + } + // A linked value at stock whose graphs were set directly (by an older version's preset). + for (const auto index : e.drives) + if (auto &target = s.entries[index]; target.touched && !target.frozen) { + set_entry(target, target.stock); + ++count; + } }; for (const auto &[user_name, values] : s.user) { if (lower(user_name) != wanted) continue; @@ -593,7 +632,7 @@ std::string apply_preset(std::string_view name) { changed(); return std::format("Stock: {} values put back (frozen values kept).", count); } - std::size_t count{}; + std::size_t count{}, locked{}; std::string applied; if (wanted == "map") { if (s.map_file.preset.empty()) return "error: this map ships no trainer preset."; @@ -615,8 +654,12 @@ std::string apply_preset(std::string_view name) { for (const auto &rule : preset.rules) for (auto &e : s.entries) { const bool scale = e.kind == Kind::curve || e.kind == Kind::graph; - if (e.frozen || e.detail || scale != rule.curves || !matches(e.key, rule.pattern)) continue; + if (e.detail || scale != rule.curves || !matches(e.key, rule.pattern)) continue; if (e.kind == Kind::flag && rule.multiply) continue; + if (e.frozen) { + ++locked; + continue; + } set_entry(e, rule.multiply ? e.stock * rule.amount : rule.amount); ++count; } @@ -626,7 +669,8 @@ std::string apply_preset(std::string_view name) { if (applied.empty()) return "error: no preset is called \"" + std::string(name) + "\"."; if (std::ranges::find(s.active, applied) == s.active.end()) s.active.push_back(applied); changed(); - return std::format("{}: {} values set.", applied, count); + return std::format("{}: {} values set{}.", applied, count, + locked ? std::format("; {} locked values left alone (untick their boxes in Tune to let presets change them)", locked) : ""); } // ---- maps ------------------------------------------------------------------------------ diff --git a/Extension/Trainer/trainer_presets.cpp b/Extension/Trainer/trainer_presets.cpp index d721b876..cc8e36af 100644 --- a/Extension/Trainer/trainer_presets.cpp +++ b/Extension/Trainer/trainer_presets.cpp @@ -12,24 +12,36 @@ bool value_used(std::uint16_t offset) { }; return std::binary_search(std::begin(used), std::end(used), offset); } -// Only values the game was found to read: a slider that does nothing is worse than none. +// The game ignores these named values; the graphs on the right are what it reads. Linking them +// lets "ollie height" and "spin speed" be plain numbers instead of graph multipliers. +const std::vector &value_links() { + static const std::vector links{ + {"physicsmode.jumpmaxheight", "physicsjump.maxheightvsspeed x"}, + {"physicsmode.jumpminheight", "physicsjump.minheightvsspeed x"}, + {"physicsairstates.maxspinspeed", "physicsbodyspin.propbodyspinvstime x"}, + {"physicsairstates.maxspinspeed", "physicsbodyspin.pbsvst_easy x"}, + {"physicsairstates.maxspinspeed", "physicsbodyspin.maxdeltavstime x"}, + {"physicsairstates.maxspinspeed", "physicsbodyspin.maxdeltavstimeeasy x"}, + }; + return links; +} +// Only values that do something: read by the game, or linked above. std::string_view essential_name(std::string_view key, int *rank) { - static constexpr std::array, 22> names{{ - {"physicsjump.maxheightvsspeed x", "Ollie height (x)"}, - {"physicsjump.minheightvsspeed x", "Ollie height, light pop (x)"}, + static constexpr std::array, 21> names{{ + {"physicsmode.jumpmaxheight", "Ollie height (max)"}, + {"physicsmode.jumpminheight", "Ollie height (min, light pop)"}, {"physicsmode.grindjumpcommonmax", "Pop out of grinds (max)"}, {"physicsjump.jumpybonusmax", "Jump bonus (max)"}, + {"physicsreckoning.flipscalar", "Body flip speed"}, + {"physicsreckoning.flipmaxspeed", "Body flip speed limit"}, + {"physicsmode.perfectbodyflips", "Perfect body flips (exactly one rotation)"}, + {"physicsairstates.maxspinspeed", "Body spin speed"}, + {"physicsmode.maxautobodyspinspeed", "Auto body spin speed"}, + {"physicsmode.easybodyspins", "Easy body spins"}, {"physicspush.maxpushablespeed", "Top pushing speed (m/s)"}, {"physicsmode.maxpushdvstart", "Push strength"}, {"physicsmode.autopushenabled", "Auto push"}, {"physicsmode.speedwobblestartspeed", "Speed wobble starts at (m/s)"}, - {"physicsreckoning.flipscalar", "Body flip speed"}, - {"physicsreckoning.flipmaxspeed", "Body flip speed limit"}, - {"physicsmode.perfectbodyflips", "Perfect body flips (exactly one rotation)"}, - {"physicsbodyspin.propbodyspinvstime x", "Body spin speed (x)"}, - {"physicsbodyspin.maxdeltavstime x", "Body spin acceleration (x)"}, - {"physicsmode.maxautobodyspinspeed", "Auto body spin speed"}, - {"physicsmode.easybodyspins", "Easy body spins"}, {"physicsmode.grindlockdist", "Grind lock-on distance"}, {"physicsgrind.commonfrictionscalar", "Grind friction"}, {"physicsmode.makesurfacessmooth", "Smooth surfaces"}, @@ -48,22 +60,21 @@ std::string_view essential_name(std::string_view key, int *rank) { // whatever build is running and simply skips the ones that build lacks. const std::vector &builtin_presets() { static const std::vector presets{ - // Rules name values the game was found to read (trainer_used.inc). Jump height comes - // from the PhysicsJump height graphs (the Mode jump heights are never read); body flips + // Rules name values the game was found to read (trainer_used.inc) or linked values + // (value_links). Jump height comes from the PhysicsJump height graphs; body flips // from FlipScalar and FlipMaxSpeed unless PerfectBodyFlips forces exactly one rotation; // body spins from the PhysicsBodyspin graphs and MaxAutoBodySpinSpeed. {"Super Ollie", "Huge ollies: about three times the height at any speed.", - {{"physicsjump.maxheightvsspeed", true, 3.0, true}, {"physicsjump.minheightvsspeed", true, 3.0, true}, + {{"physicsmode.jumpmaxheight", true, 3.0}, {"physicsmode.jumpminheight", true, 3.0}, {"physicsjump.absoluteminheight", true, 2.0}, {"physicsjump.jumpybonusmax", true, 3.0}, {"physicsmode.grindjump", true, 2.5}}}, {"Fast Flips", "Front flips and back flips rotate three times as fast.", {{"physicsreckoning.flipscalar", true, 3.0}, {"physicsreckoning.flipmaxspeed", true, 3.0}, {"physicsmode.perfectbodyflips", false, 0.0}}}, {"Fast Spins", "Body spins rotate three times as fast.", - {{"physicsbodyspin.propbodyspinvstime", true, 3.0, true}, {"physicsbodyspin.pbsvst_easy", true, 3.0, true}, - {"physicsbodyspin.maxdeltavstime", true, 3.0, true}, {"physicsmode.maxautobodyspinspeed", true, 3.0}}}, + {{"physicsairstates.maxspinspeed", true, 3.0}, {"physicsmode.maxautobodyspinspeed", true, 3.0}}}, {"Mega Pop", "Ollies and grind pops go about twice as high.", - {{"physicsjump.maxheightvsspeed", true, 2.0, true}, {"physicsjump.minheightvsspeed", true, 1.6, true}, + {{"physicsmode.jumpmaxheight", true, 2.0}, {"physicsmode.jumpminheight", true, 1.6}, {"physicsjump.jumpybonusmax", true, 2.0}, {"physicsmode.grindjump", true, 1.6}}}, {"Fast", "Push to a higher top speed and get there sooner.", {{"physicspush.maxpushablespeed !camera", true, 1.8}, {"physicspush.maxspeedforautopush", true, 1.8}, diff --git a/Extension/Trainer/trainer_presets.h b/Extension/Trainer/trainer_presets.h index c998564e..c593e2ab 100644 --- a/Extension/Trainer/trainer_presets.h +++ b/Extension/Trainer/trainer_presets.h @@ -22,6 +22,13 @@ std::string_view essential_name(std::string_view key, int *rank = nullptr); // Whether the game's code was found to read the tuning value at this offset of the asset. // False means "no use found": changing it will probably do nothing. bool value_used(std::uint16_t offset); +// Values the game does not read itself but that the trainer makes work: changing one scales +// the graphs that really control the behaviour by the same ratio (value / stock). +struct Link { + std::string_view value; // lower-case id of the value the player edits + std::string_view drives; // lower-case id of a graph multiplier it scales +}; +const std::vector &value_links(); // Presets stack: each applies on top of what is already changed; Stock clears them. const std::vector &builtin_presets(); } // namespace dingosdk::trainer From 013d131c692d009bf118bd788a583345f6abbaa4 Mon Sep 17 00:00:00 2001 From: andrewnakas Date: Sat, 3 Oct 2026 12:44:28 -0600 Subject: [PATCH 005/109] Trainer 0.1.3: hippy jump height, Realistic preset, spin and flip in the jump read-out The hippy jump's height is set by the game's trick scripts, not by the tuning asset (traced: no tuning value is read for it). The trainer recognises one starting (riding -> off board, rising) and scales the off-board upward velocity through the existing off-board velocity path; a plain dismount is not rising and is left alone. The jump read-out gains degrees of spin (and the peak rate) and of body tumble. The table of tuning values the game reads now includes offsets seen in traced play sessions. Co-Authored-By: Claude Opus 5.5 (1M context) --- Extension/Skater/client_noclip.cpp | 55 ++++++ Extension/Skater/client_source_spawn.h | 11 ++ Extension/Skater/offboard_flight.cpp | 20 +++ Extension/Skater/offboard_flight.h | 5 + Extension/Trainer/CHANGELOG.md | 13 ++ Extension/Trainer/README.md | 6 +- Extension/Trainer/trainer.cpp | 70 +++++++- Extension/Trainer/trainer.h | 5 + Extension/Trainer/trainer_commands.cpp | 2 +- Extension/Trainer/trainer_page.cpp | 26 +++ Extension/Trainer/trainer_presets.cpp | 7 + Extension/Trainer/trainer_used.inc | 229 +++++++++++++------------ 12 files changed, 337 insertions(+), 112 deletions(-) diff --git a/Extension/Skater/client_noclip.cpp b/Extension/Skater/client_noclip.cpp index ec02861c..2d20fabf 100644 --- a/Extension/Skater/client_noclip.cpp +++ b/Extension/Skater/client_noclip.cpp @@ -181,10 +181,41 @@ void noclip_apply_velocity(std::uintptr_t core) noexcept { } catch (const SourceGuard& issue) { debug_stop_noclip(debug); debug.status = issue.message; } catch (...) { debug_stop_noclip(debug); debug.status = "Flight stopped after a physics error."; } } +struct JumpScale { + std::atomic pending{}; + std::uintptr_t client{}, entity{}, core{}; + ULONGLONG expires{}; + float factor{1}; + std::atomic outcome{}; + std::atomic up_speed{}; +}; +JumpScale& jump_scale() { static auto* value = new JumpScale; return *value; } +// The trainer's hippy jump height: scale the upward velocity the game gave the off-board skater. +void trainer_apply_jump_scale(std::uintptr_t core) noexcept { + auto& j = jump_scale(); + if (!j.pending.load(std::memory_order_acquire) || j.core != core) return; + SourceLastError error; + auto& state = source_state(); + if (!state.initialized.load(std::memory_order_acquire) || state.busy.test_and_set(std::memory_order_acquire)) return; + SourceBusyScope scope{state.busy}; + j.pending.store(false, std::memory_order_release); + try { + if (GetTickCount64() >= j.expires) { j.outcome.store(-3); return; } + const auto bodies = debug_noclip_bodies(state.trial.base, j.client, j.entity); + source_require(bodies.core == core, "Skater physics was replaced."); + // On the board the game drives a jump along its own path and undoes a velocity change. + if (!bodies.offboard) { j.outcome.store(-2); return; } + float before{}; + const bool scaled = scale_offboard_up_velocity(state.trial.base, core, bodies.context, bodies.rig_wrapper, j.factor, &before); + j.up_speed.store(before); + j.outcome.store(scaled ? 2 : before < 0.5f ? -1 : -2); + } catch (...) { j.outcome.store(-3); } +} void noclip_physics_update(std::uintptr_t core) { const auto original = source_state().velocity_update_original.load(std::memory_order_acquire); if (original) original(core); noclip_apply_velocity(core); + trainer_apply_jump_scale(core); } bool noclip_motion_target(std::uintptr_t rig, std::uintptr_t context, const std::array* supplied, std::array& target) noexcept { @@ -247,6 +278,30 @@ void noclip_skater_motion(std::uintptr_t rig, std::uintptr_t context, namespace dingosdk { using namespace client_source::detail; +bool queue_jump_scale(std::uintptr_t client, std::uintptr_t entity, float factor) noexcept { + SourceLastError error; + try { + auto& state = source_state(); + auto& j = jump_scale(); + if (!state.initialized.load(std::memory_order_acquire) || !state.velocity_guard_active.load(std::memory_order_acquire) || + !std::isfinite(factor) || factor <= 0 || factor > 20 || j.pending.load(std::memory_order_acquire)) return false; + const auto bodies = debug_noclip_bodies(state.trial.base, client, entity); + j.client = client; + j.entity = entity; + j.core = bodies.core; + j.factor = factor; + j.expires = GetTickCount64() + 150; + j.outcome.store(0); + j.pending.store(true, std::memory_order_release); + return true; + } catch (...) { return false; } +} +JumpScaleResult take_jump_scale_result() noexcept { + auto& j = jump_scale(); + if (j.pending.load(std::memory_order_acquire)) return {}; + return {j.outcome.exchange(0), j.up_speed.load()}; +} + bool start_client_noclip_velocity(std::uintptr_t base) noexcept { SourceLastError error; try { diff --git a/Extension/Skater/client_source_spawn.h b/Extension/Skater/client_source_spawn.h index 3401d3b1..9397f65d 100644 --- a/Extension/Skater/client_source_spawn.h +++ b/Extension/Skater/client_source_spawn.h @@ -9,6 +9,17 @@ namespace dingosdk { void initialize_client_source_spawn(std::uintptr_t base); // Installs the exact camera-reset handler guard; forwarding is unchanged until a Noclip request is queued. bool start_client_noclip_velocity(std::uintptr_t base) noexcept; +// Trainer: at the local skater's next physics update, multiply its upward velocity by +// `factor` if it is off the board and rising (a hippy jump that has just started). One request +// at a time; it expires after 150 ms. +bool queue_jump_scale(std::uintptr_t client, std::uintptr_t entity, float factor) noexcept; +// What the last request did: 0 pending or none, 2 scaled, -1 the skater was not rising yet, +// -2 nothing to scale (on the board, or a state that keeps no velocity), -3 it failed. +struct JumpScaleResult { + int outcome{}; + float up_speed{}; // before scaling +}; +JumpScaleResult take_jump_scale_result() noexcept; // Engine-thread-only interactive controls. Presentation callbacks only queue requests. overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t client, bool can_control, bool camera_phase_observed, const overlay::DebugRequest* request = nullptr, diff --git a/Extension/Skater/offboard_flight.cpp b/Extension/Skater/offboard_flight.cpp index ae8ecc9c..7ce03065 100644 --- a/Extension/Skater/offboard_flight.cpp +++ b/Extension/Skater/offboard_flight.cpp @@ -35,6 +35,26 @@ bool offboard_flight_compatible(std::uintptr_t base) noexcept { } return true; } +bool scale_offboard_up_velocity(std::uintptr_t base, std::uintptr_t core, std::uintptr_t context, + std::uintptr_t rig, float factor, float* before) noexcept { + FlightLastError error; + if (before) *before = 0; + if (!object(base) || !object(context) || !object(rig) || !std::isfinite(factor) || factor <= 0 || factor > 20 || + link(core) != base + addr::no_bail::bail_core_vtable || link(core, 0x3c0) != context || link(core, 0x438) != rig) return false; + const auto parent = link(core, 0x3b0); + if (link(parent) != base + offboard_flight_vtable || link(parent, 8) != context) return false; + const auto active = link(parent, 0x48); + for (const auto& state : offboard_flight_states) { + if (active != parent + state.offset) continue; + std::array velocity{}; + if (link(active) != base + state.vtable_rva || !memory::read(active + 0x10, velocity)) return false; + if (before) *before = velocity[1]; + if (!std::isfinite(velocity[1]) || velocity[1] < 0.5f) return false; // not a jump (yet) + velocity[1] *= factor; + return sync_offboard_flight_velocity(base, core, context, rig, velocity); + } + return false; +} bool sync_offboard_flight_velocity(std::uintptr_t base, std::uintptr_t core, std::uintptr_t context, std::uintptr_t rig, const std::array& velocity) noexcept { FlightLastError error; diff --git a/Extension/Skater/offboard_flight.h b/Extension/Skater/offboard_flight.h index 889560b3..ebbe8b39 100644 --- a/Extension/Skater/offboard_flight.h +++ b/Extension/Skater/offboard_flight.h @@ -8,4 +8,9 @@ bool offboard_flight_compatible(std::uintptr_t base) noexcept; // input. Synchronize the active falling/ground state, preserving its fourth lane. bool sync_offboard_flight_velocity(std::uintptr_t base, std::uintptr_t core, std::uintptr_t context, std::uintptr_t rig, const std::array& velocity) noexcept; +// Multiplies the upward part of the active off-board state's velocity, once (a jump that has +// just started). False when the skater is not rising or the active state keeps no velocity +// of its own (animation, trajectory, ragdoll). `before` receives the upward speed found. +bool scale_offboard_up_velocity(std::uintptr_t base, std::uintptr_t core, std::uintptr_t context, + std::uintptr_t rig, float factor, float* before) noexcept; } diff --git a/Extension/Trainer/CHANGELOG.md b/Extension/Trainer/CHANGELOG.md index 3b01d864..2384f845 100644 --- a/Extension/Trainer/CHANGELOG.md +++ b/Extension/Trainer/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v0.1.3 - 2026-10-03 + +- **Hippy jump height.** A slider on the Tune tab (top of Essentials) and the Presets tab. The game + sets this jump in its trick scripts, not in its tuning, so the trainer recognises a hippy jump + starting and scales the skater's upward speed. +- **Realistic preset:** lower pop, slower pushing and rotation, earlier speed wobble, easier bails. +- **Spin and flip in the jump read-out:** degrees turned about the vertical (and the peak rate) and + degrees the body tumbled, in the HUD card, the Map & HUD tab and the log. +- More tuning values are recognised as used: the table now also includes everything the game read + in traced play sessions (584 of 930 rows, was 527). +- No comply and boneless heights are not adjustable yet: the game drives those jumps along a + scripted path that a velocity change does not move. + ## v0.1.2 - 2026-10-03 - **Tune no longer offers values that do nothing.** A pass over the game's code found which tuning diff --git a/Extension/Trainer/README.md b/Extension/Trainer/README.md index 0bd92baf..8aec6edc 100644 --- a/Extension/Trainer/README.md +++ b/Extension/Trainer/README.md @@ -9,9 +9,9 @@ telemetry HUD. It ships no game data: the list of values is built at run time fr | Tab | What you get | |---|---| | **TUNE** | An Essentials list of 21 plainly named values (ollie height, top speed, body flip and spin speed, grind lock-on...), then every plain value of the game's physics tuning (about 760), plus one multiplier for each of its 169 curves and graphs. Search, filter by group, "only what I changed", lock, reset. Changes apply while you skate. Values that no game code was found reading are hidden unless you ask for them. | -| **PRESETS** | Quick switches for super high ollie, fast flips, fast spins and never bail. Built-in presets you switch on and off, and that stack (Super Ollie, Fast Flips, Fast Spins, Mega Pop, Fast, Hard To Bail, Sticky Grinds, ...), your own saved presets, and a preset a map applies every time it loads. | +| **PRESETS** | Quick switches for super high ollie, fast flips, fast spins and never bail, and a hippy jump height slider. Built-in presets you switch on and off, and that stack (Super Ollie, Fast Flips, Fast Spins, Realistic, Mega Pop, Fast, Hard To Bail, Sticky Grinds, ...), your own saved presets, and a preset a map applies every time it loads. | | **PRACTICE** | Game speed and pause, five marker slots per map (save / go / clear), return to the marker after a bail, teleport to coordinates, copy your position (game or Blender axes). | -| **MAP & HUD** | Speed and air-time HUD, a read-out after every jump (takeoff speed and angle, height, distance, drop, landing speed), telemetry recording to CSV, and whatever the map's author ships for the trainer. | +| **MAP & HUD** | Speed and air-time HUD, a read-out after every jump (takeoff speed and angle, height, distance, drop, landing speed, spin and flip), telemetry recording to CSV, and whatever the map's author ships for the trainer. | Controller: hold **LB + RB**, then D-pad **up** saves the marker, **down** goes to it, **left / right** pick the slot. @@ -90,4 +90,6 @@ folder>` (CMake option `DINGOSDK_BUILD_TRAINER_TESTS`) lists the tuning values w flips from `PhysicsReckoning.FlipScalar` and `FlipMaxSpeed` (`PerfectBodyFlips` forces exactly one rotation and ignores them), body spins from the `PhysicsBodyspin` graphs. "No use found" is not proof: the pass can miss a use. +- Hippy jump height works by scaling the skater's upward speed as the jump starts. No comply and + boneless heights are not adjustable: the game moves those along a scripted path. - Built for one game build (the one ReSkate 1.0.3 supports). A game update needs a new build. diff --git a/Extension/Trainer/trainer.cpp b/Extension/Trainer/trainer.cpp index fc84196b..66f05dc9 100644 --- a/Extension/Trainer/trainer.cpp +++ b/Extension/Trainer/trainer.cpp @@ -7,6 +7,7 @@ #include "Engine/Game/Build/20260929/physics_tuning.h" #include "Engine/Game/Multiplayer/session_tools.h" #include "Extension/Profile/local_profile_runtime.h" +#include "Extension/Skater/client_source_spawn.h" #include "Extension/Skater/client_source_spawn_internal.h" #include "Extension/Skater/no_bail.h" #include "Extension/Skater/physics_tuning.h" @@ -75,6 +76,10 @@ struct Motion { Vec3 takeoff{}, takeoff_velocity{}; std::int64_t takeoff_stamp{}; float apex{}; + bool axes_valid{}; + Vec3 forward{}, up{}; + float spin{}, spin_rate{}, flip{}; + std::uint32_t air_state{}, ground_state{}; }; struct State { bool loaded{}; // the store was read @@ -97,6 +102,12 @@ struct State { MapFile map_file; int slot{}; bool auto_return{}, pad_shortcuts{true}, hud{}, hud_jump{true}, logging{}; + // The hippy jump's height is set by the game's trick scripts, not by tuning: the trainer + // scales the upward velocity when it sees one start. + float hippy_height{1}; + std::uint32_t last_state{}; + float boost_factor{}; // velocity factor of the hippy jump now starting, 0: none + std::uint64_t boost_until{}; float return_delay{1.5f}; std::uint64_t seen_wipeouts{}, return_at{}, open_serial{}; int open_tab{}; @@ -194,6 +205,7 @@ void load_store() { s.hud = o.value("hud", false); s.hud_jump = o.value("hud_jump", true); s.return_delay = std::clamp(o.value("return_delay", 1.5f), 0.0f, 10.0f); + s.hippy_height = std::clamp(o.value("hippy_height", 1.0f), 0.25f, 20.0f); s.slot = std::clamp(o.value("slot", 0), 0, static_cast(marker_slots) - 1); } if (json->contains("values") && json->at("values").is_object()) @@ -235,6 +247,7 @@ void save_store() { options["hud"] = s.hud; options["hud_jump"] = s.hud_jump; options["return_delay"] = s.return_delay; + options["hippy_height"] = s.hippy_height; options["slot"] = s.slot; json["options"] = std::move(options); Json values = Json::object(); @@ -778,13 +791,19 @@ void finish_jump(const Vec3 &landing, float landing_speed, std::int64_t landed_a jump.distance = horizontal({landing[0] - m.takeoff[0], 0, landing[2] - m.takeoff[2]}); jump.drop = m.takeoff[1] - landing[1]; jump.landing_speed = landing_speed; + jump.spin = std::abs(m.spin); + jump.spin_rate = m.spin_rate; + jump.flip = m.flip; + jump.state = m.air_state; s.telemetry.last = jump; if (jump.distance > s.telemetry.best.distance) s.telemetry.best = jump; say(logging::Level::info, std::format("Trainer jump {}: takeoff {:.1f} km/h at {:.1f} deg, air {:.2f} s, height {:.2f} m, distance {:.2f} m, drop {:.2f} m, " - "landing {:.1f} km/h at ({:.1f}, {:.1f}, {:.1f}).", + "landing {:.1f} km/h at ({:.1f}, {:.1f}, {:.1f}); spin {:.0f} deg (peak {:.0f} deg/s), flip {:.0f} deg, " + "states {}>{}.", jump.serial, jump.takeoff_speed * 3.6f, jump.takeoff_angle, jump.air_time, jump.height, jump.distance, jump.drop, - jump.landing_speed * 3.6f, landing[0], landing[1], landing[2])); + jump.landing_speed * 3.6f, landing[0], landing[1], landing[2], jump.spin, jump.spin_rate, jump.flip, m.ground_state, + m.air_state)); } void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) { auto &s = state(); @@ -817,6 +836,27 @@ void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) { if (t.heading < 0) t.heading += 360.0f; t.physics_state = watch.valid ? watch.state : 0; if (watch.valid) ++s.states[watch.state]; + // A hippy jump leaves the board (504) straight from riding (100): measured. A plain + // dismount does the same but is not rising, so it is left alone. + if (watch.valid && watch.state != s.last_state) { + if (watch.state == addr::no_bail::offboard_physics_state && s.last_state == 100 && s.hippy_height != 1.0f) { + s.boost_factor = std::sqrt(s.hippy_height); // height goes with the square of the speed + s.boost_until = now + 250; + } + s.last_state = watch.state; + } + if (s.boost_factor > 0) { + const auto result = take_jump_scale_result(); + if (result.outcome > 0) { + say(logging::Level::info, std::format("Trainer: hippy jump boosted: up speed {:.2f} -> {:.2f} m/s.", result.up_speed, + result.up_speed * s.boost_factor)); + s.boost_factor = 0; + } else if (now >= s.boost_until || result.outcome == -2) { + s.boost_factor = 0; + } else if (!multiplayer_session_active() || session_boosts_allowed()) { + (void)queue_jump_scale(client, s.entity, s.boost_factor); // again each tick until the skater is rising + } + } const auto seconds = m.valid ? clock_seconds(stamp - m.stamp) : 0.0; const Vec3 step{position[0] - m.position[0], position[1] - m.position[1], position[2] - m.position[2]}; const bool jumped = std::sqrt(step[0] * step[0] + step[1] * step[1] + step[2] * step[2]) > 25.0f; @@ -836,7 +876,26 @@ void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) { // The skater's physics says when it is in the air: its states 200-299 are the pop and // the flight (100s ride the ground, 300 is a wipeout, 500s are off the board). const bool flying = watch.valid && watch.state >= air_states_first && watch.state < air_states_end; + // Rotation in the air: about the vertical (spins) and of the up axis (flips and rolls). + const Vec3 forward{matrix[8], matrix[9], matrix[10]}, up{matrix[4], matrix[5], matrix[6]}; + if (m.airborne && m.axes_valid) { + const float yaw = std::atan2(m.forward[2] * forward[0] - m.forward[0] * forward[2], m.forward[0] * forward[0] + m.forward[2] * forward[2]); + const float degrees = yaw * 180.0f / std::numbers::pi_v; + // A ragdoll or a pose snap turns further in one tick than any trick does: leave those out. + if (std::abs(degrees) < 45.0f) { + m.spin += degrees; + m.spin_rate = std::max(m.spin_rate, std::abs(degrees) / dt); + } + const float cosine = std::clamp(m.up[0] * up[0] + m.up[1] * up[1] + m.up[2] * up[2], -1.0f, 1.0f); + if (const float tumble = std::acos(cosine) * 180.0f / std::numbers::pi_v; tumble < 45.0f) m.flip += tumble; + } + m.forward = forward; + m.up = up; + m.axes_valid = true; + if (!flying && watch.valid) m.ground_state = watch.state; if (flying && !m.airborne) { + m.spin = m.spin_rate = m.flip = 0; + m.air_state = watch.state; m.airborne = true; m.takeoff = m.position; m.takeoff_velocity = velocity; @@ -1021,6 +1080,7 @@ void build_view() { for (const auto &[name, values] : s.user) next->presets.push_back({name, std::format("{} values", values.size()), false, is_active(name)}); next->slot = s.slot; next->auto_return = s.auto_return; + next->hippy_height = s.hippy_height; next->return_delay = s.return_delay; next->pad_shortcuts = s.pad_shortcuts; next->hud = s.hud; @@ -1204,7 +1264,11 @@ std::string run(std::string_view verb, const std::vector &a) { if (v == "option") { const auto name = lower(arg(0)); bool on{}; - if (name == "return_delay") { + if (name == "hippy_height") { + const auto value = number(arg(1)); + if (!value) return "error: hippy_height needs a multiplier, 1 = the game's own height."; + s.hippy_height = std::clamp(static_cast(*value), 0.25f, 20.0f); + } else if (name == "return_delay") { const auto value = number(arg(1)); if (!value) return "error: return_delay needs seconds."; s.return_delay = std::clamp(static_cast(*value), 0.0f, 10.0f); diff --git a/Extension/Trainer/trainer.h b/Extension/Trainer/trainer.h index cb9416da..e00e3964 100644 --- a/Extension/Trainer/trainer.h +++ b/Extension/Trainer/trainer.h @@ -60,6 +60,8 @@ struct View { bool auto_return{}; float return_delay{1.5f}; bool pad_shortcuts{true}; + // Height of the hippy jump, which the game scripts instead of tuning (x of its own height). + float hippy_height{1}; // HUD bool hud{}, hud_jump{true}, logging{}; // The loaded map and what its author ships for the trainer (Mods//trainer.json). @@ -73,6 +75,9 @@ struct View { struct Jump { std::uint64_t serial{}; // 0: none yet float takeoff_speed{}, takeoff_angle{}, air_time{}, height{}, distance{}, drop{}, landing_speed{}; + float spin{}, spin_rate{}; // degrees turned about the vertical in the air, and the fastest rate (deg/s) + float flip{}; // degrees the skater's up axis tumbled (body flips and rolls) + std::uint32_t state{}; // the physics state it took off into (tells trick kinds apart) std::array takeoff{}, landing{}; }; // Changes every client tick. diff --git a/Extension/Trainer/trainer_commands.cpp b/Extension/Trainer/trainer_commands.cpp index ab6cc000..ebec3159 100644 --- a/Extension/Trainer/trainer_commands.cpp +++ b/Extension/Trainer/trainer_commands.cpp @@ -22,7 +22,7 @@ void register_trainer_commands(Commands ®istry) { {"marker", "Save, go to or clear a marker", "save|go|clear [slot]", false}, {"tp", "Teleport the skater", " ", false}, {"spot", "Go to one of the map author's spots", "", false}, - {"option", "Trainer options", "hud|hud_jump|auto_return|pad|log 0|1, return_delay ", true}, + {"option", "Trainer options", "hud|hud_jump|auto_return|pad|log 0|1, return_delay , hippy_height ", true}, {"profile", "The preset this map applies on load", "set |clear", false}, {"jumps", "The last measured jump", nullptr, false}, {"where", "The skater's position, heading and speed", nullptr, false}, diff --git a/Extension/Trainer/trainer_page.cpp b/Extension/Trainer/trainer_page.cpp index 1d2ffc56..f0a7feb2 100644 --- a/Extension/Trainer/trainer_page.cpp +++ b/Extension/Trainer/trainer_page.cpp @@ -29,9 +29,12 @@ struct Page { std::array preset_name{}; std::array teleport{}; double speed_edit{-1}, speed_until{}; + float hippy_edit{1}; + bool hippy_editing{}; std::uint64_t open_serial{}; // the last `trainer open` acted on bool show_page{}; }; +void trick_heights(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::View &view); Page &page() { static auto *value = new Page; return *value; @@ -180,6 +183,8 @@ void tune_tab(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trai ImGui::SameLine(); if (p.hidden_unused) ImGui::TextDisabled("%zu shown, %zu changed, %zu hidden", p.shown.size(), view.touched, p.hidden_unused); else ImGui::TextDisabled("%zu shown, %zu changed", p.shown.size(), view.touched); + // The hippy jump's height is not a tuning value; it sits with the Essentials. + if (p.group == 0 && p.search[0] == 0 && !p.only_changed) trick_heights(menu, callbacks, p, view); const bool with_group = p.search[0] != 0 || p.group <= 1 || p.only_changed; const bool friendly = p.group == 0 && p.search[0] == 0 && !p.only_changed; ImGui::BeginChild("tune-rows", ImVec2(0, 0), ImGuiChildFlags_None); @@ -192,6 +197,24 @@ void tune_tab(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trai ImGui::EndChild(); } +void trick_heights(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) { + begin_card(menu, "trick-heights", "HIPPY JUMP", "1.0 is the game's own height"); + const auto slider = [&](const char *label, const char *option, float value, float &edit, bool &editing) { + field(menu, label); + ImGui::PushID(option); + float shown = editing ? edit : value; + if (ImGui::SliderFloat("##height", &shown, 0.5f, 10.0f, "x %.2f", ImGuiSliderFlags_AlwaysClamp)) { + edit = shown; + editing = true; + } + if (ImGui::IsItemDeactivatedAfterEdit()) trainer_command(menu, callbacks, std::format("option {} {:.2f}", option, edit)); + if (!ImGui::IsItemActive() && editing && std::abs(value - edit) < 0.005f) editing = false; + ImGui::PopID(); + }; + slider("Hippy jump height", "hippy_height", view.hippy_height, p.hippy_edit, p.hippy_editing); + note("Separate from ollie height because the game sets it in its trick scripts."); + end_card(); +} void presets_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) { if (!view.editable && !view.blocked.empty()) warn(view.blocked.c_str()); begin_card(menu, "quick", "QUICK", "The ones everybody wants"); @@ -217,6 +240,7 @@ void presets_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbac debug_request(menu, callbacks, {DebugAction::set_no_bail, no_bail}); } end_card(); + trick_heights(menu, callbacks, p, view); begin_card(menu, "presets", "PRESETS", "Switch any of them on and off; they stack"); ImGui::BeginDisabled(!view.ready || !view.editable); const float button = ImGui::CalcTextSize("Turn off").x + ImGui::GetStyle().FramePadding.x * 2; @@ -370,6 +394,7 @@ void jump_lines(SkateMenu &menu, const trainer::Jump &jump) { info(menu, "Distance", std::format("{:.2f} m, {:.2f} m drop", jump.distance, jump.drop)); info(menu, "Landing", std::format("{:.1f} km/h at {:.1f}, {:.1f}, {:.1f}", jump.landing_speed * 3.6f, jump.landing[0], jump.landing[1], jump.landing[2])); + info(menu, "Rotation", std::format("spin {:.0f} degrees (peak {:.0f} per second), flip {:.0f} degrees", jump.spin, jump.spin_rate, jump.flip)); } void map_tab(SkateMenu &menu, const CallbacksV3 &callbacks, const trainer::View &view) { const auto telemetry = trainer::telemetry(); @@ -486,6 +511,7 @@ void draw_trainer_hud() { lines.push_back(std::format("jump {}: {:.1f} km/h at {:.0f} deg", jump.serial, jump.takeoff_speed * 3.6f, jump.takeoff_angle)); lines.push_back(std::format("{:.2f} s {:.2f} m high {:.2f} m far", jump.air_time, jump.height, jump.distance)); lines.push_back(std::format("landed at {:.1f} km/h, {:.2f} m lower", jump.landing_speed * 3.6f, jump.drop)); + if (jump.spin >= 45.0f || jump.flip >= 90.0f) lines.push_back(std::format("spin {:.0f} deg flip {:.0f} deg", jump.spin, jump.flip)); } if (lines.empty()) return; auto *draw = ImGui::GetForegroundDrawList(); diff --git a/Extension/Trainer/trainer_presets.cpp b/Extension/Trainer/trainer_presets.cpp index cc8e36af..f3ec64e5 100644 --- a/Extension/Trainer/trainer_presets.cpp +++ b/Extension/Trainer/trainer_presets.cpp @@ -73,6 +73,13 @@ const std::vector &builtin_presets() { {"physicsmode.perfectbodyflips", false, 0.0}}}, {"Fast Spins", "Body spins rotate three times as fast.", {{"physicsairstates.maxspinspeed", true, 3.0}, {"physicsmode.maxautobodyspinspeed", true, 3.0}}}, + {"Realistic", "Lower pop, slower pushing and rotation, earlier speed wobble, easier to bail.", + {{"physicsmode.jumpmaxheight", true, 0.75}, {"physicsmode.jumpminheight", true, 0.8}, + {"physicsmode.grindjump", true, 0.8}, {"physicspush.maxpushablespeed !camera", true, 0.75}, + {"physicsmode.maxpushdv", true, 0.8}, {"physicsmode.speedwobblestartspeed", true, 0.7}, + {"physicsreckoning.flipscalar", true, 0.8}, {"physicsairstates.maxspinspeed", true, 0.75}, + {"physicsmode.maxautobodyspinspeed", true, 0.75}, {"physicsmode.grindlockdist", true, 0.7}, + {"physicswipeout.wipeout_ maxspeed", true, 0.8}, {"physicsmode.wipeout_ acceleration", true, 0.8}}}, {"Mega Pop", "Ollies and grind pops go about twice as high.", {{"physicsmode.jumpmaxheight", true, 2.0}, {"physicsmode.jumpminheight", true, 1.6}, {"physicsjump.jumpybonusmax", true, 2.0}, {"physicsmode.grindjump", true, 1.6}}}, diff --git a/Extension/Trainer/trainer_used.inc b/Extension/Trainer/trainer_used.inc index 344a4706..c312efce 100644 --- a/Extension/Trainer/trainer_used.inc +++ b/Extension/Trainer/trainer_used.inc @@ -1,111 +1,128 @@ -// Offsets of Gameplay/SkatePhysicsTuning that the game's code was found to read (game build -// 25414733): a static pass over the code that follows the asset pointer, the per-group accessor -// functions and the skater's mode pointer. A value not listed may still be used through a path -// the pass does not follow, so the menu says "no use found", not "unused". +// Offsets of Gameplay/SkatePhysicsTuning that the game was found to read (game build 25414733): +// a static pass over the code that follows the asset pointer, the per-group accessor functions +// and the skater's mode pointer, plus every offset the game read in traced play sessions. +// A value not listed may still be used through a path neither saw, so the menu says +// "no use found", not "unused". 0x178, 0x17c, 0x180, 0x184, 0x188, 0x18c, 0x190, 0x194, 0x198, 0x19c, 0x1a0, 0x1a4, 0x1a8, 0x1ac, 0x1b0, 0x1b4, 0x1b8, 0x1bc, 0x1c0, 0x1c4, 0x1c8, 0x1cc, 0x1d0, 0x1d4, 0x1d8, 0x1dc, 0x1e0, 0x1e4, 0x1e8, 0x1ec, 0x1f0, 0x1f4, 0x1f8, 0x1fc, 0x200, 0x204, 0x208, 0x20c, 0x210, 0x218, 0x220, 0x228, 0x230, 0x23c, 0x240, 0x248, 0x24c, 0x254, -0x258, 0x268, 0x270, 0x278, 0x280, 0x284, 0x28c, 0x294, 0x298, 0x2a0, 0x2b0, 0x2b8, 0x2c0, 0x2c8, 0x2d0, 0x2d4, -0x2d8, 0x2dc, 0x2e0, 0x2e4, 0x2e8, 0x2f4, 0x2f8, 0x2fc, 0x6d8, 0x6dc, 0x6e0, 0x6e4, 0x6e8, 0x6ec, 0x6f0, 0x6f4, -0x6f8, 0x6fc, 0x700, 0x704, 0x708, 0x70c, 0x710, 0x714, 0x718, 0x71c, 0x720, 0x724, 0x728, 0x72c, 0x730, 0x734, -0x738, 0x73c, 0x740, 0x744, 0x748, 0x74c, 0x750, 0x754, 0x758, 0x75c, 0x760, 0x764, 0x768, 0x76c, 0x818, 0x81c, -0x820, 0x824, 0x828, 0x82c, 0x830, 0x834, 0x838, 0x83c, 0x840, 0x844, 0x848, 0x84c, 0x850, 0x854, 0x858, 0x85c, -0x860, 0x864, 0x868, 0x86c, 0x870, 0x874, 0x878, 0x87c, 0x880, 0x884, 0x888, 0x88c, 0x890, 0x894, 0x898, 0x89c, -0x8a0, 0x8a4, 0x8a8, 0x8ac, 0xb38, 0xb3c, 0xb40, 0xb44, 0xb48, 0xb4c, 0xb50, 0xb54, 0xb58, 0xb5c, 0xb60, 0xb64, -0xb68, 0xb6c, 0xb70, 0xb74, 0xb78, 0xb7c, 0xb80, 0xb84, 0xb88, 0xb8c, 0xb90, 0xb94, 0xb98, 0xb9c, 0xba0, 0xba4, -0xba8, 0xbac, 0xbb0, 0xbb4, 0xbb8, 0xbbc, 0xbc0, 0xbc4, 0xbc8, 0xbcc, 0xbd8, 0xbdc, 0xbe0, 0xbe4, 0xbe8, 0xbec, -0xbf0, 0xbf4, 0xbf8, 0xbfc, 0xc00, 0xc04, 0xc08, 0xc0c, 0xc10, 0xc14, 0xc18, 0xc1c, 0xc20, 0xc24, 0xc28, 0xc2c, -0xc30, 0xc34, 0xc38, 0xc3c, 0xc40, 0xc44, 0xc48, 0xc4c, 0xc50, 0xc54, 0xc58, 0xc5c, 0xc60, 0xc64, 0xc68, 0xc6c, -0xe50, 0xe5c, 0xe64, 0xe70, 0xe74, 0xe78, 0xe90, 0xe98, 0xe9c, 0xea4, 0xea8, 0xeb0, 0xeb4, 0xeb8, 0xebc, 0xec0, -0xfd8, 0x15b0, 0x1660, 0x1664, 0x1668, 0x166c, 0x1670, 0x1674, 0x1678, 0x167c, 0x1680, 0x1684, 0x1688, 0x168c, 0x1690, 0x1694, -0x1698, 0x169c, 0x16a0, 0x16a4, 0x16a8, 0x16ac, 0x16b0, 0x16b4, 0x16b8, 0x16bc, 0x16c0, 0x16c4, 0x16c8, 0x16cc, 0x16d0, 0x16d4, -0x16d8, 0x16dc, 0x16e0, 0x16e4, 0x16e8, 0x16ec, 0x16f0, 0x16f4, 0x1700, 0x1704, 0x1708, 0x170c, 0x1710, 0x1714, 0x1718, 0x171c, -0x1720, 0x1724, 0x1728, 0x172c, 0x1730, 0x1734, 0x1738, 0x173c, 0x1740, 0x1744, 0x1748, 0x174c, 0x1750, 0x1754, 0x1758, 0x175c, -0x1760, 0x1764, 0x1768, 0x176c, 0x1770, 0x1774, 0x1778, 0x177c, 0x1780, 0x1784, 0x1788, 0x178c, 0x1790, 0x1794, 0x17a0, 0x17a4, -0x17a8, 0x17ac, 0x17b0, 0x17b4, 0x17b8, 0x17bc, 0x17c0, 0x17c4, 0x17c8, 0x17cc, 0x17d0, 0x17d4, 0x17d8, 0x17dc, 0x17e0, 0x17e4, -0x17e8, 0x17ec, 0x17f0, 0x17f4, 0x17f8, 0x17fc, 0x1800, 0x1804, 0x1808, 0x180c, 0x1810, 0x1814, 0x1818, 0x181c, 0x1820, 0x1824, -0x1828, 0x182c, 0x1830, 0x1834, 0x18e0, 0x18e4, 0x18e8, 0x18ec, 0x18f0, 0x18f4, 0x18f8, 0x18fc, 0x1900, 0x1904, 0x1908, 0x190c, -0x1910, 0x1914, 0x1918, 0x191c, 0x1920, 0x1924, 0x1928, 0x192c, 0x1930, 0x1934, 0x1938, 0x193c, 0x1940, 0x1944, 0x1948, 0x194c, -0x1950, 0x1954, 0x1958, 0x195c, 0x1960, 0x1964, 0x1968, 0x196c, 0x1970, 0x1974, 0x1980, 0x1984, 0x1988, 0x198c, 0x1990, 0x1994, -0x1998, 0x199c, 0x19a0, 0x19a4, 0x19a8, 0x19ac, 0x19b0, 0x19b4, 0x19b8, 0x19bc, 0x19c0, 0x19c4, 0x19c8, 0x19cc, 0x19d0, 0x19d4, -0x19d8, 0x19dc, 0x19e0, 0x19e4, 0x19e8, 0x19ec, 0x19f0, 0x19f4, 0x19f8, 0x19fc, 0x1a00, 0x1a04, 0x1a08, 0x1a0c, 0x1a10, 0x1a14, -0x1a18, 0x1a28, 0x1a2c, 0x1a30, 0x1a34, 0x1a3c, 0x1a40, 0x1a44, 0x1a48, 0x1a4c, 0x1a50, 0x1a54, 0x1a58, 0x1a5c, 0x1a60, 0x1a68, -0x1a6c, 0x1a70, 0x1a74, 0x1a78, 0x1a7c, 0x1a84, 0x1a88, 0x1a8c, 0x1a90, 0x1a94, 0x1a98, 0x1a9c, 0x1aa0, 0x1aa4, 0x1aa8, 0x1aac, -0x1ab0, 0x1ab4, 0x1ab8, 0x1abc, 0x1ac0, 0x1acc, 0x1ad0, 0x1ad4, 0x1ad8, 0x1adc, 0x1ae0, 0x1ae4, 0x1ae8, 0x1aec, 0x1af0, 0x1af8, -0x1afc, 0x1b00, 0x1b08, 0x1b0c, 0x1b10, 0x1b13, 0x1d00, 0x1d04, 0x1d08, 0x1d0c, 0x1d10, 0x1d14, 0x1d18, 0x1d1c, 0x1d20, 0x1d24, +0x258, 0x268, 0x270, 0x278, 0x280, 0x284, 0x28c, 0x294, 0x298, 0x2a0, 0x2a4, 0x2a8, 0x2b0, 0x2b8, 0x2c0, 0x2c8, +0x2d0, 0x2d4, 0x2d8, 0x2dc, 0x2e0, 0x2e4, 0x2e8, 0x2f4, 0x2f8, 0x2fc, 0x6d8, 0x6dc, 0x6e0, 0x6e4, 0x6e8, 0x6ec, +0x6f0, 0x6f4, 0x6f8, 0x6fc, 0x700, 0x704, 0x708, 0x70c, 0x710, 0x714, 0x718, 0x71c, 0x720, 0x724, 0x728, 0x72c, +0x730, 0x734, 0x738, 0x73c, 0x740, 0x744, 0x748, 0x74c, 0x750, 0x754, 0x758, 0x75c, 0x760, 0x764, 0x768, 0x76c, +0x818, 0x81c, 0x820, 0x824, 0x828, 0x82c, 0x830, 0x834, 0x838, 0x83c, 0x840, 0x844, 0x848, 0x84c, 0x850, 0x854, +0x858, 0x85c, 0x860, 0x864, 0x868, 0x86c, 0x870, 0x874, 0x878, 0x87c, 0x880, 0x884, 0x888, 0x88c, 0x890, 0x894, +0x898, 0x89c, 0x8a0, 0x8a4, 0x8a8, 0x8ac, 0xb38, 0xb3c, 0xb40, 0xb44, 0xb48, 0xb4c, 0xb50, 0xb54, 0xb58, 0xb5c, +0xb60, 0xb64, 0xb68, 0xb6c, 0xb70, 0xb74, 0xb78, 0xb7c, 0xb80, 0xb84, 0xb88, 0xb8c, 0xb90, 0xb94, 0xb98, 0xb9c, +0xba0, 0xba4, 0xba8, 0xbac, 0xbb0, 0xbb4, 0xbb8, 0xbbc, 0xbc0, 0xbc4, 0xbc8, 0xbcc, 0xbd8, 0xbdc, 0xbe0, 0xbe4, +0xbe8, 0xbec, 0xbf0, 0xbf4, 0xbf8, 0xbfc, 0xc00, 0xc04, 0xc08, 0xc0c, 0xc10, 0xc14, 0xc18, 0xc1c, 0xc20, 0xc24, +0xc28, 0xc2c, 0xc30, 0xc34, 0xc38, 0xc3c, 0xc40, 0xc44, 0xc48, 0xc4c, 0xc50, 0xc54, 0xc58, 0xc5c, 0xc60, 0xc64, +0xc68, 0xc6c, 0xe50, 0xe5c, 0xe64, 0xe70, 0xe74, 0xe78, 0xe90, 0xe98, 0xe9c, 0xea4, 0xea8, 0xeb0, 0xeb4, 0xeb8, +0xebc, 0xec0, 0xfd8, 0x15b0, 0x15c0, 0x15c4, 0x15c8, 0x15cc, 0x15d0, 0x15d4, 0x15d8, 0x15dc, 0x15e0, 0x15e4, 0x15e8, 0x15ec, +0x15f0, 0x15f4, 0x15f8, 0x15fc, 0x1600, 0x1604, 0x1608, 0x160c, 0x1610, 0x1614, 0x1618, 0x161c, 0x1620, 0x1624, 0x1628, 0x162c, +0x1630, 0x1634, 0x1638, 0x163c, 0x1640, 0x1644, 0x1648, 0x164c, 0x1650, 0x1654, 0x1660, 0x1664, 0x1668, 0x166c, 0x1670, 0x1674, +0x1678, 0x167c, 0x1680, 0x1684, 0x1688, 0x168c, 0x1690, 0x1694, 0x1698, 0x169c, 0x16a0, 0x16a4, 0x16a8, 0x16ac, 0x16b0, 0x16b4, +0x16b8, 0x16bc, 0x16c0, 0x16c4, 0x16c8, 0x16cc, 0x16d0, 0x16d4, 0x16d8, 0x16dc, 0x16e0, 0x16e4, 0x16e8, 0x16ec, 0x16f0, 0x16f4, +0x1700, 0x1704, 0x1708, 0x170c, 0x1710, 0x1714, 0x1718, 0x171c, 0x1720, 0x1724, 0x1728, 0x172c, 0x1730, 0x1734, 0x1738, 0x173c, +0x1740, 0x1744, 0x1748, 0x174c, 0x1750, 0x1754, 0x1758, 0x175c, 0x1760, 0x1764, 0x1768, 0x176c, 0x1770, 0x1774, 0x1778, 0x177c, +0x1780, 0x1784, 0x1788, 0x178c, 0x1790, 0x1794, 0x17a0, 0x17a4, 0x17a8, 0x17ac, 0x17b0, 0x17b4, 0x17b8, 0x17bc, 0x17c0, 0x17c4, +0x17c8, 0x17cc, 0x17d0, 0x17d4, 0x17d8, 0x17dc, 0x17e0, 0x17e4, 0x17e8, 0x17ec, 0x17f0, 0x17f4, 0x17f8, 0x17fc, 0x1800, 0x1804, +0x1808, 0x180c, 0x1810, 0x1814, 0x1818, 0x181c, 0x1820, 0x1824, 0x1828, 0x182c, 0x1830, 0x1834, 0x1840, 0x1844, 0x1848, 0x184c, +0x1850, 0x1854, 0x1858, 0x185c, 0x1860, 0x1864, 0x1868, 0x186c, 0x1870, 0x1874, 0x1878, 0x187c, 0x1880, 0x1884, 0x1888, 0x188c, +0x1890, 0x1894, 0x1898, 0x189c, 0x18a0, 0x18a4, 0x18a8, 0x18ac, 0x18b0, 0x18b4, 0x18b8, 0x18bc, 0x18c0, 0x18c4, 0x18c8, 0x18cc, +0x18d0, 0x18d4, 0x18e0, 0x18e4, 0x18e8, 0x18ec, 0x18f0, 0x18f4, 0x18f8, 0x18fc, 0x1900, 0x1904, 0x1908, 0x190c, 0x1910, 0x1914, +0x1918, 0x191c, 0x1920, 0x1924, 0x1928, 0x192c, 0x1930, 0x1934, 0x1938, 0x193c, 0x1940, 0x1944, 0x1948, 0x194c, 0x1950, 0x1954, +0x1958, 0x195c, 0x1960, 0x1964, 0x1968, 0x196c, 0x1970, 0x1974, 0x1980, 0x1984, 0x1988, 0x198c, 0x1990, 0x1994, 0x1998, 0x199c, +0x19a0, 0x19a4, 0x19a8, 0x19ac, 0x19b0, 0x19b4, 0x19b8, 0x19bc, 0x19c0, 0x19c4, 0x19c8, 0x19cc, 0x19d0, 0x19d4, 0x19d8, 0x19dc, +0x19e0, 0x19e4, 0x19e8, 0x19ec, 0x19f0, 0x19f4, 0x19f8, 0x19fc, 0x1a00, 0x1a04, 0x1a08, 0x1a0c, 0x1a10, 0x1a14, 0x1a18, 0x1a28, +0x1a2c, 0x1a30, 0x1a34, 0x1a3c, 0x1a40, 0x1a44, 0x1a48, 0x1a4c, 0x1a50, 0x1a54, 0x1a58, 0x1a5c, 0x1a60, 0x1a68, 0x1a6c, 0x1a70, +0x1a74, 0x1a78, 0x1a7c, 0x1a84, 0x1a88, 0x1a8c, 0x1a90, 0x1a94, 0x1a98, 0x1a9c, 0x1aa0, 0x1aa4, 0x1aa8, 0x1aac, 0x1ab0, 0x1ab4, +0x1ab8, 0x1abc, 0x1ac0, 0x1acc, 0x1ad0, 0x1ad4, 0x1ad8, 0x1adc, 0x1ae0, 0x1ae4, 0x1ae8, 0x1aec, 0x1af0, 0x1af8, 0x1afc, 0x1b00, +0x1b08, 0x1b0c, 0x1b10, 0x1b11, 0x1b12, 0x1b13, 0x1d00, 0x1d04, 0x1d08, 0x1d0c, 0x1d10, 0x1d14, 0x1d18, 0x1d1c, 0x1d20, 0x1d24, 0x1d28, 0x1d2c, 0x1d30, 0x1d34, 0x1d38, 0x1d3c, 0x1d40, 0x1d44, 0x1d48, 0x1d4c, 0x1d50, 0x1d54, 0x1d58, 0x1d5c, 0x1d60, 0x1d64, -0x1d68, 0x1d6c, 0x1d70, 0x1d74, 0x1d78, 0x1d7c, 0x1d80, 0x1d84, 0x1d88, 0x1d8c, 0x1d90, 0x1d94, 0x1f84, 0x1fa8, 0x1fac, 0x2068, -0x206c, 0x2070, 0x2074, 0x2078, 0x207c, 0x2080, 0x2084, 0x2088, 0x208c, 0x2090, 0x2094, 0x2098, 0x209c, 0x20a0, 0x20a4, 0x20a8, -0x20ac, 0x20b0, 0x20b4, 0x20b8, 0x20bc, 0x20c0, 0x20c4, 0x20c8, 0x20cc, 0x20d0, 0x20d4, 0x20d8, 0x20dc, 0x20e0, 0x20e4, 0x20e8, -0x20ec, 0x20f0, 0x20f4, 0x20f8, 0x20fc, 0x2108, 0x210c, 0x2110, 0x2114, 0x2118, 0x211c, 0x2120, 0x2124, 0x2128, 0x212c, 0x2130, -0x2134, 0x2138, 0x213c, 0x2140, 0x2144, 0x2148, 0x214c, 0x2150, 0x2154, 0x2158, 0x215c, 0x2160, 0x2164, 0x2168, 0x216c, 0x2170, -0x2174, 0x2178, 0x217c, 0x2180, 0x2184, 0x2188, 0x218c, 0x2190, 0x2194, 0x2198, 0x219c, 0x21a8, 0x21ac, 0x21b0, 0x21b4, 0x21b8, -0x21bc, 0x21c0, 0x21c4, 0x21c8, 0x21cc, 0x21d0, 0x21d4, 0x21d8, 0x21dc, 0x21e0, 0x21e4, 0x21e8, 0x21ec, 0x21f0, 0x21f4, 0x21f8, -0x21fc, 0x2200, 0x2204, 0x2208, 0x220c, 0x2210, 0x2214, 0x2218, 0x221c, 0x2220, 0x2224, 0x2228, 0x222c, 0x2230, 0x2234, 0x2238, -0x223c, 0x2248, 0x224c, 0x2250, 0x2254, 0x2258, 0x225c, 0x2260, 0x2264, 0x2268, 0x226c, 0x2270, 0x2274, 0x2278, 0x227c, 0x2280, -0x2284, 0x2288, 0x228c, 0x2290, 0x2294, 0x2298, 0x229c, 0x22a0, 0x22a4, 0x22a8, 0x22ac, 0x22b0, 0x22b4, 0x22b8, 0x22bc, 0x22c0, -0x22c4, 0x22c8, 0x22cc, 0x22d0, 0x22d4, 0x22d8, 0x22dc, 0x22e8, 0x22ec, 0x22f0, 0x22f4, 0x22f8, 0x22fc, 0x2300, 0x2304, 0x2308, -0x230c, 0x2310, 0x2314, 0x2318, 0x231c, 0x2320, 0x2324, 0x2328, 0x232c, 0x2330, 0x2334, 0x2338, 0x233c, 0x2340, 0x2344, 0x2348, -0x234c, 0x2350, 0x2354, 0x2358, 0x235c, 0x2360, 0x2364, 0x2368, 0x236c, 0x2370, 0x2374, 0x2378, 0x237c, 0x2388, 0x238c, 0x2390, -0x2394, 0x2398, 0x239c, 0x23a0, 0x23a4, 0x23a8, 0x23ac, 0x23b0, 0x23b4, 0x23b8, 0x23bc, 0x23c0, 0x23c4, 0x23c8, 0x23cc, 0x23d0, -0x23d4, 0x23d8, 0x23dc, 0x23e0, 0x23e4, 0x23e8, 0x23ec, 0x23f0, 0x23f4, 0x23f8, 0x23fc, 0x2400, 0x2404, 0x2408, 0x240c, 0x2410, -0x2414, 0x2418, 0x241c, 0x2420, 0x2424, 0x24d8, 0x24dc, 0x24e0, 0x24e4, 0x24e8, 0x24ec, 0x24f0, 0x24f4, 0x24f8, 0x24fc, 0x2500, -0x2504, 0x2508, 0x250c, 0x2510, 0x2514, 0x2518, 0x251c, 0x2520, 0x2524, 0x2528, 0x252c, 0x2530, 0x2534, 0x2538, 0x253c, 0x2540, -0x2544, 0x2548, 0x254c, 0x2550, 0x2554, 0x2558, 0x255c, 0x2560, 0x2564, 0x2568, 0x256c, 0x2570, 0x2578, 0x257c, 0x2580, 0x2584, -0x2588, 0x258c, 0x2590, 0x2594, 0x2598, 0x259c, 0x25a0, 0x25a4, 0x25a8, 0x25ac, 0x25b0, 0x25b4, 0x25b8, 0x25bc, 0x25c0, 0x25c4, -0x25c8, 0x25cc, 0x25d0, 0x25d4, 0x25d8, 0x25dc, 0x25e0, 0x25e4, 0x25e8, 0x25ec, 0x25f0, 0x25f4, 0x25f8, 0x25fc, 0x2600, 0x2604, -0x2608, 0x260c, 0x2618, 0x261c, 0x2620, 0x2624, 0x2628, 0x262c, 0x2630, 0x2634, 0x2638, 0x263c, 0x2640, 0x2644, 0x2648, 0x264c, -0x2650, 0x2654, 0x2658, 0x265c, 0x2660, 0x2664, 0x2668, 0x266c, 0x2670, 0x2674, 0x2678, 0x267c, 0x2680, 0x2684, 0x2688, 0x268c, -0x2690, 0x2694, 0x2698, 0x269c, 0x26a0, 0x26a4, 0x26a8, 0x26ac, 0x26b0, 0x26b8, 0x26bc, 0x26c0, 0x26c4, 0x26c8, 0x26cc, 0x26d0, -0x26d4, 0x26d8, 0x26dc, 0x26e0, 0x26e4, 0x26e8, 0x26ec, 0x26f0, 0x26f4, 0x26f8, 0x26fc, 0x2700, 0x2704, 0x2708, 0x270c, 0x2710, -0x2714, 0x2718, 0x271c, 0x2720, 0x2724, 0x2728, 0x272c, 0x2730, 0x2734, 0x2738, 0x273c, 0x2740, 0x2744, 0x2748, 0x274c, 0x2758, -0x275c, 0x2760, 0x2764, 0x2768, 0x2774, 0x2778, 0x277c, 0x2aa8, 0x2ab0, 0x2ab4, 0x2ab8, 0x2abc, 0x2ac0, 0x2ac4, 0x2ac8, 0x2acc, -0x2ad0, 0x2c18, 0x2c20, 0x2c24, 0x2c28, 0x2c2c, 0x2c30, 0x2c34, 0x2c38, 0x2c3c, 0x2c40, 0x2c44, 0x2c48, 0x2c4c, 0x2c50, 0x2c54, -0x2c58, 0x2c5c, 0x2c60, 0x2c64, 0x2c68, 0x2c6c, 0x2c70, 0x2c74, 0x2c78, 0x2c7c, 0x2c80, 0x2c84, 0x2c88, 0x2c8c, 0x2c90, 0x2c94, -0x2c98, 0x2c9c, 0x2ca0, 0x2ca4, 0x2ca8, 0x2cac, 0x2cb0, 0x2cb4, 0x2e00, 0x2e04, 0x2e08, 0x2eb8, 0x2ebc, 0x2ec0, 0x2ec4, 0x2ec8, -0x2ecc, 0x2ed0, 0x2ed4, 0x2ed8, 0x2edc, 0x2ee0, 0x2ee4, 0x2ee8, 0x2eec, 0x2ef0, 0x2ef4, 0x2ef8, 0x2efc, 0x2f00, 0x2f04, 0x2f08, -0x2f0c, 0x2f10, 0x2f14, 0x2f18, 0x2f1c, 0x2f20, 0x2f24, 0x2f28, 0x2f2c, 0x2f30, 0x2f34, 0x2f38, 0x2f3c, 0x2f40, 0x2f44, 0x2f48, -0x2f4c, 0x2f58, 0x2f5c, 0x2f60, 0x2f64, 0x2f68, 0x2f6c, 0x2f70, 0x2f74, 0x2f78, 0x2f7c, 0x2f80, 0x2f84, 0x2f88, 0x2f8c, 0x2f90, -0x2f94, 0x2f98, 0x2f9c, 0x2fa0, 0x2fa4, 0x2fa8, 0x2fac, 0x2fb0, 0x2fb4, 0x2fb8, 0x2fbc, 0x2fc0, 0x2fc4, 0x2fc8, 0x2fcc, 0x2fd0, -0x2fd4, 0x2fd8, 0x2fdc, 0x2fe0, 0x2fe4, 0x2fe8, 0x2fec, 0x2ff8, 0x2ffc, 0x3000, 0x3004, 0x3008, 0x300c, 0x3010, 0x3014, 0x3018, -0x301c, 0x3020, 0x3024, 0x3028, 0x302c, 0x3030, 0x3034, 0x3038, 0x303c, 0x3040, 0x3044, 0x3048, 0x304c, 0x3050, 0x3054, 0x3058, -0x305c, 0x3060, 0x3064, 0x3068, 0x306c, 0x3070, 0x3074, 0x3078, 0x307c, 0x3080, 0x3084, 0x3088, 0x308c, 0x3098, 0x30a0, 0x30a8, -0x30ac, 0x30b0, 0x30bc, 0x30c0, 0x3170, 0x3174, 0x3178, 0x317c, 0x3180, 0x3184, 0x3188, 0x318c, 0x3190, 0x3194, 0x3198, 0x319c, -0x31a0, 0x31a4, 0x31a8, 0x31ac, 0x31b0, 0x31b4, 0x31b8, 0x31bc, 0x31c0, 0x31c4, 0x31c8, 0x31cc, 0x31d0, 0x31d4, 0x31d8, 0x31dc, -0x31e0, 0x31e4, 0x31e8, 0x31ec, 0x31f0, 0x31f4, 0x31f8, 0x31fc, 0x3200, 0x3204, 0x3348, 0x3350, 0x3408, 0x340c, 0x3410, 0x3414, -0x3418, 0x341c, 0x3420, 0x3424, 0x3428, 0x342c, 0x3430, 0x3434, 0x3438, 0x343c, 0x3440, 0x3444, 0x3448, 0x344c, 0x3450, 0x3454, -0x3458, 0x345c, 0x3460, 0x3464, 0x3468, 0x346c, 0x3470, 0x3474, 0x3478, 0x347c, 0x3480, 0x3484, 0x3488, 0x348c, 0x3490, 0x3494, -0x3498, 0x349c, 0x34a8, 0x34ac, 0x34b0, 0x34b4, 0x34b8, 0x34bc, 0x34c0, 0x34c4, 0x34c8, 0x34cc, 0x34d0, 0x34d4, 0x34d8, 0x34dc, -0x34e0, 0x34e4, 0x34e8, 0x34ec, 0x34f0, 0x34f4, 0x34f8, 0x34fc, 0x3500, 0x3504, 0x3508, 0x350c, 0x3510, 0x3514, 0x3518, 0x351c, -0x3520, 0x3524, 0x3528, 0x352c, 0x3530, 0x3534, 0x3538, 0x353c, 0x3544, 0x3548, 0x354c, 0x3550, 0x355c, 0x3560, 0x3564, 0x3568, -0x356c, 0x3570, 0x3574, 0x3578, 0x357c, 0x3580, 0x3584, 0x3588, 0x3630, 0x3638, 0x363c, 0x3640, 0x3644, 0x3648, 0x364c, 0x3650, -0x3654, 0x3658, 0x365c, 0x3660, 0x3664, 0x3668, 0x366c, 0x3670, 0x3674, 0x3678, 0x367c, 0x3680, 0x3684, 0x3688, 0x368c, 0x3690, -0x3694, 0x3698, 0x369c, 0x36a0, 0x36a4, 0x36a8, 0x36ac, 0x36b0, 0x36b4, 0x36b8, 0x36bc, 0x36c0, 0x36c4, 0x36c8, 0x36cc, 0x36d0, -0x36d8, 0x36dc, 0x36e0, 0x36e4, 0x36e8, 0x36ec, 0x36f0, 0x36f4, 0x36f8, 0x36fc, 0x3700, 0x3704, 0x3708, 0x370c, 0x3710, 0x3714, -0x3718, 0x371c, 0x3720, 0x3724, 0x3728, 0x372c, 0x3730, 0x3734, 0x3738, 0x373c, 0x3740, 0x3744, 0x3748, 0x374c, 0x3750, 0x3754, -0x3758, 0x375c, 0x3760, 0x3764, 0x3768, 0x376c, 0x3770, 0x3774, 0x3778, 0x377c, 0x3780, 0x3784, 0x3788, 0x378c, 0x3790, 0x3794, -0x3798, 0x379c, 0x37a0, 0x37a4, 0x37a8, 0x37ac, 0x3858, 0x385c, 0x3860, 0x3864, 0x3868, 0x386c, 0x3870, 0x3874, 0x3878, 0x387c, -0x3880, 0x3884, 0x3888, 0x388c, 0x3890, 0x3894, 0x3898, 0x389c, 0x38a0, 0x38a4, 0x38a8, 0x38ac, 0x38b0, 0x38b4, 0x38b8, 0x38bc, -0x38c0, 0x38c4, 0x38c8, 0x38cc, 0x38d0, 0x38d4, 0x38d8, 0x38dc, 0x38e0, 0x38e4, 0x38e8, 0x38ec, 0x38f8, 0x38fc, 0x3900, 0x3904, -0x3908, 0x390c, 0x3910, 0x3914, 0x3918, 0x391c, 0x3920, 0x3924, 0x3928, 0x392c, 0x3930, 0x3934, 0x3938, 0x393c, 0x3940, 0x3944, -0x3948, 0x394c, 0x3950, 0x3954, 0x3958, 0x395c, 0x3960, 0x3964, 0x3968, 0x396c, 0x3970, 0x3974, 0x3978, 0x397c, 0x3980, 0x3984, -0x3988, 0x398c, 0x3994, 0x3998, 0x399c, 0x39a0, 0x39a8, 0x39ac, 0x39b0, 0x3c64, 0x3c6c, 0x3c70, 0x3c74, 0x3c78, 0x3c7c, 0x3c80, -0x3c84, 0x3c88, 0x3c8c, 0x3c94, 0x3c98, 0x3ca0, 0x3ca4, 0x3cac, 0x3cb0, 0x3cb4, 0x3cb8, 0x3cbc, 0x3cc0, 0x3cc4, 0x3cd0, 0x3cd8, -0x3cdc, 0x3ce8, 0x3cf4, 0x3cf8, 0x3d00, 0x3d08, 0x3d0c, 0x3d10, 0x3d18, 0x3d1c, 0x3d24, 0x3d2c, 0x3d30, 0x3d34, 0x3d38, 0x3d3c, -0x3d44, 0x3d54, 0x3d60, 0x3d68, 0x3d70, 0x3d74, 0x3d78, 0x3d7c, 0x3d80, 0x3d88, 0x3d8c, 0x3d90, 0x3d98, 0x3ee8, 0x3ef4, 0x3ef8, -0x3efc, 0x3f04, 0x3f0c, 0x3f24, 0x3f30, 0x3f38, 0x3f3c, 0x3f40, 0x3f44, 0x3f50, 0x3f90, 0x3f98, 0x3fb8, 0x3fc8, 0x3fcc, 0x3fd0, -0x3fd4, 0x3fd8, 0x3fdc, 0x3fe0, 0x3fe4, 0x3fec, 0x3ffc, 0x4000, 0x4004, 0x4008, 0x400c, 0x4010, 0x4014, 0x4018, 0x401c, 0x4020, -0x4024, 0x4028, 0x4034, 0x4038, 0x4040, 0x4044, 0x4050, 0x4058, 0x405c, 0x4060, 0x4064, 0x4068, 0x406c, 0x4070, 0x4074, 0x4078, -0x407c, 0x4080, 0x4084, 0x4088, 0x408c, 0x4090, 0x4094, 0x4098, 0x409c, 0x40a0, 0x40a4, 0x40a8, 0x40ac, 0x40b0, 0x40b4, 0x40b8, -0x40bc, 0x40c0, 0x40c4, 0x40cc, 0x40d0, 0x40d4, 0x40d8, 0x40dc, 0x40e0, 0x40e4, 0x40e8, 0x40ec, 0x40f0, 0x40f4, 0x40f8, 0x40fc, -0x4100, 0x4104, 0x4108, 0x4138, 0x413c, 0x4140, 0x4148, 0x414c, 0x4150, 0x4154, 0x415c, 0x4160, 0x4170, 0x4174, 0x4178, 0x417c, -0x4180, 0x4184, 0x4188, 0x4190, 0x4198, 0x41a8, 0x41b0, 0x41b8, 0x41c0, 0x41c4, 0x41c8, 0x41cc, 0x41d0, 0x41d4, 0x41d8, 0x41dc, +0x1d68, 0x1d6c, 0x1d70, 0x1d74, 0x1d78, 0x1d7c, 0x1d80, 0x1d84, 0x1d88, 0x1d8c, 0x1d90, 0x1d94, 0x1f78, 0x1f84, 0x1fa8, 0x1fac, +0x1fc8, 0x1fcc, 0x1fd0, 0x1fd4, 0x1fd8, 0x1fdc, 0x1fe0, 0x1fe4, 0x1fe8, 0x1fec, 0x1ff0, 0x1ff4, 0x1ff8, 0x1ffc, 0x2000, 0x2004, +0x2008, 0x200c, 0x2010, 0x2014, 0x2018, 0x201c, 0x2020, 0x2024, 0x2028, 0x202c, 0x2030, 0x2034, 0x2038, 0x203c, 0x2040, 0x2044, +0x2048, 0x204c, 0x2050, 0x2054, 0x2058, 0x205c, 0x2068, 0x206c, 0x2070, 0x2074, 0x2078, 0x207c, 0x2080, 0x2084, 0x2088, 0x208c, +0x2090, 0x2094, 0x2098, 0x209c, 0x20a0, 0x20a4, 0x20a8, 0x20ac, 0x20b0, 0x20b4, 0x20b8, 0x20bc, 0x20c0, 0x20c4, 0x20c8, 0x20cc, +0x20d0, 0x20d4, 0x20d8, 0x20dc, 0x20e0, 0x20e4, 0x20e8, 0x20ec, 0x20f0, 0x20f4, 0x20f8, 0x20fc, 0x2108, 0x210c, 0x2110, 0x2114, +0x2118, 0x211c, 0x2120, 0x2124, 0x2128, 0x212c, 0x2130, 0x2134, 0x2138, 0x213c, 0x2140, 0x2144, 0x2148, 0x214c, 0x2150, 0x2154, +0x2158, 0x215c, 0x2160, 0x2164, 0x2168, 0x216c, 0x2170, 0x2174, 0x2178, 0x217c, 0x2180, 0x2184, 0x2188, 0x218c, 0x2190, 0x2194, +0x2198, 0x219c, 0x21a8, 0x21ac, 0x21b0, 0x21b4, 0x21b8, 0x21bc, 0x21c0, 0x21c4, 0x21c8, 0x21cc, 0x21d0, 0x21d4, 0x21d8, 0x21dc, +0x21e0, 0x21e4, 0x21e8, 0x21ec, 0x21f0, 0x21f4, 0x21f8, 0x21fc, 0x2200, 0x2204, 0x2208, 0x220c, 0x2210, 0x2214, 0x2218, 0x221c, +0x2220, 0x2224, 0x2228, 0x222c, 0x2230, 0x2234, 0x2238, 0x223c, 0x2248, 0x224c, 0x2250, 0x2254, 0x2258, 0x225c, 0x2260, 0x2264, +0x2268, 0x226c, 0x2270, 0x2274, 0x2278, 0x227c, 0x2280, 0x2284, 0x2288, 0x228c, 0x2290, 0x2294, 0x2298, 0x229c, 0x22a0, 0x22a4, +0x22a8, 0x22ac, 0x22b0, 0x22b4, 0x22b8, 0x22bc, 0x22c0, 0x22c4, 0x22c8, 0x22cc, 0x22d0, 0x22d4, 0x22d8, 0x22dc, 0x22e8, 0x22ec, +0x22f0, 0x22f4, 0x22f8, 0x22fc, 0x2300, 0x2304, 0x2308, 0x230c, 0x2310, 0x2314, 0x2318, 0x231c, 0x2320, 0x2324, 0x2328, 0x232c, +0x2330, 0x2334, 0x2338, 0x233c, 0x2340, 0x2344, 0x2348, 0x234c, 0x2350, 0x2354, 0x2358, 0x235c, 0x2360, 0x2364, 0x2368, 0x236c, +0x2370, 0x2374, 0x2378, 0x237c, 0x2388, 0x238c, 0x2390, 0x2394, 0x2398, 0x239c, 0x23a0, 0x23a4, 0x23a8, 0x23ac, 0x23b0, 0x23b4, +0x23b8, 0x23bc, 0x23c0, 0x23c4, 0x23c8, 0x23cc, 0x23d0, 0x23d4, 0x23d8, 0x23dc, 0x23e0, 0x23e4, 0x23e8, 0x23ec, 0x23f0, 0x23f4, +0x23f8, 0x23fc, 0x2400, 0x2404, 0x2408, 0x240c, 0x2410, 0x2414, 0x2418, 0x241c, 0x2420, 0x2424, 0x2438, 0x243c, 0x2440, 0x2444, +0x2448, 0x244c, 0x2450, 0x2454, 0x2458, 0x245c, 0x2460, 0x2464, 0x2468, 0x246c, 0x2470, 0x2474, 0x2478, 0x247c, 0x2480, 0x2484, +0x2488, 0x248c, 0x2490, 0x2494, 0x2498, 0x249c, 0x24a0, 0x24a4, 0x24a8, 0x24ac, 0x24b0, 0x24b4, 0x24b8, 0x24bc, 0x24c0, 0x24c4, +0x24c8, 0x24cc, 0x24d8, 0x24dc, 0x24e0, 0x24e4, 0x24e8, 0x24ec, 0x24f0, 0x24f4, 0x24f8, 0x24fc, 0x2500, 0x2504, 0x2508, 0x250c, +0x2510, 0x2514, 0x2518, 0x251c, 0x2520, 0x2524, 0x2528, 0x252c, 0x2530, 0x2534, 0x2538, 0x253c, 0x2540, 0x2544, 0x2548, 0x254c, +0x2550, 0x2554, 0x2558, 0x255c, 0x2560, 0x2564, 0x2568, 0x256c, 0x2570, 0x2578, 0x257c, 0x2580, 0x2584, 0x2588, 0x258c, 0x2590, +0x2594, 0x2598, 0x259c, 0x25a0, 0x25a4, 0x25a8, 0x25ac, 0x25b0, 0x25b4, 0x25b8, 0x25bc, 0x25c0, 0x25c4, 0x25c8, 0x25cc, 0x25d0, +0x25d4, 0x25d8, 0x25dc, 0x25e0, 0x25e4, 0x25e8, 0x25ec, 0x25f0, 0x25f4, 0x25f8, 0x25fc, 0x2600, 0x2604, 0x2608, 0x260c, 0x2618, +0x261c, 0x2620, 0x2624, 0x2628, 0x262c, 0x2630, 0x2634, 0x2638, 0x263c, 0x2640, 0x2644, 0x2648, 0x264c, 0x2650, 0x2654, 0x2658, +0x265c, 0x2660, 0x2664, 0x2668, 0x266c, 0x2670, 0x2674, 0x2678, 0x267c, 0x2680, 0x2684, 0x2688, 0x268c, 0x2690, 0x2694, 0x2698, +0x269c, 0x26a0, 0x26a4, 0x26a8, 0x26ac, 0x26b0, 0x26b8, 0x26bc, 0x26c0, 0x26c4, 0x26c8, 0x26cc, 0x26d0, 0x26d4, 0x26d8, 0x26dc, +0x26e0, 0x26e4, 0x26e8, 0x26ec, 0x26f0, 0x26f4, 0x26f8, 0x26fc, 0x2700, 0x2704, 0x2708, 0x270c, 0x2710, 0x2714, 0x2718, 0x271c, +0x2720, 0x2724, 0x2728, 0x272c, 0x2730, 0x2734, 0x2738, 0x273c, 0x2740, 0x2744, 0x2748, 0x274c, 0x2750, 0x2758, 0x275c, 0x2760, +0x2764, 0x2768, 0x2774, 0x2778, 0x277c, 0x2aa8, 0x2ab0, 0x2ab4, 0x2ab8, 0x2abc, 0x2ac0, 0x2ac4, 0x2ac8, 0x2acc, 0x2ad0, 0x2c18, +0x2c20, 0x2c24, 0x2c28, 0x2c2c, 0x2c30, 0x2c34, 0x2c38, 0x2c3c, 0x2c40, 0x2c44, 0x2c48, 0x2c4c, 0x2c50, 0x2c54, 0x2c58, 0x2c5c, +0x2c60, 0x2c64, 0x2c68, 0x2c6c, 0x2c70, 0x2c74, 0x2c78, 0x2c7c, 0x2c80, 0x2c84, 0x2c88, 0x2c8c, 0x2c90, 0x2c94, 0x2c98, 0x2c9c, +0x2ca0, 0x2ca4, 0x2ca8, 0x2cac, 0x2cb0, 0x2cb4, 0x2e00, 0x2e04, 0x2e08, 0x2eb8, 0x2ebc, 0x2ec0, 0x2ec4, 0x2ec8, 0x2ecc, 0x2ed0, +0x2ed4, 0x2ed8, 0x2edc, 0x2ee0, 0x2ee4, 0x2ee8, 0x2eec, 0x2ef0, 0x2ef4, 0x2ef8, 0x2efc, 0x2f00, 0x2f04, 0x2f08, 0x2f0c, 0x2f10, +0x2f14, 0x2f18, 0x2f1c, 0x2f20, 0x2f24, 0x2f28, 0x2f2c, 0x2f30, 0x2f34, 0x2f38, 0x2f3c, 0x2f40, 0x2f44, 0x2f48, 0x2f4c, 0x2f58, +0x2f5c, 0x2f60, 0x2f64, 0x2f68, 0x2f6c, 0x2f70, 0x2f74, 0x2f78, 0x2f7c, 0x2f80, 0x2f84, 0x2f88, 0x2f8c, 0x2f90, 0x2f94, 0x2f98, +0x2f9c, 0x2fa0, 0x2fa4, 0x2fa8, 0x2fac, 0x2fb0, 0x2fb4, 0x2fb8, 0x2fbc, 0x2fc0, 0x2fc4, 0x2fc8, 0x2fcc, 0x2fd0, 0x2fd4, 0x2fd8, +0x2fdc, 0x2fe0, 0x2fe4, 0x2fe8, 0x2fec, 0x2ff8, 0x2ffc, 0x3000, 0x3004, 0x3008, 0x300c, 0x3010, 0x3014, 0x3018, 0x301c, 0x3020, +0x3024, 0x3028, 0x302c, 0x3030, 0x3034, 0x3038, 0x303c, 0x3040, 0x3044, 0x3048, 0x304c, 0x3050, 0x3054, 0x3058, 0x305c, 0x3060, +0x3064, 0x3068, 0x306c, 0x3070, 0x3074, 0x3078, 0x307c, 0x3080, 0x3084, 0x3088, 0x308c, 0x3098, 0x30a0, 0x30a8, 0x30ac, 0x30b0, +0x30bc, 0x30c0, 0x3170, 0x3174, 0x3178, 0x317c, 0x3180, 0x3184, 0x3188, 0x318c, 0x3190, 0x3194, 0x3198, 0x319c, 0x31a0, 0x31a4, +0x31a8, 0x31ac, 0x31b0, 0x31b4, 0x31b8, 0x31bc, 0x31c0, 0x31c4, 0x31c8, 0x31cc, 0x31d0, 0x31d4, 0x31d8, 0x31dc, 0x31e0, 0x31e4, +0x31e8, 0x31ec, 0x31f0, 0x31f4, 0x31f8, 0x31fc, 0x3200, 0x3204, 0x3348, 0x3350, 0x3408, 0x340c, 0x3410, 0x3414, 0x3418, 0x341c, +0x3420, 0x3424, 0x3428, 0x342c, 0x3430, 0x3434, 0x3438, 0x343c, 0x3440, 0x3444, 0x3448, 0x344c, 0x3450, 0x3454, 0x3458, 0x345c, +0x3460, 0x3464, 0x3468, 0x346c, 0x3470, 0x3474, 0x3478, 0x347c, 0x3480, 0x3484, 0x3488, 0x348c, 0x3490, 0x3494, 0x3498, 0x349c, +0x34a8, 0x34ac, 0x34b0, 0x34b4, 0x34b8, 0x34bc, 0x34c0, 0x34c4, 0x34c8, 0x34cc, 0x34d0, 0x34d4, 0x34d8, 0x34dc, 0x34e0, 0x34e4, +0x34e8, 0x34ec, 0x34f0, 0x34f4, 0x34f8, 0x34fc, 0x3500, 0x3504, 0x3508, 0x350c, 0x3510, 0x3514, 0x3518, 0x351c, 0x3520, 0x3524, +0x3528, 0x352c, 0x3530, 0x3534, 0x3538, 0x353c, 0x3544, 0x3548, 0x354c, 0x3550, 0x355c, 0x3560, 0x3564, 0x3568, 0x356c, 0x3570, +0x3574, 0x3578, 0x357c, 0x3580, 0x3584, 0x3588, 0x3630, 0x3638, 0x363c, 0x3640, 0x3644, 0x3648, 0x364c, 0x3650, 0x3654, 0x3658, +0x365c, 0x3660, 0x3664, 0x3668, 0x366c, 0x3670, 0x3674, 0x3678, 0x367c, 0x3680, 0x3684, 0x3688, 0x368c, 0x3690, 0x3694, 0x3698, +0x369c, 0x36a0, 0x36a4, 0x36a8, 0x36ac, 0x36b0, 0x36b4, 0x36b8, 0x36bc, 0x36c0, 0x36c4, 0x36c8, 0x36cc, 0x36d0, 0x36d8, 0x36dc, +0x36e0, 0x36e4, 0x36e8, 0x36ec, 0x36f0, 0x36f4, 0x36f8, 0x36fc, 0x3700, 0x3704, 0x3708, 0x370c, 0x3710, 0x3714, 0x3718, 0x371c, +0x3720, 0x3724, 0x3728, 0x372c, 0x3730, 0x3734, 0x3738, 0x373c, 0x3740, 0x3744, 0x3748, 0x374c, 0x3750, 0x3754, 0x3758, 0x375c, +0x3760, 0x3764, 0x3768, 0x376c, 0x3770, 0x3774, 0x3778, 0x377c, 0x3780, 0x3784, 0x3788, 0x378c, 0x3790, 0x3794, 0x3798, 0x379c, +0x37a0, 0x37a4, 0x37a8, 0x37ac, 0x3858, 0x385c, 0x3860, 0x3864, 0x3868, 0x386c, 0x3870, 0x3874, 0x3878, 0x387c, 0x3880, 0x3884, +0x3888, 0x388c, 0x3890, 0x3894, 0x3898, 0x389c, 0x38a0, 0x38a4, 0x38a8, 0x38ac, 0x38b0, 0x38b4, 0x38b8, 0x38bc, 0x38c0, 0x38c4, +0x38c8, 0x38cc, 0x38d0, 0x38d4, 0x38d8, 0x38dc, 0x38e0, 0x38e4, 0x38e8, 0x38ec, 0x38f8, 0x38fc, 0x3900, 0x3904, 0x3908, 0x390c, +0x3910, 0x3914, 0x3918, 0x391c, 0x3920, 0x3924, 0x3928, 0x392c, 0x3930, 0x3934, 0x3938, 0x393c, 0x3940, 0x3944, 0x3948, 0x394c, +0x3950, 0x3954, 0x3958, 0x395c, 0x3960, 0x3964, 0x3968, 0x396c, 0x3970, 0x3974, 0x3978, 0x397c, 0x3980, 0x3984, 0x3988, 0x398c, +0x3990, 0x3994, 0x3998, 0x399c, 0x39a0, 0x39a4, 0x39a8, 0x39ac, 0x39b0, 0x39b8, 0x3af8, 0x3b98, 0x3b9c, 0x3ba0, 0x3ba4, 0x3ba8, +0x3bc0, 0x3bc4, 0x3bc8, 0x3bcc, 0x3bd0, 0x3bd4, 0x3bd8, 0x3bdc, 0x3be0, 0x3be4, 0x3be8, 0x3bec, 0x3bf0, 0x3bf4, 0x3bf8, 0x3bfc, +0x3c00, 0x3c04, 0x3c08, 0x3c0c, 0x3c10, 0x3c14, 0x3c18, 0x3c1c, 0x3c20, 0x3c24, 0x3c28, 0x3c2c, 0x3c30, 0x3c34, 0x3c38, 0x3c3c, +0x3c40, 0x3c44, 0x3c48, 0x3c4c, 0x3c50, 0x3c54, 0x3c64, 0x3c6c, 0x3c70, 0x3c74, 0x3c78, 0x3c7c, 0x3c80, 0x3c84, 0x3c88, 0x3c8c, +0x3c94, 0x3c98, 0x3ca0, 0x3ca4, 0x3cac, 0x3cb0, 0x3cb4, 0x3cb8, 0x3cbc, 0x3cc0, 0x3cc4, 0x3cd0, 0x3cd8, 0x3cdc, 0x3ce8, 0x3cf4, +0x3cf8, 0x3d00, 0x3d08, 0x3d0c, 0x3d10, 0x3d18, 0x3d1c, 0x3d24, 0x3d2c, 0x3d30, 0x3d34, 0x3d38, 0x3d3c, 0x3d44, 0x3d54, 0x3d60, +0x3d68, 0x3d70, 0x3d74, 0x3d78, 0x3d7c, 0x3d80, 0x3d88, 0x3d8c, 0x3d90, 0x3d98, 0x3ee8, 0x3ef4, 0x3ef8, 0x3efc, 0x3f04, 0x3f0c, +0x3f1c, 0x3f24, 0x3f30, 0x3f38, 0x3f3c, 0x3f40, 0x3f44, 0x3f50, 0x3f90, 0x3f98, 0x3fa0, 0x3fb8, 0x3fc8, 0x3fcc, 0x3fd0, 0x3fd4, +0x3fd8, 0x3fdc, 0x3fe0, 0x3fe4, 0x3fec, 0x3ffc, 0x4000, 0x4004, 0x4008, 0x400c, 0x4010, 0x4014, 0x4018, 0x401c, 0x4020, 0x4024, +0x4028, 0x4034, 0x4038, 0x4040, 0x4044, 0x4050, 0x4058, 0x405c, 0x4060, 0x4064, 0x4068, 0x406c, 0x4070, 0x4074, 0x4078, 0x407c, +0x4080, 0x4084, 0x4088, 0x408c, 0x4090, 0x4094, 0x4098, 0x409c, 0x40a0, 0x40a4, 0x40a8, 0x40ac, 0x40b0, 0x40b4, 0x40b8, 0x40bc, +0x40c0, 0x40c4, 0x40cc, 0x40d0, 0x40d4, 0x40d8, 0x40dc, 0x40e0, 0x40e4, 0x40e8, 0x40ec, 0x40f0, 0x40f4, 0x40f8, 0x40fc, 0x4100, +0x4104, 0x4108, 0x4138, 0x413c, 0x4140, 0x4148, 0x414c, 0x4150, 0x4154, 0x415c, 0x4160, 0x4170, 0x4174, 0x4178, 0x417c, 0x4180, +0x4184, 0x4188, 0x4190, 0x4198, 0x41a0, 0x41a8, 0x41b0, 0x41b8, 0x41c0, 0x41c4, 0x41c8, 0x41cc, 0x41d0, 0x41d4, 0x41d8, 0x41dc, 0x41e0, 0x41e8, 0x41f0, 0x41f4, 0x41f8, 0x41fc, 0x4200, 0x4204, 0x4208, 0x4210, 0x4218, 0x421c, 0x4220, 0x4224, 0x4228, 0x422c, -0x4230, 0x4238, 0x423c, 0x4240, 0x4248, 0x4258, 0x425c, 0x426c, 0x4270, 0x4280, 0x4284, 0x4294, 0x4298, 0x42a8, 0x42d4, 0x42e4, -0x42fc, 0x430c, 0x4310, 0x4320, 0x4324, 0x4334, 0x434c, 0x4350, 0x4354, 0x435c, 0x4364, 0x436c, 0x4370, 0x4378, 0x437c, 0x4380, -0x4384, 0x438c, 0x4394, 0x4398, 0x439c, 0x43a8, 0x43ac, 0x43b4, 0x43b8, 0x43c0, 0x43cc, 0x43cd, 0x43ce, 0x43cf, 0x43d0, 0x43d1, -0x43d2, 0x43d3, 0x43d8, 0x43dc, 0x43e0, 0x43e4, 0x43e8, 0x43f8, 0x43fc, 0x4414, 0x4418, 0x441c, 0x4420, 0x4430, 0x4438, 0x443c, -0x4440, 0x4450, 0x4454, 0x4458, 0x445c, 0x4460, 0x4464, 0x4468, 0x446c, 0x4470, 0x4474, 0x4478, 0x447c, 0x4480, 0x4484, 0x4488, -0x448c, 0x4490, 0x4494, 0x4498, 0x449c, 0x44a0, 0x44a4, 0x44a8, 0x44b0, 0x44b4, 0x44b8, 0x44bc, 0x44c0, 0x44c4, 0x44c8, 0x44d8, -0x44dc, 0x44e0, 0x44e4, 0x44e8, 0x44ec, 0x44f0, 0x44f4, 0x44f8, 0x44fc, 0x4500, 0x4504, 0x4508, 0x450c, 0x4510, 0x4514, 0x4518, -0x451c, 0x4520, 0x4524, 0x4528, 0x4530, 0x4534, 0x4538, 0x4544, 0x4548, 0x454c, 0x4554, 0x4560, 0x4564, 0x456c, 0x4570, +0x4230, 0x4238, 0x423c, 0x4240, 0x4248, 0x424c, 0x4250, 0x4254, 0x4258, 0x425c, 0x4260, 0x4264, 0x4268, 0x426c, 0x4270, 0x4274, +0x4278, 0x427c, 0x4280, 0x4284, 0x4288, 0x428c, 0x4290, 0x4294, 0x4298, 0x429c, 0x42a0, 0x42a4, 0x42a8, 0x42d4, 0x42d8, 0x42dc, +0x42e0, 0x42e4, 0x42fc, 0x4300, 0x4304, 0x4308, 0x430c, 0x4310, 0x4314, 0x4318, 0x431c, 0x4320, 0x4324, 0x4328, 0x432c, 0x4330, +0x4334, 0x434c, 0x4350, 0x4354, 0x435c, 0x4360, 0x4364, 0x4368, 0x436c, 0x4370, 0x4374, 0x4378, 0x437c, 0x4380, 0x4384, 0x438c, +0x4394, 0x4398, 0x439c, 0x43a4, 0x43a8, 0x43ac, 0x43b0, 0x43b4, 0x43b8, 0x43bc, 0x43c0, 0x43cc, 0x43cd, 0x43ce, 0x43cf, 0x43d0, +0x43d1, 0x43d2, 0x43d3, 0x43d8, 0x43dc, 0x43e0, 0x43e4, 0x43e8, 0x43f8, 0x43fc, 0x4414, 0x4418, 0x441c, 0x4420, 0x4430, 0x4438, +0x443c, 0x4440, 0x4450, 0x4454, 0x4458, 0x445c, 0x4460, 0x4464, 0x4468, 0x446c, 0x4470, 0x4474, 0x4478, 0x447c, 0x4480, 0x4484, +0x4488, 0x448c, 0x4490, 0x4494, 0x4498, 0x449c, 0x44a0, 0x44a4, 0x44a8, 0x44ac, 0x44b0, 0x44b4, 0x44b8, 0x44bc, 0x44c0, 0x44c4, +0x44c8, 0x44d8, 0x44dc, 0x44e0, 0x44e4, 0x44e8, 0x44ec, 0x44f0, 0x44f4, 0x44f8, 0x44fc, 0x4500, 0x4504, 0x4508, 0x450c, 0x4510, +0x4514, 0x4518, 0x451c, 0x4520, 0x4524, 0x4528, 0x4530, 0x4534, 0x4538, 0x4544, 0x4548, 0x454c, 0x4554, 0x4560, 0x4564, 0x456c, +0x4570, From 3af7cabb75f5ddc612508d8694e186913a607603 Mon Sep 17 00:00:00 2001 From: andrewnakas Date: Sat, 3 Oct 2026 14:55:53 -0600 Subject: [PATCH 006/109] Trainer 0.1.4: no comply and boneless heights, top pushing speed that works The game's trick scripts launch the no comply and the boneless through its jump-trajectory setter; the trainer multiplies the launch speed there, told apart by the physics states around the call. Pushing is scripted too, so the push tuning values were never read: the top pushing speed is now applied after each physics step, and push strength is hidden. Co-Authored-By: Claude Opus 5.5 (1M context) --- Extension/Skater/client_noclip.cpp | 66 +++++++++++++ Extension/Skater/client_source_spawn.h | 4 + Extension/Skater/no_bail.cpp | 12 ++- Extension/Skater/no_bail.h | 2 + Extension/Trainer/CHANGELOG.md | 13 +++ Extension/Trainer/README.md | 13 ++- Extension/Trainer/trainer.cpp | 32 ++++++- Extension/Trainer/trainer.h | 2 + Extension/Trainer/trainer_commands.cpp | 2 +- Extension/Trainer/trainer_jump.cpp | 122 +++++++++++++++++++++++++ Extension/Trainer/trainer_jump.h | 19 ++++ Extension/Trainer/trainer_page.cpp | 12 ++- Extension/Trainer/trainer_presets.cpp | 14 +-- Extension/Trainer/trainer_used.inc | 2 +- cmake/Runtime.cmake | 1 + 15 files changed, 294 insertions(+), 22 deletions(-) create mode 100644 Extension/Trainer/trainer_jump.cpp create mode 100644 Extension/Trainer/trainer_jump.h diff --git a/Extension/Skater/client_noclip.cpp b/Extension/Skater/client_noclip.cpp index 2d20fabf..1ac5ed58 100644 --- a/Extension/Skater/client_noclip.cpp +++ b/Extension/Skater/client_noclip.cpp @@ -211,11 +211,69 @@ void trainer_apply_jump_scale(std::uintptr_t core) noexcept { j.outcome.store(scaled ? 2 : before < 0.5f ? -1 : -2); } catch (...) { j.outcome.store(-3); } } +// The trainer's top pushing speed. On the board the game holds the skater at the speed its +// trick scripts ask for (context +0x17f4: 4 m/s for taps, then about 5, 7, 8.5 and 9.1 m/s as +// a push is held) and never runs its own code for the push tuning values. So, after each +// physics step while the skater rides with such a target: at the game's last step the skater +// carries on to `top` times it, and a `top` under 1 holds every step down to that share of +// the last one. The game's choice of step is left alone: it follows the skater's speed, and a +// multiplier on every step feeds back into it (measured: x2 ran away to 17 m/s). +constexpr float push_gain = 0.2f; // m/s the game's own push gains each physics step (0 to 4 m/s in 19 steps) +constexpr float last_step = 0.95f; // of the stock top pushing speed: the game's last step is 9.1 of 9.25 m/s +struct PushTop { + std::atomic top{1}, stock{9.25f}; + std::uintptr_t client{}, entity{}; + std::atomic expires{}; +}; +PushTop& push_top() { static auto* value = new PushTop; return *value; } +void trainer_push_top(std::uintptr_t core) noexcept { + auto& p = push_top(); + const float top = p.top.load(std::memory_order_relaxed); + if (top == 1 || GetTickCount64() >= p.expires.load(std::memory_order_acquire)) return; + const auto watch = watched_physics_state(); + if (!watch.valid || watch.state != 100) return; // riding the ground + SourceLastError error; + auto& state = source_state(); + if (!state.initialized.load(std::memory_order_acquire) || state.busy.test_and_set(std::memory_order_acquire)) return; + SourceBusyScope scope{state.busy}; + try { + const auto bodies = debug_noclip_bodies(state.trial.base, p.client, p.entity); + if (bodies.core != core || bodies.offboard || bodies.parts.empty()) return; + SourceReader reader; + const float target = reader.value(bodies.context, 0x17f4); + std::array, 32> velocities{}; + std::array flags{}; + const auto count = std::min(bodies.parts.size(), velocities.size()); + for (std::size_t i = 0; i < count; ++i) { + velocities[i] = reader.value>(bodies.parts[i], 0x70); + flags[i] = reader.value(bodies.parts[i], 0x60); + } + reader.verify(); + if (!(target > 0.5f) || !(target < 100.0f)) return; + const float speed = std::sqrt(velocities[0][0] * velocities[0][0] + velocities[0][2] * velocities[0][2]); + if (!(speed >= 0.5f) || !(speed < 1000.0f)) return; + const float stock = p.stock.load(std::memory_order_relaxed); + float change = 0; + if (top > 1 && target >= stock * last_step && speed >= target * 0.9f) change = std::min(push_gain, target * top - speed); + // Pushed speed only: a hill's is faster than the game's target and is left alone. + else if (top < 1 && speed > stock * top && speed <= target * 1.1f) change = -std::min(push_gain, speed - stock * top); + if (std::abs(change) < 0.005f) return; + const float scale = (speed + change) / speed; + // Like the native velocity writers: XYZ at +70 and the dirty bit 8 at +60. + for (std::size_t i = 0; i < count; ++i) { + velocities[i][0] *= scale; + velocities[i][2] *= scale; + body_write(bodies.parts[i] + 0x70, velocities[i]); + body_write(bodies.parts[i] + 0x60, flags[i] | 8u); + } + } catch (...) {} +} void noclip_physics_update(std::uintptr_t core) { const auto original = source_state().velocity_update_original.load(std::memory_order_acquire); if (original) original(core); noclip_apply_velocity(core); trainer_apply_jump_scale(core); + trainer_push_top(core); } bool noclip_motion_target(std::uintptr_t rig, std::uintptr_t context, const std::array* supplied, std::array& target) noexcept { @@ -296,6 +354,14 @@ bool queue_jump_scale(std::uintptr_t client, std::uintptr_t entity, float factor return true; } catch (...) { return false; } } +void set_push_top(std::uintptr_t client, std::uintptr_t entity, float top, float stock) noexcept { + auto& p = push_top(); + p.client = client; + p.entity = entity; + p.stock.store(stock > 1 && stock < 100 ? stock : 9.25f, std::memory_order_relaxed); + p.top.store(top > 0.05f && top <= 20 ? top : 1.0f, std::memory_order_relaxed); + p.expires.store(GetTickCount64() + 500, std::memory_order_release); +} JumpScaleResult take_jump_scale_result() noexcept { auto& j = jump_scale(); if (j.pending.load(std::memory_order_acquire)) return {}; diff --git a/Extension/Skater/client_source_spawn.h b/Extension/Skater/client_source_spawn.h index 9397f65d..1a0c5d98 100644 --- a/Extension/Skater/client_source_spawn.h +++ b/Extension/Skater/client_source_spawn.h @@ -20,6 +20,10 @@ struct JumpScaleResult { float up_speed{}; // before scaling }; JumpScaleResult take_jump_scale_result() noexcept; +// Trainer: the local skater's top pushing speed as a multiple of the game's own (1 leaves the +// skater alone); `stock` is the game's top pushing speed in m/s. Publish every client tick: it +// expires after 500 ms. +void set_push_top(std::uintptr_t client, std::uintptr_t entity, float top, float stock) noexcept; // Engine-thread-only interactive controls. Presentation callbacks only queue requests. overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t client, bool can_control, bool camera_phase_observed, const overlay::DebugRequest* request = nullptr, diff --git a/Extension/Skater/no_bail.cpp b/Extension/Skater/no_bail.cpp index 828d1401..2efddc5c 100644 --- a/Extension/Skater/no_bail.cpp +++ b/Extension/Skater/no_bail.cpp @@ -64,7 +64,8 @@ Protection& protection() { static auto* value = new Protection; return *value; } struct StateWatch { std::atomic selector{}; std::atomic until{}, changes{}, wipeouts{}; - std::atomic state{}; + std::atomic state{}, previous{}; + std::atomic since{}, previous_ticks{}; // performance-counter ticks }; StateWatch& state_watch() { static auto* value = new StateWatch; return *value; } @@ -198,7 +199,11 @@ std::uint32_t choose_state(std::uintptr_t selector, std::uint32_t current) { const auto chosen = filtered && next == wipeout_physics_state && protected_owner(selector, &Owner::selector) ? current : next; auto& w = state_watch(); if (selector == w.selector.load(std::memory_order_acquire) && GetTickCount64() < w.until.load(std::memory_order_acquire)) { - if (w.state.exchange(chosen, std::memory_order_acq_rel) != chosen) { + if (const auto before = w.state.exchange(chosen, std::memory_order_acq_rel); before != chosen) { + LARGE_INTEGER now{}; + QueryPerformanceCounter(&now); + w.previous.store(before, std::memory_order_relaxed); + w.previous_ticks.store(now.QuadPart - w.since.exchange(now.QuadPart, std::memory_order_relaxed), std::memory_order_relaxed); w.changes.fetch_add(1, std::memory_order_relaxed); if (chosen == wipeout_physics_state) w.wipeouts.fetch_add(1, std::memory_order_relaxed); } @@ -400,6 +405,9 @@ PhysicsStateWatch watched_physics_state() noexcept { result.valid = protection().ready.load(std::memory_order_acquire) && w.selector.load(std::memory_order_acquire) && GetTickCount64() < w.until.load(std::memory_order_acquire); result.state = w.state.load(std::memory_order_acquire); + result.previous = w.previous.load(std::memory_order_relaxed); + static const double frequency = [] { LARGE_INTEGER value{}; QueryPerformanceFrequency(&value); return static_cast(value.QuadPart); }(); + result.previous_seconds = static_cast(static_cast(w.previous_ticks.load(std::memory_order_relaxed)) / frequency); result.changes = w.changes.load(std::memory_order_relaxed); result.wipeouts = w.wipeouts.load(std::memory_order_relaxed); return result; diff --git a/Extension/Skater/no_bail.h b/Extension/Skater/no_bail.h index 2297f3a7..910da1fb 100644 --- a/Extension/Skater/no_bail.h +++ b/Extension/Skater/no_bail.h @@ -19,6 +19,8 @@ void update_board_lock(std::uintptr_t client, std::uintptr_t entity, bool locked struct PhysicsStateWatch { bool valid{}; std::uint32_t state{}; + std::uint32_t previous{}; // the state before this one + float previous_seconds{}; // how long that one lasted std::uint64_t changes{}, wipeouts{}; // counted since the process started }; void watch_physics_state(std::uintptr_t client, std::uintptr_t entity) noexcept; diff --git a/Extension/Trainer/CHANGELOG.md b/Extension/Trainer/CHANGELOG.md index 2384f845..ad033344 100644 --- a/Extension/Trainer/CHANGELOG.md +++ b/Extension/Trainer/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v0.1.4 - 2026-10-03 + +- **No comply height and boneless height.** Two new sliders beside the hippy jump's (Tune tab, + top of Essentials, and the Presets tab). The game's trick scripts launch these jumps; the + trainer multiplies the launch where the game sets the jump's trajectory, so the skater and + the board go up together. Measured: no comply 0.50 m stock, 1.70 m at x4; boneless 0.46 m + stock, 3.39 m at x9. Console: `trainer option nocomply_height|boneless_height `. +- **Top pushing speed works.** The game skips its own push tuning (pushes aim for speeds the + trick scripts pick), so the value did nothing. Now, holding push carries on past the game's + 9.1 m/s to your number, and a lower number caps pushing. Taps still cruise at the game's + 4 m/s. The Fast and Realistic presets use it. +- **Push strength is hidden:** nothing in this game build reads it while pushes are scripted. + ## v0.1.3 - 2026-10-03 - **Hippy jump height.** A slider on the Tune tab (top of Essentials) and the Presets tab. The game diff --git a/Extension/Trainer/README.md b/Extension/Trainer/README.md index 8aec6edc..b693ba32 100644 --- a/Extension/Trainer/README.md +++ b/Extension/Trainer/README.md @@ -9,7 +9,7 @@ telemetry HUD. It ships no game data: the list of values is built at run time fr | Tab | What you get | |---|---| | **TUNE** | An Essentials list of 21 plainly named values (ollie height, top speed, body flip and spin speed, grind lock-on...), then every plain value of the game's physics tuning (about 760), plus one multiplier for each of its 169 curves and graphs. Search, filter by group, "only what I changed", lock, reset. Changes apply while you skate. Values that no game code was found reading are hidden unless you ask for them. | -| **PRESETS** | Quick switches for super high ollie, fast flips, fast spins and never bail, and a hippy jump height slider. Built-in presets you switch on and off, and that stack (Super Ollie, Fast Flips, Fast Spins, Realistic, Mega Pop, Fast, Hard To Bail, Sticky Grinds, ...), your own saved presets, and a preset a map applies every time it loads. | +| **PRESETS** | Quick switches for super high ollie, fast flips, fast spins and never bail, and height sliders for the no comply, the boneless and the hippy jump. Built-in presets you switch on and off, and that stack (Super Ollie, Fast Flips, Fast Spins, Realistic, Mega Pop, Fast, Hard To Bail, Sticky Grinds, ...), your own saved presets, and a preset a map applies every time it loads. | | **PRACTICE** | Game speed and pause, five marker slots per map (save / go / clear), return to the marker after a bail, teleport to coordinates, copy your position (game or Blender axes). | | **MAP & HUD** | Speed and air-time HUD, a read-out after every jump (takeoff speed and angle, height, distance, drop, landing speed, spin and flip), telemetry recording to CSV, and whatever the map's author ships for the trainer. | @@ -90,6 +90,13 @@ folder>` (CMake option `DINGOSDK_BUILD_TRAINER_TESTS`) lists the tuning values w flips from `PhysicsReckoning.FlipScalar` and `FlipMaxSpeed` (`PerfectBodyFlips` forces exactly one rotation and ignores them), body spins from the `PhysicsBodyspin` graphs. "No use found" is not proof: the pass can miss a use. -- Hippy jump height works by scaling the skater's upward speed as the jump starts. No comply and - boneless heights are not adjustable: the game moves those along a scripted path. +- No comply, boneless and hippy jump heights are not tuning values: the game's trick scripts launch + those. The trainer multiplies the launch speed where the game sets the jump's trajectory (no + comply, boneless: `trainer_jump.cpp`) or scales the skater's upward speed as the jump starts + (hippy jump). +- Pushing is scripted too: the game holds the skater at a speed its trick scripts pick (about 4 m/s + for taps, up to 9.1 m/s while push is held) and skips its own code for the push tuning values. + "Top pushing speed" is therefore the trainer's doing: past the game's last step the skater + carries on to your number, and a lower number caps every step. Push strength has no effect in + this game build and is hidden. - Built for one game build (the one ReSkate 1.0.3 supports). A game update needs a new build. diff --git a/Extension/Trainer/trainer.cpp b/Extension/Trainer/trainer.cpp index 66f05dc9..00e3b384 100644 --- a/Extension/Trainer/trainer.cpp +++ b/Extension/Trainer/trainer.cpp @@ -1,4 +1,5 @@ #include "trainer.h" +#include "trainer_jump.h" #include "trainer_presets.h" #include "Engine/Core/Json/json.h" #include "Engine/Core/Log/logging.h" @@ -105,6 +106,8 @@ struct State { // The hippy jump's height is set by the game's trick scripts, not by tuning: the trainer // scales the upward velocity when it sees one start. float hippy_height{1}; + // The no comply and the boneless are launched by the trick scripts too (trainer_jump.cpp). + float nocomply_height{1}, boneless_height{1}; std::uint32_t last_state{}; float boost_factor{}; // velocity factor of the hippy jump now starting, 0: none std::uint64_t boost_until{}; @@ -206,6 +209,8 @@ void load_store() { s.hud_jump = o.value("hud_jump", true); s.return_delay = std::clamp(o.value("return_delay", 1.5f), 0.0f, 10.0f); s.hippy_height = std::clamp(o.value("hippy_height", 1.0f), 0.25f, 20.0f); + s.nocomply_height = std::clamp(o.value("nocomply_height", 1.0f), 0.25f, 20.0f); + s.boneless_height = std::clamp(o.value("boneless_height", 1.0f), 0.25f, 20.0f); s.slot = std::clamp(o.value("slot", 0), 0, static_cast(marker_slots) - 1); } if (json->contains("values") && json->at("values").is_object()) @@ -248,6 +253,8 @@ void save_store() { options["hud_jump"] = s.hud_jump; options["return_delay"] = s.return_delay; options["hippy_height"] = s.hippy_height; + options["nocomply_height"] = s.nocomply_height; + options["boneless_height"] = s.boneless_height; options["slot"] = s.slot; json["options"] = std::move(options); Json values = Json::object(); @@ -1081,6 +1088,8 @@ void build_view() { next->slot = s.slot; next->auto_return = s.auto_return; next->hippy_height = s.hippy_height; + next->nocomply_height = s.nocomply_height; + next->boneless_height = s.boneless_height; next->return_delay = s.return_delay; next->pad_shortcuts = s.pad_shortcuts; next->hud = s.hud; @@ -1133,6 +1142,22 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool playing, const std::s s.motion.valid = false; s.entity = 0; } + (void)start_trick_heights(base); + // Like the hippy jump's, these are boosts: a session that turns boosts off turns them off. + const bool boosts = !multiplayer_session_active() || session_boosts_allowed(); + set_trick_heights(boosts ? s.nocomply_height : 1.0f, boosts ? s.boneless_height : 1.0f); + // Pushing aims for speeds the trick scripts set and the game's code for its push tuning + // is skipped: the top pushing speed is kept as the player's number and handed over as + // a multiple of the stock one. + const auto ratio = [&](std::string_view id) { + const auto *e = find_entry(id); + return e && e->touched && e->stock > 0 && editable() ? static_cast(e->value / e->stock) : 1.0f; + }; + const auto *top_speed = find_entry("physicspush.maxpushablespeed"); + set_push_top(client, s.entity, boosts ? ratio("physicspush.maxpushablespeed") : 1.0f, top_speed ? static_cast(top_speed->stock) : 0.0f); + for (TrickLaunch launch; take_trick_launch(launch);) + say(logging::Level::info, std::format("Trainer trick: {} launched at {:.2f} m/s up, x{:.2f}.", + launch.trick == Trick::boneless ? "boneless" : "no comply", launch.up_speed, launch.factor)); hold(now); run_selftest(now); publish(s.telemetry); @@ -1264,10 +1289,11 @@ std::string run(std::string_view verb, const std::vector &a) { if (v == "option") { const auto name = lower(arg(0)); bool on{}; - if (name == "hippy_height") { + if (name == "hippy_height" || name == "nocomply_height" || name == "boneless_height") { const auto value = number(arg(1)); - if (!value) return "error: hippy_height needs a multiplier, 1 = the game's own height."; - s.hippy_height = std::clamp(static_cast(*value), 0.25f, 20.0f); + if (!value) return "error: " + name + " needs a multiplier, 1 = the game's own height."; + (name == "hippy_height" ? s.hippy_height : name == "nocomply_height" ? s.nocomply_height : s.boneless_height) = + std::clamp(static_cast(*value), 0.25f, 20.0f); } else if (name == "return_delay") { const auto value = number(arg(1)); if (!value) return "error: return_delay needs seconds."; diff --git a/Extension/Trainer/trainer.h b/Extension/Trainer/trainer.h index e00e3964..3c59ff19 100644 --- a/Extension/Trainer/trainer.h +++ b/Extension/Trainer/trainer.h @@ -62,6 +62,8 @@ struct View { bool pad_shortcuts{true}; // Height of the hippy jump, which the game scripts instead of tuning (x of its own height). float hippy_height{1}; + // The same for the no comply and the boneless. + float nocomply_height{1}, boneless_height{1}; // HUD bool hud{}, hud_jump{true}, logging{}; // The loaded map and what its author ships for the trainer (Mods//trainer.json). diff --git a/Extension/Trainer/trainer_commands.cpp b/Extension/Trainer/trainer_commands.cpp index ebec3159..ad0cf818 100644 --- a/Extension/Trainer/trainer_commands.cpp +++ b/Extension/Trainer/trainer_commands.cpp @@ -22,7 +22,7 @@ void register_trainer_commands(Commands ®istry) { {"marker", "Save, go to or clear a marker", "save|go|clear [slot]", false}, {"tp", "Teleport the skater", " ", false}, {"spot", "Go to one of the map author's spots", "", false}, - {"option", "Trainer options", "hud|hud_jump|auto_return|pad|log 0|1, return_delay , hippy_height ", true}, + {"option", "Trainer options", "hud|hud_jump|auto_return|pad|log 0|1, return_delay , hippy_height|nocomply_height|boneless_height ", true}, {"profile", "The preset this map applies on load", "set |clear", false}, {"jumps", "The last measured jump", nullptr, false}, {"where", "The skater's position, heading and speed", nullptr, false}, diff --git a/Extension/Trainer/trainer_jump.cpp b/Extension/Trainer/trainer_jump.cpp new file mode 100644 index 00000000..a5045f6b --- /dev/null +++ b/Extension/Trainer/trainer_jump.cpp @@ -0,0 +1,122 @@ +#include "trainer_jump.h" +#include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Log/logging.h" +#include "Extension/Skater/no_bail.h" +#include +#include +#include +#include +#include +#include + +namespace dingosdk::trainer { +namespace { +// Game build 25414733. void set_trajectory(trajectory, parameters): the launch velocity is +// the vector at parameters + 0x80. Measured with a scripted controller: +// - a no comply calls it from 0x482f646 on entering the pop (state 200) after a crouch +// (state 103) of about 0.20 s; an ollie crouches for 0.08 s and launches elsewhere; +// - a boneless calls it from 0x47691f8 while the foot is planted (state 602). +constexpr std::uintptr_t set_trajectory = 0x4752aa0, no_comply_call = 0x482f646, boneless_call = 0x47691f8; +constexpr std::array set_trajectory_prefix{0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x80, 0xb9, 0x88, 0xbb, 0x00, + 0x00, 0x00, 0x48, 0x8b, 0xd9, 0x0f, 0x85, 0xf6, 0x00, 0x00, 0x00, + 0xc5, 0xfc, 0x28, 0x05, 0x62, 0x7d, 0xe8, 0x01, 0xc5, 0xfc}; +constexpr std::array no_comply_call_bytes{0xe8, 0x55, 0x34, 0xf2, 0xff}, boneless_call_bytes{0xe8, 0xa3, 0x98, 0xfe, 0xff}; +constexpr std::uint32_t crouch_state = 103, pop_state = 200, plant_state = 602; +constexpr float no_comply_crouch = 0.12f; // seconds: between an ollie's crouch and a no comply's +constexpr std::size_t velocity_y = 0x84 / sizeof(float); + +using SetTrajectory = void (*)(std::uintptr_t, float *); +struct Hooked { + std::atomic original{}; + std::uintptr_t base{}; + std::atomic no_comply{1}, boneless{1}; + // The last few launches, for the trainer's log line: written by the physics thread, read + // by the client tick. + std::array, 8> seen{}; + std::atomic written{}, read{}; + bool attempted{}, ready{}; +}; +Hooked &hooked() { + static auto *value = new Hooked; + return *value; +} +void set_trajectory_hook(std::uintptr_t trajectory, float *parameters) { + auto &h = hooked(); + const auto caller = reinterpret_cast(_ReturnAddress()) - h.base; + if (parameters && (caller == no_comply_call + 5 || caller == boneless_call + 5)) { + const auto watch = watched_physics_state(); // the local skater's + Trick trick{}; + if (caller == boneless_call + 5 && watch.valid && watch.state == plant_state) trick = Trick::boneless; + else if (caller == no_comply_call + 5 && watch.valid && watch.state == pop_state && watch.previous == crouch_state && + watch.previous_seconds >= no_comply_crouch) + trick = Trick::no_comply; + const float up = parameters[velocity_y]; + if (trick != Trick::none && std::isfinite(up) && up > 0.5f) { + const float factor = (trick == Trick::boneless ? h.boneless : h.no_comply).load(std::memory_order_relaxed); + if (factor != 1 && up * factor < 200.0f) parameters[velocity_y] = up * factor; + std::uint32_t bits[2]; + std::memcpy(&bits[0], &up, 4); + std::memcpy(&bits[1], &factor, 4); + // 31 bits of each float (both positive) and the trick in the top bit. + const auto packed = (std::uint64_t{bits[0]} << 32) | (std::uint64_t{bits[1]} << 1) | (trick == Trick::boneless ? 1u : 0u); + h.seen[h.written.fetch_add(1, std::memory_order_relaxed) % h.seen.size()].store(packed, std::memory_order_release); + } + } + if (const auto original = h.original.load(std::memory_order_acquire)) original(trajectory, parameters); +} +bool known_code(std::uintptr_t base) { + __try { + return std::memcmp(reinterpret_cast(base + set_trajectory), set_trajectory_prefix.data(), set_trajectory_prefix.size()) == 0 && + std::memcmp(reinterpret_cast(base + no_comply_call), no_comply_call_bytes.data(), no_comply_call_bytes.size()) == 0 && + std::memcmp(reinterpret_cast(base + boneless_call), boneless_call_bytes.data(), boneless_call_bytes.size()) == 0; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +} // namespace + +bool start_trick_heights(std::uintptr_t base) noexcept { + auto &h = hooked(); + if (h.attempted || !base) return h.ready; + h.attempted = true; + try { + if (!known_code(base)) { + logging::write(logging::Level::warning, logging::Channel::skater, + "Trainer: this game build's trick code is not the known one; no comply and boneless heights are off."); + return false; + } + h.base = base; + auto *target = reinterpret_cast(base + set_trajectory); + void *original{}; + if (hook_prepare(target, reinterpret_cast(&set_trajectory_hook), &original) != HookOk) return false; + if (!original) { + (void)hook_remove(target); + return false; + } + h.original.store(reinterpret_cast(original), std::memory_order_release); + if (hook_enable(target) != HookOk) return false; + h.ready = true; + logging::write(logging::Level::info, logging::Channel::skater, "Trainer: no comply and boneless heights ready."); + } catch (...) {} + return h.ready; +} +void set_trick_heights(float no_comply, float boneless) noexcept { + const auto speed = [](float height) { return std::isfinite(height) && height > 0 ? std::sqrt(std::min(height, 400.0f)) : 1.0f; }; + hooked().no_comply.store(speed(no_comply), std::memory_order_relaxed); + hooked().boneless.store(speed(boneless), std::memory_order_relaxed); +} +bool take_trick_launch(TrickLaunch &out) noexcept { + auto &h = hooked(); + const auto written = h.written.load(std::memory_order_acquire); + auto read = h.read.load(std::memory_order_relaxed); + if (read == written) return false; + if (written - read > h.seen.size()) read = written - static_cast(h.seen.size()); + const auto packed = h.seen[read % h.seen.size()].load(std::memory_order_acquire); + h.read.store(read + 1, std::memory_order_relaxed); + const std::uint32_t bits[2]{static_cast(packed >> 32), static_cast(packed >> 1) & 0x7fffffffu}; + out.trick = packed & 1 ? Trick::boneless : Trick::no_comply; + std::memcpy(&out.up_speed, &bits[0], 4); + std::memcpy(&out.factor, &bits[1], 4); + return true; +} +} // namespace dingosdk::trainer diff --git a/Extension/Trainer/trainer_jump.h b/Extension/Trainer/trainer_jump.h new file mode 100644 index 00000000..f9a2c0f2 --- /dev/null +++ b/Extension/Trainer/trainer_jump.h @@ -0,0 +1,19 @@ +#pragma once +// No comply and boneless heights. The game's trick scripts launch these two through its +// jump-trajectory setter with a fixed upward speed, and no tuning value is read for it: +// the trainer multiplies that speed as the trajectory is set. +#include + +namespace dingosdk::trainer { +enum class Trick : std::uint8_t { none, no_comply, boneless }; +struct TrickLaunch { + Trick trick{}; + float up_speed{}, factor{1}; // the game's own upward speed, and what it was multiplied by +}; +// Installs the hook once the game's code is recognised. Safe to call every tick. +bool start_trick_heights(std::uintptr_t base) noexcept; +// Height multipliers (1: the game's own height). The launch speed is scaled by their roots. +void set_trick_heights(float no_comply, float boneless) noexcept; +// The scripted launches since the last call, oldest first; false when there are none. +bool take_trick_launch(TrickLaunch &out) noexcept; +} // namespace dingosdk::trainer diff --git a/Extension/Trainer/trainer_page.cpp b/Extension/Trainer/trainer_page.cpp index f0a7feb2..9884fbce 100644 --- a/Extension/Trainer/trainer_page.cpp +++ b/Extension/Trainer/trainer_page.cpp @@ -29,8 +29,8 @@ struct Page { std::array preset_name{}; std::array teleport{}; double speed_edit{-1}, speed_until{}; - float hippy_edit{1}; - bool hippy_editing{}; + float hippy_edit{1}, nocomply_edit{1}, boneless_edit{1}; + bool hippy_editing{}, nocomply_editing{}, boneless_editing{}; std::uint64_t open_serial{}; // the last `trainer open` acted on bool show_page{}; }; @@ -183,7 +183,7 @@ void tune_tab(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trai ImGui::SameLine(); if (p.hidden_unused) ImGui::TextDisabled("%zu shown, %zu changed, %zu hidden", p.shown.size(), view.touched, p.hidden_unused); else ImGui::TextDisabled("%zu shown, %zu changed", p.shown.size(), view.touched); - // The hippy jump's height is not a tuning value; it sits with the Essentials. + // These trick heights are not tuning values; they sit with the Essentials. if (p.group == 0 && p.search[0] == 0 && !p.only_changed) trick_heights(menu, callbacks, p, view); const bool with_group = p.search[0] != 0 || p.group <= 1 || p.only_changed; const bool friendly = p.group == 0 && p.search[0] == 0 && !p.only_changed; @@ -198,7 +198,7 @@ void tune_tab(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trai } void trick_heights(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) { - begin_card(menu, "trick-heights", "HIPPY JUMP", "1.0 is the game's own height"); + begin_card(menu, "trick-heights", "TRICK HEIGHTS", "1.0 is the game's own height"); const auto slider = [&](const char *label, const char *option, float value, float &edit, bool &editing) { field(menu, label); ImGui::PushID(option); @@ -211,8 +211,10 @@ void trick_heights(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const if (!ImGui::IsItemActive() && editing && std::abs(value - edit) < 0.005f) editing = false; ImGui::PopID(); }; + slider("No comply height", "nocomply_height", view.nocomply_height, p.nocomply_edit, p.nocomply_editing); + slider("Boneless height", "boneless_height", view.boneless_height, p.boneless_edit, p.boneless_editing); slider("Hippy jump height", "hippy_height", view.hippy_height, p.hippy_edit, p.hippy_editing); - note("Separate from ollie height because the game sets it in its trick scripts."); + note("Separate from ollie height because the game sets these in its trick scripts."); end_card(); } void presets_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) { diff --git a/Extension/Trainer/trainer_presets.cpp b/Extension/Trainer/trainer_presets.cpp index f3ec64e5..a2501ec7 100644 --- a/Extension/Trainer/trainer_presets.cpp +++ b/Extension/Trainer/trainer_presets.cpp @@ -27,7 +27,7 @@ const std::vector &value_links() { } // Only values that do something: read by the game, or linked above. std::string_view essential_name(std::string_view key, int *rank) { - static constexpr std::array, 21> names{{ + static constexpr std::array, 20> names{{ {"physicsmode.jumpmaxheight", "Ollie height (max)"}, {"physicsmode.jumpminheight", "Ollie height (min, light pop)"}, {"physicsmode.grindjumpcommonmax", "Pop out of grinds (max)"}, @@ -38,8 +38,7 @@ std::string_view essential_name(std::string_view key, int *rank) { {"physicsairstates.maxspinspeed", "Body spin speed"}, {"physicsmode.maxautobodyspinspeed", "Auto body spin speed"}, {"physicsmode.easybodyspins", "Easy body spins"}, - {"physicspush.maxpushablespeed", "Top pushing speed (m/s)"}, - {"physicsmode.maxpushdvstart", "Push strength"}, + {"physicspush.maxpushablespeed", "Top pushing speed (m/s, hold push)"}, {"physicsmode.autopushenabled", "Auto push"}, {"physicsmode.speedwobblestartspeed", "Speed wobble starts at (m/s)"}, {"physicsmode.grindlockdist", "Grind lock-on distance"}, @@ -63,7 +62,8 @@ const std::vector &builtin_presets() { // Rules name values the game was found to read (trainer_used.inc) or linked values // (value_links). Jump height comes from the PhysicsJump height graphs; body flips // from FlipScalar and FlipMaxSpeed unless PerfectBodyFlips forces exactly one rotation; - // body spins from the PhysicsBodyspin graphs and MaxAutoBodySpinSpeed. + // body spins from the PhysicsBodyspin graphs and MaxAutoBodySpinSpeed. The top pushing + // speed is the trainer's own doing (set_push_top); the game skips its other push values. {"Super Ollie", "Huge ollies: about three times the height at any speed.", {{"physicsmode.jumpmaxheight", true, 3.0}, {"physicsmode.jumpminheight", true, 3.0}, {"physicsjump.absoluteminheight", true, 2.0}, {"physicsjump.jumpybonusmax", true, 3.0}, @@ -76,16 +76,16 @@ const std::vector &builtin_presets() { {"Realistic", "Lower pop, slower pushing and rotation, earlier speed wobble, easier to bail.", {{"physicsmode.jumpmaxheight", true, 0.75}, {"physicsmode.jumpminheight", true, 0.8}, {"physicsmode.grindjump", true, 0.8}, {"physicspush.maxpushablespeed !camera", true, 0.75}, - {"physicsmode.maxpushdv", true, 0.8}, {"physicsmode.speedwobblestartspeed", true, 0.7}, + {"physicsmode.speedwobblestartspeed", true, 0.7}, {"physicsreckoning.flipscalar", true, 0.8}, {"physicsairstates.maxspinspeed", true, 0.75}, {"physicsmode.maxautobodyspinspeed", true, 0.75}, {"physicsmode.grindlockdist", true, 0.7}, {"physicswipeout.wipeout_ maxspeed", true, 0.8}, {"physicsmode.wipeout_ acceleration", true, 0.8}}}, {"Mega Pop", "Ollies and grind pops go about twice as high.", {{"physicsmode.jumpmaxheight", true, 2.0}, {"physicsmode.jumpminheight", true, 1.6}, {"physicsjump.jumpybonusmax", true, 2.0}, {"physicsmode.grindjump", true, 1.6}}}, - {"Fast", "Push to a higher top speed and get there sooner.", + {"Fast", "Hold push to reach a much higher top speed.", {{"physicspush.maxpushablespeed !camera", true, 1.8}, {"physicspush.maxspeedforautopush", true, 1.8}, - {"physicsmode.maxpushdv", true, 1.6}, {"physicsmode.speedwobblestartspeed", true, 3.0}}}, + {"physicsmode.speedwobblestartspeed", true, 3.0}}}, {"No Speed Wobble", "The board stays steady at any speed.", {{"physicsmode.speedwobblestartspeed", true, 20.0}}}, {"Auto Push", "The skater keeps pushing without input.", {{"physicsmode.autopushenabled", false, 1.0}}}, {"Hard To Bail", "Much larger impacts are needed before a wipeout.", diff --git a/Extension/Trainer/trainer_used.inc b/Extension/Trainer/trainer_used.inc index c312efce..aa2ccac7 100644 --- a/Extension/Trainer/trainer_used.inc +++ b/Extension/Trainer/trainer_used.inc @@ -118,7 +118,7 @@ 0x4230, 0x4238, 0x423c, 0x4240, 0x4248, 0x424c, 0x4250, 0x4254, 0x4258, 0x425c, 0x4260, 0x4264, 0x4268, 0x426c, 0x4270, 0x4274, 0x4278, 0x427c, 0x4280, 0x4284, 0x4288, 0x428c, 0x4290, 0x4294, 0x4298, 0x429c, 0x42a0, 0x42a4, 0x42a8, 0x42d4, 0x42d8, 0x42dc, 0x42e0, 0x42e4, 0x42fc, 0x4300, 0x4304, 0x4308, 0x430c, 0x4310, 0x4314, 0x4318, 0x431c, 0x4320, 0x4324, 0x4328, 0x432c, 0x4330, -0x4334, 0x434c, 0x4350, 0x4354, 0x435c, 0x4360, 0x4364, 0x4368, 0x436c, 0x4370, 0x4374, 0x4378, 0x437c, 0x4380, 0x4384, 0x438c, +0x4334, 0x434c, 0x4350, 0x4354, 0x435c, 0x4360, 0x4364, 0x4368, 0x436c, 0x4374, 0x4378, 0x4380, 0x4384, 0x438c, 0x4394, 0x4398, 0x439c, 0x43a4, 0x43a8, 0x43ac, 0x43b0, 0x43b4, 0x43b8, 0x43bc, 0x43c0, 0x43cc, 0x43cd, 0x43ce, 0x43cf, 0x43d0, 0x43d1, 0x43d2, 0x43d3, 0x43d8, 0x43dc, 0x43e0, 0x43e4, 0x43e8, 0x43f8, 0x43fc, 0x4414, 0x4418, 0x441c, 0x4420, 0x4430, 0x4438, 0x443c, 0x4440, 0x4450, 0x4454, 0x4458, 0x445c, 0x4460, 0x4464, 0x4468, 0x446c, 0x4470, 0x4474, 0x4478, 0x447c, 0x4480, 0x4484, diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index b58cc254..89ae523e 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -106,6 +106,7 @@ add_library(dingosdk_runtime SHARED Extension/Skater/physics_tuning_model.cpp Extension/Trainer/trainer.cpp Extension/Trainer/trainer_presets.cpp + Extension/Trainer/trainer_jump.cpp Extension/Trainer/trainer_commands.cpp Extension/Skater/offboard_flight.cpp Extension/Skater/camera_observer.cpp From 15d105ce345ac29f2239c189d2e7a5b915ffa9ae Mon Sep 17 00:00:00 2001 From: andrewnakas Date: Sat, 3 Oct 2026 15:26:33 -0600 Subject: [PATCH 007/109] Trainer 0.1.4: auto push that works The game hands its auto push flag to the animation and nothing comes of it. With it on, the trainer's push code now brings a rolling skater that is not braking up to the auto push speed. Co-Authored-By: Claude Opus 5.5 (1M context) --- Extension/Skater/client_noclip.cpp | 23 ++++++++++++++++------- Extension/Skater/client_source_spawn.h | 5 +++-- Extension/Trainer/CHANGELOG.md | 5 +++++ Extension/Trainer/README.md | 2 +- Extension/Trainer/trainer.cpp | 6 +++++- Extension/Trainer/trainer_presets.cpp | 7 ++++--- 6 files changed, 34 insertions(+), 14 deletions(-) diff --git a/Extension/Skater/client_noclip.cpp b/Extension/Skater/client_noclip.cpp index 1ac5ed58..5bcfa0b0 100644 --- a/Extension/Skater/client_noclip.cpp +++ b/Extension/Skater/client_noclip.cpp @@ -218,18 +218,22 @@ void trainer_apply_jump_scale(std::uintptr_t core) noexcept { // carries on to `top` times it, and a `top` under 1 holds every step down to that share of // the last one. The game's choice of step is left alone: it follows the skater's speed, and a // multiplier on every step feeds back into it (measured: x2 ran away to 17 m/s). +// Auto push: the game hands its flag to the animation and nothing comes of it (measured: the +// same coast-down with it on). With `cruise` set, a rolling skater that is not braking gains +// speed gently up to it. constexpr float push_gain = 0.2f; // m/s the game's own push gains each physics step (0 to 4 m/s in 19 steps) constexpr float last_step = 0.95f; // of the stock top pushing speed: the game's last step is 9.1 of 9.25 m/s +constexpr float cruise_gain = 0.15f; // m/s each physics step: the game steers the speed back toward its own, so less does nothing struct PushTop { - std::atomic top{1}, stock{9.25f}; + std::atomic top{1}, stock{9.25f}, cruise{}; std::uintptr_t client{}, entity{}; std::atomic expires{}; }; PushTop& push_top() { static auto* value = new PushTop; return *value; } void trainer_push_top(std::uintptr_t core) noexcept { auto& p = push_top(); - const float top = p.top.load(std::memory_order_relaxed); - if (top == 1 || GetTickCount64() >= p.expires.load(std::memory_order_acquire)) return; + const float top = p.top.load(std::memory_order_relaxed), cruise = p.cruise.load(std::memory_order_relaxed); + if ((top == 1 && cruise <= 0) || GetTickCount64() >= p.expires.load(std::memory_order_acquire)) return; const auto watch = watched_physics_state(); if (!watch.valid || watch.state != 100) return; // riding the ground SourceLastError error; @@ -241,6 +245,9 @@ void trainer_push_top(std::uintptr_t core) noexcept { if (bodies.core != core || bodies.offboard || bodies.parts.empty()) return; SourceReader reader; const float target = reader.value(bodies.context, 0x17f4); + // What the game's brake code tests: a brake held (bit 2) or one squeezed by some amount (bit 1, +0x1880). + const auto requests = reader.value(bodies.context, 0x13c4); + const bool braking = (requests & 4u) != 0 || ((requests & 2u) != 0 && std::abs(reader.value(bodies.context, 0x1880)) > 0.05f); std::array, 32> velocities{}; std::array flags{}; const auto count = std::min(bodies.parts.size(), velocities.size()); @@ -249,14 +256,15 @@ void trainer_push_top(std::uintptr_t core) noexcept { flags[i] = reader.value(bodies.parts[i], 0x60); } reader.verify(); - if (!(target > 0.5f) || !(target < 100.0f)) return; const float speed = std::sqrt(velocities[0][0] * velocities[0][0] + velocities[0][2] * velocities[0][2]); if (!(speed >= 0.5f) || !(speed < 1000.0f)) return; const float stock = p.stock.load(std::memory_order_relaxed); + const bool pushed = target > 0.5f && target < 100.0f; // the game has a push speed in mind float change = 0; - if (top > 1 && target >= stock * last_step && speed >= target * 0.9f) change = std::min(push_gain, target * top - speed); + if (pushed && top > 1 && target >= stock * last_step && speed >= target * 0.9f) change = std::min(push_gain, target * top - speed); // Pushed speed only: a hill's is faster than the game's target and is left alone. - else if (top < 1 && speed > stock * top && speed <= target * 1.1f) change = -std::min(push_gain, speed - stock * top); + else if (pushed && top < 1 && speed > stock * top && speed <= target * 1.1f) change = -std::min(push_gain, speed - stock * top); + else if (cruise > 0 && !braking && speed >= 1.0f && speed < std::min(cruise, stock * top)) change = std::min(cruise_gain, cruise - speed); if (std::abs(change) < 0.005f) return; const float scale = (speed + change) / speed; // Like the native velocity writers: XYZ at +70 and the dirty bit 8 at +60. @@ -354,8 +362,9 @@ bool queue_jump_scale(std::uintptr_t client, std::uintptr_t entity, float factor return true; } catch (...) { return false; } } -void set_push_top(std::uintptr_t client, std::uintptr_t entity, float top, float stock) noexcept { +void set_push_top(std::uintptr_t client, std::uintptr_t entity, float top, float stock, float cruise) noexcept { auto& p = push_top(); + p.cruise.store(cruise > 0 && cruise < 100 ? cruise : 0.0f, std::memory_order_relaxed); p.client = client; p.entity = entity; p.stock.store(stock > 1 && stock < 100 ? stock : 9.25f, std::memory_order_relaxed); diff --git a/Extension/Skater/client_source_spawn.h b/Extension/Skater/client_source_spawn.h index 1a0c5d98..391df022 100644 --- a/Extension/Skater/client_source_spawn.h +++ b/Extension/Skater/client_source_spawn.h @@ -21,9 +21,10 @@ struct JumpScaleResult { }; JumpScaleResult take_jump_scale_result() noexcept; // Trainer: the local skater's top pushing speed as a multiple of the game's own (1 leaves the -// skater alone); `stock` is the game's top pushing speed in m/s. Publish every client tick: it +// skater alone); `stock` is the game's top pushing speed in m/s. `cruise` above 0 is auto push: +// a rolling skater that is not braking gains speed up to it (m/s). Publish every client tick: it // expires after 500 ms. -void set_push_top(std::uintptr_t client, std::uintptr_t entity, float top, float stock) noexcept; +void set_push_top(std::uintptr_t client, std::uintptr_t entity, float top, float stock, float cruise) noexcept; // Engine-thread-only interactive controls. Presentation callbacks only queue requests. overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t client, bool can_control, bool camera_phase_observed, const overlay::DebugRequest* request = nullptr, diff --git a/Extension/Trainer/CHANGELOG.md b/Extension/Trainer/CHANGELOG.md index ad033344..13ec4f87 100644 --- a/Extension/Trainer/CHANGELOG.md +++ b/Extension/Trainer/CHANGELOG.md @@ -11,7 +11,12 @@ trick scripts pick), so the value did nothing. Now, holding push carries on past the game's 9.1 m/s to your number, and a lower number caps pushing. Taps still cruise at the game's 4 m/s. The Fast and Realistic presets use it. +- **Auto push works.** The game hands its auto push flag to the animation and nothing comes of it + (measured: the same coast-down with it on). With it on, a rolling skater that is not braking + now gains speed up to the auto push speed (8 m/s; both are on the Essentials list). - **Push strength is hidden:** nothing in this game build reads it while pushes are scripted. +- Checked without a player, with a scripted controller: marker pad shortcuts (LB+RB+Up saves, + LB+RB+Down returns), ollie height, Fast Spins. ## v0.1.3 - 2026-10-03 diff --git a/Extension/Trainer/README.md b/Extension/Trainer/README.md index b693ba32..bb4eb476 100644 --- a/Extension/Trainer/README.md +++ b/Extension/Trainer/README.md @@ -97,6 +97,6 @@ folder>` (CMake option `DINGOSDK_BUILD_TRAINER_TESTS`) lists the tuning values w - Pushing is scripted too: the game holds the skater at a speed its trick scripts pick (about 4 m/s for taps, up to 9.1 m/s while push is held) and skips its own code for the push tuning values. "Top pushing speed" is therefore the trainer's doing: past the game's last step the skater - carries on to your number, and a lower number caps every step. Push strength has no effect in + carries on to your number, and a lower number caps every step. Auto push is the trainer's doing as well (the game's flag only reaches its animation): once rolling and not braking, the skater gains speed up to the auto push speed. Push strength has no effect in this game build and is hidden. - Built for one game build (the one ReSkate 1.0.3 supports). A game update needs a new build. diff --git a/Extension/Trainer/trainer.cpp b/Extension/Trainer/trainer.cpp index 00e3b384..27b9d589 100644 --- a/Extension/Trainer/trainer.cpp +++ b/Extension/Trainer/trainer.cpp @@ -1154,7 +1154,11 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool playing, const std::s return e && e->touched && e->stock > 0 && editable() ? static_cast(e->value / e->stock) : 1.0f; }; const auto *top_speed = find_entry("physicspush.maxpushablespeed"); - set_push_top(client, s.entity, boosts ? ratio("physicspush.maxpushablespeed") : 1.0f, top_speed ? static_cast(top_speed->stock) : 0.0f); + // Auto push is the trainer's doing as well: the game's flag reaches only its animation. + const auto *auto_push = find_entry("physicsmode.autopushenabled"), *auto_speed = find_entry("physicspush.maxspeedforautopush"); + const bool cruising = boosts && editable() && auto_push && auto_speed && auto_push->touched && auto_push->value != 0; + set_push_top(client, s.entity, boosts ? ratio("physicspush.maxpushablespeed") : 1.0f, top_speed ? static_cast(top_speed->stock) : 0.0f, + cruising ? static_cast(auto_speed->value) : 0.0f); for (TrickLaunch launch; take_trick_launch(launch);) say(logging::Level::info, std::format("Trainer trick: {} launched at {:.2f} m/s up, x{:.2f}.", launch.trick == Trick::boneless ? "boneless" : "no comply", launch.up_speed, launch.factor)); diff --git a/Extension/Trainer/trainer_presets.cpp b/Extension/Trainer/trainer_presets.cpp index a2501ec7..cf5a7686 100644 --- a/Extension/Trainer/trainer_presets.cpp +++ b/Extension/Trainer/trainer_presets.cpp @@ -27,7 +27,7 @@ const std::vector &value_links() { } // Only values that do something: read by the game, or linked above. std::string_view essential_name(std::string_view key, int *rank) { - static constexpr std::array, 20> names{{ + static constexpr std::array, 21> names{{ {"physicsmode.jumpmaxheight", "Ollie height (max)"}, {"physicsmode.jumpminheight", "Ollie height (min, light pop)"}, {"physicsmode.grindjumpcommonmax", "Pop out of grinds (max)"}, @@ -39,7 +39,8 @@ std::string_view essential_name(std::string_view key, int *rank) { {"physicsmode.maxautobodyspinspeed", "Auto body spin speed"}, {"physicsmode.easybodyspins", "Easy body spins"}, {"physicspush.maxpushablespeed", "Top pushing speed (m/s, hold push)"}, - {"physicsmode.autopushenabled", "Auto push"}, + {"physicsmode.autopushenabled", "Auto push (keeps a rolling skater going)"}, + {"physicspush.maxspeedforautopush", "Auto push speed (m/s)"}, {"physicsmode.speedwobblestartspeed", "Speed wobble starts at (m/s)"}, {"physicsmode.grindlockdist", "Grind lock-on distance"}, {"physicsgrind.commonfrictionscalar", "Grind friction"}, @@ -87,7 +88,7 @@ const std::vector &builtin_presets() { {{"physicspush.maxpushablespeed !camera", true, 1.8}, {"physicspush.maxspeedforautopush", true, 1.8}, {"physicsmode.speedwobblestartspeed", true, 3.0}}}, {"No Speed Wobble", "The board stays steady at any speed.", {{"physicsmode.speedwobblestartspeed", true, 20.0}}}, - {"Auto Push", "The skater keeps pushing without input.", {{"physicsmode.autopushenabled", false, 1.0}}}, + {"Auto Push", "Once rolling, the skater keeps gaining speed up to the auto push speed (8 m/s).", {{"physicsmode.autopushenabled", false, 1.0}}}, {"Hard To Bail", "Much larger impacts are needed before a wipeout.", {{"physicswipeout.wipeout_ force", true, 3.0}, {"physicswipeout.wipeout_ acceleration", true, 3.0}, {"physicswipeout.wipeout_ maxspeed", true, 2.5}, {"physicswipeout.wipeout_ maxdisp", true, 3.0}, {"physicswipeout.wipeout_ relativevel", true, 3.0}, From 151fc78eccb3a5d8862d0039b1595e0b6260946e Mon Sep 17 00:00:00 2001 From: andrewnakas Date: Sat, 3 Oct 2026 23:10:42 -0600 Subject: [PATCH 008/109] Trainer: a tool that lists the game's data assets and their values Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018szL7EmApPZxh9GK4Phw6x --- CMakeLists.txt | 3 + Extension/Trainer/Test/asset_list.cpp | 159 ++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 Extension/Trainer/Test/asset_list.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 05e33a7f..f92d8cb1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -189,6 +189,9 @@ if(DINGOSDK_BUILD_TRAINER_TESTS) 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}") + 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) diff --git a/Extension/Trainer/Test/asset_list.cpp b/Extension/Trainer/Test/asset_list.cpp new file mode 100644 index 00000000..ffc1fdb2 --- /dev/null +++ b/Extension/Trainer/Test/asset_list.cpp @@ -0,0 +1,159 @@ +// Lists the data assets of the game's level root bundle, or the values of one of them. +// dingosdk_trainer_asset_list [ ...] names containing any word +// dingosdk_trainer_asset_list --dump every plain value, by name +#include "Engine/Resource/ebx_document.h" +#include "Engine/Vfs/game_bundles.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ebx = dingosdk::frostbite::ebx; +namespace { +std::string lower(std::string_view text) { + std::string result(text); + std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return result; +} +std::size_t value_size(ebx::FieldType type) { + switch (type) { + case ebx::FieldType::boolean: case ebx::FieldType::int8: case ebx::FieldType::uint8: return 1; + case ebx::FieldType::int16: case ebx::FieldType::uint16: return 2; + case ebx::FieldType::int32: case ebx::FieldType::uint32: case ebx::FieldType::enumeration: + case ebx::FieldType::float32: return 4; + case ebx::FieldType::int64: case ebx::FieldType::uint64: case ebx::FieldType::float64: return 8; + default: return 0; + } +} +void walk(const ebx::Document &document, const ebx::TypeDescriptor &type, std::span image, std::size_t start, int depth, + const std::string &path) { + if (depth > 16) return; + for (std::size_t i = 0; i < type.fieldCount; ++i) { + const auto index = static_cast(type.fieldIndex) + i; + if (index >= document.fields.size()) return; + const auto &field = document.fields[index]; + const auto kind = field.type(); + const auto name = path.empty() ? field.name : field.name.empty() ? path : path + "." + field.name; + if (kind == ebx::FieldType::inherited || (kind == ebx::FieldType::structure && field.category() != ebx::FieldCategory::array)) { + if (field.classRef >= document.types.size()) continue; + const bool inherited = kind == ebx::FieldType::inherited; + walk(document, document.types[field.classRef], image, inherited ? start : start + field.dataOffset, depth + 1, + inherited ? path : name); + continue; + } + const auto offset = start + field.dataOffset; + if (field.category() == ebx::FieldCategory::array) { + std::cout << name << "\t0x" << std::hex << offset << std::dec << "\tarray\n"; + continue; + } + if (kind == ebx::FieldType::pointer) { + std::cout << name << "\t0x" << std::hex << offset << std::dec << "\tpointer\n"; + continue; + } + const auto size = value_size(kind); + if (!size || offset + size > image.size()) continue; + std::cout << name << "\t0x" << std::hex << offset << std::dec << '\t'; + if (kind == ebx::FieldType::float32) { + float value; + std::memcpy(&value, image.data() + offset, 4); + std::cout << "real\t" << value; + } else if (kind == ebx::FieldType::boolean) { + std::cout << "flag\t" << static_cast(image[offset]); + } else { + std::int64_t value{}; + std::memcpy(&value, image.data() + offset, size); + std::cout << "int" << size * 8 << '\t' << value; + } + std::cout << '\n'; + } +} +} // namespace + +int main(int argc, char **argv) { + if (argc < 3) { + std::cerr << "usage: dingosdk_trainer_asset_list ... | --dump | --types ...\n"; + return 2; + } + try { + const std::filesystem::path game_root(argv[1]); + const dingosdk::vfs::GameData data(game_root); + const auto found = data.read_bundle(data.read_toc("Win32/levels/game/bam_levelroot/bam_levelroot.toc"), + "win32/levels/game/bam_levelroot/bam_levelroot"); + if (!found) throw std::runtime_error("the level root bundle was not found"); + const auto &bundle = *found; + const std::string mode = argv[2]; + if (mode == "--floats" && argc >= 4) { + // Every aligned four bytes of the asset's data that read as a plausible number. + const auto wanted = lower(argv[3]); + for (std::size_t i = 0; i < bundle.manifest.ebx.size(); ++i) { + if (lower(bundle.manifest.ebx[i].name) != wanted) continue; + const auto *payload = bundle.payload(dingosdk::frostbite::AssetKind::ebx, i); + if (!payload) break; + const auto bytes = data.read(*payload); + const auto document = ebx::read_document(bytes); + std::cerr << document.rootType << ": data " << document.dataStart << ".." << document.dataEnd << " of " << bytes.size() << "\n"; + for (std::size_t at = document.dataStart & ~std::size_t{3}; at + 4 <= bytes.size(); at += 4) { + float value; + std::memcpy(&value, bytes.data() + at, 4); + const float size = value < 0 ? -value : value; + if (size > 0.0009f && size < 100000.0f) std::cout << "0x" << std::hex << at << std::dec << "\t" << value << "\n"; + } + return 0; + } + return 1; + } + if (mode == "--dump" && argc >= 4) { + const auto wanted = lower(argv[3]); + for (std::size_t i = 0; i < bundle.manifest.ebx.size(); ++i) { + if (lower(bundle.manifest.ebx[i].name) != wanted) continue; + const auto *payload = bundle.payload(dingosdk::frostbite::AssetKind::ebx, i); + if (!payload) break; + const auto bytes = data.read(*payload); + const auto document = ebx::read_document(bytes); + std::cerr << bundle.manifest.ebx[i].name << ": root type " << document.rootType << ", " << document.instances.size() + << " instances\n"; + // Every instance: an asset keeps its lists and sub-objects as separate ones. + std::size_t number{}; + for (const auto &instance : document.instances) { + if (instance.descriptor < 0 || static_cast(instance.descriptor) >= document.types.size()) continue; + const auto &type = document.types[static_cast(instance.descriptor)]; + std::cout << "# instance " << number++ << " " << type.name << " (" << instance.rawImage.size() << " bytes)\n"; + walk(document, type, instance.rawImage, 0, 0, {}); + } + return 0; + } + std::cerr << "no such asset\n"; + return 1; + } + std::vector words; + for (int i = (mode == "--types" ? 3 : 2); i < argc; ++i) words.push_back(lower(argv[i])); + std::size_t shown{}; + for (std::size_t i = 0; i < bundle.manifest.ebx.size(); ++i) { + const auto name = lower(bundle.manifest.ebx[i].name); + std::string type; + bool match = mode != "--types" && std::ranges::any_of(words, [&](const std::string &w) { return name.find(w) != std::string::npos; }); + if (mode == "--types") { + // Slower: opens every asset to learn its root type. + try { + if (const auto *payload = bundle.payload(dingosdk::frostbite::AssetKind::ebx, i)) { + type = ebx::read_document(data.read(*payload)).rootType; + const auto low = lower(type); + match = std::ranges::any_of(words, [&](const std::string &w) { return low.find(w) != std::string::npos; }); + } + } catch (const std::exception &) {} + } + if (!match) continue; + std::cout << bundle.manifest.ebx[i].name << (type.empty() ? "" : "\t" + type) << '\n'; + ++shown; + } + std::cerr << shown << " of " << bundle.manifest.ebx.size() << " assets\n"; + return 0; + } catch (const std::exception &error) { + std::cerr << "error: " << error.what() << '\n'; + return 1; + } +} From da0aeff7524b5b504c46703a69a0b32915e80eb0 Mon Sep 17 00:00:00 2001 From: andrewnakas Date: Sun, 4 Oct 2026 01:04:24 -0600 Subject: [PATCH 009/109] Trainer 0.2.0: Realistic and Fun modes, the game's other tuning classes, push speed on every push The game keeps much of its tuning in data-defined classes (push speeds, everything on foot, dive and glide, bail speeds...). The trainer finds 24 of them in memory by the defaults and field order the game's data ships and lists their 314 values by name. Push speed now drives the push class's own speeds; auto push, off-board jump height and wider trick height ranges come with it. The Tune tab opens on a Realistic or a Fun short list. Built on ReSkate 1.0.7. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018szL7EmApPZxh9GK4Phw6x --- Extension/Skater/client_noclip.cpp | 81 ++++-- Extension/Skater/client_source_spawn.h | 8 +- Extension/Trainer/CHANGELOG.md | 31 +++ Extension/Trainer/README.md | 13 +- Extension/Trainer/Test/asset_list.cpp | 70 +++++ Extension/Trainer/trainer.cpp | 129 ++++++++-- Extension/Trainer/trainer.h | 4 + Extension/Trainer/trainer_classes.cpp | 328 +++++++++++++++++++++++ Extension/Trainer/trainer_classes.h | 39 +++ Extension/Trainer/trainer_classes.inc | 344 +++++++++++++++++++++++++ Extension/Trainer/trainer_commands.cpp | 5 +- Extension/Trainer/trainer_jump.cpp | 2 +- Extension/Trainer/trainer_page.cpp | 138 +++++++--- Extension/Trainer/trainer_presets.cpp | 115 ++++++--- Extension/Trainer/trainer_presets.h | 6 +- cmake/Runtime.cmake | 1 + 16 files changed, 1176 insertions(+), 138 deletions(-) create mode 100644 Extension/Trainer/trainer_classes.cpp create mode 100644 Extension/Trainer/trainer_classes.h create mode 100644 Extension/Trainer/trainer_classes.inc diff --git a/Extension/Skater/client_noclip.cpp b/Extension/Skater/client_noclip.cpp index 5bcfa0b0..ff42da69 100644 --- a/Extension/Skater/client_noclip.cpp +++ b/Extension/Skater/client_noclip.cpp @@ -211,29 +211,40 @@ void trainer_apply_jump_scale(std::uintptr_t core) noexcept { j.outcome.store(scaled ? 2 : before < 0.5f ? -1 : -2); } catch (...) { j.outcome.store(-3); } } -// The trainer's top pushing speed. On the board the game holds the skater at the speed its -// trick scripts ask for (context +0x17f4: 4 m/s for taps, then about 5, 7, 8.5 and 9.1 m/s as -// a push is held) and never runs its own code for the push tuning values. So, after each -// physics step while the skater rides with such a target: at the game's last step the skater -// carries on to `top` times it, and a `top` under 1 holds every step down to that share of -// the last one. The game's choice of step is left alone: it follows the skater's speed, and a -// multiplier on every step feeds back into it (measured: x2 ran away to 17 m/s). +// The trainer's push speed. On the board the game steers the skater to the speed its trick +// scripts ask for (context +0x17f4) and never runs its own code for the push tuning values. +// Measured: a tapped push asks for the skater's own speed kept between 4 and about 9.5 m/s, a +// held one walks up 4.95, 6.98, 8.5 and 9.1 m/s, and the game holds that speed for about six +// seconds after the last push before the skater coasts. A multiplier on that target would +// feed back (the target follows the speed: x2 ran away to 17 m/s), so after each physics step +// of a riding skater: +// - `factor` over 1: once the game has the skater at its tap speed it is carried on to +// 4 m/s x factor; a held push that has reached the game's last step is carried on to that +// step x factor, and stays there while the game holds its own; +// - `factor` under 1: the skater is held down to the game's target x factor (it then never +// reaches the speed the next step would need, so nothing feeds back). // Auto push: the game hands its flag to the animation and nothing comes of it (measured: the // same coast-down with it on). With `cruise` set, a rolling skater that is not braking gains -// speed gently up to it. -constexpr float push_gain = 0.2f; // m/s the game's own push gains each physics step (0 to 4 m/s in 19 steps) -constexpr float last_step = 0.95f; // of the stock top pushing speed: the game's last step is 9.1 of 9.25 m/s +// speed up to it. +constexpr float push_gain = 0.2f; // m/s the game's own push gains each physics step (0 to 4 m/s in 19 steps) +constexpr float tap_speed = 4.0f; // m/s the game's tapped push settles at +constexpr float last_step = 0.95f; // of the stock top pushing speed: the game's last step is 9.1 of 9.25 m/s constexpr float cruise_gain = 0.15f; // m/s each physics step: the game steers the speed back toward its own, so less does nothing -struct PushTop { - std::atomic top{1}, stock{9.25f}, cruise{}; +constexpr int held_steps = 36; // a push flagged this long is held (a tap lasts 19 steps) +constexpr int hold_steps = 360; // how long after a push the game holds its speed +struct PushSpeed { + std::atomic factor{1}, stock{9.25f}, cruise{}; std::uintptr_t client{}, entity{}; std::atomic expires{}; + // Physics thread only. + int pushing{}, since_push{hold_steps}; + bool carried{}; // a held push is being carried past the game's last step }; -PushTop& push_top() { static auto* value = new PushTop; return *value; } -void trainer_push_top(std::uintptr_t core) noexcept { - auto& p = push_top(); - const float top = p.top.load(std::memory_order_relaxed), cruise = p.cruise.load(std::memory_order_relaxed); - if ((top == 1 && cruise <= 0) || GetTickCount64() >= p.expires.load(std::memory_order_acquire)) return; +PushSpeed& push_speed() { static auto* value = new PushSpeed; return *value; } +void trainer_push_speed(std::uintptr_t core) noexcept { + auto& p = push_speed(); + const float factor = p.factor.load(std::memory_order_relaxed), cruise = p.cruise.load(std::memory_order_relaxed); + if ((factor == 1 && cruise <= 0) || GetTickCount64() >= p.expires.load(std::memory_order_acquire)) return; const auto watch = watched_physics_state(); if (!watch.valid || watch.state != 100) return; // riding the ground SourceLastError error; @@ -256,15 +267,31 @@ void trainer_push_top(std::uintptr_t core) noexcept { flags[i] = reader.value(bodies.parts[i], 0x60); } reader.verify(); + if (requests & 0x40u) { // the push request the game's own push code tests + ++p.pushing; + p.since_push = 0; + } else { + p.pushing = 0; + if (p.since_push < hold_steps) ++p.since_push; + } const float speed = std::sqrt(velocities[0][0] * velocities[0][0] + velocities[0][2] * velocities[0][2]); if (!(speed >= 0.5f) || !(speed < 1000.0f)) return; const float stock = p.stock.load(std::memory_order_relaxed); - const bool pushed = target > 0.5f && target < 100.0f; // the game has a push speed in mind + const bool pushed = target > 0.5f && target < 100.0f && p.since_push < hold_steps; // the game is holding a push speed + const bool at_last_step = pushed && target >= stock * last_step && speed >= target * 0.9f; + if (braking || !at_last_step) p.carried = false; + else if (p.pushing >= held_steps) p.carried = true; float change = 0; - if (pushed && top > 1 && target >= stock * last_step && speed >= target * 0.9f) change = std::min(push_gain, target * top - speed); - // Pushed speed only: a hill's is faster than the game's target and is left alone. - else if (pushed && top < 1 && speed > stock * top && speed <= target * 1.1f) change = -std::min(push_gain, speed - stock * top); - else if (cruise > 0 && !braking && speed >= 1.0f && speed < std::min(cruise, stock * top)) change = std::min(cruise_gain, cruise - speed); + if (braking) { + } else if (pushed && factor > 1 && p.carried) { + change = std::min(push_gain, target * factor - speed); + } else if (pushed && factor > 1 && speed >= tap_speed * 0.85f && speed < tap_speed * factor) { + change = std::min(push_gain, tap_speed * factor - speed); + } else if (pushed && factor < 1 && speed > target * factor && speed <= target * 1.1f) { + change = -std::min(push_gain, speed - target * factor); // pushed speed only: a hill's is faster than the target + } else if (cruise > 0 && speed >= 1.0f && speed < cruise) { + change = std::min(cruise_gain, cruise - speed); + } if (std::abs(change) < 0.005f) return; const float scale = (speed + change) / speed; // Like the native velocity writers: XYZ at +70 and the dirty bit 8 at +60. @@ -281,7 +308,7 @@ void noclip_physics_update(std::uintptr_t core) { if (original) original(core); noclip_apply_velocity(core); trainer_apply_jump_scale(core); - trainer_push_top(core); + trainer_push_speed(core); } bool noclip_motion_target(std::uintptr_t rig, std::uintptr_t context, const std::array* supplied, std::array& target) noexcept { @@ -362,13 +389,13 @@ bool queue_jump_scale(std::uintptr_t client, std::uintptr_t entity, float factor return true; } catch (...) { return false; } } -void set_push_top(std::uintptr_t client, std::uintptr_t entity, float top, float stock, float cruise) noexcept { - auto& p = push_top(); - p.cruise.store(cruise > 0 && cruise < 100 ? cruise : 0.0f, std::memory_order_relaxed); +void set_push_speed(std::uintptr_t client, std::uintptr_t entity, float factor, float stock, float cruise) noexcept { + auto& p = push_speed(); p.client = client; p.entity = entity; + p.cruise.store(cruise > 0 && cruise < 100 ? cruise : 0.0f, std::memory_order_relaxed); p.stock.store(stock > 1 && stock < 100 ? stock : 9.25f, std::memory_order_relaxed); - p.top.store(top > 0.05f && top <= 20 ? top : 1.0f, std::memory_order_relaxed); + p.factor.store(factor > 0.02f && factor <= 50 ? factor : 1.0f, std::memory_order_relaxed); p.expires.store(GetTickCount64() + 500, std::memory_order_release); } JumpScaleResult take_jump_scale_result() noexcept { diff --git a/Extension/Skater/client_source_spawn.h b/Extension/Skater/client_source_spawn.h index 391df022..4941f74b 100644 --- a/Extension/Skater/client_source_spawn.h +++ b/Extension/Skater/client_source_spawn.h @@ -20,11 +20,11 @@ struct JumpScaleResult { float up_speed{}; // before scaling }; JumpScaleResult take_jump_scale_result() noexcept; -// Trainer: the local skater's top pushing speed as a multiple of the game's own (1 leaves the -// skater alone); `stock` is the game's top pushing speed in m/s. `cruise` above 0 is auto push: -// a rolling skater that is not braking gains speed up to it (m/s). Publish every client tick: it +// Trainer: the local skater's push speed as a multiple of the game's own (1 leaves the skater +// alone); `stock` is the game's top pushing speed in m/s. `cruise` above 0 is auto push: a +// rolling skater that is not braking gains speed up to it (m/s). Publish every client tick: it // expires after 500 ms. -void set_push_top(std::uintptr_t client, std::uintptr_t entity, float top, float stock, float cruise) noexcept; +void set_push_speed(std::uintptr_t client, std::uintptr_t entity, float factor, float stock, float cruise) noexcept; // Engine-thread-only interactive controls. Presentation callbacks only queue requests. overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t client, bool can_control, bool camera_phase_observed, const overlay::DebugRequest* request = nullptr, diff --git a/Extension/Trainer/CHANGELOG.md b/Extension/Trainer/CHANGELOG.md index 13ec4f87..b2ad625c 100644 --- a/Extension/Trainer/CHANGELOG.md +++ b/Extension/Trainer/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## v0.2.0 - 2026-10-04 + +Built on ReSkate 1.0.7. + +- **Realistic, Fun and Everything.** The Tune tab opens on one of two short lists: REALISTIC + (pop, push speeds, flip catch times, bail limits, on-foot jump and sprint, all draggable below + the game's own values, with the Realistic preset one click away) and FUN (the big switches + and everything on foot and in the air). EVERYTHING is the whole table with search. + `trainer open realistic|fun|everything` opens them from the console. +- **The game's other tuning.** Much of the game is not tuned by its physics tuning asset but by + data-defined classes: push speeds, on-foot jump, sprint, flips and rolls, wallrun, vault and + mantle, dive, torpedo and glide, bail fall speeds, flip catch times, grind control, pumping, + powerslide, the speed model. The trainer now finds 24 of them in memory (by the defaults and + field order the game's data ships) and lists their 314 values by name, next to the 3801 of + the tuning asset. +- **Push speed scales every push.** "Push speed" now drives the game's own push speeds (a + tapped push, a held one, the top), so taps cruise faster or slower too. The three speeds are + on the Realistic list by themselves. Measured: x2 cruises at 8.2 m/s from taps (4.0 stock), + the Realistic preset at 3.0. +- **On foot:** jump height (also as a multiplier beside the trick heights), sprint speed, flip + rotation and roll speed, wallrun boosts; in the air: glide gravity, air resistance and + steering, torpedo and dive steering. New presets: Moon Jump, Fast On Foot, Fast Parkour + Flips, Super Glide, Torpedo Boost. Measured: Moon Jump 0.9 m to 2.6 m, Fast On Foot 6.6 to + 9.8 m/s. The flip, glide, torpedo and dive values were not exercised by a test. +- **Powerslide** forward force and friction, for the speed tricks slides used to allow. +- Trick height sliders go from x0.1 to x50 (Ctrl+click to type); the console takes 0.05 to 100. +- Return after a bail no longer ignores the first bail of a session, and says in the log what + it did. (The switch is on the Practice tab.) +- Not done: the speed of board flip tricks. The game keeps it in curves the trainer cannot + reach yet. + ## v0.1.4 - 2026-10-03 - **No comply height and boneless height.** Two new sliders beside the hippy jump's (Tune tab, diff --git a/Extension/Trainer/README.md b/Extension/Trainer/README.md index bb4eb476..0ee88c8a 100644 --- a/Extension/Trainer/README.md +++ b/Extension/Trainer/README.md @@ -94,9 +94,14 @@ folder>` (CMake option `DINGOSDK_BUILD_TRAINER_TESTS`) lists the tuning values w those. The trainer multiplies the launch speed where the game sets the jump's trajectory (no comply, boneless: `trainer_jump.cpp`) or scales the skater's upward speed as the jump starts (hippy jump). -- Pushing is scripted too: the game holds the skater at a speed its trick scripts pick (about 4 m/s - for taps, up to 9.1 m/s while push is held) and skips its own code for the push tuning values. - "Top pushing speed" is therefore the trainer's doing: past the game's last step the skater - carries on to your number, and a lower number caps every step. Auto push is the trainer's doing as well (the game's flag only reaches its animation): once rolling and not braking, the skater gains speed up to the auto push speed. Push strength has no effect in +- Much of the game's tuning is not in `Gameplay/SkatePhysicsTuning` but in data-defined classes + (push speeds, everything on foot, dive and glide, bail speeds...). A live copy has no name to + look up, so the trainer finds it by searching writable memory for the class's defaults laid + out as the class lays them out (`trainer_classes.cpp`; the table in `trainer_classes.inc` is + generated from the game's own data). The search runs on its own thread a few seconds after a + level loads; `trainer classes` says what it found. Native code keeps its own copy of the push + speeds, found and written the same way. +- "Push speed" scales the push class's speeds (a tapped push, a held one, the top) and the + tuning's top pushing speed, which only gates whether a push may start. Auto push is the trainer's doing as well (the game's flag only reaches its animation): once rolling and not braking, the skater gains speed up to the auto push speed. Push strength has no effect in this game build and is hidden. - Built for one game build (the one ReSkate 1.0.3 supports). A game update needs a new build. diff --git a/Extension/Trainer/Test/asset_list.cpp b/Extension/Trainer/Test/asset_list.cpp index ffc1fdb2..882e4d5e 100644 --- a/Extension/Trainer/Test/asset_list.cpp +++ b/Extension/Trainer/Test/asset_list.cpp @@ -9,7 +9,9 @@ #include #include #include +#include #include +#include #include namespace ebx = dingosdk::frostbite::ebx; @@ -71,6 +73,38 @@ void walk(const ebx::Document &document, const ebx::TypeDescriptor &type, std::s std::cout << '\n'; } } +void tree(const ebx::Value &value, int depth, const std::string &indent); +void tree(const ebx::Object &object, int depth, const std::string &indent) { + for (const auto &field : object.fields) { + std::cout << indent << (field.name.empty() ? "?" : field.name) << " = "; + tree(field.value, depth, indent); + } +} +void tree(const ebx::Value &value, int depth, const std::string &indent) { + if (depth > 12) { std::cout << "...\n"; return; } + if (const auto *b = std::get_if(&value.data)) std::cout << (*b ? "true" : "false") << '\n'; + else if (const auto *i = std::get_if(&value.data)) std::cout << *i << '\n'; + else if (const auto *u = std::get_if(&value.data)) std::cout << *u << '\n'; + else if (const auto *d = std::get_if(&value.data)) std::cout << *d << '\n'; + else if (const auto *s = std::get_if(&value.data)) std::cout << '"' << *s << "\"\n"; + else if (const auto *r = std::get_if(&value.data)) + std::cout << (r->kind == ebx::PointerKind::null ? "null" : r->kind == ebx::PointerKind::internal ? "-> instance " : "-> import ") << r->index << '\n'; + else if (const auto *x = std::get_if(&value.data)) std::cout << "boxed type 0x" << std::hex << x->encodedType << std::dec << " at " << x->dataOffset << '\n'; + else if (const auto *y = std::get_if(&value.data)) std::cout << "type " << y->descriptor << " 0x" << std::hex << y->encoded << std::dec << '\n'; + else if (const auto *o = std::get_if>(&value.data)) { + std::cout << "{\n"; + if (*o) tree(**o, depth + 1, indent + " "); + std::cout << indent << "}\n"; + } else if (const auto *a = std::get_if(&value.data)) { + std::cout << "[" << a->size() << "]\n"; + std::size_t shown{}; + for (const auto &element : *a) { + if (++shown > 40) { std::cout << indent << " ...\n"; break; } + std::cout << indent << " - "; + tree(element, depth + 1, indent + " "); + } + } else std::cout << "(other)\n"; +} } // namespace int main(int argc, char **argv) { @@ -106,6 +140,42 @@ int main(int argc, char **argv) { } return 1; } + if (mode == "--tree" && argc >= 4) { + const auto wanted = lower(argv[3]); + for (std::size_t i = 0; i < bundle.manifest.ebx.size(); ++i) { + if (lower(bundle.manifest.ebx[i].name) != wanted) continue; + const auto *payload = bundle.payload(dingosdk::frostbite::AssetKind::ebx, i); + if (!payload) break; + const auto bytes = data.read(*payload); + const auto document = ebx::read_document(bytes); + std::size_t number{}; + for (const auto &instance : document.instances) { + const auto type = instance.descriptor >= 0 && static_cast(instance.descriptor) < document.types.size() + ? document.types[static_cast(instance.descriptor)].name : std::string("?"); + std::cout << "# instance " << number++ << " " << type << "\n"; + if (instance.object) tree(*instance.object, 0, " "); + } + number = 0; + for (const auto &boxed : document.boxedValues) { + std::cout << "# boxed " << number++ << ": type 0x" << std::hex << boxed.encodedType << std::dec << " class " << boxed.classRef + << " count " << boxed.count << " offset " << boxed.offset << " bytes " << boxed.rawBytes.size() << " :"; + for (std::size_t at = 0; at + 4 <= boxed.rawBytes.size() && at < 256; at += 4) { + float real; std::int32_t whole; + std::memcpy(&real, boxed.rawBytes.data() + at, 4); + std::memcpy(&whole, boxed.rawBytes.data() + at, 4); + const float size = real < 0 ? -real : real; + if (size > 0.0001f && size < 1.0e6f) std::cout << ' ' << real; else std::cout << " #" << whole; + } + std::cout << "\n"; + } + for (const auto &import : document.imports) (void)import; + std::cout << "# imports " << document.imports.size() << ", arrays " << document.arrays.size() << ", types"; + for (const auto &type : document.types) std::cout << ' ' << type.name; + std::cout << "\n"; + return 0; + } + return 1; + } if (mode == "--dump" && argc >= 4) { const auto wanted = lower(argv[3]); for (std::size_t i = 0; i < bundle.manifest.ebx.size(); ++i) { diff --git a/Extension/Trainer/trainer.cpp b/Extension/Trainer/trainer.cpp index 27b9d589..a98c277e 100644 --- a/Extension/Trainer/trainer.cpp +++ b/Extension/Trainer/trainer.cpp @@ -1,4 +1,5 @@ #include "trainer.h" +#include "trainer_classes.h" #include "trainer_jump.h" #include "trainer_presets.h" #include "Engine/Core/Json/json.h" @@ -32,6 +33,7 @@ namespace { namespace tuning = dingosdk::physics_tuning; using Vec3 = std::array; constexpr float max_coordinate = 1.0e6f; +constexpr float height_low = 0.05f, height_high = 100.0f; // trick height multipliers constexpr std::uint32_t air_states_first = 200, air_states_end = 300; constexpr std::uint32_t xinput_up = 0x1, xinput_down = 0x2, xinput_left = 0x4, xinput_right = 0x8, xinput_lb = 0x100, xinput_rb = 0x200; @@ -47,6 +49,7 @@ struct Entry { bool used{}; // the game's code was found to read it (trainer_used.inc), or it is linked std::vector drives; // graph multipliers this value scales (value_links) std::vector members; // graph: its outputs (Y0.., Min_Y, Max_Y) + int class_field{-1}; // a field of one of the game's data-defined classes (trainer_classes.h), not of the tuning asset }; struct MapData { std::array markers{}; @@ -106,13 +109,21 @@ struct State { // The hippy jump's height is set by the game's trick scripts, not by tuning: the trainer // scales the upward velocity when it sees one start. float hippy_height{1}; + // An on-foot jump: the same scaling, when a skater standing off the board starts to rise. + float offboard_height{1}; + bool offboard_grounded{}; // off the board and not moving up or down at the last tick + const char *boost_name{""}; // The no comply and the boneless are launched by the trick scripts too (trainer_jump.cpp). float nocomply_height{1}, boneless_height{1}; std::uint32_t last_state{}; - float boost_factor{}; // velocity factor of the hippy jump now starting, 0: none + float boost_factor{}; // velocity factor of the jump now starting, 0: none std::uint64_t boost_until{}; float return_delay{1.5f}; std::uint64_t seen_wipeouts{}, return_at{}, open_serial{}; + std::uint64_t class_search_at{}; // when to look for the game's tuning classes (0: not due) + int class_search_tries{}; + bool classes_stock{}; // a session's host tuning is in force: the classes hold their defaults + bool wipeouts_known{}; // the wipeout count the session started with was read int open_tab{}; std::uint32_t pad_previous{}; Motion motion; @@ -208,9 +219,10 @@ void load_store() { s.hud = o.value("hud", false); s.hud_jump = o.value("hud_jump", true); s.return_delay = std::clamp(o.value("return_delay", 1.5f), 0.0f, 10.0f); - s.hippy_height = std::clamp(o.value("hippy_height", 1.0f), 0.25f, 20.0f); - s.nocomply_height = std::clamp(o.value("nocomply_height", 1.0f), 0.25f, 20.0f); - s.boneless_height = std::clamp(o.value("boneless_height", 1.0f), 0.25f, 20.0f); + s.hippy_height = std::clamp(o.value("hippy_height", 1.0f), height_low, height_high); + s.nocomply_height = std::clamp(o.value("nocomply_height", 1.0f), height_low, height_high); + s.offboard_height = std::clamp(o.value("offboard_height", 1.0f), height_low, height_high); + s.boneless_height = std::clamp(o.value("boneless_height", 1.0f), height_low, height_high); s.slot = std::clamp(o.value("slot", 0), 0, static_cast(marker_slots) - 1); } if (json->contains("values") && json->at("values").is_object()) @@ -254,6 +266,7 @@ void save_store() { options["return_delay"] = s.return_delay; options["hippy_height"] = s.hippy_height; options["nocomply_height"] = s.nocomply_height; + options["offboard_height"] = s.offboard_height; options["boneless_height"] = s.boneless_height; options["slot"] = s.slot; json["options"] = std::move(options); @@ -367,6 +380,10 @@ double sane(const Entry &e, double value) { } void apply_entry(Entry &e) { auto &s = state(); + if (e.class_field >= 0) { + want_class_value(static_cast(e.class_field), static_cast(e.touched ? e.value : e.stock)); + return; + } if (e.kind == Kind::graph) { // A point the player set by hand keeps their value. for (const auto index : e.members) { @@ -398,7 +415,11 @@ void set_entry(Entry &e, double value) { const auto targets = e.drives; // set_entry may not touch `e` again, but keep the list stable auto &s = state(); for (const auto index : targets) - if (index < s.entries.size() && !s.entries[index].frozen) set_entry(s.entries[index], ratio); + if (index < s.entries.size() && !s.entries[index].frozen) { + auto &target = s.entries[index]; + const bool multiplier = target.kind == Kind::curve || target.kind == Kind::graph; + set_entry(target, multiplier ? ratio : target.stock * ratio); + } } } Entry *find_entry(std::string_view id) { @@ -465,6 +486,23 @@ void build_entries() { add(parent, Kind::graph, offset, 0, 0); s.entries.back().members = std::move(outputs); } + // The game's data-defined classes: one row per number field, under the class's name. + for (std::size_t c = 0; c < class_count; ++c) + for (std::size_t i = 0; i < class_specs[c].count; ++i) { + const auto &field = class_fields[class_specs[c].first + i]; + Entry e; + e.id = std::format("{}.{}", class_specs[c].key, field.name); + e.key = lower(e.id); + if (s.index.contains(e.key)) continue; + e.group = class_specs[c].group; + e.label = spaced(field.name); + e.kind = Kind::real; + e.used = true; + e.class_field = static_cast(class_specs[c].first + i); + e.stock = e.value = field.stock; + s.index.emplace(e.key, s.entries.size()); + s.entries.push_back(std::move(e)); + } for (const auto &link : value_links()) { const auto from = s.index.find(link.value), to = s.index.find(link.drives); if (from == s.index.end() || to == s.index.end()) continue; @@ -479,7 +517,7 @@ void adopt(const tuning::Values &live) { s.own = live; s.want = live; for (auto &e : s.entries) { - e.stock = e.kind == Kind::curve || e.kind == Kind::graph ? 1.0 : read_field(s.own.image, e); + e.stock = e.class_field >= 0 ? class_fields[e.class_field].stock : e.kind == Kind::curve || e.kind == Kind::graph ? 1.0 : read_field(s.own.image, e); const auto found = s.saved.find(e.key); if (found != s.saved.end()) { e.frozen = found->second.frozen; @@ -497,7 +535,8 @@ void adopt(const tuning::Values &live) { for (const auto index : e.drives) { auto &target = s.entries[index]; if (target.touched || target.frozen) continue; - target.value = sane(target, e.value / e.stock); + const bool multiplier = target.kind == Kind::curve || target.kind == Kind::graph; + target.value = sane(target, multiplier ? e.value / e.stock : target.stock * e.value / e.stock); target.touched = target.value != target.stock; if (target.touched) { apply_entry(target); @@ -739,6 +778,9 @@ void enter_map(const std::string &level) { s.telemetry.top_speed = 0; s.return_at = 0; s.profile_due = false; + // A level brings fresh copies of the game's tuning classes: look for them once it has settled. + s.class_search_at = level.empty() ? 0 : GetTickCount64() + 10000; + s.class_search_tries = 0; if (!level.empty()) { const auto found = s.maps.find(level); s.profile_due = found != s.maps.end() && !found->second.preset.empty(); @@ -849,13 +891,14 @@ void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) { if (watch.state == addr::no_bail::offboard_physics_state && s.last_state == 100 && s.hippy_height != 1.0f) { s.boost_factor = std::sqrt(s.hippy_height); // height goes with the square of the speed s.boost_until = now + 250; + s.boost_name = "hippy jump"; } s.last_state = watch.state; } if (s.boost_factor > 0) { const auto result = take_jump_scale_result(); if (result.outcome > 0) { - say(logging::Level::info, std::format("Trainer: hippy jump boosted: up speed {:.2f} -> {:.2f} m/s.", result.up_speed, + say(logging::Level::info, std::format("Trainer: {} boosted: up speed {:.2f} -> {:.2f} m/s.", s.boost_name, result.up_speed, result.up_speed * s.boost_factor)); s.boost_factor = 0; } else if (now >= s.boost_until || result.outcome == -2) { @@ -880,6 +923,18 @@ void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) { const auto dt = static_cast(seconds); const Vec3 raw{step[0] / dt, step[1] / dt, step[2] / dt}; const Vec3 velocity{m.velocity[0] * 0.5f + raw[0] * 0.5f, m.velocity[1] * 0.5f + raw[1] * 0.5f, m.velocity[2] * 0.5f + raw[2] * 0.5f}; + // An on-foot jump: off the board, level at the last tick and now rising faster than stairs + // or a slope can lift a runner. + if (watch.valid && watch.state == addr::no_bail::offboard_physics_state) { + if (s.offboard_grounded && raw[1] > 2.2f && s.boost_factor == 0 && s.offboard_height != 1.0f) { + s.boost_factor = std::sqrt(s.offboard_height); + s.boost_until = now + 250; + s.boost_name = "off-board jump"; + } + s.offboard_grounded = std::abs(raw[1]) < 0.6f; + } else { + s.offboard_grounded = false; + } // The skater's physics says when it is in the air: its states 200-299 are the pop and // the flight (100s ride the ground, 300 is a wipeout, 500s are off the board). const bool flying = watch.valid && watch.state >= air_states_first && watch.state < air_states_end; @@ -929,9 +984,14 @@ void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) { position[1], position[2], t.speed, t.vertical, t.heading, t.airborne ? 1 : 0, t.physics_state); } // A bail: back to the selected marker once the skater has had time to fall. - if (watch.valid && watch.wipeouts != s.seen_wipeouts) { - if (s.seen_wipeouts && s.auto_return && current_map().markers[static_cast(s.slot)].set) - s.return_at = now + static_cast(s.return_delay * 1000.0f); + if (watch.valid && (!s.wipeouts_known || watch.wipeouts != s.seen_wipeouts)) { + if (s.wipeouts_known && s.auto_return) { + const bool marked = current_map().markers[static_cast(s.slot)].set; + if (marked) s.return_at = now + static_cast(s.return_delay * 1000.0f); + say(logging::Level::info, marked ? std::format("Trainer: bail seen; back to marker {} in {:.1f} s.", s.slot + 1, s.return_delay) + : std::format("Trainer: bail seen, but marker {} is empty.", s.slot + 1)); + } + s.wipeouts_known = true; s.seen_wipeouts = watch.wipeouts; } } @@ -1074,8 +1134,9 @@ void build_view() { next->rows.reserve(s.entries.size()); for (const auto &e : s.entries) { int rank{}; - const auto friendly = essential_name(e.key, &rank); - next->rows.push_back({e.id, e.label, e.group, e.kind, e.value, e.stock, e.touched, e.frozen, e.detail, std::string(friendly), rank, + std::uint8_t modes{}; + const auto friendly = essential_name(e.key, &rank, &modes); + next->rows.push_back({e.id, e.label, e.group, e.kind, e.value, e.stock, e.touched, e.frozen, e.detail, std::string(friendly), rank, modes, e.used}); if (e.touched) ++next->touched; if (std::ranges::find(next->groups, e.group) == next->groups.end()) next->groups.push_back(e.group); @@ -1089,6 +1150,7 @@ void build_view() { next->auto_return = s.auto_return; next->hippy_height = s.hippy_height; next->nocomply_height = s.nocomply_height; + next->offboard_height = s.offboard_height; next->boneless_height = s.boneless_height; next->return_delay = s.return_delay; next->pad_shortcuts = s.pad_shortcuts; @@ -1143,21 +1205,28 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool playing, const std::s s.entity = 0; } (void)start_trick_heights(base); + if (s.class_search_at && now >= s.class_search_at && playing && s.telemetry.skater && !finding_classes()) { + // Until the push class turns up: the skater's own classes arrive a little after it does. + const bool have = class_searches() && class_copies(0) != 0; + s.class_search_at = have || ++s.class_search_tries > 6 ? 0 : now + 15000; + if (!have) find_classes(); + } + if (const bool stock = !editable(); stock != s.classes_stock) { + s.classes_stock = stock; + for (auto &e : s.entries) + if (e.class_field >= 0) want_class_value(static_cast(e.class_field), static_cast(e.touched && !stock ? e.value : e.stock)); + } + (void)apply_classes(); // Like the hippy jump's, these are boosts: a session that turns boosts off turns them off. const bool boosts = !multiplayer_session_active() || session_boosts_allowed(); set_trick_heights(boosts ? s.nocomply_height : 1.0f, boosts ? s.boneless_height : 1.0f); - // Pushing aims for speeds the trick scripts set and the game's code for its push tuning - // is skipped: the top pushing speed is kept as the player's number and handed over as - // a multiple of the stock one. - const auto ratio = [&](std::string_view id) { - const auto *e = find_entry(id); - return e && e->touched && e->stock > 0 && editable() ? static_cast(e->value / e->stock) : 1.0f; - }; + // The speeds pushes aim for are the push class's (value_links ties them to the tuning's top + // pushing speed, which only gates whether a push may start). const auto *top_speed = find_entry("physicspush.maxpushablespeed"); // Auto push is the trainer's doing as well: the game's flag reaches only its animation. const auto *auto_push = find_entry("physicsmode.autopushenabled"), *auto_speed = find_entry("physicspush.maxspeedforautopush"); const bool cruising = boosts && editable() && auto_push && auto_speed && auto_push->touched && auto_push->value != 0; - set_push_top(client, s.entity, boosts ? ratio("physicspush.maxpushablespeed") : 1.0f, top_speed ? static_cast(top_speed->stock) : 0.0f, + set_push_speed(client, s.entity, 1.0f, top_speed ? static_cast(top_speed->stock) : 0.0f, cruising ? static_cast(auto_speed->value) : 0.0f); for (TrickLaunch launch; take_trick_launch(launch);) say(logging::Level::info, std::format("Trainer trick: {} launched at {:.2f} m/s up, x{:.2f}.", @@ -1293,11 +1362,11 @@ std::string run(std::string_view verb, const std::vector &a) { if (v == "option") { const auto name = lower(arg(0)); bool on{}; - if (name == "hippy_height" || name == "nocomply_height" || name == "boneless_height") { + if (name == "hippy_height" || name == "nocomply_height" || name == "boneless_height" || name == "offboard_height") { const auto value = number(arg(1)); if (!value) return "error: " + name + " needs a multiplier, 1 = the game's own height."; - (name == "hippy_height" ? s.hippy_height : name == "nocomply_height" ? s.nocomply_height : s.boneless_height) = - std::clamp(static_cast(*value), 0.25f, 20.0f); + (name == "hippy_height" ? s.hippy_height : name == "nocomply_height" ? s.nocomply_height : name == "offboard_height" ? s.offboard_height : s.boneless_height) = + std::clamp(static_cast(*value), height_low, height_high); } else if (name == "return_delay") { const auto value = number(arg(1)); if (!value) return "error: return_delay needs seconds."; @@ -1332,9 +1401,10 @@ std::string run(std::string_view verb, const std::vector &a) { } if (v == "open") { const auto tab = lower(arg(0)); - const std::array tabs{"tune", "presets", "practice", "map"}; + // The last three are the Tune tab on one of its lists. + const std::array tabs{"tune", "presets", "practice", "map", "realistic", "fun", "everything"}; const auto found = std::ranges::find(tabs, tab); - if (!tab.empty() && found == tabs.end()) return "error: usage: trainer open [tune|presets|practice|map]"; + if (!tab.empty() && found == tabs.end()) return "error: usage: trainer open [tune|presets|practice|map|realistic|fun|everything]"; s.open_tab = tab.empty() ? 1 : static_cast(found - tabs.begin()); ++s.open_serial; s.view_due = true; @@ -1352,6 +1422,13 @@ std::string run(std::string_view verb, const std::vector &a) { return std::format("({:.2f}, {:.2f}, {:.2f}) heading {:.0f}, {:.1f} km/h. Blender: ({:.2f}, {:.2f}, {:.2f})", p[0], p[1], p[2], s.telemetry.heading, s.telemetry.speed * 3.6f, p[0], -p[2], p[1]); } + if (v == "classes") { + if (lower(arg(0)) == "find") { + find_classes(); + return "Looking for the game's tuning classes."; + } + return "Copies found: " + classes_summary(); + } if (v == "refresh") return tuning::refresh_skater(s.base, s.entity) ? "Skater copy refreshed." : "error: the skater has no cached copy right now."; if (v == "states") { std::string result; diff --git a/Extension/Trainer/trainer.h b/Extension/Trainer/trainer.h index 3c59ff19..9153fbf8 100644 --- a/Extension/Trainer/trainer.h +++ b/Extension/Trainer/trainer.h @@ -15,6 +15,8 @@ namespace dingosdk::trainer { // curve: a FloatCurve the asset points at; graph: a table of points stored in the asset // itself (X0..X15, Y0..Y15). Both are edited as one multiplier on their outputs. enum class Kind : std::uint8_t { real, integer, flag, curve, graph }; +// The Tune tab's two short lists: which of them a value is on (Row::modes). +inline constexpr std::uint8_t mode_realistic = 1, mode_fun = 2; // One editable value of Gameplay/SkatePhysicsTuning. `id` is the name the game's data gives // it ("PhysicsMode.JumpMaxHeight"). @@ -26,6 +28,7 @@ struct Row { bool detail{}; // a single point or bound of a graph: hidden unless asked for std::string friendly; // a plain name, for the handful of values on the Essentials list int rank{}; // its place on that list, from 1; 0: not on it + std::uint8_t modes{}; // which short lists it is on (trainer_presets.h: mode_realistic, mode_fun) bool used{}; // the game's code was found to read it }; struct PresetRow { @@ -64,6 +67,7 @@ struct View { float hippy_height{1}; // The same for the no comply and the boneless. float nocomply_height{1}, boneless_height{1}; + float offboard_height{1}; // a jump on foot // HUD bool hud{}, hud_jump{true}, logging{}; // The loaded map and what its author ships for the trainer (Mods//trainer.json). diff --git a/Extension/Trainer/trainer_classes.cpp b/Extension/Trainer/trainer_classes.cpp new file mode 100644 index 00000000..3899b883 --- /dev/null +++ b/Extension/Trainer/trainer_classes.cpp @@ -0,0 +1,328 @@ +#include "trainer_classes.h" +#include "Engine/Core/Log/logging.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::trainer { +namespace { +constexpr std::size_t max_copies = 12; // a class found more often than this is not told apart from chance +// A copy: where it starts, and how it is laid out. The class's own layout has every field at +// its natural alignment (curve references between the numbers); the other holds only the +// numbers, one eight-byte slot each in the same order. +struct Copy { + std::uintptr_t base{}; + bool slots{}; + bool operator==(const Copy &) const = default; +}; +using Copies = std::array, class_count>; +std::uintptr_t field_offset(const ClassSpec &spec, std::size_t i, bool slots) noexcept { + return slots ? i * 8 : class_fields[spec.first + i].offset; +} +// The game's native code keeps its own packed copy of some of these numbers, filled when a +// skater spawns, and reads that instead of the class: found the same way and written together +// with it. Seen in memory beside the push class (four-byte numbers in a row): +// 0.25 0.85 2.356 1.5 0.7 [light] [medium] [strong] [long hold] [short hold] [medium override] [horse push] 0.9 0 6.25 +struct MirrorSlot { + float value; // what a fresh copy holds here + const char *field; // the class field this slot follows ("push.MaxPushSpeedLight"), or null + bool checked; // part of the pattern a copy is recognised by +}; +struct Mirror { + std::size_t anchor; // the slot searched for + std::array slots; +}; +constexpr Mirror mirrors[]{ + {10, {{{0.25f, nullptr, true}, {0.85f, nullptr, true}, {0, nullptr, false}, {1.5f, nullptr, true}, {0.7f, nullptr, true}, + {4.0f, "push.MaxPushSpeedLight", true}, {8.5f, "push.MaxPushSpeedMedium", true}, {9.5f, "push.MaxPushSpeedStrong", true}, + {0.45f, "push.LongInputHoldTime_S", true}, {0.15f, "push.ShortInputHoldTime_S", true}, + {7.2f, "push.MinMediumPushOverrideSpeed", true}, {12.0f, "push.ForwardToPushEngageHorsePushSpeed", true}, + {0.9f, nullptr, true}, {0, nullptr, false}, {6.25f, nullptr, true}}}}, +}; +constexpr std::size_t mirror_count = sizeof(mirrors) / sizeof(mirrors[0]); +using MirrorCopies = std::array, mirror_count>; +// The class field a mirror slot follows, as an index into class_fields (-1: none). +int mirror_field(const MirrorSlot &slot) noexcept { + if (!slot.field) return -1; + const std::string_view wanted(slot.field); + for (std::size_t c = 0; c < class_count; ++c) { + const std::string_view key(class_specs[c].key); + if (wanted.size() <= key.size() || !wanted.starts_with(key) || wanted[key.size()] != '.') continue; + for (std::size_t i = 0; i < class_specs[c].count; ++i) + if (wanted.substr(key.size() + 1) == class_fields[class_specs[c].first + i].name) return static_cast(class_specs[c].first + i); + } + return -1; +} +struct Found { + std::mutex mutex; + Copies copies; + MirrorCopies mirror_copies; + std::array, mirror_count> mirror_fields{}; + std::array wanted{}; // what each field should hold + std::array written{}; // what the copies hold (the defaults until written) + bool ready{}; + std::atomic searching{}; + std::atomic searches{}; + bool dirty{}; +}; +Found &found() { + static auto *value = [] { + auto *f = new Found; + for (std::size_t i = 0; i < class_field_count; ++i) f->wanted[i] = f->written[i] = class_fields[i].stock; + for (std::size_t m = 0; m < mirror_count; ++m) + for (std::size_t i = 0; i < mirrors[m].slots.size(); ++i) f->mirror_fields[m][i] = mirror_field(mirrors[m].slots[i]); + return f; + }(); + return *value; +} +std::uint32_t bits(float value) { + std::uint32_t result; + std::memcpy(&result, &value, 4); + return result; +} +// Does the memory at `base` hold class `spec` with every field at its default (or, for a copy +// the trainer has written, at what it wrote)? `low`/`high` bound the readable region. +bool matches(const ClassSpec &spec, Copy copy, std::uintptr_t low, std::uintptr_t high, const float *expected) noexcept { + for (std::size_t i = 0; i < spec.count; ++i) { + const auto address = copy.base + field_offset(spec, i, copy.slots); + if (address < low || address + 4 > high) return false; + const float want = expected[spec.first + i]; + if (want == 0) continue; // a field with no default of its own: whatever the game put there + if (*reinterpret_cast(address) != bits(want)) return false; + } + return true; +} +// Does the memory at `base` hold mirror `m`, its followed slots at `expected` (indexed like class_fields)? +bool mirror_matches(std::size_t m, const std::array &fields, std::uintptr_t base, std::uintptr_t low, std::uintptr_t high, + const float *expected) noexcept { + const auto &mirror = mirrors[m]; + if (base < low || base + mirror.slots.size() * 4 > high) return false; + for (std::size_t i = 0; i < mirror.slots.size(); ++i) { + if (!mirror.slots[i].checked) continue; + const float want = fields[i] >= 0 ? expected[fields[i]] : mirror.slots[i].value; + if (*reinterpret_cast(base + i * 4) != bits(want)) return false; + } + return true; +} +struct Anchor { + std::uint32_t pattern; + std::uint16_t spec; // a class, or class_count + a mirror +}; +void search_region(std::uintptr_t start, std::size_t size, const std::vector &anchors, std::uint64_t filter, + Copies &copies, MirrorCopies &mirror_copies, const std::array, mirror_count> &mirror_fields, + const float *expected) noexcept { + __try { + const auto *words = reinterpret_cast(start); + const std::size_t total = size / 4; + for (std::size_t i = 0; i < total; ++i) { + const auto word = words[i]; + if (!((filter >> (word % 61)) & 1)) continue; + for (const auto &anchor : anchors) { + if (anchor.pattern != word) continue; + if (anchor.spec >= class_count) { + const std::size_t m = anchor.spec - class_count; + const auto base = start + i * 4 - mirrors[m].anchor * 4; + if (mirror_matches(m, mirror_fields[m], base, start, start + size, expected) && mirror_copies[m].size() <= max_copies) + mirror_copies[m].push_back(base); + continue; + } + const auto &spec = class_specs[anchor.spec]; + for (const bool slots : {false, true}) { + const Copy copy{start + i * 4 - field_offset(spec, spec.anchor, slots), slots}; + if (copy.base < start || !matches(spec, copy, start, start + size, expected)) continue; + auto &list = copies[anchor.spec]; + if (list.size() <= max_copies) list.push_back(copy); + } + } + } + } __except (EXCEPTION_EXECUTE_HANDLER) {} +} +bool still_there(const ClassSpec &spec, Copy copy, const float *expected) noexcept { + __try { + const auto base = copy.base; + MEMORY_BASIC_INFORMATION info{}; + if (VirtualQuery(reinterpret_cast(base), &info, sizeof(info)) != sizeof(info) || info.State != MEM_COMMIT || info.Protect != PAGE_READWRITE) + return false; + const auto low = reinterpret_cast(info.BaseAddress); + return matches(spec, copy, low, low + info.RegionSize, expected); + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +bool mirror_still_there(std::size_t m, const std::array &fields, std::uintptr_t base, const float *expected) noexcept { + __try { + MEMORY_BASIC_INFORMATION info{}; + if (VirtualQuery(reinterpret_cast(base), &info, sizeof(info)) != sizeof(info) || info.State != MEM_COMMIT || info.Protect != PAGE_READWRITE) + return false; + const auto low = reinterpret_cast(info.BaseAddress); + return mirror_matches(m, fields, base, low, low + info.RegionSize, expected); + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +bool put(std::uintptr_t address, float value) noexcept { + __try { + *reinterpret_cast(address) = value; + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +DWORD WINAPI search(void *) noexcept { + auto &f = found(); + try { + // The defaults are what a fresh copy holds. + std::array stock{}; + for (std::size_t i = 0; i < class_field_count; ++i) stock[i] = class_fields[i].stock; + std::vector anchors; + std::uint64_t filter{}; + for (std::size_t c = 0; c < class_count; ++c) { + const auto pattern = bits(class_fields[class_specs[c].first + class_specs[c].anchor].stock); + anchors.push_back({pattern, static_cast(c)}); + filter |= std::uint64_t{1} << (pattern % 61); + } + for (std::size_t m = 0; m < mirror_count; ++m) { + const auto pattern = bits(mirrors[m].slots[mirrors[m].anchor].value); + anchors.push_back({pattern, static_cast(class_count + m)}); + filter |= std::uint64_t{1} << (pattern % 61); + } + Copies fresh; + MirrorCopies fresh_mirrors; + const auto started = GetTickCount64(); + std::size_t bytes{}; + MEMORY_BASIC_INFORMATION info{}; + for (std::uintptr_t at = 0x10000; at < 0x7fffffff0000ull && VirtualQuery(reinterpret_cast(at), &info, sizeof(info)) == sizeof(info); + at = reinterpret_cast(info.BaseAddress) + info.RegionSize) { + if (info.State != MEM_COMMIT || info.Protect != PAGE_READWRITE || info.Type == MEM_IMAGE) continue; + search_region(reinterpret_cast(info.BaseAddress), info.RegionSize, anchors, filter, fresh, fresh_mirrors, f.mirror_fields, + stock.data()); + bytes += info.RegionSize; + } + std::size_t classes{}, copies{}; + { + std::lock_guard lock(f.mutex); + for (std::size_t c = 0; c < class_count; ++c) { + // Copies the trainer has already written no longer hold the defaults: keep the + // ones that still hold what it wrote. + std::vector kept; + for (const auto copy : f.copies[c]) + if (std::ranges::find(fresh[c], copy) == fresh[c].end() && still_there(class_specs[c], copy, f.written.data())) kept.push_back(copy); + if (fresh[c].size() > max_copies) fresh[c].clear(); + // A fresh copy holds the defaults: bring it level with what the older ones hold. + for (const auto copy : fresh[c]) + for (std::size_t i = 0; i < class_specs[c].count; ++i) + if (const auto index = class_specs[c].first + i; f.written[index] != class_fields[index].stock) + (void)put(copy.base + field_offset(class_specs[c], i, copy.slots), f.written[index]); + kept.insert(kept.end(), fresh[c].begin(), fresh[c].end()); + f.copies[c] = std::move(kept); + classes += !f.copies[c].empty(); + copies += f.copies[c].size(); + } + for (std::size_t m = 0; m < mirror_count; ++m) { + std::vector kept; + for (const auto base : f.mirror_copies[m]) + if (std::ranges::find(fresh_mirrors[m], base) == fresh_mirrors[m].end() && mirror_still_there(m, f.mirror_fields[m], base, f.written.data())) + kept.push_back(base); + if (fresh_mirrors[m].size() > max_copies) fresh_mirrors[m].clear(); + for (const auto base : fresh_mirrors[m]) + for (std::size_t i = 0; i < mirrors[m].slots.size(); ++i) + if (const auto index = f.mirror_fields[m][i]; index >= 0 && f.written[index] != class_fields[index].stock) + (void)put(base + i * 4, f.written[index]); + kept.insert(kept.end(), fresh_mirrors[m].begin(), fresh_mirrors[m].end()); + f.mirror_copies[m] = std::move(kept); + copies += f.mirror_copies[m].size(); + } + f.ready = true; + f.dirty = true; + } + logging::write(logging::Level::info, logging::Channel::skater, + std::format("Trainer: found {} of {} game tuning classes ({} copies) in {} MB, {} ms.", classes, class_count, copies, bytes >> 20, + GetTickCount64() - started)); + } catch (...) {} + f.searches.fetch_add(1, std::memory_order_release); + f.searching.store(false, std::memory_order_release); + return 0; +} +} // namespace + +void find_classes() noexcept { + auto &f = found(); + if (f.searching.exchange(true, std::memory_order_acq_rel)) return; + const auto thread = CreateThread(nullptr, 0, search, nullptr, 0, nullptr); + if (thread) { + SetThreadPriority(thread, THREAD_PRIORITY_BELOW_NORMAL); + CloseHandle(thread); + } else { + f.searching.store(false, std::memory_order_release); + } +} +bool finding_classes() noexcept { return found().searching.load(std::memory_order_acquire); } +std::uint64_t class_searches() noexcept { return found().searches.load(std::memory_order_acquire); } +std::size_t class_copies(std::size_t index) noexcept { + auto &f = found(); + if (index >= class_count) return 0; + std::lock_guard lock(f.mutex); + return f.copies[index].size(); +} +void want_class_value(std::size_t field, float value) noexcept { + auto &f = found(); + if (field >= class_field_count || !(value == value)) return; + std::lock_guard lock(f.mutex); + if (f.wanted[field] != value) { + f.wanted[field] = value; + f.dirty = true; + } +} +std::size_t apply_classes() noexcept { + auto &f = found(); + std::unique_lock lock(f.mutex, std::try_to_lock); + if (!lock.owns_lock() || !f.ready || !f.dirty) return 0; + f.dirty = false; + std::size_t count{}; + for (std::size_t m = 0; m < mirror_count; ++m) { + auto &list = f.mirror_copies[m]; + bool differs = false; + for (const auto index : f.mirror_fields[m]) differs = differs || (index >= 0 && f.wanted[index] != f.written[index]); + if (!differs || list.empty()) continue; + std::erase_if(list, [&](std::uintptr_t base) { return !mirror_still_there(m, f.mirror_fields[m], base, f.written.data()); }); + for (std::size_t i = 0; i < mirrors[m].slots.size(); ++i) + if (const auto index = f.mirror_fields[m][i]; index >= 0 && f.wanted[index] != f.written[index]) + for (const auto base : list) + if (put(base + i * 4, f.wanted[index])) ++count; + } + for (std::size_t c = 0; c < class_count; ++c) { + const auto &spec = class_specs[c]; + auto &list = f.copies[c]; + bool differs = false; + for (std::size_t i = 0; i < spec.count && !differs; ++i) differs = f.wanted[spec.first + i] != f.written[spec.first + i]; + if (!differs) continue; + // A copy that no longer holds what was last written is someone else's memory now. + std::erase_if(list, [&](Copy copy) { return !still_there(spec, copy, f.written.data()); }); + for (std::size_t i = 0; i < spec.count; ++i) { + const auto index = spec.first + i; + if (f.wanted[index] == f.written[index]) continue; + for (const auto copy : list) + if (put(copy.base + field_offset(spec, i, copy.slots), f.wanted[index])) ++count; + f.written[index] = f.wanted[index]; + } + } + return count; +} +std::string classes_summary() { + auto &f = found(); + std::lock_guard lock(f.mutex); + std::string result = f.ready ? "" : "(no search has finished yet) "; + for (std::size_t c = 0; c < class_count; ++c) { + const auto slots = std::ranges::count_if(f.copies[c], [](Copy copy) { return copy.slots; }); + result += std::format("{}{}:{}+{}", c ? ", " : "", class_specs[c].key, f.copies[c].size() - static_cast(slots), slots); + } + for (std::size_t m = 0; m < mirror_count; ++m) result += std::format(", native copy {}:{}", m, f.mirror_copies[m].size()); + return result; +} +} // namespace dingosdk::trainer diff --git a/Extension/Trainer/trainer_classes.h b/Extension/Trainer/trainer_classes.h new file mode 100644 index 00000000..b6cd3564 --- /dev/null +++ b/Extension/Trainer/trainer_classes.h @@ -0,0 +1,39 @@ +#pragma once +// The game keeps much of its tuning outside Gameplay/SkatePhysicsTuning, in data-defined classes +// (push speeds, on-foot jumps, flips and rolls, dive and glide, bail speeds...). Their defaults +// and field order ship in the game's data (trainer_classes.inc); a live copy has no name to look +// up, so it is found by searching writable memory for the class's defaults laid out as the +// class lays them out. Game thread, except the search itself, which runs on its own thread. +#include +#include +#include + +namespace dingosdk::trainer { +struct ClassField { + const char *name; + float stock; + std::uint16_t offset; +}; +struct ClassSpec { + const char *group; // shown in the menu + const char *key; // in value ids: "." + std::uint16_t first, count, anchor; +}; +#include "trainer_classes.inc" +inline constexpr std::size_t class_count = sizeof(class_specs) / sizeof(class_specs[0]); +inline constexpr std::size_t class_field_count = sizeof(class_fields) / sizeof(class_fields[0]); + +// Starts a search for every class's live copies unless one is running. Copies found earlier +// that still hold what the trainer last wrote are kept. +void find_classes() noexcept; +bool finding_classes() noexcept; +// How many searches have finished (0: none yet). +std::uint64_t class_searches() noexcept; +// Copies of class `index` found. +std::size_t class_copies(std::size_t index) noexcept; +// What field `field` (an index into class_fields) should hold. Written by apply_classes. +void want_class_value(std::size_t field, float value) noexcept; +// Writes the wanted values into every copy found; call each tick. Returns the fields written. +std::size_t apply_classes() noexcept; +std::string classes_summary(); +} // namespace dingosdk::trainer diff --git a/Extension/Trainer/trainer_classes.inc b/Extension/Trainer/trainer_classes.inc new file mode 100644 index 00000000..761f16cd --- /dev/null +++ b/Extension/Trainer/trainer_classes.inc @@ -0,0 +1,344 @@ +// Generated by trainer-dev/genclasses.py from the game's own data (game build 25414733): its +// data-defined classes, their number fields in memory order and the defaults they ship with. +inline constexpr ClassField class_fields[]{ // name, default, offset in the class + {"MaxTimeToDropBlockStanceChange", 2.0f, 0x0}, + {"FastFakieSpeed", 1.5f, 0x4}, + {"FastFakieStanceChangeTime", 1.1f, 0x8}, + {"SlowFakieStanceChangeTime", 1.5f, 0xc}, + {"StraightFlipCatchTime", 0.2f, 0x0}, + {"ShuvMinCatchTime", 0.275f, 0x4}, + {"VarialMinCatchTime", 0.25f, 0x8}, + {"BigFlipMinCatchTime", 0.4f, 0xc}, + {"StraightFlipCatchExtensionTime", 0.1f, 0x10}, + {"ShuvFlipCatchExtensionTime", 0.2f, 0x14}, + {"VarialFlipCatchExtensionTime", 0.2f, 0x18}, + {"BigFlipFlipCatchExtensionTime", 0.225f, 0x1c}, + {"ImpossibleMinCatchTime", 0.35f, 0x20}, + {"ImpossibleCatchExtensionTime", 0.35f, 0x24}, + {"NoseInputAngleLimit_Deg", 60.0f, 0x0}, + {"TailInputAngleLimit_Deg", 60.0f, 0x4}, + {"IdleDeadzone", 0.1f, 0x8}, + {"ReenterThreshold", 0.5f, 0xc}, + {"MinLeftRightEngageMagnitude", 0.5f, 0x10}, + {"MaxWrongHemisphere_NoseTail", 2.0f, 0x14}, + {"MaxWrongHemisphere_LeftRight", 2.0f, 0x18}, + {"LegacyPositiveAccelerationScalar", 0.25f, 0x0}, + {"LegacyNegativeAccelerationScalar", 0.25f, 0x4}, + {"DefaultPinTimer", 1.0f, 0x8}, + {"MaxMountPinSpeed", 0.1f, 0xc}, + {"MaxPinGroundAngle", 10.0f, 0x10}, + {"MaxPositiveAcceleration", 18.0f, 0x14}, + {"MaxNegativeAcceleration", 18.0f, 0x18}, + {"WipeoutAcc_Standard", 8.5f, 0x0}, + {"WipeoutAcc_Friendly", 10.5f, 0x4}, + {"LightPushSpeed", 4.25f, 0x8}, + {"MediumPushSpeed", 6.75f, 0xc}, + {"StrongPushSpeed", 8.5f, 0x10}, + {"MinTrickSetupThreshold", 0.05f, 0x14}, + {"HeldTrickSetupThreshold", 0.9f, 0x18}, + {"MaxParallelToGrindAngle", 67.5f, 0x0}, + {"GrindExitMinSpeed", 0.0f, 0x4}, + {"MaxGrindTimeBelowMinSpeed", 0.0f, 0x8}, + {"DropToBoardslideMaxPitch", 0.1f, 0xc}, + {"ZeroHeadingDeadzone", 0.2f, 0x10}, + {"LowMaxSpeed_Ground", 9.0f, 0x0}, + {"LowMaxSpeed_Grind", 6.43f, 0x4}, + {"NormalMaxSpeed_Ground", 11.2f, 0x8}, + {"NormalMaxSpeed_Grind", 8.0f, 0xc}, + {"HighMaxSpeed_Ground", 15.0f, 0x10}, + {"HighMaxSpeed_Grind", 11.43f, 0x14}, + {"LowHorizontalSpeed_OffboardLanding", 10.0f, 0x18}, + {"NormalHorizontalSpeed_OffboardLanding", 12.0f, 0x1c}, + {"HighHorizontalSpeed_OffboardLanding", 15.0f, 0x20}, + {"MinSmithPitch", 0.0125f, 0x10}, + {"MinSmithHeading", 0.2f, 0x14}, + {"Max5_0TargetPitch", 0.4f, 0x18}, + {"GrindLeanExitTime", 0.15f, 0x78}, + {"GrindExitMaxLeanAngleDeg", 10.0f, 0x7c}, + {"GrindLeanSpeed", 2.75f, 0x80}, + {"5_0_ExitBoardSideMinHeading", 15.0f, 0x84}, + {"MaxGrindDynamicsTime", 0.5f, 0x90}, + {"MinTransitionHeadingChangeDeg", 15.0f, 0x94}, + {"MinTransitionHeadingVelocityDegPerS", 50.0f, 0x98}, + {"FreshInputGrindLeanExitTime", 0.66f, 0xa8}, + {"FastFlipInputSpeed", 0.0025f, 0x8}, + {"SingleStickPopHeight", 0.1f, 0x20}, + {"DoubleStickPopHeight", 1.0f, 0x24}, + {"GestureInputBufferTime", 0.2f, 0x28}, + {"StickVelocityUpdateThreshold", 0.2f, 0x2c}, + {"LegacyPositiveAccelerationScalar", 0.25f, 0x0}, + {"LegacyNegativeAccelerationScalar", 0.25f, 0x4}, + {"DefaultPinTimer", 1.0f, 0x8}, + {"MaxMountPinSpeed", 0.1f, 0xc}, + {"MaxPinGroundAngle", 10.0f, 0x10}, + {"MaxPinGroundSpeed", 1.0f, 0x14}, + {"MaxPositiveAcceleration", 18.0f, 0x18}, + {"MaxNegativeAcceleration", 18.0f, 0x1c}, + {"MaxPushSpeedLight", 4.0f, 0x0}, + {"MaxPushSpeedMedium", 8.5f, 0x4}, + {"MaxPushSpeedStrong", 9.5f, 0x8}, + {"PushingNormalLookaheadTime_S", 0.6f, 0x18}, + {"InputMashMeterPressIncrease", 0.15f, 0x1c}, + {"InputMashMeterDecayRate_PerS", 0.5f, 0x20}, + {"LongInputHoldTime_S", 0.45f, 0x38}, + {"ShortInputHoldTime_S", 0.15f, 0x3c}, + {"MashInputMax", 0.75f, 0x40}, + {"MinMediumPushOverrideSpeed", 7.2f, 0x50}, + {"MaxBackwardRollSpeed", 1.5f, 0x54}, + {"MaxFlatgroundBackwardsRollSpeed", 1.0f, 0x58}, + {"MaxSlopeThreshold", 5.0f, 0x5c}, + {"ForwardToPushEngageHorsePushSpeed", 12.0f, 0x60}, + {"DynoNearDistanceMin", 0.8f, 0x0}, + {"DynoNearDistanceMax", 0.8f, 0x4}, + {"DynoDistanceMin", 1.5f, 0x8}, + {"DynoDistanceMax", 1.5f, 0xc}, + {"DynoAwayDistanceMin", 1.0f, 0x10}, + {"DynoAwayDistanceMax", 3.5f, 0x14}, + {"DynoQueryStartOffsetDistance", -0.4f, 0x18}, + {"DynoQueryEndOffsetDistance", 0.4f, 0x1c}, + {"DynoEdgeMinHeightOverGround", 1.0f, 0x20}, + {"DynoInnerDistanceMin", 0.5f, 0x24}, + {"DynoInnerDistanceMax", 0.5f, 0x28}, + {"ShimmyDistanceCheck", 0.5f, 0x2c}, + {"ClimbJumpAwayHorizontalVel", 4.0f, 0x30}, + {"ClimbJumpAwayVerticalVelocity", 3.0f, 0x34}, + {"ClimbJumpAwayUpBoost", 2.0f, 0x38}, + {"ClimbJumpAwayDirectionalBoost", 1.0f, 0x3c}, + {"ClimbJumpAwayDownBoost", -2.0f, 0x40}, + {"MantleMinHeightToHangFrom", 1.75f, 0x44}, + {"MantleMinHeightToDeadHangFrom", 2.25f, 0x48}, + {"MinStickDeflectionShimmy", 0.5f, 0x4c}, + {"MinStickDeflectionReachAway", 0.0f, 0x50}, + {"MinStickDeflectionReachMove", 0.5f, 0x54}, + {"MinStickDeflectionReachCorner", 0.5f, 0x58}, + {"MaxStickDeflectionReachAway", 0.5f, 0x5c}, + {"MantleMaxHeight", 2.4f, 0x60}, + {"FootHoldCheckVerticalOffset", 1.2f, 0x64}, + {"FootHoldCheckSideOffset", 0.3f, 0x68}, + {"FootHoldForwardOffset", 0.5f, 0x6c}, + {"MaxStickDeflectionAngleShimmy", 20.0f, 0x70}, + {"ShimmyDeadhangVelocityThreshold", 0.3f, 0x74}, + {"ClimbHeightRelTrajToMantle", 0.0f, 0x80}, + {"ClimbMaxVelocityToMantle", -6.0f, 0x84}, + {"DynoCollisionDistanceFromEdge", 0.6f, 0x88}, + {"DynoCollisionStartHeight", 0.8f, 0x8c}, + {"DynoCornerMinDistance", 0.6f, 0x90}, + {"DynoCollisionSideUpOffset", 0.0f, 0x94}, + {"AdvancedPumpInputResponsiveness", 10.0f, 0x0}, + {"AutoPumpCooldown", 1.5f, 0x10}, + {"PumpVelocityResetAngleDeg", 45.0f, 0x14}, + {"MaxDeviationFromCachedTightZone_Deg", 10.0f, 0x20}, + {"DownwardPumpDotProductThreshold", -0.7f, 0x24}, + {"RoughMinPumpableLength", 1.5f, 0x28}, + {"MaxDistancePastCachedTightZoneMidpoint", 1.0f, 0x2c}, + {"TightZonePastMidpointCloseEnoughThreshold", 1.0f, 0x30}, + {"TightZoneMaxSegmentLength", 1.25f, 0x40}, + {"MaxTransitionRadius", 15.0f, 0x44}, + {"DropInPumpAssistTime", 1.0f, 0x48}, + {"PumpAssistAmount", 1.0f, 0x4c}, + {"MinPumpSpeed", 1.0f, 0x50}, + {"PreviousMidpointDistanceThreshold", 0.1f, 0x54}, + {"MaxStartDistanceToDeck", 1.0f, 0x58}, + {"TransitionHistoryMaxTurnAngleDeg", 60.0f, 0x5c}, + {"MinBendDegrees", 2.5f, 0x7c}, + {"SemiAutoPumpCooldownTime_S", 0.01f, 0x88}, + {"AddAccelerationWhileUnderSpeedValue", 25.0f, 0x8c}, + {"ExperimentalAutoPumpCooldown", 0.05f, 0xb0}, + {"PitchDirectionChangeThreshold", 15.0f, 0xc0}, + {"ExperimentalTightZonePastMidPointCloseEnoughThreshold", 0.1f, 0xc4}, + {"FlumpTuckTriggerThreshold", 0.1f, 0x0}, + {"FlumpTuckMaxDisengageAngle", 15.0f, 0x4}, + {"FlumpTuckDecelerationVelocity", 0.1f, 0x8}, + {"FlumpTuckAccelerationMultiplier", 0.1f, 0xc}, + {"FlumpTuckDecelerationMultiplier", 0.1f, 0x10}, + {"FlumpTuckMaxRotationVelocity", 9.0f, 0x14}, + {"FlumpMaxMountBoardAngle", 40.0f, 0x18}, + {"SlideMinSteeringSpeed", 0.05f, 0x1c}, + {"SlideMaxSteeringSpeed", 4.0f, 0x20}, + {"SlideAlignToGroundRotationSpeed", 270.0f, 0x24}, + {"SlideDistanceToGroundWipeoutThreshold", 0.5f, 0x28}, + {"RollMinSteeringSpeed", 1.0f, 0x2c}, + {"RollMaxSteeringSpeed", 4.0f, 0x30}, + {"RollVelocityMultiplier", 3.0f, 0x34}, + {"RollMaxVelocity", 3.0f, 0x38}, + {"RollDistanceToGroundWipeoutThreshold", 0.4f, 0x3c}, + {"RollMinRotationVelocityWipeoutThreshold", 0.2f, 0x40}, + {"DisableDiveToWallMaxTimeThreshold", 0.75f, 0x44}, + {"MinInAirMountTimeToLand", 0.5f, 0x48}, + {"RollOptimalSpeed", 3.5f, 0x4c}, + {"RollAccelerationFactor", 0.2f, 0x50}, + {"RollDecelerationFactor", 0.5f, 0x54}, + {"MinRotationSpeed", 2.0f, 0x58}, + {"WallRunGravity", -7.0f, 0x0}, + {"EngageGroundVelUp", 4.0f, 0x4}, + {"EngageInAirVelUpBoost", 3.0f, 0x8}, + {"MinVerticalVelocityWallRunUpForSlide", -1.0f, 0xc}, + {"MinVerticalVelocityWallRunSideForSlide", -5.0f, 0x10}, + {"WallRunGravityActivationBlendDuration", 0.2f, 0x14}, + {"WallRunUpEngageXZVelocity", 1.0f, 0x18}, + {"WallRunSideEngageXZVelocity", 6.0f, 0x1c}, + {"WallRunUpVerticalEngageVelocity", 3.75f, 0x20}, + {"WallRunSideVerticalEngageVelocity", 1.5f, 0x24}, + {"MaxWallRunSlideXZVelocity", 1.5f, 0x28}, + {"WallRunSlideAirControlForce", 1.5f, 0x2c}, + {"MaxWallRunUpXZVelocity", 1.5f, 0x30}, + {"WallRunUpAirControlForce", 1.5f, 0x34}, + {"WallRunSideAirControlForce", 6.0f, 0x38}, + {"WallRunJumpUpAirControlForce", 1.5f, 0x3c}, + {"WallRunJumpUpVelocityBoost", 4.0f, 0x40}, + {"WallRunJumpUpMaxVerticalVelocity", 4.0f, 0x44}, + {"WallRunJumpUpHorizontalBoost", 0.5f, 0x48}, + {"WallRunJumpAwayXZVelocity", 2.5f, 0x4c}, + {"WallRunJumpAwayVerticalVelocity", 2.0f, 0x50}, + {"WallRunJumpSideMaxVerticalVelocity", 1.75f, 0x54}, + {"WallRunJumpSideStickDirectionInfluence", 0.75f, 0x58}, + {"WallRunSideMinEngageVelocity", 1.0f, 0x5c}, + {"MaxWallRunUpVerticalVelocity", 4.0f, 0x60}, + {"EngageInAirSideVelUpBoost", 2.0f, 0x64}, + {"MaxWallRunSideVerticalVelocity", 4.0f, 0x68}, + {"WallRunStickAngleLimitAutoEngage", 75.0f, 0x6c}, + {"WallRunStickAngleLimitManual", 85.0f, 0x70}, + {"WallRunStickAngleLimitOnJumpToWall", 105.0f, 0x74}, + {"MaxDistanceToEngageVerticalOnGround", 2.0f, 0x78}, + {"MaxDistanceToEngageHorizontalOnGround", 0.8f, 0x7c}, + {"MaxFallingVelocityToEngageWallRunInAir", 2.5f, 0x80}, + {"EngageGroundVelUpMultiple", 1.5f, 0x84}, + {"EngageInAirSideVelUpBoostMultiple", 1.0f, 0x88}, + {"MaxFallingVelocityToEngageWallRunInAirDirect", 5.0f, 0x8c}, + {"MinInAirFallingSpeedVelBoost", -100.0f, 0x90}, + {"MaxTimeToLandForDisengageHorizontal", 0.3f, 0x94}, + {"MaxTimeToLandForDisengageVertical", 0.3f, 0x98}, + {"MaxDistanceToGroundDisengage", 0.5f, 0x9c}, + {"MinDownwardAngleDeg", 2.5f, 0x0}, + {"MaxDownwardAngleDeg", 70.0f, 0x4}, + {"MaxDownwardAngleDeg_NoBanks", 35.0f, 0x8}, + {"MaxDeckForwardYForBanks", 0.5f, 0xc}, + {"LookaheadTime_S", 0.1f, 0x10}, + {"Powerslide_ForwardForceScalar_Slide", 20.0f, 0x0}, + {"Powerslide_ForwardForceScalar_Revert", 1.0f, 0x4}, + {"Powerslide_ForwardForceScalar_AutoRevert", 1.0f, 0x8}, + {"FrictionScalar_Slide", 1.0f, 0xc}, + {"FrictionScalar_Revert", 0.25f, 0x10}, + {"FrictionScalar_AutoRevert", 0.25f, 0x14}, + {"MaxPendulumVelocity", 4.0f, 0x0}, + {"PendulumLaunchVelocityMultiplier", 1.5f, 0x4}, + {"MaxPendulumSwingAngleToMantle", 15.0f, 0x8}, + {"MaxPendulumVelocityToMantle", 2.0f, 0xc}, + {"MinPendulumSwingAngleToJump", 40.0f, 0x10}, + {"MaxBarVerticalThickness", 0.15f, 0x14}, + {"MaxAngleCurbToGroundNormal_Deg", 15.0f, 0x8}, + {"MinSpeedIntoSlappy", 2.5f, 0xc}, + {"MaxTimeToCurbForSlappy", 0.2f, 0x10}, + {"MaxAngleOfApproachDeg", 40.0f, 0x14}, + {"SprintSpeedBoost", 8.25f, 0x0}, + {"SprintSpeed", 6.5f, 0x4}, + {"MinRunStickMagnitude", 0.65f, 0x24}, + {"MinJumpVelocity", 3.0f, 0x0}, + {"MaxJumpVelocity", 6.5f, 0x4}, + {"DrasticTurnJumpVelocityScale", 0.75f, 0x8}, + {"MinJumpHeight", 0.1f, 0xc}, + {"MaxJumpHeight", 1.2f, 0x10}, + {"BaseJumpHeight", 0.73f, 0x14}, + {"AirControlForce", 4.0f, 0x18}, + {"MinFallVelocityXZ", 3.0f, 0x1c}, + {"CoffinAcceleration", 0.0f, 0x0}, + {"GravityAcceleration", 9.81f, 0x4}, + {"MaxGravityAcceleration", 9.81f, 0x8}, + {"NoInputTime", 5.0f, 0x20}, + {"MoonwalkMaxTimeSpinningSameWay", 0.5f, 0x30}, + {"MaxMoonwalkAccelerationSpeed", 3.5f, 0x34}, + {"MaxMoonwalkDecelerationSpeed", 1.0f, 0x38}, + {"MaxMoonwalkAcceleration", 0.04f, 0x3c}, + {"NoInputBrakeSpeed", 2.0f, 0x48}, + {"Left Stick Forward Push Toggle Reset Speed", 5.0f, 0x58}, + {"CarveFrictionGrowResponsiveness", 0.85f, 0xa8}, + {"CarveFrictionDecayResponsiveness", 200.0f, 0xac}, + {"SpreadEagleAirResistance", 0.6f, 0x0}, + {"SpreadEagleAirControlMax", 12.0f, 0x4}, + {"TorpedoAirControlMax", 8.0f, 0x8}, + {"DefaultAirResistance", 0.2f, 0xc}, + {"DefaultAirControl", 8.0f, 0x10}, + {"WipeoutBallRotationalControl", 15.0f, 0x14}, + {"WipeoutBallPushOffForce", 300.0f, 0x18}, + {"SpreadEagleGravity", -8.0f, 0x1c}, + {"TorpedoAirResistance", 0.0f, 0x20}, + {"CannonballMaxAngularSpeed", 20.0f, 0x24}, + {"CannonballMaxLinearSpeed", 20.0f, 0x28}, + {"CannonballLinearDrag", 25.0f, 0x2c}, + {"CannonballAngularDrag", 0.5f, 0x30}, + {"CannonballRestitution", 0.0f, 0x34}, + {"CannonballFriction", 0.5f, 0x38}, + {"CannonballLinearAcceleration", 5.0f, 0x3c}, + {"CannonballAngularAcceleration", 0.0f, 0x40}, + {"CannonballMaxImpactForce", 15.0f, 0x44}, + {"CannonballImpactDuration", 0.3f, 0x48}, + {"CannonballMinAccelerationVel", 5.0f, 0x4c}, + {"CannonballMaxAccelerationVel", 25.0f, 0x50}, + {"CannonballInputDirectionDrag", 100.0f, 0x54}, + {"SpreadEagleAirResistanceXZ", 1.0f, 0x58}, + {"SpreadEagleAirResistanceStickDeflection", 0.4f, 0x5c}, + {"SpreadEagleAirResistanceXZStickDeflection", 0.4f, 0x60}, + {"StrongImpactMinVelocity", 15.0f, 0x64}, + {"StrongImpactMaxVelocity", 25.0f, 0x68}, + {"StrongImpactBounceDuration", 2.0f, 0x6c}, + {"StrongImpactVelocityMultiplier", 2.5f, 0x70}, + {"WipeoutAirControlImpulse", 200.0f, 0x74}, + {"StrongImpactRestitution", 0.5f, 0x78}, + {"WipeoutGroundControlImpulse", 100.0f, 0x7c}, + {"StrongImpactConsecutiveMinVelocity", 1.0f, 0x80}, + {"StrongImpactConsecutiveVelMulDecrement", 0.7f, 0x84}, + {"WipeoutBounceAirControlImpulse", 400.0f, 0x88}, + {"WipeoutBounceGroundControlImpulse", 300.0f, 0x8c}, + {"SpreadEagleAirControlMinFallVelocity", 0.0f, 0x90}, + {"SpreadEagleAirControlMaxFallVelocity", 4.0f, 0x94}, + {"SpreadEagleAirResistanceMinFallVelocity", 4.0f, 0x98}, + {"SpreadEagleAirResistanceMaxFallVelocity", 8.0f, 0x9c}, + {"MaxVaultEdgeToEdgeDistance", 1.0f, 0x0}, + {"MaxVaultEndEdgeHeightOverGround", 2.3f, 0x4}, + {"MinHeightOverGroundMantle", 0.4f, 0x8}, + {"OnGroundMantleQueryHeight", 1.3f, 0xc}, + {"OnGroundVaultQueryHeight", 1.25f, 0x10}, + {"MaxDistanceToContactMantleOnGround", 2.0f, 0x14}, + {"MaxDistanceToContactMantleInAir", 1.0f, 0x18}, + {"MinVaultEdgeAngleDifference", 135.0f, 0x1c}, + {"MaxStickVsEdgeAngleDifference", 75.0f, 0x20}, + {"MaxDistanceToContactAutoMantleOnGr", 1.5f, 0x24}, + {"MaxDistanceToContactVaultOnGround", 2.0f, 0x28}, + {"MaxSkinnyWidthVaultToMantle", 0.3f, 0x2c}, + {"MaxStickDeflectionVaultToMantle", 0.5f, 0x30}, + {"MaxDistanceToContactWallRunMantleOnGround", 3.0f, 0x34}, + {"MaxDegreesToLockFacingToCollision", 160.0f, 0x38}, + {"MaxAutoMantleLowHeightObstacle", 0.75f, 0x3c}, + {"MinHeightOverGroundInAirMantle", 1.0f, 0x40}, + {"OnGroundWallRunMantleQueryHeight", 2.0f, 0x44}, + {"VaultOverCollisionHeightOffset", 0.75f, 0x48}, + {"MaxDistanceToContactAutoStepUp", 1.0f, 0x4c}, + {"CameraAngleDifferenceToLockMountFacing", 110.0f, 0x50}, + {"MaxAngleToMantle", 30.0f, 0x54}, +}; +inline constexpr ClassSpec class_specs[]{ // group shown, key, first field, field count, anchor field + {"Stance", "stance", 0, 4, 2}, + {"Flip catch", "heldflip", 4, 10, 7}, + {"Board adjust", "boardadjust", 14, 7, 2}, + {"Speed model", "speedmodel", 21, 7, 3}, + {"On board constants", "onboardconstants", 28, 7, 2}, + {"Grindconstants", "grindconstants", 35, 5, 0}, + {"Bail speeds", "wipeoutfallspeed", 40, 9, 5}, + {"Grind control", "grindcontrol", 49, 11, 0}, + {"Onboard doublestickinputmetrics", "onboard_doublestickinputmetrics", 60, 5, 0}, + {"Speed model 2", "speedmodel_2", 65, 8, 3}, + {"Push", "push", 73, 14, 6}, + {"On foot: climb", "climbing", 87, 36, 17}, + {"Pumping", "pumping", 123, 22, 8}, + {"On foot: flips and rolls", "flumping", 145, 23, 17}, + {"On foot: wallrun", "wallrun", 168, 40, 21}, + {"Anti pumping", "antipumping", 208, 5, 0}, + {"Onboard powerslide", "onboard_powerslide", 213, 6, 0}, + {"On foot: swing", "swinging", 219, 6, 5}, + {"Slappy", "slappy", 225, 4, 1}, + {"On foot: run", "locomotion", 229, 3, 0}, + {"On foot: jump", "jump", 232, 8, 5}, + {"Speed model", "onboard_speedmodel", 240, 12, 10}, + {"In the air: dive, torpedo, glide", "wipeout", 252, 40, 28}, + {"On foot: vault and mantle", "traversal", 292, 22, 4}, +}; diff --git a/Extension/Trainer/trainer_commands.cpp b/Extension/Trainer/trainer_commands.cpp index ad0cf818..3e832e63 100644 --- a/Extension/Trainer/trainer_commands.cpp +++ b/Extension/Trainer/trainer_commands.cpp @@ -12,7 +12,7 @@ void register_trainer_commands(Commands ®istry) { }; const Verb verbs[]{ {"status", "What the trainer is doing", nullptr, false}, - {"open", "Open the menu on the trainer page", "[tune|presets|practice|map]", false}, + {"open", "Open the menu on the trainer page", "[tune|presets|practice|map|realistic|fun|everything]", false}, {"set", "Set a physics tuning value live", " ", true}, {"freeze", "Keep a value through presets and resets", " 0|1", true}, {"reset", "Put a value (or all) back to stock", "|all", false}, @@ -22,11 +22,12 @@ void register_trainer_commands(Commands ®istry) { {"marker", "Save, go to or clear a marker", "save|go|clear [slot]", false}, {"tp", "Teleport the skater", " ", false}, {"spot", "Go to one of the map author's spots", "", false}, - {"option", "Trainer options", "hud|hud_jump|auto_return|pad|log 0|1, return_delay , hippy_height|nocomply_height|boneless_height ", true}, + {"option", "Trainer options", "hud|hud_jump|auto_return|pad|log 0|1, return_delay , hippy_height|nocomply_height|boneless_height|offboard_height ", true}, {"profile", "The preset this map applies on load", "set |clear", false}, {"jumps", "The last measured jump", nullptr, false}, {"where", "The skater's position, heading and speed", nullptr, false}, {"states", "Physics states the skater has been in (diagnostic)", nullptr, false}, + {"classes", "The game's tuning classes the trainer found in memory (diagnostic)", "[find]", false}, {"refresh", "Refresh the skater's cached copy of the tuning (diagnostic)", nullptr, false}, {"dump", "Write every tuning value to a text file", nullptr, false}, {"selftest", "Check the trainer against the running game", nullptr, false}, diff --git a/Extension/Trainer/trainer_jump.cpp b/Extension/Trainer/trainer_jump.cpp index a5045f6b..50d35931 100644 --- a/Extension/Trainer/trainer_jump.cpp +++ b/Extension/Trainer/trainer_jump.cpp @@ -101,7 +101,7 @@ bool start_trick_heights(std::uintptr_t base) noexcept { return h.ready; } void set_trick_heights(float no_comply, float boneless) noexcept { - const auto speed = [](float height) { return std::isfinite(height) && height > 0 ? std::sqrt(std::min(height, 400.0f)) : 1.0f; }; + const auto speed = [](float height) { return std::isfinite(height) && height > 0 ? std::sqrt(std::min(height, 100.0f)) : 1.0f; }; hooked().no_comply.store(speed(no_comply), std::memory_order_relaxed); hooked().boneless.store(speed(boneless), std::memory_order_relaxed); } diff --git a/Extension/Trainer/trainer_page.cpp b/Extension/Trainer/trainer_page.cpp index 9884fbce..5536b0e8 100644 --- a/Extension/Trainer/trainer_page.cpp +++ b/Extension/Trainer/trainer_page.cpp @@ -16,7 +16,8 @@ namespace { struct Page { int tab{1}; // opens on PRESETS: the quick switches std::array search{}; - int group{}; // 0: the Essentials list, 1: every group, 2..: one of the view's groups + int mode{1}; // the Tune tab: 0 Realistic, 1 Fun (two short lists), 2 Everything + int group{1}; // Everything: 1: every group, 2..: one of the view's groups bool only_changed{}, graph_points{}, show_unused{}; std::size_t hidden_unused{}; // rows the filters would show but for "no use found" // The rows the filters leave, rebuilt when the snapshot or a filter changes. @@ -29,8 +30,8 @@ struct Page { std::array preset_name{}; std::array teleport{}; double speed_edit{-1}, speed_until{}; - float hippy_edit{1}, nocomply_edit{1}, boneless_edit{1}; - bool hippy_editing{}, nocomply_editing{}, boneless_editing{}; + float hippy_edit{1}, nocomply_edit{1}, boneless_edit{1}, offboard_edit{1}; + bool hippy_editing{}, nocomply_editing{}, boneless_editing{}, offboard_editing{}; std::uint64_t open_serial{}; // the last `trainer open` acted on bool show_page{}; }; @@ -61,25 +62,26 @@ void trainer_command(SkateMenu &menu, const CallbacksV3 &callbacks, const std::s void filter_rows(Page &p, const trainer::View &view) { const auto words = lower(p.search.data()); - const auto key = std::format("{}|{}|{}|{}|{}", words, p.group, p.only_changed, p.graph_points, p.show_unused); + const auto key = std::format("{}|{}|{}|{}|{}|{}", words, p.group, p.only_changed, p.graph_points, p.show_unused, p.mode); if (p.shown_revision == view.revision && p.shown_key == key) return; p.shown_revision = view.revision; p.shown_key = key; p.shown.clear(); p.hidden_unused = 0; - const std::string *group = p.group > 1 && static_cast(p.group) <= view.groups.size() + 1 + const std::string *group = p.mode == 2 && p.group > 1 && static_cast(p.group) <= view.groups.size() + 1 ? &view.groups[static_cast(p.group) - 2] : nullptr; - const bool essentials = p.group == 0 && words.empty() && !p.only_changed; + const bool essentials = p.mode < 2; + const std::uint8_t list = p.mode == 0 ? trainer::mode_realistic : trainer::mode_fun; for (std::size_t i = 0; i < view.rows.size(); ++i) { const auto &row = view.rows[i]; if (essentials) { - if (!row.friendly.empty()) p.shown.push_back(i); + if (!row.friendly.empty() && (row.modes & list)) p.shown.push_back(i); continue; } if (row.detail && !p.graph_points && !row.touched) continue; if (p.only_changed && !row.touched && !row.frozen) continue; // Searching looks through every group. - if (words.empty() ? (group && row.group != *group) : !contains_words(lower(row.id + " " + row.label + " " + row.friendly), words)) + if (words.empty() || p.mode < 2 ? (group && row.group != *group) : !contains_words(lower(row.id + " " + row.label + " " + row.friendly), words)) continue; // A value nothing in the game reads would be a slider that does nothing. if (!row.used && !p.show_unused && !row.touched && !row.frozen) { @@ -153,40 +155,90 @@ void tune_tab(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trai return; } if (!view.editable) warn(view.blocked.c_str()); - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.42f); - ImGui::InputTextWithHint("##search", "Search every value...", p.search.data(), p.search.size()); - ImGui::SameLine(); - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.42f); - const char *current = p.group == 0 ? "Essentials" : p.group > 1 && static_cast(p.group) <= view.groups.size() + 1 - ? view.groups[static_cast(p.group) - 2].c_str() : "Every group"; - if (ImGui::BeginCombo("##group", current)) { - if (ImGui::Selectable("Essentials", p.group == 0)) p.group = 0; - if (ImGui::Selectable("Every group", p.group == 1)) p.group = 1; - for (std::size_t i = 0; i < view.groups.size(); ++i) - if (ImGui::Selectable(view.groups[i].c_str(), p.group == static_cast(i) + 2)) p.group = static_cast(i) + 2; - ImGui::EndCombo(); + // Two short lists and the whole table. + { + static constexpr const char *modes[]{"REALISTIC", "FUN", "EVERYTHING"}; + const float width = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 2) / 3; + for (int i = 0; i < 3; ++i) { + if (i) ImGui::SameLine(); + const bool on = p.mode == i; + if (on) ImGui::PushStyleColor(ImGuiCol_Button, skate_theme::blue); + if (ImGui::Button(modes[i], ImVec2(width, 0))) p.mode = i; + if (on) ImGui::PopStyleColor(); + } + } + const auto preset_on = [&](std::string_view name) { + for (const auto &preset : view.presets) + if (preset.name == name) return preset.active; + return false; + }; + const auto preset_button = [&](const char *name) { + const bool on = preset_on(name); + if (on) ImGui::PushStyleColor(ImGuiCol_Button, skate_theme::blue); + if (ImGui::Button(name)) trainer_command(menu, callbacks, std::string(on ? "preset remove " : "preset apply ") + name); + if (on) ImGui::PopStyleColor(); + }; + ImGui::BeginDisabled(!view.editable || !callbacks.queue_console_command); + if (p.mode == 0) { + note("Tone the game down: lower pop, slower pushing and rotation, stricter catches and bails. Drag any value below the game's own."); + preset_button("Realistic"); + ImGui::SameLine(); + if (ImGui::Button("Reset all")) trainer_command(menu, callbacks, "reset all"); + ImGui::SameLine(); + ImGui::TextDisabled("%zu changed", view.touched); + } else if (p.mode == 1) { + note("Turn it up: huge pop, fast pushing, flips, spins, and everything on foot and in the air."); + static constexpr const char *fun[]{"Super Ollie", "Fast", "Fast Flips", "Fast Spins", "Auto Push", "Moon Jump", "Fast On Foot", + "Fast Parkour Flips", "Super Glide", "Torpedo Boost"}; + for (std::size_t i = 0; i < std::size(fun); ++i) { + if (i && ImGui::GetItemRectMax().x + ImGui::CalcTextSize(fun[i]).x + ImGui::GetStyle().FramePadding.x * 4 < + ImGui::GetWindowPos().x + ImGui::GetContentRegionMax().x) + ImGui::SameLine(); + preset_button(fun[i]); + } + if (ImGui::Button("Reset all")) trainer_command(menu, callbacks, "reset all"); + ImGui::SameLine(); + ImGui::TextDisabled("%zu changed", view.touched); } - ImGui::SameLine(); - ImGui::BeginDisabled(!view.touched || !view.editable); - if (ImGui::Button("Reset all")) trainer_command(menu, callbacks, "reset all"); ImGui::EndDisabled(); - note("Changes apply as you drag. The box on the left only locks a value against presets and Reset all."); - ImGui::Checkbox("Only what I changed", &p.only_changed); - ImGui::SameLine(); - ImGui::Checkbox("Graph points", &p.graph_points); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Show every point of the tuning graphs, not just one multiplier per graph."); - ImGui::SameLine(); - ImGui::Checkbox("Values with no use found", &p.show_unused); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Also list the tuning values that no game code was found reading.\nChanging those will probably do nothing."); + if (p.mode == 2) { + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.42f); + ImGui::InputTextWithHint("##search", "Search every value...", p.search.data(), p.search.size()); + ImGui::SameLine(); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.42f); + if (p.group < 1) p.group = 1; + const char *current = p.group > 1 && static_cast(p.group) <= view.groups.size() + 1 + ? view.groups[static_cast(p.group) - 2].c_str() : "Every group"; + if (ImGui::BeginCombo("##group", current)) { + if (ImGui::Selectable("Every group", p.group == 1)) p.group = 1; + for (std::size_t i = 0; i < view.groups.size(); ++i) + if (ImGui::Selectable(view.groups[i].c_str(), p.group == static_cast(i) + 2)) p.group = static_cast(i) + 2; + ImGui::EndCombo(); + } + ImGui::SameLine(); + ImGui::BeginDisabled(!view.touched || !view.editable); + if (ImGui::Button("Reset all")) trainer_command(menu, callbacks, "reset all"); + ImGui::EndDisabled(); + note("Changes apply as you drag. The box on the left only locks a value against presets and Reset all."); + ImGui::Checkbox("Only what I changed", &p.only_changed); + ImGui::SameLine(); + ImGui::Checkbox("Graph points", &p.graph_points); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Show every point of the tuning graphs, not just one multiplier per graph."); + ImGui::SameLine(); + ImGui::Checkbox("Values with no use found", &p.show_unused); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Also list the tuning values that no game code was found reading.\nChanging those will probably do nothing."); + } filter_rows(p, view); - ImGui::SameLine(); - if (p.hidden_unused) ImGui::TextDisabled("%zu shown, %zu changed, %zu hidden", p.shown.size(), view.touched, p.hidden_unused); - else ImGui::TextDisabled("%zu shown, %zu changed", p.shown.size(), view.touched); - // These trick heights are not tuning values; they sit with the Essentials. - if (p.group == 0 && p.search[0] == 0 && !p.only_changed) trick_heights(menu, callbacks, p, view); - const bool with_group = p.search[0] != 0 || p.group <= 1 || p.only_changed; - const bool friendly = p.group == 0 && p.search[0] == 0 && !p.only_changed; + if (p.mode == 2) { + ImGui::SameLine(); + if (p.hidden_unused) ImGui::TextDisabled("%zu shown, %zu changed, %zu hidden", p.shown.size(), view.touched, p.hidden_unused); + else ImGui::TextDisabled("%zu shown, %zu changed", p.shown.size(), view.touched); + } + // The trick heights are not tuning values; they sit with the short lists. + if (p.mode < 2) trick_heights(menu, callbacks, p, view); + const bool with_group = p.mode == 2 && (p.search[0] != 0 || p.group <= 1 || p.only_changed); + const bool friendly = p.mode < 2; ImGui::BeginChild("tune-rows", ImVec2(0, 0), ImGuiChildFlags_None); ImGuiListClipper clipper; clipper.Begin(static_cast(p.shown.size())); @@ -203,7 +255,7 @@ void trick_heights(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const field(menu, label); ImGui::PushID(option); float shown = editing ? edit : value; - if (ImGui::SliderFloat("##height", &shown, 0.5f, 10.0f, "x %.2f", ImGuiSliderFlags_AlwaysClamp)) { + if (ImGui::SliderFloat("##height", &shown, 0.1f, 50.0f, "x %.2f", ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_Logarithmic)) { edit = shown; editing = true; } @@ -214,7 +266,8 @@ void trick_heights(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const slider("No comply height", "nocomply_height", view.nocomply_height, p.nocomply_edit, p.nocomply_editing); slider("Boneless height", "boneless_height", view.boneless_height, p.boneless_edit, p.boneless_editing); slider("Hippy jump height", "hippy_height", view.hippy_height, p.hippy_edit, p.hippy_editing); - note("Separate from ollie height because the game sets these in its trick scripts."); + slider("Off-board jump height", "offboard_height", view.offboard_height, p.offboard_edit, p.offboard_editing); + note("Separate from ollie height because the game sets these in its trick scripts. Ctrl+click a slider to type a number."); end_card(); } void presets_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) { @@ -459,7 +512,8 @@ bool trainer_take_open() { auto &p = page(); if (view->open_serial == p.open_serial) return false; p.open_serial = view->open_serial; - p.tab = std::clamp(view->open_tab, 0, 3); + p.tab = view->open_tab >= 4 ? 0 : std::clamp(view->open_tab, 0, 3); + if (view->open_tab >= 4) p.mode = std::clamp(view->open_tab - 4, 0, 2); p.show_page = true; return true; } diff --git a/Extension/Trainer/trainer_presets.cpp b/Extension/Trainer/trainer_presets.cpp index cf5a7686..0a48fcbc 100644 --- a/Extension/Trainer/trainer_presets.cpp +++ b/Extension/Trainer/trainer_presets.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace dingosdk::trainer { @@ -22,37 +23,80 @@ const std::vector &value_links() { {"physicsairstates.maxspinspeed", "physicsbodyspin.pbsvst_easy x"}, {"physicsairstates.maxspinspeed", "physicsbodyspin.maxdeltavstime x"}, {"physicsairstates.maxspinspeed", "physicsbodyspin.maxdeltavstimeeasy x"}, + // Pushing: the tuning's top pushing speed only gates whether a push may start. The speeds + // pushes aim for are the push class's (trainer_classes.h), scaled here by the same ratio. + {"physicspush.maxpushablespeed", "push.maxpushspeedlight"}, + {"physicspush.maxpushablespeed", "push.maxpushspeedmedium"}, + {"physicspush.maxpushablespeed", "push.maxpushspeedstrong"}, + {"physicspush.maxpushablespeed", "push.minmediumpushoverridespeed"}, }; return links; } -// Only values that do something: read by the game, or linked above. -std::string_view essential_name(std::string_view key, int *rank) { - static constexpr std::array, 21> names{{ - {"physicsmode.jumpmaxheight", "Ollie height (max)"}, - {"physicsmode.jumpminheight", "Ollie height (min, light pop)"}, - {"physicsmode.grindjumpcommonmax", "Pop out of grinds (max)"}, - {"physicsjump.jumpybonusmax", "Jump bonus (max)"}, - {"physicsreckoning.flipscalar", "Body flip speed"}, - {"physicsreckoning.flipmaxspeed", "Body flip speed limit"}, - {"physicsmode.perfectbodyflips", "Perfect body flips (exactly one rotation)"}, - {"physicsairstates.maxspinspeed", "Body spin speed"}, - {"physicsmode.maxautobodyspinspeed", "Auto body spin speed"}, - {"physicsmode.easybodyspins", "Easy body spins"}, - {"physicspush.maxpushablespeed", "Top pushing speed (m/s, hold push)"}, - {"physicsmode.autopushenabled", "Auto push (keeps a rolling skater going)"}, - {"physicspush.maxspeedforautopush", "Auto push speed (m/s)"}, - {"physicsmode.speedwobblestartspeed", "Speed wobble starts at (m/s)"}, - {"physicsmode.grindlockdist", "Grind lock-on distance"}, - {"physicsgrind.commonfrictionscalar", "Grind friction"}, - {"physicsmode.makesurfacessmooth", "Smooth surfaces"}, - {"physicsmode.wipeoutcheckforbadlanding", "Bail on bad landings"}, - {"physicsmode.wipeout_groundxzacceleration", "Bail: sideways hit limit"}, - {"physicstrucks.truckzposfront", "Front truck position (next respawn)"}, - {"physicstrucks.truckzposback", "Back truck position (next respawn)"}, - }}; - const auto found = std::ranges::find(names, key, &std::pair::first); - if (rank) *rank = found == names.end() ? 0 : static_cast(found - names.begin()) + 1; - return found == names.end() ? std::string_view{} : found->second; +// The two short lists of the Tune tab. Only values that do something: read by the game, linked +// above, or a field of one of the game's data-defined classes. +std::string_view essential_name(std::string_view key, int *rank, std::uint8_t *modes) { + struct Name { + std::string_view key, label; + std::uint8_t modes; + }; + constexpr std::uint8_t r = mode_realistic, f = mode_fun, both = mode_realistic | mode_fun; + static constexpr Name names[]{ + {"physicsmode.jumpmaxheight", "Ollie height (max)", both}, + {"physicsmode.jumpminheight", "Ollie height (min, light pop)", both}, + {"physicsmode.grindjumpcommonmax", "Pop out of grinds (max)", both}, + {"physicsjump.jumpybonusmax", "Jump bonus (max)", f}, + {"physicspush.maxpushablespeed", "Push speed (9.25 = the game's; scales every push)", both}, + {"push.maxpushspeedlight", "Push: a tapped push settles at (m/s)", r}, + {"push.maxpushspeedmedium", "Push: a held push reaches (m/s)", r}, + {"push.maxpushspeedstrong", "Push: top speed (m/s)", r}, + {"physicsmode.autopushenabled", "Auto push (keeps a rolling skater going)", f}, + {"physicspush.maxspeedforautopush", "Auto push speed (m/s)", f}, + {"physicsreckoning.flipscalar", "Body flip speed", both}, + {"physicsreckoning.flipmaxspeed", "Body flip speed limit", both}, + {"physicsmode.perfectbodyflips", "Perfect body flips (exactly one rotation)", f}, + {"physicsairstates.maxspinspeed", "Body spin speed", both}, + {"physicsmode.maxautobodyspinspeed", "Auto body spin speed", both}, + {"physicsmode.easybodyspins", "Easy body spins", f}, + {"heldflip.straightflipcatchtime", "Flip catch time: kickflips and heelflips (s)", r}, + {"heldflip.shuvmincatchtime", "Flip catch time: shuvits (s)", r}, + {"heldflip.bigflipmincatchtime", "Flip catch time: big flips (s)", r}, + {"physicsmode.speedwobblestartspeed", "Speed wobble starts at (m/s)", both}, + {"physicsmode.grindlockdist", "Grind lock-on distance", both}, + {"physicsgrind.commonfrictionscalar", "Grind friction", both}, + {"physicsmode.makesurfacessmooth", "Smooth surfaces", f}, + {"onboard_powerslide.powerslide_forwardforcescalar_slide", "Powerslide: forward force", f}, + {"onboard_powerslide.frictionscalar_slide", "Powerslide: friction", both}, + {"onboard_speedmodel.noinputbrakespeed", "Coasting: speed the skater slows to with no input (m/s)", r}, + {"physicsmode.wipeoutcheckforbadlanding", "Bail on bad landings", both}, + {"physicsmode.wipeout_groundxzacceleration", "Bail: sideways hit limit", both}, + {"wipeoutfallspeed.normalmaxspeed_ground", "Bail: fall speed limit on the ground (m/s)", r}, + {"wipeoutfallspeed.normalmaxspeed_grind", "Bail: fall speed limit in a grind (m/s)", r}, + {"jump.basejumpheight", "On foot: jump height (m)", both}, + {"jump.maxjumpheight", "On foot: highest jump (m)", both}, + {"jump.maxjumpvelocity", "On foot: jump speed limit (m/s)", f}, + {"jump.aircontrolforce", "On foot: steering in the air", f}, + {"locomotion.sprintspeed", "On foot: sprint speed (m/s)", both}, + {"locomotion.sprintspeedboost", "On foot: sprint boost speed (m/s)", f}, + {"flumping.flumptuckmaxrotationvelocity", "On foot: flip rotation speed", both}, + {"flumping.rollmaxvelocity", "On foot: roll speed (m/s)", f}, + {"flumping.rollvelocitymultiplier", "On foot: roll speed gain", f}, + {"wipeout.spreadeaglegravity", "Glide: gravity (-8; nearer 0 falls slower)", f}, + {"wipeout.spreadeagleairresistance", "Glide: air resistance", f}, + {"wipeout.spreadeagleaircontrolmax", "Glide: steering", f}, + {"wipeout.torpedoaircontrolmax", "Torpedo: steering (fast)", f}, + {"wipeout.torpedoaircontrolmin", "Torpedo: steering (slow)", f}, + {"wipeout.defaultaircontrol", "Dive: steering", f}, + {"wipeout.defaultairresistance", "Dive: air resistance", f}, + {"wallrun.wallrunjumpupvelocityboost", "Wallrun: jump up boost", f}, + {"wallrun.wallrungravity", "Wallrun: gravity", f}, + {"physicstrucks.truckzposfront", "Front truck position (next respawn)", r}, + {"physicstrucks.truckzposback", "Back truck position (next respawn)", r}, + }; + const auto found = std::ranges::find(names, key, &Name::key); + const bool known = found != std::end(names); + if (rank) *rank = known ? static_cast(found - std::begin(names)) + 1 : 0; + if (modes) *modes = known ? found->modes : std::uint8_t{}; + return known ? found->label : std::string_view{}; } // Patterns are matched against the lower-case ids the game's own data gives its tuning @@ -79,14 +123,25 @@ const std::vector &builtin_presets() { {"physicsmode.grindjump", true, 0.8}, {"physicspush.maxpushablespeed !camera", true, 0.75}, {"physicsmode.speedwobblestartspeed", true, 0.7}, {"physicsreckoning.flipscalar", true, 0.8}, {"physicsairstates.maxspinspeed", true, 0.75}, - {"physicsmode.maxautobodyspinspeed", true, 0.75}, {"physicsmode.grindlockdist", true, 0.7}, + {"physicsmode.maxautobodyspinspeed", true, 0.75}, {"physicsmode.grindlockdist", true, 0.7}, {"jump.basejumpheight", true, 0.85}, {"jump.maxjumpheight", true, 0.85}, + {"locomotion.sprintspeed", true, 0.85}, {"wipeoutfallspeed.", true, 0.8}, {"physicswipeout.wipeout_ maxspeed", true, 0.8}, {"physicsmode.wipeout_ acceleration", true, 0.8}}}, {"Mega Pop", "Ollies and grind pops go about twice as high.", {{"physicsmode.jumpmaxheight", true, 2.0}, {"physicsmode.jumpminheight", true, 1.6}, {"physicsjump.jumpybonusmax", true, 2.0}, {"physicsmode.grindjump", true, 1.6}}}, - {"Fast", "Hold push to reach a much higher top speed.", + {"Fast", "Every push is 1.8 times as fast.", {{"physicspush.maxpushablespeed !camera", true, 1.8}, {"physicspush.maxspeedforautopush", true, 1.8}, {"physicsmode.speedwobblestartspeed", true, 3.0}}}, + {"Moon Jump", "On foot: jumps about three times as high.", + {{"jump.basejumpheight", true, 3.0}, {"jump.maxjumpheight", true, 3.0}, {"jump.maxjumpvelocity", true, 2.0}}}, + {"Fast On Foot", "On foot: sprint nearly twice as fast.", + {{"locomotion.sprintspeed", true, 1.8}, {"locomotion.sprintspeedboost", true, 1.8}}}, + {"Fast Parkour Flips", "On foot: flips and rolls rotate and travel much faster.", + {{"flumping.flumptuckmaxrotationvelocity", true, 2.5}, {"flumping.rollmaxvelocity", true, 2.0}, {"flumping.rollvelocitymultiplier", true, 1.5}}}, + {"Super Glide", "Spread-eagle falls slowly and steers hard.", + {{"wipeout.spreadeaglegravity", true, 0.35}, {"wipeout.spreadeagleaircontrolmax", true, 2.0}, {"wipeout.spreadeagleaircontrolmin", true, 2.0}}}, + {"Torpedo Boost", "Torpedo and dive steer and carry much harder.", + {{"wipeout.torpedoaircontrolmax", true, 2.5}, {"wipeout.torpedoaircontrolmin", true, 2.5}, {"wipeout.defaultaircontrol", true, 2.0}}}, {"No Speed Wobble", "The board stays steady at any speed.", {{"physicsmode.speedwobblestartspeed", true, 20.0}}}, {"Auto Push", "Once rolling, the skater keeps gaining speed up to the auto push speed (8 m/s).", {{"physicsmode.autopushenabled", false, 1.0}}}, {"Hard To Bail", "Much larger impacts are needed before a wipeout.", diff --git a/Extension/Trainer/trainer_presets.h b/Extension/Trainer/trainer_presets.h index c593e2ab..6f381cd8 100644 --- a/Extension/Trainer/trainer_presets.h +++ b/Extension/Trainer/trainer_presets.h @@ -1,4 +1,5 @@ #pragma once +#include "trainer.h" #include #include #include @@ -17,8 +18,9 @@ struct BuiltinPreset { std::string_view name, note; std::vector rules; }; -// The plain name of a value on the Essentials list, by its lower-case id; empty for the rest. -std::string_view essential_name(std::string_view key, int *rank = nullptr); +// The plain name of a value on the Tune tab's short lists, by its lower-case id; empty for the +// rest. `modes`: which lists it is on (mode_realistic, mode_fun). +std::string_view essential_name(std::string_view key, int *rank = nullptr, std::uint8_t *modes = nullptr); // Whether the game's code was found to read the tuning value at this offset of the asset. // False means "no use found": changing it will probably do nothing. bool value_used(std::uint16_t offset); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 23005524..ef807398 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -108,6 +108,7 @@ add_library(dingosdk_runtime SHARED Extension/Trainer/trainer.cpp Extension/Trainer/trainer_presets.cpp Extension/Trainer/trainer_jump.cpp + Extension/Trainer/trainer_classes.cpp Extension/Trainer/trainer_commands.cpp Extension/Skater/offboard_flight.cpp Extension/Skater/camera_observer.cpp From 10623d187e6e6e654e68d02ae330805041911e77 Mon Sep 17 00:00:00 2001 From: andrewnakas Date: Sun, 4 Oct 2026 03:07:30 -0600 Subject: [PATCH 010/109] Trainer 0.2.0: flip trick speed, read evidence for every class value, a locked value keeps what it drives Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018szL7EmApPZxh9GK4Phw6x --- Extension/Trainer/CHANGELOG.md | 21 +- Extension/Trainer/trainer.cpp | 64 ++- Extension/Trainer/trainer.h | 2 + Extension/Trainer/trainer_classes.cpp | 174 ++++++- Extension/Trainer/trainer_classes.h | 18 + Extension/Trainer/trainer_classes.inc | 630 ++++++++++++------------- Extension/Trainer/trainer_commands.cpp | 2 +- Extension/Trainer/trainer_page.cpp | 16 +- Extension/Trainer/trainer_presets.cpp | 7 +- 9 files changed, 589 insertions(+), 345 deletions(-) diff --git a/Extension/Trainer/CHANGELOG.md b/Extension/Trainer/CHANGELOG.md index b2ad625c..5739c8a3 100644 --- a/Extension/Trainer/CHANGELOG.md +++ b/Extension/Trainer/CHANGELOG.md @@ -28,8 +28,25 @@ Built on ReSkate 1.0.7. - Trick height sliders go from x0.1 to x50 (Ctrl+click to type); the console takes 0.05 to 100. - Return after a bail no longer ignores the first bail of a session, and says in the log what it did. (The switch is on the Practice tab.) -- Not done: the speed of board flip tricks. The game keeps it in curves the trainer cannot - reach yet. +- **Flip trick speed** (the TRICKS card). The game keeps board flip speed in eight curves; the + trainer finds them and scales the four speed curves. It slows flips (x0.4: a kickflip turns at + about 950 deg/s instead of 1300 to 1400); above 1 the game's own limit on how fast a board + turns takes over, so the slider stops at x3. +- **Evidence behind every class value.** A scripted skater rode, pushed, ollied, flipped, did no + complies and bonelesses, ran, jumped and sprinted while hardware read watches sat on every copy + of each of the 314 values: the game read 187. The rest (grind control, pumping, climbing, + vaults, hard impacts: moves the script does not do) are marked "no use found" and hidden unless + asked for, like the tuning asset's unread values. +- **A locked value keeps what it drives.** Stock and Reset all used to leave a locked ollie + height showing its number while resetting the graphs behind it. +- The jump read-out, the log and the telemetry CSV carry the board's fastest turn rate. +- Checked by measurement, with a scripted controller: push speed (x2 cruises at 8.2 m/s, the + Realistic preset at 3.0), auto push, ollie height, Mega Pop (0.39 to 0.79 m), Fast Spins, no + comply and boneless heights, on-foot jump (Moon Jump 0.9 to 2.6 m), sprint (Fast On Foot 6.6 + to 9.8 m/s), flip trick speed, game speed (0.5x doubles an ollie's air time), markers and + their pad shortcuts, teleport. No effect could be measured for the speed wobble start speed + (heading at 7.5 m/s) or the sideways bail limit (0.01 did not cause a bail); spread-eagle, + torpedo, on-foot flips, grinds, bails and return-after-bail were not reached by the script. ## v0.1.4 - 2026-10-03 diff --git a/Extension/Trainer/trainer.cpp b/Extension/Trainer/trainer.cpp index a98c277e..c09a292a 100644 --- a/Extension/Trainer/trainer.cpp +++ b/Extension/Trainer/trainer.cpp @@ -83,6 +83,7 @@ struct Motion { bool axes_valid{}; Vec3 forward{}, up{}; float spin{}, spin_rate{}, flip{}; + float board_turn{}; // the board's fastest turn rate in this jump, degrees per second std::uint32_t air_state{}, ground_state{}; }; struct State { @@ -111,6 +112,8 @@ struct State { float hippy_height{1}; // An on-foot jump: the same scaling, when a skater standing off the board starts to rise. float offboard_height{1}; + // Board flip tricks: a multiplier on the game's flip speed curves (trainer_classes.h). + float flip_speed{1}; bool offboard_grounded{}; // off the board and not moving up or down at the last tick const char *boost_name{""}; // The no comply and the boneless are launched by the trick scripts too (trainer_jump.cpp). @@ -222,6 +225,7 @@ void load_store() { s.hippy_height = std::clamp(o.value("hippy_height", 1.0f), height_low, height_high); s.nocomply_height = std::clamp(o.value("nocomply_height", 1.0f), height_low, height_high); s.offboard_height = std::clamp(o.value("offboard_height", 1.0f), height_low, height_high); + s.flip_speed = std::clamp(o.value("flip_speed", 1.0f), 0.1f, 3.0f); s.boneless_height = std::clamp(o.value("boneless_height", 1.0f), height_low, height_high); s.slot = std::clamp(o.value("slot", 0), 0, static_cast(marker_slots) - 1); } @@ -267,6 +271,7 @@ void save_store() { options["hippy_height"] = s.hippy_height; options["nocomply_height"] = s.nocomply_height; options["offboard_height"] = s.offboard_height; + options["flip_speed"] = s.flip_speed; options["boneless_height"] = s.boneless_height; options["slot"] = s.slot; json["options"] = std::move(options); @@ -422,6 +427,16 @@ void set_entry(Entry &e, double value) { } } } +// A value a locked linked value drives (its graphs, the push class's speeds) is locked with it: +// resetting it alone would leave the lock showing a number that no longer does anything. +bool is_locked(const Entry &e) { + if (e.frozen) return true; + auto &s = state(); + const auto index = static_cast(&e - s.entries.data()); + return std::ranges::any_of(s.entries, [&](const Entry &other) { + return other.frozen && std::ranges::find(other.drives, index) != other.drives.end(); + }); +} Entry *find_entry(std::string_view id) { auto &s = state(); const auto found = s.index.find(lower(id)); @@ -497,7 +512,7 @@ void build_entries() { e.group = class_specs[c].group; e.label = spaced(field.name); e.kind = Kind::real; - e.used = true; + e.used = field.seen; e.class_field = static_cast(class_specs[c].first + i); e.stock = e.value = field.stock; s.index.emplace(e.key, s.entries.size()); @@ -632,7 +647,7 @@ std::string remove_preset(std::string_view name) { std::size_t count{}; std::string removed; const auto put_back = [&](Entry &e) { - if (e.frozen) return; + if (is_locked(e)) return; if (e.touched) { set_entry(e, e.stock); ++count; @@ -640,7 +655,7 @@ std::string remove_preset(std::string_view name) { } // A linked value at stock whose graphs were set directly (by an older version's preset). for (const auto index : e.drives) - if (auto &target = s.entries[index]; target.touched && !target.frozen) { + if (auto &target = s.entries[index]; target.touched && !is_locked(target)) { set_entry(target, target.stock); ++count; } @@ -676,7 +691,7 @@ std::size_t reset_all() { auto &s = state(); std::size_t count{}; for (auto &e : s.entries) { - if (e.frozen || !e.touched) continue; + if (!e.touched || is_locked(e)) continue; set_entry(e, e.stock); ++count; } @@ -696,14 +711,14 @@ std::string apply_preset(std::string_view name) { if (wanted == "map") { if (s.map_file.preset.empty()) return "error: this map ships no trainer preset."; for (const auto &[id, value] : s.map_file.preset) - if (auto *e = find_entry(id); e && !e->frozen) { set_entry(*e, value); ++count; } + if (auto *e = find_entry(id); e && !is_locked(*e)) { set_entry(*e, value); ++count; } applied = s.map_file.preset_name.empty() ? "Map preset" : s.map_file.preset_name; } if (applied.empty()) for (const auto &[user_name, values] : s.user) { if (lower(user_name) != wanted) continue; for (const auto &[key, value] : values) - if (auto *e = find_entry(key); e && !e->frozen) { set_entry(*e, value); ++count; } + if (auto *e = find_entry(key); e && !is_locked(*e)) { set_entry(*e, value); ++count; } applied = user_name; break; } @@ -715,7 +730,7 @@ std::string apply_preset(std::string_view name) { const bool scale = e.kind == Kind::curve || e.kind == Kind::graph; if (e.detail || scale != rule.curves || !matches(e.key, rule.pattern)) continue; if (e.kind == Kind::flag && rule.multiply) continue; - if (e.frozen) { + if (is_locked(e)) { ++locked; continue; } @@ -843,15 +858,16 @@ void finish_jump(const Vec3 &landing, float landing_speed, std::int64_t landed_a jump.spin = std::abs(m.spin); jump.spin_rate = m.spin_rate; jump.flip = m.flip; + jump.board_turn = m.board_turn; jump.state = m.air_state; s.telemetry.last = jump; if (jump.distance > s.telemetry.best.distance) s.telemetry.best = jump; say(logging::Level::info, std::format("Trainer jump {}: takeoff {:.1f} km/h at {:.1f} deg, air {:.2f} s, height {:.2f} m, distance {:.2f} m, drop {:.2f} m, " "landing {:.1f} km/h at ({:.1f}, {:.1f}, {:.1f}); spin {:.0f} deg (peak {:.0f} deg/s), flip {:.0f} deg, " - "states {}>{}.", + "board {:.0f} deg/s, states {}>{}.", jump.serial, jump.takeoff_speed * 3.6f, jump.takeoff_angle, jump.air_time, jump.height, jump.distance, jump.drop, - jump.landing_speed * 3.6f, landing[0], landing[1], landing[2], jump.spin, jump.spin_rate, jump.flip, m.ground_state, + jump.landing_speed * 3.6f, landing[0], landing[1], landing[2], jump.spin, jump.spin_rate, jump.flip, jump.board_turn, m.ground_state, m.air_state)); } void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) { @@ -956,7 +972,7 @@ void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) { m.axes_valid = true; if (!flying && watch.valid) m.ground_state = watch.state; if (flying && !m.airborne) { - m.spin = m.spin_rate = m.flip = 0; + m.spin = m.spin_rate = m.flip = m.board_turn = 0; m.air_state = watch.state; m.airborne = true; m.takeoff = m.position; @@ -979,9 +995,23 @@ void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) { m.velocity = velocity; m.position = position; m.stamp = stamp; + // How fast the board turns (flip tricks, shuvits): body 0's angular velocity (+0x90), read while a jump + // is measured or telemetry is recorded. + float board_turn = 0; + if (m.airborne || (s.logging && s.log)) { + try { + const auto bodies = client_source::detail::debug_noclip_bodies(base, client, s.entity); + std::array spin{}; + if (!bodies.parts.empty() && memory::peek(bodies.parts[0] + 0x90, spin)) { + board_turn = std::sqrt(spin[0] * spin[0] + spin[1] * spin[1] + spin[2] * spin[2]) * 180.0f / std::numbers::pi_v; + if (!std::isfinite(board_turn) || board_turn > 1.0e5f) board_turn = 0; + } + } catch (...) {} + if (m.airborne) m.board_turn = std::max(m.board_turn, board_turn); + } if (s.logging && s.log) { - s.log << std::format("{:.4f},{:.3f},{:.3f},{:.3f},{:.3f},{:.3f},{:.1f},{},{}\n", clock_seconds(stamp - s.log_start), position[0], - position[1], position[2], t.speed, t.vertical, t.heading, t.airborne ? 1 : 0, t.physics_state); + s.log << std::format("{:.4f},{:.3f},{:.3f},{:.3f},{:.3f},{:.3f},{:.1f},{},{},{:.0f}\n", clock_seconds(stamp - s.log_start), position[0], + position[1], position[2], t.speed, t.vertical, t.heading, t.airborne ? 1 : 0, t.physics_state, board_turn); } // A bail: back to the selected marker once the skater has had time to fall. if (watch.valid && (!s.wipeouts_known || watch.wipeouts != s.seen_wipeouts)) { @@ -1012,7 +1042,7 @@ void set_logging(bool on) { time.wMinute, time.wSecond); s.log.open(file, std::ios::binary | std::ios::trunc); if (!s.log) return; - s.log << "seconds,x,y,z,speed_mps,vertical_mps,heading_deg,airborne,physics_state\n"; + s.log << "seconds,x,y,z,speed_mps,vertical_mps,heading_deg,airborne,physics_state,board_turn_dps\n"; s.log_start = clock_now(); s.logging = true; } @@ -1151,6 +1181,7 @@ void build_view() { next->hippy_height = s.hippy_height; next->nocomply_height = s.nocomply_height; next->offboard_height = s.offboard_height; + next->flip_speed = s.flip_speed; next->boneless_height = s.boneless_height; next->return_delay = s.return_delay; next->pad_shortcuts = s.pad_shortcuts; @@ -1216,6 +1247,7 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool playing, const std::s for (auto &e : s.entries) if (e.class_field >= 0) want_class_value(static_cast(e.class_field), static_cast(e.touched && !stock ? e.value : e.stock)); } + want_flip_speed((!multiplayer_session_active() || session_boosts_allowed()) && editable() ? s.flip_speed : 1.0f); (void)apply_classes(); // Like the hippy jump's, these are boosts: a session that turns boosts off turns them off. const bool boosts = !multiplayer_session_active() || session_boosts_allowed(); @@ -1362,7 +1394,11 @@ std::string run(std::string_view verb, const std::vector &a) { if (v == "option") { const auto name = lower(arg(0)); bool on{}; - if (name == "hippy_height" || name == "nocomply_height" || name == "boneless_height" || name == "offboard_height") { + if (name == "flip_speed") { + const auto value = number(arg(1)); + if (!value) return "error: flip_speed needs a multiplier, 1 = the game's own speed."; + s.flip_speed = std::clamp(static_cast(*value), 0.1f, 3.0f); + } else if (name == "hippy_height" || name == "nocomply_height" || name == "boneless_height" || name == "offboard_height") { const auto value = number(arg(1)); if (!value) return "error: " + name + " needs a multiplier, 1 = the game's own height."; (name == "hippy_height" ? s.hippy_height : name == "nocomply_height" ? s.nocomply_height : name == "offboard_height" ? s.offboard_height : s.boneless_height) = diff --git a/Extension/Trainer/trainer.h b/Extension/Trainer/trainer.h index 9153fbf8..e0bb23b6 100644 --- a/Extension/Trainer/trainer.h +++ b/Extension/Trainer/trainer.h @@ -68,6 +68,7 @@ struct View { // The same for the no comply and the boneless. float nocomply_height{1}, boneless_height{1}; float offboard_height{1}; // a jump on foot + float flip_speed{1}; // board flip tricks: x of the game's own speed // HUD bool hud{}, hud_jump{true}, logging{}; // The loaded map and what its author ships for the trainer (Mods//trainer.json). @@ -83,6 +84,7 @@ struct Jump { float takeoff_speed{}, takeoff_angle{}, air_time{}, height{}, distance{}, drop{}, landing_speed{}; float spin{}, spin_rate{}; // degrees turned about the vertical in the air, and the fastest rate (deg/s) float flip{}; // degrees the skater's up axis tumbled (body flips and rolls) + float board_turn{}; // the board's fastest turn rate, degrees per second (flip tricks, shuvits) std::uint32_t state{}; // the physics state it took off into (tells trick kinds apart) std::array takeoff{}, landing{}; }; diff --git a/Extension/Trainer/trainer_classes.cpp b/Extension/Trainer/trainer_classes.cpp index 3899b883..5f581bd2 100644 --- a/Extension/Trainer/trainer_classes.cpp +++ b/Extension/Trainer/trainer_classes.cpp @@ -59,8 +59,19 @@ int mirror_field(const MirrorSlot &slot) noexcept { } return -1; } +// A curve of the flip trick tuning: where its points are and the outputs they shipped with. +// A point is 0x1c bytes with its input at +0xc and its output at +0x14 (the layout the physics +// tuning's FloatCurves have); the count sits four bytes before the first point. +struct FlipCurve { + std::uintptr_t curve{}, points{}; + std::vector outputs; + float low{}, high{}; // the bounds the curve clamps its output to (+0x20, +0x24) +}; +constexpr std::size_t curve_point = 0x1c, curve_x = 0xc, curve_y = 0x14, curve_points_at = 0x18; struct Found { std::mutex mutex; + std::vector flip; + float flip_wanted{1}, flip_written{1}; Copies copies; MirrorCopies mirror_copies; std::array, mirror_count> mirror_fields{}; @@ -174,6 +185,84 @@ bool put(std::uintptr_t address, float value) noexcept { return false; } } +template bool peek(std::uintptr_t address, T &value) noexcept { + __try { + std::memcpy(&value, reinterpret_cast(address), sizeof(T)); + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +template void each_region(Visit &&visit) { + MEMORY_BASIC_INFORMATION info{}; + for (std::uintptr_t at = 0x10000; at < 0x7fffffff0000ull && VirtualQuery(reinterpret_cast(at), &info, sizeof(info)) == sizeof(info); + at = reinterpret_cast(info.BaseAddress) + info.RegionSize) { + if (info.State != MEM_COMMIT || info.Protect != PAGE_READWRITE || info.Type == MEM_IMAGE) continue; + visit(reinterpret_cast(info.BaseAddress), info.RegionSize); + } +} +void collect_typed(std::uintptr_t start, std::size_t size, std::uintptr_t type, std::vector &objects) noexcept { + __try { + const auto *words = reinterpret_cast(start); + for (std::size_t i = 0; i < size / 8; ++i) + if (words[i] == type && objects.size() < 100000) objects.push_back(start + i * 8); + } __except (EXCEPTION_EXECUTE_HANDLER) {} +} +void collect_eights(std::uintptr_t start, std::size_t size, const std::vector &objects, std::vector &runs) noexcept { + __try { + const auto *words = reinterpret_cast(start); + const std::size_t total = size / 8; + std::size_t run{}; + for (std::size_t i = 0; i <= total; ++i) { + const bool hit = i < total && words[i] >= objects.front() && words[i] <= objects.back() && std::binary_search(objects.begin(), objects.end(), words[i]); + if (hit) { ++run; continue; } + if (run == 8 && runs.size() < 64) runs.push_back(start + (i - 8) * 8); + run = 0; + } + } __except (EXCEPTION_EXECUTE_HANDLER) {} +} +// One curve's points: false unless it has `count` points starting at (x0, y0). +bool read_curve(std::uintptr_t curve, std::uint32_t count, float x0, float y0, FlipCurve &out) noexcept { + std::uintptr_t points{}; + std::uint32_t n{}; + float x{}, y{}; + if (!peek(curve + curve_points_at, points) || !points || !peek(points - 4, n) || (n & 0x7fffffffu) != count) return false; + if (!peek(points + curve_x, x) || !peek(points + curve_y, y) || std::abs(x - x0) > 0.01f || std::abs(y - y0) > 0.01f) return false; + out.curve = curve; + out.points = points; + if (!peek(curve + 0x20, out.low) || !peek(curve + 0x24, out.high)) return false; + out.outputs.clear(); + for (std::uint32_t i = 0; i < count; ++i) { + if (!peek(points + i * curve_point + curve_y, y)) return false; + out.outputs.push_back(y); + } + return true; +} +// The flip trick tuning's speed curves. `curve_type`: the first eight bytes of any FloatCurve. +// The class's fields in memory order (the game's data): ExperimentalFlipTrickSpeedCurve, +// LegacyFlipTrickSpeedCurve, two flick height curves, GestureSpeedToFlipSpeed, +// OllieSpeedToFlipSpeed, two flick sensitivity curves. As they ship: the first runs +// (0, 0.4)..(0.9, 1.4) on three points, the second starts at (0.35, 0.65), the fifth at +// (0, 0.05), the sixth is flat at 1. +std::vector find_flip_curves(std::uintptr_t curve_type) { + std::vector result; + std::vector objects, runs; + each_region([&](std::uintptr_t start, std::size_t size) { collect_typed(start, size, curve_type, objects); }); + std::ranges::sort(objects); + if (objects.size() < 8) return result; + each_region([&](std::uintptr_t start, std::size_t size) { collect_eights(start, size, objects, runs); }); + for (const auto run : runs) { + std::array curves{}; + if (!peek(run, curves)) continue; + std::array speed; + if (!read_curve(curves[0], 3, 0.0f, 0.4f, speed[0]) || !read_curve(curves[1], 3, 0.35f, 0.65f, speed[1]) || + !read_curve(curves[4], 3, 0.0f, 0.05f, speed[2]) || !read_curve(curves[5], 2, 0.0f, 1.0f, speed[3])) + continue; + for (auto &curve : speed) + if (std::ranges::none_of(result, [&](const FlipCurve &known) { return known.points == curve.points; })) result.push_back(std::move(curve)); + } + return result; +} DWORD WINAPI search(void *) noexcept { auto &f = found(); try { @@ -241,9 +330,41 @@ DWORD WINAPI search(void *) noexcept { f.ready = true; f.dirty = true; } + // The flip trick curves: any FloatCurve gives the type to look for, and the push class + // keeps one 0x10 after its first number. + std::uintptr_t curve_type{}; + std::size_t flip_found{}; + { + std::uintptr_t push_copy{}; + { + std::lock_guard lock(f.mutex); + for (std::size_t c = 0; c < class_count && !push_copy; ++c) + if (std::string_view(class_specs[c].key) == "push") + for (const auto copy : f.copies[c]) + if (!copy.slots) { push_copy = copy.base; break; } + } + std::uintptr_t curve{}; + if (push_copy && peek(push_copy + 0x10, curve) && curve) (void)peek(curve, curve_type); + } + if (curve_type) { + bool have; + { + std::lock_guard lock(f.mutex); + have = !f.flip.empty(); + } + // Curves already scaled no longer look like the shipped ones: keep those. + if (!have) { + auto curves = find_flip_curves(curve_type); + std::lock_guard lock(f.mutex); + flip_found = curves.size(); + f.flip = std::move(curves); + f.flip_written = 1; + f.dirty = true; + } + } logging::write(logging::Level::info, logging::Channel::skater, - std::format("Trainer: found {} of {} game tuning classes ({} copies) in {} MB, {} ms.", classes, class_count, copies, bytes >> 20, - GetTickCount64() - started)); + std::format("Trainer: found {} of {} game tuning classes ({} copies) and {} flip trick speed curves in {} MB, {} ms.", classes, + class_count, copies, flip_found, bytes >> 20, GetTickCount64() - started)); } catch (...) {} f.searches.fetch_add(1, std::memory_order_release); f.searching.store(false, std::memory_order_release); @@ -285,6 +406,21 @@ std::size_t apply_classes() noexcept { if (!lock.owns_lock() || !f.ready || !f.dirty) return 0; f.dirty = false; std::size_t count{}; + if (f.flip_wanted != f.flip_written && !f.flip.empty()) { + // A curve whose first output is not what was last written is no longer that curve. + std::erase_if(f.flip, [&](const FlipCurve &curve) { + float y{}; + return curve.outputs.empty() || !peek(curve.points + curve_y, y) || std::abs(y - curve.outputs[0] * f.flip_written) > 0.001f * std::max(1.0f, std::abs(y)); + }); + for (const auto &curve : f.flip) { + for (std::size_t i = 0; i < curve.outputs.size(); ++i) + if (put(curve.points + i * curve_point + curve_y, curve.outputs[i] * f.flip_wanted)) ++count; + // The bounds move with the outputs, or the curve clamps the scaled values back. + (void)put(curve.curve + 0x20, curve.low > 0 ? curve.low * f.flip_wanted : curve.low); + (void)put(curve.curve + 0x24, curve.high > 0 ? curve.high * f.flip_wanted : curve.high); + } + f.flip_written = f.flip_wanted; + } for (std::size_t m = 0; m < mirror_count; ++m) { auto &list = f.mirror_copies[m]; bool differs = false; @@ -314,6 +450,39 @@ std::size_t apply_classes() noexcept { } return count; } +void want_flip_speed(float factor) noexcept { + auto &f = found(); + if (!(factor > 0.01f) || !(factor < 100.0f)) return; + std::lock_guard lock(f.mutex); + if (f.flip_wanted != factor) { + f.flip_wanted = factor; + f.dirty = true; + } +} +std::size_t flip_curves() noexcept { + auto &f = found(); + std::lock_guard lock(f.mutex); + return f.flip.size(); +} +std::size_t class_field_copies(std::size_t field, FieldCopy *out, std::size_t capacity) noexcept { + auto &f = found(); + std::lock_guard lock(f.mutex); + std::size_t count{}; + // Native code's copies first: where there is one, it is what the game reads. + for (std::size_t m = 0; m < mirror_count; ++m) + for (std::size_t i = 0; i < mirrors[m].slots.size(); ++i) + if (f.mirror_fields[m][i] == static_cast(field)) + for (const auto base : f.mirror_copies[m]) + if (count < capacity) out[count++] = {base + i * 4, 'n'}; + for (std::size_t c = 0; c < class_count; ++c) { + const auto &spec = class_specs[c]; + if (field < spec.first || field >= static_cast(spec.first) + spec.count) continue; + for (const bool slots : {true, false}) + for (const auto copy : f.copies[c]) + if (copy.slots == slots && count < capacity) out[count++] = {copy.base + field_offset(spec, field - spec.first, slots), slots ? 's' : 'c'}; + } + return count; +} std::string classes_summary() { auto &f = found(); std::lock_guard lock(f.mutex); @@ -323,6 +492,7 @@ std::string classes_summary() { result += std::format("{}{}:{}+{}", c ? ", " : "", class_specs[c].key, f.copies[c].size() - static_cast(slots), slots); } for (std::size_t m = 0; m < mirror_count; ++m) result += std::format(", native copy {}:{}", m, f.mirror_copies[m].size()); + result += std::format(", flip trick speed curves:{}", f.flip.size()); return result; } } // namespace dingosdk::trainer diff --git a/Extension/Trainer/trainer_classes.h b/Extension/Trainer/trainer_classes.h index b6cd3564..d324af04 100644 --- a/Extension/Trainer/trainer_classes.h +++ b/Extension/Trainer/trainer_classes.h @@ -13,6 +13,11 @@ struct ClassField { const char *name; float stock; std::uint16_t offset; + // The game was seen reading it while a scripted skater rode, pushed, ollied, flipped, ran, + // jumped and sprinted (hardware read watches on every copy), or its effect was measured. + // False is not proof: values of moves the script does not do (grinds, vaults, climbing, + // hard impacts) were never asked for. + bool seen; }; struct ClassSpec { const char *group; // shown in the menu @@ -36,4 +41,17 @@ void want_class_value(std::size_t field, float value) noexcept; // Writes the wanted values into every copy found; call each tick. Returns the fields written. std::size_t apply_classes() noexcept; std::string classes_summary(); +// Board flip trick speed. The game's flip trick tuning is a class of eight curves and no numbers +// (speed against the flick, against the ollie...); the search finds it as eight curve references +// in a row and apply_classes multiplies the outputs of its four speed curves by this. +void want_flip_speed(float factor) noexcept; +// How many flip trick speed curves were found (0: the multiplier does nothing yet). +std::size_t flip_curves() noexcept; +// Where field `field` (an index into class_fields) lives: each copy's address and kind ('c' the +// class's own layout, 's' the eight-byte slots, 'n' native code's copy). Returns how many. +struct FieldCopy { + std::uintptr_t address{}; + char kind{}; +}; +std::size_t class_field_copies(std::size_t field, FieldCopy *out, std::size_t capacity) noexcept; } // namespace dingosdk::trainer diff --git a/Extension/Trainer/trainer_classes.inc b/Extension/Trainer/trainer_classes.inc index 761f16cd..5f489373 100644 --- a/Extension/Trainer/trainer_classes.inc +++ b/Extension/Trainer/trainer_classes.inc @@ -1,320 +1,320 @@ // Generated by trainer-dev/genclasses.py from the game's own data (game build 25414733): its // data-defined classes, their number fields in memory order and the defaults they ship with. -inline constexpr ClassField class_fields[]{ // name, default, offset in the class - {"MaxTimeToDropBlockStanceChange", 2.0f, 0x0}, - {"FastFakieSpeed", 1.5f, 0x4}, - {"FastFakieStanceChangeTime", 1.1f, 0x8}, - {"SlowFakieStanceChangeTime", 1.5f, 0xc}, - {"StraightFlipCatchTime", 0.2f, 0x0}, - {"ShuvMinCatchTime", 0.275f, 0x4}, - {"VarialMinCatchTime", 0.25f, 0x8}, - {"BigFlipMinCatchTime", 0.4f, 0xc}, - {"StraightFlipCatchExtensionTime", 0.1f, 0x10}, - {"ShuvFlipCatchExtensionTime", 0.2f, 0x14}, - {"VarialFlipCatchExtensionTime", 0.2f, 0x18}, - {"BigFlipFlipCatchExtensionTime", 0.225f, 0x1c}, - {"ImpossibleMinCatchTime", 0.35f, 0x20}, - {"ImpossibleCatchExtensionTime", 0.35f, 0x24}, - {"NoseInputAngleLimit_Deg", 60.0f, 0x0}, - {"TailInputAngleLimit_Deg", 60.0f, 0x4}, - {"IdleDeadzone", 0.1f, 0x8}, - {"ReenterThreshold", 0.5f, 0xc}, - {"MinLeftRightEngageMagnitude", 0.5f, 0x10}, - {"MaxWrongHemisphere_NoseTail", 2.0f, 0x14}, - {"MaxWrongHemisphere_LeftRight", 2.0f, 0x18}, - {"LegacyPositiveAccelerationScalar", 0.25f, 0x0}, - {"LegacyNegativeAccelerationScalar", 0.25f, 0x4}, - {"DefaultPinTimer", 1.0f, 0x8}, - {"MaxMountPinSpeed", 0.1f, 0xc}, - {"MaxPinGroundAngle", 10.0f, 0x10}, - {"MaxPositiveAcceleration", 18.0f, 0x14}, - {"MaxNegativeAcceleration", 18.0f, 0x18}, - {"WipeoutAcc_Standard", 8.5f, 0x0}, - {"WipeoutAcc_Friendly", 10.5f, 0x4}, - {"LightPushSpeed", 4.25f, 0x8}, - {"MediumPushSpeed", 6.75f, 0xc}, - {"StrongPushSpeed", 8.5f, 0x10}, - {"MinTrickSetupThreshold", 0.05f, 0x14}, - {"HeldTrickSetupThreshold", 0.9f, 0x18}, - {"MaxParallelToGrindAngle", 67.5f, 0x0}, - {"GrindExitMinSpeed", 0.0f, 0x4}, - {"MaxGrindTimeBelowMinSpeed", 0.0f, 0x8}, - {"DropToBoardslideMaxPitch", 0.1f, 0xc}, - {"ZeroHeadingDeadzone", 0.2f, 0x10}, - {"LowMaxSpeed_Ground", 9.0f, 0x0}, - {"LowMaxSpeed_Grind", 6.43f, 0x4}, - {"NormalMaxSpeed_Ground", 11.2f, 0x8}, - {"NormalMaxSpeed_Grind", 8.0f, 0xc}, - {"HighMaxSpeed_Ground", 15.0f, 0x10}, - {"HighMaxSpeed_Grind", 11.43f, 0x14}, - {"LowHorizontalSpeed_OffboardLanding", 10.0f, 0x18}, - {"NormalHorizontalSpeed_OffboardLanding", 12.0f, 0x1c}, - {"HighHorizontalSpeed_OffboardLanding", 15.0f, 0x20}, - {"MinSmithPitch", 0.0125f, 0x10}, - {"MinSmithHeading", 0.2f, 0x14}, - {"Max5_0TargetPitch", 0.4f, 0x18}, - {"GrindLeanExitTime", 0.15f, 0x78}, - {"GrindExitMaxLeanAngleDeg", 10.0f, 0x7c}, - {"GrindLeanSpeed", 2.75f, 0x80}, - {"5_0_ExitBoardSideMinHeading", 15.0f, 0x84}, - {"MaxGrindDynamicsTime", 0.5f, 0x90}, - {"MinTransitionHeadingChangeDeg", 15.0f, 0x94}, - {"MinTransitionHeadingVelocityDegPerS", 50.0f, 0x98}, - {"FreshInputGrindLeanExitTime", 0.66f, 0xa8}, - {"FastFlipInputSpeed", 0.0025f, 0x8}, - {"SingleStickPopHeight", 0.1f, 0x20}, - {"DoubleStickPopHeight", 1.0f, 0x24}, - {"GestureInputBufferTime", 0.2f, 0x28}, - {"StickVelocityUpdateThreshold", 0.2f, 0x2c}, - {"LegacyPositiveAccelerationScalar", 0.25f, 0x0}, - {"LegacyNegativeAccelerationScalar", 0.25f, 0x4}, - {"DefaultPinTimer", 1.0f, 0x8}, - {"MaxMountPinSpeed", 0.1f, 0xc}, - {"MaxPinGroundAngle", 10.0f, 0x10}, - {"MaxPinGroundSpeed", 1.0f, 0x14}, - {"MaxPositiveAcceleration", 18.0f, 0x18}, - {"MaxNegativeAcceleration", 18.0f, 0x1c}, - {"MaxPushSpeedLight", 4.0f, 0x0}, - {"MaxPushSpeedMedium", 8.5f, 0x4}, - {"MaxPushSpeedStrong", 9.5f, 0x8}, - {"PushingNormalLookaheadTime_S", 0.6f, 0x18}, - {"InputMashMeterPressIncrease", 0.15f, 0x1c}, - {"InputMashMeterDecayRate_PerS", 0.5f, 0x20}, - {"LongInputHoldTime_S", 0.45f, 0x38}, - {"ShortInputHoldTime_S", 0.15f, 0x3c}, - {"MashInputMax", 0.75f, 0x40}, - {"MinMediumPushOverrideSpeed", 7.2f, 0x50}, - {"MaxBackwardRollSpeed", 1.5f, 0x54}, - {"MaxFlatgroundBackwardsRollSpeed", 1.0f, 0x58}, - {"MaxSlopeThreshold", 5.0f, 0x5c}, - {"ForwardToPushEngageHorsePushSpeed", 12.0f, 0x60}, - {"DynoNearDistanceMin", 0.8f, 0x0}, - {"DynoNearDistanceMax", 0.8f, 0x4}, - {"DynoDistanceMin", 1.5f, 0x8}, - {"DynoDistanceMax", 1.5f, 0xc}, - {"DynoAwayDistanceMin", 1.0f, 0x10}, - {"DynoAwayDistanceMax", 3.5f, 0x14}, - {"DynoQueryStartOffsetDistance", -0.4f, 0x18}, - {"DynoQueryEndOffsetDistance", 0.4f, 0x1c}, - {"DynoEdgeMinHeightOverGround", 1.0f, 0x20}, - {"DynoInnerDistanceMin", 0.5f, 0x24}, - {"DynoInnerDistanceMax", 0.5f, 0x28}, - {"ShimmyDistanceCheck", 0.5f, 0x2c}, - {"ClimbJumpAwayHorizontalVel", 4.0f, 0x30}, - {"ClimbJumpAwayVerticalVelocity", 3.0f, 0x34}, - {"ClimbJumpAwayUpBoost", 2.0f, 0x38}, - {"ClimbJumpAwayDirectionalBoost", 1.0f, 0x3c}, - {"ClimbJumpAwayDownBoost", -2.0f, 0x40}, - {"MantleMinHeightToHangFrom", 1.75f, 0x44}, - {"MantleMinHeightToDeadHangFrom", 2.25f, 0x48}, - {"MinStickDeflectionShimmy", 0.5f, 0x4c}, - {"MinStickDeflectionReachAway", 0.0f, 0x50}, - {"MinStickDeflectionReachMove", 0.5f, 0x54}, - {"MinStickDeflectionReachCorner", 0.5f, 0x58}, - {"MaxStickDeflectionReachAway", 0.5f, 0x5c}, - {"MantleMaxHeight", 2.4f, 0x60}, - {"FootHoldCheckVerticalOffset", 1.2f, 0x64}, - {"FootHoldCheckSideOffset", 0.3f, 0x68}, - {"FootHoldForwardOffset", 0.5f, 0x6c}, - {"MaxStickDeflectionAngleShimmy", 20.0f, 0x70}, - {"ShimmyDeadhangVelocityThreshold", 0.3f, 0x74}, - {"ClimbHeightRelTrajToMantle", 0.0f, 0x80}, - {"ClimbMaxVelocityToMantle", -6.0f, 0x84}, - {"DynoCollisionDistanceFromEdge", 0.6f, 0x88}, - {"DynoCollisionStartHeight", 0.8f, 0x8c}, - {"DynoCornerMinDistance", 0.6f, 0x90}, - {"DynoCollisionSideUpOffset", 0.0f, 0x94}, - {"AdvancedPumpInputResponsiveness", 10.0f, 0x0}, - {"AutoPumpCooldown", 1.5f, 0x10}, - {"PumpVelocityResetAngleDeg", 45.0f, 0x14}, - {"MaxDeviationFromCachedTightZone_Deg", 10.0f, 0x20}, - {"DownwardPumpDotProductThreshold", -0.7f, 0x24}, - {"RoughMinPumpableLength", 1.5f, 0x28}, - {"MaxDistancePastCachedTightZoneMidpoint", 1.0f, 0x2c}, - {"TightZonePastMidpointCloseEnoughThreshold", 1.0f, 0x30}, - {"TightZoneMaxSegmentLength", 1.25f, 0x40}, - {"MaxTransitionRadius", 15.0f, 0x44}, - {"DropInPumpAssistTime", 1.0f, 0x48}, - {"PumpAssistAmount", 1.0f, 0x4c}, - {"MinPumpSpeed", 1.0f, 0x50}, - {"PreviousMidpointDistanceThreshold", 0.1f, 0x54}, - {"MaxStartDistanceToDeck", 1.0f, 0x58}, - {"TransitionHistoryMaxTurnAngleDeg", 60.0f, 0x5c}, - {"MinBendDegrees", 2.5f, 0x7c}, - {"SemiAutoPumpCooldownTime_S", 0.01f, 0x88}, - {"AddAccelerationWhileUnderSpeedValue", 25.0f, 0x8c}, - {"ExperimentalAutoPumpCooldown", 0.05f, 0xb0}, - {"PitchDirectionChangeThreshold", 15.0f, 0xc0}, - {"ExperimentalTightZonePastMidPointCloseEnoughThreshold", 0.1f, 0xc4}, - {"FlumpTuckTriggerThreshold", 0.1f, 0x0}, - {"FlumpTuckMaxDisengageAngle", 15.0f, 0x4}, - {"FlumpTuckDecelerationVelocity", 0.1f, 0x8}, - {"FlumpTuckAccelerationMultiplier", 0.1f, 0xc}, - {"FlumpTuckDecelerationMultiplier", 0.1f, 0x10}, - {"FlumpTuckMaxRotationVelocity", 9.0f, 0x14}, - {"FlumpMaxMountBoardAngle", 40.0f, 0x18}, - {"SlideMinSteeringSpeed", 0.05f, 0x1c}, - {"SlideMaxSteeringSpeed", 4.0f, 0x20}, - {"SlideAlignToGroundRotationSpeed", 270.0f, 0x24}, - {"SlideDistanceToGroundWipeoutThreshold", 0.5f, 0x28}, - {"RollMinSteeringSpeed", 1.0f, 0x2c}, - {"RollMaxSteeringSpeed", 4.0f, 0x30}, - {"RollVelocityMultiplier", 3.0f, 0x34}, - {"RollMaxVelocity", 3.0f, 0x38}, - {"RollDistanceToGroundWipeoutThreshold", 0.4f, 0x3c}, - {"RollMinRotationVelocityWipeoutThreshold", 0.2f, 0x40}, - {"DisableDiveToWallMaxTimeThreshold", 0.75f, 0x44}, - {"MinInAirMountTimeToLand", 0.5f, 0x48}, - {"RollOptimalSpeed", 3.5f, 0x4c}, - {"RollAccelerationFactor", 0.2f, 0x50}, - {"RollDecelerationFactor", 0.5f, 0x54}, - {"MinRotationSpeed", 2.0f, 0x58}, - {"WallRunGravity", -7.0f, 0x0}, - {"EngageGroundVelUp", 4.0f, 0x4}, - {"EngageInAirVelUpBoost", 3.0f, 0x8}, - {"MinVerticalVelocityWallRunUpForSlide", -1.0f, 0xc}, - {"MinVerticalVelocityWallRunSideForSlide", -5.0f, 0x10}, - {"WallRunGravityActivationBlendDuration", 0.2f, 0x14}, - {"WallRunUpEngageXZVelocity", 1.0f, 0x18}, - {"WallRunSideEngageXZVelocity", 6.0f, 0x1c}, - {"WallRunUpVerticalEngageVelocity", 3.75f, 0x20}, - {"WallRunSideVerticalEngageVelocity", 1.5f, 0x24}, - {"MaxWallRunSlideXZVelocity", 1.5f, 0x28}, - {"WallRunSlideAirControlForce", 1.5f, 0x2c}, - {"MaxWallRunUpXZVelocity", 1.5f, 0x30}, - {"WallRunUpAirControlForce", 1.5f, 0x34}, - {"WallRunSideAirControlForce", 6.0f, 0x38}, - {"WallRunJumpUpAirControlForce", 1.5f, 0x3c}, - {"WallRunJumpUpVelocityBoost", 4.0f, 0x40}, - {"WallRunJumpUpMaxVerticalVelocity", 4.0f, 0x44}, - {"WallRunJumpUpHorizontalBoost", 0.5f, 0x48}, - {"WallRunJumpAwayXZVelocity", 2.5f, 0x4c}, - {"WallRunJumpAwayVerticalVelocity", 2.0f, 0x50}, - {"WallRunJumpSideMaxVerticalVelocity", 1.75f, 0x54}, - {"WallRunJumpSideStickDirectionInfluence", 0.75f, 0x58}, - {"WallRunSideMinEngageVelocity", 1.0f, 0x5c}, - {"MaxWallRunUpVerticalVelocity", 4.0f, 0x60}, - {"EngageInAirSideVelUpBoost", 2.0f, 0x64}, - {"MaxWallRunSideVerticalVelocity", 4.0f, 0x68}, - {"WallRunStickAngleLimitAutoEngage", 75.0f, 0x6c}, - {"WallRunStickAngleLimitManual", 85.0f, 0x70}, - {"WallRunStickAngleLimitOnJumpToWall", 105.0f, 0x74}, - {"MaxDistanceToEngageVerticalOnGround", 2.0f, 0x78}, - {"MaxDistanceToEngageHorizontalOnGround", 0.8f, 0x7c}, - {"MaxFallingVelocityToEngageWallRunInAir", 2.5f, 0x80}, - {"EngageGroundVelUpMultiple", 1.5f, 0x84}, - {"EngageInAirSideVelUpBoostMultiple", 1.0f, 0x88}, - {"MaxFallingVelocityToEngageWallRunInAirDirect", 5.0f, 0x8c}, - {"MinInAirFallingSpeedVelBoost", -100.0f, 0x90}, - {"MaxTimeToLandForDisengageHorizontal", 0.3f, 0x94}, - {"MaxTimeToLandForDisengageVertical", 0.3f, 0x98}, - {"MaxDistanceToGroundDisengage", 0.5f, 0x9c}, - {"MinDownwardAngleDeg", 2.5f, 0x0}, - {"MaxDownwardAngleDeg", 70.0f, 0x4}, - {"MaxDownwardAngleDeg_NoBanks", 35.0f, 0x8}, - {"MaxDeckForwardYForBanks", 0.5f, 0xc}, - {"LookaheadTime_S", 0.1f, 0x10}, - {"Powerslide_ForwardForceScalar_Slide", 20.0f, 0x0}, - {"Powerslide_ForwardForceScalar_Revert", 1.0f, 0x4}, - {"Powerslide_ForwardForceScalar_AutoRevert", 1.0f, 0x8}, - {"FrictionScalar_Slide", 1.0f, 0xc}, - {"FrictionScalar_Revert", 0.25f, 0x10}, - {"FrictionScalar_AutoRevert", 0.25f, 0x14}, - {"MaxPendulumVelocity", 4.0f, 0x0}, - {"PendulumLaunchVelocityMultiplier", 1.5f, 0x4}, - {"MaxPendulumSwingAngleToMantle", 15.0f, 0x8}, - {"MaxPendulumVelocityToMantle", 2.0f, 0xc}, - {"MinPendulumSwingAngleToJump", 40.0f, 0x10}, - {"MaxBarVerticalThickness", 0.15f, 0x14}, - {"MaxAngleCurbToGroundNormal_Deg", 15.0f, 0x8}, - {"MinSpeedIntoSlappy", 2.5f, 0xc}, - {"MaxTimeToCurbForSlappy", 0.2f, 0x10}, - {"MaxAngleOfApproachDeg", 40.0f, 0x14}, - {"SprintSpeedBoost", 8.25f, 0x0}, - {"SprintSpeed", 6.5f, 0x4}, - {"MinRunStickMagnitude", 0.65f, 0x24}, - {"MinJumpVelocity", 3.0f, 0x0}, - {"MaxJumpVelocity", 6.5f, 0x4}, - {"DrasticTurnJumpVelocityScale", 0.75f, 0x8}, - {"MinJumpHeight", 0.1f, 0xc}, - {"MaxJumpHeight", 1.2f, 0x10}, - {"BaseJumpHeight", 0.73f, 0x14}, - {"AirControlForce", 4.0f, 0x18}, - {"MinFallVelocityXZ", 3.0f, 0x1c}, - {"CoffinAcceleration", 0.0f, 0x0}, - {"GravityAcceleration", 9.81f, 0x4}, - {"MaxGravityAcceleration", 9.81f, 0x8}, - {"NoInputTime", 5.0f, 0x20}, - {"MoonwalkMaxTimeSpinningSameWay", 0.5f, 0x30}, - {"MaxMoonwalkAccelerationSpeed", 3.5f, 0x34}, - {"MaxMoonwalkDecelerationSpeed", 1.0f, 0x38}, - {"MaxMoonwalkAcceleration", 0.04f, 0x3c}, - {"NoInputBrakeSpeed", 2.0f, 0x48}, - {"Left Stick Forward Push Toggle Reset Speed", 5.0f, 0x58}, - {"CarveFrictionGrowResponsiveness", 0.85f, 0xa8}, - {"CarveFrictionDecayResponsiveness", 200.0f, 0xac}, - {"SpreadEagleAirResistance", 0.6f, 0x0}, - {"SpreadEagleAirControlMax", 12.0f, 0x4}, - {"TorpedoAirControlMax", 8.0f, 0x8}, - {"DefaultAirResistance", 0.2f, 0xc}, - {"DefaultAirControl", 8.0f, 0x10}, - {"WipeoutBallRotationalControl", 15.0f, 0x14}, - {"WipeoutBallPushOffForce", 300.0f, 0x18}, - {"SpreadEagleGravity", -8.0f, 0x1c}, - {"TorpedoAirResistance", 0.0f, 0x20}, - {"CannonballMaxAngularSpeed", 20.0f, 0x24}, - {"CannonballMaxLinearSpeed", 20.0f, 0x28}, - {"CannonballLinearDrag", 25.0f, 0x2c}, - {"CannonballAngularDrag", 0.5f, 0x30}, - {"CannonballRestitution", 0.0f, 0x34}, - {"CannonballFriction", 0.5f, 0x38}, - {"CannonballLinearAcceleration", 5.0f, 0x3c}, - {"CannonballAngularAcceleration", 0.0f, 0x40}, - {"CannonballMaxImpactForce", 15.0f, 0x44}, - {"CannonballImpactDuration", 0.3f, 0x48}, - {"CannonballMinAccelerationVel", 5.0f, 0x4c}, - {"CannonballMaxAccelerationVel", 25.0f, 0x50}, - {"CannonballInputDirectionDrag", 100.0f, 0x54}, - {"SpreadEagleAirResistanceXZ", 1.0f, 0x58}, - {"SpreadEagleAirResistanceStickDeflection", 0.4f, 0x5c}, - {"SpreadEagleAirResistanceXZStickDeflection", 0.4f, 0x60}, - {"StrongImpactMinVelocity", 15.0f, 0x64}, - {"StrongImpactMaxVelocity", 25.0f, 0x68}, - {"StrongImpactBounceDuration", 2.0f, 0x6c}, - {"StrongImpactVelocityMultiplier", 2.5f, 0x70}, - {"WipeoutAirControlImpulse", 200.0f, 0x74}, - {"StrongImpactRestitution", 0.5f, 0x78}, - {"WipeoutGroundControlImpulse", 100.0f, 0x7c}, - {"StrongImpactConsecutiveMinVelocity", 1.0f, 0x80}, - {"StrongImpactConsecutiveVelMulDecrement", 0.7f, 0x84}, - {"WipeoutBounceAirControlImpulse", 400.0f, 0x88}, - {"WipeoutBounceGroundControlImpulse", 300.0f, 0x8c}, - {"SpreadEagleAirControlMinFallVelocity", 0.0f, 0x90}, - {"SpreadEagleAirControlMaxFallVelocity", 4.0f, 0x94}, - {"SpreadEagleAirResistanceMinFallVelocity", 4.0f, 0x98}, - {"SpreadEagleAirResistanceMaxFallVelocity", 8.0f, 0x9c}, - {"MaxVaultEdgeToEdgeDistance", 1.0f, 0x0}, - {"MaxVaultEndEdgeHeightOverGround", 2.3f, 0x4}, - {"MinHeightOverGroundMantle", 0.4f, 0x8}, - {"OnGroundMantleQueryHeight", 1.3f, 0xc}, - {"OnGroundVaultQueryHeight", 1.25f, 0x10}, - {"MaxDistanceToContactMantleOnGround", 2.0f, 0x14}, - {"MaxDistanceToContactMantleInAir", 1.0f, 0x18}, - {"MinVaultEdgeAngleDifference", 135.0f, 0x1c}, - {"MaxStickVsEdgeAngleDifference", 75.0f, 0x20}, - {"MaxDistanceToContactAutoMantleOnGr", 1.5f, 0x24}, - {"MaxDistanceToContactVaultOnGround", 2.0f, 0x28}, - {"MaxSkinnyWidthVaultToMantle", 0.3f, 0x2c}, - {"MaxStickDeflectionVaultToMantle", 0.5f, 0x30}, - {"MaxDistanceToContactWallRunMantleOnGround", 3.0f, 0x34}, - {"MaxDegreesToLockFacingToCollision", 160.0f, 0x38}, - {"MaxAutoMantleLowHeightObstacle", 0.75f, 0x3c}, - {"MinHeightOverGroundInAirMantle", 1.0f, 0x40}, - {"OnGroundWallRunMantleQueryHeight", 2.0f, 0x44}, - {"VaultOverCollisionHeightOffset", 0.75f, 0x48}, - {"MaxDistanceToContactAutoStepUp", 1.0f, 0x4c}, - {"CameraAngleDifferenceToLockMountFacing", 110.0f, 0x50}, - {"MaxAngleToMantle", 30.0f, 0x54}, +inline constexpr ClassField class_fields[]{ // name, default, offset in the class, seen being read by the game (or measured) + {"MaxTimeToDropBlockStanceChange", 2.0f, 0x0, true}, + {"FastFakieSpeed", 1.5f, 0x4, true}, + {"FastFakieStanceChangeTime", 1.1f, 0x8, true}, + {"SlowFakieStanceChangeTime", 1.5f, 0xc, true}, + {"StraightFlipCatchTime", 0.2f, 0x0, true}, + {"ShuvMinCatchTime", 0.275f, 0x4, true}, + {"VarialMinCatchTime", 0.25f, 0x8, true}, + {"BigFlipMinCatchTime", 0.4f, 0xc, true}, + {"StraightFlipCatchExtensionTime", 0.1f, 0x10, true}, + {"ShuvFlipCatchExtensionTime", 0.2f, 0x14, true}, + {"VarialFlipCatchExtensionTime", 0.2f, 0x18, true}, + {"BigFlipFlipCatchExtensionTime", 0.225f, 0x1c, true}, + {"ImpossibleMinCatchTime", 0.35f, 0x20, true}, + {"ImpossibleCatchExtensionTime", 0.35f, 0x24, true}, + {"NoseInputAngleLimit_Deg", 60.0f, 0x0, true}, + {"TailInputAngleLimit_Deg", 60.0f, 0x4, true}, + {"IdleDeadzone", 0.1f, 0x8, true}, + {"ReenterThreshold", 0.5f, 0xc, true}, + {"MinLeftRightEngageMagnitude", 0.5f, 0x10, true}, + {"MaxWrongHemisphere_NoseTail", 2.0f, 0x14, true}, + {"MaxWrongHemisphere_LeftRight", 2.0f, 0x18, true}, + {"LegacyPositiveAccelerationScalar", 0.25f, 0x0, true}, + {"LegacyNegativeAccelerationScalar", 0.25f, 0x4, true}, + {"DefaultPinTimer", 1.0f, 0x8, true}, + {"MaxMountPinSpeed", 0.1f, 0xc, true}, + {"MaxPinGroundAngle", 10.0f, 0x10, true}, + {"MaxPositiveAcceleration", 18.0f, 0x14, true}, + {"MaxNegativeAcceleration", 18.0f, 0x18, true}, + {"WipeoutAcc_Standard", 8.5f, 0x0, true}, + {"WipeoutAcc_Friendly", 10.5f, 0x4, true}, + {"LightPushSpeed", 4.25f, 0x8, true}, + {"MediumPushSpeed", 6.75f, 0xc, true}, + {"StrongPushSpeed", 8.5f, 0x10, true}, + {"MinTrickSetupThreshold", 0.05f, 0x14, true}, + {"HeldTrickSetupThreshold", 0.9f, 0x18, true}, + {"MaxParallelToGrindAngle", 67.5f, 0x0, true}, + {"GrindExitMinSpeed", 0.0f, 0x4, true}, + {"MaxGrindTimeBelowMinSpeed", 0.0f, 0x8, true}, + {"DropToBoardslideMaxPitch", 0.1f, 0xc, true}, + {"ZeroHeadingDeadzone", 0.2f, 0x10, true}, + {"LowMaxSpeed_Ground", 9.0f, 0x0, true}, + {"LowMaxSpeed_Grind", 6.43f, 0x4, true}, + {"NormalMaxSpeed_Ground", 11.2f, 0x8, true}, + {"NormalMaxSpeed_Grind", 8.0f, 0xc, true}, + {"HighMaxSpeed_Ground", 15.0f, 0x10, true}, + {"HighMaxSpeed_Grind", 11.43f, 0x14, true}, + {"LowHorizontalSpeed_OffboardLanding", 10.0f, 0x18, true}, + {"NormalHorizontalSpeed_OffboardLanding", 12.0f, 0x1c, true}, + {"HighHorizontalSpeed_OffboardLanding", 15.0f, 0x20, true}, + {"MinSmithPitch", 0.0125f, 0x10, false}, + {"MinSmithHeading", 0.2f, 0x14, false}, + {"Max5_0TargetPitch", 0.4f, 0x18, false}, + {"GrindLeanExitTime", 0.15f, 0x78, false}, + {"GrindExitMaxLeanAngleDeg", 10.0f, 0x7c, false}, + {"GrindLeanSpeed", 2.75f, 0x80, false}, + {"5_0_ExitBoardSideMinHeading", 15.0f, 0x84, false}, + {"MaxGrindDynamicsTime", 0.5f, 0x90, false}, + {"MinTransitionHeadingChangeDeg", 15.0f, 0x94, false}, + {"MinTransitionHeadingVelocityDegPerS", 50.0f, 0x98, false}, + {"FreshInputGrindLeanExitTime", 0.66f, 0xa8, false}, + {"FastFlipInputSpeed", 0.0025f, 0x8, false}, + {"SingleStickPopHeight", 0.1f, 0x20, false}, + {"DoubleStickPopHeight", 1.0f, 0x24, false}, + {"GestureInputBufferTime", 0.2f, 0x28, false}, + {"StickVelocityUpdateThreshold", 0.2f, 0x2c, false}, + {"LegacyPositiveAccelerationScalar", 0.25f, 0x0, true}, + {"LegacyNegativeAccelerationScalar", 0.25f, 0x4, true}, + {"DefaultPinTimer", 1.0f, 0x8, true}, + {"MaxMountPinSpeed", 0.1f, 0xc, true}, + {"MaxPinGroundAngle", 10.0f, 0x10, true}, + {"MaxPinGroundSpeed", 1.0f, 0x14, true}, + {"MaxPositiveAcceleration", 18.0f, 0x18, true}, + {"MaxNegativeAcceleration", 18.0f, 0x1c, true}, + {"MaxPushSpeedLight", 4.0f, 0x0, true}, + {"MaxPushSpeedMedium", 8.5f, 0x4, true}, + {"MaxPushSpeedStrong", 9.5f, 0x8, true}, + {"PushingNormalLookaheadTime_S", 0.6f, 0x18, false}, + {"InputMashMeterPressIncrease", 0.15f, 0x1c, false}, + {"InputMashMeterDecayRate_PerS", 0.5f, 0x20, false}, + {"LongInputHoldTime_S", 0.45f, 0x38, false}, + {"ShortInputHoldTime_S", 0.15f, 0x3c, false}, + {"MashInputMax", 0.75f, 0x40, false}, + {"MinMediumPushOverrideSpeed", 7.2f, 0x50, true}, + {"MaxBackwardRollSpeed", 1.5f, 0x54, false}, + {"MaxFlatgroundBackwardsRollSpeed", 1.0f, 0x58, false}, + {"MaxSlopeThreshold", 5.0f, 0x5c, false}, + {"ForwardToPushEngageHorsePushSpeed", 12.0f, 0x60, false}, + {"DynoNearDistanceMin", 0.8f, 0x0, false}, + {"DynoNearDistanceMax", 0.8f, 0x4, false}, + {"DynoDistanceMin", 1.5f, 0x8, false}, + {"DynoDistanceMax", 1.5f, 0xc, false}, + {"DynoAwayDistanceMin", 1.0f, 0x10, false}, + {"DynoAwayDistanceMax", 3.5f, 0x14, false}, + {"DynoQueryStartOffsetDistance", -0.4f, 0x18, false}, + {"DynoQueryEndOffsetDistance", 0.4f, 0x1c, false}, + {"DynoEdgeMinHeightOverGround", 1.0f, 0x20, false}, + {"DynoInnerDistanceMin", 0.5f, 0x24, false}, + {"DynoInnerDistanceMax", 0.5f, 0x28, false}, + {"ShimmyDistanceCheck", 0.5f, 0x2c, false}, + {"ClimbJumpAwayHorizontalVel", 4.0f, 0x30, false}, + {"ClimbJumpAwayVerticalVelocity", 3.0f, 0x34, false}, + {"ClimbJumpAwayUpBoost", 2.0f, 0x38, false}, + {"ClimbJumpAwayDirectionalBoost", 1.0f, 0x3c, false}, + {"ClimbJumpAwayDownBoost", -2.0f, 0x40, false}, + {"MantleMinHeightToHangFrom", 1.75f, 0x44, false}, + {"MantleMinHeightToDeadHangFrom", 2.25f, 0x48, false}, + {"MinStickDeflectionShimmy", 0.5f, 0x4c, false}, + {"MinStickDeflectionReachAway", 0.0f, 0x50, false}, + {"MinStickDeflectionReachMove", 0.5f, 0x54, false}, + {"MinStickDeflectionReachCorner", 0.5f, 0x58, false}, + {"MaxStickDeflectionReachAway", 0.5f, 0x5c, false}, + {"MantleMaxHeight", 2.4f, 0x60, false}, + {"FootHoldCheckVerticalOffset", 1.2f, 0x64, false}, + {"FootHoldCheckSideOffset", 0.3f, 0x68, false}, + {"FootHoldForwardOffset", 0.5f, 0x6c, false}, + {"MaxStickDeflectionAngleShimmy", 20.0f, 0x70, false}, + {"ShimmyDeadhangVelocityThreshold", 0.3f, 0x74, false}, + {"ClimbHeightRelTrajToMantle", 0.0f, 0x80, false}, + {"ClimbMaxVelocityToMantle", -6.0f, 0x84, false}, + {"DynoCollisionDistanceFromEdge", 0.6f, 0x88, false}, + {"DynoCollisionStartHeight", 0.8f, 0x8c, false}, + {"DynoCornerMinDistance", 0.6f, 0x90, false}, + {"DynoCollisionSideUpOffset", 0.0f, 0x94, false}, + {"AdvancedPumpInputResponsiveness", 10.0f, 0x0, false}, + {"AutoPumpCooldown", 1.5f, 0x10, false}, + {"PumpVelocityResetAngleDeg", 45.0f, 0x14, false}, + {"MaxDeviationFromCachedTightZone_Deg", 10.0f, 0x20, false}, + {"DownwardPumpDotProductThreshold", -0.7f, 0x24, false}, + {"RoughMinPumpableLength", 1.5f, 0x28, false}, + {"MaxDistancePastCachedTightZoneMidpoint", 1.0f, 0x2c, false}, + {"TightZonePastMidpointCloseEnoughThreshold", 1.0f, 0x30, false}, + {"TightZoneMaxSegmentLength", 1.25f, 0x40, false}, + {"MaxTransitionRadius", 15.0f, 0x44, false}, + {"DropInPumpAssistTime", 1.0f, 0x48, false}, + {"PumpAssistAmount", 1.0f, 0x4c, false}, + {"MinPumpSpeed", 1.0f, 0x50, false}, + {"PreviousMidpointDistanceThreshold", 0.1f, 0x54, false}, + {"MaxStartDistanceToDeck", 1.0f, 0x58, false}, + {"TransitionHistoryMaxTurnAngleDeg", 60.0f, 0x5c, false}, + {"MinBendDegrees", 2.5f, 0x7c, false}, + {"SemiAutoPumpCooldownTime_S", 0.01f, 0x88, false}, + {"AddAccelerationWhileUnderSpeedValue", 25.0f, 0x8c, false}, + {"ExperimentalAutoPumpCooldown", 0.05f, 0xb0, false}, + {"PitchDirectionChangeThreshold", 15.0f, 0xc0, false}, + {"ExperimentalTightZonePastMidPointCloseEnoughThreshold", 0.1f, 0xc4, false}, + {"FlumpTuckTriggerThreshold", 0.1f, 0x0, true}, + {"FlumpTuckMaxDisengageAngle", 15.0f, 0x4, true}, + {"FlumpTuckDecelerationVelocity", 0.1f, 0x8, true}, + {"FlumpTuckAccelerationMultiplier", 0.1f, 0xc, true}, + {"FlumpTuckDecelerationMultiplier", 0.1f, 0x10, true}, + {"FlumpTuckMaxRotationVelocity", 9.0f, 0x14, true}, + {"FlumpMaxMountBoardAngle", 40.0f, 0x18, true}, + {"SlideMinSteeringSpeed", 0.05f, 0x1c, true}, + {"SlideMaxSteeringSpeed", 4.0f, 0x20, true}, + {"SlideAlignToGroundRotationSpeed", 270.0f, 0x24, true}, + {"SlideDistanceToGroundWipeoutThreshold", 0.5f, 0x28, true}, + {"RollMinSteeringSpeed", 1.0f, 0x2c, true}, + {"RollMaxSteeringSpeed", 4.0f, 0x30, true}, + {"RollVelocityMultiplier", 3.0f, 0x34, true}, + {"RollMaxVelocity", 3.0f, 0x38, true}, + {"RollDistanceToGroundWipeoutThreshold", 0.4f, 0x3c, true}, + {"RollMinRotationVelocityWipeoutThreshold", 0.2f, 0x40, true}, + {"DisableDiveToWallMaxTimeThreshold", 0.75f, 0x44, true}, + {"MinInAirMountTimeToLand", 0.5f, 0x48, true}, + {"RollOptimalSpeed", 3.5f, 0x4c, true}, + {"RollAccelerationFactor", 0.2f, 0x50, true}, + {"RollDecelerationFactor", 0.5f, 0x54, true}, + {"MinRotationSpeed", 2.0f, 0x58, true}, + {"WallRunGravity", -7.0f, 0x0, true}, + {"EngageGroundVelUp", 4.0f, 0x4, true}, + {"EngageInAirVelUpBoost", 3.0f, 0x8, true}, + {"MinVerticalVelocityWallRunUpForSlide", -1.0f, 0xc, true}, + {"MinVerticalVelocityWallRunSideForSlide", -5.0f, 0x10, true}, + {"WallRunGravityActivationBlendDuration", 0.2f, 0x14, true}, + {"WallRunUpEngageXZVelocity", 1.0f, 0x18, true}, + {"WallRunSideEngageXZVelocity", 6.0f, 0x1c, true}, + {"WallRunUpVerticalEngageVelocity", 3.75f, 0x20, true}, + {"WallRunSideVerticalEngageVelocity", 1.5f, 0x24, true}, + {"MaxWallRunSlideXZVelocity", 1.5f, 0x28, true}, + {"WallRunSlideAirControlForce", 1.5f, 0x2c, true}, + {"MaxWallRunUpXZVelocity", 1.5f, 0x30, true}, + {"WallRunUpAirControlForce", 1.5f, 0x34, true}, + {"WallRunSideAirControlForce", 6.0f, 0x38, true}, + {"WallRunJumpUpAirControlForce", 1.5f, 0x3c, true}, + {"WallRunJumpUpVelocityBoost", 4.0f, 0x40, true}, + {"WallRunJumpUpMaxVerticalVelocity", 4.0f, 0x44, true}, + {"WallRunJumpUpHorizontalBoost", 0.5f, 0x48, true}, + {"WallRunJumpAwayXZVelocity", 2.5f, 0x4c, true}, + {"WallRunJumpAwayVerticalVelocity", 2.0f, 0x50, true}, + {"WallRunJumpSideMaxVerticalVelocity", 1.75f, 0x54, true}, + {"WallRunJumpSideStickDirectionInfluence", 0.75f, 0x58, true}, + {"WallRunSideMinEngageVelocity", 1.0f, 0x5c, true}, + {"MaxWallRunUpVerticalVelocity", 4.0f, 0x60, true}, + {"EngageInAirSideVelUpBoost", 2.0f, 0x64, true}, + {"MaxWallRunSideVerticalVelocity", 4.0f, 0x68, true}, + {"WallRunStickAngleLimitAutoEngage", 75.0f, 0x6c, true}, + {"WallRunStickAngleLimitManual", 85.0f, 0x70, true}, + {"WallRunStickAngleLimitOnJumpToWall", 105.0f, 0x74, true}, + {"MaxDistanceToEngageVerticalOnGround", 2.0f, 0x78, true}, + {"MaxDistanceToEngageHorizontalOnGround", 0.8f, 0x7c, true}, + {"MaxFallingVelocityToEngageWallRunInAir", 2.5f, 0x80, true}, + {"EngageGroundVelUpMultiple", 1.5f, 0x84, true}, + {"EngageInAirSideVelUpBoostMultiple", 1.0f, 0x88, true}, + {"MaxFallingVelocityToEngageWallRunInAirDirect", 5.0f, 0x8c, true}, + {"MinInAirFallingSpeedVelBoost", -100.0f, 0x90, true}, + {"MaxTimeToLandForDisengageHorizontal", 0.3f, 0x94, true}, + {"MaxTimeToLandForDisengageVertical", 0.3f, 0x98, true}, + {"MaxDistanceToGroundDisengage", 0.5f, 0x9c, true}, + {"MinDownwardAngleDeg", 2.5f, 0x0, true}, + {"MaxDownwardAngleDeg", 70.0f, 0x4, true}, + {"MaxDownwardAngleDeg_NoBanks", 35.0f, 0x8, true}, + {"MaxDeckForwardYForBanks", 0.5f, 0xc, true}, + {"LookaheadTime_S", 0.1f, 0x10, true}, + {"Powerslide_ForwardForceScalar_Slide", 20.0f, 0x0, true}, + {"Powerslide_ForwardForceScalar_Revert", 1.0f, 0x4, true}, + {"Powerslide_ForwardForceScalar_AutoRevert", 1.0f, 0x8, true}, + {"FrictionScalar_Slide", 1.0f, 0xc, true}, + {"FrictionScalar_Revert", 0.25f, 0x10, true}, + {"FrictionScalar_AutoRevert", 0.25f, 0x14, true}, + {"MaxPendulumVelocity", 4.0f, 0x0, true}, + {"PendulumLaunchVelocityMultiplier", 1.5f, 0x4, true}, + {"MaxPendulumSwingAngleToMantle", 15.0f, 0x8, true}, + {"MaxPendulumVelocityToMantle", 2.0f, 0xc, true}, + {"MinPendulumSwingAngleToJump", 40.0f, 0x10, true}, + {"MaxBarVerticalThickness", 0.15f, 0x14, true}, + {"MaxAngleCurbToGroundNormal_Deg", 15.0f, 0x8, true}, + {"MinSpeedIntoSlappy", 2.5f, 0xc, true}, + {"MaxTimeToCurbForSlappy", 0.2f, 0x10, true}, + {"MaxAngleOfApproachDeg", 40.0f, 0x14, true}, + {"SprintSpeedBoost", 8.25f, 0x0, true}, + {"SprintSpeed", 6.5f, 0x4, true}, + {"MinRunStickMagnitude", 0.65f, 0x24, true}, + {"MinJumpVelocity", 3.0f, 0x0, true}, + {"MaxJumpVelocity", 6.5f, 0x4, true}, + {"DrasticTurnJumpVelocityScale", 0.75f, 0x8, true}, + {"MinJumpHeight", 0.1f, 0xc, true}, + {"MaxJumpHeight", 1.2f, 0x10, true}, + {"BaseJumpHeight", 0.73f, 0x14, true}, + {"AirControlForce", 4.0f, 0x18, true}, + {"MinFallVelocityXZ", 3.0f, 0x1c, true}, + {"CoffinAcceleration", 0.0f, 0x0, false}, + {"GravityAcceleration", 9.81f, 0x4, false}, + {"MaxGravityAcceleration", 9.81f, 0x8, false}, + {"NoInputTime", 5.0f, 0x20, false}, + {"MoonwalkMaxTimeSpinningSameWay", 0.5f, 0x30, false}, + {"MaxMoonwalkAccelerationSpeed", 3.5f, 0x34, false}, + {"MaxMoonwalkDecelerationSpeed", 1.0f, 0x38, false}, + {"MaxMoonwalkAcceleration", 0.04f, 0x3c, false}, + {"NoInputBrakeSpeed", 2.0f, 0x48, false}, + {"Left Stick Forward Push Toggle Reset Speed", 5.0f, 0x58, false}, + {"CarveFrictionGrowResponsiveness", 0.85f, 0xa8, false}, + {"CarveFrictionDecayResponsiveness", 200.0f, 0xac, false}, + {"SpreadEagleAirResistance", 0.6f, 0x0, true}, + {"SpreadEagleAirControlMax", 12.0f, 0x4, true}, + {"TorpedoAirControlMax", 8.0f, 0x8, true}, + {"DefaultAirResistance", 0.2f, 0xc, true}, + {"DefaultAirControl", 8.0f, 0x10, true}, + {"WipeoutBallRotationalControl", 15.0f, 0x14, true}, + {"WipeoutBallPushOffForce", 300.0f, 0x18, true}, + {"SpreadEagleGravity", -8.0f, 0x1c, true}, + {"TorpedoAirResistance", 0.0f, 0x20, true}, + {"CannonballMaxAngularSpeed", 20.0f, 0x24, true}, + {"CannonballMaxLinearSpeed", 20.0f, 0x28, true}, + {"CannonballLinearDrag", 25.0f, 0x2c, true}, + {"CannonballAngularDrag", 0.5f, 0x30, true}, + {"CannonballRestitution", 0.0f, 0x34, true}, + {"CannonballFriction", 0.5f, 0x38, true}, + {"CannonballLinearAcceleration", 5.0f, 0x3c, true}, + {"CannonballAngularAcceleration", 0.0f, 0x40, true}, + {"CannonballMaxImpactForce", 15.0f, 0x44, true}, + {"CannonballImpactDuration", 0.3f, 0x48, true}, + {"CannonballMinAccelerationVel", 5.0f, 0x4c, true}, + {"CannonballMaxAccelerationVel", 25.0f, 0x50, true}, + {"CannonballInputDirectionDrag", 100.0f, 0x54, true}, + {"SpreadEagleAirResistanceXZ", 1.0f, 0x58, true}, + {"SpreadEagleAirResistanceStickDeflection", 0.4f, 0x5c, true}, + {"SpreadEagleAirResistanceXZStickDeflection", 0.4f, 0x60, true}, + {"StrongImpactMinVelocity", 15.0f, 0x64, true}, + {"StrongImpactMaxVelocity", 25.0f, 0x68, false}, + {"StrongImpactBounceDuration", 2.0f, 0x6c, false}, + {"StrongImpactVelocityMultiplier", 2.5f, 0x70, false}, + {"WipeoutAirControlImpulse", 200.0f, 0x74, false}, + {"StrongImpactRestitution", 0.5f, 0x78, true}, + {"WipeoutGroundControlImpulse", 100.0f, 0x7c, false}, + {"StrongImpactConsecutiveMinVelocity", 1.0f, 0x80, false}, + {"StrongImpactConsecutiveVelMulDecrement", 0.7f, 0x84, false}, + {"WipeoutBounceAirControlImpulse", 400.0f, 0x88, false}, + {"WipeoutBounceGroundControlImpulse", 300.0f, 0x8c, false}, + {"SpreadEagleAirControlMinFallVelocity", 0.0f, 0x90, false}, + {"SpreadEagleAirControlMaxFallVelocity", 4.0f, 0x94, false}, + {"SpreadEagleAirResistanceMinFallVelocity", 4.0f, 0x98, false}, + {"SpreadEagleAirResistanceMaxFallVelocity", 8.0f, 0x9c, false}, + {"MaxVaultEdgeToEdgeDistance", 1.0f, 0x0, false}, + {"MaxVaultEndEdgeHeightOverGround", 2.3f, 0x4, false}, + {"MinHeightOverGroundMantle", 0.4f, 0x8, false}, + {"OnGroundMantleQueryHeight", 1.3f, 0xc, true}, + {"OnGroundVaultQueryHeight", 1.25f, 0x10, true}, + {"MaxDistanceToContactMantleOnGround", 2.0f, 0x14, false}, + {"MaxDistanceToContactMantleInAir", 1.0f, 0x18, false}, + {"MinVaultEdgeAngleDifference", 135.0f, 0x1c, false}, + {"MaxStickVsEdgeAngleDifference", 75.0f, 0x20, false}, + {"MaxDistanceToContactAutoMantleOnGr", 1.5f, 0x24, false}, + {"MaxDistanceToContactVaultOnGround", 2.0f, 0x28, false}, + {"MaxSkinnyWidthVaultToMantle", 0.3f, 0x2c, false}, + {"MaxStickDeflectionVaultToMantle", 0.5f, 0x30, false}, + {"MaxDistanceToContactWallRunMantleOnGround", 3.0f, 0x34, false}, + {"MaxDegreesToLockFacingToCollision", 160.0f, 0x38, true}, + {"MaxAutoMantleLowHeightObstacle", 0.75f, 0x3c, false}, + {"MinHeightOverGroundInAirMantle", 1.0f, 0x40, false}, + {"OnGroundWallRunMantleQueryHeight", 2.0f, 0x44, true}, + {"VaultOverCollisionHeightOffset", 0.75f, 0x48, false}, + {"MaxDistanceToContactAutoStepUp", 1.0f, 0x4c, false}, + {"CameraAngleDifferenceToLockMountFacing", 110.0f, 0x50, false}, + {"MaxAngleToMantle", 30.0f, 0x54, false}, }; inline constexpr ClassSpec class_specs[]{ // group shown, key, first field, field count, anchor field {"Stance", "stance", 0, 4, 2}, diff --git a/Extension/Trainer/trainer_commands.cpp b/Extension/Trainer/trainer_commands.cpp index 3e832e63..528d0fd0 100644 --- a/Extension/Trainer/trainer_commands.cpp +++ b/Extension/Trainer/trainer_commands.cpp @@ -22,7 +22,7 @@ void register_trainer_commands(Commands ®istry) { {"marker", "Save, go to or clear a marker", "save|go|clear [slot]", false}, {"tp", "Teleport the skater", " ", false}, {"spot", "Go to one of the map author's spots", "", false}, - {"option", "Trainer options", "hud|hud_jump|auto_return|pad|log 0|1, return_delay , hippy_height|nocomply_height|boneless_height|offboard_height ", true}, + {"option", "Trainer options", "hud|hud_jump|auto_return|pad|log 0|1, return_delay , hippy_height|nocomply_height|boneless_height|offboard_height|flip_speed ", true}, {"profile", "The preset this map applies on load", "set |clear", false}, {"jumps", "The last measured jump", nullptr, false}, {"where", "The skater's position, heading and speed", nullptr, false}, diff --git a/Extension/Trainer/trainer_page.cpp b/Extension/Trainer/trainer_page.cpp index 5536b0e8..964fce5e 100644 --- a/Extension/Trainer/trainer_page.cpp +++ b/Extension/Trainer/trainer_page.cpp @@ -30,8 +30,8 @@ struct Page { std::array preset_name{}; std::array teleport{}; double speed_edit{-1}, speed_until{}; - float hippy_edit{1}, nocomply_edit{1}, boneless_edit{1}, offboard_edit{1}; - bool hippy_editing{}, nocomply_editing{}, boneless_editing{}, offboard_editing{}; + float hippy_edit{1}, nocomply_edit{1}, boneless_edit{1}, offboard_edit{1}, flip_edit{1}; + bool hippy_editing{}, nocomply_editing{}, boneless_editing{}, offboard_editing{}, flip_editing{}; std::uint64_t open_serial{}; // the last `trainer open` acted on bool show_page{}; }; @@ -109,7 +109,7 @@ void value_row(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const tra if (tinted) ImGui::PopStyleColor(); if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s\nStock: %s%s", row.id.c_str(), number(row.stock).c_str(), - row.used ? "" : "\nNo game code was found reading this value, so changing it will probably do nothing."); + row.used ? "" : "\nThe game was not found (or seen) reading this value, so changing it may do nothing."); ImGui::SameLine(column); const float reset = ImGui::CalcTextSize("Reset").x + ImGui::GetStyle().FramePadding.x * 2; ImGui::BeginDisabled(!view.editable || !callbacks.queue_console_command); @@ -250,12 +250,12 @@ void tune_tab(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trai } void trick_heights(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) { - begin_card(menu, "trick-heights", "TRICK HEIGHTS", "1.0 is the game's own height"); - const auto slider = [&](const char *label, const char *option, float value, float &edit, bool &editing) { + begin_card(menu, "trick-heights", "TRICKS", "1.0 is the game's own"); + const auto slider = [&](const char *label, const char *option, float value, float &edit, bool &editing, float high = 50.0f) { field(menu, label); ImGui::PushID(option); float shown = editing ? edit : value; - if (ImGui::SliderFloat("##height", &shown, 0.1f, 50.0f, "x %.2f", ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_Logarithmic)) { + if (ImGui::SliderFloat("##height", &shown, 0.1f, high, "x %.2f", ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_Logarithmic)) { edit = shown; editing = true; } @@ -263,11 +263,13 @@ void trick_heights(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const if (!ImGui::IsItemActive() && editing && std::abs(value - edit) < 0.005f) editing = false; ImGui::PopID(); }; + slider("Flip trick speed", "flip_speed", view.flip_speed, p.flip_edit, p.flip_editing, 3.0f); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Slows board flips. Above 1 the game's own limit on how fast a board turns takes over."); slider("No comply height", "nocomply_height", view.nocomply_height, p.nocomply_edit, p.nocomply_editing); slider("Boneless height", "boneless_height", view.boneless_height, p.boneless_edit, p.boneless_editing); slider("Hippy jump height", "hippy_height", view.hippy_height, p.hippy_edit, p.hippy_editing); slider("Off-board jump height", "offboard_height", view.offboard_height, p.offboard_edit, p.offboard_editing); - note("Separate from ollie height because the game sets these in its trick scripts. Ctrl+click a slider to type a number."); + note("Not tuning values: the game sets these in its trick scripts and flip curves. Ctrl+click a slider to type a number."); end_card(); } void presets_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) { diff --git a/Extension/Trainer/trainer_presets.cpp b/Extension/Trainer/trainer_presets.cpp index 0a48fcbc..b3b7c282 100644 --- a/Extension/Trainer/trainer_presets.cpp +++ b/Extension/Trainer/trainer_presets.cpp @@ -66,7 +66,6 @@ std::string_view essential_name(std::string_view key, int *rank, std::uint8_t *m {"physicsmode.makesurfacessmooth", "Smooth surfaces", f}, {"onboard_powerslide.powerslide_forwardforcescalar_slide", "Powerslide: forward force", f}, {"onboard_powerslide.frictionscalar_slide", "Powerslide: friction", both}, - {"onboard_speedmodel.noinputbrakespeed", "Coasting: speed the skater slows to with no input (m/s)", r}, {"physicsmode.wipeoutcheckforbadlanding", "Bail on bad landings", both}, {"physicsmode.wipeout_groundxzacceleration", "Bail: sideways hit limit", both}, {"wipeoutfallspeed.normalmaxspeed_ground", "Bail: fall speed limit on the ground (m/s)", r}, @@ -85,8 +84,8 @@ std::string_view essential_name(std::string_view key, int *rank, std::uint8_t *m {"wipeout.spreadeagleaircontrolmax", "Glide: steering", f}, {"wipeout.torpedoaircontrolmax", "Torpedo: steering (fast)", f}, {"wipeout.torpedoaircontrolmin", "Torpedo: steering (slow)", f}, - {"wipeout.defaultaircontrol", "Dive: steering", f}, - {"wipeout.defaultairresistance", "Dive: air resistance", f}, + {"wipeout.defaultaircontrol", "Falling: steering", f}, + {"wipeout.defaultairresistance", "Falling: air resistance", f}, {"wallrun.wallrunjumpupvelocityboost", "Wallrun: jump up boost", f}, {"wallrun.wallrungravity", "Wallrun: gravity", f}, {"physicstrucks.truckzposfront", "Front truck position (next respawn)", r}, @@ -140,7 +139,7 @@ const std::vector &builtin_presets() { {{"flumping.flumptuckmaxrotationvelocity", true, 2.5}, {"flumping.rollmaxvelocity", true, 2.0}, {"flumping.rollvelocitymultiplier", true, 1.5}}}, {"Super Glide", "Spread-eagle falls slowly and steers hard.", {{"wipeout.spreadeaglegravity", true, 0.35}, {"wipeout.spreadeagleaircontrolmax", true, 2.0}, {"wipeout.spreadeagleaircontrolmin", true, 2.0}}}, - {"Torpedo Boost", "Torpedo and dive steer and carry much harder.", + {"Torpedo Boost", "Torpedo steers and carries much harder, and so does a plain fall.", {{"wipeout.torpedoaircontrolmax", true, 2.5}, {"wipeout.torpedoaircontrolmin", true, 2.5}, {"wipeout.defaultaircontrol", true, 2.0}}}, {"No Speed Wobble", "The board stays steady at any speed.", {{"physicsmode.speedwobblestartspeed", true, 20.0}}}, {"Auto Push", "Once rolling, the skater keeps gaining speed up to the auto push speed (8 m/s).", {{"physicsmode.autopushenabled", false, 1.0}}}, From e715b9bf752bf8bef75e8eaacbcb22d0128a6dd0 Mon Sep 17 00:00:00 2001 From: DeckardDetribine <116809774+DeckardDetribine@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:44:00 +0100 Subject: [PATCH 011/109] Mod merge: carry the assets a mod adds into the copies maps carry Maps ship their own copy of game bundles such as bam_levelroot/bam_coregameassets, and that copy loads instead of the game's on their levels. Changes an asset mod makes to the game's EBX already propagate into those copies; assets it adds did not. A mod that appends a new song to the master music playlist therefore left the playlist naming a song the map's copy never contained, so the song was missing on custom maps. collect_asset_overrides now also records what an asset mod adds to a game bundle, and combine inserts it into other mods' copies of the same bundle, after their own assets, unless the copy already has that name: - an added EBX is carried only when one of the mod's recorded changes imports it, or another carried addition does (the playlist names the song, the song imports its wave), so an addition never travels without the change that needs it; - a resource the mod adds under the same name as a carried EBX goes with it (a wave's sound-bank resource registers the wave; without it the game crashes instantiating the wave); - TOC chunks the mod adds that its carried EBX name are listed in each superbundle those assets go into, pointing at where the mod's archive landed (a map's superbundle resolves chunks from its own TOC). A copy in the TOC the adding mod ships its own bundle in (cosmetic mods share one) merges with that mod's bundle and already has the asset, so only copies in other superbundles, i.e. maps' level TOCs, receive additions. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- Engine/Vfs/mod_merge.cpp | 7 +- Engine/Vfs/mod_merge_combine.cpp | 61 ++++++++++++++- Engine/Vfs/mod_merge_internal.h | 35 +++++++-- Engine/Vfs/mod_merge_overrides.cpp | 116 +++++++++++++++++++++++++++-- 4 files changed, 203 insertions(+), 16 deletions(-) diff --git a/Engine/Vfs/mod_merge.cpp b/Engine/Vfs/mod_merge.cpp index 41805116..f39c89ae 100644 --- a/Engine/Vfs/mod_merge.cpp +++ b/Engine/Vfs/mod_merge.cpp @@ -291,7 +291,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) { diff --git a/Engine/Vfs/mod_merge_combine.cpp b/Engine/Vfs/mod_merge_combine.cpp index 5de90c45..db4d4129 100644 --- a/Engine/Vfs/mod_merge_combine.cpp +++ b/Engine/Vfs/mod_merge_combine.cpp @@ -147,6 +147,8 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, std::string example; }; std::map archiveFaults; + // Mods whose added assets went into a copy here: their added chunks come too. + std::set addedFrom; const auto absorb = [&](const fb::TocBundle& bundle, const ArchivePlacement* placement, bool isBase, const fs::path& root) { @@ -300,8 +302,8 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, 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 named = overrides.changed.find(lower(asset.name)); + if (named == overrides.changed.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()) @@ -321,8 +323,37 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, report.notes.push_back(modName + ": " + bundle.name + ": " + std::to_string(overridden.size()) + " asset(s) take another mod's change, e.g. " + example); } + // 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 additions; + if (propagate && casBacked) { + if (const auto found = overrides.added.find(key); found != overrides.added.end()) { + std::set> present; + for (const auto& asset : casManifest.ebx) present.insert(asset_key(asset)); + for (const auto& asset : casManifest.resources) present.insert(asset_key(asset)); + // 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); + for (const auto& addition : found->second) { + if (addition.mod == modName || addition.asset.kind == fb::AssetKind::chunk || + addition.toc == here || present.contains(asset_key(addition.asset))) continue; + try { + additions.push_back({addition.asset, store.write(region.files.front().location.installChunk, + manifestArchive, addition.encoded)}); + 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); @@ -333,6 +364,16 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, region.files[index] = file; 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 = 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() + static_cast(at), addition.file); + 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); @@ -637,6 +678,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()) + diff --git a/Engine/Vfs/mod_merge_internal.h b/Engine/Vfs/mod_merge_internal.h index 8f04fb1f..af6dc054 100644 --- a/Engine/Vfs/mod_merge_internal.h +++ b/Engine/Vfs/mod_merge_internal.h @@ -175,12 +175,37 @@ struct AssetOverride { std::uint64_t originalSize{}; std::vector encoded; // its payload, as stored in cas }; -// By lower-case asset name, then the game's sha1 the change replaces. -using AssetOverrides = std::map, std::less<>>; +// 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. +struct AssetAddition { + std::string mod; + fb::BundleAsset asset; + std::vector 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; +}; +struct AssetOverrides { + // By lower-case asset name, then the game's sha1 the change replaces. + std::map, std::less<>> changed; + // By lower-case bundle name, in priority order. + std::map, 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::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() && added.empty(); } +}; + +// The changes and additions asset mods (mods that add no levels) make to the +// game's own EBX, 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& mods, const std::map& modFiles, const CasStore& store, const fs::path& baseRoot, diff --git a/Engine/Vfs/mod_merge_overrides.cpp b/Engine/Vfs/mod_merge_overrides.cpp index 0bb612da..17ac1780 100644 --- a/Engine/Vfs/mod_merge_overrides.cpp +++ b/Engine/Vfs/mod_merge_overrides.cpp @@ -1,5 +1,9 @@ #include "mod_merge_internal.h" +#include "Engine/Resource/cas_codec.h" +#include "Engine/Resource/ebx_document.h" +#include +#include #include namespace dingosdk::mods::detail { @@ -17,6 +21,16 @@ AssetOverrides collect_asset_overrides(const std::vector& 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> candidates, companions; + std::vector newChunks; // TOC chunks the game's TOCs do not have + std::set imported; + const auto decoded = [&](std::span 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,6 +40,10 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, const auto game = fb::read_toc(read_file(baseToc)); std::map> gameBundles; for (const auto& bundle : game.bundles) gameBundles.emplace(lower(bundle.name), &bundle); + std::set 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; @@ -38,17 +56,39 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, 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 == 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, - AssetOverride{mod->name, asset.sha1, asset.originalSize, - store.read(file.location.patch ? mod->directory : baseRoot, - file.location, file.offset, file.size)}); + 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& versions = out.changed[name]; + if (versions.contains(game_copy->second)) continue; + const auto& change = versions.emplace(game_copy->second, + AssetOverride{mod->name, asset.sha1, asset.originalSize, payload()}).first->second; ++changed; + try { + for (const auto& reference : decoded(change.encoded).imports) + imported.insert(reference.fileGuid); + } catch (const std::exception&) {} + } + // 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> 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) { @@ -56,6 +96,68 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, ": 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. + const auto keep = [&](const std::string& bundle, AssetAddition addition) { + auto& list = out.added[bundle]; + const auto name = lower(addition.asset.name); + if (std::ranges::any_of(list, [&](const AssetAddition& other) { + return other.asset.kind == addition.asset.kind && lower(other.asset.name) == name; })) + 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). + struct Candidate { fb::Guid file; std::vector imports; bool taken{}; }; + std::vector parsed(candidates.size()); + for (std::size_t index = 0; index < candidates.size(); ++index) { + try { + const auto document = decoded(candidates[index].second.encoded); + parsed[index].file = document.fileGuid; + for (const auto& reference : document.imports) parsed[index].imports.push_back(reference.fileGuid); + } catch (const std::exception&) { parsed[index].taken = true; } // unreadable: never follows + } + for (bool grew = true; grew;) { + grew = false; + for (std::size_t index = 0; index < candidates.size(); ++index) { + auto& candidate = parsed[index]; + if (candidate.taken || !imported.contains(candidate.file)) continue; + candidate.taken = grew = true; + imported.insert(candidate.imports.begin(), candidate.imports.end()); + auto& [bundle, addition] = candidates[index]; + keep(bundle, std::move(addition)); + } + } + for (auto& [bundle, companion] : companions) { + const auto& list = out.added[bundle]; + const auto name = lower(companion.asset.name); + if (std::ranges::any_of(list, [&](const AssetAddition& addition) { + return addition.mod == mod->name && addition.asset.kind == fb::AssetKind::ebx && + lower(addition.asset.name) == name; })) + keep(bundle, std::move(companion)); + } + // 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> 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 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& 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"); From dddb91f3812fff3daa8bc6692514dc27cce4814b Mon Sep 17 00:00:00 2001 From: DeckardDetribine <116809774+DeckardDetribine@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:44:09 +0100 Subject: [PATCH 012/109] Test the merge on assets a mod adds next to a map's own copy of the bundle A fixture of a made-up game, a level mod with its own level superbundle holding a copy of a bundle, and an asset mod that changes one asset to name a new song and adds the song, its wave, the wave's resource and one asset nothing names. The merged copy in the map's TOC must gain exactly the three the changed asset leads to, whichever mod has priority. Against upstream's merge it fails with the merge's own warning that the mod's additions to the bundle are missing. The documents are built in the test, so it needs no game files. A second scenario puts two mods' copies in the adder's own TOC and checks nothing is carried into it and the merged bundle is just the union. Co-Authored-By: Claude Sonnet 5.5 --- .../Vfs/Test/mod_merge_added_assets_tests.cpp | 345 ++++++++++++++++++ cmake/Apps.cmake | 4 + 2 files changed, 349 insertions(+) create mode 100644 Engine/Vfs/Test/mod_merge_added_assets_tests.cpp diff --git a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp new file mode 100644 index 00000000..414c0f15 --- /dev/null +++ b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp @@ -0,0 +1,345 @@ +// 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 +// 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/toc.h" +#include "Engine/Vfs/mod_catalog.h" +#include "Engine/Vfs/native_db.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +namespace fb = dingosdk::frostbite; +namespace db = dingosdk::native_db; +using namespace dingosdk; + +namespace { +using Bytes = std::vector; +constexpr std::uint32_t package = 0x1234; +constexpr char bundle_name[] = "win32/test/shared"; + +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(value >> shift)); +} +void put_bytes(Bytes& out, std::string_view text) { + for (const char c : text) out.push_back(static_cast(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(seed)); + return value; +} +void write(const fs::path& path, std::span bytes) { + fs::create_directories(path.parent_path()); + std::ofstream(path, std::ios::binary).write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); +} +Bytes read(const fs::path& path) { + std::ifstream in(path, std::ios::binary | std::ios::ate); + Bytes bytes(static_cast(in.tellg())); + in.seekg(0); + in.read(reinterpret_cast(bytes.data()), static_cast(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(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& 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(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(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& extra = {}) { + const auto leaf = [](unsigned type, const char* name, std::vector 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 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& names) { + std::vector rows; + for (const auto& name : names) rows.push_back(db::make_named_record("name", name)); + return rows; + }; + const auto low = static_cast(package & 0xFF), high = static_cast(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(name[i]); + value.bytes[19] = static_cast(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& assets, const std::vector& 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, stored in the game's own archive. + const std::vector assets{{"test/playlist", 0, ebx_document(guid(1), {})}, {"test/other", 0, ebx_document(guid(2), {})}}; + Bytes archive; + std::vector files; + for (const auto& asset : assets) { + const auto payload = encoded(asset.payload); + files.push_back({{false, package, 1}, static_cast(archive.size()), static_cast(payload.size())}); + archive.insert(archive.end(), payload.begin(), payload.end()); + } + const std::vector bundles{{bundle_name, fb::write_bundle_region(files, fb::write_binary_bundle(manifest_of(assets, {}))), 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 the bundle: the manifest first (raw), then every payload. + void add(const std::string& name, bool levels, const std::string& toc, const std::vector& superbundles, + const std::vector& assets, const std::vector& resources = {}) { + const auto listing = fb::write_binary_bundle(manifest_of(assets, resources)); + Bytes archive(listing); + std::vector files{{{true, package, 1}, 0, static_cast(listing.size())}}; + const auto store = [&](const Bytes& payload) { + const auto bytes = encoded(payload); + files.push_back({{true, package, 1}, static_cast(archive.size()), static_cast(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 bundles{{bundle_name, 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 the bundle in `toc` lists, or -1 when the merge left none. + int merged_files(const std::string& relative) 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 == bundle_name) return static_cast(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 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 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 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(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)"); +} +} // 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(); + 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; +} diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index be7f09f2..0ce1c0c4 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -48,6 +48,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_link_libraries(dingosdk_mod_merge_unshift_tests PRIVATE dingosdk_mods) target_include_directories(dingosdk_mod_merge_unshift_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME mod_merge_unshift COMMAND dingosdk_mod_merge_unshift_tests) + add_executable(dingosdk_mod_merge_added_assets_tests Engine/Vfs/Test/mod_merge_added_assets_tests.cpp) + target_link_libraries(dingosdk_mod_merge_added_assets_tests PRIVATE dingosdk_mods) + target_include_directories(dingosdk_mod_merge_added_assets_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME mod_merge_added_assets COMMAND dingosdk_mod_merge_added_assets_tests) endif() if(WIN32) From 2e8bb57d9ed7aa99cd5c28e44142cd4aaf81c91c Mon Sep 17 00:00:00 2001 From: DeckardDetribine <116809774+DeckardDetribine@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:12:42 +0100 Subject: [PATCH 013/109] Music: show the stations the content cache defines, with their names and art The music menu listed only the playlists the game's own music assets declare. The licensed stations (San Van Soundtrack, 80's Mixtape, ...) live in the content cache's music chunk: a record with the station id (field 1), its tracks (repeated field 2, "Artist - Title") and a message with its display name and cdn:/ artwork (field 10, parts 10 and 11). Read them into Catalogs, merge their membership into the music catalogue (only songs the game registered, so no unknown id is referenced), and send the name and artwork with each playlist. The catalogue test pins two stations from the cache. Co-Authored-By: Claude Sonnet 5.5 --- Engine/Vfs/Test/content_catalogs_tests.cpp | 9 ++++ Engine/Vfs/content_catalogs.cpp | 25 ++++++++++ Engine/Vfs/content_catalogs.h | 9 ++++ Extension/Music/local_music_assets.cpp | 53 +++++++++++++++++++++- Extension/Music/local_music_assets.h | 2 + Extension/Music/local_music_ui.cpp | 11 +++-- Extension/Music/local_music_ui.h | 2 +- 7 files changed, 106 insertions(+), 5 deletions(-) diff --git a/Engine/Vfs/Test/content_catalogs_tests.cpp b/Engine/Vfs/Test/content_catalogs_tests.cpp index 2b1f62ff..04596a7f 100644 --- a/Engine/Vfs/Test/content_catalogs_tests.cpp +++ b/Engine/Vfs/Test/content_catalogs_tests.cpp @@ -62,6 +62,15 @@ int main(int argc, char** argv) { check(stadium != travel.access_points.end() && stadium->second == std::vector{"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 (argc > 2) { Json dump{{"items", catalogs.items}, {"challenges", catalogs.challenges}, {"entitlements", Json::array()}}; for (const auto& id : catalogs.entitlements) dump["entitlements"].push_back(id); diff --git a/Engine/Vfs/content_catalogs.cpp b/Engine/Vfs/content_catalogs.cpp index d6bc99e3..20b4f50d 100644 --- a/Engine/Vfs/content_catalogs.cpp +++ b/Engine/Vfs/content_catalogs.cpp @@ -54,6 +54,30 @@ 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 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)); +} + // Grant lists: repeated `number` entries, each {1: kind, 2: id}. struct Grant { std::string kind, id; }; std::vector grants(const Fields& fields, std::uint32_t number) { @@ -221,6 +245,7 @@ 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_challenge(out, *id, *fields); add_object_category(out, *id, *fields); add_travel(out, *id, *fields); diff --git a/Engine/Vfs/content_catalogs.h b/Engine/Vfs/content_catalogs.h index d0bcef71..c4631c70 100644 --- a/Engine/Vfs/content_catalogs.h +++ b/Engine/Vfs/content_catalogs.h @@ -2,6 +2,7 @@ #include "Engine/Core/Json/json.h" #include #include +#include #include #include #include @@ -40,6 +41,14 @@ struct Catalogs { std::vector travel_locations; // Access point id (accesspoint_*) -> the location ids it offers. std::vector>> 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 tracks; + }; + std::map> music_playlists; bool reserved(const std::string& key) const; }; // Read once, on first use. Empty (available == false) when no pack is installed. diff --git a/Extension/Music/local_music_assets.cpp b/Extension/Music/local_music_assets.cpp index 876a103f..2720faea 100644 --- a/Extension/Music/local_music_assets.cpp +++ b/Extension/Music/local_music_assets.cpp @@ -1,8 +1,12 @@ #include "Extension/Customization/local_customization_runtime.h" #include "local_music_assets.h" +#include "Engine/Core/Log/logging.h" #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/local_music.h" +#include "Engine/Vfs/content_catalogs.h" #include "Extension/Profile/runtime_internal.h" +#include +#include namespace dingosdk::profile_runtime { // Snapshot the game's registered music assets, as the cosmetic catalog does @@ -102,6 +106,46 @@ bool read_music_catalog(MusicCatalog& result) { if (visited.size() != count || !read(base + addr::local_music::asset_manager, current) || current != manager || !read(manager + 0x78, current_buckets) || current_buckets != buckets || !read(manager + 0x84, current_count) || current_count != count) return false; + // The registered assets only declare the playlists the game ships offline + // (the Universal production library); the licensed stations live in the + // content-cache music chunk. Merge that membership, intersected with the + // songs actually registered, so no unregistered id can ever be referenced. + const auto native_playlists = playlists.size(); + const auto& cache = content_cache::catalogs(); + std::map> playlist_names, playlist_artwork; + std::size_t cache_playlists = 0, matched = 0, unmatched = 0, added = 0; + if (!cache.music_playlists.empty()) { + std::map> index; + for (std::size_t i = 0; i < snapshot.songs.size(); ++i) index.emplace(snapshot.songs[i].id, i); + for (const auto& [id, entry] : cache.music_playlists) { + ++cache_playlists; + auto& members = playlists[id]; + for (const auto& track : entry.tracks) { + const auto found = index.find(track); + if (found == index.end()) { ++unmatched; continue; } + ++matched; + auto& song = snapshot.songs[found->second]; + if (std::find(song.playlists.begin(), song.playlists.end(), id) != song.playlists.end()) + continue; + song.playlists.push_back(id); + members.push_back(song.id); + ++added; + } + if (!members.empty() && !entry.name.empty()) playlist_names[id] = entry.name; + if (!members.empty() && !entry.artwork.empty()) playlist_artwork[id] = entry.artwork; + } + for (auto it = playlists.begin(); it != playlists.end();) + it = it->second.empty() ? playlists.erase(it) : std::next(it); + } + { + std::ostringstream event; + event << "{\"event\":\"music_catalog_membership\",\"songs\":" << snapshot.songs.size() + << ",\"native_playlists\":" << native_playlists + << ",\"cache_playlists\":" << cache_playlists + << ",\"matched\":" << matched << ",\"unmatched\":" << unmatched + << ",\"added\":" << added << ",\"playlists\":" << playlists.size() << "}"; + dingosdk::logging::event(dingosdk::logging::Channel::music, event.str()); + } // Registry order is bucket order, not authored ordering. Use stable IDs for // deterministic UI order; do not claim this is the original service order. std::sort(snapshot.songs.begin(), snapshot.songs.end(), [](const auto& a, const auto& b) { @@ -109,7 +153,14 @@ bool read_music_catalog(MusicCatalog& result) { }); for (auto& [id, songs] : playlists) { std::sort(songs.begin(), songs.end()); - snapshot.playlists.push_back({id, std::move(songs)}); + MusicPlaylist row; + row.id = id; + row.songs = std::move(songs); + if (const auto found = playlist_names.find(id); found != playlist_names.end()) + row.name = found->second; + if (const auto found = playlist_artwork.find(id); found != playlist_artwork.end()) + row.artwork = found->second; + snapshot.playlists.push_back(std::move(row)); } result = std::move(snapshot); return true; diff --git a/Extension/Music/local_music_assets.h b/Extension/Music/local_music_assets.h index 16439f76..6ce81df8 100644 --- a/Extension/Music/local_music_assets.h +++ b/Extension/Music/local_music_assets.h @@ -9,6 +9,8 @@ struct MusicSong { struct MusicPlaylist { std::string id; + std::string name; // display name from the content cache; empty for native groups + std::string artwork; // cdn:/ artwork id from the content cache; empty when unknown std::vector songs; }; diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index 7d673b5d..b21ed24f 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -8,6 +8,7 @@ #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/engine.h" #include "Engine/Game/Build/20260929/local_music.h" +#include "Engine/Game/World/location_travel.h" #include "Extension/Profile/runtime_internal.h" namespace dingosdk::profile_runtime { @@ -106,14 +107,18 @@ bool music_ui_catalog_valid(const MusicCatalog& catalog, const std::string& favo } std::string music_ui_wire(std::string_view id, std::string_view artist, std::string_view title, - const std::vector* members) { + const std::vector* members, std::string_view name, std::string_view artwork) { std::string body, presentation, framed; cosmetic_wire_string(body, 1, id); if (members) { for (const auto& song : *members) cosmetic_wire_string(body, 2, song); // A local shelf choice for real authored groups, NOT recovered AMP classification. cosmetic_wire_number(body, 3, 1); - cosmetic_wire_string(presentation, 10, id); + // The catalogue's display name when known; the raw id otherwise. + cosmetic_wire_string(presentation, 10, name.empty() ? id : name); + // Artwork: the catalogue's cdn:/ id, resolved to the CDN rendition. + if (const auto url = travel_artwork_url(artwork); !url.empty()) + cosmetic_wire_string(presentation, 11, url); cosmetic_wire_number(presentation, 12, 0); } else { cosmetic_wire_string(presentation, 10, artist); @@ -236,7 +241,7 @@ void update_music_catalog() { ui.functions.allocator[1] = arena; MusicUiMessages playlists, songs; playlists.items.reserve(catalog.playlists.size()); songs.items.reserve(catalog.songs.size()); - for (const auto& playlist : catalog.playlists) playlists.items.push_back(music_ui_message(music_ui_wire(playlist.id, {}, {}, &playlist.songs), true)); + for (const auto& playlist : catalog.playlists) playlists.items.push_back(music_ui_message(music_ui_wire(playlist.id, {}, {}, &playlist.songs, playlist.name, playlist.artwork), true)); for (const auto& song : catalog.songs) songs.items.push_back(music_ui_message(music_ui_wire(song.id, song.artist, song.title, nullptr), false)); auto& model = game::native_data().models; auto& field = game::native_data().models.field; if (!music_ui_current(*pending)) { retry = false; return; } diff --git a/Extension/Music/local_music_ui.h b/Extension/Music/local_music_ui.h index 787cfdc6..f5fa19ed 100644 --- a/Extension/Music/local_music_ui.h +++ b/Extension/Music/local_music_ui.h @@ -61,7 +61,7 @@ bool music_ui_text(std::string_view value, std::size_t limit = 255); bool music_ui_catalog_valid(const MusicCatalog& catalog, const std::string& favorites); std::string music_ui_wire(std::string_view id, std::string_view artist, std::string_view title, - const std::vector* members); + const std::vector* members, std::string_view name = {}, std::string_view artwork = {}); CosmeticShared music_ui_message(const std::string& wire, bool playlist); From 855e84993ad8c16574c49b329d87430070b15a76 Mon Sep 17 00:00:00 2001 From: DeckardDetribine <116809774+DeckardDetribine@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:12:42 +0100 Subject: [PATCH 014/109] Music: give songs their cover art from the content cache Song records in the content cache carry a field-10 message (10 artist, 11 title, 12 cdn:/ cover art). Read the art per song and send it as the song's presentation field 12, resolved to the CDN rendition like a station's art, so the game's music screen and now-playing toast show covers instead of a black square. The catalogue test pins one song's art. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- Engine/Vfs/Test/content_catalogs_tests.cpp | 5 +++++ Engine/Vfs/content_catalogs.cpp | 11 +++++++++++ Engine/Vfs/content_catalogs.h | 3 +++ Extension/Music/local_music_assets.cpp | 8 +++++++- Extension/Music/local_music_assets.h | 1 + Extension/Music/local_music_ui.cpp | 7 +++++-- 6 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Engine/Vfs/Test/content_catalogs_tests.cpp b/Engine/Vfs/Test/content_catalogs_tests.cpp index 04596a7f..798c0667 100644 --- a/Engine/Vfs/Test/content_catalogs_tests.cpp +++ b/Engine/Vfs/Test/content_catalogs_tests.cpp @@ -71,6 +71,11 @@ int main(int argc, char** argv) { 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); diff --git a/Engine/Vfs/content_catalogs.cpp b/Engine/Vfs/content_catalogs.cpp index 20b4f50d..0000e9bd 100644 --- a/Engine/Vfs/content_catalogs.cpp +++ b/Engine/Vfs/content_catalogs.cpp @@ -78,6 +78,16 @@ void add_music_playlist(Catalogs& out, const std::string& id, const Fields& reco 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 grants(const Fields& fields, std::uint32_t number) { @@ -246,6 +256,7 @@ Catalogs read_catalogs(const std::filesystem::path& folder) { 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); diff --git a/Engine/Vfs/content_catalogs.h b/Engine/Vfs/content_catalogs.h index c4631c70..71f8b850 100644 --- a/Engine/Vfs/content_catalogs.h +++ b/Engine/Vfs/content_catalogs.h @@ -49,6 +49,9 @@ struct Catalogs { std::vector tracks; }; std::map> 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> music_song_artwork; bool reserved(const std::string& key) const; }; // Read once, on first use. Empty (available == false) when no pack is installed. diff --git a/Extension/Music/local_music_assets.cpp b/Extension/Music/local_music_assets.cpp index 2720faea..1f767d84 100644 --- a/Extension/Music/local_music_assets.cpp +++ b/Extension/Music/local_music_assets.cpp @@ -114,6 +114,12 @@ bool read_music_catalog(MusicCatalog& result) { const auto& cache = content_cache::catalogs(); std::map> playlist_names, playlist_artwork; std::size_t cache_playlists = 0, matched = 0, unmatched = 0, added = 0; + std::size_t artwork = 0; + for (auto& song : snapshot.songs) + if (const auto found = cache.music_song_artwork.find(song.id); found != cache.music_song_artwork.end()) { + song.artwork = found->second; + ++artwork; + } if (!cache.music_playlists.empty()) { std::map> index; for (std::size_t i = 0; i < snapshot.songs.size(); ++i) index.emplace(snapshot.songs[i].id, i); @@ -143,7 +149,7 @@ bool read_music_catalog(MusicCatalog& result) { << ",\"native_playlists\":" << native_playlists << ",\"cache_playlists\":" << cache_playlists << ",\"matched\":" << matched << ",\"unmatched\":" << unmatched - << ",\"added\":" << added << ",\"playlists\":" << playlists.size() << "}"; + << ",\"added\":" << added << ",\"artwork\":" << artwork << ",\"playlists\":" << playlists.size() << "}"; dingosdk::logging::event(dingosdk::logging::Channel::music, event.str()); } // Registry order is bucket order, not authored ordering. Use stable IDs for diff --git a/Extension/Music/local_music_assets.h b/Extension/Music/local_music_assets.h index 6ce81df8..ab19ab88 100644 --- a/Extension/Music/local_music_assets.h +++ b/Extension/Music/local_music_assets.h @@ -4,6 +4,7 @@ namespace dingosdk::profile_runtime { struct MusicSong { std::string id, artist, title; + std::string artwork; // cdn:/ cover art from the content cache; empty when unknown std::vector playlists; }; diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index b21ed24f..b249242d 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -123,8 +123,11 @@ std::string music_ui_wire(std::string_view id, std::string_view artist, std::str } else { cosmetic_wire_string(presentation, 10, artist); cosmetic_wire_string(presentation, 11, title); + // Cover art: the content cache song record's cdn:/ id (its field 10.12), resolved like a playlist's. + if (const auto url = travel_artwork_url(artwork); !url.empty()) + cosmetic_wire_string(presentation, 12, url); } - cosmetic_wire_string(body, 10, presentation); // Artwork intentionally absent. + cosmetic_wire_string(body, 10, presentation); cosmetic_varint(framed, body.size()); framed += body; return framed; } @@ -242,7 +245,7 @@ void update_music_catalog() { MusicUiMessages playlists, songs; playlists.items.reserve(catalog.playlists.size()); songs.items.reserve(catalog.songs.size()); for (const auto& playlist : catalog.playlists) playlists.items.push_back(music_ui_message(music_ui_wire(playlist.id, {}, {}, &playlist.songs, playlist.name, playlist.artwork), true)); - for (const auto& song : catalog.songs) songs.items.push_back(music_ui_message(music_ui_wire(song.id, song.artist, song.title, nullptr), false)); + for (const auto& song : catalog.songs) songs.items.push_back(music_ui_message(music_ui_wire(song.id, song.artist, song.title, nullptr, {}, song.artwork), false)); auto& model = game::native_data().models; auto& field = game::native_data().models.field; if (!music_ui_current(*pending)) { retry = false; return; } { From c7f54b76805e9c9ffa97f2d86432b96d044fc7b1 Mon Sep 17 00:00:00 2001 From: DeckardDetribine <116809774+DeckardDetribine@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:12:42 +0100 Subject: [PATCH 015/109] Music: let a mod name a playlist for its songs A mod that adds songs can ship reskate-music.json ({"schema":1,"playlists":[{"name","songs":["artist - title", ...]}]}). Each playlist is merged into the game's music screen the way a content-cache station is, under the id "mod::", with its songs matched against the songs the game has registered; entries that match nothing are ignored and a playlist left empty is dropped. A file of the wrong shape is logged and skipped. The file is read by parse_music_playlists in Engine/Vfs/mod_music, which a new test covers (ids, which entries survive, the limits, and that a malformed document is refused). The format is described in the README. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- Engine/Vfs/Test/mod_music_tests.cpp | 73 ++++++++++++++++++++++ Engine/Vfs/mod_music.cpp | 35 +++++++++++ Engine/Vfs/mod_music.h | 23 +++++++ Extension/Music/local_music_assets.cpp | 83 ++++++++++++++++++-------- Extension/Music/local_music_assets.h | 4 ++ README.md | 10 ++++ cmake/Apps.cmake | 4 ++ cmake/Core.cmake | 1 + 8 files changed, 208 insertions(+), 25 deletions(-) create mode 100644 Engine/Vfs/Test/mod_music_tests.cpp create mode 100644 Engine/Vfs/mod_music.cpp create mode 100644 Engine/Vfs/mod_music.h diff --git a/Engine/Vfs/Test/mod_music_tests.cpp b/Engine/Vfs/Test/mod_music_tests.cpp new file mode 100644 index 00000000..d5b99910 --- /dev/null +++ b/Engine/Vfs/Test/mod_music_tests.cpp @@ -0,0 +1,73 @@ +// 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 +#include +#include + +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::"); + check(lists.at(0).name == "Kevin MacLeod", "name is the shown name"); + check(lists.at(0).songs == std::vector{"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{"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"); + 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; +} diff --git a/Engine/Vfs/mod_music.cpp b/Engine/Vfs/mod_music.cpp new file mode 100644 index 00000000..18a6c4bf --- /dev/null +++ b/Engine/Vfs/mod_music.cpp @@ -0,0 +1,35 @@ +#include "Engine/Vfs/mod_music.h" +#include "Engine/Core/Json/json.h" +#include +#include + +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; }); +} +} + +std::vector parse_music_playlists(const std::string& mod, const std::string& json) { + std::vector result; + const auto root = Json::parse(json); + if (!root.contains("schema") || root.at("schema").get() != 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"); + 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(), {}}; + 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()); + if (text_ok(playlist.id)) result.push_back(std::move(playlist)); + } + return result; +} + +} // namespace dingosdk::mods diff --git a/Engine/Vfs/mod_music.h b/Engine/Vfs/mod_music.h new file mode 100644 index 00000000..3f93941e --- /dev/null +++ b/Engine/Vfs/mod_music.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +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::" + std::string name; // shown in the music screen + std::vector songs; +}; + +// Reads a mod's reskate-music.json: +// {"schema":1,"playlists":[{"name":"...","songs":["artist - title", ...]}]} +// 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 parse_music_playlists(const std::string& mod, const std::string& json); + +} // namespace dingosdk::mods diff --git a/Extension/Music/local_music_assets.cpp b/Extension/Music/local_music_assets.cpp index 1f767d84..84d2ab5c 100644 --- a/Extension/Music/local_music_assets.cpp +++ b/Extension/Music/local_music_assets.cpp @@ -4,7 +4,10 @@ #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/local_music.h" #include "Engine/Vfs/content_catalogs.h" +#include "Engine/Vfs/mod_catalog.h" +#include "Engine/Vfs/mod_music.h" #include "Extension/Profile/runtime_internal.h" +#include #include #include @@ -27,6 +30,27 @@ bool music_asset_type(std::uintptr_t asset, std::uintptr_t vtable_rva) { return asset && read(asset, vtable) && vtable == local_runtime().base + vtable_rva; } +// reskate-music.json in each enabled mod: {"schema":1,"playlists":[{"name":..,"songs":["artist - title",..]}]}. +// Read on every call: it only runs while the music page waits for its catalog. A bad +// file is logged and skipped; its songs still play from the master playlist. +std::vector mod_music_playlists() { + std::vector result; + for (const auto& mod : mods::catalog().mods) { + const auto path = mod.directory / L"reskate-music.json"; + std::error_code error; + if (!std::filesystem::is_regular_file(path, error)) continue; + try { + std::ifstream in(path, std::ios::binary); + auto playlists = mods::parse_music_playlists(mod.name, std::string((std::istreambuf_iterator(in)), {})); + std::move(playlists.begin(), playlists.end(), std::back_inserter(result)); + } catch (const std::exception& failure) { + dingosdk::logging::event(dingosdk::logging::Channel::music, + dingosdk::Json{{"event", "mod_music_playlists_skipped"}, {"mod", mod.name}, {"reason", failure.what()}}.dump().c_str()); + } + } + return result; +} + bool read_music_catalog(MusicCatalog& result) { const auto base = local_runtime().base; auto& native = music_asset_functions(); @@ -113,43 +137,52 @@ bool read_music_catalog(MusicCatalog& result) { const auto native_playlists = playlists.size(); const auto& cache = content_cache::catalogs(); std::map> playlist_names, playlist_artwork; - std::size_t cache_playlists = 0, matched = 0, unmatched = 0, added = 0; - std::size_t artwork = 0; + std::size_t cache_playlists = 0, mod_playlists = 0, matched = 0, unmatched = 0, added = 0; + std::size_t song_artwork = 0; for (auto& song : snapshot.songs) if (const auto found = cache.music_song_artwork.find(song.id); found != cache.music_song_artwork.end()) { song.artwork = found->second; - ++artwork; + ++song_artwork; } - if (!cache.music_playlists.empty()) { - std::map> index; - for (std::size_t i = 0; i < snapshot.songs.size(); ++i) index.emplace(snapshot.songs[i].id, i); - for (const auto& [id, entry] : cache.music_playlists) { - ++cache_playlists; - auto& members = playlists[id]; - for (const auto& track : entry.tracks) { - const auto found = index.find(track); - if (found == index.end()) { ++unmatched; continue; } - ++matched; - auto& song = snapshot.songs[found->second]; - if (std::find(song.playlists.begin(), song.playlists.end(), id) != song.playlists.end()) - continue; - song.playlists.push_back(id); - members.push_back(song.id); - ++added; - } - if (!members.empty() && !entry.name.empty()) playlist_names[id] = entry.name; - if (!members.empty() && !entry.artwork.empty()) playlist_artwork[id] = entry.artwork; + std::map> index; + for (std::size_t i = 0; i < snapshot.songs.size(); ++i) index.emplace(snapshot.songs[i].id, i); + const auto merge = [&](const std::string& id, const std::string& name, const std::string& artwork, + const std::vector& tracks) { + auto& members = playlists[id]; + for (const auto& track : tracks) { + const auto found = index.find(track); + if (found == index.end()) { ++unmatched; continue; } + ++matched; + auto& song = snapshot.songs[found->second]; + if (std::find(song.playlists.begin(), song.playlists.end(), id) != song.playlists.end()) + continue; + song.playlists.push_back(id); + members.push_back(song.id); + ++added; } - for (auto it = playlists.begin(); it != playlists.end();) - it = it->second.empty() ? playlists.erase(it) : std::next(it); + if (!members.empty() && !name.empty()) playlist_names[id] = name; + if (!members.empty() && !artwork.empty()) playlist_artwork[id] = artwork; + }; + for (const auto& [id, entry] : cache.music_playlists) { + ++cache_playlists; + merge(id, entry.name, entry.artwork, entry.tracks); } + // Playlists music mods declare in reskate-music.json (ReSkateMusicPacker writes it): + // a name and the ids of songs the mod adds, which carry no station tag of their own. + for (const auto& playlist : mod_music_playlists()) { + ++mod_playlists; + merge(playlist.id, playlist.name, {}, playlist.songs); + } + for (auto it = playlists.begin(); it != playlists.end();) + it = it->second.empty() ? playlists.erase(it) : std::next(it); { std::ostringstream event; event << "{\"event\":\"music_catalog_membership\",\"songs\":" << snapshot.songs.size() << ",\"native_playlists\":" << native_playlists << ",\"cache_playlists\":" << cache_playlists + << ",\"mod_playlists\":" << mod_playlists << ",\"matched\":" << matched << ",\"unmatched\":" << unmatched - << ",\"added\":" << added << ",\"artwork\":" << artwork << ",\"playlists\":" << playlists.size() << "}"; + << ",\"added\":" << added << ",\"artwork\":" << song_artwork << ",\"playlists\":" << playlists.size() << "}"; dingosdk::logging::event(dingosdk::logging::Channel::music, event.str()); } // Registry order is bucket order, not authored ordering. Use stable IDs for diff --git a/Extension/Music/local_music_assets.h b/Extension/Music/local_music_assets.h index ab19ab88..85352478 100644 --- a/Extension/Music/local_music_assets.h +++ b/Extension/Music/local_music_assets.h @@ -1,4 +1,5 @@ #pragma once +#include "Engine/Vfs/mod_music.h" #include "Extension/Profile/runtime_internal.h" namespace dingosdk::profile_runtime { @@ -28,5 +29,8 @@ MusicAssetFunctions& music_asset_functions(); bool music_asset_type(std::uintptr_t asset, std::uintptr_t vtable_rva); +// Playlists the enabled mods declare in reskate-music.json (see Engine/Vfs/mod_music.h). +std::vector mod_music_playlists(); + bool read_music_catalog(MusicCatalog& result); } diff --git a/README.md b/README.md index a092aea7..1483c21d 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,16 @@ The menu and console keys can be changed in the launcher's Settings. - In game, the **MODS** tab of the ReSkate menu (**Insert**) turns mods on and off and applies the changes. - Mods are checked against the game build they were made for. Outdated mods, or mods that can't be merged cleanly, are left out with a message naming them, and the rest still load. +- A mod that adds songs can give them their own playlist in the game's music screen with a + `reskate-music.json` in its folder: + + ```json + {"schema": 1, "playlists": [{"name": "My Playlist", "songs": ["Artist - Title", "Artist - Other Title"]}]} + ``` + + Each entry is the song's artist and title exactly as the mod registers them, joined by ` - `. Entries that + match no song are ignored, and a playlist with no matching songs is not shown. A file that does not follow + this shape is skipped and logged. The songs themselves still have to be added by the mod. Only install mods you trust. Mods change game data, and custom scripts can run code. diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index be7f09f2..c951698d 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -44,6 +44,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) add_executable(dingosdk_mod_scoring_tests Engine/Vfs/Test/mod_scoring_tests.cpp) target_link_libraries(dingosdk_mod_scoring_tests PRIVATE dingosdk_mods) add_test(NAME mod_scoring COMMAND dingosdk_mod_scoring_tests "${DINGOSDK_TEST_GAME_ROOT}") + add_executable(dingosdk_mod_music_tests Engine/Vfs/Test/mod_music_tests.cpp) + target_link_libraries(dingosdk_mod_music_tests PRIVATE dingosdk_mods) + target_include_directories(dingosdk_mod_music_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME mod_music COMMAND dingosdk_mod_music_tests) add_executable(dingosdk_mod_merge_unshift_tests Engine/Vfs/Test/mod_merge_unshift_tests.cpp) target_link_libraries(dingosdk_mod_merge_unshift_tests PRIVATE dingosdk_mods) target_include_directories(dingosdk_mod_merge_unshift_tests PRIVATE "${PROJECT_SOURCE_DIR}") diff --git a/cmake/Core.cmake b/cmake/Core.cmake index c3143fbc..e7f6d4f8 100644 --- a/cmake/Core.cmake +++ b/cmake/Core.cmake @@ -107,6 +107,7 @@ if(WIN32) Engine/Vfs/mod_merge_overrides.cpp Engine/Vfs/mod_merge_grid.cpp Engine/Vfs/mod_merge_load_screens.cpp + Engine/Vfs/mod_music.cpp Engine/Vfs/mod_scoring.cpp) target_link_libraries(dingosdk_mods PUBLIC dingosdk_json dingosdk_mod_list PRIVATE dingosdk_game_archives dingosdk_native_db dingosdk_frostbite shell32 bcrypt) From 9b8f8b0267f23638fcdebb977f2ef7dd95e53c9e Mon Sep 17 00:00:00 2001 From: andrewnakas Date: Sun, 4 Oct 2026 11:57:42 -0600 Subject: [PATCH 016/109] Trainer 0.3.0: one Tune screen, presets as dials, reset everything, 56 more values confirmed Presets, trick sliders and the values share one screen and one set of values, so a preset cannot say "on" while a slider says otherwise. Every built-in preset is a dial (1 = the game's own). Reset everything clears locks and trick sliders too. A player's grinds, pumping, climbing, vaults and bails confirmed 56 more class values as read by the game (243 of 314). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018szL7EmApPZxh9GK4Phw6x --- Extension/Trainer/CHANGELOG.md | 24 +++ Extension/Trainer/README.md | 8 +- Extension/Trainer/trainer.cpp | 137 +++++++++++- Extension/Trainer/trainer.h | 8 +- Extension/Trainer/trainer_classes.h | 8 +- Extension/Trainer/trainer_classes.inc | 112 +++++----- Extension/Trainer/trainer_commands.cpp | 1 + Extension/Trainer/trainer_page.cpp | 287 ++++++++++++++----------- Extension/Trainer/trainer_presets.cpp | 33 ++- Extension/Trainer/trainer_presets.h | 7 + 10 files changed, 425 insertions(+), 200 deletions(-) diff --git a/Extension/Trainer/CHANGELOG.md b/Extension/Trainer/CHANGELOG.md index 5739c8a3..e64e866f 100644 --- a/Extension/Trainer/CHANGELOG.md +++ b/Extension/Trainer/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## v0.3.0 - 2026-10-04 + +Built on ReSkate 1.0.7. + +- **One Tune screen.** The Presets tab is gone: presets, trick sliders, your own presets and the + values are one screen, still split into REALISTIC, FUN and EVERYTHING. They all show the same + values, so a preset can no longer say "on" while a slider says something else. +- **Presets are dials.** Each built-in preset is a slider: 1 is the game's own, the preset's + number is the preset as it shipped (Super Ollie = Ollie height x3), and anything between or + beyond works. Below 1 turns the same things down, which is what the Realistic list is for. + The preset's button still switches it on and off, and lights up whenever the values match + it, whatever set them. `trainer dial ` from the console. +- **Reset everything**, above the tabs: every value, lock, preset and trick slider back to the + game as it shipped. Locked values used to survive every reset (a locked ollie height kept + Super Ollie alive), and the trick sliders had no reset at all. Each part also has its own: + Reset values (keeps locks, and says how many), Reset tricks. +- **More values confirmed, by hand.** A player ground, pumped, climbed, vaulted and bailed while + the trainer watched which values the game read: 56 more are confirmed (climbing 23, vault + and mantle 10, the speed model 8, push 7, bails 5, grind control 3), 243 of 314 in all. The + 71 that were never read in any of it (all of pumping, the double-stick flip metrics, grind + lean and transition, strong-impact bounces) stay listed as "no use found". +- The short lists only show values the game was found or seen to read. +- Torpedo Boost no longer names a value the game does not have. + ## v0.2.0 - 2026-10-04 Built on ReSkate 1.0.7. diff --git a/Extension/Trainer/README.md b/Extension/Trainer/README.md index 0ee88c8a..f94b1bde 100644 --- a/Extension/Trainer/README.md +++ b/Extension/Trainer/README.md @@ -8,16 +8,16 @@ telemetry HUD. It ships no game data: the list of values is built at run time fr | Tab | What you get | |---|---| -| **TUNE** | An Essentials list of 21 plainly named values (ollie height, top speed, body flip and spin speed, grind lock-on...), then every plain value of the game's physics tuning (about 760), plus one multiplier for each of its 169 curves and graphs. Search, filter by group, "only what I changed", lock, reset. Changes apply while you skate. Values that no game code was found reading are hidden unless you ask for them. | -| **PRESETS** | Quick switches for super high ollie, fast flips, fast spins and never bail, and height sliders for the no comply, the boneless and the hippy jump. Built-in presets you switch on and off, and that stack (Super Ollie, Fast Flips, Fast Spins, Realistic, Mega Pop, Fast, Hard To Bail, Sticky Grinds, ...), your own saved presets, and a preset a map applies every time it loads. | +| **TUNE** | Three lists: REALISTIC, FUN and EVERYTHING. Each opens on the dials: every built-in preset as one slider (Ollie height, Push speed, Body flip and spin speed, Bail resistance, Grind lock-on and friction, on-foot jump and sprint, glide, torpedo), where 1 is the game's own and the preset's button jumps to the preset; then the switches (Auto Push, No Speed Wobble, Smooth Surfaces, Long Wheelbase, Never bail), the trick sliders (flip trick speed, no comply, boneless, hippy and off-board jump height) and the values themselves: a short plainly named list, or under EVERYTHING the whole table (the physics tuning's values, one multiplier per curve and graph, and the 314 values of the game's other tuning classes) with search, groups, "only what I changed", locks, your own saved presets and a preset a map applies every time it loads. Changes apply while you skate. Values the game was never found or seen reading are hidden unless you ask for them. **Reset everything**, above the tabs, puts the game back as it shipped. | | **PRACTICE** | Game speed and pause, five marker slots per map (save / go / clear), return to the marker after a bail, teleport to coordinates, copy your position (game or Blender axes). | | **MAP & HUD** | Speed and air-time HUD, a read-out after every jump (takeoff speed and angle, height, distance, drop, landing speed, spin and flip), telemetry recording to CSV, and whatever the map's author ships for the trainer. | Controller: hold **LB + RB**, then D-pad **up** saves the marker, **down** goes to it, **left / right** pick the slot. -Everything is also a console command (`~`): `trainer open [tune|presets|practice|map]`, `trainer status`, `trainer set `, -`trainer find `, `trainer preset apply|remove `, `trainer marker save|go|clear [slot]`, +Everything is also a console command (`~`): `trainer open [tune|practice|map|realistic|fun|everything]`, `trainer status`, `trainer set `, +`trainer find `, `trainer preset apply|remove `, `trainer dial `, +`trainer reset |all|tricks|presets|everything`, `trainer marker save|go|clear [slot]`, `trainer tp `, `trainer where`, `trainer jumps`, `trainer dump`, `trainer selftest`. ## For map makers: `trainer.json` diff --git a/Extension/Trainer/trainer.cpp b/Extension/Trainer/trainer.cpp index c09a292a..bcd54d96 100644 --- a/Extension/Trainer/trainer.cpp +++ b/Extension/Trainer/trainer.cpp @@ -641,6 +641,62 @@ bool matches(const std::string &key, std::string_view pattern) { } return true; } +// What each built-in preset's rules reach in the running game: (entry, rule) pairs, worked out +// once per table of entries. +const std::vector>> &preset_targets() { + static std::vector>> cache; + static const Entry *cached_for{}; + static std::size_t cached_count{}; + auto &s = state(); + if (cached_for == s.entries.data() && cached_count == s.entries.size() && !cache.empty()) return cache; + cached_for = s.entries.data(); + cached_count = s.entries.size(); + const auto &presets = builtin_presets(); + cache.assign(presets.size(), {}); + for (std::size_t p = 0; p < presets.size(); ++p) + for (std::size_t r = 0; r < presets[p].rules.size(); ++r) { + const auto &rule = presets[p].rules[r]; + for (std::size_t i = 0; i < s.entries.size(); ++i) { + const auto &e = s.entries[i]; + const bool scale = e.kind == Kind::curve || e.kind == Kind::graph; + if (e.detail || scale != rule.curves || !matches(e.key, rule.pattern)) continue; + if (e.kind == Kind::flag && rule.multiply) continue; + cache[p].emplace_back(i, r); + } + } + return cache; +} +// A preset as a dial: `factor` is where its first value goes (x of stock); the others follow in +// proportion, so 1 is the game's own and the first rule's amount is the preset as it ships. +std::string apply_dial(std::string_view name, double factor) { + auto &s = state(); + const auto &presets = builtin_presets(); + const auto wanted = lower(name); + std::size_t p = 0; + while (p < presets.size() && lower(presets[p].name) != wanted) ++p; + if (p == presets.size()) return "error: no built-in preset is called \"" + std::string(name) + "\"."; + const auto &rules = presets[p].rules; + if (rules.empty() || !rules[0].multiply || rules[0].amount <= 0 || rules[0].amount == 1) + return "error: " + std::string(presets[p].name) + " is a switch, not a dial."; + if (!(factor > 0) || !std::isfinite(factor)) return "error: a dial needs a multiplier above 0; 1 is the game's own."; + const double t = std::log(factor) / std::log(rules[0].amount); + std::size_t count{}, locked{}; + for (const auto &[index, r] : preset_targets()[p]) { + auto &e = s.entries[index]; + const auto &rule = rules[r]; + if (is_locked(e)) { + ++locked; + continue; + } + if (rule.multiply) set_entry(e, rule.amount > 0 ? e.stock * std::pow(rule.amount, t) : e.stock * (1 + (rule.amount - 1) * t)); + else set_entry(e, t > 0.01 ? rule.amount : e.stock); // a switch that goes with the preset's direction + ++count; + } + std::erase(s.active, std::string(presets[p].name)); + if (std::abs(t - 1) < 1e-3) s.active.emplace_back(presets[p].name); + changed(); + return std::format("{} x{:.2f}: {} values set{}.", presets[p].name, factor, count, locked ? std::format("; {} locked values left alone", locked) : ""); +} std::string remove_preset(std::string_view name) { auto &s = state(); const auto wanted = lower(name); @@ -998,7 +1054,8 @@ void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) { // How fast the board turns (flip tricks, shuvits): body 0's angular velocity (+0x90), read while a jump // is measured or telemetry is recorded. float board_turn = 0; - if (m.airborne || (s.logging && s.log)) { + bool turning = m.airborne || (s.logging && s.log); + if (turning) { try { const auto bodies = client_source::detail::debug_noclip_bodies(base, client, s.entity); std::array spin{}; @@ -1173,8 +1230,37 @@ void build_view() { } const auto is_active = [&](std::string_view name) { return std::ranges::find(s.active, name) != s.active.end(); }; next->presets.push_back({"Stock", "Everything back to how this map loaded (frozen values stay).", true, s.active.empty() && !next->touched}); - for (const auto &preset : builtin_presets()) - next->presets.push_back({std::string(preset.name), std::string(preset.note), true, is_active(preset.name)}); + { + // A built-in preset is on when the values say so, whatever switched them: a slider, a + // dial or another preset. + const auto &presets = builtin_presets(); + const auto &targets = preset_targets(); + for (std::size_t p = 0; p < presets.size(); ++p) { + const auto &preset = presets[p]; + PresetRow row{std::string(preset.name), std::string(preset.note), true, false}; + bool any = false, on = true; + const Entry *headline{}; + for (const auto &[index, r] : targets[p]) { + const auto &e = s.entries[index]; + const auto &rule = preset.rules[r]; + if (!headline && r == 0) headline = &e; + const double expected = rule.multiply ? e.stock * rule.amount : rule.amount; + any = true; + // To the two decimals a dial shows: dragging one to the preset's number switches it on. + if (std::abs(e.value - sane(e, expected)) > 4e-3 * std::max(1e-3, std::abs(expected))) on = false; + } + row.active = any && on; + const auto dial = preset_dial(preset.name); + row.title = std::string(dial.title); + row.modes = dial.modes; + if (!preset.rules.empty() && preset.rules[0].multiply && headline && headline->stock != 0 && !dial.title.empty()) { + row.dial = true; + row.amount = preset.rules[0].amount; + row.factor = headline->value / headline->stock; + } + next->presets.push_back(std::move(row)); + } + } for (const auto &[name, values] : s.user) next->presets.push_back({name, std::format("{} values", values.size()), false, is_active(name)}); next->slot = s.slot; next->auto_return = s.auto_return; @@ -1285,8 +1371,41 @@ std::string run(std::string_view verb, const std::vector &a) { if (v == "set" || v == "freeze" || v == "reset") { if (v == "reset" && lower(arg(0)) == "all") { const auto count = reset_all(); + std::size_t locked{}; + for (const auto &e : s.entries) locked += e.touched; changed(); - return std::format("{} values put back.", count); + return locked ? std::format("{} values put back; {} locked values kept (Reset everything clears those too).", count, locked) + : std::format("{} values put back.", count); + } + if (v == "reset" && lower(arg(0)) == "tricks") { + s.hippy_height = s.nocomply_height = s.boneless_height = s.offboard_height = s.flip_speed = 1.0f; + changed(); + return "Trick heights and flip trick speed are the game's own again."; + } + if (v == "reset" && lower(arg(0)) == "presets") { + const auto on = s.active; + for (const auto &name : on) (void)remove_preset(name); + s.active.clear(); + changed(); + return std::format("{} presets switched off.", on.size()); + } + if (v == "reset" && lower(arg(0)) == "everything") { + // The game as it shipped: no lock survives, no preset stays on, no trick multiplier. + std::size_t count{}; + for (auto &e : s.entries) e.frozen = false; + for (auto &e : s.entries) { + if (e.touched) { + set_entry(e, e.stock); + ++count; + } else { + remember(e); + } + } + s.saved.clear(); + s.active.clear(); + s.hippy_height = s.nocomply_height = s.boneless_height = s.offboard_height = s.flip_speed = 1.0f; + changed(); + return std::format("Everything is the game's own again: {} values put back, locks cleared, presets off, trick sliders at 1.", count); } auto *e = find_entry(arg(0)); if (!e) return "error: no tuning value is called \"" + arg(0) + "\". Try: trainer find "; @@ -1313,6 +1432,16 @@ std::string run(std::string_view verb, const std::vector &a) { if (matches(e.key, pattern) && ++count <= 12) result += std::format("{}{} = {:.6g}", result.empty() ? "" : "; ", e.id, e.value); return count ? std::format("{} match: {}{}", count, result, count > 12 ? "; ..." : "") : "Nothing matches."; } + if (v == "dial") { + // trainer dial + const auto factor = number(arg(0)); + std::string name; + for (std::size_t i = 1; i < a.size(); ++i) name += (i > 1 ? " " : "") + a[i]; + if (!factor || name.empty()) return "error: usage: trainer dial "; + std::string why; + if (!editable(&why)) return "error: " + why; + return apply_dial(name, *factor); + } if (v == "preset") { const auto action = lower(arg(0)); std::string name; diff --git a/Extension/Trainer/trainer.h b/Extension/Trainer/trainer.h index e0bb23b6..2bdd3a76 100644 --- a/Extension/Trainer/trainer.h +++ b/Extension/Trainer/trainer.h @@ -33,7 +33,13 @@ struct Row { }; struct PresetRow { std::string name, note; - bool builtin{}, active{}; + bool builtin{}, active{}; // active: every value it sets holds what it sets, right now + // A dial: one multiplier that moves everything the preset moves, 1 = the game's own and + // `amount` = the preset as it ships. `factor` is where its first value stands. + bool dial{}; + std::string title; + double factor{1}, amount{1}; + std::uint8_t modes{}; // which of the short lists show it (mode_realistic, mode_fun) }; struct Marker { bool set{}; diff --git a/Extension/Trainer/trainer_classes.h b/Extension/Trainer/trainer_classes.h index d324af04..9ac4e0e1 100644 --- a/Extension/Trainer/trainer_classes.h +++ b/Extension/Trainer/trainer_classes.h @@ -13,10 +13,10 @@ struct ClassField { const char *name; float stock; std::uint16_t offset; - // The game was seen reading it while a scripted skater rode, pushed, ollied, flipped, ran, - // jumped and sprinted (hardware read watches on every copy), or its effect was measured. - // False is not proof: values of moves the script does not do (grinds, vaults, climbing, - // hard impacts) were never asked for. + // The game was seen reading it (hardware read watches on every copy) while a scripted skater + // rode, pushed, ollied, flipped, ran, jumped and sprinted, or while a player ground, pumped, + // climbed, vaulted and bailed; or its effect was measured. False: no read turned up in any + // of that, and no other copy of the numbers was found in memory. bool seen; }; struct ClassSpec { diff --git a/Extension/Trainer/trainer_classes.inc b/Extension/Trainer/trainer_classes.inc index 5f489373..e0e240df 100644 --- a/Extension/Trainer/trainer_classes.inc +++ b/Extension/Trainer/trainer_classes.inc @@ -50,9 +50,9 @@ inline constexpr ClassField class_fields[]{ // name, default, offset in the clas {"LowHorizontalSpeed_OffboardLanding", 10.0f, 0x18, true}, {"NormalHorizontalSpeed_OffboardLanding", 12.0f, 0x1c, true}, {"HighHorizontalSpeed_OffboardLanding", 15.0f, 0x20, true}, - {"MinSmithPitch", 0.0125f, 0x10, false}, - {"MinSmithHeading", 0.2f, 0x14, false}, - {"Max5_0TargetPitch", 0.4f, 0x18, false}, + {"MinSmithPitch", 0.0125f, 0x10, true}, + {"MinSmithHeading", 0.2f, 0x14, true}, + {"Max5_0TargetPitch", 0.4f, 0x18, true}, {"GrindLeanExitTime", 0.15f, 0x78, false}, {"GrindExitMaxLeanAngleDeg", 10.0f, 0x7c, false}, {"GrindLeanSpeed", 2.75f, 0x80, false}, @@ -77,47 +77,47 @@ inline constexpr ClassField class_fields[]{ // name, default, offset in the clas {"MaxPushSpeedLight", 4.0f, 0x0, true}, {"MaxPushSpeedMedium", 8.5f, 0x4, true}, {"MaxPushSpeedStrong", 9.5f, 0x8, true}, - {"PushingNormalLookaheadTime_S", 0.6f, 0x18, false}, + {"PushingNormalLookaheadTime_S", 0.6f, 0x18, true}, {"InputMashMeterPressIncrease", 0.15f, 0x1c, false}, {"InputMashMeterDecayRate_PerS", 0.5f, 0x20, false}, - {"LongInputHoldTime_S", 0.45f, 0x38, false}, - {"ShortInputHoldTime_S", 0.15f, 0x3c, false}, + {"LongInputHoldTime_S", 0.45f, 0x38, true}, + {"ShortInputHoldTime_S", 0.15f, 0x3c, true}, {"MashInputMax", 0.75f, 0x40, false}, {"MinMediumPushOverrideSpeed", 7.2f, 0x50, true}, - {"MaxBackwardRollSpeed", 1.5f, 0x54, false}, - {"MaxFlatgroundBackwardsRollSpeed", 1.0f, 0x58, false}, - {"MaxSlopeThreshold", 5.0f, 0x5c, false}, - {"ForwardToPushEngageHorsePushSpeed", 12.0f, 0x60, false}, - {"DynoNearDistanceMin", 0.8f, 0x0, false}, - {"DynoNearDistanceMax", 0.8f, 0x4, false}, - {"DynoDistanceMin", 1.5f, 0x8, false}, - {"DynoDistanceMax", 1.5f, 0xc, false}, - {"DynoAwayDistanceMin", 1.0f, 0x10, false}, - {"DynoAwayDistanceMax", 3.5f, 0x14, false}, - {"DynoQueryStartOffsetDistance", -0.4f, 0x18, false}, - {"DynoQueryEndOffsetDistance", 0.4f, 0x1c, false}, + {"MaxBackwardRollSpeed", 1.5f, 0x54, true}, + {"MaxFlatgroundBackwardsRollSpeed", 1.0f, 0x58, true}, + {"MaxSlopeThreshold", 5.0f, 0x5c, true}, + {"ForwardToPushEngageHorsePushSpeed", 12.0f, 0x60, true}, + {"DynoNearDistanceMin", 0.8f, 0x0, true}, + {"DynoNearDistanceMax", 0.8f, 0x4, true}, + {"DynoDistanceMin", 1.5f, 0x8, true}, + {"DynoDistanceMax", 1.5f, 0xc, true}, + {"DynoAwayDistanceMin", 1.0f, 0x10, true}, + {"DynoAwayDistanceMax", 3.5f, 0x14, true}, + {"DynoQueryStartOffsetDistance", -0.4f, 0x18, true}, + {"DynoQueryEndOffsetDistance", 0.4f, 0x1c, true}, {"DynoEdgeMinHeightOverGround", 1.0f, 0x20, false}, - {"DynoInnerDistanceMin", 0.5f, 0x24, false}, - {"DynoInnerDistanceMax", 0.5f, 0x28, false}, - {"ShimmyDistanceCheck", 0.5f, 0x2c, false}, + {"DynoInnerDistanceMin", 0.5f, 0x24, true}, + {"DynoInnerDistanceMax", 0.5f, 0x28, true}, + {"ShimmyDistanceCheck", 0.5f, 0x2c, true}, {"ClimbJumpAwayHorizontalVel", 4.0f, 0x30, false}, {"ClimbJumpAwayVerticalVelocity", 3.0f, 0x34, false}, {"ClimbJumpAwayUpBoost", 2.0f, 0x38, false}, {"ClimbJumpAwayDirectionalBoost", 1.0f, 0x3c, false}, {"ClimbJumpAwayDownBoost", -2.0f, 0x40, false}, - {"MantleMinHeightToHangFrom", 1.75f, 0x44, false}, - {"MantleMinHeightToDeadHangFrom", 2.25f, 0x48, false}, - {"MinStickDeflectionShimmy", 0.5f, 0x4c, false}, - {"MinStickDeflectionReachAway", 0.0f, 0x50, false}, - {"MinStickDeflectionReachMove", 0.5f, 0x54, false}, - {"MinStickDeflectionReachCorner", 0.5f, 0x58, false}, - {"MaxStickDeflectionReachAway", 0.5f, 0x5c, false}, + {"MantleMinHeightToHangFrom", 1.75f, 0x44, true}, + {"MantleMinHeightToDeadHangFrom", 2.25f, 0x48, true}, + {"MinStickDeflectionShimmy", 0.5f, 0x4c, true}, + {"MinStickDeflectionReachAway", 0.0f, 0x50, true}, + {"MinStickDeflectionReachMove", 0.5f, 0x54, true}, + {"MinStickDeflectionReachCorner", 0.5f, 0x58, true}, + {"MaxStickDeflectionReachAway", 0.5f, 0x5c, true}, {"MantleMaxHeight", 2.4f, 0x60, false}, - {"FootHoldCheckVerticalOffset", 1.2f, 0x64, false}, - {"FootHoldCheckSideOffset", 0.3f, 0x68, false}, - {"FootHoldForwardOffset", 0.5f, 0x6c, false}, - {"MaxStickDeflectionAngleShimmy", 20.0f, 0x70, false}, - {"ShimmyDeadhangVelocityThreshold", 0.3f, 0x74, false}, + {"FootHoldCheckVerticalOffset", 1.2f, 0x64, true}, + {"FootHoldCheckSideOffset", 0.3f, 0x68, true}, + {"FootHoldForwardOffset", 0.5f, 0x6c, true}, + {"MaxStickDeflectionAngleShimmy", 20.0f, 0x70, true}, + {"ShimmyDeadhangVelocityThreshold", 0.3f, 0x74, true}, {"ClimbHeightRelTrajToMantle", 0.0f, 0x80, false}, {"ClimbMaxVelocityToMantle", -6.0f, 0x84, false}, {"DynoCollisionDistanceFromEdge", 0.6f, 0x88, false}, @@ -241,14 +241,14 @@ inline constexpr ClassField class_fields[]{ // name, default, offset in the clas {"BaseJumpHeight", 0.73f, 0x14, true}, {"AirControlForce", 4.0f, 0x18, true}, {"MinFallVelocityXZ", 3.0f, 0x1c, true}, - {"CoffinAcceleration", 0.0f, 0x0, false}, - {"GravityAcceleration", 9.81f, 0x4, false}, - {"MaxGravityAcceleration", 9.81f, 0x8, false}, - {"NoInputTime", 5.0f, 0x20, false}, - {"MoonwalkMaxTimeSpinningSameWay", 0.5f, 0x30, false}, - {"MaxMoonwalkAccelerationSpeed", 3.5f, 0x34, false}, - {"MaxMoonwalkDecelerationSpeed", 1.0f, 0x38, false}, - {"MaxMoonwalkAcceleration", 0.04f, 0x3c, false}, + {"CoffinAcceleration", 0.0f, 0x0, true}, + {"GravityAcceleration", 9.81f, 0x4, true}, + {"MaxGravityAcceleration", 9.81f, 0x8, true}, + {"NoInputTime", 5.0f, 0x20, true}, + {"MoonwalkMaxTimeSpinningSameWay", 0.5f, 0x30, true}, + {"MaxMoonwalkAccelerationSpeed", 3.5f, 0x34, true}, + {"MaxMoonwalkDecelerationSpeed", 1.0f, 0x38, true}, + {"MaxMoonwalkAcceleration", 0.04f, 0x3c, true}, {"NoInputBrakeSpeed", 2.0f, 0x48, false}, {"Left Stick Forward Push Toggle Reset Speed", 5.0f, 0x58, false}, {"CarveFrictionGrowResponsiveness", 0.85f, 0xa8, false}, @@ -284,32 +284,32 @@ inline constexpr ClassField class_fields[]{ // name, default, offset in the clas {"StrongImpactVelocityMultiplier", 2.5f, 0x70, false}, {"WipeoutAirControlImpulse", 200.0f, 0x74, false}, {"StrongImpactRestitution", 0.5f, 0x78, true}, - {"WipeoutGroundControlImpulse", 100.0f, 0x7c, false}, + {"WipeoutGroundControlImpulse", 100.0f, 0x7c, true}, {"StrongImpactConsecutiveMinVelocity", 1.0f, 0x80, false}, {"StrongImpactConsecutiveVelMulDecrement", 0.7f, 0x84, false}, {"WipeoutBounceAirControlImpulse", 400.0f, 0x88, false}, {"WipeoutBounceGroundControlImpulse", 300.0f, 0x8c, false}, - {"SpreadEagleAirControlMinFallVelocity", 0.0f, 0x90, false}, - {"SpreadEagleAirControlMaxFallVelocity", 4.0f, 0x94, false}, - {"SpreadEagleAirResistanceMinFallVelocity", 4.0f, 0x98, false}, - {"SpreadEagleAirResistanceMaxFallVelocity", 8.0f, 0x9c, false}, - {"MaxVaultEdgeToEdgeDistance", 1.0f, 0x0, false}, - {"MaxVaultEndEdgeHeightOverGround", 2.3f, 0x4, false}, - {"MinHeightOverGroundMantle", 0.4f, 0x8, false}, + {"SpreadEagleAirControlMinFallVelocity", 0.0f, 0x90, true}, + {"SpreadEagleAirControlMaxFallVelocity", 4.0f, 0x94, true}, + {"SpreadEagleAirResistanceMinFallVelocity", 4.0f, 0x98, true}, + {"SpreadEagleAirResistanceMaxFallVelocity", 8.0f, 0x9c, true}, + {"MaxVaultEdgeToEdgeDistance", 1.0f, 0x0, true}, + {"MaxVaultEndEdgeHeightOverGround", 2.3f, 0x4, true}, + {"MinHeightOverGroundMantle", 0.4f, 0x8, true}, {"OnGroundMantleQueryHeight", 1.3f, 0xc, true}, {"OnGroundVaultQueryHeight", 1.25f, 0x10, true}, - {"MaxDistanceToContactMantleOnGround", 2.0f, 0x14, false}, - {"MaxDistanceToContactMantleInAir", 1.0f, 0x18, false}, - {"MinVaultEdgeAngleDifference", 135.0f, 0x1c, false}, - {"MaxStickVsEdgeAngleDifference", 75.0f, 0x20, false}, + {"MaxDistanceToContactMantleOnGround", 2.0f, 0x14, true}, + {"MaxDistanceToContactMantleInAir", 1.0f, 0x18, true}, + {"MinVaultEdgeAngleDifference", 135.0f, 0x1c, true}, + {"MaxStickVsEdgeAngleDifference", 75.0f, 0x20, true}, {"MaxDistanceToContactAutoMantleOnGr", 1.5f, 0x24, false}, - {"MaxDistanceToContactVaultOnGround", 2.0f, 0x28, false}, + {"MaxDistanceToContactVaultOnGround", 2.0f, 0x28, true}, {"MaxSkinnyWidthVaultToMantle", 0.3f, 0x2c, false}, {"MaxStickDeflectionVaultToMantle", 0.5f, 0x30, false}, - {"MaxDistanceToContactWallRunMantleOnGround", 3.0f, 0x34, false}, + {"MaxDistanceToContactWallRunMantleOnGround", 3.0f, 0x34, true}, {"MaxDegreesToLockFacingToCollision", 160.0f, 0x38, true}, {"MaxAutoMantleLowHeightObstacle", 0.75f, 0x3c, false}, - {"MinHeightOverGroundInAirMantle", 1.0f, 0x40, false}, + {"MinHeightOverGroundInAirMantle", 1.0f, 0x40, true}, {"OnGroundWallRunMantleQueryHeight", 2.0f, 0x44, true}, {"VaultOverCollisionHeightOffset", 0.75f, 0x48, false}, {"MaxDistanceToContactAutoStepUp", 1.0f, 0x4c, false}, diff --git a/Extension/Trainer/trainer_commands.cpp b/Extension/Trainer/trainer_commands.cpp index 528d0fd0..ae01487b 100644 --- a/Extension/Trainer/trainer_commands.cpp +++ b/Extension/Trainer/trainer_commands.cpp @@ -18,6 +18,7 @@ void register_trainer_commands(Commands ®istry) { {"reset", "Put a value (or all) back to stock", "|all", false}, {"find", "List tuning values whose id contains the words", "", false}, {"preset", "Apply, remove, save or delete a preset", "apply|remove|save|delete ", false}, + {"dial", "Turn a built-in preset up or down: 1 is the game's own", " ", false}, {"slot", "Select a marker slot", "<1-5>", false}, {"marker", "Save, go to or clear a marker", "save|go|clear [slot]", false}, {"tp", "Teleport the skater", " ", false}, diff --git a/Extension/Trainer/trainer_page.cpp b/Extension/Trainer/trainer_page.cpp index 964fce5e..7023d345 100644 --- a/Extension/Trainer/trainer_page.cpp +++ b/Extension/Trainer/trainer_page.cpp @@ -14,7 +14,7 @@ namespace dingosdk::overlay::menu { namespace { struct Page { - int tab{1}; // opens on PRESETS: the quick switches + int tab{}; // opens on TUNE: the dials and switches std::array search{}; int mode{1}; // the Tune tab: 0 Realistic, 1 Fun (two short lists), 2 Everything int group{1}; // Everything: 1: every group, 2..: one of the view's groups @@ -75,7 +75,8 @@ void filter_rows(Page &p, const trainer::View &view) { for (std::size_t i = 0; i < view.rows.size(); ++i) { const auto &row = view.rows[i]; if (essentials) { - if (!row.friendly.empty() && (row.modes & list)) p.shown.push_back(i); + // Only what the game was found or seen to read: a short list has no room for maybes. + if (!row.friendly.empty() && (row.modes & list) && row.used) p.shown.push_back(i); continue; } if (row.detail && !p.graph_points && !row.touched) continue; @@ -98,7 +99,7 @@ void value_row(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const tra ImGui::PushID(row.id.c_str()); bool frozen = row.frozen; if (ImGui::Checkbox("##freeze", &frozen)) trainer_command(menu, callbacks, std::format("freeze {} {}", row.id, frozen ? 1 : 0)); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Lock: presets and Reset all leave this value alone.\nYou do not need it for a change to apply."); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Lock: presets and Reset values leave this value alone.\nReset everything (top of the page) clears locks too.\nYou do not need it for a change to apply."); ImGui::SameLine(); const float column = ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x * 0.52f; const auto label = friendly && !row.friendly.empty() ? row.friendly : with_group ? row.group + " / " + row.label : row.label; @@ -146,7 +147,58 @@ void value_row(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const tra ImGui::PopID(); } -void tune_tab(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) { +// A built-in preset as a switch: blue while every value it sets holds what it sets. +void preset_switch(SkateMenu &menu, const CallbacksV3 &callbacks, const trainer::PresetRow &preset) { + if (preset.active) ImGui::PushStyleColor(ImGuiCol_Button, skate_theme::blue); + const bool pressed = ImGui::Button(preset.name.c_str()); + if (preset.active) ImGui::PopStyleColor(); + if (pressed) trainer_command(menu, callbacks, std::string(preset.active ? "preset remove " : "preset apply ") + preset.name); + if (ImGui::IsItemHovered() && !preset.note.empty()) ImGui::SetTooltip("%s", preset.note.c_str()); +} +// A built-in preset as a dial: one multiplier that moves everything the preset moves. +void dial_row(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::PresetRow &preset) { + ImGui::PushID(preset.name.c_str()); + const float column = ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x * 0.40f; + const bool moved = std::abs(preset.factor - 1.0) > 1e-4; + if (moved) ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::blue); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(preset.title.c_str()); + if (moved) ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s\n1 is the game's own; \"%s\" is x %s.", preset.note.c_str(), preset.name.c_str(), number(preset.amount).c_str()); + ImGui::SameLine(column); + const float button = ImGui::CalcTextSize("Fast Parkour Flips").x + ImGui::GetStyle().FramePadding.x * 2; + const float reset = ImGui::CalcTextSize("Reset").x + ImGui::GetStyle().FramePadding.x * 2; + const auto id = "dial:" + preset.name; + double value = p.active == id ? p.active_value : preset.factor; + // A preset that turns its value down gets a dial with more room below 1. + const double low = preset.amount < 1 ? 0.05 : 0.2, high = preset.amount < 1 ? 3.0 : 10.0; + ImGui::SetNextItemWidth(std::max(px(90), ImGui::GetContentRegionAvail().x - button - reset - ImGui::GetStyle().ItemSpacing.x * 2)); + if (ImGui::SliderScalar("##dial", ImGuiDataType_Double, &value, &low, &high, "x %.2f", ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_Logarithmic)) + trainer_command(menu, callbacks, std::format("dial {} {}", number(value), preset.name)); + if (ImGui::IsItemActive()) { + p.active = id; + p.active_value = value; + } else if (p.active == id) { + p.active.clear(); + } + ImGui::SameLine(); + if (preset.active) ImGui::PushStyleColor(ImGuiCol_Button, skate_theme::blue); + const bool pressed = ImGui::Button(preset.name.c_str(), ImVec2(button, 0)); + if (preset.active) ImGui::PopStyleColor(); + if (pressed) trainer_command(menu, callbacks, std::string(preset.active ? "preset remove " : "preset apply ") + preset.name); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", preset.note.c_str()); + if (moved) { + ImGui::SameLine(); + if (ImGui::Button("Reset", ImVec2(reset, 0))) trainer_command(menu, callbacks, "dial 1 " + preset.name); + } + ImGui::PopID(); +} + +// One screen for everything that changes how the game plays: the presets as dials and +// switches, the trick multipliers, the player's own presets and the values themselves. All of +// them show the same values, so none can disagree with another. +void tune_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) { if (!view.ready) { begin_card(menu, "tune-wait", "PHYSICS TUNING"); note(view.status.c_str()); @@ -167,40 +219,89 @@ void tune_tab(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trai if (on) ImGui::PopStyleColor(); } } - const auto preset_on = [&](std::string_view name) { - for (const auto &preset : view.presets) - if (preset.name == name) return preset.active; - return false; - }; - const auto preset_button = [&](const char *name) { - const bool on = preset_on(name); - if (on) ImGui::PushStyleColor(ImGuiCol_Button, skate_theme::blue); - if (ImGui::Button(name)) trainer_command(menu, callbacks, std::string(on ? "preset remove " : "preset apply ") + name); - if (on) ImGui::PopStyleColor(); - }; - ImGui::BeginDisabled(!view.editable || !callbacks.queue_console_command); - if (p.mode == 0) { - note("Tone the game down: lower pop, slower pushing and rotation, stricter catches and bails. Drag any value below the game's own."); - preset_button("Realistic"); - ImGui::SameLine(); - if (ImGui::Button("Reset all")) trainer_command(menu, callbacks, "reset all"); - ImGui::SameLine(); - ImGui::TextDisabled("%zu changed", view.touched); - } else if (p.mode == 1) { - note("Turn it up: huge pop, fast pushing, flips, spins, and everything on foot and in the air."); - static constexpr const char *fun[]{"Super Ollie", "Fast", "Fast Flips", "Fast Spins", "Auto Push", "Moon Jump", "Fast On Foot", - "Fast Parkour Flips", "Super Glide", "Torpedo Boost"}; - for (std::size_t i = 0; i < std::size(fun); ++i) { - if (i && ImGui::GetItemRectMax().x + ImGui::CalcTextSize(fun[i]).x + ImGui::GetStyle().FramePadding.x * 4 < - ImGui::GetWindowPos().x + ImGui::GetContentRegionMax().x) - ImGui::SameLine(); - preset_button(fun[i]); - } - if (ImGui::Button("Reset all")) trainer_command(menu, callbacks, "reset all"); - ImGui::SameLine(); - ImGui::TextDisabled("%zu changed", view.touched); + const std::uint8_t list = p.mode == 0 ? trainer::mode_realistic : p.mode == 1 ? trainer::mode_fun : std::uint8_t{0xff}; + const bool can_edit = view.editable && callbacks.queue_console_command != nullptr; + + begin_card(menu, "dials", p.mode == 0 ? "FEEL: TONE IT DOWN" : p.mode == 1 ? "FEEL: TURN IT UP" : "FEEL", + "1 is the game's own. A dial moves every value its preset moves."); + ImGui::BeginDisabled(!can_edit); + for (const auto &preset : view.presets) + if (preset.builtin && preset.dial && (p.mode == 2 || (preset.modes & list))) dial_row(menu, callbacks, p, preset); + // The presets that are plain switches, and the ones that set several dials at once. + bool first = true; + for (const auto &preset : view.presets) { + if (!preset.builtin || preset.dial || preset.name == "Stock" || !(p.mode == 2 || (preset.modes & list))) continue; + if (!first && ImGui::GetItemRectMax().x + ImGui::CalcTextSize(preset.name.c_str()).x + ImGui::GetStyle().FramePadding.x * 4 < + ImGui::GetWindowPos().x + ImGui::GetContentRegionMax().x) + ImGui::SameLine(); + first = false; + preset_switch(menu, callbacks, preset); } ImGui::EndDisabled(); + if (p.mode != 0) { + // The game's own bail protection, the same switch as Skater > Movement. + const auto &debug = model.debug; + bool no_bail = debug.no_bail; + if (toggle_row(menu, "Never bail", "No wipeouts at all. Recover from a bail before switching it on.", no_bail, + (debug.no_bail_available || debug.no_bail) && callbacks.queue_debug != nullptr)) + debug_request(menu, callbacks, {DebugAction::set_no_bail, no_bail}); + } + ImGui::BeginDisabled(!can_edit || !view.touched); + if (ImGui::Button("Reset values")) trainer_command(menu, callbacks, "reset all"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Every tuning value back to the game's own. Locked values stay."); + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("%zu values changed", view.touched); + end_card(); + + trick_heights(menu, callbacks, p, view); + + if (p.mode == 2) { + begin_card(menu, "preset-save", "YOUR PRESETS", "Save what is changed right now"); + ImGui::BeginDisabled(!can_edit); + const float button = ImGui::CalcTextSize("Turn off").x + ImGui::GetStyle().FramePadding.x * 2; + for (const auto &preset : view.presets) { + if (preset.builtin) continue; + ImGui::PushID(preset.name.c_str()); + if (preset.active) { + ImGui::PushStyleColor(ImGuiCol_Button, skate_theme::blue); + if (ImGui::Button("Turn off", ImVec2(button, 0))) trainer_command(menu, callbacks, "preset remove " + preset.name); + ImGui::PopStyleColor(); + } else if (ImGui::Button("Turn on", ImVec2(button, 0))) { + trainer_command(menu, callbacks, "preset apply " + preset.name); + } + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(preset.name.c_str()); + ImGui::SameLine(); + ImGui::TextDisabled("%s", preset.note.c_str()); + ImGui::SameLine(ImGui::GetContentRegionAvail().x + ImGui::GetCursorPosX() - px(120)); + if (ImGui::SmallButton("This map")) trainer_command(menu, callbacks, "profile set " + preset.name); + ImGui::SameLine(); + if (ImGui::SmallButton("Delete")) trainer_command(menu, callbacks, "preset delete " + preset.name); + ImGui::PopID(); + } + ImGui::EndDisabled(); + field(menu, "Name"); + const float save = ImGui::CalcTextSize("Save").x + ImGui::GetStyle().FramePadding.x * 2; + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - save - ImGui::GetStyle().ItemSpacing.x); + ImGui::InputText("##preset-name", p.preset_name.data(), p.preset_name.size()); + ImGui::SameLine(); + ImGui::BeginDisabled(!p.preset_name[0] || !view.touched); + if (ImGui::Button("Save", ImVec2(save, 0))) trainer_command(menu, callbacks, std::string("preset save ") + p.preset_name.data()); + ImGui::EndDisabled(); + if (!view.map.empty()) { + info(menu, "This map applies", view.profile_preset.empty() ? "nothing" : view.profile_preset); + if (!view.profile_preset.empty() && ImGui::Button("Stop applying it on this map")) trainer_command(menu, callbacks, "profile clear"); + note("\"This map\" beside one of your presets applies it every time this map loads."); + } + end_card(); + } + + // The values themselves: the short list's, or all of them. + ImGui::Spacing(); + ImGui::SeparatorText(p.mode == 2 ? "EVERY VALUE" : "FINE TUNING"); if (p.mode == 2) { ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.42f); ImGui::InputTextWithHint("##search", "Search every value...", p.search.data(), p.search.size()); @@ -215,11 +316,6 @@ void tune_tab(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trai if (ImGui::Selectable(view.groups[i].c_str(), p.group == static_cast(i) + 2)) p.group = static_cast(i) + 2; ImGui::EndCombo(); } - ImGui::SameLine(); - ImGui::BeginDisabled(!view.touched || !view.editable); - if (ImGui::Button("Reset all")) trainer_command(menu, callbacks, "reset all"); - ImGui::EndDisabled(); - note("Changes apply as you drag. The box on the left only locks a value against presets and Reset all."); ImGui::Checkbox("Only what I changed", &p.only_changed); ImGui::SameLine(); ImGui::Checkbox("Graph points", &p.graph_points); @@ -227,7 +323,9 @@ void tune_tab(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trai ImGui::SameLine(); ImGui::Checkbox("Values with no use found", &p.show_unused); if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Also list the tuning values that no game code was found reading.\nChanging those will probably do nothing."); + ImGui::SetTooltip("Also list the tuning values that the game was never found or seen reading.\nChanging those will probably do nothing."); + } else { + note("The same values the dials move, one at a time. The box on the left locks a value against presets, dials and Reset values."); } filter_rows(p, view); if (p.mode == 2) { @@ -235,18 +333,14 @@ void tune_tab(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trai if (p.hidden_unused) ImGui::TextDisabled("%zu shown, %zu changed, %zu hidden", p.shown.size(), view.touched, p.hidden_unused); else ImGui::TextDisabled("%zu shown, %zu changed", p.shown.size(), view.touched); } - // The trick heights are not tuning values; they sit with the short lists. - if (p.mode < 2) trick_heights(menu, callbacks, p, view); const bool with_group = p.mode == 2 && (p.search[0] != 0 || p.group <= 1 || p.only_changed); const bool friendly = p.mode < 2; - ImGui::BeginChild("tune-rows", ImVec2(0, 0), ImGuiChildFlags_None); ImGuiListClipper clipper; clipper.Begin(static_cast(p.shown.size())); while (clipper.Step()) for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) value_row(menu, callbacks, p, view, view.rows[p.shown[static_cast(i)]], with_group, friendly); if (p.shown.empty()) note("Nothing matches."); - ImGui::EndChild(); } void trick_heights(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) { @@ -269,87 +363,13 @@ void trick_heights(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const slider("Boneless height", "boneless_height", view.boneless_height, p.boneless_edit, p.boneless_editing); slider("Hippy jump height", "hippy_height", view.hippy_height, p.hippy_edit, p.hippy_editing); slider("Off-board jump height", "offboard_height", view.offboard_height, p.offboard_edit, p.offboard_editing); + if (ImGui::Button("Reset tricks")) { + p.hippy_editing = p.nocomply_editing = p.boneless_editing = p.offboard_editing = p.flip_editing = false; + trainer_command(menu, callbacks, "reset tricks"); + } note("Not tuning values: the game sets these in its trick scripts and flip curves. Ctrl+click a slider to type a number."); end_card(); } -void presets_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) { - if (!view.editable && !view.blocked.empty()) warn(view.blocked.c_str()); - begin_card(menu, "quick", "QUICK", "The ones everybody wants"); - { - const auto active = [&](std::string_view name) { - for (const auto &preset : view.presets) - if (preset.name == name) return preset.active; - return false; - }; - const auto quick = [&](const char *name, const char *label, const char *hint) { - bool on = active(name); - if (toggle_row(menu, label, hint, on, view.ready && view.editable && callbacks.queue_console_command != nullptr)) - trainer_command(menu, callbacks, std::string(on ? "preset apply " : "preset remove ") + name); - }; - quick("Super Ollie", "Super high ollie", "About four times the pop, off the ground and out of grinds."); - quick("Fast Flips", "Fast flips", "Flip tricks spin three times as fast."); - quick("Fast Spins", "Fast spins", "Spin three times as fast."); - // The game's own bail protection, the same switch as Skater > Movement. - const auto &debug = model.debug; - bool no_bail = debug.no_bail; - if (toggle_row(menu, "Never bail", "No wipeouts at all. Recover from a bail before switching it on.", no_bail, - (debug.no_bail_available || debug.no_bail) && callbacks.queue_debug != nullptr)) - debug_request(menu, callbacks, {DebugAction::set_no_bail, no_bail}); - } - end_card(); - trick_heights(menu, callbacks, p, view); - begin_card(menu, "presets", "PRESETS", "Switch any of them on and off; they stack"); - ImGui::BeginDisabled(!view.ready || !view.editable); - const float button = ImGui::CalcTextSize("Turn off").x + ImGui::GetStyle().FramePadding.x * 2; - for (const auto &preset : view.presets) { - ImGui::PushID(preset.name.c_str()); - if (preset.name == "Stock") { - // Not a preset to switch: everything back to how the map loaded. - if (ImGui::Button("Reset", ImVec2(button, 0))) trainer_command(menu, callbacks, "preset apply Stock"); - } else if (preset.active) { - ImGui::PushStyleColor(ImGuiCol_Button, skate_theme::blue); - if (ImGui::Button("Turn off", ImVec2(button, 0))) trainer_command(menu, callbacks, "preset remove " + preset.name); - ImGui::PopStyleColor(); - } else if (ImGui::Button("Turn on", ImVec2(button, 0))) { - trainer_command(menu, callbacks, "preset apply " + preset.name); - } - ImGui::SameLine(); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(preset.name.c_str()); - if (preset.active && preset.name != "Stock") { - ImGui::SameLine(); - tag(menu, "ON", skate_theme::blue); - } - ImGui::SameLine(); - ImGui::TextDisabled("%s", preset.note.c_str()); - if (!preset.builtin) { - ImGui::SameLine(ImGui::GetContentRegionAvail().x + ImGui::GetCursorPosX() - px(120)); - if (ImGui::SmallButton("This map")) trainer_command(menu, callbacks, "profile set " + preset.name); - ImGui::SameLine(); - if (ImGui::SmallButton("Delete")) trainer_command(menu, callbacks, "preset delete " + preset.name); - } - ImGui::PopID(); - } - ImGui::EndDisabled(); - end_card(); - - begin_card(menu, "preset-save", "YOUR PRESETS", "Save what is changed right now"); - field(menu, "Name"); - const float save = ImGui::CalcTextSize("Save").x + ImGui::GetStyle().FramePadding.x * 2; - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - save - ImGui::GetStyle().ItemSpacing.x); - ImGui::InputText("##preset-name", p.preset_name.data(), p.preset_name.size()); - ImGui::SameLine(); - ImGui::BeginDisabled(!p.preset_name[0] || !view.touched); - if (ImGui::Button("Save", ImVec2(save, 0))) trainer_command(menu, callbacks, std::string("preset save ") + p.preset_name.data()); - ImGui::EndDisabled(); - info(menu, "Changed values", std::to_string(view.touched)); - if (!view.map.empty()) { - info(menu, "This map applies", view.profile_preset.empty() ? "nothing" : view.profile_preset); - if (!view.profile_preset.empty() && ImGui::Button("Stop applying it on this map")) trainer_command(menu, callbacks, "profile clear"); - note("\"This map\" beside one of your presets applies it every time this map loads."); - } - end_card(); -} void practice_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) { const auto telemetry = trainer::telemetry(); @@ -514,7 +534,8 @@ bool trainer_take_open() { auto &p = page(); if (view->open_serial == p.open_serial) return false; p.open_serial = view->open_serial; - p.tab = view->open_tab >= 4 ? 0 : std::clamp(view->open_tab, 0, 3); + // `trainer open` still takes "presets": they live on the Tune tab now. + p.tab = view->open_tab >= 4 ? 0 : std::clamp(view->open_tab - 1, 0, 2); if (view->open_tab >= 4) p.mode = std::clamp(view->open_tab - 4, 0, 2); p.show_page = true; return true; @@ -528,13 +549,21 @@ bool trainer_page_wanted() { void trainer_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks) { auto &p = page(); const auto view = trainer::view(); - category_tabs(menu, p.tab, {"TUNE", "PRESETS", "PRACTICE", "MAP & HUD"}, "trainer-tabs"); + ImGui::BeginDisabled(!callbacks.queue_console_command); + if (ImGui::Button("RESET EVERYTHING")) { + p.hippy_editing = p.nocomply_editing = p.boneless_editing = p.offboard_editing = p.flip_editing = false; + trainer_command(menu, callbacks, "reset everything"); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("The game as it shipped: every value, lock, preset and trick slider."); + category_tabs(menu, p.tab, {"TUNE", "PRACTICE", "MAP & HUD"}, "trainer-tabs"); ImGui::PushID(p.tab); ImGui::BeginChild("trainer-tab", ImVec2(0, page_body_height(menu))); switch (p.tab) { - case 0: tune_tab(menu, callbacks, p, *view); break; - case 1: presets_tab(menu, model, callbacks, p, *view); break; - case 2: practice_tab(menu, model, callbacks, p, *view); break; + case 0: tune_tab(menu, model, callbacks, p, *view); break; + case 1: practice_tab(menu, model, callbacks, p, *view); break; default: map_tab(menu, callbacks, *view); break; } ImGui::EndChild(); diff --git a/Extension/Trainer/trainer_presets.cpp b/Extension/Trainer/trainer_presets.cpp index b3b7c282..fa08f922 100644 --- a/Extension/Trainer/trainer_presets.cpp +++ b/Extension/Trainer/trainer_presets.cpp @@ -83,7 +83,6 @@ std::string_view essential_name(std::string_view key, int *rank, std::uint8_t *m {"wipeout.spreadeagleairresistance", "Glide: air resistance", f}, {"wipeout.spreadeagleaircontrolmax", "Glide: steering", f}, {"wipeout.torpedoaircontrolmax", "Torpedo: steering (fast)", f}, - {"wipeout.torpedoaircontrolmin", "Torpedo: steering (slow)", f}, {"wipeout.defaultaircontrol", "Falling: steering", f}, {"wipeout.defaultairresistance", "Falling: air resistance", f}, {"wallrun.wallrunjumpupvelocityboost", "Wallrun: jump up boost", f}, @@ -98,6 +97,36 @@ std::string_view essential_name(std::string_view key, int *rank, std::uint8_t *m return known ? found->label : std::string_view{}; } +PresetDial preset_dial(std::string_view name) { + struct Dial { + std::string_view name, title; + std::uint8_t modes; + }; + constexpr std::uint8_t r = mode_realistic, f = mode_fun, both = mode_realistic | mode_fun; + static constexpr Dial dials[]{ + {"Super Ollie", "Ollie height", both}, + {"Fast", "Push speed", both}, + {"Fast Flips", "Body flip speed", both}, + {"Fast Spins", "Body spin speed", both}, + {"Hard To Bail", "Bail resistance (higher: harder to bail)", both}, + {"Sticky Grinds", "Grind lock-on", both}, + {"Slick Grinds", "Grind friction", both}, + {"Moon Jump", "On foot: jump height", both}, + {"Fast On Foot", "On foot: sprint speed", both}, + {"Fast Parkour Flips", "On foot: flip and roll speed", f}, + {"Super Glide", "Glide: gravity (lower falls slower)", f}, + {"Torpedo Boost", "Torpedo and falling: steering", f}, + {"Realistic", "", r}, + {"Mega Pop", "", 0}, + {"No Speed Wobble", "", f}, + {"Auto Push", "", f}, + {"Smooth Surfaces", "", f}, + {"Long Wheelbase", "", both}, + }; + const auto found = std::ranges::find(dials, name, &Dial::name); + return found == std::end(dials) ? PresetDial{} : PresetDial{found->title, found->modes}; +} + // Patterns are matched against the lower-case ids the game's own data gives its tuning // (run `trainer dump` for the list), so a preset reaches every value a pattern names in // whatever build is running and simply skips the ones that build lacks. @@ -140,7 +169,7 @@ const std::vector &builtin_presets() { {"Super Glide", "Spread-eagle falls slowly and steers hard.", {{"wipeout.spreadeaglegravity", true, 0.35}, {"wipeout.spreadeagleaircontrolmax", true, 2.0}, {"wipeout.spreadeagleaircontrolmin", true, 2.0}}}, {"Torpedo Boost", "Torpedo steers and carries much harder, and so does a plain fall.", - {{"wipeout.torpedoaircontrolmax", true, 2.5}, {"wipeout.torpedoaircontrolmin", true, 2.5}, {"wipeout.defaultaircontrol", true, 2.0}}}, + {{"wipeout.torpedoaircontrolmax", true, 2.5}, {"wipeout.defaultaircontrol", true, 2.0}}}, {"No Speed Wobble", "The board stays steady at any speed.", {{"physicsmode.speedwobblestartspeed", true, 20.0}}}, {"Auto Push", "Once rolling, the skater keeps gaining speed up to the auto push speed (8 m/s).", {{"physicsmode.autopushenabled", false, 1.0}}}, {"Hard To Bail", "Much larger impacts are needed before a wipeout.", diff --git a/Extension/Trainer/trainer_presets.h b/Extension/Trainer/trainer_presets.h index 6f381cd8..35e9547e 100644 --- a/Extension/Trainer/trainer_presets.h +++ b/Extension/Trainer/trainer_presets.h @@ -31,6 +31,13 @@ struct Link { std::string_view drives; // lower-case id of a graph multiplier it scales }; const std::vector &value_links(); +// How a built-in preset shows on the Tune tab: as a dial under `title` when its first rule is a +// multiplier, and on which of the two short lists. +struct PresetDial { + std::string_view title; + std::uint8_t modes{}; +}; +PresetDial preset_dial(std::string_view name); // Presets stack: each applies on top of what is already changed; Stock clears them. const std::vector &builtin_presets(); } // namespace dingosdk::trainer From fe8e2667f25b56d3deb07351f26a4d358ab0995f Mon Sep 17 00:00:00 2001 From: andrewnakas Date: Sun, 4 Oct 2026 12:12:08 -0600 Subject: [PATCH 017/109] Trainer 0.3.0: type any multiplier beside a dial or trick slider The sliders cover the sensible range; the box takes anything (Ollie height x10000). Curve and graph multipliers and the trick heights no longer stop at x100. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018szL7EmApPZxh9GK4Phw6x --- Extension/Trainer/CHANGELOG.md | 3 +++ Extension/Trainer/trainer.cpp | 4 ++-- Extension/Trainer/trainer_jump.cpp | 2 +- Extension/Trainer/trainer_page.cpp | 36 +++++++++++++++++++++++------- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/Extension/Trainer/CHANGELOG.md b/Extension/Trainer/CHANGELOG.md index e64e866f..a2b59f1d 100644 --- a/Extension/Trainer/CHANGELOG.md +++ b/Extension/Trainer/CHANGELOG.md @@ -21,6 +21,9 @@ Built on ReSkate 1.0.7. and mantle 10, the speed model 8, push 7, bails 5, grind control 3), 243 of 314 in all. The 71 that were never read in any of it (all of pumping, the double-stick flip metrics, grind lean and transition, strong-impact bounces) stay listed as "no use found". +- **Type any number.** Every dial and trick slider has a box beside it: type a multiplier and + press Enter to go past the slider's end (Ollie height x10000 if you like). Curve and graph + multipliers and the trick heights no longer stop at x100. - The short lists only show values the game was found or seen to read. - Torpedo Boost no longer names a value the game does not have. diff --git a/Extension/Trainer/trainer.cpp b/Extension/Trainer/trainer.cpp index bcd54d96..2aa6d42c 100644 --- a/Extension/Trainer/trainer.cpp +++ b/Extension/Trainer/trainer.cpp @@ -33,7 +33,7 @@ namespace { namespace tuning = dingosdk::physics_tuning; using Vec3 = std::array; constexpr float max_coordinate = 1.0e6f; -constexpr float height_low = 0.05f, height_high = 100.0f; // trick height multipliers +constexpr float height_low = 0.05f, height_high = 1.0e6f; // trick height multipliers (the sliders stop far short; a typed number need not) constexpr std::uint32_t air_states_first = 200, air_states_end = 300; constexpr std::uint32_t xinput_up = 0x1, xinput_down = 0x2, xinput_left = 0x4, xinput_right = 0x8, xinput_lb = 0x100, xinput_rb = 0x200; @@ -380,7 +380,7 @@ double sane(const Entry &e, double value) { if (!std::isfinite(value)) return e.stock; if (e.kind == Kind::flag) return value != 0 ? 1 : 0; if (e.kind == Kind::integer) return std::round(value); - if (e.kind == Kind::curve || e.kind == Kind::graph) return std::clamp(value, 0.0, 100.0); + if (e.kind == Kind::curve || e.kind == Kind::graph) return std::clamp(value, 0.0, 1.0e6); return std::clamp(value, -1.0e6, 1.0e6); } void apply_entry(Entry &e) { diff --git a/Extension/Trainer/trainer_jump.cpp b/Extension/Trainer/trainer_jump.cpp index 50d35931..a613e490 100644 --- a/Extension/Trainer/trainer_jump.cpp +++ b/Extension/Trainer/trainer_jump.cpp @@ -101,7 +101,7 @@ bool start_trick_heights(std::uintptr_t base) noexcept { return h.ready; } void set_trick_heights(float no_comply, float boneless) noexcept { - const auto speed = [](float height) { return std::isfinite(height) && height > 0 ? std::sqrt(std::min(height, 100.0f)) : 1.0f; }; + const auto speed = [](float height) { return std::isfinite(height) && height > 0 ? std::sqrt(std::min(height, 1.0e6f)) : 1.0f; }; hooked().no_comply.store(speed(no_comply), std::memory_order_relaxed); hooked().boneless.store(speed(boneless), std::memory_order_relaxed); } diff --git a/Extension/Trainer/trainer_page.cpp b/Extension/Trainer/trainer_page.cpp index 7023d345..c220831c 100644 --- a/Extension/Trainer/trainer_page.cpp +++ b/Extension/Trainer/trainer_page.cpp @@ -126,7 +126,7 @@ void value_row(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const tra ImGui::SetNextItemWidth(std::max(px(90), ImGui::GetContentRegionAvail().x - reset - ImGui::GetStyle().ItemSpacing.x)); const bool scale = row.kind == trainer::Kind::curve || row.kind == trainer::Kind::graph; const bool whole = row.kind == trainer::Kind::integer; - const double low = scale ? 0.0 : -1.0e6, high = scale ? 100.0 : 1.0e6; + const double low = scale ? 0.0 : -1.0e6, high = 1.0e6; const auto speed = whole ? 0.1f : scale ? 0.01f : static_cast(std::max({std::abs(row.stock), std::abs(value), 0.01}) * 0.004); edited = ImGui::DragScalar("##value", ImGuiDataType_Double, &value, speed, &low, &high, whole ? "%.0f" : scale ? "x %.2f" : "%.4g", @@ -173,8 +173,9 @@ void dial_row(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trai double value = p.active == id ? p.active_value : preset.factor; // A preset that turns its value down gets a dial with more room below 1. const double low = preset.amount < 1 ? 0.05 : 0.2, high = preset.amount < 1 ? 3.0 : 10.0; - ImGui::SetNextItemWidth(std::max(px(90), ImGui::GetContentRegionAvail().x - button - reset - ImGui::GetStyle().ItemSpacing.x * 2)); - if (ImGui::SliderScalar("##dial", ImGuiDataType_Double, &value, &low, &high, "x %.2f", ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_Logarithmic)) + const float box = px(64); + ImGui::SetNextItemWidth(std::max(px(70), ImGui::GetContentRegionAvail().x - box - button - reset - ImGui::GetStyle().ItemSpacing.x * 3)); + if (ImGui::SliderScalar("##dial", ImGuiDataType_Double, &value, &low, &high, "x %.2f", ImGuiSliderFlags_Logarithmic) && value > 0) trainer_command(menu, callbacks, std::format("dial {} {}", number(value), preset.name)); if (ImGui::IsItemActive()) { p.active = id; @@ -183,6 +184,13 @@ void dial_row(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trai p.active.clear(); } ImGui::SameLine(); + // Any number at all: the slider only covers the sensible range. + double typed = preset.factor; + ImGui::SetNextItemWidth(box); + if (ImGui::InputDouble("##typed", &typed, 0, 0, "%.6g", ImGuiInputTextFlags_EnterReturnsTrue) && typed > 0) + trainer_command(menu, callbacks, std::format("dial {} {}", number(typed), preset.name)); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Type any multiplier and press Enter: the slider's ends are not a limit."); + ImGui::SameLine(); if (preset.active) ImGui::PushStyleColor(ImGuiCol_Button, skate_theme::blue); const bool pressed = ImGui::Button(preset.name.c_str(), ImVec2(button, 0)); if (preset.active) ImGui::PopStyleColor(); @@ -345,19 +353,31 @@ void tune_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, void trick_heights(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const trainer::View &view) { begin_card(menu, "trick-heights", "TRICKS", "1.0 is the game's own"); - const auto slider = [&](const char *label, const char *option, float value, float &edit, bool &editing, float high = 50.0f) { + // `limited`: the game itself stops at the slider's end, so a typed number is held to it too. + const auto slider = [&](const char *label, const char *option, float value, float &edit, bool &editing, float high = 50.0f, bool limited = false) { field(menu, label); ImGui::PushID(option); float shown = editing ? edit : value; - if (ImGui::SliderFloat("##height", &shown, 0.1f, high, "x %.2f", ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_Logarithmic)) { + const float box = px(64); + ImGui::SetNextItemWidth(std::max(px(70), ImGui::GetContentRegionAvail().x - box - ImGui::GetStyle().ItemSpacing.x)); + if (ImGui::SliderFloat("##height", &shown, 0.1f, high, "x %.2f", (limited ? ImGuiSliderFlags_AlwaysClamp : 0) | ImGuiSliderFlags_Logarithmic)) { edit = shown; editing = true; } if (ImGui::IsItemDeactivatedAfterEdit()) trainer_command(menu, callbacks, std::format("option {} {:.2f}", option, edit)); - if (!ImGui::IsItemActive() && editing && std::abs(value - edit) < 0.005f) editing = false; + if (!ImGui::IsItemActive() && editing && std::abs(value - edit) < 0.005f * std::max(1.0f, std::abs(edit))) editing = false; + ImGui::SameLine(); + float typed = value; + ImGui::SetNextItemWidth(box); + if (ImGui::InputFloat("##typed", &typed, 0, 0, "%.6g", ImGuiInputTextFlags_EnterReturnsTrue) && typed > 0) { + editing = false; + trainer_command(menu, callbacks, std::format("option {} {}", option, typed)); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(limited ? "Type a multiplier and press Enter." : "Type any multiplier and press Enter: the slider's end is not a limit."); ImGui::PopID(); }; - slider("Flip trick speed", "flip_speed", view.flip_speed, p.flip_edit, p.flip_editing, 3.0f); + slider("Flip trick speed", "flip_speed", view.flip_speed, p.flip_edit, p.flip_editing, 3.0f, true); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Slows board flips. Above 1 the game's own limit on how fast a board turns takes over."); slider("No comply height", "nocomply_height", view.nocomply_height, p.nocomply_edit, p.nocomply_editing); slider("Boneless height", "boneless_height", view.boneless_height, p.boneless_edit, p.boneless_editing); @@ -367,7 +387,7 @@ void trick_heights(SkateMenu &menu, const CallbacksV3 &callbacks, Page &p, const p.hippy_editing = p.nocomply_editing = p.boneless_editing = p.offboard_editing = p.flip_editing = false; trainer_command(menu, callbacks, "reset tricks"); } - note("Not tuning values: the game sets these in its trick scripts and flip curves. Ctrl+click a slider to type a number."); + note("Not tuning values: the game sets these in its trick scripts and flip curves. Type in the box to go past a slider's end."); end_card(); } From 76330e40bbf1b431ee06a05c8604503f5dd18455 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sun, 4 Oct 2026 13:27:21 -0500 Subject: [PATCH 018/109] Skater slot, menu and throwdown fixes; MY MODS rework; lobby parties Skater slot, menu and throwdown fixes; MY MODS rework; lobby parties Saved skaters (Extension/Customization, Extension/Skater) - The selected slot, and slots before it, came up as the standard skater after a start or map change. The game asks for a slot's saved outfit once per level, and that happened before the cosmetics catalog could be read. The first selection now waits for the catalog (60 s at most), and later map changes check against the catalog already read. - The retry from abdda77 is removed: the record it wrote to is a temporary on the game's stack, so it could never work. Pause menu (Extension/UI/NativeMenu) - ReSkate's entries stopped working after enough map changes: each level change took new slots in a fixed table of 1024 for the same buttons. A button now keeps its slot, and a full table hands out the slot that has gone longest unused. Throwdowns (Extension/Throwdowns, Runtime/runtime_init.cpp) - Multiplayer crashed when someone placed a throwdown after a map change. The event types are freed with the level but were cached for the process. They are now cleared when a level is left, checked before use, and looked for again in each level. MY MODS (Launcher/gui_mods.cpp, gui.cpp) - Search, a filter and an order; a switch per mod; a menu per row. - Multi-select with tick boxes, Shift-click and Ctrl+A, and a bar to enable, disable, move, update or uninstall everything ticked. Lobby parties (Extension/Multiplayer/Session) - A lobby is no longer one party. Its host keeps parties the way a dedicated server does: invite, accept, join, leave, kick, promote, open and close, with "/p" party chat. The "Lobby party" switch is gone. Protocol version unchanged (38). Mod merge (Engine/Vfs), follow-ups to e715b9b - Files rewritten into the merged patch are tracked by position, so another mod's added assets no longer shift the lookup. - Two mods adding different assets under one name is now noted, with whose copy the maps get. - mod_merge_unshift_tests compiles with the launcher tests on. --- CMakeLists.txt | 4 +- Engine/Game/Multiplayer/session_limits.h | 2 +- Engine/Game/Multiplayer/session_model.h | 9 +- .../Vfs/Test/mod_merge_added_assets_tests.cpp | 25 + Engine/Vfs/Test/mod_merge_unshift_tests.cpp | 18 +- Engine/Vfs/mod_merge_combine.cpp | 25 +- Engine/Vfs/mod_merge_overrides.cpp | 26 +- .../local_customization_runtime.cpp | 77 +-- .../local_customization_runtime.h | 28 +- Extension/Multiplayer/Hud/native_party.cpp | 2 +- Extension/Multiplayer/Hud/native_party.h | 4 +- .../Multiplayer/Hud/native_party_hooks.cpp | 2 +- .../Multiplayer/Hud/native_party_requests.cpp | 6 +- Extension/Multiplayer/Net/protocol.h | 16 +- Extension/Multiplayer/Session/party_book.h | 6 +- Extension/Multiplayer/Session/session.cpp | 20 +- .../Multiplayer/Session/session_commands.cpp | 24 +- .../Multiplayer/Session/session_internal.h | 29 +- .../Multiplayer/Session/session_party.cpp | 214 +++++++- .../Multiplayer/Session/session_receive.cpp | 25 +- .../Multiplayer/Session/session_send.cpp | 46 +- .../Multiplayer/Session/session_view.cpp | 5 +- Extension/Multiplayer/Test/mesh_tests.cpp | 64 ++- Extension/Multiplayer/console_commands.cpp | 4 +- Extension/Profile/local_profile_runtime.cpp | 17 +- Extension/Profile/local_profile_runtime.h | 5 + Extension/Skater/skater_slot_override.cpp | 30 +- Extension/Skater/skater_slot_override.h | 6 +- .../Throwdowns/Test/throwdown_relay_tests.cpp | 24 +- Extension/Throwdowns/throwdown_lab.cpp | 47 +- Extension/Throwdowns/throwdown_lab.h | 3 + Extension/Throwdowns/throwdown_relay.cpp | 8 +- Extension/Throwdowns/throwdown_relay.h | 4 +- .../Test/native_menu_lifetime_tests.cpp | 49 +- Extension/UI/NativeMenu/native_menu.cpp | 55 +- .../UI/NativeMenu/native_menu_internal.h | 3 +- .../UI/NativeMenu/native_menu_lifetime.h | 28 + .../UI/NativeMenu/native_menu_multiplayer.cpp | 6 +- Extension/UI/Overlay/multiplayer_menu.cpp | 6 - Launcher/gui.cpp | 29 + Launcher/gui_internal.h | 14 +- Launcher/gui_mods.cpp | 502 +++++++++++++++--- README.md | 3 +- Runtime/client_tick.cpp | 3 +- Runtime/runtime_init.cpp | 2 + cmake/Runtime.cmake | 1 + 46 files changed, 1193 insertions(+), 333 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 70a0b077..5cbefe26 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -148,8 +148,8 @@ 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) diff --git a/Engine/Game/Multiplayer/session_limits.h b/Engine/Game/Multiplayer/session_limits.h index 22acef3d..dd8c4074 100644 --- a/Engine/Game/Multiplayer/session_limits.h +++ b/Engine/Game/Multiplayer/session_limits.h @@ -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); } diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 6dd34720..486272ae 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -105,9 +105,8 @@ struct MultiplayerModel { // 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}; + // Local display preference: the floating name label above each peer. + bool nametags{true}; // Nametag style: ReSkate's own (name, distance, role colours, dots) or the game's. bool custom_nametags{true}; // Local: whether session text chat shows at all (and T opens it). @@ -134,8 +133,8 @@ struct MultiplayerModel { bool password_required{}; int players = 1, capacity = multiplayer_player_limit; std::vector 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 party_invites; // newest last diff --git a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp index 414c0f15..337e86b3 100644 --- a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp +++ b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp @@ -325,6 +325,29 @@ void copy_in_the_adders_toc_is_left_alone() { 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. +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)); + 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)); +} } // namespace int main() try { @@ -333,6 +356,8 @@ int main() try { 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); if (failures) { std::cerr << failures << " failure(s)\n"; return 1; diff --git a/Engine/Vfs/Test/mod_merge_unshift_tests.cpp b/Engine/Vfs/Test/mod_merge_unshift_tests.cpp index f92717c9..e5f65130 100644 --- a/Engine/Vfs/Test/mod_merge_unshift_tests.cpp +++ b/Engine/Vfs/Test/mod_merge_unshift_tests.cpp @@ -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 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"); diff --git a/Engine/Vfs/mod_merge_combine.cpp b/Engine/Vfs/mod_merge_combine.cpp index db4d4129..2c7160e3 100644 --- a/Engine/Vfs/mod_merge_combine.cpp +++ b/Engine/Vfs/mod_merge_combine.cpp @@ -359,9 +359,15 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, 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 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 @@ -369,9 +375,10 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, // positions still hold. for (auto& addition : additions) { const auto ebx = addition.asset.kind == fb::AssetKind::ebx; - const auto at = 1 + casManifest.ebx.size() + (ebx ? 0 : casManifest.resources.size()); + const auto at = static_cast(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() + static_cast(at), addition.file); + 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); @@ -429,13 +436,13 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, entry.base = isBase; 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 diff --git a/Engine/Vfs/mod_merge_overrides.cpp b/Engine/Vfs/mod_merge_overrides.cpp index 17ac1780..17e9adaf 100644 --- a/Engine/Vfs/mod_merge_overrides.cpp +++ b/Engine/Vfs/mod_merge_overrides.cpp @@ -96,13 +96,23 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, ": 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. + // 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::less<>> shadowed; // by the mod kept const auto keep = [&](const std::string& bundle, AssetAddition addition) { auto& list = out.added[bundle]; const auto name = lower(addition.asset.name); - if (std::ranges::any_of(list, [&](const AssetAddition& other) { - return other.asset.kind == addition.asset.kind && lower(other.asset.name) == 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; + } return false; + } list.push_back(std::move(addition)); return true; }; @@ -129,13 +139,19 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, } } for (auto& [bundle, companion] : companions) { - const auto& list = out.added[bundle]; + // 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(list, [&](const AssetAddition& addition) { + 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 (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()) { diff --git a/Extension/Customization/local_customization_runtime.cpp b/Extension/Customization/local_customization_runtime.cpp index f5cb0378..26fb10de 100644 --- a/Extension/Customization/local_customization_runtime.cpp +++ b/Extension/Customization/local_customization_runtime.cpp @@ -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 native; @@ -331,15 +324,14 @@ bool apply_saved_loadout(const std::string& id, void* destination) { profile::CosmeticLoadout defaults; std::vector 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; @@ -393,7 +385,6 @@ bool apply_saved_loadout(const std::string& id, void* destination) { } 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 +396,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(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; } diff --git a/Extension/Customization/local_customization_runtime.h b/Extension/Customization/local_customization_runtime.h index d6744f0a..609794b0 100644 --- a/Extension/Customization/local_customization_runtime.h +++ b/Extension/Customization/local_customization_runtime.h @@ -15,24 +15,10 @@ struct CosmeticRuntime { std::uintptr_t item_manager{}; std::uint32_t item_count{}; std::map> 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 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> 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. @@ -122,11 +108,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); } diff --git a/Extension/Multiplayer/Hud/native_party.cpp b/Extension/Multiplayer/Hud/native_party.cpp index c694f219..930e4fee 100644 --- a/Extension/Multiplayer/Hud/native_party.cpp +++ b/Extension/Multiplayer/Hud/native_party.cpp @@ -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(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(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; diff --git a/Extension/Multiplayer/Hud/native_party.h b/Extension/Multiplayer/Hud/native_party.h index 026b02bd..856e908b 100644 --- a/Extension/Multiplayer/Hud/native_party.h +++ b/Extension/Multiplayer/Hud/native_party.h @@ -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. diff --git a/Extension/Multiplayer/Hud/native_party_hooks.cpp b/Extension/Multiplayer/Hud/native_party_hooks.cpp index eba5afb2..f74c4192 100644 --- a/Extension/Multiplayer/Hud/native_party_hooks.cpp +++ b/Extension/Multiplayer/Hud/native_party_hooks.cpp @@ -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{}; - // 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 || diff --git a/Extension/Multiplayer/Hud/native_party_requests.cpp b/Extension/Multiplayer/Hud/native_party_requests.cpp index d5564df5..a8e538a6 100644 --- a/Extension/Multiplayer/Hud/native_party_requests.cpp +++ b/Extension/Multiplayer/Hud/native_party_requests.cpp @@ -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 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::chrono::duration_cast( 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 result; // id -> leads if (!p) return result; diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 5973f575..39b7f46a 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -64,16 +64,16 @@ 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 }; -// 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,8 +84,8 @@ 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 @@ -122,7 +122,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 diff --git a/Extension/Multiplayer/Session/party_book.h b/Extension/Multiplayer/Session/party_book.h index 955786f1..15a15d3b 100644 --- a/Extension/Multiplayer/Session/party_book.h +++ b/Extension/Multiplayer/Session/party_book.h @@ -4,9 +4,9 @@ #include 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 { diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index ada649d1..9e271026 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -100,6 +100,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; @@ -244,23 +246,21 @@ 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); s.chat_visible = profile_runtime::local_preference("ChatVisible").value_or(true); s.chat_filter = profile_runtime::local_preference("ChatFilter").value_or(true); 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(max_players) - : dedicated_host(s) ? static_cast(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(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 diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index bb127a80..dca0b0bf 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -113,9 +113,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 ." - : "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 ."; + 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 +122,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 verbs[] = { {"invite", PartyAction::invite}, {"accept", PartyAction::accept}, {"decline", PartyAction::decline}, {"join", PartyAction::join}, {"leave", PartyAction::leave}, {"kick", PartyAction::kick}, @@ -307,16 +305,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."; @@ -453,7 +441,7 @@ bool queue_command(std::string_view action, std::string_view argument, std::stri if (launcher::offline_mode()) 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 != "nametags" && action != "nametag-style" && action != "chat-visible" && action != "chat-filter" && 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" && @@ -513,8 +501,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") { @@ -630,7 +618,7 @@ std::string command(std::string_view action, std::string_view argument, std::str {"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}, + {"nametags", edit_nametags}, {"nametag-style", edit_nametag_style}, {"chat-visible", edit_chat_visible}, {"chat-filter", edit_chat_filter}}; for (const auto &[name, edit] : settings) diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 75e1c27e..615452e6 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -10,6 +10,7 @@ #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 "password.h" #include "client_timing.h" @@ -43,7 +44,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 @@ -105,7 +107,7 @@ struct Peer { bool received_voice{}; VoiceBudget voice_budget; ChatRate chat_rate; - ChatBudget throwdown_budget; + 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 scoring; @@ -200,7 +202,7 @@ 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 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 chat_filter = true; // bad words in chat show as **** (Engine/Core/Text/word_filter.h) bool game_menu{}; // a game menu is up or the game's UI is hidden: no chat on screen @@ -257,8 +259,11 @@ struct Session { std::uint64_t object_owners_at{}; // Throwdown messages received since the last relay tick: sender, encoded message. std::vector>> 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 @@ -311,6 +316,8 @@ 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 " 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 chat_commands(const Session &s); // One encoded throwdown message from this player to everyone else (through the host). @@ -352,8 +359,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 &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); diff --git a/Extension/Multiplayer/Session/session_party.cpp b/Extension/Multiplayer/Session/session_party.cpp index 79cb2b4e..759b7f0c 100644 --- a/Extension/Multiplayer/Session/session_party.cpp +++ b/Extension/Multiplayer/Session/session_party.cpp @@ -2,13 +2,61 @@ #include "Extension/Multiplayer/Hud/native_party.h" #include -// 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 ", 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 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 &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 diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index 28f85b8e..ffaecf7d 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -667,15 +667,24 @@ 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 @@ -690,6 +699,11 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { // 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; + // "/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; + } 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); if (s.mode == Mode::host) broadcast(s, p, true, false, now, p.source); @@ -798,6 +812,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); diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index 1bf9cb69..f5392072 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -207,6 +207,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 message) { if ((s.mode != Mode::host && s.mode != Mode::join) || message.empty() || message.size() > max_throwdown_message) return; const auto now = now_us(); @@ -403,18 +426,19 @@ void send_roster(Session &s, std::uint64_t now) { peer.member.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; diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 310185c4..72f29724 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -167,7 +167,6 @@ 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(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(s.host_preferences.capacity), s.host_preferences.tps, s.host_preferences.lobby_name}; @@ -181,7 +180,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 @@ -395,7 +394,7 @@ std::vector chat_commands(const Session &s) { {"/tp", "/tp ", "Teleport beside a player (or /tp )", "player"}, {"/p", "/p ", "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 ", "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"}); diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 828aa8f6..6e4f750a 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -572,6 +572,63 @@ void throwdown_routing_checks() { sim.run(10); check(host.throwdown_inbox.empty() && second.throwdown_inbox.empty(), "A throwdown message from another world was relayed"); } +// A lobby's parties are the ones its players form, kept by the host the way a dedicated +// server keeps them: nobody is in a party for being in the lobby. +void party_checks() { + Simulation sim; + for (unsigned i = 0; i < 4; ++i) sim.add(); + sim.run(60); sim.fresh(4); + auto &host = *sim.nodes[0], &first = *sim.nodes[1], &second = *sim.nodes[2], &third = *sim.nodes[3]; + const auto id = [](const Session &s) { return s.transport.status().local_id; }; + for (const auto &node : sim.nodes) + check(!node->local_party && node->party_invites.empty(), "A lobby put a player in a party nobody formed"); + + check(send_party_request(first, PartyAction::invite, id(second)).empty(), "A guest's party invite was not sent"); + sim.run(20); + check(second.party_invites.size() == 1 && second.party_invites.front().from == id(first), + "The host did not pass a guest's invite on to the invited guest"); + check(!first.local_party && !second.local_party, "An unanswered invite made a party"); + check(send_party_request(second, PartyAction::accept, id(first)).empty(), "Accepting an invite was not sent"); + sim.run(40); + check(first.local_party && first.local_party == second.local_party && first.local_party_leader && !second.local_party_leader, + "An accepted invite did not put both guests in one party led by the inviter"); + check(party_member(first, id(second)) && party_member(second, id(first)), "Party members do not see each other as members"); + check(!host.local_party && !third.local_party && !party_member(third, id(first)) && party_of(third, id(first)) == first.local_party, + "Players outside a party were put in it, or cannot see who is in it"); + + // The host's own requests are answered on the spot, and make a second party. + check(send_party_request(host, PartyAction::invite, id(third)).empty(), "The host's own party invite was refused"); + sim.run(20); + check(third.party_invites.size() == 1 && third.party_invites.front().from == id(host), "The host's invite did not reach the guest"); + check(send_party_request(third, PartyAction::accept, id(host)).empty(), "Accepting the host's invite was not sent"); + sim.run(40); + check(host.local_party && host.local_party == third.local_party && host.local_party_leader && host.local_party != first.local_party, + "The host and its guest are not in a party of their own"); + check(party_of(first, id(third)) == host.local_party && !party_member(first, id(third)), "A guest does not see the other party"); + + // Party chat reaches the party and nobody else. + const auto heard = [](const Session &s, std::string_view words) { + return std::any_of(s.chat.begin(), s.chat.end(), [&](const auto &line) { return line.text.find(words) != std::string::npos; }); + }; + check(send_party_chat(first, "meet at the bowl").empty(), "A guest's party chat was refused"); + sim.run(20); + check(heard(first, "meet at the bowl") && heard(second, "meet at the bowl"), "Party chat did not reach the party"); + check(!heard(host, "meet at the bowl") && !heard(third, "meet at the bowl"), "Party chat reached players outside the party"); + check(send_party_chat(host, "host party only").empty(), "The host's party chat was refused"); + sim.run(20); + check(heard(third, "host party only") && !heard(first, "host party only") && !heard(second, "host party only"), + "The host's party chat did not stay in its party"); + + // Leaving: a party left with one member is no party. + check(send_party_request(second, PartyAction::leave, 0).empty(), "Leaving a party was not sent"); + sim.run(40); + check(!first.local_party && !second.local_party, "A party of one was kept"); + check(send_party_chat(first, "anyone").starts_with("You're not in a party"), "Party chat was sent without a party"); + // A guest leaving the lobby is out of their party as well. + stop(third, "Left"); sim.run(40); + check(!host.local_party && host.parties.parties().empty(), "A guest who left the lobby stayed in the host's party"); + std::cout << "Lobby parties: nobody by default, invites, two parties, party chat, leaving and departures passed.\n"; +} // A lobby host keeps a guest whose mods change scoring or physics out of linked activities: the roster // flags them for everyone and the host passes none of their throwdown messages on. void scoring_checks() { @@ -1214,7 +1271,7 @@ void session_controls_checks() { p.object_placement = host.object_placement; p.members.push_back({p.source, host.epoch, "Host"}); for (const auto &peer : host.peers) if (peer.handshaken) p.members.push_back(peer.member); - // The lobby party, as the host's own roster sends it. + // Everyone in one party led by the host, as a roster may list them. for (auto &m : p.members) { m.party = p.members.size() > 1 ? lobby_party : 0; m.party_leader = m.party && m.id == p.source; @@ -1414,7 +1471,7 @@ void world_layer_sync_checks() { p.layers = pack_world_layers(host.layers); p.members.push_back({p.source, host.epoch, "Host"}); for (const auto &peer : host.peers) if (peer.handshaken) p.members.push_back(peer.member); - // The lobby party, as the host's own roster sends it. + // Everyone in one party led by the host, as a roster may list them. for (auto &m : p.members) { m.party = p.members.size() > 1 ? lobby_party : 0; m.party_leader = m.party && m.id == p.source; @@ -1498,7 +1555,7 @@ void distance_settings_checks() { p.distances = host.distances; p.capacity = host.capacity; p.members.push_back({p.source, host.epoch, "Host"}); for (const auto &peer : host.peers) if (peer.handshaken) p.members.push_back(peer.member); - // The lobby party, as the host's own roster sends it. + // Everyone in one party led by the host, as a roster may list them. for (auto &m : p.members) { m.party = p.members.size() > 1 ? lobby_party : 0; m.party_leader = m.party && m.id == p.source; @@ -1794,6 +1851,7 @@ int main(int argc, char **argv) { if (argc == 2 && std::string_view(argv[1]) == "--travel-only") return 0; dingosdk::multiplayer::mesh_checks(); dingosdk::multiplayer::throwdown_routing_checks(); + dingosdk::multiplayer::party_checks(); dingosdk::multiplayer::scoring_checks(); dingosdk::multiplayer::object_sync_checks(); dingosdk::multiplayer::session_controls_checks(); diff --git a/Extension/Multiplayer/console_commands.cpp b/Extension/Multiplayer/console_commands.cpp index 8a669a12..896a1715 100644 --- a/Extension/Multiplayer/console_commands.cpp +++ b/Extension/Multiplayer/console_commands.cpp @@ -26,7 +26,7 @@ void register_multiplayer_commands(Commands ®istry) { Command{"chat", "Send a text message to everyone in the session (T opens the chat box)"}, Command{"server", "Send a command to the dedicated server you are an admin of (try: help)"}, Command{"tp", "Teleport to a player (name start) or to x y z"}, - Command{"party", "Your party on a dedicated server: invite|join|kick|promote , " + Command{"party", "Your party in a session: invite|join|kick|promote , " "accept|decline [player], leave, open, close, status"}, Command{"tpall", "Host or server admin: teleport everyone to you"}, Command{"tphere", "Host or server admin: teleport one player to you"}, @@ -74,7 +74,7 @@ void register_multiplayer_commands(Commands ®istry) { auto markers_choice = argument("on|off", Type::text, true); auto markers = action("mp map-markers", "Named, selectable markers for your party members on the pause map, like the live game's " - "(default; a lobby is one party). Off, or players outside your party: the game's plain player dots", + "(default). Off, or players outside your party: the game's plain player dots", Group::gameplay, {std::move(markers_choice)}); markers.run = [](const Model &, const Values &values, const Output &out) { const auto &choice = std::get(values[0]); diff --git a/Extension/Profile/local_profile_runtime.cpp b/Extension/Profile/local_profile_runtime.cpp index bdc3a211..e7596edd 100644 --- a/Extension/Profile/local_profile_runtime.cpp +++ b/Extension/Profile/local_profile_runtime.cpp @@ -303,10 +303,6 @@ void update_local_customization() noexcept { if (!c.catalog_failed && refresh_cosmetic_catalog()) { publish_cosmetic_catalog(); publish_cosmetic_inventory(); - // Outfits the game asked for before the catalog was ready: it does - // not ask again, so they are applied here instead of the player - // finding a standard skater that will not save over itself. - retry_pending_cosmetic_loads(); } } catch (...) { cosmetic_runtime().catalog_failed = true; @@ -357,6 +353,19 @@ std::uint32_t local_customization_selected_preset() noexcept { if (!s.active.load(std::memory_order_acquire)) return 0; try { return s.store->selected_cosmetic_preset(); } catch (...) { return 0; } } +bool local_customization_outfits_loadable() noexcept { + PreserveError preserve; + auto& s = local_runtime(); + if (!s.active.load(std::memory_order_acquire)) return true; + std::lock_guard lock(s.native_mutex); + try { + if (!cosmetic_runtime().items.empty() || refresh_cosmetic_catalog()) return true; + // Nothing saved is nothing to check: a new profile's slots are built + // from the game's own defaults and need no catalog. + const auto snapshot = s.store->shared_snapshot(); + return snapshot->customization.value("loadouts", dingosdk::Json::object()).empty(); + } catch (...) { return true; } +} void observe_local_customization_selection(std::int32_t index) noexcept { PreserveError preserve; auto& s = local_runtime(); diff --git a/Extension/Profile/local_profile_runtime.h b/Extension/Profile/local_profile_runtime.h index 8f617cbb..17586388 100644 --- a/Extension/Profile/local_profile_runtime.h +++ b/Extension/Profile/local_profile_runtime.h @@ -34,6 +34,11 @@ void local_profile_before_level_transition(unsigned next) noexcept; using LocalLocationTravelQueue = bool (*)(const char* map); void set_local_location_travel_queue(LocalLocationTravelQueue) noexcept; std::uint32_t local_customization_selected_preset() noexcept; +// Whether a saved outfit can be loaded yet: a cosmetics catalog has been read +// this session to check it against, or nothing is saved. The game builds every +// slot up to the selected one when a slot is selected and does not ask again, +// so the first selection waits for this. +bool local_customization_outfits_loadable() noexcept; void observe_local_customization_selection(std::int32_t index) noexcept; struct LocalMissionRow { std::string id, group; diff --git a/Extension/Skater/skater_slot_override.cpp b/Extension/Skater/skater_slot_override.cpp index c9ce32e0..f47414b6 100644 --- a/Extension/Skater/skater_slot_override.cpp +++ b/Extension/Skater/skater_slot_override.cpp @@ -25,6 +25,8 @@ constexpr std::uintptr_t slot_highest = memory::highest_user_address; constexpr std::uint32_t slot_target_count = 10; constexpr std::uint32_t free_slot_count = 3; constexpr std::size_t slot_entry_size = 0x48; +// How long the first selection waits for saved outfits to become loadable. +constexpr std::uint64_t selection_wait_limit_ms = 60000; using SlotSettingsLookup = std::uintptr_t (*)(std::uintptr_t, const void*); using SlotNativeResize = void (*)(std::uintptr_t, std::uint32_t); @@ -146,6 +148,9 @@ struct SlotOverrideState { bool active{}; bool requested{}; std::array leases{}; + // The manager whose first selection is waiting on saved outfits, and since when. + std::uintptr_t waiting_manager{}; + std::uint64_t waiting_since{}; SkaterSlotOverrideObservation observation; }; @@ -453,7 +458,7 @@ void maintain_slot_count(SlotOverrideState& state) { state.observation.status = "Restored ten process-local skater preset slots."; } -void maintain_initial_selection(SlotOverrideState& state, std::uint32_t preferred) { +void maintain_initial_selection(SlotOverrideState& state, std::uint32_t preferred, bool outfits_loadable) { auto& observation = state.observation; observation.initial_binding_ready = observation.initial_appearance_ready = false; observation.initial_category_count = 0; @@ -471,6 +476,23 @@ void maintain_initial_selection(SlotOverrideState& state, std::uint32_t preferre memory::read(categories - 4, category_count)) observation.initial_category_count = category_count & 0x7fffffff; if (!observation.initial_appearance_ready || !observation.initial_category_count || observation.initial_category_count > 64) return; + // Selecting a slot makes the game build every slot up to it there and then, + // each from its saved outfit or, when that cannot be loaded, as a standard + // skater; it does not ask again until the next level. So the selection + // waits until saved outfits can be loaded. A catalog that never turns up + // must not leave the player with no slot at all, hence the limit. + if (!outfits_loadable) { + const auto now = GetTickCount64(); + if (state.waiting_manager != manager.manager) { + state.waiting_manager = manager.manager; + state.waiting_since = now; + } + if (now - state.waiting_since < selection_wait_limit_ms) { + observation.status = "Waiting for the cosmetics catalog before restoring the saved skater."; + return; + } + } + state.waiting_manager = 0; // the next level's manager can be given this one's address // The first native selection restores the requested saved preset, or slot 0. // It constructs any missing slots from the loaded appearance // categories. With previous index -1, it does not save a previous preset or @@ -550,6 +572,8 @@ bool initialize_skater_slot_override( state.active = authored_offline_route_active && validate_slot_image(image_base); state.requested = false; state.leases = {}; + state.waiting_manager = 0; + state.waiting_since = 0; state.observation = {}; state.observation.initialized = true; state.observation.available = state.active; @@ -564,7 +588,7 @@ bool initialize_skater_slot_override( } SkaterSlotOverrideObservation update_skater_slot_override( - SkaterSlotOverrideAction action, std::uint32_t initial_preset) noexcept { + SkaterSlotOverrideAction action, std::uint32_t initial_preset, bool outfits_loadable) noexcept { SlotLastErrorScope preserve_error; auto& state = slot_override_state(); std::scoped_lock lock(state.mutex); @@ -591,7 +615,7 @@ SkaterSlotOverrideObservation update_skater_slot_override( } else if (state.requested) { apply_slot_settings(state); maintain_slot_count(state); - maintain_initial_selection(state, initial_preset); + maintain_initial_selection(state, initial_preset, outfits_loadable); } } catch (const SlotFailure& failure) { ++state.observation.rejected; diff --git a/Extension/Skater/skater_slot_override.h b/Extension/Skater/skater_slot_override.h index 56fbea93..00c39d9b 100644 --- a/Extension/Skater/skater_slot_override.h +++ b/Extension/Skater/skater_slot_override.h @@ -42,10 +42,12 @@ bool initialize_skater_slot_override( // Call from one game update thread. Once enabled, tick reapplies the reversible // settings leases and asks the game's own loadout manager to restore ten slots -// if an inventory/profile response reduces the process-local count. +// if an inventory/profile response reduces the process-local count. The first +// selection after a level load restores `initial_preset`, once +// `outfits_loadable` says the slots it builds can take their saved outfits. SkaterSlotOverrideObservation update_skater_slot_override( SkaterSlotOverrideAction action = SkaterSlotOverrideAction::tick, - std::uint32_t initial_preset = 0) noexcept; + std::uint32_t initial_preset = 0, bool outfits_loadable = true) noexcept; SkaterSlotOverrideObservation skater_slot_override_observation(); } diff --git a/Extension/Throwdowns/Test/throwdown_relay_tests.cpp b/Extension/Throwdowns/Test/throwdown_relay_tests.cpp index ca5443df..da28c45e 100644 --- a/Extension/Throwdowns/Test/throwdown_relay_tests.cpp +++ b/Extension/Throwdowns/Test/throwdown_relay_tests.cpp @@ -24,6 +24,7 @@ struct Call { std::vector calls; std::map settings_changed; bool leaderboard_live = true; +bool types_ready = true; // stands in for the event types of the level being found std::uint64_t last_token{}; } @@ -37,7 +38,7 @@ namespace dingosdk::multiplayer { std::uint32_t local_native_player_id() noexcept { return 2; } std::uint64_t native_party_player_info(std::uintptr_t, std::size_t) noexcept { return 0; } void spectate_party_member(std::uint64_t) noexcept {} -bool prepare_throwdown_injection() noexcept { calls.push_back({"prepare"}); return true; } +bool prepare_throwdown_injection() noexcept { calls.push_back({"prepare"}); return types_ready; } bool queue_throwdown_spawn(std::uint64_t token, std::uint32_t host, const std::string &series, const std::vector &, const std::vector &) noexcept { last_token = token; @@ -759,6 +760,24 @@ void beacon_flow() { check(out.size() == 1 && out[0].kind == Kind::beacon && !out[0].add, "The local beacon's removal was not relayed"); end_session(); } +// A level change takes the event types with it. The relay asks for them again instead of +// remembering that it once had them, and shows a drop only when they are back. +void level_change_flow() { + using namespace std::chrono_literals; + const ThrowdownRelayInput in{me, {{leader, 3}}, true, 77}; + tick(in); + types_ready = false; + std::this_thread::sleep_for(300ms); // past the relay's quarter-second re-check + calls.clear(); + receive(leader, offer(leader, 0x4321)); + tick(in); + check(count("prepare") == 1 && !find("spawn"), "A drop was shown while the level's event types were not found"); + types_ready = true; + std::this_thread::sleep_for(300ms); + tick(in); + check(count("spawn") == 1, "The drop was not shown once the event types were found again"); + end_session(); +} } // namespace int main() { @@ -771,7 +790,8 @@ int main() { alone_flow(); challenge_flow(); beacon_flow(); - std::cout << "Throwdown relay: guest, leader, removal, Spot Battle, S.K.A.T.E., last-player, coop challenge and party beacon flows passed.\n"; + level_change_flow(); + std::cout << "Throwdown relay: guest, leader, removal, Spot Battle, S.K.A.T.E., last-player, coop challenge, party beacon and level change flows passed.\n"; return 0; } catch (const std::exception &e) { std::cerr << e.what() << '\n'; diff --git a/Extension/Throwdowns/throwdown_lab.cpp b/Extension/Throwdowns/throwdown_lab.cpp index 8a91bf01..df6b0bc2 100644 --- a/Extension/Throwdowns/throwdown_lab.cpp +++ b/Extension/Throwdowns/throwdown_lab.cpp @@ -9,6 +9,7 @@ #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/engine.h" #include "Engine/Game/Build/20260929/native_throwdowns.h" +#include "Engine/Game/Build/supported_build.h" #include "Engine/Game/Multiplayer/session_tools.h" #include "native_throwdowns.h" #include "throwdown_relay.h" @@ -202,6 +203,9 @@ struct Lab { std::vector host_params; // ... and of its HostSetThrowdownParameters (the host's settings) std::string params_note; std::map types; + // Bumped when a level is left. The types above belong to the level; a heap scan begun + // before it was left found ones that are gone by the time it reports them. + std::atomic world{}; std::atomic type_scan_running{}; // prepare_throwdown_injection's worker std::atomic type_scan_retry{}; // after a scan that missed some: not before this std::atomic beacon_scan_retry{}; // the same for the party beacon events @@ -1020,10 +1024,31 @@ struct Instance { reinterpret_cast(lab().base + throwdowns::send_event)(0, 0, arguments.data()); } }; +// Whether `object` is the type object of the event `hash` right now. An event's type is +// defined by data the level's bundles carry: it is freed when the level is left and made +// again, anywhere, by the next one, and the memory the old one was in soon holds something +// else. The game constructs an event by calling through its type record (type_construct +// calls record+0x30), so a record that no longer names the event, or whose constructor is +// not code, must never be handed to it. +bool type_alive(Address object, std::uint32_t hash) { + Address record{}, construct{}; + std::uint32_t name{}; + if (!memory::peek(object, record) || !memory::peek(record, name) || name != hash || + !memory::peek(record + 0x30, construct)) return false; + const auto base = lab().base; + if (construct >= base && construct - base < supported_build::game_image_size) return true; + MEMORY_BASIC_INFORMATION info{}; + return VirtualQuery(reinterpret_cast(construct), &info, sizeof(info)) && info.State == MEM_COMMIT && + !(info.Protect & PAGE_GUARD) && + (info.Protect & (PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY)); +} Address type_of(std::uint32_t hash) { std::lock_guard lock(lab().mutex); const auto it = lab().types.find(hash); - return it == lab().types.end() ? 0 : it->second; + if (it == lab().types.end()) return 0; + if (type_alive(it->second, hash)) return it->second; + lab().types.erase(it); // gone with the level it was found in; the next scan finds this level's + return 0; } std::string run_job(const Job& job) { auto& l = lab(); @@ -1326,13 +1351,15 @@ void remember_player(std::uint32_t id) { if (!current && slot.compare_exchange_strong(current, id)) return; } } -// Type objects of the injected events; found on the heap once and cached. +// Type objects of the injected events; found on the heap and kept until the level is left. std::string ensure_types(std::initializer_list hashes) { std::vector queries; for (const auto hash : hashes) if (!type_of(hash)) queries.push_back({hash, 0}); if (queries.empty()) return {}; + const auto world = lab().world.load(std::memory_order_acquire); const auto missing = find_native_types(queries); std::lock_guard lock(lab().mutex); + if (lab().world.load(std::memory_order_acquire) != world) return "the level changed while the event types were looked for"; for (const auto& q : queries) if (q.object) lab().types[q.hash] = q.object; return missing ? std::format("{} event type(s) not found on the heap; is the game in the world?", missing) : std::string{}; } @@ -1893,6 +1920,18 @@ void pump_throwdown_lab(Address vm) noexcept { } } +void throwdown_lab_before_level_transition(unsigned next) noexcept { + // 14, 22 and 3 leave a level or sublevel, 24 shuts down: the level's bundles are unloaded, + // and the events' types with them. + if (next != 14 && next != 22 && next != 3 && next != 24) return; + try { + auto& l = lab(); + std::lock_guard lock(l.mutex); + l.world.fetch_add(1, std::memory_order_acq_rel); + l.types.clear(); + } catch (...) {} +} + bool throwdown_lab_player(std::uint32_t player_id) noexcept { if (!player_id) return false; for (const auto& slot : lab().players) if (slot.load(std::memory_order_relaxed) == player_id) return true; @@ -1903,8 +1942,8 @@ namespace { constexpr std::array relay_events{event_debug_spawn, event_add_ai, event_force_start, event_destroy, event_remove_participant, event_player_score, event_request_turn_end, event_skate_submit, event_force_destroy}; -// The beacon events' types, looked for once in the background (only the local player's own -// beacon would otherwise show them); retried while the world is still loading them. +// The beacon events' types, looked for in the background in each level (only the local player's +// own beacon would otherwise show them); retried while the world is still loading them. bool beacon_types_ready() { auto& l = lab(); if (type_of(event_beacon_move) && type_of(event_beacon_despawn)) return true; diff --git a/Extension/Throwdowns/throwdown_lab.h b/Extension/Throwdowns/throwdown_lab.h index 8a23a59d..7d644637 100644 --- a/Extension/Throwdowns/throwdown_lab.h +++ b/Extension/Throwdowns/throwdown_lab.h @@ -17,6 +17,9 @@ void initialize_throwdown_lab(std::uintptr_t base) noexcept; // Called after every authored expression (vm = the expression that just ran); sends // queued events from the client realm and reads results off server graphs. void pump_throwdown_lab(std::uintptr_t vm) noexcept; +// Before the game changes level state to `next`: forgets the event types found in a level that +// is being left, which are freed with it. +void throwdown_lab_before_level_transition(unsigned next) noexcept; // Called by the SendNetworkedEvent hook for every event the local client sends. void observe_throwdown_send(std::uint32_t hash, std::uintptr_t type, std::uintptr_t payload) noexcept; // True for ids the lab put into a throwdown; the UI player lookups answer them with diff --git a/Extension/Throwdowns/throwdown_relay.cpp b/Extension/Throwdowns/throwdown_relay.cpp index 3760b86f..22e7be9e 100644 --- a/Extension/Throwdowns/throwdown_relay.cpp +++ b/Extension/Throwdowns/throwdown_relay.cpp @@ -1173,9 +1173,11 @@ void apply_queue_timer(Relay &r, bool linked, std::uint64_t now) { void maintain(Relay &r, std::uint64_t now) { if (!r.in_world) return; - // The event types are found once per process by a background heap scan, started as soon - // as the player is in a world with others so a drop never waits for it. - if (!r.prepared && now >= r.next_prepare) { + // The event types belong to the level: each one's are found by a background heap scan, + // started as soon as the player is in a world with others so a drop never waits for it. + // Asked again four times a second rather than remembered, since a level change (in a + // session or out of one) leaves them to be found again. + if (now >= r.next_prepare) { r.next_prepare = now + 250; r.prepared = prepare_throwdown_injection(); } diff --git a/Extension/Throwdowns/throwdown_relay.h b/Extension/Throwdowns/throwdown_relay.h index ebada35c..d28caca6 100644 --- a/Extension/Throwdowns/throwdown_relay.h +++ b/Extension/Throwdowns/throwdown_relay.h @@ -22,8 +22,8 @@ struct ThrowdownPeer { std::size_t slot{}; // session slot on this machine std::string name; // as the session shows it bool nearby{}; // close enough to the local skater to be invited into a coop challenge - // In the local player's party (a lobby is one party): only party members are invited into - // each other's coop challenges. + // In the local player's party: only party members are invited into each other's coop + // challenges. bool party{}; }; struct ThrowdownRelayInput { diff --git a/Extension/UI/NativeMenu/Test/native_menu_lifetime_tests.cpp b/Extension/UI/NativeMenu/Test/native_menu_lifetime_tests.cpp index 670a934e..18defa18 100644 --- a/Extension/UI/NativeMenu/Test/native_menu_lifetime_tests.cpp +++ b/Extension/UI/NativeMenu/Test/native_menu_lifetime_tests.cpp @@ -3,9 +3,13 @@ #include #include #include +#include +#include using dingosdk::multiplayer::menu_data::OwnedMenuModels; using dingosdk::multiplayer::menu_data::MenuLifetime; +using dingosdk::multiplayer::menu_data::MenuAction; +using dingosdk::multiplayer::menu_data::action_slot; struct Model { unsigned handle, type; }; void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); } @@ -136,10 +140,51 @@ void native_transition_without_load_request() { check(lifetime.blocked() && destroyed == 4, "Shutdown during loading must not release roots twice"); } } +void action_slots() { + // A page's buttons, rendered the way the menu does it: every button asks for + // its command's slot on every pass, and a map load starts a new generation. + constexpr std::size_t capacity = 8; + std::array actions; + std::size_t used{}; + std::uint64_t pass{}, generation{}; + const auto ask = [&](std::string command, std::string argument = {}) { + const auto slot = action_slot(used, capacity, [&](std::size_t i) -> const MenuAction& { return actions[i]; }, + command, argument, pass); + if (!slot) return capacity; + actions[*slot] = {generation, std::move(command), std::move(argument), pass}; + if (*slot == used) ++used; + return *slot; + }; + const auto render = [&](std::initializer_list buttons) { + ++pass; + std::vector slots; + for (const auto* button : buttons) slots.push_back(ask("load", button)); + return slots; + }; + const auto first = render({"a", "b", "c", "d", "e", "f"}); + check(used == 6 && first == std::vector{0, 1, 2, 3, 4, 5}, "Each command takes the next unused slot"); + // Fifteen map loads filled the table when every generation took new slots. + for (unsigned load = 0; load < 100; ++load) { + ++generation; + check(render({"a", "b", "c", "d", "e", "f"}) == first && used == 6, + "The same buttons after a map load keep their slots"); + check(actions[0].generation == generation, "A kept slot belongs to the menu that is up now"); + } + // A list whose rows change (lobbies, players): the slots of rows that went away are reused. + check(render({"a", "b", "c", "d", "e", "f", "g", "h"}).back() == 7 && used == capacity, "The table can fill"); + check(render({"a", "b", "c", "d", "e", "f", "g", "h"}).back() == 7, "A full table still serves the commands it holds"); + check(ask("load", "i") == capacity, "A slot a button asked for in this pass or the last is not given away"); + render({"a", "b", "c", "d", "e", "f"}); + check(ask("load", "i") == capacity, "One pass without its button is not enough: the pass may not be over"); + const auto later = render({"a", "b", "c", "d", "e", "f", "i"}); + check(later.back() == 6 && actions[6].argument == "i", "A slot no button has asked for is handed to a new command"); + check(render({"a", "b", "c", "d", "e", "f", "i", "g"}).back() == 7 && actions[7].argument == "g", + "A command that lost its slot gets another when its button returns"); +} int main() { try { - map_transition(); interrupted_cleanup(); window_close(); native_transition_without_load_request(); - std::puts("Native menu lifetime: scheduled/native transitions, blocked callbacks, reload, shutdown, partial builds and retries passed."); + map_transition(); interrupted_cleanup(); window_close(); native_transition_without_load_request(); action_slots(); + std::puts("Native menu lifetime: scheduled/native transitions, blocked callbacks, reload, shutdown, partial builds, retries and action slots passed."); return 0; } catch (const std::exception& e) { std::fprintf(stderr, "%s\n", e.what()); return 1; diff --git a/Extension/UI/NativeMenu/native_menu.cpp b/Extension/UI/NativeMenu/native_menu.cpp index 38092511..2b25d128 100644 --- a/Extension/UI/NativeMenu/native_menu.cpp +++ b/Extension/UI/NativeMenu/native_menu.cpp @@ -40,10 +40,11 @@ template void activate() noexcept { if (lifetime.blocked()) return; auto& s = page_state(Slot); if (Index >= s.action_count.load(std::memory_order_acquire)) return; - const auto& action = s.actions[Index].action; - if (action.generation != s.generation.load(std::memory_order_acquire)) return; try { + // Under the lock: a render pass can hand this slot to another command. std::lock_guard lock(s.mutex); + const auto& action = s.actions[Index].action; + if (action.generation != s.generation.load(std::memory_order_acquire)) return; if (s.pending.size() < 8) s.pending.push_back(action); } catch (...) { } } @@ -61,27 +62,36 @@ Address action(const Context& context, std::string command, std::string argument auto& s = state(); const auto generation = s.generation.load(); const auto count = s.action_count.load(); - for (unsigned i = 0; i < count; ++i) { - const auto& a = s.actions[i].action; - if (a.generation == generation && a.command == command && a.argument == argument) - return reinterpret_cast
(&s.actions[i].info); + const auto slot = action_slot(count, max_actions, [&](std::size_t i) -> const Action& { return s.actions[i].action; }, + command, argument, s.pass); + require(slot.has_value(), "Native menu action limit reached; restart to refresh the menu."); + auto& result = s.actions[*slot]; + const bool unused = *slot == count; + if (unused) { + static const std::array functions{callbacks<0>(std::make_index_sequence{}), + callbacks<1>(std::make_index_sequence{})}; + // Native void-action descriptor used by this build. Only the invokers are + // replaced; it is never added to the game's global function registry. + const auto prototype = context.base + addr::native_menu::action_prototype; + require(read
(prototype + 0x40) == context.base + addr::native_menu::action_invoker && + read(prototype + 0x68) == 0, + "Native menu action ABI differs."); + require(memory::read_bytes(prototype, result.descriptor.data(), result.descriptor.size()), "Native menu action template is unavailable."); + const auto function = reinterpret_cast
(functions[s.slot][count]); + for (const auto offset : {0x30, 0x40, 0x48}) std::memcpy(result.descriptor.data() + offset, &function, sizeof(function)); + result.info = reinterpret_cast
(result.descriptor.data()); } - require(count < max_actions, "Native menu action limit reached; restart to refresh the menu."); - static const std::array functions{callbacks<0>(std::make_index_sequence{}), - callbacks<1>(std::make_index_sequence{})}; - auto& result = s.actions[count]; - // Native void-action descriptor used by this build. Only the invokers are - // replaced; it is never added to the game's global function registry. - const auto prototype = context.base + addr::native_menu::action_prototype; - require(read
(prototype + 0x40) == context.base + addr::native_menu::action_invoker && - read(prototype + 0x68) == 0, - "Native menu action ABI differs."); - require(memory::read_bytes(prototype, result.descriptor.data(), result.descriptor.size()), "Native menu action template is unavailable."); - const auto function = reinterpret_cast
(functions[s.slot][count]); - for (const auto offset : {0x30, 0x40, 0x48}) std::memcpy(result.descriptor.data() + offset, &function, sizeof(function)); - result.info = reinterpret_cast
(result.descriptor.data()); - result.action = {generation, std::move(command), std::move(argument)}; - s.action_count.store(count + 1, std::memory_order_release); + { + // activate() reads the slot on the game's own thread. + std::lock_guard lock(s.mutex); + if (unused || result.action.command != command || result.action.argument != argument) { + result.action.command = std::move(command); + result.action.argument = std::move(argument); + } + result.action.generation = generation; + result.action.pass = s.pass; + } + if (unused) s.action_count.store(count + 1, std::memory_order_release); return reinterpret_cast
(&result.info); } Address blueprint(std::string_view name) { @@ -104,6 +114,7 @@ Value make(const Context& context, Schema schema) { namespace { void render(const Context& context, const MultiplayerModel& model) { auto& s = state(); + ++s.pass; const auto publish = [&](const std::vector& rows, Value list, unsigned slot, float width) { s.row_width = width; std::vector visible; diff --git a/Extension/UI/NativeMenu/native_menu_internal.h b/Extension/UI/NativeMenu/native_menu_internal.h index 2ce2b5cd..2659565f 100644 --- a/Extension/UI/NativeMenu/native_menu_internal.h +++ b/Extension/UI/NativeMenu/native_menu_internal.h @@ -35,7 +35,7 @@ constexpr const char* anchored_widget = "UI/Foundations/Templates/Layouts/Anchor constexpr const char* horizontal_widget = "UI/Foundations/Templates/Layouts/Partitions/Horizontal/HorizontalPartition_LinearFocus_Widget"; constexpr const char* vertical_widget = "UI/Foundations/Templates/Layouts/Partitions/Vertical/VerticalPartition_LinearFocus_Widget"; enum class RowKind { button, input, text }; -struct Action { std::uint64_t generation{}; std::string command, argument; }; +using Action = menu_data::MenuAction; struct NativeAction { // An unbound native FunctionTypeInfo reference. The native delegate copier // borrows untagged references, so descriptors live for the process lifetime. @@ -57,6 +57,7 @@ struct State { std::array actions; std::atomic action_count{}; std::atomic generation{}; + std::uint64_t pass{}; // render passes so far; see menu_data::action_slot Address base{}, manager{}; Value core, page, list, menu_item; OwnedMenuModels owned_models; diff --git a/Extension/UI/NativeMenu/native_menu_lifetime.h b/Extension/UI/NativeMenu/native_menu_lifetime.h index 1d243ccb..2b4a3588 100644 --- a/Extension/UI/NativeMenu/native_menu_lifetime.h +++ b/Extension/UI/NativeMenu/native_menu_lifetime.h @@ -1,6 +1,11 @@ #pragma once #include #include +#include +#include +#include +#include +#include #include namespace dingosdk::multiplayer::menu_data { @@ -47,4 +52,27 @@ public: // Use only after the native manager itself has been replaced. void manager_replaced() { models_.clear(); } }; + +// A command one of a page's native buttons runs: the pause-menu generation it was +// last handed out in, and the render pass that last asked for it. +struct MenuAction { std::uint64_t generation{}; std::string command, argument; std::uint64_t pass{}; }; + +// The slot a button gets for a command. A slot is a native callback whose +// descriptor the game borrows for the rest of the process, so a page has a +// fixed number of them and they are reused, never added to: a command keeps +// the slot it has, whichever generation of the menu gave it out (every map +// load starts a new one); a new command takes the next unused slot; and with +// none left, the slot asked for longest ago. Every button asks on every render +// pass, so a slot not asked for in this pass or the one before is on no button. +// Empty when every slot is on a button. +template std::optional action_slot(std::size_t used, std::size_t capacity, At&& at, + std::string_view command, std::string_view argument, std::uint64_t pass) { + std::optional idle; + for (std::size_t i = 0; i < used; ++i) { + const MenuAction& action = at(i); + if (action.command == command && action.argument == argument) return i; + if (action.pass + 1 < pass && (!idle || action.pass < at(*idle).pass)) idle = i; + } + return used < capacity ? used : idle; +} } // namespace dingosdk::multiplayer::menu_data diff --git a/Extension/UI/NativeMenu/native_menu_multiplayer.cpp b/Extension/UI/NativeMenu/native_menu_multiplayer.cpp index 83a0263d..ec94eaaf 100644 --- a/Extension/UI/NativeMenu/native_menu_multiplayer.cpp +++ b/Extension/UI/NativeMenu/native_menu_multiplayer.cpp @@ -322,7 +322,7 @@ void render_section(const Context& context, const MultiplayerModel& model, Secti for (const auto& player : model.roster) { if (player.id == model.local_id || !player.connected) continue; const auto id = std::to_string(player.id); - // On a dedicated server, your party members are marked (a lobby is one party anyway). + // Your party members are marked. const std::string party = !model.parties || !player.party_member ? "" : player.party_leader ? " / PARTY LEADER" : " / PARTY"; add_button(context, main, "player-" + id, menu_view::caption(player.name) + @@ -358,7 +358,7 @@ void render_section(const Context& context, const MultiplayerModel& model, Secti add_text(context, side, "invite", model.invite, 80.f); add_text(context, side, "session-tps", "Tick rate: " + std::to_string(model.tps), 48.f); if (model.parties) { - // Parties on a dedicated server: yours, and the invites waiting for an answer. + // Parties: yours, and the invites waiting for an answer. for (const auto& invite : model.party_invites) { const auto from = std::to_string(invite.from); add_text(context, side, "party-invite-" + from, menu_view::caption(invite.name, 32) + " invited you to their party", 48.f); @@ -391,8 +391,6 @@ void render_section(const Context& context, const MultiplayerModel& model, Secti model.hosting ? "tuning-enforce" : "", "toggle", false, 136.f); add_button(context, side, "layers", std::string("World layer sync: ") + (model.force_world_layers ? "On" : "Off"), model.hosting ? "world-layer-sync" : "", "toggle", false, 136.f); - add_button(context, side, "party-overlay", std::string("Lobby party: ") + (model.party_overlay ? "On" : "Off"), - "party-overlay", "toggle", false, 136.f); add_button(context, side, "nametags", std::string("Nametags: ") + (model.nametags ? "On" : "Off"), "nametags", "toggle", false, 136.f); add_button(context, side, "nametag-style", std::string("Nametag style: ") + (model.custom_nametags ? "ReSkate" : "Game"), diff --git a/Extension/UI/Overlay/multiplayer_menu.cpp b/Extension/UI/Overlay/multiplayer_menu.cpp index ea8ba9ca..a150880f 100644 --- a/Extension/UI/Overlay/multiplayer_menu.cpp +++ b/Extension/UI/Overlay/multiplayer_menu.cpp @@ -236,12 +236,6 @@ void debug_page(SkateMenu &menu, const MultiplayerModel &mp, const CallbacksV3 & void multiplayer_display_settings(SkateMenu &menu, const Model &model) { const auto &mp = model.multiplayer; begin_card(menu, "multiplayer-display", "MULTIPLAYER"); - bool party_overlay = mp.party_overlay; - if (toggle_row(menu, "Lobby party", "Everyone in a lobby is in your game's own party (party list, member counter, Coop button, map colours). A party you form on a dedicated server always shows. Local only.", - party_overlay)) { - std::array unused{}; - send_private(menu, "party-overlay", party_overlay ? "on" : "off", unused, false); - } bool nametags = mp.nametags; if (toggle_row(menu, "Player nametags", "The name above each skater.", nametags)) { std::array unused{}; diff --git a/Launcher/gui.cpp b/Launcher/gui.cpp index f59db345..20277a07 100644 --- a/Launcher/gui.cpp +++ b/Launcher/gui.cpp @@ -122,6 +122,35 @@ bool list_row(const char* id, float width, float height, bool ticked) { return pressed; } +bool toggle(const char* id, bool* on) { + const ImVec2 size(S(42), S(22)); + const ImVec2 start = ImGui::GetCursorScreenPos(); + const bool pressed = ImGui::InvisibleButton(id, size); + if (pressed) *on = !*on; + const bool hovered = ImGui::IsItemHovered(); + if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + auto* draw = ImGui::GetWindowDrawList(); + const float radius = size.y * 0.5f; + draw->AddRectFilled(start, ImVec2(start.x + size.x, start.y + size.y), + *on ? color::blue : rgba(255, 255, 255, hovered ? 0.2f : 0.13f), radius); + draw->AddCircleFilled(ImVec2(start.x + (*on ? size.x - radius : radius), start.y + radius), radius - S(3), + *on ? color::ink : rgba(196, 202, 212)); + return pressed; +} + +bool more_button(const char* id, float size) { + const ImVec2 start = ImGui::GetCursorScreenPos(); + const bool pressed = ImGui::InvisibleButton(id, ImVec2(size, size)); + const bool hovered = ImGui::IsItemHovered(); + if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + auto* draw = ImGui::GetWindowDrawList(); + if (hovered) draw->AddRectFilled(start, ImVec2(start.x + size, start.y + size), rgba(255, 255, 255, 0.1f), S(4)); + for (int dot = -1; dot <= 1; ++dot) + draw->AddCircleFilled(ImVec2(start.x + size * 0.5f + static_cast(dot) * S(6), start.y + size * 0.5f), S(1.8f), + hovered ? color::text : color::muted); + return pressed; +} + void open_path(const fs::path& path) { std::error_code error; fs::create_directories(path, error); diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index 16ab218a..07ad623f 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -122,6 +123,10 @@ void field(const Fonts& fonts, const char* name, const std::string& value); // blue edge when it is ticked. Returns true when the row itself was clicked, // which opens the mod's overview; the widgets drawn over it keep their clicks. bool list_row(const char* id, float width, float height, bool ticked); +// An on/off switch, blue while on. Returns true when it was flipped. +bool toggle(const char* id, bool* on); +// A square button with three dots, for a row's menu. +bool more_button(const char* id, float size); // Draws only the rows a scrolling child actually shows. Unlike ImGuiListClipper // this copes with rows of different heights, which one open row needs. @@ -385,9 +390,16 @@ struct ModsPanel { int selected{-1}; // the mod whose overview is open bool overview{}; // the overview popup is showing int tab{}; // 0 MY MODS, 1 GET MODS + // MY MODS: what the list is narrowed to and ordered by, and the mods ticked + // to be changed together (folder names). + std::array search{}; + int filter{}; + int order{}; + std::set> marked; + std::string anchor; // the last mod ticked: where a Shift-click range starts std::string message; bool message_error{}; - std::string confirm_remove; // folder awaiting "Remove" confirmation + std::vector confirm_remove; // folders awaiting "Uninstall" confirmation fs::path conflict_source; // install waiting for "Replace" confirmation std::string conflict_name; diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index efe28c88..116df32c 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -163,75 +163,281 @@ bool rail(const Fonts& fonts, ModsPanel& panel, HWND window, float width, // ---------------------------------------------------------------- MY MODS -// One installed mod: enabled state, its place in the load order, and a click -// anywhere else for everything the mod says about itself. -void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Installed& installed, int index, - float tall, bool& changed, int& move_from, int& move_to) { +constexpr std::array filter_names{"All mods", "Enabled", "Disabled", "Maps", "Game data", "Updates", + "Problems"}; +constexpr std::array order_names{"Load order", "Name", "Largest first"}; + +// Something the page was asked to do to one mod or to all the ticked ones. +// Carried out once the list is drawn: most of it reorders or shrinks the list. +struct Request { + enum class Kind { none, enable, disable, top, bottom, up, down, update, uninstall }; + Kind kind{Kind::none}; + std::vector mods; // folder names +}; + +std::string folded(std::string_view text) { + std::string result(text); + for (auto& letter : result) letter = static_cast(std::tolower(static_cast(letter))); + return result; +} + +bool update_waiting(const ModsPanel& panel, const thunderstore::Installed& installed, const std::string& name) { + const auto* package = package_for(panel.store, name); + return package && thunderstore::update_available(*package, installed); +} + +// The rows the list shows, as places in the load order: what the search and the +// filter leave, in the order asked for. +std::vector visible_mods(const ModsPanel& panel, const thunderstore::Installed& installed) { + const auto& entries = panel.list.entries; + const auto query = folded(panel.search.data()); + std::vector view; + for (int index = 0; index < static_cast(entries.size()); ++index) { + const auto& entry = entries[static_cast(index)]; + const auto& mod = entry.mod; + bool keep = true; + switch (panel.filter) { + case 1: keep = entry.enabled; break; + case 2: keep = !entry.enabled; break; + case 3: keep = !mod.levels.empty(); break; + case 4: keep = mod.levels.empty() && mod.provides_layout; break; + case 5: keep = update_waiting(panel, installed, mod.name); break; + case 6: keep = !mod.outdated.empty() || panel.list.excluded.contains(mod.name); break; + default: break; + } + if (keep && !query.empty()) + keep = folded(mod.title).find(query) != std::string::npos || + folded(mod.name).find(query) != std::string::npos || + folded(mod.author).find(query) != std::string::npos; + if (keep) view.push_back(index); + } + const auto size = [&](int index) { + const auto found = panel.sizes.find(entries[static_cast(index)].mod.name); + return found == panel.sizes.end() ? std::uint64_t{} : found->second; + }; + if (panel.order == 1) + std::stable_sort(view.begin(), view.end(), [&](int a, int b) { + return folded(entries[static_cast(a)].mod.title) < + folded(entries[static_cast(b)].mod.title); + }); + else if (panel.order == 2) + std::stable_sort(view.begin(), view.end(), [&](int a, int b) { return size(a) > size(b); }); + return view; +} + +// Ticks or unticks the row at `position`; with `range`, every row from the last +// one ticked to it, the way Shift-click works in a file list. +void mark(ModsPanel& panel, const std::vector& view, int position, bool on, bool range) { + const auto name = [&](int at) -> const std::string& { + return panel.list.entries[static_cast(view[static_cast(at)])].mod.name; + }; + int from = position; + if (range) + for (int at = 0; at < static_cast(view.size()); ++at) + if (name(at) == panel.anchor) from = at; + for (int at = std::min(from, position); at <= std::max(from, position); ++at) { + if (on) panel.marked.insert(name(at)); + else panel.marked.erase(name(at)); + } + panel.anchor = name(position); +} + +void carry_out(ModsPanel& panel, const thunderstore::Installed& installed, const Request& request) { + auto& entries = panel.list.entries; + const auto asked = [&](const mods::ModEntry& entry) { + return std::find(request.mods.begin(), request.mods.end(), entry.mod.name) != request.mods.end(); + }; + const auto count = request.mods.size(); + const auto saved = [&](const char* one, const char* many) { + save(panel); + if (!panel.message_error) + panel.message = (count == 1 ? std::string(one) : std::format("{} {}", count, many)) + + " Changes apply the next time Skate starts."; + }; + // The open overview follows its mod through a reorder. + const std::string open = panel.selected >= 0 && panel.selected < static_cast(entries.size()) + ? entries[static_cast(panel.selected)].mod.name : std::string(); + switch (request.kind) { + case Request::Kind::enable: + case Request::Kind::disable: { + const bool on = request.kind == Request::Kind::enable; + for (auto& entry : entries) + if (asked(entry)) entry.enabled = on; + saved(on ? "Mod enabled." : "Mod disabled.", on ? "mods enabled." : "mods disabled."); + break; + } + case Request::Kind::top: + std::stable_partition(entries.begin(), entries.end(), asked); + saved("Moved to the top.", "mods moved to the top."); + break; + case Request::Kind::bottom: + std::stable_partition(entries.begin(), entries.end(), + [&](const mods::ModEntry& entry) { return !asked(entry); }); + saved("Moved to the bottom.", "mods moved to the bottom."); + break; + case Request::Kind::up: + case Request::Kind::down: { + const auto found = std::find_if(entries.begin(), entries.end(), asked); + if (found == entries.end()) break; + const bool up = request.kind == Request::Kind::up; + if (up ? found == entries.begin() : found + 1 == entries.end()) break; + std::iter_swap(found, up ? found - 1 : found + 1); + save(panel); + break; + } + case Request::Kind::update: { + std::vector packages; + for (const auto& name : request.mods) + if (update_waiting(panel, installed, name)) packages.push_back(*package_for(panel.store, name)); + start_store_install(panel, std::move(packages)); + break; + } + case Request::Kind::uninstall: + panel.confirm_remove = request.mods; + break; + case Request::Kind::none: + break; + } + if (!open.empty()) + for (std::size_t i = 0; i < entries.size(); ++i) + if (entries[i].mod.name == open) panel.selected = static_cast(i); +} + +// One installed mod. The tick on the left picks it for the bar above the list, +// the switch on the right is whether it loads, and a click anywhere else opens +// everything the mod says about itself. +void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Installed& installed, + const std::vector& view, int position, float tall, bool reorder, Request& request) { + const int index = view[static_cast(position)]; auto& entry = panel.list.entries[static_cast(index)]; const auto& mod = entry.mod; - ImGui::PushID(index); + ImGui::PushID(mod.name.c_str()); auto* draw = ImGui::GetWindowDrawList(); const ImVec2 start = ImGui::GetCursorScreenPos(); const float width = ImGui::GetContentRegionAvail().x; - if (list_row("##row", width, tall, false)) { - panel.selected = index; - panel.overview = false; + const bool ticked = panel.marked.contains(mod.name); + const bool pressed = list_row("##row", width, tall, ticked); + bool menu = ImGui::IsItemClicked(ImGuiMouseButton_Right); + if (pressed) { + // Ctrl and Shift pick, as they do in a file list; a plain click opens the mod. + if (ImGui::GetIO().KeyShift) mark(panel, view, position, true, true); + else if (ImGui::GetIO().KeyCtrl) mark(panel, view, position, !ticked, false); + else { + panel.selected = index; + panel.overview = false; + } } const float right = start.x + width; const bool left_out = panel.list.excluded.contains(mod.name); const auto* package = package_for(panel.store, mod.name); const bool update = package && thunderstore::update_available(*package, installed); - // The same icon the store shows, so a mod looks like itself on both pages. - mod_icon(panel, package, ImVec2(start.x + S(48), start.y + (tall - S(52)) * 0.5f), S(52)); - const float text_x = start.x + S(112); - const auto title = std::to_string(index + 1) + ". " + mod.title; - draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(text_x, start.y + S(16)), - entry.enabled ? color::text : color::muted, title.c_str()); - if (const auto detail = summary(panel, mod); !detail.empty()) - draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x, start.y + S(40)), color::muted, detail.c_str()); - - // The widgets that sit on the row, over its Selectable. ImGui::SetCursorScreenPos(ImVec2(start.x + S(14), start.y + (tall - ImGui::GetFrameHeight()) * 0.5f)); - if (ImGui::Checkbox("##enabled", &entry.enabled)) changed = true; - if (ImGui::IsItemHovered()) ImGui::SetTooltip(entry.enabled ? "Enabled: loads when Skate starts" : "Disabled"); + bool tick = ticked; + if (ImGui::Checkbox("##pick", &tick)) mark(panel, view, position, tick, ImGui::GetIO().KeyShift); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Tick mods to change several at once.\nShift-click ticks every mod in between."); + + // Right to left: the menu, whether it loads, its place in the load order. + const float more = S(30); + const float more_x = right - S(12) - more; + ImGui::SetCursorScreenPos(ImVec2(more_x, start.y + (tall - more) * 0.5f)); + if (more_button("##more", more)) menu = true; + if (ImGui::IsItemHovered()) ImGui::SetTooltip("More"); + + const float switch_x = more_x - S(12) - S(42); + ImGui::SetCursorScreenPos(ImVec2(switch_x, start.y + (tall - S(22)) * 0.5f)); + bool enabled = entry.enabled; + if (toggle("##enabled", &enabled)) + request = {enabled ? Request::Kind::enable : Request::Kind::disable, {mod.name}}; + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(entry.enabled ? "Enabled: loads when Skate starts" : "Disabled: stays installed, does not load"); - // Right to left: the load order, then what you can do to the mod. const float arrow = ImGui::GetFrameHeight(); - const float arrows_x = right - S(16) - arrow * 2 - S(6); + const float arrows_x = switch_x - S(18) - arrow * 2 - S(6); + const char* fixed = "Show all mods in load order to move them"; ImGui::SetCursorScreenPos(ImVec2(arrows_x, start.y + (tall - arrow) * 0.5f)); - ImGui::BeginDisabled(index == 0); - if (ImGui::ArrowButton("##up", ImGuiDir_Up)) { move_from = index; move_to = index - 1; } + ImGui::BeginDisabled(!reorder || index == 0); + if (ImGui::ArrowButton("##up", ImGuiDir_Up)) request = {Request::Kind::up, {mod.name}}; ImGui::EndDisabled(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Load earlier: later mods win where they overlap"); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("%s", reorder ? "Load earlier: where two mods change the same thing, the higher one wins" : fixed); ImGui::SameLine(0, S(6)); - ImGui::BeginDisabled(index + 1 == static_cast(panel.list.entries.size())); - if (ImGui::ArrowButton("##down", ImGuiDir_Down)) { move_from = index; move_to = index + 1; } + ImGui::BeginDisabled(!reorder || index + 1 == static_cast(panel.list.entries.size())); + if (ImGui::ArrowButton("##down", ImGuiDir_Down)) request = {Request::Kind::down, {mod.name}}; ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", reorder ? "Load later" : fixed); - const float button = S(104), button_y = start.y + (tall - S(30)) * 0.5f; - float next = arrows_x - S(14) - button; - ImGui::SetCursorScreenPos(ImVec2(next, button_y)); - if (ImGui::Button("Uninstall", ImVec2(button, S(30)))) panel.confirm_remove = mod.name; - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Delete this mod's folder (it goes to the Recycle Bin)"); + float next = arrows_x - S(14); if (update) { - next -= S(8) + button; - ImGui::SetCursorScreenPos(ImVec2(next, button_y)); + const float button = S(96); + next -= button; + ImGui::SetCursorScreenPos(ImVec2(next, start.y + (tall - S(30)) * 0.5f)); push_primary_button(); - if (ImGui::Button("UPDATE", ImVec2(button, S(30)))) start_store_install(panel, {*package}); + if (ImGui::Button("UPDATE", ImVec2(button, S(30)))) request = {Request::Kind::update, {mod.name}}; pop_primary_button(); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Thunderstore has v%s", package->latest().number.c_str()); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Thunderstore has v%s", package->latest().number.c_str()); + next -= S(14); } - float pill_x = next - S(14); const float pill_y = start.y + (tall - fonts.caption->FontSize - S(8)) * 0.5f; const auto pill = [&](const char* label, ImU32 fill) { - pill_x -= badge_width(fonts, label); - badge(draw, fonts, ImVec2(pill_x, pill_y), label, fill, color::ink); - pill_x -= S(8); + next -= badge_width(fonts, label); + badge(draw, fonts, ImVec2(next, pill_y), label, fill, color::ink); + next -= S(12); }; if (!mod.outdated.empty()) pill("OUTDATED", color::warning); else if (left_out) pill("NOT LOADED", color::danger); + + // The same icon the store shows, so a mod looks like itself on both pages. + mod_icon(panel, package, ImVec2(start.x + S(48), start.y + (tall - S(48)) * 0.5f), S(48)); + const float text_x = start.x + S(108); + const ImVec4 clip(text_x, start.y, next, start.y + tall); + // The number is its place in the load order, whatever order the list is shown in. + const auto title = std::to_string(index + 1) + ". " + mod.title; + draw->AddText(fonts.bold, fonts.bold->FontSize, ImVec2(text_x, start.y + S(13)), + entry.enabled ? color::text : color::muted, title.c_str(), nullptr, 0, &clip); + if (const auto detail = summary(panel, mod); !detail.empty()) + draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x, start.y + S(37)), color::muted, detail.c_str(), + nullptr, 0, &clip); + + if (menu) ImGui::OpenPopup("##menu"); + if (ImGui::BeginPopup("##menu")) { + // Opened on one of several ticked mods, the menu is about all of them. + const bool group = ticked && panel.marked.size() > 1; + const std::vector names = group + ? std::vector(panel.marked.begin(), panel.marked.end()) : std::vector{mod.name}; + const auto ask = [&](Request::Kind kind) { request = {kind, names}; }; + if (group) { + ImGui::TextDisabled("%s", std::format("{} mods selected", names.size()).c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Enable")) ask(Request::Kind::enable); + if (ImGui::MenuItem("Disable")) ask(Request::Kind::disable); + } else { + if (ImGui::MenuItem("Details")) { + panel.selected = index; + panel.overview = false; + } + if (ImGui::MenuItem(entry.enabled ? "Disable" : "Enable")) + ask(entry.enabled ? Request::Kind::disable : Request::Kind::enable); + } + if (std::any_of(names.begin(), names.end(), + [&](const std::string& name) { return update_waiting(panel, installed, name); }) && + ImGui::MenuItem("Update")) ask(Request::Kind::update); + ImGui::Separator(); + if (ImGui::MenuItem("Move to top")) ask(Request::Kind::top); + if (ImGui::MenuItem("Move to bottom")) ask(Request::Kind::bottom); + if (!group) { + ImGui::Separator(); + if (ImGui::MenuItem("Open folder")) open_path(mod.directory); + if (package && !package->package_url.empty() && ImGui::MenuItem("Thunderstore page")) + open_url(package->package_url); + } + ImGui::Separator(); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color::danger)); + if (ImGui::MenuItem("Uninstall")) ask(Request::Kind::uninstall); + ImGui::PopStyleColor(); + ImGui::EndPopup(); + } ImGui::PopID(); } @@ -239,11 +445,101 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co float height, bool installing) { auto& entries = panel.list.entries; const float top = ImGui::GetCursorPosY(); - ImGui::SetCursorPosX(ImGui::GetContentRegionMax().x - S(110)); - if (ImGui::Button("Refresh", ImVec2(S(110), 0))) refresh_mods(launcher, panel); + + // ------------------------------------------------ search, filter, order, refresh + const float combo = S(170), refresh = S(110); + ImGui::SetNextItemWidth(std::max(S(140), ImGui::GetContentRegionAvail().x - (combo + S(10)) * 2 - refresh - S(10))); + ImGui::InputTextWithHint("##installed_search", "Search your mods", panel.search.data(), panel.search.size()); + ImGui::SameLine(); + const auto choice = [&](const char* id, const auto& names, int& value) { + const int last = static_cast(names.size()) - 1; + ImGui::SetNextItemWidth(combo); + if (ImGui::BeginCombo(id, names[static_cast(std::clamp(value, 0, last))])) { + for (int i = 0; i <= last; ++i) + if (ImGui::Selectable(names[static_cast(i)], value == i)) value = i; + ImGui::EndCombo(); + } + }; + choice("##installed_filter", filter_names, panel.filter); + ImGui::SameLine(); + choice("##installed_order", order_names, panel.order); + ImGui::SameLine(); + if (ImGui::Button("Refresh", ImVec2(refresh, 0))) refresh_mods(launcher, panel); + + const auto view = visible_mods(panel, installed); + // Arrows move a mod past its neighbour in the load order, which is only + // what the list shows when nothing is filtered out or sorted another way. + const bool reorder = panel.order == 0 && view.size() == entries.size(); + const auto shown = [&](int at) -> const std::string& { + return entries[static_cast(view[static_cast(at)])].mod.name; + }; + Request request; + + // ------------------------------------------------ tick all, and what to do with the ticked + ImGui::Spacing(); + ImGui::BeginDisabled(installing); + bool all = !view.empty(); + for (int at = 0; at < static_cast(view.size()) && all; ++at) all = panel.marked.contains(shown(at)); + ImGui::BeginDisabled(view.empty()); + if (ImGui::Checkbox("##all", &all)) + for (int at = 0; at < static_cast(view.size()); ++at) { + if (all) panel.marked.insert(shown(at)); + else panel.marked.erase(shown(at)); + } + ImGui::EndDisabled(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(view.size() == entries.size() ? "Tick every mod" : "Tick every mod shown"); + ImGui::SameLine(0, S(10)); + ImGui::AlignTextToFramePadding(); + const auto total = [&](auto&& counted) { + std::uint64_t bytes{}; + for (const auto& [name, size] : panel.sizes) + if (counted(name)) bytes += size; + return bytes; + }; + if (panel.marked.empty()) { + const auto enabled = std::count_if(entries.begin(), entries.end(), [](const auto& entry) { return entry.enabled; }); + auto line = std::format("{} {} / {} enabled", entries.size(), entries.size() == 1 ? "mod" : "mods", enabled); + if (const auto bytes = total([](const std::string&) { return true; })) line += " / " + size_text(bytes); + if (view.size() != entries.size()) line += std::format(" / {} shown", view.size()); + ImGui::TextDisabled("%s", line.c_str()); + } else { + const std::vector names(panel.marked.begin(), panel.marked.end()); + auto line = std::format("{} selected", names.size()); + if (const auto bytes = total([&](const std::string& name) { return panel.marked.contains(name); })) + line += " / " + size_text(bytes); + ImGui::TextUnformatted(line.c_str()); + const auto waiting = std::count_if(names.begin(), names.end(), + [&](const std::string& name) { return update_waiting(panel, installed, name); }); + const auto update_label = std::format("UPDATE {}", waiting); + struct Action { const char* label; float width; Request::Kind kind; }; + const std::array actions{Action{"Enable", S(84), Request::Kind::enable}, + Action{"Disable", S(84), Request::Kind::disable}, Action{"Move to top", S(112), Request::Kind::top}, + Action{"Move to bottom", S(132), Request::Kind::bottom}, + Action{update_label.c_str(), S(104), Request::Kind::update}, + Action{"Uninstall", S(96), Request::Kind::uninstall}}; + const float gap = S(8), clear = S(70); + float across = clear; + for (const auto& action : actions) + if (action.kind != Request::Kind::update || waiting) across += action.width + gap; + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetContentRegionMax().x - across)); + for (const auto& action : actions) { + if (action.kind == Request::Kind::update && !waiting) continue; + const bool primary = action.kind == Request::Kind::update, danger = action.kind == Request::Kind::uninstall; + if (primary) push_primary_button(); + if (danger) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color::danger)); + if (ImGui::Button(action.label, ImVec2(action.width, 0))) request = {action.kind, names}; + if (danger) ImGui::PopStyleColor(); + if (primary) pop_primary_button(); + ImGui::SameLine(0, gap); + } + if (ImGui::Button("Clear", ImVec2(clear, 0))) panel.marked.clear(); + } + ImGui::EndDisabled(); + + // ------------------------------------------------ what the player should know ImGui::PushTextWrapPos(0); - ImGui::TextDisabled("Mods load top to bottom: where two change the same thing, the higher one wins. Changes apply " - "the next time Skate starts. Click a mod to see everything about it."); if (launcher.game()) ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning), "Skate is running: restart it to apply changes."); @@ -272,32 +568,39 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co ImGui::PopTextWrapPos(); ImGui::Spacing(); - const float body = std::max(S(120), height - (ImGui::GetCursorPosY() - top)); + // ------------------------------------------------ the list, and under it how its order works + const float hint = ImGui::GetTextLineHeightWithSpacing(); + const float body = std::max(S(120), height - (ImGui::GetCursorPosY() - top) - hint); ImGui::BeginChild("##mod_list", ImVec2(0, body), ImGuiChildFlags_Borders); - if (entries.empty()) { + const auto note = [](const char* first, const char* second = nullptr) { ImGui::Spacing(); ImGui::Indent(S(14)); - ImGui::TextDisabled(panel.list.present ? "No mods installed yet." : "No Mods folder yet."); - ImGui::TextDisabled("Use GET MODS to browse Thunderstore, or drop a mod .zip on the window."); + ImGui::TextDisabled("%s", first); + if (second) ImGui::TextDisabled("%s", second); ImGui::Unindent(S(14)); - } - bool changed = false; - int move_from = -1, move_to = -1; - const float row = S(76); + }; + if (entries.empty()) + note(panel.list.present ? "No mods installed yet." : "No Mods folder yet.", + "Use GET MODS to browse Thunderstore, or drop a mod .zip on the window."); + else if (view.empty()) note("No mods match."); + const float row = S(68); ImGui::BeginDisabled(installing); - virtual_rows(static_cast(entries.size()), [&](int) { return row; }, [&](int index, float tall) { - installed_row(fonts, panel, installed, index, tall, changed, move_from, move_to); + virtual_rows(static_cast(view.size()), [&](int) { return row; }, [&](int position, float tall) { + installed_row(fonts, panel, installed, view, position, tall, reorder, request); }); ImGui::EndDisabled(); ImGui::EndChild(); + ImGui::TextDisabled("Mods load top to bottom: where two change the same thing, the higher one wins. " + "Changes apply the next time Skate starts."); - if (move_from >= 0) { - std::swap(entries[static_cast(move_from)], entries[static_cast(move_to)]); - if (panel.selected == move_from) panel.selected = move_to; - else if (panel.selected == move_to) panel.selected = move_from; - changed = true; + // Ctrl+A and Delete, as in a file list, while nothing is being typed. + if (!installing && !ImGui::GetIO().WantTextInput && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId)) { + if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_A, false)) + for (int at = 0; at < static_cast(view.size()); ++at) panel.marked.insert(shown(at)); + if (!panel.marked.empty() && ImGui::IsKeyPressed(ImGuiKey_Delete, false)) + request = {Request::Kind::uninstall, std::vector(panel.marked.begin(), panel.marked.end())}; } - if (changed) save(panel); + if (request.kind != Request::Kind::none) carry_out(panel, installed, request); } // Everything an installed mod says about itself, in a popup over the page. @@ -360,7 +663,7 @@ void mod_overview(const Fonts& fonts, ModsPanel& panel, const thunderstore::Inst if (ImGui::Button("Uninstall", ImVec2(0, S(34)))) { const auto name = mod.name; close(); - panel.confirm_remove = name; + panel.confirm_remove = {name}; } ImGui::PopStyleColor(); ImGui::EndDisabled(); @@ -486,6 +789,10 @@ void scan(ModsPanel& panel, const launcher_app::Session& session) { for (const auto& entry : panel.list.entries) panel.sizes[entry.mod.name] = folder_size(entry.mod.directory); panel.scanned = true; if (panel.selected >= static_cast(panel.list.entries.size())) panel.selected = -1; + std::erase_if(panel.marked, [&](const std::string& name) { + return std::none_of(panel.list.entries.begin(), panel.list.entries.end(), + [&](const mods::ModEntry& entry) { return entry.mod.name == name; }); + }); } void start_install(ModsPanel& panel, const fs::path& source, bool replace) { @@ -730,35 +1037,70 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo else mod_overview(fonts, panel, installed, frame, installing); if (leave) close(); if (!installing && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId) && !ImGui::IsAnyItemActive() && - ImGui::IsKeyPressed(ImGuiKey_Escape, false)) close(); + ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + // Escape lets go of the ticked mods first, and leaves the page after that. + if (panel.tab == 0 && !panel.marked.empty()) panel.marked.clear(); + else close(); + } // ------------------------------------------------ confirmations - if (!panel.confirm_remove.empty() && !ImGui::IsPopupOpen("Uninstall mod")) ImGui::OpenPopup("Uninstall mod"); + // One title for both, so the popup is the same window whether it is one mod or several. + const char* uninstall = panel.confirm_remove.size() > 1 ? "Uninstall mods###uninstall" : "Uninstall mod###uninstall"; + if (!panel.confirm_remove.empty() && !ImGui::IsPopupOpen(uninstall)) ImGui::OpenPopup(uninstall); if (!panel.conflict_name.empty() && !ImGui::IsPopupOpen("Replace mod")) ImGui::OpenPopup("Replace mod"); - ImGui::SetNextWindowSize(ImVec2(S(440), 0)); - if (ImGui::BeginPopupModal("Uninstall mod", nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) { + ImGui::SetNextWindowSize(ImVec2(S(460), 0)); + if (ImGui::BeginPopupModal(uninstall, nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) { + const auto names = panel.confirm_remove; + const auto title = [&](const std::string& name) -> const std::string& { + for (const auto& entry : entries) + if (entry.mod.name == name) return entry.mod.title; + return name; + }; + std::uint64_t bytes{}; + for (const auto& name : names) + if (const auto found = panel.sizes.find(name); found != panel.sizes.end()) bytes += found->second; ImGui::PushTextWrapPos(0); - ImGui::Text("Uninstall \"%s\"? Its folder goes to the Recycle Bin.", panel.confirm_remove.c_str()); + if (names.size() == 1) { + ImGui::Text("Uninstall \"%s\"? Its folder goes to the Recycle Bin.", title(names.front()).c_str()); + } else { + ImGui::Text("Uninstall these %d mods? Their folders go to the Recycle Bin.", static_cast(names.size())); + ImGui::Spacing(); + const std::size_t listed = std::min(names.size(), 8); + for (std::size_t i = 0; i < listed; ++i) ImGui::TextDisabled("%s", title(names[i]).c_str()); + if (names.size() > listed) ImGui::TextDisabled("and %d more", static_cast(names.size() - listed)); + } + if (bytes) ImGui::TextDisabled("That frees %s.", size_text(bytes).c_str()); ImGui::PopTextWrapPos(); ImGui::Spacing(); if (ImGui::Button("Cancel", ImVec2(S(110), 0))) { panel.confirm_remove.clear(); ImGui::CloseCurrentPopup(); } ImGui::SameLine(); push_primary_button(); if (ImGui::Button("UNINSTALL", ImVec2(S(110), 0))) { - const auto name = panel.confirm_remove; - try { - launcher_mods::remove(panel.root, name); - std::erase_if(entries, [&](const auto& entry) { return entry.mod.name == name; }); - save(panel); - panel.message = "Uninstalled " + name + ". It is in the Recycle Bin if you want it back."; - panel.selected = -1; - logging::write(logging::Level::info, logging::Channel::launcher, "Mod removed: " + name); - } catch (const std::exception& failure) { - panel.message = failure.what(); - panel.message_error = true; + // Stops at the first folder that will not go: what went is gone, the rest is untouched. + std::vector removed; + std::string failure; + for (const auto& name : names) { + try { + launcher_mods::remove(panel.root, name); + removed.push_back(name); + logging::write(logging::Level::info, logging::Channel::launcher, "Mod removed: " + name); + } catch (const std::exception& error) { + failure = error.what(); + break; + } } - const auto message = panel.message; - const bool error = panel.message_error; + const auto gone = [&](const std::string& name) { + return std::find(removed.begin(), removed.end(), name) != removed.end(); + }; + const auto first = removed.empty() ? std::string() : title(removed.front()); + std::erase_if(entries, [&](const auto& entry) { return gone(entry.mod.name); }); + if (!removed.empty()) save(panel); + auto message = !failure.empty() ? failure + : removed.size() == 1 ? "Uninstalled " + first + ". It is in the Recycle Bin if you want it back." + : std::format("Uninstalled {} mods. They are in the Recycle Bin if you want them back.", removed.size()); + const bool error = !failure.empty() || panel.message_error; + if (failure.empty() && panel.message_error) message = panel.message; // mods.json could not be rewritten + panel.selected = -1; scan(panel, session); panel.message = message; panel.message_error = error; diff --git a/README.md b/README.md index a092aea7..69d928a5 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,9 @@ the launcher, the runtime that loads into the game, and the dedicated server. and the game runs even with Steam closed. - **Multiplayer.** - Host a Steam lobby for up to 32 players: public, or joined with a code, with an optional password. - Everyone in a lobby is in one party. - Join dedicated servers from the in-game server browser. - Proximity voice chat, and text chat with emotes and an optional bad-word filter. - - Parties on dedicated servers: invite, join, leave, promote. + - Parties in lobbies and on dedicated servers: invite, join, leave, promote. - Throwdowns with other players (Jam, Spot Battle and S.K.A.T.E.), and co-op challenges. - **In-game menu and console.** - Map and fast travel. diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 2df1c6b3..0608e9a2 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -528,7 +528,8 @@ void update_model(std::uintptr_t client, TickState& frame) { enable_saved_feature("EnableCASArtistSandbox", profile_access.cosmetics); enable_saved_feature("EnableMyStuffMenu", profile_access.cosmetics); if (profile_access.preset_slots) slot_action = dingosdk::SkaterSlotOverrideAction::enable; - const auto slots = dingosdk::update_skater_slot_override(slot_action, dingosdk::local_customization_selected_preset()); + const auto slots = dingosdk::update_skater_slot_override(slot_action, dingosdk::local_customization_selected_preset(), + dingosdk::local_customization_outfits_loadable()); if (slots.manager_available && slots.ui_ready) dingosdk::observe_local_customization_selection(slots.selected_slot); const bool missions_requested = offline.model.activities.available && diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 06da626d..478c1d96 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -14,6 +14,7 @@ #include "Extension/Progression/entitlement_request_hook.h" #include "Extension/Skater/skater_observer.h" #include "Extension/UI/NativeMenu/native_menu.h" +#include "Extension/Throwdowns/throwdown_lab.h" #include "Extension/Multiplayer/Session/session.h" #include "Extension/Throwdowns/throwdown_debug_text.h" #include "Extension/Multiplayer/Hud/custom_nametags.h" @@ -414,6 +415,7 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { [](std::uintptr_t base, unsigned next) noexcept { dingosdk::local_profile_before_level_transition(next); dingosdk::multiplayer::native_menu_before_level_transition(base, next); + dingosdk::multiplayer::throwdown_lab_before_level_transition(next); }); if (!r.native_loading_logging) dingosdk::logging::write(dingosdk::logging::Level::warning, dingosdk::logging::Channel::ui, "Native ReSkate menu disabled: shutdown cleanup hook is unavailable."); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index eb4a52cc..6fc582e1 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -19,6 +19,7 @@ add_library(dingosdk_runtime SHARED Extension/Multiplayer/Session/session_receive.cpp Extension/Multiplayer/Session/session_commands.cpp Extension/Multiplayer/Session/session_party.cpp + Extension/Multiplayer/Session/party_book.cpp Extension/UI/NativeMenu/native_menu.cpp Extension/UI/NativeMenu/native_menu_rows.cpp Extension/UI/NativeMenu/native_menu_multiplayer.cpp From a7d95057ac9b5017996907c3fe189d06334210f5 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sun, 4 Oct 2026 13:49:11 -0500 Subject: [PATCH 019/109] Harden multiplayer against disruptive and spoofed traffic Cap outfit packets and stop dropping recipients when a relay cannot be built. Add per-player relay limits for outfits, skater sound and voice, and a back-off for Steam IDs that keep failing to join. Throwdowns: only participants can write rows, turns and scores; a copy that fails to spawn no longer blocks the others. Throttle remote skater and skateboard re-creation, cap remote objects per session, and show developer and friend marks only for identities Steam vouches for. Route all connections through Steam's relays. Dedicated server: validate map changes, clean player names, keep vote cooldowns across reconnects, and cut ban labels on a character boundary. --- Extension/Multiplayer/Net/protocol.cpp | 21 +++++- Extension/Multiplayer/Net/protocol.h | 2 + Extension/Multiplayer/Remote/cosmetics.h | 3 + .../Remote/native_skater_internal.h | 3 + .../Remote/native_skater_spawn.cpp | 4 +- Extension/Multiplayer/Session/room.h | 58 ++++++++++++++++ Extension/Multiplayer/Session/session.cpp | 19 ++++-- .../Multiplayer/Session/session_internal.h | 14 ++++ .../Multiplayer/Session/session_receive.cpp | 10 ++- .../Multiplayer/Session/session_send.cpp | 24 +++++-- .../Multiplayer/Session/session_view.cpp | 5 +- .../Steam/steam_server_browser.cpp | 22 +++++- .../Multiplayer/Steam/steam_transport.cpp | 9 ++- .../Multiplayer/Test/voice_chat_tests.cpp | 2 +- Extension/Multiplayer/Voice/voice_chat.cpp | 12 +++- Extension/Multiplayer/Voice/voice_state.h | 4 +- Extension/Objects/network_object_runtime.cpp | 12 +++- Extension/Throwdowns/throwdown_lab.cpp | 6 ++ Extension/Throwdowns/throwdown_relay.cpp | 66 +++++++++++++++--- Server/server_host.cpp | 68 +++++++++++++++---- Server/server_host.h | 4 ++ Server/server_text.h | 13 ++++ 22 files changed, 328 insertions(+), 53 deletions(-) diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index fc8eada1..b1822ea1 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -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 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(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) @@ -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) @@ -950,6 +956,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(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; diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 39b7f46a..0c3fcabd 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -205,6 +205,8 @@ 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 encode(const Packet &, bool compact_pose = false); // The same, with a pose encoded at another update interval (a recipient thinned by diff --git a/Extension/Multiplayer/Remote/cosmetics.h b/Extension/Multiplayer/Remote/cosmetics.h index d64a4e66..3d5fff17 100644 --- a/Extension/Multiplayer/Remote/cosmetics.h +++ b/Extension/Multiplayer/Remote/cosmetics.h @@ -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; diff --git a/Extension/Multiplayer/Remote/native_skater_internal.h b/Extension/Multiplayer/Remote/native_skater_internal.h index 4546fb3a..07bd7b1f 100644 --- a/Extension/Multiplayer/Remote/native_skater_internal.h +++ b/Extension/Multiplayer/Remote/native_skater_internal.h @@ -97,6 +97,9 @@ struct Remote { std::atomic 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; diff --git a/Extension/Multiplayer/Remote/native_skater_spawn.cpp b/Extension/Multiplayer/Remote/native_skater_spawn.cpp index ef0e98e5..888b8706 100644 --- a/Extension/Multiplayer/Remote/native_skater_spawn.cpp +++ b/Extension/Multiplayer/Remote/native_skater_spawn.cpp @@ -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; diff --git a/Extension/Multiplayer/Session/room.h b/Extension/Multiplayer/Session/room.h index 77f8cf28..2b8655d1 100644 --- a/Extension/Multiplayer/Session/room.h +++ b/Extension/Multiplayer/Session/room.h @@ -1,6 +1,7 @@ #pragma once #include "Extension/Multiplayer/Net/protocol.h" #include +#include #include namespace dingosdk::multiplayer { @@ -102,6 +103,63 @@ 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 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(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; + }); + } +}; struct ReceiveBudget { std::uint64_t since{}, bytes{}, packets{}; bool accept(std::uint64_t now, std::size_t size, unsigned sources = 1) { diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 9e271026..f066678b 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -92,6 +92,7 @@ void stop(Session &s, std::string reason) { 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(); @@ -435,6 +436,8 @@ 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) ? p.member.id : std::uint64_t{}; }; 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)) @@ -444,8 +447,8 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui // 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); + update_developer_hoodie(s.base, remote_skater_entity(), developer_id(p), remote_skater_generation(), p.developer_hoodie); + update_developer_board(s.base, remote_board_entity(), developer_id(p), remote_skater_generation(), p.developer_board); present_audio(p); update_party_position(&p.render_pose); if (labels) label(p); @@ -457,7 +460,9 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui : p.poses.sample_remote(now, p.render_pose); // 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 (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()) { if (spawning) { @@ -514,15 +519,17 @@ 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.next_spawn = now + 3000000; + } p.native_status = sampled ? "Waiting for the player's cosmetic recipe." : "Waiting for player poses."; } } - update_developer_hoodie(s.base, p.visible ? remote_skater_entity() : 0, p.member.id, + update_developer_hoodie(s.base, p.visible ? remote_skater_entity() : 0, developer_id(p), remote_skater_generation(), p.developer_hoodie); - update_developer_board(s.base, p.visible ? remote_board_entity() : 0, p.member.id, + update_developer_board(s.base, p.visible ? remote_board_entity() : 0, developer_id(p), remote_skater_generation(), p.developer_board); if (!p.visible) { stop_remote_audio(); diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 615452e6..208bdbf5 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -88,6 +88,9 @@ struct Peer { // One entry per pose source relayed or sent to this recipient (at most max_players). std::vector 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{}; ReceiveBudget budget; PoseBuffer poses; Pose render_pose; @@ -106,6 +109,8 @@ 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, party_budget; // Host: how the guest's mods change trick scoring, as they reported it (Engine/Vfs/mod_scoring.h): @@ -164,6 +169,8 @@ struct Session { bool force_world_layers{}; // Players the host kicked. They cannot reconnect until the session ends. std::set 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 bans; bool bans_loaded{}; @@ -291,6 +298,13 @@ template 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. +inline bool steam_vouched(const Session &s, const Peer &peer) { + return s.mode != Mode::join || peer.member.id == s.host_id || peer.direct_ready; +} Peer *find_peer(Session &s, std::uint64_t id); unsigned player_count(const Session &s); void reset_peer(Session &s, std::size_t slot); diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index ffaecf7d..25e86e85 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -183,13 +183,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,6 +222,7 @@ 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; s.transport.poll(); @@ -274,6 +275,10 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { 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) { @@ -574,6 +579,7 @@ 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) { diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index f5392072..b6194b43 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -120,6 +120,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 +138,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; @@ -334,8 +345,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; @@ -419,11 +433,13 @@ 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); } // Parties are the ones the lobby's players formed (session_party.cpp), as on a dedicated diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 72f29724..8ac07ad1 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -506,11 +506,12 @@ std::pair player_role(Session &s, std::uint64_t send 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"}; const auto *peer = local ? nullptr : find_peer(s, sender); + const bool vouched = local || (peer && steam_vouched(s, *peer)); + if (vouched && reskate_developer(sender)) return {nametag_developer, "Dev"}; 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"}; } diff --git a/Extension/Multiplayer/Steam/steam_server_browser.cpp b/Extension/Multiplayer/Steam/steam_server_browser.cpp index 650f5a87..703bfc6b 100644 --- a/Extension/Multiplayer/Steam/steam_server_browser.cpp +++ b/Extension/Multiplayer/Steam/steam_server_browser.cpp @@ -85,6 +85,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(result[cut]) & 0xC0) == 0x80) --cut; + result.resize(cut); + } + return result; +} } // namespace std::optional read_server_tags(std::string_view tags, std::uint64_t steam_id) { @@ -121,8 +132,13 @@ std::optional read_server_tags(std::string_view tags, std::uin 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(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; } @@ -175,8 +191,8 @@ void SteamServerBrowser::read() { // 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) { diff --git a/Extension/Multiplayer/Steam/steam_transport.cpp b/Extension/Multiplayer/Steam/steam_transport.cpp index c8bd598c..4b2d4b8a 100644 --- a/Extension/Multiplayer/Steam/steam_transport.cpp +++ b/Extension/Multiplayer/Steam/steam_transport.cpp @@ -146,12 +146,17 @@ struct SteamTransport::Impl { reinterpret_cast(&changed)); return option; } - std::array options() { - std::array out{}; + std::array options() { + std::array out{}; 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); + // 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) { diff --git a/Extension/Multiplayer/Test/voice_chat_tests.cpp b/Extension/Multiplayer/Test/voice_chat_tests.cpp index 9d255c7c..03e7b1d4 100644 --- a/Extension/Multiplayer/Test/voice_chat_tests.cpp +++ b/Extension/Multiplayer/Test/voice_chat_tests.cpp @@ -174,7 +174,7 @@ int main() try { for (int i = 0; i < 80; ++i) check(budget.accept(1000000, 32), "Normal voice cadence rejected"); check(!budget.accept(1000000, 32) && budget.accept(2000000, 32), "Voice rate limit failed"); budget = {}; - for (int i = 0; i < 6; ++i) check(budget.accept(1000000, max_voice_bytes), "Voice byte budget rejected early"); + for (int i = 0; i < 2; ++i) check(budget.accept(1000000, max_voice_bytes), "Voice byte budget rejected early"); check(!budget.accept(1000000, 1), "Voice byte budget exceeded"); VoiceSettings settings; check(settings.valid() && !settings.enabled && !settings.open_mic && settings.push_to_talk == 'V', "Unsafe microphone default"); diff --git a/Extension/Multiplayer/Voice/voice_chat.cpp b/Extension/Multiplayer/Voice/voice_chat.cpp index 13a02b40..0bd11ee9 100644 --- a/Extension/Multiplayer/Voice/voice_chat.cpp +++ b/Extension/Multiplayer/Voice/voice_chat.cpp @@ -58,6 +58,16 @@ struct SteamVoice { if (!user) throw std::runtime_error("Steam Voice is unavailable."); } }; +// Steam's decoder reads bytes another player sent. A fault inside it is that one packet lost, +// not the game: the call is guarded like the other native calls made with remote data. +int decode_voice(const SteamVoice &steam, const void *in, std::uint32_t in_size, void *out, std::uint32_t out_size, + std::uint32_t *written, std::uint32_t rate) noexcept { + __try { + return steam.decode(steam.user, in, in_size, out, out_size, written, rate); + } __except (EXCEPTION_EXECUTE_HANDLER) { + return -1; + } +} struct PcmBuffer { std::vector bytes; std::atomic done{}; @@ -271,7 +281,7 @@ struct VoiceChat::Impl { const bool use_native = native.available() && radius_for(packet.voice.distance, settings) > 0.f; std::vector pcm(max_pcm_bytes); std::uint32_t size{}; - if (steam.decode(steam.user, packet.voice.bytes.data(), static_cast(packet.voice.bytes.size()), + if (decode_voice(steam, packet.voice.bytes.data(), static_cast(packet.voice.bytes.size()), pcm.data(), static_cast(pcm.size()), &size, use_native ? native_voice_rate : sample_rate) != 0 || !size || size > pcm.size() || size % 2) continue; diff --git a/Extension/Multiplayer/Voice/voice_state.h b/Extension/Multiplayer/Voice/voice_state.h index 01994255..107ce06e 100644 --- a/Extension/Multiplayer/Voice/voice_state.h +++ b/Extension/Multiplayer/Voice/voice_state.h @@ -29,11 +29,13 @@ inline bool valid_voice(const VoiceData &voice) noexcept { std::isfinite(voice.distance) && (voice.distance == 0.f || (voice.distance >= min_hearing_distance && voice.distance <= max_hearing_distance)); } +// One speaker per second. Steam's compressed voice is a few kilobytes a second, captured about +// fifty times a second: this leaves several times that and no more, since a host relays it all. struct VoiceBudget { std::uint64_t since{}, bytes{}, packets{}; bool accept(std::uint64_t now, std::size_t size) noexcept { if (now < since || now - since >= 1000000) { since = now; bytes = packets = 0; } - if (!size || size > max_voice_bytes || packets >= 80 || bytes + size > 96 * 1024) return false; + if (!size || size > max_voice_bytes || packets >= 80 || bytes + size > 32 * 1024) return false; ++packets; bytes += size; return true; } diff --git a/Extension/Objects/network_object_runtime.cpp b/Extension/Objects/network_object_runtime.cpp index b9277051..6c204076 100644 --- a/Extension/Objects/network_object_runtime.cpp +++ b/Extension/Objects/network_object_runtime.cpp @@ -302,6 +302,10 @@ void update_network_object_moves() { } // namespace dingosdk::profile_runtime namespace dingosdk { +// Everything the other players have placed that is shown here, all of them together. Each +// owner has a limit of its own; without one for the session, every player a session can hold +// could add a full layout to the native world. +constexpr std::size_t max_remote_objects = 16384; void set_remote_network_objects(std::string_view map, std::span owners) { using namespace profile_runtime; if (owners.size() > multiplayer::max_remote_players) @@ -318,9 +322,10 @@ void set_remote_network_objects(std::string_view map, std::spansecond.id == object.id && found->second.item == object.item && found->second.position == object.position && found->second.rotation == object.rotation && @@ -335,6 +340,9 @@ void set_remote_network_objects(std::string_view map, std::span desired; for (const auto &owner : owners) { for (const auto &object : owner.objects) { + // Owners come in a stable order: past the limit, the later ones' objects are left out. + if (desired.size() == max_remote_objects) + break; if (!multiplayer::valid_network_object(object)) return; if (!desired diff --git a/Extension/Throwdowns/throwdown_lab.cpp b/Extension/Throwdowns/throwdown_lab.cpp index df6b0bc2..1aef52b0 100644 --- a/Extension/Throwdowns/throwdown_lab.cpp +++ b/Extension/Throwdowns/throwdown_lab.cpp @@ -1902,6 +1902,9 @@ void pump_throwdown_lab(Address vm) noexcept { } if (!l.pending.load(std::memory_order_acquire)) return; profile_runtime::PreserveError preserve; + // A relay spawn that fails here never reaches the server: the relay is told, so the copy + // is dropped instead of waiting for an MMID that will not come. + std::uint64_t spawn_token{}; try { if (realm() != client_realm) return; Job job{}; @@ -1911,13 +1914,16 @@ void pump_throwdown_lab(Address vm) noexcept { job = l.jobs.front(); l.jobs.pop_front(); if (l.jobs.empty()) l.pending.store(false, std::memory_order_release); } + spawn_token = job.token; const auto result = run_job(job); + spawn_token = 0; logging::log(logging::Level::info, logging::Channel::progression, "Throwdown lab: {}.", result); } catch (const std::exception& e) { logging::log(logging::Level::warning, logging::Channel::progression, "Throwdown lab: injection failed: {}", e.what()); } catch (...) { logging::write(logging::Level::warning, logging::Channel::progression, "Throwdown lab: injection failed."); } + if (spawn_token) throwdown_relay_spawned(spawn_token, 0, 0); } void throwdown_lab_before_level_transition(unsigned next) noexcept { diff --git a/Extension/Throwdowns/throwdown_relay.cpp b/Extension/Throwdowns/throwdown_relay.cpp index 22e7be9e..10577180 100644 --- a/Extension/Throwdowns/throwdown_relay.cpp +++ b/Extension/Throwdowns/throwdown_relay.cpp @@ -43,6 +43,10 @@ bool turn_based(std::string_view series) { return series == spot_battle_mode || // long belongs to a turn this machine never gives them. constexpr std::uint64_t attempt_wait_ms = 20000; constexpr std::uint64_t offer_interval_ms = 10000, score_interval_ms = 250, gone_forget_ms = 60000; +// A copy whose spawn is never answered is given up after this long, so the next can be tried; +// its leader's following offers wait spawn_refused_ms. A leader's repeated coop challenge starts +// are declined once per decline_interval_ms. +constexpr std::uint64_t spawning_wait_ms = 30000, spawn_refused_ms = 30000, decline_interval_ms = 5000; // While other players are in the session nobody's queue may start on its own timer: // the leader starts it (force start) and every joined copy follows. constexpr std::string_view queue_timer_setting = "DingoThrowdowns.QueueStartTimer"; @@ -103,7 +107,7 @@ struct Mirror { std::string series; std::vector placement, settings; std::vector members, added; // remote players in it (never the leader or us) - std::uint64_t token{}, gone_at{}; + std::uint64_t token{}, gone_at{}, spawning_since{}; std::uint32_t mmid{}, max_players{}; bool joined{}; // the local player is in it std::optional> start; // the leader started before the copy existed @@ -198,6 +202,10 @@ struct Relay { std::uint32_t beacon_revision{}; std::uint64_t beacon_sent_at{}; std::map beacons; + // Leaders whose last drop could not be spawned here: their new offers wait until then (ms). + std::map spawn_refused; + // When a coop challenge from each leader was last declined: one answer per leader at a time. + std::map declined; bool party_settings{}; std::uint64_t next_party_settings{}; }; @@ -333,7 +341,8 @@ void start_mirror(Relay &r, const Key &key, Mirror &m, const std::vector= max_throwdown_order) return; m.members.push_back(player); if (!m.joined && m.max_players && 1 + m.members.size() >= m.max_players) { destroy_mirror(r, m, now); @@ -421,6 +430,13 @@ void set_running(Relay &r, const Key &key, const std::string &series) { r.early.clear(); r.early_remote.clear(); } +// The running throwdown's participants are known: only they have rows, turns and totals in it. +// What arrived early from anyone else is dropped. +void set_players(Relay &r, const std::vector &players) { + r.players = players; + std::erase_if(r.scores, [&](const auto &entry) { return !contains(r.players, entry.first); }); + std::erase_if(r.remote, [&](const Remote &entry) { return !contains(r.players, entry.player); }); +} // --------------------------------------------------------------------------- void on_challenge_local(Relay &r, const ThrowdownLocalAction &a, std::uint64_t now); @@ -500,7 +516,7 @@ void on_local(Relay &r, ThrowdownLocalAction &a, std::uint64_t now) { m.state = Mirror::State::started; m.start.reset(); set_running(r, key, m.series); - r.players = players; + set_players(r, players); note("your copy of a linked {} started with {} player(s).", m.series, players.size()); return; } @@ -510,7 +526,7 @@ void on_local(Relay &r, ThrowdownLocalAction &a, std::uint64_t now) { r.hosted->started = true; if (r.hosted->relayed) { set_running(r, {r.local, r.hosted->id}, r.hosted->series); - r.players = players; + set_players(r, players); } } break; @@ -622,6 +638,8 @@ void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std:: for (const auto player : m.order) mirror_member(r, mirror->second, player, now); return; } + // A leader whose last drop could not be shown here waits before another is tried. + if (const auto refused = r.spawn_refused.find(m.leader); refused != r.spawn_refused.end() && now < refused->second) return; // A leader hosts one drop at a time: a new one replaces its older copies. for (auto &[other, copy] : r.mirrors) if (other.leader == m.leader) destroy_mirror(r, copy, now); @@ -687,7 +705,8 @@ void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std:: : Remote::What::attempt, sender, m.board, m.add, m.value, m.trick}; if (r.running && *r.running == key) { - if (r.remote.size() < 512) r.remote.push_back(entry); + // Only a participant has turns and rows in it. + if (contains(r.players, sender) && r.remote.size() < 512) r.remote.push_back(entry); } else if ((m.leader == r.local && r.hosted && r.hosted->id == m.id) || mirror != r.mirrors.end()) { if (auto &list = r.early_remote[key]; list.size() < 512) list.push_back(entry); } @@ -695,7 +714,7 @@ void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std:: } case Kind::score: if (r.running && *r.running == key) { - r.scores[sender] = {m.value, true}; + if (contains(r.players, sender)) r.scores[sender] = {m.value, true}; } else if ((m.leader == r.local && r.hosted && r.hosted->id == m.id) || mirror != r.mirrors.end()) { r.early[key][sender] = m.value; } @@ -709,6 +728,7 @@ void on_spawned(Relay &r, std::uint64_t token, std::uint32_t mmid, std::uint32_t if (!mmid) { m.state = Mirror::State::gone; m.gone_at = now; + r.spawn_refused[key.leader] = now + spawn_refused_ms; return; } m.mmid = mmid; @@ -776,6 +796,13 @@ void pump_challenge(Relay &r, std::uint64_t now) { c.held.pop_front(); } } +// Whether a decline may be sent to `leader` now (and notes that it is). +bool decline_due(Relay &r, std::uint64_t leader, std::uint64_t now) { + auto &last = r.declined[leader]; + if (last && now >= last && now - last < decline_interval_ms) return false; + last = now; + return true; +} void on_challenge_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std::uint64_t now) { const Key key{m.leader, m.id}; if (m.kind == Kind::challenge_start) { @@ -784,8 +811,11 @@ void on_challenge_message(Relay &r, std::uint64_t sender, const ThrowdownMessage // Only a party member pulls us into their challenge. const bool stranger = !r.party.contains(m.leader); if (busy || stranger || !challenge_invites_enabled.load(std::memory_order_relaxed)) { - send(r, message(Kind::challenge_optout, m.leader, m.id)); - note("coop challenge from {:#x} declined ({}).", m.leader, busy ? "busy" : stranger ? "not in our party" : "invites off"); + // One answer per leader every few seconds: repeated starts are not each answered. + if (decline_due(r, m.leader, now)) { + send(r, message(Kind::challenge_optout, m.leader, m.id)); + note("coop challenge from {:#x} declined ({}).", m.leader, busy ? "busy" : stranger ? "not in our party" : "invites off"); + } return; } if (r.challenge) return; // already joining this one @@ -800,7 +830,7 @@ void on_challenge_message(Relay &r, std::uint64_t sender, const ThrowdownMessage r.plan.guest = true; r.plan.series = c.series; r.plan.id = c.challenge; r.plan.ids = c.ids; } if (!queue_challenge_start(c.series, c.challenge, c.contest)) { - send(r, message(Kind::challenge_optout, m.leader, m.id)); + if (decline_due(r, m.leader, now)) send(r, message(Kind::challenge_optout, m.leader, m.id)); std::lock_guard lock(r.mutex); r.plan.guest = false; return; @@ -1021,6 +1051,8 @@ void reset(Relay &r, bool destroy_copies, std::uint64_t now) { if (destroy_copies && r.in_world && r.prepared) for (auto &[key, m] : r.mirrors) destroy_mirror(r, m, now); r.mirrors.clear(); + r.spawn_refused.clear(); + r.declined.clear(); r.running.reset(); r.scores.clear(); r.early.clear(); @@ -1181,7 +1213,18 @@ void maintain(Relay &r, std::uint64_t now) { r.next_prepare = now + 250; r.prepared = prepare_throwdown_injection(); } + std::erase_if(r.spawn_refused, [&](const auto &entry) { return now >= entry.second; }); + std::erase_if(r.declined, [&](const auto &entry) { return now < entry.second || now - entry.second >= decline_interval_ms; }); if (r.prepared) { + // A spawn nothing answered (the lab reports failures; this is the backstop) must not + // hold every other copy back. + for (auto &[key, m] : r.mirrors) + if (m.state == Mirror::State::spawning && now >= m.spawning_since && now - m.spawning_since > spawning_wait_ms) { + m.state = Mirror::State::gone; + m.gone_at = now; + r.spawn_refused[key.leader] = now + spawn_refused_ms; + note("a linked {} never spawned here; giving up on it.", m.series); + } // One spawn at a time: the server reports MMIDs in order. if (std::none_of(r.mirrors.begin(), r.mirrors.end(), [](const auto &entry) { return entry.second.state == Mirror::State::spawning; })) @@ -1190,7 +1233,10 @@ void maintain(Relay &r, std::uint64_t now) { const auto host = virtual_id(r, key.leader); if (!host) { m.state = Mirror::State::gone; m.gone_at = now; continue; } m.token = r.next_token++; - if (queue_throwdown_spawn(m.token, host, m.series, m.placement, m.settings)) m.state = Mirror::State::spawning; + if (queue_throwdown_spawn(m.token, host, m.series, m.placement, m.settings)) { + m.state = Mirror::State::spawning; + m.spawning_since = now; + } break; } for (auto &[key, m] : r.mirrors) { diff --git a/Server/server_host.cpp b/Server/server_host.cpp index cd880aee..d054b412 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -50,12 +50,6 @@ constexpr std::string_view help_text = "activity-log on|off | announce-throwdowns on|off | parties [on|off] | party-size <2-8> | speed-check off|warn|kick\n" "score-check [off|warn|kick] | score-allow [|remove ]\n" "admin add|remove | admins | update | quit"; -// A command as the log shows it: the log is plain text, so a new password is left out. -std::string loggable(std::string_view command) { - const auto [verb, argument] = split(command); - if (lower(verb) != "password" || argument.empty() || argument == "off") return std::string(command); - return std::string(verb) + " "; -} } // namespace Host::Host(ServerConfig &config, SteamTransport &transport, Log log) @@ -91,6 +85,31 @@ Packet Host::packet(PacketKind kind, std::uint64_t now) { std::string Host::guest_name(const Guest &g) const { return g.member.name.empty() ? std::to_string(g.member.id) : g.member.name; } +// The name a joining player is known by here. It is the one their game sent, which the +// server cannot check against Steam, so it is made safe to show and to type: never blank, +// never the server's or ReSkate's own, never read as a SteamID64 by kick or ban (which take +// a number as one), and never the same as another player's. +std::string Host::player_name(std::string_view wanted, std::uint64_t id) const { + const auto fallback = "Player " + std::to_string(id % 10000); + std::string name(trim(wanted)); + cut_text(name, max_member_name); + // Names show in every player's roster, nametags and party UI. + if (text::contains_bad_words(name)) name = text::mask_bad_words(name); + const auto folded = lower(name); + const auto first = split(name).first; + const bool digits = !first.empty() && std::all_of(first.begin(), first.end(), [](char c) { return c >= '0' && c <= '9'; }); + if (name.empty() || folded == "server" || folded == "reskate" || folded == lower(config_.name)) name = fallback; + else if (digits) name = "Player " + name; + const auto taken = [&](const std::string &candidate) { + return std::any_of(guests_.begin(), guests_.end(), [&](const auto &entry) { + return entry.first != id && entry.second->handshaken && lower(entry.second->member.name) == lower(candidate); + }); + }; + auto unique = name; + for (unsigned copy = 2; taken(unique) && copy < 1000; ++copy) unique = name + " (" + std::to_string(copy) + ")"; + cut_text(unique, 128); + return unique; +} bool Host::is_admin(std::uint64_t id) const { return std::find(config_.admins.begin(), config_.admins.end(), id) != config_.admins.end(); } @@ -150,6 +169,9 @@ void Host::apply_layers() { bool Host::send_packet(Guest &g, const Packet &p, bool reliable, bool fresh, std::span raw, std::span wire) { auto update = raw.empty() ? g.sender.prepare(p) : g.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 (!transport_.send(g.member.id, update.bytes, reliable || update.establishes_baseline(), fresh, traffic_lane(p.kind))) return false; @@ -307,6 +329,8 @@ void Host::send_maps(Guest &admin) { if (send_packet(admin, list, true, false)) admin.maps_sent = true; } void Host::change_map(std::string_view map) { + if (!valid_map_destination(map_destination(map_setting(map)))) + throw std::invalid_argument("That map does not name a destination."); // Everything tied to the old world goes; admission and player slots stay. for (auto &[id, guest] : guests_) { auto &g = *guest; @@ -350,10 +374,17 @@ void Host::drop(std::uint64_t id, const std::string &reason) { roster_dirty_ = true; log_(name + " left (" + reason + ")"); activity_.left(id); + } else { + // Never admitted: it held a player slot meanwhile, so it shows in the log, and an ID + // that keeps failing waits longer each time before its connection is taken again. + const auto failures = join_backoff_.failed(id, now_); + log_("[join] " + std::to_string(id) + " did not finish joining (" + reason + ")" + + (failures > 1 ? ", attempt " + std::to_string(failures) : std::string{})); } guests_.erase(found); party_left(id, name); - vote_cooldowns_.erase(id); + // Their vote cooldown stays (tick() drops it once it has run out): leaving and coming + // back does not let a player start another vote sooner. // One voter fewer, or the player a kick vote was about. Counted in tick(): a passing kick // vote drops another player, which must not happen inside a caller's walk over guests_. vote_recount_ = true; @@ -363,10 +394,10 @@ void Host::drop(std::uint64_t id, const std::string &reason) { bool Host::accept_data(Guest &source, const Packet &p) { bool accepted{}; if (p.kind == PacketKind::cosmetics) { - accepted = source.appearance.push(p); + accepted = source.outfit_budget.accept(now_) && source.appearance.push(p); if (accepted) source.cosmetic_packet = encode_wire(p); } else if (p.kind == PacketKind::audio) - accepted = source.audio.push(p, now_); + accepted = source.sound_budget.accept(now_, p.audio.size()) && source.audio.push(p, now_); else if (p.kind == PacketKind::pose) accepted = source.poses.push_validated(p, now_); // The server never plays anything back: keep only what ordering needs. @@ -482,10 +513,8 @@ void Host::receive(std::uint64_t peer, std::span bytes) { const bool joined = !link->handshaken; link->member.epoch = p.epoch; if (joined) { - link->member.name = p.text.empty() ? "Player " + std::to_string(peer % 10000) : p.text; - if (link->member.name.size() > 128) link->member.name.resize(128); - // Names show in every player's roster, nametags and party UI. - if (text::contains_bad_words(link->member.name)) link->member.name = text::mask_bad_words(link->member.name); + link->member.name = player_name(p.text, peer); + join_backoff_.joined(peer); } link->handshaken = true; link->world_ready = true; @@ -717,6 +746,10 @@ void Host::tick(std::uint64_t now) { continue; } auto *guest = find(link.id); + if (!guest && join_backoff_.waiting(link.id, now_)) { + transport_.disconnect(link.id, "Too many failed attempts to join. Wait a little and try again."); + continue; + } if (!guest) { if (!individual_steam_id(link.id) || guests_.size() >= config_.max_players) { transport_.disconnect(link.id, "The server is full."); @@ -765,6 +798,8 @@ void Host::tick(std::uint64_t now) { activity_.tick(now_); if (std::exchange(vote_recount_, false)) check_vote(false); if (vote_ && now_ >= vote_->ends) check_vote(true); + std::erase_if(vote_cooldowns_, [&](const auto &entry) { return now_ >= entry.second; }); + join_backoff_.prune(now_); } // ---- Commands -------------------------------------------------------------------------------- @@ -838,7 +873,7 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { if (!console && is_admin(id)) return "Admins cannot ban other admins."; if (is_banned(id)) return std::to_string(id) + " is already banned."; auto label = guest ? guest->member.name : clean_chat_text(reason); - if (label.size() > 64) label.resize(64); + cut_text(label, 64); config_.bans.push_back({id, label, static_cast(std::time(nullptr))}); if (guest) drop(id, "You were banned from this server."); return changed((label.empty() ? std::to_string(id) : label) + " was banned."); @@ -858,7 +893,10 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { return text; } if (name == "map") { - if (argument.empty() || !valid_map_destination(map_destination(argument))) + // The map as it will be stored must still name a destination: one that does not would + // leave the server unable to tell players where to go, and unable to start again. + if (argument.empty() || !valid_map_destination(map_destination(argument)) || + !valid_map_destination(map_destination(map_setting(argument)))) return "No single map is called \"" + std::string(argument) + "\". Type maps for the list."; if (map_hash(map_destination(argument)) == map_) return "The server is already on that map."; change_map(argument); diff --git a/Server/server_host.h b/Server/server_host.h index 260cc053..198ebeb2 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -77,6 +77,8 @@ class Host { bool received_voice{}; std::uint32_t voice_sequence{}; VoiceBudget voice_budget; + OutfitBudget outfit_budget; + SoundBudget sound_budget; ChatRate chat_rate; ChatBudget admin_budget, throwdown_budget, party_budget; SpeedCheck speed; // how fast their game runs, from their pose timestamps @@ -123,6 +125,7 @@ class Host { std::uint64_t party_revision_{}; std::map> guests_; std::set kicked_; + JoinBackoff join_backoff_; // Steam IDs whose attempts to join keep failing std::optional password_; std::uint64_t id_{}, secret_{}, epoch_{}, map_{}, world_ = 1; std::uint32_t sequence_{}; @@ -137,6 +140,7 @@ class Host { Packet packet(PacketKind kind, std::uint64_t now); unsigned capacity() const { return config_.max_players + 1; } std::string guest_name(const Guest &) const; + std::string player_name(std::string_view wanted, std::uint64_t id) const; bool is_admin(std::uint64_t id) const; bool is_banned(std::uint64_t id) const; void save(); diff --git a/Server/server_text.h b/Server/server_text.h index 81128a06..8bb15ad1 100644 --- a/Server/server_text.h +++ b/Server/server_text.h @@ -37,4 +37,17 @@ inline std::string lower(std::string_view text) { if (c >= 'A' && c <= 'Z') c = static_cast(c + 32); return result; } +// Cuts text to at most `limit` bytes, never through a UTF-8 character. +inline void cut_text(std::string &text, std::size_t limit) { + if (text.size() <= limit) return; + auto cut = limit; + while (cut && (static_cast(text[cut]) & 0xC0) == 0x80) --cut; + text.resize(cut); +} +// A command as the log shows it: the log is plain text, so a new password is left out. +inline std::string loggable(std::string_view command) { + const auto [verb, argument] = split(command); + if (lower(verb) != "password" || argument.empty() || argument == "off") return std::string(command); + return std::string(verb) + " "; +} } // namespace dingosdk::server From 3fddd1433ab536b5474627235063eea8c6e8bc4f Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sun, 4 Oct 2026 14:19:30 -0500 Subject: [PATCH 020/109] Trainer: quiet by default, host's setup for guests; drop the out-of-view cut Follow-ups to the trainer (PR 37), and one multiplayer change. Off until asked for (Extension/Trainer) - The jump read-out and the LB+RB controller shortcuts start off. Jumps are logged only while the read-out or telemetry recording is on, and trick launches only when the trainer scaled them. A saved trainer.json keeps what the player chose. Memory search on demand (trainer.cpp, trainer_classes.cpp) - The search for the game's tuning classes reads all writable memory. It ran for every player after a level loaded, and up to seven times when it found nothing. It now runs only when a class value or the flip speed is not the game's own (the player's, or a host's they skate with) or the EVERYTHING list is open, and at most three times a level. - For those players it runs again on each level, which brings fresh copies; a search from an earlier level used to count as done. Guests under enforced physics - A guest is locked by the session's enforce setting, from the moment of joining (Engine/Game/Multiplayer/session_physics.h), not by whether the enforcement status text was set yet. - A guest's class values no longer come back after a map change. The host's whole setup reaches its guests (protocol 39) - Only Gameplay/SkatePhysicsTuning travelled, so a host could push, sprint or jump further than the guests it held to the game's own. - New packet physics_extras (30): the host's class values, trick multipliers and auto push (trainer_session.h), sent when they change and to players who join later, taken from the host only. A dedicated server's are the game's own and it ignores the packet. - Under enforcement a guest's own trick multipliers stand down for the host's, whatever the boosts switch says. With enforcement off nothing changes. - A guest refuses bytes that are not an encoding, holds values to the trainer's own ranges, and leaves out class values written for another game build's table. - protocol_version 38 -> 39: released versions disconnect from a peer that sends a packet they cannot decode. Out-of-view players are posed every frame again (Session/session.cpp) - Players outside the camera's view and past 8 m were sampled at 20 Hz (b763631). The game's replays record each skater as it stood every frame and their camera looks anywhere, so those players moved at 20 Hz in replays. Removed; the distance rule (20 Hz past 200 m, 10 Hz past 350 m) stays. This gives back the client-tick time it saved on busy servers. --- CMakeLists.txt | 4 + Engine/Game/Multiplayer/session_physics.h | 71 ++++++++ Extension/Multiplayer/Net/protocol.cpp | 14 +- Extension/Multiplayer/Net/protocol.h | 10 +- Extension/Multiplayer/Session/session.cpp | 39 ++-- .../Multiplayer/Session/session_internal.h | 13 +- .../Multiplayer/Session/session_receive.cpp | 7 + .../Multiplayer/Session/session_send.cpp | 25 +++ Extension/Multiplayer/Test/mesh_tests.cpp | 65 +++++++ Extension/Multiplayer/Test/protocol_tests.cpp | 13 ++ Extension/Trainer/CHANGELOG.md | 20 ++ Extension/Trainer/README.md | 27 ++- .../Trainer/Test/session_extras_tests.cpp | 120 ++++++++++++ Extension/Trainer/trainer.cpp | 172 ++++++++++++++---- Extension/Trainer/trainer.h | 11 +- Extension/Trainer/trainer_classes.cpp | 8 + Extension/Trainer/trainer_classes.h | 3 + Extension/Trainer/trainer_page.cpp | 5 + Extension/Trainer/trainer_session.cpp | 107 +++++++++++ Extension/Trainer/trainer_session.h | 41 +++++ Extension/Trainer/trainer_view.cpp | 4 + Server/server_host.cpp | 3 +- cmake/Runtime.cmake | 1 + 23 files changed, 699 insertions(+), 84 deletions(-) create mode 100644 Engine/Game/Multiplayer/session_physics.h create mode 100644 Extension/Trainer/Test/session_extras_tests.cpp create mode 100644 Extension/Trainer/trainer_session.cpp create mode 100644 Extension/Trainer/trainer_session.h diff --git a/CMakeLists.txt b/CMakeLists.txt index c502537b..63b78dbb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -190,6 +190,10 @@ if(DINGOSDK_BUILD_TRAINER_TESTS) 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) 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) diff --git a/Engine/Game/Multiplayer/session_physics.h b/Engine/Game/Multiplayer/session_physics.h new file mode 100644 index 00000000..18eb4cde --- /dev/null +++ b/Engine/Game/Multiplayer/session_physics.h @@ -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 +#include +#include +#include +#include +#include +#include + +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 enforced{}; +struct Box { + std::mutex mutex; + std::vector 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 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 &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 bytes) { + session_physics_detail::put(session_physics_detail::local_box(), bytes); +} +inline bool local_physics_extras(std::uint64_t &seen, std::vector &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 bytes) { + session_physics_detail::put(session_physics_detail::host_box(), bytes); +} +inline bool host_physics_extras(std::uint64_t &seen, std::vector &bytes) { + return session_physics_detail::take(session_physics_detail::host_box(), seen, bytes); +} +} // namespace dingosdk diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index b1822ea1..d4c2a038 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -340,6 +340,8 @@ std::vector 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)))) @@ -379,7 +381,7 @@ std::vector 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 @@ -491,6 +493,10 @@ std::vector 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); @@ -723,6 +729,12 @@ std::optional decode(std::span bytes) noexcept { return {}; p.tuning.assign(bytes.begin() + static_cast(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(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); diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 0c3fcabd..441cce08 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -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,7 @@ 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 = 39; 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; @@ -68,7 +69,11 @@ enum class PacketKind : std::uint16_t { 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 whoever hosts, a dedicated server or a // lobby's host (party_player = the other player involved, 0 for leave/open/close); invited @@ -185,6 +190,7 @@ struct Packet { std::vector throwdown; // throwdown: one encoded message (1..max_throwdown_message bytes) std::array teleport{}; // teleport: where the receiver goes (world position) std::vector tuning; // physics_tuning: 0..max_physics_tuning bytes + std::vector 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 diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index f066678b..71e5b68d 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -86,6 +86,12 @@ 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(); @@ -342,12 +348,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 &view) { @@ -361,23 +366,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 &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(metres); }; if (!std::isfinite(distance) || !beyond(far_full_rate_return)) return 0; @@ -445,8 +433,7 @@ 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))) { + 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); update_developer_board(s.base, remote_board_entity(), developer_id(p), remote_skater_generation(), p.developer_board); present_audio(p); @@ -477,9 +464,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); @@ -700,6 +684,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. diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 208bdbf5..4b8d0acd 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -94,11 +94,9 @@ struct Peer { 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. @@ -161,6 +159,13 @@ struct Session { std::optional> sent_tuning; std::vector 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> host_extras; + std::uint64_t sent_extras{}; + std::vector 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. diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index 25e86e85..dbc59e16 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -585,6 +585,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t 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) @@ -657,6 +658,12 @@ 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; diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index b6194b43..9249f441 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -462,13 +462,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 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; diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 6e4f750a..7ca8f183 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -629,6 +629,70 @@ void party_checks() { check(!host.local_party && host.parties.parties().empty(), "A guest who left the lobby stayed in the host's party"); std::cout << "Lobby parties: nobody by default, invites, two parties, party chat, leaving and departures passed.\n"; } +// What a host changes outside its physics tuning (the trainer's class values and trick +// multipliers) reaches its guests while it sets everyone's physics: when it changes, to a +// player who joins later, and from nobody but the host. +void physics_extras_checks() { + Simulation sim; + for (unsigned i = 0; i < 3; ++i) sim.add(); + sim.run(60); sim.fresh(3); + auto &host = *sim.nodes[0], &first = *sim.nodes[1], &second = *sim.nodes[2]; + // The simulated players share one process, so they share what "the local player" changed: + // only the host's session sends it. + const std::vector boosted{1, 2, 3, 4, 5}, calmer{9, 8}; + const auto physics = [&] { for (auto &node : sim.nodes) update_physics_tuning(*node, sim.local, sim.now); }; + dingosdk::set_local_physics_extras(boosted); + physics(); + sim.run(10); + check(first.host_extras == boosted && second.host_extras == boosted, "The host's physics extras did not reach its guests"); + check(!host.host_extras, "The host took physics extras for itself"); + + update_physics_tuning(first, sim.local, sim.now); + std::uint64_t seen{}; + std::vector handed; + check(dingosdk::session_tuning_enforced() && dingosdk::host_physics_extras(seen, handed) && handed == boosted, + "A guest's game was not handed the host's physics extras"); + update_physics_tuning(host, sim.local, sim.now); + check(!dingosdk::session_tuning_enforced(), "A host was told another player sets its physics"); + + auto &late = sim.add(); + sim.run(60); sim.fresh(4); + check(late.host_extras == boosted, "A player who joined later did not get the host's physics extras"); + + dingosdk::set_local_physics_extras(calmer); + physics(); + sim.run(10); + check(first.host_extras == calmer && second.host_extras == calmer && late.host_extras == calmer, + "A change to the host's physics extras did not reach its guests"); + + // A guest cannot set the others' physics. + auto forged = packet(first, PacketKind::physics_extras, sim.now); + forged.extras = {6, 6, 6}; + broadcast(first, forged, true, false, sim.now); + sim.run(10); + check(second.host_extras == calmer && late.host_extras == calmer && !host.host_extras, + "A guest's physics extras were taken for the host's"); + + // Back to the game's own: sent, so no guest keeps what it had. + dingosdk::set_local_physics_extras({}); + physics(); + sim.run(10); + check(first.host_extras && first.host_extras->empty() && second.host_extras && second.host_extras->empty(), + "Guests kept physics extras the host no longer has"); + + // Enforcement off: nothing kept for late joiners, and a guest's game is its own again. + host.enforce_tuning = false; + host.roster_dirty = true; + physics(); + sim.run(20); + check(host.extras_packet.empty() && !host.sent_extras, "A host that stopped setting everyone's physics kept its extras packet"); + check(!first.enforce_tuning, "The guest was not told the host stopped setting everyone's physics"); + update_physics_tuning(first, sim.local, sim.now); + check(!dingosdk::session_tuning_enforced(), "A guest stayed under the host's physics after the host let go"); + dingosdk::set_session_tuning_enforced(false); + dingosdk::set_host_physics_extras({}); + std::cout << "Physics extras: host to guests, late joiners, changes, forged senders and enforcement off passed.\n"; +} // A lobby host keeps a guest whose mods change scoring or physics out of linked activities: the roster // flags them for everyone and the host passes none of their throwdown messages on. void scoring_checks() { @@ -1852,6 +1916,7 @@ int main(int argc, char **argv) { dingosdk::multiplayer::mesh_checks(); dingosdk::multiplayer::throwdown_routing_checks(); dingosdk::multiplayer::party_checks(); + dingosdk::multiplayer::physics_extras_checks(); dingosdk::multiplayer::scoring_checks(); dingosdk::multiplayer::object_sync_checks(); dingosdk::multiplayer::session_controls_checks(); diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index f8b93e11..efc3b58a 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -789,6 +789,19 @@ void dedicated_server_codec() { check(decode(encode(tuning)) && decode(encode(tuning))->tuning == tuning.tuning, "Physics tuning bytes lost"); tuning.tuning.assign(max_physics_tuning + 1, 0); check(reject(tuning), "Oversized physics tuning encoded"); + Packet extras; + extras.kind = PacketKind::physics_extras; extras.session = 9; extras.epoch = 10; extras.map = 11; extras.source = player; + check(decode(encode(extras)) && decode(encode(extras))->kind == PacketKind::physics_extras && decode(encode(extras))->extras.empty(), + "Empty physics extras (the game's own) lost"); + extras.extras = {1, 0xde, 0xad, 0xbe, 0xef, 0, 0, 0x80, 0x3f}; + check(decode(encode(extras)) && decode(encode(extras))->extras == extras.extras, "Physics extras bytes lost"); + extras.extras.assign(dingosdk::max_physics_extras, 7); + check(decode(encode(extras)) && decode(encode(extras))->extras.size() == dingosdk::max_physics_extras, "The largest physics extras lost"); + extras.extras.assign(dingosdk::max_physics_extras + 1, 0); + check(reject(extras), "Oversized physics extras encoded"); + extras.extras = {1}; + extras.source = 0; + check(reject(extras), "Physics extras from nobody encoded"); auto moved = roster; moved.members = {{player, 20, "Skater"}, {server, 10, "My server"}}; check(reject(moved), "A game server accepted as a guest"); diff --git a/Extension/Trainer/CHANGELOG.md b/Extension/Trainer/CHANGELOG.md index a2b59f1d..311b0919 100644 --- a/Extension/Trainer/CHANGELOG.md +++ b/Extension/Trainer/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## In ReSkate + +Changes made when the trainer moved into ReSkate itself. + +- **Nothing happens until you ask.** The jump read-out and the controller shortcuts start off, + jumps are written to the log only while the read-out or telemetry recording is on, and trick + launches only when the trainer scaled them. +- **The memory search runs on demand.** It used to run for every player after a level loaded, + and up to seven times when it found nothing. Now it runs once one of the tuning classes' + values or the flip speed is changed (yours, or a host's you skate with), or the EVERYTHING list + is open, and at most three times per level. It also runs again on each level for those + players: a level brings fresh copies. +- **A host's whole setup reaches its guests.** While a session's host sets everyone's physics, + guests get its class values, trick multipliers and auto push as well as its tuning, and their + own stand down; a player who joins later gets them too. Before, only the tuning travelled, so + a host could push or jump further than the guests it was holding to the game's own. +- **Guests are locked by the session's own switch**, from the moment they join, rather than by + whether the host's tuning had arrived yet. A guest's class values also no longer come back + after a map change in such a session. + ## v0.3.0 - 2026-10-04 Built on ReSkate 1.0.7. diff --git a/Extension/Trainer/README.md b/Extension/Trainer/README.md index f94b1bde..59753140 100644 --- a/Extension/Trainer/README.md +++ b/Extension/Trainer/README.md @@ -12,8 +12,11 @@ telemetry HUD. It ships no game data: the list of values is built at run time fr | **PRACTICE** | Game speed and pause, five marker slots per map (save / go / clear), return to the marker after a bail, teleport to coordinates, copy your position (game or Blender axes). | | **MAP & HUD** | Speed and air-time HUD, a read-out after every jump (takeoff speed and angle, height, distance, drop, landing speed, spin and flip), telemetry recording to CSV, and whatever the map's author ships for the trainer. | -Controller: hold **LB + RB**, then D-pad **up** saves the marker, **down** goes to it, **left / right** -pick the slot. +The HUD, the jump read-out and the controller shortcuts are off until you switch them on (MAP & HUD, +PRACTICE): a player who never opens the trainer sees and feels nothing of it. + +Controller, once switched on: hold **LB + RB**, then D-pad **up** saves the marker, **down** goes to it, +**left / right** pick the slot. Everything is also a console command (`~`): `trainer open [tune|practice|map|realistic|fun|everything]`, `trainer status`, `trainer set `, `trainer find `, `trainer preset apply|remove `, `trainer dial `, @@ -47,11 +50,16 @@ trainer, players get your spots and your recommended tuning on the MAP & HUD tab The trainer goes through ReSkate's own session rules instead of around them: -- A guest whose host sets the physics tuning cannot edit it; the page says so. +- While a session's host sets everyone's physics (the session's "enforce tuning", on by default), + a guest cannot edit anything here; the page says so. They skate with the host's whole setup: its + tuning through ReSkate's host-tuning sync, and its class values, trick multipliers and auto push + through the session's physics extras (`trainer_session.h`), sent whenever the host changes one + and to players who join later. On a dedicated server all of it is the game's own. +- With that switched off, everyone's physics are their own, and the trick multipliers and auto push + follow the host's boosts permission like ReSkate's other boosts. - Teleports and markers follow the host's noclip / teleport permission. - Game speed is ReSkate's `SimulationTime.TimeScale` setting, which ReSkate locks in a session. -- A host's tuning changes reach guests through ReSkate's existing host-tuning sync, and servers' - `score_check` / `enforce_tuning` see them like any other tuning mod. +- Servers' `score_check` / `enforce_tuning` see a player's tuning changes like any other tuning mod. It unlocks no cosmetics or entitlements. @@ -98,9 +106,12 @@ folder>` (CMake option `DINGOSDK_BUILD_TRAINER_TESTS`) lists the tuning values w (push speeds, everything on foot, dive and glide, bail speeds...). A live copy has no name to look up, so the trainer finds it by searching writable memory for the class's defaults laid out as the class lays them out (`trainer_classes.cpp`; the table in `trainer_classes.inc` is - generated from the game's own data). The search runs on its own thread a few seconds after a - level loads; `trainer classes` says what it found. Native code keeps its own copy of the push - speeds, found and written the same way. + generated from the game's own data). The search reads all of the game's writable memory, so it + only runs for a player with a use for it: one of those values or the flip speed is not the + game's own (theirs, or a host's they skate with), or the EVERYTHING list is open. Then it runs + on its own thread a few seconds after a level loads, and at most three times per level; + `trainer classes` says what it found. Native code keeps its own copy of the push speeds, found + and written the same way. - "Push speed" scales the push class's speeds (a tapped push, a held one, the top) and the tuning's top pushing speed, which only gates whether a push may start. Auto push is the trainer's doing as well (the game's flag only reaches its animation): once rolling and not braking, the skater gains speed up to the auto push speed. Push strength has no effect in this game build and is hidden. diff --git a/Extension/Trainer/Test/session_extras_tests.cpp b/Extension/Trainer/Test/session_extras_tests.cpp new file mode 100644 index 00000000..37883657 --- /dev/null +++ b/Extension/Trainer/Test/session_extras_tests.cpp @@ -0,0 +1,120 @@ +// What a session's host shares beyond its tuning (trainer_session.h): that it survives the +// trip to a guest, that nothing is sent for the game's own, and that bytes which are not an +// encoding, or values no trainer would hold, do not reach the game. +#include "Extension/Trainer/trainer_classes.h" +#include "Extension/Trainer/trainer_session.h" +#include "Engine/Game/Multiplayer/session_physics.h" + +#include +#include +#include +#include +#include + +namespace { +using namespace dingosdk::trainer; +int failures = 0; +void check(bool condition, const std::string &message) { + if (!condition) { std::cerr << "FAIL: " << message << '\n'; ++failures; } +} +// The encoding's layout, for the cases that break it: version, table, six numbers, count, rows. +constexpr std::size_t table_at = 1, numbers_at = 5, count_at = 29, rows_at = 31; +template void poke(std::vector &bytes, std::size_t at, T value) { std::memcpy(bytes.data() + at, &value, sizeof(T)); } +std::pair row(std::size_t index, float value) { return {static_cast(index), value}; } +} + +int main() { + check(encode_session_extras({}).empty(), "the game's own takes no bytes"); + const auto nothing = decode_session_extras({}); + check(nothing && nothing->stock(), "no bytes are the game's own"); + + SessionExtras host; + host.flip_speed = 0.5f; + host.hippy_height = 3.0f; + host.nocomply_height = 2.0f; + host.boneless_height = 1.5f; + host.offboard_height = 4.0f; + host.cruise = 8.5f; + host.classes = {row(0, 2.5f), row(7, -1.0f), row(class_field_count - 1, 12.0f)}; + const auto bytes = encode_session_extras(host); + check(!bytes.empty() && bytes.size() <= dingosdk::max_physics_extras, "a host's extras fit in a session's"); + const auto guest = decode_session_extras(bytes); + check(guest && *guest == host, "a guest reads what the host wrote"); + + // Every class value changed still fits. + SessionExtras everything; + for (std::size_t i = 0; i < class_field_count; ++i) everything.classes.push_back(row(i, static_cast(i) + 0.25f)); + const auto full = encode_session_extras(everything); + check(full.size() <= dingosdk::max_physics_extras, "every class value fits"); + const auto all = decode_session_extras(full); + check(all && all->classes.size() == class_field_count && all->classes.back().second == static_cast(class_field_count - 1) + 0.25f, + "every class value arrives"); + + // The writer puts the rows in order and drops what is not a field. + SessionExtras untidy; + untidy.classes = {row(9, 1.0f), row(3, 2.0f), row(9, 5.0f), row(class_field_count, 1.0f), row(4, std::numeric_limits::quiet_NaN())}; + const auto tidy = decode_session_extras(encode_session_extras(untidy)); + check(tidy && tidy->classes.size() == 2 && tidy->classes[0].first == 3 && tidy->classes[1].first == 9, + "rows are written in order, once each, and only for real fields"); + + // Only the multipliers changed: no rows. + SessionExtras heights; + heights.boneless_height = 2.0f; + const auto only = decode_session_extras(encode_session_extras(heights)); + check(only && *only == heights && encode_session_extras(heights).size() == rows_at, "multipliers alone travel without rows"); + + // Not an encoding. + const auto refused = [&](std::vector broken) { return !decode_session_extras(broken); }; + check(refused({1}), "a lone byte is refused"); + check(refused(std::vector(bytes.begin(), bytes.end() - 1)), "a short row is refused"); + { + auto more = bytes; + more.push_back(0); + check(refused(more), "bytes after the last row are refused"); + auto version = bytes; + version[0] = 2; + check(refused(version), "another version is refused"); + auto count = bytes; + poke(count, count_at, std::uint16_t{4}); + check(refused(count), "a count that does not match the rows is refused"); + auto order = bytes; + poke(order, rows_at + 6, std::uint16_t{0}); // the second row's index, now the first's + check(refused(order), "rows out of order are refused"); + auto beyond = bytes; + poke(beyond, rows_at + 12, static_cast(class_field_count)); + check(refused(beyond), "a row for a field the table does not have is refused"); + auto nan = bytes; + poke(nan, rows_at + 2, std::numeric_limits::quiet_NaN()); + check(refused(nan), "a value that is not a number is refused"); + auto infinite = bytes; + poke(infinite, numbers_at, std::numeric_limits::infinity()); + check(refused(infinite), "a multiplier that is not a number is refused"); + check(refused(std::vector(dingosdk::max_physics_extras + 1, 1)), "more than a session carries is refused"); + } + + // Values no trainer holds are brought into range rather than handed to the game. + { + auto wild = bytes; + poke(wild, numbers_at, 50.0f); // flip speed + poke(wild, numbers_at + 4, 1.0e9f); // hippy jump height + poke(wild, numbers_at + 8, -3.0f); // no comply height + poke(wild, numbers_at + 20, 5000.0f); // auto push speed + poke(wild, rows_at + 2, 1.0e12f); // a class value + const auto held = decode_session_extras(wild); + check(held && held->flip_speed == flip_high && held->hippy_height == height_high && held->nocomply_height == height_low && + held->cruise == 0.0f && held->classes[0].second == 1.0e6f, + "multipliers and values are held to what the trainer allows"); + } + + // A host on another game build's table: the multipliers still apply, the rows mean nothing. + { + auto other = bytes; + poke(other, table_at, class_table_id() + 1); + const auto shared = decode_session_extras(other); + check(shared && shared->classes.empty() && shared->hippy_height == host.hippy_height && shared->cruise == host.cruise, + "another table's rows are left out, the multipliers kept"); + } + + if (failures == 0) std::cout << "trainer session extras tests passed\n"; + return failures == 0 ? 0 : 1; +} diff --git a/Extension/Trainer/trainer.cpp b/Extension/Trainer/trainer.cpp index 2aa6d42c..26c2487c 100644 --- a/Extension/Trainer/trainer.cpp +++ b/Extension/Trainer/trainer.cpp @@ -2,11 +2,13 @@ #include "trainer_classes.h" #include "trainer_jump.h" #include "trainer_presets.h" +#include "trainer_session.h" #include "Engine/Core/Json/json.h" #include "Engine/Core/Log/logging.h" #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/no_bail.h" #include "Engine/Game/Build/20260929/physics_tuning.h" +#include "Engine/Game/Multiplayer/session_physics.h" #include "Engine/Game/Multiplayer/session_tools.h" #include "Extension/Profile/local_profile_runtime.h" #include "Extension/Skater/client_source_spawn.h" @@ -33,7 +35,6 @@ namespace { namespace tuning = dingosdk::physics_tuning; using Vec3 = std::array; constexpr float max_coordinate = 1.0e6f; -constexpr float height_low = 0.05f, height_high = 1.0e6f; // trick height multipliers (the sliders stop far short; a typed number need not) constexpr std::uint32_t air_states_first = 200, air_states_end = 300; constexpr std::uint32_t xinput_up = 0x1, xinput_down = 0x2, xinput_left = 0x4, xinput_right = 0x8, xinput_lb = 0x100, xinput_rb = 0x200; @@ -86,6 +87,11 @@ struct Motion { float board_turn{}; // the board's fastest turn rate in this jump, degrees per second std::uint32_t air_state{}, ground_state{}; }; +// The trick multipliers and auto push in force: x of the game's own, and for auto push the +// speed a rolling skater is carried up to (m/s, 0: off). +struct Boosts { + float flip{1}, hippy{1}, nocomply{1}, boneless{1}, offboard{1}, cruise{}; +}; struct State { bool loaded{}; // the store was read std::shared_ptr model; @@ -106,7 +112,7 @@ struct State { std::string map; MapFile map_file; int slot{}; - bool auto_return{}, pad_shortcuts{true}, hud{}, hud_jump{true}, logging{}; + bool auto_return{}, pad_shortcuts{}, hud{}, hud_jump{}, logging{}; // The hippy jump's height is set by the game's trick scripts, not by tuning: the trainer // scales the upward velocity when it sees one start. float hippy_height{1}; @@ -125,7 +131,17 @@ struct State { std::uint64_t seen_wipeouts{}, return_at{}, open_serial{}; std::uint64_t class_search_at{}; // when to look for the game's tuning classes (0: not due) int class_search_tries{}; - bool classes_stock{}; // a session's host tuning is in force: the classes hold their defaults + std::uint64_t class_search_seen{}; // the searches that had finished when this level loaded + bool class_list_wanted{}; // the player has the list of every value open on this level + std::vector class_entries; // the entries that are fields of those classes + // A session whose host sets everyone's physics (session_physics.h): what the host shares + // beyond its tuning, and what each class field holds for it. The player's own stand down. + bool enforced{}; + std::uint64_t host_revision{}; + SessionExtras host; + std::array host_classes{}; + Boosts boosts; // what is in force this tick + bool extras_due{true}; // the player's own extras are to be published for a session again bool wipeouts_known{}; // the wipeout count the session started with was read int open_tab{}; std::uint32_t pad_previous{}; @@ -218,14 +234,14 @@ void load_store() { if (json->contains("options") && json->at("options").is_object()) { const auto &o = json->at("options"); s.auto_return = o.value("auto_return", false); - s.pad_shortcuts = o.value("pad_shortcuts", true); + s.pad_shortcuts = o.value("pad_shortcuts", false); s.hud = o.value("hud", false); - s.hud_jump = o.value("hud_jump", true); + s.hud_jump = o.value("hud_jump", false); s.return_delay = std::clamp(o.value("return_delay", 1.5f), 0.0f, 10.0f); s.hippy_height = std::clamp(o.value("hippy_height", 1.0f), height_low, height_high); s.nocomply_height = std::clamp(o.value("nocomply_height", 1.0f), height_low, height_high); s.offboard_height = std::clamp(o.value("offboard_height", 1.0f), height_low, height_high); - s.flip_speed = std::clamp(o.value("flip_speed", 1.0f), 0.1f, 3.0f); + s.flip_speed = std::clamp(o.value("flip_speed", 1.0f), flip_low, flip_high); s.boneless_height = std::clamp(o.value("boneless_height", 1.0f), height_low, height_high); s.slot = std::clamp(o.value("slot", 0), 0, static_cast(marker_slots) - 1); } @@ -327,6 +343,7 @@ void save_store() { void changed(bool save = true) { auto &s = state(); s.view_due = true; + s.extras_due = true; if (save) { s.save_due = true; s.next_save = GetTickCount64() + 1500; @@ -383,10 +400,17 @@ double sane(const Entry &e, double value) { if (e.kind == Kind::curve || e.kind == Kind::graph) return std::clamp(value, 0.0, 1.0e6); return std::clamp(value, -1.0e6, 1.0e6); } +// What a class field should hold: the player's value, or the host's while a session's host +// sets everyone's physics. +void want_class(const Entry &e) { + auto &s = state(); + const auto field = static_cast(e.class_field); + want_class_value(field, s.enforced ? s.host_classes[field] : static_cast(e.touched ? e.value : e.stock)); +} void apply_entry(Entry &e) { auto &s = state(); if (e.class_field >= 0) { - want_class_value(static_cast(e.class_field), static_cast(e.touched ? e.value : e.stock)); + want_class(e); return; } if (e.kind == Kind::graph) { @@ -446,6 +470,7 @@ void build_entries() { auto &s = state(); s.entries.clear(); s.index.clear(); + s.class_entries.clear(); const auto &m = *s.model; const auto add = [&](std::string name, Kind kind, std::uint16_t offset, std::uint16_t size, std::size_t curve) { if (name.empty()) name = std::format("Unnamed.0x{:x}", offset); @@ -516,6 +541,7 @@ void build_entries() { e.class_field = static_cast(class_specs[c].first + i); e.stock = e.value = field.stock; s.index.emplace(e.key, s.entries.size()); + s.class_entries.push_back(s.entries.size()); s.entries.push_back(std::move(e)); } for (const auto &link : value_links()) { @@ -524,6 +550,9 @@ void build_entries() { s.entries[from->second].drives.push_back(to->second); s.entries[from->second].used = true; } + // A host's values may have arrived before there were entries to hold them. + for (const auto index : s.class_entries) want_class(s.entries[index]); + s.extras_due = true; say(logging::Level::info, std::format("Trainer: {} tuning values and {} curves are editable.", m.fields.size(), m.curve_slots.size())); } // A new copy of the asset (first sight, or a level load): its values are the stock ones. @@ -564,14 +593,68 @@ void adopt(const tuning::Values &live) { } std::string apply_preset(std::string_view name); bool editable(std::string *why = nullptr) { - // A guest's tuning is the host's (physics_tuning::enforce); writing over it would only - // fight the session, so the trainer stands down while that is in force. - if (multiplayer_session_active() && !tuning::status().empty()) { - if (why) *why = "This session's host sets the physics tuning."; + // A guest's physics are the host's while the session enforces them: the tuning + // (physics_tuning::enforce), and what the host shares beyond it (sync_session). Writing + // over either would only fight the session, so the player's own stand down. + if (session_tuning_enforced()) { + if (why) *why = "This session's host sets everyone's physics: you skate with the host's."; return false; } return true; } +// Auto push is the trainer's doing: the game's flag reaches only its animation. +float own_cruise() { + const auto *auto_push = find_entry("physicsmode.autopushenabled"), *auto_speed = find_entry("physicspush.maxspeedforautopush"); + return auto_push && auto_speed && auto_push->touched && auto_push->value != 0 ? static_cast(auto_speed->value) : 0.0f; +} +// The host's where a session's host sets everyone's physics, whatever the boosts switch says +// (that switch is about a guest's own); otherwise the player's, and the game's own in a +// session that has turned boosts off. +Boosts boosts_in_force() { + auto &s = state(); + if (s.enforced) + return {s.host.flip_speed, s.host.hippy_height, s.host.nocomply_height, s.host.boneless_height, s.host.offboard_height, s.host.cruise}; + if (multiplayer_session_active() && !session_boosts_allowed()) return {}; + return {s.flip_speed, s.hippy_height, s.nocomply_height, s.boneless_height, s.offboard_height, own_cruise()}; +} +// The player's own, for a session they host to share with its guests. +void publish_extras() { + auto &s = state(); + s.extras_due = false; + SessionExtras own; + own.flip_speed = s.flip_speed; + own.hippy_height = s.hippy_height; + own.nocomply_height = s.nocomply_height; + own.boneless_height = s.boneless_height; + own.offboard_height = s.offboard_height; + own.cruise = own_cruise(); + for (const auto index : s.class_entries) + if (const auto &e = s.entries[index]; e.touched) + own.classes.emplace_back(static_cast(e.class_field), static_cast(e.value)); + set_local_physics_extras(encode_session_extras(own)); +} +// Follows a session into and out of its host's physics, and the host's changes while there. +void sync_session() { + auto &s = state(); + const bool enforced = session_tuning_enforced(); + std::vector bytes; + bool fresh = false; + if (enforced) fresh = host_physics_extras(s.host_revision, bytes); + else s.host_revision = 0; // read again the next time a host sets the physics + if (enforced == s.enforced && !fresh) return; + if (fresh) { + auto decoded = decode_session_extras(bytes); + if (!decoded) say(logging::Level::warning, "Trainer: what the host shares beyond its tuning could not be read; the game's own is used for it."); + s.host = decoded ? std::move(*decoded) : SessionExtras{}; + for (std::size_t i = 0; i < class_field_count; ++i) s.host_classes[i] = class_fields[i].stock; + for (const auto &[index, value] : s.host.classes) s.host_classes[index] = value; + if (!s.host.stock()) + say(logging::Level::info, std::format("Trainer: skating with the host's trick multipliers and {} of its class values.", s.host.classes.size())); + } + s.enforced = enforced; + for (const auto index : s.class_entries) want_class(s.entries[index]); + changed(false); +} void hold(std::uint64_t now) { auto &s = state(); if (!s.base) return; @@ -852,6 +935,8 @@ void enter_map(const std::string &level) { // A level brings fresh copies of the game's tuning classes: look for them once it has settled. s.class_search_at = level.empty() ? 0 : GetTickCount64() + 10000; s.class_search_tries = 0; + s.class_search_seen = class_searches(); + s.class_list_wanted = false; if (!level.empty()) { const auto found = s.maps.find(level); s.profile_due = found != s.maps.end() && !found->second.preset.empty(); @@ -918,6 +1003,8 @@ void finish_jump(const Vec3 &landing, float landing_speed, std::int64_t landed_a jump.state = m.air_state; s.telemetry.last = jump; if (jump.distance > s.telemetry.best.distance) s.telemetry.best = jump; + // In the log for a player who is measuring: the read-out is on, or telemetry is recorded. + if (!s.hud_jump && !s.logging) return; say(logging::Level::info, std::format("Trainer jump {}: takeoff {:.1f} km/h at {:.1f} deg, air {:.2f} s, height {:.2f} m, distance {:.2f} m, drop {:.2f} m, " "landing {:.1f} km/h at ({:.1f}, {:.1f}, {:.1f}); spin {:.0f} deg (peak {:.0f} deg/s), flip {:.0f} deg, " @@ -960,8 +1047,8 @@ void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) { // A hippy jump leaves the board (504) straight from riding (100): measured. A plain // dismount does the same but is not rising, so it is left alone. if (watch.valid && watch.state != s.last_state) { - if (watch.state == addr::no_bail::offboard_physics_state && s.last_state == 100 && s.hippy_height != 1.0f) { - s.boost_factor = std::sqrt(s.hippy_height); // height goes with the square of the speed + if (watch.state == addr::no_bail::offboard_physics_state && s.last_state == 100 && s.boosts.hippy != 1.0f) { + s.boost_factor = std::sqrt(s.boosts.hippy); // height goes with the square of the speed s.boost_until = now + 250; s.boost_name = "hippy jump"; } @@ -975,7 +1062,8 @@ void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) { s.boost_factor = 0; } else if (now >= s.boost_until || result.outcome == -2) { s.boost_factor = 0; - } else if (!multiplayer_session_active() || session_boosts_allowed()) { + } else { + // The factor comes from the multipliers in force, so a session's rules are already in it. (void)queue_jump_scale(client, s.entity, s.boost_factor); // again each tick until the skater is rising } } @@ -998,8 +1086,8 @@ void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) { // An on-foot jump: off the board, level at the last tick and now rising faster than stairs // or a slope can lift a runner. if (watch.valid && watch.state == addr::no_bail::offboard_physics_state) { - if (s.offboard_grounded && raw[1] > 2.2f && s.boost_factor == 0 && s.offboard_height != 1.0f) { - s.boost_factor = std::sqrt(s.offboard_height); + if (s.offboard_grounded && raw[1] > 2.2f && s.boost_factor == 0 && s.boosts.offboard != 1.0f) { + s.boost_factor = std::sqrt(s.boosts.offboard); s.boost_until = now + 250; s.boost_name = "off-board jump"; } @@ -1307,6 +1395,8 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool playing, const std::s const auto now = GetTickCount64(); if (!s.loaded) load_store(); s.base = base; + sync_session(); + s.boosts = boosts_in_force(); if (const auto key = lower(playing ? level : std::string{}); key != s.map) enter_map(key); if (playing) { observe(base, client, now); @@ -1322,34 +1412,37 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool playing, const std::s s.entity = 0; } (void)start_trick_heights(base); - if (s.class_search_at && now >= s.class_search_at && playing && s.telemetry.skater && !finding_classes()) { - // Until the push class turns up: the skater's own classes arrive a little after it does. - const bool have = class_searches() && class_copies(0) != 0; - s.class_search_at = have || ++s.class_search_tries > 6 ? 0 : now + 15000; - if (!have) find_classes(); + if (take_class_list_shown()) s.class_list_wanted = true; + // The search reads all of the game's writable memory, so it runs only for a player with + // a use for it: one of the classes' values or the flip speed is not the game's own + // (theirs, or a host's they skate with), or they have the list of every value open. + if (s.class_search_at && now >= s.class_search_at && playing && s.telemetry.skater && !finding_classes() && + (s.class_list_wanted || classes_wanted())) { + // A level brings fresh copies, so only a search finished since it loaded counts. The + // skater's own classes arrive a little after the skater does: a search that came up + // empty is repeated, twice. + const bool have = class_searches() > s.class_search_seen && class_copies(0) != 0; + if (have || s.class_search_tries >= 3) { + s.class_search_at = 0; + } else { + ++s.class_search_tries; + s.class_search_at = now + 15000; + find_classes(); + } } - if (const bool stock = !editable(); stock != s.classes_stock) { - s.classes_stock = stock; - for (auto &e : s.entries) - if (e.class_field >= 0) want_class_value(static_cast(e.class_field), static_cast(e.touched && !stock ? e.value : e.stock)); - } - want_flip_speed((!multiplayer_session_active() || session_boosts_allowed()) && editable() ? s.flip_speed : 1.0f); + want_flip_speed(s.boosts.flip); (void)apply_classes(); - // Like the hippy jump's, these are boosts: a session that turns boosts off turns them off. - const bool boosts = !multiplayer_session_active() || session_boosts_allowed(); - set_trick_heights(boosts ? s.nocomply_height : 1.0f, boosts ? s.boneless_height : 1.0f); + set_trick_heights(s.boosts.nocomply, s.boosts.boneless); // The speeds pushes aim for are the push class's (value_links ties them to the tuning's top // pushing speed, which only gates whether a push may start). const auto *top_speed = find_entry("physicspush.maxpushablespeed"); - // Auto push is the trainer's doing as well: the game's flag reaches only its animation. - const auto *auto_push = find_entry("physicsmode.autopushenabled"), *auto_speed = find_entry("physicspush.maxspeedforautopush"); - const bool cruising = boosts && editable() && auto_push && auto_speed && auto_push->touched && auto_push->value != 0; - set_push_speed(client, s.entity, 1.0f, top_speed ? static_cast(top_speed->stock) : 0.0f, - cruising ? static_cast(auto_speed->value) : 0.0f); + set_push_speed(client, s.entity, 1.0f, top_speed ? static_cast(top_speed->stock) : 0.0f, s.boosts.cruise); for (TrickLaunch launch; take_trick_launch(launch);) - say(logging::Level::info, std::format("Trainer trick: {} launched at {:.2f} m/s up, x{:.2f}.", - launch.trick == Trick::boneless ? "boneless" : "no comply", launch.up_speed, launch.factor)); + if (launch.factor != 1.0f) + say(logging::Level::info, std::format("Trainer trick: {} launched at {:.2f} m/s up, x{:.2f}.", + launch.trick == Trick::boneless ? "boneless" : "no comply", launch.up_speed, launch.factor)); hold(now); + if (s.extras_due) publish_extras(); run_selftest(now); publish(s.telemetry); if (s.view_due) build_view(); @@ -1523,13 +1616,16 @@ std::string run(std::string_view verb, const std::vector &a) { if (v == "option") { const auto name = lower(arg(0)); bool on{}; + std::string why; if (name == "flip_speed") { const auto value = number(arg(1)); if (!value) return "error: flip_speed needs a multiplier, 1 = the game's own speed."; - s.flip_speed = std::clamp(static_cast(*value), 0.1f, 3.0f); + if (!editable(&why)) return "error: " + why; + s.flip_speed = std::clamp(static_cast(*value), flip_low, flip_high); } else if (name == "hippy_height" || name == "nocomply_height" || name == "boneless_height" || name == "offboard_height") { const auto value = number(arg(1)); if (!value) return "error: " + name + " needs a multiplier, 1 = the game's own height."; + if (!editable(&why)) return "error: " + why; (name == "hippy_height" ? s.hippy_height : name == "nocomply_height" ? s.nocomply_height : name == "offboard_height" ? s.offboard_height : s.boneless_height) = std::clamp(static_cast(*value), height_low, height_high); } else if (name == "return_delay") { diff --git a/Extension/Trainer/trainer.h b/Extension/Trainer/trainer.h index 2bdd3a76..b0cb44a4 100644 --- a/Extension/Trainer/trainer.h +++ b/Extension/Trainer/trainer.h @@ -57,7 +57,7 @@ struct View { bool ready{}; // the game's tuning is read and the running game's copy was found std::string status; // why not std::string last; // what the last command answered - bool editable{}; // false while a session's host tuning is enforced + bool editable{}; // false while a session's host sets everyone's physics (the host's apply) std::string blocked; // the reason shown on locked rows std::vector rows; std::vector groups; @@ -68,7 +68,7 @@ struct View { std::array markers{}; bool auto_return{}; float return_delay{1.5f}; - bool pad_shortcuts{true}; + bool pad_shortcuts{}; // Height of the hippy jump, which the game scripts instead of tuning (x of its own height). float hippy_height{1}; // The same for the no comply and the boneless. @@ -76,7 +76,7 @@ struct View { float offboard_height{1}; // a jump on foot float flip_speed{1}; // board flip tricks: x of the game's own speed // HUD - bool hud{}, hud_jump{true}, logging{}; + bool hud{}, hud_jump{}, logging{}; // The loaded map and what its author ships for the trainer (Mods//trainer.json). std::string map, map_note, map_preset, profile_preset; std::vector spots; @@ -112,6 +112,11 @@ std::shared_ptr view() noexcept; Telemetry telemetry() noexcept; void publish(std::shared_ptr) noexcept; void publish(const Telemetry &) noexcept; +// The menu is drawing the list of every value, the game's tuning classes among them. Those +// have to be found in memory before an edit to one does anything, and the search is only run +// for a player who has a use for it: this is one. Any thread; the game thread takes the note. +void note_class_list_shown() noexcept; +bool take_class_list_shown() noexcept; // Game thread (trainer.cpp). // Each client tick; `playing` while a local skater can exist, `level` the loaded level asset. diff --git a/Extension/Trainer/trainer_classes.cpp b/Extension/Trainer/trainer_classes.cpp index 5f581bd2..beb56615 100644 --- a/Extension/Trainer/trainer_classes.cpp +++ b/Extension/Trainer/trainer_classes.cpp @@ -400,6 +400,14 @@ void want_class_value(std::size_t field, float value) noexcept { f.dirty = true; } } +bool classes_wanted() noexcept { + auto &f = found(); + std::lock_guard lock(f.mutex); + if (f.flip_wanted != 1) return true; + for (std::size_t i = 0; i < class_field_count; ++i) + if (f.wanted[i] != class_fields[i].stock) return true; + return false; +} std::size_t apply_classes() noexcept { auto &f = found(); std::unique_lock lock(f.mutex, std::try_to_lock); diff --git a/Extension/Trainer/trainer_classes.h b/Extension/Trainer/trainer_classes.h index 9ac4e0e1..2fb29201 100644 --- a/Extension/Trainer/trainer_classes.h +++ b/Extension/Trainer/trainer_classes.h @@ -38,6 +38,9 @@ std::uint64_t class_searches() noexcept; std::size_t class_copies(std::size_t index) noexcept; // What field `field` (an index into class_fields) should hold. Written by apply_classes. void want_class_value(std::size_t field, float value) noexcept; +// Whether anything is wanted that is not the game's own (a field, or the flip speed): until +// then there is nothing to write and no reason to search. +bool classes_wanted() noexcept; // Writes the wanted values into every copy found; call each tick. Returns the fields written. std::size_t apply_classes() noexcept; std::string classes_summary(); diff --git a/Extension/Trainer/trainer_page.cpp b/Extension/Trainer/trainer_page.cpp index c220831c..ff850b5b 100644 --- a/Extension/Trainer/trainer_page.cpp +++ b/Extension/Trainer/trainer_page.cpp @@ -263,7 +263,9 @@ void tune_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, ImGui::TextDisabled("%zu values changed", view.touched); end_card(); + ImGui::BeginDisabled(!can_edit); trick_heights(menu, callbacks, p, view); + ImGui::EndDisabled(); if (p.mode == 2) { begin_card(menu, "preset-save", "YOUR PRESETS", "Save what is changed right now"); @@ -311,6 +313,9 @@ void tune_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks, ImGui::Spacing(); ImGui::SeparatorText(p.mode == 2 ? "EVERY VALUE" : "FINE TUNING"); if (p.mode == 2) { + // The list includes the game's tuning classes, which the trainer has to find in memory + // before an edit to one does anything: with the list open, it looks now. + trainer::note_class_list_shown(); ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.42f); ImGui::InputTextWithHint("##search", "Search every value...", p.search.data(), p.search.size()); ImGui::SameLine(); diff --git a/Extension/Trainer/trainer_session.cpp b/Extension/Trainer/trainer_session.cpp new file mode 100644 index 00000000..40e37b46 --- /dev/null +++ b/Extension/Trainer/trainer_session.cpp @@ -0,0 +1,107 @@ +#include "trainer_session.h" +#include "trainer_classes.h" +#include "Engine/Game/Multiplayer/session_physics.h" +#include +#include +#include +#include +#include + +namespace dingosdk::trainer { +namespace { +// version, table id, six multipliers, count; then an index and a value per class field. +constexpr std::uint8_t version = 1; +constexpr std::size_t header = 1 + 4 + 6 * 4 + 2, row = 2 + 4; +static_assert(header + class_field_count * row <= max_physics_extras, "every class value must fit in a session's physics extras"); +static_assert(class_field_count <= 0xffff); +constexpr float class_value_high = 1.0e6f; // what the trainer holds its own edits to + +template void put(std::vector &out, T value) { + std::array bytes; + std::memcpy(bytes.data(), &value, sizeof(T)); + out.insert(out.end(), bytes.begin(), bytes.end()); +} +template T take(std::span bytes, std::size_t &at) { + T value; + std::memcpy(&value, bytes.data() + at, sizeof(T)); + at += sizeof(T); + return value; +} +} // namespace + +std::uint32_t class_table_id() noexcept { + // FNV-1a over what an index means: each class's key and each field's name and place. + static const std::uint32_t id = [] { + std::uint32_t hash = 2166136261u; + const auto mix = [&](std::string_view text) { + for (const char c : text) hash = (hash ^ static_cast(c)) * 16777619u; + hash = (hash ^ 0xffu) * 16777619u; + }; + for (std::size_t c = 0; c < class_count; ++c) { + mix(class_specs[c].key); + for (std::size_t i = 0; i < class_specs[c].count; ++i) { + const auto &field = class_fields[class_specs[c].first + i]; + mix(field.name); + hash = (hash ^ field.offset) * 16777619u; + } + } + return hash; + }(); + return id; +} + +std::vector encode_session_extras(const SessionExtras &extras) { + std::vector out; + if (extras.stock()) return out; + out.reserve(header + extras.classes.size() * row); + put(out, version); + put(out, class_table_id()); + for (const float value : {extras.flip_speed, extras.hippy_height, extras.nocomply_height, extras.boneless_height, + extras.offboard_height, extras.cruise}) + put(out, value); + auto classes = extras.classes; + std::erase_if(classes, [](const auto &entry) { return entry.first >= class_field_count || !std::isfinite(entry.second); }); + std::ranges::stable_sort(classes, {}, [](const auto &entry) { return entry.first; }); + classes.erase(std::unique(classes.begin(), classes.end(), [](const auto &a, const auto &b) { return a.first == b.first; }), classes.end()); + put(out, static_cast(classes.size())); + for (const auto &[index, value] : classes) { + put(out, index); + put(out, value); + } + return out; +} + +std::optional decode_session_extras(std::span bytes) { + SessionExtras extras; + if (bytes.empty()) return extras; + if (bytes.size() < header || bytes.size() > max_physics_extras || bytes[0] != version) return std::nullopt; + std::size_t at = 1; + const auto table = take(bytes, at); + std::array numbers; + for (auto &number : numbers) { + number = take(bytes, at); + if (!std::isfinite(number)) return std::nullopt; + } + extras.flip_speed = std::clamp(numbers[0], flip_low, flip_high); + extras.hippy_height = std::clamp(numbers[1], height_low, height_high); + extras.nocomply_height = std::clamp(numbers[2], height_low, height_high); + extras.boneless_height = std::clamp(numbers[3], height_low, height_high); + extras.offboard_height = std::clamp(numbers[4], height_low, height_high); + extras.cruise = numbers[5] > 0 && numbers[5] < cruise_high ? numbers[5] : 0.0f; + const std::size_t count = take(bytes, at); + if (bytes.size() != header + count * row) return std::nullopt; + const bool same_table = table == class_table_id(); + int previous = -1; + for (std::size_t i = 0; i < count; ++i) { + const auto index = take(bytes, at); + const auto value = take(bytes, at); + // Another table's rows mean nothing here, but the encoding still has to be one. + if (!std::isfinite(value) || static_cast(index) <= previous) return std::nullopt; + previous = index; + if (!same_table) continue; + if (index >= class_field_count) return std::nullopt; + extras.classes.emplace_back(index, std::clamp(value, -class_value_high, class_value_high)); + } + return extras; +} +} // namespace dingosdk::trainer diff --git a/Extension/Trainer/trainer_session.h b/Extension/Trainer/trainer_session.h new file mode 100644 index 00000000..4430296f --- /dev/null +++ b/Extension/Trainer/trainer_session.h @@ -0,0 +1,41 @@ +#pragma once +// What the trainer changes outside Gameplay/SkatePhysicsTuning, as a session's host shares it +// with its guests: the tuning asset travels as the session's physics tuning already +// (Extension/Skater/physics_tuning.h); this is the rest, so that "the host's physics for +// everyone" means all of it. The bytes are the session's physics extras +// (Engine/Game/Multiplayer/session_physics.h). No game access: plain data in, plain data out. +#include +#include +#include +#include +#include + +namespace dingosdk::trainer { +// What the trick multipliers may be, here and in the trainer's own options. +inline constexpr float height_low = 0.05f, height_high = 1.0e6f; // heights; the sliders stop far short, a typed number need not +inline constexpr float flip_low = 0.1f, flip_high = 3.0f; // board flip speed: the game's own limit takes over above +inline constexpr float cruise_high = 100.0f; // auto push, m/s + +struct SessionExtras { + // x of the game's own: board flip speed, and the heights of the hippy jump, no comply, + // boneless and on-foot jump. + float flip_speed{1}, hippy_height{1}, nocomply_height{1}, boneless_height{1}, offboard_height{1}; + // Auto push: the speed a rolling skater is carried up to, m/s; 0: off. + float cruise{}; + // Values of the game's tuning classes that are not the game's own: an index into + // class_fields (trainer_classes.h) and the value, by ascending index. + std::vector> classes; + bool operator==(const SessionExtras &) const = default; + // Nothing differs from the game's own. + bool stock() const { return *this == SessionExtras{}; } +}; +// Identifies the table of class fields the indices refer to (the game build's): a host and a +// guest whose tables differ share the multipliers only. +std::uint32_t class_table_id() noexcept; +// Nothing at all for extras that are the game's own. +std::vector encode_session_extras(const SessionExtras &extras); +// A host's bytes: empty is the game's own; nullopt is not an encoding (also the game's own to +// whoever asks, and worth a log line). Multipliers are held to the ranges above, and class +// values written for another table are left out. +std::optional decode_session_extras(std::span bytes); +} // namespace dingosdk::trainer diff --git a/Extension/Trainer/trainer_view.cpp b/Extension/Trainer/trainer_view.cpp index c7d4e15c..d716fd9d 100644 --- a/Extension/Trainer/trainer_view.cpp +++ b/Extension/Trainer/trainer_view.cpp @@ -1,4 +1,5 @@ #include "trainer.h" +#include #include // The snapshots the menu and HUD read. Kept apart from trainer.cpp so the overlay library @@ -9,6 +10,7 @@ struct Feed { std::mutex mutex; std::shared_ptr view = std::make_shared(); Telemetry telemetry; + std::atomic class_list_shown{}; }; Feed &feed() { static auto *value = new Feed; @@ -36,4 +38,6 @@ void publish(const Telemetry &next) noexcept { std::lock_guard lock(f.mutex); f.telemetry = next; } +void note_class_list_shown() noexcept { feed().class_list_shown.store(true, std::memory_order_release); } +bool take_class_list_shown() noexcept { return feed().class_list_shown.exchange(false, std::memory_order_acq_rel); } } // namespace dingosdk::trainer diff --git a/Server/server_host.cpp b/Server/server_host.cpp index d054b412..1a452eb5 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -566,7 +566,8 @@ void Host::receive(std::uint64_t peer, std::span bytes) { } if (p.kind == PacketKind::roster) return drop(peer, "Only the server may publish the player roster."); if (p.kind == PacketKind::teleport) return drop(peer, "Only the server may teleport players."); - if (p.kind == PacketKind::physics_tuning) return; // a listen host's; the server's is the game's own + // A listen host's; the server's physics are the game's own. + if (p.kind == PacketKind::physics_tuning || p.kind == PacketKind::physics_extras) return; if (p.kind == PacketKind::chat) { if (!routed_source(p, link->member, peer, true, id_) || link->chat_rate.accept(now_, p.text, 1) != ChatRate::Verdict::accepted) diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 253f3553..2ac9edc5 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -112,6 +112,7 @@ add_library(dingosdk_runtime SHARED Extension/Trainer/trainer_jump.cpp Extension/Trainer/trainer_classes.cpp Extension/Trainer/trainer_commands.cpp + Extension/Trainer/trainer_session.cpp Extension/Skater/offboard_flight.cpp Extension/Skater/camera_observer.cpp Extension/Boot/offline_boot.cpp From 8fa971ceae95a06e813940e89a1ec58552671546 Mon Sep 17 00:00:00 2001 From: WackyHcky Date: Mon, 5 Oct 2026 06:22:24 +1100 Subject: [PATCH 021/109] Raise physics world body pool for large custom maps Stock worlds allocate 8,200 bodies (+1 internal) from FBPhysicsWorldSize. Studio maps give every collision part its own static body, so large maps (e.g. a 41k-part GTA IV export) hit the FatalAssert "bodyIndex < m_freeBodyList.capacity()" in FBPhysicsWorld::asyncAllocateBody and crash on load. Hook fb::FBPhysicsWorld creation (RVA 0x02606ff0) and raise the size descriptor before it is copied into the job arguments, so every free list and per-body array is sized consistently. BodyCount becomes 65,000 (kept below 16 bits); the pools a static mesh body also draws from (geometry, owner, hull/mesh, shape, aggregate/fixed shape, fixed partition, persistent spatial index) scale by the same factor, capped at 2^20. Contacts, joints, queries and culling tables keep their native sizes. Both native callers build the descriptor on their own stack, so raising it in place only affects that world. Contract bytes for the entry, the descriptor copy, the internal-body increment and both call sites are validated before the hook is enabled. Co-Authored-By: Claude Opus 5.5 --- .../Game/Build/20260929/physics_world_size.h | 72 +++++++++ Extension/World/physics_world_size.cpp | 145 ++++++++++++++++++ Extension/World/physics_world_size.h | 18 +++ Runtime/bootstrap.cpp | 3 + cmake/Runtime.cmake | 1 + 5 files changed, 239 insertions(+) create mode 100644 Engine/Game/Build/20260929/physics_world_size.h create mode 100644 Extension/World/physics_world_size.cpp create mode 100644 Extension/World/physics_world_size.h diff --git a/Engine/Game/Build/20260929/physics_world_size.h b/Engine/Game/Build/20260929/physics_world_size.h new file mode 100644 index 00000000..90ef97a6 --- /dev/null +++ b/Engine/Game/Build/20260929/physics_world_size.h @@ -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 +#include +#include +#include + +namespace dingosdk::game::build::v20260929::physics_world_size { +template consteval auto hex_bytes(const char (&hex)[N]) { + static_assert(N % 2 == 1); + std::array result{}; + const auto nibble = [](char c) -> unsigned { + if (c >= '0' && c <= '9') return static_cast(c - '0'); + if (c >= 'a' && c <= 'f') return static_cast(c - 'a' + 10); + throw "Invalid physics world size contract hex"; + }; + for (std::size_t i = 0; i < result.size(); ++i) + result[i] = static_cast((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 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 diff --git a/Extension/World/physics_world_size.cpp b/Extension/World/physics_world_size.cpp new file mode 100644 index 00000000..517f81c0 --- /dev/null +++ b/Extension/World/physics_world_size.cpp @@ -0,0 +1,145 @@ +#include "physics_world_size.h" +#include "Engine/Game/Build/supported_build.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/physics_world_size.h" +#include "Engine/Core/Log/logging.h" + +#include +#include "Engine/Core/Hooks/hooks.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk { +namespace { +namespace world_size = addr::physics_world_size; +using WorldCreate = void* (*)(void*, void*); +std::atomic original_create{}; +std::uintptr_t installed_base{}; +std::mutex install_mutex; + +// Every pool a static triangle-mesh body is carved from. Contacts, joints, +// queries and culling tables track dynamic interactions and stay native. +constexpr std::size_t scaled_fields[]{ + world_size::geometry_count, + world_size::owner_count, + world_size::body_count, + world_size::hull_and_mesh_count, + world_size::shape_count, + world_size::aggregate_shape_count, + world_size::fixed_shape_count, + world_size::fixed_partition_count, + world_size::persistent_spatial_index_count, +}; + +bool read_memory(const void* source, void* destination, std::size_t size) noexcept { + SIZE_T count{}; + return ReadProcessMemory(GetCurrentProcess(), source, destination, size, &count) && count == size; +} + +bool validate_contract(std::uintptr_t base) noexcept { + if (!base || base > std::numeric_limits::max() - supported_build::game_image_size) + return false; + IMAGE_DOS_HEADER dos{}; + IMAGE_NT_HEADERS64 nt{}; + if (!read_memory(reinterpret_cast(base), &dos, sizeof(dos)) || + dos.e_magic != IMAGE_DOS_SIGNATURE || dos.e_lfanew < static_cast(sizeof(dos)) || + dos.e_lfanew > 0x1000 || + !read_memory(reinterpret_cast(base + dos.e_lfanew), &nt, sizeof(nt)) || + nt.Signature != IMAGE_NT_SIGNATURE || nt.FileHeader.Machine != IMAGE_FILE_MACHINE_AMD64 || + nt.OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC || + nt.OptionalHeader.SizeOfImage != supported_build::game_image_size) return false; + for (const auto& contract : world_size::contracts) { + std::array actual{}; + if (!read_memory(reinterpret_cast(base + contract.rva), actual.data(), contract.bytes.size()) || + std::memcmp(actual.data(), contract.bytes.data(), contract.bytes.size()) != 0) return false; + } + return true; +} + +std::uint32_t field(const std::uint8_t* size, std::size_t offset) noexcept { + std::uint32_t value{}; + std::memcpy(&value, size + offset, sizeof(value)); + return value; +} + +void* create_world(void* world, void* descriptor) { + const auto incoming_error = GetLastError(); + const auto original = original_create.load(std::memory_order_acquire); + alignas(16) std::array size{}; + // The descriptor is the caller's stack local, rebuilt for every creation, so + // raising it in place affects only this world. Absolute targets keep it idempotent. + if (descriptor && read_memory(descriptor, size.data(), size.size())) { + const auto native_size = size; + const auto bodies = field(size.data(), world_size::body_count); + if (bodies > 0 && bodies < physics_world_body_target) { + const auto scale = (physics_world_body_target + bodies - 1) / bodies; + for (const auto offset : scaled_fields) { + const auto native = field(size.data(), offset); + auto raised = offset == world_size::body_count + ? physics_world_body_target + : static_cast(std::min( + std::uint64_t{native} * scale, physics_world_pool_ceiling)); + raised = std::max(raised, native); + std::memcpy(size.data() + offset, &raised, sizeof(raised)); + } + std::memcpy(descriptor, size.data(), size.size()); + const auto pair = [&](std::size_t offset) { + return std::format("{} -> {}", field(native_size.data(), offset), field(size.data(), offset)); + }; + logging::log(logging::Level::info, logging::Channel::world, + "Physics world pools raised: bodies {}, shapes {}, fixed shapes {}, owners {}, " + "geometries {}, hull/mesh {}, partitions {}, spatial indices {}", + pair(world_size::body_count), pair(world_size::shape_count), + pair(world_size::fixed_shape_count), pair(world_size::owner_count), + pair(world_size::geometry_count), pair(world_size::hull_and_mesh_count), + pair(world_size::fixed_partition_count), pair(world_size::persistent_spatial_index_count)); + } + } + SetLastError(incoming_error); + return original(world, descriptor); +} +} // namespace + +bool start_physics_world_size(std::uintptr_t base, std::string& error) { + std::lock_guard lock(install_mutex); + error.clear(); + if (installed_base) { + if (installed_base == base) return true; + error = "Physics world size support is already installed on another image"; + return false; + } + if (!validate_contract(base)) { + error = "Physics world creation contract does not match the supported game"; + return false; + } + auto* target = reinterpret_cast(base + world_size::create_rva); + WorldCreate original{}; + const auto created = hook_prepare(target, reinterpret_cast(&create_world), + reinterpret_cast(&original)); + if (created != HookOk) { + error = "Cannot create physics world hook (Detours hook service status " + std::to_string(created) + ")"; + return false; + } + if (!original) { + hook_remove(target); + error = "Physics world trampoline is unavailable"; + return false; + } + original_create.store(original, std::memory_order_release); + const auto enabled = hook_enable(target); + if (enabled != HookOk) { + hook_remove(target); + original_create.store(nullptr, std::memory_order_release); + error = "Cannot enable physics world hook (Detours hook service status " + std::to_string(enabled) + ")"; + return false; + } + installed_base = base; + return true; +} +} // namespace dingosdk diff --git a/Extension/World/physics_world_size.h b/Extension/World/physics_world_size.h new file mode 100644 index 00000000..eb89df8e --- /dev/null +++ b/Extension/World/physics_world_size.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace dingosdk { +// Static collision slots per physics world. Stock worlds hold 8,200 bodies +// (+1 internal); a large custom map gives every collision part its own body. +// Kept below 65,536 so no 16-bit index elsewhere in the engine can wrap. +inline constexpr std::uint32_t physics_world_body_target = 65000; +// Pools that each static mesh body also draws from scale with BodyCount, up to this. +inline constexpr std::uint32_t physics_world_pool_ceiling = 1u << 20; + +// Install after Detours hook service init, before any level loads. Raises the +// body pool and its companion static-collision pools at world creation only; +// queries, contacts, joints and per-query result limits keep their native size. +bool start_physics_world_size(std::uintptr_t base, std::string& error); +} // namespace dingosdk diff --git a/Runtime/bootstrap.cpp b/Runtime/bootstrap.cpp index 898142ad..d04e5383 100644 --- a/Runtime/bootstrap.cpp +++ b/Runtime/bootstrap.cpp @@ -15,6 +15,7 @@ #include "Extension/Rendering/display_startup.h" #include "Extension/World/native_route_lookahead.h" #include "Extension/World/native_entity_pages.h" +#include "Extension/World/physics_world_size.h" #include namespace dingosdk::runtime { @@ -51,6 +52,8 @@ bool initialize_bootstrap(std::uintptr_t base) { if (!stage(Channel::assets, "Native render-resource pools", ready, error)) return false; error.clear(); ready = start_native_entity_pages(base, error); if (!stage(Channel::world, "Native entity pages (1048576 entity/update/render slots, 16384 spatial blocks/bucket, 128 MiB arena minimum)", ready, error)) return false; + error.clear(); ready = start_physics_world_size(base, error); + if (!stage(Channel::world, "Physics world pools (65000 static bodies)", ready, error)) return false; error.clear(); ready = start_native_route_lookahead(base, error); if (!stage(Channel::world, "NPC route cycle and endpoint guards", ready, error)) return false; if (!stage(Channel::graphics, "Display startup settings", start_display_settings(base), {})) return false; diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 2ac9edc5..c8ced382 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -87,6 +87,7 @@ add_library(dingosdk_runtime SHARED Extension/Assets/morph_memory_pool.cpp Extension/Assets/native_render_resource_pool.cpp Extension/World/native_entity_pages.cpp + Extension/World/physics_world_size.cpp Extension/Rendering/display_startup.cpp Extension/Rendering/replay_export.cpp Engine/Game/World/world_model.cpp From 5e557d184e427254ad4e012bad843dfd983a7aeb Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Sun, 4 Oct 2026 13:27:55 -0600 Subject: [PATCH 022/109] Keep a non-ASCII last letter in the server browser's tag name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tag_text dropped a trailing lead byte even when its character was whole, so "Skate Café" was listed as "Skate Caf". The browser shows the tag name until the server answers a query, so hosts without a forwarded query port kept it. --- Extension/Multiplayer/Test/protocol_tests.cpp | 11 +++++++++++ Server/steam_server.cpp | 15 +++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index f8b93e11..fb7bf455 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -856,6 +856,17 @@ void dedicated_server_codec() { check(row && row->dedicated && row->name == "Big friendly server" && row->map == ad.map && row->players == 3 && row->capacity == 16 && row->password_required && row->code == format_invite({server, ad.secret}), "Server tags did not round trip"); + // A whole last character is kept, and one the byte limit cuts through is dropped whole. + const auto accented = read_server_tags(dingosdk::server::server_tags({"Skate Caf\xC3\xA9", "Caf\xC3\xA9", 1, 8, false, true, 1}), server); + check(accented && accented->name == "Skate Caf\xC3\xA9" && accented->map == "Caf\xC3\xA9", + "A server name lost its non-ASCII last letter"); + for (std::size_t lead = 0; lead < 3; ++lead) { + std::string euros(lead, 'a'); + for (int i = 0; i < 50; ++i) euros += "\xE2\x82\xAC"; + const auto cut = dingosdk::server::server_tags({euros, "Caf\xC3\xA9", 1, 8, false, true, 1}); + const auto name = cut.substr(cut.find(",n") + 2); + check(cut.size() < 128 && name.size() > lead && (name.size() - lead) % 3 == 0, "A server name was cut inside a character"); + } check(!read_server_tags(tags, player), "A player's ID read as a server"); check(!read_server_tags("reskate,v1,k1,c4", server), "An incompatible server version listed"); } diff --git a/Server/steam_server.cpp b/Server/steam_server.cpp index e2a3fa8d..66921691 100644 --- a/Server/steam_server.cpp +++ b/Server/steam_server.cpp @@ -1,4 +1,5 @@ #include "steam_server.h" +#include "server_text.h" #include "Extension/Multiplayer/Net/protocol.h" #ifdef _WIN32 #include @@ -12,6 +13,7 @@ #include #include #endif +#include #include #include #include @@ -45,14 +47,11 @@ struct SteamIP { // Tags are comma separated, so a name loses its commas; the whole list must // stay under Steam's 128 byte limit. std::string tag_text(std::string_view text, std::size_t limit) { - std::string result; - for (const char c : text) { - if (result.size() >= limit) break; - result += c == ',' ? ' ' : c; - } - // Never end inside a UTF-8 sequence. - while (!result.empty() && (static_cast(result.back()) & 0xC0) == 0x80) result.pop_back(); - if (!result.empty() && (static_cast(result.back()) & 0xC0) == 0xC0) result.pop_back(); + std::string result(text); + std::replace(result.begin(), result.end(), ',', ' '); + // Cut at the limit only, and never through a character: a whole last character + // is kept ("Café" stays "Café"), one the limit splits is dropped. + cut_text(result, limit); return result; } // Steam's library prints its own crash-reporting notes ("Setting breakpad From c157d01286ae075b4987b1990e1020234909d5ff Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sun, 4 Oct 2026 14:32:41 -0500 Subject: [PATCH 023/109] add tuning enforce to allowed session commands --- .../Multiplayer/Session/session_commands.cpp | 2 +- Extension/Multiplayer/Test/mesh_tests.cpp | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index dca0b0bf..ff18d237 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -445,7 +445,7 @@ bool queue_command(std::string_view action, std::string_view argument, std::stri 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 diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 7ca8f183..42c4bf3b 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -389,6 +389,16 @@ void join_tick_checks() { check(queue_command("party", "status", {}), "A party request was not queued"); tick(0, 0, true, map, nullptr); check(guest.requests.empty(), "A queued party request was not run"); + // So do the host's switches in both menus: each has to be let into the queue. The + // physics tuning one was left out, and its switch did nothing in either menu. + for (const char *setting : {"object-placement", "noclip-allow", "nobail-allow", "boosts-allow", "tuning-enforce", + "world-layer-sync", "clear-objects"}) { + const bool queued = queue_command(setting, "toggle", {}); + if (!queued) std::cerr << "Refused: " << setting << "\n"; + check(queued, "A menu's host setting was refused before it reached the session"); + tick(0, 0, true, map, nullptr); + check(guest.requests.empty(), "A queued host setting was not run"); + } if (friend_join) { check(queue_command("join-friend-lobby", "9002", {}), "Steam friend join request should enter the normal queue"); tick(0, 0, true, map, nullptr); @@ -839,6 +849,13 @@ void tick_settings_checks() { } (void)command("host", "code 8 120 is a lobby name"); check(configured.tps == 30 && configured.lobby_name == "120 is a lobby name", "Legacy host command changed meaning"); + // Whose physics guests skate with is the host's to switch, and is remembered for next time. + check(configured.enforce_tuning, "A new lobby did not start with the host's physics for everyone"); + (void)command("tuning-enforce", "off"); + check(!configured.enforce_tuning && !configured.host_preferences.enforce_tuning && configured.roster_dirty, + "The host could not let guests skate with their own physics"); + (void)command("tuning-enforce", "toggle"); + check(configured.enforce_tuning && configured.host_preferences.enforce_tuning, "The host could not switch its physics for everyone back on"); stop(configured, "TPS fixture complete"); } void pacing_checks() { From b20406897cd56798d25d24950522cc4ac2d83575 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Sun, 4 Oct 2026 13:43:32 -0600 Subject: [PATCH 024/109] Refuse server ports outside 1-65535 instead of wrapping them A port of 70000 was narrowed to 4464 without a word, and the next saved setting wrote 4464 over the owner's typo. The check sits in load_config because config_error only ever sees the narrowed value. --- Server/Test/server_config_tests.cpp | 16 ++++++++++++++++ Server/server_config.cpp | 11 +++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index c6a62567..b1c41fa4 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -68,6 +68,22 @@ int run() { const auto odd = load_config(file); check(odd.score_check == "warn" && odd.score_allow == std::vector{0xabc}, "Bad scoring settings not cleaned"); + // A port outside 1-65535 is refused, not wrapped to another port, and the file keeps the typo. + const auto refused = [&](const char *json) { + std::ofstream(file, std::ios::binary) << json; + try { + static_cast(load_config(file)); + } catch (const std::exception &) { + return text(file) == json; + } + return false; + }; + check(refused(R"({"query_port": 70000})") && refused(R"({"port": 65536})") && refused(R"({"port": 0})"), + "An out-of-range port was accepted or the file rewritten"); + std::ofstream(file, std::ios::binary) << R"({"port": 65535, "query_port": 1})"; + const auto edges = load_config(file); + check(edges.port == 65535 && edges.query_port == 1, "Ports at the ends of the range refused"); + std::filesystem::remove_all(folder); if (failures) return 1; std::cout << "server config: ok\n"; diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 8be3b34e..30530426 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -128,8 +128,15 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector(root.value("port", static_cast(c.port))); - c.query_port = static_cast(root.value("query_port", static_cast(c.query_port))); + // Checked here, not in config_error: once narrowed, 70000 is just port 4464, and the + // next save would write that over the owner's typo. + const auto read_port = [&](const char *key, std::uint16_t fallback) { + const auto value = root.value(key, static_cast(fallback)); + if (value < 1 || value > 65535) throw std::runtime_error(std::string(key) + " must be 1 to 65535."); + return static_cast(value); + }; + c.port = read_port("port", c.port); + c.query_port = read_port("query_port", c.query_port); c.tps = root.value("tps", c.tps); c.voice_chat = root.value("voice_chat", c.voice_chat); c.voice_range = root.value("voice_range", c.voice_range); From 03630964a07cdbde02990b9660b4773a9c284889 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Sun, 4 Oct 2026 14:32:07 -0600 Subject: [PATCH 025/109] Keep the server window open after a startup failure until Enter is pressed A double-clicked server closed its window with the error still in it. It only waits when it is alone on a visible console, so terminals, hosting panels and hidden scheduled tasks still see it exit at once. --- Server/main.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Server/main.cpp b/Server/main.cpp index 97b10683..3b38e239 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -171,6 +171,16 @@ Input &console_input() { return input; } constexpr auto update_interval = std::chrono::minutes(30); +#ifdef _WIN32 +// Double-clicked, the server is alone on a visible console that closes with it. +// From a terminal, a script, a hosting panel or a hidden scheduled task it is not. +bool own_window() { + DWORD processes[2]{}; + const auto window = GetConsoleWindow(); + return GetConsoleProcessList(processes, 2) == 1 && window && IsWindowVisible(window) && + GetFileType(GetStdHandle(STD_INPUT_HANDLE)) == FILE_TYPE_CHAR; +} +#endif } // namespace #ifdef _WIN32 @@ -447,6 +457,14 @@ int wmain(int argc, wchar_t **argv) { } } finished = true; + // Keep a failure on screen until the host has read it, instead of the window + // vanishing with it. Closing the window or Ctrl+C still ends it at once. + if (code != 0 && !stopping && own_window()) { + auto &input = console_input(); + input.take(); + std::printf("Press Enter to close.\n"); + while (!stopping && input.take().empty()) Sleep(50); + } return code; } #else From 81913d89742614581e13811df103629ed9353833 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sun, 4 Oct 2026 16:07:48 -0500 Subject: [PATCH 026/109] fix modlist --- Engine/Vfs/mod_catalog.cpp | 8 +------- Engine/Vfs/mod_list.cpp | 8 +++----- Engine/Vfs/mod_list.h | 4 +++- Launcher/Test/mod_manager_tests.cpp | 29 +++++++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/Engine/Vfs/mod_catalog.cpp b/Engine/Vfs/mod_catalog.cpp index 3e964b34..ff670f7f 100644 --- a/Engine/Vfs/mod_catalog.cpp +++ b/Engine/Vfs/mod_catalog.cpp @@ -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)); diff --git a/Engine/Vfs/mod_list.cpp b/Engine/Vfs/mod_list.cpp index 3d680907..04bc0f07 100644 --- a/Engine/Vfs/mod_list.cpp +++ b/Engine/Vfs/mod_list.cpp @@ -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> 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> order; std::set> 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& 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)) diff --git a/Engine/Vfs/mod_list.h b/Engine/Vfs/mod_list.h index 0f3a8ecd..aff4fbd4 100644 --- a/Engine/Vfs/mod_list.h +++ b/Engine/Vfs/mod_list.h @@ -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"; diff --git a/Launcher/Test/mod_manager_tests.cpp b/Launcher/Test/mod_manager_tests.cpp index 4e406299..e16c901a 100644 --- a/Launcher/Test/mod_manager_tests.cpp +++ b/Launcher/Test/mod_manager_tests.cpp @@ -206,6 +206,35 @@ int main() { mods::save_mod_order(mods, broken.entries); check(mods::scan_mods(game).issue.empty(), "Saving repairs a malformed mods.json"); + // A big collection, with the longest names, can be switched off and reordered, and the + // list reads back. (There was a limit of 64 mods once: past it nothing could be saved, so + // no mod could be disabled.) + { + constexpr std::size_t collection = 300; + const auto big_game = root / L"big"; + const auto big = launcher_mods::mods_root(big_game); + for (std::size_t i = 0; i < collection; ++i) { + auto name = std::to_string(i); + name = std::string(4 - name.size(), '0') + name + "-"; + name.resize(mods::maximum_mod_name, 'x'); + write(big / name / L"layout.toc", "toc"); + } + auto many = mods::scan_mods(big_game); + check(many.issue.empty() && many.entries.size() == collection, "A big Mods folder is listed"); + for (std::size_t i = 0; i < many.entries.size(); i += 3) many.entries[i].enabled = false; + std::swap(many.entries.front(), many.entries.back()); + std::string refusal; + try { mods::save_mod_order(big, many.entries); } catch (const std::exception& failure) { refusal = failure.what(); } + if (!refusal.empty()) std::cerr << "save refused: " << refusal << '\n'; + check(refusal.empty(), "A big mod list can be saved"); + const auto again = mods::scan_mods(big_game); + check(again.issue.empty() && again.entries.size() == many.entries.size(), "A big mods.json reads back"); + bool same = again.entries.size() == many.entries.size(); + for (std::size_t i = 0; same && i < again.entries.size(); ++i) + same = again.entries[i].mod.name == many.entries[i].mod.name && again.entries[i].enabled == many.entries[i].enabled; + check(same, "Every mod keeps its place and whether it loads"); + } + // Thunderstore packages: manifest.json, icon.png and README.md at the top of the zip. { const auto store_game = root / L"store"; From 150560563a7a70ceee5b2f302d80fe31d7e03bc8 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sun, 4 Oct 2026 17:06:29 -0500 Subject: [PATCH 027/109] say which file cannot be read, and why "Cannot open file for SHA-256" was all a player saw when the launcher could not read ReSkate.dll, at startup and again right after each download. That is an antivirus holding or blocking the file. - A sharing violation is waited out for up to five seconds: a file that was just written is often held by the scanner for a moment. - Otherwise the error names the file and its folder, says to add the folder to the antivirus exclusions where that is the likely cause, and carries the Windows error code. --- Engine/Core/Platform/launcher_pe.cpp | 48 ++++++++++++++++-- Extension/Settings/local_native_settings.cpp | 52 +++++++++++++++++++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/Engine/Core/Platform/launcher_pe.cpp b/Engine/Core/Platform/launcher_pe.cpp index affed24e..4517e929 100644 --- a/Engine/Core/Platform/launcher_pe.cpp +++ b/Engine/Core/Platform/launcher_pe.cpp @@ -1,5 +1,6 @@ #include "launcher_support.h" #include "launcher_support_internal.h" +#include "path_text.h" #ifdef _WIN32 #include @@ -137,15 +138,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) diff --git a/Extension/Settings/local_native_settings.cpp b/Extension/Settings/local_native_settings.cpp index 42341baf..64c8f80b 100644 --- a/Extension/Settings/local_native_settings.cpp +++ b/Extension/Settings/local_native_settings.cpp @@ -6,6 +6,8 @@ #include "Engine/Game/Build/20260929/local_native_settings.h" #include "Engine/Game/Build/20260929/named_settings.h" #include "local_user_settings.h" +#include "Extension/Profile/local_profile_runtime.h" +#include #include #include #include @@ -88,6 +90,50 @@ const dingosdk::Json* saved_native_option(unsigned location, const char* key) { } } +namespace { +// Options the game keeps on this device (location 0) whose default ReSkate changes. The game +// saves these itself, so nothing is stored here but the fact that the default was applied: +// once per profile, the first time the game asks for the option. After that the option is the +// player's, to switch in the game's own menus. +// Accessibility_Party_Menu_Narration: on in the game's data, so the party menu was read +// aloud to every new player until they found the switch. +struct OwnDefault { + std::string_view key; + bool value; + const char* applied; // the ReSkate preference that records it +}; +constexpr OwnDefault own_defaults[]{{"Accessibility_Party_Menu_Narration", false, "Defaults.PartyMenuNarration"}}; + +bool per_device_group(std::uintptr_t group) { + auto& s = local_runtime(); + std::uintptr_t manager{}, begin{}, end{}, first{}; + return group && s.active.load(std::memory_order_acquire) && + memory::peek(s.base + addr::local_native_settings::profile_settings_manager, manager) && manager && + memory::peek(manager, begin) && memory::peek(manager + 8, end) && begin && end >= begin && + end - begin == 3 * sizeof(std::uintptr_t) && memory::peek(begin, first) && first == group; +} +// Runs on every get of an option outside the cloud groups: the key is compared first, and +// only a key from the table costs anything more. +void apply_own_default(std::uintptr_t group, const char* key, const NativeSettingValue* result) { + char text[64]; + const auto length = memory::peek_cstring(reinterpret_cast(key), text, sizeof(text)); + if (length <= 0) return; + const std::string_view name(text, static_cast(length)); + for (const auto& own : own_defaults) { + if (name != own.key) continue; + if (!per_device_group(group) || local_preference(own.applied).value_or(false)) return; + NativeSettingValue native{}; + const auto current = native_setting_json(result); + if (!current || !current->is_boolean() || !memory::peek(reinterpret_cast(result), native)) return; + if (current->get() != own.value && !restore_native_scalar(group, key, native.type, dingosdk::Json(own.value))) return; + set_local_preference(own.applied, true); + dingosdk::logging::event(dingosdk::logging::Channel::settings, + dingosdk::Json{{"event", "local_native_default_applied"}, {"key", std::string(own.key)}, {"value", own.value}}.dump().c_str()); + return; + } +} +} + bool restore_native_setting(std::uintptr_t group, const char* key, std::uintptr_t type, const dingosdk::Json& saved) { const auto base = local_runtime().base; @@ -115,7 +161,11 @@ const NativeSettingValue* native_setting_get(std::uintptr_t group, const char* k PreserveError preserve; try { const auto location = native_settings_location(group); - const auto* saved = location ? saved_native_option(location, key) : nullptr; + if (!location) { + apply_own_default(group, key, result); + return result; + } + const auto* saved = saved_native_option(location, key); if (!saved) return result; const auto current = native_setting_json(result); if (!current || *current == *saved) return result; From ecb083f417fd3b67f06afdf50b420c7ef1ba7043 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sun, 4 Oct 2026 17:56:24 -0500 Subject: [PATCH 028/109] Don't drop every player after the server is held up --- Extension/Multiplayer/Session/room.h | 12 +++- .../Multiplayer/Session/session_receive.cpp | 2 +- .../Multiplayer/Steam/steam_transport.cpp | 3 +- Extension/Multiplayer/Steam/steam_transport.h | 4 ++ Extension/Multiplayer/Test/mesh_tests.cpp | 60 +++++++++++++++++- Server/main.cpp | 60 +++++++++++++++++- Server/server_host.cpp | 8 ++- Server/server_host.h | 3 +- assets/launcher/background.jpg | Bin 469527 -> 566926 bytes 9 files changed, 139 insertions(+), 13 deletions(-) diff --git a/Extension/Multiplayer/Session/room.h b/Extension/Multiplayer/Session/room.h index 2b8655d1..f1688390 100644 --- a/Extension/Multiplayer/Session/room.h +++ b/Extension/Multiplayer/Session/room.h @@ -160,11 +160,17 @@ struct JoinBackoff { }); } }; +// 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; diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index dbc59e16..379363eb 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -334,7 +334,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { if (!link) continue; const bool direct_link = s.mode == Mode::join && message.peer != s.host_id; - if (!link->budget.accept(now, message.bytes.size(), + 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) diff --git a/Extension/Multiplayer/Steam/steam_transport.cpp b/Extension/Multiplayer/Steam/steam_transport.cpp index 4b2d4b8a..16ccf320 100644 --- a/Extension/Multiplayer/Steam/steam_transport.cpp +++ b/Extension/Multiplayer/Steam/steam_transport.cpp @@ -696,7 +696,8 @@ std::vector SteamTransport::receive() { continue; } const auto *data = static_cast(message->m_pData); - result.push_back({id, {data, data + message->m_cbSize}}); + result.push_back({id, {data, data + message->m_cbSize}, + message->m_usecTimeReceived > 0 ? static_cast(message->m_usecTimeReceived) : 0}); ++p.state.received; p.state.received_bytes += static_cast(message->m_cbSize); } diff --git a/Extension/Multiplayer/Steam/steam_transport.h b/Extension/Multiplayer/Steam/steam_transport.h index 8ab05b04..d035b136 100644 --- a/Extension/Multiplayer/Steam/steam_transport.h +++ b/Extension/Multiplayer/Steam/steam_transport.h @@ -16,6 +16,10 @@ struct TransportPeer { struct TransportMessage { std::uint64_t peer{}; std::vector 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. diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 42c4bf3b..77d05126 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -89,6 +89,8 @@ struct SimulatedNetwork { static inline std::uint64_t hold_control_to{}; static inline std::array lane_sends{}; static inline std::map queues; + // What a message's arrival time is (TransportMessage::arrived); 0: the transport does not say. + static inline std::uint64_t clock{}; static std::pair pair(std::uint64_t a, std::uint64_t b) { return {std::min(a, b), std::max(a, b)}; } @@ -200,7 +202,7 @@ bool SteamTransport::send(std::uint64_t id, std::span bytes, return true; if (!reliable && SimulatedNetwork::lose_unreliable && ++p.unreliable % 5 == 0) return true; - Impl::bus.at(id)->inbox.push_back({{p.state.local_id, {bytes.begin(), bytes.end()}}, lane}); + Impl::bus.at(id)->inbox.push_back({{p.state.local_id, {bytes.begin(), bytes.end()}, SimulatedNetwork::clock}, lane}); return true; } void SteamTransport::send_batch(std::span messages) { @@ -469,6 +471,8 @@ struct Simulation { NativeFrame local; std::vector positions; unsigned capacity, tps; + std::size_t held = static_cast(-1); // a node that is not run: held up, reading nothing + bool stamp{}; // messages carry when they arrived explicit Simulation(unsigned limit = max_players, unsigned rate = 20) : capacity(limit), tps(rate) { local.ready = true; local.pose.skater.resize(395); @@ -503,9 +507,10 @@ struct Simulation { void run(unsigned frames) { for (unsigned i = 0; i < frames; ++i) { now += network_tick_us; + SimulatedNetwork::clock = stamp ? now : 0; for (std::size_t n = 0; n < nodes.size(); ++n) { auto &s = *nodes[n]; - if (s.mode == Mode::off) + if (s.mode == Mode::off || n == held) continue; local.pose.root.position[0] = static_cast(now - 10000000) / 1000000; local.pose.root.position[2] = n < positions.size() ? positions[n] : static_cast(n); @@ -639,6 +644,56 @@ void party_checks() { check(!host.local_party && host.parties.parties().empty(), "A guest who left the lobby stayed in the host's party"); std::cout << "Lobby parties: nobody by default, invites, two parties, party chat, leaving and departures passed.\n"; } +// A host that is kept from reading for a few seconds (a server whose console held it up) then +// reads everything its guests sent meanwhile in one go. That is not a flood: nobody is dropped +// for it, while a real flood still is. +void stall_checks() { + { + // 16 s of a player's ordinary traffic read at once, counted by when it arrived. + ReceiveBudget backlog; + bool kept = true; + for (std::uint64_t i = 0; i < 16 * 90; ++i) kept = kept && backlog.accept(5000000 + i * (1000000 / 90), 300); + check(kept, "A backlog read after a stall was taken for a flood"); + ReceiveBudget flood; + bool refused = false; + for (std::uint64_t i = 0; i < 2000; ++i) refused = refused || !flood.accept(5000000 + i * 100, 300); + check(refused, "A flood was let through the packet limit"); + // Lanes are read one after another, so arrival times step back and forth. + ReceiveBudget lanes; + refused = false; + for (std::uint64_t i = 0; i < 2000; ++i) refused = refused || !lanes.accept(i % 2 ? 9000000 : 9500000, 300); + check(refused, "Packets read out of arrival order started the count again"); + } + Simulation sim(max_players, 120); + sim.stamp = true; + for (unsigned i = 0; i < 4; ++i) sim.add(); + sim.run(240); + auto &host = *sim.nodes[0]; + const auto connected = [&] { + unsigned count{}; + for (std::size_t n = 1; n < sim.nodes.size(); ++n) { + const auto *peer = find_peer(host, sim.nodes[n]->transport.status().local_id); + count += peer && peer->handshaken && sim.nodes[n]->mode == Mode::join; + } + return count; + }; + check(connected() == 3, "The stall fixture did not connect its guests"); + // Nine seconds unread: under the ten after which a silent player is given up on, and more + // than a second's allowance from every guest. + const auto sent = sim.nodes[1]->transport.status().sent; + sim.held = 0; + sim.run(static_cast(9000000 / network_tick_us)); + sim.held = static_cast(-1); + check(sim.nodes[1]->transport.status().sent - sent > 2ULL * multiplayer_tick_rates.back() + 80 + 32, + "The stall fixture did not queue more than the packet limit"); + sim.run(240); + if (host.mode != Mode::host || connected() != 3) + for (std::size_t n = 0; n < sim.nodes.size(); ++n) + std::cerr << " node " << n << " mode " << static_cast(sim.nodes[n]->mode) << ": " << sim.nodes[n]->status << "\n"; + check(host.mode == Mode::host && connected() == 3, "Guests were dropped after their host was held up for a few seconds"); + SimulatedNetwork::clock = 0; + std::cout << "Stalls: a held-up host keeps its guests; floods and out-of-order arrival are still limited.\n"; +} // What a host changes outside its physics tuning (the trainer's class values and trick // multipliers) reaches its guests while it sets everyone's physics: when it changes, to a // player who joins later, and from nobody but the host. @@ -1934,6 +1989,7 @@ int main(int argc, char **argv) { dingosdk::multiplayer::throwdown_routing_checks(); dingosdk::multiplayer::party_checks(); dingosdk::multiplayer::physics_extras_checks(); + dingosdk::multiplayer::stall_checks(); dingosdk::multiplayer::scoring_checks(); dingosdk::multiplayer::object_sync_checks(); dingosdk::multiplayer::session_controls_checks(); diff --git a/Server/main.cpp b/Server/main.cpp index 3b38e239..6cd5e27c 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -23,6 +23,7 @@ #endif #include #include +#include #include #include #include @@ -44,6 +45,57 @@ constexpr int restart_for_update = -2; std::string update_version; std::mutex log_mutex; std::ofstream log_file; +// The console is written by a thread of its own. Windows holds a console's output while text +// in its window is selected, and a pipe nobody reads fills up: whoever writes then waits. When +// that was the server loop, the server read nothing from its players until the console let go, +// and they were gone by then. The log file is still written where the line is made. +struct Console { + std::mutex mutex; + std::condition_variable more, drained; + std::deque lines; + std::size_t skipped{}; + bool writing{}, started{}; + void write(std::string line) { + std::lock_guard lock(mutex); + if (!started) { + started = true; + std::thread([this] { run(); }).detach(); + } + // A console that stays held must not grow this without end: the oldest lines go, and + // the file has them all. + if (lines.size() >= 4096) { + lines.pop_front(); + ++skipped; + } + lines.push_back(std::move(line)); + more.notify_one(); + } + void run() { + for (;;) { + std::unique_lock lock(mutex); + more.wait(lock, [&] { return !lines.empty(); }); + const auto line = std::move(lines.front()); + lines.pop_front(); + const auto missed = std::exchange(skipped, 0); + writing = true; + lock.unlock(); + if (missed) std::printf("(%zu earlier lines are only in ReSkateServer.log: the console was not taking output)\n", missed); + std::fputs(line.c_str(), stdout); + lock.lock(); + writing = false; + if (lines.empty()) drained.notify_all(); + } + } + // Waits until everything written so far is on screen, but not for a console that is held. + void flush(std::chrono::milliseconds limit) { + std::unique_lock lock(mutex); + drained.wait_for(lock, limit, [&] { return lines.empty() && !writing; }); + } +}; +Console &console() { + static auto *value = new Console; // outlives every thread that may still be writing at exit + return *value; +} void write_log(const std::string &text) { std::lock_guard lock(log_mutex); const auto now = std::time(nullptr); @@ -55,8 +107,8 @@ void write_log(const std::string &text) { #endif char stamp[32]{}; std::strftime(stamp, sizeof stamp, "%Y-%m-%d %H:%M:%S", &local); - std::printf("[%s] %s\n", stamp + 11, text.c_str()); if (log_file) log_file << '[' << stamp << "] " << text << std::endl; + console().write(std::string("[") + (stamp + 11) + "] " + text + "\n"); } std::atomic finished{}; #ifdef _WIN32 @@ -444,6 +496,7 @@ int wmain(int argc, wchar_t **argv) { write_log("Installing server update " + update_version + "..."); install_update(folder()); write_log("Server update " + update_version + " installed; starting it."); + console().flush(std::chrono::seconds(2)); if (relaunch()) { code = 0; break; @@ -462,9 +515,10 @@ int wmain(int argc, wchar_t **argv) { if (code != 0 && !stopping && own_window()) { auto &input = console_input(); input.take(); - std::printf("Press Enter to close.\n"); + console().write("Press Enter to close.\n"); while (!stopping && input.take().empty()) Sleep(50); } + console().flush(std::chrono::seconds(2)); return code; } #else @@ -478,6 +532,7 @@ int main(int argc, char **argv) { write_log("Installing server update " + update_version + "..."); install_update(folder()); write_log("Server update " + update_version + " installed; starting it."); + console().flush(std::chrono::seconds(2)); if (relaunch()) { code = 0; break; @@ -491,6 +546,7 @@ int main(int argc, char **argv) { } } finished = true; + console().flush(std::chrono::seconds(2)); return code; } #endif diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 1a452eb5..5833df38 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -414,10 +414,12 @@ bool Host::accept_data(Guest &source, const Packet &p) { } return true; } -void Host::receive(std::uint64_t peer, std::span bytes) { +void Host::receive(std::uint64_t peer, std::span bytes, std::uint64_t received_at) { auto *link = find(peer); if (!link) return; - if (!link->budget.accept(now_, bytes.size(), 1U)) return drop(peer, "Peer exceeded the multiplayer packet limit."); + // Counted by arrival: after the server was held up, everyone's packets are read at once. + if (!link->budget.accept(received_at ? received_at : now_, bytes.size(), 1U)) + return drop(peer, "Peer exceeded the multiplayer packet limit."); bool missing_reference{}; const auto decoded = link->receiver.receive(bytes, missing_reference, world_); if (missing_reference) return; @@ -764,7 +766,7 @@ void Host::tick(std::uint64_t now) { } if (link.connected && !guest->connected_at) guest->connected_at = now_; } - for (const auto &message : transport_.receive()) receive(message.peer, message.bytes); + for (const auto &message : transport_.receive()) receive(message.peer, message.bytes, message.arrived); receive_cosmetics(); for (auto it = guests_.begin(); it != guests_.end();) { auto &g = *(it++)->second; diff --git a/Server/server_host.h b/Server/server_host.h index 198ebeb2..8ca26a4c 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -158,7 +158,8 @@ class Host { void change_map(std::string_view map); // a level name, level path or destination bool same_map(std::string_view asset) const { return map_hash(map_destination(asset)) == map_; } bool accept_data(Guest &source, const Packet &); - void receive(std::uint64_t peer, std::span bytes); + // `received_at`: the transport's arrival time for the message (TransportMessage::arrived). + void receive(std::uint64_t peer, std::span bytes, std::uint64_t received_at); void receive_cosmetics(); void sync_objects(); void apply_layers(); diff --git a/assets/launcher/background.jpg b/assets/launcher/background.jpg index 2c80aeba19f4c4172abdbb1b3b0ffe170c81a946..1bea5f35866f3af0a2c94ce57201d8f0a9bb9cd1 100644 GIT binary patch literal 566926 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�e_zK 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

3xW?LraetroJ)c43hcB9vUP`H2-N?(U~}pT=|H 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
tvK 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�g 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

MW$=CYZ=EP5j@LLA2*#pQTBV3Jba-?sxQ{2`KZ8 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

Dh=QIJ&oF{ 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%0AZO=HU9v;D{|j60I-n79CIU}G|r``VHQr1cRT0P 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 i5xL!?8@JT=tqCry>@7?-1S=nz$L>!$(Lmr literal 469527 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

M^fK&H0V#1JnO6xwa;_91$=c8 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ɣ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 z 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

ZNB%UG-hvsNwEtbk|MY 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 D`9{%xO(uInn_ao&R$vIy({on3qFV7ND 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 MqZz`I?p%-m+0TwfRR910 From 902e064aadffac718c28ce692ddebaf60f6cd766 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Sun, 4 Oct 2026 17:03:36 -0600 Subject: [PATCH 029/109] Keep packing emotes when a file name is outside the ANSI code page --- EmotePacker/main.cpp | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/EmotePacker/main.cpp b/EmotePacker/main.cpp index 7b18bc53..54a59d93 100644 --- a/EmotePacker/main.cpp +++ b/EmotePacker/main.cpp @@ -8,6 +8,7 @@ // // Dropping a folder on the exe writes the pack next to it, in "_pack". #include "Engine/Core/Json/json.h" +#include "Engine/Core/Platform/path_text.h" #include #include #include @@ -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 tidy(std::vector 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(c)) ? c : '_'; + for (const wchar_t c : file.stem().wstring()) + name += c < 128 && std::isalnum(static_cast(c)) ? static_cast(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::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 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 emotes; std::set 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 From 5a01b1b0de5cdf61f5bd8147510a79de73ab9fdd Mon Sep 17 00:00:00 2001 From: ReSkate local Date: Sun, 4 Oct 2026 22:34:28 +0100 Subject: [PATCH 030/109] Music: support custom playlist and song cover artwork in mods --- Engine/Vfs/Test/mod_music_tests.cpp | 14 ++ Engine/Vfs/mod_music.cpp | 25 +++ Engine/Vfs/mod_music.h | 8 + Extension/Music/Test/music_artwork_tests.cpp | 77 +++++++++ Extension/Music/local_music_assets.cpp | 17 +- Extension/Music/local_music_assets.h | 4 +- Extension/Music/local_music_ui.cpp | 13 +- Extension/Music/music_artwork.cpp | 165 +++++++++++++++++++ Extension/Music/music_artwork.h | 20 +++ README.md | 16 ++ cmake/Apps.cmake | 4 + cmake/Runtime.cmake | 1 + 12 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 Extension/Music/Test/music_artwork_tests.cpp create mode 100644 Extension/Music/music_artwork.cpp create mode 100644 Extension/Music/music_artwork.h diff --git a/Engine/Vfs/Test/mod_music_tests.cpp b/Engine/Vfs/Test/mod_music_tests.cpp index d5b99910..79495b4f 100644 --- a/Engine/Vfs/Test/mod_music_tests.cpp +++ b/Engine/Vfs/Test/mod_music_tests.cpp @@ -55,6 +55,20 @@ int main() try { 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"); diff --git a/Engine/Vfs/mod_music.cpp b/Engine/Vfs/mod_music.cpp index 18a6c4bf..5fa56da7 100644 --- a/Engine/Vfs/mod_music.cpp +++ b/Engine/Vfs/mod_music.cpp @@ -11,6 +11,20 @@ bool text_ok(const std::string& value) { } } +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 parse_music_playlists(const std::string& mod, const std::string& json) { std::vector result; const auto root = Json::parse(json); @@ -18,15 +32,26 @@ std::vector parse_music_playlists(const std::string& mod, const s !root.at("playlists").is_array()) throw std::runtime_error("expected schema 1 with a playlists array"); const auto& list = root.at("playlists"); + std::map 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; diff --git a/Engine/Vfs/mod_music.h b/Engine/Vfs/mod_music.h index 3f93941e..ea30e0f8 100644 --- a/Engine/Vfs/mod_music.h +++ b/Engine/Vfs/mod_music.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include namespace dingosdk::mods { @@ -11,13 +12,20 @@ struct MusicPlaylist { std::string id; // "mod::" std::string name; // shown in the music screen std::vector songs; + std::string artwork; // relative PNG path inside the mod; empty means no cover + std::map 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 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 diff --git a/Extension/Music/Test/music_artwork_tests.cpp b/Extension/Music/Test/music_artwork_tests.cpp new file mode 100644 index 00000000..d85593d0 --- /dev/null +++ b/Extension/Music/Test/music_artwork_tests.cpp @@ -0,0 +1,77 @@ +#include +#include +#include "Extension/Music/music_artwork.h" +#include +#include +#include +#include +#include + +namespace { +int failures = 0; +void check(bool ok, const char* message) { + if (!ok) { ++failures; std::cerr << "FAIL: " << message << '\n'; } +} +struct Handle { + HINTERNET value{}; + ~Handle() { if (value) WinHttpCloseHandle(value); } +}; +std::pair fetch(const std::string& url) { + const std::wstring wide(url.begin(), url.end()); + URL_COMPONENTS parts{sizeof(parts)}; + parts.dwHostNameLength = parts.dwUrlPathLength = static_cast(-1); + if (!WinHttpCrackUrl(wide.c_str(), 0, 0, &parts)) return {}; + Handle session{WinHttpOpen(L"Artwork test", WINHTTP_ACCESS_TYPE_NO_PROXY, nullptr, nullptr, 0)}; + if (!session.value) return {}; + WinHttpSetTimeouts(session.value, 3000, 3000, 3000, 3000); + const std::wstring host(parts.lpszHostName, parts.dwHostNameLength), path(parts.lpszUrlPath, parts.dwUrlPathLength); + Handle connection{WinHttpConnect(session.value, host.c_str(), parts.nPort, 0)}; + if (!connection.value) return {}; + Handle request{WinHttpOpenRequest(connection.value, L"GET", path.c_str(), nullptr, nullptr, nullptr, 0)}; + if (!request.value || !WinHttpSendRequest(request.value, nullptr, 0, nullptr, 0, 0, 0) || + !WinHttpReceiveResponse(request.value, nullptr)) return {}; + DWORD status{}, size = sizeof(status); + if (!WinHttpQueryHeaders(request.value, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + nullptr, &status, &size, nullptr)) return {}; + std::string body; + std::array buffer{}; + DWORD received{}; + while (WinHttpReadData(request.value, buffer.data(), static_cast(buffer.size()), &received) && received) + body.append(buffer.data(), received); + return {status, body}; +} +} +int main() try { + namespace fs = std::filesystem; + const auto root = fs::temp_directory_path() / (L"ReSkateArtworkTest-" + std::to_wstring(GetCurrentProcessId()) + L"-" + std::to_wstring(GetTickCount64())); + fs::create_directories(root / L"mod" / L"artwork"); + struct Cleanup { fs::path root; ~Cleanup() { std::error_code ec; fs::remove_all(root, ec); } } cleanup{root}; + // A real one-pixel grayscale PNG. Bytes returned by HTTP must match it exactly. + constexpr unsigned char png[]{ + 137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,4,0,0,0,181,28,12,2, + 0,0,0,11,73,68,65,84,120,218,99,100,248,15,0,1,5,1,1,39,24,227,102,0,0,0,0,73,69,78,68,174,66,96,130}; + const std::string bytes(reinterpret_cast(png), sizeof(png)); + { std::ofstream out(root / L"mod/artwork/cover.png", std::ios::binary); out.write(bytes.data(), bytes.size()); } + { std::ofstream out(root / L"mod/artwork/bad.png", std::ios::binary); out << "not an image"; } + { std::ofstream out(root / L"outside.png", std::ios::binary); out.write(bytes.data(), bytes.size()); } + dingosdk::profile_runtime::MusicArtworkServer server; + const auto url = server.add(root / L"mod", "artwork/cover.png"); + check(url.starts_with("http://127.0.0.1:"), "server uses an ephemeral loopback URL"); + check(server.add(root / L"mod", "artwork/cover.png") == url, "repeated registration keeps the URL"); + check(server.add(root / L"mod", "../outside.png").empty(), "parent traversal is refused"); + check(server.add(root / L"mod", "artwork/missing.png").empty(), "missing image is ignored"); + check(server.add(root / L"mod", "artwork/bad.png").empty(), "non-PNG bytes are ignored"); + if (!url.empty()) { + const auto [status, body] = fetch(url); + check(status == 200 && body == bytes, "HTTP returns the exact registered PNG"); + const auto base = url.substr(0, url.find('/', 7)); + check(fetch(base + "/music-art/unknown.png").first == 404, "unknown route is not served"); + check(fetch(base + "/outside.png").first == 404, "HTTP cannot read arbitrary files"); + } + std::error_code ec; + fs::create_directory_symlink(root, root / L"mod" / L"escape", ec); + if (!ec) check(server.add(root / L"mod", "escape/outside.png").empty(), "symlink escape is refused"); + else std::cout << "SKIP: symlink test needs Windows symlink privileges\n"; + if (!failures) std::cout << "music artwork tests passed\n"; + return failures ? 1 : 0; +} catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } diff --git a/Extension/Music/local_music_assets.cpp b/Extension/Music/local_music_assets.cpp index 84d2ab5c..79c03b74 100644 --- a/Extension/Music/local_music_assets.cpp +++ b/Extension/Music/local_music_assets.cpp @@ -1,5 +1,6 @@ #include "Extension/Customization/local_customization_runtime.h" #include "local_music_assets.h" +#include "music_artwork.h" #include "Engine/Core/Log/logging.h" #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/local_music.h" @@ -30,7 +31,8 @@ bool music_asset_type(std::uintptr_t asset, std::uintptr_t vtable_rva) { return asset && read(asset, vtable) && vtable == local_runtime().base + vtable_rva; } -// reskate-music.json in each enabled mod: {"schema":1,"playlists":[{"name":..,"songs":["artist - title",..]}]}. +// reskate-music.json in each enabled mod: {"schema":1,"playlists":[{"name":.., +// "songs":["artist - title",..]}]}. // Read on every call: it only runs while the music page waits for its catalog. A bad // file is logged and skipped; its songs still play from the master playlist. std::vector mod_music_playlists() { @@ -42,6 +44,10 @@ std::vector mod_music_playlists() { try { std::ifstream in(path, std::ios::binary); auto playlists = mods::parse_music_playlists(mod.name, std::string((std::istreambuf_iterator(in)), {})); + for (auto& playlist : playlists) { + if (!playlist.artwork.empty()) playlist.artwork = mod_music_artwork_url(mod.directory, playlist.artwork); + for (auto& [id, artwork] : playlist.song_artwork) artwork = mod_music_artwork_url(mod.directory, artwork); + } std::move(playlists.begin(), playlists.end(), std::back_inserter(result)); } catch (const std::exception& failure) { dingosdk::logging::event(dingosdk::logging::Channel::music, @@ -171,7 +177,12 @@ bool read_music_catalog(MusicCatalog& result) { // a name and the ids of songs the mod adds, which carry no station tag of their own. for (const auto& playlist : mod_music_playlists()) { ++mod_playlists; - merge(playlist.id, playlist.name, {}, playlist.songs); + merge(playlist.id, playlist.name, playlist.artwork, playlist.songs); + for (const auto& [id, artwork] : playlist.song_artwork) + if (const auto found = index.find(id); found != index.end() && !artwork.empty() && snapshot.songs[found->second].artwork.empty()) { + snapshot.songs[found->second].artwork = artwork; + ++song_artwork; + } } for (auto it = playlists.begin(); it != playlists.end();) it = it->second.empty() ? playlists.erase(it) : std::next(it); @@ -204,4 +215,4 @@ bool read_music_catalog(MusicCatalog& result) { result = std::move(snapshot); return true; } -} \ No newline at end of file +} diff --git a/Extension/Music/local_music_assets.h b/Extension/Music/local_music_assets.h index 85352478..f3ba05d1 100644 --- a/Extension/Music/local_music_assets.h +++ b/Extension/Music/local_music_assets.h @@ -5,14 +5,14 @@ namespace dingosdk::profile_runtime { struct MusicSong { std::string id, artist, title; - std::string artwork; // cdn:/ cover art from the content cache; empty when unknown + std::string artwork; // content-cache cdn:/ id or registered mod loopback URL; empty when unknown std::vector playlists; }; struct MusicPlaylist { std::string id; std::string name; // display name from the content cache; empty for native groups - std::string artwork; // cdn:/ artwork id from the content cache; empty when unknown + std::string artwork; // content-cache cdn:/ id or registered mod loopback URL; empty when unknown std::vector songs; }; diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index b249242d..a5e1aef9 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -12,6 +12,13 @@ #include "Extension/Profile/runtime_internal.h" namespace dingosdk::profile_runtime { +namespace { +std::string music_artwork_url(std::string_view value) { + // Only URLs produced by the local artwork server bypass CDN resolution. + if (value.starts_with("http://127.0.0.1:")) return std::string(value); + return travel_artwork_url(value); +} +} // Runtime-only music UI hydration: read_music_catalog copies actual registered // MusicGraphAsset metadata/TagRefs. No generated catalog, guessed memberships, @@ -117,14 +124,14 @@ std::string music_ui_wire(std::string_view id, std::string_view artist, std::str // The catalogue's display name when known; the raw id otherwise. cosmetic_wire_string(presentation, 10, name.empty() ? id : name); // Artwork: the catalogue's cdn:/ id, resolved to the CDN rendition. - if (const auto url = travel_artwork_url(artwork); !url.empty()) + if (const auto url = music_artwork_url(artwork); !url.empty()) cosmetic_wire_string(presentation, 11, url); cosmetic_wire_number(presentation, 12, 0); } else { cosmetic_wire_string(presentation, 10, artist); cosmetic_wire_string(presentation, 11, title); // Cover art: the content cache song record's cdn:/ id (its field 10.12), resolved like a playlist's. - if (const auto url = travel_artwork_url(artwork); !url.empty()) + if (const auto url = music_artwork_url(artwork); !url.empty()) cosmetic_wire_string(presentation, 12, url); } cosmetic_wire_string(body, 10, presentation); @@ -287,4 +294,4 @@ void update_music_catalog() { dingosdk::logging::event(dingosdk::logging::Channel::music, dingosdk::Json{{"event", "native_music_ui_complete"}, {"playlists", playlist_count}, {"songs", song_count}, {"source", "runtime_music_assets"}}.dump().c_str()); } catch (...) { retry = false; dingosdk::logging::event(dingosdk::logging::Channel::music, "{\"event\":\"native_music_ui_publish_failed\"}"); } } -} \ No newline at end of file +} diff --git a/Extension/Music/music_artwork.cpp b/Extension/Music/music_artwork.cpp new file mode 100644 index 00000000..1fbacb54 --- /dev/null +++ b/Extension/Music/music_artwork.cpp @@ -0,0 +1,165 @@ +#include +#include +#include "music_artwork.h" +#include "Engine/Vfs/mod_music.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::profile_runtime { +namespace { +constexpr std::size_t max_image = 4 * 1024 * 1024, max_total = 64 * 1024 * 1024; +struct Socket { + SOCKET value = INVALID_SOCKET; + ~Socket() { if (value != INVALID_SOCKET) closesocket(value); } +}; +bool send_all(SOCKET socket, const char* bytes, std::size_t count) { + while (count) { + const auto sent = send(socket, bytes, static_cast(count), 0); + if (sent <= 0) return false; + bytes += sent; + count -= static_cast(sent); + } + return true; +} +bool png_ok(const std::vector& bytes) { + constexpr unsigned char magic[]{137, 80, 78, 71, 13, 10, 26, 10}; + if (bytes.size() < 33) return false; + for (std::size_t i = 0; i < 8; ++i) + if (static_cast(bytes[i]) != magic[i]) return false; + if (std::string_view(bytes.data() + 12, 4) != "IHDR") return false; + const auto dimension = [&](std::size_t offset) { + std::uint32_t value = 0; + for (std::size_t i = offset; i < offset + 4; ++i) value = (value << 8) | static_cast(bytes[i]); + return value; + }; + const auto w = dimension(16), h = dimension(20); + return w && h && w <= 2048 && h <= 2048; +} +} +struct MusicArtworkServer::Impl { + bool winsock = false; + Socket listener; + unsigned short port = 0; + std::mutex mutex; + std::map>> images; + std::map> paths; + std::size_t total = 0, next = 0; + std::jthread worker; + + Impl() { + WSADATA data{}; + if (WSAStartup(MAKEWORD(2, 2), &data) != 0) return; + winsock = true; + listener.value = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (listener.value == INVALID_SOCKET) return; + const BOOL exclusive = TRUE; + if (setsockopt(listener.value, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, + reinterpret_cast(&exclusive), sizeof(exclusive)) != 0) return; + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (bind(listener.value, reinterpret_cast(&address), sizeof(address)) != 0 || + listen(listener.value, SOMAXCONN) != 0) return; + int size = sizeof(address); + if (getsockname(listener.value, reinterpret_cast(&address), &size) != 0) return; + worker = std::jthread([this](std::stop_token stop) { serve(stop); }); + port = ntohs(address.sin_port); + } + ~Impl() { + worker.request_stop(); + if (worker.joinable()) worker.join(); + if (listener.value != INVALID_SOCKET) { closesocket(listener.value); listener.value = INVALID_SOCKET; } + if (winsock) WSACleanup(); + } + void serve(std::stop_token stop) noexcept { + while (!stop.stop_requested()) { + fd_set ready; + FD_ZERO(&ready); + FD_SET(listener.value, &ready); + timeval timeout{0, 100000}; + if (select(0, &ready, nullptr, nullptr, &timeout) <= 0) continue; + Socket client{accept(listener.value, nullptr, nullptr)}; + if (client.value == INVALID_SOCKET) continue; + const DWORD milliseconds = 1000; + setsockopt(client.value, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&milliseconds), sizeof(milliseconds)); + setsockopt(client.value, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast(&milliseconds), sizeof(milliseconds)); + try { respond(client.value); } catch (...) {} // Failed requests never take down the music catalog. + } + } + void respond(SOCKET client) { + std::string request; + std::array buffer{}; + const auto started = GetTickCount64(); + while (request.find("\r\n\r\n") == std::string::npos && request.size() < 8192) { + if (GetTickCount64() - started > 2000) return; + const auto received = recv(client, buffer.data(), static_cast(buffer.size()), 0); + if (received <= 0) return; + request.append(buffer.data(), static_cast(received)); + } + const auto end = request.find("\r\n"); + const auto line = request.substr(0, end); + std::shared_ptr> bytes; + if (request.find("\r\n\r\n") != std::string::npos && line.starts_with("GET ")) { + const auto space = line.find(' ', 4); + if (space != std::string::npos && (line.substr(space) == " HTTP/1.1" || line.substr(space) == " HTTP/1.0")) { + std::lock_guard lock(mutex); + if (const auto found = images.find(line.substr(4, space - 4)); found != images.end()) bytes = found->second; + } + } + if (!bytes) { + constexpr std::string_view missing = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + send_all(client, missing.data(), missing.size()); + return; + } + const auto header = "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: " + std::to_string(bytes->size()) + + "\r\nCache-Control: private, max-age=31536000\r\nConnection: close\r\n\r\n"; + if (send_all(client, header.data(), header.size())) send_all(client, bytes->data(), bytes->size()); + } +}; +MusicArtworkServer::MusicArtworkServer() : impl_(std::make_unique()) {} +MusicArtworkServer::~MusicArtworkServer() = default; +std::string MusicArtworkServer::add(const std::filesystem::path& mod, const std::string& relative) { + if (!impl_->port || !mods::music_artwork_path(relative)) return {}; + try { + const auto root = std::filesystem::canonical(mod); + const auto file = std::filesystem::canonical(root / std::filesystem::path(std::u8string(relative.begin(), relative.end()))); + auto a = root.begin(), b = file.begin(); + for (; a != root.end(); ++a, ++b) + if (b == file.end() || _wcsicmp(a->c_str(), b->c_str()) != 0) return {}; + if (b == file.end()) return {}; + const auto stamp = std::filesystem::last_write_time(file); + std::lock_guard lock(impl_->mutex); + if (const auto found = impl_->paths.find(file); found != impl_->paths.end() && found->second.first == stamp) + return found->second.second; + const auto size = std::filesystem::file_size(file); + if (size > max_image || impl_->total + size > max_total) return {}; + std::ifstream input(file, std::ios::binary); + auto bytes = std::make_shared>(static_cast(size)); + if (!input.read(bytes->data(), static_cast(size)) || !png_ok(*bytes)) return {}; + const auto route = "/music-art/" + std::to_string(impl_->next++) + ".png"; + const auto url = "http://127.0.0.1:" + std::to_string(impl_->port) + route; + impl_->images.emplace(route, bytes); + impl_->total += bytes->size(); + impl_->paths[file] = {stamp, url}; + return url; + } catch (const std::exception&) { return {}; } +} +std::string mod_music_artwork_url(const std::filesystem::path& mod, const std::string& relative) { + // The injected runtime has process lifetime. Pin it before starting a worker, so its + // code cannot be unloaded while the image endpoint is serving requests. Avoid joining + // a worker from CRT teardown under the loader lock. Tests use the owned server above. + static auto* server = []() -> MusicArtworkServer* { + HMODULE module{}; + if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + reinterpret_cast(&mod_music_artwork_url), &module)) return nullptr; + return new MusicArtworkServer; + }(); + return server ? server->add(mod, relative) : std::string{}; +} +} diff --git a/Extension/Music/music_artwork.h b/Extension/Music/music_artwork.h new file mode 100644 index 00000000..c3e9d442 --- /dev/null +++ b/Extension/Music/music_artwork.h @@ -0,0 +1,20 @@ +#pragma once +#include +#include +#include + +namespace dingosdk::profile_runtime { +// Serves only registered PNG bytes on an ephemeral loopback port. No filesystem HTTP routes. +class MusicArtworkServer { +public: + MusicArtworkServer(); + ~MusicArtworkServer(); + MusicArtworkServer(const MusicArtworkServer&) = delete; + MusicArtworkServer& operator=(const MusicArtworkServer&) = delete; + std::string add(const std::filesystem::path& mod, const std::string& relative); +private: + struct Impl; + std::unique_ptr impl_; +}; +std::string mod_music_artwork_url(const std::filesystem::path& mod, const std::string& relative); +} diff --git a/README.md b/README.md index 2bd7b2ba..ad3c5e0e 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,22 @@ The menu and console keys can be changed in the launcher's Settings. match no song are ignored, and a playlist with no matching songs is not shown. A file that does not follow this shape is skipped and logged. The songs themselves still have to be added by the mod. + Optional playlist and track covers are PNG files packaged inside the mod: + + ```json + { + "schema": 1, + "playlists": [{"name": "My Playlist", "artwork": "artwork/playlist.png", "songs": ["Artist - Title"]}], + "song_artwork": {"Artist - Title": "artwork/track.png"} + } + ``` + + Artwork paths are relative to the mod folder and use forward slashes. Images must be PNGs, at most + 2048x2048 and 4 MiB each. Missing or invalid covers are ignored without removing the playlist or song. + ReSkate serves registered image bytes through a process-local HTTP endpoint bound only to `127.0.0.1` + on an automatically selected port; no internet hosting is needed. The endpoint exposes no filesystem + routes and retains at most 64 MiB of artwork. Existing content-cache covers keep their priority. + Only install mods you trust. Mods change game data, and custom scripts can run code. ## Dedicated servers diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index b9c8f25d..f857b46d 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -48,6 +48,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_link_libraries(dingosdk_mod_music_tests PRIVATE dingosdk_mods) target_include_directories(dingosdk_mod_music_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME mod_music COMMAND dingosdk_mod_music_tests) + add_executable(dingosdk_music_artwork_tests Extension/Music/Test/music_artwork_tests.cpp Extension/Music/music_artwork.cpp) + target_link_libraries(dingosdk_music_artwork_tests PRIVATE dingosdk_mods ws2_32 winhttp) + dingosdk_configure_target(dingosdk_music_artwork_tests) + add_test(NAME music_artwork COMMAND dingosdk_music_artwork_tests) add_executable(dingosdk_mod_merge_unshift_tests Engine/Vfs/Test/mod_merge_unshift_tests.cpp) target_link_libraries(dingosdk_mod_merge_unshift_tests PRIVATE dingosdk_mods) target_include_directories(dingosdk_mod_merge_unshift_tests PRIVATE "${PROJECT_SOURCE_DIR}") diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 2ac9edc5..b3092098 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -138,6 +138,7 @@ add_library(dingosdk_runtime SHARED Extension/News/live_news.cpp Extension/Objects/local_object_runtime.cpp Extension/Music/local_music_assets.cpp + Extension/Music/music_artwork.cpp Extension/Music/local_music_ui.cpp Extension/Objects/local_buildkit_labels.cpp Extension/Objects/local_buildkit_limits.cpp From 702e5c74d2183d0853100be7a0fcae9dd9a897c5 Mon Sep 17 00:00:00 2001 From: ReSkate local Date: Mon, 5 Oct 2026 00:09:51 +0100 Subject: [PATCH 031/109] Music: serve mod cover artwork over HTTP/2 cleartext The game fetches the artwork URL with HTTP/2 cleartext (h2c, prior knowledge), and only from 127.0.0.1. The loopback server spoke HTTP/1.1, so every cover request was answered with an HTTP/1.1 404 and mod playlist/song covers never appeared. Speak h2c: complete the SETTINGS handshake, read the request header block (HEADERS and CONTINUATION frames), decode :path with a minimal HPACK decoder (static table and Huffman code from RFC 7541 in hpack_tables.h), and serve the matching image as HEADERS plus DATA frames with flow control. Keep the HTTP/1.1 path for other clients. Serve from one stable origin (http://127.0.0.1:47823/music-art//) so the game's HTTP cache is reused, send Cache-Control and an ETag so it stops re-fetching, close gracefully (closing with unread frames queued makes Windows send RST and the client drops the tail), and use a non-blocking listener since several pollers share it. Co-Authored-By: Claude Sonnet 5.5 --- Extension/Music/Test/music_artwork_tests.cpp | 64 +++ Extension/Music/hpack_tables.h | 139 ++++++ Extension/Music/music_artwork.cpp | 430 +++++++++++++++++-- 3 files changed, 597 insertions(+), 36 deletions(-) create mode 100644 Extension/Music/hpack_tables.h diff --git a/Extension/Music/Test/music_artwork_tests.cpp b/Extension/Music/Test/music_artwork_tests.cpp index d85593d0..62fabf39 100644 --- a/Extension/Music/Test/music_artwork_tests.cpp +++ b/Extension/Music/Test/music_artwork_tests.cpp @@ -1,3 +1,4 @@ +#include #include #include #include "Extension/Music/music_artwork.h" @@ -40,6 +41,65 @@ std::pair fetch(const std::string& url) { body.append(buffer.data(), received); return {status, body}; } +// A minimal HTTP/2 cleartext (h2c) client, the way the game's client connects: send the connection +// preface, SETTINGS and a HEADERS frame, then collect the DATA frames of the response. +std::pair fetch_h2c(unsigned short port, const std::string& path) { + SOCKET socket_value = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (socket_value == INVALID_SOCKET) return {}; + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_port = htons(port); + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (connect(socket_value, reinterpret_cast(&address), sizeof(address)) != 0) { + closesocket(socket_value); + return {}; + } + constexpr char preface[] = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; + send(socket_value, preface, 24, 0); + const char settings[] = {0, 0, 0, 4, 0, 0, 0, 0, 0}; + send(socket_value, settings, 9, 0); + // HPACK block: :method GET (indexed 2), :scheme http (indexed 6), :path as a literal without + // indexing with the static name index 4 and a raw (non-Huffman) value. + std::string block; + block.push_back(static_cast(0x82)); + block.push_back(static_cast(0x86)); + block.push_back(static_cast(0x04)); + block.push_back(static_cast(path.size())); + block.insert(block.end(), path.begin(), path.end()); + const char headers[] = {0, 0, static_cast(block.size()), 1, 4, 0, 0, 0, 1}; + send(socket_value, headers, 9, 0); + send(socket_value, block.data(), static_cast(block.size()), 0); + std::string buffer, body; + int status = 0; + for (;;) { + char chunk[16384]; + const int got = recv(socket_value, chunk, sizeof(chunk), 0); + if (got <= 0) break; + buffer.append(chunk, got); + bool done = false; + while (buffer.size() >= 9) { + const auto length = (std::size_t(static_cast(buffer[0])) << 16) | + (std::size_t(static_cast(buffer[1])) << 8) | std::size_t(static_cast(buffer[2])); + const auto type = static_cast(buffer[3]); + const auto flags = static_cast(buffer[4]); + const auto stream = ((static_cast(static_cast(buffer[5])) << 24) | + (static_cast(static_cast(buffer[6])) << 16) | + (static_cast(static_cast(buffer[7])) << 8) | + static_cast(static_cast(buffer[8]))) & 0x7fffffffu; + if (buffer.size() < 9 + length) break; + const std::string payload = buffer.substr(9, length); + buffer.erase(0, 9 + length); + if (type == 0x1 && stream == 1 && !payload.empty() && static_cast(payload[0]) == 0x88) status = 200; + if (type == 0x0 && stream == 1) { + body += payload; + if (flags & 0x1) done = true; + } + } + if (done) break; + } + closesocket(socket_value); + return {status, body}; +} } int main() try { namespace fs = std::filesystem; @@ -67,6 +127,10 @@ int main() try { const auto base = url.substr(0, url.find('/', 7)); check(fetch(base + "/music-art/unknown.png").first == 404, "unknown route is not served"); check(fetch(base + "/outside.png").first == 404, "HTTP cannot read arbitrary files"); + const auto colon = url.find(':', 7), slash = url.find('/', colon); + const auto port = static_cast(std::stoi(url.substr(colon + 1, slash - colon - 1))); + const auto [h2status, h2body] = fetch_h2c(port, url.substr(slash)); + check(h2status == 200 && h2body == bytes, "h2c returns the exact registered PNG"); } std::error_code ec; fs::create_directory_symlink(root, root / L"mod" / L"escape", ec); diff --git a/Extension/Music/hpack_tables.h b/Extension/Music/hpack_tables.h new file mode 100644 index 00000000..2429fe91 --- /dev/null +++ b/Extension/Music/hpack_tables.h @@ -0,0 +1,139 @@ +// Generated from RFC 7541 (Appendix A static table, Appendix B Huffman code). Do not edit. +#pragma once +#include +#include +#include + +namespace dingosdk::profile_runtime::hpack { +// HPACK static table; entry N is index N+1 (HPACK index 0 is invalid). +struct StaticEntry { std::string_view name, value; }; +inline constexpr std::array static_table{{ + {":authority", ""}, // 1 + {":method", "GET"}, // 2 + {":method", "POST"}, // 3 + {":path", "/"}, // 4 + {":path", "/index.html"}, // 5 + {":scheme", "http"}, // 6 + {":scheme", "https"}, // 7 + {":status", "200"}, // 8 + {":status", "204"}, // 9 + {":status", "206"}, // 10 + {":status", "304"}, // 11 + {":status", "400"}, // 12 + {":status", "404"}, // 13 + {":status", "500"}, // 14 + {"accept-charset", ""}, // 15 + {"accept-encoding", "gzip, deflate"}, // 16 + {"accept-language", ""}, // 17 + {"accept-ranges", ""}, // 18 + {"accept", ""}, // 19 + {"access-control-allow-origin", ""}, // 20 + {"age", ""}, // 21 + {"allow", ""}, // 22 + {"authorization", ""}, // 23 + {"cache-control", ""}, // 24 + {"content-disposition", ""}, // 25 + {"content-encoding", ""}, // 26 + {"content-language", ""}, // 27 + {"content-length", ""}, // 28 + {"content-location", ""}, // 29 + {"content-range", ""}, // 30 + {"content-type", ""}, // 31 + {"cookie", ""}, // 32 + {"date", ""}, // 33 + {"etag", ""}, // 34 + {"expect", ""}, // 35 + {"expires", ""}, // 36 + {"from", ""}, // 37 + {"host", ""}, // 38 + {"if-match", ""}, // 39 + {"if-modified-since", ""}, // 40 + {"if-none-match", ""}, // 41 + {"if-range", ""}, // 42 + {"if-unmodified-since", ""}, // 43 + {"last-modified", ""}, // 44 + {"link", ""}, // 45 + {"location", ""}, // 46 + {"max-forwards", ""}, // 47 + {"proxy-authenticate", ""}, // 48 + {"proxy-authorization", ""}, // 49 + {"range", ""}, // 50 + {"referer", ""}, // 51 + {"refresh", ""}, // 52 + {"retry-after", ""}, // 53 + {"server", ""}, // 54 + {"set-cookie", ""}, // 55 + {"strict-transport-security", ""}, // 56 + {"transfer-encoding", ""}, // 57 + {"user-agent", ""}, // 58 + {"vary", ""}, // 59 + {"via", ""}, // 60 + {"www-authenticate", ""}, // 61 +}}; + +// Huffman code per symbol, MSB-first in `code` using `bits` bits; symbol 256 is EOS. +inline constexpr std::array huffman_code{{ + 8184, 8388568, 268435426, 268435427, 268435428, 268435429, + 268435430, 268435431, 268435432, 16777194, 1073741820, 268435433, + 268435434, 1073741821, 268435435, 268435436, 268435437, 268435438, + 268435439, 268435440, 268435441, 268435442, 1073741822, 268435443, + 268435444, 268435445, 268435446, 268435447, 268435448, 268435449, + 268435450, 268435451, 20, 1016, 1017, 4090, + 8185, 21, 248, 2042, 1018, 1019, + 249, 2043, 250, 22, 23, 24, + 0, 1, 2, 25, 26, 27, + 28, 29, 30, 31, 92, 251, + 32764, 32, 4091, 1020, 8186, 33, + 93, 94, 95, 96, 97, 98, + 99, 100, 101, 102, 103, 104, + 105, 106, 107, 108, 109, 110, + 111, 112, 113, 114, 252, 115, + 253, 8187, 524272, 8188, 16380, 34, + 32765, 3, 35, 4, 36, 5, + 37, 38, 39, 6, 116, 117, + 40, 41, 42, 7, 43, 118, + 44, 8, 9, 45, 119, 120, + 121, 122, 123, 32766, 2044, 16381, + 8189, 268435452, 1048550, 4194258, 1048551, 1048552, + 4194259, 4194260, 4194261, 8388569, 4194262, 8388570, + 8388571, 8388572, 8388573, 8388574, 16777195, 8388575, + 16777196, 16777197, 4194263, 8388576, 16777198, 8388577, + 8388578, 8388579, 8388580, 2097116, 4194264, 8388581, + 4194265, 8388582, 8388583, 16777199, 4194266, 2097117, + 1048553, 4194267, 4194268, 8388584, 8388585, 2097118, + 8388586, 4194269, 4194270, 16777200, 2097119, 4194271, + 8388587, 8388588, 2097120, 2097121, 4194272, 2097122, + 8388589, 4194273, 8388590, 8388591, 1048554, 4194274, + 4194275, 4194276, 8388592, 4194277, 4194278, 8388593, + 67108832, 67108833, 1048555, 524273, 4194279, 8388594, + 4194280, 33554412, 67108834, 67108835, 67108836, 134217694, + 134217695, 67108837, 16777201, 33554413, 524274, 2097123, + 67108838, 134217696, 134217697, 67108839, 134217698, 16777202, + 2097124, 2097125, 67108840, 67108841, 268435453, 134217699, + 134217700, 134217701, 1048556, 16777203, 1048557, 2097126, + 4194281, 2097127, 2097128, 8388595, 4194282, 4194283, + 33554414, 33554415, 16777204, 16777205, 67108842, 8388596, + 67108843, 134217702, 67108844, 67108845, 134217703, 134217704, + 134217705, 134217706, 134217707, 268435454, 134217708, 134217709, + 134217710, 134217711, 134217712, 67108846, 1073741823, +}}; +inline constexpr std::array huffman_bits{{ + 13, 23, 28, 28, 28, 28, 28, 28, 28, 24, 30, 28, 28, 30, 28, 28, + 28, 28, 28, 28, 28, 28, 30, 28, 28, 28, 28, 28, 28, 28, 28, 28, + 6, 10, 10, 12, 13, 6, 8, 11, 10, 10, 8, 11, 8, 6, 6, 6, + 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 7, 8, 15, 6, 12, 10, + 13, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 13, 19, 13, 14, 6, + 15, 5, 6, 5, 6, 5, 6, 6, 6, 5, 7, 7, 6, 6, 6, 5, + 6, 7, 6, 5, 5, 6, 7, 7, 7, 7, 7, 15, 11, 14, 13, 28, + 20, 22, 20, 20, 22, 22, 22, 23, 22, 23, 23, 23, 23, 23, 24, 23, + 24, 24, 22, 23, 24, 23, 23, 23, 23, 21, 22, 23, 22, 23, 23, 24, + 22, 21, 20, 22, 22, 23, 23, 21, 23, 22, 22, 24, 21, 22, 23, 23, + 21, 21, 22, 21, 23, 22, 23, 23, 20, 22, 22, 22, 23, 22, 22, 23, + 26, 26, 20, 19, 22, 23, 22, 25, 26, 26, 26, 27, 27, 26, 24, 25, + 19, 21, 26, 27, 27, 26, 27, 24, 21, 21, 26, 26, 28, 27, 27, 27, + 20, 24, 20, 21, 22, 21, 21, 23, 22, 22, 25, 25, 24, 24, 26, 23, + 26, 27, 26, 26, 27, 27, 27, 27, 27, 28, 27, 27, 27, 27, 27, 26, + 30, +}}; +} // namespace dingosdk::profile_runtime::hpack diff --git a/Extension/Music/music_artwork.cpp b/Extension/Music/music_artwork.cpp index 1fbacb54..9fba0ae6 100644 --- a/Extension/Music/music_artwork.cpp +++ b/Extension/Music/music_artwork.cpp @@ -1,23 +1,55 @@ #include #include #include "music_artwork.h" +#include "hpack_tables.h" #include "Engine/Vfs/mod_music.h" +#include #include +#include +#include #include +#include +#include +#include +#include #include #include +#include #include +#include +#include #include #include +#include #include namespace dingosdk::profile_runtime { namespace { constexpr std::size_t max_image = 4 * 1024 * 1024, max_total = 64 * 1024 * 1024; +constexpr unsigned short fixed_port = 47823; // stable origin so the game's HTTP cache is reused +constexpr std::size_t max_frame = 16384; + +// HTTP/2 (RFC 7540) cleartext, because the game's HTTP client speaks h2c prior-knowledge. +constexpr char h2_preface[24] = {'P', 'R', 'I', ' ', '*', ' ', 'H', 'T', 'T', 'P', '/', '2', '.', '0', + '\r', '\n', '\r', '\n', 'S', 'M', '\r', '\n', '\r', '\n'}; +constexpr std::uint8_t f_data = 0x0, f_headers = 0x1, f_settings = 0x4, f_ping = 0x6, + f_goaway = 0x7, f_window_update = 0x8, f_continuation = 0x9; +constexpr std::uint8_t flag_ack = 0x1, flag_end_stream = 0x1, flag_end_headers = 0x4, flag_padded = 0x8, flag_priority = 0x20; + struct Socket { SOCKET value = INVALID_SOCKET; + Socket() = default; + explicit Socket(SOCKET socket) : value(socket) {} + Socket(Socket&& other) noexcept : value(other.value) { other.value = INVALID_SOCKET; } + Socket& operator=(Socket&& other) noexcept { + if (this != &other) { if (value != INVALID_SOCKET) closesocket(value); value = other.value; other.value = INVALID_SOCKET; } + return *this; + } + Socket(const Socket&) = delete; + Socket& operator=(const Socket&) = delete; ~Socket() { if (value != INVALID_SOCKET) closesocket(value); } }; + bool send_all(SOCKET socket, const char* bytes, std::size_t count) { while (count) { const auto sent = send(socket, bytes, static_cast(count), 0); @@ -41,42 +73,250 @@ bool png_ok(const std::vector& bytes) { const auto w = dimension(16), h = dimension(20); return w && h && w <= 2048 && h <= 2048; } + +// ---- minimal HPACK decoder (RFC 7541), enough to read the request's :path ---------------------- +using Octets = std::span; +bool decode_integer(Octets data, std::size_t& at, unsigned prefix_bits, std::uint64_t& out) { + if (at >= data.size()) return false; + const std::uint64_t mask = (1u << prefix_bits) - 1; + std::uint64_t value = data[at++] & mask; + if (value < mask) { out = value; return true; } + unsigned shift = 0; + for (;;) { + if (at >= data.size()) return false; + const auto byte = data[at++]; + value += std::uint64_t(byte & 0x7f) << shift; + if (!(byte & 0x80)) break; + shift += 7; + if (shift > 56) return false; + } + out = value; + return true; } +bool huffman_decode(std::string_view input, std::string& out) { + struct Node { std::int32_t child[2] = {-1, -1}; std::int32_t symbol = -1; }; + static const auto tree = [] { + std::vector nodes(1); + for (std::size_t symbol = 0; symbol < hpack::huffman_bits.size(); ++symbol) { + const auto bits = hpack::huffman_bits[symbol]; + const auto code = hpack::huffman_code[symbol]; + if (!bits) continue; + std::int32_t node = 0; + for (int i = bits - 1; i >= 0; --i) { + const auto bit = (code >> i) & 1; + if (nodes[node].child[bit] < 0) { + nodes[node].child[bit] = static_cast(nodes.size()); + nodes.emplace_back(); + } + node = nodes[node].child[bit]; + } + nodes[node].symbol = static_cast(symbol); + } + return nodes; + }(); + std::int32_t node = 0; + for (const auto byte : input) { + for (int i = 7; i >= 0; --i) { + node = tree[node].child[(static_cast(byte) >> i) & 1]; + if (node < 0) return false; + if (tree[node].symbol >= 0) { + if (tree[node].symbol == 256) return false; // EOS must not appear in a value + out.push_back(static_cast(tree[node].symbol)); + node = 0; + } + } + } + return true; // trailing padding bits (EOS prefix) are ignored +} +bool decode_string(Octets data, std::size_t& at, std::string& out) { + if (at >= data.size()) return false; + const bool huffman = data[at] & 0x80; + std::uint64_t length = 0; + if (!decode_integer(data, at, 7, length) || at + length > data.size()) return false; + const std::string_view bytes(reinterpret_cast(data.data() + at), static_cast(length)); + at += length; + return huffman ? huffman_decode(bytes, out) : (out.assign(bytes), true); +} +// Returns the request's :path, or empty on a malformed block. Dynamic table state is per connection. +std::string decode_request_path(Octets block) { + std::deque> dynamic; + const auto name_at = [&](std::uint64_t index, std::string& name) { + if (index == 0) return false; + if (index <= 61) { name = hpack::static_table[index - 1].name; return true; } + const auto position = index - 62; + if (position >= dynamic.size()) return false; + name = dynamic[position].first; + return true; + }; + const auto value_at = [&](std::uint64_t index, std::string& name, std::string& value) { + if (index == 0) return false; + if (index <= 61) { name = hpack::static_table[index - 1].name; value = hpack::static_table[index - 1].value; return true; } + const auto position = index - 62; + if (position >= dynamic.size()) return false; + name = dynamic[position].first; + value = dynamic[position].second; + return true; + }; + std::size_t at = 0; + while (at < block.size()) { + const auto byte = block[at]; + std::string name, value; + if (byte & 0x80) { + std::uint64_t index = 0; + if (!decode_integer(block, at, 7, index) || !value_at(index, name, value)) return {}; + } else if ((byte & 0xc0) == 0x40) { + std::uint64_t index = 0; + if (!decode_integer(block, at, 6, index)) return {}; + if (index == 0) { if (!decode_string(block, at, name)) return {}; } + else if (!name_at(index, name)) return {}; + if (!decode_string(block, at, value)) return {}; + dynamic.emplace_front(name, value); + } else if ((byte & 0xe0) == 0x20) { + std::uint64_t size = 0; + if (!decode_integer(block, at, 5, size)) return {}; + if (size == 0) dynamic.clear(); + continue; + } else { + std::uint64_t index = 0; + if (!decode_integer(block, at, 4, index)) return {}; + if (index == 0) { if (!decode_string(block, at, name)) return {}; } + else if (!name_at(index, name)) return {}; + if (!decode_string(block, at, value)) return {}; + } + if (name == ":path") return value; + } + return {}; +} + +// ---- HTTP/2 framing ---------------------------------------------------------------------------- +std::uint32_t be32(const char* p) { + return (std::uint32_t(static_cast(p[0])) << 24) | (std::uint32_t(static_cast(p[1])) << 16) | + (std::uint32_t(static_cast(p[2])) << 8) | std::uint32_t(static_cast(p[3])); +} +bool send_frame(SOCKET socket, std::uint8_t type, std::uint8_t flags, std::uint32_t stream, std::span payload) { + std::array header{}; + const auto length = payload.size(); + header[0] = static_cast((length >> 16) & 0xff); + header[1] = static_cast((length >> 8) & 0xff); + header[2] = static_cast(length & 0xff); + header[3] = static_cast(type); + header[4] = static_cast(flags); + header[5] = static_cast((stream >> 24) & 0x7f); + header[6] = static_cast((stream >> 16) & 0xff); + header[7] = static_cast((stream >> 8) & 0xff); + header[8] = static_cast(stream & 0xff); + return send_all(socket, header.data(), header.size()) && (payload.empty() || send_all(socket, payload.data(), payload.size())); +} +// HPACK literals without indexing (no Huffman). Static-table names use the indexed-name form. +void append_indexed_name(std::vector& out, unsigned name_index, std::string_view value) { + if (name_index < 15) out.push_back(static_cast(name_index)); + else { out.push_back(0x0f); out.push_back(static_cast(name_index - 15)); } + out.push_back(static_cast(value.size())); + out.insert(out.end(), value.begin(), value.end()); +} +void append_new_name(std::vector& out, std::string_view name, std::string_view value) { + out.push_back(0x00); + out.push_back(static_cast(name.size())); + out.insert(out.end(), name.begin(), name.end()); + out.push_back(static_cast(value.size())); + out.insert(out.end(), value.begin(), value.end()); +} +std::string etag_for(const std::vector& bytes) { + std::uint64_t hash = 1469598103934665603ull; + for (const auto byte : bytes) { hash ^= static_cast(byte); hash *= 1099511628211ull; } + char text[24]; + std::snprintf(text, sizeof(text), "\"%016llx\"", static_cast(hash)); + return text; +} +// :status 200 (indexed 8), content-type (31), content-length (28), cache-control (24), etag. +std::vector response_header_block(std::size_t length, const std::string& etag) { + std::vector out; + out.push_back(static_cast(0x88)); + append_indexed_name(out, 31, "image/png"); + append_indexed_name(out, 28, std::to_string(length)); + append_indexed_name(out, 24, "public, max-age=31536000, immutable"); + if (!etag.empty()) append_new_name(out, "etag", etag); + return out; +} +constexpr char status_404_block = static_cast(0x80 | 13); // :status 404 (static index 13) + +struct Buffered { + SOCKET socket{}; + std::string buffer; + bool take(std::size_t count, std::string& out) { + while (buffer.size() < count) { + std::array chunk{}; + const auto received = recv(socket, chunk.data(), static_cast(chunk.size()), 0); + if (received <= 0) return false; + buffer.append(chunk.data(), static_cast(received)); + } + out.assign(buffer, 0, count); + buffer.erase(0, count); + return true; + } +}; + +std::string sanitize(std::string_view value) { + std::string out; + for (const auto c : value) out.push_back(std::isalnum(static_cast(c)) ? c : '_'); + return out; +} +std::string mod_folder_name(const std::filesystem::path& mod) { + const auto name = mod.filename().wstring(); + std::string out; + out.reserve(name.size()); + for (const auto c : name) out.push_back(static_cast(c)); + return out; +} +} // namespace + struct MusicArtworkServer::Impl { bool winsock = false; Socket listener; unsigned short port = 0; std::mutex mutex; - std::map>> images; + std::map>, std::less<>> images; // route -> bytes std::map> paths; - std::size_t total = 0, next = 0; - std::jthread worker; + std::size_t total = 0; + std::vector workers; Impl() { WSADATA data{}; if (WSAStartup(MAKEWORD(2, 2), &data) != 0) return; winsock = true; - listener.value = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + listener = bind_listener(fixed_port); + if (listener.value == INVALID_SOCKET) listener = bind_listener(0); // port taken: fall back if (listener.value == INVALID_SOCKET) return; + const auto count = std::clamp(std::thread::hardware_concurrency(), 4u, 8u); + for (unsigned i = 0; i < count; ++i) + workers.emplace_back([this](std::stop_token stop) { serve(stop); }); + } + ~Impl() { + for (auto& worker : workers) worker.request_stop(); + for (auto& worker : workers) if (worker.joinable()) worker.join(); + if (winsock) WSACleanup(); + } + + Socket bind_listener(unsigned short wanted) { + Socket bound{socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)}; + if (bound.value == INVALID_SOCKET) return {}; const BOOL exclusive = TRUE; - if (setsockopt(listener.value, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, - reinterpret_cast(&exclusive), sizeof(exclusive)) != 0) return; + if (setsockopt(bound.value, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, + reinterpret_cast(&exclusive), sizeof(exclusive)) != 0) return {}; sockaddr_in address{}; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - if (bind(listener.value, reinterpret_cast(&address), sizeof(address)) != 0 || - listen(listener.value, SOMAXCONN) != 0) return; + address.sin_port = htons(wanted); + if (bind(bound.value, reinterpret_cast(&address), sizeof(address)) != 0 || + listen(bound.value, SOMAXCONN) != 0) return {}; int size = sizeof(address); - if (getsockname(listener.value, reinterpret_cast(&address), &size) != 0) return; - worker = std::jthread([this](std::stop_token stop) { serve(stop); }); + if (getsockname(bound.value, reinterpret_cast(&address), &size) != 0) return {}; + u_long nonblocking = 1; // several pollers share the listener; a racing accept must not block + ioctlsocket(bound.value, FIONBIO, &nonblocking); port = ntohs(address.sin_port); + return bound; } - ~Impl() { - worker.request_stop(); - if (worker.joinable()) worker.join(); - if (listener.value != INVALID_SOCKET) { closesocket(listener.value); listener.value = INVALID_SOCKET; } - if (winsock) WSACleanup(); - } + void serve(std::stop_token stop) noexcept { while (!stop.stop_requested()) { fd_set ready; @@ -84,14 +324,130 @@ struct MusicArtworkServer::Impl { FD_SET(listener.value, &ready); timeval timeout{0, 100000}; if (select(0, &ready, nullptr, nullptr, &timeout) <= 0) continue; + if (!FD_ISSET(listener.value, &ready)) continue; Socket client{accept(listener.value, nullptr, nullptr)}; if (client.value == INVALID_SOCKET) continue; - const DWORD milliseconds = 1000; + u_long blocking = 0; + ioctlsocket(client.value, FIONBIO, &blocking); // accepted sockets inherit the listener mode + const DWORD milliseconds = 2000; setsockopt(client.value, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&milliseconds), sizeof(milliseconds)); setsockopt(client.value, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast(&milliseconds), sizeof(milliseconds)); - try { respond(client.value); } catch (...) {} // Failed requests never take down the music catalog. + try { handle(client.value); } catch (...) {} } } + + void handle(SOCKET client) { + std::array peek{}; + for (;;) { + const auto available = recv(client, peek.data(), static_cast(peek.size()), MSG_PEEK); + if (available <= 0) return; + if (static_cast(available) >= peek.size()) break; + } + if (std::memcmp(peek.data(), h2_preface, sizeof(h2_preface)) == 0) serve_h2c(client); + else respond(client); + } + + std::shared_ptr> find(std::string_view route) { + std::lock_guard lock(mutex); + const auto found = images.find(route); + return found == images.end() ? nullptr : found->second; + } + + // Graceful close: closing with unread frames queued makes Windows send RST and the client can + // drop the response tail. + static void finish(SOCKET client) { + shutdown(client, SD_SEND); + const DWORD drain_ms = 300; + setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&drain_ms), sizeof(drain_ms)); + std::array discard{}; + while (recv(client, discard.data(), static_cast(discard.size()), 0) > 0) {} + } + + void serve_h2c(SOCKET client) { + Buffered reader{client, {}}; + std::string preface; + if (!reader.take(sizeof(h2_preface), preface) || !send_frame(client, f_settings, 0, 0, {})) return; + const auto read_frame = [&](std::uint8_t& type, std::uint8_t& flags, std::uint32_t& stream, std::string& payload) { + std::string header; + if (!reader.take(9, header)) return false; + const auto length = (std::size_t(static_cast(header[0])) << 16) | + (std::size_t(static_cast(header[1])) << 8) | std::size_t(static_cast(header[2])); + type = static_cast(header[3]); + flags = static_cast(header[4]); + stream = be32(header.data() + 5) & 0x7fffffffu; + payload.clear(); + return length == 0 || reader.take(length, payload); + }; + std::uint32_t request_stream = 0; + std::int64_t connection_window = 65535, stream_window = 65535; + std::string block; + for (;;) { + std::uint8_t type{}, flags{}; std::uint32_t stream{}; std::string payload; + if (!read_frame(type, flags, stream, payload)) return; + if (type == f_settings) { + if (!(flags & flag_ack)) { + for (std::size_t at = 0; at + 6 <= payload.size(); at += 6) + if (((std::uint16_t(static_cast(payload[at])) << 8) | static_cast(payload[at + 1])) == 4) + stream_window = be32(payload.data() + at + 2); + if (!send_frame(client, f_settings, flag_ack, 0, {})) return; + } + } else if (type == f_ping && !(flags & flag_ack)) { + if (!send_frame(client, f_ping, flag_ack, 0, payload)) return; + } else if (type == f_window_update) { + if (payload.size() >= 4) (stream == 0 ? connection_window : stream_window) += be32(payload.data()) & 0x7fffffffu; + } else if (type == f_headers) { + request_stream = stream; + std::size_t at = 0; + if ((flags & flag_padded) && at < payload.size()) at += 1 + static_cast(payload[at]); + if (flags & flag_priority) at += 5; + if (at < payload.size()) block.append(payload, at, std::string::npos); + if (flags & flag_end_headers) break; + } else if (type == f_continuation) { + block.append(payload); + if (flags & flag_end_headers) break; + } else if (type == f_goaway) { + return; + } + } + if (!request_stream) return; + const auto route = decode_request_path(Octets(reinterpret_cast(block.data()), block.size())); + const auto image = route.empty() ? nullptr : find(route); + if (!image) { + send_frame(client, f_headers, flag_end_headers | flag_end_stream, request_stream, std::span(&status_404_block, 1)); + send_frame(client, f_goaway, 0, 0, {}); + finish(client); + return; + } + const auto headers = response_header_block(image->size(), etag_for(*image)); + if (!send_frame(client, f_headers, flag_end_headers, request_stream, std::span(headers.data(), headers.size()))) return; + std::size_t sent = 0; + while (sent < image->size()) { + if (connection_window <= 0 || stream_window <= 0) { + std::uint8_t type{}, flags{}; std::uint32_t stream{}; std::string payload; + if (!read_frame(type, flags, stream, payload)) return; + if (type == f_window_update) { + if (payload.size() >= 4) (stream == 0 ? connection_window : stream_window) += be32(payload.data()) & 0x7fffffffu; + } else if (type == f_ping && !(flags & flag_ack)) { + if (!send_frame(client, f_ping, flag_ack, 0, payload)) return; + } else if (type == f_settings && !(flags & flag_ack)) { + if (!send_frame(client, f_settings, flag_ack, 0, {})) return; + } else if (type == f_goaway) { + return; + } + continue; + } + const auto chunk = std::min({std::size_t(connection_window), std::size_t(stream_window), max_frame, image->size() - sent}); + const bool last = sent + chunk == image->size(); + if (!send_frame(client, f_data, last ? flag_end_stream : std::uint8_t(0), request_stream, + std::span(image->data() + sent, chunk))) return; + sent += chunk; + connection_window -= static_cast(chunk); + stream_window -= static_cast(chunk); + } + send_frame(client, f_goaway, 0, 0, {}); + finish(client); + } + void respond(SOCKET client) { std::string request; std::array buffer{}; @@ -102,30 +458,29 @@ struct MusicArtworkServer::Impl { if (received <= 0) return; request.append(buffer.data(), static_cast(received)); } - const auto end = request.find("\r\n"); - const auto line = request.substr(0, end); - std::shared_ptr> bytes; + const auto line = request.substr(0, request.find("\r\n")); + std::shared_ptr> image; if (request.find("\r\n\r\n") != std::string::npos && line.starts_with("GET ")) { const auto space = line.find(' ', 4); - if (space != std::string::npos && (line.substr(space) == " HTTP/1.1" || line.substr(space) == " HTTP/1.0")) { - std::lock_guard lock(mutex); - if (const auto found = images.find(line.substr(4, space - 4)); found != images.end()) bytes = found->second; - } + if (space != std::string::npos && (line.substr(space) == " HTTP/1.1" || line.substr(space) == " HTTP/1.0")) + image = find(line.substr(4, space - 4)); } - if (!bytes) { + if (!image) { constexpr std::string_view missing = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; send_all(client, missing.data(), missing.size()); return; } - const auto header = "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: " + std::to_string(bytes->size()) + - "\r\nCache-Control: private, max-age=31536000\r\nConnection: close\r\n\r\n"; - if (send_all(client, header.data(), header.size())) send_all(client, bytes->data(), bytes->size()); + const auto header = "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: " + std::to_string(image->size()) + + "\r\nCache-Control: public, max-age=31536000, immutable\r\nConnection: close\r\n\r\n"; + if (send_all(client, header.data(), header.size())) send_all(client, image->data(), image->size()); } }; + MusicArtworkServer::MusicArtworkServer() : impl_(std::make_unique()) {} MusicArtworkServer::~MusicArtworkServer() = default; + std::string MusicArtworkServer::add(const std::filesystem::path& mod, const std::string& relative) { - if (!impl_->port || !mods::music_artwork_path(relative)) return {}; + if (!impl_->winsock || impl_->port == 0 || !mods::music_artwork_path(relative)) return {}; try { const auto root = std::filesystem::canonical(mod); const auto file = std::filesystem::canonical(root / std::filesystem::path(std::u8string(relative.begin(), relative.end()))); @@ -142,18 +497,21 @@ std::string MusicArtworkServer::add(const std::filesystem::path& mod, const std: std::ifstream input(file, std::ios::binary); auto bytes = std::make_shared>(static_cast(size)); if (!input.read(bytes->data(), static_cast(size)) || !png_ok(*bytes)) return {}; - const auto route = "/music-art/" + std::to_string(impl_->next++) + ".png"; + // A stable, readable route so the URL is identical every launch (and the game's HTTP cache + // can be reused) and the image identity is in the path, like the game's own CDN links. + const auto route = "/music-art/" + sanitize(mod_folder_name(mod)) + "/" + relative; const auto url = "http://127.0.0.1:" + std::to_string(impl_->port) + route; - impl_->images.emplace(route, bytes); + impl_->images[route] = bytes; impl_->total += bytes->size(); impl_->paths[file] = {stamp, url}; return url; } catch (const std::exception&) { return {}; } } + std::string mod_music_artwork_url(const std::filesystem::path& mod, const std::string& relative) { - // The injected runtime has process lifetime. Pin it before starting a worker, so its - // code cannot be unloaded while the image endpoint is serving requests. Avoid joining - // a worker from CRT teardown under the loader lock. Tests use the owned server above. + // The injected runtime has process lifetime. Pin it before starting workers, so its code cannot + // be unloaded while the image endpoint is serving requests. Avoid joining a worker from CRT + // teardown under the loader lock. Tests use the owned server above. static auto* server = []() -> MusicArtworkServer* { HMODULE module{}; if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, @@ -162,4 +520,4 @@ std::string mod_music_artwork_url(const std::filesystem::path& mod, const std::s }(); return server ? server->add(mod, relative) : std::string{}; } -} +} // namespace dingosdk::profile_runtime From ddd24c0f41947573eafe6910fdfee744e85fdc48 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Sun, 4 Oct 2026 17:23:54 -0600 Subject: [PATCH 032/109] Pass empty launch arguments to the game as "" --- Launcher/launch.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Launcher/launch.cpp b/Launcher/launch.cpp index 7aa81c44..7058557c 100644 --- a/Launcher/launch.cpp +++ b/Launcher/launch.cpp @@ -174,7 +174,7 @@ bool same_file(const fs::path& first, const fs::path& second) { } std::wstring quote_argument(std::wstring_view value) { - if (value.find_first_of(L" \t\n\v\"") == std::wstring_view::npos) return std::wstring(value); + if (!value.empty() && value.find_first_of(L" \t\n\v\"") == std::wstring_view::npos) return std::wstring(value); std::wstring output(1, L'\"'); std::size_t slashes{}; for (const auto character : value) { From 493cc4f5a100836fd66766b226611d47a2424f69 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sun, 4 Oct 2026 19:14:36 -0500 Subject: [PATCH 033/109] mem pool cap decrease --- Runtime/client_tick.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 8ce5575a..880f7201 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -139,9 +139,11 @@ void apply_performance_settings() { // of older cards resetting the device while loading (DXGI_ERROR_DEVICE_RESET in // gameRendBeginFrame on an RX 580, 2026-10-03). A quarter of the card's dedicated memory, from // the game's own 520 MB up to 3.5 GiB (from 16 GB cards up); cards of 6 GB or less keep the -// game's value. +// game's value. Cards of up to 12 GB stop at 2 GiB: a quarter was 3 GiB of a 12 GB card, and +// with a big map and texture mods on top the game ran out of video memory (E_OUTOFMEMORY from +// CreateCommittedResource on an RTX 4070 SUPER, 2026-10-04). namespace mesh_pool { -constexpr std::uint32_t stock_kb = 532960, most_kb = 3584u * 1024u; +constexpr std::uint32_t stock_kb = 532960, most_kb = 3584u * 1024u, most_to_12gb_kb = 2048u * 1024u; static_assert((std::uint64_t{most_kb} + 24576u) * 1024u <= 0xffffffffull); struct Card { std::uint64_t memory{}; std::string name; }; // The hardware adapter with the most dedicated memory: the one the game renders on (a laptop's @@ -169,9 +171,11 @@ Card largest_card() { } // KiB for the pool on this card, or 0 to keep the game's own value. std::uint32_t size_kb(std::uint64_t memory) { - constexpr std::uint64_t six_gib = 6ull << 30, slack = 256ull << 20; // "6 GB" cards report a little under + // Cards report a little under or over their nominal size. + constexpr std::uint64_t six_gib = 6ull << 30, twelve_gib = 12ull << 30, slack = 256ull << 20; if (memory <= six_gib + slack) return 0; - return static_cast(std::clamp(memory / 4 / 1024, stock_kb, most_kb)); + const auto most = memory <= twelve_gib + slack ? most_to_12gb_kb : most_kb; + return static_cast(std::clamp(memory / 4 / 1024, stock_kb, most)); } } void apply_mesh_streaming_pool() { From 65e0e8d86c01279c9e2716cc5f37b7bf5381c639 Mon Sep 17 00:00:00 2001 From: Vex Date: Sun, 4 Oct 2026 21:25:52 -0300 Subject: [PATCH 034/109] Send the systemd unit's stdout to the journal, not a second copy of the log The server appends every line to ReSkateServer.log next to itself (main.cpp write_log). The unit also appended stdout to that same file, so each line was written twice, once per stream, and the copies could interleave out of order. The journal keeps the console copy instead. Co-Authored-By: Claude Opus 5.5 --- Server/README-linux.md | 3 +++ contrib/reskate-server.service | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Server/README-linux.md b/Server/README-linux.md index 07e1654b..1bbed098 100644 --- a/Server/README-linux.md +++ b/Server/README-linux.md @@ -104,6 +104,9 @@ sudo systemctl daemon-reload sudo systemctl enable --now reskate-server ``` +The server writes its own `ReSkateServer.log` next to the binary; the console +output goes to the journal (`journalctl -u reskate-server -f`). + ## Notes / limits (V1) - `auto_update` / `update` command: `updates_enabled()==false` on Linux. diff --git a/contrib/reskate-server.service b/contrib/reskate-server.service index 744919bd..795dc52b 100644 --- a/contrib/reskate-server.service +++ b/contrib/reskate-server.service @@ -12,7 +12,9 @@ Restart=always RestartSec=5 # Server uses restart_for_update internally; V1 Linux has self-update off, # so any non-zero exit still restarts here. -StandardOutput=append:/opt/reskate-server/ReSkateServer.log +# The server already appends every line to ReSkateServer.log next to itself; +# appending stdout there too wrote each line twice. The journal keeps the console copy. +StandardOutput=journal StandardError=inherit [Install] From 3a23cf0a18b15b91aec783189d6e06eec10c581f Mon Sep 17 00:00:00 2001 From: Vex Date: Sun, 4 Oct 2026 21:25:54 -0300 Subject: [PATCH 035/109] Accept CRLF lines on the Linux server console The Linux console reader split stdin on '\n' only, so a line ending in "\r\n" kept its '\r': "quit" did not stop the server and every command answered Unknown command. Lines from a script saved on Windows, a hosting panel or telnet end that way. (The Windows reader uses std::getline on a text-mode stream, which already drops the '\r'.) Co-Authored-By: Claude Opus 5.5 --- Server/main.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Server/main.cpp b/Server/main.cpp index 6cd5e27c..35e54632 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -190,6 +190,9 @@ struct Input { } for (ssize_t i = 0; i < count; ++i) { if (buffer[i] == '\n') { + // CRLF input (a script saved on Windows, a panel or telnet): without + // this, "quit\r" is an unknown command and the server keeps running. + if (!pending.empty() && pending.back() == '\r') pending.pop_back(); std::lock_guard lock(mutex); lines.push_back(pending); pending.clear(); From 278b6a2a5c38187582a447a3e60f6123796c6bc6 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Sun, 4 Oct 2026 18:34:35 -0600 Subject: [PATCH 036/109] Let `perf sample` take the thread id it advertises The console enforces an argument's choices as an allowlist, so listing client, present and all there refused every thread id before the command ran. They are now completion suggestions only. --- Extension/Console/perf_commands.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Extension/Console/perf_commands.cpp b/Extension/Console/perf_commands.cpp index 2af5ebf1..644fe3f6 100644 --- a/Extension/Console/perf_commands.cpp +++ b/Extension/Console/perf_commands.cpp @@ -101,7 +101,7 @@ void register_perf_commands(Commands& registry) { seconds.minimum = 0.5; seconds.maximum = 60; auto target = argument("client|present|all|", Type::text, true); - target.choices = {"client", "present", "all"}; + target.complete = [](const Model&, auto) { return std::vector{"client", "present", "all"}; }; auto rate = argument("rate", Type::unsigned_integer, true); rate.minimum = 50; rate.maximum = 4000; From e5def363ca3d96e77feb8887934ca12a3fe1c988 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Sun, 4 Oct 2026 18:57:37 -0600 Subject: [PATCH 037/109] Reject freecamfov values the client would refuse --- Extension/Skater/console_commands.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Extension/Skater/console_commands.cpp b/Extension/Skater/console_commands.cpp index 792e7814..5a7fb97c 100644 --- a/Extension/Skater/console_commands.cpp +++ b/Extension/Skater/console_commands.cpp @@ -80,8 +80,13 @@ void register_movement_commands(Commands ®istry) { return State{m.debug.available, value_text(static_cast(m.debug.free_camera_fov)), "Waiting for local player controls.", {}, false}; }; - free_fov.run = [](const Model &, const Values &args, const Output &) { - request_debug(Debug::set_free_camera_fov, false, static_cast(std::get(args[0]))); + free_fov.run = [](const Model &, const Values &args, const Output &out) { + const auto degrees = std::get(args[0]); + if (degrees != 0 && degrees < 40) { + out("error: Use 0 or 40-120."); + return; + } + request_debug(Debug::set_free_camera_fov, false, static_cast(degrees)); }; free_fov.reset = [](const Model &, const Output &) { request_debug(Debug::set_free_camera_fov, false, 0.0f); }; registry.add(std::move(free_fov)); From 2c3b2f63d4e49f635643eaede920ba01a4777320 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Sun, 4 Oct 2026 19:11:47 -0600 Subject: [PATCH 038/109] Drop the nonexistent Developer group from the help text --- Extension/Console/commands.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Extension/Console/commands.cpp b/Extension/Console/commands.cpp index e79ae795..278cdc10 100644 --- a/Extension/Console/commands.cpp +++ b/Extension/Console/commands.cpp @@ -47,7 +47,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(args[0]); From c930b512e6aa95a2e0907c9c988c9f57b0afd0cb Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sun, 4 Oct 2026 22:33:57 -0500 Subject: [PATCH 039/109] Backend lists for tags, cosmetics and bans - Read Dev / Creator / Homie Steam IDs from api.reskate.dev instead of the hardcoded list - Creator (red) and Homie (gold) tags and hoodie/board colours; Admin tag is now pink - Global bans: lobby hosts and dedicated servers turn banned players away ("global_bans": false in ReSkateServer.json opts a server out) - SPECIAL menu page for listed players to hide their own tag and items - Fix hoodie and board keeping the animated colour after the animation turns off - Multiplayer protocol 39 -> 40 --- CMakeLists.txt | 10 + Engine/Game/Multiplayer/session_model.h | 6 +- Engine/Resource/ebx_document.cpp | 7 + Engine/Resource/ebx_document.h | 3 + Engine/Resource/ebx_merge.cpp | 17 +- Engine/Resource/ebx_merge.h | 15 +- Engine/Vfs/Test/item_list_merge_tests.cpp | 393 ++++++++++++++++++ .../Vfs/Test/mod_merge_added_assets_tests.cpp | 7 +- Engine/Vfs/https_download.cpp | 23 + Engine/Vfs/https_download.h | 6 + Engine/Vfs/mod_merge_combine.cpp | 164 +++++++- Extension/Customization/developer_board.cpp | 17 +- .../Customization/developer_board_material.h | 20 +- Extension/Customization/developer_hoodie.cpp | 16 +- .../Customization/developer_hoodie_material.h | 52 ++- Extension/Multiplayer/Hud/custom_nametags.h | 9 +- Extension/Multiplayer/Net/protocol.cpp | 7 +- Extension/Multiplayer/Net/protocol.h | 2 +- Extension/Multiplayer/Remote/cosmetics.h | 3 + Extension/Multiplayer/Session/session.cpp | 5 +- .../Multiplayer/Session/session_commands.cpp | 19 +- .../Multiplayer/Session/session_internal.h | 10 +- .../Multiplayer/Session/session_receive.cpp | 15 + .../Multiplayer/Session/session_send.cpp | 5 +- .../Multiplayer/Session/session_view.cpp | 15 +- .../Test/developer_identity_tests.cpp | 219 ++++++++++ Extension/Multiplayer/Test/mesh_tests.cpp | 93 +++++ Extension/Multiplayer/Test/protocol_tests.cpp | 8 +- Extension/Multiplayer/developer_identity.cpp | 62 +++ Extension/Multiplayer/developer_identity.h | 57 ++- .../Multiplayer/developer_identity_fetch.cpp | 59 +++ Extension/UI/Overlay/multiplayer_menu.cpp | 22 +- Extension/UI/Overlay/nametag_gradient.h | 23 +- Extension/UI/Overlay/overlay.h | 2 +- Extension/UI/Overlay/role_badge.h | 4 +- Extension/UI/Overlay/skate_menu.cpp | 17 +- Extension/UI/Overlay/skate_menu_internal.h | 1 + Runtime/client_tick.cpp | 2 + Server/README-linux.md | 7 +- Server/README.txt | 5 + Server/Test/global_bans_tests.cpp | 57 +++ Server/Test/server_config_tests.cpp | 5 +- Server/global_bans.cpp | 73 ++++ Server/global_bans.h | 24 ++ Server/main.cpp | 21 + Server/server_config.cpp | 2 + Server/server_config.h | 1 + Server/server_host.cpp | 6 + cmake/Apps.cmake | 7 +- cmake/Runtime.cmake | 2 + 50 files changed, 1539 insertions(+), 86 deletions(-) create mode 100644 Engine/Vfs/Test/item_list_merge_tests.cpp create mode 100644 Extension/Multiplayer/Test/developer_identity_tests.cpp create mode 100644 Extension/Multiplayer/developer_identity.cpp create mode 100644 Extension/Multiplayer/developer_identity_fetch.cpp create mode 100644 Server/Test/global_bans_tests.cpp create mode 100644 Server/global_bans.cpp create mode 100644 Server/global_bans.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 63b78dbb..50c2ff11 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -81,6 +81,11 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS) dingosdk_lz4 dingosdk_zstd) endif() add_test(NAME server_config COMMAND dingosdk_server_config_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 $<$: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) @@ -154,6 +159,11 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32) 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) diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 486272ae..15db4660 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -69,7 +69,7 @@ struct MultiplayerChatLine { std::string name, text; bool local{}; // sent by this player // The sender's role, as their nametag shows it: its colour (IM_COL32 layout, 0 = none) - // and a tag shown in a box before the name ("Dev", "Admin", "Host", "Friend" or empty). + // and a tag shown in a box before the name ("Dev", "Creator", "Homie", "Admin", "Host", "Friend" or empty). std::uint32_t color{}; std::string tag; }; @@ -113,6 +113,10 @@ struct MultiplayerModel { bool chat_visible{true}; // Local: bad words in chat names and messages show as **** (on by default). bool chat_filter{true}; + // Local: the tag the ReSkate backend gives this player ("Dev", "Creator" or "Homie"; empty + // for most players), and whether they show it and its animated items, to everyone. + std::string identity_tag; + bool identity_marks{true}; 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. diff --git a/Engine/Resource/ebx_document.cpp b/Engine/Resource/ebx_document.cpp index a7eee7cf..3f439739 100644 --- a/Engine/Resource/ebx_document.cpp +++ b/Engine/Resource/ebx_document.cpp @@ -631,4 +631,11 @@ Document read_document(const std::span bytes) { return result; } +Guid read_file_guid(const std::span bytes) { + BinaryReader reader(bytes); + const auto chunks = read_chunks(reader); + reader.seek(unique_chunk(chunks, efix, "EFIX").begin); + return reader.guid(); +} + } diff --git a/Engine/Resource/ebx_document.h b/Engine/Resource/ebx_document.h index 8d50e399..4ae98098 100644 --- a/Engine/Resource/ebx_document.h +++ b/Engine/Resource/ebx_document.h @@ -194,5 +194,8 @@ struct Document { }; [[nodiscard]] Document read_document(std::span 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 bytes); } diff --git a/Engine/Resource/ebx_merge.cpp b/Engine/Resource/ebx_merge.cpp index b4d3d858..da307d46 100644 --- a/Engine/Resource/ebx_merge.cpp +++ b/Engine/Resource/ebx_merge.cpp @@ -289,7 +289,7 @@ void sort_instances(Document& document) { } // namespace detail Document merge_documents(const Document& base, const std::span 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,7 +302,8 @@ Document merge_documents(const Document& base, const std::spansize()); MergeSummary totals; - for (const auto* edit : edits) { + 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"); @@ -320,7 +321,17 @@ Document merge_documents(const Document& base, const std::spansize(); ++at) { - target->push_back(mapper.value((*editArray)[at])); + const auto& entry = (*editArray)[at]; + const auto* pointer = carry ? std::get_if(&entry.data) : nullptr; + if (pointer && pointer->kind == PointerKind::external && pointer->index >= 0 && + static_cast(pointer->index) < edit->imports.size()) { + const auto& reference = edit->imports[static_cast(pointer->index)]; + if (!carry(reference)) { + totals.dropped.push_back({index, reference}); + continue; + } + } + target->push_back(mapper.value(entry)); ++totals.arrayEntries; } } diff --git a/Engine/Resource/ebx_merge.h b/Engine/Resource/ebx_merge.h index fe3d0559..5440e769 100644 --- a/Engine/Resource/ebx_merge.h +++ b/Engine/Resource/ebx_merge.h @@ -3,6 +3,7 @@ #include "ebx_document.h" #include +#include #include #include @@ -12,8 +13,19 @@ 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; }; +// 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; + // 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 @@ -21,6 +33,7 @@ struct MergeSummary { // be honoured. Edits are applied in the order given, so the last one wins a tie. [[nodiscard]] Document merge_documents(const Document& base, std::span edits, - MergeSummary* summary = nullptr); + MergeSummary* summary = nullptr, + const CarryImport& carry = {}); } // namespace dingosdk::frostbite::ebx diff --git a/Engine/Vfs/Test/item_list_merge_tests.cpp b/Engine/Vfs/Test/item_list_merge_tests.cpp new file mode 100644 index 00000000..4d5317bc --- /dev/null +++ b/Engine/Vfs/Test/item_list_merge_tests.cpp @@ -0,0 +1,393 @@ +// 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 mods are made here from the installed game's own list and one of its items. +// Arguments: []; 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 + +#include +#include +#include + +namespace fs = std::filesystem; +namespace fb = dingosdk::frostbite; +using namespace dingosdk; +using namespace dingosdk::mods::detail; + +namespace { +using Bytes = std::vector; +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(static_cast(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 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(&items->value.data) : nullptr; + if (!entries) throw std::runtime_error("an item list has no Items"); + std::vector result; + for (const auto& entry : *entries) { + const auto* pointer = std::get_if(&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(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 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(field.value.data); + for (const auto& item : items) { + fb::ebx::Value entry; + entry.data = fb::ebx::PointerReference{fb::ebx::PointerKind::external, + static_cast(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(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(field.value.data)) field.value.data = static_cast(hash); + else field.value.data = static_cast(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{}; + 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.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. +void write_mod(const fs::path& directory, const CasStore& store, const Game& game, const Bytes* list, + std::initializer_list items) { + auto manifest = game.shared.listing.manifest; + const auto& shipped = game.shared.listing.files; + std::vector files(shipped.begin() + static_cast(game.shared.listing.first), shipped.end()); + const auto installChunk = shipped.front().location.installChunk; + Bytes archive; + const auto stored = [&](std::span bytes) { + const fb::BundleFileInfo file{{true, installChunk, 1}, static_cast(archive.size()), + static_cast(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& 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(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 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 held; + std::map> 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 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& 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)); +} +} // 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); + 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; +} diff --git a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp index 337e86b3..34571741 100644 --- a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp +++ b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp @@ -328,7 +328,8 @@ void copy_in_the_adders_toc_is_left_alone() { // 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. +// 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"); @@ -345,6 +346,10 @@ void same_name_from_two_mods(bool different) { "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)); } diff --git a/Engine/Vfs/https_download.cpp b/Engine/Vfs/https_download.cpp index 9d654b54..d655c721 100644 --- a/Engine/Vfs/https_download.cpp +++ b/Engine/Vfs/https_download.cpp @@ -2,6 +2,9 @@ #include #include #include +#include +#include +#include #include 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 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 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 text; + if (download.ok) { + std::ifstream in(path, std::ios::binary); + text.emplace(std::istreambuf_iterator(in), std::istreambuf_iterator()); + } + std::filesystem::remove(path, error); + return text; +} } diff --git a/Engine/Vfs/https_download.h b/Engine/Vfs/https_download.h index e0cfe491..91b777ae 100644 --- a/Engine/Vfs/https_download.h +++ b/Engine/Vfs/https_download.h @@ -1,6 +1,8 @@ #pragma once #include #include +#include +#include #include 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 get_text(std::wstring_view url, std::uint64_t max_bytes, + std::uint32_t timeout_seconds, const wchar_t* agent, Download* result = nullptr); } diff --git a/Engine/Vfs/mod_merge_combine.cpp b/Engine/Vfs/mod_merge_combine.cpp index 2c7160e3..03f29a1a 100644 --- a/Engine/Vfs/mod_merge_combine.cpp +++ b/Engine/Vfs/mod_merge_combine.cpp @@ -8,7 +8,9 @@ #include "Engine/Core/Platform/path_text.h" #include +#include #include +#include #include namespace dingosdk::mods::detail { @@ -32,6 +34,7 @@ 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 }; // Where one contributor's copy of an asset lives, so conflicting copies can be @@ -68,6 +71,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. @@ -90,6 +103,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> baseSha; + // Assets a mod added that a higher-priority mod's different asset of the + // same name then replaced: the key, and the sha1 of the copy that went. + std::vector> displaced; +}; + +// 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(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 guids_; + std::map displaced_; + bool added_{}, shipped_{}, unsure_{}, displacedRead_{}; }; // Rebuilds the manifest and the matching placement list in the order the @@ -149,6 +234,8 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, std::map archiveFaults; // Mods whose added assets went into a copy here: their added chunks come too. std::set addedFrom; + // The mods absorbed so far, so each asset can say whose copy it is. + std::set owners; const auto absorb = [&](const fb::TocBundle& bundle, const ArchivePlacement* placement, bool isBase, const fs::path& root) { @@ -431,9 +518,13 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, if (!region.files.empty()) state.manifestChunk = region.files.front().location.installChunk; if (fresh || state.metadata.empty()) state.metadata = manifest.chunkMetadata; 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> 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) // or added from another mod is not this mod's edit and does not lie in @@ -479,9 +570,24 @@ 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); + } 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); @@ -497,6 +603,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. @@ -522,16 +637,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> editBytes; for (const auto* edit : edits) editBytes.push_back(decode(*edit)); @@ -581,6 +691,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 @@ -598,12 +710,41 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, editDocuments.push_back(fb::ebx::read_document(bytes)); std::vector 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); + }; 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 : fb::ebx::CarryImport{}); + // One mod's copy with nothing to leave out stays as that mod wrote it. + if (summary.dropped.empty() && + (edits.size() < 2 || (!summary.instances && !summary.arrayEntries))) continue; rebuilt = fb::ebx::write_document(combined); what = std::to_string(summary.instances) + " instance(s), " + std::to_string(summary.arrayEntries) + " list entries"; + std::map> 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) + if ((taken = held.replacement(guid))) break; + report.notes.push_back(path_utf8(edits[index]->root.filename()) + ": " + entry.asset.name + + ": " + std::to_string(guids.size()) + " item(s) 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})); @@ -611,6 +752,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; diff --git a/Extension/Customization/developer_board.cpp b/Extension/Customization/developer_board.cpp index 0df1cbf1..6e6d6c62 100644 --- a/Extension/Customization/developer_board.cpp +++ b/Extension/Customization/developer_board.cpp @@ -46,9 +46,10 @@ bool publish_materials(std::uintptr_t base, std::uintptr_t item) noexcept { } // 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); + const auto animation = developer_board_detail::animation_for(steam_id); + const bool listed = animation != developer_board_detail::Animation::none; if (!board) { state = {}; return; } - if (!developer && !state.count) return; + if (!listed && !state.count) return; try { auto read = [](std::uintptr_t address, void *out, std::size_t size) { return memory::peek_bytes(address, out, size); }; std::array publisher{}; @@ -64,14 +65,14 @@ 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); + developer_board_detail::animate(state, live, board, generation, animation, GetTickCount64(), write, publish); const auto wanted = static_cast(std::count_if(live.bindings.begin(), live.bindings.begin() + live.count, [](const auto &binding) { return binding.eligible; })); 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 && state.count < 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 && state.count) + report_board(board, steam_id, "animation active on the owned pink board cosmetics", logging::Level::info); } catch (const std::exception &error) { report_board(board, steam_id, error.what(), logging::Level::warning); } catch (...) {} @@ -81,8 +82,8 @@ 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_marks_shown() ? social->local.id : 0; + if (developer_board_detail::animation_for(id) == developer_board_detail::Animation::none && !state.count) 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); }; diff --git a/Extension/Customization/developer_board_material.h b/Extension/Customization/developer_board_material.h index 1b432b9c..bda9d0ae 100644 --- a/Extension/Customization/developer_board_material.h +++ b/Extension/Customization/developer_board_material.h @@ -6,7 +6,10 @@ namespace dingosdk { namespace developer_board_detail { using developer_hoodie_detail::Color; using developer_hoodie_detail::material_hash; -using developer_hoodie_detail::rainbow; +using developer_hoodie_detail::Animation; +using developer_hoodie_detail::animation_for; +using developer_hoodie_detail::item_color; +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}; @@ -58,6 +61,7 @@ struct Binding { struct SavedColor { Binding binding; Color original{}, last{}; + std::uint8_t settling{}; // publishes still owed after going back to `original` }; inline constexpr std::size_t max_bindings = targets.size() * 4; struct Materials { @@ -179,13 +183,13 @@ namespace developer_board_detail { // Never clear a NativeValue: the renderer still copies the key's full payload. template 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) { + Animation animation, 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); + const auto color = item_color(animation, milliseconds); std::array changed_items{}; std::size_t changed{}; for (std::size_t i = 0; i < live.count; ++i) { @@ -194,14 +198,18 @@ void animate(DeveloperBoardState &state, const Materials &live, std::uintptr_t e 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) { + if (animation != Animation::none && 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); + } else if (saved && (binding.color == saved->last || binding.color == saved->original)) { + // Back to the part's own color, which takes a few publishes to show (settle_publishes): + // `wrote` has the part published for each of them. + wrote = binding.color == saved->original || write(binding, saved->original); + const std::uint8_t left = saved->settling ? saved->settling - 1 : settle_publishes; if (!wrote) next.colors[next.count++] = *saved; + else if (left) next.colors[next.count++] = {binding, saved->original, saved->original, left}; } if (wrote && std::find(changed_items.begin(), changed_items.begin() + changed, binding.item) == changed_items.begin() + changed) changed_items[changed++] = binding.item; diff --git a/Extension/Customization/developer_hoodie.cpp b/Extension/Customization/developer_hoodie.cpp index 0cb4dc84..e07f6b78 100644 --- a/Extension/Customization/developer_hoodie.cpp +++ b/Extension/Customization/developer_hoodie.cpp @@ -44,9 +44,10 @@ bool publish_materials(std::uintptr_t base, std::uintptr_t item) noexcept { } // 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); + const auto animation = developer_hoodie_detail::animation_for(steam_id); + const bool listed = animation != developer_hoodie_detail::Animation::none; if (!entity) { state = {}; return; } - if (!developer && !state.count) return; + if (!listed && !state.count) return; try { auto read = [](std::uintptr_t address, void *out, std::size_t size) { return memory::peek_bytes(address, out, size); }; std::array prefix{}; @@ -58,10 +59,11 @@ 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); + developer_hoodie_detail::animate(state, live, entity, generation, animation, 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) + else if (listed && state.count) + report_hoodie(entity, steam_id, "animation active on the owned hoodie material", logging::Level::info); + else if (listed && live.eligible) report_hoodie(entity, steam_id, "waiting for the assigned top slot's color parameters", logging::Level::warning); } catch (const std::exception &error) { report_hoodie(entity, steam_id, error.what(), logging::Level::warning); @@ -77,8 +79,8 @@ 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_marks_shown() ? social->local.id : 0; + if (developer_hoodie_detail::animation_for(id) == developer_hoodie_detail::Animation::none && !state.count) return; const auto local = multiplayer::capture_local(base, client, false); if (local.ready) update_developer_hoodie(base, local.entity, id, local.context, state); else report_hoodie(client, id, local.detail, logging::Level::warning); diff --git a/Extension/Customization/developer_hoodie_material.h b/Extension/Customization/developer_hoodie_material.h index 1c3d2b24..a649cd8b 100644 --- a/Extension/Customization/developer_hoodie_material.h +++ b/Extension/Customization/developer_hoodie_material.h @@ -14,6 +14,8 @@ inline constexpr std::string_view asset = "Own_TopShirt_Gen_HoodieRelaxed_00001" 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. +// 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. The other three do not show. inline constexpr std::array color_keys{ 0x8c46cd354b18a9e2ULL, 0x1567b5742f4ec0b2ULL, 0x1564a942cb33c53bULL, 0xf526305270d94cb3ULL, 0x99879bf202d1960aULL, 0x998450425dbd9044ULL}; @@ -56,6 +58,33 @@ inline Color rainbow(std::uint64_t milliseconds) noexcept { default: return {high, low, falling}; } } +// What a listed player's hoodie and board do: a developer's cycle the rainbow, a content +// creator's go 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. +enum class Animation : std::uint8_t { none, rainbow, red, gold }; +inline Animation animation_for(std::uint64_t steam_id) noexcept { + using multiplayer::IdentityList; + switch (multiplayer::identity_mark(steam_id).value_or(IdentityList::count)) { + case IdentityList::developer: return Animation::rainbow; + case IdentityList::content_creator: return Animation::red; + case IdentityList::homie: return Animation::gold; + default: return Animation::none; + } +} +inline Color item_color(Animation animation, std::uint64_t milliseconds) noexcept { + if (animation == Animation::rainbow) return rainbow(milliseconds); + // 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, and back. + constexpr std::array red{{{.2f, .004f, .01f}, {.8f, .03f, .03f}, {.8f, .05f, .13f}}}; + constexpr std::array gold{{{.34f, .12f, .006f}, {.8f, .5f, .06f}, {.8f, .64f, .11f}}}; + const auto &stops = animation == Animation::red ? red : gold; + // 0 to 2 and back, every three seconds. + const auto along = 1.f - std::cos(static_cast(milliseconds % 3000) * (6.2831853f / 3000.f)); + const auto &from = stops[along < 1.f ? 0 : 1], &to = 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}; +} // All discovery is bounded and follows the actor's own component/controller/material // backlinks. Never edit the shared AppearanceShaderExpressionPreset or recipe. template Materials materials(Read &read, std::uintptr_t base, std::uintptr_t entity) { @@ -142,19 +171,27 @@ struct DeveloperHoodieState { std::uint64_t generation{}; std::array colors{}; std::size_t count{}; + std::uint8_t settling{}; // publishes still owed after the animation went off (settle_publishes) }; namespace developer_hoodie_detail { +// The renderer shows a color one publish late. While the animation runs the next tick's +// publish brings it; once it is off, the item's own colors need publishing again after the +// write that brought them back, or the item keeps the last animated color on screen (seen +// in game, 2026-10-04). The saved colors are kept for these extra publishes. +inline constexpr std::uint8_t settle_publishes = 3; // Writes are only to freshly rediscovered bindings. Restoration additionally // requires the current color to still be ours; a new native preset takes precedence. template 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) { + Animation animation, 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); + const bool enabled = animation != Animation::none && live.eligible; + const auto color = item_color(animation, milliseconds); + const std::uint8_t settling = enabled ? 0 : state.settling ? state.settling - 1 : settle_publishes; DeveloperHoodieState next; - if (enabled) { + next.settling = settling; + if (enabled || settling) { next.entity = entity; next.generation = generation; next.component = live.component; next.appearance = live.appearance; next.item = live.item; } @@ -170,10 +207,13 @@ void animate(DeveloperHoodieState &state, const Materials &live, std::uintptr_t 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; + } else if (saved && (binding.color == saved->last || binding.color == saved->original)) { + if (binding.color != saved->original) write(binding, saved->original); + changed = true; + if (settling) next.colors[next.count++] = {binding, saved->original, saved->original}; } } + if (!next.count) next = {}; state = next; if (changed) publish(live.item); } diff --git a/Extension/Multiplayer/Hud/custom_nametags.h b/Extension/Multiplayer/Hud/custom_nametags.h index 16bc9b8e..cfc5920b 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.h +++ b/Extension/Multiplayer/Hud/custom_nametags.h @@ -19,10 +19,15 @@ struct NametagPlayer { }; // 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_admin = 0xffc674ffU, // pink nametag_host = 0xffffc85eU, // blue nametag_friend = 0xff8ae07bU; // green -inline constexpr std::uint32_t nametag_developer_start = 0xffff206eU; // #6E20FF, IM_COL32 layout +// Developers, content creators and homies shimmer from these to their role colour. +inline constexpr std::uint32_t nametag_developer_start = 0xffff206eU, // #6E20FF, IM_COL32 layout + nametag_creator_start = 0xff2e10c8U, // #C8102E + nametag_homie_start = 0xff008ae0U; // #E08A00 // 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. diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index d4c2a038..5fa607f8 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -237,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; for (const auto *r : {&a.skater, &a.board}) { if (r->scalars.size() > max_cosmetic_scalars || r->items.empty() || r->items.size() > max_cosmetic_slots) @@ -445,6 +445,7 @@ std::vector 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.unmarked ? 1 : 0, 1); } else if (p.kind == PacketKind::audio) { w.integer(p.audio.size(), 2); AudioState previous; @@ -674,7 +675,9 @@ std::optional decode(std::span bytes) noexcept { p.appearance.card.background = static_cast(r.integer(4)); p.appearance.card.emblem = static_cast(r.integer(4)); p.appearance.card.title = static_cast(r.integer(4)); - if (!p.map || !valid_appearance(p.appearance)) + const auto flags = r.integer(1); + p.appearance.unmarked = flags == 1; + if (flags > 1 || !p.map || !valid_appearance(p.appearance)) return {}; } else if (p.kind == PacketKind::audio) { const auto count = r.integer(2); diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 441cce08..a2fdb89f 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -25,7 +25,7 @@ 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 = 39; +constexpr std::uint16_t protocol_version = 40; 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; diff --git a/Extension/Multiplayer/Remote/cosmetics.h b/Extension/Multiplayer/Remote/cosmetics.h index 3d5fff17..d49c3e44 100644 --- a/Extension/Multiplayer/Remote/cosmetics.h +++ b/Extension/Multiplayer/Remote/cosmetics.h @@ -31,6 +31,9 @@ struct PlayerCard { struct Appearance { CosmeticRecipe skater, board; PlayerCard card; + // The player has turned off the marks the ReSkate backend gives them (developer_identity.h): + // nobody shows their tag or animates their hoodie and board. + bool unmarked{}; bool operator==(const Appearance &) const = default; }; // Stand-ins for a peer's cosmetic that this PC does not have (a player's diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 71e5b68d..0ba82454 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -257,6 +257,7 @@ void publish_party(Session &s) { s.custom_nametags = profile_runtime::local_preference("CustomNametags").value_or(true); s.chat_visible = profile_runtime::local_preference("ChatVisible").value_or(true); s.chat_filter = profile_runtime::local_preference("ChatFilter").value_or(true); + show_own_marks(profile_runtime::local_preference("IdentityMarks").value_or(true)); apply_nametags(s); } // The party's limit: parties players form hold up to eight (the game's Party panel rows), @@ -425,7 +426,9 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui } }; // 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) ? p.member.id : std::uint64_t{}; }; + const auto developer_id = [&](const Peer &p) { + return steam_vouched(s, p) && shows_marks(p) ? p.member.id : std::uint64_t{}; + }; 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)) diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index ff18d237..bf0c1f3b 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -324,6 +324,15 @@ std::string edit_nametag_style(Session &s, std::string_view argument) { 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 and items. +// Their next appearance packet tells everyone (session_send.cpp). +std::string edit_marks(Session &, std::string_view argument) { + const auto shown = parse_switch(argument, own_marks_shown()); + if (!shown) return "Use on, off, or toggle for your tag and items."; + show_own_marks(*shown); + profile_runtime::set_local_preference("IdentityMarks", *shown); + return *shown ? "Your tag and items show." : "Your tag and items are hidden, for you and everyone you skate with."; +} // Hidden chat still receives lines, so showing it again brings back the conversation. std::string edit_chat_visible(Session &s, std::string_view argument) { const auto visible = parse_switch(argument, s.chat_visible); @@ -438,10 +447,12 @@ std::string send_admin(Session &s, std::string text) { } } // namespace bool queue_command(std::string_view action, std::string_view argument, std::string_view password) { - if (launcher::offline_mode()) return false; + // A player's own marks are theirs offline as well, where their hoodie and board still animate. + if (launcher::offline_mode() && action != "marks") 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 != "nametags" && action != "nametag-style" && action != "chat-visible" && action != "chat-filter" && + action != "marks" && 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" && @@ -465,7 +476,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() && action != "marks") + 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; @@ -620,6 +632,7 @@ std::string command(std::string_view action, std::string_view argument, std::str {"world-layer-sync", edit_world_layer_sync}, {"distances", edit_distances}, {"nametags", edit_nametags}, {"nametag-style", edit_nametag_style}, + {"marks", edit_marks}, {"chat-visible", edit_chat_visible}, {"chat-filter", edit_chat_filter}}; for (const auto &[name, edit] : settings) if (action == name) { @@ -705,6 +718,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) { diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 4b8d0acd..db57058c 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -310,6 +310,12 @@ inline bool dedicated_host(const Session &s) { return s.mode == Mode::join && ga inline bool steam_vouched(const Session &s, const Peer &peer) { return s.mode != Mode::join || peer.member.id == s.host_id || peer.direct_ready; } +// Whether a player shows the marks the backend gives them: not until their appearance has +// arrived, and not when it says they turned them off (Appearance::unmarked). +inline bool shows_marks(const Peer &peer) { + const auto &look = peer.appearance.value(); + return look && !look->unmarked; +} Peer *find_peer(Session &s, std::uint64_t id); unsigned player_count(const Session &s); void reset_peer(Session &s, std::size_t slot); @@ -327,7 +333,9 @@ 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 +// The colour and badge of one of the backend's categories. +std::pair mark_role(IdentityList list); +// A player's role colour and badge ("Dev", "Creator", "Homie", "Admin", "Host", "Friend" or none), shown in chat // and on their nametag. `local`: the local player. std::pair player_role(Session &s, std::uint64_t id, bool local); // Sends one line from this player; returns why not when it cannot. diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index 379363eb..a9d69f3b 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -265,6 +265,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."); @@ -274,6 +285,10 @@ 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."); diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index 9249f441..c37e961d 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -595,7 +595,10 @@ 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 choice to go without their marks travels with their outfit, so a change + // is sent like one and reaches players who join later. + if (appearance) appearance->unmarked = !own_marks_shown(); if (appearance && (!s.sent_appearance || *appearance != *s.sent_appearance)) { auto p = packet(s, PacketKind::cosmetics, now); p.appearance = *appearance; diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 8ac07ad1..11e5538e 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -172,6 +172,9 @@ void publish(Session &s, const NativeFrame *local) { static_cast(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)) view.identity_tag = mark_role(*mark).second; + view.identity_marks = own_marks_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; @@ -501,6 +504,13 @@ void publish_chat(Session &s) { std::lock_guard lock(s.mutex); s.chat_view = std::move(view); } +std::pair mark_role(IdentityList list) { + switch (list) { + case IdentityList::developer: return {nametag_developer, "Dev"}; + case IdentityList::content_creator: return {nametag_creator, "Creator"}; + default: return {nametag_homie, "Homie"}; + } +} // The colour and tag a player gets, in chat and on their nametag. std::pair player_role(Session &s, std::uint64_t sender, bool local) { if (!sender) return {}; @@ -508,7 +518,10 @@ std::pair player_role(Session &s, std::uint64_t send if (dedicated && sender == s.host_id) return {nametag_admin, {}}; // the server itself const auto *peer = local ? nullptr : find_peer(s, sender); const bool vouched = local || (peer && steam_vouched(s, *peer)); - if (vouched && reskate_developer(sender)) return {nametag_developer, "Dev"}; + // Who the backend says a player is comes before what they are in this lobby, unless they + // have turned their marks off (the Special page), which their appearance tells everyone. + if (vouched && (local ? own_marks_shown() : shows_marks(*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 && vouched) { diff --git a/Extension/Multiplayer/Test/developer_identity_tests.cpp b/Extension/Multiplayer/Test/developer_identity_tests.cpp new file mode 100644 index 00000000..c6df8cdf --- /dev/null +++ b/Extension/Multiplayer/Test/developer_identity_tests.cpp @@ -0,0 +1,219 @@ +#include "Extension/Customization/developer_board_material.h" +#include "Extension/Customization/developer_hoodie_material.h" +#include "Extension/Multiplayer/developer_identity.h" +#include +#include +#include +#include +#include +#include + +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(), + "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[3] == 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 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", "Just a moment...", "[]", "{}", 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"); + check(own_marks_shown(), "A player's marks start hidden"); + show_own_marks(false); + check(!own_marks_shown() && identity_mark(dev) == L::developer, "Hiding their own marks did not take, or changed the lists"); + show_own_marks(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: a developer's rainbow, a content creator's red, a homie's gold. +void items() { + using namespace dingosdk::developer_hoodie_detail; + publish_identity_lists({{{dev}, {dev, homie, creator}, {dev, creator}}}); + check(animation_for(dev) == Animation::rainbow && animation_for(creator) == Animation::red && + animation_for(homie) == Animation::gold, + "A list did not give its animation, a developer's first and a content creator's before a homie's"); + check(animation_for(stranger) == Animation::none && animation_for(0) == Animation::none, "An unlisted player's items animate"); + check(item_color(Animation::rainbow, 0) == rainbow(0) && item_color(Animation::rainbow, 3000) == rainbow(3000), + "The developer's rainbow changed"); + // Each goes from a deep shade to a bright one, on into the colour next to it, and back + // every three seconds. + for (const auto animation : {Animation::red, Animation::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 == 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"); + } + } +} + +// Turning the animation off (the Special page, or a list that no longer has the player) puts an +// item's own colours back, and publishes them the extra times the renderer needs to show them. +void settling() { + using namespace dingosdk::developer_hoodie_detail; + const Color black{.02f, .02f, .02f}, navy{.02f, .02f, .2f}, teal{.02f, .3f, .3f}; + // What the game holds for each colour region; the materials are read afresh each tick, as in game. + std::array held{black, navy}; + dingosdk::DeveloperHoodieState state; + Materials live; + live.component = 1, live.appearance = 2, live.item = 3, live.eligible = true, live.count = 2; + live.bindings[0] = {10, 100, 7, color_keys[0], black}; + live.bindings[1] = {10, 101, 7, color_keys[3], navy}; + int publishes{}; + auto write = [&](const Binding &binding, const Color &color) { + held[binding.node - 100] = color; + return true; + }; + auto publish = [&](std::uintptr_t) { ++publishes; }; + const auto tick = [&](Animation animation, 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, animation, at, write, publish); + }; + tick(Animation::red, 0); + tick(Animation::red, 700); + check(state.count == 2 && held[0] == item_color(Animation::red, 700) && held[1] == held[0] && publishes == 2, + "The animation did not colour the hoodie"); + publishes = 0; + tick(Animation::none, 800); + check(held[0] == black && held[1] == navy && publishes == 1, "Turning it off did not bring the hoodie's own colours back"); + for (std::uint64_t at = 900; at < 910; ++at) tick(Animation::none, at); + check(publishes == 1 + settle_publishes && !state.count && held[0] == black && held[1] == navy, + "The own colours were not published the extra times, or the hoodie was not left alone after"); + // A colour the game itself sets meanwhile is the game's to keep. + tick(Animation::gold, 1000); + held[0] = teal; + tick(Animation::none, 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(Animation::none, at); + tick(Animation::red, 2000); + tick(Animation::none, 2100); + check(held[0] == teal && held[1] == navy, "A second round lost the hoodie's own colours"); + + // The board does the same, part by part. + namespace board = dingosdk::developer_board_detail; + std::array painted{black, navy}; + dingosdk::DeveloperBoardState deck; + board::Materials parts; + parts.component = 1, parts.appearance = 2, parts.count = 2; + parts.bindings[0] = {30, 10, 100, 7, board::parameter_key(board::base_color, 1), black, true}; + parts.bindings[1] = {31, 11, 101, 7, board::parameter_key(board::base_color, 1), navy, true}; + int shown{}; + auto paint = [&](const board::Binding &binding, const Color &color) { + painted[binding.node - 100] = color; + return true; + }; + auto show = [&](std::uintptr_t) { ++shown; }; + const auto roll = [&](Animation animation, std::uint64_t at) { + for (std::size_t i = 0; i < painted.size(); ++i) parts.bindings[i].color = painted[i]; + board::animate(deck, parts, 5, 6, animation, at, paint, show); + }; + roll(Animation::gold, 0); + roll(Animation::gold, 700); + check(deck.count == 2 && painted[0] == item_color(Animation::gold, 700) && painted[1] == painted[0], "The animation did not colour the board"); + shown = 0; + for (std::uint64_t at = 800; at < 810; ++at) roll(Animation::none, at); + check(painted[0] == black && painted[1] == navy && shown == 2 * (1 + settle_publishes) && !deck.count, + "Turning it off did not bring the board's own colours back and publish each part the extra times"); +} + +// --live : 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"; +} diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 77d05126..42953737 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -39,6 +39,9 @@ void update_developer_hoodie(std::uintptr_t, std::uintptr_t, std::uint64_t, std: void update_developer_board(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperBoardState &) noexcept {} } namespace dingosdk::multiplayer { +// The backend's lists are not read here: a check lists the players it means. +std::set> simulated_identities; +bool identity_listed(std::uint64_t id, IdentityList list) noexcept { return simulated_identities.contains({id, list}); } bool local_allows_player_collision(std::uintptr_t, std::uintptr_t) noexcept { return false; } void update_remote_collision(std::uintptr_t, std::uintptr_t, const Pose &, bool, std::uint64_t) noexcept {} void expire_remote_collision(std::uintptr_t, std::uint64_t) noexcept {} @@ -644,6 +647,94 @@ void party_checks() { check(!host.local_party && host.parties.parties().empty(), "A guest who left the lobby stayed in the host's party"); std::cout << "Lobby parties: nobody by default, invites, two parties, party chat, leaving and departures passed.\n"; } +// A player's badge and colour, in chat and on their nametag: who the backend lists them as +// comes before what they are in the lobby, and a developer before the other lists. +void role_checks() { + Simulation sim; + for (unsigned i = 0; i < 4; ++i) sim.add(); + sim.run(60); sim.fresh(4); + auto &host = *sim.nodes[0], &first = *sim.nodes[1], &second = *sim.nodes[2], &third = *sim.nodes[3]; + const auto id = [](const Session &s) { return s.transport.status().local_id; }; + using Role = std::pair; + using L = IdentityList; + check(player_role(first, id(host), false) == Role{nametag_host, "Host"} && + player_role(host, id(host), true) == Role{nametag_host, "Host"} && + player_role(host, id(first), false) == Role{nametag_white, {}}, + "Players the backend does not list did not get their lobby roles"); + + simulated_identities = {{id(host), L::homie}, {id(first), L::content_creator}, + {id(second), L::developer}, {id(second), L::content_creator}, {id(second), L::homie}, + {id(third), L::content_creator}, {id(third), L::homie}}; + check(player_role(first, id(host), false) == Role{nametag_homie, "Homie"} && + player_role(host, id(host), true) == Role{nametag_homie, "Homie"}, + "A homie who hosts is not shown as a homie"); + check(player_role(host, id(first), false) == Role{nametag_creator, "Creator"} && + player_role(first, id(first), true) == Role{nametag_creator, "Creator"}, + "A content creator is not shown as one"); + check(player_role(host, id(second), false) == Role{nametag_developer, "Dev"}, + "A developer on every list is not shown as a developer"); + check(player_role(host, id(third), false) == Role{nametag_creator, "Creator"}, + "A content creator who is also a homie is not shown as a creator"); + // A player who has turned their marks off (the Special page) is whatever they are in the lobby, + // to everyone: their appearance carries the choice through the host. + auto plain = packet(host, PacketKind::cosmetics, sim.now); + plain.appearance = {{skater_recipe_key, 2, {}, {{1, "Outfit0", {}}}}, {board_recipe_key, 1, {}, {{2, "Deck", {}}}}}; + plain.appearance.unmarked = true; + host.cosmetic_packet = encode_wire(plain); + broadcast(host, plain, true, false, sim.now); + sim.run(20); + check(player_role(first, id(host), false) == Role{nametag_host, "Host"} && + player_role(second, id(host), false) == Role{nametag_host, "Host"}, + "A homie who turned their marks off still shows as a homie"); + check(player_role(host, id(first), false) == Role{nametag_creator, "Creator"}, "One player's choice hid another's marks"); + show_own_marks(false); + check(player_role(first, id(first), true) == Role{nametag_white, {}} && + player_role(host, id(first), false) == Role{nametag_creator, "Creator"}, + "A player's own choice did not hide their marks from themselves, or hid them from others before they were told"); + show_own_marks(true); + // A chat line carries its sender's role. + check(send_chat(first, "new video is up").empty(), "A guest's chat was refused"); + sim.run(20); + const auto said = std::find_if(host.chat.begin(), host.chat.end(), [](const auto &line) { return line.text == "new video is up"; }); + check(said != host.chat.end() && said->color == nametag_creator && said->tag == "Creator", + "A content creator's chat line does not carry their role"); + simulated_identities.clear(); + std::cout << "Roles: lobby roles, homie, content creator, developer first and chat lines passed.\n"; +} +// The backend's bans hold in every session, and reach one that is running: a banned guest is +// out and cannot come back, the others stay, and nobody stays with a banned host. +void global_ban_checks() { + Simulation sim; + for (unsigned i = 0; i < 3; ++i) sim.add(); + sim.run(60); sim.fresh(3); + auto &host = *sim.nodes[0], &first = *sim.nodes[1], &second = *sim.nodes[2]; + const auto id = [](const Session &s) { return s.transport.status().local_id; }; + const auto first_id = id(first), host_id = id(host); + + simulated_identities = {{first_id, IdentityList::banned}}; + sim.run(40); + check(first.mode == Mode::off && first.status == banned_notice, "A banned guest stayed in the session"); + check(!find_peer(host, first_id) && !find_peer(second, first_id), "The session kept a banned guest"); + check(second.mode == Mode::join && find_peer(host, id(second)), "Banning one guest took another out"); + // A banned player whose game does not stop itself is turned away by the host all the same. + first.mode = Mode::join; + first.host_id = host_id; + first.transport.join(host_id); + host.transport.poll(); + networking(host, sim.local, sim.now); + const auto &links = host.transport.status().peers; + check(std::none_of(links.begin(), links.end(), [&](const auto &link) { return link.id == first_id; }) && + !find_peer(host, first_id), + "The host let a banned player back in"); + stop(first, "Left"); + + simulated_identities = {{host_id, IdentityList::banned}}; + sim.run(40); + check(host.mode == Mode::off && host.status == banned_notice, "A banned host kept hosting"); + check(second.mode == Mode::off, "A guest stayed with a banned host"); + simulated_identities.clear(); + std::cout << "Global bans: a banned guest, a guest who comes back, the other guests and a banned host passed.\n"; +} // A host that is kept from reading for a few seconds (a server whose console held it up) then // reads everything its guests sent meanwhile in one go. That is not a flood: nobody is dropped // for it, while a real flood still is. @@ -1988,6 +2079,8 @@ int main(int argc, char **argv) { dingosdk::multiplayer::mesh_checks(); dingosdk::multiplayer::throwdown_routing_checks(); dingosdk::multiplayer::party_checks(); + dingosdk::multiplayer::role_checks(); + dingosdk::multiplayer::global_ban_checks(); dingosdk::multiplayer::physics_extras_checks(); dingosdk::multiplayer::stall_checks(); dingosdk::multiplayer::scoring_checks(); diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index 6921a412..32bffce6 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -331,8 +331,14 @@ void cosmetics_codec() { {board_recipe_key, 1, {0x3f800000}, {{13, "Own_Deck", {7}}}}}; const auto bytes = encode(p); const auto decoded = decode(bytes); - check(decoded && decoded->appearance == p.appearance, + check(decoded && decoded->appearance == p.appearance && !decoded->appearance.unmarked, "Cosmetic fields or opaque parameter bits were lost"); + // A player's choice to go without their backend marks travels with their outfit. + auto unmarked = p; + unmarked.appearance.unmarked = true; + const auto told = decode(encode(unmarked)); + check(told && told->appearance.unmarked && told->appearance == unmarked.appearance && !(told->appearance == p.appearance), + "A player's choice to hide their marks was lost"); for (std::size_t n = 0; n < bytes.size(); ++n) check(!decode(std::span(bytes).first(n)), "Truncated cosmetics accepted"); auto corrupt = bytes; diff --git a/Extension/Multiplayer/developer_identity.cpp b/Extension/Multiplayer/developer_identity.cpp new file mode 100644 index 00000000..83a7bca1 --- /dev/null +++ b/Extension/Multiplayer/developer_identity.cpp @@ -0,0 +1,62 @@ +#include "developer_identity.h" +#include "Engine/Core/Json/json.h" +#include +#include +#include +#include +#include + +// The lists themselves, shared by the game and the dedicated server. Each reads +// them from the backend its own way: developer_identity_fetch.cpp, Server/global_bans.cpp. +namespace dingosdk::multiplayer { +namespace { +// The API's name for each category, in IdentityList order; the ban list follows them. +constexpr std::array categories{"dev", "homie", "content_creator"}; +constexpr auto banned = static_cast(IdentityList::banned); + +std::atomic> current; + +// A player's SteamID64 is 76561197960265728 plus a 32-bit account number, and +// nobody has account 0. The API sends them as strings. +std::uint64_t steam_id(const Json &entry) { + constexpr std::uint64_t base = 76561197960265728ULL, last = base + 0xffffffffULL; + std::uint64_t id{}; + if (entry.is_string()) { + const auto &text = entry.string(); + const auto parsed = std::from_chars(text.data(), text.data() + text.size(), id); + if (parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size() && id > base && id <= last) return id; + } + throw std::runtime_error("an entry is not a player's SteamID64"); +} +} // namespace + +IdentityLists parse_identity_lists(std::string_view json) { + const auto answer = Json::parse(json); + if (!answer.is_object() || !answer.contains("categories") || !answer.at("categories").is_object()) + throw std::runtime_error("the answer has no categories"); + const auto &listed = answer.at("categories"); + IdentityLists lists; + const auto read = [](const Json &entries, std::vector &ids) { + if (!entries.is_array()) throw std::runtime_error("a list of players is not a list"); + for (const auto &entry : entries) ids.push_back(steam_id(entry)); + std::sort(ids.begin(), ids.end()); + ids.erase(std::unique(ids.begin(), ids.end()), ids.end()); + }; + for (std::size_t index = 0; index < categories.size(); ++index) + if (listed.contains(categories[index])) read(listed.at(categories[index]), lists[index]); + if (answer.contains("banned")) read(answer.at("banned"), lists[banned]); + return lists; +} +bool publish_identity_lists(IdentityLists lists) { + const auto previous = current.load(); + if (previous && *previous == lists) return false; + current.store(std::make_shared(std::move(lists))); + return true; +} +bool identity_listed(std::uint64_t id, IdentityList list) noexcept { + const auto lists = current.load(); + if (!id || !lists || list >= IdentityList::count) return false; + const auto &ids = (*lists)[static_cast(list)]; + return std::binary_search(ids.begin(), ids.end(), id); +} +} // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/developer_identity.h b/Extension/Multiplayer/developer_identity.h index 9e2ca9db..183f3583 100644 --- a/Extension/Multiplayer/developer_identity.h +++ b/Extension/Multiplayer/developer_identity.h @@ -1,16 +1,55 @@ #pragma once #include +#include +#include #include +#include +#include +#include namespace dingosdk::multiplayer { -// Shared by developer tags and cosmetics. Steam authenticates these identities. -inline constexpr std::array reskate_developers{ - 76561198084159190ULL, // zee_x64 - 76561198255588397ULL, // reglitched -}; -inline bool reskate_developer(std::uint64_t id) noexcept { - for (const auto developer : reskate_developers) - if (developer == id) return true; - return false; +// Who the ReSkate backend lists in each of its Steam ID categories, and who it +// has banned. The lists are kept in its admin panel and read from +// api.reskate.dev, so they change without a release. Steam authenticates these +// identities. Each category has its tag in chat and on nametags (player_role) +// and its animation on the marked hoodie and board (developer_hoodie_material.h). +enum class IdentityList : std::uint8_t { developer, homie, content_creator, banned, count }; +using IdentityLists = std::array, static_cast(IdentityList::count)>; +inline constexpr std::string_view identity_lists_url = "https://api.reskate.dev/api/v1/steam-ids"; + +// The game's way of reading them (the dedicated server's is Server/global_bans.h). Called +// every client tick: reads the lists in the background, at startup and every ten +// minutes after (a minute after a failure). Offline mode reads them too, for the +// player's own hoodie and board. +void refresh_identity_lists() noexcept; +// Any thread. Nobody is listed until the first answer has arrived. +bool identity_listed(std::uint64_t id, IdentityList list) noexcept; +// Shared by the developer tag and the rainbow hoodie and board. +inline bool reskate_developer(std::uint64_t id) noexcept { return identity_listed(id, IdentityList::developer); } +// The category a player's tag and items come from: a developer's before a content creator's +// before a homie's. Most players are in none. +inline std::optional identity_mark(std::uint64_t id) noexcept { + for (const auto list : {IdentityList::developer, IdentityList::content_creator, IdentityList::homie}) + if (identity_listed(id, list)) return list; + return std::nullopt; } +// The local player's own choice to go without their marks (the menu's Special page, which +// only a listed player gets). Their appearance tells everyone else (Appearance::unmarked). +inline std::atomic own_marks{true}; +inline bool own_marks_shown() noexcept { return own_marks.load(std::memory_order_relaxed); } +inline void show_own_marks(bool shown) noexcept { own_marks.store(shown, std::memory_order_relaxed); } +// A global ban is enforced by whoever a player would play with: a host and a dedicated server +// turn them away, and a guest leaves a lobby they host, which their own game does not start +// either (session_receive.cpp, Server/server_host.cpp). A server can opt out ("global_bans"), +// so a banned player's own game still lets them try to join. Nobody is banned while the lists +// cannot be read. +inline bool reskate_banned(std::uint64_t id) noexcept { return identity_listed(id, IdentityList::banned); } +inline constexpr std::string_view banned_notice = "You are banned from ReSkate multiplayer."; + +// The API's answer, {"categories":{"dev":["7656119..."],"homie":[],...},"banned":[]}, +// as sorted lists. A category this build does not know is ignored and a list the +// answer leaves out is empty; an entry that is not a player's SteamID64 throws. +IdentityLists parse_identity_lists(std::string_view json); +// Puts sorted lists in use. False when they are the ones already in use. +bool publish_identity_lists(IdentityLists lists); } // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/developer_identity_fetch.cpp b/Extension/Multiplayer/developer_identity_fetch.cpp new file mode 100644 index 00000000..09765644 --- /dev/null +++ b/Extension/Multiplayer/developer_identity_fetch.cpp @@ -0,0 +1,59 @@ +#include "developer_identity.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Vfs/https_download.h" +#include +#include +#include +#include + +// The game's way of reading the lists: in the background, driven by the client tick. +namespace dingosdk::multiplayer { +namespace { +constexpr std::uint64_t max_bytes = 256 * 1024, refresh_ms = 10 * 60 * 1000, retry_ms = 60 * 1000; +std::atomic fetching{}; +std::atomic next_fetch{}; // GetTickCount64 time + +void fetch() noexcept { + // The log says when the lists arrive, change or stop arriving, not every refresh. + static bool failing{}; + bool ok{}; + try { + https::Download download; + const auto answer = https::get_text(std::wstring(identity_lists_url.begin(), identity_lists_url.end()), max_bytes, 10, + L"ReSkate-Identity/1", &download); + if (answer) { + auto lists = parse_identity_lists(*answer); + const auto developers = lists[0].size(), homies = lists[1].size(), creators = lists[2].size(), + bans = lists[static_cast(IdentityList::banned)].size(); + if (publish_identity_lists(std::move(lists)) || failing) + logging::log(logging::Level::info, logging::Channel::runtime, + "Identity lists: {} developer(s), {} homie(s), {} content creator(s), {} banned.", developers, + homies, creators, bans); + ok = true; + } else if (!failing) { + logging::log(logging::Level::info, logging::Channel::runtime, + "Identity lists unavailable (HTTP {}, error {}); trying again every minute.", download.http_status, + download.error); + } + } catch (const std::exception &e) { + if (!failing) + logging::log(logging::Level::warning, logging::Channel::runtime, + "Identity lists are invalid ({}); trying again every minute.", e.what()); + } catch (...) {} + failing = !ok; + // A failure keeps the lists already in use. + next_fetch = GetTickCount64() + (ok ? refresh_ms : retry_ms); + fetching = false; +} +} // namespace + +void refresh_identity_lists() noexcept { + if (GetTickCount64() < next_fetch || fetching.exchange(true)) return; + try { + std::thread(fetch).detach(); + } catch (...) { + next_fetch = GetTickCount64() + retry_ms; + fetching = false; + } +} +} // namespace dingosdk::multiplayer diff --git a/Extension/UI/Overlay/multiplayer_menu.cpp b/Extension/UI/Overlay/multiplayer_menu.cpp index a150880f..06f3b5e4 100644 --- a/Extension/UI/Overlay/multiplayer_menu.cpp +++ b/Extension/UI/Overlay/multiplayer_menu.cpp @@ -243,8 +243,9 @@ void multiplayer_display_settings(SkateMenu &menu, const Model &model) { } bool custom = mp.custom_nametags; if (toggle_row(menu, "ReSkate nametags", - "Names with distance: purple for ReSkate developers, gold for server admins, blue for the host, " - "green for your Steam friends; far and off-screen players as dots. Off: the game's own nametags and arrows.", + "Names with distance: purple for ReSkate developers, red for content creators, gold for homies, " + "pink for server admins, blue for the host, green for your Steam friends; far and off-screen players as dots. " + "Off: the game's own nametags and arrows.", custom, mp.nametags, "OFF")) { std::array unused{}; send_private(menu, "nametag-style", custom ? "reskate" : "game", unused, false); @@ -266,6 +267,23 @@ void multiplayer_display_settings(SkateMenu &menu, const Model &model) { void multiplayer_network_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks) { debug_page(menu, model.multiplayer, callbacks); } +// SPECIAL: the page only a player on one of the backend's lists gets. Their tag, and the hoodie +// and board that come with it, on or off for everyone at once. +void special_page(SkateMenu &menu, const Model &model, const CallbacksV3 &) { + const auto &mp = model.multiplayer; + ImGui::BeginChild("special", ImVec2(0, page_body_height(menu))); + begin_card(menu, "special-marks", "YOUR TAG", mp.identity_tag.c_str()); + bool shown = mp.identity_marks; + if (toggle_row(menu, "Show my tag and items", + "Your tag in chat and on your nametag, and the animated hoodie and board that come with it. " + "Off: you look like any other player, to yourself and to everyone you skate with.", + shown)) { + std::array unused{}; + send_private(menu, "marks", shown ? "on" : "off", unused, false); + } + end_card(); + ImGui::EndChild(); +} void multiplayer_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks) { const auto &mp = model.multiplayer; const bool has_session = mp.active || mp.lobby_joining; diff --git a/Extension/UI/Overlay/nametag_gradient.h b/Extension/UI/Overlay/nametag_gradient.h index d8578e0c..dc6f6caa 100644 --- a/Extension/UI/Overlay/nametag_gradient.h +++ b/Extension/UI/Overlay/nametag_gradient.h @@ -2,26 +2,37 @@ #include "Extension/Multiplayer/Hud/custom_nametags.h" #include #include +#include #include #include namespace dingosdk::overlay::detail { inline constexpr double nametag_gradient_period = 4.0; -inline bool developer_nametag_colour(ImU32 colour) noexcept { - return (colour & ~IM_COL32_A_MASK) == (multiplayer::nametag_developer & ~IM_COL32_A_MASK); +// The roles whose name and badge shimmer, each between `from` and its role colour. +struct NametagGradient { ImU32 from, to; }; +inline constexpr std::array nametag_gradients{ + NametagGradient{multiplayer::nametag_developer_start, multiplayer::nametag_developer}, + NametagGradient{multiplayer::nametag_creator_start, multiplayer::nametag_creator}, + NametagGradient{multiplayer::nametag_homie_start, multiplayer::nametag_homie}, +}; +inline const NametagGradient* nametag_gradient(ImU32 colour) noexcept { + for (const auto& gradient : nametag_gradients) + if ((colour & ~IM_COL32_A_MASK) == (gradient.to & ~IM_COL32_A_MASK)) return &gradient; + return nullptr; } inline ImU32 animated_nametag_colour(ImU32 colour, double seconds, float across = 0.0f) noexcept { - if (!developer_nametag_colour(colour)) return colour; + const auto* gradient = nametag_gradient(colour); + if (!gradient) return colour; // Render-time phase: cached chat lines animate too, without changing their role colour. const double phase = std::clamp(across, 0.0f, 1.0f) * 0.5 - std::fmod(seconds, nametag_gradient_period) / nametag_gradient_period; const double blend = (1.0 - std::cos(2.0 * std::numbers::pi * phase)) * 0.5; ImU32 result = colour & IM_COL32_A_MASK; for (const int shift : {IM_COL32_R_SHIFT, IM_COL32_G_SHIFT, IM_COL32_B_SHIFT}) { - const double from = (multiplayer::nametag_developer_start >> shift) & 0xff; - const double to = (multiplayer::nametag_developer >> shift) & 0xff; + const double from = (gradient->from >> shift) & 0xff; + const double to = (gradient->to >> shift) & 0xff; result |= static_cast(std::lround(from + (to - from) * blend)) << shift; } return result; @@ -29,7 +40,7 @@ inline ImU32 animated_nametag_colour(ImU32 colour, double seconds, float across inline void shade_nametag_gradient(ImDrawList* draw, int first_vertex, float left, float width, ImU32 role_colour, double seconds) noexcept { - if (!developer_nametag_colour(role_colour) || width <= 0.0f) return; + if (!nametag_gradient(role_colour) || width <= 0.0f) return; for (int i = first_vertex; i < draw->VtxBuffer.Size; ++i) { auto& vertex = draw->VtxBuffer[i]; const auto colour = animated_nametag_colour(role_colour, seconds, (vertex.pos.x - left) / width); diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index e5547d81..3c3b5b8a 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -305,7 +305,7 @@ struct Nametag { std::array position{}; // above the skater's head, world space std::string name; std::uint32_t color{0xffffffffU}; // R, G, B, A bytes (IM_COL32) - std::string tag; // role badge before the name ("Dev", "Admin", "Host", "Friend") + std::string tag; // role badge before the name ("Dev", "Creator", "Homie", "Admin", "Host", "Friend") float distance{}; // metres from the local skater bool talking{}; }; diff --git a/Extension/UI/Overlay/role_badge.h b/Extension/UI/Overlay/role_badge.h index 743772c8..f9e89857 100644 --- a/Extension/UI/Overlay/role_badge.h +++ b/Extension/UI/Overlay/role_badge.h @@ -4,8 +4,8 @@ #include #include -// The role badge ("Dev", "Admin", "Host", "Friend") before a player's name, in chat and on -// nametags: a pill in the role's colour (animated for developers) with dark text. +// The role badge ("Dev", "Creator", "Homie", "Admin", "Host", "Friend") before a player's name, in chat +// and on nametags: a pill in the role's colour (animated for the roles in nametag_gradient.h) with dark text. namespace dingosdk::overlay::detail { inline float role_badge_width(ImFont *font, float size, const std::string &tag) { return tag.empty() ? 0.0f : font->CalcTextSizeA(size * 0.78f, FLT_MAX, 0.0f, tag.c_str()).x + size * 0.7f; diff --git a/Extension/UI/Overlay/skate_menu.cpp b/Extension/UI/Overlay/skate_menu.cpp index f794292b..0c70800e 100644 --- a/Extension/UI/Overlay/skate_menu.cpp +++ b/Extension/UI/Overlay/skate_menu.cpp @@ -276,12 +276,13 @@ ImFont* embedded_font(const wchar_t* name, float size, const ImWchar* ranges) { &config, ranges); } -enum Page { map, world, build, skater, training, multiplayer, progress, mods, settings, developer, page_count }; +enum Page { map, world, build, skater, training, multiplayer, progress, mods, settings, special, developer, page_count }; constexpr std::array page_names{ - "MAP", "WORLD", "BUILD", "SKATER", "TRAINER", "MULTIPLAYER", "PROGRESS", "MODS", "SETTINGS", "DEVELOPER"}; + "MAP", "WORLD", "BUILD", "SKATER", "TRAINER", "MULTIPLAYER", "PROGRESS", "MODS", "SETTINGS", "SPECIAL", "DEVELOPER"}; constexpr std::array page_subtitles{ "Pick your spot.", "Set the vibe.", "Make the park yours.", "Ride it your way.", "Tune it. Drill it. Measure it.", - "Bring your crew.", "Pick up where you want.", "Bring your own.", "Your controls, your screen.", "Under the hood."}; + "Bring your crew.", "Pick up where you want.", "Bring your own.", "Your controls, your screen.", "Not everyone gets this page.", + "Under the hood."}; } void load_skate_fonts(SkateMenu& menu) { @@ -369,12 +370,17 @@ void draw_skate_menu(SkateMenu& menu, const Model& model, const CallbacksV3& cal if (trainer_page_wanted()) menu.page = training; menu.page = std::clamp(menu.page, 0, static_cast(page_count) - 1); // Developer (the network view) is hidden for now; offline there is no Multiplayer either. - if (menu.page == developer || (model.steam_offline && menu.page == multiplayer)) menu.page = map; + // Special is only there for a player the backend lists. + const auto hidden = [&](int page) { + return page == developer || (model.steam_offline && page == multiplayer) || + (page == special && model.multiplayer.identity_tag.empty()); + }; + if (hidden(menu.page)) menu.page = map; ImGui::SetCursorPos(ImVec2(px(10), px(96))); ImGui::BeginChild("navigation", ImVec2(sidebar - px(20), size.y - px(140)), ImGuiChildFlags_None); const float tab_height = px(40); for (int i = 0; i < static_cast(page_count); ++i) { - if (i == developer || (model.steam_offline && i == multiplayer)) continue; + if (hidden(i)) continue; const auto at = ImGui::GetCursorScreenPos(); const bool selected = menu.page == i; ImGui::PushID(i); @@ -432,6 +438,7 @@ void draw_skate_menu(SkateMenu& menu, const Model& model, const CallbacksV3& cal ImGui::PopID(); break; case settings: settings_page(menu, model, callbacks); break; + case special: special_page(menu, model, callbacks); break; case developer: developer_page(menu, model, callbacks); break; } if (!menu.feedback.empty() && ImGui::GetTime() < menu.feedback_until) { diff --git a/Extension/UI/Overlay/skate_menu_internal.h b/Extension/UI/Overlay/skate_menu_internal.h index 9f07eeae..ebf9e26e 100644 --- a/Extension/UI/Overlay/skate_menu_internal.h +++ b/Extension/UI/Overlay/skate_menu_internal.h @@ -42,6 +42,7 @@ void debug_request(SkateMenu& menu, const CallbacksV3& callbacks, DebugRequest r // HOST shows the host form until a session exists, then that session (SESSION). inline constexpr int multiplayer_session_tab = 1, multiplayer_voice_tab = 2, multiplayer_bans_tab = 3; void multiplayer_display_settings(SkateMenu&, const Model&); +void special_page(SkateMenu&, const Model&, const CallbacksV3&); void multiplayer_network_page(SkateMenu&, const Model&, const CallbacksV3&); void atmosphere_menu(SkateMenu&, const Model&, const CallbacksV3&); void progression_page(SkateMenu&, const Model&, const CallbacksV3&); diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 880f7201..8ee41e09 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -10,6 +10,7 @@ #include "Extension/UI/NativeMenu/native_menu.h" #include "Extension/Multiplayer/Hud/native_party.h" #include "Extension/Multiplayer/Hud/custom_nametags.h" +#include "Extension/Multiplayer/developer_identity.h" #include "Extension/Multiplayer/Hud/follow_camera.h" #include "Engine/Game/UI/game_view.h" #include "Extension/Multiplayer/Steam/steam_friend_join.h" @@ -991,6 +992,7 @@ void tick(std::uintptr_t client, std::uintptr_t update) { DINGO_PROFILE_ZONE("tick/multiplayer"); dingosdk::multiplayer::tick(r.base,client,multiplayer_ready,r.multiplayer_map,load_multiplayer_map); } + dingosdk::multiplayer::refresh_identity_lists(); dingosdk::tick_local_developer_hoodie(r.base, client, multiplayer_ready); dingosdk::tick_local_developer_board(r.base, client, multiplayer_ready); // The session spawns and places skaters and can teleport: check the camera again. diff --git a/Server/README-linux.md b/Server/README-linux.md index 07e1654b..b402ff1e 100644 --- a/Server/README-linux.md +++ b/Server/README-linux.md @@ -7,6 +7,9 @@ Self-update is disabled on Linux (V1) — update by replacing the binary. - 64-bit Linux (tested: CachyOS/Arch, Ubuntu 22.04+ should work). - `cmake >= 3.24`, `g++ >= 12` (C++20), `libssl-dev` (OpenSSL), `libcurl` headers optional (not needed for V1). +- `curl` on the machine that runs the server: it is how the server reads the ReSkate team's + global ban list (`api.reskate.dev`). Without it the server says so in its log and enforces + only its own bans. Not needed with `"global_bans": false`. - Steam shared libs beside the binary: `libsteam_api.so` + `steamclient.so` (Valve proprietary, not in git — but bundled in the CI/release assets, like the Windows zip bundles its DLLs; otherwise fetch them, see below). @@ -65,8 +68,8 @@ cmake --build --preset linux-release -j$(nproc) ctest --test-dir build/linux --output-on-failure ``` -Expected: 8 passed (`multiplayer_parties`, `server_activity`, `server_speed_check`, -`server_config`, `word_filter`, `multiplayer_bandwidth`, `multiplayer_lanes`, +Expected: 9 passed (`multiplayer_parties`, `server_activity`, `server_speed_check`, +`server_config`, `server_global_bans`, `word_filter`, `multiplayer_bandwidth`, `multiplayer_lanes`, `multiplayer_prediction`). Windows-only tests (voice, lobbies, UI) stay `WIN32`-gated. ## Run diff --git a/Server/README.txt b/Server/README.txt index 6cc89fad..4c198d70 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -47,6 +47,10 @@ password Empty for anyone; otherwise players type it to join. welcome A chat line sent to each player as they join. listed false hides the server; players then need the code. auto_update Install new ReSkate releases when nobody is on (default true). +global_bans Turn away players the ReSkate team has banned from multiplayer + (default true). The list is read from api.reskate.dev at startup + and every ten minutes. false lets them in; the server's own + "bans" apply either way. votes Player votes, each off until turned on: "map": {"enabled": true, "percent": 60} /vote map "kick": {"enabled": true, "percent": 60} /vote kick @@ -104,6 +108,7 @@ world_layer_sync Force the "layers" below on every player. layers World layer key -> "on" / "off". admins SteamID64s (as strings) who may change settings in-game. bans Players who can never join. Managed with ban / unban. + The ReSkate team's own list is separate: see global_bans. Every change made from the console or by an admin is saved back to this file. diff --git a/Server/Test/global_bans_tests.cpp b/Server/Test/global_bans_tests.cpp new file mode 100644 index 00000000..51c567e5 --- /dev/null +++ b/Server/Test/global_bans_tests.cpp @@ -0,0 +1,57 @@ +#include "Server/global_bans.h" +#include "Extension/Multiplayer/developer_identity.h" +#include +#include +#include + +using namespace dingosdk::server; +using dingosdk::multiplayer::reskate_banned; +namespace { +void check(bool ok, const std::string &message) { + if (ok) return; + std::cerr << message << '\n'; + std::exit(1); +} +constexpr std::uint64_t griefer = 76561198000000009ULL, cheater = 76561198000000010ULL, player = 76561198000000011ULL; + +void lists() { + check(!reskate_banned(griefer), "A player was banned before any list was read"); + // The first list read counts as news even when it bans nobody, so the server can say it has it. + auto read = use_ban_list(R"({"categories":{"dev":["76561198000000011"]},"banned":[]})"); + check(read.ok && read.changed && read.banned == 0 && read.problem.empty() && !reskate_banned(griefer), + "A list with nobody on it was not taken as read"); + read = use_ban_list(R"({"categories":{},"banned":["76561198000000010","76561198000000009"]})"); + check(read.ok && read.changed && read.banned == 2 && reskate_banned(griefer) && reskate_banned(cheater) && !reskate_banned(player), + "The ban list did not ban the players on it"); + // The other lists changing is no news about bans. + read = use_ban_list(R"({"categories":{"homie":["76561198000000011"]},"banned":["76561198000000009","76561198000000010"]})"); + check(read.ok && !read.changed && read.banned == 2, "The same ban list counted as a change"); + // An answer that is not the lists (the backend down, a proxy's error page) lifts no ban. + for (const std::string_view wrong : {"", "502 Bad Gateway", R"({"banned":["76561198000000009"]})", + R"({"categories":{},"banned":["everyone"]})"}) { + read = use_ban_list(wrong); + check(!read.ok && !read.problem.empty() && reskate_banned(griefer) && reskate_banned(cheater), + "A bad answer was accepted, or lifted the bans: " + std::string(wrong)); + } + read = use_ban_list(R"({"categories":{},"banned":["76561198000000010"]})"); + check(read.ok && read.changed && read.banned == 1 && !reskate_banned(griefer) && reskate_banned(cheater), "A lifted ban stayed"); + // A backend from before it had bans bans nobody. + read = use_ban_list(R"({"categories":{"dev":[]}})"); + check(read.ok && read.changed && read.banned == 0 && !reskate_banned(cheater), "A list without bans kept one"); +} + +// --live: the deployed backend, read the way the server reads it. Not part of the +// test run, which stays off the network. +int live() { + const auto read = read_global_bans(); + if (read.ok) std::cout << "the backend's ban list was read: " << read.banned << " banned\n"; + else std::cout << "the backend's ban list could not be read: " << read.problem << '\n'; + return read.ok ? 0 : 1; +} +} // namespace + +int main(int count, char **arguments) { + if (count == 2 && std::string_view(arguments[1]) == "--live") return live(); + lists(); + std::cout << "global ban tests passed\n"; +} diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index b1c41fa4..c8f1627d 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -34,14 +34,15 @@ int run() { std::vector added; const auto config = load_config(file, &added); const auto has = [&](std::string_view name) { return std::ranges::find(added, name) != added.end(); }; - check(has("enforce_tuning") && has("votes.seconds") && has("votes.kick") && has("score_check") && has("score_allow"), + check(has("enforce_tuning") && has("votes.seconds") && has("votes.kick") && has("score_check") && has("score_allow") && + has("global_bans"), "New settings not reported"); check(!has("name") && !has("tps") && !has("votes.map"), "Settings the file had reported as new"); const auto written = text(file); check(written.find("\"enforce_tuning\"") != std::string::npos && written.find("\"seconds\"") != std::string::npos, "New settings not written into the file"); check(config.name == "Old Server" && config.tps == 60 && !config.boosts && config.votes.map.enabled && - config.votes.map.percent == 60 && config.enforce_tuning, + config.votes.map.percent == 60 && config.enforce_tuning && config.global_bans, "The file's own values or the new defaults were lost"); const auto reloaded = load_config(file); check(reloaded.name == "Old Server" && !reloaded.boosts && reloaded.votes.map.percent == 60, "Values lost on rewrite"); diff --git a/Server/global_bans.cpp b/Server/global_bans.cpp new file mode 100644 index 00000000..15cd4914 --- /dev/null +++ b/Server/global_bans.cpp @@ -0,0 +1,73 @@ +#include "global_bans.h" +#include "Extension/Multiplayer/developer_identity.h" +#ifdef _WIN32 +#include "Engine/Vfs/https_download.h" +#else +#include +#endif +#include +#include +#include +#include + +namespace dingosdk::server { +namespace { +constexpr std::size_t max_bytes = 256 * 1024; + +#ifdef _WIN32 +std::string download() { + const auto url = multiplayer::identity_lists_url; + https::Download result; + auto answer = https::get_text(std::wstring(url.begin(), url.end()), max_bytes, 15, L"ReSkateServer/1", &result); + if (!answer) throw std::runtime_error("HTTP " + std::to_string(result.http_status) + ", error " + std::to_string(result.error)); + return std::move(*answer); +} +#else +// curl does the TLS: the Linux server has no HTTPS client of its own (server_update.cpp). +// The command is fixed text, with nothing from a player or the config in it. +std::string download() { + const auto command = "curl --silent --show-error --fail --max-time 15 --max-filesize " + std::to_string(max_bytes) + + " --proto =https --user-agent ReSkateServer/1 " + std::string(multiplayer::identity_lists_url) + " 2>&1"; + auto *pipe = popen(command.c_str(), "r"); + if (!pipe) throw std::runtime_error("curl could not be started"); + std::string output; + char buffer[4096]; + for (std::size_t count; (count = fread(buffer, 1, sizeof(buffer), pipe)) > 0;) + if (output.size() < max_bytes) output.append(buffer, count); + if (pclose(pipe) != 0) { + // curl's own words (or the shell's, when curl is not installed), on one line. + while (!output.empty() && (output.back() == '\n' || output.back() == '\r')) output.pop_back(); + std::replace(output.begin(), output.end(), '\n', ' '); + throw std::runtime_error(output.empty() ? "curl failed" : output); + } + return output; +} +#endif +} // namespace + +BanListCheck use_ban_list(std::string_view answer) { + static std::optional> last; + BanListCheck check; + try { + auto lists = multiplayer::parse_identity_lists(answer); + const auto &bans = lists[static_cast(multiplayer::IdentityList::banned)]; + check.banned = bans.size(); + check.changed = last != bans; + last = bans; + multiplayer::publish_identity_lists(std::move(lists)); + check.ok = true; + } catch (const std::exception &e) { + check.problem = std::string("the answer was not the lists: ") + e.what(); + } + return check; +} +BanListCheck read_global_bans() { + try { + return use_ban_list(download()); + } catch (const std::exception &e) { + BanListCheck check; + check.problem = e.what(); + return check; + } +} +} // namespace dingosdk::server diff --git a/Server/global_bans.h b/Server/global_bans.h new file mode 100644 index 00000000..26c2d1ee --- /dev/null +++ b/Server/global_bans.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include +#include + +// The ReSkate backend's ban list (its admin panel's Bans page), which a dedicated +// server enforces beside its own: Host::tick turns those players away +// (multiplayer::reskate_banned). The server reads the list at startup and every +// ten minutes; until it has, and while the backend cannot be reached, it bans +// nobody by it. +namespace dingosdk::server { +struct BanListCheck { + bool ok{}; + bool changed{}; // the ban list is not the one read before (or is the first) + std::size_t banned{}; // players on it + std::string problem; // why it could not be read, or empty +}; +// Puts the backend's answer (the JSON of developer_identity.h) in use. An answer +// that is not the lists changes nothing. +BanListCheck use_ban_list(std::string_view answer); +// Asks the backend and does the same; blocks for a few seconds at most. Windows +// asks over WinHTTP; Linux runs curl, which has to be installed. +BanListCheck read_global_bans(); +} // namespace dingosdk::server diff --git a/Server/main.cpp b/Server/main.cpp index 6cd5e27c..f38bac12 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -2,6 +2,7 @@ // in-game server browser. Runs from its own folder, next to steam_api64.dll and // the Steam client files (steamclient64.dll, tier0_s64.dll, vstdlib_s64.dll). // On Linux the Steam files are libsteam_api.so and steamclient.so. +#include "global_bans.h" #include "server_config.h" #include "server_host.h" #include "server_update.h" @@ -393,6 +394,12 @@ int run(int argc, char **argv, bool skip_update) { auto next_update_check = next_advertise + update_interval; std::future update_check; bool update_now{}, update_waiting{}, restart{}; + // The backend's ban list (global_bans.h): read now and every ten minutes, a minute after a + // failure. "global_bans": false leaves it unread and lets those players in. + std::future ban_check; + auto next_ban_check = next_advertise; + bool bans_unread{}; + if (!config.global_bans) write_log("Global bans are off (\"global_bans\": false): only this server's own bans apply."); while (!stopping && !restart) { steam.run_callbacks(); try { @@ -447,6 +454,19 @@ int run(int argc, char **argv, bool skip_update) { } update_now = false; } + if (config.global_bans && !ban_check.valid() && now_time >= next_ban_check) + ban_check = std::async(std::launch::async, read_global_bans); + if (ban_check.valid() && ban_check.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + const auto check = ban_check.get(); + next_ban_check = now_time + (check.ok ? std::chrono::minutes(10) : std::chrono::minutes(1)); + // Said when it changes, not every ten minutes. + if (check.ok && (check.changed || bans_unread)) + write_log("Global bans: " + std::to_string(check.banned) + " player(s) banned from ReSkate multiplayer cannot join."); + else if (!check.ok && !bans_unread) + write_log("The global ban list could not be read (" + check.problem + "). Trying again every minute; " + + "until then the bans already read hold."); + bans_unread = !check.ok; + } if (update_waiting && !restart && host.players() == 0) { write_log("Nobody is on; restarting to install server update " + update_version + "."); restart = true; @@ -478,6 +498,7 @@ int run(int argc, char **argv, bool skip_update) { input.shutdown(); #endif if (update_check.valid()) update_check.wait(); + if (ban_check.valid()) ban_check.wait(); write_log(restart ? "Restarting for an update." : "Shutting down."); host.stop(restart ? "The server is restarting for an update. Rejoin in a minute." : "The server is shutting down."); // Leaving scope closes the networking before Steam itself shuts down. diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 30530426..76816150 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -21,6 +21,7 @@ Json to_json(const ServerConfig &c) { root["welcome"] = c.welcome; root["listed"] = c.listed; root["auto_update"] = c.auto_update; + root["global_bans"] = c.global_bans; root["activity_log"] = c.activity_log; root["announce_throwdowns"] = c.announce_throwdowns; root["parties"] = c.parties; @@ -115,6 +116,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector:Launcher/updater.cpp> + Server/global_bans.cpp Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/Steam/steam_transport.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Remote/playback_buffers.cpp Extension/Multiplayer/Session/password.cpp @@ -135,7 +140,7 @@ add_executable(dingosdk_server Server/main.cpp Server/server_host.cpp Server/ser target_include_directories(dingosdk_server SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking") if(WIN32) target_link_libraries(dingosdk_server PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json - dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_miniz dingosdk_word_filter winhttp bcrypt winmm) + dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_miniz dingosdk_word_filter dingosdk_https winhttp bcrypt winmm) set_target_properties(dingosdk_server PROPERTIES OUTPUT_NAME "ReSkateServer") dingosdk_version_info(dingosdk_server "ReSkate dedicated server" "ReSkateServer.exe" VFT_APP) else() diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index c8ced382..5989d22a 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -60,6 +60,8 @@ add_library(dingosdk_runtime SHARED Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Session/password.cpp + Extension/Multiplayer/developer_identity.cpp + Extension/Multiplayer/developer_identity_fetch.cpp Extension/Settings/console_commands.cpp Extension/Settings/job_spin.cpp Extension/Settings/engine_tweaks.cpp From e145a67db9b15c2b0044525bcc529d80e089743c Mon Sep 17 00:00:00 2001 From: ReSkate local Date: Mon, 5 Oct 2026 05:04:37 +0100 Subject: [PATCH 040/109] Music: add a native Mods shelf above Featured Create independent anchored and tile-list models before native widget binding. Populate Mods from Featured rows with mod playlist IDs while retaining native presentation and actions. Leave existing playlist contexts and cosmetic ownership unchanged. Document the native UI model flow, verified loader entry points, typed publication, and inspection workflow for future modders. --- Extension/Music/local_music_ui.cpp | 211 ++++++++++++- Extension/Music/local_music_ui.h | 10 + Extension/Profile/local_profile_runtime.cpp | 9 +- contrib/native-music-ui.md | 310 ++++++++++++++++++++ 4 files changed, 538 insertions(+), 2 deletions(-) create mode 100644 contrib/native-music-ui.md diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index b249242d..2c4fa180 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -10,8 +10,182 @@ #include "Engine/Game/Build/20260929/local_music.h" #include "Engine/Game/World/location_travel.h" #include "Extension/Profile/runtime_internal.h" +#include "Extension/UI/NativeMenu/native_menu_data.h" +#include +#include namespace dingosdk::profile_runtime { +namespace { +// Format model handles for shelf publication logs. +std::string music_diag_hex(std::uint64_t value, int width) { + static constexpr char digits[] = "0123456789abcdef"; + std::string out(static_cast(width), '0'); + for (int i = width - 1; i >= 0; --i, value >>= 4) out[static_cast(i)] = digits[value & 15]; + return out; +} +namespace md = dingosdk::multiplayer::menu_data; +struct ModsShelf { + std::uintptr_t manager{}; + md::Value anchor{}, tiles{}, featured_anchor{}; + ULONGLONG next_poll{}; + std::vector published_rows; +}; +ModsShelf mods_shelf; +// Read inline typed fields without creating child handles during authored construction. +std::uintptr_t music_inline_path(const md::Context& context, std::uintptr_t data, + std::uintptr_t type, std::initializer_list hashes) { + for (auto hash : hashes) { + const auto field = context.member(type, hash); + data += field.offset; + type = field.type; + } + return data; +} +md::Ref music_tile_ref(const md::Context& context, md::Value anchor) { + return md::read(music_inline_path(context, context.address(anchor), anchor.type, + {0x214d4984U, 0x25e4d6c8U})); +} +void music_sync_mods(const md::Context& context) { + if (mods_shelf.manager != context.manager || !mods_shelf.tiles.handle) return; + const auto ref = music_tile_ref(context, mods_shelf.featured_anchor); + const auto handle = ref.handle & ~std::uint64_t{1}; + if (!handle) return; + const md::Value featured{handle, context.type_of(handle)}; + md::require(featured.type == mods_shelf.tiles.type, "Featured tile schema differs."); + const auto source_items = context.field(featured, 0x67223da7U); + unsigned count{}, stride{}; + const auto source = context.array(source_items, 1024, count, stride); + const auto row_type = md::read(md::read(source_items.type) + 0x30); + md::require(stride == 1200 && md::read(md::read(row_type)) == 0xdeb20e0fU, + "Music tile row schema differs."); + std::vector filtered; + unsigned mods{}; + for (unsigned i = 0; i < count; ++i) { + const auto at = reinterpret_cast(source.data() + std::size_t{i} * stride); + const auto tile = md::read(music_inline_path(context, at, row_type, + {0x214d4984U, 0x0fb0d794U, 0xa704272aU, 0xc52416efU, 0x0abf7c31U})); + const auto tile_handle = tile.handle & ~std::uint64_t{1}; + if (!tile_handle) continue; + const md::Value tile_model{tile_handle, context.type_of(tile_handle)}; + const auto playlist_handle = md::read(music_inline_path(context, + context.address(tile_model), tile_model.type, {0xeb4f9b4cU})) & ~std::uint64_t{1}; + if (!playlist_handle) continue; + const md::Value playlist{playlist_handle, context.type_of(playlist_handle)}; + const auto id = context.text(context.field(playlist, 0xa5b84b8aU), 2048); + if (!id.starts_with("mod:")) continue; + filtered.insert(filtered.end(), source.begin() + std::size_t{i} * stride, + source.begin() + std::size_t{i + 1} * stride); + ++mods; + } + const auto destination = context.field(mods_shelf.tiles, 0x67223da7U); + unsigned previous{}, previous_stride{}; + context.array(destination, 1024, previous, previous_stride); + md::require(previous_stride == stride, "Mods tile row schema differs."); + if (previous == mods && mods_shelf.published_rows == filtered) return; + context.array(destination, filtered, mods); + mods_shelf.published_rows = std::move(filtered); + dingosdk::logging::event(dingosdk::logging::Channel::music, + dingosdk::Json{{"event", "music_mods_shelf_fill"}, {"count", mods}, {"featured", count}}.dump().c_str()); +} +void music_poll_mods(std::uintptr_t base) { + if (!mods_shelf.manager || GetTickCount64() < mods_shelf.next_poll) return; + mods_shelf.next_poll = GetTickCount64() + 250; + std::uintptr_t provider{}, manager{}; + if (!read(base + addr::local_music::ui_manager, provider) || !provider || + !read(provider + 0x80, manager) || manager != mods_shelf.manager) return; + try { + game::ModelWriteLock lock(mods_shelf.manager); + music_sync_mods(md::Context{base, mods_shelf.manager}); + } catch (const std::exception& error) { + static std::string last_error; + if (last_error != error.what()) { + last_error = error.what(); + dingosdk::logging::event(dingosdk::logging::Channel::music, + dingosdk::Json{{"event", "music_mods_shelf_waiting"}, {"reason", last_error}}.dump().c_str()); + } + } +} +// Publish the independent Mods shelf before native widgets bind. +std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, std::uint64_t exact_list = 0) { + namespace md = dingosdk::multiplayer::menu_data; + game::ModelWriteLock lock(model); + md::Context shelf{base, model}; + std::string report = "shelf_append"; + unsigned candidates = 0; + const auto roots = exact_list + ? std::vector{{md::Value{exact_list, shelf.type_of(exact_list)}, 0}} + : shelf.roots({0x48455d84U}); + for (const auto& root : roots) { + try { + const auto items = shelf.field(root.model, 0x67223da7U); + unsigned count = 0, stride = 0; + const auto bytes = shelf.array(items, 64, count, stride); + if (stride != 16 || count < 1 || count > 8) continue; + const auto first = shelf.element(items, 0); + const auto element_schema = first.type ? md::read(md::read(first.type)) : 0U; + if (element_schema != 0x62088281U) continue; + std::uint64_t target = 0; + std::memcpy(&target, bytes.data() + 8, sizeof(target)); + std::uint32_t target_schema = 0; + if (target) { try { const auto type = shelf.type_of(target & ~std::uint64_t{1}); target_schema = type ? md::read(md::read(type)) : 0U; } catch (const std::exception&) {} } + ++candidates; + if (target_schema != 0x0e3be640U) continue; + report += " list=0x" + music_diag_hex(root.model.handle, 16) + " count=" + std::to_string(count); + if (count >= 4) { report += " already"; break; } + if (count < 2) { report += " too_few"; break; } + std::uint64_t record_ref = 0, source_vm = 0; + std::memcpy(&record_ref, bytes.data() + stride, 8); + std::memcpy(&source_vm, bytes.data() + stride + 8, 8); + const auto source_type = shelf.type_of(source_vm & ~std::uint64_t{1}); + auto new_vm = shelf.create(md::Schema{0x0e3be640U, 192}, (GetTickCount64() << 12) | 0x4dU); + md::Value new_tiles{}; + try { + const md::Value source_anchor{source_vm, source_type}; + shelf.copy(new_vm, shelf.address(source_anchor)); + const auto tile_ref = music_tile_ref(shelf, source_anchor); + auto tile_handle = tile_ref.handle & ~std::uint64_t{1}; + const auto tile_type = shelf.type(md::Schema{0x74b1ea1dU, 392}); + std::uintptr_t tile_data{}; + if (tile_handle && shelf.type_of(tile_handle) == tile_type) + tile_data = shelf.address(md::Value{tile_handle, tile_type}); + else if (tile_ref.record && md::read(tile_ref.record + 0x18) == tile_type) + tile_data = md::read(tile_ref.record + 0x20); + md::require(tile_data != 0, "Music tile template is not available."); + new_tiles = shelf.create(md::Schema{0x74b1ea1dU, 392}, (GetTickCount64() << 12) | 0x4eU); + shelf.copy(new_tiles, tile_data); + shelf.text(shelf.path(new_tiles, {0xfaec2d05U, 0xcb79482dU, 0x4d8e01b9U}), "Mods"); + // An independent list retains each native row's actions and artwork on publication. + shelf.array(shelf.field(new_tiles, 0x67223da7U), std::span{}, 0); + shelf.set(shelf.path(new_vm, {0x214d4984U, 0x25e4d6c8U}), md::Ref{0, new_tiles.handle}); + } catch (...) { + if (new_tiles.handle) shelf.destroy(new_tiles); + shelf.destroy(new_vm); + throw; + } + std::vector element(stride); + std::memcpy(element.data(), &record_ref, 8); + std::memcpy(element.data() + 8, &new_vm.handle, 8); + auto merged = bytes; + merged.insert(merged.begin(), element.begin(), element.end()); + shelf.array(items, merged, count + 1); + const auto previous = mods_shelf; + mods_shelf = {model, new_vm, new_tiles, md::Value{target, shelf.type_of(target)}, 0}; + // Release our previous generation after the new shelf has been published. + if (previous.manager == model) { + try { shelf.destroy(previous.anchor); shelf.destroy(previous.tiles); } catch (const std::exception&) {} + } + try { music_sync_mods(shelf); } catch (const std::exception&) {} + unsigned after = 0, after_stride = 0; + shelf.array(items, 64, after, after_stride); + report += " new=0x" + music_diag_hex(new_vm.handle, 16) + " appended=" + std::to_string(count + 1) + + " after=" + std::to_string(after); + break; + } catch (const std::exception& error) { report += " error=" + std::string(error.what()); continue; } + } + report += " candidates=" + std::to_string(candidates); + return report; +} +} // Runtime-only music UI hydration: read_music_catalog copies actual registered // MusicGraphAsset metadata/TagRefs. No generated catalog, guessed memberships, @@ -29,6 +203,40 @@ void music_release_weak(std::uintptr_t owner) noexcept { MusicUiRuntime& music_ui_runtime() { static auto* r = new MusicUiRuntime; return *r; } +// CONFIRMED: the authored loader calls the shared constructor at RVA 0x1912670. +// The return path exposes the shelf list before its native widgets bind. +MusicModelConstruct music_model_construct_original{}; +std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mode, + std::uint64_t id, std::uintptr_t type, bool flag, std::uintptr_t record) { + const auto handle = music_model_construct_original(manager, mode, id, type, flag, record); + PreserveError preserve; + try { + if (!handle || !type || md::read(md::read(type)) != 0x48455d84U) + return handle; + game::ModelWriteLock lock(manager); + md::Context context{local_runtime().base, manager}; + const auto items = context.field(md::Value{handle, type}, 0x67223da7U); + unsigned count{}, stride{}; + const auto bytes = context.array(items, 1024, count, stride); + if (count != 3 || stride != 16) return handle; + constexpr const char* records[] = { + "MusicPlaylistManager_Base_ContentResources/Featured_AnchoredContent_VM", + "MusicPlaylistManager_Base_ContentResources/Liked_AnchoredContent_VM", + "MusicPlaylistManager_Base_ContentResources/NewlyDiscovered_AnchoredContent_VM"}; + for (unsigned item = 0; item < 3; ++item) { + std::uintptr_t item_record{}; + std::memcpy(&item_record, bytes.data() + item * stride, sizeof(item_record)); + // Named record header +0x28 is also used by the native UI dumper. + if (!item_record || md::string(md::read(item_record + 0x28), 256) != records[item]) + return handle; + } + const auto report = music_shelf_append_one(local_runtime().base, manager, handle); + dingosdk::logging::event(dingosdk::logging::Channel::music, + dingosdk::Json{{"event", "music_shelf_append"}, {"report", report}}.dump().c_str()); + } catch (...) {} // A shelf failure must not interrupt the native constructor. + return handle; +} + bool initialize_music_functions(std::uintptr_t base) { namespace music = addr::local_music; for (const auto& fp : music::music_ui_contracts) { @@ -212,6 +420,7 @@ void update_music_catalog() { auto& s = local_runtime(); auto& ui = music_ui_runtime(); if (!s.active.load(std::memory_order_acquire) || ui.updating || cosmetic_runtime().update_thread != GetCurrentThreadId()) return; PreserveError preserve; std::lock_guard lock(s.native_mutex); + music_poll_mods(s.base); if (!ui.pending) return; if (ui.pending->thread != GetCurrentThreadId() || !music_ui_current(*ui.pending)) { ui.pending.reset(); return; } const auto now = GetTickCount64(); if (now < ui.pending->next_poll) return; @@ -287,4 +496,4 @@ void update_music_catalog() { dingosdk::logging::event(dingosdk::logging::Channel::music, dingosdk::Json{{"event", "native_music_ui_complete"}, {"playlists", playlist_count}, {"songs", song_count}, {"source", "runtime_music_assets"}}.dump().c_str()); } catch (...) { retry = false; dingosdk::logging::event(dingosdk::logging::Channel::music, "{\"event\":\"native_music_ui_publish_failed\"}"); } } -} \ No newline at end of file +} diff --git a/Extension/Music/local_music_ui.h b/Extension/Music/local_music_ui.h index f5fa19ed..57062f58 100644 --- a/Extension/Music/local_music_ui.h +++ b/Extension/Music/local_music_ui.h @@ -82,4 +82,14 @@ void music_ui_initialize_hook(std::uint64_t all, std::uint64_t hidden, std::uint const std::uint32_t* handle, const void* callback); void update_music_catalog(); + +// Shared authored-model constructor, ABI verified at Skate.exe +0x1912670 and +0x191868f. +inline constexpr game::build::Fingerprint music_model_construct_contract{0x1912670, + {0x4c,0x89,0x44,0x24,0x18,0x55,0x56,0x41,0x55,0x41,0x56,0x48,0x8d,0x6c,0x24,0xd1, + 0x48,0x81,0xec,0xf8,0x00,0x00,0x00,0x4d,0x8b,0xf1,0x44,0x0f,0xb6,0xea,0x48,0x8b}}; +using MusicModelConstruct = std::uint64_t (*)(std::uintptr_t, std::uint8_t, std::uint64_t, + std::uintptr_t, bool, std::uintptr_t); +extern MusicModelConstruct music_model_construct_original; +std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mode, + std::uint64_t id, std::uintptr_t type, bool flag, std::uintptr_t record); } diff --git a/Extension/Profile/local_profile_runtime.cpp b/Extension/Profile/local_profile_runtime.cpp index e7596edd..637acf02 100644 --- a/Extension/Profile/local_profile_runtime.cpp +++ b/Extension/Profile/local_profile_runtime.cpp @@ -523,8 +523,15 @@ bool initialize_local_profile(std::uintptr_t base, bool authored_offline, hook(news_list_contract, &news_list_hook, news_runtime().functions.list); hook(news_subscribe_contract, &news_subscribe_hook, news_runtime().functions.subscribe); hook(object_subscribe_contract, &object_categories_hook, object_runtime().functions.subscribe); - if (music_ready) + if (music_ready) { hook(music_ui_initialize_contract, &music_ui_initialize_hook, music_ui_runtime().functions.initialize); + std::array construct_bytes{}; + if (read(base + music_model_construct_contract.rva, construct_bytes) && + construct_bytes == music_model_construct_contract.bytes) + hook(music_model_construct_contract, &music_model_construct_hook, music_model_construct_original); + else dingosdk::logging::event(dingosdk::logging::Channel::music, + "{\"event\":\"music_model_construct_contract_mismatch\"}"); + } hook(buildkit_text_exists_contract, &buildkit_text_exists, buildkit_text_functions().exists); hook(buildkit_text_translate_contract, &buildkit_text_translate, buildkit_text_functions().translate); hook(buildkit_grabber_settings_contract, &buildkit_grabber_settings_hook, buildkit_limits_runtime().grabber_settings); diff --git a/contrib/native-music-ui.md b/contrib/native-music-ui.md new file mode 100644 index 00000000..8510c067 --- /dev/null +++ b/contrib/native-music-ui.md @@ -0,0 +1,310 @@ +# Modding the native Music Playlist Manager + +This guide explains how ReSkate adds a **Mods** shelf above Featured in skate.'s +existing Music Playlist Manager. It is a worked example of changing native UI +through its data models, while reusing the game's layout, playlist tiles and +selection behavior. + +The implementation lives in [local_music_ui.cpp](../Extension/Music/local_music_ui.cpp). +These are C++ runtime changes. A music mod can supply playlists through the +existing music-mod pipeline; adding a new native shelf requires runtime code +such as this hook. There is no general declarative UI-mod API established here. + +## Evidence and version boundary + +The loader addresses below are **CONFIRMED by disassembly** for Skate.exe SHA256 +`fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9`. +Addresses use preferred image base `0x140000000`; runtime code uses the loaded +base plus an RVA. Schema sizes and field paths were checked against authored +type metadata and live UI dumps on that build. + +The independent Mods shelf and its position above Featured were **confirmed by +the user's in-game observation** on the personal development build. That is +distinct from compiling the isolated upstream-based PR branch. It does not +establish compatibility with a different executable or every native screen. + +## Three layers to keep separate + +**Authored assets describe the screen.** The music content-resource asset contains +named records for its shelves. Type-info assets describe the structures and their +fields. These records are templates and references, rather than live widgets. + +**Runtime models hold the current values.** A model manager instantiates typed +values and assigns handles. The music provider publishes playlist/song data into +its contexts. The native UI also has presentation models: lists, labels, +presenters and tile rows. A playlist model and its visible tile are different +objects. + +**Widgets consume presentation models.** A presenter binds a model to an authored +widget. Updating a value and creating a new visible widget are therefore separate +events. For this screen, adding a shelf after binding changed the array count +without producing the fourth rendered shelf. Inserting it before binding worked. +That observation is specific to the shelf list; do not assume all native lists +have identical behavior. + +```mermaid +flowchart TD + A[Authored music content records] --> B[Shared model constructor] + B --> C[Shelf linear list] + C --> D[Anchored presenter model] + D --> E[Content presenter / DataModelReference] + E --> F[TileListViewModel] + F --> G[Label and inline tile rows] + G --> H[Native widgets] + I[Music provider and playlist contexts] --> J[Featured presentation rows] + J --> K[Filter playlist IDs beginning with mod:] + K --> L[Independent Mods tile list] + L --> H +``` + +## Finding the right model + +A schema hash identifies a **type**, not a particular screen instance. Many +screens use `linear_list`, and several lists can contain three anchored +presenters. Matching only a schema and count was insufficient. + +The music hook first checks for a `linear_list`, then requires three 16-byte +entries with these exact authored record names, in this order: + +```text +MusicPlaylistManager_Base_ContentResources/Featured_AnchoredContent_VM +MusicPlaylistManager_Base_ContentResources/Liked_AnchoredContent_VM +MusicPlaylistManager_Base_ContentResources/NewlyDiscovered_AnchoredContent_VM +``` + +Record identity is available earlier than every nested runtime binding. An early +attempt to identify the list by traversing its rendered titles did not complete. +Checking the authored names allowed the constructor-time insertion to fire. +The precise cause of that failed title traversal was not established. + +### Types encountered in the shelf + +| Type or role | Schema hash | Size on the tested build | +| --- | --- | ---: | +| PageViewModel | `0x5844be65` | 1968 bytes | +| Shelf `linear_list` | `0x48455d84` | 464 bytes | +| DataModelReference | `0x62088281` | 16 bytes | +| AnchoredContentPresenterViewModel | `0x0e3be640` | 192 bytes | +| ContentPresenter | `0x395d20a4` | 24 bytes | +| TileListViewModel | `0x74b1ea1d` | 392 bytes | +| Inline playlist tile row | `0xdeb20e0f` | 1200 bytes | +| Playlist data model | `0x63ddd270` | Resolve its fields through metadata | + +The shelf list and the tile list both expose `ListItems`, hash `0x67223da7`, but +their element types differ. Shelf elements are 16-byte references. Playlist tile +elements are complete 1200-byte inline structures. Treating every `ListItems` +array as an array of handles corrupts the interpretation. + +### Records, handles and references + +The helper API represents a DataModelReference as: + +```cpp +struct Ref { + std::uintptr_t record; // authored record pointer, +0x00 + std::uint64_t handle; // runtime ValueContext, +0x08 +}; +``` + +A record pointer and a model handle are different forms of identity. A handle is +not a memory address; resolve its type and storage through the model manager. +On the verified loader at `0x141918520` (RVA `0x1918520`), a named record supplies +its type at `+0x18` and typed data at `+0x20`. The UI dumper also reads its name +from `+0x28`. + +The outer shelf reference points to an anchored model. Within that model: + +```text +AnchoredContentPresenterViewModel + -> field 0x214d4984 + -> field 0x25e4d6c8 : DataModelReference + record +0x00 + ValueContext +0x08 : TileListViewModel handle +``` + +The tile-list title is a separate nested value: + +```text +TileListViewModel + -> CommonListData 0xfaec2d05 + -> label field 0xcb79482d + -> text field 0x4d8e01b9 +``` + +Use field metadata to obtain offsets and types. These hashes were recovered from +the imported type-info assets: `FieldNameHash` in authored values corresponds to +`NameHash` in field metadata. They are not names to recover by assuming an +ordinary string-hash algorithm. Type schema hashes form another lookup concern. + +## Why the constructor hook matters + +Hooking the public model-create wrapper did not reveal the authored music list. +The two creation paths meet lower down: + +```text +models.create wrapper: 0x141918860 + calls shared constructor at 0x141918a1e + +authored record loader: 0x141918520 + calls shared constructor at 0x14191868f + +shared constructor: 0x141912670 (RVA 0x1912670) +``` + +**CONFIRMED:** the authored loader bypasses `models.create` while still using the +shared constructor. Absence from a wrapper hook did not mean the model was never +created. + +The verified shared-constructor signature is: + +```cpp +std::uint64_t construct( + std::uintptr_t manager, + std::uint8_t mode, + std::uint64_t id, + std::uintptr_t type, + bool flag, + std::uintptr_t record); +``` + +The shelf hook calls the original constructor first. On its return, it recognizes +the exact music list and inserts the new entry before the native caller continues +to bind widgets. It returns the original handle unchanged. Installation checks +an exact 32-byte executable fingerprint; a mismatch leaves this hook uninstalled. +See [local_music_ui.h](../Extension/Music/local_music_ui.h) for the contract and +[local_profile_runtime.cpp](../Extension/Profile/local_profile_runtime.cpp) for +installation. That profile-runtime change only installs the music hook; it does +not change cosmetic ownership or progression. + +## Building an independent shelf + +The successful duplicate experiment cloned Liked's anchored presenter with a new +handle. That rendered a fourth shelf, but still referenced Liked's tile list. +Cloning only the outer presenter does not make its contents independent. + +The Mods implementation performs these steps under the native model write lock: + +1. Read the original three shelf entries and retain the Featured anchor as the + source of native playlist rows. +2. Create an anchored model and copy Liked's anchored template into it. +3. Create a separate TileListViewModel and copy Liked's tile-list template. + Use resolved runtime data if available, otherwise verified authored record + data of the expected type. +4. Publish the new title text, `Mods`, through the nested label field. +5. Clear the new tile array. Retarget the new anchor's presenter reference to + `{record = 0, handle = new_tiles.handle}`. +6. Insert the new outer shelf reference at index zero. The result is + **Mods, Featured, Liked, Newly Discovered**. +7. Populate the owned tile list from mod playlist rows in Featured. + +The native template supplies layout and presentation defaults. Creating both +models separately supplies independent state. Changing the label on the original +Liked tile list would rename the original shelf too. + +## Keeping the native tile behavior + +A row contains more than its visible title. It carries nested presentation data +and references used by native behavior. Rather than inventing a replacement row, +the implementation copies the existing Featured rows for mod playlists. + +The tested inline path to the row's tile-model reference is: + +```text +row 0xdeb20e0f + -> 0x214d4984 + -> 0x0fb0d794 + -> 0xa704272a + -> 0xc52416ef + -> 0x0abf7c31 : DataModelReference +``` + +Resolve that reference's runtime handle, then read field `0xeb4f9b4c` to obtain +the playlist handle. Playlist field `0xa5b84b8a` contains its identifier. Rows +whose identifiers start with `mod:` are included in Mods. The native copies +retain the existing actions and artwork references; filtering does not create +new playlist identities or replace the music provider. + +The client update path polls every 250 ms because Featured presentation rows may +be hydrated after shelf construction. It follows the captured Featured anchor, +checks the current music provider's model manager, and republishes only when the +filtered row bytes or destination count change. A cached copy of source row bytes +avoids repeatedly publishing merely because native copying changes storage. + +This adds a presentation shelf. Mod playlists can still appear in Featured; +the implementation does not remove them there or write them into Liked and Newly +Discovered. + +## Mutation and ownership + +Use [menu_data::Context](../Extension/UI/NativeMenu/native_menu_data.h) on the +client thread while holding `game::ModelWriteLock`. Its helpers validate model +types, resolve field metadata, and publish through native model operations. + +| Helper | Purpose | +| --- | --- | +| `type_of`, `address` | Resolve a handle and validate live typed storage | +| `member` | Read a field's metadata, including its offset and type | +| `field`, `path` | Resolve native handles for fields being read or published | +| `create`, `copy` | Create an owned root and copy a typed template | +| `text`, `set`, `array` | Publish native values rather than assign raw pointers | +| `destroy` | Release an owned root, with type/expiration checks | + +`Context::array` reads the element stride from the array's metadata and checks +that byte length matches element count. Its write helper builds the native array +header and performs synchronous typed publication. Native copying handles strings, +delegates, references and nested arrays; a borrowed pointer into a temporary +`std::vector` must not be installed as persistent model storage. + +Raw traversal is useful for inspecting inline values before nested field handles +are available. The Mods hook uses metadata offsets for those reads. It still +uses native publication for writes. A raw memory snapshot containing pointers is +not a self-contained owned model. + +Handle lifetime also matters. The implementation releases newly created roots +if template preparation fails and releases its previous owned anchor/tile roots +when replacing its tracked generation in the same manager. Reopening and switching +screens must be tested: a valid handle from one observation is not proof of its +validity after scene teardown. + +## Inspecting a screen yourself + +ReSkate's console exposes the live model dumper: + +```text +ui dump roots +ui dump MusicPlaylistManager +ui dump 0x48455d84 +ui dump 0x0e3be640 +ui dump 0x74b1ea1d +ui dump 0x74b1ea1d 5 +``` + +The last form schedules the dump after five seconds, allowing time to open the +screen. Output goes to the game's `logs` directory. Read +[native_menu_dump.cpp](../Extension/UI/NativeMenu/native_menu_dump.cpp) for the +walker and [console_commands.cpp](../Extension/Multiplayer/console_commands.cpp) +for command registration. + +Start with roots, identify the presentation type, follow record and handle +references, and compare observations before and after opening the screen. The +dumper has depth, element and node limits; a truncated dump cannot prove that a +field or model is absent. + +For a new native UI change, establish four separate results: the intended model +was identified, native publication changed its value, widgets displayed the +change, and user interaction still worked. Test reopening as well as first open. +For shelves, test empty contents, multiple mod playlists and controller navigation. +Those are useful future regression checks, not claims that every case has already +passed for this implementation. + +## Lessons that transfer to other screens + +Recognize the particular authored resource before touching shared UI types. Trace +all relevant creation paths rather than relying on a convenient wrapper. Check +when widgets bind before choosing a mutation point. Clone every layer that needs +independent state, preserve native rows when their behavior is useful, and publish +through the engine's typed operations. + +The Mods shelf demonstrates those techniques on one screen and one executable +build. Other screens need their own identity checks, schemas, timing observations +and interaction tests. From 81f3a77a25a98160ca50ecfe0670befa3163d362 Mon Sep 17 00:00:00 2001 From: ReSkate local Date: Mon, 5 Oct 2026 05:15:06 +0100 Subject: [PATCH 041/109] Music: isolate shelf helpers from artwork UI hunk --- Extension/Music/local_music_ui.cpp | 34 ++++++++++++++++-------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index 2c4fa180..af17d38f 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -15,6 +15,24 @@ #include namespace dingosdk::profile_runtime { + +// Runtime-only music UI hydration: read_music_catalog copies actual registered + +// MusicGraphAsset metadata/TagRefs. No generated catalog, guessed memberships, + +// artwork, audio-residency gate, or preference persistence. Bounds: 1024 songs, + +// 256 authored groups, 8192 membership edges. Include after assets/news/model helpers. + +void music_release_weak(std::uintptr_t owner) noexcept { + if (owner && InterlockedDecrement(reinterpret_cast(owner + 12)) == 0) { + const auto table = *reinterpret_cast(owner); + reinterpret_cast(table[2])(reinterpret_cast(owner)); + } +} + +MusicUiRuntime& music_ui_runtime() { static auto* r = new MusicUiRuntime; return *r; } + namespace { // Format model handles for shelf publication logs. std::string music_diag_hex(std::uint64_t value, int width) { @@ -186,22 +204,6 @@ std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, st return report; } } -// Runtime-only music UI hydration: read_music_catalog copies actual registered - -// MusicGraphAsset metadata/TagRefs. No generated catalog, guessed memberships, - -// artwork, audio-residency gate, or preference persistence. Bounds: 1024 songs, - -// 256 authored groups, 8192 membership edges. Include after assets/news/model helpers. - -void music_release_weak(std::uintptr_t owner) noexcept { - if (owner && InterlockedDecrement(reinterpret_cast(owner + 12)) == 0) { - const auto table = *reinterpret_cast(owner); - reinterpret_cast(table[2])(reinterpret_cast(owner)); - } -} - -MusicUiRuntime& music_ui_runtime() { static auto* r = new MusicUiRuntime; return *r; } // CONFIRMED: the authored loader calls the shared constructor at RVA 0x1912670. // The return path exposes the shelf list before its native widgets bind. From 916e281e69400f2fadc16a05af29a2e6de90812b Mon Sep 17 00:00:00 2001 From: ReSkate local Date: Mon, 5 Oct 2026 05:15:17 +0100 Subject: [PATCH 042/109] Music: preserve artwork insertion context --- Extension/Music/local_music_ui.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index af17d38f..48633384 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -15,7 +15,6 @@ #include namespace dingosdk::profile_runtime { - // Runtime-only music UI hydration: read_music_catalog copies actual registered // MusicGraphAsset metadata/TagRefs. No generated catalog, guessed memberships, From 96cd684e206f1b795a6dc537ae331f8b6285bf34 Mon Sep 17 00:00:00 2001 From: ReSkate local Date: Mon, 5 Oct 2026 05:20:49 +0100 Subject: [PATCH 043/109] Music: isolate native shelf implementation --- Extension/Music/local_music_shelf.cpp | 229 ++++++++++++++++++++ Extension/Music/local_music_shelf.h | 15 ++ Extension/Music/local_music_ui.cpp | 207 ------------------ Extension/Music/local_music_ui.h | 9 - Extension/Profile/local_profile_runtime.cpp | 2 + cmake/Runtime.cmake | 1 + contrib/native-music-ui.md | 5 +- 7 files changed, 250 insertions(+), 218 deletions(-) create mode 100644 Extension/Music/local_music_shelf.cpp create mode 100644 Extension/Music/local_music_shelf.h diff --git a/Extension/Music/local_music_shelf.cpp b/Extension/Music/local_music_shelf.cpp new file mode 100644 index 00000000..a9f614a2 --- /dev/null +++ b/Extension/Music/local_music_shelf.cpp @@ -0,0 +1,229 @@ +#include "Extension/Music/local_music_shelf.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Game/Abi/native_data.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/engine.h" +#include "Engine/Game/Build/20260929/local_music.h" +#include "Extension/Profile/runtime_internal.h" +#include "Extension/UI/NativeMenu/native_menu_data.h" +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::profile_runtime { +namespace { +// Format model handles for shelf publication logs. +std::string music_diag_hex(std::uint64_t value, int width) { + static constexpr char digits[] = "0123456789abcdef"; + std::string out(static_cast(width), '0'); + for (int i = width - 1; i >= 0; --i, value >>= 4) out[static_cast(i)] = digits[value & 15]; + return out; +} +namespace md = dingosdk::multiplayer::menu_data; +struct ModsShelf { + std::uintptr_t manager{}; + md::Value anchor{}, tiles{}, featured_anchor{}; + ULONGLONG next_poll{}; + std::vector published_rows; +}; +ModsShelf mods_shelf; +// Read inline typed fields without creating child handles during authored construction. +std::uintptr_t music_inline_path(const md::Context& context, std::uintptr_t data, + std::uintptr_t type, std::initializer_list hashes) { + for (auto hash : hashes) { + const auto field = context.member(type, hash); + data += field.offset; + type = field.type; + } + return data; +} +md::Ref music_tile_ref(const md::Context& context, md::Value anchor) { + return md::read(music_inline_path(context, context.address(anchor), anchor.type, + {0x214d4984U, 0x25e4d6c8U})); +} +void music_sync_mods(const md::Context& context) { + if (mods_shelf.manager != context.manager || !mods_shelf.tiles.handle) return; + const auto ref = music_tile_ref(context, mods_shelf.featured_anchor); + const auto handle = ref.handle & ~std::uint64_t{1}; + if (!handle) return; + const md::Value featured{handle, context.type_of(handle)}; + md::require(featured.type == mods_shelf.tiles.type, "Featured tile schema differs."); + const auto source_items = context.field(featured, 0x67223da7U); + unsigned count{}, stride{}; + const auto source = context.array(source_items, 1024, count, stride); + const auto row_type = md::read(md::read(source_items.type) + 0x30); + md::require(stride == 1200 && md::read(md::read(row_type)) == 0xdeb20e0fU, + "Music tile row schema differs."); + std::vector filtered; + unsigned mods{}; + for (unsigned i = 0; i < count; ++i) { + const auto at = reinterpret_cast(source.data() + std::size_t{i} * stride); + const auto tile = md::read(music_inline_path(context, at, row_type, + {0x214d4984U, 0x0fb0d794U, 0xa704272aU, 0xc52416efU, 0x0abf7c31U})); + const auto tile_handle = tile.handle & ~std::uint64_t{1}; + if (!tile_handle) continue; + const md::Value tile_model{tile_handle, context.type_of(tile_handle)}; + const auto playlist_handle = md::read(music_inline_path(context, + context.address(tile_model), tile_model.type, {0xeb4f9b4cU})) & ~std::uint64_t{1}; + if (!playlist_handle) continue; + const md::Value playlist{playlist_handle, context.type_of(playlist_handle)}; + const auto id = context.text(context.field(playlist, 0xa5b84b8aU), 2048); + if (!id.starts_with("mod:")) continue; + filtered.insert(filtered.end(), source.begin() + std::size_t{i} * stride, + source.begin() + std::size_t{i + 1} * stride); + ++mods; + } + const auto destination = context.field(mods_shelf.tiles, 0x67223da7U); + unsigned previous{}, previous_stride{}; + context.array(destination, 1024, previous, previous_stride); + md::require(previous_stride == stride, "Mods tile row schema differs."); + if (previous == mods && mods_shelf.published_rows == filtered) return; + context.array(destination, filtered, mods); + mods_shelf.published_rows = std::move(filtered); + dingosdk::logging::event(dingosdk::logging::Channel::music, + dingosdk::Json{{"event", "music_mods_shelf_fill"}, {"count", mods}, {"featured", count}}.dump().c_str()); +} +void music_poll_mods(std::uintptr_t base) { + if (!mods_shelf.manager || GetTickCount64() < mods_shelf.next_poll) return; + mods_shelf.next_poll = GetTickCount64() + 250; + std::uintptr_t provider{}, manager{}; + if (!read(base + addr::local_music::ui_manager, provider) || !provider || + !read(provider + 0x80, manager) || manager != mods_shelf.manager) return; + try { + game::ModelWriteLock lock(mods_shelf.manager); + music_sync_mods(md::Context{base, mods_shelf.manager}); + } catch (const std::exception& error) { + static std::string last_error; + if (last_error != error.what()) { + last_error = error.what(); + dingosdk::logging::event(dingosdk::logging::Channel::music, + dingosdk::Json{{"event", "music_mods_shelf_waiting"}, {"reason", last_error}}.dump().c_str()); + } + } +} +// Publish the independent Mods shelf before native widgets bind. +std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, std::uint64_t exact_list = 0) { + namespace md = dingosdk::multiplayer::menu_data; + game::ModelWriteLock lock(model); + md::Context shelf{base, model}; + std::string report = "shelf_append"; + unsigned candidates = 0; + const auto roots = exact_list + ? std::vector{{md::Value{exact_list, shelf.type_of(exact_list)}, 0}} + : shelf.roots({0x48455d84U}); + for (const auto& root : roots) { + try { + const auto items = shelf.field(root.model, 0x67223da7U); + unsigned count = 0, stride = 0; + const auto bytes = shelf.array(items, 64, count, stride); + if (stride != 16 || count < 1 || count > 8) continue; + const auto first = shelf.element(items, 0); + const auto element_schema = first.type ? md::read(md::read(first.type)) : 0U; + if (element_schema != 0x62088281U) continue; + std::uint64_t target = 0; + std::memcpy(&target, bytes.data() + 8, sizeof(target)); + std::uint32_t target_schema = 0; + if (target) { try { const auto type = shelf.type_of(target & ~std::uint64_t{1}); target_schema = type ? md::read(md::read(type)) : 0U; } catch (const std::exception&) {} } + ++candidates; + if (target_schema != 0x0e3be640U) continue; + report += " list=0x" + music_diag_hex(root.model.handle, 16) + " count=" + std::to_string(count); + if (count >= 4) { report += " already"; break; } + if (count < 2) { report += " too_few"; break; } + std::uint64_t record_ref = 0, source_vm = 0; + std::memcpy(&record_ref, bytes.data() + stride, 8); + std::memcpy(&source_vm, bytes.data() + stride + 8, 8); + const auto source_type = shelf.type_of(source_vm & ~std::uint64_t{1}); + auto new_vm = shelf.create(md::Schema{0x0e3be640U, 192}, (GetTickCount64() << 12) | 0x4dU); + md::Value new_tiles{}; + try { + const md::Value source_anchor{source_vm, source_type}; + shelf.copy(new_vm, shelf.address(source_anchor)); + const auto tile_ref = music_tile_ref(shelf, source_anchor); + auto tile_handle = tile_ref.handle & ~std::uint64_t{1}; + const auto tile_type = shelf.type(md::Schema{0x74b1ea1dU, 392}); + std::uintptr_t tile_data{}; + if (tile_handle && shelf.type_of(tile_handle) == tile_type) + tile_data = shelf.address(md::Value{tile_handle, tile_type}); + else if (tile_ref.record && md::read(tile_ref.record + 0x18) == tile_type) + tile_data = md::read(tile_ref.record + 0x20); + md::require(tile_data != 0, "Music tile template is not available."); + new_tiles = shelf.create(md::Schema{0x74b1ea1dU, 392}, (GetTickCount64() << 12) | 0x4eU); + shelf.copy(new_tiles, tile_data); + shelf.text(shelf.path(new_tiles, {0xfaec2d05U, 0xcb79482dU, 0x4d8e01b9U}), "Mods"); + // An independent list retains each native row's actions and artwork on publication. + shelf.array(shelf.field(new_tiles, 0x67223da7U), std::span{}, 0); + shelf.set(shelf.path(new_vm, {0x214d4984U, 0x25e4d6c8U}), md::Ref{0, new_tiles.handle}); + } catch (...) { + if (new_tiles.handle) shelf.destroy(new_tiles); + shelf.destroy(new_vm); + throw; + } + std::vector element(stride); + std::memcpy(element.data(), &record_ref, 8); + std::memcpy(element.data() + 8, &new_vm.handle, 8); + auto merged = bytes; + merged.insert(merged.begin(), element.begin(), element.end()); + shelf.array(items, merged, count + 1); + const auto previous = mods_shelf; + mods_shelf = {model, new_vm, new_tiles, md::Value{target, shelf.type_of(target)}, 0}; + // Release our previous generation after the new shelf has been published. + if (previous.manager == model) { + try { shelf.destroy(previous.anchor); shelf.destroy(previous.tiles); } catch (const std::exception&) {} + } + try { music_sync_mods(shelf); } catch (const std::exception&) {} + unsigned after = 0, after_stride = 0; + shelf.array(items, 64, after, after_stride); + report += " new=0x" + music_diag_hex(new_vm.handle, 16) + " appended=" + std::to_string(count + 1) + + " after=" + std::to_string(after); + break; + } catch (const std::exception& error) { report += " error=" + std::string(error.what()); continue; } + } + report += " candidates=" + std::to_string(candidates); + return report; +} +} + + +MusicModelConstruct music_model_construct_original{}; +std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mode, + std::uint64_t id, std::uintptr_t type, bool flag, std::uintptr_t record) { + const auto handle = music_model_construct_original(manager, mode, id, type, flag, record); + PreserveError preserve; + try { + if (!handle || !type || md::read(md::read(type)) != 0x48455d84U) + return handle; + game::ModelWriteLock lock(manager); + md::Context context{local_runtime().base, manager}; + const auto items = context.field(md::Value{handle, type}, 0x67223da7U); + unsigned count{}, stride{}; + const auto bytes = context.array(items, 1024, count, stride); + if (count != 3 || stride != 16) return handle; + constexpr const char* records[] = { + "MusicPlaylistManager_Base_ContentResources/Featured_AnchoredContent_VM", + "MusicPlaylistManager_Base_ContentResources/Liked_AnchoredContent_VM", + "MusicPlaylistManager_Base_ContentResources/NewlyDiscovered_AnchoredContent_VM"}; + for (unsigned item = 0; item < 3; ++item) { + std::uintptr_t item_record{}; + std::memcpy(&item_record, bytes.data() + item * stride, sizeof(item_record)); + // Named record header +0x28 is also used by the native UI dumper. + if (!item_record || md::string(md::read(item_record + 0x28), 256) != records[item]) + return handle; + } + const auto report = music_shelf_append_one(local_runtime().base, manager, handle); + dingosdk::logging::event(dingosdk::logging::Channel::music, + dingosdk::Json{{"event", "music_shelf_append"}, {"report", report}}.dump().c_str()); + } catch (...) {} // A shelf failure must not interrupt the native constructor. + return handle; +} + +void update_music_shelf() { + auto& runtime = local_runtime(); + if (!runtime.active.load(std::memory_order_acquire)) return; + PreserveError preserve; + music_poll_mods(runtime.base); +} +} // namespace dingosdk::profile_runtime diff --git a/Extension/Music/local_music_shelf.h b/Extension/Music/local_music_shelf.h new file mode 100644 index 00000000..3bb80750 --- /dev/null +++ b/Extension/Music/local_music_shelf.h @@ -0,0 +1,15 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" +#include + +namespace dingosdk::profile_runtime { +inline constexpr game::build::Fingerprint music_model_construct_contract{0x1912670, + {0x4c,0x89,0x44,0x24,0x18,0x55,0x56,0x41,0x55,0x41,0x56,0x48,0x8d,0x6c,0x24,0xd1, + 0x48,0x81,0xec,0xf8,0x00,0x00,0x00,0x4d,0x8b,0xf1,0x44,0x0f,0xb6,0xea,0x48,0x8b}}; +using MusicModelConstruct = std::uint64_t (*)(std::uintptr_t, std::uint8_t, std::uint64_t, + std::uintptr_t, bool, std::uintptr_t); +extern MusicModelConstruct music_model_construct_original; +std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mode, + std::uint64_t id, std::uintptr_t type, bool flag, std::uintptr_t record); +void update_music_shelf(); +} // namespace dingosdk::profile_runtime diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index 48633384..2cad4181 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -10,9 +10,6 @@ #include "Engine/Game/Build/20260929/local_music.h" #include "Engine/Game/World/location_travel.h" #include "Extension/Profile/runtime_internal.h" -#include "Extension/UI/NativeMenu/native_menu_data.h" -#include -#include namespace dingosdk::profile_runtime { // Runtime-only music UI hydration: read_music_catalog copies actual registered @@ -32,212 +29,9 @@ void music_release_weak(std::uintptr_t owner) noexcept { MusicUiRuntime& music_ui_runtime() { static auto* r = new MusicUiRuntime; return *r; } -namespace { -// Format model handles for shelf publication logs. -std::string music_diag_hex(std::uint64_t value, int width) { - static constexpr char digits[] = "0123456789abcdef"; - std::string out(static_cast(width), '0'); - for (int i = width - 1; i >= 0; --i, value >>= 4) out[static_cast(i)] = digits[value & 15]; - return out; -} -namespace md = dingosdk::multiplayer::menu_data; -struct ModsShelf { - std::uintptr_t manager{}; - md::Value anchor{}, tiles{}, featured_anchor{}; - ULONGLONG next_poll{}; - std::vector published_rows; -}; -ModsShelf mods_shelf; -// Read inline typed fields without creating child handles during authored construction. -std::uintptr_t music_inline_path(const md::Context& context, std::uintptr_t data, - std::uintptr_t type, std::initializer_list hashes) { - for (auto hash : hashes) { - const auto field = context.member(type, hash); - data += field.offset; - type = field.type; - } - return data; -} -md::Ref music_tile_ref(const md::Context& context, md::Value anchor) { - return md::read(music_inline_path(context, context.address(anchor), anchor.type, - {0x214d4984U, 0x25e4d6c8U})); -} -void music_sync_mods(const md::Context& context) { - if (mods_shelf.manager != context.manager || !mods_shelf.tiles.handle) return; - const auto ref = music_tile_ref(context, mods_shelf.featured_anchor); - const auto handle = ref.handle & ~std::uint64_t{1}; - if (!handle) return; - const md::Value featured{handle, context.type_of(handle)}; - md::require(featured.type == mods_shelf.tiles.type, "Featured tile schema differs."); - const auto source_items = context.field(featured, 0x67223da7U); - unsigned count{}, stride{}; - const auto source = context.array(source_items, 1024, count, stride); - const auto row_type = md::read(md::read(source_items.type) + 0x30); - md::require(stride == 1200 && md::read(md::read(row_type)) == 0xdeb20e0fU, - "Music tile row schema differs."); - std::vector filtered; - unsigned mods{}; - for (unsigned i = 0; i < count; ++i) { - const auto at = reinterpret_cast(source.data() + std::size_t{i} * stride); - const auto tile = md::read(music_inline_path(context, at, row_type, - {0x214d4984U, 0x0fb0d794U, 0xa704272aU, 0xc52416efU, 0x0abf7c31U})); - const auto tile_handle = tile.handle & ~std::uint64_t{1}; - if (!tile_handle) continue; - const md::Value tile_model{tile_handle, context.type_of(tile_handle)}; - const auto playlist_handle = md::read(music_inline_path(context, - context.address(tile_model), tile_model.type, {0xeb4f9b4cU})) & ~std::uint64_t{1}; - if (!playlist_handle) continue; - const md::Value playlist{playlist_handle, context.type_of(playlist_handle)}; - const auto id = context.text(context.field(playlist, 0xa5b84b8aU), 2048); - if (!id.starts_with("mod:")) continue; - filtered.insert(filtered.end(), source.begin() + std::size_t{i} * stride, - source.begin() + std::size_t{i + 1} * stride); - ++mods; - } - const auto destination = context.field(mods_shelf.tiles, 0x67223da7U); - unsigned previous{}, previous_stride{}; - context.array(destination, 1024, previous, previous_stride); - md::require(previous_stride == stride, "Mods tile row schema differs."); - if (previous == mods && mods_shelf.published_rows == filtered) return; - context.array(destination, filtered, mods); - mods_shelf.published_rows = std::move(filtered); - dingosdk::logging::event(dingosdk::logging::Channel::music, - dingosdk::Json{{"event", "music_mods_shelf_fill"}, {"count", mods}, {"featured", count}}.dump().c_str()); -} -void music_poll_mods(std::uintptr_t base) { - if (!mods_shelf.manager || GetTickCount64() < mods_shelf.next_poll) return; - mods_shelf.next_poll = GetTickCount64() + 250; - std::uintptr_t provider{}, manager{}; - if (!read(base + addr::local_music::ui_manager, provider) || !provider || - !read(provider + 0x80, manager) || manager != mods_shelf.manager) return; - try { - game::ModelWriteLock lock(mods_shelf.manager); - music_sync_mods(md::Context{base, mods_shelf.manager}); - } catch (const std::exception& error) { - static std::string last_error; - if (last_error != error.what()) { - last_error = error.what(); - dingosdk::logging::event(dingosdk::logging::Channel::music, - dingosdk::Json{{"event", "music_mods_shelf_waiting"}, {"reason", last_error}}.dump().c_str()); - } - } -} -// Publish the independent Mods shelf before native widgets bind. -std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, std::uint64_t exact_list = 0) { - namespace md = dingosdk::multiplayer::menu_data; - game::ModelWriteLock lock(model); - md::Context shelf{base, model}; - std::string report = "shelf_append"; - unsigned candidates = 0; - const auto roots = exact_list - ? std::vector{{md::Value{exact_list, shelf.type_of(exact_list)}, 0}} - : shelf.roots({0x48455d84U}); - for (const auto& root : roots) { - try { - const auto items = shelf.field(root.model, 0x67223da7U); - unsigned count = 0, stride = 0; - const auto bytes = shelf.array(items, 64, count, stride); - if (stride != 16 || count < 1 || count > 8) continue; - const auto first = shelf.element(items, 0); - const auto element_schema = first.type ? md::read(md::read(first.type)) : 0U; - if (element_schema != 0x62088281U) continue; - std::uint64_t target = 0; - std::memcpy(&target, bytes.data() + 8, sizeof(target)); - std::uint32_t target_schema = 0; - if (target) { try { const auto type = shelf.type_of(target & ~std::uint64_t{1}); target_schema = type ? md::read(md::read(type)) : 0U; } catch (const std::exception&) {} } - ++candidates; - if (target_schema != 0x0e3be640U) continue; - report += " list=0x" + music_diag_hex(root.model.handle, 16) + " count=" + std::to_string(count); - if (count >= 4) { report += " already"; break; } - if (count < 2) { report += " too_few"; break; } - std::uint64_t record_ref = 0, source_vm = 0; - std::memcpy(&record_ref, bytes.data() + stride, 8); - std::memcpy(&source_vm, bytes.data() + stride + 8, 8); - const auto source_type = shelf.type_of(source_vm & ~std::uint64_t{1}); - auto new_vm = shelf.create(md::Schema{0x0e3be640U, 192}, (GetTickCount64() << 12) | 0x4dU); - md::Value new_tiles{}; - try { - const md::Value source_anchor{source_vm, source_type}; - shelf.copy(new_vm, shelf.address(source_anchor)); - const auto tile_ref = music_tile_ref(shelf, source_anchor); - auto tile_handle = tile_ref.handle & ~std::uint64_t{1}; - const auto tile_type = shelf.type(md::Schema{0x74b1ea1dU, 392}); - std::uintptr_t tile_data{}; - if (tile_handle && shelf.type_of(tile_handle) == tile_type) - tile_data = shelf.address(md::Value{tile_handle, tile_type}); - else if (tile_ref.record && md::read(tile_ref.record + 0x18) == tile_type) - tile_data = md::read(tile_ref.record + 0x20); - md::require(tile_data != 0, "Music tile template is not available."); - new_tiles = shelf.create(md::Schema{0x74b1ea1dU, 392}, (GetTickCount64() << 12) | 0x4eU); - shelf.copy(new_tiles, tile_data); - shelf.text(shelf.path(new_tiles, {0xfaec2d05U, 0xcb79482dU, 0x4d8e01b9U}), "Mods"); - // An independent list retains each native row's actions and artwork on publication. - shelf.array(shelf.field(new_tiles, 0x67223da7U), std::span{}, 0); - shelf.set(shelf.path(new_vm, {0x214d4984U, 0x25e4d6c8U}), md::Ref{0, new_tiles.handle}); - } catch (...) { - if (new_tiles.handle) shelf.destroy(new_tiles); - shelf.destroy(new_vm); - throw; - } - std::vector element(stride); - std::memcpy(element.data(), &record_ref, 8); - std::memcpy(element.data() + 8, &new_vm.handle, 8); - auto merged = bytes; - merged.insert(merged.begin(), element.begin(), element.end()); - shelf.array(items, merged, count + 1); - const auto previous = mods_shelf; - mods_shelf = {model, new_vm, new_tiles, md::Value{target, shelf.type_of(target)}, 0}; - // Release our previous generation after the new shelf has been published. - if (previous.manager == model) { - try { shelf.destroy(previous.anchor); shelf.destroy(previous.tiles); } catch (const std::exception&) {} - } - try { music_sync_mods(shelf); } catch (const std::exception&) {} - unsigned after = 0, after_stride = 0; - shelf.array(items, 64, after, after_stride); - report += " new=0x" + music_diag_hex(new_vm.handle, 16) + " appended=" + std::to_string(count + 1) + - " after=" + std::to_string(after); - break; - } catch (const std::exception& error) { report += " error=" + std::string(error.what()); continue; } - } - report += " candidates=" + std::to_string(candidates); - return report; -} -} // CONFIRMED: the authored loader calls the shared constructor at RVA 0x1912670. // The return path exposes the shelf list before its native widgets bind. -MusicModelConstruct music_model_construct_original{}; -std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mode, - std::uint64_t id, std::uintptr_t type, bool flag, std::uintptr_t record) { - const auto handle = music_model_construct_original(manager, mode, id, type, flag, record); - PreserveError preserve; - try { - if (!handle || !type || md::read(md::read(type)) != 0x48455d84U) - return handle; - game::ModelWriteLock lock(manager); - md::Context context{local_runtime().base, manager}; - const auto items = context.field(md::Value{handle, type}, 0x67223da7U); - unsigned count{}, stride{}; - const auto bytes = context.array(items, 1024, count, stride); - if (count != 3 || stride != 16) return handle; - constexpr const char* records[] = { - "MusicPlaylistManager_Base_ContentResources/Featured_AnchoredContent_VM", - "MusicPlaylistManager_Base_ContentResources/Liked_AnchoredContent_VM", - "MusicPlaylistManager_Base_ContentResources/NewlyDiscovered_AnchoredContent_VM"}; - for (unsigned item = 0; item < 3; ++item) { - std::uintptr_t item_record{}; - std::memcpy(&item_record, bytes.data() + item * stride, sizeof(item_record)); - // Named record header +0x28 is also used by the native UI dumper. - if (!item_record || md::string(md::read(item_record + 0x28), 256) != records[item]) - return handle; - } - const auto report = music_shelf_append_one(local_runtime().base, manager, handle); - dingosdk::logging::event(dingosdk::logging::Channel::music, - dingosdk::Json{{"event", "music_shelf_append"}, {"report", report}}.dump().c_str()); - } catch (...) {} // A shelf failure must not interrupt the native constructor. - return handle; -} - bool initialize_music_functions(std::uintptr_t base) { namespace music = addr::local_music; for (const auto& fp : music::music_ui_contracts) { @@ -421,7 +215,6 @@ void update_music_catalog() { auto& s = local_runtime(); auto& ui = music_ui_runtime(); if (!s.active.load(std::memory_order_acquire) || ui.updating || cosmetic_runtime().update_thread != GetCurrentThreadId()) return; PreserveError preserve; std::lock_guard lock(s.native_mutex); - music_poll_mods(s.base); if (!ui.pending) return; if (ui.pending->thread != GetCurrentThreadId() || !music_ui_current(*ui.pending)) { ui.pending.reset(); return; } const auto now = GetTickCount64(); if (now < ui.pending->next_poll) return; diff --git a/Extension/Music/local_music_ui.h b/Extension/Music/local_music_ui.h index 57062f58..5b0e5990 100644 --- a/Extension/Music/local_music_ui.h +++ b/Extension/Music/local_music_ui.h @@ -83,13 +83,4 @@ void music_ui_initialize_hook(std::uint64_t all, std::uint64_t hidden, std::uint void update_music_catalog(); -// Shared authored-model constructor, ABI verified at Skate.exe +0x1912670 and +0x191868f. -inline constexpr game::build::Fingerprint music_model_construct_contract{0x1912670, - {0x4c,0x89,0x44,0x24,0x18,0x55,0x56,0x41,0x55,0x41,0x56,0x48,0x8d,0x6c,0x24,0xd1, - 0x48,0x81,0xec,0xf8,0x00,0x00,0x00,0x4d,0x8b,0xf1,0x44,0x0f,0xb6,0xea,0x48,0x8b}}; -using MusicModelConstruct = std::uint64_t (*)(std::uintptr_t, std::uint8_t, std::uint64_t, - std::uintptr_t, bool, std::uintptr_t); -extern MusicModelConstruct music_model_construct_original; -std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mode, - std::uint64_t id, std::uintptr_t type, bool flag, std::uintptr_t record); } diff --git a/Extension/Profile/local_profile_runtime.cpp b/Extension/Profile/local_profile_runtime.cpp index 637acf02..17599a8f 100644 --- a/Extension/Profile/local_profile_runtime.cpp +++ b/Extension/Profile/local_profile_runtime.cpp @@ -5,6 +5,7 @@ #include "Extension/Customization/local_customization_runtime.h" #include "Extension/Customization/local_player_card_runtime.h" #include "Extension/Music/local_music_ui.h" +#include "Extension/Music/local_music_shelf.h" #include "Extension/News/local_news_runtime.h" #include "Extension/Objects/local_buildkit_labels.h" #include "Extension/Objects/local_buildkit_limits.h" @@ -325,6 +326,7 @@ void update_local_customization() noexcept { try { update_object_categories(); } catch (...) { dingosdk::logging::event(dingosdk::logging::Channel::profile, "{\"event\":\"local_object_categories_failed\",\"operation\":\"update\"}"); } update_music_catalog(); + update_music_shelf(); try { if (cosmetic_runtime().update_thread == GetCurrentThreadId() && !cosmetic_runtime().items.empty()) update_player_card(); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 5989d22a..84ecdad9 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -142,6 +142,7 @@ add_library(dingosdk_runtime SHARED Extension/Objects/local_object_runtime.cpp Extension/Music/local_music_assets.cpp Extension/Music/local_music_ui.cpp + Extension/Music/local_music_shelf.cpp Extension/Objects/local_buildkit_labels.cpp Extension/Objects/local_buildkit_limits.cpp Extension/Progression/local_rip_score_runtime.cpp diff --git a/contrib/native-music-ui.md b/contrib/native-music-ui.md index 8510c067..8e3ea857 100644 --- a/contrib/native-music-ui.md +++ b/contrib/native-music-ui.md @@ -5,7 +5,8 @@ existing Music Playlist Manager. It is a worked example of changing native UI through its data models, while reusing the game's layout, playlist tiles and selection behavior. -The implementation lives in [local_music_ui.cpp](../Extension/Music/local_music_ui.cpp). +The shelf hook and model management live in [local_music_shelf.cpp](../Extension/Music/local_music_shelf.cpp). +The playlist and song UI publication remains in [local_music_ui.cpp](../Extension/Music/local_music_ui.cpp). These are C++ runtime changes. A music mod can supply playlists through the existing music-mod pipeline; adding a new native shelf requires runtime code such as this hook. There is no general declarative UI-mod API established here. @@ -171,7 +172,7 @@ The shelf hook calls the original constructor first. On its return, it recognize the exact music list and inserts the new entry before the native caller continues to bind widgets. It returns the original handle unchanged. Installation checks an exact 32-byte executable fingerprint; a mismatch leaves this hook uninstalled. -See [local_music_ui.h](../Extension/Music/local_music_ui.h) for the contract and +See [local_music_shelf.h](../Extension/Music/local_music_shelf.h) for the contract and [local_profile_runtime.cpp](../Extension/Profile/local_profile_runtime.cpp) for installation. That profile-runtime change only installs the music hook; it does not change cosmetic ownership or progression. From d4d3549799f4f5b9aff6009215720ae3a0bae956 Mon Sep 17 00:00:00 2001 From: ReSkate local Date: Mon, 5 Oct 2026 05:21:00 +0100 Subject: [PATCH 044/109] Music: keep shelf code out of playlist UI module --- Extension/Music/local_music_shelf.cpp | 2 ++ Extension/Music/local_music_ui.cpp | 5 +---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Extension/Music/local_music_shelf.cpp b/Extension/Music/local_music_shelf.cpp index a9f614a2..1ffd9563 100644 --- a/Extension/Music/local_music_shelf.cpp +++ b/Extension/Music/local_music_shelf.cpp @@ -188,6 +188,8 @@ std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, st } +// CONFIRMED: the authored loader calls the shared constructor at RVA 0x1912670. +// The return path exposes the shelf list before its native widgets bind. MusicModelConstruct music_model_construct_original{}; std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mode, std::uint64_t id, std::uintptr_t type, bool flag, std::uintptr_t record) { diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index 2cad4181..b249242d 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -29,9 +29,6 @@ void music_release_weak(std::uintptr_t owner) noexcept { MusicUiRuntime& music_ui_runtime() { static auto* r = new MusicUiRuntime; return *r; } - -// CONFIRMED: the authored loader calls the shared constructor at RVA 0x1912670. -// The return path exposes the shelf list before its native widgets bind. bool initialize_music_functions(std::uintptr_t base) { namespace music = addr::local_music; for (const auto& fp : music::music_ui_contracts) { @@ -290,4 +287,4 @@ void update_music_catalog() { dingosdk::logging::event(dingosdk::logging::Channel::music, dingosdk::Json{{"event", "native_music_ui_complete"}, {"playlists", playlist_count}, {"songs", song_count}, {"source", "runtime_music_assets"}}.dump().c_str()); } catch (...) { retry = false; dingosdk::logging::event(dingosdk::logging::Channel::music, "{\"event\":\"native_music_ui_publish_failed\"}"); } } -} +} \ No newline at end of file From 5bfe4f7bfafa58db8c816090778078f3d5db2c73 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Mon, 5 Oct 2026 00:00:45 -0500 Subject: [PATCH 045/109] tag preview, separate switches and per-item colors - Split the single switch into "Show my tag" and "Show my items", and show the tag as chat and nametags draw it. - Color whatever is equipped instead of the one hoodie and pink board: top, bottoms, shoes, socks, hat, glasses, outfit, costume, deck, grip tape, trucks and wheels, each set on its own. - Each item can use its list's animation (rainbow, red or gold), a gradient between two picked colors, one solid color, or be off. - The choices travel with the player's appearance so everyone sees them. Protocol version 41. --- Engine/Game/Multiplayer/session_model.h | 18 +- Extension/Customization/developer_board.cpp | 46 +- Extension/Customization/developer_board.h | 5 +- .../Customization/developer_board_material.h | 138 ++---- Extension/Customization/developer_hoodie.cpp | 48 +- Extension/Customization/developer_hoodie.h | 5 +- .../Customization/developer_hoodie_material.h | 442 ++++++++++++------ Extension/Multiplayer/Net/protocol.cpp | 23 +- Extension/Multiplayer/Net/protocol.h | 2 +- Extension/Multiplayer/Remote/cosmetics.h | 25 +- Extension/Multiplayer/Session/session.cpp | 18 +- .../Multiplayer/Session/session_commands.cpp | 59 ++- .../Multiplayer/Session/session_internal.h | 19 +- .../Multiplayer/Session/session_send.cpp | 10 +- .../Multiplayer/Session/session_view.cpp | 28 +- .../Test/developer_identity_tests.cpp | 228 ++++++--- Extension/Multiplayer/Test/mesh_tests.cpp | 42 +- Extension/Multiplayer/Test/protocol_tests.cpp | 30 +- Extension/Multiplayer/developer_identity.h | 13 +- Extension/UI/Overlay/multiplayer_menu.cpp | 132 +++++- 20 files changed, 910 insertions(+), 421 deletions(-) diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 15db4660..a2f7ef58 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -114,9 +114,23 @@ struct MultiplayerModel { // Local: bad words in chat names and messages show as **** (on by default). bool chat_filter{true}; // Local: the tag the ReSkate backend gives this player ("Dev", "Creator" or "Homie"; empty - // for most players), and whether they show it and its animated items, to everyone. + // 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; - bool identity_marks{true}; + 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", "RED" or "GOLD"), 1 off, 2 a gradient + // between the two colours they picked, 3 the first of them alone. speed: 0 normal, 1 slow, + // 2 fast. + struct IdentityStyle { + std::string name; + int mode{}, speed{}; + std::array from{}, to{}; + }; + std::vector identity_styles; + 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. diff --git a/Extension/Customization/developer_board.cpp b/Extension/Customization/developer_board.cpp index 6e6d6c62..5b5442f9 100644 --- a/Extension/Customization/developer_board.cpp +++ b/Extension/Customization/developer_board.cpp @@ -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; +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 auto animation = developer_board_detail::animation_for(steam_id); - const bool listed = animation != developer_board_detail::Animation::none; + 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 (!listed && !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 publisher{}; @@ -65,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, animation, GetTickCount64(), write, publish); - const auto wanted = static_cast(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 (listed && !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 (listed && state.count) - report_board(board, steam_id, "animation 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 (...) {} @@ -82,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 && multiplayer::own_marks_shown() ? social->local.id : 0; - if (developer_board_detail::animation_for(id) == developer_board_detail::Animation::none && !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 diff --git a/Extension/Customization/developer_board.h b/Extension/Customization/developer_board.h index 678eb74a..fbb203e3 100644 --- a/Extension/Customization/developer_board.h +++ b/Extension/Customization/developer_board.h @@ -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 diff --git a/Extension/Customization/developer_board_material.h b/Extension/Customization/developer_board_material.h index bda9d0ae..fb0c95bd 100644 --- a/Extension/Customization/developer_board_material.h +++ b/Extension/Customization/developer_board_material.h @@ -4,11 +4,14 @@ namespace dingosdk { namespace developer_board_detail { +using developer_hoodie_detail::Binding; using developer_hoodie_detail::Color; -using developer_hoodie_detail::material_hash; -using developer_hoodie_detail::Animation; -using developer_hoodie_detail::animation_for; +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::settle_publishes; inline constexpr std::uint64_t graphic_color = 0x873f48af36e50d59ULL; inline constexpr std::uint64_t base_color = 0x8c46cd354b18a9e2ULL; @@ -17,59 +20,40 @@ inline constexpr std::array 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 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(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{}; - std::uint8_t settling{}; // publishes still owed after going back to `original` -}; inline constexpr std::size_t max_bindings = targets.size() * 4; -struct Materials { - std::uintptr_t component{}, appearance{}; - std::array bindings{}; - std::size_t count{}; - bool pending{}; -}; +using developer_hoodie_detail::Materials; template std::uintptr_t parameter(Memory &m, std::uintptr_t material, std::uint64_t key) { const auto buckets = m.ptr(material, 0x268); @@ -89,21 +73,9 @@ template Materials materials(Read &read, std::uintptr_t base, std::u using namespace multiplayer; CosmeticMemory 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(out.component, 0x10a) || m.template get(out.component, 0x10d); - const auto recipe = m.ptr(out.component, 0x158); - const auto count = m.count(recipe, sizeof(NativeCosmeticItem), max_cosmetic_slots); - std::array eligible{}; - for (std::size_t i = 0; i < count; ++i) { - const auto item = m.template get(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(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(info) == 0x885eff59 && @@ -130,8 +102,7 @@ template 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); @@ -156,66 +127,17 @@ template Materials materials(Read &read, std::uintptr_t base, std::u color = m.template get(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 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 -void animate(DeveloperBoardState &state, const Materials &live, std::uintptr_t entity, std::uint64_t generation, - Animation animation, 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 = item_color(animation, milliseconds); - std::array 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 (animation != Animation::none && 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)) { - // Back to the part's own color, which takes a few publishes to show (settle_publishes): - // `wrote` has the part published for each of them. - wrote = binding.color == saved->original || write(binding, saved->original); - const std::uint8_t left = saved->settling ? saved->settling - 1 : settle_publishes; - if (!wrote) next.colors[next.count++] = *saved; - else if (left) next.colors[next.count++] = {binding, saved->original, saved->original, left}; - } - 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 diff --git a/Extension/Customization/developer_hoodie.cpp b/Extension/Customization/developer_hoodie.cpp index e07f6b78..a5541ac0 100644 --- a/Extension/Customization/developer_hoodie.cpp +++ b/Extension/Customization/developer_hoodie.cpp @@ -5,6 +5,7 @@ #include "Extension/Multiplayer/Remote/native_skater.h" #include #include +#include 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,13 +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; +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 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 auto animation = developer_hoodie_detail::animation_for(steam_id); - const bool listed = animation != developer_hoodie_detail::Animation::none; + 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 (!listed && !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 prefix{}; @@ -58,13 +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, animation, 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 (listed && state.count) - report_hoodie(entity, steam_id, "animation active on the owned hoodie material", logging::Level::info); - else if (listed && 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 (...) { @@ -79,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 && multiplayer::own_marks_shown() ? social->local.id : 0; - if (developer_hoodie_detail::animation_for(id) == developer_hoodie_detail::Animation::none && !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 (...) {} } diff --git a/Extension/Customization/developer_hoodie.h b/Extension/Customization/developer_hoodie.h index 3842763a..97235e98 100644 --- a/Extension/Customization/developer_hoodie.h +++ b/Extension/Customization/developer_hoodie.h @@ -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 diff --git a/Extension/Customization/developer_hoodie_material.h b/Extension/Customization/developer_hoodie_material.h index a649cd8b..29e0a48f 100644 --- a/Extension/Customization/developer_hoodie_material.h +++ b/Extension/Customization/developer_hoodie_material.h @@ -1,50 +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 #include +#include #include +#include #include +#include +#include +#include +#include +// 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. -// 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. The other three do not show. -inline constexpr std::array 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 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 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; +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 bindings{}; - std::size_t count{}; - bool eligible{}; + std::uintptr_t component{}, appearance{}; + std::vector 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 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(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(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(milliseconds % period) * (6.f / static_cast(period)); const auto sector = static_cast(hue); constexpr float high = .8f, low = .025f; const auto rising = low + (high - low) * (hue - static_cast(sector)); @@ -58,164 +101,267 @@ inline Color rainbow(std::uint64_t milliseconds) noexcept { default: return {high, low, falling}; } } -// What a listed player's hoodie and board do: a developer's cycle the rainbow, a content -// creator's go 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. -enum class Animation : std::uint8_t { none, rainbow, red, gold }; -inline Animation animation_for(std::uint64_t steam_id) noexcept { - using multiplayer::IdentityList; - switch (multiplayer::identity_mark(steam_id).value_or(IdentityList::count)) { - case IdentityList::developer: return Animation::rainbow; - case IdentityList::content_creator: return Animation::red; - case IdentityList::homie: return Animation::gold; - default: return Animation::none; - } +// 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 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 &picked) noexcept { + Color out{}; + for (std::size_t i = 0; i < out.size(); ++i) + out[i] = std::max(.004f, std::pow(static_cast(picked[i]) / 255.f, 2.2f) * .8f); + return out; } -inline Color item_color(Animation animation, std::uint64_t milliseconds) noexcept { - if (animation == Animation::rainbow) return rainbow(milliseconds); - // 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, and back. - constexpr std::array red{{{.2f, .004f, .01f}, {.8f, .03f, .03f}, {.8f, .05f, .13f}}}; - constexpr std::array gold{{{.34f, .12f, .006f}, {.8f, .5f, .06f}, {.8f, .64f, .11f}}}; - const auto &stops = animation == Animation::red ? red : gold; - // 0 to 2 and back, every three seconds. - const auto along = 1.f - std::cos(static_cast(milliseconds % 3000) * (6.2831853f / 3000.f)); - const auto &from = stops[along < 1.f ? 0 : 1], &to = stops[along < 1.f ? 1 : 2]; +// The colours the pickers start from: about where each list's own animation begins and ends. +inline std::pair, std::array> 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}}; + 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. No style asks for the rainbow: +// it is a developer's standard, and nobody else's. +inline ItemAnimation item_animation(std::optional 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; + const std::uint64_t normal = out.rainbow ? 8000 : 3000; + out.period = style.speed == 1 ? normal * 2 : style.speed == 2 ? normal / 2 : normal; + 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 red{{{.2f, .004f, .01f}, {.8f, .03f, .03f}, {.8f, .05f, .13f}}}; + constexpr std::array gold{{{.34f, .12f, .006f}, {.8f, .5f, .06f}, {.8f, .64f, .11f}}}; + out.stops = *mark == IdentityList::content_creator ? red : 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(milliseconds % animation.period) * + (6.2831853f / static_cast(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 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(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 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(c - '0'); + else if (c >= 'a' && c <= 'f') byte = byte * 16 + static_cast(c - 'a' + 10); + else valid = false; + } + return static_cast(byte); + }; + for (auto &style : styles) { + style.mode = static_cast(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 ParameterMap parameter_map(Memory &m, std::uintptr_t material) { + ParameterMap out{m.ptr(material, 0x268), 0, m.template get(material, 0x270), + m.template get(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 +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(node, 8) != key) continue; + native = m.template get(node); + const auto type = m.ptr(node, 0x20); + if (type < base || type >= base + 0x09144000 || native.size != sizeof(Color) || native.count != 1 || + !(m.template get(node, 0x28) & 1) || m.template get(type, 8) != native.type) + return 0; + const auto info = m.ptr(type); + if (m.template get(info) != 0x885eff59U || m.template get(info, 6) != sizeof(Color)) return 0; + color = m.template get(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 Materials materials(Read &read, std::uintptr_t base, std::uintptr_t entity) { using namespace multiplayer; CosmeticMemory 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(out.component, 0x10a) || m.template get(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(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(item.asset)); - out.eligible = !pending && (name.empty() || name == asset); - } - } + out.pending = m.template get(out.component, 0x10a) || m.template get(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(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(slot - slots.begin()); + // A slot with nothing in it, or with an item still on its way, has no materials. + if (!(m.template get(item, 0xb0) & 2)) continue; const auto buckets = m.ptr(item, 0x1f0); const auto size = m.template get(item, 0x1f8), total = m.template get(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(node) != hash) continue; - std::array 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(material, 0x270), count = m.template get(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(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(parameter, 2) == sizeof(Color) && - m.template get(m.ptr(type)) == 0x885eff59U && - m.template get(m.ptr(type), 6) == sizeof(Color) && m.template get(parameter, 6) == 1 && - (m.template get(parameter, 0x28) & 1) && - m.template get(type, 8) == m.template get(parameter, 4), - "Hoodie color parameter type differs."); - const auto color = m.template get(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 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(node) % size != bucket) break; // the next bucket's + m.check(visited++ < total, "Skater material chain exceeds bounds."); + std::array 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(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 colors{}; - std::size_t count{}; - std::uint8_t settling{}; // publishes still owed after the animation went off (settle_publishes) -}; -namespace developer_hoodie_detail { -// The renderer shows a color one publish late. While the animation runs the next tick's -// publish brings it; once it is off, the item's own colors need publishing again after the -// write that brought them back, or the item keeps the last animated color on screen (seen -// in game, 2026-10-04). The saved colors are kept for these extra publishes. +// 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; -// Writes are only to freshly rediscovered bindings. Restoration additionally -// requires the current color to still be ours; a new native preset takes precedence. -template -void animate(DeveloperHoodieState &state, const Materials &live, std::uintptr_t entity, std::uint64_t generation, - Animation animation, 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 = animation != Animation::none && live.eligible; - const auto color = item_color(animation, milliseconds); - const std::uint8_t settling = enabled ? 0 : state.settling ? state.settling - 1 : settle_publishes; - DeveloperHoodieState next; - next.settling = settling; - if (enabled || settling) { - 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) { +// 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 +void animate(ItemState &state, const Materials &live, std::uintptr_t entity, std::uint64_t generation, + const std::array &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 saved(live.bindings.size()); + std::array 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)) { - if (binding.color != saved->original) write(binding, saved->original); - changed = true; - if (settling) next.colors[next.count++] = {binding, saved->original, saved->original}; + 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; } - if (!next.count) next = {}; - 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 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(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 diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index 5fa607f8..559ab567 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -237,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 + 13; + 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) @@ -445,7 +445,13 @@ std::vector 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.unmarked ? 1 : 0, 1); + 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(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; @@ -676,8 +682,17 @@ std::optional decode(std::span bytes) noexcept { p.appearance.card.emblem = static_cast(r.integer(4)); p.appearance.card.title = static_cast(r.integer(4)); const auto flags = r.integer(1); - p.appearance.unmarked = flags == 1; - if (flags > 1 || !p.map || !valid_appearance(p.appearance)) + 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(r.integer(1)); + for (auto &part : style.from) part = static_cast(r.integer(1)); + for (auto &part : style.to) part = static_cast(r.integer(1)); + style.speed = static_cast(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); diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index a2fdb89f..a86d70ff 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -25,7 +25,7 @@ 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 = 40; +constexpr std::uint16_t protocol_version = 41; 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; diff --git a/Extension/Multiplayer/Remote/cosmetics.h b/Extension/Multiplayer/Remote/cosmetics.h index d49c3e44..7b152912 100644 --- a/Extension/Multiplayer/Remote/cosmetics.h +++ b/Extension/Multiplayer/Remote/cosmetics.h @@ -28,12 +28,31 @@ 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 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 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; +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 the marks the ReSkate backend gives them (developer_identity.h): - // nobody shows their tag or animates their hoodie and board. - bool unmarked{}; + // 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 diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 0ba82454..2500dff2 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -257,7 +257,11 @@ void publish_party(Session &s) { s.custom_nametags = profile_runtime::local_preference("CustomNametags").value_or(true); s.chat_visible = profile_runtime::local_preference("ChatVisible").value_or(true); s.chat_filter = profile_runtime::local_preference("ChatFilter").value_or(true); - show_own_marks(profile_runtime::local_preference("IdentityMarks").value_or(true)); + 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: parties players form hold up to eight (the game's Party panel rows), @@ -427,7 +431,7 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui }; // 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_marks(p) ? p.member.id : std::uint64_t{}; + return steam_vouched(s, p) && shows_items(p) ? p.member.id : std::uint64_t{}; }; each_active_peer(s, [&](Peer &p) { // A dedicated server has no skater to show. @@ -437,8 +441,10 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui // 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) { - update_developer_hoodie(s.base, remote_skater_entity(), developer_id(p), remote_skater_generation(), p.developer_hoodie); - update_developer_board(s.base, remote_board_entity(), developer_id(p), remote_skater_generation(), p.developer_board); + 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); @@ -515,9 +521,9 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui } } update_developer_hoodie(s.base, p.visible ? remote_skater_entity() : 0, developer_id(p), - remote_skater_generation(), p.developer_hoodie); + 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); + remote_skater_generation(), p.developer_board, mark_styles(p)); if (!p.visible) { stop_remote_audio(); p.presented_audio.reset(); diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index bf0c1f3b..c3f67082 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -324,14 +325,42 @@ std::string edit_nametag_style(Session &s, std::string_view argument) { 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 and items. -// Their next appearance packet tells everyone (session_send.cpp). -std::string edit_marks(Session &, std::string_view argument) { - const auto shown = parse_switch(argument, own_marks_shown()); - if (!shown) return "Use on, off, or toggle for your tag and items."; - show_own_marks(*shown); - profile_runtime::set_local_preference("IdentityMarks", *shown); - return *shown ? "Your tag and items show." : "Your tag and items are hidden, for you and everyone you skate with."; +// 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 +// " " (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 > 3 || + from > 0xffffff || to > 0xffffff || speed > 2) + return "That is not a cosmetic style."; + const auto colour = [](unsigned value) { + return std::array{static_cast(value >> 16), static_cast(value >> 8), + static_cast(value)}; + }; + auto styles = developer_hoodie_detail::own_styles.load(); + styles[item] = {static_cast(mode), colour(from), colour(to), static_cast(speed)}; + 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." : ": 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) { @@ -445,14 +474,18 @@ 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) { - // A player's own marks are theirs offline as well, where their hoodie and board still animate. - if (launcher::offline_mode() && action != "marks") 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 != "nametags" && action != "nametag-style" && action != "chat-visible" && action != "chat-filter" && - action != "marks" && + !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" && @@ -476,7 +509,7 @@ 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() && action != "marks") + 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"; @@ -632,7 +665,7 @@ std::string command(std::string_view action, std::string_view argument, std::str {"world-layer-sync", edit_world_layer_sync}, {"distances", edit_distances}, {"nametags", edit_nametags}, {"nametag-style", edit_nametag_style}, - {"marks", edit_marks}, + {"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}}; for (const auto &[name, edit] : settings) if (action == name) { diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index db57058c..46ca45ad 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -310,11 +310,22 @@ inline bool dedicated_host(const Session &s) { return s.mode == Mode::join && ga inline bool steam_vouched(const Session &s, const Peer &peer) { return s.mode != Mode::join || peer.member.id == s.host_id || peer.direct_ready; } -// Whether a player shows the marks the backend gives them: not until their appearance has -// arrived, and not when it says they turned them off (Appearance::unmarked). -inline bool shows_marks(const Peer &peer) { +// 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->unmarked; + 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); diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index c37e961d..604092c0 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -596,9 +596,13 @@ 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; auto appearance = capture_cosmetics(s.base, local, s.cosmetic_capture_status); - // The player's choice to go without their marks travels with their outfit, so a change - // is sent like one and reaches players who join later. - if (appearance) appearance->unmarked = !own_marks_shown(); + // 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; diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 11e5538e..21c9ac75 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -173,8 +173,28 @@ void publish(Session &s, const NativeFrame *local) { 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)) view.identity_tag = mark_role(*mark).second; - view.identity_marks = own_marks_shown(); + 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" : "GOLD"; + 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. + const bool picked = styles[i].mode == MarkMode::gradient || styles[i].mode == MarkMode::solid || + styles[i].from != styles[i].to || styles[i].from != std::array{}; + 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 = static_cast(styles[i].mode); + shown.speed = styles[i].speed; + for (std::size_t part = 0; part < 3; ++part) + shown.from[part] = static_cast(from[part]) / 255.f, shown.to[part] = static_cast(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; @@ -519,8 +539,8 @@ std::pair player_role(Session &s, std::uint64_t send 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 marks off (the Special page), which their appearance tells everyone. - if (vouched && (local ? own_marks_shown() : shows_marks(*peer))) + // have turned their tag off (the Special page), which their appearance tells everyone. + if (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"}; diff --git a/Extension/Multiplayer/Test/developer_identity_tests.cpp b/Extension/Multiplayer/Test/developer_identity_tests.cpp index c6df8cdf..53df6ed7 100644 --- a/Extension/Multiplayer/Test/developer_identity_tests.cpp +++ b/Extension/Multiplayer/Test/developer_identity_tests.cpp @@ -82,33 +82,41 @@ void lookup() { 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"); - check(own_marks_shown(), "A player's marks start hidden"); - show_own_marks(false); - check(!own_marks_shown() && identity_mark(dev) == L::developer, "Hiding their own marks did not take, or changed the lists"); - show_own_marks(true); + // 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: a developer's rainbow, a content creator's red, a homie's gold. +// 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; - publish_identity_lists({{{dev}, {dev, homie, creator}, {dev, creator}}}); - check(animation_for(dev) == Animation::rainbow && animation_for(creator) == Animation::red && - animation_for(homie) == Animation::gold, - "A list did not give its animation, a developer's first and a content creator's before a homie's"); - check(animation_for(stranger) == Animation::none && animation_for(0) == Animation::none, "An unlisted player's items animate"); - check(item_color(Animation::rainbow, 0) == rainbow(0) && item_color(Animation::rainbow, 3000) == rainbow(3000), - "The developer's rainbow changed"); - // Each goes from a deep shade to a bright one, on into the colour next to it, and back + 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 : {Animation::red, Animation::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"); + 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 == Animation::red) { + 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"); @@ -119,81 +127,177 @@ void items() { } 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); + 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"); + 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"); } -// Turning the animation off (the Special page, or a list that no longer has the player) puts an -// item's own colours back, and publishes them the extra times the renderer needs to show them. +// 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; + 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}; - // What the game holds for each colour region; the materials are read afresh each tick, as in game. - std::array held{black, navy}; + 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 held{black, navy, neutral, neutral}; dingosdk::DeveloperHoodieState state; Materials live; - live.component = 1, live.appearance = 2, live.item = 3, live.eligible = true, live.count = 2; - live.bindings[0] = {10, 100, 7, color_keys[0], black}; - live.bindings[1] = {10, 101, 7, color_keys[3], navy}; - int publishes{}; + 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 published; + int writes{}; auto write = [&](const Binding &binding, const Color &color) { held[binding.node - 100] = color; + ++writes; return true; }; - auto publish = [&](std::uintptr_t) { ++publishes; }; - const auto tick = [&](Animation animation, std::uint64_t at) { + 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, animation, at, write, publish); + animate(state, live, 5, 6, animations, at, write, publish); }; - tick(Animation::red, 0); - tick(Animation::red, 700); - check(state.count == 2 && held[0] == item_color(Animation::red, 700) && held[1] == held[0] && publishes == 2, - "The animation did not colour the hoodie"); - publishes = 0; - tick(Animation::none, 800); - check(held[0] == black && held[1] == navy && publishes == 1, "Turning it off did not bring the hoodie's own colours back"); - for (std::uint64_t at = 900; at < 910; ++at) tick(Animation::none, at); - check(publishes == 1 + settle_publishes && !state.count && held[0] == black && held[1] == navy, - "The own colours were not published the extra times, or the hoodie was not left alone after"); + using Items = std::vector; + 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(Animation::gold, 1000); + tick(wearing(gold, none), 1000); held[0] = teal; - tick(Animation::none, 1100); + 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(Animation::none, at); - tick(Animation::red, 2000); - tick(Animation::none, 2100); - check(held[0] == teal && held[1] == navy, "A second round lost the hoodie's own colours"); + 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); - // The board does the same, part by part. + // 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 painted{black, navy}; + std::array painted{black, board::shader_default}; dingosdk::DeveloperBoardState deck; - board::Materials parts; - parts.component = 1, parts.appearance = 2, parts.count = 2; - parts.bindings[0] = {30, 10, 100, 7, board::parameter_key(board::base_color, 1), black, true}; - parts.bindings[1] = {31, 11, 101, 7, board::parameter_key(board::base_color, 1), navy, true}; + 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{}; - auto paint = [&](const board::Binding &binding, const Color &color) { - painted[binding.node - 100] = color; + 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; }; - const auto roll = [&](Animation animation, std::uint64_t at) { - for (std::size_t i = 0; i < painted.size(); ++i) parts.bindings[i].color = painted[i]; - board::animate(deck, parts, 5, 6, animation, at, paint, show); + using Board = std::array; + 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(Animation::gold, 0); - roll(Animation::gold, 700); - check(deck.count == 2 && painted[0] == item_color(Animation::gold, 700) && painted[1] == painted[0], "The animation did not colour the board"); + 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 = 800; at < 810; ++at) roll(Animation::none, at); - check(painted[0] == black && painted[1] == navy && shown == 2 * (1 + settle_publishes) && !deck.count, - "Turning it off did not bring the board's own colours back and publish each part the extra times"); + 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 : the deployed backend, read the way the game reads it. Not diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 42953737..e0895a89 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -35,8 +35,10 @@ bool clear_lobby_guest_objects() { ++simulated_guest_wipes; return true; } namespace dingosdk { bool teleport_local_skater(const std::array&) { return true; } void update_board_lock(std::uintptr_t, std::uintptr_t, bool) noexcept {} -void update_developer_hoodie(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperHoodieState &) noexcept {} -void update_developer_board(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperBoardState &) noexcept {} +void update_developer_hoodie(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperHoodieState &, + const multiplayer::MarkStyles &) noexcept {} +void update_developer_board(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperBoardState &, + const multiplayer::MarkStyles &) noexcept {} } namespace dingosdk::multiplayer { // The backend's lists are not read here: a check lists the players it means. @@ -675,23 +677,43 @@ void role_checks() { "A developer on every list is not shown as a developer"); check(player_role(host, id(third), false) == Role{nametag_creator, "Creator"}, "A content creator who is also a homie is not shown as a creator"); - // A player who has turned their marks off (the Special page) is whatever they are in the lobby, - // to everyone: their appearance carries the choice through the host. + // A player who has turned their tag off (the Special page) is whatever they are in the lobby, + // to everyone: their appearance carries the choice through the host. Their items are a + // separate choice, which leaves the tag alone. auto plain = packet(host, PacketKind::cosmetics, sim.now); plain.appearance = {{skater_recipe_key, 2, {}, {{1, "Outfit0", {}}}}, {board_recipe_key, 1, {}, {{2, "Deck", {}}}}}; - plain.appearance.unmarked = true; + plain.appearance.hide_items = true; + host.cosmetic_packet = encode_wire(plain); + broadcast(host, plain, true, false, sim.now); + sim.run(20); + const auto told = [&](const Session &s) { + const auto *peer = find_peer(const_cast(s), id(host)); + return peer && !shows_items(*peer) && shows_tag(*peer); + }; + check(told(first) && told(second) && player_role(first, id(host), false) == Role{nametag_homie, "Homie"}, + "A homie who turned their items off was not seen to, or lost their tag"); + // A later packet, as the host's game would send after the change. + const auto look = plain.appearance; + plain = packet(host, PacketKind::cosmetics, sim.now); + plain.appearance = look; + plain.appearance.hide_items = false, plain.appearance.hide_tag = true; host.cosmetic_packet = encode_wire(plain); broadcast(host, plain, true, false, sim.now); sim.run(20); check(player_role(first, id(host), false) == Role{nametag_host, "Host"} && player_role(second, id(host), false) == Role{nametag_host, "Host"}, - "A homie who turned their marks off still shows as a homie"); - check(player_role(host, id(first), false) == Role{nametag_creator, "Creator"}, "One player's choice hid another's marks"); - show_own_marks(false); + "A homie who turned their tag off still shows as a homie"); + const auto *shown = find_peer(first, id(host)); + check(shown && shows_items(*shown), "Turning their tag off turned a player's items off"); + check(player_role(host, id(first), false) == Role{nametag_creator, "Creator"}, "One player's choice hid another's tag"); + show_own_items(false); + check(player_role(first, id(first), true) == Role{nametag_creator, "Creator"}, "Turning their items off hid a player's own tag"); + show_own_items(true); + show_own_tag(false); check(player_role(first, id(first), true) == Role{nametag_white, {}} && player_role(host, id(first), false) == Role{nametag_creator, "Creator"}, - "A player's own choice did not hide their marks from themselves, or hid them from others before they were told"); - show_own_marks(true); + "A player's own choice did not hide their tag from themselves, or hid it from others before they were told"); + show_own_tag(true); // A chat line carries its sender's role. check(send_chat(first, "new video is up").empty(), "A guest's chat was refused"); sim.run(20); diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index 32bffce6..223296db 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -331,14 +331,30 @@ void cosmetics_codec() { {board_recipe_key, 1, {0x3f800000}, {{13, "Own_Deck", {7}}}}}; const auto bytes = encode(p); const auto decoded = decode(bytes); - check(decoded && decoded->appearance == p.appearance && !decoded->appearance.unmarked, + check(decoded && decoded->appearance == p.appearance && !decoded->appearance.hide_tag && !decoded->appearance.hide_items, "Cosmetic fields or opaque parameter bits were lost"); - // A player's choice to go without their backend marks travels with their outfit. - auto unmarked = p; - unmarked.appearance.unmarked = true; - const auto told = decode(encode(unmarked)); - check(told && told->appearance.unmarked && told->appearance == unmarked.appearance && !(told->appearance == p.appearance), - "A player's choice to hide their marks was lost"); + // A player's choices to go without their backend tag, or its animated items, travel with + // their outfit, each on its own. + for (const auto &[tag, items] : {std::pair{true, false}, std::pair{false, true}, std::pair{true, true}}) { + auto hidden = p; + hidden.appearance.hide_tag = tag, hidden.appearance.hide_items = items; + const auto told = decode(encode(hidden)); + check(told && told->appearance.hide_tag == tag && told->appearance.hide_items == items && + told->appearance == hidden.appearance && !(told->appearance == p.appearance), + "A player's choice to hide their tag or their items was lost"); + } + // So does how they have each marked cosmetic animate. + auto styled = p; + styled.appearance.marks[0] = {MarkMode::gradient, {1, 2, 3}, {250, 251, 252}, 2}; + styled.appearance.marks[4] = {MarkMode::off, {}, {}, 1}; + styled.appearance.marks.back() = {MarkMode::solid, {9, 8, 7}, {}, 0}; + const auto kept = decode(encode(styled)); + check(kept && kept->appearance == styled.appearance && kept->appearance.marks[0].to[2] == 252 && + kept->appearance.marks.back().mode == MarkMode::solid && kept->appearance.marks.back().from[0] == 9 && + kept->appearance.marks[1] == MarkStyle{} && !(kept->appearance == p.appearance), + "A player's cosmetic styles were lost"); + check(!valid_mark_style({static_cast(4), {}, {}, 0}) && !valid_mark_style({MarkMode::standard, {}, {}, 3}), + "A cosmetic style no menu can make was accepted"); for (std::size_t n = 0; n < bytes.size(); ++n) check(!decode(std::span(bytes).first(n)), "Truncated cosmetics accepted"); auto corrupt = bytes; diff --git a/Extension/Multiplayer/developer_identity.h b/Extension/Multiplayer/developer_identity.h index 183f3583..e03ceb87 100644 --- a/Extension/Multiplayer/developer_identity.h +++ b/Extension/Multiplayer/developer_identity.h @@ -33,11 +33,14 @@ inline std::optional identity_mark(std::uint64_t id) noexcept { if (identity_listed(id, list)) return list; return std::nullopt; } -// The local player's own choice to go without their marks (the menu's Special page, which -// only a listed player gets). Their appearance tells everyone else (Appearance::unmarked). -inline std::atomic own_marks{true}; -inline bool own_marks_shown() noexcept { return own_marks.load(std::memory_order_relaxed); } -inline void show_own_marks(bool shown) noexcept { own_marks.store(shown, std::memory_order_relaxed); } +// The local player's own choices to go without their tag, and without their animated items +// (the menu's Special page, which only a listed player gets). Their appearance tells everyone +// else (Appearance::hide_tag, Appearance::hide_items). +inline std::atomic own_tag{true}, own_items{true}; +inline bool own_tag_shown() noexcept { return own_tag.load(std::memory_order_relaxed); } +inline void show_own_tag(bool shown) noexcept { own_tag.store(shown, std::memory_order_relaxed); } +inline bool own_items_shown() noexcept { return own_items.load(std::memory_order_relaxed); } +inline void show_own_items(bool shown) noexcept { own_items.store(shown, std::memory_order_relaxed); } // A global ban is enforced by whoever a player would play with: a host and a dedicated server // turn them away, and a guest leaves a lobby they host, which their own game does not start // either (session_receive.cpp, Server/server_host.cpp). A server can opt out ("global_bans"), diff --git a/Extension/UI/Overlay/multiplayer_menu.cpp b/Extension/UI/Overlay/multiplayer_menu.cpp index 06f3b5e4..322360c5 100644 --- a/Extension/UI/Overlay/multiplayer_menu.cpp +++ b/Extension/UI/Overlay/multiplayer_menu.cpp @@ -1,8 +1,12 @@ #include "multiplayer_menu_internal.h" +#include "role_badge.h" #include "Engine/Game/World/world_names.h" #include #include +#include +#include #include +#include #include namespace dingosdk::overlay { @@ -267,21 +271,129 @@ void multiplayer_display_settings(SkateMenu &menu, const Model &model) { void multiplayer_network_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks) { debug_page(menu, model.multiplayer, callbacks); } -// SPECIAL: the page only a player on one of the backend's lists gets. Their tag, and the hoodie -// and board that come with it, on or off for everyone at once. +// SPECIAL: the page only a player on one of the backend's lists gets. Their tag as everyone +// sees it, and whether it shows; then the items that come with it, on or off together and +// each on its own: what their list gives it, two colours of their own, or nothing. void special_page(SkateMenu &menu, const Model &model, const CallbacksV3 &) { const auto &mp = model.multiplayer; + std::array unused{}; ImGui::BeginChild("special", ImVec2(0, page_body_height(menu))); - begin_card(menu, "special-marks", "YOUR TAG", mp.identity_tag.c_str()); - bool shown = mp.identity_marks; - if (toggle_row(menu, "Show my tag and items", - "Your tag in chat and on your nametag, and the animated hoodie and board that come with it. " - "Off: you look like any other player, to yourself and to everyone you skate with.", - shown)) { - std::array unused{}; - send_private(menu, "marks", shown ? "on" : "off", unused, false); + begin_card(menu, "special-tag", "YOUR TAG"); + { + // Drawn the way chat and nametags draw it (role_badge.h), and faint while it is off. + field(menu, "Looks like", "Your tag and name, as chat and your nametag show them."); + const std::string name = mp.local_name.empty() ? std::string("You") : mp.local_name; + const float size = px(18), height = ImGui::GetFrameHeight(), alpha = mp.identity_tag_shown ? 1.0f : .3f; + auto *draw = ImGui::GetWindowDrawList(); + const auto at = ImGui::GetCursorScreenPos(); + const float badge = overlay::detail::role_badge_width(menu.bold, size, mp.identity_tag) + px(6); + overlay::detail::draw_role_badge(draw, menu.bold, size, at, height, mp.identity_tag, mp.identity_tag_colour, alpha); + const auto extent = menu.bold->CalcTextSizeA(size, FLT_MAX, 0, name.c_str()); + const ImVec2 name_at(at.x + badge, at.y + (height - extent.y) * .5f); + const int name_vertices = draw->VtxBuffer.Size; + draw->PushClipRect(at, ImVec2(at.x + ImGui::GetContentRegionAvail().x, at.y + height), true); + draw->AddText(menu.bold, size, name_at, + (mp.identity_tag_colour & ~IM_COL32_A_MASK) | (static_cast(255.0f * alpha) << IM_COL32_A_SHIFT), name.c_str()); + draw->PopClipRect(); + overlay::detail::shade_nametag_gradient(draw, name_vertices, name_at.x, extent.x, mp.identity_tag_colour, ImGui::GetTime()); + ImGui::Dummy(ImVec2(std::min(badge + extent.x, ImGui::GetContentRegionAvail().x), height)); } + bool tag = mp.identity_tag_shown; + if (toggle_row(menu, "Show my tag", + "Your tag and the color of your name, in chat and on your nametag. " + "Off: your name looks like any other player's, to yourself and to everyone you skate with.", + tag)) + send_private(menu, "mark-tag", tag ? "on" : "off", unused, false); end_card(); + + begin_card(menu, "special-items", "YOUR ITEMS"); + bool shown = mp.identity_items_shown; + if (toggle_row(menu, "Show my items", + "The colors that come with your tag, on whatever you wear and ride. " + "Off: it all keeps its own colors, for you and for everyone you skate with.", + shown)) + send_private(menu, "mark-items", shown ? "on" : "off", unused, false); + note("Below, each one on its own: it colors whatever you have on in that slot. Everyone you skate with sees what you pick."); + end_card(); + + // The colours being picked: held here while the mouse has them, and after it lets go until + // the saved ones catch up, so a swatch never jumps back to the colour it had. + struct Picking { + std::array from{}, to{}; + bool changed{}; + int waiting{}; // frames + }; + static std::vector picking; + picking.resize(mp.identity_styles.size()); + for (int item = 0; item < static_cast(mp.identity_styles.size()); ++item) { + const auto &style = mp.identity_styles[static_cast(item)]; + auto &picked = picking[static_cast(item)]; + const auto same = [](const std::array &a, const std::array &b) { + return std::abs(a[0] - b[0]) < .003f && std::abs(a[1] - b[1]) < .003f && std::abs(a[2] - b[2]) < .003f; + }; + if (picked.waiting && same(picked.from, style.from) && same(picked.to, style.to)) picked.waiting = 0; + else if (picked.waiting) --picked.waiting; + if (!picked.changed && !picked.waiting) picked.from = style.from, picked.to = style.to; + const auto send = [&](int mode, int speed) { + const auto hex = [](const std::array &colour) { + unsigned value{}; + for (const float part : colour) value = value << 8 | static_cast(std::clamp(part, 0.0f, 1.0f) * 255.0f + 0.5f); + return value; + }; + send_private(menu, "mark-style", std::format("{} {} {:06x} {:06x} {}", item, mode, hex(picked.from), hex(picked.to), speed), + unused, false); + }; + // The card is headed by the cosmetic: whatever is in that slot is what gets coloured. + std::string title = style.name; + for (auto &letter : title) letter = static_cast(std::toupper(static_cast(letter))); + ImGui::PushID(item); + begin_card(menu, title.c_str(), title.c_str()); + // The options in the order shown; a style's mode is 0 what the list gives, 1 off, 2 a + // gradient between the player's two colours, 3 their one colour. + static constexpr std::array modes{0, 2, 3, 1}; + int option = static_cast(std::find(modes.begin(), modes.end(), style.mode) - modes.begin()) % 4; + field(menu, "Color"); + if (choice(menu, "mode", option, {mp.identity_animation.c_str(), "GRADIENT", "SOLID", "OFF"}, shown)) + send(modes[static_cast(option)], style.speed); + if (style.mode == 2 || style.mode == 3) { + const bool gradient = style.mode == 2; + field(menu, gradient ? "Colors" : "Pick", gradient ? "The two colors it moves between. Click one to change it." + : "Click the color to change it."); + ImGui::BeginDisabled(!shown); + constexpr auto flags = ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoLabel | ImGuiColorEditFlags_NoAlpha; + if (ImGui::ColorEdit3("##from", picked.from.data(), flags)) picked.changed = true; + // Beside the swatch, what it gives: the one colour, or all that lies between the two. + ImGui::SameLine(0, px(6)); + const auto at = ImGui::GetCursorScreenPos(); + const float swatch = gradient ? ImGui::GetFrameHeight() + px(6) : 0.0f; + const ImVec2 bar(std::max(px(20), ImGui::GetContentRegionAvail().x - swatch), ImGui::GetFrameHeight()); + const auto solid = [&](const std::array &colour) { + return ImGui::ColorConvertFloat4ToU32(ImVec4(colour[0], colour[1], colour[2], shown ? 1.0f : .4f)); + }; + const auto left = solid(picked.from), right = solid(gradient ? picked.to : picked.from); + ImGui::GetWindowDrawList()->AddRectFilledMultiColor(at, ImVec2(at.x + bar.x, at.y + bar.y), left, right, right, left); + ImGui::Dummy(bar); + if (gradient) { + ImGui::SameLine(0, px(6)); + if (ImGui::ColorEdit3("##to", picked.to.data(), flags)) picked.changed = true; + } + ImGui::EndDisabled(); + // One save when the mouse lets go, not one for every shade dragged through. + if (picked.changed && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + picked.changed = false; + picked.waiting = 120; + send(style.mode, style.speed); + } + } + // A colour that stands still has no speed. + if (style.mode == 0 || style.mode == 2) { + int speed = style.speed; + field(menu, "Speed"); + if (choice(menu, "speed", speed, {"NORMAL", "SLOW", "FAST"}, shown)) send(style.mode, speed); + } + end_card(); + ImGui::PopID(); + } ImGui::EndChild(); } void multiplayer_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks) { From 2e6882ede1e072f99a412832c7764bc07c8cb2e2 Mon Sep 17 00:00:00 2001 From: ReSkate local Date: Mon, 5 Oct 2026 06:31:13 +0100 Subject: [PATCH 046/109] Music: guard the playlist lookup against unresolved song entries The music player's playlist walk loads a song entry, masks it and reads a flag at [rcx+0x14] before it checks the entry's identity. An unresolved or NULL entry makes that read fault and takes the game down while a playlist is built or opened. Install a 13-byte in-place guard at the reviewed RVA 0x14f80c7 when the music contracts pass. It keeps the original flag test, shrinks the leading mov eax,0 to xor eax,eax, and adds a jrcxz that skips the dereference when the entry is NULL, landing on the loop increment. The site is matched against its contract bytes before anything is written, so an unsupported image is left untouched, and the guard is applied with VirtualProtect and FlushInstructionCache and then made read-only again. The contract and patch bytes live with the other music contracts in Engine/Game/Build/20260929/local_music.h; the installer is Extension/Music/local_music_safety.{h,cpp}; local_music_ui.cpp invokes it from initialize_music_functions; cmake/Runtime.cmake builds it. A new dingosdk_music_safety_tests (CTest music_safety) pins the contract length, the decoded instructions and the branch target arithmetic without needing the game. --- Engine/Game/Build/20260929/local_music.h | 17 +++++ .../Music/Test/local_music_safety_tests.cpp | 40 +++++++++++ Extension/Music/local_music_safety.cpp | 68 +++++++++++++++++++ Extension/Music/local_music_safety.h | 10 +++ Extension/Music/local_music_ui.cpp | 5 ++ cmake/Apps.cmake | 4 ++ cmake/Runtime.cmake | 1 + 7 files changed, 145 insertions(+) create mode 100644 Extension/Music/Test/local_music_safety_tests.cpp create mode 100644 Extension/Music/local_music_safety.cpp create mode 100644 Extension/Music/local_music_safety.h diff --git a/Engine/Game/Build/20260929/local_music.h b/Engine/Game/Build/20260929/local_music.h index 99b1b573..da5c2da8 100644 --- a/Engine/Game/Build/20260929/local_music.h +++ b/Engine/Game/Build/20260929/local_music.h @@ -2,6 +2,7 @@ #include "Engine/Game/Build/fingerprint.h" #include "engine.h" #include +#include #include namespace dingosdk::game::build::v20260929::local_music { @@ -49,4 +50,20 @@ inline constexpr std::array 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 playlist_lookup_safety_contract{ + 0xb8, 0x00, 0x00, 0x00, 0x00, 0x48, 0x83, 0xe1, 0xfb, 0x66, 0x85, 0x71, 0x14}; +inline constexpr std::array 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; } diff --git a/Extension/Music/Test/local_music_safety_tests.cpp b/Extension/Music/Test/local_music_safety_tests.cpp new file mode 100644 index 00000000..1c64bf8f --- /dev/null +++ b/Extension/Music/Test/local_music_safety_tests.cpp @@ -0,0 +1,40 @@ +// Contract tests for the playlist lookup crash guard. No game binary is needed: +// the patch site bytes, the jrcxz displacement and the branch target arithmetic +// are all fixed data of the supported build, so they are checked here. +#include "Engine/Game/Build/20260929/local_music.h" +#include +#include + +namespace music = dingosdk::game::build::v20260929::local_music; + +int main() { + int failures = 0; + const auto check = [&](bool ok, const char* what) { + if (!ok) { std::printf("FAIL: %s\n", what); ++failures; } + }; + + check(music::playlist_lookup_safety_contract.size() == 13, "the contract is 13 bytes"); + check(music::playlist_lookup_patch_bytes.size() == 13, "the patch is 13 bytes"); + check(music::playlist_lookup_safety_contract.size() == music::playlist_lookup_patch_bytes.size(), + "the patch replaces the contract byte for byte"); + check(music::playlist_lookup_safety_rva == 0x14f80c7, "the site is the reviewed RVA"); + + // The patch decodes as xor eax,eax; and rcx,-5; jrcxz; the preserved flag test; nop. + const auto& patch = music::playlist_lookup_patch_bytes; + check(patch[0] == 0x31 && patch[1] == 0xc0, "mov eax,0 becomes xor eax,eax"); + check(patch[2] == 0x48 && patch[3] == 0x83 && patch[4] == 0xe1 && patch[5] == 0xfb, + "and rcx,-5 is preserved"); + check(patch[6] == 0xe3, "jrcxz is installed after the mask"); + check(patch[7] == music::playlist_lookup_jump_displacement, "the jrcxz displacement is declared"); + check(patch[8] == 0x66 && patch[9] == 0x85 && patch[10] == 0x71 && patch[11] == 0x14, + "the [rcx+0x14] flag test is preserved"); + check(patch[12] == 0x90, "the trailing byte pads the 5-byte mov"); + + // The 2-byte jrcxz sits at +6, so its relative displacement is measured from +8. + check(music::playlist_lookup_jump_next == 8, "jrcxz ends 8 bytes into the site"); + check(music::playlist_lookup_jump_target == 0x14f80f8, + "the guard jumps to the loop increment (inc r9d; add r8,4)"); + + std::printf(failures ? "%d failure(s)\n" : "all passed\n", failures); + return failures ? 1 : 0; +} diff --git a/Extension/Music/local_music_safety.cpp b/Extension/Music/local_music_safety.cpp new file mode 100644 index 00000000..42a34fb7 --- /dev/null +++ b/Extension/Music/local_music_safety.cpp @@ -0,0 +1,68 @@ +#include "local_music_safety.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/20260929/local_music.h" +#include "Engine/Game/Build/supported_build.h" +#include +#include +#include + +namespace dingosdk::profile_runtime { +namespace { +using namespace dingosdk::game::build::v20260929; +using local_music::playlist_lookup_patch_bytes; +using local_music::playlist_lookup_safety_contract; +using local_music::playlist_lookup_safety_rva; + +void report(logging::Level level, const char* message) noexcept { + logging::write(level, logging::Channel::music, message); +} + +bool write_patch(std::uintptr_t address, const std::array& bytes) noexcept { + DWORD previous{}; + auto* const destination = reinterpret_cast(address); + if (!VirtualProtect(destination, bytes.size(), PAGE_EXECUTE_READWRITE, &previous)) return false; + std::memcpy(destination, bytes.data(), bytes.size()); + const bool flushed = FlushInstructionCache(GetCurrentProcess(), destination, bytes.size()) != FALSE; + DWORD discarded{}; + const bool restored = VirtualProtect(destination, bytes.size(), previous, &discarded) != FALSE; + return flushed && restored; +} +} + +bool install_playlist_lookup_safety(std::uintptr_t base) noexcept { + const DWORD saved = GetLastError(); + constexpr auto size = playlist_lookup_safety_contract.size(); + const auto rva = playlist_lookup_safety_rva; + if (base == 0 || rva >= supported_build::game_image_size || + size > supported_build::game_image_size - rva) { + report(logging::Level::error, "Music playlist lookup safety patch: the site is outside the game image."); + SetLastError(saved); + return false; + } + const auto address = base + rva; + std::array actual{}; + if (!memory::read_bytes(address, actual.data(), actual.size())) { + report(logging::Level::error, "Music playlist lookup safety patch: the site is unreadable."); + SetLastError(saved); + return false; + } + if (actual == playlist_lookup_patch_bytes) { + SetLastError(saved); // Already guarded by an earlier install. + return true; + } + if (actual != playlist_lookup_safety_contract) { + report(logging::Level::error, "Music playlist lookup safety patch: the site does not match the supported build."); + SetLastError(saved); + return false; + } + if (!write_patch(address, playlist_lookup_patch_bytes)) { + report(logging::Level::error, "Music playlist lookup safety patch: could not make the site writable."); + SetLastError(saved); + return false; + } + logging::event(logging::Channel::music, "{\"event\":\"music_playlist_lookup_safety\"}"); + SetLastError(saved); + return true; +} +} diff --git a/Extension/Music/local_music_safety.h b/Extension/Music/local_music_safety.h new file mode 100644 index 00000000..653b82d8 --- /dev/null +++ b/Extension/Music/local_music_safety.h @@ -0,0 +1,10 @@ +#pragma once +#include + +namespace dingosdk::profile_runtime { +// Installs the in-place playlist-lookup crash guard described in +// Engine/Game/Build/20260929/local_music.h. Verifies the 13 contract bytes at +// `playlist_lookup_safety_rva` before writing, so an unsupported image is left +// untouched. Never throws; a failed install is logged and reported as false. +bool install_playlist_lookup_safety(std::uintptr_t base) noexcept; +} diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index b249242d..975cd479 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -3,6 +3,7 @@ #include "Extension/Customization/local_customization_runtime.h" #include "Extension/Customization/local_player_card_runtime.h" #include "local_music_assets.h" +#include "local_music_safety.h" #include "local_music_ui.h" #include "Engine/Game/Abi/native_data.h" #include "Engine/Game/Build/addresses.h" @@ -42,6 +43,10 @@ bool initialize_music_functions(std::uintptr_t base) { if (!read(base + music::playlist_control_vtable, playlist) || !read(base + music::song_control_vtable, song) || playlist != rebased(music::playlist_control_slots) || song != rebased(music::song_control_slots)) return false; + // Guard the native playlist walk before any mod song can reach it. A failed + // install leaves music usable; the crash guard is a safety net, not a gate. + if (!install_playlist_lookup_safety(base)) + logging::event(logging::Channel::music, "{\"event\":\"music_playlist_lookup_safety_failed\"}"); auto& f = music_ui_runtime().functions; f.construct_playlist = reinterpret_cast(base + music::construct_playlist); f.construct_song = reinterpret_cast(base + music::construct_song); diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 35c239cb..75e6261b 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -48,6 +48,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_link_libraries(dingosdk_mod_music_tests PRIVATE dingosdk_mods) target_include_directories(dingosdk_mod_music_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME mod_music COMMAND dingosdk_mod_music_tests) + add_executable(dingosdk_music_safety_tests Extension/Music/Test/local_music_safety_tests.cpp) + target_include_directories(dingosdk_music_safety_tests PRIVATE "${PROJECT_SOURCE_DIR}") + dingosdk_configure_target(dingosdk_music_safety_tests) + add_test(NAME music_safety COMMAND dingosdk_music_safety_tests) add_executable(dingosdk_mod_merge_unshift_tests Engine/Vfs/Test/mod_merge_unshift_tests.cpp) target_link_libraries(dingosdk_mod_merge_unshift_tests PRIVATE dingosdk_mods) target_include_directories(dingosdk_mod_merge_unshift_tests PRIVATE "${PROJECT_SOURCE_DIR}") diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 5989d22a..56cc38d1 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -141,6 +141,7 @@ add_library(dingosdk_runtime SHARED Extension/News/live_news.cpp Extension/Objects/local_object_runtime.cpp Extension/Music/local_music_assets.cpp + Extension/Music/local_music_safety.cpp Extension/Music/local_music_ui.cpp Extension/Objects/local_buildkit_labels.cpp Extension/Objects/local_buildkit_limits.cpp From 88e033b33f12cf91fc6812befc320fd6fe338f99 Mon Sep 17 00:00:00 2001 From: ReSkate local Date: Mon, 5 Oct 2026 06:51:05 +0100 Subject: [PATCH 047/109] Music: skip the Mods shelf when no mod declares a playlist Build the Mods shelf only when an enabled mod declares at least one playlist with songs, read from each mod's reskate-music.json. The shelf must be inserted before the native widgets bind, so it cannot wait for Featured rows to hydrate, and it should not publish an empty shelf when there is nothing to show. With no declared mod playlist the native three shelves are left as they are and a music_mods_shelf_skipped event is logged. --- Extension/Music/local_music_shelf.cpp | 50 ++++++++++++++++++--------- contrib/native-music-ui.md | 6 ++++ 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/Extension/Music/local_music_shelf.cpp b/Extension/Music/local_music_shelf.cpp index 1ffd9563..e731c51e 100644 --- a/Extension/Music/local_music_shelf.cpp +++ b/Extension/Music/local_music_shelf.cpp @@ -1,4 +1,5 @@ #include "Extension/Music/local_music_shelf.h" +#include "Extension/Music/local_music_assets.h" #include "Engine/Core/Log/logging.h" #include "Engine/Game/Abi/native_data.h" #include "Engine/Game/Build/addresses.h" @@ -198,22 +199,39 @@ std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mo try { if (!handle || !type || md::read(md::read(type)) != 0x48455d84U) return handle; - game::ModelWriteLock lock(manager); - md::Context context{local_runtime().base, manager}; - const auto items = context.field(md::Value{handle, type}, 0x67223da7U); - unsigned count{}, stride{}; - const auto bytes = context.array(items, 1024, count, stride); - if (count != 3 || stride != 16) return handle; - constexpr const char* records[] = { - "MusicPlaylistManager_Base_ContentResources/Featured_AnchoredContent_VM", - "MusicPlaylistManager_Base_ContentResources/Liked_AnchoredContent_VM", - "MusicPlaylistManager_Base_ContentResources/NewlyDiscovered_AnchoredContent_VM"}; - for (unsigned item = 0; item < 3; ++item) { - std::uintptr_t item_record{}; - std::memcpy(&item_record, bytes.data() + item * stride, sizeof(item_record)); - // Named record header +0x28 is also used by the native UI dumper. - if (!item_record || md::string(md::read(item_record + 0x28), 256) != records[item]) - return handle; + bool is_music_shelf = false; + { + game::ModelWriteLock lock(manager); + md::Context context{local_runtime().base, manager}; + const auto items = context.field(md::Value{handle, type}, 0x67223da7U); + unsigned count{}, stride{}; + const auto bytes = context.array(items, 1024, count, stride); + if (count != 3 || stride != 16) return handle; + constexpr const char* records[] = { + "MusicPlaylistManager_Base_ContentResources/Featured_AnchoredContent_VM", + "MusicPlaylistManager_Base_ContentResources/Liked_AnchoredContent_VM", + "MusicPlaylistManager_Base_ContentResources/NewlyDiscovered_AnchoredContent_VM"}; + is_music_shelf = true; + for (unsigned item = 0; item < 3; ++item) { + std::uintptr_t item_record{}; + std::memcpy(&item_record, bytes.data() + item * stride, sizeof(item_record)); + // Named record header +0x28 is also used by the native UI dumper. + if (!item_record || md::string(md::read(item_record + 0x28), 256) != records[item]) { + is_music_shelf = false; + break; + } + } + } + if (!is_music_shelf) return handle; + // Only add our shelf when an enabled mod actually declares a playlist with songs. Otherwise + // leave the native three shelves untouched instead of publishing an empty Mods shelf. + bool has_mod_playlists = false; + for (const auto& playlist : mod_music_playlists()) + if (!playlist.songs.empty()) { has_mod_playlists = true; break; } + if (!has_mod_playlists) { + mods_shelf = {}; + dingosdk::logging::event(dingosdk::logging::Channel::music, "{\"event\":\"music_mods_shelf_skipped\"}"); + return handle; } const auto report = music_shelf_append_one(local_runtime().base, manager, handle); dingosdk::logging::event(dingosdk::logging::Channel::music, diff --git a/contrib/native-music-ui.md b/contrib/native-music-ui.md index 8e3ea857..d5a6908c 100644 --- a/contrib/native-music-ui.md +++ b/contrib/native-music-ui.md @@ -198,6 +198,12 @@ The Mods implementation performs these steps under the native model write lock: **Mods, Featured, Liked, Newly Discovered**. 7. Populate the owned tile list from mod playlist rows in Featured. +The shelf is only built when an enabled mod declares a playlist with songs, read from +each mod's `reskate-music.json`. The insertion has to happen before the widgets bind, +so its rows cannot be awaited; the enabled mods' declarations are the signal that does +not depend on row hydration. With none, the native three shelves are left untouched +rather than publishing an empty Mods shelf. + The native template supplies layout and presentation defaults. Creating both models separately supplies independent state. Changing the label on the original Liked tile list would rename the original shelf too. From 2b39afcb6212b9cdf8a705d504de83ecdc434c06 Mon Sep 17 00:00:00 2001 From: ReSkate local Date: Mon, 5 Oct 2026 07:32:27 +0100 Subject: [PATCH 048/109] Vfs: carry added assets from every mod that edits a shared asset When several mods change the same game asset (the master music playlist, for one), only the first mod's edit was read for the imports it names, so only that mod's added songs, waves and chunks were carried into the other mods' copies of the bundle (a map's level superbundle). Every later mod's additions went missing on those levels while the game's own levels kept them, which showed up as songs vanishing from the playlist on custom maps. Read the imports of every changed asset, and merge a later mod's edit into the stored change so the combined document's imports -- and the chunks they lead to -- are followed too. Add a regression test with two music mods whose songs must both reach a map's copy of the bundle. --- .../Vfs/Test/mod_merge_added_assets_tests.cpp | 30 +++++++++++++ Engine/Vfs/mod_merge_overrides.cpp | 44 ++++++++++++++++--- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp index 34571741..b80b6815 100644 --- a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp +++ b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp @@ -11,6 +11,7 @@ // 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" @@ -353,6 +354,34 @@ void same_name_from_two_mods(bool different) { 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)); +} } // namespace int main() try { @@ -363,6 +392,7 @@ int main() try { 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); if (failures) { std::cerr << failures << " failure(s)\n"; return 1; diff --git a/Engine/Vfs/mod_merge_overrides.cpp b/Engine/Vfs/mod_merge_overrides.cpp index 17e9adaf..5269a473 100644 --- a/Engine/Vfs/mod_merge_overrides.cpp +++ b/Engine/Vfs/mod_merge_overrides.cpp @@ -1,8 +1,11 @@ #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 +#include #include #include @@ -68,15 +71,44 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, candidates.push_back({lower(bundle.name), {mod->name, asset, payload(), lower(relative)}}); continue; } - auto& versions = out.changed[name]; - if (versions.contains(game_copy->second)) continue; - const auto& change = versions.emplace(game_copy->second, - AssetOverride{mod->name, asset.sha1, asset.originalSize, payload()}).first->second; - ++changed; + const auto encodedPayload = payload(); try { - for (const auto& reference : decoded(change.encoded).imports) + for (const auto& reference : decoded(encodedPayload).imports) imported.insert(reference.fileGuid); } catch (const std::exception&) {} + auto& versions = out.changed[name]; + const auto existing = versions.find(game_copy->second); + if (existing == versions.end()) { + versions.emplace(game_copy->second, + AssetOverride{mod->name, asset.sha1, asset.originalSize, encodedPayload}); + ++changed; + } else { + try { + std::size_t baseIndex = 0; + while (baseIndex < gameListing->manifest.ebx.size() && + lower(gameListing->manifest.ebx[baseIndex].name) != name) + ++baseIndex; + if (gameListing->first + baseIndex < gameListing->files.size()) { + const auto& baseFile = gameListing->files[gameListing->first + baseIndex]; + const auto baseBytes = store.read(baseRoot, baseFile.location, baseFile.offset, baseFile.size); + const auto baseDoc = decoded(baseBytes); + const auto existingDoc = decoded(existing->second.encoded); + const auto thisDoc = decoded(encodedPayload); + const std::array editDocs{&existingDoc, &thisDoc}; + fb::ebx::MergeSummary summary; + auto combined = fb::ebx::merge_documents(baseDoc, editDocs, &summary); + if (summary.instances || summary.arrayEntries) { + 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}); + existing->second.mod += " + " + mod->name; + for (const auto& reference : combined.imports) + imported.insert(reference.fileGuid); + } + } + } catch (const std::exception&) {} + } } // 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). From bf89ea7038234bd391560fb9902616b193730bec Mon Sep 17 00:00:00 2001 From: ReSkate local Date: Mon, 5 Oct 2026 10:53:31 +0100 Subject: [PATCH 049/109] Music: preserve authored track positioning in mod playlists --- Extension/Music/local_music_assets.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Extension/Music/local_music_assets.cpp b/Extension/Music/local_music_assets.cpp index 79c03b74..3201d638 100644 --- a/Extension/Music/local_music_assets.cpp +++ b/Extension/Music/local_music_assets.cpp @@ -198,11 +198,13 @@ bool read_music_catalog(MusicCatalog& result) { } // Registry order is bucket order, not authored ordering. Use stable IDs for // deterministic UI order; do not claim this is the original service order. + // Authored mod playlists keep their defined track positioning. std::sort(snapshot.songs.begin(), snapshot.songs.end(), [](const auto& a, const auto& b) { return a.id < b.id; }); for (auto& [id, songs] : playlists) { - std::sort(songs.begin(), songs.end()); + if (!id.starts_with("mod:")) + std::sort(songs.begin(), songs.end()); MusicPlaylist row; row.id = id; row.songs = std::move(songs); From 360338e6e10f8acf28d237c4f6a8a79f99305395 Mon Sep 17 00:00:00 2001 From: NorthDroplet Date: Mon, 5 Oct 2026 14:27:08 +0000 Subject: [PATCH 050/109] Launcher: controller navigation and the Steam Deck trackpad as a mouse The launcher read no controller at all. Started from Steam (Desktop or Game Mode on a Steam Deck), Steam Input applies the game's gamepad layout to it, where the right trackpad is the right stick and its click is R3, so the trackpad did nothing; only the touch screen, which arrives as mouse messages, worked. - Every XInput pad is merged (Xbox, Steam Input's virtual pad), else a DualShock 4 / DualSense is read over HID with the reader the in-game menu already uses, now its own library (dingosdk_playstation_input). - D-pad and left stick move ImGui's focus, outlined in the launcher's blue; A / Cross presses. The hand-drawn tiles, switches and menu buttons take part in navigation (ImGuiButtonFlags_EnableNav). - B / Circle closes an open combo or menu and leaves a text field, and is Escape otherwise, which the launcher's pages already go back on. Escape now also closes the mod overviews and answers Cancel in the uninstall and replace confirmations; closing an overview no longer also leaves the page. - The right stick (the trackpad) moves a pointer that ImGui draws, and R3 (the trackpad's click) is the left button. The system cursor follows it; a mouse move or a touch elsewhere takes the pointer back. - The ImGui Win32 backend's own gamepad code (slot 0 only) is compiled out so it cannot fight the launcher's events; nothing else used it. --- Launcher/Test/gamepad_input_tests.cpp | 207 ++++++++++++++++++++++++++ Launcher/gamepad_input.cpp | 137 +++++++++++++++++ Launcher/gamepad_input.h | 51 +++++++ Launcher/gui.cpp | 26 +++- Launcher/gui_gamepad.cpp | 94 ++++++++++++ Launcher/gui_internal.h | 6 + Launcher/gui_mods.cpp | 15 +- Launcher/gui_mods_browse.cpp | 3 +- cmake/Apps.cmake | 7 +- cmake/Dependencies.cmake | 5 +- cmake/UI.cmake | 7 +- 11 files changed, 545 insertions(+), 13 deletions(-) create mode 100644 Launcher/Test/gamepad_input_tests.cpp create mode 100644 Launcher/gamepad_input.cpp create mode 100644 Launcher/gamepad_input.h create mode 100644 Launcher/gui_gamepad.cpp diff --git a/Launcher/Test/gamepad_input_tests.cpp b/Launcher/Test/gamepad_input_tests.cpp new file mode 100644 index 00000000..bdc62a53 --- /dev/null +++ b/Launcher/Test/gamepad_input_tests.cpp @@ -0,0 +1,207 @@ +// Controller input in the launcher: focus, back, the trackpad pointer and its click, +// checked through real ImGui frames. +#include "Launcher/gamepad_input.h" + +#include + +#include +#include +#include + +using dingosdk::launcher_gui::PadFeed; +using dingosdk::launcher_gui::PadState; + +namespace { + +int failures = 0; + +void check(bool condition, const char* what) { + if (condition) return; + std::cerr << "FAIL: " << what << '\n'; + ++failures; +} + +constexpr std::uint16_t dpad_down = 0x0002, right_thumb = 0x0080, button_a = 0x1000, button_b = 0x2000; + +struct Seen { + std::string pressed; // the button that reported a press this frame + bool escape{}; + bool menu_open{}; +}; + +// One launcher frame: the pad goes in before NewFrame, as gui.cpp does it. +// Two buttons stacked at the top left, and a menu popup opened on request. +Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, std::optional* warp = nullptr) { + auto& io = ImGui::GetIO(); + io.DisplaySize = ImVec2(800, 600); + io.DeltaTime = 1.0f / 60.0f; + const auto moved = feed.update(io, pad, 1.0f); + if (warp) *warp = moved; + ImGui::NewFrame(); + Seen seen; + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(800, 600)); + ImGui::Begin("##root", nullptr, ImGuiWindowFlags_NoDecoration); + ImGui::SetCursorScreenPos(ImVec2(100, 100)); + if (ImGui::InvisibleButton("first", ImVec2(200, 50), ImGuiButtonFlags_EnableNav)) seen.pressed = "first"; + ImGui::SetCursorScreenPos(ImVec2(100, 200)); + if (ImGui::InvisibleButton("second", ImVec2(200, 50), ImGuiButtonFlags_EnableNav)) seen.pressed = "second"; + if (open_menu) ImGui::OpenPopup("##menu"); + if (ImGui::BeginPopup("##menu")) { + seen.menu_open = true; + ImGui::MenuItem("Details"); + ImGui::EndPopup(); + } + seen.escape = ImGui::IsKeyPressed(ImGuiKey_Escape, false); + ImGui::End(); + ImGui::Render(); + return seen; +} + +PadState pad(std::uint16_t buttons = 0, std::int16_t right_x = 0, std::int16_t right_y = 0) { + PadState state; + state.connected = true; + state.buttons = buttons; + state.right_x = right_x; + state.right_y = right_y; + return state; +} + +// Presses and lets go, returning what the frames saw. +std::string press(PadFeed& feed, std::uint16_t button) { + std::string pressed = frame(feed, pad(button)).pressed; + const auto released = frame(feed, pad()).pressed; + return pressed.empty() ? released : pressed; +} + +void fresh() { + if (ImGui::GetCurrentContext()) ImGui::DestroyContext(); + ImGui::CreateContext(); + auto& io = ImGui::GetIO(); + io.IniFilename = nullptr; + io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; + io.Fonts->AddFontDefault(); + io.Fonts->Build(); +} + +} // namespace + +int main() { + // ------------------------------------------------ focus + { + fresh(); + PadFeed feed; + frame(feed, pad()); + frame(feed, pad()); + press(feed, dpad_down); + check(ImGui::GetIO().NavVisible, "The D-pad shows the focus"); + // The first move lands on the first item; the next one goes down. + press(feed, dpad_down); + check(press(feed, button_a) == "second", "A presses the item the D-pad moved to"); + check(!feed.pointing() && !ImGui::GetIO().MouseDrawCursor, "No pointer is drawn while the focus is used"); + } + { + fresh(); + PadFeed feed; + frame(feed, pad()); + frame(feed, pad(button_a)); + check(ImGui::IsKeyDown(ImGuiKey_GamepadFaceDown), "A (Cross) is the gamepad's activate button"); + PadState gone; // unplugged with A held + frame(feed, gone); + frame(feed, gone); + check(!ImGui::IsKeyDown(ImGuiKey_GamepadFaceDown), "An unplugged pad lets go of A"); + check(!(ImGui::GetIO().BackendFlags & ImGuiBackendFlags_HasGamepad), "An unplugged pad is no gamepad"); + } + + // ------------------------------------------------ back + { + fresh(); + PadFeed feed; + frame(feed, pad()); + frame(feed, pad()); + frame(feed, pad(button_b)); + check(ImGui::IsKeyPressed(ImGuiKey_Escape, false), "B is Escape with nothing open"); + frame(feed, pad()); + check(!ImGui::IsKeyDown(ImGuiKey_Escape), "Letting go of B lets go of Escape"); + } + { + fresh(); + PadFeed feed; + frame(feed, pad()); + check(frame(feed, pad(), true).menu_open, "The menu opens"); + frame(feed, pad()); + const auto with_b = frame(feed, pad(button_b)); + const auto after = frame(feed, pad()); + check(!after.menu_open, "B closes an open menu"); + check(!with_b.escape && !after.escape, "Closing the menu is not also Escape, which would leave the page"); + } + { + fresh(); + PadFeed feed; + frame(feed, pad()); + ImGui::GetIO().AddKeyEvent(ImGuiKey_Escape, true); // the keyboard's Escape, held + frame(feed, pad()); + frame(feed, pad()); + check(ImGui::IsKeyDown(ImGuiKey_Escape), "An idle pad does not let go of the keyboard's Escape"); + } + + // ------------------------------------------------ trackpad pointer + { + fresh(); + PadFeed feed; + ImGui::GetIO().AddMousePosEvent(400, 300); + frame(feed, pad()); + std::optional warp; + frame(feed, pad(0, 3000, 0), false, &warp); + check(!warp && !feed.pointing(), "A thumb resting near the middle does not move the pointer"); + + frame(feed, pad(0, 16000, 0), false, &warp); + check(feed.pointing() && ImGui::GetIO().MouseDrawCursor, "Tilting takes the pointer and draws it"); + check(warp && warp->x > 400 && warp->y == 300, "Tilting right moves the pointer right, from the mouse's place"); + for (int i = 0; i < 120; ++i) frame(feed, pad(0, 32767, 32767), false, &warp); + check(warp && warp->x == 799 && warp->y == 0, "The pointer stays inside the window"); + frame(feed, pad()); + check(ImGui::GetIO().MousePos.x == 799 && ImGui::GetIO().MousePos.y == 0, "ImGui's mouse is where the pad left it"); + + // The system cursor reporting a stale place (Proton, gamescope) does not take the pointer back. + ImGui::GetIO().AddMousePosEvent(10, 10); + frame(feed, pad(), false, &warp); + frame(feed, pad()); + check(!warp && ImGui::GetIO().MousePos.x == 799, "Without tilt the pointer stays put"); + + feed.mouse_moved(ImVec2(799, 0)); // the echo of our own cursor move + check(feed.pointing(), "The echo of our own cursor move keeps the pointer"); + feed.mouse_moved(ImVec2(50, 60)); // the mouse, or a finger on the screen + ImGui::GetIO().AddMousePosEvent(50, 60); + frame(feed, pad()); + frame(feed, pad()); + check(!feed.pointing() && !ImGui::GetIO().MouseDrawCursor, "The mouse takes the pointer back"); + check(ImGui::GetIO().MousePos.x == 50, "The mouse's place wins once it moved"); + } + { + fresh(); + PadFeed feed; + ImGui::GetIO().AddMousePosEvent(150, 225); // over "second" + frame(feed, pad()); + frame(feed, pad()); + std::string pressed = frame(feed, pad(right_thumb)).pressed; + check(ImGui::GetIO().MouseDown[0], "The trackpad's click (R3) holds the left button"); + const auto released = frame(feed, pad()).pressed; + if (pressed.empty()) pressed = released; + check(pressed == "second", "The trackpad's click presses what the pointer is over"); + check(!ImGui::GetIO().MouseDown[0], "Letting go of R3 lets go of the left button"); + + frame(feed, pad(0, 20000, 0)); + check(feed.pointing(), "The pointer is the pad's"); + press(feed, dpad_down); + check(!feed.pointing() && ImGui::GetIO().NavVisible, "The D-pad hands the screen back to the focus"); + } + + ImGui::DestroyContext(); + if (failures) { + std::cerr << failures << " gamepad input check(s) failed\n"; + return 1; + } + std::cout << "Gamepad input checks passed.\n"; + return 0; +} diff --git a/Launcher/gamepad_input.cpp b/Launcher/gamepad_input.cpp new file mode 100644 index 00000000..8380948a --- /dev/null +++ b/Launcher/gamepad_input.cpp @@ -0,0 +1,137 @@ +#include "gamepad_input.h" + +#include + +#include +#include + +namespace dingosdk::launcher_gui { +namespace { + +// XINPUT_GAMEPAD_* bits, spelled out so this file needs no Windows headers. +constexpr std::uint16_t dpad_up = 0x0001, dpad_down = 0x0002, dpad_left = 0x0004, dpad_right = 0x0008; +constexpr std::uint16_t right_thumb = 0x0080, button_a = 0x1000, button_b = 0x2000; + +struct Button { std::uint16_t bit; ImGuiKey key; }; +// Only what moves and presses the focus. X/Y and the shoulders stay out: ImGui +// reads them as its window switcher and tweak modifiers, which the launcher has +// no use for. +constexpr Button focus_buttons[]{ + {dpad_up, ImGuiKey_GamepadDpadUp}, {dpad_down, ImGuiKey_GamepadDpadDown}, + {dpad_left, ImGuiKey_GamepadDpadLeft}, {dpad_right, ImGuiKey_GamepadDpadRight}, + {button_a, ImGuiKey_GamepadFaceDown}}; +constexpr std::uint16_t focus_bits = dpad_up | dpad_down | dpad_left | dpad_right | button_a; + +// XInput's left stick dead zone. +constexpr float left_dead_zone = 7849.0f / 32767.0f; +// Larger on the right: a thumb resting on the Deck's trackpad is never quite +// centred, and the pointer must not creep under it. +constexpr float right_dead_zone = 0.2f; +// Pointer speed at full tilt, in design pixels a second. +constexpr float pointer_speed = 1400.0f; +// A mouse move this close to the pad's pointer is the echo of moving the +// system cursor there; one further away is the mouse or a finger. +constexpr float pointer_slack = 2.0f; + +float axis(std::int16_t value) { return std::clamp(static_cast(value) / 32767.0f, -1.0f, 1.0f); } + +void stick(ImGuiIO& io, ImGuiKey negative, ImGuiKey positive, float value) { + const auto amount = [](float tilt) { return std::clamp((tilt - left_dead_zone) / (1 - left_dead_zone), 0.0f, 1.0f); }; + io.AddKeyAnalogEvent(negative, -value > left_dead_zone, amount(-value)); + io.AddKeyAnalogEvent(positive, value > left_dead_zone, amount(value)); +} + +// B goes to ImGui's own cancel while ImGui has something to cancel: a combo or +// menu to close, or a text field to leave. Otherwise it is Escape, the +// launcher's way back out of a page or a modal. +bool imgui_cancels() { + const ImGuiContext& g = *GImGui; + if (ImGui::IsAnyItemActive()) return true; + if (g.OpenPopupStack.Size == 0) return false; + const ImGuiWindow* top = g.OpenPopupStack.back().Window; + return top && !(top->Flags & ImGuiWindowFlags_Modal); +} + +bool moved_away(ImVec2 position, ImVec2 pointer) { + return std::abs(position.x - pointer.x) > pointer_slack || std::abs(position.y - pointer.y) > pointer_slack; +} + +} // namespace + +std::optional PadFeed::update(ImGuiIO& io, const PadState& input, float scale) { + // A pad that went away lets go of everything it held. + const PadState pad = input.connected ? input : PadState{}; + const auto changed = static_cast(pad.buttons ^ buttons_); + const auto pressed = static_cast(pad.buttons & changed); + if (pad.connected) io.BackendFlags |= ImGuiBackendFlags_HasGamepad; + else io.BackendFlags &= ~ImGuiBackendFlags_HasGamepad; + + // Only changes are sent: the keyboard shares Escape and the mouse shares the + // left button, and a pad repeating "up" every frame would cancel them. + for (const auto& button : focus_buttons) + if (changed & button.bit) io.AddKeyEvent(button.key, (pad.buttons & button.bit) != 0); + if (changed & button_b) { + const bool down = (pad.buttons & button_b) != 0; + // The release goes to whichever key the press went to. + if (down) back_key_ = imgui_cancels() ? ImGuiKey_GamepadFaceRight : ImGuiKey_Escape; + io.AddKeyEvent(back_key_, down); + } + const float left_x = axis(pad.left_x), left_y = axis(pad.left_y); + if (pad.connected || connected_) { + // Analog values ImGui already has are dropped by ImGui itself. + stick(io, ImGuiKey_GamepadLStickLeft, ImGuiKey_GamepadLStickRight, left_x); + stick(io, ImGuiKey_GamepadLStickDown, ImGuiKey_GamepadLStickUp, left_y); + } + connected_ = pad.connected; + buttons_ = pad.buttons; + + // Moving the focus hands the screen back to it: ImGui hides the focus while + // a pointer moves, so the pointer goes away instead. + if ((pressed & focus_bits) || std::abs(left_x) > left_dead_zone || std::abs(left_y) > left_dead_zone) + pointing_ = false; + + bool warp = false; + const float x = axis(pad.right_x), y = -axis(pad.right_y); // screen Y grows downward + const float tilt = std::min(1.0f, std::sqrt(x * x + y * y)); + if (!pointing_ && (tilt > right_dead_zone || (pressed & right_thumb))) { + pointing_ = true; + warp = true; + // From wherever the mouse left it, which may be outside the window. + pointer_ = ImGui::IsMousePosValid(&io.MousePos) ? io.MousePos + : ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f); + } + if (pointing_ && tilt > right_dead_zone) { + // Squared, so a small tilt places the pointer precisely and a full one + // crosses the window in about a second. + const float speed = (tilt - right_dead_zone) / (1 - right_dead_zone); + const float step = speed * speed * pointer_speed * scale * io.DeltaTime / tilt; + pointer_.x += x * step; + pointer_.y += y * step; + warp = true; + } + if (pointing_) { + pointer_.x = std::clamp(pointer_.x, 0.0f, std::max(0.0f, io.DisplaySize.x - 1)); + pointer_.y = std::clamp(pointer_.y, 0.0f, std::max(0.0f, io.DisplaySize.y - 1)); + // Every frame, after the backend's own: it may report a system cursor + // that Proton or gamescope did not move along with ours. + io.AddMouseSourceEvent(ImGuiMouseSource_Mouse); + io.AddMousePosEvent(pointer_.x, pointer_.y); + } + // The release is sent even when the mouse took the pointer meanwhile: the + // press was ours. + if (changed & right_thumb) { + io.AddMouseSourceEvent(ImGuiMouseSource_Mouse); + io.AddMouseButtonEvent(ImGuiMouseButton_Left, (pad.buttons & right_thumb) != 0); + } + io.MouseDrawCursor = pointing_; + if (warp) return pointer_; + return std::nullopt; +} + +void PadFeed::mouse_moved(ImVec2 position) { + // Our own cursor move comes back as a mouse move too; only one that lands + // somewhere else is the mouse or a finger. + if (pointing_ && moved_away(position, pointer_)) pointing_ = false; +} + +} // namespace dingosdk::launcher_gui diff --git a/Launcher/gamepad_input.h b/Launcher/gamepad_input.h new file mode 100644 index 00000000..45904a95 --- /dev/null +++ b/Launcher/gamepad_input.h @@ -0,0 +1,51 @@ +#pragma once + +#include + +#include +#include + +// Controller input for the launcher window. Kept free of Windows headers so +// the mapping can be tested anywhere; gui_gamepad.cpp reads the pads. +namespace dingosdk::launcher_gui { + +// One frame of a controller in XInput's XINPUT_GAMEPAD layout: an Xbox pad, +// Steam Input's virtual pad (Steam Deck), or a DualShock 4 / DualSense read +// over HID. Several pads are merged into one. +struct PadState { + bool connected{}; + std::uint16_t buttons{}; // XINPUT_GAMEPAD_* bits + std::uint8_t left_trigger{}, right_trigger{}; + std::int16_t left_x{}, left_y{}, right_x{}, right_y{}; +}; + +// Turns a pad into the launcher's ImGui input: +// - D-pad and left stick move the focus, A (Cross) presses the focused item; +// - B (Circle) closes an open combo or menu and leaves a text field, and is +// Escape otherwise, which the launcher's pages and modals already go back on; +// - the right stick moves a pointer and R3 clicks with it. Under Steam Input's +// gamepad layouts the Steam Deck's right trackpad is the right stick and its +// click is R3, so this is what makes the trackpad a mouse in the launcher. +class PadFeed { +public: + // Call once a frame after the backends' NewFrame and before + // ImGui::NewFrame(), which takes the events in. Returns where the OS + // cursor should move (client area pixels) when the pad moved the pointer. + std::optional update(ImGuiIO& io, const PadState& pad, float scale); + // A mouse move or a touch the window received (WM_MOUSEMOVE, client area + // pixels). One that is not the echo of our own cursor move takes the + // pointer back from the pad. + void mouse_moved(ImVec2 position); + // The right stick owns the pointer; ImGui draws it, since the Steam + // Deck's Game Mode may not show the system cursor. + bool pointing() const { return pointing_; } + +private: + std::uint16_t buttons_{}; + ImGuiKey back_key_{ImGuiKey_Escape}; + bool connected_{}; + bool pointing_{}; + ImVec2 pointer_{}; +}; + +} // namespace dingosdk::launcher_gui diff --git a/Launcher/gui.cpp b/Launcher/gui.cpp index 20277a07..b72f418e 100644 --- a/Launcher/gui.cpp +++ b/Launcher/gui.cpp @@ -6,6 +6,7 @@ #include "Engine/Core/Log/logging.h" #include +#include #include #include @@ -50,7 +51,7 @@ ImVec2 begin_page(const char* id, ImVec2 size) { bool tile_hit(const char* id, ImVec2 position, ImVec2 size, bool enabled, bool& hovered) { ImGui::SetCursorScreenPos(position); ImGui::BeginDisabled(!enabled); - const bool pressed = ImGui::InvisibleButton(id, size); + const bool pressed = ImGui::InvisibleButton(id, size, ImGuiButtonFlags_EnableNav); hovered = enabled && ImGui::IsItemHovered(); ImGui::EndDisabled(); if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); @@ -125,7 +126,7 @@ bool list_row(const char* id, float width, float height, bool ticked) { bool toggle(const char* id, bool* on) { const ImVec2 size(S(42), S(22)); const ImVec2 start = ImGui::GetCursorScreenPos(); - const bool pressed = ImGui::InvisibleButton(id, size); + const bool pressed = ImGui::InvisibleButton(id, size, ImGuiButtonFlags_EnableNav); if (pressed) *on = !*on; const bool hovered = ImGui::IsItemHovered(); if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); @@ -140,7 +141,7 @@ bool toggle(const char* id, bool* on) { bool more_button(const char* id, float size) { const ImVec2 start = ImGui::GetCursorScreenPos(); - const bool pressed = ImGui::InvisibleButton(id, ImVec2(size, size)); + const bool pressed = ImGui::InvisibleButton(id, ImVec2(size, size), ImGuiButtonFlags_EnableNav); const bool hovered = ImGui::IsItemHovered(); if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); auto* draw = ImGui::GetWindowDrawList(); @@ -176,6 +177,8 @@ LRESULT CALLBACK window_procedure(HWND window, UINT message, WPARAM wparam, LPAR } if (g_capturing_key && (message == WM_CHAR || message == WM_SYSCHAR || message == WM_KEYUP || message == WM_SYSKEYUP)) return 0; + if (message == WM_MOUSEMOVE && g_pad_feed) + g_pad_feed->mouse_moved(ImVec2(static_cast(GET_X_LPARAM(lparam)), static_cast(GET_Y_LPARAM(lparam)))); if (ImGui_ImplWin32_WndProcHandler(window, message, wparam, lparam)) return 1; switch (message) { case WM_NCHITTEST: { @@ -294,7 +297,8 @@ void apply_style() { colours[ImGuiCol_ModalWindowDimBg] = rgb(rgba(4, 6, 9, 0.72f)); colours[ImGuiCol_Separator] = rgb(color::outline); colours[ImGuiCol_TextSelectedBg] = rgb(rgba(1, 131, 255, 0.45f)); - colours[ImGuiCol_NavHighlight] = ImVec4(0, 0, 0, 0); + // Only shown once a controller moves the focus: the outline of the item A presses. + colours[ImGuiCol_NavCursor] = rgb(color::blue); } @@ -366,6 +370,7 @@ int run(const launcher_app::Session& session, const std::vector& a ImGui::CreateContext(); ImGui::GetIO().IniFilename = nullptr; ImGui::GetIO().LogFilename = nullptr; + ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; const auto fonts = load_fonts(); apply_style(); ImGui_ImplWin32_Init(window); @@ -414,6 +419,8 @@ int run(const launcher_app::Session& session, const std::vector& a const auto ui_storage = std::make_unique(); auto& ui = *ui_storage; ModsPanel mods_panel; + PadFeed pad_feed; + g_pad_feed = &pad_feed; bool running = true; HANDLE game{}; DWORD game_id{}; @@ -462,6 +469,16 @@ int run(const launcher_app::Session& session, const std::vector& a if (hidden || IsIconic(window)) { Sleep(hidden ? 100 : 50); continue; } ImGui_ImplDX12_NewFrame(); ImGui_ImplWin32_NewFrame(); + // A pad pressed while another window has the focus is not meant for us; + // it reads as let go, so nothing stays held while we are in the background. + const bool foreground = GetForegroundWindow() == window; + if (const auto pointer = pad_feed.update(ImGui::GetIO(), foreground ? read_pad() : PadState{}, g_scale)) { + // The system cursor follows the pad's pointer, so the two agree when the + // mouse or the touch screen is used next. ImGui draws the pointer itself, + // so a cursor the system will not move (Proton, gamescope) costs nothing. + POINT point{static_cast(pointer->x), static_cast(pointer->y)}; + if (ClientToScreen(window, &point)) SetCursorPos(point.x, point.y); + } ImGui::NewFrame(); frame(launcher, fonts, window, ui, mods_panel); ImGui::Render(); @@ -471,6 +488,7 @@ int run(const launcher_app::Session& session, const std::vector& a logging::write(logging::Level::info, logging::Channel::launcher, "Launcher drew its first frame."); } } + g_pad_feed = nullptr; ShowWindow(window, SW_HIDE); if (game) CloseHandle(game); launcher.cancel(); diff --git a/Launcher/gui_gamepad.cpp b/Launcher/gui_gamepad.cpp new file mode 100644 index 00000000..937c0355 --- /dev/null +++ b/Launcher/gui_gamepad.cpp @@ -0,0 +1,94 @@ +#include "gui_internal.h" + +#include "Extension/UI/Overlay/playstation_input.h" + +#include + +#include +#include + +namespace dingosdk::launcher_gui::detail { +namespace { + +using GetState = DWORD(WINAPI*)(DWORD, XINPUT_STATE*); +// Loaded on first use like the overlay does: a PC without the XInput runtime +// still gets a launcher, just without Xbox pads. +GetState xinput_get_state() { + static const auto get_state = []() -> GetState { + for (const auto name : {L"xinput1_4.dll", L"xinput1_3.dll", L"xinput9_1_0.dll"}) { + const auto module = LoadLibraryExW(name, nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32); + if (!module) continue; + if (const auto entry = GetProcAddress(module, "XInputGetState")) { + GetState function{}; + static_assert(sizeof(function) == sizeof(entry)); + std::memcpy(&function, &entry, sizeof(function)); + return function; + } + FreeLibrary(module); + } + return nullptr; + }(); + return get_state; +} + +// Asking an empty XInput slot is slow (it looks for a device), so only slots +// with a pad are read every frame, and one empty slot is tried a second. +struct Slots { + unsigned connected{(1u << XUSER_MAX_COUNT) - 1}; // all four tried on the first frame + DWORD next_probe{}; + ULONGLONG probe_at{}; +}; + +} // namespace + +PadState read_pad() { + static Slots slots; + PadState pad; + if (const auto get_state = xinput_get_state()) { + const auto now = GetTickCount64(); + unsigned probe{}; + if (now >= slots.probe_at) { + slots.probe_at = now + 1000; + for (DWORD tried = 0; tried < XUSER_MAX_COUNT && !probe; ++tried) { + const DWORD slot = (slots.next_probe + tried) % XUSER_MAX_COUNT; + if (!(slots.connected & (1u << slot))) probe = 1u << slot; + } + slots.next_probe = (slots.next_probe + 1) % XUSER_MAX_COUNT; + } + // Steam, DS4Windows and ViGEm put virtual pads in any slot, and an idle + // one may sit ahead of the pad in use, so every pad is merged. + for (DWORD slot = 0; slot < XUSER_MAX_COUNT; ++slot) { + const unsigned bit = 1u << slot; + if (!((slots.connected | probe) & bit)) continue; + XINPUT_STATE sample{}; + if (get_state(slot, &sample) != ERROR_SUCCESS) { slots.connected &= ~bit; continue; } + slots.connected |= bit; + const auto& gamepad = sample.Gamepad; + pad.connected = true; + pad.buttons |= gamepad.wButtons; + pad.left_trigger = std::max(pad.left_trigger, gamepad.bLeftTrigger); + pad.right_trigger = std::max(pad.right_trigger, gamepad.bRightTrigger); + const auto magnitude = [](SHORT x, SHORT y) { return std::abs(int(x)) + std::abs(int(y)); }; + if (magnitude(gamepad.sThumbLX, gamepad.sThumbLY) > magnitude(pad.left_x, pad.left_y)) { + pad.left_x = gamepad.sThumbLX; pad.left_y = gamepad.sThumbLY; + } + if (magnitude(gamepad.sThumbRX, gamepad.sThumbRY) > magnitude(pad.right_x, pad.right_y)) { + pad.right_x = gamepad.sThumbRX; pad.right_y = gamepad.sThumbRY; + } + } + } + // XInput wins whenever a pad is there: DS4Windows and Steam Input leave the + // PlayStation pad visible beside their XInput twin, and both would double up. + if (pad.connected) return pad; + const auto playstation = overlay::detail::read_playstation_pads(); + if (!playstation.available) return {}; + const auto& sony = playstation.pad; + pad.connected = true; + pad.buttons = sony.buttons; + pad.left_trigger = sony.left_trigger; pad.right_trigger = sony.right_trigger; + pad.left_x = sony.left_x; pad.left_y = sony.left_y; + pad.right_x = sony.right_x; pad.right_y = sony.right_y; + return pad; +} + +} // namespace dingosdk::launcher_gui::detail diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index 07ad623f..efb2f721 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -1,5 +1,6 @@ #pragma once +#include "gamepad_input.h" #include "launch.h" #include "text_encoding.h" #include "thunderstore.h" @@ -162,6 +163,11 @@ inline std::atomic g_captured_key{}; // Paths dropped on the window, picked up by the next frame. inline std::mutex g_dropped_mutex; inline std::vector g_dropped; +// Every connected controller merged into one: XInput pads (Xbox, and Steam +// Input's virtual pad on a Steam Deck), else a DualShock 4 / DualSense over HID. +PadState read_pad(); +// The window's controller input, told about mouse moves by the window procedure. +inline PadFeed* g_pad_feed{}; // ---------------------------------------------------------------- settings diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index 116df32c..1e6d8ee3 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -712,7 +712,8 @@ void mod_overview(const Fonts& fonts, ModsPanel& panel, const thunderstore::Inst save(panel); } ImGui::SameLine(extent.x - S(28) - S(110)); - if (ImGui::Button("CLOSE", ImVec2(S(110), 0))) close(); + // Escape too, and so a controller's B: the modal has no other way out but CLOSE. + if (ImGui::Button("CLOSE", ImVec2(S(110), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) close(); ImGui::EndPopup(); } @@ -1033,10 +1034,12 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo } ImGui::EndChild(); + // Read before the overviews: one closing on Escape must not also leave the page. + const bool popup_open = ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId); if (panel.tab == 1) package_overview(fonts, panel, frame, installing); else mod_overview(fonts, panel, installed, frame, installing); if (leave) close(); - if (!installing && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId) && !ImGui::IsAnyItemActive() && + if (!installing && !popup_open && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId) && !ImGui::IsAnyItemActive() && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { // Escape lets go of the ticked mods first, and leaves the page after that. if (panel.tab == 0 && !panel.marked.empty()) panel.marked.clear(); @@ -1072,7 +1075,11 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo if (bytes) ImGui::TextDisabled("That frees %s.", size_text(bytes).c_str()); ImGui::PopTextWrapPos(); ImGui::Spacing(); - if (ImGui::Button("Cancel", ImVec2(S(110), 0))) { panel.confirm_remove.clear(); ImGui::CloseCurrentPopup(); } + // Escape (and a controller's B) answers Cancel, never the destructive choice. + if (ImGui::Button("Cancel", ImVec2(S(110), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + panel.confirm_remove.clear(); + ImGui::CloseCurrentPopup(); + } ImGui::SameLine(); push_primary_button(); if (ImGui::Button("UNINSTALL", ImVec2(S(110), 0))) { @@ -1117,7 +1124,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo panel.conflict_name.c_str()); ImGui::PopTextWrapPos(); ImGui::Spacing(); - if (ImGui::Button("Cancel", ImVec2(S(110), 0))) { + if (ImGui::Button("Cancel", ImVec2(S(110), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { panel.conflict_name.clear(); ImGui::CloseCurrentPopup(); } diff --git a/Launcher/gui_mods_browse.cpp b/Launcher/gui_mods_browse.cpp index 827ed9d7..4b6afe60 100644 --- a/Launcher/gui_mods_browse.cpp +++ b/Launcher/gui_mods_browse.cpp @@ -676,7 +676,8 @@ void package_overview(const Fonts& fonts, ModsPanel& panel, ImVec2 size, bool in if (ImGui::Checkbox("Install with the others I tick", &ticked)) pick(store, package.full_name, ticked); ImGui::EndDisabled(); ImGui::SameLine(extent.x - S(28) - S(110)); - if (ImGui::Button("CLOSE", ImVec2(S(110), 0))) close(); + // Escape too, and so a controller's B: the modal has no other way out but CLOSE. + if (ImGui::Button("CLOSE", ImVec2(S(110), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) close(); ImGui::EndPopup(); } diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 35c239cb..e57d5b53 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -2,9 +2,10 @@ if(WIN32) add_executable(dingosdk_launcher WIN32 Launcher/main.cpp Launcher/launch.cpp Launcher/gui.cpp Launcher/gui_launcher.cpp Launcher/gui_renderer.cpp Launcher/gui_home.cpp Launcher/gui_settings.cpp Launcher/gui_sign_in.cpp Launcher/gui_mods.cpp Launcher/gui_mods_browse.cpp + Launcher/gui_gamepad.cpp Launcher/gamepad_input.cpp Launcher/updater.cpp Launcher/mod_manager.cpp Launcher/thunderstore.cpp Launcher/problem.h) target_link_libraries(dingosdk_launcher PRIVATE dingosdk_logging dingosdk_content_cache_install dingosdk_world_layer_scan dingosdk_launcher_support dingosdk_initfs - dingosdk_mod_list dingosdk_mods dingosdk_json dingosdk_miniz dingosdk_imgui winhttp shell32 dwmapi windowscodecs ole32) + dingosdk_mod_list dingosdk_mods dingosdk_json dingosdk_miniz dingosdk_imgui dingosdk_playstation_input winhttp shell32 dwmapi windowscodecs ole32) set_target_properties(dingosdk_launcher PROPERTIES OUTPUT_NAME "ReSkateLauncher") dingosdk_version_info(dingosdk_launcher "ReSkate launcher" "ReSkateLauncher.exe" VFT_APP) endif() @@ -19,6 +20,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_link_libraries(dingosdk_thunderstore_tests PRIVATE dingosdk_json dingosdk_miniz) target_include_directories(dingosdk_thunderstore_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME launcher_thunderstore COMMAND dingosdk_thunderstore_tests) + add_executable(dingosdk_gamepad_input_tests Launcher/Test/gamepad_input_tests.cpp Launcher/gamepad_input.cpp) + target_link_libraries(dingosdk_gamepad_input_tests PRIVATE dingosdk_imgui) + target_include_directories(dingosdk_gamepad_input_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME launcher_gamepad_input COMMAND dingosdk_gamepad_input_tests) add_executable(dingosdk_content_catalogs_tests Engine/Vfs/Test/content_catalogs_tests.cpp) target_link_libraries(dingosdk_content_catalogs_tests PRIVATE dingosdk_content_cache) add_test(NAME content_catalogs COMMAND dingosdk_content_catalogs_tests) diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index a1dedab8..378c9a2f 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -7,7 +7,10 @@ if(WIN32) "${PROJECT_SOURCE_DIR}/External/imgui/backends/imgui_impl_dx12.cpp" "${PROJECT_SOURCE_DIR}/External/imgui/backends/imgui_impl_win32.cpp") target_include_directories(dingosdk_imgui PUBLIC "${PROJECT_SOURCE_DIR}/External/imgui") - target_compile_definitions(dingosdk_imgui PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX UNICODE _UNICODE) + # The backend's own gamepad reads only XInput slot 0. The launcher feeds every pad + # itself (Launcher/gamepad_input.cpp), and the in-game menu does not use ImGui's gamepad keys. + target_compile_definitions(dingosdk_imgui PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX UNICODE _UNICODE + IMGUI_IMPL_WIN32_DISABLE_GAMEPAD) target_link_libraries(dingosdk_imgui PUBLIC d3d12 dxgi d3dcompiler dwmapi) add_library(dingosdk_detours STATIC diff --git a/cmake/UI.cmake b/cmake/UI.cmake index 4679520a..2df4cc16 100644 --- a/cmake/UI.cmake +++ b/cmake/UI.cmake @@ -1,3 +1,7 @@ +# DualShock 4 / DualSense read straight from HID, for the in-game menu and the launcher. +add_library(dingosdk_playstation_input STATIC Extension/UI/Overlay/playstation_input.cpp) +target_link_libraries(dingosdk_playstation_input PUBLIC dingosdk_logging PRIVATE hid cfgmgr32) + add_library(dingosdk_overlay STATIC Extension/UI/Overlay/overlay.cpp @@ -23,7 +27,6 @@ add_library(dingosdk_overlay STATIC Extension/UI/Overlay/park_previews.cpp Extension/UI/Overlay/gpu_diagnostics.cpp Extension/UI/Overlay/input_capture.cpp - Extension/UI/Overlay/playstation_input.cpp Extension/UI/Overlay/cursor.cpp Extension/UI/Overlay/progression_menu.cpp Extension/UI/Overlay/atmosphere_menu.cpp @@ -34,7 +37,7 @@ add_library(dingosdk_overlay STATIC Extension/Trainer/trainer_page.cpp Extension/Trainer/trainer_view.cpp ) -target_link_libraries(dingosdk_overlay PUBLIC dingosdk_logging dingosdk_profiler dingosdk_imgui dingosdk_hooks dingosdk_console_core dxguid PRIVATE hid cfgmgr32 shell32 dingosdk_initfs dingosdk_custom_scripts dingosdk_game_archives dingosdk_mods dingosdk_json windowscodecs ole32) +target_link_libraries(dingosdk_overlay PUBLIC dingosdk_logging dingosdk_profiler dingosdk_imgui dingosdk_hooks dingosdk_console_core dxguid PRIVATE dingosdk_playstation_input shell32 dingosdk_initfs dingosdk_custom_scripts dingosdk_game_archives dingosdk_mods dingosdk_json windowscodecs ole32) # The window shown from the moment ReSkate loads until the game's own window appears. add_library(dingosdk_startup_window STATIC Extension/UI/Startup/startup_window.cpp) From 8c7c30e3bb11306fd45e577258587ce48930e473 Mon Sep 17 00:00:00 2001 From: NorthDroplet Date: Mon, 5 Oct 2026 14:38:48 +0000 Subject: [PATCH 051/109] Launcher: controller reaches every panel; pad pointer without cursor warps - Settings' and Mods' rails, pages and lists are NavFlattened children, so the D-pad crosses between them instead of treating each as one item that only B could leave. - The pad's pointer no longer moves the system cursor. Under Proton that move came back late as a mouse move and dropped the pointer; now a mouse move counts only when it lands somewhere new. - The first R3 only shows the pointer, so a stray click does not press whatever the mouse last rested on. --- Launcher/Test/gamepad_input_tests.cpp | 84 ++++++++++++++++++--------- Launcher/gamepad_input.cpp | 35 +++++------ Launcher/gamepad_input.h | 16 ++--- Launcher/gui.cpp | 8 +-- Launcher/gui_mods.cpp | 14 +++-- Launcher/gui_mods_browse.cpp | 5 +- Launcher/gui_settings.cpp | 7 ++- 7 files changed, 96 insertions(+), 73 deletions(-) diff --git a/Launcher/Test/gamepad_input_tests.cpp b/Launcher/Test/gamepad_input_tests.cpp index bdc62a53..063f0d65 100644 --- a/Launcher/Test/gamepad_input_tests.cpp +++ b/Launcher/Test/gamepad_input_tests.cpp @@ -31,12 +31,11 @@ struct Seen { // One launcher frame: the pad goes in before NewFrame, as gui.cpp does it. // Two buttons stacked at the top left, and a menu popup opened on request. -Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, std::optional* warp = nullptr) { +Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, bool panels = false) { auto& io = ImGui::GetIO(); io.DisplaySize = ImVec2(800, 600); io.DeltaTime = 1.0f / 60.0f; - const auto moved = feed.update(io, pad, 1.0f); - if (warp) *warp = moved; + feed.update(io, pad, 1.0f); ImGui::NewFrame(); Seen seen; ImGui::SetNextWindowPos(ImVec2(0, 0)); @@ -46,6 +45,17 @@ Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, std::opti if (ImGui::InvisibleButton("first", ImVec2(200, 50), ImGuiButtonFlags_EnableNav)) seen.pressed = "first"; ImGui::SetCursorScreenPos(ImVec2(100, 200)); if (ImGui::InvisibleButton("second", ImVec2(200, 50), ImGuiButtonFlags_EnableNav)) seen.pressed = "second"; + if (panels) { + // A page's rail and content, side by side, as Settings and Mods lay them out. + ImGui::SetCursorScreenPos(ImVec2(400, 200)); + ImGui::BeginChild("##rail", ImVec2(150, 200), ImGuiChildFlags_NavFlattened); + if (ImGui::Button("tab", ImVec2(120, 40))) seen.pressed = "tab"; + ImGui::EndChild(); + ImGui::SetCursorScreenPos(ImVec2(580, 200)); + ImGui::BeginChild("##content", ImVec2(200, 200), ImGuiChildFlags_NavFlattened); + if (ImGui::Button("option", ImVec2(120, 40))) seen.pressed = "option"; + ImGui::EndChild(); + } if (open_menu) ImGui::OpenPopup("##menu"); if (ImGui::BeginPopup("##menu")) { seen.menu_open = true; @@ -145,38 +155,55 @@ int main() { check(ImGui::IsKeyDown(ImGuiKey_Escape), "An idle pad does not let go of the keyboard's Escape"); } + { + fresh(); + PadFeed feed; + constexpr std::uint16_t dpad_right = 0x0008; + for (int i = 0; i < 2; ++i) frame(feed, pad(), false, true); + const auto tap = [&](std::uint16_t button) { frame(feed, pad(button), false, true); frame(feed, pad(), false, true); }; + tap(dpad_down); // onto "first" + tap(dpad_down); // "second", level with the panels + tap(dpad_right); // the rail + tap(dpad_right); // the content + std::string pressed = frame(feed, pad(button_a), false, true).pressed; + const auto released = frame(feed, pad(), false, true).pressed; + check((pressed.empty() ? released : pressed) == "option", + "The D-pad crosses from a page's rail into its content"); + } + // ------------------------------------------------ trackpad pointer { fresh(); PadFeed feed; - ImGui::GetIO().AddMousePosEvent(400, 300); + auto& io = ImGui::GetIO(); + io.AddMousePosEvent(400, 300); + feed.mouse_moved(ImVec2(400, 300)); frame(feed, pad()); - std::optional warp; - frame(feed, pad(0, 3000, 0), false, &warp); - check(!warp && !feed.pointing(), "A thumb resting near the middle does not move the pointer"); + frame(feed, pad(0, 3000, 0)); + check(!feed.pointing() && io.MousePos.x == 400, "A thumb resting near the middle does not move the pointer"); - frame(feed, pad(0, 16000, 0), false, &warp); - check(feed.pointing() && ImGui::GetIO().MouseDrawCursor, "Tilting takes the pointer and draws it"); - check(warp && warp->x > 400 && warp->y == 300, "Tilting right moves the pointer right, from the mouse's place"); - for (int i = 0; i < 120; ++i) frame(feed, pad(0, 32767, 32767), false, &warp); - check(warp && warp->x == 799 && warp->y == 0, "The pointer stays inside the window"); + frame(feed, pad(0, 16000, 0)); frame(feed, pad()); - check(ImGui::GetIO().MousePos.x == 799 && ImGui::GetIO().MousePos.y == 0, "ImGui's mouse is where the pad left it"); + check(feed.pointing() && io.MouseDrawCursor, "Tilting takes the pointer and draws it"); + check(io.MousePos.x > 400 && io.MousePos.y == 300, "Tilting right moves the pointer right, from the mouse's place"); + for (int i = 0; i < 120; ++i) frame(feed, pad(0, 32767, 32767)); + frame(feed, pad()); + check(io.MousePos.x == 799 && io.MousePos.y == 0, "The pointer stays inside the window"); - // The system cursor reporting a stale place (Proton, gamescope) does not take the pointer back. - ImGui::GetIO().AddMousePosEvent(10, 10); - frame(feed, pad(), false, &warp); + // The system cursor stays where the mouse left it, and is reported every frame. + io.AddMousePosEvent(400, 300); frame(feed, pad()); - check(!warp && ImGui::GetIO().MousePos.x == 799, "Without tilt the pointer stays put"); + frame(feed, pad()); + check(io.MousePos.x == 799, "The system cursor's old place does not take the pointer back"); + feed.mouse_moved(ImVec2(400, 300)); // the same place, repeated by Windows + check(feed.pointing(), "A mouse move repeated in the same place keeps the pointer"); - feed.mouse_moved(ImVec2(799, 0)); // the echo of our own cursor move - check(feed.pointing(), "The echo of our own cursor move keeps the pointer"); - feed.mouse_moved(ImVec2(50, 60)); // the mouse, or a finger on the screen - ImGui::GetIO().AddMousePosEvent(50, 60); + feed.mouse_moved(ImVec2(50, 60)); // the mouse, or a finger on the screen + io.AddMousePosEvent(50, 60); frame(feed, pad()); frame(feed, pad()); - check(!feed.pointing() && !ImGui::GetIO().MouseDrawCursor, "The mouse takes the pointer back"); - check(ImGui::GetIO().MousePos.x == 50, "The mouse's place wins once it moved"); + check(!feed.pointing() && !io.MouseDrawCursor, "The mouse takes the pointer back"); + check(io.MousePos.x == 50, "The mouse's place wins once it moved"); } { fresh(); @@ -185,14 +212,15 @@ int main() { frame(feed, pad()); frame(feed, pad()); std::string pressed = frame(feed, pad(right_thumb)).pressed; - check(ImGui::GetIO().MouseDown[0], "The trackpad's click (R3) holds the left button"); - const auto released = frame(feed, pad()).pressed; - if (pressed.empty()) pressed = released; + pressed += frame(feed, pad()).pressed; + check(feed.pointing() && pressed.empty() && !ImGui::GetIO().MouseDown[0], + "The first trackpad click (R3) only shows the pointer"); + pressed = frame(feed, pad(right_thumb)).pressed; + check(ImGui::GetIO().MouseDown[0], "The next click holds the left button"); + pressed += frame(feed, pad()).pressed; check(pressed == "second", "The trackpad's click presses what the pointer is over"); check(!ImGui::GetIO().MouseDown[0], "Letting go of R3 lets go of the left button"); - frame(feed, pad(0, 20000, 0)); - check(feed.pointing(), "The pointer is the pad's"); press(feed, dpad_down); check(!feed.pointing() && ImGui::GetIO().NavVisible, "The D-pad hands the screen back to the focus"); } diff --git a/Launcher/gamepad_input.cpp b/Launcher/gamepad_input.cpp index 8380948a..fc167c4d 100644 --- a/Launcher/gamepad_input.cpp +++ b/Launcher/gamepad_input.cpp @@ -29,8 +29,7 @@ constexpr float left_dead_zone = 7849.0f / 32767.0f; constexpr float right_dead_zone = 0.2f; // Pointer speed at full tilt, in design pixels a second. constexpr float pointer_speed = 1400.0f; -// A mouse move this close to the pad's pointer is the echo of moving the -// system cursor there; one further away is the mouse or a finger. +// A mouse move this close to the last one is the same place reported again. constexpr float pointer_slack = 2.0f; float axis(std::int16_t value) { return std::clamp(static_cast(value) / 32767.0f, -1.0f, 1.0f); } @@ -52,13 +51,13 @@ bool imgui_cancels() { return top && !(top->Flags & ImGuiWindowFlags_Modal); } -bool moved_away(ImVec2 position, ImVec2 pointer) { - return std::abs(position.x - pointer.x) > pointer_slack || std::abs(position.y - pointer.y) > pointer_slack; +bool moved_away(ImVec2 position, ImVec2 before) { + return std::abs(position.x - before.x) > pointer_slack || std::abs(position.y - before.y) > pointer_slack; } } // namespace -std::optional PadFeed::update(ImGuiIO& io, const PadState& input, float scale) { +void PadFeed::update(ImGuiIO& io, const PadState& input, float scale) { // A pad that went away lets go of everything it held. const PadState pad = input.connected ? input : PadState{}; const auto changed = static_cast(pad.buttons ^ buttons_); @@ -90,12 +89,13 @@ std::optional PadFeed::update(ImGuiIO& io, const PadState& input, float if ((pressed & focus_bits) || std::abs(left_x) > left_dead_zone || std::abs(left_y) > left_dead_zone) pointing_ = false; - bool warp = false; const float x = axis(pad.right_x), y = -axis(pad.right_y); // screen Y grows downward const float tilt = std::min(1.0f, std::sqrt(x * x + y * y)); + // The first R3 only shows the pointer: a stray click must not press + // whatever the mouse last rested on. + const bool click = pointing_ && (pressed & right_thumb); if (!pointing_ && (tilt > right_dead_zone || (pressed & right_thumb))) { pointing_ = true; - warp = true; // From wherever the mouse left it, which may be outside the window. pointer_ = ImGui::IsMousePosValid(&io.MousePos) ? io.MousePos : ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f); @@ -107,31 +107,28 @@ std::optional PadFeed::update(ImGuiIO& io, const PadState& input, float const float step = speed * speed * pointer_speed * scale * io.DeltaTime / tilt; pointer_.x += x * step; pointer_.y += y * step; - warp = true; } if (pointing_) { pointer_.x = std::clamp(pointer_.x, 0.0f, std::max(0.0f, io.DisplaySize.x - 1)); pointer_.y = std::clamp(pointer_.y, 0.0f, std::max(0.0f, io.DisplaySize.y - 1)); - // Every frame, after the backend's own: it may report a system cursor - // that Proton or gamescope did not move along with ours. + // Every frame, after the backend's own report of the system cursor, + // which stays where the mouse left it. io.AddMouseSourceEvent(ImGuiMouseSource_Mouse); io.AddMousePosEvent(pointer_.x, pointer_.y); } - // The release is sent even when the mouse took the pointer meanwhile: the - // press was ours. - if (changed & right_thumb) { + if (click || (clicking_ && (changed & right_thumb))) { + clicking_ = click; io.AddMouseSourceEvent(ImGuiMouseSource_Mouse); - io.AddMouseButtonEvent(ImGuiMouseButton_Left, (pad.buttons & right_thumb) != 0); + io.AddMouseButtonEvent(ImGuiMouseButton_Left, click); } io.MouseDrawCursor = pointing_; - if (warp) return pointer_; - return std::nullopt; } void PadFeed::mouse_moved(ImVec2 position) { - // Our own cursor move comes back as a mouse move too; only one that lands - // somewhere else is the mouse or a finger. - if (pointing_ && moved_away(position, pointer_)) pointing_ = false; + // Windows repeats a mouse move where the mouse already was when a window + // changes under it; only a move to a new place is the mouse or a finger. + if (pointing_ && moved_away(position, mouse_)) pointing_ = false; + mouse_ = position; } } // namespace dingosdk::launcher_gui diff --git a/Launcher/gamepad_input.h b/Launcher/gamepad_input.h index 45904a95..3a9dc332 100644 --- a/Launcher/gamepad_input.h +++ b/Launcher/gamepad_input.h @@ -3,7 +3,6 @@ #include #include -#include // Controller input for the launcher window. Kept free of Windows headers so // the mapping can be tested anywhere; gui_gamepad.cpp reads the pads. @@ -29,15 +28,14 @@ struct PadState { class PadFeed { public: // Call once a frame after the backends' NewFrame and before - // ImGui::NewFrame(), which takes the events in. Returns where the OS - // cursor should move (client area pixels) when the pad moved the pointer. - std::optional update(ImGuiIO& io, const PadState& pad, float scale); + // ImGui::NewFrame(), which takes the events in. + void update(ImGuiIO& io, const PadState& pad, float scale); // A mouse move or a touch the window received (WM_MOUSEMOVE, client area - // pixels). One that is not the echo of our own cursor move takes the - // pointer back from the pad. + // pixels). A move to a new place takes the pointer back from the pad. void mouse_moved(ImVec2 position); - // The right stick owns the pointer; ImGui draws it, since the Steam - // Deck's Game Mode may not show the system cursor. + // The right stick owns the pointer. ImGui draws it and the system cursor + // stays put: under Proton a moved system cursor comes back late as a + // mouse move, which would look like the mouse taking over. bool pointing() const { return pointing_; } private: @@ -45,7 +43,9 @@ private: ImGuiKey back_key_{ImGuiKey_Escape}; bool connected_{}; bool pointing_{}; + bool clicking_{}; // R3 holds the left button ImVec2 pointer_{}; + ImVec2 mouse_{-1, -1}; // where the last mouse move was }; } // namespace dingosdk::launcher_gui diff --git a/Launcher/gui.cpp b/Launcher/gui.cpp index b72f418e..f29efac4 100644 --- a/Launcher/gui.cpp +++ b/Launcher/gui.cpp @@ -472,13 +472,7 @@ int run(const launcher_app::Session& session, const std::vector& a // A pad pressed while another window has the focus is not meant for us; // it reads as let go, so nothing stays held while we are in the background. const bool foreground = GetForegroundWindow() == window; - if (const auto pointer = pad_feed.update(ImGui::GetIO(), foreground ? read_pad() : PadState{}, g_scale)) { - // The system cursor follows the pad's pointer, so the two agree when the - // mouse or the touch screen is used next. ImGui draws the pointer itself, - // so a cursor the system will not move (Proton, gamescope) costs nothing. - POINT point{static_cast(pointer->x), static_cast(pointer->y)}; - if (ClientToScreen(window, &point)) SetCursorPos(point.x, point.y); - } + pad_feed.update(ImGui::GetIO(), foreground ? read_pad() : PadState{}, g_scale); ImGui::NewFrame(); frame(launcher, fonts, window, ui, mods_panel); ImGui::Render(); diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index 1e6d8ee3..9c26f679 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -571,7 +571,7 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co // ------------------------------------------------ the list, and under it how its order works const float hint = ImGui::GetTextLineHeightWithSpacing(); const float body = std::max(S(120), height - (ImGui::GetCursorPosY() - top) - hint); - ImGui::BeginChild("##mod_list", ImVec2(0, body), ImGuiChildFlags_Borders); + ImGui::BeginChild("##mod_list", ImVec2(0, body), ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened); const auto note = [](const char* first, const char* second = nullptr) { ImGui::Spacing(); ImGui::Indent(S(14)); @@ -670,7 +670,8 @@ void mod_overview(const Fonts& fonts, ModsPanel& panel, const thunderstore::Inst ImGui::Spacing(); ImGui::BeginChild("##mod_overview_body", - ImVec2(0, std::max(S(80), extent.y - ImGui::GetCursorPosY() - S(24) - ImGui::GetFrameHeight()))); + ImVec2(0, std::max(S(80), extent.y - ImGui::GetCursorPosY() - S(24) - ImGui::GetFrameHeight())), + ImGuiChildFlags_NavFlattened); ImGui::PushTextWrapPos(0); if (!mod.outdated.empty()) ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger), @@ -850,7 +851,7 @@ void mods_broken_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& const float footer = ImGui::GetFrameHeight() + ImGui::GetTextLineHeight() + S(56); ImGui::BeginChild("##broken_list", ImVec2(0, frame.y - ImGui::GetCursorPosY() - footer), - ImGuiChildFlags_Borders); + ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened); for (const auto& problem : problems) { ImGui::PushFont(fonts.bold); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color::danger)); @@ -934,7 +935,7 @@ void mods_outdated_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, U const float footer = ImGui::GetFrameHeight() + S(44); ImGui::BeginChild("##outdated_list", ImVec2(0, frame.y - ImGui::GetCursorPosY() - footer), - ImGuiChildFlags_Borders); + ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened); for (const auto* package : pending) { const auto found = installed.find(thunderstore::folder_for(package->full_name)); ImGui::PushFont(fonts.bold); @@ -1013,12 +1014,13 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo const float rail_top = top + title_size * 2 + S(16); ImGui::SetCursorPos(ImVec2(rail_x, rail_top)); - ImGui::BeginChild("##rail", ImVec2(rail_width, bottom - rail_top)); + // Flattened, so a controller's D-pad crosses from the rail into the list and back. + ImGui::BeginChild("##rail", ImVec2(rail_width, bottom - rail_top), ImGuiChildFlags_NavFlattened); const bool leave = rail(fonts, panel, window, rail_width, pending, installing); ImGui::EndChild(); ImGui::SetCursorPos(ImVec2(content_x, top)); - ImGui::BeginChild("##content", ImVec2(frame.x - content_x - S(28), bottom - top)); + ImGui::BeginChild("##content", ImVec2(frame.x - content_x - S(28), bottom - top), ImGuiChildFlags_NavFlattened); // With nothing to say, the list runs all the way down to BACK's bottom // edge; a message takes two lines off it until it is gone. const float status = panel.message.empty() ? S(4) : ImGui::GetTextLineHeight() * 2 + S(12); diff --git a/Launcher/gui_mods_browse.cpp b/Launcher/gui_mods_browse.cpp index 4b6afe60..35b17ad7 100644 --- a/Launcher/gui_mods_browse.cpp +++ b/Launcher/gui_mods_browse.cpp @@ -474,7 +474,7 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float const auto packages = visible_packages(store, installed); // ------------------------------------------------ the list - ImGui::BeginChild("##store_list", ImVec2(0, body), ImGuiChildFlags_Borders); + ImGui::BeginChild("##store_list", ImVec2(0, body), ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened); const auto note = [](const char* text) { ImGui::Spacing(); ImGui::Indent(S(14)); @@ -653,7 +653,8 @@ void package_overview(const Fonts& fonts, ModsPanel& panel, ImVec2 size, bool in ImGui::Spacing(); ImGui::BeginChild("##overview_body", - ImVec2(0, std::max(S(80), extent.y - ImGui::GetCursorPosY() - S(24) - ImGui::GetFrameHeight()))); + ImVec2(0, std::max(S(80), extent.y - ImGui::GetCursorPosY() - S(24) - ImGui::GetFrameHeight())), + ImGuiChildFlags_NavFlattened); ImGui::PushTextWrapPos(0); if (package.deprecated) ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning), diff --git a/Launcher/gui_settings.cpp b/Launcher/gui_settings.cpp index 394d5eb4..ae1ef4c3 100644 --- a/Launcher/gui_settings.cpp +++ b/Launcher/gui_settings.cpp @@ -123,7 +123,8 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui // ------------------------------------------------ rail const float rail_top = top + title_size + S(20); ImGui::SetCursorPos(ImVec2(rail_x, rail_top)); - ImGui::BeginChild("##settings_rail", ImVec2(rail_width, bottom - rail_top)); + // Flattened, so a controller's D-pad crosses from the rail into the page and back. + ImGui::BeginChild("##settings_rail", ImVec2(rail_width, bottom - rail_top), ImGuiChildFlags_NavFlattened); static constexpr std::array names{"GAME", "DISPLAY", "KEYS", "ADVANCED"}; ImGui::BeginDisabled(binding); for (int i = 0; i < static_cast(names.size()); ++i) @@ -140,9 +141,9 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui // Kept to a readable column: settings are sentences, not a table. ImGui::SetCursorPos(ImVec2(content_x, top)); ImGui::BeginChild("##settings_content", - ImVec2(std::min(S(760), frame.x - content_x - S(28)), bottom - top)); + ImVec2(std::min(S(760), frame.x - content_x - S(28)), bottom - top), ImGuiChildFlags_NavFlattened); const float footer = ImGui::GetTextLineHeight() + S(16); - ImGui::BeginChild("##settings_page", ImVec2(0, ImGui::GetWindowHeight() - footer)); + ImGui::BeginChild("##settings_page", ImVec2(0, ImGui::GetWindowHeight() - footer), ImGuiChildFlags_NavFlattened); switch (ui.settings_tab) { case 0: { section_caption(fonts, "GAME FOLDER"); From deaa041f24f7f08c994f8b8b07e844c5f3fe02e5 Mon Sep 17 00:00:00 2001 From: NorthDroplet Date: Mon, 5 Oct 2026 15:20:22 +0000 Subject: [PATCH 052/109] Launcher: controller focus stays inside pages; mouse cursor hides while it moves begin_panel and begin_page focused their window again every frame. With a controller that pulled the focus out of the page's child panels, so in Settings and the Mod manager (GET MODS included) the D-pad and left stick could reach nothing. The window is now focused only when the focus is not already inside it. While the pad moves the focus the mouse cursor hides, and the next mouse move brings it back. --- Launcher/gui.cpp | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/Launcher/gui.cpp b/Launcher/gui.cpp index f29efac4..5b82c64d 100644 --- a/Launcher/gui.cpp +++ b/Launcher/gui.cpp @@ -9,6 +9,8 @@ #include #include +#include + #include #include @@ -21,6 +23,21 @@ extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND, UINT, WPARAM, namespace dingosdk::launcher_gui { namespace detail { +namespace { + +// Keeps a panel or page in front, but not over its own open combo or popup, and +// not while the focus is already inside it: focusing the window again every +// frame would pull a controller's focus out of its child panels. +void keep_in_front(const char* id) { + if (ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId)) return; + const ImGuiWindow* window = ImGui::FindWindowByName(id); + const ImGuiWindow* focused = GImGui->NavWindow; + if (window && focused && focused->RootWindow == window) return; + ImGui::SetNextWindowFocus(); +} + +} // namespace + void panel_title(const Fonts& fonts, const char* text) { ImGui::PushFont(fonts.tile); ImGui::TextUnformatted(text); @@ -30,8 +47,7 @@ void panel_title(const Fonts& fonts, const char* text) { ImVec2 begin_panel(const char* id, ImVec2 size, ImVec2 panel) { ImGui::SetNextWindowPos(ImVec2((size.x - panel.x) * 0.5f, (size.y - panel.y) * 0.5f)); ImGui::SetNextWindowSize(panel); - // Keep panels in front, but not over their own open combo or popup. - if (!ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId)) ImGui::SetNextWindowFocus(); + keep_in_front(id); ImGui::Begin(id, nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings); return panel; } @@ -39,7 +55,7 @@ ImVec2 begin_panel(const char* id, ImVec2 size, ImVec2 panel) { ImVec2 begin_page(const char* id, ImVec2 size) { ImGui::SetNextWindowPos(ImVec2(0, 0)); ImGui::SetNextWindowSize(size); - if (!ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId)) ImGui::SetNextWindowFocus(); + keep_in_front(id); // Opaque, and darker than a tile: a page covers the window, so the main // screen must not show through, and its tiles need something to sit on. ImGui::PushStyleColor(ImGuiCol_WindowBg, ImGui::ColorConvertU32ToFloat4(rgba(14, 15, 18))); @@ -475,6 +491,10 @@ int run(const launcher_app::Session& session, const std::vector& a pad_feed.update(ImGui::GetIO(), foreground ? read_pad() : PadState{}, g_scale); ImGui::NewFrame(); frame(launcher, fonts, window, ui, mods_panel); + // While the pad moves the focus, the mouse cursor (the Deck's trackpad in its + // desktop layout) hides; the next mouse move hides the focus and brings it back. + if (GImGui->NavCursorVisible && GImGui->NavHighlightItemUnderNav && !pad_feed.pointing()) + ImGui::SetMouseCursor(ImGuiMouseCursor_None); ImGui::Render(); renderer.render(); if (!drew) { From 21d5a0ba4bdfd6e588a5c92043b19a68764a7f42 Mon Sep 17 00:00:00 2001 From: NorthDroplet Date: Mon, 5 Oct 2026 15:26:41 +0000 Subject: [PATCH 053/109] Launcher: the left stick scrolls under the focus or the pointer ImGui 1.91 moves the focus with the D-pad only; its left stick scrolls the focused window, which under a pointer is usually the page itself. The stick also handed the screen from the pointer to the focus, so scrolling flipped between the two. The launcher now scrolls itself: the nearest scrollable panel around the focus while the focus is shown, else the panel under the pointer or the mouse (as wheel notches). Scrolling no longer switches between them. --- Launcher/Test/gamepad_input_tests.cpp | 64 +++++++++++++++++++++++++-- Launcher/gamepad_input.cpp | 38 ++++++++++------ Launcher/gamepad_input.h | 4 +- 3 files changed, 86 insertions(+), 20 deletions(-) diff --git a/Launcher/Test/gamepad_input_tests.cpp b/Launcher/Test/gamepad_input_tests.cpp index 063f0d65..736e0fa4 100644 --- a/Launcher/Test/gamepad_input_tests.cpp +++ b/Launcher/Test/gamepad_input_tests.cpp @@ -3,6 +3,7 @@ #include "Launcher/gamepad_input.h" #include +#include #include #include @@ -21,17 +22,18 @@ void check(bool condition, const char* what) { ++failures; } -constexpr std::uint16_t dpad_down = 0x0002, right_thumb = 0x0080, button_a = 0x1000, button_b = 0x2000; +constexpr std::uint16_t dpad_down = 0x0002, dpad_right = 0x0008, right_thumb = 0x0080, button_a = 0x1000, button_b = 0x2000; struct Seen { std::string pressed; // the button that reported a press this frame bool escape{}; bool menu_open{}; + float list_scroll{}; }; // One launcher frame: the pad goes in before NewFrame, as gui.cpp does it. // Two buttons stacked at the top left, and a menu popup opened on request. -Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, bool panels = false) { +Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, bool panels = false, bool list = false) { auto& io = ImGui::GetIO(); io.DisplaySize = ImVec2(800, 600); io.DeltaTime = 1.0f / 60.0f; @@ -56,6 +58,18 @@ Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, bool pane if (ImGui::Button("option", ImVec2(120, 40))) seen.pressed = "option"; ImGui::EndChild(); } + if (list) { + // A long list in a panel, like GET MODS. + ImGui::SetCursorScreenPos(ImVec2(400, 100)); + ImGui::BeginChild("##list", ImVec2(300, 200), ImGuiChildFlags_NavFlattened); + for (int row = 0; row < 30; ++row) { + ImGui::PushID(row); + if (ImGui::Selectable("##row", false, 0, ImVec2(280, 40))) seen.pressed = "row " + std::to_string(row); + ImGui::PopID(); + } + seen.list_scroll = ImGui::GetScrollY(); + ImGui::EndChild(); + } if (open_menu) ImGui::OpenPopup("##menu"); if (ImGui::BeginPopup("##menu")) { seen.menu_open = true; @@ -68,8 +82,9 @@ Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, bool pane return seen; } -PadState pad(std::uint16_t buttons = 0, std::int16_t right_x = 0, std::int16_t right_y = 0) { +PadState pad(std::uint16_t buttons = 0, std::int16_t right_x = 0, std::int16_t right_y = 0, std::int16_t left_y = 0) { PadState state; + state.left_y = left_y; state.connected = true; state.buttons = buttons; state.right_x = right_x; @@ -158,7 +173,6 @@ int main() { { fresh(); PadFeed feed; - constexpr std::uint16_t dpad_right = 0x0008; for (int i = 0; i < 2; ++i) frame(feed, pad(), false, true); const auto tap = [&](std::uint16_t button) { frame(feed, pad(button), false, true); frame(feed, pad(), false, true); }; tap(dpad_down); // onto "first" @@ -171,6 +185,48 @@ int main() { "The D-pad crosses from a page's rail into its content"); } + // ------------------------------------------------ scrolling + { + fresh(); + PadFeed feed; + const auto tap = [&](std::uint16_t button) { frame(feed, pad(button), false, false, true); frame(feed, pad(), false, false, true); }; + for (int i = 0; i < 2; ++i) frame(feed, pad(), false, false, true); + tap(dpad_right); // from nothing onto the list's first row + tap(dpad_right); + float scroll{}; + for (int i = 0; i < 30; ++i) scroll = frame(feed, pad(0, 0, 0, -32767), false, false, true).list_scroll; + scroll = frame(feed, pad(), false, false, true).list_scroll; + check(scroll > 100, "The left stick scrolls the panel holding the focus"); + check(!feed.pointing() && GImGui->NavCursorVisible && GImGui->NavHighlightItemUnderNav, + "Scrolling stays with the focus, not the mouse"); + for (int i = 0; i < 60; ++i) frame(feed, pad(0, 0, 0, 32767), false, false, true); + check(frame(feed, pad(), false, false, true).list_scroll == 0, "Pushing up scrolls back to the top"); + } + { + fresh(); + PadFeed feed; + ImGui::GetIO().AddMousePosEvent(500, 200); // over the list + for (int i = 0; i < 2; ++i) frame(feed, pad(), false, false, true); + frame(feed, pad(0, 0, 0, 0), false, false, true); + frame(feed, pad(right_thumb), false, false, true); // the pointer shows, without a click + frame(feed, pad(), false, false, true); + float scroll{}; + for (int i = 0; i < 30; ++i) scroll = frame(feed, pad(0, 0, 0, -32767), false, false, true).list_scroll; + scroll = frame(feed, pad(), false, false, true).list_scroll; + check(scroll > 100, "The left stick scrolls the panel under the pointer"); + check(feed.pointing(), "Scrolling keeps the pointer"); + } + { + fresh(); + PadFeed feed; + ImGui::GetIO().AddMousePosEvent(500, 200); // the mouse resting over the list + for (int i = 0; i < 2; ++i) frame(feed, pad(), false, false, true); + float scroll{}; + for (int i = 0; i < 30; ++i) scroll = frame(feed, pad(0, 0, 0, -32767), false, false, true).list_scroll; + scroll = frame(feed, pad(), false, false, true).list_scroll; + check(scroll > 100, "The left stick scrolls the panel under the mouse"); + } + // ------------------------------------------------ trackpad pointer { fresh(); diff --git a/Launcher/gamepad_input.cpp b/Launcher/gamepad_input.cpp index fc167c4d..0e551cb8 100644 --- a/Launcher/gamepad_input.cpp +++ b/Launcher/gamepad_input.cpp @@ -29,15 +29,32 @@ constexpr float left_dead_zone = 7849.0f / 32767.0f; constexpr float right_dead_zone = 0.2f; // Pointer speed at full tilt, in design pixels a second. constexpr float pointer_speed = 1400.0f; +// Scrolling at full tilt: design pixels a second for the focused panel, and +// mouse wheel notches a second (ImGui scrolls five lines a notch) under a pointer. +constexpr float scroll_speed = 1200.0f; +constexpr float wheel_speed = 16.0f; // A mouse move this close to the last one is the same place reported again. constexpr float pointer_slack = 2.0f; float axis(std::int16_t value) { return std::clamp(static_cast(value) / 32767.0f, -1.0f, 1.0f); } -void stick(ImGuiIO& io, ImGuiKey negative, ImGuiKey positive, float value) { - const auto amount = [](float tilt) { return std::clamp((tilt - left_dead_zone) / (1 - left_dead_zone), 0.0f, 1.0f); }; - io.AddKeyAnalogEvent(negative, -value > left_dead_zone, amount(-value)); - io.AddKeyAnalogEvent(positive, value > left_dead_zone, amount(value)); +// The left stick scrolls whatever the user is looking at: the panel holding +// the pad's focus while the focus is shown, else the one under the pointer. +// ImGui's own stick scrolling only ever moves the focused window, which under a +// pointer is usually the page itself, with nothing to scroll. +void scroll(ImGuiIO& io, float tilt, float scale) { + const float amount = std::copysign(std::clamp((std::abs(tilt) - left_dead_zone) / (1 - left_dead_zone), 0.0f, 1.0f), tilt); + if (amount == 0.0f) return; + const ImGuiContext& g = *GImGui; + if (g.NavCursorVisible && g.NavHighlightItemUnderNav) { + // The nearest panel around the focus that can scroll. + ImGuiWindow* window = g.NavWindow; + while (window && window->ScrollMax.y <= 0 && (window->Flags & ImGuiWindowFlags_ChildWindow)) window = window->ParentWindow; + if (window && window->ScrollMax.y > 0) + ImGui::SetScrollY(window, window->Scroll.y - amount * scroll_speed * scale * io.DeltaTime); + return; + } + io.AddMouseWheelEvent(0, amount * wheel_speed * io.DeltaTime); } // B goes to ImGui's own cancel while ImGui has something to cancel: a combo or @@ -75,19 +92,12 @@ void PadFeed::update(ImGuiIO& io, const PadState& input, float scale) { if (down) back_key_ = imgui_cancels() ? ImGuiKey_GamepadFaceRight : ImGuiKey_Escape; io.AddKeyEvent(back_key_, down); } - const float left_x = axis(pad.left_x), left_y = axis(pad.left_y); - if (pad.connected || connected_) { - // Analog values ImGui already has are dropped by ImGui itself. - stick(io, ImGuiKey_GamepadLStickLeft, ImGuiKey_GamepadLStickRight, left_x); - stick(io, ImGuiKey_GamepadLStickDown, ImGuiKey_GamepadLStickUp, left_y); - } - connected_ = pad.connected; + scroll(io, axis(pad.left_y), scale); buttons_ = pad.buttons; // Moving the focus hands the screen back to it: ImGui hides the focus while - // a pointer moves, so the pointer goes away instead. - if ((pressed & focus_bits) || std::abs(left_x) > left_dead_zone || std::abs(left_y) > left_dead_zone) - pointing_ = false; + // a pointer moves, so the pointer goes away instead. Scrolling keeps both. + if (pressed & focus_bits) pointing_ = false; const float x = axis(pad.right_x), y = -axis(pad.right_y); // screen Y grows downward const float tilt = std::min(1.0f, std::sqrt(x * x + y * y)); diff --git a/Launcher/gamepad_input.h b/Launcher/gamepad_input.h index 3a9dc332..ea1d7ac2 100644 --- a/Launcher/gamepad_input.h +++ b/Launcher/gamepad_input.h @@ -19,7 +19,8 @@ struct PadState { }; // Turns a pad into the launcher's ImGui input: -// - D-pad and left stick move the focus, A (Cross) presses the focused item; +// - the D-pad moves the focus, A (Cross) presses the focused item; +// - the left stick scrolls, under the focus or under the pointer; // - B (Circle) closes an open combo or menu and leaves a text field, and is // Escape otherwise, which the launcher's pages and modals already go back on; // - the right stick moves a pointer and R3 clicks with it. Under Steam Input's @@ -41,7 +42,6 @@ public: private: std::uint16_t buttons_{}; ImGuiKey back_key_{ImGuiKey_Escape}; - bool connected_{}; bool pointing_{}; bool clicking_{}; // R3 holds the left button ImVec2 pointer_{}; From 96d44fc8a816955fc4998df7c5f94652602747c7 Mon Sep 17 00:00:00 2001 From: wishluna <173279213+wishluna@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:01:20 -0500 Subject: [PATCH 054/109] Load a saved outfit whose slots differ from the template's A mod that adds slots to a template made every outfit saved before it fail to load (recipe_template_mismatch / slot_mismatch), and switching the mod off did the same to every outfit saved with it. The preset was blocked, so the player saw the standard skater until the profile database was edited by hand. Match the saved outfit to the template slot by slot instead of by position. A template slot the outfit has no item for starts on the game's default. A saved slot the template does not have is held, like an item from a costume mod that is not installed, and a save writes it back beside the template's slots. Template key, template version, scalar count and recipe count still have to match. Co-Authored-By: Claude Fable 5.1 --- .../local_customization_runtime.cpp | 45 ++++++++++++++----- .../local_customization_runtime.h | 4 +- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/Extension/Customization/local_customization_runtime.cpp b/Extension/Customization/local_customization_runtime.cpp index 26fb10de..3ff43b49 100644 --- a/Extension/Customization/local_customization_runtime.cpp +++ b/Extension/Customization/local_customization_runtime.cpp @@ -342,7 +342,7 @@ bool load_cosmetic_hook(const void* wrapper, 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 slot_categories; if (!cosmetic_slot_categories(native[i].resource, slot_categories)) @@ -350,11 +350,23 @@ bool load_cosmetic_hook(const void* wrapper, 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 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 @@ -379,8 +391,15 @@ bool load_cosmetic_hook(const void* wrapper, 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()); @@ -423,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; @@ -435,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); } diff --git a/Extension/Customization/local_customization_runtime.h b/Extension/Customization/local_customization_runtime.h index 609794b0..5e4c144d 100644 --- a/Extension/Customization/local_customization_runtime.h +++ b/Extension/Customization/local_customization_runtime.h @@ -21,7 +21,9 @@ struct CosmeticRuntime { std::set blocked_loadouts; // 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::less<>> held_slots; std::set diagnostics; From dd1913b647cf0c9eeb71908cb0869d011b9bd1b5 Mon Sep 17 00:00:00 2001 From: ReGlitched <64649587+ReGlitched@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:32:31 -0600 Subject: [PATCH 055/109] merge bug (material fix) --- Engine/Vfs/mod_merge_combine.cpp | 50 ++++++++++++++++++------------ Engine/Vfs/mod_merge_internal.h | 10 ++++-- Engine/Vfs/mod_merge_overrides.cpp | 24 ++++++++++++++ 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/Engine/Vfs/mod_merge_combine.cpp b/Engine/Vfs/mod_merge_combine.cpp index 03f29a1a..53115122 100644 --- a/Engine/Vfs/mod_merge_combine.cpp +++ b/Engine/Vfs/mod_merge_combine.cpp @@ -385,27 +385,39 @@ 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 overridden; - if (propagate && casBacked) { + 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.changed.find(lower(asset.name)); - if (named == overrides.changed.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& 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; + 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); diff --git a/Engine/Vfs/mod_merge_internal.h b/Engine/Vfs/mod_merge_internal.h index af6dc054..6d864ed1 100644 --- a/Engine/Vfs/mod_merge_internal.h +++ b/Engine/Vfs/mod_merge_internal.h @@ -174,7 +174,11 @@ struct AssetOverride { fb::Sha1 sha1; // the mod's version std::uint64_t originalSize{}; std::vector 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 resource; }; +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 @@ -192,6 +196,8 @@ struct AssetAddition { struct AssetOverrides { // By lower-case asset name, then the game's sha1 the change replaces. std::map, std::less<>> changed; + // Separate from EBX: a script's EBX wrapper can have the same asset name. + std::map, std::less<>> scripts; // By lower-case bundle name, in priority order. std::map, std::less<>> added; // By mod folder name: TOC chunks the mod adds that its carried additions name @@ -200,11 +206,11 @@ struct AssetOverrides { // the mod's own archives; the merge shifts them to where those archives landed. std::map, std::less<>> chunks; - [[nodiscard]] bool empty() const noexcept { return changed.empty() && added.empty(); } + [[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, the highest-priority mod's version winning. Never throws: an +// 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& mods, const std::map& modFiles, diff --git a/Engine/Vfs/mod_merge_overrides.cpp b/Engine/Vfs/mod_merge_overrides.cpp index 17e9adaf..d69a742b 100644 --- a/Engine/Vfs/mod_merge_overrides.cpp +++ b/Engine/Vfs/mod_merge_overrides.cpp @@ -78,6 +78,30 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, imported.insert(reference.fileGuid); } catch (const std::exception&) {} } + // 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> gameScripts; + for (const auto& asset : gameListing->manifest.resources) + if (asset.resourceType == luaScriptResourceType) gameScripts.emplace(lower(asset.name), &asset); + for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) { + const auto& asset = modListing->manifest.resources[index]; + if (asset.resourceType != luaScriptResourceType) continue; + const auto name = lower(asset.name); + const auto originalScript = gameScripts.find(name); + if (originalScript == gameScripts.end() || originalScript->second->sha1 == asset.sha1 || + originalScript->second->resourceId != asset.resourceId) continue; + auto& versions = out.scripts[name]; + if (versions.contains(originalScript->second->sha1)) continue; + const auto at = modListing->first + modListing->manifest.ebx.size() + index; + if (at >= modListing->files.size()) continue; + const auto& file = modListing->files[at]; + versions.emplace(originalScript->second->sha1, + AssetOverride{mod->name, asset.sha1, asset.originalSize, + store.read(file.location.patch ? mod->directory : baseRoot, + file.location, file.offset, file.size), asset}); + ++changed; + } // Resources the mod adds, kept to go with an added EBX of the same name // (a wave's sound-bank resource registers the wave with the audio system). std::set> shippedResources; From ed6b4c5affa85f541da7beda0c5df18fbcf292c6 Mon Sep 17 00:00:00 2001 From: Vebjorhk <77414086+Vebjorhk@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:32:44 +0200 Subject: [PATCH 056/109] Fix empty Social page and dead Back when reopening offline pause menu In offline mode, closing the pause menu on Social or Custom Stuff and opening it again showed an empty Social page, and Back did nothing until the player switched tabs. The game reopens the page at the last position in its own tab list (Social at position 4, the Store at position 3) before insert() swaps the Store for Custom Stuff. Offline that swap leaves the tab count at 5, so the menu never redraws the page. Keep a copy of the Store tab last for a moment so the count changes and the right page is drawn, then drop it again (trim_store). Online, and reopening on Map, Hub or Skaters, nothing changes. Co-Authored-By: Claude Opus 5.5 --- Extension/UI/NativeMenu/native_menu.cpp | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/Extension/UI/NativeMenu/native_menu.cpp b/Extension/UI/NativeMenu/native_menu.cpp index 2b25d128..bf35f60d 100644 --- a/Extension/UI/NativeMenu/native_menu.cpp +++ b/Extension/UI/NativeMenu/native_menu.cpp @@ -24,6 +24,8 @@ constexpr std::array page_slots{0, 1}; // The Store item taken out of the pause menu, its index there, and that menu. struct RemovedStore { std::vector item; unsigned index{}; Address core{}; }; RemovedStore removed_store; +// When insert() last left a copy of that Store item last in the menu (0: not there); trim_store drops it. +std::uint64_t store_last_since{}; thread_local State* current_state{}; std::uint32_t menu_key() { return our_key + state().slot * 0x100; } std::mutex callbacks_mutex; @@ -301,12 +303,23 @@ void insert(const Context& context) { require(count + missing.size() <= 16, "Native pause menu has no room for ReSkate tabs."); // Take the Store item out. Pause-menu detection needs at least five items in the stack, so // only together with ours. + bool store_last{}; if (const auto store = find_tab(context, list, count, store_tab_label); store && !missing.empty() && count - 1 + missing.size() >= 5) { const auto at = values.begin() + static_cast(*store) * stride; removed_store = {std::vector(at, at + stride), *store, s.core.handle}; values.erase(at, at + stride); --count; + // The game has already opened the page at its selected index, which it keeps + // from the player's last visit, before ours were back. Taking the Store out + // changes the tab at that index if it is the Store's or later, and the stack + // then shows the new tab's page only if its length changes too: online the + // Multiplayer tab makes it longer, but offline Custom Stuff just takes the + // Store's place, and the old page stays up, blank, with Back dead. Then keep + // a copy of the Store item last for now, so the length changes, and drop it + // once the stack has seen that (trim_store). + const auto selected = read(context.address(context.path(s.core, {content, 0x18f8355b}))); + store_last = missing.size() == 1 && selected >= static_cast(*store); } for (const auto slot : missing) { auto& owned = page_state(slot); @@ -315,6 +328,11 @@ void insert(const Context& context) { "Native menu item is unavailable."); ++count; } + if (store_last) { + values.insert(values.end(), removed_store.item.begin(), removed_store.item.end()); + ++count; + store_last_since = GetTickCount64(); + } context.array(list, values, count); // The menu was reopened (our tabs had gone): refresh every page's Back. for (unsigned slot = 0; slot < page_count; ++slot) { @@ -330,6 +348,31 @@ void insert(const Context& context) { // StackTabs observes Content.Items and creates its focus bindings. Existing // tab models, identities and callbacks remain in their original order. } +// The copy is the item last in the stack with the removed Store's key. +bool is_store_copy(std::span item) { + return removed_store.item.size() == item.size() && + std::equal(item.begin() + 0x568, item.begin() + 0x56c, removed_store.item.begin() + 0x568); +} +// Drops the Store copy insert() left last, a few frames after the stack saw it there. +void trim_store(const Context& context, Value menu) { + if (!store_last_since || GetTickCount64() < store_last_since + 50) return; + const auto list = context.path(menu, {content, items}); + unsigned count{}, stride{}; + auto values = context.array(list, 16, count, stride); + store_last_since = 0; + // Not there: the game rebuilt its tabs, the Store back in place, as the menu reopened. + if (stride != stack_item.size || count < 6 || !is_store_copy(std::span(values).last(stride))) return; + const auto target = context.path(menu, {content, 0x18f8355b}); + const auto selected = read(context.address(target)); + if (selected == static_cast(count - 1)) { + context.set(target, static_cast(count - 2)); + context.set(context.path(menu, {content, 0x55511280}), static_cast(count - 2)); + } + values.resize(values.size() - stride); + context.array(list, values, count - 1); + logging::printf(logging::Level::info, logging::Channel::ui, + "Pause menu reopened on tab %d with a Store copy last for a moment.", selected); +} Value owned_style(const Context& context, const char* name, Schema schema) { const auto found = state().assets.find(name); if (found == state().assets.end()) throw std::runtime_error(std::string("Native menu style has not loaded yet: ") + name); @@ -558,6 +601,7 @@ void tick_page(std::uintptr_t base, bool loading) noexcept { if (!active.model.handle) s.next_scan = now + 250; } if (!active.model.handle) return; + trim_store(context, active.model); if (!s.slot) { restore_last_tab(context, active.model); hide_top_bar_currency(context, active.model); @@ -632,6 +676,9 @@ bool prepare_native_menu_level_load(std::uintptr_t base) noexcept { bool ours{}; for (unsigned i = 0; i < count; ++i) { const auto first = values.data() + i * stride; + // A Store copy insert() left last: the Store goes back to its own index below. + if (removed_store.core == s.core.handle && i + 1 == count && + is_store_copy(std::span(first, stride))) continue; std::uint32_t key{}; std::memcpy(&key, first + key_offset, sizeof(key)); if (key >= our_key && key < our_key + page_count * 0x100 && (key - our_key) % 0x100 == 0) { ours = true; From b643f76c2fd024cde500ac181c1a6cf8941262f5 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Mon, 5 Oct 2026 14:40:20 -0600 Subject: [PATCH 057/109] Let server hosts choose the map pool and rotate through it --- Server/README.txt | 12 ++++++ Server/Test/server_config_tests.cpp | 34 +++++++++++++++- Server/main.cpp | 9 ++++- Server/server_config.cpp | 40 +++++++++++++++++++ Server/server_config.h | 5 +++ Server/server_host.cpp | 62 ++++++++++++++++++++++++++--- Server/server_host.h | 8 +++- Server/server_votes.cpp | 50 ++++++++++++++++++++++- 8 files changed, 210 insertions(+), 10 deletions(-) diff --git a/Server/README.txt b/Server/README.txt index 4c198d70..8d5bd478 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -42,6 +42,14 @@ name Shown in the browser (1-64 characters). map The map everyone skates, named like the game's load command: "San Vansterdam", "Isle of Grom", "Super Ultra Mega Resort", "Stadium 1", or a custom map such as "bbcity" (see Custom maps). +map_pool The maps players may vote for and the rotation goes through, + in order, e.g. ["San Vansterdam", "Isle of Grom", "bbcity"]. + Empty (the default) allows every map the server knows. + Admins can still change to any map. +map_rotation_minutes Minutes on each map before the server moves to the next + one in map_pool (default 0: off). Players get a minute's + warning; the clock waits while nobody is on, and starts + over whenever the map changes (by a vote or an admin too). max_players 1-249. password Empty for anyone; otherwise players type it to join. welcome A chat line sent to each player as they join. @@ -53,6 +61,7 @@ global_bans Turn away players the ReSkate team has banned from multiplaye "bans" apply either way. votes Player votes, each off until turned on: "map": {"enabled": true, "percent": 60} /vote map + (a map in map_pool) "kick": {"enabled": true, "percent": 60} /vote kick "time_of_day": {"enabled": true, "percent": 50} /vote tod

], the root being a folder holding the game's Data/ and +// a Mods/ folder; skipped when none is given. The merge runs there as the game would run it and +// writes Mods/.reskate. With a second argument, every asset the patch itself stores is written +// to that file, one line each, to compare two merges of the same mods. +#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/mod_merge_internal.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +namespace fb = dingosdk::frostbite; +using namespace dingosdk; +using namespace dingosdk::mods::detail; + +namespace { +using Bytes = std::vector; + +std::map failures; +// The first of each kind in full, the rest counted: one broken mod can fail ten thousand times. +void fail(const std::string& kind, const std::string& text) { + const auto count = ++failures[kind]; + if (count <= 25) std::cout << "FAIL [" << kind << "] " << text << '\n'; + else if (count == 26) std::cout << "FAIL [" << kind << "] ... more of these, counted below\n"; +} +void section(const std::string& title) { std::cout << "\n=== " << title << '\n'; } + +std::string hex(const fb::Sha1& sha) { + static constexpr char digits[] = "0123456789abcdef"; + std::string text; + for (const auto value : sha.bytes) { + text += digits[static_cast(value) >> 4]; + text += digits[static_cast(value) & 15]; + } + return text; +} +std::string key_of(const fb::BundleAsset& asset) { + return std::to_string(static_cast(asset.kind)) + ':' + + (asset.kind == fb::AssetKind::chunk ? asset.guid.string() : lower(asset.name)); +} +const char* kind_name(fb::AssetKind kind) { + return kind == fb::AssetKind::ebx ? "ebx" : kind == fb::AssetKind::resource ? "res" : "chunk"; +} +std::string name_of(const fb::BundleAsset& asset) { + return std::string(kind_name(asset.kind)) + " " + (asset.kind == fb::AssetKind::chunk ? asset.guid.string() : asset.name); +} + +struct Entry { + const fb::BundleAsset* asset{}; + fb::BundleFileInfo file; +}; +// One copy of a bundle: its asset list, and the assets in region order (ebx, resources, chunks). +struct Bundle { + std::string name; + std::optional listing; + std::vector assets; + std::map at; // asset key -> index into assets +}; +struct Toc { + std::string relative; // Win32/..., generic separators + fb::TocDocument document; + std::vector bundles; + std::map bundleAt; // lower-case name + std::set chunks; +}; + +// Where a payload really lives: 0 the game, 1 the merged patch, 2 and up a mod. +struct Place { + int layer{}; + std::uint32_t chunk{}; + std::uint16_t archive{}; + std::uint32_t offset{}, size{}; + auto operator<=>(const Place&) const = default; +}; + +struct EbxFacts { + bool parsed{}; + fb::Guid file; + std::string rootType; + std::vector imports; +}; + +// One mod's own copy of an asset that is not the game's copy of it. +struct Copy { + std::size_t mod{}; + fb::Sha1 sha1; + fb::BundleFileInfo file; +}; +// An asset at least one mod ships a copy of its own of, and what the patch has for it. +struct Contested { + std::string bundle, name; + fb::AssetKind kind{}; + bool shipped{}; // the game has this asset in this bundle + fb::Sha1 game, merged; + Place gamePlace, mergedPlace; + std::vector copies; +}; + +void walk(const fb::ebx::Value& value, const std::function& visit) { + if (const auto* pointer = std::get_if(&value.data)) { visit(*pointer); return; } + if (const auto* object = std::get_if>(&value.data)) { + if (*object) for (const auto& field : (*object)->fields) walk(field.value, visit); + return; + } + if (const auto* array = std::get_if(&value.data)) + for (const auto& element : *array) walk(element, visit); +} + +// What a list entry refers to, so the same entry can be found in another copy of the document; +// empty for an entry that is not a reference to something with a guid. +std::string identity(const fb::ebx::Document& document, const fb::ebx::Value& value) { + const auto* pointer = std::get_if(&value.data); + if (!pointer || pointer->kind == fb::ebx::PointerKind::null || pointer->index < 0) return {}; + const auto index = static_cast(pointer->index); + if (pointer->kind == fb::ebx::PointerKind::external) + return index < document.imports.size() + ? "x:" + document.imports[index].fileGuid.string() + "/" + document.imports[index].classGuid.string() : std::string{}; + return index < document.instances.size() && document.instances[index].exported + ? "i:" + document.instances[index].instanceGuid.string() : std::string{}; +} +// The document a list entry names, when it names one in another file. +const fb::Guid* named(const fb::ebx::Document& document, const fb::ebx::Value& value) { + const auto* pointer = std::get_if(&value.data); + if (!pointer || pointer->kind != fb::ebx::PointerKind::external || pointer->index < 0 || + static_cast(pointer->index) >= document.imports.size()) return nullptr; + return &document.imports[static_cast(pointer->index)].fileGuid; +} + +// Every value of a document by where it is: its instance (the root, an exported one's guid, an +// internal one's place among the internal ones), then the fields and list positions down to it. +// A plain value is spelled with a lower-case letter in front; a reference (what it leads to) and +// a list's length are spelled with an upper-case one, and are the document's shape. +struct Flat { + std::map values; + std::size_t internal{}; // internal instances: a place is all one is known by +}; +bool plain(const std::string& spelled) { return !spelled.empty() && spelled.front() >= 'a' && spelled.front() <= 'z'; } +void flatten(const fb::ebx::Document& document, const std::vector& instances, + const fb::ebx::Value& value, const std::string& path, Flat& out) { + const auto& data = value.data; + if (const auto* flag = std::get_if(&data)) out.values[path] = *flag ? "b1" : "b0"; + else if (const auto* number = std::get_if(&data)) out.values[path] = "i" + std::to_string(*number); + else if (const auto* count = std::get_if(&data)) out.values[path] = "u" + std::to_string(*count); + else if (const auto* real = std::get_if(&data)) out.values[path] = "d" + std::to_string(std::bit_cast(*real)); + else if (const auto* text = std::get_if(&data)) out.values[path] = "s" + *text; + else if (const auto* guid = std::get_if(&data)) out.values[path] = "g" + guid->string(); + else if (const auto* sha = std::get_if(&data)) out.values[path] = "h" + hex(*sha); + else if (const auto* resource = std::get_if(&data)) out.values[path] = "r" + std::to_string(resource->id); + else if (const auto* pointer = std::get_if(&data)) { + const auto index = pointer->index < 0 ? std::size_t{} : static_cast(pointer->index); + if (pointer->kind == fb::ebx::PointerKind::null || pointer->index < 0) out.values[path] = "P"; + else if (pointer->kind == fb::ebx::PointerKind::external) + out.values[path] = index < document.imports.size() + ? "P" + document.imports[index].fileGuid.string() + "/" + document.imports[index].classGuid.string() : std::string("P?"); + else out.values[path] = index < instances.size() ? "P" + instances[index] : std::string("P?"); + } else if (const auto* object = std::get_if>(&data)) { + if (*object) for (const auto& field : (*object)->fields) flatten(document, instances, field.value, path + "/" + field.name, out); + } else if (const auto* array = std::get_if(&data)) { + out.values[path + "/#"] = "N" + std::to_string(array->size()); + for (std::size_t index = 0; index < array->size(); ++index) + flatten(document, instances, (*array)[index], path + "/" + std::to_string(index), out); + } +} +Flat flatten(const fb::ebx::Document& document) { + Flat out; + std::vector instances; + for (std::size_t index = 0; index < document.instances.size(); ++index) { + const auto& instance = document.instances[index]; + instances.push_back(index == 0 ? std::string("root") + : instance.exported ? "x:" + instance.instanceGuid.string() : "n:" + std::to_string(out.internal++)); + } + for (std::size_t index = 0; index < document.instances.size(); ++index) + if (const auto& object = document.instances[index].object) + for (const auto& field : object->fields) flatten(document, instances, field.value, instances[index] + "/" + field.name, out); + return out; +} + +struct Audit { + fs::path root, base, out; + mods::Catalog catalog; + vfs::Layout baseLayout, mergedLayout; + std::unique_ptr store; + // A deque: bundles hold pointers into their own asset lists, which must not move. + std::deque tocs; + std::map tocAt; // lower-case relative path + // The game's bundles by name, wherever they are, to tell a mod's own content from the game's + // copy of something it carries. + std::map gameBundles; + std::map> gameListings; + std::map facts; + std::map guids; + std::vector contested; + std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now(); + + void progress(const std::string& what) const { + const auto seconds = std::chrono::duration_cast(std::chrono::steady_clock::now() - started).count(); + std::cerr << "[" << seconds << "s] " << what << std::endl; + } + const fs::path& layer(int index) const { + return index == 0 ? base : index == 1 ? out : catalog.mods.at(static_cast(index - 2)).directory; + } + // A placement that is not a patch one is in the game, whichever layer's bundle names it. + Place place(int from, const fb::BundleFileInfo& file) const { + return {file.location.patch ? from : 0, file.location.installChunk, file.location.archive, file.offset, file.size}; + } + Bytes payload(const Place& where) const { + fb::CasDecodeOptions options{root}; + options.maximumOutputSize = 2048ULL * 1024ULL * 1024ULL; + return fb::decode_cas(store->read(layer(where.layer), {where.layer != 0, where.chunk, where.archive}, where.offset, where.size), options); + } + static EbxFacts facts_of(const Bytes& bytes) { + EbxFacts result; + try { + const auto document = fb::ebx::read_document(bytes); + result.parsed = true; + result.file = document.fileGuid; + result.rootType = document.rootType; + result.imports = document.imports; + } catch (const std::exception&) {} + return result; + } + const EbxFacts& ebx(const Place& where) { + if (const auto found = facts.find(where); found != facts.end()) return found->second; + EbxFacts result; + try { result = facts_of(payload(where)); } catch (const std::exception&) {} + return facts.emplace(where, std::move(result)).first->second; + } + const fb::Guid* guid(const Place& where) { + if (const auto found = guids.find(where); found != guids.end()) return &found->second; + if (const auto known = facts.find(where); known != facts.end() && known->second.parsed) + return &guids.emplace(where, known->second.file).first->second; + try { return &guids.emplace(where, fb::ebx::read_file_guid(payload(where))).first->second; } + catch (const std::exception&) { return nullptr; } + } + + Bundle read_bundle(int from, const fb::TocBundle& bundle) const { + Bundle result; + result.name = bundle.name; + try { + result.listing = list_bundle(*store, layer(from), base, bundle, root); + if (!result.listing) return result; + std::size_t at = result.listing->first; + for (const auto* list : {&result.listing->manifest.ebx, &result.listing->manifest.resources, &result.listing->manifest.chunks}) + for (const auto& asset : *list) { + if (at >= result.listing->files.size()) throw std::runtime_error("more assets than files"); + result.at.emplace(key_of(asset), result.assets.size()); + result.assets.push_back({&asset, result.listing->files[at++]}); + } + } catch (const std::exception&) { + result.listing.reset(); + result.assets.clear(); + result.at.clear(); + } + return result; + } + const Bundle* game_bundle(const std::string& name) { + const auto key = lower(name); + if (const auto found = gameListings.find(key); found != gameListings.end()) return found->second ? &*found->second : nullptr; + auto& slot = gameListings[key]; + if (const auto shipped = gameBundles.find(key); shipped != gameBundles.end()) + if (auto bundle = read_bundle(0, shipped->second); bundle.listing) slot = std::move(bundle); + return slot ? &*slot : nullptr; + } + // The guid of every document a copy of a bundle holds. + std::set held(int from, const Bundle& bundle, bool& unsure) { + std::set result; + for (const auto& entry : bundle.assets) { + if (entry.asset->kind != fb::AssetKind::ebx) continue; + if (const auto* id = guid(place(from, entry.file))) result.insert(*id); else unsure = true; + } + return result; + } + std::string mod_names(const std::vector& list) const { + std::set unique; + for (const auto index : list) unique.insert(catalog.mods[index].name); + std::string text; + for (const auto& name : unique) text += (text.empty() ? "" : ", ") + name; + return text; + } + + // ---------------------------------------------------------------- the merge, and reading what it wrote + bool merge() { + section("Merge"); + catalog = mods::load_catalog(root); + std::cout << catalog.mods.size() << " mod(s) merged, " << catalog.excluded.size() << " left out, " + << catalog.disabled.size() << " disabled; " << catalog.notes.size() << " note(s)\n"; + if (!catalog.issue.empty()) fail("merge", catalog.issue); + for (const auto& warning : catalog.warnings) fail("left out", warning); + if (!catalog.merged) fail("merge", "no merged patch was produced"); + return catalog.merged; + } + + void load() { + base = root / L"Data"; + out = root / L"Mods" / mods::generated_folder; + baseLayout = vfs::read_layout(base / L"layout.toc"); + mergedLayout = vfs::read_layout(out / L"layout.toc"); + store = std::make_unique(base, fs::temp_directory_path() / L"reskate-merge-audit-unused", mergedLayout.root); + + section("Superbundles"); + const auto toc_files = [](const fs::path& under) { + std::vector files; + std::error_code error; + for (fs::recursive_directory_iterator it(under / L"Win32", error), end; it != end && !error; it.increment(error)) + if (it->is_regular_file(error) && lower(it->path().extension().string()) == ".toc") files.push_back(it->path()); + std::ranges::sort(files); + return files; + }; + std::error_code error; + std::size_t bundles{}, chunks{}, unreadable{}; + for (const auto& file : toc_files(out)) { + Toc toc; + toc.relative = fs::relative(file, out, error).generic_string(); + try { + toc.document = fb::read_toc(read_file(file)); + fb::verify_toc(toc.document); + } catch (const std::exception& failure) { + fail("toc", toc.relative + ": " + failure.what()); + continue; + } + toc.bundles.reserve(toc.document.bundles.size()); + for (const auto& bundle : toc.document.bundles) { + if (!toc.bundleAt.emplace(lower(bundle.name), toc.bundles.size()).second) + fail("toc", toc.relative + " lists " + bundle.name + " twice"); + toc.bundles.push_back(read_bundle(1, bundle)); + unreadable += !toc.bundles.back().listing; + ++bundles; + } + for (const auto& chunk : toc.document.chunks) + if (!chunk.removed) toc.chunks.insert(chunk.guid); + chunks += toc.document.chunks.size(); + tocAt.emplace(lower(toc.relative), tocs.size()); + tocs.push_back(std::move(toc)); + } + std::cout << "the patch: " << tocs.size() << " superbundle TOC(s), " << bundles << " bundle(s) (" << unreadable + << " without a readable asset list), " << chunks << " TOC chunk(s)\n"; + std::size_t shipped{}; + for (const auto& file : toc_files(base)) { + ++shipped; + try { + for (auto& bundle : fb::read_toc(read_file(file)).bundles) gameBundles.try_emplace(lower(bundle.name), std::move(bundle)); + } catch (const std::exception&) {} + } + std::cout << "the game: " << shipped << " superbundle TOC(s), " << gameBundles.size() << " bundle name(s)\n"; + } + + // ---------------------------------------------------------------- placements and the layout + void placements() { + section("Placements and layout"); + using Archive = std::pair; + const auto declared_in = [](const native_db::Node& layout) { + std::set result; + for (const auto* field : {"layeredInstallChunkFiles", "unlayeredInstallChunkFiles"}) + if (const auto* node = layout.field(field); node && node->type == 19) + vfs::for_each_install_chunk_file(node->payload(), [&](std::uint32_t id, std::uint16_t archive) { result.emplace(id, archive); }); + return result; + }; + const auto declared = declared_in(mergedLayout.root), shipped = declared_in(baseLayout.root); + std::set used; + std::size_t checked{}; + const auto check = [&](const std::string& what, const fb::CasIdentifier& location, std::uint32_t offset, std::uint32_t size) { + ++checked; + const auto on_disk = store->archive_size(location.patch ? out : base, location); + if (!on_disk) { + fail("placement", what + " is in " + store->describe(location) + (location.patch ? " of the patch" : " of the game") + ", which is not there"); + return; + } + if (static_cast(offset) + size > *on_disk) + fail("placement", what + ": " + std::to_string(size) + " bytes at " + std::to_string(offset) + " run past the end of " + + store->describe(location) + " (" + std::to_string(*on_disk) + " bytes)"); + if (location.patch) used.emplace(location.installChunk, location.archive); + }; + for (const auto& toc : tocs) { + for (const auto& bundle : toc.document.bundles) { + const auto region = fb::read_bundle_region(bundle.region); + for (std::size_t index = 0; index < region.files.size(); ++index) + check(toc.relative + ": " + bundle.name + " file " + std::to_string(index), region.files[index].location, + region.files[index].offset, region.files[index].size); + } + for (const auto& chunk : toc.document.chunks) + if (!chunk.removed) check(toc.relative + ": chunk " + chunk.guid.string(), chunk.location, chunk.offset, chunk.size); + } + // An archive the layout does not declare is never opened; one it declares and the patch + // lacks sends the engine looking for a file that is absent. + for (const auto& archive : used) + if (!declared.contains(archive)) + fail("layout", "the patch reads archive " + std::to_string(archive.second) + " of install chunk " + + std::to_string(archive.first) + ", which the merged layout does not declare"); + std::size_t added{}; + for (const auto& archive : declared) { + if (shipped.contains(archive)) continue; + ++added; + if (!store->archive_size(out, {true, archive.first, archive.second})) + fail("layout", "the merged layout declares archive " + std::to_string(archive.second) + " of install chunk " + + std::to_string(archive.first) + ", and the patch has no such file"); + } + std::cout << checked << " placement(s) checked; " << used.size() << " patch archive(s) in use, " << added << " declared beyond the game's\n"; + + // The superbundle list is searched by name, so it stays in order; a mod's own superbundles + // have to be in it, in an install chunk, and have a TOC. + const auto names = [](const native_db::Node& layout) { + std::vector result; + if (const auto* list = layout.field("superBundles")) + for (const auto& row : list->children) + if (const auto* name = row.field("name"); name && name->type == 7) result.push_back(lower(name->text)); + return result; + }; + const auto merged = names(mergedLayout.root), game = names(baseLayout.root); + const std::set known(merged.begin(), merged.end()), stock(game.begin(), game.end()); + if (!std::ranges::is_sorted(merged)) fail("layout", "the merged superbundle list is not in name order"); + if (known.size() != merged.size()) fail("layout", "the merged superbundle list names one twice"); + std::set placed; + if (const auto* manifest = mergedLayout.root.field("installManifest")) + if (const auto* chunks = manifest->field("installChunks")) + for (const auto& chunk : chunks->children) + if (const auto* list = chunk.field("superbundles")) + for (const auto& entry : list->children) + if (entry.type == 7) placed.insert(lower(entry.text)); + std::size_t own{}; + for (const auto& mod : catalog.mods) { + if (!mod.provides_layout) continue; + try { + const auto layout = vfs::read_layout(mod.directory / L"layout.toc"); + for (const auto& name : names(layout.root)) { + if (stock.contains(name)) continue; + ++own; + if (!known.contains(name)) fail("layout", mod.name + ": its superbundle " + name + " is not in the merged layout"); + if (!placed.contains(name)) fail("layout", mod.name + ": its superbundle " + name + " is in no install chunk of the merged layout"); + if (!tocAt.contains(name + ".toc")) fail("layout", mod.name + ": its superbundle " + name + " has no TOC in the patch"); + } + } catch (const std::exception& failure) { + fail("layout", mod.name + ": layout.toc could not be read (" + failure.what() + ")"); + } + } + std::cout << merged.size() << " superbundle(s) in the merged layout, " << own << " listed by mods beyond the game's\n"; + } + + // ---------------------------------------------------------------- asset lists and payloads + void payloads(const fs::path& listing) { + section("Asset lists and payloads"); + // What a listed size means is learnt from the game's own assets first: only a kind whose + // every sample agrees is held to it. + std::array sampled{}, agreed{}; + std::set seen; + for (const auto& toc : tocs) + for (const auto& bundle : toc.bundles) + for (const auto& entry : bundle.assets) { + const auto kind = static_cast(entry.asset->kind); + const auto where = place(1, entry.file); + if (where.layer != 0 || sampled[kind] >= 150 || entry.file.size > (8u << 20) || !seen.insert(where).second) continue; + ++sampled[kind]; + try { agreed[kind] += payload(where).size() == entry.asset->originalSize; } catch (const std::exception&) {} + } + std::array sized{}; + for (std::size_t kind = 0; kind < 3; ++kind) sized[kind] = sampled[kind] >= 20 && agreed[kind] == sampled[kind]; + + std::map> stored; + std::size_t listed{}; + for (const auto& toc : tocs) + for (const auto& bundle : toc.bundles) { + if (bundle.at.size() != bundle.assets.size()) { + std::set names; + std::string example; + for (const auto& entry : bundle.assets) + if (!names.insert(key_of(*entry.asset)).second && example.empty()) example = name_of(*entry.asset); + fail("asset list", toc.relative + ": " + bundle.name + " lists an asset twice, e.g. " + example); + } + for (const auto& entry : bundle.assets) { + ++listed; + if (const auto where = place(1, entry.file); where.layer == 1) stored.try_emplace(where, &entry, toc.relative + ": " + bundle.name); + } + } + std::cout << listed << " asset(s) listed across the patch; " << stored.size() << " payload(s) are stored in the patch's own archives\n"; + progress("decoding " + std::to_string(stored.size()) + " payloads"); + std::ofstream written; + if (!listing.empty()) written.open(listing, std::ios::binary); + std::array decoded{}; + std::size_t bytesIn{}, bytesOut{}, unparsed{}; + for (const auto& [where, what] : stored) { + const auto& [entry, from] = what; + const auto kind = static_cast(entry->asset->kind); + try { + const auto bytes = payload(where); + ++decoded[kind]; + bytesIn += where.size; + bytesOut += bytes.size(); + if (sized[kind] && bytes.size() != entry->asset->originalSize) + fail("payload", from + ": " + name_of(*entry->asset) + " decodes to " + std::to_string(bytes.size()) + + " bytes, its asset list says " + std::to_string(entry->asset->originalSize)); + if (entry->asset->kind == fb::AssetKind::ebx) { + auto result = facts_of(bytes); + if (!result.parsed) { ++unparsed; fail("payload", from + ": " + name_of(*entry->asset) + " is not a document the merge can read"); } + facts.emplace(where, std::move(result)); + } + if (written.is_open()) written << from << '\t' << name_of(*entry->asset) << '\t' << bytes.size() << '\t' << hex(sha1_of(bytes)) << '\n'; + } catch (const std::exception& failure) { + fail("payload", from + ": " + name_of(*entry->asset) + " at " + store->describe({true, where.chunk, where.archive}) + "+" + + std::to_string(where.offset) + " cannot be decoded (" + failure.what() + ")"); + } + } + std::cout << decoded[0] << " ebx, " << decoded[1] << " res and " << decoded[2] << " chunk payload(s) decoded: " << bytesIn / (1024 * 1024) + << " MB stored, " << bytesOut / (1024 * 1024) << " MB decoded; " << unparsed << " EBX do not parse\n"; + } + + // ---------------------------------------------------------------- every mod against the patch + void mods_against_patch() { + section("Every mod against the patch"); + std::map found; // by bundle name, asset and what the patch has + std::size_t bundles{}, assets{}, chunks{}, byName{}; + for (std::size_t index = 0; index < catalog.mods.size(); ++index) { + const auto& mod = catalog.mods[index]; + if (!mod.provides_layout) continue; + if (index % 20 == 0) progress("mod " + std::to_string(index + 1) + " of " + std::to_string(catalog.mods.size())); + for (const auto& relative : scan(mod.directory).tocs) { + const auto merged = tocAt.find(lower(relative)); + if (merged == tocAt.end()) { fail("mod content", mod.name + ": the patch has no " + relative); continue; } + const auto& toc = tocs[merged->second]; + fb::TocDocument own; + try { own = fb::read_toc(read_file(mod.directory / fs::path(relative))); } + catch (const std::exception& failure) { fail("mod content", mod.name + ": " + relative + " cannot be read (" + failure.what() + ")"); continue; } + for (const auto& chunk : own.chunks) { + if (chunk.removed) continue; + ++chunks; + if (!toc.chunks.contains(chunk.guid)) + fail("mod content", mod.name + ": " + relative + ": its chunk " + chunk.guid.string() + " is not in the patch's TOC"); + } + for (const auto& bundle : own.bundles) { + ++bundles; + const auto at = toc.bundleAt.find(lower(bundle.name)); + if (at == toc.bundleAt.end()) { fail("mod content", mod.name + ": " + relative + ": its bundle " + bundle.name + " is not in the patch"); continue; } + const auto& patched = toc.bundles[at->second]; + const auto mine = read_bundle(static_cast(index + 2), bundle); + if (!mine.listing || !patched.listing) { ++byName; continue; } + const auto* game = game_bundle(bundle.name); + for (const auto& entry : mine.assets) { + ++assets; + const auto key = key_of(*entry.asset); + const auto there = patched.at.find(key); + if (there == patched.at.end()) { + fail("mod content", mod.name + ": " + relative + ": " + bundle.name + ": its " + name_of(*entry.asset) + " is not in the patch's copy"); + continue; + } + const auto& kept = patched.assets[there->second]; + const Entry* shipped{}; + if (game) if (const auto it = game->at.find(key); it != game->at.end()) shipped = &game->assets[it->second]; + // The game's copy carried along is not this mod's content. + if (shipped && shipped->asset->sha1 == entry.asset->sha1) continue; + auto& record = found[lower(bundle.name) + '|' + key + '|' + hex(kept.asset->sha1)]; + if (record.copies.empty()) { + record.bundle = bundle.name; + record.name = entry.asset->name; + record.kind = entry.asset->kind; + record.shipped = shipped != nullptr; + if (shipped) { record.game = shipped->asset->sha1; record.gamePlace = place(0, shipped->file); } + record.merged = kept.asset->sha1; + record.mergedPlace = place(1, kept.file); + } + if (std::ranges::none_of(record.copies, [&](const Copy& copy) { return copy.mod == index && copy.sha1 == entry.asset->sha1; })) + record.copies.push_back({index, entry.asset->sha1, entry.file}); + } + } + } + } + std::cout << bundles << " mod bundle(s), " << assets << " listed asset(s) and " << chunks << " TOC chunk(s) looked up in the patch; " + << byName << " bundle(s) could only be checked by name\n"; + for (auto& [key, record] : found) contested.push_back(std::move(record)); + } + + // ---------------------------------------------------------------- what became of each mod's own content + void outcomes() { + section("What became of each mod's own content"); + struct Clash { const Contested* asset; std::vector kept, lost; }; + std::vector clashes, overridden; + std::vector written; + std::size_t asShipped{}, rewritten{}; + for (const auto& record : contested) { + std::set versions; + for (const auto& copy : record.copies) versions.insert(copy.sha1); + const bool one = versions.contains(record.merged); + if (one && versions.size() == 1) { ++asShipped; continue; } + if (record.shipped && record.merged == record.game) { + fail("lost edit", record.bundle + ": " + record.name + ": " + std::to_string(record.copies.size()) + " mod(s) change it (e.g. " + + catalog.mods[record.copies.front().mod].name + ") and the patch has the game's own copy"); + continue; + } + if (!one) { + // No one mod's copy: the merge wrote it (edits combined, a change carried in). + if (record.kind == fb::AssetKind::ebx) written.push_back(&record); else ++rewritten; + continue; + } + Clash clash{&record, {}, {}}; + for (const auto& copy : record.copies) (copy.sha1 == record.merged ? clash.kept : clash.lost).push_back(copy.mod); + (record.shipped ? overridden : clashes).push_back(std::move(clash)); + } + std::cout << asShipped << " asset(s) are in the patch as their one mod ships them; the merge wrote " << written.size() + << " EBX document(s) and " << rewritten << " other asset(s) itself\n"; + + std::cout << "\nassets two mods add under one name, with different content: " << clashes.size() << '\n'; + std::map> pairs; + for (const auto& clash : clashes) pairs[mod_names(clash.lost) + " lost them to " + mod_names(clash.kept)].push_back(&clash); + for (const auto& [pair, list] : pairs) + std::cout << " " << pair << ": " << list.size() << ", e.g. " << list.front()->asset->name << " in " << list.front()->asset->bundle << '\n'; + + std::cout << "assets of the game two mods change, where the patch has one mod's copy whole: " << overridden.size() << '\n'; + std::size_t printed{}; + for (const auto& clash : overridden) { + if (++printed > 40) { std::cout << " ... and " << overridden.size() - 40 << " more\n"; break; } + std::cout << " " << kind_name(clash.asset->kind) << ' ' << clash.asset->name << " (" << clash.asset->bundle << "): kept " + << mod_names(clash.kept) << "; not in it: " << mod_names(clash.lost) << '\n'; + } + + // A reference is by the guid of the document it names. Where two mods' assets shared a + // name and one went, whatever still names the one that went finds nothing. + section("References to documents that lost their name to another mod"); + struct Gone { std::string mod, name, keptBy; }; + std::map gone; + for (const auto& clash : clashes) { + if (clash.asset->kind != fb::AssetKind::ebx) continue; + const auto& kept = ebx(clash.asset->mergedPlace); + for (const auto& copy : clash.asset->copies) { + if (copy.sha1 == clash.asset->merged) continue; + const auto& lost = ebx(place(static_cast(copy.mod + 2), copy.file)); + // The same guid under both copies still resolves, to the copy that stayed. + if (lost.parsed && kept.parsed && lost.file != kept.file) + gone.emplace(lost.file, Gone{catalog.mods[copy.mod].name, clash.asset->name, mod_names(clash.kept)}); + } + } + std::map> holders; + std::size_t references{}; + for (const auto& toc : tocs) + for (const auto& bundle : toc.bundles) + for (const auto& entry : bundle.assets) { + if (entry.asset->kind != fb::AssetKind::ebx) continue; + const auto where = place(1, entry.file); + if (where.layer != 1) continue; + for (const auto& import : ebx(where).imports) { + const auto lost = gone.find(import.fileGuid); + if (lost == gone.end()) continue; + ++references; + auto& [count, example] = holders[bundle.name + ": " + entry.asset->name + " names " + lost->second.mod + "'s " + + lost->second.name + ", which " + lost->second.keptBy + "'s replaced"]; + if (!count++) example = toc.relative; + } + } + for (const auto& [text, where] : holders) + fail("dangling reference", text + " (" + std::to_string(where.first) + " cop" + (where.first == 1 ? "y" : "ies") + ", e.g. in " + where.second + ")"); + std::cout << gone.size() << " document(s) went with a guid nothing in the patch has; " << references << " reference(s) in the patch still name one\n"; + + section("Documents the merge wrote"); + std::size_t checked{}, lists{}, kept{}, inPlace{}, valueEdits{}, reshaped{}; + for (const auto* record : written) { + fb::ebx::Document merged; + try { merged = fb::ebx::read_document(payload(record->mergedPlace)); } + catch (const std::exception& failure) { fail("merged document", record->bundle + ": " + record->name + " cannot be read back (" + failure.what() + ")"); continue; } + ++checked; + const auto label = record->bundle + ": " + record->name + " (" + merged.rootType + ")"; + // The engine binary-searches the exported instances after the root by guid, and + // internal ones have to follow them. + bool internal{}; + const fb::Guid* previous{}; + for (std::size_t index = 1; index < merged.instances.size(); ++index) { + const auto& instance = merged.instances[index]; + if (!instance.exported) { internal = true; continue; } + if (internal) { fail("merged document", label + ": an exported instance follows an internal one"); break; } + if (previous && !(*previous < instance.instanceGuid)) { fail("merged document", label + ": its exported instances are not in guid order"); break; } + previous = &instance.instanceGuid; + } + std::size_t stray{}; + for (const auto& instance : merged.instances) + if (instance.object) + for (const auto& field : instance.object->fields) + walk(field.value, [&](const fb::ebx::PointerReference& pointer) { + const auto limit = pointer.kind == fb::ebx::PointerKind::internal ? merged.instances.size() + : pointer.kind == fb::ebx::PointerKind::external ? merged.imports.size() : std::size_t{}; + stray += pointer.kind != fb::ebx::PointerKind::null && (pointer.index < 0 || static_cast(pointer.index) >= limit); + }); + if (stray) fail("merged document", label + ": " + std::to_string(stray) + " reference(s) index past the instance or import table"); + if (!record->shipped) continue; + fb::ebx::Document game; + try { game = fb::ebx::read_document(payload(record->gamePlace)); } catch (const std::exception&) { continue; } + if (!merged.root() || !merged.root()->object || !game.root() || !game.root()->object) continue; + // Every reference a mod appended to one of the root's lists is in the merged list, + // unless what it names lost its name to another mod's asset. + std::set done; + for (const auto& copy : record->copies) { + if (!done.insert(copy.sha1).second) continue; + fb::ebx::Document edit; + try { edit = fb::ebx::read_document(payload(place(static_cast(copy.mod + 2), copy.file))); } catch (const std::exception&) { continue; } + if (!edit.root() || !edit.root()->object) continue; + for (const auto& field : edit.root()->object->fields) { + const auto* added = std::get_if(&field.value.data); + const auto* before = game.root()->object->find(field.name); + const auto* after = merged.root()->object->find(field.name); + const auto* beforeList = before ? std::get_if(&before->value.data) : nullptr; + const auto* afterList = after ? std::get_if(&after->value.data) : nullptr; + if (!added || !beforeList || !afterList || added->size() <= beforeList->size()) continue; + ++lists; + std::set present; + for (const auto& value : *afterList) present.insert(identity(merged, value)); + for (auto at = beforeList->size(); at < added->size(); ++at) { + const auto id = identity(edit, (*added)[at]); + if (id.empty()) continue; + if (present.contains(id)) { ++kept; continue; } + if (const auto* target = named(edit, (*added)[at]); target && gone.contains(*target)) continue; + fail("merged list", label + ": " + field.name + " lacks an entry " + catalog.mods[copy.mod].name + " added (" + id + ")"); + } + } + } + + // Values changed in place. What the merged document has that the game's does not is + // some mod's value, and a mod that changed values and nothing else has every one of + // them changed in the merged document: to its own, or to another mod's that changed + // the same one. + const auto before = flatten(game), after = flatten(merged); + // An internal instance is known by its place alone, which says nothing once one was + // added or taken away. + const auto told = [&](const Flat& other, const std::string& key) { + return !key.starts_with("n:") || other.internal == before.internal; + }; + std::vector> edits; + done.clear(); + for (const auto& copy : record->copies) { + if (!done.insert(copy.sha1).second) continue; + try { edits.emplace_back(copy.mod, flatten(fb::ebx::read_document(payload(place(static_cast(copy.mod + 2), copy.file))))); } + catch (const std::exception&) {} + } + std::size_t invented{}; + std::string example; + for (const auto& [key, value] : before.values) { + if (!plain(value) || !told(after, key)) continue; + const auto now = after.values.find(key); + if (now == after.values.end() || now->second == value) continue; + ++inPlace; + if (std::ranges::none_of(edits, [&](const std::pair& edit) { + const auto theirs = edit.second.values.find(key); + return told(edit.second, key) && theirs != edit.second.values.end() && theirs->second == now->second; })) + if (!invented++) example = key; + } + if (invented) + fail("merged value", label + ": " + std::to_string(invented) + " value(s) are neither the game's nor any mod's, e.g. " + example); + for (const auto& [mod, edit] : edits) { + bool shape{}; // a reference moved, a list resized, something of the game's gone + std::vector changed; + for (const auto& [key, value] : before.values) { + if (!told(edit, key)) continue; + const auto theirs = edit.values.find(key); + if (theirs == edit.values.end()) { shape = true; continue; } + if (theirs->second == value) continue; + if (plain(value) && plain(theirs->second)) { changed.push_back(&key); continue; } + // A list on the root that grew at its end is the one change of shape that + // is combined, and is checked above. + const bool rootList = key.starts_with("root/") && key.ends_with("/#") && std::ranges::count(key, '/') == 2; + if (!(rootList && std::stoull(theirs->second.substr(1)) > std::stoull(value.substr(1)))) shape = true; + } + if (changed.empty()) continue; + if (shape) { ++reshaped; continue; } + std::size_t lost{}; + for (const auto* key : changed) { + const auto now = after.values.find(*key); + if (told(after, *key) && (now == after.values.end() || now->second == before.values.at(*key)) && !lost++) example = *key; + } + if (lost) + fail("lost value", label + ": " + std::to_string(lost) + " of the " + std::to_string(changed.size()) + " value(s) " + + catalog.mods[mod].name + " changed are the game's again, e.g. " + example); + ++valueEdits; + } + } + std::cout << checked << " document(s) read back; " << lists << " list(s) a mod appended to, " << kept << " appended reference(s) found in them\n"; + std::cout << inPlace << " value(s) in them differ from the game's, each one some mod's; " << valueEdits + << " mod copy(ies) that changed values alone have them all in; " << reshaped + << " that also moved a reference or resized a list are not combined that way\n"; + } + + // ---------------------------------------------------------------- lists and what they name + // The game keeps what some lists name in the list's own bundle (an item list and its items, + // the music playlist and its songs), and reads such a list without checking an entry. So in + // every copy of that bundle the patch carries, the entries mods added have to be there too. + void lists() { + section("Lists whose entries the game keeps beside them"); + struct Shipped { fb::ebx::Document document; std::set held; bool unsure{}; }; + std::map> shipped; // by bundle and asset key + std::map> missing, twice; + std::size_t copies{}, entries{}; + for (const auto& toc : tocs) + for (const auto& bundle : toc.bundles) { + const auto* game = game_bundle(bundle.name); + if (!game) continue; + std::optional> here; + bool unsure{}; + for (const auto& entry : bundle.assets) { + if (entry.asset->kind != fb::AssetKind::ebx) continue; + const auto where = place(1, entry.file); + const auto key = key_of(*entry.asset); + const auto original = game->at.find(key); + if (where.layer != 1 || original == game->at.end()) continue; + auto& known = shipped[lower(bundle.name) + '|' + key]; + if (!known) { + Shipped value; + try { value.document = fb::ebx::read_document(payload(place(0, game->assets[original->second].file))); } catch (const std::exception&) {} + known = std::move(value); + } + const auto* gameRoot = known->document.root(); + if (!gameRoot || !gameRoot->object) continue; + // Only a document with a list of references to other documents is read at all. + const auto lists_references = std::ranges::any_of(gameRoot->object->fields, [&](const fb::ebx::FieldValue& field) { + const auto* list = std::get_if(&field.value.data); + return list && !list->empty() && named(known->document, list->front()); + }); + if (!lists_references) continue; + fb::ebx::Document merged; + try { merged = fb::ebx::read_document(payload(where)); } catch (const std::exception&) { continue; } + if (!merged.root() || !merged.root()->object) continue; + for (const auto& field : merged.root()->object->fields) { + const auto* list = std::get_if(&field.value.data); + const auto* before = gameRoot->object->find(field.name); + const auto* beforeList = before ? std::get_if(&before->value.data) : nullptr; + if (!list || !beforeList || beforeList->empty() || list->size() <= beforeList->size()) continue; + // Does the game keep every one of its own entries in this bundle? + if (known->held.empty() && !known->unsure) known->held = held(0, *game, known->unsure); + if (known->unsure || !std::ranges::all_of(*beforeList, [&](const fb::ebx::Value& value) { + const auto* target = named(known->document, value); + return target && known->held.contains(*target); })) continue; + if (!here) here = held(1, bundle, unsure); + if (unsure) continue; + ++copies; + std::set seen; + std::size_t absent{}, repeated{}; + for (std::size_t index = 0; index < list->size(); ++index) { + const auto* target = named(merged, (*list)[index]); + if (!target) { if (index >= beforeList->size()) ++absent; continue; } + if (index < beforeList->size()) continue; + ++entries; + absent += !here->contains(*target); + repeated += !seen.insert(*target).second; + } + const auto what = bundle.name + ": " + field.name + " of " + entry.asset->name; + if (absent) { auto& [count, example] = missing[what + ": " + std::to_string(absent) + " of " + std::to_string(list->size() - beforeList->size()) + + " added entr" + (absent == 1 ? "y names" : "ies name") + " a document that bundle does not hold"]; + if (!count++) example = toc.relative; } + if (repeated) { auto& [count, example] = twice[what + ": " + std::to_string(repeated) + " added entr" + (repeated == 1 ? "y repeats" : "ies repeat") + " one a mod already added"]; + if (!count++) example = toc.relative; } + } + } + } + for (const auto& [text, where] : missing) + fail("list entry", text + " (" + std::to_string(where.first) + " cop" + (where.first == 1 ? "y" : "ies") + ", e.g. in " + where.second + ")"); + for (const auto& [text, where] : twice) + fail("list entry", text + " (" + std::to_string(where.first) + " cop" + (where.first == 1 ? "y" : "ies") + ", e.g. in " + where.second + ")"); + std::cout << copies << " such list(s) with entries mods added, across every copy in the patch; " << entries << " added entries checked\n"; + } + + int summary() const { + section("Summary"); + std::size_t total{}; + for (const auto& [kind, count] : failures) { std::cout << count << " x " << kind << '\n'; total += count; } + std::cout << (total ? "mod merge audit: " + std::to_string(total) + " problem(s)" : std::string("mod merge audit: ok")) << '\n'; + return total ? 1 : 0; + } +}; +} // namespace + +int main(int argc, char** argv) try { + if (argc < 2 || !fs::exists(fs::path(argv[1]) / "Data") || !fs::exists(fs::path(argv[1]) / "Mods")) { + std::cout << "No data root with Data and Mods given; skipped.\n"; + return 0; + } + Audit audit; + audit.root = argv[1]; + if (!audit.merge()) return audit.summary(); + audit.load(); + audit.placements(); + audit.payloads(argc > 2 ? fs::path(argv[2]) : fs::path{}); + audit.mods_against_patch(); + audit.outcomes(); + audit.lists(); + return audit.summary(); +} catch (const std::exception& error) { + std::cerr << "FAIL: " << error.what() << '\n'; + return 1; +} diff --git a/Engine/Vfs/mod_merge_combine.cpp b/Engine/Vfs/mod_merge_combine.cpp index 53115122..3695e4ce 100644 --- a/Engine/Vfs/mod_merge_combine.cpp +++ b/Engine/Vfs/mod_merge_combine.cpp @@ -12,6 +12,7 @@ #include #include #include +#include namespace dingosdk::mods::detail { namespace { @@ -236,6 +237,22 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, std::set addedFrom; // The mods absorbed so far, so each asset can say whose copy it is. std::set 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> addedByGuid; + // The resource a mod added to a bundle under a name, by bundle, mod and name. + std::map, 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, fb::BundleFileInfo> carriedAt; const auto absorb = [&](const fb::TocBundle& bundle, const ArchivePlacement* placement, bool isBase, const fs::path& root) { @@ -385,6 +402,8 @@ 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 overridden; + // The documents those changes refer to: the ones a mod added come along below. + std::set wanted; if (propagate && (casBacked || !region.inlineManifest.empty())) { std::string example; auto manifest = casBacked ? std::move(casManifest) : fb::read_binary_bundle(region.inlineManifest); @@ -407,6 +426,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, 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 + @@ -426,23 +446,55 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, // loads instead of the game's on this mod's levels, so it gets them too. std::vector additions; if (propagate && casBacked) { - if (const auto found = overrides.added.find(key); found != overrides.added.end()) { + std::vector take; + std::set 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 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> present; for (const auto& asset : casManifest.ebx) present.insert(asset_key(asset)); for (const auto& asset : casManifest.resources) present.insert(asset_key(asset)); - // 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); - for (const auto& addition : found->second) { - if (addition.mod == modName || addition.asset.kind == fb::AssetKind::chunk || - addition.toc == here || present.contains(asset_key(addition.asset))) continue; + const auto installChunk = region.files.front().location.installChunk; + for (const auto* addition : take) { + if (!present.insert(asset_key(addition->asset)).second) continue; try { - additions.push_back({addition.asset, store.write(region.files.front().location.installChunk, - manifestArchive, addition.encoded)}); - addedFrom.insert(addition.mod); + 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() + ")"); + report.notes.push_back(modName + ": " + bundle.name + ": " + addition->asset.name + + " from " + addition->mod + " could not be added (" + error.what() + ")"); } } if (!additions.empty()) @@ -733,15 +785,55 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, 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, - itemList ? inBundle : fb::ebx::CarryImport{}); - // One mod's copy with nothing to leave out stays as that mod wrote it. - if (summary.dropped.empty() && - (edits.size() < 2 || (!summary.instances && !summary.arrayEntries))) continue; + 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 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> left; // by edit for (const auto& gone : summary.dropped) left[gone.edit].push_back(gone.target.fileGuid); for (const auto& [index, guids] : left) { @@ -749,8 +841,9 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, const Asset* taken{}; for (const auto& guid : guids) if ((taken = held.replacement(guid))) break; - report.notes.push_back(path_utf8(edits[index]->root.filename()) + ": " + entry.asset.name + - ": " + std::to_string(guids.size()) + " item(s) left out of the list because the merged " + 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" diff --git a/Engine/Vfs/mod_merge_internal.h b/Engine/Vfs/mod_merge_internal.h index 52c483e6..c2dac61e 100644 --- a/Engine/Vfs/mod_merge_internal.h +++ b/Engine/Vfs/mod_merge_internal.h @@ -180,13 +180,19 @@ struct AssetOverride { // Present for a Lua resource replacement: its identity and metadata must // travel with the script bytes, including source and bytecode sizes. std::optional 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 names; }; 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. +// 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; @@ -195,6 +201,10 @@ struct AssetAddition { // 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 names; }; struct AssetOverrides { // By lower-case asset name, then the game's sha1 the change replaces. diff --git a/Engine/Vfs/mod_merge_overrides.cpp b/Engine/Vfs/mod_merge_overrides.cpp index 23cbbe1e..049e7c73 100644 --- a/Engine/Vfs/mod_merge_overrides.cpp +++ b/Engine/Vfs/mod_merge_overrides.cpp @@ -31,6 +31,20 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, std::vector> candidates, companions; std::vector newChunks; // TOC chunks the game's TOCs do not have std::set 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 encoded; + std::optional gameFile; // where the game's copy is + }; + std::vector changes; const auto decoded = [&](std::span encoded) { return fb::ebx::read_document(fb::decode_cas(encoded, {gameRoot})); }; @@ -53,13 +67,17 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, 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> 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::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; + 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]; @@ -71,44 +89,25 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, candidates.push_back({lower(bundle.name), {mod->name, asset, payload(), lower(relative)}}); continue; } - const auto encodedPayload = payload(); + auto encodedPayload = payload(); try { for (const auto& reference : decoded(encodedPayload).imports) imported.insert(reference.fileGuid); } catch (const std::exception&) {} - auto& versions = out.changed[name]; - const auto existing = versions.find(game_copy->second); - if (existing == versions.end()) { - versions.emplace(game_copy->second, - AssetOverride{mod->name, asset.sha1, asset.originalSize, encodedPayload}); - ++changed; - } else { - try { - std::size_t baseIndex = 0; - while (baseIndex < gameListing->manifest.ebx.size() && - lower(gameListing->manifest.ebx[baseIndex].name) != name) - ++baseIndex; - if (gameListing->first + baseIndex < gameListing->files.size()) { - const auto& baseFile = gameListing->files[gameListing->first + baseIndex]; - const auto baseBytes = store.read(baseRoot, baseFile.location, baseFile.offset, baseFile.size); - const auto baseDoc = decoded(baseBytes); - const auto existingDoc = decoded(existing->second.encoded); - const auto thisDoc = decoded(encodedPayload); - const std::array editDocs{&existingDoc, &thisDoc}; - fb::ebx::MergeSummary summary; - auto combined = fb::ebx::merge_documents(baseDoc, editDocs, &summary); - if (summary.instances || summary.arrayEntries) { - 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}); - existing->second.mod += " + " + mod->name; - for (const auto& reference : combined.imports) - imported.insert(reference.fileGuid); - } - } + // 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 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. @@ -157,6 +156,9 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, // 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::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 lost; const auto keep = [&](const std::string& bundle, AssetAddition addition) { auto& list = out.added[bundle]; const auto name = lower(addition.asset.name); @@ -166,6 +168,8 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, 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; } @@ -174,23 +178,25 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, }; // Transitively: an addition a following addition imports follows too (a new song // imports its new wave, and only the song is named by the changed playlist). - struct Candidate { fb::Guid file; std::vector imports; bool taken{}; }; - std::vector parsed(candidates.size()); + std::vector followed(candidates.size()); + // The documents this mod added to each bundle, by guid. + std::map, std::less<>> addedIn; for (std::size_t index = 0; index < candidates.size(); ++index) { + auto& addition = candidates[index].second; try { - const auto document = decoded(candidates[index].second.encoded); - parsed[index].file = document.fileGuid; - for (const auto& reference : document.imports) parsed[index].imports.push_back(reference.fileGuid); - } catch (const std::exception&) { parsed[index].taken = true; } // unreadable: never follows + 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& candidate = parsed[index]; - if (candidate.taken || !imported.contains(candidate.file)) continue; - candidate.taken = grew = true; - imported.insert(candidate.imports.begin(), candidate.imports.end()); 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)); } } @@ -204,6 +210,65 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, 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 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 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 + diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 355270b0..7c77a21c 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -68,6 +68,14 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_link_libraries(dingosdk_item_list_merge_tests PRIVATE dingosdk_mods) target_include_directories(dingosdk_item_list_merge_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME item_list_merge COMMAND dingosdk_item_list_merge_tests "${DINGOSDK_TEST_GAME_ROOT}") + add_executable(dingosdk_ebx_merge_tests Engine/Vfs/Test/ebx_merge_tests.cpp) + target_link_libraries(dingosdk_ebx_merge_tests PRIVATE dingosdk_mods) + target_include_directories(dingosdk_ebx_merge_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME ebx_merge COMMAND dingosdk_ebx_merge_tests) + add_executable(dingosdk_mod_merge_audit_tests Engine/Vfs/Test/mod_merge_audit_tests.cpp) + target_link_libraries(dingosdk_mod_merge_audit_tests PRIVATE dingosdk_mods) + target_include_directories(dingosdk_mod_merge_audit_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME mod_merge_audit COMMAND dingosdk_mod_merge_audit_tests "${DINGOSDK_TEST_GAME_ROOT}") add_executable(dingosdk_store_copies_tests Engine/Vfs/Test/store_copies_tests.cpp) target_link_libraries(dingosdk_store_copies_tests PRIVATE dingosdk_mods dingosdk_content_cache) target_include_directories(dingosdk_store_copies_tests PRIVATE "${PROJECT_SOURCE_DIR}") From dcc5e31a679144f1bac09934d58d9de7ba5ecc6a Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Mon, 5 Oct 2026 18:57:42 -0500 Subject: [PATCH 067/109] Update server_host.cpp --- Server/server_host.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 40d2b336..ddf72022 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -914,8 +914,8 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { } } if (recipients.empty()) return "No admins are online."; - const auto line = dm_line(from, scope, text, multiplayer_chat_max_bytes); - for (auto *guest : recipients) send_chat(line, guest); + const auto message = dm_line(from, scope, text, multiplayer_chat_max_bytes); + for (auto *guest : recipients) send_chat(message, guest); const auto done = "Sent to " + label + (recipients.size() > 1 || to_admins ? " (" + std::to_string(recipients.size()) + " players)" : "") + "."; if (!console) log_("[dm] " + from + " -> " + label + ": " + clean_chat_text(text)); return done; From 3db1ef7ca57b0b0189776b7288f7d346b18ffce5 Mon Sep 17 00:00:00 2001 From: ReSkate local Date: Tue, 6 Oct 2026 02:07:26 +0100 Subject: [PATCH 068/109] Music: release Mods shelf before map unloading --- .../Test/local_music_shelf_lifetime_tests.cpp | 84 +++++++++++++++++++ Extension/Music/local_music_shelf.cpp | 80 +++++++++++++++--- Extension/Music/local_music_shelf.h | 1 + Extension/Music/local_music_shelf_lifetime.h | 18 ++++ Extension/Profile/local_profile_runtime.cpp | 1 + cmake/Apps.cmake | 4 + contrib/native-music-ui.md | 11 +++ 7 files changed, 188 insertions(+), 11 deletions(-) create mode 100644 Extension/Music/Test/local_music_shelf_lifetime_tests.cpp create mode 100644 Extension/Music/local_music_shelf_lifetime.h diff --git a/Extension/Music/Test/local_music_shelf_lifetime_tests.cpp b/Extension/Music/Test/local_music_shelf_lifetime_tests.cpp new file mode 100644 index 00000000..e4795676 --- /dev/null +++ b/Extension/Music/Test/local_music_shelf_lifetime_tests.cpp @@ -0,0 +1,84 @@ +#include "Extension/Music/local_music_shelf_lifetime.h" +#include +#include +#include +#include + +namespace md = dingosdk::multiplayer::menu_data; +struct Ref { std::uint64_t record, handle; bool operator==(const Ref&) const = default; }; +struct Model { std::uint64_t handle, type; }; +void check(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +void shelf_transition(unsigned leaving, unsigned active) { + md::MenuLifetime lifetime; + md::OwnedMenuModels models; + lifetime.before_transition(13, [] {}); + const std::vector native{{11, 100}, {22, 200}, {33, 300}}; + auto refs = native; + refs.insert(refs.begin() + 1, Ref{22, 401}); // Tagged anchor, same record as native Liked. + models.track({400, 1}); models.track({500, 2}); + bool child_bound = true, assets_live = true; + unsigned cleanups{}; + std::vector destroyed; + const auto cleanup = [&] { + ++cleanups; + check(lifetime.blocked(), "Block shelf creation and polling before detach callbacks"); + models.release([&] { + refs = dingosdk::profile_runtime::music_shelf_detached(std::span{refs}, 400); + child_bound = false; + }, [&](Model model) { + check(assets_live, "Destroy widget-bearing rows before unloading their assets"); + check(refs == native && !child_bound, "Detach shelf and child bindings before destruction"); + destroyed.push_back(model.handle); + }); + }; + lifetime.before_transition(leaving, cleanup); + assets_live = false; + for (unsigned next : {leaving, 3U, 4U, 5U, 12U}) { + lifetime.before_transition(next, cleanup); + check(lifetime.blocked(), "Loading ticks must not recreate or republish the shelf"); + } + check(cleanups == 1 && models.empty() && destroyed == std::vector{500, 400}, + "Release both roots exactly once, including the two-row tile list"); + assets_live = true; + lifetime.before_transition(active, cleanup); + check(!lifetime.blocked(), "Allow rebuilding in the next active world"); + models.track({600, 1}); + lifetime.before_transition(24, cleanup); + assets_live = false; + lifetime.before_transition(24, cleanup); + lifetime.before_transition(active, cleanup); + check(lifetime.blocked() && models.empty() && destroyed.back() == 600 && cleanups == 2, + "Shutdown must release a rebuilt shelf and permanently suppress recreation"); +} + +void partial_generation() { + md::OwnedMenuModels models; + models.track({400, 1}); // Anchor created, tile-template preparation failed. + unsigned destroys{}; + try { models.release([] { throw std::runtime_error("detach failed"); }, [&](Model) { ++destroys; }); } + catch (const std::runtime_error&) {} + check(!models.empty() && !destroys, "Keep failed cleanup ownership for a retry"); + models.release([] {}, [&](Model) { ++destroys; }); + check(models.empty() && destroys == 1, "Release partial builds as well as published shelves"); + models.track({400, 1}); + models.manager_replaced(); + models.release([] {}, [](Model) { throw std::runtime_error("expired manager dereferenced"); }); +} + +int main() { + try { + for (unsigned leaving : {14U, 22U, 3U}) shelf_transition(leaving, leaving == 22 ? 21 : 13); + partial_generation(); + const std::array native{{{1, 100}}}; + check(dingosdk::profile_runtime::music_shelf_detached(std::span{native}, 400) == + std::vector{native.begin(), native.end()}, "Expired/missing shelf removal preserves native entries"); + std::puts("Music shelf lifetime tests passed."); + return 0; + } catch (const std::exception& error) { + std::fprintf(stderr, "%s\n", error.what()); + return 1; + } +} diff --git a/Extension/Music/local_music_shelf.cpp b/Extension/Music/local_music_shelf.cpp index e731c51e..a3254e28 100644 --- a/Extension/Music/local_music_shelf.cpp +++ b/Extension/Music/local_music_shelf.cpp @@ -1,4 +1,5 @@ #include "Extension/Music/local_music_shelf.h" +#include "Extension/Music/local_music_shelf_lifetime.h" #include "Extension/Music/local_music_assets.h" #include "Engine/Core/Log/logging.h" #include "Engine/Game/Abi/native_data.h" @@ -27,11 +28,50 @@ std::string music_diag_hex(std::uint64_t value, int width) { namespace md = dingosdk::multiplayer::menu_data; struct ModsShelf { std::uintptr_t manager{}; - md::Value anchor{}, tiles{}, featured_anchor{}; + md::Value anchor{}, tiles{}, featured_anchor{}, list{}; ULONGLONG next_poll{}; std::vector published_rows; }; ModsShelf mods_shelf; +md::MenuLifetime music_shelf_lifetime; +md::OwnedMenuModels music_shelf_models; +bool music_shelf_cleaning{}; + +void music_release_shelf(std::uintptr_t base) { + if (music_shelf_models.empty()) { mods_shelf = {}; return; } + // The provider can disappear before this callback. Use the common UI model + // manager, as native-menu cleanup does, rather than requiring a live provider. + std::uintptr_t ui{}, manager{}; + md::require(read(base + addr::engine::ui_manager, ui) && ui && + read(ui + 0x140, manager) && manager, "Music shelf cleanup manager is unavailable."); + if (manager != mods_shelf.manager) { + music_shelf_models.manager_replaced(); + mods_shelf = {}; + return; + } + const bool was_cleaning = music_shelf_cleaning; + music_shelf_cleaning = true; + struct Restore { bool previous; ~Restore() { music_shelf_cleaning = previous; } } restore{was_cleaning}; + game::ModelWriteLock lock(manager); + const md::Context context{base, manager}; + music_shelf_models.release([&] { + if (mods_shelf.list.handle && context.type_of(mods_shelf.list.handle) == mods_shelf.list.type) { + const auto items = context.field(mods_shelf.list, 0x67223da7U); + unsigned count{}, stride{}; + const auto bytes = context.array(items, 64, count, stride); + md::require(stride == sizeof(md::Ref), "Music shelf cleanup list differs."); + std::vector refs(count); + if (!bytes.empty()) std::memcpy(refs.data(), bytes.data(), bytes.size()); + const auto retained = music_shelf_detached(std::span{refs}, mods_shelf.anchor.handle); + if (retained.size() != refs.size()) context.array(items, retained); + } + // Unbind the child before reverse-creation-order destruction. Both roots + // must release their widget references while the outgoing assets live. + if (mods_shelf.anchor.handle && context.type_of(mods_shelf.anchor.handle) == mods_shelf.anchor.type) + context.set(context.path(mods_shelf.anchor, {0x214d4984U, 0x25e4d6c8U}), md::Ref{}); + }, [&](md::Value value) { context.destroy(value); }); + mods_shelf = {}; +} // Read inline typed fields without creating child handles during authored construction. std::uintptr_t music_inline_path(const md::Context& context, std::uintptr_t data, std::uintptr_t type, std::initializer_list hashes) { @@ -89,7 +129,8 @@ void music_sync_mods(const md::Context& context) { dingosdk::Json{{"event", "music_mods_shelf_fill"}, {"count", mods}, {"featured", count}}.dump().c_str()); } void music_poll_mods(std::uintptr_t base) { - if (!mods_shelf.manager || GetTickCount64() < mods_shelf.next_poll) return; + if (music_shelf_lifetime.blocked() || music_shelf_cleaning || + !mods_shelf.manager || GetTickCount64() < mods_shelf.next_poll) return; mods_shelf.next_poll = GetTickCount64() + 250; std::uintptr_t provider{}, manager{}; if (!read(base + addr::local_music::ui_manager, provider) || !provider || @@ -138,7 +179,14 @@ std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, st std::memcpy(&record_ref, bytes.data() + stride, 8); std::memcpy(&source_vm, bytes.data() + stride + 8, 8); const auto source_type = shelf.type_of(source_vm & ~std::uint64_t{1}); + // A screen can be recreated in the same manager without changing maps. + // Detach and release the old generation, rather than dropping its handles. + music_release_shelf(base); + mods_shelf.manager = model; + mods_shelf.list = root.model; auto new_vm = shelf.create(md::Schema{0x0e3be640U, 192}, (GetTickCount64() << 12) | 0x4dU); + mods_shelf.anchor = new_vm; + music_shelf_models.track(new_vm); md::Value new_tiles{}; try { const md::Value source_anchor{source_vm, source_type}; @@ -153,14 +201,15 @@ std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, st tile_data = md::read(tile_ref.record + 0x20); md::require(tile_data != 0, "Music tile template is not available."); new_tiles = shelf.create(md::Schema{0x74b1ea1dU, 392}, (GetTickCount64() << 12) | 0x4eU); + mods_shelf.tiles = new_tiles; + music_shelf_models.track(new_tiles); shelf.copy(new_tiles, tile_data); shelf.text(shelf.path(new_tiles, {0xfaec2d05U, 0xcb79482dU, 0x4d8e01b9U}), "Mods"); // An independent list retains each native row's actions and artwork on publication. shelf.array(shelf.field(new_tiles, 0x67223da7U), std::span{}, 0); shelf.set(shelf.path(new_vm, {0x214d4984U, 0x25e4d6c8U}), md::Ref{0, new_tiles.handle}); } catch (...) { - if (new_tiles.handle) shelf.destroy(new_tiles); - shelf.destroy(new_vm); + music_release_shelf(base); throw; } std::vector element(stride); @@ -169,12 +218,7 @@ std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, st auto merged = bytes; merged.insert(merged.begin(), element.begin(), element.end()); shelf.array(items, merged, count + 1); - const auto previous = mods_shelf; - mods_shelf = {model, new_vm, new_tiles, md::Value{target, shelf.type_of(target)}, 0}; - // Release our previous generation after the new shelf has been published. - if (previous.manager == model) { - try { shelf.destroy(previous.anchor); shelf.destroy(previous.tiles); } catch (const std::exception&) {} - } + mods_shelf.featured_anchor = md::Value{target, shelf.type_of(target)}; try { music_sync_mods(shelf); } catch (const std::exception&) {} unsigned after = 0, after_stride = 0; shelf.array(items, 64, after, after_stride); @@ -196,6 +240,7 @@ std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mo std::uint64_t id, std::uintptr_t type, bool flag, std::uintptr_t record) { const auto handle = music_model_construct_original(manager, mode, id, type, flag, record); PreserveError preserve; + if (music_shelf_lifetime.blocked() || music_shelf_cleaning) return handle; try { if (!handle || !type || md::read(md::read(type)) != 0x48455d84U) return handle; @@ -229,7 +274,7 @@ std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mo for (const auto& playlist : mod_music_playlists()) if (!playlist.songs.empty()) { has_mod_playlists = true; break; } if (!has_mod_playlists) { - mods_shelf = {}; + music_release_shelf(local_runtime().base); dingosdk::logging::event(dingosdk::logging::Channel::music, "{\"event\":\"music_mods_shelf_skipped\"}"); return handle; } @@ -240,6 +285,19 @@ std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mo return handle; } +void music_shelf_before_level_transition(unsigned next) noexcept { + try { + music_shelf_lifetime.before_transition(next, [&] { + music_release_shelf(local_runtime().base); + dingosdk::logging::event(dingosdk::logging::Channel::music, + "{\"event\":\"music_mods_shelf_released\"}"); + }); + } catch (...) { + try { dingosdk::logging::event(dingosdk::logging::Channel::music, + "{\"event\":\"music_mods_shelf_cleanup_failed\"}"); } catch (...) {} + } +} + void update_music_shelf() { auto& runtime = local_runtime(); if (!runtime.active.load(std::memory_order_acquire)) return; diff --git a/Extension/Music/local_music_shelf.h b/Extension/Music/local_music_shelf.h index 3bb80750..e129d8c8 100644 --- a/Extension/Music/local_music_shelf.h +++ b/Extension/Music/local_music_shelf.h @@ -12,4 +12,5 @@ extern MusicModelConstruct music_model_construct_original; std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mode, std::uint64_t id, std::uintptr_t type, bool flag, std::uintptr_t record); void update_music_shelf(); +void music_shelf_before_level_transition(unsigned next) noexcept; } // namespace dingosdk::profile_runtime diff --git a/Extension/Music/local_music_shelf_lifetime.h b/Extension/Music/local_music_shelf_lifetime.h new file mode 100644 index 00000000..335312f0 --- /dev/null +++ b/Extension/Music/local_music_shelf_lifetime.h @@ -0,0 +1,18 @@ +#pragma once +#include "Extension/UI/NativeMenu/native_menu_lifetime.h" +#include +#include +#include + +namespace dingosdk::profile_runtime { +// Remove only our independently owned anchor. Preserve native records, handles, +// and order, including when another shelf was inserted ahead of ours. +template +std::vector music_shelf_detached(std::span refs, std::uint64_t anchor) { + std::vector retained; + retained.reserve(refs.size()); + for (const auto& ref : refs) + if ((ref.handle & ~std::uint64_t{1}) != anchor) retained.push_back(ref); + return retained; +} +} diff --git a/Extension/Profile/local_profile_runtime.cpp b/Extension/Profile/local_profile_runtime.cpp index 17599a8f..b6c53181 100644 --- a/Extension/Profile/local_profile_runtime.cpp +++ b/Extension/Profile/local_profile_runtime.cpp @@ -340,6 +340,7 @@ void local_profile_before_level_transition(unsigned next) noexcept { auto& s = local_runtime(); if (!s.active.load(std::memory_order_acquire)) return; std::lock_guard lock(s.native_mutex); + music_shelf_before_level_transition(next); news_runtime().pending.before_transition(next); object_runtime().pending.before_transition(next); auto& placements = placements_runtime(); diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 7c77a21c..c6b73124 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -52,6 +52,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_include_directories(dingosdk_music_safety_tests PRIVATE "${PROJECT_SOURCE_DIR}") dingosdk_configure_target(dingosdk_music_safety_tests) add_test(NAME music_safety COMMAND dingosdk_music_safety_tests) + add_executable(dingosdk_music_shelf_lifetime_tests Extension/Music/Test/local_music_shelf_lifetime_tests.cpp) + target_include_directories(dingosdk_music_shelf_lifetime_tests PRIVATE "${PROJECT_SOURCE_DIR}") + dingosdk_configure_target(dingosdk_music_shelf_lifetime_tests) + add_test(NAME music_shelf_lifetime COMMAND dingosdk_music_shelf_lifetime_tests) add_executable(dingosdk_music_artwork_tests Extension/Music/Test/music_artwork_tests.cpp Extension/Music/music_artwork.cpp) target_link_libraries(dingosdk_music_artwork_tests PRIVATE dingosdk_mods ws2_32 winhttp) dingosdk_configure_target(dingosdk_music_artwork_tests) diff --git a/contrib/native-music-ui.md b/contrib/native-music-ui.md index d5a6908c..c6ab55eb 100644 --- a/contrib/native-music-ui.md +++ b/contrib/native-music-ui.md @@ -273,6 +273,17 @@ when replacing its tracked generation in the same manager. Reopening and switchi screens must be tested: a valid handle from one observation is not proof of its validity after scene teardown. +The Mods shelf participates in the existing pre-level-transition callback. Before +leaving a level/sublevel or shutting down, it removes its anchor from the native +shelf list, clears the anchor's tile binding, and destroys both owned roots while +their widget assets are still loaded. Construction and row polling are suspended +during loading and resume when the next world becomes active. Recreating the +screen also releases the previous generation, including partially built roots. +This addresses the observed stale widget-reference crash at `0x143DC5EAA` during +map unloading. The installed Release build was confirmed in game to resolve the +reported shelf-creation/map-switch reproduction; the lifetime tests separately +cover ownership and transition ordering. + ## Inspecting a screen yourself ReSkate's console exposes the live model dumper: From 23151496f1604f9c8d3d0acda97be943c521444d Mon Sep 17 00:00:00 2001 From: Juan <76052920+jnslol@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:46:40 -0300 Subject: [PATCH 069/109] Add chat bubbles above each skater A player's recent chat lines show as speech bubbles above their skater: newest nearest the head, older lines stacking upward, each popping in with a slight overshoot before it fades. Emotes render as their images, a message that is nothing but a few emotes shows them jumbo, and a message longer than a handful of lines is cut with an ellipsis so one line can never wall the screen off. Local display options live under Settings > Interface > Multiplayer (on/off, own messages, distance, duration and how many recent lines stack) and the console gains mp chat-bubbles, chat-bubbles-own, chat-bubbles-distance, chat-bubbles-duration and chat-bubbles-history. The chat panel and the bubbles now share one rich-text module (chat_rich), and a filtered message keeps its emote names: the bad-word mask is applied, but emote tokens are put back from the original before they are parsed. --- Engine/Game/Multiplayer/session_model.h | 13 + Extension/Multiplayer/Hud/custom_nametags.cpp | 15 +- Extension/Multiplayer/Hud/custom_nametags.h | 17 +- Extension/Multiplayer/Session/session.cpp | 62 ++++- .../Multiplayer/Session/session_commands.cpp | 58 +++- .../Multiplayer/Session/session_internal.h | 4 + .../Multiplayer/Session/session_view.cpp | 9 +- Extension/Multiplayer/Test/mesh_tests.cpp | 2 +- Extension/Multiplayer/console_commands.cpp | 12 + .../UI/NativeMenu/native_menu_multiplayer.cpp | 4 + Extension/UI/Overlay/chat_overlay.cpp | 132 +-------- Extension/UI/Overlay/chat_rich.cpp | 193 ++++++++++++++ Extension/UI/Overlay/chat_rich.h | 43 +++ Extension/UI/Overlay/multiplayer_menu.cpp | 50 ++++ Extension/UI/Overlay/nametag_overlay.cpp | 251 +++++++++++++++--- Extension/UI/Overlay/overlay.h | 13 + Extension/UI/Overlay/skate_menu.h | 4 + cmake/UI.cmake | 1 + 18 files changed, 715 insertions(+), 168 deletions(-) create mode 100644 Extension/UI/Overlay/chat_rich.cpp create mode 100644 Extension/UI/Overlay/chat_rich.h diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index be3aedae..0594ed59 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -67,12 +67,16 @@ 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", "Creator", "Homie", "Admin", "Host", "Friend" 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; }; // A command typed into chat with a leading "/" (shown as the player types "/"). struct MultiplayerChatCommand { @@ -114,6 +118,15 @@ struct MultiplayerModel { bool chat_visible{true}; // Local: bad words in chat names and messages show as **** (on by default). bool chat_filter{true}; + // 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", "Creator" 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. diff --git a/Extension/Multiplayer/Hud/custom_nametags.cpp b/Extension/Multiplayer/Hud/custom_nametags.cpp index 922af760..a2f67de0 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.cpp +++ b/Extension/Multiplayer/Hud/custom_nametags.cpp @@ -26,6 +26,8 @@ struct State { std::vector 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{}; + float bubble_distance{40.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 players, - std::optional> local) noexcept { + std::optional> local, bool show_names, + bool show_bubbles, float bubble_distance) noexcept { auto &s = state(); if (!s.enabled.load(std::memory_order_acquire)) return; s.base.store(base, std::memory_order_release); @@ -54,6 +57,10 @@ void publish_custom_nametags(std::uintptr_t base, std::vector 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; 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 +69,9 @@ void publish_custom_nametags(std::uintptr_t base, std::vector 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; if (!view) { s.tags.clear(); return; @@ -113,6 +123,9 @@ 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; view = s.view; } read_live_view(s.base.load(std::memory_order_acquire), view); diff --git a/Extension/Multiplayer/Hud/custom_nametags.h b/Extension/Multiplayer/Hud/custom_nametags.h index cfc5920b..c259337b 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.h +++ b/Extension/Multiplayer/Hud/custom_nametags.h @@ -10,12 +10,24 @@ // 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 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 bubbles; + // The local player's own tag: only ever draws bubbles, never a name or dot. + bool self{}; }; // Role colours; friends are the viewer's own Steam friends. inline constexpr std::uint32_t nametag_white = 0xffffffffU, nametag_developer = 0xffff78b4U, // purple @@ -31,8 +43,11 @@ inline constexpr std::uint32_t nametag_developer_start = 0xffff206eU, // #6E20FF // 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. void publish_custom_nametags(std::uintptr_t base, std::vector players, - std::optional> local) noexcept; + std::optional> local, bool show_names, + bool show_bubbles, float bubble_distance) 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. diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 8a1c7122..606d065c 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -20,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 @@ -261,6 +262,14 @@ void publish_party(Session &s) { s.custom_nametags = profile_runtime::local_preference("CustomNametags").value_or(true); 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(), 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(), 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(saved->get()), 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()) @@ -385,10 +394,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 nametags; - const bool labels = s.nametags && s.custom_nametags; + const bool bubbles = s.chat_bubbles; + const bool labels = (s.nametags && s.custom_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 result; + if (!bubbles) return result; + const int limit = std::max(1, s.chat_bubbles_history); + const auto duration = static_cast(std::max(0.5f, s.chat_bubbles_duration) * 1e6f); + const auto fade_from = std::min(duration, 500000); + for (auto it = s.chat.rbegin(); it != s.chat.rend() && static_cast(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(age) / static_cast(pop_us)); + line.fade = fade_from ? std::min(1.0f, static_cast(duration - age) / static_cast(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) { @@ -399,6 +441,7 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui // 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); 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 @@ -539,9 +582,22 @@ 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 && s.custom_nametags, bubbles, s.chat_bubbles_distance); } } // namespace MultiplayerModel model() { diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index c3f67082..ba691606 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -32,7 +32,9 @@ void apply_object_placement(Session &s, ObjectPlacement policy) { 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); + // 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. + set_custom_nametags_enabled((s.nametags && s.custom_nametags) || s.chat_bubbles); set_native_nametags_enabled(s.nametags && !s.custom_nametags); set_native_compass_enabled(!(s.nametags && s.custom_nametags)); } @@ -379,6 +381,52 @@ 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_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(value)}}); + return "Chat bubbles show within " + std::to_string(static_cast(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(value)}}); + return "Chat bubbles last " + std::to_string(static_cast(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(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(' '); @@ -485,6 +533,8 @@ bool queue_command(std::string_view action, std::string_view argument, std::stri 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 != "nametags" && action != "nametag-style" && action != "chat-visible" && action != "chat-filter" && + 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" && @@ -666,7 +716,11 @@ std::string command(std::string_view action, std::string_view argument, std::str {"nametags", edit_nametags}, {"nametag-style", edit_nametag_style}, {"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-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); diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 3c2c4a38..fe157401 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -229,6 +229,10 @@ struct Session { bool 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 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. diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index d6d2e418..ce5b422e 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -220,6 +220,11 @@ void publish(Session &s, const NativeFrame *local) { } 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(player_count(s)); view.sent = t.sent; @@ -523,7 +528,7 @@ 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); @@ -560,7 +565,7 @@ std::pair player_role(Session &s, std::uint64_t send 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, marks); - s.chat.push_back({++s.chat_sequence, sender, std::move(name), std::move(text), local, color, std::move(tag)}); + s.chat.push_back({++s.chat_sequence, sender, now_us(), std::move(name), std::move(text), local, color, std::move(tag)}); while (s.chat.size() > multiplayer_chat_history) s.chat.pop_front(); publish_chat(s); } diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 1e5dc9cb..7f84ae8c 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -269,7 +269,7 @@ void prepare_native_indicators(std::uintptr_t) noexcept {} void prepare_player_ui(std::uintptr_t) noexcept {} void prepare_remote_audio(std::uintptr_t) noexcept {} bool install_entity_hooks(std::uintptr_t, std::string &) noexcept { return true; } -void publish_custom_nametags(std::uintptr_t, std::vector, std::optional>) noexcept {} +void publish_custom_nametags(std::uintptr_t, std::vector, std::optional>, bool, bool, float) noexcept {} void set_custom_nametags_enabled(bool) noexcept {} GameUiState sample_game_ui_state(std::uintptr_t) noexcept { return {}; } void note_local_skater(const Transform &) noexcept {} diff --git a/Extension/Multiplayer/console_commands.cpp b/Extension/Multiplayer/console_commands.cpp index 896a1715..a87af0c6 100644 --- a/Extension/Multiplayer/console_commands.cpp +++ b/Extension/Multiplayer/console_commands.cpp @@ -32,6 +32,11 @@ void register_multiplayer_commands(Commands ®istry) { Command{"tphere", "Host or server admin: teleport one player to you"}, Command{"nametags", "Show or hide player nametags (on, off, toggle)"}, Command{"nametag-style", "ReSkate nametags or the game's own (reskate, game, toggle)"}, + Command{"chat-bubbles", "Show or hide chat bubbles above skaters (on, off, toggle)"}, + Command{"chat-bubbles-own", "Also show your own chat messages above your skater (on, off, toggle)"}, + Command{"chat-bubbles-distance", "How far away a player may be and still show a chat bubble, in metres (5-500)"}, + Command{"chat-bubbles-duration", "How many seconds a chat bubble stays before it fades (1-30)"}, + Command{"chat-bubbles-history", "How many recent messages stack above a skater (1-8)"}, Command{"score-check", "Host: keep players whose mods change scoring or physics out of throwdowns and " "coop challenges (on, off, toggle; on by default)"}, Command{"retry", "Retry remote skater creation after an error"}, @@ -47,8 +52,15 @@ void register_multiplayer_commands(Commands ®istry) { if (std::string_view(c.name) == "join-lobby") args.push_back(argument("lobby_id")); if (std::string_view(c.name) == "nametags" || std::string_view(c.name) == "nametag-style" || + std::string_view(c.name) == "chat-bubbles" || std::string_view(c.name) == "chat-bubbles-own" || std::string_view(c.name) == "score-check") args.push_back(argument("choice")); + if (std::string_view(c.name) == "chat-bubbles-distance") + args.push_back(argument("metres")); + if (std::string_view(c.name) == "chat-bubbles-duration") + args.push_back(argument("seconds")); + if (std::string_view(c.name) == "chat-bubbles-history") + args.push_back(argument("lines")); if (std::string_view(c.name) == "chat" || std::string_view(c.name) == "server" || std::string_view(c.name) == "party" || std::string_view(c.name) == "tp" || std::string_view(c.name) == "tphere") { auto message = argument("message"); diff --git a/Extension/UI/NativeMenu/native_menu_multiplayer.cpp b/Extension/UI/NativeMenu/native_menu_multiplayer.cpp index ec94eaaf..09addd75 100644 --- a/Extension/UI/NativeMenu/native_menu_multiplayer.cpp +++ b/Extension/UI/NativeMenu/native_menu_multiplayer.cpp @@ -395,6 +395,10 @@ void render_section(const Context& context, const MultiplayerModel& model, Secti "nametags", "toggle", false, 136.f); add_button(context, side, "nametag-style", std::string("Nametag style: ") + (model.custom_nametags ? "ReSkate" : "Game"), "nametag-style", "toggle", false, 136.f); + add_button(context, side, "chat-bubbles", std::string("Chat bubbles: ") + (model.chat_bubbles ? "On" : "Off"), + "chat-bubbles", "toggle", false, 136.f); + add_button(context, side, "chat-bubbles-own", std::string("Own chat bubbles: ") + (model.chat_bubbles_own ? "On" : "Off"), + model.chat_bubbles ? "chat-bubbles-own" : "", "toggle", false, 136.f); add_button(context, side, "copy-code", "Copy join code", model.invite.empty() ? "" : "copy-code", {}, false, 136.f); add_button(context, side, "leave", model.hosting ? "End session" : "Leave session", "stop", {}, false, 136.f); } diff --git a/Extension/UI/Overlay/chat_overlay.cpp b/Extension/UI/Overlay/chat_overlay.cpp index 78d394ea..99b39c67 100644 --- a/Extension/UI/Overlay/chat_overlay.cpp +++ b/Extension/UI/Overlay/chat_overlay.cpp @@ -1,5 +1,6 @@ #include "overlay_internal.h" #include "chat_emotes.h" +#include "chat_rich.h" #include "nametag_gradient.h" #include "role_badge.h" #include @@ -124,119 +125,8 @@ std::vector matching_commands(const MultiplayerCh if (command.name.starts_with(lowered) || lowered.starts_with(command.name + " ")) result.push_back(&command); return result; } -// A message laid out with its :emote: images, in lines no wider than `wrap`; the first line -// starts `indent` in. Emotes are a little taller than the text and centred on their line. -// Items refer to the message by offset, so a layout stays valid for the line after the feed -// is polled again (a line's text never changes). -struct RichItem { - std::size_t begin{}, length{}; // within the message: the text, or the emote's name - bool emote{}; - ImVec2 at, size; - std::size_t line{}; -}; -struct RichText { - std::vector items; - float height{}; -}; -bool has_emotes(std::string_view text) { - if (!chat_emotes_loaded()) return false; - for (std::size_t at = text.find(':'); at != std::string_view::npos; at = text.find(':', at + 1)) { - const auto end = text.find(':', at + 1); - if (end == std::string_view::npos) return false; - ChatEmote emote; - if (end > at + 1 && chat_emote(text.substr(at + 1, end - at - 1), emote)) return true; - } - return false; -} -RichText lay_out(ImFont* font, float size, std::string_view text, float wrap, float indent) { - RichText out; - const float text_height = font->CalcTextSizeA(size, FLT_MAX, 0.0f, "Ay").y, emote_height = text_height * 1.3f; - std::vector lines{text_height}; - float x = indent; - const auto offset = [&](std::string_view part) { return static_cast(part.data() - text.data()); }; - const auto measure = [&](std::string_view part) { - return font->CalcTextSizeA(size, FLT_MAX, 0.0f, part.data(), part.data() + part.size()); - }; - const auto new_line = [&] { - x = 0.0f; - lines.push_back(text_height); - }; - const auto place = [&](RichItem item) { - if (x > 0.0f && x + item.size.x > wrap) new_line(); - item.at.x = x; - item.line = lines.size() - 1; - lines.back() = std::max(lines.back(), item.size.y); - x += item.size.x; - out.items.push_back(item); - }; - // Text: whole where it fits on a line; longer than a line, broken where it must be, each - // piece filling what is left of its line. - const auto place_text = [&](std::string_view part) { - while (!part.empty()) { - const auto whole = measure(part); - if (whole.x <= wrap) { - place({offset(part), part.size(), false, {}, ImVec2(whole.x, text_height)}); - return; - } - if (x > 0.0f && measure(part.substr(0, 1)).x > wrap - x) new_line(); - std::size_t fit = 1; - while (fit < part.size() && measure(part.substr(0, fit + 1)).x <= wrap - x) ++fit; - const auto piece = part.substr(0, fit); - place({offset(piece), piece.size(), false, {}, ImVec2(measure(piece).x, text_height)}); - part.remove_prefix(fit); - if (!part.empty()) new_line(); - } - }; - for (std::size_t at = 0; at < text.size();) { - // A word and the spaces after it. - auto end = text.find(' ', at); - end = end == std::string_view::npos ? text.size() : text.find_first_not_of(' ', end); - if (end == std::string_view::npos) end = text.size(); - const auto token = text.substr(at, end - at); - const auto word = token.substr(0, token.find_last_not_of(' ') + 1); - at = end; - // Emotes can sit inside a word too (":blob::blob:"): each :name: of a known emote is an - // image, the rest text. - std::size_t done = 0; - for (auto open = word.find(':'); open != std::string_view::npos;) { - const auto close = word.find(':', open + 1); - if (close == std::string_view::npos) break; - ChatEmote emote; - if (close > open + 1 && chat_emote(word.substr(open + 1, close - open - 1), emote)) { - if (open > done) place_text(word.substr(done, open - done)); - place({offset(word) + open + 1, close - open - 1, true, {}, ImVec2(emote_height * emote.aspect, emote_height)}); - done = close + 1; - open = word.find(':', done); - } else { - open = close; // it may open the next one - } - } - if (done < word.size()) place_text(word.substr(done)); - const auto spaces = token.substr(word.size()); - if (!spaces.empty()) place({offset(spaces), spaces.size(), false, {}, measure(spaces)}); - } - std::vector tops(lines.size()); - for (std::size_t i = 1; i < lines.size(); ++i) tops[i] = tops[i - 1] + lines[i - 1]; - for (auto& item : out.items) item.at.y = tops[item.line] + (lines[item.line] - item.size.y) * 0.5f; - out.height = tops.back() + lines.back(); - return out; -} -void draw_rich(ImDrawList* draw, ImFont* font, float size, ImVec2 origin, std::string_view text, const RichText& rich, - ImU32 colour, float alpha) { - for (const auto& item : rich.items) { - if (item.begin > text.size() || item.length > text.size() - item.begin) continue; - const auto part = text.substr(item.begin, item.length); - const ImVec2 at(origin.x + item.at.x, origin.y + item.at.y); - if (item.emote) { - // Looked up as it is drawn, so an animated emote shows its current frame. - ChatEmote emote; - if (chat_emote(part, emote)) - draw->AddImage(emote.texture, at, ImVec2(at.x + item.size.x, at.y + item.size.y), emote.uv0, emote.uv1, - IM_COL32(255, 255, 255, static_cast(255.0f * alpha))); - } else - draw->AddText(font, size, at, colour, part.data(), part.data() + part.size()); - } -} +// Messages with their :emote: images: chat_rich.h, shared with the chat bubbles. +using namespace chat_rich; // A chat line as drawn: its label, whether it has emotes and their layout, kept per line // (sequences never repeat) for the font, size, width and indent it was laid out with, instead @@ -249,6 +139,7 @@ struct LineLayout { float size{}, wrap{}, indent{}; bool emotes_loaded{}; bool emotes{}; // has_emotes; without any the text is drawn plainly + std::string text; // what is drawn: the line's text with any emote the filter masked put back RichText rich; // its layout, when it has ImVec2 extent{}; // its size: the layout's, or the plain text's wrapped at `wrap` (the closed view) }; @@ -281,9 +172,10 @@ const LineLayout& layout_for(LineLayout& entry, const MultiplayerChatLine& line, entry.wrap = wrap; entry.indent = indent; entry.emotes_loaded = loaded; - entry.emotes = has_emotes(line.text); - entry.rich = entry.emotes ? lay_out(font, size, line.text, wrap, indent) : RichText{}; - entry.extent = entry.emotes ? ImVec2(wrap, entry.rich.height) : font->CalcTextSizeA(size, FLT_MAX, wrap, line.text.c_str()); + entry.text = restore_emotes(line.text, line.unmasked); + entry.emotes = has_emotes(entry.text); + entry.rich = entry.emotes ? lay_out(font, size, entry.text, wrap, indent) : RichText{}; + entry.extent = entry.emotes ? ImVec2(wrap, entry.rich.height) : font->CalcTextSizeA(size, FLT_MAX, wrap, entry.text.c_str()); return entry; } @@ -600,12 +492,12 @@ void draw_chat() { const auto& drawn = layout_for(layout, line, ImGui::GetFont(), ImGui::GetFontSize(), line_width, ImGui::GetCursorScreenPos().x - line_start.x); if (drawn.emotes) { - draw_rich(ImGui::GetWindowDrawList(), ImGui::GetFont(), ImGui::GetFontSize(), line_start, line.text, + draw_rich(ImGui::GetWindowDrawList(), ImGui::GetFont(), ImGui::GetFontSize(), line_start, drawn.text, drawn.rich, ImGui::GetColorU32(ImGuiCol_Text), 1.0f); ImGui::SetCursorScreenPos(line_start); ImGui::Dummy(ImVec2(line_width, drawn.rich.height)); } else { - ImGui::TextUnformatted(line.text.c_str()); + ImGui::TextUnformatted(drawn.text.c_str()); } } if (c.feed.lines.empty()) { @@ -711,8 +603,8 @@ void draw_chat() { draw->AddText(heading, name_size, ImVec2(at.x + badge, at.y), with_alpha(name_colour(*line), alpha), label.c_str()); shade_nametag_gradient(draw, name_vertices, at.x + badge, name_extent.x, name_colour(*line), ImGui::GetTime()); const ImVec2 text_at = beside ? ImVec2(at.x + indent, at.y) : ImVec2(at.x, at.y + name_extent.y); - if (emotes) draw_rich(draw, body, size, text_at, line->text, drawn.rich, with_alpha(theme::paper, alpha), alpha); - else draw->AddText(body, size, text_at, with_alpha(theme::paper, alpha), line->text.c_str(), nullptr, wrap); + if (emotes) draw_rich(draw, body, size, text_at, drawn.text, drawn.rich, with_alpha(theme::paper, alpha), alpha); + else draw->AddText(body, size, text_at, with_alpha(theme::paper, alpha), drawn.text.c_str(), nullptr, wrap); bottom = top_left.y - gap; } } diff --git a/Extension/UI/Overlay/chat_rich.cpp b/Extension/UI/Overlay/chat_rich.cpp new file mode 100644 index 00000000..a84a064c --- /dev/null +++ b/Extension/UI/Overlay/chat_rich.cpp @@ -0,0 +1,193 @@ +#include "chat_rich.h" +#include "chat_emotes.h" +#include +#include + +namespace dingosdk::overlay::chat_rich { +namespace { +bool known_emote(std::string_view name) { + ChatEmote emote; + return !name.empty() && chat_emote(name, emote); +} +bool spaces_only(std::string_view part) { return part.find_first_not_of(' ') == std::string_view::npos; } +} // namespace + +bool has_emotes(std::string_view text) { + if (!chat_emotes_loaded()) return false; + for (std::size_t at = text.find(':'); at != std::string_view::npos; at = text.find(':', at + 1)) { + const auto end = text.find(':', at + 1); + if (end == std::string_view::npos) return false; + if (end > at + 1 && known_emote(text.substr(at + 1, end - at - 1))) return true; + } + return false; +} + +bool only_emotes(std::string_view text, std::size_t &count) { + count = 0; + if (!chat_emotes_loaded()) return false; + for (std::size_t at = 0; at < text.size();) { + if (text[at] == ' ') { + ++at; + continue; + } + if (text[at] != ':') return false; + const auto end = text.find(':', at + 1); + if (end == std::string_view::npos || !known_emote(text.substr(at + 1, end - at - 1))) return false; + ++count; + at = end + 1; + } + return count > 0; +} + +RichText lay_out(ImFont *font, float size, std::string_view text, float wrap, float indent, std::size_t max_lines, + float emote_scale) { + RichText out; + const float text_height = font->CalcTextSizeA(size, FLT_MAX, 0.0f, "Ay").y, emote_height = text_height * emote_scale; + std::vector lines{text_height}; + float x = indent; + const auto offset = [&](std::string_view part) { return static_cast(part.data() - text.data()); }; + const auto measure = [&](std::string_view part) { + return font->CalcTextSizeA(size, FLT_MAX, 0.0f, part.data(), part.data() + part.size()); + }; + const auto new_line = [&] { + x = 0.0f; + lines.push_back(text_height); + }; + const auto place = [&](RichItem item) { + if (x > 0.0f && x + item.size.x > wrap) new_line(); + item.at.x = x; + item.line = lines.size() - 1; + lines.back() = std::max(lines.back(), item.size.y); + x += item.size.x; + out.items.push_back(item); + }; + // Text: whole where it fits on a line; longer than a line, broken where it must be, each + // piece filling what is left of its line. + const auto place_text = [&](std::string_view part) { + while (!part.empty()) { + const auto whole = measure(part); + if (whole.x <= wrap - x || (x == 0.0f && whole.x <= wrap)) { + place({offset(part), part.size(), false, {}, ImVec2(whole.x, text_height)}); + return; + } + if (whole.x <= wrap) { // fits a line of its own + new_line(); + continue; + } + if (x > 0.0f && measure(part.substr(0, 1)).x > wrap - x) new_line(); + std::size_t fit = 1; + while (fit < part.size() && measure(part.substr(0, fit + 1)).x <= wrap - x) ++fit; + // Never split a UTF-8 character. + const auto continuation = [&](std::size_t i) { + return i < part.size() && (static_cast(part[i]) & 0xC0) == 0x80; + }; + while (fit > 1 && continuation(fit)) --fit; + while (continuation(fit)) ++fit; // a single character wider than the line + const auto piece = part.substr(0, fit); + place({offset(piece), piece.size(), false, {}, ImVec2(measure(piece).x, text_height)}); + part.remove_prefix(fit); + if (!part.empty()) new_line(); + } + }; + for (std::size_t at = 0; at < text.size();) { + // A word and the spaces after it. + auto end = text.find(' ', at); + end = end == std::string_view::npos ? text.size() : text.find_first_not_of(' ', end); + if (end == std::string_view::npos) end = text.size(); + const auto token = text.substr(at, end - at); + const auto last = token.find_last_not_of(' '); + const auto word = last == std::string_view::npos ? std::string_view{} : token.substr(0, last + 1); + at = end; + // Emotes can sit inside a word too (":blob::blob:"): each :name: of a known emote is an + // image, the rest text. + std::size_t done = 0; + for (auto open = word.find(':'); open != std::string_view::npos;) { + const auto close = word.find(':', open + 1); + if (close == std::string_view::npos) break; + ChatEmote emote; + if (close > open + 1 && chat_emote(word.substr(open + 1, close - open - 1), emote)) { + if (open > done) place_text(word.substr(done, open - done)); + place({offset(word) + open + 1, close - open - 1, true, {}, ImVec2(emote_height * emote.aspect, emote_height)}); + done = close + 1; + open = word.find(':', done); + } else { + open = close; // it may open the next one + } + } + if (done < word.size()) place_text(word.substr(done)); + const auto spaces = token.substr(word.size()); + if (!spaces.empty()) place({offset(spaces), spaces.size(), false, {}, measure(spaces)}); + } + // Past `max_lines`: cut, and make room for "..." at the end of the last line kept. + if (max_lines && lines.size() > max_lines) { + out.truncated = true; + lines.resize(max_lines); + std::erase_if(out.items, [&](const RichItem &item) { return item.line >= max_lines; }); + const float dots = measure("...").x; + const auto line_end = [&] { + float end = 0.0f; + for (const auto &item : out.items) + if (item.line + 1 == max_lines) end = std::max(end, item.at.x + item.size.x); + return end; + }; + while (!out.items.empty() && out.items.back().line + 1 == max_lines && line_end() + dots > wrap) + out.items.pop_back(); + // Trailing spaces before the dots read oddly. + while (!out.items.empty() && out.items.back().line + 1 == max_lines && !out.items.back().emote && + spaces_only(text.substr(out.items.back().begin, out.items.back().length))) + out.items.pop_back(); + out.ellipsis.x = line_end(); + } + std::vector tops(lines.size()); + for (std::size_t i = 1; i < lines.size(); ++i) tops[i] = tops[i - 1] + lines[i - 1]; + for (auto &item : out.items) item.at.y = tops[item.line] + (lines[item.line] - item.size.y) * 0.5f; + out.height = tops.back() + lines.back(); + out.lines = lines.size(); + for (const auto &item : out.items) + if (item.emote || !spaces_only(text.substr(item.begin, item.length))) + out.width = std::max(out.width, item.at.x + item.size.x); + if (out.truncated) { + out.ellipsis.y = tops.back() + (lines.back() - text_height) * 0.5f; + out.width = std::max(out.width, out.ellipsis.x + measure("...").x); + } + return out; +} + +void draw_rich(ImDrawList *draw, ImFont *font, float size, ImVec2 origin, std::string_view text, const RichText &rich, + ImU32 colour, float alpha, float scale) { + const auto image = IM_COL32(255, 255, 255, static_cast(255.0f * std::clamp(alpha, 0.0f, 1.0f))); + for (const auto &item : rich.items) { + if (item.begin > text.size() || item.length > text.size() - item.begin) continue; + const auto part = text.substr(item.begin, item.length); + const ImVec2 at(origin.x + item.at.x * scale, origin.y + item.at.y * scale); + if (item.emote) { + // Looked up as it is drawn, so an animated emote shows its current frame. + ChatEmote emote; + if (chat_emote(part, emote)) + draw->AddImage(emote.texture, at, ImVec2(at.x + item.size.x * scale, at.y + item.size.y * scale), emote.uv0, + emote.uv1, image); + } else { + draw->AddText(font, size * scale, at, colour, part.data(), part.data() + part.size()); + } + } + if (rich.truncated) + draw->AddText(font, size * scale, ImVec2(origin.x + rich.ellipsis.x * scale, origin.y + rich.ellipsis.y * scale), + colour, "..."); +} + +std::string restore_emotes(std::string_view masked, std::string_view raw) { + std::string result(masked); + if (raw.empty() || raw.size() != masked.size() || masked == raw || !chat_emotes_loaded()) return result; + for (auto open = raw.find(':'); open != std::string_view::npos;) { + const auto close = raw.find(':', open + 1); + if (close == std::string_view::npos) break; + if (known_emote(raw.substr(open + 1, close - open - 1))) { + result.replace(open, close - open + 1, raw.substr(open, close - open + 1)); + open = raw.find(':', close + 1); + } else { + open = close; + } + } + return result; +} +} // namespace dingosdk::overlay::chat_rich diff --git a/Extension/UI/Overlay/chat_rich.h b/Extension/UI/Overlay/chat_rich.h new file mode 100644 index 00000000..ff17e923 --- /dev/null +++ b/Extension/UI/Overlay/chat_rich.h @@ -0,0 +1,43 @@ +#pragma once +#include +#include +#include +#include +#include + +// Chat text with its :emote: images (chat_emotes.h), laid out in wrapped lines. Shared by the +// chat panel (chat_overlay.cpp) and the chat bubbles over each skater (nametag_overlay.cpp). +namespace dingosdk::overlay::chat_rich { +// Items refer to the message by offset, so a layout stays valid for as long as the text it +// was made from does not change. +struct RichItem { + std::size_t begin{}, length{}; // within the message: the text, or the emote's name + bool emote{}; + ImVec2 at, size; + std::size_t line{}; +}; +struct RichText { + std::vector items; + float width{}; // the widest line, without trailing spaces + float height{}; + std::size_t lines{}; // lines laid out (after any cut) + bool truncated{}; // cut at `max_lines`: "..." ends the last line + ImVec2 ellipsis{}; // where that "..." goes +}; +// Whether `text` holds at least one known :emote:. +bool has_emotes(std::string_view text); +// Whether `text` is nothing but known :emotes: (and spaces); `count` says how many. +bool only_emotes(std::string_view text, std::size_t &count); +// `text` in lines no wider than `wrap`, the first starting `indent` in. Words longer than a +// line are broken where they must be. Emotes are `emote_scale` times the text height, centred +// on their line. With `max_lines`, anything past that many lines is cut. +RichText lay_out(ImFont *font, float size, std::string_view text, float wrap, float indent, + std::size_t max_lines = 0, float emote_scale = 1.3f); +// Draws a layout at `origin`, everything multiplied by `scale` (for animation). +void draw_rich(ImDrawList *draw, ImFont *font, float size, ImVec2 origin, std::string_view text, const RichText &rich, + ImU32 colour, float alpha, float scale = 1.0f); +// The bad-word filter keeps a message's length, so the original's known :emote: spans can be +// put back into the masked text: a filtered word must not break an emote's name. Returns +// `masked` unchanged when the two do not line up. +std::string restore_emotes(std::string_view masked, std::string_view raw); +} // namespace dingosdk::overlay::chat_rich diff --git a/Extension/UI/Overlay/multiplayer_menu.cpp b/Extension/UI/Overlay/multiplayer_menu.cpp index 322360c5..e98a8e26 100644 --- a/Extension/UI/Overlay/multiplayer_menu.cpp +++ b/Extension/UI/Overlay/multiplayer_menu.cpp @@ -265,6 +265,56 @@ void multiplayer_display_settings(SkateMenu &menu, const Model &model) { std::array unused{}; send_private(menu, "chat-filter", filter ? "on" : "off", unused, false); } + bool bubbles = mp.chat_bubbles; + if (toggle_row(menu, "Chat bubbles", "Show each player's newest chat line in a bubble above their skater.", bubbles)) { + std::array unused{}; + send_private(menu, "chat-bubbles", bubbles ? "on" : "off", unused, false); + } + bool own_bubbles = mp.chat_bubbles_own; + if (toggle_row(menu, "Own chat bubbles", "Also show your own messages above your skater.", own_bubbles, mp.chat_bubbles, + "OFF")) { + std::array unused{}; + send_private(menu, "chat-bubbles-own", own_bubbles ? "on" : "off", unused, false); + } + if (mp.chat_bubbles) { + std::array unused{}; + field(menu, "Bubble distance", "How far away a player can be and still show a chat bubble."); + float distance = menu.chat_bubbles_distance_pending.value_or(mp.chat_bubbles_distance); + ImGui::SliderFloat("##bubble-distance", &distance, 5.f, 200.f, "%.0f m", ImGuiSliderFlags_AlwaysClamp); + if (ImGui::IsItemActive()) menu.chat_bubbles_distance_pending = distance; + if (ImGui::IsItemDeactivatedAfterEdit()) { + send_private(menu, "chat-bubbles-distance", std::to_string(static_cast(distance)), unused, false); + menu.chat_bubbles_distance_until = ImGui::GetTime() + 2; + } + if (!ImGui::IsItemActive() && menu.chat_bubbles_distance_pending && + (*menu.chat_bubbles_distance_pending == mp.chat_bubbles_distance || + ImGui::GetTime() >= menu.chat_bubbles_distance_until)) + menu.chat_bubbles_distance_pending.reset(); + field(menu, "Bubble duration", "How many seconds a chat bubble stays before it fades."); + float duration = menu.chat_bubbles_duration_pending.value_or(mp.chat_bubbles_duration); + ImGui::SliderFloat("##bubble-duration", &duration, 1.f, 30.f, "%.0f s", ImGuiSliderFlags_AlwaysClamp); + if (ImGui::IsItemActive()) menu.chat_bubbles_duration_pending = duration; + if (ImGui::IsItemDeactivatedAfterEdit()) { + send_private(menu, "chat-bubbles-duration", std::to_string(static_cast(duration)), unused, false); + menu.chat_bubbles_duration_until = ImGui::GetTime() + 2; + } + if (!ImGui::IsItemActive() && menu.chat_bubbles_duration_pending && + (*menu.chat_bubbles_duration_pending == mp.chat_bubbles_duration || + ImGui::GetTime() >= menu.chat_bubbles_duration_until)) + menu.chat_bubbles_duration_pending.reset(); + field(menu, "Bubble history", "How many recent messages stack above each skater (1-8)."); + int history = menu.chat_bubbles_history_pending.value_or(mp.chat_bubbles_history); + ImGui::SliderInt("##bubble-history", &history, 1, 8, "%d lines", ImGuiSliderFlags_AlwaysClamp); + if (ImGui::IsItemActive()) menu.chat_bubbles_history_pending = history; + if (ImGui::IsItemDeactivatedAfterEdit()) { + send_private(menu, "chat-bubbles-history", std::to_string(history), unused, false); + menu.chat_bubbles_history_until = ImGui::GetTime() + 2; + } + if (!ImGui::IsItemActive() && menu.chat_bubbles_history_pending && + (*menu.chat_bubbles_history_pending == mp.chat_bubbles_history || + ImGui::GetTime() >= menu.chat_bubbles_history_until)) + menu.chat_bubbles_history_pending.reset(); + } note("These only change your screen; nobody else is affected."); end_card(); } diff --git a/Extension/UI/Overlay/nametag_overlay.cpp b/Extension/UI/Overlay/nametag_overlay.cpp index b2989af4..2ab43c3a 100644 --- a/Extension/UI/Overlay/nametag_overlay.cpp +++ b/Extension/UI/Overlay/nametag_overlay.cpp @@ -1,7 +1,13 @@ #include "overlay_internal.h" +#include "chat_emotes.h" +#include "chat_rich.h" #include "nametag_gradient.h" #include "role_badge.h" #include +#include +#include +#include +#include // ReSkate's nametags. The game side hands over each other player's head position, // name, colour and distance with the camera the client last used; they are placed here @@ -53,6 +59,138 @@ void speaker(ImDrawList *draw, ImVec2 at, float size, ImU32 colour) { draw->PathArcTo(ImVec2(at.x + size * 0.62f, at.y), size * 0.42f, -0.9f, 0.9f, 10); draw->PathStroke(colour, 0, std::max(1.0f, size * 0.11f)); } +// ---- chat bubbles over each skater's head. +// Text pops in with a slight overshoot, holds, then fades. A message longer than `bubble_max_lines` +// is cut with "..." so one line can never wall the screen off. +constexpr float bubble_base_size = 15.0f; +constexpr float bubble_wrap_factor = 7.0f; +constexpr float bubble_wrap_min = 260.0f; +constexpr std::size_t bubble_max_lines = 6; +constexpr float bubble_pad_factor = 0.6f; +constexpr float bubble_gap = 3.0f; +constexpr float bubble_margin = 6.0f; + +float ease_out_back(float t) { + constexpr float c1 = 1.70158f, c3 = c1 + 1.0f; + const float x = std::clamp(t, 0.0f, 1.0f) - 1.0f; + return 1.0f + c3 * x * x * x + c1 * x * x; +} +float smoothstep(float t) { + t = std::clamp(t, 0.0f, 1.0f); + return t * t * (3.0f - 2.0f * t); +} +// A laid-out bubble, cached by its text and the font and size it was laid out with, so a line +// is measured once and drawn many frames. +struct BubbleKey { + const ImFont *font{}; + float size{}, wrap{}; + std::string text, raw; + bool operator<(const BubbleKey &other) const { + return std::tie(font, size, wrap, text, raw) < std::tie(other.font, other.size, other.wrap, other.text, other.raw); + } +}; +struct BubbleLayout { + std::string drawn; + chat_rich::RichText rich; + float width{}, height{}; + std::uint64_t frame{}; +}; +std::map &bubble_cache() { + static std::map value; + return value; +} +const BubbleLayout &bubble_layout(const std::string &text, const std::string &raw, ImFont *font, float size, float wrap, + std::uint64_t frame) { + auto &cache = bubble_cache(); + BubbleKey key{font, size, wrap, text, raw}; + const auto found = cache.find(key); + if (found != cache.end()) { + found->second.frame = frame; + return found->second; + } + BubbleLayout layout; + layout.drawn = chat_rich::restore_emotes(text, raw); + // A message that is nothing but a few emotes shows them jumbo, like a chat app. + std::size_t emotes{}; + const bool jumbo = chat_rich::only_emotes(layout.drawn, emotes) && emotes <= 3; + layout.rich = chat_rich::lay_out(font, size, layout.drawn, wrap, 0.0f, bubble_max_lines, jumbo ? 2.4f : 1.3f); + layout.width = std::max(layout.rich.width, font->CalcTextSizeA(size, FLT_MAX, 0.0f, "Ay").x * 0.5f); + layout.height = layout.rich.height; + auto [it, added] = cache.emplace(std::move(key), std::move(layout)); + it->second.frame = frame; + return it->second; +} +// Draws one bubble with its tail (if any), scaled by `pop` about its bottom-centre. Returns +// the drawn top y. +float draw_bubble(ImDrawList *draw, ImFont *body_font, float size, const BubbleLayout &layout, ImVec2 at, + float settled_bottom, float alpha, float pop, ImU32 accent, float rounding, bool tail, float k) { + const float pad = size * bubble_pad_factor; + const float width = (layout.width + pad * 2.0f) * pop; + const float height = (layout.height + pad * 2.0f) * pop; + const float display_width = ImGui::GetIO().DisplaySize.x; + const float left = std::clamp(at.x - width * 0.5f, 4.0f * k, std::max(4.0f * k, display_width - width - 4.0f * k)); + const float top = settled_bottom - height; + const ImVec2 min(left, top), max(left + width, settled_bottom); + const ImU32 bg = with_alpha(theme::ink, 0.9f * alpha); + const ImU32 border = with_alpha(accent, 0.95f * alpha); + const ImU32 colour = with_alpha(theme::paper, alpha); + // A soft drop shadow under the body. + draw->AddRectFilled(ImVec2(min.x + 1.0f * k, min.y + 2.0f * k), ImVec2(max.x + 1.0f * k, max.y + 2.0f * k), + with_alpha(IM_COL32(0, 0, 0, 255), 0.35f * alpha), rounding); + draw->AddRectFilled(min, max, bg, rounding); + if (tail) { + const float tail_x = std::clamp(at.x, min.x + size * 0.6f, max.x - size * 0.6f); + draw->AddTriangleFilled(ImVec2(tail_x - size * 0.4f, max.y - 1.0f), ImVec2(tail_x + size * 0.4f, max.y - 1.0f), + ImVec2(tail_x, max.y + size * 0.6f), bg); + } + draw->AddRect(min, max, border, rounding, 0, std::max(1.0f, size * 0.1f)); + const ImVec2 origin(min.x + pad * pop, top + pad * pop); + if (layout.rich.items.empty()) { + draw->AddText(body_font, size * pop, origin, colour, layout.drawn.c_str()); + } else { + chat_rich::draw_rich(draw, body_font, size, origin, layout.drawn, layout.rich, colour, alpha, pop); + } + return top; +} +// A player's bubble stack: newest nearest the head, older lines above, each popping in around +// its own bottom-centre. Only the newest points its tail down. Returns the y of the top of the +// stack, or `head_bottom` when nothing was drawn. +float draw_bubbles(ImDrawList *draw, ImFont *body_font, const Nametag &tag, ImVec2 at, float head_bottom, float k, + float max_distance, ImU32 accent, std::uint64_t frame) { + const float nearness = 1.0f - std::clamp(tag.distance / std::max(1.0f, max_distance), 0.0f, 1.0f); + const float size = std::clamp(bubble_base_size * k, 10.0f, 44.0f) * (0.85f + 0.15f * nearness); + const float wrap = std::max(size * bubble_wrap_factor, bubble_wrap_min * k); + const float pad = size * bubble_pad_factor; + const float gap = std::max(2.0f, bubble_gap * k); + const float margin = bubble_margin * k; + const float rounding = size * 0.45f; + + struct Placed { + const NametagBubble *line; + const BubbleLayout *layout; + float settled_bottom, height; + }; + // Newest first; always keep the newest, older ones only while they still fit on screen. + std::vector placed; + float cursor = head_bottom; + for (auto it = tag.bubbles.rbegin(); it != tag.bubbles.rend(); ++it) { + const auto &layout = bubble_layout(it->text, it->raw, body_font, size, wrap, frame); + const float height = layout.height + pad * 2.0f; + if (!placed.empty() && cursor - height < margin) break; + placed.push_back({&*it, &layout, cursor, height}); + cursor -= height + gap; + } + if (placed.empty()) return head_bottom; + // Oldest first, so the newest (which has the tail) draws on top. + for (std::size_t i = placed.size(); i-- > 0;) { + const auto &p = placed[i]; + const float pop = 0.55f + 0.45f * ease_out_back(p.line->appear); + const float alpha = std::clamp(p.line->fade, 0.0f, 1.0f) * smoothstep(p.line->appear); + if (alpha <= 0.001f) continue; + draw_bubble(draw, body_font, size, *p.layout, at, p.settled_bottom, alpha, pop, accent, rounding, i == 0, k); + } + return placed.back().settled_bottom - placed.back().height; +} } // namespace bool nametags_pending() { @@ -77,8 +215,10 @@ void draw_nametags() { const float margin = edge_margin * k; auto &s = state(); auto *font = s.menu.bold ? s.menu.bold : ImGui::GetFont(); + auto *body_font = s.menu.body ? s.menu.body : ImGui::GetFont(); auto *draw = ImGui::GetBackgroundDrawList(); const double animation_time = ImGui::GetTime(); + const auto frame = static_cast(ImGui::GetFrameCount()); // Far first, so nearer names draw over them. std::sort(value.tags.begin(), value.tags.end(), [](const Nametag &a, const Nametag &b) { return a.distance > b.distance; }); for (const auto &tag : value.tags) { @@ -92,47 +232,82 @@ void draw_nametags() { on_screen = at.x >= margin && at.x <= display.x - margin && at.y >= margin && at.y <= display.y - margin; } if (!on_screen) { - // The direction to them from the middle of the screen, pushed out to the edge. - float dx = depth > 0.1f ? at.x - centre.x : side, dy = depth > 0.1f ? at.y - centre.y : -height; - if (depth <= 0.1f && std::abs(dx) < 1e-3f && std::abs(dy) < 1e-3f) dy = 1.0f; // straight behind - const float hx = centre.x - margin, hy = centre.y - margin; - const float scale = std::min(std::abs(dx) > 1e-6f ? hx / std::abs(dx) : FLT_MAX, - std::abs(dy) > 1e-6f ? hy / std::abs(dy) : FLT_MAX); - outlined_dot(draw, ImVec2(centre.x + dx * scale, centre.y + dy * scale), 5.0f * k, animated_colour); + // The local player has no name or edge dot, only a bubble when they are visible. + if (!tag.self && value.show_names) { + // The direction to them from the middle of the screen, pushed out to the edge. + float dx = depth > 0.1f ? at.x - centre.x : side, dy = depth > 0.1f ? at.y - centre.y : -height; + if (depth <= 0.1f && std::abs(dx) < 1e-3f && std::abs(dy) < 1e-3f) dy = 1.0f; // straight behind + const float hx = centre.x - margin, hy = centre.y - margin; + const float scale = std::min(std::abs(dx) > 1e-6f ? hx / std::abs(dx) : FLT_MAX, + std::abs(dy) > 1e-6f ? hy / std::abs(dy) : FLT_MAX); + outlined_dot(draw, ImVec2(centre.x + dx * scale, centre.y + dy * scale), 5.0f * k, animated_colour); + } continue; } - if (tag.distance > name_range) { - outlined_dot(draw, at, 4.0f * k, animated_colour); - continue; + // Where a bubble's tail should point: the top of the name, or the head itself. + float bubble_bottom = at.y; + if (!tag.self && value.show_names) { + if (tag.distance > name_range) { + outlined_dot(draw, at, 4.0f * k, animated_colour); + bubble_bottom = at.y - 6.0f * k; + } else { + // Names shrink a little with distance and fade slightly towards the name range. + const float nearness = 1.0f - std::clamp(tag.distance / name_range, 0.0f, 1.0f); + const float size = std::clamp((15.0f + 7.0f * nearness) * k, 10.0f, 44.0f); + const float alpha = 0.7f + 0.3f * nearness; + const auto colour = with_alpha(tag.color, alpha); + const auto shadow = with_alpha(IM_COL32(0, 0, 0, 255), 0.75f * alpha); + const float offset = std::max(1.0f, size / 14.0f); + const auto name_size = font->CalcTextSizeA(size, FLT_MAX, 0.0f, tag.name.c_str()); + const auto distance = distance_text(tag.distance); + const float detail = size * 0.72f; + const auto distance_size = font->CalcTextSizeA(detail, FLT_MAX, 0.0f, distance.c_str()); + // The name sits above the head point, the distance under it, and the role badge (as in + // chat) before the name, the two centred together. + const float badge = role_badge_width(font, size, tag.tag); + const float badge_gap = badge > 0.0f ? size * 0.3f : 0.0f; + const float left = at.x - (badge + badge_gap + name_size.x) * 0.5f; + const ImVec2 name_at(left + badge + badge_gap, at.y - name_size.y - distance_size.y); + draw_role_badge(draw, font, size, ImVec2(left, name_at.y), name_size.y, tag.tag, tag.color, alpha); + draw->AddText(font, size, ImVec2(name_at.x + offset, name_at.y + offset), shadow, tag.name.c_str()); + const int name_vertices = draw->VtxBuffer.Size; + draw->AddText(font, size, name_at, colour, tag.name.c_str()); + shade_nametag_gradient(draw, name_vertices, name_at.x, name_size.x, tag.color, animation_time); + const ImVec2 distance_at(at.x - distance_size.x * 0.5f, at.y - distance_size.y); + const auto dim = with_alpha(IM_COL32(230, 230, 230, 255), 0.85f * alpha); + draw->AddText(font, detail, ImVec2(distance_at.x + offset, distance_at.y + offset), shadow, distance.c_str()); + draw->AddText(font, detail, distance_at, dim, distance.c_str()); + if (tag.talking) speaker(draw, ImVec2(left - size * 1.05f, name_at.y + name_size.y * 0.5f), size * 0.8f, + with_alpha(animated_colour, alpha)); + bubble_bottom = name_at.y; + } + } + if (value.show_bubbles && !tag.bubbles.empty() && tag.distance <= value.bubble_distance) { + const float stack_top = draw_bubbles(draw, body_font, tag, at, bubble_bottom, k, value.bubble_distance, + animated_colour, frame); + // When the name itself is not shown (nametags off, or a player past the name range), + // label the stack so each bubble is still attributed. + const bool named = !tag.self && value.show_names && tag.distance <= name_range; + if (!named && !tag.name.empty() && stack_top < bubble_bottom - 1.0f) { + const float nearness = 1.0f - std::clamp(tag.distance / name_range, 0.0f, 1.0f); + const float label_size = std::clamp((12.0f + 3.0f * nearness) * k, 9.0f, 30.0f); + const float alpha = 0.85f; + const auto name_size = font->CalcTextSizeA(label_size, FLT_MAX, 0.0f, tag.name.c_str()); + const float badge = role_badge_width(font, label_size, tag.tag); + const float badge_gap = badge > 0.0f ? label_size * 0.3f : 0.0f; + const float left = at.x - (badge + badge_gap + name_size.x) * 0.5f; + const float label_y = stack_top - name_size.y - label_size * 0.2f; + draw_role_badge(draw, font, label_size, ImVec2(left, label_y), name_size.y, tag.tag, tag.color, alpha); + const auto shadow = with_alpha(IM_COL32(0, 0, 0, 255), 0.8f * alpha); + const float offset = std::max(1.0f, label_size / 14.0f); + draw->AddText(font, label_size, ImVec2(left + badge + badge_gap + offset, label_y + offset), shadow, + tag.name.c_str()); + draw->AddText(font, label_size, ImVec2(left + badge + badge_gap, label_y), + with_alpha(animated_colour, alpha), tag.name.c_str()); + } } - // Names shrink a little with distance and fade slightly towards the name range. - const float nearness = 1.0f - std::clamp(tag.distance / name_range, 0.0f, 1.0f); - const float size = std::clamp((15.0f + 7.0f * nearness) * k, 10.0f, 44.0f); - const float alpha = 0.7f + 0.3f * nearness; - const auto colour = with_alpha(tag.color, alpha); - const auto shadow = with_alpha(IM_COL32(0, 0, 0, 255), 0.75f * alpha); - const float offset = std::max(1.0f, size / 14.0f); - const auto name_size = font->CalcTextSizeA(size, FLT_MAX, 0.0f, tag.name.c_str()); - const auto distance = distance_text(tag.distance); - const float detail = size * 0.72f; - const auto distance_size = font->CalcTextSizeA(detail, FLT_MAX, 0.0f, distance.c_str()); - // The name sits above the head point, the distance under it, and the role badge (as in - // chat) before the name, the two centred together. - const float badge = role_badge_width(font, size, tag.tag); - const float badge_gap = badge > 0.0f ? size * 0.3f : 0.0f; - const float left = at.x - (badge + badge_gap + name_size.x) * 0.5f; - const ImVec2 name_at(left + badge + badge_gap, at.y - name_size.y - distance_size.y); - draw_role_badge(draw, font, size, ImVec2(left, name_at.y), name_size.y, tag.tag, tag.color, alpha); - draw->AddText(font, size, ImVec2(name_at.x + offset, name_at.y + offset), shadow, tag.name.c_str()); - const int name_vertices = draw->VtxBuffer.Size; - draw->AddText(font, size, name_at, colour, tag.name.c_str()); - shade_nametag_gradient(draw, name_vertices, name_at.x, name_size.x, tag.color, animation_time); - const ImVec2 distance_at(at.x - distance_size.x * 0.5f, at.y - distance_size.y); - const auto dim = with_alpha(IM_COL32(230, 230, 230, 255), 0.85f * alpha); - draw->AddText(font, detail, ImVec2(distance_at.x + offset, distance_at.y + offset), shadow, distance.c_str()); - draw->AddText(font, detail, distance_at, dim, distance.c_str()); - if (tag.talking) speaker(draw, ImVec2(left - size * 1.05f, name_at.y + name_size.y * 0.5f), size * 0.8f, - with_alpha(animated_colour, alpha)); } + // Drop layouts no bubble asked for this frame. + std::erase_if(bubble_cache(), [&](const auto &entry) { return entry.second.frame != frame; }); } } // namespace dingosdk::overlay::detail diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 3c3b5b8a..6251b849 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -301,6 +301,13 @@ void set_skate_hud_feed(SkateHudFeed) noexcept; // ReSkate's own nametags: one per other player, placed over the world with the camera the // client last used. Close ones show a name and distance, far ones a dot, and players off // screen a dot at the screen's edge. Empty = nothing to draw (off, or the game hides its UI). +// 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 (same length; emote names are taken back from it), how +// far its pop-in has come (0 just arrived, 1 settled) and how opaque it still is (1 down to 0). +struct NametagBubble { + std::string text, raw; + float appear{1}, fade{1}; +}; struct Nametag { std::array position{}; // above the skater's head, world space std::string name; @@ -308,10 +315,16 @@ struct Nametag { std::string tag; // role badge before the name ("Dev", "Creator", "Homie", "Admin", "Host", "Friend") float distance{}; // metres from the local skater bool talking{}; + // Recent chat lines to show as bubbles above the head, oldest first ("" = none). + std::vector bubbles; + bool self{}; // the local player: bubbles only, never a name or dot }; struct Nametags { std::array camera{}; // world matrix: right, up, back, position rows float vertical_fov{}; + bool show_names{true}; // draw the name, distance and role badge + bool show_bubbles{}; // draw chat bubbles above the heads + float bubble_distance{40.f}; // furthest a player may be and still show a bubble (metres) std::vector tags; }; using NametagFeed = Nametags (*)(); diff --git a/Extension/UI/Overlay/skate_menu.h b/Extension/UI/Overlay/skate_menu.h index c993bceb..111c6390 100644 --- a/Extension/UI/Overlay/skate_menu.h +++ b/Extension/UI/Overlay/skate_menu.h @@ -54,6 +54,10 @@ struct SkateMenu { std::optional voice_pending; std::optional voice_range_pending; // the host's range slider while dragged / until the model agrees double voice_range_until{}; + // Chat bubble distance/duration/history sliders, likewise held while dragged. + std::optional chat_bubbles_distance_pending, chat_bubbles_duration_pending; + std::optional chat_bubbles_history_pending; + double chat_bubbles_distance_until{}, chat_bubbles_duration_until{}, chat_bubbles_history_until{}; std::map> voice_volume_pending; std::array voice_keys_down{}; std::string multiplayer_distance_lobby; diff --git a/cmake/UI.cmake b/cmake/UI.cmake index 4679520a..39697e82 100644 --- a/cmake/UI.cmake +++ b/cmake/UI.cmake @@ -11,6 +11,7 @@ add_library(dingosdk_overlay STATIC Extension/UI/Overlay/skate_hud_overlay.cpp Extension/UI/Overlay/nametag_overlay.cpp Extension/UI/Overlay/chat_emotes.cpp + Extension/UI/Overlay/chat_rich.cpp Extension/UI/Overlay/console_suggestions.cpp Extension/UI/Overlay/skate_menu.cpp Extension/UI/Overlay/skate_menu_world.cpp From d264ac778cccd58e249f77c9964928368bfb1ce6 Mon Sep 17 00:00:00 2001 From: ReSkate local Date: Tue, 6 Oct 2026 03:26:37 +0100 Subject: [PATCH 070/109] Music: add playlist-order playback toggle --- Engine/Game/Build/20260929/local_music.h | 5 + .../local_music_playback_policy_tests.cpp | 77 +++++ Extension/Music/local_music_playback.cpp | 262 ++++++++++++++++++ Extension/Music/local_music_playback.h | 19 ++ Extension/Music/local_music_playback_policy.h | 61 ++++ Extension/Profile/local_profile_runtime.cpp | 10 + Extension/UI/Overlay/skate_menu_settings.cpp | 11 + cmake/Apps.cmake | 4 + cmake/Runtime.cmake | 1 + contrib/music-playback-order.md | 22 ++ 10 files changed, 472 insertions(+) create mode 100644 Extension/Music/Test/local_music_playback_policy_tests.cpp create mode 100644 Extension/Music/local_music_playback.cpp create mode 100644 Extension/Music/local_music_playback.h create mode 100644 Extension/Music/local_music_playback_policy.h create mode 100644 contrib/music-playback-order.md diff --git a/Engine/Game/Build/20260929/local_music.h b/Engine/Game/Build/20260929/local_music.h index da5c2da8..b472fe4b 100644 --- a/Engine/Game/Build/20260929/local_music.h +++ b/Engine/Game/Build/20260929/local_music.h @@ -41,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 music_ui_contracts{{ music_ui_initialize_contract, {construct_playlist, {0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x8b,0xda,0x48,0x8b,0xf9,0x48,0x8d,0x15,0x21,0x95,0x32,0x04,0xe8,0x94,0x30,0x5e,0x00,0x33,0xc9,0x48,0x8d}}, diff --git a/Extension/Music/Test/local_music_playback_policy_tests.cpp b/Extension/Music/Test/local_music_playback_policy_tests.cpp new file mode 100644 index 00000000..f2769c89 --- /dev/null +++ b/Extension/Music/Test/local_music_playback_policy_tests.cpp @@ -0,0 +1,77 @@ +#include "Extension/Music/local_music_playback_policy.h" +#include + +namespace policy = dingosdk::profile_runtime::music_playback; + +int main() { + int failures = 0; + const auto check = [&](bool ok, const char* message) { + if (!ok) { std::printf("FAIL: %s\n", message); ++failures; } + }; + + check(policy::cursor_sync_sentinel == 0x7fffffffu, + "native cursor synchronization sentinel is INT_MAX, distinct from queue -1"); + + // The queue's global candidate asset pool can contain hundreds of songs while + // the selected native playlist contains only its authored subset. + std::array two_track_members{}; + two_track_members[0] = 0x12; + two_track_members[16] = 0x34; + const std::array second_track{0x34}; + std::uint32_t member_position{}; + check(policy::find_member_position(two_track_members.data(), 2, second_track, member_position) && + member_position == 1, + "active current-song GUID is resolved against a playlist subset, independent of candidate-pool size"); + + auto plan = policy::plan_cursor(false, policy::native_default_mode, + policy::cursor_sync_sentinel, true, false, false, 0, 0); + check(plan.mode == policy::native_sequential_mode && plan.set_mode, + "sequential is selected when shuffle is off"); + check(plan.set_cursor && plan.cursor == 0, "a new sequential playlist starts at authored entry zero"); + + plan = policy::plan_cursor(true, policy::native_sequential_mode, 4, + false, false, true, 3, 6); + check(plan.mode == policy::native_default_mode && plan.set_mode && !plan.set_cursor, + "shuffle restores native default selection without moving its cursor"); + + plan = policy::plan_cursor(false, policy::native_default_mode, + policy::cursor_sync_sentinel, false, true, true, 2, 6); + check(plan.set_mode && plan.set_cursor && plan.cursor == policy::cursor_sync_sentinel, + "a pending explicit song request keeps native GUID synchronization"); + + plan = policy::plan_cursor(false, policy::native_default_mode, + policy::cursor_sync_sentinel, false, false, true, 0, 6); + check(plan.set_mode && plan.cursor == 1, + "turning shuffle off mid-play continues after the current first track"); + + plan = policy::plan_cursor(false, policy::native_default_mode, + policy::cursor_sync_sentinel, false, false, true, 5, 6); + check(plan.cursor == 0, "sequential playback wraps after the final playlist entry"); + + plan = policy::plan_cursor(false, policy::native_sequential_mode, 3, + false, false, true, 2, 6); + check(!plan.set_mode && !plan.set_cursor && plan.cursor == 3, + "an active sequential cursor keeps progressing without being reseeded"); + + plan = policy::plan_cursor(false, policy::native_sequential_mode, 3, + true, false, true, 2, 6); + check(plan.set_cursor && plan.cursor == 0, + "a newly selected playlist starts at its first entry even if its current GUID matches"); + + plan = policy::plan_cursor(false, policy::native_sequential_mode, 0, + true, false, false, 0, 2); + check(plan.set_cursor && plan.cursor == 0, + "new playlist identity explicitly seeds entry zero even when the prior cursor was also zero"); + + plan = policy::plan_cursor(false, policy::native_default_mode, + policy::cursor_sync_sentinel, false, false, false, 0, 6); + check(plan.cursor == 0, "an unresolvable current track safely restarts at the first entry"); + + plan = policy::plan_cursor(false, policy::native_sequential_mode, + policy::cursor_sync_sentinel, false, false, false, 0, 6); + check(!plan.set_mode && plan.set_cursor && plan.cursor == 0, + "a stale sync sentinel without a pending click cannot strand sequential selection"); + + std::printf(failures ? "%d failure(s)\n" : "all passed\n", failures); + return failures ? 1 : 0; +} diff --git a/Extension/Music/local_music_playback.cpp b/Extension/Music/local_music_playback.cpp new file mode 100644 index 00000000..14c056e0 --- /dev/null +++ b/Extension/Music/local_music_playback.cpp @@ -0,0 +1,262 @@ +#include "local_music_playback.h" + +#include "local_music_playback_policy.h" +#include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/20260929/local_music.h" +#include "Engine/Game/Build/supported_build.h" + +#include +#include +#include +#include +#include + +namespace dingosdk::profile_runtime { +namespace { +using namespace dingosdk::game::build::v20260929; +using local_music::playback_mode_setter_contract; +using local_music::playback_select_next_contract; +using music_playback::CursorPlan; + +std::atomic image_base{}; +std::atomic shuffle_enabled{}; +std::atomic playback_available{}; +MusicSelectNext select_next{}; +using SetPlaybackMode = void (__fastcall *)(std::uintptr_t manager, std::uint32_t playlist_id, + std::uint32_t mode); +SetPlaybackMode set_playback_mode{}; +struct LastBinding { + std::uintptr_t manager{}; + std::uintptr_t queue{}; + std::uintptr_t node{}; + std::uint32_t playlist_id{std::numeric_limits::max()}; +}; +std::array last_binding_by_channel{}; // Protected by the binding-cache lock. +SRWLOCK binding_cache_lock = SRWLOCK_INIT; + +bool read_at(std::uintptr_t address, auto& value) noexcept { + return dingosdk::memory::peek(address, value); +} + +struct CriticalSectionGuard { + explicit CriticalSectionGuard(std::uintptr_t address) noexcept + : section(reinterpret_cast(address)) { EnterCriticalSection(section); } + ~CriticalSectionGuard() { LeaveCriticalSection(section); } + CriticalSectionGuard(const CriticalSectionGuard&) = delete; + CriticalSectionGuard& operator=(const CriticalSectionGuard&) = delete; + LPCRITICAL_SECTION section; +}; + +struct BindingCacheGuard { + BindingCacheGuard() noexcept { AcquireSRWLockExclusive(&binding_cache_lock); } + ~BindingCacheGuard() { ReleaseSRWLockExclusive(&binding_cache_lock); } + BindingCacheGuard(const BindingCacheGuard&) = delete; + BindingCacheGuard& operator=(const BindingCacheGuard&) = delete; +}; + +bool valid_range(std::uintptr_t value, std::size_t size) noexcept { + return value >= 0x10000 && value <= dingosdk::memory::highest_user_address - size; +} + +bool find_channel_node(std::uintptr_t manager, std::uint32_t channel, + std::uint32_t& playlist_id) noexcept { + std::uintptr_t buckets{}; + std::uint32_t capacity{}; + std::uintptr_t sentinel{}, node{}; + if (!read_at(manager + 0xc8, buckets) || !read_at(manager + 0xd0, capacity) || + !buckets || !capacity || capacity > 4096 || !valid_range(buckets, (capacity + 1ULL) * 8) || + !read_at(buckets + capacity * 8ULL, sentinel) || + !read_at(buckets + (channel % capacity) * 8ULL, node)) return false; + for (std::uint32_t seen = 0; node && node != sentinel && seen < 4096; ++seen) { + std::uint32_t key{}, id{}; + std::uintptr_t next{}; + if (!valid_range(node, 0x10) || !read_at(node, key) || !read_at(node + 4, id) || + !read_at(node + 8, next)) return false; + if (key == channel) { playlist_id = id; return true; } + node = next; + } + return false; +} + +std::uintptr_t find_playlist_node(std::uintptr_t manager, std::uint32_t playlist_id) noexcept { + std::uintptr_t buckets{}; + std::uint32_t capacity{}; + std::uintptr_t sentinel{}, node{}; + if (!read_at(manager + 0xa0, buckets) || !read_at(manager + 0xa8, capacity) || + !buckets || !capacity || capacity > 4096 || !valid_range(buckets, (capacity + 1ULL) * 8) || + !read_at(buckets + capacity * 8ULL, sentinel) || + !read_at(buckets + (playlist_id % capacity) * 8ULL, node)) return 0; + for (std::uint32_t seen = 0; node && node != sentinel && seen < 8192; ++seen) { + std::uint32_t id{}; + std::uintptr_t next{}; + if (!valid_range(node, 0x60) || !read_at(node, id) || !read_at(node + 0x58, next)) return 0; + if (id == playlist_id) return node; + node = next; + } + return 0; +} + +bool song_count(std::uintptr_t playlist_asset, std::uint32_t& count) noexcept { + std::uintptr_t entries{}; + std::uint32_t encoded_count{}; + if (!read_at(playlist_asset + 0x60, entries) || !entries || + !read_at(entries - sizeof(encoded_count), encoded_count)) return false; + count = encoded_count & 0x7fffffffu; + return count > 0 && count <= 8192 && valid_range(entries, static_cast(count) * 8); +} + +bool member_count(std::uintptr_t node, std::uintptr_t& begin, std::uint32_t& count) noexcept { + std::uintptr_t end{}; + if (!read_at(node + 0x10, begin) || !read_at(node + 0x18, end) || !begin || end < begin || + (end - begin) % 16 || (end - begin) / 16 > 8192) return false; + count = static_cast((end - begin) / 16); + return count > 0 && valid_range(begin, static_cast(count) * 16); +} + +bool current_guid(std::uintptr_t queue, std::uintptr_t playlist_asset, + bool& found, std::array& guid) noexcept { + found = false; + std::int32_t current_index{}; + std::uintptr_t candidates_begin{}, candidates_end{}, entries{}; + std::uint32_t count{}; + if (!read_at(queue + 0x70, current_index)) return false; + if (current_index < 0) return true; + if (!read_at(queue + 0x78, candidates_begin) || !read_at(queue + 0x80, candidates_end) || + !read_at(playlist_asset + 0x60, entries) || !song_count(playlist_asset, count) || + candidates_end < candidates_begin || (candidates_end - candidates_begin) % 4) return false; + if (static_cast(current_index) >= (candidates_end - candidates_begin) / 4) return false; + + std::uint32_t asset_index{}; + if (!read_at(candidates_begin + static_cast(current_index) * 4, asset_index) || asset_index >= count) + return false; + std::uintptr_t graph{}; + if (!read_at(entries + static_cast(asset_index) * 8, graph)) return false; + graph &= ~std::uintptr_t{4}; + if (!valid_range(graph, 0x20) || graph < 16) return false; + std::uint32_t flags{}; + if (!read_at(graph + 0x14, flags)) return false; + if (!(flags & 0x100)) return true; + if (!dingosdk::memory::peek_bytes(graph - 16, guid.data(), guid.size())) return false; + found = true; + return true; +} + +bool member_index(std::uintptr_t members, std::uint32_t member_count_value, + const std::array& guid, bool has_guid, + bool& found, std::uint32_t& position) noexcept { + found = false; + if (!has_guid) return true; + for (std::uint32_t i = 0; i < member_count_value; ++i) { + std::array candidate{}; + if (!dingosdk::memory::peek_bytes(members + static_cast(i) * 16, + candidate.data(), candidate.size())) return false; + std::uint32_t ignored{}; + if (music_playback::find_member_position(candidate.data(), 1, guid, ignored)) { + found = true; + position = i; + break; + } + } + return true; +} + +bool pending_song_request(std::uintptr_t queue, bool& pending) noexcept { + std::uintptr_t begin{}, end{}; + pending = false; + if (!read_at(queue + 0x98, begin) || !read_at(queue + 0xa0, end) || end < begin || + (end - begin) % 4 || (end - begin) / 4 > 4096) return false; + pending = begin != end; + return true; +} + +void apply_policy(std::uintptr_t queue, std::uintptr_t playlist_asset) noexcept { + const auto base = image_base.load(std::memory_order_acquire); + if (!base || !queue || !playlist_asset) return; + std::uint32_t queue_type{}, channel{}, channel_playlist{}; + if (!read_at(playlist_asset + 0x70, queue_type) || queue_type != 4 || + !read_at(queue + 0x60, channel) || channel >= last_binding_by_channel.size()) return; + + // Snapshot the queue-local state under its native lock, then release it before + // taking the music manager lock. The selector can re-enter this queue lock. + bool pending{}, has_current_guid{}; + std::array playing_guid{}; + std::uint32_t asset_songs{}; + { + CriticalSectionGuard queue_lock(queue + 0x150); + if (!pending_song_request(queue, pending) || !song_count(playlist_asset, asset_songs) || + !current_guid(queue, playlist_asset, has_current_guid, playing_guid)) return; + } + + std::uintptr_t manager{}; + if (!read_at(base + local_music::asset_manager, manager) || !manager || + !valid_range(manager, 0xd8)) return; + CriticalSectionGuard manager_lock(manager + 0x20); + std::uintptr_t current_manager{}; + if (!read_at(base + local_music::asset_manager, current_manager) || current_manager != manager || + !find_channel_node(manager, channel, channel_playlist)) return; + const auto node = find_playlist_node(manager, channel_playlist); + if (!node) return; + BindingCacheGuard cache_lock; + + std::uint32_t current_mode{}, current_cursor{}, member_total{}; + std::uintptr_t members{}; + if (!read_at(node + 0x0c, current_mode) || !read_at(node + 0x30, current_cursor) || + !member_count(node, members, member_total)) return; + + const auto& previous = last_binding_by_channel[channel]; + const bool new_group = previous.manager != manager || previous.queue != queue || + previous.node != node || previous.playlist_id != channel_playlist; + bool current_found{}; + std::uint32_t current_position{}; + if (!member_index(members, member_total, playing_guid, has_current_guid, current_found, current_position)) return; + + const auto plan = music_playback::plan_cursor(shuffle_enabled.load(std::memory_order_acquire), + current_mode, current_cursor, new_group, pending, current_found, current_position, member_total); + if (plan.set_mode) { + // Clear the rebuild latch only as an input. The native rebuild sets it on + // success; preserving that output is required for later rebuild semantics. + *reinterpret_cast(node + 8) = 0; + set_playback_mode(manager, channel_playlist, plan.mode); + } + if (plan.set_cursor) *reinterpret_cast(node + 0x30) = plan.cursor; + last_binding_by_channel[channel] = {manager, queue, node, channel_playlist}; +} +} + +bool initialize_music_playback(std::uintptr_t base, bool shuffle) noexcept { + if (!base || local_music::playback_select_next_contract.rva >= supported_build::game_image_size || + local_music::playback_mode_setter_contract.rva >= supported_build::game_image_size) return false; + std::array selector{}, setter{}; + if (!dingosdk::memory::read_bytes(base + local_music::playback_select_next_contract.rva, + selector.data(), selector.size()) || selector != local_music::playback_select_next_contract.bytes || + !dingosdk::memory::read_bytes(base + local_music::playback_mode_setter_contract.rva, + setter.data(), setter.size()) || setter != local_music::playback_mode_setter_contract.bytes) return false; + image_base.store(base, std::memory_order_release); + set_playback_mode = reinterpret_cast(base + local_music::playback_mode_setter_contract.rva); + shuffle_enabled.store(shuffle, std::memory_order_release); + last_binding_by_channel = {}; + playback_available.store(false, std::memory_order_release); + return true; +} + +void activate_music_playback() noexcept { playback_available.store(true, std::memory_order_release); } +void deactivate_music_playback() noexcept { playback_available.store(false, std::memory_order_release); } +bool music_playback_available() noexcept { return playback_available.load(std::memory_order_acquire); } + +MusicSelectNext& music_select_next_original() noexcept { return select_next; } + +std::int32_t __fastcall music_select_next_hook(std::uintptr_t queue, std::uintptr_t playlist_asset) { + if (select_next) { + apply_policy(queue, playlist_asset); + return select_next(queue, playlist_asset); + } + return -1; +} + +bool music_shuffle_enabled() noexcept { return shuffle_enabled.load(std::memory_order_acquire); } + +void set_music_shuffle_enabled(bool enabled) noexcept { + shuffle_enabled.store(enabled, std::memory_order_release); +} +} // namespace dingosdk::profile_runtime diff --git a/Extension/Music/local_music_playback.h b/Extension/Music/local_music_playback.h new file mode 100644 index 00000000..ee59bcbb --- /dev/null +++ b/Extension/Music/local_music_playback.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace dingosdk::profile_runtime { +using MusicSelectNext = std::int32_t (__fastcall *)(std::uintptr_t queue, std::uintptr_t playlist_asset); + +// Validate the supported native selector/setter contracts and seed the lock-free +// preference read used by the audio selection hook. +bool initialize_music_playback(std::uintptr_t base, bool shuffle) noexcept; +void activate_music_playback() noexcept; +void deactivate_music_playback() noexcept; +bool music_playback_available() noexcept; +MusicSelectNext& music_select_next_original() noexcept; +std::int32_t __fastcall music_select_next_hook(std::uintptr_t queue, std::uintptr_t playlist_asset); + +bool music_shuffle_enabled() noexcept; +void set_music_shuffle_enabled(bool enabled) noexcept; +} // namespace dingosdk::profile_runtime diff --git a/Extension/Music/local_music_playback_policy.h b/Extension/Music/local_music_playback_policy.h new file mode 100644 index 00000000..8ce23088 --- /dev/null +++ b/Extension/Music/local_music_playback_policy.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +namespace dingosdk::profile_runtime::music_playback { +inline constexpr std::uint32_t native_default_mode = 0; +inline constexpr std::uint32_t native_sequential_mode = 1; +inline constexpr std::uint32_t cursor_sync_sentinel = 0x7fffffffu; + +struct CursorPlan { + std::uint32_t mode{}; + bool set_mode{}; + bool set_cursor{}; + std::uint32_t cursor{}; +}; + +constexpr bool find_member_position(const std::uint8_t* members, std::uint32_t member_count, + const std::array& guid, + std::uint32_t& position) noexcept { + if (!members) return false; + for (std::uint32_t i = 0; i < member_count; ++i) { + bool equal = true; + for (std::uint32_t byte = 0; byte < guid.size(); ++byte) + equal &= members[i * guid.size() + byte] == guid[byte]; + if (equal) { position = i; return true; } + } + return false; +} + +// Plan a native transition without touching the game's data. The current position +// is in the mode-1 ordered-index vector after rebuilding it as identity order. +constexpr CursorPlan plan_cursor(bool shuffle, std::uint32_t current_mode, + std::uint32_t current_cursor, bool new_group, + bool explicit_request_pending, bool current_track_found, + std::uint32_t current_order_position, + std::uint32_t order_count) noexcept { + const auto desired_mode = shuffle ? native_default_mode : native_sequential_mode; + CursorPlan plan{desired_mode, current_mode != desired_mode, false, current_cursor}; + if (shuffle) return plan; + + if (explicit_request_pending) { + plan.set_cursor = current_cursor != cursor_sync_sentinel || plan.set_mode || new_group; + plan.cursor = cursor_sync_sentinel; + } else if (new_group) { + plan.set_cursor = true; + plan.cursor = 0; + } else if (plan.set_mode) { + plan.set_cursor = true; + plan.cursor = current_track_found && order_count + ? (current_order_position + 1 == order_count ? 0 : current_order_position + 1) + : 0; + } else if (current_cursor == cursor_sync_sentinel) { + // No queued manual song can synchronize the sentinel, so a fresh playlist + // starts at its first authored entry. + plan.set_cursor = true; + plan.cursor = 0; + } + return plan; +} +} // namespace dingosdk::profile_runtime::music_playback diff --git a/Extension/Profile/local_profile_runtime.cpp b/Extension/Profile/local_profile_runtime.cpp index b6c53181..903e0249 100644 --- a/Extension/Profile/local_profile_runtime.cpp +++ b/Extension/Profile/local_profile_runtime.cpp @@ -6,6 +6,7 @@ #include "Extension/Customization/local_player_card_runtime.h" #include "Extension/Music/local_music_ui.h" #include "Extension/Music/local_music_shelf.h" +#include "Extension/Music/local_music_playback.h" #include "Extension/News/local_news_runtime.h" #include "Extension/Objects/local_buildkit_labels.h" #include "Extension/Objects/local_buildkit_limits.h" @@ -535,6 +536,13 @@ bool initialize_local_profile(std::uintptr_t base, bool authored_offline, else dingosdk::logging::event(dingosdk::logging::Channel::music, "{\"event\":\"music_model_construct_contract_mismatch\"}"); } + const bool playback_ready = initialize_music_playback(base, + local_preference("MusicShuffle").value_or(false)); + if (playback_ready) + hook(addr::local_music::playback_select_next_contract, + &music_select_next_hook, music_select_next_original()); + else dingosdk::logging::event(dingosdk::logging::Channel::music, + "{\"event\":\"music_playback_order_contract_mismatch\"}"); hook(buildkit_text_exists_contract, &buildkit_text_exists, buildkit_text_functions().exists); hook(buildkit_text_translate_contract, &buildkit_text_translate, buildkit_text_functions().translate); hook(buildkit_grabber_settings_contract, &buildkit_grabber_settings_hook, buildkit_limits_runtime().grabber_settings); @@ -570,6 +578,7 @@ bool initialize_local_profile(std::uintptr_t base, bool authored_offline, enable_attempted = true; if (hook_enable(target) != HookOk) throw std::runtime_error("Cannot enable local profile hook"); } + if (playback_ready) activate_music_playback(); initialize_placement_store(path); initialize_park_editor(path.parent_path()); set_park_mods_root(mods::engine_data_root()); @@ -585,6 +594,7 @@ bool initialize_local_profile(std::uintptr_t base, bool authored_offline, return true; } catch (const std::exception& e) { s.active.store(false, std::memory_order_release); + deactivate_music_playback(); set_local_profile_event_provider(base, false); set_local_object_browser_provider(base, nullptr); for (auto* target : created) { diff --git a/Extension/UI/Overlay/skate_menu_settings.cpp b/Extension/UI/Overlay/skate_menu_settings.cpp index e63bdcc9..36479e13 100644 --- a/Extension/UI/Overlay/skate_menu_settings.cpp +++ b/Extension/UI/Overlay/skate_menu_settings.cpp @@ -1,6 +1,8 @@ #include "Engine/Core/Platform/launcher_support.h" #include "skate_menu_internal.h" #include "Engine/Core/Profiling/profiler.h" +#include "Extension/Music/local_music_playback.h" +#include "Extension/Profile/local_profile_runtime.h" #include #include @@ -8,6 +10,15 @@ // The SETTINGS and DEVELOPER pages. namespace dingosdk::overlay::menu { void ui_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { + begin_card(menu, "music-playback", "MUSIC PLAYBACK"); + bool shuffle = dingosdk::profile_runtime::music_shuffle_enabled(); + if (toggle_row(menu, "Shuffle playlists", "Off plays songs in playlist order. On shuffles.", shuffle, + dingosdk::profile_runtime::music_playback_available())) { + dingosdk::profile_runtime::set_music_shuffle_enabled(shuffle); + dingosdk::profile_runtime::set_local_preference("MusicShuffle", shuffle); + } + end_card(); + begin_card(menu, "on-screen", "ON SCREEN"); bool hidden = model.debug.game_ui_hidden; if (toggle_row(menu, "Hide game UI", "Keep the view clear for riding and captures.", hidden, diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index c6b73124..2f367e74 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -52,6 +52,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_include_directories(dingosdk_music_safety_tests PRIVATE "${PROJECT_SOURCE_DIR}") dingosdk_configure_target(dingosdk_music_safety_tests) add_test(NAME music_safety COMMAND dingosdk_music_safety_tests) + add_executable(dingosdk_music_playback_policy_tests Extension/Music/Test/local_music_playback_policy_tests.cpp) + target_include_directories(dingosdk_music_playback_policy_tests PRIVATE "${PROJECT_SOURCE_DIR}") + dingosdk_configure_target(dingosdk_music_playback_policy_tests) + add_test(NAME music_playback_policy COMMAND dingosdk_music_playback_policy_tests) add_executable(dingosdk_music_shelf_lifetime_tests Extension/Music/Test/local_music_shelf_lifetime_tests.cpp) target_include_directories(dingosdk_music_shelf_lifetime_tests PRIVATE "${PROJECT_SOURCE_DIR}") dingosdk_configure_target(dingosdk_music_shelf_lifetime_tests) diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index d8458a68..e9f4e7bc 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -142,6 +142,7 @@ add_library(dingosdk_runtime SHARED Extension/Objects/local_object_runtime.cpp Extension/Music/local_music_assets.cpp Extension/Music/local_music_safety.cpp + Extension/Music/local_music_playback.cpp Extension/Music/music_artwork.cpp Extension/Music/local_music_ui.cpp Extension/Music/local_music_shelf.cpp diff --git a/contrib/music-playback-order.md b/contrib/music-playback-order.md new file mode 100644 index 00000000..90d4975b --- /dev/null +++ b/contrib/music-playback-order.md @@ -0,0 +1,22 @@ +# Music playback order + +The music selector used a random candidate scan for both stock and mod playlists. The selector now resolves its queue channel to the registered native playlist and applies `PlaylistPlaybackMode::Sequential` to that native node when Shuffle is off. The registered native playlist already carries the published or authored GUID order, so this change reuses that membership and order. Queue channels and playlist IDs are looked up at runtime; neither is fixed in the hook. Only `MusicPlaylistType::SelectionBehavior` queues with a valid native channel/playlist binding are handled. + +Open **Settings → Interface → Music Playback → Shuffle playlists**. Shuffle defaults to off, so playback begins at the playlist's first entry and continues in playlist order. Turning it on restores the engine's default random selection. The value is stored as the local profile preference `ReSkate.MusicShuffle`. + +On an active playlist, turning Shuffle off seeds the sequential cursor from the current song's GUID so the next selection follows that song. A queued manual song request retains native GUID synchronization. A newly selected playlist starts at its first entry. The engine's existing per-update admission-latch reset remains responsible for allowing the following track; the hook does not clear that latch itself. + +The code targets the supported 2026-09-29 game build. Selector and mode-setter fingerprints gate installation. The selector hook returns the native signed candidate index (`-1` on failure) and forwards unchanged whenever the queue or native playlist mapping is not verified. + +## Verification + +Static/build checks: + +- Configure: `cmake --preset vs2022-x64 -DDINGOSDK_BUILD_LAUNCHER_TESTS=ON` +- Build: `cmake --build build/vs2022-x64 --config Release --parallel 8 --target dingosdk_runtime dingosdk_music_playback_policy_tests` +- Existing focused test executables: `cmake --build build/vs2022-x64 --config Release --parallel 8 --target dingosdk_mod_music_tests dingosdk_music_safety_tests dingosdk_music_shelf_lifetime_tests` +- Tests: `ctest --test-dir build/vs2022-x64 -C Release -R "^(music_shelf_lifetime|music_safety|mod_music|music_playback_policy)$" --output-on-failure` + +Configuration and both build commands completed successfully. The focused CTest selection passed 4/4 tests (`mod_music`, `music_safety`, `music_playback_policy`, and `music_shelf_lifetime`). + +Live acceptance remains pending. Check San Van Soundtrack starts with its first three published tracks; Your Mixtape follows its two authored tracks and wraps; Next and natural completion use the same order; manual song selection and Previous retain their expected behavior; toggling Shuffle on and off mid-playlist continues from the current song; the setting survives relaunch; and changing playlists or maps does not carry the prior playlist's cursor into the next one. From 6399119646364720a79b25eede81a0201094ce68 Mon Sep 17 00:00:00 2001 From: ReSkate local Date: Tue, 6 Oct 2026 03:43:51 +0100 Subject: [PATCH 071/109] Music: verify shuffle preference persistence --- .../Profile/Test/profile_change_tests.cpp | 4 ++++ .../Profile/local_profile_preferences.cpp | 23 +++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/Extension/Profile/Test/profile_change_tests.cpp b/Extension/Profile/Test/profile_change_tests.cpp index 4fe8c045..f939ecbd 100644 --- a/Extension/Profile/Test/profile_change_tests.cpp +++ b/Extension/Profile/Test/profile_change_tests.cpp @@ -41,6 +41,9 @@ int main() { check(announced(), "several settings saved together are announced"); store.set_bool_option("gp_use_high_cam", false); check(announced(), "a profile option save is announced"); + store.set_user_value("ReSkate.MusicShuffle", true); + check(announced() && store.user_value("ReSkate.MusicShuffle") == Json(true), + "the music shuffle preference is saved as a Boolean profile option"); store.set_selected_cosmetic_preset(3); check(announced(), "a save outside the settings is announced"); } @@ -50,6 +53,7 @@ int main() { const auto number = store.user_value("grindAssist"); check(store.native_profile_option(2, "UseHighCam") == Json(false) && store.user_value("enableCompass") == Json(false) && store.user_value("cameraMode") == Json(2) && + store.user_value("ReSkate.MusicShuffle") == Json(true) && number && number->is_number_float() && number->get() == 20.0 && store.selected_cosmetic_preset() == 3, "saved settings come back after a restart"); } diff --git a/Extension/Profile/local_profile_preferences.cpp b/Extension/Profile/local_profile_preferences.cpp index 6494e070..7ef08abd 100644 --- a/Extension/Profile/local_profile_preferences.cpp +++ b/Extension/Profile/local_profile_preferences.cpp @@ -62,8 +62,27 @@ std::optional local_preference(std::string_view key) noexcept { void set_local_preference(std::string_view key, bool value) noexcept { try { auto& s = local_runtime(); - if (s.store) s.store->set_user_value("ReSkate." + std::string(key), value); - } catch (...) { /* Saving is best effort; the runtime value already applied. */ } + if (!s.store) { + dingosdk::logging::event(dingosdk::logging::Channel::profile, + "{\"event\":\"local_preference_save_failed\",\"reason\":\"profile_unavailable\"}"); + return; + } + const auto full_key = "ReSkate." + std::string(key); + s.store->set_user_value(full_key, value); + const auto saved = s.store->user_value(full_key); + if (!saved || !saved->is_boolean() || saved->get() != value) { + dingosdk::logging::event(dingosdk::logging::Channel::profile, + dingosdk::Json{{"event", "local_preference_save_failed"}, + {"key", full_key}, {"reason", "readback_mismatch"}}.dump().c_str()); + return; + } + dingosdk::logging::event(dingosdk::logging::Channel::profile, + dingosdk::Json{{"event", "local_preference_saved"}, + {"key", full_key}, {"value", value}}.dump().c_str()); + } catch (...) { + dingosdk::logging::event(dingosdk::logging::Channel::profile, + "{\"event\":\"local_preference_save_failed\",\"reason\":\"exception\"}"); + } } std::optional local_value(std::string_view key) noexcept { From 2cdc5ee8df91caf32beb91eb372cf2d3640d37d1 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Mon, 5 Oct 2026 23:39:16 -0500 Subject: [PATCH 072/109] Fix costume merging, restrict server names, add Centrix tag - Mod merge: rebuild a merged bundle's chunk metadata the way the game writes it (one record per chunk, in chunk GUID order), so costumes from different mods built on the same game costume keep their textures and meshes - Merge audit checks that layout, and there is a new unit test for it - Server names: only letters, numbers, spaces and - _ [ ] ( ), 1 to 64 characters; the server browser hides servers with other names - New Centrix tag from the backend's "centrix" list: blue and white name, badge and item animation, ranked after Dev and before Creator --- Engine/Game/Multiplayer/session_model.h | 21 +- .../Vfs/Test/chunk_metadata_merge_tests.cpp | 203 ++++++++++++++++++ Engine/Vfs/Test/mod_merge_audit_tests.cpp | 153 ++++++++++++- Engine/Vfs/mod_merge_combine.cpp | 175 ++++++++++++++- Engine/Vfs/mod_merge_internal.h | 34 +++ Engine/Vfs/native_db.cpp | 2 +- Engine/Vfs/native_db.h | 4 + .../Customization/developer_hoodie_material.h | 7 +- Extension/Multiplayer/Hud/custom_nametags.h | 7 +- .../Multiplayer/Session/session_internal.h | 2 +- .../Multiplayer/Session/session_view.cpp | 4 +- .../Steam/steam_server_browser.cpp | 5 +- .../Test/developer_identity_tests.cpp | 16 +- Extension/Multiplayer/Test/mesh_tests.cpp | 6 + Extension/Multiplayer/developer_identity.cpp | 2 +- Extension/Multiplayer/developer_identity.h | 8 +- Extension/UI/Overlay/nametag_gradient.h | 1 + Extension/UI/Overlay/overlay.h | 2 +- Extension/UI/Overlay/role_badge.h | 2 +- Server/README.txt | 2 +- Server/Test/server_config_tests.cpp | 10 + Server/server_config.cpp | 2 +- Server/server_host.cpp | 2 +- cmake/Apps.cmake | 4 + 24 files changed, 645 insertions(+), 29 deletions(-) create mode 100644 Engine/Vfs/Test/chunk_metadata_merge_tests.cpp diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 0594ed59..4b3a8383 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -8,10 +8,25 @@ #include #include #include +#include #include 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; @@ -71,7 +86,7 @@ struct MultiplayerChatLine { std::string name, text; bool local{}; // sent by this player // The sender's role, as their nametag shows it: its colour (IM_COL32 layout, 0 = none) - // and a tag shown in a box before the name ("Dev", "Creator", "Homie", "Admin", "Host", "Friend" or empty). + // and a tag shown in a box before the name ("Dev", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend" 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 @@ -127,7 +142,7 @@ struct MultiplayerModel { float chat_bubbles_distance{40.f}; float chat_bubbles_duration{5.f}; int chat_bubbles_history{3}; - // Local: the tag the ReSkate backend gives this player ("Dev", "Creator" or "Homie"; empty + // Local: the tag the ReSkate backend gives this player ("Dev", "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; @@ -135,7 +150,7 @@ struct MultiplayerModel { 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", "RED" or "GOLD"), 1 off, 2 a gradient + // their list gives (`identity_animation`: "RAINBOW", "RED", "BLUE" or "GOLD"), 1 off, 2 a gradient // between the two colours they picked, 3 the first of them alone. speed: 0 normal, 1 slow, // 2 fast. struct IdentityStyle { diff --git a/Engine/Vfs/Test/chunk_metadata_merge_tests.cpp b/Engine/Vfs/Test/chunk_metadata_merge_tests.cpp new file mode 100644 index 00000000..67aab2d4 --- /dev/null +++ b/Engine/Vfs/Test/chunk_metadata_merge_tests.cpp @@ -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 +#include +#include +#include + +namespace db = dingosdk::native_db; +namespace fb = dingosdk::frostbite; +using namespace dingosdk::mods::detail; + +namespace { +using Bytes = std::vector; + +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(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 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(value >> (8 * byte))); + return node; +} +Bytes list_of(const std::vector& 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(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 read_list(const Bytes& list) { + const std::span bytes(reinterpret_cast(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 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 shipped{guid(0x50), guid(0x30), guid(0x70), guid(0x10), guid(0x60)}; + const std::vector 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 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 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 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 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 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{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 withNewer{list_of(game), list_of(newer)}; + const std::vector 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{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 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 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{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 modsOnly{list_of({firstTexture, firstMesh}), list_of({firstTexture, firstMesh, secondTexture})}; + const std::vector 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 both{chunk(0x90, 0, 0), chunk(0x20, 0, 1), chunk(0x40, 1, 2)}; + check(read_list(merge_chunk_metadata(modsOnly, both)) == std::vector{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 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 none{Bytes{}, Bytes{}}; + const std::vector 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 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; +} diff --git a/Engine/Vfs/Test/mod_merge_audit_tests.cpp b/Engine/Vfs/Test/mod_merge_audit_tests.cpp index 692688c2..321ba3ea 100644 --- a/Engine/Vfs/Test/mod_merge_audit_tests.cpp +++ b/Engine/Vfs/Test/mod_merge_audit_tests.cpp @@ -12,7 +12,10 @@ // every reference a mod appended to one of its lists, and has every value a mod changed in // place changed: no value in it is the merge's own invention; // * a list whose entries the game keeps beside it (an item list, the music playlist) names -// nothing its own bundle does not hold, in every copy of that bundle the patch carries. +// nothing its own bundle does not hold, in every copy of that bundle the patch carries; +// * a bundle's chunk metadata has one record for each of its chunks, in the order of the +// chunks' guids as the game writes it: every texture's own record, with the first mip its +// header gives, is at its chunk's place. // What is only reported: assets two mods add under one name, and assets of the game two mods // change where the patch has one mod's copy whole. // @@ -30,7 +33,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -225,6 +230,12 @@ struct Audit { std::map facts; std::map guids; std::vector contested; + // The patch's bundles (by TOC and bundle) that a mod ships a readable copy of: the ones whose + // chunk metadata the merge may have written. + std::set> chunkBundles; + // Those of them the patch has exactly as one mod ships them, chunks and list: the merge wrote + // nothing there. + std::set> chunkBundlesAsShipped; std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now(); void progress(const std::string& what) const { @@ -305,6 +316,84 @@ struct Audit { } return result; } + // One record of a bundle's chunk metadata: whose the chunk is, and a texture's first mip. + struct ChunkMeta { + std::optional owner; + std::optional firstMip; + }; + // A copy's chunk metadata, record by record; nothing when the list cannot be read. + static std::optional> records_of(const Bundle& bundle) { + std::vector records; + const auto& list = bundle.listing->manifest.chunkMetadata; + if (list.empty()) return records; + try { + const std::span bytes(reinterpret_cast(list.data()), list.size()); + const auto root = native_db::read(bytes, "chunk metadata", nullptr, {.unique_fields = false, .max_entries = bytes.size()}); + for (const auto& row : root.children) { + ChunkMeta record; + if (const auto* hash = row.field("h64"); hash && hash->payload().size() == 8) + std::memcpy(&record.owner.emplace(), hash->payload().data(), 8); + if (const auto* meta = row.field("meta")) + if (const auto* mip = meta->field("firstMip"); mip && mip->payload().size() == 4) + std::memcpy(&record.firstMip.emplace(), mip->payload().data(), 4); + records.push_back(record); + } + } catch (const std::exception&) { + return std::nullopt; + } + return records; + } + // The hash a record names a resource by: of its lower-case name. + static std::uint64_t owner_hash(std::string_view name) { + std::uint64_t value = 5381; + for (const auto c : name) value = value * 33U ^ static_cast(std::tolower(static_cast(c))); + return value; + } + // The textures of a copy of a bundle whose pixels are one of the bundle's chunks, each against + // the record at its chunk's place when the chunks are in the order of their guids: how many + // there are, how many have another resource's record (or none) there, how many their own with + // another first mip than the texture's header gives, and one of those that are wrong. + struct TextureRecords { + std::size_t textures{}, misplaced{}, mips{}; + std::string example; + }; + TextureRecords texture_records(int from, const Bundle& bundle, const std::vector& records) const { + constexpr std::uint32_t textureType = 0x6BDE20BA; + const auto& manifest = bundle.listing->manifest; + std::vector order(manifest.chunks.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 manifest.chunks[left].guid < manifest.chunks[right].guid; }); + std::map guidPlace; + for (std::size_t at = 0; at < order.size(); ++at) guidPlace.emplace(manifest.chunks[order[at]].guid, at); + TextureRecords result; + for (std::size_t index = 0; index < manifest.resources.size(); ++index) { + const auto& resource = manifest.resources[index]; + if (resource.resourceType != textureType || resource.resourceMeta.size() < 4) continue; + Bytes header; + try { header = payload(place(from, bundle.assets[manifest.ebx.size() + index].file)); } catch (const std::exception&) { continue; } + if (header.size() != 144 && header.size() != 180) continue; + // The header: type and format, a version's extra field, then flags and sizes, the mip + // count and first mip, padding, and the chunk the pixels are in. + std::uint32_t version{}; + std::memcpy(&version, resource.resourceMeta.data(), 4); + const std::size_t mips = 8 + 4 + 4 + (version >= 12 ? 4 : 0) + 10; + const auto firstMip = static_cast(header[mips + 1]); + fb::Guid chunk; + std::memcpy(chunk.bytes.data(), header.data() + mips + 2 + (header.size() == 180 ? 8 : 4), 16); + const auto at = guidPlace.find(chunk); + if (at == guidPlace.end()) continue; // its pixels are not in this bundle + ++result.textures; + const auto* record = at->second < records.size() ? &records[at->second] : nullptr; + if (!record || record->owner != owner_hash(resource.name)) { + if (!result.misplaced++ && result.example.empty()) result.example = resource.name + " has " + (record ? "another resource's record" : "no record") + " at its chunk's place"; + } else if (record->firstMip != firstMip) { + if (!result.mips++ && result.example.empty()) + result.example = resource.name + " is told first mip " + (record->firstMip ? std::to_string(*record->firstMip) : std::string("none")) + + ", its header says " + std::to_string(firstMip); + } + } + return result; + } std::string mod_names(const std::vector& list) const { std::set unique; for (const auto index : list) unique.insert(catalog.mods[index].name); @@ -567,6 +656,11 @@ struct Audit { const auto mine = read_bundle(static_cast(index + 2), bundle); if (!mine.listing || !patched.listing) { ++byName; continue; } const auto* game = game_bundle(bundle.name); + chunkBundles.emplace(merged->second, at->second); + if (mine.listing->manifest.chunkMetadata == patched.listing->manifest.chunkMetadata && + std::ranges::equal(mine.listing->manifest.chunks, patched.listing->manifest.chunks, {}, + &fb::BundleAsset::guid, &fb::BundleAsset::guid)) + chunkBundlesAsShipped.emplace(merged->second, at->second); for (const auto& entry : mine.assets) { ++assets; const auto key = key_of(*entry.asset); @@ -884,6 +978,62 @@ struct Audit { std::cout << copies << " such list(s) with entries mods added, across every copy in the patch; " << entries << " added entries checked\n"; } + // ---------------------------------------------------------------- chunk metadata + // The engine reads a chunk's record before it streams the chunk. The game writes a bundle's + // list with a record for each chunk, in the order of the chunks' guids, so a texture's record + // (the hash of its name, and its first mip) is at its chunk's place in that order. A bundle + // the patch has from more than the game's copy (two mods' costumes built on one game costume, + // each with chunks of its own) has to be in that form as well: with fewer records than + // chunks, or with records where the mods' own lists have them, textures and meshes get + // another's record or none and never appear. Checked wherever the game's own copy of the + // bundle is in that form, which is everywhere it has been looked at. + void chunk_metadata() { + section("Chunk metadata"); + std::size_t bundles{}, untouched{}, asShipped{}, checked{}, textures{}, unknownForm{}; + for (const auto& where : chunkBundles) { + const auto& toc = tocs[where.first]; + const auto& patched = toc.bundles[where.second]; + const auto& kept = patched.listing->manifest.chunks; + if (kept.empty()) continue; + ++bundles; + const auto what = toc.relative + ": " + patched.name; + const auto* game = game_bundle(patched.name); + // The game's own list on the game's own chunks is what the game shipped. + if (game && game->listing->manifest.chunkMetadata == patched.listing->manifest.chunkMetadata && + game->listing->manifest.chunks.size() == kept.size() && + std::ranges::equal(game->listing->manifest.chunks, kept, {}, &fb::BundleAsset::guid, &fb::BundleAsset::guid)) { + ++untouched; + continue; + } + // One mod's copy passed through whole is that mod's to have right: its tool's list is + // not in the game's form, and the merge does not rewrite what only one mod ships. + if (chunkBundlesAsShipped.contains(where)) { ++asShipped; continue; } + const auto records = records_of(patched); + if (!records) { fail("chunk metadata", what + ": the patch's list cannot be read"); continue; } + if (records->size() != kept.size() && !(records->empty() && (!game || game->listing->manifest.chunkMetadata.empty()))) { + fail("chunk metadata", what + ": " + std::to_string(records->size()) + " record(s) for " + + std::to_string(kept.size()) + " chunk(s)"); + continue; + } + // Only a bundle whose own copy in the game is in guid order says what the patch's has to be. + if (!game) { ++unknownForm; continue; } + const auto shipped = records_of(*game); + if (!shipped) { ++unknownForm; continue; } + const auto original = texture_records(0, *game, *shipped); + if (original.misplaced || original.mips) { ++unknownForm; continue; } + ++checked; + const auto merged = texture_records(1, patched, *records); + textures += merged.textures; + if (merged.misplaced || merged.mips) + fail("chunk metadata", what + ": of " + std::to_string(merged.textures) + " texture(s) with a chunk in the bundle, " + + std::to_string(merged.misplaced) + " do not have their own record at their chunk's place and " + + std::to_string(merged.mips) + " have another first mip than their header, e.g. " + merged.example); + } + std::cout << bundles << " bundle(s) mods ship that hold chunks: " << untouched << " with the game's own list on the game's own chunks, " + << asShipped << " exactly as one mod ships them, " << checked << " others checked, " << textures + << " texture record(s) in them; " << unknownForm << " whose own copy in the game gives nothing to check against\n"; + } + int summary() const { section("Summary"); std::size_t total{}; @@ -908,6 +1058,7 @@ int main(int argc, char** argv) try { audit.mods_against_patch(); audit.outcomes(); audit.lists(); + audit.chunk_metadata(); return audit.summary(); } catch (const std::exception& error) { std::cerr << "FAIL: " << error.what() << '\n'; diff --git a/Engine/Vfs/mod_merge_combine.cpp b/Engine/Vfs/mod_merge_combine.cpp index 3695e4ce..dc6b4cf4 100644 --- a/Engine/Vfs/mod_merge_combine.cpp +++ b/Engine/Vfs/mod_merge_combine.cpp @@ -36,6 +36,9 @@ struct 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 @@ -95,7 +98,13 @@ struct BundleState { std::uint32_t manifestChunk{}; std::vector assets; std::vector files; - std::vector 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> 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 shippedChunks; // Keyed position of each asset, so replacing one stays constant time: these // bundles hold tens of thousands of entries. std::map> seen; @@ -196,8 +205,115 @@ void build_manifest(const std::vector& assets, std::span emit(fb::AssetKind::chunk, manifest.chunks); } +std::span unsigned_bytes(std::span data) { + return {reinterpret_cast(data.data()), data.size()}; +} + } // namespace +std::vector merge_chunk_metadata(std::span> lists, + std::span chunks, std::size_t game, + std::span shipped) { + // A bundle with no chunks has nothing to describe; what the first copy has stays. + if (chunks.empty()) return lists.empty() ? std::vector{} : 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> 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 place(shipped.size()); // where each of the game's chunks has its record + if (original) { + std::vector 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 records; + std::vector 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 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 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(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. @@ -561,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; } @@ -580,7 +698,29 @@ 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(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. @@ -645,6 +785,8 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, 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); } } @@ -884,9 +1026,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 metadata; + { + std::vector 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 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 { diff --git a/Engine/Vfs/mod_merge_internal.h b/Engine/Vfs/mod_merge_internal.h index c2dac61e..722ae95b 100644 --- a/Engine/Vfs/mod_merge_internal.h +++ b/Engine/Vfs/mod_merge_internal.h @@ -230,6 +230,40 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, 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(-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 merge_chunk_metadata(std::span> lists, + std::span chunks, + std::size_t game = ChunkRecord::none, + std::span shipped = {}); + fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, std::vector& sources, MergeReport& report, const std::string& relative, ArchiveUse& used, CasStore& store, diff --git a/Engine/Vfs/native_db.cpp b/Engine/Vfs/native_db.cpp index 0e465181..8a428d9f 100644 --- a/Engine/Vfs/native_db.cpp +++ b/Engine/Vfs/native_db.cpp @@ -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; diff --git a/Engine/Vfs/native_db.h b/Engine/Vfs/native_db.h index 5d0c0d16..e6380ace 100644 --- a/Engine/Vfs/native_db.h +++ b/Engine/Vfs/native_db.h @@ -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". diff --git a/Extension/Customization/developer_hoodie_material.h b/Extension/Customization/developer_hoodie_material.h index 29e0a48f..6281af39 100644 --- a/Extension/Customization/developer_hoodie_material.h +++ b/Extension/Customization/developer_hoodie_material.h @@ -121,12 +121,14 @@ inline std::pair, std::array> stand 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}}; 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. No style asks for the rainbow: +// homie's from deep gold through bright gold to yellow and back, and Centrix's from blue +// through light blue to white and back. No style asks for the rainbow: // it is a developer's standard, and nobody else's. inline ItemAnimation item_animation(std::optional mark, const multiplayer::MarkStyle &style) noexcept { using multiplayer::IdentityList; @@ -147,7 +149,8 @@ inline ItemAnimation item_animation(std::optional mar // next to it: a deep shade, a bright one, then a reddish pink or a yellow. constexpr std::array red{{{.2f, .004f, .01f}, {.8f, .03f, .03f}, {.8f, .05f, .13f}}}; constexpr std::array gold{{{.34f, .12f, .006f}, {.8f, .5f, .06f}, {.8f, .64f, .11f}}}; - out.stops = *mark == IdentityList::content_creator ? red : gold; + constexpr std::array blue{{{.01f, .16f, .8f}, {.3f, .5f, .8f}, {.8f, .8f, .8f}}}; + out.stops = *mark == IdentityList::content_creator ? red : *mark == IdentityList::centrix ? blue : gold; } return out; } diff --git a/Extension/Multiplayer/Hud/custom_nametags.h b/Extension/Multiplayer/Hud/custom_nametags.h index c259337b..9850af35 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.h +++ b/Extension/Multiplayer/Hud/custom_nametags.h @@ -33,13 +33,16 @@ struct NametagPlayer { inline constexpr std::uint32_t nametag_white = 0xffffffffU, nametag_developer = 0xffff78b4U, // purple nametag_creator = 0xff6464ffU, // red nametag_homie = 0xff7ae2ffU, // gold + nametag_centrix = 0xffff7b1fU, // blue (#1F7BFF) nametag_admin = 0xffc674ffU, // pink nametag_host = 0xffffc85eU, // blue nametag_friend = 0xff8ae07bU; // green -// Developers, content creators and homies shimmer from these to their role colour. +// Developers, 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_homie_start = 0xff008ae0U, // #E08A00 + nametag_centrix_start = 0xffffffffU; // white // 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. diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index fe157401..00ea1b7c 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -363,7 +363,7 @@ void refresh_friends(Session &s); 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 mark_role(IdentityList list); -// A player's role colour and badge ("Dev", "Creator", "Homie", "Admin", "Host", "Friend" or none), shown in chat +// A player's role colour and badge ("Dev", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend" or none), shown in chat // and on their nametag. `local`: the local player. std::pair 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. diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index ce5b422e..f6579ab2 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -176,7 +176,8 @@ void publish(Session &s, const NativeFrame *local) { 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" : "GOLD"; + *mark == IdentityList::developer ? "RAINBOW" : *mark == IdentityList::content_creator ? "RED" : + *mark == IdentityList::centrix ? "BLUE" : "GOLD"; const auto styles = developer_hoodie_detail::own_styles.load(); const auto standard = developer_hoodie_detail::standard_picks(*mark); view.identity_styles.resize(styles.size()); @@ -538,6 +539,7 @@ std::pair 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"}; default: return {nametag_homie, "Homie"}; } } diff --git a/Extension/Multiplayer/Steam/steam_server_browser.cpp b/Extension/Multiplayer/Steam/steam_server_browser.cpp index 703bfc6b..03017f0f 100644 --- a/Extension/Multiplayer/Steam/steam_server_browser.cpp +++ b/Extension/Multiplayer/Steam/steam_server_browser.cpp @@ -206,10 +206,11 @@ void SteamServerBrowser::read() { } // 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). + // 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 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; 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) { return std::find(other.second.addresses.begin(), other.second.addresses.end(), a) != other.second.addresses.end(); diff --git a/Extension/Multiplayer/Test/developer_identity_tests.cpp b/Extension/Multiplayer/Test/developer_identity_tests.cpp index 865fb67e..6ef4fd31 100644 --- a/Extension/Multiplayer/Test/developer_identity_tests.cpp +++ b/Extension/Multiplayer/Test/developer_identity_tests.cpp @@ -33,12 +33,12 @@ void parsing() { 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[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[3] == std::vector{dev, stranger}, "The ban list was not read"); + check(bans[0] == std::vector{dev} && bans[static_cast(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"]}})"); @@ -72,7 +72,7 @@ void lookup() { "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) && + 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"); @@ -82,6 +82,16 @@ void lookup() { 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(L::centrix)] == std::vector{stranger} && centrix[static_cast(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"); + 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); diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 7f84ae8c..7f37c780 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -677,6 +677,12 @@ void role_checks() { "A developer on every list is not shown as a developer"); check(player_role(host, id(third), false) == Role{nametag_creator, "Creator"}, "A content creator who is also a homie is not shown as a creator"); + // A special tag comes before every lobby role: a Centrix player who hosts is Centrix, not Host. + simulated_identities.insert({id(host), L::centrix}); + check(player_role(first, id(host), false) == Role{nametag_centrix, "Centrix"} && + player_role(host, id(host), true) == Role{nametag_centrix, "Centrix"}, + "A Centrix player who hosts is not shown as Centrix"); + simulated_identities.erase({id(host), L::centrix}); // A player who has turned their tag off (the Special page) is whatever they are in the lobby, // to everyone: their appearance carries the choice through the host. Their items are a // separate choice, which leaves the tag alone. diff --git a/Extension/Multiplayer/developer_identity.cpp b/Extension/Multiplayer/developer_identity.cpp index 83a7bca1..8b2d9fbc 100644 --- a/Extension/Multiplayer/developer_identity.cpp +++ b/Extension/Multiplayer/developer_identity.cpp @@ -11,7 +11,7 @@ namespace dingosdk::multiplayer { namespace { // The API's name for each category, in IdentityList order; the ban list follows them. -constexpr std::array categories{"dev", "homie", "content_creator"}; +constexpr std::array categories{"dev", "homie", "content_creator", "centrix"}; constexpr auto banned = static_cast(IdentityList::banned); std::atomic> current; diff --git a/Extension/Multiplayer/developer_identity.h b/Extension/Multiplayer/developer_identity.h index e03ceb87..03290ba2 100644 --- a/Extension/Multiplayer/developer_identity.h +++ b/Extension/Multiplayer/developer_identity.h @@ -13,7 +13,7 @@ namespace dingosdk::multiplayer { // api.reskate.dev, so they change without a release. Steam authenticates these // identities. Each category has its tag in chat and on nametags (player_role) // and its animation on the marked hoodie and board (developer_hoodie_material.h). -enum class IdentityList : std::uint8_t { developer, homie, content_creator, banned, count }; +enum class IdentityList : std::uint8_t { developer, homie, content_creator, centrix, banned, count }; using IdentityLists = std::array, static_cast(IdentityList::count)>; inline constexpr std::string_view identity_lists_url = "https://api.reskate.dev/api/v1/steam-ids"; @@ -26,10 +26,10 @@ void refresh_identity_lists() noexcept; bool identity_listed(std::uint64_t id, IdentityList list) noexcept; // Shared by the developer tag and the rainbow hoodie and board. inline bool reskate_developer(std::uint64_t id) noexcept { return identity_listed(id, IdentityList::developer); } -// The category a player's tag and items come from: a developer's before a content creator's -// before a homie's. Most players are in none. +// The category a player's tag and items come from: a developer's before Centrix's before a +// content creator's before a homie's. Most players are in none. inline std::optional identity_mark(std::uint64_t id) noexcept { - for (const auto list : {IdentityList::developer, IdentityList::content_creator, IdentityList::homie}) + for (const auto list : {IdentityList::developer, IdentityList::centrix, IdentityList::content_creator, IdentityList::homie}) if (identity_listed(id, list)) return list; return std::nullopt; } diff --git a/Extension/UI/Overlay/nametag_gradient.h b/Extension/UI/Overlay/nametag_gradient.h index dc6f6caa..386f1246 100644 --- a/Extension/UI/Overlay/nametag_gradient.h +++ b/Extension/UI/Overlay/nametag_gradient.h @@ -15,6 +15,7 @@ inline constexpr std::array nametag_gradients{ NametagGradient{multiplayer::nametag_developer_start, multiplayer::nametag_developer}, NametagGradient{multiplayer::nametag_creator_start, multiplayer::nametag_creator}, NametagGradient{multiplayer::nametag_homie_start, multiplayer::nametag_homie}, + NametagGradient{multiplayer::nametag_centrix_start, multiplayer::nametag_centrix}, }; inline const NametagGradient* nametag_gradient(ImU32 colour) noexcept { for (const auto& gradient : nametag_gradients) diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 6251b849..f6484524 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -312,7 +312,7 @@ struct Nametag { std::array position{}; // above the skater's head, world space std::string name; std::uint32_t color{0xffffffffU}; // R, G, B, A bytes (IM_COL32) - std::string tag; // role badge before the name ("Dev", "Creator", "Homie", "Admin", "Host", "Friend") + std::string tag; // role badge before the name ("Dev", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend") float distance{}; // metres from the local skater bool talking{}; // Recent chat lines to show as bubbles above the head, oldest first ("" = none). diff --git a/Extension/UI/Overlay/role_badge.h b/Extension/UI/Overlay/role_badge.h index f9e89857..f92c34b4 100644 --- a/Extension/UI/Overlay/role_badge.h +++ b/Extension/UI/Overlay/role_badge.h @@ -4,7 +4,7 @@ #include #include -// The role badge ("Dev", "Creator", "Homie", "Admin", "Host", "Friend") before a player's name, in chat +// The role badge ("Dev", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend") before a player's name, in chat // and on nametags: a pill in the role's colour (animated for the roles in nametag_gradient.h) with dark text. namespace dingosdk::overlay::detail { inline float role_badge_width(ImFont *font, float size, const std::string &tag) { diff --git a/Server/README.txt b/Server/README.txt index ab01244a..610b3ccd 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -38,7 +38,7 @@ straight away (players are told to rejoin). Turn this off with ReSkateServer.json ------------------ -name Shown in the browser (1-64 characters). +name Shown in the browser: 1-64 letters, numbers, spaces and - _ [ ] ( ). map The map everyone skates, named like the game's load command: "San Vansterdam", "Isle of Grom", "Super Ultra Mega Resort", "Stadium 1", or a custom map such as "bbcity" (see Custom maps). diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 80e525f0..24633dbf 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -23,6 +23,7 @@ std::string text(const std::filesystem::path &file) { int run() { using namespace dingosdk::server; + using dingosdk::valid_server_name; const auto folder = std::filesystem::temp_directory_path() / "reskate_server_config_tests"; std::filesystem::remove_all(folder); std::filesystem::create_directories(folder); @@ -47,6 +48,15 @@ int run() { const auto reloaded = load_config(file); check(reloaded.name == "Old Server" && !reloaded.boosts && reloaded.votes.map.percent == 60, "Values lost on rewrite"); + // Server names: letters, digits, spaces and - _ [ ] ( ) only. + check(valid_server_name("Old Server") && valid_server_name("[EU] Skate_Park-2 (24x7)") && valid_server_name("a"), + "A plain server name was refused"); + check(!valid_server_name("") && !valid_server_name(std::string(65, 'a')) && !valid_server_name("Best! Server") && + !valid_server_name("caf\xC3\xA9") && !valid_server_name("a.b") && !valid_server_name("x") && + !valid_server_name(" padded") && !valid_server_name("padded ") && !valid_server_name("[]--()") && + !valid_server_name("two\nlines"), + "A server name with other characters was accepted"); + // An up-to-date file is left alone. const auto before = std::filesystem::last_write_time(file); added.clear(); diff --git a/Server/server_config.cpp b/Server/server_config.cpp index ab2f1840..6c9e4cba 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -230,7 +230,7 @@ std::optional parse_scoring(std::string_view text) { } std::string config_error(const ServerConfig &c) { using namespace multiplayer; - if (c.name.empty() || c.name.size() > 64 || !valid_member_name(c.name)) return "name must be 1 to 64 characters."; + if (!valid_server_name(c.name)) return std::string("name must be ") + server_name_rule + "."; if (c.map.empty() || !valid_map_destination(map_destination(c.map))) return "map \"" + c.map + "\" is not a known map. Use a name like \"San Vansterdam\", or put the map's mod " "folder in Mods next to the server."; diff --git a/Server/server_host.cpp b/Server/server_host.cpp index ddf72022..a0ce3d2b 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -1017,7 +1017,7 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { return changed(rotation_text()); } if (name == "name") { - if (argument.empty() || argument.size() > 64 || !valid_member_name(argument)) return "Server names are 1 to 64 characters."; + if (!valid_server_name(argument)) return std::string("Server names are ") + server_name_rule + "."; config_.name = argument; if (text::contains_bad_words(config_.name)) return changed("Server renamed to " + config_.name + diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 7c77a21c..a7ee95df 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -60,6 +60,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_link_libraries(dingosdk_mod_merge_unshift_tests PRIVATE dingosdk_mods) target_include_directories(dingosdk_mod_merge_unshift_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME mod_merge_unshift COMMAND dingosdk_mod_merge_unshift_tests) + add_executable(dingosdk_chunk_metadata_merge_tests Engine/Vfs/Test/chunk_metadata_merge_tests.cpp) + target_link_libraries(dingosdk_chunk_metadata_merge_tests PRIVATE dingosdk_mods) + target_include_directories(dingosdk_chunk_metadata_merge_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME chunk_metadata_merge COMMAND dingosdk_chunk_metadata_merge_tests) add_executable(dingosdk_mod_merge_added_assets_tests Engine/Vfs/Test/mod_merge_added_assets_tests.cpp) target_link_libraries(dingosdk_mod_merge_added_assets_tests PRIVATE dingosdk_mods) target_include_directories(dingosdk_mod_merge_added_assets_tests PRIVATE "${PROJECT_SOURCE_DIR}") From e0b9367e71c45c30cf0f4190d8d5dc39f83f6f62 Mon Sep 17 00:00:00 2001 From: DeckardDetribine <116809774+DeckardDetribine@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:47:06 +0100 Subject: [PATCH 073/109] fix(music): reload artwork beyond the cache budget --- Extension/Music/Test/music_artwork_tests.cpp | 61 ++++++++++++++++++ Extension/Music/music_artwork.cpp | 68 ++++++++++++++++---- Extension/Music/music_artwork.h | 3 +- 3 files changed, 120 insertions(+), 12 deletions(-) diff --git a/Extension/Music/Test/music_artwork_tests.cpp b/Extension/Music/Test/music_artwork_tests.cpp index 62fabf39..547c54f5 100644 --- a/Extension/Music/Test/music_artwork_tests.cpp +++ b/Extension/Music/Test/music_artwork_tests.cpp @@ -3,6 +3,7 @@ #include #include "Extension/Music/music_artwork.h" #include +#include #include #include #include @@ -136,6 +137,66 @@ int main() try { fs::create_directory_symlink(root, root / L"mod" / L"escape", ec); if (!ec) check(server.add(root / L"mod", "escape/outside.png").empty(), "symlink escape is refused"); else std::cout << "SKIP: symlink test needs Windows symlink privileges\n"; + // Cross the production 64 MiB cache budget. Padding preserves the PNG header; + // these fixtures exercise byte storage/transport, not an image decoder. + std::string large = bytes; + large.resize(1024 * 1024, '\0'); + std::string first, last; + for (unsigned i = 0; i < 65; ++i) { + const auto relative = "artwork/large-" + std::to_string(i) + ".png"; + { std::ofstream out(root / L"mod" / relative, std::ios::binary); out.write(large.data(), large.size()); } + const auto registered = server.add(root / L"mod", relative); + check(!registered.empty(), "artwork beyond 64 MiB still registers"); + if (i == 0) first = registered; + if (i == 64) last = registered; + } + check(server.add(root / L"mod", "artwork/large-0.png") == first, "evicted artwork keeps its registered URL"); + if (!first.empty()) { + const auto [status, body] = fetch(first); + check(status == 200 && body == large, "evicted artwork reloads with exact bytes"); + } + if (!last.empty()) { + const auto [status, body] = fetch(last); + check(status == 200 && body == large, "artwork registered beyond the budget is served"); + } + if (!url.empty()) { + const auto colon = url.find(':', 7), slash = url.find('/', colon); + const auto port = static_cast(std::stoi(url.substr(colon + 1, slash - colon - 1))); + const auto [status, body] = fetch_h2c(port, url.substr(slash)); + check(status == 200 && body == bytes, "h2c reloads evicted artwork with exact bytes"); + } + // Replacing one file must not consume another image's worth of budget forever. + const auto replacement = root / L"mod/artwork/replacement.png"; + std::string replacement_url; + const auto stamp = fs::file_time_type::clock::now(); + for (unsigned i = 0; i < 65; ++i) { + large.back() = static_cast(i); + { std::ofstream out(replacement, std::ios::binary); out.write(large.data(), large.size()); } + fs::last_write_time(replacement, stamp + std::chrono::seconds(i)); + const auto registered = server.add(root / L"mod", "artwork/replacement.png"); + check(!registered.empty(), "replacing artwork does not exhaust the cache budget"); + if (i == 0) replacement_url = registered; + else check(registered == replacement_url, "replacement retains its stable URL"); + } + if (!replacement_url.empty()) { + const auto [status, body] = fetch(replacement_url); + check(status == 200 && body == large, "replacement serves the latest registered bytes"); + } + // large-1 was evicted; a reload must validate the registered file again. + const auto invalid_url = server.add(root / L"mod", "artwork/large-1.png"); + { std::ofstream out(root / L"mod/artwork/large-1.png", std::ios::binary); out << "not an image"; } + if (!invalid_url.empty()) check(fetch(invalid_url).first == 404, "evicted artwork refuses invalid replacement bytes"); + const auto removed_url = server.add(root / L"mod", "artwork/large-2.png"); + fs::remove(root / L"mod/artwork/large-2.png"); + if (!removed_url.empty()) check(fetch(removed_url).first == 404, "missing evicted artwork returns 404"); + ec.clear(); + fs::create_symlink(root / L"outside.png", root / L"mod/artwork/large-2.png", ec); + if (!ec && !removed_url.empty()) check(fetch(removed_url).first == 404, "evicted artwork refuses a new symlink target"); + else if (ec) std::cout << "SKIP: reload symlink test needs Windows symlink privileges\n"; + std::string oversized = bytes; + oversized.resize(4 * 1024 * 1024 + 1, '\0'); + { std::ofstream out(root / L"mod/artwork/oversized.png", std::ios::binary); out.write(oversized.data(), oversized.size()); } + check(server.add(root / L"mod", "artwork/oversized.png").empty(), "individual images remain limited to 4 MiB"); if (!failures) std::cout << "music artwork tests passed\n"; return failures ? 1 : 0; } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } diff --git a/Extension/Music/music_artwork.cpp b/Extension/Music/music_artwork.cpp index 9fba0ae6..14c5fbfa 100644 --- a/Extension/Music/music_artwork.cpp +++ b/Extension/Music/music_artwork.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -25,7 +26,7 @@ namespace dingosdk::profile_runtime { namespace { -constexpr std::size_t max_image = 4 * 1024 * 1024, max_total = 64 * 1024 * 1024; +constexpr std::size_t max_image = 4 * 1024 * 1024, max_cached = 64 * 1024 * 1024; constexpr unsigned short fixed_port = 47823; // stable origin so the game's HTTP cache is reused constexpr std::size_t max_frame = 16384; @@ -271,13 +272,19 @@ std::string mod_folder_name(const std::filesystem::path& mod) { } // namespace struct MusicArtworkServer::Impl { + struct Image { + std::filesystem::path file; + std::shared_ptr> bytes; + std::list::iterator position; + }; bool winsock = false; Socket listener; unsigned short port = 0; std::mutex mutex; - std::map>, std::less<>> images; // route -> bytes + std::map> images; // registered routes survive byte-cache eviction std::map> paths; - std::size_t total = 0; + std::list cached; // most recently used first + std::size_t cached_bytes = 0; // active responses can retain their own shared bytes std::vector workers; Impl() { @@ -347,10 +354,51 @@ struct MusicArtworkServer::Impl { else respond(client); } + static std::shared_ptr> read_png(const std::filesystem::path& file) { + // A registered canonical file must not become a symlink to an unregistered file. + if (std::filesystem::canonical(file) != file) return {}; + const auto size = std::filesystem::file_size(file); + if (size > max_image) return {}; + std::ifstream input(file, std::ios::binary); + auto bytes = std::make_shared>(static_cast(size)); + if (!input.read(bytes->data(), static_cast(size)) || !png_ok(*bytes)) return {}; + return bytes; + } + + // Called with mutex held. Keep the byte cache bounded without invalidating any URLs. + void cache(const std::string& route, Image& image, std::shared_ptr> bytes) { + if (image.bytes) { + cached_bytes -= image.bytes->size(); + cached.erase(image.position); + image.bytes.reset(); + } + while (cached_bytes + bytes->size() > max_cached && !cached.empty()) { + auto& oldest = images.at(cached.back()); + cached_bytes -= oldest.bytes->size(); + oldest.bytes.reset(); + cached.pop_back(); + } + cached.push_front(route); + image.position = cached.begin(); + image.bytes = std::move(bytes); + cached_bytes += image.bytes->size(); + } + std::shared_ptr> find(std::string_view route) { std::lock_guard lock(mutex); const auto found = images.find(route); - return found == images.end() ? nullptr : found->second; + if (found == images.end()) return {}; + auto& image = found->second; + if (image.bytes) { + cached.splice(cached.begin(), cached, image.position); + } else { + try { + auto bytes = read_png(image.file); + if (!bytes) return {}; + cache(found->first, image, std::move(bytes)); + } catch (const std::exception&) { return {}; } + } + return image.bytes; } // Graceful close: closing with unread frames queued makes Windows send RST and the client can @@ -492,17 +540,15 @@ std::string MusicArtworkServer::add(const std::filesystem::path& mod, const std: std::lock_guard lock(impl_->mutex); if (const auto found = impl_->paths.find(file); found != impl_->paths.end() && found->second.first == stamp) return found->second.second; - const auto size = std::filesystem::file_size(file); - if (size > max_image || impl_->total + size > max_total) return {}; - std::ifstream input(file, std::ios::binary); - auto bytes = std::make_shared>(static_cast(size)); - if (!input.read(bytes->data(), static_cast(size)) || !png_ok(*bytes)) return {}; + auto bytes = Impl::read_png(file); + if (!bytes) return {}; // A stable, readable route so the URL is identical every launch (and the game's HTTP cache // can be reused) and the image identity is in the path, like the game's own CDN links. const auto route = "/music-art/" + sanitize(mod_folder_name(mod)) + "/" + relative; const auto url = "http://127.0.0.1:" + std::to_string(impl_->port) + route; - impl_->images[route] = bytes; - impl_->total += bytes->size(); + auto& image = impl_->images[route]; + image.file = file; + impl_->cache(route, image, std::move(bytes)); impl_->paths[file] = {stamp, url}; return url; } catch (const std::exception&) { return {}; } diff --git a/Extension/Music/music_artwork.h b/Extension/Music/music_artwork.h index c3e9d442..6eae2bfb 100644 --- a/Extension/Music/music_artwork.h +++ b/Extension/Music/music_artwork.h @@ -4,7 +4,8 @@ #include namespace dingosdk::profile_runtime { -// Serves only registered PNG bytes on an ephemeral loopback port. No filesystem HTTP routes. +// Serves registered mod PNGs on loopback. The 64 MiB byte cache reloads evicted +// images from their registered canonical files; HTTP paths never name arbitrary files. class MusicArtworkServer { public: MusicArtworkServer(); From c646906b0b0fdbfa6cf69564a319e17c15272290 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Tue, 6 Oct 2026 13:08:15 -0600 Subject: [PATCH 074/109] Find the injected DLL in a game folder outside the ANSI code page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toolhelp returns module paths through the ANSI code page, so a folder like "Skate Тест" came back with '?' in it, the injected DLL was never matched, and the launcher killed the game. The loader's own list keeps the real path. --- Launcher/launch.cpp | 48 +++++++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/Launcher/launch.cpp b/Launcher/launch.cpp index 7058557c..9fdffbc1 100644 --- a/Launcher/launch.cpp +++ b/Launcher/launch.cpp @@ -9,7 +9,7 @@ #include "Engine/Game/Build/20260929/runtime.h" #include -#include +#include #include #include #include @@ -366,27 +366,33 @@ struct RemoteModule { fs::path path; }; -std::vector remote_modules(DWORD process_id) { - Handle snapshot; +std::vector remote_modules(HANDLE process) { // not Toolhelp: it turns non-ANSI path characters into '?' + std::vector handles(512); for (unsigned attempt = 0; attempt < 8; ++attempt) { - snapshot.reset(CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, process_id)); - if (snapshot || GetLastError() != ERROR_BAD_LENGTH) break; + const auto capacity = static_cast(handles.size() * sizeof(HMODULE)); + DWORD needed{}; + if (!EnumProcessModulesEx(process, handles.data(), capacity, &needed, LIST_MODULES_DEFAULT)) { + if (GetLastError() == ERROR_PARTIAL_COPY) continue; // the list changed while it was read + win32_failure(L"EnumProcessModulesEx"); + } + if (needed > capacity) { handles.resize(needed / sizeof(HMODULE) + 16); continue; } + handles.resize(needed / sizeof(HMODULE)); + std::vector output; + std::vector path(32768); + for (const auto handle : handles) { + MODULEINFO info{}; + const auto length = GetModuleFileNameExW(process, handle, path.data(), static_cast(path.size())); + if (!length || length >= path.size() || !GetModuleInformation(process, handle, &info, sizeof(info))) continue; + output.push_back({reinterpret_cast(info.lpBaseOfDll), info.SizeOfImage, + fs::path(std::wstring(path.data(), length))}); + } + return output; } - if (!snapshot) win32_failure(L"CreateToolhelp32Snapshot"); - MODULEENTRY32W entry{sizeof(entry)}; - if (!Module32FirstW(snapshot.get(), &entry)) win32_failure(L"Module32FirstW"); - std::vector output; - do { - output.push_back({reinterpret_cast(entry.modBaseAddr), entry.modBaseSize, - fs::path(entry.szExePath)}); - entry.dwSize = sizeof(entry); - } while (Module32NextW(snapshot.get(), &entry)); - if (GetLastError() != ERROR_NO_MORE_FILES) win32_failure(L"Module32NextW"); - return output; + throw std::runtime_error("The child module list kept changing while it was read"); } -RemoteModule find_remote_module(DWORD process_id, const fs::path& path) { - for (const auto& module : remote_modules(process_id)) +RemoteModule find_remote_module(HANDLE process, const fs::path& path) { + for (const auto& module : remote_modules(process)) if (same_file(path, module.path)) return module; throw std::runtime_error("Injected ReSkate.dll is not present in the child module list"); } @@ -416,7 +422,7 @@ DWORD run_remote_thread(HANDLE process, std::uintptr_t start, void* parameter, return result; } -RemoteModule inject_dll(HANDLE process, DWORD process_id, const fs::path& dll) { +RemoteModule inject_dll(HANDLE process, const fs::path& dll) { const auto path = dll.wstring(); const auto byte_count = (path.size() + 1) * sizeof(wchar_t); RemoteAllocation remote_path(process, byte_count); @@ -433,7 +439,7 @@ RemoteModule inject_dll(HANDLE process, DWORD process_id, const fs::path& dll) { const auto loader_result = run_remote_thread(process, loader, remote_path.get(), L"LoadLibraryW"); // A thread exit code is only 32 bits and cannot carry an x64 HMODULE. Confirm // success and obtain the full module base from the target's module list. - const auto module = find_remote_module(process_id, dll); + const auto module = find_remote_module(process, dll); std::wostringstream loaded; loaded << L"ReSkate.dll loaded at 0x" << std::hex << module.base << L" (LoadLibraryW low result 0x" << loader_result << L')'; @@ -734,7 +740,7 @@ DWORD start_game(const Session& session, const launcher::LaunchOptions& options, loader_ready << L", skipped " << exiting << L" already exiting"; logging::write(logging::Level::info, logging::Channel::launcher, loader_ready.str()); - const auto remote_dll = inject_dll(child.process(), child.id(), paths.dll); + const auto remote_dll = inject_dll(child.process(), paths.dll); const auto initialize_address = remote_dll.base + initialize_rva; if (initialize_address < remote_dll.base || initialize_rva >= remote_dll.size) throw std::runtime_error("Remote DingoSDKDebugInitialize address is invalid"); From 7a37f6efc5987baa1f3e4651d033efa95fa4e460 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Mon, 5 Oct 2026 16:56:46 -0600 Subject: [PATCH 075/109] Keep listing saved parks when the parks folder holds an oddly named file --- Extension/Objects/ParkEditor/Test/park_editor_tests.cpp | 3 +++ Extension/Objects/ParkEditor/park_document.cpp | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Extension/Objects/ParkEditor/Test/park_editor_tests.cpp b/Extension/Objects/ParkEditor/Test/park_editor_tests.cpp index 1bc5893f..2f3e1ae6 100644 --- a/Extension/Objects/ParkEditor/Test/park_editor_tests.cpp +++ b/Extension/Objects/ParkEditor/Test/park_editor_tests.cpp @@ -275,8 +275,11 @@ int main(int argc, char **argv) { bad.objects[0].rotation = {}; rejects([&] { save_park(directory, name, bad); }); check(load_park(directory, name).objects == park.objects, "Rejected save overwrote previous park"); + const auto stray = directory / L"\U0001F6F9 notes.txt"; // No ANSI code page can hold this name. + std::ofstream(stray).put('x'); const auto names = list_parks(directory); check(std::find(names.begin(), names.end(), name) != names.end(), "Saved park missing from browser"); + std::filesystem::remove(stray); std::filesystem::remove(directory / (name + ".park.json")); // Park mods: /Mods//manifest.json + parks/.park.json. { diff --git a/Extension/Objects/ParkEditor/park_document.cpp b/Extension/Objects/ParkEditor/park_document.cpp index 27be27fe..e1c9c820 100644 --- a/Extension/Objects/ParkEditor/park_document.cpp +++ b/Extension/Objects/ParkEditor/park_document.cpp @@ -1,4 +1,5 @@ #include "park_document.h" +#include "Engine/Vfs/mod_list.h" #include #include #include @@ -80,7 +81,7 @@ std::vector list_parks(const std::filesystem::path &directory) { !error && it != end && names.size() < 256; it.increment(error)) { if (!it->is_regular_file(error)) continue; - auto name = it->path().filename().string(); + auto name = mods::ascii_path(it->path().filename()); if (!name.ends_with(".park.json")) continue; name.resize(name.size() - 10); From 87824ab811e8a47c5b109423568426916228a033 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Sun, 4 Oct 2026 20:38:13 -0600 Subject: [PATCH 076/109] Keep a non-ASCII data path from stopping the game at startup Startup logged the mods folder, the merged patch and each map manifest with path.string(), which throws outside the code page and fails runtime init. --- Extension/Assets/mod_layers.cpp | 5 +++-- Runtime/runtime_state.cpp | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Extension/Assets/mod_layers.cpp b/Extension/Assets/mod_layers.cpp index 840d7be2..fa33671c 100644 --- a/Extension/Assets/mod_layers.cpp +++ b/Extension/Assets/mod_layers.cpp @@ -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()}; } diff --git a/Runtime/runtime_state.cpp b/Runtime/runtime_state.cpp index 1edfc3e5..6c2c1979 100644 --- a/Runtime/runtime_state.cpp +++ b/Runtime/runtime_state.cpp @@ -1,5 +1,6 @@ #include "runtime_internal.h" #include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/path_text.h" #include "Extension/Progression/fixed_stop_entitlement_provider.h" #include "Extension/Progression/mission_progression_override.h" #include "Extension/Progression/neighborhood_unlock_override.h" @@ -23,10 +24,10 @@ dingosdk::CustomLevelManifest read_custom_levels(const std::vector Date: Tue, 6 Oct 2026 13:23:26 -0600 Subject: [PATCH 077/109] Print the log path from a non-ASCII game folder --- Extension/Console/commands.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Extension/Console/commands.cpp b/Extension/Console/commands.cpp index 278cdc10..3a599b60 100644 --- a/Extension/Console/commands.cpp +++ b/Extension/Console/commands.cpp @@ -1,5 +1,6 @@ #include "commands.h" #include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/path_text.h" #include namespace dingosdk::console { @@ -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"); From 2d3430cbd4aadd1ff6504b08f97ccd15cebdb4d8 Mon Sep 17 00:00:00 2001 From: xThrasherrr Date: Tue, 6 Oct 2026 13:23:26 -0600 Subject: [PATCH 078/109] Write paths as UTF-8 in the remaining messages Each built its text with path::string(), which throws for a character outside the ANSI code page, so the real error or message was replaced by a code-page error. --- Engine/Resource/cas_codec.cpp | 3 ++- Extension/Assets/live_mods.cpp | 3 ++- Extension/Objects/ParkEditor/park_mods.cpp | 11 ++++++----- Extension/Trainer/trainer.cpp | 3 ++- Extension/UI/NativeMenu/native_menu_dump.cpp | 5 +++-- Extension/UI/Overlay/perf_overlay.cpp | 3 ++- 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/Engine/Resource/cas_codec.cpp b/Engine/Resource/cas_codec.cpp index 8249df8e..9149e1f0 100644 --- a/Engine/Resource/cas_codec.cpp +++ b/Engine/Resource/cas_codec.cpp @@ -1,4 +1,5 @@ #include "cas_codec.h" +#include "Engine/Core/Platform/path_text.h" #ifdef _WIN32 #include @@ -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; } diff --git a/Extension/Assets/live_mods.cpp b/Extension/Assets/live_mods.cpp index 33accd73..f58dfcd3 100644 --- a/Extension/Assets/live_mods.cpp +++ b/Extension/Assets/live_mods.cpp @@ -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 bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); 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(GetModuleHandleW(nullptr)); diff --git a/Extension/Objects/ParkEditor/park_mods.cpp b/Extension/Objects/ParkEditor/park_mods.cpp index caa5adb3..a8207dcf 100644 --- a/Extension/Objects/ParkEditor/park_mods.cpp +++ b/Extension/Objects/ParkEditor/park_mods.cpp @@ -2,6 +2,7 @@ #include "Engine/Vfs/mod_list.h" #include "Engine/Core/Json/json.h" +#include "Engine/Core/Platform/path_text.h" #include @@ -46,23 +47,23 @@ void write_atomically(const fs::path &path, const std::string &text) { stream.write(text.data(), static_cast(text.size())); stream.flush(); if (!stream) - throw std::runtime_error("Could not write " + path.filename().string() + ". The previous copy was kept."); + throw std::runtime_error("Could not write " + path_utf8(path.filename()) + ". The previous copy was kept."); } if (!MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) - throw std::runtime_error("Could not replace " + path.filename().string() + ". The previous copy was kept."); + throw std::runtime_error("Could not replace " + path_utf8(path.filename()) + ". The previous copy was kept."); } std::string read_file(const fs::path &path, std::size_t limit) { std::error_code error; const auto size = fs::file_size(path, error); if (error) - throw std::runtime_error("Could not open " + path.filename().string() + "."); + throw std::runtime_error("Could not open " + path_utf8(path.filename()) + "."); if (size > limit) - throw std::runtime_error(path.filename().string() + " is too large."); + throw std::runtime_error(path_utf8(path.filename()) + " is too large."); std::ifstream stream(path, std::ios::binary); std::string text(static_cast(size), '\0'); if (!stream.read(text.data(), static_cast(text.size()))) - throw std::runtime_error("Could not read " + path.filename().string() + "."); + throw std::runtime_error("Could not read " + path_utf8(path.filename()) + "."); return text; } diff --git a/Extension/Trainer/trainer.cpp b/Extension/Trainer/trainer.cpp index 26c2487c..c67aabde 100644 --- a/Extension/Trainer/trainer.cpp +++ b/Extension/Trainer/trainer.cpp @@ -5,6 +5,7 @@ #include "trainer_session.h" #include "Engine/Core/Json/json.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/no_bail.h" #include "Engine/Game/Build/20260929/physics_tuning.h" @@ -1706,7 +1707,7 @@ std::string run(std::string_view verb, const std::vector &a) { file << std::format("{}\t0x{:x}\t{}\t{:.6g}\t{:.6g}\n", e.id, e.offset, e.kind == Kind::real ? "real" : e.kind == Kind::flag ? "flag" : e.kind == Kind::curve ? "curve" : e.kind == Kind::graph ? "graph" : "int", e.stock, e.value); - return std::format("Wrote {} values to {}.", s.entries.size(), (directory / L"tuning-values.txt").string()); + return std::format("Wrote {} values to {}.", s.entries.size(), path_utf8(directory / L"tuning-values.txt")); } if (v == "selftest") { if (s.test.step) return "The self test is already running."; diff --git a/Extension/UI/NativeMenu/native_menu_dump.cpp b/Extension/UI/NativeMenu/native_menu_dump.cpp index 18d59451..de3d0b94 100644 --- a/Extension/UI/NativeMenu/native_menu_dump.cpp +++ b/Extension/UI/NativeMenu/native_menu_dump.cpp @@ -1,6 +1,7 @@ #include "native_menu_dump.h" #include "native_menu_data.h" #include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/path_text.h" #include "Engine/Game/Abi/native_data.h" #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/engine.h" @@ -225,7 +226,7 @@ void run_pending_ui_dump(std::uintptr_t base) noexcept { if (text.empty()) text = "asset " + name + " is not loaded in any domain\n"; const auto path = dump_path("asset", GetTickCount64()); std::ofstream(path, std::ios::trunc) << text; - logging::log(logging::Level::info, logging::Channel::ui, "UI dump: asset {} written to {}.", name, path.string()); + logging::log(logging::Level::info, logging::Channel::ui, "UI dump: asset {} written to {}.", name, path_utf8(path)); return; } std::uint32_t only_schema{}; @@ -255,7 +256,7 @@ void run_pending_ui_dump(std::uintptr_t base) noexcept { out << "roots " << roots << ", written " << written << ", filter " << (what.empty() ? "all" : what) << "\n" << text; logging::log(logging::Level::info, logging::Channel::ui, - "UI dump: {} of {} roots written to {}.", written, roots, path.string()); + "UI dump: {} of {} roots written to {}.", written, roots, path_utf8(path)); } catch (const std::exception& error) { logging::log(logging::Level::warning, logging::Channel::ui, "UI dump failed: {}.", error.what()); } diff --git a/Extension/UI/Overlay/perf_overlay.cpp b/Extension/UI/Overlay/perf_overlay.cpp index 6ff2934e..57ca6806 100644 --- a/Extension/UI/Overlay/perf_overlay.cpp +++ b/Extension/UI/Overlay/perf_overlay.cpp @@ -1,6 +1,7 @@ #include "overlay_internal.h" #include "skate_style.h" #include "Engine/Core/Profiling/profiler.h" +#include "Engine/Core/Platform/path_text.h" #include #include @@ -251,7 +252,7 @@ void draw_perf_window() { for (const auto& thread : report->threads) ImGui::TextDisabled(" %5.1f%% thread %u %s", thread.cpu_percent, thread.id, thread.label.c_str()); if (!report->error.empty()) ImGui::TextColored(ImColor(skate::danger), "%s", report->error.c_str()); - const auto report_path = report->report.string(), folded_path = report->folded.string(); + const auto report_path = path_utf8(report->report), folded_path = path_utf8(report->folded); ImGui::TextDisabled("Report: %s", report_path.c_str()); ImGui::SameLine(); if (ImGui::SmallButton("Copy##report")) ImGui::SetClipboardText(report_path.c_str()); From 14beb88b085719b0d9d804bd9cf1f1172c09486a Mon Sep 17 00:00:00 2001 From: Vex Date: Tue, 6 Oct 2026 22:24:59 -0300 Subject: [PATCH 079/109] Document running the game on Linux through Proton Steam starts EA's anti-cheat launcher, which refuses Proton; a launch option swaps it for ReSkate's launcher. Tested with 1.1.3 on Proton Experimental (launcher, server browser, joining a public server, hosting a lobby). Co-Authored-By: Claude Opus 5.5 --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 8d4e46c0..b0e7ab94 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,30 @@ the launcher, the runtime that loads into the game, and the dedicated server. 4. Press **PLAY**. ReSkate supports one game build at a time (Steam build `25414733`). + +### Linux (Proton) + +1. Install skate. with Steam and put `ReSkateLauncher.exe` and `ReSkate.dll` beside `Skate.exe` + (step 2 above, first option). +2. In Steam → skate. → Properties → Launch Options, enter: + + ``` + bash -c 'exec "${@/EAAntiCheat.GameServiceLauncher.exe/ReSkateLauncher.exe}"' -- %command% + ``` + +3. Press **Play** on skate. in your Steam library. + +Steam starts EA's anti-cheat launcher (`EAAntiCheat.GameServiceLauncher.exe`), not `Skate.exe`, and the +anti-cheat refuses to run under Proton (*E111000B … Wine, Proton, and Steam Deck are not supported*). +The launch option swaps it for ReSkate's launcher, which starts the game itself. Launching from the game's +own Steam entry, rather than adding the launcher as a non-Steam game, keeps skate.'s app ID and its +Proton prefix. + +Tested with ReSkate 1.1.3 and Proton Experimental on CachyOS: the launcher, the server browser, joining +a public server and hosting a lobby. Steam Deck should work the same way but is untested. If you load a +custom map and then join a server on another map, the screen can stay black +([#31](https://github.com/Dingo-Shenanigans/ReSkate/issues/31)): load the server's map first. + ### Controls | Key | Opens | From 14fd2c97c37ae08e41a40f644fe1c6027ee36608 Mon Sep 17 00:00:00 2001 From: ComputerKWasTaken Date: Tue, 6 Oct 2026 20:40:03 -0500 Subject: [PATCH 080/109] Fix Forward Boost direction while riding Fakie --- CMakeLists.txt | 2 + .../Game/Skater/Test/velocity_boost_tests.cpp | 57 +++++++++++++++++++ Engine/Game/Skater/velocity_boost.h | 13 ++++- Extension/Skater/client_debug.cpp | 4 +- 4 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 Engine/Game/Skater/Test/velocity_boost_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 9eb4ff81..ee98477b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -105,6 +105,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}") diff --git a/Engine/Game/Skater/Test/velocity_boost_tests.cpp b/Engine/Game/Skater/Test/velocity_boost_tests.cpp new file mode 100644 index 00000000..f9aa04fc --- /dev/null +++ b/Engine/Game/Skater/Test/velocity_boost_tests.cpp @@ -0,0 +1,57 @@ +#include "Engine/Game/Skater/velocity_boost.h" +#include +#include +#include + +namespace { +void check(bool condition, const char* message) { + if (!condition) { std::cerr << message << '\n'; std::exit(1); } +} +bool near(float a, float b) { return std::abs(a - b) < .001f; } +} + +int main() { + using dingosdk::VelocityBoostDirection; + using dingosdk::velocity_boost_delta; + constexpr auto forward = VelocityBoostDirection::forward; + constexpr auto up = VelocityBoostDirection::up; + std::array transform{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; + // Rotate through world headings; changing preferred stance does not change + // this contract: facing-aligned travel boosts forward, fakie travel reverses it. + for (int heading = 0; heading < 360; heading += 15) { + const float angle = static_cast(heading) * 3.14159265f / 180.f; + transform[8] = std::sin(angle); + transform[10] = std::cos(angle); + for (const float speed : {1.f, 75.f, 300.f}) { + for (const float travel : {12.f, -12.f}) { + const std::array motion{travel * transform[8], 0, travel * transform[10]}; + const auto delta = velocity_boost_delta(transform, speed, forward, motion); + check(delta.has_value(), "Valid riding boost rejected"); + const float sign = travel < 0 ? -1.f : 1.f; + check(near((*delta)[0], sign * speed * transform[8]) && near((*delta)[2], sign * speed * transform[10]), + "Boost opposes riding direction"); + check(near(std::hypot((*delta)[0], (*delta)[2]), speed), "Configured boost magnitude changed"); + } + } + } + transform[8] = 0; transform[10] = 1; + for (const auto motion : {std::array{0, 0, 0}, {0, 0, -.05f}, {12, 0, 0}, {0, -50, 0}, {0, 50, 0}}) { + const auto delta = velocity_boost_delta(transform, 75, forward, motion); + check(delta && near((*delta)[2], 75), "Stationary, sideways or vertical motion reversed boost"); + } + // Preserve the original three-dimensional facing axis and magnitude on ramps. + transform[9] = .6f; transform[10] = .8f; + const auto ramp = velocity_boost_delta(transform, 75, forward, {0, -100, -12}); + check(ramp && near((*ramp)[1], -45) && near((*ramp)[2], -60), "Fakie ramp direction or magnitude changed"); + const auto rising = velocity_boost_delta(transform, 75, forward, {0, -100, 12}); + check(rising && near((*rising)[1], 45) && near((*rising)[2], 60), "Vertical motion overrode forward travel"); + const auto upward = velocity_boost_delta(transform, 25, up, {0, -100, -12}); + check(upward && *upward == std::array{0, 25, 0}, "Up Boost changed in fakie"); + for (const float speed : {0.f, 301.f, std::numeric_limits::quiet_NaN()}) + check(!velocity_boost_delta(transform, speed, forward), "Invalid forward speed accepted"); + check(!velocity_boost_delta(transform, 26, up), "Invalid up speed accepted"); + check(!velocity_boost_delta({}, 75, forward), "Degenerate facing accepted"); + check(!velocity_boost_delta(transform, 75, forward, {0, 0, std::numeric_limits::infinity()}), + "Invalid travel velocity accepted"); + std::cout << "Velocity boost regressions passed.\n"; +} diff --git a/Engine/Game/Skater/velocity_boost.h b/Engine/Game/Skater/velocity_boost.h index 7bb2b9b4..00edd6d6 100644 --- a/Engine/Game/Skater/velocity_boost.h +++ b/Engine/Game/Skater/velocity_boost.h @@ -7,7 +7,8 @@ namespace dingosdk { enum class VelocityBoostDirection { forward, up }; inline std::optional> velocity_boost_delta( - const std::array& transform, float speed, VelocityBoostDirection direction) noexcept { + const std::array& transform, float speed, VelocityBoostDirection direction, + const std::array& board_velocity = {}) noexcept { const float maximum = direction == VelocityBoostDirection::up ? 25.0f : 300.0f; if (!std::isfinite(speed) || speed < 1.0f || speed > maximum) return {}; if (direction == VelocityBoostDirection::up) return std::array{0, speed, 0}; @@ -17,6 +18,16 @@ inline std::optional> velocity_boost_delta( if (!std::isfinite(length) || length <= .01f) return {}; const auto scale = speed / std::sqrt(length); for (auto& component : delta) component *= scale; + // Fakie travel opposes the skater's facing axis. Use horizontal motion so + // falling/rising cannot reverse a boost, and retain facing at rest or sideways. + const float horizontal_length = std::hypot(delta[0], delta[2]); + if (horizontal_length > .01f) { + const float travel = (delta[0] * board_velocity[0] + delta[2] * board_velocity[2]) / horizontal_length; + if (!std::isfinite(travel)) return {}; + // Ignore tiny backwards drift around standstill (0.1 world units/s). + if (travel < -.1f) + for (auto& component : delta) component = -component; + } return delta; } } diff --git a/Extension/Skater/client_debug.cpp b/Extension/Skater/client_debug.cpp index db209dc6..251b9848 100644 --- a/Extension/Skater/client_debug.cpp +++ b/Extension/Skater/client_debug.cpp @@ -488,9 +488,11 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b SourceReader reader; source_require(reader.value(skater.skater_identity, 0x7e0) == 0, "Wait for the current teleport before using velocity boosts."); + const auto board_velocity = up ? std::array{} : + reader.value>(bodies.parts[0], 0x70); reader.verify(); const auto velocity = velocity_boost_delta(transform, up ? debug.up_velocity_speed : debug.forward_velocity_speed, - up ? VelocityBoostDirection::up : VelocityBoostDirection::forward); + up ? VelocityBoostDirection::up : VelocityBoostDirection::forward, board_velocity); source_require(velocity.has_value(), "Velocity boost direction or speed is invalid."); auto& boost = up ? debug.up_velocity : debug.forward_velocity; source_require(!boost.valid, "Velocity boost is already queued."); From 101e1af6e42b6ad27958279caa1458b39e8640be Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Wed, 7 Oct 2026 00:19:56 -0500 Subject: [PATCH 081/109] Official, staff and friends in the server browser; server tokens; Linux self-update Server browser - Official servers (the backend's "official_servers") sort first and are drawn in ReSkate blue - Servers Steam friends are skating in sort next, drawn in green with the friends' names; searching a friend's name finds their server - A player in a session they joined can join another server straight from the list; hosts still end their session first, and a Steam friend's join offer never replaces a session - Joining a dedicated server from the list records it as the player's public session, so friends see it and the row shows HERE - Blocked servers (the backend's "blocked_servers") are hidden and cannot be joined, by code either - When the backend sets "server_tokens_required", dedicated servers that signed in without a login token are hidden, except on the player's own network - A search that gets no internet list from Steam keeps the last list for two minutes, and waits six seconds before treating it as empty Roles - New Staff tag from the backend's "staff" list: green name, badge and item animation, ranked after Dev - Staff can pick a rainbow for their items on the Special page; it is sent as a solid colour older builds can show, so the protocol is unchanged Dedicated server - steam_token: sign in with a Steam game server login token and keep the same Steam ID every start; empty signs in anonymously as before - A listed server without a token says so at startup, and when the token rule starts or stops hiding it - Linux servers update themselves from the release's "server_linux" tarball, with the machine's curl and tar, pinned by SHA-256, and carry on in the same process Launcher - Only create the MiniDumpWriteDump API-set shim under Wine/Proton --- CMakeLists.txt | 4 + Engine/Game/Multiplayer/session_model.h | 22 +- .../Customization/developer_hoodie_material.h | 18 +- Extension/Multiplayer/Hud/custom_nametags.h | 8 +- Extension/Multiplayer/Net/protocol.h | 7 +- Extension/Multiplayer/Remote/cosmetics.h | 6 + .../Multiplayer/Session/session_commands.cpp | 46 +++- .../Multiplayer/Session/session_internal.h | 2 +- .../Multiplayer/Session/session_view.cpp | 22 +- .../Multiplayer/Steam/steam_friend_join.cpp | 6 +- .../Multiplayer/Steam/steam_friend_join.h | 15 ++ .../Steam/steam_server_browser.cpp | 44 +++- .../Multiplayer/Steam/steam_server_browser.h | 13 +- Extension/Multiplayer/Steam/steam_social.cpp | 13 +- Extension/Multiplayer/Steam/steam_social.h | 5 + .../Test/developer_identity_tests.cpp | 46 ++++ Extension/Multiplayer/Test/mesh_tests.cpp | 10 + .../Test/steam_friend_join_tests.cpp | 20 ++ Extension/Multiplayer/developer_identity.cpp | 49 +++- Extension/Multiplayer/developer_identity.h | 35 ++- .../Multiplayer/developer_identity_fetch.cpp | 1 + .../Test/native_menu_view_tests.cpp | 37 ++- .../UI/NativeMenu/native_menu_multiplayer.cpp | 12 +- Extension/UI/NativeMenu/native_menu_view.h | 12 +- Extension/UI/Overlay/multiplayer_lobbies.cpp | 52 +++- Extension/UI/Overlay/multiplayer_menu.cpp | 20 +- Extension/UI/Overlay/nametag_gradient.h | 1 + Extension/UI/Overlay/overlay.h | 2 +- Extension/UI/Overlay/role_badge.h | 2 +- Extension/UI/skate_theme.h | 7 + Launcher/launch.cpp | 14 +- Server/README-linux.md | 32 ++- Server/README.txt | 9 + Server/Test/server_config_tests.cpp | 14 ++ Server/Test/server_release_tests.cpp | 70 ++++++ Server/global_bans.cpp | 1 + Server/main.cpp | 27 ++- Server/server_config.cpp | 5 + Server/server_config.h | 4 + Server/server_release.cpp | 97 ++++++++ Server/server_release.h | 33 +++ Server/server_update.cpp | 222 +++++++++++++++++- Server/server_update.h | 11 +- Server/steam_server.cpp | 5 +- Server/steam_server.h | 9 +- cmake/Apps.cmake | 4 +- contrib/reskate-server.service | 4 +- 47 files changed, 976 insertions(+), 122 deletions(-) create mode 100644 Server/Test/server_release_tests.cpp create mode 100644 Server/server_release.cpp create mode 100644 Server/server_release.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9eb4ff81..34277fca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,10 @@ 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}") diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 4b3a8383..cb90d400 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -34,8 +34,19 @@ 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 + // Steam friends skating there now, by name (steam_social.h). + std::vector 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; @@ -86,7 +97,7 @@ struct MultiplayerChatLine { std::string name, text; bool local{}; // sent by this player // The sender's role, as their nametag shows it: its colour (IM_COL32 layout, 0 = none) - // and a tag shown in a box before the name ("Dev", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend" or empty). + // and a tag shown in a box before the name ("Dev", "Staff", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend" 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 @@ -142,7 +153,7 @@ struct MultiplayerModel { float chat_bubbles_distance{40.f}; float chat_bubbles_duration{5.f}; int chat_bubbles_history{3}; - // Local: the tag the ReSkate backend gives this player ("Dev", "Creator", "Centrix" or "Homie"; empty + // 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; @@ -150,15 +161,16 @@ struct MultiplayerModel { 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", "RED", "BLUE" or "GOLD"), 1 off, 2 a gradient - // between the two colours they picked, 3 the first of them alone. speed: 0 normal, 1 slow, - // 2 fast. + // 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 from{}, to{}; }; std::vector 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. diff --git a/Extension/Customization/developer_hoodie_material.h b/Extension/Customization/developer_hoodie_material.h index 6281af39..d4a2bbf2 100644 --- a/Extension/Customization/developer_hoodie_material.h +++ b/Extension/Customization/developer_hoodie_material.h @@ -122,24 +122,28 @@ inline std::pair, std::array> stand 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, and Centrix's from blue -// through light blue to white and back. No style asks for the rainbow: -// it is a developer's standard, and nobody else's. +// 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 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; + 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 (style.mode == MarkMode::solid) { + 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); @@ -150,7 +154,9 @@ inline ItemAnimation item_animation(std::optional mar constexpr std::array red{{{.2f, .004f, .01f}, {.8f, .03f, .03f}, {.8f, .05f, .13f}}}; constexpr std::array gold{{{.34f, .12f, .006f}, {.8f, .5f, .06f}, {.8f, .64f, .11f}}}; constexpr std::array blue{{{.01f, .16f, .8f}, {.3f, .5f, .8f}, {.8f, .8f, .8f}}}; - out.stops = *mark == IdentityList::content_creator ? red : *mark == IdentityList::centrix ? blue : gold; + constexpr std::array 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; } diff --git a/Extension/Multiplayer/Hud/custom_nametags.h b/Extension/Multiplayer/Hud/custom_nametags.h index 9850af35..face9f85 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.h +++ b/Extension/Multiplayer/Hud/custom_nametags.h @@ -34,15 +34,17 @@ inline constexpr std::uint32_t nametag_white = 0xffffffffU, nametag_developer = 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 -// Developers, content creators, homies and Centrix shimmer from these to their role colour -// (Centrix between white and its blue). +// 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_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. diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 521f3489..d10d0106 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -94,8 +94,8 @@ enum class PartyAction : std::uint8_t { }; 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); } @@ -103,6 +103,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; diff --git a/Extension/Multiplayer/Remote/cosmetics.h b/Extension/Multiplayer/Remote/cosmetics.h index 7b152912..c64a6329 100644 --- a/Extension/Multiplayer/Remote/cosmetics.h +++ b/Extension/Multiplayer/Remote/cosmetics.h @@ -43,6 +43,12 @@ inline constexpr std::array 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; +// 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 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; } diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index ba691606..018ba9aa 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -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" @@ -350,19 +351,28 @@ std::string edit_own_items(Session &, std::string_view argument) { 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 > 3 || + 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{static_cast(value >> 16), static_cast(value >> 8), static_cast(value)}; }; auto styles = developer_hoodie_detail::own_styles.load(); - styles[item] = {static_cast(mode), colour(from), colour(to), static_cast(speed)}; + styles[item] = {static_cast(mode == 4 ? 3 : mode), colour(from), colour(to), static_cast(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." : ": back to the usual."); + (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) { @@ -759,8 +769,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; } @@ -770,10 +784,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; @@ -845,6 +876,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. diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 00ea1b7c..1d40d7ae 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -363,7 +363,7 @@ void refresh_friends(Session &s); 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 mark_role(IdentityList list); -// A player's role colour and badge ("Dev", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend" or none), shown in chat +// 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 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. diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index f6579ab2..cd6bf37a 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -177,18 +177,22 @@ void publish(Session &s, const NativeFrame *local) { 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" : "GOLD"; + *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. - const bool picked = styles[i].mode == MarkMode::gradient || styles[i].mode == MarkMode::solid || - styles[i].from != styles[i].to || styles[i].from != std::array{}; + // 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{}); 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 = static_cast(styles[i].mode); + shown.mode = rainbow ? 4 : static_cast(styles[i].mode); shown.speed = styles[i].speed; for (std::size_t part = 0; part < 3; ++part) shown.from[part] = static_cast(from[part]) / 255.f, shown.to[part] = static_cast(to[part]) / 255.f; @@ -261,7 +265,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(); @@ -540,6 +553,7 @@ std::pair mark_role(IdentityList 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"}; } } diff --git a/Extension/Multiplayer/Steam/steam_friend_join.cpp b/Extension/Multiplayer/Steam/steam_friend_join.cpp index f133aae1..5cafe7a0 100644 --- a/Extension/Multiplayer/Steam/steam_friend_join.cpp +++ b/Extension/Multiplayer/Steam/steam_friend_join.cpp @@ -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) { diff --git a/Extension/Multiplayer/Steam/steam_friend_join.h b/Extension/Multiplayer/Steam/steam_friend_join.h index 71367b97..02a112f3 100644 --- a/Extension/Multiplayer/Steam/steam_friend_join.h +++ b/Extension/Multiplayer/Steam/steam_friend_join.h @@ -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 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; diff --git a/Extension/Multiplayer/Steam/steam_server_browser.cpp b/Extension/Multiplayer/Steam/steam_server_browser.cpp index 03017f0f..4b0f07ae 100644 --- a/Extension/Multiplayer/Steam/steam_server_browser.cpp +++ b/Extension/Multiplayer/Steam/steam_server_browser.cpp @@ -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 @@ -161,7 +162,11 @@ void SteamServerBrowser::refresh(std::uint64_t now) { 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", {}}; @@ -172,21 +177,25 @@ 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; 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; @@ -204,15 +213,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. + // 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 rows; for (const auto &[id, entry] : found_) { 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(); }); }); @@ -223,17 +235,29 @@ 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(search.response)->done || - (now - started_ > 1000000 && !api().refreshing(servers_, search.request)); + if (static_cast(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): keep what was listed in the + // last two minutes. + const auto before = found_.size(); + std::erase_if(found_, [&](const auto &entry) { + return entry.second.search != search_ && (internet_listed_ || now - entry.second.listed > 120000000); + }); + 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); diff --git a/Extension/Multiplayer/Steam/steam_server_browser.h b/Extension/Multiplayer/Steam/steam_server_browser.h index 25a3773b..ab5c7b44 100644 --- a/Extension/Multiplayer/Steam/steam_server_browser.h +++ b/Extension/Multiplayer/Steam/steam_server_browser.h @@ -15,6 +15,12 @@ std::optional 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> 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 searches_; // the internet and LAN lists std::map 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 rows_; void release(); - void read(); + void read(std::uint64_t now); }; } // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Steam/steam_social.cpp b/Extension/Multiplayer/Steam/steam_social.cpp index 87c1d4fd..42a6e54e 100644 --- a/Extension/Multiplayer/Steam/steam_social.cpp +++ b/Extension/Multiplayer/Steam/steam_social.cpp @@ -1,4 +1,5 @@ #include "steam_social.h" +#include "steam_friend_join.h" #include "Engine/Core/Platform/launcher_support.h" #include #include @@ -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(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 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: diff --git a/Extension/Multiplayer/Steam/steam_social.h b/Extension/Multiplayer/Steam/steam_social.h index c2156ee3..3e670b1e 100644 --- a/Extension/Multiplayer/Steam/steam_social.h +++ b/Extension/Multiplayer/Steam/steam_social.h @@ -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 steam_social_snapshot(); } diff --git a/Extension/Multiplayer/Test/developer_identity_tests.cpp b/Extension/Multiplayer/Test/developer_identity_tests.cpp index 6ef4fd31..f8f2b277 100644 --- a/Extension/Multiplayer/Test/developer_identity_tests.cpp +++ b/Extension/Multiplayer/Test/developer_identity_tests.cpp @@ -44,6 +44,28 @@ void parsing() { 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(L::official_server)] == std::vector{85568392924040001ULL, 85568392924040002ULL} && + bans[static_cast(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(L::blocked_server)] == std::vector{85568392924040001ULL, 90071992551410001ULL} && + blocks[static_cast(L::official_server)].empty() && servers[static_cast(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"); @@ -91,6 +113,30 @@ void lookup() { 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(L::staff)] == std::vector{stranger} && staff[static_cast(L::centrix)] == std::vector{stranger} && + staff[static_cast(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"); diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 7f37c780..c6bf74c2 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -411,6 +411,16 @@ void join_tick_checks() { tick(0, 0, true, map, nullptr); check(guest.mode == Mode::join && guest.host_id == host.host_id && guest.joined_public_lobby == 9001, "A Steam join click must not replace an active session"); + // Picking the server they are on in the browser changes nothing either; picking + // another one leaves this session for it. + check(queue_command("join-lobby", "9001", {}), "A browser pick should enter the normal queue"); + tick(0, 0, true, map, nullptr); + check(guest.mode == Mode::join && guest.host_id == host.host_id && guest.joined_public_lobby == 9001, + "Picking the current server replaced the session"); + check(queue_command("join-lobby", "9002", {}), "A browser hop should enter the normal queue"); + tick(0, 0, true, map, nullptr); + check(guest.joined_public_lobby != 9001 && guest.status != "Leave your current session before joining another lobby.", + "A guest could not hop to another server from the browser"); } } stop(guest, "Timeout checks"); diff --git a/Extension/Multiplayer/Test/steam_friend_join_tests.cpp b/Extension/Multiplayer/Test/steam_friend_join_tests.cpp index e0ad29a6..33c3aa47 100644 --- a/Extension/Multiplayer/Test/steam_friend_join_tests.cpp +++ b/Extension/Multiplayer/Test/steam_friend_join_tests.cpp @@ -34,6 +34,26 @@ int main() { check(steam_join_presence(model) == "+reskate_lobby 9001", "Connected guests can advertise their verified public lobby"); model.connected = false; check(steam_join_presence(model).empty(), "Guests cannot advertise before admission"); + // Where a player skates, for friends' server lists: any public session, joinable or not. + check(steam_session_target("9001") == 9001, "A session id parses"); + for (const auto* value : {"", "0", "-1", "9001 ", "+reskate_lobby 9001", "18446744073709551616", "lobby"}) + check(!steam_session_target(value), "Malformed session values must be ignored"); + model = open; + check(steam_session_presence(model) == "9001", "A public host tells friends its listing"); + model.password_required = true; model.players = model.capacity; model.local_ready = false; + check(steam_session_presence(model) == "9001", "A full, protected or loading public session is still told"); + model = open; model.public_host = false; + check(steam_session_presence(model).empty(), "A private host is never told"); + model = open; model.public_lobby = 0; + check(steam_session_presence(model).empty(), "A session joined by a private code is never told"); + model = open; model.lobby_listed = false; + check(steam_session_presence(model).empty(), "A closed listing is not told"); + model = open; model.active = false; + check(steam_session_presence(model).empty(), "An ended session is not told"); + model = open; model.hosting = model.public_host = model.lobby_listed = false; + check(steam_session_presence(model).empty(), "A guest is not told before admission"); + model.connected = true; + check(steam_session_presence(model) == "9001", "A connected guest tells friends the server they are on"); std::cout << "Steam friend join checks passed.\n"; } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } } diff --git a/Extension/Multiplayer/developer_identity.cpp b/Extension/Multiplayer/developer_identity.cpp index 8b2d9fbc..d4373cb8 100644 --- a/Extension/Multiplayer/developer_identity.cpp +++ b/Extension/Multiplayer/developer_identity.cpp @@ -1,5 +1,6 @@ #include "developer_identity.h" #include "Engine/Core/Json/json.h" +#include "Extension/Multiplayer/Net/protocol.h" #include #include #include @@ -10,24 +11,41 @@ // them from the backend its own way: developer_identity_fetch.cpp, Server/global_bans.cpp. namespace dingosdk::multiplayer { namespace { -// The API's name for each category, in IdentityList order; the ban list follows them. -constexpr std::array categories{"dev", "homie", "content_creator", "centrix"}; +// The API's name for each category of players. The ban list and the servers come beside them. +constexpr std::array, 5> categories{{ + {"dev", IdentityList::developer}, {"staff", IdentityList::staff}, {"homie", IdentityList::homie}, + {"content_creator", IdentityList::content_creator}, {"centrix", IdentityList::centrix}}}; constexpr auto banned = static_cast(IdentityList::banned); +constexpr auto servers = static_cast(IdentityList::official_server); +constexpr auto blocked = static_cast(IdentityList::blocked_server); std::atomic> current; // A player's SteamID64 is 76561197960265728 plus a 32-bit account number, and // nobody has account 0. The API sends them as strings. +std::optional number(const Json &entry) { + std::uint64_t id{}; + if (!entry.is_string()) return std::nullopt; + const auto &text = entry.string(); + const auto parsed = std::from_chars(text.data(), text.data() + text.size(), id); + return parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size() ? std::optional(id) : std::nullopt; +} std::uint64_t steam_id(const Json &entry) { constexpr std::uint64_t base = 76561197960265728ULL, last = base + 0xffffffffULL; - std::uint64_t id{}; - if (entry.is_string()) { - const auto &text = entry.string(); - const auto parsed = std::from_chars(text.data(), text.data() + text.size(), id); - if (parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size() && id > base && id <= last) return id; - } + if (const auto id = number(entry); id && *id > base && *id <= last) return *id; throw std::runtime_error("an entry is not a player's SteamID64"); } +// Only a server signed in with a login token keeps its ID; an anonymous one's is gone at its +// next start, so listing it would mean nothing. +std::uint64_t server_id(const Json &entry) { + if (const auto id = number(entry); id && persistent_server_steam_id(*id)) return *id; + throw std::runtime_error("an entry is not the SteamID64 of a server with a login token"); +} +// A block can name an anonymous server too: its ID is all there is to hold on to. +std::uint64_t any_server_id(const Json &entry) { + if (const auto id = number(entry); id && game_server_steam_id(*id)) return *id; + throw std::runtime_error("an entry is not the SteamID64 of a server"); +} } // namespace IdentityLists parse_identity_lists(std::string_view json) { @@ -36,17 +54,24 @@ IdentityLists parse_identity_lists(std::string_view json) { throw std::runtime_error("the answer has no categories"); const auto &listed = answer.at("categories"); IdentityLists lists; - const auto read = [](const Json &entries, std::vector &ids) { + const auto read = [](const Json &entries, std::vector &ids, std::uint64_t (*id)(const Json &) = steam_id) { if (!entries.is_array()) throw std::runtime_error("a list of players is not a list"); - for (const auto &entry : entries) ids.push_back(steam_id(entry)); + for (const auto &entry : entries) ids.push_back(id(entry)); std::sort(ids.begin(), ids.end()); ids.erase(std::unique(ids.begin(), ids.end()), ids.end()); }; - for (std::size_t index = 0; index < categories.size(); ++index) - if (listed.contains(categories[index])) read(listed.at(categories[index]), lists[index]); + for (const auto &[name, list] : categories) + if (listed.contains(name)) read(listed.at(name), lists[static_cast(list)]); if (answer.contains("banned")) read(answer.at("banned"), lists[banned]); + if (answer.contains("official_servers")) read(answer.at("official_servers"), lists[servers], server_id); + if (answer.contains("blocked_servers")) read(answer.at("blocked_servers"), lists[blocked], any_server_id); return lists; } +bool parse_server_tokens_required(std::string_view json) { + const auto answer = Json::parse(json); + return answer.is_object() && answer.contains("server_tokens_required") && answer.at("server_tokens_required").is_boolean() && + answer.at("server_tokens_required").get(); +} bool publish_identity_lists(IdentityLists lists) { const auto previous = current.load(); if (previous && *previous == lists) return false; diff --git a/Extension/Multiplayer/developer_identity.h b/Extension/Multiplayer/developer_identity.h index 03290ba2..851f5307 100644 --- a/Extension/Multiplayer/developer_identity.h +++ b/Extension/Multiplayer/developer_identity.h @@ -13,7 +13,11 @@ namespace dingosdk::multiplayer { // api.reskate.dev, so they change without a release. Steam authenticates these // identities. Each category has its tag in chat and on nametags (player_role) // and its animation on the marked hoodie and board (developer_hoodie_material.h). -enum class IdentityList : std::uint8_t { developer, homie, content_creator, centrix, banned, count }; +// The last list is not of players: the dedicated servers the ReSkate team runs, which the +// server browser puts first. A server is on it by the Steam ID its login token gives it. +// And one of servers the team has blocked: the browser does not show them and the game does +// not join them. +enum class IdentityList : std::uint8_t { developer, homie, content_creator, centrix, banned, official_server, staff, blocked_server, count }; using IdentityLists = std::array, static_cast(IdentityList::count)>; inline constexpr std::string_view identity_lists_url = "https://api.reskate.dev/api/v1/steam-ids"; @@ -26,10 +30,11 @@ void refresh_identity_lists() noexcept; bool identity_listed(std::uint64_t id, IdentityList list) noexcept; // Shared by the developer tag and the rainbow hoodie and board. inline bool reskate_developer(std::uint64_t id) noexcept { return identity_listed(id, IdentityList::developer); } -// The category a player's tag and items come from: a developer's before Centrix's before a -// content creator's before a homie's. Most players are in none. +// The category a player's tag and items come from: a developer's before the staff's before +// Centrix's before a content creator's before a homie's. Most players are in none. inline std::optional identity_mark(std::uint64_t id) noexcept { - for (const auto list : {IdentityList::developer, IdentityList::centrix, IdentityList::content_creator, IdentityList::homie}) + for (const auto list : {IdentityList::developer, IdentityList::staff, IdentityList::centrix, IdentityList::content_creator, + IdentityList::homie}) if (identity_listed(id, list)) return list; return std::nullopt; } @@ -47,11 +52,27 @@ inline void show_own_items(bool shown) noexcept { own_items.store(shown, std::me // so a banned player's own game still lets them try to join. Nobody is banned while the lists // cannot be read. inline bool reskate_banned(std::uint64_t id) noexcept { return identity_listed(id, IdentityList::banned); } +// A dedicated server the ReSkate team runs. Steam vouches for the ID: only the holder of the +// server's login token can sign in as it. +inline bool official_server(std::uint64_t id) noexcept { return identity_listed(id, IdentityList::official_server); } +inline bool blocked_server(std::uint64_t id) noexcept { return identity_listed(id, IdentityList::blocked_server); } +inline constexpr std::string_view blocked_server_notice = "This server has been blocked by the ReSkate team."; +// Whether the server browser shows only dedicated servers signed in with a Steam login token +// (protocol.h: persistent_server_steam_id), which the backend switches on and off beside the +// lists ("server_tokens_required"). Such a server keeps its Steam ID, so a block holds on it. +// Players' own lobbies, servers on the player's network and servers joined by code are not +// affected. Off until the backend says otherwise. +inline std::atomic server_tokens_rule{}; +inline bool server_tokens_required() noexcept { return server_tokens_rule.load(std::memory_order_relaxed); } +// What the API's answer says about that; false when it says nothing. Throws on an answer +// that is not JSON. +bool parse_server_tokens_required(std::string_view json); inline constexpr std::string_view banned_notice = "You are banned from ReSkate multiplayer."; -// The API's answer, {"categories":{"dev":["7656119..."],"homie":[],...},"banned":[]}, -// as sorted lists. A category this build does not know is ignored and a list the -// answer leaves out is empty; an entry that is not a player's SteamID64 throws. +// The API's answer, {"categories":{"dev":["7656119..."],"homie":[],...},"banned":[], +// "official_servers":["8556839..."],"blocked_servers":[]}, as sorted lists. A category this build does not know +// is ignored and a list the answer leaves out is empty; an entry that is not a player's +// SteamID64 (for the servers: a token server's) throws. IdentityLists parse_identity_lists(std::string_view json); // Puts sorted lists in use. False when they are the ones already in use. bool publish_identity_lists(IdentityLists lists); diff --git a/Extension/Multiplayer/developer_identity_fetch.cpp b/Extension/Multiplayer/developer_identity_fetch.cpp index 09765644..848476b1 100644 --- a/Extension/Multiplayer/developer_identity_fetch.cpp +++ b/Extension/Multiplayer/developer_identity_fetch.cpp @@ -23,6 +23,7 @@ void fetch() noexcept { L"ReSkate-Identity/1", &download); if (answer) { auto lists = parse_identity_lists(*answer); + server_tokens_rule = parse_server_tokens_required(*answer); const auto developers = lists[0].size(), homies = lists[1].size(), creators = lists[2].size(), bans = lists[static_cast(IdentityList::banned)].size(); if (publish_identity_lists(std::move(lists)) || failing) diff --git a/Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp b/Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp index b1b2870d..c3141ded 100644 --- a/Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp +++ b/Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp @@ -78,6 +78,27 @@ int main() { check(browse(model, options).total == 5, "Combine name and current-map filters"); options = {}; options.sort = Sort::players; check(browse(model, options).lobbies.front()->players == 14, "Most populated first"); + // The team's own servers lead the list under every sort, in that sort's order among themselves. + model.lobbies[2].dedicated = model.lobbies[2].official = true; + model.lobbies[5].dedicated = model.lobbies[5].official = true; + result = browse(model, options); + check(result.lobbies[0]->id == 105 && result.lobbies[1]->id == 102 && result.lobbies[2]->players == 14, + "Official servers are not first when sorting by players"); + result = browse(model, {}); + check(result.lobbies[0]->id == 102 && result.lobbies[1]->id == 105 && result.lobbies[2]->id == 100, + "Official servers are not first when sorting by name"); + // Then the servers friends are in, which a search for a friend's name finds too. + model.lobbies[9].friends = {"Ana"}; + result = browse(model, {}); + check(result.lobbies[0]->id == 102 && result.lobbies[1]->id == 105 && result.lobbies[2]->id == 109 && result.lobbies[3]->id == 100, + "A friend's server is not next after the official ones"); + BrowserOptions by_friend; + by_friend.query = "ANA"; + result = browse(model, by_friend); + check(result.total == 1 && result.lobbies.front()->id == 109, "A friend's name did not find their server"); + model.lobbies[9].friends.clear(); + model.lobbies[2].dedicated = model.lobbies[2].official = false; + model.lobbies[5].dedicated = model.lobbies[5].official = false; options.query = "missing"; result = browse(model, options); check(result.total == 0 && result.lobbies.empty(), "Empty search"); @@ -87,9 +108,19 @@ int main() { check(!can_join(model, lobby), "Full lobby must not join"); lobby.players = 1; lobby.owner = model.local_id; check(!can_join(model, lobby), "Own lobby must not join"); - lobby.owner = 123; model.active = true; - check(!can_join(model, lobby), "Active session must not join"); - model.active = false; model.lobby_joining = true; + // A guest hops straight to another server; a host ends their session first, and nobody + // joins the one they are in. + lobby.owner = 123; model.active = true; model.hosting = true; + check(!can_join(model, lobby), "A host must end their session before joining"); + model.hosting = false; model.public_lobby = 999; + check(can_join(model, lobby), "A guest could not hop to another server"); + model.public_lobby = lobby.id; + check(!can_join(model, lobby), "The server a player is in must not join"); + lobby.friends = {"Ana", "Ben", "Cy", "Di"}; + check(lobby_friends_text(lobby) == "Ana, Ben and 2 more" && lobby_friends_text(lobby, 4) == "Ana, Ben, Cy, Di", + "Friends in a server were not named"); + lobby.friends.clear(); + model.active = false; model.public_lobby = 0; model.lobby_joining = true; check(!can_join(model, lobby), "Pending connection must not join"); MultiplayerPlayer player; player.id = 88; player.epoch = 7; player.connected = true; player.name = "Skater"; diff --git a/Extension/UI/NativeMenu/native_menu_multiplayer.cpp b/Extension/UI/NativeMenu/native_menu_multiplayer.cpp index 09addd75..773c3140 100644 --- a/Extension/UI/NativeMenu/native_menu_multiplayer.cpp +++ b/Extension/UI/NativeMenu/native_menu_multiplayer.cpp @@ -128,7 +128,9 @@ void process_actions(const Context& context, const MultiplayerModel& model) { } else { std::string command = cmd, argument = request.argument, password; if (cmd == "host" || cmd == "join" || cmd == "join-lobby") { - if (model.active || model.lobby_joining) { s.feedback = "Leave the current session before connecting."; continue; } + // From a session they joined a player can hop to a server in the list (can_join). + const bool hop = cmd == "join-lobby" && model.active && !model.hosting && !model.echo; + if ((model.active && !hop) || model.lobby_joining) { s.feedback = "Leave the current session before connecting."; continue; } if (cmd == "host") { if (!model.local_ready) { s.feedback = "Load a map before hosting."; continue; } const auto name = input_text(context, "host-name"); @@ -206,9 +208,10 @@ void render_section(const Context& context, const MultiplayerModel& model, Secti for (const auto* lobby : result.lobbies) { const auto id = std::to_string(lobby->id); const bool available = menu_view::can_join(model, *lobby); - const auto title = menu_view::caption(lobby->name) + (lobby->dedicated ? " [SERVER]" : "") + "\n" + menu_view::caption(menu_view::map_name(lobby->map, levels), 32) + + const auto title = menu_view::caption(lobby->name) + (lobby->official ? " [OFFICIAL]" : lobby->dedicated ? " [SERVER]" : "") + "\n" + menu_view::caption(menu_view::map_name(lobby->map, levels), 32) + " / " + std::to_string(lobby->players) + "/" + std::to_string(lobby->capacity) + - " skaters / " + (lobby->players >= lobby->capacity ? "FULL" : lobby->password_required ? "PASSWORD" : "OPEN"); + " skaters / " + (lobby->players >= lobby->capacity ? "FULL" : lobby->password_required ? "PASSWORD" : "OPEN") + + (lobby->friends.empty() ? std::string{} : " / WITH " + menu_view::caption(lobby_friends_text(*lobby), 40)); add_button(context, main, "lobby-" + id, title, available ? "join-lobby" : "", id); } s.row_width = side_width; @@ -220,7 +223,8 @@ void render_section(const Context& context, const MultiplayerModel& model, Secti add_button(context, side, "refresh", model.lobby_searching ? "Searching..." : "Refresh lobbies", model.lobby_searching ? "" : "browse", "", true); add_text(context, side, "browser-status", s.feedback.empty() ? model.browser_status : s.feedback); - if (!idle) add_text(context, side, "browser-connected", "Leave your session before joining another."); + if (!idle) add_text(context, side, "browser-connected", model.active && !model.hosting && !model.echo + ? "Pick another server to leave this one and join it." : "End your session before joining another."); } else if (section == Section::host) { add_text(context, main, "host-heading", "MAKE IT YOUR SESSION"); add_text(context, main, "host-name-label", "LOBBY NAME"); diff --git a/Extension/UI/NativeMenu/native_menu_view.h b/Extension/UI/NativeMenu/native_menu_view.h index 78e20433..67986d2d 100644 --- a/Extension/UI/NativeMenu/native_menu_view.h +++ b/Extension/UI/NativeMenu/native_menu_view.h @@ -47,7 +47,9 @@ struct BrowserResults { std::size_t total{}; }; inline bool can_join(const MultiplayerModel& model, const MultiplayerLobby& lobby) { - return !model.active && !model.lobby_joining && lobby.players < lobby.capacity && lobby.owner != model.local_id; + // From a session they joined a player can hop straight to another; a host ends theirs first. + const bool free = !model.active || (!model.hosting && !model.echo && lobby.id != model.public_lobby); + return free && !model.lobby_joining && lobby.players < lobby.capacity && lobby.owner != model.local_id; } inline std::string player_identity(const MultiplayerPlayer& player) { return std::to_string(player.id) + " " + std::to_string(player.epoch); @@ -66,10 +68,16 @@ inline BrowserResults browse(const MultiplayerModel& model, const BrowserOptions // Servers advertise their map's name rather than its destination. if (options.same_map && lobby.map != model.map && !(lobby.dedicated && map_name(lobby.map, levels) == map_name(model.map, levels))) continue; - if (!query.empty() && fold(lobby.name + " " + map_name(lobby.map, levels) + " " + lobby.map).find(query) == std::string::npos) continue; + auto text = lobby.name + " " + map_name(lobby.map, levels) + " " + lobby.map; + for (const auto& name : lobby.friends) text += " " + name; + if (!query.empty() && fold(text).find(query) == std::string::npos) continue; result.lobbies.push_back(&lobby); } std::sort(result.lobbies.begin(), result.lobbies.end(), [&](const auto* a, const auto* b) { + // The ReSkate team's own servers lead the list, however the rest is sorted. + if (a->official != b->official) return a->official; + // Then where friends are. + if (a->friends.empty() != b->friends.empty()) return !a->friends.empty(); if (options.sort == Sort::players && a->players != b->players) return a->players > b->players; if (options.sort == Sort::map) { const auto an = fold(map_name(a->map, levels)), bn = fold(map_name(b->map, levels)); diff --git a/Extension/UI/Overlay/multiplayer_lobbies.cpp b/Extension/UI/Overlay/multiplayer_lobbies.cpp index b0794de6..ea5a4f13 100644 --- a/Extension/UI/Overlay/multiplayer_lobbies.cpp +++ b/Extension/UI/Overlay/multiplayer_lobbies.cpp @@ -49,7 +49,7 @@ LobbyRows &lobby_rows() { bool same_lobby(const MultiplayerLobby &a, const MultiplayerLobby &b) { return a.id == b.id && a.owner == b.owner && a.name == b.name && a.map == b.map && a.code == b.code && a.password_required == b.password_required && a.players == b.players && a.capacity == b.capacity && - a.dedicated == b.dedicated && a.ping == b.ping; + a.dedicated == b.dedicated && a.official == b.official && a.ping == b.ping && a.friends == b.friends; } bool rows_current(const LobbyRows &cache, const SkateMenu &menu, const Model &model) { const auto &mp = model.multiplayer; @@ -91,12 +91,19 @@ const std::vector &lobby_list(const SkateMenu &menu, const Model &model) { row.map_key = lowercase(row.map); if (menu.multiplayer_same_map_only && !row.same_map) continue; if (menu.multiplayer_dedicated_only && !lobby.dedicated) continue; - if (!filter.empty() && (row.host_key + " " + row.map_key + " " + lowercase(lobby.map)).find(filter) == std::string::npos) - continue; + if (!filter.empty()) { + auto text = row.host_key + " " + row.map_key + " " + lowercase(lobby.map); + for (const auto &name : lobby.friends) text += " " + lowercase(name); + if (text.find(filter) == std::string::npos) continue; + } rows.push_back(std::move(row)); } std::sort(rows.begin(), rows.end(), [&](const Row &a, const Row &b) { const auto &first = mp.lobbies[a.lobby], &second = mp.lobbies[b.lobby]; + // The ReSkate team's own servers lead the list, however the rest is sorted. + if (first.official != second.official) return first.official; + // Then where friends are. + if (first.friends.empty() != second.friends.empty()) return !first.friends.empty(); switch (menu.multiplayer_lobby_sort) { case 1: if (first.players != second.players) return first.players > second.players; @@ -325,9 +332,18 @@ void join_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks ImGui::PushID(id.c_str()); const auto top = ImGui::GetCursorScreenPos(); const float width = ImGui::GetContentRegionAvail().x; - const bool joinable = !mp.active && !mp.lobby_joining && !row.self && !row.full; - skate_theme::rough_rect(draw, top, ImVec2(top.x + width, top.y + tile_height), skate_theme::tile, + // From a session they joined a player can hop straight to another; a host ends theirs first. + const bool here = mp.active && lobby.id == mp.public_lobby; + const bool hop = mp.active && !mp.hosting && !mp.echo && !here; + const bool joinable = (!mp.active || hop) && !mp.lobby_joining && !row.self && !row.full; + // The team's own servers are ReSkate blue, and the ones friends are skating in green. + const bool with_friends = !lobby.friends.empty() && !lobby.official; + skate_theme::rough_rect(draw, top, ImVec2(top.x + width, top.y + tile_height), + lobby.official ? skate_theme::official_tile : with_friends ? skate_theme::friends_tile : skate_theme::tile, static_cast(i + 41), px(1)); + if (lobby.official || with_friends) + draw->AddRect(top, ImVec2(top.x + width, top.y + tile_height), lobby.official ? skate_theme::official : skate_theme::good, 0, 0, + px(1.5f)); if (row.same_map) draw->AddRectFilled(top, ImVec2(top.x + px(4), top.y + tile_height), skate_theme::blue); // Right: Join, then the player count and tags leading into it. @@ -336,7 +352,7 @@ void join_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks ImGui::BeginDisabled(!joinable); skate_theme::push_primary_button(); ImGui::PushFont(menu.bold); - if (ImGui::Button(row.self ? "YOURS" : row.full ? "FULL" : "JOIN", ImVec2(join_width, 0))) { + if (ImGui::Button(row.self ? "YOURS" : here ? "HERE" : row.full ? "FULL" : "JOIN", ImVec2(join_width, 0))) { if (lobby.password_required) prompt_password(menu, &lobby); else send_private(menu, "join-lobby", id, menu.multiplayer_join_password, false); } @@ -344,11 +360,14 @@ void join_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks skate_theme::pop_primary_button(); ImGui::EndDisabled(); if (!joinable && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { - ImGui::SetTooltip("%s", row.self ? "This is your lobby." : row.full ? "This server is full." + ImGui::SetTooltip("%s", row.self ? "This is your lobby." : here ? "You are skating here." + : row.full ? "This server is full." : mp.lobby_joining ? "A lobby join is in progress." - : "Leave your current session before joining."); - } else if (joinable && !row.same_map && ImGui::IsItemHovered()) { - ImGui::SetTooltip("Joining loads %s for you.", row.map.c_str()); + : "End your session before joining another."); + } else if (joinable && ImGui::IsItemHovered()) { + if (hop && row.same_map) ImGui::SetTooltip("Leaves your current session and joins this one."); + else if (hop) ImGui::SetTooltip("Leaves your current session, loads %s and joins this one.", row.map.c_str()); + else if (!row.same_map) ImGui::SetTooltip("Joining loads %s for you.", row.map.c_str()); } const auto players = std::to_string(lobby.players) + " / " + std::to_string(lobby.capacity); const auto players_size = menu.bold->CalcTextSizeA(px(18), FLT_MAX, 0, players.c_str()); @@ -362,11 +381,11 @@ void join_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks tag(menu, "PASSWORD", skate_theme::warning); } { - const char *kind = lobby.dedicated ? "DEDICATED" : "PLAYER"; + const char *kind = lobby.official ? "OFFICIAL" : lobby.dedicated ? "DEDICATED" : "PLAYER"; const auto size = menu.bold->CalcTextSizeA(px(12), FLT_MAX, 0, kind); right -= size.x + px(14) + px(12); ImGui::SetCursorScreenPos(ImVec2(right, top.y + (tile_height - ImGui::GetFrameHeight()) * .5f)); - tag(menu, kind, lobby.dedicated ? skate_theme::blue : skate_theme::tile_light); + tag(menu, kind, lobby.official ? skate_theme::official : lobby.dedicated ? skate_theme::blue : skate_theme::tile_light); } // Left: lobby name over its map. @@ -376,12 +395,21 @@ void join_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks ImGui::PushFont(menu.bold); ImGui::PushClipRect(ImGui::GetCursorScreenPos(), ImVec2(ImGui::GetCursorScreenPos().x + text_width, top.y + tile_height), true); + if (lobby.official || with_friends) + ImGui::PushStyleColor(ImGuiCol_Text, lobby.official ? skate_theme::official_text : skate_theme::friends_text); ImGui::TextUnformatted(lobby.name.c_str()); + if (lobby.official || with_friends) ImGui::PopStyleColor(); ImGui::PopFont(); ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::grey_text); ImGui::TextUnformatted((row.map + (row.same_map ? " - your map" : "") + (lobby.ping >= 0 ? " - " + std::to_string(lobby.ping) + " ms" : std::string{})).c_str()); ImGui::PopStyleColor(); + if (!lobby.friends.empty()) { + ImGui::SameLine(0, 0); + ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::good); + ImGui::TextUnformatted((" - with " + lobby_friends_text(lobby)).c_str()); + ImGui::PopStyleColor(); + } ImGui::PopClipRect(); ImGui::EndGroup(); diff --git a/Extension/UI/Overlay/multiplayer_menu.cpp b/Extension/UI/Overlay/multiplayer_menu.cpp index e98a8e26..a6c19c4e 100644 --- a/Extension/UI/Overlay/multiplayer_menu.cpp +++ b/Extension/UI/Overlay/multiplayer_menu.cpp @@ -399,12 +399,20 @@ void special_page(SkateMenu &menu, const Model &model, const CallbacksV3 &) { ImGui::PushID(item); begin_card(menu, title.c_str(), title.c_str()); // The options in the order shown; a style's mode is 0 what the list gives, 1 off, 2 a - // gradient between the player's two colours, 3 their one colour. - static constexpr std::array modes{0, 2, 3, 1}; - int option = static_cast(std::find(modes.begin(), modes.end(), style.mode) - modes.begin()) % 4; + // gradient between the player's two colours, 3 their one colour, 4 the rainbow (the + // staff's to pick). field(menu, "Color"); - if (choice(menu, "mode", option, {mp.identity_animation.c_str(), "GRADIENT", "SOLID", "OFF"}, shown)) - send(modes[static_cast(option)], style.speed); + if (mp.identity_rainbow) { + static constexpr std::array modes{0, 4, 2, 3, 1}; + int option = static_cast(std::find(modes.begin(), modes.end(), style.mode) - modes.begin()) % 5; + if (choice(menu, "mode", option, {mp.identity_animation.c_str(), "RAINBOW", "GRADIENT", "SOLID", "OFF"}, shown)) + send(modes[static_cast(option)], style.speed); + } else { + static constexpr std::array modes{0, 2, 3, 1}; + int option = static_cast(std::find(modes.begin(), modes.end(), style.mode) - modes.begin()) % 4; + if (choice(menu, "mode", option, {mp.identity_animation.c_str(), "GRADIENT", "SOLID", "OFF"}, shown)) + send(modes[static_cast(option)], style.speed); + } if (style.mode == 2 || style.mode == 3) { const bool gradient = style.mode == 2; field(menu, gradient ? "Colors" : "Pick", gradient ? "The two colors it moves between. Click one to change it." @@ -436,7 +444,7 @@ void special_page(SkateMenu &menu, const Model &model, const CallbacksV3 &) { } } // A colour that stands still has no speed. - if (style.mode == 0 || style.mode == 2) { + if (style.mode == 0 || style.mode == 2 || style.mode == 4) { int speed = style.speed; field(menu, "Speed"); if (choice(menu, "speed", speed, {"NORMAL", "SLOW", "FAST"}, shown)) send(style.mode, speed); diff --git a/Extension/UI/Overlay/nametag_gradient.h b/Extension/UI/Overlay/nametag_gradient.h index 386f1246..d3864d9b 100644 --- a/Extension/UI/Overlay/nametag_gradient.h +++ b/Extension/UI/Overlay/nametag_gradient.h @@ -16,6 +16,7 @@ inline constexpr std::array nametag_gradients{ NametagGradient{multiplayer::nametag_creator_start, multiplayer::nametag_creator}, NametagGradient{multiplayer::nametag_homie_start, multiplayer::nametag_homie}, NametagGradient{multiplayer::nametag_centrix_start, multiplayer::nametag_centrix}, + NametagGradient{multiplayer::nametag_staff_start, multiplayer::nametag_staff}, }; inline const NametagGradient* nametag_gradient(ImU32 colour) noexcept { for (const auto& gradient : nametag_gradients) diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index f6484524..db36708c 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -312,7 +312,7 @@ struct Nametag { std::array position{}; // above the skater's head, world space std::string name; std::uint32_t color{0xffffffffU}; // R, G, B, A bytes (IM_COL32) - std::string tag; // role badge before the name ("Dev", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend") + std::string tag; // role badge before the name ("Dev", "Staff", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend") float distance{}; // metres from the local skater bool talking{}; // Recent chat lines to show as bubbles above the head, oldest first ("" = none). diff --git a/Extension/UI/Overlay/role_badge.h b/Extension/UI/Overlay/role_badge.h index f92c34b4..cabc7857 100644 --- a/Extension/UI/Overlay/role_badge.h +++ b/Extension/UI/Overlay/role_badge.h @@ -4,7 +4,7 @@ #include #include -// The role badge ("Dev", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend") before a player's name, in chat +// The role badge ("Dev", "Staff", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend") before a player's name, in chat // and on nametags: a pill in the role's colour (animated for the roles in nametag_gradient.h) with dark text. namespace dingosdk::overlay::detail { inline float role_badge_width(ImFont *font, float size, const std::string &tag) { diff --git a/Extension/UI/skate_theme.h b/Extension/UI/skate_theme.h index 0cc79214..94b2761c 100644 --- a/Extension/UI/skate_theme.h +++ b/Extension/UI/skate_theme.h @@ -16,6 +16,13 @@ inline constexpr ImU32 tile_light = IM_COL32(61, 62, 66, 240); inline constexpr ImU32 blue = IM_COL32(1, 131, 255, 255); inline constexpr ImU32 blue_hover = IM_COL32(40, 152, 255, 255); inline constexpr ImU32 blue_active = IM_COL32(0, 110, 215, 255); +// The ReSkate team's own servers: the logo's blue, a server tile tinted with it, and their name on it. +inline constexpr ImU32 official = IM_COL32(0, 145, 255, 255); +inline constexpr ImU32 official_tile = IM_COL32(8, 44, 82, 240); +inline constexpr ImU32 official_text = IM_COL32(110, 190, 255, 255); +// A server Steam friends are skating in: a tile tinted green, and its name on it. +inline constexpr ImU32 friends_tile = IM_COL32(14, 58, 32, 240); +inline constexpr ImU32 friends_text = IM_COL32(120, 226, 156, 255); inline constexpr ImU32 black = IM_COL32(0, 0, 0, 255); inline constexpr ImU32 white = IM_COL32(245, 245, 245, 255); inline constexpr ImU32 grey_text = IM_COL32(150, 152, 158, 255); diff --git a/Launcher/launch.cpp b/Launcher/launch.cpp index 9fdffbc1..ba33ec83 100644 --- a/Launcher/launch.cpp +++ b/Launcher/launch.cpp @@ -564,10 +564,17 @@ std::string steam_persona_name() { // injection (seen as "Cannot sample the executable primary thread", Windows // error 5, once the suspended thread tears down). dbghelp.dll exports // MiniDumpWriteDump under the same name, so a copy of it beside Skate.exe under -// the API set's file name satisfies the import. On Windows the schema is used -// instead and a same-named file is ignored, so creating it there is harmless. +// the API set's file name satisfies the import. On Windows the schema handles +// this import without a shim, so do not create one there. void ensure_minidump_apiset_shim(const fs::path& game_directory) noexcept { try { + // Windows resolves this API set itself. Only Wine/Proton needs a file + // beside Skate.exe; copying a system DLL under an API-set name on + // Windows creates an unnecessary executable that looks suspicious. + const auto ntdll = GetModuleHandleW(L"ntdll.dll"); + const auto kernel32 = GetModuleHandleW(L"kernel32.dll"); + if (!(ntdll && GetProcAddress(ntdll, "wine_get_version")) && + !(kernel32 && GetProcAddress(kernel32, "wine_get_dos_file_name"))) return; const auto shim = game_directory / L"api-ms-win-core-debug-minidump-l1-1-0.dll"; std::error_code ec; if (fs::exists(shim, ec)) return; @@ -601,8 +608,7 @@ DWORD start_game(const Session& session, const launcher::LaunchOptions& options, throw std::runtime_error( "Skate.exe, ReSkate.dll, and the original steam_api64.dll must be beside ReSkateLauncher.exe"); - // Satisfy Skate.exe's MiniDumpWriteDump API-set import before it is created, - // so the game loads under Wine/Proton instead of aborting in the loader. + // Satisfy Skate.exe's MiniDumpWriteDump API-set import under Wine/Proton. ensure_minidump_apiset_shim(paths.directory); logging::write(logging::Level::info, logging::Channel::launcher, L"Validating exact Skate.exe SHA-256 and PE identity"); diff --git a/Server/README-linux.md b/Server/README-linux.md index 6bebb795..76645957 100644 --- a/Server/README-linux.md +++ b/Server/README-linux.md @@ -1,15 +1,16 @@ # ReSkate dedicated server on Linux Native x86_64 build. Same lobby protocol as `ReSkateServer.exe`; needs no game install. -Self-update is disabled on Linux (V1) — update by replacing the binary. +A release build updates itself, like the Windows server: see [Updating](#updating). ## Requirements - 64-bit Linux (tested: CachyOS/Arch, Ubuntu 22.04+ should work). - `cmake >= 3.24`, `g++ >= 12` (C++20), `libssl-dev` (OpenSSL), `libcurl` headers optional (not needed for V1). - `curl` on the machine that runs the server: it is how the server reads the ReSkate team's - global ban list (`api.reskate.dev`). Without it the server says so in its log and enforces - only its own bans. Not needed with `"global_bans": false`. + global ban list (`api.reskate.dev`) and how it downloads its own updates. Without it the + server says so in its log, enforces only its own bans and stays on its version. +- `tar` (with gzip) on that machine too, to unpack an update. - Steam shared libs beside the binary: `libsteam_api.so` + `steamclient.so` (Valve proprietary, not in git — but bundled in the CI/release assets, like the Windows zip bundles its DLLs; otherwise fetch them, see below). @@ -110,10 +111,31 @@ sudo systemctl enable --now reskate-server The server writes its own `ReSkateServer.log` next to the binary; the console output goes to the journal (`journalctl -u reskate-server -f`). +## Updating + +A server from a release tarball updates itself the way the Windows one does: at startup and +every half hour it asks GitHub for the latest release, and when that holds a newer Linux +server it installs it once nobody is on, then carries on as the new version. `update` in +the console does it straight away. `"auto_update": false` in `ReSkateServer.json`, or +`--no-update`, turns it off. A server you built yourself never replaces itself. + +- The release's `launcher.json` pins the tarball and the `ReSkateServer` inside it by + SHA-256; nothing else is installed. The download is `curl` over HTTPS and the unpacking + is `tar`, so both must be on the machine. +- Everything in the tarball is replaced in the server's folder (the server, the Steam + libraries, the readmes, `world-layers.json`). Your own files are not in it and are kept: + `ReSkateServer.json`, `Mods`, the logs. +- The user the server runs as must be able to write to the server's folder. With the + systemd unit below that is `reskate` and `/opt/reskate-server`: + `sudo chown -R reskate: /opt/reskate-server`. +- The updated server takes over the same process, so systemd (or your terminal) keeps the + server it started; nothing restarts the service. +- A release made before this (1.1.3 and older) has no Linux entry in `launcher.json`: the + server logs that the latest release has no Linux server and stays as it is. To get onto + a self-updating version from one of those, replace the files once by hand. + ## Notes / limits (V1) -- `auto_update` / `update` command: `updates_enabled()==false` on Linux. - `check_for_update` reports “self-update is not supported on Linux”. - `--export-world-layers` on Linux: the reader works, the scanner needs the Windows game (`Data/layout.toc` + CAS) and `oo2core_9_win64.dll` (Oodle). Oodle blocks throw `Oodle CAS data is only supported on Windows` (caught, diff --git a/Server/README.txt b/Server/README.txt index 3d721e3f..ac77a8dd 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -58,6 +58,15 @@ max_players 1-249. password Empty for anyone; otherwise players type it to join. welcome A chat line sent to each player as they join. listed false hides the server; players then need the code. +steam_token A Steam game server login token, or empty (default). Without one + the server signs in anonymously and gets a new Steam ID every + start. With one it keeps the same Steam ID, printed at startup. + Make a token at steamcommunity.com/dev/managegameservers with + App ID 3354750; each running server needs its own. Keep it + private: anyone with it can sign in as your server. + The ReSkate team can set the in-game server browser to show + only servers that have one; the server says so in its log when + that hides it. Players can always join with the code. auto_update Install new ReSkate releases when nobody is on (default true). global_bans Turn away players the ReSkate team has banned from multiplayer (default true). The list is read from api.reskate.dev at startup diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 24633dbf..a16a5bd3 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -48,6 +48,20 @@ int run() { const auto reloaded = load_config(file); check(reloaded.name == "Old Server" && !reloaded.boosts && reloaded.votes.map.percent == 60, "Values lost on rewrite"); + // steam_token: empty (anonymous) unless set, kept on a rewrite, and only letters and digits. + // (The maps are not loaded yet, so config_error has another complaint: look for this one.) + const auto token_refused = [](const ServerConfig &c) { return config_error(c).find("steam_token") != std::string::npos; }; + check(has("steam_token") && config.steam_token.empty() && !token_refused(config), "steam_token is not a new, empty setting"); + auto tokened = reloaded; + tokened.steam_token = "0123456789ABCDEF0123456789ABCDEF"; + save_config(tokened); + check(load_config(file).steam_token == tokened.steam_token && !token_refused(tokened), "steam_token was not kept"); + tokened.steam_token = "not a token"; + check(token_refused(tokened), "A steam_token with other characters was accepted"); + tokened.steam_token = std::string(65, 'A'); + check(token_refused(tokened), "An overlong steam_token was accepted"); + save_config(reloaded); + // Server names: letters, digits, spaces and - _ [ ] ( ) only. check(valid_server_name("Old Server") && valid_server_name("[EU] Skate_Park-2 (24x7)") && valid_server_name("a"), "A plain server name was refused"); diff --git a/Server/Test/server_release_tests.cpp b/Server/Test/server_release_tests.cpp new file mode 100644 index 00000000..1a07c40d --- /dev/null +++ b/Server/Test/server_release_tests.cpp @@ -0,0 +1,70 @@ +// What the Linux server's self-update accepts from a release: launcher.json's "server_linux" +// entry, and the list of files in the tarball. +#include "Server/server_release.h" +#include +#include + +namespace { +int failures{}; +void check(bool ok, const char *what) { + if (!ok) { + std::cerr << "FAIL: " << what << '\n'; + ++failures; + } +} +template bool throws(Call &&call) { + try { + call(); + } catch (const std::exception &) { + return true; + } + return false; +} +} // namespace + +int main() { + using namespace dingosdk::server; + const std::string sha(64, 'a'), exe(64, 'b'); + const auto assets = release_assets( + R"({"tag_name":"v1.2.0","assets":[{"name":"launcher.json","browser_download_url":"https://github.com/o/r/releases/download/v1.2.0/launcher.json"},)" + R"({"name":"ReSkateServer-Linux-1.2.0.tar.gz","browser_download_url":"https://github.com/o/r/releases/download/v1.2.0/ReSkateServer-Linux-1.2.0.tar.gz"}]})"); + check(assets.size() == 2 && assets.contains("launcher.json"), "A release's assets were not read"); + check(throws([] { release_assets(R"({"message":"API rate limit exceeded"})"); }), "An answer that is not a release was accepted"); + + const auto entry = [&](std::string url, std::string hash, std::string exe_hash, std::string size, std::string version = "1.2.0") { + return R"({"schema":1,"server":{"url":"asset:ReSkateServer-1.2.0.zip"},"server_linux":{"version":")" + version + R"(","url":")" + url + + R"(","sha256":")" + hash + R"(","size":)" + size + R"(,"exe_sha256":")" + exe_hash + R"("}})"; + }; + const auto good = parse_linux_release(entry("asset:ReSkateServer-Linux-1.2.0.tar.gz", sha, exe, "31000000"), assets); + check(good && good->version == "1.2.0" && good->size == 31000000 && good->sha256 == sha && good->exe_sha256 == exe && + good->url == "https://github.com/o/r/releases/download/v1.2.0/ReSkateServer-Linux-1.2.0.tar.gz", + "A Linux release was not read, or its asset was not resolved"); + const auto direct = parse_linux_release(entry("https://example.com/server.tar.gz", sha, exe, "1"), {}); + check(direct && direct->url == "https://example.com/server.tar.gz", "A direct HTTPS link was not kept"); + // A release from before Linux servers updated themselves has no entry: nothing to do. + check(!parse_linux_release(R"({"schema":1,"server":{"url":"asset:ReSkateServer-1.1.3.zip"}})", assets), "A release with no Linux server gave one"); + for (const auto &wrong : {entry("asset:missing.tar.gz", sha, exe, "1"), entry("http://example.com/s.tar.gz", sha, exe, "1"), + entry("https://example.com/s.tar.gz --output /etc/passwd", sha, exe, "1"), + entry("https://example.com/s.tar.gz", std::string(64, 'A'), exe, "1"), + entry("https://example.com/s.tar.gz", sha, "abc", "1"), entry("https://example.com/s.tar.gz", sha, exe, "0"), + entry("https://example.com/s.tar.gz", sha, exe, "999999999999"), + entry("https://example.com/s.tar.gz", sha, exe, "-5"), entry("https://example.com/s.tar.gz", sha, exe, "1", "1.2; rm"), + std::string(R"({"server_linux":"yes"})"), std::string("[]")}) + check(throws([&] { parse_linux_release(wrong, assets); }), ("A wrong Linux release was accepted: " + wrong).c_str()); + + check(https_url("https://github.com/o/r/releases/download/v1/a.tar.gz?x=1&y=%20") && !https_url("https://") && + !https_url("file:///etc/passwd") && !https_url("https://a b") && !https_url("https://a\nb") && !https_url("-o x"), + "Only plain HTTPS links may be handed to curl"); + + check(checked_archive_listing("ReSkateServer-Linux-1.2.0/\nReSkateServer-Linux-1.2.0/ReSkateServer\n" + "ReSkateServer-Linux-1.2.0/licenses/\nReSkateServer-Linux-1.2.0/licenses/zstd-LICENSE.txt\n") == + "ReSkateServer-Linux-1.2.0", + "A release tarball's listing was refused"); + check(checked_archive_listing("./pkg/\r\n./pkg/ReSkateServer\r\n") == "pkg", "A listing written with ./ was refused"); + for (const char *wrong : {"", "pkg/\n", "pkg/ReSkateServer\nother/file\n", "pkg/a\n/etc/passwd\n", "pkg/a\npkg/../../etc/passwd\n", + "pkg/a\npkg//b\n", "pkg/a\npkg\\b\n", "../a\n../b\n", "pkg/a\n./\n"}) + check(throws([&] { checked_archive_listing(wrong); }), "A tarball that is not one plain folder was accepted"); + + if (!failures) std::cout << "server release: ok\n"; + return failures ? 1 : 0; +} diff --git a/Server/global_bans.cpp b/Server/global_bans.cpp index 15cd4914..5ab5a092 100644 --- a/Server/global_bans.cpp +++ b/Server/global_bans.cpp @@ -50,6 +50,7 @@ BanListCheck use_ban_list(std::string_view answer) { BanListCheck check; try { auto lists = multiplayer::parse_identity_lists(answer); + multiplayer::server_tokens_rule = multiplayer::parse_server_tokens_required(answer); const auto &bans = lists[static_cast(multiplayer::IdentityList::banned)]; check.banned = bans.size(); check.changed = last != bans; diff --git a/Server/main.cpp b/Server/main.cpp index e4125140..c0768360 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -6,6 +6,7 @@ #include "server_config.h" #include "server_host.h" #include "server_update.h" +#include "Extension/Multiplayer/developer_identity.h" #include "steam_server.h" #include "Extension/Multiplayer/Session/monotonic_clock.h" #include "Engine/Core/Platform/path_text.h" @@ -364,16 +365,19 @@ int run(int argc, char **argv, bool skip_update) { SteamServer steam; std::string error; - if (!steam.start(here, config.port, config.query_port, error)) { + if (!steam.start(here, config.port, config.query_port, config.steam_token, error)) { write_log(error); return 1; } - write_log("Signing in to Steam..."); + write_log(config.steam_token.empty() ? "Signing in to Steam..." : "Signing in to Steam with steam_token..."); const auto login_started = std::chrono::steady_clock::now(); while (!steam.logged_on() && !stopping) { steam.run_callbacks(); if (std::chrono::steady_clock::now() - login_started > std::chrono::seconds(60)) { - write_log("Steam sign-in timed out after 60 s. Check the internet connection and try again."); + write_log(config.steam_token.empty() + ? "Steam sign-in timed out after 60 s. Check the internet connection and try again." + : "Steam sign-in timed out after 60 s. Steam may not have accepted steam_token: it must be a token for " + "app 3354750 that no other running server is using. Or check the internet connection."); return 1; } std::this_thread::sleep_for(std::chrono::milliseconds(20)); @@ -391,8 +395,14 @@ int run(int argc, char **argv, bool skip_update) { return 1; } write_log(config.name + " is up on " + host.map_name() + " for " + std::to_string(config.max_players) + " players."); - write_log("Steam ID " + std::to_string(steam.steam_id()) + ", public IP " + steam.public_ip() + "."); + write_log("Steam ID " + std::to_string(steam.steam_id()) + + (config.steam_token.empty() ? " (anonymous: new every start; set steam_token to keep one)" : " (from steam_token: the same every start)") + + ", public IP " + steam.public_ip() + "."); write_log("Join code: " + host.invite() + (config.password.empty() ? "" : " (password required)")); + if (config.steam_token.empty() && config.listed) + write_log("No steam_token: the server browser can be set to show only servers that have one, and then " + "this server is not in it (players can still join with the code). It takes a minute to make " + "one: see steam_token in README."); write_log(config.admins.empty() ? "No admins yet: type \"admin add \" to add one." : std::to_string(config.admins.size()) + " admin(s). Type help for commands."); @@ -402,6 +412,7 @@ int run(int argc, char **argv, bool skip_update) { #endif auto next_advertise = std::chrono::steady_clock::now(); std::optional name_allowed; // last seen: whether the name may be listed + bool tokens_required{}; // last seen: whether the browser wants a steam_token auto next_update_check = next_advertise + update_interval; std::future update_check; bool update_now{}, update_waiting{}, restart{}; @@ -484,6 +495,14 @@ int run(int argc, char **argv, bool skip_update) { } if (const auto now = std::chrono::steady_clock::now(); now >= next_advertise) { next_advertise = now + std::chrono::seconds(2); + // The ReSkate team's rule, read with its ban list: say when it starts or stops hiding this server. + if (const bool required = multiplayer::server_tokens_required(); required != tokens_required) { + tokens_required = required; + if (config.steam_token.empty() && config.listed) + write_log(required ? "The server browser now shows only servers with a steam_token, so this server is " + "hidden from it. Add a steam_token (see README) to be listed again." + : "The server browser shows servers without a steam_token again."); + } // A name with a bad word in it is never listed (clients hide one too); the // server still runs and players can join with its code. const bool allowed = !text::contains_bad_words(config.name); diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 574bbd78..4c370e4f 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -4,6 +4,7 @@ #include "Extension/Multiplayer/Net/protocol.h" #include "Engine/Game/World/world_names.h" #include +#include #include #include #include @@ -25,6 +26,7 @@ Json to_json(const ServerConfig &c) { root["password"] = c.password; root["welcome"] = c.welcome; root["listed"] = c.listed; + root["steam_token"] = c.steam_token; root["auto_update"] = c.auto_update; root["global_bans"] = c.global_bans; root["activity_log"] = c.activity_log; @@ -124,6 +126,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector parse_scoring(std::string_view text) { std::string config_error(const ServerConfig &c) { using namespace multiplayer; if (!valid_server_name(c.name)) return std::string("name must be ") + server_name_rule + "."; + if (c.steam_token.size() > 64 || !std::all_of(c.steam_token.begin(), c.steam_token.end(), [](unsigned char ch) { return std::isalnum(ch); })) + return "steam_token must be a game server login token (letters and digits), or empty to sign in anonymously."; if (c.map.empty() || !valid_map_destination(map_destination(c.map))) return "map \"" + c.map + "\" is not a known map. Use a name like \"San Vansterdam\", or put the map's mod " "folder in Mods next to the server."; diff --git a/Server/server_config.h b/Server/server_config.h index bfcb0e26..95c2f9e6 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -39,6 +39,10 @@ struct ServerConfig { std::string password; // empty: anyone may join std::string welcome; // sent to each player as they join bool listed = true; // shown in the in-game server browser + // A Steam game server login token (steamcommunity.com/dev/managegameservers, app 3354750). + // With one the server signs in to its own account and keeps the same Steam ID every start, + // which is how the ReSkate team's list of official servers knows it. Empty: anonymous. + std::string steam_token; bool auto_update = true; // install new releases when nobody is on bool global_bans = true; // turn away players the ReSkate team has banned (global_bans.h) bool activity_log = true; // console lines for throwdowns, objects and loading diff --git a/Server/server_release.cpp b/Server/server_release.cpp new file mode 100644 index 00000000..38650dd9 --- /dev/null +++ b/Server/server_release.cpp @@ -0,0 +1,97 @@ +#include "server_release.h" +#include "Engine/Core/Json/json.h" +#include +#include +#include + +namespace dingosdk::server { +namespace { +bool sha256_text(std::string_view text) noexcept { + return text.size() == 64 && + std::all_of(text.begin(), text.end(), [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); }); +} +} // namespace + +bool https_url(std::string_view url) noexcept { + constexpr std::string_view scheme = "https://"; + if (!url.starts_with(scheme) || url.size() == scheme.size() || url.size() > 2048) return false; + return std::all_of(url.begin(), url.end(), [](unsigned char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + std::string_view("-._~:/?#[]@!$&'()*+,;=%").find(static_cast(c)) != std::string_view::npos; + }); +} + +std::map> release_assets(std::string_view release_json) { + const auto release = Json::parse(release_json); + if (!release.is_object() || !release.contains("assets") || !release.at("assets").is_array()) + throw std::runtime_error("GitHub's answer is not a release"); + std::map> assets; + for (const auto &asset : release.at("assets")) + if (asset.is_object()) assets[asset.value("name", "")] = asset.value("browser_download_url", ""); + return assets; +} + +std::optional parse_linux_release(std::string_view launcher_json, + const std::map> &assets) { + const auto root = Json::parse(launcher_json); + if (!root.is_object()) throw std::runtime_error("launcher.json is not a JSON object"); + if (!root.contains("server_linux")) return std::nullopt; + const auto &entry = root.at("server_linux"); + if (!entry.is_object()) throw std::runtime_error("launcher.json \"server_linux\" is not an object"); + LinuxRelease release; + release.version = entry.value("version", ""); + release.url = entry.value("url", ""); + release.sha256 = entry.value("sha256", ""); + release.exe_sha256 = entry.value("exe_sha256", ""); + if (entry.contains("size") && entry.at("size").is_number_unsigned()) release.size = entry.at("size").get(); + constexpr std::string_view asset = "asset:"; + if (release.url.starts_with(asset)) { + const auto found = assets.find(std::string_view(release.url).substr(asset.size())); + if (found == assets.end()) throw std::runtime_error("the release is missing asset " + release.url.substr(asset.size())); + release.url = found->second; + } + if (!https_url(release.url)) throw std::runtime_error("launcher.json \"server_linux.url\" is not an HTTPS link"); + if (!sha256_text(release.sha256) || !sha256_text(release.exe_sha256)) + throw std::runtime_error("launcher.json \"server_linux\" hashes must be 64 lowercase hex digits"); + if (!release.size || release.size > max_linux_release_bytes) + throw std::runtime_error("launcher.json \"server_linux.size\" is out of range"); + if (release.version.empty() || release.version.size() > 32 || + !std::all_of(release.version.begin(), release.version.end(), + [](unsigned char c) { return std::isalnum(c) || c == '.' || c == '-' || c == '_'; })) + throw std::runtime_error("launcher.json \"server_linux.version\" is not a version"); + return release; +} + +std::string checked_archive_listing(std::string_view listing) { + std::string top; + std::size_t entries{}; + for (std::size_t start = 0; start < listing.size();) { + auto end = listing.find('\n', start); + if (end == std::string_view::npos) end = listing.size(); + auto line = listing.substr(start, end - start); + start = end + 1; + if (!line.empty() && line.back() == '\r') line.remove_suffix(1); + if (line.empty()) continue; + if (line.front() == '/' || line.find('\\') != std::string_view::npos || line.size() > 512) + throw std::runtime_error("the update has a file outside its folder"); + // "./name/..." is how some tars write it. + if (line.starts_with("./")) line.remove_prefix(2); + std::string_view first; + for (std::size_t at = 0; at <= line.size();) { + auto slash = line.find('/', at); + if (slash == std::string_view::npos) slash = line.size(); + const auto part = line.substr(at, slash - at); + if (part == ".." || part == "." || (part.empty() && slash != line.size())) + throw std::runtime_error("the update has a file outside its folder"); + if (at == 0) first = part; + at = slash + 1; + } + if (first.empty()) throw std::runtime_error("the update has a file outside its folder"); + if (top.empty()) top = first; + else if (top != first) throw std::runtime_error("the update is not one folder"); + ++entries; + } + if (top.empty() || entries < 2) throw std::runtime_error("the update is empty"); + return top; +} +} // namespace dingosdk::server diff --git a/Server/server_release.h b/Server/server_release.h new file mode 100644 index 00000000..0295e8d8 --- /dev/null +++ b/Server/server_release.h @@ -0,0 +1,33 @@ +#pragma once +#include +#include +#include +#include +#include + +// What the Linux server's self-update reads from a release, apart from how it is fetched +// (server_update.cpp): the release's launcher.json entry for the Linux server, and the checks +// on the tarball before anything is unpacked. Portable, so the tests run on Windows too. +namespace dingosdk::server { +// launcher.json's "server_linux": the tarball (pinned by size and SHA-256) and the SHA-256 of +// the ReSkateServer inside it, which a running server compares with its own. +struct LinuxRelease { + std::string version, url, sha256, exe_sha256; + std::uint64_t size{}; +}; +// The largest tarball a server will download. +inline constexpr std::uint64_t max_linux_release_bytes = 512ull * 1024 * 1024; +// GitHub's answer for a release, as the names of its assets and where each downloads from. +// Throws when the answer is not a release. +std::map> release_assets(std::string_view release_json); +// Nothing when the release was made before Linux servers updated themselves (no +// "server_linux"). An "asset:" URL becomes that asset's download link. Throws on an +// entry that is there but wrong: no HTTPS link, a hash that is not one, a size out of range. +std::optional parse_linux_release(std::string_view launcher_json, + const std::map> &assets); +// `tar -t`'s list of a release tarball: every entry under the one top folder the package has, +// none absolute or with a ".." in it. Returns that folder's name; throws otherwise. +std::string checked_archive_listing(std::string_view listing); +// A link curl may be handed: https, with nothing but URL characters in it. +bool https_url(std::string_view url) noexcept; +} // namespace dingosdk::server diff --git a/Server/server_update.cpp b/Server/server_update.cpp index 0f4e7a9c..f8262c91 100644 --- a/Server/server_update.cpp +++ b/Server/server_update.cpp @@ -4,7 +4,17 @@ #include "Launcher/updater.h" #include #else +#include "server_release.h" +#include "launcher_update_config.h" +#include +#include +#include #include +#include +#include +#include +#include +#include #endif #include #include @@ -88,24 +98,218 @@ void remove_previous_update(const std::filesystem::path &folder) noexcept { } #else // _WIN32 -// Linux V1: self-update is disabled. Operators update by replacing the binary -// (package manager / re-download). This keeps the server free of WinHTTP and -// Windows file-replacement semantics. A curl-based updater can be added later. -bool updates_enabled() noexcept { return false; } +// Linux: the same releases, read with the tools every server machine has. curl does the HTTPS +// (as for the global bans) and tar unpacks; both are run directly, never through a shell, and +// nothing is installed that launcher.json does not pin by SHA-256. +namespace { +std::mutex release_mutex; +std::optional release; // from the last check + +// Read once, before an update can replace the file: /proc/self/exe then names a deleted one. +const std::filesystem::path &self() { + static const std::filesystem::path path = std::filesystem::read_symlink("/proc/self/exe"); + return path; +} + +// Runs a program found on PATH with these arguments and returns what it printed, at most +// `limit` bytes. Throws with the program's own complaint when it fails or cannot be started. +std::string run_tool(const std::vector &arguments, std::size_t limit) { + int out[2], err[2]; + if (pipe(out) != 0) throw std::runtime_error(arguments.front() + " could not be started"); + if (pipe(err) != 0) { + close(out[0]), close(out[1]); + throw std::runtime_error(arguments.front() + " could not be started"); + } + const auto child = fork(); + if (child < 0) { + for (const int fd : {out[0], out[1], err[0], err[1]}) close(fd); + throw std::runtime_error(arguments.front() + " could not be started"); + } + if (child == 0) { + std::vector argv; + for (const auto &argument : arguments) argv.push_back(const_cast(argument.c_str())); + argv.push_back(nullptr); + dup2(out[1], STDOUT_FILENO); + dup2(err[1], STDERR_FILENO); + if (const int null = open("/dev/null", O_RDONLY); null >= 0) dup2(null, STDIN_FILENO); + for (int fd = 3; fd < 1024; ++fd) close(fd); + execvp(argv.front(), argv.data()); + _exit(127); + } + close(out[1]), close(err[1]); + std::string output, complaint; + bool too_much{}; + pollfd fds[2]{{out[0], POLLIN, 0}, {err[0], POLLIN, 0}}; + for (int open_ends = 2; open_ends;) { + if (poll(fds, 2, -1) < 0) { + if (errno == EINTR) continue; + break; + } + for (auto &fd : fds) { + if (fd.fd < 0 || !(fd.revents & (POLLIN | POLLHUP | POLLERR))) continue; + char buffer[65536]; + const auto count = read(fd.fd, buffer, sizeof(buffer)); + if (count > 0) { + auto &text = fd.fd == out[0] ? output : complaint; + const auto room = (fd.fd == out[0] ? limit : std::size_t{4096}) - std::min(text.size(), fd.fd == out[0] ? limit : std::size_t{4096}); + text.append(buffer, std::min(room, static_cast(count))); + if (fd.fd == out[0] && static_cast(count) > room) too_much = true; + } else if (count == 0 || errno != EINTR) { + close(fd.fd); + fd.fd = -1; + --open_ends; + } + } + } + for (auto &fd : fds) + if (fd.fd >= 0) close(fd.fd); + int status{}; + while (waitpid(child, &status, 0) < 0 && errno == EINTR) {} + if (WIFEXITED(status) && WEXITSTATUS(status) == 127 && complaint.empty()) + throw std::runtime_error(arguments.front() + " is not installed"); + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + while (!complaint.empty() && (complaint.back() == '\n' || complaint.back() == '\r')) complaint.pop_back(); + std::replace(complaint.begin(), complaint.end(), '\n', ' '); + throw std::runtime_error(complaint.empty() ? arguments.front() + " failed" : complaint); + } + if (too_much) throw std::runtime_error(arguments.front() + " answered with more than expected"); + return output; +} + +std::vector curl(const std::string &url, std::uint64_t limit, int seconds) { + if (!https_url(url)) throw std::runtime_error("a download link is not HTTPS"); + return {"curl", "--silent", "--show-error", "--fail", "--location", "--proto", "=https", "--proto-redir", "=https", + "--max-time", std::to_string(seconds), "--max-filesize", std::to_string(limit), "--user-agent", "ReSkateServer/1", + "--url", url}; +} +std::string https_text(const std::string &url, std::size_t limit) { return run_tool(curl(url, limit, 20), limit); } + +struct RemovePath { + std::filesystem::path path; + ~RemovePath() { + std::error_code ignored; + std::filesystem::remove_all(path, ignored); + } +}; +const char *const archive_name = "ReSkateServer-update.tar.gz"; +const char *const stage_name = ".reskate-update"; +} // namespace + +bool updates_enabled() noexcept { return launcher_update::launcher_binary_updates; } UpdateCheck check_for_update() { UpdateCheck check; - check.problem = "self-update is not supported on Linux; update by replacing the binary"; + try { + std::optional found; + if (const char *local = std::getenv("RESKATE_LAUNCHER_CONFIG_FILE"); local && *local) { + // Test an unpublished config before attaching it to a release, as the launcher can. + // Its links must be whole HTTPS ones: there is no release to find an "asset:" in. + std::ifstream input(local, std::ios::binary); + const std::string text{std::istreambuf_iterator(input), std::istreambuf_iterator()}; + if (!input && !input.eof()) throw std::runtime_error(std::string("cannot read ") + local); + found = parse_linux_release(text, {}); + } else { + std::string repo; + for (const wchar_t c : std::wstring_view(launcher_update::launcher_release_repo)) repo += static_cast(c); + // Only this lookup uses GitHub's rate-limited API; the files come from the release's download links. + const auto assets = release_assets(https_text("https://api.github.com/repos/" + repo + "/releases/latest", 4 * 1024 * 1024)); + const auto manifest = assets.find("launcher.json"); + if (manifest == assets.end()) { + check.problem = "the latest release has no launcher.json"; + return check; + } + found = parse_linux_release(https_text(manifest->second, 256 * 1024), assets); + } + if (!found) { + check.problem = "the latest release has no Linux server"; + return check; + } + check.version = found->version; + check.available = launcher::sha256_file(self()) != found->exe_sha256; + std::lock_guard lock(release_mutex); + release = std::move(found); + } catch (const std::exception &e) { + check.problem = e.what(); + } return check; } -void install_update(const std::filesystem::path &) { - throw std::runtime_error("self-update is not supported on Linux"); +void install_archive(const std::filesystem::path &archive, const std::filesystem::path &folder, std::string_view exe_sha256) { + namespace fs = std::filesystem; + // Looked at before it is unpacked: one folder, nothing that reaches outside it. + checked_archive_listing(run_tool({"tar", "-tzf", archive.string()}, 1024 * 1024)); + const auto stage = folder / stage_name; + RemovePath remove_stage{stage}; + fs::remove_all(stage); + fs::create_directory(stage); + run_tool({"tar", "-xzf", archive.string(), "-C", stage.string(), "--strip-components=1", "--no-same-owner"}, 64 * 1024); + // Plain files and folders only: a link could point anywhere. + std::vector files, folders; + for (auto entry = fs::recursive_directory_iterator(stage); entry != fs::recursive_directory_iterator(); ++entry) { + const auto status = entry->symlink_status(); + if (fs::is_directory(status)) folders.push_back(fs::relative(entry->path(), stage)); + else if (fs::is_regular_file(status)) files.push_back(fs::relative(entry->path(), stage)); + else throw std::runtime_error("the update has something that is not a file or a folder"); + } + const auto exe = self().filename(); + // Otherwise the new copy would find itself out of date and update forever. + if (std::find(files.begin(), files.end(), exe) == files.end() || launcher::sha256_file(stage / exe) != exe_sha256) + throw std::runtime_error("the release's ReSkateServer doesn't match launcher.json"); + // Nothing in the folder has changed until here. Each file then takes its place in one step + // (a rename within the folder), which Linux allows for a program and libraries in use; the + // server itself goes last, so a failure part-way leaves a server that updates again. + for (const auto &relative : folders) fs::create_directories(folder / relative); + std::stable_partition(files.begin(), files.end(), [&](const fs::path &file) { return file != exe; }); + for (const auto &relative : files) fs::rename(stage / relative, folder / relative); } -bool relaunch() { return false; } +void install_update(const std::filesystem::path &folder) { + LinuxRelease pinned; + { + std::lock_guard lock(release_mutex); + if (!release) throw std::runtime_error("no update has been found"); + pinned = *release; + } + const auto archive = folder / archive_name; + RemovePath remove_archive{archive}; + auto download = curl(pinned.url, pinned.size, 600); + download.insert(download.end(), {"--output", archive.string()}); + run_tool(download, 64 * 1024); + if (std::filesystem::file_size(archive) != pinned.size || launcher::sha256_file(archive) != pinned.sha256) + throw std::runtime_error("the downloaded update doesn't match launcher.json"); + install_archive(archive, folder, pinned.exe_sha256); +} -void remove_previous_update(const std::filesystem::path &) noexcept {} +// The updated server takes this one's place: the same process, so whatever started it (a +// terminal, systemd) keeps the server it started. +bool relaunch() { + std::vector arguments; + { + std::ifstream in("/proc/self/cmdline", std::ios::binary); + for (std::string argument; std::getline(in, argument, '\0');) arguments.push_back(argument); + } + if (arguments.empty()) arguments.push_back(self().string()); + std::vector argv; + for (auto &argument : arguments) argv.push_back(argument.data()); + argv.push_back(nullptr); + // Nothing this server had open (Steam's sockets, the log) carries over. + std::error_code ignored; + std::vector open_files; + for (const auto &entry : std::filesystem::directory_iterator("/proc/self/fd", ignored)) { + const auto fd = std::atoi(entry.path().filename().c_str()); + if (fd > STDERR_FILENO) open_files.push_back(fd); + } + for (const int fd : open_files) + if (const int flags = fcntl(fd, F_GETFD); flags >= 0) fcntl(fd, F_SETFD, flags | FD_CLOEXEC); + execv(self().c_str(), argv.data()); + return false; +} + +void remove_previous_update(const std::filesystem::path &folder) noexcept { + std::error_code ignored; + std::filesystem::remove_all(folder / stage_name, ignored); + std::filesystem::remove(folder / archive_name, ignored); +} #endif // _WIN32 } // namespace dingosdk::server diff --git a/Server/server_update.h b/Server/server_update.h index 1c9048b5..0009eba0 100644 --- a/Server/server_update.h +++ b/Server/server_update.h @@ -1,12 +1,15 @@ #pragma once #include #include +#include // Self-update for release builds of the dedicated server. The latest GitHub // release's launcher.json pins the server zip (size and SHA-256) and the // SHA-256 of the ReSkateServer.exe inside it; a server whose own exe differs // installs that zip over its folder and starts again. Its own files // (ReSkateServer.json, Mods, logs) are not in the zip, so they are kept. +// The Linux server does the same from the release's tarball ("server_linux" in +// launcher.json, server_release.h), using the machine's curl and tar. namespace dingosdk::server { // Release builds only: a local build never replaces itself. bool updates_enabled() noexcept; @@ -21,8 +24,14 @@ UpdateCheck check_for_update(); // Downloads the pinned zip into `folder` and unpacks it there. Throws on any // failure, before anything in the folder has changed. void install_update(const std::filesystem::path& folder); -// Starts a new copy of this exe with the same command line. +// Starts a new copy of this exe with the same command line. On Linux the new copy takes +// this process's place, so this only returns (false) when that failed. bool relaunch(); // Deletes the files a previous update renamed aside. void remove_previous_update(const std::filesystem::path& folder) noexcept; +#ifndef _WIN32 +// The second half of install_update, for a tarball already downloaded and verified: checks +// what is in it, that its ReSkateServer has this SHA-256, and puts its files in `folder`. +void install_archive(const std::filesystem::path& archive, const std::filesystem::path& folder, std::string_view exe_sha256); +#endif } // namespace dingosdk::server diff --git a/Server/steam_server.cpp b/Server/steam_server.cpp index 66921691..532b231b 100644 --- a/Server/steam_server.cpp +++ b/Server/steam_server.cpp @@ -133,7 +133,7 @@ std::string server_tags(const Advertisement &a) { } SteamServer::~SteamServer() { stop(); } bool SteamServer::start(const std::filesystem::path &folder, std::uint16_t port, std::uint16_t query_port, - std::string &error) { + const std::string &token, std::string &error) { try { std::optional quiet{std::in_place}; #ifdef _WIN32 @@ -199,7 +199,8 @@ bool SteamServer::start(const std::filesystem::path &folder, std::uint16_t port, text("SteamAPI_ISteamGameServer_SetProduct", "reskate"); text("SteamAPI_ISteamGameServer_SetGameDescription", "ReSkate dedicated server"); symbol(module_, "SteamAPI_ISteamGameServer_SetDedicatedServer")(server_, true); - symbol(module_, "SteamAPI_ISteamGameServer_LogOnAnonymous")(server_); + if (token.empty()) symbol(module_, "SteamAPI_ISteamGameServer_LogOnAnonymous")(server_); + else text("SteamAPI_ISteamGameServer_LogOn", token.c_str()); return true; } catch (const std::exception &e) { error = e.what(); diff --git a/Server/steam_server.h b/Server/steam_server.h index d5864144..db20889f 100644 --- a/Server/steam_server.h +++ b/Server/steam_server.h @@ -15,13 +15,16 @@ struct Advertisement { }; std::string server_tags(const Advertisement &); -// A Steam game server for Skate's app, signed in anonymously. Its SteamID is -// new each start, so invite codes last only while the server runs. +// A Steam game server for Skate's app. Signed in anonymously its SteamID is new each +// start, so invite codes last only while the server runs; signed in with a game server +// login token it is the token's account, with the same SteamID every start. class SteamServer { public: ~SteamServer(); // Loads steam_api64.dll from `folder`, next to the Steam client files it needs. - bool start(const std::filesystem::path &folder, std::uint16_t port, std::uint16_t query_port, std::string &error); + // `token`: a game server login token, or empty to sign in anonymously. + bool start(const std::filesystem::path &folder, std::uint16_t port, std::uint16_t query_port, const std::string &token, + std::string &error); void run_callbacks(); bool logged_on(); std::uint64_t steam_id(); diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 29c8d394..f630f2c3 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -160,10 +160,10 @@ endif() # ReSkate dedicated server: a headless session host. It runs from its own folder # next to steam_api64.dll and the Steam client files; no game install needed. -# On Linux next to libsteam_api.so; self-update is disabled there (see server_update.cpp). +# On Linux next to libsteam_api.so; it updates itself there too, with the machine's curl and tar. add_executable(dingosdk_server Server/main.cpp Server/server_host.cpp Server/server_party.cpp Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/steam_server.cpp - Server/server_update.cpp $<$:Launcher/updater.cpp> + Server/server_update.cpp Server/server_release.cpp $<$:Launcher/updater.cpp> Server/global_bans.cpp Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/Steam/steam_transport.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp diff --git a/contrib/reskate-server.service b/contrib/reskate-server.service index 795dc52b..ed927270 100644 --- a/contrib/reskate-server.service +++ b/contrib/reskate-server.service @@ -10,8 +10,8 @@ WorkingDirectory=/opt/reskate-server ExecStart=/opt/reskate-server/ReSkateServer Restart=always RestartSec=5 -# Server uses restart_for_update internally; V1 Linux has self-update off, -# so any non-zero exit still restarts here. +# The server updates itself in place (the new version takes over this same process), so +# its user must be able to write to WorkingDirectory. Any exit still restarts it here. # The server already appends every line to ReSkateServer.log next to itself; # appending stdout there too wrote each line twice. The journal keeps the console copy. StandardOutput=journal From 3d259667d706e76feb91b5faec2ba1e7f31c4805 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Wed, 7 Oct 2026 00:28:58 -0500 Subject: [PATCH 082/109] Sort servers by players by default; allow / in server names - The server browser opens sorted by most players, in the overlay and the native menu; official and friends' servers still come first - Dedicated server names may contain "/" (builds before this hide a server whose name has one) --- Engine/Game/Multiplayer/session_model.h | 6 +++--- Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp | 7 ++++--- Extension/UI/NativeMenu/native_menu_view.h | 3 ++- Extension/UI/Overlay/skate_menu.h | 2 +- Server/README.txt | 2 +- Server/Test/server_config_tests.cpp | 5 +++-- 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index cb90d400..1e24eae1 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -13,16 +13,16 @@ 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 +// 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 - _ [ ] ( )"; +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; + if (!word && std::string_view(" -_/[]()").find(c) == std::string_view::npos) return false; named |= word; } return named; diff --git a/Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp b/Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp index c3141ded..9eb463be 100644 --- a/Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp +++ b/Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp @@ -57,6 +57,7 @@ int main() { check(missing_tabs({1, 2, 3, 4}, 8, false) == std::vector{0}, "The Multiplayer page does not wait for the overlay's tool callbacks"); MultiplayerModel model; + const BrowserOptions by_name{{}, false, Sort::name}; model.local_id = 77; model.map = "Levels/San_Vansterdam"; for (unsigned i = 0; i < 14; ++i) { MultiplayerLobby lobby; @@ -66,7 +67,7 @@ int main() { lobby.players = static_cast(i + 1); lobby.capacity = 16; model.lobbies.push_back(lobby); } - auto result = browse(model, {}); + auto result = browse(model, by_name); check(result.total == 14 && result.lobbies.size() == 14, "All matching lobbies remain scrollable"); check(result.lobbies.front()->id == 100 && result.lobbies[6]->id == 112 && result.lobbies.back()->id == 113, "Stable ordering of duplicate names across the full list"); @@ -84,12 +85,12 @@ int main() { result = browse(model, options); check(result.lobbies[0]->id == 105 && result.lobbies[1]->id == 102 && result.lobbies[2]->players == 14, "Official servers are not first when sorting by players"); - result = browse(model, {}); + result = browse(model, by_name); check(result.lobbies[0]->id == 102 && result.lobbies[1]->id == 105 && result.lobbies[2]->id == 100, "Official servers are not first when sorting by name"); // Then the servers friends are in, which a search for a friend's name finds too. model.lobbies[9].friends = {"Ana"}; - result = browse(model, {}); + result = browse(model, by_name); check(result.lobbies[0]->id == 102 && result.lobbies[1]->id == 105 && result.lobbies[2]->id == 109 && result.lobbies[3]->id == 100, "A friend's server is not next after the official ones"); BrowserOptions by_friend; diff --git a/Extension/UI/NativeMenu/native_menu_view.h b/Extension/UI/NativeMenu/native_menu_view.h index 67986d2d..07076633 100644 --- a/Extension/UI/NativeMenu/native_menu_view.h +++ b/Extension/UI/NativeMenu/native_menu_view.h @@ -8,7 +8,8 @@ namespace dingosdk::multiplayer::menu_view { enum class Sort { name, players, map }; -struct BrowserOptions { std::string query; bool same_map{}; Sort sort{}; }; +// The busiest servers first, until the player picks another order. +struct BrowserOptions { std::string query; bool same_map{}; Sort sort = Sort::players; }; // Owned pages: Multiplayer and Custom Stuff (needs the overlay's tool callbacks). inline constexpr unsigned page_count = 2, tools_page = 1; diff --git a/Extension/UI/Overlay/skate_menu.h b/Extension/UI/Overlay/skate_menu.h index 111c6390..9fd1223a 100644 --- a/Extension/UI/Overlay/skate_menu.h +++ b/Extension/UI/Overlay/skate_menu.h @@ -31,7 +31,7 @@ struct SkateMenu { std::optional map_rotation_pending; // the rotation slider, likewise double map_rotation_until{}; bool ban_confirm_on_server{}; // the Ban popup bans from a dedicated server - int multiplayer_lobby_sort = 0; + int multiplayer_lobby_sort = 1; // most players first float multiplayer_code_height = 0; // what the join-by-code card took last frame // Bans: the manual add form, and the player a Ban button is asking about. std::array ban_id{}; diff --git a/Server/README.txt b/Server/README.txt index ac77a8dd..b3faa69b 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -42,7 +42,7 @@ straight away (players are told to rejoin). Turn this off with ReSkateServer.json ------------------ -name Shown in the browser: 1-64 letters, numbers, spaces and - _ [ ] ( ). +name Shown in the browser: 1-64 letters, numbers, spaces and - _ / [ ] ( ). map The map everyone skates, named like the game's load command: "San Vansterdam", "Isle of Grom", "Super Ultra Mega Resort", "Stadium 1", or a custom map such as "bbcity" (see Custom maps). diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index a16a5bd3..d887d295 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -62,8 +62,9 @@ int run() { check(token_refused(tokened), "An overlong steam_token was accepted"); save_config(reloaded); - // Server names: letters, digits, spaces and - _ [ ] ( ) only. - check(valid_server_name("Old Server") && valid_server_name("[EU] Skate_Park-2 (24x7)") && valid_server_name("a"), + // Server names: letters, digits, spaces and - _ / [ ] ( ) only. + check(valid_server_name("Old Server") && valid_server_name("[EU] Skate_Park-2 (24x7)") && valid_server_name("a") && + valid_server_name("EU/West 24/7") && !valid_server_name("///") && !valid_server_name("a\\b"), "A plain server name was refused"); check(!valid_server_name("") && !valid_server_name(std::string(65, 'a')) && !valid_server_name("Best! Server") && !valid_server_name("caf\xC3\xA9") && !valid_server_name("a.b") && !valid_server_name("x") && From a64ccdf2884a193839a073dcc3cfba35621198cb Mon Sep 17 00:00:00 2001 From: wildesPepega <68642699+wildesPepega@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:49:39 +0000 Subject: [PATCH 083/109] Add a Pterodactyl egg for the Linux dedicated server contrib/pterodactyl/egg-reskate-server.json installs the server from the release's launcher.json (server_linux tarball, checked by SHA-256), keeps the panel's port, query_port and steam_token in ReSkateServer.json, starts on "is up on" and stops with quit. Image ghcr.io/parkervcp/yolks:debian (Debian 13: glibc 2.41, OpenSSL 3, curl and tar for the self-update). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FN9ZCrWqg6MVuLbn65aVzX --- Server/README-linux.md | 6 ++ contrib/pterodactyl/README.md | 42 ++++++++++++++ contrib/pterodactyl/egg-reskate-server.json | 62 +++++++++++++++++++++ contrib/pterodactyl/install.sh | 51 +++++++++++++++++ 4 files changed, 161 insertions(+) create mode 100644 contrib/pterodactyl/README.md create mode 100644 contrib/pterodactyl/egg-reskate-server.json create mode 100644 contrib/pterodactyl/install.sh diff --git a/Server/README-linux.md b/Server/README-linux.md index 76645957..1534c083 100644 --- a/Server/README-linux.md +++ b/Server/README-linux.md @@ -111,6 +111,12 @@ sudo systemctl enable --now reskate-server The server writes its own `ReSkateServer.log` next to the binary; the console output goes to the journal (`journalctl -u reskate-server -f`). +## Pterodactyl + +`contrib/pterodactyl/egg-reskate-server.json` is an egg for Pterodactyl (and Pelican): it installs +the server from the latest release and keeps the panel's ports and `steam_token` in +`ReSkateServer.json`. See `contrib/pterodactyl/README.md`. + ## Updating A server from a release tarball updates itself the way the Windows one does: at startup and diff --git a/contrib/pterodactyl/README.md b/contrib/pterodactyl/README.md new file mode 100644 index 00000000..2cfcda86 --- /dev/null +++ b/contrib/pterodactyl/README.md @@ -0,0 +1,42 @@ +# Pterodactyl + +`egg-reskate-server.json` runs the Linux dedicated server on [Pterodactyl](https://pterodactyl.io) +(also works on Pelican). It installs the server from the ReSkate GitHub release and the server then +updates itself, as described in `Server/README-linux.md`. + +## Import + +1. Admin area → **Nests** → **Import Egg**, choose `egg-reskate-server.json` and a nest. +2. **Servers** → **Create New** with the **ReSkate Server** egg: + - the **primary allocation** is the game port (`port`), + - add a **second allocation** and put its port into **Query Port** (`query_port`). It must + differ from the game port. +3. 256 MB of memory is plenty for a typical server; give it about 300 MB of disk (the Steam + libraries are in the tarball). + +## What the egg does + +- **Install** (`install.sh`, also inlined in the egg): reads the release's `launcher.json`, + downloads the `server_linux` tarball, checks its SHA-256 and unpacks it into the server's + folder. `ReSkateServer.json`, `Mods` and the logs are not in the tarball, so **Reinstall** keeps + them. On the first install it writes the panel's ports into `ReSkateServer.json`; the server adds + every other setting with its default on its first start. +- **Start**: `./ReSkateServer`. The panel sees the server as running at `is up on`; **Stop** sends + `quit`, so it signs out of Steam cleanly. The console takes every server command. +- **Before each start** the panel writes `port`, `query_port` and `steam_token` into + `ReSkateServer.json`, so set those in the panel, not in the file. +- **Updates**: the server installs new releases itself once nobody is on (`auto_update`), using the + image's `curl` and `tar`, and carries on in the same process. Reinstalling also updates it. + +## Variables + +| Variable | Env | Default | | +|---|---|---|---| +| Query Port | `QUERY_PORT` | `27016` | Second UDP allocation, Steam server queries | +| Steam Token | `STEAM_TOKEN` | empty | Game server login token (App ID 3354750, one per server); see `steam_token` in `Server/README.txt` | +| Release | `RELEASE` | `latest` | `latest`, or a tag such as `v1.1.4` (1.1.4 or newer: older releases have no Linux server) | + +## Image + +`ghcr.io/parkervcp/yolks:debian` (Debian 13). The server needs glibc 2.38 or newer and OpenSSL 3 +(`libcrypto.so.3`); it uses `curl` for the global ban list and its updates, and `tar` to unpack them. diff --git a/contrib/pterodactyl/egg-reskate-server.json b/contrib/pterodactyl/egg-reskate-server.json new file mode 100644 index 00000000..e30c299b --- /dev/null +++ b/contrib/pterodactyl/egg-reskate-server.json @@ -0,0 +1,62 @@ +{ + "_comment": "Pterodactyl egg for the ReSkate dedicated server (Linux). See contrib/pterodactyl/README.md.", + "meta": { + "version": "PTDL_v2", + "update_url": "https://raw.githubusercontent.com/Dingo-Shenanigans/ReSkate/main/contrib/pterodactyl/egg-reskate-server.json" + }, + "exported_at": "2026-10-07T00:00:00+00:00", + "name": "ReSkate Server", + "author": "68642699+wildesPepega@users.noreply.github.com", + "description": "ReSkate dedicated server for skate. (Linux). Players find it in the in-game server browser or join with its code. Needs a primary UDP allocation (game port) and a second one for the query port. Updates itself from the ReSkate releases.", + "features": null, + "docker_images": { + "Debian 13": "ghcr.io/parkervcp/yolks:debian" + }, + "file_denylist": [], + "startup": "./ReSkateServer", + "config": { + "files": "{\n \"ReSkateServer.json\": {\n \"parser\": \"json\",\n \"find\": {\n \"port\": \"{{server.build.default.port}}\",\n \"query_port\": \"{{server.build.env.QUERY_PORT}}\",\n \"steam_token\": \"{{server.build.env.STEAM_TOKEN}}\"\n }\n }\n}", + "startup": "{\n \"done\": \"is up on \"\n}", + "logs": "{}", + "stop": "quit" + }, + "scripts": { + "installation": { + "script": "#!/bin/bash\n# ReSkate dedicated server (Linux) for Pterodactyl: the tarball the release's launcher.json\n# names (\"server_linux\"), checked against its SHA-256 and unpacked into the server's folder.\n# ReSkateServer.json, Mods and the logs are not in the tarball, so a reinstall keeps them.\nset -euo pipefail\napt-get update\napt-get install -y --no-install-recommends ca-certificates curl jq tar\n\nrepo=\"https://github.com/Dingo-Shenanigans/ReSkate\"\nif [ -z \"${RELEASE:-}\" ] || [ \"${RELEASE}\" = \"latest\" ]; then\n base=\"${repo}/releases/latest/download\"\nelse\n base=\"${repo}/releases/download/${RELEASE}\"\nfi\nserver=\"${SERVER_DIR:-/mnt/server}\"\nmkdir -p \"${server}\"\nwork=\"$(mktemp -d)\"\ntrap 'rm -rf \"${work}\"' EXIT\n\necho \"Reading ${base}/launcher.json\"\ncurl -fsSL --retry 3 -o \"${work}/launcher.json\" \"${base}/launcher.json\"\nurl=\"$(jq -r '.server_linux.url // empty' \"${work}/launcher.json\")\"\nsha=\"$(jq -r '.server_linux.sha256 // empty' \"${work}/launcher.json\")\"\nversion=\"$(jq -r '.server_linux.version // empty' \"${work}/launcher.json\")\"\nif [ -z \"${url}\" ] || [ -z \"${sha}\" ]; then\n echo \"This release has no Linux server (no \\\"server_linux\\\" in launcher.json). Use 1.1.4 or newer.\"\n exit 1\nfi\ncase \"${url}\" in\n asset:*) url=\"${base}/${url#asset:}\" ;;\n https://*) ;;\n *) echo \"launcher.json names no usable download: ${url}\"; exit 1 ;;\nesac\n\necho \"Downloading the ReSkate Linux server ${version}\"\ncurl -fSL --retry 3 -o \"${work}/server.tar.gz\" \"${url}\"\necho \"${sha} ${work}/server.tar.gz\" | sha256sum -c -\ntar -xzf \"${work}/server.tar.gz\" -C \"${server}\" --strip-components=1\nchmod +x \"${server}/ReSkateServer\"\n\n# Steam looks for steamclient.so in ~/.steam/sdk64 too (HOME is the server's folder in the\n# container); a link keeps pointing at the copy the self-update replaces.\nmkdir -p \"${server}/.steam/sdk64\"\nln -sf ../../steamclient.so \"${server}/.steam/sdk64/steamclient.so\"\n\n# First install: the panel's ports. The server adds every other setting with its default on\n# its first start; the panel writes the ports (and steam_token) again before each start.\nif [ ! -f \"${server}/ReSkateServer.json\" ]; then\n printf '{\\n \"port\": %s,\\n \"query_port\": %s\\n}\\n' \"${SERVER_PORT:-27015}\" \"${QUERY_PORT:-27016}\" > \"${server}/ReSkateServer.json\"\nfi\necho \"Installed the ReSkate Linux server ${version}.\"\n", + "container": "ghcr.io/parkervcp/installers:debian", + "entrypoint": "bash" + } + }, + "variables": [ + { + "name": "Query Port", + "description": "UDP port for Steam server queries (ping in the server browser). Add it as a second allocation; it must differ from the game port.", + "env_variable": "QUERY_PORT", + "default_value": "27016", + "user_viewable": true, + "user_editable": true, + "rules": "required|integer|between:1,65535", + "field_type": "text" + }, + { + "name": "Steam Token", + "description": "Optional Steam game server login token (steamcommunity.com/dev/managegameservers, App ID 3354750; one per server). With it the server keeps the same Steam ID every start and stays in the server browser when it shows only servers with a token. Empty: anonymous. Written to steam_token in ReSkateServer.json before each start.", + "env_variable": "STEAM_TOKEN", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string|alpha_num|max:64", + "field_type": "text" + }, + { + "name": "Release", + "description": "The ReSkate release to install: latest, or a tag such as v1.1.4 (1.1.4 or newer). Reinstall the server to apply. The server also updates itself to the latest release when nobody is on (auto_update in ReSkateServer.json).", + "env_variable": "RELEASE", + "default_value": "latest", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|regex:/^(latest|v[0-9][0-9A-Za-z.\\-]*)$/|max:32", + "field_type": "text" + } + ] +} diff --git a/contrib/pterodactyl/install.sh b/contrib/pterodactyl/install.sh new file mode 100644 index 00000000..8ad25ff6 --- /dev/null +++ b/contrib/pterodactyl/install.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# ReSkate dedicated server (Linux) for Pterodactyl: the tarball the release's launcher.json +# names ("server_linux"), checked against its SHA-256 and unpacked into the server's folder. +# ReSkateServer.json, Mods and the logs are not in the tarball, so a reinstall keeps them. +set -euo pipefail +apt-get update +apt-get install -y --no-install-recommends ca-certificates curl jq tar + +repo="https://github.com/Dingo-Shenanigans/ReSkate" +if [ -z "${RELEASE:-}" ] || [ "${RELEASE}" = "latest" ]; then + base="${repo}/releases/latest/download" +else + base="${repo}/releases/download/${RELEASE}" +fi +server="${SERVER_DIR:-/mnt/server}" +mkdir -p "${server}" +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT + +echo "Reading ${base}/launcher.json" +curl -fsSL --retry 3 -o "${work}/launcher.json" "${base}/launcher.json" +url="$(jq -r '.server_linux.url // empty' "${work}/launcher.json")" +sha="$(jq -r '.server_linux.sha256 // empty' "${work}/launcher.json")" +version="$(jq -r '.server_linux.version // empty' "${work}/launcher.json")" +if [ -z "${url}" ] || [ -z "${sha}" ]; then + echo "This release has no Linux server (no \"server_linux\" in launcher.json). Use 1.1.4 or newer." + exit 1 +fi +case "${url}" in + asset:*) url="${base}/${url#asset:}" ;; + https://*) ;; + *) echo "launcher.json names no usable download: ${url}"; exit 1 ;; +esac + +echo "Downloading the ReSkate Linux server ${version}" +curl -fSL --retry 3 -o "${work}/server.tar.gz" "${url}" +echo "${sha} ${work}/server.tar.gz" | sha256sum -c - +tar -xzf "${work}/server.tar.gz" -C "${server}" --strip-components=1 +chmod +x "${server}/ReSkateServer" + +# Steam looks for steamclient.so in ~/.steam/sdk64 too (HOME is the server's folder in the +# container); a link keeps pointing at the copy the self-update replaces. +mkdir -p "${server}/.steam/sdk64" +ln -sf ../../steamclient.so "${server}/.steam/sdk64/steamclient.so" + +# First install: the panel's ports. The server adds every other setting with its default on +# its first start; the panel writes the ports (and steam_token) again before each start. +if [ ! -f "${server}/ReSkateServer.json" ]; then + printf '{\n "port": %s,\n "query_port": %s\n}\n' "${SERVER_PORT:-27015}" "${QUERY_PORT:-27016}" > "${server}/ReSkateServer.json" +fi +echo "Installed the ReSkate Linux server ${version}." From 230b4ba6e47b39b2bba994820aed7f535c544867 Mon Sep 17 00:00:00 2001 From: Zachary Gladfelter Date: Wed, 7 Oct 2026 05:08:31 -0400 Subject: [PATCH 084/109] Trainer: teleport to the pause-map waypoint, onto the surface there PRACTICE > TELEPORT now shows the waypoint the player placed on the game's pause map, with a "Go to map waypoint" button. The console gets `trainer waypoint [info]` and `trainer ground `. Reading the waypoint (trainer_waypoint.cpp) - It is a point of interest in the map's registry, the one ReSkate's party markers register into (addr::native_party::map_manager), with kind DingoMapPOIType_Waypoint (3) and its world matrix at @0. - The registry is walked only while the TELEPORT card is drawn (the page notes it, like the class list) or the command runs, every 250 ms at most. A player who never opens the card pays nothing. - A registry the map has filled without a waypoint means it was removed; an empty or unreadable one keeps the last waypoint seen. A new level forgets it. Landing on the surface (trainer_landing.h) - The waypoint's height is not the ground: teleporting to it dropped the skater through the map. local_ground_height() casts the park editor's downward ray (editor::cast_surface) through the client physics world and takes the topmost hit. - The first ray starts at 1500 or 500 above the hint. A custom map can sit higher than that (a waypoint at Y 12 over ground at Y 1939 was seen in game), so a miss is cast again from 20000. - If collision there is still streaming in, the teleport goes to the hint's height and a 12 s watch puts the skater on the surface when it arrives (at most 6 corrections; the teleport is re-sent up to 3 times if it never arrives). - Teleports still go through go_to(), so they follow the host's noclip / teleport permission in a session. The decisions (ray spans, the watch, remember/forget) have no game in them and are tested by trainer_landing (DINGOSDK_BUILD_TRAINER_TESTS). README and CHANGELOG updated. --- CMakeLists.txt | 4 + Extension/Profile/local_profile_objects.cpp | 25 +++++ Extension/Profile/local_profile_runtime.h | 4 + Extension/Trainer/CHANGELOG.md | 6 ++ Extension/Trainer/README.md | 14 ++- Extension/Trainer/Test/landing_tests.cpp | 90 ++++++++++++++++ Extension/Trainer/trainer.cpp | 106 ++++++++++++++++++- Extension/Trainer/trainer.h | 7 ++ Extension/Trainer/trainer_commands.cpp | 2 + Extension/Trainer/trainer_landing.h | 107 ++++++++++++++++++++ Extension/Trainer/trainer_page.cpp | 7 ++ Extension/Trainer/trainer_view.cpp | 3 + Extension/Trainer/trainer_waypoint.cpp | 91 +++++++++++++++++ Extension/Trainer/trainer_waypoint.h | 16 +++ cmake/Runtime.cmake | 1 + 15 files changed, 479 insertions(+), 4 deletions(-) create mode 100644 Extension/Trainer/Test/landing_tests.cpp create mode 100644 Extension/Trainer/trainer_landing.h create mode 100644 Extension/Trainer/trainer_waypoint.cpp create mode 100644 Extension/Trainer/trainer_waypoint.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 34277fca..74d6dc0f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -211,6 +211,10 @@ if(DINGOSDK_BUILD_TRAINER_TESTS) add_executable(dingosdk_trainer_session_tests Extension/Trainer/Test/session_extras_tests.cpp Extension/Trainer/trainer_session.cpp) target_include_directories(dingosdk_trainer_session_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME trainer_session_extras COMMAND dingosdk_trainer_session_tests) + # Ground-safe arrivals: the downward ray, the landing watch, and the remembered map waypoint. + add_executable(dingosdk_trainer_landing_tests Extension/Trainer/Test/landing_tests.cpp) + target_include_directories(dingosdk_trainer_landing_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME trainer_landing COMMAND dingosdk_trainer_landing_tests) add_executable(dingosdk_trainer_asset_list Extension/Trainer/Test/asset_list.cpp) target_include_directories(dingosdk_trainer_asset_list PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_trainer_asset_list PRIVATE dingosdk_game_archives) diff --git a/Extension/Profile/local_profile_objects.cpp b/Extension/Profile/local_profile_objects.cpp index 4a49cc32..99ce3442 100644 --- a/Extension/Profile/local_profile_objects.cpp +++ b/Extension/Profile/local_profile_objects.cpp @@ -1,6 +1,8 @@ #include "runtime_internal.h" #include "Extension/Objects/local_placements_runtime.h" #include "Extension/Objects/ParkEditor/park_editor_runtime.h" +#include "Extension/Objects/ParkEditor/park_editor_surface.h" +#include #include "Extension/World/local_world_layers.h" namespace dingosdk { @@ -57,6 +59,29 @@ bool teleport_local_skater(const std::array& position) { r.position_teleport_at = GetTickCount64(); return true; } +std::optional local_ground_height(float x, float z, float top, float bottom) { + std::lock_guard lock(local_runtime().native_mutex); + auto& r = placements_runtime(); + if (!local_runtime().active || !r.context) return {}; + for (const auto v : {x, z, top, bottom}) + if (!std::isfinite(v) || std::abs(v) > 1e5f) return {}; + if (!(top > bottom)) return {}; + static std::uintptr_t api_base{}; + static editor::NativeSurfaceApi api; + if (api_base != local_runtime().base) { api = editor::surface_api(local_runtime().base); api_base = local_runtime().base; } + if (!api.ready) return {}; + // The client physics world, resolved from the client TLS context like the park editor's + // surface probe (the server context has no world). + std::array context{}; + r.context(context.data()); + if (!context[0]) return {}; + const auto world = api.world(context[0]); + if (!world) return {}; + // Closest hit from the top: the topmost surface (a roof or deck above the street wins). + const auto hit = editor::cast_surface(api, world, {x, top, z}, {x, bottom, z}); + if (!hit) return {}; + return (*hit)[1]; +} bool teleport_to_local_placed_object(std::string_view map, std::uint64_t token) { std::lock_guard lock(local_runtime().native_mutex); auto& r = placements_runtime(); diff --git a/Extension/Profile/local_profile_runtime.h b/Extension/Profile/local_profile_runtime.h index 17586388..5ddd2025 100644 --- a/Extension/Profile/local_profile_runtime.h +++ b/Extension/Profile/local_profile_runtime.h @@ -70,6 +70,10 @@ bool teleport_to_local_placed_object(std::string_view map, std::uint64_t token); // Teleports the local skater to a world position (sent within 5 s, when the skater can be // moved). False when the game's teleport is unavailable. bool teleport_local_skater(const std::array& position); +// The highest collision surface straight down at (x, z) between world heights `top` and +// `bottom`, from the client physics world (the park editor's native ray). Empty when nothing +// is there yet (collision still streaming) or the query is unavailable. Client update thread. +std::optional local_ground_height(float x, float z, float top, float bottom); // Small allowlisted progression commands, executed on the game update thread. bool set_local_progression(const std::vector& arguments); // Small ReSkate-owned booleans saved beside the profile under a "ReSkate." diff --git a/Extension/Trainer/CHANGELOG.md b/Extension/Trainer/CHANGELOG.md index 311b0919..082f1ea8 100644 --- a/Extension/Trainer/CHANGELOG.md +++ b/Extension/Trainer/CHANGELOG.md @@ -19,6 +19,12 @@ Changes made when the trainer moved into ReSkate itself. - **Guests are locked by the session's own switch**, from the moment they join, rather than by whether the host's tuning had arrived yet. A guest's class values also no longer come back after a map change in such a session. +- **Teleport to your map waypoint.** PRACTICE > TELEPORT shows the waypoint you placed on the + pause map, with a button to go there (`trainer waypoint [info]`). You land on the topmost + surface there, not at the waypoint's own height, which isn't the ground and could drop you + through the map. If collision there is still loading, a short watch puts you back on the + surface once it arrives. `trainer ground ` does the same for any spot. + The map's registry is only read while the TELEPORT card is on screen or the command runs. ## v0.3.0 - 2026-10-04 diff --git a/Extension/Trainer/README.md b/Extension/Trainer/README.md index 59753140..38d4d07f 100644 --- a/Extension/Trainer/README.md +++ b/Extension/Trainer/README.md @@ -9,7 +9,7 @@ telemetry HUD. It ships no game data: the list of values is built at run time fr | Tab | What you get | |---|---| | **TUNE** | Three lists: REALISTIC, FUN and EVERYTHING. Each opens on the dials: every built-in preset as one slider (Ollie height, Push speed, Body flip and spin speed, Bail resistance, Grind lock-on and friction, on-foot jump and sprint, glide, torpedo), where 1 is the game's own and the preset's button jumps to the preset; then the switches (Auto Push, No Speed Wobble, Smooth Surfaces, Long Wheelbase, Never bail), the trick sliders (flip trick speed, no comply, boneless, hippy and off-board jump height) and the values themselves: a short plainly named list, or under EVERYTHING the whole table (the physics tuning's values, one multiplier per curve and graph, and the 314 values of the game's other tuning classes) with search, groups, "only what I changed", locks, your own saved presets and a preset a map applies every time it loads. Changes apply while you skate. Values the game was never found or seen reading are hidden unless you ask for them. **Reset everything**, above the tabs, puts the game back as it shipped. | -| **PRACTICE** | Game speed and pause, five marker slots per map (save / go / clear), return to the marker after a bail, teleport to coordinates, copy your position (game or Blender axes). | +| **PRACTICE** | Game speed and pause, five marker slots per map (save / go / clear), return to the marker after a bail, teleport to coordinates or to the waypoint you placed on the pause map (onto the surface there), copy your position (game or Blender axes). | | **MAP & HUD** | Speed and air-time HUD, a read-out after every jump (takeoff speed and angle, height, distance, drop, landing speed, spin and flip), telemetry recording to CSV, and whatever the map's author ships for the trainer. | The HUD, the jump read-out and the controller shortcuts are off until you switch them on (MAP & HUD, @@ -21,7 +21,7 @@ Controller, once switched on: hold **LB + RB**, then D-pad **up** saves the mark Everything is also a console command (`~`): `trainer open [tune|practice|map|realistic|fun|everything]`, `trainer status`, `trainer set `, `trainer find `, `trainer preset apply|remove `, `trainer dial `, `trainer reset |all|tricks|presets|everything`, `trainer marker save|go|clear [slot]`, -`trainer tp `, `trainer where`, `trainer jumps`, `trainer dump`, `trainer selftest`. +`trainer tp `, `trainer waypoint [info]`, `trainer ground `, `trainer where`, `trainer jumps`, `trainer dump`, `trainer selftest`. ## For map makers: `trainer.json` @@ -73,6 +73,13 @@ It unlocks no cosmetics or entitlements. snapshots (`trainer_view.cpp`). - Jumps are measured from the skater's position each client tick: the game's own physics state says when the skater is in the air. Wipeouts come from the same state, which the no-bail hook already sees. +- The pause map's waypoint is a point of interest in the map's registry (the one ReSkate's party + markers use, `addr::native_party::map_manager`), kind `DingoMapPOIType_Waypoint` (3). The registry + is walked only while the TELEPORT card is on screen or `trainer waypoint` runs (`trainer_waypoint.cpp`). +- A waypoint's height is not the ground. `trainer waypoint` and `trainer ground` cast the park + editor's downward ray through the client physics world and land on the topmost surface; if + collision there is still streaming in, a short watch puts the skater back on it once it arrives. + The decisions are in `trainer_landing.h`, tested by `dingosdk_trainer_landing_tests`. - Settings, presets, markers: `%LOCALAPPDATA%\ReSkate\trainer\trainer.json`. ## Checking a build @@ -83,7 +90,8 @@ ReSkateLauncher.exe --no-gui --no-update ``` then read the `trainer selftest:` lines in `logs\ReSkate.log`. `dingosdk_trainer_tuning_dump ` (CMake option `DINGOSDK_BUILD_TRAINER_TESTS`) lists the tuning values without the game. +folder>` (CMake option `DINGOSDK_BUILD_TRAINER_TESTS`) lists the tuning values without the game; +the same option builds the `trainer_session_extras` and `trainer_landing` tests (`ctest -R trainer`). ## Known limits diff --git a/Extension/Trainer/Test/landing_tests.cpp b/Extension/Trainer/Test/landing_tests.cpp new file mode 100644 index 00000000..9e8f1843 --- /dev/null +++ b/Extension/Trainer/Test/landing_tests.cpp @@ -0,0 +1,90 @@ +// The decisions behind a ground-safe arrival (trainer_landing.h): where the downward ray +// starts, the watch that puts a skater who sinks back on the surface, and when a pause-map +// waypoint is remembered or forgotten. +#include "Extension/Trainer/trainer_landing.h" + +#include +#include + +namespace { +using namespace dingosdk::trainer::landing; +int failures = 0; +void check(bool condition, const std::string &message) { + if (!condition) { std::cerr << "FAIL: " << message << '\n'; ++failures; } +} +Sample at(std::uint64_t now, Vec3 position, std::optional ground, float vertical = 0.0f, bool may_resend = true) { + return Sample{.now = now, .position = position, .vertical = vertical, .ground = ground, .may_resend = may_resend}; +} +} // namespace + +int main() { + // ---- the ray -------------------------------------------------------------------------- + check(first_ray(12.0f).top == ray_ceiling && first_ray(12.0f).bottom == ray_floor, "a low hint starts at the usual ceiling"); + check(first_ray(1800.0f).top == 2300.0f, "a high hint starts 500 above it"); + check(std::isfinite(first_ray(std::nanf("")).top), "a broken hint still gives a usable ray"); + // A waypoint at Y 12 over a custom map's ground at Y 1939 (seen in game): the first ray + // starts below that ground and misses, so the retry covers what lies above the first one. + const auto retry = retry_ray(first_ray(12.0f)); + check(retry && retry->top >= 1939.0f && retry->bottom == ray_ceiling, "a miss is cast again from far above"); + check(!retry_ray(Ray{ray_retry_ceiling, 0.0f}), "nothing to retry from the top of the world"); + + // ---- the landing watch ---------------------------------------------------------------- + { + auto w = start(100.0f, 200.0f, 50.5f, 1000); + check(step(w, at(1000, {0, 0, 0}, 50.0f)) == Step::wait, "the teleport gets a moment to arrive"); + check(step(w, at(2600, {100.0f, 50.6f, 200.0f}, 50.0f)) == Step::landed, "standing on the surface ends the watch"); + check(!w.active, "a landed watch is over"); + check(step(w, at(2800, {100.0f, -20.0f, 200.0f}, 50.0f)) == Step::wait, "an ended watch does nothing"); + } + { + auto w = start(100.0f, 200.0f, 50.5f, 1000); + check(step(w, at(2600, {100.0f, 30.0f, 200.0f}, 50.0f)) == Step::put_back, "sinking under the surface puts the skater back"); + check(w.fixes == 1, "a correction is counted"); + check(step(w, at(2700, {100.0f, 30.0f, 200.0f}, 50.0f)) == Step::wait, "the next look waits for the correction to arrive"); + check(step(w, at(4200, {100.0f, 50.4f, 200.0f}, 50.0f)) == Step::landed, "then it lands"); + } + { + auto w = start(100.0f, 200.0f, 50.5f, 1000); + check(step(w, at(2600, {100.0f, 30.0f, 200.0f}, std::nullopt)) == Step::wait, "no ground yet: collision is streaming, wait"); + check(w.active, "still watching"); + check(step(w, at(2800, {100.0f, 49.0f, 200.0f}, 50.0f, -8.0f)) == Step::wait, "still falling onto it: not landed yet"); + } + { + auto w = start(100.0f, 200.0f, 50.5f, 1000); + check(step(w, at(2600, {0, 0, 0}, 50.0f)) == Step::wait, "not arrived, too early to resend"); + check(step(w, at(3600, {0, 0, 0}, 50.0f, 0.0f, false)) == Step::wait, "never resend while the game ignores teleports"); + check(step(w, at(3800, {0, 0, 0}, 50.0f)) == Step::resend, "a teleport that never arrived is sent again"); + check(w.resends == max_resends - 1, "the resend is counted"); + check(step(w, at(4000, {0, 0, 0}, 50.0f)) == Step::wait, "resends are spaced out"); + int sent = 1; + for (std::uint64_t now = 4000; now < 60000 && w.active; now += 200) + if (step(w, at(now, {0, 0, 0}, 50.0f)) == Step::resend) ++sent; + check(sent == max_resends, "at most three resends"); + check(!w.active, "a teleport that never arrives ends the watch"); + } + { + auto w = start(100.0f, 200.0f, 50.5f, 1000); + int fixes = 0; + Step last{}; + for (std::uint64_t now = 2600; now < 60000 && w.active; now += 100) + if ((last = step(w, at(now, {100.0f, 0.0f, 200.0f}, 50.0f))) == Step::put_back) ++fixes; + check(fixes == max_fixes && last == Step::ended, "a skater who keeps sinking is corrected six times, then left alone"); + } + { + auto w = start(100.0f, 200.0f, 50.5f, 1000); + check(step(w, at(1000 + watch_ms + 1, {100.0f, 70.0f, 200.0f}, 50.0f)) == Step::ended, "the watch times out"); + } + + // ---- the remembered waypoint ---------------------------------------------------------- + const std::optional old = Vec3{1.0f, 2.0f, 3.0f}; + const Vec3 fresh{4.0f, 5.0f, 6.0f}; + check(remembered(old, Reading{.read = true, .typed = 58, .waypoint = fresh}) == fresh, "a placed waypoint replaces the old one"); + check(!remembered(old, Reading{.read = true, .typed = 58, .waypoint = std::nullopt}), "a filled map without one: it was removed"); + check(remembered(old, Reading{.read = true, .typed = 0, .waypoint = std::nullopt}) == old, "an empty map says nothing: keep it"); + check(remembered(old, Reading{}) == old, "an unreadable registry says nothing: keep it"); + check(!remembered(std::nullopt, Reading{}), "nothing seen, nothing remembered"); + + if (failures) std::cerr << failures << " failure(s)\n"; + else std::cout << "landing: all checks passed\n"; + return failures ? 1 : 0; +} diff --git a/Extension/Trainer/trainer.cpp b/Extension/Trainer/trainer.cpp index c67aabde..511285e6 100644 --- a/Extension/Trainer/trainer.cpp +++ b/Extension/Trainer/trainer.cpp @@ -3,6 +3,8 @@ #include "trainer_jump.h" #include "trainer_presets.h" #include "trainer_session.h" +#include "trainer_landing.h" +#include "trainer_waypoint.h" #include "Engine/Core/Json/json.h" #include "Engine/Core/Log/logging.h" #include "Engine/Core/Platform/path_text.h" @@ -146,6 +148,14 @@ struct State { bool wipeouts_known{}; // the wipeout count the session started with was read int open_tab{}; std::uint32_t pad_previous{}; + // The pause map's waypoint. The registry is read only while the player has a use for the + // answer (the TELEPORT card is on screen, or `trainer waypoint`), and the waypoint is + // forgotten on a new level: it belonged to the old one. + std::optional waypoint; + std::string waypoint_detail, waypoint_level; + std::uint64_t waypoint_wanted_until{}, waypoint_next_read{}; + // A ground-snapped arrival being watched (trainer_landing.h). + landing::Watch landing; Motion motion; Telemetry telemetry; std::ofstream log; @@ -930,6 +940,7 @@ void enter_map(const std::string &level) { s.motion = {}; s.telemetry.last = {}; s.telemetry.best = {}; + s.landing = {}; s.telemetry.top_speed = 0; s.return_at = 0; s.profile_due = false; @@ -963,6 +974,73 @@ std::string go_to(const Vec3 &position, const std::string &what) { return "Teleporting to " + what + "."; } +// ---- ground-safe arrivals ------------------------------------------------------------- +// The topmost collision surface at x, z (trainer_landing.h for the ray), from the client +// physics world. Empty while collision there is still streaming in. +std::optional topmost_ground(float x, float z, float hint_y) { + const auto first = landing::first_ray(hint_y); + if (const auto hit = local_ground_height(x, z, first.top, first.bottom)) return hit; + if (const auto retry = landing::retry_ray(first)) return local_ground_height(x, z, retry->top, retry->bottom); + return std::nullopt; +} + +// Teleports onto the surface at x, z rather than to a raw height: a map waypoint's height is +// not the ground, and arriving under it drops the skater through the map. +std::string land_at(float x, float z, float hint_y, const std::string &what) { + auto &s = state(); + const auto ground = topmost_ground(x, z, hint_y); + // Collision far away may not be loaded yet: go to the hint's height, and the landing + // watch puts the skater on the surface once it streams in. + const Vec3 to{x, ground ? *ground + landing::stand_height : hint_y + 2.0f, z}; + const auto answer = go_to(to, what); + if (answer.starts_with("error")) return answer; + s.landing = landing::start(x, z, to[1], GetTickCount64()); + say(logging::Level::info, ground ? std::format("Trainer: landing on the surface at {:.1f}, {:.1f}, {:.1f}.", to[0], to[1], to[2]) + : std::format("Trainer: no collision at {:.1f}, {:.1f} yet; going to height {:.1f} and waiting for the ground.", x, z, to[1])); + const auto stem = answer.substr(0, answer.size() - 1); + return ground ? stem + std::format(" (ground at {:.1f}).", *ground) + : stem + " (the ground there is still loading; you'll be put on it when it arrives)."; +} + +void update_landing(std::uint64_t now) { + auto &s = state(); + auto &w = s.landing; + if (!w.active || now < w.next || !s.telemetry.skater) return; + const auto &p = s.telemetry.position; + landing::Sample sample{.now = now, .position = p, .vertical = s.telemetry.vertical, .ground = std::nullopt, .may_resend = true}; + const float dx = p[0] - w.x, dz = p[2] - w.z; + if (dx * dx + dz * dz <= landing::arrived_radius * landing::arrived_radius) sample.ground = topmost_ground(p[0], p[2], p[1]); + switch (landing::step(w, sample)) { + case landing::Step::resend: { + const auto ground = topmost_ground(w.x, w.z, w.y); + (void)go_to({w.x, ground ? *ground + landing::stand_height : w.y, w.z}, "the same spot"); + say(logging::Level::info, "Trainer: the teleport hadn't arrived; sent it again."); + break; + } + case landing::Step::put_back: + (void)go_to({p[0], *sample.ground + landing::stand_height, p[2]}, "the surface"); + say(logging::Level::info, std::format("Trainer: sank to {:.1f} under the surface at {:.1f}; put back on it.", p[1], *sample.ground)); + break; + case landing::Step::landed: + say(logging::Level::info, std::format("Trainer: landed at {:.1f}, {:.1f}, {:.1f}.", p[0], p[1], p[2])); + break; + case landing::Step::ended: + say(logging::Level::info, std::format("Trainer: landing watch ended at {:.1f}, {:.1f}, {:.1f} after {} correction(s).", p[0], p[1], p[2], w.fixes)); + break; + case landing::Step::wait: break; + } +} + +void refresh_waypoint() { + auto &s = state(); + const auto read = read_map_waypoint(s.base); + s.waypoint_detail = read.detail; + if (const auto next = landing::remembered(s.waypoint, read.reading); next != s.waypoint) { + s.waypoint = next; + s.view_due = true; + } +} + // ---- telemetry ------------------------------------------------------------------------- std::int64_t clock_now() { LARGE_INTEGER value; @@ -1367,6 +1445,8 @@ void build_view() { next->map_note = s.map_file.note; next->map_preset = s.map_file.preset.empty() ? std::string{} : s.map_file.preset_name.empty() ? "Map preset" : s.map_file.preset_name; next->spots = s.map_file.spots; + next->waypoint_set = s.waypoint.has_value(); + if (s.waypoint) next->waypoint = *s.waypoint; next->open_serial = s.open_serial; next->open_tab = s.open_tab; if (const auto found = s.maps.find(s.map); found != s.maps.end()) { @@ -1399,9 +1479,20 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool playing, const std::s sync_session(); s.boosts = boosts_in_force(); if (const auto key = lower(playing ? level : std::string{}); key != s.map) enter_map(key); + if (const auto key = lower(level); !key.empty() && key != s.waypoint_level) { + s.waypoint_level = key; + s.waypoint.reset(); + s.view_due = true; + } + if (take_teleport_card_shown()) s.waypoint_wanted_until = now + 1000; + if (now < s.waypoint_wanted_until && now >= s.waypoint_next_read) { + s.waypoint_next_read = now + 250; + refresh_waypoint(); + } if (playing) { observe(base, client, now); shortcuts(); + update_landing(now); if (s.return_at && now >= s.return_at) { s.return_at = 0; const auto &marker = current_map().markers[static_cast(s.slot)]; @@ -1603,6 +1694,19 @@ std::string run(std::string_view verb, const std::vector &a) { } return "error: usage: trainer marker save|go|clear [slot]"; } + if (v == "ground") { + const auto x = number(arg(0)), z = number(arg(1)); + if (!x || !z) return "error: usage: trainer ground (teleport to the topmost surface there)"; + return land_at(static_cast(*x), static_cast(*z), s.telemetry.position[1], "the ground there"); + } + if (v == "waypoint") { + refresh_waypoint(); + if (lower(arg(0)) == "info") + return "Map waypoint: " + (s.waypoint ? std::format("{:.1f}, {:.1f}, {:.1f}", (*s.waypoint)[0], (*s.waypoint)[1], (*s.waypoint)[2]) : std::string("none")) + + " (" + s.waypoint_detail + ")."; + if (!s.waypoint) return "error: no waypoint on the map. Open the pause map and place one first."; + return land_at((*s.waypoint)[0], (*s.waypoint)[2], (*s.waypoint)[1], "your map waypoint"); + } if (v == "tp") { const auto x = number(arg(0)), y = number(arg(1)), z = number(arg(2)); if (!x || !y || !z) return "error: usage: trainer tp "; @@ -1716,7 +1820,7 @@ std::string run(std::string_view verb, const std::vector &a) { s.test.until = GetTickCount64() + 500; return "Self test started; see the log for \"trainer selftest:\" lines."; } - return "error: trainer status|open|set|freeze|reset|find|preset|slot|marker|tp|spot|option|profile|jumps|where|states|dump|selftest"; + return "error: trainer status|open|set|freeze|reset|find|preset|slot|marker|tp|ground|waypoint|spot|option|profile|jumps|where|states|dump|selftest"; } } // namespace diff --git a/Extension/Trainer/trainer.h b/Extension/Trainer/trainer.h index b0cb44a4..6aa72dca 100644 --- a/Extension/Trainer/trainer.h +++ b/Extension/Trainer/trainer.h @@ -80,6 +80,9 @@ struct View { // The loaded map and what its author ships for the trainer (Mods//trainer.json). std::string map, map_note, map_preset, profile_preset; std::vector spots; + // The waypoint placed on the game's pause map (trainer_waypoint.h), if any. + bool waypoint_set{}; + std::array waypoint{}; // `trainer open `: each new serial opens the menu on the trainer page at that tab. std::uint64_t open_serial{}; int open_tab{}; @@ -117,6 +120,10 @@ void publish(const Telemetry &) noexcept; // for a player who has a use for it: this is one. Any thread; the game thread takes the note. void note_class_list_shown() noexcept; bool take_class_list_shown() noexcept; +// The menu is drawing the TELEPORT card, which shows the pause map's waypoint. The map's +// registry is read only while it is. Any thread; the game thread takes the note. +void note_teleport_card_shown() noexcept; +bool take_teleport_card_shown() noexcept; // Game thread (trainer.cpp). // Each client tick; `playing` while a local skater can exist, `level` the loaded level asset. diff --git a/Extension/Trainer/trainer_commands.cpp b/Extension/Trainer/trainer_commands.cpp index ae01487b..de4b74c9 100644 --- a/Extension/Trainer/trainer_commands.cpp +++ b/Extension/Trainer/trainer_commands.cpp @@ -22,6 +22,8 @@ void register_trainer_commands(Commands ®istry) { {"slot", "Select a marker slot", "<1-5>", false}, {"marker", "Save, go to or clear a marker", "save|go|clear [slot]", false}, {"tp", "Teleport the skater", " ", false}, + {"waypoint", "Teleport to the waypoint placed on the pause map, onto the surface there", "[info]", false}, + {"ground", "Teleport to the topmost surface at a map position", " ", false}, {"spot", "Go to one of the map author's spots", "", false}, {"option", "Trainer options", "hud|hud_jump|auto_return|pad|log 0|1, return_delay , hippy_height|nocomply_height|boneless_height|offboard_height|flip_speed ", true}, {"profile", "The preset this map applies on load", "set |clear", false}, diff --git a/Extension/Trainer/trainer_landing.h b/Extension/Trainer/trainer_landing.h new file mode 100644 index 00000000..d6127725 --- /dev/null +++ b/Extension/Trainer/trainer_landing.h @@ -0,0 +1,107 @@ +#pragma once +// The decisions behind a ground-safe arrival (the pause-map waypoint, `trainer ground`) and +// the remembered waypoint. They have no game in them, so the trainer tests run them on their +// own (Test/landing_tests.cpp); trainer.cpp feeds them positions, times and ray results. +#include +#include +#include +#include +#include + +namespace dingosdk::trainer::landing { +using Vec3 = std::array; + +// "The topmost surface at x, z" is a downward ray: its closest hit is the highest surface (a +// deck or a roof wins over the street under it). The first ray starts well above the height +// the caller knows of; a custom map can sit higher than that, so a miss is cast once more from +// far above, down to where the first one started. +constexpr float ray_ceiling = 1500.0f, ray_floor = -1500.0f, ray_retry_ceiling = 20000.0f; +// Above the surface: the game's teleport settles the skater from here. +constexpr float stand_height = 0.5f; +struct Ray { float top, bottom; }; +inline Ray first_ray(float hint_y) noexcept { + const float hint = std::isfinite(hint_y) ? std::clamp(hint_y, ray_floor, ray_retry_ceiling - 500.0f) : 0.0f; + return {std::max(ray_ceiling, hint + 500.0f), ray_floor}; +} +inline std::optional retry_ray(const Ray &first) noexcept { + if (first.top >= ray_retry_ceiling) return std::nullopt; + return Ray{ray_retry_ceiling, first.top}; +} + +// After a ground-snapped teleport, collision at the spot may still be streaming in. The +// skater is watched for a while and put back on the surface if it sinks through it. +constexpr std::uint64_t watch_ms = 12000, first_look_ms = 1500, look_every_ms = 150, after_fix_ms = 1500; +constexpr std::uint64_t resend_every_ms = 2500, resend_extends_ms = 8000; +constexpr int max_fixes = 6, max_resends = 3; +constexpr float arrived_radius = 30.0f, sunk_below = 1.0f, landed_within = 2.5f, settled_speed = 1.5f; + +struct Watch { + bool active{}; + float x{}, z{}, y{}; // where the skater was sent + std::uint64_t until{}, next{}, resend_at{}; + int fixes{}, resends{}; +}; +inline Watch start(float x, float z, float y, std::uint64_t now) noexcept { + return Watch{.active = true, .x = x, .z = z, .y = y, .until = now + watch_ms, .next = now + first_look_ms, + .resend_at = now + resend_every_ms, .fixes = 0, .resends = max_resends}; +} + +struct Sample { + std::uint64_t now{}; + Vec3 position{}; + float vertical{}; // vertical speed, up is positive + std::optional ground; // the topmost surface at the skater's x, z, if known yet + bool may_resend{true}; // the game will act on a teleport now (not paused on a menu) +}; +enum class Step { + wait, // nothing to do yet + resend, // the teleport never arrived: send it to the watch's x, y, z again + put_back, // sank under the surface: teleport to the ground at the skater's x, z + landed, // standing on the surface; the watch is over + ended, // gave up (timed out or corrected too often); the watch is over +}; +inline Step step(Watch &w, const Sample &s) noexcept { + if (!w.active || s.now < w.next) return Step::wait; + w.next = s.now + look_every_ms; + if (s.now > w.until || w.fixes >= max_fixes) { + w.active = false; + return Step::ended; + } + const float dx = s.position[0] - w.x, dz = s.position[2] - w.z; + if (dx * dx + dz * dz > arrived_radius * arrived_radius) { + if (w.resends > 0 && s.may_resend && s.now >= w.resend_at) { + --w.resends; + w.resend_at = s.now + resend_every_ms; + w.until = std::max(w.until, s.now + resend_extends_ms); + return Step::resend; + } + return Step::wait; + } + if (!s.ground) return Step::wait; // collision still streaming in + if (s.position[1] < *s.ground - sunk_below) { + ++w.fixes; + w.next = s.now + after_fix_ms; + return Step::put_back; + } + if (std::abs(s.position[1] - *s.ground) < landed_within && std::abs(s.vertical) < settled_speed) { + w.active = false; + return Step::landed; + } + return Step::wait; +} + +// One read of the pause map's point-of-interest registry. +struct Reading { + bool read{}; // the registry was found and walked + unsigned typed{}; // map POIs whose kind was read (0: the map has not built them) + std::optional waypoint; // the player's waypoint, if one is placed +}; +// What to remember after a read. A registry the map has filled but without a waypoint means +// the player removed it; one that is missing, unreadable or empty says nothing, so the last +// waypoint seen is kept. +inline std::optional remembered(const std::optional &before, const Reading &r) noexcept { + if (r.waypoint) return r.waypoint; + if (r.read && r.typed > 0) return std::nullopt; + return before; +} +} // namespace dingosdk::trainer::landing diff --git a/Extension/Trainer/trainer_page.cpp b/Extension/Trainer/trainer_page.cpp index ff850b5b..8153785b 100644 --- a/Extension/Trainer/trainer_page.cpp +++ b/Extension/Trainer/trainer_page.cpp @@ -470,6 +470,13 @@ void practice_tab(SkateMenu &menu, const Model &model, const CallbacksV3 &callba end_card(); begin_card(menu, "teleport", "TELEPORT"); + // The waypoint line is why the map's registry is read at all: only while this is drawn. + trainer::note_teleport_card_shown(); + info(menu, "Map waypoint", view.waypoint_set ? std::format("{:.1f}, {:.1f}, {:.1f}", view.waypoint[0], view.waypoint[1], view.waypoint[2]) + : "none - place one on the pause map"); + ImGui::BeginDisabled(!view.waypoint_set || !telemetry.skater); + if (ImGui::Button("Go to map waypoint")) trainer_command(menu, callbacks, "waypoint"); + ImGui::EndDisabled(); if (telemetry.skater) info(menu, "You are at", std::format("{:.2f}, {:.2f}, {:.2f}", telemetry.position[0], telemetry.position[1], telemetry.position[2])); field(menu, "X, Y, Z"); diff --git a/Extension/Trainer/trainer_view.cpp b/Extension/Trainer/trainer_view.cpp index d716fd9d..1c37a72d 100644 --- a/Extension/Trainer/trainer_view.cpp +++ b/Extension/Trainer/trainer_view.cpp @@ -11,6 +11,7 @@ struct Feed { std::shared_ptr view = std::make_shared(); Telemetry telemetry; std::atomic class_list_shown{}; + std::atomic teleport_card_shown{}; }; Feed &feed() { static auto *value = new Feed; @@ -40,4 +41,6 @@ void publish(const Telemetry &next) noexcept { } void note_class_list_shown() noexcept { feed().class_list_shown.store(true, std::memory_order_release); } bool take_class_list_shown() noexcept { return feed().class_list_shown.exchange(false, std::memory_order_acq_rel); } +void note_teleport_card_shown() noexcept { feed().teleport_card_shown.store(true, std::memory_order_release); } +bool take_teleport_card_shown() noexcept { return feed().teleport_card_shown.exchange(false, std::memory_order_acq_rel); } } // namespace dingosdk::trainer diff --git a/Extension/Trainer/trainer_waypoint.cpp b/Extension/Trainer/trainer_waypoint.cpp new file mode 100644 index 00000000..8487832f --- /dev/null +++ b/Extension/Trainer/trainer_waypoint.cpp @@ -0,0 +1,91 @@ +#include "trainer_waypoint.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Abi/native_data.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/native_party.h" +#include +#include +#include + +// The pause map keeps every point of interest in one registry (the map POI manager, the same +// one ReSkate's party markers register into: native_party_hooks.cpp update_map_markers). A +// waypoint the player places with the map cursor is a POI whose kind (MapObjectData @404) is +// DingoMapPOIType_Waypoint (3); its world transform is the matrix at @0. +namespace dingosdk::trainer { +namespace { +namespace party = addr::native_party; +using Address = std::uintptr_t; +using Handle = std::uint64_t; +constexpr std::uint32_t kind_waypoint = 3; +constexpr std::uint32_t kind_offset = 404; +constexpr std::uint32_t max_buckets = 65536, max_entries = 20000; + +template bool get(Address address, T &value) { return address && memory::peek(address, value); } + +Address model_type(Address base, Address manager, Handle handle) { + const auto record = reinterpret_cast
(base + party::model_record)(manager, handle, 0); + if (!record) return 0; + return reinterpret_cast
(base + party::model_type)(manager, handle, record, 0); +} + +bool sane(const std::array &world) { + for (const auto i : {12, 13, 14}) + if (!std::isfinite(world[i]) || std::abs(world[i]) > 1e6f) return false; + return true; +} + +MapWaypointRead walk(Address base) { + MapWaypointRead out; + Address map{}, vtable{}, manager{}; + if (!get(base + party::map_manager, map) || !map) { out.detail = "no map POI manager"; return out; } + if (!get(map, vtable) || vtable != base + party::map_manager_vtable) { out.detail = "map POI manager type differs"; return out; } + if (!get(map + 0x18, manager) || !manager) { out.detail = "no map model manager"; return out; } + auto &n = game::native_data().models; + if (!n.value || !n.lock || !n.unlock) { out.detail = "native model API unavailable"; return out; } + game::ModelWriteLock lock(manager); + Address table{}; + std::uint32_t buckets{}, count{}; + if (!get(map + 0x48, table) || !get(map + 0x50, buckets) || !get(map + 0x54, count) || !table || + buckets > max_buckets || count > max_entries) { out.detail = "map POI registry unreadable"; return out; } + const auto poi_type = base + party::map_poi_type; + std::map kinds; + unsigned visited{}; + for (std::uint32_t i = 0; i < buckets && visited < count; ++i) { + Address node{}; + if (!get(table + i * 8, node)) break; + while (node && visited++ < count) { + Handle handle{}; + if (!get(node, handle)) break; + if (handle && model_type(base, manager, handle) == poi_type) { + if (const auto value = n.value(manager, handle, 0, 0)) { + std::uint32_t kind{}; + if (get(value + kind_offset, kind)) { + ++kinds[kind]; + ++out.reading.typed; + std::array world{}; + if (kind == kind_waypoint && !out.reading.waypoint && get(value, world) && sane(world)) + out.reading.waypoint = landing::Vec3{world[12], world[13], world[14]}; + } + } + } + if (!get(node + 8, node)) break; + } + } + out.reading.read = true; + out.detail = std::format("{} map POIs", visited); + for (const auto &[kind, number] : kinds) out.detail += std::format(", kind {} x{}", kind, number); + return out; +} +} // namespace + +MapWaypointRead read_map_waypoint(std::uintptr_t base) noexcept { + if (!base) return {}; + try { + return walk(base); + } catch (const std::exception &e) { + return MapWaypointRead{.reading = {}, .detail = e.what()}; + } catch (...) { + return MapWaypointRead{.reading = {}, .detail = "map POI walk failed"}; + } +} +} // namespace dingosdk::trainer diff --git a/Extension/Trainer/trainer_waypoint.h b/Extension/Trainer/trainer_waypoint.h new file mode 100644 index 00000000..98c5c31a --- /dev/null +++ b/Extension/Trainer/trainer_waypoint.h @@ -0,0 +1,16 @@ +#pragma once +// The waypoint the player placed on the pause map (DingoMapPOIType_Waypoint), read from the +// map's point-of-interest registry so the trainer can teleport to it. Game thread only. +#include "trainer_landing.h" +#include +#include + +namespace dingosdk::trainer { +struct MapWaypointRead { + landing::Reading reading; + std::string detail; // what the walk saw, for `trainer waypoint info` and the log +}; +// One walk of the registry under the map model manager's lock. Cheap, but it walks every +// POI, so the trainer only calls it while the player has a use for the answer. +MapWaypointRead read_map_waypoint(std::uintptr_t base) noexcept; +} // namespace dingosdk::trainer diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index e9f4e7bc..790d8710 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -116,6 +116,7 @@ add_library(dingosdk_runtime SHARED Extension/Trainer/trainer_classes.cpp Extension/Trainer/trainer_commands.cpp Extension/Trainer/trainer_session.cpp + Extension/Trainer/trainer_waypoint.cpp Extension/Skater/offboard_flight.cpp Extension/Skater/camera_observer.cpp Extension/Boot/offline_boot.cpp From 3e3c749d305c8ea63e235f23a973078ab4807dca Mon Sep 17 00:00:00 2001 From: "BLACKETT, Lenny" Date: Wed, 7 Oct 2026 11:50:48 +0200 Subject: [PATCH 085/109] Name the file and the reason when Linux cannot open it for SHA-256 --- Engine/Core/Platform/launcher_pe.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Engine/Core/Platform/launcher_pe.cpp b/Engine/Core/Platform/launcher_pe.cpp index 4517e929..64a72a4c 100644 --- a/Engine/Core/Platform/launcher_pe.cpp +++ b/Engine/Core/Platform/launcher_pe.cpp @@ -6,6 +6,7 @@ #include #include #else +#include #include #include #endif @@ -221,8 +222,15 @@ std::string sha256_file(const fs::path& path) { } return output; #else + // Name the file and the reason, as open_to_read does on Windows: a server admin reading + // "cannot open file" in the log has no idea which file, or what to fix. std::ifstream input(path, std::ios::binary); - if (!input) detail::fail("Cannot open file for SHA-256"); + if (!input) { + const int error = errno; + throw std::runtime_error("Cannot open " + path_utf8(path.filename()) + " in " + + path_utf8(path.parent_path()) + " for SHA-256: " + + (error ? std::strerror(error) : "unknown error")); + } SHA256_CTX context; if (!SHA256_Init(&context)) detail::fail("Cannot initialize SHA-256"); std::vector bytes(1024 * 1024); From 07fef3820d682a34820dc29d5a76043e9a193f66 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Wed, 7 Oct 2026 10:03:54 -0500 Subject: [PATCH 086/109] Limit how many objects each player can place (protocol 43) Limit how many objects each player can place (protocol 43) - Servers and lobby hosts set how many objects each player may have placed: object_limit in ReSkateServer.json and the "objects" command, "Objects per player" in the host options. Default 100; 0 is no limit - The host and a server's admins are not limited - At the limit a player's game refuses another object, from the native object tool and the park editor, and the menu shows how many they have placed - Enforced by the host or server, which shows everyone no more than the limit of each player's layout; objects already shown keep their place - The limit travels in the roster, which takes the protocol to version 43: builds before this cannot join sessions with builds after it - The protocol tests link again (the server browser needs the identity lists since the official servers change) --- CMakeLists.txt | 4 +- Engine/Game/Multiplayer/object_placement.h | 18 ++++++++ Engine/Game/Multiplayer/session_model.h | 3 ++ Extension/Multiplayer/Net/protocol.cpp | 5 +++ Extension/Multiplayer/Net/protocol.h | 4 +- Extension/Multiplayer/Session/object_state.h | 10 +++++ Extension/Multiplayer/Session/session.cpp | 2 + .../Multiplayer/Session/session_commands.cpp | 26 ++++++++++-- .../Multiplayer/Session/session_internal.h | 4 ++ .../Multiplayer/Session/session_receive.cpp | 1 + .../Multiplayer/Session/session_send.cpp | 3 ++ .../Multiplayer/Session/session_view.cpp | 6 +++ Extension/Multiplayer/Test/mesh_tests.cpp | 3 ++ Extension/Multiplayer/Test/protocol_tests.cpp | 42 +++++++++++++++++++ .../Objects/ParkEditor/park_editor_lobby.cpp | 18 +++++++- .../ParkEditor/park_editor_operations.cpp | 3 ++ .../ParkEditor/park_editor_preview.cpp | 9 ++++ .../Objects/ParkEditor/park_editor_runtime.h | 8 ++++ .../Objects/local_placements_runtime.cpp | 8 ++++ .../UI/NativeMenu/native_menu_multiplayer.cpp | 11 ++++- Extension/UI/Overlay/multiplayer_session.cpp | 22 ++++++++++ Server/README.txt | 4 ++ Server/Test/server_config_tests.cpp | 9 ++++ Server/server_config.cpp | 3 ++ Server/server_config.h | 2 + Server/server_host.cpp | 14 ++++++- 26 files changed, 233 insertions(+), 9 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 34277fca..639d84f5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -142,8 +142,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) diff --git a/Engine/Game/Multiplayer/object_placement.h b/Engine/Game/Multiplayer/object_placement.h index e53010a5..443b9b1b 100644 --- a/Engine/Game/Multiplayer/object_placement.h +++ b/Engine/Game/Multiplayer/object_placement.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include @@ -27,4 +28,21 @@ inline std::optional parse_object_placement(std::string_view ar : current == ObjectPlacement::host_only ? ObjectPlacement::nobody : ObjectPlacement::everyone; return {}; } +// How many objects each player may have placed in a session, set by the host or the server: +// 0 is no limit beyond the protocol's own (max_object_limit). The host and a server's admins +// are not held to it. A player over it keeps what everyone already sees of theirs; only the +// objects past the limit are left out. +inline constexpr unsigned max_object_limit = 1024; +// What a server or a host starts with, until they choose otherwise. +inline constexpr unsigned default_object_limit = 100; +inline constexpr bool valid_object_limit(std::uint64_t value) noexcept { return value <= max_object_limit; } +// Accepts a number from 1 to max_object_limit, and off|none|0 for no limit. +inline std::optional parse_object_limit(std::string_view argument) noexcept { + if (argument == "off" || argument == "none") return 0U; + unsigned value{}; + const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), value); + if (argument.empty() || parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || !valid_object_limit(value)) + return {}; + return value; +} } // namespace dingosdk diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 1e24eae1..9ef549f1 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -130,6 +130,9 @@ struct MultiplayerModel { std::vector bans; unsigned tps = multiplayer_default_tps; ObjectPlacement object_placement = ObjectPlacement::everyone; + // Objects each player may have placed in this session (0: no limit), the limit this + // player is held to (0 for the host and a server's admins), and how many they have placed. + unsigned object_limit{}, object_limit_own{}, objects_placed{}; // 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 diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index 4a553958..26c7b57d 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -558,6 +558,8 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 if (!valid_multiplayer_tps(p.tps)) throw std::invalid_argument("Invalid session TPS"); w.integer(p.tps, 1); w.integer(static_cast(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. @@ -864,6 +866,9 @@ std::optional decode(std::span bytes) noexcept { const auto placement = r.integer(1); if (!valid_object_placement(placement)) return {}; p.object_placement = static_cast(placement); + const auto object_limit = r.integer(2); + if (!valid_object_limit(object_limit)) return {}; + p.object_limit = static_cast(object_limit); const auto forced = r.integer(1); if (forced > 1) return {}; p.force_world_layers = forced != 0; diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index d10d0106..25c3540e 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -25,7 +25,7 @@ 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 = 42; +constexpr std::uint16_t protocol_version = 43; 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; @@ -171,6 +171,8 @@ 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{}; diff --git a/Extension/Multiplayer/Session/object_state.h b/Extension/Multiplayer/Session/object_state.h index 20554da1..822197b4 100644 --- a/Extension/Multiplayer/Session/object_state.h +++ b/Extension/Multiplayer/Session/object_state.h @@ -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 limited_layout(std::vector layout, const std::map &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. diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 606d065c..697b2100 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -73,6 +73,8 @@ 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; diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index 018ba9aa..51779545 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -27,6 +27,10 @@ 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. @@ -288,6 +292,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) { @@ -541,7 +560,7 @@ bool own_mark_command(std::string_view action) { bool queue_command(std::string_view action, std::string_view argument, std::string_view password) { 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 != "distances" && action != "object-placement" && action != "object-limit" && action != "kick" && action != "clear-objects" && action != "nametags" && action != "nametag-style" && action != "chat-visible" && action != "chat-filter" && action != "chat-bubbles" && action != "chat-bubbles-own" && action != "chat-bubbles-distance" && action != "chat-bubbles-duration" && action != "chat-bubbles-history" && @@ -581,7 +600,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")))) { @@ -716,7 +735,7 @@ 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 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}, @@ -919,6 +938,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; diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 1d40d7ae..3229f371 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -146,6 +146,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 @@ -242,6 +243,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; @@ -433,6 +435,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); diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index b2b28d57..b1cda91e 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -202,6 +202,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; diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index 5f8be1dd..4e88dc4e 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -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(); } @@ -429,6 +431,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; diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index cd6bf37a..7644e761 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -50,6 +50,8 @@ void load_host_preferences(Session &s) { p.voice_range = value->get(); if (const auto value = number("Host.ObjectPlacement"); value && *value >= 0 && valid_object_placement(static_cast(*value))) p.placement = static_cast(*value); + if (const auto value = number("Host.ObjectLimit"); value && *value >= 0 && valid_object_limit(static_cast(*value))) + p.object_limit = static_cast(*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(p.distances.half_rate_return)}, {"Host.Distance.LowStart", static_cast(p.distances.low_rate_start)}, {"Host.ObjectPlacement", static_cast(p.placement)}, + {"Host.ObjectLimit", static_cast(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(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; diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index c6bf74c2..20e7a401 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -29,6 +29,9 @@ void remove_remote_network_objects(std::uint64_t owner, std::uint64_t epoch) { std::string network_object_status() { return {}; } bool simulated_placement_allowed = true; void set_lobby_object_placement_allowed(bool allowed) { simulated_placement_allowed = allowed; } +unsigned simulated_object_limit{}; +void set_lobby_object_limit(unsigned limit) noexcept { simulated_object_limit = limit; } +unsigned lobby_object_limit() noexcept { return simulated_object_limit; } unsigned simulated_guest_wipes{}; bool clear_lobby_guest_objects() { ++simulated_guest_wipes; return true; } } diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index 029e0dca..8aed8e1f 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -721,6 +721,7 @@ void dedicated_server_codec() { roster.members = {{server, 10, "My server"}, {player, 20, "Skater", true}, {other, 30, "Other"}}; roster.voice_range = 450; roster.guest_noclip = false; + roster.object_limit = 50; Packet teleport; teleport.kind = PacketKind::teleport; teleport.session = 9; teleport.epoch = 10; teleport.map = 11; teleport.source = server; teleport.teleport = {612.5f, 199.25f, -1075.75f}; @@ -744,6 +745,47 @@ void dedicated_server_codec() { "Server roster, admin flags or voice range lost"); check(decoded && !decoded->guest_noclip && decoded->guest_no_bail && decoded->guest_boosts, "Guest noclip / No Bail / boost permissions lost"); + // The limit on each player's objects rides in the roster too; one past the protocol's own is refused. + check(decoded && decoded->object_limit == 50, "The object limit was lost"); + { + auto unlimited = roster; + unlimited.object_limit = 0; + const auto back = decode(encode(unlimited)); + check(back && back->object_limit == 0, "No object limit did not stay none"); + auto over = roster; + over.object_limit = dingosdk::max_object_limit + 1; + bool refused{}; + try { + encode(over); + } catch (const std::exception &) { + refused = true; + } + check(refused, "An object limit past the protocol's was sent"); + check(dingosdk::parse_object_limit("25") == 25U && dingosdk::parse_object_limit("off") == 0U && + dingosdk::parse_object_limit("1024") == 1024U && !dingosdk::parse_object_limit("1025") && + !dingosdk::parse_object_limit("") && !dingosdk::parse_object_limit("-1") && !dingosdk::parse_object_limit("ten"), + "Object limits were not read"); + // What a host shows of a layout under a limit: what is already shown stays, the rest fills up in order. + const auto object = [](std::uint64_t id) { + dingosdk::NetworkObject value; + value.id = id; + value.item = "own_bk_test"; + return value; + }; + const auto ids = [](const std::vector &layout) { + std::vector out; + for (const auto &entry : layout) out.push_back(entry.id); + return out; + }; + const std::vector uploaded{object(1), object(2), object(3), object(4), object(5)}; + std::map shown{{4, object(4)}, {5, object(5)}}; + check(ids(limited_layout(uploaded, {}, 0)) == std::vector{1, 2, 3, 4, 5} && + ids(limited_layout(uploaded, {}, 9)) == std::vector{1, 2, 3, 4, 5}, + "A layout within the limit was cut"); + check(ids(limited_layout(uploaded, {}, 3)) == std::vector{1, 2, 3}, "A layout over the limit was not cut to it"); + check(ids(limited_layout(uploaded, shown, 3)) == std::vector{4, 5, 1}, + "Objects already shown lost their place to newer ones"); + } // Parties ride in the roster: each has one leader and at least two members; a server is in none. auto party = roster; party.members[1].party = party.members[2].party = 7; diff --git a/Extension/Objects/ParkEditor/park_editor_lobby.cpp b/Extension/Objects/ParkEditor/park_editor_lobby.cpp index f0087339..13d6e1d1 100644 --- a/Extension/Objects/ParkEditor/park_editor_lobby.cpp +++ b/Extension/Objects/ParkEditor/park_editor_lobby.cpp @@ -5,10 +5,26 @@ #include namespace dingosdk { -namespace { std::atomic placement_allowed{true}; } +namespace { +std::atomic placement_allowed{true}; +std::atomic object_limit{}; +} bool lobby_object_placement_allowed() noexcept { return placement_allowed.load(std::memory_order_acquire); } +void set_lobby_object_limit(unsigned limit) noexcept { object_limit.store(limit, std::memory_order_release); } +unsigned lobby_object_limit() noexcept { return object_limit.load(std::memory_order_acquire); } using namespace profile_runtime; using namespace profile_runtime::park_editor_detail; +// Not for callers that hold the native mutex: they have the layout, and count it themselves. +std::size_t lobby_object_count() { + std::lock_guard lock(local_runtime().native_mutex); + auto &r = placements_runtime(); + const auto found = r.document.maps.find(r.map); + return found == r.document.maps.end() ? 0 : found->second.size(); +} +bool lobby_object_limit_reached() { + const auto limit = lobby_object_limit(); + return limit && lobby_object_count() >= limit; +} void set_lobby_object_guest(bool guest) { std::lock_guard lock(local_runtime().native_mutex); auto &r = placements_runtime(); diff --git a/Extension/Objects/ParkEditor/park_editor_operations.cpp b/Extension/Objects/ParkEditor/park_editor_operations.cpp index 61691474..78fec0b7 100644 --- a/Extension/Objects/ParkEditor/park_editor_operations.cpp +++ b/Extension/Objects/ParkEditor/park_editor_operations.cpp @@ -105,6 +105,9 @@ std::string edit_local_park(std::string_view operation, std::string_view map, st auto object = supplied; if (r.next_id == UINT64_MAX || target.size() >= 1024) throw std::runtime_error("Park object limit reached."); + // The session's own limit on each player (the mutex is held: count here). + if (const auto limit = lobby_object_limit(); limit && target.size() >= limit) + throw std::runtime_error(object_limit_notice); object.id = r.next_id++; if (!e.assets || std::none_of(e.assets->begin(), e.assets->end(), [&](const auto &item) { return item.key == object.item; })) diff --git a/Extension/Objects/ParkEditor/park_editor_preview.cpp b/Extension/Objects/ParkEditor/park_editor_preview.cpp index e1bdfbcf..706c7a9b 100644 --- a/Extension/Objects/ParkEditor/park_editor_preview.cpp +++ b/Extension/Objects/ParkEditor/park_editor_preview.cpp @@ -66,6 +66,11 @@ bool queue_local_park_paste(const EditorPasteRequest &request) { auto target = r.document.maps[r.map]; if (target.size() + request.objects.size() > 1024 || r.next_id > UINT64_MAX - request.objects.size()) return false; + // The session's own limit on each player: copies that would pass it are not made. + if (const auto limit = lobby_object_limit(); limit && target.size() + request.objects.size() > limit) { + e.status = object_limit_notice; + return false; + } auto next_id = r.next_id; for (const auto © : request.objects) { if (std::none_of(e.assets->begin(), e.assets->end(), @@ -103,6 +108,10 @@ bool queue_local_park_preview(const EditorPreviewRequest &request) { profile::PlacedObject object; std::uint64_t entity{}; if (placing) { + if (const auto limit = lobby_object_limit(); limit && layout.size() >= limit) { + e.status = object_limit_notice; + return false; + } if (layout.size() >= 1024 || r.next_id == UINT64_MAX || !e.assets || std::none_of(e.assets->begin(), e.assets->end(), [&](const auto &asset) { return asset.key == request.item; })) diff --git a/Extension/Objects/ParkEditor/park_editor_runtime.h b/Extension/Objects/ParkEditor/park_editor_runtime.h index a10faea5..48729186 100644 --- a/Extension/Objects/ParkEditor/park_editor_runtime.h +++ b/Extension/Objects/ParkEditor/park_editor_runtime.h @@ -12,6 +12,14 @@ void set_park_mod_list_visible(bool visible) noexcept; // on disconnect; existing placed objects remain in the world. void set_lobby_object_placement_allowed(bool enabled); bool lobby_object_placement_allowed() noexcept; +// The session's limit on this player's objects (object_placement.h), 0 for none: the host's +// own game and a server admin's are given none. Adding an object is refused at the limit. +void set_lobby_object_limit(unsigned limit) noexcept; +unsigned lobby_object_limit() noexcept; +// Objects this player has on the current map, and whether one more would pass the limit. +std::size_t lobby_object_count(); +bool lobby_object_limit_reached(); +inline constexpr char object_limit_notice[] = "You have placed as many objects as this session allows each player."; bool queue_local_park_surface(const EditorSurfaceRequest &); bool queue_local_park_preview(const EditorPreviewRequest &); bool queue_local_park_selection(const EditorSelectionRequest &); diff --git a/Extension/Objects/local_placements_runtime.cpp b/Extension/Objects/local_placements_runtime.cpp index 2f8f14a1..9d727bc2 100644 --- a/Extension/Objects/local_placements_runtime.cpp +++ b/Extension/Objects/local_placements_runtime.cpp @@ -311,6 +311,14 @@ void placement_message_hook(std::uintptr_t manager, const void* message) { PlacementBatch batch; batch.native = true; { PreserveError preserve; read(reinterpret_cast(message) + 0xc, batch.type); } + if (placement_adds_objects(batch.type) && lobby_object_placement_allowed() && lobby_object_limit_reached()) { + // As below: the request never reaches the server, so nothing is created. + PreserveError preserve; + placements_runtime().status = object_limit_notice; + dingosdk::logging::event(dingosdk::logging::Channel::objects, dingosdk::Json{ + {"event", "local_placement_over_limit"}, {"type", batch.type}, {"limit", lobby_object_limit()}}.dump().c_str()); + return; + } if (placement_adds_objects(batch.type) && !lobby_object_placement_allowed()) { // Drop the request before the server sees it: no entity is created. PreserveError preserve; diff --git a/Extension/UI/NativeMenu/native_menu_multiplayer.cpp b/Extension/UI/NativeMenu/native_menu_multiplayer.cpp index 773c3140..25db9a22 100644 --- a/Extension/UI/NativeMenu/native_menu_multiplayer.cpp +++ b/Extension/UI/NativeMenu/native_menu_multiplayer.cpp @@ -168,7 +168,7 @@ void process_actions(const Context& context, const MultiplayerModel& model) { SecureZeroMemory(password.data(), password.size()); s.feedback = "Password is too long. Use a shorter password."; continue; } - if ((cmd == "kick" || cmd == "object-placement" || cmd == "clear-objects" || cmd == "world-layer-sync" || + if ((cmd == "kick" || cmd == "object-placement" || cmd == "object-limit" || cmd == "clear-objects" || cmd == "world-layer-sync" || cmd == "voice-allow" || cmd == "noclip-allow" || cmd == "nobail-allow" || cmd == "boosts-allow" || cmd == "tuning-enforce") && !model.hosting) { @@ -382,6 +382,15 @@ void render_section(const Context& context, const MultiplayerModel& model, Secti ? std::string("Admins only") : std::string(object_placement_name(model.object_placement)); add_button(context, side, "editor", "Object placement: " + placement, model.hosting ? "object-placement" : "", "next", false, 136.f); + { + // The same round numbers as the overlay's list, one press each. + static constexpr std::array limits{0, 10, 25, 50, 100, 250, 500}; + const auto found = std::find(limits.begin(), limits.end(), model.object_limit); + const auto next = limits[found == limits.end() ? 0 : static_cast(found - limits.begin() + 1) % limits.size()]; + add_button(context, side, "object-limit", + "Objects per player: " + (model.object_limit ? std::to_string(model.object_limit) : std::string("No limit")), + model.hosting ? "object-limit" : "", next ? std::to_string(next) : std::string("off"), false, 136.f); + } if (model.hosting) add_button(context, side, "clear-objects", "Delete all guest objects", "clear-objects", {}, false, 136.f); add_button(context, side, "guest-noclip", std::string("Guest noclip: ") + (model.guest_noclip ? "Allowed" : "Off"), diff --git a/Extension/UI/Overlay/multiplayer_session.cpp b/Extension/UI/Overlay/multiplayer_session.cpp index 14e06b62..62c18bc2 100644 --- a/Extension/UI/Overlay/multiplayer_session.cpp +++ b/Extension/UI/Overlay/multiplayer_session.cpp @@ -608,6 +608,28 @@ void session_page(SkateMenu &menu, const Model &model) { : "Only the host can build. Your editor and object tools are locked.") : mp.dedicated ? "Nobody can build. Existing objects stay." : "Nobody can build, including the host. Existing objects stay."); + // How many objects each player may have placed: a few round numbers, and whatever the + // server's file or console set that is not one of them. + { + static constexpr std::array limits{0, 10, 25, 50, 100, 250, 500}; + const auto found = std::find(limits.begin(), limits.end(), mp.object_limit); + const std::string custom = std::to_string(mp.object_limit); + std::array labels{"No limit", "10", "25", "50", "100", "250", "500", custom.c_str()}; + int picked = static_cast(found - limits.begin()); + field(menu, "Objects per player"); + ImGui::BeginDisabled(!controls_session(mp)); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::Combo("##object-limit", &picked, labels.data(), found == limits.end() ? 8 : 7) && picked < 7) + send_private(menu, "object-limit", picked ? std::to_string(limits[static_cast(picked)]) : std::string("off"), unused, false); + ImGui::EndDisabled(); + const char *exempt = mp.dedicated ? "Admins" : "The host"; + if (!mp.object_limit) note("Everyone can place as many objects as they like."); + else if (mp.object_limit_own) + note(("You have placed " + std::to_string(mp.objects_placed) + " of " + std::to_string(mp.object_limit_own) + + " objects. Delete one to place another once you reach the limit.").c_str()); + else note((std::string("Each player can have ") + std::to_string(mp.object_limit) + " objects placed. " + exempt + + (mp.dedicated ? " are not limited." : " is not limited.")).c_str()); + } if (controls_session(mp)) { note(mp.dedicated ? "Enforced by the server, so modified clients cannot get around it." : "Enforced by the host, so modified clients cannot get around it."); diff --git a/Server/README.txt b/Server/README.txt index b3faa69b..4c6d3b50 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -119,6 +119,9 @@ voice_chat Allow voice chat. voice_range How far proximity voice reaches, 50-1000 m. distances When far-away players update less often (metres). object_placement everyone, admins (only admins can build), or nobody. +object_limit How many objects each player may have placed, 1-1024 + (default 100), or 0 for no limit. Admins are not limited. + A player at the limit deletes one to place another. noclip, no_bail, Let players use noclip (and tp) / No Bail / the forward and up boosts boosts (default true; admins always can). enforce_tuning Players skate with the game's own Gameplay/SkatePhysicsTuning, @@ -163,6 +166,7 @@ and change voice, distances, placement and kicks from the Multiplayer menu. tps 20|30|60|120 voice on|off voice-range distances placement everyone|admins|nobody clear-objects + objects |off How many objects each player may have placed. noclip on|off nobail on|off boosts on|off What players may use (admins always can). tuning on|off Everyone on the game's own physics tuning. diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index d887d295..96072bed 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -60,6 +60,15 @@ int run() { check(token_refused(tokened), "A steam_token with other characters was accepted"); tokened.steam_token = std::string(65, 'A'); check(token_refused(tokened), "An overlong steam_token was accepted"); + // object_limit: 100 unless set (0 is no limit), and kept on a rewrite. + check(has("object_limit") && config.object_limit == 100, "object_limit is not a new setting of 100"); + auto limited = reloaded; + limited.object_limit = 50; + save_config(limited); + check(load_config(file).object_limit == 50, "object_limit was not kept"); + limited.object_limit = 0; + save_config(limited); + check(load_config(file).object_limit == 0, "No object limit became the default again"); save_config(reloaded); // Server names: letters, digits, spaces and - _ / [ ] ( ) only. diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 4c370e4f..518f3cb8 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -50,6 +50,7 @@ Json to_json(const ServerConfig &c) { distances["low_rate_start"] = c.distances.low_rate_start; root["distances"] = std::move(distances); root["object_placement"] = placement_text(c.object_placement); + root["object_limit"] = c.object_limit; root["noclip"] = c.noclip; root["no_bail"] = c.no_bail; root["boosts"] = c.boosts; @@ -179,6 +180,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector | maps | name | password | welcome | listed on|off\n" "tps 20|30|60|120 | voice on|off | voice-range <50-1000> | distances \n" - "placement everyone|admins|nobody | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" + "placement everyone|admins|nobody | objects |off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" "tpall [player] | tphere | votes [map|kick|tod on|off|] | vote-cancel\n" "map-pool [add|remove |clear] | rotation [|off]\n" "park | layer-sync on|off | layer default|on|off | tod \n" @@ -261,6 +261,7 @@ void Host::send_roster() { p.voice_range = config_.voice_range; p.distances = config_.distances; p.object_placement = config_.object_placement; + p.object_limit = config_.object_limit; p.guest_noclip = config_.noclip; p.guest_no_bail = config_.no_bail; p.guest_boosts = config_.boosts; @@ -698,6 +699,8 @@ void Host::sync_objects() { (config_.object_placement == ObjectPlacement::host_only && is_admin(id)))) { auto layout = guest->objects.layout(); std::erase_if(layout, [&](const auto &object) { return guest->cleared.contains(object.id); }); + // No more of a player's objects than the server allows each of them; admins are not limited. + if (!is_admin(id)) layout = limited_layout(std::move(layout), guest->shared.objects(), config_.object_limit); if (config_.activity_log) activity_.objects(id, guest->shared.layout(), layout); guest->shared.replace(layout); guest->shared_from = guest->objects.revision(); @@ -840,6 +843,7 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { const auto verb = lower(action); // The in-game menu's names for the same settings. const std::string name = verb == "voice-allow" ? "voice" : verb == "object-placement" ? "placement" + : verb == "object-limit" ? "objects" : verb == "world-layer-sync" ? "layer-sync" : verb == "noclip-allow" ? "noclip" : verb == "nobail-allow" ? "nobail" : verb == "boosts-allow" ? "boosts" : verb == "tuning-enforce" ? "tuning" : verb; @@ -1190,6 +1194,14 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { : *policy == ObjectPlacement::host_only ? "Only admins can place objects. Everyone else's are frozen." : "Object placement is off. Existing objects stay."); } + if (name == "objects") { + const auto limit = parse_object_limit(argument); + if (!limit) return "objects <1-" + std::to_string(max_object_limit) + ">|off"; + config_.object_limit = *limit; + for (auto &[id, guest] : guests_) guest->shared_from = 0; // look at every layout again + return changed(*limit ? "Each player can place up to " + std::to_string(*limit) + " objects. Admins are not limited." + : std::string("Players can place as many objects as they like.")); + } if (name == "votes") { // votes | votes on|off| | votes seconds|cooldown const auto [what_text, value_text] = split(argument); From c98c3f32076a1a035a2fa11257cabd3dd738587c Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Wed, 7 Oct 2026 14:43:45 -0500 Subject: [PATCH 087/109] Keep busy dedicated servers playable; hold stale skaters instead of rebuilding them No protocol change: a server built from this still takes 1.1.4 players. Server: what each player is sent - A crowd limit: each player is sent at most crowd_budget poses a second (default 600, 0 for none; "crowd |off"). Everyone at 5 a second at least, the nearest at the full rate, the next nearest at 10. Before, everyone within full-rate distance was sent at the full rate, which a crowd of 40 turned into more than a connection carries - A skater's sound goes only to players within the full-rate distance, and in a crowd only from the nearest; it went to everyone - A player who joins, or arrives after a map change, is shown the others about seven a second, nearest first, and none while what they were already sent is still on its way. Before, every outfit and every stream went at once (and to everyone at once after a map change) - Whole pose and sound states every 8 seconds instead of 2, on the source's own clock, and slower pose rates sent on that clock too, so recipients share one patch and compression (DeltaCache) instead of one each. The settings survive a map change - Object updates wait for a connection that is behind Server: Steam connections - send_rate (default 900 KB/s, "rate ") is set as both Steam's minimum and maximum, for open connections and each new one. With only the maximum raised a connection stayed at Steam's 256 KB/s; set high, what was lost was resent until the connection was full of resends (about 1.1 MB/s reaches a player through the relays) - A 4 MB send buffer, and a refused must-arrive message no longer ends the player's session at once: 15 seconds of refusals do (30 for object updates). A full queue used to drop everyone who was behind together Server: seeing what is going on - "net [player]": every connection's ping, delivery, queue and rate, the traffic by kind (poses, sound, voice, outfits, objects), who uploads most, and how busy the server's loop is - A [network] line in the log every minute - A leave or failed join says how it ended: closed by the player's game with Steam's reason, connection lost, or closed by the server and why - The queue time no longer prints Steam's "unknown" as a number Server: other - Reserved slots: reserved_slots and reserved in ReSkateServer.json, and the "reserved" command; admins always have one - The speed check flags a game at about 1.5x and over (was 1.06x), and tells only that player; for the admins it is in the log Game - A remote skater with no pose for a second stays where it was for up to eight seconds instead of being destroyed and rebuilt three seconds later. On a busy server one slow frame did that to many players at once, and the rebuilding made the next frames slower still - Every waiting message is read each frame (at most 128 were) - On an official server the role tags (Dev, Staff, Homie, Creator) show for every player, not only the few the game is connected to directly; community servers keep the old rule - The server browser searches Steam at most every 15 seconds (45 after an empty answer) and keeps the last list for 15 minutes when Steam returns nothing, up from 2 - The game's own connections send at up to 900 KB/s --- Engine/Game/Multiplayer/tick_settings.h | 6 + Extension/Multiplayer/Net/delta_codec.cpp | 3 +- Extension/Multiplayer/Net/delta_codec.h | 12 + Extension/Multiplayer/Net/protocol.h | 2 + .../Multiplayer/Remote/playback_buffers.cpp | 3 + Extension/Multiplayer/Session/room.h | 35 ++ Extension/Multiplayer/Session/session.cpp | 8 +- .../Multiplayer/Session/session_internal.h | 8 +- .../Multiplayer/Session/session_receive.cpp | 3 +- Extension/Multiplayer/Steam/steam_lanes.h | 4 + .../Steam/steam_server_browser.cpp | 11 +- .../Multiplayer/Steam/steam_transport.cpp | 183 ++++++-- Extension/Multiplayer/Steam/steam_transport.h | 24 + .../Multiplayer/Test/bandwidth_tests.cpp | 38 ++ Server/README.txt | 32 +- Server/Test/server_config_tests.cpp | 39 ++ Server/Test/speed_check_tests.cpp | 7 +- Server/server_config.cpp | 24 + Server/server_config.h | 11 + Server/server_host.cpp | 443 ++++++++++++++++-- Server/server_host.h | 36 +- Server/speed_check.h | 4 +- 22 files changed, 865 insertions(+), 71 deletions(-) diff --git a/Engine/Game/Multiplayer/tick_settings.h b/Engine/Game/Multiplayer/tick_settings.h index 3f534404..43fa9b37 100644 --- a/Engine/Game/Multiplayer/tick_settings.h +++ b/Engine/Game/Multiplayer/tick_settings.h @@ -18,4 +18,10 @@ constexpr bool valid_pose_interval(std::uint32_t interval) noexcept { if (interval == multiplayer_pose_interval(rate)) return true; return false; } +// The most poses a second one player is sent on a dedicated server, whatever the crowd +// around them (Extension/Multiplayer/Session/room.h, crowd_limits); 0: no limit. +inline constexpr unsigned crowd_pose_budget = 600, min_crowd_budget = 300, max_crowd_budget = 20000; +constexpr bool valid_crowd_budget(unsigned budget) noexcept { + return !budget || (budget >= min_crowd_budget && budget <= max_crowd_budget); +} } // namespace dingosdk diff --git a/Extension/Multiplayer/Net/delta_codec.cpp b/Extension/Multiplayer/Net/delta_codec.cpp index 2ad5c1d9..1d71a4c6 100644 --- a/Extension/Multiplayer/Net/delta_codec.cpp +++ b/Extension/Multiplayer/Net/delta_codec.cpp @@ -43,7 +43,8 @@ WireUpdate DeltaSender::build(const Packet &p, std::span 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()); diff --git a/Extension/Multiplayer/Net/delta_codec.h b/Extension/Multiplayer/Net/delta_codec.h index 7ca5fbfc..456ced83 100644 --- a/Extension/Multiplayer/Net/delta_codec.h +++ b/Extension/Multiplayer/Net/delta_codec.h @@ -32,6 +32,8 @@ class DeltaSender { }; std::map bases_; std::uint64_t clock_{}; + std::uint64_t refresh_us_ = 2000000; + bool aligned_{}; WireUpdate build(const Packet &, std::span raw, std::span wire, DeltaCache *cache) const; @@ -44,6 +46,16 @@ class DeltaSender { WireUpdate prepare(const Packet &, std::span raw, std::span wire, DeltaCache &cache) const; void sent(const Packet &, WireUpdate &&); + // 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 { diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 25c3540e..85792c60 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -322,6 +322,8 @@ class PoseBuffer { bool sample(std::uint64_t now_us, Pose &out) const; std::optional 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 sample_remote(std::uint64_t now_us); PosePlayback playback() const { return playback_; } void clear(); diff --git a/Extension/Multiplayer/Remote/playback_buffers.cpp b/Extension/Multiplayer/Remote/playback_buffers.cpp index 7d1ac485..79f461d9 100644 --- a/Extension/Multiplayer/Remote/playback_buffers.cpp +++ b/Extension/Multiplayer/Remote/playback_buffers.cpp @@ -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; diff --git a/Extension/Multiplayer/Session/room.h b/Extension/Multiplayer/Session/room.h index f1688390..49958abf 100644 --- a/Extension/Multiplayer/Session/room.h +++ b/Extension/Multiplayer/Session/room.h @@ -1,7 +1,9 @@ #pragma once #include "Extension/Multiplayer/Net/protocol.h" #include +#include #include +#include #include namespace dingosdk::multiplayer { @@ -76,7 +78,40 @@ 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 }; +// 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. +struct CrowdLimits { + float half = std::numeric_limits::infinity(), low = std::numeric_limits::infinity(); +}; +inline CrowdLimits crowd_limits(std::span 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(squared.size()); + if (count * full <= budget) return {}; + std::sort(squared.begin(), squared.end()); + const unsigned spare = budget > count * low ? budget - count * low : 0; + const unsigned at_full = std::min(count, spare * 2 / 3 / (full - low)); + const unsigned at_half = std::min(count - at_full, (spare - at_full * (full - low)) / (half - low)); + 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::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) { diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 697b2100..08304d2c 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -503,13 +503,19 @@ 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 && 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.appearance.value()) { if (spawning) { native_pass = true; p.applied_cosmetics = 0; // the new actor wears no recipe yet diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 3229f371..343bd1fb 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -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" @@ -324,9 +325,12 @@ inline bool dedicated_host(const Session &s) { return s.mode == Mode::join && ga // 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. +// 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; + 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 diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index b1cda91e..3353b013 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -73,7 +73,8 @@ 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) { - return s.mode == Mode::join && sender.member.id != s.host_id && steam_vouched(s, sender) && shows_tag(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) { diff --git a/Extension/Multiplayer/Steam/steam_lanes.h b/Extension/Multiplayer/Steam/steam_lanes.h index 4eb11b68..e7fbb105 100644 --- a/Extension/Multiplayer/Steam/steam_lanes.h +++ b/Extension/Multiplayer/Steam/steam_lanes.h @@ -53,6 +53,10 @@ inline std::uint64_t lane_queue_time(const SteamNetConnectionRealTimeStatus_t &i if (time == std::numeric_limits::max()) return 0; return static_cast(std::max(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 lanes, TrafficLane lane) { if (info.m_eState != k_ESteamNetworkingConnectionState_Connected) return false; diff --git a/Extension/Multiplayer/Steam/steam_server_browser.cpp b/Extension/Multiplayer/Steam/steam_server_browser.cpp index 4b0f07ae..50c37a92 100644 --- a/Extension/Multiplayer/Steam/steam_server_browser.cpp +++ b/Extension/Multiplayer/Steam/steam_server_browser.cpp @@ -159,6 +159,10 @@ 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(); @@ -248,11 +252,12 @@ void SteamServerBrowser::tick(std::uint64_t now) { 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): keep what was listed in the - // last two minutes. + // 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 > 120000000); + return entry.second.search != search_ && (internet_listed_ || now - entry.second.listed > 900000000); }); if (found_.size() != before) read(now); std::string list; diff --git a/Extension/Multiplayer/Steam/steam_transport.cpp b/Extension/Multiplayer/Steam/steam_transport.cpp index 16ccf320..40153c6f 100644 --- a/Extension/Multiplayer/Steam/steam_transport.cpp +++ b/Extension/Multiplayer/Steam/steam_transport.cpp @@ -94,6 +94,39 @@ struct SteamTransport::Impl { bool recheck{}; // a status callback named this connection: read its state this poll }; std::map links; + // How the connections Steam closed ended (take_closed). A handful at most wait here. + std::map 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 *){}; + // 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(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(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(s.m_flOutBytesPerSec / 1024.f + .5f)) + " KB/s out, " + + std::to_string(static_cast(s.m_flInBytesPerSec / 1024.f + .5f)) + " KB/s in"; + } std::uint64_t frame{}; ULONGLONG next_measure{}, next_sweep{}; struct Name { @@ -146,12 +179,20 @@ struct SteamTransport::Impl { reinterpret_cast(&changed)); return option; } - std::array options() { - std::array out{}; + std::array options() { + std::array out{}; 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. @@ -303,6 +344,12 @@ bool SteamTransport::bind(void *library, void *sockets, void *networking_utils) } catch (...) { p.lanes = {}; } + p.utils = networking_utils; + try { + p.set_config = symbol(module, "SteamAPI_ISteamNetworkingUtils_SetConfigValue"); + } catch (...) { + p.set_config = nullptr; // open connections keep their rate; new ones get the new one + } symbol(module, "SteamAPI_ISteamNetworkingUtils_InitRelayNetworkAccess")(networking_utils); symbol( @@ -407,6 +454,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; @@ -476,6 +526,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(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)}); @@ -539,8 +590,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)}); + 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. @@ -565,6 +618,13 @@ void SteamTransport::poll() { } 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."); @@ -575,6 +635,61 @@ 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::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 SteamTransport::links() { + auto &p = *impl_; + std::vector 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); + } + out.push_back(row); + } + return out; +} std::string SteamTransport::name(std::uint64_t id) { auto &p = *impl_; if (!p.friends || !id) @@ -674,33 +789,41 @@ std::vector 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(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(message->m_pData); - result.push_back({id, {data, data + message->m_cbSize}, - message->m_usecTimeReceived > 0 ? static_cast(message->m_usecTimeReceived) : 0}); - ++p.state.received; - p.state.received_bytes += static_cast(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(max_packet) || + !message->m_pData) { + ++p.state.dropped; + ++p.state.invalid_messages; + continue; + } + const auto *data = static_cast(message->m_pData); + result.push_back({id, {data, data + message->m_cbSize}, + message->m_usecTimeReceived > 0 ? static_cast(message->m_usecTimeReceived) : 0}); + ++p.state.received; + p.state.received_bytes += static_cast(message->m_cbSize); + } + if (count < 128) break; } + if (failed) disconnect(id, "Steam receive failed."); } return result; } diff --git a/Extension/Multiplayer/Steam/steam_transport.h b/Extension/Multiplayer/Steam/steam_transport.h index d035b136..8f9fb97e 100644 --- a/Extension/Multiplayer/Steam/steam_transport.h +++ b/Extension/Multiplayer/Steam/steam_transport.h @@ -13,6 +13,15 @@ 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 +}; struct TransportMessage { std::uint64_t peer{}; std::vector bytes; @@ -44,6 +53,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(); @@ -69,6 +80,19 @@ class SteamTransport { std::vector 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 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 links(); private: bool bind(void *steam_api, void *sockets, void *networking_utils); diff --git a/Extension/Multiplayer/Test/bandwidth_tests.cpp b/Extension/Multiplayer/Test/bandwidth_tests.cpp index 6af0044b..ba2588a4 100644 --- a/Extension/Multiplayer/Test/bandwidth_tests.cpp +++ b/Extension/Multiplayer/Test/bandwidth_tests.cpp @@ -135,6 +135,44 @@ void rate_checks() { 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 few(20), crowd(49), packed(127); + for (std::size_t i = 0; i < packed.size(); ++i) { + const auto d = static_cast((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 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 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); + check(sent <= 602 && 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: all at the low rate, which is the floor. + const auto all_low = crowd_limits(packed, 30, 600); + check(count(packed, all_low, 30) == packed.size() * 5, "An over-full crowd was not sent at the low rate"); + // 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; diff --git a/Server/README.txt b/Server/README.txt index 4c6d3b50..170323ac 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -55,6 +55,27 @@ map_rotation_minutes Minutes on each map before the server moves to the next warning; the clock waits while nobody is on, and starts over whenever the map changes (by a vote or an admin too). max_players 1-249. +send_rate The most the server sends one player, in KB/s (default 900, + 128-16384). About 1100 is what reaches a player through + Steam's relays: set higher, what is lost is resent until the + connection is full of resends and half of everything is + lost. Console: rate , also for the players already on. +crowd_budget The most position updates a second one player is sent + (default 600, 0 for no limit). Players near each other are + sent at the full rate (tps); this only matters once more are + in one place than budget / tps, about 20 at 30 TPS. Then the + nearest stay at the full rate and the farthest of the crowd + drop to 10 and 5 a second, instead of everyone's connection + filling up. About 1000 is what reaches a player through + Steam's relays; more than that was seen to lose half. + Console: crowd |off. +reserved_slots How many of those are kept for the players in "reserved" and + the admins (default 0). With 64 players and 4 reserved slots, + anyone can join until 60 are on; the last 4 only they can + take. The browser still shows 64, and everyone else is told + the remaining slots are reserved. +reserved SteamID64s of the players with a reserved slot, e.g. + ["76561198000000000"]. Admins always have one. password Empty for anyone; otherwise players type it to join. welcome A chat line sent to each player as they join. listed false hides the server; players then need the code. @@ -95,7 +116,7 @@ speed_check Catch players whose game runs faster than normal (Cheat Engine's speedhack and the like), measured from the timing of what their game sends: "warn" (default) takes them out of throwdowns and coop challenges until their speed is normal - again and tells the admins, "kick" removes them from the + again and tells them so (the log has it for the admins), "kick" removes them from the server, "off" does not check. score_check Catch players whose mods change how many points tricks score (per-trick points, the scoring multipliers, the @@ -148,6 +169,15 @@ and change voice, distances, placement and kicks from the Multiplayer menu. help A short list of every command. status Name, map, players, code. players Connected players and their SteamID64s. + reserved Who has a reserved slot and how many are kept. + reserved slots Keep n slots for them (console only), 0 for none. + reserved add|remove (console only) + net [player] How the connections are doing right now: traffic, + queues, the server's own loop timing, and the twelve + connections doing worst. With a player, that one's + ping, delivery, queue and when it was last heard from. + The log also gets a [network] line every minute and, + when a player's connection ends, how Steam says it ended. say Chat as the server (console only). msg Private message, shown to them as "[DM from ] ...". Name start (one word) or SteamID64. diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 96072bed..40a3b0a3 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -60,6 +60,45 @@ int run() { check(token_refused(tokened), "A steam_token with other characters was accepted"); tokened.steam_token = std::string(65, 'A'); check(token_refused(tokened), "An overlong steam_token was accepted"); + { + check(has("crowd_budget") && config.crowd_budget == 600, "crowd_budget is not a new setting of 600"); + check(has("send_rate") && config.send_rate == 900, "send_rate is not a new setting of 900"); + ServerConfig rate; + rate.send_rate = 64; + check(config_error(rate).find("send_rate") != std::string::npos, "A send rate too low to play with was accepted"); + ServerConfig crowd; + crowd.crowd_budget = 50; + check(config_error(crowd).find("crowd_budget") != std::string::npos, "A crowd budget too small to play with was accepted"); + } + // Reserved slots: none unless set; the last of them only the listed players and admins take. + { + check(has("reserved_slots") && has("reserved") && config.reserved_slots == 0 && config.reserved.empty(), + "Reserved slots are not new settings that reserve nothing"); + ServerConfig slots; + slots.max_players = 4; + const std::uint64_t vip = 76561198000000001ULL, admin = 76561198000000002ULL, anyone = 76561198000000003ULL; + check(may_join(slots, anyone, 3) && !may_join(slots, anyone, 4) && !may_join(slots, vip, 4), "A server with nothing reserved did not fill up plainly"); + slots.reserved_slots = 2; + slots.reserved = {vip}; + slots.admins = {admin}; + check(may_join(slots, anyone, 1) && !may_join(slots, anyone, 2) && !may_join(slots, anyone, 3), "Reserved slots were given to anyone"); + check(may_join(slots, vip, 2) && may_join(slots, vip, 3) && may_join(slots, admin, 3), "A reserved player or an admin was kept out of a reserved slot"); + check(!may_join(slots, vip, 4) && !may_join(slots, admin, 4), "A full server let someone in"); + const auto refused = [](const ServerConfig &c) { return config_error(c).find("reserved") != std::string::npos; }; + check(!refused(slots), "Valid reserved slots were refused"); + slots.reserved_slots = 4; + check(refused(slots), "A server with every slot reserved was accepted"); + slots.reserved_slots = 2; + slots.reserved.push_back(42); + check(refused(slots), "A reserved entry that is not a player was accepted"); + auto kept = reloaded; + kept.reserved_slots = 3; + kept.reserved = {vip}; + save_config(kept); + const auto back = load_config(file); + check(back.reserved_slots == 3 && back.reserved == std::vector{vip}, "Reserved slots were not kept"); + save_config(reloaded); + } // object_limit: 100 unless set (0 is no limit), and kept on a rewrite. check(has("object_limit") && config.object_limit == 100, "object_limit is not a new setting of 100"); auto limited = reloaded; diff --git a/Server/Test/speed_check_tests.cpp b/Server/Test/speed_check_tests.cpp index 34bb5726..ad25a03e 100644 --- a/Server/Test/speed_check_tests.cpp +++ b/Server/Test/speed_check_tests.cpp @@ -56,8 +56,9 @@ int main() { check(fast.flagged && std::abs(fast.speed - 1.5) < 0.02, "A 1.5x speedhack was not caught"); const auto slight = run(1.03, 120, false); check(!slight.flagged, "A 3% difference was flagged"); - const auto subtle = run(1.1, 60, false); - check(subtle.flagged, "A 1.1x speedhack was not caught"); + // Under half as fast again is left alone; from there up it is caught. + check(!run(1.1, 60, false).flagged && !run(1.3, 60, false).flagged, "A game under 1.5x speed was flagged"); + check(run(2.0, 60, false).flagged, "A 2x speedhack was not caught"); const auto slow = run(0.5, 60, false); check(!slow.flagged, "A slowed-down client was flagged"); // Loading (no poses for a while) starts the measurement over. @@ -65,5 +66,5 @@ int main() { for (int i = 0; i < 30 * 15; ++i) gaps.sample(1000000 + i * 50000, 1000000 + i * 33333); gaps.sample(1000000 + 30 * 15 * 50000, 1000000 + 30 * 15 * 33333 + 5000000); check(!gaps.flagged() && gaps.speed() == 1.0, "A pause did not restart the measurement"); - std::cout << "Speed check: real time, lag, 1.5x and 1.1x speedhacks, small drift, slow motion and pauses passed.\n"; + std::cout << "Speed check: real time, lag, 1.5x and 2x speedhacks, 1.1x and 1.3x left alone, small drift, slow motion and pauses passed.\n"; } diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 518f3cb8..b109db62 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -23,6 +23,12 @@ Json to_json(const ServerConfig &c) { root["map_pool"] = std::move(pool); root["map_rotation_minutes"] = c.map_rotation; root["max_players"] = c.max_players; + root["reserved_slots"] = c.reserved_slots; + root["crowd_budget"] = c.crowd_budget; + root["send_rate"] = c.send_rate; + auto reserved = Json::array(); + for (const auto id : c.reserved) reserved.push_back(std::to_string(id)); // as strings, like the admins + root["reserved"] = std::move(reserved); root["password"] = c.password; root["welcome"] = c.welcome; root["listed"] = c.listed; @@ -124,6 +130,11 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector parse_scoring(std::string_view text) { if (!value) return std::nullopt; return value; } +bool may_join(const ServerConfig &config, std::uint64_t id, std::size_t on) noexcept { + if (on >= config.max_players) return false; + const auto listed = [&](const std::vector &ids) { return std::find(ids.begin(), ids.end(), id) != ids.end(); }; + return on + config.reserved_slots < config.max_players || listed(config.reserved) || listed(config.admins); +} std::string config_error(const ServerConfig &c) { using namespace multiplayer; if (!valid_server_name(c.name)) return std::string("name must be ") + server_name_rule + "."; + for (const auto id : c.reserved) + if (!individual_steam_id(id)) return "reserved must be SteamID64s (17 digits starting 7656119)."; + if (c.reserved_slots >= c.max_players) return "reserved_slots must be less than max_players, so that anyone can join at all."; + if (c.reserved.size() > 1024) return "reserved holds at most 1024 players."; + if (c.send_rate < 128 || c.send_rate > 16384) return "send_rate must be 128 to 16384 (KB/s for each player)."; + if (!valid_crowd_budget(c.crowd_budget)) + return "crowd_budget must be 0 (no limit) or " + std::to_string(min_crowd_budget) + " to " + + std::to_string(max_crowd_budget) + "."; if (c.steam_token.size() > 64 || !std::all_of(c.steam_token.begin(), c.steam_token.end(), [](unsigned char ch) { return std::isalnum(ch); })) return "steam_token must be a game server login token (letters and digits), or empty to sign in anonymously."; if (c.map.empty() || !valid_map_destination(map_destination(c.map))) diff --git a/Server/server_config.h b/Server/server_config.h index 61d54208..a46dc361 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -36,6 +36,14 @@ struct ServerConfig { std::vector map_pool; // maps for votes and the rotation, in order; empty: every map unsigned map_rotation = 0; // minutes per map before the next pool map (0: off) unsigned max_players = 16; // players; the server itself is not one + // Of those, how many are kept for the players in `reserved` and the admins: everyone else + // is told the server is full once only these are left. 0: none are kept. + unsigned reserved_slots = 0; + // The most poses a second one player is sent (crowd_limits); 0: no limit. + unsigned crowd_budget = crowd_pose_budget; + // What the server may send each player, in KB/s (128-16384). + unsigned send_rate = 900; + std::vector reserved; std::string password; // empty: anyone may join std::string welcome; // sent to each player as they join bool listed = true; // shown in the in-game server browser @@ -88,6 +96,9 @@ struct ServerConfig { // their names go to `added` ("votes.seconds" for a nested one). ServerConfig load_config(const std::filesystem::path &file, std::vector *added = nullptr); void save_config(const ServerConfig &config); +// Whether a player who is not yet on may join a server with `on` players on it: anyone while +// an unreserved slot is free, then only the reserved players and the admins until it is full. +bool may_join(const ServerConfig &config, std::uint64_t id, std::size_t on) noexcept; // Why `config` cannot run, or empty. std::string config_error(const ServerConfig &config); // A scoring fingerprint as the config and console write it (16 hex digits), and read back diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 56c9afe1..8c843e85 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -42,16 +42,16 @@ std::uint64_t nonce() { return value; } constexpr std::string_view help_text = - "status | players | say | msg | msg-party | msg-admins | kick | ban [name] | unban | bans\n" + "status | net [player] | players | say | msg | msg-party | msg-admins | kick | ban [name] | unban | bans\n" "map | maps | name | password | welcome | listed on|off\n" - "tps 20|30|60|120 | voice on|off | voice-range <50-1000> | distances \n" + "tps 20|30|60|120 | voice on|off | voice-range <50-1000> | distances | crowd |off | rate \n" "placement everyone|admins|nobody | objects |off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" "tpall [player] | tphere | votes [map|kick|tod on|off|] | vote-cancel\n" "map-pool [add|remove |clear] | rotation [|off]\n" "park | layer-sync on|off | layer default|on|off | tod \n" "activity-log on|off | announce-throwdowns on|off | parties [on|off] | party-size <2-8> | speed-check off|warn|kick\n" "score-check [off|warn|kick] | score-allow [|remove ]\n" - "admin add|remove | admins | update | quit"; + "reserved [slots | add|remove ] | admin add|remove | admins | update | quit"; } // namespace Host::Host(ServerConfig &config, SteamTransport &transport, Log log) @@ -137,6 +137,7 @@ unsigned Host::players() const { } bool Host::start(std::string &error) { + transport_.set_send_rate(static_cast(config_.send_rate * 1024)); if (!transport_.host(capacity())) { error = transport_.status().detail; return false; @@ -173,6 +174,13 @@ void Host::apply_layers() { } // ---- Sending --------------------------------------------------------------------------------- +namespace { +constexpr std::array traffic_names{"poses", "sound", "voice", "outfits", "objects", "other"}; +constexpr std::size_t traffic_kind(PacketKind kind) noexcept { + return kind == PacketKind::pose ? 0 : kind == PacketKind::audio ? 1 : kind == PacketKind::voice ? 2 + : kind == PacketKind::cosmetics ? 3 : kind == PacketKind::objects ? 4 : 5; +} +} // namespace bool Host::send_packet(Guest &g, const Packet &p, bool reliable, bool fresh, std::span raw, std::span wire) { auto update = raw.empty() ? g.sender.prepare(p) : g.sender.prepare(p, raw, wire); @@ -182,6 +190,10 @@ bool Host::send_packet(Guest &g, const Packet &p, bool reliable, bool fresh, std // A stream and its reliable delta references must always use the same lane. if (!transport_.send(g.member.id, update.bytes, reliable || update.establishes_baseline(), fresh, traffic_lane(p.kind))) return false; + for (auto *counted : {&g.traffic, &traffic_}) { + counted->total.out[traffic_kind(p.kind)] += update.bytes.size(); + counted->total.snapshots += update.establishes_baseline(); + } g.sender.sent(p, std::move(update)); return true; } @@ -193,8 +205,13 @@ void Host::send_required(Guest &g, const std::vector &bytes) { } void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint64_t except) { const auto *source = find(packet.source); - struct Encoded { Packet packet; std::vector raw, wire; }; + // Recipients holding the same delta reference share one patch and compression + // (DeltaCache): every update is built first, then all are sent and recorded. + struct Encoded { Packet packet; std::vector raw, wire; DeltaCache deltas; }; std::array encoded; + struct Outgoing { Guest *guest; std::uint64_t id; WireUpdate update; PoseDelivery *delivery; std::uint32_t interval; unsigned variant; }; + std::vector outgoing; + outgoing.reserve(guests_.size()); const bool gameplay = packet.kind == PacketKind::pose || packet.kind == PacketKind::audio || packet.kind == PacketKind::voice || packet.kind == PacketKind::cosmetics; for (auto &[id, guest] : guests_) { @@ -212,6 +229,23 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 continue; } } + // Not before they have been shown this player (introduce). + if (!p.unmet.empty() && (packet.kind == PacketKind::pose || packet.kind == PacketKind::audio || packet.kind == PacketKind::cosmetics) && + p.unmet.contains(packet.source)) + continue; + // A skater's sound goes to those who would hear it: within the full pose rate's reach, + // and in a crowd only from the nearest (the same players sent at the full rate). + // A game stops a sound it hears nothing more of after a second. + if (packet.kind == PacketKind::audio && source && source->latest_root && source->pose_arrival && + now_ - source->pose_arrival <= 1000000 && p.latest_root && p.pose_arrival && now_ - p.pose_arrival <= 1000000) { + float distance{}; + for (unsigned i = 0; i < 3; ++i) { + const auto d = p.latest_root->position[i] - source->latest_root->position[i]; + distance += d * d; + } + const auto reach = static_cast(config_.distances.half_rate_start); + if (distance > p.crowd.half || (config_.distances.valid() && distance > reach * reach)) continue; + } // Frequent gameplay streams skip the server once the receiver confirms a direct route. if (source && (packet.kind == PacketKind::pose || packet.kind == PacketKind::audio) && !needs_relay(p.direct_routes, p.route_reported, source->member, now_)) @@ -226,14 +260,17 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 [](const auto &a, const auto &b) { return a.last_sent < b.last_sent; }); delivery = &*entry; if (delivery->source != packet.source || delivery->epoch != packet.epoch) - *delivery = {packet.source, packet.epoch, 0, interval}; + *delivery = {packet.source, packet.epoch, 0, interval, 0, 0}; if (p.latest_root && p.pose_arrival && now_ >= p.pose_arrival && now_ - p.pose_arrival <= 1000000) { float distance{}; for (unsigned i = 0; i < 3; ++i) { const auto d = p.latest_root->position[i] - packet.pose.root.position[i]; distance += d * d; } - interval = pose_interval(distance, delivery->interval_us, config_.distances, config_.tps); + delivery->by_distance = pose_interval(distance, delivery->by_distance, config_.distances, config_.tps); + interval = crowd_interval(delivery->by_distance, distance, p.crowd); + } else { + delivery->by_distance = 0; } if (delivery->interval_us != interval) delivery->next_source_time = 0; delivery->interval_us = interval; @@ -247,12 +284,83 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 data.raw = encode(data.packet, true); data.wire = encode_wire_bytes(data.raw); } - const bool sent = send_packet(p, data.packet, reliable, fresh, data.raw, data.wire); - if (sent && delivery) { - delivery->last_sent = now_; - advance_pose_deadline(delivery->next_source_time, packet.time_us, interval); + outgoing.push_back({&p, id, p.sender.prepare(data.packet, data.raw, data.wire, data.deltas), delivery, interval, variant}); + } + for (auto &out : outgoing) { + // 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. + bool sent = true; + if (!out.update.bytes.empty()) { + const auto &sending = encoded[out.variant].packet; + const bool whole = out.update.establishes_baseline(); + sent = transport_.send(out.id, out.update.bytes, reliable || whole, fresh, traffic_lane(sending.kind)); + if (sent) { + for (auto *counted : {&out.guest->traffic, &traffic_}) { + counted->total.out[traffic_kind(sending.kind)] += out.update.bytes.size(); + counted->total.snapshots += whole; + } + out.guest->sender.sent(sending, std::move(out.update)); + } } - if (!sent && reliable && !fresh) transport_.disconnect(id, "Cannot deliver required session data. Join again."); + if (sent && out.delivery) { + out.delivery->last_sent = now_; + // The next slot on the source's own clock, not this long after this send: everyone + // sent a source at a slower rate is then sent the same packets of it, and shares + // their encoding and patch. + out.delivery->next_source_time = (packet.time_us / out.interval + 1) * out.interval; + } + // Refused by a full queue: a connection that is only behind keeps its player (a chat + // line or an outfit change is lost to them); one that takes nothing for a while does not. + if (reliable && !fresh) { + auto &since = out.guest->undelivered_since; + if (sent) since = 0; + else if (!since) since = now_; + else if (now_ - since > 15000000) transport_.disconnect(out.id, "Cannot deliver required session data. Join again."); + } + } +} +// A player who has just arrived in this world is shown the others a few at a time (introduce). +void Host::meet_later(Guest &guest) { + guest.unmet.clear(); + for (const auto &[id, other] : guests_) + if (other->handshaken && id != guest.member.id) guest.unmet.insert(id); + guest.next_introduction = now_; +} +// Shows each arriving player one more of the others: that player's outfit, after which their +// poses and sound follow. The nearest first, about seven a second, and none while what +// they have already been sent is still on its way. A busy server's eighty outfits and +// streams in one burst is what a joining game, building every skater at once, fell over on. +void Host::introduce() { + for (auto &[id, guest] : guests_) { + auto &g = *guest; + if (g.unmet.empty() || !g.handshaken || !g.world_ready || now_ < g.next_introduction) continue; + if (transport_.pending(id) > 96 * 1024) continue; + const Guest *next{}; + float nearest{}; + for (auto it = g.unmet.begin(); it != g.unmet.end();) { + const auto *other = find(*it); + if (!other || !other->handshaken) { + it = g.unmet.erase(it); + continue; + } + ++it; + float distance = std::numeric_limits::max(); + if (g.latest_root && other->latest_root) { + distance = 0; + for (unsigned i = 0; i < 3; ++i) { + const auto d = g.latest_root->position[i] - other->latest_root->position[i]; + distance += d * d; + } + } + if (!next || distance < nearest) { + next = other; + nearest = distance; + } + } + if (!next) continue; + g.unmet.erase(next->member.id); + g.next_introduction = now_ + 150000; + send_required(g, next->cosmetic_packet); } } void Host::send_roster() { @@ -355,6 +463,8 @@ void Host::change_map(std::string_view map) { for (auto &[id, guest] : guests_) { auto &g = *guest; auto old = std::exchange(g, Guest{}); + g.sender.set_refresh(8000000, true); // as for a new player (tick): a new world's streams start over + g.traffic = old.traffic; g.member = std::move(old.member); g.handshaken = old.handshaken; g.map_authorized = old.map_authorized; @@ -387,21 +497,22 @@ void Host::change_map(std::string_view map) { send_world_state(); roster_dirty_ = true; } -void Host::drop(std::uint64_t id, const std::string &reason) { +void Host::drop(std::uint64_t id, const std::string &reason, const std::string &detail) { transport_.disconnect(id, reason.c_str()); const auto found = guests_.find(id); if (found == guests_.end()) return; const auto name = guest_name(*found->second); if (found->second->handshaken) { roster_dirty_ = true; - log_(name + " left (" + reason + ")"); + log_(name + " left (" + reason + ")" + (detail.empty() ? std::string{} : " [" + detail + "]")); activity_.left(id); } else { // Never admitted: it held a player slot meanwhile, so it shows in the log, and an ID // that keeps failing waits longer each time before its connection is taken again. const auto failures = join_backoff_.failed(id, now_); log_("[join] " + std::to_string(id) + " did not finish joining (" + reason + ")" + - (failures > 1 ? ", attempt " + std::to_string(failures) : std::string{})); + (failures > 1 ? ", attempt " + std::to_string(failures) : std::string{}) + + (detail.empty() ? std::string{} : " [" + detail + "]")); } guests_.erase(found); party_left(id, name); @@ -448,6 +559,8 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: if (!decoded || decoded->session != secret_ || decoded->source != peer) return drop(peer, "Join code, sender identity, or multiplayer protocol did not match."); const auto &p = *decoded; + link->traffic.total.in[traffic_kind(p.kind)] += bytes.size(); + traffic_.total.in[traffic_kind(p.kind)] += bytes.size(); switch (p.kind) { case PacketKind::world_state: return drop(peer, "Only the server may change the room's map."); // Sent once the player's game knows, and again if a mod enabled later changes it; not tied to @@ -474,9 +587,7 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: log_("[map] " + guest_name(*link) + " finished loading (" + std::to_string((now_ - since) / 1000000) + " s)"); link->world_ready = p.world_ready; if (p.world_ready) link->travel_since = link->loading_since = 0; - if (arrived) - for (auto &[id, other] : guests_) - if (other->handshaken && id != peer) send_required(*link, other->cosmetic_packet); + if (arrived) meet_later(*link); return; } case PacketKind::map_request: { @@ -548,8 +659,7 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: send_required(*link, encode_wire(packet(PacketKind::welcome, now_))); if (joined) { send_roster(); - for (auto &[id, other] : guests_) - if (other->handshaken && id != peer) send_required(*link, other->cosmetic_packet); + meet_later(*link); if (!config_.welcome.empty()) send_chat(config_.welcome, link); log_(guest_name(*link) + " joined (" + std::to_string(peer) + (is_admin(peer) ? ", admin" : "") + "), " + std::to_string(players()) + "/" + std::to_string(config_.max_players) + " players" + @@ -712,6 +822,9 @@ void Host::sync_objects() { for (auto &[id, guest] : guests_) { auto &peer = *guest; if (!peer.handshaken || !peer.world_ready || sources.empty()) continue; + // Objects wait for a connection that is behind: they must arrive, so they would only + // queue up behind the poses and push those out. + if (transport_.pending(id) > 128 * 1024) continue; auto &delivery = peer.object_delivery; const auto find_source = [&](std::uint64_t source, std::uint64_t epoch) { return std::find_if(sources.begin(), sources.end(), [&](const auto &s) { return s.id == source && s.epoch == epoch; }); @@ -740,9 +853,14 @@ void Host::sync_objects() { update.epoch = delivery.epoch; update.objects = delivery.chunks[delivery.next]; if (!send_packet(peer, update, true, false)) { - transport_.disconnect(id, "Cannot deliver shared object state. Join again."); + // Refused for now (a full queue): the same part is tried again. Only a + // connection that takes none for a long while is given up on. + if (!delivery.failing_since) delivery.failing_since = now_; + if (now_ - delivery.failing_since > 30000000) + transport_.disconnect(id, "Cannot deliver shared object state. Join again."); break; } + delivery.failing_since = 0; if (++delivery.next == delivery.chunks.size()) { delivery.sent[delivery.source] = {delivery.epoch, update.objects.revision}; delivery.chunks.clear(); @@ -751,10 +869,165 @@ void Host::sync_objects() { } } +// `net`: how the connections and the server's own loop are doing right now. With a player's +// name or SteamID64, that player alone. In-game admins get the first line: a reply has to fit +// one chat-sized message. +std::string Host::network_report(std::string_view player, bool console) { + const auto links = transport_.links(); + const auto &net = transport_.status(); + const auto kb = [](float bytes) { return std::to_string(static_cast(bytes / 1024.f + .5f)); }; + const auto percent = [](float quality) { return quality < 0 ? std::string("?") : std::to_string(static_cast(quality * 100.f + .5f)) + "%"; }; + const auto row = [&](const multiplayer::TransportLink &link) { + const auto *guest = find(link.id); + std::string name = guest ? guest_name(*guest) : std::to_string(link.id); + if (name.size() > 20) name.resize(20); + name.resize(21, ' '); + if (!link.measured) return " " + name + (link.connected ? "not measured yet" : "connecting"); + const auto silent = guest && guest->last_packet && now_ > guest->last_packet ? (now_ - guest->last_packet) / 100000 : 0; // tenths of a second + return " " + name + "ping " + std::to_string(link.ping_ms) + " ms, quality " + percent(link.quality_local) + " here / " + + percent(link.quality_remote) + " there, queued " + std::to_string(link.pending_bytes) + " B (" + + std::to_string(link.queue_us / 1000) + " ms), out " + kb(link.out_bps) + " of " + kb(static_cast(link.send_rate)) + + " KB/s, in " + kb(link.in_bps) + " KB/s, last heard " + std::to_string(silent / 10) + "." + std::to_string(silent % 10) + " s ago" + + (guest && !guest->handshaken ? ", joining" : guest && !guest->world_ready ? ", loading" : ""); + }; + // By what it carries, in KB/s: the last whole half minute, or the one so far. + const bool whole = traffic_window_us_ != 0; + const double seconds = whole ? static_cast(traffic_window_us_) / 1e6 + : std::max(1.0, static_cast(now_ - traffic_mark_) / 1e6); + const auto rate = [&](const Counted &c, bool out, std::size_t kind) { + const auto bytes = whole ? (out ? c.last.out[kind] : c.last.in[kind]) + : out ? c.total.out[kind] - c.mark.out[kind] : c.total.in[kind] - c.mark.in[kind]; + return static_cast(bytes) / 1024.0 / seconds; + }; + const auto number = [](double value) { + char text[32]; + std::snprintf(text, sizeof(text), value < 10 ? "%.1f" : "%.0f", value); + return std::string(text); + }; + const auto by_kind = [&](const Counted &c) { + std::string text = "KB/s out / in:"; + for (std::size_t kind = 0; kind < traffic_names.size(); ++kind) + text += std::string(kind ? ", " : " ") + traffic_names[kind] + " " + number(rate(c, true, kind)) + " / " + number(rate(c, false, kind)); + const auto snapshots = whole ? c.last.snapshots : c.total.snapshots - c.mark.snapshots; + return text + "; " + number(static_cast(snapshots) / seconds) + " whole states a second"; + }; + if (!player.empty()) { + const auto wanted = lower(player); + for (const auto &link : links) { + const auto *guest = find(link.id); + if (std::to_string(link.id) == player || (guest && lower(guest_name(*guest)).starts_with(wanted))) + return row(link).substr(2) + (guest ? "\n" + by_kind(guest->traffic) : std::string{}); + } + return "No connected player matches \"" + std::string(player) + "\"."; + } + float out{}, in{}; + std::uint64_t queued{}, longest_queue{}; + std::size_t waiting{}, poor{}; + int worst_ping{}; + for (const auto &link : links) { + if (!link.measured) continue; + out += link.out_bps; + in += link.in_bps; + queued += static_cast(std::max(0, link.pending_bytes)); + longest_queue = std::max(longest_queue, link.queue_us); + waiting += link.queue_us > 50000; + poor += (link.quality_local >= 0 && link.quality_local < .9f) || (link.quality_remote >= 0 && link.quality_remote < .9f); + worst_ping = std::max(worst_ping, link.ping_ms); + } + std::string text = std::to_string(players()) + " players (" + std::to_string(links.size()) + " connections), " + std::to_string(config_.tps) + + " TPS | " + kb(out) + " KB/s out, " + kb(in) + " KB/s in | worst ping " + std::to_string(worst_ping) + " ms | " + + std::to_string(queued / 1024) + " KB queued, longest wait " + std::to_string(longest_queue / 1000) + " ms, " + + std::to_string(waiting) + " waiting over 50 ms | " + std::to_string(poor) + " under 90% quality"; + if (!console) return text; + text += "\nloop: " + loop_report(); + text += "\n" + by_kind(traffic_); + { + // Who uploads most, and what most of it is: one player's outfit or objects can cost + // every other player's connection. + struct Upload { const Guest *guest; double total; std::size_t most; }; + std::vector uploads; + for (const auto &[id, guest] : guests_) { + Upload upload{guest.get(), 0, 0}; + for (std::size_t kind = 0; kind < traffic_names.size(); ++kind) { + upload.total += rate(guest->traffic, false, kind); + if (rate(guest->traffic, false, kind) > rate(guest->traffic, false, upload.most)) upload.most = kind; + } + uploads.push_back(upload); + } + std::stable_sort(uploads.begin(), uploads.end(), [](const auto &a, const auto &b) { return a.total > b.total; }); + if (!uploads.empty()) text += "\nmost uploaded:"; + for (std::size_t i = 0; i < std::min(uploads.size(), 6); ++i) + text += std::string(i ? ", " : " ") + guest_name(*uploads[i].guest) + " " + number(uploads[i].total) + " KB/s (" + + traffic_names[uploads[i].most] + " " + number(rate(uploads[i].guest->traffic, false, uploads[i].most)) + ")"; + } + text += "\nsends: " + std::to_string(net.send_failures) + " failed, " + std::to_string(net.skipped) + " skipped, " + + std::to_string(net.dropped) + " dropped (since the server started)"; + // The connections doing worst first: a long queue, poor delivery, silence, then ping. + auto order = links; + const auto badness = [&](const multiplayer::TransportLink &link) { + const auto *guest = find(link.id); + const double silent = guest && guest->handshaken && now_ > guest->last_packet ? static_cast(now_ - guest->last_packet) / 1000.0 : 0; + const double lost = 1.0 - std::min(link.quality_local < 0 ? 1.f : link.quality_local, link.quality_remote < 0 ? 1.f : link.quality_remote); + return static_cast(link.queue_us) / 1000.0 + lost * 2000.0 + silent + link.ping_ms; + }; + std::stable_sort(order.begin(), order.end(), [&](const auto &a, const auto &b) { return badness(a) > badness(b); }); + const std::size_t shown = std::min(order.size(), 12); + if (shown) text += "\nworst " + std::to_string(shown) + " of " + std::to_string(order.size()) + " (net for one):"; + for (std::size_t i = 0; i < shown; ++i) text += "\n" + row(order[i]); + return text; +} + // ---- Tick ------------------------------------------------------------------------------------ +namespace { +std::uint64_t clock_us() { + return static_cast( + std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count()); +} +} // namespace +std::string Host::loop_report() const { + // The last whole minute when there is one, or the minute so far. + const auto &l = last_loop_.passes ? last_loop_ : loop_; + const auto seconds = std::max(1, last_loop_.passes ? 60 : (clock_us() - l.since) / 1000000); + const auto ms = [](std::uint64_t us) { + char text[32]; + std::snprintf(text, sizeof(text), "%.1f", static_cast(us) / 1000.0); + return std::string(text); + }; + return std::to_string(l.passes / seconds) + " passes/s, " + + std::to_string(l.passes ? l.busy_us * 100 / (seconds * 1000000) : 0) + "% busy, longest pass " + ms(l.longest_pass_us) + + " ms, longest gap " + ms(l.longest_gap_us) + " ms (worst since start: " + ms(worst_loop_.longest_pass_us) + " ms pass, " + + ms(worst_loop_.longest_gap_us) + " ms gap)"; +} void Host::tick(std::uint64_t now) { if (!running_) return; now_ = now; + // Timed on a clock of its own, from the start of one pass to the start of the next. + struct Timed { + Host &host; + std::uint64_t started = clock_us(); + explicit Timed(Host &h) : host(h) { + auto &l = host.loop_; + if (!l.since) l.since = started; + if (host.pass_started_) { + const auto gap = started - host.pass_started_; + l.longest_gap_us = std::max(l.longest_gap_us, gap); + host.worst_loop_.longest_gap_us = std::max(host.worst_loop_.longest_gap_us, gap); + } + host.pass_started_ = started; + } + ~Timed() { + auto &l = host.loop_; + const auto took = clock_us() - started; + ++l.passes; + l.busy_us += took; + l.longest_pass_us = std::max(l.longest_pass_us, took); + host.worst_loop_.longest_pass_us = std::max(host.worst_loop_.longest_pass_us, took); + if (started - l.since >= 60000000) { + host.last_loop_ = l; + l = {}; + } + } + } timed{*this}; transport_.poll(); const auto links = transport_.status().peers; const auto linked = [&](std::uint64_t id) { @@ -762,7 +1035,11 @@ void Host::tick(std::uint64_t now) { }; for (auto it = guests_.begin(); it != guests_.end();) { const auto id = (it++)->first; - if (!linked(id)) drop(id, "Disconnected."); + if (linked(id)) continue; + // Steam closed it: the log says how (the player's game closing it, or the connection + // failing at one end or the other), which "Disconnected." alone does not. + const auto how = transport_.take_closed(id); + drop(id, "Disconnected.", how); } for (const auto &link : links) { if (kicked_.contains(link.id)) { @@ -784,11 +1061,15 @@ void Host::tick(std::uint64_t now) { continue; } if (!guest) { - if (!individual_steam_id(link.id) || guests_.size() >= config_.max_players) { - transport_.disconnect(link.id, "The server is full."); + if (!individual_steam_id(link.id) || !may_join(config_, link.id, guests_.size())) { + // Full for them: for everyone, or with only the reserved slots left. + transport_.disconnect(link.id, guests_.size() >= config_.max_players || !individual_steam_id(link.id) + ? "The server is full." + : "The server is full: the slots that are left are reserved."); continue; } auto created = std::make_unique(); + created->sender.set_refresh(8000000, true); created->member.id = link.id; created->last_packet = now_; guest = created.get(); @@ -798,6 +1079,7 @@ void Host::tick(std::uint64_t now) { } for (const auto &message : transport_.receive()) receive(message.peer, message.bytes, message.arrived); receive_cosmetics(); + introduce(); for (auto it = guests_.begin(); it != guests_.end();) { auto &g = *(it++)->second; // Authorized arrivals get time for loading; this deadline is absolute, so @@ -807,11 +1089,63 @@ void Host::tick(std::uint64_t now) { if (now_ - g.travel_since > 180000000) drop(g.member.id, "Could not finish loading the new map."); continue; } + // The connection is still open but nothing came over it: what Steam measures of it says + // whether the link went bad or the player's game just stopped sending. if ((!g.handshaken && g.connected_at && now_ - g.connected_at > handshake_timeout) || (g.handshaken && now_ - g.last_packet > 10000000)) - drop(g.member.id, "Timed out waiting for gameplay data."); + drop(g.member.id, "Timed out waiting for gameplay data.", + "nothing for " + std::to_string((now_ - g.last_packet) / 1000000) + " s; " + transport_.link_report(g.member.id)); + } + // Every half minute, what was sent and received in it (net). + if (now_ >= traffic_mark_ + 30000000) { + const auto roll = [](Counted &c) { + for (std::size_t i = 0; i < c.total.out.size(); ++i) { + c.last.out[i] = c.total.out[i] - c.mark.out[i]; + c.last.in[i] = c.total.in[i] - c.mark.in[i]; + } + c.last.snapshots = c.total.snapshots - c.mark.snapshots; + c.mark = c.total; + }; + roll(traffic_); + for (auto &[id, guest] : guests_) roll(guest->traffic); + traffic_window_us_ = traffic_mark_ ? now_ - traffic_mark_ : 0; + traffic_mark_ = now_; + } + // In a crowd, how far the full and half pose rates reach for each player (crowd_limits). + if (now_ >= next_crowd_) { + next_crowd_ = now_ + 500000; + std::vector squared; + for (auto &[id, guest] : guests_) { + auto &g = *guest; + g.crowd = {}; + if (!g.latest_root || !config_.crowd_budget || guests_.size() * config_.tps <= config_.crowd_budget) continue; + squared.clear(); + for (const auto &[other_id, other] : guests_) { + if (other_id == id || !other->handshaken || !other->world_ready) continue; + float distance{}; + if (other->latest_root) + for (unsigned i = 0; i < 3; ++i) { + const auto d = g.latest_root->position[i] - other->latest_root->position[i]; + distance += d * d; + } + squared.push_back(distance); // not placed yet: counted as beside them + } + g.crowd = crowd_limits(squared, config_.tps, config_.crowd_budget); + } } tick_parties(); + // Once a minute while anyone is on: how the connections are doing as a whole. A queue that + // grows with the player count, or sends that fail, is the server not getting data out. + if (config_.activity_log && now_ >= next_network_log_ && !guests_.empty()) { + next_network_log_ = now_ + 60000000; + const auto &net = transport_.status(); + if (net.telemetry) + log_("[network] " + std::to_string(guests_.size()) + " players, " + std::to_string(static_cast(net.outgoing_bps / 1024.f + .5f)) + + " KB/s out, " + std::to_string(static_cast(net.incoming_bps / 1024.f + .5f)) + " KB/s in, worst ping " + + std::to_string(net.ping_ms) + " ms, " + std::to_string(net.pending_bytes / 1024) + " KB queued (longest " + + std::to_string(net.queue_us / 1000) + " ms), " + std::to_string(net.send_failures) + " failed sends, " + + std::to_string(net.skipped) + " skipped, " + std::to_string(net.dropped) + " dropped | loop " + loop_report()); + } if (roster_dirty_ || now_ - last_roster_ > 2000000) send_roster(); for (auto &[id, guest] : guests_) if (guest->handshaken) { @@ -876,6 +1210,7 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { std::to_string(config_.max_players) + " players | " + std::to_string(config_.tps) + " TPS | voice " + (voice_policy_.allowed ? "on" : "off") + " (" + std::to_string(static_cast(config_.voice_range)) + " m) | password " + (password_ ? "on" : "off") + " | code " + invite(); + if (name == "net") return network_report(argument, console); if (name == "players") { std::string text = std::to_string(players()) + " players"; for (const auto &[id, guest] : guests_) @@ -1123,6 +1458,29 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { return changed(remove ? "Scoring " + scoring_text(*fingerprint) + " is no longer accepted." : "Scoring " + scoring_text(*fingerprint) + " is accepted like the game's own."); } + if (name == "rate") { + // rate : what the server may send each player, from now on and to those on. + const auto value = number(argument); + if (!value || *value < 128 || *value > 16384) + return "rate <128-16384> (KB/s for each player, now " + std::to_string(config_.send_rate) + ")"; + config_.send_rate = static_cast(*value); + const bool applied = transport_.set_send_rate(static_cast(config_.send_rate * 1024)); + return changed("Each player is sent at most " + std::to_string(config_.send_rate) + " KB/s" + + (applied ? "." : ": players who join from now on. Steam did not change the connections already open.")); + } + if (name == "crowd") { + // crowd |off: the most one player is sent (crowd_limits). + auto value = number(argument); + if (argument == "off") value = 0; + if (!value || *value > max_crowd_budget || !valid_crowd_budget(static_cast(*value))) + return "crowd <" + std::to_string(min_crowd_budget) + "-" + std::to_string(max_crowd_budget) + ">|off (now " + + (config_.crowd_budget ? std::to_string(config_.crowd_budget) : std::string("off")) + ")"; + config_.crowd_budget = static_cast(*value); + next_crowd_ = 0; + return changed(*value ? "Each player is sent at most " + std::to_string(*value) + " poses a second: about " + + std::to_string(*value / config_.tps) + " players near them at the full rate." + : std::string("No crowd limit: every player near is sent at the full rate.")); + } if (name == "party-size") { const auto value = number(argument); if (!value || *value < 2 || *value > 8) return "party-size <2-8> (now " + std::to_string(config_.party_size) + ")"; @@ -1387,6 +1745,39 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { return changed(found->label + " set to " + std::string(mode) + (config_.world_layer_sync ? "." : ". Turn on layer-sync to apply it to everyone.")); } + if (name == "reserved") { + // reserved | reserved slots | reserved add|remove + if (!console) return "Only the server console manages reserved slots."; + const auto [sub, who] = split(argument); + if (sub.empty()) { + std::string text = std::to_string(config_.reserved_slots) + " of " + std::to_string(config_.max_players) + + " slots are reserved for " + std::to_string(config_.reserved.size()) + " players and the admins"; + for (const auto id : config_.reserved) { + const auto *guest = find(id); + text += "\n " + std::to_string(id) + (guest ? " " + guest_name(*guest) : std::string{}); + } + return text; + } + if (sub == "slots") { + const auto slots = number(who); + if (!slots || *slots >= config_.max_players) return "reserved slots <0-" + std::to_string(config_.max_players - 1) + ">"; + config_.reserved_slots = static_cast(*slots); + return changed(*slots ? std::to_string(*slots) + " slots are kept for reserved players and admins." + : std::string("No slots are reserved.")); + } + auto *guest = target(who); + const auto id = guest ? guest->member.id : number(who).value_or(0); + if (!individual_steam_id(id) || (sub != "add" && sub != "remove")) return "reserved | reserved slots | reserved add|remove "; + const bool listed = std::find(config_.reserved.begin(), config_.reserved.end(), id) != config_.reserved.end(); + if (sub == "add") { + if (!listed && config_.reserved.size() >= 1024) return "The reserved list is full."; + if (!listed) config_.reserved.push_back(id); + return changed(std::to_string(id) + " has a reserved slot." + + (config_.reserved_slots ? std::string{} : " No slots are reserved yet: reserved slots .")); + } + std::erase(config_.reserved, id); + return changed(std::to_string(id) + " no longer has a reserved slot."); + } if (name == "admins" || name == "admin") { if (!console) return "Only the server console manages admins."; const auto [sub, who] = split(argument); @@ -1461,9 +1852,7 @@ bool Host::check_speed(Guest &guest, std::uint64_t sent) { guest.speed_normal_since = 0; roster_dirty_ = true; send_chat("The server measured your game running faster than normal: throwdowns and challenges are off for you until it's back to normal speed.", &guest); - for (auto &[id, other] : guests_) - if (other->handshaken && is_admin(id) && other.get() != &guest) - send_chat(std::string(text) + " They were taken out of throwdowns and challenges.", other.get()); + // Only the player is told in the game; for the admins it is in the log. return true; } if (!guest.speeding) return true; diff --git a/Server/server_host.h b/Server/server_host.h index 1a60b1b2..a354c9be 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -52,8 +52,18 @@ class Host { return ++messages <= burst; } }; + // What is sent and received, in bytes, by what it carries (traffic_kind): poses, sound, + // voice, outfits, objects, the rest. `last` is the last whole half minute. + struct Traffic { + std::array out{}, in{}; + std::uint64_t snapshots{}; // whole states sent reliably; the rest of a stream is differences + }; + struct Counted { + Traffic total, mark, last; + }; struct Guest { Member member; + Counted traffic; std::uint64_t password_challenge{}; bool handshaken{}, map_authorized{}, world_ready = true; std::uint64_t last_map_offer{}, travel_since{}, connected_at{}, last_packet{}; @@ -71,6 +81,7 @@ class Host { std::optional latest_root; std::uint64_t pose_arrival{}; std::array pose_delivery; + CrowdLimits crowd; // how far the full and half rates reach for them in a crowd std::vector direct_routes; std::uint64_t route_reported{}; std::uint32_t route_sequence{}; @@ -91,6 +102,12 @@ class Host { bool scoring_flagged{}; // out of linked activities (config score_check) ChatBudget scoring_budget; std::uint64_t bans_sent{}; // the ban list revision this admin has + std::uint64_t undelivered_since{}; // Steam has refused what they must be sent since + // The players they have not been shown yet (introduce): on joining, and after a map + // change, a player is sent the others a few a second, nearest first, instead of every + // outfit and every stream at once. Nothing of a player in here is sent to them. + std::set unmet; + std::uint64_t next_introduction{}; bool maps_sent{}; // this player has the server's map list (send_maps) // The owner's own upload, and what the server shares of it. ObjectState objects, shared; @@ -101,6 +118,7 @@ class Host { std::vector chunks; std::uint64_t source{}, epoch{}; std::size_t next{}, cursor{}; + std::uint64_t failing_since{}; // Steam has refused their object updates since } object_delivery; }; @@ -137,6 +155,21 @@ class Host { bool roster_dirty_ = true, running_{}; std::uint64_t bans_revision_ = 1; // bumped whenever the ban list or the admins change std::uint64_t now_{}, last_roster_{}, last_world_state_{}, next_object_update_{}, travel_started_{}; + std::uint64_t next_crowd_{}; // when the crowd limits are worked out again + std::uint64_t next_network_log_{}; // the log's once-a-minute line about the connections + // How the server's own loop is keeping up, for `net` and that line: passes of tick() in a + // minute, the time spent in them, the longest one and the longest wait between two. A long + // pass or gap is the server itself stalling, which every player feels at once. + struct Loop { + std::uint64_t since{}, passes{}, busy_us{}, longest_pass_us{}, longest_gap_us{}; + } loop_, last_loop_, worst_loop_; + std::uint64_t pass_started_{}; + Counted traffic_; + std::uint64_t traffic_mark_{}, traffic_window_us_{}; // when `mark` was taken, and how long `last` covers + void meet_later(Guest &guest); + void introduce(); + std::string loop_report() const; + std::string network_report(std::string_view player, bool console); Guest *find(std::uint64_t id); Packet packet(PacketKind kind, std::uint64_t now); @@ -147,7 +180,8 @@ class Host { bool is_banned(std::uint64_t id) const; void save(); - void drop(std::uint64_t id, const std::string &reason); + // `detail` is for the log alone: the player is told `reason`. + void drop(std::uint64_t id, const std::string &reason, const std::string &detail = {}); bool send_packet(Guest &, const Packet &, bool reliable, bool fresh, std::span raw = {}, std::span wire = {}); void send_required(Guest &, const std::vector &bytes); diff --git a/Server/speed_check.h b/Server/speed_check.h index 18fbace9..9bad51c6 100644 --- a/Server/speed_check.h +++ b/Server/speed_check.h @@ -14,7 +14,9 @@ class SpeedCheck { static constexpr std::uint64_t block_us = 2000000; // one minimum per block static constexpr std::uint64_t window_us = 20000000; // speed measured across this static constexpr std::uint64_t gap_us = 2500000; // longer without poses (loading): start over - static constexpr double limit = 1.06; // a real clock never runs this far ahead + // Half as fast again and more is flagged. A little under 1.5, since a game at exactly 1.5x + // measures a hair either side of it; nothing but a speed hack comes near. + static constexpr double limit = 1.45; static constexpr unsigned strikes_needed = 3; // consecutive fast measurements (one per block) // One pose: the sender's timestamp and when it arrived, both in microseconds. Returns true From 56b687b058e11471d63047be9ecd505ab783d082 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Wed, 7 Oct 2026 15:01:55 -0500 Subject: [PATCH 088/109] Launcher: Thunderstore's mark on MODS and its button; ask about enabled mods' updates only - The MODS tile shows Thunderstore's mark in its own green instead of the skate tool, which stays as the fallback when the mark is not built in - "Open Thunderstore" in the mod manager is a branded button: the mark and name in Thunderstore green on its dark blue, taller than the plain buttons beside it - The mark is a new asset, assets/launcher/icon_thunderstore.png, embedded like the other tile icons - A disabled mod's update is no longer asked about before PLAY or counted on the MODS tile, and "UPDATE AND PLAY" no longer installs it. A disabled mod does not load, so its update stays on the Mods page, where it can still be updated by choice --- Launcher/gui.cpp | 3 ++- Launcher/gui_home.cpp | 11 +++++---- Launcher/gui_internal.h | 6 +++-- Launcher/gui_mods.cpp | 33 ++++++++++++++++++++++++-- Launcher/gui_mods_browse.cpp | 5 ++-- assets/launcher/icon_thunderstore.png | Bin 0 -> 17533 bytes cmake/Apps.cmake | 5 ++-- 7 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 assets/launcher/icon_thunderstore.png diff --git a/Launcher/gui.cpp b/Launcher/gui.cpp index 20277a07..e3d9ddcd 100644 --- a/Launcher/gui.cpp +++ b/Launcher/gui.cpp @@ -391,7 +391,8 @@ int run(const launcher_app::Session& session, const std::vector& a } } for (auto [name, icon] : {std::pair{L"LAUNCHER_ICON_MODS", &g_icon_mods}, - std::pair{L"LAUNCHER_ICON_SETTINGS", &g_icon_settings}}) { + std::pair{L"LAUNCHER_ICON_SETTINGS", &g_icon_settings}, + std::pair{L"LAUNCHER_ICON_THUNDERSTORE", &g_icon_thunderstore}}) { std::vector pixels; UINT icon_width{}, icon_height{}; const auto bytes = resource_bytes(name); diff --git a/Launcher/gui_home.cpp b/Launcher/gui_home.cpp index f12306b2..5498912b 100644 --- a/Launcher/gui_home.cpp +++ b/Launcher/gui_home.cpp @@ -84,11 +84,12 @@ void draw_background(ImDrawList* draw, ImVec2 size, float time) { } // A tile's icon centred at `centre`; false when it isn't loaded (use the drawn one). -bool tile_icon(ImDrawList* draw, const Background& icon, ImVec2 centre, float size, bool hovered) { +bool tile_icon(ImDrawList* draw, const Background& icon, ImVec2 centre, float size, bool hovered, float idle = 0.32f, + float hot = 0.6f) { if (!icon.id) return false; const float half = size * 0.5f; draw->AddImage(icon.id, ImVec2(centre.x - half, centre.y - half), ImVec2(centre.x + half, centre.y + half), - ImVec2(0, 0), ImVec2(1, 1), rgba(255, 255, 255, hovered ? 0.6f : 0.32f)); + ImVec2(0, 0), ImVec2(1, 1), rgba(255, 255, 255, hovered ? hot : idle)); return true; } @@ -211,8 +212,10 @@ bool mods_tile(ImDrawList* draw, const Fonts& fonts, ImVec2 position, ImVec2 siz draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(position.x + S(20), detail_y), bad ? color::danger : color::muted, detail.c_str(), nullptr, 0, &clip); } - // The skate tool, faded like the wheel on SETTINGS. + // Thunderstore's mark, where the mods come from, in its own green: less faded than the + // wheel on SETTINGS, which is only white. The skate tool when the mark is not built in. const ImVec2 centre(end.x - S(46), position.y + size.y * 0.5f); + if (tile_icon(draw, g_icon_thunderstore, centre, S(62), hovered, 0.7f, 1.f)) return pressed; if (tile_icon(draw, g_icon_mods, centre, S(56), hovered)) return pressed; const ImU32 ink = rgba(255, 255, 255, hovered ? 0.55f : 0.3f); for (int layer = 0; layer < 3; ++layer) { @@ -477,7 +480,7 @@ void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPane for (const auto& entry : list.entries) if (entry.enabled && (list.excluded.contains(entry.mod.name) || !entry.mod.outdated.empty())) dropped.push_back(entry.mod.title); - const auto pending = updates(mods_panel.store, installed_versions(list)).size(); + const auto pending = updates(mods_panel.store, installed_versions(list, true)).size(); ui.mods_pending = pending; ui.mods_mark_bad = !dropped.empty(); ui.mods_mark = !dropped.empty() ? std::string("NOT LOADED") diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index 07ad623f..f633c363 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -86,7 +86,7 @@ struct Background { }; inline Background g_background; // White tile icons (assets/launcher/icon_*.png), drawn faded like the HUB's. -inline Background g_icon_mods, g_icon_settings; +inline Background g_icon_mods, g_icon_settings, g_icon_thunderstore; using launcher_text::utf8; using launcher_text::wide; @@ -435,7 +435,9 @@ std::string size_text(std::uint64_t bytes); void refresh_listing(ModsPanel& panel, double time, bool force = false); // Takes over a fetch the worker finished; call once a frame. void collect_listing(ModsPanel& panel); -thunderstore::Installed installed_versions(const mods::ModList& list); +// `enabled_only`: for what is asked before playing. A disabled mod does not load, so its +// update is offered on the Mods page and nowhere else. +thunderstore::Installed installed_versions(const mods::ModList& list, bool enabled_only = false); // The package an installed mod folder came from, if the listing has it. const thunderstore::Package* package_for(const Store& store, std::string_view folder); std::vector updates(const Store& store, const thunderstore::Installed& installed); diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index 116df32c..f5cb498a 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -4,6 +4,7 @@ #include "Engine/Core/Log/logging.h" +#include #include #include @@ -116,6 +117,33 @@ std::string summary(const ModsPanel& panel, const mods::Mod& mod) { // The rail: the two pages, then what you can do to the Mods folder. Returns // true when BACK was pressed. +// The way to Thunderstore, in Thunderstore's colours: its mark and green on its dark blue, and +// taller than the plain buttons beside it. +bool thunderstore_button(const Fonts& fonts, const char* label, float height) { + const ImVec2 position = ImGui::GetCursorScreenPos(), size(ImGui::GetContentRegionAvail().x, height); + const bool pressed = ImGui::InvisibleButton(label, size); + const bool hovered = ImGui::IsItemHovered(); + if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + auto* draw = ImGui::GetWindowDrawList(); + const ImVec2 end(position.x + size.x, position.y + size.y); + draw->AddRectFilled(position, end, hovered ? rgba(28, 44, 74) : rgba(19, 30, 52), S(4)); + draw->AddRect(position, end, rgba(35, 255, 176, hovered ? 1.f : 0.6f), S(4), 0, S(1.5f)); + const float mark = g_icon_thunderstore.id ? height - S(18) : 0, gap = mark ? S(10) : 0; + const char* shown = std::strstr(label, "##"); // what follows only names the button + if (!shown) shown = label + std::strlen(label); + const auto text = fonts.body->CalcTextSizeA(fonts.body->FontSize, FLT_MAX, 0, label, shown); + float x = position.x + std::max(S(10), (size.x - mark - gap - text.x) * 0.5f); + if (mark) { + const float y = position.y + (height - mark) * 0.5f; + draw->AddImage(g_icon_thunderstore.id, ImVec2(x, y), ImVec2(x + mark, y + mark)); + x += mark + gap; + } + const ImVec4 clip(position.x, position.y, end.x - S(6), end.y); + draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(x, position.y + (height - text.y) * 0.5f), rgba(35, 255, 176), label, + shown, 0, &clip); + return pressed; +} + bool rail(const Fonts& fonts, ModsPanel& panel, HWND window, float width, const std::vector& pending, bool installing) { const auto installed = panel.list.entries.size(); @@ -150,7 +178,8 @@ bool rail(const Fonts& fonts, ModsPanel& panel, HWND window, float width, if (ImGui::Button("Install folder", ImVec2(-1, S(32)))) if (const auto path = pick(window, true); !path.empty()) start_install(panel, path, false); if (ImGui::Button("Open Mods folder", ImVec2(-1, S(32)))) open_path(panel.root); - if (ImGui::Button("Open Thunderstore", ImVec2(-1, S(32)))) + ImGui::Dummy(ImVec2(0, S(4))); + if (thunderstore_button(fonts, "THUNDERSTORE##visit", S(48))) open_url(utf8(thunderstore::community_page(thunderstore::community()))); ImGui::EndDisabled(); @@ -913,7 +942,7 @@ void mods_broken_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& // trust -- so it stays on offer, and taking it is remembered for the session. void mods_outdated_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, ModsPanel& panel) { if (!panel.scanned) scan(panel, launcher.session()); - const auto installed = installed_versions(panel.list); + const auto installed = installed_versions(panel.list, true); const auto pending = updates(panel.store, installed); // Nothing left to update (an update ran, or the listing changed): just go. if (pending.empty()) { diff --git a/Launcher/gui_mods_browse.cpp b/Launcher/gui_mods_browse.cpp index 827ed9d7..ffe650f2 100644 --- a/Launcher/gui_mods_browse.cpp +++ b/Launcher/gui_mods_browse.cpp @@ -293,9 +293,10 @@ void collect_listing(ModsPanel& panel) { store.incoming.clear(); } -thunderstore::Installed installed_versions(const mods::ModList& list) { +thunderstore::Installed installed_versions(const mods::ModList& list, bool enabled_only) { ts::Installed installed; - for (const auto& entry : list.entries) installed[entry.mod.name] = entry.mod.version; + for (const auto& entry : list.entries) + if (entry.enabled || !enabled_only) installed[entry.mod.name] = entry.mod.version; return installed; } diff --git a/assets/launcher/icon_thunderstore.png b/assets/launcher/icon_thunderstore.png new file mode 100644 index 0000000000000000000000000000000000000000..ea3af647adb66aa1fbbb67f20830b1d317f62719 GIT binary patch literal 17533 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 literal 0 HcmV?d00001 diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index f630f2c3..88156971 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -108,8 +108,9 @@ if(WIN32) list(APPEND launcher_resources "LAUNCHER_BACKGROUND RCDATA \"${launcher_background}\"\n") set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${launcher_background}") endif() - # Tile icons: the skate tool on MODS, the wheel on SETTINGS. - foreach(tile_icon mods settings) + # Tile icons: Thunderstore's mark on MODS (the skate tool when it is missing) and on the mod + # manager's Thunderstore button, the wheel on SETTINGS. + foreach(tile_icon mods settings thunderstore) set(tile_icon_path "${PROJECT_SOURCE_DIR}/assets/launcher/icon_${tile_icon}.png") if(EXISTS "${tile_icon_path}") string(TOUPPER "${tile_icon}" tile_icon_name) From 7050fad406c047cb23752790cd41649d2d3768b1 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Wed, 7 Oct 2026 15:12:35 -0500 Subject: [PATCH 089/109] Launcher: trucks back on MODS; Thunderstore button reads as a link out - The MODS tile shows the skate tool again, faded like the wheel on SETTINGS. Thunderstore's mark is no longer drawn there - The mod manager's Thunderstore button is laid out as a link sign, the name, then Thunderstore's mark at the far end, so it is plainly a way out to a website. The sign is drawn (a box with an arrow leaving its corner); the name is no longer in capitals - It keeps Thunderstore's green on its dark blue and its height --- Launcher/gui_home.cpp | 9 +++------ Launcher/gui_mods.cpp | 34 ++++++++++++++++++++++++---------- cmake/Apps.cmake | 4 ++-- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/Launcher/gui_home.cpp b/Launcher/gui_home.cpp index 5498912b..dc46a562 100644 --- a/Launcher/gui_home.cpp +++ b/Launcher/gui_home.cpp @@ -84,12 +84,11 @@ void draw_background(ImDrawList* draw, ImVec2 size, float time) { } // A tile's icon centred at `centre`; false when it isn't loaded (use the drawn one). -bool tile_icon(ImDrawList* draw, const Background& icon, ImVec2 centre, float size, bool hovered, float idle = 0.32f, - float hot = 0.6f) { +bool tile_icon(ImDrawList* draw, const Background& icon, ImVec2 centre, float size, bool hovered) { if (!icon.id) return false; const float half = size * 0.5f; draw->AddImage(icon.id, ImVec2(centre.x - half, centre.y - half), ImVec2(centre.x + half, centre.y + half), - ImVec2(0, 0), ImVec2(1, 1), rgba(255, 255, 255, hovered ? hot : idle)); + ImVec2(0, 0), ImVec2(1, 1), rgba(255, 255, 255, hovered ? 0.6f : 0.32f)); return true; } @@ -212,10 +211,8 @@ bool mods_tile(ImDrawList* draw, const Fonts& fonts, ImVec2 position, ImVec2 siz draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(position.x + S(20), detail_y), bad ? color::danger : color::muted, detail.c_str(), nullptr, 0, &clip); } - // Thunderstore's mark, where the mods come from, in its own green: less faded than the - // wheel on SETTINGS, which is only white. The skate tool when the mark is not built in. + // The skate tool, faded like the wheel on SETTINGS. const ImVec2 centre(end.x - S(46), position.y + size.y * 0.5f); - if (tile_icon(draw, g_icon_thunderstore, centre, S(62), hovered, 0.7f, 1.f)) return pressed; if (tile_icon(draw, g_icon_mods, centre, S(56), hovered)) return pressed; const ImU32 ink = rgba(255, 255, 255, hovered ? 0.55f : 0.3f); for (int layer = 0; layer < 3; ++layer) { diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index f5cb498a..d15784cb 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -117,7 +117,7 @@ std::string summary(const ModsPanel& panel, const mods::Mod& mod) { // The rail: the two pages, then what you can do to the Mods folder. Returns // true when BACK was pressed. -// The way to Thunderstore, in Thunderstore's colours: its mark and green on its dark blue, and +// The way out to Thunderstore's website, in Thunderstore's colours (its green on its dark blue), // taller than the plain buttons beside it. bool thunderstore_button(const Fonts& fonts, const char* label, float height) { const ImVec2 position = ImGui::GetCursorScreenPos(), size(ImGui::GetContentRegionAvail().x, height); @@ -128,19 +128,33 @@ bool thunderstore_button(const Fonts& fonts, const char* label, float height) { const ImVec2 end(position.x + size.x, position.y + size.y); draw->AddRectFilled(position, end, hovered ? rgba(28, 44, 74) : rgba(19, 30, 52), S(4)); draw->AddRect(position, end, rgba(35, 255, 176, hovered ? 1.f : 0.6f), S(4), 0, S(1.5f)); - const float mark = g_icon_thunderstore.id ? height - S(18) : 0, gap = mark ? S(10) : 0; + // [ link Thunderstore mark ]: the link sign first, so it reads as a way out to a website, + // the name, and Thunderstore's mark at the far end. + const ImU32 green = rgba(35, 255, 176); + const float pad = S(14), mark = g_icon_thunderstore.id ? height - S(20) : 0; + { + // A box with its top right corner open and an arrow leaving through it. + const float box = S(13), stroke = S(1.8f); + const ImVec2 at(position.x + pad, position.y + (height - box) * 0.5f); + const ImVec2 outline[]{ImVec2(at.x + box * 0.45f, at.y + box * 0.15f), ImVec2(at.x, at.y + box * 0.15f), + ImVec2(at.x, at.y + box), ImVec2(at.x + box * 0.85f, at.y + box), + ImVec2(at.x + box * 0.85f, at.y + box * 0.55f)}; + draw->AddPolyline(outline, 5, green, 0, stroke); + draw->AddLine(ImVec2(at.x + box * 0.4f, at.y + box * 0.6f), ImVec2(at.x + box, at.y), green, stroke); + const ImVec2 head[]{ImVec2(at.x + box * 0.6f, at.y), ImVec2(at.x + box, at.y), ImVec2(at.x + box, at.y + box * 0.4f)}; + draw->AddPolyline(head, 3, green, 0, stroke); + } const char* shown = std::strstr(label, "##"); // what follows only names the button if (!shown) shown = label + std::strlen(label); const auto text = fonts.body->CalcTextSizeA(fonts.body->FontSize, FLT_MAX, 0, label, shown); - float x = position.x + std::max(S(10), (size.x - mark - gap - text.x) * 0.5f); + const float text_x = position.x + pad + S(13) + S(10); + const ImVec4 clip(position.x, position.y, end.x - pad - mark - (mark ? S(8) : 0), end.y); + draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x, position.y + (height - text.y) * 0.5f), green, label, shown, 0, + &clip); if (mark) { - const float y = position.y + (height - mark) * 0.5f; - draw->AddImage(g_icon_thunderstore.id, ImVec2(x, y), ImVec2(x + mark, y + mark)); - x += mark + gap; + const ImVec2 at(end.x - pad - mark, position.y + (height - mark) * 0.5f); + draw->AddImage(g_icon_thunderstore.id, at, ImVec2(at.x + mark, at.y + mark)); } - const ImVec4 clip(position.x, position.y, end.x - S(6), end.y); - draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(x, position.y + (height - text.y) * 0.5f), rgba(35, 255, 176), label, - shown, 0, &clip); return pressed; } @@ -179,7 +193,7 @@ bool rail(const Fonts& fonts, ModsPanel& panel, HWND window, float width, if (const auto path = pick(window, true); !path.empty()) start_install(panel, path, false); if (ImGui::Button("Open Mods folder", ImVec2(-1, S(32)))) open_path(panel.root); ImGui::Dummy(ImVec2(0, S(4))); - if (thunderstore_button(fonts, "THUNDERSTORE##visit", S(48))) + if (thunderstore_button(fonts, "Thunderstore##visit", S(48))) open_url(utf8(thunderstore::community_page(thunderstore::community()))); ImGui::EndDisabled(); diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 88156971..bcc15c70 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -108,8 +108,8 @@ if(WIN32) list(APPEND launcher_resources "LAUNCHER_BACKGROUND RCDATA \"${launcher_background}\"\n") set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${launcher_background}") endif() - # Tile icons: Thunderstore's mark on MODS (the skate tool when it is missing) and on the mod - # manager's Thunderstore button, the wheel on SETTINGS. + # Tile icons: the skate tool on MODS, the wheel on SETTINGS; and Thunderstore's mark for the + # mod manager's Thunderstore button. foreach(tile_icon mods settings thunderstore) set(tile_icon_path "${PROJECT_SOURCE_DIR}/assets/launcher/icon_${tile_icon}.png") if(EXISTS "${tile_icon_path}") From 737c1611afb1c2d45ea2625f7300465bdd7353b8 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Wed, 7 Oct 2026 15:16:53 -0500 Subject: [PATCH 090/109] Server: only change to a map the server has - The map command, from the console or an admin, refuses a map the server does not have: the game's own maps are always allowed, a custom one only when its mod folder is in the server's Mods. It accepted any level path before, so a server could move everyone to a map it had no mod for - A map setting in ReSkateServer.json that names a map the server does not have now stops the server at start, saying to add the map's mod or change the map - The map change itself checks too, whatever asks for it. Votes, rotation and the map pool were already limited to known maps --- Server/Test/server_config_tests.cpp | 8 ++++++++ Server/server_config.cpp | 9 +++++++-- Server/server_config.h | 3 +++ Server/server_host.cpp | 4 ++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 40a3b0a3..f518de44 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -159,6 +159,14 @@ int run() { check(edges.port == 65535 && edges.query_port == 1, "Ports at the ends of the range refused"); static_cast(load_levels(folder / "Mods")); // no Mods folder: the retail maps only + { + // Only maps the server has: the game's own always, a custom one once its mod is in Mods. + ServerConfig elsewhere; + elsewhere.map = "Levels/Game/NotHere/NotHere"; + check(config_error(elsewhere).find("not a map this server has") != std::string::npos && !installed_map(elsewhere.map), + "A map the server does not have was accepted"); + check(installed_map("Isle of Grom") && installed_map("Levels/Game/DingoLevel_MPR/DingoLevel_MPR"), "One of the game's own maps was refused"); + } ServerConfig pool; check(pool_levels(pool).size() == levels().size() && in_map_pool(pool, "Stadium 2"), "An empty pool does not allow every map"); diff --git a/Server/server_config.cpp b/Server/server_config.cpp index b109db62..adcbc36e 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -263,8 +263,8 @@ std::string config_error(const ServerConfig &c) { std::to_string(max_crowd_budget) + "."; if (c.steam_token.size() > 64 || !std::all_of(c.steam_token.begin(), c.steam_token.end(), [](unsigned char ch) { return std::isalnum(ch); })) return "steam_token must be a game server login token (letters and digits), or empty to sign in anonymously."; - if (c.map.empty() || !valid_map_destination(map_destination(c.map))) - return "map \"" + c.map + "\" is not a known map. Use a name like \"San Vansterdam\", or put the map's mod " + if (c.map.empty() || !valid_map_destination(map_destination(c.map)) || !installed_map(c.map)) + return "map \"" + c.map + "\" is not a map this server has. Use a name like \"San Vansterdam\", or put the map's mod " "folder in Mods next to the server."; for (const auto &map : c.map_pool) if (!find_level(map) || !valid_map_destination(map_destination(map))) @@ -356,6 +356,11 @@ const ServerLevel *find_level(std::string_view map) { } return nullptr; } +bool installed_map(std::string_view map) { + const auto destination = map_destination(map); + const auto asset = world_destination_asset(destination); + return !asset.empty() && std::any_of(level_list().begin(), level_list().end(), [&](const auto &level) { return same(level.asset, asset); }); +} std::string map_destination(std::string_view map) { if (map.find('|') != std::string_view::npos) return std::string(map); if (const auto *level = find_level(map)) return std::string(root_level) + "|" + level->asset; diff --git a/Server/server_config.h b/Server/server_config.h index a46dc361..a257a963 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -118,6 +118,9 @@ std::vector load_levels(const std::filesystem::path &mods); const std::vector &levels(); // Like the game's `load`: a level path, a name or short name, or the unique start of one. const ServerLevel *find_level(std::string_view map); +// Whether the server has this map (a name, level path or destination): one of the game's own, +// or one a mod folder in its Mods lists. It only moves players to a map it has itself. +bool installed_map(std::string_view map); // What players load for a map, as the protocol carries it ("|"). // Empty when the map is unknown (a full level path is always accepted). std::string map_destination(std::string_view map); diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 8c843e85..dee1ffa2 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -459,6 +459,7 @@ void Host::send_maps(Guest &guest) { // admins: every map and the pool; players: void Host::change_map(std::string_view map) { if (!valid_map_destination(map_destination(map_setting(map)))) throw std::invalid_argument("That map does not name a destination."); + if (!installed_map(map)) throw std::invalid_argument("This server does not have that map."); // Everything tied to the old world goes; admission and player slots stay. for (auto &[id, guest] : guests_) { auto &g = *guest; @@ -1303,6 +1304,9 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { if (argument.empty() || !valid_map_destination(map_destination(argument)) || !valid_map_destination(map_destination(map_setting(argument)))) return "No single map is called \"" + std::string(argument) + "\". Type maps for the list."; + // Only a map the server has: the game's own, or one from a mod in its Mods folder. + if (!installed_map(argument)) + return "This server does not have that map. Put the map's mod folder in Mods next to the server, then restart it."; if (map_hash(map_destination(argument)) == map_) return "The server is already on that map."; change_map(argument); save(); From d540b8864006997e22453cde2ebe9dbf05d7d9e4 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Wed, 7 Oct 2026 15:27:05 -0500 Subject: [PATCH 091/109] Dedicated servers run at 20 TPS; still players at the low rate; message commands in the "/" menu Server - Dedicated servers run at 20 network updates a second, fixed for now: whatever tps says in ReSkateServer.json is read as 20, and the tps command no longer changes it. A busy server's traffic, in and out, is its players' poses, and 20 a second is a third less than 30. Lobbies keep their own choice - A player who has not moved or turned for three seconds is sent on at five poses a second; the full rate is back with their first movement - "net" shows what a pose costs as sent, and what it would cost against a reference renewed every second, every quarter second, and against the pose before, measured on every fourth pose that arrives. It only measures: a nearer reference packs smaller, and this says by how much before the server is made to send them that way Game - The "/" menu on a dedicated server lists /w for everyone, and /msg, /msg-party and /msg-admins for admins. The server already answered them; the menu is the game's own list and never had them --- .../Multiplayer/Session/session_view.cpp | 6 ++ Server/README.txt | 7 ++- Server/Test/server_config_tests.cpp | 2 +- Server/server_config.cpp | 4 +- Server/server_config.h | 6 +- Server/server_host.cpp | 58 +++++++++++++++++-- Server/server_host.h | 15 +++++ 7 files changed, 85 insertions(+), 13 deletions(-) diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 7644e761..7eff34d6 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -457,6 +457,9 @@ std::vector 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 ", "Send a player a private message", "player"}); if (s.server_votes & server_vote_map) list.push_back({"/vote map", "/vote map ", "Start a vote to change the map", "map"}); if (s.server_votes & server_vote_kick) list.push_back({"/vote kick", "/vote kick ", "Start a vote to kick a player", "player"}); @@ -467,6 +470,9 @@ std::vector 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 ", "Admin: message a player privately", "player"}); + list.push_back({"/msg-party", "/msg-party ", "Admin: message everyone in a player's party", "player"}); + list.push_back({"/msg-admins", "/msg-admins ", "Admin: message the admins who are on"}); list.push_back({"/kick", "/kick ", "Admin: kick a player until the server restarts", "player"}); list.push_back({"/ban", "/ban ", "Admin: ban a player", "player"}); list.push_back({"/map", "/map ", "Admin: change the server's map", "map"}); diff --git a/Server/README.txt b/Server/README.txt index 170323ac..dd5a177f 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -63,7 +63,7 @@ send_rate The most the server sends one player, in KB/s (default 900, crowd_budget The most position updates a second one player is sent (default 600, 0 for no limit). Players near each other are sent at the full rate (tps); this only matters once more are - in one place than budget / tps, about 20 at 30 TPS. Then the + in one place than budget / tps, about 30 at 20 TPS. Then the nearest stay at the full rate and the farthest of the crowd drop to 10 and 5 a second, instead of everyone's connection filling up. About 1000 is what reaches a player through @@ -135,7 +135,8 @@ activity_log Log what players do (default true): throwdown drops placed, joins, starts, turns and results; objects placed or removed; how long players take to load. port, query_port Steam game server ports (default 27015, 27016). -tps Network updates per second: 20, 30, 60 or 120. +tps Network updates per second. Fixed at 20 on dedicated servers + for now: whatever is set here is read as 20. voice_chat Allow voice chat. voice_range How far proximity voice reaches, 50-1000 m. distances When far-away players update less often (metres). @@ -193,7 +194,7 @@ and change voice, distances, placement and kicks from the Multiplayer menu. rotation uses (see map_pool). rotation [|off] Change the map on a timer (see map_rotation_minutes). name password welcome listed on|off - tps 20|30|60|120 voice on|off voice-range + voice on|off voice-range distances placement everyone|admins|nobody clear-objects objects |off How many objects each player may have placed. diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index f518de44..2312f0f2 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -42,7 +42,7 @@ int run() { const auto written = text(file); check(written.find("\"enforce_tuning\"") != std::string::npos && written.find("\"seconds\"") != std::string::npos, "New settings not written into the file"); - check(config.name == "Old Server" && config.tps == 60 && !config.boosts && config.votes.map.enabled && + check(config.name == "Old Server" && config.tps == dedicated_tps /* fixed: the file's 60 is not kept */ && !config.boosts && config.votes.map.enabled && config.votes.map.percent == 60 && config.enforce_tuning && config.global_bans, "The file's own values or the new defaults were lost"); const auto reloaded = load_config(file); diff --git a/Server/server_config.cpp b/Server/server_config.cpp index adcbc36e..3c64b41d 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -163,7 +163,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector 64) return "password must be at most 64 characters."; if (!c.welcome.empty() && !valid_chat_text(c.welcome)) return "welcome must be one chat line (at most 200 bytes)."; - if (!valid_multiplayer_tps(c.tps)) return "tps must be 20, 30, 60 or 120."; + if (c.tps != dedicated_tps) return "tps is " + std::to_string(dedicated_tps) + " on dedicated servers."; if (!valid_voice_range(c.voice_range)) return "voice_range must be 50 to 1000."; if (!valid_object_limit(c.object_limit)) return "object_limit must be 0 (no limit) to " + std::to_string(max_object_limit) + "."; if (!c.distances.valid()) return "distances must be ordered: full_rate_return < half_rate_start <= half_rate_return < low_rate_start <= 10000."; diff --git a/Server/server_config.h b/Server/server_config.h index a257a963..3bb75025 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -27,6 +27,7 @@ struct VoteSettings { }; // ReSkateServer.json. Every setting an admin or the console changes is saved // back, so a restart keeps it. +inline constexpr unsigned dedicated_tps = 20; struct ServerConfig { std::filesystem::path file; std::string name = "ReSkate server"; @@ -70,7 +71,10 @@ struct ServerConfig { // everyone installs). Each player's fingerprint is in the console when they are flagged. std::vector score_allow; std::uint16_t port = 27015, query_port = 27016; - unsigned tps = multiplayer_default_tps; + // Fixed for dedicated servers for now (dedicated_tps): a busy one's traffic, in and out, + // is its players' poses, and at 20 a second that is a third less than at 30. Whatever + // the file says is read as this; lobbies keep their own choice. + unsigned tps = dedicated_tps; bool voice_chat = true; float voice_range = default_voice_range; MultiplayerDistances distances; diff --git a/Server/server_host.cpp b/Server/server_host.cpp index dee1ffa2..3dd4bf09 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -1,6 +1,8 @@ #include "server_host.h" #include "server_text.h" #include "Extension/Multiplayer/Net/wire_codec.h" +#include "Extension/Multiplayer/Net/block_codec.h" +#include "Extension/Multiplayer/Net/pose_delta.h" #include "Extension/Multiplayer/developer_identity.h" #include "Extension/Multiplayer/Session/monotonic_clock.h" #include "Engine/Core/Text/word_filter.h" @@ -44,7 +46,7 @@ std::uint64_t nonce() { constexpr std::string_view help_text = "status | net [player] | players | say | msg | msg-party | msg-admins | kick | ban [name] | unban | bans\n" "map | maps | name | password | welcome | listed on|off\n" - "tps 20|30|60|120 | voice on|off | voice-range <50-1000> | distances | crowd |off | rate \n" + "voice on|off | voice-range <50-1000> | distances | crowd |off | rate \n" "placement everyone|admins|nobody | objects |off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" "tpall [player] | tphere | votes [map|kick|tod on|off|] | vote-cancel\n" "map-pool [add|remove |clear] | rotation [|off]\n" @@ -203,7 +205,32 @@ void Host::send_required(Guest &g, const std::vector &bytes) { if (!p || !send_packet(g, *p, true, false)) transport_.disconnect(g.member.id, "Cannot deliver required session data. Join again."); } +void Host::measure_pose(Guest &from, const Packet &packet) { + auto raw = encode(packet, true); + // As DeltaSender would send it: the patch packed, behind its 34 byte header. + const auto cost = [&](const Guest::Earlier &reference) -> std::uint64_t { + if (reference.raw.empty()) return 0; + const auto patch = pose_delta::encode(raw, reference.raw); + return patch.empty() ? 0 : 34 + compress_block(patch).bytes.size(); + }; + if (packet.sequence % 4 == 0) { + const auto last = cost(from.pose_last), quarter = cost(from.pose_quarter), second = cost(from.pose_second); + if (last && quarter && second) { + ++pose_sizes_.samples; + pose_sizes_.whole += encode_wire_bytes(raw).size() + 4; + pose_sizes_.last += last; + pose_sizes_.quarter += quarter; + pose_sizes_.second += second; + } + } + // Each kept reference is replaced once it is its age old, as a whole state sent that often would be. + for (auto [kept, age] : {std::pair{&from.pose_quarter, 250000ULL}, std::pair{&from.pose_second, 1000000ULL}}) + if (kept->raw.empty() || packet.time_us < kept->time || packet.time_us - kept->time >= age) *kept = {raw, packet.time_us}; + from.pose_last = {std::move(raw), packet.time_us}; +} void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint64_t except) { + if (packet.kind == PacketKind::pose) + if (auto *from = find(packet.source)) measure_pose(*from, packet); const auto *source = find(packet.source); // Recipients holding the same delta reference share one patch and compression // (DeltaCache): every update is built first, then all are sent and recorded. @@ -272,6 +299,9 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 } else { delivery->by_distance = 0; } + // Standing still for a few seconds: five poses a second carry it to anyone. The + // rate is back at once when they move (the change of rate sends the next pose). + if (source && source->moved_at && now_ - source->moved_at > 3000000) interval = std::max(interval, 200000); if (delivery->interval_us != interval) delivery->next_source_time = 0; delivery->interval_us = interval; if (interval > multiplayer_pose_interval(config_.tps) && packet.time_us < delivery->next_source_time) continue; @@ -299,6 +329,10 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 counted->total.out[traffic_kind(sending.kind)] += out.update.bytes.size(); counted->total.snapshots += whole; } + if (sending.kind == PacketKind::pose && !whole) { + ++pose_sizes_.sent; + pose_sizes_.sent_bytes += out.update.bytes.size(); + } out.guest->sender.sent(sending, std::move(out.update)); } } @@ -544,6 +578,16 @@ bool Host::accept_data(Guest &source, const Packet &p) { if (p.kind == PacketKind::pose) { source.pose_arrival = now_; source.latest_root = p.pose.root; + { + const auto &root = p.pose.root, &was = source.still_at; + float moved{}, facing{}; + for (unsigned i = 0; i < 3; ++i) moved += (root.position[i] - was.position[i]) * (root.position[i] - was.position[i]); + for (unsigned i = 0; i < 4; ++i) facing += root.rotation[i] * was.rotation[i]; + if (!source.moved_at || moved > 0.05f * 0.05f || std::abs(facing) < 0.9995f) { + source.still_at = root; + source.moved_at = now_; + } + } return check_speed(source, p.time_us); // last: a speed-check kick frees `source` } return true; @@ -942,6 +986,12 @@ std::string Host::network_report(std::string_view player, bool console) { if (!console) return text; text += "\nloop: " + loop_report(); text += "\n" + by_kind(traffic_); + if (pose_sizes_.samples && pose_sizes_.sent) { + const auto each = [&](std::uint64_t sum) { return std::to_string(sum / pose_sizes_.samples) + " B"; }; + text += "\npose size: " + std::to_string(pose_sizes_.sent_bytes / pose_sizes_.sent) + " B as sent (whole " + each(pose_sizes_.whole) + + "); it would be " + each(pose_sizes_.second) + " against a reference renewed every second, " + each(pose_sizes_.quarter) + + " every quarter second, " + each(pose_sizes_.last) + " against the pose before (" + std::to_string(pose_sizes_.samples) + " measured)"; + } { // Who uploads most, and what most of it is: one player's outfit or objects can cost // every other player's connection. @@ -1507,11 +1557,7 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { return changed(*value ? "The server is listed in the server browser." : "The server is hidden; players need the code."); } if (name == "tps") { - const auto value = number(argument); - if (!value || !valid_multiplayer_tps(static_cast(*value))) return "tps 20|30|60|120"; - config_.tps = static_cast(*value); - for (auto &[id, guest] : guests_) guest->pose_delivery = {}; - return changed("Network updates set to " + std::to_string(config_.tps) + " TPS."); + return "Dedicated servers run at " + std::to_string(dedicated_tps) + " TPS for now; it cannot be changed."; } if (name == "voice") { const auto value = on_off(argument); diff --git a/Server/server_host.h b/Server/server_host.h index a354c9be..4d35af4b 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -79,6 +79,14 @@ class Host { struct PendingCosmetics { Packet packet; std::uint64_t received{}; }; std::vector pending_cosmetics; std::optional latest_root; + // Where they last stood and faced, and when they last moved from it: a player standing + // still (in a menu, away from the keyboard, watching) is sent on at the low rate. + // Their earlier poses, as encoded: the one before, and ones kept a quarter of a second + // and a second (measure_pose). + struct Earlier { std::vector raw; std::uint64_t time{}; }; + Earlier pose_last, pose_quarter, pose_second; + Transform still_at; + std::uint64_t moved_at{}; std::uint64_t pose_arrival{}; std::array pose_delivery; CrowdLimits crowd; // how far the full and half rates reach for them in a crowd @@ -165,6 +173,13 @@ class Host { } loop_, last_loop_, worst_loop_; std::uint64_t pass_started_{}; Counted traffic_; + // What a pose costs to send as a difference from references of different ages, measured on + // every fourth pose that arrives (net). A nearer reference changes less, so it packs + // smaller: this says by how much, before the server is made to send them that way. + struct PoseSizes { + std::uint64_t samples{}, whole{}, last{}, quarter{}, second{}, sent{}, sent_bytes{}; + } pose_sizes_; + void measure_pose(Guest &from, const Packet &packet); std::uint64_t traffic_mark_{}, traffic_window_us_{}; // when `mark` was taken, and how long `last` covers void meet_later(Guest &guest); void introduce(); From 7b093429a22a7f9ba128cb658d33cd2f5dbcd403 Mon Sep 17 00:00:00 2001 From: NorthDroplet Date: Wed, 7 Oct 2026 20:57:08 +0000 Subject: [PATCH 092/109] Launcher: controller starts on PLAY and the open tab; rows and side moves in lists - The focus starts on PLAY when the launcher opens with a pad connected, and on the open tab (GAME, MY MODS) when the pad opens Settings or the Mod manager. - List rows act as one item: up and down go from row to row in one press, and a row's buttons take the focus only from the row (D-pad right). - D-pad left and right between the rail and a list: ImGui looks to the side only along a thin band through the focus and missed items level with the gap between two rows, so a side move that finds nothing looks again across the focus's full height. - The left stick scrolls the panel under the pointer directly instead of through wheel events, which queued behind the pointer's moves. - On a Steam Deck (SteamDeck=1) the right stick gets a trackpad's feel: small swipes move the pointer, and the glide after the thumb lifts is cut. --- Launcher/Test/gamepad_input_tests.cpp | 112 +++++++++++++++++++++++ Launcher/gamepad_input.cpp | 122 ++++++++++++++++++++------ Launcher/gamepad_input.h | 24 +++++ Launcher/gui.cpp | 6 +- Launcher/gui_home.cpp | 5 +- Launcher/gui_mods.cpp | 6 ++ Launcher/gui_mods_browse.cpp | 3 + Launcher/gui_settings.cpp | 5 +- 8 files changed, 255 insertions(+), 28 deletions(-) diff --git a/Launcher/Test/gamepad_input_tests.cpp b/Launcher/Test/gamepad_input_tests.cpp index 736e0fa4..37df5304 100644 --- a/Launcher/Test/gamepad_input_tests.cpp +++ b/Launcher/Test/gamepad_input_tests.cpp @@ -22,6 +22,7 @@ void check(bool condition, const char* what) { ++failures; } +constexpr std::uint16_t dpad_up = 0x0001, dpad_left = 0x0004; constexpr std::uint16_t dpad_down = 0x0002, dpad_right = 0x0008, right_thumb = 0x0080, button_a = 0x1000, button_b = 0x2000; struct Seen { @@ -39,6 +40,7 @@ Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, bool pane io.DeltaTime = 1.0f / 60.0f; feed.update(io, pad, 1.0f); ImGui::NewFrame(); + feed.after_new_frame(); Seen seen; ImGui::SetNextWindowPos(ImVec2(0, 0)); ImGui::SetNextWindowSize(ImVec2(800, 600)); @@ -99,6 +101,56 @@ std::string press(PadFeed& feed, std::uint16_t button) { return pressed.empty() ? released : pressed; } +// The Mod manager's layout: a rail of tabs beside a list whose rows carry +// buttons, the rail's tiles level with the gaps between rows. +std::string mods_page(PadFeed& feed, const PadState& state) { + auto& io = ImGui::GetIO(); + io.DisplaySize = ImVec2(900, 700); + io.DeltaTime = 1.0f / 60.0f; + feed.update(io, state, 1.0f); + ImGui::NewFrame(); + feed.after_new_frame(); + std::string pressed; + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(900, 700)); + ImGui::Begin("##page", nullptr, ImGuiWindowFlags_NoDecoration); + ImGui::SetCursorPos(ImVec2(20, 100)); + ImGui::BeginChild("##rail", ImVec2(200, 400), ImGuiChildFlags_NavFlattened); + if (ImGui::Button("MY MODS", ImVec2(150, 40))) pressed = "MY MODS"; + dingosdk::launcher_gui::default_focus(); + if (ImGui::Button("GET MODS", ImVec2(150, 40))) pressed = "GET MODS"; + ImGui::EndChild(); + ImGui::SetCursorPos(ImVec2(260, 100)); + ImGui::BeginChild("##content", ImVec2(600, 500), ImGuiChildFlags_NavFlattened); + for (int row = 0; row < 12; ++row) { + ImGui::PushID(row); + dingosdk::launcher_gui::begin_row(); + const ImVec2 start = ImGui::GetCursorScreenPos(); + if (ImGui::Selectable("##row", false, ImGuiSelectableFlags_AllowOverlap, ImVec2(560, 60))) + pressed = "row " + std::to_string(row); + const ImVec2 next = ImGui::GetCursorScreenPos(); + dingosdk::launcher_gui::row_buttons(); + ImGui::SetCursorScreenPos(ImVec2(start.x + 380, start.y + 14)); + if (ImGui::Button("INSTALL", ImVec2(100, 32))) pressed = "install " + std::to_string(row); + dingosdk::launcher_gui::end_row(); + ImGui::SetCursorScreenPos(next); + ImGui::PopID(); + } + ImGui::EndChild(); + ImGui::End(); + ImGui::Render(); + return pressed; +} + +// Taps a D-pad direction on the Mod manager, then A: what A pressed is where the focus went. +std::string mods_tap(PadFeed& feed, std::uint16_t button) { + mods_page(feed, pad(button)); + for (int i = 0; i < 3; ++i) mods_page(feed, pad()); + std::string pressed = mods_page(feed, pad(button_a)); + pressed += mods_page(feed, pad()); + return pressed; +} + void fresh() { if (ImGui::GetCurrentContext()) ImGui::DestroyContext(); ImGui::CreateContext(); @@ -138,6 +190,33 @@ int main() { check(!(ImGui::GetIO().BackendFlags & ImGuiBackendFlags_HasGamepad), "An unplugged pad is no gamepad"); } + // ------------------------------------------------ the Mod manager + { + fresh(); + PadFeed feed; + mods_page(feed, pad()); + mods_page(feed, pad()); + check(GImGui->NavCursorVisible && mods_tap(feed, 0) == "MY MODS", + "With a pad connected the page opens with the focus on its tab"); + check(mods_tap(feed, dpad_down) == "GET MODS", "Down goes to the next tab"); + const auto row = mods_tap(feed, dpad_right); + check(row.rfind("row ", 0) == 0, "Right goes from a tab level with a gap between rows into the list"); + check(mods_tap(feed, dpad_left) == "GET MODS", "Left goes from the list back to the tabs"); + mods_tap(feed, dpad_right); + const int first = std::stoi(mods_tap(feed, dpad_down).substr(4)); + check(mods_tap(feed, dpad_down) == "row " + std::to_string(first + 1), "Down goes to the next row in one press"); + check(mods_tap(feed, dpad_up) == "row " + std::to_string(first), "Up goes to the row above in one press"); + check(mods_tap(feed, dpad_right) == "install " + std::to_string(first), "Right goes to the row's own button"); + check(mods_tap(feed, dpad_up) == "row " + std::to_string(first - 1), "Up from a row's button goes to the row above"); + } + { + fresh(); + PadFeed feed; + mods_page(feed, PadState{}); + mods_page(feed, PadState{}); + check(!GImGui->NavCursorVisible, "Without a pad nothing shows a focus"); + } + // ------------------------------------------------ back { fresh(); @@ -227,6 +306,39 @@ int main() { check(scroll > 100, "The left stick scrolls the panel under the mouse"); } + // ------------------------------------------------ the Steam Deck's trackpad + { + fresh(); + PadFeed stick; + PadFeed trackpad(true); + ImGui::GetIO().AddMousePosEvent(400, 300); + frame(trackpad, pad()); + for (int i = 0; i < 10; ++i) frame(trackpad, pad(0, 3300, 0)); // a slow swipe + frame(trackpad, pad()); + check(ImGui::GetIO().MousePos.x > 405, "A slow swipe on the trackpad moves the pointer"); + fresh(); + ImGui::GetIO().AddMousePosEvent(400, 300); + frame(stick, pad()); + for (int i = 0; i < 10; ++i) frame(stick, pad(0, 3300, 0)); + frame(stick, pad()); + check(!stick.pointing() && ImGui::GetIO().MousePos.x == 400, "The same small tilt of a thumbstick does not"); + + fresh(); + PadFeed glide(true); + ImGui::GetIO().AddMousePosEvent(100, 300); + frame(glide, pad()); + for (int i = 0; i < 5; ++i) frame(glide, pad(0, 20000, 0)); // the swipe + float tilt = 20000; + float stopped = 0; + for (int i = 0; i < 30; ++i) { // the thumb lifted: the tilt only fades + tilt *= 0.9f; + frame(glide, pad(0, static_cast(tilt), 0)); + if (i == 6) stopped = ImGui::GetIO().MousePos.x; + } + frame(glide, pad()); + check(stopped > 100 && ImGui::GetIO().MousePos.x == stopped, "The trackpad's glide after the thumb lifts does not carry the pointer on"); + } + // ------------------------------------------------ trackpad pointer { fresh(); diff --git a/Launcher/gamepad_input.cpp b/Launcher/gamepad_input.cpp index 0e551cb8..f6df1d11 100644 --- a/Launcher/gamepad_input.cpp +++ b/Launcher/gamepad_input.cpp @@ -24,15 +24,25 @@ constexpr std::uint16_t focus_bits = dpad_up | dpad_down | dpad_left | dpad_righ // XInput's left stick dead zone. constexpr float left_dead_zone = 7849.0f / 32767.0f; -// Larger on the right: a thumb resting on the Deck's trackpad is never quite -// centred, and the pointer must not creep under it. -constexpr float right_dead_zone = 0.2f; -// Pointer speed at full tilt, in design pixels a second. -constexpr float pointer_speed = 1400.0f; -// Scrolling at full tilt: design pixels a second for the focused panel, and -// mouse wheel notches a second (ImGui scrolls five lines a notch) under a pointer. +// Scrolling at full tilt, in design pixels a second. constexpr float scroll_speed = 1200.0f; -constexpr float wheel_speed = 16.0f; + +// How the right stick drives the pointer. +struct PointerFeel { + float dead_zone; + float speed; // design pixels a second at full tilt + bool squared; // a small tilt is slow, for placing the pointer precisely + bool cut_glide; // stop once the tilt only fades +}; +// A thumbstick sets a speed. A resting thumb is never quite centred, so the +// dead zone is large. +constexpr PointerFeel stick_feel{0.2f, 1400.0f, true, false}; +// The Steam Deck's trackpad, in Steam's gamepad layouts, tilts the stick by how +// fast the thumb swipes and keeps gliding after it lifts, fading out. Small +// swipes must move the pointer, and the glide must not carry it on. +constexpr PointerFeel trackpad_feel{0.06f, 2600.0f, false, true}; +// Frames of a steadily fading tilt that make it a glide rather than the thumb. +constexpr int glide_frames = 4; // A mouse move this close to the last one is the same place reported again. constexpr float pointer_slack = 2.0f; @@ -41,20 +51,17 @@ float axis(std::int16_t value) { return std::clamp(static_cast(value) / 3 // The left stick scrolls whatever the user is looking at: the panel holding // the pad's focus while the focus is shown, else the one under the pointer. // ImGui's own stick scrolling only ever moves the focused window, which under a -// pointer is usually the page itself, with nothing to scroll. -void scroll(ImGuiIO& io, float tilt, float scale) { +// pointer is usually the page itself, with nothing to scroll. It scrolls the +// panel directly: wheel events would queue behind the pointer's moves and lag. +void scroll(const ImGuiIO& io, float tilt, float scale) { const float amount = std::copysign(std::clamp((std::abs(tilt) - left_dead_zone) / (1 - left_dead_zone), 0.0f, 1.0f), tilt); if (amount == 0.0f) return; const ImGuiContext& g = *GImGui; - if (g.NavCursorVisible && g.NavHighlightItemUnderNav) { - // The nearest panel around the focus that can scroll. - ImGuiWindow* window = g.NavWindow; - while (window && window->ScrollMax.y <= 0 && (window->Flags & ImGuiWindowFlags_ChildWindow)) window = window->ParentWindow; - if (window && window->ScrollMax.y > 0) - ImGui::SetScrollY(window, window->Scroll.y - amount * scroll_speed * scale * io.DeltaTime); - return; - } - io.AddMouseWheelEvent(0, amount * wheel_speed * io.DeltaTime); + ImGuiWindow* window = g.NavCursorVisible && g.NavHighlightItemUnderNav ? g.NavWindow : g.HoveredWindow; + // The nearest panel around it that can scroll. + while (window && window->ScrollMax.y <= 0 && (window->Flags & ImGuiWindowFlags_ChildWindow)) window = window->ParentWindow; + if (window && window->ScrollMax.y > 0) + ImGui::SetScrollY(window, window->Scroll.y - amount * scroll_speed * scale * io.DeltaTime); } // B goes to ImGui's own cancel while ImGui has something to cancel: a combo or @@ -99,22 +106,28 @@ void PadFeed::update(ImGuiIO& io, const PadState& input, float scale) { // a pointer moves, so the pointer goes away instead. Scrolling keeps both. if (pressed & focus_bits) pointing_ = false; + const PointerFeel& feel = trackpad_ ? trackpad_feel : stick_feel; const float x = axis(pad.right_x), y = -axis(pad.right_y); // screen Y grows downward const float tilt = std::min(1.0f, std::sqrt(x * x + y * y)); + if (feel.cut_glide) { + // A glide only ever fades; the thumb speeds up again or stops. + if (tilt <= feel.dead_zone || tilt > last_tilt_ + 0.01f) fading_ = 0; + else if (tilt < last_tilt_) ++fading_; + last_tilt_ = tilt; + } + const bool steering = tilt > feel.dead_zone && fading_ < glide_frames; // The first R3 only shows the pointer: a stray click must not press // whatever the mouse last rested on. const bool click = pointing_ && (pressed & right_thumb); - if (!pointing_ && (tilt > right_dead_zone || (pressed & right_thumb))) { + if (!pointing_ && (steering || (pressed & right_thumb))) { pointing_ = true; // From wherever the mouse left it, which may be outside the window. pointer_ = ImGui::IsMousePosValid(&io.MousePos) ? io.MousePos : ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f); } - if (pointing_ && tilt > right_dead_zone) { - // Squared, so a small tilt places the pointer precisely and a full one - // crosses the window in about a second. - const float speed = (tilt - right_dead_zone) / (1 - right_dead_zone); - const float step = speed * speed * pointer_speed * scale * io.DeltaTime / tilt; + if (pointing_ && steering) { + const float speed = (tilt - feel.dead_zone) / (1 - feel.dead_zone); + const float step = (feel.squared ? speed * speed : speed) * feel.speed * scale * io.DeltaTime / tilt; pointer_.x += x * step; pointer_.y += y * step; } @@ -134,6 +147,65 @@ void PadFeed::update(ImGuiIO& io, const PadState& input, float scale) { io.MouseDrawCursor = pointing_; } +void PadFeed::after_new_frame() { + ImGuiContext& g = *GImGui; + // ImGui looks to the side only along a thin band through the middle of the + // focus, so it misses an item level with the gap between two list rows: the + // rail's tiles beside a list. A side move that found nothing looks again + // across the focus's full height. + if (side_dir_ != ImGuiDir_None && g.NavId == side_from_ && g.NavWindow && !g.NavMoveSubmitted) { + const ImGuiDir dir = side_dir_; + side_dir_ = ImGuiDir_None; + ImGui::NavMoveRequestSubmit(dir, dir, ImGuiNavMoveFlags_None, + ImGuiScrollFlags_KeepVisibleEdgeX | ImGuiScrollFlags_KeepVisibleEdgeY); + g.NavScoringRect = ImGui::WindowRectRelToAbs(g.NavWindow, g.NavWindow->NavRectRel[g.NavLayer]); + last_nav_id_ = g.NavId; + return; + } + side_dir_ = ImGuiDir_None; + if (g.NavMoveSubmitted && (g.NavMoveDir == ImGuiDir_Left || g.NavMoveDir == ImGuiDir_Right)) { + side_dir_ = g.NavMoveDir; + // ImGui clears the focus for the move when the item is wider than its + // panel; the move then lands on that same item, which is no move either. + side_from_ = g.NavId ? g.NavId : last_nav_id_; + } + last_nav_id_ = g.NavId; +} + +namespace { +bool g_row_focused{}; +} // namespace + +void default_focus() { + ImGui::SetItemDefaultFocus(); + ImGuiContext& g = *GImGui; + // With a pad there, the focus starts on it straight away as the window + // appears: when the launcher starts, and when the pad opened this page. + // (Not whenever nothing has the focus: ImGui clears it for a frame while + // the focus moves off a row wider than its panel.) + const bool pad = (g.IO.BackendFlags & ImGuiBackendFlags_HasGamepad) != 0; + const bool pad_led = g.NavCursorVisible && g.NavHighlightItemUnderNav; + if (!pad || !ImGui::IsWindowAppearing() || !(pad_led || g.FrameCount <= 2)) return; + ImGui::SetFocusID(g.LastItemData.ID, g.CurrentWindow); + g.NavCursorVisible = true; + g.NavHighlightItemUnderNav = true; +} + +void begin_row() { + const ImGuiID scope = ImGui::GetID("##row_focus"); + g_row_focused = GImGui->NavFocusScopeId == scope; + ImGui::PushFocusScope(scope); +} + +void row_buttons() { + ImGui::PushItemFlag(ImGuiItemFlags_NoNav, !g_row_focused); +} + +void end_row() { + ImGui::PopItemFlag(); + ImGui::PopFocusScope(); +} + void PadFeed::mouse_moved(ImVec2 position) { // Windows repeats a mouse move where the mouse already was when a window // changes under it; only a move to a new place is the mouse or a finger. diff --git a/Launcher/gamepad_input.h b/Launcher/gamepad_input.h index ea1d7ac2..5bffc92a 100644 --- a/Launcher/gamepad_input.h +++ b/Launcher/gamepad_input.h @@ -28,9 +28,14 @@ struct PadState { // click is R3, so this is what makes the trackpad a mouse in the launcher. class PadFeed { public: + // `trackpad`: the right stick is a Steam Deck's trackpad, not a thumbstick. + explicit PadFeed(bool trackpad = false) : trackpad_(trackpad) {} // Call once a frame after the backends' NewFrame and before // ImGui::NewFrame(), which takes the events in. void update(ImGuiIO& io, const PadState& pad, float scale); + // Call right after ImGui::NewFrame(): looks again for a D-pad left or right + // that ImGui found nothing for. + void after_new_frame(); // A mouse move or a touch the window received (WM_MOUSEMOVE, client area // pixels). A move to a new place takes the pointer back from the pad. void mouse_moved(ImVec2 position); @@ -40,7 +45,13 @@ public: bool pointing() const { return pointing_; } private: + bool trackpad_{}; std::uint16_t buttons_{}; + float last_tilt_{}; + int fading_{}; // frames the right stick's tilt only faded + ImGuiDir side_dir_{ImGuiDir_None}; // a D-pad left or right ImGui looked for last frame + ImGuiID side_from_{}; + ImGuiID last_nav_id_{}; ImGuiKey back_key_{ImGuiKey_Escape}; bool pointing_{}; bool clicking_{}; // R3 holds the left button @@ -48,4 +59,17 @@ private: ImVec2 mouse_{-1, -1}; // where the last mouse move was }; +// Makes the item just submitted the one a controller starts on in its window. +// With a pad connected, the focus moves there at once when nothing has it yet +// or when the pad opened the page. +void default_focus(); + +// A list row a controller treats as one item: up and down go from row to row +// in one press, and the buttons on a row take the focus only from the row +// itself (D-pad right). begin_row() before the row's list_row, row_buttons() +// after it, end_row() after the row's last button. +void begin_row(); +void row_buttons(); +void end_row(); + } // namespace dingosdk::launcher_gui diff --git a/Launcher/gui.cpp b/Launcher/gui.cpp index 5b82c64d..ba7ef22c 100644 --- a/Launcher/gui.cpp +++ b/Launcher/gui.cpp @@ -435,7 +435,10 @@ int run(const launcher_app::Session& session, const std::vector& a const auto ui_storage = std::make_unique(); auto& ui = *ui_storage; ModsPanel mods_panel; - PadFeed pad_feed; + // The Steam Deck's right trackpad reaches the launcher as the right stick + // (Steam sets SteamDeck=1 for what it starts there), which wants a + // trackpad's feel rather than a thumbstick's. + PadFeed pad_feed(GetEnvironmentVariableW(L"SteamDeck", nullptr, 0) > 0); g_pad_feed = &pad_feed; bool running = true; HANDLE game{}; @@ -490,6 +493,7 @@ int run(const launcher_app::Session& session, const std::vector& a const bool foreground = GetForegroundWindow() == window; pad_feed.update(ImGui::GetIO(), foreground ? read_pad() : PadState{}, g_scale); ImGui::NewFrame(); + pad_feed.after_new_frame(); frame(launcher, fonts, window, ui, mods_panel); // While the pad moves the focus, the mouse cursor (the Deck's trackpad in its // desktop layout) hides; the next mouse move hides the focus and brings it back. diff --git a/Launcher/gui_home.cpp b/Launcher/gui_home.cpp index f12306b2..40223c16 100644 --- a/Launcher/gui_home.cpp +++ b/Launcher/gui_home.cpp @@ -1,6 +1,7 @@ #include "gui_internal.h" -#include "mod_manager.h" +#include "mod_manager.h" + #include "problem.h" #include @@ -463,6 +464,8 @@ void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPane ? (ui.mods_pending == 1 ? std::string("1 MOD UPDATE") : std::format("{} MOD UPDATES", ui.mods_pending)) : std::string(); if (action_tile(draw, fonts, primary, primary_size, label, detail, enabled && !modal, secondary, play_mark)) act(); + // A controller starts on PLAY. + if (!modal) default_focus(); if (settings_tile(draw, fonts, settings, ImVec2(column, S(84)), !modal)) ui.settings = true; if (time - ui.mods_checked > 5 && !ui.mods) { std::size_t enabled_mods = 0, installed = 0; diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index 9c26f679..7b4f40bf 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -129,10 +129,13 @@ bool rail(const Fonts& fonts, ModsPanel& panel, HWND window, float width, : pending.size() == 1 ? std::string("1 update on Thunderstore") : std::format("{} updates on Thunderstore", pending.size()))) panel.tab = 0; + // A controller starts on the open tab's tile. + if (panel.tab == 0) default_focus(); if (nav_tile(fonts, width, "GET MODS", panel.tab == 1, store.loaded ? std::to_string(store.packages.size()) : std::string("..."), false, store.loaded ? std::string() : std::string("Loading the Thunderstore listing"))) panel.tab = 1; + if (panel.tab == 1) default_focus(); ImGui::Dummy(ImVec2(0, S(8))); if (!pending.empty()) { @@ -316,8 +319,10 @@ void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Ins const ImVec2 start = ImGui::GetCursorScreenPos(); const float width = ImGui::GetContentRegionAvail().x; const bool ticked = panel.marked.contains(mod.name); + begin_row(); const bool pressed = list_row("##row", width, tall, ticked); bool menu = ImGui::IsItemClicked(ImGuiMouseButton_Right); + row_buttons(); if (pressed) { // Ctrl and Shift pick, as they do in a file list; a plain click opens the mod. if (ImGui::GetIO().KeyShift) mark(panel, view, position, true, true); @@ -399,6 +404,7 @@ void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Ins if (const auto detail = summary(panel, mod); !detail.empty()) draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x, start.y + S(37)), color::muted, detail.c_str(), nullptr, 0, &clip); + end_row(); if (menu) ImGui::OpenPopup("##menu"); if (ImGui::BeginPopup("##menu")) { diff --git a/Launcher/gui_mods_browse.cpp b/Launcher/gui_mods_browse.cpp index 35b17ad7..544db04b 100644 --- a/Launcher/gui_mods_browse.cpp +++ b/Launcher/gui_mods_browse.cpp @@ -518,10 +518,12 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float const ImVec2 start = ImGui::GetCursorScreenPos(); const float width = ImGui::GetContentRegionAvail().x; // The row itself opens the overview; the widgets on it keep their clicks. + begin_row(); if (list_row("##row", width, tall, ticked)) { store.selected = package.full_name; store.overview = false; } + row_buttons(); const float right = start.x + width; const float text_x = start.x + S(82); mod_icon(panel, &package, ImVec2(start.x + S(14), start.y + S(11)), S(56)); @@ -572,6 +574,7 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float std::format("v{}{} / {} downloads / {}", version.number, version.file_size ? " / " + size_text(version.file_size) : std::string(), count_text(package.downloads), date_text(package.date_updated)).c_str()); + end_row(); ImGui::PopID(); }); ImGui::EndDisabled(); diff --git a/Launcher/gui_settings.cpp b/Launcher/gui_settings.cpp index ae1ef4c3..8db3361c 100644 --- a/Launcher/gui_settings.cpp +++ b/Launcher/gui_settings.cpp @@ -127,9 +127,12 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui ImGui::BeginChild("##settings_rail", ImVec2(rail_width, bottom - rail_top), ImGuiChildFlags_NavFlattened); static constexpr std::array names{"GAME", "DISPLAY", "KEYS", "ADVANCED"}; ImGui::BeginDisabled(binding); - for (int i = 0; i < static_cast(names.size()); ++i) + for (int i = 0; i < static_cast(names.size()); ++i) { if (nav_tile(fonts, rail_width, names[static_cast(i)], ui.settings_tab == i)) ui.settings_tab = i; + // A controller starts on the open tab's tile. + if (ui.settings_tab == i) default_focus(); + } ImGui::SetCursorPosY(ImGui::GetWindowHeight() - S(38)); push_primary_button(); if (ImGui::Button("\xe2\x86\x90 BACK", ImVec2(-1, S(34)))) open = false; From d04920c793a65ed90664b1405495f72625ce418e Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Wed, 7 Oct 2026 16:02:26 -0500 Subject: [PATCH 093/109] Server: the nearest players always at the full rate; whole states once a minute; relay routes in net - The crowd limit always sends a player the nearest 8 others at the full rate and the next 12 at ten a second, whatever the budget. It gave everyone the low rate first and the nearest what was left, and with enough players on (about 120 at the default budget, fewer at a lower one) nothing was left: nobody was at the full rate, and since a skater's sound follows the full rate, nobody was heard either - Whole pose and sound states are sent every minute instead of every 8 seconds. A whole pose measured 3.7 KB against 0.7 KB for a difference from one, however old the reference, and at every 8 seconds they were about a seventh of what a busy server sent - "net" lists which of Steam's relay locations the connections go through, and "net " that player's: Steam reports pings of several hundred ms on a server measured at 55-84 ms directly, and this says which way the route went --- Extension/Multiplayer/Session/room.h | 10 ++++++-- .../Multiplayer/Steam/steam_transport.cpp | 12 ++++++++++ Extension/Multiplayer/Steam/steam_transport.h | 4 ++++ .../Multiplayer/Test/bandwidth_tests.cpp | 14 +++++++---- Server/server_host.cpp | 23 ++++++++++++++++--- 5 files changed, 54 insertions(+), 9 deletions(-) diff --git a/Extension/Multiplayer/Session/room.h b/Extension/Multiplayer/Session/room.h index 49958abf..3b38d1cb 100644 --- a/Extension/Multiplayer/Session/room.h +++ b/Extension/Multiplayer/Session/room.h @@ -89,6 +89,7 @@ struct PoseDelivery { // 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::infinity(), low = std::numeric_limits::infinity(); }; @@ -98,8 +99,13 @@ inline CrowdLimits crowd_limits(std::span squared, unsigned tps, unsigned if (count * full <= budget) return {}; std::sort(squared.begin(), squared.end()); const unsigned spare = budget > count * low ? budget - count * low : 0; - const unsigned at_full = std::min(count, spare * 2 / 3 / (full - low)); - const unsigned at_half = std::min(count - at_full, (spare - at_full * (full - low)) / (half - low)); + // 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) { diff --git a/Extension/Multiplayer/Steam/steam_transport.cpp b/Extension/Multiplayer/Steam/steam_transport.cpp index 40153c6f..f0acca6e 100644 --- a/Extension/Multiplayer/Steam/steam_transport.cpp +++ b/Extension/Multiplayer/Steam/steam_transport.cpp @@ -685,6 +685,18 @@ std::vector SteamTransport::links() { row.out_bps = s.m_flOutBytesPerSec; row.in_bps = s.m_flInBytesPerSec; row.queue_us = queue_time(s); + 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((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); } diff --git a/Extension/Multiplayer/Steam/steam_transport.h b/Extension/Multiplayer/Steam/steam_transport.h index 8f9fb97e..c4a2aec5 100644 --- a/Extension/Multiplayer/Steam/steam_transport.h +++ b/Extension/Multiplayer/Steam/steam_transport.h @@ -21,6 +21,10 @@ struct TransportLink { 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; }; struct TransportMessage { std::uint64_t peer{}; diff --git a/Extension/Multiplayer/Test/bandwidth_tests.cpp b/Extension/Multiplayer/Test/bandwidth_tests.cpp index ba2588a4..8280ade4 100644 --- a/Extension/Multiplayer/Test/bandwidth_tests.cpp +++ b/Extension/Multiplayer/Test/bandwidth_tests.cpp @@ -160,15 +160,21 @@ void rate_checks() { for (const unsigned tps : {20U, 30U, 60U, 120U}) { const auto limits = crowd_limits(crowd, tps, 600); const auto sent = count(crowd, limits, tps); - check(sent <= 602 && sent > 450, "A crowd was not sent within the budget"); + // 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(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: all at the low rate, which is the floor. - const auto all_low = crowd_limits(packed, 30, 600); - check(count(packed, all_low, 30) == packed.size() * 5, "An over-full crowd was not sent at the low rate"); + // 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"); diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 3dd4bf09..2a405328 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -177,6 +177,10 @@ void Host::apply_layers() { // ---- Sending --------------------------------------------------------------------------------- namespace { +// How often a stream's whole state is sent again. A whole pose measured 3.7 KB against 0.7 KB +// for a difference from one, however old the reference (net's pose size line), so they are +// kept rare: at every 8 seconds they were a seventh of everything a busy server sent. +constexpr std::uint64_t whole_state_refresh = 60000000; constexpr std::array traffic_names{"poses", "sound", "voice", "outfits", "objects", "other"}; constexpr std::size_t traffic_kind(PacketKind kind) noexcept { return kind == PacketKind::pose ? 0 : kind == PacketKind::audio ? 1 : kind == PacketKind::voice ? 2 @@ -498,7 +502,7 @@ void Host::change_map(std::string_view map) { for (auto &[id, guest] : guests_) { auto &g = *guest; auto old = std::exchange(g, Guest{}); - g.sender.set_refresh(8000000, true); // as for a new player (tick): a new world's streams start over + g.sender.set_refresh(whole_state_refresh, true); // as for a new player (tick): a new world's streams start over g.traffic = old.traffic; g.member = std::move(old.member); g.handshaken = old.handshaken; @@ -961,7 +965,9 @@ std::string Host::network_report(std::string_view player, bool console) { for (const auto &link : links) { const auto *guest = find(link.id); if (std::to_string(link.id) == player || (guest && lower(guest_name(*guest)).starts_with(wanted))) - return row(link).substr(2) + (guest ? "\n" + by_kind(guest->traffic) : std::string{}); + return row(link).substr(2) + (guest ? "\n" + by_kind(guest->traffic) : std::string{}) + + "\nroute: through Steam's relay " + (link.relay.empty() ? std::string("(unknown)") : link.relay) + + " on this side and " + (link.remote_relay.empty() ? std::string("(unknown)") : link.remote_relay) + " on theirs"; } return "No connected player matches \"" + std::string(player) + "\"."; } @@ -984,6 +990,17 @@ std::string Host::network_report(std::string_view player, bool console) { std::to_string(queued / 1024) + " KB queued, longest wait " + std::to_string(longest_queue / 1000) + " ms, " + std::to_string(waiting) + " waiting over 50 ms | " + std::to_string(poor) + " under 90% quality"; if (!console) return text; + { + // The relay locations in use, most connections first: everyone through one far away is a route problem. + std::map relays; + for (const auto &link : links) + if (link.measured) ++relays[(link.relay.empty() ? "?" : link.relay) + "-" + (link.remote_relay.empty() ? "?" : link.remote_relay)]; + std::vector> order(relays.begin(), relays.end()); + std::stable_sort(order.begin(), order.end(), [](const auto &a, const auto &b) { return a.second > b.second; }); + text += "\nrelays (ours-theirs):"; + for (std::size_t i = 0; i < std::min(order.size(), 10); ++i) + text += " " + order[i].first + " x" + std::to_string(order[i].second); + } text += "\nloop: " + loop_report(); text += "\n" + by_kind(traffic_); if (pose_sizes_.samples && pose_sizes_.sent) { @@ -1120,7 +1137,7 @@ void Host::tick(std::uint64_t now) { continue; } auto created = std::make_unique(); - created->sender.set_refresh(8000000, true); + created->sender.set_refresh(whole_state_refresh, true); created->member.id = link.id; created->last_packet = now_; guest = created.get(); From 7ee82b2771d96de011511e4a71d4770cf7d4cc67 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Wed, 7 Oct 2026 16:43:18 -0500 Subject: [PATCH 094/109] Player distance and nametag settings; far players sent less and more coarsely; profiler addresses Game - Player distance (Settings > Interface, "player-distance"): how far away another player is still built as a skater, 50 to 1000 m, default 120. Past it they keep their nametag or dot. All the way up shows everyone. A player already shown stays until 15% further out, so one skating along the edge is not built and taken down over and over. The game ran a full skater for every player on the server wherever they were, which is most of a frame on a full one - Nametags are always ReSkate's own. The "ReSkate nametags" toggle, the "Nametag style" button and the nametag-style command are gone; the game's own nametags are never shown - Nametag distance ("nametag-distance"): how far away a name still shows before it becomes a dot, 10 to 500 m. Default 120, was a fixed 150, so by default the name and the skater go at the same distance - Nametag dots ("nametag-dots"): off hides the dots for far players and the ones at the screen's edge for players off screen - Friends' nametags only ("nametags-friends"): only Steam friends get a name or dot; chat bubbles still show for everyone Server - A player out of sight of another (half as far again as the low rate starts, 255 m by default) is sent to them once a second instead of five times. On a full server most players are out of each other's sight, and at five a second each they were most of what was sent - Rotations are rounded before a pose is passed on, more coarsely the further away the recipient is: 0.04 degrees within the full rate's reach, 0.16 within the half rate's, a third of a degree past that, about 5 degrees out of sight. The pose is still an ordinary one. A bone that turned by less than a step is the same bytes as before, so the difference from the reference leaves it out, and the low bits of the ones that did turn pack away. By distance only: a player the crowd limit slowed keeps the fine rotations - A recipient crossing from one precision to another is sent a whole state of that player, since a difference from a reference of another precision would carry every bone again - "net" shows the size of the poses sent at each precision Profiler - A function in ReSkate.dll is reported by its address when the DLL has no symbols. A release build named every one of ours as the nearest of three exports, so a report could not say which was slow; the release's symbols name the address afterwards --- Engine/Core/Profiling/sampler.cpp | 13 +++- Engine/Game/Multiplayer/session_model.h | 14 ++++- Extension/Multiplayer/Hud/custom_nametags.cpp | 11 +++- Extension/Multiplayer/Hud/custom_nametags.h | 8 ++- Extension/Multiplayer/Net/delta_codec.h | 2 + Extension/Multiplayer/Net/protocol.cpp | 31 ++++++++++ Extension/Multiplayer/Net/protocol.h | 7 +++ Extension/Multiplayer/Session/room.h | 1 + Extension/Multiplayer/Session/session.cpp | 40 ++++++++++--- .../Multiplayer/Session/session_commands.cpp | 60 ++++++++++++++----- .../Multiplayer/Session/session_internal.h | 8 ++- .../Multiplayer/Session/session_view.cpp | 5 +- .../Multiplayer/Test/bandwidth_tests.cpp | 47 ++++++++++++++- Extension/Multiplayer/Test/mesh_tests.cpp | 2 +- Extension/Multiplayer/console_commands.cpp | 12 +++- .../UI/NativeMenu/native_menu_multiplayer.cpp | 2 - Extension/UI/Overlay/multiplayer_menu.cpp | 52 +++++++++++++--- Extension/UI/Overlay/nametag_overlay.cpp | 15 +++-- Extension/UI/Overlay/overlay.h | 3 + Extension/UI/Overlay/skate_menu.h | 5 +- Server/server_host.cpp | 49 ++++++++++++--- Server/server_host.h | 3 +- 22 files changed, 325 insertions(+), 65 deletions(-) diff --git a/Engine/Core/Profiling/sampler.cpp b/Engine/Core/Profiling/sampler.cpp index 3d93b7da..fc62615d 100644 --- a/Engine/Core/Profiling/sampler.cpp +++ b/Engine/Core/Profiling/sampler.cpp @@ -186,6 +186,7 @@ const HANDLE symbol_session = reinterpret_cast(static_cast symbols_loaded; +const char waiting_marker{}; // an address inside this module, to tell it from the others std::string symbol_name(const Module& module, std::uintptr_t address) { std::lock_guard lock(symbols_mutex); if (!symbols_ready) { @@ -200,8 +201,16 @@ std::string symbol_name(const Module& module, std::uintptr_t address) { info->SizeOfStruct = sizeof(SYMBOL_INFO); info->MaxNameLen = 511; DWORD64 displacement{}; - if (symbols_ready && SymFromAddr(symbol_session, address, &displacement, info) && info->Name[0]) - return module.name + "!" + info->Name; + if (symbols_ready && SymFromAddr(symbol_session, address, &displacement, info) && info->Name[0]) { + // A release ReSkate.dll ships without its symbols, so all this finds in it is the nearest + // export, megabytes away: every function of ours then reads as one of three exports and + // a report cannot say which was slow. Unless the address is inside a symbol with a size + // (the real ones have one), give our own module's address instead: the release's symbols + // (build/release/ReSkate--symbols) name it afterwards. + const auto here = reinterpret_cast(&waiting_marker); + const bool own = module.base <= here && here < module.end; + if (!own || (info->Size && displacement < info->Size)) return module.name + "!" + info->Name; + } return std::format("{}+0x{:x}", module.name, address - module.base); } diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 9ef549f1..35c36b62 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -125,6 +125,7 @@ struct MultiplayerBan { std::string name; // as they were known when banned; may be empty std::int64_t added{}; // Unix time }; +inline constexpr float player_distance_least = 50.f, player_distance_unlimited = 1000.f; struct MultiplayerModel { VoiceModel voice; std::vector bans; @@ -141,12 +142,21 @@ struct MultiplayerModel { std::string tuning_status; // Local display preference: the floating name label above each peer. bool nametags{true}; - // Nametag style: ReSkate's own (name, distance, role colours, dots) or the game's. - bool custom_nametags{true}; // Local: whether session text chat shows at all (and T opens it). bool chat_visible{true}; // Local: bad words in chat names and messages show as **** (on by default). bool chat_filter{true}; + // Local, for ReSkate's nametags: `nametag_distance` is how far away a player's name still + // shows (past it they are a dot), `nametag_dots` whether those dots and the ones at the + // screen's edge for off-screen players show at all, `nametags_friends` names only the + // player's Steam friends. + // Local: how far away another player still gets a skater built for them, in metres. Past + // it they keep their nametag or dot. Each skater costs memory and frame time, in view or + // not. `player_distance_unlimited` and above: every player, however far. + float player_distance{120.f}; + 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. diff --git a/Extension/Multiplayer/Hud/custom_nametags.cpp b/Extension/Multiplayer/Hud/custom_nametags.cpp index a2f67de0..8c4dadd8 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.cpp +++ b/Extension/Multiplayer/Hud/custom_nametags.cpp @@ -26,8 +26,8 @@ struct State { std::vector 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{}; - float bubble_distance{40.f}; + bool show_names{true}, show_bubbles{}, dots{true}; + float bubble_distance{40.f}, name_distance{120.f}; }; State &state() { static auto *value = new State; @@ -37,7 +37,7 @@ State &state() { void publish_custom_nametags(std::uintptr_t base, std::vector players, std::optional> local, bool show_names, - bool show_bubbles, float bubble_distance) noexcept { + 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); @@ -61,6 +61,7 @@ void publish_custom_nametags(std::uintptr_t base, std::vector pla 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); @@ -72,6 +73,8 @@ void publish_custom_nametags(std::uintptr_t base, std::vector pla 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; @@ -126,6 +129,8 @@ overlay::Nametags custom_nametags() { 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); diff --git a/Extension/Multiplayer/Hud/custom_nametags.h b/Extension/Multiplayer/Hud/custom_nametags.h index face9f85..4320cfb6 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.h +++ b/Extension/Multiplayer/Hud/custom_nametags.h @@ -28,6 +28,8 @@ struct NametagPlayer { std::vector 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 @@ -49,10 +51,12 @@ inline constexpr std::uint32_t nametag_developer_start = 0xffff206eU, // #6E20FF // 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. +// 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 players, std::optional> local, bool show_names, - bool show_bubbles, float bubble_distance) noexcept; + 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. diff --git a/Extension/Multiplayer/Net/delta_codec.h b/Extension/Multiplayer/Net/delta_codec.h index 456ced83..ba43e47f 100644 --- a/Extension/Multiplayer/Net/delta_codec.h +++ b/Extension/Multiplayer/Net/delta_codec.h @@ -46,6 +46,8 @@ class DeltaSender { WireUpdate prepare(const Packet &, std::span raw, std::span 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. diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index 26c7b57d..9b636aff 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -315,6 +315,37 @@ 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(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 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 encode(const Packet &p, bool compact_pose) { return encode(p, compact_pose, p.pose_interval_us); } diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 85792c60..a84c6d53 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -225,6 +225,13 @@ std::string clean_chat_text(std::string_view); std::string clean_roster_name(std::string_view); bool valid_pose(const Pose &) noexcept; std::vector 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; // 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 encode(const Packet &, bool compact_pose, std::uint32_t pose_interval_us); diff --git a/Extension/Multiplayer/Session/room.h b/Extension/Multiplayer/Session/room.h index 3b38d1cb..3d951510 100644 --- a/Extension/Multiplayer/Session/room.h +++ b/Extension/Multiplayer/Session/room.h @@ -79,6 +79,7 @@ struct PoseDelivery { 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 diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 08304d2c..cc2859b1 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -261,7 +261,12 @@ void publish_party(Session &s) { if (!s.display_preferences_loaded) { s.display_preferences_loaded = 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(), 10.f, 500.f); + s.nametag_dots = profile_runtime::local_preference("NametagDots").value_or(true); + if (const auto saved = profile_runtime::local_value("PlayerDistance"); saved && saved->is_number()) + s.player_distance = std::clamp(saved->get(), 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); @@ -400,7 +405,7 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui // each frame. std::vector nametags; const bool bubbles = s.chat_bubbles; - const bool labels = (s.nametags && s.custom_nametags) || 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 @@ -442,6 +447,8 @@ 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)); @@ -482,6 +489,20 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui 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)) @@ -510,12 +531,13 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui // 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 (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 || held) && 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 @@ -571,8 +593,11 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui remove_remote(s.base); p.next_spawn = now + 3000000; } - p.native_status = - sampled ? "Waiting for the player's cosmetic recipe." : "Waiting for player poses."; + 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, developer_id(p), @@ -605,7 +630,8 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui if (labels) publish_custom_nametags(s.base, std::move(nametags), local.ready ? std::optional(local.pose.root.position) : std::nullopt, - s.nametags && s.custom_nametags, bubbles, s.chat_bubbles_distance); + s.nametags, bubbles, s.chat_bubbles_distance, s.nametag_distance, + s.nametag_dots); } } // namespace MultiplayerModel model() { diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index 51779545..54763465 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -39,9 +39,11 @@ void apply_object_placement(Session &s, ObjectPlacement policy) { void apply_nametags(const Session &s) { // 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. - set_custom_nametags_enabled((s.nametags && s.custom_nametags) || s.chat_bubbles); - set_native_nametags_enabled(s.nametags && !s.custom_nametags); - set_native_compass_enabled(!(s.nametags && s.custom_nametags)); + // 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; @@ -337,16 +339,6 @@ 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(true) : argument == "game" ? std::optional(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). @@ -410,6 +402,42 @@ 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(value)}}); + return value >= player_distance_unlimited ? std::string("Every player is shown as a skater, however far.") + : "Players within " + std::to_string(static_cast(value)) + " m are shown as skaters."; +} +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(value)}}); + return "Names show within " + std::to_string(static_cast(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."; @@ -561,7 +589,8 @@ bool queue_command(std::string_view action, std::string_view argument, std::stri 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 != "object-limit" && action != "kick" && action != "clear-objects" && - action != "nametags" && action != "nametag-style" && action != "chat-visible" && action != "chat-filter" && + action != "nametags" && action != "chat-visible" && action != "chat-filter" && + action != "nametag-distance" && action != "nametag-dots" && action != "nametags-friends" && action != "player-distance" && action != "chat-bubbles" && action != "chat-bubbles-own" && action != "chat-bubbles-distance" && action != "chat-bubbles-duration" && action != "chat-bubbles-history" && !own_mark_command(action) && @@ -743,7 +772,8 @@ std::string command(std::string_view action, std::string_view argument, std::str {"clear-objects", clear_guest_objects}, {"world-layer-sync", edit_world_layer_sync}, {"distances", edit_distances}, {"nametags", edit_nametags}, - {"nametag-style", edit_nametag_style}, + {"nametag-distance", edit_nametag_distance}, {"nametag-dots", edit_nametag_dots}, + {"nametags-friends", edit_nametags_friends}, {"player-distance", edit_player_distance}, {"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}, diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 343bd1fb..8ce5b2b6 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -92,6 +92,8 @@ struct Peer { // 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; @@ -229,7 +231,11 @@ struct Session { unsigned capacity = max_players; // Local display preferences, loaded once from the profile. Never sent to peers. bool nametags = true, chat_visible = true, display_preferences_loaded{}; - bool custom_nametags = true; // ReSkate's nametags instead of the game's (Hud/custom_nametags.h) + 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; + 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{}; diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 7eff34d6..4e5a1565 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -177,7 +177,6 @@ void publish(Session &s, const NativeFrame *local) { view.saved_host = {true, s.host_preferences.public_lobby, s.host_preferences.password_required, static_cast(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); @@ -229,6 +228,10 @@ void publish(Session &s, const NativeFrame *local) { 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.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; diff --git a/Extension/Multiplayer/Test/bandwidth_tests.cpp b/Extension/Multiplayer/Test/bandwidth_tests.cpp index 8280ade4..a59258eb 100644 --- a/Extension/Multiplayer/Test/bandwidth_tests.cpp +++ b/Extension/Multiplayer/Test/bandwidth_tests.cpp @@ -130,6 +130,51 @@ 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 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(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"); + } + 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"); @@ -219,6 +264,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; } } diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 20e7a401..e9b3893f 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -272,7 +272,7 @@ void prepare_native_indicators(std::uintptr_t) noexcept {} void prepare_player_ui(std::uintptr_t) noexcept {} void prepare_remote_audio(std::uintptr_t) noexcept {} bool install_entity_hooks(std::uintptr_t, std::string &) noexcept { return true; } -void publish_custom_nametags(std::uintptr_t, std::vector, std::optional>, bool, bool, float) noexcept {} +void publish_custom_nametags(std::uintptr_t, std::vector, std::optional>, bool, bool, float, float, bool) noexcept {} void set_custom_nametags_enabled(bool) noexcept {} GameUiState sample_game_ui_state(std::uintptr_t) noexcept { return {}; } void note_local_skater(const Transform &) noexcept {} diff --git a/Extension/Multiplayer/console_commands.cpp b/Extension/Multiplayer/console_commands.cpp index a87af0c6..2acc6bc6 100644 --- a/Extension/Multiplayer/console_commands.cpp +++ b/Extension/Multiplayer/console_commands.cpp @@ -31,7 +31,10 @@ void register_multiplayer_commands(Commands ®istry) { Command{"tpall", "Host or server admin: teleport everyone to you"}, Command{"tphere", "Host or server admin: teleport one player to you"}, Command{"nametags", "Show or hide player nametags (on, off, toggle)"}, - Command{"nametag-style", "ReSkate nametags or the game's own (reskate, game, toggle)"}, + Command{"player-distance", "How far away another player still gets a skater, in metres (50-1000; 1000: every player). Past it: a nametag or dot"}, + Command{"nametag-distance", "How far away a player's name still shows, in metres (10-500); past it they are a dot"}, + Command{"nametag-dots", "Show far and off-screen players as dots (on, off, toggle)"}, + Command{"nametags-friends", "Only your Steam friends have nametags (on, off, toggle)"}, Command{"chat-bubbles", "Show or hide chat bubbles above skaters (on, off, toggle)"}, Command{"chat-bubbles-own", "Also show your own chat messages above your skater (on, off, toggle)"}, Command{"chat-bubbles-distance", "How far away a player may be and still show a chat bubble, in metres (5-500)"}, @@ -51,14 +54,17 @@ void register_multiplayer_commands(Commands ®istry) { } if (std::string_view(c.name) == "join-lobby") args.push_back(argument("lobby_id")); - if (std::string_view(c.name) == "nametags" || std::string_view(c.name) == "nametag-style" || + if (std::string_view(c.name) == "nametags" || std::string_view(c.name) == "chat-bubbles" || std::string_view(c.name) == "chat-bubbles-own" || + std::string_view(c.name) == "nametag-dots" || std::string_view(c.name) == "nametags-friends" || std::string_view(c.name) == "score-check") args.push_back(argument("choice")); - if (std::string_view(c.name) == "chat-bubbles-distance") + if (std::string_view(c.name) == "chat-bubbles-distance" || std::string_view(c.name) == "nametag-distance") args.push_back(argument("metres")); if (std::string_view(c.name) == "chat-bubbles-duration") args.push_back(argument("seconds")); + if (std::string_view(c.name) == "player-distance") + args.push_back(argument("metres")); if (std::string_view(c.name) == "chat-bubbles-history") args.push_back(argument("lines")); if (std::string_view(c.name) == "chat" || std::string_view(c.name) == "server" || std::string_view(c.name) == "party" || diff --git a/Extension/UI/NativeMenu/native_menu_multiplayer.cpp b/Extension/UI/NativeMenu/native_menu_multiplayer.cpp index 25db9a22..8db8a946 100644 --- a/Extension/UI/NativeMenu/native_menu_multiplayer.cpp +++ b/Extension/UI/NativeMenu/native_menu_multiplayer.cpp @@ -406,8 +406,6 @@ void render_section(const Context& context, const MultiplayerModel& model, Secti model.hosting ? "world-layer-sync" : "", "toggle", false, 136.f); add_button(context, side, "nametags", std::string("Nametags: ") + (model.nametags ? "On" : "Off"), "nametags", "toggle", false, 136.f); - add_button(context, side, "nametag-style", std::string("Nametag style: ") + (model.custom_nametags ? "ReSkate" : "Game"), - "nametag-style", "toggle", false, 136.f); add_button(context, side, "chat-bubbles", std::string("Chat bubbles: ") + (model.chat_bubbles ? "On" : "Off"), "chat-bubbles", "toggle", false, 136.f); add_button(context, side, "chat-bubbles-own", std::string("Own chat bubbles: ") + (model.chat_bubbles_own ? "On" : "Off"), diff --git a/Extension/UI/Overlay/multiplayer_menu.cpp b/Extension/UI/Overlay/multiplayer_menu.cpp index a6c19c4e..0c8412bb 100644 --- a/Extension/UI/Overlay/multiplayer_menu.cpp +++ b/Extension/UI/Overlay/multiplayer_menu.cpp @@ -240,19 +240,55 @@ void debug_page(SkateMenu &menu, const MultiplayerModel &mp, const CallbacksV3 & void multiplayer_display_settings(SkateMenu &menu, const Model &model) { const auto &mp = model.multiplayer; begin_card(menu, "multiplayer-display", "MULTIPLAYER"); + { + std::array unused{}; + field(menu, "Player distance", "How far away other players are still shown as skaters. Past it they keep their nametag " + "or dot. Lower it on a busy server for more frames and less memory; all the way up shows everyone."); + float shown = menu.player_distance_pending.value_or(mp.player_distance); + ImGui::SliderFloat("##player-distance", &shown, player_distance_least, player_distance_unlimited, + shown >= player_distance_unlimited ? "Everyone" : "%.0f m", ImGuiSliderFlags_AlwaysClamp); + if (ImGui::IsItemActive()) menu.player_distance_pending = shown; + if (ImGui::IsItemDeactivatedAfterEdit()) { + send_private(menu, "player-distance", std::to_string(static_cast(shown)), unused, false); + menu.player_distance_until = ImGui::GetTime() + 2; + } + if (!ImGui::IsItemActive() && menu.player_distance_pending && + (*menu.player_distance_pending == mp.player_distance || ImGui::GetTime() >= menu.player_distance_until)) + menu.player_distance_pending.reset(); + } bool nametags = mp.nametags; - if (toggle_row(menu, "Player nametags", "The name above each skater.", nametags)) { + if (toggle_row(menu, "Player nametags", + "The name above each skater, with their distance: purple for ReSkate developers, red for content creators, " + "gold for homies, pink for server admins, blue for the host, green for your Steam friends.", + nametags)) { std::array unused{}; send_private(menu, "nametags", nametags ? "on" : "off", unused, false); } - bool custom = mp.custom_nametags; - if (toggle_row(menu, "ReSkate nametags", - "Names with distance: purple for ReSkate developers, red for content creators, gold for homies, " - "pink for server admins, blue for the host, green for your Steam friends; far and off-screen players as dots. " - "Off: the game's own nametags and arrows.", - custom, mp.nametags, "OFF")) { + if (mp.nametags) { std::array unused{}; - send_private(menu, "nametag-style", custom ? "reskate" : "game", unused, false); + field(menu, "Nametag distance", "How far away a player's name still shows. Past it they are a dot."); + float distance = menu.nametag_distance_pending.value_or(mp.nametag_distance); + ImGui::SliderFloat("##nametag-distance", &distance, 10.f, 500.f, "%.0f m", ImGuiSliderFlags_AlwaysClamp); + if (ImGui::IsItemActive()) menu.nametag_distance_pending = distance; + if (ImGui::IsItemDeactivatedAfterEdit()) { + send_private(menu, "nametag-distance", std::to_string(static_cast(distance)), unused, false); + menu.nametag_distance_until = ImGui::GetTime() + 2; + } + if (!ImGui::IsItemActive() && menu.nametag_distance_pending && + (*menu.nametag_distance_pending == mp.nametag_distance || ImGui::GetTime() >= menu.nametag_distance_until)) + menu.nametag_distance_pending.reset(); + } + bool dots = mp.nametag_dots; + if (toggle_row(menu, "Nametag dots", "Show players past the nametag distance, and players off screen, as dots.", dots, + mp.nametags, "OFF")) { + std::array unused{}; + send_private(menu, "nametag-dots", dots ? "on" : "off", unused, false); + } + bool friends_only = mp.nametags_friends; + if (toggle_row(menu, "Friends' nametags only", "Only your Steam friends have a name or dot. Chat bubbles still show for everyone.", + friends_only, mp.nametags, "OFF")) { + std::array unused{}; + send_private(menu, "nametags-friends", friends_only ? "on" : "off", unused, false); } bool chat = mp.chat_visible; if (toggle_row(menu, "Text chat", "Show session chat in the bottom-right corner; T opens it. Hidden, nothing shows and T does nothing.", diff --git a/Extension/UI/Overlay/nametag_overlay.cpp b/Extension/UI/Overlay/nametag_overlay.cpp index 2ab43c3a..4cd0123e 100644 --- a/Extension/UI/Overlay/nametag_overlay.cpp +++ b/Extension/UI/Overlay/nametag_overlay.cpp @@ -24,7 +24,6 @@ using namespace dingosdk::overlay::detail; namespace dingosdk::overlay::detail { namespace { // Nearer than this a player gets their name; further, only a dot. -constexpr float name_range = 150.0f; // How far in from the screen's edge the dots of players off screen sit (1080p pixels). constexpr float edge_margin = 28.0f; @@ -221,8 +220,10 @@ void draw_nametags() { const auto frame = static_cast(ImGui::GetFrameCount()); // Far first, so nearer names draw over them. std::sort(value.tags.begin(), value.tags.end(), [](const Nametag &a, const Nametag &b) { return a.distance > b.distance; }); + const float name_range = std::max(1.0f, value.name_distance); for (const auto &tag : value.tags) { const auto animated_colour = animated_nametag_colour(tag.color, animation_time); + const bool unnamed = tag.self || tag.nameless; // bubbles only: no name, no dot const Vec3 delta{tag.position[0] - origin[0], tag.position[1] - origin[1], tag.position[2] - origin[2]}; const float depth = -dot(delta, back), side = dot(delta, right), height = dot(delta, up); bool on_screen = false; @@ -233,7 +234,7 @@ void draw_nametags() { } if (!on_screen) { // The local player has no name or edge dot, only a bubble when they are visible. - if (!tag.self && value.show_names) { + if (!unnamed && value.show_names && value.dots) { // The direction to them from the middle of the screen, pushed out to the edge. float dx = depth > 0.1f ? at.x - centre.x : side, dy = depth > 0.1f ? at.y - centre.y : -height; if (depth <= 0.1f && std::abs(dx) < 1e-3f && std::abs(dy) < 1e-3f) dy = 1.0f; // straight behind @@ -246,10 +247,12 @@ void draw_nametags() { } // Where a bubble's tail should point: the top of the name, or the head itself. float bubble_bottom = at.y; - if (!tag.self && value.show_names) { + if (!unnamed && value.show_names) { if (tag.distance > name_range) { - outlined_dot(draw, at, 4.0f * k, animated_colour); - bubble_bottom = at.y - 6.0f * k; + if (value.dots) { + outlined_dot(draw, at, 4.0f * k, animated_colour); + bubble_bottom = at.y - 6.0f * k; + } } else { // Names shrink a little with distance and fade slightly towards the name range. const float nearness = 1.0f - std::clamp(tag.distance / name_range, 0.0f, 1.0f); @@ -287,7 +290,7 @@ void draw_nametags() { animated_colour, frame); // When the name itself is not shown (nametags off, or a player past the name range), // label the stack so each bubble is still attributed. - const bool named = !tag.self && value.show_names && tag.distance <= name_range; + const bool named = !unnamed && value.show_names && tag.distance <= name_range; if (!named && !tag.name.empty() && stack_top < bubble_bottom - 1.0f) { const float nearness = 1.0f - std::clamp(tag.distance / name_range, 0.0f, 1.0f); const float label_size = std::clamp((12.0f + 3.0f * nearness) * k, 9.0f, 30.0f); diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index db36708c..16a9d523 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -318,6 +318,7 @@ struct Nametag { // Recent chat lines to show as bubbles above the head, oldest first ("" = none). std::vector bubbles; bool self{}; // the local player: bubbles only, never a name or dot + bool nameless{}; // another player whose name is not shown: bubbles only too }; struct Nametags { std::array camera{}; // world matrix: right, up, back, position rows @@ -325,6 +326,8 @@ struct Nametags { bool show_names{true}; // draw the name, distance and role badge bool show_bubbles{}; // draw chat bubbles above the heads float bubble_distance{40.f}; // furthest a player may be and still show a bubble (metres) + float name_distance{120.f}; // furthest a player's name shows; past it they are a dot + bool dots{true}; // draw those dots, and the ones at the edge for off-screen players std::vector tags; }; using NametagFeed = Nametags (*)(); diff --git a/Extension/UI/Overlay/skate_menu.h b/Extension/UI/Overlay/skate_menu.h index 9fd1223a..99feff1a 100644 --- a/Extension/UI/Overlay/skate_menu.h +++ b/Extension/UI/Overlay/skate_menu.h @@ -55,7 +55,10 @@ struct SkateMenu { std::optional voice_range_pending; // the host's range slider while dragged / until the model agrees double voice_range_until{}; // Chat bubble distance/duration/history sliders, likewise held while dragged. - std::optional chat_bubbles_distance_pending, chat_bubbles_duration_pending; + std::optional chat_bubbles_distance_pending, chat_bubbles_duration_pending, nametag_distance_pending; + double nametag_distance_until{}; + std::optional player_distance_pending; + double player_distance_until{}; std::optional chat_bubbles_history_pending; double chat_bubbles_distance_until{}, chat_bubbles_duration_until{}, chat_bubbles_history_until{}; std::map> voice_volume_pending; diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 2a405328..4eca32e2 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -181,6 +181,13 @@ namespace { // for a difference from one, however old the reference (net's pose size line), so they are // kept rare: at every 8 seconds they were a seventh of everything a busy server sent. constexpr std::uint64_t whole_state_refresh = 60000000; +// How finely a player's rotations are sent, by how far away the recipient is (coarsen_rotations): +// within the full rate's reach, the half rate's, the low rate's, and out of sight. The +// nearest is 0.04 degrees a step, which nothing shows; the low rate's a third of a degree, +// at 170 m and more; out of sight only where the skater is matters. A bone that turns by less +// than a step is not sent at all, and a far skater's fingers and face never turn by more. +constexpr std::array pose_precision{4, 6, 7, 11}; +constexpr std::array pose_precision_names{"near", "mid", "far", "out of sight"}; constexpr std::array traffic_names{"poses", "sound", "voice", "outfits", "objects", "other"}; constexpr std::size_t traffic_kind(PacketKind kind) noexcept { return kind == PacketKind::pose ? 0 : kind == PacketKind::audio ? 1 : kind == PacketKind::voice ? 2 @@ -239,7 +246,7 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 // Recipients holding the same delta reference share one patch and compression // (DeltaCache): every update is built first, then all are sent and recorded. struct Encoded { Packet packet; std::vector raw, wire; DeltaCache deltas; }; - std::array encoded; + std::array encoded; // by the interval the pose carries, then by precision struct Outgoing { Guest *guest; std::uint64_t id; WireUpdate update; PoseDelivery *delivery; std::uint32_t interval; unsigned variant; }; std::vector outgoing; outgoing.reserve(guests_.size()); @@ -282,6 +289,9 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 !needs_relay(p.direct_routes, p.route_reported, source->member, now_)) continue; PoseDelivery *delivery{}; + std::uint32_t slot{}; // how often this source's poses go to them, when not every `interval` + bool out_of_sight{}; + unsigned precision{}; std::uint32_t interval = multiplayer_pose_interval(config_.tps); if (packet.kind == PacketKind::pose) { auto entry = std::find_if(p.pose_delivery.begin(), p.pose_delivery.end(), @@ -300,8 +310,21 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 } delivery->by_distance = pose_interval(distance, delivery->by_distance, config_.distances, config_.tps); interval = crowd_interval(delivery->by_distance, distance, p.crowd); + // Half as far again as the low rate starts: nothing of a skater can be made out + // there, only where they are (their dot, the map). + // (A little nearer to come back into sight than to leave it.) + const auto sight = static_cast(config_.distances.low_rate_start) * (delivery->precision == 3 ? 1.4f : 1.5f); + out_of_sight = config_.distances.valid() && distance > sight * sight; + precision = out_of_sight ? 3U : delivery->by_distance == 200000 ? 2U : delivery->by_distance == 100000 ? 1U : 0U; } else { delivery->by_distance = 0; + precision = delivery->precision; + } + // By distance only, which changes seldom: a difference from a reference of another + // precision carries every bone again, so the reference is renewed with it. + if (precision != delivery->precision) { + p.sender.forget(packet.source, PacketKind::pose); + delivery->precision = static_cast(precision); } // Standing still for a few seconds: five poses a second carry it to anyone. The // rate is back at once when they move (the change of rate sends the next pose). @@ -309,16 +332,21 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 if (delivery->interval_us != interval) delivery->next_source_time = 0; delivery->interval_us = interval; if (interval > multiplayer_pose_interval(config_.tps) && packet.time_us < delivery->next_source_time) continue; + // Out of sight, one pose a second is sent (as low-rate poses, a game holds a skater + // it hears nothing of for several seconds). On a full server most players are out + // of each other's sight, and at five a second each they were most of what was sent. + slot = out_of_sight && interval == 200000 ? 1000000U : interval; } - const auto variant = interval == 200000 ? 2U : interval == 100000 ? 1U : 0U; + const auto variant = (interval == 200000 ? 2U : interval == 100000 ? 1U : 0U) * 4 + precision; auto &data = encoded[variant]; if (data.raw.empty()) { data.packet = packet; data.packet.pose_interval_us = interval; + if (packet.kind == PacketKind::pose) coarsen_rotations(data.packet.pose, pose_precision[precision]); data.raw = encode(data.packet, true); data.wire = encode_wire_bytes(data.raw); } - outgoing.push_back({&p, id, p.sender.prepare(data.packet, data.raw, data.wire, data.deltas), delivery, interval, variant}); + outgoing.push_back({&p, id, p.sender.prepare(data.packet, data.raw, data.wire, data.deltas), delivery, slot ? slot : interval, variant}); } for (auto &out : outgoing) { // A packet that cannot be built for anyone is its source's fault, never this @@ -334,8 +362,8 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 counted->total.snapshots += whole; } if (sending.kind == PacketKind::pose && !whole) { - ++pose_sizes_.sent; - pose_sizes_.sent_bytes += out.update.bytes.size(); + ++pose_sizes_.sent[out.variant % 4]; + pose_sizes_.sent_bytes[out.variant % 4] += out.update.bytes.size(); } out.guest->sender.sent(sending, std::move(out.update)); } @@ -1003,11 +1031,14 @@ std::string Host::network_report(std::string_view player, bool console) { } text += "\nloop: " + loop_report(); text += "\n" + by_kind(traffic_); - if (pose_sizes_.samples && pose_sizes_.sent) { + if (pose_sizes_.samples) { const auto each = [&](std::uint64_t sum) { return std::to_string(sum / pose_sizes_.samples) + " B"; }; - text += "\npose size: " + std::to_string(pose_sizes_.sent_bytes / pose_sizes_.sent) + " B as sent (whole " + each(pose_sizes_.whole) + - "); it would be " + each(pose_sizes_.second) + " against a reference renewed every second, " + each(pose_sizes_.quarter) + - " every quarter second, " + each(pose_sizes_.last) + " against the pose before (" + std::to_string(pose_sizes_.samples) + " measured)"; + text += "\npose size as sent:"; + for (std::size_t i = 0; i < pose_sizes_.sent.size(); ++i) + if (pose_sizes_.sent[i]) + text += std::string(" ") + pose_precision_names[i] + " " + std::to_string(pose_sizes_.sent_bytes[i] / pose_sizes_.sent[i]) + " B x" + + std::to_string(pose_sizes_.sent[i]); + text += "; as it arrives " + each(pose_sizes_.last) + " against the pose before, whole " + each(pose_sizes_.whole); } { // Who uploads most, and what most of it is: one player's outfit or objects can cost diff --git a/Server/server_host.h b/Server/server_host.h index 4d35af4b..c5b641e1 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -177,7 +177,8 @@ class Host { // every fourth pose that arrives (net). A nearer reference changes less, so it packs // smaller: this says by how much, before the server is made to send them that way. struct PoseSizes { - std::uint64_t samples{}, whole{}, last{}, quarter{}, second{}, sent{}, sent_bytes{}; + std::uint64_t samples{}, whole{}, last{}, quarter{}, second{}; + std::array sent{}, sent_bytes{}; // differences sent, by precision (pose_precision) } pose_sizes_; void measure_pose(Guest &from, const Packet &packet); std::uint64_t traffic_mark_{}, traffic_window_us_{}; // when `mark` was taken, and how long `last` covers From 6edb6660115cfe515eb055158c764ed85ed9464a Mon Sep 17 00:00:00 2001 From: Alexandeeer <138784964+AlexanderPuglisi@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:30:52 +0200 Subject: [PATCH 095/109] Launcher: log why Steam counts as offline ReSkate.log only said "Steam is not running or not signed in", even with Steam open and signed in. steam_offline_reason() names the check that failed (ActiveProcess pid or ActiveUser is 0, the process cannot be opened or identified, has exited, or is not steam.exe) and start_game appends it to that line. steam_signed_in() behaves as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VNYF3aYa2q3MkFXc5xYnDd --- Launcher/launch.cpp | 29 ++++++++++++++++++++++------- Launcher/launch.h | 2 ++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Launcher/launch.cpp b/Launcher/launch.cpp index ba33ec83..9d8b9f70 100644 --- a/Launcher/launch.cpp +++ b/Launcher/launch.cpp @@ -507,17 +507,30 @@ void relaunch(const fs::path& self, const std::vector& arguments) CloseHandle(process.hProcess); } -bool steam_signed_in() { +std::wstring steam_offline_reason() { const auto pid = registry_dword(steam_process_key, L"pid"); - if (!pid || !*pid || !active_steam_id()) return false; + if (!pid || !*pid) return L"no Steam client is registered as running (ActiveProcess pid is 0)"; + if (!active_steam_id()) return L"Steam reports no signed-in account (ActiveProcess ActiveUser is 0)"; Handle process(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, *pid)); + if (!process.get()) + return std::format(L"the registered Steam process {} cannot be opened (Windows error {})", *pid, GetLastError()); DWORD code{}; - if (!process.get() || !GetExitCodeProcess(process.get(), &code) || code != STILL_ACTIVE) return false; + if (!GetExitCodeProcess(process.get(), &code) || code != STILL_ACTIVE) + return std::format(L"the registered Steam process {} is no longer running", *pid); std::wstring image(MAX_PATH, L'\0'); DWORD length = static_cast(image.size()); - if (!QueryFullProcessImageNameW(process.get(), 0, image.data(), &length)) return false; + if (!QueryFullProcessImageNameW(process.get(), 0, image.data(), &length)) + return std::format(L"the registered Steam process {} cannot be identified (Windows error {})", *pid, + GetLastError()); image.resize(length); - return _wcsicmp(fs::path(image).filename().c_str(), L"steam.exe") == 0; + const auto name = fs::path(image).filename().wstring(); + if (_wcsicmp(name.c_str(), L"steam.exe") != 0) + return std::format(L"the registered Steam process {} is {}, not steam.exe", *pid, name); + return {}; +} + +bool steam_signed_in() { + return steam_offline_reason().empty(); } std::string steam_persona_name() { @@ -702,13 +715,15 @@ DWORD start_game(const Session& session, const launcher::LaunchOptions& options, throw std::runtime_error("The menu and console need different keys. Change one in Settings."); set_environment(L"RESKATE_MENU_KEY", std::to_wstring(options.menu_key).c_str()); set_environment(L"RESKATE_CONSOLE_KEY", std::to_wstring(options.console_key).c_str()); - const bool offline = options.offline || !launcher_app::steam_signed_in(); + const auto steam_reason = options.offline ? std::wstring{} : launcher_app::steam_offline_reason(); + const bool offline = options.offline || !steam_reason.empty(); set_environment(L"RESKATE_OFFLINE", offline ? L"1" : L"0"); const auto steam_id = offline ? last_steam_id() : std::nullopt; set_environment(L"RESKATE_OFFLINE_STEAM_ID", steam_id ? std::to_wstring(*steam_id).c_str() : nullptr); if (offline) logging::write(logging::Level::info, logging::Channel::launcher, options.offline ? - L"Offline mode: requested; Steam is not used" : L"Offline mode: Steam is not running or not signed in"); + std::wstring(L"Offline mode: requested; Steam is not used") : + L"Offline mode: Steam is not running or not signed in: " + steam_reason); logging::write(logging::Level::info, logging::Channel::launcher, std::wstring(L"Graphics options: DRED=") + (options.gpu_diagnostics ? L"enabled" : L"default")); if (options.force_windowed) diff --git a/Launcher/launch.h b/Launcher/launch.h index 79327837..19f91490 100644 --- a/Launcher/launch.h +++ b/Launcher/launch.h @@ -25,6 +25,8 @@ void relaunch(const std::filesystem::path& self, const std::vector // Steam's client process is running and signed in to an account. When it is // not, start_game runs the game in offline mode. bool steam_signed_in(); +// Why steam_signed_in() is false, for ReSkate.log; empty when it is true. +std::wstring steam_offline_reason(); // Display name of the signed-in Steam account (UTF-8), or empty when Steam is // not running, signed out, or the name cannot be read. std::string steam_persona_name(); From 91c939ea4f35bc1aa99ea16158d01599dec060de Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Wed, 7 Oct 2026 19:42:48 -0500 Subject: [PATCH 096/109] Server: limit how far a mod may resize part of a skater for other players - bone_scale_limit in ReSkateServer.json, and the bone-scale command: the most a bone may be scaled in the poses the server passes on, 1 to 8, and its inverse the least. 1 shows every skater at the game's own proportions; 0 (the default, "bone-scale off") is no limit - A "big head" mod sets one joint's scale in the body's bind pose (animation/dingo/cas_rsp_body_bind, the head from 1 to 4). The game applies it to the bone, and the bone's scale travels in every pose, which is how players without the mod see it. The game's own skeleton has no scaled joint, so 1 changes nothing for anyone else - The player with the mod still sees it on their own screen: the server decides only what the others are sent - Changing it sends whole states again, so nobody keeps a reference with the old sizes in it - Dedicated servers only; a lobby's host has no such setting --- Extension/Multiplayer/Net/protocol.cpp | 9 +++++++ Extension/Multiplayer/Net/protocol.h | 4 ++++ .../Multiplayer/Test/bandwidth_tests.cpp | 12 ++++++++++ Server/README.txt | 6 +++++ Server/Test/server_config_tests.cpp | 4 ++++ Server/server_config.cpp | 4 ++++ Server/server_config.h | 3 +++ Server/server_host.cpp | 24 +++++++++++++++++-- 8 files changed, 64 insertions(+), 2 deletions(-) diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index 9b636aff..a5555a23 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -340,6 +340,15 @@ void coarsen(Transform &t, unsigned bits) noexcept { 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 diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index a84c6d53..b847e744 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -232,6 +232,10 @@ std::vector encode(const Packet &, bool compact_pose = false); // 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 encode(const Packet &, bool compact_pose, std::uint32_t pose_interval_us); diff --git a/Extension/Multiplayer/Test/bandwidth_tests.cpp b/Extension/Multiplayer/Test/bandwidth_tests.cpp index a59258eb..586a36c9 100644 --- a/Extension/Multiplayer/Test/bandwidth_tests.cpp +++ b/Extension/Multiplayer/Test/bandwidth_tests.cpp @@ -173,6 +173,18 @@ void coarse_checks() { 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{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{1, 1, 1} && plain.skater[6].scale == std::array{1, 1, 1} && + free.skater[5].scale == std::array{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() { diff --git a/Server/README.txt b/Server/README.txt index dd5a177f..0de4121b 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -60,6 +60,12 @@ send_rate The most the server sends one player, in KB/s (default 900, Steam's relays: set higher, what is lost is resent until the connection is full of resends and half of everything is lost. Console: rate , also for the players already on. +bone_scale_limit How far a mod may resize part of a skater for the other + players (a "big head" mod and the like): the most a bone may + be scaled, 1 to 8. 1 shows every skater at the game's own + proportions; 0 (default) is no limit. The player with the + mod still sees it on their own screen. Console: + bone-scale <1-8>|off. crowd_budget The most position updates a second one player is sent (default 600, 0 for no limit). Players near each other are sent at the full rate (tps); this only matters once more are diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 2312f0f2..71dfd19f 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -66,6 +66,10 @@ int run() { ServerConfig rate; rate.send_rate = 64; check(config_error(rate).find("send_rate") != std::string::npos, "A send rate too low to play with was accepted"); + check(has("bone_scale_limit") && config.bone_scale_limit == 0, "bone_scale_limit is not a new setting of no limit"); + ServerConfig scaled; + scaled.bone_scale_limit = 0.5f; + check(config_error(scaled).find("bone_scale_limit") != std::string::npos, "A bone scale limit under 1 was accepted"); ServerConfig crowd; crowd.crowd_budget = 50; check(config_error(crowd).find("crowd_budget") != std::string::npos, "A crowd budget too small to play with was accepted"); diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 3c64b41d..cf0a3f48 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -25,6 +25,7 @@ Json to_json(const ServerConfig &c) { root["max_players"] = c.max_players; root["reserved_slots"] = c.reserved_slots; root["crowd_budget"] = c.crowd_budget; + root["bone_scale_limit"] = c.bone_scale_limit; root["send_rate"] = c.send_rate; auto reserved = Json::array(); for (const auto id : c.reserved) reserved.push_back(std::to_string(id)); // as strings, like the admins @@ -132,6 +133,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector= c.max_players) return "reserved_slots must be less than max_players, so that anyone can join at all."; if (c.reserved.size() > 1024) return "reserved holds at most 1024 players."; if (c.send_rate < 128 || c.send_rate > 16384) return "send_rate must be 128 to 16384 (KB/s for each player)."; + if (c.bone_scale_limit != 0 && !(c.bone_scale_limit >= 1.f && c.bone_scale_limit <= 8.f)) + return "bone_scale_limit must be 0 (no limit) or 1 to 8 (1: no resized body parts at all)."; if (!valid_crowd_budget(c.crowd_budget)) return "crowd_budget must be 0 (no limit) or " + std::to_string(min_crowd_budget) + " to " + std::to_string(max_crowd_budget) + "."; diff --git a/Server/server_config.h b/Server/server_config.h index 3bb75025..d1748acf 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -42,6 +42,9 @@ struct ServerConfig { unsigned reserved_slots = 0; // The most poses a second one player is sent (crowd_limits); 0: no limit. unsigned crowd_budget = crowd_pose_budget; + // The most a mod may resize part of a skater for the other players, as a factor (and its + // inverse the least): 1 shows every skater at the game's own proportions, 0 is no limit. + float bone_scale_limit = 0; // What the server may send each player, in KB/s (128-16384). unsigned send_rate = 900; std::vector reserved; diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 4eca32e2..a85a31f2 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -46,7 +46,7 @@ std::uint64_t nonce() { constexpr std::string_view help_text = "status | net [player] | players | say | msg | msg-party | msg-admins | kick | ban [name] | unban | bans\n" "map | maps | name | password | welcome | listed on|off\n" - "voice on|off | voice-range <50-1000> | distances | crowd |off | rate \n" + "voice on|off | voice-range <50-1000> | distances | crowd |off | rate | bone-scale <1-8>|off\n" "placement everyone|admins|nobody | objects |off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" "tpall [player] | tphere | votes [map|kick|tod on|off|] | vote-cancel\n" "map-pool [add|remove |clear] | rotation [|off]\n" @@ -342,7 +342,11 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 if (data.raw.empty()) { data.packet = packet; data.packet.pose_interval_us = interval; - if (packet.kind == PacketKind::pose) coarsen_rotations(data.packet.pose, pose_precision[precision]); + if (packet.kind == PacketKind::pose) { + // What a mod resized on its player's skater reaches the others only as far as the server allows. + if (config_.bone_scale_limit >= 1.f) limit_bone_scale(data.packet.pose, config_.bone_scale_limit); + coarsen_rotations(data.packet.pose, pose_precision[precision]); + } data.raw = encode(data.packet, true); data.wire = encode_wire_bytes(data.raw); } @@ -1570,6 +1574,22 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { return changed("Each player is sent at most " + std::to_string(config_.send_rate) + " KB/s" + (applied ? "." : ": players who join from now on. Steam did not change the connections already open.")); } + if (name == "bone-scale") { + // bone-scale <1-8>|off: how far a mod may resize part of a skater for the other players. + float value{}; + const bool off = argument == "off" || argument == "0"; + const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), value); + if (!off && (parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || !(value >= 1.f && value <= 8.f))) + return "bone-scale <1-8>|off: 1 shows every skater at the game's own proportions, off allows anything (now " + + (config_.bone_scale_limit >= 1.f ? std::to_string(config_.bone_scale_limit).substr(0, 4) : std::string("off")) + ")"; + config_.bone_scale_limit = off ? 0.f : value; + // Whole states go again so that nobody keeps a reference with the old sizes in it. + for (auto &[id, guest] : guests_) + for (const auto &[other, unused] : guests_) guest->sender.forget(other, PacketKind::pose); + return changed(off ? std::string("Mods may resize skaters' body parts freely.") + : value == 1.f ? std::string("Skaters show at the game's own proportions: resized body parts are not passed on.") + : "Resized body parts show at up to " + std::to_string(value).substr(0, 4) + "x."); + } if (name == "crowd") { // crowd |off: the most one player is sent (crowd_limits). auto value = number(argument); From 98e07f6899d1f2fcdac79e629299d7735322254b Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Wed, 7 Oct 2026 19:46:23 -0500 Subject: [PATCH 097/109] Server: resized body parts are off unless a server allows them - bone_scale_limit defaults to 1: every skater shows at the game's own proportions. A server that wants "big head" mods and the like to show sets a higher limit, or "bone-scale off" --- Server/README.txt | 4 ++-- Server/Test/server_config_tests.cpp | 2 +- Server/server_config.h | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Server/README.txt b/Server/README.txt index 0de4121b..7e9bf82f 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -62,8 +62,8 @@ send_rate The most the server sends one player, in KB/s (default 900, lost. Console: rate , also for the players already on. bone_scale_limit How far a mod may resize part of a skater for the other players (a "big head" mod and the like): the most a bone may - be scaled, 1 to 8. 1 shows every skater at the game's own - proportions; 0 (default) is no limit. The player with the + be scaled, 1 to 8. 1 (default) shows every skater at the + game's own proportions; 0 is no limit. The player with the mod still sees it on their own screen. Console: bone-scale <1-8>|off. crowd_budget The most position updates a second one player is sent diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 71dfd19f..2a5ced2a 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -66,7 +66,7 @@ int run() { ServerConfig rate; rate.send_rate = 64; check(config_error(rate).find("send_rate") != std::string::npos, "A send rate too low to play with was accepted"); - check(has("bone_scale_limit") && config.bone_scale_limit == 0, "bone_scale_limit is not a new setting of no limit"); + check(has("bone_scale_limit") && config.bone_scale_limit == 1, "bone_scale_limit is not a new setting of 1"); ServerConfig scaled; scaled.bone_scale_limit = 0.5f; check(config_error(scaled).find("bone_scale_limit") != std::string::npos, "A bone scale limit under 1 was accepted"); diff --git a/Server/server_config.h b/Server/server_config.h index d1748acf..f149d46e 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -43,8 +43,9 @@ struct ServerConfig { // The most poses a second one player is sent (crowd_limits); 0: no limit. unsigned crowd_budget = crowd_pose_budget; // The most a mod may resize part of a skater for the other players, as a factor (and its - // inverse the least): 1 shows every skater at the game's own proportions, 0 is no limit. - float bone_scale_limit = 0; + // inverse the least): 1, the default, shows every skater at the game's own proportions; + // 0 is no limit. + float bone_scale_limit = 1; // What the server may send each player, in KB/s (128-16384). unsigned send_rate = 900; std::vector reserved; From 38d86e1b7245e39bd11d70f9262e6d039b89ec21 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Wed, 7 Oct 2026 23:10:13 -0500 Subject: [PATCH 098/109] New pose and sound formats for dedicated servers; direct connections; protocol 44 Poses and skater sound between a dedicated server and its players use new formats. Lobbies and the links between games are unchanged. Poses (pose_codec.h, pose_batch.h): - A pose is rounded once (rotations to 0.08 degrees, positions to a millimetre) and sent as its differences from a pose of the same player the receiver has acknowledged. Several players' poses share one message. - The receiver acknowledges messages once a tick; the sender builds only on acknowledged poses, or on the whole pose it sent reliably. Loss costs the poses in that packet and nothing more. - Fingers are not sent to players further than "finger_distance" (25 m). - Games upload their own poses the same way, and the server acknowledges. - About 300 B a pose at full rate against about 660 B before. - The server no longer rounds rotations by distance: it saved little. Sound (sound_codec.h): - Each sample is sent as what changed since the one before, numbers rounded to 1/1024. One reliable message a tick carries every skater a player can hear. A stream is sent whole at its start and every two seconds. - About 106 B a message against 368 B before, on a recording of real play. Direct connections: - "connection": "relay" (default) or "direct", with "direct_address" and "direct_port". A direct server listens on UDP and is listed with its address; players still sign in through Steam. - Games connect straight to servers that offer it and fall back to the relays after 5 s. Setting and "mp direct-connections" to turn it off. Server: - "bone_scale_limit" is 1 by default: resized body parts are off unless a server allows them. - Logs Steam's relay network status at start-up and in "net", and watches the Steam sign-in. - "pack_ms" (10): how long Steam may hold a message to pack it with others. - "steam_debug": logs what Steam's networking says, for connection faults. - "relay_everything": sends poses and sound through the server even between games that have linked, for testing with few players. - "net" shows pose sizes by rate, the share sent without fingers, and whole poses sent. Game: - Only ReSkate's nametags are shown; the game's own are removed. - "mp pose-dump " records the local skater's poses and sound. Tests: pose_codec_tests covers exact round trips, chains of differences, held fingers, damaged input, the sender and receiver over a link that delays, loses and reorders, and the sound format; with a recording as its argument it prints sizes on real play. Protocol 44: servers and games must both update. --- CMakeLists.txt | 6 + Engine/Game/Multiplayer/session_model.h | 7 + Extension/Multiplayer/Net/pose_batch.h | 458 ++++++++++++++ Extension/Multiplayer/Net/pose_codec.h | 361 +++++++++++ Extension/Multiplayer/Net/protocol.h | 2 +- Extension/Multiplayer/Net/sound_codec.h | 273 +++++++++ Extension/Multiplayer/Session/session.cpp | 18 + .../Multiplayer/Session/session_commands.cpp | 47 +- .../Multiplayer/Session/session_internal.h | 22 + .../Multiplayer/Session/session_receive.cpp | 107 +++- .../Multiplayer/Session/session_send.cpp | 55 ++ .../Multiplayer/Session/session_view.cpp | 1 + .../Steam/steam_server_browser.cpp | 25 + .../Multiplayer/Steam/steam_transport.cpp | 186 +++++- Extension/Multiplayer/Steam/steam_transport.h | 26 +- .../Multiplayer/Test/pose_codec_tests.cpp | 564 ++++++++++++++++++ Extension/Multiplayer/console_commands.cpp | 5 +- Extension/UI/Overlay/multiplayer_menu.cpp | 9 + Server/README.txt | 44 ++ Server/Test/server_config_tests.cpp | 6 + Server/main.cpp | 78 ++- Server/server_config.cpp | 35 ++ Server/server_config.h | 23 + Server/server_host.cpp | 325 ++++++++-- Server/server_host.h | 60 +- Server/steam_server.cpp | 2 + Server/steam_server.h | 2 + 27 files changed, 2689 insertions(+), 58 deletions(-) create mode 100644 Extension/Multiplayer/Net/pose_batch.h create mode 100644 Extension/Multiplayer/Net/pose_codec.h create mode 100644 Extension/Multiplayer/Net/sound_codec.h create mode 100644 Extension/Multiplayer/Test/pose_codec_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 639d84f5..cabe51e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -101,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) diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 35c36b62..7381ddae 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -37,6 +37,10 @@ struct MultiplayerLobby { // A dedicated server the ReSkate team runs (developer_identity.h): first in the browser. bool official{}; int ping = -1; // ms, when the server answered directly + // A dedicated server that takes direct connections: its address (host byte order) and UDP + // port, 0 for none. + std::uint32_t direct_ip{}; + int direct_port{}; // Steam friends skating there now, by name (steam_social.h). std::vector friends; }; @@ -154,6 +158,9 @@ struct MultiplayerModel { // 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{}; diff --git a/Extension/Multiplayer/Net/pose_batch.h b/Extension/Multiplayer/Net/pose_batch.h new file mode 100644 index 00000000..53e45e56 --- /dev/null +++ b/Extension/Multiplayer/Net/pose_batch.h @@ -0,0 +1,458 @@ +#pragma once +#include "pose_codec.h" +#include +#include +#include +#include +#include +#include +#include +#include + +// 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 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 bytes) noexcept { + return bytes.size() >= header_size && std::equal(batch_magic.begin(), batch_magic.end(), bytes.begin()); +} +inline bool is_ack(std::span bytes) noexcept { + return bytes.size() == ack_size && std::equal(ack_magic.begin(), ack_magic.end(), bytes.begin()); +} + +namespace detail { +inline void put(std::vector &out, std::uint64_t value, unsigned bytes) { + for (unsigned i = 0; i < bytes; ++i) out.push_back(static_cast(value >> (8 * i))); +} +inline void varint(std::vector &out, std::uint64_t value) { + while (value >= 0x80) { + out.push_back(static_cast(value | 0x80)); + value >>= 7; + } + out.push_back(static_cast(value)); +} +struct Reader { + std::span 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 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(value) << 1) ^ static_cast(value >> 63); +} +constexpr std::int64_t unzigzag(std::uint64_t value) noexcept { return static_cast(value >> 1) ^ -static_cast(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 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 begin(std::uint32_t number, std::uint64_t world, std::uint64_t map) { + std::vector 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 &out, const Entry &entry) { + detail::varint(out, entry.stream); + out.push_back(static_cast((entry.whole ? 1U : 0U) | (entry.collision ? 2U : 0U) | (static_cast(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 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 read(std::span bytes) noexcept { + try { + if (!is_batch(bytes)) return {}; + detail::Reader r{bytes, 4}; + Batch batch; + batch.number = static_cast(r.get(4)); + batch.world = static_cast(r.get(4)); + batch.map = static_cast(r.get(4)); + while (r.at < bytes.size()) { + Entry entry; + const auto stream = r.varint(); + if (stream > 0xffff) return {}; + entry.stream = static_cast(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((flags >> 2) & 3); + if (entry.whole) { + entry.source = r.get(8); + entry.epoch = r.get(8); + entry.sequence = static_cast(r.get(4)); + entry.time_us = r.get(8); + } else { + entry.reference = static_cast(r.get(2)); + const auto ahead = r.varint(); + if (!ahead || ahead > 0xffffff) return {}; + entry.ahead = static_cast(ahead); + entry.time_step = detail::unzigzag(r.varint()); + } + const auto length = r.varint(); + if (length > bytes.size()) return {}; + entry.pose = r.take(static_cast(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 bytes() const noexcept { + std::array out{}; + std::copy(ack_magic.begin(), ack_magic.end(), out.begin()); + for (unsigned i = 0; i < 4; ++i) out[4 + i] = static_cast(newest >> (8 * i)); + for (unsigned i = 0; i < 8; ++i) out[8 + i] = static_cast(earlier >> (8 * i)); + return out; + } + static std::optional read(std::span 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; + const Held *find_low(std::uint16_t low) const noexcept { + for (auto it = held.rbegin(); it != held.rend(); ++it) + if (static_cast(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 std::optional 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(static_cast(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 + 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 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 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(reference->sequence); + entry.ahead = pose.sequence - reference->sequence; + entry.time_step = static_cast(pose.time_us) - static_cast(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 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 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 void send(Emit &&emit, bool reliable) { + if (batch_.poses.empty()) return; + if (emit(std::span(message_), reliable)) { + sent_.push_back(std::move(batch_)); + while (sent_.size() > 256) sent_.pop_front(); + } + batch_ = {}; + message_.clear(); + } + std::map streams_; + std::deque sent_; + std::vector 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 diff --git a/Extension/Multiplayer/Net/pose_codec.h b/Extension/Multiplayer/Net/pose_codec.h new file mode 100644 index 00000000..c9a9385d --- /dev/null +++ b/Extension/Multiplayer/Net/pose_codec.h @@ -0,0 +1,361 @@ +#pragma once +#include "protocol.h" +#include +#include +#include +#include +#include +#include +#include +#include + +// 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 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(1U << rotation_shift); + const auto most = static_cast(32767 >> rotation_shift); + bone.largest = static_cast(largest); + unsigned out{}; + for (unsigned i = 0; i < 4; ++i) + if (i != largest) bone.rotation[out++] = static_cast(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(t.position[i]) * 1000.0) : 0LL; + bone.position[i] = static_cast(std::clamp(mm, -position_limit, position_limit)); + } + bone.scaled = t.scale != std::array{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(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(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(static_cast(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(pose.skater.size()); + out.board = static_cast(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 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(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(std::bit_width(value)); + put(0, width - 1); + put(1, 1); + put(value, width - 1); + } + std::vector finish() { + if (held) bytes.push_back(static_cast(hold)); + hold = held = 0; + return std::move(bytes); + } +}; +struct BitReader { + std::span 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::uint64_t{1} << (width - 1)) | get(width - 1)); + } +}; +constexpr std::uint32_t zigzag(std::int64_t value) noexcept { + return static_cast(value >= 0 ? static_cast(value) * 2 : static_cast(-value) * 2 - 1); +} +constexpr std::int64_t unzigzag(std::uint64_t value) noexcept { + return (value & 1) ? -static_cast((value + 1) / 2) : static_cast(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(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(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(r.get(2)); + for (auto &component : bone.rotation) component = static_cast(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(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(r.get(16)); + axis = std::bit_cast(low | (static_cast(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 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 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(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(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 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 decode(std::span 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(r.get(10)); + pose.board = static_cast(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::clamp(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(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::clamp(component + detail::small(r, 4), INT16_MIN, INT16_MAX)); + }); + list([&](QuantBone &bone) { + for (auto &mm : bone.position) mm = static_cast(std::clamp(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::clamp(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 diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index b847e744..c634e1fc 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -25,7 +25,7 @@ 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 = 43; +constexpr std::uint16_t protocol_version = 44; 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; diff --git a/Extension/Multiplayer/Net/sound_codec.h b/Extension/Multiplayer/Net/sound_codec.h new file mode 100644 index 00000000..68c69efb --- /dev/null +++ b/Extension/Multiplayer/Net/sound_codec.h @@ -0,0 +1,273 @@ +#pragma once +#include "Extension/Multiplayer/Remote/audio_state.h" +#include "pose_batch.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// 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 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 bytes) noexcept { + return bytes.size() >= header_size && std::equal(magic.begin(), magic.end(), bytes.begin()); +} + +struct QuantState { + std::array values{}; + std::array 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(state.values[i]) : 0.0; + auto rounded = std::llround(std::clamp(value * static_cast(value_scale), -static_cast(value_limit), static_cast(value_limit))); + if (!rounded && value != 0.0) rounded = value > 0 ? 1 : -1; + out.values[i] = static_cast(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(static_cast(state.values[i]) / static_cast(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 &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(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(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(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 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((whole ? 1U : 0U) | (static_cast(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(time_us) - static_cast(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 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 streams_, staged_; + std::vector 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 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 +std::optional> read(Streams &streams, std::span bytes, std::uint64_t world, std::uint64_t map) noexcept { + try { + if (!is_sound(bytes)) return {}; + detail::Reader r{bytes, 4}; + std::vector 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(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(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(stream)); found != streams.end()) { + in = found->second; + known = true; + } + in.sequence += static_cast(ahead); + in.time_us = static_cast(static_cast(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(stream)] = in; + out.push_back(std::move(heard)); + if (out.size() > 512) return {}; + } + return out; + } catch (...) { + return {}; + } +} +} // namespace dingosdk::multiplayer::sound_codec diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index cc2859b1..7e9978ef 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -155,6 +155,13 @@ 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.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); } @@ -162,6 +169,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; @@ -264,6 +279,7 @@ void publish_party(Session &s) { if (const auto saved = profile_runtime::local_value("NametagDistance"); saved && saved->is_number()) s.nametag_distance = std::clamp(saved->get(), 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(), player_distance_least, player_distance_unlimited); s.nametags_friends = profile_runtime::local_preference("NametagsFriendsOnly").value_or(false); @@ -889,6 +905,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()); diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index 54763465..3ea39db5 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -414,6 +414,32 @@ std::string edit_player_distance(Session &s, std::string_view argument) { return value >= player_distance_unlimited ? std::string("Every player is shown as a skater, however far.") : "Players within " + std::to_string(static_cast(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 (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(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); @@ -590,7 +616,7 @@ bool queue_command(std::string_view action, std::string_view argument, std::stri if ((action != "host" && action != "host-config" && action != "join" && action != "join-lobby" && action != "join-friend-lobby" && action != "stop" && 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 != "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) && @@ -773,7 +799,7 @@ std::string command(std::string_view action, std::string_view argument, std::str {"world-layer-sync", edit_world_layer_sync}, {"distances", edit_distances}, {"nametags", edit_nametags}, {"nametag-distance", edit_nametag_distance}, {"nametag-dots", edit_nametag_dots}, - {"nametags-friends", edit_nametags_friends}, {"player-distance", edit_player_distance}, + {"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}, @@ -980,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(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; diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 8ce5b2b6..2f92778e 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -13,14 +13,18 @@ #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 +#include #include #include #include #include +#include #include #include #include @@ -234,6 +238,24 @@ struct Session { 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 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 own_poses; + // Skaters' sound to and from a dedicated server (sound_codec.h). + std::unordered_map 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) diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index 3353b013..1e45f8f6 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -130,6 +130,73 @@ void proven_chat(Session &s, Peer &sender, const Packet &p, std::uint64_t now) { // 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 unpack_poses(Session &s, std::span bytes) { + std::vector out; + const auto batch = pose_batch::read(bytes); + if (!batch || batch->world != static_cast(s.world) || batch->map != static_cast(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 unpack_sound(Session &s, std::span bytes) { + std::vector 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 stale_pose_deltas(Session &s, const std::vector &messages) { std::vector stale(messages.size()); struct Stream { @@ -412,12 +479,23 @@ 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 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; + 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."); @@ -427,8 +505,25 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { } if (stale[index]) continue; + // The server saying which of this game's own pose messages it read. + if (const auto ack = pose_batch::Ack::read(message.bytes)) { + if (s.mode == Mode::join && !direct_link && dedicated_host(s)) s.pose_upload.ack(*ack); + continue; + } + if (sound_codec::is_sound(message.bytes)) { + if (s.mode == Mode::join && !direct_link && dedicated_host(s)) batch = unpack_sound(s, message.bytes); + if (batch.empty()) continue; + } + if (pose_batch::is_batch(message.bytes)) { + // Only a dedicated server sends these, and only to its players. + if (s.mode == Mode::join && !direct_link && dedicated_host(s)) 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 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 || @@ -880,6 +975,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]; diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index 4e88dc4e..511da74b 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -258,6 +258,25 @@ void send_throwdown(Session &s, std::vector 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 { + 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 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); @@ -316,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) { @@ -635,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(raw.size()); + s.pose_dump.write(reinterpret_cast(&time), sizeof time); + s.pose_dump.write(reinterpret_cast(&length), sizeof length); + s.pose_dump.write(reinterpret_cast(raw.data()), static_cast(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(raw.size()); + s.pose_dump.write(reinterpret_cast(&time), sizeof time); + s.pose_dump.write(reinterpret_cast(&length), sizeof length); + s.pose_dump.write(reinterpret_cast(raw.data()), static_cast(raw.size())); + } deliver(a, std::any_of(a.audio.begin(), a.audio.end(), [](const auto &sample) { return sample.event; }), true); } } diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 4e5a1565..9b0d447b 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -229,6 +229,7 @@ void publish(Session &s, const NativeFrame *local) { 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; diff --git a/Extension/Multiplayer/Steam/steam_server_browser.cpp b/Extension/Multiplayer/Steam/steam_server_browser.cpp index 50c37a92..43dcfb65 100644 --- a/Extension/Multiplayer/Steam/steam_server_browser.cpp +++ b/Extension/Multiplayer/Steam/steam_server_browser.cpp @@ -127,6 +127,30 @@ std::optional 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(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; @@ -196,6 +220,7 @@ void SteamServerBrowser::read(std::uint64_t now) { 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; diff --git a/Extension/Multiplayer/Steam/steam_transport.cpp b/Extension/Multiplayer/Steam/steam_transport.cpp index f0acca6e..386ba40f 100644 --- a/Extension/Multiplayer/Steam/steam_transport.cpp +++ b/Extension/Multiplayer/Steam/steam_transport.cpp @@ -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,6 +93,7 @@ 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 links; // How the connections Steam closed ended (take_closed). A handful at most wait here. @@ -101,6 +103,46 @@ struct SteamTransport::Impl { 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 direct_options() { + std::array 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(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 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) { @@ -141,6 +183,9 @@ struct SteamTransport::Impl { std::deque events; static inline std::atomic 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){}; @@ -179,8 +224,9 @@ struct SteamTransport::Impl { reinterpret_cast(&changed)); return option; } - std::array options() { - std::array out{}; + std::array options() { + std::array out{}; + out[5].SetInt32(k_ESteamNetworkingConfig_NagleTime, packing_us ? packing_us : 5000); // 5000: Steam's own out[0] = callback_option(); // 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 @@ -276,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; @@ -345,6 +396,23 @@ bool SteamTransport::bind(void *library, void *sockets, void *networking_utils) p.lanes = {}; } p.utils = networking_utils; + try { + p.listen_ip = symbol(module, "SteamAPI_ISteamNetworkingSockets_CreateListenSocketIP"); + p.connect_ip = symbol(module, "SteamAPI_ISteamNetworkingSockets_ConnectByIPAddress"); + } catch (...) { + p.listen_ip = nullptr; + p.connect_ip = nullptr; + } + try { + p.debug_output = symbol(module, "SteamAPI_ISteamNetworkingUtils_SetDebugOutputFunction"); + } catch (...) { + p.debug_output = nullptr; + } + try { + p.relay_network = symbol(module, "SteamAPI_ISteamNetworkingUtils_GetRelayNetworkStatus"); + } catch (...) { + p.relay_network = nullptr; + } try { p.set_config = symbol(module, "SteamAPI_ISteamNetworkingUtils_SetConfigValue"); } catch (...) { @@ -466,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; @@ -495,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; @@ -511,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(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(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 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 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(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(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) @@ -579,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) { @@ -592,7 +720,7 @@ void SteamTransport::poll() { } 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 @@ -606,12 +734,40 @@ 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."; @@ -653,6 +809,19 @@ bool SteamTransport::set_send_rate(int rate) { 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(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; @@ -685,6 +854,7 @@ std::vector SteamTransport::links() { 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. @@ -740,7 +910,7 @@ bool SteamTransport::send(std::uint64_t id, std::span 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(bytes.size()), flags, nullptr); @@ -758,7 +928,7 @@ void SteamTransport::send_batch(std::span 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. diff --git a/Extension/Multiplayer/Steam/steam_transport.h b/Extension/Multiplayer/Steam/steam_transport.h index c4a2aec5..8ef4d068 100644 --- a/Extension/Multiplayer/Steam/steam_transport.h +++ b/Extension/Multiplayer/Steam/steam_transport.h @@ -25,6 +25,7 @@ struct TransportLink { // ("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{}; @@ -70,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 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); bool socket_test(); @@ -91,6 +111,10 @@ class SteamTransport { 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); diff --git a/Extension/Multiplayer/Test/pose_codec_tests.cpp b/Extension/Multiplayer/Test/pose_codec_tests.cpp new file mode 100644 index 00000000..fa342796 --- /dev/null +++ b/Extension/Multiplayer/Test/pose_codec_tests.cpp @@ -0,0 +1,564 @@ +#include "Extension/Multiplayer/Net/pose_batch.h" +#include "Extension/Multiplayer/Net/pose_codec.h" +#include "Extension/Multiplayer/Net/sound_codec.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace dingosdk::multiplayer; +using namespace dingosdk::multiplayer::pose_codec; + +namespace { +int failures = 0; +void check(bool ok, const char *what) { + if (ok) return; + ++failures; + std::cerr << "FAIL: " << what << '\n'; +} +// A skater of the measured skeleton in some pose; `turn` moves it on a little or a lot. +Pose skeleton(std::mt19937 &random) { + std::uniform_real_distribution any(-1.f, 1.f); + Pose pose; + pose.skater.resize(finger_skeleton); + pose.board.resize(17); + const auto set = [&](Transform &t, float reach) { + t.position = {any(random) * reach, any(random) * reach, any(random) * reach}; + 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); + }; + set(pose.root, 900.f); + for (auto &t : pose.skater) set(t, 0.4f); + for (auto &t : pose.board) set(t, 0.4f); + pose.board[0].position = {512.25f, 31.5f, -640.125f}; + return pose; +} +void turn(Pose &pose, std::mt19937 &random, float amount, unsigned bones) { + std::uniform_real_distribution any(-amount, amount); + std::uniform_int_distribution which(0, pose.skater.size() - 1); + for (unsigned i = 0; i < bones; ++i) { + auto &t = pose.skater[which(random)]; + for (float &v : t.rotation) v += any(random); + float norm{}; + for (float v : t.rotation) norm += v * v; + for (float &v : t.rotation) v /= std::sqrt(norm); + } + pose.root.position[0] += any(random); + pose.board[0].position[2] += any(random); +} + +void exact_checks() { + std::mt19937 random(5); + auto pose = skeleton(random); + // The awkward rotations: two components equal and as large as a smaller one can be, an exact + // axis either way round, and all four equal. + pose.skater[0].rotation = {.70710678f, .70710678f, 0, 0}; + pose.skater[1].rotation = {0, 0, 0, -1}; + pose.skater[2].rotation = {.5f, .5f, .5f, .5f}; + pose.skater[3].rotation = {-.5f, .5f, -.5f, .5f}; + pose.skater[4].scale = {4, 4, 4}; + const auto first = quantize(pose); + check(first.bones.size() == 1 + finger_skeleton + 17, "A pose did not keep its bones"); + const auto whole_bytes = encode_whole(first); + const auto back = decode(whole_bytes, nullptr); + check(whole(whole_bytes) && back && *back == first, "A whole pose did not come back as it was sent"); + // Rounded and restored, a rotation is within a step and a position within a millimetre. + const auto restored = restore(first); + const float step = static_cast(1U << rotation_shift) / rotation_scale; + bool near = restored.skater.size() == pose.skater.size(); + for (std::size_t i = 0; near && i < pose.skater.size(); ++i) { + const auto &a = pose.skater[i].rotation, &b = restored.skater[i].rotation; + near = std::abs(a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]) > 1.f - 3.f * step * step - 1e-4f; + for (unsigned axis = 0; axis < 3; ++axis) near = near && std::abs(pose.skater[i].position[axis] - restored.skater[i].position[axis]) < 0.00051f; + } + check(near && std::abs(restored.board[0].position[0] - 512.25f) < 0.001f && restored.skater[4].scale == std::array{4, 4, 4}, + "A restored pose was not within a step of the original"); + // A chain of differences lands exactly on what rounding the pose directly gives: no drift. + auto held_by_receiver = first; + auto moving = pose; + std::size_t bytes{}; + for (unsigned frame = 0; frame < 200; ++frame) { + turn(moving, random, frame % 17 ? 0.01f : 0.6f, 70); + if (frame == 60) moving.skater[9].scale = {1.5f, 1, 1}; + if (frame == 90) moving.skater[9].scale = {1, 1, 1}; + const auto now = quantize(moving); + const auto delta = encode_delta(now, held_by_receiver); + bytes += delta.size(); + const auto got = decode(delta, &held_by_receiver); + if (!got || *got != now || whole(delta)) { + check(false, "A difference did not rebuild the sender's pose exactly"); + break; + } + held_by_receiver = *got; + } + // Nothing changed: a handful of bytes. + check(encode_delta(first, first).size() <= 6, "An unchanged pose was not nearly free"); + // Another skeleton cannot be a difference. + auto other = first; + other.bones.pop_back(); + --other.board; + check(encode_delta(other, first).empty(), "A difference between two skeletons was offered"); + std::cout << "Pose codec: whole poses, exact chains of differences (" << bytes / 200 << " B a pose on the synthetic skater).\n"; +} + +void finger_checks() { + std::mt19937 random(9); + auto pose = skeleton(random); + const auto start = quantize(pose); + // Held: everything but the fingers follows; the receiver's fingers stay as they were. + auto receiver = start; + QuantPose truth = start; + for (unsigned frame = 0; frame < 40; ++frame) { + turn(pose, random, 0.2f, 200); + truth = quantize(pose); + const auto got = decode(encode_delta(truth, receiver, Fingers::held), &receiver); + if (!got) return check(false, "A pose with its fingers held did not decode"); + receiver = *got; + } + bool body{true}, fingers_kept{true}, fingers_moved{}; + for (std::size_t bone = 0; bone < truth.bones.size(); ++bone) { + if (finger_bone(truth, bone)) { + fingers_kept = fingers_kept && receiver.bones[bone] == start.bones[bone]; + fingers_moved = fingers_moved || !(truth.bones[bone] == start.bones[bone]); + } else { + body = body && receiver.bones[bone] == truth.bones[bone]; + } + } + check(body && fingers_kept && fingers_moved, "Held fingers moved, or the rest of the pose did not follow"); + // Whole: the receiver's stale fingers are replaced, whatever its reference holds. + turn(pose, random, 0.2f, 200); + truth = quantize(pose); + const auto synced = decode(encode_delta(truth, receiver, Fingers::whole), &receiver); + check(synced && *synced == truth, "Fingers sent whole did not bring the receiver back to the sender's pose"); + // And held fingers are what makes the pose small. + turn(pose, random, 0.02f, 395); + const auto next = quantize(pose); + check(encode_delta(next, truth, Fingers::held).size() < encode_delta(next, truth).size(), "Holding the fingers did not make a pose smaller"); + QuantPose small; + small.skater = 12; + small.board = 1; + small.bones.resize(14); + check(!finger_bone(small, 60), "Another skeleton was given fingers"); + std::cout << "Pose codec: fingers held, and sent whole again.\n"; +} + +void hostile_checks() { + // Whatever arrives, decoding answers or declines: it never reads past the bytes or builds an + // impossible pose. + std::mt19937 random(21); + auto pose = skeleton(random); + const auto reference = quantize(pose); + turn(pose, random, 0.3f, 120); + const auto good = encode_delta(quantize(pose), reference); + const auto whole_bytes = encode_whole(reference); + unsigned answered{}; + for (const auto &source : {good, whole_bytes}) { + for (std::size_t cut = 0; cut < source.size(); cut += std::max(1, source.size() / 97)) { + const std::vector part(source.begin(), source.begin() + static_cast(cut)); + answered += decode(part, &reference).has_value(); + } + for (unsigned round = 0; round < 400; ++round) { + auto damaged = source; + for (unsigned flips = 0; flips < 1 + round % 5; ++flips) damaged[random() % damaged.size()] ^= static_cast(1U << (random() % 8)); + if (const auto got = decode(damaged, &reference)) { + ++answered; + bool sane = got->bones.size() == std::size_t{1} + got->skater + got->board; + for (const auto &bone : got->bones) { + sane = sane && bone.largest < 4; + for (const auto component : bone.rotation) sane = sane && std::abs(component) <= (32767 >> rotation_shift); + for (const auto mm : bone.position) sane = sane && std::abs(std::int64_t{mm}) <= position_limit; + } + if (!sane) return check(false, "Damaged bytes decoded to an impossible pose"); + } + } + } + std::vector noise(300); + for (unsigned round = 0; round < 2000; ++round) { + for (auto &byte : noise) byte = static_cast(random()); + (void)decode(noise, round % 2 ? &reference : nullptr); + } + check(!decode({}, &reference) && !decode(good, nullptr), "Empty bytes or a difference without its reference decoded"); + std::cout << "Pose codec: cut, damaged and random bytes handled (" << answered << " still decoded, all to possible poses).\n"; +} + +// The server's sender and a game's receiver, joined by a link that delays, loses and reorders: +// the same code a dedicated server and a game run (pose_batch.h). Whatever the link does, every +// pose the receiver rebuilds must be the sender's exactly, no stream may stop for good, and whole +// poses must stay rare. +struct LinkResult { + std::size_t sent{}, rebuilt{}, wrong{}, wholes{}, messages{}, bytes{}, stalled{}; +}; +LinkResult run_link(unsigned seed, double loss, unsigned delay_least, unsigned delay_most, std::size_t kept_poses) { + using namespace pose_batch; + std::mt19937 random(seed); + std::uniform_real_distribution chance(0, 1); + constexpr std::size_t sources = 6; + constexpr unsigned ticks = 800; // 40 s at 20 a second + struct Source { + Pose pose; + std::uint32_t sequence{}; + std::deque kept; + unsigned every{1}; // sent every this many ticks + }; + std::array world; + for (std::size_t i = 0; i < sources; ++i) { + world[i].pose = skeleton(random); + world[i].every = i == 3 ? 2 : i == 4 ? 4 : i == 5 ? 20 : 1; + } + std::map, QuantPose> truth; + Sender sender; + std::unordered_map streams; + Ack ack; + bool ack_due{}; + struct Flying { + unsigned arrives{}; + std::vector bytes; + }; + std::vector to_receiver, to_sender; + const auto delay = [&] { return delay_least + static_cast(random() % (delay_most - delay_least + 1)); }; + LinkResult result; + std::array last_rebuilt{}; + // Fingers held and let go in turns, on every other player. A pose's number is its tick. + const auto held = [](std::size_t source, unsigned tick) { return source % 2 == 1 && (tick / 100) % 4 == 1; }; + for (unsigned tick = 1; tick <= ticks; ++tick) { + const std::uint64_t now = std::uint64_t{tick} * 50000; + for (auto it = to_sender.begin(); it != to_sender.end();) { + if (it->arrives > tick) { + ++it; + continue; + } + if (const auto got = Ack::read(it->bytes)) sender.ack(*got); + it = to_sender.erase(it); + } + // The server's pass: every player moves, and those due go out. + const auto emit = [&](std::span message, bool reliable) { + ++result.messages; + result.bytes += message.size(); + if (reliable || chance(random) >= loss) to_receiver.push_back({tick + delay(), {message.begin(), message.end()}}); + return true; + }; + sender.begin(7, 42); + for (std::size_t i = 0; i < sources; ++i) { + auto &source = world[i]; + turn(source.pose, random, tick % 37 ? 0.02f : 0.5f, 90); + ++source.sequence; + source.kept.push_back({source.sequence, now, quantize(source.pose)}); + while (source.kept.size() > kept_poses) source.kept.pop_front(); + truth[{i, source.sequence}] = source.kept.back().pose; + if (tick % source.every) continue; + const auto find = [&](std::uint32_t sequence) -> std::optional { + for (const auto &pose : source.kept) + if (pose.sequence == sequence) return KeptView{pose.sequence, pose.time_us, &pose.pose}; + return {}; + }; + const auto added = sender.add(1000 + i, 5, *find(source.sequence), find, held(i, tick), false, Rate::full, now, emit); + result.wholes += added.did == Sender::Did::whole; + result.sent += added.did != Sender::Did::nothing; + } + sender.flush(emit); + // What reached the game this tick, in whatever order. + std::shuffle(to_receiver.begin(), to_receiver.end(), random); + for (auto it = to_receiver.begin(); it != to_receiver.end();) { + if (it->arrives > tick) { + ++it; + continue; + } + if (const auto batch = read(it->bytes); batch && batch->world == 7 && batch->map == 42) { + bool complete = true; + for (const auto &entry : batch->entries) { + const auto got = rebuild(streams, entry); + if (!got) { + complete = false; + continue; + } + const auto who = static_cast(got->source - 1000); + const auto &real = truth[{who, got->sequence}]; + bool same = got->pose.bones.size() == real.bones.size() && got->epoch == 5; + // A pose sent with its fingers held has the receiver's old ones; every other is exact. + const bool fingers = !held(who, got->sequence); + for (std::size_t bone = 0; same && bone < real.bones.size(); ++bone) + if (fingers || !finger_bone(real, bone)) same = got->pose.bones[bone] == real.bones[bone]; + result.wrong += !same; + ++result.rebuilt; + last_rebuilt[who] = tick; + } + if (complete) { + ack.note(batch->number); + ack_due = true; + } + } + it = to_receiver.erase(it); + } + if (ack_due) { + const auto bytes = ack.bytes(); + if (chance(random) >= loss) to_sender.push_back({tick + delay(), {bytes.begin(), bytes.end()}}); + ack_due = false; + } + } + // Nobody left behind: each stream's last pose came within a few seconds of the end. + for (std::size_t i = 0; i < sources; ++i) result.stalled += last_rebuilt[i] + 80 + world[i].every < ticks; + return result; +} +void link_checks() { + struct Case { + const char *name; + double loss; + unsigned least, most; + std::size_t kept, most_wholes; + }; + for (const auto &c : {Case{"a clean link", 0.0, 1, 1, 300, 6}, Case{"a 400 ms link losing 5%", 0.05, 7, 9, 300, 12}, + Case{"a link losing 30% and reordering", 0.30, 1, 6, 300, 60}, Case{"poses kept only briefly", 0.02, 2, 4, 12, 80}}) { + const auto r = run_link(31, c.loss, c.least, c.most, c.kept); + std::cout << " " << c.name << ": " << r.sent << " poses sent, " << r.rebuilt << " rebuilt, " << r.wrong << " wrong, " << r.wholes << " whole, " + << r.bytes / std::max(1, r.sent) << " B a pose in " << r.messages / 40 << " messages a second\n"; + check(r.wrong == 0, "A pose was rebuilt differently from what the server sent"); + check(r.stalled == 0, "A stream stopped for good"); + check(static_cast(r.rebuilt) > static_cast(r.sent) * (1 - c.loss) * 0.85, "Too few of the poses sent were rebuilt"); + check(r.wholes <= c.most_wholes, "Too many whole poses were needed"); + } + // Acks: the newest and the 64 before it, in any order. + pose_batch::Ack ack; + for (const std::uint32_t number : {5U, 3U, 9U, 8U, 70U, 200U, 199U, 136U}) ack.note(number); + check(ack.holds(200) && ack.holds(199) && ack.holds(136) && !ack.holds(135) && !ack.holds(70) && !ack.holds(198), "Acks did not keep the last 65 messages"); + const auto bytes = ack.bytes(); + const auto back = pose_batch::Ack::read(bytes); + check(back && back->newest == 200 && back->holds(199) && back->holds(136) && !back->holds(137), "An ack did not survive being sent"); + // Noise under the right first four bytes is declined or rebuilds nothing; it never throws. + std::vector junk(96); + std::copy(pose_batch::batch_magic.begin(), pose_batch::batch_magic.end(), junk.begin()); + std::mt19937 random(77); + for (unsigned round = 0; round < 5000; ++round) { + for (std::size_t i = 4; i < junk.size(); ++i) junk[i] = static_cast(round % 3 ? random() : random() % 4); + if (const auto batch = pose_batch::read(junk)) { + std::unordered_map streams; + for (const auto &entry : batch->entries) (void)pose_batch::rebuild(streams, entry); + } + } + std::cout << "Pose batches: delay, loss, reordering, short memory and noise handled.\n"; +} + +// Sizes on real play: a pose-dump recording (mp pose-dump), each record a time, a length and a +// compact packet. Not a pass or fail: prints what each choice costs, and checks every difference +// rebuilds the pose it was made from. +int bench(const char *path) { + std::ifstream in(path, std::ios::binary); + char magic[6]{}; + if (!in.read(magic, 6) || std::memcmp(magic, "RSPD1\n", 6) != 0) { + std::cerr << "Not a pose-dump recording: " << path << '\n'; + return 2; + } + std::vector poses; + for (;;) { + std::uint64_t time{}; + std::uint32_t length{}; + if (!in.read(reinterpret_cast(&time), 8) || !in.read(reinterpret_cast(&length), 4) || length > 1 << 20) break; + std::vector raw(length); + if (!in.read(reinterpret_cast(raw.data()), length)) break; + if (const auto packet = dingosdk::multiplayer::decode(raw); packet && packet->kind == PacketKind::pose) poses.push_back(quantize(packet->pose)); + } + if (poses.size() < 100) { + std::cerr << "Too few poses in " << path << '\n'; + return 2; + } + std::cout << poses.size() << " recorded poses, " << poses[0].bones.size() << " bones; rotations to " + << static_cast(1U << rotation_shift) * 0.00247 << " degrees\n"; + std::cout << "whole pose: " << encode_whole(poses[poses.size() / 2]).size() << " B\n"; + std::cout << "reference age all bones fingers held\n"; + bool exact = true; + for (const std::size_t age : {std::size_t{1}, std::size_t{2}, std::size_t{3}, std::size_t{6}, std::size_t{12}, std::size_t{30}, std::size_t{150}, std::size_t{600}}) { + std::size_t all{}, held{}, count{}; + for (std::size_t frame = age; frame < poses.size(); ++frame) { + const auto delta = encode_delta(poses[frame], poses[frame - age]); + all += delta.size(); + held += encode_delta(poses[frame], poses[frame - age], Fingers::held).size(); + ++count; + if (frame % 13 == 0) { + const auto got = decode(delta, &poses[frame - age]); + exact = exact && got && *got == poses[frame]; + } + } + std::cout << " " << age * 33 << " ms" << std::string(age * 33 < 100 ? 9 : age * 33 < 1000 ? 8 : age * 33 < 10000 ? 7 : 6, ' ') << all / count + << " B " << held / count << " B\n"; + } + const auto started = std::chrono::steady_clock::now(); + std::size_t made{}; + for (unsigned round = 0; round < 20; ++round) + for (std::size_t frame = 2; frame < poses.size(); ++frame) made += encode_delta(poses[frame], poses[frame - 2]).size() > 0; + const auto encode_us = std::chrono::duration(std::chrono::steady_clock::now() - started).count() / static_cast(made); + std::cout << "one difference takes " << encode_us << " us to build; every one checked rebuilt its pose: " << (exact ? "yes" : "NO") << '\n'; + return exact ? 0 : 1; +} +// Skater sound (sound_codec.h): a chain of messages rebuilds every sample as the sender rounded +// it, a message that did not go leaves the chain where it was, and noise is declined. +AudioSample sound_sample(std::mt19937 &random, AudioState &state, std::uint32_t age) { + std::uniform_real_distribution any(-1.f, 1.f); + for (std::size_t i = 0; i < 20; ++i) state.values[i] += any(random) * 0.2f; + if (random() % 9 == 0) state.values[20 + random() % 38] = any(random) * 90.f; + if (random() % 7 == 0) state.flags[1 + random() % 42] ^= 1; + if (random() % 11 == 0) state.selectors[random() % 26] = static_cast(random()); + return {age, state, random() % 3 == 0}; +} +void sound_checks() { + using namespace sound_codec; + std::mt19937 random(41); + Sender sender; + std::unordered_map streams; + std::array states{}; + states[1].values[7] = 0.0001f; // not nothing: must not arrive as nothing + std::size_t bytes{}, messages{}, heard_samples{}; + bool exact = true; + for (unsigned tick = 1; tick <= 600; ++tick) { + const std::uint64_t now = std::uint64_t{tick} * 33333; + sender.begin(7, 42); + std::array, 3> made; + for (std::size_t who = 0; who < 3; ++who) { + if (who == 2 && (tick / 50) % 2) continue; // out of earshot for a while + made[who] = {sound_sample(random, states[who], 24000), sound_sample(random, states[who], 9000)}; + sender.add(500 + who, 9, tick, now, made[who], now); + } + const bool went = tick % 13 != 0; // some messages are refused by the connection + std::vector message(sender.message().begin(), sender.message().end()); + sender.sent(went); + if (!went) continue; + bytes += message.size(); + ++messages; + const auto heard = read(streams, message, 7, 42); + if (!heard) { + exact = false; + break; + } + for (const auto &one : *heard) { + const auto who = static_cast(one.source - 500); + exact = exact && who < 3 && one.epoch == 9 && one.sequence == tick && one.time_us == now && one.samples.size() == made[who].size() && + valid_audio_batch(one.samples); + for (std::size_t i = 0; exact && i < one.samples.size(); ++i) { + exact = one.samples[i].state == restore(quantize(made[who][i].state)) && one.samples[i].event == made[who][i].event && + one.samples[i].age_us == (made[who][i].age_us >> age_shift << age_shift); + ++heard_samples; + } + } + } + check(exact && heard_samples > 2500, "Sound was not rebuilt as it was sent"); + check(restore(quantize(states[1])).values[7] != 0.f, "A small sound value arrived as nothing"); + // Rounding again changes nothing: what a server relays is what it was sent. + const auto once = quantize(states[0]); + check(quantize(restore(once)) == once, "Sound did not survive being rounded twice"); + // Another world's message holds nothing; a stream never started is passed over, not guessed. + sender.begin(7, 42); + const std::vector one{sound_sample(random, states[0], 5000)}; + sender.add(500, 9, 700, 1, one, 600 * 33333 + 1); + std::vector message(sender.message().begin(), sender.message().end()); + sender.sent(true); + std::unordered_map fresh; + const auto other_world = read(streams, message, 8, 42); + const auto unknown = read(fresh, message, 7, 42); + check(other_world && other_world->empty() && unknown && unknown->empty() && fresh.empty(), "Sound for another world or an unknown stream was played"); + for (unsigned round = 0; round < 4000; ++round) { + auto damaged = message; + if (round % 2) damaged.resize(random() % damaged.size()); + else damaged[random() % damaged.size()] ^= static_cast(1U << (random() % 8)); + std::unordered_map copy = streams; + if (const auto got = read(copy, damaged, 7, 42)) + for (const auto &entry : *got) + for (const auto &sample : entry.samples) + if (!valid_audio(sample.state)) return check(false, "Damaged sound decoded to an impossible sample"); + } + std::cout << "Sound codec: chains, refused messages, other worlds and damage handled (" << bytes / messages / 3 << " B a player a message on synthetic sound).\n"; +} +// Sizes on real play: the sound in a pose-dump recording, sent as a game would send it. +int sound_bench(const char *path) { + using namespace sound_codec; + std::ifstream in(path, std::ios::binary); + char magic_bytes[6]{}; + if (!in.read(magic_bytes, 6)) return 2; + Sender sender; + std::unordered_map streams; + std::size_t messages{}, old_bytes{}, new_bytes{}, samples{}; + bool exact = true; + std::uint64_t first{}, last{}; + for (;;) { + std::uint64_t time{}; + std::uint32_t length{}; + if (!in.read(reinterpret_cast(&time), 8) || !in.read(reinterpret_cast(&length), 4) || length > 1 << 20) break; + std::vector raw(length); + if (!in.read(reinterpret_cast(raw.data()), length)) break; + const auto packet = dingosdk::multiplayer::decode(raw); + if (!packet || packet->kind != PacketKind::audio) continue; + if (!first) first = packet->time_us; + last = packet->time_us; + sender.begin(1, 2); + sender.add(packet->source, packet->epoch, packet->sequence, packet->time_us, packet->audio, packet->time_us); + const std::vector message(sender.message().begin(), sender.message().end()); + sender.sent(true); + const auto heard = read(streams, message, 1, 2); + exact = exact && heard && heard->size() == 1 && heard->front().samples.size() == packet->audio.size(); + for (std::size_t i = 0; exact && i < packet->audio.size(); ++i) exact = heard->front().samples[i].state == restore(quantize(packet->audio[i].state)); + ++messages; + samples += packet->audio.size(); + old_bytes += length; + new_bytes += message.size(); + } + if (!messages) return 0; + const double seconds = static_cast(last - first) / 1e6; + std::cout << messages << " sound messages, " << samples << " samples in " << seconds << " s\n" + << " old format, before the wire's own compression: " << old_bytes / messages << " B a message, " << static_cast(old_bytes) / seconds / 1024 << " KB/s\n" + << " new format: " << new_bytes / messages << " B a message, " << static_cast(new_bytes) / seconds / 1024 << " KB/s; every sample rebuilt as rounded: " + << (exact ? "yes" : "NO") << '\n'; + return exact ? 0 : 1; +} + +// The sound in a recording, laid flat for study: for each sample its message's number and size, +// its age, whether it is an event, and its values, selectors and flags. +int sound_out(const char *path, const char *to) { + std::ifstream in(path, std::ios::binary); + char magic[6]{}; + if (!in.read(magic, 6)) return 2; + std::ofstream out(to, std::ios::binary); + std::uint32_t message{}; + for (;;) { + std::uint64_t time{}; + std::uint32_t length{}; + if (!in.read(reinterpret_cast(&time), 8) || !in.read(reinterpret_cast(&length), 4) || length > 1 << 20) break; + std::vector raw(length); + if (!in.read(reinterpret_cast(raw.data()), length)) break; + const auto packet = dingosdk::multiplayer::decode(raw); + if (!packet || packet->kind != PacketKind::audio) continue; + for (const auto &sample : packet->audio) { + const std::uint32_t head[4]{message, length, sample.age_us, sample.event ? 1U : 0U}; + out.write(reinterpret_cast(head), sizeof head); + out.write(reinterpret_cast(&time), 8); + out.write(reinterpret_cast(sample.state.values.data()), sizeof sample.state.values); + out.write(reinterpret_cast(sample.state.selectors.data()), sizeof sample.state.selectors); + out.write(reinterpret_cast(sample.state.flags.data()), sizeof sample.state.flags); + } + ++message; + } + std::cout << message << " sound messages\n"; + return 0; +} +} // namespace + +int main(int argc, char **argv) { + if (argc == 3) return sound_out(argv[1], argv[2]); + if (argc == 2) { + const auto poses = bench(argv[1]); + const auto sound = sound_bench(argv[1]); + return poses ? poses : sound; + } + exact_checks(); + finger_checks(); + hostile_checks(); + link_checks(); + sound_checks(); + if (failures) return 1; + std::cout << "Pose codec tests passed.\n"; + return 0; +} diff --git a/Extension/Multiplayer/console_commands.cpp b/Extension/Multiplayer/console_commands.cpp index 2acc6bc6..61fe763d 100644 --- a/Extension/Multiplayer/console_commands.cpp +++ b/Extension/Multiplayer/console_commands.cpp @@ -31,6 +31,8 @@ void register_multiplayer_commands(Commands ®istry) { Command{"tpall", "Host or server admin: teleport everyone to you"}, Command{"tphere", "Host or server admin: teleport one player to you"}, Command{"nametags", "Show or hide player nametags (on, off, toggle)"}, + Command{"pose-dump", "Research: record your own poses for a number of seconds (1-600) to logs/poses-*.bin"}, + Command{"direct-connections", "Connect straight to servers that offer it, not through Steam's relays (on, off, toggle)"}, Command{"player-distance", "How far away another player still gets a skater, in metres (50-1000; 1000: every player). Past it: a nametag or dot"}, Command{"nametag-distance", "How far away a player's name still shows, in metres (10-500); past it they are a dot"}, Command{"nametag-dots", "Show far and off-screen players as dots (on, off, toggle)"}, @@ -57,11 +59,12 @@ void register_multiplayer_commands(Commands ®istry) { if (std::string_view(c.name) == "nametags" || std::string_view(c.name) == "chat-bubbles" || std::string_view(c.name) == "chat-bubbles-own" || std::string_view(c.name) == "nametag-dots" || std::string_view(c.name) == "nametags-friends" || + std::string_view(c.name) == "direct-connections" || std::string_view(c.name) == "score-check") args.push_back(argument("choice")); if (std::string_view(c.name) == "chat-bubbles-distance" || std::string_view(c.name) == "nametag-distance") args.push_back(argument("metres")); - if (std::string_view(c.name) == "chat-bubbles-duration") + if (std::string_view(c.name) == "chat-bubbles-duration" || std::string_view(c.name) == "pose-dump") args.push_back(argument("seconds")); if (std::string_view(c.name) == "player-distance") args.push_back(argument("metres")); diff --git a/Extension/UI/Overlay/multiplayer_menu.cpp b/Extension/UI/Overlay/multiplayer_menu.cpp index 0c8412bb..6869ae95 100644 --- a/Extension/UI/Overlay/multiplayer_menu.cpp +++ b/Extension/UI/Overlay/multiplayer_menu.cpp @@ -256,6 +256,15 @@ void multiplayer_display_settings(SkateMenu &menu, const Model &model) { (*menu.player_distance_pending == mp.player_distance || ImGui::GetTime() >= menu.player_distance_until)) menu.player_distance_pending.reset(); } + bool direct = mp.prefer_direct; + if (toggle_row(menu, "Direct connections", + "Connect straight to dedicated servers that offer it: the shortest route, so the lowest ping. The server can then " + "see your IP address, as with any game's dedicated servers; other players never can. Off: always through Steam's " + "relays. Applies from the next server you join.", + direct)) { + std::array unused{}; + send_private(menu, "direct-connections", direct ? "on" : "off", unused, false); + } bool nametags = mp.nametags; if (toggle_row(menu, "Player nametags", "The name above each skater, with their distance: purple for ReSkate developers, red for content creators, " diff --git a/Server/README.txt b/Server/README.txt index 7e9bf82f..69277deb 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -60,6 +60,50 @@ send_rate The most the server sends one player, in KB/s (default 900, Steam's relays: set higher, what is lost is resent until the connection is full of resends and half of everything is lost. Console: rate , also for the players already on. +finger_distance Past this many metres (default 25) a player's fingers are not + sent moving: they stay as they were, and move again when the + player is nearer. Fingers are nearly half of every position + update and cannot be made out at that distance. 0 always + sends them. +pack_ms How long a message to a player may wait to go in the same + packet as the next ones, in milliseconds (default 10, 0 to + 50). A full server sends each player hundreds of small + messages a second, each in a packet of its own with its own + headers; packed, the same updates take fewer packets, less + bandwidth and less CPU. It adds up to that long to when an + update arrives. Voice is never held back. 0 sends every + message at once, as before. Takes effect on restart. +connection How players reach the server: "relay" (default) or "direct". + relay Through Steam's relay network. Nothing to open, and + the server never sees a player's address. The route + is Steam's choice and can be a long way round. + direct Straight to this server's "port" over UDP, which + must be open to the internet. The shortest route, + so the lowest ping. The server sees the addresses + of the players who connect this way, as any + dedicated server does; players never see each + other's. + A direct server still answers through the relays: a game + tries the port first and uses the relays if it gets no + answer in a few seconds, and a player can turn direct + connections off in their own settings. So "direct" never + keeps anyone out. Steam vouches for who each player is + either way. "net" shows who is direct, and the log has a + [direct] line for each player who asks to connect that way. +direct_address For "direct": the address players connect to, like + 203.0.113.7, when it is not the one the server's start-up + line calls its public IP. Some hosts send from one address + and take connections on another; use the one your panel + shows for the server. Empty (default): the public IP. +steam_debug true logs what Steam's own networking says it is doing: each + connection asked for, and what it refused or ignored and + why. For finding out why players cannot connect; it is a + lot of text, so turn it off again afterwards. +relay_everything true sends poses through the server even between players + whose games have linked to each other. For testing with a + few players; leave it false otherwise. +direct_port For "direct": the UDP port to listen on when it is not + "port" (default 0: "port"). Must differ from query_port. bone_scale_limit How far a mod may resize part of a skater for the other players (a "big head" mod and the like): the most a bone may be scaled, 1 to 8. 1 (default) shows every skater at the diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 2a5ced2a..56a673cf 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -70,6 +70,12 @@ int run() { ServerConfig scaled; scaled.bone_scale_limit = 0.5f; check(config_error(scaled).find("bone_scale_limit") != std::string::npos, "A bone scale limit under 1 was accepted"); + check(has("pack_ms") && config.pack_ms == 10, "pack_ms is not a new setting of 10"); + check(has("finger_distance") && config.finger_distance == 25, "finger_distance is not a new setting of 25"); + check(has("connection") && config.connection == "relay", "connection is not a new setting of relay"); + ServerConfig how; + how.connection = "carrier pigeon"; + check(config_error(how).find("connection") != std::string::npos, "An unknown connection was accepted"); ServerConfig crowd; crowd.crowd_budget = 50; check(config_error(crowd).find("crowd_budget") != std::string::npos, "A crowd budget too small to play with was accepted"); diff --git a/Server/main.cpp b/Server/main.cpp index c0768360..f28adf47 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -15,6 +15,8 @@ #include "Engine/Game/World/world_names.h" #include "Engine/Vfs/world_layer_scan.h" #ifdef _WIN32 +#include +#include #include #include #else @@ -389,6 +391,65 @@ int run(int argc, char **argv, bool skip_update) { write_log("Steam networking failed: " + transport.status().detail); return 1; } + // A check of another server's direct connections, from here: RESKATE_PROBE_DIRECT set to + // ",,". Signs in as this server would, connects + // as a game does (the address first, Steam's relays after five seconds), says which it got, + // and exits. Run it from a machine outside the other server's network. + std::string probe; +#ifdef _WIN32 + { + char *value{}; + std::size_t length{}; + if (!_dupenv_s(&value, &length, "RESKATE_PROBE_DIRECT") && value) probe = value; + std::free(value); + } +#else + if (const char *value = std::getenv("RESKATE_PROBE_DIRECT")) probe = value; +#endif + if (!probe.empty()) { + unsigned long long id{}; + unsigned port{}; + const auto first = probe.find(','), second = probe.find(',', first == std::string::npos ? 0 : first + 1); + const auto address = first != std::string::npos && second != std::string::npos ? direct_ipv4(std::string_view(probe).substr(first + 1, second - first - 1)) : 0; + const bool parsed = address && std::from_chars(probe.data(), probe.data() + first, id).ec == std::errc{} && + std::from_chars(probe.data() + second + 1, probe.data() + probe.size(), port).ec == std::errc{}; + if (!parsed || !id || !port || port > 65535) { + write_log("RESKATE_PROBE_DIRECT must be ,,."); + return 1; + } + const unsigned a = address >> 24, b = (address >> 16) & 255, c = (address >> 8) & 255, d = address & 255; + write_log("Probe: connecting straight to " + std::to_string(a) + "." + std::to_string(b) + "." + std::to_string(c) + "." + + std::to_string(d) + ":" + std::to_string(port) + " as a game would..."); + if (!transport.join(id, address, static_cast(port))) { + write_log("Probe: Steam would not start the connection: " + transport.status().detail); + return 1; + } + std::string said; + const auto started = std::chrono::steady_clock::now(); + while (!stopping && std::chrono::steady_clock::now() - started < std::chrono::seconds(30)) { + steam.run_callbacks(); + transport.poll(); + if (const auto &detail = transport.status().detail; detail != said) { + said = detail; + write_log("Probe: " + said); + } + const auto links = transport.links(); + if (links.empty()) { + write_log("Probe: no connection either way. " + transport.take_closed(id)); + return 1; + } + if (links.front().connected && links.front().measured) { + write_log(links.front().direct + ? "Probe: CONNECTED DIRECTLY, ping " + std::to_string(links.front().ping_ms) + " ms." + : "Probe: the address did not answer; connected through Steam's relays (" + links.front().relay + "-" + + links.front().remote_relay + "), ping " + std::to_string(links.front().ping_ms) + " ms."); + return 0; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + write_log("Probe: nothing after 30 s."); + return 1; + } Host host(config, transport, write_log); if (!host.start(error)) { write_log("Could not open the server: " + error); @@ -422,8 +483,23 @@ int run(int argc, char **argv, bool skip_update) { auto next_ban_check = next_advertise; bool bans_unread{}; if (!config.global_bans) write_log("Global bans are off (\"global_bans\": false): only this server's own bans apply."); + // Signed in to Steam, as last logged, and when it is looked at again. Players already on stay + // connected through a lost sign-in, but nobody new can join: Steam carries the first messages + // of a connection, and theirs time out ("negotiate rendezvous"). + bool signed_in = true; + auto next_sign_in_check = std::chrono::steady_clock::now(); while (!stopping && !restart) { steam.run_callbacks(); + if (const auto now = std::chrono::steady_clock::now(); now >= next_sign_in_check) { + next_sign_in_check = now + std::chrono::seconds(5); + if (const bool on = steam.logged_on(); on != signed_in) { + signed_in = on; + write_log(on ? "[steam] Signed in to Steam again: players can join." + : "[steam] No longer signed in to Steam: players already on stay, but nobody can join until it is back. " + "Steam signs a server out when another one signs in with the same steam_token, and for its own " + "maintenance or a lost connection."); + } + } try { host.tick(multiplayer::now_us()); } catch (const std::exception &e) { @@ -516,7 +592,7 @@ int run(int argc, char **argv, bool skip_update) { name_allowed = allowed; } steam.advertise({config.name, host.map_name(), host.players(), config.max_players, !config.password.empty(), - config.listed && allowed, host.secret()}); + config.listed && allowed, host.secret(), host.direct_port(), host.direct_address()}); } std::this_thread::sleep_for(std::chrono::milliseconds(2)); } diff --git a/Server/server_config.cpp b/Server/server_config.cpp index cf0a3f48..a9ee42f1 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -26,6 +26,13 @@ Json to_json(const ServerConfig &c) { root["reserved_slots"] = c.reserved_slots; root["crowd_budget"] = c.crowd_budget; root["bone_scale_limit"] = c.bone_scale_limit; + root["connection"] = c.connection; + root["direct_address"] = c.direct_address; + root["steam_debug"] = c.steam_debug; + root["relay_everything"] = c.relay_everything; + root["direct_port"] = c.direct_port; + root["pack_ms"] = c.pack_ms; + root["finger_distance"] = c.finger_distance; root["send_rate"] = c.send_rate; auto reserved = Json::array(); for (const auto id : c.reserved) reserved.push_back(std::to_string(id)); // as strings, like the admins @@ -134,6 +141,13 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector 16384) return "send_rate must be 128 to 16384 (KB/s for each player)."; if (c.bone_scale_limit != 0 && !(c.bone_scale_limit >= 1.f && c.bone_scale_limit <= 8.f)) return "bone_scale_limit must be 0 (no limit) or 1 to 8 (1: no resized body parts at all)."; + if (c.connection != "relay" && c.connection != "direct") return "connection must be \"relay\" or \"direct\"."; + if (!c.direct_address.empty() && !direct_ipv4(c.direct_address)) return "direct_address must be an address like 203.0.113.7, or empty."; + if (c.direct_port && c.direct_port == c.query_port) return "direct_port and query_port must differ."; + if (c.pack_ms > 50) return "pack_ms must be 0 (off) to 50."; + if (c.finger_distance > 10000) return "finger_distance must be 0 (fingers always sent) to 10000."; if (!valid_crowd_budget(c.crowd_budget)) return "crowd_budget must be 0 (no limit) or " + std::to_string(min_crowd_budget) + " to " + std::to_string(max_crowd_budget) + "."; @@ -360,6 +379,22 @@ const ServerLevel *find_level(std::string_view map) { } return nullptr; } +std::uint32_t direct_ipv4(std::string_view text) noexcept { + std::uint32_t address{}; + unsigned parts{}; + while (!text.empty() && parts < 4) { + const auto dot = text.find('.'); + const auto part = text.substr(0, dot); + unsigned value{}; + const auto parsed = std::from_chars(part.data(), part.data() + part.size(), value); + if (part.empty() || part.size() > 3 || parsed.ec != std::errc{} || parsed.ptr != part.data() + part.size() || value > 255) return 0; + address = (address << 8) | value; + ++parts; + text = dot == std::string_view::npos ? std::string_view{} : text.substr(dot + 1); + if (dot != std::string_view::npos && text.empty()) return 0; // a trailing dot + } + return parts == 4 && text.empty() ? address : 0; +} bool installed_map(std::string_view map) { const auto destination = map_destination(map); const auto asset = world_destination_asset(destination); diff --git a/Server/server_config.h b/Server/server_config.h index f149d46e..eeb5d1f2 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -46,6 +46,27 @@ struct ServerConfig { // inverse the least): 1, the default, shows every skater at the game's own proportions; // 0 is no limit. float bone_scale_limit = 1; + // How players reach the server: "relay", through Steam's relay network only, or "direct", + // straight to `port` (UDP). A direct server still answers through the relays, for a player + // the port does not reach, one who has turned direct connections off, or an older game. + std::string connection = "relay"; + // The address players connect straight to, a.b.c.d, when it is not the one Steam lists the + // server under (a host that sends from one address and takes connections on another). + // Empty: the listed one. And the port, when it is not `port`; 0: `port`. + std::string direct_address; + // Logs what Steam's networking says it is doing, to find out why connections fail. + bool steam_debug = false; + // For testing with few players: poses go through the server even between players whose + // games have linked to each other. + bool relay_everything = false; + std::uint16_t direct_port = 0; + // How long a message to a player may wait to share a packet with the next ones, in + // milliseconds (0: each goes at once in a packet of its own). Fewer, fuller packets: + // less sent for the same updates, and less work sending it. + unsigned pack_ms = 10; + // Past this many metres a player's fingers are not sent moving (0: always). A skater's + // forty finger bones turn in nearly every pose and are half of what a pose carries. + unsigned finger_distance = 25; // What the server may send each player, in KB/s (128-16384). unsigned send_rate = 900; std::vector reserved; @@ -129,6 +150,8 @@ const ServerLevel *find_level(std::string_view map); // Whether the server has this map (a name, level path or destination): one of the game's own, // or one a mod folder in its Mods lists. It only moves players to a map it has itself. bool installed_map(std::string_view map); +// "a.b.c.d" as a number (a the highest byte), or 0 when it is not an IPv4 address. +std::uint32_t direct_ipv4(std::string_view text) noexcept; // What players load for a map, as the protocol carries it ("|"). // Empty when the map is unknown (a full level path is always accepted). std::string map_destination(std::string_view map); diff --git a/Server/server_host.cpp b/Server/server_host.cpp index a85a31f2..84d79058 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -140,10 +140,29 @@ unsigned Host::players() const { bool Host::start(std::string &error) { transport_.set_send_rate(static_cast(config_.send_rate * 1024)); + transport_.set_packing(config_.pack_ms); + if (config_.steam_debug) + log_(transport_.set_steam_debug(true) ? "Steam networking debug output is on (\"steam_debug\"): its lines are marked [direct] Steam:." + : "steam_debug: this Steam has no debug output."); if (!transport_.host(capacity())) { error = transport_.status().detail; return false; } + direct_port_ = 0; + if (config_.connection == "direct") { + const auto port = config_.direct_port ? config_.direct_port : config_.port; + if (transport_.listen_direct(port)) { + direct_port_ = port; + log_("Connection: direct, on UDP port " + std::to_string(port) + + (config_.direct_address.empty() ? std::string{} : " at " + config_.direct_address) + + ". The port must be open to the internet; players it does not reach come through Steam's relays."); + } else { + log_("Connection: direct was asked for, but Steam could not listen on UDP port " + std::to_string(port) + + " (in use, or not allowed here). Players connect through Steam's relays only."); + } + } else { + log_("Connection: through Steam's relays (\"connection\": \"direct\" lets players connect straight to the server)."); + } id_ = transport_.status().local_id; secret_ = nonce(); epoch_ = nonce(); @@ -181,13 +200,14 @@ namespace { // for a difference from one, however old the reference (net's pose size line), so they are // kept rare: at every 8 seconds they were a seventh of everything a busy server sent. constexpr std::uint64_t whole_state_refresh = 60000000; -// How finely a player's rotations are sent, by how far away the recipient is (coarsen_rotations): -// within the full rate's reach, the half rate's, the low rate's, and out of sight. The -// nearest is 0.04 degrees a step, which nothing shows; the low rate's a third of a degree, -// at 170 m and more; out of sight only where the skater is matters. A bone that turns by less -// than a step is not sent at all, and a far skater's fingers and face never turn by more. -constexpr std::array pose_precision{4, 6, 7, 11}; -constexpr std::array pose_precision_names{"near", "mid", "far", "out of sight"}; +// Rotations were rounded more coarsely for far recipients for a while (coarsen_rotations). +// Measured on a full server it saved 0 to 7% of a pose (24% out of sight), because a pose is +// sent as a difference from a reference up to a minute old, from which every animated bone +// has turned by far more than any step; and renewing the reference whenever a recipient +// crossed from one precision to another cost more than that saved. So poses go as they come. +constexpr std::array pose_rate_names{"full rate", "half rate", "low rate", "out of sight"}; +// How long a kept pose stays (KeptPose::keep), in microseconds: by the slowest rate it went out at. +constexpr std::array pose_kept_for{1500000, 3000000, 5000000, 15000000}; constexpr std::array traffic_names{"poses", "sound", "voice", "outfits", "objects", "other"}; constexpr std::size_t traffic_kind(PacketKind kind) noexcept { return kind == PacketKind::pose ? 0 : kind == PacketKind::audio ? 1 : kind == PacketKind::voice ? 2 @@ -228,6 +248,26 @@ void Host::measure_pose(Guest &from, const Packet &packet) { const auto last = cost(from.pose_last), quarter = cost(from.pose_quarter), second = cost(from.pose_second); if (last && quarter && second) { ++pose_sizes_.samples; + // What changed since the pose before, field by field, as pose_delta walks them. + try { + constexpr auto prefix = packet_header_size + 6; + const auto bones = pose_delta::count(raw); + if (bones && bones == pose_delta::count(from.pose_last.raw)) { + pose_delta::Cursor now_at{raw, prefix}, before_at{from.pose_last.raw, prefix}; + pose_sizes_.bones += bones; + for (unsigned bone = 0; bone < bones; ++bone) { + const auto current = pose_delta::fields(now_at), earlier = pose_delta::fields(before_at); + // Fields: flags, position (12 bytes as floats, 6 in mm), rotation, scale. + for (unsigned field = 1; field < 4; ++field) { + if (std::equal(current[field].begin(), current[field].end(), earlier[field].begin(), earlier[field].end())) continue; + auto &kind = pose_sizes_.changed[field == 1 ? (current[1].size() == 12 ? 0 : 1) : field]; + ++kind.fields; + kind.bytes += current[field].size(); + } + } + } + } catch (...) { + } pose_sizes_.whole += encode_wire_bytes(raw).size() + 4; pose_sizes_.last += last; pose_sizes_.quarter += quarter; @@ -239,9 +279,95 @@ void Host::measure_pose(Guest &from, const Packet &packet) { if (kept->raw.empty() || packet.time_us < kept->time || packet.time_us - kept->time >= age) *kept = {raw, packet.time_us}; from.pose_last = {std::move(raw), packet.time_us}; } +Host::Guest::KeptPose *Host::keep_pose(Guest &from, const Packet &packet) { + auto &kept = from.kept_poses; + std::erase_if(kept, [&](const Guest::KeptPose &pose) { return now_ - pose.kept_at > pose_kept_for[std::min(pose.keep, 3)]; }); + while (kept.size() >= 96) kept.pop_front(); + if (!kept.empty() && kept.back().sequence == packet.sequence) return &kept.back(); + // What a mod resized on its player's skater reaches the others only as far as the server allows. + if (config_.bone_scale_limit >= 1.f) { + auto pose = packet.pose; + limit_bone_scale(pose, config_.bone_scale_limit); + kept.push_back({packet.sequence, packet.time_us, now_, 0, pose_codec::quantize(pose)}); + } else { + kept.push_back({packet.sequence, packet.time_us, now_, 0, pose_codec::quantize(packet.pose)}); + } + return &kept.back(); +} +// Sends each player the poses queued for them this pass (broadcast): their differences from a +// pose of the same player the recipient is known to hold, packed several to a message. +void Host::flush_poses() { + for (auto &[id, guest] : guests_) { + auto &g = *guest; + // Which of their own pose messages arrived, once for all read this pass. + if (std::exchange(g.upload_ack_due, false)) { + const auto ack = g.upload_ack.bytes(); + if (transport_.send(id, ack, false, true, TrafficLane::gameplay)) + for (auto *counted : {&g.traffic, &traffic_}) counted->total.out[traffic_kind(PacketKind::pose)] += ack.size(); + } + // The sound of the skaters they can hear, in one message, reliably: each builds on the + // last, and many samples are a pulse that must not be lost. + if (!g.queued_sound.empty()) { + const auto sounds = std::exchange(g.queued_sound, {}); + if (g.handshaken && g.world_ready) { + g.sound_sender.begin(world_, map_); + for (const auto &q : sounds) + if (const auto *from = find(q.source)) g.sound_sender.add(q.source, from->member.epoch, q.sequence, q.time_us, *q.samples, now_); + if (g.sound_sender.pending()) { + const auto message = g.sound_sender.message(); + const bool went = transport_.send(id, message, true, true, traffic_lane(PacketKind::audio)); + if (went) + for (auto *counted : {&g.traffic, &traffic_}) counted->total.out[traffic_kind(PacketKind::audio)] += message.size(); + g.sound_sender.sent(went); + } + } + } + if (g.queued_poses.empty()) continue; + const auto queued = std::exchange(g.queued_poses, {}); + if (!g.handshaken || !g.world_ready) continue; + const auto emit = [&](std::span message, bool reliable) { + if (!transport_.send(id, message, reliable, !reliable, TrafficLane::gameplay)) return false; + for (auto *counted : {&g.traffic, &traffic_}) counted->total.out[traffic_kind(PacketKind::pose)] += message.size(); + return true; + }; + g.pose_sender.begin(world_, map_); + for (const auto &q : queued) { + auto *from = find(q.source); + if (!from) continue; + const auto find_kept = [&](std::uint32_t sequence) -> std::optional { + for (auto it = from->kept_poses.rbegin(); it != from->kept_poses.rend(); ++it) + if (it->sequence == sequence) return pose_batch::KeptView{it->sequence, it->time_us, &it->pose}; + return {}; + }; + const auto pose = find_kept(q.sequence); + if (!pose) continue; + const auto added = g.pose_sender.add(q.source, from->member.epoch, *pose, find_kept, q.hold_fingers, q.collision, q.rate, now_, emit); + if (added.did == pose_batch::Sender::Did::whole) { + // Kept long enough for the ack of it to find it here. + for (auto &kept : from->kept_poses) + if (kept.sequence == q.sequence) kept.keep = 3; + for (auto *counted : {&g.traffic, &traffic_}) ++counted->total.snapshots; + ++pose_sizes_.whole_sent; + pose_sizes_.whole_sent_bytes += added.bytes; + } else if (added.did == pose_batch::Sender::Did::difference) { + const auto tier = std::min(q.tier, 3); + ++pose_sizes_.sent[tier]; + pose_sizes_.sent_bytes[tier] += added.bytes + 8; + pose_sizes_.held += q.hold_fingers; + } + } + g.pose_sender.flush(emit); + } +} +// A player said which messages of poses they read in full: the poses in those are ones they hold. +void Host::pose_ack(Guest &g, const pose_batch::Ack &ack) { g.pose_sender.ack(ack); } void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint64_t except) { - if (packet.kind == PacketKind::pose) - if (auto *from = find(packet.source)) measure_pose(*from, packet); + // A pose is rounded once and kept (keep_pose); each recipient is then sent its differences + // from a pose of this player they hold, several players to a message (flush_poses). + auto *kept = packet.kind == PacketKind::pose ? [&]() -> Guest::KeptPose * { + auto *from = find(packet.source); + return from ? keep_pose(*from, packet) : nullptr; + }() : nullptr; const auto *source = find(packet.source); // Recipients holding the same delta reference share one patch and compression // (DeltaCache): every update is built first, then all are sent and recorded. @@ -250,6 +376,7 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 struct Outgoing { Guest *guest; std::uint64_t id; WireUpdate update; PoseDelivery *delivery; std::uint32_t interval; unsigned variant; }; std::vector outgoing; outgoing.reserve(guests_.size()); + std::shared_ptr> sound; // a skater's samples, shared by everyone sent them const bool gameplay = packet.kind == PacketKind::pose || packet.kind == PacketKind::audio || packet.kind == PacketKind::voice || packet.kind == PacketKind::cosmetics; for (auto &[id, guest] : guests_) { @@ -285,9 +412,15 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 if (distance > p.crowd.half || (config_.distances.valid() && distance > reach * reach)) continue; } // Frequent gameplay streams skip the server once the receiver confirms a direct route. - if (source && (packet.kind == PacketKind::pose || packet.kind == PacketKind::audio) && + if (source && !config_.relay_everything && (packet.kind == PacketKind::pose || packet.kind == PacketKind::audio) && !needs_relay(p.direct_routes, p.route_reported, source->member, now_)) continue; + // Sound is sent with everyone else's they hear, once a pass (flush_poses). + if (packet.kind == PacketKind::audio) { + if (!sound) sound = std::make_shared>(packet.audio); + p.queued_sound.push_back({packet.source, packet.time_us, packet.sequence, sound}); + continue; + } PoseDelivery *delivery{}; std::uint32_t slot{}; // how often this source's poses go to them, when not every `interval` bool out_of_sight{}; @@ -313,18 +446,16 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 // Half as far again as the low rate starts: nothing of a skater can be made out // there, only where they are (their dot, the map). // (A little nearer to come back into sight than to leave it.) - const auto sight = static_cast(config_.distances.low_rate_start) * (delivery->precision == 3 ? 1.4f : 1.5f); + const auto sight = static_cast(config_.distances.low_rate_start) * ((delivery->precision & 1) ? 1.4f : 1.5f); out_of_sight = config_.distances.valid() && distance > sight * sight; - precision = out_of_sight ? 3U : delivery->by_distance == 200000 ? 2U : delivery->by_distance == 100000 ? 1U : 0U; + // Too far to make out a hand: the fingers are not sent (pose_codec's Fingers). A + // little nearer to get them back than to lose them. + const auto hands = static_cast(config_.finger_distance) * ((delivery->precision & 2) ? 0.9f : 1.1f); + const bool held = config_.finger_distance && packet.pose.skater.size() == pose_codec::finger_skeleton && distance > hands * hands; + precision = held ? 1U : 0U; + delivery->precision = static_cast((out_of_sight ? 1U : 0U) | (held ? 2U : 0U)); // which side of each they are on } else { delivery->by_distance = 0; - precision = delivery->precision; - } - // By distance only, which changes seldom: a difference from a reference of another - // precision carries every bone again, so the reference is renewed with it. - if (precision != delivery->precision) { - p.sender.forget(packet.source, PacketKind::pose); - delivery->precision = static_cast(precision); } // Standing still for a few seconds: five poses a second carry it to anyone. The // rate is back at once when they move (the change of rate sends the next pose). @@ -336,6 +467,18 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 // it hears nothing of for several seconds). On a full server most players are out // of each other's sight, and at five a second each they were most of what was sent. slot = out_of_sight && interval == 200000 ? 1000000U : interval; + if (kept) { + const std::uint8_t tier = slot == 1000000 ? 3 : interval == 200000 ? 2 : interval == 100000 ? 1 : 0; + p.queued_poses.push_back({packet.source, packet.sequence, + tier >= 2 ? pose_batch::Rate::low : tier == 1 ? pose_batch::Rate::half : pose_batch::Rate::full, + packet.player_collision, precision == 1, tier}); + kept->keep = std::max(kept->keep, tier); + delivery->last_sent = now_; + // The next slot on the source's own clock: everyone sent a source at a slower rate + // is sent the same poses of it, so few of them need keeping to build on. + delivery->next_source_time = (packet.time_us / slot + 1) * slot; + continue; + } } const auto variant = (interval == 200000 ? 2U : interval == 100000 ? 1U : 0U) * 4 + precision; auto &data = encoded[variant]; @@ -345,7 +488,6 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 if (packet.kind == PacketKind::pose) { // What a mod resized on its player's skater reaches the others only as far as the server allows. if (config_.bone_scale_limit >= 1.f) limit_bone_scale(data.packet.pose, config_.bone_scale_limit); - coarsen_rotations(data.packet.pose, pose_precision[precision]); } data.raw = encode(data.packet, true); data.wire = encode_wire_bytes(data.raw); @@ -366,8 +508,8 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 counted->total.snapshots += whole; } if (sending.kind == PacketKind::pose && !whole) { - ++pose_sizes_.sent[out.variant % 4]; - pose_sizes_.sent_bytes[out.variant % 4] += out.update.bytes.size(); + ++pose_sizes_.sent[out.variant / 4]; + pose_sizes_.sent_bytes[out.variant / 4] += out.update.bytes.size(); } out.guest->sender.sent(sending, std::move(out.update)); } @@ -586,6 +728,10 @@ void Host::drop(std::uint64_t id, const std::string &reason, const std::string & (detail.empty() ? std::string{} : " [" + detail + "]")); } guests_.erase(found); + for (auto &[other, guest] : guests_) { + guest->pose_sender.forget(id); + guest->sound_sender.forget(id); + } party_left(id, name); // Their vote cooldown stays (tick() drops it once it has run out): leaving and coming // back does not let a player start another vote sooner. @@ -628,14 +774,95 @@ bool Host::accept_data(Guest &source, const Packet &p) { } return true; } -void Host::receive(std::uint64_t peer, std::span bytes, std::uint64_t received_at) { +// A player's own poses (pose_batch.h): each is rebuilt from one of theirs held here and taken as +// the packet it would have been. A message read in full is acked (flush_poses); one with a pose +// that could not be rebuilt is not, so their game goes on building on what is held here. +void Host::receive_poses(std::uint64_t peer, std::span bytes, std::uint64_t received_at) { + auto *link = find(peer); + if (!link || !link->handshaken) return; + const auto batch = pose_batch::read(bytes); + if (!batch || batch->world != static_cast(world_) || batch->map != static_cast(map_)) return; + bool complete = true, first = true; + for (const auto &entry : batch->entries) { + if (entry.whole) { + // Only themselves, and one stream at a time: a new one replaces the last. + if (entry.source != peer || entry.epoch != link->member.epoch) { + complete = false; + continue; + } + if (!link->upload_streams.contains(entry.stream)) link->upload_streams.clear(); + } + const auto rebuilt = pose_batch::rebuild(link->upload_streams, entry); + if (!rebuilt) { + complete = false; + continue; + } + if (rebuilt->repeat) continue; + Packet p; + p.kind = PacketKind::pose; + p.session = secret_; + p.map = map_; + p.world = world_; + p.source = peer; + p.epoch = rebuilt->epoch; + p.sequence = rebuilt->sequence; + p.time_us = rebuilt->time_us; + p.player_collision = rebuilt->collision; + p.pose = pose_codec::restore(rebuilt->pose); + // The message's bytes are counted once, with its first pose. + receive(peer, first ? bytes : bytes.first(0), received_at, &p); + first = false; + link = find(peer); // a pose can be what gets its player removed + if (!link) return; + } + if (complete) { + link->upload_ack.note(batch->number); + link->upload_ack_due = true; + } +} +// A player's own skater's sound (sound_codec.h), taken as the packet it would have been. +void Host::receive_sound(std::uint64_t peer, std::span bytes, std::uint64_t received_at) { + auto *link = find(peer); + if (!link || !link->handshaken) return; + if (link->sound_in.size() > 2) link->sound_in.clear(); // only their own, one stream at a time + const auto heard = sound_codec::read(link->sound_in, bytes, world_, map_); + if (!heard) return; + bool first = true; + for (const auto &one : *heard) { + if (one.source != peer || one.epoch != link->member.epoch || !valid_audio_batch(one.samples)) continue; + Packet p; + p.kind = PacketKind::audio; + p.session = secret_; + p.map = map_; + p.world = world_; + p.source = peer; + p.epoch = one.epoch; + p.sequence = one.sequence; + p.time_us = one.time_us; + p.audio = one.samples; + receive(peer, first ? bytes : bytes.first(0), received_at, &p); + first = false; + link = find(peer); + if (!link) return; + } +} +void Host::receive(std::uint64_t peer, std::span bytes, std::uint64_t received_at, const Packet *ready) { auto *link = find(peer); if (!link) return; - // Counted by arrival: after the server was held up, everyone's packets are read at once. - if (!link->budget.accept(received_at ? received_at : now_, bytes.size(), 1U)) - return drop(peer, "Peer exceeded the multiplayer packet limit."); + if (!ready) { + // Counted by arrival: after the server was held up, everyone's packets are read at once. + if (!link->budget.accept(received_at ? received_at : now_, bytes.size(), 1U)) + return drop(peer, "Peer exceeded the multiplayer packet limit."); + // Which messages of poses they read (pose_batch.h): not a packet, and says nothing else. + if (const auto ack = pose_batch::Ack::read(bytes)) { + if (link->handshaken) pose_ack(*link, *ack); + return; + } + if (pose_batch::is_batch(bytes)) return receive_poses(peer, bytes, received_at); + if (sound_codec::is_sound(bytes)) return receive_sound(peer, bytes, received_at); + } bool missing_reference{}; - const auto decoded = link->receiver.receive(bytes, missing_reference, world_); + const auto decoded = ready ? std::optional(*ready) : link->receiver.receive(bytes, missing_reference, world_); if (missing_reference) return; if (!decoded || decoded->session != secret_ || decoded->source != peer) return drop(peer, "Join code, sender identity, or multiplayer protocol did not match."); @@ -998,8 +1225,9 @@ std::string Host::network_report(std::string_view player, bool console) { const auto *guest = find(link.id); if (std::to_string(link.id) == player || (guest && lower(guest_name(*guest)).starts_with(wanted))) return row(link).substr(2) + (guest ? "\n" + by_kind(guest->traffic) : std::string{}) + - "\nroute: through Steam's relay " + (link.relay.empty() ? std::string("(unknown)") : link.relay) + - " on this side and " + (link.remote_relay.empty() ? std::string("(unknown)") : link.remote_relay) + " on theirs"; + (link.direct ? std::string("\nroute: direct, not through Steam's relays") + : "\nroute: through Steam's relay " + (link.relay.empty() ? std::string("(unknown)") : link.relay) + + " on this side and " + (link.remote_relay.empty() ? std::string("(unknown)") : link.remote_relay) + " on theirs"); } return "No connected player matches \"" + std::string(player) + "\"."; } @@ -1026,23 +1254,30 @@ std::string Host::network_report(std::string_view player, bool console) { // The relay locations in use, most connections first: everyone through one far away is a route problem. std::map relays; for (const auto &link : links) - if (link.measured) ++relays[(link.relay.empty() ? "?" : link.relay) + "-" + (link.remote_relay.empty() ? "?" : link.remote_relay)]; + if (link.measured) + ++relays[link.direct ? std::string("direct") + : (link.relay.empty() ? "?" : link.relay) + "-" + (link.remote_relay.empty() ? "?" : link.remote_relay)]; std::vector> order(relays.begin(), relays.end()); std::stable_sort(order.begin(), order.end(), [](const auto &a, const auto &b) { return a.second > b.second; }); + text += "\nSteam relay network: " + transport_.relay_status(); text += "\nrelays (ours-theirs):"; for (std::size_t i = 0; i < std::min(order.size(), 10); ++i) text += " " + order[i].first + " x" + std::to_string(order[i].second); } text += "\nloop: " + loop_report(); text += "\n" + by_kind(traffic_); - if (pose_sizes_.samples) { - const auto each = [&](std::uint64_t sum) { return std::to_string(sum / pose_sizes_.samples) + " B"; }; - text += "\npose size as sent:"; - for (std::size_t i = 0; i < pose_sizes_.sent.size(); ++i) - if (pose_sizes_.sent[i]) - text += std::string(" ") + pose_precision_names[i] + " " + std::to_string(pose_sizes_.sent_bytes[i] / pose_sizes_.sent[i]) + " B x" + - std::to_string(pose_sizes_.sent[i]); - text += "; as it arrives " + each(pose_sizes_.last) + " against the pose before, whole " + each(pose_sizes_.whole); + { + std::uint64_t poses{}; + for (const auto count : pose_sizes_.sent) poses += count; + if (poses) { + text += "\npose sizes:"; + for (std::size_t i = 0; i < pose_sizes_.sent.size(); ++i) + if (pose_sizes_.sent[i]) + text += std::string(" ") + pose_rate_names[i] + " " + std::to_string(pose_sizes_.sent_bytes[i] / pose_sizes_.sent[i]) + " B x" + + std::to_string(pose_sizes_.sent[i]); + text += "; " + std::to_string(pose_sizes_.held * 100 / poses) + "% without fingers; whole poses " + std::to_string(pose_sizes_.whole_sent) + + (pose_sizes_.whole_sent ? " at " + std::to_string(pose_sizes_.whole_sent_bytes / pose_sizes_.whole_sent) + " B" : std::string{}); + } } { // Who uploads most, and what most of it is: one player's outfit or objects can cost @@ -1181,6 +1416,7 @@ void Host::tick(std::uint64_t now) { if (link.connected && !guest->connected_at) guest->connected_at = now_; } for (const auto &message : transport_.receive()) receive(message.peer, message.bytes, message.arrived); + flush_poses(); receive_cosmetics(); introduce(); for (auto it = guests_.begin(); it != guests_.end();) { @@ -1199,6 +1435,19 @@ void Host::tick(std::uint64_t now) { drop(g.member.id, "Timed out waiting for gameplay data.", "nothing for " + std::to_string((now_ - g.last_packet) / 1000000) + " s; " + transport_.link_report(g.member.id)); } + for (auto note : transport_.take_direct_notes()) { + while (!note.empty() && (note.back() == '\n' || note.back() == '\r')) note.pop_back(); + log_("[direct] " + note); + } + // Steam's relay network, whenever what it says of itself changes: every connection goes + // through it, and joins time out while it is not ready. + if (now_ >= next_relay_check_) { + next_relay_check_ = now_ + 10000000; + if (auto status = transport_.relay_status(); status != relay_status_) { + log_("[steam] Relay network: " + status); + relay_status_ = std::move(status); + } + } // Every half minute, what was sent and received in it (net). if (now_ >= traffic_mark_ + 30000000) { const auto roll = [](Counted &c) { diff --git a/Server/server_host.h b/Server/server_host.h index c5b641e1..60d44523 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -4,12 +4,15 @@ #include "speed_check.h" #include "Engine/Game/Multiplayer/chat_rate.h" #include "Extension/Multiplayer/Net/delta_codec.h" +#include "Extension/Multiplayer/Net/pose_batch.h" +#include "Extension/Multiplayer/Net/sound_codec.h" #include "Extension/Multiplayer/Session/object_state.h" #include "Extension/Multiplayer/Session/party_book.h" #include "Extension/Multiplayer/Session/password.h" #include "Extension/Multiplayer/Session/room.h" #include "Extension/Multiplayer/Steam/steam_transport.h" #include "Engine/Game/World/world_layers.h" +#include #include #include #include @@ -39,6 +42,9 @@ class Host { std::string map_name() const; unsigned players() const; std::uint64_t secret() const { return secret_; } + // The UDP port players may connect straight to, or 0 (config connection). + std::uint16_t direct_port() const { return direct_port_; } + const std::string &direct_address() const { return config_.direct_address; } enum class VoteKind { map, kick, time }; @@ -85,6 +91,42 @@ class Host { // and a second (measure_pose). struct Earlier { std::vector raw; std::uint64_t time{}; }; Earlier pose_last, pose_quarter, pose_second; + // Their own last poses, rounded (pose_codec.h): what the others' differences are built + // on. `keep` is how long one stays, by the slowest rate anyone was sent it at: a + // player sent this one once a second must still find it here when their ack arrives. + struct KeptPose { + std::uint32_t sequence{}; + std::uint64_t time_us{}, kept_at{}; + std::uint8_t keep{}; + pose_codec::QuantPose pose; + }; + std::deque kept_poses; + // What this player is being sent of each other one, and what of it they acked. + pose_batch::Sender pose_sender; + // Their own poses arrive the same way (receive_poses): the last ones rebuilt, and which + // of their messages were read in full, which they are told once a pass. + std::unordered_map upload_streams; + pose_batch::Ack upload_ack; + bool upload_ack_due{}; + // Poses due to them this pass (flush_poses). + struct QueuedPose { + std::uint64_t source{}; + std::uint32_t sequence{}; + pose_batch::Rate rate{}; + bool collision{}, hold_fingers{}; + std::uint8_t tier{}; // 0 full rate, 1 half, 2 low, 3 out of sight + }; + std::vector queued_poses; + // Skaters' sound (sound_codec.h): what this player holds of each other one's, the + // samples due to them this pass, and what is held here of their own. + sound_codec::Sender sound_sender; + struct QueuedSound { + std::uint64_t source{}, time_us{}; + std::uint32_t sequence{}; + std::shared_ptr> samples; + }; + std::vector queued_sound; + std::unordered_map sound_in; Transform still_at; std::uint64_t moved_at{}; std::uint64_t pose_arrival{}; @@ -163,6 +205,9 @@ class Host { bool roster_dirty_ = true, running_{}; std::uint64_t bans_revision_ = 1; // bumped whenever the ban list or the admins change std::uint64_t now_{}, last_roster_{}, last_world_state_{}, next_object_update_{}, travel_started_{}; + std::string relay_status_; // Steam's relay network as last logged + std::uint16_t direct_port_{}; + std::uint64_t next_relay_check_{}; std::uint64_t next_crowd_{}; // when the crowd limits are worked out again std::uint64_t next_network_log_{}; // the log's once-a-minute line about the connections // How the server's own loop is keeping up, for `net` and that line: passes of tick() in a @@ -178,9 +223,18 @@ class Host { // smaller: this says by how much, before the server is made to send them that way. struct PoseSizes { std::uint64_t samples{}, whole{}, last{}, quarter{}, second{}; - std::array sent{}, sent_bytes{}; // differences sent, by precision (pose_precision) + std::array sent{}, sent_bytes{}; // differences sent, by rate: full, half, low, out of sight + std::uint64_t whole_sent{}, whole_sent_bytes{}, held{}; // whole poses sent; differences sent without fingers + // Of the poses measured, what had changed since the pose before, by the kind of field: + // how many, and the bytes they take before packing. Says what a pose's size is made of. + struct Changed { std::uint64_t fields{}, bytes{}; }; + std::array changed{}; // positions as floats, positions in mm, rotations, scales + std::uint64_t bones{}; } pose_sizes_; void measure_pose(Guest &from, const Packet &packet); + Guest::KeptPose *keep_pose(Guest &from, const Packet &packet); + void flush_poses(); + void pose_ack(Guest &guest, const pose_batch::Ack &ack); std::uint64_t traffic_mark_{}, traffic_window_us_{}; // when `mark` was taken, and how long `last` covers void meet_later(Guest &guest); void introduce(); @@ -212,7 +266,9 @@ class Host { bool same_map(std::string_view asset) const { return map_hash(map_destination(asset)) == map_; } bool accept_data(Guest &source, const Packet &); // `received_at`: the transport's arrival time for the message (TransportMessage::arrived). - void receive(std::uint64_t peer, std::span bytes, std::uint64_t received_at); + void receive(std::uint64_t peer, std::span bytes, std::uint64_t received_at, const Packet *ready = nullptr); + void receive_poses(std::uint64_t peer, std::span bytes, std::uint64_t received_at); + void receive_sound(std::uint64_t peer, std::span bytes, std::uint64_t received_at); void receive_cosmetics(); void sync_objects(); void apply_layers(); diff --git a/Server/steam_server.cpp b/Server/steam_server.cpp index 532b231b..f6eb162f 100644 --- a/Server/steam_server.cpp +++ b/Server/steam_server.cpp @@ -127,6 +127,8 @@ std::string server_tags(const Advertisement &a) { for (int shift = 60; shift >= 0; shift -= 4) secret += hex[(a.secret >> shift) & 15]; auto tags = "reskate,v" + std::to_string(multiplayer::protocol_version) + ",k" + secret + ",p" + std::to_string(a.players) + ",c" + std::to_string(a.max_players) + (a.password ? ",w1" : ",w0"); + // "d27015", or "d203.0.113.7:27015" for a server reached at another address than it is listed under. + if (a.direct_port) tags += ",d" + (a.direct_address.empty() ? std::string{} : a.direct_address + ":") + std::to_string(a.direct_port); tags += ",m" + tag_text(a.map, 24); tags += ",n" + tag_text(a.name, 127 - tags.size() - 2); return tags; diff --git a/Server/steam_server.h b/Server/steam_server.h index db20889f..2b1ebfff 100644 --- a/Server/steam_server.h +++ b/Server/steam_server.h @@ -12,6 +12,8 @@ struct Advertisement { unsigned players{}, max_players{}; bool password{}, listed{true}; std::uint64_t secret{}; + std::uint16_t direct_port{}; // players may connect straight to this UDP port; 0: relays only + std::string direct_address; // at this address, when it is not the one Steam lists the server under }; std::string server_tags(const Advertisement &); From 7a34ead2d59ba3581a6559140473b3d945d22d51 Mon Sep 17 00:00:00 2001 From: NorthDroplet Date: Thu, 8 Oct 2026 07:21:05 +0000 Subject: [PATCH 099/109] Launcher: steadier trackpad pointer and list navigation - The trackpad pointer follows the whole swipe; Steam stops the tilt when the thumb lifts, so nothing has to be cut off. - Up and down stay inside the mod lists and leave a row from any of its parts (tick, switch, buttons) in one press. - Left from anywhere in a list reaches the nearest rail tile, BACK included. - The focus no longer falls back to a tab when ImGui lets go of a row the list only partly shows. --- Launcher/Test/gamepad_input_tests.cpp | 144 ++++++++++++++++++++++++-- Launcher/gamepad_input.cpp | 143 +++++++++++++++++-------- Launcher/gamepad_input.h | 10 +- Launcher/gui_internal.h | 4 +- Launcher/gui_mods.cpp | 1 + Launcher/gui_mods_browse.cpp | 1 + 6 files changed, 246 insertions(+), 57 deletions(-) diff --git a/Launcher/Test/gamepad_input_tests.cpp b/Launcher/Test/gamepad_input_tests.cpp index 37df5304..2ce0c55e 100644 --- a/Launcher/Test/gamepad_input_tests.cpp +++ b/Launcher/Test/gamepad_input_tests.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -115,13 +116,17 @@ std::string mods_page(PadFeed& feed, const PadState& state) { ImGui::SetNextWindowSize(ImVec2(900, 700)); ImGui::Begin("##page", nullptr, ImGuiWindowFlags_NoDecoration); ImGui::SetCursorPos(ImVec2(20, 100)); - ImGui::BeginChild("##rail", ImVec2(200, 400), ImGuiChildFlags_NavFlattened); + ImGui::BeginChild("##rail", ImVec2(200, 520), ImGuiChildFlags_NavFlattened); if (ImGui::Button("MY MODS", ImVec2(150, 40))) pressed = "MY MODS"; dingosdk::launcher_gui::default_focus(); if (ImGui::Button("GET MODS", ImVec2(150, 40))) pressed = "GET MODS"; + // BACK at the rail's foot, under the list's last row: the list leaves room + // for a line of text below it. + ImGui::SetCursorPos(ImVec2(0, 480)); + if (ImGui::Button("BACK", ImVec2(150, 40))) pressed = "BACK"; ImGui::EndChild(); ImGui::SetCursorPos(ImVec2(260, 100)); - ImGui::BeginChild("##content", ImVec2(600, 500), ImGuiChildFlags_NavFlattened); + ImGui::BeginChild("##content", ImVec2(600, 460), ImGuiChildFlags_NavFlattened); for (int row = 0; row < 12; ++row) { ImGui::PushID(row); dingosdk::launcher_gui::begin_row(); @@ -136,6 +141,7 @@ std::string mods_page(PadFeed& feed, const PadState& state) { ImGui::SetCursorScreenPos(next); ImGui::PopID(); } + dingosdk::launcher_gui::keep_focus_in_list(); ImGui::EndChild(); ImGui::End(); ImGui::Render(); @@ -151,6 +157,85 @@ std::string mods_tap(PadFeed& feed, std::uint16_t button) { return pressed; } +// MY MODS as the launcher draws it at the Steam Deck's scale: the rail with +// BACK at its foot, a tick above the list for every mod, and rows that carry +// a tick on the left and a switch and a menu on the right. Returns the name of +// the item holding the focus. +std::string my_mods(PadFeed& feed, const PadState& state) { + constexpr float k = 0.85f; + static std::map names; + const auto name = [](const std::string& text) { names[ImGui::GetItemID()] = text; }; + auto& io = ImGui::GetIO(); + io.DisplaySize = ImVec2(1227, 716); + io.DeltaTime = 1.0f / 60.0f; + feed.update(io, state, k); + ImGui::NewFrame(); + feed.after_new_frame(); + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(io.DisplaySize); + ImGui::Begin("##page", nullptr, ImGuiWindowFlags_NoDecoration); + ImGui::SetCursorPos(ImVec2(28 * k, 156 * k)); + ImGui::BeginChild("##rail", ImVec2(236 * k, 716 - 180 * k), ImGuiChildFlags_NavFlattened); + ImGui::Button("MY MODS", ImVec2(-1, 56 * k)); + name("MY MODS"); + dingosdk::launcher_gui::default_focus(); + ImGui::Button("GET MODS", ImVec2(-1, 56 * k)); + name("GET MODS"); + for (const char* label : {"Install .zip", "Install folder", "Open Mods folder", "Open Thunderstore"}) { + ImGui::Button(label, ImVec2(-1, 32 * k)); + name(label); + } + ImGui::SetCursorPosY(ImGui::GetWindowHeight() - 38 * k); + ImGui::Button("BACK", ImVec2(-1, 34 * k)); + name("BACK"); + ImGui::EndChild(); + ImGui::SetCursorPos(ImVec2(290 * k, 52 * k)); + ImGui::BeginChild("##content", ImVec2(1227 - 318 * k, 716 - 76 * k), ImGuiChildFlags_NavFlattened); + static bool all = false; + ImGui::Checkbox("##all", &all); + name("tick all"); + ImGui::Spacing(); + const float body = ImGui::GetContentRegionAvail().y - ImGui::GetTextLineHeightWithSpacing(); + ImGui::BeginChild("##mod_list", ImVec2(0, body), ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened); + const float tall = 68 * k; + for (int row = 0; row < 20; ++row) { + ImGui::PushID(row); + const ImVec2 start = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x; + dingosdk::launcher_gui::begin_row(); + ImGui::Selectable("##row", false, ImGuiSelectableFlags_AllowOverlap, ImVec2(width, tall)); + name("row " + std::to_string(row)); + const ImVec2 next = ImGui::GetCursorScreenPos(); + dingosdk::launcher_gui::row_buttons(); + ImGui::SetCursorScreenPos(ImVec2(start.x + 14 * k, start.y + (tall - ImGui::GetFrameHeight()) * 0.5f)); + static bool ticks[20]; + ImGui::Checkbox("##pick", &ticks[row]); + name("tick " + std::to_string(row)); + ImGui::SetCursorScreenPos(ImVec2(start.x + width - 96 * k, start.y + 23 * k)); + ImGui::Button("##switch", ImVec2(42 * k, 22 * k)); + name("switch " + std::to_string(row)); + dingosdk::launcher_gui::end_row(); + ImGui::SetCursorScreenPos(next); + ImGui::PopID(); + } + dingosdk::launcher_gui::keep_focus_in_list(); + ImGui::EndChild(); + ImGui::TextDisabled("Mods load top to bottom"); + ImGui::EndChild(); + ImGui::End(); + ImGui::Render(); + const auto found = names.find(GImGui->NavId); + return found == names.end() ? std::string() : found->second; +} + +// One D-pad press, held for a few frames as a thumb does, then let go. +std::string my_mods_tap(PadFeed& feed, std::uint16_t button) { + for (int i = 0; i < 6; ++i) my_mods(feed, pad(button)); + std::string at; + for (int i = 0; i < 6; ++i) at = my_mods(feed, pad()); + return at; +} + void fresh() { if (ImGui::GetCurrentContext()) ImGui::DestroyContext(); ImGui::CreateContext(); @@ -208,6 +293,41 @@ int main() { check(mods_tap(feed, dpad_up) == "row " + std::to_string(first), "Up goes to the row above in one press"); check(mods_tap(feed, dpad_right) == "install " + std::to_string(first), "Right goes to the row's own button"); check(mods_tap(feed, dpad_up) == "row " + std::to_string(first - 1), "Up from a row's button goes to the row above"); + for (int i = 0; i < 14; ++i) mods_tap(feed, dpad_down); + check(mods_tap(feed, dpad_down) == "row 11", "Down at the end of the list stays on its last row"); + check(mods_tap(feed, dpad_left) == "BACK", "Left from the end of the list reaches BACK, far below the tabs"); + mods_tap(feed, dpad_right); + for (int i = 0; i < 14; ++i) mods_tap(feed, dpad_up); + check(mods_tap(feed, dpad_up) == "row 0", "Up at the top of the list stays on its first row"); + const auto tab = mods_tap(feed, dpad_left); + check(tab == "MY MODS" || tab == "GET MODS", "Left still leaves the list"); + mods_tap(feed, dpad_down); + check(mods_tap(feed, dpad_down) == "BACK", "Down the tabs reaches BACK"); + } + { + fresh(); + ImGui::GetStyle().ScaleAllSizes(0.85f); + PadFeed feed; + for (int i = 0; i < 3; ++i) my_mods(feed, pad()); + std::string at = my_mods_tap(feed, dpad_right); + for (int i = 0; i < 20 && at != "row 7"; ++i) at = my_mods_tap(feed, dpad_down); + check(at == "row 7", "Right and down walk into the rows"); + check(my_mods_tap(feed, dpad_left) == "tick 7", "Left goes to the row's tick"); + at = my_mods_tap(feed, dpad_left); + check(at != "tick all" && at.rfind("row", 0) != 0 && !at.empty(), "Left from a tick halfway down reaches the rail, not the tick above the list"); + my_mods_tap(feed, dpad_right); + for (int i = 0; i < 3; ++i) my_mods_tap(feed, dpad_down); + check(my_mods_tap(feed, dpad_left).rfind("tick ", 0) == 0, "Back on a row's tick"); + const std::string from = my_mods_tap(feed, dpad_up); + check(from.rfind("row ", 0) == 0, "Up from a tick goes to the row above"); + const int row = std::stoi(from.substr(4)); + check(my_mods_tap(feed, dpad_up) == "row " + std::to_string(row - 1), "The next up goes to the next row, not to that row's tick"); + check(my_mods_tap(feed, dpad_right) == "switch " + std::to_string(row - 1), "Right goes to the row's switch"); + check(my_mods_tap(feed, dpad_down) == "row " + std::to_string(row), "Down from the switch goes to the row below"); + for (int i = 0; i < 25; ++i) at = my_mods_tap(feed, dpad_down); + check(at == "row 19", "Down stops on the last row"); + my_mods_tap(feed, dpad_left); + check(my_mods_tap(feed, dpad_left) == "BACK", "Left from the last row's tick reaches BACK"); } { fresh(); @@ -324,19 +444,21 @@ int main() { check(!stick.pointing() && ImGui::GetIO().MousePos.x == 400, "The same small tilt of a thumbstick does not"); fresh(); - PadFeed glide(true); + PadFeed slowing(true); ImGui::GetIO().AddMousePosEvent(100, 300); - frame(glide, pad()); - for (int i = 0; i < 5; ++i) frame(glide, pad(0, 20000, 0)); // the swipe + frame(slowing, pad()); + for (int i = 0; i < 5; ++i) frame(slowing, pad(0, 20000, 0)); // the swipe + const float swiped = ImGui::GetIO().MousePos.x; float tilt = 20000; - float stopped = 0; - for (int i = 0; i < 30; ++i) { // the thumb lifted: the tilt only fades + for (int i = 0; i < 12; ++i) { // the thumb slows down before it lifts tilt *= 0.9f; - frame(glide, pad(0, static_cast(tilt), 0)); - if (i == 6) stopped = ImGui::GetIO().MousePos.x; + frame(slowing, pad(0, static_cast(tilt), 0)); } - frame(glide, pad()); - check(stopped > 100 && ImGui::GetIO().MousePos.x == stopped, "The trackpad's glide after the thumb lifts does not carry the pointer on"); + const float slowed = ImGui::GetIO().MousePos.x; + frame(slowing, pad(0, -900, -1200)); // lifted: back to Steam's resting tilt + frame(slowing, pad(0, -900, -1200)); + check(slowed > swiped + 60, "A swipe that slows down moves the pointer to its end"); + check(ImGui::GetIO().MousePos.x == slowed, "The pointer stops when the thumb lifts"); } // ------------------------------------------------ trackpad pointer diff --git a/Launcher/gamepad_input.cpp b/Launcher/gamepad_input.cpp index f6df1d11..3ee41692 100644 --- a/Launcher/gamepad_input.cpp +++ b/Launcher/gamepad_input.cpp @@ -32,17 +32,15 @@ struct PointerFeel { float dead_zone; float speed; // design pixels a second at full tilt bool squared; // a small tilt is slow, for placing the pointer precisely - bool cut_glide; // stop once the tilt only fades }; // A thumbstick sets a speed. A resting thumb is never quite centred, so the // dead zone is large. -constexpr PointerFeel stick_feel{0.2f, 1400.0f, true, false}; +constexpr PointerFeel stick_feel{0.2f, 1400.0f, true}; // The Steam Deck's trackpad, in Steam's gamepad layouts, tilts the stick by how -// fast the thumb swipes and keeps gliding after it lifts, fading out. Small -// swipes must move the pointer, and the glide must not carry it on. -constexpr PointerFeel trackpad_feel{0.06f, 2600.0f, false, true}; -// Frames of a steadily fading tilt that make it a glide rather than the thumb. -constexpr int glide_frames = 4; +// fast the thumb swipes, and a quick swipe already tilts it all the way. The +// tilt drops back to rest the moment the thumb lifts. Small swipes must move +// the pointer and a quick one must cross the window. +constexpr PointerFeel trackpad_feel{0.06f, 2600.0f, false}; // A mouse move this close to the last one is the same place reported again. constexpr float pointer_slack = 2.0f; @@ -109,13 +107,7 @@ void PadFeed::update(ImGuiIO& io, const PadState& input, float scale) { const PointerFeel& feel = trackpad_ ? trackpad_feel : stick_feel; const float x = axis(pad.right_x), y = -axis(pad.right_y); // screen Y grows downward const float tilt = std::min(1.0f, std::sqrt(x * x + y * y)); - if (feel.cut_glide) { - // A glide only ever fades; the thumb speeds up again or stops. - if (tilt <= feel.dead_zone || tilt > last_tilt_ + 0.01f) fading_ = 0; - else if (tilt < last_tilt_) ++fading_; - last_tilt_ = tilt; - } - const bool steering = tilt > feel.dead_zone && fading_ < glide_frames; + const bool steering = tilt > feel.dead_zone; // The first R3 only shows the pointer: a stray click must not press // whatever the mouse last rested on. const bool click = pointing_ && (pressed & right_thumb); @@ -147,35 +139,84 @@ void PadFeed::update(ImGuiIO& io, const PadState& input, float scale) { io.MouseDrawCursor = pointing_; } -void PadFeed::after_new_frame() { - ImGuiContext& g = *GImGui; - // ImGui looks to the side only along a thin band through the middle of the - // focus, so it misses an item level with the gap between two list rows: the - // rail's tiles beside a list. A side move that found nothing looks again - // across the focus's full height. - if (side_dir_ != ImGuiDir_None && g.NavId == side_from_ && g.NavWindow && !g.NavMoveSubmitted) { - const ImGuiDir dir = side_dir_; - side_dir_ = ImGuiDir_None; - ImGui::NavMoveRequestSubmit(dir, dir, ImGuiNavMoveFlags_None, - ImGuiScrollFlags_KeepVisibleEdgeX | ImGuiScrollFlags_KeepVisibleEdgeY); - g.NavScoringRect = ImGui::WindowRectRelToAbs(g.NavWindow, g.NavWindow->NavRectRel[g.NavLayer]); - last_nav_id_ = g.NavId; - return; - } - side_dir_ = ImGuiDir_None; - if (g.NavMoveSubmitted && (g.NavMoveDir == ImGuiDir_Left || g.NavMoveDir == ImGuiDir_Right)) { - side_dir_ = g.NavMoveDir; - // ImGui clears the focus for the move when the item is wider than its - // panel; the move then lands on that same item, which is no move either. - side_from_ = g.NavId ? g.NavId : last_nav_id_; - } - last_nav_id_ = g.NavId; +namespace { + +// The list row holding the focus, as it was last drawn. +struct FocusedRow { + ImGuiID scope{}, window{}; + ImRect rect; // relative to its window +}; +FocusedRow g_focused_row; +bool g_row_focused{}; +ImGuiID g_row_scope{}; + +// The outermost of the flattened panels around a window: a page's rail, or +// its content with the list inside it. +ImGuiWindow* column_of(ImGuiWindow* window) { + if (!(window->ChildFlags & ImGuiChildFlags_NavFlattened)) return nullptr; + while (window->ParentWindow && (window->ParentWindow->ChildFlags & ImGuiChildFlags_NavFlattened)) + window = window->ParentWindow; + return window; } -namespace { -bool g_row_focused{}; } // namespace +void PadFeed::after_new_frame() { + ImGuiContext& g = *GImGui; + const ImGuiID nav_window = g.NavWindow ? g.NavWindow->ID : 0; + // ImGui lets go of an item its panel only partly shows, to move from the + // part in view, and falls back to the page's default item, a rail tab, + // when that move finds nothing. The focus stays where it was instead. + if (g.NavMoveSubmitted && !g.NavId && last_nav_id_ && nav_window == last_nav_window_) { + g.NavId = last_nav_id_; + g.NavInitRequest = g.NavInitRequestFromMove = false; + } + + // Up and down from a list row leave the row, from whichever of its parts + // has the focus: ImGui looks along the line the focus last went sideways + // on, and finds the row's own tick or switch just above or below it. + if (g.NavMoveSubmitted && (g.NavMoveDir == ImGuiDir_Up || g.NavMoveDir == ImGuiDir_Down) && + g_focused_row.scope && g.NavFocusScopeId == g_focused_row.scope) + if (ImGuiWindow* window = ImGui::FindWindowByID(g_focused_row.window)) { + const ImRect row = ImGui::WindowRectRelToAbs(window, g_focused_row.rect); + g.NavScoringRect.Min.y = g.NavScoringRect.Max.y = g.NavMoveDir == ImGuiDir_Up ? row.Min.y : row.Max.y; + } + + // ImGui looks to the side only along one line through the focus, so from a + // list row it misses the rail's tiles above or below it. A side move that + // found nothing looks again: across the focus's full height, for a tile + // level with the gap between two rows; then at every height from the edge + // of the focus's column, nearest first, for BACK far under a row. + if (side_dir_ != ImGuiDir_None && side_tries_ < 2 && g.NavId == side_from_ && g.NavWindow && !g.NavMoveSubmitted) { + ++side_tries_; + ImGui::NavMoveRequestSubmit(side_dir_, side_dir_, ImGuiNavMoveFlags_None, + ImGuiScrollFlags_KeepVisibleEdgeX | ImGuiScrollFlags_KeepVisibleEdgeY); + ImRect from = ImGui::WindowRectRelToAbs(g.NavWindow, g.NavWindow->NavRectRel[g.NavLayer]); + if (side_tries_ == 2) { + if (const ImGuiWindow* column = column_of(g.NavWindow)) { + // From the column's edge, so nothing else in it, like the tick + // above the list, comes before the next column. + from.Min.x = from.Max.x = side_dir_ == ImGuiDir_Left ? column->Rect().Min.x : column->Rect().Max.x; + } + // ImGui measures only the middle 60% of the height, which must + // still cover the whole window. + const float middle = from.GetCenter().y, reach = g.IO.DisplaySize.y * 2; + from.Min.y = middle - reach; + from.Max.y = middle + reach; + } + g.NavScoringRect = from; + } else { + side_dir_ = ImGuiDir_None; + side_tries_ = 0; + if (g.NavMoveSubmitted && (g.NavMoveDir == ImGuiDir_Left || g.NavMoveDir == ImGuiDir_Right)) { + side_dir_ = g.NavMoveDir; + side_from_ = g.NavId; + } + } + last_nav_id_ = g.NavId; + last_nav_window_ = nav_window; +} + void default_focus() { ImGui::SetItemDefaultFocus(); ImGuiContext& g = *GImGui; @@ -192,12 +233,16 @@ void default_focus() { } void begin_row() { - const ImGuiID scope = ImGui::GetID("##row_focus"); - g_row_focused = GImGui->NavFocusScopeId == scope; - ImGui::PushFocusScope(scope); + g_row_scope = ImGui::GetID("##row_focus"); + g_row_focused = GImGui->NavFocusScopeId == g_row_scope; + ImGui::PushFocusScope(g_row_scope); } void row_buttons() { + if (g_row_focused) { + ImGuiWindow* window = ImGui::GetCurrentWindow(); + g_focused_row = {g_row_scope, window->ID, ImGui::WindowRectAbsToRel(window, GImGui->LastItemData.NavRect)}; + } ImGui::PushItemFlag(ImGuiItemFlags_NoNav, !g_row_focused); } @@ -206,6 +251,20 @@ void end_row() { ImGui::PopFocusScope(); } +void keep_focus_in_list() { + ImGuiContext& g = *GImGui; + if (!g.NavMoveScoringItems || (g.NavMoveDir != ImGuiDir_Up && g.NavMoveDir != ImGuiDir_Down)) return; + // By the columns the list covers: its rows run on past what it shows. + const ImRect list = g.CurrentWindow->Rect(); + const auto in_list = [&](float x) { return x >= list.Min.x && x <= list.Max.x; }; + if (!list.Contains(g.NavScoringRect.GetCenter())) return; + // Everything around the list has had its chance by now: the rail comes + // before the content, and nothing under the list takes the focus. + const ImGuiNavItemData& best = g.NavMoveResultLocal.ID ? g.NavMoveResultLocal : g.NavMoveResultOther; + if (best.ID && in_list(ImGui::WindowRectRelToAbs(best.Window, best.RectRel).GetCenter().x)) return; + ImGui::NavMoveRequestCancel(); +} + void PadFeed::mouse_moved(ImVec2 position) { // Windows repeats a mouse move where the mouse already was when a window // changes under it; only a move to a new place is the mouse or a finger. diff --git a/Launcher/gamepad_input.h b/Launcher/gamepad_input.h index 5bffc92a..1d66b055 100644 --- a/Launcher/gamepad_input.h +++ b/Launcher/gamepad_input.h @@ -47,11 +47,10 @@ public: private: bool trackpad_{}; std::uint16_t buttons_{}; - float last_tilt_{}; - int fading_{}; // frames the right stick's tilt only faded ImGuiDir side_dir_{ImGuiDir_None}; // a D-pad left or right ImGui looked for last frame ImGuiID side_from_{}; - ImGuiID last_nav_id_{}; + int side_tries_{}; // how many times it looked again + ImGuiID last_nav_id_{}, last_nav_window_{}; ImGuiKey back_key_{ImGuiKey_Escape}; bool pointing_{}; bool clicking_{}; // R3 holds the left button @@ -72,4 +71,9 @@ void begin_row(); void row_buttons(); void end_row(); +// Call just before a list's EndChild(): up and down from a row stay in the +// list, so the end of the list holds the focus instead of passing it to +// whatever lies beyond, like the rail's BACK. +void keep_focus_in_list(); + } // namespace dingosdk::launcher_gui diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index efb2f721..7baace0b 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -139,7 +139,9 @@ void virtual_rows(int count, Height height, Row row) { float y = start.y; for (int index = 0; index < count; ++index) { const float tall = height(index); - if (y + tall >= scroll && y <= scroll + view) { + // One row past each edge too: a controller moving off the last row + // shown needs the next one there to move to. + if (y + 2 * tall >= scroll && y <= scroll + view + tall) { ImGui::SetCursorPos(ImVec2(start.x, y)); row(index, tall); } diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index 7b4f40bf..79d92a38 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -595,6 +595,7 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co installed_row(fonts, panel, installed, view, position, tall, reorder, request); }); ImGui::EndDisabled(); + keep_focus_in_list(); ImGui::EndChild(); ImGui::TextDisabled("Mods load top to bottom: where two change the same thing, the higher one wins. " "Changes apply the next time Skate starts."); diff --git a/Launcher/gui_mods_browse.cpp b/Launcher/gui_mods_browse.cpp index 544db04b..850ad9a5 100644 --- a/Launcher/gui_mods_browse.cpp +++ b/Launcher/gui_mods_browse.cpp @@ -578,6 +578,7 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float ImGui::PopID(); }); ImGui::EndDisabled(); + keep_focus_in_list(); ImGui::EndChild(); } From 93ff021d7e59126b4cbc378206b18edbbb17df28 Mon Sep 17 00:00:00 2001 From: NorthDroplet Date: Thu, 8 Oct 2026 08:04:48 +0000 Subject: [PATCH 100/109] Launcher: the trackpad pointer measures from where the stick rests, so it does not creep --- Launcher/Test/gamepad_input_tests.cpp | 24 ++++++++++++++++++++---- Launcher/gamepad_input.cpp | 25 +++++++++++++++++++++++-- Launcher/gamepad_input.h | 4 ++++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/Launcher/Test/gamepad_input_tests.cpp b/Launcher/Test/gamepad_input_tests.cpp index 2ce0c55e..654980da 100644 --- a/Launcher/Test/gamepad_input_tests.cpp +++ b/Launcher/Test/gamepad_input_tests.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -432,13 +433,13 @@ int main() { PadFeed stick; PadFeed trackpad(true); ImGui::GetIO().AddMousePosEvent(400, 300); - frame(trackpad, pad()); + for (int i = 0; i < 20; ++i) frame(trackpad, pad()); // it settles first for (int i = 0; i < 10; ++i) frame(trackpad, pad(0, 3300, 0)); // a slow swipe frame(trackpad, pad()); check(ImGui::GetIO().MousePos.x > 405, "A slow swipe on the trackpad moves the pointer"); fresh(); ImGui::GetIO().AddMousePosEvent(400, 300); - frame(stick, pad()); + for (int i = 0; i < 20; ++i) frame(stick, pad()); for (int i = 0; i < 10; ++i) frame(stick, pad(0, 3300, 0)); frame(stick, pad()); check(!stick.pointing() && ImGui::GetIO().MousePos.x == 400, "The same small tilt of a thumbstick does not"); @@ -446,7 +447,7 @@ int main() { fresh(); PadFeed slowing(true); ImGui::GetIO().AddMousePosEvent(100, 300); - frame(slowing, pad()); + for (int i = 0; i < 20; ++i) frame(slowing, pad()); for (int i = 0; i < 5; ++i) frame(slowing, pad(0, 20000, 0)); // the swipe const float swiped = ImGui::GetIO().MousePos.x; float tilt = 20000; @@ -459,6 +460,21 @@ int main() { frame(slowing, pad(0, -900, -1200)); check(slowed > swiped + 60, "A swipe that slows down moves the pointer to its end"); check(ImGui::GetIO().MousePos.x == slowed, "The pointer stops when the thumb lifts"); + + fresh(); + PadFeed drifting(true); + ImGui::GetIO().AddMousePosEvent(400, 300); + for (int i = 0; i < 60; ++i) frame(drifting, pad(0, -2600, -2600)); // resting off the middle + check(!drifting.pointing() && ImGui::GetIO().MousePos.x == 400 && ImGui::GetIO().MousePos.y == 300, + "A trackpad resting off the middle does not move the pointer"); + for (int i = 0; i < 10; ++i) frame(drifting, pad(0, 9000, -2600)); + check(drifting.pointing() && ImGui::GetIO().MousePos.x > 420 && std::abs(ImGui::GetIO().MousePos.y - 300) < 1, + "A swipe moves it from where the trackpad rests"); + for (int i = 0; i < 60; ++i) frame(drifting, pad(0, -1000, 1500)); // lifted: resting somewhere new + const ImVec2 rested = ImGui::GetIO().MousePos; + for (int i = 0; i < 60; ++i) frame(drifting, pad(0, -1000, 1500)); + check(ImGui::GetIO().MousePos.x == rested.x && ImGui::GetIO().MousePos.y == rested.y, + "After a touch it rests again without the pointer creeping"); } // ------------------------------------------------ trackpad pointer @@ -468,7 +484,7 @@ int main() { auto& io = ImGui::GetIO(); io.AddMousePosEvent(400, 300); feed.mouse_moved(ImVec2(400, 300)); - frame(feed, pad()); + for (int i = 0; i < 20; ++i) frame(feed, pad()); frame(feed, pad(0, 3000, 0)); check(!feed.pointing() && io.MousePos.x == 400, "A thumb resting near the middle does not move the pointer"); diff --git a/Launcher/gamepad_input.cpp b/Launcher/gamepad_input.cpp index 3ee41692..7afd2399 100644 --- a/Launcher/gamepad_input.cpp +++ b/Launcher/gamepad_input.cpp @@ -41,6 +41,12 @@ constexpr PointerFeel stick_feel{0.2f, 1400.0f, true}; // tilt drops back to rest the moment the thumb lifts. Small swipes must move // the pointer and a quick one must cross the window. constexpr PointerFeel trackpad_feel{0.06f, 2600.0f, false}; +// The right stick never rests quite at the middle, and Steam's trackpad rests +// a little off it too, somewhere new after each touch. A reading that holds +// this still, this near the middle, for this long is where it rests. +constexpr float rest_jitter = 0.01f; +constexpr float rest_limit = 0.12f; +constexpr float rest_seconds = 0.3f; // A mouse move this close to the last one is the same place reported again. constexpr float pointer_slack = 2.0f; @@ -105,9 +111,24 @@ void PadFeed::update(ImGuiIO& io, const PadState& input, float scale) { if (pressed & focus_bits) pointing_ = false; const PointerFeel& feel = trackpad_ ? trackpad_feel : stick_feel; - const float x = axis(pad.right_x), y = -axis(pad.right_y); // screen Y grows downward + const float raw_x = axis(pad.right_x), raw_y = -axis(pad.right_y); // screen Y grows downward + if (std::abs(raw_x - last_x_) < rest_jitter && std::abs(raw_y - last_y_) < rest_jitter && + std::sqrt(raw_x * raw_x + raw_y * raw_y) < rest_limit) { + steady_ += io.DeltaTime; + if (steady_ >= rest_seconds) { + rest_ = ImVec2(raw_x, raw_y); + rest_known_ = true; + } + } else { + steady_ = 0; + } + last_x_ = raw_x; + last_y_ = raw_y; + // Measured from where it rests, and not at all until that is known: a + // pointer creeping off on its own would take the screen from the focus. + const float x = raw_x - rest_.x, y = raw_y - rest_.y; const float tilt = std::min(1.0f, std::sqrt(x * x + y * y)); - const bool steering = tilt > feel.dead_zone; + const bool steering = rest_known_ && tilt > feel.dead_zone; // The first R3 only shows the pointer: a stray click must not press // whatever the mouse last rested on. const bool click = pointing_ && (pressed & right_thumb); diff --git a/Launcher/gamepad_input.h b/Launcher/gamepad_input.h index 1d66b055..b684aa56 100644 --- a/Launcher/gamepad_input.h +++ b/Launcher/gamepad_input.h @@ -54,6 +54,10 @@ private: ImGuiKey back_key_{ImGuiKey_Escape}; bool pointing_{}; bool clicking_{}; // R3 holds the left button + ImVec2 rest_{}; // where the right stick rests + bool rest_known_{}; + float steady_{}; // seconds the right stick held still + float last_x_{}, last_y_{}; ImVec2 pointer_{}; ImVec2 mouse_{-1, -1}; // where the last mouse move was }; From f66c1e74dbeefeecde1dea0c5423b3e6ae0e927c Mon Sep 17 00:00:00 2001 From: NorthDroplet Date: Thu, 8 Oct 2026 08:12:37 +0000 Subject: [PATCH 101/109] Launcher: the focus goes back to the mod after an install instead of the window buttons --- Launcher/Test/gamepad_input_tests.cpp | 28 +++++++++++++++++++++++++ Launcher/gamepad_input.cpp | 30 ++++++++++++++++++++++++--- Launcher/gamepad_input.h | 6 ++++++ Launcher/gui.cpp | 3 +++ Launcher/gui_mods.cpp | 1 + 5 files changed, 65 insertions(+), 3 deletions(-) diff --git a/Launcher/Test/gamepad_input_tests.cpp b/Launcher/Test/gamepad_input_tests.cpp index 654980da..8f4f6290 100644 --- a/Launcher/Test/gamepad_input_tests.cpp +++ b/Launcher/Test/gamepad_input_tests.cpp @@ -103,6 +103,9 @@ std::string press(PadFeed& feed, std::uint16_t button) { return pressed.empty() ? released : pressed; } +// The install progress, drawn over the Mod manager while this is set. +bool g_installing = false; + // The Mod manager's layout: a rail of tabs beside a list whose rows carry // buttons, the rail's tiles level with the gaps between rows. std::string mods_page(PadFeed& feed, const PadState& state) { @@ -116,6 +119,10 @@ std::string mods_page(PadFeed& feed, const PadState& state) { ImGui::SetNextWindowPos(ImVec2(0, 0)); ImGui::SetNextWindowSize(ImVec2(900, 700)); ImGui::Begin("##page", nullptr, ImGuiWindowFlags_NoDecoration); + dingosdk::launcher_gui::hold_focus(g_installing); + // The window's own buttons come first on the page, as in the launcher. + ImGui::SetCursorPos(ImVec2(866, 0)); + if (ImGui::Button("_", ImVec2(30, 30))) pressed = "minimise"; ImGui::SetCursorPos(ImVec2(20, 100)); ImGui::BeginChild("##rail", ImVec2(200, 520), ImGuiChildFlags_NavFlattened); if (ImGui::Button("MY MODS", ImVec2(150, 40))) pressed = "MY MODS"; @@ -145,6 +152,15 @@ std::string mods_page(PadFeed& feed, const PadState& state) { dingosdk::launcher_gui::keep_focus_in_list(); ImGui::EndChild(); ImGui::End(); + if (g_installing) { + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(io.DisplaySize); + ImGui::SetNextWindowFocus(); + ImGui::Begin("##installing", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoBackground); + ImGui::SetCursorPos(ImVec2(400, 400)); + if (ImGui::Button("CANCEL")) pressed = "cancel"; + ImGui::End(); + } ImGui::Render(); return pressed; } @@ -304,6 +320,18 @@ int main() { check(tab == "MY MODS" || tab == "GET MODS", "Left still leaves the list"); mods_tap(feed, dpad_down); check(mods_tap(feed, dpad_down) == "BACK", "Down the tabs reaches BACK"); + + // INSTALL on a row, then the install progress over the page until it is done. + mods_tap(feed, dpad_up); + mods_tap(feed, dpad_right); + check(mods_tap(feed, dpad_right).rfind("install ", 0) == 0, "On a row's INSTALL"); + const int installed = std::stoi(mods_tap(feed, 0).substr(8)); + g_installing = true; + for (int i = 0; i < 10; ++i) mods_page(feed, pad()); + g_installing = false; + for (int i = 0; i < 3; ++i) mods_page(feed, pad()); + check(GImGui->NavCursorVisible, "The focus shows again once the install is done"); + check(mods_tap(feed, 0) == "row " + std::to_string(installed), "The focus goes back to the installed mod's row"); } { fresh(); diff --git a/Launcher/gamepad_input.cpp b/Launcher/gamepad_input.cpp index 7afd2399..6765c1e9 100644 --- a/Launcher/gamepad_input.cpp +++ b/Launcher/gamepad_input.cpp @@ -164,7 +164,7 @@ namespace { // The list row holding the focus, as it was last drawn. struct FocusedRow { - ImGuiID scope{}, window{}; + ImGuiID scope{}, window{}, item{}; ImRect rect; // relative to its window }; FocusedRow g_focused_row; @@ -262,7 +262,8 @@ void begin_row() { void row_buttons() { if (g_row_focused) { ImGuiWindow* window = ImGui::GetCurrentWindow(); - g_focused_row = {g_row_scope, window->ID, ImGui::WindowRectAbsToRel(window, GImGui->LastItemData.NavRect)}; + g_focused_row = {g_row_scope, window->ID, GImGui->LastItemData.ID, + ImGui::WindowRectAbsToRel(window, GImGui->LastItemData.NavRect)}; } ImGui::PushItemFlag(ImGuiItemFlags_NoNav, !g_row_focused); } @@ -272,13 +273,36 @@ void end_row() { ImGui::PopFocusScope(); } +void hold_focus(bool covered) { + static struct { + bool held{}; + ImGuiID id{}, window{}; + bool shown{}; + } held; + ImGuiContext& g = *GImGui; + if (covered && !held.held) { + const bool in_row = g_focused_row.scope && g.NavFocusScopeId == g_focused_row.scope; + held = {true, in_row ? g_focused_row.item : g.NavId, + in_row ? g_focused_row.window : g.NavWindow ? g.NavWindow->ID : 0, + g.NavCursorVisible && g.NavHighlightItemUnderNav}; + } else if (!covered && held.held) { + held.held = false; + ImGuiWindow* window = ImGui::FindWindowByID(held.window); + if (!held.id || !window) return; + ImGui::SetFocusID(held.id, window); + g.NavCursorVisible = g.NavHighlightItemUnderNav = held.shown; + } +} + void keep_focus_in_list() { ImGuiContext& g = *GImGui; if (!g.NavMoveScoringItems || (g.NavMoveDir != ImGuiDir_Up && g.NavMoveDir != ImGuiDir_Down)) return; // By the columns the list covers: its rows run on past what it shows. const ImRect list = g.CurrentWindow->Rect(); const auto in_list = [&](float x) { return x >= list.Min.x && x <= list.Max.x; }; - if (!list.Contains(g.NavScoringRect.GetCenter())) return; + // The focus is in the list itself, or in a panel inside it. + ImGuiWindow* window = g.CurrentWindow; + if (!g.NavWindow || !(g.NavWindow == window || ImGui::IsWindowChildOf(g.NavWindow, window, false))) return; // Everything around the list has had its chance by now: the rail comes // before the content, and nothing under the list takes the focus. const ImGuiNavItemData& best = g.NavMoveResultLocal.ID ? g.NavMoveResultLocal : g.NavMoveResultOther; diff --git a/Launcher/gamepad_input.h b/Launcher/gamepad_input.h index b684aa56..42305de7 100644 --- a/Launcher/gamepad_input.h +++ b/Launcher/gamepad_input.h @@ -75,6 +75,12 @@ void begin_row(); void row_buttons(); void end_row(); +// Call once a frame, after the page's Begin, with whether a window over the +// page holds the focus, like the install progress. When it goes, the focus +// goes back to where it was, or to its list row: the button pressed may be +// gone by then, an INSTALL turned INSTALLED. +void hold_focus(bool covered); + // Call just before a list's EndChild(): up and down from a row stay in the // list, so the end of the list holds the focus instead of passing it to // whatever lies beyond, like the rail's BACK. diff --git a/Launcher/gui.cpp b/Launcher/gui.cpp index ba7ef22c..f9368040 100644 --- a/Launcher/gui.cpp +++ b/Launcher/gui.cpp @@ -33,6 +33,9 @@ void keep_in_front(const char* id) { const ImGuiWindow* window = ImGui::FindWindowByName(id); const ImGuiWindow* focused = GImGui->NavWindow; if (window && focused && focused->RootWindow == window) return; + // The install progress covers the Mods page and holds the focus; taking it + // back every frame would flash the page's own default item. + if (focused && focused->RootWindow == ImGui::FindWindowByName("##installing")) return; ImGui::SetNextWindowFocus(); } diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index 79d92a38..c5387a8a 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -1001,6 +1001,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo pump_icons(panel); auto* draw = ImGui::GetWindowDrawList(); const bool installing = panel.installing; + hold_focus(installing); auto& entries = panel.list.entries; const auto installed = installed_versions(panel.list); const auto pending = updates(panel.store, installed); From 94e4df12bad9c0b523dd64b9f70c3624070d7ddc Mon Sep 17 00:00:00 2001 From: Swagi-Redacted Date: Thu, 8 Oct 2026 13:40:35 -0400 Subject: [PATCH 102/109] Sync latest bind changes, added freecam bind, tp to freecam bind, and off board up boost --- Engine/Core/Platform/memory.h | 6 +- Engine/Game/Build/20260929/offboard_flight.h | 12 ++ Engine/Game/Input/controller_bindings.h | 144 +++++++++++++-- Engine/Vfs/mod_merge_combine.cpp | 6 +- Extension/Profile/local_profile.h | 15 ++ .../Profile/local_profile_preferences.cpp | 68 ++++++- Extension/Profile/local_profile_runtime.h | 6 + Extension/Profile/local_profile_store.cpp | 64 ++++++- Extension/Profile/profile_update.cpp | 3 +- Extension/Settings/input_bindings.cpp | 73 +++++++- Extension/Skater/client_debug.cpp | 117 ++++++++++-- Extension/Skater/client_noclip.cpp | 97 ++++++++-- Extension/Skater/client_source_spawn.h | 1 + .../Skater/client_source_spawn_internal.h | 7 + Extension/Skater/console_commands.cpp | 74 +++++++- Extension/Skater/offboard_flight.cpp | 48 +++++ Extension/Skater/offboard_flight.h | 7 +- Extension/UI/Overlay/input_capture.cpp | 169 +++++++++++++++++- Extension/UI/Overlay/overlay.h | 7 + Extension/UI/Overlay/overlay_input.cpp | 33 +++- Extension/UI/Overlay/overlay_internal.h | 1 + Extension/UI/Overlay/overlay_render.cpp | 1 - Extension/UI/Overlay/playstation_input.cpp | 2 + Extension/UI/Overlay/skate_menu_settings.cpp | 27 +-- Extension/UI/Overlay/skate_menu_skater.cpp | 17 ++ Runtime/client_tick.cpp | 67 ++++++- Runtime/runtime_internal.h | 4 + contrib/setup-linux-server-libs.sh | 0 patch11.py | 44 +++++ 29 files changed, 1042 insertions(+), 78 deletions(-) mode change 100755 => 100644 contrib/setup-linux-server-libs.sh create mode 100644 patch11.py diff --git a/Engine/Core/Platform/memory.h b/Engine/Core/Platform/memory.h index edc53d78..589416be 100644 --- a/Engine/Core/Platform/memory.h +++ b/Engine/Core/Platform/memory.h @@ -40,8 +40,10 @@ inline std::ptrdiff_t peek_cstring(std::uintptr_t address, char* destination, st if (!destination || !capacity || address < 0x10000 || address > highest_user_address - capacity) return -1; __try { const auto* source = reinterpret_cast(address); - for (std::size_t i = 0; i < capacity; ++i) - if (!(destination[i] = source[i])) return static_cast(i); + for (std::size_t i = 0; i < capacity; ++i) { + destination[i] = source[i]; + if (destination[i] == '\0') return static_cast(i); + } return -1; } __except (EXCEPTION_EXECUTE_HANDLER) { return -1; diff --git a/Engine/Game/Build/20260929/offboard_flight.h b/Engine/Game/Build/20260929/offboard_flight.h index c0d65407..5d2c731f 100644 --- a/Engine/Game/Build/20260929/offboard_flight.h +++ b/Engine/Game/Build/20260929/offboard_flight.h @@ -35,4 +35,16 @@ inline constexpr std::array offboard_flight_states{{ 0xe3,0x71,0x21,0x4f,0x18,0x20,0xc5,0xe8,0x58,0xc1,0xc5,0xf8,0x11,0x06,0x4c,0x8b}}}, }}; inline constexpr std::uintptr_t offboard_flight_vtable = 0x65e8f40; +// FollowRagdoll / FollowAnimatedRagdoll / FollowSimulatedRagdoll also keep +// world velocity at +10. Their shared update derives it from displacement +// (RVA 48380DE), including Y at +14. Boosts must update this cached velocity +// as well as the skeleton bodies when diving or gliding. +inline constexpr std::array, 3> offboard_ragdoll_states{{ + {0x720, 0x65e8df8, 0x4832c80}, + {0x8e0, 0x65e8e60, 0x4830a70}, + {0xaa0, 0x65e8ed0, 0x48332f0}, +}}; +inline constexpr Fingerprint ragdoll_velocity_write{0x48380de, { + 0xc4,0xe2,0x79,0x18,0x80,0xec,0x17,0x00,0x00,0xc5,0xe8,0x5e,0xc8,0xc5,0xf8,0x11, + 0x4f,0x10,0xc5,0xf8,0x10,0x43,0x30,0xc5,0xf8,0x11,0x87,0x60,0x01,0x00,0x00,0x4c}}; } diff --git a/Engine/Game/Input/controller_bindings.h b/Engine/Game/Input/controller_bindings.h index f833dac1..cffcf00c 100644 --- a/Engine/Game/Input/controller_bindings.h +++ b/Engine/Game/Input/controller_bindings.h @@ -1,5 +1,7 @@ #pragma once +#include #include +#include #include #include #include @@ -7,10 +9,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(key - '0' + 1); + if (key >= 'A' && key <= 'Z') return static_cast(key - 'A' + 11); + if (key == 0x20) return 37; // Space + if (key >= 0x70 && key <= 0x7b) return static_cast(key - 0x70 + 38); // F1-F12 + if (key == 0x11) return 50; // Ctrl + if (key == 0x10) return 51; // Shift + if (key == 0x12) return 52; // Alt + return 0; +} +inline constexpr std::uint8_t keyboard_key_from_id(std::uint32_t id) noexcept { + if (id >= 1 && id <= 10) return static_cast('0' + id - 1); + if (id >= 11 && id <= 36) return static_cast('A' + id - 11); + if (id == 37) return 0x20; + if (id >= 38 && id <= 49) return static_cast(0x70 + id - 38); + if (id == 50) return 0x11; + if (id == 51) return 0x10; + if (id == 52) return 0x12; + return 0; +} +inline constexpr bool bindable_keyboard_key(std::uint32_t key) noexcept { + return keyboard_key_id(key) != 0; +} +inline constexpr bool keyboard_binding(std::uint32_t value) noexcept { + if ((value & ~0xffu) == keyboard_binding_tag) return bindable_keyboard_key(value & 0xffu); + if ((value & 0xc0000000u) != keyboard_chord_tag) return false; + unsigned count = 0, previous = 0; + bool ended = false; + for (unsigned slot = 0; slot < 5; ++slot) { + const unsigned id = (value >> (slot * 6)) & 0x3fu; + if (!id) { ended = true; continue; } + if (ended || !keyboard_key_from_id(id) || id <= previous) return false; + previous = id; + ++count; + } + return count >= 2; +} +inline std::uint32_t keyboard_binding_from_keys(const std::array& keys) noexcept { + std::uint32_t value = keyboard_chord_tag; + unsigned count = 0; + for (unsigned id = 1; id <= 52; ++id) { + const auto key = keyboard_key_from_id(id); + if (!(keys[key / 64] & (std::uint64_t{1} << (key % 64)))) continue; + if (count == 5) return 0; + value |= id << (count++ * 6); + } + if (count == 1) { + const auto id = value & 0x3fu; + return keyboard_binding_tag | keyboard_key_from_id(id); + } + return count >= 2 ? value : 0; +} inline constexpr bool valid_controller_combo(std::uint32_t value) noexcept { return (value & ~controller_button_mask) == 0; } -// Button names shown to the player. Bindings are always stored as XInput bits. +inline constexpr bool valid_action_binding(std::uint32_t value) noexcept { + return valid_controller_combo(value) || keyboard_binding(value); +} +// Button names shown to the player. Controller chords keep their XInput bits. enum class ControllerStyle : std::uint8_t { xbox, dualshock4, dualsense }; struct ControllerInput { bool available{}; @@ -19,16 +80,46 @@ struct ControllerInput { // (a pad connecting, disconnecting or switching source) resets latches. unsigned device{}; ControllerStyle style{}; + std::array 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(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 update(ControllerInput input) noexcept { - if (!input.available || input.device != device) { - chord = 0; ready = false; device = input.device; + std::array keyboard_chord{}; + std::optional update(ControllerInput input, bool allow_keyboard = false) noexcept { + if (input.device != device || (!input.available && !allow_keyboard)) { + chord = 0; ready = false; keyboard_chord = {}; device = input.device; + } + if (!input.available && !allow_keyboard) return {}; + const auto buttons = input.buttons & controller_button_mask; + const bool keys_held = std::any_of(input.keys.begin(), input.keys.end(), [](auto word) { return word != 0; }); + if (!ready) { ready = buttons == 0 && (!allow_keyboard || !keys_held); return {}; } + if (allow_keyboard && keys_held && !chord) { + if (std::popcount(input.keys[0]) + std::popcount(input.keys[1]) + + std::popcount(input.keys[2]) + std::popcount(input.keys[3]) > + std::popcount(keyboard_chord[0]) + std::popcount(keyboard_chord[1]) + + std::popcount(keyboard_chord[2]) + std::popcount(keyboard_chord[3])) keyboard_chord = input.keys; + } + if (std::any_of(keyboard_chord.begin(), keyboard_chord.end(), [](auto word) { return word != 0; })) { + if (!keys_held) { + const auto result = keyboard_binding_from_keys(keyboard_chord); + keyboard_chord = {}; ready = false; + if (result) return result; + } + return {}; } if (!input.available) return {}; - const auto buttons = input.buttons & controller_button_mask; - if (!ready) { ready = buttons == 0; return {}; } if (std::popcount(buttons) > std::popcount(chord)) chord = buttons; if (!buttons && chord) { const auto result = chord; chord = 0; ready = false; diff --git a/Engine/Vfs/mod_merge_combine.cpp b/Engine/Vfs/mod_merge_combine.cpp index dc6b4cf4..2824a879 100644 --- a/Engine/Vfs/mod_merge_combine.cpp +++ b/Engine/Vfs/mod_merge_combine.cpp @@ -981,8 +981,10 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, for (const auto& [index, guids] : left) { // Name the mod whose asset took the item's name, when that is why. const Asset* taken{}; - for (const auto& guid : guids) - if ((taken = held.replacement(guid))) break; + for (const auto& guid : guids) { + taken = held.replacement(guid); + if (taken) break; + } report.notes.push_back(mod_of(index) + ": " + entry.asset.name + ": " + std::to_string(guids.size()) + (itemList ? " item(s)" : " entry(ies)") + " left out of the list because the merged " diff --git a/Extension/Profile/local_profile.h b/Extension/Profile/local_profile.h index 23d4e29f..aec449a7 100644 --- a/Extension/Profile/local_profile.h +++ b/Extension/Profile/local_profile.h @@ -112,9 +112,14 @@ ParkChoices park_choices(const Snapshot&); WorldLayerChoices world_layer_choices(const Snapshot&); WorldControls world_controls(const Snapshot&); GraphicsControls graphics_controls(const Snapshot&); +std::uint32_t freecam_controller_binding(const Snapshot&); +bool freecam_controller(const Snapshot&); +std::uint32_t freecam_binding(const Snapshot&); +std::uint32_t tp_to_freecam_binding(const Snapshot&); std::uint32_t noclip_binding(const Snapshot&); std::uint32_t forward_velocity_binding(const Snapshot&); std::uint32_t up_velocity_binding(const Snapshot&); +std::uint32_t offboard_up_velocity_binding(const Snapshot&); std::vector challenge_completed_criteria(const Snapshot&, std::string_view); std::string encode(const Snapshot&); Snapshot decode(std::string_view); @@ -177,12 +182,22 @@ public: void save_park_choice(unsigned lot, std::string_view id); void save_world_controls(const WorldControls&); void save_graphics_controls(const GraphicsControls&); + bool freecam_controller() const; + void save_freecam_controller(bool); + std::uint32_t freecam_controller_binding() const; + void save_freecam_controller_binding(std::uint32_t); + std::uint32_t freecam_binding() const; + void save_freecam_binding(std::uint32_t); + std::uint32_t tp_to_freecam_binding() const; + void save_tp_to_freecam_binding(std::uint32_t); std::uint32_t noclip_binding() const; void save_noclip_binding(std::uint32_t); std::uint32_t forward_velocity_binding() const; void save_forward_velocity_binding(std::uint32_t); std::uint32_t up_velocity_binding() const; void save_up_velocity_binding(std::uint32_t); + std::uint32_t offboard_up_velocity_binding() const; + void save_offboard_up_velocity_binding(std::uint32_t); void save_world_layer_choice(unsigned layer, std::string_view mode); void restore_world_layers(WorldMap map); std::uint64_t begin_challenge(std::string_view id); diff --git a/Extension/Profile/local_profile_preferences.cpp b/Extension/Profile/local_profile_preferences.cpp index 7ef08abd..a9701201 100644 --- a/Extension/Profile/local_profile_preferences.cpp +++ b/Extension/Profile/local_profile_preferences.cpp @@ -9,15 +9,20 @@ ControllerBindingsModel local_profile_controller_bindings() { ControllerBindingsModel result; result.status = binding_feedback; if (!s.active || !s.store) return result; + result.freecam_controller = s.store->freecam_controller(); + result.freecam_controller_combo = s.store->freecam_controller_binding(); + result.freecam_combo = s.store->freecam_binding(); + result.tp_to_freecam_combo = s.store->tp_to_freecam_binding(); result.noclip_combo = s.store->noclip_binding(); result.forward_velocity_combo = s.store->forward_velocity_binding(); result.up_velocity_combo = s.store->up_velocity_binding(); + result.offboard_up_velocity_combo = s.store->offboard_up_velocity_binding(); result.available = true; return result; } bool set_local_noclip_binding(std::uint32_t combo) { auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); - if (!s.active || !s.store || !valid_controller_combo(combo)) return false; + if (!s.active || !s.store || !valid_action_binding(combo)) return false; try { s.store->save_noclip_binding(combo); binding_feedback = combo ? "Noclip binding saved." : "Noclip binding cleared."; @@ -26,9 +31,55 @@ bool set_local_noclip_binding(std::uint32_t combo) { } catch (...) { binding_feedback = "Could not save the binding. See the console log."; return false; } } +bool local_freecam_controller() { + auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); + return s.active && s.store && s.store->freecam_controller(); +} +bool set_local_freecam_controller(bool value) { + auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); + if (!s.active || !s.store) return false; + try { + s.store->save_freecam_controller(value); + return true; + } catch (...) { return false; } +} + +bool set_local_freecam_controller_binding(std::uint32_t combo) { + auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); + if (!s.active || !s.store || !valid_action_binding(combo)) return false; + try { + s.store->save_freecam_controller_binding(combo); + binding_feedback = combo ? "Freecam Controller binding saved." : "Freecam Controller binding cleared."; + dingosdk::logging::event(dingosdk::logging::Channel::profile, dingosdk::Json{{"event","controller_binding_saved"},{"action","freecam_controller"},{"combo",combo}}.dump().c_str()); + return true; + } catch (...) { binding_feedback = "Could not save the binding. See the console log."; return false; } +} + +bool set_local_freecam_binding(std::uint32_t combo) { + auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); + if (!s.active || !s.store || !valid_action_binding(combo)) return false; + try { + s.store->save_freecam_binding(combo); + binding_feedback = combo ? "Freecam binding saved." : "Freecam binding cleared."; + dingosdk::logging::event(dingosdk::logging::Channel::profile, dingosdk::Json{{"event","controller_binding_saved"},{"action","freecam"},{"combo",combo}}.dump().c_str()); + return true; + } catch (...) { binding_feedback = "Could not save the binding. See the console log."; return false; } +} + +bool set_local_tp_to_freecam_binding(std::uint32_t combo) { + auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); + if (!s.active || !s.store || !valid_action_binding(combo)) return false; + try { + s.store->save_tp_to_freecam_binding(combo); + binding_feedback = combo ? "TP to Freecam binding saved." : "TP to Freecam binding cleared."; + dingosdk::logging::event(dingosdk::logging::Channel::profile, dingosdk::Json{{"event","controller_binding_saved"},{"action","tp_to_freecam"},{"combo",combo}}.dump().c_str()); + return true; + } catch (...) { binding_feedback = "Could not save the binding. See the console log."; return false; } +} + bool set_local_forward_velocity_binding(std::uint32_t combo) { auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); - if (!s.active || !s.store || !valid_controller_combo(combo)) return false; + if (!s.active || !s.store || !valid_action_binding(combo)) return false; try { s.store->save_forward_velocity_binding(combo); binding_feedback = combo ? "Forward Boost binding saved." : "Forward Boost binding cleared."; @@ -37,9 +88,20 @@ bool set_local_forward_velocity_binding(std::uint32_t combo) { } catch (...) { binding_feedback = "Could not save the binding. See the console log."; return false; } } +bool set_local_offboard_up_velocity_binding(std::uint32_t combo) { + auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); + if (!s.active || !s.store || !valid_action_binding(combo)) return false; + try { + s.store->save_offboard_up_velocity_binding(combo); + binding_feedback = combo ? "Off-board Up Boost binding saved." : "Off-board Up Boost binding cleared."; + dingosdk::logging::event(dingosdk::logging::Channel::profile, dingosdk::Json{{"event","controller_binding_saved"},{"action","offboard_up_velocity"},{"combo",combo}}.dump().c_str()); + return true; + } catch (...) { binding_feedback = "Could not save the binding. See the console log."; return false; } +} + bool set_local_up_velocity_binding(std::uint32_t combo) { auto& s = local_runtime(); std::lock_guard lock(s.native_mutex); - if (!s.active || !s.store || !valid_controller_combo(combo)) return false; + if (!s.active || !s.store || !valid_action_binding(combo)) return false; try { s.store->save_up_velocity_binding(combo); binding_feedback = combo ? "Up Boost binding saved." : "Up Boost binding cleared."; diff --git a/Extension/Profile/local_profile_runtime.h b/Extension/Profile/local_profile_runtime.h index 17586388..caecaaee 100644 --- a/Extension/Profile/local_profile_runtime.h +++ b/Extension/Profile/local_profile_runtime.h @@ -60,9 +60,15 @@ bool queue_local_park_selection(const EditorSelectionRequest &); bool queue_local_park_paste(const EditorPasteRequest &); void tick_local_park_editor() noexcept; ControllerBindingsModel local_profile_controller_bindings(); +bool set_local_freecam_controller(bool); +bool local_freecam_controller(); +bool set_local_freecam_controller_binding(std::uint32_t); +bool set_local_freecam_binding(std::uint32_t); +bool set_local_tp_to_freecam_binding(std::uint32_t); bool set_local_noclip_binding(std::uint32_t); bool set_local_forward_velocity_binding(std::uint32_t); bool set_local_up_velocity_binding(std::uint32_t); +bool set_local_offboard_up_velocity_binding(std::uint32_t); bool set_local_object_persistence(bool enabled); bool clear_local_persisted_objects(std::string_view map); bool delete_local_placed_object(std::string_view map, std::uint64_t token); diff --git a/Extension/Profile/local_profile_store.cpp b/Extension/Profile/local_profile_store.cpp index a040226e..2fc5d776 100644 --- a/Extension/Profile/local_profile_store.cpp +++ b/Extension/Profile/local_profile_store.cpp @@ -146,12 +146,58 @@ void Store::save_district_rank(std::string_view id, std::uint32_t rank) { } +bool Store::freecam_controller() const { + std::lock_guard lock(mutex_); + return profile::freecam_controller(value_); +} +void Store::save_freecam_controller(bool value) { + std::lock_guard lock(mutex_); + Update update(*this); + update.json(value_.settings, {"options", "freecam_controller"}) = value; + update.commit(); +} +std::uint32_t Store::freecam_controller_binding() const { + std::lock_guard lock(mutex_); + return profile::freecam_controller_binding(value_); +} +void Store::save_freecam_controller_binding(std::uint32_t combo) { + require(valid_action_binding(combo), "Unsupported controller combo or keyboard key"); + std::lock_guard lock(mutex_); + Update update(*this); + update.json(value_.settings, {"bindings", "freecam_controller"}) = combo; + update.commit(); +} + +std::uint32_t Store::freecam_binding() const { + std::lock_guard lock(mutex_); + return profile::freecam_binding(value_); +} +void Store::save_freecam_binding(std::uint32_t combo) { + require(valid_action_binding(combo), "Unsupported controller combo or keyboard key"); + std::lock_guard lock(mutex_); + Update update(*this); + update.json(value_.settings, {"bindings", "freecam"}) = combo; + update.commit(); +} + +std::uint32_t Store::tp_to_freecam_binding() const { + std::lock_guard lock(mutex_); + return profile::tp_to_freecam_binding(value_); +} +void Store::save_tp_to_freecam_binding(std::uint32_t combo) { + require(valid_action_binding(combo), "Unsupported controller combo or keyboard key"); + std::lock_guard lock(mutex_); + Update update(*this); + update.json(value_.settings, {"bindings", "tp_to_freecam"}) = combo; + update.commit(); +} + std::uint32_t Store::noclip_binding() const { std::lock_guard lock(mutex_); return profile::noclip_binding(value_); } void Store::save_noclip_binding(std::uint32_t combo) { - require(valid_controller_combo(combo), "Unsupported controller combo"); + require(valid_action_binding(combo), "Unsupported controller combo or keyboard key"); std::lock_guard lock(mutex_); Update update(*this); update.json(value_.settings, {"bindings", "noclip"}) = combo; @@ -164,19 +210,31 @@ std::uint32_t Store::forward_velocity_binding() const { return profile::forward_velocity_binding(value_); } void Store::save_forward_velocity_binding(std::uint32_t combo) { - require(valid_controller_combo(combo), "Unsupported controller combo"); + require(valid_action_binding(combo), "Unsupported controller combo or keyboard key"); std::lock_guard lock(mutex_); Update update(*this); update.json(value_.settings, {"bindings", "forward_velocity"}) = combo; update.commit(); } +std::uint32_t Store::offboard_up_velocity_binding() const { + std::lock_guard lock(mutex_); + return profile::offboard_up_velocity_binding(value_); +} +void Store::save_offboard_up_velocity_binding(std::uint32_t combo) { + require(valid_action_binding(combo), "Unsupported controller combo or keyboard key"); + std::lock_guard lock(mutex_); + Update update(*this); + update.json(value_.settings, {"bindings", "offboard_up_velocity"}) = combo; + update.commit(); +} + std::uint32_t Store::up_velocity_binding() const { std::lock_guard lock(mutex_); return profile::up_velocity_binding(value_); } void Store::save_up_velocity_binding(std::uint32_t combo) { - require(valid_controller_combo(combo), "Unsupported controller combo"); + require(valid_action_binding(combo), "Unsupported controller combo or keyboard key"); std::lock_guard lock(mutex_); Update update(*this); update.json(value_.settings, {"bindings", "up_velocity"}) = combo; diff --git a/Extension/Profile/profile_update.cpp b/Extension/Profile/profile_update.cpp index e731a97b..8af0be04 100644 --- a/Extension/Profile/profile_update.cpp +++ b/Extension/Profile/profile_update.cpp @@ -67,9 +67,10 @@ void Store::Update::encode(Json& patch) { if (settings) { detail::validate_settings(value); (void)park_choices(value); (void)world_layer_choices(value); - (void)world_controls(value); (void)graphics_controls(value); (void)profile::noclip_binding(value); + (void)world_controls(value); (void)graphics_controls(value); (void)profile::freecam_controller(value); (void)profile::freecam_controller_binding(value); (void)profile::freecam_binding(value); (void)profile::tp_to_freecam_binding(value); (void)profile::noclip_binding(value); (void)profile::forward_velocity_binding(value); (void)profile::up_velocity_binding(value); + (void)profile::offboard_up_velocity_binding(value); remove("player_settings", [](const Json& row) { return row[0] != "options"; }); auto residual = value.settings; const auto flatten = [&](auto&& self, Json& node, const std::string& scope) -> void { diff --git a/Extension/Settings/input_bindings.cpp b/Extension/Settings/input_bindings.cpp index a5abc6d3..55d7f35f 100644 --- a/Extension/Settings/input_bindings.cpp +++ b/Extension/Settings/input_bindings.cpp @@ -5,6 +5,54 @@ namespace dingosdk::profile { using namespace detail; +std::uint32_t freecam_controller_binding(const Snapshot& s) { + const auto bindings = s.settings.find("bindings"); + if (bindings == s.settings.end()) return 0; + require(bindings->is_object(), "Bindings must be an object"); + const auto value = bindings->find("freecam_controller"); + if (value == bindings->end()) return 0; + require(value->is_number_unsigned() || (value->is_number_integer() && value->get() >= 0), + "Freecam Controller binding must be a nonnegative integer"); + const auto mask = value->get(); + require(mask <= UINT32_MAX && valid_action_binding(static_cast(mask)), + "Unsupported controller or keyboard key in Freecam Controller binding"); + return static_cast(mask); +} +bool freecam_controller(const Snapshot& s) { + const auto options = s.settings.find("options"); + if (options == s.settings.end()) return false; + require(options->is_object(), "Options must be an object"); + const auto value = options->find("freecam_controller"); + if (value == options->end()) return false; + require(value->is_boolean(), "freecam_controller must be a boolean"); + return value->get(); +} +std::uint32_t freecam_binding(const Snapshot& s) { + const auto bindings = s.settings.find("bindings"); + if (bindings == s.settings.end()) return 0; + require(bindings->is_object(), "Bindings must be an object"); + const auto value = bindings->find("freecam"); + if (value == bindings->end()) return 0; + require(value->is_number_unsigned() || (value->is_number_integer() && value->get() >= 0), + "Freecam binding must be a nonnegative integer"); + const auto mask = value->get(); + require(mask <= UINT32_MAX && valid_action_binding(static_cast(mask)), + "Unsupported controller or keyboard key in Freecam binding"); + return static_cast(mask); +} +std::uint32_t tp_to_freecam_binding(const Snapshot& s) { + const auto bindings = s.settings.find("bindings"); + if (bindings == s.settings.end()) return 0; + require(bindings->is_object(), "Bindings must be an object"); + const auto value = bindings->find("tp_to_freecam"); + if (value == bindings->end()) return 0; + require(value->is_number_unsigned() || (value->is_number_integer() && value->get() >= 0), + "TP to Freecam binding must be a nonnegative integer"); + const auto mask = value->get(); + require(mask <= UINT32_MAX && valid_action_binding(static_cast(mask)), + "Unsupported controller or keyboard key in TP to Freecam binding"); + return static_cast(mask); +} std::uint32_t noclip_binding(const Snapshot& s) { const auto bindings = s.settings.find("bindings"); if (bindings == s.settings.end()) return 0; @@ -14,8 +62,8 @@ std::uint32_t noclip_binding(const Snapshot& s) { require(value->is_number_unsigned() || (value->is_number_integer() && value->get() >= 0), "Noclip binding must be a nonnegative integer"); const auto mask = value->get(); - require(mask <= controller_button_mask && valid_controller_combo(static_cast(mask)), - "Unsupported controller buttons in Noclip binding"); + require(mask <= UINT32_MAX && valid_action_binding(static_cast(mask)), + "Unsupported controller or keyboard key in Noclip binding"); return static_cast(mask); } @@ -28,11 +76,24 @@ std::uint32_t forward_velocity_binding(const Snapshot& s) { require(value->is_number_unsigned() || (value->is_number_integer() && value->get() >= 0), "Forward velocity binding must be a nonnegative integer"); const auto mask = value->get(); - require(mask <= controller_button_mask && valid_controller_combo(static_cast(mask)), - "Unsupported controller buttons in Forward Boost binding"); + require(mask <= UINT32_MAX && valid_action_binding(static_cast(mask)), + "Unsupported controller or keyboard key in Forward Boost binding"); return static_cast(mask); } +std::uint32_t offboard_up_velocity_binding(const Snapshot& s) { + const auto bindings = s.settings.find("bindings"); + if (bindings == s.settings.end()) return 0; + require(bindings->is_object(), "Bindings must be an object"); + const auto value = bindings->find("offboard_up_velocity"); + if (value == bindings->end()) return 0; + require(value->is_number_unsigned() || (value->is_number_integer() && value->get() >= 0), + "Off-board Up Boost binding must be a nonnegative integer"); + const auto mask = value->get(); + require(mask <= UINT32_MAX && valid_action_binding(static_cast(mask)), + "Unsupported controller or keyboard key in Off-board Up Boost binding"); + return static_cast(mask); +} std::uint32_t up_velocity_binding(const Snapshot& s) { const auto bindings = s.settings.find("bindings"); if (bindings == s.settings.end()) return 0; @@ -42,8 +103,8 @@ std::uint32_t up_velocity_binding(const Snapshot& s) { require(value->is_number_unsigned() || (value->is_number_integer() && value->get() >= 0), "Up velocity binding must be a nonnegative integer"); const auto mask = value->get(); - require(mask <= controller_button_mask && valid_controller_combo(static_cast(mask)), - "Unsupported controller buttons in Up Boost binding"); + require(mask <= UINT32_MAX && valid_action_binding(static_cast(mask)), + "Unsupported controller or keyboard key in Up Boost binding"); return static_cast(mask); } diff --git a/Extension/Skater/client_debug.cpp b/Extension/Skater/client_debug.cpp index db209dc6..285e3d4c 100644 --- a/Extension/Skater/client_debug.cpp +++ b/Extension/Skater/client_debug.cpp @@ -1,6 +1,7 @@ #include "client_source_spawn.h" #include "client_source_spawn_internal.h" #include "no_bail.h" +#include "offboard_flight.h" #include "Engine/Core/Log/logging.h" #include "Engine/Core/Platform/launcher_support.h" #include "Engine/Game/Build/20260929/engine.h" @@ -242,6 +243,7 @@ constexpr const char* no_bail = "NoBail"; constexpr const char* flight_speed = "FlightSpeed"; constexpr const char* forward_velocity = "ForwardVelocity"; constexpr const char* up_velocity = "UpVelocity"; +constexpr const char* offboard_up_velocity = "OffboardUpVelocity"; constexpr const char* first_person_fov = "FirstPerson.Fov"; constexpr const char* free_camera_fov = "FreeCamera.Fov"; constexpr const char* spring = "FirstPerson.Spring"; @@ -272,6 +274,7 @@ void save_debug(InteractiveDebug& debug) noexcept { {saved::flight_speed, static_cast(debug.flight_speed)}, {saved::forward_velocity, static_cast(debug.forward_velocity_speed)}, {saved::up_velocity, static_cast(debug.up_velocity_speed)}, + {saved::offboard_up_velocity, static_cast(debug.offboard_up_velocity_speed)}, {saved::first_person_fov, static_cast(debug.first_person_fov)}, {saved::free_camera_fov, static_cast(debug.free_camera_fov)}, {saved::spring, arm.enabled}}; @@ -300,6 +303,8 @@ void load_saved_debug(InteractiveDebug& debug) noexcept { debug.forward_velocity_speed = *speed; if (const auto speed = saved::number(saved::up_velocity); speed && *speed >= 1.0f && *speed <= 25.0f) debug.up_velocity_speed = *speed; + if (const auto speed = saved::number(saved::offboard_up_velocity); speed && *speed >= 1.0f && *speed <= 25.0f) + debug.offboard_up_velocity_speed = *speed; if (const auto fov = saved::number(saved::first_person_fov); fov && *fov >= first_person_fov_min && *fov <= first_person_fov_max) { debug.first_person_fov = *fov; @@ -372,6 +377,7 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b source_require(!debug.park_editor || debug.editor_transition || request.action == overlay::DebugAction::restore_debug || request.action == overlay::DebugAction::set_camera_speed || request.action == overlay::DebugAction::set_forward_velocity_speed || request.action == overlay::DebugAction::set_up_velocity_speed || + request.action == overlay::DebugAction::set_offboard_up_velocity_speed || request.action == overlay::DebugAction::set_first_person_fov || request.action == overlay::DebugAction::set_free_camera_fov || (request.action >= overlay::DebugAction::set_first_person_spring && request.action <= overlay::DebugAction::reset_first_person_arm) || @@ -424,13 +430,21 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b return; } if (request.action == overlay::DebugAction::set_forward_velocity_speed || - request.action == overlay::DebugAction::set_up_velocity_speed) { - const bool up = request.action == overlay::DebugAction::set_up_velocity_speed; - source_require(std::isfinite(request.value) && request.value >= 1.0f && request.value <= (up ? 25.0f : 300.0f), - up ? "Up Boost speed must be between 1 and 25." : "Forward Boost speed must be between 1 and 300."); - (up ? debug.up_velocity_speed : debug.forward_velocity_speed) = request.value; + request.action == overlay::DebugAction::set_up_velocity_speed || + request.action == overlay::DebugAction::set_offboard_up_velocity_speed) { + if (request.action == overlay::DebugAction::set_offboard_up_velocity_speed) { + source_require(std::isfinite(request.value) && request.value >= 1.0f && request.value <= 25.0f, + "Off-board Up Boost speed must be between 1 and 25."); + debug.offboard_up_velocity_speed = request.value; + debug.status = "Off-board Up Boost speed updated."; + } else { + const bool up = request.action == overlay::DebugAction::set_up_velocity_speed; + source_require(std::isfinite(request.value) && request.value >= 1.0f && request.value <= (up ? 25.0f : 300.0f), + up ? "Up Boost speed must be between 1 and 25." : "Forward Boost speed must be between 1 and 300."); + (up ? debug.up_velocity_speed : debug.forward_velocity_speed) = request.value; + debug.status = up ? "Up Boost speed updated." : "Forward Boost speed updated."; + } mark_debug_changed(debug); - debug.status = up ? "Up Boost speed updated." : "Forward Boost speed updated."; return; } if (request.action == overlay::DebugAction::restore_debug) { @@ -443,7 +457,9 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b debug.forward_velocity_speed = 20.0f; debug.forward_velocity.valid = false; debug.up_velocity_speed = 20.0f; + debug.offboard_up_velocity_speed = 20.0f; debug.up_velocity.valid = false; + debug.offboard_up_velocity.valid = false; debug.no_bail = false; debug_stop_noclip(debug); clear_no_bail(); @@ -475,27 +491,61 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b return; } source_require(can_control, "Wait for an active local level before changing debug settings."); - if (request.action == overlay::DebugAction::add_forward_velocity || request.action == overlay::DebugAction::add_up_velocity) { + if (request.action == overlay::DebugAction::add_forward_velocity || request.action == overlay::DebugAction::add_up_velocity || request.action == overlay::DebugAction::add_offboard_up_velocity) { source_require(session_boosts_allowed(), "The host has turned off boosts in this session."); - const bool up = request.action == overlay::DebugAction::add_up_velocity; + const bool offboard_boost = request.action == overlay::DebugAction::add_offboard_up_velocity; + const bool up = request.action == overlay::DebugAction::add_up_velocity || offboard_boost; source_require(!debug.noclip, "Disable Noclip before using velocity boosts."); source_require(source_state().velocity_guard_active.load(std::memory_order_acquire), "Velocity boosts are unavailable for this game build."); overlay::DebugModel skater; const auto transform = debug_skater(trial.base, client, skater); const auto bodies = debug_noclip_bodies(trial.base, client, skater.skater_identity); - source_require(!bodies.offboard, "Velocity boosts require the skater to be on the board."); + if (offboard_boost) { + source_require(bodies.offboard || bodies.wipeout, "Off-board Up Boost requires walking, falling or gliding."); + } else { + source_require(!bodies.offboard, "Velocity boosts require the skater to be on the board."); + } SourceReader reader; source_require(reader.value(skater.skater_identity, 0x7e0) == 0, "Wait for the current teleport before using velocity boosts."); reader.verify(); - const auto velocity = velocity_boost_delta(transform, up ? debug.up_velocity_speed : debug.forward_velocity_speed, + const float speed = offboard_boost ? debug.offboard_up_velocity_speed : (up ? debug.up_velocity_speed : debug.forward_velocity_speed); + const auto velocity = velocity_boost_delta(transform, speed, up ? VelocityBoostDirection::up : VelocityBoostDirection::forward); source_require(velocity.has_value(), "Velocity boost direction or speed is invalid."); - auto& boost = up ? debug.up_velocity : debug.forward_velocity; - source_require(!boost.valid, "Velocity boost is already queued."); + auto& boost = offboard_boost ? debug.offboard_up_velocity : (up ? debug.up_velocity : debug.forward_velocity); + source_require(offboard_boost || !boost.valid, "Velocity boost is already queued."); boost = {client, skater.skater_identity, bodies.core, GetTickCount64() + 1000, *velocity, true}; - debug.status = up ? "Up Boost queued." : "Forward Boost queued."; + if (offboard_boost) source_state().offboard_boost_core.store(bodies.core, std::memory_order_release); + if (offboard_boost) { + // Give the player the impulse on this client tick. The motion hook + // keeps its upward speed briefly if native off-board drive rewrites it. + const float current_up = reader.value(bodies.parts[9], 0x74); + const float target_up = std::max(speed, current_up + speed); + source_require(std::isfinite(target_up) && target_up <= 100000, + "Off-board boost produced an invalid upward speed."); + std::array, 32> velocities{}; + std::array flags{}; + for (std::size_t i = 9; i < bodies.parts.size(); ++i) { + velocities[i] = reader.value>(bodies.parts[i], 0x70); + flags[i] = reader.value(bodies.parts[i], 0x60); + velocities[i][1] = std::max(velocities[i][1], target_up); + } + reader.verify(); + if (bodies.offboard) + source_require(ensure_offboard_up_velocity(trial.base, bodies.core, bodies.context, + bodies.rig_wrapper, target_up), "Off-board boost motion ownership changed."); + for (std::size_t i = 9; i < bodies.parts.size(); ++i) { + debug_write(bodies.parts[i] + 0x70, velocities[i]); + debug_write(bodies.parts[i] + 0x60, flags[i] | 8u); + } + boost.velocity[1] = target_up; + boost.applied = true; + boost.expires = GetTickCount64() + 180; + ++debug.offboard_up_velocity_updates; + debug.status = "Off-board up velocity added."; + } else debug.status = up ? "Up Boost queued." : "Forward Boost queued."; return; } if (request.action == overlay::DebugAction::set_no_bail) { @@ -530,6 +580,7 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b source_require(session_noclip_allowed(), "The host has turned off noclip in this session."); debug.forward_velocity.valid = false; debug.up_velocity.valid = false; + debug.offboard_up_velocity.valid = false; source_require(no_bail_available(), "Noclip requires the native No Bail hooks."); source_require(source_state().velocity_guard_active.load(std::memory_order_acquire), "Velocity flight is unavailable for this game build."); @@ -669,6 +720,7 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli SourceLastError error; overlay::DebugModel model; auto& state = source_state(); + model.free_camera = state.free_camera_active.load(std::memory_order_acquire); if (!state.initialized.load(std::memory_order_acquire) || state.busy.test_and_set(std::memory_order_acquire)) return model; SourceBusyScope scope{state.busy}; auto& trial = state.trial; @@ -687,6 +739,7 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli if (!can_control) { debug.forward_velocity.valid = false; debug.up_velocity.valid = false; + debug.offboard_up_velocity.valid = false; } if (debug.park_editor && !lobby_object_placement_allowed() && can_control && camera_phase_observed) { try { @@ -708,6 +761,10 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli catch (const SourceGuard& guard) { debug.status = guard.message; } catch (...) { debug.status = "Debug action failed; inspect the current state before retrying."; } } + // Camera ownership survives a busy tick or a failed presentation snapshot. + // Input capture must follow the lease, not those transient model failures. + state.free_camera_active.store(debug.camera_owned && !debug.first_person, std::memory_order_release); + model.free_camera = debug.camera_owned && !debug.first_person; std::optional flight_camera; try { debug_flight_tick(trial, client, can_control, camera_phase_observed, flight_input, error.value, flight_camera); } catch (const SourceGuard& guard) { debug_stop_noclip(debug); debug.status = guard.message; } @@ -767,6 +824,11 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli debug.noclip ? "Disable Noclip before using Up Boost." : !state.velocity_guard_active.load(std::memory_order_acquire) ? "Up Boost is unavailable for this game build." : "Local skater physics could not be read."; + model.offboard_up_velocity_unavailable = !can_control ? "Waiting for active local controls." : + debug.park_editor ? "Close Park Editor before using Off-board Up Boost." : + debug.noclip ? "Disable Noclip before using Off-board Up Boost." : + !state.velocity_guard_active.load(std::memory_order_acquire) ? "Off-board Up Boost is unavailable for this game build." : + "Local skater physics could not be read."; try { (void)debug_skater(base, client, model); const auto bodies = debug_noclip_bodies(base, client, model.skater_identity); @@ -798,6 +860,22 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli } else if (debug.up_velocity.valid) { model.up_velocity_unavailable = "Up Boost is being applied."; } + if (debug.offboard_up_velocity.valid && (GetTickCount64() >= debug.offboard_up_velocity.expires || + debug.offboard_up_velocity.entity != model.skater_identity || debug.offboard_up_velocity.core != bodies.core || + (!debug.offboard_up_velocity.applied && !bodies.offboard && !bodies.wipeout))) { + const bool applied = debug.offboard_up_velocity.applied; + debug.offboard_up_velocity.valid = false; + if (!applied) debug.status = "Off-board Up Boost cancelled because skater physics changed."; + } + if (can_control && !debug.park_editor && !debug.noclip && + state.velocity_guard_active.load(std::memory_order_acquire) && (bodies.offboard || bodies.wipeout)) { + model.offboard_up_velocity_available = true; + model.offboard_up_velocity_unavailable.clear(); + } else if (!bodies.offboard && !bodies.wipeout) { + model.offboard_up_velocity_unavailable = "Off-board Up Boost requires walking, falling or gliding."; + } else if (debug.offboard_up_velocity.valid) { + model.offboard_up_velocity_unavailable = "Off-board Up Boost is being applied."; + } model.no_bail_available = can_control && update_no_bail(client, model.skater_identity, debug.no_bail && no_bail_allowed, debug.noclip && debug.noclip_velocity.valid, debug.noclip_velocity.expires); if (model.camera_available) { @@ -814,14 +892,16 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli if (model.camera_available) model.noclip_unavailable = guard.message; model.forward_velocity_unavailable = guard.message; model.up_velocity_unavailable = guard.message; + model.offboard_up_velocity_unavailable = guard.message; } catch (...) { if (model.camera_available) model.noclip_unavailable = "Local skater physics could not be read."; model.forward_velocity_unavailable = "Local skater physics could not be read."; model.up_velocity_unavailable = "Local skater physics could not be read."; + model.offboard_up_velocity_unavailable = model.up_velocity_unavailable; } if (!session_boosts_allowed()) { - model.forward_velocity_available = model.up_velocity_available = false; - model.forward_velocity_unavailable = model.up_velocity_unavailable = "The host has turned off boosts in this session."; + model.forward_velocity_available = model.up_velocity_available = model.offboard_up_velocity_available = false; + model.forward_velocity_unavailable = model.up_velocity_unavailable = model.offboard_up_velocity_unavailable = "The host has turned off boosts in this session."; } if (!model.no_bail_available) { clear_no_bail(); @@ -848,8 +928,15 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli model.forward_velocity_updates = debug.forward_velocity_updates; model.up_velocity_speed = debug.up_velocity_speed; model.up_velocity_updates = debug.up_velocity_updates; + model.offboard_up_velocity_speed = debug.offboard_up_velocity_speed; + model.offboard_up_velocity_updates = debug.offboard_up_velocity_updates; + state.offboard_boost_core.store(debug.offboard_up_velocity.valid ? debug.offboard_up_velocity.core : 0, + std::memory_order_release); return model; } +bool client_free_camera_active() noexcept { + return source_state().free_camera_active.load(std::memory_order_acquire); +} bool restore_client_debug(std::uintptr_t base, std::uintptr_t client, bool camera_phase_observed) { const overlay::DebugRequest request{overlay::DebugAction::restore_debug}; const auto model = on_client_debug_tick(base, client, false, camera_phase_observed, &request); diff --git a/Extension/Skater/client_noclip.cpp b/Extension/Skater/client_noclip.cpp index ff42da69..2ceaaa52 100644 --- a/Extension/Skater/client_noclip.cpp +++ b/Extension/Skater/client_noclip.cpp @@ -3,6 +3,7 @@ #include "no_bail.h" #include "offboard_flight.h" #include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Log/logging.h" #include "Engine/Game/Build/20260929/engine.h" #include "Engine/Game/Build/20260929/no_bail.h" #include "Engine/Game/Build/20260929/offboard_flight.h" @@ -77,6 +78,11 @@ NoclipBodies debug_noclip_bodies(std::uintptr_t base, std::uintptr_t client, std result.seconds = reader.value(result.context, 0x17ec); source_require(std::isfinite(result.seconds) && result.seconds >= 0 && result.seconds <= .1f, "Invalid physics timestep."); result.offboard = reader.pointer(reader.pointer(result.core, 0x3b0)) == base + addr::offboard_flight::offboard_flight_vtable; + // Read the committed native state, not the selector's proposed next state + // (which is remapped by its caller and may be stale between selections). + const auto physics = reader.value(result.context, 0x1414); + result.wipeout = physics == addr::no_bail::wipeout_physics_state || + physics == addr::no_bail::offboard_physics_state; source_require(reader.pointer(board) == base + spawn::board_physics_vtable && reader.pointer(rig) == base + spawn::rig_physics_vtable, "Unsupported board or skeleton physics."); const auto board_parts = reader.pointer(board, 0x20), rig_parts = reader.pointer(rig, 0x20); @@ -104,41 +110,76 @@ void noclip_apply_velocity(std::uintptr_t core) noexcept { state.busy.test_and_set(std::memory_order_acquire)) return; SourceBusyScope scope{state.busy}; auto& debug = state.trial.debug; - const auto apply_boost = [&](InteractiveDebug::VelocityRequest& boost, std::uint64_t& updates, bool up) { + const auto apply_boost = [&](InteractiveDebug::VelocityRequest& boost, std::uint64_t& updates, bool up, bool offboard = false) { if (!boost.valid || boost.core != core) return; try { + if (offboard && boost.applied && GetTickCount64() >= boost.expires) { + boost.valid = false; + state.offboard_boost_core.store(0, std::memory_order_release); + return; + } source_require(GetTickCount64() < boost.expires, "Velocity boost timed out."); const auto bodies = debug_noclip_bodies(state.trial.base, boost.client, boost.entity); source_require(bodies.core == core, "Skater physics was replaced before the velocity boost could apply."); - source_require(!bodies.offboard, "Velocity boosts require the skater to be on the board."); + source_require(offboard ? (boost.applied || bodies.offboard || bodies.wipeout) : !bodies.offboard, + offboard ? "Off-board Up Boost requires walking, falling or gliding." : + "Velocity boosts require the skater to be on the board."); source_require(!debug.noclip && !debug.park_editor, "Velocity boost cancelled while editing or flying."); SourceReader reader; source_require(reader.value(boost.entity, 0x7e0) == 0, "Wait for the current teleport before using velocity boosts."); std::array, 32> velocities{}; std::array flags{}; - for (std::size_t i = 0; i < bodies.parts.size(); ++i) { + const std::size_t first = offboard ? 9 : 0; // Leave the carried/detached board alone. + if (offboard && !boost.applied) { + const auto current_up = reader.value(bodies.parts[9], 0x74); + boost.velocity[1] = std::max(boost.velocity[1], current_up + boost.velocity[1]); + } + for (std::size_t i = first; i < bodies.parts.size(); ++i) { velocities[i] = reader.value>(bodies.parts[i], 0x70); flags[i] = reader.value(bodies.parts[i], 0x60); for (std::size_t axis = 0; axis < 3; ++axis) { - velocities[i][axis] += boost.velocity[axis]; + if (offboard && axis == 1) velocities[i][axis] = std::max(velocities[i][axis], boost.velocity[1]); + else velocities[i][axis] += boost.velocity[axis]; source_require(std::isfinite(velocities[i][axis]) && std::abs(velocities[i][axis]) <= 100000, "Velocity boost produced an invalid physics velocity."); } } reader.verify(); - boost.valid = false; - for (std::size_t i = 0; i < bodies.parts.size(); ++i) { + if (offboard && bodies.offboard) + source_require(ensure_offboard_up_velocity(state.trial.base, core, bodies.context, + bodies.rig_wrapper, boost.velocity[1]), "Off-board boost motion ownership changed."); + for (std::size_t i = first; i < bodies.parts.size(); ++i) { body_write(bodies.parts[i] + 0x70, velocities[i]); body_write(bodies.parts[i] + 0x60, flags[i] | 8u); } - ++updates; - debug.status = up ? "Up velocity added." : "Forward velocity added."; - } catch (const SourceGuard& issue) { boost.valid = false; debug.status = issue.message; } - catch (...) { boost.valid = false; debug.status = "Velocity boost failed during the physics update."; } + if (offboard) { + if (!boost.applied) { + ++updates; + boost.applied = true; + boost.expires = GetTickCount64() + 180; + debug.status = "Off-board up velocity added."; + } + } else { + boost.valid = false; + ++updates; + debug.status = up ? "Up velocity added." : "Forward velocity added."; + } + } catch (const SourceGuard& issue) { + boost.valid = false; + if (offboard) state.offboard_boost_core.store(0, std::memory_order_release); + debug.status = issue.message; + if (offboard) logging::write(logging::Level::warning, logging::Channel::skater, issue.message); + } catch (...) { + boost.valid = false; + if (offboard) state.offboard_boost_core.store(0, std::memory_order_release); + debug.status = "Velocity boost failed during the physics update."; + if (offboard) logging::write(logging::Level::warning, logging::Channel::skater, debug.status); + } }; apply_boost(debug.forward_velocity, debug.forward_velocity_updates, false); apply_boost(debug.up_velocity, debug.up_velocity_updates, true); + apply_boost(debug.offboard_up_velocity, debug.offboard_up_velocity_updates, true, true); const auto& request = debug.noclip_velocity; if (!debug.noclip || !request.valid || request.core != core) return; try { @@ -318,6 +359,36 @@ bool noclip_motion_target(std::uintptr_t rig, std::uintptr_t context, state.busy.test_and_set(std::memory_order_acquire)) return false; SourceBusyScope scope{state.busy}; auto& debug = state.trial.debug; + auto& boost = debug.offboard_up_velocity; + if (!debug.noclip && boost.valid && boost.applied) { + try { + SourceReader reader; + if (reader.pointer(boost.core, 0x438) != rig || reader.pointer(boost.core, 0x3c0) != context) + return false; + if (GetTickCount64() >= boost.expires) return false; + const auto bodies = debug_noclip_bodies(state.trial.base, boost.client, boost.entity); + source_require(bodies.core == boost.core && bodies.rig_wrapper == rig && bodies.context == context, + "Off-board boost skater changed."); + source_require(!debug.park_editor && reader.value(boost.entity, 0x7e0) == 0, + "Off-board boost interrupted by editing or teleporting."); + source_require(reader.raw(reinterpret_cast(supplied), target.data(), sizeof(target)) && + valid_flight_transform(target), "Invalid off-board motion target."); + // Native off-board movement drives the skeleton toward this target. + // Body velocity alone is undone by that drive on the same step. + // Lead only Y for the launch; keep native X/Z, rotation and collision flags. + target[13] = std::max(target[13], bodies.root[1] + boost.velocity[1] * bodies.seconds); + source_require(valid_flight_transform(target), "Off-board boost target is out of bounds."); + reader.verify(); + if (bodies.offboard) + source_require(ensure_offboard_up_velocity(state.trial.base, bodies.core, context, rig, boost.velocity[1]), + "Off-board boost motion ownership changed."); + return bodies.seconds > 0; + } catch (const SourceGuard& issue) { debug.status = issue.message; } + catch (...) { debug.status = "Off-board boost motion failed."; } + boost.valid = false; + state.offboard_boost_core.store(0, std::memory_order_release); + return false; + } const auto& request = debug.noclip_velocity; if (!debug.noclip || !request.valid) return false; try { @@ -364,6 +435,12 @@ void noclip_skater_motion(std::uintptr_t rig, std::uintptr_t context, alignas(16) std::array target{}; const auto* motion = noclip_motion_target(rig, context, supplied, target) ? &target : supplied; if (original) original(rig, context, motion, flags); + if (source_state().offboard_boost_core.load(std::memory_order_acquire)) { + std::uintptr_t core{}; + if (memory::peek(rig + 0x4630, core) && + core == source_state().offboard_boost_core.load(std::memory_order_acquire)) + noclip_apply_velocity(core); + } } } } diff --git a/Extension/Skater/client_source_spawn.h b/Extension/Skater/client_source_spawn.h index 4941f74b..186323b7 100644 --- a/Extension/Skater/client_source_spawn.h +++ b/Extension/Skater/client_source_spawn.h @@ -30,6 +30,7 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli bool can_control, bool camera_phase_observed, const overlay::DebugRequest* request = nullptr, const overlay::FlightInput* flight_input = nullptr); bool restore_client_debug(std::uintptr_t base, std::uintptr_t client, bool camera_phase_observed); +bool client_free_camera_active() noexcept; // Native camera lease for the party's Spectate action. Null restores only the // camera acquired by this function; it never changes another debug camera. A positive // `fov` is applied to the spectate camera while it is held; its own comes back after. diff --git a/Extension/Skater/client_source_spawn_internal.h b/Extension/Skater/client_source_spawn_internal.h index a1efea73..918e533a 100644 --- a/Extension/Skater/client_source_spawn_internal.h +++ b/Extension/Skater/client_source_spawn_internal.h @@ -123,14 +123,18 @@ struct InteractiveDebug { ULONGLONG expires{}; std::array velocity{}; bool valid{}; + bool applied{}; } noclip_velocity; VelocityRequest forward_velocity; VelocityRequest up_velocity; + VelocityRequest offboard_up_velocity; std::uint64_t noclip_velocity_updates{}, noclip_motion_updates{}; std::uint64_t forward_velocity_updates{}; float forward_velocity_speed = 20.0f; std::uint64_t up_velocity_updates{}; + std::uint64_t offboard_up_velocity_updates{}; float up_velocity_speed = 20.0f; + float offboard_up_velocity_speed = 20.0f; float noclip_altitude{}; bool noclip_altitude_valid{}, noclip_altitude_offboard{}; // Player choices kept in the local profile (see load_saved_debug): @@ -158,6 +162,8 @@ struct SourceState { std::atomic_flag busy = ATOMIC_FLAG_INIT; bool velocity_guard_attempted{}; // Protected by initialization_mutex. std::atomic velocity_guard_active{}; + std::atomic free_camera_active{}; + std::atomic offboard_boost_core{}; std::atomic velocity_update_original{}; std::atomic motion_original{}; SourceTrial trial; @@ -172,6 +178,7 @@ struct NoclipBodies { std::uintptr_t core{}, context{}, rig_wrapper{}; float seconds{}, board_height{}; bool offboard{}; + bool wipeout{}; // Spread-eagle/torpedo use the native wipeout physics path. std::array root{}; // Entity root: the camera target and the idle motion target. std::array parts{}; // Board 0..8, native skeleton velocity parts 1..23. }; diff --git a/Extension/Skater/console_commands.cpp b/Extension/Skater/console_commands.cpp index 5a7fb97c..141406e5 100644 --- a/Extension/Skater/console_commands.cpp +++ b/Extension/Skater/console_commands.cpp @@ -135,11 +135,67 @@ void register_movement_commands(Commands ®istry) { request_debug(Debug::add_forward_velocity); }; registry.add(std::move(boost)); - auto mask = argument("controller_mask", Type::unsigned_integer); + auto mask = argument("button_or_key", Type::unsigned_integer); mask.minimum = 0; mask.maximum = UINT32_MAX; + auto freecamcontroller = + action("freecam_controller", "Block input and use controller for Freecam", Group::movement, {argument("enabled", Type::boolean)}); + freecamcontroller.inspect = [](const Model &m) { + return boolean_state(m.bindings.available, m.bindings.freecam_controller, "Controller bindings are unavailable."); + }; + freecamcontroller.run = [](const Model &, const Values &args, const Output &out) { + const bool saved = set_local_freecam_controller(std::get(args[0])); + out(saved ? "" : "error: Invalid setting or save failed."); + }; + registry.add(std::move(freecamcontroller)); + + auto freecamcontroller_bind = + action("bind freecamcontroller", "Bind a controller combo or keyboard key to freecam controller toggle; 0 clears the binding", Group::movement, {mask}); + freecamcontroller_bind.inspect = [](const Model &m) { + return State{m.bindings.available, {}, "Controller bindings are unavailable.", {}, false}; + }; + freecamcontroller_bind.run = [](const Model &, const Values &args, const Output &out) { + const bool saved = set_local_freecam_controller_binding(static_cast(std::get(args[0]))); + out(saved ? local_profile_controller_bindings().status : "error: Invalid binding or save failed."); + }; + registry.add(std::move(freecamcontroller_bind)); + + auto tp_to_freecam = + action("tp_to_freecam", "Teleport to freecam position and disable freecam", Group::movement); + tp_to_freecam.inspect = [](const Model &m) { + return boolean_state(m.debug.camera_available && m.debug.camera_position_valid, m.debug.free_camera, + !m.debug.camera_position_valid ? "Camera position unavailable" : "Camera controls unavailable"); + }; + tp_to_freecam.run = [](const Model &m, const Values &, const Output &) { + if (!m.debug.camera_position_valid) return; + teleport_local_skater(m.debug.camera_position); + if (m.debug.free_camera) + request_debug(overlay::DebugAction::set_free_camera, false); + }; + registry.add(std::move(tp_to_freecam)); + + auto tp_to_freecam_bind = + action("bind tptofreecam", "Bind a controller combo or keyboard key to TP to Freecam; 0 clears the binding", Group::movement, {mask}); + tp_to_freecam_bind.inspect = [](const Model &m) { + return State{m.bindings.available, {}, "Controller bindings are unavailable.", {}, false}; + }; + tp_to_freecam_bind.run = [](const Model &, const Values &args, const Output &out) { + const bool saved = set_local_tp_to_freecam_binding(static_cast(std::get(args[0]))); + out(saved ? local_profile_controller_bindings().status : "error: Invalid binding or save failed."); + }; + registry.add(std::move(tp_to_freecam_bind)); + auto freecam_bind = + action("bind freecam", "Bind a controller combo or keyboard key to freecam; 0 clears the binding", Group::movement, {mask}); + freecam_bind.inspect = [](const Model &m) { + return State{m.bindings.available, {}, "Controller bindings are unavailable.", {}, false}; + }; + freecam_bind.run = [](const Model &, const Values &args, const Output &out) { + const bool saved = set_local_freecam_binding(static_cast(std::get(args[0]))); + out(saved ? local_profile_controller_bindings().status : "error: Invalid binding or save failed."); + }; + registry.add(std::move(freecam_bind)); auto bind = - action("bind noclip", "Bind controller buttons to noclip; 0 clears the binding", Group::movement, {mask}); + action("bind noclip", "Bind a controller combo or keyboard key to noclip; 0 clears the binding", Group::movement, {mask}); bind.inspect = [](const Model &m) { return State{m.bindings.available, {}, "Controller bindings are unavailable.", {}, false}; }; @@ -149,7 +205,7 @@ void register_movement_commands(Commands ®istry) { }; registry.add(std::move(bind)); auto boost_bind = - action("bind forwardvelocity", "Bind controller buttons to Forward Boost; 0 clears the binding", Group::movement, {mask}); + action("bind forwardvelocity", "Bind a controller combo or keyboard key to Forward Boost; 0 clears the binding", Group::movement, {mask}); boost_bind.inspect = [](const Model &m) { return State{m.bindings.available, {}, "Controller bindings are unavailable.", {}, false}; }; @@ -159,7 +215,7 @@ void register_movement_commands(Commands ®istry) { }; registry.add(std::move(boost_bind)); auto up_bind = - action("bind upvelocity", "Bind controller buttons to Up Boost; 0 clears the binding", Group::movement, {mask}); + action("bind upvelocity", "Bind a controller combo or keyboard key to Up Boost; 0 clears the binding", Group::movement, {mask}); up_bind.inspect = [](const Model &m) { return State{m.bindings.available, {}, "Controller bindings are unavailable.", {}, false}; }; @@ -168,6 +224,16 @@ void register_movement_commands(Commands ®istry) { out(saved ? local_profile_controller_bindings().status : "error: Invalid binding or save failed."); }; registry.add(std::move(up_bind)); + auto offboard_up_bind = + action("bind offboardupvelocity", "Bind a controller combo or keyboard key to Off-board Up Boost; 0 clears the binding", Group::movement, {mask}); + offboard_up_bind.inspect = [](const Model &m) { + return State{m.bindings.available, {}, "Controller bindings are unavailable.", {}, false}; + }; + offboard_up_bind.run = [](const Model &, const Values &args, const Output &out) { + const bool saved = set_local_offboard_up_velocity_binding(static_cast(std::get(args[0]))); + out(saved ? local_profile_controller_bindings().status : "error: Invalid binding or save failed."); + }; + registry.add(std::move(offboard_up_bind)); } void register_ai_commands(Commands ®istry) { const auto ready = [](const Model &m) { diff --git a/Extension/Skater/offboard_flight.cpp b/Extension/Skater/offboard_flight.cpp index 7ce03065..8a8cda7c 100644 --- a/Extension/Skater/offboard_flight.cpp +++ b/Extension/Skater/offboard_flight.cpp @@ -4,6 +4,7 @@ #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/no_bail.h" #include "Engine/Game/Build/20260929/offboard_flight.h" +#include #include namespace dingosdk { @@ -33,6 +34,11 @@ bool offboard_flight_compatible(std::uintptr_t base) noexcept { if (link(base + state.vtable_rva, 0x18) != base + state.update_rva || !memory::read(base + state.velocity_write.rva, bytes) || bytes != state.velocity_write.bytes) return false; } + for (const auto& state : offboard_ragdoll_states) + if (link(base + state[1], 0x18) != base + state[2]) return false; + std::array ragdoll_bytes{}; + if (!memory::read(base + ragdoll_velocity_write.rva, ragdoll_bytes) || + ragdoll_bytes != ragdoll_velocity_write.bytes) return false; return true; } bool scale_offboard_up_velocity(std::uintptr_t base, std::uintptr_t core, std::uintptr_t context, @@ -55,6 +61,48 @@ bool scale_offboard_up_velocity(std::uintptr_t base, std::uintptr_t core, std::u } return false; } +bool ensure_offboard_up_velocity(std::uintptr_t base, std::uintptr_t core, std::uintptr_t context, + std::uintptr_t rig, float minimum_up_speed) noexcept { + FlightLastError error; + if (!object(base) || !object(context) || !object(rig) || !std::isfinite(minimum_up_speed) || + minimum_up_speed <= 0 || minimum_up_speed > 100000 || + link(core) != base + addr::no_bail::bail_core_vtable || link(core, 0x3c0) != context || link(core, 0x438) != rig) + return false; + const auto parent = link(core, 0x3b0); + if (link(parent) != base + offboard_flight_vtable || link(parent, 8) != context || + link(parent, 0x10) != link(core, 0x430) || link(parent, 0x18) != rig || + link(rig) != context || link(rig, 0x4630) != core) return false; + const auto active = link(parent, 0x48); + for (const auto& state : offboard_flight_states) { + if (active != parent + state.offset) continue; + std::array velocity{}; + if (link(active) != base + state.vtable_rva || !memory::read(active + 0x10, velocity)) return false; + velocity[1] = std::max(velocity[1], minimum_up_speed); + return sync_offboard_flight_velocity(base, core, context, rig, velocity); + } + for (const auto& state : offboard_ragdoll_states) { + if (active != parent + state[0]) continue; + if (link(active) != base + state[1]) return false; + float vertical{}; + const auto address = active + 0x14; + if (!memory::read(address, vertical) || !std::isfinite(vertical) || std::abs(vertical) > 100000) + return false; + // Replace a fall with the positive launch speed, rather than adding + // an impulse to a cached negative velocity. Preserve horizontal motion. + const float launched = std::max(vertical, minimum_up_speed); + MEMORY_BASIC_INFORMATION page{}; + if (!VirtualQuery(reinterpret_cast(address), &page, sizeof(page)) || + page.State != MEM_COMMIT || (page.Protect & (PAGE_GUARD | PAGE_NOACCESS)) || + !(page.Protect & (PAGE_READWRITE | PAGE_EXECUTE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_WRITECOPY))) + return false; + SIZE_T written{}; + return WriteProcessMemory(GetCurrentProcess(), reinterpret_cast(address), &launched, + sizeof(launched), &written) && written == sizeof(launched) && + memory::read(address, vertical) && vertical == launched; + } + // Scripted animation/trajectory states have different motion rules. + return true; +} bool sync_offboard_flight_velocity(std::uintptr_t base, std::uintptr_t core, std::uintptr_t context, std::uintptr_t rig, const std::array& velocity) noexcept { FlightLastError error; diff --git a/Extension/Skater/offboard_flight.h b/Extension/Skater/offboard_flight.h index ebbe8b39..389e5e8f 100644 --- a/Extension/Skater/offboard_flight.h +++ b/Extension/Skater/offboard_flight.h @@ -3,9 +3,12 @@ #include namespace dingosdk { +// Keep a short boost's minimum rise speed through native off-board motion updates. +bool ensure_offboard_up_velocity(std::uintptr_t base, std::uintptr_t core, std::uintptr_t context, + std::uintptr_t rig, float minimum_up_speed) noexcept; bool offboard_flight_compatible(std::uintptr_t base) noexcept; -// Only call during a freshly validated local noclip motion update, with live -// input. Synchronize the active falling/ground state, preserving its fourth lane. +// Only call during a freshly validated local motion/physics update. +// Synchronize the active falling/ground state, preserving its fourth lane. bool sync_offboard_flight_velocity(std::uintptr_t base, std::uintptr_t core, std::uintptr_t context, std::uintptr_t rig, const std::array& velocity) noexcept; // Multiplies the upward part of the active off-board state's velocity, once (a jump that has diff --git a/Extension/UI/Overlay/input_capture.cpp b/Extension/UI/Overlay/input_capture.cpp index 82c2e103..d5c9f703 100644 --- a/Extension/UI/Overlay/input_capture.cpp +++ b/Extension/UI/Overlay/input_capture.cpp @@ -2,6 +2,8 @@ #include "overlay_internal.h" #include "input_capture.h" #include "held_input.h" +#include "Engine/Game/Input/playstation_report.h" +#include #include #include #include @@ -19,7 +21,7 @@ thread_local unsigned overlay_input_access = 0; thread_local HRAWINPUT noted_raw_input = nullptr; bool block_polled_input() { const auto error = GetLastError(); - const bool capture = !overlay_input_access && owns_menu_cursor(state()); + const bool capture = !overlay_input_access && (owns_menu_cursor(state()) || state().freecam_controller_active.load(std::memory_order_relaxed)); SetLastError(error); return capture; } @@ -28,6 +30,11 @@ void note_input_record(const RAWINPUT& record) noexcept; HeldInput& held_input() { static auto* value = new HeldInput; return *value; } struct InputCaptureHooks { + Hook hid_attributes, read_file, overlapped, overlapped_ex, completion, completion_ex, close_handle; + struct HidRead { HANDLE file; void* buffer; DWORD capacity; PlayStationPad kind; }; + std::mutex hid_mutex; + std::map hid_devices; + std::map hid_reads; Hook register_raw; Hook async_key, key, keyboard, raw_data, raw_buffer, capture, release_capture, cursor, show_cursor, physical_cursor, cursor_position; std::array xinput_state, xinput_extended, xinput_keystroke; @@ -43,6 +50,139 @@ struct InputCaptureHooks { }; InputCaptureHooks& input_hooks() { static auto* hooks = new InputCaptureHooks; return *hooks; } +BOOLEAN __stdcall captured_hid_attributes(HANDLE file, PHIDD_ATTRIBUTES attributes) { + const auto result = original(input_hooks().hid_attributes)(file, attributes); + const auto error = GetLastError(); + if (result && attributes) { + const auto kind = playstation_pad(attributes->VendorID, attributes->ProductID); + if (kind != PlayStationPad::none) { + std::lock_guard lock(input_hooks().hid_mutex); + input_hooks().hid_devices[file] = kind; + } + } + SetLastError(error); + return result; +} +void neutral_hid(const InputCaptureHooks::HidRead& read, DWORD size) { + if (!block_polled_input() || !read.buffer || size > read.capacity) return; + auto* bytes = static_cast(read.buffer); + if (!parse_playstation_report(read.kind, playstation_bluetooth(read.capacity), bytes, size)) return; + const bool sense_full = read.kind == PlayStationPad::dualsense && + (bytes[0] == 0x31 || (bytes[0] == 1 && !playstation_bluetooth(read.capacity))); + const unsigned offset = bytes[0] == 0x31 ? 2 : bytes[0] == 0x11 ? 3 : 1; + auto* data = bytes + offset; + std::fill_n(data, 4, std::uint8_t{128}); + if (sense_full) { + data[4] = data[5] = 0; // triggers; retain report sequence at +6 + data[7] = 8; data[8] = data[9] = 0; + } else { + data[4] = 8; data[5] = 0; data[6] &= 0xfc; // retain DS4 sequence bits + data[7] = data[8] = 0; + } + // Full Bluetooth reports have a CRC over the 0xA1 input prefix and payload. + // A stale checksum makes the native reader discard the neutral report. + if (size == 78 && (bytes[0] == 0x31 || bytes[0] == 0x11)) { + std::uint32_t crc = 0xffffffffu; + const auto feed = [&](std::uint8_t byte) { + crc ^= byte; + for (unsigned bit = 0; bit < 8; ++bit) crc = (crc >> 1) ^ (0xedb88320u & (0u - (crc & 1u))); + }; + feed(0xa1); + for (DWORD i = 0; i < size - 4; ++i) feed(bytes[i]); + crc = ~crc; + std::memcpy(bytes + size - 4, &crc, sizeof(crc)); + } + static std::atomic reported{}; + if (!reported.exchange(true)) logging::write(logging::Level::info, logging::Channel::input, + "Native PlayStation HID report neutralized; overlay retains its independent input stream."); +} +void complete_hid(OVERLAPPED* request, DWORD bytes, bool success, bool finished) { + auto& h = input_hooks(); + InputCaptureHooks::HidRead read{}; + { + std::lock_guard lock(h.hid_mutex); + const auto found = h.hid_reads.find(request); + if (found == h.hid_reads.end()) return; + read = found->second; + if (finished) h.hid_reads.erase(found); + } + if (success) neutral_hid(read, bytes); +} +BOOL WINAPI captured_read_file(HANDLE file, LPVOID buffer, DWORD size, LPDWORD bytes, LPOVERLAPPED request) { + auto& h = input_hooks(); + InputCaptureHooks::HidRead read{file, buffer, size, PlayStationPad::none}; + if (!overlay_input_access) { + // Some readers inspect a temporary enumeration handle, then open a + // second handle for reports. Identify that handle as well. + bool known{}; + { + std::lock_guard lock(h.hid_mutex); + known = h.hid_devices.contains(file); + } + if (!known && size >= 10 && size <= 512) { + const auto error = GetLastError(); + HIDD_ATTRIBUTES attributes{sizeof(attributes)}; + const auto okay = original(h.hid_attributes)(file, &attributes); + const auto kind = okay ? playstation_pad(attributes.VendorID, attributes.ProductID) : PlayStationPad::none; + { + std::lock_guard lock(h.hid_mutex); + h.hid_devices[file] = kind; + } + SetLastError(error); + } + std::lock_guard lock(h.hid_mutex); + const auto found = h.hid_devices.find(file); + if (found != h.hid_devices.end() && found->second != PlayStationPad::none) { + read.kind = found->second; + if (request) h.hid_reads[request] = read; + } + } + const auto result = original(h.read_file)(file, buffer, size, bytes, request); + const auto error = GetLastError(); + if (read.kind != PlayStationPad::none) { + if (result) neutral_hid(read, bytes ? *bytes : request ? static_cast(request->InternalHigh) : 0); + if (!result && error != ERROR_IO_PENDING && request) complete_hid(request, 0, false, true); + } + SetLastError(error); + return result; +} +BOOL WINAPI captured_overlapped(HANDLE file, LPOVERLAPPED request, LPDWORD bytes, BOOL wait) { + const auto result = original(input_hooks().overlapped)(file, request, bytes, wait); + const auto error = GetLastError(); + complete_hid(request, result && bytes ? *bytes : 0, result != FALSE, result || error != ERROR_IO_INCOMPLETE); + SetLastError(error); return result; +} +BOOL WINAPI captured_overlapped_ex(HANDLE file, LPOVERLAPPED request, LPDWORD bytes, DWORD timeout, BOOL alertable) { + const auto result = original(input_hooks().overlapped_ex)(file, request, bytes, timeout, alertable); + const auto error = GetLastError(); + complete_hid(request, result && bytes ? *bytes : 0, result != FALSE, + result || (error != ERROR_IO_INCOMPLETE && error != WAIT_TIMEOUT && error != WAIT_IO_COMPLETION)); + SetLastError(error); return result; +} +BOOL WINAPI captured_completion(HANDLE port, LPDWORD bytes, PULONG_PTR key, LPOVERLAPPED* request, DWORD timeout) { + const auto result = original(input_hooks().completion)(port, bytes, key, request, timeout); + const auto error = GetLastError(); + if (request && *request) complete_hid(*request, result && bytes ? *bytes : 0, result != FALSE, true); + SetLastError(error); return result; +} +BOOL WINAPI captured_completion_ex(HANDLE port, LPOVERLAPPED_ENTRY entries, ULONG count, PULONG removed, DWORD timeout, BOOL alertable) { + const auto result = original(input_hooks().completion_ex)(port, entries, count, removed, timeout, alertable); + const auto error = GetLastError(); + if (result && entries && removed) for (ULONG i = 0; i < *removed; ++i) + complete_hid(entries[i].lpOverlapped, entries[i].dwNumberOfBytesTransferred, + static_cast(entries[i].Internal) >= 0, true); + SetLastError(error); return result; +} +BOOL WINAPI captured_close_handle(HANDLE file) { + auto& h = input_hooks(); + { + std::lock_guard lock(h.hid_mutex); + if (h.hid_devices.erase(file)) + std::erase_if(h.hid_reads, [&](const auto& entry) { return entry.second.file == file; }); + } + return original(h.close_handle)(file); +} + SHORT WINAPI captured_async_key(int key) { const auto value = original(input_hooks().async_key)(key); return block_polled_input() ? 0 : value; @@ -354,6 +494,7 @@ HRESULT STDMETHODCALLTYPE captured_device_state(void* device, DWORD size, LPVOID DWORD neutral = 0; if (object.dwType & DIDFT_POV) neutral = 0xffffffffu; else if (object.dwType & DIDFT_ABSAXIS) { + neutral = 32767; // Standard DirectInput axis midpoint if range lookup fails. DIPROPRANGE range{}; range.diph = {sizeof(range), sizeof(range.diph), object.dwOfs, DIPH_BYOFFSET}; auto** vtable = *reinterpret_cast(device); @@ -363,6 +504,17 @@ HRESULT STDMETHODCALLTYPE captured_device_state(void* device, DWORD size, LPVOID } else continue; std::memcpy(static_cast(data) + object.dwOfs, &neutral, 4); } + } else if (size == sizeof(DIJOYSTATE) || size == sizeof(DIJOYSTATE2)) { + // A zeroed DirectInput axis is full travel, not the center. Some + // controllers use the standard joystick layout without a format + // record we can inspect; keep those pads neutral while captured. + auto* bytes = static_cast(data); + const DWORD center = 32767; + const DWORD released_pov = 0xffffffffu; + for (std::size_t offset = 0; offset < 8 * sizeof(DWORD); offset += sizeof(DWORD)) + std::memcpy(bytes + offset, ¢er, sizeof(center)); + for (std::size_t offset = 8 * sizeof(DWORD); offset < 12 * sizeof(DWORD); offset += sizeof(DWORD)) + std::memcpy(bytes + offset, &released_pov, sizeof(released_pov)); } } return result; @@ -523,6 +675,19 @@ void sync_raw_mouse_registration(bool overlay_owns_pointer) { bool install_input_capture() { auto& h = input_hooks(); + const auto hid = LoadLibraryExW(L"hid.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32); + const auto kernel = GetModuleHandleW(L"kernel32.dll"); + if (!hid || !install(h.hid_attributes, reinterpret_cast(GetProcAddress(hid, "HidD_GetAttributes")), + reinterpret_cast(captured_hid_attributes))) return false; + const auto kernel_hook = [&](Hook& hook, const char* name, void* detour) { + return install(hook, reinterpret_cast(GetProcAddress(kernel, name)), detour); + }; + if (!kernel_hook(h.read_file, "ReadFile", reinterpret_cast(captured_read_file)) || + !kernel_hook(h.overlapped, "GetOverlappedResult", reinterpret_cast(captured_overlapped)) || + !kernel_hook(h.overlapped_ex, "GetOverlappedResultEx", reinterpret_cast(captured_overlapped_ex)) || + !kernel_hook(h.completion, "GetQueuedCompletionStatus", reinterpret_cast(captured_completion)) || + !kernel_hook(h.completion_ex, "GetQueuedCompletionStatusEx", reinterpret_cast(captured_completion_ex)) || + !kernel_hook(h.close_handle, "CloseHandle", reinterpret_cast(captured_close_handle))) return false; const auto user32 = GetModuleHandleW(L"user32.dll"); const auto add = [&](Hook& hook, const char* name, void* detour) { const bool okay = install(hook, reinterpret_cast(GetProcAddress(user32, name)), detour); @@ -575,7 +740,7 @@ bool install_input_capture() { if (!install(h.direct_create, reinterpret_cast(GetProcAddress(direct, "DirectInput8Create")), reinterpret_cast(captured_direct_create))) return false; } - dingosdk::logging::write(dingosdk::logging::Level::info, dingosdk::logging::Channel::input, "Menu input capture installed: keyboard, raw input, XInput, DirectInput and cursor ownership."); + dingosdk::logging::write(dingosdk::logging::Level::info, dingosdk::logging::Channel::input, "Menu input capture installed: keyboard, raw input, XInput, DirectInput, PlayStation HID and cursor ownership."); return true; } diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 16a9d523..85f2856f 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -52,6 +52,8 @@ enum class DebugAction { set_park_editor, add_up_velocity, set_up_velocity_speed, + add_offboard_up_velocity, + set_offboard_up_velocity_speed, set_first_person, set_first_person_fov, set_first_person_spring, @@ -100,20 +102,24 @@ struct DebugModel { bool noclip = false, noclip_available = false; bool forward_velocity_available = false; bool up_velocity_available = false; + bool offboard_up_velocity_available = false; std::string camera_unavailable = "Waiting for local controls."; std::string noclip_unavailable = "Waiting for local controls."; std::string forward_velocity_unavailable = "Waiting for local controls."; std::string up_velocity_unavailable = "Waiting for local controls."; + std::string offboard_up_velocity_unavailable = "Waiting for local controls."; bool no_bail = false, no_bail_available = false, no_bail_active = false; std::uint64_t noclip_velocity_updates = 0, noclip_motion_updates = 0; std::uint64_t forward_velocity_updates = 0; std::uint64_t up_velocity_updates = 0; + std::uint64_t offboard_up_velocity_updates = 0; bool game_ui_hidden = false; bool settings_owned = false; // ReSkate free-flight speed in world units per second. float camera_speed = 15.0f; float forward_velocity_speed = 20.0f; float up_velocity_speed = 20.0f; + float offboard_up_velocity_speed = 20.0f; bool camera_position_valid = false; bool skater_position_valid = false; std::array camera_position{}; @@ -391,6 +397,7 @@ extern "C" __declspec(dllexport) void DingoSDKOverlayGetStatus(dingosdk::overlay // Poll only on the client update thread. Returns no input while either overlay // surface is open, the game is unfocused, or the overlay has stopped. extern "C" void DingoSDKOverlayReadFlightInput(dingosdk::overlay::FlightInput* input, bool flight_active, bool player_flight = false); +extern "C" void DingoSDKOverlaySetFreecamInputCapture(bool active); // allow_menu is used only by the Binds page while recording. Focus and lifetime // gates still apply. Gameplay callers leave it false. extern "C" void DingoSDKOverlayReadControllerInput(dingosdk::ControllerInput* input, bool allow_menu = false); diff --git a/Extension/UI/Overlay/overlay_input.cpp b/Extension/UI/Overlay/overlay_input.cpp index ebcf8429..95ee5c3f 100644 --- a/Extension/UI/Overlay/overlay_input.cpp +++ b/Extension/UI/Overlay/overlay_input.cpp @@ -241,7 +241,9 @@ LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { } } const bool capture = owns_pointer(); - if (capture && is_input(message)) { + const bool freecam_capture = s.freecam_controller_active.load(std::memory_order_relaxed); + if (freecam_capture) release_game_buttons(window, previous); + if ((capture || freecam_capture) && is_input(message)) { // Foreground raw-input packets still need DefWindowProc cleanup. return message == WM_INPUT ? DefWindowProcW(window, message, wp, lp) : 0; } @@ -390,11 +392,27 @@ dingosdk::overlay::FlightInput read_player_flight_controller() { const auto sample = read_controller_sample(); if (!sample.device) return {}; const auto& pad = sample.pad; - return dingosdk::controller_flight_input(pad.sThumbLX, pad.sThumbLY, pad.bLeftTrigger, pad.bRightTrigger, + auto input = dingosdk::controller_flight_input(pad.sThumbLX, pad.sThumbLY, pad.bLeftTrigger, pad.bRightTrigger, (pad.wButtons & XINPUT_GAMEPAD_LEFT_THUMB) != 0); + const float rx = static_cast(pad.sThumbRX), ry = static_cast(pad.sThumbRY); + const float magnitude = std::sqrt(rx * rx + ry * ry); + constexpr float deadzone = 8689.0f; + if (magnitude > deadzone) { + const float scale = std::clamp((magnitude - deadzone) / (32767.0f - deadzone), 0.0f, 1.0f) / magnitude; + input.look_x = rx * scale * 25.0f; + input.look_y = ry * scale * -25.0f; + } + return input; } } +extern "C" void DingoSDKOverlaySetFreecamInputCapture(bool active) { + const bool previous = state().freecam_controller_active.exchange(active, std::memory_order_relaxed); + if (previous != active) + dingosdk::logging::printf(dingosdk::logging::Level::info, dingosdk::logging::Channel::input, + "Freecam controller input capture %s.", active ? "enabled" : "disabled"); +} + extern "C" void DingoSDKOverlayReadControllerInput(dingosdk::ControllerInput* output, bool allow_menu) { if (!output) return; struct PreserveError { DWORD value = GetLastError(); ~PreserveError() { SetLastError(value); } } preserve_error; @@ -403,6 +421,15 @@ extern "C" void DingoSDKOverlayReadControllerInput(dingosdk::ControllerInput* ou const HWND window = s.window.load(); if (!window || s.stop.load() || s.failed.load() || (!allow_menu && interactive_visible(s)) || !game_window_foreground(window)) return; + // Read through the overlay's bypass: freecam capture hides keys from the game. + { + OverlayInputAccess access; + for (unsigned key = 0; key < 256; ++key) { + if (dingosdk::bindable_keyboard_key(key) && (GetAsyncKeyState(key) & 0x8000)) { + output->keys[key / 64] |= std::uint64_t{1} << (key % 64); + } + } + } const auto sample = read_controller_sample(); output->style = sample.style; if (!sample.device) return; @@ -455,6 +482,8 @@ extern "C" void DingoSDKOverlayReadFlightInput(dingosdk::overlay::FlightInput* o output->forward = std::clamp(output->forward + controller.forward, -1.0f, 1.0f); output->up = std::clamp(output->up + controller.up, -1.0f, 1.0f); output->boost = output->boost || controller.boost; + output->look_x = controller.look_x; + output->look_y = controller.look_y; looking = false; return; // Native camera owns look input; do not sample or recenter the cursor. } diff --git a/Extension/UI/Overlay/overlay_internal.h b/Extension/UI/Overlay/overlay_internal.h index 324adc3d..ab7d27f3 100644 --- a/Extension/UI/Overlay/overlay_internal.h +++ b/Extension/UI/Overlay/overlay_internal.h @@ -138,6 +138,7 @@ struct State { std::atomic failed{false}; bool win32_ready = false; bool dx12_ready = false; + std::atomic freecam_controller_active{false}; bool show_on_ready = false; bool force_windowed = false; HANDLE stop_event = nullptr; diff --git a/Extension/UI/Overlay/overlay_render.cpp b/Extension/UI/Overlay/overlay_render.cpp index 894e7423..e344a3c9 100644 --- a/Extension/UI/Overlay/overlay_render.cpp +++ b/Extension/UI/Overlay/overlay_render.cpp @@ -380,7 +380,6 @@ void draw_menu() { // than copying every retained log string a second time each poll. ingest_console_log(std::move(next.console_log)); s.model = std::move(next); - } } // Nothing but the chat: the held model may be old, so it must not reopen the editor. diff --git a/Extension/UI/Overlay/playstation_input.cpp b/Extension/UI/Overlay/playstation_input.cpp index 2c477f18..551a5ad5 100644 --- a/Extension/UI/Overlay/playstation_input.cpp +++ b/Extension/UI/Overlay/playstation_input.cpp @@ -1,4 +1,5 @@ #include "playstation_input.h" +#include "input_capture.h" #include "Engine/Core/Log/logging.h" #include #include @@ -96,6 +97,7 @@ std::unique_ptr open_pad(const std::wstring& path) { // Drains every queued report so the newest one wins. Returns false once the // device is gone. bool drain(Pad& pad) { + OverlayInputAccess access; // This independent HID stream belongs to freecam/menu. for (int reports = 0; reports < 256; ++reports) { if (!pad.pending) { ResetEvent(pad.event); diff --git a/Extension/UI/Overlay/skate_menu_settings.cpp b/Extension/UI/Overlay/skate_menu_settings.cpp index 36479e13..a146353c 100644 --- a/Extension/UI/Overlay/skate_menu_settings.cpp +++ b/Extension/UI/Overlay/skate_menu_settings.cpp @@ -67,12 +67,12 @@ void ui_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) } void binds_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - begin_card(menu, "controller-binds", "CONTROLLER BINDS"); + begin_card(menu, "controller-binds", "ACTION BINDS"); const bool available = model.bindings.available && callbacks.queue_console_command; const auto save = [&](int action, std::uint32_t combo) { std::array result{}; const auto command = std::string("bind ") + - (action == 1 ? "noclip " : action == 2 ? "forwardvelocity " : "upvelocity ") + std::to_string(combo); + (action == 1 ? "freecamcontroller " : action == 2 ? "freecam " : action == 3 ? "noclip " : action == 4 ? "forwardvelocity " : action == 5 ? "upvelocity " : action == 6 ? "tptofreecam " : "offboardupvelocity ") + std::to_string(combo); const bool queued = callbacks.queue_console_command(callbacks.user, command.c_str(), result.data(), result.size()); result.back() = '\0'; feedback(menu, result[0] ? result.data() : queued ? "Saving binding..." : "Could not queue binding."); @@ -83,7 +83,7 @@ void binds_page(SkateMenu& menu, const Model& model, const CallbacksV3& callback if (!available || ImGui::GetTime() >= menu.bind_capture_until) { menu.recording_bind = 0; feedback(menu, "Recording cancelled. Your binding is unchanged."); - } else if (const auto combo = menu.bind_capture.update(controller)) { + } else if (const auto combo = menu.bind_capture.update(controller, true)) { const auto action = menu.recording_bind; menu.recording_bind = 0; save(action, *combo); @@ -92,7 +92,7 @@ void binds_page(SkateMenu& menu, const Model& model, const CallbacksV3& callback ImGui::BeginDisabled(!available); if (ImGui::BeginTable("controller-binds", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { ImGui::TableSetupColumn("Action", ImGuiTableColumnFlags_WidthFixed, px(130)); - ImGui::TableSetupColumn("Controller combo", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Controller combo / key", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("##controls", ImGuiTableColumnFlags_WidthFixed, px(170)); ImGui::TableHeadersRow(); const auto row = [&](int action, const char* name, std::uint32_t combo) { @@ -120,19 +120,22 @@ void binds_page(SkateMenu& menu, const Model& model, const CallbacksV3& callback } ImGui::PopID(); }; - row(1, "Noclip", model.bindings.noclip_combo); - row(2, "Forward Boost", model.bindings.forward_velocity_combo); - row(3, "Up Boost", model.bindings.up_velocity_combo); + row(1, "Freecam Controller", model.bindings.freecam_controller_combo); + row(2, "Freecam", model.bindings.freecam_combo); + row(3, "Noclip", model.bindings.noclip_combo); + row(4, "Forward Boost", model.bindings.forward_velocity_combo); + row(5, "Up Boost", model.bindings.up_velocity_combo); + row(6, "TP to Freecam", model.bindings.tp_to_freecam_combo); + row(7, "Off-board Up Boost", model.bindings.offboard_up_velocity_combo); ImGui::EndTable(); } ImGui::EndDisabled(); if (menu.recording_bind) { - warn(!controller.available ? "Connect a controller and keep the game focused to record. PlayStation pads work directly or through DS4Windows / Steam Input." : - !menu.bind_capture.ready ? "Release all controller buttons first." : - "Hold the buttons you want together, then release them to save."); + warn(!menu.bind_capture.ready ? "Release controller buttons and keyboard keys first." : + "Hold up to five keys together, then release them. A-Z, 0-9, Space, F1-F12, Ctrl, Shift and Alt work; controller combos work too."); } else { - note(("Record a button or combo, such as " + controller_combo_label(0x300, controller.style) + - ". Noclip toggles; Forward Boost and Up Boost add their velocity once per press. Use a different combo for each.").c_str()); + note(("Record a key, keyboard chord or controller combo, such as Ctrl + F5 or " + controller_combo_label(0x300, controller.style) + + ". Freecam, Freecam Controller, and Noclip toggle; boosts add velocity once per press. Off-board Up Boost also works while falling or gliding; release and press again to repeat. On-board and off-board boosts can share a combo. Give toggles different combos.").c_str()); note("Saved to your profile."); if (!model.bindings.status.empty()) note(model.bindings.status.c_str()); if (!model.bindings.available) warn("Waiting for the local profile."); diff --git a/Extension/UI/Overlay/skate_menu_skater.cpp b/Extension/UI/Overlay/skate_menu_skater.cpp index 6c6e7d16..736ec69e 100644 --- a/Extension/UI/Overlay/skate_menu_skater.cpp +++ b/Extension/UI/Overlay/skate_menu_skater.cpp @@ -18,6 +18,19 @@ void camera_controls(SkateMenu& menu, const Model& model, const CallbacksV3& cal if (toggle_row(menu, "Freecam", "Detach the camera and explore.", flight, debug.available && debug.camera_available && callbacks.queue_debug)) debug_request(menu, callbacks, {DebugAction::set_free_camera, flight}); + bool freecam_controller = model.bindings.freecam_controller; + if (toggle_row(menu, "Block Input(Enable Controller Support)", "Block player input and use the controller for the Freecam.", freecam_controller, + model.bindings.available && callbacks.queue_debug)) { + std::array result{}; + callbacks.queue_console_command(callbacks.user, freecam_controller ? "freecam_controller true" : "freecam_controller false", result.data(), result.size()); + } + field(menu, "Teleport to Freecam"); + ImGui::BeginDisabled(!debug.free_camera || !debug.camera_position_valid || !callbacks.queue_console_command); + if (ImGui::Button("Teleport", ImVec2(-1, 0))) { + std::array result{}; + callbacks.queue_console_command(callbacks.user, "tp_to_freecam", result.data(), result.size()); + } + ImGui::EndDisabled(); { field(menu, "Field of view"); const bool custom = debug.free_camera_fov > 0; @@ -126,6 +139,10 @@ void movement_controls(SkateMenu& menu, const Model& model, const CallbacksV3& c float up_velocity = debug.up_velocity_speed; if (ImGui::SliderFloat("##up-velocity", &up_velocity, 1.0f, 25.0f, "+%.1f", ImGuiSliderFlags_AlwaysClamp)) debug_request(menu, callbacks, {DebugAction::set_up_velocity_speed, false, up_velocity}); + field(menu, "Off-board up boost"); + float offboard_up_velocity = debug.offboard_up_velocity_speed; + if (ImGui::SliderFloat("##offboard-up-velocity", &offboard_up_velocity, 1.0f, 25.0f, "+%.1f", ImGuiSliderFlags_AlwaysClamp)) + debug_request(menu, callbacks, {DebugAction::set_offboard_up_velocity_speed, false, offboard_up_velocity}); ImGui::EndDisabled(); note("Controller: left stick moves, right stick looks, RT / LT rise and fall, click the left stick to boost."); note("Keyboard: WASD / Q E, Shift to boost. Close the menu to fly."); diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 7d3f17a7..ba3a324a 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -375,18 +375,57 @@ void update_model(std::uintptr_t client, TickState& frame) { const auto camera_phase_ready = [&] { return camera_phase_issue() == nullptr; }; std::optional debug_request; bool debug_busy{}, controller_busy{}; - std::uint32_t noclip_combo{}, forward_velocity_combo{}, up_velocity_combo{}; + std::uint32_t freecam_controller_combo{}, freecam_combo{}, tp_to_freecam_combo{}, noclip_combo{}, forward_velocity_combo{}, up_velocity_combo{}, offboard_up_velocity_combo{}; + bool freecam_controller = dingosdk::local_freecam_controller(); { std::lock_guard lock(r.mutex); debug_busy = r.requests.loading(); controller_busy = !r.requests.idle(); + freecam_controller_combo = r.model.bindings.available ? r.model.bindings.freecam_controller_combo : 0; + freecam_combo = r.model.bindings.available ? r.model.bindings.freecam_combo : 0; + tp_to_freecam_combo = r.model.bindings.available ? r.model.bindings.tp_to_freecam_combo : 0; noclip_combo = r.model.bindings.available ? r.model.bindings.noclip_combo : 0; forward_velocity_combo = r.model.bindings.available ? r.model.bindings.forward_velocity_combo : 0; up_velocity_combo = r.model.bindings.available ? r.model.bindings.up_velocity_combo : 0; + offboard_up_velocity_combo = r.model.bindings.available ? r.model.bindings.offboard_up_velocity_combo : 0; debug_request = r.requests.take(GetCurrentThreadId()); } + // Capture belongs to the freecam state itself. Keep it active across brief + // request/loading phases instead of opening a path back to player input. + DingoSDKOverlaySetFreecamInputCapture(dingosdk::client_free_camera_active() && freecam_controller); dingosdk::ControllerInput controller; - if (noclip_combo || forward_velocity_combo || up_velocity_combo) DingoSDKOverlayReadControllerInput(&controller); + if (freecam_controller_combo || freecam_combo || tp_to_freecam_combo || noclip_combo || forward_velocity_combo || up_velocity_combo || offboard_up_velocity_combo) DingoSDKOverlayReadControllerInput(&controller); + + if (r.freecam_controller_bind_latch.update(freecam_controller_combo, controller, r.observer_failed || controller_busy || !r.debug_model.free_camera)) { + if (dingosdk::set_local_freecam_controller(!freecam_controller)) + freecam_controller = !freecam_controller; + DingoSDKOverlaySetFreecamInputCapture(dingosdk::client_free_camera_active() && freecam_controller); + record("{\"event\":\"controller_binding_triggered\",\"action\":\"freecam_controller\"}"); + } + + if (r.tp_to_freecam_bind_latch.update(tp_to_freecam_combo, controller, r.observer_failed || controller_busy || !r.debug_model.free_camera || !r.debug_model.camera_position_valid || debug_request.has_value())) { + if (dingosdk::teleport_local_skater(r.debug_model.camera_position)) { + std::lock_guard lock(r.mutex); + const dingosdk::overlay::DebugRequest request{dingosdk::overlay::DebugAction::set_free_camera, false}; + if (r.requests.enqueue(request, request_context(r), GetCurrentThreadId())) + debug_request = r.requests.take(GetCurrentThreadId()); + } + record("{\"event\":\"controller_binding_triggered\",\"action\":\"tp_to_freecam\"}"); + } + + const bool freecam_bind_blocked = r.observer_failed || controller_busy || debug_request.has_value() || + (state != 13 && state != 21) || !r.debug_model.camera_available || + (freecam_combo && !camera_phase_ready()); + if (r.freecam_bind_latch.update(freecam_combo, controller, freecam_bind_blocked)) { + { + std::lock_guard lock(r.mutex); + const dingosdk::overlay::DebugRequest request{dingosdk::overlay::DebugAction::set_free_camera, !r.debug_model.free_camera}; + if (r.requests.enqueue(request, request_context(r), GetCurrentThreadId())) + debug_request = r.requests.take(GetCurrentThreadId()); + } + record("{\"event\":\"controller_binding_triggered\",\"action\":\"freecam\"}"); + } + const bool bind_blocked = r.observer_failed || controller_busy || debug_request.has_value() || (state != 13 && state != 21) || !r.debug_model.noclip_available || (noclip_combo && !camera_phase_ready()); @@ -401,6 +440,8 @@ void update_model(std::uintptr_t client, TickState& frame) { record("{\"event\":\"controller_binding_triggered\",\"action\":\"noclip\"}"); } const bool forward_velocity_blocked = r.observer_failed || controller_busy || debug_request.has_value() || + (r.debug_model.offboard_up_velocity_available && forward_velocity_combo == offboard_up_velocity_combo) || + (r.debug_model.free_camera && freecam_controller) || (state != 13 && state != 21) || !r.debug_model.forward_velocity_available || (forward_velocity_combo && forward_velocity_combo == noclip_combo); if (r.forward_velocity_bind_latch.update(forward_velocity_combo, controller, forward_velocity_blocked)) { @@ -413,6 +454,8 @@ void update_model(std::uintptr_t client, TickState& frame) { record("{\"event\":\"controller_binding_triggered\",\"action\":\"forward_velocity\"}"); } const bool up_velocity_blocked = r.observer_failed || controller_busy || debug_request.has_value() || + (r.debug_model.offboard_up_velocity_available && up_velocity_combo == offboard_up_velocity_combo) || + (r.debug_model.free_camera && freecam_controller) || (state != 13 && state != 21) || !r.debug_model.up_velocity_available || (up_velocity_combo && (up_velocity_combo == noclip_combo || up_velocity_combo == forward_velocity_combo)); if (r.up_velocity_bind_latch.update(up_velocity_combo, controller, up_velocity_blocked)) { @@ -423,6 +466,19 @@ void update_model(std::uintptr_t client, TickState& frame) { record("{\"event\":\"controller_binding_triggered\",\"action\":\"up_velocity\"}"); } } + + const bool offboard_up_velocity_blocked = r.observer_failed || controller_busy || debug_request.has_value() || + (r.debug_model.free_camera && freecam_controller) || + (state != 13 && state != 21) || !r.debug_model.offboard_up_velocity_available || + (offboard_up_velocity_combo && offboard_up_velocity_combo == noclip_combo); + if (r.offboard_up_velocity_bind_latch.update(offboard_up_velocity_combo, controller, offboard_up_velocity_blocked)) { + std::lock_guard lock(r.mutex); + const dingosdk::overlay::DebugRequest request{dingosdk::overlay::DebugAction::add_offboard_up_velocity}; + if (r.requests.enqueue(request, request_context(r), GetCurrentThreadId())) { + debug_request = r.requests.take(GetCurrentThreadId()); + record("{\"event\":\"controller_binding_triggered\",\"action\":\"offboard_up_velocity\"}"); + } + } if (debug_request || r.debug_model.free_camera || r.debug_model.first_person || r.debug_model.noclip || now >= r.next_debug) { ScheduledWorkScope work{r, debug_request.has_value()}; @@ -430,11 +486,13 @@ void update_model(std::uintptr_t client, TickState& frame) { const bool debug_ready = !r.observer_failed && !debug_busy && (state == 13 || state == 21) && native_context_ready(); dingosdk::overlay::FlightInput flight_input; DingoSDKOverlayReadFlightInput(&flight_input, (r.debug_model.free_camera || r.debug_model.noclip) && debug_ready, - r.debug_model.noclip); + r.debug_model.noclip || (r.debug_model.free_camera && freecam_controller)); const auto phase_issue = camera_phase_issue(); auto debug = dingosdk::on_client_debug_tick(r.base, client, debug_ready, phase_issue == nullptr, debug_request ? &*debug_request : nullptr, &flight_input); + // Input ownership must follow the live camera even with every overlay closed. + DingoSDKOverlaySetFreecamInputCapture(dingosdk::client_free_camera_active() && freecam_controller); // A debug action, or a mode the tick ended by itself (host policy, lost // camera), can have replaced the native camera: validate it again. if (debug_request || debug.free_camera != r.debug_model.free_camera || @@ -470,6 +528,8 @@ void update_model(std::uintptr_t client, TickState& frame) { record("{\"event\":\"forward_velocity_applied\",\"count\":" + std::to_string(debug.forward_velocity_updates) + "}"); if (debug.up_velocity_updates != r.debug_model.up_velocity_updates) record("{\"event\":\"up_velocity_applied\",\"count\":" + std::to_string(debug.up_velocity_updates) + "}"); + if (debug.offboard_up_velocity_updates != r.debug_model.offboard_up_velocity_updates) + record("{\"event\":\"offboard_up_velocity_applied\",\"count\":" + std::to_string(debug.offboard_up_velocity_updates) + "}"); if (r.debug_model.noclip && !debug.noclip && !debug_request) { std::ostringstream stopped; stopped << "{\"event\":\"noclip_stopped\",\"reason\":" << std::quoted(debug.status) << '}'; @@ -898,6 +958,7 @@ void update_model(std::uintptr_t client, TickState& frame) { // Validated afresh for the restore, which may itself change the camera. frame.camera_issue.reset(); const bool restored = dingosdk::restore_client_debug(r.base, client, camera_phase_ready()); + DingoSDKOverlaySetFreecamInputCapture(false); frame.camera_issue.reset(); if (!restored) { std::lock_guard lock(r.mutex); diff --git a/Runtime/runtime_internal.h b/Runtime/runtime_internal.h index 6ab359aa..a511c00f 100644 --- a/Runtime/runtime_internal.h +++ b/Runtime/runtime_internal.h @@ -69,9 +69,13 @@ struct Runtime { std::vector patch_level_manifests; ULONGLONG next_debug{}, flight_unavailable_since{}; std::string flight_unavailable_issue, flight_unavailable_logged; + dingosdk::ControllerComboLatch freecam_controller_bind_latch; + dingosdk::ControllerComboLatch freecam_bind_latch; + dingosdk::ControllerComboLatch tp_to_freecam_bind_latch; dingosdk::ControllerComboLatch noclip_bind_latch; dingosdk::ControllerComboLatch forward_velocity_bind_latch; dingosdk::ControllerComboLatch up_velocity_bind_latch; + dingosdk::ControllerComboLatch offboard_up_velocity_bind_latch; ULONGLONG next_offline{}; bool transition_seen{}, initialized{}, catalog_logged{}, native_tick_ready{}, native_loading_logging{}, fixed_stop_entitlement_route_ready{}, menu_load_queued{}, startup_load_queued{}; diff --git a/contrib/setup-linux-server-libs.sh b/contrib/setup-linux-server-libs.sh old mode 100755 new mode 100644 diff --git a/patch11.py b/patch11.py new file mode 100644 index 00000000..99c95e6c --- /dev/null +++ b/patch11.py @@ -0,0 +1,44 @@ +import sys + +with open('Extension/UI/Overlay/skate_menu_skater.cpp', 'r', encoding='utf-8') as f: + c = f.read() + +t_slider = ''' ImGui::BeginDisabled(!debug.up_velocity_available || !callbacks.queue_debug); + if (button_row(menu, "Up Boost", "Instantly add vertical velocity. (On board only.)")) + debug_request(menu, callbacks, {DebugAction::add_up_velocity}); + ImGui::EndDisabled(); + }''' +r_slider = ''' ImGui::BeginDisabled(!debug.up_velocity_available || !callbacks.queue_debug); + if (button_row(menu, "Up Boost", "Instantly add vertical velocity. (On board only.)")) + debug_request(menu, callbacks, {DebugAction::add_up_velocity}); + ImGui::EndDisabled(); + } + + { + field(menu, "Off-board Up Boost speed"); + int offboard_up = static_cast(std::lround(debug.offboard_up_velocity_speed)); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); + ImGui::BeginDisabled(!callbacks.queue_debug); + if (ImGui::SliderInt("##offboard-up-speed", &offboard_up, 1, 25, "%d", ImGuiSliderFlags_AlwaysClamp)) + debug_request(menu, callbacks, {DebugAction::set_offboard_up_velocity_speed, false, static_cast(offboard_up)}); + ImGui::EndDisabled(); + if (!debug.offboard_up_velocity_available) warn(debug.offboard_up_velocity_unavailable.c_str()); + ImGui::BeginDisabled(!debug.offboard_up_velocity_available || !callbacks.queue_debug); + if (button_row(menu, "Off-board Up Boost", "Instantly add vertical velocity. (Off board only.)")) + debug_request(menu, callbacks, {DebugAction::add_offboard_up_velocity}); + ImGui::EndDisabled(); + }''' + +c = c.replace(t_slider, r_slider) +with open('Extension/UI/Overlay/skate_menu_skater.cpp', 'w', encoding='utf-8') as f: + f.write(c) + +with open('Extension/UI/Overlay/skate_menu_settings.cpp', 'r', encoding='utf-8') as f: + c = f.read() + +c = c.replace('row(6, "TP to Freecam", model.bindings.tp_to_freecam_combo);', 'row(6, "TP to Freecam", model.bindings.tp_to_freecam_combo);\n row(7, "Off-board Up Boost", model.bindings.offboard_up_velocity_combo);') +c = c.replace('action == 5 ? "upvelocity " : "tptofreecam "', 'action == 5 ? "upvelocity " : action == 6 ? "tptofreecam " : "offboardupvelocity "') + +with open('Extension/UI/Overlay/skate_menu_settings.cpp', 'w', encoding='utf-8') as f: + f.write(c) +print("Menus updated") From b5d511fda2f4079f9a3b4a4257d1bdd71041c7ef Mon Sep 17 00:00:00 2001 From: Swagi-REDACTED <111149273+Swagi-REDACTED@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:48:42 -0400 Subject: [PATCH 103/109] Delete patch11.py --- patch11.py | 44 -------------------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 patch11.py diff --git a/patch11.py b/patch11.py deleted file mode 100644 index 99c95e6c..00000000 --- a/patch11.py +++ /dev/null @@ -1,44 +0,0 @@ -import sys - -with open('Extension/UI/Overlay/skate_menu_skater.cpp', 'r', encoding='utf-8') as f: - c = f.read() - -t_slider = ''' ImGui::BeginDisabled(!debug.up_velocity_available || !callbacks.queue_debug); - if (button_row(menu, "Up Boost", "Instantly add vertical velocity. (On board only.)")) - debug_request(menu, callbacks, {DebugAction::add_up_velocity}); - ImGui::EndDisabled(); - }''' -r_slider = ''' ImGui::BeginDisabled(!debug.up_velocity_available || !callbacks.queue_debug); - if (button_row(menu, "Up Boost", "Instantly add vertical velocity. (On board only.)")) - debug_request(menu, callbacks, {DebugAction::add_up_velocity}); - ImGui::EndDisabled(); - } - - { - field(menu, "Off-board Up Boost speed"); - int offboard_up = static_cast(std::lround(debug.offboard_up_velocity_speed)); - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); - ImGui::BeginDisabled(!callbacks.queue_debug); - if (ImGui::SliderInt("##offboard-up-speed", &offboard_up, 1, 25, "%d", ImGuiSliderFlags_AlwaysClamp)) - debug_request(menu, callbacks, {DebugAction::set_offboard_up_velocity_speed, false, static_cast(offboard_up)}); - ImGui::EndDisabled(); - if (!debug.offboard_up_velocity_available) warn(debug.offboard_up_velocity_unavailable.c_str()); - ImGui::BeginDisabled(!debug.offboard_up_velocity_available || !callbacks.queue_debug); - if (button_row(menu, "Off-board Up Boost", "Instantly add vertical velocity. (Off board only.)")) - debug_request(menu, callbacks, {DebugAction::add_offboard_up_velocity}); - ImGui::EndDisabled(); - }''' - -c = c.replace(t_slider, r_slider) -with open('Extension/UI/Overlay/skate_menu_skater.cpp', 'w', encoding='utf-8') as f: - f.write(c) - -with open('Extension/UI/Overlay/skate_menu_settings.cpp', 'r', encoding='utf-8') as f: - c = f.read() - -c = c.replace('row(6, "TP to Freecam", model.bindings.tp_to_freecam_combo);', 'row(6, "TP to Freecam", model.bindings.tp_to_freecam_combo);\n row(7, "Off-board Up Boost", model.bindings.offboard_up_velocity_combo);') -c = c.replace('action == 5 ? "upvelocity " : "tptofreecam "', 'action == 5 ? "upvelocity " : action == 6 ? "tptofreecam " : "offboardupvelocity "') - -with open('Extension/UI/Overlay/skate_menu_settings.cpp', 'w', encoding='utf-8') as f: - f.write(c) -print("Menus updated") From 2833ab1f88f2c85111237efa235bb1c1d74f44a0 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Thu, 8 Oct 2026 16:18:29 -0500 Subject: [PATCH 104/109] config in sections Server config: - Written in sections, in reading order: server, access, maps, players, anti_cheat, network, votes. A config from an older version, flat and under older names, is read as it is and written back this way on first start; only settings that are really new are reported as new. - Renamed: no_bail, noclip, boosts, parties, voice_chat to allow_*; global_bans to use_global_bans; welcome to welcome_message; speed_check to speed_hack; score_check to modified_scoring; score_allow to allowed_scoring_mods; map_pool and map_rotation_minutes to maps.pool and maps.rotation_minutes. Console and admin commands are unchanged. - Bans are kept in data/bans.json beside the config. A config that holds them has them moved there; a bans file that cannot be read stops the server from starting. - Reserved slots are one list, "reserved_players_slots": a slot is kept for each listed player who is not on. "reserved_slots" and the "reserved slots " command are gone; admins may take a kept slot but none is kept for them. - "tps" is no longer in the file: dedicated servers run at 20. - "bone_scale_limit" is 1 by default: resized body parts are off unless a server allows them. - New: "pack_ms" (10), how long Steam may hold a message to pack it with others; "finger_distance" (25); "steam_debug", which logs what Steam's networking says, for connection faults. --- Server/README.txt | 268 ++++++++-------- Server/Test/server_config_tests.cpp | 112 +++++-- Server/main.cpp | 4 +- Server/server_config.cpp | 462 +++++++++++++++++----------- Server/server_config.h | 30 +- Server/server_host.cpp | 34 +- Server/server_host.h | 1 - Server/steam_server.cpp | 3 +- Server/steam_server.h | 1 - 9 files changed, 531 insertions(+), 384 deletions(-) diff --git a/Server/README.txt b/Server/README.txt index 69277deb..1ea22487 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -42,93 +42,18 @@ straight away (players are told to rejoin). Turn this off with ReSkateServer.json ------------------ +The settings are in sections; each setting below is listed under the section it is in, +like "server": {"name": "My server"}. A file from an older version, with every setting at +the top and some under older names, is read as it is and written back this way the first +time the server starts. + +"server" - The server itself: its name, who it lets in by password, and its ports. name Shown in the browser: 1-64 letters, numbers, spaces and - _ / [ ] ( ). -map The map everyone skates, named like the game's load command: - "San Vansterdam", "Isle of Grom", "Super Ultra Mega Resort", - "Stadium 1", or a custom map such as "bbcity" (see Custom maps). -map_pool The maps players may vote for and the rotation goes through, - in order, e.g. ["San Vansterdam", "Isle of Grom", "bbcity"]. - Empty (the default) allows every map the server knows. - Admins can still change to any map. -map_rotation_minutes Minutes on each map before the server moves to the next - one in map_pool (default 0: off). Players get a minute's - warning; the clock waits while nobody is on, and starts - over whenever the map changes (by a vote or an admin too). -max_players 1-249. -send_rate The most the server sends one player, in KB/s (default 900, - 128-16384). About 1100 is what reaches a player through - Steam's relays: set higher, what is lost is resent until the - connection is full of resends and half of everything is - lost. Console: rate , also for the players already on. -finger_distance Past this many metres (default 25) a player's fingers are not - sent moving: they stay as they were, and move again when the - player is nearer. Fingers are nearly half of every position - update and cannot be made out at that distance. 0 always - sends them. -pack_ms How long a message to a player may wait to go in the same - packet as the next ones, in milliseconds (default 10, 0 to - 50). A full server sends each player hundreds of small - messages a second, each in a packet of its own with its own - headers; packed, the same updates take fewer packets, less - bandwidth and less CPU. It adds up to that long to when an - update arrives. Voice is never held back. 0 sends every - message at once, as before. Takes effect on restart. -connection How players reach the server: "relay" (default) or "direct". - relay Through Steam's relay network. Nothing to open, and - the server never sees a player's address. The route - is Steam's choice and can be a long way round. - direct Straight to this server's "port" over UDP, which - must be open to the internet. The shortest route, - so the lowest ping. The server sees the addresses - of the players who connect this way, as any - dedicated server does; players never see each - other's. - A direct server still answers through the relays: a game - tries the port first and uses the relays if it gets no - answer in a few seconds, and a player can turn direct - connections off in their own settings. So "direct" never - keeps anyone out. Steam vouches for who each player is - either way. "net" shows who is direct, and the log has a - [direct] line for each player who asks to connect that way. -direct_address For "direct": the address players connect to, like - 203.0.113.7, when it is not the one the server's start-up - line calls its public IP. Some hosts send from one address - and take connections on another; use the one your panel - shows for the server. Empty (default): the public IP. -steam_debug true logs what Steam's own networking says it is doing: each - connection asked for, and what it refused or ignored and - why. For finding out why players cannot connect; it is a - lot of text, so turn it off again afterwards. -relay_everything true sends poses through the server even between players - whose games have linked to each other. For testing with a - few players; leave it false otherwise. -direct_port For "direct": the UDP port to listen on when it is not - "port" (default 0: "port"). Must differ from query_port. -bone_scale_limit How far a mod may resize part of a skater for the other - players (a "big head" mod and the like): the most a bone may - be scaled, 1 to 8. 1 (default) shows every skater at the - game's own proportions; 0 is no limit. The player with the - mod still sees it on their own screen. Console: - bone-scale <1-8>|off. -crowd_budget The most position updates a second one player is sent - (default 600, 0 for no limit). Players near each other are - sent at the full rate (tps); this only matters once more are - in one place than budget / tps, about 30 at 20 TPS. Then the - nearest stay at the full rate and the farthest of the crowd - drop to 10 and 5 a second, instead of everyone's connection - filling up. About 1000 is what reaches a player through - Steam's relays; more than that was seen to lose half. - Console: crowd |off. -reserved_slots How many of those are kept for the players in "reserved" and - the admins (default 0). With 64 players and 4 reserved slots, - anyone can join until 60 are on; the last 4 only they can - take. The browser still shows 64, and everyone else is told - the remaining slots are reserved. -reserved SteamID64s of the players with a reserved slot, e.g. - ["76561198000000000"]. Admins always have one. password Empty for anyone; otherwise players type it to join. -welcome A chat line sent to each player as they join. +welcome_message A chat line sent to each player as they join. listed false hides the server; players then need the code. +max_players 1-249. +port, query_port Steam game server ports (default 27015, 27016). steam_token A Steam game server login token, or empty (default). Without one the server signs in anonymously and gets a new Steam ID every start. With one it keeps the same Steam ID, printed at startup. @@ -139,36 +64,70 @@ steam_token A Steam game server login token, or empty (default). Without only servers that have one; the server says so in its log when that hides it. Players can always join with the code. auto_update Install new ReSkate releases when nobody is on (default true). -global_bans Turn away players the ReSkate team has banned from multiplayer +activity_log Log what players do (default true): throwdown drops placed, + joins, starts, turns and results; objects placed or removed; + how long players take to load. + +"access" - Who runs the server and who always has a place on it. +admins SteamID64s (as strings) who may change settings in-game. +reserved_players_slots + SteamID64s of the players with a reserved slot, e.g. + ["76561198000000000"] (default none). A slot is kept free + for each of them who is not on: with 64 players and 4 + listed, none of them on, anyone can join until 60 are on + and only they can take the last 4. One who is on has taken + theirs. The browser still shows 64, and everyone else is + told the remaining slots are reserved. Admins may take a + kept slot too, but none is kept for them. Must list fewer + players than max_players. +use_global_bans Turn away players the ReSkate team has banned from multiplayer (default true). The list is read from api.reskate.dev at startup and every ten minutes. false lets them in; the server's own - "bans" apply either way. -votes Player votes, each off until turned on: - "map": {"enabled": true, "percent": 60} /vote map - (a map in map_pool) - "kick": {"enabled": true, "percent": 60} /vote kick - "time_of_day": {"enabled": true, "percent": 50} /vote tod

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%0D4r^ zr~&HLpAeeCi$^0Wla>m8ZIAV;wR48ueU^4@K{B{3Kseu~X+D62lSehfO%PzL-+!KJ z%5*f>oHOiD$2wei>p9C1K2wb2KU!#5GK$My4yZjEcFnM@2wRcuS(f~(N&|{bXw?w- z$4}WUq(Bhdpfa}pa=Q=6iomtJidNBo2$RG4Bt*uaLI*KjHydBHd^EWO_!D7?9DyIE zaa^n3M-8Lf#~4!~%dq_^$D@-bq*S^4JIF*ql{3in_NLURq9s)z^WXg5gNA0#^mAhI zq;Da>kZMw=HKQ{cOIa2HDQrgD2FK-2kt}on02CD}!}0Z`VAL2#hb#A8_usV_huTSb z_KhW?5kyO!IXp&9{k^Kf546ju%!zo=C?vKQZfySFwbGSfY_ktkG&-#2gyYhm3i@gwkn` z9%dQULTF)`(JrB9E#(c#m(czlXJRE_G{$Uys#@_BJU-`KVenEz z@{`h|KL(21g*>vz&v)W^bOJ#gzYev}G29^=YQlnWf5Tlhz2i%2w-(nWa02*pe7c81 zdQ`Pt48gJSZtfwrE2iR8rBs|}pyyX^1@{PPZKQXF^pzt!5!_I3r<&sZt}ak4;{#>E zTyn0fg}fgV@TqM$jB&|x3i(^_kVef!rCabcdCL;an8uC3#^hDlgB8$&CAPrpRiK4a zB5&clH#YmAbE{Gh4fMv~uibEYgIbb3)7!d)lg}zI%!guoW``J3s@iaR05CE8(FLq! zB|aurFfzj%w_)i-jU_4K=r4c($EWQ`0XfF}vD%4k3f#AeEb59I&8xLSNXcz0mSTBx z-lDPiG8K>%kPtULX#u26G3jY!O)A4lJK*|MVVx#v7B)G~Kse<_i4~(onNih7K}zQtxYX0 zU{fXnIOeIzx9d}5qP6=;J)>F08M~AKtn4x)^*xPptrN#NNmQc_;}z08lHEPXvu3l> zG|HrG#C6EzYbxeRZV;vHkM^jDP^_5Bcg<9EOB&k!;l}!>^dG9l4`=-j z+asa;YUT#!cmtDWPwXboHI18p(|^BD=y3CUr$4&BK=g;i6A85?#TMNS?j%P)xPuG)0v zyLQh?+GpY1RTfhma7YtNB8`UJ#Y>c;zClJB;YfV$M;Sk5`^RN7r{s30Bo{i>BdfGlXtHp=Q@R-_qX74>h=y;Y8U#3Kqx1pKF- zd(@2VV+EfKIeH2<*oK&@;2;Hn=s8d}8@W54T&S66!iLhLuWCsOXDu)wbLpC{h75+# z&Z?@QjkCV=@j|l5tE)L9I2hWjm_(5QuLH2;KEGNkK#dv+E@M(K4*9^}x4lmwD}9FD zW4Mvo$As7eJnFnKs>|jJfVnw1qezb%=U%-{l5j@<09s&5z7{2R+zoua>XnI?B`Y1g zU`ZH#wp@~;p(Li^!DiY$l~Hhe?@>VvX_*{K*xyLleSf7^xs}{tX$qrmhw!Ak0@k?R zMv7S@2l=~ID}rH|jhr4t?VoCeyq+1$N2wcbS>BjFB-R(o39H%wvckz4xfs+rZ9rfY zj4*Ea=|q}fBf_vOPX3h=SHn_9TaoXYqyzygxc0|-fMPS9@ypVk8JL`|N2N27pD6r! z(*h{?AQQ0PIO|WH#yNY`k~55vg+HAVD;cLkVp8EZIHh0(mApN-AEjTmcxSUP-CW3p zNbt}uN3B@0yR@{jMGCEQ(rRMkWCOo?9K1!YuUo)@6iV0wPWkUhM4I7JBX1qUkfU(T z`3eV$vH3T01_?DbVQ(3U;sRY<%I-&ch0lYn-ZcSRa0f~d0x}ZA13BqYnno;Cj1Y2j z&XY0*@{D7VH6v~$nUKu6Dp)gZpGsf_SQZ+-QPQT2H4VAb6lnB$a8CR3H3Fn;!+HiD zNfp_!s!u8!Hi(+_@uZ>EoNf_9kl{bx2H9mZ@=Y53{SQXL$z#9_W z#aDOMRBxnnq_zObUHbMEJ@dCZwt)<8bp#x-o$9vnok4ax0%_O`WMEf(>E%efNZOgV zW4>vcO=Ri-Wal7qq9lLA)P7d=?LY~_p~|of4gd%9&1*$__Ip=Z!>r1C=q8?k&;v-X^pY#&Z^x>4-~zM-PBunEENi`l;;N?hKIxr^zum@iHs1y$ABD@ z*Vc^^g7;SUO6tl)u(~cAP&hd5I{hkArFJK?M&sfr?8|VQt3h#Tvo@{Dx2LA`4jFgD zpm(^lXHr4`0K?4V8oB^|YEdoRF`o)iG`}{P7a-tc0;oq7mlggQRkXYu@}w&sqLPei zgZMQfu#SEKEO*yBE*?WF#1EM>%Oka6jvIS5gK;q0;*Em4=PZYi&!;M~*Ror2H8Mi0 z_GrQp83gj%ezk7*i4G5Y_GsEr>6Bu$}e6*jgA>*z&I_{o!1uPDLwSe zvX;qpi1P&F+;gn~#Vl?f`-j>WF#V;F3v7{x=KCGMZ12-*=D2^@FZj)ng34(OvH%t{ zf)!3OM)?2_PF1tUcy|o2h^H2{yM|dMX8V1rf{Y7gLBag7ovRrw?(D9OE*)Zi9{7eU zsraR|ww@7=2D!OiaAaeV#>DNB-mvVxD74@<6WnmvB9Dl)#bimC$(11Pq;v<~xy~`d z;<0$1(}+cXZ#ox7i{@N&47|as98IkFzYkl!9mC{`<^Vp`;U|y`bM)o?Yefa7#Mo{f z#keOBTgCW}+Go1Ba_fH+9Y{_#E6W2Nx|-*%V}d)kh8aZBE0D73)2Mg!t6W2g+ucV9 zj>ae?Rl~@q=i9b6tnEr<&e+D?tD+I2Y?-%c(PhZe#DGpJXrYv(YtNDEj@2I%O7GO< z)MI@c>c;!ydcDw#aQMnI^nPE4n5vB*+cBKu3b z6LBeDA#C7gqhhh131_%gnn8wALXK*^2^}*+y0q}2@zt{n5`)dUpVt*;JC>g4N=zvD z!DE~i?T)nbSqlD#&EF5nKRpz~ffH)L{Z^#*C7|jtB>B z`tQ9ONh7#LX!v|km_B?y3E!ssnyC%LMb(HvN{{Ygi z{{Wp@$0kdXMm%w}t)UdNG2}PRXxrE%3?6+lKk{=?@7}cK;tp*V1h<0Od6j`Q>g}HO zK)&9!qKc1Xw%GHzcFP=^0U&gw@0!ry{0`%bNvMAg-A|fB`VQukm6VZA+S+T&g@W26 zB#+5OYRB-O7+_059DnslH~cHAvb470u=qBS1kcR;*}csh#C+tM&yiGe1K}Phd6)6TF&cH=#mfIRwdn)-wtq>F%-`u%}=qXVL_FH;goHUz|_EI`Bjca z*sAuDs>n-VC*IE?Mwg60rwYv>?4xQuxc6e@!o?o5%qZ_f5ym-|C$2WE$#|^C?x%c;8xkQB$m(*Gb;7xy*srb|b81q2vfw1TdE@S|^&jUtQ*MoLrGK*2j&34mkPJ8nJ)Cm~+#J;9k~V_gRUTk8 zK0A-?imZl2R#M@W#v2=k^{DK-f$dp6opI5z##&w@#K|XgfpMN^39A)|+qZFA(%mV0 zE7%=Oh9Dh#74q-y0Id7imOJU=Kvh-9QJmtT8Dx!Y3Xj&D4e7U2wJkspyX4g)5gpAj z8B#$!smCu|(g&VKY4jqY=R%X^HDXl?a40S^2(3ve;IY62`&7ldo@6h%tECZDLJTPx zsN#x2WpQkS=4Nj|F+^C=c}BVQ-iS>$Y2VOLi!m51X@=CZOic~82?*02#Wrd4QZi}0 zV0PNHct;1Di%wO1kf!oTc_{w1PlwvceR&nEL(MAyJv*B8`#u{5ji9%;U*UiUsr917 z%*c2j4zz(tvyGS?i;YLz)!TS2<$>XNV==bHG=M1h!s&D6to|Vv5rISS;<;a$NC#Ny zjP>-Py#!UWCyDUG!u143xUiV-89T<^2tEC49LXWJNQno*Y{bWy2blKH?^}oBC3G-x z8(AVGVYCju$CY#Cz>t;QbFm|xVs^#4rd(TrEwt9>WI$zwdFkrPKQr|8tetYn5GXvM zvKKoqtyq!THd#|*4n;N0D;y$7kCIe49RTh0JAG-zq{We1v?!Q5gV2oCh>J#&%18zn znSnj|56p_KW~ael!i{$*hlWbJu+uW29l+fFdT8@Qx{a8|o2@wq8OGHb)Z-k|2AZ5w z8MkFO+APC31fywhUZB;5!C>pXo+>G!s`d=SWYzT?ZuHv zQ|#7vvo#n@C?RaUlmgDF>PM^c7G74p-_cSVC4Pqs9uLIL}kLt#PZB8D*CkST-3z zdsTd$>dE9rIn`oxvG%UEa07FSrE_BQuxT#kjwa-Ya4PeSL=5}T?q6E?U~QeN8Sh51 z(Vqtjwn;%ibpw#Z?_D*GoDBdz8u*<_-+!%Eva%(CImsOBYTg47*lp=t@}oszAcGFq(#){{RYtfoa<#@Tq`C-ErEaBVaj_4?|N+a>U_x^`KxU zg=dB0VU%?$!#FsuAwC?QS??i_Ee)=(;sLw2MIN>23XHf?xTU5bF`RBr!>t@_T&VJU zEh~Jgr#h64>gy+(yO-cRF^Ob_B6eK?&~61$li@pe1jlyh+2qO%CkAT_sVZkRZD}pk z%M2)5M>`7a_-6`-h1*~@sTh9|@yz!2sBnG~oHeGEq!P&9RUl&v{cA*p=1-X82c=Xa zTO%e;T3r!mX5WzLDi|l3$?sAi0k>YYB?lar(5ur#y+E2qbxPPRM)` z2_P>))YO)3I`*x_px-vVQ;EKb^s6s&=VYhyiIFrK)0_+Ah zG}o6AT3eZAV2TJCJ;}iQ>K6B~+(|5PkYtE~A`SGDL$LTblNGEZG(HsK=5; z4yWjA8u?(jwn-M!D`|ntNgEKPV{b0|`%#`|bPJw!Qt8y(MvB3J8-RM!i)eC$SW6^i zgKhHJ+i^#3SRBdgQH%vl^(35j?NSFSH{05s-p+X{bcq3u+^LD&0gb8fNcE_;#{!Uq z$I5r-&Y+K-sWHgY)YNo>*v~pZ5S3)X+ci&#;K033tOMy*xsCQ(tHhLxbtLkBe)Vwd zPopG?jw(dcVz)CJdTNz300kj~YLr`4Ij|kg6alEvfR!R?jWMPn^%`wdx1~S|l`2#K zsZyq(W78UXZghZwp~$^xrlLTg0Yd%^=_WI!fx+uqSG+z;bK^#Zt=MW;8{i)Gn#+S- zPXV|S@bM8NTqj8#!R1aVJqfzm&760l+ewEgc{eY5-n`&3Tub6a!VHfv3z78gQIFx< zWB|!5jC0L!De!hKNXt8Qr9_@T3TH#UmLiABq+=RgHm5Y+)BxDUDte6r98{y4OL>-9 z*sH0?BBP#E#Y->)nb=YUbZ~KB7R)k7iCCSF%D%q4(AlP+{n=!H`0CO>0aa1b6`&M;FLU-BGaEQe4ZFMYu5^Bc2qPj>Xid`pI-T zKKR*(xTC}b5Mvm~6^l(GyDT)Ch*cY&wQ4j0a%zpJ@gGs;R_B;TocF~@z|A%bDBhuM z33ksg!vNKF{w&!CYSp)lDvX|Dm=kaWdBDi59w|6h$9mhjaFJ)0E0n!+HQs}`2O^wR zu}SDq`2m=&d((hsc6<=M1u0I1RoDa0j90+686XleeLX73C|s~Dr>{!n9xZx`mStY8 z)b_5w1d!x&HOv`W8KopH>_E>f>aAjDBDK7Q3zP$1k1pMF{VLp6mp2jkFpzYA5s;i@ z`cNYc6l};r)svMwt|%=yj&*%RVe)bKn!frVJ@wOF+%k#D1q5;u2g(Q_bm}WTVP#^( z;WUy+JvS9&h%KgtmI)c&RxC##gF6ZaiH)dvBr6fwbJUKM*FX~9b%8u>2*q)w9Ap#U z6{%yRjx^$b4BeeBrx?np`F^#Bqr?L)6aj&rz3Tn5JY_!3CxreOSs&c=sgnBa5nx?i zn4&K(nC-l=r#97|-t}Vf?j)5_97u|C@~h7f~!o87l|B_5h7rOQJqJ%T)9&VOzo>I z02+x5N2lvl)9l(vmPus=h8%2H1D5>%0BR62#e$PAttd5s4mKcG+vP!%RY{HEGKFa!@u`m54o~AvBMgkR_w&k82^yo=HglwJ z*YP!8O**`!5rMd%nVVspY8dkOsqN5`e)dNr+O8^@gB)|Enr^hKK@Uwzl>jPK)TkKr zsZ&q`(x#Co9@9lsD$pK&wb@P7J!I=|DEx893=o~rmc@XHwN&43hJ%oNCi%>^G zRgX=M4rqfg%(j^mX6t7pvFd#9kyLW48kKeroyTEY4Fu5I8$og&7T+La9=_F9?QIqm zia3an0sKHY`_(36ML4C2Aa!7TLUOD+_Xe%n@r#>l@3qAYVYKORbA}$ivtCkWbv^e7!T&hY}JfpA4;igj}^KkidEHc zLvs{x41gl^NQ<0AOt1QED*QG`|ygw2V z=8jFB3D}BYVzM)5NXs65>7=)1!FE++(4FX$!eYBI#`z<4!NLCky;YF5a!JpvY)E3H zhG QUK}>dr*lH?wJOF#F4Syr-J2UMhDdGK*Z5%1+{vb0mvj(f+UD(IO;Rd)piFy z^Z>^I)8iDqY1HaFiU6E@kU*=kV0oQa3(F%os^H3&SB_|&H-}Sgf@)g;X=-F* zR&Tl=AW`l3Q_U;Chy=<*f=11~MhnKYv$%voyPOv^IQ(IOsn-jP=a;1$1Z+<~+3ks0 zbKXwi&5iKJo> zY(UkZcPG|{0jKdLk}}Nx7#NJO8D-B}&p&OjxfgLt=_qgzqYzs@zqMT#(oZk(6&f3G zK?raN&o%tEpv>Z1uoVQR72m1`J#*Kk3-$xu0jEsM9J5Khjh)o=z@TX?La5yG6#CWj za#}_{a>H?tPL#$uHSHd)LuNY#+%(Q|m`OR?|WT3`zhrsPg8Q20KN`&Bfr69X`xhI_Y9t2NhD=h|OjQg_y3AG&^N?^ro?~IU{;In>4vGETdA8 zpg-2`H#^|fo9PnfWQ1$V|(g&rtj=JH@wbh(i3TMO50)E@R+ z;aKVR#U`Y{+_s9*kD1VWFgyK48fAq*5z6uost^tR#xY#i+T=XWedxmF+>b5~rq!Rf zE4v>>=+ZS}@`B_ZWby)`J|0a$$SiPjcLIhH&g2dE*ygU6+95 zC97Z<#-sCX)8E#MX~T^2Vdqbjxl%Xu>(-3HA%)N=ZM@B>oMW{F+zDWKql_VS8D=}+ z3}964fY>^fWo= zJ`*MZXCRD?wjC&|9*Sj^TM@^nbMtSSXI5v=EwR5!ase4TG5J8E#UjHnc2#h=I$Ll} zXh2X)qME^tq;Lsn*jdqqixR7$&fx#yh^=nV*^l50k_pA90pkz_6Mp4p&Jg|=AC1H~sk zDzS@yf4H zajM)#bRgDpRBmdW#IX>m3Y{Qs$C)*1#6`%?k>&twX`2L&gV&V{Q5?C|n4;GaXTq;6 zlS9zJuo&BKr8c3Tty-GhqEW5mlw%-9*&>9i19K)bLTZ4T1(8YBvW)5LQG0oO7M<5h z1EgSVTeS`cRb#It5zx^jV5~Jr&4Nxbw*5strU2s@VBTY-d4_SZ=~KF03SeS&M8{5g zgHSI187jI|?pvv*X_{EdxGv!A+2lJ?-GBfA1f1+TRN2Wz8oF);C0D>80<3ZG?^EzK z%69<=-u9+3T1VnepfZ9t^Kz{b5l0Cl2>NIGRaoE?$RoF1Dn4f?p{r8d_^y%Y))BC+ zxYRjRJ8TO->QY<6kVOy{`>sg&{e2Bvla8CXo&`_gqUAQ~Io3Sx0%1-;Y#7^`k^51j zm>8t+U@__$EuFTga8Gmwq!Fdvy~NWazlx+RAmb!*K8CZ4T`i*9%^a+*z`{oR_6PQ( zOL;5D3&2=hf>^2ykUIS;cPkY9G)%I*5+T*#7V@#q%EBqClfxibm)8 zbI|+K&BCLR*6vuDOn{QA{{SmiWHU!|X*5DHtYU3yy0PeS`B1of z+Y6Rv;xfZB1u@7}Z_A;rzl=B?#pBxEK`+{j9WGAgt;PZ3rUxYGL7RNw~hS~xjvF?4r!-w61;M(2p|ULrVrR;;aVZsRBe zGcYJv^TsjPI)JV^$BJEW9w9N0!k0OYRBtjk&!_8M?A&XK@%{xRh5rB#wL^7mC5A8 zawGY_r4vzdK{&>K_Hp#8@v|!i2TOPDMevAztB;!w8BXlGjP|3)#V#R@ z;y{uy%AD`EKLbo|;ZRo>=L@wCJCWO_3t}&!zsrTBU*OAekI1*`* zV2&W83=rfV#~GkEl%4qxC>6Xu9k{sS4+XRrE{lMLZQSZ7_8rYxxA-fH!#kzTjoRow zR!d_dx8Q2iq*XJSiQ@#bsnVw)05?9ShW`L5H~^El9V@PXg}8x@(ryvNFnr12+h7xp zWOVIWPw<}~QNIVh+n4YdkJ6eI7`uKuZBkcdZhK@Lfw=XpSiThF)0IWxcOg$BBzhO| zYym|-3(o-6d3|MWaq^jUu<6M&T2+advtY=~r%)##;}pXjh^~}yo&y9xz1`LOV=5w1 z3J5!V{JxA6y*DZ%tLYaCZ(@?=)uh1-Fq32s<@ z>7Ppf0Q9TB=T;%&w^79D0ZVw8V@xrs6ZHc)`qdJIj2fC?B7@LXd{;u-5n@lRMR&N2 zFv{u#gH8@k-%~+(0=R8J;L&Z4kV$39pzFcU<3edcJo8;o0{CrorRNe}d!z(@1;3?Y z*=Ydae-1tHTf4B)+jCXq(tkl+aS0r297?Ggj4H0c3Ue+%0~j2IM4XPsvuvU{K9t0h z%A&wiQIz;sA zOo3zp%AqtdTtZ@*7DPPNhSe>HI?m#}Vjde2iOJt{QdwdrjieqSDmv*It8v8EQo|#SP_ZDm8oOnABrFw$2hkg98WRM zoOb-_QXKi!qm-5^1Ln;+J7Sm}=m1iVv=TR?%esmSa;;ZHjvFzjb;zjoq4=5@sT|JK ziDTFB6vRx_O99rKNj%5nP7r&0Q9wslc>_(7dw#S{Be&y7`cMQknvhTMQWa{-)$ea2 zwvXnOf{#i7=zb01PS+=jI65K)V$a@UFn)j<>;thQU=O8A(%u^@SgoQMNY_Zn=R{(? z8wb>LscZq6be&nj>JH%4CwSg7@~3{(MvS(_0nbosw9&^JkmIjIQrPIu;zq~7Z?qi7 zqb-#?dgr}yqG)Ay5Na4F2;A|~uY5;%(_R}m*yu;g4D&b}A8P01*x+a1siLgXGI1}n z5(?s(B_<;nO?q;9H$U%CjH?W4(s9oowI#TC&IibT>D_C7&p+gy zpdJ(U1M5~T6(PEYSx%)#1dMD(Y4RB44JSOaPxg4EkxYukbGnAcYFvJe7DeRhVECSU ztA>sXSoAN;$mPy`8cYb;0w{L2*W%gL&@ag}MGHoFgN4~uac9z=|vPPL@(;yXCo zQO+u*v!W!DvuOhfk~wPs0JT^f6X{)EpJ}s))TXQG8#X~bL8`KK6=F{*&w8yP9LIX; zXCYSbGg`1ik1`L%!2RoD+QiCvb;zu_onp3%X&5YjY>8N&SOk;$3h3-_p*orwEv5q) z2-(GQWtxv>@Qs%3t#F`eqexOSfUMhO>FHJ8;#>wKV>^HiN5vuZjCv9?kH)zkXqzl- zKD?ILX#j7`pP;8Rf-tDSt-0*u_fi8_q$s)!`1oZsBT8EN84!HU}t*cdM zO1R&Vrr4eE2fbkYEkBZbZp1o++mWt=Y~xSXsxsuP!!9?DZV$k*k&1mPM&#u5rd;Rc zBB%16cpYeD72IhvC{^E zFaa6JqFg&X{$t5PkbNjyV<7Xc3BQv%qtRaO=HAi=xt+>K81oP_isv}5#n2=k9}ISB zSbx2z8a{`asC;R}p*FGD$2c;HHAsuN0CLatt}yBd7^w=xfP3P!$Bi{?iAk*@^Ig5m z11ytBSe%F7G$<~mfRh}O$EzC35;h;LJT|j6$ci@enDvicxm06<=uDAlV9wf9Fzd>? zDG{?{h5Sa`Hc3`wdxHF#q&Wb4=bd#Hd}7~-TCK!;Hz!8iuKQP?r-;%B{39U%uAFXr z)t(!7EwsbITPyOu^_rV*zb>TG6|PjaVtVt;1FDpMhl=TZSSQ;<|8q{T^73N-L=AC&A&~uW`k$@^Blf5P&`HMPp=EnAegdHp87U zAo8S6X{$j96VisvXrTTyCmpB(qZxdjlttd77{dF}0NZ`2Ks7Zv$4Y8q8lNVWHfRCq zVw*IxNMqufl{RPr`BKd_DgZ=O4NTMx07ZsHX>cdebs)&yI{Vgf*~S3fQJgMwU3j7c^kPj091sPI^9?`;~^S+wgR z%qL<<2X!@^mWHLXe%IqJ9X{VT<% z207So&#yY`xK-?&Q{kJNdx;PJ6#z%mlAvxsEQ(T%3NFs>J5=+1k^Dlu;y*h5I@Uz< zyt5U?FmOQ}tA&f=ZY^gtWL=gixDEmQ`PXaz0K9fLoE3Pq_UuZKleAdWjp{mz&QPM0 zvi+JJV>sHhfx_t>>l=r0o)>W-jtkh@PKw@AeulNwoMy3iWul)Hn?x`B0WKG|*IXD23w_dfLlCw>&EkPA_8nz>^ z)`!qJ#^{HqJv$6X>gDXqpqHMo=S8i2JZlrkCFc-rIbx;ORQ%`p^zivJ> zrJXQ^z#|}0Wk^O$IcS&fk%Dk20E?k5(5o}Z;N^7k89RE`>^zH^*( zqi94$D8+*eoQ=V&kXa-_vcsz=!_;mcNBR+2K0` zTCPY&X{A>l#4$b_X>a9fsPVi_rbQsonqW05RH=ZeQl&t}N|ikTH6cos06jRSr=Sb` zKKd(SzBCOmQgNloARUg}yHj6H40p35##BZEM6sVJ9>3>0ryTOzI?W7*IS}M9rkx~` z7P^q?GCoNItz_d)#+iQIw6-&lcNMX2{!lBLu$x+#{_<|V)w^pZK=q|tI^ykFtpn+i z7#+g{T<;pU3-K-FQ-Tqt)(Y%5^9}yByBXT3d}VJV&jsXc1aAN>j$n zlqcAhNW&&hF`m6>@d;tIEom?UP&EQT!StdAL@N{H40Z%(darSB;N6204bJ}n@H7E( z2Fz@&DV33kEKYMqL%7XUfC%4rEYkBh81$ zIP(!i+c?EG12{R@Qv#daM*)$YBDoT_Pnd$Z<+V{W<=R39T=|f$Mm;M^`pjGtB!BK> zB!55mtl0b&0LZSy9oIMnR*|YCs&+e5Nc!Yv#+^qv6=mKyE?J-&m+qJ8K=8>p0FrqC zGgbtVvmTH+9GZlQS60U9pG~!-2wibFE-fli}hbM&jt z!!$S&-P^9);fsEPq%CYyob$`J;ZQFA|uWwUpOh{DsUAHj5GC2_k3 zJ8j#YWaq6sZKc8Wr@;l9)~41bNoBu^S%!ShWFbdEjWsL5X*wWIDHtJ%IY~P7`D-Dz zVkxO`LCD&MOAC1NZ&ATC(WzMCi9!DWj(=LMN}7s7veR)(*3a?TbI(o}KZRCl9nM+Hdzy_Z zpdOVfQ~=qfnpDP+79eIw^(iVzAd$|bPEJne)`xC$8;tT{x8p>n0;Z>?phfN&#a=)O z&cs(qXMXW63t;R|uhO}G9)IyT&N8O9qan1nUPhuxJu;Br5nin5$kroS0E>d#pK8EH z3Kl|hgN*uDM}?5GFbpxcVUTLG@Mxq)Htb_&8>4Qg>(aCK>!57Kap94knJ4SYhEC;x zJ7665t2efH5VEY1=@`Q2?+-&t+lSoU9HN~pes|b^dYT&9=v+GkhFaiYcq{q}u+FG5 z>=b&|V{rymSm8|ytAVyj`gE#IGtBuc&UKgAW(9K_fsF@Z4G+MRnMU@-Yu$01Ne71&v!GQPBvt`|>z%h0 zt$6rt)A6j)890*} z3%C6~wKTYrJ!NsHJME2MN|uCQF}tBG*(V?o%Wi^%vSSfOp|{TC=A|9WNT5c=K<1-3 z9@PU8ESf=fU`{cReJDTzc^kxHb}TV~bGK@$%HRenbmp%~5Xd_WDfxNl)}=c|90(Zz z7}x>K)g}c1pfJ{axY>tFkp(v=Np4&3QI&?A6P~M%RS7jJjEv{+XY#57M1?zoezg!v zFk%P<9AcnPf7X`m$n@%I&BQ$WHtVIH7%&N|cx9JOTQwE#&pU; z@XiW?3C?l71<7V5K~?GiC)CEOgcIKsC4s+$Kxl6GOwJn7+O z)t4u3UX-53Zt*7$(cW96X%ns*6x%`Sa4L#{G-fRBzGXP(I@6@ij3(HwFvG4YGb-C$ zq>;Dq+k69A#ZsU|3@BMi2MoL8y*k9N667%>o=p}_AzMaWVi^sbbD%L|rNDhnfs@SD z>;}?fM*y;^7})JYOoV8{8LCa^- zj|6KgoGB4!Wg$S0#~# zl6J|?DzrAUO%%QygGvoWLFwL$9lgAqG`cWXjfSm!_uDm{Bzbz0>||_bxSIAP zwL#;s2)Q@};~UbxYq%s%AAH0cX&WAzv7e^jol-*R;z-K|Q;@@@N=YP|K=KmuImu?+ znk!-_8%b!DBEx5h(#t-676UI3A z2eZELEkU7}L+wmSDeB3hMf~OE@c!Vjc zNMkth7!8K5`gi9-;C}}mGX>@U03E!R&44Vd!=BkJ#PsFwy>w!q82o{wn~#i0I6H9J zP)i=IQ8OLmJ#k%U#)>Y!6X93bITEk~o&%z{?OctQ3c2DuQ8;~?EVD>RL7W4CPI7$) zYqjI{_g4P^`#xK1h`^fKczi%N)8%FUpN(y#^dxz+W3Jyy88m4Irpn_dprGLITA>m_ zxC3%|=A0>J*>XVN6%bEPYO||MKme-#*u^FyyNnPw4flcRRzeU48zsh5uCITsNgqBhizozo=76%HF2oQ(^6$!vupG3SLlwv;0QK*+TC<$^j!JI(4=zYO>Y8jp`E&W! zj6k3uMtn{~){|f!+T8t|O>p~?aDZ+PaoV;xXT;Z>CLtZN!=y1JvSphmJGVNma*EJG z!vGW*3(J*K5qZGxu&$`aDRgJEvV;EsX!oeU#kEF1@qB*OscZiLv}-v|nPQO}2W)3Q zQC@E-WX%#d%uAEIFv&l)J`sKg$(_~b{?X>$Z7k78Mv=0A0a%xR8}ZA66>h|N{zMxO ztpKz@OTS={Nu&T2(u(jaY_)sEFE0*mE+muPw)`tSn_PhpBe|)u8O1?Z3CFcJAV@LI zM2rp1CVb-tpj#r7m{BYWB!!jSH#FORl~}PAVhDxV(WX-1Y-3GX^dh3lpmR9!TO5hV z`tM5G7rM9+r(SiYpAB&&7#Vc>O;b*91Mu z@iNOk5wa`7ATjBXvm9f}a0j(xab6wvHdaDfC)j70J*!H7KLw@6hEn+XwDh9!KMwDN znc_N-fa-Zyp^_NfTw~ffAuNfhOQv!%E)8i|{7b}aw5N;XMn2@5?eEU1O1YpFuO>&L zj`M~5O=rXk#My#l$s2HejcbWPJJ*&4-n*^?de=NICUQ%sk%{Y$UzRH-iZ->3`qWsA zZ$k}(np*Wz89ed>@v7WJYPVC66m`tl7ZM3FPS_(Iz#p9-hVf-WEN>t9V`$_DIz2tA(_33U0flxL&Pe|N zy=8GO9;gh^{F(C)<^I(TQPQUw=-zNqiX3Ote9+l|d8lfq3@p6=aEu zwpd^u*`r(V8_SJ4JU=%qwkqUS6Ix#<4`Mt}`jyv{spna_s?oGDHrFnLMRE<4FnCKG zurWyner{)f(t|SmuB7R1NA{+T)T(LD33kucq>LIjh>iYn=tn=x^sOeoD4bHMHQHeLt7&u*We>s-z@c5+ed zvGE<8R(xi3w_HL$?MjuHCk=K9Whl8I?_E7!H@*1O%R=^a%=(YXojf(#+5f(dIT{v z)QPC-0P~;;3f=fc<6nqv#F+DjRV4Bxe}n5-0g2=d9?tEo?;^QYP|-MWGuYAt*4o(# zZA6T9%?rWhrQG|9GEWuU32_r_>KRW$4MElOgRnhTrr1&;;kfyv!ur-{8oPTQ8zt4M z8Ch5^vOKAtzvppU4!&Ya{40Jg-O(*(mq^`vFvbaN=KIx(XpA>v$JI6 z9q0~q5((w``_;}TX>-Fkr1lq$x^iL3$1q2@tu7_Q?dG|5U{#6SuGysJMv#M-M>s%H zw$*Egolhczl2aM#Hx*>qMFR#nU=GfA1Kd@VRZ!%|i3tiw8T|hM3S6wX<7wLj;GBg7 zlhJyLej>m9Md8Y24%+1;1Gvtfe}zg#MH<*S$MW-|-MPBp7Gg9pgqeXL9P-H|_1Jz? zCtE6Z(~2=fWO;g;ksu8t{=$q&$;j zu##kB=F0)t?LiTcayCA^g-KK%m;+!B>r}!?GeXi;j^Ri+S2{@T+P0b$xPg*Sk%kDa zYSo^A3ENI$s>?lP(%|x;;NlliX@AvcjtA3J&TqMn?*uU~G9E>bYGW zpzRffI%1{XwQRL5?9P^sCYVue?EO5KVGjSOYjj})lAIiELJcN!*;#S85whORn+XKD_ z_pVzcZ_DdlZ^tSjioyujOllt;&n|Vx`wHftX3^-;w&R>m(2oZCU>A~;sD1}()3^B7 zh)1;X!!Z8FxXpQxBfU*Q&1UdL^jEmgUV><@Bi9V6BIg>BfghC^E_TIL;e0tb zZw-dpB><{|=er(et_8srzBniNm*hxJwdGv}HZ237E?&!i?EmsmWg83etw&E4~SF1+2_gIhDtVx_Rx= zt2mPNRwj}NwKIi`9d3nA<2!Tj{c7a0jvWEEfsmQ9jFLFO(xi>Oan7M)+0~QQ+*NkJ z*ssDPmdA@*c%UlW20%k(a?kBuUIwh{{A-<8TiI49XOd@2wUjcc@YDemi*kNAsl^0aNFyZL}8nEwE^1~m() zYgWh10R)eFyK0#8XvpcBuLhh=2DG=hnk#^qk}T+SIL3ck;iZmSTZVzhnH+*Wc~_x8 z>Qznv9dXLJ&Mj(Jjm++d1fgCgU|RVQro+G0uNm5X~!@zfH@jwEj8INIg^@lz~ptN=xKS2U~W3n5bMlo!6R}h5_KK_ z02)}sVBnG7pBjkuJJSO%ip!Z2Jp7|4+}3fa+|A7#QVT{{Wooj-a=AkQtQadGFX(lMyAhwcLPew3hM0>dQJt&loDs zrvRT|Y7;e|Mc3hniSVTm-M~@=DpOYK-raLRfUS+%Oc8WiG;z#8&J}?K{xy%TdmDyi zeJpk^yQ}{ITC;1#BSgB6du5R>qX@+t1QE#K)U?dgM7Y-vXl|xP=7%HOr{z=NR}W(z zB}Nck05z}O!N4ST-mP%{Gi7mW9hVk^TSB^GpH`hq{H; zSw1RTo3%3AnBzRyKB5PFV!9N2Yi5O|o-rK5F+O~+>}!VO@FW~gQ3{krAwcbcioL_+ zd%uS6ZJg^wU}6~ETOizslocu(mEE1B{{Y98V;?ViETBgb$mNRji(WCrcvZ+) z=W*e-3~##o5NozhFCP@OhFfg|#yTD{-e}tyJ9QfxhZho}`4ui?4>8Aj!{S8RUCp^~ z6-J~fwvwZK_sFdaULkFB#1aVEgR@~(*BXzdQ}~}TaUTi6Rq)i4rw9A;trv96~IrrYQE(M0Pa_=iTGswy?brFyUwL2M5UNn*0MJky*7h(Z9 z&T+MA*uf^NQxu3lhlvr_nvze}u~_7*1A+h>3|CJj^}Vo(=eILqav6W}&cKoQ)XQBB z8FvFs!GZ`3Eb?ugyt{JusjO`qIaXj$kcCG1_5A9@wvTp}?COZHoMdv5dE`>LmN|#; zkfSmJPQY{ODOifW6lV%p>L)sm-6~ELLh>tY&HzKt-k-$EFd$}6CXj}P^@@gGuh9Kbif#`bGOPIuR`9>9nKw~HNjm#sMIu!^f~%cU=A&DI>>+rUTfaA ztt1;2`CD;VWSr+99k=INwo|~?u%ZVOrmqt}D-7&KXCqv1bZ^4h0*}U~Shy9n!=ZJJ z+A;^PFQ&$+TRzox_91uh&HP7m7MYjmcl?Q|@^2+^In|mw*e-+X@R^lkkCu~+^ZIh4 zr0`r35kkf#)T~ZUK%<|rCjyKkU=`ORs|{+~4aaTqSn{dy)=6}uGVUVkJJ52ImgR(* zjy1!t-aB9&h^S8_QYgejIo*}Bk?T=(jKVcY#vs_`xb~s4tVbDqu+NxdZibqsOAD=0 zax>2;MmO)8(HLY_9F1ngz(~Op#E-UW@NJj22Gp1!paY%v9O;k}a(eWruglM2&W#=$ z8ShAAk$?alYD&2Sahi4@<0AvLKsFqyA6h_2oh`9Ey3;WSd~7*Xb!y1`O*qII>(YUc zIX|Y=72BOp8)S{@4NL?Z7|6jDLgq+T;Om8?Wk30nY)4IoG5FSq8|31%c#2|Wamk12 zT4SpvgBN8n;ll!S{{U<^>zb4CJJ$hC)PYAO4S+s^s?%2Fgn6Zt=aJjpiKu3pl`tNa zH7WplRH;w`rl+Pfm=k}T(^779r~y*NDpUZ}sq;;mECouIX|qbedQ}}%;4$P0`cY#Q zMAY;@Y%d| zx%eAQ=-rrCPit=yNKMtii4OWyYCqDISnh}`X>=#4tvfy&b8y*M$|ra2?L>o!@LMh8 zjpcvlB8cA>MmCPvEpXpetvp35R{{WNy zXp3)n!{pPR_@P0+0_2g+41j4lAOT#D(w_%V!N@fr zx>uF~AB{gya5nl;Vq@em0|S=kpGgNu13kOcOLFq#+M7|p18P7_YX@QlSw@x}E`+H& zWlk!&bZR5}Xi%GKF!yL=q*9Y;S%J!2)L@M#0<<4>as4DuLX#s-) zkbfaVbtU}Er1Ry9N-1$kV!6wU_1L#@fjlV6Xs^X+hIce0099cDSr$d{M^49c#Ca8-y9N?AoRvJW zOp(R&U|wa!)!mgcx{l7NXh#BOnfR367aq zt7K;&gIA@7DdmX7LoC5T12r38s$Z%IHhJ+PhRZzpyqNLA18wiIXj&DwU1KHai+MpTrk8TtOt7 zBG4+7V4DzFa|Bj=3bydNAjZF&)KjR6MA7o{!5d<-X+Fw3J`EoVZWS*Fi~j%>9I}5A zW^E4`VYUu2kaw58ibfVz>Tc_o%z_a|)hB-cyA zW^OPEd)V!GtX9nwXz7A?$rw9e{^ql{k7?L;aI>?CPOw9QK?Gv9ZDF2UdwFMt1&zZ9 zwj1gL8ne@J`BqO7S>wN+JC+SHIag&l1dm#=dB(19tnFzfMz*A|j^ z;DF#T(g*V?A4CNM>S4VjMR!_-45-6lCJU@oc!Hyf?#O6$22gj!r=XuB?7Fvt`1M470uA z;`zXm)@4}#0QryMb$M?+?hJVM@P2&CdEWNnz^mS{J2w>B3snc-v_uo#VqJ&z+&vgLUEusrj74%|f=7`F8l#iWpND^Hyl!0yq_#@nmt&-TDla$;GDa{5KT6q~h5e^g zk~oRmeGxB}Pw!my^|Y3=G;(tsh~!0f&kUg(W)aI7Izf#2+*27f6IsG$jQ17Q@U9QS zuPxSLc_jOJl|WLbu^ZPjbZOl>Fc|uCtr&h1RUq8nd#q{un%UNK&YUq6xeki4a84C( z%Gbmrtc*|P%C9S|N)jYk)N%?i1q8XxMWt0-u>gAQO4|wvG~OCB?sS4{2KG~JX6@A% zIqrY&SMD!iXqgf9F&G#hy=HNQX0e@DbEMa)=wj4l{G5KRH7KbZlB--?1t)enB>r{O zWmDm-0fD7}ANs4C;r?s*(-|-2UvtX3PB2#9!W&dOXY~H&v%zXIllm(*+++JW`2_@u zER8`XxY^W%IoC(<%$jXw86XKiYUcC3Zt!5@&>ysj)tH?i4PS|=XlrLmvJ7PM_1c2` zLLFLT)ieJ9EMq^PAyrlzbpf1sgnj87SL;Oa_4y$NL;R>SQ$r+Un)g3zA~YEL`4XIC<6gkrE^!`5H-B-Zjc`l7^`aA2D0Vi*Y`Vk+7-Gc-<>)U((z6|1%N3Y zC7<~L*?s>2rE_n`Z{@jJUSp&k<6V@F#8!F9B$J<7eKhD3I#qR%R%nUR!&083)84i% zrnKV_t)CN*SznT6ICS93K{a@C*D;_W7M9{vH#BPo{-j!X@1CRYxENiSh>8!{&BLgWr~PAqj?qLHy<&*(pDJn=2EB11Hc z3gNOe{)E+5XE+B87nY%%QSbPpqx=P>B_huVXxc!Y1X`48)KC&eeQQJPrEXcJW}TGm zN#*Q1eJc_<8roHsA%|W^rD#va1G4>|#hI93l6fCW#(ybXO(TutjZD&W_GAPfG2GQI zChaY%-9&WD5hGCkRv16$tw`L>EwFjw5w~F3II7dLhK>bdq@8kOV~&EdigsQ~aigV^ zgIMt!3u&z7NF+!C<%SkO0O#24&bf$+iH34ctQ>O3y<7WakXf`Ue69$`YNDKp?^dTO zraXR{4cipTRU96GiXQ}?80MDRb@I--Lu2JVD$Lu+az1VG?^yDK4EY-nb6O>OIdWQQ zn>0FUO zBz~mRAvx_zz_9saw7P~e3H>RH1#E4B+pP?*J?SsZXbU|b;Ep#H#h~GH1`|M~qa%~9 zbB}SK!n%QgHrVt$>&W;$`*7YP1>*1FILPh}#C` z3Afu$`&E@}VY8fbrkXW%4mow83_ptaN^x!tCCV0KfQACUdZ-wNb!am z057W>jko+OxprhbatCmF*CWI@YH+)O6ixgIvhi=ao~nOiN=rt#ol2~&G>`UnWsGX% zRgS`=Y-Bcz;xs&6C1hD@AJ*#JiM-)5;^xZ?P7zS`yZ`&QZ{#Bz| zMk-yL{e&rVaA4oVVUoGdy#(VK5d=g%&ZJ#Xo__bET%rliKW!4IAL zVy1_g(hX^So);Kp)Q6L=F;wTY@E9Fg2a&E@N3%@>@eW4Zt7$UUF}QVQCtwchdWy@6 zGRMk*o~EzcL*YV0e8l9{!Lf5z_+zNoUfQ?->p9*u{ADZ{NA%=0DCKE88EK>wy9Wfh-Tt8 z=!{oXtFhd3q8#96wEh~1%f_J~0uVDC`+s_Ay9ZQu`g7Njq!)Z*m$ge~Qh5=~R;EJ0 z*keDvG07aUo>aPE(m2mL20~O0npkQB2Tpn8dc&H|>i*(KHf43`Z~RQT2*BrHyvN~P z20Xdk{VEycHqSa}MnpUW`0YIRjvb$0@E6117(4EF1HD;;gK+2@QrbtK=W(c?sjJ(A zwh8S^1m`3T=mII_0C{oRq}ilYXCP#E%{Up!0~n+-APw*_Oisrq9+dkLQ2c`7?KuQIXEeg zdh@%uL@gw8oh2Jf@7UL($)%5+c|g^@alaH>2spot6cn99DaP7{$M!#3(-YXrHu6C7 z0+C6pt14$c^|u_PLOi8&&rrb?k!bI)cg8tltv$)lLVYV~7QaS?tloSRlbmBcC>&>r zSy^1jl3JOs6-S27-bDwI$L&?E+>4PRI0WLV{6!JE)j5*%eJH(a$-_&8ZVnnr%LP zsnCEaBLWLlQ0z~PVuvF5!D7-*p|&}x9cdRF^Hvry1VlD`XZeTnFK=@~R5+(OQX>Ww zVD`m6j}s`QYUDj>!2lCbL#IyJW^C=wk~^o0GgQYBvW3E*RXphU=rB($>sBri73^kf zE47wG<}oqQ-rbE-xwM)B+T&zp=BzMR`&V1TYT)ef?TnhhXD=jXDV-bck|}`|dy87iWqYtPjCa8KYJeM7t7I|Bqz?S)`cn~+ zPJm|Rk@cqv$}&a?so;qd}; z%unG@_9$1Hll80G26K;UZHFP60HyY5%V0#f_NYCqVEll6D(2&S{uDRd5O?iG0ahD3 zd7Q-O!ejEbcFh7$_#Xm%zvdxVzOM3 z6HI%v?f4JtQExIZ8Qc+?gb@9mG~^tfbXzjNqT)@Y<~ZVueYBv+`kEZK6S+B=`S-x6 z1I!co(>582#!Vn{VvH=a1B?~i_BDPOmge?2n2-vRam?bgvE`BLy=TqC zCWb~>%E%PsDsW9S$we(hbqDE0hU#l_OfK;rr5hQhbdocPBP0HyieOOpADF5cYR8LS zaO=Z$Z81k<=UiYvxaC?B@tF99%01gMF5!V08|+SN7fyas54AQ3=e0|UV^U{J!)CR; zfjDKw(noUvD+H%?l{w=*2&r6Hq;`_n+sY-7>YG>?IT*$V(z$%^osC|y;`dfliBZ+q zZHWeR_2*LJ{0&KYH2AD5dkk0Uj}GQA+j0yB7=AP^2Crohn7a5gP9t<-%NWBCsWcl7 zIQCOWhT1u7rG0H~$01L3JjG}k;Nu=N5xj8&8Ss#KeEH8Wr6!ut*XYLMc5b{vW1OE6 zvsV5efo9Qh9PJG7N*wj_<9(}5VE+JLw^`uP_OQAxVPX`k4YhC3;)5J9@X0Jo1^x?o zbGYOKfi8Zy$g)zu770)IeyHsYL z(hJiS?+s;CVyDA>1TK3Jcc;poP`E0z%%K{wE>{RoA=BEee+xuL*HD)MA6eP6UB$Np z_;yQ3E^VXnpqzlDyKLQ2p#bdI8SZ;iPkxex&m^iiB!HZeoe+nR4<}aBO zEC@e$=~^$r@9yPX+epfbh1*DPa%;BhnpANl<74<=h_ddHrgNQA4D$5pMT_8>ivIu_ z{Zx~TPF(t9`wXCd+H~l2dz*6VZB6- zlxEU-f+%OUcAy9!%uw!zsP%s>5Vi=w8K}MpY;wlcOR*GlN~B{w2sFhRd8r>t*2F4Q z)U&=bKoX@<-j-+qQ;KSEX@KH|F(OQJ&NiZo9Nt+~hBJylO48PQ`PSavGbE01s=aGs z%ERmylRe}T$8{-Gw~f?-MyxhHNBPdOAf4@{Nnmpvi-Hd$pF>?;kHqF{PqlGt$DPf< zF~t;PQZNJ@kOb$KPxgjZay!~ylqS4TS0BYcsz5A161A62&FS8*b?)4oG?&eZ%T zf{V41?(XEtSfKS$(eq9IhRRTjWm2FC)8bYo?9;U713N`b_HX9m&xo_nk zQ%~Y*jTx{!#di81-m8Naf}ry09QxG33U;WW(l_h9M_4B{lNetUB%ts3O4LW$I`C0z-O4+t6u(YDFAE?%i}`fk zJxB1!t!^Q~xHY#AhUU|b8`pD(w-&}XPjX2V0fCx{HEY34?4OCGsuW-%eOEuNIu(`4 zqyb}vQcs|(VVOn&1KCA6l2-YpbN>LS)1a^^XLl}iTgq|I%66%Jg6Bq}sH3w;p#B-D zV?9aw($YIoL1Yc1E;UaO&t}d)3W^3~(WTsKpU@b-~=Kue%BWtEbYIF;|BSlOQ~Zx%$(u z508CPvB4V!r%Wvw!)Xz{YFNhIt55wh-0VxAtrXco^DJPn-=3go;wv$!!n16kpJ7`@ z*%1Rqh#gLKY3Kc@p|GQT067h_M-a0OZKq+CiOxMMWGa;b#+;3}sbsrjE6cq*py~^K zXyP1-?H-rX%;Wz6NdC1k0ne0|!NYU{rr?NyIXNS!{5R)Lu96)pd2<94kEbehg=8d* zMi&YREsjQ{BP5WcPW+GcsWI~*IP#uA_o4t!bKkdGbQZw{kj6o00FJxo*iIF24xC5~xY8bk=KyxbO@3AI}1C=;fLa6gN z^Lh%4C!B;M9XFx}1Z~fmC#@nI86yeW=3e=Ah3EDe)*cRE@& zBu6hWanSnI`@5n{a7fNaKD0q5tdqGTeDgJaejkX8$^1m*KsaSTy;WN@nSX^KEsfOk z2imDlEdUJViB;bmsw&t%L(iDT)i;W0TMn53?M&_@TpWYXJ!<~|fFyxi%)4!jRI(#J zVjClDjMPHl>L-{R_oGE2gc%7N3=xg#03w8GD&eu%5Eszqh^*Mr$e)`#)5_C$;5#vl zj#_cIZK|}Pqj9I$dXCfpSjxsoka;n<0DiQIHq5fax}I8>G@}MlsarT3gVLEn84b^t z0p67eiB**zMr<%|myv2{L}>cl>_NfK;-br>Y8=~a3L2d)ogk8a_aRoc0lYX^IcXF& zGt?TcWK0zWj(VPzabt(Tkyb`&J4U->zD)zcd?*EZ;~i*22W$? zKu6cm3MIm6iy9aOAPjOo{a<}2)TdGVh<5^tWpN1-FNddKRAVBQ35+6)smv!N?#^+F zjPnu#k1DT0LTi_i{KGwfrgV1Z0)-uEh;?lAJA=xbWn7Rmj2zI>h*CNVmRln<#ux1@ ziKZYUQa8rr(1umD5OR4FQozpVVcMo78O!;Uat4?10%;jy7>`MDomtLkf5OstZ1<$c z#*_|2kk3jRfErNLn*bNgeavN+4(%?3Fc%-(H$PwBmMgRgZM)^7BD;7T! z!+#26hS`BQ+t#!=8~vU~ypR(T`CNgL0qR9%lK8!ic1rVvTyWdPHxfqZ+bgRA8~Rd~ z;XJxy2viWncExm-9}fIK#oNcWmQuMTq7SnA9-_GZBN~0WbhS)dqmZFku)y0nsDzaC z{0*utc<9#2;RbnRAyj9rGR|YlNgFJn?wG1(WLZ*JmrJUHvD;AVTCv{4cQAXqe+PQXnaED9&fT;#U`lKH-_Uzsv-^qE5r$ ztUts2S`VN2GktRr{*}b6(<(eD$4r|Q?_QbkjmHeRf3ooFQxs~TNL%lgQ_u9THx_b~ z6PTF9Uq+#@C$?!t^6*!KtN1QOxwoFd(jkC#=kKY`=e<_Cu!F{nhj9Dbhg+1?h*Y{9 z^2Xkr>Rc1xjw5&k*DTghu;hQ-VcQ)(wcFa;*l@&4*6MzX7&-_O+rRAFpYKeoN z#8-c#zjl|KFwaUBd2QM>Q^^w@<6@^ChO6YC?_CGRQ%5(&H=$ri7CA~`cGrXRA5&N) zDSfsbs|IL@9(4X@D!ck;r7&FNlYvN)fCl?g8E)Poq?csy)x5x--}j-+36+?(HGH9V z+M-)XDoYLd5w_G%4V)z9P=cX%J3f$SLxVsU#ZE6&AUfz|J7-=#z zw^ptVKo34pOr^y+2K$Ndv>^zAdcBkKjIBx zK=Vg5fJX5#0C@n&2hzF=yjXC}2zH4u53hg5yZr_<=H&WEC#j#sMR_7P?W9*e49q~} zSPX!DYOlmmfg~n&`OvZ5&pEK?|b_NZ8;3T@O3Sj#vGJu_#V7 zb5f1!FkH=RFqR1#HynzbQDz&}ax2bfVmiJP@nCWx;uuyt2|(EUj+N0H+jYn?u6qNT z^Bh-QYPSmnWx}{B519H^PEm{b9T>63Z*1I?h(f1l%$ET^RzQF2$NvDGS{8g!Oa-BM z+EM=GdCoxOYkXP#xo@M(_4wXYn(Z18@arpCO~OK|<8tSx^sFu=dG@Q8aD0Pg_M%?# zDei`rAyfHRk@u{*WUT#0btT3f9GGypzEbRiw)8S-iKy2R)JvJgY1zz@f{3InU<{tM zE)Bwd*nDZ>nc(GwQ@^fjay}ntYa=XNGGeTGonO|nd0bs2Zv`c^=*l&-z#OEw8wXv- zv7t4*C}elE8bpMzpB`L))l-3xPG^c1hBCNBC%-d}f}AGg-Ptp~?BwqAKM|Rkv#wSo zpL!TlNIG&(-i<|_0?y#_)M#u$z{g6|$c8uNihONPiitFUepRbucqal0mSesFR@oQ+rp;#X z$mouEf|<1dPg)5*zX~jwVm99!=^Io+I*hIdp*-s)2|khP`?&F>oPe@vQJ!Xr6ErWa zKBK)2n3uy0A6htED5*77F1jT>*^vSS(d(-wroI$Os3tXzdC$^r8Qt6&vNUxGZ@C&f4@Jgkke=B-Y;>6ls%p`Txe9C{A z9<`Yao&&2!@2KqSjjLmeTMj9~Ja~p>wi>b5Pu?HD<5c^zoj1meCcX^c?zTO#QvU!{ zYjDB{qm1%09oy+ypd8bYupYHciwgIXIRMDOb`=JbQnszygrhsu*lfFGu%|@X1}9bl z->Iqd0F#d9qE`-c??lst0FBuGR0tDi3OnypiG0|pDhU(}fWy5=fftuhXCtQ}ObBV# z8)1+-b*Dwi09L0A;L_b-`2O_w6(Ju?cj}1^SH?dnuwb@b+EgX8Dh1W z<4k3h2^9vmWsrk}BjyY+MR!TWrAb39fQ;j0Bk;kZytW)cf#O!nC|$J>-_t*x4N%WN z%KWSHj;F19N5VWn-*8yb&SbX!8TB7`^4sZL$Hbox+u6pvLxsuWS-Hb5a$-NODZUK9 z`zII{?r@hQMhW}e*ZO^GK=fgQh3U?gqkt+ZMnR-|_QgvYGI;M0Ha4v5j~YX!Fh+Ok ze+uIynWSShtkOtM%-(OUYWzVnU2$1Pza$_c>~K5&b(3(UXHEzM8*k3GiqX$GT&N;W z&V`Vi5xzUsxm5D%6byR`%~oh$7|0`2VSyE=Awj`9z?6%y3Bjg;tU+Lm{wwj*ahEX2N_h;fkv~=%*(qkB!$~ay8BHMAd$-!{tUE_0tS6^sh=EiRJ_F*I8ho zG3If(rwDPqJ8jm8t*8z6tFWRfxTyiSsdlLWwn3#}4sD`7xT;qcYU5Tj+tRLFoJ0xa zO;c$IJ94f(&*Xg%^yU6f_X8Z#LIQvQ9P?Jhi#l(oZ(4*e9IN5Vxz6Cw4V$iXNT+>( ztWQKT%2~v5VJ$%&qU$uR#_2u z)}=?e28P6Bj2exznCA`HgZbB6=y~S{Ngrp6ypnG6d zKD{=tK5yjCh0zuSZ~D`NPUFfqsCE^b?6f`z139hlgk#mjC1MYfEwSor1MhA&=yT4x zuY@Ba8>uod%&I<_{YGwVpVOztU(!)F<$A!7q>RQ%3GHmuW~$7%*SIM|Vl zbEqCf1NqbR7^v>28TX(Ck;^CRQ6OsVnvwIKTaHxNz!~RAVh74=C*zC555khefP85q zQhRP&{{YRcQ_FL)HG}cehWK2$>2nw#{VP&qJk6S(E2*vDR5HA6-57&IW4%DN6b8gU z6>*3LKHBmr<|P61^*hn$_^t1)L>JakNvCyq*rNmAt#jGUHfnfB=%pra+G2Q?ReS9y<7WdKB8M;;58Bnr#^GIQY z{G<;&pY)I3gC*ExFvA#zE~*Y$zv?}IN)+?SJf=w;xjlOh-h!);!O0^~J64j(Ym9V` zj93(3DeeCN-hz?HZcaK>PQYaOiP$dYnM;AT0Q_kx;kAS29cu%tMu!W(n}=NqkdOIA zotSnt*Z%;a@NO>>-A4#d5NA|JN&1dom2h}jPFo8g zaHL`89eoG&tsfZnaAJ;3uA&M{J{twvaQ+i)lHIqI&m-g~e00t_io@bIufuOz-sf|T zLPR+X4_@>TWQEp1?2Hanslgt!)5|T~#%bhvT=Qo+HG^rR*2jtCbh`fl*$kr$tf2a0 zktQ|;kESzGAYhFmn5iQgi5|5k)=gZl!2N4>(~PK&IIjVo5HNCl>FTHUtT2kBCXwgs zO-ZL{iQ|mbNgKRVg(L|b-J^+Zr8rgtm%n4_Sz$8TyB#L2@NOXX^R2vqNpCX+kq_!U zMN{MLCk>u(h^!Vu3Y_iO{cEi?mJQa?<7UM0&b%_b+F^=9aGBI)86VS;sw80JvB9pY zID>PpN@ZxSB}UnU5JggN$mbNgHQ~Cjvy8@)43U-~6O&Xf?xC7yku@CSVmj3-bX`r1 zd(e@XeECMe6H>XhlNQgg@z)?I$0AqusGn-JX&jQexyeX|0X&9tf%=-FOVKFCIt_&w zAkI0OWdHzqZS|>wIRXIu%}|0QU~YD)6##V@FqVu&pR^J_4)Sk43}RksLK<9T|AK^$EB1q@*s@nptcsf zvcjmz=2ATkK1R|6(l^x@dbq2Jd;}NyA*4J?O zi!i}-IAD4l8pqqdDPWz({?wXGX|#0KJZ8rB6u08m<)@7_B>J!%4qClv(o7+_i;0y! z0=bSw9U@_;KTHbb^9}^lstJnqi@?ktz=noo4ZSMZnHDMc}831_U&2=$8BI1lSK@MNTXr8DHObr&=qWvy+AXd)d0kUkIdxIZqHXd!;Qq9)b``2JxDmLX3yU;%(IVtv{EjyV z*z&5v>idInGRY%FJZA*wAo?ESu31{k!=-zW(!|lYD}nQ_GJh&x5m}G7Ni<{1z&Ie} z5Jq$P(^X+^k<{FLK_TH*{94`+@iD~F54wFmxv6k(i7oiO&)Iw?f^(9J$05Cse@ejk zdPpufYjFA05?{qt$pwZwA8}Y8#j?B)8D(hLGM=FHqBgA#RN7~%M?6xqsz|KJaLNwq zeMMKi;xJi^GisBf4t2}l!k5=c#T8u=Y&@zPZF+P1ij~~jUEQ0nGwD>Ns~Ux{_k2Qo zmMqxEKze;D{{RD3o+)ILPPorJe51W{me+#j;3y=b`I)hes{M_mw^w+Eb&pUwv)kLP zC$jXGi(F=SZlsCf2Md(R>$Yn%oooC{XCDpta@a$iOK_}Q^CW})D;*!ija&={T-3Ek zJe{!0GaX0|DD|co@alVDR4^EV2hyXBfbG3Zjk8QwCXmYPGQf1rG^bX>?*6qswPc)% zfWtUbxj84^n1B_|*rZp>%8KiZ401Ib5_3!n#BM2<8j1kMKsmqtXsoB6C@u<;YJ>qm zE$_8i<};igwOP-b-iaKCBPW+Cp#>4f+Ze-pZBO=S5B^p^^%^3mVUfKZEy=*txji#e zV=Ie0VmD90jW^gW_xZ=5YPxM$4CQ|jQSjM$jAz!4U$8UVg{R$-f$5Q&iQ)1OmDRs0 z($l!_Pp6myj%+B$dud_>vfvJ&?Ll;NjVY3LReV8sgX%kV~}}vts^T2 zM3z$7BRzB0wRmmptpafU5r~YwDLQ=F13$H8%*C9u9FxCeT09sbm9+*El{$0Cj#kw{09N>BNFz|wAk?ZxeCxmFGAom@u;S6h zEU2pldQP!0z+vrBogC9t!CT8~HJousj|ZCt;orm_qde-p!lF*;5d{Il;OB38*IRpL zmfe2H6UD{~>|FIAjkc`o_!i#r<4D~Vb^-aHU#~jT8`+aZN}6vA_Y4o~Lp3;eSx5Xq z{KamG46a#oo%+&N!J(nS$9kAj&4mP$&{sU8Or#n!gOWC)tLT1P9+c6*0{;LJ_aAk4 z{OIu&L$cwq%x-HjjF$5;B~atPDmb=xOc@XaIVU-#8@{zIk-u7Ze=)!V9jOe7wuy^s zm53dNDn1k-`9o(tO-$!jF@w)En*d=>uK1t8oU&5UQnYgJ4nYkk$VOp^i1IhpguPT$X z6*_ajF1VtFW@6e-<0}!zuGKZ+)3@@E{amWzMn>xFsyT0x$G;;+jdUL|W2=#4oKv8( zg7D@8FCZD^g)~LsvG1x!By+;zu95}-#!o}OYAT^$p}@#$DGJyX;nu@_EJtG1FS6UA z7=?Q0JJHC%+;7sGQiU3}B#&REFb2`8l`+Pnp$fdJSZ;*#sj`AOb>&eH8jNcI^3PfT zWlp<}qkdFHL#KVnC#4MB#)r)(3Q|h^?d`^<>BIQs))qwT@4uPi z7R1IQ3f)TpYNB4<+-FS0W1mVr$cuwFnealYoRhi7Ay5zIz&j4D(y~N1GJNy5;%WZC zal12pDqd6^8LRe~Bi-geNaRjwj}x>5{olYEpe$k>)xM^tmT-~0d;8LPN5CWTJ7#eB zk+IVo8XTNWWMEbnINNsV+KOOBUn&gyRD$|+F*9mpCwv@kJwZQ*wPFRz`APQ0K;WJCJBm*nTe8%(Myw36%3(8VR?3G{^A5lE zsy9SKE*A$Jop~Q>3^KL6rFK4srod?^zGIvKb|*Zllxc~*1WUJb=fiD+4_clfazlco z{HCkPXCx>j3^wP3%7^xc9PmalM-=)bGbux)wpW-nW;j9Xh?&N8%G$#1kg|-7KNYNP= zd>!%9k7%eKMreeL0U=mz^{87ck%wiM3O-<))riiS71dZ{nzalGO$Lau6iXJdsmd`p z9^EK1Tm+G1bXE$00s4DU-d_=9kc0fqoOWbSHD3#bH-o0T~809{TLXO&F9l~@6epweR?!Z0wNW8c5hwK!ZdsA){9 zk?X{$ae_MysNE-rG~!#r~=q;`M=*0Wqv# z4qxVL2XxcuXqd71dg!Kydv{!!BX_~}-m^ughh-(}B8V83i5bg1kzx!Xr zQr$-asgh$N#0Eop9QXa}7g10fK45k@tPoX!!5HOF+h6(>>W===_%z%= zPh-SC)6!UDHxZ`VA1ny`>tBc5G_KN2d^jOM9Fv6@7~Z`90ER;s+05eWRPJ#kMQ~Sr zj(X<399k&&a4cC8GAz2YjIhDqHQN=#)2H{LLB+~Rt>~jS8MwKcXf2B%5Se1a?&VLJ zPrl>utczPFm5WLmGliK8HVYL~(C=Cg1dvMb#*o}dWAeU9Evx3nHs?}R`&4#u#LTw{ zteS>!t&T@O=JcbBR?g^pZ7EIv0NOXLu2SaGJAgEYU0&Vmjp8@oZN9jhn({&D2(E(u zR^eX`tQlV!*TWe6&2skk(n}QcD)}Oh%zgG1>Hh#v!x+X{U+|AR)Wtilk&*G5=i({k zQH7E?q#n7AbNS}E@z0iPr1-^H+*nKxmRls-{*qVru6=_#WdZVT*c*Bp^75*iLx$pr z2=W=9^P)6jHA8{E<4)$BTb6bR{$vEP&pZyFokK_n3BV^99P&SE=Pro&a>&{jG9G|b zyccM-Dr*B7)6+fZkTkGJC5?iW+!``V6p=E>WsDZsN3xUDnk+>i2_T^?Nx&U*MoF|u z9F3@H=r%a*QJUIlLdzzgtGha;qP1O&yA=B7iKCB7q~ixW=_ir@ z06N!6#?ZZ-!Fk~Rwa8me@k*yw7f{Y?zVc3?)P`0Z)a_oUpsJKv!Rphv$mISVw`;C1 z9ls@$XKZp6yYYl~D|Z<&x7njWk+)3s&#hj9YaR+(;KZm%I0!dp#?{K*aVxu+d>LnV zk5;`T=LWq008xr?%946t{r!I*{he5*ryD4~=7v_SL2EVK!WbfHByLKhCc2It@G})z zIE>l>{{YL2bM-u`O-?>28d*{`8?gXaVsxk(I0I_mvG_mX*wFZ&YmQ!$c)u^U2kIzX zOaB08%Q;y%R~B<9I-`o@bM|5VD-4U1HG|@ga%;F57^~~>_h6gWbo^wD7~*mp{k%!gevo9wPdDBa?4=*3xg1az$*^j;vy8|=&h z{$dB~QEmr*yy8*+0IX2JJ>;S*cwNvjbp+#Xl|;!4or?_j6`{ne=aMLrG}WL0NF%VU zrnj9BnkE?ZrDC9rv8mog`fpl%Gls!BEb^9QIl_UAhcb zWXeZEIFOv|&Zn_F=n|6QgBJ5`kawyI#2U8@rd7i!{{W~D)|^_QG|X%3c8YRH<}*~! z&0P)3#Vlqw1)O}PM&hw9Eha`GRFFFkR9XsUc4`qdp~3if`i~G;r7W>1*C8D6IuBay z7SDsmp>1t#(Lly!hg%=6O-p6Q7J1fakVa;mV;xTZg1rhZ4`A$kYiM3Ma;FL~a=92F z0u5ka@E`PDH$lyhX*P;9#}t0T+vd-&;a^y5MT~lQx|S$eAQ;WffHW6LqB(V zto&6#@<+e?k1DTnOwWQZmm?=MgO(Wp@7}Uy;uAXQ1SvdP9jv2U7*xk)zpN7;e>^`AEZi8i_>d^!c16`Zq|XsT_2u zn!xtWP7iHsGd#Bpzrx(BpD71y{VPZPPR1<|LSA+)q-h#c2Ow?891&dJXx66}aG&|F z>s88@j(OoycP4dQb_pKMQUxc%8ngqu^a%MuwwDabY0@f&NYwKOwB40r{d#&#Lb z)yZEf+ugrJbL=&B#ZEp=J3QqjbYx7P6sJ$UXxUvGRs?Sxqmkr$im!JVbk&YjtU^m` zB00c953uiBU7{qZi;w(1EcEdUDA?IZ0bn=qk7MY1ejfbxFxl^3tHJF)+rj3voC!e6 z;Bs_4{{U*^ejxZ&_*`P<-a|FHZ8#&$r1$Ut0B-c-KMcxH@{V{$gDhMH?be1)rp__Y z3b}E^Z!J)~w$7*yHG!4?-t-y9cB)k02{YjLj1UL_EW7w$v>wa8$4u#^2tpMQIxWthvtF)+;xb zDA{3~G_2Y=A$xnckAWyC`CMZ?&3XR-5ww$o@p)~-9Z*S>e&d>-!xiZ^oGR2dq0GgVP zIvz|v8o1}5YUVxxxmkE^&9fchkTUx){-U~-ReyffMrRkr5uoDt3d-1K)(9OA^`CX& zJ7i-g9*3=Te;3&mSg+$;ls+5#9!9zF1kkHXl7nCf-1{2R(pGakP2~8MV}YLHHv?$j zEJ^4p&^a{+JAwcTqY9&?3VV@l9;3TAAy`TCdObW)_M@`dCZ)t({{Yb~V?ao0q-G#? z01wpHKQ!~sa`F;84nl^7)8&w<^s0<0*`7?iqT-IHf`t1_g?Axylh-({9Lla5P| zz*TS$Yzni*kO`xZH~WzT_zYDk9rNTPVb8y%a7}!f(z+!3)N`B~K%EP#B;%Q=mny#1 zn<)(LRQh!_+k7mZ9|_dUgCvY|+;aV^i9|9;0t}Y&fCl8(qVVf4u<-aT+ZvH@*yWyP zssfQ{u=-SGA);mGM&hL4avRVdPY=z$IeS%*WZcc?j|U_$t>SVGbMuc9^$|HUjq7}4 zJJ5lSS0k{dIMOhB)9PYQ&neK-6~_)Yu(>r&dv%Waqsb0hzPU zy)YZ#f_JHE1LaV(*e8(~^`eJj1gY45CY`a2iizip?@Tv1+b3#t0NCNP$O_K*wH5ve zy=L62#>3Oh2k%-~)N##6D$MxB{{a2ayHVXg`*oxTlRr8cqCYwr_n-!*r`y(*12ujg zg=gXhH#kCsu{{)Se<50Ovs_B<#{}#E*a6UcQuu#`%MS^?;xYiDNOVLoxf<6e_NWp_ z&Nm$DCj1_cql#(BgDF#vwH(n7Sm5qy{2~rjF69NfhF1tdm2w#J9Ow{VU0cE6!*L=YeOgrQp8o)qOkQ2QhDN%RSzyHK z+^`r2@U54#Te2GU4Z3Q-xotig;w&XHcu}la1hZ#3$1Ho*f$+4$#Y1}tk`d=nq~kog z_TTWVx0Toe@=8b;$$ZcV48@qc6S*guqnhW;BD0G~HT9maR1g1f6aUX1Y@*7!(0xTm>G3m1pq#GYiQf8@bYl{yl%E<5wpP-bKkLUel99WyYV0DjXFp-PW&wbW%I-yILGXVCg@Jgk zE(jZmB?=GGMt?dYtelRh^Sw2rX~FOAV^)IBX#>gQU+fY&e02<-=jHX^AJCaE*Kqv5~kgO68q|p*UxxpUzp#(bv%8rTv@Hn8zCA9bSM^q&-YO>qBQG zZ`am<3M9j}oM!`b`&9f@Ddr@T`2PTEs9Ov>^4gb(PW#dVz#x&4x1|(gpd0PVt1EKG z^kprOdsEmB$%okBbvU8Qp+<9@a;du$u<23LkXeqxfGSD}9)hYc1mdpCx$oMk%Dl}c zG-+AKB#o^%i-LheDyZBr!OcxB!LpYs zY>`VRG{%tvEz=p^r-ozDU{J>~-ki1<8TY9fvucLk#xxter&r^!{HT&PyNw`>0f0RR zDnNe~8#vIJ$6`j`=cNwZ>y_N>s&Pe~@rp?xsu-|b4ExmMU?d9PdBS_-ji^vd;xv*U zm2w3wlEJPd9%UMof5_}VT>U9nTX2&tI)l3_5}Ir^q~;9`sPoi9R7q%iDRc!(m$zU}_8q!8Cg*_;(PDaO*feE>h_f zzD|*y4-LB(&lJFESw@Ko`PUn5^c2tV`MHd&w;(Sz2)QJG-nXuBLa6F;b5#pzb<#$8 z8a+I{%xX;zZcuzmBjLMPb$D{RhSRW2V*?-7p*O?vNgPTZbTKn9b;eF{{VRGFX(HqU zdQz4dG;{Z@=^1j7i9NVHHd`y}nXV#s46Q2y=tpB%#@;J6NaB&5akj30)zV&DYk`Y% z10uQGxEsSR0rH&ElvN7G$i~NNm_0jHIOM<`d)1U|p*gO0iVRx|iizB8b5O}WYFRkt z&XCBF<`|@rq7ua9=W0hd+npL4aLfrA0A$bwcV47pH5>ts^jVLbfy)(Loq!+_y)Y!B zxZ0`CNCt}XX&zz$&r`~)ODG2l*%e4da&z=Ql{y{hL9pa~4G~VltVQU-8>!gQL1IS# z0F?p<#xvLQs|t6)sQ}9(f^cv<;8cW+cECTCLpyECkOZ96&_Ft(se7Z03-_ z=hv+R9d_QZuE37sMG>e0@=tv4TYv+RS)4{V+W3X$6cdff+O8dmlnXM;%UE7n6@P@t ze&4OmbtwbWYNqB70}2?)w&$g3SaA8`x3}Vs$kxpvjmSAEow7&Pt-2h0MvoDkLYA&o zjAlWvLBSwyKs_q`{gl=hI-NAzPlthy#{_3d9SEbI8)tgP)>$>GuC)lmQl44bqxKFf zeH@9UWDw4Xh0lheRB~11Iqz7xY~E68L3nl3T9U{{?wtMWF}^H{DOpXMGV0iu=D&qq zw*h4UjNzB@3_rbK@hQg;5Gq(n5QR#qEzf>gBmB)*9!yzvMwN#WypnlmkwnqRHGQ0X z@vn66w=r3qTKnzyW@SQ&@~Cf{1F5Z>xj0AJr28Z#W_a+K1ZdCcSU+TD(hH{9DjJ=>d{R53ewAccsplah(Q0dH6mF~w6VIM2ql+fWwPGd;fDWU2k%~2Z1Q;VD zIW-BCAPfe>Vg*J>x*B1F=V3%v(!ifDy;TyjuAzVrK~Q-ahE8!@#hW(E_OikqCrRHY z6{QynCBr(R%74X_RgIxd|{pz0+w-;6u#75;Z2dLlntf7)zlWfizWyv|0fKQyT!K8rXP`Cdr%umLuq_z_LjXbF+L-~<&mbIQIioS> zrv<%tpfbja8&C{7oQ&6GWvga%NyenoM^T?_)WU(1qx*^i+(#x{`SYO=B1U>NPB zBdIHj9OL#i2|kF00Du6cf)62@3rlibh^?KE{Xe}(E$JC})<0Z|UgSsZ$Rm<*$Qa7%E#t;2-sF{)Gr2$1M*|N^eMi)b_1O{-5F1o zH*we{b(UWSTG&P;xti7?)#MS$0+dvhk6Yf~XTaBH+q2rncOPHrv+Ht?Z4{Z7KPqu|z-(E>Pa$tpkdCsC2~ zYM1IHTEP>JgMo2*Mp-ucdy>)}5$MZO?c)lMpu4J~onTI1RqA-4$%@Mdvb}6lRObQ3{Tz_hZHd=}*k-=@i z*Ts&Z_hTpl`eLcv-9YM1w9^>zxn|CKSRVcMa?wwZ@@rn+rY$bD)yZKmmcK^4>B<$aqd02=@(oe1Th%ApX{ zppLz2M;f|TU?QM?)PeJ48gY!%YQew)fF=){d}5FQgNlgGdQgb^sg9o6#TPzWU|bYIk9cAb>?MAZ`ivr(>ue%8LU7 z&WcnMqo1i9jSbYP+kbwwYTbbaSm1oX_RsgI$ObS5-Me~!N>@zGs90xgjl0vC8&(VC zdsNjj%uX?+^As~12EiDruqF^TkUI)2NH9h-`ub3iX(Le}XL2apBVkB&J7>T2sbCIv zF_5a+2Y+gc%1<-TwHjD9iHe;qvYh9?_Nr>YJ`TdIJq#p~yi5xWYTQ+rye|diW0uks zFE8`^P~`=}2W>@H2heA4)|IPa@r;8AEBy{$ofn`3_jUEbb3+-MVC$nj$miz9{%*20L>(t zR0$3k5O8-p;{t}UCbej_9Q5oxXoYe>AmE$~YzJC&I;AbBoaIO&mq1HIWQRF@ON;}7 z&X;^Q&5g<1`_!B%49Y-WK#rSLV(p&{jfZ|_l2!$yB6y(2ngJs!4#f4L%5_G&D9O%q zx8Xrth9F}sG4-gV%423LtM0O}{OF(oG1hXqAamQLJSnMWA|N#nDaXol%kZaKvnK|-~{{ULB%P6{Ou&$_d0*4?~HM*S|G9)L% zAgDWlYTO1}vEf4))W=-TPLa&@=s8o`9)#!=mPpICpkJQY|GJ1lUZL|wo|(gCYY z3fZXFBzmQ7NgQV&0oT^5NQ{n1ZA6_}9cU04yft1~kT&NenjD!YECB07z|{JeZsvwb z3U}C35F%t0ht?Ur}v)4=-x91+X)g66Hc{3vXFTC)mqzOtz65m`9wx89EW) z_Ng*TkxMjj5e}?^r#eC9Qnrijmf>ZUAz{dWIO-^JW(y+>Pa_Ek)CW*E-|JcV18?EZ zA#Xc6aU}|mwt5^6MwK9vRtYXllyiOy|$J4!2vxp_M4+0B{lH#Sn*t&NOp7_0?xwogA+cr&>dL`+-I5e9l)ymYk^%!!fiZD>vL^t z#x&F!3hKu&bK9P3t)0|X=m>Iee4OQ^Wr_XLje;fb=hP?3{>2nSBUbH{qut{{YBq)A%jY!@+I!I}B?3xgRn5*P)V8kEV;0uaS;BS@f?# z;jl-(7wD!efrQh7+i#K6`&F$lTEfLbMH=Lj$W>d8IcWrJ^Gb_>&wtYu#PQFIzTYQ- zZ;=4%+iL#+Kg`xKYH#H9c1g}p=cW7q0J|;mo+NmU>18LvO`{*Z9r)i0;k+Dh<0+Qr zatyu@1R==geJhB$x|Uf9X&K`U<;KE<1_0D@+|Q@Ic4qZy!Z`7&NY5jLl8jc4!{e?U z6}5*4XxyB~GG+0}SRelYI=HZ#H<)t+9*3=ZC&lrt@4|dO=~FsxaIKsb4uIq9kIKCM z;Y5B`)F(zE)a(}=tAm@9x7ieykVle9B5)Wg^K}3W>;T)|szm+~*2)+VK+j5enVRKJ zS5~a$hSX%XMUo=qx;ln^3%cav+Ln-Z00Ds7^*(cow`D7M>l(}9tiww;^7~b1bc$y% zGZZ@wU}qk+D7-PDF@9RQ>&f;5@iYUmCjDXl1ciyQ|L!u1Pcq$QCNvkdi@}Ai3T?K~~ zdEzS(SY=fUpd9J|U~}t0Plx50=66W;*<%`y){P$v;qVdSGdUg3H~LW=++D(Yy;8>DG+`h3Qm@@@vO9|9emJoZ-Nk&P0_9Gj z(<2-GMOHUXg(c+U_*Vsr`DA(IWmwxzmFj)#V%LROSxh8?EIu@rW+!7=96O8XwPghs zR&rlf--R9ch1W;=;8nR9(XR1ie3>1ptkEetv8M-?D=qJ?SZ^~7P#*f@1Q|&Oo@lYr zb&wH(&y`kdXyhbduH&s%`&^&=)A&`3FSK6WBoUF9JCZpL^qi2=DBRg5l0No!)H90N z;ql8N03e+}P@s5wel^hAaO-$#2*GS2cp1h3>OGLqeYWYbz^>=Kk+4f6Co zsx!@M%1c}zWg8H4x6--xzPOZ<@)tdh^<9|b1D9&q%Jf1=>;4|NhH36JHl`70m#D6u z#l_DIp531fADMOsmrrWq{t<=$0P&Xxr#kbVImMp(Q36O!tSo#b=t<_-_3hHMxX&4MwR;%OB$0oP+^|2F z`u-Knu;h7WoL7=^PyHj3SwRPWMufzi4*Sta#C*PEwK#~#0bFElf^k-2Kma4j9J^FB zkidFTTTjRiak(Orx?l+qoOzh6+84kwOLkMdx7EZt&rK| zMRXivfz4=5#6QG{{KO5{+*UU|jcTRk7SfS_gYjv2aRC^Br(<11-ABglu@;E#PBJ=3 zIqlfj5(W)gwYqzD1y0?md9s{MPZH|VR*8>={LspzjCzW|_=AO87#jA`BaPR25-jN> zxb0n)t^KbTfo<xd1&&Bn~1-OqeP4w2c`}; ztS8`?X!zG|%baTyu0LG^gEI&#~8JTjmsSN^TizMp&1Oj?YTpl|T-;9ynWJTbINMPyMlyS2Irpa?Em|gOY10kxq}KMo70+#KUni-{cl5D#dY@bSH4I_k2$*g4j9k`&9 zvJ%K6jJeznL%n7(l4&w`bK82ea<3)V*~rca04dI*Yg2~s+i0xTYmltc9OD@n=s(W& zOG?c0H#t$6ZNjeZNz$sMbanv#9O^UhtLtz~wz2>|@EIS7t!u6oYcsdm@eHZWQS+zV zbgNttUaXp$@5F_WkgRtHui$9rFKFd4ig4OaPds|b3F^b4s9ohrHHX1(md@-6 z8xcvf9^SRmTtluigPQEd?Huw{!*J;gmWoc~5(l9@9X^xOyty~hD91Cj#U~_|I4pPeq9n!{85m%VzO^kH*r^+5 zIL$_7F8KooHOAeaNR=%G)*B=aezg)o=lW2VQyDoN=!@q$#xc&74#V0n{vZ>%891vi zjp#Ou^FPvvN%yYI3*%?c{{T^+EY zYqf>;IARaf59n%+FcfpZt&TNl*0ErZDPTuzZYvujIsB`KFXVJXp`Sio@lf9!{AAkP*xtdGxK65=L#=*jwL0cE7=IFy@D1D0d7B)ZpVCJt(1$cO2+rpE`W%Y#oJ5NM4y9^Z=NhSs31-abz#;WwVrj4rD0Dws{|+ z-|0k6E#(IRza!p@1o{A0UbyzDV@HSF+sh2{Lg+)r#aMPDodm-t^4C-Gf+~DVccpZO zD*oiCB>pvmO)DK0JOiEB3ew>mAMMvbK1?aRgmv`#)o=+RK*=WpyBiyH;r44D!$8*| z9Zy=Qt7NxDo0)Z5@d$tdC}53ISCWlGYUdfTk>8)CY}?9P;oc=YgqEIUQS{!jMhOGG zFHs&d_>W9aB^9XrAq*nAme-*v1vEiZy~s7b%@+Hme?BSM|_h@ zML86!6=W*Io_W$;oDex=)|VLKOT_QZ!p0Fog#nu+_%va6yoeHW&bM*j08I z9Jx<8*9f20P-lmMJLR7(*mpO67ObAlM&!VNe|N%T*^iQ_u`xQVpDR0AjH^LSq|N z+(X9rGVl?+Gpmex;;}yNe>&XYfB3!^#JF~R(naDR$1*>`THtk&GmF;7@UsrkjWTDL z85PiQ9v1Pk-drg8e}<>msjh#AIz_}GGJs<&0|VICc1|m|)WWh~+2X?|`Fub%j4gI9 za6s+g@4I}F#Kw58-}56r}x?e3mL zwVB4mM#u853>@N<&=zAEJM*Il(zpwg(|WHPZB`Uua-=dWNx^P?DhvyAKb;kq8@bX? zL(YKhw%d-GrC>$XLgzbXs>v=66jbFbNXaDn&?Gxx^yX**qY_PV95D@2NWoNhCW-^3 zhA9_IDZx|B8n^JwJ}<-&u>j}{#2;_(Tb~o~#AyEj)FT*?ND_LEK>N@J3_u@BE;inR zr#g4%M<31W%9x0fc?yei9+fkUW2xs+Vdck4U{;j&s&eOgxcg?Q%j-#ujh?o+9vIzs z&tY0JJ4o#9xhEvmdl;T5B%Of^FO_|J(X+KcOTfmP(vhXgq>|YEbNW%>R9!BoIb1O64IT!&EbMm%lCfe2H{PHdd{lt}hIaL-H7oIP{{YmyBv_e>3}jHPi6Rk#cS@awC&S{3+DV1HWX z^_1|n{2Tgv=Vry&{t!9F4O9xOd*ZI#jKJQQHC0rF!qn8x7stb5Qv+*d;+K(?YYS(mZv1RQ#8Ob$JU zhB&D##A62-rs8otZJ5El_{iT692jsf!2brNWZH4U*%fs=5a9PXfU zIlvsn76@#vVGaJL94=UmGe*M$p~wVO=Yi(p5rjN|)O!I{X0a*ax_sFr4up29b5Tmc zMYCZ1H4J<*Ta$j5Qvrz5)q?%8HgE*<>RiV*`~X4BuLUDvj9nq64h&%A{m)2lb?KSZA)(Lb`|m zU{iD?^rQqvH2YN>yH6J6KD{GIr&kPHL1=Kxl?w;@ZTIe2OiGUQ}qVT!o~ zehs9yVVJ=&1<;*|9FL_;?fX(YQB-#yJ#DmMfZo_@7u=QMTP?9U8kRi2l{{YH2#aqcAvkz`xLL>YXvb}kpm1f_AacR^@ z(8X;T>nme5Von6{#$H?dagXJ}sd?avSZ2)DQ=X!omn(uYI)g<{4|jCO!i?dbF~EQuD;6f<{k;8iqLlpXNJN7~;9Rxe!4ac@HH~T^zPND%KWh z5U559iw-A%;5QLc{ai})5WeQGr+;XY>yilA}eAooxoI{0i8xwLs zS=-Bo&$#^St%-)5R~^N8yu4E8DT;VY3I&Y;$X@61t*#r#pqvD`A{l?1?zi-=FI9?8 zq>;huVJ6g(zN|6yr*3r=!Ay@ZJcy?&<=(vWjWd>?gxy7SV~#%*I~_VfCm%}Ev-n$y z+Y zClzPC>C&DxDRyHYhV5XJM3!<~N_RS6P|vLqJ`sji@us!5g-8DYHTrsi{cCP6790kW zJL$<8%g7LZ8KwLySN<0WUmG7dV8`*N$(=Nc@Nm8zXopSZqT}vBi=2C$)lUsBBxwHL z#v*Zqi&HtaqkL_GL{{ZbPjo_?i+9A_STZ+%S_|J}BC=&4r200k;pN;^k_T(>eC!xHN zyhbZ-2C)Kh%$|5S@cbc|3<0%ScUBYpDxl zxj$Nd(-X*#Xt?7`Y70;C6_V#TSYuO<%+@6T02bNE507{ZY)!ud9w9q3=>+?n8uLQrpGrZ` z(yQ7;`>gD*K0BU!hLt#NpaW$R0>plwr3yd&p>dmdio^D3`f6B3a>cL;oU`BU6JJqP)3FgA#ah{~rNeL?j^Ce7cG{*`hliT)z4Nyud*<{f^c z{NAfV?CPZFOKvN>mJEC+E~K7?xO z3gEJ4Gr2vzKN@)7B#Z{ruq1b(rX-I`owmmR0EJCA)Pp;oT!%W%h%1wVfPzn#N!qL7 znbtB$8?eUSYMS_3aUm?r(>?d6bx4rDzJ}Fmgr-8}_DllUV>}M%y3CD3UN~ zSdu=2Vrn`IPEJCHz$9;qw7?`zu8as}U=9d1CD`gMkjFS3cA%@6r1@~la1At!6rC-c z3=(_N0h&TrBwz!YiIXk91}YCBT|v*AuGIj+8C#y!R0Fi2>1F6Q0;Z+hw}y1#eAyWl z5jh2bi_0O&P&rZ1v5l59fa8!IsemRPKm?`S90lit-l{sXvkdCra%k+D4uv^=INP_i zBNz~~NXK4upwHvAG%x{x(}B3oO%%sCv@3`h z6pPBJ2YO;Ef~8T~MlKF9j+JIfob9%exTs4hPbp9iJhUZjpe?&lB4$P^K^Pg?s<~W| zzt=Qa*eL<`8O||M@XMC-4D&cVsf-%q7&soHqQe>MzdrO*FuRS$*r*fB6afVuZuIU> zayF;j9259;r&1NcAmDTMpa>uWKxED{zB^R4G8QqhU9t40kfy^^8b}5*taH>*1GdA{ zprE$RMv=;Frx_d2fg^ebTLeS^QLz;1AxK>#`IzTXs{U;{$tQeNlP!QZ&nlV&PR>X~ zF&Q!xK3sP7tCCL@tH&fV>(r-14u{sVBFRz)=QLzo7Ly}Rk{D;@tfdblE?nP-9yrKy z!^9)>5>MN@;r?`D6 zk{@z<0qb0SrK=u23PP-~%`r%svx21KpIYDeqv5V2X=4=8N#of^7?toqD#YNOJn_h8 zyK=Z>^uFAl?J^j2(dfwM)Z38!#Eb&i!|$(!k?z2;6t0 z+uBWeGLY1RjX^Q@j^x$r{tbP><_UWNi%`n8 zK~^2X8yc))F2YFI0=u(v#TG7(^7uW+{>@Rmr^09*iw;8tKXF_Q)M~n8F-zsfI1$v zYU78(w&dH0nN~mv)slPjrO9+YB}mupv*p~ZYK5Lj20{XQQ`{IMnk$$^#41-JO@Wa2 z9f#vq?yY0BYiOgrS)-Cst|iVC`~KBKi(ReN*N|d3#)T9ui=ymYV~NLXG~5Yd&Ydp0 zvU_J4tF~IM?WUGUJQj$k#!e1>52a#HGqh@~Sqdo92EgZ?betyK+Wb6)@tNOV-L@On zu-|0Jr*m?aae_vsKPvaGo8Y^-E;x0=yg)CC0+H8k`TZ-LgM>}Tc-PsIO%cR_o$;~{ z>TA7*-U~Z)w}d2c#xSY~{QYS*0i59ZalYHsMq3#fJ^uiuMW%{4nr30)s3ghvMm=jL zQM1V-TsP92OE}LmI(k=YH^B4V@dcC6%jbUzHU96yCe5&r;kTuCRn8xNrU zYU0Q^eU$gouu&QkGrn7r{&=M$8E!G$)=$P)dLXp0L%s~s56ORj#GD)6%SP9xyJW@a1pfp!0voe>e55!@ncHLsFYe^Q9LDO!IA6 zBoTr+ig4A`an1rRm9DK5OyhS!_x}LBatun64#ZZ;rc#l18-|60D`K{X5r>KJ*5Q0a z7lHRy2hAIFAlERmk+GrOUX+Z(8+nez_7zE66xva?R=c&eSC$B+58=5H%clW{wPwo` z#MpJmW6=9myzPpfvSZM9Q_LPlx+wGP&d;SvH_+0$r&BN zqK9WmZy}V6n2MO$be;Ks+N=q1GIzytkX%b{!J~4xAmck4-)V^pBK)!d!m*TS>xNE7 zkd;bgoFXG92ewOgKc%T?8ry-d0{*}$5q+`f1d!BTOuAY&@Cq_JtDmOS)sdxKr$DUWt1~ySw zQR1|PVV_iNq^d~6W*s->%8g)%5mi24yX{&s#)2Xa4o2e@4bifRfg!ev;K~qq@(f`) zztW`ztSXKrSmbugv-Af9oF0Hw&)8s`BVsLYjWNfcsUodS6l{`4jxqsO)AvCb-|?Zc zluUgIJP`})gi^{1u5^-d%$|9xHc|bCIcOunU$}`Qo53Ngn0+m}ic;fMHgb_BqPo+*d@6NkjLKP7%;Xneg{vWYH z5xsRf$ts!T2UA-`b!;{#t$Gmn#@WLhZ!FXzQUt_hMK}^LC)WcMPajI-JjD>igQjzu zfr&xoowha2DO=g}SaB>-;}OoW zI{*d`Kq_#qh1TQ#pIWgRXk4F|^Q(j$-5kEB)GQFcJ_ke*piq5D^{5=w$=Zg;73fAO zDo4!DZgO&cvJJ|HK5^=5O^j4dbqsf=K`tS#ULPcE#Q1DV{zvo%u!mi_*FwyX8@n(n zP6+fMkKVcEAZOaRv#q9dW9Xc62;4TDkU4d$K;)^=S38hs7Q;?vWgw50zoi>e{{Sw1 zPW#lup|G(@EJraeHo@B<`_zTuI?R^vzA?B=8W-_S04zqw0OJ(ANAs>*BNc2&vmBO> zAX!pKa!$bH0qko>PFsq07gQWBHpnrKhhTb|kOOV%IuwJ>o-i4J$>+Ut9pvJWc%uq&-lNQ)L&~&= z3BUgUPX7Q=SVw?4?h9i$&O26X|9a_nBhZV^Zch{S@HRl9$Be?PI-Pbx)kHdP<}aNcz+k35HN+c zr~M~!T~7(Q`*#eERz7evx45j&vp>Wf6e{7btVYAEk^can=JYRyuaep1w3)PtNGMM% z_OD&Pdk-brK4NfqvN*`-d=XFKsHxJfTVtSPDzIF0rbZ0R0QpWSKl{=Be@cvB?o0mw zRVx@CDBd3mx^c`zYk^OeyPiJgJ_&M1RSWo#D~E^z+t2~xLG{Meb(o@s{Leay9rngL zRElZ@kSPE*ZxN{#9L-NiQA2kb$)IIsX3!e#gEp4Mol0${tkqj`R2w#N2fiB zKiayhm{#Hkg~ygfDr*Da$ngc#x%rt_9@+l2(lt`(N1qtlDIO*sWF#6QpIWuYAU6D7 zQ;#f|arNi<)jW*j=|B=3>Mge_mf5CZw%hlnBD|aBvF%k{79m%1PAcQ_^yxvjU@qe< zKmg}{{VG{YY`HO2K??FikDvg5TCdWJI(TxCj)Q*m5^yUqNk}0rTpU_khj_dWK@=Tqm0#@>Up5Z1{1fkA5)7@S?*FG`AkaD1MUGqUKPQX)$ z$uy{Zolz-gmhMw5r!0AzWI!Eg!Z4#0H+?Ou8;lGZ1F|Sc=T2kaH5KKt{6$DHBO?TQ z8bC`xmr9MFx#Vb;$Uu{pus|w%Pzzv<@N-3hkBZppyN|6N3SE{5K15@sPXP0%5665| zzO^XS72<}ooDt0pa!1~}>Hh%uS%CgV{VS1b(j+mdvr3zKp$|%$nt&xsH5}5Q31*c> zo`4>eH9Y`GRE0oP)`62zUGKttE9@LR-b}cf;dam6PM?oI!nvP^I6>mPMN!oF5&5Mf zw?XxKMs5Bbco5<^b}mO69`p-v@JE$>$F%9U6zWDzuS?O+*rNL}E1@XB{a6_;sKT zTZ%?C%-n`ebJt2@ksVG6INPYMy5h-iC6WxXht!0rk%}rg{*+!XW>QZTHr4qz_pJ1|C_>6Kp8e@#lF0=mV<_>J z8X?azPAW{<`qg!lP~7IHtC7u6g2r=^jP|CIL-Hu!mIX9rr1Buk9)gb)3lH%fwMo!) z6uf~YLkwqet+|o;3KPYl06#G6>sDeSRT72_I%(-qm3FQy*=_M&4y;h6Z02w6ybF*< z(Yeo{G-0!mS1(~%P@{bhYM}^qC10BDp5nUj&yq388ZO2*%^5ewXlOwLu%fQ#HK~YY z-+q)uz`+zSGrbjX7d0#qEqEjJsOl$bCI><5njSE7(vdAa4)m1mN%g z0BVmjw<^)$=)MH+AquO4Temv6enMpkI5!u%3PQ&U9!>LS@XcLL0Lzt{__Txn04)@P ze;Pm9ZY^#6YfHHz2OBGSPJJl$-xF~d-Aj~_dVl~Y>M&~M9G)4ZTt15S{{RK?8@Virnoc_3}+bGj`#Ouz8$w z*EqrV#?@Nyjo3t|$K&$M{{YJrKriIAiE+f>z0u%LGleH2To1@lkej1IZ;2#1j@r^5 z9*6HLmi+}(i-_=;oXK@P#FBr)*lvB;@Ay@9VU9wiJk?3X68SRB-xxxN7B2E{+v_)0Hxa#lNjHzBloRiJaO=4 zDCB-bcpa&f&BZw6C7W}hwUhOuMS1Y=3odP9p6*RLa_0)&{+~(#;P%)!@dy5)Lp&V_ z+Gif>4QcO0%i58Ad|reIn}x&nenv2Qei)-K0^vMvHlKvt{hfw2!yq^W^7j1eA-FLE zX@-4PrEh0_XC}LAg+?P8R>dCVUy*Q^(Pmx?$GBd6G@C~~P&T3YR&~wY`35VQ=f_oY zMQ$$^_>LA1lE%sWfG&~y)jl)Dc;^$g7Smg3k3Mq}{QmU54c>|N{LzM&J`AeyfLRD5 zJhNKO{{XZuH4u2=Ht>RW(Gc?cRx?^$$MZoV=l&+CT|^bNr(c+MG&H1IZYI?K02=Y@ zmBgG%U^~V_AIlY=FBiR;^-Ftj=LBylHJ+fB1HCx&HaT?7E-0={k)4U=PrV^2Jhnfr z2!f{vVk$^7Nj!k(RYchOi6^iX0_4+&+-f9(F-~D@`8?y&iVGQ7lZuh#NNk*b6twtT zxf#a5R7Ow-A6j%4Lp3A>l07O|g^oj%^u}o%09GYq-Hj9>238;egN?~FGNo9nY(+~! zDh3E5rWvpUsx~=f(LrP*8FA^(kz={ZDi11~l14Qm4$Dy*6-d?3(wzW|MZC%82cYID z#7sAFqiZ&q{ZRIUC|R;XZ0H5SPj2Ne)@^!;jlfT;jrl5yXqE?Wv_Bpd)i zC%r6;^xM{fix?qa9bez^QkNH5A-;# z2^q^So$@#QO-&ah^cctGPGa(g&)y@SrA(qI9&a!i6(Su*g2x#r1XP+_jlkc_j8F+X z={Ql>)}A~UI+){6n`%@Az)I>NaL4D#&PVttJ}Y>efO0&lI#dZEf!+5T_oXJHtW|WO zAZel3^C>Wb5RIR200FORt1>YXev%UeJW(eUs;eck&po2 zBB2S>6B052nBpeqa=#(HhitcPvOoB_Va@~U7~lTy02 zvcX0&dQx^)VaRQPO&}W11~S_Yl-e*dcB%pr24V=wH5CWU-8LT7May#eK*mKe*loB6 z@D#vofwLTArYg)WHiH~z7zK_tCaUKkmv4UIGe{7Z=^{w@>**e)v>?`L0Ap}EQ;3>E zNF)Q_?^6&Z4(+A$oZxm8V5DeB#&UWt=9!^Ln^LJN2UDHt8Fi^fXJMX0y#pc-h{cb} zHXW&nBRI#FhbxxC0fb+aR23P{7k=~rc|w3NbJQGDDO`EEiilYdY0?|5O%7D;iU1pF z>zq=}yeziu~XSimKJisYpqd6dNinSHYF~;`tG>vRR z`CEM_sH~dZflelCTZ568Qb)0?%fzC%k$==7@j(DIs7QPcSn2syE2K+#M}qp5mmu{5 ztl8g31Wc_0yqP2tpo8dYq^(%bK(OL7@y-oqu||-szJZ~37s`TI+iRSrVA5+ zk&mr*)t|;~{A)iIet3?ceLB{KB#%AeJb&$$&0{nvAZ~EVx|ANJLFwtztlWGfVRry2BsHXwHASf3Qy8xAaQA_Y?9c&b-zFPt#XzW&5fI9J5eMYY_SBxA%Q zA%|uiefiKeB=sYx;lB<%JSIy^$reJo-YKJPMPxXLP!W2Sk8wjnInm+p~FO zRRf6H*-AQk>NyA2oPS0#(N-&kFC&>&GK=Nh;g7XW-bJuK!YOcM3Uj}Z_1>zwG?ToA zFd2$~;Em6)#UA2&NiEg^hzc*8!jB=_)RW4oqHH%3Na2VQrc%VJu;iS&^))G$7$gZj zvf(50K5_}hcgI?!h3vA?x=N%1NXWt zd5n#iK|_rNun8dViZ=wZxPB@mj5>(?&d9mX*Ov7@+^lOPUXjx=64}pUx`IfjPP4gS zMh<hyMV1%l`n<)}`F0!UJ;Z*-@M}#l1Z_){UgmTtJeh860F9&TE6wtYEV3 zy_uXY##j;jTr0Z(MnS@j_x!&Kej{P_pAFf<6*@IzI)_2-D%I81jB*h79vcI=Rv^?Y z?yY5*tPwrT{aj#vysJBXohuZ0@$d@AfI!dUNi33u+%BAF-*Z_X7}y06@i8iwnT&ET zES2QLKA-0G)ZAVzt&XWYlT37lRvEzNN7MDHd~V&ip8>iHZj-n{EOLwk<;J7GBaGyA ztH*6yFv}Rh)K51A?@(e%-ilPB>LVSq)}ou4m8ppmJfrZY3+E%&rr11z0CLY- zkaMRPP=A}PFdG`&u6+efRihh>6R;g>01dg+hdx;}fb?z&efHiP1;IIdX@B~L-Ot~3yLJHtM47nm(i$lqjNW1kLAJ_7)LE&X+O}uu9 zyda}SRYRhdQaw43!l^b%{{U^s?~1)Nqs5Wcxsn$ZN;2YQ2Lup$a;;P$DN&ax#x?<= zkdx&D{-y&om&H=bpd584YO-I#y75y>GLSTpztXG8IhDZD6<2%=<9~Wmc%rn5kGUtd zXmGK1Wy?siN1QNF02%AwH6AghI4=PE(G}eH0*4Hb1er!Fz79K9HxQm{iPrtk*=mafEhLA z7ZMq!d0QHtiyV5=j^x>*XQ$%MtP)$S( zoC>-ZLIfQ-`kD|Ht)+(E1dOrIHo(sHZDqERu(t{~+gKy|)>b@Q;>eO3Kt8T!;r-OXr$SfKcAti5+{cvCp{+_tfd+~4^N-n)9iq$fJ%}& zR7TY}pG1I>k=atYLTcOb!H@b(O3PS{Zu2) zilZ@s>S-~fw6TzQV2!Y=f%Fw_;z`yp(89AW4z<)l`c)-E6}&DEHAJBL5CE$SG=WJ& zze8N_yE{=)CP!BS79Y)|>Lc89siZJ?tvn=Xk#OX5`qZ-(!+#b7kGy$oPDg|lD+M8C` z#b|SbI0u$$8eTx!Rfq$84pkt`jg&jo;lBGkh02|M)`m1tx_uBiQMnpG_o}v57d%35 z3feNqspJQ}Zt*D7iz`^_O6UMV}p&lQ%cO`*yL(B)N!4Lc~ZdUMt;2Mz&OqT^(Lyz#Q1zCOqZ~R z#&8=yr9B}Q2n6!QJ_a$2XY;Bb`^CY){{Z7ofBF-DN*nx7!iOoOe`w7XfYR2(Y@Ad0 zvK8jX-m#CxJTM*1`2PUtf8Lz@Rl_g`@$uY7Kdmc3YwF(`vs2VS!8oi#@m~!X(-p_Q zk^O0Z?_Ulh9u(yC*ne6r4A{v zbo>?G(}eLJDBC-a1~c5Af3PV4O)O6GC6c@90Qt2Z)E)e@QO zy$VC15ngLPo(h!%xRFOp)a{>KK=d5yoUfmySh64ZVd_bygyr(0!B9@w^rWgWOFQPA z3imrBg zuy>&a%$dg2$a^eA+84j25lcwNO5~o^1p`)FWgY54jx>Tm?bOmVZjr0OIHBP@N-11| zpy`kU_e~FvS(`2`Dy}>!4~Y>Rw3?u+j0{%Y=sZi|nCCD2TL$phcSDSR)!XRuaGxKu z$Nr;>lvYopp@EBA#>bgJPvKLl92)dq@Z@aysP7CILW4Y$=U}e`?33<(xDgo z(*FR|KT4dxIv-kuU+>C2N9j^CJK}jXa93aFbswI8YU86gP;0FCxBmdF>;C}JSM)W> zL^3D>%w%<<>&YgBxC%O)REQ_1_n-%Nz{fqP40+Vp_WVr+%}Bt#42`qWror!r+L<{{S`FxqUxs*J`c95Jkf+BOHWF&)YR=s)Tdj6kBRYZbwX`exvuUb$)8@ zIE}a3_A$>xbtPGy&ZV*{E%5&Oist5 zllfLB!t3R^e&h<*y>ON@vFRE8D?8zoE5qH$`K0{?d8=3@{f^0hF8C8;yy;u8nrd~3PG|v%6>Q|THT97Fgv1guz{VJ{9oxHhe zQ9=G};MbeZGIf!EbJKWQR|iE2?`@e$68$k$5^*zsuwKURp%f}f?RUXSvfezpe z>Jj)v>=cpOsm#+|PS)Uo8XWSe$6~0#yjI9L*in(iyR)+joL3$LJ?YVpQ(WsTmy?~vGoHBkM#@Z-GsV~j)Cc3eWLpLFM;{QuoV0JtYzpY^z8JKTSbdH#PgFRpNBCY4 zN@iH49UBZA)N;*L<9j9ZLoXe&(hEB&>{QX?;?{C7RhP_p4JS32cpUcE9wWw=NIvOe zxuD!jb!-oVWGz>#D9QY3sN;PyT%^1m58EIOrKAc^Alo0#h_c4p22<>7ie;8Vmn5F- zX)G)bb%E_e#=gO1e@Ac_bOZ3Bs8Tt8b;ZTwD8k&xJ&j$0dz9sKDbIGzLx=3OEAVuz zpmoPgld!H+jH;d|bc-O7LzNB(Jb&7?;huQ`SCp_L1RAwz!6CoasRSS(^ACUSg z;=)mLRE{OEqxfof`^KVEZhxH?NVGdE9F-(o%SO5Ioxaz!4(rQx1~6mkkM z#k|e(#>eSW7IUBczh2e2BOvZ^Q7F3f;P1@moeDulL)gy}>L7aqy#-}>o65xvZp#o6 zHu>9at3FFslx-PsI)TcB>;Xl@cm62P{FnMwlOjsGk%JFP&XUqaJeUvp3Y1o@s1YWX z>53@@ns&z{>sHSDbgGuIvM-&r9{9yt&cM{MkB|WXa_A|?EQ%RyZasx1*QcS!(xe5Y zbr#Yxd(_U}^%OY-oM+I~u|N?ojGlmR>rT6Yo^-_E><6tl1ahDTU_s7K{OUeoE|8}U z$d2?eYWy#BO6af0NcjrzIK4YNC=S}8Qf%1X%G_k%;GfWHu6d==(b5d5z;{bZ| z-kGpQ%nx0SPAqB)!v_cJS~5cESj!f{Y;q@;twG7>j$?dpikyp_kXeU8`&0_CIo)z{ zDg+Ce8EgTz^)mW&sNV;FdK-<2BO;(TBSFRv$JUqzSJc}dG4$G}zk+LZHgk6Yz$=e27nK%Y#w9+a5n>$ zR*;zUjCpxcmRCtM=PY(Ce@d@A4-*;Nl?Vn`AP+V(){0vo5rMYD6c7nuys|3HNDAb) zA=ZWjK*=N%+m%V#f`BjpKJ_rCPR-^AuPRHWcho@JZhYwgHUxr4G1$|f%aG$Ay*82v z1O*zinv@94<%rIal6%qsz7L1C*aM|i(tI#B2RJ>&Up_?u=Ti&==|KB9An%s^ccLr` zE->Vb(OW@4ssNC4AmWcCAc2E`%g+=Nd<71e=i6~sm<*?0p(Mpda5RteYH1;jWk}mHf`(q}F;rKu?(8%0? z<~!2@OAe&eFyr)JMy^#i3- z1+WI#Cp5rPIxszYgPIF$0U6xX!y=KS`G+hDX}#MlOzT#Ny*=x4T;IabLUN@=W~!Lk6K_zlZtHlLY_l3#feZl3T*eF zWVRI?kxPH2HYfq{ur+Sh5MRtbP+6($1Ry`!0Mn;qjMUE34`6DO2WrdPyRy9Mc>^*A#02G;FmK#i#js?=#{$sf{Sjh@$Wy@rM4so|yl>CGG z0+!McSSczF7@kAktiKJpRPmND@Tfj`{J?wF%Qz59*8c!#kzPP^7B|mho>gMTz&*9x z$$umX0U)xkm4~iBYL_u19@M;YkR;Mw4j3F$+(K>S(XAaBLu4HP0NSkG+P4kxX2L&> z7?w9vmB%h!>ZYL~%L3)e1ajZKQg^g4OvmNe;PW5Wry8Rx65e6c6w*T?fgxWh*grh+LnPw0R$i6%Sn%1F#MmLn+83Ak#JoAdG@AMPcyHIc4@{gxoBT5!gm` z9VgrCQ=g0%+7)0ua48o@Iq@e7H*m>fiW+F84-QT>XJRwz2tWF3ke}h(7^Z zA*d+j&zI+3fhjY{9#UP0B2NDR-j8R*Z8&|r%# zk}{o$nc^?11(P^FP;phrDEo!q{{U4^;yEFh2f@#n5!6tmCnRTXl<0PX2O=})Y_J2K z*c~d}oDy7;%ydRZHo(+HSG2ZkmJul1IVZ61T>)4jK(PYL7#PP&xWvOQUOHWrT|fa5 zLN0NQ`Sqx*dYlbPckha}@00SLWSt})W|BG*K{zNnVAI*0#7G z7LL?B6G*Xz8jziVW8b;$RMr$FnMR%Tg1d5|TJZCIcoC12cXk5@9Eamev16KYI-AIC z?o`{!9-)9;kKR1;d(}{W!=pq`6m7SLPH~RZc4Wfo1*FccJ*vFu#Sr9SvUK(}j!JPo z3{9y7lTGA0NJk!{aYDC>D@nsN>lnreRowbe9zD5KB0w$^BVclUg>pWQ)groGT;Kde z{vcR|uw;xA@^sIxYpU>Kds~87+veGlUy9!oMYED*1@Ue zSO`hPkTNUd01n6N`qx@isPo*Mn`Z@cz!0jvrx1eUBXx72kH>1uyRx~llf$`%qHdWR zA7j?Kf40Tq_%p#6RTvo=!5dL#w70yrF#rOdLpe}?Z>1DOTW1xIT9Sgy02$2^_SNjJ zp59NH4Fwph1_uNjicF)v;rs%^!obaS9C8r3cX67_a*0pmh&%686^ z$c?K`!{R#*BV+b^j7K2{S`cu3L8^k<3%kDt)=jbVIlv!YwZnokmQR~${{ZOWi^!#J z%byMK!D9-$ukmCaa(WKc(OaF$o~vBtq&F7V=G9f_tnlreiR9HC3J@%#`laO$E^7W}~$+tYxILZ7dgq&TYNkvIh0Q<~bmIQLgG1~^K zuo{6G0PVe7afkO6UDv0kXr4(=va!KOpzHid@;TzOucO7%$^QV&0lvJC=~pe^r=>N@ zqp~hWv^s+4aycK*6hYG)KzPjN+u@tp0l_io9_(@Y6JA{Kob1B7-aW@8;v6~~5dxty zmSPV)avzzlYT^S7r5J)h2P$$m8Li-&G05$d%rbTd>rgSc`-ZL4U{%?XNQ2ZGZL0M} zz`@uZso1Ee^`%XUlBr?<87Gwk3vK~&Pu&?6zi-2B;er{jRzu>AK|?AGeNIhfTE>x0 z7$IDi8i5%V(DCG%IFmH00y2*Xfq|zShqX1Zde~?1QEV5<|EUU6XK{^F>2Ud4yRKAg3LSp4No7H zS+^y_l^MKigj!ie1?LHxc_DTx%)=oF$51MgPWKC{OluxeM_$#x@Lac1ad!~s!FY(D zZ!S5E{cE3zqAzf;f{~*ZMPr@2W}2sO)SiiTsS1E^=JK5K?@PsAgy+;6%8yxO zhuCGvVUD#9C|ms}b~y1y-$O>XQ7yzsn;k$MLv8B68oxxhlZLcmoH$_PY#;j?BECjh z@hAYVlxexA;rqCUCYlE=-_eGJg8@ z_Z0F&$~>q`!#dlj;+Eb>=K#d(rNbSuUR}g31)c1WB!WZ-DC^0Wp|4N6m@TlDH4*UI6+LxFnuaTfX}5f{Goc9#*Vu)?i&~#=oLAfJfx08 zVMDFrsT@<6DOim_)aoQtBxBBvY@RIfMVtm;Gwx_utzHLdESy5#MU1dV1)Ccv8`5ry zNwcY74!GNqWBS!OUTb#2B&a(PMRp;`G}_4=&(^uD?nth; zfi&|QlbW+?qXqtgs^qpe-1Vw4{7r8VF}1s$EJBqdao)7UT&+N6{8t70 zSv|$I!x+&?c)4S4eznW_HqFvjk;!~P#7taH;tNRu#Ec7%enNu@5n9GtO+@*bvz_ZI zCd}cE`{d`|v~(wmjgr5_!2^*y;*^(i$Prmz6t@x7}gmYR22b%-P zcc+rwj9O8dSe)x#e$^I%S>j0!H8Ax!10?=cmYKUv2D`qBICE~REA=_qM|uUX65=qj zxnxAnaezN+w{;A2+pOh6ymBbmc|wqR{c%;{whKFXLwKhQ80Q1iwJlKElaSnMTbQ_< zJWe37NXi>hIXNTTb`_y7#au?=L&qY<7;U8o?^%Bl2w{faBqLuSyvPIo^-97`Q8M|w zhneI(t3>`ME>%)yoVRy3Co@kZhyF}W4Ob?ZV2;D}rw4pz*i?{6u1KU;1A1Bua}^T3 zG0&wThz#d^?^1Q!dV-HDdUvVpLgbCW#Q-+lX)9opidug~GdGD`wbkphp+OnasClRzJ?e;Th8g5?J&kMe z_<-UqGOm@KLyvw&u_SK^@DLk-Wif6Uht!zR@; zt_})UXL%aI@O6w^2A)G98RyUYS7h4%01N%Y)2IF=QO_9z4D-iYjIEJ^Gqop({L7Eh zgu?)m*dF4%%^ygl{FNqL6Ns!O*}cB_{7Fd_8XnVl{{PRA7bvB+~2*#~@W zPt0PGsvTH?%9!Bb{u|Q(K<7)58+D{HYkcfC8{WwX^6nZHgnREIQ8|USpvWcfjOx$%|{zh1P>!o z)ry(xiiB3T7qT>Vs9nOA(hlSbX<3w{qqY??W0|ka~*ZxaEr2aW{=W+JE9iMmW1ffanikS zQ57hxi20~CpB8CIr0K^)nuKUX^0#t7N9Q@ z=|yf{^#vJ`eZ>HJRA?he<6cU*A3;+zk2UtEBB(-?#s)6Gs@tFq^QS)KeQG!dpg!XihB_{$9I@|5(~r7n zQK$>ZckfctI|=}wxv7n)V4vnctxE@|<^3rDnT|2s;-5O5k4)2y9XaW_G|7TFVvxsi zh|jJlPHJ*&LiEOHXLIkh0~=0suW|)x@;h?PXT&pe7$Y8h)`wB;NtY3^mgkCM2-`b% z6#WRKIpEZmM02iwQHh^#ylbP!t#en-!t!B|2C_L;uyn|h8>K1+wm&H*qIDmbll84R z6U4A|^*^04bO(0WP&A(Equ!R0$#*^RMCUxFgo?BA8a=KU9IM9M>2+to>}FJ(G});iO%@G zk3$qA+B3NP-WLmX9ONNt#|ww-{5AwU^y)t6dduUEZg`EYlJB@J?tis!V@LpU8&+9< zIHMoEjyuTT)`<#K!k)Pe2dc#cQW5uCaaJlaCy2P~%vVpQ%k58nGaT6Y$My_3UPSNS zjII1WTlTE`o5@t--2r9isD4yfZlsiJc=R_SGm-SKc{^6mDXp0tUx{5>T@->}4_4=4 zziOa3_YzZ|wCCMLb#YkROic2^vOaJN4DCcVcDTpc;>Z0WysZAG3drK&XH+37TRC{X zCb?htqK-k_Y-*pxoJ`(W+lbYpNg0a*dE@@|)ZB1xA2xMcDwEZAth?R~4dsxwPa%u_ zw>#tC-mlWE8D!-M(?ZJU6-}Hrnp$e*-G#-B#w&=*H%`ac)qOSSoa8{F#-z4%>I=CZ zl?+zb2DUQTUYrA|LXP>tHJ(YRM%kt~bCm^$j72noB#gzKfzCx%;}FX&)G?{qfdCVM zn%1=7mN%RtC(u?$UE~LF)tMqrVV2f!{vy!oL);4Ci-MiQ$NjUhPE(H=nXSU-UD=dl z)|JBZJ&kG!7UBh!e(>COH9~dV`qf7yoW5QQc%q>A`5_6(_N$R@H>wbv9M$N^&+lDq z^9v|;@7ks}J942bZGdRYfB_<;WRCo)X+5dfosIxC4e_2rq-3y0{S95P7*_|ls!OT% z1DWMkgIroHax`bbe!b{w^lgGlGg4vglS(u7cO=n;4%ya7n?cw)#Y$3E2Rw*ft2b^( zsTPzhe-I*0|nCuf`)#Z?;fgA9?=(-(Vke zL=o{wH(3^3bSIzVS4S#vcEO|FZ?qg2f|7AP4if4Xg}d~tp_PWDV9I$AcBso5N`+)l z>)4J}QLSaQ{w^m->&b>c}aRJ~ME-jL53=_*xqrJBPy@_R-)k?Pdoiazctz#%X ziMcM14mLLobK#x)(*FQVxIDlf=Dm{l;C>HzZtY8!3^8P`ju z$A8b-yK(WKM>MS*+VIC>127rwQ~v-;0PiPG)v@5Jv0Yd-0`}NtdC*{gFs-;EIWsAE zY7x0(^r_PnKaa1JTgIE-DP(5){5#!Q| zO-0y(F`uBWOEw^5{{Rg`Mpy!)Prq7|1TT;>$Gu7$oDTF;Y@U+z-Z?d341w{`=4bwmh-Fr#efV=RElg zVxkThl6UXgq;he&+odoOryGpzQPKg)H7s`8kP&>8sKap-5x92>P6(-;sBt!o`qe{B zA4G00t);be6D~-|8LAKwuVN3E`c>)57#y;x!V|dVwPskznu7eN^{bI6(~oX-B1q2I z_3uzojCRdPlb(BZ#bi*xhZzKc?Ndnf?pU^a(gSR4G18ni7_JTg*!Q3b4YtKSF(90M zX^Go#XaVuhC!IcOJDtBubCK47Bsbq`M*!{0hFFt;aC*|;Cm7%Ipa~vz9Yl=M9$}I& zak!_zErH8=0KqCV(>UlVQDIzb2f03#6S*v)WF7E%(}@Ue*KWqNWR|1L6O7}~b*7L2 zK1w;AoLs=U+Ip?3H zOch5t8)WnSYN!JE#qM*f3ZrTyY&XtuNx;QaQHCcVHgI?4MMYK~Qdg$j%_=|~CzO&& zQP-sgL?QJB&aOv0Vvhh*$Q%QKo=55FQ_!I?^J?elf2Av+D=QRm3W5jCgVLJef;kb8 z6~^FpsRb`4`6La1??Eb9k{1N?-mAzA2rM@P1Hb1)-G&LkJL8ZvJP{tExbsmLOP~$Y zs672CfTfx*4H6|eQRXKbny%8Q$RPRm$2xf+k(=aI9Ek^NceGI!u)qPFjPFfQ5Rqdz zLW~=&GcE$CZMhEhaw8c~iKiIJInH~Ut0^rZNK_CB{w6wASctgtfsMN6hWN=PhI8JE z%Y+fexj&hqHW#N(bjC#HquQny10)lkl?Eq1Vak{YY)Q`{OfomeI-FrlfH24+pXEg$ zZ_v^i5Uv?@0s-Zkk|1dS=uJAQZP|`ja1Sg|5qB+)TTlUj2MPzhTeS#ZRD}3YMqPmF zPcPc4X~5@$ovOpLeU0Hn0ij(L|9-hfz>S z+#ZIZ7*~%7bAy6EdMuE(_YDljU=^LVaKmN?oom_gsf32y$7c<*GdJ-g%CR+oRHjG= zo(}X_WPL>#Y&QV)r6?%~oyEU}8cZcgB9Qq)?0*iF0$H-S8=bu?Ldk7XWS(|1Bu#-4 ziaxU#<|-dvBh$zNsv>K=LZ{Rjw0not=GgnQh%QMTw@qhq!H{YfRUXG5=kd~o^&?L zog;ImmI}rJ7|#3lr$VvO*_`B)n%L1KcCnQQ45I_6tb7Q;Z9wPc0=8h88p17PkQ9zv zjrvu@!z+4d12eYldSsrp7KPLv^xMcJ$nkcQ71YdAPi?5%^N@wg^h57?vK<`t7GNGP@!bCPYyk^ z&AOlMM@H64901t?dMWKx$icwhJiTbphR6&69;X{su}UkkiyWMQagLspcxLY0yx4J& zaqB^mF{y)U2OIbGsYbg2{%ybOPr#ixmo`Xzs8z9yqi1tk+z3W3rI~d!h4gA8Yz@G! zepVpu&}ZsvrQp`=yfPJMGI*JC7p8dvew8nv#R_&?l(c%TSeVs7)-jw4=i2X26>G5P zz?raBk0t5&RJgAcj_v$7h9ig^oS!=%+ij|ptNbB`a8!T~%m?99MN1=6RCJm%zQki< zJ63NOoLXA6YyRxR0Cfx0{?tw%c(L$tG^bEPRYxrL6_avaX&P&Q&h8F!y9&xU_H|D> zO1!gA;pAjSC_?S?ayid0$Mmj_Aq3aMT0OZaR#BZkZvLBB7h!YuYuH$s1Zs4$sOCTK zU6gEERCQ4#hzhh!2bpom9=x&jtr?UZoPEqFlC~LHKsYBmjO|dioWm~7g7?L$` zys|1b%0$dEPQRFwl1*hU4Rx@NCbDzA8ju}IJq+3vE)6NKBFkTFtw;N8(l&u#y$P8TL3Lv~Jf>b;r!O$I`G1 zV#?IM2uX;?;D2h<8XSRf3pbKmw2m^*Z8>rjXA6^`+OoK$J6lSpA>+Y5p#K2cx+__w zpNa7};W?62eLL=Ut6U~79{dt0_=S{nWtbAf47kT4HyrC*qcwnMWq;RhDkiD(5=}n?NPy5BO_Ae1A|;w z4dJiEW+zsN(|3Gl=sjzxwu(DAyf_y?>T|jGu57tfb>!{AhErGIJFU!j1pSg}b${jn zayw#<^G|q*_KS5<@~~l$!=b_D)}?8GEKV@w4|?dWr%R}O>K#utVUb+k`?IcIog`q~ zN}Mx=PGJcK5aUP#CmVa3^XoB!+rTJ6TsH9x7wy0Fp^y;OUDuPMa0oJU|A~>T#X+6=zF_!Ln z53On0@Fpy;cG_2&LC@>jw$)ZO(25a9AO^yb-G|aSZhA4G;7W_G<@FKw6PMK1?Taib zLa0>0!hx`;5vfmIs4=#79H~iY?RX-f;}lqxeCHkNI&o7za8F-Kj{U2hqm5d0$dt$? zff{R{1D7*a7==wa-18Koi%7}aKb2Hs?CHafP21=?;dyOO(;aFYfXT^f#!7YKnfdeNl$m+a~KM_@~r29qGuBR)X$Zbypc{J~<2X25@ zR925VPTOY9sW%BO2rmfLu*S9S1xsEJmpSyTxTbhvhIr1IB67Lz4*vkwwO>ctBvf2! z0kAt7nZwEw7l`8LCj0rlDn}Y}pkVi^r1`yR z1|;&LfV8vx#dB?BhbwCcQ`;x}6fcDBl1@DUaj76u$LK$;3y3hey|}nvKDi05-&puYIL&{j zL!L)JqKvpHvHU8Gj@ZW4Y0r@3r#hu3#=O*fl6osjNe66W*0rLyYj`17&RR8n{YGmk z@Nz4n;<1~1n4@pG3~GLhioXXeU-^_%ByI-Ofhu?2k{=(Cy(#(a z)3pn(!ltBkA6(EB#%!Z!p`u8^+_LYL7^ps+;DgGYw2dW3GPH+GV%Q$F201+TsW978 zo>c?6z#Eze(6CP`o&a1EQdsni5O)+NJ8U^~pg`Md()eM5amd&C)tBsRarwJ#(EyK@j@m-!({$qaZWApz=;mrwiPE+ z{g-%=Hy;gI&mhaeD?%H(<0CX*ge3v)qt&?zzp{PseCe$ zLB-lOU7j)*(%!Wb&Zmz`SgcGAOXC@Hacd4Sg=WvW z$L&~1e;V2N$YY5?{{Yaxb6Bx%cccZ8xCge>vT$hvF|gaEG5K<67$Of^f#*g0r@alf zU#%b@!xSx8U_s<|qPy*|s^^~E=mXPjoXu$j#DZjuF~_K*Rem23hlg84KI}`++N{$7 zsj2Cx1CitQi4PWMPym&DOnUM)o2+Z+wQ77rI6PKGKXnyH>VI0s4Ynu{S0@w$xv7`f z4CfrEbfm<_IL$a-wG+ygqC3<8DF(vvtNaB2$VI)!75@}vcY6+Bc{pENKZ zO(s$Xb5h91B5Eg2cT@OM0xH=xn0`VD!N%Lp%uA3fSdChZtP`CPoyOGQE6;Tr@={(z3DZu_%!1ZHH za!yW7M1oCSh?6J>EWnyUjyID4_NeJ5qDJ&s2GppasblrUL&O~0E$NzoT4JFEi7j=0Y9|Le)%4QYKdaR9Fl!%A!=CjPy++KbYBOnHyZ~T#B5D- z;SPCMQSeG1x7;}7v0$H4NA#$lMws^`b(c1AT}up)vq1Wk_w=hd01{3B+?t5%S^RSJ zTG%vde~B>C2d7hBQ{|F;Pf64xWj+oK_ZhgoZNhxNU5Dje4QKFcPqFayRv35lH`cG# zexhnw9dxu#IJF)2Jv}u(D+0w&O^R?R0+f6mX5tdvO(Qg9^!eq6R=O16rm2v* z;kNho<_n0!spNX8?mJf>#y%Fi;fiNZv2sM;(Ro+sdX`!1KDFG9TW$ zYdeT9AQM~2vc`Ik-oA!86yl}!a5y-+Ja{d1pADh=6vrDG2kBie6!=4i-jZ9?h z(kU{Gbr`FWfu_6S?kS#DMN()YbKZ@fH0DKJ;C(6O30w!#JS1Rf6Hajs_LKoPS#9&j!`6 z2KZ!?r{vBsKMcDuNOKgQE7g}z5+{$T1ParpW;e(Dx6|m(!5sP4=K_cbC6}uovZ4UMoi= zAmfpz;Lrm!#-s%zE6+Mc29P_`Ql+&~mO42P_QYK4Qi#B*RP?7wCi^$b}A26ES zAzczfr_JDwW1f;SDJ(DTPJo>f?KD7K~3 z2Ak-w$DVXpL=4JH5OkbomkYgng)mMrG7SF!)H~7myhd9>$THC|a6P%!JTtG_?c|7( z+VDrAt+Ay%Rb36Gw`WyqFhQeQd0;WHI1Ix{?V5xj0YxV@%gA&>&QAhnvvaWXM0>Aq zrDJ?$!H*KLGC`lTL<>i!ZC%&*_pN)VT4;kI(==yPZR^|XP>$$b+A{?rMER4dkzF{a zQd0i_b2%$ic*KZzCBHh4+1ZyXxIJs5_{V|$j_}{xu5GSPP6o*RN7tDC)tkfP)oH%? zVk^~(wsN;b_%;Qbsm@i7e=01o>y5FtLE4(ZL~>6V89K5t?NgAD#4zW}2DV)pzQLi& z0(y#Faf%c~_)EFiVDD0lVC3^W@@tKoggnM63P)^Hb2&7CmUbIc9$4>7J5wF@#@>{` za4<2*=WX*#Ah)eVE&#@M*wltb+0J^<1LO~SZx9C4kPk2^VCR{nV+;nN%aQF(0E^)5 zj#;J*M*I6vTg4BO4hKATqjMf%&%GuCh756&vB$kjBN)vFGlbt^&~?Qvv!1j7LPmEu z&w3fojk%HDqzNZs%9*xZh7Kv9D$L}-%lyL?l8jN*uP|##gBL@5^pn>D%SN8K@ROuRW260fyBA95{rb!#%3LaRHam!&=RF4Nr43J9f zI(pIwWRR#_4LBN(2Hj~`07TI++n&pwRB)wbWMosM^6!IFDdI9s6bDY|qyAL}g_`7t zI!NYN^1;O`up(eC0;yA${0XD|n8N@%KrC=qAdqPirsGtbZGd}@_@hUmH6ILSL5#87 zdi&y~$$&4jx(!kSh!l`}nyVlTLwT13oMV+-dDX6zXw-*c^cc+wWL;h{B*=C-9r+p) z`vRDih$_SpzvELz3cC=Xjq}!rC-BRFaybLup50EgcEA}J)%wEq-h}7FAsNGJ-G)9|j;4|}2k!1R%|Nq}xX8vciV^{XGCoj{ME0W&M)`LU~>4G%frJgM17G0vmc6o5pHyu)gV z+~b`hF-=Tlk0t3=;=zFrJ%}|=?t4|MLVPF4I9>DFfFcy@3)-xrVOyT8>?#NWlO%uu z`qYH#U5LovKGcUq;8OCS=v-&U;et2*Mc^%Bbo>k`J{F%e0zF>q=3RxA~fUL|mHb8wbKw z9QHfaVBn^(stGyHIa50S01PfS(s!v0tZJk0AIhH;g2f!Nvar-o*QG{0wA-AHRX+^I zg-*omJx6*&LXApt#A8l5Q=qU+H8{sAl!{v?uo#sj%P<8eN9(fA?`NrFx;;73aYoer)l6f#Jayt47+Txb2VZg2J;*s@9CYKrKwom43F(xtG zEQK-=hstxxtnm&YyiVXgfU6o1s7~}YZ5dQ{W-9EX`I|pMQj-c)F4zXhh!>Yq6{Xx|vmwm(YZ%I%#>KDE&N zKWz(J%`BK;#G`-3asB9#vh3&NJD++G=bohCQD#gwG`Hd^COs$_h#Aj4={8R#L-@|{ zv}=tKqY!)6a2~aJ12l7J&e8AdlZrnG;Pc(zB25!4N^pvGJtI-;QB*R>6msuP z#X%f4NaHvx0{;M?N|ddX-4#3cQeC4HS=1bsQyuA4Hq6B6R$OPQKk4q2)Z%JDd4a-?+zplPw1 z(8M!3PTpV@&)x$7`--n{3dX>K4%<}l1x;mf$&TAmL$f_sQvf>Eq>sZsP&xBGXtTOH zgW)2-^C-vmr?wiWgb|#&jD2clqTsEab(OfZv5F8dLgl~eJm}R@{{V_dYa^nKOs52% zLW^k9%L$TkmMyR&U_C3#lx1Am?!s_SgnS25LCCG{5ps?+76JMKahm2Y_+*7vR!NFV8zstNeW}M1lzCY*%;PRDCx7A%3>XGdFhS%;YVv+9!^aNelEST~ zBz;K7`A*~O(!ClxrZz~<+M7=2zpZl|Vg$b=oQV_VW#>;0PBNxWd@gXc+0R}1)um|L zpDb>@jaOC;8t2QT?r4{7Br2hs5-HP>_l7$6sm&Y-ZyPfYH#}}L>0Q;M%};wQ*{J(P zoPcfOX+MsCde3V=wcogcFA?3?fKJ2I)XnLJ4+<<~uwea0-6&S`v=b!CcSE0gs6nl= zGUQUEjZ1HbB2pgiQ!D=fB5b4U*0inc?ckUgt>ZvD4bRr5vxyN|K2{kA(xs@LxT$TH zppb8z(XDP8+Er1yvBo=k)kSO_sph$k2#v(MB7gZupcO4%i|Ey!D{d1eg{ z2O|Yr{Mx+odv~>+?c^AE*sBcn6iaK{i#fbYd0IB=LFez@p>=TgcP~7E2ax5o^37?E zE-sEaDE5HUVxF3|AZmJ4g##XyEYcK)PB@-f7C6+iF~9?@be0@0J0%X-dCxZJzTeh` z!0bd&{iaWzL51{R?^>tUgVprDoh8m)kih={4J`Kj>GmB3J~O`Ghbj*G8yOiJdrT!%btVRz^?fF)Fa;=v~hn{@$ISc?f!KFjgRkKEH za~zFP%*N+SttvxmVjJ3wM~1*BPTYX$Mk5ClHXdyE ztqCAUwMrsVRUqP~5PmfsIB`(ciceze=k}v1ZR~55k*3Z8{Vnuo&n@ z;QeSWN{rQOYts=b-e}vJ)tM8?;)d55J2&imS2Ur-t0~Pf6Cut4=T=l?gV()g-m`eX z1B@+QwUb@ACvpb#HJ~jh$4Vx1%>60}xEs`f#{KyUfC;c%DBr237{Jd;W;hgp2cCP- zGXDS#I+>!$(&$j*)K@5$<8zWcu`?fI{u*$X7iGd6 z+K<_@!EaxL+|M&ctj!Tb-(@-GYJ$T0%!v9)JM1y|Re_A-uU~pb;S}jrEJriYQ_GrJ zWpX`_PwC->d`eTnA!0@wxEoe{v4Er#rL&DBVAjL2lbd7EZB*`6UuhH*(C2!|9Wth5 zsyvgdNTr>a00&cEgU5JSytFe$0G=h!{X?j%zl3;9Y|`KB22^bb2>EvB?_Dk6kt}B{ z0RlGMdsgvEj~6aRva0ZK0eOCgra{w|3cK?g)K4%poDtfRe4F$&lRb#B6b0BRZM)QT zu^jnsX|?7;RPDVmK2w)$_5zUXoP;P)M&xHXrzF0CorXa6sJ5MmIq8gVPm)zYB;zM0 zf!O3Ud*^)jrq~@rAoZw_%-{ozovKA2rujW74#|SdRO`VbsP(9Mmsa~`rLE&d7zBTM zjGr#q-hgjF7_lSD1$4g(aFPMyQw$x}5&0IbX^9KEk-i3NxcF*imx08f?;A3l`{(zq zAhvn_vQ0xkz9+LcJU7OTb0h~>u9MI9KT6~+A};G8$Xxz4=@rCrH}JT%z|*G^Hr%SR zFD71!ZTw$>-0IQ~AajaL#k~a$WI{J#y-3X(00G{yJxcN?kSWqU&IaSPABEhs97+cV z8Eq^FW3^Cgh-1h?N$Z|wxBeN9+9=P~!Njg!tMT8<3})?0-t?qlkoYiH3kZ5wuBiuG%LBf}?9&|A$YCvV{sLE@G+++Nkh zwnj4_EkFh%xb~=h=&gA?#ZJdO`cpC9w0N%tn}x_+W#A`f#^cb|eg-mrw9~L^h;NO$ z@}ZDQowgh1kE2s<$Z`~>Ay+Iyief7GdZW%ZJp0t`+67}B!ha0!kUYwJ=7Zv}0ds-r zLh}+^^`r-3^VZnIlR9XWb~*wOH7kN7h5!F?sPvH+6c08d{^?Te)=8D5o5%AQpU zEDl5BO9?ISn(QuNnDZZXyBfypw_5Ia-k(NbN{vXGcFM{OUP%z+j|%)w6;&q2P9+z!PLelh%X=q6aL|-yR)l@ZJcw7RzyP z(#NRzR4Ckc-lB7iBXTr^9GxSgi{Rjh6khTl{IBmuUxQHNRqJEYG5u)&0P3=iN%_>j z)u+cH_}2&cpV{5*FQ7mBnGP^79Cq>wVem*K;{1>P#r^2t>hpFKMdH(EI!EJ2zxxkp z0Qe(s{f6ddgp+Bx`d3ePAN9R)fhZ(PxP- z6Fg|AKrvC#gU-EPfBl^BDZlu8tC)#7-GdKqRhe`D0J7->WZCgZrEKi7L{o2YPw7^& z#EuN}z5f8gO{dV;QG4(w#1?C->?URJ0|)$u{{WR?+*sUNzKe(?iT?nV)T#XIMI_1M z(Nqj{{OhgwC3L0ZoB(tW*12s5JgakqD@S27Hbz^Q(w`oB9A&}T+6mzb>}5g_Ww3LB zT&>0HUGX2bJZ0gHhnJvUf`JXS=ZGUnoEwiQ1bp3VvasQB+3@jd=&!7Q6F+#@pEr-w zZtcJN&X+l5rO^u>AqD(p;XAL)r0zpKr>#D``c= zymK!p6g3&J&l*lkvllqY=pm-XN|kel_R*LVv!YS4pPhEnO^1E@M(-^8cytc z)1qDcH+%|`_U?I)yg#jVs~E;=7dEk6!qCj2k-67m^;2^B^hdUpt zu4$t@7l@df{{V!X`B?qaR;^@{f!~`elbdNyQTuxT09w)FRw;dG@HR4cQ`q#XmPtl+ zH*tUW{m|l-Ty)V&MXIu{+^M*1?<7xcKGQ&Ska^;bGJ%{OswEc}Ozn)6m7^|kS(k1j zLe0olwZN>4)zuTPaBECmihUIJ6KA%Dup zb@Ka~1b8N_Y0t3dNr@5}CY`_-+dj0}E3oZS+~A$DQZfW!QSD=vNuyZD@ilp9KxMrv zcY|AvEpTv8%D$cIGEaXl!Lunu2&j#38t+OW!U=FYY9x& zYFp-CD}5^GN}*;vE4#Ql2i88dNnBW)+Ae9P#wclYjda$)9ZEBvlw(PzyB*ls?@f*A zcd6qf98wuxaBZbq)EvCC4(s3PSK9;zQ;31#_@KU2tlM;Wp;+ZWI(Ma#eW~T21)VDBh|vap)RM zA{+{qs%&DG;M5F|ZMLd+aLXK`_RxP7N1Z>Me_FNHn>ak`<0ltnwPsc7#l-kc+-n(R znkLe?+%Opf@dCX60EF)ZtJ#6CMvR!Gl~e!(t&!fm`o<##?cKQq6k;%W>}%e@(@CR? zDxxF!S92dlY`II;B%$}@9j$fe@bF{ccup! z-)aDTSmn;2G&9d2^QnyU#Q-Kb{Awjm2n6SyPa9z3klW=H0Rf#LkT(>>XLHDOqG73)0M=aZF>C%8P01ABB$3kf~GIDk+oYKf7f-&z& zhFB}?0sjC6TP2cZ-%_#L<;Pkp7x0Fna#vtE3MQ4v&_=Bp8jnvyQW4+~@(_WsllxY7 zNCd&i1%`PKr4~mxU4s47o#<@tWkA@-8)t31QSKN@3y8#ED99Z#Ljg#{N{tMgpzW|e zl__C~k~UT)j_q;cVUt`0aMU{voennTQY;3kxugJki3hp zCphKkX-GcNaONIc?t0aIBt}?_%x)f`Y+F=kknH(sgK2O7a*jozJBT z)j@L4{=bbv zRzdMBf&AYu*%P5|pn2&oNQuOU82iMY zRQi~whp6f2ijJRetsosj9G7h*9P-X-mPHIpu0iRXVwjKlegc@N?fB3Hylgh1fO=!4 zMdfE=#F9-Qj7|X0w)91^xPDQbdhJDk9Fxx!wR_dn;ZnEE%D&swWUvVOd`ZkCg z!4v@h09t(1X;1{SN3*#$^M?DU%EQvD(gv8Cv!JmmP-vsa;H7ObmS}q#Yrg=~Wm6;B8sg z6Nh3DjchV96TE8CM290Rx+z8aXu# zHb^W6<21$sKbUy~Mn}!m9@O0$w;B%=gHw=5^J8ZHjY8s6rcLw7cVFpG9A{54 zxjjW!bUKDq;K{kEGEq)NuMn0BH^i&*ws{JBmr$x$Y25t79V)6hW=s-rS&nJsm4O)8 zW2V%09^c4A!P0Q+vYxeW8}_x1c;b;XKEx6aOjdu3klF2xz&&Z&QKk8APuvulZY?k} zNHKxFDV|KIZn+!KS+&c6MlOcy0pa(C}kBH`AtG&z*`(cy!cFvlL1 z(nR1mZiN1|SsPsrJM6MfaB2)ZwKLo4Om|$?BPz@~*Cog8C4%V95n&wJVh3@~yBQaq zXWrYa@VsmR2faAUi?O)!RC3%8h$j!Y^Nl$>hpXQh+6Ru?h}!_x(quL{qn?M;wN#gf z8#yD=prnTZP6<}`?OGNK6C>KVQ5>U8k-p%L=yJ;G z_&uD87^0D5R01{xL>yer4(^ za$@1TFAiRAsJ?E>+i1;EE(TeS0Nk33K6=$2EpX7-HjZ)-pN~4uG36wViOE6^Pr;9U zGRq87pO}&Yo>oy^`n7olYtl zrY9AliMPUU*8OUpFC8Kh@!>~o3=L7?OTw^C|)Sna5N1zLp}OCy(J6pbRJ z0CgU8jTKCrv8kz2rXfXc&Tmd-~I9AZMASmN>=;scv!U&lCX$ zC!pS(pDz1Uj;f0*6TKi43@%4rwDI|Z4038jG1#4|F}{b+K2w@QIty0ter`izDfqp@ zLBj54Rsh8tFgVWm+w-ic@5Hi*;>ZQJG`QUQJ{r4`fy1Ve^&nJvF`B5k9%}sPW~SWg zJqfLk0zjBiB08eSQMC=K)N$g`TO8QaGhj_Bkr*`CrXjsgDv;b}nD2l&3YxABXq6Jl zFPU(8j$X9IaxqdPH2KKiV^ktNDTvj-QBr04R6)l&Kv{&61zEs5RXAh{aCv@}WVLe6 zG|tR&v?l}bs#gar7oe+-z#(Qh0;wM}62qTLta06wnp&a?2|4M~rOTZ;{3*8S_?q11 z81L^=3Nc6x^G{76AVw(mx36wU*_7;5@*;+4O^$+-K#B1fmO0haJ{+nUK{>}lKM_;m z&*f!359?F93oi(UKzz{1RlSJF{S8*Id{3nP@I_-KqK#!Z+NKUo2sTYj5uK|ch0fTg z9=$1tKJ_g5hgw4oYbKe7K9o%wxUJJB03{s#Mt@qX87U}k#;jdlco!(-;bw(KpfjYY zBb6`U==9DjB68@mS{U|YmLpN$koBP(n1{=wJu(`p!o}X&PS#zSMac$|snDK+g1WqVebF7iy^NJ{gIV zgrlPo;=3`}cgO8gKm$5C{&kVWZ=Uk)=6+0e z9fv_%xg{D$mx>!JMMX!$n{s~bs=&<*exX+bf;_u zVeL^@pXNWMA(4}H$QzC6Fd>P}J`N83DYNc! z0iZ*A@-%}tUFq^QXQek@R0v`fh7NFgS4r@d%B)h^GkJmW6Fo;-=E>>#R*wLXCm6TX zaHN&`kZPjooZg};aCUap%Y0K{%W{{9JdQNE?8BGhKT7JV62%;~XRgp%)y7s9c%AEacsl50`)>zNk<#^n3*SM~J^2Tl-bZzD;Ur0Gqv8>cu zI`U3Jk9zlxj{tjsW0MZUr1}t(%Gm5x& zb7X%LN-HaL{{U~{k{EyU32)Y}#|8RF{0Qp_?z!NK+Z z)tYSht>}>_QKdqv_^q*Q?!zC_pPDhRC3}gugF+c@ZKag%u_UF*9)MsP=hKegLBv(m zwjpEUL?Y9ubq2TQ;n3K|jXT{+AY#ha@dJ(Xx%=DtcdMyy!G0rzy2{fX8eD5C!1p7) zGDf(H+UnmY4-BdbllPR@0mk@ToGR?EI9%l$_O9E3TihoP`!O-kZxLezsNQU6bzxKD zTs}T0V(>}^Ps&FyX|?{OH2gEn-fb&|-Hi;RNJg?~*LJg5-MkX684u&@Oso7Xw=+p> zIx7^%l#Ed!T(D4!k5QUVaB>eJy#`4Rtmc>+R<8^|A~{h|eOMz6h& z_2vdv8Pdc9+PhDKZj82bTgktLq=TOM+w!Ry(B_p-EBwEmEkmM;Rt5*f6O^-#5OLx$ zC)j`9xt7M94%N~8T@f%ra0WDzjrH7qhPiEIf=@oRYoRfT@{v##B6c}Dbfz4tBuL|G zL{g~%kx&4bVxpw#2iAzomZ#DMFbbrCXo2%V2BfB?j55+9pbqss?MMjq#YKfnY|}8> zfrI84A9}yRujPX2EaaC&5=a9E6_ z;MnhkytZTi09Gnr>60DA*605KvWlx7aJd6G-`=Iqop_o~sP(oSaLclGMQ{3KQ~T77 zh3c+8&d1vZf&d^K3X*CcDJ$$~_={ma$#@1@+E72~sQ6P4{Fy)cQ&JMl4${a!^%{}A zx&Y$lU;dhph-?AKI3SO4Nsi;CT3#V|qd#bK?sG){08qMh%Sx^2P#QJ%7R>PY>rx|7 zJ^C8FFC-&);3L){OfD3B)|T!60HYLZ>_4%ba=huaZIQKKdy95HYY2bzk|~6iL}TnD z=_^zI;r)tYc~E&JVfCU%#V(`!`S%qtIDSs6dB?PX(`ezu=l=i_pMk1Pu9+^ri%@() zGGkKb*wxdmXci(rAn1T7^+G#YBX5IXn>loDG^iKwJ{m@=}cyC72)Z`Eew35UE@Bzs+R@3 zHx`E8Q~YQ=q#k5e*{lvGpXr=dE+ZJka-9;2+aJy^8~((-K1E(N5wX6DiAFrEbV>(b z^Y)_1RnRh=s|+<)NG zGfg2)ULf=INiP{{X#Zm0TI7CsRh~Fc? zj5nWU;-cYBR2(;`c{Ka}cpXC;xgPSXBUJl0g z546Cq5&kN9;$ixG8VoWCp-BM7p}6dORBY~&*@djk6$s1@PQ-sN(w-uMY1TFjqD+h) zSs68hS}GcJ7O2d^--;xzaFRwrKBBKx9xoS(WvmRC*dL`lb;_TT=hc;XR;hS|vmE@S zpQ){Sp~2(RXkV<1e*szoo>y}gHBli&jdCfmMu>H+nPZjm)>jk~L*d>+aPb@;P-|Vr z1y_;}9w&&8ok*e%orIODABNwj3*G8M^J zLxYWoHLb&ValdA{xg^J(_ubEW+FqX=A2#%JDYvEAH{&^%axNiR*y)%j^}m61Ka*M$qT(KE|!}tec(%6N1*y z;h*Jg$M&IH@f)rmAh*9pDbbHH_3A2KD9?D`_8%s{BWrGyjX>N&A`=- z+Ux%S31SNu*uQb<{cAX4l-W(BShjI;w-PDHGaV#)3gx)nn6~5VaG(UcjOQQBIsVnK zmOMh*;i9r~+7vSk;1lgq;xUdV!+d3r62RKZ^-?xr_*bjx+Kj0^9Ql>U_Hs9BJXl-) zMSl@OlU4v9%g(G`?Haf8x|#%_6SpjHn(t>SD&(2SJj#Rl)UrojRGVp&9YF@41fG@4 zk$jD#(H!nPr|DA%8)Fp118mc6+Mr~K)zfNhobuYAr7UHM$v%_-fXT_H#{Bux)9bx$ z@c#gYuC7FHbDLkC43k?{{{Rj#(k^DhpSc(f zk8@ot7WTY8$L-ntIaz5`qYGv86i&Hdmlqo(vrA|Hnh$j>KQHNcx6J8A;T%?XfS-_ zcdm=#TYnW2@mCC>B%Y)0pWE=RawCaFF9R)p6scyJvcRa?kPdp&V;}aW17n{yd(sL3 zzyMGLgA>eqREIo)P{t}$0Xkg8Lw&_JQM~|69qIB1S`mNacz8Uso|R!q*V7H@%B@1l zux-C{?Lw6wPzt9Va4OhhGxtU}J?Ra%)z9;)P!2~ux}IB9bm}{F+|da?&aL#J0?gd= z?^3~qjz(g*^ALB>YPdm`6$Qx7Lns`x+NnmY8%{>#40Fv|iFIlb>I80kWE##yhKd0z zGD^yaU>u%D*S^&Wd_8&jWbpufwKmxG?~00SKjcZl&ni>{_-tK)4UoAy zy7#7ym@Z>2kPdd+6yipdIVa{Ehgy!ZBhnZUG6wvp0zeNBDh^5fex0aA-B%hhl_2`l zteRtBK4Nw>&aM?f=EGnqh!Q{X3vcrB^{I&lH3CL9sCZ;~af6NND(k{vj+m;@#%KH3 z$5SkS8!05<93J%05Cw)e>zbGw>Cynk{ps04fHoj*d(=tXX#|dR zz#z_kY@PA#MT#wQX)FZc+L;N_Mf!}4TR=@}hn9@P% zj8n-hni?S9LfWy7^HvWIx?z(E0DmzH`qT}D!25@8USa)exJ&3(fhx&xA31Fcp5#@1 z{f~b2c_F#CY=m=>gQSDE(xPF^e|fNX^6b8qF5>nws7*sr1do1x)fP|~0yK)X;CWg- zgn(+se&I1+H2E>Z#xC;-nOCUe_$N(d-Cb@Z^T+ZVeVo&E% zn{AD%Lu4LCwzzGKQd%Uh7-HEuKJ|nf4W7Q0)mUV3_;b%rX|W+J3cRoY<$+8hK>WGU zk4g~S=dO9w6gW|y)sW4XVtSg{#bNTMB6_h)JJd5vGz^}qgB7+m05~KLW}N%f zezf`8fEb1TQTS9^H0PyieQBHJJ9MTZh|hYKGg8yHDp)?1LL-k_a7ywg=})Adl(FeR zgwi{YGZXG=H7t5lVhnRn+!u^ zJ2?I7=yG9gCB_Lo7;I}WWHHFcK9y-tmHxk_Xv`-SqD>|o;gIjwB98=(sW23sN0W}# zo?U|+G5lzd$Uy|)?oQx#q|izlG-77(*D;gNs5MY{TGW-%fLkPEy+BA(43tuI_Qefk zU`QH=B-y_jR77CahlV(WpetkeV?J)A`q1tn0ggAu2Wpge0}ms%bsUVKI}TM^NkJ!N z2d;0f6Ek`HCEUd-^@?%R>ay= zjkA-s^+r7U)~^NOuf#7@I6ev-xj&^#X)9)&IXXIC2V^bmQ2pr!qxG$Y7$2<;BnmvE z0Bi?pA+9Fv+2|!ogh@Q=Zr&7OR#lEdbC%C9r8Fz9O!O5S_VD7?(Z?iX<_*vBryjxm z6)&#mg#f#Y?G~&zDt121IT{pc8^UI=oh1q|>mr4a4uGDbt33DwSkCLK=7LSbeR&#I zP)3X&L5@}%fOa2B*P+lkWS%L}mNi)k^Ekl3_0KU{JSp*DA|&dGh}`r$iUry-j}YMF zze;`;aRWQVrHjZw$y{>%s%Nw`sOXV{!>g#Mk-HDVhiLrJ(TK;mtV!q;pD8t3WyWgT zVyj3tAoirnMq9?FQm!`6$F*nN!qG@aCPU0trNZ+y8<@V+Y?ChTqXc&qc;(=!FD6CD z5%JbLDWmbz1iXE5(+9m_@lF*d4`r2eCA6gYa%1rav8!(y-0=wsW^80e-3BXa&h|A= zvfW0q!22>tGN=6STmBswap!E~R;63KYvI9ct$D-COKqIIXLg7Bk6Oedm1!9!tr$u! zE|@kMILAssOGs6=BVZ~=BbU7_5<};6zH`o#DLoxm2E8{neiWxN!{s^VM?*xG!Uugt zK<)t`8p-%>8E*L0FryXmLn&f1Hd23T(tFPh+BW%=ka1pqXiFTfK2LAb3hcNwzuEXyLz6$8{eQM8GrQ44mP`6BWP^q3HuoJqwEH*= zGrcBVNO8}Y_oSom1GAjtw|axxBY-iVr8WTo=amt&la0kN-?*j(aCXM^5(sY9Gk|g5 z@~7#~Gywp|kYH+Bv-1Ov^n(M-f2}6psGtOJ-D$L+bMJ~-$Eh@c-{qiVordO{1Z}JG zr^fq?W4$*loMch~i2(N<=xwuP?UBlf`3}_*6u1w|ts#+tOWYt{dC8~v%5MXJ!EU63 zG@I|xe}c5F?O}zBpx>7|=YBeyRF*K~b&Z=o=O48twsLxzRyJ}C&Y1G48jm{I@Ypj~ zhTJwizW4R2cS$so-XR8U}4t5Qb?9H~@G zZ%z#WJuZB}G^ywU8V76|O3a%p5$TN7sZwgjn?q`5-Pp0|o|N3iY0Wn(T>z<4%|Nv= z6U{g|pcka9#QAyBu%O1E-F<3pLSs#V7kw}?{{S%?P@;Vjt9Ci1G29Nqp;IwblIGHM&J1oFe_#7oEiU6V(0mw6O*#X%fKTK7>LaFYlW6WO zXYgba1LVdC9jjarMnMD~qOhY<6sgHmMzrG4#dtV-w)pa%$N6h!xhqF9PUwCo#vg@E z8oVmj`Zpx`nX|}r&sxaiHW6J~4n4soF-QR}Q0de<`x@!4ZQ{L!;+8dKJ9*b2{VO8t zPYdDmSld{PNW6s0r+@m6Y7p7S@Ea~AH0^U?a>Hs(Ufd?k* z^fk@!Ydgyui6C#E(O`JUvB%Ud6 zOEi|C2NcGncO9w+ZRr5{+NO;{$nGi8#&Qm57;o2DcgF0J7e&sG#+tGH zG_!_N>r82zbuIEE;^wxnFU(tIQ~8oe8+RL+?gE2b|Bt5Ha&abT)lCS3B`VuZ4QEsKU%Q z{{W~~{gVBgjNaPvSHz3{eQLkNcHTb>b_2~34nKP9$1iWxCc1z9ktNF=eHa#3{ybg; zr}(Ncr2XOjYQ5X&4Bdum0@&Qxc<`U&+nf+Oj)&f>8=-XrhT1@v#he&mw2jeZ*bEOSaZG|WY1aag<_Amj`QJl~Hx+_Sg)EviI*U^3qQYKI+& z7^0V`C4I#_GfOtQV#y!!_8!B-z2T`GpgxhLgV2s&m0Gp5j?N<-WSpFmde#>Wz55)k ztysq6d~`kQdJCv-lQ75|xa3FDt1M*}{)diANqvQ&$1FFHxI?Kl_NR+F#(SBu;m~J_Wgfa=&d0TTE__6LV;P_W+K+fGV_W503%w6;n7%fAR4^W zZyZcG)R`f%+MjIXb?H&-Ph5o#I~pAD#c>geWelFXW7dh%t8od^3n>~#11ds}?+j}xRJko~Tjw0qdivJIt*yPfYJqd8eEG>Dp33Gc`z#U|h0`J$uL{2oxfaga zt>rm|kKH}1vCdLlW!nD$*)T~vEIgvt?m1lIDF=ql*z*VVs5+u?R||qBi(3GCgOAdW zEK4j+9IL4qV1ra_xM;DRf|Jl~T3j=N-f=_!0Edqe zgNBuk6n%TwLBjqOg5ONMMTGKm@KpIn{YS6Tx(Lo<04k!XAyGyE8R#oFFQY{dvWE=d z96yC&)qc}(Kl5Z8Fa08~TxLQw9F|}S^c()wPJ8EyIavTCOe0;o4u8F9FyEDBB-YtV zo{^mS`!wVK0OAcjynZA;Obi|KL%J-N6DB;YR!_)}+KU#^sO|vTy5abkOR_R2hJ^C` z>b3zL{c7BRXgL`js(@^LD#$FvxKFK4urIzKn$GF%(iq50Zp81Any>!=y;I|RXo>HW zUQS<0nsahU@5h=EidI|WYHaUqxS9`@w8@Y2wrgZ~Q=-TKWnqwgD~`S6mv_*ZUqZ%6 zBr4X2!@N`|7BjX6e}u>Ms>$i{#}b#7{{RAxSUI)fn^zn|+;{GhO`#%<2m0TslhxF7oZ|n7=(q@#KjGc==e6RvsfNC{`HEaRp{LpV684p^WE3L0GC z@^X-a{seBe}I*pf*BhBMBsi4jbU6MeV+s$PJs>cDx1HE+4!6a&1R`qi13 zV;}*m2iVni1dNV&rhsCNk~HplZMmiZ1A~l#ikh^IsX@;#rANbM-1gtrm|!GASc}U8%*`41^ZRQhW3jZdl958m{4U&ut^MR!{?ZM_d}XV-A3(qyGRN)IPvt zu4uw4G>kmCKb9)$aJE?ixNR8btGOrFwQPJl@Z|S0iK7JjLrcTtE!V<+e?!)eVMiKY z6(zbOXH}7Lf*faRoz1M)^E5KV$s+Ss7$SgUBjzAeY;+1sD5jHDvlViG+jGqNH}$Hs zE5jtPO&pP&;H&df>sQo>*fN4x9D;gPtDBL=OMj>^@rvRp!hxjrY;zx_Wa6Ji9QPs# z-_TTamOBn+i!@CPjT3^dhYY8s4SDWJ1GPZL1gepZjarNU0EZagC$&~*%wb6<1d;Wt zQ15Ot@A=R$yJbgip43qrn8Rlr{{SuM1f64Jk^9xMpdbf!10I`T{`E2%HuxMq9g64g z%agY)jd7=+YV7SJDZnLi7#$9F>U94A-njxJIOe!r{Y^w3*;45O9P6z`@SsJ>IzS%v z%S4LOC?`1}ZCzUooPRA_B@El2YKAZ~zSQN2_ofFV;-I0WaIiVsI!@J#TDN{%S3J%D z{Ocss5X?^@PfHZUP%#`I=}7ug&N))gO!J@xNK(xz0D7EkQPtNOQ=R!x!NAyNpHk_| za_oB02I^kh-NUKjjoK^#k?tP@JB$xnuXZiX%Qx90Rdvw$7*y=ugjEYxV|LTqqiR+R zkjI>D({5mQtM?X@No}rq(PSQCfpLO*YCUTe5&;?*tUrHkw;*7p6Mg>xDm~t{wF@agUBC*s zI0L>hzA!43^N@6ccH5Z#mDh>6D>(B;GXqo*bXV9NVZhMk$VSzduf! z_M(|jfsAfPVbuFplyk~5v6>O6?mr5WLFFfJl=a7Iu4GcgkU9SVEgCsjM?7-mNb5tg z-z0-e0|ilzyY3Gvs8HzuH<^bnxj)*h&o)LpL-L-W{{TuDQYQ?roUs6!r=p*-2hI(ES3N3UwDah%7TAOxHH+i4Fk=Py;^a@~CXr8{~4z8H%XM zCtwG9?l>)v*z8d;=V6vV=lQizi{UH26~v-j{{Ramb*M(wf!xp)yek8h&C9I8$F~%c^XPMmA7sOYr0$WDq82Om5L0gwIEHN~$a1>&sT`&4#JWNmSg73Pw>6=`?sZy33lc$LlixZ0 z>V@A6;tzQ>vf4=U!hGo%Est8zv$B@T!C|;UlItwUd9P2}wUW?ceJJX%ILa{P-#qIHqqx?8@j3$J4Fhvs5y2MrbG*_^L5B|#_?{~= zDud=w8zgfa>WW11s|8^sXLVq4k@cy_OARJ@*nicEsU&b`{jFK*#GlmDo-Ao^Lxx5mm6S=QS5K!v~Xh1LC!nTPCG+s zNmDUyt!J{B#d8b8Am*;PHD6MJjm9gzmybbz0gCR_I{yGHpfLW`&0bjW;^)JTSiwA% z@(1Hqrk`dn6I~Q|UG2rK+Rtq58^^dS(yd%x39clOmkiC5r>GVf<)8{{X6pB~kwXn;1Xwj33snTFTDn z!MawIZm~@m8vOz9Lz4v*QMlnZR{jyu;w*>C(e4g8(Bih53ERer<2nz_q*lig;;DHR zq*jr%I69m$*;|nUx9sNCYLVQktC6Qx`bq3T<)NwC| zqM3EsCxtM{@Jo4If5iU)pGxK~W0DCBGNU7}%&ag)K1xhgK*czyijb-h^r@-o4FrZW zEXf$=%msJ4RS+uSRdxfgu0MfA;_+B^*)r#T!}`}>kXL$^dme%{7ai5p~}siwv` zV2<@8oB{gM#y@%hh5`Avq*M-r+chjwAc47{2=d$jDaZqQL56nvnp}q9Vt^4iUb(1( zd4fpwsWR*_`qXkk!QUN)A(1;%5u6H>4XPs;(mK#G-Mpj>6WY1YjvO8(ZoO6kpTySu zMz~BXyEuJk{xg|0+^G^NwGQ>6;xUbD!{v!O`9);pXPB*$nn)X9 zY$-@aT#O4AJxvSgiYs>NMM1Ra1RR`@Yem@usj23Mv_V=D1h(d;twEDwed#My*vlZ( zgF{oA+Ohb0?t6lhsf7`B$&51r+fH+zN_GibqbiRDmEG1n3u$D}RYFB{FJWsJ3%k*3 z_RSoAR+cjDC;qNW^c8l>-o_pYX+6n%hGg0&xKvg8XO(i~^(sG%TiqRz^tCQIC13aZ zBbYoE^eARXkRRrm0sJ$X7y4}G=j_VRnKbJE0Chn8NyT<)dZ`bMZ((GRhS8}*NrpW# zqjOodx2t#YB=R+Zx@L$di5Ey1#!1!4R7W?ccX;djk0vY;s+Io$vCUoZ>&rFOJPfIv z2Oec7+-+4-8K%|YMDL_z=^4Q9UAo&!*H=uC+VJaSKqYsV#Y2(E4V0fl%DGGHwn!KU zVkRU;qy^I-d<}s=vF}|`w;Ps@S@2S*J4R6?nl-rHiKwb(j;o|%sUjGd(2WtOW9J_9 z*ox9NWIu&O8i_e1_oNMEQF6*p4plwQ4Qej!ipaGZnMZdOvGRf3iirSaJMyXY^`WC} zo^>@NHJHhRxW#Dj*%2;RQO8aBR$)4Q>D7oMzgkRau*jC;?j^uqv>hX!*s78dq>8`9 z#BrSwkC=z{@`}rl5)RdV4dUq(TjxIXSoRsH%(`S4>H(yb%pYcjvX+Ff<(iYmSLP!) zs6(H#s^=Np)W`&23<`#OcoJCA3@f+Bc)nli~zv8b%Km9dfd|Si}JSaebD_k}S&)ppV0BYRu z*QBfU1Xm^TnQygQMXgwPWq?!5f9+OsC9{_~#wx@!-B?>R_S4A=u>i6*4k`t4ziySP zZB||i%(x+tkVkR7LuRdL$d`<98So{(xNLIFhP60v#0cuP+(@ryQg+XLS5`O8T@yLS zshaUhlfudaBWIfqUxBJTPRqv3u&yQQy&L=fwFHuA+?esdFn7%v-f3bg(dY*o=A6@p z%yG@~(T5xtGMpq#e+su_#jbdJ_);lkQ@m}l9@VjL0gBTxAZ9#)I0XCEel^47kz#d^ zRO^7jH^@KIv4&?SwWGGv)Pr1E@cun&N`)uzWk0oXJX?iN!mdil360N{@yz=A*F|py z&5WW`GlT$wUq8<5+8Xn=s-DntPa7J|x#+a4Q zG5FA-kl+GODkC#;2lAu?835`(8jDU3AW8J5Z1?=CAX6DFM2wIJxv1o79M1gd`E#ai z=ox2*<#?sI2M5FgyI`d9t}llGZ>?kpe;5N@nz64l)_zFu^xumLoVTa%wrSUKNp@^k z3DikWR*@OSIKbyi8KeY8#EO_3Qvv458&tVd5Qg1pb>5pNG{t}bnm{~-bJCzOzbZwQ zmYXAOser^|u4%XWRLTNs1YqD3KoKjGNta02=B3DNYCSig0LaB-b{xW!`Bx);*$OMF zyKOeIk^F|a8{?M)wR+xxd}PiqQ`$#u!U0|tY7ajQRnT^-wpm*!0oaJwKZRJ$cv&6I zv!QfMQ;LRaK-3J7r>Fx@nqnnNn>4ev04&nyO`33NfhLtAiKLL}LiREF2|><#IkL^CNbY_fzLxsU zjP0r8Y-;W*+_DQ^Hv_)OJZ3#zj%ugFygo|{R}zt~LJ0$t$mD8`fw;M{n%411l{|&Nm=Uq4a9LQ|wX~Z_ zY{dxl=}|0npXj8`14Cg%CWj*hQta4JGEZv2vlx(mCtx{<259$j2_ksUCsMzSP~lUX z-Z^!3_jF->v$bl>1~MyL>BdQYBoKCv7lko~`e;v8BkB!M;}%84NP5=U>sg6J-XPsEF9wqDuN%KfENxD{2R?ea*L>BSFHwd6+g?9nC zwUQysjIW^_s>}1JSD2*Jv(Rf)0?u!=TBL{O#v9yotoFBlEyQG2Vz5ATaVzJkB+@xvqx&wC23@VJCkUMR-MujCeY|Se(^axF&<**~-zl zV>W3_gAE<;@trr=8py3H&L;P zw*z%N#Wk9Rw1_C%PHBEA+S#vlMUnGW#2#9$8tnvf{k?49J)gpgd z03F0$HN26Zyrffb_t#+wJoMx6qWb50t;5(Xw>HFMM4E@Or7gjk%eyZQbo9g0pF85Y z#6H-nm-Y*Mv4@bTM#{x#{UaM`X0iCT+J zFM9|8DU`B-&@cp7ccaPvlbh5>{YG5Nn4E9QqDUL;b6WPi1Bh`2EgW7POh4R{Wj@{h zm7^EKFj=a2vl*orCO@M1fSvdlY=Pg?A(?Jeve@NHrd zq4|jB{^zY$v%*W9;X)jf+iIDG@7G+8(Zlx;Y+KaVJ67rRb@Ab@*>r8W%F(g?3{qhD&&kbx!Q~_hI`3TL+y-!HhCVplU&- zPhnD}IdUc`q-F62CgVu{QAb0B861a7t#sk!);HChemMQA%8Vk9L0xdvQG~xHL8R3S zoE67$ilV`W;B0E_=P~I&N~Rlp5wPo8BQ|n;V?XMXWMJ+33ZiR6jijIIb34XVG4hf& zBNd;@sWifv)Pct0l`3F#TqoiXp>6M*VY18H-}=$MD~&EVIyzyBKD1%JMmPPB(z$xQ zV|8;Hm{nR>2^!M`fI9nan&F?NjB+fH(bF8RQR5{5O7un@_PWlM|_Z9O ze`+hS0k`YE1q7WrQb<2^RPY^k2Pc+JD?uKXUUZPV$B55FInn9sPj56#tKpCaId(ba zPd62|jv`E!`p+O=Z+fh}YlWn`Wn|7y>z5w<3w1akwDbJ zqYMx0il{yx;TIvC@aSEb4be1%^{N*i6G=Lhj@TkMH8O3FpazD1A-+}FqFwXy=M?>n zf_Y$Bm*8FxC;-Co1^JmJKZg0LGyeeW>cxpLT->7LeCcoLQ5by?Nx+>Iz^6IXLDii-OLq4o^(hdmj{dq_L3Of(Y1*P;}tx#(gW? z97GhWsL)#k9mc|_$-($E%zRE8Y{VY1;~#}mY+MoKaEvJoqf2C*bEYVD9l5SIuTq!c z4hJ+ui$4vSuo)y_kD=T0sxR;-3n(;8JZ-l6gJ(ZVRxS+l(H@pk#E)KCqTAf8a_C{D zIXPZ?9P_T({sdz`g&z>f8>o2w`sTBn{{UyaQ4462+FXKuVm9x%qrSz(ob#}j7j$(& zlZ7WGvs5Nk5@9j`+j{CQJ_zGcvVY;xfn2Cb7jMMYeE$FrahUbAoI2nIIR5}_^8M+? zkW(?yGQe^e=UUtshaMxvZS3+th}b7R2IumsM%GBYvm?fPGLc;uz@;PjjRC-BiGuo^ zXZqEy$!DU$1a{W(+r+qzF{MQm^T6#+YA2m*Vi<~AXi;59G_pbtC5Yja^T$K%XvY)) z$Cy1SY-t{Tl!X9f@!ksu610%`A+8kKw~%_3sK&b-{1dVa zCx$QvJV3{7^IZrW^H{cVn|r94^BCVzZGk)1)=~Py^MAMb&bPs0{C<@_$cS zt_5A;>oUqEgu1dY^8t`K^QA6<7j11)88I6Lm;ys;(y`|N@A`A+M3CvUxGqvqRe(Bm zX+6hpdbI-H$rz3$QmisT$KX#gbG|4XQ4-e5OLTowedgp3psa1SjXW6~9I{@AP%x@X zXK{+@7XCPbvP2^p1Vk4ApMTueSHqUFG?y;183>Fh03Kxedm7${gmE?rB3exDqyPdC zqw%Ma+=4w6uWmS;mbX9nCW$;k(&Xw~?}92pvl`*P({-s-qAtT*4|7yrJx!dG z>?>mREp51z!rI|3V#r}C3AMPcj$e?Y&BAn*-RNl>1egIZKg8LF#`aL_lyiUTf5Q)0qx8$Kpj;NdeLb z2hGue=}EImNu&Z*Cu|;Hy+D7s5B*1%ty-5slmi*YSMK`M5u$l=0aXZl_oZS|e0nQP zPEHBu`w9?ao@e|uc7G2i1oQ5DRdNuYnDXyIahAF|wYcG&JBM4RhpbB&V};2*$*aE! zaZVk=_^tFYfcE!MEQo;#^PxUb+nF`SEYB8NIk#QFKaE|mzB5naMj9F78nvLvKT77u zf-rX5GlG;|qk8K4d3ck_c!g!SCrAVwWA5XnO4EqJblOm|zCKo5a;RAF>F=2??k4eD z9!-Xo9^LB0aGOsvTF6zCX9(Ljp{{w)5}QouUai`og|o2S^sNYE@SzL$o1JG_gCqEa zY&j2F(91gr(D|ESdk&_OJsAkwrPF43-QfoT6l5{=^sOM^c zKI!f$Nm)>r6M>d3T*ub7J|48j9hY`s0ra9@@EPEY$qDh1dEj}m?N@jy1|%5dyZRdE zntdH{l4{Ar!WV{Hc(AiPQ_9{hp}1qqe%PxVI&p7s%OdMW7-jEU_Ffcro&>5e~uA>s@oIY8c1=}#)AnlZ-hII`8%_lqH92Lt5>v*b-WTXTbvfts>YD@dpt0n1}h zvb8HI<4sYOC8#sACx{f2g)OkpQBam4jlPwl%eku4CqGJAGP(9%ENUGLtEx3VmCA9+ zKFP$)QZ^!)L#h5o?@V{;XrFMj*q|LQ;GbClkqeBOc1U)^`x!?Y<~*J zOQ~aB8PvU-d{y2U8vgbxVc;^6@gITTD+C<7< zK^k$JL2V%9?t7Xf$7N2N07ksZ;@}3e z^+HJsHhSl3mUSaS#2@tN3x6#b@6&$u81WoPfNU3)OdRCXAX)xlOmGOn+n}ex#zi&; z2H#2mW(002!31X{`nof`;tnGo%FxRv2J+pr3AauIR6g zY8%{D3)s>*6e8dey&DaJ+3#Qy+nk@JYa0I?wFP<&_TYmGxjM=C)Cf;R+orliuL zHD_(c=eCoCPZ!zzStwvT|`JTtut#meAN=W#0G0DOsxra#e$G%QF<2%9OT?8Gpc0#Lce`HZgsYG?W%;Ow7^;Jln`N?TXLg2qfc|0WKcWF#r$Y3~C-xk2X&H z^IB=eY|{n~1p4!xlRvd{m;6ir0IIi>U20Pzs4+&s>)32a<;!~Kj(<^_;-km+`!>tz zabl~#?$`Vx&l9|}v@*?Y3Nj3$NiJk*T=O|RfX;RutCzW)-CdbmR#=ajiyy|Pd3Pn; zy5ztBVO4etI|>9tEKh3egjz{AY~sr(%2yRgvZDiPl``zK_0V8Js6^v_)K;N4%HGDM zf+5pMpVwQU)?N6vt0S9>7zyCmSd=4H)S~AvsIvn2;*z)W_*a zmB%B$T+qnhy-a&hD`Efvfv9$^e}=e&Szq{aZxr}O$42ODB!Fr*&l=tXkB7sdtMw7L1vudc`Y)MNPS;yog3D5L;+w7F7!dets3@dSKzB0*?ik|rb{ z6Hp-Y=shb-v&rEoN|{#X)Z0S?nFBS%W=%NxKx}^1V};!a?InUj0*4Hz-(o6+^2sgK zD(*-~im2_*nYMrqnv1dj0H>GcYF|Y!C69*VAM1V@KXHKHop)&vHb7}78x;o~c~_Um zJh0p?#BNeWZ9#`aT{IsY$r@ry6=RP!B)})|+LV*KMrp4nP*U9(Dnz+s<{cd^$QrttmnIy zNk)~HXb#N!PBZC_)pU}(slgp+wl`?9mQWHy^Qbrv>+e;|lBPJN9gvOVWH7p%5!4;? zRquFp)YhpDqz*wloa15YYW?3BYbVt4W;Xd5_sCre?#HM6N211TAsZCrLepertwQxLFfz80LmgY5Qwg;awo`?F^b@u7* zAZCUHHw(=P*~f9~=~?k{M7svw{Z&I`1_NQ}MJYp;h=r~^NE<4{aqB>a z&f?{<1QJSr;H1}Ed%$GjcTaI_s9|BaWeii)XQct=oyYMmBy9B6wQ*$4!POhX4<&YD zFT?ynfHFj|4gUb%aHM@X{#6;QTE!4F@kn2CBRSLQTS7I)aaFJF{99tcd6UdNy4G;@ zEzKvaq-jC{ujwRfCDgOvxhNK{mg*z~D|&f^sfL#f3S zFri?Ikmph&WYiSqQT3^|Pa{KJqy~Y4BN#MQV#0>UY<8%l>1tyOo`#Uz(~*(2HgI`V zfXz*cS;?p6y)l40RO{e5RtzbAi&3hh_`TpiV_U z&Ifv9w<@3zrvn0`%+$`?^`r#0*wENx>r)N2MoeHDKtnXx$Tb3K2FRubP9jR#%a1|% z*C})h-71b`fGeir#EVb;V!5OSPAdwIWpygdSJuLHXJsHm;hi7e^8jv}zHqYXF z$Ve0EojT`Z_}5ts%QQ(UA1kOMG_!hn;rxOVQ|LZxI{S`U*fxiKa&<)9k9sc=nn-wj z@3G?W7f{&Nur=nY#YiCF zjlmr&&^UCmO~b9^mpa)2IjZ%(nmB4v>4q$hT4Lgs&3VITg$7uzRCgnKufr@YxILJj z<&|Y4F74CMR)}ik{wKO;;yw$?a zx&1192JUb5B|{@J$_}7TbGB;_gmBB-xmDs#DV!I6Ron3H6$OIoP+n3ubNg3zPiG7q zBiWRBN!u@Uu73LT@V+r41lE(HmR#e1LtFUutVm%uJbvh1MjcgfH4T92T(~JV<<%NW z^+5js_nCD60PwA={;JKt<5ySeU}nFg7q-1-iJtjrD$YSV93rXf_8H~?>4IV9&NCT|r=jvt9g zA^4QLHjo@-o|{pwT_sY{uyNcR)_vqN@f*vDpp3egJJxH9s~I(EXwRtF*R3oHYCGCT zI-8BU(+6??0L9Dy09mUGhE2OwR(}>&kc?_M#U2yWw$<2`By!8K4VtdPcdB^vZQici zuT!-W5q2Sk7~JNGs1@^PokB7(Oh=o4rDjnj$z$5I_zHNOQ4ZcvPvwfsj21lx)v>_Y z7m5Wxc^D6Te`<`MgD)TCjZQChEuFHwKx$^jNX{r+JBW+bDBvusxdW39^nNdHZa8y= z8DztO?asOGA1Q|Z7aWUeKE|-cjx)mk(bGA&&3zkuR^P|6W&VmN=I(NT@rLQHJ6xm1c#*g*Z5@ zsnyXW>9!z(G7m6nRTIvT8Fzdf6XRL8g&_O6z&_ootXKC|IELyH!FB}fe;&2HQFzi- z(ISUl#Ado-oz+cRCYstS#l)eO+DRgBIw?9^V_Func=oYNwpl?qtj6GQ$TeFxgx!wo zRybU(2sSgRNu$XFxb&-+2}a;)89Sy~T*Y)$e3K1B(T6|AkI>Mre5kSxaky`%Bl^*~ z8p-{d4Ox=Q_Fg?3osv`vYfqT0emtI7WE&7l{`Gj3%Zu_xPsYvaeC_E}_;uIX>{3(n z4=bL;Rm$a*TwOAI6)(z~pQT6${*_w(+}zqSr~8TLL+UEd;+vM^rBqvsv=3!rr-^Xg z`N+GD%s;&chuwXq4KK9Od0ZdXr)T^N+lyhck|ja!&a>~?xOW!&LJus1Q60xx>V#CA zg8oq|Ty|@9%~h7p+wH>S+bhoa#E-Zb9l2(#B#A6bEN!V6WEz$=E(qLmr3T|8St~_L zdxIucLQVvQHcLo#u|BnQ8t0t8RBpGwuyO(R zh$KA2mHb6o9O}Hs3J+&f`HGd@z_o`^8Fn~9+|;z?Ng2wMY_LYu3z-aX62Cd}ueq$b z8;QU^(%SKykWNNDM@qK@hW1aiV612`fc0WGKecJ?Q@gZgr^`lowKlp_UZUVWduFfh z$S}iUil$|2zYX-22g#2`=6_nXIk`R*ly4oBBX~%7T)x#vxGV%G>>Cf`6 zEaddZ^sh$*`9U06W5z@`rY+MH-K$bf)0G}oT_%`29kmVUy8=fo z`qgmiq02IcCm1KCY-Si8@t>_h@tihKeAErp3QlHB@5_3Lx`~@1n2$w2(v1Nq+ZiMh zPW-BEsffVj+xpOi-4+RX^%(tW#Ik402jbv)H0n*f-$!-ni2jY5EP#_aw{FUmlM(lj~h2Ev_tyP(~V!L zK1dk#$Re<#TRIk0!9p>X%~e80!;z^$++_3bS#tbEa6*Q&T!Icn*dL`NK2E6k*{HexOc?~K?PkdHHe-K*75Lbq9bNqn*^l$a98sO$e1D*_QF-0)6 zT-H|PNr9co^rM@CzHKCupbq}ko_MDWkm~zdE}%9ogTFc{yt4>G+@k_A33b|)0ckD_ zivcV5jz=tQRuW}mG3~ulibQQb9t!QaIjczT%!oC|?mw>LwDXR25J?mPHzJjc-1E^5Rf+od~2_N!O6#$}H(LVbF#)P7>1O-M5?Bu2v_ z7$cC}7B<@j@p!+_Qju^V|X z*gs0ew4D}{?9H_Q0OF95nzYIcT1PXd)1aqb8G94IiDx>n7{f+(r&!(82VEd_C#P;y zp2{?!dx#4}kuH@Z%g41q^(oAIR0_&und2Fa!43Hv7W2mwl=vsf`)@OPp(s@J(m$oYnp$-^T#-TBblJ zu4w?kccqL|=cj6J)BuGs6!fSVhbmiA&MC$|lnAi|D)#a-X$(#yCGo#eirnE`J_}eO zyScdtOzN`0PtOtc8-g%@DywJ2>}_I5#4-`Syx{NO*0k)nm9*9~Le>i+$lCS<90RZz z<&WN;Sg6G`?9s&~X`MF>;TBU&m$pk6`g0trNTjyh#^<=sYjJG&X(bnjK*2^%V+Z!G zTj2@gnP=fw7RGCN37vC~CjEJOS5F-B+rrEwXIOJ>B$NKF`N} z5k$DuSYRmnR$QMKSjTYCO(Vui2984!(7^QvsQOl2@5K{cMIt4t=;^D>G2Mr^(y7P9 z4ate_?BTp>KE1t!V)}uBueTVB;6=PRF#pD3_s56+!W8&v&@S3!V)R~ksD zykrm*hxa?^R94m>JQs86)M`{{ERhiZW8o8WqX!=GqF5|DI8a6S= zSD-ybOG8X+TS;VfByQlZnBa0fC|qvb!#pl=jXHK1ALlyKNfn+VI!GyB#4oMb510=x zxUA-_7Z*&yUDSGtSlIn(N4g4@H#Nw06`Xad^AFOkx5#}er1`03q7Zz_0=3}eJ|@}_sm`eL-izC>mGPBe_1S6lF}47Y{}xZV2y z0O9Qi@WCH5cl|NyYCLn{n@$`o($Yy}xSjN2$id~f+qtWM7tEYK&d-3`93&_nTz%uy z_di zRK|~;DxYyTNY(l|#x7HI1kk)zQAau%bt?j=ksOUw%#2(+D=vJaPfuEm z%!_ZO4mtof6`MDWjXdxn-ed%N=BUp)$Wf7l431QcamN^82xcc39K}|hO3k(oKJwL3 zE>EI_jjBR<0(n)-yLXP&9|R1aVmcbAk$98%T7V?uKDDx(Li>f=_!HTzi_AL=kH)n) z%w;t(P%Wv;X$zB_4nn3X$tEj9f}%;qU=E>_64*Sp1dqL*iY)i>>c_`4nr1PNP z)5n%Xj)UzINW`7JnxwK2MozDK7>&r-VxsC}=suMVA3~WcmO4+ZWXgYvqmV2C6{hm2 z@}1ScD#^NW454xgM!N?6~&DY>X>{2QJl> zcDYEH{KHZc>`hBKJxi#hWwIR6tyeOSN|U8V7VzXA^do_qoN~x4HrL2DlxUL!la9Yp zSjU#zS4AYtcVPCcesBu#$1xgA5G6D~XbZ@1iYk-Vr3@%+a;e8Okv6E^ zk-MqEp#w`az_Bgu)XSz@nO%D`k}9hja!n11=}2QSsoQQ^glN|LAwA6tuw{x}Dmkak zXh{~KIQ|p=0MkV@DKp^7j^DjQ8Y$AIrCAhMfMfuAwkvQb)O!Jn!i(hk*1S?>Rlk^K zra%~+J~~kcoCBU;l?R#hr$R$*Pyv*USULI%G;U&%T~2XKpb!ru`cvU?%zIRfG?A*l z$I4Du9Wnu`*6_GTN5TzcjFHNSB@G0M!^(@RJoz8*R9^BTxB#*G{^qSSbtvhua-x?d zGr1>!dWMsnVxKz5IKb^ii8V@{a?}|m%2bPdyJDhqt6{mR$@E%mKzm^;OvS#Sx1Y!D2_Lp_s;d z`cROiE9e95>+4m^m1?l#^Pt3$$v6|aMC!t+D>*YuindyXvObh>c01L4+iMAJBQk-$ zp}i0m8=4x~c(N%)G*}LB26-L5DYrQzG%Z7})Ps;c4JtS0iIog9Q5h70_ovQz^q@|d z+v93%WpPr@)g&2z+JadKY^1w(SjvJ2Q$>@6&tlQElSL6@<ir{z3v^`zb~wmB=Bh1G=K6@fdnip~JTD!^#*Hq7#$#YZ52Z>T5r4$d(#q0F+8{Lu z*E&HY?_0kQd@9^R^`g5iZw~Tx@ZJ|=!)(Q^oGTJ@INY)BPg6~pNz$Xi zFL+h;fyLdV(0}wqBB>Ex?Z-G|cXqNvBI=FQs}D-?ZY{&);haKSNjK1Zt^C8GrkRmv z;d76OEx+}D_NKiAkOoNrcQxnV41fK>ZT|qP{{Xc$+}?k*qoF&}ocKY=agQk^5$-Kl zX1PQE06;BS*E3tmZ6ZhGc#VRR3@(NTAdYnh*x+@c5O7K5UTgI^ACt9Uf(gSo(s34R zE5Nbdk0UF5lMmR??yOqWJ3ao`?25YZs1Mj0@PC47d%@`Wn^vf8sQ~(`kJm zTbyZ07;PZz-{p&5 zqi1(D%Qc>CO~xYO2V%9#g$XE=HL#@UPip8&V$C1$Fj4jS#WLBN4&H zMCk`tZRsv>Pih>k<;QbKU|o(VMX^L+O$@D2%Mi=39O$G5u_jW`g!P6ybr``qy(4vnXOTk~bZz&b)Gd@8ey!!|F!{fUr3^ zJNC)NaC+FTDo1anZrU{lLCL|$sTTZch7Q!^a<4G$8(t~%o$*bhJ!)Y*yy<|$1R6x= z@TYCP#YJ}FBf?q43kP<=UD|Tk(LWS`se(C zG{0oLFqg@iVC_w+pc`sIvtCFQIL({i@beT$&Zkfr=jmK@&`krBmNrHm7*~0kb^(A4 zp@*oh-%aZt6~?U?iH3CF}2q0kAMd!nomn6I-!2X-B;aX*>wbnTD&l|WpE>TkK#kKfeDCQ45 z{wgorQH=Z6_#torz#CA!P3sehUh$iImuMml27IJ0Yi?OROsZ1<0DTit#RAK9t-j`hdeaLcQxJZo^Jaz-^^=B4%yD85k~ zqaB`!dRSd^DNa>%byH0gE2>!)2Xbqd3ojjl^doTzK_&&0DwN z5kTuD!}zQ>3y&x?M;;tcl%;H>vQJ4-LI_U};U-61u^QNZ)mr2%hzRo|43AM;m$3;V zbU%4Wt|RQXj-kZ}dr9nYds^Ed< z}EPc4C z3?JI9|A|hT5s$VNJBXQepYmHW^fnGAoIl9|yjbvmguW zBV^XSg@CbF{wAh!3BbS=aMM$sdCgPk#X%w@)kWeq&ve$o$dOJr8xz=7qs*G*_=qXQ zXBolPNTUWNk+j7raXPvk!#sBKJbyB-N78}AZjIfVNZY#kTijL5XJg3oteSC^KLvJz z+DNj-lCLKKidXh#;vpy7I={@Ocx_;2(B9-t-kJ?lp}7&yV)5y;l^<4MH0Hv1Z>aZ7V^6fp)0 zOn_vLTUF|>cg_#~jI^TZ+8nwn@YpBew}#$Q3QRJLr(#F;tw|+VVR;`qP-M_Jil4K1 z$~^F}kbOD+R30rhI8(YEq`(Kz)IalNoAP7$qU=(n(5%s%s|<=AjL$Wka!PW|pRV;& zfVplRhWoBSqgct{%PotX6k^Gryr+N4l%ZUryaIhP?^lr+W~=eowOTB|WW?M18nJx9NoYIFD3_6V)^kUWXlrQ8i;RhC(?(1) zjvmpr{{UmS8kGM4A?sSmM%d(gRUQ_Jd%?^T45(th-E%;+o%ns%*e;vNpW!*}TFG*4 zc|(M&vKQ87;^pn_f}*FML;TKp55lR)T3a>S%*+&zUSG9un3gFVs5z0fHhb1f$BWMl z^7}|XL%nuk$$I(|g(v$pa3Y6pNWY~M7!)~%U~gaRQjLs=8@V|4&1R#kF84$)Nn1%+ zb5+RtQ(DL`rWZ`4ENi6!{$tG3-~RycQ-9G#vWvm8iZjnnPr2T%O-2{w##@k5LZ(}l zRkn}7l4u9Yt*L#DKM;OuV8_u$=BEDu{BB1ki}XLXpyL5TV7qMnn9^VOS3%$ixggk{<(Nv@gFa0p)R=3XUm+TS`*cLxi zS{32vmn@I;dbrg^3Z9r&M;7CcVR9gfC1MvWTlB2q2~E^TDJZLE#sTNJJu88~wH8;? zKvb|+^6${@E2`nVavS*sEQTpa*k>U78l~|~r|{yuP&Pgr{-gJKca$GG;T$PiA{Y3-!7l*cq>AD;baY6~_MOB2XZ`vSC*Op+fl13q20qT9*hYdnup zQh2U64+vz>%TBR{$c!@NZUC!{m6F57q2>uL(f#AwJ!(_H_R^T8*z?bE+*Nq>I~75w z@;T@#>&bCFl3ThKF>bN}og=ZTl$>|qMd4uxA)IPF_hM^O$Kla*s;bc>k|-b;$phLGD+EOxfX?>WJ5VcXue?f7(7(;`7T>u9`^bNcU*OE0K|T`5x>F}JpkDdG?@ zjDQM~4xU?3*@!qK>~d(1FmsuH5s2XD2Ln0lP6kIhiUM8p$Zh#i2Im>u z)|iJQ8{;HY*E)_q=oK9Pbu`@x&oX~pQV@^KLCtFL=$GtDz?_yj$mLlhlfOOde$hhN z!ZU-UZaN%RS)Pq?_9-IHofzkzdakf)1+l*~wOeiFQI1XDW3@7-l~bNs70qVFH@5^g zujx>Z+!@I{nTFw`}3GfXlu|P-p-z2olB$j02KrhSU_w^&NWc zM@0uEhWR-o(3-G9g$s=L6j&Lu+W!C)Y#1H4?M!TwPnj2=G6Ah15Xz@mBxL*3v|-yA z$PK*~1`FHjbU_$XBdUxHW4#tBWscxQ8MuE7B2VJ6<~!os zYXZVs$Y=3h>_ZI13{Q#pTX9|-3@e%7I*@-b>Oaoa=L|?VuVLHiUR}oQ+lTRMi*hibwI`~b z#ZnQQ<~=BpoMl^l?NKJ1G%=%R;m#%go5YkzyCP>8&H(nNLK-8GToL!qK^=FlMs8Jp5yGa)`wg}m=D@{y{{Y22CLR~XOfaq^ zWDL3ApRQ}ujisu`Dh;p) zI^8{4i(4{yYz-vlW*Z>D_cgA&Hgiz`0e~sM#!V*eh|0Y=yL|M==~a#~j#O*54RYt! zf)AxI7;m{Wsb{4s0Affq{VBQfr~1$_sm9#)rB>})7)v~mtmnhZjGZcT&8PsDmysZf zIU++4&b_i<`d39fl0(65)&Pi+XHJPQaK}9H&)C)(v|${R%&Vb>Q^O#EpW3b3sW!+M zDkAdE`2x3{w3bioYw~KO%ivAKZ1cj~5!%LDctVsdoypEltbyOBD!=gM>)i1Bi%Sc` z4eQ{?;^d+z=Z<^&V!7u_my$^>auZtX0kEx{;9z?inmmxnEOA>86Hd1z3~kRI)yu8N z!76QN(t8de!b<(F>n2=>ia7~5=tu*VN&|H=$uuI_VuJyMvS?<{p{hUX_=`7hD_jAk zR{*XDAjI$MwF(=gxLIa&jaiN~vYcQJg1R8&*A>~MX5YmZ>^SxF!79LGhZ1ToN&G!| zRgN>n-uBAL=M2&YeR@ICSa$kj@vKWwmV81N`()OBBY`B5=PJE+tuKjh1Xh-J3w&>& zo5V2^nhNd#2d*kyIM02UW`vYlY{|QnyPUQ$%r8y5)noy(LT5PKpY@LYIPWb+nV;lCUHva(Lr`%;MQAJ@H$SjMD z^-=p8B54J3pkaCz8`Q`HmMR#???<@7;!GUObrK!Aeg6PYS|+B}2zTB3rg~I* z8}jQ<+$G}G5}lkj3!j&&dewbyb{bpqG-68+F(;=w6RHvFI~sh@QdZ4K$4rA9`aSy-ahM0G^%ioG6~;ndue zWY%m?;z$RrUuF#O!W#wKKS5kk+oRH!gW8gijd%t8$GJ6SUoD<=8DiBY{K2B4l?aG)APRAfrPBff!-lTDpwFY@SD|uh}P@l?> z89Wn-&v`t?>gNgo(;BGUpQkF97UP$8JWg0+@YyyaQuoMbnI4T8u0D^sM z{@jNn{w>?s9UW zUt#7ueJWpShTa1$%*hgOhhvO;S5`cH7W+A}WajnI2)I@{9rbVz0Lx8pjXO7L!z3G)k6Y6EsAI)a?F_^U10CpwhTB$=f{DkU)JrqB z(Gx;yA#JJQwVLVlLWPgDQO;Fi+L=|D=}<|}AzLGdTHBA~%B6=eJjd`Gesp**64i*Z zxAT#+zfY%1d|T*x@?|dwylo~(#46m3hfn3sweO;hEv=)9b$<>pt*GFs&rI~HlgjeG zx#lvt=%<(0`c+w+;{XBl{pjKrgW6oZz53yUDR3ALM}C#o>_POdA~@ZOghniPTng-> zb|7j}q<}~~#dG93@IKHfmvW#MC#6)pMvfto)~w4UGHkMxV0Xh{)jP*`ZSHm*GB>D4 zLo1W5ayeCLSRp5{qP)Hu5T`k+H!O688?&BOQC7_6(m-_#>Rb{z_BCc%g!a0>h&24c zTO;Z^Rd}G1NM&S3Wk3rMGpE%0P{1dpY>~v-C8>&vqZ5FqJ*ewzG31|^sw9Z6%Z0)% zxLhg8nRTD8{{Ygti%SV+yNVJ98HRV{I@e-bQ6WauGt=}i+mRtAnqw;9kJM1ujPl#o zqRIdn9Q#x=)Q+qcRQUJKTWF0$l9t}KrNt1RFV*5J?h9LZ;{KbT@Dw;SU^GN(w0X3J!$}g zDYhFB25A-(IOm#tgV25yA^vSr*bL`Bl!i~+13r}!F}O6r`GbK^tZX^`C;>AJZ_cBf z^T8B}2a%<#8z0J$#wP}t?s*Dx&%GiK{XX;o6U!Liol&`r7-ZxM$*b`n)K$q22dz?) zaM!Y1>;d773nfT;?KnvgUBDpb^{0TWR)=Aum?B52SYraKx5qQDN4 zdzu$u!{-#XH4q6jvAzk#Scf3cIh1oN+B7+H2^ql zf%;Uzz#Y2K)O9^7c^WsQGU0X!v|onx&qgbHJCK$)FuwjG5D#su4Fx!ZEJiWE zGgcx5I+2R6Jr(r>VD9&@{ZHNd^OPw&*dRDHyJz@BN|gRV?@=rJRCQ4Dv6^&C;!296%5Jg-&(;Qywq;h@#_S zdg80LLi4K+@|vpMtHwv9W$~MNW`P|BrHqowIs4WN5U0wZde>la%N2ohz~>UPKv2d` zMotLyuS3&kNpY5rOxT-zk*mP|Ardrp*Rtr5e6d`g{?)sR?QBxQW8q6-0$hQdX&8S{ zYs-fSxX+o9M||eCxHlh$%&m>x%S{kISvSl_rVncESz$d{%;6}jX!K-ARm5>xtZV=! zf-VQAtypO^F0BNRHw)6a=9`GJ2e#rl1Y~MOAdhe=th0PUkN3+D_!Sj?(-LuhPO3i_ zn~r?ObB}7Td~JejmT2N&HfC&R-ni+e;_d$HT;tr;88t4EsGn88GfVrzrX8TspsbAI#-(BOmB^wB?+sb zYUb9-A`O?^1Iq)wRU4lEhO4~plS!NttmIVVxsCRJDg3LhDv`^UPEYs`h3$%xNu=XB z6&+h+nDwQskCXsMN@hX!=xV%-JfpU0wVaBJHeY%`OaYCkZO)%siUY|^Wr(*s8j~pP zk7^B+a$McT3}YpjXWOlGw|o-cz0k=9pFc}%A0PYVgQZ7|+ z1tljQv;heQ+`8084#((dt^q3^#(@AdQE$3ps|^DI5^*W zgYgSxI(46LyH<-9N7Pvka9yw$2TK9zRXj{h2dty?G_P-xX;?(8`WM?Y18*z5GihKW zI)+BX@ zA=ToXoZx2xg(v+p8+N4V7pawWZ9fsVS1M*11AGE#XNo`=DH3Gz%3`wgCQ)^+rulUY zetl{tEm&DWZ!Si5`e&t5{+o$**uTU`)0c}zn<|psnBaai$B49SHRAMVJGrW2m`G-$TQS>UdsR7R-M&O_G2C4c?H~5KVpV(X8Cw$6c zv5j<`dCP8ohNJN0GyedpBi!r|>SuNxs!szJ$s6tuKy$v-(P=5CM!b&rB+(yF2tmdQ ztQ^~R&eRN#DFhwHDfw3)EBGm`Ns2-?(ofggtigMG6hV#KmLue0t8V+(6ukt$(#=+7 z-|7#8;3Bp(jZ=!RTq-YlcauOzVsCW=;oM!9?)oyN95YQ1&A= z(@P$9#bn%Cg_Wcn-9oq0sxFg7wRFx$h_rJ|uM=w^6(bcyf(Ii+kxWuD6S|TqD;)E$ zZV^$6Nb#oJq}vo^lbXFA1}L^pRdI};TG5TjQOotAvI?kGP#w-X4z%hdB%Ljj{{RzL zd{!6$ z=!0WieyJ@ps{`#6__q!FY+>TSnK@_g4%H41X*I39f@IKHvIc!CP>O?WeGgja_hrL} zgMJH_AWd1EW?u^4BEL6?2>Mo(j_(ICwJ+2~@9O)YdKe3?M@pnl+&BOsmQ;lc${7}EhT}O0aBDny9yxMDlxMLb=XH`_XNe{X>BGN=BeGjPxMw zR_)LW>&GAxSx3_pD|p415qJ<{8OF370}f)Flz7xu)iVlruuVGfZXFCoHA=%c>7KNE zSz_W*#dHBlB5ZBbxb`#-HD@i!fWn%HB}mVITCu}yTE^U?IA>svVoZeprf*u`TR36jU=bIjGE*vUONHJ(W(>OCT& zn(WKqU?N<`KJ2EfaVrJCgHFOl-5d7ytQa_y_LE5QY11QQh8r5_EU)6ei!0@kesn!Q zwQZUcg#E-!T562`8+RjvX*fPCSpc4R>}lQ~682RGOW-;m`2qc^#r!E0@ZPAy>j>m^ z2Wl8N<8d!_&*W>T3sU(<=xx%b8s-@wI%vGLdgDA?VJzalVIOSO6=^;MIO&Z9o2;8Au_RUp^vX-Qc%qRA% zy+vl0&}_HBI@kRCD)H?<`{9uLLb;7hi6I$e&Yw)zBk@58h@b!rMn1cm>+Vk~<31(- z0PCm!0ApIq2J`;_V_e_(%z|mNN}9bx&mNp_QO!Eg#*aX=Q5~ErGqhv$qR`+vpp5rH z`cW(ACEGoeJy7C&A}%GRix}lmfWTxC>sRSf88|zLq?1(4ZX59b0NAdU;#q@~&XrM( z>dzZV<*;}mweiG*avTDL5JAUUxp8w1q()iXA`ZRj;+ZFjWr?*T41-!clX6)kM+oua zk386I$kbQ{QaO6!wD{eh*|@BuP8H6z`u_livzw*{{VUc^)t-A)=xEbIk*hg5C%qof zPvhp@u=wYi4ho(2KD$+mNNy!!_T++tm^4Y~mRY&A;uFdwl>@dhu^7cwG0ANxVj4LI zESj?~`huNK&7S*JCza%o83{c-s$qRRP-Ct6WcQRT#$F=%8a*B z8HA0oP{{TJca9f2(4V}j0=|zAY`~X*;Hg5aX zfvC2X8;$YWrQn??0Oo%hD#Moq^X2(aAaFwMD=|6pjBQtiu2eFO&p&F7v_~7@Wylyk zwyTQ6$_97al><4yii;_=w-X4XD(uR@;qiYH?4;W{~k+hG}l1 zV;+@kN_@+{qJCq)rU%ljTio#2rZ8Pv2?Q!##AM-j$=kOeeT4=vL=O*$sOhC*1Q13} z`NurfP9bFWm+d9vOBalpbWm|3=L2!;jM7R???t66`ZdoUmiS2nJYhVnqzrg?Am_2G zd=~0o^*Q2fcv2m=+_yaQ?Xj#2Szf`u$uuXxC&dE*>m3Gp{VGx0I%&F&S&YQ*s7dnCgQ?XPn->CM@Acu(wZx@n#wz)vbqK%-jj5!NUd#oG}AD0G=ZL< zO1iQPR!-{878M2ZYuj6s?Eb77BDobuKap&nhW^Kyg<%cN4nd(|ES;u|8x!D^(nAy%8H8)O0h_IA0fo ziCQF)7Y8_!J&u#>wP;-Us))-j&URl68uL2~`M71;&k4voGM-F3SAA!EFJiZz-tn0n z?yP{7?kkrrI@&jc?@T+%EF>QjR-uq2Tx0O}ptIfND#Z+}*eam@2YS95QzL^aw}wXA zG0)be8@kH5NXY~F@P9hM)Zw_UM-!P_0ITW19fm51BcSV6FJ$p==Y2qyk&ixm)na>B zLg>Ma$DS#`C+SRmDQ5`Bw)61vl>j{M`Sn zM7qk!n9E~10Fj@_)~)@gj;OYa6~3J1*_n$2+?~GxLE|%9U0g;uR1G!Rlkb{Ohy8<#Eu>fXNL~euX_-dn%%EeZ>r~|q=&yy+M>luFWwE|XW|9e= zhIK_IR1W0h0*7v^Qh9)R zqM($C-%JkPhCJqiBf?4kogWM|`r%`3{>5WAi_SRj0neQ3(}!=N+g`j-6