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{};
std::vector<MultiplayerPartyInvite> party_invites; // newest last
bool active{}, hosting{}, connected{}, echo{}, remote_visible{}, local_ready{};
// The session is connected but waiting on its map, which this PC does not have and is
// being asked about or downloaded: not yet a session the player is in.
bool map_fetching{};
std::uint64_t local_id{}, peer_id{}, sent{}, received{}, dropped{}, pose_updates{}, board_pose_updates{};
std::size_t skater_bones{}, board_bones{};
std::string invite, map;
@@ -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,
std::uint64_t max_bytes, std::uint32_t timeout_seconds, const wchar_t* agent) {
std::uint64_t max_bytes, std::uint32_t timeout_seconds, const wchar_t* agent, Watch* watch) {
Download result;
const auto fail = [&](DWORD error) { result.error = error; return result; };
if (url.empty() || url.size() >= 4096 || url.find_first_of(L"\r\n\t #") != std::wstring_view::npos)
@@ -57,18 +57,21 @@ Download get(std::wstring_view url, const std::filesystem::path& destination,
DWORD length{}; size = sizeof(length);
if (WinHttpQueryHeaders(request.value, WINHTTP_QUERY_CONTENT_LENGTH | WINHTTP_QUERY_FLAG_NUMBER,
nullptr, &length, &size, nullptr) && length > max_bytes) return fail(ERROR_FILE_TOO_LARGE);
if (watch) watch->total.store(length), watch->received.store(0);
Handle output{CreateFileW(destination.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr)};
if (output.value == INVALID_HANDLE_VALUE) return fail(GetLastError());
std::array<char, 65536> buffer{};
std::uint64_t total{};
for (;;) {
if (GetTickCount64() - started >= std::uint64_t{timeout_seconds} * 1000) return fail(ERROR_TIMEOUT);
if (watch && watch->cancel.load()) return fail(ERROR_CANCELLED);
DWORD read{}, written{};
if (!WinHttpReadData(request.value, buffer.data(), static_cast<DWORD>(buffer.size()), &read)) return fail(GetLastError());
if (!read) break;
if (total + read > max_bytes) return fail(ERROR_FILE_TOO_LARGE);
if (!WriteFile(output.value, buffer.data(), read, &written, nullptr) || written != read) return fail(ERROR_WRITE_FAULT);
total += read;
if (watch) watch->received.store(total);
}
if (!total) return fail(ERROR_BAD_LENGTH);
if (!FlushFileBuffers(output.value)) return fail(GetLastError());
+8 -1
View File
@@ -1,4 +1,5 @@
#pragma once
#include <atomic>
#include <cstdint>
#include <filesystem>
#include <optional>
@@ -10,11 +11,17 @@ struct Download {
bool ok{};
unsigned long http_status{}, error{};
};
// A long download as another thread sees it: how far it is (`total` 0 when the server does
// not say), and a way to stop it (the download then fails with ERROR_CANCELLED).
struct Watch {
std::atomic<std::uint64_t> received{}, total{};
std::atomic<bool> cancel{};
};
// Streams an HTTPS GET into `destination`, which must not exist yet. Follows
// redirects that stay on HTTPS; refuses bodies over `max_bytes` or transfers
// that take longer than `timeout_seconds` in total.
Download get(std::wstring_view url, const std::filesystem::path& destination,
std::uint64_t max_bytes, std::uint32_t timeout_seconds, const wchar_t* agent);
std::uint64_t max_bytes, std::uint32_t timeout_seconds, const wchar_t* agent, Watch* watch = nullptr);
// The body of the same GET, for an answer small enough to hold: nothing when it
// fails, and `result`, when given, says how.
std::optional<std::string> get_text(std::wstring_view url, std::uint64_t max_bytes,
+27 -8
View File
@@ -6,6 +6,8 @@
#include "mod_store_copies.h"
#include "native_db.h"
#include <Windows.h>
#include <algorithm>
#include <array>
#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
// mod's author to read. The catalogue is only read once a mod turns out to add
// an item at all.
bool store_copy_problems(const Catalog& catalog, MergeReport& report) {
bool store_copy_problems(const Catalog& all, MergeReport& report, const std::vector<std::string>& checked,
std::size_t threads, bool background) {
// (Only when some are skipped is the catalogue copied, without them.)
std::optional<Catalog> fewer;
if (!checked.empty()) {
fewer = all;
std::erase_if(fewer->mods, [&](const Mod& mod) {
return std::ranges::any_of(checked, [&](const std::string& name) { return lower(name) == lower(mod.name); });
});
}
const Catalog& catalog = fewer ? *fewer : all;
std::optional<content_cache::Catalogs> store;
const auto found = check_store_copies(catalog, [&store](const std::string& key) {
if (!store) store = content_cache::read_catalogs(content_cache::directory());
return store->reserved(key);
}, &report.notes);
}, &report.notes, threads, background);
for (const auto& source : found.mods) report.problems[source.mod].emplace_back(store_copies_problem);
return !found.mods.empty();
}
@@ -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
// mod that cannot be merged, and the patch on disk stays for that merge to
// reuse or replace.
if (storeKnown && store_copy_problems(catalog, report)) return report;
// Threads for the three steps that read many files: as many as there are cores at
// launch (the player is waiting on nothing else), half of them and fewer while the game runs.
const auto cores = std::max(1U, std::thread::hardware_concurrency());
const std::size_t readers = std::min<std::size_t>(options.live ? std::max(1U, cores / 2) : cores, options.live ? 6U : 8U);
if (storeKnown && store_copy_problems(catalog, report, options.live ? options.checked : std::vector<std::string>{},
readers - 1, options.live)) return report;
if (!options.live) fs::remove_all(output, error);
lap("checking the mods");
@@ -347,7 +364,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
lap("load screens");
}
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.
for (auto& [name, chunks] : overrides.chunks)
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
// (CasStore::waiting). So they are merged on several threads, and settled here one
// after another in the order they always were, which puts every byte where merging
// them in turn would have. A merge while the game runs does them in turn, on this
// thread: the game is using the others.
// them in turn would have. A merge while the game runs uses fewer threads, at a lower
// priority: the game is using the others, and a player is waiting on this one.
struct Job {
std::string relative;
std::optional<CasStore> store; // waiting: what the merge wrote
@@ -426,13 +443,15 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
std::atomic<bool>& flag;
~Stop() { flag = true; }
} stopOnExit{stopJobs};
const std::size_t wantedWorkers = options.live || jobs.size() < 2 ? 0
: std::min<std::size_t>({jobs.size(), std::max(1U, std::thread::hardware_concurrency()), 8});
const std::size_t wantedWorkers = jobs.size() < 2 ? 0
: std::min<std::size_t>({jobs.size(), options.live ? std::max(1U, cores / 2) : cores, options.live ? 6U : 8U});
try {
// Threads of their own, not the system's pool: the launcher holds the pool's
// threads back while the game starts (see world_layer_scan.cpp).
for (std::size_t index = 0; index < wantedWorkers; ++index)
workers.emplace_back([&] {
// (Under the game's own threads, so a merge shows as a wait and not as stutter.)
if (options.live) SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL);
for (;;) {
const auto mine = nextJob.fetch_add(1);
if (mine >= jobs.size() || stopJobs) return;
+5
View File
@@ -62,6 +62,11 @@ struct MergeOptions {
// only read at startup and stays as it is; the stamp goes, so the next
// launch builds a clean patch.
bool live{};
// Mods (folder names) already loaded in the running game, by the launch's merge or an
// earlier live one: they passed the store-copies check then, so a live merge holds only
// the others against it. Reading every mod's items and meshes again is most of what
// adding one map would otherwise wait on.
std::vector<std::string> checked;
};
// Combines every enabled mod into one patch under Mods/.reskate: superbundle
+6 -2
View File
@@ -265,11 +265,15 @@ struct AssetOverrides {
// The changes and additions asset mods (mods that add no levels) make to the
// game's own EBX and Lua scripts, the highest-priority mod's version winning. Never throws: an
// unreadable mod is noted and simply changes nothing elsewhere.
// unreadable mod is noted and simply changes nothing elsewhere. The mods' files are read on up
// to `threads` threads beside this one (`background`: at a priority under the game's, for a
// merge while it runs); what they mean together is then settled in priority order, so the
// result is the same whatever the count.
AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
const std::map<const Mod*, RelativeFiles>& modFiles,
const CasStore& store, const fs::path& baseRoot,
const fs::path& gameRoot, MergeReport& report);
const fs::path& gameRoot, MergeReport& report,
std::size_t threads = 0, bool background = false);
// A bundle's chunk metadata is a list with one record for each of its chunks: the
// hash of the name of the resource the chunk belongs to ("h64") and what the
+265 -143
View File
@@ -4,156 +4,289 @@
#include "Engine/Resource/ebx_merge.h"
#include "Engine/Resource/ebx_writer.h"
#include <Windows.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <cstring>
#include <exception>
#include <mutex>
#include <set>
#include <stdexcept>
#include <thread>
namespace dingosdk::mods::detail {
namespace {
// The game's side of what every mod is held against, read once for all of them: forty
// cosmetic mods each patch the same few bundles, the largest with some 17,000 assets, and
// each used to list that bundle again for itself.
struct GameBundle {
std::optional<Listing> listing;
// The game's copy of each asset: its sha1, and where in the listing it is.
std::map<std::string, std::pair<fb::Sha1, std::size_t>, std::less<>> original;
std::map<std::string, const fb::BundleAsset*, std::less<>> scripts;
std::set<std::string, std::less<>> resources;
};
struct GameToc {
fb::TocDocument document;
std::map<std::string, const fb::TocBundle*, std::less<>> bundles;
std::set<fb::Guid> chunks;
};
// Made on first use by whichever thread asks first; the others wait for it. One that could
// not be read throws for each asker, as reading it for each did.
template <class Value> class Shared {
public:
template <class Make> const Value& get(const std::string& key, Make&& make) {
Entry* entry{};
{
std::lock_guard lock(mutex_);
entry = &entries_[key];
}
std::call_once(entry->once, [&] { make(entry->value); });
return entry->value;
}
private:
struct Entry {
std::once_flag once;
Value value;
};
std::mutex mutex_;
std::map<std::string, Entry, std::less<>> entries_; // nodes stay where they are
};
// The game's assets a mod changed. They wait until its additions are settled: one that
// another mod's asset of the same name stands in for is not where a copy can find it, and a
// change must not name it there.
struct Change {
std::string bundle; // lower case: where the mod changed it
std::string name; // lower case
std::string asset; // as the bundle spells it
fb::Sha1 game; // the game's copy it replaces
fb::Sha1 sha1;
std::uint64_t originalSize{};
std::vector<std::byte> encoded;
std::optional<fb::BundleFileInfo> gameFile; // where the game's copy is
};
struct Script {
std::string name; // lower case
fb::Sha1 game; // the game's copy it replaces
AssetOverride value;
};
// What one mod's files say, read without regard to any other mod: the part of the work that
// is reading and decoding, done for several mods at once. What it means beside the mods
// above it is settled afterwards, one mod at a time in priority order.
struct ModRead {
// This mod's new assets, and the partitions its recorded changes import.
// An added asset only follows a change that refers to it: copied on its
// own, it could arrive in another mod's bundle without the resources
// and chunks it was added with.
std::vector<std::pair<std::string, AssetAddition>> candidates, companions;
std::vector<bool> unreadable; // per candidate: never follows
std::map<std::string, std::set<fb::Guid>, std::less<>> addedIn; // the documents it added to each bundle, by guid
std::vector<fb::TocChunk> newChunks; // TOC chunks the game's TOCs do not have
std::set<fb::Guid> imported;
std::vector<Change> changes;
std::vector<Script> scripts;
std::vector<std::string> notes;
std::exception_ptr failure;
};
ModRead read_mod(const Mod& mod, const RelativeFiles& files, const CasStore& store, const fs::path& baseRoot,
const fs::path& gameRoot, Shared<GameToc>& gameTocs, Shared<GameBundle>& gameBundles) {
ModRead read;
auto& [candidates, companions, unreadable, addedIn, newChunks, imported, changes, scripts, notes, failure] = read;
const auto decoded = [&](std::span<const std::byte> encoded) {
return fb::ebx::read_document(fb::decode_cas(encoded, {gameRoot}));
};
for (const auto& relative : files.tocs) {
std::error_code error;
const auto baseToc = baseRoot / fs::path(relative);
if (!fs::is_regular_file(baseToc, error)) continue;
try {
const auto own = fb::read_toc(read_file(mod.directory / fs::path(relative)));
const auto tocKey = lower(relative);
const auto& game = gameTocs.get(tocKey, [&](GameToc& toc) {
toc.document = fb::read_toc(read_file(baseToc));
for (const auto& bundle : toc.document.bundles) toc.bundles.emplace(lower(bundle.name), &bundle);
for (const auto& chunk : toc.document.chunks) toc.chunks.insert(chunk.guid);
});
for (const auto& chunk : own.chunks)
if (!chunk.removed && chunk.location.patch && !game.chunks.contains(chunk.guid)) newChunks.push_back(chunk);
for (const auto& bundle : own.bundles) {
const auto bundleName = lower(bundle.name);
const auto shipped = game.bundles.find(bundleName);
if (shipped == game.bundles.end()) continue;
const auto modListing = list_bundle(store, mod.directory, baseRoot, bundle, gameRoot);
const auto& known = gameBundles.get(tocKey + '|' + bundleName, [&](GameBundle& made) {
made.listing = list_bundle(store, baseRoot, baseRoot, *shipped->second, gameRoot);
if (!made.listing) return;
const auto& manifest = made.listing->manifest;
for (std::size_t index = 0; index < manifest.ebx.size(); ++index)
made.original.emplace(lower(manifest.ebx[index].name), std::pair{manifest.ebx[index].sha1, index});
// Maps carry stock SkaterLoader too. Replacing it only in the game's
// bundles loses custom cosmetic material targets on a map transition.
// Other resource types retain their own table/dependency merge rules.
for (const auto& asset : manifest.resources) {
if (asset.resourceType == luaScriptResourceType) made.scripts.emplace(lower(asset.name), &asset);
made.resources.insert(lower(asset.name));
}
});
if (!modListing || !known.listing) continue;
const auto& gameListing = known.listing;
const auto& original = known.original;
for (std::size_t index = 0; index < modListing->manifest.ebx.size(); ++index) {
const auto& asset = modListing->manifest.ebx[index];
const auto name = lower(asset.name);
const auto game_copy = original.find(name);
if (game_copy != original.end() && game_copy->second.first == asset.sha1) continue;
const auto at = modListing->first + index;
if (at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
const auto payload = [&] {
return store.read(file.location.patch ? mod.directory : baseRoot,
file.location, file.offset, file.size);
};
if (game_copy == original.end()) {
candidates.push_back({bundleName, {mod.name, asset, payload(), tocKey}});
continue;
}
auto encodedPayload = payload();
try {
for (const auto& reference : decoded(encodedPayload).imports)
imported.insert(reference.fileGuid);
} catch (const std::exception&) {}
std::optional<fb::BundleFileInfo> gameFile;
if (const auto gameAt = gameListing->first + game_copy->second.second;
gameAt < gameListing->files.size())
gameFile = gameListing->files[gameAt];
changes.push_back({bundleName, name, asset.name, game_copy->second.first, asset.sha1,
asset.originalSize, std::move(encodedPayload), gameFile});
}
for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) {
const auto& asset = modListing->manifest.resources[index];
if (asset.resourceType != luaScriptResourceType) continue;
const auto name = lower(asset.name);
const auto originalScript = known.scripts.find(name);
if (originalScript == known.scripts.end() || originalScript->second->sha1 == asset.sha1 ||
originalScript->second->resourceId != asset.resourceId) continue;
// (A higher mod's version of the script, or this mod's own from another
// bundle, wins when the mods are settled: read here either way.)
if (std::ranges::any_of(scripts, [&](const Script& have) {
return have.name == name && have.game == originalScript->second->sha1; })) continue;
const auto at = modListing->first + modListing->manifest.ebx.size() + index;
if (at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
scripts.push_back({name, originalScript->second->sha1,
AssetOverride{mod.name, asset.sha1, asset.originalSize,
store.read(file.location.patch ? mod.directory : baseRoot,
file.location, file.offset, file.size), asset}});
}
// Resources the mod adds, kept to go with an added EBX of the same name
// (a wave's sound-bank resource registers the wave with the audio system).
for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) {
const auto& asset = modListing->manifest.resources[index];
const auto at = modListing->first + modListing->manifest.ebx.size() + index;
if (known.resources.contains(lower(asset.name)) || at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
companions.push_back({bundleName, {mod.name, asset,
store.read(file.location.patch ? mod.directory : baseRoot, file.location, file.offset, file.size), tocKey}});
}
}
} catch (const std::exception& failed) {
notes.push_back(mod.name + ": " + relative +
": its changes could not be read for other mods' copies (" + failed.what() + ")");
}
}
// Each added document's guid, and the guids of the documents it refers to in turn.
unreadable.assign(candidates.size(), false);
for (std::size_t index = 0; index < candidates.size(); ++index) {
auto& addition = candidates[index].second;
try {
const auto document = decoded(addition.encoded);
addition.file = document.fileGuid;
for (const auto& reference : document.imports) addition.names.push_back(reference.fileGuid);
addedIn[candidates[index].first].insert(addition.file);
} catch (const std::exception&) { unreadable[index] = true; }
}
return read;
}
} // namespace
AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& mods,
const std::map<const Mod*, RelativeFiles>& modFiles,
const CasStore& store, const fs::path& baseRoot,
const fs::path& gameRoot, MergeReport& report) {
const fs::path& gameRoot, MergeReport& report,
std::size_t threads, bool background) {
AssetOverrides out;
// Where in `out.added` each bundle's addition of a kind and (lower-case) name is: looked up
// for every addition and companion, which a search of the list made quadratic.
std::map<std::string, std::map<std::pair<fb::AssetKind, std::string>, std::size_t>, std::less<>> addedAt;
// 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) {
// A map's edits to the game's assets serve its own levels; asset mods
// (cameras, tuning, cosmetics) are the ones meant to apply everywhere.
if (mod->provides_levels) continue;
const auto files = modFiles.find(mod);
if (files == modFiles.end()) continue;
std::size_t changed{};
// This mod's new assets, and the partitions its recorded changes import.
// An added asset only follows a change that refers to it: copied on its
// own, it could arrive in another mod's bundle without the resources
// and chunks it was added with.
std::vector<std::pair<std::string, AssetAddition>> candidates, companions;
std::vector<fb::TocChunk> newChunks; // TOC chunks the game's TOCs do not have
std::set<fb::Guid> imported;
// The game's assets this mod changed. They wait until its additions are
// settled: one that another mod's asset of the same name stands in for
// is not where a copy can find it, and a change must not name it there.
struct Change {
std::string bundle; // lower case: where the mod changed it
std::string name; // lower case
std::string asset; // as the bundle spells it
fb::Sha1 game; // the game's copy it replaces
fb::Sha1 sha1;
std::uint64_t originalSize{};
std::vector<std::byte> encoded;
std::optional<fb::BundleFileInfo> gameFile; // where the game's copy is
};
std::vector<Change> changes;
const auto decoded = [&](std::span<const std::byte> encoded) {
return fb::ebx::read_document(fb::decode_cas(encoded, {gameRoot}));
};
for (const auto& relative : files->second.tocs) {
std::error_code error;
const auto baseToc = baseRoot / fs::path(relative);
if (!fs::is_regular_file(baseToc, error)) continue;
try {
const auto own = fb::read_toc(read_file(mod->directory / fs::path(relative)));
const auto game = fb::read_toc(read_file(baseToc));
std::map<std::string, const fb::TocBundle*, std::less<>> gameBundles;
for (const auto& bundle : game.bundles) gameBundles.emplace(lower(bundle.name), &bundle);
std::set<fb::Guid> gameChunks;
for (const auto& chunk : game.chunks) gameChunks.insert(chunk.guid);
for (const auto& chunk : own.chunks)
if (!chunk.removed && chunk.location.patch && !gameChunks.contains(chunk.guid)) newChunks.push_back(chunk);
for (const auto& bundle : own.bundles) {
const auto shipped = gameBundles.find(lower(bundle.name));
if (shipped == gameBundles.end()) continue;
const auto modListing = list_bundle(store, mod->directory, baseRoot, bundle, gameRoot);
const auto gameListing = list_bundle(store, baseRoot, baseRoot, *shipped->second, gameRoot);
if (!modListing || !gameListing) continue;
// The game's copy of each asset: its sha1, and where in the listing it is.
std::map<std::string, std::pair<fb::Sha1, std::size_t>, std::less<>> original;
for (std::size_t index = 0; index < gameListing->manifest.ebx.size(); ++index) {
const auto& asset = gameListing->manifest.ebx[index];
original.emplace(lower(asset.name), std::pair{asset.sha1, index});
}
for (std::size_t index = 0; index < modListing->manifest.ebx.size(); ++index) {
const auto& asset = modListing->manifest.ebx[index];
const auto name = lower(asset.name);
const auto game_copy = original.find(name);
if (game_copy != original.end() && game_copy->second.first == asset.sha1) continue;
const auto at = modListing->first + index;
if (at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
const auto payload = [&] {
return store.read(file.location.patch ? mod->directory : baseRoot,
file.location, file.offset, file.size);
};
if (game_copy == original.end()) {
candidates.push_back({lower(bundle.name), {mod->name, asset, payload(), lower(relative)}});
continue;
}
auto encodedPayload = payload();
try {
for (const auto& reference : decoded(encodedPayload).imports)
imported.insert(reference.fileGuid);
} catch (const std::exception&) {}
// A higher mod's version of the asset, which this change is
// combined with below, may name what this mod adds.
if (const auto versions = out.changed.find(name); versions != out.changed.end())
if (const auto existing = versions->second.find(game_copy->second.first);
existing != versions->second.end()) try {
for (const auto& reference : decoded(existing->second.encoded).imports)
imported.insert(reference.fileGuid);
} catch (const std::exception&) {}
std::optional<fb::BundleFileInfo> gameFile;
if (const auto gameAt = gameListing->first + game_copy->second.second;
gameAt < gameListing->files.size())
gameFile = gameListing->files[gameAt];
changes.push_back({lower(bundle.name), name, asset.name, game_copy->second.first, asset.sha1,
asset.originalSize, std::move(encodedPayload), gameFile});
}
// Maps carry stock SkaterLoader too. Replacing it only in the game's
// bundles loses custom cosmetic material targets on a map transition.
// Other resource types retain their own table/dependency merge rules.
std::map<std::string, const fb::BundleAsset*, std::less<>> gameScripts;
for (const auto& asset : gameListing->manifest.resources)
if (asset.resourceType == luaScriptResourceType) gameScripts.emplace(lower(asset.name), &asset);
for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) {
const auto& asset = modListing->manifest.resources[index];
if (asset.resourceType != luaScriptResourceType) continue;
const auto name = lower(asset.name);
const auto originalScript = gameScripts.find(name);
if (originalScript == gameScripts.end() || originalScript->second->sha1 == asset.sha1 ||
originalScript->second->resourceId != asset.resourceId) continue;
auto& versions = out.scripts[name];
if (versions.contains(originalScript->second->sha1)) continue;
const auto at = modListing->first + modListing->manifest.ebx.size() + index;
if (at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
versions.emplace(originalScript->second->sha1,
AssetOverride{mod->name, asset.sha1, asset.originalSize,
store.read(file.location.patch ? mod->directory : baseRoot,
file.location, file.offset, file.size), asset});
++changed;
}
// Resources the mod adds, kept to go with an added EBX of the same name
// (a wave's sound-bank resource registers the wave with the audio system).
std::set<std::string, std::less<>> shippedResources;
for (const auto& asset : gameListing->manifest.resources) shippedResources.insert(lower(asset.name));
for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) {
const auto& asset = modListing->manifest.resources[index];
const auto at = modListing->first + modListing->manifest.ebx.size() + index;
if (shippedResources.contains(lower(asset.name)) || at >= modListing->files.size()) continue;
const auto& file = modListing->files[at];
companions.push_back({lower(bundle.name), {mod->name, asset,
store.read(file.location.patch ? mod->directory : baseRoot, file.location, file.offset, file.size), lower(relative)}});
}
if (const auto files = modFiles.find(mod); files != modFiles.end()) asked.emplace_back(mod, &files->second);
}
// Read: every mod's files, several mods at a time.
Shared<GameToc> gameTocs;
Shared<GameBundle> gameBundles;
std::vector<ModRead> reads(asked.size());
{
std::atomic<std::size_t> next{0};
const auto work = [&] {
for (;;) {
const auto mine = next.fetch_add(1);
if (mine >= asked.size()) return;
try {
reads[mine] = read_mod(*asked[mine].first, *asked[mine].second, store, baseRoot, gameRoot, gameTocs, gameBundles);
} catch (...) {
reads[mine].failure = std::current_exception();
}
} catch (const std::exception& failure) {
report.notes.push_back(mod->name + ": " + relative +
": its changes could not be read for other mods' copies (" + failure.what() + ")");
}
};
std::vector<std::jthread> workers;
try {
// Threads of their own, not the system's pool (see the superbundles in mod_merge.cpp).
for (std::size_t index = 0; index < std::min(threads, asked.size()) && asked.size() > 1; ++index)
workers.emplace_back([&] {
if (background) SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL);
work();
});
} catch (const std::system_error&) {} // fewer threads, or none: this one does the rest
work();
}
const auto decoded = [&](std::span<const std::byte> encoded) {
return fb::ebx::read_document(fb::decode_cas(encoded, {gameRoot}));
};
// Settle: highest priority first, an asset a higher mod already changed keeps that change.
for (std::size_t slot = 0; slot < asked.size(); ++slot) {
const auto* mod = asked[slot].first;
auto& read = reads[slot];
if (read.failure) std::rethrow_exception(read.failure);
auto& [candidates, companions, unreadable, addedIn, newChunks, imported, changes, scripts, notes, failure] = read;
std::size_t changed{};
report.notes.insert(report.notes.end(), std::make_move_iterator(notes.begin()), std::make_move_iterator(notes.end()));
// A higher mod's version of an asset, which this mod's change is combined with
// below, may name what this mod adds.
for (const auto& change : changes)
if (const auto versions = out.changed.find(change.name); versions != out.changed.end())
if (const auto existing = versions->second.find(change.game); existing != versions->second.end()) try {
for (const auto& reference : decoded(existing->second.encoded).imports)
imported.insert(reference.fileGuid);
} catch (const std::exception&) {}
for (auto& script : scripts) {
auto& versions = out.scripts[script.name];
if (versions.contains(script.game)) continue;
versions.emplace(script.game, std::move(script.value));
++changed;
}
// One addition per kind and name in a bundle, the highest-priority mod's. Two
// mods adding the same asset is no clash; two different assets under one name
@@ -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
// imports its new wave, and only the song is named by the changed playlist).
std::vector<bool> followed(candidates.size());
// The documents this mod added to each bundle, by guid.
std::map<std::string, std::set<fb::Guid>, std::less<>> addedIn;
for (std::size_t index = 0; index < candidates.size(); ++index) {
auto& addition = candidates[index].second;
try {
const auto document = decoded(addition.encoded);
addition.file = document.fileGuid;
for (const auto& reference : document.imports) addition.names.push_back(reference.fileGuid);
addedIn[candidates[index].first].insert(addition.file);
} catch (const std::exception&) { followed[index] = true; } // unreadable: never follows
}
std::vector<bool> followed = unreadable; // (an unreadable one never follows)
for (bool grew = true; grew;) {
grew = false;
for (std::size_t index = 0; index < candidates.size(); ++index) {
+175 -71
View File
@@ -6,8 +6,13 @@
#include "Engine/Resource/ebx_document.h"
#include "Engine/Resource/mesh_set.h"
#include <Windows.h>
#include <algorithm>
#include <atomic>
#include <cmath>
#include <mutex>
#include <thread>
#include <stdexcept>
namespace dingosdk::mods {
@@ -231,44 +236,6 @@ public:
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.
// 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
@@ -320,43 +287,126 @@ public:
return items;
}
// Every item the game ships; how many were read.
[[nodiscard]] std::size_t read_game(StoreItems& items, const StoreItem& sold) {
// Every item the game ships, and with `meshes` the meshes its items have: the whole
// costumes' under their items, every other one as nobody's (a costume is partly made of
// clothes anyone has). How many items were read, and how many costumes' meshes.
//
// Each of the game's item bundles is read by itself, on up to `threads` threads beside
// this one; what was read is then filed in the bundles' own order, so a part two items
// share is the same item's whatever the count.
struct GameCounts {
std::size_t items{}, costumes{};
};
[[nodiscard]] GameCounts read_game(StoreItems& items, const StoreItem& sold, StoreMeshes* meshes, std::size_t threads,
bool background) {
const auto* toc = game_toc(items_toc);
if (!toc) return 0;
std::size_t read{};
for (const auto& [name, bundle] : *toc) {
if (!toc) return {};
const auto& document = tocs_.at(lower(items_toc)).document;
struct Read {
struct Item {
ItemContent content;
bool costume{}; // one of the game's whole costumes, by where its asset is
};
std::vector<Item> items;
struct Mesh {
std::string resource;
std::vector<std::vector<std::uint64_t>> levels;
};
std::vector<Mesh> meshes;
std::string note;
};
std::vector<const fb::TocBundle*> bundles;
for (const auto& [name, bundle] : *toc) bundles.push_back(bundle);
std::vector<Read> reads(bundles.size());
const auto read_bundle = [&](std::size_t at) {
auto& read = reads[at];
try {
const auto listing = list_bundle(store_, baseRoot_, baseRoot_, *bundle, gameRoot_);
if (!listing) continue;
for (std::size_t index = 0; index < listing->manifest.ebx.size(); ++index) {
if (!item_name(lower(listing->manifest.ebx[index].name))) continue;
const auto listing = list_bundle(store_, baseRoot_, baseRoot_, *bundles[at], gameRoot_);
if (!listing) return;
const auto& manifest = listing->manifest;
for (std::size_t index = 0; index < manifest.ebx.size(); ++index) {
const auto name = lower(manifest.ebx[index].name);
if (!item_name(name)) continue;
try {
const auto document = fb::ebx::read_document(
read_asset(store_, baseRoot_, baseRoot_, *listing, index, gameRoot_));
const auto item = item_content(document);
if (!item) continue;
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});
const auto asset = fb::ebx::read_document(read_asset(store_, baseRoot_, baseRoot_, *listing, index, gameRoot_));
if (auto item = item_content(asset)) read.items.push_back({std::move(*item), name.starts_with(costume_items)});
} catch (const std::exception&) {}
}
if (!meshes) return;
for (std::size_t index = 0; index < manifest.resources.size(); ++index) {
if (manifest.resources[index].resourceType != mesh_set_type) continue;
Read::Mesh mesh{manifest.resources[index].name, {}};
read_mesh(baseRoot_, *listing, manifest.ebx.size() + index, document, 16,
[&](std::vector<std::uint64_t> points) { mesh.levels.push_back(std::move(points)); });
if (!mesh.levels.empty()) read.meshes.push_back(std::move(mesh));
}
} catch (const std::exception& failure) {
note("The game's " + bundle->name + " could not be read for " + std::string(store_copies_check) + " (" + failure.what() + ")");
read.note = "The game's " + bundles[at]->name + " could not be read for " + std::string(store_copies_check) + " (" + failure.what() + ")";
}
};
{
std::atomic<std::size_t> next{0};
const auto work = [&] {
for (;;) {
const auto mine = next.fetch_add(1);
if (mine >= bundles.size()) return;
read_bundle(mine);
}
};
std::vector<std::jthread> workers;
try {
// Threads of their own, not the system's pool (see the superbundles in mod_merge.cpp).
for (std::size_t index = 0; index < threads && bundles.size() > 1; ++index)
workers.emplace_back([&] {
if (background) SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL);
work();
});
} catch (const std::system_error&) {} // fewer threads, or none: this one does the rest
work();
}
GameCounts counts;
for (auto& read : reads) {
if (!read.note.empty()) note(std::move(read.note));
for (const auto& item : read.items) {
const bool store = sold(lower(item.content.key));
items.add(item.content, store);
++counts.items;
if (!item.costume) continue;
for (const auto& shape : item.content.shapes)
if (shape.starts_with(costume_shape))
costumes_.insert_or_assign(shape.substr(costume_shape.size()), Costume{item.content.key, store});
}
}
return read;
if (!meshes || costumes_.empty()) return counts;
// A costume's own bundle is named after the preset its item is fitted on.
for (std::size_t at = 0; at < bundles.size(); ++at) {
const auto name = lower(bundles[at]->name);
const Costume* costume{};
if (name.ends_with(costume_bundle_suffix)) {
const auto stem = name.substr(0, name.size() - costume_bundle_suffix.size());
const auto slash = stem.rfind('/');
const auto found = costumes_.find(stem.substr(slash == std::string::npos ? 0 : slash + 1));
if (found != costumes_.end()) costume = &found->second;
}
for (const auto& mesh : reads[at].meshes)
for (const auto& level : mesh.levels) {
// (Each other mesh under its own name, so two of them sharing a point is also seen.)
if (costume) meshes->add(costume->key, costume->sold, level);
else meshes->add("mesh " + mesh.resource, false, level);
}
counts.costumes += costume && !reads[at].meshes.empty();
}
return counts;
}
private:
using Bundles = std::map<std::string, const fb::TocBundle*, std::less<>>;
using Assets = std::map<std::string, fb::Sha1, std::less<>>;
// (Both of these are asked by several threads while the mods are read: one at a time,
// and what they hand out stays where it is.)
const Bundles* game_toc(std::string_view relative) {
std::lock_guard lock(cache_mutex_);
const auto key = lower(relative);
if (const auto found = tocs_.find(key); found != tocs_.end())
return found->second.index.empty() ? nullptr : &found->second.index;
@@ -377,6 +427,7 @@ private:
// The game's copy of a bundle: each EBX it holds, to tell a mod's additions
// and changes from what it carries untouched. Null when it cannot be read.
const Assets* game_assets(std::string_view relative, const fb::TocBundle& bundle) {
std::lock_guard lock(cache_mutex_);
const auto key = lower(relative) + '|' + lower(bundle.name);
if (const auto found = assets_.find(key); found != assets_.end())
return found->second ? &*found->second : nullptr;
@@ -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) {
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 {
@@ -435,6 +489,22 @@ private:
std::vector<std::string>* notes_;
std::map<std::string, GameToc, std::less<>> tocs_;
std::map<std::string, std::optional<Assets>, std::less<>> assets_;
std::mutex cache_mutex_;
static inline thread_local std::vector<std::string>* note_sink = nullptr;
public:
// While one lives, this thread's notes go into `kept`.
struct NotesFor {
explicit NotesFor(std::vector<std::string>& kept) { note_sink = &kept; }
~NotesFor() { note_sink = nullptr; }
NotesFor(const NotesFor&) = delete;
NotesFor& operator=(const NotesFor&) = delete;
};
void say(std::vector<std::string>& kept) {
if (notes_) notes_->insert(notes_->end(), std::make_move_iterator(kept.begin()), std::make_move_iterator(kept.end()));
}
private:
// The game's whole costumes: the preset each is fitted on -> its item.
struct Costume {
std::string key;
@@ -545,7 +615,8 @@ std::string StoreMeshes::original(const std::vector<std::uint64_t>& points) cons
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;
try {
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<Added> added;
for (const auto& mod : catalog.mods) {
if (!mod.provides_layout) continue;
Added entry{&mod, scan.read_mod(mod), scan.read_mod_meshes(mod)};
result.mod_meshes += entry.meshes.size();
if (!entry.items.empty() || !entry.meshes.empty()) added.push_back(std::move(entry));
{
// Each mod is read by itself, several at a time; they are gone through in their own
// order afterwards.
std::vector<const Mod*> asked;
for (const auto& mod : catalog.mods)
if (mod.provides_layout) asked.push_back(&mod);
std::vector<Added> reads(asked.size());
std::vector<std::vector<std::string>> said(asked.size());
std::atomic<std::size_t> next{0};
const auto work = [&] {
for (;;) {
const auto mine = next.fetch_add(1);
if (mine >= asked.size()) return;
const Scan::NotesFor kept(said[mine]);
try {
reads[mine] = {asked[mine], scan.read_mod(*asked[mine]), scan.read_mod_meshes(*asked[mine])};
} catch (...) {
reads[mine] = {asked[mine], {}, {}}; // (neither reader throws; a mod that cannot be read adds nothing)
}
}
};
{
std::vector<std::jthread> workers;
try {
for (std::size_t index = 0; index < threads && asked.size() > 1; ++index)
workers.emplace_back([&] {
if (background) SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL);
work();
});
} catch (const std::system_error&) {} // fewer threads, or none: this one does the rest
work();
}
for (std::size_t index = 0; index < asked.size(); ++index) {
scan.say(said[index]);
result.mod_meshes += reads[index].meshes.size();
if (!reads[index].items.empty() || !reads[index].meshes.empty()) added.push_back(std::move(reads[index]));
}
}
if (added.empty()) return result;
// The costumes' meshes are only read when a mod ships a mesh at all.
StoreItems game;
result.game_items = scan.read_game(game, sold);
StoreMeshes costumes;
const auto counts = scan.read_game(game, sold, result.mod_meshes ? &costumes : nullptr, threads, background);
result.game_items = counts.items;
result.game_costumes = counts.costumes;
if (!result.game_items) {
if (notes) notes->push_back(std::string(store_copies_check) + " could not read the game's own files and did not run");
return result;
}
// 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) {
StoreCopies::Source source{mod->name};
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
// has an item at all. Never throws: a bundle that cannot be read is left out
// (the merge leaves its mod out of the game too) and noted in `notes`.
// The game's own files are read on up to `threads` threads beside the caller's
// (`background`: at a priority under the game's, for a merge while it runs); the verdicts do
// not depend on the count.
[[nodiscard]] StoreCopies check_store_copies(const Catalog& catalog, const StoreItem& sold,
std::vector<std::string>* notes = nullptr) noexcept;
std::vector<std::string>* notes = nullptr, std::size_t threads = 0,
bool background = false) noexcept;
} // namespace dingosdk::mods
+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::mutex state_mutex;
std::string last_status;
Progress merge_progress; // under state_mutex, while merging
std::optional<std::vector<std::filesystem::path>> pending_levels;
std::optional<std::vector<std::string>> applied; // set by a live apply; the launch's list before one
std::string current_level;
@@ -345,7 +346,11 @@ void apply_now() {
mods::MergeReport report;
std::string left_out;
for (int round = 0;; ++round) {
report = mods::merge_mods(catalog, {}, {.live = true});
// The mods in the game now were checked against the store when they went in.
report = mods::merge_mods(catalog, [](const mods::MergeProgress &now) {
std::lock_guard lock(state_mutex);
merge_progress = {now.done, now.total, now.step};
}, {.live = true, .checked = before});
if (!report.issue.empty()) break;
bool removed = false;
for (std::size_t i = catalog.mods.size(); i-- > 0;) {
@@ -436,6 +441,9 @@ void apply_now() {
for (const auto &mod : catalog.mods) applied->push_back(mod.name);
}
if (!left_out.empty()) problem += (problem.empty() ? "left out " : "; left out ") + left_out + ", which could not be merged cleanly";
// Where the time went, step by step, as the launch's merge logs it.
for (const auto &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;
set_status("applied " + std::to_string(catalog.mods.size()) + " mod(s) in " + std::to_string(elapsed).substr(0, 4) + "s; " +
(needed ? "reloading the level"
@@ -462,6 +470,10 @@ void run() {
std::string apply(bool reload_level) {
if (!layout_objects().manager) return "No merged mod patch was mounted at launch; restart the game to load mods.";
if (merging.exchange(true)) return "Mods are already being applied.";
{
std::lock_guard lock(state_mutex);
merge_progress = {};
}
reload_after = reload_level;
std::thread(run).detach();
return reload_level ? "Applying mods; the level reloads when they are ready, unless only maps changed."
@@ -511,6 +523,11 @@ ApplyEffect preview(const mods::ModList &list) {
return reload ? ApplyEffect::reload_level : ApplyEffect::maps_only;
}
bool busy() noexcept { return merging.load(); }
Progress progress() {
std::lock_guard lock(state_mutex);
return merge_progress;
}
bool only_maps(const std::filesystem::path& directory) { return scan_maps_only(directory); }
std::string status() {
std::lock_guard lock(state_mutex);
return last_status;
+9
View File
@@ -47,6 +47,12 @@ inline const char* apply_label(ApplyEffect effect) {
void set_current_level(std::string level);
// True while a live merge runs.
bool busy() noexcept;
// How far it is: steps done of all, and the step in hand ("Merging items from 12 mods").
struct Progress {
std::size_t done{}, total{};
std::string step;
};
Progress progress();
// The last live apply's outcome for the overlay, or empty before the first.
std::string status();
// Folder names of the mods in effect now: the launch's, or the last live apply's.
@@ -54,6 +60,9 @@ std::vector<std::string> applied_mods();
// True when the launch's merge placed this mod's archives (enabled or not),
// so it can be enabled without a restart.
bool placed_at_launch(const std::string& name);
// Whether a mod folder holds maps and nothing else: level bundles and the two files maps
// register themselves in (its layout and globals). Reads the folder; not cached.
bool only_maps(const std::filesystem::path& directory);
// reskate-levels.json of every mod a live apply left enabled, highest
// priority first; handed out once per successful apply.
std::optional<std::vector<std::filesystem::path>> take_level_manifests();
+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");
if ((p.kind == PacketKind::map_offer || p.kind == PacketKind::world_state) && !valid_map_label(p.map_label))
throw std::invalid_argument("Invalid map name");
if ((p.kind == PacketKind::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 &&
(p.destination.empty() ? (p.map != 0 || p.world_ready)
: (!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.integer(p.map_label.size(), 1);
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) {
w.integer(p.text.size(), 1);
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.integer(p.map_label.size(), 1);
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) {
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) {
w.integer(p.pose.skater.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;
p.map_label.assign(reinterpret_cast<const char *>(bytes.data() + r.at), static_cast<std::size_t>(length));
r.at += static_cast<std::size_t>(length);
return valid_map_label(p.map_label);
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.
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))
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) {
const auto length = r.integer(1);
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)
for (auto &v : p.build) v = static_cast<std::uint8_t>(r.integer(1));
const auto ready = r.integer(1);
if (ready > 1) return {};
p.world_ready = ready != 0;
if (ready > (p.kind == PacketKind::world_ready ? 2U : 1U)) return {};
p.world_ready = ready == 1;
p.map_fetching = ready == 2;
if (p.kind == PacketKind::world_state) {
const auto length = r.integer(2);
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 {
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 {
if (pool.size() > maps) return false;
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_packet = 24576;
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).
inline constexpr std::uint32_t default_server_chat_badge = 0xffff5c8eU, default_server_chat_text = 0xffffc8d9U;
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_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
// 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 {
std::array<float, 3> position{};
std::array<float, 4> rotation{0, 0, 0, 1};
@@ -201,6 +215,9 @@ struct Packet {
std::string destination;
bool map_authorized{};
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;
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)
@@ -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::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_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::array<float, 3> teleport{}; // teleport: where the receiver goes (world position)
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_destination.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.travelling = false;
s.host_world_ready = true;
@@ -208,6 +211,7 @@ void clear_world(Session &s, std::uint64_t now) {
peer.password_challenge = challenge;
peer.world_ready = false;
peer.travel_since = admitted ? now : 0;
peer.fetching_since = 0;
peer.last_packet = now;
}
reset_audio();
@@ -688,6 +692,9 @@ MultiplayerModel model() {
return s.view;
}
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() {
auto &s = session();
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) {
if (!s.awaiting_map)
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.");
return false;
}
@@ -768,6 +776,8 @@ bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoade
return false;
}
s.awaiting_map = false;
s.fetching_since = 0;
fetching_map_flag.store(false, std::memory_order_relaxed);
s.last_hello = 0;
s.status = s.travelling ? "Your map is ready. Waiting for the host..." : "Host map ready. Joining the session...";
return true;
@@ -783,8 +793,31 @@ bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoade
if (result == MapLoadResult::missing) {
const auto name = s.map_label.empty() ? world_level_name(world_destination_asset(s.join_destination)) : s.map_label;
const char *who = dedicated_host(s) ? "server" : "host";
s.leave_notice = (s.travelling ? std::string("The ") + who + " moved to " : std::string("The ") + who + " is on ") +
name + ", which is not installed on this PC. Install its map mod and join again.";
const auto where = (s.travelling ? std::string("The ") + who + " moved to " : std::string("The ") + who + " is on ") + name;
// 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);
return false;
}
+21
View File
@@ -1,6 +1,7 @@
#pragma once
#include "Engine/Game/Multiplayer/session_model.h"
#include <cstdint>
#include <optional>
#include <string>
namespace dingosdk::multiplayer {
@@ -22,6 +23,26 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool gameplay_ready, std::
MapLoader loader = nullptr);
MultiplayerModel model();
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.
MultiplayerChat chat();
} // namespace dingosdk::multiplayer
@@ -613,7 +613,7 @@ bool own_mark_command(std::string_view action) {
} // namespace
bool queue_command(std::string_view action, std::string_view argument, std::string_view password) {
if (launcher::offline_mode() && !own_mark_command(action)) return false;
if ((action != "host" && action != "host-config" && action != "join" && action != "join-lobby" && action != "join-friend-lobby" && action != "stop" &&
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 != "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" &&
@@ -922,8 +922,17 @@ std::string command(std::string_view action, std::string_view argument, std::str
publish(s);
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.";
s.rejoin.reset();
s.map_need.reset();
unsigned capacity = multiplayer_lobby_player_limit;
unsigned tps = multiplayer_default_tps;
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.");
}
std::optional<Invite> invitation;
if (action == "join") {
invitation = parse_invite(argument);
if (rejoin) invitation = rejoin->invite;
if (action == "join" || rejoin) {
if (!rejoin) invitation = parse_invite(argument);
if (!invitation)
return refuse("Invalid join code. Paste the complete SteamID-session code from the host.");
if (blocked_server(invitation->steam_id)) return refuse(std::string(blocked_server_notice));
@@ -985,7 +995,7 @@ std::string command(std::string_view action, std::string_view argument, std::str
s.epoch = nonce();
s.secret = invitation ? invitation->secret : nonce();
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") {
s.mode = Mode::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/sound_codec.h"
#include "password.h"
#include "Engine/Game/World/world_names.h"
#include "client_timing.h"
#include "monotonic_clock.h"
#include <atomic>
@@ -80,6 +81,7 @@ struct Peer {
std::uint64_t last_map_offer{};
bool world_ready = true;
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::uint64_t next_dial{}, last_direct_hello{}, last_direct_pose{}, route_reported{};
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)
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_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::uint64_t joined_public_lobby{};
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);
// 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<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{};
// "/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);
@@ -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) {
auto request = packet(s, PacketKind::map_request, now);
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) {
request.challenge = host->password_challenge;
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.map_name.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_load_submitted = false;
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()) {
s.join_destination = s.map_name = p.destination;
s.map_label = p.map_label;
s.map_package = p.map_package;
s.map = p.map;
}
s.host_world_ready = p.world_ready;
@@ -645,6 +650,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
continue;
link->ready_sequence = p.sequence;
link->last_packet = now;
if (p.map_fetching) guest_fetching(*link, now);
const bool arrived = p.world_ready && !link->world_ready;
link->world_ready = p.world_ready;
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.");
continue;
}
if (p.map_fetching) guest_fetching(*link, now);
link->member.epoch = p.epoch;
bool authorized = !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) {
auto offer = packet(s, PacketKind::map_offer, now);
offer.destination = s.map_name;
offer.map_package = host_map_package(s.map_name);
offer.challenge = link->password_challenge;
offer.map_authorized = authorized;
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;
s.join_destination = p.destination;
s.map_label = p.map_label;
s.map_package = p.map_package;
s.world = p.world;
s.join_map_authorized |= p.map_authorized;
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) {
auto ready = packet(s, PacketKind::world_ready, now);
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));
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;
// Never admitted: it held a player slot meanwhile. An ID that keeps failing waits
// longer each time before its connection is taken again.
if (s.mode == Mode::host && !s.peers[i].handshaken) {
// 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);
logging::log(logging::Level::info, logging::Channel::runtime,
"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) {
auto state = packet(s, PacketKind::world_state, now);
state.destination = s.map_name;
state.map_package = host_map_package(s.map_name);
state.world_ready = s.host_world_ready;
const auto bytes = encode_wire(state);
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.active = s.mode != Mode::off;
view.hosting = s.mode == Mode::host;
view.map_fetching = s.fetching_since != 0;
view.echo = s.mode == Mode::echo;
view.local_id = t.local_id;
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;
unnamed.map_label.assign(max_member_name + 1, 'a');
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;
stray.map_pool = {2};
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{};
ULONGLONG repeat_at{};
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{};
};
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.");
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) {
SecureZeroMemory(secret.data(), secret.size());
secret.clear();
@@ -355,7 +367,9 @@ void join(Hub &h, const Row &row) {
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())) {
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);
}
}
@@ -519,7 +533,7 @@ void browser_page(Ui &ui, ImVec2 a, ImVec2 z) {
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",
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;
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");
@@ -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);
}
// 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
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 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,
!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.clicked = ui.input && ImGui::IsMouseClicked(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.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.
if (mp.active != h.was_active) {
h.was_active = mp.active;
h.tab = mp.active ? Tab::session : Tab::browser;
// In a session, as far as the tabs go: not one that is still waiting on a map being fetched,
// which the player may yet turn down.
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();
}
if ((h.tab == Tab::session || h.tab == Tab::voice) && !mp.active) h.tab = Tab::browser;
const bool runs_session = mp.active && (mp.hosting || mp.server_admin);
if (h.tab == Tab::settings && !runs_session) h.tab = mp.active ? Tab::session : Tab::browser;
if ((h.tab == Tab::session || h.tab == Tab::voice) && !in_session) h.tab = Tab::browser;
const bool runs_session = in_session && (mp.hosting || mp.server_admin);
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
if (ui.input) DingoSDKOverlayReadControllerInput(&ui.pad, true);
@@ -1388,7 +1492,7 @@ void draw_hub_page() {
} else {
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.
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.
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);
}
// 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);
else switch (h.tab) {
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::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;
// 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.
+124 -1
View File
@@ -18,9 +18,20 @@ namespace dingosdk::overlay::detail {
// 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.
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() {
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);
return capture;
}
@@ -37,6 +48,10 @@ struct InputCaptureHooks {
Hook register_raw;
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;
// 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;
std::array<Hook, 2> device_create;
// 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);
}
// 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) {
const auto value = original<SHORT(WINAPI*)(int)>(input_hooks().async_key)(key);
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)>(
input_hooks().raw_buffer)(data, size, header_size);
const bool capture = result != UINT(-1) && data && block_polled_input();
note_prompt_read(prompt_raw);
if (result != UINT(-1) && data && header_size == sizeof(RAWINPUTHEADER)) {
// Skate drains its mouse here; read the packets on the way past.
// 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) {
const auto& hook = Extended ? input_hooks().xinput_extended[I] : input_hooks().xinput_state[I];
const bool capture = block_polled_input();
note_prompt_read(prompt_xinput);
DWORD result;
{ // XInput exports can forward through another hooked XInput export.
OverlayInputAccess access;
@@ -483,6 +605,7 @@ template<std::size_t I>
HRESULT STDMETHODCALLTYPE captured_device_state(void* device, DWORD size, LPVOID data) {
const auto result = original<HRESULT(STDMETHODCALLTYPE*)(void*, DWORD, LPVOID)>(
input_hooks().device_state[I])(device, size, data);
note_prompt_read(prompt_direct);
if (SUCCEEDED(result) && data && block_polled_input()) {
std::memset(data, 0, size);
std::lock_guard lock(input_hooks().formats_mutex);
+14
View File
@@ -1,5 +1,6 @@
#pragma once
#include <Windows.h>
#include <string>
namespace dingosdk::overlay::detail {
extern thread_local unsigned overlay_input_access;
struct OverlayInputAccess {
@@ -11,6 +12,19 @@ struct OverlayInputAccess {
// through GetRawInputData is then not noted a second time.
extern thread_local HRAWINPUT noted_raw_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();
// 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
+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) {
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) {
+23
View File
@@ -277,6 +277,23 @@ struct CallbacksV2 {
using MultiplayerQueue = bool (*)(const char *action, const char *argument, const char *password,
char *result, std::size_t size);
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).
using ChatFeed = MultiplayerChat (*)();
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.
extern "C" void DingoSDKOverlayReadFlightInput(dingosdk::overlay::FlightInput* input, bool flight_active, bool player_flight = false);
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
// gates still apply. Gameplay callers leave it 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 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 ((capture || freecam_capture) && is_input(message)) {
// 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();
for (;;) {
WaitForSingleObject(slots.wake.load(), INFINITE);
keep_xinput_capture_first();
OverlayInputAccess access;
for (DWORD slot = 0; get_state && slot < XUSER_MAX_COUNT; ++slot) {
const unsigned bit = 1u << slot;
@@ -418,6 +420,47 @@ extern "C" void DingoSDKOverlaySetFreecamInputCapture(bool active) {
"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) {
if (!output) return;
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
// for that long, as for chat.
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> stop{false};
std::atomic<HWND> window{nullptr};
@@ -145,6 +148,7 @@ struct State {
bool win32_ready = false;
bool dx12_ready = false;
std::atomic<bool> freecam_controller_active{false};
std::atomic<bool> prompt_input_active{false}; // DingoSDKOverlaySetPromptInputCapture
bool show_on_ready = false;
bool force_windowed = false;
HANDLE stop_event = nullptr;
@@ -164,6 +168,9 @@ struct State {
ComPtr<ID3D12CommandQueue> queue;
ComPtr<ID3D12Device> device;
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<ID3D12Fence> fence;
HANDLE fence_event = nullptr;
@@ -270,6 +277,19 @@ void draw_notices();
// chat_pending also refreshes the feed, so it runs every presented frame.
bool chat_pending();
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.
bool game_text_pending();
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.ui_was_interactive = false;
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();
if (s.fence_event) CloseHandle(s.fence_event);
s.fence_event = nullptr;
@@ -232,7 +234,7 @@ bool setup_graphics() {
return false;
}
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;
graphics_result = s.device->CreateDescriptorHeap(&heap, IID_PPV_ARGS(&s.srvs));
if (FAILED(graphics_result)) {
@@ -441,6 +443,86 @@ void draw_menu() {
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) {
auto& s = state();
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.
// Refreshes the chat feed every frame, which also decides whether T opens it.
const bool chat_frame = chat_pending();
const bool map_download_frame = map_download_pending();
const bool game_text_frame = game_text_pending();
const bool skate_hud_frame = skate_hud_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.
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) {
s.ui_was_interactive = true;
s.last_model = {}; // Reopening immediately reads fresh state.
@@ -558,6 +641,7 @@ void render(IDXGISwapChain* presented, UINT flags) {
draw_trainer_hud();
draw_notices();
draw_chat();
draw_map_download();
sync_menu_cursor(); // close buttons also change visibility, without a key message
ImGui::Render();
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))) {
ImGui::SetCurrentContext(previous); s.failed = true; restore_input(true); return;
}
upload_card_image(s);
D3D12_RESOURCE_BARRIER barrier{};
barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
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.
// 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) {
const auto query = lower(store.search.data());
std::vector<const ts::Package*> result;
for (const auto& package : store.packages) {
const bool have = installed.contains(ts::folder_for(package.full_name));
// Deprecated and NSFW packages only show once installed.
if ((package.deprecated || package.nsfw) && !have) continue;
// NSFW packages are never listed here, installed or not: by Thunderstore's own mark,
// 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 (!query.empty() && lower(package.title()).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::SameLine();
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)
if (std::find(categories.begin(), categories.end(), category) == categories.end()) categories.push_back(category);
}
std::sort(categories.begin(), categories.end());
ImGui::SetNextItemWidth(combo);
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_board.h"
#include "Extension/Assets/live_mods.h"
#include "Extension/Assets/map_download.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Core/Profiling/profiler.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.
std::array<std::uint32_t, dingosdk::action_binds.size()> action_combos{};
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();
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;
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 (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_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());
}
// 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);
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 ||
vote_yes_combo || vote_no_combo || poll_answers || std::ranges::any_of(action_combos, [](auto combo) { return combo != 0; }))
DingoSDKOverlayReadControllerInput(&controller);
vote_yes_combo || vote_no_combo || poll_answers || map_prompt ||
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)
if (r.action_bind_latches[i].update(action_combos[i], controller, r.observer_failed)) {
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
// 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", ""))
record("{\"event\":\"controller_binding_triggered\",\"action\":\"vote_yes\"}");
if (r.vote_no_bind_latch.update(vote_no_combo, controller, !vote_open) && dingosdk::multiplayer::queue_command("vote", "no", ""))
record("{\"event\":\"controller_binding_triggered\",\"action\":\"vote_no\"}");
if (r.vote_yes_bind_latch.update(vote_yes_combo, controller, !vote_open && !map_prompt)) {
if (map_prompt) dingosdk::map_download::answer(true);
else if (dingosdk::multiplayer::queue_command("vote", "yes", ""))
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
// the keys are the game's own the rest of the time.
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);
if (auto notice = dingosdk::multiplayer::take_leave_notice(); !notice.empty())
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();
// 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/supported_build.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Extension/Assets/map_download.h"
#include "Extension/Boot/offline_boot.h"
#include "Extension/HallOfMeat/hall_of_meat.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::overlay::set_multiplayer_queue(queue_multiplayer_command);
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_callbacks(native_callbacks);
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
-----------
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
chosen by name. Players need the same map mod installed to join.
next to the server (its reskate-levels.json and manifest.json are read). The
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
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;
text << in.rdbuf();
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")) {
const auto asset = level.at("asset").string();
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); }))
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) {
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;
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;
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"...
struct ServerLevel {
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
// (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);
// A map's name for people: "San Vansterdam".
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
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
+30 -4
View File
@@ -118,6 +118,21 @@ void Host::save() {
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::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
@@ -698,6 +713,7 @@ void Host::send_world_state() {
auto state = packet(PacketKind::world_state, now_);
state.destination = map_destination(config_.map);
state.map_label = wire_map_label();
state.map_package = map_package(config_.map);
state.world_ready = true; // the server has nothing to load
const auto bytes = encode_wire(state);
for (auto &[id, guest] : guests_)
@@ -793,6 +809,7 @@ void Host::change_map(std::string_view map) {
g.scoring_flagged = old.scoring_flagged;
g.world_ready = false;
g.travel_since = g.handshaken ? now_ : 0;
g.fetching_since = 0;
g.last_packet = now_;
}
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 {
// Never admitted: it held a player slot meanwhile, so it shows in the log, and an ID
// that keeps failing waits longer each time before its connection is taken again.
const auto failures = join_backoff_.failed(id, now_);
log_("[join] " + std::to_string(id) + " did not finish joining (" + reason + ")" +
(failures > 1 ? ", attempt " + std::to_string(failures) : std::string{}) +
(detail.empty() ? std::string{} : " [" + detail + "]"));
// Not one that was let in (past the password) and left while fetching the map: turning
// a download down, or stopping one, is no failed attempt, and they may come straight back.
if (found->second->map_authorized && found->second->fetching_since) {
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);
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;
link->ready_sequence = p.sequence;
link->last_packet = now_;
if (p.map_fetching) fetching(*link);
const bool arrived = p.world_ready && !link->world_ready;
if (!p.world_ready && link->world_ready) link->loading_since = now_;
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.");
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_fetching) fetching(*link);
link->member.epoch = p.epoch;
bool authorized = !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_);
offer.destination = map_destination(config_.map);
offer.map_label = wire_map_label();
offer.map_package = map_package(config_.map);
offer.challenge = link->password_challenge;
offer.map_authorized = authorized;
send_required(*link, encode_wire(offer));
+5
View File
@@ -75,6 +75,10 @@ class Host {
bool handshaken{}, map_authorized{}, world_ready = true;
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)
// 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{};
ReceiveBudget budget;
DeltaSender sender;
@@ -328,6 +332,7 @@ class Host {
void send_maps(Guest &admin);
void change_map(std::string_view map); // a level name, level path or destination
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 accept_data(Guest &source, const Packet &);
// `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_include_directories(dingosdk_thunderstore_tests PRIVATE "${PROJECT_SOURCE_DIR}")
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)
target_include_directories(dingosdk_game_settings_tests PRIVATE "${PROJECT_SOURCE_DIR}")
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/mod_layers.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/Scripting/lua_startup.cpp
Extension/Scripting/custom_script_loader.cpp
@@ -194,7 +197,7 @@ add_library(dingosdk_runtime SHARED
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
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)
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_notices.cpp
Extension/UI/Overlay/chat_overlay.cpp
Extension/UI/Overlay/map_download_card.cpp
Extension/UI/Overlay/hub_page.cpp
Extension/UI/Overlay/game_text_overlay.cpp
Extension/UI/Overlay/perf_overlay.cpp