diff --git a/Engine/Game/Build/20260929/mesh_variations.h b/Engine/Game/Build/20260929/mesh_variations.h new file mode 100644 index 00000000..5c4a54b3 --- /dev/null +++ b/Engine/Game/Build/20260929/mesh_variations.h @@ -0,0 +1,24 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" +#include +#include + +// 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 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 next_entry_bytes{0x48, 0x83, 0xc3, 0x30}; +} diff --git a/Engine/Game/World/world_names.h b/Engine/Game/World/world_names.h index 64733aa8..147fc0b7 100644 --- a/Engine/Game/World/world_names.h +++ b/Engine/Game/World/world_names.h @@ -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(c + ('a' - 'A')); if (folded.starts_with("dingolevel_")) { name.erase(0, 11); folded.erase(0, 11); } + // A custom map's level is dingolevel_reskate_ (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 ": 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; diff --git a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp index 29236513..2a450d39 100644 --- a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp +++ b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp @@ -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); diff --git a/Engine/Vfs/mod_merge.cpp b/Engine/Vfs/mod_merge.cpp index 710d1e37..219f5050 100644 --- a/Engine/Vfs/mod_merge.cpp +++ b/Engine/Vfs/mod_merge.cpp @@ -194,6 +194,21 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con if (options.live) record = read_placements(placementsPath); std::vector 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]; } diff --git a/Engine/Vfs/mod_merge_files.cpp b/Engine/Vfs/mod_merge_files.cpp index 591b5028..4a23ab62 100644 --- a/Engine/Vfs/mod_merge_files.cpp +++ b/Engine/Vfs/mod_merge_files.cpp @@ -156,6 +156,9 @@ PlacementRecord read_placements(const fs::path& file) { {spot.at("archive").get(), spot.at("offset").get()}; } } + 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); diff --git a/Engine/Vfs/mod_merge_internal.h b/Engine/Vfs/mod_merge_internal.h index b2cb4e3b..df9cbf52 100644 --- a/Engine/Vfs/mod_merge_internal.h +++ b/Engine/Vfs/mod_merge_internal.h @@ -64,6 +64,10 @@ struct ArchivePlacement { std::uint64_t offset{}; }; std::map, 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 diff --git a/Extension/Assets/map_download.cpp b/Extension/Assets/map_download.cpp index e69b6aa2..2e2636e6 100644 --- a/Extension/Assets/map_download.cpp +++ b/Extension/Assets/map_download.cpp @@ -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> 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 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{} : 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> 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(std::tolower(c)); }); diff --git a/Extension/Assets/map_download.h b/Extension/Assets/map_download.h index e09e1065..68f30ac6 100644 --- a/Extension/Assets/map_download.h +++ b/Extension/Assets/map_download.h @@ -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 diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 6c38e43c..3e083263 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -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 position{}; std::array rotation{0, 0, 0, 1}; diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index a18ee2ec..4130fa7b 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -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 } diff --git a/Extension/Multiplayer/Session/session.h b/Extension/Multiplayer/Session/session.h index 0181f7f1..ade2b8c3 100644 --- a/Extension/Multiplayer/Session/session.h +++ b/Extension/Multiplayer/Session/session.h @@ -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 take_map_need(); // Whether a session is waiting on a map being fetched right now. Thread-safe. diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index c3f8ca8e..0c868c9c 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -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" && diff --git a/Extension/UI/Overlay/chat_overlay.cpp b/Extension/UI/Overlay/chat_overlay.cpp index e35b3fcf..9be0ea6a 100644 --- a/Extension/UI/Overlay/chat_overlay.cpp +++ b/Extension/UI/Overlay/chat_overlay.cpp @@ -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(vote.yes) / static_cast(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(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(vote.yes) / static_cast(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(vote.needed) / static_cast(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) { diff --git a/Extension/UI/Overlay/map_download_card.cpp b/Extension/UI/Overlay/map_download_card.cpp index 70c9a3da..b8a72f3d 100644 --- a/Extension/UI/Overlay/map_download_card.cpp +++ b/Extension/UI/Overlay/map_download_card.cpp @@ -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()); diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index bc3dc3ea..5b30bf90 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -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}; diff --git a/Extension/World/console_commands.cpp b/Extension/World/console_commands.cpp index d7876097..05e21380 100644 --- a/Extension/World/console_commands.cpp +++ b/Extension/World/console_commands.cpp @@ -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) { diff --git a/Extension/World/mesh_unload_guard.cpp b/Extension/World/mesh_unload_guard.cpp new file mode 100644 index 00000000..b7ae1cb5 --- /dev/null +++ b/Extension/World/mesh_unload_guard.cpp @@ -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 +#include +#include + +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 installed{}; +std::atomic 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(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 head{}; + std::array read{}; + std::array 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(base + mv::remove_databases.rva); + if (hook_prepare(target, reinterpret_cast(&guarded), reinterpret_cast(&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 diff --git a/Extension/World/mesh_unload_guard.h b/Extension/World/mesh_unload_guard.h new file mode 100644 index 00000000..806fd7b9 --- /dev/null +++ b/Extension/World/mesh_unload_guard.h @@ -0,0 +1,15 @@ +#pragma once +#include + +// 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//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; +} diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 5f5cecd0..1a39da0c 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -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); diff --git a/Server/GUI/server_gui.cpp b/Server/GUI/server_gui.cpp index 12bcc3c7..2e708a4b 100644 --- a/Server/GUI/server_gui.cpp +++ b/Server/GUI/server_gui.cpp @@ -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)); diff --git a/Server/README-roles.txt b/Server/README-roles.txt index a9ffe806..46fdcb99 100644 --- a/Server/README-roles.txt +++ b/Server/README-roles.txt @@ -143,7 +143,8 @@ Messages Map and world map map : 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 |clear]: the maps players vote between and the rotation uses. rotation rotation [|off]: change the map on a timer. diff --git a/Server/README.txt b/Server/README.txt index df5e9e3f..7640270b 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -326,8 +326,11 @@ votes Player votes, each off until turned on: "kick": {"enabled": true, "percent": 60} /vote kick "time_of_day": {"enabled": true, "percent": 50} /vote tod