Map downloads on join, faster mod merging, connect cards in the server browser

Map downloader
- A host or dedicated server now tells joiners which Thunderstore package
  its map comes from (the Mods folder name plus manifest.json).
- A player without the map gets a card: icon, name, author, version and
  description, with DOWNLOAD and CANCEL. It works with mouse, keyboard and
  controller.
- Only packages listed as Maps in the ReSkate community are offered, and
  never deprecated, rejected or NSFW ones.
- On yes the package is downloaded, installed into Mods and applied as a
  live mod, then the session loads the map. A small card on the right
  shows the progress, so the player can keep skating.
- The player stays connected while it downloads: the host gives a player
  who says they are fetching the map up to 45 minutes instead of the
  usual load time.
- When the server changes map with the player in it, the card says so.
- Protocol version 47: map package on map_offer and world_state, and a
  "fetching the map" flag on map_request and world_ready.

Joining
- The pause-menu server browser shows a CONNECTING card over the page
  while a join is under way, and a COULDN'T CONNECT card with the reason
  when it does not get in.
- The session and voice tabs no longer show before the player is in.
- Leaving without the map (declined or cancelled download) no longer
  counts towards the failed-join limit, on servers and player hosts.

Input
- A card that asks something holds the game's input until the buttons
  are released.
- Steam's overlay hooks XInput in front of ReSkate and answers the game
  itself, so a controller reached the game behind menus. ReSkate now
  hooks that hook as well.

Mod merging
- Mods are read on several threads, at launch and for live merges. A live
  merge runs below the game's priority.
- The paid-cosmetics check reads the game's files and the mods on several
  threads.
- A live merge reuses the paid-cosmetics verdicts it already has and
  reports its progress and timings.

Launcher
- NSFW mods and their categories are not shown in the mod browser.
This commit is contained in:
AyeZeeBB committed 2026-10-10 13:15:04 -05:00
1 parent b7936a36c6
commit 0ae0e71f0f
46 files changed
+2448 -275

No files matched your search

