mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-25 07:14:41 +01:00
Votes by those who vote, map versions, maps reload, and a crash leaving a map
Votes: a vote is decided by the votes cast, not by everyone on the server. It
passes when yes is its percentage of them, at the end of its time, or sooner
once the players still to vote could not change it. A kick still needs two
yes. The card's bar is yes in green from the left and no in red from the
right, with a mark where yes has to reach. No protocol change.
Map versions: a map installed at another version than the host's is no longer
loaded as it is. The download card offers the host's version (MAP VERSION,
UPDATE), which replaces the installed one and is applied while the game runs.
For that the live merge keeps each mod's fingerprint with where its archives
were placed, and places a mod again when its files have changed since: before,
a mod replaced while the game ran was read where the old one lay. When the
host's version is gone from Thunderstore, or the old one's files are in use,
the player is told and leaves the session.
Dedicated server: "maps reload" reads the Mods folder again, so a map copied
in while the server runs can be switched to at once. The server window reads a
copy of the map list.
Map names: a custom map's level is dingolevel_reskate_<map>, and "reskate" was
left in its name ("/map reskate badlands"). The map's own name is shown and
typed now; the old spelling still finds it.
as a level unloads, the game takes each mesh variation out of its registry by the
entry's mesh and reads it unchecked, and this map's meshes unload before
their databases. A guard on that one function has the read of a mesh that is
gone go on with the next entry, and logs how many were passed over.
This commit is contained in:
1 parent
1b15a04e29
commit
8351654222
29 files changed
+423
-40
No files matched your search
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Build/fingerprint.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
// The game's mesh variation databases (Extension/World/mesh_unload_guard.cpp).
|
||||
namespace dingosdk::game::build::v20260929::mesh_variations {
|
||||
// remove_databases(registry, ?, list): run on a job worker as a level's bundles unload. For
|
||||
// each MeshVariationDatabase in the list it takes every entry out of the registry
|
||||
// (0x3f5a200), keyed by the entry's mesh (its name hash, mesh + 0xc8) and variation hashes
|
||||
// (entry + 0x20, + 0x24). It holds the registry's lock (0x158f2c0 / 0x158f430) throughout.
|
||||
inline constexpr Fingerprint remove_databases{0x3f5fbc0, {
|
||||
0x48,0x89,0x54,0x24,0x10,0x53,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,
|
||||
0x83,0xec,0x28,0x48,0x8b,0x79,0x10,0x4c,0x8b,0xe2,0x4c,0x8b,0xe9,0x49,0x8b,0xd8}};
|
||||
// The read of the mesh's name hash (mov edx, [rax + 0xc8]), with no check that the entry still
|
||||
// has its mesh: an access violation reading 0xc8 when the mesh was unloaded before its
|
||||
// database (crash reports of 2026-10-08 to 2026-10-11, always on leaving one custom map).
|
||||
inline constexpr std::uintptr_t mesh_hash_read = 0x3f5fc52;
|
||||
inline constexpr std::array<unsigned char, 6> mesh_hash_read_bytes{0x8b, 0x90, 0xc8, 0x00, 0x00, 0x00};
|
||||
// The step to the next entry (add rbx, 0x30), after the call that takes one out: where an entry
|
||||
// without a mesh goes on from.
|
||||
inline constexpr std::uintptr_t next_entry = 0x3f5fc67;
|
||||
inline constexpr std::array<unsigned char, 4> next_entry_bytes{0x48, 0x83, 0xc3, 0x30};
|
||||
}
|
||||
@@ -9,10 +9,22 @@ inline std::string world_level_short_name(std::string_view asset) {
|
||||
auto folded = name;
|
||||
for (auto& c : folded) if (c >= 'A' && c <= 'Z') c = static_cast<char>(c + ('a' - 'A'));
|
||||
if (folded.starts_with("dingolevel_")) { name.erase(0, 11); folded.erase(0, 11); }
|
||||
// A custom map's level is dingolevel_reskate_<map> (ReSkate Studio): the map's own name is
|
||||
// what is shown and typed.
|
||||
if (folded.starts_with("reskate_") && folded.size() > 8) { name.erase(0, 8); folded.erase(0, 8); }
|
||||
if (folded.ends_with("_levelroot")) name.resize(name.size() - 10);
|
||||
std::replace(name.begin(), name.end(), '_', ' ');
|
||||
return name;
|
||||
}
|
||||
// A map's name as a player typed it. Custom maps used to be listed as "reskate <map>": typed
|
||||
// that way it still means the map.
|
||||
inline std::string_view world_level_typed_name(std::string_view typed) {
|
||||
constexpr std::string_view old = "reskate ";
|
||||
if (typed.size() <= old.size()) return typed;
|
||||
for (std::size_t i = 0; i < old.size(); ++i)
|
||||
if ((typed[i] | 0x20) != old[i] && typed[i] != old[i]) return typed;
|
||||
return typed.substr(old.size());
|
||||
}
|
||||
inline std::string world_level_name(std::string_view asset) {
|
||||
auto name = world_level_short_name(asset);
|
||||
auto folded = name;
|
||||
|
||||
@@ -423,9 +423,40 @@ void carried_into_another_bundle_with_the_list(bool music_first) {
|
||||
expect(own == built, order + ": the music mod's own bundle is as it built it (" + std::to_string(own) + " files, " +
|
||||
std::to_string(built) + " built)");
|
||||
}
|
||||
|
||||
// A mod replaced while the game runs (a map at another version): the merge reads its new files
|
||||
// where it puts them now, not where the old ones lay.
|
||||
void replaced_while_the_game_runs() {
|
||||
Fixture fixture("live-replaced");
|
||||
fixture.add("music", false, shared_toc, {}, music_assets(), music_resources());
|
||||
auto report = mods::merge_mods(fixture.catalog);
|
||||
expect(report.built && report.issue.empty(), "replaced: the launch's merge\n" + describe(report));
|
||||
const auto noted = [&](std::string_view text) {
|
||||
return std::any_of(report.notes.begin(), report.notes.end(), [&](const std::string& note) { return note.find(text) != std::string::npos; });
|
||||
};
|
||||
// Untouched, it stays where the launch put it.
|
||||
report = mods::merge_mods(fixture.catalog, {}, {.live = true});
|
||||
expect(report.built && report.issue.empty() && !noted("placed again"), "replaced: an unchanged mod was placed again\n" + describe(report));
|
||||
// The same folder with other files in it.
|
||||
const auto before = fixture.merged_files(shared_toc);
|
||||
fs::remove_all(fixture.catalog.mods.back().directory);
|
||||
fixture.catalog.mods.clear();
|
||||
auto more = music_assets();
|
||||
more.push_back({"test/another", 0, ebx_document(guid(77), {})});
|
||||
fixture.add("music", false, shared_toc, {}, more, music_resources());
|
||||
report = mods::merge_mods(fixture.catalog, {}, {.live = true});
|
||||
expect(report.built && report.issue.empty(), "replaced: the merge with the new files\n" + describe(report));
|
||||
expect(noted("placed again") && noted("added while the game runs"), "replaced: a changed mod kept the old one's place\n" + describe(report));
|
||||
expect(fixture.merged_files(shared_toc) == before + 1, "replaced: the new file is not in the merged copy (" +
|
||||
std::to_string(fixture.merged_files(shared_toc)) + ", " + std::to_string(before) + " before)");
|
||||
// And it is not placed a third time.
|
||||
report = mods::merge_mods(fixture.catalog, {}, {.live = true});
|
||||
expect(report.built && report.issue.empty() && !noted("placed again"), "replaced: placed again without a change\n" + describe(report));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() try {
|
||||
replaced_while_the_game_runs();
|
||||
const auto baseline = map_only();
|
||||
expect(baseline > 0, "map only: the map's copy exists (" + std::to_string(baseline) + ")");
|
||||
carried_into_the_maps_copy(true, baseline);
|
||||
|
||||
@@ -194,6 +194,21 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
|
||||
if (options.live) record = read_placements(placementsPath);
|
||||
std::vector<const Mod*> placedMods = mods;
|
||||
for (const auto& mod : catalog.inactive) placedMods.push_back(&mod);
|
||||
// A mod whose files changed since its archives were placed (updated, or removed and
|
||||
// installed again at another version, while the game runs) is forgotten here and so
|
||||
// placed again below, as one installed since launch is; the old archives stay where
|
||||
// the game may still hold them. Its place in the root's order goes too: the root is
|
||||
// written again with it last.
|
||||
if (options.live)
|
||||
for (const auto* mod : placedMods) {
|
||||
const auto found = record.mods.find(mod->name);
|
||||
if (found == record.mods.end() || found->second.fingerprint.empty() ||
|
||||
found->second.fingerprint == mod_fingerprint(mod->directory))
|
||||
continue;
|
||||
record.mods.erase(found);
|
||||
std::erase(record.root, mod->name);
|
||||
report.notes.push_back(mod->name + ": its files changed since it was loaded, so it is placed again");
|
||||
}
|
||||
for (const auto* mod : placedMods) {
|
||||
const bool active = std::find(mods.begin(), mods.end(), mod) != mods.end();
|
||||
if (options.live && !active && !record.mods.contains(mod->name)) continue; // installed since launch, still off
|
||||
@@ -248,6 +263,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
|
||||
std::to_string(spot.offset) + " (added while the game runs)");
|
||||
++report.archives;
|
||||
}
|
||||
placements[mod].fingerprint = mod_fingerprint(mod->directory);
|
||||
record.mods[mod->name] = placements[mod];
|
||||
continue;
|
||||
}
|
||||
@@ -304,6 +320,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con
|
||||
(active ? "" : " (disabled, ready to enable)"));
|
||||
++report.archives;
|
||||
}
|
||||
placements[mod].fingerprint = mod_fingerprint(mod->directory);
|
||||
record.mods[mod->name] = placements[mod];
|
||||
}
|
||||
|
||||
|
||||
@@ -156,6 +156,9 @@ PlacementRecord read_placements(const fs::path& file) {
|
||||
{spot.at("archive").get<std::uint16_t>(), spot.at("offset").get<std::uint64_t>()};
|
||||
}
|
||||
}
|
||||
if (root.contains("fingerprints"))
|
||||
for (const auto& [name, files] : root.at("fingerprints").items())
|
||||
if (const auto found = record.mods.find(name); found != record.mods.end() && files.is_string()) found->second.fingerprint = files.string();
|
||||
const auto& tocs = root.at("tocs");
|
||||
for (std::size_t i = 0; i < tocs.size(); ++i) record.tocs.push_back(tocs.at(i).string());
|
||||
if (root.contains("root")) {
|
||||
@@ -182,6 +185,10 @@ void write_placements(const fs::path& file, const PlacementRecord& record) {
|
||||
mods[name] = std::move(spots);
|
||||
}
|
||||
root["mods"] = std::move(mods);
|
||||
auto fingerprints = Json::object();
|
||||
for (const auto& [name, placement] : record.mods)
|
||||
if (!placement.fingerprint.empty()) fingerprints[name] = placement.fingerprint;
|
||||
root["fingerprints"] = std::move(fingerprints);
|
||||
auto tocs = Json::array();
|
||||
for (const auto& toc : record.tocs) tocs.push_back(toc);
|
||||
root["tocs"] = std::move(tocs);
|
||||
|
||||
@@ -64,6 +64,10 @@ struct ArchivePlacement {
|
||||
std::uint64_t offset{};
|
||||
};
|
||||
std::map<std::pair<std::string, std::uint16_t>, Spot> at;
|
||||
// The mod's files as they were when its archives were placed (mod_fingerprint). A merge
|
||||
// while the game runs places a mod again when they have changed since: one updated, or
|
||||
// removed and installed again at another version, is not read where the old one lay.
|
||||
std::string fingerprint;
|
||||
};
|
||||
|
||||
// Reverses CasStore::shift: turns a placement in the merged patch back into the
|
||||
|
||||
@@ -55,6 +55,14 @@ std::string read_bytes(const fs::path& path, std::uintmax_t most) {
|
||||
}
|
||||
|
||||
std::string host_word(bool server) { return server ? "server" : "host"; }
|
||||
std::string need_package() {
|
||||
std::lock_guard lock(mutex);
|
||||
return need.package;
|
||||
}
|
||||
bool need_server() {
|
||||
std::lock_guard lock(mutex);
|
||||
return need.server;
|
||||
}
|
||||
|
||||
// The session that is waiting on the map is left: there is no map to wait for.
|
||||
void leave_session() {
|
||||
@@ -64,17 +72,27 @@ void leave_session() {
|
||||
// Back to idle with the reason said, as the plain "map not installed" notice is.
|
||||
void fail(std::string reason) {
|
||||
std::string text;
|
||||
bool update{};
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
update = !need.have.empty();
|
||||
text = "The " + host_word(need.server) + (need.moved ? " changed to " : " is on ") + need.map +
|
||||
", which is not installed on this PC. " + reason;
|
||||
(update ? ", at another version (" + std::string(multiplayer::map_package_version(need.package)) + ") than the one installed on this PC (" + need.have + "). "
|
||||
: ", 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));
|
||||
overlay::notify(overlay::NoticeLevel::warning, update ? "Map version differs" : "Map not installed", std::move(text));
|
||||
}
|
||||
// The packages of the maps installed here, by level (package_of): forgotten when one is installed.
|
||||
struct Known {
|
||||
std::string package;
|
||||
std::uint64_t until{};
|
||||
};
|
||||
std::mutex known_mutex;
|
||||
std::map<std::string, Known, std::less<>> known_packages;
|
||||
|
||||
// The mod's row in mods.json when its folder is here: whether it is switched on, and what it
|
||||
// says of itself.
|
||||
@@ -91,13 +109,16 @@ std::optional<bool> installed(const std::string& folder, std::string& about) {
|
||||
// Stage::checking: is it here but off, or what does Thunderstore say of it.
|
||||
void check() {
|
||||
package::Name name;
|
||||
bool update{};
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
name = wanted;
|
||||
update = !need.have.empty();
|
||||
}
|
||||
try {
|
||||
std::string about;
|
||||
if (const auto on = installed(name.folder(), about)) {
|
||||
// (At another version than the host's, it is fetched whether it is on or off.)
|
||||
if (const auto on = update ? std::optional<bool>{} : 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);
|
||||
@@ -119,6 +140,9 @@ void check() {
|
||||
: "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.");
|
||||
// Another version is only worth fetching when it is the host's: any other leaves the two apart still.
|
||||
if (update && choice.version != multiplayer::map_package_version(need_package()))
|
||||
return fail("That version is not on Thunderstore any more. Ask the " + host_word(need_server()) + " to update the map, or install its version yourself.");
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
version = choice.version;
|
||||
@@ -139,13 +163,14 @@ void check() {
|
||||
void fetch() {
|
||||
package::Name name;
|
||||
std::string get, fallback;
|
||||
bool off{};
|
||||
bool off{}, update{};
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
name = wanted;
|
||||
get = version;
|
||||
fallback = newest;
|
||||
off = installed_off;
|
||||
update = !need.have.empty();
|
||||
}
|
||||
const auto root = mods_root();
|
||||
const auto folder = name.folder();
|
||||
@@ -161,7 +186,7 @@ void fetch() {
|
||||
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()) {
|
||||
if (!update && !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);
|
||||
@@ -176,12 +201,21 @@ void fetch() {
|
||||
}
|
||||
stage.store(Stage::installing);
|
||||
try {
|
||||
launcher_mods::install(root, archive, false, [](float) {}, stop_install, {folder, name.owner, true});
|
||||
// (In the installed one's place when it is the same map at another version. The
|
||||
// game may hold the old one's files open when it was skated this run: then it
|
||||
// cannot be replaced until the game is closed.)
|
||||
launcher_mods::install(root, archive, update, [](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());
|
||||
return fail(update ? std::string("It could not be replaced while the game runs (") + failure.what() +
|
||||
"). Close the game, update it from the launcher and join again."
|
||||
: std::string("It could not be installed: ") + failure.what());
|
||||
}
|
||||
fs::remove(archive, error);
|
||||
{
|
||||
std::lock_guard known(known_mutex);
|
||||
known_packages.clear();
|
||||
}
|
||||
} else {
|
||||
stage.store(Stage::installing);
|
||||
}
|
||||
@@ -193,7 +227,7 @@ void fetch() {
|
||||
}
|
||||
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) {
|
||||
if (off || update) { // (a map at another version may have been switched off: it is wanted now)
|
||||
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)
|
||||
@@ -301,6 +335,7 @@ View view() {
|
||||
out.server = need.server;
|
||||
out.moved = need.moved;
|
||||
out.installed = installed_off;
|
||||
out.have = need.have;
|
||||
out.choice = pick_download.load(std::memory_order_relaxed);
|
||||
out.received = watch.received.load();
|
||||
out.total = watch.total.load();
|
||||
@@ -358,12 +393,8 @@ void tick() {
|
||||
}
|
||||
|
||||
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;
|
||||
auto& cache_mutex = known_mutex;
|
||||
auto& cache = known_packages;
|
||||
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)); });
|
||||
|
||||
@@ -35,6 +35,7 @@ struct View {
|
||||
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
|
||||
std::string have; // the version installed here, when the host's is another and replaces it
|
||||
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
|
||||
|
||||
@@ -196,6 +196,24 @@ bool valid_map_package(std::string_view package) noexcept;
|
||||
// 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);
|
||||
// A package's version ("1.2.3" of "Owner-Name-1.2.3"), or nothing when it names none.
|
||||
inline std::string_view map_package_version(std::string_view package) noexcept {
|
||||
const auto first = package.find('-');
|
||||
const auto second = first == std::string_view::npos ? first : package.find('-', first + 1);
|
||||
return second == std::string_view::npos ? std::string_view{} : package.substr(second + 1);
|
||||
}
|
||||
// The version of a map installed here, when the host's package is the same map at another
|
||||
// one: players skate the host's version, so that is fetched first. Nothing when the versions
|
||||
// agree, either names none, or they are different packages.
|
||||
inline std::string_view other_map_version(std::string_view host, std::string_view mine) noexcept {
|
||||
const auto wanted = map_package_version(host), have = map_package_version(mine);
|
||||
if (wanted.empty() || have.empty() || wanted == have) return {};
|
||||
const auto host_name = host.substr(0, host.size() - wanted.size()), my_name = mine.substr(0, mine.size() - have.size());
|
||||
if (host_name.size() != my_name.size()) return {};
|
||||
for (std::size_t i = 0; i < host_name.size(); ++i)
|
||||
if ((host_name[i] | 0x20) != (my_name[i] | 0x20)) return {};
|
||||
return have;
|
||||
}
|
||||
struct Transform {
|
||||
std::array<float, 3> position{};
|
||||
std::array<float, 4> rotation{0, 0, 0, 1};
|
||||
|
||||
@@ -799,7 +799,11 @@ bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoade
|
||||
if (!s.last_map_load_check || now - s.last_map_load_check >= 500000) {
|
||||
s.last_map_load_check = now;
|
||||
std::string detail;
|
||||
const auto result = loader(s.join_destination, s.map_load_submitted, detail);
|
||||
// The host's map, installed here at another version: it is not loaded as it is. It is
|
||||
// fetched at the host's version as a missing one is (the downloader puts it in the
|
||||
// installed one's place), and loads when the two agree.
|
||||
const auto have = std::string(other_map_version(s.map_package, host_map_package(s.join_destination)));
|
||||
const auto result = have.empty() ? loader(s.join_destination, s.map_load_submitted, detail) : MapLoadResult::missing;
|
||||
if (result == MapLoadResult::missing) {
|
||||
const auto name = s.map_label.empty() ? world_level_name(world_destination_asset(s.join_destination)) : s.map_label;
|
||||
const char *who = dedicated_host(s) ? "server" : "host";
|
||||
@@ -812,7 +816,7 @@ bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoade
|
||||
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.map_need = MapNeed{s.map_package, name, dedicated_host(s), s.travelling, have};
|
||||
s.rejoin = Session::Rejoin{{s.host_id, s.secret}, s.password};
|
||||
s.last_map_request = s.last_world_ready = 0; // say so at once
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ struct MapNeed {
|
||||
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)
|
||||
// The version of it installed here, when that is another than the host's: the host's is
|
||||
// fetched in its place. Empty: the map is not installed.
|
||||
std::string have;
|
||||
};
|
||||
std::optional<MapNeed> take_map_need();
|
||||
// Whether a session is waiting on a map being fetched right now. Thread-safe.
|
||||
|
||||
@@ -1150,6 +1150,17 @@ void dedicated_server_codec() {
|
||||
elsewhere.map_fetching = true;
|
||||
check(reject(elsewhere), "A map fetch notice on a map change encoded");
|
||||
}
|
||||
// A map installed at another version than the host's is fetched at the host's: only when
|
||||
// both name a version of the same package.
|
||||
check(other_map_version("Sandos-Vancouver_Plaza-2.0.0", "Sandos-Vancouver_Plaza-1.9.0") == "1.9.0" &&
|
||||
other_map_version("Sandos-Vancouver_Plaza-2.0.0", "sandos-vancouver_plaza-1.9.0") == "1.9.0" &&
|
||||
other_map_version("Sandos-Vancouver_Plaza-2.0.0", "Sandos-Vancouver_Plaza-2.0.0").empty() &&
|
||||
other_map_version("Sandos-Vancouver_Plaza", "Sandos-Vancouver_Plaza-1.9.0").empty() &&
|
||||
other_map_version("Sandos-Vancouver_Plaza-2.0.0", "Sandos-Vancouver_Plaza").empty() &&
|
||||
other_map_version("Sandos-Vancouver_Plaza-2.0.0", "Other-Vancouver_Plaza-1.9.0").empty() &&
|
||||
other_map_version("Sandos-Vancouver_Plaza-2.0.0", "").empty() && other_map_version("", "").empty() &&
|
||||
map_package_version("Sandos-Vancouver_Plaza-2.0.0") == "2.0.0" && map_package_version("Sandos-Vancouver_Plaza").empty(),
|
||||
"A map's version is not compared with the host's as it should be");
|
||||
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" &&
|
||||
|
||||
@@ -585,7 +585,7 @@ void draw_vote(const MultiplayerVote& vote, ImFont* heading, ImFont* body, ImVec
|
||||
if (running) question += '?';
|
||||
const float width = 360.0f * scale, margin = 24.0f * scale, strip = 5.0f * scale;
|
||||
const float pad = 14.0f * scale, gap = 8.0f * scale, title_size = 13.0f * scale, size = 18.0f * scale, fine = 13.0f * scale;
|
||||
const float button_height = 34.0f * scale, bar = 4.0f * scale, inner = width - strip - pad * 2.0f;
|
||||
const float button_height = 34.0f * scale, bar = 10.0f * scale, inner = width - strip - pad * 2.0f;
|
||||
const auto question_extent = heading->CalcTextSizeA(size, FLT_MAX, inner, question.c_str());
|
||||
const bool answers = running && vote.may_vote;
|
||||
const float height = pad + title_size + gap + question_extent.y + gap + bar + gap + fine +
|
||||
@@ -605,10 +605,20 @@ void draw_vote(const MultiplayerVote& vote, ImFont* heading, ImFont* body, ImVec
|
||||
y += title_size + gap;
|
||||
draw->AddText(heading, size, ImVec2(left, y), theme::paper, question.c_str(), nullptr, inner);
|
||||
y += question_extent.y + gap;
|
||||
// How far Yes is towards what it needs, then the tally under it.
|
||||
const float share = vote.needed ? std::clamp(static_cast<float>(vote.yes) / static_cast<float>(vote.needed), 0.0f, 1.0f) : 0.0f;
|
||||
// The votes cast, yes in green from the left and no in red from the right: a vote is
|
||||
// decided by those who vote. The mark is where yes has to reach to pass; nothing is cast
|
||||
// yet, the bar is empty.
|
||||
const unsigned cast = static_cast<unsigned>(vote.yes) + vote.no;
|
||||
draw->AddRectFilled(ImVec2(left, y), ImVec2(right, y + bar), skate_theme::tile_light);
|
||||
if (share > 0.0f) draw->AddRectFilled(ImVec2(left, y), ImVec2(left + inner * share, y + bar), green);
|
||||
if (cast) {
|
||||
const float split = std::floor(left + inner * static_cast<float>(vote.yes) / static_cast<float>(cast));
|
||||
if (vote.yes) draw->AddRectFilled(ImVec2(left, y), ImVec2(split, y + bar), green);
|
||||
if (vote.no) draw->AddRectFilled(ImVec2(split, y), ImVec2(right, y + bar), red);
|
||||
if (running && vote.needed && vote.needed <= cast) {
|
||||
const float mark = std::floor(left + inner * static_cast<float>(vote.needed) / static_cast<float>(cast));
|
||||
draw->AddRectFilled(ImVec2(mark - scale, y - 3.0f * scale), ImVec2(mark + scale, y + bar + 3.0f * scale), theme::paper);
|
||||
}
|
||||
}
|
||||
y += bar + gap;
|
||||
float x = left;
|
||||
const auto word = [&](const std::string& text, ImU32 colour) {
|
||||
|
||||
@@ -186,12 +186,13 @@ void draw_map_download() {
|
||||
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"
|
||||
const char* title = asking ? (!card.have.empty() ? "MAP VERSION" : 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?")
|
||||
const std::string status = asking ? (!card.have.empty() ? std::format("The {} runs version {} of this map and you have {}. Update to its version?", host, card.version, card.have)
|
||||
: 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 + "...")
|
||||
@@ -302,7 +303,7 @@ void draw_map_download() {
|
||||
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 std::string label = yes ? (!card.have.empty() ? "UPDATE" : 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());
|
||||
|
||||
@@ -297,6 +297,7 @@ void set_multiplayer_queue(MultiplayerQueue) noexcept;
|
||||
struct MapDownloadCard {
|
||||
int stage{}; // 0 none, 1 asking, 2 downloading, 3 installing, 4 applying, 5 joining
|
||||
std::string map, package, author, version, description;
|
||||
std::string have; // the version installed here, when the host's replaces it
|
||||
std::size_t step{}, steps{}; // applying: the merge's steps done of all
|
||||
std::string step_name;
|
||||
bool server{}, installed{}, moved{}, choice{true};
|
||||
|
||||
@@ -51,6 +51,7 @@ const overlay::Level *find_destination(const Model &model, std::string_view name
|
||||
for (const auto &level : model.levels)
|
||||
if (offered(level, maps) && equal(level.asset, name))
|
||||
return &level;
|
||||
name = world_level_typed_name(name);
|
||||
for (const bool exact : {true, false}) {
|
||||
const overlay::Level *result = nullptr;
|
||||
for (const auto &level : model.levels) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#include "mesh_unload_guard.h"
|
||||
#include "Engine/Core/Hooks/hooks.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/20260929/mesh_variations.h"
|
||||
#include <Windows.h>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
|
||||
namespace dingosdk::mesh_unload_guard {
|
||||
namespace {
|
||||
namespace mv = addr::mesh_variations;
|
||||
// (Three arguments are read; the fourth is passed on as it came.)
|
||||
using RemoveDatabases = std::uint64_t (*)(void*, void*, void*, std::uint64_t);
|
||||
RemoveDatabases original{};
|
||||
std::uintptr_t fault_at{}, resume_at{};
|
||||
std::atomic<bool> installed{};
|
||||
std::atomic<std::uint64_t> skips{};
|
||||
|
||||
// Only the read of a mesh that is gone (a null one: rax is the entry's mesh): the function goes
|
||||
// on at its step to the next entry, as if that entry had been taken out. Nothing else is this
|
||||
// guard's.
|
||||
int ours(EXCEPTION_POINTERS* info) noexcept {
|
||||
const auto& record = *info->ExceptionRecord;
|
||||
if (record.ExceptionCode != EXCEPTION_ACCESS_VIOLATION || reinterpret_cast<std::uintptr_t>(record.ExceptionAddress) != fault_at ||
|
||||
info->ContextRecord->Rax != 0)
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
info->ContextRecord->Rip = resume_at;
|
||||
skips.fetch_add(1, std::memory_order_relaxed);
|
||||
return EXCEPTION_CONTINUE_EXECUTION;
|
||||
}
|
||||
// Said once per unload that had any, with how many: a map can have hundreds.
|
||||
__declspec(noinline) void note_skips(std::uint64_t before) noexcept {
|
||||
const auto now = skips.load(std::memory_order_relaxed);
|
||||
if (now == before) return;
|
||||
try {
|
||||
logging::log(logging::Level::warning, logging::Channel::runtime,
|
||||
"Level unload: {} mesh variation(s) had lost their mesh before their database was unloaded, where the game would "
|
||||
"have crashed; they were passed over ({} since the game started).", now - before, now);
|
||||
} catch (...) {}
|
||||
}
|
||||
std::uint64_t guarded(void* registry, void* second, void* list, std::uint64_t fourth) {
|
||||
const auto before = skips.load(std::memory_order_relaxed);
|
||||
std::uint64_t result{};
|
||||
__try {
|
||||
result = original(registry, second, list, fourth);
|
||||
} __except (ours(GetExceptionInformation())) {
|
||||
// (Never entered: the filter either goes on in the function or passes the fault up.)
|
||||
}
|
||||
note_skips(before);
|
||||
return result;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool install(std::uintptr_t base) noexcept {
|
||||
if (installed.exchange(true)) return original != nullptr;
|
||||
if (!base) return false;
|
||||
std::array<unsigned char, 32> head{};
|
||||
std::array<unsigned char, 6> read{};
|
||||
std::array<unsigned char, 4> next{};
|
||||
if (!memory::peek(base + mv::remove_databases.rva, head) || head != mv::remove_databases.bytes ||
|
||||
!memory::peek(base + mv::mesh_hash_read, read) || read != mv::mesh_hash_read_bytes ||
|
||||
!memory::peek(base + mv::next_entry, next) || next != mv::next_entry_bytes) {
|
||||
logging::log(logging::Level::warning, logging::Channel::runtime,
|
||||
"Mesh unload guard: this build's mesh variation code differs; it is left as it is.");
|
||||
return false;
|
||||
}
|
||||
fault_at = base + mv::mesh_hash_read;
|
||||
resume_at = base + mv::next_entry;
|
||||
const auto target = reinterpret_cast<void*>(base + mv::remove_databases.rva);
|
||||
if (hook_prepare(target, reinterpret_cast<void*>(&guarded), reinterpret_cast<void**>(&original)) != HookOk ||
|
||||
hook_enable(target) != HookOk) {
|
||||
original = nullptr;
|
||||
logging::log(logging::Level::warning, logging::Channel::runtime, "Mesh unload guard: the game's function could not be hooked.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::uint64_t skipped() noexcept { return skips.load(std::memory_order_relaxed); }
|
||||
} // namespace dingosdk::mesh_unload_guard
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
// Leaving some custom maps crashed the game: as a level unloads, the game takes each mesh
|
||||
// variation database's entries out of its registry by the entry's mesh, and reads the mesh
|
||||
// without checking it is still there (Engine/Game/Build/<build>/mesh_variations.h). A map whose
|
||||
// meshes unload before their databases leaves entries without one. The guard runs that one
|
||||
// function under an exception handler and, for that one read alone, has it go on with the next
|
||||
// entry: the function finishes, lock and all. Every other fault is left to the crash reporter.
|
||||
namespace dingosdk::mesh_unload_guard {
|
||||
// Checks this build's function and hooks it; once. False when the build differs (no guard).
|
||||
bool install(std::uintptr_t base) noexcept;
|
||||
// How many entries were passed over since the game started.
|
||||
std::uint64_t skipped() noexcept;
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "Engine/Core/Profiling/profiler.h"
|
||||
#include "Extension/Settings/engine_tweaks.h"
|
||||
#include "Extension/Settings/replay_guard.h"
|
||||
#include "Extension/World/mesh_unload_guard.h"
|
||||
#include "Extension/Console/commands.h"
|
||||
#include "Extension/UI/ui_pointer_skip.h"
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
@@ -215,6 +216,7 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() {
|
||||
card.server = view.server;
|
||||
card.moved = view.moved;
|
||||
card.installed = view.installed;
|
||||
card.have = view.have;
|
||||
card.choice = view.choice;
|
||||
card.received = view.received;
|
||||
card.total = view.total;
|
||||
@@ -314,6 +316,7 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() {
|
||||
(void)dingosdk::ui_pointer::install(r.base);
|
||||
(void)dingosdk::engine_tweaks::install(r.base);
|
||||
(void)dingosdk::replay_guard::install(r.base);
|
||||
(void)dingosdk::mesh_unload_guard::install(r.base);
|
||||
dingosdk::console::start_memory_trace_from_environment();
|
||||
const bool fixed_stop_provider = (global_offline_hook && progression_guard_armed) &&
|
||||
dingosdk::prepare_fixed_stop_entitlement_provider(r.base);
|
||||
|
||||
@@ -1159,7 +1159,7 @@ void draw_world_tab(const ServerStatusSnapshot& stats) {
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::Spacing();
|
||||
|
||||
const auto& map_list = levels();
|
||||
const auto map_list = level_snapshot(); // a copy: "maps reload" changes the list on the server's thread
|
||||
if (!map_list.empty()) {
|
||||
for (const auto& level : map_list) {
|
||||
const bool active = (stats.map_name == level.name || map_setting(stats.map_name) == map_setting(level.name));
|
||||
|
||||
@@ -143,7 +143,8 @@ Messages
|
||||
Map and world
|
||||
map map <name>: change the map. Also lets the player pick
|
||||
the server's level from Levels or Travel in the game.
|
||||
maps The maps this server knows.
|
||||
maps The maps this server knows. (maps reload, which reads the
|
||||
Mods folder again, takes the map command.)
|
||||
map-pool map-pool [add|remove <map>|clear]: the maps players vote
|
||||
between and the rotation uses.
|
||||
rotation rotation [<minutes>|off]: change the map on a timer.
|
||||
|
||||
+7
-2
@@ -326,8 +326,11 @@ votes Player votes, each off until turned on:
|
||||
"kick": {"enabled": true, "percent": 60} /vote kick <player>
|
||||
"time_of_day": {"enabled": true, "percent": 50} /vote tod <time>
|
||||
(needs world_layer_sync)
|
||||
"percent" is the share of connected players whose yes passes
|
||||
it. "seconds" (default 30) is how long a vote runs and
|
||||
"percent" is the share of the votes cast that yes needs to
|
||||
pass it: players who do not vote count for neither side. A
|
||||
vote runs its time and is decided then, or sooner once the
|
||||
players still to vote could not change it. A kick always
|
||||
needs two yes votes. "seconds" (default 30) is how long a vote runs and
|
||||
"cooldown_seconds" (default 60) how long a player waits before
|
||||
starting another. Players vote with /yes and /no in chat;
|
||||
admins cannot be vote-kicked.
|
||||
@@ -514,6 +517,8 @@ or Travel, and voice, distances, placement and kicks from the Multiplayer menu.
|
||||
ban <player or id> [name] For good. unban <id> bans
|
||||
map <name> e.g. map San Vansterdam, map grom, map bbcity
|
||||
maps The maps this server knows.
|
||||
maps reload Read the Mods folder again: a map mod copied in
|
||||
while the server runs can be switched to at once.
|
||||
map-pool [add|remove <map>|clear] The maps players vote between and the
|
||||
rotation uses (see maps.pool).
|
||||
rotation [<minutes>|off] Change the map on a timer (see maps.rotation_minutes).
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//
|
||||
// dingosdk_server_park_tests <Server/Test/fixtures/park_mods>
|
||||
#include "Server/server_host.h"
|
||||
#include "Engine/Game/World/world_names.h"
|
||||
#include "Server/server_parks.h"
|
||||
#include "Extension/Multiplayer/Net/pose_batch.h"
|
||||
#include "Extension/Multiplayer/Net/sound_codec.h"
|
||||
@@ -458,6 +459,60 @@ int run(const std::filesystem::path &fixtures) {
|
||||
const auto &objects = a.park.objects();
|
||||
check(std::any_of(objects.begin(), objects.end(), [](const auto &row) { return row.second.scale == 2.5f; }), "A resized object lost its size");
|
||||
check(command("park-mod add street").find("belongs on San Vansterdam") != std::string::npos, "A park for the old map was spawned");
|
||||
// A map mod copied in while the server runs is known after "maps reload".
|
||||
{
|
||||
check(command("maps").find("Brand New") == std::string::npos, "A map that is not installed is listed");
|
||||
std::filesystem::create_directories(mods / "someone-Brand_New");
|
||||
std::ofstream(mods / "someone-Brand_New" / "reskate-levels.json", std::ios::binary)
|
||||
<< R"({"schema":1,"levels":[{"asset":"levels/game/dingolevel_reskate_brandnew/dingolevel_reskate_brandnew","displayName":"Brand New"}]})";
|
||||
reply = command("maps reload");
|
||||
check(reply.find("new: Brand New") != std::string::npos, "maps reload: " + reply);
|
||||
check(command("maps").find("Brand New") != std::string::npos && find_level("Brand New"), "A map copied in was not known after a reload");
|
||||
check(command("maps reload").find("none new") != std::string::npos, "A second reload found the same map new again");
|
||||
// A custom map goes by its own name: its level is dingolevel_reskate_<map>, and "reskate"
|
||||
// is no part of what is shown or typed (typed the old way, it still means the map).
|
||||
check(dingosdk::world_level_name("levels/game/dingolevel_reskate_brandnew/dingolevel_reskate_brandnew") == "brandnew" &&
|
||||
dingosdk::world_level_name("Levels/Game/BAM_LevelRoot/BAM_LevelRoot") == "San Vansterdam",
|
||||
"A custom map's name still starts with reskate, or a game map's name changed");
|
||||
const auto *brand_new = find_level("Brand New");
|
||||
check(brand_new && find_level("brandnew") == brand_new && find_level("reskate brandnew") == brand_new &&
|
||||
find_level("RESKATE brand") == brand_new && !find_level("reskate"),
|
||||
"A custom map was not found by its own name, or by the old one");
|
||||
}
|
||||
// Votes are decided by those who vote, at the end of their time: with three players on, one
|
||||
// yes and nobody else voting passes; a yes and a no fails (60% of the votes cast is needed);
|
||||
// and two yes passes at once, since the third player could not change it.
|
||||
{
|
||||
auto &c = add_game(76561198000000003ULL, "Cee");
|
||||
auto &d = add_game(76561198000000004ULL, "Dee");
|
||||
c.map = d.map = a.map; // the map and world the server is on now
|
||||
c.world = d.world = a.world;
|
||||
c.hello(now);
|
||||
d.hello(now);
|
||||
run_for(2000000);
|
||||
check(c.joined && d.joined, "The players for the votes did not join");
|
||||
const auto said = [&](std::string_view text) {
|
||||
return std::count_if(a.chat.begin(), a.chat.end(), [&](const std::string &line) { return line.find(text) != std::string::npos; });
|
||||
};
|
||||
const auto passed = said("passed ("), failed = said("failed ("), alone = said("passed (1 yes, 0 no, 1 needed of 1 vote)");
|
||||
a.say("/vote unpark street", now);
|
||||
run_for(5000000);
|
||||
check(said("passed (") == passed && said("failed (") == failed, "A vote two players had not voted in was decided before its time");
|
||||
run_for(27000000);
|
||||
check(said("passed (1 yes, 0 no, 1 needed of 1 vote)") == alone + 1, "A vote did not pass on the votes cast");
|
||||
a.say("/vote unpark street", now);
|
||||
run_for(1000000);
|
||||
c.say("/no", now);
|
||||
run_for(5000000);
|
||||
check(said("failed (") == failed && said("passed (") == passed + 1, "A vote a player could still pass was decided before its time");
|
||||
run_for(27000000);
|
||||
check(said("failed (1 yes, 1 no, 2 needed of 2 votes)") == 1, "A vote did not fail on the votes cast");
|
||||
a.say("/vote unpark street", now);
|
||||
run_for(1000000);
|
||||
d.say("/yes", now);
|
||||
run_for(1000000);
|
||||
check(said("passed (2 yes, 0 no, 2 needed of 2 votes)") == 1, "A vote nobody could change any more was not decided at once");
|
||||
}
|
||||
check(!wire().closed.contains(admin_id), "The player was disconnected: " + (wire().closed.contains(admin_id) ? wire().closed[admin_id] : std::string{}));
|
||||
return failures;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace dingosdk::server {
|
||||
namespace {
|
||||
constexpr std::string_view help_text =
|
||||
"status | net [player] | players | say <text> | msg <player> <text> | msg-party <player> <text> | msg-admins <text> | kick <player> | ban <player or SteamID64> [name] | unban <SteamID64> | bans\n"
|
||||
"map <name, e.g. San Vansterdam> | maps | name <text> | password <text|off> | welcome <text|off> | listed on|off\n"
|
||||
"map <name, e.g. San Vansterdam> | maps [reload] | name <text> | password <text|off> | welcome <text|off> | listed on|off\n"
|
||||
"voice on|off | voice-range <50-1000> | distances <full> <half> <half-return> <low> | crowd <n>|off | rate <KB/s> | bone-scale <1-8>|off | bone-reach <0.5-20>|off\n"
|
||||
"placement everyone|admins|nobody | objects <number>|off | object-scaling on|off | effects on|off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n"
|
||||
"tpall [player] | tphere <player> | teleport <player|SteamID64,SteamID64,...> <player|x y z (y is up)>\n"
|
||||
@@ -180,6 +180,25 @@ std::string Host::command(std::string_view line, std::uint64_t admin) {
|
||||
save();
|
||||
return changed("Changing map to " + map_name());
|
||||
}
|
||||
if (name == "maps" && lower(argument) == "reload") {
|
||||
// A map mod copied into Mods while the server runs: its maps can be switched to at once.
|
||||
if (!console && !can(admin, "map")) return "Your role cannot reload the maps.";
|
||||
std::vector<std::string> before;
|
||||
for (const auto &level : levels()) before.push_back(level.asset);
|
||||
const auto problems = load_levels(config_.mods);
|
||||
resend_maps(); // whoever picks maps is sent the list again
|
||||
std::string added;
|
||||
std::size_t gone{};
|
||||
for (const auto &level : levels())
|
||||
if (std::find(before.begin(), before.end(), level.asset) == before.end()) added += (added.empty() ? "" : ", ") + level.name;
|
||||
for (const auto &asset : before)
|
||||
if (!find_level(asset)) ++gone;
|
||||
std::string text = "Maps read again from Mods: " + std::to_string(levels().size()) + " maps" +
|
||||
(added.empty() ? ", none new" : ", new: " + added) + (gone ? ", " + std::to_string(gone) + " no longer there" : "") + ".";
|
||||
for (const auto &problem : problems) text += "\nMods: skipped " + problem;
|
||||
if (!console) log_(text);
|
||||
return text;
|
||||
}
|
||||
if (name == "maps") {
|
||||
std::string text = std::to_string(levels().size()) + " maps (custom maps come from Mods next to the server)";
|
||||
for (const auto &level : levels())
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "server_config.h"
|
||||
#include <mutex>
|
||||
#include "Engine/Core/Json/json.h"
|
||||
#include "Engine/Core/Platform/path_text.h"
|
||||
#include "Extension/Multiplayer/Net/protocol.h"
|
||||
@@ -875,10 +876,20 @@ std::vector<ServerLevel> &level_list() {
|
||||
static std::vector<ServerLevel> value;
|
||||
return value;
|
||||
}
|
||||
// The list is the server thread's; the server window reads a copy of it (level_snapshot), and
|
||||
// this is held while one is taken or the list is put in place.
|
||||
std::mutex level_mutex;
|
||||
} // namespace
|
||||
std::vector<std::string> load_levels(const std::filesystem::path &mods) {
|
||||
auto &list = level_list();
|
||||
list.clear();
|
||||
// Built aside and put in place at the end: "maps reload" reads the folder while the server runs.
|
||||
std::vector<ServerLevel> list;
|
||||
struct Publish {
|
||||
std::vector<ServerLevel> &fresh;
|
||||
~Publish() {
|
||||
std::lock_guard lock(level_mutex);
|
||||
level_list() = std::move(fresh);
|
||||
}
|
||||
} publish{list};
|
||||
// The retail maps players can skate together; the game names them the same way.
|
||||
for (const char *asset : {"Levels/Game/BAM_LevelRoot/BAM_LevelRoot",
|
||||
"Levels/Game/DingoLevel_Isle_of_Grom/DingoLevel_Isle_of_Grom",
|
||||
@@ -920,10 +931,15 @@ std::vector<std::string> load_levels(const std::filesystem::path &mods) {
|
||||
return problems;
|
||||
}
|
||||
const std::vector<ServerLevel> &levels() { return level_list(); }
|
||||
std::vector<ServerLevel> level_snapshot() {
|
||||
std::lock_guard lock(level_mutex);
|
||||
return level_list();
|
||||
}
|
||||
const ServerLevel *find_level(std::string_view map) {
|
||||
if (map.empty()) return nullptr;
|
||||
for (const auto &level : level_list())
|
||||
if (same(level.asset, map)) return &level;
|
||||
map = world_level_typed_name(map);
|
||||
for (const bool exact : {true, false}) {
|
||||
const ServerLevel *result{};
|
||||
for (const auto &level : level_list()) {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::server {
|
||||
// One kind of player vote: whether players may start it, and the share of connected players
|
||||
// One kind of player vote: whether players may start it, and the share of the votes cast
|
||||
// (percent, 1-100) whose yes passes it.
|
||||
struct VoteSetting {
|
||||
bool enabled{};
|
||||
@@ -259,6 +259,8 @@ struct ServerLevel {
|
||||
// (players' map mods, copied next to the server). Returns why a mod was skipped.
|
||||
std::vector<std::string> load_levels(const std::filesystem::path &mods);
|
||||
const std::vector<ServerLevel> &levels();
|
||||
// A copy of them, for a thread that is not the server's (the server window).
|
||||
std::vector<ServerLevel> level_snapshot();
|
||||
// Like the game's `load`: a level path, a name or short name, or the unique start of one.
|
||||
const ServerLevel *find_level(std::string_view map);
|
||||
// Whether the server has this map (a name, level path or destination): one of the game's own,
|
||||
|
||||
@@ -412,7 +412,7 @@ class Host {
|
||||
multiplayer::ServerPolls enabled_polls() const;
|
||||
std::vector<multiplayer::ServerCustomVote> custom_votes() const; // the owner's votes that are on, for the roster
|
||||
std::set<std::uint64_t> vote_voters(const Vote &) const; // who may vote in it
|
||||
unsigned votes_needed(const Vote &, unsigned voters) const;
|
||||
unsigned votes_needed(const Vote &, unsigned cast) const;
|
||||
std::vector<unsigned> poll_count(const Vote &) const;
|
||||
std::string running_vote_text() const; // what is running, and how to answer it
|
||||
// The "votes" and "announcements" commands (server_votes.cpp); the bool: a setting changed.
|
||||
|
||||
+16
-8
@@ -317,7 +317,7 @@ std::string Host::start_vote(Guest *starter, VoteKind kind, std::string_view arg
|
||||
const auto label = vote.label;
|
||||
vote_ = std::move(vote);
|
||||
const auto who = starter ? guest_name(*starter) : std::string("The server");
|
||||
send_chat(who + " started a vote to " + label + " (" + std::to_string(setting.percent) + "% needed, " +
|
||||
send_chat(who + " started a vote to " + label + " (" + std::to_string(setting.percent) + "% of the votes cast passes it, " +
|
||||
std::to_string(seconds) + " s). Vote on the card at the right of your screen, or type /yes or /no.");
|
||||
log_("[vote] " + who + " started a vote to " + label + ".");
|
||||
check_vote(false);
|
||||
@@ -411,10 +411,12 @@ std::set<std::uint64_t> Host::vote_voters(const Vote &vote) const {
|
||||
if (guest->handshaken && id != vote.target) voters.insert(id);
|
||||
return voters;
|
||||
}
|
||||
unsigned Host::votes_needed(const Vote &vote, unsigned voters) const {
|
||||
// The yes votes that pass a vote in which `cast` players voted: its percentage of them. A vote is
|
||||
// decided by those who vote, not by everyone on the server.
|
||||
unsigned Host::votes_needed(const Vote &vote, unsigned cast) const {
|
||||
const auto percent = std::clamp(vote_setting(vote.kind, vote.custom).percent, 1U, 100U);
|
||||
// A kick needs a second player's yes: with only the starter and the target on, it fails.
|
||||
return std::max(vote.kind == VoteKind::kick ? 2U : 1U, (voters * percent + 99) / 100);
|
||||
return std::max(vote.kind == VoteKind::kick ? 2U : 1U, (cast * percent + 99) / 100);
|
||||
}
|
||||
std::vector<unsigned> Host::poll_count(const Vote &poll) const {
|
||||
std::vector<unsigned> count(poll.answers.size());
|
||||
@@ -436,7 +438,8 @@ void Host::show_vote(const Vote &vote, std::uint8_t outcome) {
|
||||
} else {
|
||||
shown.yes = capped(static_cast<unsigned>(vote.yes.size()));
|
||||
shown.no = capped(static_cast<unsigned>(vote.no.size()));
|
||||
shown.needed = capped(votes_needed(vote, static_cast<unsigned>(vote_voters(vote).size())));
|
||||
// What yes needs of the votes cast so far: where the card's bar tips over.
|
||||
shown.needed = capped(votes_needed(vote, static_cast<unsigned>(vote.yes.size() + vote.no.size())));
|
||||
}
|
||||
shown.starter = vote.starter;
|
||||
shown.target = vote.target;
|
||||
@@ -486,11 +489,16 @@ void Host::check_vote(bool expired) {
|
||||
}
|
||||
if (vote.kind == VoteKind::kick && !find(vote.target)) return cancel_vote("the player left");
|
||||
const auto eligible = static_cast<unsigned>(voters.size());
|
||||
const auto needed = votes_needed(vote, eligible);
|
||||
const auto yes = static_cast<unsigned>(vote.yes.size()), no = static_cast<unsigned>(vote.no.size());
|
||||
const bool passed = yes >= needed;
|
||||
const bool lost = !passed && (expired || yes + (eligible - std::min(eligible, yes + no)) < needed);
|
||||
const auto tally = std::to_string(yes) + " yes, " + std::to_string(no) + " no, " + std::to_string(needed) + " needed";
|
||||
const auto cast = yes + no, waiting = eligible - std::min(eligible, cast);
|
||||
// It runs its time and is then decided by the votes cast. It ends before that once the
|
||||
// players still to vote cannot change it: yes has enough even if they all say no, or not
|
||||
// enough even if they all say yes (which is also how it ends when everyone has voted).
|
||||
const auto needed = votes_needed(vote, cast), of_all = votes_needed(vote, eligible);
|
||||
const bool passed = expired ? cast && yes >= needed : yes >= of_all;
|
||||
const bool lost = !passed && (expired || yes + waiting < of_all);
|
||||
const auto tally = std::to_string(yes) + " yes, " + std::to_string(no) + " no, " + std::to_string(needed) + " needed of " +
|
||||
std::to_string(cast) + " vote" + (cast == 1 ? "" : "s");
|
||||
if (!passed && !lost) {
|
||||
// Games show the tally as it changes (the vote card); chat is not filled with it.
|
||||
show_vote(vote, multiplayer::vote_running);
|
||||
|
||||
@@ -70,6 +70,7 @@ add_library(dingosdk_runtime SHARED
|
||||
Extension/Settings/job_spin.cpp
|
||||
Extension/Settings/engine_tweaks.cpp
|
||||
Extension/Settings/replay_guard.cpp
|
||||
Extension/World/mesh_unload_guard.cpp
|
||||
Extension/Settings/replay_drafts.cpp
|
||||
Extension/UI/ui_pointer_skip.cpp
|
||||
Extension/Settings/named_settings.cpp
|
||||
|
||||
Reference in new issue
Block a user