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:
waguandClaude Opus 5.5 committed 2026-10-20 12:13:19 -04:00
commit cd36529865
228 files changed
+29595 -3083

No files matched your search

+4 -1
View File
@@ -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
View File
@@ -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{
+8 -1
View File
@@ -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};
+15
View File
@@ -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);
}
}
+2 -1
View File
@@ -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);
+5 -3
View File
@@ -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); }
+13
View File
@@ -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;
+98
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+44
View File
@@ -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
View File
@@ -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);
+1 -1
View File
@@ -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) {
+94 -8
View File
@@ -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;
}
}
+12 -2
View File
@@ -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);
+18 -3
View File
@@ -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,
+99 -6
View File
@@ -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
+2 -2
View File
@@ -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) {
+3 -3
View File
@@ -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;
-1
View File
@@ -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;
-1
View File
@@ -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 {
+2
View File
@@ -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.
+38 -12
View File
@@ -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 &note : 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) {
+34 -1
View File
@@ -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;
+3 -1
View File
@@ -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 &registry) {
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());
+1 -2
View File
@@ -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{};
+613 -31
View File
@@ -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);
+52 -3
View File
@@ -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");
+10 -2
View File
@@ -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);
+287
View File
@@ -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
+365
View File
@@ -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}});
}
}
+1 -1
View File
@@ -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;
}
+169 -62
View File
@@ -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}});
+1 -2
View File
@@ -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
View File
@@ -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; }
+324 -21
View File
@@ -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{};
+23 -16
View File
@@ -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;
+11 -3
View File
@@ -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 {
+7 -2
View File
@@ -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,
+4
View File
@@ -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_;
+1 -1
View File
@@ -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:
+19 -12
View File
@@ -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();
+4 -1
View File
@@ -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);
}
+54 -3
View File
@@ -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);
+1
View File
@@ -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 -2
View File
@@ -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>
+29 -19
View File
@@ -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 -2
View File
@@ -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.
+724
View File
@@ -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
+85
View File
@@ -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
+37
View File
@@ -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
-147
View File
@@ -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 (...) {}
}
}
-24
View File
@@ -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 &registry) {
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;
}
}
+19 -23
View File
@@ -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);
+73
View File
@@ -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 {
+7
View File
@@ -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.
+53 -1
View File
@@ -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 {
+5 -1
View File
@@ -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;
+10
View File
@@ -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); }
}
+1 -1
View File
@@ -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
+63
View File
@@ -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
+27
View File
@@ -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
+39
View File
@@ -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
+126
View File
@@ -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
+22
View File
@@ -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
+166
View File
@@ -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
+23
View File
@@ -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
+2 -2
View File
@@ -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;
}
}
+53
View File
@@ -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();
}
+11 -83
View File
@@ -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
+19 -39
View File
@@ -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
-291
View File
@@ -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
-52
View File
@@ -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
+111
View File
@@ -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