+3
View File
@@ -253,6 +253,9 @@ struct MultiplayerModel {
bool party_leader{}, party_open{}, parties{}; bool party_leader{}, party_open{}, parties{};
std::vector<MultiplayerPartyInvite> party_invites; // newest last std::vector<MultiplayerPartyInvite> party_invites; // newest last
bool active{}, hosting{}, connected{}, echo{}, remote_visible{}, local_ready{}; bool active{}, hosting{}, connected{}, echo{}, remote_visible{}, local_ready{};
// The session is connected but waiting on its map, which this PC does not have and is
// being asked about or downloaded: not yet a session the player is in.
bool map_fetching{};
std::uint64_t local_id{}, peer_id{}, sent{}, received{}, dropped{}, pose_updates{}, board_pose_updates{}; std::uint64_t local_id{}, peer_id{}, sent{}, received{}, dropped{}, pose_updates{}, board_pose_updates{};
std::size_t skater_bones{}, board_bones{}; std::size_t skater_bones{}, board_bones{};
std::string invite, map; std::string invite, map;
@@ -0,0 +1,90 @@
// One Thunderstore package looked up by name (Engine/Vfs/thunderstore_package.h): the name a
// host gives, and what Thunderstore's answer has to say before the game downloads it.
#include "Engine/Vfs/thunderstore_package.h"
#include <iostream>
#include <string>
using namespace dingosdk;
namespace package = thunderstore_package;
namespace {
int failures = 0;
void check(bool condition, const char* what) {
if (condition) return;
std::cerr << "FAIL: " << what << '\n';
++failures;
}
// Thunderstore's answer for a package, as /api/experimental/package/<owner>/<name>/ gives it.
std::string answer(std::string_view owner, std::string_view name, std::string_view version, std::string_view community,
std::string_view category, std::string_view review, bool deprecated = false) {
return std::string("{\"namespace\":\"") + std::string(owner) + "\",\"name\":\"" + std::string(name) +
"\",\"full_name\":\"x\",\"is_deprecated\":" + (deprecated ? "true" : "false") +
",\"latest\":{\"version_number\":\"" + std::string(version) +
"\",\"icon\":\"https://gcdn.thunderstore.io/live/repository/icons/x.png\",\"description\":\"A plaza\\nby the sea\",\"download_url\":\"https://elsewhere.example/steal.zip\",\"is_active\":true},\"community_listings\":[{\"has_nsfw_content\":false,"
"\"categories\":[\"" + std::string(category) + "\"],\"community\":\"" + std::string(community) +
"\",\"review_status\":\"" + std::string(review) + "\"}]}";
}
}
int main() {
{
const auto plain = package::parse_name("Sandos-Vancouver_Plaza");
check(plain && plain->owner == "Sandos" && plain->name == "Vancouver_Plaza" && plain->version.empty() &&
plain->folder() == "Sandos-Vancouver_Plaza", "a package name read");
const auto versioned = package::parse_name("Sandos-Vancouver_Plaza-2.0.0");
check(versioned && versioned->version == "2.0.0" && versioned->folder() == "Sandos-Vancouver_Plaza", "and with its version");
for (const char* bad : {"", "Sandos", "Sandos-", "-Plaza", "San dos-Plaza", "Sandos-Plaza-2.0", "Sandos-Plaza-2.0.0.1", "Sandos-Plaza-two",
"../x-y", "Sandos-Plaza/..", "Sandos-Pla.za", "a-b-1.2.3-4"})
check(!package::parse_name(bad), bad);
const auto name = *versioned;
check(package::details_url(name) == L"https://thunderstore.io/api/experimental/package/Sandos/Vancouver_Plaza/", "where it is asked about");
check(package::download_url(name, "2.0.0") == L"https://thunderstore.io/package/download/Sandos/Vancouver_Plaza/2.0.0/",
"where it is downloaded from");
}
{
const package::Name newest{"Sandos", "Vancouver_Plaza", ""}, pinned{"Sandos", "Vancouver_Plaza", "1.4.0"};
const auto listed = answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Maps", "unreviewed");
auto choice = package::choose(listed, newest, "reskate");
check(choice.ok && choice.version == "2.0.0" && choice.newest == "2.0.0", "a listed map is taken, its newest version");
check(choice.description == "A plaza by the sea", "with what it says of itself, on one line");
check(choice.icon == "https://gcdn.thunderstore.io/live/repository/icons/x.png", "and its icon, from Thunderstore's own host");
{
auto elsewhere = listed;
const std::string host = "gcdn.thunderstore.io";
elsewhere.replace(elsewhere.find(host), host.size(), "thunderstore.io.example.com");
const auto other = package::choose(elsewhere, newest, "reskate");
check(other.ok && other.icon.empty(), "an icon anywhere else is not fetched");
}
choice = package::choose(listed, pinned, "reskate");
check(choice.ok && choice.version == "1.4.0" && choice.newest == "2.0.0", "or the version the host has, with the newest to fall back on");
check(package::choose(answer("sandos", "vancouver_plaza", "2.0.0", "ReSkate", "maps", "approved"), newest, "reskate").ok,
"names compare whatever their case");
const auto refused = [&](const std::string& text, const char* what) {
const auto result = package::choose(text, newest, "reskate");
check(!result.ok && !result.reason.empty() && result.version.empty(), what);
};
refused(answer("Someone", "Else", "2.0.0", "reskate", "Maps", "unreviewed"), "another package's answer is refused");
refused(answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Cosmetics", "unreviewed"), "a package that is not a map is refused");
refused(answer("Sandos", "Vancouver_Plaza", "2.0.0", "othergame", "Maps", "unreviewed"), "a package of another community is refused");
refused(answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Maps", "rejected"), "a package the moderators rejected is refused");
refused(answer("Sandos", "Vancouver_Plaza", "2.0.0", "reskate", "Maps", "unreviewed", true), "a deprecated package is refused");
refused(answer("Sandos", "Vancouver_Plaza", "latest", "reskate", "Maps", "unreviewed"), "a version that is not one is refused");
{
auto marked = listed;
const std::string off = "\"has_nsfw_content\":false";
marked.replace(marked.find(off), off.size(), "\"has_nsfw_content\":true");
refused(marked, "a package marked NSFW is refused");
}
refused("{\"namespace\":\"Sandos\",\"name\":\"Vancouver_Plaza\"}", "an answer with no listing is refused");
refused("not json", "an answer that is not JSON is refused");
refused("[]", "an answer that is not an object is refused");
}
if (failures) {
std::cerr << failures << " failure(s)\n";
return 1;
}
std::cout << "thunderstore package tests passed\n";
return 0;
}
+4 -1
View File
@@ -20,7 +20,7 @@ struct Internet {
} }
Download get(std::wstring_view url, const std::filesystem::path& destination, Download get(std::wstring_view url, const std::filesystem::path& destination,
std::uint64_t max_bytes, std::uint32_t timeout_seconds, const wchar_t* agent) { std::uint64_t max_bytes, std::uint32_t timeout_seconds, const wchar_t* agent, Watch* watch) {
Download result; Download result;
const auto fail = [&](DWORD error) { result.error = error; return result; }; const auto fail = [&](DWORD error) { result.error = error; return result; };
if (url.empty() || url.size() >= 4096 || url.find_first_of(L"\r\n\t #") != std::wstring_view::npos) if (url.empty() || url.size() >= 4096 || url.find_first_of(L"\r\n\t #") != std::wstring_view::npos)
@@ -57,18 +57,21 @@ Download get(std::wstring_view url, const std::filesystem::path& destination,
DWORD length{}; size = sizeof(length); DWORD length{}; size = sizeof(length);
if (WinHttpQueryHeaders(request.value, WINHTTP_QUERY_CONTENT_LENGTH | WINHTTP_QUERY_FLAG_NUMBER, if (WinHttpQueryHeaders(request.value, WINHTTP_QUERY_CONTENT_LENGTH | WINHTTP_QUERY_FLAG_NUMBER,
nullptr, &length, &size, nullptr) && length > max_bytes) return fail(ERROR_FILE_TOO_LARGE); nullptr, &length, &size, nullptr) && length > max_bytes) return fail(ERROR_FILE_TOO_LARGE);
if (watch) watch->total.store(length), watch->received.store(0);
Handle output{CreateFileW(destination.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr)}; Handle output{CreateFileW(destination.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr)};
if (output.value == INVALID_HANDLE_VALUE) return fail(GetLastError()); if (output.value == INVALID_HANDLE_VALUE) return fail(GetLastError());
std::array<char, 65536> buffer{}; std::array<char, 65536> buffer{};
std::uint64_t total{}; std::uint64_t total{};
for (;;) { for (;;) {
if (GetTickCount64() - started >= std::uint64_t{timeout_seconds} * 1000) return fail(ERROR_TIMEOUT); if (GetTickCount64() - started >= std::uint64_t{timeout_seconds} * 1000) return fail(ERROR_TIMEOUT);
if (watch && watch->cancel.load()) return fail(ERROR_CANCELLED);
DWORD read{}, written{}; DWORD read{}, written{};
if (!WinHttpReadData(request.value, buffer.data(), static_cast<DWORD>(buffer.size()), &read)) return fail(GetLastError()); if (!WinHttpReadData(request.value, buffer.data(), static_cast<DWORD>(buffer.size()), &read)) return fail(GetLastError());
if (!read) break; if (!read) break;
if (total + read > max_bytes) return fail(ERROR_FILE_TOO_LARGE); if (total + read > max_bytes) return fail(ERROR_FILE_TOO_LARGE);
if (!WriteFile(output.value, buffer.data(), read, &written, nullptr) || written != read) return fail(ERROR_WRITE_FAULT); if (!WriteFile(output.value, buffer.data(), read, &written, nullptr) || written != read) return fail(ERROR_WRITE_FAULT);
total += read; total += read;
if (watch) watch->received.store(total);
} }
if (!total) return fail(ERROR_BAD_LENGTH); if (!total) return fail(ERROR_BAD_LENGTH);
if (!FlushFileBuffers(output.value)) return fail(GetLastError()); if (!FlushFileBuffers(output.value)) return fail(GetLastError());
+8 -1
View File
@@ -1,4 +1,5 @@
#pragma once #pragma once
#include <atomic>
#include <cstdint> #include <cstdint>
#include <filesystem> #include <filesystem>
#include <optional> #include <optional>
@@ -10,11 +11,17 @@ struct Download {
bool ok{}; bool ok{};
unsigned long http_status{}, error{}; unsigned long http_status{}, error{};
}; };
// A long download as another thread sees it: how far it is (`total` 0 when the server does
// not say), and a way to stop it (the download then fails with ERROR_CANCELLED).
struct Watch {
std::atomic<std::uint64_t> received{}, total{};
std::atomic<bool> cancel{};
};
// Streams an HTTPS GET into `destination`, which must not exist yet. Follows // Streams an HTTPS GET into `destination`, which must not exist yet. Follows
// redirects that stay on HTTPS; refuses bodies over `max_bytes` or transfers // redirects that stay on HTTPS; refuses bodies over `max_bytes` or transfers
// that take longer than `timeout_seconds` in total. // that take longer than `timeout_seconds` in total.
Download get(std::wstring_view url, const std::filesystem::path& destination, Download get(std::wstring_view url, const std::filesystem::path& destination,
std::uint64_t max_bytes, std::uint32_t timeout_seconds, const wchar_t* agent); std::uint64_t max_bytes, std::uint32_t timeout_seconds, const wchar_t* agent, Watch* watch = nullptr);
// The body of the same GET, for an answer small enough to hold: nothing when it // The body of the same GET, for an answer small enough to hold: nothing when it
// fails, and `result`, when given, says how. // fails, and `result`, when given, says how.
std::optional<std::string> get_text(std::wstring_view url, std::uint64_t max_bytes, std::optional<std::string> get_text(std::wstring_view url, std::uint64_t max_bytes,
+27 -8
View File
@@ -6,6 +6,8 @@
#include "mod_store_copies.h" #include "mod_store_copies.h"
#include "native_db.h" #include "native_db.h"
#include <Windows.h>
#include <algorithm> #include <algorithm>
#include <array> #include <array>
#include <atomic> #include <atomic>
@@ -45,12 +47,22 @@ constexpr std::array<std::string_view, 2> launch_superbundles{"Win32/globals.toc
// more than that the mod could not be merged: what was found is not for the // more than that the mod could not be merged: what was found is not for the
// mod's author to read. The catalogue is only read once a mod turns out to add // mod's author to read. The catalogue is only read once a mod turns out to add
// an item at all. // an item at all.
bool store_copy_problems(const Catalog& catalog, MergeReport& report) { bool store_copy_problems(const Catalog& all, MergeReport& report, const std::vector<std::string>& checked,
std::size_t threads, bool background) {
// (Only when some are skipped is the catalogue copied, without them.)
std::optional<Catalog> fewer;
if (!checked.empty()) {
fewer = all;
std::erase_if(fewer->mods, [&](const Mod& mod) {
return std::ranges::any_of(checked, [&](const std::string& name) { return lower(name) == lower(mod.name); });
});
}
const Catalog& catalog = fewer ? *fewer : all;
std::optional<content_cache::Catalogs> store; std::optional<content_cache::Catalogs> store;
const auto found = check_store_copies(catalog, [&store](const std::string& key) { const auto found = check_store_copies(catalog, [&store](const std::string& key) {
if (!store) store = content_cache::read_catalogs(content_cache::directory()); if (!store) store = content_cache::read_catalogs(content_cache::directory());
return store->reserved(key); return store->reserved(key);
}, &report.notes); }, &report.notes, threads, background);
for (const auto& source : found.mods) report.problems[source.mod].emplace_back(store_copies_problem); for (const auto& source : found.mods) report.problems[source.mod].emplace_back(store_copies_problem);
return !found.mods.empty(); return !found.mods.empty();
} }
@@ -95,7 +107,12 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
// anything is built: the caller merges again without it, as it does for a // anything is built: the caller merges again without it, as it does for a
// mod that cannot be merged, and the patch on disk stays for that merge to // mod that cannot be merged, and the patch on disk stays for that merge to
// reuse or replace. // reuse or replace.
if (storeKnown && store_copy_problems(catalog, report)) return report; // Threads for the three steps that read many files: as many as there are cores at
// launch (the player is waiting on nothing else), half of them and fewer while the game runs.
const auto cores = std::max(1U, std::thread::hardware_concurrency());
const std::size_t readers = std::min<std::size_t>(options.live ? std::max(1U, cores / 2) : cores, options.live ? 6U : 8U);
if (storeKnown && store_copy_problems(catalog, report, options.live ? options.checked : std::vector<std::string>{},
readers - 1, options.live)) return report;
if (!options.live) fs::remove_all(output, error); if (!options.live) fs::remove_all(output, error);
lap("checking the mods"); lap("checking the mods");
@@ -347,7 +364,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
lap("load screens"); lap("load screens");
} }
advance("Collecting asset overrides"); advance("Collecting asset overrides");
auto overrides = collect_asset_overrides(mods, modFiles, store, baseRoot, gameRoot, report); auto overrides = collect_asset_overrides(mods, modFiles, store, baseRoot, gameRoot, report, readers - 1, options.live);
// Carried chunks point into their mod's archives; move them to where those landed. // Carried chunks point into their mod's archives; move them to where those landed.
for (auto& [name, chunks] : overrides.chunks) for (auto& [name, chunks] : overrides.chunks)
for (const auto* mod : mods) for (const auto* mod : mods)
@@ -362,8 +379,8 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
// what was worked out above, and what it writes waits in its own copy of the store // what was worked out above, and what it writes waits in its own copy of the store
// (CasStore::waiting). So they are merged on several threads, and settled here one // (CasStore::waiting). So they are merged on several threads, and settled here one
// after another in the order they always were, which puts every byte where merging // after another in the order they always were, which puts every byte where merging
// them in turn would have. A merge while the game runs does them in turn, on this // them in turn would have. A merge while the game runs uses fewer threads, at a lower
// thread: the game is using the others. // priority: the game is using the others, and a player is waiting on this one.
struct Job { struct Job {
std::string relative; std::string relative;
std::optional<CasStore> store; // waiting: what the merge wrote std::optional<CasStore> store; // waiting: what the merge wrote
@@ -426,13 +443,15 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
std::atomic<bool>& flag; std::atomic<bool>& flag;
~Stop() { flag = true; } ~Stop() { flag = true; }
} stopOnExit{stopJobs}; } stopOnExit{stopJobs};
const std::size_t wantedWorkers = options.live || jobs.size() < 2 ? 0 const std::size_t wantedWorkers = jobs.size() < 2 ? 0
: std::min<std::size_t>({jobs.size(), std::max(1U, std::thread::hardware_concurrency()), 8}); : std::min<std::size_t>({jobs.size(), options.live ? std::max(1U, cores / 2) : cores, options.live ? 6U : 8U});
try { try {
// Threads of their own, not the system's pool: the launcher holds the pool's // Threads of their own, not the system's pool: the launcher holds the pool's
// threads back while the game starts (see world_layer_scan.cpp). // threads back while the game starts (see world_layer_scan.cpp).
for (std::size_t index = 0; index < wantedWorkers; ++index) for (std::size_t index = 0; index < wantedWorkers; ++index)
workers.emplace_back([&] { workers.emplace_back([&] {
// (Under the game's own threads, so a merge shows as a wait and not as stutter.)
if (options.live) SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL);
for (;;) { for (;;) {
const auto mine = nextJob.fetch_add(1); const auto mine = nextJob.fetch_add(1);
if (mine >= jobs.size() || stopJobs) return; if (mine >= jobs.size() || stopJobs) return;
+5
View File
@@ -62,6 +62,11 @@ struct MergeOptions {
// only read at startup and stays as it is; the stamp goes, so the next // only read at startup and stays as it is; the stamp goes, so the next
// launch builds a clean patch. // launch builds a clean patch.
bool live{}; bool live{};
// Mods (folder names) already loaded in the running game, by the launch's merge or an
// earlier live one: they passed the store-copies check then, so a live merge holds only
// the others against it. Reading every mod's items and meshes again is most of what
// adding one map would otherwise wait on.
std::vector<std::string> checked;
}; };
// Combines every enabled mod into one patch under Mods/.reskate: superbundle // Combines every enabled mod into one patch under Mods/.reskate: superbundle
+6 -2
View File
@@ -265,11 +265,15 @@ struct AssetOverrides {
// The changes and additions asset mods (mods that add no levels) make to the // The changes and additions asset mods (mods that add no levels) make to the
// game's own EBX and Lua scripts, the highest-priority mod's version winning. Never throws: an // game's own EBX and Lua scripts, the highest-priority mod's version winning. Never throws: an
// unreadable mod is noted and simply changes nothing elsewhere. // unreadable mod is noted and simply changes nothing elsewhere. The mods' files are read on up
// to `threads` threads beside this one (`background`: at a priority under the game's, for a
// merge while it runs); what they mean together is then settled in priority order, so the
// result is the same whatever the count.
AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods, AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
const std::map<const Mod*, RelativeFiles>& modFiles, const std::map<const Mod*, RelativeFiles>& modFiles,
const CasStore& store, const fs::path& baseRoot, const CasStore& store, const fs::path& baseRoot,
const fs::path& gameRoot, MergeReport& report); const fs::path& gameRoot, MergeReport& report,
std::size_t threads = 0, bool background = false);
// A bundle's chunk metadata is a list with one record for each of its chunks: the // 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 // hash of the name of the resource the chunk belongs to ("h64") and what the
+265 -143
View File
@@ -4,156 +4,289 @@
#include "Engine/Resource/ebx_merge.h" #include "Engine/Resource/ebx_merge.h"
#include "Engine/Resource/ebx_writer.h" #include "Engine/Resource/ebx_writer.h"
#include <Windows.h>
#include <algorithm> #include <algorithm>
#include <array> #include <array>
#include <atomic>
#include <cstring> #include <cstring>
#include <exception>
#include <mutex>
#include <set> #include <set>
#include <stdexcept> #include <stdexcept>
#include <thread>
namespace dingosdk::mods::detail { namespace dingosdk::mods::detail {
namespace {
// The game's side of what every mod is held against, read once for all of them: forty
// cosmetic mods each patch the same few bundles, the largest with some 17,000 assets, and
// each used to list that bundle again for itself.
struct GameBundle {
std::optional<Listing> listing;
// The game's copy of each asset: its sha1, and where in the listing it is.
std::map<std::string, std::pair<fb::Sha1, std::size_t>, std::less<>> original;
std::map<std::string, const fb::BundleAsset*, std::less<>> scripts;
std::set<std::string, std::less<>> resources;
};
struct GameToc {
fb::TocDocument document;
std::map<std::string, const fb::TocBundle*, std::less<>> bundles;
std::set<fb::Guid> chunks;
};
// Made on first use by whichever thread asks first; the others wait for it. One that could
// not be read throws for each asker, as reading it for each did.
template <class Value> class Shared {
public:
template <class Make> const Value& get(const std::string& key, Make&& make) {
Entry* entry{};
{
std::lock_guard lock(mutex_);
entry = &entries_[key];
}
std::call_once(entry->once, [&] { make(entry->value); });
return entry->value;
}
private:
struct Entry {
std::once_flag once;
Value value;
};
std::mutex mutex_;
std::map<std::string, Entry, std::less<>> entries_; // nodes stay where they are
};
// The game's assets a mod changed. They wait until its additions are settled: one that
// another mod's asset of the same name stands in for is not where a copy can find it, and a
// change must not name it there.
struct Change {
std::string bundle; // lower case: where the mod changed it
std::string name; // lower case
std::string asset; // as the bundle spells it
fb::Sha1 game; // the game's copy it replaces
fb::Sha1 sha1;
std::uint64_t originalSize{};
std::vector<std::byte> encoded;
std::optional<fb::BundleFileInfo> gameFile; // where the game's copy is
};
struct Script {
std::string name; // lower case
fb::Sha1 game; // the game's copy it replaces
AssetOverride value;
};
// What one mod's files say, read without regard to any other mod: the part of the work that
// is reading and decoding, done for several mods at once. What it means beside the mods
// above it is settled afterwards, one mod at a time in priority order.
struct ModRead {
// This mod's new assets, and the partitions its recorded changes import.
// An added asset only follows a change that refers to it: copied on its
// own, it could arrive in another mod's bundle without the resources
// and chunks it was added with.
std::vector<std::pair<std::string, AssetAddition>> candidates, companions;
std::vector<bool> unreadable; // per candidate: never follows
std::map<std::string, std::set<fb::Guid>, std::less<>> addedIn; // the documents it added to each bundle, by guid
std::vector<fb::TocChunk> newChunks; // TOC chunks the game's TOCs do not have
std::set<fb::Guid> imported;
std::vector<Change> changes;
std::vector<Script> scripts;
std::vector<std::string> notes;
std::exception_ptr failure;
};
ModRead read_mod(const Mod& mod, const RelativeFiles& files, const CasStore& store, const fs::path& baseRoot,
const fs::path& gameRoot, Shared<GameToc>& gameTocs, Shared<GameBundle>& gameBundles) {
ModRead read;
auto& [candidates, companions, unreadable, addedIn, newChunks, imported, changes, scripts, notes, failure] = read;
const auto decoded = [&](std::span<const std::byte> encoded) {
return fb::ebx::read_document(fb::decode_cas(encoded, {gameRoot}));
};
for (const auto& relative : files.tocs) {
std::error_code error;
const auto baseToc = baseRoot / fs::path(relative);
if (!fs::is_regular_file(baseToc, error)) continue;
try {
const auto own = fb::read_toc(read_file(mod.directory / fs::path(relative)));
const auto tocKey = lower(relative);
const auto& game = gameTocs.get(tocKey, [&](GameToc& toc) {
toc.document = fb::read_toc(read_file(baseToc));
for (const auto& bundle : toc.document.bundles) toc.bundles.emplace(lower(bundle.name), &bundle);
for (const auto& chunk : toc.document.chunks) toc.chunks.insert(chunk.guid);
});
for (const auto& chunk : own.chunks)
if (!chunk.removed && chunk.location.patch && !game.chunks.contains(chunk.guid)) newChunks.push_back(chunk);
for (const auto& bundle : own.bundles) {
const auto bundleName = lower(bundle.name);
const auto shipped = game.bundles.find(bundleName);
if (shipped == game.bundles.end()) continue;
const auto modListing = list_bundle(store, mod.directory, baseRoot, bundle, gameRoot);
const auto& known = gameBundles.get(tocKey + '|' + bundleName, [&](GameBundle& made) {
made.listing = list_bundle(store, baseRoot, baseRoot, *shipped->second, gameRoot);
if (!made.listing) return;
const auto& manifest = made.listing->manifest;
for (std::size_t index = 0; index < manifest.ebx.size(); ++index)
made.original.emplace(lower(manifest.ebx[index].name), std::pair{manifest.ebx[index].sha1, index});
// Maps carry stock SkaterLoader too. Replacing it only in the game's
// bundles loses custom cosmetic material targets on a map transition.
// Other resource types retain their own table/dependency merge rules.
for (const auto& asset : manifest.resources) {
if (asset.resourceType == luaScriptResourceType) made.scripts.emplace(lower(asset.name), &asset);
made.resources.insert(lower(asset.name));
}
});
if (!modListing || !known.listing) continue;
const auto& gameListing = known.listing;
const auto& original = known.original;
for (std::size_t index = 0; index < modListing->manifest.ebx.size(); ++index) {
const auto& asset = modListing->manifest.ebx[index];
const auto name = lower(asset.name);
const auto game_copy = original.find(name);
if (game_copy != original.end() && game_copy->second.first == asset.sha1) continue;
const auto at = modListing->first + index;
if (at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
const auto payload = [&] {
return store.read(file.location.patch ? mod.directory : baseRoot,
file.location, file.offset, file.size);
};
if (game_copy == original.end()) {
candidates.push_back({bundleName, {mod.name, asset, payload(), tocKey}});
continue;
}
auto encodedPayload = payload();
try {
for (const auto& reference : decoded(encodedPayload).imports)
imported.insert(reference.fileGuid);
} catch (const std::exception&) {}
std::optional<fb::BundleFileInfo> gameFile;
if (const auto gameAt = gameListing->first + game_copy->second.second;
gameAt < gameListing->files.size())
gameFile = gameListing->files[gameAt];
changes.push_back({bundleName, name, asset.name, game_copy->second.first, asset.sha1,
asset.originalSize, std::move(encodedPayload), gameFile});
}
for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) {
const auto& asset = modListing->manifest.resources[index];
if (asset.resourceType != luaScriptResourceType) continue;
const auto name = lower(asset.name);
const auto originalScript = known.scripts.find(name);
if (originalScript == known.scripts.end() || originalScript->second->sha1 == asset.sha1 ||
originalScript->second->resourceId != asset.resourceId) continue;
// (A higher mod's version of the script, or this mod's own from another
// bundle, wins when the mods are settled: read here either way.)
if (std::ranges::any_of(scripts, [&](const Script& have) {
return have.name == name && have.game == originalScript->second->sha1; })) continue;
const auto at = modListing->first + modListing->manifest.ebx.size() + index;
if (at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
scripts.push_back({name, originalScript->second->sha1,
AssetOverride{mod.name, asset.sha1, asset.originalSize,
store.read(file.location.patch ? mod.directory : baseRoot,
file.location, file.offset, file.size), asset}});
}
// Resources the mod adds, kept to go with an added EBX of the same name
// (a wave's sound-bank resource registers the wave with the audio system).
for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) {
const auto& asset = modListing->manifest.resources[index];
const auto at = modListing->first + modListing->manifest.ebx.size() + index;
if (known.resources.contains(lower(asset.name)) || at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
companions.push_back({bundleName, {mod.name, asset,
store.read(file.location.patch ? mod.directory : baseRoot, file.location, file.offset, file.size), tocKey}});
}
}
} catch (const std::exception& failed) {
notes.push_back(mod.name + ": " + relative +
": its changes could not be read for other mods' copies (" + failed.what() + ")");
}
}
// Each added document's guid, and the guids of the documents it refers to in turn.
unreadable.assign(candidates.size(), false);
for (std::size_t index = 0; index < candidates.size(); ++index) {
auto& addition = candidates[index].second;
try {
const auto document = decoded(addition.encoded);
addition.file = document.fileGuid;
for (const auto& reference : document.imports) addition.names.push_back(reference.fileGuid);
addedIn[candidates[index].first].insert(addition.file);
} catch (const std::exception&) { unreadable[index] = true; }
}
return read;
}
} // namespace
AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods, AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
const std::map<const Mod*, RelativeFiles>& modFiles, const std::map<const Mod*, RelativeFiles>& modFiles,
const CasStore& store, const fs::path& baseRoot, const CasStore& store, const fs::path& baseRoot,
const fs::path& gameRoot, MergeReport& report) { const fs::path& gameRoot, MergeReport& report,
std::size_t threads, bool background) {
AssetOverrides out; AssetOverrides out;
// Where in `out.added` each bundle's addition of a kind and (lower-case) name is: looked up // Where in `out.added` each bundle's addition of a kind and (lower-case) name is: looked up
// for every addition and companion, which a search of the list made quadratic. // for every addition and companion, which a search of the list made quadratic.
std::map<std::string, std::map<std::pair<fb::AssetKind, std::string>, std::size_t>, std::less<>> addedAt; std::map<std::string, std::map<std::pair<fb::AssetKind, std::string>, std::size_t>, std::less<>> addedAt;
// Highest priority first: an asset a higher mod already changed keeps that change.
// A map's edits to the game's assets serve its own levels; asset mods
// (cameras, tuning, cosmetics) are the ones meant to apply everywhere.
std::vector<std::pair<const Mod*, const RelativeFiles*>> asked;
for (const auto* mod : mods) { for (const auto* mod : mods) {
// A map's edits to the game's assets serve its own levels; asset mods
// (cameras, tuning, cosmetics) are the ones meant to apply everywhere.
if (mod->provides_levels) continue; if (mod->provides_levels) continue;
const auto files = modFiles.find(mod); if (const auto files = modFiles.find(mod); files != modFiles.end()) asked.emplace_back(mod, &files->second);
if (files == modFiles.end()) continue; }
std::size_t changed{}; // Read: every mod's files, several mods at a time.
// This mod's new assets, and the partitions its recorded changes import. Shared<GameToc> gameTocs;
// An added asset only follows a change that refers to it: copied on its Shared<GameBundle> gameBundles;
// own, it could arrive in another mod's bundle without the resources std::vector<ModRead> reads(asked.size());
// and chunks it was added with. {
std::vector<std::pair<std::string, AssetAddition>> candidates, companions; std::atomic<std::size_t> next{0};
std::vector<fb::TocChunk> newChunks; // TOC chunks the game's TOCs do not have const auto work = [&] {
std::set<fb::Guid> imported; for (;;) {
// The game's assets this mod changed. They wait until its additions are const auto mine = next.fetch_add(1);
// settled: one that another mod's asset of the same name stands in for if (mine >= asked.size()) return;
// is not where a copy can find it, and a change must not name it there. try {
struct Change { reads[mine] = read_mod(*asked[mine].first, *asked[mine].second, store, baseRoot, gameRoot, gameTocs, gameBundles);
std::string bundle; // lower case: where the mod changed it } catch (...) {
std::string name; // lower case reads[mine].failure = std::current_exception();
std::string asset; // as the bundle spells it
fb::Sha1 game; // the game's copy it replaces
fb::Sha1 sha1;
std::uint64_t originalSize{};
std::vector<std::byte> encoded;
std::optional<fb::BundleFileInfo> gameFile; // where the game's copy is
};
std::vector<Change> changes;
const auto decoded = [&](std::span<const std::byte> encoded) {
return fb::ebx::read_document(fb::decode_cas(encoded, {gameRoot}));
};
for (const auto& relative : files->second.tocs) {
std::error_code error;
const auto baseToc = baseRoot / fs::path(relative);
if (!fs::is_regular_file(baseToc, error)) continue;
try {
const auto own = fb::read_toc(read_file(mod->directory / fs::path(relative)));
const auto game = fb::read_toc(read_file(baseToc));
std::map<std::string, const fb::TocBundle*, std::less<>> gameBundles;
for (const auto& bundle : game.bundles) gameBundles.emplace(lower(bundle.name), &bundle);
std::set<fb::Guid> gameChunks;
for (const auto& chunk : game.chunks) gameChunks.insert(chunk.guid);
for (const auto& chunk : own.chunks)
if (!chunk.removed && chunk.location.patch && !gameChunks.contains(chunk.guid)) newChunks.push_back(chunk);
for (const auto& bundle : own.bundles) {
const auto shipped = gameBundles.find(lower(bundle.name));
if (shipped == gameBundles.end()) continue;
const auto modListing = list_bundle(store, mod->directory, baseRoot, bundle, gameRoot);
const auto gameListing = list_bundle(store, baseRoot, baseRoot, *shipped->second, gameRoot);
if (!modListing || !gameListing) continue;
// The game's copy of each asset: its sha1, and where in the listing it is.
std::map<std::string, std::pair<fb::Sha1, std::size_t>, std::less<>> original;
for (std::size_t index = 0; index < gameListing->manifest.ebx.size(); ++index) {
const auto& asset = gameListing->manifest.ebx[index];
original.emplace(lower(asset.name), std::pair{asset.sha1, index});
}
for (std::size_t index = 0; index < modListing->manifest.ebx.size(); ++index) {
const auto& asset = modListing->manifest.ebx[index];
const auto name = lower(asset.name);
const auto game_copy = original.find(name);
if (game_copy != original.end() && game_copy->second.first == asset.sha1) continue;
const auto at = modListing->first + index;
if (at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
const auto payload = [&] {
return store.read(file.location.patch ? mod->directory : baseRoot,
file.location, file.offset, file.size);
};
if (game_copy == original.end()) {
candidates.push_back({lower(bundle.name), {mod->name, asset, payload(), lower(relative)}});
continue;
}
auto encodedPayload = payload();
try {
for (const auto& reference : decoded(encodedPayload).imports)
imported.insert(reference.fileGuid);
} catch (const std::exception&) {}
// A higher mod's version of the asset, which this change is
// combined with below, may name what this mod adds.
if (const auto versions = out.changed.find(name); versions != out.changed.end())
if (const auto existing = versions->second.find(game_copy->second.first);
existing != versions->second.end()) try {
for (const auto& reference : decoded(existing->second.encoded).imports)
imported.insert(reference.fileGuid);
} catch (const std::exception&) {}
std::optional<fb::BundleFileInfo> gameFile;
if (const auto gameAt = gameListing->first + game_copy->second.second;
gameAt < gameListing->files.size())
gameFile = gameListing->files[gameAt];
changes.push_back({lower(bundle.name), name, asset.name, game_copy->second.first, asset.sha1,
asset.originalSize, std::move(encodedPayload), gameFile});
}
// Maps carry stock SkaterLoader too. Replacing it only in the game's
// bundles loses custom cosmetic material targets on a map transition.
// Other resource types retain their own table/dependency merge rules.
std::map<std::string, const fb::BundleAsset*, std::less<>> gameScripts;
for (const auto& asset : gameListing->manifest.resources)
if (asset.resourceType == luaScriptResourceType) gameScripts.emplace(lower(asset.name), &asset);
for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) {
const auto& asset = modListing->manifest.resources[index];
if (asset.resourceType != luaScriptResourceType) continue;
const auto name = lower(asset.name);
const auto originalScript = gameScripts.find(name);
if (originalScript == gameScripts.end() || originalScript->second->sha1 == asset.sha1 ||
originalScript->second->resourceId != asset.resourceId) continue;
auto& versions = out.scripts[name];
if (versions.contains(originalScript->second->sha1)) continue;
const auto at = modListing->first + modListing->manifest.ebx.size() + index;
if (at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
versions.emplace(originalScript->second->sha1,
AssetOverride{mod->name, asset.sha1, asset.originalSize,
store.read(file.location.patch ? mod->directory : baseRoot,
file.location, file.offset, file.size), asset});
++changed;
}
// Resources the mod adds, kept to go with an added EBX of the same name
// (a wave's sound-bank resource registers the wave with the audio system).
std::set<std::string, std::less<>> shippedResources;
for (const auto& asset : gameListing->manifest.resources) shippedResources.insert(lower(asset.name));
for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) {
const auto& asset = modListing->manifest.resources[index];
const auto at = modListing->first + modListing->manifest.ebx.size() + index;
if (shippedResources.contains(lower(asset.name)) || at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
companions.push_back({lower(bundle.name), {mod->name, asset,
store.read(file.location.patch ? mod->directory : baseRoot, file.location, file.offset, file.size), lower(relative)}});
}
} }
} catch (const std::exception& failure) {
report.notes.push_back(mod->name + ": " + relative +
": its changes could not be read for other mods' copies (" + failure.what() + ")");
} }
};
std::vector<std::jthread> workers;
try {
// Threads of their own, not the system's pool (see the superbundles in mod_merge.cpp).
for (std::size_t index = 0; index < std::min(threads, asked.size()) && asked.size() > 1; ++index)
workers.emplace_back([&] {
if (background) SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL);
work();
});
} catch (const std::system_error&) {} // fewer threads, or none: this one does the rest
work();
}
const auto decoded = [&](std::span<const std::byte> encoded) {
return fb::ebx::read_document(fb::decode_cas(encoded, {gameRoot}));
};
// Settle: highest priority first, an asset a higher mod already changed keeps that change.
for (std::size_t slot = 0; slot < asked.size(); ++slot) {
const auto* mod = asked[slot].first;
auto& read = reads[slot];
if (read.failure) std::rethrow_exception(read.failure);
auto& [candidates, companions, unreadable, addedIn, newChunks, imported, changes, scripts, notes, failure] = read;
std::size_t changed{};
report.notes.insert(report.notes.end(), std::make_move_iterator(notes.begin()), std::make_move_iterator(notes.end()));
// A higher mod's version of an asset, which this mod's change is combined with
// below, may name what this mod adds.
for (const auto& change : changes)
if (const auto versions = out.changed.find(change.name); versions != out.changed.end())
if (const auto existing = versions->second.find(change.game); existing != versions->second.end()) try {
for (const auto& reference : decoded(existing->second.encoded).imports)
imported.insert(reference.fileGuid);
} catch (const std::exception&) {}
for (auto& script : scripts) {
auto& versions = out.scripts[script.name];
if (versions.contains(script.game)) continue;
versions.emplace(script.game, std::move(script.value));
++changed;
} }
// One addition per kind and name in a bundle, the highest-priority mod's. Two // 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 // mods adding the same asset is no clash; two different assets under one name
@@ -182,18 +315,7 @@ AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
}; };
// Transitively: an addition a following addition imports follows too (a new song // 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). // imports its new wave, and only the song is named by the changed playlist).
std::vector<bool> followed(candidates.size()); std::vector<bool> followed = unreadable; // (an unreadable one never follows)
// The documents this mod added to each bundle, by guid.
std::map<std::string, std::set<fb::Guid>, std::less<>> addedIn;
for (std::size_t index = 0; index < candidates.size(); ++index) {
auto& addition = candidates[index].second;
try {
const auto document = decoded(addition.encoded);
addition.file = document.fileGuid;
for (const auto& reference : document.imports) addition.names.push_back(reference.fileGuid);
addedIn[candidates[index].first].insert(addition.file);
} catch (const std::exception&) { followed[index] = true; } // unreadable: never follows
}
for (bool grew = true; grew;) { for (bool grew = true; grew;) {
grew = false; grew = false;
for (std::size_t index = 0; index < candidates.size(); ++index) { for (std::size_t index = 0; index < candidates.size(); ++index) {
+175 -71
View File
@@ -6,8 +6,13 @@
#include "Engine/Resource/ebx_document.h" #include "Engine/Resource/ebx_document.h"
#include "Engine/Resource/mesh_set.h" #include "Engine/Resource/mesh_set.h"
#include <Windows.h>
#include <algorithm> #include <algorithm>
#include <atomic>
#include <cmath> #include <cmath>
#include <mutex>
#include <thread>
#include <stdexcept> #include <stdexcept>
namespace dingosdk::mods { namespace dingosdk::mods {
@@ -231,44 +236,6 @@ public:
return meshes; return meshes;
} }
// The meshes of the game's whole costumes (read_game found their items), and every other
// mesh its items have, which are nobody's: a costume is partly made of clothes anyone has.
// How many costumes were read.
[[nodiscard]] std::size_t read_costumes(StoreMeshes& meshes) {
const auto* toc = game_toc(items_toc);
if (!toc || costumes_.empty()) return 0;
const auto& document = tocs_.at(lower(items_toc)).document;
std::size_t read{};
for (const auto& [name, bundle] : *toc) {
const Costume* costume{};
if (name.ends_with(costume_bundle_suffix)) {
const auto stem = name.substr(0, name.size() - costume_bundle_suffix.size());
const auto slash = stem.rfind('/');
const auto found = costumes_.find(stem.substr(slash == std::string::npos ? 0 : slash + 1));
if (found != costumes_.end()) costume = &found->second;
}
try {
const auto listing = list_bundle(store_, baseRoot_, baseRoot_, *bundle, gameRoot_);
if (!listing) continue;
const auto& manifest = listing->manifest;
bool any{};
for (std::size_t index = 0; index < manifest.resources.size(); ++index) {
if (manifest.resources[index].resourceType != mesh_set_type) continue;
read_mesh(baseRoot_, *listing, manifest.ebx.size() + index, document, 16, [&](std::vector<std::uint64_t> points) {
// (Each other mesh under its own name, so two of them sharing a point is also seen.)
if (costume) meshes.add(costume->key, costume->sold, points);
else meshes.add("mesh " + manifest.resources[index].name, false, points);
any = true;
});
}
read += any && costume;
} catch (const std::exception& failure) {
note("The game's " + bundle->name + " could not be read for " + std::string(store_copies_check) + " (" + failure.what() + ")");
}
}
return read;
}
// The items a mod adds to the game's bundles, and the game's own it changes. // The items a mod adds to the game's bundles, and the game's own it changes.
// An item only loads from a bundle the game loads, and the lists the game // An item only loads from a bundle the game loads, and the lists the game
// reads its items from are in one of those, so a mod's own bundles (a map's // reads its items from are in one of those, so a mod's own bundles (a map's
@@ -320,43 +287,126 @@ public:
return items; return items;
} }
// Every item the game ships; how many were read. // Every item the game ships, and with `meshes` the meshes its items have: the whole
[[nodiscard]] std::size_t read_game(StoreItems& items, const StoreItem& sold) { // costumes' under their items, every other one as nobody's (a costume is partly made of
// clothes anyone has). How many items were read, and how many costumes' meshes.
//
// Each of the game's item bundles is read by itself, on up to `threads` threads beside
// this one; what was read is then filed in the bundles' own order, so a part two items
// share is the same item's whatever the count.
struct GameCounts {
std::size_t items{}, costumes{};
};
[[nodiscard]] GameCounts read_game(StoreItems& items, const StoreItem& sold, StoreMeshes* meshes, std::size_t threads,
bool background) {
const auto* toc = game_toc(items_toc); const auto* toc = game_toc(items_toc);
if (!toc) return 0; if (!toc) return {};
std::size_t read{}; const auto& document = tocs_.at(lower(items_toc)).document;
for (const auto& [name, bundle] : *toc) { struct Read {
struct Item {
ItemContent content;
bool costume{}; // one of the game's whole costumes, by where its asset is
};
std::vector<Item> items;
struct Mesh {
std::string resource;
std::vector<std::vector<std::uint64_t>> levels;
};
std::vector<Mesh> meshes;
std::string note;
};
std::vector<const fb::TocBundle*> bundles;
for (const auto& [name, bundle] : *toc) bundles.push_back(bundle);
std::vector<Read> reads(bundles.size());
const auto read_bundle = [&](std::size_t at) {
auto& read = reads[at];
try { try {
const auto listing = list_bundle(store_, baseRoot_, baseRoot_, *bundle, gameRoot_); const auto listing = list_bundle(store_, baseRoot_, baseRoot_, *bundles[at], gameRoot_);
if (!listing) continue; if (!listing) return;
for (std::size_t index = 0; index < listing->manifest.ebx.size(); ++index) { const auto& manifest = listing->manifest;
if (!item_name(lower(listing->manifest.ebx[index].name))) continue; for (std::size_t index = 0; index < manifest.ebx.size(); ++index) {
const auto name = lower(manifest.ebx[index].name);
if (!item_name(name)) continue;
try { try {
const auto document = fb::ebx::read_document( const auto asset = fb::ebx::read_document(read_asset(store_, baseRoot_, baseRoot_, *listing, index, gameRoot_));
read_asset(store_, baseRoot_, baseRoot_, *listing, index, gameRoot_)); if (auto item = item_content(asset)) read.items.push_back({std::move(*item), name.starts_with(costume_items)});
const auto item = item_content(document);
if (!item) continue;
const bool store = sold(lower(item->key));
items.add(*item, store);
++read;
if (lower(listing->manifest.ebx[index].name).starts_with(costume_items))
for (const auto& shape : item->shapes)
if (shape.starts_with(costume_shape))
costumes_.insert_or_assign(shape.substr(costume_shape.size()), Costume{item->key, store});
} catch (const std::exception&) {} } catch (const std::exception&) {}
} }
if (!meshes) return;
for (std::size_t index = 0; index < manifest.resources.size(); ++index) {
if (manifest.resources[index].resourceType != mesh_set_type) continue;
Read::Mesh mesh{manifest.resources[index].name, {}};
read_mesh(baseRoot_, *listing, manifest.ebx.size() + index, document, 16,
[&](std::vector<std::uint64_t> points) { mesh.levels.push_back(std::move(points)); });
if (!mesh.levels.empty()) read.meshes.push_back(std::move(mesh));
}
} catch (const std::exception& failure) { } catch (const std::exception& failure) {
note("The game's " + bundle->name + " could not be read for " + std::string(store_copies_check) + " (" + failure.what() + ")"); read.note = "The game's " + bundles[at]->name + " could not be read for " + std::string(store_copies_check) + " (" + failure.what() + ")";
}
};
{
std::atomic<std::size_t> next{0};
const auto work = [&] {
for (;;) {
const auto mine = next.fetch_add(1);
if (mine >= bundles.size()) return;
read_bundle(mine);
}
};
std::vector<std::jthread> workers;
try {
// Threads of their own, not the system's pool (see the superbundles in mod_merge.cpp).
for (std::size_t index = 0; index < threads && bundles.size() > 1; ++index)
workers.emplace_back([&] {
if (background) SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL);
work();
});
} catch (const std::system_error&) {} // fewer threads, or none: this one does the rest
work();
}
GameCounts counts;
for (auto& read : reads) {
if (!read.note.empty()) note(std::move(read.note));
for (const auto& item : read.items) {
const bool store = sold(lower(item.content.key));
items.add(item.content, store);
++counts.items;
if (!item.costume) continue;
for (const auto& shape : item.content.shapes)
if (shape.starts_with(costume_shape))
costumes_.insert_or_assign(shape.substr(costume_shape.size()), Costume{item.content.key, store});
} }
} }
return read; if (!meshes || costumes_.empty()) return counts;
// A costume's own bundle is named after the preset its item is fitted on.
for (std::size_t at = 0; at < bundles.size(); ++at) {
const auto name = lower(bundles[at]->name);
const Costume* costume{};
if (name.ends_with(costume_bundle_suffix)) {
const auto stem = name.substr(0, name.size() - costume_bundle_suffix.size());
const auto slash = stem.rfind('/');
const auto found = costumes_.find(stem.substr(slash == std::string::npos ? 0 : slash + 1));
if (found != costumes_.end()) costume = &found->second;
}
for (const auto& mesh : reads[at].meshes)
for (const auto& level : mesh.levels) {
// (Each other mesh under its own name, so two of them sharing a point is also seen.)
if (costume) meshes->add(costume->key, costume->sold, level);
else meshes->add("mesh " + mesh.resource, false, level);
}
counts.costumes += costume && !reads[at].meshes.empty();
}
return counts;
} }
private: private:
using Bundles = std::map<std::string, const fb::TocBundle*, std::less<>>; using Bundles = std::map<std::string, const fb::TocBundle*, std::less<>>;
using Assets = std::map<std::string, fb::Sha1, std::less<>>; using Assets = std::map<std::string, fb::Sha1, std::less<>>;
// (Both of these are asked by several threads while the mods are read: one at a time,
// and what they hand out stays where it is.)
const Bundles* game_toc(std::string_view relative) { const Bundles* game_toc(std::string_view relative) {
std::lock_guard lock(cache_mutex_);
const auto key = lower(relative); const auto key = lower(relative);
if (const auto found = tocs_.find(key); found != tocs_.end()) if (const auto found = tocs_.find(key); found != tocs_.end())
return found->second.index.empty() ? nullptr : &found->second.index; return found->second.index.empty() ? nullptr : &found->second.index;
@@ -377,6 +427,7 @@ private:
// The game's copy of a bundle: each EBX it holds, to tell a mod's additions // The game's copy of a bundle: each EBX it holds, to tell a mod's additions
// and changes from what it carries untouched. Null when it cannot be read. // and changes from what it carries untouched. Null when it cannot be read.
const Assets* game_assets(std::string_view relative, const fb::TocBundle& bundle) { const Assets* game_assets(std::string_view relative, const fb::TocBundle& bundle) {
std::lock_guard lock(cache_mutex_);
const auto key = lower(relative) + '|' + lower(bundle.name); const auto key = lower(relative) + '|' + lower(bundle.name);
if (const auto found = assets_.find(key); found != assets_.end()) if (const auto found = assets_.find(key); found != assets_.end())
return found->second ? &*found->second : nullptr; return found->second ? &*found->second : nullptr;
@@ -421,8 +472,11 @@ private:
} }
} }
// What a mod's reading has to say is kept with that mod while the mods are read at once
// (notes_for), so the notes come out in the mods' order however the threads ran.
void note(std::string text) { void note(std::string text) {
if (notes_) notes_->push_back(std::move(text)); if (note_sink) note_sink->push_back(std::move(text));
else if (notes_) notes_->push_back(std::move(text));
} }
struct GameToc { struct GameToc {
@@ -435,6 +489,22 @@ private:
std::vector<std::string>* notes_; std::vector<std::string>* notes_;
std::map<std::string, GameToc, std::less<>> tocs_; std::map<std::string, GameToc, std::less<>> tocs_;
std::map<std::string, std::optional<Assets>, std::less<>> assets_; std::map<std::string, std::optional<Assets>, std::less<>> assets_;
std::mutex cache_mutex_;
static inline thread_local std::vector<std::string>* note_sink = nullptr;
public:
// While one lives, this thread's notes go into `kept`.
struct NotesFor {
explicit NotesFor(std::vector<std::string>& kept) { note_sink = &kept; }
~NotesFor() { note_sink = nullptr; }
NotesFor(const NotesFor&) = delete;
NotesFor& operator=(const NotesFor&) = delete;
};
void say(std::vector<std::string>& kept) {
if (notes_) notes_->insert(notes_->end(), std::make_move_iterator(kept.begin()), std::make_move_iterator(kept.end()));
}
private:
// The game's whole costumes: the preset each is fitted on -> its item. // The game's whole costumes: the preset each is fitted on -> its item.
struct Costume { struct Costume {
std::string key; std::string key;
@@ -545,7 +615,8 @@ std::string StoreMeshes::original(const std::vector<std::uint64_t>& points) cons
return most ? *most : std::string(); return most ? *most : std::string();
} }
StoreCopies check_store_copies(const Catalog& catalog, const StoreItem& sold, std::vector<std::string>* notes) noexcept { StoreCopies check_store_copies(const Catalog& catalog, const StoreItem& sold, std::vector<std::string>* notes,
std::size_t threads, bool background) noexcept {
StoreCopies result; StoreCopies result;
try { try {
if (!sold || std::none_of(catalog.mods.begin(), catalog.mods.end(), [](const Mod& mod) { return mod.provides_layout; })) if (!sold || std::none_of(catalog.mods.begin(), catalog.mods.end(), [](const Mod& mod) { return mod.provides_layout; }))
@@ -557,22 +628,55 @@ StoreCopies check_store_copies(const Catalog& catalog, const StoreItem& sold, st
std::vector<Scan::Mesh> meshes; std::vector<Scan::Mesh> meshes;
}; };
std::vector<Added> added; std::vector<Added> added;
for (const auto& mod : catalog.mods) { {
if (!mod.provides_layout) continue; // Each mod is read by itself, several at a time; they are gone through in their own
Added entry{&mod, scan.read_mod(mod), scan.read_mod_meshes(mod)}; // order afterwards.
result.mod_meshes += entry.meshes.size(); std::vector<const Mod*> asked;
if (!entry.items.empty() || !entry.meshes.empty()) added.push_back(std::move(entry)); for (const auto& mod : catalog.mods)
if (mod.provides_layout) asked.push_back(&mod);
std::vector<Added> reads(asked.size());
std::vector<std::vector<std::string>> said(asked.size());
std::atomic<std::size_t> next{0};
const auto work = [&] {
for (;;) {
const auto mine = next.fetch_add(1);
if (mine >= asked.size()) return;
const Scan::NotesFor kept(said[mine]);
try {
reads[mine] = {asked[mine], scan.read_mod(*asked[mine]), scan.read_mod_meshes(*asked[mine])};
} catch (...) {
reads[mine] = {asked[mine], {}, {}}; // (neither reader throws; a mod that cannot be read adds nothing)
}
}
};
{
std::vector<std::jthread> workers;
try {
for (std::size_t index = 0; index < threads && asked.size() > 1; ++index)
workers.emplace_back([&] {
if (background) SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL);
work();
});
} catch (const std::system_error&) {} // fewer threads, or none: this one does the rest
work();
}
for (std::size_t index = 0; index < asked.size(); ++index) {
scan.say(said[index]);
result.mod_meshes += reads[index].meshes.size();
if (!reads[index].items.empty() || !reads[index].meshes.empty()) added.push_back(std::move(reads[index]));
}
} }
if (added.empty()) return result; if (added.empty()) return result;
// The costumes' meshes are only read when a mod ships a mesh at all.
StoreItems game; StoreItems game;
result.game_items = scan.read_game(game, sold); StoreMeshes costumes;
const auto counts = scan.read_game(game, sold, result.mod_meshes ? &costumes : nullptr, threads, background);
result.game_items = counts.items;
result.game_costumes = counts.costumes;
if (!result.game_items) { if (!result.game_items) {
if (notes) notes->push_back(std::string(store_copies_check) + " could not read the game's own files and did not run"); if (notes) notes->push_back(std::string(store_copies_check) + " could not read the game's own files and did not run");
return result; return result;
} }
// The costumes' meshes are only read when a mod ships a mesh at all.
StoreMeshes costumes;
if (result.mod_meshes) result.game_costumes = scan.read_costumes(costumes);
for (const auto& [mod, items, meshes] : added) { for (const auto& [mod, items, meshes] : added) {
StoreCopies::Source source{mod->name}; StoreCopies::Source source{mod->name};
for (const auto& mesh : meshes) { for (const auto& mesh : meshes) {
+5 -1
View File
@@ -134,7 +134,11 @@ using StoreItem = std::function<bool(const std::string&)>;
// and holds them against the game's own. The game's are only read when a mod // and holds them against the game's own. The game's are only read when a mod
// has an item at all. Never throws: a bundle that cannot be read is left out // has an item at all. Never throws: a bundle that cannot be read is left out
// (the merge leaves its mod out of the game too) and noted in `notes`. // (the merge leaves its mod out of the game too) and noted in `notes`.
// The game's own files are read on up to `threads` threads beside the caller's
// (`background`: at a priority under the game's, for a merge while it runs); the verdicts do
// not depend on the count.
[[nodiscard]] StoreCopies check_store_copies(const Catalog& catalog, const StoreItem& sold, [[nodiscard]] StoreCopies check_store_copies(const Catalog& catalog, const StoreItem& sold,
std::vector<std::string>* notes = nullptr) noexcept; std::vector<std::string>* notes = nullptr, std::size_t threads = 0,
bool background = false) noexcept;
} // namespace dingosdk::mods } // namespace dingosdk::mods
+123
View File
@@ -0,0 +1,123 @@
#include "thunderstore_package.h"
#include "Engine/Core/Json/json.h"
#include <algorithm>
namespace dingosdk::thunderstore_package {
namespace {
constexpr char site[] = "https://thunderstore.io";
bool word(std::string_view text) {
return !text.empty() && text.size() <= 64 && std::all_of(text.begin(), text.end(), [](char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_';
});
}
bool version(std::string_view text) {
unsigned numbers{};
for (std::size_t at = 0; at <= text.size();) {
const auto end = std::min(text.find('.', at), text.size());
const auto number = text.substr(at, end - at);
if (number.empty() || number.size() > 9 || !std::all_of(number.begin(), number.end(), [](char c) { return c >= '0' && c <= '9'; }))
return false;
++numbers;
at = end + 1;
}
return numbers == 3;
}
bool same(std::string_view a, std::string_view b) {
return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) {
const auto lower = [](char c) { return c >= 'A' && c <= 'Z' ? static_cast<char>(c + 32) : c; };
return lower(x) == lower(y);
});
}
std::wstring wide(const std::string& text) { return std::wstring(text.begin(), text.end()); } // ASCII only here
std::string text_of(const Json& object, std::string_view key) {
return object.is_object() && object.contains(key) && object.at(key).is_string() ? object.at(key).string() : std::string();
}
bool flag(const Json& object, std::string_view key) {
return object.is_object() && object.contains(key) && object.at(key).is_boolean() && object.at(key).get<bool>();
}
} // namespace
std::optional<Name> parse_name(std::string_view package) {
const auto first = package.find('-');
if (first == std::string_view::npos) return std::nullopt;
const auto rest = package.substr(first + 1);
const auto second = rest.find('-');
Name name{std::string(package.substr(0, first)), std::string(rest.substr(0, second)),
second == std::string_view::npos ? std::string() : std::string(rest.substr(second + 1))};
if (!word(name.owner) || !word(name.name) || (!name.version.empty() && !version(name.version))) return std::nullopt;
return name;
}
std::wstring details_url(const Name& name) {
return wide(std::string(site) + "/api/experimental/package/" + name.owner + '/' + name.name + '/');
}
std::wstring download_url(const Name& name, std::string_view wanted) {
return wide(std::string(site) + "/package/download/" + name.owner + '/' + name.name + '/' + std::string(wanted) + '/');
}
Choice choose(std::string_view json, const Name& wanted, std::string_view community) {
Choice choice;
try {
const auto root = Json::parse(json);
if (!root.is_object() || !same(text_of(root, "namespace"), wanted.owner) || !same(text_of(root, "name"), wanted.name)) {
choice.reason = "Thunderstore does not have that package.";
return choice;
}
if (flag(root, "is_deprecated")) {
choice.reason = "Its package on Thunderstore is marked deprecated.";
return choice;
}
bool listed{}, map{}, rejected{}, nsfw{};
if (root.contains("community_listings") && root.at("community_listings").is_array())
for (const auto& listing : root.at("community_listings")) {
if (!same(text_of(listing, "community"), community)) continue;
listed = true;
rejected = same(text_of(listing, "review_status"), "rejected");
nsfw = flag(listing, "has_nsfw_content");
if (listing.contains("categories") && listing.at("categories").is_array())
for (const auto& category : listing.at("categories"))
{
map = map || (category.is_string() && same(category.string(), "Maps"));
nsfw = nsfw || (category.is_string() && same(category.string(), "NSFW"));
}
}
if (!listed) { choice.reason = "Its package is not listed for ReSkate on Thunderstore."; return choice; }
if (rejected) { choice.reason = "Its package was rejected by Thunderstore's moderators."; return choice; }
if (nsfw) { choice.reason = "Its package is marked NSFW on Thunderstore."; return choice; }
if (!map) { choice.reason = "Its package is not listed as a map on Thunderstore."; return choice; }
const auto newest = root.contains("latest") ? text_of(root.at("latest"), "version_number") : std::string();
if (!version(newest)) { choice.reason = "Thunderstore gave no version of that package."; return choice; }
choice.newest = newest;
// Its own words about itself, kept to printable text of a card's length.
for (const unsigned char c : text_of(root.at("latest"), "description")) {
if (choice.description.size() >= 240) { choice.description += "..."; break; }
choice.description += c < 0x20 || c == 0x7f ? ' ' : static_cast<char>(c);
}
{
// https://<something>.thunderstore.io/...png, and nothing that is not a plain URL.
const auto icon = text_of(root.at("latest"), "icon");
constexpr std::string_view scheme = "https://";
const auto path = icon.find('/', scheme.size());
const auto host = icon.starts_with(scheme) && path != std::string::npos ? std::string_view(icon).substr(scheme.size(), path - scheme.size())
: std::string_view{};
const bool plain = icon.size() < 400 && std::all_of(icon.begin(), icon.end(), [](unsigned char c) {
return c > 0x20 && c < 0x7f && c != '"' && c != '<' && c != '>' && c != '\\' && c != '#' && c != '?' && c != '@';
});
if (plain && (host == "thunderstore.io" || host.ends_with(".thunderstore.io"))) choice.icon = icon;
}
choice.version = wanted.version.empty() ? newest : wanted.version;
choice.ok = true;
} catch (const std::exception&) {
choice = {};
choice.reason = "Thunderstore's answer could not be read.";
}
return choice;
}
} // namespace dingosdk::thunderstore_package
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <optional>
#include <string>
#include <string_view>
// One Thunderstore package looked up by name, for the game: the map a multiplayer host is on
// and this player does not have (Extension/Assets/map_download.h). No network here; the caller
// fetches details_url() and hands the answer to choose().
namespace dingosdk::thunderstore_package {
struct Name {
std::string owner, name;
std::string version; // "1.2.3", or empty for the newest
// The Mods folder the package installs as, and how its files name it.
[[nodiscard]] std::string folder() const { return owner + '-' + name; }
bool operator==(const Name&) const = default;
};
// "Owner-Name" or "Owner-Name-1.2.3", as a host names a map's package
// (Extension/Multiplayer/Net/protocol.h: valid_map_package). Nothing for anything else.
std::optional<Name> parse_name(std::string_view package);
// /api/experimental/package/<owner>/<name>/: what Thunderstore says of the package.
std::wstring details_url(const Name& name);
// Where a version of it downloads from. Always thunderstore.io, whatever an answer says.
std::wstring download_url(const Name& name, std::string_view version);
struct Choice {
bool ok{};
std::string version; // the one to download: the one asked for, else the newest
std::string newest; // the newest, to fall back on when the one asked for is gone
// What its page says of it, for the player: one line of plain text, cut to a few lines' worth.
std::string description;
// Its picture, a PNG on Thunderstore's own hosts; empty when the answer names anything else.
std::string icon;
std::string reason; // why not, for the player
};
// Reads that answer. The package is taken only when it is the one asked for, is listed in
// `community` as a map (the "Maps" category), is not deprecated or marked NSFW there and was
// not rejected by the community's moderators.
Choice choose(std::string_view json, const Name& wanted, std::string_view community);
} // namespace dingosdk::thunderstore_package
+18 -1
View File
@@ -37,6 +37,7 @@ std::atomic<bool> merging{};
std::atomic<bool> reload_after{}, reload_ready{}; std::atomic<bool> reload_after{}, reload_ready{};
std::mutex state_mutex; std::mutex state_mutex;
std::string last_status; std::string last_status;
Progress merge_progress; // under state_mutex, while merging
std::optional<std::vector<std::filesystem::path>> pending_levels; std::optional<std::vector<std::filesystem::path>> pending_levels;
std::optional<std::vector<std::string>> applied; // set by a live apply; the launch's list before one std::optional<std::vector<std::string>> applied; // set by a live apply; the launch's list before one
std::string current_level; std::string current_level;
@@ -345,7 +346,11 @@ void apply_now() {
mods::MergeReport report; mods::MergeReport report;
std::string left_out; std::string left_out;
for (int round = 0;; ++round) { for (int round = 0;; ++round) {
report = mods::merge_mods(catalog, {}, {.live = true}); // The mods in the game now were checked against the store when they went in.
report = mods::merge_mods(catalog, [](const mods::MergeProgress &now) {
std::lock_guard lock(state_mutex);
merge_progress = {now.done, now.total, now.step};
}, {.live = true, .checked = before});
if (!report.issue.empty()) break; if (!report.issue.empty()) break;
bool removed = false; bool removed = false;
for (std::size_t i = catalog.mods.size(); i-- > 0;) { for (std::size_t i = catalog.mods.size(); i-- > 0;) {
@@ -436,6 +441,9 @@ void apply_now() {
for (const auto &mod : catalog.mods) applied->push_back(mod.name); for (const auto &mod : catalog.mods) applied->push_back(mod.name);
} }
if (!left_out.empty()) problem += (problem.empty() ? "left out " : "; left out ") + left_out + ", which could not be merged cleanly"; if (!left_out.empty()) problem += (problem.empty() ? "left out " : "; left out ") + left_out + ", which could not be merged cleanly";
// Where the time went, step by step, as the launch's merge logs it.
for (const auto &note : report.notes)
if (note.starts_with("Merge times:")) logging::log(logging::Level::info, logging::Channel::assets, "Live mods: {}", note);
const auto elapsed = static_cast<double>(GetTickCount64() - started) / 1000.0; const auto elapsed = static_cast<double>(GetTickCount64() - started) / 1000.0;
set_status("applied " + std::to_string(catalog.mods.size()) + " mod(s) in " + std::to_string(elapsed).substr(0, 4) + "s; " + set_status("applied " + std::to_string(catalog.mods.size()) + " mod(s) in " + std::to_string(elapsed).substr(0, 4) + "s; " +
(needed ? "reloading the level" (needed ? "reloading the level"
@@ -462,6 +470,10 @@ void run() {
std::string apply(bool reload_level) { std::string apply(bool reload_level) {
if (!layout_objects().manager) return "No merged mod patch was mounted at launch; restart the game to load mods."; if (!layout_objects().manager) return "No merged mod patch was mounted at launch; restart the game to load mods.";
if (merging.exchange(true)) return "Mods are already being applied."; if (merging.exchange(true)) return "Mods are already being applied.";
{
std::lock_guard lock(state_mutex);
merge_progress = {};
}
reload_after = reload_level; reload_after = reload_level;
std::thread(run).detach(); std::thread(run).detach();
return reload_level ? "Applying mods; the level reloads when they are ready, unless only maps changed." return reload_level ? "Applying mods; the level reloads when they are ready, unless only maps changed."
@@ -511,6 +523,11 @@ ApplyEffect preview(const mods::ModList &list) {
return reload ? ApplyEffect::reload_level : ApplyEffect::maps_only; return reload ? ApplyEffect::reload_level : ApplyEffect::maps_only;
} }
bool busy() noexcept { return merging.load(); } bool busy() noexcept { return merging.load(); }
Progress progress() {
std::lock_guard lock(state_mutex);
return merge_progress;
}
bool only_maps(const std::filesystem::path& directory) { return scan_maps_only(directory); }
std::string status() { std::string status() {
std::lock_guard lock(state_mutex); std::lock_guard lock(state_mutex);
return last_status; return last_status;
+9
View File
@@ -47,6 +47,12 @@ inline const char* apply_label(ApplyEffect effect) {
void set_current_level(std::string level); void set_current_level(std::string level);
// True while a live merge runs. // True while a live merge runs.
bool busy() noexcept; bool busy() noexcept;
// How far it is: steps done of all, and the step in hand ("Merging items from 12 mods").
struct Progress {
std::size_t done{}, total{};
std::string step;
};
Progress progress();
// The last live apply's outcome for the overlay, or empty before the first. // The last live apply's outcome for the overlay, or empty before the first.
std::string status(); std::string status();
// Folder names of the mods in effect now: the launch's, or the last live apply's. // Folder names of the mods in effect now: the launch's, or the last live apply's.
@@ -54,6 +60,9 @@ std::vector<std::string> applied_mods();
// True when the launch's merge placed this mod's archives (enabled or not), // True when the launch's merge placed this mod's archives (enabled or not),
// so it can be enabled without a restart. // so it can be enabled without a restart.
bool placed_at_launch(const std::string& name); bool placed_at_launch(const std::string& name);
// Whether a mod folder holds maps and nothing else: level bundles and the two files maps
// register themselves in (its layout and globals). Reads the folder; not cached.
bool only_maps(const std::filesystem::path& directory);
// reskate-levels.json of every mod a live apply left enabled, highest // reskate-levels.json of every mod a live apply left enabled, highest
// priority first; handed out once per successful apply. // priority first; handed out once per successful apply.
std::optional<std::vector<std::filesystem::path>> take_level_manifests(); std::optional<std::vector<std::filesystem::path>> take_level_manifests();
+386
View File
@@ -0,0 +1,386 @@
#include "map_download.h"
#include "live_mods.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Vfs/https_download.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Engine/Vfs/mod_list.h"
#include "Engine/Vfs/thunderstore_package.h"
#include "Extension/Multiplayer/Net/protocol.h"
#include "Extension/UI/Overlay/overlay.h"
#include "Launcher/mod_manager.h"
#include <Windows.h>
#include <algorithm>
#include <atomic>
#include <fstream>
#include <map>
#include <mutex>
#include <thread>
namespace dingosdk::map_download {
namespace {
namespace fs = std::filesystem;
namespace package = thunderstore_package;
constexpr wchar_t agent[] = L"ReSkate";
constexpr char community[] = "reskate";
// A map is at most a few gigabytes (an archive of one holds four); the download may take as
// long as a slow line needs for that.
constexpr std::uint64_t largest_download = 8ull << 30;
constexpr std::uint32_t download_seconds = 4 * 60 * 60;
std::mutex mutex;
std::atomic<Stage> stage{Stage::idle};
multiplayer::MapNeed need; // under mutex, while not idle
package::Name wanted; // under mutex
std::string version; // under mutex: the one to download
std::string newest; // under mutex
std::string description; // under mutex: Thunderstore's, or the installed mod's own
bool installed_off{}; // under mutex: here already, switched off
std::string last_failed; // under mutex: a package not to offer again this run
https::Watch watch;
std::atomic<bool> stop_install{};
std::atomic<bool> apply_wanted{}, apply_started{};
std::atomic<std::uint64_t> join_at{};
std::atomic<std::uint32_t> yes_bind{}, no_bind{};
std::atomic<bool> pick_download{true};
bool same(std::string_view a, std::string_view b) { return a.size() == b.size() && _strnicmp(a.data(), b.data(), a.size()) == 0; }
fs::path mods_root() { return mods::engine_data_root() / mods::mods_folder; }
std::string read_bytes(const fs::path& path, std::uintmax_t most) {
std::error_code error;
const auto size = fs::file_size(path, error);
if (error || size > most) return {};
std::ifstream input(path, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>());
}
std::string host_word(bool server) { return server ? "server" : "host"; }
// The session that is waiting on the map is left: there is no map to wait for.
void leave_session() {
if (multiplayer::fetching_map()) multiplayer::queue_command("stop", "", "");
}
// Back to idle with the reason said, as the plain "map not installed" notice is.
void fail(std::string reason) {
std::string text;
{
std::lock_guard lock(mutex);
text = "The " + host_word(need.server) + (need.moved ? " changed to " : " is on ") + need.map +
", which is not installed on this PC. " + reason;
last_failed = need.package;
}
stage.store(Stage::idle);
leave_session();
logging::log(logging::Level::warning, logging::Channel::assets, "Map download: {}", text);
overlay::notify(overlay::NoticeLevel::warning, "Map not installed", std::move(text));
}
// The mod's row in mods.json when its folder is here: whether it is switched on, and what it
// says of itself.
std::optional<bool> installed(const std::string& folder, std::string& about) {
const auto list = mods::scan_mods(mods::engine_data_root());
for (const auto& entry : list.entries)
if (same(entry.mod.name, folder)) {
about = entry.mod.description;
return entry.enabled;
}
return std::nullopt;
}
// Stage::checking: is it here but off, or what does Thunderstore say of it.
void check() {
package::Name name;
{
std::lock_guard lock(mutex);
name = wanted;
}
try {
std::string about;
if (const auto on = installed(name.folder(), about)) {
if (*on) return fail("Its mod is installed, but the map did not load: update or reinstall it from the launcher.");
{
std::lock_guard lock(mutex);
installed_off = true;
description = about.substr(0, 240);
}
// Its own icon, from its folder.
try {
const auto bytes = read_bytes(mods_root() / name.folder() / L"icon.png", 4 * 1024 * 1024);
overlay::set_map_download_icon(bytes);
} catch (const std::exception&) {}
stage.store(Stage::asking);
return;
}
https::Download result;
const auto answer = https::get_text(package::details_url(name), 512 * 1024, 20, agent, &result);
if (!answer)
return fail(result.http_status == 404 ? "Its mod is not on Thunderstore. Install its map mod yourself and join again."
: "Thunderstore could not be reached to look for it.");
const auto choice = package::choose(*answer, name, community);
if (!choice.ok) return fail(choice.reason + " Install its map mod yourself and join again.");
{
std::lock_guard lock(mutex);
version = choice.version;
newest = choice.newest;
description = choice.description;
}
// Its icon for the card: small, and not worth holding the question up for long.
if (!choice.icon.empty())
if (const auto png = https::get_text(std::wstring(choice.icon.begin(), choice.icon.end()), 2 * 1024 * 1024, 8, agent))
overlay::set_map_download_icon(*png);
stage.store(Stage::asking);
} catch (const std::exception& failure) {
fail(std::string("Looking for it failed: ") + failure.what());
}
}
// Stage::downloading and Stage::installing, then Stage::applying is the client thread's.
void fetch() {
package::Name name;
std::string get, fallback;
bool off{};
{
std::lock_guard lock(mutex);
name = wanted;
get = version;
fallback = newest;
off = installed_off;
}
const auto root = mods_root();
const auto folder = name.folder();
try {
if (!off) {
wchar_t temp[MAX_PATH + 1]{};
const auto length = GetTempPathW(MAX_PATH, temp);
const auto archive = fs::path(length ? std::wstring(temp, length) : L".") /
(L"ReSkate-map-" + std::to_wstring(GetCurrentProcessId()) + L"-" + std::wstring(folder.begin(), folder.end()) + L".zip");
std::error_code error;
fs::remove(archive, error);
watch.received.store(0);
watch.total.store(0);
auto result = https::get(package::download_url(name, get), archive, largest_download, download_seconds, agent, &watch);
// The host's version can be gone from Thunderstore: the newest is the same map.
if (!result.ok && result.http_status == 404 && get != fallback && !watch.cancel.load()) {
fs::remove(archive, error);
logging::log(logging::Level::info, logging::Channel::assets,
"Map download: {} {} is not on Thunderstore any more; getting {}.", folder, get, fallback);
result = https::get(package::download_url(name, fallback), archive, largest_download, download_seconds, agent, &watch);
}
if (!result.ok) {
fs::remove(archive, error);
if (watch.cancel.load()) { stage.store(Stage::idle); leave_session(); return; }
return fail(result.http_status && result.http_status != 200
? "The download failed (Thunderstore answered " + std::to_string(result.http_status) + ")."
: "The download failed (Windows error " + std::to_string(result.error) + ").");
}
stage.store(Stage::installing);
try {
launcher_mods::install(root, archive, false, [](float) {}, stop_install, {folder, name.owner, true});
} catch (const std::exception& failure) {
fs::remove(archive, error);
return fail(std::string("It could not be installed: ") + failure.what());
}
fs::remove(archive, error);
} else {
stage.store(Stage::installing);
}
// A map and nothing else: this way in is for maps.
std::error_code error;
if (!fs::exists(root / folder / L"reskate-levels.json", error) || !live_mods::only_maps(root / folder)) {
if (!off) {
try { launcher_mods::remove(root, folder); } catch (const std::exception&) {}
}
return fail("Its package holds more than a map, so it is not installed from here. Install it from the launcher if you trust it.");
}
if (off) {
auto list = mods::scan_mods(mods::engine_data_root());
if (!list.issue.empty()) return fail("mods.json could not be read: " + list.issue);
for (auto& entry : list.entries)
if (same(entry.mod.name, folder)) entry.enabled = true;
mods::save_mod_order(list.root, list.entries);
}
logging::log(logging::Level::info, logging::Channel::assets, "Map download: {} is in Mods; applying it.", folder);
apply_started.store(false);
apply_wanted.store(true);
stage.store(Stage::applying);
} catch (const std::exception& failure) {
fail(std::string("It could not be installed: ") + failure.what());
}
}
// One piece of work at a time, which the stage sees to: each is started by the move into its
// stage. Detached, as the live merge's thread is: the game may close while a map downloads.
void start(void (*work)()) { std::thread(work).detach(); }
} // namespace
void offer(const multiplayer::MapNeed& asked) {
auto idle = Stage::idle;
const auto name = package::parse_name(asked.package);
// (Taken before anything is set up, so a second need while this one is in hand is dropped.)
if (!stage.compare_exchange_strong(idle, Stage::checking)) {
// Another map is in hand (the session moved on while it came). One at a time: the one
// being fetched is finished, and the session, which now waits on this other one, is left.
bool other{};
{
std::lock_guard lock(mutex);
other = asked.package != need.package;
}
if (other) {
overlay::notify(overlay::NoticeLevel::warning, "Map changed again",
"The " + host_word(asked.server) + " moved on to " + asked.map + " while another map was downloading. Join again when it is done.");
leave_session();
}
return;
}
{
std::lock_guard lock(mutex);
need = asked;
if (!name || asked.package == last_failed) {
// (Said once already, or nothing this can fetch: the plain notice.)
overlay::notify(overlay::NoticeLevel::warning, "Map not installed",
"The " + host_word(asked.server) + (asked.moved ? " changed to " : " is on ") + asked.map +
", which is not installed on this PC. Install its map mod and join again.");
stage.store(Stage::idle);
leave_session();
return;
}
wanted = *name;
version.clear();
newest.clear();
description.clear();
installed_off = false;
}
overlay::set_map_download_icon({});
watch.cancel.store(false);
stop_install.store(false);
pick_download.store(true);
start(check);
}
void answer(bool download) noexcept {
try {
// (A click and a key can answer in the same moment: only the first does.)
auto asked = Stage::asking;
if (stage.compare_exchange_strong(asked, download ? Stage::downloading : Stage::idle)) {
if (download) start(fetch);
else leave_session();
} else if (asked == Stage::downloading && !download) {
watch.cancel.store(true);
}
} catch (...) {
stage.store(Stage::idle);
}
}
bool asking() noexcept { return stage.load(std::memory_order_relaxed) == Stage::asking; }
void pick(bool download) noexcept { pick_download.store(download, std::memory_order_relaxed); }
bool picked() noexcept { return pick_download.load(std::memory_order_relaxed); }
void set_binds(std::uint32_t yes, std::uint32_t no) noexcept {
yes_bind.store(yes, std::memory_order_relaxed);
no_bind.store(no, std::memory_order_relaxed);
}
View view() {
View out;
out.stage = stage.load();
if (out.stage == Stage::idle || out.stage == Stage::checking) return out;
std::lock_guard lock(mutex);
out.map = need.map;
out.package = wanted.folder() + (version.empty() ? std::string() : " " + version);
out.author = wanted.owner;
out.version = version;
out.description = description;
if (out.stage == Stage::applying) {
const auto now = live_mods::progress();
out.step = now.done;
out.steps = now.total;
out.step_name = now.step;
}
out.server = need.server;
out.moved = need.moved;
out.installed = installed_off;
out.choice = pick_download.load(std::memory_order_relaxed);
out.received = watch.received.load();
out.total = watch.total.load();
out.yes_bind = yes_bind.load(std::memory_order_relaxed);
out.no_bind = no_bind.load(std::memory_order_relaxed);
return out;
}
void tick() {
const auto now = stage.load();
if (now == Stage::applying) {
if (apply_wanted.load()) {
// (A merge of the player's own may be running: wait for it and start this one.)
if (live_mods::busy()) return;
apply_wanted.store(false);
const auto said = live_mods::apply(false);
if (!live_mods::busy()) return fail("It is installed, but could not be applied now: " + said + " Restart the game to load it.");
apply_started.store(true);
return;
}
if (!apply_started.load() || live_mods::busy()) return;
apply_started.store(false);
std::string folder;
{
std::lock_guard lock(mutex);
folder = wanted.folder();
}
const auto applied = live_mods::applied_mods();
if (std::none_of(applied.begin(), applied.end(), [&](const std::string& name) { return same(name, folder); }))
return fail("It is installed, but was left out when the mods were applied (" + live_mods::status() + ").");
// The level list takes the new map on the next ticks, and the session, which has been
// trying the map all along, loads it then.
join_at.store(GetTickCount64());
stage.store(Stage::joining);
return;
}
if (now == Stage::joining) {
const auto waited = GetTickCount64() - join_at.load();
if (waited < 2500) return;
// Still waiting on it: the game has the mod and cannot find the map in it.
if (multiplayer::fetching_map()) {
if (waited > 30000) fail("It is installed, but the game still cannot load the map. Restart the game and join again.");
return;
}
{
// Should it not load after all, it is said and not offered round and round.
std::lock_guard lock(mutex);
last_failed = need.package;
}
stage.store(Stage::idle);
// The session ended while the map came (the host left, the connection dropped): back to it.
if (!multiplayer::model().active && !multiplayer::queue_command("rejoin", "", ""))
overlay::notify(overlay::NoticeLevel::warning, "Map installed", "Join the session again from the server browser.");
}
}
std::string package_of(std::string_view asset) {
struct Known {
std::string package;
std::uint64_t until{};
};
static std::mutex cache_mutex;
static std::map<std::string, Known, std::less<>> cache;
if (asset.empty()) return {};
std::string key(asset);
std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
const auto now = GetTickCount64();
std::lock_guard lock(cache_mutex);
if (const auto found = cache.find(key); found != cache.end() && now < found->second.until) return found->second.package;
// Asked with every map offer a host sends: the Mods folder is read at most every two
// minutes for a level.
std::string result;
const auto list = mods::scan_mods(mods::engine_data_root());
for (const auto& entry : list.entries) {
if (!entry.enabled || !entry.mod.provides_levels) continue;
if (std::none_of(entry.mod.levels.begin(), entry.mod.levels.end(), [&](const std::string& level) { return same(level, key); })) continue;
result = multiplayer::map_package_name(entry.mod.name, entry.mod.version);
break;
}
cache[key] = {result, now + 120000};
return result;
}
}
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include "Extension/Multiplayer/Session/session.h"
#include <cstdint>
#include <string>
#include <string_view>
// A map a multiplayer session needs and this PC does not have, fetched from Thunderstore
// while the game runs. The host says which package its map is from (session.h: MapNeed). The
// player is asked; on yes the package is downloaded, installed into Mods and applied as a live
// mod (live_mods.h). The session stays connected meanwhile and loads the map once it is there;
// on no, or when the map cannot be had, the session is left.
//
// Only maps come in this way. The package has to be listed as a map in ReSkate's Thunderstore
// community, not deprecated and not rejected by its moderators; what is installed has to be a
// map and nothing else (level bundles and their registration: no cosmetics, no settings, no
// scripts), or it is taken out again. Everything is downloaded from thunderstore.io, whatever
// a host says.
namespace dingosdk::map_download {
enum class Stage {
idle,
checking, // asking Thunderstore about the package; nothing is shown yet
asking, // the card asks: download, or not
downloading,
installing,
applying, // the live merge
joining, // the map is in the game: the session is loading it (or is joined again)
};
struct View {
Stage stage{};
std::string map, package; // "Vancouver Plaza", "Sandos-Vancouver_Plaza 2.0.0"
std::string author, version, description; // "Sandos", "2.0.0", and what its Thunderstore page says of it
// applying: the live merge's steps done of all, and the one in hand.
std::size_t step{}, steps{};
std::string step_name;
bool server{}; // a dedicated server's map, else a player's lobby
bool moved{}; // the session changed to it with the player in it (else: joining it)
bool installed{}; // asking: the mod is here but switched off, so nothing is downloaded
bool choice{true}; // asking: the answer a controller has picked (true: download)
std::uint64_t received{}, total{}; // downloading; total 0 when the size is not known
std::uint32_t yes_bind{}, no_bind{}; // the vote binds, which answer the card
};
// Client thread: a session just ended over this map. Ignored while another is in hand.
void offer(const multiplayer::MapNeed& need);
// Any thread. Yes downloads (when asking); no declines, or stops a download under way.
void answer(bool download) noexcept;
bool asking() noexcept;
// A controller's pick between the two answers, and what it is.
void pick(bool download) noexcept;
bool picked() noexcept;
void set_binds(std::uint32_t yes, std::uint32_t no) noexcept;
View view();
// Client thread, every tick: starts the live apply and the join when their turn comes.
void tick();
// The Thunderstore package an enabled mod's level comes from (its folder and manifest), for a
// host to tell its guests; empty when the level is the game's own or the mod is not a package.
std::string package_of(std::string_view asset);
}
+74 -4
View File
@@ -437,6 +437,10 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
throw std::invalid_argument("Invalid host map destination"); throw std::invalid_argument("Invalid host map destination");
if ((p.kind == PacketKind::map_offer || p.kind == PacketKind::world_state) && !valid_map_label(p.map_label)) if ((p.kind == PacketKind::map_offer || p.kind == PacketKind::world_state) && !valid_map_label(p.map_label))
throw std::invalid_argument("Invalid map name"); throw std::invalid_argument("Invalid map name");
if ((p.kind == PacketKind::map_offer || p.kind == PacketKind::world_state) && !valid_map_package(p.map_package))
throw std::invalid_argument("Invalid map package");
if (p.map_fetching && (p.world_ready || (p.kind != PacketKind::map_request && p.kind != PacketKind::world_ready)))
throw std::invalid_argument("Invalid map fetch notice");
if (p.kind == PacketKind::world_state && if (p.kind == PacketKind::world_state &&
(p.destination.empty() ? (p.map != 0 || p.world_ready) (p.destination.empty() ? (p.map != 0 || p.world_ready)
: (!valid_map_destination(p.destination) || p.map != map_hash(p.destination)))) : (!valid_map_destination(p.destination) || p.map != map_hash(p.destination))))
@@ -482,7 +486,10 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
w.bytes.insert(w.bytes.end(), p.destination.begin(), p.destination.end()); w.bytes.insert(w.bytes.end(), p.destination.begin(), p.destination.end());
w.integer(p.map_label.size(), 1); w.integer(p.map_label.size(), 1);
w.bytes.insert(w.bytes.end(), p.map_label.begin(), p.map_label.end()); w.bytes.insert(w.bytes.end(), p.map_label.begin(), p.map_label.end());
w.integer(p.map_package.size(), 1);
w.bytes.insert(w.bytes.end(), p.map_package.begin(), p.map_package.end());
} }
if (p.kind == PacketKind::map_request) w.integer(p.map_fetching ? 1 : 0, 1);
if (p.kind == PacketKind::hello) { if (p.kind == PacketKind::hello) {
w.integer(p.text.size(), 1); w.integer(p.text.size(), 1);
w.bytes.insert(w.bytes.end(), p.text.begin(), p.text.end()); w.bytes.insert(w.bytes.end(), p.text.begin(), p.text.end());
@@ -494,8 +501,10 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
w.bytes.insert(w.bytes.end(), p.destination.begin(), p.destination.end()); w.bytes.insert(w.bytes.end(), p.destination.begin(), p.destination.end());
w.integer(p.map_label.size(), 1); w.integer(p.map_label.size(), 1);
w.bytes.insert(w.bytes.end(), p.map_label.begin(), p.map_label.end()); w.bytes.insert(w.bytes.end(), p.map_label.begin(), p.map_label.end());
w.integer(p.map_package.size(), 1);
w.bytes.insert(w.bytes.end(), p.map_package.begin(), p.map_package.end());
} else if (p.kind == PacketKind::world_ready) { } else if (p.kind == PacketKind::world_ready) {
w.integer(p.world_ready ? 1 : 0, 1); w.integer(p.world_ready ? 1 : p.map_fetching ? 2 : 0, 1); // 2: not ready, and fetching the map
} else if (p.kind == PacketKind::pose) { } else if (p.kind == PacketKind::pose) {
w.integer(p.pose.skater.size(), 2); w.integer(p.pose.skater.size(), 2);
w.integer(p.pose.board.size(), 2); w.integer(p.pose.board.size(), 2);
@@ -734,7 +743,13 @@ static bool read_map_label(Reader &r, std::span<const std::uint8_t> bytes, Packe
if (length > max_member_name || length > bytes.size() - r.at) return false; if (length > max_member_name || length > bytes.size() - r.at) return false;
p.map_label.assign(reinterpret_cast<const char *>(bytes.data() + r.at), static_cast<std::size_t>(length)); p.map_label.assign(reinterpret_cast<const char *>(bytes.data() + r.at), static_cast<std::size_t>(length));
r.at += static_cast<std::size_t>(length); r.at += static_cast<std::size_t>(length);
return valid_map_label(p.map_label); if (!valid_map_label(p.map_label)) return false;
// And the package it comes from, which follows the name wherever the name is carried.
const auto package = r.integer(1);
if (package > max_map_package || package > bytes.size() - r.at) return false;
p.map_package.assign(reinterpret_cast<const char *>(bytes.data() + r.at), static_cast<std::size_t>(package));
r.at += static_cast<std::size_t>(package);
return valid_map_package(p.map_package);
} }
// Short text written by encode's `text`: nothing when it is longer than `limit` or the packet. // Short text written by encode's `text`: nothing when it is longer than `limit` or the packet.
static std::optional<std::string> read_text(Reader &r, std::span<const std::uint8_t> bytes, unsigned width, std::size_t limit) { static std::optional<std::string> read_text(Reader &r, std::span<const std::uint8_t> bytes, unsigned width, std::size_t limit) {
@@ -785,6 +800,11 @@ std::optional<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
if (!valid_map_destination(p.destination) || p.map != map_hash(p.destination) || !read_map_label(r, bytes, p)) if (!valid_map_destination(p.destination) || p.map != map_hash(p.destination) || !read_map_label(r, bytes, p))
return {}; return {};
} }
if (p.kind == PacketKind::map_request) {
const auto fetching = r.integer(1);
if (fetching > 1) return {};
p.map_fetching = fetching != 0;
}
if (p.kind == PacketKind::hello) { if (p.kind == PacketKind::hello) {
const auto length = r.integer(1); const auto length = r.integer(1);
if (length > max_member_name || length > bytes.size() - r.at) return {}; if (length > max_member_name || length > bytes.size() - r.at) return {};
@@ -796,8 +816,9 @@ std::optional<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
if (p.kind == PacketKind::world_state) if (p.kind == PacketKind::world_state)
for (auto &v : p.build) v = static_cast<std::uint8_t>(r.integer(1)); for (auto &v : p.build) v = static_cast<std::uint8_t>(r.integer(1));
const auto ready = r.integer(1); const auto ready = r.integer(1);
if (ready > 1) return {}; if (ready > (p.kind == PacketKind::world_ready ? 2U : 1U)) return {};
p.world_ready = ready != 0; p.world_ready = ready == 1;
p.map_fetching = ready == 2;
if (p.kind == PacketKind::world_state) { if (p.kind == PacketKind::world_state) {
const auto length = r.integer(2); const auto length = r.integer(2);
if (length > 256 || length > bytes.size() - r.at) return {}; if (length > 256 || length > bytes.size() - r.at) return {};
@@ -1217,6 +1238,55 @@ bool valid_map_asset(std::string_view asset) noexcept {
bool valid_map_label(std::string_view label) noexcept { bool valid_map_label(std::string_view label) noexcept {
return label.empty() || (label.size() <= max_member_name && valid_member_name(label)); return label.empty() || (label.size() <= max_member_name && valid_member_name(label));
} }
namespace {
// A Thunderstore team or package name: letters, digits and '_'.
bool package_word(std::string_view text) noexcept {
return !text.empty() && std::all_of(text.begin(), text.end(), [](char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_';
});
}
// Three numbers with dots between: 1.2.3.
bool package_version(std::string_view text) noexcept {
unsigned numbers{};
for (std::size_t at = 0; at <= text.size();) {
const auto end = std::min(text.find('.', at), text.size());
const auto number = text.substr(at, end - at);
if (number.empty() || number.size() > 9 || !std::all_of(number.begin(), number.end(), [](char c) { return c >= '0' && c <= '9'; }))
return false;
++numbers;
at = end + 1;
}
return numbers == 3;
}
} // namespace
bool valid_map_package(std::string_view package) noexcept {
if (package.empty()) return true;
if (package.size() > max_map_package) return false;
const auto first = package.find('-');
if (first == std::string_view::npos) return false;
const auto rest = package.substr(first + 1);
const auto second = rest.find('-');
if (!package_word(package.substr(0, first)) || !package_word(rest.substr(0, second))) return false;
return second == std::string_view::npos || package_version(rest.substr(second + 1));
}
std::string map_package_name(std::string_view folder, std::string_view version) {
const auto dash = folder.find('-');
if (dash == std::string_view::npos || !package_word(folder.substr(0, dash))) return {};
// "Owner-Name", or "Owner-Name-1.2.3": what a package's zip unpacks to when it is not
// installed by the launcher, as on a server. The manifest's version is the one believed.
auto rest = folder.substr(dash + 1);
std::string_view named;
if (const auto second = rest.find('-'); second != std::string_view::npos) {
named = rest.substr(second + 1);
rest = rest.substr(0, second);
if (!package_version(named)) return {};
}
if (!package_word(rest)) return {};
std::string name(folder.substr(0, dash + 1 + rest.size()));
if (package_version(version)) name += '-' + std::string(version);
else if (!named.empty()) name += '-' + std::string(named);
return name.size() <= max_map_package ? name : std::string();
}
bool valid_map_pool(std::span<const std::uint16_t> pool, std::size_t maps) noexcept { bool valid_map_pool(std::span<const std::uint16_t> pool, std::size_t maps) noexcept {
if (pool.size() > maps) return false; if (pool.size() > maps) return false;
for (std::size_t i = 0; i < pool.size(); ++i) for (std::size_t i = 0; i < pool.size(); ++i)
+19 -1
View File
@@ -26,7 +26,7 @@ namespace dingosdk::multiplayer {
constexpr std::size_t max_skater_bones = 512, max_board_bones = 64; constexpr std::size_t max_skater_bones = 512, max_board_bones = 64;
constexpr std::size_t max_packet = 24576; constexpr std::size_t max_packet = 24576;
constexpr std::size_t packet_header_size = 64; constexpr std::size_t packet_header_size = 64;
constexpr std::uint16_t protocol_version = 46; constexpr std::uint16_t protocol_version = 47;
// A dedicated server's chat lines unless its owner says otherwise: violet (#8E5CFF) and lavender (#D9C8FF). // A dedicated server's chat lines unless its owner says otherwise: violet (#8E5CFF) and lavender (#D9C8FF).
inline constexpr std::uint32_t default_server_chat_badge = 0xffff5c8eU, default_server_chat_text = 0xffffc8d9U; inline constexpr std::uint32_t default_server_chat_badge = 0xffff5c8eU, default_server_chat_text = 0xffffc8d9U;
constexpr std::size_t max_throwdown_message = 4096; constexpr std::size_t max_throwdown_message = 4096;
@@ -161,6 +161,20 @@ constexpr std::size_t max_server_maps = 128, max_map_asset = 128;
bool valid_map_asset(std::string_view asset) noexcept; bool valid_map_asset(std::string_view asset) noexcept;
bool valid_map_pool(std::span<const std::uint16_t> pool, std::size_t maps) noexcept; // distinct indices below `maps` bool valid_map_pool(std::span<const std::uint16_t> pool, std::size_t maps) noexcept; // distinct indices below `maps`
bool valid_map_label(std::string_view label) noexcept; // empty, or a name like a member's bool valid_map_label(std::string_view label) noexcept; // empty, or a name like a member's
// How long a host goes on waiting for a player who says they are fetching the map
// (Packet::map_fetching), from the first time they say so: a large map on a slow line. Past
// it they are held to the time a load takes again.
inline constexpr std::uint64_t map_fetch_limit_us = 45ull * 60 * 1000000;
// The Thunderstore package a map comes from, so a player without it can be offered it:
// "Owner-Name", or "Owner-Name-1.2.3" when the host knows its version. Empty for the game's own
// maps and for a map mod that is not from Thunderstore.
inline constexpr std::size_t max_map_package = 96;
bool valid_map_package(std::string_view package) noexcept;
// That name for a mod folder and the version its manifest gives; empty when the folder is not
// named as a package: "Owner-Name", as the launcher installs one, or "Owner-Name-1.2.3", as its
// zip unpacks by hand. The manifest's version comes before the folder's; one that is not three
// numbers is left off.
std::string map_package_name(std::string_view folder, std::string_view version);
struct Transform { struct Transform {
std::array<float, 3> position{}; std::array<float, 3> position{};
std::array<float, 4> rotation{0, 0, 0, 1}; std::array<float, 4> rotation{0, 0, 0, 1};
@@ -201,6 +215,9 @@ struct Packet {
std::string destination; std::string destination;
bool map_authorized{}; bool map_authorized{};
bool world_ready{}; bool world_ready{};
// map_request, world_ready: the player does not have the map and is downloading it
// (never with world_ready). A host gives such a player longer than a load takes.
bool map_fetching{};
Pose pose; Pose pose;
std::uint32_t pose_interval_us = 50000; std::uint32_t pose_interval_us = 50000;
// pose: the sender has the game's party collision on (the top bit of the pose's rate field) // pose: the sender has the game's party collision on (the top bit of the pose's rate field)
@@ -257,6 +274,7 @@ struct Packet {
std::vector<std::uint16_t> map_pool; // maps: the pool as indices into `maps`, rotation order (empty: every map) std::vector<std::uint16_t> map_pool; // maps: the pool as indices into `maps`, rotation order (empty: every map)
std::uint16_t map_rotation{}; // maps: minutes per map (0: off) std::uint16_t map_rotation{}; // maps: minutes per map (0: off)
std::string map_label; // map_offer, world_state: the map's name for people (may be empty) std::string map_label; // map_offer, world_state: the map's name for people (may be empty)
std::string map_package; // map_offer, world_state: valid_map_package (may be empty)
std::vector<std::uint8_t> throwdown; // throwdown: one encoded message (1..max_throwdown_message bytes) std::vector<std::uint8_t> throwdown; // throwdown: one encoded message (1..max_throwdown_message bytes)
std::array<float, 3> teleport{}; // teleport: where the receiver goes (world position) std::array<float, 3> teleport{}; // teleport: where the receiver goes (world position)
std::vector<std::uint8_t> tuning; // physics_tuning: 0..max_physics_tuning bytes std::vector<std::uint8_t> tuning; // physics_tuning: 0..max_physics_tuning bytes
+36 -3
View File
@@ -140,6 +140,9 @@ void stop(Session &s, std::string reason) {
s.join_started = s.last_map_request = s.last_map_load_check = 0; s.join_started = s.last_map_request = s.last_map_load_check = 0;
s.join_destination.clear(); s.join_destination.clear();
s.map_label.clear(); s.map_label.clear();
s.map_package.clear();
s.fetching_since = 0;
fetching_map_flag.store(false, std::memory_order_relaxed);
s.world = 1; s.world = 1;
s.travelling = false; s.travelling = false;
s.host_world_ready = true; s.host_world_ready = true;
@@ -208,6 +211,7 @@ void clear_world(Session &s, std::uint64_t now) {
peer.password_challenge = challenge; peer.password_challenge = challenge;
peer.world_ready = false; peer.world_ready = false;
peer.travel_since = admitted ? now : 0; peer.travel_since = admitted ? now : 0;
peer.fetching_since = 0;
peer.last_packet = now; peer.last_packet = now;
} }
reset_audio(); reset_audio();
@@ -688,6 +692,9 @@ MultiplayerModel model() {
return s.view; return s.view;
} }
std::string take_leave_notice() { return std::exchange(session().leave_notice, {}); } std::string take_leave_notice() { return std::exchange(session().leave_notice, {}); }
std::optional<MapNeed> take_map_need() { return std::exchange(session().map_need, std::nullopt); }
void set_map_package_lookup(MapPackageLookup lookup) noexcept { map_package_lookup.store(lookup, std::memory_order_relaxed); }
bool fetching_map() noexcept { return fetching_map_flag.load(std::memory_order_relaxed); }
MultiplayerChat chat() { MultiplayerChat chat() {
auto &s = session(); auto &s = session();
std::lock_guard lock(s.mutex); std::lock_guard lock(s.mutex);
@@ -756,7 +763,8 @@ bool observe_local_world(Session &s, const NativeFrame &local, std::string_view
bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoader loader, std::uint64_t now) { bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoader loader, std::uint64_t now) {
if (!s.awaiting_map) if (!s.awaiting_map)
return true; return true;
if (now - s.join_started > 180000000) { // (While the map is being fetched the clock is held: see below.)
if (!s.fetching_since && now - s.join_started > 180000000) {
stop(s, "Joining timed out while waiting for the host's map to load."); stop(s, "Joining timed out while waiting for the host's map to load.");
return false; return false;
} }
@@ -768,6 +776,8 @@ bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoade
return false; return false;
} }
s.awaiting_map = false; s.awaiting_map = false;
s.fetching_since = 0;
fetching_map_flag.store(false, std::memory_order_relaxed);
s.last_hello = 0; s.last_hello = 0;
s.status = s.travelling ? "Your map is ready. Waiting for the host..." : "Host map ready. Joining the session..."; s.status = s.travelling ? "Your map is ready. Waiting for the host..." : "Host map ready. Joining the session...";
return true; return true;
@@ -783,8 +793,31 @@ bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoade
if (result == MapLoadResult::missing) { if (result == MapLoadResult::missing) {
const auto name = s.map_label.empty() ? world_level_name(world_destination_asset(s.join_destination)) : s.map_label; const auto name = s.map_label.empty() ? world_level_name(world_destination_asset(s.join_destination)) : s.map_label;
const char *who = dedicated_host(s) ? "server" : "host"; const char *who = dedicated_host(s) ? "server" : "host";
s.leave_notice = (s.travelling ? std::string("The ") + who + " moved to " : std::string("The ") + who + " is on ") + const auto where = (s.travelling ? std::string("The ") + who + " moved to " : std::string("The ") + who + " is on ") + name;
name + ", which is not installed on this PC. Install its map mod and join again."; // A map the host named the package of can be downloaded (take_map_need). The
// session waits for it: the host is told the map is being fetched and gives that
// far longer than a load, and the map is tried again here until it is installed,
// when it loads like any other. The wait has an end of its own.
if (!s.map_package.empty()) {
if (!s.fetching_since) {
s.fetching_since = now;
fetching_map_flag.store(true, std::memory_order_relaxed);
s.map_need = MapNeed{s.map_package, name, dedicated_host(s), s.travelling};
s.rejoin = Session::Rejoin{{s.host_id, s.secret}, s.password};
s.last_map_request = s.last_world_ready = 0; // say so at once
}
if (now - s.fetching_since > map_fetch_limit_us) {
s.leave_notice = where + ", which did not finish downloading in time. Install its map mod and join again.";
stop(s, s.leave_notice);
return false;
}
// The time a load gets starts when the map is here.
s.join_started = now;
if (s.travelling) s.travel_started = now;
s.status = "Waiting for " + name + " to download...";
return false;
}
s.leave_notice = where + ", which is not installed on this PC. Install its map mod and join again.";
stop(s, s.leave_notice); stop(s, s.leave_notice);
return false; return false;
} }
+21
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "Engine/Game/Multiplayer/session_model.h" #include "Engine/Game/Multiplayer/session_model.h"
#include <cstdint> #include <cstdint>
#include <optional>
#include <string> #include <string>
namespace dingosdk::multiplayer { namespace dingosdk::multiplayer {
@@ -22,6 +23,26 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool gameplay_ready, std::
MapLoader loader = nullptr); MapLoader loader = nullptr);
MultiplayerModel model(); MultiplayerModel model();
std::string take_leave_notice(); // why the session just ended over a missing map, once; client thread std::string take_leave_notice(); // why the session just ended over a missing map, once; client thread
// The same map when the host said which Thunderstore package it is from, so it can be offered
// for download instead (Extension/Assets/map_download.h): the session does not end. It tells
// the host the map is being fetched, which is given much longer than a load, and goes on
// trying the map, so it loads as soon as it is installed. Once per map; client thread.
// The downloader ends the session (queue_command "stop") when the player says no or the map
// cannot be had. Should the session end meanwhile for another reason,
// queue_command("rejoin", "", "") goes back to it, with the password it was joined with.
struct MapNeed {
std::string package; // protocol.h: valid_map_package, never empty
std::string map; // its name for people
bool server{}; // a dedicated server's map, not a player's lobby
bool moved{}; // the session changed to this map with the player in it (else: joining)
};
std::optional<MapNeed> take_map_need();
// Whether a session is waiting on a map being fetched right now. Thread-safe.
bool fetching_map() noexcept;
// How a host learns the package of the map it is on, to tell its guests: the level's asset in,
// the package (or nothing) out. Called on the client thread, often; it should be quick.
using MapPackageLookup = std::string (*)(std::string_view asset);
void set_map_package_lookup(MapPackageLookup lookup) noexcept;
// The session's chat lines for the overlay. Thread-safe and cheap: callable every frame. // The session's chat lines for the overlay. Thread-safe and cheap: callable every frame.
MultiplayerChat chat(); MultiplayerChat chat();
} // namespace dingosdk::multiplayer } // namespace dingosdk::multiplayer
@@ -613,7 +613,7 @@ bool own_mark_command(std::string_view action) {
} // namespace } // namespace
bool queue_command(std::string_view action, std::string_view argument, std::string_view password) { 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 (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" && if ((action != "host" && action != "host-config" && action != "join" && action != "rejoin" && action != "join-lobby" && action != "join-friend-lobby" && action != "stop" &&
action != "distances" && action != "object-placement" && action != "object-limit" && action != "kick" && action != "clear-objects" && action != "distances" && action != "object-placement" && action != "object-limit" && action != "kick" && action != "clear-objects" &&
action != "nametags" && 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 != "direct-connections" && action != "pose-dump" && action != "vote" && action != "voice-chat" && action != "nametag-distance" && action != "nametag-dots" && action != "nametags-friends" && action != "player-distance" && action != "direct-connections" && action != "pose-dump" && action != "vote" && action != "voice-chat" &&
@@ -922,8 +922,17 @@ std::string command(std::string_view action, std::string_view argument, std::str
publish(s); publish(s);
return s.status; return s.status;
} }
if (action != "host" && action != "join" && action != "echo") // "rejoin": back to the session a missing map ended, once the map is installed
// (session.h: take_map_need), with the password that session was joined with.
std::optional<Session::Rejoin> rejoin;
if (action == "rejoin") {
rejoin = std::exchange(s.rejoin, std::nullopt);
if (!rejoin) return "There is no session to go back to.";
}
if (action != "host" && action != "join" && action != "echo" && !rejoin)
return "Unknown multiplayer action."; return "Unknown multiplayer action.";
s.rejoin.reset();
s.map_need.reset();
unsigned capacity = multiplayer_lobby_player_limit; unsigned capacity = multiplayer_lobby_player_limit;
unsigned tps = multiplayer_default_tps; unsigned tps = multiplayer_default_tps;
std::string_view lobby_name; std::string_view lobby_name;
@@ -971,8 +980,9 @@ std::string command(std::string_view action, std::string_view argument, std::str
return refuse("That lobby name contains blocked words. Choose another one."); return refuse("That lobby name contains blocked words. Choose another one.");
} }
std::optional<Invite> invitation; std::optional<Invite> invitation;
if (action == "join") { if (rejoin) invitation = rejoin->invite;
invitation = parse_invite(argument); if (action == "join" || rejoin) {
if (!rejoin) invitation = parse_invite(argument);
if (!invitation) if (!invitation)
return refuse("Invalid join code. Paste the complete SteamID-session code from the host."); 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)); if (blocked_server(invitation->steam_id)) return refuse(std::string(blocked_server_notice));
@@ -985,7 +995,7 @@ std::string command(std::string_view action, std::string_view argument, std::str
s.epoch = nonce(); s.epoch = nonce();
s.secret = invitation ? invitation->secret : nonce(); s.secret = invitation ? invitation->secret : nonce();
if (action != "echo") if (action != "echo")
s.password = password_key(input.password, s.secret); s.password = rejoin ? rejoin->password : password_key(input.password, s.secret);
if (action == "echo") { if (action == "echo") {
s.mode = Mode::echo; s.mode = Mode::echo;
s.peers[0].member = {1, s.epoch, "Local Echo"}; s.peers[0].member = {1, s.epoch, "Local Echo"};
@@ -18,6 +18,7 @@
#include "Extension/Multiplayer/Net/pose_batch.h" #include "Extension/Multiplayer/Net/pose_batch.h"
#include "Extension/Multiplayer/Net/sound_codec.h" #include "Extension/Multiplayer/Net/sound_codec.h"
#include "password.h" #include "password.h"
#include "Engine/Game/World/world_names.h"
#include "client_timing.h" #include "client_timing.h"
#include "monotonic_clock.h" #include "monotonic_clock.h"
#include <atomic> #include <atomic>
@@ -80,6 +81,7 @@ struct Peer {
std::uint64_t last_map_offer{}; std::uint64_t last_map_offer{};
bool world_ready = true; bool world_ready = true;
std::uint64_t travel_since{}; std::uint64_t travel_since{};
std::uint64_t fetching_since{}; // a guest's: when they first said they are fetching the map (0: never, this world)
std::uint32_t ready_sequence{}; std::uint32_t ready_sequence{};
std::uint64_t next_dial{}, last_direct_hello{}, last_direct_pose{}, route_reported{}; std::uint64_t next_dial{}, last_direct_hello{}, last_direct_pose{}, route_reported{};
std::uint32_t route_sequence{}; std::uint32_t route_sequence{};
@@ -242,6 +244,17 @@ struct Session {
std::vector<std::string> server_map_pool; // admins: the pool's assets in rotation order (empty: every map) std::vector<std::string> server_map_pool; // admins: the pool's assets in rotation order (empty: every map)
unsigned server_map_rotation{}; // minutes per map (0: off) unsigned server_map_rotation{}; // minutes per map (0: off)
std::string map_label; // the host's name for join_destination (may be empty) std::string map_label; // the host's name for join_destination (may be empty)
std::string map_package; // the Thunderstore package it is from (may be empty)
// The map is not installed and is being fetched (take_map_need): since when, 0 for not.
// The host is told with what is sent while waiting (Packet::map_fetching).
std::uint64_t fetching_since{};
std::optional<MapNeed> map_need; // take_map_need()
// The session a missing map ended, for "rejoin": who hosted it and the password key it was joined with.
struct Rejoin {
Invite invite;
std::optional<PasswordKey> password;
};
std::optional<Rejoin> rejoin;
std::string leave_notice; // take_leave_notice() std::string leave_notice; // take_leave_notice()
std::uint64_t joined_public_lobby{}; std::uint64_t joined_public_lobby{};
std::array<Peer, max_remote_players> peers; std::array<Peer, max_remote_players> peers;
@@ -440,6 +453,29 @@ std::string cast_server_vote(Session &s, bool yes);
std::string answer_server_poll(Session &s, std::size_t answer); std::string answer_server_poll(Session &s, std::size_t answer);
// Whether a vote the local player may answer is running: read by the game thread for the binds. // Whether a vote the local player may answer is running: read by the game thread for the binds.
inline std::atomic<bool> server_vote_open_flag{}; inline std::atomic<bool> server_vote_open_flag{};
inline std::atomic<MapPackageLookup> map_package_lookup{};
inline std::atomic<bool> fetching_map_flag{};
// A guest without the map is downloading it: the clock they are loading against starts over
// each time they say so, for as long as map_fetch_limit_us from the first time.
inline void guest_fetching(Peer &peer, std::uint64_t now) {
// (Only for a guest who is past the password.)
if (!peer.handshaken && !peer.map_authorized) return;
if (!peer.fetching_since) peer.fetching_since = now;
if (now - peer.fetching_since > map_fetch_limit_us) return;
if (!peer.handshaken) peer.connected_at = now;
else if (peer.travel_since) peer.travel_since = now;
}
// The package of the map a host is on, for its map offers and world states.
inline std::string host_map_package(std::string_view destination) {
const auto lookup = map_package_lookup.load(std::memory_order_relaxed);
if (!lookup || destination.empty()) return {};
try {
auto package = lookup(world_destination_asset(destination));
return valid_map_package(package) ? package : std::string();
} catch (...) {
return {};
}
}
inline std::atomic<unsigned> server_poll_answers_flag{}; inline std::atomic<unsigned> server_poll_answers_flag{};
// "/p <message>" in a lobby: one line for the local player's party only, relayed by the host. // "/p <message>" in a lobby: one line for the local player's party only, relayed by the host.
std::string send_party_chat(Session &s, std::string_view typed); std::string send_party_chat(Session &s, std::string_view typed);
@@ -505,6 +505,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
(s.awaiting_map || !local.ready) && now - s.last_map_request >= 1000000) { (s.awaiting_map || !local.ready) && now - s.last_map_request >= 1000000) {
auto request = packet(s, PacketKind::map_request, now); auto request = packet(s, PacketKind::map_request, now);
request.map = s.join_destination.empty() ? 0 : map_hash(s.join_destination); request.map = s.join_destination.empty() ? 0 : map_hash(s.join_destination);
request.map_fetching = s.fetching_since != 0;
if (s.password && host->password_challenge && request.map) { if (s.password && host->password_challenge && request.map) {
request.challenge = host->password_challenge; request.challenge = host->password_challenge;
request.proof = password_proof(*s.password, s.secret, request.map, s.host_id, request.source, request.proof = password_proof(*s.password, s.secret, request.map, s.host_id, request.source,
@@ -611,6 +612,9 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
s.join_destination.clear(); s.join_destination.clear();
s.map_name.clear(); s.map_name.clear();
s.map_label.clear(); s.map_label.clear();
s.map_package.clear();
s.fetching_since = 0;
fetching_map_flag.store(false, std::memory_order_relaxed);
s.map = 0; s.map = 0;
s.map_load_submitted = false; s.map_load_submitted = false;
s.last_map_load_check = 0; s.last_map_load_check = 0;
@@ -623,6 +627,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
if (!p.destination.empty()) { if (!p.destination.empty()) {
s.join_destination = s.map_name = p.destination; s.join_destination = s.map_name = p.destination;
s.map_label = p.map_label; s.map_label = p.map_label;
s.map_package = p.map_package;
s.map = p.map; s.map = p.map;
} }
s.host_world_ready = p.world_ready; s.host_world_ready = p.world_ready;
@@ -645,6 +650,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
continue; continue;
link->ready_sequence = p.sequence; link->ready_sequence = p.sequence;
link->last_packet = now; link->last_packet = now;
if (p.map_fetching) guest_fetching(*link, now);
const bool arrived = p.world_ready && !link->world_ready; const bool arrived = p.world_ready && !link->world_ready;
link->world_ready = p.world_ready; link->world_ready = p.world_ready;
if (p.world_ready) link->travel_since = 0; if (p.world_ready) link->travel_since = 0;
@@ -671,6 +677,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
disconnect(s, message.peer, "The host changed maps while you were joining. Join again."); disconnect(s, message.peer, "The host changed maps while you were joining. Join again.");
continue; continue;
} }
if (p.map_fetching) guest_fetching(*link, now);
link->member.epoch = p.epoch; link->member.epoch = p.epoch;
bool authorized = !s.password; bool authorized = !s.password;
if (s.password) { if (s.password) {
@@ -691,6 +698,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
if (newly_authorized || !link->last_map_offer || now - link->last_map_offer >= 1000000) { if (newly_authorized || !link->last_map_offer || now - link->last_map_offer >= 1000000) {
auto offer = packet(s, PacketKind::map_offer, now); auto offer = packet(s, PacketKind::map_offer, now);
offer.destination = s.map_name; offer.destination = s.map_name;
offer.map_package = host_map_package(s.map_name);
offer.challenge = link->password_challenge; offer.challenge = link->password_challenge;
offer.map_authorized = authorized; offer.map_authorized = authorized;
send_required(s, message.peer, encode_wire(offer)); send_required(s, message.peer, encode_wire(offer));
@@ -722,6 +730,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
link->map_authorized |= p.map_authorized; link->map_authorized |= p.map_authorized;
s.join_destination = p.destination; s.join_destination = p.destination;
s.map_label = p.map_label; s.map_label = p.map_label;
s.map_package = p.map_package;
s.world = p.world; s.world = p.world;
s.join_map_authorized |= p.map_authorized; s.join_map_authorized |= p.map_authorized;
if (new_challenge) if (new_challenge)
@@ -1108,6 +1117,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
if (const auto *host_peer = find_peer(s, s.host_id); host_peer && host_peer->handshaken) { if (const auto *host_peer = find_peer(s, s.host_id); host_peer && host_peer->handshaken) {
auto ready = packet(s, PacketKind::world_ready, now); auto ready = packet(s, PacketKind::world_ready, now);
ready.world_ready = local.ready && !s.awaiting_map; ready.world_ready = local.ready && !s.awaiting_map;
ready.map_fetching = !ready.world_ready && s.fetching_since != 0;
send_required(s, s.host_id, encode_wire(ready)); send_required(s, s.host_id, encode_wire(ready));
s.last_world_ready = now; s.last_world_ready = now;
} }
@@ -125,7 +125,11 @@ void disconnect(Session &s, std::uint64_t id, const std::string &reason) {
s.roster_dirty |= s.peers[i].handshaken; s.roster_dirty |= s.peers[i].handshaken;
// Never admitted: it held a player slot meanwhile. An ID that keeps failing waits // Never admitted: it held a player slot meanwhile. An ID that keeps failing waits
// longer each time before its connection is taken again. // longer each time before its connection is taken again.
if (s.mode == Mode::host && !s.peers[i].handshaken) { // Not one that was let in and left while fetching the map (they turned the download
// down, or stopped it): that is no failed attempt.
if (s.mode == Mode::host && !s.peers[i].handshaken && s.peers[i].map_authorized && s.peers[i].fetching_since) {
logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: {} left without the map ({}).", id, reason);
} else if (s.mode == Mode::host && !s.peers[i].handshaken) {
const auto failures = s.join_backoff.failed(id, s.network_now); const auto failures = s.join_backoff.failed(id, s.network_now);
logging::log(logging::Level::info, logging::Channel::runtime, logging::log(logging::Level::info, logging::Channel::runtime,
"Multiplayer: {} did not finish joining ({}), attempt {}.", id, reason, failures); "Multiplayer: {} did not finish joining ({}), attempt {}.", id, reason, failures);
@@ -161,6 +165,7 @@ void send_required(Session &s, std::uint64_t id, const std::vector<std::uint8_t>
void send_world_state(Session &s, std::uint64_t now) { void send_world_state(Session &s, std::uint64_t now) {
auto state = packet(s, PacketKind::world_state, now); auto state = packet(s, PacketKind::world_state, now);
state.destination = s.map_name; state.destination = s.map_name;
state.map_package = host_map_package(s.map_name);
state.world_ready = s.host_world_ready; state.world_ready = s.host_world_ready;
const auto bytes = encode_wire(state); const auto bytes = encode_wire(state);
for (const auto &peer : active_peers(s)) for (const auto &peer : active_peers(s))
@@ -168,6 +168,7 @@ void publish(Session &s, const NativeFrame *local) {
view.direct_upload_limit = s.direct_upload.limit; view.direct_upload_limit = s.direct_upload.limit;
view.active = s.mode != Mode::off; view.active = s.mode != Mode::off;
view.hosting = s.mode == Mode::host; view.hosting = s.mode == Mode::host;
view.map_fetching = s.fetching_since != 0;
view.echo = s.mode == Mode::echo; view.echo = s.mode == Mode::echo;
view.local_id = t.local_id; view.local_id = t.local_id;
view.local_name = s.mode != Mode::off ? s.transport.name(t.local_id) : steam_social_snapshot()->local.name; view.local_name = s.mode != Mode::off ? s.transport.name(t.local_id) : steam_social_snapshot()->local.name;
@@ -1043,6 +1043,66 @@ void dedicated_server_codec() {
auto unnamed = changed_map; auto unnamed = changed_map;
unnamed.map_label.assign(max_member_name + 1, 'a'); unnamed.map_label.assign(max_member_name + 1, 'a');
check(reject(unnamed), "An overlong map name encoded"); check(reject(unnamed), "An overlong map name encoded");
// The Thunderstore package a map is from, beside its name.
check(arrived && arrived->map_package.empty(), "A map with no package arrived with one");
auto packaged = changed_map;
packaged.map_package = "Sandos-Vancouver_Plaza-2.0.0";
const auto with_package = decode_wire(encode_wire(packaged));
check(with_package && with_package->map_package == packaged.map_package && with_package->map_label == "Momentum Park",
"The map's package lost from a map change");
auto offered = packaged;
offered.kind = PacketKind::map_offer;
offered.map_package = "Sandos-Vancouver_Plaza";
const auto offer_arrived = decode_wire(encode_wire(offered));
check(offer_arrived && offer_arrived->map_package == "Sandos-Vancouver_Plaza" && offer_arrived->destination == offered.destination,
"The map's package lost from a map offer");
for (const char *bad : {"Sandos", "Sandos-", "-Plaza", "San dos-Plaza", "Sandos-Plaza-2.0", "Sandos-Plaza-2.0.0.1", "../x-y",
"Sandos-Pla/za", "Sandos-Plaza-1.2.3-4", "https://thunderstore.io/x-y"}) {
auto wrong = packaged;
wrong.map_package = bad;
check(reject(wrong) && !valid_map_package(bad), "A map package that is not one encoded");
}
auto long_package = packaged;
long_package.map_package = std::string(60, 'a') + "-" + std::string(60, 'b');
check(reject(long_package), "An overlong map package encoded");
check(valid_map_package("") && valid_map_package("A_1-b_2") && valid_map_package("A-B-10.20.30"), "A map package refused");
{
// A player fetching the map says so on what they already send while they wait.
Packet waiting;
waiting.kind = PacketKind::world_ready; waiting.session = 9; waiting.epoch = 10; waiting.source = server + 1; waiting.world = 2;
waiting.map = changed_map.map;
waiting.map_fetching = true;
const auto heard = decode_wire(encode_wire(waiting));
check(heard && heard->map_fetching && !heard->world_ready, "A map fetch notice lost from a readiness message");
waiting.map_fetching = false;
const auto plain = decode_wire(encode_wire(waiting));
check(plain && !plain->map_fetching && !plain->world_ready, "A readiness message gained a map fetch notice");
waiting.world_ready = true;
const auto ready = decode_wire(encode_wire(waiting));
check(ready && ready->world_ready && !ready->map_fetching, "Readiness lost");
waiting.map_fetching = true;
check(reject(waiting), "Ready and fetching at once encoded");
Packet asking;
asking.kind = PacketKind::map_request; asking.session = 9; asking.epoch = 10; asking.source = server + 1; asking.world = 1;
asking.map = changed_map.map;
asking.map_fetching = true;
const auto requested = decode_wire(encode_wire(asking));
check(requested && requested->map_fetching, "A map fetch notice lost from a map request");
asking.map_fetching = false;
const auto quiet = decode_wire(encode_wire(asking));
check(quiet && !quiet->map_fetching, "A map request gained a map fetch notice");
auto elsewhere = changed_map;
elsewhere.map_fetching = true;
check(reject(elsewhere), "A map fetch notice on a map change encoded");
}
check(map_package_name("Sandos-Vancouver_Plaza", "2.0.0") == "Sandos-Vancouver_Plaza-2.0.0" &&
map_package_name("Sandos-Vancouver_Plaza", "") == "Sandos-Vancouver_Plaza" &&
map_package_name("Sandos-Vancouver_Plaza", "two") == "Sandos-Vancouver_Plaza" &&
map_package_name("My Map", "1.0.0").empty() && map_package_name("bbcity", "1.0.0").empty() &&
map_package_name("Realeza-CODOL_RUST_2_LONG-1.0.0", "") == "Realeza-CODOL_RUST_2_LONG-1.0.0" &&
map_package_name("Realeza-CODOL_RUST_2_LONG-1.0.0", "1.2.0") == "Realeza-CODOL_RUST_2_LONG-1.2.0" &&
map_package_name("Team-Map-latest", "1.0.0").empty() && map_package_name("Team-Map-1.0.0-x", "").empty(),
"A mod folder named as a package, or not");
auto stray = maps; auto stray = maps;
stray.map_pool = {2}; stray.map_pool = {2};
check(reject(stray), "A map pool entry past the map list encoded"); check(reject(stray), "A map pool entry past the map list encoded");
+124 -10
View File
@@ -146,6 +146,12 @@ struct Hub {
std::array<bool, 8> keys_before{}; std::array<bool, 8> keys_before{};
ULONGLONG repeat_at{}; ULONGLONG repeat_at{};
std::string notice; std::string notice;
std::string joining_name; // the session a join was asked for from this page, for its card
// A join followed to its end, so one that did not get in can say why (failed_page).
// join_asked: when one was asked for here and has not shown in the model yet (GetTickCount64).
bool join_seen{}, join_fetching{}, join_cancelled{};
ULONGLONG join_asked{};
std::string failed, failed_name; // why the last join did not get in, and where to; empty: nothing to say
ULONGLONG notice_until{}; ULONGLONG notice_until{};
}; };
Hub &hub() { Hub &hub() {
@@ -187,6 +193,12 @@ bool send(Hub &h, const char *action, const std::string &argument = {}, const st
say(h, "Couldn't do that just now. Try again."); say(h, "Couldn't do that just now. Try again.");
return false; return false;
} }
// A join was asked for from this page.
void asked_join(Hub &h) {
h.join_asked = GetTickCount64();
h.join_cancelled = false;
h.failed.clear();
}
void wipe(std::string &secret) { void wipe(std::string &secret) {
SecureZeroMemory(secret.data(), secret.size()); SecureZeroMemory(secret.data(), secret.size());
secret.clear(); secret.clear();
@@ -355,7 +367,9 @@ void join(Hub &h, const Row &row) {
return say(h, "Enter the password, then join."); return say(h, "Enter the password, then join.");
} }
if (send(h, "join-lobby", std::to_string(row.lobby->id), row.lobby->password_required ? h.password : std::string())) { if (send(h, "join-lobby", std::to_string(row.lobby->id), row.lobby->password_required ? h.password : std::string())) {
say(h, "Joining " + view::caption(row.lobby->name, 40) + "..."); h.joining_name = view::caption(row.lobby->name, 40);
asked_join(h);
say(h, "Joining " + h.joining_name + "...");
wipe(h.password); wipe(h.password);
} }
} }
@@ -519,7 +533,7 @@ void browser_page(Ui &ui, ImVec2 a, ImVec2 z) {
const bool idle = !mp.active && !mp.lobby_joining; const bool idle = !mp.active && !mp.lobby_joining;
go |= button(ui, hash("join-code"), ImVec2(left, y), ImVec2(edge, y + tall), mp.lobby_joining ? "JOINING..." : idle ? "JOIN" : "LEAVE YOUR SESSION FIRST", go |= button(ui, hash("join-code"), ImVec2(left, y), ImVec2(edge, y + tall), mp.lobby_joining ? "JOINING..." : idle ? "JOIN" : "LEAVE YOUR SESSION FIRST",
Look::sticky, idle && !h.code.empty()); Look::sticky, idle && !h.code.empty());
if (go && idle && !h.code.empty() && send(h, "join", h.code, h.code_password)) say(h, "Joining..."), wipe(h.code_password); if (go && idle && !h.code.empty() && send(h, "join", h.code, h.code_password)) h.joining_name.clear(), asked_join(h), say(h, "Joining..."), wipe(h.code_password);
y += tall + 20 * u; y += tall + 20 * u;
label(draw, ui.body, 30 * u, ImVec2(left, y), edge, theme::muted, view::caption(!h.notice.empty() ? h.notice : mp.status, 96)); label(draw, ui.body, 30 * u, ImVec2(left, y), edge, theme::muted, view::caption(!h.notice.empty() ? h.notice : mp.status, 96));
if (button(ui, hash("code-back"), foot, ImVec2(edge, foot.y + 84 * u), "BACK TO THE LIST", Look::plain)) h.code_mode = false, h.focus_next = hash("code-open"); if (button(ui, hash("code-back"), foot, ImVec2(edge, foot.y + 84 * u), "BACK TO THE LIST", Look::plain)) h.code_mode = false, h.focus_next = hash("code-open");
@@ -807,6 +821,65 @@ void voice_page(Ui &ui, ImVec2 a, ImVec2 z) {
!h.notice.empty() ? h.notice : mp.voice.transmitting ? std::string("MICROPHONE TRANSMITTING") : mp.voice.status); !h.notice.empty() ? h.notice : mp.voice.transmitting ? std::string("MICROPHONE TRANSMITTING") : mp.voice.status);
} }
// A join under way, as a card over the middle of the page (which is dimmed behind it): where
// to, what is happening, and the way out.
void connecting_page(Ui &ui, ImVec2 a, ImVec2 z) {
auto &h = ui.h;
const auto &mp = ui.mp;
const float u = ui.u, width = 1500 * u, inset = 48 * u, tall = 100 * u;
const float height = inset + 28 * u + 30 * u + 64 * u + 30 * u + 34 * u + 40 * u + 14 * u + 44 * u + tall + inset;
const ImVec2 p(a.x + (z.x - a.x - width) * .5f, a.y + std::max(0.f, (z.y - a.y - height) * .4f)), q(p.x + width, p.y + height);
auto *draw = ui.draw;
draw->AddRectFilled(ImVec2(0, 0), ImGui::GetIO().DisplaySize, IM_COL32(0, 0, 0, 150));
skate_theme::rough_rect(draw, p, q, IM_COL32(26, 26, 26, 252), 0x4c0, u * 2);
const float left = p.x + inset, edge = q.x - inset;
float y = p.y + inset;
draw->AddText(ui.bold, 28 * u, ImVec2(left, y), theme::blue, "CONNECTING");
y += 28 * u + 30 * u;
const auto name = !h.joining_name.empty() ? h.joining_name : !mp.lobby_name.empty() ? view::caption(mp.lobby_name, 40) : std::string("Joining the session");
label(draw, ui.heading, 64 * u, ImVec2(left, y), edge, theme::paper, name);
y += 64 * u + 30 * u;
label(draw, ui.body, 34 * u, ImVec2(left, y), edge, theme::muted, view::caption(mp.status.empty() ? std::string("Connecting...") : mp.status, 100));
y += 34 * u + 40 * u;
// Not a measure of anything: a block going back and forth while it is under way.
draw->AddRectFilled(ImVec2(left, y), ImVec2(edge, y + 14 * u), skate_theme::tile_light, 4 * u);
const float at = static_cast<float>(std::fmod(ImGui::GetTime(), 2.0)), span = edge - left;
const float where = (at < 1.f ? at : 2.f - at) * span * .75f;
draw->AddRectFilled(ImVec2(left + where, y), ImVec2(left + where + span * .25f, y + 14 * u), theme::blue, 4 * u);
y += 14 * u + 44 * u;
const auto cancel = hash("join-cancel");
if (button(ui, cancel, ImVec2(left, y), ImVec2(edge, y + tall), "CANCEL", Look::plain) && send(h, "stop")) h.join_cancelled = true, say(h, "Cancelled.");
if (!h.focus) h.focus_next = cancel;
}
// A join that did not get in, in the connecting card's place: where to, why, and a button that
// puts it away.
void failed_page(Ui &ui, ImVec2 a, ImVec2 z) {
auto &h = ui.h;
const float u = ui.u, width = 1500 * u, inset = 48 * u, tall = 100 * u, size = 34 * u;
const float column = width - inset * 2;
const auto reason = view::caption(h.failed, 400);
const float lines = std::min(ui.body->CalcTextSizeA(size, FLT_MAX, column, reason.c_str()).y, size * 6.2f);
const float height = inset + 28 * u + 30 * u + 64 * u + 30 * u + lines + 44 * u + tall + inset;
const ImVec2 p(a.x + (z.x - a.x - width) * .5f, a.y + std::max(0.f, (z.y - a.y - height) * .4f)), q(p.x + width, p.y + height);
auto *draw = ui.draw;
draw->AddRectFilled(ImVec2(0, 0), ImGui::GetIO().DisplaySize, IM_COL32(0, 0, 0, 150));
skate_theme::rough_rect(draw, p, q, IM_COL32(26, 26, 26, 252), 0x4c0, u * 2);
const float left = p.x + inset, edge = q.x - inset;
float y = p.y + inset;
draw->AddText(ui.bold, 28 * u, ImVec2(left, y), skate_theme::danger, "COULDN'T CONNECT");
y += 28 * u + 30 * u;
label(draw, ui.heading, 64 * u, ImVec2(left, y), edge, theme::paper, h.failed_name.empty() ? std::string("The session") : h.failed_name);
y += 64 * u + 30 * u;
draw->PushClipRect(ImVec2(left, y), ImVec2(edge, y + lines), true);
draw->AddText(ui.body, size, ImVec2(left, y), theme::muted, reason.c_str(), nullptr, column);
draw->PopClipRect();
y += lines + 44 * u;
const auto ok = hash("join-failed-ok");
if (button(ui, ok, ImVec2(left, y), ImVec2(edge, y + tall), "OK", Look::plain)) h.failed.clear();
if (!h.focus) h.focus_next = ok;
}
// ---------------------------------------------------------------- Mod Options // ---------------------------------------------------------------- Mod Options
enum ToolsTab : int { official_tab, custom_tab, world_tab, parks_tab, player_tab, online_tab, visuals_tab, binds_tab }; enum ToolsTab : int { official_tab, custom_tab, world_tab, parks_tab, player_tab, online_tab, visuals_tab, binds_tab };
@@ -1305,7 +1378,9 @@ void draw_hub_page() {
const float lower = (down - layout_height) * .05f; // how far a taller window moves the page down const float lower = (down - layout_height) * .05f; // how far a taller window moves the page down
const bool others = s.visible.load() || s.console_visible.load() || s.editor_visible.load() || s.chat_visible.load(); const bool others = s.visible.load() || s.console_visible.load() || s.editor_visible.load() || s.chat_visible.load();
Ui ui{h, ImGui::GetBackgroundDrawList(), s.menu.bold ? s.menu.bold : ImGui::GetFont(), s.menu.body ? s.menu.body : ImGui::GetFont(), nullptr, u, io.MousePos, Ui ui{h, ImGui::GetBackgroundDrawList(), s.menu.bold ? s.menu.bold : ImGui::GetFont(), s.menu.body ? s.menu.body : ImGui::GetFont(), nullptr, u, io.MousePos,
!others && game_window_foreground(s.window.load()), false, false, false, 0, h.model, {}}; // (A card that asks something over the page has the input while it asks.)
!others && !s.prompt_input_active.load(std::memory_order_relaxed) && game_window_foreground(s.window.load()),
false, false, false, 0, h.model, {}};
ui.heading = s.menu.heading ? s.menu.heading : ui.bold; ui.heading = s.menu.heading ? s.menu.heading : ui.bold;
ui.clicked = ui.input && ImGui::IsMouseClicked(ImGuiMouseButton_Left); ui.clicked = ui.input && ImGui::IsMouseClicked(ImGuiMouseButton_Left);
ui.doubled = ui.input && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left); ui.doubled = ui.input && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left);
@@ -1315,14 +1390,43 @@ void draw_hub_page() {
if (h.rows_stale) rebuild(h); if (h.rows_stale) rebuild(h);
if (h.notice_until && GetTickCount64() >= h.notice_until) h.notice.clear(), h.notice_until = 0; if (h.notice_until && GetTickCount64() >= h.notice_until) h.notice.clear(), h.notice_until = 0;
// Going into a session shows it; leaving one goes back to the browser. // Going into a session shows it; leaving one goes back to the browser.
if (mp.active != h.was_active) { // In a session, as far as the tabs go: not one that is still waiting on a map being fetched,
h.was_active = mp.active; // which the player may yet turn down.
h.tab = mp.active ? Tab::session : Tab::browser; const bool in_session = mp.active && !mp.lobby_joining && !mp.map_fetching;
// A join under way (connecting, the host's map loading): a card over the page says so until
// the player is in, or calls it off. A map that has to be fetched first has a card of its
// own (map_download_card.cpp), which takes this one's place.
const bool connecting = !mp.hosting && !mp.echo && mp.lobby_joining && !mp.map_fetching;
// A join is followed to its end. One that ends with the player not in, and not by their own
// choice (the card's CANCEL, or a map they were asked about: its card says its own piece),
// says why. One asked for here that never shows in the model was turned down at once.
if (!mp.hosting && !mp.echo && (mp.lobby_joining || mp.map_fetching)) {
if (!h.join_seen) h.failed.clear();
h.join_seen = true;
h.join_fetching = mp.map_fetching;
h.join_asked = 0;
} else if (mp.active) {
h.join_seen = h.join_cancelled = false;
h.join_asked = 0;
} else if (h.join_seen || (h.join_asked && GetTickCount64() - h.join_asked > 2000)) {
if (!h.join_cancelled && !(h.join_seen && h.join_fetching)) {
const bool said = !mp.status.empty() && mp.status != MultiplayerModel{}.status;
h.failed = said ? mp.status : std::string("The session did not answer. It may be full, closed or on another version of ReSkate.");
h.failed_name = !h.joining_name.empty() ? h.joining_name : view::caption(mp.lobby_name, 40);
h.notice.clear(), h.notice_until = 0;
h.focus = 0;
}
h.join_seen = h.join_cancelled = false;
h.join_asked = 0;
}
if (in_session != h.was_active) {
h.was_active = in_session;
h.tab = in_session ? Tab::session : Tab::browser;
h.selected_player.clear(); h.selected_player.clear();
} }
if ((h.tab == Tab::session || h.tab == Tab::voice) && !mp.active) h.tab = Tab::browser; if ((h.tab == Tab::session || h.tab == Tab::voice) && !in_session) h.tab = Tab::browser;
const bool runs_session = mp.active && (mp.hosting || mp.server_admin); const bool runs_session = in_session && (mp.hosting || mp.server_admin);
if (h.tab == Tab::settings && !runs_session) h.tab = mp.active ? Tab::session : Tab::browser; if (h.tab == Tab::settings && !runs_session) h.tab = in_session ? Tab::session : Tab::browser;
// ---- the controller and the keys, watched while the game has them // ---- the controller and the keys, watched while the game has them
if (ui.input) DingoSDKOverlayReadControllerInput(&ui.pad, true); if (ui.input) DingoSDKOverlayReadControllerInput(&ui.pad, true);
@@ -1388,7 +1492,7 @@ void draw_hub_page() {
} else { } else {
tabs = {{static_cast<int>(Tab::browser), "SERVER BROWSER"}, {static_cast<int>(Tab::host), "HOST LOBBY"}}; tabs = {{static_cast<int>(Tab::browser), "SERVER BROWSER"}, {static_cast<int>(Tab::host), "HOST LOBBY"}};
// The session and its voice chat are only there in one. // The session and its voice chat are only there in one.
if (mp.active) tabs.emplace_back(static_cast<int>(Tab::session), "CURRENT SESSION"), tabs.emplace_back(static_cast<int>(Tab::voice), "VOICE CHAT"); if (in_session) tabs.emplace_back(static_cast<int>(Tab::session), "CURRENT SESSION"), tabs.emplace_back(static_cast<int>(Tab::voice), "VOICE CHAT");
// And its settings only for whoever runs it. // And its settings only for whoever runs it.
if (runs_session) tabs.emplace_back(static_cast<int>(Tab::settings), "SESSION SETTINGS"); if (runs_session) tabs.emplace_back(static_cast<int>(Tab::settings), "SESSION SETTINGS");
} }
@@ -1425,6 +1529,10 @@ void draw_hub_page() {
if (!tools) h.tab = static_cast<Tab>(multiplayer_tab); if (!tools) h.tab = static_cast<Tab>(multiplayer_tab);
} }
// Under the connecting card the page is still drawn, to look at and not to use.
const bool page_input = ui.input, page_clicked = ui.clicked, page_doubled = ui.doubled;
const bool failed = !connecting && !h.failed.empty() && !in_session;
if ((connecting || failed) && !tools) ui.input = ui.clicked = ui.doubled = false;
if (tools) tools_page(ui, a, z); if (tools) tools_page(ui, a, z);
else switch (h.tab) { else switch (h.tab) {
case Tab::browser: browser_page(ui, a, z); break; case Tab::browser: browser_page(ui, a, z); break;
@@ -1433,6 +1541,12 @@ void draw_hub_page() {
case Tab::voice: voice_page(ui, a, z); break; case Tab::voice: voice_page(ui, a, z); break;
case Tab::settings: settings_page(ui, a, z); break; case Tab::settings: settings_page(ui, a, z); break;
} }
if ((connecting || failed) && !tools) {
ui.input = page_input, ui.clicked = page_clicked, ui.doubled = page_doubled;
h.items.clear(); // nothing of the page takes the focus: only the card's way out
if (connecting) connecting_page(ui, a, z);
else failed_page(ui, a, z);
}
if (ui.input) h.pad_before = ui.pad.buttons; if (ui.input) h.pad_before = ui.pad.buttons;
// A press that found nothing to land on is forgotten; the focus stays on a control that is // A press that found nothing to land on is forgotten; the focus stays on a control that is
// still there, or goes to the first one. // still there, or goes to the first one.
+124 -1
View File
@@ -18,9 +18,20 @@ namespace dingosdk::overlay::detail {
// Window messages do not cover the game's independently polled input devices. // Window messages do not cover the game's independently polled input devices.
// Only the overlay's own Win32 backend and binding reader may bypass these gates. // Only the overlay's own Win32 backend and binding reader may bypass these gates.
thread_local HRAWINPUT noted_raw_input = nullptr; thread_local HRAWINPUT noted_raw_input = nullptr;
namespace {
std::atomic<unsigned> prompt_xinput{}, prompt_direct{}, prompt_hid{}, prompt_raw{};
// A read the game made while a card held its input: counted, by the way it read.
void note_prompt_read(std::atomic<unsigned>& count) {
if (!overlay_input_access && state().prompt_input_active.load(std::memory_order_relaxed)) count.fetch_add(1, std::memory_order_relaxed);
}
}
PromptReads take_prompt_reads() noexcept {
return {prompt_xinput.exchange(0), 0, prompt_direct.exchange(0), prompt_hid.exchange(0), prompt_raw.exchange(0)};
}
bool block_polled_input() { bool block_polled_input() {
const auto error = GetLastError(); const auto error = GetLastError();
const bool capture = !overlay_input_access && (owns_menu_cursor(state()) || state().freecam_controller_active.load(std::memory_order_relaxed)); const bool capture = !overlay_input_access && (owns_menu_cursor(state()) || state().freecam_controller_active.load(std::memory_order_relaxed) ||
state().prompt_input_active.load(std::memory_order_relaxed));
SetLastError(error); SetLastError(error);
return capture; return capture;
} }
@@ -37,6 +48,10 @@ struct InputCaptureHooks {
Hook register_raw; Hook register_raw;
Hook async_key, key, keyboard, raw_data, raw_buffer, capture, release_capture, cursor, show_cursor, physical_cursor, cursor_position; Hook async_key, key, keyboard, raw_data, raw_buffer, capture, release_capture, cursor, show_cursor, physical_cursor, cursor_position;
std::array<Hook, 5> xinput_state, xinput_extended, xinput_keystroke; std::array<Hook, 5> xinput_state, xinput_extended, xinput_keystroke;
// Hooks other programs put in front of those, hooked in turn (keep_xinput_capture_first).
static constexpr std::size_t front_slots = 8;
std::array<Hook, front_slots> xinput_front;
std::mutex front_mutex;
Hook direct_create; Hook direct_create;
std::array<Hook, 2> device_create; std::array<Hook, 2> device_create;
// Mouse/keyboard and A/W devices may have distinct implementations. Slots // Mouse/keyboard and A/W devices may have distinct implementations. Slots
@@ -182,6 +197,111 @@ BOOL WINAPI captured_close_handle(HANDLE file) {
return original<decltype(&CloseHandle)>(h.close_handle)(file); return original<decltype(&CloseHandle)>(h.close_handle)(file);
} }
// Where the jump at an entry point goes, through any stubs on the way: the first address inside
// a module, and that module. Null when the entry point is not hooked (or cannot be read).
struct Landing { void* at{}; HMODULE module{}; };
Landing hook_landing(void* entry) {
const auto self = GetCurrentProcess();
const auto read = [&](const void* at, void* out, std::size_t size) {
SIZE_T got{};
return ReadProcessMemory(self, at, out, size, &got) && got == size;
};
auto* at = static_cast<std::uint8_t*>(entry);
for (int hop = 0; hop < 8; ++hop) {
std::uint8_t code[16]{};
if (!read(at, code, sizeof(code))) return {};
std::uint8_t* next{};
if (code[0] == 0xe9) {
std::int32_t offset; std::memcpy(&offset, code + 1, 4);
next = at + 5 + offset;
} else if (code[0] == 0xff && code[1] == 0x25) {
std::int32_t offset; std::memcpy(&offset, code + 2, 4);
if (!read(at + 6 + offset, &next, sizeof(next))) return {};
} else if (code[0] == 0x48 && code[1] == 0xb8 && code[10] == 0xff && code[11] == 0xe0) {
std::memcpy(&next, code + 2, 8);
} else return {};
at = next;
HMODULE module{};
if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(at), &module) && module) return {at, module};
}
return {};
}
HMODULE module_of(const void* address) {
HMODULE module{};
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(address), &module);
return module;
}
std::string module_name(HMODULE module) {
wchar_t path[MAX_PATH]{};
GetModuleFileNameW(module, path, MAX_PATH);
const wchar_t* name = path;
for (const wchar_t* c = path; *c; ++c) if (*c == L'\\') name = c + 1;
std::string text;
for (; *name; ++name) text += static_cast<char>(*name);
return text;
}
std::string xinput_entry_owners() {
std::string text;
const auto& h = input_hooks();
const auto owner = [](void* entry) {
const auto landing = hook_landing(entry);
return landing.module ? module_name(landing.module) : std::string("not hooked");
};
constexpr std::array names{"xinput1_4", "xinput1_3", "xinput9_1_0", "xinput1_2", "xinput1_1"};
for (std::size_t i = 0; i < names.size(); ++i) {
if (h.xinput_state[i].target) text += (text.empty() ? "" : ", ") + std::string(names[i]) + " " + owner(h.xinput_state[i].target);
if (h.xinput_extended[i].target) text += (text.empty() ? "" : ", ") + std::string(names[i]) + " (extended) " + owner(h.xinput_extended[i].target);
}
return text.empty() ? "none" : text;
}
// Another program's hook on XInput that was put in front of the capture's own after it was
// installed (Steam's overlay does, for Steam Input, and answers the game with its own pad
// without going on to XInput): the capture then never sees the game's reads, and a menu that
// holds the game's input does not hold its controller. That hook's own function is hooked
// then, so the capture is in front of it again.
template<std::size_t I>
DWORD WINAPI captured_front(DWORD user, XINPUT_STATE* output) {
const bool capture = block_polled_input();
note_prompt_read(prompt_xinput);
DWORD result;
{ // It may go on to XInput's own entry point, which is hooked as well.
OverlayInputAccess access;
result = original<DWORD(WINAPI*)(DWORD, XINPUT_STATE*)>(input_hooks().xinput_front[I])(user, output);
}
if (result == ERROR_SUCCESS && output && capture) {
output->Gamepad = {};
output->dwPacketNumber ^= 0x80000000u;
}
return result;
}
void keep_xinput_capture_first() {
auto& h = input_hooks();
static const HMODULE self = module_of(&keep_xinput_capture_first);
const auto detours = []<std::size_t... I>(std::index_sequence<I...>) {
return std::array{reinterpret_cast<void*>(captured_front<I>)...};
}(std::make_index_sequence<InputCaptureHooks::front_slots>{});
std::lock_guard lock(h.front_mutex);
const auto guard = [&](void* entry) {
if (!entry) return;
const auto landing = hook_landing(entry);
// Not hooked over, the capture's own, or a jump inside XInput itself.
if (!landing.at || landing.module == self || landing.module == module_of(entry)) return;
for (const auto& hook : h.xinput_front) if (hook.target == landing.at) return;
for (std::size_t slot = 0; slot < h.xinput_front.size(); ++slot) {
if (h.xinput_front[slot].target) continue;
const bool okay = install(h.xinput_front[slot], landing.at, detours[slot]);
logging::printf(okay ? logging::Level::info : logging::Level::warning, logging::Channel::input,
okay ? "XInput is answered by %s ahead of ReSkate: its reads are now held with the rest of the game's input."
: "XInput is answered by %s ahead of ReSkate, and that could not be hooked: a controller may reach the game behind a menu.",
module_name(landing.module).c_str());
return;
}
};
for (std::size_t i = 0; i < h.xinput_state.size(); ++i) guard(h.xinput_state[i].target), guard(h.xinput_extended[i].target);
}
SHORT WINAPI captured_async_key(int key) { SHORT WINAPI captured_async_key(int key) {
const auto value = original<SHORT(WINAPI*)(int)>(input_hooks().async_key)(key); const auto value = original<SHORT(WINAPI*)(int)>(input_hooks().async_key)(key);
return block_polled_input() ? 0 : value; return block_polled_input() ? 0 : value;
@@ -299,6 +419,7 @@ UINT WINAPI captured_raw_buffer(PRAWINPUT data, PUINT size, UINT header_size) {
const auto result = original<UINT(WINAPI*)(PRAWINPUT, PUINT, UINT)>( const auto result = original<UINT(WINAPI*)(PRAWINPUT, PUINT, UINT)>(
input_hooks().raw_buffer)(data, size, header_size); input_hooks().raw_buffer)(data, size, header_size);
const bool capture = result != UINT(-1) && data && block_polled_input(); const bool capture = result != UINT(-1) && data && block_polled_input();
note_prompt_read(prompt_raw);
if (result != UINT(-1) && data && header_size == sizeof(RAWINPUTHEADER)) { if (result != UINT(-1) && data && header_size == sizeof(RAWINPUTHEADER)) {
// Skate drains its mouse here; read the packets on the way past. // Skate drains its mouse here; read the packets on the way past.
// NEXTRAWINPUTBLOCK: records are 8-byte aligned on x64. // NEXTRAWINPUTBLOCK: records are 8-byte aligned on x64.
@@ -443,6 +564,7 @@ template<std::size_t I, bool Extended = false>
DWORD WINAPI captured_xinput(DWORD user, XINPUT_STATE* output) { DWORD WINAPI captured_xinput(DWORD user, XINPUT_STATE* output) {
const auto& hook = Extended ? input_hooks().xinput_extended[I] : input_hooks().xinput_state[I]; const auto& hook = Extended ? input_hooks().xinput_extended[I] : input_hooks().xinput_state[I];
const bool capture = block_polled_input(); const bool capture = block_polled_input();
note_prompt_read(prompt_xinput);
DWORD result; DWORD result;
{ // XInput exports can forward through another hooked XInput export. { // XInput exports can forward through another hooked XInput export.
OverlayInputAccess access; OverlayInputAccess access;
@@ -483,6 +605,7 @@ template<std::size_t I>
HRESULT STDMETHODCALLTYPE captured_device_state(void* device, DWORD size, LPVOID data) { HRESULT STDMETHODCALLTYPE captured_device_state(void* device, DWORD size, LPVOID data) {
const auto result = original<HRESULT(STDMETHODCALLTYPE*)(void*, DWORD, LPVOID)>( const auto result = original<HRESULT(STDMETHODCALLTYPE*)(void*, DWORD, LPVOID)>(
input_hooks().device_state[I])(device, size, data); input_hooks().device_state[I])(device, size, data);
note_prompt_read(prompt_direct);
if (SUCCEEDED(result) && data && block_polled_input()) { if (SUCCEEDED(result) && data && block_polled_input()) {
std::memset(data, 0, size); std::memset(data, 0, size);
std::lock_guard lock(input_hooks().formats_mutex); std::lock_guard lock(input_hooks().formats_mutex);
+14
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <Windows.h> #include <Windows.h>
#include <string>
namespace dingosdk::overlay::detail { namespace dingosdk::overlay::detail {
extern thread_local unsigned overlay_input_access; extern thread_local unsigned overlay_input_access;
struct OverlayInputAccess { struct OverlayInputAccess {
@@ -11,6 +12,19 @@ struct OverlayInputAccess {
// through GetRawInputData is then not noted a second time. // through GetRawInputData is then not noted a second time.
extern thread_local HRAWINPUT noted_raw_input; extern thread_local HRAWINPUT noted_raw_input;
bool block_polled_input(); bool block_polled_input();
// How the game read its controllers while a card held its input (prompt_input_active), for the
// log when the hold ends: which of the ways the overlay can keep input from it the game used.
struct PromptReads {
unsigned xinput{}, xinput_other_thread{}, direct_input{}, hid{}, raw{};
};
PromptReads take_prompt_reads() noexcept;
// For the same log: whose code XInput's entry points lead to first now ("ReSkate.dll" while
// the capture's hooks are the first there). Another program's hook put in front of them
// (Steam's or Discord's overlay) can answer the game itself and never come here.
std::string xinput_entry_owners();
// Puts the capture back in front of such a hook (it hooks that hook's function). Cheap when
// there is nothing to do; called about once a second while a controller is being read.
void keep_xinput_capture_first();
bool install_input_capture(); bool install_input_capture();
// Skate's controller mode registers the mouse with RIDEV_NOLEGACY, which stops // Skate's controller mode registers the mouse with RIDEV_NOLEGACY, which stops
// the Windows cursor from moving. While the overlay owns the pointer the mouse // the Windows cursor from moving. While the overlay owns the pointer the mouse
+319
View File
@@ -0,0 +1,319 @@
#include "overlay_internal.h"
#include "Engine/Game/Input/controller_bindings.h"
#include <format>
#include <mutex>
#include <wincodec.h>
// The card for a map a multiplayer session needs and this PC does not have
// (Extension/Assets/map_download.h), laid out as the mod's page is: its icon, name, author,
// version and description, then what is being asked or done. It asks whether to download it,
// in the middle of the screen with the controls under it as the game's own menus have them.
// Once the player has said yes it becomes a small card at the right edge, with the download,
// the install and the apply, so they can skate on while they wait; it goes when the map is in
// the game and starts loading. Both in the look of ReSkate's pages in the pause menu (hub_page.cpp).
//
// Answered by a controller (D-pad to pick, A to take the pick, B for no), the keyboard (arrow
// keys, Enter, Esc), the vote binds, or a click: the overlay has the pointer while
// the card asks (State::prompt_pointer). The runtime reads those and holds the game's input meanwhile.
//
// The icon is the package's own, a PNG the downloader hands over (set_map_download_icon). It
// is decoded here and goes to the GPU from the render thread (overlay_render.cpp), into the
// overlay's second texture slot; the first is ImGui's atlas, which is filled once at startup.
namespace dingosdk::overlay {
namespace {
using Microsoft::WRL::ComPtr;
std::atomic<MapDownloadFeed> feed{};
std::atomic<MapDownloadAnswer> answer{};
std::mutex image_mutex;
detail::CardPixels current; // the icon in use, kept to upload again after a device reset
bool waiting{}; // `current` has not gone to the GPU yet
unsigned image_serial{};
std::atomic<ImTextureID> image{}; // on the GPU and drawable; 0 for none
// Straight-alpha RGBA, at most 512 pixels a side (an icon is 256).
bool decode(const std::string& bytes, detail::CardPixels& out) {
const HRESULT com = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
bool ok{};
{
ComPtr<IWICImagingFactory> factory;
ComPtr<IWICStream> stream;
ComPtr<IWICBitmapDecoder> decoder;
ComPtr<IWICBitmapFrameDecode> frame;
ComPtr<IWICFormatConverter> converter;
UINT width{}, height{};
if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&factory))) &&
SUCCEEDED(factory->CreateStream(&stream)) &&
SUCCEEDED(stream->InitializeFromMemory(reinterpret_cast<BYTE*>(const_cast<char*>(bytes.data())), static_cast<DWORD>(bytes.size()))) &&
SUCCEEDED(factory->CreateDecoderFromStream(stream.Get(), nullptr, WICDecodeMetadataCacheOnDemand, &decoder)) &&
SUCCEEDED(decoder->GetFrame(0, &frame)) && SUCCEEDED(frame->GetSize(&width, &height)) && width && height &&
width <= 512 && height <= 512 && SUCCEEDED(factory->CreateFormatConverter(&converter)) &&
SUCCEEDED(converter->Initialize(frame.Get(), GUID_WICPixelFormat32bppRGBA, WICBitmapDitherTypeNone, nullptr, 0,
WICBitmapPaletteTypeCustom))) {
out.rgba.resize(static_cast<std::size_t>(width) * height * 4);
out.width = width;
out.height = height;
ok = SUCCEEDED(converter->CopyPixels(nullptr, width * 4, static_cast<UINT>(out.rgba.size()), out.rgba.data()));
}
}
if (SUCCEEDED(com)) CoUninitialize();
return ok;
}
}
void set_map_download_feed(MapDownloadFeed value) noexcept { feed.store(value); }
void set_map_download_answer(MapDownloadAnswer value) noexcept { answer.store(value); }
void set_map_download_icon(const std::string& png) noexcept {
try {
detail::CardPixels pixels;
const bool decoded = !png.empty() && png.size() <= 4 * 1024 * 1024 && decode(png, pixels);
std::lock_guard lock(image_mutex);
image.store(0);
pixels.serial = ++image_serial;
current = decoded ? std::move(pixels) : detail::CardPixels{};
current.serial = image_serial;
waiting = decoded;
} catch (...) {}
}
} // namespace dingosdk::overlay
namespace dingosdk::overlay::detail {
bool take_card_pixels(CardPixels& out) {
std::lock_guard lock(image_mutex);
if (!waiting) return false;
waiting = false;
out = current; // (kept: a device reset uploads it again)
return true;
}
void card_image_uploaded(ImTextureID texture, unsigned serial) {
std::lock_guard lock(image_mutex);
if (serial == image_serial) image.store(texture);
}
void card_image_lost() {
std::lock_guard lock(image_mutex);
image.store(0);
waiting = !current.rgba.empty();
}
namespace {
MapDownloadCard card; // this frame's, read by map_download_pending
ImU32 faded(ImU32 colour, float alpha) {
const auto base = static_cast<float>((colour >> IM_COL32_A_SHIFT) & 0xFF) / 255.0f;
return (colour & ~IM_COL32_A_MASK) | (static_cast<ImU32>(base * std::clamp(alpha, 0.0f, 1.0f) * 255.0f + 0.5f) << IM_COL32_A_SHIFT);
}
std::string megabytes(std::uint64_t bytes) {
const auto mb = static_cast<double>(bytes) / (1024.0 * 1024.0);
return mb >= 1024.0 ? std::format("{:.2f} GB", mb / 1024.0) : std::format("{:.0f} MB", mb);
}
}
bool map_download_pending() {
const auto read = feed.load();
card = read ? read() : MapDownloadCard{};
// (Nothing is shown once the map is in the game: its loading has the game's own screen.)
if (card.stage == 5) card.stage = 0;
state().prompt_pointer.store(card.stage == 1);
return card.stage != 0;
}
namespace {
// After the player said yes: a small card at the right edge, out of the way of skating, with
// what is being fetched and how far it is.
void draw_progress(State& s) {
auto* bold = s.menu.bold ? s.menu.bold : ImGui::GetFont();
auto* body = s.menu.body ? s.menu.body : ImGui::GetFont();
const auto display = ImGui::GetIO().DisplaySize;
const float u = std::min(display.x / 3840.0f, display.y / 2160.0f);
const bool downloading = card.stage == 2, applying = card.stage == 4;
const char* title = downloading ? "DOWNLOADING MAP" : card.stage == 3 ? "INSTALLING MAP" : "APPLYING MAP";
const std::string status = downloading ? (card.total ? megabytes(card.received) + " of " + megabytes(card.total) : megabytes(card.received))
: card.stage == 3 ? std::string("Putting it in your Mods folder...")
: card.step_name.empty() ? std::string("Adding it to the game...") : card.step_name + "...";
const float width = 820 * u, inset = 30 * u, icon = 132 * u, between = 26 * u, bar = 12 * u;
const float label_size = 24 * u, name_size = 38 * u, text_size = 26 * u;
const float column = width - inset * 2 - icon - between;
const float height = inset + std::max(icon, label_size + 12 * u + name_size + 18 * u + bar + 14 * u + text_size) + inset;
// Beside where a vote's card goes (the middle of the right edge): above it.
const ImVec2 min(std::floor(display.x - 48 * u - width), std::floor(display.y * 0.2f)), max(min.x + width, min.y + height);
auto* draw = ImGui::GetForegroundDrawList();
skate_theme::rough_rect(draw, min, max, IM_COL32(26, 26, 26, 235), 0x4d2, u * 2);
const float left = min.x + inset, top = min.y + inset;
const ImVec2 icon_min(left, top), icon_max(left + icon, top + icon);
if (const auto texture = image.load()) draw->AddImage(texture, icon_min, icon_max);
else skate_theme::rough_rect(draw, icon_min, icon_max, skate_theme::tile_light, 0x1c1, u * 2);
const float x = left + icon + between, right = max.x - inset;
float y = top;
draw->AddText(bold, label_size, ImVec2(x, y), theme::blue, title);
y += label_size + 12 * u;
draw->PushClipRect(ImVec2(x, y - name_size), ImVec2(right, y + name_size * 2), true);
draw->AddText(bold, name_size, ImVec2(x, y), theme::paper, card.map.c_str());
draw->PopClipRect();
y += name_size + 18 * u;
const float share = downloading ? (card.total ? static_cast<float>(card.received) / static_cast<float>(card.total) : -1.0f)
: (applying && card.steps ? static_cast<float>(card.step) / static_cast<float>(card.steps) : -1.0f);
draw->AddRectFilled(ImVec2(x, y), ImVec2(right, y + bar), skate_theme::tile_light, 4 * u);
if (share >= 0.0f) draw->AddRectFilled(ImVec2(x, y), ImVec2(x + column * std::clamp(share, 0.0f, 1.0f), y + bar), theme::blue, 4 * u);
else {
// How far is not known: a block going back and forth.
const float at = static_cast<float>(std::fmod(ImGui::GetTime(), 2.0));
const float where = (at < 1.0f ? at : 2.0f - at) * (column * 0.75f);
draw->AddRectFilled(ImVec2(x + where, y), ImVec2(x + where + column * 0.25f, y + bar), theme::blue, 4 * u);
}
y += bar + 14 * u;
draw->PushClipRect(ImVec2(x, y - text_size), ImVec2(right, y + text_size * 2), true);
draw->AddText(body, text_size, ImVec2(x, y), theme::muted, status.c_str());
draw->PopClipRect();
}
}
void draw_map_download() {
if (!card.stage) return;
auto& s = state();
if (card.stage != 1) return draw_progress(s);
// Drawn as the pages of the game's pause menu are (hub_page.cpp): the same unit (one of
// the game's 3840 by 2160), brushed heading, hand-cut tiles, the yellow note for what to
// press and the keycaps under it.
auto* bold = s.menu.bold ? s.menu.bold : ImGui::GetFont();
auto* body = s.menu.body ? s.menu.body : ImGui::GetFont();
auto* heading = s.menu.heading ? s.menu.heading : bold;
const auto display = ImGui::GetIO().DisplaySize;
const float u = std::min(display.x / 3840.0f, display.y / 2160.0f);
constexpr ImU32 sticky = IM_COL32(236, 215, 108, 255), sticky_dim = IM_COL32(150, 140, 86, 255);
const bool asking = card.stage == 1, downloading = card.stage == 2, applying = card.stage == 4;
const bool clickable = asking || s.visible.load() || s.console_visible.load();
const char* host = card.server ? "server" : "host";
const auto width_of = [](ImFont* font, float size, const std::string& text) { return font->CalcTextSizeA(size, FLT_MAX, 0, text.c_str()).x; };
const char* title = asking ? (card.moved ? "MAP CHANGED" : "MAP NEEDED") : downloading ? "DOWNLOADING MAP" : card.stage == 3 ? "INSTALLING MAP"
: applying ? "APPLYING MAP" : "JOINING";
// Why it is asked: the session moved here with the player in it, or the player is joining.
const auto why = card.moved ? std::format("The {} changed to this map, and you need it to stay in the session.", host)
: std::format("The {} is on this map, and you need it to join.", host);
const std::string status = asking ? why + (card.installed ? " You have it, switched off. Switch it on?" : " Download it?")
: downloading ? (card.total ? megabytes(card.received) + " of " + megabytes(card.total) : megabytes(card.received))
: card.stage == 3 ? std::string("Putting it in your Mods folder...")
: applying ? (card.step_name.empty() ? std::string("Adding it to the game...") : card.step_name + "...")
: std::string("Loading the map...");
struct Fact { const char* name; std::string value; };
std::vector<Fact> facts{{"Author", card.author}};
if (!card.version.empty()) facts.push_back({"Version", card.version});
facts.push_back({"From", card.installed ? std::string("Your Mods folder, switched off") : std::string("Thunderstore")});
// The controls under the card: the pad's own names for its buttons when one is connected.
dingosdk::ControllerInput pad;
DingoSDKOverlayReadControllerInput(&pad, true);
struct Hint { std::string key; const char* what; };
std::vector<Hint> hints;
if (asking) {
hints.push_back({pad.available ? std::string("D-pad") : std::string("< >"), "Pick"});
hints.push_back({pad.available ? dingosdk::controller_combo_label(0x1000, pad.style) : std::string("Enter"), "Select"});
hints.push_back({pad.available ? dingosdk::controller_combo_label(0x2000, pad.style) : std::string("Esc"), "Cancel"});
}
const float key_scale = u * 2, hint_gap = 36 * u;
float hints_width{};
for (const auto& hint : hints)
hints_width += width_of(bold, 12 * key_scale, hint.key) + 20 * key_scale + width_of(bold, 14 * key_scale, hint.what);
if (!hints.empty()) hints_width += hint_gap * static_cast<float>(hints.size() - 1);
const float width = 1500 * u, inset = 44 * u, icon = 300 * u, between = 44 * u, tall = 110 * u, bar = 14 * u;
const float inner = width - inset * 2, column = inner - icon - between;
const float label_size = 28 * u, name_size = 64 * u, fact_size = 34 * u, text_size = 32 * u;
const auto name_extent = heading->CalcTextSizeA(name_size, FLT_MAX, column, card.map.c_str());
const auto described = card.description.empty() ? ImVec2{} : body->CalcTextSizeA(text_size, FLT_MAX, inner, card.description.c_str());
const float beside = name_extent.y + 22 * u + static_cast<float>(facts.size()) * 54 * u;
const float top_block = std::max(icon, beside);
const auto status_extent = body->CalcTextSizeA(text_size, FLT_MAX, inner, status.c_str());
const bool progress = downloading || applying;
float height = inset + label_size + 26 * u + top_block + inset + status_extent.y + inset;
if (!card.description.empty()) height += described.y + 36 * u;
if (progress) height += bar + 30 * u;
if (asking) height += 30 * u + tall;
if (downloading) height += 16 * u + label_size;
const float under = hints.empty() ? 0.0f : 34 * u + 20 * key_scale;
// In the middle while it asks; near the top, out of the way, while the player skates on.
const float top = asking ? (display.y - height - under) * 0.5f : 150 * u;
const ImVec2 min(std::floor((display.x - width) * 0.5f), std::floor(top)), max(min.x + width, min.y + height);
auto* draw = ImGui::GetForegroundDrawList();
// While it asks, what is behind is dimmed: the pause menu takes no input then, nor the game.
if (asking) draw->AddRectFilled(ImVec2(0, 0), display, IM_COL32(0, 0, 0, s.hub_pointer ? 170 : 110));
skate_theme::rough_rect(draw, min, max, IM_COL32(26, 26, 26, 250), 0x4d1, u * 2);
const float left = min.x + inset, right = max.x - inset;
float y = min.y + inset;
draw->AddText(bold, label_size, ImVec2(left, y), theme::blue, title);
y += label_size + 26 * u;
// The icon, or a plain tile until it is here (and for a mod that has none).
const ImVec2 icon_min(left, y), icon_max(left + icon, y + icon);
if (const auto texture = image.load()) draw->AddImage(texture, icon_min, icon_max);
else {
skate_theme::rough_rect(draw, icon_min, icon_max, skate_theme::tile_light, 0x1c0, u * 2);
const std::string mark = "MAP";
draw->AddText(heading, 56 * u, ImVec2(left + (icon - width_of(heading, 56 * u, mark)) * 0.5f, y + (icon - 56 * u) * 0.5f), theme::muted, mark.c_str());
}
// Beside it, the map's name and what is known of the mod, as a session's facts are listed.
const float text_left = left + icon + between;
float text_y = y;
draw->AddText(heading, name_size, ImVec2(text_left, text_y), theme::paper, card.map.c_str(), nullptr, column);
text_y += name_extent.y + 22 * u;
for (const auto& fact : facts) {
draw->AddText(body, fact_size, ImVec2(text_left, text_y), theme::muted, fact.name);
draw->PushClipRect(ImVec2(text_left + 220 * u, text_y - fact_size), ImVec2(right, text_y + fact_size * 2), true);
draw->AddText(bold, fact_size, ImVec2(std::max(text_left + 220 * u, right - width_of(bold, fact_size, fact.value)), text_y), theme::paper, fact.value.c_str());
draw->PopClipRect();
text_y += 54 * u;
}
y += top_block + inset;
if (!card.description.empty()) {
draw->AddText(body, text_size, ImVec2(left, y), theme::muted, card.description.c_str(), nullptr, inner);
y += described.y + 36 * u;
}
if (progress) {
const float share = downloading ? (card.total ? static_cast<float>(card.received) / static_cast<float>(card.total) : -1.0f)
: (card.steps ? static_cast<float>(card.step) / static_cast<float>(card.steps) : -1.0f);
draw->AddRectFilled(ImVec2(left, y), ImVec2(right, y + bar), skate_theme::tile_light, 4 * u);
if (share >= 0.0f) draw->AddRectFilled(ImVec2(left, y), ImVec2(left + inner * std::clamp(share, 0.0f, 1.0f), y + bar), theme::blue, 4 * u);
else {
// How far is not known: a block going back and forth.
const float at = static_cast<float>(std::fmod(ImGui::GetTime(), 2.0));
const float where = (at < 1.0f ? at : 2.0f - at) * (inner * 0.75f);
draw->AddRectFilled(ImVec2(left + where, y), ImVec2(left + where + inner * 0.25f, y + bar), theme::blue, 4 * u);
}
y += bar + 30 * u;
}
draw->AddText(body, text_size, ImVec2(left, y), asking ? theme::paper : theme::muted, status.c_str(), nullptr, inner);
y += status_extent.y;
if (downloading) {
y += 16 * u;
draw->AddText(body, label_size, ImVec2(left, y), theme::muted, "You can keep skating while it downloads.");
return;
}
if (!asking) return;
y += 30 * u;
// The game's two looks for a button: the yellow note for the thing to do, a tile for the
// way out. The picked one is the lit one: the note full yellow in a blue edge, the tile blue.
const float button_width = (inner - 24 * u) * 0.5f;
const auto button = [&](float at, bool yes, unsigned seed) {
const ImVec2 a(at, y), b(at + button_width, y + tall);
const bool picked = card.choice == yes;
const bool hovered = clickable && ImGui::IsMouseHoveringRect(a, b, false);
const bool lit = picked || hovered;
const ImU32 fill = yes ? (lit ? sticky : sticky_dim) : (lit ? theme::blue : skate_theme::tile_light);
skate_theme::rough_rect(draw, a, b, fill, seed, u * 2);
if (yes && picked) draw->AddRect(a, b, theme::blue, 0, 0, 6 * u);
const std::string label = yes ? (card.installed ? "SWITCH ON" : "DOWNLOAD") : "CANCEL";
const float size = 40 * u;
draw->AddText(bold, size, ImVec2(a.x + (button_width - width_of(bold, size, label)) * 0.5f, a.y + (tall - size) * 0.5f),
yes || lit ? skate_theme::black : theme::paper, label.c_str());
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left))
if (const auto reply = answer.load()) reply(yes);
};
button(left, true, 0x51);
button(left + button_width + 24 * u, false, 0x52);
// The controls, under the card and in its middle.
float x = min.x + (width - hints_width) * 0.5f;
const float hint_y = max.y + 34 * u;
for (const auto& hint : hints) x += theme::keycap(draw, bold, key_scale, ImVec2(x, hint_y), hint.key.c_str(), hint.what) + hint_gap;
}
} // namespace dingosdk::overlay::detail
+1 -1
View File
@@ -44,7 +44,7 @@ bool is_input(UINT message) {
bool interactive_visible(const State& s) { bool interactive_visible(const State& s) {
return s.visible.load() || s.console_visible.load() || s.editor_visible.load() || s.chat_visible.load() || return s.visible.load() || s.console_visible.load() || s.editor_visible.load() || s.chat_visible.load() ||
s.hub_typing.load(); s.hub_typing.load() || s.prompt_pointer.load();
} }
void restore_input(bool hide_menu) { void restore_input(bool hide_menu) {
+23
View File
@@ -277,6 +277,23 @@ struct CallbacksV2 {
using MultiplayerQueue = bool (*)(const char *action, const char *argument, const char *password, using MultiplayerQueue = bool (*)(const char *action, const char *argument, const char *password,
char *result, std::size_t size); char *result, std::size_t size);
void set_multiplayer_queue(MultiplayerQueue) noexcept; void set_multiplayer_queue(MultiplayerQueue) noexcept;
// A map the session needs being fetched (Extension/Assets/map_download.h), for its card.
struct MapDownloadCard {
int stage{}; // 0 none, 1 asking, 2 downloading, 3 installing, 4 applying, 5 joining
std::string map, package, author, version, description;
std::size_t step{}, steps{}; // applying: the merge's steps done of all
std::string step_name;
bool server{}, installed{}, moved{}, choice{true};
std::uint64_t received{}, total{};
std::uint32_t yes_bind{}, no_bind{};
};
using MapDownloadFeed = MapDownloadCard (*)();
void set_map_download_feed(MapDownloadFeed) noexcept;
using MapDownloadAnswer = void (*)(bool download) noexcept;
void set_map_download_answer(MapDownloadAnswer) noexcept;
// The card's icon: the package's own picture as PNG bytes, or nothing to show a plain tile.
// Any thread; decoded here and uploaded when the overlay next draws.
void set_map_download_icon(const std::string& png) noexcept;
// The session's text chat, read by the chat panel every frame (thread-safe, cheap). // The session's text chat, read by the chat panel every frame (thread-safe, cheap).
using ChatFeed = MultiplayerChat (*)(); using ChatFeed = MultiplayerChat (*)();
void set_chat_feed(ChatFeed) noexcept; void set_chat_feed(ChatFeed) noexcept;
@@ -430,6 +447,12 @@ extern "C" __declspec(dllexport) void DingoSDKOverlayGetStatus(dingosdk::overlay
// surface is open, the game is unfocused, or the overlay has stopped. // 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 DingoSDKOverlayReadFlightInput(dingosdk::overlay::FlightInput* input, bool flight_active, bool player_flight = false);
extern "C" void DingoSDKOverlaySetFreecamInputCapture(bool active); extern "C" void DingoSDKOverlaySetFreecamInputCapture(bool active);
// The same hold on the game's input while a card asks something of the player and a
// controller answers it (the map download's): the buttons that answer must not also skate.
extern "C" void DingoSDKOverlaySetPromptInputCapture(bool active);
// The keyboard keys such a card answers to, read past that hold: bit 0 left, 1 right, 2 Enter,
// 3 Esc. Nothing while the game is not in front or the menu, console or chat has the keys.
extern "C" unsigned DingoSDKOverlayReadPromptKeys();
// allow_menu is used only by the Binds page while recording. Focus and lifetime // allow_menu is used only by the Binds page while recording. Focus and lifetime
// gates still apply. Gameplay callers leave it false. // gates still apply. Gameplay callers leave it false.
extern "C" void DingoSDKOverlayReadControllerInput(dingosdk::ControllerInput* input, bool allow_menu = false); extern "C" void DingoSDKOverlayReadControllerInput(dingosdk::ControllerInput* input, bool allow_menu = false);
+44 -1
View File
@@ -246,7 +246,8 @@ LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
} }
} }
const bool capture = owns_pointer(); const bool capture = owns_pointer();
const bool freecam_capture = s.freecam_controller_active.load(std::memory_order_relaxed); const bool freecam_capture = s.freecam_controller_active.load(std::memory_order_relaxed) ||
s.prompt_input_active.load(std::memory_order_relaxed);
if (freecam_capture) release_game_buttons(window, previous); if (freecam_capture) release_game_buttons(window, previous);
if ((capture || freecam_capture) && is_input(message)) { if ((capture || freecam_capture) && is_input(message)) {
// Foreground raw-input packets still need DefWindowProc cleanup. // Foreground raw-input packets still need DefWindowProc cleanup.
@@ -315,6 +316,7 @@ DWORD WINAPI probe_xinput_slots(void* parameter) noexcept {
const auto get_state = xinput_get_state(); const auto get_state = xinput_get_state();
for (;;) { for (;;) {
WaitForSingleObject(slots.wake.load(), INFINITE); WaitForSingleObject(slots.wake.load(), INFINITE);
keep_xinput_capture_first();
OverlayInputAccess access; OverlayInputAccess access;
for (DWORD slot = 0; get_state && slot < XUSER_MAX_COUNT; ++slot) { for (DWORD slot = 0; get_state && slot < XUSER_MAX_COUNT; ++slot) {
const unsigned bit = 1u << slot; const unsigned bit = 1u << slot;
@@ -418,6 +420,47 @@ extern "C" void DingoSDKOverlaySetFreecamInputCapture(bool active) {
"Freecam controller input capture %s.", active ? "enabled" : "disabled"); "Freecam controller input capture %s.", active ? "enabled" : "disabled");
} }
extern "C" void DingoSDKOverlaySetPromptInputCapture(bool active) {
const bool before = state().prompt_input_active.exchange(active, std::memory_order_relaxed);
if (before == active) return;
// For the log: which of the ways input can be kept from the game the game actually read
// its controller by while it was held. All zero means it reads some other way, and the
// hold did not reach it.
if (active) {
keep_xinput_capture_first();
(void)take_prompt_reads();
dingosdk::logging::write(dingosdk::logging::Level::info, dingosdk::logging::Channel::input, "Card input hold on.");
} else {
const auto reads = take_prompt_reads();
// And which controller libraries the game has loaded at all.
std::string loaded;
for (const auto* name : {L"xinput1_4.dll", L"xinput1_3.dll", L"xinput9_1_0.dll", L"gameinput.dll", L"gameinputredist.dll",
L"windows.gaming.input.dll", L"steamclient64.dll", L"dinput8.dll"})
if (GetModuleHandleW(name)) {
if (!loaded.empty()) loaded += ", ";
for (const wchar_t* at = name; *at; ++at) loaded += static_cast<char>(*at);
}
dingosdk::logging::printf(dingosdk::logging::Level::info, dingosdk::logging::Channel::input,
"Card input hold off. While held the game read: XInput %u, DirectInput %u, raw input %u time(s). Controller libraries loaded: %s. XInput leads to: %s.",
reads.xinput, reads.direct_input, reads.raw, loaded.empty() ? "none" : loaded.c_str(), xinput_entry_owners().c_str());
}
}
extern "C" unsigned DingoSDKOverlayReadPromptKeys() {
struct PreserveError { DWORD value = GetLastError(); ~PreserveError() { SetLastError(value); } } preserve_error;
const auto& s = state();
const HWND window = s.window.load();
// (Not while a menu, the console or chat has the keyboard; the card itself having the pointer is no reason.)
const bool typing = s.visible.load() || s.console_visible.load() || s.editor_visible.load() || s.chat_visible.load() || s.hub_typing.load();
if (!window || s.stop.load() || s.failed.load() || typing || !game_window_foreground(window)) return 0;
OverlayInputAccess access;
unsigned keys{};
constexpr int wanted[]{VK_LEFT, VK_RIGHT, VK_RETURN, VK_ESCAPE};
for (unsigned bit = 0; bit < 4; ++bit)
if (GetAsyncKeyState(wanted[bit]) & 0x8000) keys |= 1u << bit;
return keys;
}
extern "C" void DingoSDKOverlayReadControllerInput(dingosdk::ControllerInput* output, bool allow_menu) { extern "C" void DingoSDKOverlayReadControllerInput(dingosdk::ControllerInput* output, bool allow_menu) {
if (!output) return; if (!output) return;
struct PreserveError { DWORD value = GetLastError(); ~PreserveError() { SetLastError(value); } } preserve_error; struct PreserveError { DWORD value = GetLastError(); ~PreserveError() { SetLastError(value); } } preserve_error;
+20
View File
@@ -131,6 +131,9 @@ struct State {
// keeps it. hub_typing: one of its text boxes has the keyboard, which the overlay takes // keeps it. hub_typing: one of its text boxes has the keyboard, which the overlay takes
// for that long, as for chat. // for that long, as for chat.
std::atomic<bool> hub_pointer{false}, hub_typing{false}; std::atomic<bool> hub_pointer{false}, hub_typing{false};
// The map download card is asking (map_download_card.cpp): the overlay has the pointer, with
// its own cursor, so the card's buttons can be clicked whatever is behind it.
std::atomic<bool> prompt_pointer{false};
std::atomic<bool> input_attached{false}; std::atomic<bool> input_attached{false};
std::atomic<bool> stop{false}; std::atomic<bool> stop{false};
std::atomic<HWND> window{nullptr}; std::atomic<HWND> window{nullptr};
@@ -145,6 +148,7 @@ struct State {
bool win32_ready = false; bool win32_ready = false;
bool dx12_ready = false; bool dx12_ready = false;
std::atomic<bool> freecam_controller_active{false}; std::atomic<bool> freecam_controller_active{false};
std::atomic<bool> prompt_input_active{false}; // DingoSDKOverlaySetPromptInputCapture
bool show_on_ready = false; bool show_on_ready = false;
bool force_windowed = false; bool force_windowed = false;
HANDLE stop_event = nullptr; HANDLE stop_event = nullptr;
@@ -164,6 +168,9 @@ struct State {
ComPtr<ID3D12CommandQueue> queue; ComPtr<ID3D12CommandQueue> queue;
ComPtr<ID3D12Device> device; ComPtr<ID3D12Device> device;
ComPtr<ID3D12DescriptorHeap> rtvs, srvs; ComPtr<ID3D12DescriptorHeap> rtvs, srvs;
// The map download card's icon (map_download_card.cpp), in the SRV heap's second slot, and
// the buffer it was uploaded from, which lives until the next one replaces it.
ComPtr<ID3D12Resource> card_texture, card_upload;
ComPtr<ID3D12GraphicsCommandList> commands; ComPtr<ID3D12GraphicsCommandList> commands;
ComPtr<ID3D12Fence> fence; ComPtr<ID3D12Fence> fence;
HANDLE fence_event = nullptr; HANDLE fence_event = nullptr;
@@ -270,6 +277,19 @@ void draw_notices();
// chat_pending also refreshes the feed, so it runs every presented frame. // chat_pending also refreshes the feed, so it runs every presented frame.
bool chat_pending(); bool chat_pending();
void draw_chat(); void draw_chat();
// The map download card (map_download_card.cpp): whether there is one this frame, and drawing it.
bool map_download_pending();
void draw_map_download();
// Its icon on the way to the GPU: decoded pixels the render thread takes once, then says where
// they went; lost when the device is torn down, after which they are handed out again.
struct CardPixels {
std::vector<unsigned char> rgba;
UINT width{}, height{};
unsigned serial{};
};
bool take_card_pixels(CardPixels& pixels);
void card_image_uploaded(ImTextureID texture, unsigned serial);
void card_image_lost();
// Game debug text (game_text_overlay.cpp): polled every presented frame. // Game debug text (game_text_overlay.cpp): polled every presented frame.
bool game_text_pending(); bool game_text_pending();
void draw_game_text(); void draw_game_text();
+87 -2
View File
@@ -71,6 +71,8 @@ void destroy_graphics() {
s.win32_ready = s.dx12_ready = s.ready = false; s.win32_ready = s.dx12_ready = s.ready = false;
s.ui_was_interactive = false; s.ui_was_interactive = false;
s.frames.clear(); s.frames.clear();
s.card_texture.Reset(); s.card_upload.Reset();
card_image_lost();
s.commands.Reset(); s.rtvs.Reset(); s.srvs.Reset(); s.fence.Reset(); s.device.Reset(); s.commands.Reset(); s.rtvs.Reset(); s.srvs.Reset(); s.fence.Reset(); s.device.Reset();
if (s.fence_event) CloseHandle(s.fence_event); if (s.fence_event) CloseHandle(s.fence_event);
s.fence_event = nullptr; s.fence_event = nullptr;
@@ -232,7 +234,7 @@ bool setup_graphics() {
return false; return false;
} }
heap.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV; heap.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
heap.NumDescriptors = 1; heap.NumDescriptors = 2; // ImGui's atlas, then the map download card's icon (upload_card_image)
heap.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE; heap.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
graphics_result = s.device->CreateDescriptorHeap(&heap, IID_PPV_ARGS(&s.srvs)); graphics_result = s.device->CreateDescriptorHeap(&heap, IID_PPV_ARGS(&s.srvs));
if (FAILED(graphics_result)) { if (FAILED(graphics_result)) {
@@ -441,6 +443,86 @@ void draw_menu() {
if (!visible) s.visible.store(false); if (!visible) s.visible.store(false);
} }
// The map download card's icon (map_download_card.cpp), when one is waiting: made into a
// texture in the SRV heap's second slot. Recorded on the frame's command list ahead of ImGui's
// draws, so the copy is done before anything samples it. Nothing here is fatal: without the
// icon the card shows a plain tile.
void upload_card_image(State& s) {
CardPixels pixels;
if (!take_card_pixels(pixels) || pixels.rgba.empty() || !s.device || !s.srvs) return;
// Frames still on the GPU may sample the icon this one replaces.
if (s.card_texture && s.next_fence && !completed(s.next_fence, render_fence_timeout_ms)) return;
s.card_texture.Reset();
s.card_upload.Reset();
D3D12_RESOURCE_DESC texture{};
texture.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D;
texture.Width = pixels.width;
texture.Height = pixels.height;
texture.DepthOrArraySize = 1;
texture.MipLevels = 1;
texture.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
texture.SampleDesc.Count = 1;
D3D12_HEAP_PROPERTIES heap{};
heap.Type = D3D12_HEAP_TYPE_DEFAULT;
D3D12_PLACED_SUBRESOURCE_FOOTPRINT layout{};
UINT rows{};
UINT64 row_bytes{}, total{};
s.device->GetCopyableFootprints(&texture, 0, 1, 0, &layout, &rows, &row_bytes, &total);
D3D12_RESOURCE_DESC buffer{};
buffer.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER;
buffer.Width = total;
buffer.Height = 1;
buffer.DepthOrArraySize = 1;
buffer.MipLevels = 1;
buffer.SampleDesc.Count = 1;
buffer.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR;
D3D12_HEAP_PROPERTIES upload_heap{};
upload_heap.Type = D3D12_HEAP_TYPE_UPLOAD;
ComPtr<ID3D12Resource> made, upload;
if (!total || rows != pixels.height || row_bytes < std::uint64_t{pixels.width} * 4 ||
FAILED(s.device->CreateCommittedResource(&heap, D3D12_HEAP_FLAG_NONE, &texture, D3D12_RESOURCE_STATE_COPY_DEST, nullptr,
IID_PPV_ARGS(&made))) ||
FAILED(s.device->CreateCommittedResource(&upload_heap, D3D12_HEAP_FLAG_NONE, &buffer, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr,
IID_PPV_ARGS(&upload))))
return;
void* mapped{};
const D3D12_RANGE none{};
if (FAILED(upload->Map(0, &none, &mapped)) || !mapped) return;
for (UINT row = 0; row < pixels.height; ++row)
std::memcpy(static_cast<unsigned char*>(mapped) + layout.Offset + std::size_t{row} * layout.Footprint.RowPitch,
pixels.rgba.data() + std::size_t{row} * pixels.width * 4, std::size_t{pixels.width} * 4);
upload->Unmap(0, nullptr);
D3D12_TEXTURE_COPY_LOCATION to{}, from{};
to.pResource = made.Get();
to.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX;
from.pResource = upload.Get();
from.Type = D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT;
from.PlacedFootprint = layout;
s.commands->CopyTextureRegion(&to, 0, 0, 0, &from, nullptr);
D3D12_RESOURCE_BARRIER ready{};
ready.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
ready.Transition.pResource = made.Get();
ready.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
ready.Transition.StateBefore = D3D12_RESOURCE_STATE_COPY_DEST;
ready.Transition.StateAfter = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE;
s.commands->ResourceBarrier(1, &ready);
const auto step = s.device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV);
auto cpu = s.srvs->GetCPUDescriptorHandleForHeapStart();
auto gpu = s.srvs->GetGPUDescriptorHandleForHeapStart();
cpu.ptr += step;
gpu.ptr += step;
D3D12_SHADER_RESOURCE_VIEW_DESC view{};
view.Format = texture.Format;
view.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D;
view.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
view.Texture2D.MipLevels = 1;
s.device->CreateShaderResourceView(made.Get(), &view, cpu);
s.card_texture = std::move(made);
s.card_upload = std::move(upload);
// Drawable from the next frame on: this frame's draws were made before the copy was recorded.
card_image_uploaded(static_cast<ImTextureID>(gpu.ptr), pixels.serial);
}
void render(IDXGISwapChain* presented, UINT flags) { void render(IDXGISwapChain* presented, UINT flags) {
auto& s = state(); auto& s = state();
if (flags & DXGI_PRESENT_TEST) return; if (flags & DXGI_PRESENT_TEST) return;
@@ -477,6 +559,7 @@ void render(IDXGISwapChain* presented, UINT flags) {
// Input hotkeys/controller state are handled independently of ImGui frames. // Input hotkeys/controller state are handled independently of ImGui frames.
// Refreshes the chat feed every frame, which also decides whether T opens it. // Refreshes the chat feed every frame, which also decides whether T opens it.
const bool chat_frame = chat_pending(); const bool chat_frame = chat_pending();
const bool map_download_frame = map_download_pending();
const bool game_text_frame = game_text_pending(); const bool game_text_frame = game_text_pending();
const bool skate_hud_frame = skate_hud_pending(); const bool skate_hud_frame = skate_hud_pending();
const bool nametag_frame = nametags_pending(); const bool nametag_frame = nametags_pending();
@@ -500,7 +583,7 @@ void render(IDXGISwapChain* presented, UINT flags) {
} }
// Hidden, the overlay still draws while a notice or chat line is on screen. // Hidden, the overlay still draws while a notice or chat line is on screen.
if (s.loaded_notice_posted && !notices_pending() && !chat_frame && !game_text_frame && !skate_hud_frame && if (s.loaded_notice_posted && !notices_pending() && !chat_frame && !game_text_frame && !skate_hud_frame &&
!nametag_frame && !meat_frame && !perf_frame && !hub_frame) return; !nametag_frame && !meat_frame && !perf_frame && !hub_frame && !map_download_frame) return;
} else if (!s.ui_was_interactive) { } else if (!s.ui_was_interactive) {
s.ui_was_interactive = true; s.ui_was_interactive = true;
s.last_model = {}; // Reopening immediately reads fresh state. s.last_model = {}; // Reopening immediately reads fresh state.
@@ -558,6 +641,7 @@ void render(IDXGISwapChain* presented, UINT flags) {
draw_trainer_hud(); draw_trainer_hud();
draw_notices(); draw_notices();
draw_chat(); draw_chat();
draw_map_download();
sync_menu_cursor(); // close buttons also change visibility, without a key message sync_menu_cursor(); // close buttons also change visibility, without a key message
ImGui::Render(); ImGui::Render();
if (ImGui::GetDrawData()->TotalVtxCount == 0) { ImGui::SetCurrentContext(previous); return; } if (ImGui::GetDrawData()->TotalVtxCount == 0) { ImGui::SetCurrentContext(previous); return; }
@@ -576,6 +660,7 @@ void render(IDXGISwapChain* presented, UINT flags) {
if (FAILED(frame.allocator->Reset()) || FAILED(s.commands->Reset(frame.allocator.Get(), nullptr))) { if (FAILED(frame.allocator->Reset()) || FAILED(s.commands->Reset(frame.allocator.Get(), nullptr))) {
ImGui::SetCurrentContext(previous); s.failed = true; restore_input(true); return; ImGui::SetCurrentContext(previous); s.failed = true; restore_input(true); return;
} }
upload_card_image(s);
D3D12_RESOURCE_BARRIER barrier{}; D3D12_RESOURCE_BARRIER barrier{};
barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
barrier.Transition.pResource = frame.buffer.Get(); barrier.Transition.pResource = frame.buffer.Get();
+13 -3
View File
@@ -196,13 +196,21 @@ std::string install_package(ModsPanel& panel, const fs::path& root, const ts::Pa
} }
// Search, category and the sort order, pinned packages first like the site. // Search, category and the sort order, pinned packages first like the site.
// Not safe for work: Thunderstore's own mark on the package, or a category of that name.
bool nsfw(const ts::Package& package) {
return package.nsfw || std::any_of(package.categories.begin(), package.categories.end(),
[](const std::string& category) { return lower(category) == "nsfw"; });
}
std::vector<const ts::Package*> visible_packages(const Store& store, const ts::Installed& installed) { std::vector<const ts::Package*> visible_packages(const Store& store, const ts::Installed& installed) {
const auto query = lower(store.search.data()); const auto query = lower(store.search.data());
std::vector<const ts::Package*> result; std::vector<const ts::Package*> result;
for (const auto& package : store.packages) { for (const auto& package : store.packages) {
const bool have = installed.contains(ts::folder_for(package.full_name)); const bool have = installed.contains(ts::folder_for(package.full_name));
// Deprecated and NSFW packages only show once installed. // NSFW packages are never listed here, installed or not: by Thunderstore's own mark,
if ((package.deprecated || package.nsfw) && !have) continue; // or a category of that name. A deprecated one only shows once installed.
if (nsfw(package)) continue;
if (package.deprecated && !have) continue;
if (!store.category.empty() && !package.in_category(store.category)) continue; if (!store.category.empty() && !package.in_category(store.category)) continue;
if (!query.empty() && lower(package.title()).find(query) == std::string::npos && if (!query.empty() && lower(package.title()).find(query) == std::string::npos &&
lower(package.owner).find(query) == std::string::npos && lower(package.owner).find(query) == std::string::npos &&
@@ -440,9 +448,11 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float
ImGui::InputTextWithHint("##search", "Search mods", store.search.data(), store.search.size()); ImGui::InputTextWithHint("##search", "Search mods", store.search.data(), store.search.size());
ImGui::SameLine(); ImGui::SameLine();
std::vector<std::string> categories; std::vector<std::string> categories;
for (const auto& package : store.packages) for (const auto& package : store.packages) {
if (nsfw(package)) continue; // (and no category that only they have)
for (const auto& category : package.categories) for (const auto& category : package.categories)
if (std::find(categories.begin(), categories.end(), category) == categories.end()) categories.push_back(category); if (std::find(categories.begin(), categories.end(), category) == categories.end()) categories.push_back(category);
}
std::sort(categories.begin(), categories.end()); std::sort(categories.begin(), categories.end());
ImGui::SetNextItemWidth(combo); ImGui::SetNextItemWidth(combo);
if (ImGui::BeginCombo("##category", store.category.empty() ? "All categories" : store.category.c_str())) { if (ImGui::BeginCombo("##category", store.category.empty() ? "All categories" : store.category.c_str())) {
+74 -7
View File
@@ -2,6 +2,7 @@
#include "Extension/Customization/developer_hoodie.h" #include "Extension/Customization/developer_hoodie.h"
#include "Extension/Customization/developer_board.h" #include "Extension/Customization/developer_board.h"
#include "Extension/Assets/live_mods.h" #include "Extension/Assets/live_mods.h"
#include "Extension/Assets/map_download.h"
#include "Engine/Core/Log/logging.h" #include "Engine/Core/Log/logging.h"
#include "Engine/Core/Profiling/profiler.h" #include "Engine/Core/Profiling/profiler.h"
#include "Extension/Settings/job_spin.h" #include "Extension/Settings/job_spin.h"
@@ -391,6 +392,26 @@ void update_model(std::uintptr_t client, TickState& frame) {
// The switches on buttons (action_binds): each runs its console command. // The switches on buttons (action_binds): each runs its console command.
std::array<std::uint32_t, dingosdk::action_binds.size()> action_combos{}; std::array<std::uint32_t, dingosdk::action_binds.size()> action_combos{};
const bool vote_open = dingosdk::multiplayer::server_vote_open(); const bool vote_open = dingosdk::multiplayer::server_vote_open();
// The map download's card (map_download.h) is answered with the same two binds, and by a
// controller, whose buttons are kept from the game while it asks.
const bool map_prompt = dingosdk::map_download::asking();
// The hold on the game's input outlasts the card's question until everything pressed is let
// go (or a moment has passed): the button that answered must not also be a press in the
// game, or in the pause menu behind the card.
static bool prompt_held{};
static ULONGLONG prompt_release_by{};
if (map_prompt) {
prompt_held = true;
prompt_release_by = 0;
} else if (prompt_held) {
if (!prompt_release_by) prompt_release_by = GetTickCount64() + 1500;
dingosdk::ControllerInput held;
DingoSDKOverlayReadControllerInput(&held, true);
const bool pressed = (held.available && held.buttons) || DingoSDKOverlayReadPromptKeys() ||
std::ranges::any_of(held.keys, [](std::uint64_t word) { return word != 0; });
if (!pressed || GetTickCount64() >= prompt_release_by) prompt_held = false;
}
DingoSDKOverlaySetPromptInputCapture(map_prompt || prompt_held);
const unsigned poll_answers = dingosdk::multiplayer::server_poll_answers(); const unsigned poll_answers = dingosdk::multiplayer::server_poll_answers();
bool freecam_controller = dingosdk::local_freecam_controller(); bool freecam_controller = dingosdk::local_freecam_controller();
{ {
@@ -405,10 +426,12 @@ void update_model(std::uintptr_t client, TickState& frame) {
up_velocity_combo = r.model.bindings.available ? r.model.bindings.up_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; offboard_up_velocity_combo = r.model.bindings.available ? r.model.bindings.offboard_up_velocity_combo : 0;
if (r.model.bindings.available) action_combos = r.model.bindings.action_combos; if (r.model.bindings.available) action_combos = r.model.bindings.action_combos;
if (vote_open && r.model.bindings.available) { if ((vote_open || map_prompt) && r.model.bindings.available) {
vote_yes_combo = r.model.bindings.vote_yes_combo; vote_yes_combo = r.model.bindings.vote_yes_combo;
vote_no_combo = r.model.bindings.vote_no_combo; vote_no_combo = r.model.bindings.vote_no_combo;
} }
if (r.model.bindings.available)
dingosdk::map_download::set_binds(r.model.bindings.vote_yes_combo, r.model.bindings.vote_no_combo);
debug_request = r.requests.take<dingosdk::overlay::DebugRequest>(GetCurrentThreadId()); debug_request = r.requests.take<dingosdk::overlay::DebugRequest>(GetCurrentThreadId());
} }
// Capture belongs to the freecam state itself. Keep it active across brief // Capture belongs to the freecam state itself. Keep it active across brief
@@ -416,8 +439,10 @@ void update_model(std::uintptr_t client, TickState& frame) {
DingoSDKOverlaySetFreecamInputCapture(dingosdk::client_free_camera_active() && freecam_controller); DingoSDKOverlaySetFreecamInputCapture(dingosdk::client_free_camera_active() && freecam_controller);
dingosdk::ControllerInput controller; dingosdk::ControllerInput controller;
if (freecam_controller_combo || freecam_combo || tp_to_freecam_combo || noclip_combo || forward_velocity_combo || up_velocity_combo || offboard_up_velocity_combo || if (freecam_controller_combo || freecam_combo || tp_to_freecam_combo || noclip_combo || forward_velocity_combo || up_velocity_combo || offboard_up_velocity_combo ||
vote_yes_combo || vote_no_combo || poll_answers || std::ranges::any_of(action_combos, [](auto combo) { return combo != 0; })) vote_yes_combo || vote_no_combo || poll_answers || map_prompt ||
DingoSDKOverlayReadControllerInput(&controller); std::ranges::any_of(action_combos, [](auto combo) { return combo != 0; }))
// (The card that asks has the pointer, as a menu does: it is still read for.)
DingoSDKOverlayReadControllerInput(&controller, map_prompt);
for (std::size_t i = 0; i < action_combos.size(); ++i) for (std::size_t i = 0; i < action_combos.size(); ++i)
if (r.action_bind_latches[i].update(action_combos[i], controller, r.observer_failed)) { if (r.action_bind_latches[i].update(action_combos[i], controller, r.observer_failed)) {
std::array<char, 256> result{}; std::array<char, 256> result{};
@@ -427,10 +452,48 @@ void update_model(std::uintptr_t client, TickState& frame) {
} }
// (The input reads as nothing while the menu, the console or the chat box is open, so typing // (The input reads as nothing while the menu, the console or the chat box is open, so typing
// a bound key answers no vote.) // a bound key answers no vote.)
if (r.vote_yes_bind_latch.update(vote_yes_combo, controller, !vote_open) && dingosdk::multiplayer::queue_command("vote", "yes", "")) if (r.vote_yes_bind_latch.update(vote_yes_combo, controller, !vote_open && !map_prompt)) {
record("{\"event\":\"controller_binding_triggered\",\"action\":\"vote_yes\"}"); if (map_prompt) dingosdk::map_download::answer(true);
if (r.vote_no_bind_latch.update(vote_no_combo, controller, !vote_open) && dingosdk::multiplayer::queue_command("vote", "no", "")) else if (dingosdk::multiplayer::queue_command("vote", "yes", ""))
record("{\"event\":\"controller_binding_triggered\",\"action\":\"vote_no\"}"); record("{\"event\":\"controller_binding_triggered\",\"action\":\"vote_yes\"}");
}
if (r.vote_no_bind_latch.update(vote_no_combo, controller, !vote_open && !map_prompt)) {
if (map_prompt) dingosdk::map_download::answer(false);
else if (dingosdk::multiplayer::queue_command("vote", "no", ""))
record("{\"event\":\"controller_binding_triggered\",\"action\":\"vote_no\"}");
}
{
// A controller on the card: D-pad left and right pick, A takes the pick, B is no. Only a
// button pressed since the card came up counts, so the one that was held does not answer.
static bool prompt_before{};
static std::uint32_t buttons_before{};
// The keyboard's arrows, Enter and Esc do the same, above the pad's bits.
const std::uint32_t buttons = !map_prompt ? 0
: (controller.available ? controller.buttons : 0) | (DingoSDKOverlayReadPromptKeys() << 20);
if (map_prompt && prompt_before) {
const auto pressed = buttons & ~buttons_before;
if (pressed & (0x0004 | 1u << 20)) dingosdk::map_download::pick(true);
if (pressed & (0x0008 | 1u << 21)) dingosdk::map_download::pick(false);
if (pressed & (0x1000 | 1u << 22)) dingosdk::map_download::answer(dingosdk::map_download::picked());
else if (pressed & (0x2000 | 1u << 23)) dingosdk::map_download::answer(false);
}
prompt_before = map_prompt;
buttons_before = buttons;
}
// While it downloads, the No bind stops it.
if (!map_prompt && !vote_open && dingosdk::map_download::view().stage == dingosdk::map_download::Stage::downloading) {
std::uint32_t stop_combo{};
{
std::lock_guard lock(r.mutex);
if (r.model.bindings.available) stop_combo = r.model.bindings.vote_no_combo;
}
if (stop_combo) {
dingosdk::ControllerInput input;
DingoSDKOverlayReadControllerInput(&input);
static dingosdk::ControllerComboLatch stop_latch;
if (stop_latch.update(stop_combo, input, false)) dingosdk::map_download::answer(false);
}
}
// A poll's answers are on the number keys, 1 for the first: only while one is running, so // A poll's answers are on the number keys, 1 for the first: only while one is running, so
// the keys are the game's own the rest of the time. // the keys are the game's own the rest of the time.
for (unsigned answer = 0; answer < r.poll_answer_latches.size(); ++answer) { for (unsigned answer = 0; answer < r.poll_answer_latches.size(); ++answer) {
@@ -1114,6 +1177,10 @@ void tick(std::uintptr_t client, std::uintptr_t update) {
dingosdk::multiplayer::tick(r.base,client,multiplayer_ready,r.multiplayer_map,load_multiplayer_map); dingosdk::multiplayer::tick(r.base,client,multiplayer_ready,r.multiplayer_map,load_multiplayer_map);
if (auto notice = dingosdk::multiplayer::take_leave_notice(); !notice.empty()) if (auto notice = dingosdk::multiplayer::take_leave_notice(); !notice.empty())
dingosdk::overlay::notify(dingosdk::overlay::NoticeLevel::warning, "Map not installed", std::move(notice)); dingosdk::overlay::notify(dingosdk::overlay::NoticeLevel::warning, "Map not installed", std::move(notice));
// Or, when the host said which Thunderstore package its map is from, the offer to
// fetch it (map_download.h), which then applies it and joins the session again.
if (const auto need = dingosdk::multiplayer::take_map_need()) dingosdk::map_download::offer(*need);
dingosdk::map_download::tick();
} }
dingosdk::multiplayer::refresh_identity_lists(); dingosdk::multiplayer::refresh_identity_lists();
// The player's Discord status (discord_presence.h): where they skate and with whom, // The player's Discord status (discord_presence.h): where they skate and with whom,
+29
View File
@@ -9,6 +9,7 @@
#include "Engine/Game/Build/20260929/profiler_labels.h" #include "Engine/Game/Build/20260929/profiler_labels.h"
#include "Engine/Game/Build/supported_build.h" #include "Engine/Game/Build/supported_build.h"
#include "Engine/Vfs/mod_catalog.h" #include "Engine/Vfs/mod_catalog.h"
#include "Extension/Assets/map_download.h"
#include "Extension/Boot/offline_boot.h" #include "Extension/Boot/offline_boot.h"
#include "Extension/HallOfMeat/hall_of_meat.h" #include "Extension/HallOfMeat/hall_of_meat.h"
#include "Extension/Skater/camera_observer.h" #include "Extension/Skater/camera_observer.h"
@@ -189,6 +190,34 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() {
dingosdk::multiplayer::set_native_menu_callbacks(native_callbacks); dingosdk::multiplayer::set_native_menu_callbacks(native_callbacks);
dingosdk::overlay::set_multiplayer_queue(queue_multiplayer_command); dingosdk::overlay::set_multiplayer_queue(queue_multiplayer_command);
dingosdk::overlay::set_chat_feed(dingosdk::multiplayer::chat); dingosdk::overlay::set_chat_feed(dingosdk::multiplayer::chat);
// A map a session needs: hosts say which Thunderstore package theirs is from, and a
// guest without it is offered it on a card (Extension/Assets/map_download.h).
dingosdk::multiplayer::set_map_package_lookup(dingosdk::map_download::package_of);
dingosdk::overlay::set_map_download_feed([]() {
const auto view = dingosdk::map_download::view();
using Stage = dingosdk::map_download::Stage;
dingosdk::overlay::MapDownloadCard card;
card.stage = view.stage == Stage::asking ? 1 : view.stage == Stage::downloading ? 2 : view.stage == Stage::installing ? 3
: view.stage == Stage::applying ? 4 : view.stage == Stage::joining ? 5 : 0;
card.map = view.map;
card.package = view.package;
card.author = view.author;
card.version = view.version;
card.description = view.description;
card.step = view.step;
card.steps = view.steps;
card.step_name = view.step_name;
card.server = view.server;
card.moved = view.moved;
card.installed = view.installed;
card.choice = view.choice;
card.received = view.received;
card.total = view.total;
card.yes_bind = view.yes_bind;
card.no_bind = view.no_bind;
return card;
});
dingosdk::overlay::set_map_download_answer(dingosdk::map_download::answer);
dingosdk::overlay::set_hub_page_feed(dingosdk::multiplayer::native_menu_page); dingosdk::overlay::set_hub_page_feed(dingosdk::multiplayer::native_menu_page);
dingosdk::overlay::set_hub_callbacks(native_callbacks); dingosdk::overlay::set_hub_callbacks(native_callbacks);
dingosdk::overlay::set_ui_sound(dingosdk::multiplayer::queue_ui_sound); dingosdk::overlay::set_ui_sound(dingosdk::multiplayer::queue_ui_sound);
+11 -2
View File
@@ -19,8 +19,17 @@ with the defaults, as on the first run.
Custom maps Custom maps
----------- -----------
Copy a custom map's mod folder from the game's Mods folder into a Mods folder Copy a custom map's mod folder from the game's Mods folder into a Mods folder
next to the server (only its reskate-levels.json is read). The map can then be next to the server (its reskate-levels.json and manifest.json are read). The
chosen by name. Players need the same map mod installed to join. map can then be chosen by name. Players need the same map mod installed to
join.
A player without it is offered it when the map is from Thunderstore: the game
downloads it, applies it and joins the server again. For that the folder has to
keep the name Thunderstore gives it: Owner-Name as the launcher installs it
(Sandos-Vancouver_Plaza), or Owner-Name-1.0.0 as its zip unpacks. The server
tells players that name, and the version from manifest.json when the file is
there. A map under any other folder name
works as before: players install it themselves.
Players connect through Steam's relay network, so no ports need opening. If you Players connect through Steam's relay network, so no ports need opening. If you
do forward UDP 27015-27016 (port, query_port), the browser also shows the do forward UDP 27015-27016 (port, query_port), the browser also shows the
+14 -1
View File
@@ -656,11 +656,20 @@ std::vector<std::string> load_levels(const std::filesystem::path &mods) {
std::stringstream text; std::stringstream text;
text << in.rdbuf(); text << in.rdbuf();
const auto root = Json::parse(text.str()); const auto root = Json::parse(text.str());
// The mod's version, when the whole folder was copied here and not only its level list.
std::string version;
try {
std::ifstream about(entry.path() / "manifest.json", std::ios::binary);
std::stringstream about_text;
about_text << about.rdbuf();
if (about) version = Json::parse(about_text.str()).value("version_number", "");
} catch (const std::exception &) {}
const auto package = multiplayer::map_package_name(entry.path().filename().string(), version);
for (const auto &level : root.at("levels")) { for (const auto &level : root.at("levels")) {
const auto asset = level.at("asset").string(); const auto asset = level.at("asset").string();
const auto name = level.value("displayName", world_level_name(asset)); const auto name = level.value("displayName", world_level_name(asset));
if (std::none_of(list.begin(), list.end(), [&](const auto &l) { return same(l.asset, asset); })) if (std::none_of(list.begin(), list.end(), [&](const auto &l) { return same(l.asset, asset); }))
list.push_back({asset, name.empty() ? world_level_name(asset) : name}); list.push_back({asset, name.empty() ? world_level_name(asset) : name, package});
} }
} catch (const std::exception &e) { } catch (const std::exception &e) {
problems.push_back(entry.path().filename().string() + ": " + e.what()); problems.push_back(entry.path().filename().string() + ": " + e.what());
@@ -730,6 +739,10 @@ std::string map_label(std::string_view map) {
if (const auto *level = find_level(map)) return level->name; if (const auto *level = find_level(map)) return level->name;
return world_level_name(world_destination_asset(map_destination(map))); return world_level_name(world_destination_asset(map_destination(map)));
} }
std::string map_package(std::string_view map) {
const auto *level = find_level(map);
return level ? level->package : std::string();
}
std::vector<const ServerLevel *> pool_levels(const ServerConfig &config) { std::vector<const ServerLevel *> pool_levels(const ServerConfig &config) {
std::vector<const ServerLevel *> pool; std::vector<const ServerLevel *> pool;
const auto add = [&](const ServerLevel *level) { const auto add = [&](const ServerLevel *level) {
+5
View File
@@ -206,6 +206,9 @@ std::optional<std::uint64_t> parse_scoring(std::string_view text);
// "San Vansterdam", "Isle of Grom", a custom map's "bbcity"... // "San Vansterdam", "Isle of Grom", a custom map's "bbcity"...
struct ServerLevel { struct ServerLevel {
std::string asset, name; std::string asset, name;
// The Thunderstore package a custom map's mod folder is (protocol.h: map_package_name), from
// the folder's name and its manifest.json, so players without the map can be offered it.
std::string package;
}; };
// The retail maps, and the custom maps in `mods`\<mod>\reskate-levels.json // The retail maps, and the custom maps in `mods`\<mod>\reskate-levels.json
// (players' map mods, copied next to the server). Returns why a mod was skipped. // (players' map mods, copied next to the server). Returns why a mod was skipped.
@@ -226,6 +229,8 @@ std::string map_destination(std::string_view map);
std::string map_setting(std::string_view map); std::string map_setting(std::string_view map);
// A map's name for people: "San Vansterdam". // A map's name for people: "San Vansterdam".
std::string map_label(std::string_view map); std::string map_label(std::string_view map);
// The Thunderstore package that map comes from; empty for the game's own and when unknown.
std::string map_package(std::string_view map);
std::vector<const ServerLevel *> pool_levels(const ServerConfig &config); // known pool maps once each; all when empty std::vector<const ServerLevel *> pool_levels(const ServerConfig &config); // known pool maps once each; all when empty
bool in_map_pool(const ServerConfig &config, std::string_view map); bool in_map_pool(const ServerConfig &config, std::string_view map);
const ServerLevel *next_pool_map(const ServerConfig &config, std::string_view map); // after `map`; null if no other const ServerLevel *next_pool_map(const ServerConfig &config, std::string_view map); // after `map`; null if no other
+30 -4
View File
@@ -118,6 +118,21 @@ void Host::save() {
log_(std::string("Could not save the config: ") + e.what()); log_(std::string("Could not save the config: ") + e.what());
} }
} }
// A player without the map is downloading it: the clock they are loading against starts over
// each time they say so, for as long as map_fetch_limit_us from the first time. A slot is still
// never held for good, and a player who stops saying it has the usual time to load.
void Host::fetching(Guest &guest) {
// Only for a player who is past the password: anyone else is held to the short time a
// connection gets to show it belongs here.
if (!guest.handshaken && !guest.map_authorized) return;
if (!guest.fetching_since) {
guest.fetching_since = now_;
log_("[map] " + guest_name(guest) + " is downloading the map");
}
if (now_ - guest.fetching_since > map_fetch_limit_us) return;
if (!guest.handshaken) guest.connected_at = now_;
else if (guest.travel_since) guest.travel_since = now_;
}
std::string Host::invite() const { return format_invite({id_, secret_}); } std::string Host::invite() const { return format_invite({id_, secret_}); }
std::string Host::map_name() const { return map_label(config_.map); } std::string Host::map_name() const { return map_label(config_.map); }
std::string Host::wire_map_label() const { // players without the map's mod still see its name std::string Host::wire_map_label() const { // players without the map's mod still see its name
@@ -698,6 +713,7 @@ void Host::send_world_state() {
auto state = packet(PacketKind::world_state, now_); auto state = packet(PacketKind::world_state, now_);
state.destination = map_destination(config_.map); state.destination = map_destination(config_.map);
state.map_label = wire_map_label(); state.map_label = wire_map_label();
state.map_package = map_package(config_.map);
state.world_ready = true; // the server has nothing to load state.world_ready = true; // the server has nothing to load
const auto bytes = encode_wire(state); const auto bytes = encode_wire(state);
for (auto &[id, guest] : guests_) for (auto &[id, guest] : guests_)
@@ -793,6 +809,7 @@ void Host::change_map(std::string_view map) {
g.scoring_flagged = old.scoring_flagged; g.scoring_flagged = old.scoring_flagged;
g.world_ready = false; g.world_ready = false;
g.travel_since = g.handshaken ? now_ : 0; g.travel_since = g.handshaken ? now_ : 0;
g.fetching_since = 0;
g.last_packet = now_; g.last_packet = now_;
} }
if (++world_ == 0) throw std::runtime_error("Map transition counter exhausted."); if (++world_ == 0) throw std::runtime_error("Map transition counter exhausted.");
@@ -818,10 +835,16 @@ void Host::drop(std::uint64_t id, const std::string &reason, const std::string &
} else { } else {
// Never admitted: it held a player slot meanwhile, so it shows in the log, and an ID // 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. // that keeps failing waits longer each time before its connection is taken again.
const auto failures = join_backoff_.failed(id, now_); // Not one that was let in (past the password) and left while fetching the map: turning
log_("[join] " + std::to_string(id) + " did not finish joining (" + reason + ")" + // a download down, or stopping one, is no failed attempt, and they may come straight back.
(failures > 1 ? ", attempt " + std::to_string(failures) : std::string{}) + if (found->second->map_authorized && found->second->fetching_since) {
(detail.empty() ? std::string{} : " [" + detail + "]")); log_("[join] " + std::to_string(id) + " left without the map (" + reason + ")");
} else {
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{}) +
(detail.empty() ? std::string{} : " [" + detail + "]"));
}
} }
guests_.erase(found); guests_.erase(found);
for (auto &[other, guest] : guests_) { for (auto &[other, guest] : guests_) {
@@ -987,6 +1010,7 @@ void Host::receive(std::uint64_t peer, std::span<const std::uint8_t> bytes, std:
return; return;
link->ready_sequence = p.sequence; link->ready_sequence = p.sequence;
link->last_packet = now_; link->last_packet = now_;
if (p.map_fetching) fetching(*link);
const bool arrived = p.world_ready && !link->world_ready; const bool arrived = p.world_ready && !link->world_ready;
if (!p.world_ready && link->world_ready) link->loading_since = now_; if (!p.world_ready && link->world_ready) link->loading_since = now_;
if (arrived && config_.activity_log) if (arrived && config_.activity_log)
@@ -1005,6 +1029,7 @@ void Host::receive(std::uint64_t peer, std::span<const std::uint8_t> bytes, std:
return drop(peer, "Invalid map request. Update ReSkate to the server's version and join again."); return drop(peer, "Invalid map request. Update ReSkate to the server's version and join again.");
if (link->handshaken) return; if (link->handshaken) return;
if (p.map && p.map != map_) return drop(peer, "The server changed maps while you were joining. Join again."); if (p.map && p.map != map_) return drop(peer, "The server changed maps while you were joining. Join again.");
if (p.map_fetching) fetching(*link);
link->member.epoch = p.epoch; link->member.epoch = p.epoch;
bool authorized = !password_; bool authorized = !password_;
if (password_) { if (password_) {
@@ -1021,6 +1046,7 @@ void Host::receive(std::uint64_t peer, std::span<const std::uint8_t> bytes, std:
auto offer = packet(PacketKind::map_offer, now_); auto offer = packet(PacketKind::map_offer, now_);
offer.destination = map_destination(config_.map); offer.destination = map_destination(config_.map);
offer.map_label = wire_map_label(); offer.map_label = wire_map_label();
offer.map_package = map_package(config_.map);
offer.challenge = link->password_challenge; offer.challenge = link->password_challenge;
offer.map_authorized = authorized; offer.map_authorized = authorized;
send_required(*link, encode_wire(offer)); send_required(*link, encode_wire(offer));
+5
View File
@@ -75,6 +75,10 @@ class Host {
bool handshaken{}, map_authorized{}, world_ready = true; bool handshaken{}, map_authorized{}, world_ready = true;
std::uint64_t last_map_offer{}, travel_since{}, connected_at{}, last_packet{}; std::uint64_t last_map_offer{}, travel_since{}, connected_at{}, last_packet{};
std::uint64_t loading_since{}; // not ready in this world since (reloading on their own) std::uint64_t loading_since{}; // not ready in this world since (reloading on their own)
// When they first said they are fetching the map (Packet::map_fetching), 0 for never
// in this world: while they go on saying so, within map_fetch_limit_us of then, the
// time they are given to load starts over.
std::uint64_t fetching_since{};
std::uint32_t ready_sequence{}; std::uint32_t ready_sequence{};
ReceiveBudget budget; ReceiveBudget budget;
DeltaSender sender; DeltaSender sender;
@@ -328,6 +332,7 @@ class Host {
void send_maps(Guest &admin); void send_maps(Guest &admin);
void change_map(std::string_view map); // a level name, level path or destination void change_map(std::string_view map); // a level name, level path or destination
std::string wire_map_label() const; std::string wire_map_label() const;
void fetching(Guest &guest);
bool same_map(std::string_view asset) const { return map_hash(map_destination(asset)) == map_; } bool same_map(std::string_view asset) const { return map_hash(map_destination(asset)) == map_; }
bool accept_data(Guest &source, const Packet &); bool accept_data(Guest &source, const Packet &);
// `received_at`: the transport's arrival time for the message (TransportMessage::arrived). // `received_at`: the transport's arrival time for the message (TransportMessage::arrived).
+4
View File
@@ -20,6 +20,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32)
target_link_libraries(dingosdk_thunderstore_tests PRIVATE dingosdk_json dingosdk_miniz) target_link_libraries(dingosdk_thunderstore_tests PRIVATE dingosdk_json dingosdk_miniz)
target_include_directories(dingosdk_thunderstore_tests PRIVATE "${PROJECT_SOURCE_DIR}") target_include_directories(dingosdk_thunderstore_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME launcher_thunderstore COMMAND dingosdk_thunderstore_tests) add_test(NAME launcher_thunderstore COMMAND dingosdk_thunderstore_tests)
add_executable(dingosdk_thunderstore_package_tests Engine/Vfs/Test/thunderstore_package_tests.cpp Engine/Vfs/thunderstore_package.cpp)
target_link_libraries(dingosdk_thunderstore_package_tests PRIVATE dingosdk_json)
target_include_directories(dingosdk_thunderstore_package_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME thunderstore_package COMMAND dingosdk_thunderstore_package_tests)
add_executable(dingosdk_game_settings_tests Launcher/Test/game_settings_tests.cpp Launcher/game_settings.cpp) add_executable(dingosdk_game_settings_tests Launcher/Test/game_settings_tests.cpp Launcher/game_settings.cpp)
target_include_directories(dingosdk_game_settings_tests PRIVATE "${PROJECT_SOURCE_DIR}") target_include_directories(dingosdk_game_settings_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME launcher_game_settings COMMAND dingosdk_game_settings_tests) add_test(NAME launcher_game_settings COMMAND dingosdk_game_settings_tests)
+4 -1
View File
@@ -86,6 +86,9 @@ add_library(dingosdk_runtime SHARED
Extension/Assets/native_patch_support.cpp Extension/Assets/native_patch_support.cpp
Extension/Assets/mod_layers.cpp Extension/Assets/mod_layers.cpp
Extension/Assets/live_mods.cpp Extension/Assets/live_mods.cpp
Extension/Assets/map_download.cpp
Engine/Vfs/thunderstore_package.cpp
Launcher/mod_manager.cpp
Extension/Assets/loose_files.cpp Extension/Assets/loose_files.cpp
Extension/Scripting/lua_startup.cpp Extension/Scripting/lua_startup.cpp
Extension/Scripting/custom_script_loader.cpp Extension/Scripting/custom_script_loader.cpp
@@ -194,7 +197,7 @@ add_library(dingosdk_runtime SHARED
Extension/Boot/ea_service_block.cpp) Extension/Boot/ea_service_block.cpp)
target_link_libraries(dingosdk_runtime PRIVATE dingosdk_logging dingosdk_profiler dingosdk_supported_build dingosdk_overlay dingosdk_console_core dingosdk_steam_restart_guard target_link_libraries(dingosdk_runtime PRIVATE dingosdk_logging dingosdk_profiler dingosdk_supported_build dingosdk_overlay dingosdk_console_core dingosdk_steam_restart_guard
dingosdk_startup_interventions dingosdk_fast_travel_unlock dingosdk_local_profile dingosdk_initfs dingosdk_mods dingosdk_startup_window dingosdk_custom_scripts dingosdk_world_layer_scan bcrypt dingosdk_startup_interventions dingosdk_fast_travel_unlock dingosdk_local_profile dingosdk_initfs dingosdk_mods dingosdk_mod_list dingosdk_launcher_support dingosdk_miniz shell32 dingosdk_startup_window dingosdk_custom_scripts dingosdk_world_layer_scan bcrypt
dingosdk_https winhttp ws2_32 xaudio2 ole32 dingosdk_game_archives dingosdk_word_filter) dingosdk_https winhttp ws2_32 xaudio2 ole32 dingosdk_game_archives dingosdk_word_filter)
add_library(dingosdk_custom_level_manifest STATIC add_library(dingosdk_custom_level_manifest STATIC
+1
View File
@@ -10,6 +10,7 @@ add_library(dingosdk_overlay STATIC
Extension/UI/Overlay/overlay_input.cpp Extension/UI/Overlay/overlay_input.cpp
Extension/UI/Overlay/overlay_notices.cpp Extension/UI/Overlay/overlay_notices.cpp
Extension/UI/Overlay/chat_overlay.cpp Extension/UI/Overlay/chat_overlay.cpp
Extension/UI/Overlay/map_download_card.cpp
Extension/UI/Overlay/hub_page.cpp Extension/UI/Overlay/hub_page.cpp
Extension/UI/Overlay/game_text_overlay.cpp Extension/UI/Overlay/game_text_overlay.cpp
Extension/UI/Overlay/perf_overlay.cpp Extension/UI/Overlay/perf_overlay.cpp