diff --git a/.gitignore b/.gitignore index 8e41f97..70500ef 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ analysis/raw/**/*.res /analysis/ /docs/ /release + +# Drone model sources (assets/models/fpv/CREDITS.md): large; only the prepared drone.glb is kept. +/assets/models/fpv/dji_avata2.glb +/assets/models/fpv/fpv_drone.glb diff --git a/Engine/Game/Build/20260929/world_props.h b/Engine/Game/Build/20260929/world_props.h new file mode 100644 index 0000000..6ba173a --- /dev/null +++ b/Engine/Game/Build/20260929/world_props.h @@ -0,0 +1,16 @@ +#pragma once +#include + +// Props spawned by ReSkate itself (Extension/Multiplayer/Remote/native_prop.cpp): an object +// blueprint loaded on the map, created as a client-only entity the way the remote skateboard is +// (skater_entities.h, native_skater.h) and moved every frame by the plain entity transform setter +// (skater_entities::place_entity). Found in game on 2026-10-15 with the traffic cone +// (world/props/common/construction/cmn_prp_trafficcone_01/cmn_prp_trafficcone_01): the creation +// list made one entity, and only place_entity moved it smoothly (the blueprint transform did not). +namespace dingosdk::game::build::v20260929::world_props { +// An ObjectBlueprint's vtable (the cone's). The skater code's blueprint vtable is +// engine::blueprint_vtable; an object blueprint has its own. +inline constexpr std::uintptr_t object_blueprint_vtable = 0x638dfa8; +// The entity a static-model object blueprint creates on the client (the cone's). +inline constexpr std::uintptr_t static_model_entity_vtable = 0x6403720; +} // namespace dingosdk::game::build::v20260929::world_props diff --git a/Engine/Vfs/builtin_packs.cpp b/Engine/Vfs/builtin_packs.cpp new file mode 100644 index 0000000..9921308 --- /dev/null +++ b/Engine/Vfs/builtin_packs.cpp @@ -0,0 +1,182 @@ +#include "builtin_packs.h" +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::mods { +namespace { +namespace fs = std::filesystem; +// Each pack: its RCDATA resource in ReSkate.dll (cmake/Runtime.cmake) and its folder name. +struct Embedded { + const wchar_t* resource; + const char* name; +}; +constexpr Embedded embedded[]{{L"BUILTIN_PACK_FPV_DRONE", "fpv_drone"}}; +constexpr std::uint64_t max_pack_bytes = 512ull * 1024 * 1024; +constexpr std::string_view version_file = ".builtin-version"; + +struct Source { + HMODULE module{}; + bool owned{}; + Source() = default; + Source(const Source&) = delete; + Source& operator=(const Source&) = delete; + ~Source() { + if (owned && module) FreeLibrary(module); + } +}; +bool carries_packs(HMODULE module) { + return module && FindResourceW(module, embedded[0].resource, MAKEINTRESOURCEW(10) /* RT_RCDATA */); +} +// ReSkate.dll: this module when it is the DLL (the game), else ReSkate.dll beside the program +// (the launcher, which sits beside Skate.exe and the DLL), as data only. +void open_source(Source& source) { + HMODULE self{}; + if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&carries_packs), &self) && carries_packs(self)) { + source.module = self; + return; + } + if (const auto loaded = GetModuleHandleW(L"ReSkate.dll"); carries_packs(loaded)) { + source.module = loaded; + return; + } + std::wstring program(32768, L'\0'); + const auto length = GetModuleFileNameW(nullptr, program.data(), static_cast(program.size())); + if (!length || length >= program.size()) return; + program.resize(length); + const auto dll = fs::path(program).parent_path() / L"ReSkate.dll"; + source.module = LoadLibraryExW(dll.c_str(), nullptr, LOAD_LIBRARY_AS_DATAFILE | LOAD_LIBRARY_AS_IMAGE_RESOURCE); + source.owned = source.module != nullptr; +} +std::span resource_bytes(HMODULE module, const wchar_t* name) { + const auto resource = FindResourceW(module, name, MAKEINTRESOURCEW(10)); + if (!resource) return {}; + const auto loaded = LoadResource(module, resource); + const auto size = SizeofResource(module, resource); + const auto* data = loaded ? static_cast(LockResource(loaded)) : nullptr; + return data && size ? std::span(data, size) : std::span{}; +} +// Which build of a pack this is: enough to tell when the one embedded changed. +std::string version_of(std::span bytes) { + std::uint64_t hash = 0xcbf29ce484222325ULL; + for (const auto byte : bytes) hash = (hash ^ byte) * 0x100000001b3ULL; + return std::format("{:016x}-{}", hash, bytes.size()); +} +std::string read_text(const fs::path& path) { + std::ifstream input(path, std::ios::binary); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; +} +// A path inside the pack: relative, forward slashes, no "..", plain characters. +bool safe_entry(std::string_view name) { + if (name.empty() || name.size() > 260 || name.front() == '/' || name.find(':') != std::string_view::npos || + name.find('\\') != std::string_view::npos) + return false; + std::size_t start = 0; + while (start <= name.size()) { + const auto end = std::min(name.find('/', start), name.size()); + const auto part = name.substr(start, end - start); + if (part.empty() || part == "." || part == "..") return false; + if (!std::all_of(part.begin(), part.end(), [](char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; + })) + return false; + start = end + 1; + } + return true; +} +bool unpack(std::span bytes, const fs::path& target, std::string& issue) { + struct Zip { + mz_zip_archive value{}; + ~Zip() { mz_zip_reader_end(&value); } + } zip; + if (!mz_zip_reader_init_mem(&zip.value, bytes.data(), bytes.size(), 0)) { + issue = "the embedded pack is not a zip"; + return false; + } + const auto count = mz_zip_reader_get_num_files(&zip.value); + std::uint64_t total{}; + for (mz_uint index = 0; index < count; ++index) { + mz_zip_archive_file_stat info{}; + if (!mz_zip_reader_file_stat(&zip.value, index, &info) || info.m_is_encrypted) { + issue = "the embedded pack has an unreadable entry"; + return false; + } + if (info.m_is_directory) continue; + total += info.m_uncomp_size; + if (!safe_entry(info.m_filename) || total > max_pack_bytes) { + issue = std::string("the embedded pack has an entry it may not: ") + info.m_filename; + return false; + } + const auto path = target / fs::path(info.m_filename); + fs::create_directories(path.parent_path()); + struct File { + HANDLE value; + ~File() { if (value != INVALID_HANDLE_VALUE) CloseHandle(value); } + } file{CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)}; + struct Sink { + HANDLE file; + std::uint64_t written{}, limit; + } sink{file.value, 0, info.m_uncomp_size}; + const auto write = [](void* opaque, mz_uint64 offset, const void* buffer, size_t size) -> size_t { + auto& target = *static_cast(opaque); + if (offset != target.written || size > target.limit - target.written) return 0; + DWORD done{}; + if (!WriteFile(target.file, buffer, static_cast(size), &done, nullptr) || done != size) return 0; + target.written += size; + return size; + }; + if (file.value == INVALID_HANDLE_VALUE || !mz_zip_reader_extract_to_callback(&zip.value, index, write, &sink, 0) || + sink.written != info.m_uncomp_size) { + issue = std::string("could not unpack ") + info.m_filename; + return false; + } + } + return true; +} +} // namespace + +std::vector unpack_builtin_packs(const fs::path& mods_root, std::vector& notes) noexcept { + std::vector packs; + try { + Source source; + open_source(source); + if (!source.module) return packs; + for (const auto& pack : embedded) { + const auto bytes = resource_bytes(source.module, pack.resource); + if (bytes.empty()) continue; + const auto directory = mods_root / std::string(builtin_folder) / pack.name; + const auto version = version_of(bytes); + std::error_code error; + if (read_text(directory / version_file) != version) { + // Unpacked beside it, then swapped in: a pack is never half there. + const auto staging = fs::path(directory.wstring() + std::format(L".unpacking-{}", GetCurrentProcessId())); + fs::remove_all(staging, error); + std::string issue; + if (!unpack(bytes, staging, issue)) { + fs::remove_all(staging, error); + notes.push_back(std::format("Built-in content {} could not be unpacked: {}", pack.name, issue)); + continue; + } + std::ofstream(staging / version_file, std::ios::binary) << version; + fs::remove_all(directory, error); + fs::rename(staging, directory, error); + if (error) { + fs::remove_all(staging, error); + notes.push_back(std::format("Built-in content {} could not be put in place ({})", pack.name, error.message())); + continue; + } + notes.push_back(std::format("Built-in content {} unpacked ({} bytes)", pack.name, bytes.size())); + } + packs.push_back({std::string("reskate-builtin-") + pack.name, directory}); + } + } catch (const std::exception& failure) { + notes.push_back(std::string("Built-in content could not be prepared: ") + failure.what()); + } + return packs; +} +} // namespace dingosdk::mods diff --git a/Engine/Vfs/builtin_packs.h b/Engine/Vfs/builtin_packs.h new file mode 100644 index 0000000..efbac97 --- /dev/null +++ b/Engine/Vfs/builtin_packs.h @@ -0,0 +1,22 @@ +#pragma once +#include +#include +#include + +// Game content ReSkate carries itself: packs of game assets built by ReSkate Studio (the FPV +// drone's model, tools/models/build_drone_pack.py), zipped into ReSkate.dll. Nobody installs or +// sees them. Before a merge each is unpacked under Mods/.reskate-builtin/ (a dot folder, +// which no mod list shows) when the one embedded differs from what is there, and the catalogue +// merges it after every installed mod, always on (mod_catalog.cpp). +namespace dingosdk::mods { +inline constexpr std::string_view builtin_folder = ".reskate-builtin"; +struct BuiltinPack { + std::string name; // as the merge knows it: reskate-builtin- + std::filesystem::path directory; // Mods/.reskate-builtin/ +}; +// The packs ReSkate.dll carries, unpacked and current under `mods_root`. ReSkate.dll is the +// module running this when it is loaded (the game); otherwise the copy beside this program (the +// launcher) is opened as data. Never throws; `notes` says what was unpacked or went wrong. +std::vector unpack_builtin_packs(const std::filesystem::path& mods_root, + std::vector& notes) noexcept; +} // namespace dingosdk::mods diff --git a/Engine/Vfs/mod_catalog.cpp b/Engine/Vfs/mod_catalog.cpp index a881e79..a419b98 100644 --- a/Engine/Vfs/mod_catalog.cpp +++ b/Engine/Vfs/mod_catalog.cpp @@ -1,4 +1,5 @@ #include "mod_catalog.h" +#include "builtin_packs.h" #include "mod_merge.h" #include "native_db.h" @@ -305,7 +306,12 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver result.issue = "The engine data root could not be resolved"; return result; } + // Content ReSkate carries itself (builtin_packs.h), unpacked first: it makes the Mods folder + // when there is none, so the content loads without any mod installed. + std::vector builtin_notes; + const auto builtin = unpack_builtin_packs(data_root / mods_folder, builtin_notes); auto list = scan_mods(data_root); + for (auto& note : builtin_notes) list.notes.push_back(std::move(note)); result.root = std::move(list.root); result.present = list.present; if (!result.present) return result; @@ -319,6 +325,9 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver try { for (const auto& name : list.missing) result.notes.push_back("mods.json names a folder that is not present: " + name); + // After every installed mod, so none of theirs is ever shadowed by it; always on. + for (const auto& pack : builtin) + list.entries.push_back({describe_mod(pack.directory, pack.name, result.notes), true}); // Board Break: a deck pack holds several decks in one mod folder (mod_catalog.h). Each deck // inside becomes a mod of its own for the run, listed after the pack as #, so // that a pack's decks are chosen, set aside and switched like decks in folders of their own. diff --git a/Engine/Vfs/mod_list.cpp b/Engine/Vfs/mod_list.cpp index 04bc0f0..e2e3bd6 100644 --- a/Engine/Vfs/mod_list.cpp +++ b/Engine/Vfs/mod_list.cpp @@ -323,6 +323,28 @@ bool valid_mod_name(std::string_view name) { }); } +Mod describe_mod(const std::filesystem::path& directory, std::string name, std::vector& notes) { + std::error_code error; + Mod mod; + mod.name = std::move(name); + mod.title = mod.name; + mod.directory = directory; + mod.provides_layout = fs::is_regular_file(mod.directory / layout_name, error); + mod.provides_levels = fs::is_regular_file(mod.directory / levels_name, error); + for (fs::directory_iterator it(mod.directory / "parks", error), end; !error && it != end; it.increment(error)) { + auto file = ascii_path(it->path().filename()); + if (!file.ends_with(".park.json") || mod.park_maps.size() >= 16) continue; + file.resize(file.size() - 10); + mod.park_maps.push_back(std::move(file)); + } + std::sort(mod.park_maps.begin(), mod.park_maps.end()); + read_build_info(mod, notes); + read_mod_info(mod, notes); + read_levels(mod); + read_studio_marker(mod); + return mod; +} + ModList scan_mods(const std::filesystem::path& data_root) noexcept { ModList result; try { @@ -364,24 +386,7 @@ ModList scan_mods(const std::filesystem::path& data_root) noexcept { } ModEntry entry; entry.enabled = enabled; - auto& mod = entry.mod; - mod.name = found->second; - mod.title = mod.name; - mod.directory = result.root / mod.name; - mod.provides_layout = fs::is_regular_file(mod.directory / layout_name, error); - mod.provides_levels = fs::is_regular_file(mod.directory / levels_name, error); - for (fs::directory_iterator it(mod.directory / "parks", error), end; !error && it != end; it.increment(error)) { - auto file = ascii_path(it->path().filename()); - if (!file.ends_with(".park.json") || mod.park_maps.size() >= 16) continue; - file.resize(file.size() - 10); - mod.park_maps.push_back(std::move(file)); - } - error.clear(); - std::sort(mod.park_maps.begin(), mod.park_maps.end()); - read_build_info(mod, result.notes); - read_mod_info(mod, result.notes); - read_levels(mod); - read_studio_marker(mod); + entry.mod = describe_mod(result.root / found->second, found->second, result.notes); result.entries.push_back(std::move(entry)); } // Only mods named in the exclusions file are fingerprinted. diff --git a/Engine/Vfs/mod_list.h b/Engine/Vfs/mod_list.h index aff4fbd..5237ca5 100644 --- a/Engine/Vfs/mod_list.h +++ b/Engine/Vfs/mod_list.h @@ -110,6 +110,9 @@ std::string ascii_path(const std::filesystem::path& path); // malformed, `issue` says why and `entries` still lists the folders (all // shown as enabled) so a manager can offer to rewrite it. ModList scan_mods(const std::filesystem::path& data_root) noexcept; +// One mod folder as scan_mods reads it: its layout, levels, parks, info files and build stamp. +// `name` is how it is known (for a folder under Mods/, the folder's name). Throws on I/O errors. +Mod describe_mod(const std::filesystem::path& directory, std::string name, std::vector& notes); // Writes mods.json for `entries` in order, replacing the file atomically. // Throws with a readable message on failure. diff --git a/Extension/Multiplayer/Remote/native_prop.cpp b/Extension/Multiplayer/Remote/native_prop.cpp new file mode 100644 index 0000000..b2971f6 --- /dev/null +++ b/Extension/Multiplayer/Remote/native_prop.cpp @@ -0,0 +1,358 @@ +#include "native_prop.h" +#include "native_skater_internal.h" +#include "native_creation_list.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Game/Abi/native_data.h" +#include "Engine/Game/Build/20260929/engine.h" +#include "Engine/Game/Build/20260929/world_props.h" +#include "Extension/Skater/drone_beacon.h" +#include +#include +#include + +namespace dingosdk::multiplayer { +using namespace native_skater_detail; +namespace props = addr::world_props; +namespace { +// ---- Placed props, and the engine taking them away +// Every prop entity placed and not yet destroyed. The engine's destruction hook takes an entity +// out before it is freed, under the same lock that every move and removal holds, so nothing +// moves a freed prop. (Recursive: a removal is a destruction of our own.) +struct Registry { + std::recursive_mutex mutex; + std::vector live; + std::atomic count{}; +}; +Registry ®istry() { + static auto *r = new Registry; + return *r; +} +bool tracked(std::uintptr_t entity) { + auto &r = registry(); + return entity && std::find(r.live.begin(), r.live.end(), entity) != r.live.end(); +} +void track(std::uintptr_t entity) { + auto &r = registry(); + r.live.push_back(entity); + r.count.store(r.live.size(), std::memory_order_release); +} +void untrack(std::uintptr_t entity) { + auto &r = registry(); + std::erase(r.live, entity); + r.count.store(r.live.size(), std::memory_order_release); +} + +struct Prop { + std::uintptr_t entity{}, parent{}, context{}; +}; +// A blueprint the way the cone's is one (world_props.h), or the skateboard's or the skater +// code's; `chain` gets what it was, for the log. +bool is_blueprint(std::uintptr_t base, std::uintptr_t object, std::string &chain) { + const auto vtable = ptr(object); + chain = std::format("vtable Skate+{:#x}", vtable - base); + if (vtable == base + props::object_blueprint_vtable || vtable == base + addr::engine::blueprint_vtable) return true; + auto type = ptr(object, 8); + for (int depth = 0; depth < 8 && type; ++depth) { + if (type == base + addr::engine::board_blueprint_type || type == base + entities::blueprint_type) return true; + const auto super = ptr(type, 0x20); + if (super == type) break; + type = super; + } + return false; +} +// A loaded named blueprint, from whichever asset domain holds it; 0 when none does. +std::uintptr_t find_named_blueprint(std::uintptr_t base, std::string_view name) { + const auto find = game::native_data().find_asset; + require(find != nullptr, "Native asset lookup is unavailable."); + const std::string text(name); + for (std::uint16_t domain = 0; domain < 0xbbf; ++domain) { + if (!ptr(base, addr::engine::domain_owners + domain * std::uintptr_t{8})) continue; + if (const auto asset = find(domain, text.c_str())) { + std::string chain; + if (!is_blueprint(base, asset, chain)) + throw std::runtime_error(std::format("{} is not a blueprint ({}).", name, chain)); + return asset; + } + } + return 0; +} +// Whether entities can be created now, on this thread: the hooks are in, the engine is ready +// and the client's job context is this one. Throws only when something is wrong; false is "wait". +bool can_create(std::uintptr_t base, const NativeFrame &local) { + install(base); + require(shared().hooks, "The native entity hooks are not installed."); + require(GetCurrentThreadId() == shared().engine_thread, "Props are created on the client thread."); + if (read(base, addr::engine::entity_creation_ready) == 0) return false; + const auto tls_index = read(base, addr::engine::tls_index); + require(tls_index <= 4095, "Native TLS index changed."); + const auto tls = ptr(static_cast(__readgsqword(0x58)), std::uintptr_t{tls_index} * 8); + return read(tls, 0xb19) != 0 && ptr(tls, 0x550) == local.context; +} +// As the remote skateboard is made (native_skater_spawn.cpp): a fresh entity through a creation +// list, the list initialized, then placed with its lifecycle callbacks. `kinds` gets the vtable +// of each entity the list made. Under the registry lock. +Prop create_prop(std::uintptr_t base, const NativeFrame &local, std::uintptr_t blueprint, const std::array &where, + std::string *kinds = nullptr) { + alignas(16) const auto matrix = where; + NativeCreationList list{}; + alignas(16) std::array descriptor{}; + using Init = void *(*)(void *, std::uintptr_t, std::uintptr_t, const void *); + reinterpret_cast(base + entities::descriptor_init)(descriptor.data(), 0, local.parent, matrix.data()); + struct DescriptorScope { + std::uintptr_t base; + void *data; + ~DescriptorScope() { reinterpret_cast(base + entities::descriptor_destroy)(static_cast(data) + 0x10); } + } scope{base, descriptor.data()}; + const std::uint32_t id = 255; // local, unassociated + std::memcpy(descriptor.data() + 0x38, &id, sizeof(id)); + descriptor[0x151] = 0; + auto *list_pointer = &list; + std::memcpy(descriptor.data() + 0x158, &list_pointer, sizeof(list_pointer)); + std::array result{}; + using Create = void *(*)(void *, void *, std::uintptr_t, std::uintptr_t, std::uintptr_t); + reinterpret_cast(base + entities::create_entity)(result.data(), descriptor.data(), blueprint, 0, 0); + if (result[1]) reinterpret_cast(base + entities::release_reference)(result[1]); + const auto created = read_native_creation_list(readable, list); + if (kinds) + for (const auto entity : created.entities) *kinds += std::format(" Skate+{:#x}", ptr(entity) - base); + if (!created.pages.empty()) reinterpret_cast(base + native::initialize_creation_list)(&list); + for (const auto page : created.pages) { + const std::uint32_t empty = 0; + (void)write(page + 8, &empty, sizeof(empty)); + reinterpret_cast(base + native::free_creation_page)(&list, page, 0x1f0); + } + require(result[0] != 0, "The engine did not create the prop."); + using InitializePlacement = void (*)(std::uintptr_t, const void *, std::uintptr_t, std::uint8_t); + reinterpret_cast(base + entities::initialize_placement)(result[0], matrix.data(), 0, 1); + track(result[0]); + return {result[0], ptr(result[0], 0x40), local.context}; +} +// The plain entity transform setter: what moves a prop smoothly (world_props.h). Under the lock. +void place(std::uintptr_t base, const Prop &prop, const std::array &where) { + alignas(16) const auto matrix = where; + reinterpret_cast(base + entities::place_entity)(prop.entity, matrix.data()); +} +// Under the lock. One the engine already took is only forgotten. +void destroy(const Prop &prop) { + if (!tracked(prop.entity)) return; + untrack(prop.entity); + if (shared().hooks && ptr(prop.entity, 0x20) == prop.context) shared().original_destroy(prop.entity, prop.parent); +} +std::array matrix_of(const std::array &position, const std::array &rotation) { + Transform t; + t.position = position; + t.rotation = rotation; + return to_matrix(t); +} + +// ---- Other players' drones +struct DroneModel { + std::uint64_t id{}; + Prop prop; +}; +struct Models { + std::vector list; // client thread + std::uintptr_t context{}, blueprint{}; // the map the models are on, and what they are made of + std::string blueprint_name; + ULONGLONG next_lookup{}; + std::mutex status_mutex; + std::string status = "No other player is flying a drone."; +}; +Models &models() { + static auto *m = new Models; + return *m; +} +void set_models_status(std::string text, bool log) { + auto &m = models(); + { + std::lock_guard lock(m.status_mutex); + if (m.status == text) return; + m.status = text; + } + if (log) logging::log(logging::Level::info, logging::Channel::runtime, "Drone models: {}", text); +} + +// ---- The test that found how +struct Request { + std::string blueprint; // empty: stop + int mode{}; +}; +struct PropTest { + std::mutex mutex; + std::optional request; + std::string status = "Not running. Console: dronemodeltest [blueprint] [mode 0-2], dronemodeltest stop"; + Prop prop; // client thread + std::array anchor{}, position{}; + ULONGLONG started{}; + unsigned frames{}; + int mode{}; +}; +PropTest &test() { + static auto *t = new PropTest; + return *t; +} +void set_test_status(std::string text) { + logging::log(logging::Level::info, logging::Channel::runtime, "Drone model test: {}", text); + std::lock_guard lock(test().mutex); + test().status = std::move(text); +} +std::array yawed(const std::array &position, float yaw) { + return matrix_of(position, {0.0f, std::sin(yaw / 2), 0.0f, std::cos(yaw / 2)}); +} +void test_spawn(std::uintptr_t base, std::uintptr_t client, const Request &request) { + auto &t = test(); + const auto local = capture_local(base, client, false); + require(local.ready && local.context && local.parent && local.entity, "The local skater is not ready."); + require(can_create(base, local), "Waiting for the engine to allow creating entities; try again."); + const auto blueprint = find_named_blueprint(base, request.blueprint); + require(blueprint != 0, "That blueprint is not loaded on this map."); + const auto root = to_matrix(local.pose.root); + const std::array at{local.pose.root.position[0] - root[8] * 4.0f, local.pose.root.position[1] + 2.0f, + local.pose.root.position[2] - root[10] * 4.0f}; + std::string kinds; + t.prop = create_prop(base, local, blueprint, yawed(at, 0.0f), &kinds); + t.anchor = at; + t.started = GetTickCount64(); + t.frames = 0; + t.mode = request.mode; + set_test_status(std::format("spawned {} at ({:.1f}, {:.1f}, {:.1f}): entity {:#x} (vtable Skate+{:#x}), made:{}; moving it with mode {}.", + request.blueprint, at[0], at[1], at[2], t.prop.entity, ptr(t.prop.entity) - base, kinds, t.mode)); +} +// Round a 3 m circle every 6 s, turning to face along it. +void test_move(std::uintptr_t base) { + auto &t = test(); + const float seconds = static_cast(GetTickCount64() - t.started) / 1000.0f; + const float angle = seconds * 6.2831853f / 6.0f; + const std::array at{t.anchor[0] + 3.0f * std::cos(angle), t.anchor[1] + 0.5f * std::sin(angle * 2.0f), + t.anchor[2] + 3.0f * std::sin(angle)}; + const auto matrix = yawed(at, -angle); + t.position = at; + if (t.mode >= 1 && t.prop.parent) { + const auto transform = ptr(t.prop.parent, 0x18); + alignas(16) const auto copy = matrix; + if (transform && ptr(transform) == base + native::blueprint_transform_vtable) + reinterpret_cast(base + native::set_blueprint_transform)(transform, copy.data()); + } + if (t.mode <= 1) place(base, t.prop, matrix); + if (++t.frames == 120) set_test_status(std::format("moved 120 frames with mode {}. Is it going round a circle?", t.mode)); +} +} // namespace + +void prop_entity_destroyed(std::uintptr_t entity) noexcept { + auto &r = registry(); + if (!r.count.load(std::memory_order_acquire)) return; // every destruction passes through here + std::lock_guard lock(r.mutex); + if (tracked(entity)) untrack(entity); +} + +void tick_drone_models(std::uintptr_t base, std::uintptr_t client, bool show) noexcept { + auto &m = models(); + try { + std::lock_guard lock(registry().mutex); + // The engine took some away (a map change takes them all). + std::erase_if(m.list, [](const DroneModel &model) { return !tracked(model.prop.entity); }); + const auto drones = show ? drone::beacon::snapshot() : std::vector{}; + if (drones.empty() && m.list.empty()) { + set_models_status(show ? "No other player is flying a drone." : "Other players' drones are hidden.", false); + return; + } + const auto local = capture_local(base, client, false); + if (!local.ready || !local.context || !local.parent) return; + if (local.context != m.context) { + // Another map: whatever was there went with it, and the blueprint is found again. + m.list.clear(); + m.context = local.context; + m.blueprint = 0; + m.next_lookup = 0; + } + // Those who landed or left. + for (auto it = m.list.begin(); it != m.list.end();) { + const bool flying = std::any_of(drones.begin(), drones.end(), [&](const auto &d) { return d.id == it->id; }); + if (flying) { + ++it; + continue; + } + destroy(it->prop); + it = m.list.erase(it); + } + // What the models are made of: the drone's own model, else the stand-in. + const auto now = GetTickCount64(); + if (!m.blueprint && !drones.empty() && now >= m.next_lookup) { + m.next_lookup = now + 5000; + for (const auto name : {drone_model_blueprint, drone_model_stand_in}) + if ((m.blueprint = find_named_blueprint(base, name))) { + m.blueprint_name = std::string(name); + break; + } + if (!m.blueprint) + set_models_status("Neither the drone model nor its stand-in is loaded on this map: other players' drones show as markers only.", true); + } + // One new model a frame at most, so a lobby taking off together does not stall a frame. + bool spawned = false; + for (const auto &drone : drones) { + const auto matrix = matrix_of(drone.position, drone.rotation); + const auto found = std::find_if(m.list.begin(), m.list.end(), [&](const DroneModel &model) { return model.id == drone.id; }); + if (found != m.list.end()) { + place(base, found->prop, matrix); + } else if (m.blueprint && !spawned && m.list.size() < drone::beacon::max_remote && can_create(base, local)) { + m.list.push_back({drone.id, create_prop(base, local, m.blueprint, matrix)}); + spawned = true; + } + } + if (m.blueprint) + set_models_status(std::format("{} other player(s) flying; {} model(s) shown, made of {}.", drones.size(), m.list.size(), + m.blueprint_name), spawned); + } catch (const std::exception &e) { + // Give up on what is there; the next frame starts over. + for (const auto &model : m.list) destroy(model.prop); + m.list.clear(); + m.blueprint = 0; + m.next_lookup = GetTickCount64() + 5000; + set_models_status(std::format("failed: {}", e.what()), true); + } +} +std::string drone_models_status() { + auto &m = models(); + std::lock_guard lock(m.status_mutex); + return m.status; +} + +void request_prop_test(std::string blueprint, int mode) { + std::lock_guard lock(test().mutex); + test().request = Request{std::move(blueprint), std::clamp(mode, 0, 2)}; +} +std::optional> prop_test_position() noexcept { + const auto &t = test(); + if (!t.prop.entity) return std::nullopt; + return t.position; +} +std::string prop_test_status() { + std::lock_guard lock(test().mutex); + return test().status; +} +void tick_prop_test(std::uintptr_t base, std::uintptr_t client) noexcept { + auto &t = test(); + std::optional request; + { + std::lock_guard lock(t.mutex); + request.swap(t.request); + } + try { + std::lock_guard lock(registry().mutex); + if (t.prop.entity && !tracked(t.prop.entity)) { + t.prop = {}; + set_test_status("the engine took the prop away (a map change)."); + } + if (request) { + if (t.prop.entity) destroy(t.prop); + t.prop = {}; + if (request->blueprint.empty()) set_test_status("stopped; the prop is removed."); + else test_spawn(base, client, *request); + } + if (t.prop.entity) test_move(base); + } catch (const std::exception &e) { + t.prop = {}; + set_test_status(std::format("failed: {}", e.what())); + } +} +} // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Remote/native_prop.h b/Extension/Multiplayer/Remote/native_prop.h new file mode 100644 index 0000000..c409223 --- /dev/null +++ b/Extension/Multiplayer/Remote/native_prop.h @@ -0,0 +1,36 @@ +#pragma once +#include +#include +#include +#include +#include + +// Models placed in the world by ReSkate: an object blueprint loaded on the map, created as a +// client-only entity and moved every frame (Engine/Game/Build//world_props.h). +namespace dingosdk::multiplayer { +// Other players' FPV drones. The drone's own model (made by ReSkate Studio from +// assets/models/fpv/drone.glb) when it is loaded; until then a stand-in prop of the game's. +inline constexpr std::string_view drone_model_blueprint = "reskate/drone/fpv_drone"; +inline constexpr std::string_view drone_model_stand_in = "world/props/common/construction/cmn_prp_trafficcone_01/cmn_prp_trafficcone_01"; + +// The client update thread, every frame while in a level: shows a model at each other player's +// flying drone (drone_beacon.h), moves it, and takes it away when they land or leave. `show` +// false (other players' drones hidden) takes them all away. +void tick_drone_models(std::uintptr_t base, std::uintptr_t client, bool show) noexcept; +std::string drone_models_status(); + +// The test that found how (dronemodeltest): spawns a named blueprint a few metres in front of the +// local skater and moves it round a circle. Mode 0 the entity transform setter (what works), +// 1 the blueprint transform then the setter, 2 the blueprint transform alone. Empty name: stop. +inline constexpr std::string_view prop_test_default = drone_model_stand_in; +void request_prop_test(std::string blueprint, int mode); +void tick_prop_test(std::uintptr_t base, std::uintptr_t client) noexcept; +std::string prop_test_status(); +// Where the test's prop is now, while it runs (client update thread): drone_sound.cpp plays a +// motor there, so the sound can be heard without a second player. +std::optional> prop_test_position() noexcept; + +// The engine's entity destruction hook (native_skater.cpp): a prop the engine destroys (a map +// change takes them all) is forgotten before it is freed. Any thread; cheap with none placed. +void prop_entity_destroyed(std::uintptr_t entity) noexcept; +} // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Remote/native_skater.cpp b/Extension/Multiplayer/Remote/native_skater.cpp index ff94f0f..654802d 100644 --- a/Extension/Multiplayer/Remote/native_skater.cpp +++ b/Extension/Multiplayer/Remote/native_skater.cpp @@ -1,4 +1,5 @@ #include "native_skater_internal.h" +#include "native_prop.h" #include "native_pose_layout.h" #include "puppet_cost.h" #include "Extension/Multiplayer/Session/monotonic_clock.h" @@ -385,6 +386,7 @@ void animation_hook(std::uintptr_t component, std::uintptr_t update) { void destroy_hook(std::uintptr_t entity, std::uintptr_t owner) { // Other SDK-created actors share this hook; they must forget the entity // before the engine frees it. + prop_entity_destroyed(entity); if (const auto listener = shared().destroyed_listener.load(std::memory_order_acquire)) listener(entity); // The first slot watching it as either skater or board. diff --git a/Extension/Skater/console_commands.cpp b/Extension/Skater/console_commands.cpp index 6feebdc..3e469f9 100644 --- a/Extension/Skater/console_commands.cpp +++ b/Extension/Skater/console_commands.cpp @@ -3,6 +3,7 @@ #include "ai_skaters.h" #include "drone_flight.h" #include "drone_outfit.h" +#include "Extension/Multiplayer/Remote/native_prop.h" #include #include #include @@ -175,6 +176,34 @@ void register_movement_commands(Commands ®istry) { profile_runtime::set_local_values({{"Drone.Radio", 1.0}}); }; registry.add(std::move(radio_mode)); + // A test toward the drone's 3D model (Multiplayer/Remote/native_prop.h): can a spawned prop + // be moved every frame? + auto model_name = argument("blueprint", Type::text, true); + auto model_mode = argument("mode", Type::integer, true); + auto model_test = action("dronemodeltest", + "Test for the drone model: spawns the drone (dronemodeltest drone), the traffic cone (cone) or a named blueprint in front of you and moves it " + "round a circle. Mode 0, 1 or 2 tries a different way of moving it. 'dronemodeltest stop' removes it; on its own it reports", + Group::movement, {model_name, model_mode}); + model_test.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; }; + model_test.run = [](const Model &, const Values &args, const Output &out) { + const auto name = args.empty() ? std::string() : std::get(args[0]); + const int mode = args.size() > 1 ? static_cast(std::get(args[1])) : 1; + if (name.empty() || name == "status") { + out(multiplayer::prop_test_status()); + out("Other players' drones: " + multiplayer::drone_models_status()); + return; + } + if (name == "stop") { + multiplayer::request_prop_test({}, 0); + out("Removing the test prop."); + return; + } + const std::string blueprint = name == "drone" ? std::string(multiplayer::drone_model_blueprint) + : name == "cone" || name == "start" ? std::string(multiplayer::prop_test_default) : name; + multiplayer::request_prop_test(blueprint, mode); + out(std::format("Spawning {} with mode {}. Run dronemodeltest again in a moment for what happened.", blueprint, mode)); + }; + registry.add(std::move(model_test)); auto radio_check = action("droneradiocheck", "Show what your controller is sending right now, and how the drone reads it", Group::movement); radio_check.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; }; radio_check.run = [](const Model &, const Values &, const Output &out) { diff --git a/Extension/UI/Overlay/drone_page.cpp b/Extension/UI/Overlay/drone_page.cpp index 8d9bde1..d882907 100644 --- a/Extension/UI/Overlay/drone_page.cpp +++ b/Extension/UI/Overlay/drone_page.cpp @@ -522,6 +522,7 @@ void lobby_tab(SkateMenu& menu, const CallbacksV3& callbacks) { toggle(menu, callbacks, Knob::kBeacon, "Let other players see my drone"); toggle(menu, callbacks, Knob::kShowBeacons, "Show other players' drones"); note("Other players only see drones if they are on a version of ReSkate with the drone."); + note("Drone model: FPV Drone by PinaPipe (CC BY 4.0), reduced and rescaled for the game."); end_card(); } } // namespace diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 1a59e07..d52d7d2 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -23,6 +23,7 @@ #include "Extension/UI/NativeMenu/native_menu.h" #include "Extension/Multiplayer/Hud/native_party.h" #include "Extension/Multiplayer/Session/session.h" +#include "Extension/Multiplayer/Remote/native_prop.h" #include "Extension/Boot/discord_presence.h" #include "Engine/Game/World/world_names.h" #include "Extension/Multiplayer/Hud/custom_nametags.h" @@ -1284,6 +1285,12 @@ void tick(std::uintptr_t client, std::uintptr_t update) { { DINGO_PROFILE_ZONE("tick/multiplayer"); dingosdk::multiplayer::tick(r.base,client,multiplayer_ready,r.multiplayer_map,load_multiplayer_map); + if (multiplayer_ready) { + dingosdk::multiplayer::tick_prop_test(r.base, client); + // Other players' drones as models in the world (DRONE page, LOBBY tab: show other players' drones). + dingosdk::multiplayer::tick_drone_models(r.base, client, + dingosdk::drone::knobs().value[dingosdk::drone::kShowBeacons].load(std::memory_order_relaxed) >= 0.5f); + } if (auto notice = dingosdk::multiplayer::take_leave_notice(); !notice.empty()) dingosdk::overlay::notify(dingosdk::overlay::NoticeLevel::warning, "Map not installed", std::move(notice)); // Kicked, banned or otherwise put out by the host or server: said in the middle of diff --git a/assets/builtin/fpv_drone.zip b/assets/builtin/fpv_drone.zip new file mode 100644 index 0000000..0086a6e Binary files /dev/null and b/assets/builtin/fpv_drone.zip differ diff --git a/assets/models/fpv/CREDITS.md b/assets/models/fpv/CREDITS.md new file mode 100644 index 0000000..37748bf --- /dev/null +++ b/assets/models/fpv/CREDITS.md @@ -0,0 +1,17 @@ +# FPV drone models + +Both are licensed CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/), which allows them to +be shipped, changed and shared as long as the author is credited and changes are noted. + +| File | Title | Author | Source | +| --- | --- | --- | --- | +| `fpv_drone.glb` | FPV Drone | PinaPipe (https://sketchfab.com/PinaPipe) | https://sketchfab.com/3d-models/fpv-drone-92152c31ec9a4a87b2a0478bd114d293 | +| `dji_avata2.glb` | DJI Avata2 | raphael.harris.gaffga (https://sketchfab.com/raphael.harris.gaffga) | https://sketchfab.com/3d-models/dji-avata2-e27ed758e2174a89a48368e84027f8d9 | + +`drone.glb` is the game model, made from `fpv_drone.glb` by `tools/models/prepare_drone.py`: +reduced from 27,946 to about 8,000 triangles, textures shrunk to 512 pixels, scaled to 0.29 m +across, centred and turned to face forward: + + blender --background --python tools/models/prepare_drone.py -- assets/models/fpv/fpv_drone.glb assets/models/fpv/drone.glb --triangles 8000 --span 0.33 --forward 0.305,-0.952 --texture-size 512 + +In game the credit reads: "Drone model: FPV Drone by PinaPipe (CC BY 4.0)". diff --git a/assets/models/fpv/drone.glb b/assets/models/fpv/drone.glb new file mode 100644 index 0000000..e6733a2 Binary files /dev/null and b/assets/models/fpv/drone.glb differ diff --git a/cmake/Core.cmake b/cmake/Core.cmake index 5b1df29..b7e6850 100644 --- a/cmake/Core.cmake +++ b/cmake/Core.cmake @@ -105,8 +105,9 @@ if(WIN32) Engine/Vfs/mod_merge_load_screens.cpp Engine/Vfs/mod_music.cpp Engine/Vfs/mod_scoring.cpp - Engine/Vfs/mod_store_copies.cpp) - target_link_libraries(dingosdk_mods PUBLIC dingosdk_json dingosdk_mod_list PRIVATE dingosdk_game_archives dingosdk_native_db dingosdk_frostbite dingosdk_content_cache shell32 bcrypt) + Engine/Vfs/mod_store_copies.cpp + Engine/Vfs/builtin_packs.cpp) + target_link_libraries(dingosdk_mods PUBLIC dingosdk_json dingosdk_mod_list PRIVATE dingosdk_game_archives dingosdk_native_db dingosdk_frostbite dingosdk_content_cache dingosdk_miniz shell32 bcrypt) add_library(dingosdk_custom_scripts STATIC Engine/Scripting/custom_scripts.cpp) target_link_libraries(dingosdk_custom_scripts PUBLIC dingosdk_json PRIVATE dingosdk_initfs) diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 0417485..10fbc2f 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -32,6 +32,7 @@ add_library(dingosdk_runtime SHARED Extension/UI/NativeMenu/native_menu_data.cpp Extension/Multiplayer/Remote/native_skater.cpp Extension/Multiplayer/Remote/native_skater_spawn.cpp + Extension/Multiplayer/Remote/native_prop.cpp Extension/Multiplayer/Remote/puppet_cost.cpp Extension/Multiplayer/Remote/native_cosmetics.cpp Extension/Multiplayer/Remote/native_audio.cpp @@ -315,6 +316,17 @@ if(EXISTS "${dingosdk_emotes}/emotes.json" AND EXISTS "${dingosdk_emotes}/emotes "EMOTES_PNG RCDATA \"${dingosdk_emotes_native}\\\\emotes.png\"\n") target_sources(dingosdk_runtime PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated/emotes.rc") endif() +# Game content ReSkate carries itself (Engine/Vfs/builtin_packs.h): the FPV drone's model, built +# by tools/models/build_drone_pack.py. Without the zip, other players' drones use a stand-in prop. +set(dingosdk_drone_pack "${PROJECT_SOURCE_DIR}/assets/builtin/fpv_drone.zip") +if(EXISTS "${dingosdk_drone_pack}") + file(TO_NATIVE_PATH "${dingosdk_drone_pack}" dingosdk_drone_pack_native) + string(REPLACE "\\" "\\\\" dingosdk_drone_pack_native "${dingosdk_drone_pack_native}") + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/generated/builtin_packs.rc" + "BUILTIN_PACK_FPV_DRONE RCDATA \"${dingosdk_drone_pack_native}\"\n") + target_sources(dingosdk_runtime PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated/builtin_packs.rc") + set_property(SOURCE "${CMAKE_CURRENT_BINARY_DIR}/generated/builtin_packs.rc" APPEND PROPERTY OBJECT_DEPENDS "${dingosdk_drone_pack}") +endif() # The music sources' setup page (Extension/Music/spotify_dashboard.h). file(TO_NATIVE_PATH "${PROJECT_SOURCE_DIR}/Extension/Music/setup_page.html" dingosdk_music_page_native) string(REPLACE "\\" "\\\\" dingosdk_music_page_native "${dingosdk_music_page_native}")