diff --git a/.gitignore b/.gitignore index 1f79901d..0da2b9ea 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,10 @@ analysis/raw/**/*.res # 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 -/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown +# SmokeyDawg58's ReSkate_Throwdown mod: its assets stay out (his to publish); its plugin is kept. +/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/* +!/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/ +!/contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/reskate-plugin.json /contrib/plugins/*.fbmod # vtf's linux development configuration diff --git a/CMakeLists.txt b/CMakeLists.txt index 8033bb8c..a22900a6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -254,7 +254,7 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32) target_link_libraries(dingosdk_multiplayer_tests PRIVATE dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_word_filter dingosdk_json) add_test(NAME multiplayer_protocol COMMAND dingosdk_multiplayer_tests) add_executable(dingosdk_throwdown_relay_tests Extension/Throwdowns/Test/throwdown_relay_tests.cpp - Extension/Throwdowns/throwdown_relay.cpp Extension/Throwdowns/throwdown_wire.cpp) + Extension/Throwdowns/throwdown_relay.cpp Extension/Throwdowns/throwdown_wire.cpp Extension/Throwdowns/custom_modes.cpp) target_include_directories(dingosdk_throwdown_relay_tests PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_throwdown_relay_tests PRIVATE dingosdk_logging) add_test(NAME throwdown_relay COMMAND dingosdk_throwdown_relay_tests) diff --git a/Engine/Game/Build/20260929/guid_table_lookup.h b/Engine/Game/Build/20260929/guid_table_lookup.h new file mode 100644 index 00000000..8d8bbc1b --- /dev/null +++ b/Engine/Game/Build/20260929/guid_table_lookup.h @@ -0,0 +1,20 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" +#include +// find(table, key, a, b): an open-addressed table of u16 record indices (+0x958, sized from the +// record count at +0x7c, at least 16 and 1.5 times it, rounded up to a power of two), probed from +// the key's first dword by (key & 0x3e) | 1. Each index is turned into its record's 16-byte key +// by a virtual call (+0xc0) on the source at +0x18. The probe stops only on a matching key: a key +// that is not there loops forever, the empty slots (0xffff) included. That froze the game joining +// custom-map servers with the Throwdown asset pack installed (key 53c67866-6592-9274-105c- +// eb835e72f8c9, in two hang dumps, 2026-10-17). On a match it goes on to record(table, index, a, b). +namespace dingosdk::game::build::v20260929::guid_table_lookup { +inline constexpr Fingerprint find{0x41d3a50, { + 0x40,0x53,0x55,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x83,0xec, + 0x38,0x44,0x8b,0x59,0x7c,0xb8,0x10,0x00,0x00,0x00,0x41,0xd1,0xeb,0x4d,0x8b,0xe8}}; +inline constexpr Fingerprint record_contract{0x41c7df0, { + 0x48,0x89,0x5c,0x24,0x20,0x4c,0x89,0x44,0x24,0x18,0x66,0x89,0x54,0x24,0x10,0x55,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x30,0x48,0x8b}}; +inline constexpr std::uintptr_t record = record_contract.rva; +inline constexpr std::uintptr_t count = 0x7c, slots = 0x958, source = 0x18, key_of = 0xc0; +inline constexpr std::uint16_t empty = 0xffff; +} diff --git a/Engine/Game/Build/20260929/native_throwdowns.h b/Engine/Game/Build/20260929/native_throwdowns.h index 5151b2f1..84328d07 100644 --- a/Engine/Game/Build/20260929/native_throwdowns.h +++ b/Engine/Game/Build/20260929/native_throwdowns.h @@ -4,6 +4,11 @@ namespace dingosdk::game::build::v20260929::native_throwdowns { // Hooked functions. +// GetPlayerIsInActivity (6dae8d15). The typed fast thunk calls this same +// implementation, including calls inlined into the activity pause menu. +inline constexpr std::uintptr_t player_in_activity = 0x834520; +inline constexpr std::array player_in_activity_prefix{ + 0x40,0x55,0x48,0x83,0xec,0x20,0x8b,0xe9,0xba,0x89,0x97,0x0f,0xbf,0x48,0x8d,0x4c,0x24,0x38,0xe8}; // Reports whether a throwdown mode exists in the backend catalogue. inline constexpr std::uintptr_t mode_available = 0x807130; inline constexpr std::array mode_available_prefix{ diff --git a/Engine/Game/Build/20260929/no_bail.h b/Engine/Game/Build/20260929/no_bail.h index 1667c049..c8625bd8 100644 --- a/Engine/Game/Build/20260929/no_bail.h +++ b/Engine/Game/Build/20260929/no_bail.h @@ -47,7 +47,7 @@ inline constexpr Fingerprint bail_publish_contract{0x47da450, { // Native consumers: skeleton caller's request test and ABI, animation's // +87 explicit request export, and +8d independent request export. No Bail // leaves the separate native recover/runout output at +9e alone. -inline constexpr std::array bail_consumer_contracts{{ +inline constexpr std::array bail_consumer_contracts{{ {0x47da40b, { 0x48,0x8b,0x8b,0xc0,0x03,0x00,0x00,0xf7,0x81,0xd4,0x13,0x00,0x00,0x00,0x00,0x00, 0x08,0x74,0x05,0x41,0xb0,0x01,0xeb,0x03,0x45,0x32,0xc0,0x48,0x8b,0x83,0xc0,0x03}}, @@ -60,6 +60,11 @@ inline constexpr std::array bail_consumer_contracts{{ {0x47da965, { 0x48,0x8b,0x87,0xc0,0x03,0x00,0x00,0xf7,0x80,0xd4,0x13,0x00,0x00,0x00,0x00,0x00, 0x08,0x74,0x04,0xb0,0x01,0xeb,0x02,0x32,0xc0,0x88,0x83,0x8d,0x00,0x00,0x00,0x48}}, + // The independent failed-landing/runout predicate is published to the + // trick state's animation output (+38) at +9e, including non-ragdoll saves. + {0x47da984, { + 0x48,0x8b,0x87,0xb8,0x03,0x00,0x00,0x48,0x8b,0x48,0x38,0x40,0x88,0xb1,0x9e,0x00, + 0x00,0x00,0x48,0x8b,0x87,0xb8,0x03,0x00,0x00,0x48,0x8b,0x97,0xc0,0x03,0x00,0x00}}, }}; // The sensitive body-contact report bypasses the cause collector. Native // collision processing sets +0x10fe for configured body bones hitting a solid @@ -120,4 +125,6 @@ inline constexpr std::uintptr_t bail_core_vtable = 0x6176d30; // Native recovery/stumble predicate. Its result is also exported to animation // at +0x9e, so No Bail never forces it. Reference only. inline constexpr std::uintptr_t recovery_predicate = 0x47d1100; +inline constexpr std::uintptr_t recovery_animation_output = 0x38; // core+3b8 -> trick state +inline constexpr std::uintptr_t recovery_animation_flag = 0x9e; // animation output, byte } diff --git a/Engine/Game/Build/20260929/skater_entities.h b/Engine/Game/Build/20260929/skater_entities.h index 630a2ad1..1843ed74 100644 --- a/Engine/Game/Build/20260929/skater_entities.h +++ b/Engine/Game/Build/20260929/skater_entities.h @@ -7,6 +7,9 @@ namespace dingosdk::game::build::v20260929::skater_entities { // Blueprint asset TypeInfo. Named character blueprints derive from it. inline constexpr std::uintptr_t blueprint_type = 0x7586ce8; +// ObjectBlueprint ClassInfo: a world object's blueprint (props, checkpoints), a different branch +// of the type hierarchy from the skater Blueprint above. +inline constexpr std::uintptr_t object_blueprint_type = 0x75043a8; // Secondary vtables of the skater animation component (engine::skater_component_vtable) at +0x38 and +0x40. inline constexpr std::array skater_component_base_vtables{0x61705e8, 0x6170650}; // ClientSkaterSource::applyAppearance. It enables the customization component and requests @@ -42,6 +45,12 @@ inline constexpr std::array release_reference_prefix{ inline constexpr std::uintptr_t place_entity = 0x53ed70; inline constexpr std::array place_entity_prefix{ 0x41,0xb1,0x01,0x45,0x0f,0xb6,0xc1,0xe9,0x44,0xfe,0xa3,0x02}; +// Generic SpatialEntity setter used by place_entity. Copies both matrix halves +// into the owned transform collection, then notifies components via +0x108. +inline constexpr std::uintptr_t move_spatial_entity = 0x2f7ebc0; +inline constexpr std::array move_spatial_entity_prefix{ + 0x48,0x83,0xec,0x48,0x4c,0x8b,0x59,0x70,0xc5,0xfc,0x10,0x02, + 0xc5,0xfc,0x10,0x4a,0x20,0x41,0x0f,0xb6,0x53,0x0a}; inline constexpr std::uintptr_t initialize_placement = 0x2f7d670; inline constexpr std::array initialize_placement_prefix{ 0x4c,0x8b,0xdc,0x53,0x55,0x56,0x57,0x48,0x83,0xec,0x68,0xf6,0x41,0x28,0x08}; diff --git a/Engine/Game/Input/owned_menu_input.h b/Engine/Game/Input/owned_menu_input.h new file mode 100644 index 00000000..25f5e40f --- /dev/null +++ b/Engine/Game/Input/owned_menu_input.h @@ -0,0 +1,15 @@ +#pragma once +#include +namespace dingosdk::game { +// Keep an opening press hidden until its physical release. Otherwise handing +// input to the newly opened native page replays that same Start and closes it. +constexpr bool owned_menu_release_latch(bool latched,bool capturing,bool down) noexcept { + return down && (capturing || latched); +} +constexpr std::uint16_t owned_menu_buttons(std::uint16_t buttons,bool suppress) noexcept { + return suppress?static_cast(buttons & ~0x0010u):buttons; +} +constexpr bool owned_menu_countdown_capture(bool countdown,bool native_panel) noexcept { + return countdown && !native_panel; +} +} diff --git a/Engine/Game/Multiplayer/session_competition.h b/Engine/Game/Multiplayer/session_competition.h new file mode 100644 index 00000000..7e848587 --- /dev/null +++ b/Engine/Game/Multiplayer/session_competition.h @@ -0,0 +1,14 @@ +#pragma once +#include + +namespace dingosdk { +namespace session_competition_detail { inline std::atomic stock{}; } +// Local participation, including hosts/admins, overrides session permissions. +// Their settings are retained for after Leave. +inline bool competition_stock_physics() noexcept { + return session_competition_detail::stock.load(std::memory_order_acquire); +} +inline void set_competition_stock_physics(bool locked) noexcept { + session_competition_detail::stock.store(locked,std::memory_order_release); +} +} diff --git a/Engine/Game/Multiplayer/session_physics.h b/Engine/Game/Multiplayer/session_physics.h index 18eb4cde..2fad7695 100644 --- a/Engine/Game/Multiplayer/session_physics.h +++ b/Engine/Game/Multiplayer/session_physics.h @@ -9,6 +9,7 @@ #include #include #include +#include "session_competition.h" namespace dingosdk { // Physics a player changes outside Gameplay/SkatePhysicsTuning: the trainer's tuning-class @@ -44,7 +45,7 @@ inline bool take(Box &box, std::uint64_t &seen, std::vector &bytes // physics (the roster's enforce_tuning): their own physics edits stand down, and what the host // shares is theirs. False outside a session and for a host. Set by the session every tick. inline bool session_tuning_enforced() noexcept { - return session_physics_detail::enforced.load(std::memory_order_acquire); + return competition_stock_physics() || session_physics_detail::enforced.load(std::memory_order_acquire); } inline void set_session_tuning_enforced(bool enforced) noexcept { session_physics_detail::enforced.store(enforced, std::memory_order_release); diff --git a/Engine/Game/Multiplayer/session_tools.h b/Engine/Game/Multiplayer/session_tools.h index 4048e405..39cb4377 100644 --- a/Engine/Game/Multiplayer/session_tools.h +++ b/Engine/Game/Multiplayer/session_tools.h @@ -1,5 +1,6 @@ #pragma once #include +#include "session_competition.h" namespace dingosdk { // Noclip (and teleporting), No Bail, the forward / up boosts and ollies as the session's host lets @@ -9,9 +10,10 @@ namespace session_tools_detail { inline std::atomic noclip{true}, no_bail{true}, boosts{true}, ollies{true}; inline std::atomic session{}; } -inline bool session_noclip_allowed() noexcept { return session_tools_detail::noclip.load(std::memory_order_acquire); } -inline bool session_no_bail_allowed() noexcept { return session_tools_detail::no_bail.load(std::memory_order_acquire); } -inline bool session_boosts_allowed() noexcept { return session_tools_detail::boosts.load(std::memory_order_acquire); } +inline bool session_noclip_allowed() noexcept { return !competition_stock_physics() && session_tools_detail::noclip.load(std::memory_order_acquire); } +inline bool session_no_bail_allowed() noexcept { return !competition_stock_physics() && session_tools_detail::no_bail.load(std::memory_order_acquire); } +inline bool session_boosts_allowed() noexcept { return !competition_stock_physics() && session_tools_detail::boosts.load(std::memory_order_acquire); } +// A lock, not a tool: a competition's stock physics leaves it as the server set it. inline bool session_ollies_allowed() noexcept { return session_tools_detail::ollies.load(std::memory_order_acquire); } // Hosting or joined to a multiplayer session (set by the session every tick). Any thread. inline bool multiplayer_session_active() noexcept { return session_tools_detail::session.load(std::memory_order_acquire); } diff --git a/Engine/Game/World/load_completion.h b/Engine/Game/World/load_completion.h new file mode 100644 index 00000000..11294820 --- /dev/null +++ b/Engine/Game/World/load_completion.h @@ -0,0 +1,13 @@ +#pragma once +namespace dingosdk { +// Some stock worlds do not appear in the supplemental sublevel registries. +// An exact destination plus an initialized, controllable local world is an +// independent completion signal. Merely reaching state 13 (the splash also +// uses it), or retaining an actor from a different map, is insufficient. +inline constexpr bool playable_load_complete(bool transitioned, unsigned state, + bool exact_destination, bool context_ready, bool players_available, + unsigned local_players, unsigned controllables) noexcept { + return transitioned && (state == 13 || state == 21) && exact_destination && + context_ready && players_available && local_players == 1 && controllables == 1; +} +} diff --git a/Engine/Vfs/Test/chunk_metadata_merge_tests.cpp b/Engine/Vfs/Test/chunk_metadata_merge_tests.cpp index 67aab2d4..9bed834a 100644 --- a/Engine/Vfs/Test/chunk_metadata_merge_tests.cpp +++ b/Engine/Vfs/Test/chunk_metadata_merge_tests.cpp @@ -143,9 +143,14 @@ int main() { // One mod's copy alone is still written in the game's form: its additions go where their guids put them. const std::vector oneMod{chunk(0x50, 1, 0, 0), chunk(0x30, 1, 1, 1), chunk(0x70, 1, 2, 2), chunk(0x10, 1, 3, 3), chunk(0x60, 1, 4, 4), chunk(0x20, 1, 5), chunk(0x80, 1, 6)}; - check(read_list(merge_chunk_metadata(lists, oneMod, gameList, shipped)) == + std::vector rows; + check(read_list(merge_chunk_metadata(lists, oneMod, gameList, shipped, &rows)) == std::vector{mesh, firstTexture, textureA, other, textureB, mesh, firstMesh}, "one mod's additions are put in guid order with the game's"); + // Where each chunk's record went, so a copy whose list this is reads its chunks' records there. + check(rows == std::vector{3, 2, 5, 0, 4, 1, 6}, "each chunk's record is said to be at its place by guid"); + (void)merge_chunk_metadata(lists, untouched, gameList, shipped, &rows); + check(rows == std::vector{2, 1, 4, 0, 3}, "and so is each in the game's own list, which comes back as it is"); // A newer tool writes the replaced texture's own hash with its first mip; the first mip is what counts. auto newer = game; newer[4] = Record{textureB.owner, 0}; @@ -172,6 +177,8 @@ int main() { const std::vector modsOnly{list_of({firstTexture, firstMesh}), list_of({firstTexture, firstMesh, secondTexture})}; const std::vector theirs{chunk(0x90, 1, 0), chunk(0x20, 1, 1), chunk(0x40, 1, 2)}; check(merge_chunk_metadata(modsOnly, theirs) == modsOnly[1], "a bundle that is one mod's copy alone keeps that copy's list"); + (void)merge_chunk_metadata(modsOnly, theirs, ChunkRecord::none, {}, &rows); + check(rows == std::vector{0, 1, 2}, "and has each record where its chunk is listed"); const std::vector both{chunk(0x90, 0, 0), chunk(0x20, 0, 1), chunk(0x40, 1, 2)}; check(read_list(merge_chunk_metadata(modsOnly, both)) == std::vector{firstMesh, secondTexture, firstTexture}, "and otherwise each chunk's record from the copy it came in, by guid like any other"); diff --git a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp index f52c1ec3..310e531e 100644 --- a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp +++ b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp @@ -26,8 +26,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -164,7 +166,61 @@ fb::Sha1 sha(const std::string& name, unsigned version) { struct Ebx { std::string name; unsigned version; Bytes payload; }; struct Resource { std::string name; Bytes payload; }; -fb::BinaryBundle manifest_of(const std::vector& assets, const std::vector& resources) { +// ---- Chunk metadata: one record a chunk, whose it is and a texture's first mip --------------------- +struct Record { + std::uint64_t owner{}; + std::uint32_t firstMip{}; + bool operator==(const Record&) const = default; +}; +struct Chunk { fb::Guid guid; Bytes payload; Record record; }; +db::Node number(unsigned type, const char* name, std::uint64_t value, std::size_t size) { + db::Node node; + node.type = type; + node.named = true; + node.name = name; + for (std::size_t byte = 0; byte < size; ++byte) node.owned.push_back(static_cast(value >> (8 * byte))); + return node; +} +// The list as the game writes it: the chunks' records in the order of their guids. +Bytes chunk_list(std::vector chunks) { + std::ranges::sort(chunks, {}, &Chunk::guid); + db::Node list; + list.type = 1; + list.named = true; + list.name = "chunkMeta"; + list.terminated = true; + for (const auto& chunk : chunks) { + db::Node meta; + meta.type = 2; + meta.named = true; + meta.name = "meta"; + meta.terminated = true; + meta.children.push_back(number(8, "firstMip", chunk.record.firstMip, 4)); + db::Node row; + row.type = 2; + row.terminated = true; + row.children.push_back(number(9, "h64", chunk.record.owner, 8)); + row.children.push_back(std::move(meta)); + list.children.push_back(std::move(row)); + } + const auto written = db::write(list); + const auto* bytes = reinterpret_cast(written.data()); + return {bytes, bytes + written.size()}; +} +std::vector records_in(std::span list) { + const std::span bytes(reinterpret_cast(list.data()), list.size()); + std::vector records; + for (const auto& row : db::read(bytes, "chunk metadata", nullptr, {.unique_fields = false, .max_entries = bytes.size()}).children) { + Record record; + if (const auto* owner = row.field("h64"); owner && owner->payload().size() == 8) std::memcpy(&record.owner, owner->payload().data(), 8); + if (const auto* meta = row.field("meta")) + if (const auto* mip = meta->field("firstMip"); mip && mip->payload().size() == 4) std::memcpy(&record.firstMip, mip->payload().data(), 4); + records.push_back(record); + } + return records; +} + +fb::BinaryBundle manifest_of(const std::vector& assets, const std::vector& resources, const std::vector& chunks = {}) { fb::BinaryBundle manifest; for (const auto& asset : assets) { fb::BundleAsset entry; @@ -185,6 +241,16 @@ fb::BinaryBundle manifest_of(const std::vector& assets, const std::vector(chunk.payload.size()); + manifest.chunks.push_back(std::move(entry)); + } + if (!chunks.empty()) manifest.chunkMetadata = chunk_list(chunks); return manifest; } @@ -229,10 +295,28 @@ struct Fixture { fs::remove_all(root, ignored); } + // The game's copy of the bundle in another of its TOCs (a level's), in the game's archive. + void game(const std::string& toc, const std::vector& assets, const std::vector& chunks) { + const auto path = catalog.data_root / "Data" / "Win32" / "pkg" / "cas_01.cas"; + auto archive = read(path); + std::vector files; + const auto store = [&](const Bytes& bytes) { + files.push_back({{false, package, 1}, static_cast(archive.size()), static_cast(bytes.size())}); + archive.insert(archive.end(), bytes.begin(), bytes.end()); + }; + store(fb::write_binary_bundle(manifest_of(assets, {}, chunks))); + for (const auto& asset : assets) store(encoded(asset.payload)); + for (const auto& chunk : chunks) store(encoded(chunk.payload)); + write(path, archive); + const std::vector bundles{{bundle_name, fb::write_bundle_region(files), 1}}; + write(catalog.data_root / "Data" / "Win32" / fs::path(toc), fb::write_patch_toc(bundles)); + } + // A mod with its own copy of a bundle: the manifest first (raw), then every payload. void add(const std::string& name, bool levels, const std::string& toc, const std::vector& superbundles, - const std::vector& assets, const std::vector& resources = {}, const char* bundle = bundle_name) { - const auto listing = fb::write_binary_bundle(manifest_of(assets, resources)); + const std::vector& assets, const std::vector& resources = {}, const char* bundle = bundle_name, + const std::vector& chunks = {}) { + const auto listing = fb::write_binary_bundle(manifest_of(assets, resources, chunks)); Bytes archive(listing); std::vector files{{{true, package, 1}, 0, static_cast(listing.size())}}; const auto store = [&](const Bytes& payload) { @@ -242,6 +326,7 @@ struct Fixture { }; for (const auto& asset : assets) store(asset.payload); for (const auto& resource : resources) store(resource.payload); + for (const auto& chunk : chunks) store(chunk.payload); const std::vector bundles{{bundle, fb::write_bundle_region(files), 1}}; const auto directory = catalog.root / name; write(directory / "layout.toc", layout_toc(superbundles)); @@ -327,6 +412,94 @@ void carried_into_the_maps_copy(bool map_first, int baseline) { std::to_string(files) + " files, " + std::to_string(baseline) + " without the music mod)"); } +// A mod's resident list (reskate-plugin.json, read from its folder as the launcher reads it) keeps +// an asset nothing names, one its plugin picks at run time, with its additions: the map's copy of the +// bundle gets it too. Without the list it stays out (carried_into_the_maps_copy). +void resident_assets_go_with_the_additions(int baseline) { + Fixture fixture("resident"); + fixture.add("map", true, map_toc, {map_superbundle}, game_copy()); + fixture.add("music", false, shared_toc, {}, music_assets(), music_resources()); + const std::string manifest = std::string(R"({"api": 1, "entry": "plugin/main.lua", "resident": [{"bundle": ")") + bundle_name + + R"(", "roots": ["Test/Unrelated", "ui/menus/"]}]})"; + const auto bytes = std::as_bytes(std::span(manifest)); + write(fixture.catalog.root / "music" / mods::plugin_manifest_file, bytes); + std::vector notes; + const auto described = mods::describe_mod(fixture.catalog.root / "music", "music", notes); + expect(notes.empty() && described.resident.size() == 1 && mods::resident_root(described, "WIN32/test/shared", "test/unrelated") && + mods::resident_root(described, bundle_name, "ui/menus/a/b") && !mods::resident_root(described, bundle_name, "test/other") && + !mods::resident_root(described, second_bundle, "test/unrelated"), + "resident: the list is read, by bundle, a root ending in / a prefix"); + const std::string broken = R"({"api": 1, "entry": "plugin/main.lua", "resident": [{"roots": []}]})"; + write(fixture.catalog.root / "music" / mods::plugin_manifest_file, std::as_bytes(std::span(broken))); + notes.clear(); + expect(mods::describe_mod(fixture.catalog.root / "music", "music", notes).resident.empty() && notes.size() == 1, + "resident: a list without its bundle is a note, and keeps nothing"); + write(fixture.catalog.root / "music" / mods::plugin_manifest_file, bytes); + fixture.catalog.mods.back().resident = described.resident; + const auto report = mods::merge_mods(fixture.catalog); + expect(report.issue.empty() && report.built, "resident: the merge builds the patch\n" + describe(report)); + const auto files = fixture.merged_files(map_toc); + expect(files == baseline + 4, "resident: the map's copy gained the three named files and the resident one (" + std::to_string(files) + + " files, " + std::to_string(baseline) + " without the music mod)\n" + describe(report)); +} + +// The files under a folder, by path inside it, with their bytes. +std::map snapshot(const fs::path& folder) { + std::map files; + for (const auto& entry : fs::recursive_directory_iterator(folder)) + if (entry.is_regular_file()) files[entry.path().lexically_relative(folder).generic_string()] = read(entry.path()); + return files; +} + +// The music mod with a resident list, its Win32 folder a junction to a store elsewhere (a test +// root linking a mod's folders to an install's did this). Its files are where the folder shows +// them, Win32/...: the paths fs::relative gives through the link (../../../store/...) made the +// merge take the mod's own TOC for the game's and write the merged TOCs and archive links over +// the mod's real files, so the next merge read its bundles from the wrong archives and left the +// whole mod out ("Raw CAS block size does not match its header", 2026-10-20). +void linked_win32_stays_in_the_patch(int baseline) { + Fixture fixture("linked"); + fixture.add("map", true, map_toc, {map_superbundle}, game_copy()); + fixture.add("music", false, shared_toc, {}, music_assets(), music_resources()); + const auto directory = fixture.catalog.root / "music"; + const auto store = fixture.root / "store" / "music-Win32"; + fs::create_directories(store.parent_path()); + fs::rename(directory / "Win32", store); + const auto link = directory / "Win32"; + const bool linked = _wsystem((L"mklink /J \"" + link.wstring() + L"\" \"" + store.wstring() + L"\" >nul").c_str()) == 0; + expect(linked, "linked: a junction could be made for the check"); + if (!linked) return; + const std::string manifest = std::string(R"({"api": 1, "entry": "plugin/main.lua", "resident": [{"bundle": ")") + bundle_name + + R"(", "roots": ["test/unrelated"]}]})"; + write(directory / mods::plugin_manifest_file, std::as_bytes(std::span(manifest))); + std::vector notes; + fixture.catalog.mods.back().resident = mods::describe_mod(directory, "music", notes).resident; + const auto files = mods::detail::scan(directory); + expect(files.tocs == std::vector{"Win32/test_shared.toc"} && + files.archives == std::vector{"Win32/pkg/cas_01.cas"}, + "linked: the mod's files are found as Win32/..., not through the link (" + + (files.tocs.empty() ? std::string("none") : files.tocs.front()) + ")"); + const auto before = snapshot(store); + for (const auto pass : {"first", "second"}) { + const auto report = mods::merge_mods(fixture.catalog); + expect(report.issue.empty() && report.built && report.problems.empty(), + std::string("linked: the ") + pass + " merge builds the patch with no problem\n" + describe(report)); + expect(fixture.merged_files(map_toc) == baseline + 4, + std::string("linked: after the ") + pass + " merge the map's copy has the resident asset too\n" + describe(report)); + expect(snapshot(store) == before, std::string("linked: the ") + pass + " merge leaves the mod's own files as they were"); + } + RemoveDirectoryW(link.c_str()); + // Whatever names a file, nothing the merge writes leaves its output folder. + const auto refused = [](const fs::path& relative) { + try { mods::detail::inside("C:/patch", relative); } catch (const std::exception&) { return true; } + return false; + }; + expect(mods::detail::inside("C:/patch", "Win32/pkg/./cas_02.cas") == fs::path("C:/patch/Win32/pkg/cas_02.cas") && + refused("../../../reskate/mods/x/win32/items.toc") && refused("Win32/../../x.toc") && refused("C:/x.toc") && + refused("/x.toc") && refused("D:x.toc") && refused(""), + "linked: a path outside the output folder is refused"); +} + // Another asset mod ships the game's copy of the bundle in the TOC the music mod ships, the way // cosmetic mods share one. That copy merges with the music mod's bundle, which already has the // song, so nothing is carried into it (no "added by other mods" note), and the merged bundle is @@ -635,6 +808,46 @@ void a_mod_archive_numbered_like_a_placeholder() { }); } +// The game keeps the bundle in a level's TOC too, its chunks listed in an order of their own and +// their records by guid. Two map mods ship that level's copy (as SmokeyDawg58's mod and the +// built-in throwdown_menu pack both ship the game's level root bundles), made as Studio makes +// one: the chunks listed by guid, their records in the same order. The music mod's song names a +// texture chunk of its own, which is carried into the maps' copies after their chunks, and the +// list written for those copies has its record at its place by guid. Read back by where each +// chunk is listed, every chunk after it by guid had its neighbour's record once the level was +// merged with the game's copy: the carried texture another chunk's, and the game's own textures +// the first mip of the one before (23 textures in each level root bundle, 2026-10-20). +void carried_chunk_keeps_the_records_in_step() { + Fixture fixture("carried-chunk-records"); + const Chunk low{guid(0x10), {std::byte{1}}, {0x1010, 1}}, mid{guid(0x30), {std::byte{3}}, {0x3030, 2}}, + high{guid(0x50), {std::byte{5}}, {0x5050, 3}}; + const Chunk texture{guid(0x20), {std::byte{2}, std::byte{2}}, {0x2020, 0}}; + fixture.game(map_toc, game_copy(), {high, low, mid}); + fixture.add("map", true, map_toc, {map_superbundle}, game_copy(), {}, bundle_name, {low, mid, high}); + fixture.add("map2", true, map_toc, {map_superbundle}, game_copy(), {}, bundle_name, {low, mid, high}); + auto assets = music_assets(); + assets[2].payload = ebx_document(guid(10), {guid(11), texture.guid}); // the song names the texture's chunk + fixture.add("music", false, shared_toc, {}, assets, music_resources(), bundle_name, {texture}); + const auto report = mods::merge_mods(fixture.catalog); + expect(report.issue.empty() && report.built, "chunk records: the merge builds the patch\n" + describe(report)); + if (!report.built) return; + Patch patch(fixture); + std::optional listing; + for (const auto& bundle : fb::read_toc(read(patch.root / "Win32" / fs::path(map_toc))).bundles) + if (bundle.name == bundle_name) listing = mods::detail::list_bundle(patch.store, patch.root, patch.base, bundle, patch.game); + expect(listing.has_value(), "chunk records: the map's copy of the bundle can be read"); + if (!listing) return; + const auto& chunks = listing->manifest.chunks; + expect(std::ranges::any_of(chunks, [&](const fb::BundleAsset& chunk) { return chunk.guid == texture.guid; }), + "chunk records: the texture's chunk is carried into the map's copy\n" + describe(report)); + const std::vector expected{low.record, texture.record, mid.record, high.record}; + const auto records = records_in(listing->manifest.chunkMetadata); + std::string got; + for (const auto& record : records) got += " " + std::to_string(record.owner) + "/" + std::to_string(record.firstMip); + expect(chunks.size() == expected.size() && records == expected, + "chunk records: each chunk has its own record, with its own first mip, at its place by guid (got" + got + ")"); +} + // A merge built again from the same mods writes the same bytes. void two_merges_write_the_same_bytes() { Fixture fixture("same-bytes"); @@ -660,6 +873,8 @@ int main() try { expect(baseline > 0, "map only: the map's copy exists (" + std::to_string(baseline) + ")"); carried_into_the_maps_copy(true, baseline); carried_into_the_maps_copy(false, baseline); + resident_assets_go_with_the_additions(baseline); + linked_win32_stays_in_the_patch(baseline); copy_in_the_adders_toc_is_left_alone(); same_name_from_two_mods(true); same_name_from_two_mods(false); @@ -671,6 +886,7 @@ int main() try { every_placement_lands_in_an_archive(); a_mod_archive_numbered_like_a_placeholder(); two_merges_write_the_same_bytes(); + carried_chunk_keeps_the_records_in_step(); if (failures) { std::cerr << failures << " failure(s)\n"; return 1; diff --git a/Engine/Vfs/Test/mod_variants_tests.cpp b/Engine/Vfs/Test/mod_variants_tests.cpp new file mode 100644 index 00000000..2cae3a90 --- /dev/null +++ b/Engine/Vfs/Test/mod_variants_tests.cpp @@ -0,0 +1,98 @@ +#include "Engine/Vfs/mod_catalog.h" +#include "Engine/Game/Build/supported_build.h" +#include +#include +#include +#include +#include +#include +#include + +// Variants (Engine/Vfs/mod_list.h): a mod that brings several takes on the same game data, one of +// which loads, and what it displaces set aside. +namespace fs = std::filesystem; +using namespace dingosdk; +namespace { +void check(bool ok, const std::string &message) { + if (ok) return; + std::cerr << message << '\n'; + std::exit(1); +} +void write(const fs::path &path, const std::string &text) { + fs::create_directories(path.parent_path()); + std::ofstream(path, std::ios::binary) << text; +} +bool listed(const std::vector &mods, std::string_view name) { + return std::ranges::any_of(mods, [&](const mods::Mod &mod) { return mod.name == name; }); +} +bool aside(const mods::Catalog &catalog, std::string_view name) { + return std::ranges::find(catalog.set_aside, name) != catalog.set_aside.end(); +} +} // namespace + +int main() { + wchar_t temp[MAX_PATH]{}; + GetTempPathW(MAX_PATH, temp); + const auto root = fs::path(temp) / ("reskate-mod-variants-" + std::to_string(GetCurrentProcessId())); + fs::remove_all(root); + // The choices go to %LOCALAPPDATA%\ReSkate\variants.json: here, the test's own folder. + SetEnvironmentVariableW(L"LOCALAPPDATA", (root / "appdata").wstring().c_str()); + const auto mods_root = root / "Mods"; + const auto stamp = "ReSkate Studio native Patch v1\nskate_sha256=" + std::string(supported_build::game_sha256) + "\n"; + // A pack of two variants, "wide" listed first; its own layout brings the bundle they replace. + write(mods_root / "Pack" / "layout.toc", "superbundles deck_bundle"); + write(mods_root / "Pack" / ".reskate-studio-patch", stamp); + write(mods_root / "Pack" / "reskate-plugin.json", + R"({"api":1,"entry":"plugin/main.lua","variants":{"folder":"decks","order":["wide"],"displaces":["DECK_BUNDLE"]}})"); + write(mods_root / "Pack" / "decks" / "standard" / "layout.toc", "a"); + write(mods_root / "Pack" / "decks" / "wide" / "layout.toc", "b"); + write(mods_root / "Pack" / "decks" / "notes" / "readme.txt", "no layout: no variant"); + // Another mod with a deck model of its own, and one that has nothing to do with decks. + write(mods_root / "OtherDeck" / "layout.toc", "superbundles Deck_Bundle"); + write(mods_root / "OtherDeck" / ".reskate-studio-patch", stamp); + write(mods_root / "Plain" / "layout.toc", "x"); + write(mods_root / "Plain" / ".reskate-studio-patch", stamp); + // A malformed declaration is a note, and the mod loads as any other. + write(mods_root / "Odd" / "layout.toc", "x"); + write(mods_root / "Odd" / ".reskate-studio-patch", stamp); + write(mods_root / "Odd" / "reskate-plugin.json", R"({"api":1,"variants":{"folder":"../up"}})"); + + const auto list = mods::scan_mods(root); + const auto pack = std::ranges::find_if(list.entries, [](const mods::ModEntry &e) { return e.mod.name == "Pack"; }); + check(pack != list.entries.end() && pack->mod.variants && pack->mod.variants->displaces.front() == "deck_bundle", + "The pack's variants were not read"); + const auto installed = mods::installed_variants(pack->mod); + check(installed == std::vector{"wide", "standard"}, "The variants are not in the order the pack gives"); + check(mods::variant_folder(pack->mod, "standard") == mods_root / "Pack" / "decks" / "standard", "A variant's folder is not its own"); + + auto catalog = mods::load_catalog(root, {}, false); + check(catalog.issue.empty(), "The catalog was rejected: " + catalog.issue); + check(!catalog.mods.empty() && catalog.mods.front().name == "Pack#wide", "With nothing chosen, the first of the order does not load first"); + check(catalog.variants_in_play.contains("Pack") && catalog.variants_in_play.at("Pack") == "wide", "The variant in play is not the one loaded"); + check(aside(catalog, "Pack#standard") && aside(catalog, "Pack") && aside(catalog, "OtherDeck"), + "The other variant, the pack's own layout or another deck model was not set aside"); + check(!listed(catalog.mods, "Pack") && !listed(catalog.mods, "OtherDeck") && listed(catalog.mods, "Plain") && listed(catalog.mods, "Odd"), + "The wrong mods load"); + check(listed(catalog.inactive, "Pack#standard"), "A set-aside variant's archives are not placed, so it cannot be switched to live"); + check(std::ranges::any_of(catalog.notes, [](const std::string &n) { return n.find("Odd") != std::string::npos && n.find("variants") != std::string::npos; }), + "A malformed declaration made no note"); + + check(mods::save_variant("Pack", "Standard") && mods::chosen_variant("pack") == "standard", "A choice was not kept"); + check(fs::is_regular_file(root / "appdata" / "ReSkate" / "variants.json"), "The choice was not saved"); + catalog = mods::load_catalog(root, {}, false); + check(!catalog.mods.empty() && catalog.mods.front().name == "Pack#standard" && aside(catalog, "Pack#wide"), + "The chosen variant does not load"); + // A choice that is not installed falls back to the order's first. + check(mods::save_variant("Pack", "gone"), "A choice was not kept"); + catalog = mods::load_catalog(root, {}, false); + check(!catalog.mods.empty() && catalog.mods.front().name == "Pack#wide", "A missing choice does not fall back to the order"); + // Disabled, the pack loads none of its variants and sets nothing aside. + write(mods_root / "mods.json", R"({"schema":1,"mods":[{"name":"Pack","enabled":false}]})"); + catalog = mods::load_catalog(root, {}, false); + check(catalog.set_aside.empty() && catalog.variants_in_play.empty() && listed(catalog.mods, "OtherDeck"), + "A disabled pack still decided what loads"); + check(mods::variant_of("Pack#wide") == "wide" && mods::variant_owner("Pack#wide") == "Pack" && mods::variant_owner("Pack").empty(), + "A variant's entry does not name its variant and mod"); + fs::remove_all(root); + std::cout << "Mod variants: order, choice, fallback, set aside, disabled and malformed passed.\n"; +} diff --git a/Engine/Vfs/mod_catalog.cpp b/Engine/Vfs/mod_catalog.cpp index fb7cd54f..16e7174e 100644 --- a/Engine/Vfs/mod_catalog.cpp +++ b/Engine/Vfs/mod_catalog.cpp @@ -87,10 +87,10 @@ std::string sdk_identity() { return error ? std::string{} : std::to_string(size) + "-" + std::to_string(written); } -// A mod brings a deck model of its own when its bundle lists name the deck's bundle. Deck graphics -// and grip tapes are items of their own and do not touch it. -bool changes_the_deck(const fs::path& mod) { - constexpr std::string_view deck_bundle = "deck_gen_popsicle_cas_main_bundlereftable"; +/// Whether a mod's layout (layout.toc and its Win32 superbundle lists) names one of these bundles: it +// brings what a variant displaces (mod_list.h Variants). +bool lists_bundle(const fs::path& mod, const std::vector& bundles) { + if (bundles.empty()) return false; std::error_code error; std::vector lists{mod / L"layout.toc"}; for (fs::directory_iterator at(mod / L"Win32", error), end; !error && at != end; at.increment(error)) @@ -98,185 +98,160 @@ bool changes_the_deck(const fs::path& mod) { for (const auto& list : lists) { std::ifstream file(list, std::ios::binary); if (!file) continue; - const std::string bytes{std::istreambuf_iterator(file), {}}; - if (bytes.find(deck_bundle) != std::string::npos) return true; + const auto bytes = lower(std::string{std::istreambuf_iterator(file), {}}); + for (const auto& bundle : bundles) + if (bytes.find(bundle) != std::string::npos) return true; } return false; } -// The player's choice of deck is kept beside ReSkate's other files for the user, so that it is -// there before the profile is: the mods are put together as the game starts. -fs::path board_break_deck_file() { +// The choices of variant are kept beside ReSkate's other files for the user, so that they are there +// before the profile is: the mods are put together as the game starts. +// {"": ""} +fs::path variants_file() { std::array text{}; const auto size = GetEnvironmentVariableW(L"LOCALAPPDATA", text.data(), static_cast(text.size())); if (!size || size >= text.size()) return {}; - return fs::path(std::wstring(text.data(), size)) / L"ReSkate" / L"BoardBreak.deck"; + return fs::path(std::wstring(text.data(), size)) / L"ReSkate" / L"variants.json"; } -// A deck's name is a folder name's end: letters, digits and underscores. -bool deck_name(std::string_view name) { +// A variant's name is its folder's: letters, digits, '_' and '-', in small letters. +bool variant_name(std::string_view name) { return !name.empty() && name.size() <= maximum_mod_name && - std::all_of(name.begin(), name.end(), [](char c) { return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'; }); + std::all_of(name.begin(), name.end(), [](char c) { return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-'; }); } -// Where a deck comes in the menus: the decks the feature was made with in their own order, then -// the others by name. -bool deck_before(const std::string& a, const std::string& b) { - const auto place = [](const std::string& name) { - return static_cast(std::find(board_break_decks.begin(), board_break_decks.end(), name) - board_break_decks.begin()); - }; - return place(a) != place(b) ? place(a) < place(b) : a < b; -} -// The decks a pack holds beside its own (mod_catalog.h), by name, in the menus' order. -std::vector> decks_inside(const fs::path& mod) { +// The variants a mod holds (mod_list.h), by name, its "order" first and then the others by name. +std::vector> variants_inside(const Mod& mod) { std::vector> found; + if (!mod.variants) return found; std::error_code error; - for (fs::directory_iterator at(mod / L"decks", error), end; !error && at != end; at.increment(error)) { + for (fs::directory_iterator at(mod.directory / mod.variants->folder, error), end; !error && at != end; at.increment(error)) { if (!at->is_directory(error) || !fs::is_regular_file(at->path() / L"layout.toc", error)) continue; auto name = lower(path_utf8(at->path().filename())); - if (deck_name(name)) found.emplace_back(std::move(name), at->path()); + if (variant_name(name)) found.emplace_back(std::move(name), at->path()); } - std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return deck_before(a.first, b.first); }); + const auto& order = mod.variants->order; + const auto place = [&](const std::string& name) { return static_cast(std::find(order.begin(), order.end(), name) - order.begin()); }; + std::sort(found.begin(), found.end(), [&](const auto& a, const auto& b) { + return place(a.first) != place(b.first) ? place(a.first) < place(b.first) : a.first < b.first; + }); return found; } -// Every mod folder a deck can come from: those in the Mods folder (but one a built-in pack -// replaces), then the built-in packs (builtin_packs.h), which the merge also puts after them. -std::vector deck_mod_folders() { - std::vector found; - const auto mods = engine_data_root() / L"Mods"; - std::error_code error; - for (fs::directory_iterator at(mods, error), end; !error && at != end; at.increment(error)) - if (at->is_directory(error) && !kept_by_reskate(mods, path_utf8(at->path().filename()))) found.push_back(at->path()); - error.clear(); - for (fs::directory_iterator at(mods / std::string(builtin_folder), error), end; !error && at != end; at.increment(error)) - if (at->is_directory(error) && at->path().filename().wstring().find(L".unpacking-") == std::wstring::npos) - found.push_back(at->path()); - return found; -} -// Every deck in the Mods folder with the folder of the mod that brings it, in the menus' order, -// whatever mods.json says of that mod: the decks inside a pack, or else the one a folder is named for. -std::vector> installed_decks() { - std::vector> decks; - for (const auto& folder : deck_mod_folders()) { - auto inside = decks_inside(folder); - for (auto& one : inside) decks.emplace_back(std::move(one.first), folder); - if (!inside.empty()) continue; - if (auto deck = board_break_deck_of(path_utf8(folder.filename())); !deck.empty()) decks.emplace_back(std::move(deck), folder); - } - std::stable_sort(decks.begin(), decks.end(), [](const auto& a, const auto& b) { return deck_before(a.first, b.first); }); - return decks; -} -// The choice made in this run, and what the last live apply gave the game (none since launch: the +// The choices made in this run, and what the last live apply gave the game (none since launch: the // launch's catalogue says). -struct BoardBreakApplied { - std::string deck; +using VariantMap = std::map>; +struct VariantsApplied { + VariantMap in_play; std::vector set_aside; }; -std::mutex board_break_mutex; -std::string chosen_deck; -std::optional applied_board_break; +std::mutex variants_mutex; +std::optional chosen_variants; +std::optional applied_variants; +VariantMap read_choices() { + VariantMap out; + const auto path = variants_file(); + std::error_code error; + if (path.empty() || !fs::is_regular_file(path, error)) return out; + std::ifstream file(path, std::ios::binary); + const std::string text{std::istreambuf_iterator(file), {}}; + const auto root = Json::parse(text, {64 * 1024, 4, 1024}); + if (!root.is_object()) return out; + for (const auto& [mod, variant] : root.items()) + if (variant.is_string() && variant_name(lower(variant.string()))) out[lower(mod)] = lower(variant.string()); + return out; +} } // namespace -std::string board_break_deck_of(std::string_view mod) { - constexpr std::string_view prefix = "board_break_"; - auto name = lower(mod); - // A pack's deck is listed as #; no folder has a # in its name. - if (const auto mark = name.rfind('#'); mark != std::string::npos) { - name.erase(0, mark + 1); - return deck_name(name) ? name : std::string{}; - } - // Team-Board_Break_Wide and Board_Break_Wide are both the deck "wide". - if (const auto team = name.rfind('-'); team != std::string::npos) name.erase(0, team + 1); - if (!name.starts_with(prefix) || !deck_name(std::string_view(name).substr(prefix.size()))) return {}; - return name.substr(prefix.size()); +std::string variant_of(std::string_view mod) { + const auto mark = mod.rfind('#'); + if (mark == std::string_view::npos) return {}; + auto name = lower(mod.substr(mark + 1)); + return variant_name(name) ? name : std::string{}; } -std::string board_break_pack_of(std::string_view mod) { +std::string variant_owner(std::string_view mod) { const auto mark = mod.rfind('#'); return mark == std::string_view::npos ? std::string{} : std::string(mod.substr(0, mark)); } -std::string wanted_board_break_deck() noexcept { +std::vector installed_variants(const Mod& mod) { + std::vector found; try { - { - std::lock_guard lock(board_break_mutex); - if (!chosen_deck.empty()) return chosen_deck; + for (auto& variant : variants_inside(mod)) found.push_back(std::move(variant.first)); + } catch (...) {} + return found; +} +std::filesystem::path variant_folder(const Mod& mod, std::string_view variant) { + try { + const auto wanted = lower(variant); + for (auto& one : variants_inside(mod)) + if (one.first == wanted) return std::move(one.second); + } catch (...) {} + return {}; +} +std::string chosen_variant(std::string_view mod) noexcept { + try { + std::lock_guard lock(variants_mutex); + if (!chosen_variants) { + try { + chosen_variants = read_choices(); + } catch (...) { + chosen_variants.emplace(); // an unreadable file chooses nothing + } } - std::string choice; - if (const auto path = board_break_deck_file(); !path.empty()) { - std::ifstream file(path); - std::getline(file, choice); - } - while (!choice.empty() && (choice.back() == '\r' || choice.back() == ' ')) choice.pop_back(); - choice = lower(choice); - return deck_name(choice) ? choice : std::string(board_break_decks.front()); + const auto found = chosen_variants->find(lower(mod)); + return found == chosen_variants->end() ? std::string{} : found->second; } catch (...) { return {}; } } -bool save_board_break_deck(std::string_view deck) noexcept { +bool save_variant(std::string_view mod, std::string_view variant) noexcept { try { - if (!deck_name(deck)) return false; - const auto path = board_break_deck_file(); + const auto name = lower(variant); + if (mod.empty() || !variant_name(name)) return false; + const auto path = variants_file(); if (path.empty()) return false; + (void)chosen_variant(mod); // read the file once, so its other choices are kept + std::lock_guard lock(variants_mutex); + auto choices = *chosen_variants; + choices[lower(mod)] = name; + auto root = Json::object(); + for (const auto& [owner, chosen] : choices) root[owner] = chosen; std::error_code error; fs::create_directories(path.parent_path(), error); - std::ofstream file(path, std::ios::trunc); - file << deck << '\n'; + std::ofstream file(path, std::ios::binary | std::ios::trunc); + file << root.dump(2) << '\n'; if (!file) return false; - std::lock_guard lock(board_break_mutex); - chosen_deck = deck; + chosen_variants = std::move(choices); return true; } catch (...) { return false; } } -std::vector board_break_folders() noexcept { - std::vector found; - try { - for (auto& deck : installed_decks()) - if (std::find(found.begin(), found.end(), deck.second) == found.end()) found.push_back(std::move(deck.second)); - } catch (...) {} - return found; -} -std::filesystem::path board_break_deck_folder(std::string_view deck) noexcept { - try { - const auto wanted = lower(deck); - for (const auto& folder : deck_mod_folders()) { - const auto inside = decks_inside(folder); - for (const auto& one : inside) - if (one.first == wanted) return one.second; - if (inside.empty() && board_break_deck_of(path_utf8(folder.filename())) == wanted) return folder; - } - } catch (...) {} - return {}; -} -std::vector installed_board_break_decks() noexcept { - std::vector found; - try { - for (auto& deck : installed_decks()) - if (found.empty() || found.back() != deck.first) found.push_back(std::move(deck.first)); - } catch (...) {} - return found; -} -std::string board_break_in_play() noexcept { +std::string variant_in_play(std::string_view mod) noexcept { try { + const auto look = [&](const VariantMap& in_play) { + for (const auto& [owner, variant] : in_play) + if (lower(owner) == lower(mod)) return variant; + return std::string{}; + }; { - std::lock_guard lock(board_break_mutex); - if (applied_board_break) return applied_board_break->deck; + std::lock_guard lock(variants_mutex); + if (applied_variants) return look(applied_variants->in_play); } - return catalog().board_break_deck; + return look(catalog().variants_in_play); } catch (...) { return {}; } } -std::vector set_aside_for_board_break() { +std::vector set_aside_for_variants() { { - std::lock_guard lock(board_break_mutex); - if (applied_board_break) return applied_board_break->set_aside; + std::lock_guard lock(variants_mutex); + if (applied_variants) return applied_variants->set_aside; } return catalog().set_aside; } -void board_break_applied(const Catalog& catalog) noexcept { +void variants_applied(const Catalog& catalog) noexcept { try { - std::lock_guard lock(board_break_mutex); - applied_board_break = BoardBreakApplied{catalog.board_break_deck, catalog.set_aside}; + std::lock_guard lock(variants_mutex); + applied_variants = VariantsApplied{catalog.variants_in_play, catalog.set_aside}; } catch (...) {} } @@ -350,65 +325,60 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver // 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. - // It carries the pack's build stamp; mods.json and the mod list only know the pack. The - // pack's own layout is no deck here, whatever its folder is named: it is the deck a ReSkate - // from before deck packs breaks, and is set aside for the decks inside. - std::vector packs; + // Variants (mod_catalog.h): each variant a mod holds becomes a mod of its own for the run, + // listed after it as #, so that variants are chosen, set aside and switched + // like mods in folders of their own. It carries its mod's build stamp. + std::vector owners; for (std::size_t i = 0; i < list.entries.size(); ++i) { - auto inside = decks_inside(list.entries[i].mod.directory); + auto inside = variants_inside(list.entries[i].mod); if (inside.empty()) continue; - const auto pack = list.entries[i].mod; - packs.push_back(pack.name); + const auto owner = list.entries[i].mod; + owners.push_back(owner.name); const bool enabled = list.entries[i].enabled; - for (auto& [deck, directory] : inside) { + for (auto& [variant, directory] : inside) { ModEntry part; part.enabled = enabled; - part.mod.name = pack.name + "#" + deck; - part.mod.title = pack.title + " (" + deck + ")"; - part.mod.author = pack.author; - part.mod.version = pack.version; + part.mod.name = owner.name + "#" + variant; + part.mod.title = owner.title + " (" + variant + ")"; + part.mod.author = owner.author; + part.mod.version = owner.version; part.mod.directory = std::move(directory); part.mod.provides_layout = true; - part.mod.studio_marker = pack.studio_marker; - part.mod.skate_sha256 = pack.skate_sha256; - part.mod.outdated = pack.outdated; + part.mod.studio_marker = owner.studio_marker; + part.mod.skate_sha256 = owner.skate_sha256; + part.mod.outdated = owner.outdated; list.entries.insert(list.entries.begin() + static_cast(++i), std::move(part)); } } - const auto deck_of = [&](const Mod& mod) { - return std::find(packs.begin(), packs.end(), mod.name) != packs.end() ? std::string{} : board_break_deck_of(mod.name); - }; - // The breakable decks (Extension/Skater/breakable_deck.h). The deck that can break is a mod's own - // copy of the game's one deck model, and any mod that reshapes the deck replaces that same - // model: the one higher in the load order has it. So the deck the player chose goes first - // for this run, whatever mods.json says of the order, and what would fight it is set aside: - // Board Break's other decks, and other mods that bring a deck model. Those are left out - // whole: with the breakable deck in place of its own, such a mod would have its trucks and - // wheels on a deck they were not made for. mods.json is not changed, and their archives are - // placed like a disabled mod's, so another deck can be chosen while the game runs. A choice - // that is not installed falls back to the first deck, then to any that is. - { - const auto enabled_deck = [&](const std::string& deck) { + // One variant of each loads, and what would fight it is set aside: the mod's other variants, + // and every other mod whose layout lists a bundle its variants displace (the mod's own layout + // too). Those are left out whole: with a variant in place of their own, such a mod would have + // the rest of its changes on game data they were not made for. mods.json is not changed, and + // their archives are placed like a disabled mod's, so another variant can be chosen while the + // game runs. A choice that is not installed falls back to the first of the "order", then to + // any variant there is. + for (const auto& owner : owners) { + const auto held = std::find_if(list.entries.begin(), list.entries.end(), [&](const auto& entry) { return entry.mod.name == owner; }); + if (held == list.entries.end() || !held->enabled) continue; + const auto declared = *held->mod.variants; + const auto of_owner = [&](const ModEntry& entry) { return variant_owner(entry.mod.name) == owner; }; + const auto enabled_variant = [&](const std::string& name) { return std::find_if(list.entries.begin(), list.entries.end(), [&](const auto& entry) { - const auto found = deck_of(entry.mod); - return entry.enabled && !found.empty() && (deck.empty() || found == deck); + return entry.enabled && of_owner(entry) && (name.empty() || variant_of(entry.mod.name) == name); }); }; auto first = list.entries.end(); - for (const auto& deck : {wanted_board_break_deck(), std::string(board_break_decks.front()), std::string()}) - if (first == list.entries.end()) first = enabled_deck(deck); - if (first != list.entries.end()) { - std::rotate(list.entries.begin(), first, first + 1); - result.notes.push_back("Board Break: " + list.entries.front().mod.name + " loads first, so its deck is the one in the game"); - for (auto entry = list.entries.begin() + 1; entry != list.entries.end(); ++entry) { - if (!entry->enabled || (deck_of(entry->mod).empty() && !changes_the_deck(entry->mod.directory))) continue; - entry->enabled = false; - result.set_aside.push_back(entry->mod.name); - result.notes.push_back("Board Break: " + entry->mod.name + " brings a deck model too and is set aside for this run"); - } + for (const auto& name : {chosen_variant(owner), declared.order.empty() ? std::string() : declared.order.front(), std::string()}) + if (first == list.entries.end()) first = enabled_variant(name); + if (first == list.entries.end()) continue; + std::rotate(list.entries.begin(), first, first + 1); + result.notes.push_back("Variants: " + list.entries.front().mod.name + " loads first, so it is the one of " + owner + "'s in the game"); + for (auto entry = list.entries.begin() + 1; entry != list.entries.end(); ++entry) { + if (!entry->enabled || (!of_owner(*entry) && !lists_bundle(entry->mod.directory, declared.displaces))) continue; + entry->enabled = false; + result.set_aside.push_back(entry->mod.name); + result.notes.push_back("Variants: " + entry->mod.name + (of_owner(*entry) ? " is another of " + owner + "'s" : " brings what they replace") + + " and is set aside for this run"); } } for (auto& entry : list.entries) { @@ -518,12 +488,6 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver build + ", the installed game is " + game_build); result.inactive.erase(result.inactive.begin() + static_cast(i)); } - // Board Break's deck is the one in the game when its mod is among those that load and no mod - // above it brings a deck model of its own. - for (const auto& mod : result.mods) { - if (!(result.board_break_deck = deck_of(mod)).empty()) break; - if (changes_the_deck(mod.directory)) break; - } // Any Mods folder gets a patch, even an empty one or one of disabled // mods only: a mod enabled or dropped in while the game runs needs it // mounted from launch. @@ -617,6 +581,11 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver " mod(s) into " + std::to_string(merge.superbundles) + " superbundle(s) and " + std::to_string(merge.archives) + " archive(s)"); } + // A mod's variant is the one in the game when it is among the mods that load (what would fight + // it was set aside above). + for (const auto& mod : result.mods) + if (const auto owner = variant_owner(mod.name); !owner.empty() && !result.variants_in_play.contains(owner)) + result.variants_in_play.emplace(owner, variant_of(mod.name)); if (run_merge) write_exclusions(result.root, still_excluded); // One line per mod, so a log shows at a glance what was installed and // what built it. diff --git a/Engine/Vfs/mod_catalog.h b/Engine/Vfs/mod_catalog.h index 4b6b0105..5e8ee795 100644 --- a/Engine/Vfs/mod_catalog.h +++ b/Engine/Vfs/mod_catalog.h @@ -40,52 +40,36 @@ struct Catalog { // Mods left out because they were built for another game version (Mod::outdated), enabled or // not: they need updating. Also in `excluded` when they were enabled. std::vector outdated; - // Board Break: the name of its deck that is the one in the game ("standard", "wide" and so on), - // empty when none is and a board cannot break; and the enabled mods set aside for it, its - // other decks and other mods that bring a deck model. Those are not loaded and nothing about - // them waits to be applied. - std::string board_break_deck; + // Mods with variants (mod_list.h Variants): the variant of each that is the one in the game, by + // the declaring mod's folder name (none listed: none of its variants loads); and the enabled + // mods set aside for them, their other variants and other mods that bring what a variant + // displaces. Those are not loaded and nothing about them waits to be applied. + std::map> variants_in_play; std::vector set_aside; }; -// The breakable decks (Extension/Skater/breakable_deck.h). The game has one deck model for every -// board, so each deck that can break is a copy of it in a content mod, and the deck the player -// chose is the one that loads. A deck comes in one of two ways: -// - a mod whose folder is named Board_Break_ (also behind a team name, as a mod manager -// installs it: Team-Board_Break_) holds the deck ; -// - a deck pack, a mod of any name with a folder decks// for each deck it brings, laid out -// like a mod folder (layout.toc, Win32/...). One package can bring every deck that way, where -// the launcher installs one mod folder per package. For the run each deck inside is a mod of -// its own, listed as #. The pack's own layout is not one of its decks, -// whatever the folder is named, and is set aside like any other deck model. It is what an -// older ReSkate loads from the pack: in a folder named Board_Break_ the deck 2.0.6 -// breaks, which knows a deck by its folder's name only; under another name the game's own. -// A deck goes by its name in small letters. These are the decks the feature was made with, in the -// order the menus list them, and the console also knows them by their place here (deck 1 is the -// first). Any other deck comes after them, by its name: a new shape needs no change to ReSkate. -inline constexpr std::array board_break_decks{"standard", "wide", "concave", "powell", "slim", - "egg", "square", "shaped", "oldschool", "pig"}; -// The name of the deck a mod holds going by the mod's name (a folder's, or a pack's deck's), or -// nothing; and the folder name of the pack that one of a pack's decks is listed under, or nothing. -std::string board_break_deck_of(std::string_view mod); -std::string board_break_pack_of(std::string_view mod); -// The deck the player chose ("standard" when nothing was chosen): read whenever the mods are put -// together, at launch and at every live apply. Saving a choice makes it this run's at once. -std::string wanted_board_break_deck() noexcept; -bool save_board_break_deck(std::string_view deck) noexcept; -// The decks that are in the Mods folder, whatever mods.json says of their mods, in the menus' -// order; and the folders of the mods that bring them (a pack's once), for what a pack carries -// beside its decks. -std::vector installed_board_break_decks() noexcept; -std::vector board_break_folders() noexcept; -// The folder with a deck's own files (a pack's decks/, or the Board_Break_ folder): what -// it says of itself (deck.ini) is there. Empty when that deck is not installed. -std::filesystem::path board_break_deck_folder(std::string_view deck) noexcept; +// Variants (mod_list.h). For the run each variant is a mod of its own, listed after the mod that +// holds it as #, with that mod's build stamp; mods.json and the mod list only +// know the mod. One variant of each such mod loads, the one the player chose (else the first of its +// "order", else the first there is), first in the load order whatever mods.json says: what would +// fight it is set aside, whole, and its archives are placed like a disabled mod's, so another variant +// can be chosen while the game runs. The variant a mod's entry stands for, and the mod that holds +// it, or nothing (no folder has a # in its name): +std::string variant_of(std::string_view mod); +std::string variant_owner(std::string_view mod); +// The variants a mod folder holds, its "order" first, then the others by name; and the folder with a +// variant's own files (empty when it has no such variant). +std::vector installed_variants(const Mod& mod); +std::filesystem::path variant_folder(const Mod& mod, std::string_view variant); +// The variant chosen for the mod with this folder name, empty when none was: read whenever the mods +// are put together, at launch and at every live apply. Saving a choice makes it this run's at once. +std::string chosen_variant(std::string_view mod) noexcept; +bool save_variant(std::string_view mod, std::string_view variant) noexcept; // What the mods in effect give the game: the launch's catalogue, or the one the last live apply -// read (live_mods tells with board_break_applied). -std::string board_break_in_play() noexcept; -std::vector set_aside_for_board_break(); -void board_break_applied(const Catalog& catalog) noexcept; +// read (live_mods tells with variants_applied). Empty: none of that mod's variants is in the game. +std::string variant_in_play(std::string_view mod) noexcept; +std::vector set_aside_for_variants(); +void variants_applied(const Catalog& catalog) noexcept; // The engine's own data root: the -dataPath argument when present, otherwise // the executable's directory. Mirrors the game so mod paths resolve the same. diff --git a/Engine/Vfs/mod_list.cpp b/Engine/Vfs/mod_list.cpp index 128be11d..19ba80c9 100644 --- a/Engine/Vfs/mod_list.cpp +++ b/Engine/Vfs/mod_list.cpp @@ -29,6 +29,7 @@ constexpr std::size_t maximum_order_bytes = 4 * 1024 * 1024; constexpr std::size_t maximum_build_bytes = 16 * 1024; constexpr std::size_t maximum_info_bytes = 32 * 1024; constexpr std::size_t maximum_levels_bytes = 64 * 1024; +constexpr std::size_t maximum_plugin_bytes = 64 * 1024; std::string lower(std::string_view text) { std::string result(text); @@ -139,6 +140,94 @@ void read_levels(Mod& mod) { } } +// reskate-plugin.json's "resident" list (ResidentAssets). A malformed list is a note, and the mod +// keeps nothing resident; the plugin host reports the rest of the file. +void read_resident(Mod& mod, std::vector& notes) { + const auto path = mod.directory / plugin_manifest_file; + std::error_code error; + if (!fs::is_regular_file(path, error) || error) return; + try { + const auto root = parse(path, maximum_plugin_bytes); + if (!root.is_object() || !root.contains("resident")) return; + const auto name_of = [](const Json& value) { + if (!value.is_string() || value.string().empty() || value.string().size() > 260) throw std::runtime_error("a name is 1 to 260 characters"); + for (const auto c : value.string()) + if (static_cast(c) < 0x20 || static_cast(c) > 0x7e || c == '\\') + throw std::runtime_error("names are printable ASCII with / between folders"); + return lower(value.string()); + }; + const auto names_of = [&](const Json& list, std::size_t most) { + if (!list.is_array() || list.size() > most) throw std::runtime_error("roots is a list of at most " + std::to_string(most) + " names"); + std::vector out; + for (const auto& item : list) out.push_back(name_of(item)); + return out; + }; + const auto& list = root.at("resident"); + if (!list.is_array() || list.size() > 16) throw std::runtime_error("resident is a list of at most 16 bundles"); + std::vector result; + for (const auto& row : list) { + if (!row.is_object() || !row.contains("bundle")) throw std::runtime_error("each resident entry names its bundle"); + ResidentAssets entry; + entry.bundle = name_of(row.at("bundle")); + if (row.contains("roots")) entry.roots = names_of(row.at("roots"), 512); + if (row.contains("shaders")) { + const auto& shaders = row.at("shaders"); + if (!shaders.is_array() || shaders.size() > 32) throw std::runtime_error("shaders is a list of at most 32 depots"); + for (const auto& shader : shaders) { + if (!shader.is_object() || !shader.contains("name") || !shader.contains("roots")) + throw std::runtime_error("each shader names its depot and its roots"); + entry.shaders.push_back({name_of(shader.at("name")), names_of(shader.at("roots"), 64)}); + if (entry.shaders.back().roots.empty()) throw std::runtime_error("a shader needs its roots"); + } + } + result.push_back(std::move(entry)); + } + mod.resident = std::move(result); + } catch (const std::exception& failure) { + notes.push_back("Mod " + mod.name + ": " + plugin_manifest_file + "'s resident list could not be read (" + failure.what() + ")"); + } +} + +// reskate-plugin.json's "variants" (Variants). A malformed one is a note, and the mod has no variants: +// it loads as any other mod. +void read_variants(Mod& mod, std::vector& notes) { + const auto path = mod.directory / plugin_manifest_file; + std::error_code error; + if (!fs::is_regular_file(path, error) || error) return; + try { + const auto root = parse(path, maximum_plugin_bytes); + if (!root.is_object() || !root.contains("variants")) return; + const auto& declared = root.at("variants"); + if (!declared.is_object() || !declared.contains("folder")) throw std::runtime_error("variants names its folder"); + const auto text = [](const Json& value, std::size_t most, bool name) { + if (!value.is_string() || value.string().empty() || value.string().size() > most) + throw std::runtime_error("a name is 1 to " + std::to_string(most) + " characters"); + auto out = lower(value.string()); + for (const auto c : out) + if (name ? !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') + : static_cast(c) < 0x20 || static_cast(c) > 0x7e || c == '\\') + throw std::runtime_error(name ? "a folder or variant name is letters, digits, '_' and '-'" + : "a bundle name is printable ASCII with / between folders"); + return out; + }; + const auto list = [&](const char* field, std::size_t most, bool name) { + std::vector out; + if (!declared.contains(field)) return out; + const auto& items = declared.at(field); + if (!items.is_array() || items.size() > most) throw std::runtime_error(std::string(field) + " is a list of at most " + std::to_string(most)); + for (const auto& item : items) out.push_back(text(item, name ? maximum_mod_name : 260, name)); + return out; + }; + Variants result; + result.folder = text(declared.at("folder"), maximum_mod_name, true); + result.order = list("order", 64, true); + result.displaces = list("displaces", 16, false); + mod.variants = std::move(result); + } catch (const std::exception& failure) { + notes.push_back("Mod " + mod.name + ": " + plugin_manifest_file + "'s variants could not be read (" + failure.what() + ")"); + } +} + // .reskate-studio-patch: the stamp line, then key=value lines. A mod that ships game data must // record the Skate.exe it was built for, and it must be this one (supported_build). void read_studio_marker(Mod& mod) { @@ -183,6 +272,14 @@ std::vector folders_on_disk(const fs::path& root) { if (error) break; if (!entry.is_directory(error) || error) { error.clear(); continue; } const auto name = ascii_path(entry.path().filename()); + // A folder with no file of its own and no parks (only other folders, such as saved data a + // tool keeps beside the mods) holds no mod: every other mod has its manifest or layout at its + // top, and a park editor mod its parks folder. + bool content{}; + for (fs::directory_iterator inner(entry.path(), error), end; !error && inner != end && !content; inner.increment(error)) + content = inner->is_regular_file(error) || (inner->is_directory(error) && lower(ascii_path(inner->path().filename())) == "parks"); + error.clear(); + if (!content) continue; if (valid_mod_name(name)) names.push_back(name); } std::sort(names.begin(), names.end(), [](const std::string& a, const std::string& b) { @@ -229,7 +326,7 @@ std::string mod_fingerprint(const std::filesystem::path& directory) { const auto size = it->file_size(error); const auto written = it->last_write_time(error).time_since_epoch().count(); // Wide, so any file name counts; ASCII names give the same text as before. - const auto relative = fs::relative(it->path(), directory, error).generic_wstring(); + const auto relative = it->path().lexically_relative(directory).generic_wstring(); // as laid out, not through links std::string name; for (const auto ch : relative) { if (ch < 0x80) name.push_back(static_cast(ch)); @@ -347,10 +444,35 @@ Mod describe_mod(const std::filesystem::path& directory, std::string name, std:: read_build_info(mod, notes); read_mod_info(mod, notes); read_levels(mod); + read_resident(mod, notes); + read_variants(mod, notes); read_studio_marker(mod); return mod; } +bool resident_root(const Mod& mod, std::string_view bundle, std::string_view name) noexcept { + try { + const auto b = lower(bundle), n = lower(name); + for (const auto& entry : mod.resident) { + if (entry.bundle != b) continue; + for (const auto& root : entry.roots) + if (root.ends_with('/') ? n.starts_with(root) : n == root) return true; + } + } catch (...) {} + return false; +} +const ResidentAssets::Shader* resident_shader(const Mod& mod, std::string_view bundle, std::string_view name) noexcept { + try { + const auto b = lower(bundle), n = lower(name); + for (const auto& entry : mod.resident) { + if (entry.bundle != b) continue; + for (const auto& shader : entry.shaders) + if (shader.name == n) return &shader; + } + } catch (...) {} + return nullptr; +} + ModList scan_mods(const std::filesystem::path& data_root) noexcept { ModList result; try { diff --git a/Engine/Vfs/mod_list.h b/Engine/Vfs/mod_list.h index ffa9121f..6c08c1df 100644 --- a/Engine/Vfs/mod_list.h +++ b/Engine/Vfs/mod_list.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -27,11 +28,45 @@ inline constexpr char manifest_file[] = "manifest.json"; // The older name for the same information ("version" instead of // "version_number"), still read when a mod has no manifest.json. inline constexpr char info_file[] = "reskate-mod.json"; +// A plugin mod's manifest (Extension/Plugins/plugin_manifest.h). Besides what the plugin host reads, +// the merge reads its "resident" list (ResidentAssets) from here. +inline constexpr char plugin_manifest_file[] = "reskate-plugin.json"; // ReSkate Studio's stamp in every mod it builds: "ReSkate Studio native Patch v1", then // key=value lines, of which skate_sha256= decides whether the // mod loads: game data built for another Skate.exe points into that build's archives. inline constexpr char studio_marker_file[] = ".reskate-studio-patch"; +// Assets of one of the mod's bundles that the game selects at run time without an import naming them +// (a plugin's world objects, the HUD widgets it picks by size): kept with the mod's additions when +// another mod (a map) replaces that bundle, as imported additions are. reskate-plugin.json: +// "resident": [{"bundle": "win32/levels/...", "roots": ["world/...", "ui/.../"], +// "shaders": [{"name": "world/.../shaderblockdepot_...", "roots": ["world/...", ...]}]}] +// Names are lower-case asset names; a root ending in '/' keeps every asset under it. A shader block +// depot (a resource no EBX imports) goes with its roots when all of them come from the mod. +struct ResidentAssets { + std::string bundle; + std::vector roots; + struct Shader { + std::string name; + std::vector roots; + }; + std::vector shaders; +}; + +// A mod that brings several takes on the same game data, of which one is in the game at a time (a deck +// pack's decks: the game has one deck model). reskate-plugin.json: +// "variants": {"folder": "decks", "order": ["standard", "wide"], "displaces": ["..._bundlereftable"]} +// Each folder // with a layout.toc is the variant (lower-case letters, digits and +// '_'), laid out like a mod folder. "order" is the variants the menus list first, in that order; the +// first of them loads when none was chosen. "displaces" names the bundles a variant replaces: while one +// loads, every other mod whose layout lists one of them (the declaring mod's own layout too) is set +// aside for the run (mod_catalog.h). +struct Variants { + std::string folder; + std::vector order; + std::vector displaces; +}; + struct Mod { std::string name; // folder name directly under Mods/ std::filesystem::path directory; @@ -50,6 +85,10 @@ struct Mod { std::vector levels; // What the merge could not use from this mod. Empty once it merged cleanly. std::vector problems; + // From reskate-plugin.json: the assets its bundles keep resident (ResidentAssets). + std::vector resident; + // From reskate-plugin.json: the variants it holds (Variants), if it declares any. + std::optional variants; // From .reskate-studio-patch: whether the mod has one, and the Skate.exe SHA-256 it records. bool studio_marker{}; std::string skate_sha256; @@ -101,6 +140,11 @@ struct ModList { std::vector notes; }; +// Whether `name` (an asset of `bundle`, either lower-case) is one the mod keeps resident, and the +// shader block depot entry `name` is in that bundle, or null. +bool resident_root(const Mod& mod, std::string_view bundle, std::string_view name) noexcept; +const ResidentAssets::Shader* resident_shader(const Mod& mod, std::string_view bundle, std::string_view name) noexcept; + // Folder names travel into engine paths, so only plain ASCII is accepted: // letters, digits, space, '_', '-' and '.', not starting with '.'. bool valid_mod_name(std::string_view name); diff --git a/Engine/Vfs/mod_merge.cpp b/Engine/Vfs/mod_merge.cpp index 6899fc86..13950f65 100644 --- a/Engine/Vfs/mod_merge.cpp +++ b/Engine/Vfs/mod_merge.cpp @@ -149,8 +149,9 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con const auto before = options.live ? read_item_sources(output / item_sources_file) : std::vector{}; itemSources.emplace(); for (const auto* mod : mods) { - std::error_code outside; - const auto folder = fs::relative(mod->directory, catalog.root, outside); + // As the Mods folder lays it out: fs::relative resolves a linked mod folder. + const auto folder = mod->directory.lexically_relative(catalog.root); + const bool outside = folder.empty() || *folder.begin() == L".."; ItemSource source{mod->name, mod->title, mod->author, mod->version, outside ? std::string() : path_utf8(folder.generic_wstring()), {}}; const auto read = std::ranges::find(*itemsRead, mod->name, &StoreCopies::Added::mod); @@ -292,7 +293,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con if (!number) throw std::runtime_error("Unexpected archive name: " + relative); auto directory = lower(path.parent_path().generic_string()); if (directory.starts_with("win32/")) directory.erase(0, 6); - const auto target = output / path.parent_path() / archive_file(manifestArchive); + const auto target = inside(output, path.parent_path() / archive_file(manifestArchive)); if (!fs::exists(target, error)) throw std::runtime_error(mod->name + " needs a restart: the patch has no archive 1 in " + directory); // Checked before anything is appended: past 4 GB the mod's @@ -341,18 +342,18 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con if (number == manifestArchive) { if (const auto free = claim(directory); free) { spot.archive = *free; - link_or_copy(mod->directory / path, output / path.parent_path() / archive_file(*free)); + link_or_copy(mod->directory / path, inside(output, path.parent_path() / archive_file(*free))); } else { - spot.offset = append_file(mod->directory / path, output / path); + spot.offset = append_file(mod->directory / path, inside(output, path)); } } else if (claimed.insert(number).second) { - link_or_copy(mod->directory / path, output / path); + link_or_copy(mod->directory / path, inside(output, path)); } else if (const auto free = claim(directory); free) { spot.archive = *free; link_or_copy(mod->directory / path, - output / path.parent_path() / archive_file(*free)); + inside(output, path.parent_path() / archive_file(*free))); } else { - spot.offset = append_file(mod->directory / path, output / path); + spot.offset = append_file(mod->directory / path, inside(output, path)); } placements[mod].at.emplace(std::pair{directory, number}, spot); // A disabled mod's archives are read by no TOC yet, so nothing @@ -380,7 +381,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con if (!options.live) for (const auto& [directory, declared] : declaredIn) { if (!declared.contains(manifestArchive)) continue; - const auto path = output / L"Win32" / fs::path(directory) / archive_file(manifestArchive); + const auto path = inside(output, fs::path(L"Win32") / fs::path(directory) / archive_file(manifestArchive)); if (fs::exists(path, error)) continue; fs::create_directories(path.parent_path(), error); write_file(path, {}); @@ -591,7 +592,7 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con if (check.bundles.size() != job.merged.bundles.size() || check.chunks.size() != job.merged.chunks.size()) throw std::runtime_error("Merged TOC did not round-trip: " + relative); - write_file(output / fs::path(relative), bytes); + write_file(inside(output, fs::path(relative)), bytes); ++report.superbundles; // Done with: a big merge would otherwise hold every superbundle's until the end. job.merged = {}; @@ -606,7 +607,9 @@ MergeReport merge_mods(const Catalog& catalog, const MergeObserver& observe, con for (const auto& relative : superbundles) written.insert(lower(relative)); if (options.live) for (const auto& previous : record.tocs) - if (!written.contains(lower(previous)) && fs::remove(output / fs::path(previous), error)) + if (!written.contains(lower(previous)) && [&] { + try { return fs::remove(inside(output, fs::path(previous)), error); } catch (const std::exception&) { return false; } + }()) report.removed_tocs.push_back(previous); record.tocs = superbundles; write_placements(placementsPath, record); diff --git a/Engine/Vfs/mod_merge_cas_store.cpp b/Engine/Vfs/mod_merge_cas_store.cpp index bb10d7e1..b0c8e605 100644 --- a/Engine/Vfs/mod_merge_cas_store.cpp +++ b/Engine/Vfs/mod_merge_cas_store.cpp @@ -29,7 +29,7 @@ CasStore::CasStore(fs::path baseRoot, fs::path output, const native_db::Node& la } fs::path CasStore::archive_path(std::uint32_t installChunk, std::uint16_t archive) const { - return output_ / L"Win32" / fs::path(directory(installChunk)) / fs::path(archive_file(archive)); + return inside(output_, fs::path(L"Win32") / fs::path(directory(installChunk)) / fs::path(archive_file(archive))); } std::uint64_t CasStore::append(const fs::path& path, std::span encoded) { diff --git a/Engine/Vfs/mod_merge_combine.cpp b/Engine/Vfs/mod_merge_combine.cpp index 319f401b..a9a5dcbf 100644 --- a/Engine/Vfs/mod_merge_combine.cpp +++ b/Engine/Vfs/mod_merge_combine.cpp @@ -46,6 +46,7 @@ struct Asset { // For a chunk: where its record is in the chunk metadata of the copy this version // of it came in, and where the game's copy lists the chunk (merge_chunk_metadata). ChunkRecord record; + const AssetAddition* carried_from{}; }; // Where one contributor's copy of an asset lives, so conflicting copies can be @@ -241,7 +242,12 @@ void conflict(MergeReport& report, const std::string& kept, const std::string& l std::vector merge_chunk_metadata(std::span> lists, std::span chunks, std::size_t game, - std::span shipped) { + std::span shipped, std::vector* rows) { + // Unless the records are put in another order below, each chunk's is where it is listed. + if (rows) { + rows->resize(chunks.size()); + for (std::size_t index = 0; index < chunks.size(); ++index) (*rows)[index] = index; + } // A bundle with no chunks has nothing to describe; what the first copy has stays. if (chunks.empty()) return lists.empty() ? std::vector{} : lists.front(); const bool shippedList = game < lists.size() && !lists[game].empty(); @@ -254,7 +260,22 @@ std::vector merge_chunk_metadata(std::span order(chunks.size()); + for (std::size_t index = 0; index < order.size(); ++index) order[index] = index; + std::ranges::stable_sort(order, [&](std::size_t left, std::size_t right) { return chunks[left].guid < chunks[right].guid; }); + for (std::size_t at = 0; at < order.size(); ++at) (*rows)[order[at]] = at; + } + return lists[first]; + } std::vector> read(lists.size()); const auto list = [&](std::size_t copy) -> const native_db::Node* { @@ -337,6 +358,8 @@ std::vector merge_chunk_metadata(std::span(bytes.data()); return {data, data + bytes.size()}; @@ -387,12 +410,17 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, std::multimap> addedByGuid; // The resource a mod added to a bundle under a name, by bundle, mod and name. std::map, const AssetAddition*> companions; + std::map additionBundles; + std::map,const AssetAddition*> bundleChunks; for (const auto& [bundleName, list] : overrides.added) for (const auto& addition : list) { + additionBundles.emplace(&addition,&bundleName); if (addition.asset.kind == fb::AssetKind::ebx) addedByGuid.emplace(addition.file, std::pair{&bundleName, &addition}); else if (addition.asset.kind == fb::AssetKind::resource) companions.emplace(std::tuple{bundleName, addition.mod, lower(addition.asset.name)}, &addition); + else if (addition.asset.kind == fb::AssetKind::chunk) + bundleChunks.emplace(std::tuple{bundleName,addition.mod,addition.asset.guid},&addition); } // The map meshes already counted (reskate-map-meshes.json), by where they lie in a mod's folder. std::set> countedMeshes; @@ -735,10 +763,20 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, take.push_back(companion->second); } } + // Expand only the selected EBX/RES dependencies, including imports + // carried from another source bundle. Unrelated geometry stays out. + for (std::size_t index=0;indexchunk_names) + if (const auto found=bundleChunks.find({*from,asset->mod,id});found!=bundleChunks.end() && + taking.insert(found->second).second) take.push_back(found->second); + } if (!take.empty()) { std::set> present; for (const auto& asset : casManifest.ebx) present.insert(asset_key(asset)); for (const auto& asset : casManifest.resources) present.insert(asset_key(asset)); + for (const auto& asset : casManifest.chunks) present.insert(asset_key(asset)); const auto installChunk = region.files.front().location.installChunk; for (const auto* addition : take) { if (!present.insert(asset_key(addition->asset)).second) continue; @@ -748,6 +786,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, placed = carriedAt.emplace(std::pair{installChunk, addition}, store.carry(installChunk, manifestArchive, addition->encoded)).first; additions.push_back({addition->asset, placed->second}); + additions.back().carried_from=addition; addedFrom.insert(addition->mod); } catch (const std::exception& error) { report.notes.push_back(modName + ": " + bundle.name + ": " + addition->asset.name + @@ -782,14 +821,57 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, // Added EBX go after this copy's own EBX and added resources after its own // resources, ahead of its chunks; after the indexed moves above so their // positions still hold. + // A map's replacement bundle can retain the game's GUID-ordered rows + // and append its own rows. Use the actual game's baseline to interpret + // those records; the map's whole table is not a new stock baseline. + const auto defaults=overrides.bundle_chunk_defaults.find(key); + const bool hasDefaults=defaults!=overrides.bundle_chunk_defaults.end(); + std::vector> chunkLists; + if (hasDefaults) chunkLists.push_back(defaults->second.metadata); + const auto ownList=chunkLists.size(); + chunkLists.push_back(casManifest.chunkMetadata); + std::vector chunkRecords; + std::vector originalChunks; + if (hasDefaults) originalChunks=defaults->second.chunks; + std::map stockChunks; + for (std::size_t index=0;indexsecond}); + } + bool chunksAdded{}; for (auto& addition : additions) { - const auto ebx = addition.asset.kind == fb::AssetKind::ebx; - const auto at = static_cast(1 + casManifest.ebx.size() + (ebx ? 0 : casManifest.resources.size())); - (ebx ? casManifest.ebx : casManifest.resources).push_back(std::move(addition.asset)); - region.files.insert(region.files.begin() + at, addition.file); - inPatch.insert(inPatch.begin() + at, true); + auto at=1+casManifest.ebx.size(); + if (addition.asset.kind==fb::AssetKind::ebx) casManifest.ebx.push_back(std::move(addition.asset)); + else if (addition.asset.kind==fb::AssetKind::resource) { + at+=casManifest.resources.size();casManifest.resources.push_back(std::move(addition.asset)); + } else { + at+=casManifest.resources.size()+casManifest.chunks.size(); + const auto* source=addition.carried_from; + if (!source) throw std::runtime_error("Carried bundle chunk has no source record"); + auto copy=ChunkRecord::none; + if (source->chunk_metadata && !source->chunk_metadata->empty()) { + auto found=std::find(chunkLists.begin(),chunkLists.end(),*source->chunk_metadata); + if (found==chunkLists.end()) found=chunkLists.insert(chunkLists.end(),*source->chunk_metadata); + copy=static_cast(found-chunkLists.begin()); + } + const auto shipped=stockChunks.find(addition.asset.guid); + chunkRecords.push_back({addition.asset.guid,copy,source->chunk_index, + shipped==stockChunks.end()?ChunkRecord::none:shipped->second}); + casManifest.chunks.push_back(std::move(addition.asset)); + chunksAdded=true; + } + region.files.insert(region.files.begin() + static_cast(at), addition.file); + inPatch.insert(inPatch.begin() + static_cast(at), true); used.emplace(addition.file.location.installChunk, addition.file.location.archive); } + // The list written for this copy has its records by the chunks' guids, while the carried + // chunks are listed after its own: where each chunk's record went, for the records below. + std::vector chunkRows; + if (chunksAdded) casManifest.chunkMetadata=merge_chunk_metadata(chunkLists,chunkRecords, + hasDefaults?0:ChunkRecord::none,originalChunks,&chunkRows); const auto fresh = !states.contains(key); if (fresh) { order.push_back(bundle.name); states.emplace(key, BundleState{}); } auto& state = states.at(key); @@ -859,7 +941,11 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, auto& record = flat[firstChunk + index].record; record.guid = manifest.chunks[index].guid; record.copy = copy; - record.index = index; + // Where this copy's list has the chunk's record: where the chunk is listed, unless + // other mods' chunks were carried in and the list was written again by guid. Read + // by where the chunk is listed, every chunk after the first carried one by guid + // took its neighbour's record, and the first mip in it. + record.index = index < chunkRows.size() ? chunkRows[index] : index; if (isBase) record.shipped = index; } } diff --git a/Engine/Vfs/mod_merge_files.cpp b/Engine/Vfs/mod_merge_files.cpp index 0f20d113..8e0f6816 100644 --- a/Engine/Vfs/mod_merge_files.cpp +++ b/Engine/Vfs/mod_merge_files.cpp @@ -100,6 +100,13 @@ std::uint64_t append_file(const fs::path& from, const fs::path& to) { return start; } +fs::path inside(const fs::path& output, const fs::path& relative) { + const auto normal = relative.lexically_normal(); + if (normal.empty() || normal.has_root_name() || normal.has_root_directory() || *normal.begin() == L"..") + throw std::runtime_error("Refused to write " + path_utf8(relative) + ": it is outside the merged patch's folder"); + return output / normal; +} + // Hard link when the volume allows it, so a 400 MB archive costs nothing. void link_or_copy(const fs::path& from, const fs::path& to) { fs::create_directories(to.parent_path()); @@ -118,8 +125,11 @@ RelativeFiles scan(const fs::path& directory) { for (fs::recursive_directory_iterator it(root, error), end; it != end && !error; it.increment(error)) { if (!it->is_regular_file(error) || error) { error.clear(); continue; } // The game only names archives in ASCII: anything else is not one of its files. - const auto relative = ascii_path(fs::relative(it->path(), directory, error)); - if (error || relative.empty()) { error.clear(); continue; } + // Lexically, as the folder is laid out: fs::relative resolves links, and a Win32 (or a + // folder in it) that is a junction elsewhere would come out as ../../.., a path the merge + // then reads the game's TOC from and writes its own copies to outside the patch folder. + const auto relative = ascii_path(it->path().lexically_relative(directory)); + if (relative.empty() || relative.starts_with("../")) continue; const auto extension = lower(ascii_path(it->path().extension())); if (extension == ".toc") result.tocs.push_back(relative); else if (extension == ".cas") result.archives.push_back(relative); diff --git a/Engine/Vfs/mod_merge_internal.h b/Engine/Vfs/mod_merge_internal.h index df007ba1..bacbde35 100644 --- a/Engine/Vfs/mod_merge_internal.h +++ b/Engine/Vfs/mod_merge_internal.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -42,6 +43,10 @@ using vfs::archive_file; void unshare(const fs::path& path); std::uint64_t append_file(const fs::path& from, const fs::path& to); void link_or_copy(const fs::path& from, const fs::path& to); +// `relative` (a mod's file, a superbundle a layout names) under the merge's output folder. One +// that would land outside it (absolute, or climbing out with ..) throws: nothing the merge writes +// may reach a mod's own folder or the game's. +fs::path inside(const fs::path& output, const fs::path& relative); struct RelativeFiles { std::vector tocs; // Win32/... .toc, generic separators @@ -334,8 +339,14 @@ struct AssetAddition { // guids of the documents it refers to in turn (a song names its wave). fb::Guid file; std::vector names; + // Bundled mesh/texture data follows the EBX/RES that references it. + std::vector chunk_names; + std::shared_ptr> chunk_metadata; + std::size_t chunk_index{}; }; struct AssetOverrides { + struct ChunkDefaults { std::vector metadata; std::vector chunks; }; + std::map> bundle_chunk_defaults; // By lower-case asset name, then the game's sha1 the change replaces. std::map, std::less<>> changed; // Separate from EBX: a script's EBX wrapper can have the same asset name. @@ -377,7 +388,7 @@ struct ChunkRecord { static constexpr std::size_t none = static_cast(-1); fb::Guid guid; std::size_t copy{none}; // the list of the copy this version of the chunk came in; none when that copy has no list - std::size_t index{}; // where that copy lists the chunk + std::size_t index{}; // where that copy's list has its record: where the copy lists it, unless the list was written again (merge_chunk_metadata's rows) std::size_t shipped{none}; // where the game's copy lists it; none for a chunk mods add }; // The list for a merged bundle, written as the game writes one: a record for each @@ -390,12 +401,16 @@ struct ChunkRecord { // does not ship, or ships without a list, every chunk is one a mod adds. When // every chunk is the game's own where the game lists it, the game's list comes // back as it is; so does one mod's, for a bundle without a list in the game that -// is that mod's copy alone. +// is that mod's copy alone. `rows`, when given, gets where each chunk's record is in +// the list that comes back: its place by guid in a list in the game's form, else +// where the chunk is listed. A copy given that list as its own reads its chunks' +// records there (ChunkRecord::index). // Throws when a list that is needed cannot be read. [[nodiscard]] std::vector merge_chunk_metadata(std::span> lists, std::span chunks, std::size_t game = ChunkRecord::none, - std::span shipped = {}); + std::span shipped = {}, + std::vector* rows = nullptr); fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, std::vector& sources, MergeReport& report, diff --git a/Engine/Vfs/mod_merge_overrides.cpp b/Engine/Vfs/mod_merge_overrides.cpp index ec5412fd..f21ba8e6 100644 --- a/Engine/Vfs/mod_merge_overrides.cpp +++ b/Engine/Vfs/mod_merge_overrides.cpp @@ -86,6 +86,9 @@ struct ModRead { // own, it could arrive in another mod's bundle without the resources // and chunks it was added with. std::vector> candidates, companions; + struct BundleChunk { std::string bundle; AssetAddition addition; fb::BundleFileInfo file; }; + std::vector bundleChunks; + std::map> chunkDefaults; std::vector unreadable; // per candidate: never follows std::map, std::less<>> addedIn; // the documents it added to each bundle, by guid std::vector newChunks; // TOC chunks the game's TOCs do not have @@ -99,7 +102,7 @@ struct ModRead { ModRead read_mod(const Mod& mod, const RelativeFiles& files, const CasStore& store, const fs::path& baseRoot, const fs::path& gameRoot, Shared& gameTocs, Shared& gameBundles) { ModRead read; - auto& [candidates, companions, unreadable, addedIn, newChunks, imported, changes, scripts, notes, failure] = read; + auto& [candidates, companions, bundleChunks, chunkDefaults, unreadable, addedIn, newChunks, imported, changes, scripts, notes, failure] = read; const auto decoded = [&](std::span encoded) { return fb::ebx::read_document(fb::decode_cas(encoded, {gameRoot})); }; @@ -137,6 +140,12 @@ ModRead read_mod(const Mod& mod, const RelativeFiles& files, const CasStore& sto } }); if (!modListing || !known.listing) continue; + if (!known.listing->manifest.chunkMetadata.empty() && !chunkDefaults.contains(bundleName)) { + AssetOverrides::ChunkDefaults defaults; + defaults.metadata=known.listing->manifest.chunkMetadata; + for (const auto& entry:known.listing->manifest.chunks) defaults.chunks.push_back(entry.guid); + chunkDefaults.emplace(bundleName,std::move(defaults)); + } const auto& gameListing = known.listing; const auto& original = known.original; for (std::size_t index = 0; index < modListing->manifest.ebx.size(); ++index) { @@ -201,6 +210,21 @@ ModRead read_mod(const Mod& mod, const RelativeFiles& files, const CasStore& sto scripts.push_back({name, originalScript->second->sha1, std::nullopt, notes.size() - 1}); } } + // Keep chunk locations, not their potentially large payloads. Only + // data named by a retained mesh/texture is read and carried later. + const auto& chunks = modListing->manifest.chunks; + const auto chunkMetadata = chunks.empty() ? nullptr : + std::make_shared>(modListing->manifest.chunkMetadata); + for (std::size_t index = 0; index < chunks.size(); ++index) { + const auto at = modListing->first + modListing->manifest.ebx.size() + + modListing->manifest.resources.size() + index; + if (at >= modListing->files.size()) continue; + AssetAddition addition{mod.name, chunks[index], {}, tocKey}; + addition.asset.name = addition.asset.guid.string(); + addition.chunk_metadata = chunkMetadata; + addition.chunk_index = index; + bundleChunks.push_back({bundleName, std::move(addition), modListing->files[at]}); + } // Resources the mod adds, kept to go with an added EBX of the same name // (a wave's sound-bank resource registers the wave with the audio system). for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) { @@ -226,6 +250,10 @@ ModRead read_mod(const Mod& mod, const RelativeFiles& files, const CasStore& sto addition.file = document.fileGuid; for (const auto& reference : document.imports) addition.names.push_back(reference.fileGuid); addedIn[candidates[index].first].insert(addition.file); + // Assets the mod's plugin selects at run time (its world objects, HUD variants picked + // by size) have no import naming them: its resident list keeps them, as imported + // additions are kept, when a map replaces their bundle. + if (resident_root(mod, candidates[index].first, addition.asset.name)) imported.insert(addition.file); } catch (const std::exception&) { unreadable[index] = true; } } return read; @@ -287,7 +315,8 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, const auto* mod = asked[slot].first; auto& read = reads[slot]; if (read.failure) std::rethrow_exception(read.failure); - auto& [candidates, companions, unreadable, addedIn, newChunks, imported, changes, scripts, notes, failure] = read; + auto& [candidates, companions, bundleChunks, chunkDefaults, unreadable, addedIn, newChunks, imported, changes, scripts, notes, failure] = read; + for (auto& [bundle,defaults]:chunkDefaults) out.bundle_chunk_defaults.try_emplace(bundle,std::move(defaults)); std::size_t changed{}; // A higher mod's version of an asset, which this mod's change is combined with // below, may name what this mod adds. @@ -354,7 +383,69 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, // One addition per kind and name in a bundle (keep): this mod's, or not. const auto& index = addedAt[bundle]; const auto held = index.find({fb::AssetKind::ebx, lower(companion.asset.name)}); - if (held != index.end() && found->second[held->second].mod == mod->name) keep(bundle, std::move(companion)); + // Native mesh material keys do not import their shader depot by EBX GUID/name. A depot + // the mod's resident list names goes with its own runtime roots, once all of them come + // from this mod (on a replacement map's copy of the bundle too). + constexpr std::uint32_t shader_block_depot = 0x73312045; + const auto* shader = companion.asset.resourceType == shader_block_depot ? resident_shader(*mod, bundle, companion.asset.name) : nullptr; + const bool runtimeShader = shader && + std::all_of(shader->roots.begin(),shader->roots.end(),[&](const auto& root) { + return std::any_of(found->second.begin(),found->second.end(),[&](const auto& addition) { + return addition.mod==mod->name && addition.asset.kind==fb::AssetKind::ebx && + lower(addition.asset.name)==root; + }); + }); + if ((held != index.end() && found->second[held->second].mod == mod->name) || runtimeShader) + keep(bundle, std::move(companion)); + } + // A native mesh RES can refer to geometry in its bundle, rather than + // the superbundle TOC. Carrying only EBX/RES leaves the engine probing + // its chunk hash table forever on replacement-map bundles. + if (!bundleChunks.empty()) { + std::map aliases; + std::vector starts(0x10000); + const auto add_alias = [&](const fb::Guid& bytes, const fb::Guid& id) { + aliases.emplace(bytes,id); + starts[std::to_integer(bytes.bytes[0])*256 + + std::to_integer(bytes.bytes[1])] = true; + }; + for (const auto& chunk : bundleChunks) { + const auto& id=chunk.addition.asset.guid; + add_alias(id,id); + auto swapped=id; + std::reverse(swapped.bytes.begin(),swapped.bytes.begin()+4); + std::reverse(swapped.bytes.begin()+4,swapped.bytes.begin()+6); + std::reverse(swapped.bytes.begin()+6,swapped.bytes.begin()+8); + add_alias(swapped,id); + } + std::map,std::less<>> referenced; + for (auto& [bundle,list] : out.added) + for (auto& addition : list) { + if (addition.mod!=mod->name || addition.asset.kind==fb::AssetKind::chunk) continue; + std::vector bytes; + try { bytes=fb::decode_cas(addition.encoded,{gameRoot}); } + catch (const std::exception&) { continue; } + std::set named; + for (std::size_t at=0;at+16<=bytes.size();++at) { + if (!starts[std::to_integer(bytes[at])*256+ + std::to_integer(bytes[at+1])]) continue; + fb::Guid key;std::memcpy(key.bytes.data(),bytes.data()+at,16); + if (const auto found=aliases.find(key);found!=aliases.end()) named.insert(found->second); + } + addition.chunk_names.assign(named.begin(),named.end()); + referenced[bundle].insert(named.begin(),named.end()); + } + std::size_t carried{}; + for (auto& chunk : bundleChunks) { + const auto found=referenced.find(chunk.bundle); + if (found==referenced.end() || !found->second.contains(chunk.addition.asset.guid)) continue; + const auto& file=chunk.file; + chunk.addition.encoded=store.read(file.location.patch?mod->directory:baseRoot, + file.location,file.offset,file.size); + if (keep(chunk.bundle,std::move(chunk.addition))) ++carried; + } + if (carried) report.notes.push_back(mod->name+": "+std::to_string(carried)+ + " referenced bundle chunk(s) accompany mesh/texture additions into map copies"); } for (auto& change : changes) { // What the change names of the assets the mod added beside it. The mod @@ -419,13 +510,15 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, report.notes.push_back(mod->name + ": " + std::to_string(clash.first) + " added asset(s) share a name with ones " + other + " adds, e.g. " + clash.second + "; other mods' copies of the bundle get " + other + "'s"); - // The new TOC chunks this mod's carried EBX name (as raw GUID bytes, the way an - // EBX stores a ChunkId), so they can follow those assets into other superbundles. + // The new TOC chunks this mod's carried EBX or companion RES name, as raw + // GUID bytes. Texture pixel ChunkIds live in RES headers, not their EBX. + // Scan only retained dependencies so unrelated chunks do not follow them. if (!newChunks.empty()) { std::vector> carried; for (const auto& [bundle, list] : out.added) for (const auto& addition : list) - if (addition.mod == mod->name && addition.asset.kind == fb::AssetKind::ebx) try { + if (addition.mod == mod->name && (addition.asset.kind == fb::AssetKind::ebx || + addition.asset.kind == fb::AssetKind::resource)) try { carried.push_back(fb::decode_cas(addition.encoded, {gameRoot})); } catch (const std::exception&) {} // Each carried asset is gone through once, for all the chunks together: looking for diff --git a/Engine/Vfs/mod_merge_stamp.cpp b/Engine/Vfs/mod_merge_stamp.cpp index 247a1a51..e8f46da8 100644 --- a/Engine/Vfs/mod_merge_stamp.cpp +++ b/Engine/Vfs/mod_merge_stamp.cpp @@ -83,7 +83,7 @@ std::string merge_fingerprint(const Catalog& catalog, const std::vectordirectory)); std::ranges::sort(files); for (const auto& file : files) - describe(utf8(fs::relative(file, mod->directory, error)), file); + describe(utf8(file.lexically_relative(mod->directory)), file); for (const auto& toc : modFiles.at(mod).tocs) tocs.insert(lower(toc)); } for (const auto& toc : tocs) { @@ -163,7 +163,7 @@ void write_stamp(const fs::path& output, const std::string& fingerprint, const M if (!it->is_regular_file(error) || error) { error.clear(); continue; } const auto size = fs::file_size(it->path(), error); if (error) throw std::runtime_error("Cannot size " + path_utf8(it->path())); - text += "file " + std::to_string(size) + ' ' + utf8(fs::relative(it->path(), output, error)) + '\n'; + text += "file " + std::to_string(size) + ' ' + utf8(it->path().lexically_relative(output)) + '\n'; } if (error) throw std::runtime_error("Cannot list " + path_utf8(output)); for (auto note : report.notes) { diff --git a/Extension/Assets/live_mods.cpp b/Extension/Assets/live_mods.cpp index 94dd5601..46ae02ef 100644 --- a/Extension/Assets/live_mods.cpp +++ b/Extension/Assets/live_mods.cpp @@ -437,7 +437,7 @@ void apply_now() { std::lock_guard lock(state_mutex); // Set with the manifests, so the client tick sees both in one update. if (needed) reload_ready = true; - mods::board_break_applied(catalog); + mods::variants_applied(catalog); pending_levels = mods::level_manifest_paths(catalog); applied.emplace(); for (const auto &mod : catalog.mods) applied->push_back(mod.name); @@ -506,8 +506,8 @@ ApplyEffect preview(const mods::ModList &list) { }; bool reload{}; for (const auto &name : before) { - // One of a Board Break deck pack's decks stands for the pack (mod_catalog.h). - if (const auto pack = mods::board_break_pack_of(name); named(now, pack.empty() ? name : pack)) continue; + // A variant stands for the mod that holds it (mod_catalog.h). + if (const auto owner = mods::variant_owner(name); named(now, owner.empty() ? name : owner)) continue; const auto *entry = entry_of(name); // Content ReSkate keeps itself (builtin_packs.h) is in no mod list, and stays. if (!entry && (name.starts_with("reskate-builtin-") || mods::kept_by_reskate(list.root, name))) continue; diff --git a/Extension/Console/commands.cpp b/Extension/Console/commands.cpp index a9b60f17..f6906e04 100644 --- a/Extension/Console/commands.cpp +++ b/Extension/Console/commands.cpp @@ -228,7 +228,6 @@ const Commands &game_commands() { register_perf_commands(*result); register_item_commands(*result); register_trainer_commands(*result); - register_breakable_deck_commands(*result); register_replay_keyframes_commands(*result); register_plugin_commands(*result); return result; diff --git a/Extension/Console/commands.h b/Extension/Console/commands.h index 54035a39..27c1f7ab 100644 --- a/Extension/Console/commands.h +++ b/Extension/Console/commands.h @@ -26,7 +26,6 @@ void register_perf_commands(Commands &); void start_memory_trace_from_environment() noexcept; void register_item_commands(Commands &); void register_trainer_commands(Commands &); -void register_breakable_deck_commands(Commands &); void register_replay_keyframes_commands(Commands &); void register_plugin_commands(Commands &); // Runtime adapters. Invoked only by the verified game-thread dispatcher. diff --git a/Extension/Customization/local_player_card_runtime.cpp b/Extension/Customization/local_player_card_runtime.cpp index 56a7ac8d..9c22ad29 100644 --- a/Extension/Customization/local_player_card_runtime.cpp +++ b/Extension/Customization/local_player_card_runtime.cpp @@ -6,6 +6,8 @@ #include "Engine/Game/Build/20260929/engine.h" #include "Engine/Game/Build/20260929/local_player_card.h" #include "Extension/Throwdowns/native_throwdowns.h" +#include "Extension/Throwdowns/native_flag_session.h" +#include "Extension/Throwdowns/throwdown_kit.h" #include "Extension/Multiplayer/Steam/steam_social.h" namespace dingosdk::profile_runtime { @@ -211,8 +213,11 @@ bool publish_player_card(const profile::CosmeticLoadout& value) { // In-throwdown / throwdown-host flags. The throwdown Quit action does // nothing unless the first is set; the second is only for the local // player's own drop (a joined one is left, not cancelled). - const bool throwdown = multiplayer::local_throwdown_active(); - const bool host = multiplayer::local_throwdown_host(); + // A plugin match counts when it asks to be an activity (kit::match().in_activity); its + // host is whoever placed its flag. + const bool plugin_match = throwdowns::kit::match().in_activity; + const bool throwdown = multiplayer::local_throwdown_active() || plugin_match; + const bool host = multiplayer::local_throwdown_host() || (plugin_match && multiplayer::native_flag::hosting()); static bool logged_state{}; if (const auto field = n.field(model, handle, 11, UINT32_MAX, false)) n.publish(model, field, s.base + addr::engine::bool_type, &throwdown); diff --git a/Extension/Multiplayer/Hud/native_party.cpp b/Extension/Multiplayer/Hud/native_party.cpp index 6a175c17..fb9e73a0 100644 --- a/Extension/Multiplayer/Hud/native_party.cpp +++ b/Extension/Multiplayer/Hud/native_party.cpp @@ -512,6 +512,15 @@ std::string native_party_status() { auto &s = state(); std::lock_guard lock(s.status_mutex); return s.status + " Map party markers: " + std::to_string(s.map_count.load()) + "."; } +std::uint64_t native_party_player_info_by_id(std::uintptr_t manager, std::uint64_t id) noexcept { + try { + const auto published=state().published.load(std::memory_order_acquire); + if(!published || published->manager!=manager)return 0; + const auto record=published->record(id); + if(record && game::native_data().models.value(manager,record->handle,0,0))return record->handle; + } catch(...) {} + return 0; +} void set_native_party_map_markers(bool enabled) noexcept { state().map_markers.store(enabled, std::memory_order_release); } bool native_party_map_markers() noexcept { return state().map_markers.load(std::memory_order_acquire); } bool native_party_map_icon_visible(std::size_t slot) noexcept { diff --git a/Extension/Multiplayer/Hud/native_party.h b/Extension/Multiplayer/Hud/native_party.h index 7fbd0f17..064ad1d2 100644 --- a/Extension/Multiplayer/Hud/native_party.h +++ b/Extension/Multiplayer/Hud/native_party.h @@ -42,6 +42,8 @@ void spectate_party_member(std::uint64_t id) noexcept; bool party_spectating() noexcept; // Returns only a record owned by this adapter, validated against this manager. std::uint64_t native_party_player_info(std::uintptr_t manager, std::size_t slot) noexcept; +// Lookup by session identity for native activity leaderboard rows. +std::uint64_t native_party_player_info_by_id(std::uintptr_t manager, std::uint64_t id) noexcept; std::string native_party_status(); // Whether players can form parties: in a session, a lobby or a dedicated server. Outside one // the game's party buttons are refused. diff --git a/Extension/Multiplayer/Hud/native_party_hooks.cpp b/Extension/Multiplayer/Hud/native_party_hooks.cpp index 3c3e77a9..d47fdfa4 100644 --- a/Extension/Multiplayer/Hud/native_party_hooks.cpp +++ b/Extension/Multiplayer/Hud/native_party_hooks.cpp @@ -4,6 +4,7 @@ #include "Extension/Skater/client_source_spawn.h" #include "Extension/Throwdowns/native_type_scan.h" #include "Extension/Throwdowns/throwdown_relay.h" +#include "Extension/Throwdowns/throwdown_kit.h" #include "Engine/Core/Platform/memory.h" #include "follow_camera.h" #include "Extension/Customization/local_player_card_runtime.h" @@ -557,6 +558,14 @@ std::uint32_t teleport_hook(Address reason, const void *matrix, Address destinat // lip mount. While the local player waits for their S.K.A.T.E. turn, the turn start's // teleport of the local skater (id 0) lands it on foot. Kept in a static: the native // copies the options into its components, but must never see a dead buffer. + // A plugin match's countdown (kit::match().input_lock) lands the skater on the board. + if(!id && stance && dingosdk::throwdowns::kit::match().input_lock) { + static thread_local std::array on_board{}; + if(memory::peek_bytes(stance,on_board.data(),on_board.size())) { + on_board[0]=1; + return state().teleport(reason,matrix,destination,id,mode,reinterpret_cast
(on_board.data()),transition); + } + } if (!id && stance && throwdown_relay_waits_offboard()) { static thread_local std::array on_foot{}; if (memory::peek_bytes(stance, on_foot.data(), on_foot.size()) && on_foot[0]) { diff --git a/Extension/Multiplayer/Remote/native_skater.cpp b/Extension/Multiplayer/Remote/native_skater.cpp index 1e072604..5f298dee 100644 --- a/Extension/Multiplayer/Remote/native_skater.cpp +++ b/Extension/Multiplayer/Remote/native_skater.cpp @@ -1,6 +1,7 @@ #include "native_skater_internal.h" #include "native_pose_layout.h" #include "puppet_cost.h" +#include "Extension/World/world_entities.h" #include "Extension/Multiplayer/Session/monotonic_clock.h" #include "Extension/Multiplayer/Session/peer_slots.h" #include "Extension/Skater/board_pose.h" @@ -392,6 +393,7 @@ void animation_hook(std::uintptr_t component, std::uintptr_t update) { listener(component); } void destroy_hook(std::uintptr_t entity, std::uintptr_t owner) { + world_entities::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/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index bb858f17..1573a418 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -1,4 +1,7 @@ #include "session_internal.h" +#include "Extension/Throwdowns/throwdown_kit.h" +#include "Extension/Skater/skater_push.h" +#include "Extension/Throwdowns/throwdown_lab.h" #include "Engine/Core/Hooks/hooks.h" #include "Extension/Plugins/plugin_runtime.h" #include "Extension/Plugins/plugin_wire.h" @@ -358,7 +361,7 @@ void publish_party(Session &s) { // every tick, with no session too, so linked drops are removed when it ends. The input // persists: its player list is rebuilt when the admitted players change, and names // (Steam lookups) are refreshed once a second. -void relay_throwdowns(Session &s, bool in_world) { +void relay_throwdowns(Session &s, bool in_world, const NativeFrame* /*offline_skater*/ = nullptr) { auto &input = s.throwdown_input; const bool in_session = s.mode == Mode::host || s.mode == Mode::join; const auto local = in_session ? s.transport.status().local_id : 0; @@ -406,11 +409,17 @@ void relay_throwdowns(Session &s, bool in_world) { d2 = (other->position[0] - me->position[0]) * (other->position[0] - me->position[0]) + (other->position[1] - me->position[1]) * (other->position[1] - me->position[1]) + (other->position[2] - me->position[2]) * (other->position[2] - me->position[2]); + peer.position = other ? std::optional(other->position) : std::nullopt; peer.nearby = d2 < 60.f * 60.f; peer.party = party_member(s, peer.id); } - if (names) + if (names) { input.local_name = in_session ? s.transport.name(local) : std::string(steam_social_snapshot()->local.name); + if(!in_session) { + const auto card=local_profile_player_card(); + if(!card.custom_name.empty())input.local_name=card.custom_name; + } + } // Plugin messages ride in throwdown packets too, told apart by their first byte // (Extension/Plugins/plugin_wire.h); they go to the plugins, never to the relay. for (const auto &[sender, message] : s.throwdown_inbox) { @@ -619,8 +628,9 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui update_developer_board(s.base, remote_board_entity(), developer_id(p), remote_skater_generation(), p.developer_board, mark_styles(p)); present_audio(p); - update_party_position(&p.render_pose); - if (labels) label(p); + update_party_position(throwdowns::kit::hidden(p.member.id)?nullptr:&p.render_pose); + if(throwdowns::kit::hidden(p.member.id)) {update_player_ui(s.base,nullptr,{});p.ui_visible=false;} + if (labels && !throwdowns::kit::hidden(p.member.id)) label(p); return; } p.visible = false; @@ -645,7 +655,7 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui native_pass = true; p.applied_cosmetics = 0; // the new actor wears no recipe yet } - const bool hidden = throwdown_relay_hides(p.member.id); + const bool hidden = throwdown_relay_hides(p.member.id) || throwdowns::kit::hidden(p.member.id); const auto spot = hidden ? std::nullopt : throwdown_relay_celebration_offset(p.member.id, p.render_pose.root.position); p.visible = show_remote(s.base, client, local, @@ -680,9 +690,11 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui apply_cosmetics(p, spawning); // The spectate camera follows this every frame; the map and // indicators below only need network-rate updates. - update_party_position(&p.render_pose); - if (labels) label(p); - if (!p.ui_visible || now >= p.next_ui_update) { + update_party_position(throwdowns::kit::hidden(p.member.id)?nullptr:&p.render_pose); + if (labels && !throwdowns::kit::hidden(p.member.id)) label(p); + if(throwdowns::kit::hidden(p.member.id)) { + update_player_ui(s.base,nullptr,{});p.ui_visible=false; + } else if (!p.ui_visible || now >= p.next_ui_update) { update_player_ui(s.base, &p.render_pose, p.member.name); p.ui_visible = true; p.next_ui_update = now + network_tick_us; @@ -970,9 +982,19 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready, std::string_vi erase_password(s.lobby_password); expire_remote_collision(base, now_us()); if (s.mode == Mode::off) { - physics_tuning::release(base); // the player's own tuning again after a session + if (!competition_stock_physics()) physics_tuning::release(base); set_session_tuning_enforced(false); - relay_throwdowns(s, ready); + NativeFrame solo; + if(ready && !map_name.empty()) { + try {solo=capture_local(base,client,false);if(solo.ready)note_local_skater(solo.pose.root);} + catch(...) {} + } + relay_throwdowns(s, ready, &solo); + if (competition_stock_physics() && solo.ready) { + physics_tuning::prepare();physics_tuning::enforce(base,solo.entity,{}); + } else if (!competition_stock_physics()) physics_tuning::release(base); + if (solo.ready && (throwdowns::kit::match().input_lock || skater_push::holding())) + prepare_on_board_teleport(client, solo.entity); // Without a session the UI model follows at the same 10 Hz as in one; // commands still publish at once. if (const auto now = now_us(); now >= s.next_publish) { @@ -1071,9 +1093,13 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready, std::string_vi networking(s, local, receive_at); for (const auto ¬e : s.transport.take_direct_notes()) logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: {}", note); - relay_throwdowns(s, world_playing(s, local)); + // A menu or respawn can suspend the pose capture while this world and + // its Throwdown queue remain loaded. Do not cancel their coordinators. + relay_throwdowns(s, s.gameplay_ready && !s.travelling && !s.awaiting_map && s.host_world_ready); // A S.K.A.T.E. player waiting for their turn stays off the board. - update_board_lock(client, local.entity, local.ready && throwdown_relay_waits_offboard()); + update_board_lock(client, local.entity, local.ready && (throwdown_relay_waits_offboard() || throwdowns::kit::match().offboard)); + if (local.ready && (throwdowns::kit::match().input_lock || skater_push::holding())) + prepare_on_board_teleport(client, local.entity); update_physics_tuning(s, local, now); const auto send_at = now_us(); s.client_timing.record(ClientTiming::network, receive_at, send_at); diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index 7fc0429f..63007fcf 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -431,6 +431,10 @@ std::string edit_player_distance(Session &s, std::string_view argument) { : "Players within " + std::to_string(static_cast(value)) + " m are shown as skaters."; } std::string start_pose_dump(Session &s, std::string_view argument) { +#if defined(DINGOSDK_LEAN_PLAYTEST) + (void)s; (void)argument; + return "Pose recording is not included in this playtest build."; +#else int seconds{}; const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), seconds); if (parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || seconds < 1 || seconds > 600) @@ -447,6 +451,7 @@ std::string start_pose_dump(Session &s, std::string_view argument) { s.pose_dump_until = now_us() + static_cast(seconds) * 1000000; s.pose_dump_count = 0; return "Recording your poses for " + std::to_string(seconds) + " s to " + path_utf8(file) + ". Skate as you normally would."; +#endif } std::string edit_direct_connections(Session &s, std::string_view argument) { const auto enabled = parse_switch(argument, s.prefer_direct); diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index 4c2833cb..5ea156d1 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -575,6 +575,11 @@ void update_physics_tuning(Session &s, const NativeFrame &local, std::uint64_t n set_session_tuning_enforced(enforced); if (enforced && s.host_extras && !dedicated_host(s)) set_host_physics_extras(*s.host_extras); else set_host_physics_extras({}); + if (competition_stock_physics()) { + physics_tuning::prepare(); + if (world_playing(s,local)) physics_tuning::enforce(s.base,local.entity,{}); + return; + } if (s.mode == Mode::host) { physics_tuning::release(s.base); if (!s.enforce_tuning) { diff --git a/Extension/Multiplayer/Steam/steam_social.cpp b/Extension/Multiplayer/Steam/steam_social.cpp index 04286ddc..99d310bc 100644 --- a/Extension/Multiplayer/Steam/steam_social.cpp +++ b/Extension/Multiplayer/Steam/steam_social.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -24,6 +25,35 @@ std::string name(const char *value) { if (static_cast(value[i]) >= 32 && value[i] != 127) result += value[i]; return result; } +// Offline still has a Steam account identity. Resolve that account's cached +// persona, never a different account's login name or a hard-coded player name. +std::string offline_persona_name() { + const auto incoming_error=GetLastError(); + struct RestoreError { DWORD value; ~RestoreError(){SetLastError(value);} } restore{incoming_error}; + try { + wchar_t path[32768]{}; DWORD bytes=sizeof(path); + if(RegGetValueW(HKEY_CURRENT_USER,L"Software\\Valve\\Steam",L"SteamPath",RRF_RT_REG_SZ,nullptr,path,&bytes)!=ERROR_SUCCESS)return {}; + std::ifstream file(std::filesystem::path(path)/L"config"/L"loginusers.vdf"); + const auto wanted=std::to_string(launcher::offline_steam_id()); + bool account{}; + for(std::string line;std::getline(file,line);) { + std::vector tokens; + for(std::size_t i=0;i steam_social_snapshot() { next = now + 10000; SteamSocialSnapshot result; if (launcher::offline_mode()) { - result.local = {launcher::offline_steam_id(), launcher::offline_player_name, false, true}; + auto persona=offline_persona_name(); + if(persona.empty() && current->local.id==launcher::offline_steam_id() && current->local.name!=launcher::offline_player_name) + persona=current->local.name; + result.local = {launcher::offline_steam_id(), persona.empty()?launcher::offline_player_name:persona, false, true}; if (result.local != current->local) { result.revision = current->revision + 1; current = std::make_shared(std::move(result)); diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index 40ba4aad..02add6ff 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -725,6 +725,19 @@ void throwdown_codec() { ThrowdownMessage m; m.kind = kind; m.leader = leader; m.id = 5; check(decode_throwdown(encode_throwdown(m)) == m, "Throwdown control message failed to round-trip"); } + // An older client's plugin mode tag (still decoded, never sent). + ThrowdownMessage mode; + mode.kind = ThrowdownMessage::Kind::mode; mode.leader = leader; mode.id = 0x7c8bf0b2; + mode.challenge = "demo/duel"; mode.series = "duel"; mode.value = 6; + check(decode_throwdown(encode_throwdown(mode)) == mode, "Throwdown mode failed to round-trip"); + auto stock_flag = mode; stock_flag.series.clear(); + check(decode_throwdown(encode_throwdown(stock_flag)) == stock_flag, "A mode on Spot Battle's flag failed to round-trip"); + auto no_plugin = mode; no_plugin.challenge = "duel"; + check(!valid_throwdown(no_plugin), "A mode without its plugin was valid"); + auto crowded = mode; crowded.value = 11; + check(!valid_throwdown(crowded), "A mode for 11 players was valid"); + auto spaced_flag = mode; spaced_flag.series = "du el"; + check(!valid_throwdown(spaced_flag), "A flag name with a space was valid"); // Coop challenges. ThrowdownMessage challenge; challenge.kind = ThrowdownMessage::Kind::challenge_start; challenge.leader = leader; challenge.id = 3; diff --git a/Extension/Multiplayer/console_commands.cpp b/Extension/Multiplayer/console_commands.cpp index 96c7e4f0..405415d6 100644 --- a/Extension/Multiplayer/console_commands.cpp +++ b/Extension/Multiplayer/console_commands.cpp @@ -9,6 +9,7 @@ #include "Extension/Throwdowns/throwdown_relay.h" #include "Extension/UI/NativeMenu/native_menu_dump.h" #include "Extension/Assets/live_mods.h" +#include "Engine/Game/Multiplayer/session_tools.h" #include #include @@ -222,10 +223,11 @@ void register_multiplayer_commands(Commands ®istry) { auto lab_args = argument("verb_and_arguments", Type::text, true); lab_args.rest = true; lab_args.complete = [](const Model &, auto) { - return std::vector{"status", "ai", "spawn", "join", "start", "destroy", "mmid", "trace", "endturn", "score", "mirror", "attempt", "relay"}; + return std::vector{"status", "ai", "spawn", "join", "start", "solo", "destroy", "mmid", "trace", "endturn", "score", "mirror", "attempt", "relay"}; }; auto lab = action("throwdown", "Throwdown lab: add virtual participants, spawn/start/destroy queues (research aid)", Group::console, {std::move(lab_args)}); + lab.aliases={"dingo throwdown"}; lab.run = [](const Model &model, const Values &values, const Output &out) { std::optional> skater; if (model.debug.skater_position_valid) skater = model.debug.skater_position; diff --git a/Extension/Objects/ParkEditor/park_editor_runtime.cpp b/Extension/Objects/ParkEditor/park_editor_runtime.cpp index f76042b6..a35986a3 100644 --- a/Extension/Objects/ParkEditor/park_editor_runtime.cpp +++ b/Extension/Objects/ParkEditor/park_editor_runtime.cpp @@ -256,6 +256,47 @@ void move_existing(std::uint64_t entity, const profile::PlacedObject &object) { } e.transform_move(query.data(), pose.data(), source); } +struct WorldRays { + struct Ray { + std::array origin{}, end{}; + }; + std::mutex mutex; + std::map pending; + std::map answers; +}; +WorldRays &world_rays() { + static auto *value = new WorldRays; + return *value; +} +// Casts the queued outside rays on the client update, where the client physics context resolves. +void cast_world_rays() { + auto &w = world_rays(); + std::map rays; + { + std::lock_guard lock(w.mutex); + rays.swap(w.pending); + } + if (rays.empty()) + return; + auto &e = editor_state(); + auto &r = placements_runtime(); + std::array context{}; + if (r.context) + r.context(context.data()); + const auto world = e.surface_api.ready && context[0] ? e.surface_api.world(context[0]) : 0; + const auto now = GetTickCount64(); + for (const auto &[id, ray] : rays) { + WorldRayHit answer{true, false, {}, now}; + answer.available = world != 0; + if (world) + if (const auto hit = editor::cast_surface(e.surface_api, world, ray.origin, ray.end)) { + answer.hit = true; + answer.at = *hit; + } + std::lock_guard lock(w.mutex); + w.answers[id] = answer; + } +} } // namespace void initialize_park_editor(const std::filesystem::path &directory) { auto &e = editor_state(); @@ -289,6 +330,12 @@ void set_park_mods_root(const std::filesystem::path &data_root) { } void reset_park_editor() { auto &e = editor_state(); + { + auto &w = world_rays(); + std::lock_guard lock(w.mutex); + w.pending.clear(); + w.answers.clear(); + } ++e.generation; ++e.revision; e.transaction.reset(); @@ -634,12 +681,46 @@ void update_park_editor() { } // namespace dingosdk::profile_runtime namespace dingosdk { using namespace profile_runtime; +void queue_world_ray(std::uint32_t id, const std::array &origin, const std::array &direction, float length) { + for (int i = 0; i < 3; ++i) + if (!std::isfinite(origin[i]) || !std::isfinite(direction[i]) || std::abs(origin[i]) > 100000) + return; + const float size = std::sqrt(direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2]); + if (size < 0.5f || !(length > 0) || length > 2000) + return; + std::array end{}; + for (int i = 0; i < 3; ++i) + end[i] = origin[i] + direction[i] / size * length; + auto &w = world_rays(); + std::lock_guard lock(w.mutex); + w.pending[id] = {origin, end}; +} +WorldRayHit world_ray(std::uint32_t id) { + auto &w = world_rays(); + std::lock_guard lock(w.mutex); + const auto it = w.answers.find(id); + return it == w.answers.end() ? WorldRayHit{} : it->second; +} +void tick_local_world_rays() noexcept { + PreserveError preserve; + try { + std::lock_guard lock(local_runtime().native_mutex); + if (!local_runtime().active || cosmetic_runtime().update_thread != GetCurrentThreadId()) + return; + cast_world_rays(); + } catch (...) { + // A mode's optional aim query must not fail or disable park editing. + } +} void tick_local_park_editor() noexcept { PreserveError preserve; try { std::lock_guard lock(local_runtime().native_mutex); if (!local_runtime().active || cosmetic_runtime().update_thread != GetCurrentThreadId()) return; + // Mode placement uses the client physics world even before the park + // placement store/server manager becomes ready. + cast_world_rays(); update_placement_restore(); } catch (...) { std::lock_guard lock(local_runtime().native_mutex); diff --git a/Extension/Objects/ParkEditor/park_editor_runtime.h b/Extension/Objects/ParkEditor/park_editor_runtime.h index 6facc2c2..092313ea 100644 --- a/Extension/Objects/ParkEditor/park_editor_runtime.h +++ b/Extension/Objects/ParkEditor/park_editor_runtime.h @@ -32,6 +32,19 @@ bool queue_local_park_paste(const EditorPasteRequest &); bool queue_local_park_object_set(const EditorObjectSetRequest &); bool queue_local_park_delete(const EditorDeleteRequest &); void tick_local_park_editor() noexcept; +struct WorldRayHit { + bool cast{}, hit{}; + std::array at{}; + std::uint64_t when{}; // GetTickCount64 of the cast + bool available{}; // Native client collision API and world were ready. +}; +void queue_world_ray(std::uint32_t id, const std::array &origin, const std::array &direction, float length); +WorldRayHit world_ray(std::uint32_t id); +// Client collision queries used by mode placement do not require the park +// editor, its placement store, or the server object manager to be enabled. +// Runs only on the verified local client update thread. +void tick_local_world_rays() noexcept; + // Invoked through the console's verified game-thread dispatcher. `texts` // carries a park mod's name, author, version and description (mod-create: // all four; mod-details: the same after the folder in `argument`). diff --git a/Extension/Objects/local_placements_runtime.cpp b/Extension/Objects/local_placements_runtime.cpp index 7fd21167..2935db76 100644 --- a/Extension/Objects/local_placements_runtime.cpp +++ b/Extension/Objects/local_placements_runtime.cpp @@ -630,18 +630,18 @@ void update_position_teleport() { if (!r.teleport || !r.transition_ctor || !r.transition_destroy || !placement_client_channel() || !read(local_runtime().base + addr::local_placements::teleport_manager, manager) || !manager || !read(manager, state) || state != 0) return; - const auto at = *r.position_teleport; - const auto heading = r.position_teleport_heading; // set only by teleport_local_skater_heading + alignas(16) const auto transform = *r.position_teleport; + const std::array at{transform[12],transform[13],transform[14]}; r.position_teleport.reset(); - r.position_teleport_heading.reset(); - // Upright at the spot. The native teleport manager handles streaming, ground checks and physics reset. - // With a heading h the root's rows are right (cos h, 0, -sin h), up, forward (sin h, 0, cos h): - // h = 0 is the identity, facing +Z (measured: the skater faces h within 0.002 degrees). - const float sh = heading ? std::sin(*heading) : 0.0f, ch = heading ? std::cos(*heading) : 1.0f; - alignas(16) const std::array transform{ch,0,-sh,0, 0,1,0,0, sh,0,ch,0, at[0], at[1], at[2], 1}; + // Preserve the native flag's complete facing basis. The teleport manager + // still handles streaming, ground checks and physics reset. alignas(16) std::array transition{}; r.transition_ctor(transition.data()); struct Destroy { decltype(r.transition_destroy) call; void* value; ~Destroy() { call(value); } } destroy{r.transition_destroy, transition.data()}; + // The local teleport manager works in offline play. RequestTeleport's + // social/player lookup can reject the same local skater with UINT32_MAX. + // The session tick prepares the validated skater's native board option + // before this queued move, so this path also starts a plugin's match on the board. const auto request = r.teleport(manager, transform.data(), 1, transition.data()); dingosdk::logging::event(dingosdk::logging::Channel::objects, dingosdk::Json{{"event", "position_teleport_requested"}, {"x", at[0]}, {"y", at[1]}, {"z", at[2]}, {"request", request}}.dump().c_str()); diff --git a/Extension/Objects/local_placements_runtime.h b/Extension/Objects/local_placements_runtime.h index 197c8faa..79820b94 100644 --- a/Extension/Objects/local_placements_runtime.h +++ b/Extension/Objects/local_placements_runtime.h @@ -76,11 +76,10 @@ struct PlacementsRuntime { std::vector rows; std::uint64_t next_token{1}, teleport_token{}, teleport_queued_at{}; // A teleport of the local skater to a position (console tp, the host's tpall). - std::optional> position_teleport; + std::optional> position_teleport; std::uint64_t position_teleport_at{}; // The heading the pending position teleport faces (teleport_local_skater_heading), radians about // world up; unset means the identity rotation the other teleports use (facing +Z). - std::optional position_teleport_heading; std::optional inflight; std::uint64_t sent_at{}, next_poll{}, map_since{}, map_generation{}, next_id{1}; bool failed{}, restore_failed{}; diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 4180fee5..c0b206b4 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -4,6 +4,9 @@ #include "Extension/Plugins/plugin_host.h" #include "Extension/Plugins/plugin_manifest.h" #include "Extension/Plugins/plugin_wire.h" +#include "Extension/Throwdowns/challenge_registry.h" +#include "Extension/Throwdowns/custom_modes.h" +#include "Extension/Throwdowns/throwdown_kit.h" #include "Engine/Vfs/mod_list.h" #include #include @@ -58,6 +61,30 @@ struct FakeGame final : Game { } std::string board_hide(std::string_view plugin, int bone) override { calls.push_back(std::format("board hide {} {}", plugin, bone)); return {}; } void board_park(std::string_view plugin, std::vector bones) override { calls.push_back(std::format("board park {} {}", plugin, bones.size())); } + // The general services a game layer in Lua reads and drives (plugin_api_services.cpp). + std::optional telemetry; + std::optional skater_telemetry() override { return telemetry; } + std::uint64_t world_id() override { return 77; } + std::string world_map() override { return "test_map"; } + std::string pad_flight(std::string_view plugin, bool on) override { + calls.push_back(std::format("pad flight {} {}", plugin, on)); + return {}; + } + void competition(std::string_view plugin, bool on) override { calls.push_back(std::format("competition {} {}", plugin, on)); } + bool stock_physics_ready() override { return true; } + void prepare_physics() override { calls.push_back("prepare physics"); } + void trick_feed(std::string_view plugin, bool on) override { calls.push_back(std::format("trick feed {} {}", plugin, on)); } + std::vector> trick_gesture(std::string_view trick) override { + if (trick == "Kickflip") return {{0, 0}, {0.5f, 1}}; + return {}; + } + std::string bone_cam(std::string_view plugin, const std::vector& words) override { + std::string line = "bone cam " + std::string(plugin); + for (const auto& w : words) line += " " + w; + calls.push_back(line); + if (words.empty()) return "game"; + return words.front() == "ringing" ? "1" : ""; + } std::string board_toss(std::string_view plugin, std::vector bones, const Vec3& velocity, const Vec3&, float ground) override { calls.push_back(std::format("board toss {} {} {:.0f} {:.0f}", plugin, bones.size(), velocity[1], ground)); return {}; @@ -102,6 +129,24 @@ struct FakeGame final : Game { calls.push_back(std::format("{} {} {} {:.1f}", op, o.id, o.item, o.position[0])); return {}; } + // World objects as the fake keeps them: by plugin and id, ready at once unless hidden. + std::map, std::pair>> entities; + std::string entity_set(std::string_view plugin, std::uint32_t id, std::string_view asset, const std::array& m, bool hidden) override { + if (entities.size() >= 600) return "too many"; + entities[{std::string(plugin), id}] = {std::string(asset) + (hidden ? " hidden" : ""), m}; + calls.push_back(std::format("entity {} {} {} {:.2f} {:.2f} {:.2f} {:.2f}{}", plugin, id, asset, m[12], m[13], m[14], m[8], hidden ? " hidden" : "")); + return {}; + } + void entity_remove(std::string_view plugin, std::uint32_t id) override { entities.erase({std::string(plugin), id}); } + void entity_clear(std::string_view plugin) override { + std::erase_if(entities, [&](const auto& e) { return e.first.first == plugin; }); + calls.push_back("entities clear " + std::string(plugin)); + } + int entity_status(std::string_view plugin, std::uint32_t id) override { + const auto found = entities.find({std::string(plugin), id}); + return found == entities.end() ? 0 : found->second.first.ends_with(" hidden") ? 5 : 4; + } + bool entities_ready(std::string_view) override { return true; } std::string set_environment(std::string_view kind, std::string_view key, std::string_view value) override { calls.push_back(std::format("env {} {} {}", kind, key, value)); return key == "never" ? "refused" : ""; @@ -181,16 +226,85 @@ struct FakeGame final : Game { } std::string throwdown_status(std::string_view) override { return "open"; } std::vector modes; - std::vector mode_news; std::string set_throwdown_mode(std::string_view plugin, const ThrowdownMode& mode) override { - calls.push_back(std::format("mode {} {} {} rows {}{}", plugin, mode.id, mode.name, mode.rows.size(), mode.timer ? " timer" : "")); + calls.push_back(std::format("mode {} {} {}", plugin, mode.id, mode.name)); std::erase_if(modes, [&](const auto& m) { return m.id == mode.id; }); modes.push_back(mode); return {}; } void remove_throwdown_mode(std::string_view plugin, std::string_view id) override { calls.push_back(std::format("remove mode {} {}", plugin, id)); } - void release_throwdown_flag(std::string_view plugin) override { calls.push_back(std::format("release flag {}", plugin)); } - std::vector take_throwdown_news(std::string_view) override { return std::exchange(mode_news, {}); } + // The throwdown kit: documents go to the real store (so the tests read them parsed); the flag is + // the fake's own (kit::flag is the game's). + int kit_documents{}; + std::string publish_throwdown_ui(std::string_view plugin, const dingosdk::Json& document) override { + ++kit_documents; + return dingosdk::throwdowns::kit::publish(plugin, document); + } + dingosdk::throwdowns::kit::flag::State flag; + bool throwdown_flag_begin(std::string_view mode, std::uint32_t game, const dingosdk::throwdowns::kit::flag::Options& o) override { + calls.push_back(std::format("flag begin {} {} {} {} {} {:.1f}", mode, game, o.players, o.seconds, o.setup_page, o.cursor_height)); + if (flag.busy) return false; + flag = {true, false, true, true, std::string(mode), game}; + return true; + } + dingosdk::throwdowns::kit::flag::State throwdown_flag_state() override { return flag; } + void throwdown_flag_leave(std::string_view plugin) override { + calls.push_back(std::format("flag leave {}", plugin)); + if (flag.mode.starts_with(std::string(plugin) + "/")) flag = {}; + } + // A plugin's data folder, as a map by "/". + std::map files; + std::string files_list(std::string_view plugin, std::string_view folder, std::vector& names) override { + const auto prefix = std::string(plugin) + "/" + (folder.empty() ? "" : std::string(folder) + "/"); + for (const auto& [path, text] : files) { + if (!path.starts_with(prefix)) continue; + auto rest = path.substr(prefix.size()); + if (const auto slash = rest.find('/'); slash != std::string::npos) rest = rest.substr(0, slash + 1); + if (std::find(names.begin(), names.end(), rest) == names.end()) names.push_back(rest); + } + return {}; + } + std::string files_read(std::string_view plugin, std::string_view path, std::string& text) override { + const auto found = files.find(std::string(plugin) + "/" + std::string(path)); + if (found == files.end()) return "not found"; + text = found->second; + return {}; + } + std::string files_write(std::string_view plugin, std::string_view path, std::string_view text) override { + files[std::string(plugin) + "/" + std::string(path)] = std::string(text); + return {}; + } + std::string files_remove(std::string_view plugin, std::string_view path) override { + return files.erase(std::string(plugin) + "/" + std::string(path)) ? std::string{} : std::string("not found"); + } + std::optional dialog; + std::string dialog_open(std::string_view plugin, const DialogSpec& spec) override { + calls.push_back(std::format("dialog {} {} rows {} buttons {}", plugin, spec.id, spec.rows.size(), spec.buttons.size())); + dialog = spec; + return {}; + } + // SmokeyDawg58's editor reads the pad and the window's focus. + std::optional pad_bits_now; + InputFocus focus_now{}; + std::optional pad_bits(bool) override { return pad_bits_now; } + InputFocus input_focus() override { return focus_now; } + void dialog_close(std::string_view plugin, std::string_view id) override { calls.push_back(std::format("dialog close {} {}", plugin, id)); } + std::string skater_push(std::string_view plugin, const Vec3& v) override { + calls.push_back(std::format("push {} {:.0f} {:.0f} {:.0f}", plugin, v[0], v[1], v[2])); + return {}; + } + std::string skater_push_wait(std::string_view plugin) override { + calls.push_back(std::format("push wait {}", plugin)); + return {}; + } + std::string skater_push_reset(std::string_view plugin) override { + calls.push_back(std::format("push reset {}", plugin)); + return {}; + } + std::string skater_hold(std::string_view plugin, bool on) override { + calls.push_back(std::format("hold {} {}", plugin, on)); + return {}; + } std::string kick(std::uint64_t player, std::string_view why) override { calls.push_back(std::format("kick {} {}", player, why)); return {}; } std::string announce(std::string_view plugin, std::string_view text) override { calls.push_back(std::format("announce {} {}", plugin, text)); @@ -217,7 +331,7 @@ struct FakeGame final : Game { return {}; } std::vector> take_presses() override { return std::exchange(presses, {}); } - void release(std::string_view plugin) override { released.emplace_back(plugin); } + void release(std::string_view plugin) override { released.emplace_back(plugin); entity_clear(plugin); } bool called(std::string_view part) const { return std::any_of(calls.begin(), calls.end(), [&](const auto& c) { return c.find(part) != std::string::npos; }); } @@ -412,6 +526,10 @@ void a_permission_must_be_declared() { assert(not ok and err:find("needs the \"console\" permission"), err) ok, err = pcall(reskate.storage.get, "x") assert(not ok and err:find("needs the \"storage\" permission"), err) + -- A mod's variants are read freely, but choosing one (which loads the level again) is asked for. + assert(reskate.package.variants() == nil, "a mod without variants has none") + ok, err = pcall(reskate.package.choose, "wide") + assert(not ok and err:find("needs the \"variants\" permission"), err) reskate.console.print("never") )")}); stopped(host, "bare", "raised an error"); @@ -784,6 +902,42 @@ void plugins_draw_pictures() { "and in the world, so many metres tall, a part of it"); } +void plugins_place_world_entities() { + TestEnvironment environment; + auto& game = environment.fake; + Host host(environment, Side::client); + const std::string main = R"( + local gate = "World/Props/Gate_Arch" + assert(reskate.entities.set(1, gate, {position = vector.create(1, 2, 3), yaw = 90, scale = 2})) + assert(reskate.entities.set(2, gate, {position = vector.create(0, 0, 0), right = vector.create(1, 0, 0), + up = vector.create(0, 1, 0), forward = vector.create(0, 0, 3)})) + assert(reskate.entities.set(3, gate, {matrix = {1,0,0,0, 0,1,0,0, 0,0,1,0, 7,8,9,1}}, true)) + assert(reskate.entities.status(1) == "ready" and reskate.entities.status(3) == "hidden" and reskate.entities.status(9) == nil) + assert(not pcall(reskate.entities.set, 0, gate, {position = vector.create(0, 0, 0)}), "ids start at 1") + assert(not pcall(reskate.entities.set, 4, gate, {position = vector.create(0 / 0, 0, 0)}), "a finite position") + assert(not pcall(reskate.entities.set, 4, gate, {matrix = {1, 2, 3}}), "16 numbers") + reskate.entities.remove(2) + assert(reskate.entities.status(2) == nil and reskate.entities.ready()) + reskate.log("placed") + )"; + host.apply({package("props", {{manifest_file, manifest(R"("world.entities")")}, {"plugin/main.lua", main}})}); + ticks(host, environment, 1); + running(host, "props", environment); + check(environment.logged("[props] placed"), "A plugin places world objects\n" + environment.all()); + // Yaw 90: forward is +x (sin, 0, cos), scaled 2; the axes and the matrix come as given. + check(game.called("entity props 1 World/Props/Gate_Arch 1.00 2.00 3.00 2.00"), "a yaw and a scale\n" + environment.all()); + check(game.called("entity props 2 World/Props/Gate_Arch 0.00 0.00 0.00 0.00"), "its own axes"); + check(game.called("entity props 3 World/Props/Gate_Arch 7.00 8.00 9.00 0.00 hidden"), "a matrix, hidden"); + host.apply({}); + check(game.called("entities clear props"), "A plugin's objects go with it"); + // Without the permission, refused. + host.apply({package("sneaky", {{manifest_file, manifest()}, {"plugin/main.lua", R"( + reskate.log(tostring(pcall(reskate.entities.set, 1, "x", {position = vector.create(0, 0, 0)}))) + )"}})}); + ticks(host, environment, 1); + check(environment.logged("[sneaky] false"), "world.entities is needed\n" + environment.all()); +} + void plugins_draw_on_the_overlay() { TestEnvironment environment; Host host(environment, Side::client); @@ -1746,39 +1900,463 @@ void plugins_add_throwdown_modes() { auto& game = environment.fake; Host host(environment, Side::client); host.apply({script("modes", R"( - assert(reskate.throwdowns.mode{id = "oneup", name = "1-UP", description = "Beat the last total.", art = "OneUp", flag = "oneup", - players = {min = 1, max = 6, default = 2}, timer = {caption = "Turn", min = 10, max = 120, step = 10, default = 20}, - rows = {{id = "letters", caption = "Letters", min = 1, max = 7, default = 3}, - {id = "tricks", kind = "choice", caption = "Tricks", options = {"All", "Flips"}, default = 2}, - {id = "area", kind = "button", caption = "Area", text = "Set area"}}}) - assert(not pcall(reskate.throwdowns.mode, {id = "x", name = "X", rows = {{id = "r", kind = "dial"}}}), "a row kind") + assert(reskate.throwdowns.mode{id = "duel", name = "Duel", description = "Beat the last total.", art = "Duel", flag = "duel", replaces = "skate", + players = {min = 1, max = 6, default = 2}, cursor_height = 99999}) assert(not pcall(reskate.throwdowns.mode, {id = "x", name = "X", flag = "two words"}), "a flag name") - reskate.on("throwdown_mode", function(e) - reskate.log(e.mode .. " " .. e.action .. " " .. tostring(e.settings.players) .. " " .. tostring(e.settings.tricks) - .. (e.position and string.format(" at %.0f %.0f %.0f facing %.0f", e.position[1], e.position[2], e.position[3], e.heading) or "")) - if e.action == "start" then reskate.throwdowns.release_flag() end - end) - reskate.console.command("tdgone", "Removes the mode", function() reskate.throwdowns.remove_mode("oneup") end) + assert(not pcall(reskate.throwdowns.mode, {id = "x", name = "X", replaces = "bowling"}), "a stock throwdown") + assert(not pcall(reskate.throwdowns.mode, {id = "x", name = "X", cursor_height = 200000}), "a cursor height") + assert(reskate.throwdowns.release_flag == nil and reskate.throwdowns.view == nil and reskate.throwdowns.one_up_view == nil, + "the old flag and view calls are gone") + assert(not pcall(reskate.on, "throwdown_mode", function() end), "and the old news event") + reskate.console.command("tdgone", "Removes the mode", function() reskate.throwdowns.remove_mode("duel") end) )", R"("throwdowns", "console")")}); running(host, "modes", environment); - check(game.called("mode modes oneup 1-UP rows 3 timer") && game.modes.size() == 1, "The mode reaches the game\n" + environment.all()); + check(game.called("mode modes duel Duel") && game.modes.size() == 1, "The mode reaches the game\n" + environment.all()); const auto& mode = game.modes.front(); - check(mode.players == 2 && mode.min_players == 1 && mode.max_players == 6 && mode.flag == "oneup" && mode.art == "OneUp", - "with its players, flag and art"); - check(mode.timer && mode.timer->caption == "Turn" && mode.timer->step == 10 && mode.timer->value == 20, "its timer"); - check(mode.rows[0].kind == "slider" && mode.rows[0].maximum == 7 && mode.rows[0].value == 3, "a slider row"); - check(mode.rows[1].kind == "choice" && mode.rows[1].options.size() == 2 && mode.rows[1].value == 1, "a choice, counted from 0"); - check(mode.rows[2].kind == "button" && mode.rows[2].text == "Set area", "a button"); - game.mode_news.push_back({"oneup", "placed", "", 0, {{"players", 2}, {"tricks", 1}}, Vec3{1, 2, 3}, 90}); - game.mode_news.push_back({"oneup", "start", "", 0, {{"players", 2}, {"tricks", 1}}}); - ticks(host, environment, 1); - check(environment.logged("[modes] oneup placed 2 1 at 1 2 3 facing 90"), "The flag's place reaches the plugin\n" + environment.all()); - check(environment.logged("[modes] oneup start 2 1") && game.called("release flag modes"), "and its Start, and it takes the flag down"); - check(host.run_console("tdgone") && game.called("remove mode modes oneup"), "A mode can go"); + check(mode.players == 2 && mode.min_players == 1 && mode.max_players == 6 && mode.flag == "duel" && mode.art == "Duel" && mode.replaces == "skate" && + mode.cursor_height == 99999, + "with its players, flag, art and cursor height"); + check(host.run_console("tdgone") && game.called("remove mode modes duel"), "A mode can go"); host.apply({contributed("throwdown-modes-test")}); running(host, "throwdown-modes-test", environment); - check(game.called("mode throwdown-modes-test oneup 1-UP rows 0 timer") && game.called("mode throwdown-modes-test race Deathrace rows 3 timer"), + check(game.called("mode throwdown-modes-test duel Duel") && game.called("mode throwdown-modes-test gates Gates"), "The test plugin's two modes register\n" + environment.all()); + // Its card clicked: it publishes the setup page, then its waiting card once the flag is down. + namespace kit = dingosdk::throwdowns::kit; + kit::post("throwdown-modes-test/gates", "open", {"9"}); + ticks(host, environment, 1); + const auto setup = kit::documents_for("throwdown-modes-test/gates"); + check(setup && setup->setup && setup->setup->rows.size() == 3 && setup->setup->timer.present, "Its setup page\n" + environment.all()); + kit::post("throwdown-modes-test/gates", "flag_placed", {"9", "1", "2", "3", "0"}); + ticks(host, environment, 1); + const auto waiting = kit::documents_for("throwdown-modes-test/gates"); + check(waiting && !waiting->setup && waiting->waiting && waiting->waiting->can_start, "Its waiting card\n" + environment.all()); + kit::post("throwdown-modes-test/gates", "start", {"9"}); + ticks(host, environment, 1); + check(game.called("flag leave throwdown-modes-test"), "Start lets the flag go"); +} + +// The throwdown kit: a plugin registers a mode, publishes its documents (parsed into the store the +// native UI reads), hears what the native UI posts, holds the native flag, keeps data files, opens a +// dialog; and everything goes when it stops. +void plugins_drive_the_throwdown_kit() { + namespace kit = dingosdk::throwdowns::kit; + TestEnvironment environment; + auto& game = environment.fake; + Host host(environment, Side::client); + host.apply({script("kit", R"( + assert(reskate.throwdowns.mode{id = "lap", name = "Lap", description = "Through the gates.", card = "spot", order = 2, + flag = "World/ReSkate/Throwdowns/Flags/lap_Placement", color = 0xff3434ff, players = {min = 1, max = 6, default = 4}}) + assert(not pcall(reskate.throwdowns.mode, {id = "x", name = "X", card = "bowling"}), "a stock card") + assert(reskate.throwdowns.ui{ + setup = {mode = "lap", title = "LAP", players = {caption = "Players", min = 1, max = 6, step = 1, value = 4}, + timer = {caption = "Finish deadline", min = 30, max = 600, step = 30, value = 180}, + rows = {{id = "laps", kind = "choice", caption = "Laps", options = {"1", "2", "3"}, value = 2}, + {id = "area", kind = "button", caption = "Area", label = "Set optional area"}}}, + waiting = {mode = "lap", title = "LAP", status = "Waiting for host", enrolled = 1, capacity = 6, can_start = true}, + hud = {theme = "lap", layout = "side", counters = {{text = "LEADER 1,200"}}, + clock = {remaining_ms = 60000, total_ms = 90000, sampled_ms = 5, ticking = true}, + board = {title = "LEADERBOARD", columns = {"POS", "NAME", "SCORE"}, + rows = {{rank = 1, name = "Me", value = 1200, self = true, marks = {{text = "S", on = true}}}}}, + timers = {{caption = "LAP", text = {base_ms = 1500, sampled_ms = 5, running = true, cap_ms = 600000}}}, + markers = {{at = vector.create(1, 2, 3), text = "1", distance = true}}}, + panel = {title = "LAP", settings = {{icon = "players", caption = "Players", value = "4"}}, + prompts = {{input = "UI_ShoulderL", caption = "Area", command = "area"}}, players = {{id = "76561198000000001", name = "Me", self = true}}}, + flags = {{id = "f1", mode = "lap", leader = "76561198000000001", game = 7, at = vector.create(1, 2, 3), yaw = 90, + stats = {{title = "LAPS", value = "2"}}}}, + match = {teleports = true, hidden = {"76561198000000002"}}, + }) + assert(not pcall(reskate.throwdowns.ui, "text"), "a document is a table") + reskate.on("throwdown_ui", function(e) + reskate.log("ui " .. e.action .. " " .. e.mode .. " " .. table.concat(e.args, ",")) + if e.action == "open" then + assert(reskate.throwdowns.flag.begin("lap", tonumber(e.args[1]), {players = 4, seconds = 90})) + local state = reskate.throwdowns.flag.state() + assert(state.mine and state.mode == "lap" and state.game == tonumber(e.args[1]) and state.placing, "the flag is the plugin's") + elseif e.action == "leave" then + reskate.throwdowns.flag.leave() + end + end) + assert(reskate.files.write("routes/a.json", "{\"gates\": 3}")) + assert(reskate.files.read("routes/a.json") == "{\"gates\": 3}") + local names = reskate.files.list("routes") + assert(#names == 1 and names[1] == "a.json", "the folder lists its file") + assert(reskate.files.list()[1] == "routes/", "and the top its folder") + assert(not pcall(reskate.files.read, "../escape.json"), "no way out") + assert(not pcall(reskate.files.write, "C:/escape.json", ""), "no drive") + assert(not pcall(reskate.files.write, "routes/con.json", ""), "no device") + assert(reskate.files.write("old.json", "x") and reskate.files.remove("old.json")) + local missing, why = reskate.files.read("old.json") + assert(missing == nil and why == "not found", "a removed file is gone") + assert(reskate.ui.dialog("save", {title = "SAVE ROUTE", field = {text = "My route", max = 32}, + list = {rows = {{text = "Route A", sub = "3 gates", buttons = {"rename"}}}, empty = "No routes"}, + buttons = {"Save", {id = "cancel", label = "Cancel"}}, file = {label = "Load .json", filter = "*.json"}})) + assert(not pcall(reskate.ui.dialog, "x", {buttons = {"1", "2", "3", "4", "5"}}), "at most 4 buttons") + assert(reskate.skater.push(vector.create(1, 0, 2))) + assert(reskate.skater.push_wait() and reskate.skater.push_reset()) + reskate.skater.hold(true) + assert(reskate.modes == nil, "reskate.modes is gone") + -- The general services: physics, the session's world, the trick feed and flick diagrams, + -- the pad flight and the Bone Cam. + reskate.physics.competition(true) + assert(reskate.physics.stock_ready()) + reskate.physics.prepare() + local world = reskate.session.world() + assert(world.world == "77" and world.map == "test_map" and world.barred == false, "the session's world") + reskate.tricks.feed(true) + assert(#reskate.tricks.gesture("Kickflip") == 2 and #reskate.tricks.gesture("Boardslide") == 0, "flick diagrams") + assert(reskate.camera.pad_flight(true)) + assert(reskate.skater.bone_cam() == "game" and reskate.skater.bone_cam("ringing") == "1") + reskate.skater.bone_cam("game", "on") + )", R"("throwdowns", "files", "ui.screen", "skater.control", "session.read", "events.tricks", "camera")")}); + running(host, "kit", environment); + check(game.called("mode kit lap Lap") && game.modes.size() == 1, "The mode reaches the game\n" + environment.all()); + const auto& mode = game.modes.front(); + check(mode.card == "spot" && mode.order == 2 && mode.flag == "World/ReSkate/Throwdowns/Flags/lap_Placement" && mode.color == 0xff3434ffU, + "with its card, order, flag prefab and colour"); + const auto documents = kit::documents_for("kit/lap"); + check(documents && documents->plugin == "kit" && game.kit_documents == 1, "Its documents are in the kit's store"); + const auto& setup = *documents->setup; + check(setup.mode == "kit/lap" && setup.title == "LAP" && setup.players.present && setup.players.value == 4 && setup.timer.present && + setup.timer.value == 180 && setup.hide_native == std::vector{"rounds"}, + "Its setup page, its mode the SDK's id"); + check(setup.rows.size() == 2 && setup.rows[0].kind == "choice" && setup.rows[0].selected == 1 && setup.rows[1].kind == "button" && + setup.rows[1].label == "Set optional area", + "Its setup rows, a choice counted from 0"); + check(documents->waiting && documents->waiting->mode == "kit/lap" && documents->waiting->can_start && documents->waiting->capacity == 6, "Its waiting card"); + const auto& hud = documents->hud; + check(hud.active && hud.theme == "lap" && hud.counters == std::vector{"LEADER 1,200"} && hud.clock.present && hud.clock.remaining_ms == 60000, + "Its HUD"); + check(hud.board.present && hud.board.rows.size() == 1 && hud.board.rows[0].rank == "1" && hud.board.rows[0].value == "1200" && hud.board.rows[0].self && + hud.board.rows[0].marks.size() == 1 && hud.board.rows[0].marks[0].on && hud.board.columns.size() == 3, + "Its board, numbers as labels"); + check(hud.timers.size() == 1 && hud.timers[0].clock.present && hud.timers[0].clock.up && hud.timers[0].clock.remaining_ms == 1500 && + hud.timers[0].clock.total_ms == 600000 && hud.markers.size() == 1 && hud.markers[0].at[2] == 3, + "A stopwatch timer and a marker"); + check(documents->panel.present && documents->panel.prompts.size() == 1 && documents->panel.prompts[0].glyph == "area" && + documents->panel.players.size() == 1 && documents->panel.players[0].id == 76561198000000001ULL, + "Its match panel"); + check(documents->flags.size() == 1 && documents->flags[0].mode == "kit/lap" && documents->flags[0].leader == 76561198000000001ULL && + documents->flags[0].game == 7 && documents->flags[0].at[0] == 1 && documents->flags[0].stats.size() == 1, + "Its flags"); + check(kit::match().teleports && kit::hidden(76561198000000002ULL) && !kit::hidden(76561198000000001ULL) && kit::active() && kit::active()->plugin == "kit", + "What its match asks of the game"); + check(game.files.contains("kit/routes/a.json") && !game.files.contains("kit/old.json"), "Its files are in its own folder"); + check(game.called("dialog kit save rows 1 buttons 2") && game.dialog && game.dialog->field.text == "My route" && game.dialog->file.filter == "*.json" && + game.dialog->buttons[1].id == "cancel" && game.dialog->rows[0].buttons[0] == "rename", + "Its dialog"); + check(game.called("push kit 1 0 2") && game.called("push wait kit") && game.called("push reset kit") && game.called("hold kit true"), + "Push, its wait and reset, and hold reach the game"); + check(game.called("competition kit true") && game.called("prepare physics") && game.called("trick feed kit true") && + game.called("pad flight kit true") && game.called("bone cam kit game on"), + "The general services reach the game\n" + environment.all()); + // The native UI: a card clicked, a dialog answered. + kit::post("kit/lap", "open", {"7"}); + kit::post_to("kit", "dialog", "", {"save", "Save", "My route", "0", ""}); + kit::post("other/lap", "open", {"8"}); + ticks(host, environment, 1); + check(environment.logged("[kit] ui open lap 7") && game.called("flag begin kit/lap 7 4 90 true 0.0"), + "A card clicked reaches the plugin by its own id, which takes the flag\n" + environment.all()); + check(environment.logged("[kit] ui dialog save,Save,My route,0,"), "A dialog's answer reaches it\n" + environment.all()); + check(!environment.logged("ui open lap 8") && kit::take("other").size() == 1, "Another plugin's events stay its own"); + kit::post("kit/lap", "leave"); + ticks(host, environment, 1); + check(game.called("flag leave kit") && !game.flag.busy, "Leave lets the flag go"); + host.switch_off("kit"); + check(!kit::documents_for("kit/lap") && !kit::match().teleports && !kit::hidden(76561198000000002ULL), "Its documents go when it stops"); +} + +// The Throwdowns page's cards: the stock three (one replaced) and the plugin modes by order. +void the_throwdowns_page_lists_the_stock_cards_and_plugin_modes() { + namespace custom = dingosdk::multiplayer::custom_throwdowns; + using dingosdk::throwdowns::Adapter; + custom::Mode skate; + skate.plugin = "p"; + skate.id = "skate"; + skate.name = "Letters"; + skate.card = "skate"; + skate.replaces = "skate"; + custom::Mode late = skate; + late.id = "late"; + late.name = "Late"; + late.card = "jam"; + late.replaces.clear(); + late.order = 20; + late.color = 0xff00ff00U; + custom::Mode early = late; + early.id = "early"; + early.name = "Early"; + early.card = "spot"; + early.order = 10; + early.color = 0; + check(custom::set_mode(skate).empty() && custom::set_mode(late).empty() && custom::set_mode(early).empty(), "The modes register"); + check(!custom::set_mode([&] { auto bad = early; bad.card = "bowling"; return bad; }()).empty(), "A card is a stock one"); + const auto& registry = dingosdk::throwdowns::registry(); + std::vector ids; + for (const auto& d : registry.definitions()) ids.push_back(d.id); + check(ids == std::vector{"p/skate", "native.spot", "native.jam", "p/early", "p/late"}, "In the grid's order: " + [&] { + std::string all; + for (const auto& id : ids) all += id + " "; + return all; + }()); + const auto* late_card = registry.find("p/late"); + check(late_card && late_card->adapter == Adapter::plugin && late_card->donor == 2 && late_card->palette.primary == 0xff00ff00U && late_card->plugin == "p", + "A plugin card: its donor and colour"); + check(registry.find("p/early")->palette.primary == dingosdk::throwdowns::palette_for("p/early").primary, "a colour picked from its id"); + check(registry.find("native.spot")->donor == 1 && registry.find("native.jam")->adapter == Adapter::native_jam, "The stock cards"); + const auto before = registry.revision(); + custom::remove_plugin("p"); + const auto& again = dingosdk::throwdowns::registry(); + check(again.revision() > before && again.definitions().size() == 3 && again.definitions()[0].id == "native.skate", "S.K.A.T.E. is back when it goes"); +} + +void smokey_throwdowns_register_and_play_one_up() { + namespace kit = dingosdk::throwdowns::kit; + TestEnvironment environment; + auto& game = environment.fake; + const std::string plugin = "SmokeyDawg58-ReSkate_Throwdown"; + Host host(environment, Side::client); + host.apply({contributed(plugin)}); + running(host, plugin, environment); + // His challenge registry on the Throwdowns page: R.E.S.K.A.T.E. in S.K.A.T.E.'s place, 1-UP and his + // game modes after the stock cards, each with his donor card, colour, order and flag prefab. + check(game.modes.size() == 10, "His ten modes are on the Throwdowns page\n" + environment.all()); + check(game.modes.front().id == "skate" && game.modes.front().replaces == "skate" && game.modes.front().card == "skate", + "R.E.S.K.A.T.E. takes S.K.A.T.E.'s place"); + const auto registered = [&](const std::string& id) -> const ThrowdownMode& { + for (const auto& m : game.modes) if (m.id == id) return m; + throw std::runtime_error("no mode " + id); + }; + check(registered("oneup").card == "spot" && registered("oneup").order == 1 && registered("oneup").name == "1-UP" && + registered("oneup").flag == "World/ReSkate/Throwdowns/Flags/oneup_Placement", + "1-UP is one of his modes now"); + check(registered("race").card == "spot" && registered("race").color == 0xff3434ffU && registered("race").order == 4 && + registered("race").flag == "World/ReSkate/Throwdowns/Flags/race_Placement" && registered("race").cursor_height == 99999, + "Deathrace: his donor, colour, order, flag and a start flag that may stand on anything"); + check(registered("tag").card == "skate" && registered("hide").card == "skate" && registered("jam").card == "jam" && registered("meat").card == "spot", + "His donor rule: tag-like modes S.K.A.T.E.'s card, Spot Jam Skate Jam's, the rest Spot Battle's"); + SkaterTelemetry t; + t.skater = true; + t.position = {1, 2, 3}; + t.physics_state = 100; + game.telemetry = t; + environment.local_skater = SkaterState{{1, 2, 3}, 0, 0, 0, false, 0}; + environment.score_now = ScoreState{0, 1, false, {}, {}, false}; + host.set_level("sanvan"); + ticks(host, environment, 50); // its game layer loads a part a tick + running(host, plugin, environment); + check(game.kit_documents >= 5, "It publishes the kit's documents\n" + environment.all()); + { + const auto idle = kit::documents_for(plugin + "/jam"); + check(idle && !idle->hud.active && !idle->setup && !idle->waiting && !idle->panel.present, "Idle, no HUD and no pages"); + } + check(!environment.logged(".lua:") && !environment.logged("tick failed"), "Idle ticks run clean\n" + environment.all()); + // His rule checks (tdm test, his mode_rules_tests.cpp). + environment.lines.clear(); + check(host.run_console("tdm test"), "tdm test"); + check(environment.printed(" 0 failed") && environment.printed(" passed"), "His rule checks pass:\n" + printed_lines(environment)); + + const auto docs_of = [&](const std::string& key) { + const auto docs = kit::documents_for(key); + check(docs.has_value(), key + ": its documents are in the kit's store"); + return *docs; + }; + const auto flag_waiting = [&](const std::string& key, std::uint32_t id) { game.flag = {true, true, false, true, key, id}; }; + struct Expect { + const char* mode; + const char* title; + const char* timer; + std::vector rows; + }; + const std::vector expected{ + {"jam", "Spot Jam", "Timer", {"area"}}, {"meat", "Hall of Meat", "Timer", {"area"}}, + {"domination", "Domination", "Timer", {"capture-spots"}}, {"graffiti", "Graffiti", "Timer", {"area"}}, + {"tag", "Skate Tag", "Timer", {"area"}}, {"infection", "Infection", "Timer", {"area"}}, + {"hide", "Hide & Seek", "Duration", {"hide_time", "area"}}, {"skate", "R.E.S.K.A.T.E.", "Turn Timer", {"letters", "tricks", "zone"}}, + {"race", "Deathrace", "Finish deadline", {}}}; + std::uint32_t id = 101; + for (const auto& e : expected) { + const std::string mode = e.mode, key = plugin + "/" + mode; + id += 2; + // A card click: the kit begins the native flag with the stock setup page open, then says `open`. + check(game.throwdown_flag_begin(key, id, {6, 120, true, 0}), mode + ": the flag begins"); + game.flag.placing = false; + kit::post(key, "open", {std::to_string(id)}); + ticks(host, environment, 2); + auto docs = docs_of(key); + check(docs.setup && docs.setup->mode == key && docs.setup->title == e.title && docs.setup->timer.present && + docs.setup->timer.caption == e.timer && docs.setup->players.present && docs.setup->players.max == 6 && docs.setup->back == "BACK", + mode + ": his setup page\n" + environment.all()); + std::vector rows; + for (const auto& row : docs.setup->rows) rows.push_back(row.id); + check(rows == e.rows, mode + ": his setup rows (" + std::to_string(rows.size()) + ")"); + if (mode == "jam") check(docs.setup->rows[0].kind == "button" && docs.setup->rows[0].label == "Set optional area", "Spot Jam's Area button"); + if (mode == "skate") + check(docs.setup->rows[1].options.size() == 4 && docs.setup->rows[2].options.size() == 6 && docs.setup->rows[0].max == 7, + "R.E.S.K.A.T.E.'s Letters, Tricks and Trick Zone"); + kit::post(key, "setup_row", {"players", "3"}); + ticks(host, environment, 2); + check(docs_of(key).setup->players.value == 3, mode + ": the Players row sets his capacity"); + // Confirm, then the native flag placed. + kit::post(key, "setup_confirm", {"3", mode == "race" ? "120" : "300"}); + game.flag.placing = true; + ticks(host, environment, 1); + flag_waiting(key, id); + kit::post(key, "flag_placed", {std::to_string(id), "1", "2", "3", "90"}); + ticks(host, environment, 3); + docs = docs_of(key); + // Deathrace's route editor opens over its placed start flag: no waiting card until it closes. + check(mode == "race" ? !docs.waiting + : docs.waiting && docs.waiting->mode == key && docs.waiting->title == e.title && docs.waiting->capacity == 3, + mode + ": his waiting card\n" + environment.all()); + if (mode == "race") { + check(docs.hud.active && docs.hud.theme == "race" && docs.hud.editor.present && docs.hud.editor.title == "Deathrace builder" && + docs.hud.editor.prompts.size() == 11 && docs.hud.editor.info_title == "ROUTE INFO" && docs.hud.header.title == "DEATHRACE" && + docs.hud.header.frame_width == 704, + "Deathrace's start gate opens his route editor\n" + environment.all()); + // His route library: a route saved on this map, the load dialog (hold Start), a row picked. + game.files[plugin + "/routes/test_map/Loop.json"] = + R"({"format": "ReSkate Deathrace route", "version": 1, "id": 7, "name": "Loop", "map": "test_map", "settings": {)" + R"("duration_seconds": 120, "turn_seconds": 30, "strikes": 3, "radius": 6, "area_radius": 0, "corners": [],)" + R"("points": [[1, 2, 3], [1, 2, 23], [1, 2, 43]], "yaw_degrees": [0, 0, 0], "half_widths": [6, 6, 6],)" + R"("gate_types": ["required", "required", "required"], "arrow_roll_degrees": [], "pitch_degrees": [], "spawn": [1, 2, 3],)" + R"("has_spawn": true, "finish_gate": true, "trick_kinds": 15}})"; + game.focus_now = {true, false, true}; + game.pad_bits_now = 0u; + ticks(host, environment, 3); + game.pad_bits_now = 0x10u; + ticks(host, environment, 100); + check(game.dialog && game.dialog->id == "route-load" && game.dialog->title == "LOAD DEATHRACE ROUTE" && game.dialog->rows.size() == 1 && + game.dialog->rows[0].text == "Loop" && game.dialog->file.present && game.dialog->buttons[0].label == "Back to builder", + "Hold Start: his route library\n" + environment.all()); + game.pad_bits_now = 0u; + ticks(host, environment, 2); + kit::post_to(plugin, "dialog", {}, {"route-load", "row", "", "1", ""}); + ticks(host, environment, 3); + { + const auto info = docs_of(key).hud.editor.info; + check(!info.empty() && info[0].first == "Required gates: 1" && info[1].first == "Finish gate: Placed", "The route is loaded\n" + environment.all()); + } + // Tap Start: name and save it, then players can enroll. + game.pad_bits_now = 0x10u; + ticks(host, environment, 2); + game.pad_bits_now = 0u; + ticks(host, environment, 3); + check(game.dialog && game.dialog->id == "route-save" && game.dialog->title == "SAVE DEATHRACE ROUTE" && game.dialog->field.text == "Loop", + "Tap Start: his save dialog\n" + environment.all()); + kit::post_to(plugin, "dialog", {}, {"route-save", "save", "Loop Two", "0", ""}); + ticks(host, environment, 3); + check(game.files.contains(plugin + "/routes/test_map/Loop Two.json") && game.called("dialog close " + plugin + " route-save"), + "The route is saved as his JSON\n" + environment.all()); + check(game.files[plugin + "/routes/test_map/Loop Two.json"].find("\"format\": \"ReSkate Deathrace route\"") != std::string::npos, + "in his format"); + { + const auto saved = docs_of(key); + check(saved.waiting && saved.waiting->title == e.title && saved.waiting->capacity == 3 && saved.waiting->can_start && + !saved.hud.editor.present, + "A saved route closes the editor: his waiting card, and it can start\n" + environment.all()); + } + game.focus_now = {}; + game.pad_bits_now.reset(); + } else { + if (mode == "domination") check(host.run_console("tdm point"), "his Domination needs a spot: `mode point` where the skater is"); + ticks(host, environment, 2); + check(docs_of(key).waiting->can_start, mode + ": the host can start\n" + environment.all()); + kit::post(key, "start", {std::to_string(id)}); + ticks(host, environment, 400); + const auto ms = status_of(host, plugin).tick_ms; + std::cout << "SmokeyDawg58 " << mode << ": " << ms << " ms a tick playing (its budget is 2 ms)\n"; + check(ms < 2, mode + ": within its time"); + docs = docs_of(key); + const auto& hud = docs.hud; + check(hud.active && hud.theme == mode && !docs.waiting, mode + ": Start plays it alone\n" + environment.all()); + check(mode == "skate" ? hud.header.title.empty() && hud.card.present && hud.layout == "bottom" + : hud.header.title == e.title && hud.counters.size() == 2 && hud.counters[0].starts_with("LEADER"), + mode + ": his HUD's title and score block"); + check(hud.board.present && !hud.results.present + ? hud.board.title == "LEADERBOARD" && hud.board.rows.size() == 1 && hud.board.rows[0].self + : hud.results.present && hud.results.title == std::string(e.title) + " RESULTS", + mode + ": his leaderboard\n" + environment.all()); + if (!hud.results.present) + check(docs.panel.present && docs.panel.title == e.title && docs.panel.players.size() == 1 && docs.panel.settings.size() == 3, + mode + ": his Throwdown Panel"); + check(kit::match().markers || hud.results.present, mode + ": the match keeps the session markers"); + } + running(host, plugin, environment); + check(!environment.logged(".lua:") && !environment.logged("tick failed") && !environment.logged("failed:"), + mode + " runs without errors\n" + environment.all()); + kit::post(key, "quit", {}); + ticks(host, environment, 3); + game.flag = {}; + ticks(host, environment, 2); + docs = docs_of(key); + check(!docs.hud.active && !docs.waiting, mode + ": Quit ends it\n" + environment.all()); + } + // 1-UP: a practice match, alone at its flag. + const std::string oneup = plugin + "/oneup"; + check(game.throwdown_flag_begin(oneup, 501, {4, 120, true, 0}), "1-UP's flag begins"); + game.flag.placing = false; + kit::post(oneup, "open", {"501"}); + ticks(host, environment, 2); + auto docs = docs_of(oneup); + check(docs.setup && docs.setup->title == "1-UP" && docs.setup->timer.value == 20 && docs.setup->timer.max == 120, "1-UP's setup page\n" + environment.all()); + kit::post(oneup, "setup_row", {"players", "1"}); + kit::post(oneup, "setup_confirm", {"1", "20"}); + game.flag.placing = true; + ticks(host, environment, 1); + flag_waiting(oneup, 501); + kit::post(oneup, "flag_placed", {"501", "1", "2", "3", "0"}); + ticks(host, environment, 5); + docs = docs_of(oneup); + check(docs.waiting && docs.waiting->title == "1-UP" && docs.waiting->can_start && docs.waiting->capacity == 1, "1-UP's waiting card\n" + environment.all()); + kit::post(oneup, "start", {"501"}); + bool played = false; + for (int i = 0; i < 600 && !played; ++i) { + ticks(host, environment, 1); + docs = docs_of(oneup); + played = docs.hud.active && docs.hud.clock.present && docs.hud.clock.ticking; + } + const auto& hud = docs.hud; + check(played && hud.theme == "oneup" && hud.header.title == "1-UP" && hud.counters.size() == 2 && hud.counters[0].starts_with("TARGET") && + hud.board.present && hud.board.variant == "stamps" && hud.board.rows.size() == 1 && hud.board.rows[0].marks.size() == 3 && + hud.board.title.ends_with("'S TURN"), + "1-UP's practice plays, his score HUD up\n" + environment.all()); + check(docs.panel.present && docs.panel.title == "1-UP" && kit::match().markers, "1-UP's panel and markers"); + std::cout << "SmokeyDawg58 1-UP: " << status_of(host, plugin).tick_ms << " ms a tick playing\n"; + check(status_of(host, plugin).tick_ms < 2, "1-UP within its time"); + kit::post(oneup, "quit", {}); + ticks(host, environment, 3); + game.flag = {}; + ticks(host, environment, 2); + check(!docs_of(oneup).hud.active, "1-UP's Quit ends it\n" + environment.all()); + running(host, plugin, environment); + check(!environment.logged(".lua:") && !environment.logged("tick failed"), "1-UP runs without errors\n" + environment.all()); + // His ReSkate-menu 1-UP card, as the plugin's page: Place 1-Up here, then its lobby, then Exit. + environment.panels_open = true; + environment.shown = plugin; + ticks(host, environment, 5); + using K = plugin_ui::Widget::Kind; + check(!environment.panels.empty() && environment.panels[0].page && environment.panels[0].title == "1-UP" && + environment.panels[0].widgets[0].kind == K::card && environment.panels[0].widgets[0].text == "1-UP", + "His 1-UP card on the plugin's page\n" + environment.all()); + environment.actions = {plugin_ui::Action{plugin, "1-UP", "place", false}}; + ticks(host, environment, 30); + const auto heading = [&] { + for (const auto& w : environment.panels[0].widgets) if (w.kind == K::heading) return w.text; + return std::string{}; + }; + { + std::string widgets; + for (const auto& w : environment.panels[0].widgets) widgets += w.text + " | " + w.help + "\n"; + check(heading() == "READY UP", "Place 1-Up here opens its lobby: " + heading() + "\n" + widgets + environment.all()); + } + environment.actions = {plugin_ui::Action{plugin, "1-UP", "exit", false}}; + ticks(host, environment, 30); + check(heading().empty(), "and Exit 1-Up leaves it"); + environment.panels_open = false; + // His console verbs, under tdm. + check(host.run_console("tdm help"), "tdm help"); + check(!environment.logged(".lua:"), "tdm help answers\n" + environment.all()); } void grind_exits_pick_the_side() { @@ -1953,6 +2531,7 @@ int main() { tricks_reach_plugins_that_ask_for_them(); plugins_draw_on_the_overlay(); plugins_draw_pictures(); + plugins_place_world_entities(); plugins_server_copies_are_the_authority(); plugins_animate_the_skater(); plugins_play_their_own_sounds(); @@ -1974,6 +2553,9 @@ int main() { fpv_drone_flies_and_follows(); hall_of_meat_scores_a_bail(); grind_exits_pick_the_side(); + smokey_throwdowns_register_and_play_one_up(); + plugins_drive_the_throwdown_kit(); + the_throwdowns_page_lists_the_stock_cards_and_plugin_modes(); plugins_add_throwdown_modes(); reskate_hostile_is_stopped_or_refused_every_time(); std::filesystem::remove_all(scratch); diff --git a/Extension/Plugins/plugin_api.cpp b/Extension/Plugins/plugin_api.cpp index ae6109de..85646683 100644 --- a/Extension/Plugins/plugin_api.cpp +++ b/Extension/Plugins/plugin_api.cpp @@ -17,14 +17,15 @@ namespace { struct Event { std::string_view name, permission; }; -constexpr std::array events{{ +constexpr std::array events{{ {"load", {}}, {"unload", {}}, {"tick", {}}, {"level_loaded", {}}, {"level_unloading", {}}, {"trick_started", "events.tricks"}, {"trick_landed", "events.tricks"}, {"line_broken", "events.tricks"}, {"draw", "overlay"}, {"player_joined", "session.read"}, {"player_left", "session.read"}, {"message", "net.session"}, {"skater_state", "events.skater"}, {"bail", "events.skater"}, {"chat", "chat.read"}, {"track_changed", "music"}, {"throwdown", "throwdowns"}, {"action", "input"}, {"screen_closed", "ui.screen"}, {"board_landed", "events.skater"}, {"body_hit", "events.skater"}, {"bail_end", "events.skater"}, - {"physics_step", "events.physics"}, {"grind_exit", "events.skater"}, {"throwdown_mode", "throwdowns"}, + {"physics_step", "events.physics"}, {"grind_exit", "events.skater"}, {"trick_notes", "events.tricks"}, + {"throwdown_ui", "throwdowns"}, }}; constexpr std::size_t maximum_key = 128; constexpr std::size_t maximum_command = 48; @@ -76,6 +77,12 @@ Json to_json(lua_State* L, int index, int depth, Budget& budget) { budget.spend(0, length); return std::string(text, length); } + case LUA_TVECTOR: { // a position: [x, y, z] + const float* v = lua_tovector(L, index); + if (!std::isfinite(v[0]) || !std::isfinite(v[1]) || !std::isfinite(v[2])) throw std::runtime_error("JSON has no NaN or infinity"); + budget.spend(3, 0); + return Json::array({static_cast(v[0]), static_cast(v[1]), static_cast(v[2])}); + } case LUA_TTABLE: { luaL_checkstack(L, 4, "tables nested too deep"); const int length = lua_objlen(L, index); @@ -172,7 +179,11 @@ void add_functions(lua_State* L, const char* field, std::initializer_list(functions.size())); lua_pushvalue(L, -1); + // The parent may be a read-only table itself (reskate.throwdowns, for its flag). + const bool locked = lua_getreadonly(L, -3) != 0; + lua_setreadonly(L, -3, false); lua_setfield(L, -3, field); + lua_setreadonly(L, -2, locked); } // A table made read-only earlier in the install is opened for this, and closed again. lua_setreadonly(L, -1, false); @@ -314,6 +325,42 @@ struct ApiFunctions { push_json(L, Json::parse(text, {plugin.limits_.file_bytes, 64, 1u << 20})); return 1; } + // variants(): the variants the plugin's own mod holds (its reskate-plugin.json "variants", + // Engine/Vfs/mod_list.h): {list = {...}, chosen = name or nil, in_play = name or nil}, its "order" + // first; nil when it declares none. + static int variants(lua_State* L) { + auto& plugin = plugin_of(L); + const auto state = plugin.services_.game().variants(plugin.id(), plugin.directory()); + if (!state) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, 0, 3); + lua_createtable(L, static_cast(state->installed.size()), 0); + for (std::size_t i = 0; i < state->installed.size(); ++i) { + lua_pushlstring(L, state->installed[i].data(), state->installed[i].size()); + lua_rawseti(L, -2, static_cast(i + 1)); + } + lua_setfield(L, -2, "list"); + for (const auto& [field, value] : {std::pair{"chosen", &state->chosen}, std::pair{"in_play", &state->in_play}}) { + if (value->empty()) continue; + lua_pushlstring(L, value->data(), value->size()); + lua_setfield(L, -2, field); + } + return 1; + } + // choose(name): that variant of the plugin's own mod in the game: kept as the choice, the mods + // applied again and the level loaded again with it. true, or false and why not. + static int choose(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "variants", "reskate.package.choose"); + const std::string name = luaL_checkstring(L, 1); + const auto why = plugin.services_.game().choose_variant(plugin.id(), plugin.directory(), name); + lua_pushboolean(L, why.empty()); + if (why.empty()) return 1; + lua_pushlstring(L, why.data(), why.size()); + return 2; + } static int list(lua_State* L) { auto& plugin = plugin_of(L); const auto folder = package_path(L, plugin, luaL_optstring(L, 1, ""), true); @@ -649,7 +696,7 @@ void Api::install(Plugin& plugin, lua_State* L) { add_functions(L, {{"log", F::log}, {"on", F::on}}); add_functions(L, "time", {{"now", F::now}, {"after", F::after}, {"every", F::every}, {"cancel", F::cancel}}); add_functions(L, "level", {{"current", F::level}}); - add_functions(L, "package", {{"read", F::read}, {"read_json", F::read_json}, {"list", F::list}}); + add_functions(L, "package", {{"read", F::read}, {"read_json", F::read_json}, {"list", F::list}, {"variants", F::variants}, {"choose", F::choose}}); add_functions(L, "json", {{"encode", F::encode}, {"decode", F::decode}}); add_functions(L, "console", {{"command", F::command}, {"print", F::console_print}}); add_functions(L, "storage", {{"get", F::storage_get}, {"set", F::storage_set}, {"add", F::storage_add}, {"keys", F::storage_keys}}); @@ -661,6 +708,8 @@ void Api::install(Plugin& plugin, lua_State* L) { install_ui_api(plugin, L); install_more_api(plugin, L); install_world_api(plugin, L); + install_services_api(plugin, L); + install_kit_api(plugin, L); readonly(); lua_setglobal(L, "reskate"); lua_pushcfunction(L, ApiFunctions::print, "print"); diff --git a/Extension/Plugins/plugin_api.h b/Extension/Plugins/plugin_api.h index a404726f..91d6002f 100644 --- a/Extension/Plugins/plugin_api.h +++ b/Extension/Plugins/plugin_api.h @@ -16,6 +16,13 @@ void install_ui_api(Plugin& plugin, lua_State* L); void install_more_api(Plugin& plugin, lua_State* L); // The wider set's game parts (plugin_api_world.cpp), into the same table. void install_world_api(Plugin& plugin, lua_State* L); +// General services for a game layer: the skater's telemetry and Bone Cam, the pad and keys, the +// free and pad-flown cameras, the session's world, competition physics and the trick feed +// (plugin_api_services.cpp). +void install_services_api(Plugin& plugin, lua_State* L); +// The throwdown kit's documents and native flag, the plugin's data files, overlay dialogs and the +// skater's push (with its wait and reset) and hold (plugin_api_throwdowns.cpp). +void install_kit_api(Plugin& plugin, lua_State* L); // ---- adding to the reskate table --------------------------------------------------------------- // A new part of the API is a file of `static int name(lua_State*)` functions and an install_*_api @@ -29,8 +36,9 @@ struct ApiFunction { const char* name; lua_CFunction function; }; -// Adds the functions to reskate., the reskate table being on top of the stack. The table is -// made when there is none yet, and another file may add to one already there; it is left read-only. +// Adds the functions to reskate., the reskate table being on top of the stack (or to a table +// inside it, such as reskate.throwdowns, when that is on top). The table is made when there is none +// yet, and another file may add to one already there; it is left read-only. void add_functions(lua_State* L, const char* field, std::initializer_list functions); // Adds the functions to the reskate table itself (on top of the stack). void add_functions(lua_State* L, std::initializer_list functions); diff --git a/Extension/Plugins/plugin_api_services.cpp b/Extension/Plugins/plugin_api_services.cpp new file mode 100644 index 00000000..b5224853 --- /dev/null +++ b/Extension/Plugins/plugin_api_services.cpp @@ -0,0 +1,287 @@ +#include "plugin_api.h" +#include "plugin_game.h" +#include "lua.h" +#include "lualib.h" +#include +#include +#include +#include + +// General services a game layer in Lua reads and drives each tick: the skater's telemetry, stance +// and Bone Cam, the pad and keys, the free and pad-flown cameras, spectating, the session's world, +// the competition's stock physics, and skate.'s trick list and flick diagrams. None of them knows a +// throwdown mode; each checks its permission and the game (plugin_game.h) does the rest. +namespace dingosdk::plugins { +struct ServicesApi { + static Game& game(Plugin& plugin) { return plugin.services_.game(); } +}; +namespace { +Game& game_of(Plugin& plugin) { return ServicesApi::game(plugin); } +Plugin& allowed(lua_State* L, const char* permission, const char* what) { + auto& plugin = plugin_of(L); + if (!plugin.has(permission)) luaL_error(L, "%s needs the \"%s\" permission in reskate-plugin.json", what, permission); + return plugin; +} +std::uint64_t id_at(lua_State* L, int index) { + if (lua_isnumber(L, index)) return static_cast(lua_tonumber(L, index)); + // Steam IDs come as strings: a Lua number cannot hold one exactly. + const std::string text = luaL_checkstring(L, index); + std::uint64_t out{}; + for (const char c : text) { + if (c < '0' || c > '9' || out > (~0ULL - 9) / 10) luaL_argerror(L, index, "a Steam ID"); + out = out * 10 + static_cast(c - '0'); + } + return out; +} +int result(lua_State* L, const std::string& why) { + lua_pushboolean(L, why.empty()); + if (why.empty()) return 1; + lua_pushlstring(L, why.data(), why.size()); + return 2; +} +void field_number(lua_State* L, const char* name, double value) { + lua_pushnumber(L, value); + lua_setfield(L, -2, name); +} +void field_bool(lua_State* L, const char* name, bool value) { + lua_pushboolean(L, value); + lua_setfield(L, -2, name); +} +void field_vec(lua_State* L, const char* name, const Vec3& v) { + lua_pushvector(L, v[0], v[1], v[2]); + lua_setfield(L, -2, name); +} + +// ---- skater -------------------------------------------------------------------------------------- +// skater.telemetry(): the trainer's telemetry, its last jump, the physics watch and the bail's body. +int telemetry(lua_State* L) { + auto& plugin = allowed(L, "skater.read", "reskate.skater.telemetry"); + const auto t = game_of(plugin).skater_telemetry(); + if (!t) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, 0, 20); + field_bool(L, "skater", t->skater); + field_bool(L, "airborne", t->airborne); + field_vec(L, "position", t->position); + field_number(L, "heading", t->heading); + field_number(L, "speed", t->speed); + field_number(L, "vertical", t->vertical); + field_number(L, "air_time", t->air_time); + field_number(L, "height", t->height); + field_number(L, "top_speed", t->top_speed); + field_number(L, "physics_state", t->physics_state); + lua_createtable(L, 0, 16); + const auto& j = t->last; + field_number(L, "serial", static_cast(j.serial)); + field_number(L, "takeoff_speed", j.takeoff_speed); + field_number(L, "takeoff_angle", j.takeoff_angle); + field_number(L, "air_time", j.air_time); + field_number(L, "height", j.height); + field_number(L, "distance", j.distance); + field_number(L, "drop", j.drop); + field_number(L, "landing_speed", j.landing_speed); + field_number(L, "spin", j.spin); + field_number(L, "spin_rate", j.spin_rate); + field_number(L, "flip", j.flip); + field_number(L, "board_turn", j.board_turn); + field_number(L, "state", j.state); + field_vec(L, "takeoff", j.takeoff); + field_vec(L, "landing", j.landing); + lua_setfield(L, -2, "last"); + lua_createtable(L, 0, 9); + const auto& w = t->watch; + field_bool(L, "valid", w.valid); + field_bool(L, "ragdoll_known", w.ragdoll_known); + field_bool(L, "ragdoll", w.ragdoll); + field_number(L, "state", w.state); + field_number(L, "previous", w.previous); + field_number(L, "previous_seconds", w.previous_seconds); + field_number(L, "changes", static_cast(w.changes)); + field_number(L, "wipeouts", static_cast(w.wipeouts)); + field_number(L, "runouts", static_cast(w.runouts)); + lua_setfield(L, -2, "watch"); + field_bool(L, "teleport_pending", t->teleport_pending); + field_bool(L, "slam", t->slam); + field_bool(L, "struck", t->struck); + if (t->body) field_vec(L, "body", *t->body); + return 1; +} +int goofy(lua_State* L) { + auto& plugin = allowed(L, "skater.read", "reskate.skater.goofy"); + if (const auto g = game_of(plugin).stance_goofy()) lua_pushboolean(L, *g); + else lua_pushnil(L); + return 1; +} +// skater.bone_cam(word, ...): no words for its setting ("game", "on" or "off"), "status" its full +// status line, "bail" a bail to show, "ringing" "1" or "0", "game" "on"/"off" (a plugin's game +// wants it), or the `bonecam` console words. +int bone_cam(lua_State* L) { + auto& plugin = allowed(L, "camera", "reskate.skater.bone_cam"); + std::vector words; + for (int i = 1, n = std::min(lua_gettop(L), 8); i <= n; ++i) { + std::size_t length = 0; + const char* word = luaL_checklstring(L, i, &length); + words.emplace_back(word, std::min(length, 32)); + } + const auto text = game_of(plugin).bone_cam(plugin.id(), words); + lua_pushlstring(L, text.data(), text.size()); + return 1; +} + +// ---- input --------------------------------------------------------------------------------------- +// input.pad_bits(menu): XInput's button bits with LT 0x10000 and RT 0x20000, or nil without a pad; +// with `menu`, read while ReSkate's menu is open too. +int pad_bits(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.pad_bits"); + if (const auto bits = game_of(plugin).pad_bits(lua_toboolean(L, 1) != 0)) lua_pushnumber(L, *bits); + else lua_pushnil(L); + return 1; +} +int key_code(lua_State* L, int index) { + if (lua_isnumber(L, index)) { + const int vk = static_cast(lua_tonumber(L, index)); + if (vk < 1 || vk > 254) luaL_argerror(L, index, "a virtual key from 1 to 254"); + return vk; + } + const std::string name = luaL_checkstring(L, index); + static const std::pair names[] = { + {"Enter", 13}, {"Backspace", 8}, {"Escape", 27}, {"End", 35}, {"Home", 36}, {"PageUp", 33}, {"PageDown", 34}, {"Space", 32}, + {"Shift", 16}, {"Ctrl", 17}, {"Alt", 18}, {"Tab", 9}, {"Left", 37}, {"Up", 38}, {"Right", 39}, {"Down", 40}, + {"[", 0xDB}, {"]", 0xDD}, {"LeftMouse", 1}, {"RightMouse", 2}, {"MiddleMouse", 4}}; + for (const auto& [n, vk] : names) + if (name == n) return vk; + if (name.size() == 1 && ((name[0] >= 'A' && name[0] <= 'Z') || (name[0] >= '0' && name[0] <= '9'))) return name[0]; + luaL_argerror(L, index, "a key name (A-Z, 0-9, Enter, Backspace, Escape, End, PageUp, PageDown, [, ], ...) or a virtual key"); + return 0; +} +int key(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.key"); + lua_pushboolean(L, game_of(plugin).key_down(key_code(L, 1))); + return 1; +} +int wheel(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.wheel"); + lua_pushnumber(L, game_of(plugin).mouse_wheel()); + return 1; +} +// input.game_buttons(hidden, held, pause): the game buttons (XInput bits) kept from the game while +// asked, those held down for it, and all game input paused for half a second. +int game_buttons(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.game_buttons"); + const auto mask = [&](int index) { + const double v = luaL_optnumber(L, index, 0); + if (!(v >= 0 && v <= 0xffff)) luaL_argerror(L, index, "a 16-bit button mask"); + return static_cast(v); + }; + game_of(plugin).game_buttons(plugin.id(), mask(1), mask(2), lua_toboolean(L, 3) != 0); + return 0; +} +int input_allowed(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.allowed"); + lua_pushboolean(L, game_of(plugin).input_allowed()); + return 1; +} +int focus(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.focus"); + const auto f = game_of(plugin).input_focus(); + lua_createtable(L, 0, 3); + field_bool(L, "focused", f.focused); + field_bool(L, "interface_open", f.interface_open); + field_bool(L, "gameplay", f.gameplay); + return 1; +} + +// ---- camera -------------------------------------------------------------------------------------- +int free_camera(lua_State* L) { + auto& plugin = allowed(L, "camera", "reskate.camera.free"); + return result(L, game_of(plugin).free_camera(plugin.id(), lua_toboolean(L, 1) != 0)); +} +// camera.pad_flight(on): the free camera flown from the pad while placing things from the air (the +// bumpers or Q/E its height; the triggers stay the plugin's). Held by one plugin; released with it. +int pad_flight(lua_State* L) { + auto& plugin = allowed(L, "camera", "reskate.camera.pad_flight"); + return result(L, game_of(plugin).pad_flight(plugin.id(), lua_toboolean(L, 1) != 0)); +} + +// ---- session ------------------------------------------------------------------------------------- +int spectate(lua_State* L) { + auto& plugin = allowed(L, "session.read", "reskate.session.spectate"); + game_of(plugin).spectate(lua_isnoneornil(L, 1) ? 0 : id_at(L, 1)); + return 0; +} +// session.world(): {world = id (a string; it changes with the map or session), barred = the local +// player's mods change scoring, map = the map's name}. +int world(lua_State* L) { + auto& plugin = allowed(L, "session.read", "reskate.session.world"); + auto& g = game_of(plugin); + lua_createtable(L, 0, 3); + const auto id = std::to_string(g.world_id()); + lua_pushlstring(L, id.data(), id.size()); + lua_setfield(L, -2, "world"); + field_bool(L, "barred", g.world_barred()); + const auto map = g.world_map(); + lua_pushlstring(L, map.data(), map.size()); + lua_setfield(L, -2, "map"); + return 1; +} + +// ---- physics ------------------------------------------------------------------------------------- +// physics.competition(on): the game's stock physics while the local player competes (the player's +// own tuning comes back after); stock_ready(): the stock tuning has been read; prepare(): read it +// now, switching nothing. +int competition(lua_State* L) { + auto& plugin = allowed(L, "skater.control", "reskate.physics.competition"); + game_of(plugin).competition(plugin.id(), lua_toboolean(L, 1) != 0); + return 0; +} +int stock_ready(lua_State* L) { + auto& plugin = allowed(L, "skater.control", "reskate.physics.stock_ready"); + lua_pushboolean(L, game_of(plugin).stock_physics_ready()); + return 1; +} +int prepare(lua_State* L) { + auto& plugin = allowed(L, "skater.control", "reskate.physics.prepare"); + game_of(plugin).prepare_physics(); + return 0; +} + +// ---- tricks -------------------------------------------------------------------------------------- +// tricks.feed(on): skate.'s trick list, entry by entry, as trick_notes events while on. +int trick_feed(lua_State* L) { + auto& plugin = allowed(L, "events.tricks", "reskate.tricks.feed"); + game_of(plugin).trick_feed(plugin.id(), lua_toboolean(L, 1) != 0); + return 0; +} +// tricks.gesture(name): the flick diagram of a flip trick as skate. names it ({{x, y}, ...}: x to the +// left, y pulled back, the stick's gate the unit circle, a regular stance); empty until the game's +// gestures are read (the first call starts that) or for a trick without one. +int gesture(lua_State* L) { + auto& plugin = allowed(L, "events.tricks", "reskate.tricks.gesture"); + std::size_t length = 0; + const char* name = luaL_checklstring(L, 1, &length); + const auto path = game_of(plugin).trick_gesture(std::string_view(name, std::min(length, 96))); + lua_createtable(L, static_cast(path.size()), 0); + for (std::size_t i = 0; i < path.size(); ++i) { + lua_createtable(L, 2, 0); + lua_pushnumber(L, path[i][0]); + lua_rawseti(L, -2, 1); + lua_pushnumber(L, path[i][1]); + lua_rawseti(L, -2, 2); + lua_rawseti(L, -2, static_cast(i + 1)); + } + return 1; +} + +} // namespace + +void install_services_api(Plugin&, lua_State* L) { + add_functions(L, "skater", {{"telemetry", telemetry}, {"goofy", goofy}, {"bone_cam", bone_cam}}); + add_functions(L, "input", {{"pad_bits", pad_bits}, {"key", key}, {"wheel", wheel}, {"game_buttons", game_buttons}, {"allowed", input_allowed}, {"focus", focus}}); + add_functions(L, "session", {{"spectate", spectate}, {"world", world}}); + add_functions(L, "camera", {{"free", free_camera}, {"pad_flight", pad_flight}}); + add_functions(L, "physics", {{"competition", competition}, {"stock_ready", stock_ready}, {"prepare", prepare}}); + add_functions(L, "tricks", {{"feed", trick_feed}, {"gesture", gesture}}); +} +} // namespace dingosdk::plugins diff --git a/Extension/Plugins/plugin_api_throwdowns.cpp b/Extension/Plugins/plugin_api_throwdowns.cpp new file mode 100644 index 00000000..106d2c86 --- /dev/null +++ b/Extension/Plugins/plugin_api_throwdowns.cpp @@ -0,0 +1,365 @@ +#include "plugin_api.h" +#include "plugin_game.h" +#include "lua.h" +#include "lualib.h" +#include +#include +#include +#include + +// The throwdown kit's side of the reskate API (throwdown_kit.h): a plugin's documents for the +// native Throwdowns UI and the native flag registration, plus the general services its modes use: +// the plugin's own data files, overlay dialogs, and the skater's push and hold. Each checks its +// permission and its arguments; the game (plugin_game.h) does the rest. +namespace dingosdk::plugins { +namespace { +int result(lua_State* L, const std::string& why) { + lua_pushboolean(L, why.empty()); + if (why.empty()) return 1; + lua_pushlstring(L, why.data(), why.size()); + return 2; +} +// nil, why: what a call that hands back a value returns when it fails. +int failed(lua_State* L, const std::string& why) { + lua_pushnil(L); + lua_pushlstring(L, why.data(), why.size()); + return 2; +} +bool within(double now, double& second, int& count, int most) { + if (now - second >= 1000) { + second = now; + count = 0; + } + return ++count <= most; +} +// A string field of the table at `table` (nil: empty), at most `most` long. +std::string text_field(lua_State* L, int table, const char* name, std::size_t most) { + lua_getfield(L, table, name); + std::string value; + if (!lua_isnil(L, -1)) { + if (!lua_isstring(L, -1)) luaL_error(L, "%s is a string", name); + std::size_t length = 0; + const char* text = lua_tolstring(L, -1, &length); + if (length > most) luaL_error(L, "%s is longer than %d characters", name, static_cast(most)); + value.assign(text, length); + } + lua_pop(L, 1); + return value; +} +double number_field(lua_State* L, int table, const char* name, double fallback, double low, double high) { + lua_getfield(L, table, name); + double value = fallback; + if (!lua_isnil(L, -1)) { + if (!lua_isnumber(L, -1)) luaL_error(L, "%s is a number", name); + value = lua_tonumber(L, -1); + if (!(value >= low && value <= high)) luaL_error(L, "%s is %g to %g", name, low, high); + } + lua_pop(L, 1); + return value; +} +bool bool_field(lua_State* L, int table, const char* name, bool fallback) { + lua_getfield(L, table, name); + const bool value = lua_isnil(L, -1) ? fallback : lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + return value; +} +// A mode id of the plugin's own ("duel"): the SDK's is "/". +std::string own_mode(lua_State* L, int index) { + auto id = checked_text(L, index, 32, "a mode id"); + if (id.empty() || id.find('/') != std::string::npos) luaL_argerror(L, index, "a mode id of 1 to 32 characters, without /"); + return id; +} +// A path in the plugin's data folder: relative, inside it, and no name Windows keeps for a device. +std::string data_path(lua_State* L, int index, bool folder) { + if (folder && lua_isnoneornil(L, index)) return {}; + auto path = checked_text(L, index, 200, "a data path"); + while (folder && path.ends_with('/')) path.pop_back(); + if (folder && path.empty()) return {}; + if (safe_relative_path(path).empty()) luaL_argerror(L, index, "a path inside the plugin's data folder (no .., no drive, / between folders)"); + std::size_t start = 0; + while (start <= path.size()) { + const auto end = std::min(path.find('/', start), path.size()); + std::string base(path.substr(start, std::min(path.find('.', start), end) - start)); + for (auto& c : base) c = static_cast(std::tolower(static_cast(c))); + static constexpr std::array devices{"con", "prn", "aux", "nul"}; + const bool numbered = base.size() == 4 && (base.starts_with("com") || base.starts_with("lpt")) && base[3] >= '0' && base[3] <= '9'; + if (numbered || std::find(devices.begin(), devices.end(), base) != devices.end()) luaL_argerror(L, index, "a name Windows keeps for a device"); + start = end + 1; + } + return path; +} +} // namespace + +struct KitApi { + static Game& game(Plugin& plugin) { return plugin.services_.game(); } + + // ---- throwdowns: the kit --------------------------------------------------------------------- + // ui(doc): the plugin's documents (setup, waiting, hud, panel, flags, match) for the native UI. + // Every tick is fine: an unchanged document costs a comparison. + static int ui(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "throwdowns", "reskate.throwdowns.ui"); + luaL_checktype(L, 1, LUA_TTABLE); + Json document; + try { + document = lua_to_json(L, 1); + } catch (const std::exception& e) { + luaL_error(L, "%s", e.what()); + } + return result(L, game(plugin).publish_throwdown_ui(plugin.id(), document)); + } + // flag.begin(mode, game, {players, seconds, setup_page, cursor_height}): the native flag for + // one of the plugin's modes; false while another registration is busy. + static int flag_begin(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "throwdowns", "reskate.throwdowns.flag.begin"); + const auto mode = own_mode(L, 1); + const double number = luaL_checknumber(L, 2); + if (!(number >= 1 && number <= 4294967295.0) || number != std::floor(number)) luaL_argerror(L, 2, "a game number"); + throwdowns::kit::flag::Options options; + if (!lua_isnoneornil(L, 3)) { + luaL_checktype(L, 3, LUA_TTABLE); + options.players = static_cast(number_field(L, 3, "players", options.players, 1, 10)); + options.seconds = static_cast(number_field(L, 3, "seconds", options.seconds, 0, 3600)); + options.setup_page = bool_field(L, 3, "setup_page", options.setup_page); + options.cursor_height = static_cast(number_field(L, 3, "cursor_height", options.cursor_height, 0, 100000)); + } + lua_pushboolean(L, game(plugin).throwdown_flag_begin(plugin.id() + "/" + mode, static_cast(number), options)); + return 1; + } + // flag.state(): {owned, waiting, placing, busy, mine, mode, game}; mode (the plugin's own id) and + // game only when the registration is one of its modes. + static int flag_state(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "throwdowns", "reskate.throwdowns.flag.state"); + const auto state = game(plugin).throwdown_flag_state(); + const auto prefix = plugin.id() + "/"; + const bool mine = state.mode.starts_with(prefix); + lua_createtable(L, 0, 7); + lua_pushboolean(L, state.owned); + lua_setfield(L, -2, "owned"); + lua_pushboolean(L, state.waiting); + lua_setfield(L, -2, "waiting"); + lua_pushboolean(L, state.placing); + lua_setfield(L, -2, "placing"); + lua_pushboolean(L, state.busy); + lua_setfield(L, -2, "busy"); + lua_pushboolean(L, mine); + lua_setfield(L, -2, "mine"); + if (mine) { + const auto own = state.mode.substr(prefix.size()); + lua_pushlstring(L, own.data(), own.size()); + lua_setfield(L, -2, "mode"); + lua_pushnumber(L, state.game); + lua_setfield(L, -2, "game"); + } + return 1; + } + static int flag_leave(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "throwdowns", "reskate.throwdowns.flag.leave"); + game(plugin).throwdown_flag_leave(plugin.id()); + return 0; + } + + // ---- files: the plugin's own data folder ---------------------------------------------------------- + static Plugin& files_call(lua_State* L, const char* what) { + auto& plugin = plugin_of(L); + need(L, plugin, "files", what); + if (!within(plugin.services_.now_ms(), plugin.files_second_, plugin.file_calls_, 64)) luaL_error(L, "more than 64 file calls in a second"); + return plugin; + } + // list(folder?): the names in it, folders ending in "/"; or nil, why. + static int files_list(lua_State* L) { + auto& plugin = files_call(L, "reskate.files.list"); + const auto folder = data_path(L, 1, true); + std::vector names; + if (const auto why = game(plugin).files_list(plugin.id(), folder, names); !why.empty()) return failed(L, why); + lua_createtable(L, static_cast(names.size()), 0); + for (std::size_t i = 0; i < names.size(); ++i) { + lua_pushlstring(L, names[i].data(), names[i].size()); + lua_rawseti(L, -2, static_cast(i + 1)); + } + return 1; + } + // read(path): the file's text, or nil, why ("not found" for none). + static int files_read(lua_State* L) { + auto& plugin = files_call(L, "reskate.files.read"); + const auto path = data_path(L, 1, false); + std::string text; + if (const auto why = game(plugin).files_read(plugin.id(), path, text); !why.empty()) return failed(L, why); + lua_pushlstring(L, text.data(), text.size()); + return 1; + } + // write(path, text): at most 1 MB a file and 1000 files; the folders it names are made. + static int files_write(lua_State* L) { + auto& plugin = files_call(L, "reskate.files.write"); + const auto path = data_path(L, 1, false); + std::size_t length = 0; + const char* text = luaL_checklstring(L, 2, &length); + if (length > files_most_bytes) return result(L, "a file is at most 1 MB"); + return result(L, game(plugin).files_write(plugin.id(), path, std::string_view(text, length))); + } + // remove(path): a file, or a folder with nothing in it. + static int files_remove(lua_State* L) { + auto& plugin = files_call(L, "reskate.files.remove"); + return result(L, game(plugin).files_remove(plugin.id(), data_path(L, 1, false))); + } + + // ---- ui.dialog ---------------------------------------------------------------------------------- + // dialog(id, {title, message, field = {text, max}, list = {rows = {{text, sub, buttons = {"rename"}}}, + // empty}, buttons = {"OK", {id = "save", label = "Save"}}, file = {label, filter = "*.json"}}). + // Answers come as throwdown_ui events: action "dialog", args {id, button, text, row, file_text}. + // Esc (or B) answers "cancel" and closes it. + static int dialog(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "ui.screen", "reskate.ui.dialog"); + need(L, plugin, "throwdowns", "reskate.ui.dialog (its answers are throwdown_ui events)"); + if (!within(plugin.services_.now_ms(), plugin.dialog_second_, plugin.dialog_calls_, 120)) luaL_error(L, "more than 120 dialog calls in a second"); + DialogSpec spec; + spec.id = checked_text(L, 1, 32, "a dialog id"); + if (spec.id.empty()) luaL_argerror(L, 1, "a dialog id of 1 to 32 characters"); + luaL_checktype(L, 2, LUA_TTABLE); + spec.title = text_field(L, 2, "title", 64); + spec.message = text_field(L, 2, "message", 1024); + lua_getfield(L, 2, "field"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int at = lua_gettop(L); + spec.field.present = true; + spec.field.max = static_cast(number_field(L, at, "max", 64, 1, 256)); + spec.field.text = text_field(L, at, "text", spec.field.max); + } + lua_pop(L, 1); + lua_getfield(L, 2, "list"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int at = lua_gettop(L); + spec.list = true; + spec.empty = text_field(L, at, "empty", 256); + lua_getfield(L, at, "rows"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int rows = lua_gettop(L); + const int count = lua_objlen(L, rows); + if (count > 64) luaL_error(L, "a dialog's list has at most 64 rows"); + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, rows, i); + luaL_checktype(L, -1, LUA_TTABLE); + const int row = lua_gettop(L); + DialogSpec::Row r{text_field(L, row, "text", 128), text_field(L, row, "sub", 128), {}}; + lua_getfield(L, row, "buttons"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int buttons = lua_objlen(L, -1); + if (buttons > 4) luaL_error(L, "a row has at most 4 buttons"); + for (int b = 1; b <= buttons; ++b) { + lua_rawgeti(L, -1, b); + std::size_t length = 0; + const char* name = lua_isstring(L, -1) ? lua_tolstring(L, -1, &length) : nullptr; + if (!name || !length || length > 24) luaL_error(L, "a row's buttons are names of 1 to 24 characters"); + r.buttons.emplace_back(name, length); + lua_pop(L, 1); + } + } + lua_pop(L, 2); + spec.rows.push_back(std::move(r)); + } + } + lua_pop(L, 1); + } + lua_pop(L, 1); + lua_getfield(L, 2, "buttons"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int at = lua_gettop(L); + const int count = lua_objlen(L, at); + if (count > 4) luaL_error(L, "a dialog has at most 4 buttons"); + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, at, i); + DialogSpec::Button button; + if (lua_istable(L, -1)) { + button.label = text_field(L, lua_gettop(L), "label", 32); + button.id = text_field(L, lua_gettop(L), "id", 24); + } else if (lua_isstring(L, -1)) { + std::size_t length = 0; + const char* label = lua_tolstring(L, -1, &length); + if (length > 32) luaL_error(L, "a button's label is at most 32 characters"); + button.label.assign(label, length); + } else luaL_error(L, "a dialog's buttons are labels or {id, label}"); + if (button.label.empty()) luaL_error(L, "a button needs a label"); + if (button.id.empty()) button.id = button.label.substr(0, 24); + lua_pop(L, 1); + spec.buttons.push_back(std::move(button)); + } + } + lua_pop(L, 1); + lua_getfield(L, 2, "file"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int at = lua_gettop(L); + spec.file.present = true; + spec.file.label = text_field(L, at, "label", 32); + if (spec.file.label.empty()) spec.file.label = "Open a file"; + spec.file.filter = text_field(L, at, "filter", 64); + if (spec.file.filter.empty()) spec.file.filter = "*.*"; + for (const char c : spec.file.filter) + if (!std::isalnum(static_cast(c)) && c != '*' && c != '.' && c != ';' && c != '_' && c != '-') + luaL_error(L, "a file filter is patterns like *.json, split by ;"); + } + lua_pop(L, 1); + return result(L, game(plugin).dialog_open(plugin.id(), spec)); + } + static int close_dialog(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "ui.screen", "reskate.ui.close_dialog"); + const auto id = lua_isnoneornil(L, 1) ? std::string{} : checked_text(L, 1, 32, "a dialog id"); + game(plugin).dialog_close(plugin.id(), id); + return 0; + } + + // ---- skater.push / hold ------------------------------------------------------------------------- + // push(velocity): the skater's velocity (m/s, world) once it is on its board again. + static int push(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.push"); + const float* v = luaL_checkvector(L, 1); + for (int i = 0; i < 3; ++i) + if (!std::isfinite(v[i]) || std::fabs(v[i]) > 100) luaL_argerror(L, 1, "a velocity within 100 m/s on each axis"); + return result(L, game(plugin).skater_push(plugin.id(), Vec3{v[0], v[1], v[2]})); + } + // push_wait(): the skater is still getting back on its board, so a pending push waits two seconds + // more; push_reset(): a pending push is dropped. + static int push_wait(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.push_wait"); + return result(L, game(plugin).skater_push_wait(plugin.id())); + } + static int push_reset(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.push_reset"); + return result(L, game(plugin).skater_push_reset(plugin.id())); + } + static int hold(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.hold"); + return result(L, game(plugin).skater_hold(plugin.id(), lua_toboolean(L, 1) != 0)); + } + + static constexpr std::size_t files_most_bytes = 1u << 20; + static void install(Plugin& plugin, lua_State* L); +}; + +void install_kit_api(Plugin& plugin, lua_State* L) { KitApi::install(plugin, L); } + + +void KitApi::install(Plugin&, lua_State* L) { + add_functions(L, "throwdowns", {{"ui", ui}}); + lua_getfield(L, -1, "throwdowns"); + add_functions(L, "flag", {{"begin", flag_begin}, {"state", flag_state}, {"leave", flag_leave}}); + lua_pop(L, 1); + add_functions(L, "files", {{"list", files_list}, {"read", files_read}, {"write", files_write}, {"remove", files_remove}}); + add_functions(L, "ui", {{"dialog", dialog}, {"close_dialog", close_dialog}}); + add_functions(L, "skater", {{"push", push}, {"push_wait", push_wait}, {"push_reset", push_reset}, {"hold", hold}}); +} +} diff --git a/Extension/Plugins/plugin_api_ui.cpp b/Extension/Plugins/plugin_api_ui.cpp index c200c4ba..88a59988 100644 --- a/Extension/Plugins/plugin_api_ui.cpp +++ b/Extension/Plugins/plugin_api_ui.cpp @@ -502,7 +502,7 @@ struct UiApi { return 1; } if (!plugin.services_.ui_sound(name)) - luaL_error(L, "there is no menu sound \"%s\": select, cancel, navigate, toggle or tab", name.c_str()); + luaL_error(L, "there is no menu sound \"%s\": select, cancel, navigate, toggle, tab, countdown or go", name.c_str()); lua_pushboolean(L, 1); return 1; } diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 4365ae1b..7672a790 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -233,8 +233,14 @@ struct WorldApi { lua_setfield(L, -2, "position"); push_position(L, view->forward); lua_setfield(L, -2, "forward"); + push_position(L, view->right); + lua_setfield(L, -2, "right"); + push_position(L, view->up); + lua_setfield(L, -2, "up"); lua_pushnumber(L, view->fov); lua_setfield(L, -2, "fov"); + lua_pushboolean(L, view->free_camera); + lua_setfield(L, -2, "free"); return 1; } // ---- trainer: one save point of the plugin's own ------------------------------------------- @@ -353,6 +359,119 @@ struct WorldApi { if (!within(plugin.services_.now_ms(), plugin.edit_second_, plugin.edits_, 4)) return result(L, "more than 4 edits in a second"); return result(L, game(plugin).edit_object(operation, object)); } + // ---- world.entities ---------------------------------------------------------------------------- + // set(id, blueprint, transform, hidden?): the plugin's object `id` (1 to 2^31) is an entity of the + // world object blueprint at the transform, made, moved or taken away by the game a few a frame. + // A transform is {position = vector, right = vector, up = vector, forward = vector} (axes carry + // their scale), {position, yaw, pitch, roll (degrees), scale (number or vector)}, or + // {matrix = {16 numbers}} (rows right, up, forward, position). + static std::uint32_t entity_id(lua_State* L, int index) { + const double id = luaL_checknumber(L, index); + if (!(id >= 1 && id <= 2147483647.0) || id != std::floor(id)) luaL_argerror(L, index, "an object's id is a whole number from 1"); + return static_cast(id); + } + static std::array axis_of(lua_State* L, int table, const char* field) { + lua_getfield(L, table, field); + const float* v = luaL_checkvector(L, -1); + std::array result{v[0], v[1], v[2]}; + lua_pop(L, 1); + return result; + } + static std::array transform_of(lua_State* L, int table) { + luaL_checktype(L, table, LUA_TTABLE); + std::array m{}; + lua_getfield(L, table, "matrix"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + if (lua_objlen(L, -1) != 16) luaL_error(L, "a matrix is 16 numbers"); + for (int i = 0; i < 16; ++i) { + lua_rawgeti(L, -1, i + 1); + m[i] = static_cast(luaL_checknumber(L, -1)); + lua_pop(L, 1); + } + lua_pop(L, 1); + return m; + } + lua_pop(L, 1); + lua_getfield(L, table, "position"); + const auto at = position_of(L, -1, "an object's position"); + lua_pop(L, 1); + lua_getfield(L, table, "right"); + const bool axes = !lua_isnil(L, -1); + lua_pop(L, 1); + std::array right{1, 0, 0}, up{0, 1, 0}, forward{0, 0, 1}; + if (axes) { + right = axis_of(L, table, "right"); + up = axis_of(L, table, "up"); + forward = axis_of(L, table, "forward"); + } else { + const auto degrees = [&](const char* field) { + lua_getfield(L, table, field); + const double value = luaL_optnumber(L, -1, 0); + lua_pop(L, 1); + if (!std::isfinite(value)) luaL_error(L, "%s is a finite number of degrees", field); + return value * 3.14159265358979323846 / 180; + }; + const double yaw = degrees("yaw"), pitch = degrees("pitch"), roll = degrees("roll"); + // Yaw about up (forward = sin, 0, cos at zero pitch), then pitch about right, then roll + // about forward, as the game lays out a world object's axes. + const double cy = std::cos(yaw), sy = std::sin(yaw), cp = std::cos(pitch), sp = std::sin(pitch), cr = std::cos(roll), sr = std::sin(roll); + const std::array f{sy * cp, sp, cy * cp}, r0{cy, 0, -sy}; + const std::array u0{f[1] * r0[2] - f[2] * r0[1], f[2] * r0[0] - f[0] * r0[2], f[0] * r0[1] - f[1] * r0[0]}; + for (int i = 0; i < 3; ++i) { + right[i] = static_cast(r0[i] * cr + u0[i] * sr); + up[i] = static_cast(u0[i] * cr - r0[i] * sr); + forward[i] = static_cast(f[i]); + } + lua_getfield(L, table, "scale"); + std::array scale{1, 1, 1}; + if (lua_isnumber(L, -1)) scale.fill(static_cast(lua_tonumber(L, -1))); + else if (lua_isvector(L, -1)) { + const float* v = lua_tovector(L, -1); + scale = {v[0], v[1], v[2]}; + } else if (!lua_isnil(L, -1)) luaL_error(L, "a scale is a number or a vector"); + lua_pop(L, 1); + for (int i = 0; i < 3; ++i) right[i] *= scale[0], up[i] *= scale[1], forward[i] *= scale[2]; + } + for (int i = 0; i < 3; ++i) m[i] = right[i], m[4 + i] = up[i], m[8 + i] = forward[i], m[12 + i] = at[i]; + m[15] = 1; + return m; + } + static int entities_set(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "world.entities", "reskate.entities.set"); + const auto id = entity_id(L, 1); + const auto asset = checked_text(L, 2, 200, "a blueprint's name"); + const auto matrix = transform_of(L, 3); + return result(L, game(plugin).entity_set(plugin.id(), id, asset, matrix, lua_toboolean(L, 4) != 0)); + } + static int entities_remove(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "world.entities", "reskate.entities.remove"); + game(plugin).entity_remove(plugin.id(), entity_id(L, 1)); + return 0; + } + static int entities_clear(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "world.entities", "reskate.entities.clear"); + game(plugin).entity_clear(plugin.id()); + return 0; + } + static int entities_status(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "world.entities", "reskate.entities.status"); + static constexpr const char* names[] = {nullptr, "waiting", "loading", "missing", "ready", "hidden"}; + const auto status = game(plugin).entity_status(plugin.id(), entity_id(L, 1)); + if (status <= 0 || status > 5) lua_pushnil(L); + else lua_pushstring(L, names[status]); + return 1; + } + static int entities_ready(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "world.entities", "reskate.entities.ready"); + lua_pushboolean(L, game(plugin).entities_ready(plugin.id())); + return 1; + } static int place(lua_State* L) { need(L, plugin_of(L), "world.objects", "reskate.objects.place"); auto object = pose(L, 2, 3, 4); @@ -769,6 +888,22 @@ struct WorldApi { game(plugin).board_park(plugin.id(), bone_list(L, 1)); return 0; } + // fit({plates = bool, axle = 0.8 to 1.6}): how every board in the game is fitted, for a deck model + // that is not the game's own: the baseplates held on the deck, the wheels' spread. fit(nil) ends it. + static int board_fit(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "board", "reskate.board.fit"); + if (lua_isnoneornil(L, 1)) return result(L, game(plugin).board_fit(plugin.id(), std::nullopt)); + luaL_checktype(L, 1, LUA_TTABLE); + Game::BoardFit fit; + lua_getfield(L, 1, "plates"); + fit.plates = lua_toboolean(L, -1); + lua_pop(L, 1); + lua_getfield(L, 1, "axle"); + if (!lua_isnil(L, -1)) fit.axle = static_cast(luaL_checknumber(L, -1)); + lua_pop(L, 1); + return result(L, game(plugin).board_fit(plugin.id(), fit)); + } // toss({bones}, {velocity, spin, ground}): a piece of the board comes off and falls. static int board_toss(lua_State* L) { auto& plugin = plugin_of(L); @@ -1100,9 +1235,11 @@ struct WorldApi { else lua_pushlstring(L, status.data(), status.size()); return 1; } - // mode{id, name, description, art, flag, players = {min, max, default}, - // timer = {caption, min, max, step, default}, rows = {{id, kind, caption, ...}}}: the mode's card on - // the Throwdowns page, its setup page and its start flag. Again with the same id updates it. + // mode{id, name, description, art, color, card, replaces, order, flag, cursor_height, + // players = {min, max, default}}: the mode's card on the Throwdowns page (cloned from the stock + // `card`: "skate", "spot" or "jam") and its native flag's preview prefab; cursor_height (metres) + // lets its flag's placement cursor rise onto anything that high. Its setup page is the plugin's + // `setup` document (throwdowns.ui). Again with the same id updates it. static int int_field(lua_State* L, int table, const char* name, int fallback) { lua_getfield(L, table, name); const int value = lua_isnil(L, -1) ? fallback : static_cast(luaL_checkinteger(L, -1)); @@ -1119,35 +1256,6 @@ struct WorldApi { lua_pop(L, 1); return value; } - static ThrowdownRow row_of(lua_State* L, int table, const char* fallback_kind) { - ThrowdownRow row; - row.id = text_field(L, table, "id"); - row.kind = text_field(L, table, "kind"); - if (row.kind.empty()) row.kind = fallback_kind; - if (row.kind != "slider" && row.kind != "choice" && row.kind != "button") - luaL_error(L, "a row's kind is \"slider\", \"choice\" or \"button\""); - row.caption = text_field(L, table, "caption"); - row.text = text_field(L, table, "text"); - row.minimum = int_field(L, table, "min", 0); - row.maximum = int_field(L, table, "max", 0); - row.step = int_field(L, table, "step", 1); - row.value = int_field(L, table, "default", row.minimum); - lua_getfield(L, table, "options"); - if (!lua_isnil(L, -1)) { - luaL_checktype(L, -1, LUA_TTABLE); - const int count = lua_objlen(L, -1); - for (int i = 1; i <= count && i <= 16; ++i) { - lua_rawgeti(L, -1, i); - if (!lua_isstring(L, -1)) luaL_error(L, "a choice's options are strings"); - row.options.emplace_back(lua_tostring(L, -1)); - lua_pop(L, 1); - } - } - lua_pop(L, 1); - // A choice's default is its option's number, from 1 as Lua counts. - if (row.kind == "choice") row.value = int_field(L, table, "default", 1) - 1; - return row; - } static int throwdown_mode(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "throwdowns", "reskate.throwdowns.mode"); @@ -1158,8 +1266,32 @@ struct WorldApi { mode.description = text_field(L, 1, "description"); mode.art = text_field(L, 1, "art"); mode.flag = text_field(L, 1, "flag"); + mode.replaces = text_field(L, 1, "replaces"); + mode.card = text_field(L, 1, "card"); + if (mode.card.empty()) mode.card = "spot"; + mode.order = int_field(L, 1, "order", 0); + lua_getfield(L, 1, "color"); + if (lua_isnumber(L, -1)) { + const double c = lua_tonumber(L, -1); + if (!(c >= 0 && c <= 4294967295.0)) luaL_argerror(L, 1, "color is 0xAABBGGRR"); + mode.color = static_cast(c); + } + lua_pop(L, 1); + lua_getfield(L, 1, "cursor_height"); + if (!lua_isnil(L, -1)) { + const double h = luaL_checknumber(L, -1); + if (!(h >= 0 && h <= 100000)) luaL_argerror(L, 1, "cursor_height is 0 to 100000 metres"); + mode.cursor_height = static_cast(h); + } + lua_pop(L, 1); + const auto stock = [](const std::string& tile) { return tile == "skate" || tile == "spot" || tile == "spot_battle" || tile == "jam"; }; + if (!mode.replaces.empty() && !stock(mode.replaces)) luaL_argerror(L, 1, "replaces is \"skate\", \"spot\" or \"jam\""); + if (!stock(mode.card)) luaL_argerror(L, 1, "card is \"skate\", \"spot\" or \"jam\""); + if (mode.flag.size() > 256 || mode.art.size() > 256) luaL_argerror(L, 1, "art and flag are at most 256 characters"); + // An asset path: letters, digits, _ - . and /. for (const char c : mode.flag) - if (!std::isalnum(static_cast(c)) && c != '_') luaL_argerror(L, 1, "flag is letters, digits and _"); + if (!std::isalnum(static_cast(c)) && c != '_' && c != '/' && c != '-' && c != '.') + luaL_argerror(L, 1, "flag is an asset path: letters, digits, _, -, . and /"); lua_getfield(L, 1, "players"); if (!lua_isnil(L, -1)) { luaL_checktype(L, -1, LUA_TTABLE); @@ -1169,27 +1301,6 @@ struct WorldApi { mode.players = int_field(L, at, "default", std::min(4, mode.max_players)); } lua_pop(L, 1); - lua_getfield(L, 1, "timer"); - if (!lua_isnil(L, -1)) { - luaL_checktype(L, -1, LUA_TTABLE); - auto timer = row_of(L, lua_gettop(L), "slider"); - if (timer.caption.empty()) timer.caption = "Timer"; - timer.id = "timer"; - mode.timer = std::move(timer); - } - lua_pop(L, 1); - lua_getfield(L, 1, "rows"); - if (!lua_isnil(L, -1)) { - luaL_checktype(L, -1, LUA_TTABLE); - const int count = lua_objlen(L, -1); - for (int i = 1; i <= count && i <= 16; ++i) { - lua_rawgeti(L, -1, i); - luaL_checktype(L, -1, LUA_TTABLE); - mode.rows.push_back(row_of(L, lua_gettop(L), "slider")); - lua_pop(L, 1); - } - } - lua_pop(L, 1); return result(L, game(plugin).set_throwdown_mode(plugin.id(), mode)); } static int throwdown_remove_mode(lua_State* L) { @@ -1198,12 +1309,6 @@ struct WorldApi { game(plugin).remove_throwdown_mode(plugin.id(), luaL_checkstring(L, 1)); return 0; } - static int throwdown_release_flag(lua_State* L) { - auto& plugin = plugin_of(L); - need(L, plugin, "throwdowns", "reskate.throwdowns.release_flag"); - game(plugin).release_throwdown_flag(plugin.id()); - return 0; - } static int throwdown_state(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "throwdowns", "reskate.throwdowns.state"); @@ -1523,12 +1628,14 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { add_functions(L, "skater", {{"state", state}, {"noclip", noclip}, {"boost", boost}, {"freeze", freeze}, {"wipeout", wipeout}, {"xray", xray}, {"grind_exit", grind_exit}}); add_functions(L, "board", {{"motion", board_motion}, {"bones", board_bones}, {"set", board_set}, {"hide", board_hide}, {"clear", board_clear}, - {"park", board_park}, {"toss", board_toss}}); + {"park", board_park}, {"fit", board_fit}, {"toss", board_toss}}); add_functions(L, "camera", {{"look", camera_look}, {"set", camera_set}, {"follow", camera_follow}, {"release", camera_release}, {"held", camera_held}, {"get", camera_get}}); add_functions(L, "trainer", {{"set_point", set_point}, {"back", back}}); add_functions(L, "hud", {{"hide", hide_hud}, {"corner", hud_corner}}); add_functions(L, "time", {{"slow", slow}, {"speed", speed}}); add_functions(L, "objects", {{"catalog", catalog}, {"list", objects}, {"place", place}, {"move", move}, {"remove", remove}}); + add_functions(L, "entities", {{"set", entities_set}, {"remove", entities_remove}, {"clear", entities_clear}, {"status", entities_status}, + {"ready", entities_ready}}); add_functions(L, "world", {{"set_time", set_time}, {"set_control", set_control}, {"set_park", set_park}}); add_functions(L, "travel", {{"locations", locations}, {"levels", levels}, {"go", go}, {"load", load}}); add_functions(L, "ai", {{"spawn", ai_spawn}, {"clear", ai_clear}, {"count", ai_count}}); @@ -1542,7 +1649,7 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { add_functions(L, "server", {{"kick", server_kick}, {"say", server_say}, {"tell", server_tell}, {"move", server_move}, {"ban", server_ban}, {"unban", server_unban}}); add_functions(L, "throwdowns", {{"state", throwdown_state}, {"host", throwdown_host}, {"cancel", throwdown_cancel}, {"hosting", throwdown_hosting}, - {"mode", throwdown_mode}, {"remove_mode", throwdown_remove_mode}, {"release_flag", throwdown_release_flag}}); + {"mode", throwdown_mode}, {"remove_mode", throwdown_remove_mode}}); add_functions(L, "music", {{"current", current}, {"play", play}, {"pause", pause}, {"next", next}, {"previous", previous}, {"game", game_current}, {"game_next", game_next}}); add_functions(L, "progression", {{"get", progress}}); diff --git a/Extension/Plugins/plugin_camera.h b/Extension/Plugins/plugin_camera.h index bb01df12..7fc4ce14 100644 --- a/Extension/Plugins/plugin_camera.h +++ b/Extension/Plugins/plugin_camera.h @@ -10,8 +10,7 @@ #include #include -// World positions on screen, through the camera as it is now (as the nametags and Domination's -// overlay read it). The plugin host projects with it on the client thread (world_to_screen) and the +// World positions on screen, through the camera as it is now (as the nametags read it). The plugin host projects with it on the client thread (world_to_screen) and the // overlay on the render thread (world shapes), each with the camera of its own moment. namespace dingosdk::plugin_camera { inline std::atomic& image_base() { diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index c2ac1993..efa37c18 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -1,8 +1,11 @@ #pragma once #include "plugin_wave.h" +#include "Engine/Core/Json/json.h" #include "Extension/AnimationStudio/anim_core.h" +#include "Extension/Throwdowns/throwdown_kit.h" #include #include +#include #include #include #include @@ -25,6 +28,38 @@ inline constexpr char unavailable[] = "unavailable here"; struct CameraView { Vec3 position{}, forward{}; float fov{}; + Vec3 right{}, up{}; + bool free_camera{}; // the view is Freecam's, verified selected (not just asked for) +}; +// What a game layer reads of the local skater each tick (reskate.skater.telemetry): the trainer's +// telemetry and its last measured jump, the physics state the skater's selector chose (with No +// Bail's wipeout and runout counts), the Bone Cam's slams and where the body is during a bail. +struct SkaterTelemetry { + bool skater{}, airborne{}; + Vec3 position{}; + float heading{}; // the trainer's degrees: 0 faces +Z, forward (sin, 0, cos) + float speed{}, vertical{}, air_time{}, height{}, top_speed{}; + std::uint32_t physics_state{}; + struct Jump { + std::uint64_t serial{}; + float takeoff_speed{}, takeoff_angle{}, air_time{}, height{}, distance{}, drop{}, landing_speed{}; + float spin{}, spin_rate{}, flip{}, board_turn{}; + std::uint32_t state{}; + Vec3 takeoff{}, landing{}; + } last; + struct Watch { + bool valid{}, ragdoll_known{}, ragdoll{}; + std::uint32_t state{}, previous{}; + float previous_seconds{}; + std::uint64_t changes{}, wipeouts{}, runouts{}; + } watch; + bool teleport_pending{}, slam{}, struck{}; + std::optional body; // the hips, during a bail (Bone Cam) +}; +struct TrickNote { + std::uint64_t entry{}; + char kind{}; // 'n' name, 's' side, 'l' landing, 'b' body spin, 'r' board spin, 'k' stance, 'm' modifier + std::string text; }; // Input for a plugin that flies something: the flight keys and mouse look (as Freecam reads them), // a gamepad's sticks, triggers and buttons (XInput's bits), a joystick or radio's axes and buttons. @@ -156,29 +191,43 @@ struct ThrowdownState { }; std::vector skate; // S.K.A.T.E.'s players and letters, while one is shown }; -// A throwdown mode on the game's Throwdowns page: a card on its picker, the game's own setup -// page with these rows, and the game's start flag. The plugin runs the mode itself. -struct ThrowdownRow { - std::string id, kind, caption, text; // kind "slider", "choice" or "button"; text: a button's label - int minimum{}, maximum{}, step{1}, value{}; - std::vector options; // a choice's; its value is the option's index (from 0) -}; +// A throwdown mode on the game's Throwdowns page: a card on its picker, the game's own setup page +// (relabelled from the plugin's setup document) and the game's start flag. The plugin runs it. struct ThrowdownMode { std::string id, name, description; - std::string art; // the logo: a texture or image asset the plugin's mod adds, by path; empty: Spot Battle's - std::string flag; // the mod's own flag (InstanceId ReSkate.); empty: Spot Battle's + std::string art; // the logo: a texture or image asset the plugin's mod adds, by path; empty: the card's own + std::string flag; // the placement-preview prefab its native flag shows, by path; empty: Spot Battle's + std::string card = "spot"; // the stock card it is cloned from: "skate", "spot" or "jam" + std::string replaces; // the stock throwdown whose place it takes: "skate", "spot", "jam" or empty + std::uint32_t color{}; + int order{}; // among the plugin modes on the picker int min_players = 1, max_players = 6, players = 4; - std::optional timer; - std::vector rows; + float cursor_height{}; // > 0: its flag's placement cursor may rise this far (metres) }; -// "opened", "setting", "button", "placed", "start", "leave" or "cancelled" for one of the -// plugin's modes, with every setting (players, timer and the rows by id). -struct ThrowdownModeNews { - std::string mode, action, row; - int value{}; - std::map settings; - std::optional at; - float heading{}; // degrees +// reskate.ui.dialog: an overlay dialog over the game (plugin_dialog.cpp). Its answers come back to +// the plugin as throwdown_ui events with action "dialog" (throwdown_kit.h). +struct DialogSpec { + std::string id, title, message; + struct Field { + bool present{}; + std::string text; + std::size_t max{64}; + } field; + struct Row { + std::string text, sub; + std::vector buttons; // each answers with its name and the row + }; + bool list{}; + std::vector rows; + std::string empty; // shown in a list with no rows + struct Button { + std::string id, label; + }; + std::vector