mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 13:57:05 +02:00
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:
1 parent
b7936a36c6
commit
0ae0e71f0f
46 files changed
+2448
-275
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ¬e : 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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user