mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-23 09:45:53 +02:00
Merge plugins into server-lua-api
The new services and throwdown kit API files use add_functions and the shared helpers; add_functions opens a read-only parent for reskate.throwdowns.flag. throwdowns.release_flag stays removed, as plugins has it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
cd36529865
228 files changed
+29595
-3083
No files matched your search
+4
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Build/fingerprint.h"
|
||||
#include <cstdint>
|
||||
// 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;
|
||||
}
|
||||
@@ -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<unsigned char,19> 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<unsigned char, 19> mode_available_prefix{
|
||||
|
||||
@@ -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<Fingerprint, 4> bail_consumer_contracts{{
|
||||
inline constexpr std::array<Fingerprint, 5> 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<Fingerprint, 4> 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
|
||||
}
|
||||
@@ -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<std::uintptr_t, 2> skater_component_base_vtables{0x61705e8, 0x6170650};
|
||||
// ClientSkaterSource::applyAppearance. It enables the customization component and requests
|
||||
@@ -42,6 +45,12 @@ inline constexpr std::array<unsigned char, 16> release_reference_prefix{
|
||||
inline constexpr std::uintptr_t place_entity = 0x53ed70;
|
||||
inline constexpr std::array<unsigned char, 12> 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<unsigned char, 22> 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<unsigned char, 15> initialize_placement_prefix{
|
||||
0x4c,0x8b,0xdc,0x53,0x55,0x56,0x57,0x48,0x83,0xec,0x68,0xf6,0x41,0x28,0x08};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
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<std::uint16_t>(buttons & ~0x0010u):buttons;
|
||||
}
|
||||
constexpr bool owned_menu_countdown_capture(bool countdown,bool native_panel) noexcept {
|
||||
return countdown && !native_panel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
|
||||
namespace dingosdk {
|
||||
namespace session_competition_detail { inline std::atomic<bool> 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);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <mutex>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
#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<std::uint8_t> &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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
#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<bool> noclip{true}, no_bail{true}, boosts{true}, ollies{true};
|
||||
inline std::atomic<bool> 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); }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ChunkRecord> 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<std::size_t> rows;
|
||||
check(read_list(merge_chunk_metadata(lists, oneMod, gameList, shipped, &rows)) ==
|
||||
std::vector<Record>{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<std::size_t>{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<std::size_t>{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<Bytes> modsOnly{list_of({firstTexture, firstMesh}), list_of({firstTexture, firstMesh, secondTexture})};
|
||||
const std::vector<ChunkRecord> 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<std::size_t>{0, 1, 2}, "and has each record where its chunk is listed");
|
||||
const std::vector<ChunkRecord> both{chunk(0x90, 0, 0), chunk(0x20, 0, 1), chunk(0x40, 1, 2)};
|
||||
check(read_list(merge_chunk_metadata(modsOnly, both)) == std::vector<Record>{firstMesh, secondTexture, firstTexture},
|
||||
"and otherwise each chunk's record from the copy it came in, by guid like any other");
|
||||
|
||||
@@ -26,8 +26,10 @@
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -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<Ebx>& assets, const std::vector<Resource>& 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<unsigned char>(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<Chunk> 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<const std::byte*>(written.data());
|
||||
return {bytes, bytes + written.size()};
|
||||
}
|
||||
std::vector<Record> records_in(std::span<const std::byte> list) {
|
||||
const std::span<const unsigned char> bytes(reinterpret_cast<const unsigned char*>(list.data()), list.size());
|
||||
std::vector<Record> 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<Ebx>& assets, const std::vector<Resource>& resources, const std::vector<Chunk>& chunks = {}) {
|
||||
fb::BinaryBundle manifest;
|
||||
for (const auto& asset : assets) {
|
||||
fb::BundleAsset entry;
|
||||
@@ -185,6 +241,16 @@ fb::BinaryBundle manifest_of(const std::vector<Ebx>& assets, const std::vector<R
|
||||
entry.originalSize = resource.payload.size();
|
||||
manifest.resources.push_back(std::move(entry));
|
||||
}
|
||||
for (const auto& chunk : chunks) {
|
||||
fb::BundleAsset entry;
|
||||
entry.kind = fb::AssetKind::chunk;
|
||||
entry.guid = chunk.guid;
|
||||
entry.sha1 = sha(chunk.guid.string(), 0);
|
||||
entry.originalSize = chunk.payload.size();
|
||||
entry.logicalSize = static_cast<std::uint32_t>(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<Ebx>& assets, const std::vector<Chunk>& chunks) {
|
||||
const auto path = catalog.data_root / "Data" / "Win32" / "pkg" / "cas_01.cas";
|
||||
auto archive = read(path);
|
||||
std::vector<fb::BundleFileInfo> files;
|
||||
const auto store = [&](const Bytes& bytes) {
|
||||
files.push_back({{false, package, 1}, static_cast<std::uint32_t>(archive.size()), static_cast<std::uint32_t>(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<fb::TocBundle> 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<std::string>& superbundles,
|
||||
const std::vector<Ebx>& assets, const std::vector<Resource>& resources = {}, const char* bundle = bundle_name) {
|
||||
const auto listing = fb::write_binary_bundle(manifest_of(assets, resources));
|
||||
const std::vector<Ebx>& assets, const std::vector<Resource>& resources = {}, const char* bundle = bundle_name,
|
||||
const std::vector<Chunk>& chunks = {}) {
|
||||
const auto listing = fb::write_binary_bundle(manifest_of(assets, resources, chunks));
|
||||
Bytes archive(listing);
|
||||
std::vector<fb::BundleFileInfo> files{{{true, package, 1}, 0, static_cast<std::uint32_t>(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<fb::TocBundle> 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<std::string> 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<std::string, Bytes> snapshot(const fs::path& folder) {
|
||||
std::map<std::string, Bytes> 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<std::string> 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<std::string>{"Win32/test_shared.toc"} &&
|
||||
files.archives == std::vector<std::string>{"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<mods::detail::Listing> 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<Record> 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;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#include "Engine/Vfs/mod_catalog.h"
|
||||
#include "Engine/Game/Build/supported_build.h"
|
||||
#include <Windows.h>
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
// 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::Mod> &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<std::string>{"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";
|
||||
}
|
||||
+149
-180
@@ -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<std::string>& bundles) {
|
||||
if (bundles.empty()) return false;
|
||||
std::error_code error;
|
||||
std::vector<fs::path> 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<char>(file), {}};
|
||||
if (bytes.find(deck_bundle) != std::string::npos) return true;
|
||||
const auto bytes = lower(std::string{std::istreambuf_iterator<char>(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.
|
||||
// {"<mod folder, lower-case>": "<variant>"}
|
||||
fs::path variants_file() {
|
||||
std::array<wchar_t, 1024> text{};
|
||||
const auto size = GetEnvironmentVariableW(L"LOCALAPPDATA", text.data(), static_cast<DWORD>(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::size_t>(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<std::pair<std::string, fs::path>> 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<std::pair<std::string, fs::path>> variants_inside(const Mod& mod) {
|
||||
std::vector<std::pair<std::string, fs::path>> 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::size_t>(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<fs::path> deck_mod_folders() {
|
||||
std::vector<fs::path> 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<std::pair<std::string, fs::path>> installed_decks() {
|
||||
std::vector<std::pair<std::string, fs::path>> 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<std::string, std::string, std::less<>>;
|
||||
struct VariantsApplied {
|
||||
VariantMap in_play;
|
||||
std::vector<std::string> set_aside;
|
||||
};
|
||||
std::mutex board_break_mutex;
|
||||
std::string chosen_deck;
|
||||
std::optional<BoardBreakApplied> applied_board_break;
|
||||
std::mutex variants_mutex;
|
||||
std::optional<VariantMap> chosen_variants;
|
||||
std::optional<VariantsApplied> 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<char>(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 <pack's folder>#<deck>; 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<std::string> installed_variants(const Mod& mod) {
|
||||
std::vector<std::string> 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<std::filesystem::path> board_break_folders() noexcept {
|
||||
std::vector<fs::path> 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<std::string> installed_board_break_decks() noexcept {
|
||||
std::vector<std::string> 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<std::string> set_aside_for_board_break() {
|
||||
std::vector<std::string> 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 <folder>#<deck>, 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<std::string> packs;
|
||||
// Variants (mod_catalog.h): each variant a mod holds becomes a mod of its own for the run,
|
||||
// listed after it as <folder>#<variant>, 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<std::string> 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<std::ptrdiff_t>(++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<std::ptrdiff_t>(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.
|
||||
|
||||
+26
-42
@@ -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<Mod> 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<std::string, std::string, std::less<>> variants_in_play;
|
||||
std::vector<std::string> 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_<Name> (also behind a team name, as a mod manager
|
||||
// installs it: Team-Board_Break_<Name>) holds the deck <name>;
|
||||
// - a deck pack, a mod of any name with a folder decks/<name>/ 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 <pack's folder>#<name>. 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_<Name> 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<std::string_view, 10> 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<std::string> installed_board_break_decks() noexcept;
|
||||
std::vector<std::filesystem::path> board_break_folders() noexcept;
|
||||
// The folder with a deck's own files (a pack's decks/<name>, or the Board_Break_<Name> 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 <mod's folder>#<variant>, 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<std::string> 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<std::string> 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<std::string> 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.
|
||||
|
||||
+123
-1
@@ -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<std::string>& 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<unsigned char>(c) < 0x20 || static_cast<unsigned char>(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<std::string> 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<ResidentAssets> 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<std::string>& 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<unsigned char>(c) < 0x20 || static_cast<unsigned char>(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<std::string> 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<std::string> 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<char>(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 {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <cstddef>
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
@@ -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=<the Skate.exe it was built for> 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<std::string> roots;
|
||||
struct Shader {
|
||||
std::string name;
|
||||
std::vector<std::string> roots;
|
||||
};
|
||||
std::vector<Shader> 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 <folder>/<name>/ with a layout.toc is the variant <name> (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<std::string> order;
|
||||
std::vector<std::string> displaces;
|
||||
};
|
||||
|
||||
struct Mod {
|
||||
std::string name; // folder name directly under Mods/
|
||||
std::filesystem::path directory;
|
||||
@@ -50,6 +85,10 @@ struct Mod {
|
||||
std::vector<std::string> levels;
|
||||
// What the merge could not use from this mod. Empty once it merged cleanly.
|
||||
std::vector<std::string> problems;
|
||||
// From reskate-plugin.json: the assets its bundles keep resident (ResidentAssets).
|
||||
std::vector<ResidentAssets> resident;
|
||||
// From reskate-plugin.json: the variants it holds (Variants), if it declares any.
|
||||
std::optional<Variants> 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<std::string> 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);
|
||||
|
||||
+14
-11
@@ -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<ItemSource>{};
|
||||
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);
|
||||
|
||||
@@ -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<const std::byte> encoded) {
|
||||
|
||||
@@ -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<std::byte> merge_chunk_metadata(std::span<const std::vector<std::byte>> lists,
|
||||
std::span<const ChunkRecord> chunks, std::size_t game,
|
||||
std::span<const fb::Guid> shipped) {
|
||||
std::span<const fb::Guid> shipped, std::vector<std::size_t>* 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<std::byte>{} : lists.front();
|
||||
const bool shippedList = game < lists.size() && !lists[game].empty();
|
||||
@@ -254,7 +260,22 @@ std::vector<std::byte> merge_chunk_metadata(std::span<const std::vector<std::byt
|
||||
for (std::size_t index = 0; whole && index < chunks.size(); ++index)
|
||||
whole = chunks[index].copy == first && chunks[index].index == index &&
|
||||
(!shippedList || chunks[index].shipped == index);
|
||||
if (whole) return lists[first];
|
||||
// A carried dependency can be only the first chunk of a larger source
|
||||
// bundle. Matching its indices does not mean that source's whole list fits.
|
||||
if (whole && !shippedList)
|
||||
whole = native_db::read(unsigned_bytes(lists[first]), "chunk metadata", nullptr,
|
||||
{.unique_fields=false,.max_entries=lists[first].size()}).children.size()==chunks.size();
|
||||
if (whole) {
|
||||
// The game's own list, when it has a record for each chunk, has them by the chunks' guids.
|
||||
if (rows && shippedList && native_db::read(unsigned_bytes(lists[first]), "chunk metadata", nullptr,
|
||||
{.unique_fields=false,.max_entries=lists[first].size()}).children.size()==chunks.size()) {
|
||||
std::vector<std::size_t> 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<std::optional<native_db::Node>> read(lists.size());
|
||||
const auto list = [&](std::size_t copy) -> const native_db::Node* {
|
||||
@@ -337,6 +358,8 @@ std::vector<std::byte> merge_chunk_metadata(std::span<const std::vector<std::byt
|
||||
merged.children.clear();
|
||||
merged.children.reserve(records.size());
|
||||
for (const auto index : order) merged.children.push_back(std::move(records[index]));
|
||||
if (rows)
|
||||
for (std::size_t at = 0; at < order.size(); ++at) (*rows)[order[at]] = at;
|
||||
const auto bytes = native_db::write(merged);
|
||||
const auto* data = reinterpret_cast<const std::byte*>(bytes.data());
|
||||
return {data, data + bytes.size()};
|
||||
@@ -387,12 +410,17 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
|
||||
std::multimap<fb::Guid, std::pair<const std::string*, const AssetAddition*>> addedByGuid;
|
||||
// The resource a mod added to a bundle under a name, by bundle, mod and name.
|
||||
std::map<std::tuple<std::string, std::string, std::string>, const AssetAddition*> companions;
|
||||
std::map<const AssetAddition*,const std::string*> additionBundles;
|
||||
std::map<std::tuple<std::string,std::string,fb::Guid>,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<std::tuple<std::string, std::uint32_t, std::uint16_t, std::uint32_t>> 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;index<take.size();++index) {
|
||||
const auto* asset=take[index];
|
||||
const auto* from=additionBundles.at(asset);
|
||||
for (const auto& id:asset->chunk_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<std::string, std::less<>> 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<std::vector<std::byte>> chunkLists;
|
||||
if (hasDefaults) chunkLists.push_back(defaults->second.metadata);
|
||||
const auto ownList=chunkLists.size();
|
||||
chunkLists.push_back(casManifest.chunkMetadata);
|
||||
std::vector<ChunkRecord> chunkRecords;
|
||||
std::vector<fb::Guid> originalChunks;
|
||||
if (hasDefaults) originalChunks=defaults->second.chunks;
|
||||
std::map<fb::Guid,std::size_t> stockChunks;
|
||||
for (std::size_t index=0;index<originalChunks.size();++index) stockChunks.emplace(originalChunks[index],index);
|
||||
for (std::size_t index=0;index<casManifest.chunks.size();++index) {
|
||||
const auto id=casManifest.chunks[index].guid;
|
||||
const auto shipped=stockChunks.find(id);
|
||||
chunkRecords.push_back({id,chunkLists[ownList].empty()?ChunkRecord::none:ownList,index,
|
||||
shipped==stockChunks.end()?ChunkRecord::none:shipped->second});
|
||||
}
|
||||
bool chunksAdded{};
|
||||
for (auto& addition : additions) {
|
||||
const auto ebx = addition.asset.kind == fb::AssetKind::ebx;
|
||||
const auto at = static_cast<std::ptrdiff_t>(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<std::size_t>(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<std::ptrdiff_t>(at), addition.file);
|
||||
inPatch.insert(inPatch.begin() + static_cast<std::ptrdiff_t>(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<std::size_t> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <span>
|
||||
@@ -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<std::string> 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<fb::Guid> names;
|
||||
// Bundled mesh/texture data follows the EBX/RES that references it.
|
||||
std::vector<fb::Guid> chunk_names;
|
||||
std::shared_ptr<const std::vector<std::byte>> chunk_metadata;
|
||||
std::size_t chunk_index{};
|
||||
};
|
||||
struct AssetOverrides {
|
||||
struct ChunkDefaults { std::vector<std::byte> metadata; std::vector<fb::Guid> chunks; };
|
||||
std::map<std::string,ChunkDefaults,std::less<>> bundle_chunk_defaults;
|
||||
// By lower-case asset name, then the game's sha1 the change replaces.
|
||||
std::map<std::string, std::map<fb::Sha1, AssetOverride>, 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<std::size_t>(-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<std::byte> merge_chunk_metadata(std::span<const std::vector<std::byte>> lists,
|
||||
std::span<const ChunkRecord> chunks,
|
||||
std::size_t game = ChunkRecord::none,
|
||||
std::span<const fb::Guid> shipped = {});
|
||||
std::span<const fb::Guid> shipped = {},
|
||||
std::vector<std::size_t>* rows = nullptr);
|
||||
|
||||
fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
|
||||
std::vector<Source>& sources, MergeReport& report,
|
||||
|
||||
@@ -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<std::pair<std::string, AssetAddition>> candidates, companions;
|
||||
struct BundleChunk { std::string bundle; AssetAddition addition; fb::BundleFileInfo file; };
|
||||
std::vector<BundleChunk> bundleChunks;
|
||||
std::map<std::string,AssetOverrides::ChunkDefaults,std::less<>> chunkDefaults;
|
||||
std::vector<bool> unreadable; // per candidate: never follows
|
||||
std::map<std::string, std::set<fb::Guid>, std::less<>> addedIn; // the documents it added to each bundle, by guid
|
||||
std::vector<fb::TocChunk> newChunks; // TOC chunks the game's TOCs do not have
|
||||
@@ -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<GameToc>& gameTocs, Shared<GameBundle>& 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<const std::byte> 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<const std::vector<std::byte>>(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<const Mod*>& 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<const Mod*>& 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<fb::Guid, fb::Guid> aliases;
|
||||
std::vector<bool> starts(0x10000);
|
||||
const auto add_alias = [&](const fb::Guid& bytes, const fb::Guid& id) {
|
||||
aliases.emplace(bytes,id);
|
||||
starts[std::to_integer<std::size_t>(bytes.bytes[0])*256 +
|
||||
std::to_integer<std::size_t>(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::string,std::set<fb::Guid>,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<std::byte> bytes;
|
||||
try { bytes=fb::decode_cas(addition.encoded,{gameRoot}); }
|
||||
catch (const std::exception&) { continue; }
|
||||
std::set<fb::Guid> named;
|
||||
for (std::size_t at=0;at+16<=bytes.size();++at) {
|
||||
if (!starts[std::to_integer<std::size_t>(bytes[at])*256+
|
||||
std::to_integer<std::size_t>(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<const Mod*>& 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<std::vector<std::byte>> 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
|
||||
|
||||
@@ -83,7 +83,7 @@ std::string merge_fingerprint(const Catalog& catalog, const std::vector<const Mo
|
||||
if (error) throw std::runtime_error("Cannot list " + path_utf8(mod->directory));
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<std::uint8_t,3> 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<Address>(on_board.data()),transition);
|
||||
}
|
||||
}
|
||||
if (!id && stance && throwdown_relay_waits_offboard()) {
|
||||
static thread_local std::array<std::uint8_t, 3> on_foot{};
|
||||
if (memory::peek_bytes(stance, on_foot.data(), on_foot.size()) && on_foot[0]) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -431,6 +431,10 @@ std::string edit_player_distance(Session &s, std::string_view argument) {
|
||||
: "Players within " + std::to_string(static_cast<int>(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<std::uint64_t>(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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <Windows.h>
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
|
||||
@@ -24,6 +25,35 @@ std::string name(const char *value) {
|
||||
if (static_cast<unsigned char>(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<std::string> tokens;
|
||||
for(std::size_t i=0;i<line.size();++i) {
|
||||
if(line[i]!='"')continue;
|
||||
std::string token;
|
||||
for(++i;i<line.size() && line[i]!='"';++i) {
|
||||
if(line[i]=='\\' && i+1<line.size())++i;
|
||||
token.push_back(line[i]);
|
||||
}
|
||||
tokens.push_back(std::move(token));
|
||||
}
|
||||
if(tokens.size()==1 && tokens.front().size()==17 && tokens.front().find_first_not_of("0123456789")==std::string::npos)
|
||||
account=tokens.front()==wanted;
|
||||
else if(account && tokens.size()==2 && tokens.front()=="PersonaName")return name(tokens.back().c_str());
|
||||
}
|
||||
} catch(...) {}
|
||||
return {};
|
||||
}
|
||||
struct Api {
|
||||
HMODULE module{};
|
||||
int (*user_handle)(){};
|
||||
@@ -91,7 +121,10 @@ std::shared_ptr<const SteamSocialSnapshot> 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<const SteamSocialSnapshot>(std::move(result));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <algorithm>
|
||||
#include <array>
|
||||
|
||||
@@ -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<std::string>{"status", "ai", "spawn", "join", "start", "destroy", "mmid", "trace", "endturn", "score", "mirror", "attempt", "relay"};
|
||||
return std::vector<std::string>{"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<std::array<float, 3>> skater;
|
||||
if (model.debug.skater_position_valid) skater = model.debug.skater_position;
|
||||
|
||||
@@ -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<float, 3> origin{}, end{};
|
||||
};
|
||||
std::mutex mutex;
|
||||
std::map<std::uint32_t, Ray> pending;
|
||||
std::map<std::uint32_t, WorldRayHit> 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<std::uint32_t, WorldRays::Ray> 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<std::uintptr_t, 2> 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<float, 3> &origin, const std::array<float, 3> &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<float, 3> 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);
|
||||
|
||||
@@ -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<float, 3> 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<float, 3> &origin, const std::array<float, 3> &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`).
|
||||
|
||||
@@ -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<float,3> 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<float, 16> 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<std::uint64_t, 2> 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());
|
||||
|
||||
@@ -76,11 +76,10 @@ struct PlacementsRuntime {
|
||||
std::vector<PlacementRow> 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<std::array<float, 3>> position_teleport;
|
||||
std::optional<std::array<float,16>> 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<float> position_teleport_heading;
|
||||
std::optional<profile::PlacedObject> inflight;
|
||||
std::uint64_t sent_at{}, next_poll{}, map_since{}, map_generation{}, next_id{1};
|
||||
bool failed{}, restore_failed{};
|
||||
|
||||
@@ -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 <algorithm>
|
||||
#include <chrono>
|
||||
@@ -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<int> 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<SkaterTelemetry> telemetry;
|
||||
std::optional<SkaterTelemetry> 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<std::array<float, 2>> 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<std::string>& 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<int> 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<std::string, std::uint32_t>, std::pair<std::string, std::array<float, 16>>> entities;
|
||||
std::string entity_set(std::string_view plugin, std::uint32_t id, std::string_view asset, const std::array<float, 16>& 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<ThrowdownMode> modes;
|
||||
std::vector<ThrowdownModeNews> 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<ThrowdownModeNews> 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 "<plugin>/<path>".
|
||||
std::map<std::string, std::string> files;
|
||||
std::string files_list(std::string_view plugin, std::string_view folder, std::vector<std::string>& 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<DialogSpec> 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<std::uint32_t> pad_bits_now;
|
||||
InputFocus focus_now{};
|
||||
std::optional<std::uint32_t> 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<std::pair<std::string, std::string>> 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<std::string>{"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<std::string>{"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<std::string> ids;
|
||||
for (const auto& d : registry.definitions()) ids.push_back(d.id);
|
||||
check(ids == std::vector<std::string>{"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<std::string> rows;
|
||||
};
|
||||
const std::vector<Expect> 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<std::string> 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);
|
||||
|
||||
@@ -17,14 +17,15 @@ namespace {
|
||||
struct Event {
|
||||
std::string_view name, permission;
|
||||
};
|
||||
constexpr std::array<Event, 25> events{{
|
||||
constexpr std::array<Event, 26> 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<double>(v[0]), static_cast<double>(v[1]), static_cast<double>(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<ApiFun
|
||||
lua_pop(L, 1);
|
||||
lua_createtable(L, 0, static_cast<int>(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<int>(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<int>(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");
|
||||
|
||||
@@ -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.<field>, 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.<field>, 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<ApiFunction> functions);
|
||||
// Adds the functions to the reskate table itself (on top of the stack).
|
||||
void add_functions(lua_State* L, std::initializer_list<ApiFunction> functions);
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
#include "plugin_api.h"
|
||||
#include "plugin_game.h"
|
||||
#include "lua.h"
|
||||
#include "lualib.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// 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<std::uint64_t>(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<std::uint64_t>(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<double>(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<double>(w.changes));
|
||||
field_number(L, "wipeouts", static_cast<double>(w.wipeouts));
|
||||
field_number(L, "runouts", static_cast<double>(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<std::string> 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<std::size_t>(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<int>(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<const char*, int> 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<std::uint16_t>(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<std::size_t>(length, 96)));
|
||||
lua_createtable(L, static_cast<int>(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<int>(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
|
||||
@@ -0,0 +1,365 @@
|
||||
#include "plugin_api.h"
|
||||
#include "plugin_game.h"
|
||||
#include "lua.h"
|
||||
#include "lualib.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
|
||||
// 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<int>(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 "<plugin>/<id>".
|
||||
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<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
static constexpr std::array<std::string_view, 4> 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<unsigned>(number_field(L, 3, "players", options.players, 1, 10));
|
||||
options.seconds = static_cast<unsigned>(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<float>(number_field(L, 3, "cursor_height", options.cursor_height, 0, 100000));
|
||||
}
|
||||
lua_pushboolean(L, game(plugin).throwdown_flag_begin(plugin.id() + "/" + mode, static_cast<std::uint32_t>(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<std::string> names;
|
||||
if (const auto why = game(plugin).files_list(plugin.id(), folder, names); !why.empty()) return failed(L, why);
|
||||
lua_createtable(L, static_cast<int>(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<int>(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<std::size_t>(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<unsigned char>(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}});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<std::uint32_t>(id);
|
||||
}
|
||||
static std::array<float, 3> axis_of(lua_State* L, int table, const char* field) {
|
||||
lua_getfield(L, table, field);
|
||||
const float* v = luaL_checkvector(L, -1);
|
||||
std::array<float, 3> result{v[0], v[1], v[2]};
|
||||
lua_pop(L, 1);
|
||||
return result;
|
||||
}
|
||||
static std::array<float, 16> transform_of(lua_State* L, int table) {
|
||||
luaL_checktype(L, table, LUA_TTABLE);
|
||||
std::array<float, 16> 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<float>(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<float, 3> 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<double, 3> f{sy * cp, sp, cy * cp}, r0{cy, 0, -sy};
|
||||
const std::array<double, 3> 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<float>(r0[i] * cr + u0[i] * sr);
|
||||
up[i] = static_cast<float>(u0[i] * cr - r0[i] * sr);
|
||||
forward[i] = static_cast<float>(f[i]);
|
||||
}
|
||||
lua_getfield(L, table, "scale");
|
||||
std::array<float, 3> scale{1, 1, 1};
|
||||
if (lua_isnumber(L, -1)) scale.fill(static_cast<float>(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<float>(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<int>(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<std::uint32_t>(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<float>(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<unsigned char>(c)) && c != '_') luaL_argerror(L, 1, "flag is letters, digits and _");
|
||||
if (!std::isalnum(static_cast<unsigned char>(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}});
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
#include <numbers>
|
||||
#include <optional>
|
||||
|
||||
// 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<std::uintptr_t>& image_base() {
|
||||
|
||||
+166
-22
@@ -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 <array>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
@@ -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<Vec3> 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<Player> 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<std::string> 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.<flag>); 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<ThrowdownRow> timer;
|
||||
std::vector<ThrowdownRow> 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<std::string, int> settings;
|
||||
std::optional<Vec3> 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<std::string> buttons; // each answers with its name and the row
|
||||
};
|
||||
bool list{};
|
||||
std::vector<Row> rows;
|
||||
std::string empty; // shown in a list with no rows
|
||||
struct Button {
|
||||
std::string id, label;
|
||||
};
|
||||
std::vector<Button> buttons;
|
||||
struct File {
|
||||
bool present{};
|
||||
std::string label, filter; // filter: "*.json" or "*.json;*.txt"
|
||||
} file;
|
||||
};
|
||||
struct Progress {
|
||||
bool available{};
|
||||
@@ -215,6 +264,17 @@ public:
|
||||
// "place" (item and pose), "move" (id and pose) or "delete" (id): through the park editor,
|
||||
// under the session's object rules and limits. One at a time: it is busy until done.
|
||||
virtual std::string edit_object(std::string_view, const GameObject&) { return unavailable; }
|
||||
// A plugin's own world objects (world_entities.h): an entity of a world object blueprint at a
|
||||
// full transform (rows: right, up, forward axes with their scale, then the position), kept by the
|
||||
// plugin's id for it. Only this player's game shows them. Status: 0 none, 1 waiting, 2 loading,
|
||||
// 3 missing, 4 ready, 5 hidden.
|
||||
virtual std::string entity_set(std::string_view /*plugin*/, std::uint32_t, std::string_view, const std::array<float, 16>&, bool) {
|
||||
return unavailable;
|
||||
}
|
||||
virtual void entity_remove(std::string_view /*plugin*/, std::uint32_t) {}
|
||||
virtual void entity_clear(std::string_view /*plugin*/) {}
|
||||
virtual int entity_status(std::string_view /*plugin*/, std::uint32_t) { return 0; }
|
||||
virtual bool entities_ready(std::string_view /*plugin*/) { return false; }
|
||||
// kind "tod" (key: morning, noon, afternoon, evening, night, weatherday, weathernight, default),
|
||||
// "control" (key: fog, fog_distance, sky_brightness, clouds, wind_strength, wind_direction,
|
||||
// traffic, pedestrians; value a number, -1 the default) or "park" (key: a lot, value a layout).
|
||||
@@ -251,9 +311,75 @@ public:
|
||||
// Adds the mode to the Throwdowns page, or updates it (by id).
|
||||
virtual std::string set_throwdown_mode(std::string_view /*plugin*/, const ThrowdownMode&) { return unavailable; }
|
||||
virtual void remove_throwdown_mode(std::string_view /*plugin*/, std::string_view /*id*/) {}
|
||||
// Takes the plugin's start flag down: its lobby is over.
|
||||
virtual void release_throwdown_flag(std::string_view /*plugin*/) {}
|
||||
virtual std::vector<ThrowdownModeNews> take_throwdown_news(std::string_view /*plugin*/) { return {}; }
|
||||
// The throwdown kit (throwdown_kit.h): the plugin's documents for the native UI, and the one
|
||||
// native flag registration (`mode` is the SDK's id, "<plugin>/<id>"). Leave lets go of the
|
||||
// registration only when it is one of the plugin's modes.
|
||||
virtual std::string publish_throwdown_ui(std::string_view /*plugin*/, const Json&) { return unavailable; }
|
||||
virtual bool throwdown_flag_begin(std::string_view /*mode*/, std::uint32_t /*game*/, const throwdowns::kit::flag::Options&) { return false; }
|
||||
virtual throwdowns::kit::flag::State throwdown_flag_state() { return {}; }
|
||||
virtual void throwdown_flag_leave(std::string_view /*plugin*/) {}
|
||||
// ---- general services for a game layer in Lua (plugin_api_services.cpp) ----------------------
|
||||
virtual std::optional<SkaterTelemetry> skater_telemetry() { return std::nullopt; }
|
||||
// The pad's XInput button bits, LT 0x10000 and RT 0x20000 (empty: no pad). `menu`: read while
|
||||
// ReSkate's menu is open too.
|
||||
virtual std::optional<std::uint32_t> pad_bits(bool /*menu*/) { return std::nullopt; }
|
||||
virtual bool key_down(int /*virtual_key*/) { return false; }
|
||||
virtual int mouse_wheel() { return 0; }
|
||||
// Game buttons kept from the game while asked (overlay::hide_game_buttons/hold_game_buttons),
|
||||
// and all game input paused for half a second at a time.
|
||||
virtual void game_buttons(std::string_view /*plugin*/, std::uint16_t /*hidden*/, std::uint16_t /*held*/, bool /*paused*/) {}
|
||||
// The game window has the focus, ReSkate's menu is shut and the native UI lets the game play.
|
||||
virtual bool input_allowed() { return false; }
|
||||
virtual std::string free_camera(std::string_view /*plugin*/, bool) { return unavailable; }
|
||||
// The free camera flown from the pad while placing from the air (Extension/Skater/pad_flight.h);
|
||||
// one plugin holds it at a time.
|
||||
virtual std::string pad_flight(std::string_view /*plugin*/, bool) { return unavailable; }
|
||||
// The stock physics while the local player competes (set_competition_stock_physics).
|
||||
virtual void competition(std::string_view /*plugin*/, bool) {}
|
||||
virtual bool stock_physics_ready() { return false; }
|
||||
// physics_tuning::prepare(): the game's stock tuning read, without switching anything.
|
||||
virtual void prepare_physics() {}
|
||||
// The session's world (it changes with the map or session), whether the local player's mods bar
|
||||
// them from scoring, and the map's name.
|
||||
virtual std::string world_map() { return {}; }
|
||||
virtual std::uint64_t world_id() { return 0; }
|
||||
virtual bool world_barred() { return false; }
|
||||
virtual std::optional<bool> stance_goofy() { return std::nullopt; }
|
||||
virtual void trick_feed(std::string_view /*plugin*/, bool) {}
|
||||
virtual std::vector<TrickNote> take_trick_notes() { return {}; }
|
||||
// The Bone Cam (Extension/Skater/bone_cam.h): no words for its setting, "status", "bail",
|
||||
// "ringing", "game on|off" (the plugin's game asks for it), or the bonecam console words.
|
||||
virtual std::string bone_cam(std::string_view /*plugin*/, const std::vector<std::string>& /*words*/) { return {}; }
|
||||
// Three focus checks: the game window has focus, ReSkate's overlay is open, and the throwdown
|
||||
// flow lets gameplay input through.
|
||||
struct InputFocus {
|
||||
bool focused{}, interface_open{}, gameplay{};
|
||||
};
|
||||
virtual InputFocus input_focus() { return {}; }
|
||||
// Watch another player's skater the way the native party menu does (0: back to our own).
|
||||
virtual void spectate(std::uint64_t /*player*/) {}
|
||||
// The flick diagram of a flip trick as skate. names it (trick_gestures.h): read in the
|
||||
// background the first time; empty until then, or for a trick without one.
|
||||
virtual std::vector<std::array<float, 2>> trick_gesture(std::string_view /*trick*/) { return {}; }
|
||||
// ---- files: the plugin's own data folder, <game>/Mods/.reskate/data/<plugin>/ ------------------
|
||||
// Paths are relative to it and already checked (plugin_manifest.h's safe_relative_path). A listing
|
||||
// names the files and folders in `folder` (empty: the top), folders ending in "/".
|
||||
virtual std::string files_list(std::string_view /*plugin*/, std::string_view /*folder*/, std::vector<std::string>& /*names*/) { return unavailable; }
|
||||
virtual std::string files_read(std::string_view /*plugin*/, std::string_view /*path*/, std::string& /*text*/) { return unavailable; }
|
||||
virtual std::string files_write(std::string_view /*plugin*/, std::string_view /*path*/, std::string_view /*text*/) { return unavailable; }
|
||||
virtual std::string files_remove(std::string_view /*plugin*/, std::string_view /*path*/) { return unavailable; }
|
||||
// ---- ui.dialog -----------------------------------------------------------------------------------
|
||||
// One dialog at a time over the game; the same id again updates it (what was typed is kept).
|
||||
virtual std::string dialog_open(std::string_view /*plugin*/, const DialogSpec&) { return unavailable; }
|
||||
virtual void dialog_close(std::string_view /*plugin*/, std::string_view /*id, empty: any of the plugin's*/) {}
|
||||
// ---- skater.push / hold ----------------------------------------------------------------------------
|
||||
// The skater's velocity set to this (metres per second, world) once it is back on its board, and
|
||||
// the skater held where it is (no input, no physics) while asked. A pending push may wait two
|
||||
// seconds more (the skater is still getting back on its board) or be dropped.
|
||||
virtual std::string skater_push(std::string_view /*plugin*/, const Vec3&) { return unavailable; }
|
||||
virtual std::string skater_push_wait(std::string_view /*plugin*/) { return unavailable; }
|
||||
virtual std::string skater_push_reset(std::string_view /*plugin*/) { return unavailable; }
|
||||
virtual std::string skater_hold(std::string_view /*plugin*/, bool) { return unavailable; }
|
||||
// ---- the rest ----------------------------------------------------------------------------------
|
||||
// The music an outside player (Spotify and the like) is playing, and "play", "pause",
|
||||
// "next" or "previous" for it.
|
||||
@@ -335,6 +461,24 @@ public:
|
||||
virtual void board_clear(std::string_view /*plugin*/, int /*bone, -1: all*/) {}
|
||||
virtual void board_park(std::string_view /*plugin*/, std::vector<int>) {}
|
||||
virtual std::string board_toss(std::string_view /*plugin*/, std::vector<int>, const Vec3&, const Vec3&, float) { return unavailable; }
|
||||
// How every board is fitted (board_pose.h fit); none: the plugin's fit ends.
|
||||
struct BoardFit {
|
||||
bool plates{};
|
||||
float axle = 1.0f;
|
||||
};
|
||||
virtual std::string board_fit(std::string_view /*plugin*/, std::optional<BoardFit>) { return unavailable; }
|
||||
// ---- variants: of the plugin's own mod (Engine/Vfs/mod_list.h Variants) -----------------------------
|
||||
struct VariantState {
|
||||
std::vector<std::string> installed; // its "order" first
|
||||
std::string chosen; // empty: none was
|
||||
std::string in_play; // empty: none of them is in the game
|
||||
};
|
||||
// Empty when the mod declares no variants.
|
||||
virtual std::optional<VariantState> variants(std::string_view /*plugin*/, const std::filesystem::path& /*package*/) { return std::nullopt; }
|
||||
// The variant saved as the mod's choice, the mods applied again and the level loaded again with it.
|
||||
virtual std::string choose_variant(std::string_view /*plugin*/, const std::filesystem::path& /*package*/, std::string_view /*variant*/) {
|
||||
return unavailable;
|
||||
}
|
||||
// ---- the skater's physics -----------------------------------------------------------------------
|
||||
virtual SkaterNews take_skater_news() { return {}; }
|
||||
virtual std::optional<Vec3> board_velocity() { return std::nullopt; }
|
||||
|
||||
@@ -17,12 +17,14 @@
|
||||
#include "Extension/Skater/skater_events.h"
|
||||
#include "Extension/Skater/outfit_overlay.h"
|
||||
#include "Extension/Skater/board_pose.h"
|
||||
#include "Engine/Vfs/mod_catalog.h"
|
||||
#include "Extension/Assets/live_mods.h"
|
||||
#include "Extension/Skater/game_speed.h"
|
||||
#include "Extension/Skater/grind_exit.h"
|
||||
#include "Extension/Skater/xray.h"
|
||||
#include "Extension/UI/hud_corner.h"
|
||||
#include "Extension/Throwdowns/custom_modes.h"
|
||||
#include "Extension/Throwdowns/custom_placement.h"
|
||||
#include "Extension/Throwdowns/throwdown_kit.h"
|
||||
#include "plugin_camera.h"
|
||||
#include "Extension/Multiplayer/Session/session.h"
|
||||
#include "Extension/Music/external_player.h"
|
||||
@@ -37,6 +39,19 @@
|
||||
#include "Extension/Throwdowns/throwdown_debug_text.h"
|
||||
#include "Extension/Trainer/trainer.h"
|
||||
#include "Extension/Travel/local_location_travel.h"
|
||||
#include "Extension/World/world_entities.h"
|
||||
#include "Extension/Skater/bone_cam.h"
|
||||
#include "Extension/Skater/pad_flight.h"
|
||||
#include "Extension/Skater/trick_feed.h"
|
||||
#include "Extension/Skater/skater_push.h"
|
||||
#include "Extension/Skater/trick_gestures.h"
|
||||
#include "Extension/Throwdowns/throwdown_lab.h"
|
||||
#include "Extension/Console/commands.h"
|
||||
#include "Extension/Settings/local_gameplay_settings.h"
|
||||
#include "Extension/Skater/physics_tuning.h"
|
||||
#include "Engine/Game/Multiplayer/session_competition.h"
|
||||
#include "Extension/UI/NativeMenu/throwdown_menu.h"
|
||||
#include <filesystem>
|
||||
#include "Extension/UI/Overlay/overlay.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
@@ -59,6 +74,14 @@ std::string queue(overlay::DebugAction action, bool enabled = false, float value
|
||||
return reply[0] ? std::string(reply.data()) : std::string("the game is busy; try again");
|
||||
}
|
||||
|
||||
// The game's folder (the executable's), where Mods is.
|
||||
std::filesystem::path game_folder() {
|
||||
std::array<wchar_t, 32768> executable{};
|
||||
const auto length = GetModuleFileNameW(nullptr, executable.data(), static_cast<DWORD>(executable.size()));
|
||||
if (!length || length == executable.size()) throw std::runtime_error("The game folder is unavailable.");
|
||||
return std::filesystem::path(executable.data()).parent_path();
|
||||
}
|
||||
|
||||
class RealGame final : public Game {
|
||||
public:
|
||||
explicit RealGame(std::uintptr_t base) : base_(base) {}
|
||||
@@ -122,6 +145,13 @@ public:
|
||||
const auto status = edit_local_park(operation, model.map, model.generation, model.revision, {}, placed);
|
||||
return status.starts_with("error: ") ? status.substr(7) : std::string{};
|
||||
}
|
||||
std::string entity_set(std::string_view plugin, std::uint32_t id, std::string_view asset, const std::array<float, 16>& matrix, bool hidden) override {
|
||||
return world_entities::set(plugin, id, std::string(asset), matrix, hidden);
|
||||
}
|
||||
void entity_remove(std::string_view plugin, std::uint32_t id) override { world_entities::remove(plugin, id); }
|
||||
void entity_clear(std::string_view plugin) override { world_entities::clear(plugin); }
|
||||
int entity_status(std::string_view plugin, std::uint32_t id) override { return static_cast<int>(world_entities::status(plugin, id)); }
|
||||
bool entities_ready(std::string_view plugin) override { return world_entities::ready(plugin); }
|
||||
std::string set_environment(std::string_view kind, std::string_view key, std::string_view value) override {
|
||||
if (kind == "tod") return time_of_day(key);
|
||||
if (kind == "control") {
|
||||
@@ -322,6 +352,36 @@ public:
|
||||
std::string board_toss(std::string_view plugin, std::vector<int> bones, const Vec3& velocity, const Vec3& spin, float ground) override {
|
||||
return board_pose::toss(plugin, std::move(bones), velocity, spin, ground);
|
||||
}
|
||||
std::string board_fit(std::string_view plugin, std::optional<BoardFit> fit) override {
|
||||
if (!fit) return board_pose::fit(plugin, std::nullopt);
|
||||
return board_pose::fit(plugin, board_pose::Fit{fit->plates, fit->axle});
|
||||
}
|
||||
// ---- variants ------------------------------------------------------------------------------------
|
||||
// The mod's folder is read again at most every 2 s: a menu page asks every frame.
|
||||
std::optional<VariantState> variants(std::string_view plugin, const std::filesystem::path& package) override {
|
||||
const auto now = GetTickCount64();
|
||||
auto& kept = variant_reads_[std::string(plugin)];
|
||||
if (!kept.at || now - kept.at > 2000) {
|
||||
std::vector<std::string> notes;
|
||||
const auto mod = mods::describe_mod(package, std::string(plugin), notes);
|
||||
kept.declared = mod.variants.has_value();
|
||||
kept.installed = kept.declared ? mods::installed_variants(mod) : std::vector<std::string>{};
|
||||
kept.at = now;
|
||||
}
|
||||
if (!kept.declared) return std::nullopt;
|
||||
return VariantState{kept.installed, mods::chosen_variant(plugin), mods::variant_in_play(plugin)};
|
||||
}
|
||||
std::string choose_variant(std::string_view plugin, const std::filesystem::path& package, std::string_view variant) override {
|
||||
const auto state = variants(plugin, package);
|
||||
if (!state) return "its mod declares no variants";
|
||||
std::string name(variant);
|
||||
for (auto& c : name) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
if (std::find(state->installed.begin(), state->installed.end(), name) == state->installed.end()) return "its mod has no variant " + name;
|
||||
if (live_mods::busy()) return "the mods are being applied";
|
||||
if (!mods::save_variant(plugin, name)) return "the choice could not be saved";
|
||||
if (name != state->in_play) (void)live_mods::apply(true);
|
||||
return {};
|
||||
}
|
||||
// ---- outfit --------------------------------------------------------------------------------------
|
||||
std::string outfit_set(std::string_view plugin, std::string_view slot, std::string_view item, std::vector<float> placement, bool covers) override {
|
||||
return outfit_overlay::set(std::string(plugin), std::string(slot), std::string(item), std::move(placement), covers);
|
||||
@@ -392,11 +452,6 @@ public:
|
||||
return grind_exit::wish(plugin, w);
|
||||
}
|
||||
std::string set_throwdown_mode(std::string_view plugin, const ThrowdownMode& m) override {
|
||||
using multiplayer::custom_throwdowns::Row;
|
||||
const auto row_of = [](const ThrowdownRow& r) {
|
||||
return Row{r.kind == "choice" ? Row::Kind::choice : r.kind == "button" ? Row::Kind::button : Row::Kind::slider,
|
||||
r.id, r.caption, r.minimum, r.maximum, r.step, r.value, r.options, r.text};
|
||||
};
|
||||
multiplayer::custom_throwdowns::Mode mode;
|
||||
mode.plugin = plugin;
|
||||
mode.id = m.id;
|
||||
@@ -404,27 +459,139 @@ public:
|
||||
mode.description = m.description;
|
||||
mode.art = m.art;
|
||||
mode.flag = m.flag;
|
||||
mode.replaces = m.replaces;
|
||||
mode.card = m.card;
|
||||
mode.order = m.order;
|
||||
mode.color = m.color;
|
||||
mode.min_players = m.min_players;
|
||||
mode.max_players = m.max_players;
|
||||
mode.players = m.players;
|
||||
if (m.timer) mode.timer = row_of(*m.timer);
|
||||
for (const auto& r : m.rows) mode.rows.push_back(row_of(r));
|
||||
mode.cursor_height = m.cursor_height;
|
||||
return multiplayer::custom_throwdowns::set_mode(std::move(mode));
|
||||
}
|
||||
// ---- the throwdown kit -------------------------------------------------------------------------
|
||||
std::string publish_throwdown_ui(std::string_view plugin, const Json& document) override { return throwdowns::kit::publish(plugin, document); }
|
||||
bool throwdown_flag_begin(std::string_view mode, std::uint32_t game, const throwdowns::kit::flag::Options& options) override {
|
||||
return throwdowns::kit::flag::begin(mode, game, options);
|
||||
}
|
||||
throwdowns::kit::flag::State throwdown_flag_state() override { return throwdowns::kit::flag::state(); }
|
||||
// Only the plugin whose mode the registration is may let it go.
|
||||
void throwdown_flag_leave(std::string_view plugin) override {
|
||||
if (throwdowns::kit::flag::state().mode.starts_with(std::string(plugin) + "/")) throwdowns::kit::flag::leave();
|
||||
}
|
||||
// ---- files: <game>/Mods/.reskate/data/<plugin>/ -----------------------------------------------------
|
||||
static constexpr std::size_t most_file_bytes = 1u << 20, most_files = 1000;
|
||||
// The plugin's folder and the path in it, or why not. A path that resolves outside the folder
|
||||
// (through a junction someone made in it) is refused.
|
||||
static std::string data_file(std::string_view plugin, std::string_view path, std::filesystem::path& root, std::filesystem::path& file) {
|
||||
try {
|
||||
root = game_folder() / "Mods" / ".reskate" / "data" / std::filesystem::path(std::string(plugin));
|
||||
file = path.empty() ? root : root / std::filesystem::path(std::string(path));
|
||||
std::error_code error;
|
||||
const auto real_root = std::filesystem::weakly_canonical(root, error);
|
||||
const auto real_file = error ? std::filesystem::path{} : std::filesystem::weakly_canonical(file, error);
|
||||
if (error) return "the data folder cannot be read";
|
||||
const auto relative = real_file.lexically_relative(real_root);
|
||||
if (relative.empty() || *relative.begin() == "..") return "outside the data folder";
|
||||
return {};
|
||||
} catch (const std::exception& e) {
|
||||
return e.what();
|
||||
}
|
||||
}
|
||||
std::string files_list(std::string_view plugin, std::string_view folder, std::vector<std::string>& names) override {
|
||||
std::filesystem::path root, at;
|
||||
if (auto why = data_file(plugin, folder, root, at); !why.empty()) return why;
|
||||
std::error_code error;
|
||||
if (!std::filesystem::exists(at, error)) return folder.empty() ? std::string{} : std::string("not found");
|
||||
if (!std::filesystem::is_directory(at, error)) return "not a folder";
|
||||
for (std::filesystem::directory_iterator it(at, error), end; !error && it != end && names.size() < most_files; it.increment(error)) {
|
||||
auto name = it->path().filename().string();
|
||||
if (name.ends_with(".tmp")) continue; // a write going on
|
||||
if (it->is_directory(error)) name += "/";
|
||||
names.push_back(std::move(name));
|
||||
}
|
||||
if (error) return "the folder cannot be read";
|
||||
std::sort(names.begin(), names.end());
|
||||
return {};
|
||||
}
|
||||
std::string files_read(std::string_view plugin, std::string_view path, std::string& text) override {
|
||||
std::filesystem::path root, file;
|
||||
if (auto why = data_file(plugin, path, root, file); !why.empty()) return why;
|
||||
std::error_code error;
|
||||
if (!std::filesystem::is_regular_file(file, error)) return "not found";
|
||||
const auto size = std::filesystem::file_size(file, error);
|
||||
if (error) return "the file cannot be read";
|
||||
if (size > most_file_bytes) return "the file is larger than 1 MB";
|
||||
std::ifstream in(file, std::ios::binary);
|
||||
text.resize(static_cast<std::size_t>(size));
|
||||
if (!in || !in.read(text.data(), static_cast<std::streamsize>(size))) return "the file cannot be read";
|
||||
return {};
|
||||
}
|
||||
std::string files_write(std::string_view plugin, std::string_view path, std::string_view text) override {
|
||||
if (text.size() > most_file_bytes) return "a file is at most 1 MB";
|
||||
std::filesystem::path root, file;
|
||||
if (auto why = data_file(plugin, path, root, file); !why.empty()) return why;
|
||||
std::error_code error;
|
||||
if (std::filesystem::is_directory(file, error)) return "a folder is there";
|
||||
if (!std::filesystem::exists(file, error)) {
|
||||
std::size_t count = 0;
|
||||
if (std::filesystem::exists(root, error))
|
||||
for (std::filesystem::recursive_directory_iterator it(root, error), end; !error && it != end; it.increment(error))
|
||||
if (it->is_regular_file(error) && ++count >= most_files) return "the data folder holds 1000 files already";
|
||||
}
|
||||
std::filesystem::create_directories(file.parent_path(), error);
|
||||
if (error) return "the folder cannot be made";
|
||||
auto temporary = file;
|
||||
temporary += ".tmp";
|
||||
{
|
||||
std::ofstream out(temporary, std::ios::binary | std::ios::trunc);
|
||||
if (!out || !out.write(text.data(), static_cast<std::streamsize>(text.size()))) return "the file cannot be written";
|
||||
}
|
||||
std::filesystem::rename(temporary, file, error);
|
||||
if (error) {
|
||||
std::filesystem::remove(temporary, error);
|
||||
return "the file cannot be written";
|
||||
}
|
||||
return {};
|
||||
}
|
||||
std::string files_remove(std::string_view plugin, std::string_view path) override {
|
||||
std::filesystem::path root, file;
|
||||
if (auto why = data_file(plugin, path, root, file); !why.empty()) return why;
|
||||
std::error_code error;
|
||||
if (!std::filesystem::exists(file, error)) return "not found";
|
||||
if (std::filesystem::is_directory(file, error) && !std::filesystem::is_empty(file, error)) return "the folder is not empty";
|
||||
if (!std::filesystem::remove(file, error) || error) return "it cannot be removed";
|
||||
return {};
|
||||
}
|
||||
// ---- ui.dialog ----------------------------------------------------------------------------------
|
||||
std::string dialog_open(std::string_view plugin, const DialogSpec& spec) override { return overlay::open_plugin_dialog(plugin, spec); }
|
||||
void dialog_close(std::string_view plugin, std::string_view id) override { overlay::close_plugin_dialog(plugin, id); }
|
||||
// ---- skater.push / hold --------------------------------------------------------------------------
|
||||
// A velocity the next on-board physics step writes (Extension/Skater/skater_push.h), and a hold
|
||||
// that keeps the board still.
|
||||
std::string skater_push(std::string_view, const Vec3& velocity) override {
|
||||
if (!::dingosdk::skater_push::valid(velocity)) return "a push is horizontal (y 0) and at most 120 m/s";
|
||||
::dingosdk::skater_push::request(velocity);
|
||||
return {};
|
||||
}
|
||||
std::string skater_push_wait(std::string_view) override {
|
||||
::dingosdk::skater_push::wait();
|
||||
return {};
|
||||
}
|
||||
std::string skater_push_reset(std::string_view) override {
|
||||
::dingosdk::skater_push::reset();
|
||||
return {};
|
||||
}
|
||||
std::string skater_hold(std::string_view plugin, bool on) override {
|
||||
if (on) hold_owner_ = plugin;
|
||||
else if (hold_owner_ != plugin) return {};
|
||||
else hold_owner_.clear();
|
||||
::dingosdk::skater_push::hold(on);
|
||||
return {};
|
||||
}
|
||||
void remove_throwdown_mode(std::string_view plugin, std::string_view id) override {
|
||||
multiplayer::custom_throwdowns::remove_mode(plugin, id);
|
||||
}
|
||||
void release_throwdown_flag(std::string_view plugin) override { multiplayer::custom_throwdowns::release(plugin); }
|
||||
std::vector<ThrowdownModeNews> take_throwdown_news(std::string_view plugin) override {
|
||||
std::vector<ThrowdownModeNews> out;
|
||||
for (auto& n : multiplayer::custom_throwdowns::take(plugin)) {
|
||||
ThrowdownModeNews news{std::move(n.mode), std::move(n.action), std::move(n.row), n.value, std::move(n.settings)};
|
||||
if (n.at) news.at = Vec3{(*n.at)[0], (*n.at)[1], (*n.at)[2]};
|
||||
news.heading = n.heading;
|
||||
out.push_back(std::move(news));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
std::vector<GrindPop> take_grind_pops(std::string_view plugin) override {
|
||||
std::vector<GrindPop> out;
|
||||
for (auto& p : grind_exit::take_pops(plugin)) out.push_back({p.applied, std::move(p.reason), std::move(p.basis), p.toward, p.before, p.after});
|
||||
@@ -570,6 +737,118 @@ public:
|
||||
const bool allowed = !multiplayer::local_throwdown_active();
|
||||
skater_animation::tick(base, local_skater_component(base, client), allowed);
|
||||
}
|
||||
// ---- general services for a game layer in Lua ------------------------------------------------------
|
||||
std::optional<SkaterTelemetry> skater_telemetry() override {
|
||||
const auto t = trainer::telemetry();
|
||||
const auto w = watched_physics_state();
|
||||
SkaterTelemetry m;
|
||||
m.skater = t.skater;
|
||||
m.airborne = t.airborne;
|
||||
m.position = t.position;
|
||||
m.heading = t.heading;
|
||||
m.speed = t.speed;
|
||||
m.vertical = t.vertical;
|
||||
m.air_time = t.air_time;
|
||||
m.height = t.height;
|
||||
m.top_speed = t.top_speed;
|
||||
m.physics_state = t.physics_state;
|
||||
const auto& j = t.last;
|
||||
m.last = {j.serial, j.takeoff_speed, j.takeoff_angle, j.air_time, j.height, j.distance, j.drop, j.landing_speed,
|
||||
j.spin, j.spin_rate, j.flip, j.board_turn, j.state, j.takeoff, j.landing};
|
||||
m.watch = {w.valid, w.ragdoll_known, w.ragdoll, w.state, w.previous, w.previous_seconds, w.changes, w.wipeouts, w.runouts};
|
||||
m.teleport_pending = local_skater_teleport_pending();
|
||||
m.slam = bonecam::bone_cam_slam();
|
||||
m.struck = bonecam::bone_cam_struck();
|
||||
m.body = bonecam::bail_body_position();
|
||||
return m;
|
||||
}
|
||||
std::optional<std::uint32_t> pad_bits(bool menu) override {
|
||||
ControllerInput pad;
|
||||
DingoSDKOverlayReadControllerInput(&pad, menu);
|
||||
if (!pad.available) return std::nullopt;
|
||||
return pad.buttons;
|
||||
}
|
||||
bool key_down(int virtual_key) override { return overlay::key_down(virtual_key); }
|
||||
int mouse_wheel() override { return overlay::take_mouse_wheel(); }
|
||||
void game_buttons(std::string_view plugin, std::uint16_t hidden, std::uint16_t held, bool paused) override {
|
||||
buttons_owner_ = plugin;
|
||||
overlay::hide_game_buttons(hidden);
|
||||
overlay::hold_game_buttons(held);
|
||||
if (paused) overlay::pause_game_input(true);
|
||||
}
|
||||
bool input_allowed() override {
|
||||
DWORD process{};
|
||||
const auto window = GetForegroundWindow();
|
||||
const bool focused = window && GetWindowThreadProcessId(window, &process) && process == GetCurrentProcessId();
|
||||
return focused && !overlay::interface_open() && multiplayer::native_throwdown_gameplay_input_allowed();
|
||||
}
|
||||
std::string free_camera(std::string_view plugin, bool on) override {
|
||||
if (on) free_camera_owner_ = plugin;
|
||||
else if (free_camera_owner_ != plugin) return {};
|
||||
else free_camera_owner_.clear();
|
||||
console::request_debug(overlay::DebugAction::set_free_camera, on);
|
||||
return {};
|
||||
}
|
||||
std::string pad_flight(std::string_view plugin, bool on) override {
|
||||
if (on) {
|
||||
if (!pad_flight_owner_.empty() && pad_flight_owner_ != plugin) return pad_flight_owner_ + " flies the camera from the pad";
|
||||
pad_flight_owner_ = plugin;
|
||||
} else if (pad_flight_owner_ != plugin) return {};
|
||||
else pad_flight_owner_.clear();
|
||||
::dingosdk::pad_flight::request(on);
|
||||
return {};
|
||||
}
|
||||
void competition(std::string_view plugin, bool on) override {
|
||||
if (on) competition_owner_ = plugin;
|
||||
else if (competition_owner_ != plugin) return;
|
||||
else competition_owner_.clear();
|
||||
if (on) physics_tuning::prepare();
|
||||
set_competition_stock_physics(on);
|
||||
}
|
||||
bool stock_physics_ready() override { return physics_tuning::game_tuning() != nullptr; }
|
||||
std::string world_map() override { return local_profile_object_persistence().map; }
|
||||
std::uint64_t world_id() override {
|
||||
// The session's world in a session, else the native throwdown world (the one native flag
|
||||
// placement and the flags echo back).
|
||||
const auto world = multiplayer_session_active() ? multiplayer::throwdown_relay_world() : 0;
|
||||
return world ? world : multiplayer::native_throwdown_world();
|
||||
}
|
||||
bool world_barred() override { return multiplayer::throwdown_relay_barred(); }
|
||||
std::optional<bool> stance_goofy() override { return profile_runtime::game_stance_goofy(); }
|
||||
void trick_feed(std::string_view plugin, bool on) override { tricks::feed::ask(std::string(plugin), on); }
|
||||
std::vector<TrickNote> take_trick_notes() override {
|
||||
std::vector<TrickNote> out;
|
||||
for (auto& n : tricks::feed::take()) out.push_back({n.entry, n.kind, std::move(n.text)});
|
||||
return out;
|
||||
}
|
||||
void prepare_physics() override { physics_tuning::prepare(); }
|
||||
InputFocus input_focus() override {
|
||||
DWORD process{};
|
||||
const auto window = GetForegroundWindow();
|
||||
return {window && GetWindowThreadProcessId(window, &process) && process == GetCurrentProcessId(), overlay::interface_open(),
|
||||
multiplayer::native_throwdown_gameplay_input_allowed()};
|
||||
}
|
||||
void spectate(std::uint64_t player) override { multiplayer::spectate_party_member(player); }
|
||||
std::vector<std::array<float, 2>> trick_gesture(std::string_view trick) override {
|
||||
tricks::prepare_trick_gestures();
|
||||
return tricks::trick_gesture(trick);
|
||||
}
|
||||
std::string bone_cam(std::string_view plugin, const std::vector<std::string>& words) override {
|
||||
if (words.empty()) return bonecam::bone_cam_setting();
|
||||
const auto& setting = words.front();
|
||||
if (words.size() == 1 && setting == "status") return bonecam::bone_cam_command({});
|
||||
if (words.size() == 1 && setting == "bail") {
|
||||
bonecam::bone_cam_bail();
|
||||
return {};
|
||||
}
|
||||
if (words.size() == 1 && setting == "ringing") return bonecam::bone_cam_ringing() ? "1" : "0";
|
||||
// "game on|off": this plugin's game asks for the Bone Cam (the "game" setting).
|
||||
if (words.size() == 2 && setting == "game" && (words[1] == "on" || words[1] == "off")) {
|
||||
bonecam::bone_cam_game(plugin, words[1] == "on");
|
||||
return {};
|
||||
}
|
||||
return bonecam::bone_cam_command(words);
|
||||
}
|
||||
// ---- the lobby host's authority ------------------------------------------------------------------
|
||||
std::string kick(std::uint64_t player, std::string_view why) override { return multiplayer::host_kick(player, why); }
|
||||
// The host's own chat, under the plugin's name: a lobby has no server to speak for it.
|
||||
@@ -587,7 +866,9 @@ public:
|
||||
const auto view = plugin_camera::camera_now();
|
||||
if (!view || !(view->vertical_fov > 1 && view->vertical_fov < 175)) return std::nullopt;
|
||||
const auto& m = view->world;
|
||||
return CameraView{{m[12], m[13], m[14]}, {-m[8], -m[9], -m[10]}, view->vertical_fov};
|
||||
const auto free = client_free_camera_view();
|
||||
return CameraView{{m[12], m[13], m[14]}, {-m[8], -m[9], -m[10]}, view->vertical_fov, {m[0], m[1], m[2]}, {m[4], m[5], m[6]},
|
||||
free != 0 && view->camera == free};
|
||||
}
|
||||
std::string set_camera(std::string_view plugin, const std::array<float, 16>& pose, float fov) override {
|
||||
if (!camera_holder_.empty() && camera_holder_ != plugin) return camera_holder_ + " has the camera";
|
||||
@@ -654,13 +935,27 @@ public:
|
||||
}
|
||||
bool frozen() const noexcept { return !freezers_.empty(); }
|
||||
void release(std::string_view plugin) override {
|
||||
if (hold_owner_ == plugin) (void)skater_hold(plugin, false);
|
||||
world_entities::clear(plugin);
|
||||
throwdown_flag_leave(plugin);
|
||||
dialog_close(plugin, {});
|
||||
tricks::feed::ask(std::string(plugin), false);
|
||||
bonecam::bone_cam_release(plugin);
|
||||
if (buttons_owner_ == plugin) {
|
||||
overlay::hide_game_buttons(0);
|
||||
overlay::hold_game_buttons(0);
|
||||
buttons_owner_.clear();
|
||||
}
|
||||
if (free_camera_owner_ == plugin) (void)free_camera(plugin, false);
|
||||
if (pad_flight_owner_ == plugin) (void)pad_flight(plugin, false);
|
||||
if (competition_owner_ == plugin) competition(plugin, false);
|
||||
stop_clip(plugin);
|
||||
plugin_sound::stop_all(plugin);
|
||||
outfit_overlay::clear(plugin);
|
||||
board_pose::clear(plugin);
|
||||
board_pose::park(plugin, {});
|
||||
(void)board_pose::fit(plugin, std::nullopt);
|
||||
cancel_throwdown(plugin);
|
||||
multiplayer::custom_throwdowns::release(plugin);
|
||||
multiplayer::custom_throwdowns::remove_plugin(plugin);
|
||||
if (capturer_ == plugin) capturer_.clear();
|
||||
if (std::erase_if(replacements_, [&](const auto& r) { return r.second.first == plugin; })) publish_replacements();
|
||||
@@ -688,10 +983,18 @@ private:
|
||||
skater_animation::set_replacements(std::move(list));
|
||||
}
|
||||
std::string player_, recorder_, thrower_, capturer_;
|
||||
std::string buttons_owner_, free_camera_owner_, pad_flight_owner_, competition_owner_, hold_owner_;
|
||||
std::uint64_t capture_until_{}, flight_read_at_{};
|
||||
std::optional<FlightInput> flight_;
|
||||
// By (trick, pop): the plugin and its clip.
|
||||
std::map<std::pair<std::string, std::string>, std::pair<std::string, skater_animation::Replacement>> replacements_;
|
||||
// What each plugin's mod held of variants when last read (variants).
|
||||
struct VariantRead {
|
||||
std::uint64_t at{};
|
||||
bool declared{};
|
||||
std::vector<std::string> installed;
|
||||
};
|
||||
std::map<std::string, VariantRead, std::less<>> variant_reads_;
|
||||
using BindKey = std::pair<std::string, std::string>;
|
||||
struct Bind {
|
||||
std::uint32_t own{};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "plugin_api.h"
|
||||
#include "plugin_wire.h"
|
||||
#include "Engine/Core/Console/console_core.h"
|
||||
#include "Extension/Throwdowns/throwdown_kit.h"
|
||||
#include <algorithm>
|
||||
#include <format>
|
||||
#include <utility>
|
||||
@@ -86,6 +87,7 @@ void Host::unload(Entry& entry) {
|
||||
}
|
||||
release_commands(entry.candidate.id + suffix_);
|
||||
environment_.game().release(entry.candidate.id + suffix_);
|
||||
throwdowns::kit::release(entry.candidate.id + suffix_);
|
||||
}
|
||||
|
||||
void Host::collect(Entry& entry) {
|
||||
@@ -95,6 +97,7 @@ void Host::collect(Entry& entry) {
|
||||
entry.plugin.reset();
|
||||
release_commands(entry.candidate.id + suffix_);
|
||||
environment_.game().release(entry.candidate.id + suffix_);
|
||||
throwdowns::kit::release(entry.candidate.id + suffix_);
|
||||
}
|
||||
|
||||
void Host::release_commands(std::string_view id) {
|
||||
@@ -347,24 +350,28 @@ void Host::follow_game() {
|
||||
each([&](Plugin& plugin, const std::string&) { plugin.track_changed(track); });
|
||||
}
|
||||
}
|
||||
// The trick feed (tricks.feed): what the trick list wrote, to plugins that read tricks.
|
||||
if (wants("events.tricks"))
|
||||
if (const auto notes = game.take_trick_notes(); !notes.empty()) {
|
||||
auto list = Json::array();
|
||||
for (const auto& n : notes) list.push_back(Json{{"entry", std::to_string(n.entry)}, {"kind", std::string(1, n.kind)}, {"text", n.text}});
|
||||
auto data = Json::object();
|
||||
data["notes"] = std::move(list);
|
||||
each([&](Plugin& plugin, const std::string&) { plugin.game_event("trick_notes", "events.tricks", data); });
|
||||
}
|
||||
if (wants("throwdowns")) {
|
||||
each([&](Plugin& plugin, const std::string& id) {
|
||||
for (const auto& news : game.take_throwdown_news(id)) {
|
||||
each([&](Plugin& plugin, const std::string&) {
|
||||
// What the native Throwdowns UI reports of the plugin's modes (throwdown_kit.h), each
|
||||
// mode by the plugin's own id.
|
||||
const auto prefix = plugin.id() + "/";
|
||||
for (auto& e : throwdowns::kit::take(plugin.id())) {
|
||||
auto data = Json::object();
|
||||
data["mode"] = news.mode;
|
||||
data["action"] = news.action;
|
||||
if (!news.row.empty()) {
|
||||
data["row"] = news.row;
|
||||
data["value"] = news.value;
|
||||
}
|
||||
auto settings = Json::object();
|
||||
for (const auto& [row, value] : news.settings) settings[row] = value;
|
||||
data["settings"] = std::move(settings);
|
||||
if (news.at) {
|
||||
data["position"] = Json::array({(*news.at)[0], (*news.at)[1], (*news.at)[2]});
|
||||
data["heading"] = news.heading;
|
||||
}
|
||||
plugin.game_event("throwdown_mode", "throwdowns", data);
|
||||
data["action"] = std::move(e.action);
|
||||
data["mode"] = e.mode.starts_with(prefix) ? e.mode.substr(prefix.size()) : std::move(e.mode);
|
||||
auto args = Json::array();
|
||||
for (auto& a : e.args) args.push_back(std::move(a));
|
||||
data["args"] = std::move(args);
|
||||
plugin.game_event("throwdown_ui", "throwdowns", data);
|
||||
}
|
||||
});
|
||||
const bool active = game.throwdowns().active;
|
||||
|
||||
@@ -7,13 +7,21 @@
|
||||
|
||||
namespace dingosdk::plugins {
|
||||
namespace {
|
||||
constexpr std::array<std::string_view, 42> permissions{
|
||||
// What each permission opens, where it is not plain from its name: "skater.control" the skater's
|
||||
// push (with push_wait / push_reset), hold and the competition's stock physics (reskate.physics);
|
||||
// "camera" the free and pad-flown cameras and the Bone Cam (reskate.skater.bone_cam);
|
||||
// "session.read" spectating and the session's world (reskate.session.world); "events.tricks" the
|
||||
// trick events, the trick list's feed and flick diagrams (reskate.tricks); "throwdowns" the
|
||||
// throwdown kit (modes, documents, the native flag); "variants" choosing which of its own mod's
|
||||
// variants is in the game (reskate.package.choose). A manifest's "resident" list and "variants" are
|
||||
// the merge's (Engine/Vfs/mod_list.h ResidentAssets, Variants), not the plugin host's.
|
||||
constexpr std::array<std::string_view, 45> permissions{
|
||||
"console", "storage", "overlay", "menu", "ui.screen", "input", "hud", "events.tricks", "events.skater",
|
||||
"skater.read", "skater.teleport", "skater.control", "skater.animation", "trainer", "camera",
|
||||
"world.raycast", "world.objects", "world.environment", "world.ai", "travel", "session.read",
|
||||
"world.raycast", "world.objects", "world.entities", "world.environment", "world.ai", "travel", "session.read",
|
||||
"session.skaters", "session.nametags", "net.session", "net.http", "voice.control", "chat.read",
|
||||
"chat.send", "chat.local", "server", "throwdowns", "replay", "music", "progression.read", "settings.read", "audio.ui", "audio", "outfit", "board", "mods.read",
|
||||
"events.physics", "game.speed"};
|
||||
"events.physics", "game.speed", "files", "variants"};
|
||||
}
|
||||
|
||||
bool known_permission(std::string_view name) noexcept {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "Extension/Skater/score_hud.h"
|
||||
#include "Extension/Skater/trick_watch.h"
|
||||
#include "Extension/Trainer/trainer.h"
|
||||
#include "Extension/Throwdowns/throwdown_kit.h"
|
||||
#include <Windows.h>
|
||||
#include <ShlObj.h>
|
||||
#include <array>
|
||||
@@ -73,7 +74,9 @@ public:
|
||||
}
|
||||
// As the console's tp: refused in a session whose host has turned teleporting (and noclip) off.
|
||||
Teleport teleport(const Vec3& position, float heading) override {
|
||||
if (!session_noclip_allowed()) return Teleport::forbidden;
|
||||
// A throwdown mode's own moves (line-ups, recoveries, returns to the start) are the game's:
|
||||
// allowed wherever its match runs and asks for them (the kit's `match.teleports`).
|
||||
if (!session_noclip_allowed() && !throwdowns::kit::match().teleports) return Teleport::forbidden;
|
||||
return teleport_local_skater_heading(position, heading) ? Teleport::sent : Teleport::unavailable;
|
||||
}
|
||||
std::optional<Vec3> raycast(const Vec3& from, const Vec3& to) override { return local_surface_hit(from, to); }
|
||||
@@ -93,6 +96,8 @@ public:
|
||||
else if (name == "navigate") sound = UiSound::navigate;
|
||||
else if (name == "toggle") sound = UiSound::toggle;
|
||||
else if (name == "tab") sound = UiSound::tab;
|
||||
else if (name == "countdown") sound = UiSound::countdown;
|
||||
else if (name == "go") sound = UiSound::go;
|
||||
else return false;
|
||||
multiplayer::queue_ui_sound(static_cast<std::uint32_t>(sound));
|
||||
return true;
|
||||
@@ -195,7 +200,7 @@ void read_tricks() {
|
||||
signals.line_score = hud->line_score;
|
||||
signals.multiplier = hud->multiplier;
|
||||
signals.slam = hud->slam;
|
||||
// A wipeout is physics states 300-399 (as Domination reads it).
|
||||
// A wipeout is physics states 300-399.
|
||||
signals.wipeout = skater.skater && skater.physics_state >= 300 && skater.physics_state < 400;
|
||||
const auto event = trick_watch.update(signals);
|
||||
environment->score_now = ScoreState{hud->line_score, hud->multiplier, trick_watch.in_trick(), hud->header, hud->descriptor,
|
||||
|
||||
@@ -249,6 +249,8 @@ private:
|
||||
friend struct UiApi;
|
||||
friend struct MoreApi;
|
||||
friend struct WorldApi;
|
||||
friend struct ServicesApi;
|
||||
friend struct KitApi;
|
||||
struct Command { std::string help; int function{}; };
|
||||
struct Timer { double due{}, every{}; int function{}; };
|
||||
using Clock = std::chrono::steady_clock;
|
||||
@@ -343,6 +345,8 @@ private:
|
||||
int controls_{}, edits_{}, environments_{}, voices_{}, musics_{};
|
||||
double travel_at_{-1e9}, chat_at_{-1e9}, local_chat_second_{}, server_second_{}, host_at_{-1e9}, wipeout_at_{-1e9};
|
||||
int local_chats_{}, server_calls_{};
|
||||
double files_second_{}, dialog_second_{};
|
||||
int file_calls_{}, dialog_calls_{};
|
||||
std::optional<std::pair<Vec3, float>> trainer_point_;
|
||||
std::set<std::string, std::less<>> binds_;
|
||||
std::vector<PanelEntry> screens_;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
#include <vector>
|
||||
|
||||
// A plugin's message to the same plugin on other players' games, sent in the session's throwdown
|
||||
// packets (as Domination's are). Throwdown kinds are 1-15 and Domination's tag 0xD0; plugins' is
|
||||
// packets. Throwdown kinds are 1-15 and 0xD0 is an earlier game-mode tag, still kept apart; plugins' is
|
||||
// 0xD1, and 0xD2 for what the session's authority says (the server copy of a plugin, on the
|
||||
// lobby's host or the dedicated server), which only the authority may send. Little-endian: u8 tag,
|
||||
// u8 version, u32 channel (fnv1a of the plugin's lowercase name), u64 the player it is for (0:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "runtime_internal.h"
|
||||
#include "Engine/Game/Build/20260929/local_placements.h"
|
||||
#include "Extension/Objects/local_placements_runtime.h"
|
||||
#include "Extension/Objects/ParkEditor/park_editor_runtime.h"
|
||||
#include "Extension/Objects/ParkEditor/park_editor_surface.h"
|
||||
@@ -50,28 +51,34 @@ bool delete_local_placed_object(std::string_view map, std::uint64_t token) {
|
||||
return begin_placement_delete({token});
|
||||
}
|
||||
bool teleport_local_skater(const std::array<float, 3>& position) {
|
||||
return teleport_local_skater_transform({1,0,0,0,0,1,0,0,0,0,1,0,position[0],position[1],position[2],1});
|
||||
}
|
||||
bool teleport_local_skater_transform(const std::array<float,16>& transform) {
|
||||
std::lock_guard lock(local_runtime().native_mutex);
|
||||
auto& r = placements_runtime();
|
||||
if (!local_runtime().active || !r.teleport || !r.transition_ctor || !r.transition_destroy) return false;
|
||||
for (const auto v : position)
|
||||
for (const auto v : transform)
|
||||
if (!std::isfinite(v) || std::abs(v) > 1e6f) return false;
|
||||
r.position_teleport = position;
|
||||
r.position_teleport_heading.reset(); // the identity rotation
|
||||
r.position_teleport = transform;
|
||||
r.position_teleport_at = GetTickCount64();
|
||||
return true;
|
||||
}
|
||||
// Upright at the spot, facing `heading` (radians about world up): the root's rows are right
|
||||
// (cos h, 0, -sin h), up, forward (sin h, 0, cos h); h = 0 faces +Z.
|
||||
bool teleport_local_skater_heading(const std::array<float, 3>& position, float heading) {
|
||||
std::lock_guard lock(local_runtime().native_mutex);
|
||||
auto& r = placements_runtime();
|
||||
if (!local_runtime().active || !r.teleport || !r.transition_ctor || !r.transition_destroy) return false;
|
||||
for (const auto v : position)
|
||||
if (!std::isfinite(v) || std::abs(v) > 1e6f) return false;
|
||||
if (!std::isfinite(heading)) return false;
|
||||
r.position_teleport = position;
|
||||
r.position_teleport_heading = heading;
|
||||
r.position_teleport_at = GetTickCount64();
|
||||
return true;
|
||||
const float sh = std::sin(heading), ch = std::cos(heading);
|
||||
return teleport_local_skater_transform({ch, 0, -sh, 0, 0, 1, 0, 0, sh, 0, ch, 0, position[0], position[1], position[2], 1});
|
||||
}
|
||||
bool local_skater_teleport_pending() {
|
||||
std::lock_guard lock(local_runtime().native_mutex);
|
||||
const auto& r = placements_runtime();
|
||||
if (r.position_teleport) return true;
|
||||
std::uintptr_t manager{}; std::uint32_t phase{};
|
||||
return read(local_runtime().base + addr::local_placements::teleport_manager, manager) && manager &&
|
||||
read(manager, phase) && phase != 0;
|
||||
}
|
||||
|
||||
std::optional<float> local_ground_height(float x, float z, float top, float bottom) {
|
||||
std::lock_guard lock(local_runtime().native_mutex);
|
||||
auto& r = placements_runtime();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "Extension/Customization/local_player_card_runtime.h"
|
||||
#include "Extension/Music/local_music_ui.h"
|
||||
#include "Extension/Music/local_music_shelf.h"
|
||||
#include "Extension/UI/NativeMenu/native_menu_data.h"
|
||||
#include "Extension/Customization/item_browser.h"
|
||||
#include "Extension/Music/local_music_playback.h"
|
||||
#include "Extension/News/local_news_runtime.h"
|
||||
@@ -449,7 +450,7 @@ bool initialize_local_profile(std::uintptr_t base, bool authored_offline,
|
||||
object_subscribe_contract, object_allocate_contract, object_ctor_contract,
|
||||
object_register_contract, object_parse_contract, object_release_contract,
|
||||
challenge_begin_contract, challenge_end_contract, challenge_available_contract, challenge_select_contract, challenge_lookup_contract, challenge_end_invoke_contract, challenge_error_contract, challenge_counts_contract, challenge_index_insert_contract, challenge_index_append_contract, challenge_insert_contract, challenge_dispatch_contract, challenge_request_create_contract,
|
||||
rip_score_context_contract})
|
||||
rip_score_context_contract, multiplayer::menu_data::model_record_load_contract})
|
||||
if (!matches(base, f)) return false;
|
||||
if (!neighborhood_type_contract(base) || !rip_score_type_contract(base) ||
|
||||
!challenge_map_type_contract(base)) return false;
|
||||
@@ -545,6 +546,8 @@ bool initialize_local_profile(std::uintptr_t base, bool authored_offline,
|
||||
hook(news_list_contract, &news_list_hook, news_runtime().functions.list);
|
||||
hook(news_subscribe_contract, &news_subscribe_hook, news_runtime().functions.subscribe);
|
||||
hook(object_subscribe_contract, &object_categories_hook, object_runtime().functions.subscribe);
|
||||
hook(multiplayer::menu_data::model_record_load_contract,
|
||||
&multiplayer::menu_data::model_record_load_hook,multiplayer::menu_data::model_record_load_original);
|
||||
if (music_ready) {
|
||||
hook(music_ui_initialize_contract, &music_ui_initialize_hook, music_ui_runtime().functions.initialize);
|
||||
if (music_ui_runtime().functions.favorite_apply)
|
||||
|
||||
@@ -87,6 +87,9 @@ bool teleport_to_local_placed_object(std::string_view map, std::uint64_t token);
|
||||
bool teleport_local_skater(const std::array<float, 3>& position);
|
||||
// The same, facing `heading` (radians about world up; 0 = +Z, the way the other teleports face).
|
||||
bool teleport_local_skater_heading(const std::array<float, 3>& position, float heading);
|
||||
bool teleport_local_skater_transform(const std::array<float,16>& transform);
|
||||
// Includes the queued request and the native streaming/ground-check transition.
|
||||
bool local_skater_teleport_pending();
|
||||
// The highest collision surface straight down at (x, z) between world heights `top` and
|
||||
// `bottom`, from the client physics world (the park editor's native ray). Empty when nothing
|
||||
// is there yet (collision still streaming) or the query is unavailable. Client update thread.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "local_entitlement_trigger_runtime.h"
|
||||
#include "Extension/Multiplayer/Hud/native_party.h"
|
||||
#include "Extension/Throwdowns/native_throwdowns.h"
|
||||
#include "Extension/Throwdowns/throwdown_lab.h"
|
||||
#include <algorithm>
|
||||
|
||||
namespace dingosdk::profile_runtime {
|
||||
@@ -31,12 +32,13 @@ thread_local const ExpressionExecutionScope* expression_scope{};
|
||||
// profiler ran, which inflated the hooks in the very samples that measured them, 2026-10-03).
|
||||
// The sampler attributes their time.
|
||||
void execute_expression_hook(std::uintptr_t vm, std::uint32_t pc) {
|
||||
if(multiplayer::consume_match_marker_expression(vm,pc))return;
|
||||
// The setup helper only prepares registers. Native calls actually execute
|
||||
// in 1416d0b90 / 1416d1a10, including direct ECS interpreter entry paths.
|
||||
// Keep the exact cursor and profiler context when entering either runner.
|
||||
ExpressionExecutionScope scope(vm);
|
||||
initialize_starter_from_expression(vm, pc);
|
||||
multiplayer::execute_native_party_expression(vm, pc, 0,
|
||||
multiplayer::execute_throwdown_preview_expression(vm, pc, 0,
|
||||
[](std::uintptr_t expression, std::uint32_t cursor, std::uintptr_t) {
|
||||
local_runtime().execute_expression(expression, cursor);
|
||||
});
|
||||
@@ -45,9 +47,10 @@ void execute_expression_hook(std::uintptr_t vm, std::uint32_t pc) {
|
||||
}
|
||||
|
||||
void execute_profiled_expression_hook(std::uintptr_t vm, std::uint32_t pc, std::uintptr_t profiler) {
|
||||
if(multiplayer::consume_match_marker_expression(vm,pc))return;
|
||||
ExpressionExecutionScope scope(vm);
|
||||
initialize_starter_from_expression(vm, pc);
|
||||
multiplayer::execute_native_party_expression(vm, pc, profiler, local_runtime().execute_profiled_expression);
|
||||
multiplayer::execute_throwdown_preview_expression(vm, pc, profiler, local_runtime().execute_profiled_expression);
|
||||
deliver_local_challenge_completion(vm, pc);
|
||||
multiplayer::complete_native_throwdown_parameters(vm);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
|
||||
namespace dingosdk::profile_runtime {
|
||||
// Applied gameplay values live in an ECS cache even when the user-value
|
||||
@@ -96,9 +97,55 @@ void save_gameplay_setting(const void* reference, const dingosdk::Json& value) {
|
||||
} catch (...) { dingosdk::logging::event(dingosdk::logging::Channel::settings, "{\"event\":\"local_gameplay_setting_save_failed\"}"); }
|
||||
}
|
||||
|
||||
// The player's own stance (Gameplay Preferences' Default Stance Is Goofy), seen as the game reads
|
||||
// or changes it: a plugin's flick pictures follow it. Its setting is found once by its key
|
||||
// (one naming both "stance" and "goofy"); every other setting asset is checked once per thread.
|
||||
namespace {
|
||||
std::atomic<std::uintptr_t> stance_asset{};
|
||||
std::atomic<int> stance_value{-1};
|
||||
void note_stance(const void* reference, bool value) noexcept {
|
||||
std::uintptr_t asset{};
|
||||
if (!reference || !memory::peek(reinterpret_cast<std::uintptr_t>(reference), asset)) return;
|
||||
asset &= ~std::uintptr_t{4};
|
||||
if (!asset) return;
|
||||
if (const auto known = stance_asset.load(std::memory_order_relaxed); known != asset) {
|
||||
if (known) return;
|
||||
thread_local std::array<std::uintptr_t, 256> checked{};
|
||||
auto& slot = checked[((asset >> 4) ^ (asset >> 12)) & 255];
|
||||
if (slot == asset) return;
|
||||
slot = asset;
|
||||
PreserveError preserve;
|
||||
try {
|
||||
std::string key;
|
||||
if (!gameplay_setting_key(reference, key)) return;
|
||||
std::string lower;
|
||||
for (const unsigned char c : key) lower += static_cast<char>(c >= 'A' && c <= 'Z' ? c - 'A' + 'a' : c);
|
||||
if (lower.find("goofy") == std::string::npos || lower.find("stance") == std::string::npos) return;
|
||||
stance_asset.store(asset, std::memory_order_relaxed);
|
||||
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::settings,
|
||||
"Player stance: read from the game's \"{}\" setting.", key);
|
||||
} catch (...) { return; }
|
||||
}
|
||||
if (stance_value.exchange(value ? 1 : 0, std::memory_order_relaxed) != (value ? 1 : 0))
|
||||
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::settings,
|
||||
"Player stance: {}.", value ? "goofy" : "regular");
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<bool> game_stance_goofy() noexcept {
|
||||
const auto value = stance_value.load(std::memory_order_relaxed);
|
||||
if (value < 0) return {};
|
||||
return value == 1;
|
||||
}
|
||||
|
||||
bool gameplay_get_bool(std::uintptr_t m, const void* a) {
|
||||
if (const auto value = saved_gameplay_setting<bool>(a)) return *value;
|
||||
return gameplay_settings().get_bool(m, a);
|
||||
if (const auto value = saved_gameplay_setting<bool>(a)) {
|
||||
note_stance(a, *value);
|
||||
return *value;
|
||||
}
|
||||
const bool value = gameplay_settings().get_bool(m, a);
|
||||
note_stance(a, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
float gameplay_get_number(std::uintptr_t m, const void* a) {
|
||||
@@ -124,9 +171,13 @@ void gameplay_set_bool(std::uintptr_t m, const void* a, bool v) {
|
||||
PreserveError preserve; std::string key;
|
||||
}
|
||||
if (!gameplay_user_edit()) {
|
||||
gameplay_settings().set_bool(m, a, saved_gameplay_setting<bool>(a).value_or(v)); return;
|
||||
const bool applied = saved_gameplay_setting<bool>(a).value_or(v);
|
||||
gameplay_settings().set_bool(m, a, applied);
|
||||
note_stance(a, applied);
|
||||
return;
|
||||
}
|
||||
gameplay_settings().set_bool(m, a, v);
|
||||
note_stance(a, v);
|
||||
save_gameplay_setting(a, v);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ template<class T> std::optional<T> saved_gameplay_setting(const void* reference)
|
||||
|
||||
void save_gameplay_setting(const void* reference, const dingosdk::Json& value);
|
||||
|
||||
// The player's Default Stance Is Goofy setting, once the game has read or changed it.
|
||||
std::optional<bool> game_stance_goofy() noexcept;
|
||||
|
||||
bool gameplay_get_bool(std::uintptr_t m, const void* a);
|
||||
|
||||
float gameplay_get_number(std::uintptr_t m, const void* a);
|
||||
|
||||
@@ -728,6 +728,7 @@ std::optional<std::string> named_setting_value(std::string_view name) {
|
||||
return {};
|
||||
}
|
||||
std::string player_change_named_setting(std::string_view name, std::string_view value, bool restore) {
|
||||
// Player changes remain subject to the existing session settings lock.
|
||||
auto &r = runtime();
|
||||
if (!r.thread || r.thread != GetCurrentThreadId())
|
||||
return "error: Native settings require the game update thread.";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Reading tricks from the score HUD's values, apart from the game: a trick's start, its points
|
||||
// once it lands, a slam, and a trick whose points never come. Moved with the trick watch from
|
||||
// Domination (PR 279), which tests its spots on top.
|
||||
// once it lands, a slam, and a trick whose points never come.
|
||||
#include "Extension/Skater/trick_watch.h"
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "board_pose.h"
|
||||
#include "Extension/AnimationStudio/anim_core.h"
|
||||
#include "breakable_deck.h"
|
||||
#include "Extension/Multiplayer/Remote/native_pose_layout.h"
|
||||
#include <Windows.h>
|
||||
#include <algorithm>
|
||||
@@ -91,13 +90,11 @@ Shared& shared() {
|
||||
}
|
||||
std::atomic<std::uintptr_t> base_now{}, holder_now{};
|
||||
std::atomic<bool> any{};
|
||||
// A breakable deck pack is in the game (breakable_deck.h): its deck model carries four stretches of
|
||||
// deck on bones 12 to 15, which every board keeps out of sight unless a plugin places them (else they
|
||||
// show as cracks); its truck model has the baseplates on the trucks' effectors (5 and 9), held on the
|
||||
// deck; and the wheels sit as far apart as the deck's own trucks have them.
|
||||
std::atomic<bool> deck_pack{};
|
||||
std::atomic<float> deck_axle{1.0f};
|
||||
constexpr int first_stretch = 12;
|
||||
// How every board is fitted (fit): the baseplates held on the deck, and the wheels' spread. Read by
|
||||
// the render hook without the lock; the owner is under it.
|
||||
std::atomic<bool> fitted{}, fit_plates{};
|
||||
std::atomic<float> fit_axle{1.0f};
|
||||
std::string fit_owner;
|
||||
struct TruckRest {
|
||||
int truck, plate;
|
||||
Quat facing; // the truck's bone on the deck's, at rest
|
||||
@@ -217,6 +214,24 @@ void park(std::string_view owner, std::vector<int> list) {
|
||||
for (const auto& [who, bones_of] : s.park_by) s.parked.insert(bones_of.begin(), bones_of.end());
|
||||
refresh_any(s);
|
||||
}
|
||||
std::string fit(std::string_view owner, std::optional<Fit> value) {
|
||||
auto& s = shared();
|
||||
std::lock_guard lock(s.mutex);
|
||||
if (!value) {
|
||||
if (fit_owner == owner) {
|
||||
fit_owner.clear();
|
||||
fitted.store(false, std::memory_order_release);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
if (!(value->axle >= 0.8f && value->axle <= 1.6f)) return "a board's axle spread is 0.8 to 1.6 (1: the game's own)";
|
||||
if (!fit_owner.empty() && fit_owner != owner) return fit_owner + " fits the boards";
|
||||
fit_owner = owner;
|
||||
fit_plates.store(value->plates, std::memory_order_release);
|
||||
fit_axle.store(value->axle, std::memory_order_release);
|
||||
fitted.store(value->plates || value->axle != 1.0f, std::memory_order_release);
|
||||
return {};
|
||||
}
|
||||
std::string toss(std::string_view owner, std::vector<int> list, Vec3 velocity, Vec3 spin, float ground) {
|
||||
std::erase_if(list, [](int b) { return b < first_writable || b >= bones; });
|
||||
if (list.empty()) return "a piece is bones 2 to 15";
|
||||
@@ -250,11 +265,6 @@ void tick(std::uintptr_t base, std::uintptr_t component) noexcept {
|
||||
} catch (...) {}
|
||||
base_now.store(base, std::memory_order_release);
|
||||
holder_now.store(holder, std::memory_order_release);
|
||||
try {
|
||||
const auto deck = breakable_deck::in_play();
|
||||
deck_axle.store(breakable_deck::axle(deck), std::memory_order_release);
|
||||
deck_pack.store(!deck.empty(), std::memory_order_release);
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
void on_render_pose(std::uintptr_t animation_interface) noexcept {
|
||||
@@ -264,8 +274,8 @@ void on_render_pose(std::uintptr_t animation_interface) noexcept {
|
||||
const auto base = base_now.load(std::memory_order_acquire);
|
||||
const auto holder = animation_interface - 0xc0;
|
||||
const bool local = holder == holder_now.load(std::memory_order_acquire) && holder;
|
||||
const bool stretches = deck_pack.load(std::memory_order_acquire);
|
||||
if (!local && !stretches && !any.load(std::memory_order_acquire)) {
|
||||
const bool fitting = fitted.load(std::memory_order_acquire);
|
||||
if (!local && !fitting && !any.load(std::memory_order_acquire)) {
|
||||
SetLastError(error);
|
||||
return;
|
||||
}
|
||||
@@ -291,9 +301,7 @@ void on_render_pose(std::uintptr_t animation_interface) noexcept {
|
||||
if (length(at) < 100.0f) (void)store(bone_at(buffer, b), Quat{lanes[b].rotation}, add(at, {0, -2000, 0}));
|
||||
};
|
||||
for (const int b : s.parked) away(b);
|
||||
if (stretches) {
|
||||
for (int b = first_stretch; b < bones; ++b)
|
||||
if (!s.parked.contains(b)) away(b);
|
||||
if (fitting && fit_plates.load(std::memory_order_acquire)) {
|
||||
// Each baseplate on the deck, from its truck's bone as the animation left it (not while
|
||||
// the truck is off its place: a plugin moved it).
|
||||
for (const auto& rest : truck_rests) {
|
||||
@@ -304,9 +312,11 @@ void on_render_pose(std::uintptr_t animation_interface) noexcept {
|
||||
(void)store(bone_at(buffer, rest.plate), normalised(multiply(back, rest.facing)),
|
||||
rotate(back, sub(add(rest.at, rotate(rest.facing, rest.effector)), at)));
|
||||
}
|
||||
}
|
||||
if (fitting) {
|
||||
// The deck's own trucks: the wheels' place along the axle is set rather than scaled, so a
|
||||
// pose that comes by twice is not moved twice.
|
||||
if (const float axle = deck_axle.load(std::memory_order_acquire); axle != 1.0f)
|
||||
if (const float axle = fit_axle.load(std::memory_order_acquire); axle != 1.0f)
|
||||
for (const int b : wheel_bones) {
|
||||
int way = 0; // the axle, in the truck's axes: the largest part of where the wheel sits
|
||||
for (int i = 1; i < 3; ++i)
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
// the deck, 4 and 8 the trucks, 6, 7, 10, 11 the wheels, 12 to 15 a breakable deck's stretches. What
|
||||
// is written goes into the pose as it goes to the renderer, every frame, and the next animation
|
||||
// update writes the whole pose again: nothing of the board itself changes, only what is drawn. Bones
|
||||
// 0 and 1 are never written (they place the board; clips of the skater move bone 1). While a
|
||||
// breakable deck pack is in the game, every board keeps 12 to 15 out of sight unless a plugin sets them.
|
||||
// 0 and 1 are never written (they place the board; clips of the skater move bone 1).
|
||||
namespace dingosdk::board_pose {
|
||||
using Vec3 = std::array<float, 3>;
|
||||
using Quat = std::array<float, 4>; // x, y, z, w
|
||||
@@ -34,6 +33,16 @@ void clear(std::string_view owner, int bone = -1);
|
||||
// These bones go out of sight on every board, other players' included (a breakable deck's
|
||||
// stretches, which the game would draw as cracks).
|
||||
void park(std::string_view owner, std::vector<int> list);
|
||||
// How every board in the game is fitted, other players' included, for a deck model that is not the
|
||||
// game's own (a breakable deck pack's): `plates` holds each truck's baseplate (its effector, bones 5
|
||||
// and 9) on the deck as the truck steers, and `axle` sets the wheels as far apart as the deck's
|
||||
// trucks have them (1 the game's own, 0.8 to 1.6). One owner at a time; empty: fitted, else why not.
|
||||
// No value: that owner's fit ends.
|
||||
struct Fit {
|
||||
bool plates{};
|
||||
float axle = 1.0f;
|
||||
};
|
||||
std::string fit(std::string_view owner, std::optional<Fit> value);
|
||||
// A piece comes off the local board: these bones (hanging on bone 1) leave as one, from where they
|
||||
// are, at `velocity` (m/s, world) turning at `spin` (rad/s, world), fall, land on `ground` (a world
|
||||
// height) and lie there, until cleared.
|
||||
|
||||
@@ -0,0 +1,724 @@
|
||||
#include "bone_cam.h"
|
||||
#include "bone_sprites.h"
|
||||
#include "skater_skeleton.h"
|
||||
#include "Extension/Console/commands.h"
|
||||
#include "Extension/Multiplayer/Remote/native_pose_layout.h"
|
||||
#include "Extension/Skater/client_source_spawn_internal.h"
|
||||
#include "Extension/Skater/no_bail.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Game/UI/game_view.h"
|
||||
#include <Windows.h>
|
||||
#include <mmsystem.h>
|
||||
#pragma comment(lib, "winmm.lib")
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <atomic>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <span>
|
||||
|
||||
namespace dingosdk::bonecam {
|
||||
namespace {
|
||||
using Vec3 = std::array<float, 3>;
|
||||
using Quat = std::array<float, 4>;
|
||||
constexpr std::uint64_t duration_ms = 4200, fade_in_ms = 150, fade_out_ms = 600, settle_ms = 150;
|
||||
// After-effects (a concussion's) last up to half a minute, a hit flashes briefly.
|
||||
constexpr std::uint64_t mark_ms = 30000, hit_ms = 450;
|
||||
constexpr std::uint16_t head_up = 0xffff; // the skull points along the head's own up axis
|
||||
|
||||
// The animation pose is parent-local {scale, Hamilton quaternion, translation} per joint, and the
|
||||
// root chain carries the skater's world placement (client_first_person.cpp), so composing from
|
||||
// the root lands in world space.
|
||||
struct Joint {
|
||||
Quat rotation{0, 0, 0, 1};
|
||||
Vec3 position{};
|
||||
float scale = 1;
|
||||
};
|
||||
Vec3 rotate(const Quat &q, const Vec3 &v) {
|
||||
const float tx = 2 * (q[1] * v[2] - q[2] * v[1]), ty = 2 * (q[2] * v[0] - q[0] * v[2]), tz = 2 * (q[0] * v[1] - q[1] * v[0]);
|
||||
return {v[0] + q[3] * tx + (q[1] * tz - q[2] * ty), v[1] + q[3] * ty + (q[2] * tx - q[0] * tz),
|
||||
v[2] + q[3] * tz + (q[0] * ty - q[1] * tx)};
|
||||
}
|
||||
Joint child(const Joint &parent, const std::array<float, 12> &bone) {
|
||||
const auto &q = parent.rotation;
|
||||
const auto offset = rotate(q, {bone[8] * parent.scale, bone[9] * parent.scale, bone[10] * parent.scale});
|
||||
Joint result;
|
||||
for (std::size_t i = 0; i < 3; ++i) result.position[i] = parent.position[i] + offset[i];
|
||||
result.rotation = {q[3] * bone[4] + q[0] * bone[7] + q[1] * bone[6] - q[2] * bone[5],
|
||||
q[3] * bone[5] - q[0] * bone[6] + q[1] * bone[7] + q[2] * bone[4],
|
||||
q[3] * bone[6] + q[0] * bone[5] - q[1] * bone[4] + q[2] * bone[7],
|
||||
q[3] * bone[7] - q[0] * bone[4] - q[1] * bone[5] - q[2] * bone[6]};
|
||||
result.scale = parent.scale * (bone[0] + bone[1] + bone[2]) / 3.0f;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Each bone sprite between two joints.
|
||||
struct Span {
|
||||
BoneSprite sprite;
|
||||
std::uint16_t a, b;
|
||||
};
|
||||
constexpr Span spans[]{
|
||||
{BoneSprite::torso, joint::hips, joint::head}, // the torso art runs up the neck to the skull
|
||||
{BoneSprite::femur_r, joint::right_up_leg, joint::right_leg}, {BoneSprite::femur_l, joint::left_up_leg, joint::left_leg},
|
||||
{BoneSprite::shin_r, joint::right_leg, joint::right_foot}, {BoneSprite::shin_l, joint::left_leg, joint::left_foot},
|
||||
{BoneSprite::foot_r, joint::right_foot, joint::right_toe}, {BoneSprite::foot_l, joint::left_foot, joint::left_toe},
|
||||
{BoneSprite::humerus_r, joint::right_arm, joint::right_fore_arm}, {BoneSprite::humerus_l, joint::left_arm, joint::left_fore_arm},
|
||||
{BoneSprite::forearm_r, joint::right_fore_arm, joint::right_hand}, {BoneSprite::forearm_l, joint::left_fore_arm, joint::left_hand},
|
||||
{BoneSprite::hand_r, joint::right_hand, joint::right_middle}, {BoneSprite::hand_l, joint::left_hand, joint::left_middle},
|
||||
{BoneSprite::skull, joint::head, head_up},
|
||||
};
|
||||
// A joint that stops dead hurts the bone beside it: cracked (or sprained, torn...) past `crack`
|
||||
// m/s of speed lost into what it hit, broken past `snap`. Each bone has its own limits, roughly as
|
||||
// bodies go: ankles, wrists and collarbones give first; the femur and pelvis take the most. A drop
|
||||
// of about 2.5 m (7 m/s) onto one part is where it starts to hurt; a normal jump hurts nothing.
|
||||
struct Watch {
|
||||
std::uint16_t joint;
|
||||
BoneSprite sprite;
|
||||
const char *cracked_bone, *cracked, *broken_bone, *broken;
|
||||
float crack, snap;
|
||||
};
|
||||
constexpr Watch watches[]{
|
||||
{joint::head, BoneSprite::skull, "SKULL", "CONCUSSION", "SKULL", "FRACTURED", 5.5f, 9.5f},
|
||||
{joint::right_hand, BoneSprite::forearm_r, "RIGHT WRIST", "SPRAINED", "RIGHT RADIUS", "SNAPPED", 6.5f, 9.5f},
|
||||
{joint::left_hand, BoneSprite::forearm_l, "LEFT WRIST", "SPRAINED", "LEFT RADIUS", "SNAPPED", 6.5f, 9.5f},
|
||||
{joint::right_fore_arm, BoneSprite::humerus_r, "RIGHT ELBOW", "DISLOCATED", "RIGHT HUMERUS", "FRACTURED", 7.5f, 11.0f},
|
||||
{joint::left_fore_arm, BoneSprite::humerus_l, "LEFT ELBOW", "DISLOCATED", "LEFT HUMERUS", "FRACTURED", 7.5f, 11.0f},
|
||||
{joint::right_arm, BoneSprite::torso, "RIGHT COLLARBONE", "CRACKED", "RIGHT COLLARBONE", "BROKEN", 7.0f, 10.0f},
|
||||
{joint::left_arm, BoneSprite::torso, "LEFT COLLARBONE", "CRACKED", "LEFT COLLARBONE", "BROKEN", 7.0f, 10.0f},
|
||||
{joint::spine2, BoneSprite::torso, "RIBS", "CRACKED", "RIBS", "BROKEN", 8.0f, 11.5f},
|
||||
{joint::hips, BoneSprite::torso, "PELVIS", "BRUISED", "PELVIS", "FRACTURED", 9.0f, 13.0f},
|
||||
{joint::right_leg, BoneSprite::femur_r, "RIGHT KNEE", "TORN", "RIGHT FEMUR", "FRACTURED", 8.5f, 12.5f},
|
||||
{joint::left_leg, BoneSprite::femur_l, "LEFT KNEE", "TORN", "LEFT FEMUR", "FRACTURED", 8.5f, 12.5f},
|
||||
{joint::right_foot, BoneSprite::shin_r, "RIGHT ANKLE", "SPRAINED", "RIGHT TIBIA", "SNAPPED", 7.0f, 10.0f},
|
||||
{joint::left_foot, BoneSprite::shin_l, "LEFT ANKLE", "SPRAINED", "LEFT TIBIA", "SNAPPED", 7.0f, 10.0f},
|
||||
};
|
||||
// A concussion's grade from the hardest hit the head took (m/s lost), and how long its after-
|
||||
// effects last. Below the first, no concussion.
|
||||
struct Concussion {
|
||||
float from;
|
||||
const char *what;
|
||||
std::uint64_t lasts_ms;
|
||||
};
|
||||
constexpr Concussion concussions[]{
|
||||
{5.5f, "MILD CONCUSSION", 4000}, {7.0f, "CONCUSSION", 7000}, {8.5f, "SEVERE CONCUSSION", 10000}, {10.0f, "KNOCKED OUT", 12000}};
|
||||
constexpr std::size_t watch_count = std::size(watches);
|
||||
|
||||
enum class Setting { game, on, off };
|
||||
struct State {
|
||||
std::atomic<std::uintptr_t> pose_base{},pose_client{};
|
||||
std::atomic<std::uint64_t> pose_context_at{};
|
||||
Setting setting = Setting::game;
|
||||
bool effects = true;
|
||||
bool ringing = true; // a concussion's ringing in the ears (it can be muted)
|
||||
std::uint64_t effects_until{};
|
||||
// A bail heard while still in the air: measured from now on, shown once the body lands.
|
||||
bool armed{};
|
||||
std::uint64_t armed_at{};
|
||||
float hips_vy{}; // the hips' vertical speed (m/s, up positive) at the last tick
|
||||
float fell{}; // the fastest the hips fell since the bail began (negative)
|
||||
float hit{};
|
||||
std::uint64_t hit_time{};
|
||||
Vec3 hit_at{};
|
||||
float head_hit{}; // the hardest hit the head took this bail (m/s lost): its concussion
|
||||
bool rang{}; // the concussion's ringing played this bail
|
||||
bool active{}, preview{}, wipeouts_known{}, have_position{}, have_velocity{}, pose_reported{}, posed{};
|
||||
std::uint64_t started{}, serial{}, wipeouts{}, last_tick{}, unready_since{};
|
||||
std::array<Vec3, watch_count> position{}, velocity{};
|
||||
// The speed each joint carried lately (decaying over a third of a second): into the ground,
|
||||
// and along it. A bail is often noticed only once the body has already hit, so the hit is
|
||||
// measured against what the joint carried before, not just the last tick.
|
||||
std::array<float, watch_count> carry_down{}, carry_side{};
|
||||
// The hips' recent speeds, to notice the body stopping dead (the ground, a pole, a wall).
|
||||
std::array<std::pair<std::uint64_t, float>, 24> hips_speeds{};
|
||||
std::size_t hips_next{};
|
||||
std::atomic<bool> slam_seen{};
|
||||
std::uint64_t slam_time{};
|
||||
// The head, chest, shoulders or hips striking an object at speed (bone_cam_struck), and when
|
||||
// any part last did.
|
||||
std::atomic<bool> struck_seen{};
|
||||
std::uint64_t struck_time{};
|
||||
std::array<std::uint8_t, watch_count> level{};
|
||||
std::array<std::uint8_t, static_cast<std::size_t>(BoneSprite::count)> hurt{};
|
||||
std::vector<Joint> joints = std::vector<Joint>(skater_joint_count);
|
||||
std::vector<std::array<float, 12>> raw = std::vector<std::array<float, 12>>(skater_joint_count);
|
||||
std::atomic<bool> bail_pending{};
|
||||
std::mutex mutex;
|
||||
BoneCam shown;
|
||||
};
|
||||
State &state() {
|
||||
static auto *value = new State;
|
||||
return *value;
|
||||
}
|
||||
const Concussion *concussion_of(float head_hit) {
|
||||
const Concussion *found{};
|
||||
for (const auto &c : concussions)
|
||||
if (head_hit >= c.from) found = &c;
|
||||
return found;
|
||||
}
|
||||
// A concussion's ears ringing: one high tone, very quiet, fading over a few seconds.
|
||||
void ring(float strength) {
|
||||
static std::vector<std::uint8_t> wav;
|
||||
constexpr std::uint32_t rate = 22050;
|
||||
const float seconds = 1.5f + 2.0f * strength, volume = 0.02f + 0.025f * strength;
|
||||
const auto count = static_cast<std::uint32_t>(seconds * rate), bytes = count * 2;
|
||||
PlaySoundW(nullptr, nullptr, 0); // the buffer below may be playing: stop it before it changes
|
||||
wav.assign(44 + bytes, 0);
|
||||
const auto put = [&](std::size_t at, std::uint32_t value, int size) {
|
||||
for (int i = 0; i < size; ++i) wav[at + i] = static_cast<std::uint8_t>(value >> (8 * i));
|
||||
};
|
||||
std::memcpy(wav.data(), "RIFF", 4);
|
||||
put(4, 36 + bytes, 4);
|
||||
std::memcpy(wav.data() + 8, "WAVEfmt ", 8);
|
||||
put(16, 16, 4); // format block size
|
||||
put(20, 1, 2); // PCM
|
||||
put(22, 1, 2); // mono
|
||||
put(24, rate, 4);
|
||||
put(28, rate * 2, 4); // bytes per second
|
||||
put(32, 2, 2);
|
||||
put(34, 16, 2);
|
||||
std::memcpy(wav.data() + 36, "data", 4);
|
||||
put(40, bytes, 4);
|
||||
for (std::uint32_t i = 0; i < count; ++i) {
|
||||
const float t = static_cast<float>(i) / rate;
|
||||
const float envelope = std::min(1.0f, t / 0.4f) * std::max(0.0f, 1.0f - t / seconds);
|
||||
const float tone = std::sin(6.2831853f * 3800.0f * t) * (0.85f + 0.15f * std::sin(6.2831853f * 3.0f * t));
|
||||
put(44 + i * 2, static_cast<std::uint16_t>(static_cast<std::int16_t>(tone * envelope * volume * 32767)), 2);
|
||||
}
|
||||
PlaySoundW(reinterpret_cast<LPCWSTR>(wav.data()), nullptr, SND_MEMORY | SND_ASYNC | SND_NODEFAULT);
|
||||
}
|
||||
std::uint64_t now_ms() { return GetTickCount64(); }
|
||||
|
||||
|
||||
// A bail begins: its injuries start from nothing, and from now on hits count (they need the body
|
||||
// to have landed, so nothing hurts while it is still in the air).
|
||||
void begin_bail(State &s, std::uint64_t now) {
|
||||
// Joint speeds tracked up to now carry over, so the hit that set off the bail still counts.
|
||||
if (now - s.last_tick > 100) {
|
||||
s.have_position = s.have_velocity = false;
|
||||
s.carry_down.fill(0);
|
||||
s.carry_side.fill(0);
|
||||
s.last_tick = now;
|
||||
}
|
||||
s.armed = true;
|
||||
s.armed_at = now;
|
||||
s.fell = std::min(0.0f, s.hips_vy);
|
||||
++s.serial;
|
||||
s.head_hit = 0;
|
||||
s.rang = false;
|
||||
s.level.fill(0);
|
||||
s.hurt.fill(0);
|
||||
s.preview = false;
|
||||
s.pose_reported = false;
|
||||
s.posed = false;
|
||||
}
|
||||
// The X-ray comes up: when the body lands (or hits a wall), not while it is still flying.
|
||||
void show(State &s, std::uint64_t now) {
|
||||
s.armed = false;
|
||||
s.active = true;
|
||||
s.started = now;
|
||||
s.effects_until = now + mark_ms;
|
||||
logging::log(logging::Level::info, logging::Channel::runtime, "Bone Cam: started.");
|
||||
}
|
||||
void start(State &s, std::uint64_t now) {
|
||||
begin_bail(s, now);
|
||||
show(s, now);
|
||||
}
|
||||
// Ends the X-ray and everything the slams left behind.
|
||||
void stop(State &s) {
|
||||
s.active = false;
|
||||
s.armed = false;
|
||||
s.effects_until = 0;
|
||||
s.hit = 0;
|
||||
std::lock_guard lock(s.mutex);
|
||||
s.shown = {};
|
||||
}
|
||||
// The skater's pose this tick, composed into world space. Empty while it can be read, else why not.
|
||||
std::string read_pose(State &s, std::uintptr_t base, std::uintptr_t client) {
|
||||
try {
|
||||
namespace detail = client_source::detail;
|
||||
const auto component = detail::first_person_component(base, client);
|
||||
std::uintptr_t holder{};
|
||||
if (!detail::first_person_read(component + 0xa0, &holder, 8) || !holder) return "no animation holder";
|
||||
const auto pose = multiplayer::read_native_pose_layout(detail::first_person_read, base, holder, 512);
|
||||
if (!pose.buffer) return "no pose buffer";
|
||||
if (pose.count != skater_joint_count) return "the skeleton has " + std::to_string(pose.count) + " joints";
|
||||
if (!detail::first_person_read(pose.buffer, s.raw.data(), skater_joint_count * sizeof(s.raw[0]))) return "pose unreadable";
|
||||
for (std::size_t i = 0; i < skater_joint_count; ++i) {
|
||||
const auto &bone = s.raw[i];
|
||||
for (const std::size_t lane : {0u, 1u, 2u, 4u, 5u, 6u, 7u, 8u, 9u, 10u})
|
||||
if (!std::isfinite(bone[lane]) || std::abs(bone[lane]) > 1.0e6f) return "pose not ready";
|
||||
const auto parent = skater_joint_parent[i];
|
||||
s.joints[i] = child(parent < 0 ? Joint{} : s.joints[static_cast<std::size_t>(parent)], bone);
|
||||
}
|
||||
return {};
|
||||
} catch (const client_source::detail::SourceGuard &guard) {
|
||||
return guard.message ? guard.message : "skater unavailable";
|
||||
} catch (const std::exception &e) {
|
||||
return e.what();
|
||||
} catch (...) {
|
||||
return "unknown error";
|
||||
}
|
||||
}
|
||||
void injure(State &s, std::size_t w, std::uint8_t level) {
|
||||
if (level <= s.level[w]) return;
|
||||
s.level[w] = level;
|
||||
auto &hurt = s.hurt[static_cast<std::size_t>(watches[w].sprite)];
|
||||
hurt = std::max(hurt, level);
|
||||
}
|
||||
// Joints whose velocity changed sharply since the last tick hit something.
|
||||
// Joints that hit the ground hard. Only the parts of the body touching the ground (the lowest at
|
||||
// that moment) take damage, and only for the speed they were carrying into it and lost: a
|
||||
// head-first slam cracks the skull and collarbones, a feet-first one the ankles and shins, and a
|
||||
// ragdoll's flailing hurts nothing. The skull gives first; at most three bones break per bail.
|
||||
// Outside a bail (`injuring` false) it only keeps the joints' speeds, and watches the hips for a
|
||||
// dead stop: a body that loses 7 m/s within a third of a second hit something (bone_cam_slam).
|
||||
void find_impacts(State &s, std::uint64_t now, bool injuring = true) {
|
||||
const float seconds = static_cast<float>(now - s.last_tick) / 1000.0f;
|
||||
s.last_tick = now;
|
||||
const bool usable = seconds > 0.004f && seconds < 0.1f;
|
||||
float lowest = std::numeric_limits<float>::max();
|
||||
for (const auto &w : watches) lowest = std::min(lowest, s.joints[w.joint].position[1]);
|
||||
auto broken = static_cast<std::size_t>(std::count(s.level.begin(), s.level.end(), std::uint8_t{2}));
|
||||
const float decay = usable ? std::exp(-seconds / 0.33f) : 0.0f;
|
||||
// Whether the body as a whole has stopped falling: the hips' vertical speed. In the air the
|
||||
// whole body drops together and nothing has hit anything, however the limbs swing.
|
||||
float hips_vertical = 0;
|
||||
for (std::size_t w = 0; w < watch_count; ++w)
|
||||
if (watches[w].joint == joint::hips && s.have_position && usable)
|
||||
hips_vertical = (s.joints[joint::hips].position[1] - s.position[w][1]) / seconds;
|
||||
const bool body_stopped = hips_vertical > -2.5f;
|
||||
// How fast the body as a whole was going (the hips' carried speeds): no part of it hits harder
|
||||
// than that, give or take. An arm swung down in a handplant, a foot on a stair, a jump on the
|
||||
// spot move a limb fast while the body barely moves, and none of them is a fall.
|
||||
float body_down = 0, body_side = 0;
|
||||
// The body's speed (the hips') last tick and now, to notice a part of it hitting an object.
|
||||
float body_before = 0, body_now = 0;
|
||||
for (std::size_t w = 0; w < watch_count; ++w)
|
||||
if (watches[w].joint == joint::hips) {
|
||||
body_down = s.carry_down[w];
|
||||
body_side = s.carry_side[w];
|
||||
const auto &was = s.velocity[w];
|
||||
body_before = std::sqrt(was[0] * was[0] + was[1] * was[1] + was[2] * was[2]);
|
||||
if (s.have_position && usable) {
|
||||
const auto &p = s.joints[joint::hips].position, &q = s.position[w];
|
||||
const float dx = (p[0] - q[0]) / seconds, dy = (p[1] - q[1]) / seconds, dz = (p[2] - q[2]) / seconds;
|
||||
body_now = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
}
|
||||
// A real collision slows the body too, as it wraps round what it hit.
|
||||
const bool body_slowed = body_before >= 6.0f && body_now < 0.85f * body_before;
|
||||
if (s.have_position && usable) {
|
||||
s.hips_vy = hips_vertical;
|
||||
if (s.armed) s.fell = std::min(s.fell, hips_vertical); // the fastest fall since the bail began
|
||||
}
|
||||
for (std::size_t w = 0; w < watch_count; ++w) {
|
||||
const auto &p = s.joints[watches[w].joint].position;
|
||||
if (s.have_position && usable) {
|
||||
Vec3 v{};
|
||||
for (int i = 0; i < 3; ++i) v[i] = (p[i] - s.position[w][i]) / seconds;
|
||||
const float down = std::max(0.0f, -v[1]), side = std::hypot(v[0], v[2]);
|
||||
// A teleport or a reset is not a hit.
|
||||
if (down > 60.0f || side > 60.0f) {
|
||||
s.position[w] = p;
|
||||
s.carry_down[w] = s.carry_side[w] = 0;
|
||||
continue;
|
||||
}
|
||||
if (watches[w].joint == joint::hips) {
|
||||
const float speed = std::hypot(side, v[1]);
|
||||
float fastest = 0;
|
||||
for (const auto &[at, was] : s.hips_speeds)
|
||||
if (at && now - at <= 330) fastest = std::max(fastest, was);
|
||||
// Fast (8 m/s or more) a moment ago, all but stopped now, and not still falling:
|
||||
// the body hit something. Once in a while at most: on foot the measured speed
|
||||
// jitters, and one slam must not be counted thirty times.
|
||||
if (fastest >= 8.0f && speed < 2.5f && body_stopped && now - s.slam_time > 1500) {
|
||||
s.slam_seen.store(true);
|
||||
s.slam_time = now;
|
||||
}
|
||||
s.hips_speeds[s.hips_next++ % s.hips_speeds.size()] = {now, speed};
|
||||
}
|
||||
// On the ground: among the body's lowest joints, the body no longer falling, and this
|
||||
// joint itself stopped (not just swinging past the others). Or against a wall or pole:
|
||||
// the whole body stopping dead, this joint with it.
|
||||
const bool stopped = std::abs(v[1]) < 2.5f;
|
||||
const bool on_ground = p[1] - lowest <= 0.3f && body_stopped && stopped;
|
||||
const bool against = s.slam_time && now - s.slam_time <= 150 && std::hypot(side, v[1]) < 3.0f;
|
||||
// Struck an object (a pole, a tree, a rail, a ramp's edge): moving with the body at speed
|
||||
// a moment ago, nearly stopped now while the body was still going, the body slowing as it
|
||||
// wraps round it. A swinging limb does not stop dead in the world while the body flies on.
|
||||
const auto &was = s.velocity[w];
|
||||
const float before = std::sqrt(was[0] * was[0] + was[1] * was[1] + was[2] * was[2]);
|
||||
const float after = std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
|
||||
const bool struck = s.have_velocity && body_slowed && before >= 0.7f * body_before && after <= 0.35f * body_before &&
|
||||
p[1] - lowest > 0.3f;
|
||||
if (struck) {
|
||||
// The head, the chest, the shoulders or the hips: the body itself hit it (a limb alone
|
||||
// only counts once a bail is on).
|
||||
const auto j = watches[w].joint;
|
||||
const bool body = j == joint::head || j == joint::spine2 || j == joint::hips || j == joint::right_arm || j == joint::left_arm;
|
||||
if (body && now - s.struck_time > 1000) s.struck_seen.store(true);
|
||||
s.struck_time = now;
|
||||
}
|
||||
const bool touching = on_ground || against || struck;
|
||||
if (injuring && s.have_velocity && touching) {
|
||||
// The speed it carried into the ground and lost, plus some of the speed along it
|
||||
// lost (a scrape or a wall). Striking an object: all the speed it lost.
|
||||
const float into = std::min(std::max(0.0f, s.carry_down[w] - down), body_down + 2.0f);
|
||||
const float sideways = std::min(std::max(0.0f, s.carry_side[w] - side), body_side + 2.0f);
|
||||
const float impact = struck ? std::min(before - after, body_before + 2.0f) : into + 0.35f * sideways;
|
||||
// The preview shows the skeleton alone: nothing cracks or breaks in it.
|
||||
if (!s.preview) {
|
||||
if (impact >= watches[w].snap && broken < 3) {
|
||||
if (s.level[w] < 2) ++broken; // the cap holds within one tick too
|
||||
injure(s, w, 2);
|
||||
} else if (impact >= watches[w].crack) {
|
||||
injure(s, w, 1);
|
||||
}
|
||||
}
|
||||
if (!s.preview && watches[w].joint == joint::head) s.head_hit = std::max(s.head_hit, impact);
|
||||
// Spent: the same hit is not counted again next tick.
|
||||
if (impact >= 3.0f) {
|
||||
s.carry_down[w] = down;
|
||||
s.carry_side[w] = side;
|
||||
}
|
||||
if (!s.preview && impact >= 3.0f) {
|
||||
// The hit's flash, brighter the harder the hit.
|
||||
const float since = static_cast<float>(now - s.hit_time) / hit_ms;
|
||||
const float left = s.hit * std::max(0.0f, 1.0f - since), strength = std::clamp(impact / 9.0f, 0.3f, 1.0f);
|
||||
if (strength > left) {
|
||||
s.hit = strength;
|
||||
s.hit_time = now;
|
||||
s.hit_at = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
s.carry_down[w] = std::max(s.carry_down[w] * decay, down);
|
||||
s.carry_side[w] = std::max(s.carry_side[w] * decay, side);
|
||||
s.velocity[w] = v;
|
||||
}
|
||||
s.position[w] = p;
|
||||
}
|
||||
if (usable) s.have_velocity = s.have_position;
|
||||
s.have_position = true;
|
||||
}
|
||||
// The joints the 3D X-ray builds its bones on. The spine between the hips and the head, and the
|
||||
// collarbones (the arms' parents), are found once from the skeleton's parent table.
|
||||
struct PoseJoints {
|
||||
std::vector<std::uint16_t> spine; // hips .. head
|
||||
std::array<std::uint16_t, 2> clavicle{};
|
||||
};
|
||||
const PoseJoints &pose_joints() {
|
||||
static const PoseJoints value = [] {
|
||||
PoseJoints p;
|
||||
for (int j = joint::head; j >= 0 && j != joint::hips; j = skater_joint_parent[static_cast<std::size_t>(j)])
|
||||
p.spine.insert(p.spine.begin(), static_cast<std::uint16_t>(j));
|
||||
p.spine.insert(p.spine.begin(), joint::hips);
|
||||
for (std::size_t side = 0; side < 2; ++side) {
|
||||
const auto arm = side ? joint::left_arm : joint::right_arm;
|
||||
const auto parent = skater_joint_parent[arm];
|
||||
p.clavicle[side] = static_cast<std::uint16_t>(parent >= 0 ? parent : arm);
|
||||
}
|
||||
return p;
|
||||
}();
|
||||
return value;
|
||||
}
|
||||
BoneCamPose pose_of(const State &s) {
|
||||
BoneCamPose pose;
|
||||
const auto &p = pose_joints();
|
||||
const auto at = [&](std::uint16_t j) { return s.joints[j].position; };
|
||||
for (const auto j : p.spine) pose.spine.push_back(at(j));
|
||||
pose.head = at(joint::head);
|
||||
pose.head_up = rotate(s.joints[joint::head].rotation, {1, 0, 0}); // the head joint's local +X is up
|
||||
for (std::size_t side = 0; side < 2; ++side) {
|
||||
const bool l = side == 1;
|
||||
pose.clavicle[side] = at(p.clavicle[side]);
|
||||
pose.shoulder[side] = at(l ? joint::left_arm : joint::right_arm);
|
||||
pose.elbow[side] = at(l ? joint::left_fore_arm : joint::right_fore_arm);
|
||||
pose.wrist[side] = at(l ? joint::left_hand : joint::right_hand);
|
||||
pose.fingers[side] = at(l ? joint::left_middle : joint::right_middle);
|
||||
pose.hip[side] = at(l ? joint::left_up_leg : joint::right_up_leg);
|
||||
pose.knee[side] = at(l ? joint::left_leg : joint::right_leg);
|
||||
pose.ankle[side] = at(l ? joint::left_foot : joint::right_foot);
|
||||
pose.toe[side] = at(l ? joint::left_toe : joint::right_toe);
|
||||
}
|
||||
pose.hurt.assign(s.hurt.begin(), s.hurt.end());
|
||||
pose.valid = true;
|
||||
return pose;
|
||||
}
|
||||
|
||||
// The marks and hit as they look now: marks fade over their last 8 s.
|
||||
void publish_effects(State &s, std::uint64_t now, BoneCam &cam) {
|
||||
if (!s.effects || s.preview) return;
|
||||
const float since = static_cast<float>(now - s.hit_time) / hit_ms;
|
||||
if (s.hit > 0 && since < 1) {
|
||||
cam.hit = s.hit * (1 - since) * (1 - since);
|
||||
cam.hit_at = s.hit_at;
|
||||
}
|
||||
// A concussion's after-effects, once the X-ray is over: kept faint (a soft dimming at the
|
||||
// edges, a slow sway of it), strongest at once and easing off over the concussion's time.
|
||||
if (const auto *c = concussion_of(s.head_hit); c && now >= s.started + duration_ms) {
|
||||
const auto into = now - (s.started + duration_ms);
|
||||
if (into < c->lasts_ms) {
|
||||
const float grade = std::clamp((s.head_hit - 5.5f) / 5.0f, 0.0f, 1.0f);
|
||||
cam.daze = (0.35f + 0.65f * grade) * (1.0f - static_cast<float>(into) / c->lasts_ms);
|
||||
if (!s.rang) {
|
||||
s.rang = true;
|
||||
if (s.ringing) ring(grade);
|
||||
}
|
||||
}
|
||||
}
|
||||
cam.effects = cam.hit > 0 || cam.daze > 0;
|
||||
}
|
||||
void publish(State &s, std::uint64_t now, bool bones = true) {
|
||||
BoneCam cam;
|
||||
publish_effects(s, now, cam);
|
||||
cam.active = s.active;
|
||||
if (!s.active) {
|
||||
std::lock_guard lock(s.mutex);
|
||||
s.shown = std::move(cam);
|
||||
return;
|
||||
}
|
||||
cam.serial = s.serial;
|
||||
cam.preview = s.preview;
|
||||
const auto elapsed = now - s.started, left = duration_ms > elapsed ? duration_ms - elapsed : 0;
|
||||
cam.fade = std::clamp(std::min(elapsed / static_cast<float>(fade_in_ms), left / static_cast<float>(fade_out_ms)), 0.0f, 1.0f);
|
||||
const auto &l = s.joints[joint::left_arm].position, &r = s.joints[joint::right_arm].position;
|
||||
cam.left = {l[0] - r[0], l[1] - r[1], l[2] - r[2]};
|
||||
if (bones) cam.pose = pose_of(s);
|
||||
for (const auto &span : bones ? std::span<const Span>(spans) : std::span<const Span>()) {
|
||||
BoneCamBone bone;
|
||||
bone.sprite = static_cast<std::uint8_t>(span.sprite);
|
||||
bone.a = s.joints[span.a].position;
|
||||
if (span.b == head_up) {
|
||||
// The head joint's local +X is up (client_first_person.cpp).
|
||||
const auto up = rotate(s.joints[span.a].rotation, {1, 0, 0});
|
||||
for (int i = 0; i < 3; ++i) bone.b[i] = bone.a[i] + up[i] * 0.2f;
|
||||
} else {
|
||||
bone.b = s.joints[span.b].position;
|
||||
}
|
||||
bone.hurt = s.hurt[static_cast<std::size_t>(span.sprite)];
|
||||
cam.bones.push_back(bone);
|
||||
}
|
||||
for (std::size_t w = 0; w < watch_count; ++w) {
|
||||
if (watches[w].joint == joint::head) continue; // the head is listed by its concussion below
|
||||
if (s.level[w])
|
||||
cam.injuries.push_back({s.level[w] == 2 ? watches[w].broken_bone : watches[w].cracked_bone,
|
||||
s.level[w] == 2 ? watches[w].broken : watches[w].cracked, s.level[w] == 2});
|
||||
}
|
||||
// The head: a fractured skull, and the concussion graded by how hard it hit.
|
||||
if (s.level[0] == 2) cam.injuries.push_back({"SKULL", "FRACTURED", true});
|
||||
if (const auto *c = concussion_of(s.head_hit)) cam.injuries.push_back({"HEAD", c->what, c->from >= 8.5f});
|
||||
std::stable_sort(cam.injuries.begin(), cam.injuries.end(), [](const auto &a, const auto &b) { return a.severe > b.severe; });
|
||||
std::lock_guard lock(s.mutex);
|
||||
s.shown = std::move(cam);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace {
|
||||
void load_settings() noexcept; // the player's saved Bone Cam choices (below)
|
||||
}
|
||||
namespace {
|
||||
// The owners (plugins' games) asking for the "game" setting's Bone Cam.
|
||||
struct Askers {
|
||||
std::mutex mutex;
|
||||
std::set<std::string, std::less<>> owners;
|
||||
std::atomic<bool> any{};
|
||||
};
|
||||
Askers& askers() {
|
||||
static auto* value = new Askers;
|
||||
return *value;
|
||||
}
|
||||
bool game_asks() noexcept { return askers().any.load(std::memory_order_acquire); }
|
||||
} // namespace
|
||||
void bone_cam_game(std::string_view owner, bool on) {
|
||||
auto& a = askers();
|
||||
std::lock_guard lock(a.mutex);
|
||||
if (on) a.owners.emplace(owner);
|
||||
else if (const auto found = a.owners.find(owner); found != a.owners.end()) a.owners.erase(found);
|
||||
a.any.store(!a.owners.empty(), std::memory_order_release);
|
||||
}
|
||||
void bone_cam_release(std::string_view owner) { bone_cam_game(owner, false); }
|
||||
void tick_bone_cam(std::uintptr_t base, std::uintptr_t client, bool playing) noexcept {
|
||||
try {
|
||||
auto &s = state();
|
||||
static const bool loaded = (load_settings(), true); // the player's saved choices, once
|
||||
(void)loaded;
|
||||
const auto now = now_ms();
|
||||
s.pose_base=base;s.pose_client=client;s.pose_context_at=now;
|
||||
// Only a lasting loss of play ends it: a slam can briefly take the game out of play.
|
||||
if (!playing) {
|
||||
if (!s.unready_since) s.unready_since = now;
|
||||
if (!s.active || now - s.unready_since >= 2000) stop(s);
|
||||
return;
|
||||
}
|
||||
s.unready_since = 0;
|
||||
// Bails are reported by a plugin that watches the wipeout states (bone_cam_bail).
|
||||
const bool asked = game_asks();
|
||||
const bool wanted = s.setting == Setting::on || (s.setting == Setting::game && asked);
|
||||
if (s.bail_pending.exchange(false) && wanted && !s.active && !s.armed) begin_bail(s, now);
|
||||
// The X-ray comes up when the body lands: it was falling and has stopped. A bail with no
|
||||
// fall in it (the body already down) shows at once; one that never lands, after 6 s.
|
||||
if (s.armed) {
|
||||
const bool landed = s.fell < -3.0f && s.hips_vy > -2.5f;
|
||||
const bool never_fell = s.fell >= -3.0f && s.hips_vy > -2.5f && now - s.armed_at > 400;
|
||||
// Or the moment it hits something on the way down: a pole, a tree, a rail.
|
||||
const bool struck = s.struck_time >= s.armed_at && now - s.struck_time < 300;
|
||||
if (landed || never_fell || struck || now - s.armed_at > 6000) show(s, now);
|
||||
}
|
||||
// The X-ray ends after a few seconds; a concussion's after-effects last a while longer.
|
||||
if (s.active && now - s.started >= duration_ms) s.active = false;
|
||||
const bool showing = s.active || (s.effects && now < s.effects_until);
|
||||
if (!showing) {
|
||||
{
|
||||
std::lock_guard lock(s.mutex);
|
||||
s.shown = {};
|
||||
}
|
||||
// Between bails the joints are still followed, so the hit that sets off a bail counts.
|
||||
// A game that asks needs them with the Bone Cam off too: its on-foot slams (bone_cam_slam)
|
||||
// come from here.
|
||||
// While a bail is armed (in the air) its hits are measured already.
|
||||
if ((wanted || asked) && read_pose(s, base, client).empty()) find_impacts(s, now, s.armed);
|
||||
return;
|
||||
}
|
||||
if (const auto why = read_pose(s, base, client); !why.empty()) {
|
||||
if (!s.pose_reported) {
|
||||
s.pose_reported = true;
|
||||
logging::log(logging::Level::warning, logging::Channel::runtime, "Bone Cam: cannot read the skater's pose: {}", why);
|
||||
}
|
||||
// Mid-ragdoll the skater can be out of reach: the X-ray and injuries still show, with
|
||||
// the last pose read this bail if there was one.
|
||||
publish(s, now, s.posed);
|
||||
return;
|
||||
}
|
||||
s.posed = true;
|
||||
if (!s.pose_reported) {
|
||||
s.pose_reported = true;
|
||||
logging::log(logging::Level::info, logging::Channel::runtime, "Bone Cam: reading the skater's {} joints.", skater_joint_count);
|
||||
}
|
||||
find_impacts(s, now, s.active || s.armed);
|
||||
publish(s, now);
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
void bone_cam_bail() noexcept { state().bail_pending.store(true); }
|
||||
|
||||
bool bone_cam_slam() noexcept { return state().slam_seen.exchange(false); }
|
||||
|
||||
bool bone_cam_struck() noexcept { return state().struck_seen.exchange(false); }
|
||||
|
||||
std::string bone_cam_setting() {
|
||||
switch (state().setting) {
|
||||
case Setting::on: return "on";
|
||||
case Setting::off: return "off";
|
||||
default: return "game";
|
||||
}
|
||||
}
|
||||
bool bone_cam_ringing() noexcept { return state().ringing; }
|
||||
|
||||
namespace {
|
||||
// The player's Bone Cam choices, kept between launches: %LOCALAPPDATA%\ReSkate\bone_cam.txt, one
|
||||
// `name=value` per line. A missing or unreadable file leaves the defaults.
|
||||
std::filesystem::path settings_file() {
|
||||
std::array<wchar_t, 32768> local{};
|
||||
const auto length = GetEnvironmentVariableW(L"LOCALAPPDATA", local.data(), static_cast<DWORD>(local.size()));
|
||||
if (!length || length >= local.size()) return {};
|
||||
return std::filesystem::path(std::wstring(local.data(), length)) / L"ReSkate" / L"bone_cam.txt";
|
||||
}
|
||||
void save_settings(const State &s) {
|
||||
try {
|
||||
const auto path = settings_file();
|
||||
if (path.empty()) return;
|
||||
std::filesystem::create_directories(path.parent_path());
|
||||
std::ofstream out(path, std::ios::trunc);
|
||||
out << "show=" << (s.setting == Setting::on ? "on" : s.setting == Setting::off ? "off" : "game") << '\n'
|
||||
<< "effects=" << (s.effects ? "on" : "off") << '\n'
|
||||
<< "ringing=" << (s.ringing ? "on" : "off") << '\n';
|
||||
} catch (...) {}
|
||||
}
|
||||
void load_settings() noexcept {
|
||||
try {
|
||||
auto &s = state();
|
||||
const auto path = settings_file();
|
||||
if (path.empty()) return;
|
||||
std::ifstream in(path);
|
||||
for (std::string line; std::getline(in, line);) {
|
||||
const auto eq = line.find('=');
|
||||
if (eq == std::string::npos) continue;
|
||||
const auto name = line.substr(0, eq), value = line.substr(eq + 1);
|
||||
if (name == "show") s.setting = value == "on" ? Setting::on : value == "off" ? Setting::off : Setting::game;
|
||||
else if (name == "effects") s.effects = value != "off";
|
||||
else if (name == "ringing") s.ringing = value != "off";
|
||||
}
|
||||
} catch (...) {}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string bone_cam_command(const std::vector<std::string> &arguments) {
|
||||
auto &s = state();
|
||||
const auto word = arguments.empty() ? std::string() : arguments[0];
|
||||
if (word.empty())
|
||||
return "Bone Cam: " + bone_cam_setting() + ", slam effects " + (s.effects ? "on" : "off") + ", ringing " +
|
||||
(s.ringing ? "on" : "off") + " (bonecam game|on|off|test, bonecam effects on|off, bonecam ring on|off).";
|
||||
if (word == "effects") {
|
||||
const auto value = arguments.size() > 1 ? arguments[1] : std::string();
|
||||
if (value != "on" && value != "off") return "error: usage: bonecam effects on|off";
|
||||
s.effects = value == "on";
|
||||
if (!s.effects) s.hit = 0;
|
||||
save_settings(s);
|
||||
return s.effects ? "Slam effects on: the hit flash and a concussion's after-effects." : "Slam effects off.";
|
||||
}
|
||||
if (word == "ring") {
|
||||
const auto value = arguments.size() > 1 ? arguments[1] : std::string();
|
||||
if (value != "on" && value != "off") return "error: usage: bonecam ring on|off";
|
||||
s.ringing = value == "on";
|
||||
if (!s.ringing) PlaySoundW(nullptr, nullptr, 0); // and quiet one already ringing
|
||||
save_settings(s);
|
||||
return s.ringing ? "Concussion ringing on." : "Concussion ringing muted.";
|
||||
}
|
||||
if (word == "game") s.setting = Setting::game;
|
||||
else if (word == "on") s.setting = Setting::on;
|
||||
else if (word == "off") { s.setting = Setting::off; stop(s); }
|
||||
else if (word == "test") {
|
||||
// A look at the Bone Cam without bailing: the skeleton alone, nothing broken.
|
||||
stop(s);
|
||||
start(s, now_ms());
|
||||
s.preview = true;
|
||||
return "Bone Cam preview for a few seconds."; } else {
|
||||
return "error: usage: bonecam game|on|off|test";
|
||||
}
|
||||
save_settings(s);
|
||||
return word == "game" ? "Bone Cam shows while a game asks for it." : word == "on" ? "Bone Cam shows on every bail." : "Bone Cam off.";
|
||||
}
|
||||
|
||||
BoneCam bone_cam() {
|
||||
auto &s = state();
|
||||
BoneCam cam;
|
||||
{
|
||||
std::lock_guard lock(s.mutex);
|
||||
if (!s.shown.active && !s.shown.effects) return {};
|
||||
cam = s.shown;
|
||||
}
|
||||
if (const auto view = latest_game_view()) {
|
||||
cam.camera = view->world;
|
||||
cam.vertical_fov = view->vertical_fov;
|
||||
}
|
||||
return cam;
|
||||
}
|
||||
std::optional<std::array<float,3>> bail_body_position() noexcept {
|
||||
try {
|
||||
auto& owner=state();const auto sampled=owner.pose_context_at.load();const auto now=now_ms();
|
||||
if(!sampled || now<sampled || now-sampled>250)return {};
|
||||
const auto base=owner.pose_base.load(),client=owner.pose_client.load();if(!base || !client)return {};
|
||||
// Do not mutate Bone Cam's impact history, pose publication or settings.
|
||||
thread_local State probe;
|
||||
if(!read_pose(probe,base,client).empty())return {};
|
||||
const auto at=probe.joints[joint::hips].position;
|
||||
if(std::any_of(at.begin(),at.end(),[](float value){return !std::isfinite(value) || std::abs(value)>100000.f;}))return {};
|
||||
return at;
|
||||
} catch(...) {return {};}
|
||||
}
|
||||
} // namespace dingosdk::bonecam
|
||||
@@ -0,0 +1,85 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// The Bone Cam: Skate 2's X-ray bail view. When the local skater wipes out (while a plugin's game
|
||||
// asks for it, or on every bail if the player turns it on) the screen goes X-ray for a few seconds:
|
||||
// every bone is drawn between the skater's real joints, a joint that stops dead cracks or breaks
|
||||
// the bone beside it, and the injuries are listed. It never changes game speed: the game's own
|
||||
// time scale steps rather than slows, and only a console command may change it.
|
||||
namespace dingosdk::bonecam {
|
||||
// One bone of the snapshot, drawn between its real joints; those that took a hard hit in red.
|
||||
struct BoneCamBone {
|
||||
std::uint8_t sprite{}; // BoneSprite (bone_sprites.h)
|
||||
std::array<float, 3> a{}, b{}; // world: the bone's first joint, and a point along it
|
||||
std::uint8_t hurt{}; // 0 sound, 1 cracked, 2 broken
|
||||
};
|
||||
struct BoneCamInjury {
|
||||
std::string bone, what; // "LEFT FEMUR", "FRACTURED"
|
||||
bool severe{};
|
||||
};
|
||||
// What a slam leaves behind: marks on the ground where the body slid (a skid for the torso, thin
|
||||
// scrapes for hands, knees and head), and the hit itself.
|
||||
struct BoneCamMark {
|
||||
std::vector<std::array<float, 3>> path; // world, along the ground
|
||||
float alpha{}; // 0..1, fading with age
|
||||
bool skid{};
|
||||
};
|
||||
// The skater's pose for a 3D X-ray, in world space. Sides are [0] right, [1] left. `valid` once a
|
||||
// pose has been read this bail.
|
||||
struct BoneCamPose {
|
||||
using Point = std::array<float, 3>;
|
||||
bool valid{};
|
||||
std::vector<Point> spine; // the joints from the hips up to the head, in order
|
||||
Point head{}, head_up{}; // the head joint and its up axis (unit)
|
||||
std::array<Point, 2> clavicle{}, shoulder{}, elbow{}, wrist{}, fingers{}, hip{}, knee{}, ankle{}, toe{};
|
||||
// Injuries by region (BoneSprite order): 0 sound, 1 cracked, 2 broken.
|
||||
std::vector<std::uint8_t> hurt;
|
||||
};
|
||||
struct BoneCam {
|
||||
bool active{}; // the X-ray itself
|
||||
BoneCamPose pose;
|
||||
bool effects{}; // marks or a hit to draw (with or without the X-ray)
|
||||
float fade{}; // 0..1: fades in when the bail starts, out at the end
|
||||
std::vector<BoneCamMark> marks;
|
||||
float hit{}; // 0..1: the flash of an impact, fading fast
|
||||
float daze{}; // 0..1: a concussion's after-effects (kept faint), fading
|
||||
std::array<float, 3> hit_at{}; // world: where it landed
|
||||
bool preview{}; // the menu's preview: the injuries are examples
|
||||
std::uint64_t serial{}; // one per bail
|
||||
std::vector<BoneCamBone> bones;
|
||||
std::vector<BoneCamInjury> injuries;
|
||||
std::array<float, 3> left{}; // world: from the skater's right shoulder toward the left one (which way the art faces)
|
||||
std::array<float, 16> camera{}; // world matrix: right, up, back, position rows
|
||||
float vertical_fov{};
|
||||
};
|
||||
|
||||
// Game thread, every client tick after the trainer's (it reads the bail count the trainer watches).
|
||||
void tick_bone_cam(std::uintptr_t base, std::uintptr_t client, bool playing) noexcept;
|
||||
// `bonecam [game|on|off|test]`, `bonecam effects on|off`, `bonecam ring on|off` (game thread).
|
||||
std::string bone_cam_command(const std::vector<std::string> &arguments);
|
||||
std::string bone_cam_setting(); // "game", "on" or "off"
|
||||
bool bone_cam_ringing() noexcept; // a concussion's ringing is on (not muted)
|
||||
// The local skater just bailed (a plugin that watches the wipeout states says so; any thread).
|
||||
void bone_cam_bail() noexcept;
|
||||
// The "game" setting shows the Bone Cam while any owner (a plugin's game) asks for it; its slams
|
||||
// and strikes are followed then even with the Bone Cam off. An owner's ask goes with release.
|
||||
void bone_cam_game(std::string_view owner, bool on);
|
||||
void bone_cam_release(std::string_view owner);
|
||||
// Whether the skater's body stopped dead (lost 7 m/s within a third of a second) since the last
|
||||
// call: the ground, a pole, a wall. Only followed while the Bone Cam would show or a game asks.
|
||||
bool bone_cam_slam() noexcept;
|
||||
// Whether the skater's head, chest, shoulders or hips struck an object at speed (a pole, a tree, a
|
||||
// rail: nearly stopped while the body was still flying) since the last call. Followed as
|
||||
// bone_cam_slam is.
|
||||
bool bone_cam_struck() noexcept;
|
||||
// The X-ray's snapshot, with the latest camera (any thread).
|
||||
BoneCam bone_cam();
|
||||
// Fresh native hips position while the normal on-board telemetry is absent.
|
||||
// Read-only; independent of whether Bone Cam visuals/effects are enabled.
|
||||
std::optional<std::array<float,3>> bail_body_position() noexcept;
|
||||
} // namespace dingosdk::bonecam
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
// The Bone Cam's X-ray bones: one sprite per bone, cut from a public-domain skeleton illustration
|
||||
// (Mikael Häggström / LadyofHats, Wikimedia Commons) into bones.png (its bake). Each
|
||||
// sprite has two anchors in its own pixels: goes on the bone's first joint and points along
|
||||
// the bone toward its second. Generated from the bake's bones.json; regenerate both together.
|
||||
namespace dingosdk::bonecam {
|
||||
enum class BoneSprite : std::uint8_t {
|
||||
torso, skull, humerus_l, humerus_r, forearm_l, forearm_r, hand_l, hand_r,
|
||||
femur_l, femur_r, shin_l, shin_r, foot_l, foot_r, count
|
||||
};
|
||||
struct BoneSpriteRect {
|
||||
const char *name;
|
||||
int x, y, w, h; // in the atlas
|
||||
std::array<float, 2> a, b; // anchors, sprite pixels
|
||||
};
|
||||
inline constexpr int bone_atlas_width = 2048, bone_atlas_height = 858;
|
||||
inline constexpr float bone_metres_per_pixel = 0.000976088f;
|
||||
inline constexpr std::array<BoneSpriteRect, static_cast<std::size_t>(BoneSprite::count)> bone_sprites{{
|
||||
{"torso", 6, 6, 595, 656, {247.6f, 587.0f}, {251.0f, -67.0f}},
|
||||
{"skull", 1711, 6, 166, 243, {83.0f, 223.9f}, {83.0f, 4.4f}},
|
||||
{"humerus_l", 1200, 6, 123, 330, {61.3f, 17.2f}, {61.3f, 312.1f}},
|
||||
{"humerus_r", 1329, 6, 154, 323, {76.5f, 17.0f}, {76.5f, 305.7f}},
|
||||
{"forearm_l", 1570, 6, 135, 246, {81.4f, 13.8f}, {81.4f, 229.2f}},
|
||||
{"forearm_r", 1489, 6, 75, 256, {39.6f, 14.2f}, {39.6f, 239.9f}},
|
||||
{"hand_l", 6, 668, 157, 184, {78.2f, 11.4f}, {78.2f, 172.2f}},
|
||||
{"hand_r", 1883, 6, 142, 191, {70.5f, 11.7f}, {70.5f, 179.1f}},
|
||||
{"femur_l", 785, 6, 169, 507, {84.5f, 23.2f}, {84.5f, 454.9f}},
|
||||
{"femur_r", 607, 6, 172, 513, {85.8f, 23.2f}, {85.8f, 454.5f}},
|
||||
{"shin_l", 960, 6, 117, 401, {56.2f, 20.1f}, {56.2f, 380.8f}},
|
||||
{"shin_r", 1083, 6, 111, 400, {57.2f, 20.0f}, {57.2f, 379.7f}},
|
||||
{"foot_l", 169, 668, 157, 150, {78.0f, 10.0f}, {78.0f, 139.5f}},
|
||||
{"foot_r", 332, 668, 153, 146, {76.2f, 9.9f}, {76.2f, 135.6f}},
|
||||
}};
|
||||
} // namespace dingosdk::bonecam
|
||||
@@ -1,147 +0,0 @@
|
||||
#include "breakable_deck.h"
|
||||
#include "Engine/Vfs/mod_catalog.h"
|
||||
#include "Extension/Assets/live_mods.h"
|
||||
#include <Windows.h>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <charconv>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
|
||||
namespace dingosdk::breakable_deck {
|
||||
namespace {
|
||||
// What a deck says of itself, in its folder's deck.ini: `axle` for its trucks (1 = a standard board's;
|
||||
// more on a wider deck) and `label`, its name in the menus.
|
||||
struct Setup {
|
||||
float axle = 1.0f;
|
||||
std::string label;
|
||||
};
|
||||
std::string trimmed(std::string_view text) {
|
||||
while (!text.empty() && std::isspace(static_cast<unsigned char>(text.front()))) text.remove_prefix(1);
|
||||
while (!text.empty() && std::isspace(static_cast<unsigned char>(text.back()))) text.remove_suffix(1);
|
||||
return std::string(text);
|
||||
}
|
||||
Setup read_setup(std::string_view deck) {
|
||||
Setup out;
|
||||
if (deck.empty()) return out;
|
||||
try {
|
||||
const auto folder = mods::board_break_deck_folder(deck);
|
||||
if (folder.empty()) return out;
|
||||
std::ifstream in(folder / L"deck.ini");
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
if (const auto hash = line.find_first_of("#;"); hash != std::string::npos) line.erase(hash);
|
||||
const auto eq = line.find('=');
|
||||
if (eq == std::string::npos) continue;
|
||||
auto key = trimmed(std::string_view(line).substr(0, eq));
|
||||
for (auto& c : key) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
const auto value = trimmed(std::string_view(line).substr(eq + 1));
|
||||
float amount{};
|
||||
const auto parsed = std::from_chars(value.data(), value.data() + value.size(), amount);
|
||||
const bool whole = parsed.ec == std::errc{} && parsed.ptr == value.data() + value.size();
|
||||
if (key == "axle" && whole && std::isfinite(amount)) out.axle = std::clamp(amount, 0.8f, 1.6f);
|
||||
else if (key == "label") out.label = value;
|
||||
}
|
||||
} catch (...) {}
|
||||
return out;
|
||||
}
|
||||
// The same, kept a minute per deck: the menus ask every frame, and each look lists every mod's folder.
|
||||
Setup setup_of(std::string_view deck) {
|
||||
struct Kept {
|
||||
Setup setup;
|
||||
std::uint64_t at{};
|
||||
};
|
||||
static std::mutex mutex;
|
||||
static auto* kept = new std::map<std::string, Kept, std::less<>>;
|
||||
const auto now = GetTickCount64();
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
if (const auto at = kept->find(deck); at != kept->end() && now - at->second.at < 60000) return at->second.setup;
|
||||
}
|
||||
auto setup = read_setup(deck);
|
||||
std::lock_guard lock(mutex);
|
||||
(*kept)[std::string(deck)] = {setup, now};
|
||||
return setup;
|
||||
}
|
||||
// The deck `wanted` means, of those installed: a name, a number in the list (from 1), or "next"
|
||||
// for the one after the deck in the game. Empty when there is no such deck.
|
||||
std::string meant(std::string wanted, const std::string& in_game, const std::vector<std::string>& installed) {
|
||||
for (auto& c : wanted) c = c == ' ' ? '_' : static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
if (installed.empty()) return {};
|
||||
if (wanted.empty() || wanted == "next") {
|
||||
const auto now = std::find(installed.begin(), installed.end(), in_game);
|
||||
return now == installed.end() || now + 1 == installed.end() ? installed.front() : *(now + 1);
|
||||
}
|
||||
if (std::all_of(wanted.begin(), wanted.end(), [](char c) { return c >= '0' && c <= '9'; }) && wanted.size() <= 3) {
|
||||
// The decks the packs were made with keep their numbers whether they are installed or not;
|
||||
// the others follow, in the order they are listed.
|
||||
const auto number = static_cast<std::size_t>(std::stoi(wanted));
|
||||
const auto& known = mods::board_break_decks;
|
||||
if (number >= 1 && number <= known.size()) return std::string(known[number - 1]);
|
||||
std::size_t next = known.size();
|
||||
for (const auto& deck : installed)
|
||||
if (std::find(known.begin(), known.end(), deck) == known.end() && ++next == number) return deck;
|
||||
return {};
|
||||
}
|
||||
return wanted;
|
||||
}
|
||||
std::mutex wanted_mutex;
|
||||
std::string wanted_deck; // asked for, done at the next tick
|
||||
}
|
||||
|
||||
std::string in_play() noexcept { return mods::board_break_in_play(); }
|
||||
std::string label(std::string_view deck) {
|
||||
if (deck.empty()) return "None";
|
||||
if (auto title = setup_of(deck).label; !title.empty()) return title;
|
||||
std::string name(deck);
|
||||
bool start = true;
|
||||
for (auto& c : name) {
|
||||
if (c == '_') c = ' ';
|
||||
else if (start) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
start = c == ' ';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
float axle(std::string_view deck) { return deck.empty() ? 1.0f : setup_of(deck).axle; }
|
||||
|
||||
std::string choose(std::string wanted) {
|
||||
const auto installed = mods::installed_board_break_decks();
|
||||
if (installed.empty()) return "None of the breakable deck packs is installed.";
|
||||
const auto now = in_play();
|
||||
const auto chosen = meant(std::move(wanted), now, installed);
|
||||
if (chosen.empty()) return "There is no such deck.";
|
||||
if (std::find(installed.begin(), installed.end(), chosen) == installed.end())
|
||||
return "The " + label(chosen) + " deck is not installed: its mod (Board_Break_" + chosen + ") is not in the Mods folder.";
|
||||
if (chosen == now) return "The " + label(chosen) + " deck is the one in the game.";
|
||||
{
|
||||
std::lock_guard lock(wanted_mutex);
|
||||
wanted_deck = chosen;
|
||||
}
|
||||
return "The " + label(chosen) + " deck: the mods are applied and the level loads again with it.";
|
||||
}
|
||||
std::string list() {
|
||||
const auto installed = mods::installed_board_break_decks();
|
||||
const auto& known = mods::board_break_decks;
|
||||
std::string out;
|
||||
std::size_t number{};
|
||||
const auto add = [&](std::string_view name, bool there) {
|
||||
out += (++number > 1 ? ", " : "") + std::to_string(number) + " " + label(name) + (there ? "" : " (not installed)");
|
||||
};
|
||||
for (const auto name : known) add(name, std::find(installed.begin(), installed.end(), name) != installed.end());
|
||||
for (const auto& name : installed)
|
||||
if (std::find(known.begin(), known.end(), name) == known.end()) add(name, true);
|
||||
return out;
|
||||
}
|
||||
void tick() noexcept {
|
||||
try {
|
||||
std::string wanted;
|
||||
{
|
||||
std::lock_guard lock(wanted_mutex);
|
||||
wanted.swap(wanted_deck);
|
||||
}
|
||||
if (!wanted.empty() && wanted != in_play() && mods::save_board_break_deck(wanted)) (void)live_mods::apply(true);
|
||||
} catch (...) {}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
// The breakable deck packs (the Board_Break_<deck> mods; Engine/Vfs/mod_catalog.h): which deck every
|
||||
// board has, its name for people and how far apart its wheels sit. Breaking a board is a plugin's
|
||||
// (the board-break mod, through reskate.board); this is what every board needs whichever plugins run,
|
||||
// as board_pose draws it: the deck's four stretches out of sight, the baseplates on the deck and the
|
||||
// wheels at the deck's own spacing.
|
||||
namespace dingosdk::breakable_deck {
|
||||
// The deck in the game, or empty.
|
||||
std::string in_play() noexcept;
|
||||
// A deck's name in the menus: its deck.ini's `label`, else its folder's name, in words.
|
||||
std::string label(std::string_view deck);
|
||||
// How far apart the deck's wheels sit, as a standard board's are (deck.ini `axle`); 1 without one.
|
||||
float axle(std::string_view deck);
|
||||
// Puts `wanted` in the game (by name, by number, or "next"): the mods are applied again and the level
|
||||
// loads, at the next client tick. What happens, for people.
|
||||
std::string choose(std::string wanted);
|
||||
// The decks by number for the console ("1 Standard, 2 Wide (not installed), ...").
|
||||
std::string list();
|
||||
// Each client tick.
|
||||
void tick() noexcept;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
#include "breakable_deck.h"
|
||||
#include "Engine/Vfs/mod_catalog.h"
|
||||
#include "Extension/Console/commands.h"
|
||||
|
||||
// Which breakable deck every board has; the Mods page's deck card sends it too.
|
||||
namespace dingosdk::console {
|
||||
void register_breakable_deck_commands(Commands ®istry) {
|
||||
auto which = argument("deck", Type::text, true);
|
||||
which.complete = [](const Model &, auto) {
|
||||
auto names = mods::installed_board_break_decks();
|
||||
names.insert(names.begin(), "next");
|
||||
return names;
|
||||
};
|
||||
auto deck = action("boarddeck", "Which breakable deck every board has, by its name or its number, or next; the level loads again with it",
|
||||
Group::gameplay, {std::move(which)});
|
||||
deck.run = [](const Model &, const Values &args, const Output &out) {
|
||||
if (!args.empty() && !std::get<std::string>(args[0]).empty()) {
|
||||
out(breakable_deck::choose(std::get<std::string>(args[0])));
|
||||
return;
|
||||
}
|
||||
out("In the game: " + breakable_deck::label(breakable_deck::in_play()) + ". Decks: " + breakable_deck::list() + ".");
|
||||
};
|
||||
registry.add(std::move(deck));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
#include "client_source_spawn_internal.h"
|
||||
#include "free_flight.h"
|
||||
|
||||
namespace dingosdk::client_source::detail {
|
||||
// Input capture follows the lease, but placement needs proof that the native
|
||||
// view still selects it. A late flag-picker camera reset can preserve every
|
||||
// object identity while handing the local view back to mode zero.
|
||||
inline bool verified_free_camera_view(const InteractiveDebug& debug, const SourceCameraSnapshot& camera) {
|
||||
return debug.camera_owned && !debug.camera_ambiguous && !debug.first_person &&
|
||||
camera.identity == debug.camera_identity && camera.mode == 1 &&
|
||||
camera.active == camera.identity.camera && camera.input_active && camera.view_enabled;
|
||||
}
|
||||
|
||||
inline bool activate_free_camera(SourceTrial& trial, std::uintptr_t client, SourceCameraSnapshot& camera,
|
||||
DWORD error) {
|
||||
auto& debug = trial.debug;
|
||||
const bool recovering = debug.camera_owned;
|
||||
source_require(!debug.camera_ambiguous, "Native camera result is uncertain; restart before changing camera mode.");
|
||||
if (recovering) {
|
||||
source_require(camera.identity == debug.camera_identity, "The owned free camera changed.");
|
||||
if (camera.mode == 1) {
|
||||
source_require(camera.active == camera.identity.camera && camera.input_active && camera.view_enabled,
|
||||
"Another native camera has taken the local view.");
|
||||
return false;
|
||||
}
|
||||
source_require(!debug.first_person, "First person lost its native camera; exit it before enabling Freecam.");
|
||||
}
|
||||
source_require(camera.mode == 0 && camera.view_enabled && !camera.input_active &&
|
||||
camera.active != camera.identity.camera, "An active local view with no other camera override is required.");
|
||||
// Keep the last owned flight pose when native placement closes late. It
|
||||
// must not drag an already-flying route camera back to the skater.
|
||||
const auto pose = recovering ? debug.flight_matrix : debug_view_matrix(trial, camera);
|
||||
source_require(valid_flight_transform(pose), "Freecam pose is unavailable.");
|
||||
if (!recovering) debug.camera_identity = camera.identity;
|
||||
debug.camera_owned = true;
|
||||
debug.camera_ambiguous = true;
|
||||
SetLastError(error);
|
||||
trial.camera_mode(camera.identity.controller, 1);
|
||||
debug.camera_ambiguous = false;
|
||||
camera = source_camera_snapshot(trial, client);
|
||||
source_require(camera.identity == debug.camera_identity && camera.mode == 1 &&
|
||||
camera.active == camera.identity.camera && camera.input_active && camera.view_enabled,
|
||||
"Freecam activation could not be verified; disable Freecam to restore the view.");
|
||||
debug.flight_ready = false;
|
||||
SetLastError(error);
|
||||
trial.camera_transform(camera.identity.camera, &pose);
|
||||
debug.flight_matrix = pose;
|
||||
debug.flight_ready = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "Engine/Game/Build/20260929/engine.h"
|
||||
#include "Engine/Game/Multiplayer/session_tools.h"
|
||||
#include "free_flight.h"
|
||||
#include "client_camera_activation.h"
|
||||
#include "Engine/Game/Skater/first_person_cycle.h"
|
||||
#include "Engine/Game/Skater/velocity_boost.h"
|
||||
#include "Extension/Multiplayer/Remote/native_skater.h"
|
||||
@@ -20,6 +21,9 @@
|
||||
#include <utility>
|
||||
|
||||
namespace dingosdk::client_source::detail {
|
||||
namespace {
|
||||
std::atomic<std::uintptr_t> verified_free_camera{};
|
||||
}
|
||||
double source_flight_now() {
|
||||
// GetTickCount64 advances by roughly 15.6 ms even at high frame rates.
|
||||
// Keep those coarse ticks for deadlines, never for integrating motion.
|
||||
@@ -718,36 +722,22 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b
|
||||
debug.status = "Original camera restored.";
|
||||
return;
|
||||
}
|
||||
if (debug.camera_owned) {
|
||||
source_require(camera.identity == debug.camera_identity && camera.mode == 1,
|
||||
"The owned free camera changed.");
|
||||
if (debug.camera_owned && debug.first_person) {
|
||||
source_require(camera.identity == debug.camera_identity && camera.mode == 1 &&
|
||||
camera.active == camera.identity.camera, "The owned free camera changed.");
|
||||
if (debug.first_person) {
|
||||
// Keep the owned camera and fly on from the last head pose.
|
||||
first_person_disarm();
|
||||
first_person_restore_fov(debug);
|
||||
debug.first_person = false;
|
||||
debug.status = "Freecam enabled from the first-person view.";
|
||||
}
|
||||
return;
|
||||
}
|
||||
source_require(camera.mode == 0 && camera.view_enabled && !camera.input_active,
|
||||
"An active local view with no other camera override is required.");
|
||||
const auto pose = debug_view_matrix(trial, camera);
|
||||
debug.camera_identity = camera.identity;
|
||||
debug.camera_owned = true; debug.camera_ambiguous = true;
|
||||
SetLastError(error);
|
||||
trial.camera_mode(camera.identity.controller, 1);
|
||||
debug.camera_ambiguous = false;
|
||||
camera = source_camera_snapshot(trial, client);
|
||||
source_require(camera.identity == debug.camera_identity && camera.mode == 1 &&
|
||||
camera.active == camera.identity.camera && camera.input_active,
|
||||
"Freecam activation could not be verified; disable Freecam to restore the view.");
|
||||
SetLastError(error);
|
||||
trial.camera_transform(camera.identity.camera, &pose);
|
||||
debug.flight_matrix = pose;
|
||||
debug.flight_time = source_flight_now();
|
||||
debug.flight_ready = true;
|
||||
debug.status = "Freecam enabled. WASD / Q E move; hold RMB to look; Shift boosts.";
|
||||
const bool recovering = debug.camera_owned;
|
||||
if (activate_free_camera(trial, client, camera, error)) {
|
||||
debug.flight_time = source_flight_now();
|
||||
debug.status = recovering ? "Freecam restored after native placement changed the view."
|
||||
: "Freecam enabled. WASD / Q E move; hold RMB to look; Shift boosts.";
|
||||
}
|
||||
return;
|
||||
}
|
||||
source_require(false, "Unknown debug request.");
|
||||
@@ -902,6 +892,7 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli
|
||||
// (First person owns the same camera but leaves the skater its controls.)
|
||||
state.free_camera_active.store(debug.camera_owned && !debug.first_person, std::memory_order_release);
|
||||
model.free_camera = debug.camera_owned && !debug.first_person;
|
||||
verified_free_camera.store(0, std::memory_order_release);
|
||||
std::optional<SourceCameraSnapshot> flight_camera;
|
||||
try { debug_flight_tick(trial, client, can_control, camera_phase_observed, flight_input, error.value, flight_camera); }
|
||||
catch (const SourceGuard& guard) { debug_stop_noclip(debug); debug.status = guard.message; }
|
||||
@@ -918,6 +909,8 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli
|
||||
} catch (...) {}
|
||||
try {
|
||||
const auto camera = flight_camera ? *flight_camera : source_camera_snapshot(trial, client);
|
||||
if (verified_free_camera_view(debug, camera))
|
||||
verified_free_camera.store(camera.identity.camera, std::memory_order_release);
|
||||
const bool owned_view = camera.mode == 1 && camera.active == camera.identity.camera;
|
||||
model.free_camera = owned_view && !debug.first_person;
|
||||
// Handed back on foot still reads as on, so the toggle can switch it off.
|
||||
@@ -1075,6 +1068,9 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli
|
||||
bool client_free_camera_active() noexcept {
|
||||
return source_state().free_camera_active.load(std::memory_order_acquire);
|
||||
}
|
||||
std::uintptr_t client_free_camera_view() noexcept {
|
||||
return verified_free_camera.load(std::memory_order_acquire);
|
||||
}
|
||||
bool restore_client_debug(std::uintptr_t base, std::uintptr_t client, bool camera_phase_observed) {
|
||||
const overlay::DebugRequest request{overlay::DebugAction::restore_debug};
|
||||
const auto model = on_client_debug_tick(base, client, false, camera_phase_observed, &request);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "client_source_spawn.h"
|
||||
#include "skater_push.h"
|
||||
#include "client_source_spawn_internal.h"
|
||||
#include "no_bail.h"
|
||||
#include "Engine/Game/Multiplayer/session_tools.h"
|
||||
#include "offboard_flight.h"
|
||||
#include "Engine/Core/Hooks/hooks.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
@@ -8,6 +10,7 @@
|
||||
#include "Engine/Game/Build/20260929/no_bail.h"
|
||||
#include "Engine/Game/Build/20260929/offboard_flight.h"
|
||||
#include "free_flight.h"
|
||||
#include "Engine/Game/Multiplayer/session_competition.h"
|
||||
#include <cmath>
|
||||
|
||||
namespace dingosdk::client_source::detail {
|
||||
@@ -113,6 +116,7 @@ void noclip_apply_velocity(std::uintptr_t core) noexcept {
|
||||
const auto apply_boost = [&](InteractiveDebug::VelocityRequest& boost, std::uint64_t& updates, bool up, bool offboard = false) {
|
||||
if (!boost.valid || boost.core != core) return;
|
||||
try {
|
||||
source_require(session_boosts_allowed(), "Boosts are disabled while competition physics are on.");
|
||||
if (offboard && boost.applied && GetTickCount64() >= boost.expires) {
|
||||
boost.valid = false;
|
||||
state.offboard_boost_core.store(0, std::memory_order_release);
|
||||
@@ -183,6 +187,7 @@ void noclip_apply_velocity(std::uintptr_t core) noexcept {
|
||||
const auto& request = debug.noclip_velocity;
|
||||
if (!debug.noclip || !request.valid || request.core != core) return;
|
||||
try {
|
||||
source_require(session_noclip_allowed(), "Noclip is disabled while competition physics are on.");
|
||||
if (GetTickCount64() >= request.expires) { debug_stop_noclip(debug); debug.status = "Flight input timed out."; return; }
|
||||
const auto bodies = debug_noclip_bodies(state.trial.base, request.client, request.entity);
|
||||
source_require(bodies.core == core, "Skater physics was replaced; flight stopped.");
|
||||
@@ -344,15 +349,63 @@ void trainer_push_speed(std::uintptr_t core) noexcept {
|
||||
}
|
||||
} catch (...) {}
|
||||
}
|
||||
// A plugin's push (skater_push.h), written by the next on-board physics step.
|
||||
struct PushVelocity {
|
||||
std::atomic<bool> pending{};
|
||||
std::uintptr_t client{},entity{},core{};
|
||||
std::array<float,3> velocity{};
|
||||
std::uint64_t expires{};
|
||||
};
|
||||
PushVelocity& push_velocity(){static PushVelocity r;return r;}
|
||||
void apply_push_velocity(std::uintptr_t core) noexcept {
|
||||
auto& request=push_velocity();auto& state=source_state();
|
||||
if(!request.pending.load(std::memory_order_acquire) || state.busy.test_and_set(std::memory_order_acquire))return;
|
||||
SourceBusyScope scope{state.busy};
|
||||
try {
|
||||
const auto wanted=skater_push::peek();
|
||||
// Held with no push: the board stays still; a push is written once it is asked for.
|
||||
const bool hold=skater_push::holding() && request.velocity==std::array<float,3>{};
|
||||
if(!competition_stock_physics() || GetTickCount64()>=request.expires || !wanted || *wanted!=request.velocity) {
|
||||
request.pending.store(false,std::memory_order_release);return;
|
||||
}
|
||||
if(request.core!=core)return;
|
||||
const auto bodies=debug_noclip_bodies(state.trial.base,request.client,request.entity);
|
||||
if(bodies.core!=core || bodies.offboard)return;
|
||||
SourceReader reader;if(reader.value<std::uint8_t>(request.entity,0x7e0)!=0)return;
|
||||
std::array<std::array<float,3>,32> velocities{};std::array<std::uint32_t,32> flags{};
|
||||
for(std::size_t i=0;i<bodies.parts.size();++i) {
|
||||
velocities[i]=reader.value<std::array<float,3>>(bodies.parts[i],0x70);
|
||||
flags[i]=reader.value<std::uint32_t>(bodies.parts[i],0x60);
|
||||
// A push must discard fall energy as well as set absolute horizontal speed.
|
||||
velocities[i]=request.velocity;
|
||||
}
|
||||
reader.verify();
|
||||
for(std::size_t i=0;i<bodies.parts.size();++i) {
|
||||
body_write(bodies.parts[i]+0x70,velocities[i]);
|
||||
if(hold)body_write(bodies.parts[i]+0x90,std::array<float,3>{});
|
||||
body_write(bodies.parts[i]+0x60,flags[i]|(hold?0x28u:8u));
|
||||
}
|
||||
// Hold the validated local board still while the plugin holds it (a
|
||||
// countdown, a pending return). Native teleport must not carry old fall
|
||||
// velocity away from its destination before the pushed speed is applied.
|
||||
if(hold)return;
|
||||
// A queued request is not delivery: bail/teleport physics may still be
|
||||
// busy or have replaced the body. Acknowledge only the actual write.
|
||||
skater_push::accepted(request.velocity);
|
||||
request.pending.store(false,std::memory_order_release);
|
||||
}catch(...){}
|
||||
}
|
||||
void noclip_physics_update(std::uintptr_t core) {
|
||||
const auto original = source_state().velocity_update_original.load(std::memory_order_acquire);
|
||||
if (original) original(core);
|
||||
noclip_apply_velocity(core);
|
||||
trainer_apply_jump_scale(core);
|
||||
trainer_push_speed(core);
|
||||
apply_push_velocity(core);
|
||||
}
|
||||
bool noclip_motion_target(std::uintptr_t rig, std::uintptr_t context,
|
||||
const std::array<float,16>* supplied, std::array<float,16>& target) noexcept {
|
||||
if(competition_stock_physics())return false;
|
||||
SourceLastError error;
|
||||
auto& state = source_state();
|
||||
if (!state.initialized.load(std::memory_order_acquire) || !state.velocity_guard_active.load(std::memory_order_acquire) ||
|
||||
@@ -448,6 +501,26 @@ void noclip_skater_motion(std::uintptr_t rig, std::uintptr_t context,
|
||||
namespace dingosdk {
|
||||
using namespace client_source::detail;
|
||||
|
||||
bool queue_skater_push_velocity(std::uintptr_t client,std::uintptr_t entity,const std::array<float,3>& velocity) noexcept {
|
||||
try {
|
||||
auto& state=source_state();auto& request=push_velocity();
|
||||
if(!competition_stock_physics() || !start_client_noclip_velocity(state.trial.base) ||
|
||||
!skater_push::valid(velocity))return false;
|
||||
if(state.busy.test_and_set(std::memory_order_acquire))return false;
|
||||
SourceBusyScope scope{state.busy};
|
||||
if(request.pending.load(std::memory_order_acquire)) {
|
||||
if(skater_push::holding() && velocity==std::array<float,3>{} &&
|
||||
request.velocity==velocity && request.client==client && request.entity==entity) {
|
||||
request.expires=GetTickCount64()+500;return true;
|
||||
}
|
||||
if(GetTickCount64()<request.expires)return false;
|
||||
request.pending.store(false,std::memory_order_release);
|
||||
}
|
||||
const auto bodies=debug_noclip_bodies(state.trial.base,client,entity);if(bodies.offboard)return false;
|
||||
request.client=client;request.entity=entity;request.core=bodies.core;request.velocity=velocity;request.expires=GetTickCount64()+500;
|
||||
request.pending.store(true,std::memory_order_release);return true;
|
||||
}catch(...){return false;}
|
||||
}
|
||||
bool queue_jump_scale(std::uintptr_t client, std::uintptr_t entity, float factor) noexcept {
|
||||
SourceLastError error;
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,10 @@ namespace dingosdk {
|
||||
void initialize_client_source_spawn(std::uintptr_t base);
|
||||
// Installs the exact camera-reset handler guard; forwarding is unchanged until a Noclip request is queued.
|
||||
bool start_client_noclip_velocity(std::uintptr_t base) noexcept;
|
||||
// A plugin's push (skater_push.h): one horizontal velocity at the next on-board physics step,
|
||||
// in competition physics only. Uses the verified local physics owner and never enables trainer
|
||||
// boosts.
|
||||
bool queue_skater_push_velocity(std::uintptr_t client,std::uintptr_t entity,const std::array<float,3>& velocity) noexcept;
|
||||
// Trainer: at the local skater's next physics update, multiply its upward velocity by
|
||||
// `factor` if it is off the board and rising (a hippy jump that has just started). One request
|
||||
// at a time; it expires after 150 ms.
|
||||
@@ -31,6 +35,9 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli
|
||||
const overlay::FlightInput* flight_input = nullptr);
|
||||
bool restore_client_debug(std::uintptr_t base, std::uintptr_t client, bool camera_phase_observed);
|
||||
bool client_free_camera_active() noexcept;
|
||||
// The last verified mode-1 local view, or zero when native placement handed it
|
||||
// back. Unlike the input-capture lease above, this proves actual selection.
|
||||
std::uintptr_t client_free_camera_view() noexcept;
|
||||
// Native camera lease for the party's Spectate action. Null restores only the
|
||||
// camera acquired by this function; it never changes another debug camera. A positive
|
||||
// `fov` is applied to the spectate camera while it is held; its own comes back after.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "Engine/Game/Build/addresses.h"
|
||||
#include "Engine/Game/Build/20260929/engine.h"
|
||||
#include "Engine/Game/Build/20260929/no_bail.h"
|
||||
#include "Engine/Game/Build/20260929/skater_state.h"
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <intrin.h>
|
||||
@@ -70,7 +71,10 @@ struct Protection {
|
||||
Protection& protection() { static auto* value = new Protection; return *value; }
|
||||
struct StateWatch {
|
||||
std::atomic<std::uintptr_t> selector{}, causes{}; // the watched skater's selector and cause collector
|
||||
std::atomic<std::uintptr_t> core{};
|
||||
std::atomic<std::uint64_t> until{}, changes{}, wipeouts{};
|
||||
std::atomic<std::uint64_t> runouts{};
|
||||
std::atomic<bool> runout{};
|
||||
std::atomic<std::uint32_t> state{}, previous{};
|
||||
std::atomic<std::int64_t> since{}, previous_ticks{}; // performance-counter ticks
|
||||
};
|
||||
@@ -314,6 +318,20 @@ void publish_animation(std::uintptr_t core) {
|
||||
(void)filter_requests(core, &Owner::core);
|
||||
protection().publish_original(core);
|
||||
filter_contact_animation(core);
|
||||
// Sample the game's own failed-landing/runout result at its publication
|
||||
// boundary. It may be true for only one physics frame, between mode ticks.
|
||||
// Counting its rising edge retains that event without forcing a native bail
|
||||
// or treating an ordinary voluntary dismount as a failed landing.
|
||||
LastError error;
|
||||
auto& w=state_watch();
|
||||
if(core==w.core.load(std::memory_order_acquire) && GetTickCount64()<w.until.load(std::memory_order_acquire) &&
|
||||
pointer(core,0x440)==w.selector.load(std::memory_order_acquire)) {
|
||||
const auto animation=pointer(pointer(core,addr::skater_state::trick_state_offset),recovery_animation_output);
|
||||
std::uint8_t runout{};
|
||||
if(animation && read(animation+recovery_animation_flag,runout) && runout<=1 &&
|
||||
w.runout.exchange(runout!=0,std::memory_order_acq_rel)!=(runout!=0) && runout)
|
||||
w.runouts.fetch_add(1,std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
bool compatible(std::uintptr_t base) noexcept {
|
||||
if (base < 0x10000 || base > highest - supported_build::game_image_size) return false;
|
||||
@@ -424,6 +442,17 @@ bool set_teleport_on_board(std::uintptr_t component) noexcept {
|
||||
return true;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
|
||||
}
|
||||
bool prepare_on_board_teleport(std::uintptr_t client, std::uintptr_t entity) noexcept {
|
||||
LastError error;
|
||||
Owner owner;
|
||||
if(!protection().ready.load(std::memory_order_acquire) || !resolve(client,entity,owner))return false;
|
||||
std::uint8_t board{};
|
||||
if(!read(owner.component+0xc0,board))return false;
|
||||
if(board==1)return true;
|
||||
if(!set_teleport_on_board(owner.component))return false;
|
||||
logging::write(logging::Level::info,logging::Channel::skater,"Match countdown: native on-board spawn option prepared.");
|
||||
return true;
|
||||
}
|
||||
void update_board_lock(std::uintptr_t client, std::uintptr_t entity, bool locked) noexcept {
|
||||
LastError error;
|
||||
auto& p = protection();
|
||||
@@ -487,9 +516,12 @@ void watch_physics_state(std::uintptr_t client, std::uintptr_t entity) noexcept
|
||||
auto& w = state_watch();
|
||||
Owner owner;
|
||||
if (!protection().ready.load(std::memory_order_acquire) || !resolve(client, entity, owner)) return;
|
||||
if (w.selector.exchange(owner.selector, std::memory_order_acq_rel) != owner.selector)
|
||||
if (w.selector.exchange(owner.selector, std::memory_order_acq_rel) != owner.selector) {
|
||||
w.state.store(0, std::memory_order_release);
|
||||
w.runout.store(false,std::memory_order_release);
|
||||
}
|
||||
w.causes.store(owner.causes, std::memory_order_release);
|
||||
w.core.store(owner.core, std::memory_order_release);
|
||||
w.until.store(GetTickCount64() + 500, std::memory_order_release);
|
||||
}
|
||||
PhysicsStateWatch watched_physics_state() noexcept {
|
||||
@@ -503,6 +535,26 @@ PhysicsStateWatch watched_physics_state() noexcept {
|
||||
result.previous_seconds = static_cast<float>(static_cast<double>(w.previous_ticks.load(std::memory_order_relaxed)) / frequency);
|
||||
result.changes = w.changes.load(std::memory_order_relaxed);
|
||||
result.wipeouts = w.wipeouts.load(std::memory_order_relaxed);
|
||||
result.runouts = w.runouts.load(std::memory_order_relaxed);
|
||||
if(result.valid) {
|
||||
// Failed-trick animated/simulated ragdolls commonly stay in 504, just
|
||||
// like ordinary walking. The selector's 300 counter alone misses them.
|
||||
// Revalidate the published core/selector pair before reading the native
|
||||
// offboard flag; retain unknown on a transient unreadable owner.
|
||||
const auto core=w.core.load(std::memory_order_acquire);
|
||||
if(pointer(core,0x440)==w.selector.load(std::memory_order_acquire)) {
|
||||
if(result.state!=offboard_physics_state)result.ragdoll_known=true;
|
||||
else {
|
||||
namespace live=addr::skater_state;
|
||||
const auto trick=pointer(core,live::trick_state_offset);
|
||||
const auto offboard=pointer(trick,live::offboard_state_offset);
|
||||
std::uint8_t flag{};
|
||||
if(offboard && read(offboard+live::ragdoll_offset,flag) && flag<=1) {
|
||||
result.ragdoll_known=true;result.ragdoll=flag!=0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
void clear_no_bail_flight() noexcept {
|
||||
|
||||
@@ -15,14 +15,18 @@ void clear_no_bail() noexcept;
|
||||
// Releasing it also leaves the skater's teleport option on the board again, since a turn's
|
||||
// teleport may have set it off (skater component +0xc0) and the SDK's own teleports keep it.
|
||||
void update_board_lock(std::uintptr_t client, std::uintptr_t entity, bool locked) noexcept;
|
||||
// Prepare the local skater's native teleport option after validating its owner.
|
||||
// This only changes how the next teleport spawns; it does not grant No Bail.
|
||||
bool prepare_on_board_teleport(std::uintptr_t client, std::uintptr_t entity) noexcept;
|
||||
// The physics state the local skater's selector last chose, for the trainer (air time, bail
|
||||
// markers). Publish the skater to watch from the client tick; it expires if ticks stop.
|
||||
struct PhysicsStateWatch {
|
||||
bool valid{};
|
||||
bool ragdoll_known{}, ragdoll{}; // native offboard substate, distinct from walking
|
||||
std::uint32_t state{};
|
||||
std::uint32_t previous{}; // the state before this one
|
||||
float previous_seconds{}; // how long that one lasted
|
||||
std::uint64_t changes{}, wipeouts{}; // counted since the process started
|
||||
std::uint64_t changes{}, wipeouts{}, runouts{}; // counted since the process started
|
||||
};
|
||||
void watch_physics_state(std::uintptr_t client, std::uintptr_t entity) noexcept;
|
||||
PhysicsStateWatch watched_physics_state() noexcept;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
|
||||
// A plugin's free camera flown from the pad (placing things from the air): the controller drives
|
||||
// the flight, the bumpers (or Q/E) its height and the triggers stay the plugin's. Any thread.
|
||||
namespace dingosdk::pad_flight {
|
||||
inline std::atomic<bool>& requested_flag() noexcept { static std::atomic<bool> value{}; return value; }
|
||||
inline void request(bool on) noexcept { requested_flag().store(on, std::memory_order_release); }
|
||||
inline bool requested() noexcept { return requested_flag().load(std::memory_order_acquire); }
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
#include <string>
|
||||
|
||||
// skate.'s score HUD as values (ScoringHUDViewModel, read through the game's UI models), what
|
||||
// trick_watch.h turns into tricks. Moved from Domination (PR 279).
|
||||
// trick_watch.h turns into tricks.
|
||||
namespace dingosdk::tricks {
|
||||
struct ScoreHud {
|
||||
bool action{}; // a trick is going on
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
#include "skater_push.h"
|
||||
#include <Windows.h>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <mutex>
|
||||
|
||||
namespace dingosdk::skater_push {
|
||||
namespace {
|
||||
// A queued request is not delivery: bail/teleport physics may still be busy or have replaced the
|
||||
// body, so a push stays pending until the physics step acknowledges it, or two seconds pass.
|
||||
struct Delivery {
|
||||
std::mutex mutex;
|
||||
std::optional<Vec3> pending;
|
||||
std::uint64_t until{};
|
||||
std::atomic<bool> held{};
|
||||
void clear() noexcept { pending.reset(); until = 0; }
|
||||
};
|
||||
Delivery& delivery() { static auto* value = new Delivery; return *value; }
|
||||
} // namespace
|
||||
bool valid(const Vec3& velocity) noexcept {
|
||||
return std::all_of(velocity.begin(), velocity.end(), [](float n) { return std::isfinite(n); }) && velocity[1] == 0.f &&
|
||||
std::hypot(velocity[0], velocity[2]) <= max_speed + .001f;
|
||||
}
|
||||
void request(const Vec3& velocity) noexcept {
|
||||
auto& d = delivery();
|
||||
std::lock_guard lock(d.mutex);
|
||||
d.clear();
|
||||
if (valid(velocity)) { d.pending = velocity; d.until = GetTickCount64() + 2000; }
|
||||
}
|
||||
void wait() noexcept {
|
||||
auto& d = delivery();
|
||||
std::lock_guard lock(d.mutex);
|
||||
const auto now = GetTickCount64();
|
||||
if (d.pending && now < d.until) d.until = now + 2000;
|
||||
}
|
||||
void reset() noexcept {
|
||||
auto& d = delivery();
|
||||
std::lock_guard lock(d.mutex);
|
||||
d.clear();
|
||||
}
|
||||
void hold(bool on) noexcept { delivery().held.store(on, std::memory_order_release); }
|
||||
bool holding() noexcept { return delivery().held.load(std::memory_order_acquire); }
|
||||
bool pending() noexcept {
|
||||
auto& d = delivery();
|
||||
std::lock_guard lock(d.mutex);
|
||||
if (GetTickCount64() >= d.until) d.clear();
|
||||
return d.pending.has_value();
|
||||
}
|
||||
std::optional<Vec3> peek() noexcept {
|
||||
auto& d = delivery();
|
||||
std::lock_guard lock(d.mutex);
|
||||
if (GetTickCount64() >= d.until) d.clear();
|
||||
// A pending push wins over the hold: "hold still, then push" needs no release in between.
|
||||
if (d.pending) return d.pending;
|
||||
return holding() ? std::optional(Vec3{}) : std::nullopt;
|
||||
}
|
||||
void accepted(const Vec3& velocity) noexcept {
|
||||
auto& d = delivery();
|
||||
std::lock_guard lock(d.mutex);
|
||||
if (d.pending && *d.pending == velocity) d.clear();
|
||||
}
|
||||
} // namespace dingosdk::skater_push
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <optional>
|
||||
|
||||
// A push for the local skater, for plugins (reskate.skater.push / reskate.skater.hold): a
|
||||
// horizontal velocity the next on-board physics step writes (client_noclip.cpp), within two
|
||||
// seconds of the request, and a hold that keeps the board still (a countdown, a checkpoint
|
||||
// return). Any thread.
|
||||
namespace dingosdk::skater_push {
|
||||
using Vec3 = std::array<float, 3>;
|
||||
inline constexpr float max_speed = 120.f;
|
||||
// Finite, horizontal (y == 0) and at most max_speed.
|
||||
bool valid(const Vec3& velocity) noexcept;
|
||||
// Replaces any pending push; an invalid velocity only cancels it.
|
||||
void request(const Vec3& velocity) noexcept;
|
||||
// The skater is still getting back on the board: the pending push waits two seconds more.
|
||||
void wait() noexcept;
|
||||
void reset() noexcept;
|
||||
void hold(bool on) noexcept;
|
||||
bool holding() noexcept;
|
||||
// A push is waiting for its physics step.
|
||||
bool pending() noexcept;
|
||||
// The physics step: the velocity to write now (the pending push, else zero while held), or nothing.
|
||||
std::optional<Vec3> peek() noexcept;
|
||||
// The physics step wrote it.
|
||||
void accepted(const Vec3& velocity) noexcept;
|
||||
} // namespace dingosdk::skater_push
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
// The skater's animation skeleton, Animation/Dingo/AnimBase_Default_Skeleton (AntSkeletonAsset),
|
||||
// as read from the game's data: each of its 395 joints' parent (-1 for the root). Every parent
|
||||
// comes before its children, so one pass in index order composes the whole pose.
|
||||
namespace dingosdk::bonecam {
|
||||
inline constexpr std::size_t skater_joint_count = 395;
|
||||
inline constexpr std::array<std::int16_t, skater_joint_count> skater_joint_parent{
|
||||
-1, 0, 1, 1, 3, 1, 5, 1, 7, 8, 9, 10, 10, 12, 12, 9, 9, 16, 9, 9,
|
||||
9, 20, 20, 20, 9, 24, 24, 8, 27, 27, 27, 27, 27, 27, 8, 8, 8, 8, 37, 37,
|
||||
37, 37, 7, 42, 43, 44, 45, 46, 47, 48, 49, 49, 51, 52, 49, 54, 55, 56, 49, 58,
|
||||
59, 60, 49, 62, 63, 64, 49, 66, 67, 68, 48, 48, 48, 48, 73, 74, 74, 48, 77, 77,
|
||||
77, 48, 81, 81, 47, 84, 84, 84, 84, 88, 88, 88, 88, 88, 47, 47, 47, 46, 46, 46,
|
||||
46, 45, 101, 102, 103, 103, 103, 103, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107,
|
||||
107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107,
|
||||
107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107,
|
||||
107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107,
|
||||
107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107,
|
||||
107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107,
|
||||
107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107,
|
||||
107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 107, 255, 256, 257, 258,
|
||||
259, 260, 261, 262, 263, 263, 263, 266, 267, 267, 267, 102, 102, 101, 101, 45, 275, 276, 277, 278,
|
||||
279, 280, 281, 278, 278, 284, 285, 286, 278, 288, 289, 290, 278, 292, 293, 294, 278, 296, 297, 277,
|
||||
277, 277, 277, 302, 303, 303, 277, 306, 306, 306, 277, 310, 310, 276, 313, 313, 313, 313, 317, 317,
|
||||
317, 317, 317, 276, 276, 276, 275, 275, 275, 275, 45, 45, 45, 45, 45, 45, 45, 45, 45, 45,
|
||||
45, 7, 341, 342, 343, 343, 345, 345, 342, 342, 349, 342, 342, 342, 353, 353, 353, 342, 357, 357,
|
||||
341, 360, 360, 360, 360, 360, 360, 341, 341, 341, 341, 370, 370, 370, 370, 1, 375, 375, 375, 375,
|
||||
1, 380, 381, 382, 382, 382, 381, 386, 386, 386, 380, 380, 380, 380, 380,
|
||||
};
|
||||
// The joints the Bone Cam uses, by name in the asset.
|
||||
namespace joint {
|
||||
inline constexpr std::uint16_t hips = 7, right_up_leg = 8, right_leg = 9, right_foot = 10, right_toe = 11, spine2 = 44,
|
||||
right_arm = 47, right_fore_arm = 48, right_hand = 49, right_middle = 54, neck = 101, neck1 = 102, head = 103,
|
||||
left_arm = 276, left_fore_arm = 277, left_hand = 278, left_middle = 284, left_up_leg = 341, left_leg = 342,
|
||||
left_foot = 343, left_toe = 344;
|
||||
}
|
||||
} // namespace dingosdk::bonecam
|
||||
@@ -0,0 +1,126 @@
|
||||
#include "trick_feed.h"
|
||||
#include "Extension/UI/own_ui_writes.h"
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
#include <Windows.h>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <format>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <string_view>
|
||||
|
||||
// SmokeyDawg58's reading of the trick list's writes, with the asking moved here.
|
||||
namespace dingosdk::tricks::feed {
|
||||
namespace {
|
||||
constexpr std::uint32_t trick_name_member = 0x0002, trick_side_member = 0x009c, trick_landing_member = 0x0034;
|
||||
constexpr std::uint32_t trick_body_spin_member = 0x00bc, trick_board_spin_member = 0x00ac, trick_stance_member = 0x004e;
|
||||
constexpr std::uint32_t trick_late_member = 0x007c, trick_darkflip_member = 0x010d, trick_darkcatch_member = 0x00fd;
|
||||
constexpr std::uintptr_t ui_string_type_rva = 0x765dcc0; // Skate.exe: the UI model's string type
|
||||
struct Feed {
|
||||
std::atomic<bool> on{};
|
||||
std::mutex mutex;
|
||||
std::vector<Note> notes;
|
||||
std::set<std::string> owners;
|
||||
};
|
||||
Feed& feed() {
|
||||
static auto* value = new Feed;
|
||||
return *value;
|
||||
}
|
||||
// Readable text at `address`: ASCII, or UTF-16 with every other byte zero; empty when not.
|
||||
std::string text_at(std::uintptr_t address) noexcept {
|
||||
std::array<unsigned char, 96> raw{};
|
||||
if (!memory::read_bytes(address, raw.data(), raw.size())) return {};
|
||||
const auto printable = [](unsigned char c) { return c >= 0x20 && c < 0x7f; };
|
||||
std::string text;
|
||||
for (std::size_t i = 0; i < raw.size() && raw[i]; ++i) {
|
||||
if (!printable(raw[i])) return {};
|
||||
text += static_cast<char>(raw[i]);
|
||||
}
|
||||
if (text.size() >= 3) return text;
|
||||
text.clear();
|
||||
for (std::size_t i = 0; i + 1 < raw.size() && (raw[i] || raw[i + 1]); i += 2) {
|
||||
if (raw[i + 1] || !printable(raw[i])) return {};
|
||||
text += static_cast<char>(raw[i]);
|
||||
}
|
||||
return text.size() >= 3 ? text : std::string{};
|
||||
}
|
||||
// A trick list text that reads as a name: letters with digits, spaces and a little punctuation. A
|
||||
// pointer read as text ("P#a=") does not.
|
||||
bool readable_name(std::string_view text) noexcept {
|
||||
bool letter = false;
|
||||
for (const char c : text) {
|
||||
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) letter = true;
|
||||
else if (!(c >= '0' && c <= '9') && c != ' ' && c != '-' && c != '\'' && c != '.' && c != '&' && c != '/' && c != '!' && c != '_')
|
||||
return false;
|
||||
}
|
||||
return letter;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void ask(const std::string& owner, bool on) {
|
||||
auto& f = feed();
|
||||
std::lock_guard lock(f.mutex);
|
||||
if (on) f.owners.insert(owner);
|
||||
else f.owners.erase(owner);
|
||||
f.on.store(!f.owners.empty(), std::memory_order_release);
|
||||
if (f.owners.empty()) f.notes.clear();
|
||||
}
|
||||
bool wanted() noexcept { return feed().on.load(std::memory_order_acquire); }
|
||||
std::vector<Note> take() {
|
||||
auto& f = feed();
|
||||
std::lock_guard lock(f.mutex);
|
||||
return std::exchange(f.notes, {});
|
||||
}
|
||||
|
||||
void tap(std::uint64_t handle, std::uintptr_t type, const void* value) noexcept {
|
||||
auto& f = feed();
|
||||
if (!f.on.load(std::memory_order_acquire) || ui::writing_own_ui) return;
|
||||
const auto member = static_cast<std::uint32_t>(handle & 0xffff);
|
||||
if (member != trick_name_member && member != trick_side_member && member != trick_landing_member && member != trick_body_spin_member &&
|
||||
member != trick_board_spin_member && member != trick_stance_member && member != trick_late_member && member != trick_darkflip_member &&
|
||||
member != trick_darkcatch_member)
|
||||
return;
|
||||
static const auto string_type = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr)) + ui_string_type_rva;
|
||||
if (member == trick_name_member && type != string_type) return;
|
||||
try {
|
||||
std::uint64_t first{};
|
||||
if (!memory::peek_bytes(reinterpret_cast<std::uintptr_t>(value), &first, sizeof first)) return;
|
||||
auto text = text_at(static_cast<std::uintptr_t>(first));
|
||||
// A name skate. gives as a short code rather than text (one grind wrote 0x42532030): the code
|
||||
// itself, the same on every player's game, so it can still be set and copied. A pointer whose
|
||||
// text could not be read is not one: it differs from game to game. Some names sit one pointer
|
||||
// further in (dark flips): a value that does not read as a name is followed once more.
|
||||
if (member == trick_name_member && !text.empty() && !readable_name(text)) {
|
||||
std::uint64_t inner{};
|
||||
text = memory::read(static_cast<std::uintptr_t>(first), inner) ? text_at(static_cast<std::uintptr_t>(inner)) : std::string();
|
||||
if (!readable_name(text)) return;
|
||||
}
|
||||
if (text.empty() && member == trick_name_member && first && first < 0x100000000ull) text = std::format("Trick #{:x}", first);
|
||||
if (text.empty()) return;
|
||||
char kind = 'n';
|
||||
if (member == trick_side_member) {
|
||||
if (!text.starts_with("ID_TRICK_")) return;
|
||||
kind = 's';
|
||||
} else if (member == trick_landing_member) {
|
||||
if (!text.starts_with("ID_TRICK_LANDING")) return;
|
||||
kind = 'l';
|
||||
} else if (member == trick_body_spin_member) {
|
||||
if (text.find_first_not_of("0123456789") != std::string::npos) return;
|
||||
kind = 'b';
|
||||
} else if (member == trick_board_spin_member) {
|
||||
// The board's spin ("360") or how many times it flipped ("Double", "Triple").
|
||||
if (text.starts_with("ID_") || text.size() > 16) return;
|
||||
kind = 'r';
|
||||
} else if (member == trick_stance_member) {
|
||||
if (!text.starts_with("ID_STANCE_")) return;
|
||||
kind = 'k';
|
||||
} else if (member == trick_late_member || member == trick_darkflip_member || member == trick_darkcatch_member) {
|
||||
if (member == trick_late_member ? text != "Late" : !text.starts_with("ID_TRICK_DARK")) return;
|
||||
text = member == trick_late_member ? "Late" : member == trick_darkflip_member ? "Darkflip" : "Dark Catch";
|
||||
kind = 'm';
|
||||
}
|
||||
std::lock_guard lock(f.mutex);
|
||||
if (f.notes.size() < 256) f.notes.push_back({handle >> 16, kind, std::move(text)});
|
||||
} catch (...) {}
|
||||
}
|
||||
} // namespace dingosdk::tricks::feed
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// skate.'s score HUD lists each trick as it is done, one entry of ScoringHUDViewModel's trick list
|
||||
// per trick, written through the UI model's write of one value (hud_corner.cpp's hook). This is
|
||||
// SmokeyDawg58's reading of it (found with his `mode tricklog`): an entry's
|
||||
// name, side, landing, body and board spin, stance and modifiers, for plugins (reskate.tricks.feed).
|
||||
namespace dingosdk::tricks::feed {
|
||||
struct Note {
|
||||
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;
|
||||
};
|
||||
// On while any owner asks (the model write hook is kept on with it).
|
||||
void ask(const std::string& owner, bool on);
|
||||
bool wanted() noexcept;
|
||||
std::vector<Note> take();
|
||||
// From the model write hook, on whichever thread the game writes from.
|
||||
void tap(std::uint64_t handle, std::uintptr_t type, const void* value) noexcept;
|
||||
} // namespace dingosdk::tricks::feed
|
||||
@@ -0,0 +1,166 @@
|
||||
#include "trick_gestures.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Resource/ebx_document.h"
|
||||
#include "Engine/Vfs/game_bundles.h"
|
||||
#include <Windows.h>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace dingosdk::tricks {
|
||||
namespace {
|
||||
namespace ebx = frostbite::ebx;
|
||||
// Game build 20260929: the core level carries every gesture.
|
||||
constexpr std::string_view gesture_toc = "Win32/levels/game/bam_levelroot/bam_levelroot.toc";
|
||||
constexpr std::string_view gesture_bundle = "win32/levels/game/bam_levelroot/bam_levelroot";
|
||||
constexpr std::string_view gesture_prefix = "gameplay/input/gestures/fliptricks/gesture_fliptrick_";
|
||||
|
||||
using Gestures = std::map<std::string, StickPath, std::less<>>;
|
||||
struct Loader {
|
||||
std::once_flag started;
|
||||
std::atomic<std::shared_ptr<const Gestures>> gestures;
|
||||
};
|
||||
Loader &loader() {
|
||||
static auto *value = new Loader;
|
||||
return *value;
|
||||
}
|
||||
|
||||
std::string lower(std::string_view text) {
|
||||
std::string out;
|
||||
for (const char c : text) out += static_cast<char>(c >= 'A' && c <= 'Z' ? c - 'A' + 'a' : c);
|
||||
return out;
|
||||
}
|
||||
double number(const ebx::Value &value) {
|
||||
return std::visit([](const auto &v) -> double {
|
||||
using T = std::decay_t<decltype(v)>;
|
||||
if constexpr (std::is_same_v<T, std::int64_t> || std::is_same_v<T, std::uint64_t> || std::is_same_v<T, double>) return static_cast<double>(v);
|
||||
else if constexpr (std::is_same_v<T, bool>) return v ? 1.0 : 0.0;
|
||||
else return 0.0;
|
||||
}, value.data);
|
||||
}
|
||||
double field(const ebx::Object &object, std::string_view name) {
|
||||
const auto *found = object.find(name);
|
||||
return found ? number(found->value) : 0.0;
|
||||
}
|
||||
const ebx::Value::Array *array(const ebx::Object &object, std::string_view name) {
|
||||
const auto *found = object.find(name);
|
||||
return found ? std::get_if<ebx::Value::Array>(&found->value.data) : nullptr;
|
||||
}
|
||||
const ebx::Object *object_of(const ebx::Value &value) {
|
||||
const auto *pointer = std::get_if<std::shared_ptr<ebx::Object>>(&value.data);
|
||||
return pointer && *pointer ? pointer->get() : nullptr;
|
||||
}
|
||||
// A gesture's path: its first pattern of points (Mode 0), or else its first one of arcs (Mode 1),
|
||||
// each arc's middle at its middle reach. Angle 0 is the stick pulled back, 90 to the left.
|
||||
StickPath path_of(const ebx::Object &gesture) {
|
||||
const auto *patterns = array(gesture, "Patterns");
|
||||
if (!patterns) return {};
|
||||
StickPath arcs_path;
|
||||
for (const auto &item : *patterns) {
|
||||
const auto *pattern = object_of(item);
|
||||
if (!pattern) continue;
|
||||
if (field(*pattern, "Mode") == 0) {
|
||||
StickPath path;
|
||||
if (const auto *points = array(*pattern, "Points"))
|
||||
for (const auto &p : *points)
|
||||
if (const auto *point = object_of(p)) path.push_back({static_cast<float>(field(*point, "x")), static_cast<float>(field(*point, "y"))});
|
||||
if (path.size() >= 2) return path;
|
||||
} else if (arcs_path.empty()) {
|
||||
if (const auto *arcs = array(*pattern, "Arcs"))
|
||||
for (const auto &a : *arcs)
|
||||
if (const auto *arc = object_of(a)) {
|
||||
const double start = field(*arc, "StartAngle"), end = field(*arc, "EndAngle");
|
||||
const double sweep = std::fmod(end - start + 360.0, 360.0);
|
||||
const double angle = (start + sweep / 2.0) * 3.14159265358979 / 180.0;
|
||||
const double reach = std::clamp((field(*arc, "MinMagnitude") + field(*arc, "MaxMagnitude")) / 2.0, 0.0, 1.0);
|
||||
arcs_path.push_back({static_cast<float>(std::sin(angle) * reach), static_cast<float>(std::cos(angle) * reach)});
|
||||
}
|
||||
}
|
||||
}
|
||||
return arcs_path.size() >= 2 ? arcs_path : StickPath{};
|
||||
}
|
||||
std::filesystem::path game_folder() {
|
||||
std::vector<wchar_t> path(32768);
|
||||
const auto length = GetModuleFileNameW(nullptr, path.data(), static_cast<DWORD>(path.size()));
|
||||
if (!length || length >= path.size()) throw std::runtime_error("the game's folder is unknown");
|
||||
return std::filesystem::path(std::wstring(path.data(), length)).parent_path();
|
||||
}
|
||||
Gestures read_gestures(const std::filesystem::path &game_root) {
|
||||
const vfs::GameData data(game_root);
|
||||
const auto toc = data.read_toc(gesture_toc);
|
||||
const auto bundle = data.read_bundle(toc, gesture_bundle);
|
||||
if (!bundle) throw std::runtime_error("the gestures' bundle is missing");
|
||||
Gestures gestures;
|
||||
for (std::size_t i = 0; i < bundle->manifest.ebx.size(); ++i) {
|
||||
const auto name = lower(bundle->manifest.ebx[i].name);
|
||||
if (!name.starts_with(gesture_prefix)) continue;
|
||||
const auto key = name.substr(gesture_prefix.size());
|
||||
// The variants out of a grind or an underflip are the same flick done from elsewhere.
|
||||
if (key.find("fromgrind") != std::string::npos || key.find("underflip") != std::string::npos) continue;
|
||||
try {
|
||||
const auto *payload = bundle->payload(frostbite::AssetKind::ebx, i);
|
||||
if (!payload) continue;
|
||||
const auto document = ebx::read_document(data.read(*payload));
|
||||
if (const auto *root = document.root(); root && root->object)
|
||||
if (auto path = path_of(*root->object); !path.empty()) gestures[key] = std::move(path);
|
||||
} catch (...) {}
|
||||
}
|
||||
if (gestures.empty()) throw std::runtime_error("no flip trick gestures were found");
|
||||
return gestures;
|
||||
}
|
||||
|
||||
// skate.'s name for a trick, as the gestures' keys spell it: "Nollie Kickflip" is n_kickflip,
|
||||
// "FS 360 Pop Shove-it" fs360popshuvit.
|
||||
std::string gesture_key(std::string_view trick) {
|
||||
std::string text;
|
||||
for (const char c : lower(trick))
|
||||
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) text += c;
|
||||
for (const auto &[from, to] : {std::pair{"frontside", "fs"}, std::pair{"backside", ""}, std::pair{"shoveit", "shuvit"},
|
||||
std::pair{"shuveit", "shuvit"}}) {
|
||||
for (auto at = text.find(from); at != std::string::npos; at = text.find(from)) text.replace(at, std::string_view(from).size(), to);
|
||||
}
|
||||
if (text.starts_with("bs")) text.erase(0, 2);
|
||||
for (const std::string_view stance : {"fakie", "switch"})
|
||||
if (text.starts_with(stance) && text.size() > stance.size()) text.erase(0, stance.size());
|
||||
if (text.starts_with("nollie") && text.size() > 6) text = "n_" + text.substr(6);
|
||||
return text;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void prepare_trick_gestures() noexcept {
|
||||
try {
|
||||
std::call_once(loader().started, [] {
|
||||
std::thread([] {
|
||||
const auto started = GetTickCount64();
|
||||
try {
|
||||
auto read = std::make_shared<const Gestures>(read_gestures(game_folder()));
|
||||
logging::log(logging::Level::info, logging::Channel::runtime, "Trick gestures: read {} flip trick gestures in {} ms.",
|
||||
read->size(), GetTickCount64() - started);
|
||||
loader().gestures.store(std::move(read));
|
||||
} catch (const std::exception &failure) {
|
||||
logging::log(logging::Level::warning, logging::Channel::runtime, "Trick gestures: none shown: {}.", failure.what());
|
||||
}
|
||||
}).detach();
|
||||
});
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
std::size_t load_trick_gestures(const std::filesystem::path &game_root) {
|
||||
auto read = std::make_shared<const Gestures>(read_gestures(game_root));
|
||||
const auto count = read->size();
|
||||
loader().gestures.store(std::move(read));
|
||||
return count;
|
||||
}
|
||||
|
||||
StickPath trick_gesture(std::string_view trick) {
|
||||
const auto gestures = loader().gestures.load();
|
||||
if (!gestures) return {};
|
||||
const auto found = gestures->find(gesture_key(trick));
|
||||
return found != gestures->end() ? found->second : StickPath{};
|
||||
}
|
||||
} // namespace dingosdk::tricks
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <filesystem>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// The flick-it gesture behind each flip trick, read from the installed game (its
|
||||
// DelMarGameplayGesture assets, gameplay/input/gestures/fliptricks/), so a plugin can draw the
|
||||
// stick diagram for a trick the way skate.'s own S.K.A.T.E. does. Nothing of the game's
|
||||
// is shipped: it is read once, in the background, the first time it is asked for.
|
||||
namespace dingosdk::tricks {
|
||||
// A stick path in the gesture's own space: x to the left, y down (pulled back), the stick's gate
|
||||
// the unit circle; a regular stance (mirror x for goofy).
|
||||
using StickPath = std::vector<std::array<float, 2>>;
|
||||
// Starts the reading (once); nothing until it is done.
|
||||
void prepare_trick_gestures() noexcept;
|
||||
// Reads them now from a game folder (the one with Skate.exe), for tests; how many were found.
|
||||
// Throws when they cannot be read.
|
||||
std::size_t load_trick_gestures(const std::filesystem::path &game_root);
|
||||
// The path for one trick as skate. names it ("Kickflip", "Nollie Heelflip", "360 Pop Shove-it");
|
||||
// empty for a trick with no flick (grabs, grinds, manuals) or one not known.
|
||||
StickPath trick_gesture(std::string_view trick);
|
||||
} // namespace dingosdk::tricks
|
||||
@@ -1,8 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
// When a trick lands and what it was worth, from the score HUD's values each tick (score_hud.h).
|
||||
// Apart from the game, so its rules are tested alone. Moved from Domination (PR 279), which adds
|
||||
// its spots on top: the start event is where a caller records where a trick began.
|
||||
// Apart from the game, so its rules are tested alone. The start event is where a caller records
|
||||
// where a trick began.
|
||||
//
|
||||
// Measured in play (a recording of the score HUD's values in a play session, 2026-10-14):
|
||||
// ActionActive is on while a trick goes on; LineScore rises one tick after it goes off. LineScore
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Plugin throwdown modes without the game: the registry's checks and news, and the
|
||||
// pure helpers the native setup page uses.
|
||||
// Plugin throwdown modes without the game: the registry's checks, and the pure helpers the native
|
||||
// setup page and flag cursor use.
|
||||
#include "Extension/Throwdowns/custom_modes.h"
|
||||
#include "Extension/Throwdowns/native_flag_cursor_height.h"
|
||||
#include "Extension/UI/NativeMenu/native_menu_presenters.h"
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
@@ -13,61 +14,43 @@ namespace {
|
||||
using namespace dingosdk::multiplayer;
|
||||
void check(bool value, const std::string& message) { if (!value) throw std::runtime_error(message); }
|
||||
|
||||
custom_throwdowns::Mode one_up() {
|
||||
custom_throwdowns::Mode duel() {
|
||||
custom_throwdowns::Mode mode;
|
||||
mode.plugin = "smokey";
|
||||
mode.id = "oneup";
|
||||
mode.name = "1-UP";
|
||||
mode.plugin = "demo";
|
||||
mode.id = "duel";
|
||||
mode.name = "Duel";
|
||||
mode.description = "Beat the last total.";
|
||||
mode.players = 9; // more than max: clamped
|
||||
mode.timer = custom_throwdowns::Row{custom_throwdowns::Row::Kind::slider, "timer", "Turn", 10, 120, 10, 20};
|
||||
mode.rows.push_back({custom_throwdowns::Row::Kind::choice, "tricks", "Tricks", 0, 0, 1, 5, {"All", "Flips"}});
|
||||
mode.rows.push_back({custom_throwdowns::Row::Kind::button, "area", "Area", 0, 0, 1, 0, {}, "Set area"});
|
||||
return mode;
|
||||
}
|
||||
|
||||
void modes_are_checked_and_listed() {
|
||||
check(custom_throwdowns::set_mode(one_up()).empty(), "1-UP registers");
|
||||
auto bad = one_up();
|
||||
const auto before = custom_throwdowns::revision();
|
||||
check(custom_throwdowns::set_mode(duel()).empty(), "the mode registers");
|
||||
auto bad = duel();
|
||||
bad.id = "a/b";
|
||||
check(!custom_throwdowns::set_mode(bad).empty(), "an id with / is refused");
|
||||
bad = one_up();
|
||||
bad.rows.push_back({custom_throwdowns::Row::Kind::slider, "players", "Players", 1, 4});
|
||||
check(!custom_throwdowns::set_mode(bad).empty(), "a row may not be called players");
|
||||
bad = one_up();
|
||||
bad.rows.push_back({custom_throwdowns::Row::Kind::slider, "huge", "Huge", 0, 100000, 1});
|
||||
check(!custom_throwdowns::set_mode(bad).empty(), "a slider has at most 600 steps");
|
||||
const auto mode = custom_throwdowns::find("smokey/oneup");
|
||||
check(mode && mode->players == 6 && mode->rows[0].value == 1, "players and a choice are clamped");
|
||||
const auto settings = custom_throwdowns::settings("smokey/oneup");
|
||||
check(settings.at("players") == 6 && settings.at("timer") == 20 && settings.at("tricks") == 1 && !settings.contains("area"),
|
||||
"settings start at the defaults, without buttons");
|
||||
}
|
||||
|
||||
void settings_and_news_reach_the_plugin() {
|
||||
const auto before = custom_throwdowns::revision();
|
||||
custom_throwdowns::set_setting("smokey/oneup", "players", 3);
|
||||
custom_throwdowns::set_setting("smokey/oneup", "players", 3); // unchanged: no news
|
||||
custom_throwdowns::News placed;
|
||||
placed.action = "placed";
|
||||
placed.at = std::array<float, 3>{1, 2, 3};
|
||||
placed.heading = 90;
|
||||
custom_throwdowns::post("smokey/oneup", placed);
|
||||
const auto news = custom_throwdowns::take("smokey");
|
||||
check(news.size() == 2, "one setting and the flag");
|
||||
check(news[0].mode == "oneup" && news[0].action == "setting" && news[0].row == "players" && news[0].value == 3, "the setting");
|
||||
check(news[1].action == "placed" && news[1].at && (*news[1].at)[2] == 3 && news[1].settings.at("players") == 3,
|
||||
"the flag, with the settings chosen");
|
||||
check(custom_throwdowns::take("smokey").empty() && custom_throwdowns::take("other").empty(), "taken once, by its own plugin");
|
||||
// An update keeps what was chosen; a reset goes back to the defaults.
|
||||
auto again = one_up();
|
||||
bad = duel();
|
||||
bad.replaces = "bowling";
|
||||
check(!custom_throwdowns::set_mode(bad).empty(), "replaces names a stock throwdown");
|
||||
bad = duel();
|
||||
bad.card = "bowling";
|
||||
check(!custom_throwdowns::set_mode(bad).empty(), "a card is a stock one");
|
||||
bad = duel();
|
||||
bad.cursor_height = -1;
|
||||
check(!custom_throwdowns::set_mode(bad).empty(), "a cursor height is not negative");
|
||||
bad.cursor_height = 200000;
|
||||
check(!custom_throwdowns::set_mode(bad).empty(), "a cursor height is at most 100000 m");
|
||||
const auto mode = custom_throwdowns::find("demo/duel");
|
||||
check(mode && mode->players == 6 && mode->card == "spot" && mode->cursor_height == 0, "players are clamped, the card is Spot Battle's");
|
||||
// An update replaces it, and its flag may stand on anything now.
|
||||
auto again = duel();
|
||||
again.description = "New words.";
|
||||
again.cursor_height = 99999;
|
||||
check(custom_throwdowns::set_mode(again).empty() && custom_throwdowns::revision() != before, "an update");
|
||||
check(custom_throwdowns::settings("smokey/oneup").at("players") == 3, "keeps the players chosen");
|
||||
custom_throwdowns::reset_settings("smokey/oneup");
|
||||
check(custom_throwdowns::settings("smokey/oneup").at("players") == 6, "a reset starts over");
|
||||
custom_throwdowns::remove_plugin("smokey");
|
||||
check(custom_throwdowns::modes().empty() && !custom_throwdowns::find("smokey/oneup"), "a plugin's modes go with it");
|
||||
check(custom_throwdowns::find("demo/duel")->cursor_height == 99999 && custom_throwdowns::modes().size() == 1, "one mode, updated");
|
||||
custom_throwdowns::remove_plugin("demo");
|
||||
check(custom_throwdowns::modes().empty() && !custom_throwdowns::find("demo/duel"), "a plugin's modes go with it");
|
||||
}
|
||||
|
||||
struct Presenter { std::uintptr_t blueprint{}; struct { std::uintptr_t record{}; std::uint64_t handle{}; } data; };
|
||||
@@ -89,13 +72,24 @@ void setup_page_helpers() {
|
||||
check(next && (*next)[0] == std::byte{7}, "a new presenter keeps the live focus bytes");
|
||||
check(menu_data::remount_presenters<Presenter>(live, std::span(saved).first(32), 32, 8)->size() == 32, "another count takes the saved rows");
|
||||
}
|
||||
// The flag cursor's ceiling: raised to the registration's height, restored only when still ours.
|
||||
void cursor_height_lease() {
|
||||
NativeFlagCursorHeight lease;
|
||||
check(!lease.apply(5.f, 0.f), "no height asked: nothing changes");
|
||||
check(lease.apply(5.f, 99999.f) == 99999.f, "raised to the asked height");
|
||||
check(!lease.apply(99999.f, 99999.f), "already raised");
|
||||
check(lease.apply(99999.f, 300.f) == 300.f, "another height replaces ours");
|
||||
check(lease.restore(300.f) == 5.f, "the native value comes back");
|
||||
check(!lease.apply(400.f, 300.f), "a native ceiling above the asked one stays");
|
||||
check(lease.apply(5.f, 50.f) == 50.f && !lease.restore(7.f), "a native change since is not undone");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
try {
|
||||
modes_are_checked_and_listed();
|
||||
settings_and_news_reach_the_plugin();
|
||||
setup_page_helpers();
|
||||
cursor_height_lease();
|
||||
std::cout << "custom throwdown checks passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
// The throwdown relay's state machine without the game: the natives it drives are
|
||||
// recorded here, and messages are exchanged by hand as other players would send them.
|
||||
#include "Extension/Throwdowns/throwdown_relay.h"
|
||||
#include "Extension/Throwdowns/custom_modes.h"
|
||||
#include "Extension/Throwdowns/throwdown_lab.h"
|
||||
#include "Extension/Throwdowns/throwdown_wire.h"
|
||||
#include "Extension/Throwdowns/native_throwdowns.h"
|
||||
#include "Extension/Throwdowns/grouped_number.h"
|
||||
#include "Extension/Multiplayer/Hud/native_party.h"
|
||||
#include "Extension/Settings/named_settings.h"
|
||||
#include "Extension/Throwdowns/throwdown_kit.h"
|
||||
#include "Extension/Throwdowns/native_flag_session.h"
|
||||
#include <Windows.h>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace {
|
||||
@@ -27,6 +32,30 @@ std::map<std::string, std::string> settings_changed;
|
||||
bool leaderboard_live = true;
|
||||
bool types_ready = true; // stands in for the event types of the level being found
|
||||
std::uint64_t last_token{};
|
||||
// The kit's store and native flag, as the relay sees them: the plugins' documents, the events
|
||||
// it posts and the flags it hands to the native flag registration.
|
||||
std::vector<dingosdk::throwdowns::kit::Documents> kit_documents;
|
||||
std::vector<dingosdk::throwdowns::kit::Event> kit_events;
|
||||
struct Adopted { std::string mode; std::uint32_t game{}, mmid{}; unsigned capacity{}; };
|
||||
std::vector<Adopted> adopted;
|
||||
bool accept_adopt=true;
|
||||
std::set<std::uint32_t> custom_queues;
|
||||
}
|
||||
namespace dingosdk::throwdowns::kit {
|
||||
std::vector<Documents> documents() {return kit_documents;}
|
||||
void post_to(std::string_view plugin,std::string action,std::string mode,std::vector<std::string> args) {
|
||||
kit_events.push_back({std::string(plugin),std::move(action),std::move(mode),std::move(args)});
|
||||
}
|
||||
namespace flag {
|
||||
bool adopt(std::string_view mode,std::uint32_t game,std::uint32_t mmid,const Vec3&,float,unsigned,unsigned capacity) {
|
||||
adopted.push_back({std::string(mode),game,mmid,capacity});
|
||||
if(accept_adopt)for(auto& d:kit_documents)d.flags.clear(); // the plugin stops offering it
|
||||
return accept_adopt;
|
||||
}
|
||||
}
|
||||
}
|
||||
namespace dingosdk::multiplayer::native_flag {
|
||||
bool known_queue(std::uint32_t mmid) noexcept {return custom_queues.contains(mmid);}
|
||||
}
|
||||
|
||||
namespace dingosdk {
|
||||
@@ -40,9 +69,14 @@ std::uint32_t local_native_player_id() noexcept { return 2; }
|
||||
std::uint64_t native_party_player_info(std::uintptr_t, std::size_t) noexcept { return 0; }
|
||||
void spectate_party_member(std::uint64_t) noexcept {}
|
||||
bool prepare_throwdown_injection() noexcept { calls.push_back({"prepare"}); return types_ready; }
|
||||
std::string last_instance;
|
||||
std::uint32_t last_capacity{};
|
||||
bool queue_throwdown_spawn(std::uint64_t token, std::uint32_t host, const std::string &series,
|
||||
const std::vector<std::uint8_t> &, const std::vector<std::uint8_t> &) noexcept {
|
||||
const std::vector<std::uint8_t> &, const std::vector<std::uint8_t> &,
|
||||
const std::string &instance, std::uint32_t capacity) noexcept {
|
||||
last_token = token;
|
||||
last_instance = instance;
|
||||
last_capacity = capacity;
|
||||
calls.push_back({"spawn", series, host, 0, 0, token});
|
||||
return true;
|
||||
}
|
||||
@@ -51,6 +85,10 @@ bool queue_throwdown_host(std::uint64_t token, const std::string &series, const
|
||||
calls.push_back({"host", series, most_players, 0, static_cast<std::int32_t>(at[0]), token});
|
||||
return true;
|
||||
}
|
||||
bool queue_custom_throwdown_flag(std::uint64_t token,std::uint32_t host,const std::string& key,
|
||||
const std::array<float,3>& at,float yaw) noexcept {
|
||||
last_token=token;calls.push_back({"custom flag",key,host,static_cast<std::uint32_t>(at[0]),static_cast<std::int32_t>(yaw),token});return true;
|
||||
}
|
||||
bool queue_throwdown_add(std::uint32_t player, const std::string &series, std::uint32_t mmid) noexcept {
|
||||
calls.push_back({"add", series, player, mmid});
|
||||
return true;
|
||||
@@ -161,6 +199,24 @@ void end_session() {
|
||||
calls.clear();
|
||||
}
|
||||
|
||||
void native_queue_banner_lifetime() {
|
||||
const auto before=native_queue_hud_state().generation;
|
||||
local(Local::created,501);auto queue=native_queue_hud_state();
|
||||
check(queue.generation>before && queue.waiting,"Creating a native queue enables its waiting notice");
|
||||
local(Local::force_started,501);check(native_queue_hud_state().waiting,"A Start request alone does not assume the event successfully began");
|
||||
local(Local::entered,501);check(!native_queue_hud_state().waiting,"Actual activity entry retires the long-timer waiting banner");
|
||||
local(Local::ended,501);check(!native_queue_hud_state().waiting,"A completed Skate Jam cannot resurrect its waiting banner");
|
||||
local(Local::joined,502);auto rejoined=native_queue_hud_state();
|
||||
check(rejoined.generation>queue.generation && rejoined.waiting,"A new enrollment gets a fresh waiting notice");
|
||||
local(Local::left,502);check(!native_queue_hud_state().waiting,"Leave retires the queue notice immediately");
|
||||
local(Local::joined,503);local(Local::quit,503);
|
||||
check(!native_queue_hud_state().waiting,"Quit cannot leave a stock waiting banner alive");
|
||||
local(Local::created,504);retire_native_queue_hud();
|
||||
check(!native_queue_hud_state().waiting,"A real level teardown retires its waiting notice");
|
||||
local(Local::created,505);check(native_queue_hud_state().waiting,"The next map's fresh queue is visible");
|
||||
end_session();
|
||||
}
|
||||
|
||||
// Another player's drop: shown, joined natively, started by its leader, scored.
|
||||
void guest_flow() {
|
||||
const ThrowdownRelayInput in{me, {{leader, 3}, {other, 5}}, true, 77};
|
||||
@@ -216,6 +272,15 @@ void guest_flow() {
|
||||
}
|
||||
|
||||
// The local player's own drop: offered, joined by others, force-started, scored.
|
||||
void private_draft_relay_flow() {
|
||||
const ThrowdownRelayInput in{me,{{other,4}},true,78};tick(in);
|
||||
custom_queues.insert(0x999);
|
||||
ThrowdownLocalAction exited{Local::exited};exited.series="SpotBattle";exited.mmid=0x999;
|
||||
exited.placement={9};exited.settings={8};throwdown_relay_local(std::move(exited));
|
||||
const auto out=tick(in);
|
||||
check(std::none_of(out.begin(),out.end(),[](const auto& m){return m.kind==Kind::offer;}),"A custom draft leaked through the stock donor relay");
|
||||
custom_queues.clear();end_session();
|
||||
}
|
||||
void leader_flow() {
|
||||
const ThrowdownRelayInput in{me, {{other, 4}}, true, 78};
|
||||
tick(in);
|
||||
@@ -818,6 +883,26 @@ void plugin_host_flow() {
|
||||
end_session();
|
||||
}
|
||||
|
||||
// An older client's plugin mode flag (a Kind::mode tag before a mode_series offer): ignored, even
|
||||
// where the plugin has that mode; the kit's flags travel as flag messages (custom_flag_flow).
|
||||
void legacy_mode_tags_are_ignored() {
|
||||
using namespace dingosdk::multiplayer::custom_throwdowns;
|
||||
Mode mode;
|
||||
mode.plugin = "demo"; mode.id = "duel"; mode.name = "Duel"; mode.flag = "duel"; mode.players = 3;
|
||||
check(set_mode(mode).empty(), "The test mode registers");
|
||||
calls.clear();
|
||||
const ThrowdownRelayInput in{me, {{leader, 3}}, true, 82};
|
||||
tick(in);
|
||||
auto tag = make(Kind::mode, leader, 0x71);
|
||||
tag.challenge = "demo/duel"; tag.series = "duel"; tag.value = 3;
|
||||
receive(leader, tag);
|
||||
receive(leader, offer(leader, 0x71, "ReSkateMode"));
|
||||
const auto out = tick(in);
|
||||
check(!find("spawn") && out.empty(), "An older client's mode flag was shown or answered");
|
||||
end_session();
|
||||
remove_plugin("demo");
|
||||
}
|
||||
|
||||
void level_change_flow() {
|
||||
using namespace std::chrono_literals;
|
||||
const ThrowdownRelayInput in{me, {{leader, 3}}, true, 77};
|
||||
@@ -834,6 +919,108 @@ void level_change_flow() {
|
||||
check(count("spawn") == 1, "The drop was not shown once the event types were found again");
|
||||
end_session();
|
||||
}
|
||||
dingosdk::throwdowns::kit::Flag kit_flag(std::uint64_t owner,std::uint32_t game,float x,float yaw,const char* mode="demo/lap") {
|
||||
dingosdk::throwdowns::kit::Flag f;
|
||||
f.id="flag-"+std::to_string(game);f.mode=mode;f.leader=owner;f.game=game;f.at={x,2,30};f.yaw=yaw;f.players=1;f.capacity=4;
|
||||
return f;
|
||||
}
|
||||
void publish_flags(std::vector<dingosdk::throwdowns::kit::Flag> flags) {
|
||||
kit_documents.clear();
|
||||
if(flags.empty())return;
|
||||
dingosdk::throwdowns::kit::Documents d;d.plugin="demo";d.flags=std::move(flags);
|
||||
kit_documents.push_back(std::move(d));
|
||||
}
|
||||
// Other players' plugin mode flags (kit documents' `flags`, or the leader's relay): native
|
||||
// registrations hosted by the leader's virtual id, joined with the stock Join.
|
||||
void custom_flag_flow() {
|
||||
using namespace dingosdk::multiplayer::custom_throwdowns;
|
||||
Mode mode;
|
||||
mode.plugin="demo";mode.id="lap";mode.name="Lap";mode.flag="lap";
|
||||
check(set_mode(mode).empty(),"The flag test mode registers");
|
||||
const ThrowdownRelayInput in{me,{{leader,3,"Host"},{other,5,"Other"}},true,777};
|
||||
publish_flags({kit_flag(leader,23,100,90)});
|
||||
tick(in);
|
||||
check(count("custom flag")==1 && find("custom flag")->id==0x303 && find("custom flag")->series=="demo/lap" &&
|
||||
find("custom flag")->mmid==100 && find("custom flag")->value==90,"a plugin's flag spawns a native registration at its spot, hosted by its remote player");
|
||||
const auto first=last_token;
|
||||
tick(in);check(count("custom flag")==1,"repeat documents do not duplicate a physical flag");
|
||||
throwdown_relay_spawned(first,501,10);tick(in);
|
||||
check(custom_throwdown_flag_key(501)=="demo/lap","native POI/bubble resolves its own queue's mode");
|
||||
check(custom_throwdown_flag_available(leader,23) && !custom_throwdown_flag_available(other,23) && !custom_throwdown_flag_available(leader,24),
|
||||
"only the exact advertised native flag takes over the A interaction");
|
||||
kit_documents.front().flags.front().players=2;kit_documents.front().flags.front().stats={{"Gates","5"}};
|
||||
tick(in);
|
||||
const auto info=custom_throwdown_flag_info(501);
|
||||
check(info && info->players==2 && info->stats.size()==1 && info->stats.front().second=="5" && info->id=="flag-23" &&
|
||||
count("custom flag")==1 && count("destroy")==0,"updated players/stats do not respawn or flash the flag");
|
||||
local(Local::joined,501);tick(in);
|
||||
check(adopted.size()==1 && adopted.back().mmid==501 && adopted.back().game==23 && adopted.back().mode=="demo/lap" &&
|
||||
adopted.back().capacity==4 && count("start")==0 && count("destroy")==0,
|
||||
"native Join hands the registration to the flag session without starting or destroying a Spot Battle");
|
||||
check(!kit_events.empty() && kit_events.back().plugin=="demo" && kit_events.back().action=="join" &&
|
||||
kit_events.back().mode=="demo/lap" &&
|
||||
kit_events.back().args==std::vector<std::string>{"flag-23",std::to_string(leader),"23"},"the mode's plugin hears join {flag id, leader, game}");
|
||||
check(custom_throwdown_flag_key(501).empty(),"the flag session takes over the joined flag's lifetime");
|
||||
|
||||
calls.clear();publish_flags({kit_flag(leader,24,101,45)});tick(in);
|
||||
const auto pending=last_token;publish_flags({});tick(in);
|
||||
throwdown_relay_spawned(pending,502,10);tick(in);
|
||||
check(count("destroy")==1 && find("destroy")->mmid==502 && custom_throwdown_flag_key(502).empty(),"withdrawal while spawning destroys the late flag instead of reviving a stale invitation");
|
||||
|
||||
calls.clear();publish_flags({kit_flag(leader,25,102,30)});tick(in);
|
||||
throwdown_relay_spawned(last_token,503,10);tick(in);
|
||||
accept_adopt=false;local(Local::joined,503);tick(in);
|
||||
check(count("remove")==1 && find("remove")->id==2 && count("start")==0,"a refused native Join removes only the local donor participant");
|
||||
accept_adopt=true;publish_flags({});tick(in);
|
||||
check(count("destroy")==1 && find("destroy")->mmid==503,"withdrawn open flags are removed");
|
||||
|
||||
calls.clear();publish_flags({kit_flag(leader,26,103,20)});tick(in);
|
||||
const auto same_tick=last_token;
|
||||
throwdown_relay_spawned(same_tick,504,10);local(Local::joined,504);tick(in);
|
||||
check(adopted.back().mmid==504 && count("destroy")==0,"native Join in the spawn callback's tick resolves the correct flag");
|
||||
|
||||
calls.clear();publish_flags({kit_flag(leader,27,104,0,"elsewhere/lap")});tick(in);
|
||||
check(count("custom flag")==0,"a flag of a mode no plugin here registered is not shown");
|
||||
auto closed=kit_flag(leader,28,105,0);closed.open=false;publish_flags({closed});tick(in);
|
||||
check(count("custom flag")==0,"a closed flag is not shown");
|
||||
publish_flags({});end_session();
|
||||
|
||||
// The local plugin's own flag goes to the other players; theirs come back the same way.
|
||||
{
|
||||
const ThrowdownRelayInput host{me,{{other,4}},true,778};
|
||||
tick(host);
|
||||
auto own=kit_flag(0,31,106,15);own.stats={{"Laps","3"}};
|
||||
publish_flags({own});
|
||||
auto out=tick(host);
|
||||
const auto sent=std::find_if(out.begin(),out.end(),[](const auto& m){return m.kind==Kind::flag;});
|
||||
check(sent!=out.end() && sent->leader==me && sent->id==31 && sent->challenge=="demo/lap","the local plugin's flag was not sent to the other players");
|
||||
const auto body=decode_flag_body(sent->placement);
|
||||
check(body && body->id=="flag-31" && body->at[0]==106 && body->yaw==15 && body->capacity==4 && body->stats.size()==1 &&
|
||||
!body->withdrawn,"the sent flag lost its card");
|
||||
out=tick(host);
|
||||
check(std::none_of(out.begin(),out.end(),[](const auto& m){return m.kind==Kind::flag;}),"an unchanged flag was resent at once");
|
||||
publish_flags({});out=tick(host);
|
||||
const auto gone=std::find_if(out.begin(),out.end(),[](const auto& m){return m.kind==Kind::flag;});
|
||||
check(gone!=out.end() && gone->id==31 && decode_flag_body(gone->placement)->withdrawn,"a taken-down flag was not withdrawn");
|
||||
end_session();
|
||||
|
||||
const ThrowdownRelayInput guest{me,{{leader,3}},true,779};
|
||||
tick(guest);
|
||||
auto remote=make(Kind::flag,leader,41);remote.challenge="demo/lap";
|
||||
ThrowdownFlagBody flag;flag.id="remote";flag.at={107,2,30};flag.yaw=5;flag.capacity=6;
|
||||
remote.placement=encode_flag_body(flag);
|
||||
receive(leader,remote);tick(guest);
|
||||
check(count("custom flag")==1 && find("custom flag")->mmid==107 && find("custom flag")->id==0x303,"a flag another player's relay sent was not shown");
|
||||
throwdown_relay_spawned(last_token,505,10);tick(guest);
|
||||
check(custom_throwdown_flag_info(505) && custom_throwdown_flag_info(505)->id=="remote","the sent flag's id did not reach the native card");
|
||||
flag.withdrawn=true;remote.placement=encode_flag_body(flag);
|
||||
receive(leader,remote);tick(guest);
|
||||
check(count("destroy")==1 && find("destroy")->mmid==505,"a withdrawn flag stayed up");
|
||||
end_session();
|
||||
}
|
||||
remove_plugin("demo");
|
||||
kit_events.clear();adopted.clear();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// A score read from text: what atoi reads, and digit groups read whole.
|
||||
@@ -858,7 +1045,9 @@ void grouped_number_checks() {
|
||||
}
|
||||
int main() {
|
||||
try {
|
||||
native_queue_banner_lifetime();
|
||||
guest_flow();
|
||||
private_draft_relay_flow();
|
||||
leader_flow();
|
||||
removal_flow();
|
||||
spot_battle_flow();
|
||||
@@ -869,7 +1058,9 @@ int main() {
|
||||
beacon_flow();
|
||||
level_change_flow();
|
||||
plugin_host_flow();
|
||||
std::cout << "Throwdown relay: guest, leader, removal, Spot Battle, S.K.A.T.E., last-player, coop challenge, party beacon, level change and plugin drop flows passed.\n";
|
||||
legacy_mode_tags_are_ignored();
|
||||
custom_flag_flow();
|
||||
std::cout << "Throwdown relay: guest, leader, removal, Spot Battle, S.K.A.T.E., last-player, coop challenge, party beacon, level change, plugin drop, legacy mode tag and custom flag flows passed.\n";
|
||||
return 0;
|
||||
} catch (const std::exception &e) {
|
||||
std::cerr << e.what() << '\n';
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#include "challenge_registry.h"
|
||||
#include "custom_modes.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
|
||||
namespace dingosdk::throwdowns {
|
||||
Palette palette_for(std::string_view id) noexcept {
|
||||
std::uint32_t hash=2166136261U;
|
||||
for(const auto c:id) hash=(hash^static_cast<unsigned char>(c))*16777619U;
|
||||
// Authored Throwdown hues: fixed assignment across sessions and reloads.
|
||||
constexpr std::array<std::uint32_t,6> hues{0xffff8000U,0xffff5df2U,0xff00cfffU,0xff60d64bU,0xffed935bU,0xffdfae78U};
|
||||
return {hues[hash%hues.size()],0xfff3f0e5U,0xfff2cc52U,0xff171717U,0xffffffffU};
|
||||
}
|
||||
Registration Registry::add(Definition d) {
|
||||
// A plugin mode's id is "<plugin folder>/<id>": printable, no spaces.
|
||||
const auto valid_id=[](std::string_view id) {
|
||||
return !id.empty() && id.size()<=128 && std::all_of(id.begin(),id.end(),[](unsigned char c) { return c>0x20 && c<0x7f; });
|
||||
};
|
||||
if(!valid_id(d.id) || d.name.empty() || d.name.size()>64 || d.description.size()>1024 || d.donor>2)
|
||||
return {false,false,"Invalid challenge identity, description or artwork family."};
|
||||
if(d.palette.primary==0)d.palette=palette_for(d.id);
|
||||
if(const auto* existing=find(d.id)) {
|
||||
if(*existing!=d)
|
||||
return {false,false,"Challenge identifier already belongs to a different definition."};
|
||||
return {true,false,{}}; // Repeated initialization is idempotent.
|
||||
}
|
||||
definitions_.push_back(std::move(d));++revision_;
|
||||
return {true,true,{}};
|
||||
}
|
||||
const Definition* Registry::find(std::string_view id) const noexcept {
|
||||
const auto it=std::find_if(definitions_.begin(),definitions_.end(),[&](const auto& d){return d.id==id;});
|
||||
return it==definitions_.end()?nullptr:&*it;
|
||||
}
|
||||
// The stock S.K.A.T.E., Spot Battle and Skate Jam, each unless a plugin mode takes its place, then
|
||||
// every other plugin mode by its order (ties: as registered). Rebuilt when the plugins' modes
|
||||
// change; read on the UI thread.
|
||||
Registry& registry() {
|
||||
static Registry value;
|
||||
static std::uint64_t built = ~0ULL;
|
||||
const auto revision = multiplayer::custom_throwdowns::revision();
|
||||
if (revision == built) return value;
|
||||
built = revision;
|
||||
auto modes = multiplayer::custom_throwdowns::modes();
|
||||
std::stable_sort(modes.begin(), modes.end(), [](const auto& a, const auto& b) { return a.order < b.order; });
|
||||
const auto plugin_definition = [](const multiplayer::custom_throwdowns::Mode& mode) {
|
||||
Definition d;
|
||||
d.id = mode.key();
|
||||
d.name = mode.name;
|
||||
d.description = mode.description;
|
||||
d.logo = mode.art;
|
||||
d.plugin = mode.plugin;
|
||||
d.flag = mode.flag;
|
||||
d.adapter = Adapter::plugin;
|
||||
d.donor = mode.donor();
|
||||
d.replaces = mode.replaces;
|
||||
d.order = mode.order;
|
||||
d.palette = palette_for(d.id);
|
||||
if (mode.color) d.palette.primary = mode.color;
|
||||
d.min_players = mode.min_players;
|
||||
d.max_players = mode.max_players;
|
||||
d.players = mode.players;
|
||||
d.cursor_height = mode.cursor_height;
|
||||
return d;
|
||||
};
|
||||
auto& result = value;
|
||||
result.clear();
|
||||
std::vector<std::string> placed;
|
||||
struct Stock {
|
||||
const char *tile, *id, *name, *description, *logo;
|
||||
Adapter adapter;
|
||||
unsigned donor;
|
||||
};
|
||||
constexpr std::array<Stock, 3> stock{{
|
||||
{"skate", "native.skate", "S.K.A.T.E.", "Match the leader's trick or take a letter.", "ThrowdownSkate", Adapter::native_skate, 0},
|
||||
{"spot", "native.spot", "Spot Battle", "Earn the highest score in a single round.", "SpotBattle", Adapter::native_spot, 1},
|
||||
{"jam", "native.jam", "Skate Jam", "Accumulate points across all tricks.", "ThrowdownerActive", Adapter::native_jam, 2},
|
||||
}};
|
||||
for (const auto& s : stock) {
|
||||
const auto replacing = std::find_if(modes.begin(), modes.end(), [&](const auto& m) { return m.replaces == s.tile; });
|
||||
if (replacing != modes.end()) {
|
||||
result.add(plugin_definition(*replacing));
|
||||
placed.push_back(replacing->key());
|
||||
continue;
|
||||
}
|
||||
Definition d;
|
||||
d.id = s.id;
|
||||
d.name = s.name;
|
||||
d.description = s.description;
|
||||
d.logo = s.logo;
|
||||
d.adapter = s.adapter;
|
||||
d.donor = s.donor;
|
||||
d.palette = palette_for(d.id);
|
||||
result.add(std::move(d));
|
||||
}
|
||||
for (const auto& mode : modes)
|
||||
if (std::find(placed.begin(), placed.end(), mode.key()) == placed.end()) result.add(plugin_definition(mode));
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// The Throwdowns page's cards: the game's three stock throwdowns and every plugin mode
|
||||
// (custom_modes.h), in SmokeyDawg58's logo grid. Built from the plugins' modes; nothing here knows
|
||||
// a particular mode.
|
||||
namespace dingosdk::throwdowns {
|
||||
enum class Adapter : std::uint8_t { native_skate, native_spot, native_jam, plugin };
|
||||
struct Palette {
|
||||
std::uint32_t primary{}, secondary{}, accent{}, background=0xff171717U, text=0xffffffffU;
|
||||
bool operator==(const Palette&) const = default;
|
||||
};
|
||||
struct Definition {
|
||||
// A plugin mode's id is its custom_modes key ("<plugin>/<id>"); the stock ones are
|
||||
// "native.skate", "native.spot" and "native.jam".
|
||||
std::string id, name, description, logo;
|
||||
std::string plugin; // empty for the stock throwdowns
|
||||
std::string flag; // the mode's placement-preview prefab (custom_modes Mode::flag)
|
||||
Adapter adapter{};
|
||||
unsigned donor=1; // the stock card it is cloned from: 0 S.K.A.T.E., 1 Spot Battle, 2 Skate Jam
|
||||
std::string replaces; // "skate" | "spot" | "jam": the stock tile whose place it takes
|
||||
int order{}; // the grid order among plugin modes
|
||||
Palette palette;
|
||||
int min_players=1, max_players=6, players=4;
|
||||
float cursor_height{}; // > 0: its flag's placement cursor may rise this far (custom_modes Mode::cursor_height)
|
||||
bool operator==(const Definition&) const = default;
|
||||
};
|
||||
struct Registration {
|
||||
bool accepted{}, inserted{};
|
||||
std::string error;
|
||||
};
|
||||
class Registry {
|
||||
std::vector<Definition> definitions_;
|
||||
std::uint64_t revision_{};
|
||||
public:
|
||||
Registration add(Definition definition);
|
||||
const Definition* find(std::string_view id) const noexcept;
|
||||
std::span<const Definition> definitions() const noexcept { return definitions_; }
|
||||
std::uint64_t revision() const noexcept { return revision_; }
|
||||
// Empties it for a rebuild; the revision still only grows, so a reader sees the change.
|
||||
void clear() noexcept {
|
||||
definitions_.clear();
|
||||
++revision_;
|
||||
}
|
||||
};
|
||||
Palette palette_for(std::string_view stable_id) noexcept;
|
||||
// Rebuilt when the plugins' modes change; read on the UI thread.
|
||||
Registry& registry();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "custom_modes.h"
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <mutex>
|
||||
|
||||
namespace dingosdk::multiplayer::custom_throwdowns {
|
||||
@@ -8,22 +9,9 @@ namespace {
|
||||
struct Registry {
|
||||
std::mutex mutex;
|
||||
std::vector<Mode> modes;
|
||||
std::map<std::string, std::map<std::string, int>, std::less<>> settings;
|
||||
std::map<std::string, std::vector<News>, std::less<>> news;
|
||||
std::atomic<std::uint64_t> revision{1};
|
||||
};
|
||||
Registry& registry() { static auto* r = new Registry; return *r; }
|
||||
std::map<std::string, int> defaults(const Mode& mode) {
|
||||
std::map<std::string, int> result{{"players", mode.players}};
|
||||
if (mode.timer) result["timer"] = mode.timer->value;
|
||||
for (const auto& row : mode.rows)
|
||||
if (row.kind != Row::Kind::button) result[row.id] = row.value;
|
||||
return result;
|
||||
}
|
||||
std::string plugin_of(std::string_view key) {
|
||||
const auto at = key.find('/');
|
||||
return std::string(at == std::string_view::npos ? key : key.substr(0, at));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string set_mode(Mode mode) {
|
||||
@@ -31,40 +19,24 @@ std::string set_mode(Mode mode) {
|
||||
return "a mode needs an id of 1 to 32 characters, without /";
|
||||
if (mode.name.empty() || mode.name.size() > 32) return "a mode needs a name of 1 to 32 characters";
|
||||
if (mode.description.size() > 240) return "a description is at most 240 characters";
|
||||
// "spot_battle" is what plugins said before the kit named the stock cards.
|
||||
if (mode.replaces == "spot_battle") mode.replaces = "spot";
|
||||
if (!mode.replaces.empty() && mode.replaces != "skate" && mode.replaces != "spot" && mode.replaces != "jam")
|
||||
return "replaces is \"skate\", \"spot\" or \"jam\"";
|
||||
if (mode.card.empty()) mode.card = "spot";
|
||||
if (mode.card == "spot_battle") mode.card = "spot";
|
||||
if (mode.card != "skate" && mode.card != "spot" && mode.card != "jam") return "card is \"skate\", \"spot\" or \"jam\"";
|
||||
if (mode.art.size() > 256 || mode.flag.size() > 256) return "art and flag are at most 256 characters";
|
||||
if (mode.min_players < 1 || mode.max_players > 10 || mode.min_players > mode.max_players)
|
||||
return "players are 1 to 10, min no more than max";
|
||||
if (!std::isfinite(mode.cursor_height) || mode.cursor_height < 0 || mode.cursor_height > 100000)
|
||||
return "cursor_height is 0 to 100000 metres";
|
||||
mode.players = std::clamp(mode.players, mode.min_players, mode.max_players);
|
||||
if (mode.rows.size() > 8) return "a mode has at most 8 rows";
|
||||
std::vector<std::string> ids{"players", "timer"};
|
||||
for (auto& row : mode.rows) {
|
||||
if (row.id.empty() || std::find(ids.begin(), ids.end(), row.id) != ids.end())
|
||||
return "each row needs its own id (not players or timer)";
|
||||
ids.push_back(row.id);
|
||||
if (row.caption.empty() || row.caption.size() > 32) return "a row's caption is 1 to 32 characters";
|
||||
if (row.kind == Row::Kind::choice) {
|
||||
if (row.options.empty() || row.options.size() > 12) return "a choice has 1 to 12 options";
|
||||
row.value = std::clamp(row.value, 0, static_cast<int>(row.options.size()) - 1);
|
||||
}
|
||||
}
|
||||
const auto slider = [](Row& row) {
|
||||
if (row.step < 1 || row.minimum > row.maximum || (row.maximum - row.minimum) / row.step > 600) return false;
|
||||
row.value = std::clamp(row.value, row.minimum, row.maximum);
|
||||
return true;
|
||||
};
|
||||
if (mode.timer && !slider(*mode.timer)) return "the timer runs min to max in at most 600 steps";
|
||||
for (auto& row : mode.rows)
|
||||
if (row.kind == Row::Kind::slider && !slider(row)) return "a slider runs min to max in at most 600 steps";
|
||||
auto& r = registry();
|
||||
std::lock_guard lock(r.mutex);
|
||||
const auto key = mode.key();
|
||||
const auto found = std::find_if(r.modes.begin(), r.modes.end(), [&](const Mode& m) { return m.key() == key; });
|
||||
if (found == r.modes.end() && r.modes.size() >= 32) return "32 modes are registered already";
|
||||
// An update keeps the values already chosen for rows it still has.
|
||||
auto values = defaults(mode);
|
||||
if (found != r.modes.end())
|
||||
for (auto& [row, value] : values)
|
||||
if (const auto kept = r.settings[key].find(row); kept != r.settings[key].end()) value = kept->second;
|
||||
r.settings[key] = std::move(values);
|
||||
if (found != r.modes.end()) *found = std::move(mode);
|
||||
else r.modes.push_back(std::move(mode));
|
||||
r.revision.fetch_add(1);
|
||||
@@ -79,7 +51,6 @@ void remove_plugin(std::string_view plugin) {
|
||||
auto& r = registry();
|
||||
std::lock_guard lock(r.mutex);
|
||||
if (std::erase_if(r.modes, [&](const Mode& m) { return m.plugin == plugin; })) r.revision.fetch_add(1);
|
||||
if (const auto found = r.news.find(plugin); found != r.news.end()) r.news.erase(found);
|
||||
}
|
||||
std::vector<Mode> modes() {
|
||||
auto& r = registry();
|
||||
@@ -94,47 +65,4 @@ std::optional<Mode> find(std::string_view key) {
|
||||
return std::nullopt;
|
||||
}
|
||||
std::uint64_t revision() noexcept { return registry().revision.load(); }
|
||||
|
||||
std::map<std::string, int> settings(std::string_view key) {
|
||||
auto& r = registry();
|
||||
std::lock_guard lock(r.mutex);
|
||||
const auto found = r.settings.find(key);
|
||||
return found == r.settings.end() ? std::map<std::string, int>{} : found->second;
|
||||
}
|
||||
void reset_settings(std::string_view key) {
|
||||
auto& r = registry();
|
||||
std::lock_guard lock(r.mutex);
|
||||
for (const auto& mode : r.modes)
|
||||
if (mode.key() == key) r.settings[std::string(key)] = defaults(mode);
|
||||
}
|
||||
void set_setting(std::string_view key, std::string_view row, int value) {
|
||||
auto& r = registry();
|
||||
std::lock_guard lock(r.mutex);
|
||||
auto& values = r.settings[std::string(key)];
|
||||
const auto found = values.find(std::string(row));
|
||||
if (found != values.end() && found->second == value) return;
|
||||
values[std::string(row)] = value;
|
||||
const auto at = key.find('/');
|
||||
auto& queue = r.news[plugin_of(key)];
|
||||
if (queue.size() >= 64) queue.erase(queue.begin());
|
||||
queue.push_back({std::string(key.substr(at + 1)), "setting", std::string(row), value, values});
|
||||
}
|
||||
void post(std::string_view key, News news) {
|
||||
auto& r = registry();
|
||||
std::lock_guard lock(r.mutex);
|
||||
const auto at = key.find('/');
|
||||
news.mode = std::string(at == std::string_view::npos ? key : key.substr(at + 1));
|
||||
if (const auto found = r.settings.find(key); found != r.settings.end() && news.settings.empty())
|
||||
news.settings = found->second;
|
||||
auto& queue = r.news[plugin_of(key)];
|
||||
if (queue.size() >= 64) queue.erase(queue.begin());
|
||||
queue.push_back(std::move(news));
|
||||
}
|
||||
std::vector<News> take(std::string_view plugin) {
|
||||
auto& r = registry();
|
||||
std::lock_guard lock(r.mutex);
|
||||
const auto found = r.news.find(plugin);
|
||||
if (found == r.news.end()) return {};
|
||||
return std::exchange(found->second, {});
|
||||
}
|
||||
} // namespace dingosdk::multiplayer::custom_throwdowns
|
||||
@@ -1,49 +1,38 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// Throwdown modes that plugins add to the game's Throwdowns page. A mode is a
|
||||
// card on the picker, the game's own Spot Battle setup page relabelled with the
|
||||
// mode's rows, and its native start flag: the plugin is told where the flag
|
||||
// went and runs the mode itself. Thread-safe; the menu reads it on the UI
|
||||
// thread, plugins write it from theirs.
|
||||
// Throwdown modes that plugins add to the game's Throwdowns page. A mode is a card on the picker
|
||||
// (cloned from a stock card), the game's own Spot Battle setup page relabelled from the plugin's
|
||||
// setup document (throwdown_kit.h), and its native start flag: the plugin runs the mode itself.
|
||||
// Thread-safe; the menu reads it on the UI thread, plugins write it from theirs.
|
||||
namespace dingosdk::multiplayer::custom_throwdowns {
|
||||
struct Row {
|
||||
enum class Kind { slider, choice, button };
|
||||
Kind kind{};
|
||||
std::string id, caption;
|
||||
int minimum{}, maximum{}, step{1}, value{}; // slider; choice: value is the option index
|
||||
std::vector<std::string> options; // choice
|
||||
std::string text; // button: its label
|
||||
};
|
||||
struct Mode {
|
||||
std::string plugin, id, name, description;
|
||||
// The picker logo: a texture or image asset the plugin's mod adds, by path
|
||||
// (a bare name is a Throwdown_ModeSelect_ContentResources record). Empty:
|
||||
// the Spot Battle art.
|
||||
// the donor card's art.
|
||||
std::string art;
|
||||
// The mod's flag (InstanceId ReSkate.<flag>). Empty: Spot Battle's own.
|
||||
// The placement-preview prefab the native flag shows ("World/.../duel_Placement",
|
||||
// or an InstanceId name of the plugin's mod). Empty: Spot Battle's own.
|
||||
std::string flag;
|
||||
// The stock card it is cloned from: "skate", "spot" or "jam" (throwdown_kit.h).
|
||||
std::string card = "spot";
|
||||
// The stock throwdown whose place on the picker it takes: "skate", "spot"
|
||||
// or "jam"; empty: after them.
|
||||
std::string replaces;
|
||||
std::uint32_t color{}; // the card's tint; 0: one picked from the mode's id
|
||||
int order{}; // among the plugin modes on the picker, lowest first
|
||||
int min_players = 1, max_players = 6, players = 4;
|
||||
std::optional<Row> timer; // the setup page's Timer row, when the mode has one
|
||||
std::vector<Row> rows;
|
||||
// > 0: the placement cursor of its flag may rise this far (metres), so the flag can stand on
|
||||
// anything (kit::flag::Options::cursor_height); 0: the game's own ceiling.
|
||||
float cursor_height{};
|
||||
std::string key() const { return plugin + "/" + id; }
|
||||
};
|
||||
// What happened to a plugin's mode: opened (its setup page), setting (a row
|
||||
// changed), button, placed (the flag is down: `at` and `heading`), start (the
|
||||
// host held Start on the waiting card), leave, cancelled (Back out of setup or
|
||||
// placement). `settings` holds players, timer and every row by id.
|
||||
struct News {
|
||||
std::string mode, action, row;
|
||||
int value{};
|
||||
std::map<std::string, int> settings;
|
||||
std::optional<std::array<float, 3>> at;
|
||||
float heading{}; // degrees
|
||||
// 0 S.K.A.T.E., 1 Spot Battle, 2 Skate Jam (challenge_registry.h's donors).
|
||||
unsigned donor() const noexcept { return card == "skate" ? 0U : card == "jam" ? 2U : 1U; }
|
||||
};
|
||||
|
||||
// Empty when accepted, else why not.
|
||||
@@ -54,13 +43,4 @@ std::vector<Mode> modes();
|
||||
std::optional<Mode> find(std::string_view key);
|
||||
// Changes whenever a mode is set or removed.
|
||||
std::uint64_t revision() noexcept;
|
||||
|
||||
// The setup page's values: players, timer and each row's value by its id.
|
||||
// Reset to the mode's defaults when its setup opens.
|
||||
std::map<std::string, int> settings(std::string_view key);
|
||||
void reset_settings(std::string_view key);
|
||||
// Records a new value; a change is also news for the plugin.
|
||||
void set_setting(std::string_view key, std::string_view row, int value);
|
||||
void post(std::string_view key, News news);
|
||||
std::vector<News> take(std::string_view plugin);
|
||||
} // namespace dingosdk::multiplayer::custom_throwdowns
|
||||
@@ -1,291 +0,0 @@
|
||||
#include "custom_placement.h"
|
||||
#include "custom_modes.h"
|
||||
#include "throwdown_lab.h"
|
||||
#include "Extension/UI/NativeMenu/native_menu_data.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include <Windows.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
// The generic path of SmokeyDawg58's 1-UP flag adapter (one_up_placement.cpp in
|
||||
// reskate-custom-mods): native settings, then the start flag, then the waiting card.
|
||||
namespace dingosdk::multiplayer::custom_throwdowns {
|
||||
namespace {
|
||||
enum class Step { idle, open, setup, flag, placing, closing, waiting, cleanup };
|
||||
struct Placement {
|
||||
std::mutex mutex;
|
||||
Step step{};
|
||||
std::string key;
|
||||
std::uint64_t until{}, changed{}, sent{};
|
||||
std::uint32_t mmid{};
|
||||
std::vector<std::uint32_t> queues;
|
||||
bool created{}, setup_seen{}, flag_seen{}, exited{}, cancelling{}, destroy_queued{}, destroyed{};
|
||||
bool handed_off{}, show_setup{}, told{};
|
||||
std::optional<std::array<float, 3>> at;
|
||||
float yaw{};
|
||||
};
|
||||
Placement& placement() { static auto* p = new Placement; return *p; }
|
||||
void reset(Placement& p) {
|
||||
p.step = Step::idle; p.key.clear(); p.at.reset(); p.yaw = 0; p.mmid = 0; p.sent = 0;
|
||||
p.created = p.setup_seen = p.flag_seen = p.exited = p.cancelling = p.destroy_queued = p.destroyed = false;
|
||||
p.handed_off = p.show_setup = p.told = false;
|
||||
}
|
||||
void cleanup(Placement& p, std::uint64_t now) {
|
||||
if (p.step != Step::cleanup) p.sent = 0;
|
||||
p.cancelling = true; p.step = Step::cleanup; p.until = now + 15000;
|
||||
}
|
||||
// News for the plugin, once per flag: cancelled before the flag was down.
|
||||
void tell(Placement& p, std::string action) {
|
||||
if (p.key.empty() || p.told) return;
|
||||
p.told = action != "placed";
|
||||
custom_throwdowns::News news;
|
||||
news.action = std::move(action);
|
||||
if (p.at && news.action == "placed") { news.at = p.at; news.heading = p.yaw * 180.f / 3.14159265f; }
|
||||
post(p.key, std::move(news));
|
||||
}
|
||||
// QueueStateNavigation's live graph publishes these two fields, with metadata
|
||||
// [-1] for a command without arguments. Do not change the Back keyword stack.
|
||||
bool navigate(const menu_data::Context& c, std::string_view name) {
|
||||
using namespace menu_data;
|
||||
const auto roots = c.roots({0xcc4776b5});
|
||||
require(roots.size() == 1, "Native navigation model is unavailable.");
|
||||
const auto current = c.field(roots.front().model, 0xf3ba9cf8);
|
||||
const auto metadata = c.field(roots.front().model, 0xf437b255);
|
||||
if (read<std::uint32_t>(c.address(current))) return false;
|
||||
c.set(current, game::native_name_hash(name));
|
||||
c.array(metadata, std::vector<std::int32_t>{-1});
|
||||
logging::log(logging::Level::info, logging::Channel::ui, "Custom throwdown: navigation {}.", name);
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool open_setup(const std::string& key) {
|
||||
if (!find(key)) return false;
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
// A fresh card replaces a setup that never got as far as a flag.
|
||||
if (p.step != Step::idle && !((p.step == Step::open || p.step == Step::setup) && !p.flag_seen && !p.mmid)) return false;
|
||||
tell(p, "cancelled"); // the setup this card replaces, if any
|
||||
reset(p);
|
||||
p.step = Step::open; p.key = key; p.show_setup = true;
|
||||
p.changed = GetTickCount64(); p.until = p.changed + 15000;
|
||||
reset_settings(key);
|
||||
custom_throwdowns::News news;
|
||||
news.action = "opened";
|
||||
post(key, std::move(news));
|
||||
prepare_throwdown_injection();
|
||||
return true;
|
||||
}
|
||||
std::string setup_mode() {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
return p.show_setup && (p.step == Step::open || p.step == Step::setup) ? p.key : std::string{};
|
||||
}
|
||||
bool placement_active() noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
return p.step != Step::idle && p.step != Step::waiting;
|
||||
}
|
||||
bool registration_owned() noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
// The plugin's match outlives the temporary registration. Once that is
|
||||
// destroyed, stock throwdowns own their events again.
|
||||
return p.step != Step::idle && (p.step != Step::waiting || p.mmid != 0);
|
||||
}
|
||||
bool owns_registration(std::uint32_t mmid) noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
return mmid && p.step != Step::idle && p.mmid == mmid;
|
||||
}
|
||||
bool registration_waiting() noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
return p.step == Step::waiting && p.handed_off && p.at && p.mmid;
|
||||
}
|
||||
bool flag_down() noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
return (p.step == Step::closing || p.step == Step::waiting) && p.at && p.mmid && !p.cancelling;
|
||||
}
|
||||
std::string flag_key() {
|
||||
auto& p = placement();
|
||||
std::string key;
|
||||
{
|
||||
std::lock_guard lock(p.mutex);
|
||||
if (p.step == Step::idle) return {};
|
||||
key = p.key;
|
||||
}
|
||||
const auto mode = find(key);
|
||||
return mode ? mode->flag : std::string{};
|
||||
}
|
||||
bool known_queue(std::uint32_t mmid) noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
return mmid && std::find(p.queues.begin(), p.queues.end(), mmid) != p.queues.end();
|
||||
}
|
||||
void release(std::string_view plugin) noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
if (p.step == Step::idle || p.key.substr(0, p.key.find('/')) != plugin) return;
|
||||
p.told = true; // the plugin asked: no news back
|
||||
cleanup(p, GetTickCount64());
|
||||
}
|
||||
void retire_unconfirmed() noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
if ((p.step != Step::open && p.step != Step::setup && p.step != Step::cleanup) || p.flag_seen || p.mmid || p.handed_off) return;
|
||||
tell(p, "cancelled");
|
||||
reset(p);
|
||||
}
|
||||
void observe_graph(std::uint32_t graph) noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
if (p.step == Step::idle) return;
|
||||
if (graph == 0x0f131312 && p.step == Step::setup) { // Spot Battle setup OnBegin
|
||||
p.setup_seen = true; p.until = GetTickCount64() + 300000;
|
||||
}
|
||||
if (graph == 1867583346U && p.step == Step::setup && p.setup_seen) { // the same setup's OnEnd
|
||||
// Back and Confirm both leave this state. Stop adapting its page now;
|
||||
// a Confirm goes on to the flag update below. Ignore a previous
|
||||
// entry's late OnEnd, which comes before this one's OnBegin.
|
||||
p.show_setup = false; p.changed = GetTickCount64(); p.until = p.changed + 1500;
|
||||
}
|
||||
if (graph == 1867583346U && p.step == Step::cleanup && !p.flag_seen && !p.mmid) p.exited = true;
|
||||
// Spot Battle's own placement update, after its validator and spawn set up.
|
||||
if (graph == 0x9700be92 && p.created && p.setup_seen && (p.step == Step::setup || (p.step == Step::flag && !p.flag_seen))) {
|
||||
p.show_setup = false; p.step = Step::flag; p.flag_seen = true; p.until = GetTickCount64() + 300000;
|
||||
logging::write(logging::Level::info, logging::Channel::ui, "Custom throwdown: entered the native flag placement.");
|
||||
}
|
||||
}
|
||||
void observe_created() noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
if (p.step == Step::setup) p.created = true;
|
||||
}
|
||||
void observe_queue(std::uint32_t mmid) noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
if (!mmid || p.step == Step::idle || std::find(p.queues.begin(), p.queues.end(), mmid) != p.queues.end()) return;
|
||||
if (p.queues.size() == 64) p.queues.erase(p.queues.begin());
|
||||
p.queues.push_back(mmid);
|
||||
}
|
||||
void observe_position(std::uint32_t mmid, const std::array<float, 3>& at, float yaw) noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
if ((p.step != Step::placing && p.step != Step::flag) || !p.created || !p.flag_seen || !mmid || !std::isfinite(yaw) ||
|
||||
!std::all_of(at.begin(), at.end(), [](float n) { return std::isfinite(n) && std::abs(n) < 100000.f; })) return;
|
||||
p.at = at; p.yaw = yaw; p.mmid = mmid; p.step = Step::closing; p.until = GetTickCount64() + 15000;
|
||||
logging::log(logging::Level::info, logging::Channel::ui,
|
||||
"Custom throwdown: flag at ({:.2f}, {:.2f}, {:.2f}), heading {:.1f} degrees, native queue {:#x}.",
|
||||
at[0], at[1], at[2], yaw * 180.f / 3.14159265f, mmid);
|
||||
}
|
||||
void observe_exit(bool cancelling) noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
if (p.step == Step::idle) return;
|
||||
p.exited = true; p.cancelling = cancelling || !p.at;
|
||||
p.step = p.cancelling ? Step::cleanup : Step::waiting;
|
||||
p.changed = GetTickCount64(); p.until = p.changed + 15000;
|
||||
if (p.cancelling) tell(p, "cancelled");
|
||||
}
|
||||
void observe_destroyed(std::uint32_t mmid) noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
if (p.step == Step::cleanup && p.mmid == mmid) { p.destroyed = true; p.changed = GetTickCount64(); }
|
||||
}
|
||||
void waiting_start() noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
// Start can come before the setup page's exit has moved us to waiting.
|
||||
if ((p.step != Step::waiting && p.step != Step::closing) || !p.at || !p.mmid) return;
|
||||
if (!p.handed_off) { p.handed_off = true; tell(p, "placed"); }
|
||||
tell(p, "start");
|
||||
// The flag has done its job: the plugin runs the match from here.
|
||||
cleanup(p, GetTickCount64());
|
||||
}
|
||||
void waiting_left() noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
if (p.step == Step::idle) return;
|
||||
tell(p, p.handed_off ? "leave" : "cancelled");
|
||||
cleanup(p, GetTickCount64());
|
||||
}
|
||||
void abandon() noexcept {
|
||||
auto& p = placement();
|
||||
std::lock_guard lock(p.mutex);
|
||||
// The level's own teardown removes its queues. Never destroy an old MMID
|
||||
// in a new world, where the native coordinator can reuse it.
|
||||
if (p.step != Step::idle) {
|
||||
tell(p, p.handed_off ? "leave" : "cancelled");
|
||||
reset(p);
|
||||
}
|
||||
p.queues.clear();
|
||||
}
|
||||
void tick(const menu_data::Context& c) {
|
||||
const auto now = GetTickCount64();
|
||||
auto& p = placement();
|
||||
std::string command;
|
||||
Step next{};
|
||||
std::uint32_t destroy{};
|
||||
{
|
||||
std::lock_guard lock(p.mutex);
|
||||
if (p.step == Step::idle) return;
|
||||
if (!find(p.key) && p.step != Step::cleanup) { p.told = true; cleanup(p, now); } // its plugin went
|
||||
if (p.step == Step::flag && p.flag_seen) { p.step = Step::placing; p.until = now + 300000; }
|
||||
if (p.step == Step::waiting && !p.handed_off && p.at) {
|
||||
// The registration stays: it owns the world flag, the waiting card
|
||||
// and its Start and Leave prompts.
|
||||
p.handed_off = true;
|
||||
tell(p, "placed");
|
||||
}
|
||||
if (p.step == Step::setup && p.setup_seen && !p.show_setup && now >= p.until) {
|
||||
// Back already did the native navigation. Sending an exit could
|
||||
// close the next stock challenge's setup.
|
||||
tell(p, "cancelled");
|
||||
reset(p);
|
||||
return;
|
||||
}
|
||||
if ((p.step == Step::open || p.step == Step::setup) && !p.created && !p.setup_seen && now >= p.until) {
|
||||
logging::write(logging::Level::warning, logging::Channel::ui, "Custom throwdown: the setup page did not open.");
|
||||
tell(p, "cancelled");
|
||||
reset(p);
|
||||
return; // No native queue was created: never close another menu.
|
||||
}
|
||||
if (now >= p.until && p.step != Step::cleanup && p.step != Step::waiting) {
|
||||
tell(p, "cancelled");
|
||||
p.at.reset();
|
||||
cleanup(p, now);
|
||||
}
|
||||
if (p.step == Step::open) { command = "SpotBattle"; next = Step::setup; }
|
||||
else if (p.step == Step::cleanup) {
|
||||
if (!p.exited && !p.sent) { command = "ThrowdownerExit"; next = Step::cleanup; }
|
||||
if (p.mmid && !p.destroy_queued) destroy = p.mmid;
|
||||
if ((p.exited && !p.mmid) || (p.destroyed && now >= p.changed + 500)) reset(p);
|
||||
else if (now >= p.until) {
|
||||
logging::write(logging::Level::warning, logging::Channel::ui, "Custom throwdown: the native flag could not be removed.");
|
||||
reset(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (destroy && prepare_throwdown_injection() && queue_throwdown_destroy(destroy)) {
|
||||
std::lock_guard lock(p.mutex);
|
||||
if (p.mmid == destroy) p.destroy_queued = true;
|
||||
}
|
||||
if (!command.empty()) {
|
||||
// Publication can run expression callbacks: arm the next step before
|
||||
// publishing, without holding this lock across native code.
|
||||
Step previous;
|
||||
{ std::lock_guard lock(p.mutex); previous = p.step; p.step = next; }
|
||||
bool sent{};
|
||||
try { sent = navigate(c, command); }
|
||||
catch (...) { std::lock_guard lock(p.mutex); p.step = previous; throw; }
|
||||
std::lock_guard lock(p.mutex);
|
||||
if (!sent) p.step = previous;
|
||||
else { p.sent = now; if (next != Step::cleanup) p.until = now + 15000; }
|
||||
}
|
||||
}
|
||||
} // namespace dingosdk::multiplayer::custom_throwdowns
|
||||
@@ -1,52 +0,0 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace dingosdk::multiplayer::menu_data { class Context; }
|
||||
// A plugin mode's start flag, through the game's own Spot Battle flow: its
|
||||
// setup page, flag placement and waiting card. The temporary native queue
|
||||
// owns only the flag and the waiting card; it never starts a Spot Battle. The
|
||||
// pose goes to the plugin (custom_modes.h news), as do the waiting card's
|
||||
// Start and Leave. Ordinary Spot Battles never enter this adapter.
|
||||
namespace dingosdk::multiplayer::custom_throwdowns {
|
||||
// Opens the setup page for this mode (a picker card's action). False while
|
||||
// another flag of ours is being placed or is up.
|
||||
bool open_setup(const std::string& key);
|
||||
// The mode whose setup page is open (the menu relabels it), else empty.
|
||||
std::string setup_mode();
|
||||
// From the setup page opening until the flag is down or abandoned.
|
||||
bool placement_active() noexcept;
|
||||
// Placement, or a flag of ours that is up (its queue still registered).
|
||||
bool registration_owned() noexcept;
|
||||
bool owns_registration(std::uint32_t mmid) noexcept;
|
||||
// Our flag is down and its waiting card up.
|
||||
bool registration_waiting() noexcept;
|
||||
// Our flag is down: from its accepted position until it is taken away. The
|
||||
// waiting card's Start is offered from here on.
|
||||
bool flag_down() noexcept;
|
||||
// The InstanceId suffix of the owning mode's own flag, when it has one.
|
||||
std::string flag_key();
|
||||
// A queue we created; late events for it are not a new stock throwdown.
|
||||
bool known_queue(std::uint32_t mmid) noexcept;
|
||||
// The plugin is done with its flag (its match started, or it gave up).
|
||||
void release(std::string_view plugin) noexcept;
|
||||
// A fresh picker card supersedes a setup that never placed a flag.
|
||||
void retire_unconfirmed() noexcept;
|
||||
// UI thread, under the model write lock: navigation and handoff.
|
||||
void tick(const menu_data::Context& context);
|
||||
|
||||
// From the throwdown hooks (native_throwdowns.cpp, throwdown_lab.cpp).
|
||||
void observe_graph(std::uint32_t graph) noexcept;
|
||||
void observe_created() noexcept;
|
||||
void observe_queue(std::uint32_t mmid) noexcept;
|
||||
void observe_position(std::uint32_t mmid, const std::array<float, 3>& at, float yaw_radians) noexcept;
|
||||
void observe_exit(bool cancelling) noexcept;
|
||||
void observe_destroyed(std::uint32_t mmid) noexcept;
|
||||
// The waiting card's Start (ForceStart) and Leave (Destroy) on our queue.
|
||||
void waiting_start() noexcept;
|
||||
void waiting_left() noexcept;
|
||||
// The level is unloading: its queues go with it.
|
||||
void abandon() noexcept;
|
||||
} // namespace dingosdk::multiplayer::custom_throwdowns
|
||||
@@ -0,0 +1,111 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace dingosdk::multiplayer {
|
||||
struct FlagBubbleDisplay { std::uint64_t context{}, display{}; };
|
||||
|
||||
// MapObjectComponent owns the ActivityDisplay referenced by its world bubble.
|
||||
// Resolve it from the registered POI's generation-checked EntityHandle, never
|
||||
// from proximity, the donor icon, or a global UI provider. This is a read-only
|
||||
// lookup of the verified 20260929 ECS component store (no native query handles).
|
||||
template<class Reader>
|
||||
FlagBubbleDisplay flag_bubble_display(Reader&& reader,std::uintptr_t base,
|
||||
std::uint64_t entity,std::uint64_t context,
|
||||
std::uint32_t mmid) {
|
||||
try {
|
||||
auto get=[&]<class T>(std::uintptr_t at) {
|
||||
T result{};
|
||||
if(!at || !reader(at,&result,sizeof(result)))
|
||||
throw std::runtime_error("Flag bubble owner is unavailable.");
|
||||
return result;
|
||||
};
|
||||
const auto ptr=[&](std::uintptr_t at){return get.template operator()<std::uintptr_t>(at);};
|
||||
const auto u32=[&](std::uintptr_t at){return get.template operator()<std::uint32_t>(at);};
|
||||
if(!entity || !context || !mmid)return {};
|
||||
const auto world=static_cast<std::uint32_t>(entity>>32);
|
||||
const auto local=static_cast<std::uint32_t>(entity);
|
||||
// The native world ID uses two addressable bytes and a generation byte.
|
||||
if(world>>22)return {};
|
||||
const auto manager=ptr(base+0x74cd760);
|
||||
const auto world_page=ptr(ptr(manager+0x70)+(world>>14)*8);
|
||||
if(!world_page)return {};
|
||||
const auto world_context=world_page+((world>>8)&63)*0xa30;
|
||||
if(u32(world_context+8)!=world)return {};
|
||||
const auto slots=ptr(world_context+0x18);
|
||||
if(!slots)return {};
|
||||
const auto shift=u32(slots+0x2c),stride=u32(slots+0x24),mask=u32(slots+0x28);
|
||||
if(shift>16 || stride<24 || stride>4096 || mask>65535)return {};
|
||||
const auto begin=ptr(slots),end=ptr(slots+8);
|
||||
const auto index=(local>>8)>>shift;
|
||||
if(end<begin || (end-begin)%8 || (end-begin)/8>65536 || index>=(end-begin)/8)return {};
|
||||
const auto block=ptr(begin+index*8);
|
||||
if(!block)return {};
|
||||
const auto slot=(local>>8)&mask;
|
||||
const auto bits_offset=get.template operator()<std::int32_t>(slots+0x34);
|
||||
if(bits_offset<0 || bits_offset>1048576 || block<static_cast<std::uintptr_t>(bits_offset))return {};
|
||||
if(!(ptr(block+(slot>>6)*8-bits_offset)&(std::uint64_t{1}<<(slot&63))))return {};
|
||||
const auto record=block+slot*stride;
|
||||
if(u32(record)!=local)return {}; // Includes the entity generation.
|
||||
const auto archetype=ptr(record+16);
|
||||
if(!archetype)return {};
|
||||
const auto descriptors=ptr(archetype+0x10);
|
||||
const auto count=u32(archetype+0x18);
|
||||
if(!descriptors || count>128)return {};
|
||||
constexpr std::array<std::uint8_t,16> guid{
|
||||
0xf6,0xa1,0x98,0x17,0x4e,0x41,0xb5,0x43,0xaf,0xde,0x00,0x28,0x5b,0xd7,0x92,0x7d};
|
||||
std::uintptr_t descriptor{};
|
||||
for(unsigned i=0;i<count;++i) {
|
||||
const auto candidate=ptr(descriptors+i*8);
|
||||
if(!candidate)continue;
|
||||
const auto type=ptr(candidate+8),metadata=type?ptr(type):0;
|
||||
if(!metadata || get.template operator()<std::array<std::uint8_t,16>>(metadata+8)!=guid)continue;
|
||||
if(u32(metadata)!=0x68015bc7 || get.template operator()<std::uint16_t>(metadata+6)!=704)return {};
|
||||
// Verify both model fields instead of treating arbitrary component
|
||||
// bytes as a model handle if the reflected layout changes.
|
||||
const auto fields=ptr(metadata+0x38);
|
||||
const auto fields_count=get.template operator()<std::uint16_t>(metadata+0x2a);
|
||||
if(!fields || fields_count>32)return {};
|
||||
unsigned verified{};
|
||||
for(unsigned f=0;f<fields_count;++f) {
|
||||
const auto field=fields+f*24;
|
||||
const auto hash=u32(field);
|
||||
const auto offset=get.template operator()<std::uint16_t>(field+8);
|
||||
if((hash==0x9f0a28d2 && offset==576) || (hash==0x7ac3f897 && offset==584))++verified;
|
||||
}
|
||||
if(verified!=2)return {};
|
||||
descriptor=candidate;break;
|
||||
}
|
||||
if(!descriptor)return {};
|
||||
const auto table=ptr(world_context+0x9b0);
|
||||
const auto buckets=u32(world_context+0x9b8);
|
||||
if(!table || !buckets || buckets>65536)return {};
|
||||
auto node=ptr(table+(descriptor%buckets)*8);
|
||||
std::uintptr_t store{};
|
||||
for(unsigned i=0;node && i<128;++i) {
|
||||
if(ptr(node)==descriptor){store=ptr(node+8);break;}
|
||||
node=ptr(node+16);
|
||||
}
|
||||
if(!store || ptr(store)!=base+0x65d0e48 || ptr(store+0x350)!=descriptor)return {};
|
||||
const auto sentinel=store+0x10;
|
||||
auto leaf=ptr(sentinel);
|
||||
for(unsigned pages=0;leaf && leaf!=sentinel && pages<4096;++pages) {
|
||||
const auto keys=get.template operator()<std::uint16_t>(leaf+0x10);
|
||||
if(keys>64)return {};
|
||||
for(unsigned i=0;i<keys;++i) {
|
||||
const auto key=u32(leaf+0x18+i*4);
|
||||
if(key>local)return {};
|
||||
if(key!=local)continue;
|
||||
const auto holder=ptr(leaf+0x118+i*8),component=holder?ptr(holder):0;
|
||||
if(!component || ptr(component+576)!=context || u32(component+656)!=mmid)return {};
|
||||
return {context,ptr(component+584)};
|
||||
}
|
||||
leaf=ptr(leaf);
|
||||
}
|
||||
} catch(const std::runtime_error&) {
|
||||
// Flag removal and map transitions can retire an owner between frames.
|
||||
}
|
||||
return {};
|
||||
}
|
||||
} // namespace dingosdk::multiplayer
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
#include <cmath>
|
||||
#include <optional>
|
||||
namespace dingosdk::multiplayer {
|
||||
// The native flag picker uses this movement ceiling independently of its
|
||||
// ground validator. Retain the latest native value and restore only our edit:
|
||||
// the registration asks for its own ceiling (kit::flag::Options::cursor_height).
|
||||
class NativeFlagCursorHeight {
|
||||
float previous{},raised{};bool owned{};
|
||||
public:
|
||||
std::optional<float> apply(float current,float wanted) noexcept {
|
||||
if(!std::isfinite(wanted) || wanted<=0)return {};
|
||||
if(owned && current==raised && raised==wanted)return {};
|
||||
// A new ceiling replaces ours: the native value under it is still the one to restore.
|
||||
if(owned && current==raised) {raised=wanted;return wanted;}
|
||||
owned=false;
|
||||
if(!std::isfinite(current) || current<0 || current>=wanted)return {};
|
||||
previous=current;raised=wanted;owned=true;return wanted;
|
||||
}
|
||||
std::optional<float> restore(float current) noexcept {
|
||||
const bool ours=owned && current==raised;owned=false;
|
||||
return ours?std::optional{previous}:std::nullopt;
|
||||
}
|
||||
};
|
||||
}
|
||||
Loaded 100 of 228 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user