Throwdowns: the throwdown kit, so a mode is a plugin and the SDK knows no particular mode

Plugins register modes (card, replaces, order, flag, players) and publish documents through
reskate.throwdowns.ui: the setup page, the waiting card, HUD slots themed on the game's HUD_<theme>_*
widgets, the match panel, flags and match restrictions. SmokeyDawg58's native plumbing
(throwdown_menu.cpp and its .inl files) renders them and posts throwdown_ui events back. kit::flag
is the one native flag registration. General services added for it: reskate.files, reskate.ui.dialog,
reskate.skater push/hold/bone_cam, reskate.camera.pad_flight, reskate.physics competition,
reskate.session.world, reskate.tricks feed/gesture, and world entities.

His ten modes are now Lua in his plugin: the rules, referee and wire (td/rules, referee, wire), and
every page and HUD document (td/ui*.lua). Extension/Modes and reskate.modes are gone, along with
every race and 1-UP branch in the SDK. The plugin test drives each mode through kit events.

A native setup page is never written to while it closes into flag placement: its rows are not
republished, the stock page is put back once the exit is over, and the stock Players slider is left
alone while a plugin flag is out. This fixes the crashes on the Deathrace button and on 1-UP's
Confirm.
This commit is contained in:
AyeZeeBB committed 2026-10-20 01:04:54 -05:00
1 parent e14064250c
commit 8444553402
186 files changed
+28015 -2747

No files matched your search

@@ -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);
+4 -3
View File
@@ -1,5 +1,6 @@
#pragma once
#include <atomic>
#include "session_competition.h"
namespace dingosdk {
// Noclip (and teleporting), No Bail and the forward / up boosts as the session's host lets
@@ -9,9 +10,9 @@ namespace session_tools_detail {
inline std::atomic<bool> noclip{true}, no_bail{true}, boosts{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); }
// 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); }
inline void set_multiplayer_session_active(bool active) noexcept {
+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;
}
}
@@ -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);
@@ -430,6 +430,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)
@@ -446,6 +450,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,18 +725,18 @@ 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");
}
// A plugin mode's flag.
// 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 = "SmokeyDawg58-ReSkate_Throwdown/oneup"; mode.series = "oneup"; mode.value = 6;
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 = "oneup";
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 = "one up";
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;
+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{};
+608 -38
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; });
}
@@ -784,6 +898,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,47 +1896,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", replaces = "skate",
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")
assert(not pcall(reskate.throwdowns.mode, {id = "x", name = "X", replaces = "bowling"}), "a stock throwdown")
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.players then reskate.log(e.mode .. " players " .. table.concat(e.players, ",") .. " led by " .. e.leader .. (e.host and " here" or " elsewhere")) end
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", 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" && mode.replaces == "skate",
"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});
ThrowdownModeNews start{"oneup", "start", "", 0, {{"players", 2}, {"tricks", 1}}};
start.host = false;
start.leader = 76561198000000001ULL;
start.players = {76561198000000001ULL, 76561198000000002ULL};
game.mode_news.push_back(start);
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(environment.logged("[modes] oneup players 76561198000000001,76561198000000002 led by 76561198000000001 elsewhere"),
"A start in another player's flag says who plays");
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() {
@@ -1961,6 +2527,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();
@@ -1982,6 +2549,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);
+11 -2
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;
@@ -82,6 +83,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);
@@ -676,6 +683,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");
+7
View File
@@ -14,6 +14,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);
// A player as plugins get one: {id = "<SteamID64>", name, me, host}.
void push_player(lua_State* L, const SessionPlayer& player);
// A file's bytes when it is a regular file no larger than `most`; otherwise empty, and `error`
+309
View File
@@ -0,0 +1,309 @@
#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& plugin_of(lua_State* L) {
auto& plugin = *static_cast<Plugin*>(lua_callbacks(L)->userdata);
if (plugin.halted()) luaL_error(L, "the plugin was stopped");
return 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;
}
// Adds the functions to reskate.<table> (on top of the stack), making the table when there is none.
void into_table(lua_State* L, const char* table, std::initializer_list<std::pair<const char*, lua_CFunction>> functions) {
lua_getfield(L, -1, table);
if (!lua_istable(L, -1)) {
lua_pop(L, 1);
lua_createtable(L, 0, static_cast<int>(functions.size()));
lua_pushvalue(L, -1);
lua_setfield(L, -3, table);
}
lua_setreadonly(L, -1, false);
for (const auto& [name, f] : functions) {
lua_pushcfunction(L, f, name);
lua_setfield(L, -2, name);
}
lua_setreadonly(L, -1, true);
lua_pop(L, 1);
}
} // namespace
void install_services_api(Plugin&, lua_State* L) {
into_table(L, "skater", {{"telemetry", telemetry}, {"goofy", goofy}, {"bone_cam", bone_cam}});
into_table(L, "input", {{"pad_bits", pad_bits}, {"key", key}, {"wheel", wheel}, {"game_buttons", game_buttons}, {"allowed", input_allowed}, {"focus", focus}});
into_table(L, "session", {{"spectate", spectate}, {"world", world}});
into_table(L, "camera", {{"free", free_camera}, {"pad_flight", pad_flight}});
into_table(L, "physics", {{"competition", competition}, {"stock_ready", stock_ready}, {"prepare", prepare}});
into_table(L, "tricks", {{"feed", trick_feed}, {"gesture", gesture}});
}
} // namespace dingosdk::plugins
+402
View File
@@ -0,0 +1,402 @@
#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 {
Plugin& plugin_of(lua_State* L) {
auto& plugin = *static_cast<Plugin*>(lua_callbacks(L)->userdata);
if (plugin.halted()) luaL_error(L, "the plugin was stopped");
return plugin;
}
void need(lua_State* L, Plugin& plugin, const char* permission, const char* what) {
if (!plugin.has(permission)) luaL_error(L, "%s needs the \"%s\" permission in reskate-plugin.json", what, permission);
}
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;
}
std::string checked_text(lua_State* L, int index, std::size_t most, const char* what) {
std::size_t length = 0;
const char* text = luaL_checklstring(L, index, &length);
if (length > most) luaL_error(L, "%s is longer than %d characters", what, static_cast<int>(most));
return std::string(text, length);
}
// 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); }
namespace {
// Adds the functions to the table at `field` of the table on top of the stack, making it when
// there is none yet (a table made read-only earlier in the install is opened for this).
void into(lua_State* L, const char* field, std::initializer_list<std::pair<const char*, lua_CFunction>> functions) {
lua_getfield(L, -1, field);
if (lua_isnil(L, -1)) {
lua_pop(L, 1);
lua_createtable(L, 0, static_cast<int>(functions.size()));
lua_pushvalue(L, -1);
const bool locked = lua_getreadonly(L, -3) != 0;
lua_setreadonly(L, -3, false);
lua_setfield(L, -3, field);
lua_setreadonly(L, -2, locked);
}
lua_setreadonly(L, -1, false);
for (const auto& [name, function] : functions) {
lua_pushcfunction(L, function, name);
lua_setfield(L, -2, name);
}
lua_setreadonly(L, -1, true);
lua_pop(L, 1);
}
} // namespace
void KitApi::install(Plugin&, lua_State* L) {
into(L, "throwdowns", {{"ui", ui}});
lua_getfield(L, -1, "throwdowns");
into(L, "flag", {{"begin", flag_begin}, {"state", flag_state}, {"leave", flag_leave}});
lua_pop(L, 1);
into(L, "files", {{"list", files_list}, {"read", files_read}, {"write", files_write}, {"remove", files_remove}});
into(L, "ui", {{"dialog", dialog}, {"close_dialog", close_dialog}});
into(L, "skater", {{"push", push}, {"push_wait", push_wait}, {"push_reset", push_reset}, {"hold", hold}});
}
}
+1 -1
View File
@@ -520,7 +520,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;
}
+151 -63
View File
@@ -254,8 +254,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 -------------------------------------------
@@ -374,6 +380,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);
@@ -1054,9 +1173,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));
@@ -1073,35 +1194,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");
@@ -1113,10 +1205,31 @@ struct WorldApi {
mode.art = text_field(L, 1, "art");
mode.flag = text_field(L, 1, "flag");
mode.replaces = text_field(L, 1, "replaces");
if (!mode.replaces.empty() && mode.replaces != "skate" && mode.replaces != "spot_battle" && mode.replaces != "jam")
luaL_argerror(L, 1, "replaces is \"skate\", \"spot_battle\" or \"jam\"");
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);
@@ -1126,27 +1239,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) {
@@ -1155,12 +1247,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");
@@ -1490,6 +1576,8 @@ void WorldApi::install(Plugin& plugin, lua_State* L) {
into(L, "hud", {{"hide", hide_hud}, {"corner", hud_corner}});
into(L, "time", {{"slow", slow}, {"speed", speed}});
into(L, "objects", {{"catalog", catalog}, {"list", objects}, {"place", place}, {"move", move}, {"remove", remove}});
into(L, "entities", {{"set", entities_set}, {"remove", entities_remove}, {"clear", entities_clear}, {"status", entities_status},
{"ready", entities_ready}});
into(L, "world", {{"set_time", set_time}, {"set_control", set_control}, {"set_park", set_park}});
into(L, "travel", {{"locations", locations}, {"levels", levels}, {"go", go}, {"load", load}});
into(L, "ai", {{"spawn", ai_spawn}, {"clear", ai_clear}, {"count", ai_count}});
@@ -1502,7 +1590,7 @@ void WorldApi::install(Plugin& plugin, lua_State* L) {
into(L, "audio", {{"play", audio_play}, {"set", audio_set}, {"stop", audio_stop}, {"playing", audio_playing}});
into(L, "server", {{"kick", server_kick}, {"say", server_say}, {"tell", server_tell}, {"move", server_move}});
into(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}});
into(L, "music", {{"current", current}, {"play", play}, {"pause", pause}, {"next", next}, {"previous", previous},
{"game", game_current}, {"game_next", game_next}});
into(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() {
+147 -29
View File
@@ -1,6 +1,8 @@
#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 <map>
@@ -25,6 +27,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,36 +190,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 replaces; // the stock throwdown whose place it takes: "skate", "spot_battle", "jam" or empty
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).
// In a session the flag is offered to the other players with the same plugin mode: the host hears
// "joined" and "left" (`player`); a player in another's flag hears "joined" and "left" (themselves),
// "closed" (taken down) and "start", whose `players` (the leader first) also come to the host's.
struct ThrowdownModeNews {
std::string mode, action, row;
int value{};
std::map<std::string, int> settings;
std::optional<Vec3> at;
float heading{}; // degrees
bool host = true;
std::uint64_t leader{}, player{};
std::vector<std::uint64_t> players;
// 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{};
@@ -222,6 +263,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).
@@ -258,9 +310,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.
+283 -25
View File
@@ -22,7 +22,7 @@
#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 +37,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 +72,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 +143,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") {
@@ -392,11 +420,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;
@@ -405,31 +428,138 @@ public:
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;
news.host = n.host;
news.leader = n.leader;
news.player = n.player;
news.players = std::move(n.players);
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});
@@ -575,6 +705,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.
@@ -592,7 +834,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";
@@ -659,13 +903,26 @@ 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, {});
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();
@@ -693,6 +950,7 @@ 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.
+23 -24
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,32 +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;
}
data["host"] = news.host;
if (news.leader) data["leader"] = std::to_string(news.leader);
if (news.player) data["player"] = std::to_string(news.player);
if (!news.players.empty()) {
auto players = Json::array();
for (const auto who : news.players) players.push_back(std::to_string(who));
data["players"] = std::move(players);
}
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;
+10 -3
View File
@@ -7,13 +7,20 @@
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). A manifest's "resident" list is the merge's
// (Engine/Vfs/mod_list.h ResidentAssets), not the plugin host's.
constexpr std::array<std::string_view, 44> 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"};
}
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>
+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
@@ -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,64 +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");
bad = one_up();
bad = duel();
bad.replaces = "bowling";
check(!custom_throwdowns::set_mode(bad).empty(), "replaces names a stock throwdown");
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.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; };
@@ -92,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) {
@@ -8,12 +8,16 @@
#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 {
@@ -28,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 {
@@ -57,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;
@@ -167,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};
@@ -222,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);
@@ -824,87 +883,24 @@ void plugin_host_flow() {
end_session();
}
// A plugin throwdown mode's flag (custom_modes.h): offered as that mode's, shown only where the
// plugin has the mode too, held open on every copy, and its joins, leaves and start reach the
// plugins instead of starting a Spot Battle.
void plugin_mode_flow() {
// 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 = "smokey"; mode.id = "oneup"; mode.name = "1-UP"; mode.flag = "oneup"; mode.players = 3;
mode.plugin = "demo"; mode.id = "duel"; mode.name = "Duel"; mode.flag = "duel"; mode.players = 3;
check(set_mode(mode).empty(), "The test mode registers");
(void)take("smokey");
// Hosting: the flag is offered as the mode's, with an offer older versions do not show.
{
const ThrowdownRelayInput in{me, {{other, 4}}, true, 81};
tick(in);
ThrowdownLocalAction exited{Local::exited};
exited.series = "SpotBattle"; exited.mmid = 0x61; exited.placement = {9}; exited.settings = {8};
exited.mode = "smokey/oneup"; exited.flag = "oneup"; exited.mode_players = 2;
throwdown_relay_local(std::move(exited));
auto out = tick(in);
check(out.size() == 2 && out[0].kind == Kind::mode && out[0].challenge == "smokey/oneup" && out[0].series == "oneup" &&
out[0].value == 2 && out[1].kind == Kind::offer && out[1].series == "ReSkateMode",
"A mode's flag was not offered as that mode's");
receive(other, make(Kind::join, me, 0x61));
tick(in);
const auto *add = find("add");
check(add && add->id == 0x304 && add->mmid == 0x61, "A join did not enter the flag's queue");
auto news = take("smokey");
check(news.size() == 1 && news[0].action == "joined" && news[0].host && news[0].player == other,
"The host's plugin did not hear who joined");
check(throwdown_relay_mode_players(0x61) == std::vector<std::uint64_t>{me, other}, "The flag's players were not published");
local(Local::force_started, 0x61);
out = tick(in);
check(out.size() == 1 && out[0].kind == Kind::start && out[0].order == std::vector<std::uint64_t>{me, other},
"The host's Start did not reach the other players");
check(throwdown_relay_mode_players(0x61).empty(), "The flag was still up after Start");
end_session();
}
// Another player's flag, seen where the mode is: held open with the mode's flag.
{
calls.clear();
const ThrowdownRelayInput in{me, {{leader, 3}, {other, 5}}, true, 82};
tick(in);
auto tag = make(Kind::mode, leader, 0x71);
tag.challenge = "smokey/oneup"; tag.series = "oneup"; tag.value = 3;
receive(leader, tag);
receive(leader, offer(leader, 0x71, "ReSkateMode"));
tick(in);
const auto *spawn = find("spawn");
check(spawn && spawn->series == "SpotBattle" && last_instance == "ReSkate.oneup" && last_capacity == 10,
"A mode's flag was not shown as the mode's, held open");
throwdown_relay_spawned(last_token, 0x7777, 10);
tick(in);
local(Local::joined, 0x7777);
auto out = tick(in);
check(out.size() == 1 && out[0].kind == Kind::join, "Joining a mode's flag was not reported to its leader");
auto news = take("smokey");
check(news.size() == 1 && news[0].action == "joined" && !news[0].host && news[0].leader == leader,
"The plugin did not hear the local player joined");
auto start = make(Kind::start, leader, 0x71);
start.order = {leader, me};
receive(leader, start);
tick(in);
check(!find("start") && find("destroy"), "A mode's flag started a Spot Battle instead of going");
news = take("smokey");
check(news.size() == 1 && news[0].action == "start" && news[0].players == std::vector<std::uint64_t>{leader, me},
"The plugin did not hear who plays");
end_session();
}
// Where the plugin does not have that mode, nothing shows.
{
calls.clear();
const ThrowdownRelayInput in{me, {{leader, 3}}, true, 83};
tick(in);
auto tag = make(Kind::mode, leader, 0x72);
tag.challenge = "elsewhere/race"; tag.value = 4;
receive(leader, tag);
receive(leader, offer(leader, 0x72, "ReSkateMode"));
tick(in);
check(!find("spawn"), "A mode this player does not have was shown");
end_session();
}
remove_plugin("smokey");
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() {
@@ -923,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.
@@ -947,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();
@@ -958,8 +1058,9 @@ int main() {
beacon_flow();
level_change_flow();
plugin_host_flow();
plugin_mode_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 and plugin mode 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 -85
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,42 +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";
if (!mode.replaces.empty() && mode.replaces != "skate" && mode.replaces != "spot_battle" && mode.replaces != "jam")
return "replaces is \"skate\", \"spot_battle\" or \"jam\"";
// "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);
@@ -81,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();
@@ -96,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
+18 -48
View File
@@ -1,59 +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 throwdown whose place on the picker it takes: "skate",
// "spot_battle" or "jam"; empty: after them.
// 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.
// In a session the flag is offered to the other players who have the mode
// (throwdown_relay.cpp). The host hears joined and left (`player`); a player in
// another's flag hears joined and left (themselves), closed (it was taken down)
// and start. start names who plays, `players`, the leader first.
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
bool host = true; // the local player's own flag, else `leader`'s
std::uint64_t leader{}, player{}; // Steam IDs
std::vector<std::uint64_t> players;
// 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.
@@ -64,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
-322
View File
@@ -1,322 +0,0 @@
#include "custom_placement.h"
#include "custom_modes.h"
#include "throwdown_lab.h"
#include "throwdown_relay.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;
if (p.mmid && p.step == Step::waiting) {
ThrowdownLocalAction closed{ThrowdownLocalAction::Kind::destroy_requested};
closed.mmid = p.mmid;
throwdown_relay_local(std::move(closed));
}
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{};
}
std::optional<Offer> flag_offer() {
auto& p = placement();
std::string key;
std::uint32_t mmid{};
{
std::lock_guard lock(p.mutex);
if ((p.step != Step::waiting && p.step != Step::closing) || !p.at || !p.mmid || p.cancelling) return std::nullopt;
key = p.key;
mmid = p.mmid;
}
const auto mode = find(key);
if (!mode) return std::nullopt;
const auto values = settings(key);
const auto players = values.find("players");
return Offer{key, mode->flag, static_cast<std::uint32_t>(players == values.end() ? mode->players : players->second), mmid};
}
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"); }
if (!p.key.empty() && !p.told) {
p.told = true;
custom_throwdowns::News news;
news.action = "start";
news.host = true;
news.players = throwdown_relay_mode_players(p.mmid);
if (!news.players.empty()) news.leader = news.players.front();
post(p.key, std::move(news));
}
throwdown_relay_local({ThrowdownLocalAction::Kind::force_started, {}, p.mmid});
// 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
-59
View File
@@ -1,59 +0,0 @@
#pragma once
#include <array>
#include <cstdint>
#include <optional>
#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();
// The flag that is down, as the other players are offered it: its mode, flag and player count.
struct Offer {
std::string key, flag;
std::uint32_t players{}, mmid{};
};
std::optional<Offer> flag_offer();
// 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;
}
};
}
@@ -0,0 +1,50 @@
#pragma once
#include <array>
#include <cstdint>
#include <cstring>
#include <span>
#include <string_view>
namespace dingosdk::multiplayer {
inline bool flag_preview_asset_name(std::string_view name) {
constexpr std::string_view stock="ecs/activities/throwdowns/spotbattlethrowdownerinteractablevalidator_ecsprefab";
if(name.size()!=stock.size())return false;
for(std::size_t i=0;i<name.size();++i) {
const auto c=name[i]>='A' && name[i]<='Z'?name[i]+('a'-'A'):name[i];
if(c!=stock[i])return false;
}
return true;
}
inline bool flag_preview_graph(std::uint32_t hash,const std::array<std::uint32_t,10>& layout) {
struct Graph {std::uint32_t hash;std::array<std::uint32_t,10> layout;};
constexpr std::array graphs{
Graph{0x73fd87ef,{80,832,1221,601,6,0,4,655395,4456459,16844800}},
Graph{0x08a3b8c5,{80,976,1466,756,6,0,4,655409,4456459,16844800}},
Graph{0x05df4a18,{80,832,1221,601,6,0,4,655395,4456459,16844800}},
Graph{0x7a9c4313,{80,720,1205,561,6,0,4,655391,4456459,16844800}},
Graph{0xbb2b2cdd,{80,832,1221,601,6,0,4,655395,4456459,16844800}},
Graph{0x67122a51,{80,856,1221,601,6,0,4,655395,4456459,16844800}},
Graph{0xdba685be,{80,720,1205,561,6,0,4,655391,4456459,16844800}}};
for(const auto& graph:graphs)if(hash==graph.hash && layout==graph.layout)return true;
return false;
}
// Own only an interpreter invocation's pointer, never the asset's shared page.
// Nested invocations and exceptional exits restore the exact previous page.
class FlagPreviewPage {
std::uintptr_t* slot_{};
std::uintptr_t original_{};
alignas(16) std::array<std::byte,1024> bytes_;
public:
void prepare(std::span<const std::byte> page,std::size_t offset,std::uintptr_t prefab) {
if(page.size()>bytes_.size() || offset+sizeof(prefab)>page.size())return;
std::memcpy(bytes_.data(),page.data(),page.size());
std::memcpy(bytes_.data()+offset,&prefab,sizeof(prefab));
}
void publish(std::uintptr_t& slot) {
slot_=&slot;original_=slot;slot=reinterpret_cast<std::uintptr_t>(bytes_.data());
}
~FlagPreviewPage() {if(slot_)*slot_=original_;}
FlagPreviewPage()=default;
FlagPreviewPage(const FlagPreviewPage&)=delete;
};
}
@@ -0,0 +1,507 @@
#include "native_flag_session.h"
#include "native_flag_spawn.h"
#include "native_throwdowns.h"
#include "throwdown_kit.h"
#include "throwdown_lab.h"
#include "throwdown_relay.h"
#include "Extension/UI/NativeMenu/native_menu_data.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Game/Multiplayer/session_tools.h"
#include <Windows.h>
#include <algorithm>
#include <cmath>
#include <format>
#include <mutex>
#include <optional>
#include <vector>
namespace dingosdk::multiplayer::native_flag {
namespace kit = throwdowns::kit;
namespace {
enum class Step { idle, open, setup, flag, placing, closing, waiting, cleanup };
struct Request {
std::string mode;
std::uint32_t game{};
kit::flag::Options options;
std::uint64_t world{};
};
// Events for the owner's plugin, posted once the placement lock is released.
struct Note {
std::string mode, action;
std::vector<std::string> args;
};
struct Placement {
std::mutex mutex;
Step step{};
std::string mode;
std::uint32_t game{};
kit::flag::Options options;
std::uint64_t world{}, until{}, changed{}, sent{};
unsigned seconds{}, players{};
std::uint32_t mmid{};
std::vector<std::uint32_t> custom_mmids;
bool created{}, setup_seen{}, flag_seen{}, exited{}, cancelling{}, destroy_queued{}, destroyed{};
bool handed_off{}, match_seen{};
bool show_setup{}, native_entry{}, confirmed{};
bool owner_left{}; // kit::flag::leave: the plugin knows; no flag_left
bool adopted{}; // a guest's registration (kit::flag::adopt)
std::optional<std::array<float,3>> spawn;
Facing facing=default_facing;
std::optional<Request> pending; // opens once the previous registration's teardown finishes
std::string status;
std::vector<Note> notes;
};
Placement& placement() { static auto* p = new Placement; return *p; }
// Offline the native level generation; in a session the relay's world, which follows the transport.
std::uint64_t current_world() {
const auto world = multiplayer_session_active() ? throwdown_relay_world() : 0;
return world ? world : native_throwdown_world();
}
void note(Placement& p, std::string action, std::vector<std::string> args = {}) {
if (!p.mode.empty()) p.notes.push_back({p.mode, std::move(action), std::move(args)});
}
void flush() noexcept {
std::vector<Note> notes;
{
auto& p = placement();
std::lock_guard lock(p.mutex);
notes.swap(p.notes);
}
for (auto& n : notes) try { kit::post(n.mode, std::move(n.action), std::move(n.args)); } catch (...) {}
}
struct FlushOnExit { ~FlushOnExit() { flush(); } };
void reset(Placement& p) {
p.step = Step::idle; p.mode.clear(); p.game = 0; p.options = {};
p.spawn.reset(); p.facing = default_facing; 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.match_seen = false;
p.show_setup = p.native_entry = p.confirmed = p.owner_left = p.adopted = false;
}
// The registration ends; its plugin hears flag_left unless it ended it itself.
void retire(Placement& p) {
if (p.step != Step::idle && !p.owner_left) note(p, "flag_left", {std::to_string(p.game)});
reset(p);
}
void to_cleanup(Placement& p, std::uint64_t now) {
// sent belongs to the previous navigation step. A newly cancelled flag still needs its
// Exit, but repeated leave notifications must not queue that same Exit again.
if (p.step != Step::cleanup) p.sent = 0;
p.cancelling = true; p.step = Step::cleanup; p.until = now + 15000;
}
void open_request(Placement& p, const Request& r, std::uint64_t now) {
reset(p);
p.step = Step::open; p.mode = r.mode; p.game = r.game; p.options = r.options; p.world = r.world;
p.show_setup = p.native_entry = r.options.setup_page;
p.seconds = r.options.seconds; p.players = r.options.players;
p.changed = now; p.until = now + 15000;
p.status = r.options.setup_page ? "Opening the Throwdown setup..." : "Opening flag placement...";
}
bool waiting_card(const Placement& p) {
return p.step == Step::waiting && p.handed_off && p.spawn && p.mmid;
}
// Documents name their mode by the plugin's own id or the registered "<plugin>/<id>".
bool same_mode(std::string_view registered, std::string_view named) {
if (named.empty() || registered == named) return true;
return registered.size() > named.size() && registered.ends_with(named) && registered[registered.size() - named.size() - 1] == '/';
}
bool shows_waiting(const kit::Documents& d, std::string_view mode) {
return d.waiting && same_mode(mode, d.waiting->mode);
}
// 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, std::uint32_t consumed = 0) {
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);
const auto pending = read<std::uint32_t>(c.address(current));
if (pending && pending != consumed) 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, "Native flag: navigation {}.", name);
return true;
}
} // namespace
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 running match outlives its temporary native flag registration. Once that
// registration is destroyed, stock challenges own their events.
return p.step != Step::idle && (p.step != Step::waiting || p.mmid != 0);
}
bool registration_waiting() noexcept {
auto& p = placement(); std::lock_guard lock(p.mutex);
return waiting_card(p);
}
bool registration_retiring() noexcept {
auto& p = placement(); std::lock_guard lock(p.mutex);
return p.step == Step::cleanup;
}
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 hosting() noexcept {
auto& p = placement(); std::lock_guard lock(p.mutex);
return p.step != Step::idle && !p.adopted;
}
bool setup_active() noexcept {
auto& p = placement(); std::lock_guard lock(p.mutex);
return p.show_setup && (p.step == Step::open || p.step == Step::setup);
}
std::string mode() {
auto& p = placement(); std::lock_guard lock(p.mutex);
return p.mode;
}
std::uint32_t game() noexcept {
auto& p = placement(); std::lock_guard lock(p.mutex);
return p.game;
}
float cursor_height() noexcept {
auto& p = placement(); std::lock_guard lock(p.mutex);
return p.options.cursor_height > 0 && p.flag_seen && !p.cancelling && !p.spawn &&
(p.step == Step::flag || p.step == Step::placing) ? p.options.cursor_height : 0.f;
}
Limits limits() noexcept {
auto& p = placement(); std::lock_guard lock(p.mutex);
return p.step != Step::idle ? Limits{p.seconds, p.players} : Limits{};
}
std::string status() {
auto& p = placement(); std::lock_guard lock(p.mutex);
return p.status;
}
void set_setup_limits(unsigned seconds, unsigned players) noexcept {
auto& p = placement(); std::lock_guard lock(p.mutex);
if (p.step == Step::setup && seconds >= 1 && seconds <= 3600 && players >= 1 && players <= 10) { p.seconds = seconds; p.players = players; }
}
void retire_unconfirmed_setup() noexcept {
{
auto& p = placement(); std::lock_guard lock(p.mutex);
// Called by a verified fresh picker callback, after returning to Throwdowns. A
// cancelled setup with no registered flag cannot keep an unacknowledged Exit alive and
// close or block the next page.
if ((p.step != Step::open && p.step != Step::setup && p.step != Step::cleanup) ||
!p.native_entry || p.flag_seen || p.mmid || p.handed_off) return;
if (p.created) release_native_flag_placeholder();
retire(p); p.pending.reset(); p.status.clear();
}
flush();
}
bool start() noexcept {
std::string mode; std::uint32_t game{};
{
auto& p = placement(); std::lock_guard lock(p.mutex);
if (!waiting_card(p)) return false;
mode = p.mode; game = p.game;
}
try { kit::post(mode, "start", {std::to_string(game)}); } catch (...) {}
logging::log(logging::Level::info, logging::Channel::ui, "Native flag: {} Start requested on the waiting card.", mode);
return true;
}
bool leave() noexcept {
{
auto& p = placement(); std::lock_guard lock(p.mutex);
if (p.step == Step::idle) return false;
note(p, "leave", {std::to_string(p.game)});
p.pending.reset(); // Quit/Cancel must not reopen a queued replacement
to_cleanup(p, GetTickCount64());
}
flush();
return true;
}
bool quit_match() noexcept {
try {
const auto active = kit::active();
if (!active) return false;
auto mode = active->waiting ? active->waiting->mode : active->setup ? active->setup->mode : std::string{};
if (mode.empty()) mode = native_flag::mode();
if (mode.empty()) mode = kit::current();
kit::post_to(active->plugin, "quit", std::move(mode));
return true;
} catch (...) { return false; }
}
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) { // Same setup OnEnd
// Back and Confirm both leave this native state. Stop adapting its UI immediately;
// a successful Confirm enters the flag update below. Ignore a previous entry's
// delayed OnEnd 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; // the plugin closed the native setup without creating a flag
// Spot Battle's own placement update runs after its validator/spawn initialization.
// Its Confirm goes directly here; no S.K.A.T.E. area state, skip predicate, or
// synthetic navigation is involved.
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;
if (p.native_entry && !p.confirmed) {
p.confirmed = true;
note(p, "setup_confirm", {std::to_string(p.players), std::to_string(p.seconds)});
}
logging::write(logging::Level::info, logging::Channel::ui, "Native flag: entered native Spot Battle flag placement.");
}
}
flush();
}
void observe_queue(std::uint32_t mmid) noexcept {
auto& p = placement(); std::lock_guard lock(p.mutex);
if (!mmid || p.step == Step::idle) return;
if (std::find(p.custom_mmids.begin(), p.custom_mmids.end(), mmid) == p.custom_mmids.end()) {
if (p.custom_mmids.size() == 64) p.custom_mmids.erase(p.custom_mmids.begin());
p.custom_mmids.push_back(mmid);
}
}
bool known_queue(std::uint32_t mmid) noexcept {
auto& p = placement(); std::lock_guard lock(p.mutex);
return mmid && std::find(p.custom_mmids.begin(), p.custom_mmids.end(), mmid) != p.custom_mmids.end();
}
void observe_created() noexcept {
auto& p = placement(); std::lock_guard lock(p.mutex);
if (p.step == Step::setup) p.created = true;
}
void observe_position(std::uint32_t mmid, const std::array<float,3>& spawn, const std::array<float,16>* transform) 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::all_of(spawn.begin(), spawn.end(), [](float n) { return std::isfinite(n) && std::abs(n) < 100000; })) return;
if (transform) {
const auto facing = transform_facing(*transform);
if (!valid_facing(facing)) return;
p.facing = facing;
}
p.spawn = spawn; p.mmid = mmid; p.step = Step::closing; p.until = GetTickCount64() + 15000;
p.status = "Saving the starting flag...";
logging::log(logging::Level::info, logging::Channel::ui,
"Native flag: {} accepted spawn ({:.2f}, {:.2f}, {:.2f}), facing {:.2f} degrees, native queue {:#x}.",
p.mode, spawn[0], spawn[1], spawn[2], facing_yaw(p.facing), 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.spawn;
p.step = p.cancelling ? Step::cleanup : Step::waiting; p.changed = GetTickCount64(); p.until = p.changed + 15000;
if (p.cancelling) p.status = "Flag placement cancelled.";
}
void observe_destroyed(std::uint32_t mmid) noexcept {
auto& p = placement(); std::lock_guard lock(p.mutex);
if (p.step == Step::waiting && p.handed_off && p.destroy_queued && p.mmid == mmid) {
// The registration is a lobby flag, not the running match. Once its transform is
// saved, the native teardown removes the pole, ground decal and waiting notification
// without ending the match.
p.mmid = 0; p.destroyed = true; p.changed = GetTickCount64(); return;
}
if (p.step == Step::cleanup && p.mmid == mmid) { p.destroyed = true; p.changed = 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) {
if (p.created) release_native_flag_placeholder();
retire(p); p.status = "Flag placement ended because the map changed.";
}
p.pending.reset(); p.custom_mmids.clear();
}
flush();
}
void tick(const menu_data::Context& c) {
const auto now = GetTickCount64();
const auto world = current_world();
auto& p = placement();
std::string owner;
{
std::lock_guard lock(p.mutex);
if (p.step == Step::idle) return;
owner = p.mode;
}
// The owner's documents decide when its match started and ended (copies; read unlocked).
std::optional<kit::Documents> docs;
try { docs = kit::documents_for(owner); } catch (...) {}
FlushOnExit flush_notes;
std::string command; Step next{}; std::uint32_t destroy{};
bool release{};
{
std::lock_guard lock(p.mutex);
if (p.step == Step::idle || p.mode != owner) return;
// The same native setup survives connecting to a session. Offline level IDs can change
// to transport IDs before the flag is placed; keep ownership and bind the eventual spawn
// to that session. Real level teardown calls abandon before unloading.
if (p.native_entry && !p.handed_off && !p.spawn && p.step != Step::cleanup) {
if (world) p.world = world;
else if (now < p.until && p.step != Step::open) return; // setup can open before capture initializes
} else if (world != p.world || !world) {
if (p.created) release_native_flag_placeholder();
logging::log(logging::Level::warning, logging::Channel::ui,
"Native flag: session changed during step {}, world {} -> {}.", static_cast<unsigned>(p.step), p.world, world);
retire(p); p.pending.reset(); p.status = "Flag placement ended because the session changed.";
return;
}
if (p.step == Step::flag && p.flag_seen) { p.step = Step::placing; p.until = now + 300000; p.status = "Place the starting flag."; }
if (p.step == Step::waiting) {
// Keep the native registration alive: it owns the world flag, waiting notification,
// and Start/Leave input prompts.
if (!p.handed_off && p.spawn) {
const auto& at = *p.spawn;
note(p, "flag_placed", {std::to_string(p.game), std::format("{}", at[0]), std::format("{}", at[1]),
std::format("{}", at[2]), std::format("{}", facing_yaw(p.facing))});
p.handed_off = true; p.status.clear(); release = true;
}
// The registration lives while its plugin publishes the waiting card or a HUD: an editor
// over the placed flag leaves the card out for a while and shows its own HUD instead.
const bool waiting = docs && shows_waiting(*docs, p.mode);
const bool playing = docs && docs->hud.active;
const bool editing = playing && docs->hud.editor.present;
if (waiting || playing) p.match_seen = true;
// The waiting card gave way to the match HUD (not an editor's): the lobby flag goes, the
// match stays.
if (p.handed_off && p.match_seen && p.mmid && !p.destroy_queued && playing && !editing && !waiting) destroy = p.mmid;
if ((p.match_seen && !waiting && !playing) || (p.handed_off && !p.match_seen && now >= p.until)) to_cleanup(p, now);
}
if (p.step == Step::setup && p.setup_seen && !p.show_setup && p.native_entry && now >= p.until) {
// Back already performed the native navigation. Do not send an exit that could
// close the next stock challenge's setup.
release = p.created;
note(p, "setup_back", {std::to_string(p.game)});
retire(p); p.status.clear();
if (release) release_native_flag_placeholder();
return;
}
if ((p.step == Step::open || p.step == Step::setup) && !p.created && !p.setup_seen && now >= p.until) {
retire(p); p.status = "The setup did not open. Select the mode to try again.";
return; // no native queue was created; do not close another menu
}
if (now >= p.until && p.step != Step::cleanup && p.step != Step::waiting) {
p.spawn.reset(); p.status = "Flag placement timed out. Open the mode to try again.";
to_cleanup(p, now);
}
if (p.step == Step::open) { command = "SpotBattle"; next = Step::setup; }
// Native Confirm owns this transition for a setup page. OnEnd also fires on Back, so it
// must never become an automatic placement.
else if (p.step == Step::setup && p.created && p.setup_seen && !p.show_setup && !p.native_entry) { command = "ThrowdownPlacement"; next = Step::flag; }
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)) {
release = p.created;
const auto requested = std::move(p.pending);
retire(p); p.pending.reset(); p.status.clear();
if (requested && requested->world == world) open_request(p, *requested, now);
} else if (now >= p.until) {
p.status = "Could not close the flag placement. Back out of the native Throwdown before retrying.";
}
}
}
if (release) release_native_flag_placeholder();
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 expected step before publishing,
// without holding this mutex 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::native_flag
// ---- kit::flag (throwdown_kit.h) -------------------------------------------------------------------
namespace dingosdk::throwdowns::kit::flag {
bool begin(std::string_view mode, std::uint32_t game, const Options& options) {
using namespace multiplayer::native_flag;
if (mode.empty() || !game || options.players < 1 || options.players > 10 || options.seconds < 1 || options.seconds > 3600 ||
!std::isfinite(options.cursor_height) || options.cursor_height < 0 || options.cursor_height > 100000) return false;
const auto world = current_world();
if (!world) return false;
auto& p = placement();
bool accepted{};
{
std::lock_guard lock(p.mutex);
const auto now = GetTickCount64();
Request request{std::string(mode), game, options, world};
if (!options.setup_page && p.step == Step::waiting && p.mode == mode && p.game == game && p.world == world &&
p.handed_off && p.mmid && !p.destroy_queued) {
// Re-placing this mode's start flag needs a new native picker after the old
// registration is destroyed. Never reuse its MMID.
p.pending = std::move(request); p.owner_left = true; to_cleanup(p, now);
accepted = true;
} else if (p.step == Step::cleanup) {
p.pending = std::move(request); // opens once the old flag is down
accepted = true;
} else if (p.step == Step::idle ||
((p.step == Step::open || p.step == Step::setup) && p.native_entry && !p.flag_seen && !p.mmid)) {
// A fresh card can replace an abandoned setup even when the state machine omitted
// OnEnd. Never replace an actual placed flag.
if (p.step != Step::idle) {
if (p.created) multiplayer::release_native_flag_placeholder();
retire(p);
}
open_request(p, request, now);
accepted = true;
}
}
flush();
if (accepted) multiplayer::prepare_throwdown_injection(); // prewarm cleanup before the player places the flag
return accepted;
}
State state() noexcept {
using namespace multiplayer::native_flag;
auto& p = placement(); std::lock_guard lock(p.mutex);
State s;
s.busy = p.step != Step::idle;
s.owned = s.busy && (p.step != Step::waiting || p.mmid != 0);
s.waiting = waiting_card(p);
s.placing = p.step == Step::flag || p.step == Step::placing || p.step == Step::closing;
try { s.mode = p.mode; } catch (...) {}
s.game = p.game;
return s;
}
void leave() noexcept {
using namespace multiplayer::native_flag;
auto& p = placement(); std::lock_guard lock(p.mutex);
p.pending.reset();
if (p.step == Step::idle) return;
p.owner_left = true;
to_cleanup(p, GetTickCount64());
}
bool adopt(std::string_view mode, std::uint32_t game, std::uint32_t mmid, const Vec3& at, float yaw, unsigned seconds, unsigned capacity) {
using namespace multiplayer::native_flag;
try {
const auto world = current_world();
if (mode.empty() || !world || !game || !mmid || multiplayer::local_throwdown_event_active() || !std::isfinite(yaw) ||
!std::all_of(at.begin(), at.end(), [](float n) { return std::isfinite(n) && std::abs(n) < 100000.f; })) return false;
auto& p = placement(); std::lock_guard lock(p.mutex);
if (p.step != Step::idle) return p.step == Step::waiting && p.world == world && p.mode == mode && p.game == game && p.mmid == mmid;
reset(p);
p.step = Step::waiting; p.mode = std::string(mode); p.world = world; p.game = game; p.mmid = mmid;
p.spawn = at; p.facing = yaw_facing(yaw);
p.seconds = std::clamp(seconds, 1U, 3600U); p.players = std::clamp(capacity, 1U, 10U);
// The guest's plugin hears join, not flag_placed, and shows its waiting card from then.
p.handed_off = p.exited = p.adopted = true; p.match_seen = false;
p.changed = GetTickCount64(); p.until = p.changed + 15000; p.status.clear();
if (std::find(p.custom_mmids.begin(), p.custom_mmids.end(), mmid) == p.custom_mmids.end()) p.custom_mmids.push_back(mmid);
multiplayer::release_native_flag_placeholder(); // only this donor queue's activity-presence lease; no actual event exists
return true;
} catch (...) { return false; }
}
} // namespace dingosdk::throwdowns::kit::flag
@@ -0,0 +1,61 @@
#pragma once
#include <array>
#include <cstdint>
#include <string>
// The native flag registration behind kit::flag (throwdown_kit.h): one plugin mode at a time
// drives the stock Spot Battle setup page, native flag placement and the native waiting card.
// These are the hooks the native layers feed it (throwdown_lab, native_throwdowns, the native
// menu); nothing here knows a particular mode.
namespace dingosdk::multiplayer::menu_data { class Context; }
namespace dingosdk::multiplayer::native_flag {
// ---- queries (any thread) -----------------------------------------------------------------------
// The setup page, flag placement or its teardown is under way (not the waiting card).
bool placement_active() noexcept;
// Includes the placed registration while its native waiting card owns the flag.
bool registration_owned() noexcept;
// Only a confirmed, handed-off flag owns the waiting card's Start/Leave.
bool registration_waiting() noexcept;
bool registration_retiring() noexcept;
bool owns_registration(std::uint32_t mmid) noexcept;
// The local player placed the registration's flag (it was not adopted through Join); true until
// the registration ends, through the match it started.
bool hosting() noexcept;
// The stock setup page is showing for the registration's mode.
bool setup_active() noexcept;
// The registration's mode ("<plugin>/<id>") and game; empty / 0 without one.
std::string mode();
std::uint32_t game() noexcept;
// > 0 while the placement cursor may rise this far (Options::cursor_height), else 0.
float cursor_height() noexcept;
struct Limits { unsigned seconds{}, players{}; };
// The registration's selected limits (the setup page's, once changed there).
Limits limits() noexcept;
std::string status();
// ---- what the native layers report ----------------------------------------------------------
// The setup page's Players / timer changed (posted to the plugin as setup_row by the page itself).
void set_setup_limits(unsigned seconds, unsigned players) noexcept;
// A fresh grid selection supersedes an unconfirmed setup. No placed flag is discarded.
void retire_unconfirmed_setup() noexcept;
// The waiting card's Start / Leave: posted to the owner's plugin as start / leave. Leave also
// ends the registration. False without a waiting registration.
bool start() noexcept;
bool leave() noexcept;
// The pause menu's / native Quit while a plugin match runs (kit::active()): its plugin hears
// quit. False when no plugin match is running.
bool quit_match() noexcept;
// The native setup navigation, placement teardown and the owner's documents. UI thread.
void tick(const menu_data::Context& context);
void observe_graph(std::uint32_t graph) noexcept;
void observe_created() noexcept;
// Keep custom queue identities through their asynchronous native teardown: late joins of those
// queues must not revive a stock Spot Battle notification.
void observe_queue(std::uint32_t mmid) noexcept;
bool known_queue(std::uint32_t mmid) noexcept;
void observe_position(std::uint32_t mmid, const std::array<float,3>& spawn, const std::array<float,16>* transform=nullptr) noexcept;
void observe_exit(bool cancelling) noexcept;
void observe_destroyed(std::uint32_t mmid) noexcept;
// The level unloads every native queue.
void abandon() noexcept;
}
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include <array>
#include <cmath>
#include <string>
#include <string_view>
// The pose a native flag placement chose, and the assets a plugin mode's flag uses.
namespace dingosdk::multiplayer::native_flag {
using Facing = std::array<float,9>;
inline constexpr Facing default_facing{1,0,0,0,1,0,0,0,1};
inline bool valid_facing(const Facing& facing) {
for(float n:facing)if(!std::isfinite(n) || std::abs(n)>1.01f)return false;
for(unsigned row=0;row<3;++row) {
float length{};for(unsigned i=0;i<3;++i)length+=facing[row*3+i]*facing[row*3+i];
if(std::abs(length-1.f)>.03f)return false;
for(unsigned other=0;other<row;++other) {
float dot{};for(unsigned i=0;i<3;++i)dot+=facing[row*3+i]*facing[other*3+i];
if(std::abs(dot)>.03f)return false;
}
}
const auto determinant=facing[0]*(facing[4]*facing[8]-facing[5]*facing[7])-
facing[1]*(facing[3]*facing[8]-facing[5]*facing[6])+facing[2]*(facing[3]*facing[7]-facing[4]*facing[6]);
return determinant>.97f;
}
inline std::array<float,16> spawn_transform(const std::array<float,3>& position,const Facing& facing) {
return {facing[0],facing[1],facing[2],0,facing[3],facing[4],facing[5],0,
facing[6],facing[7],facing[8],0,position[0],position[1],position[2],1};
}
inline Facing transform_facing(const std::array<float,16>& transform) {
return {transform[0],transform[1],transform[2],transform[4],transform[5],transform[6],transform[8],transform[9],transform[10]};
}
inline float facing_yaw(const Facing& facing) {return std::atan2(facing[6],facing[8])*180.f/3.14159265f;}
inline Facing yaw_facing(float degrees) {
const float radians=degrees*3.14159265f/180.f,co=std::cos(radians),si=std::sin(radians);
return {co,0,-si,0,1,0,si,0,co};
}
// CreateThrowdownQueueParams.SpawnPoint is XYZ plus a yaw in radians.
// TeleportLocation is only a Vec3; treating it as a transform discards the
// facing chosen in the native placement editor and leaves an identity spawn.
inline std::array<float,16> native_spawn_transform(const std::array<float,4>& spawn) {
const float co=std::cos(spawn[3]),si=std::sin(spawn[3]);
return spawn_transform({spawn[0],spawn[1],spawn[2]},Facing{co,0,-si,0,1,0,si,0,co});
}
// A mode's `flag` (custom_modes Mode::flag) is a short name ("duel": InstanceId ReSkate.duel,
// preview World/ReSkate/Throwdowns/Flags/duel_Placement) or the preview prefab's path, whose last
// segment less "_Placement" names the InstanceId. Empty: the stock Spot Battle flag and preview.
struct FlagAssets {
std::string instance, preview; // both empty: stock
};
inline FlagAssets flag_assets(std::string_view flag) {
if(flag.empty())return {};
if(flag.find('/')==std::string_view::npos)
return {"ReSkate."+std::string(flag),"World/ReSkate/Throwdowns/Flags/"+std::string(flag)+"_Placement"};
auto name=flag.substr(flag.rfind('/')+1);
constexpr std::string_view suffix="_Placement";
if(name.size()>suffix.size() && name.ends_with(suffix))name.remove_suffix(suffix.size());
return {name.empty()?std::string{}:"ReSkate."+std::string(name),std::string(flag)};
}
}
@@ -0,0 +1,151 @@
#pragma once
#include <atomic>
#include <cstdint>
#include <mutex>
namespace dingosdk::multiplayer {
// The retail bool getter guarantees AL only on its missing-player path;
// higher return-register bits can still contain the last compared player id.
constexpr bool native_activity_flag(std::uint64_t result) noexcept { return (result & 0xffu) != 0; }
// Flow_ActivitiesMenus/B05F997 closes Details when its native activity owner
// disappears. Custom matches have their own owner. Preserve every authored
// close/navigation reason; supply only the missing custom-owner alternative.
constexpr bool custom_details_presence_needed(bool owned_request,std::uint8_t selected,
std::uint8_t native_exit,std::uint8_t close_requested,std::uint8_t activity_present) noexcept {
return owned_request && selected==1 && native_exit==1 && close_requested==0 && activity_present==0;
}
// MP participant/UniqueId and the native PlayerId occupy different domains.
// The sole offline participant can be 2 while native PlayerId 0 is valid.
struct NativeActivityPlayerIds {
std::uint32_t participant{},menu_player{};
bool matches_participant(std::uint32_t id) const noexcept { return participant && participant==id; }
bool matches_menu_player(std::uint32_t id) const noexcept { return participant && menu_player==id; }
};
// Own the offline UIPlayerInfo flags from queue creation through the native
// activity's confirmed end. Destroying its registration flag at Start is not
// an end; removal of this player from this activity is.
class NativeThrowdownLifetime {
public:
bool active() const noexcept { return flags_.load(std::memory_order_acquire) != 0; }
bool host() const noexcept { return (flags_.load(std::memory_order_acquire) & hosting) != 0; }
bool has_activity() {
std::lock_guard lock(mutex_);
return activity_ != 0;
}
bool create(std::uint32_t player) {
std::lock_guard lock(mutex_);
completed_solo_ = 0;
menu_cleanup_ = false;
player_ = player;
activity_ = 0;
player_limit_ = 0;
return flags_.exchange(hosting, std::memory_order_acq_rel) == 0;
}
bool join(std::uint32_t player) {
std::lock_guard lock(mutex_);
const auto before = flags_.load(std::memory_order_relaxed);
if (!before) { player_ = player; activity_ = 0; player_limit_ = 0; completed_solo_ = 0; menu_cleanup_ = false; }
flags_.store(before | joined, std::memory_order_release);
return (before & joined) == 0;
}
void leave_queue() {
std::lock_guard lock(mutex_);
// An in-progress leave is a request. Wait for participant removal.
if (!activity_) flags_.fetch_and(~joined, std::memory_order_acq_rel);
}
// Called only after ThrowdownRegistration.QueueFilled created the event
// containing the local player. Other activity callbacks cannot bind it.
bool activity_started(std::uintptr_t activity, std::uint32_t player) {
std::lock_guard lock(mutex_);
if (!active() || !activity || !player || (player_ && player_ != player) ||
(activity_ && activity_ != activity)) return false;
player_ = player;
activity_ = activity;
return true;
}
void player_limit(std::uint32_t limit) {
std::lock_guard lock(mutex_);
// Only the host's final, stock Throwdown parameters set this. A
// temporary plugin flag picker and a guest's queue never acquire solo cleanup.
if (host() && !activity_) player_limit_ = limit;
}
std::uintptr_t solo_activity(std::uint32_t player) {
std::lock_guard lock(mutex_);
return host() && player_limit_ == 1 && player && player == player_ ? activity_ : 0;
}
bool participant_left(std::uintptr_t activity, std::uint32_t player) {
std::lock_guard lock(mutex_);
if (!activity_ || activity != activity_ || !player || player != player_) return false;
if (host() && player_limit_ == 1) { completed_solo_ = player_; menu_cleanup_ = true; }
return clear_locked();
}
// A native client EnterEnd also proves completion. LeaveInProgress is
// merely a request and must wait for the exact server participant removal.
bool ended() {
std::lock_guard lock(mutex_);
if (activity_ && host() && player_limit_ == 1) { completed_solo_ = player_; menu_cleanup_ = true; }
return clear_locked();
}
std::uint32_t take_completed_solo() {
std::lock_guard lock(mutex_);
const auto player = completed_solo_;
completed_solo_ = 0;
return player;
}
// The Toolbox can be created after the activity's client state cleared.
// Keep its independent pending-queue cleanup until that model is present.
bool menu_cleanup_pending() {
std::lock_guard lock(mutex_);
return menu_cleanup_ && !active();
}
void menu_cleaned() {
std::lock_guard lock(mutex_);
if (!active()) menu_cleanup_ = false;
}
bool clear() {
std::lock_guard lock(mutex_);
completed_solo_ = 0;
menu_cleanup_ = false;
return clear_locked();
}
private:
bool clear_locked() {
player_ = 0;
activity_ = 0;
player_limit_ = 0;
return flags_.exchange(0, std::memory_order_acq_rel) != 0;
}
static constexpr unsigned hosting = 1, joined = 2;
std::atomic<unsigned> flags_{};
std::mutex mutex_;
std::uint32_t player_{};
std::uint32_t player_limit_{};
std::uint32_t completed_solo_{};
bool menu_cleanup_{};
std::uintptr_t activity_{};
};
// The native UI also observes ClientPlayerVars.IsInActivity directly. Query
// adapters alone do not notify that observer. Borrow only that one verified
// client flag for a custom participant; never borrow a backend activity/group
// or MPActivityIdleState, and never restore into a replacement player/world.
class NativeActivityUiLease {
std::uintptr_t variables_{};
std::uint32_t player_{},group_{};
public:
bool owned() const noexcept { return variables_ != 0; }
template<class Write> void update(bool wanted,std::uintptr_t variables,std::uint32_t player,
std::uint32_t group,bool current,bool native_activity,Write&& write) {
if(owned() && (variables_!=variables || player_!=player || group_!=group || native_activity))
*this={}; // Retired identity or an actual native activity owns it.
if(owned() && !wanted) {
if(!current || write(false))*this={};
return;
}
if(wanted && !owned() && variables && player && !current && !native_activity && write(true)) {
variables_=variables;player_=player;group_=group;
}
}
};
}
+451 -54
View File
@@ -1,5 +1,11 @@
#include "native_throwdowns.h"
#include "custom_placement.h"
#include "native_throwdown_lifetime.h"
#include "native_flag_session.h"
#include "session_marker_actions.h"
#include "throwdown_kit.h"
#include "Extension/UI/NativeMenu/native_menu_data.h"
#include "Extension/UI/NativeMenu/throwdown_menu.h"
#include "Extension/UI/NativeMenu/native_hud_selection.h"
#include "grouped_number.h"
#include "throwdown_lab.h"
#include "throwdown_relay.h"
@@ -10,6 +16,7 @@
#include "Extension/Profile/runtime_internal.h"
#include "Extension/Progression/local_entitlement_trigger_runtime.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/supported_build.h"
#include "Engine/Game/Build/20260929/engine.h"
#include "Engine/Game/Build/20260929/native_party.h"
#include "Engine/Game/Build/20260929/native_throwdowns.h"
@@ -29,6 +36,7 @@ using Address = std::uintptr_t;
namespace engine = addr::engine;
namespace party = addr::native_party;
namespace throwdowns = addr::native_throwdowns;
namespace kit = dingosdk::throwdowns::kit;
constexpr std::uint32_t client_realm = 0xbf0f9789, server_realm = 0x98be5555;
struct State {
Address base{};
@@ -38,10 +46,21 @@ struct State {
bool (*others_allowed)(){};
std::uint64_t (*player_info)(std::uint32_t){};
std::uint64_t (*service_player_info)(Address, std::uint32_t){};
std::uint64_t (*player_in_activity)(std::uint32_t){};
void (*send_event)(Address, Address, const Address*){};
Address (*parameters)(Address, Address){};
std::atomic<bool> throwdown_active{}; // hosting: created a throwdown
std::atomic<bool> throwdown_joined{}; // in one someone else placed
NativeThrowdownLifetime lifetime;
NativeActivityUiLease custom_ui_presence;
std::mutex custom_ui_presence_mutex;
std::string custom_ui_diagnostic;
std::uint64_t custom_ui_diagnostic_at{};
std::atomic<Address> menu_trace_address{};
std::atomic<DWORD> menu_trace_thread{};
std::atomic<std::int32_t> menu_trace_selection{};
std::atomic<bool> menu_details_presence_reported{};
std::atomic<bool> menu_details_check_reported{};
Address solo_client_activity{};
std::uint32_t solo_client_player{}, solo_client_group{};
};
State& state() { static auto* value = new State; return *value; }
struct PendingParameters { Address vm{}, callback{}; };
@@ -61,26 +80,208 @@ std::uint32_t realm() {
const auto offset = read<std::uint32_t>(state().base + engine::context_type_offset);
return context && offset < 0x1000000 ? read<std::uint32_t>(context + offset) : 0;
}
// Native player id of the hosted offline server's only real player, or zero.
// The MP participant id is not the PlayerId used by native client/UI getters.
// Steam roster entries are UI models and never appear in this native list.
std::uint32_t sole_offline_player_id() {
NativeActivityPlayerIds sole_offline_player_ids() {
const auto base = state().base;
const auto server = read<Address>(base + engine::game_server);
if (!server || read<Address>(server) != base + engine::server_vtable) return 0;
if (!server || read<Address>(server) != base + engine::server_vtable) return {};
const auto context = read<Address>(server + 8);
const auto manager_offset = read<std::uint32_t>(base + engine::context_player_manager_offset);
if (!context || manager_offset > 0x1000000) return 0;
if (!context || manager_offset > 0x1000000) return {};
const auto manager = read<Address>(context + manager_offset);
if (!manager || read<Address>(manager) != base + engine::server_player_manager_vtable) return 0;
if (!manager || read<Address>(manager) != base + engine::server_player_manager_vtable) return {};
const auto first = read<Address>(manager + 0xc8), last = read<Address>(manager + 0xd0);
if (!first || last - first != 8) return 0;
if (!first || last - first != 8) return {};
const auto player = read<Address>(first);
// Registration offsets locate sibling base classes of the same player, so
// the differences are signed and small.
const auto parent = static_cast<std::int64_t>(read<std::uint32_t>(base + engine::player_parent_offset));
const auto identity = static_cast<std::int64_t>(read<std::uint32_t>(base + throwdowns::player_identity_offset)) - parent;
if (player < 0x100000 || identity < -0x10000 || identity > 0x10000) return 0;
return read<std::uint32_t>(static_cast<Address>(static_cast<std::int64_t>(player) + identity) + 0x68);
if (player < 0x100000 || identity < -0x10000 || identity > 0x10000 ||
read<Address>(player)!=base+engine::server_player_vtable ||
read<Address>(base+engine::server_player_vtable+0x10)!=base+0x7ff930)return {};
// Native virtual GetPlayerId is mov eax,[rcx+48]; ret (IDA/Ghidra and
// both live client/server vtables agree). Read it without a virtual call.
static const bool id_verified=read<std::array<unsigned char,4>>(base+0x7ff930)==
std::array<unsigned char,4>{0x8b,0x41,0x48,0xc3};
if(!id_verified)return {};
return {read<std::uint32_t>(static_cast<Address>(static_cast<std::int64_t>(player) + identity) + 0x68),
read<std::uint32_t>(player+0x48)};
}
std::uint32_t sole_offline_player_id() { return sole_offline_player_ids().participant; }
// Same client-player lookup and sibling-base adjustment used by the native
// GetPlayerIsInActivity/GetPlayerMPActivityIdleState functions (834520/834950).
// Never use the skater or the SDK's UI record as a native player pointer.
Address client_activity_variables(std::uint32_t participant,std::uint32_t* menu_player=nullptr) {
const auto base=state().base;
const auto ids=sole_offline_player_ids();
if(!ids.matches_participant(participant))return 0;
// Native GetPlayerIsInActivity selects the client realm explicitly with
// GetContextForRealm, not the caller's TLS context. Our UI tick runs AFTER
// original_tick returns, when that temporary TLS scope may already be gone.
constexpr std::array<unsigned char,19> context_prefix{
0x89,0x54,0x24,0x10,0x53,0x48,0x83,0xec,0x30,0x4c,0x8b,0x05,0xe8,0xb7,0x70,0x05,0x48,0x8b,0xd9};
static const bool verified=read<std::array<unsigned char,19>>(base+0x18b6e40)==context_prefix;
if(!verified)throw std::runtime_error("Native client context lookup fingerprint differs");
Address context{};
reinterpret_cast<Address (*)(Address*,std::uint32_t)>(base+0x18b6e40)(&context,client_realm);
const auto realm_offset=read<std::uint32_t>(base+engine::context_type_offset);
if(!context || realm_offset>0x1000000 || read<std::uint32_t>(context+realm_offset)!=client_realm)return 0;
const auto offset=read<std::uint32_t>(base+engine::context_player_manager_offset);
if(!context || offset>0x1000000)return 0;
const auto manager=read<Address>(context+offset);
if(!manager || read<Address>(manager)!=base+engine::local_player_manager_vtable)return 0;
const auto first=read<Address>(manager+0x78),last=read<Address>(manager+0x80);
if(!first || last<first || last-first>64*8 || (last-first)%8)return 0;
const auto delta=static_cast<std::int64_t>(read<std::uint32_t>(base+0x6f7bae0))-
static_cast<std::int64_t>(read<std::uint32_t>(base+0x75c3588));
if(delta < -0x10000 || delta > 0x10000)return 0;
for(auto at=first;at<last;at+=8) {
const auto player=read<Address>(at);
if(!player || read<Address>(player)!=base+engine::local_player_vtable)continue;
const auto id_function=read<Address>(read<Address>(player)+0x10);
if(id_function!=base+0x7ff930)continue;
if(ids.matches_menu_player(read<std::uint32_t>(player+0x48))) {
if(menu_player)*menu_player=ids.menu_player;
return static_cast<Address>(static_cast<std::int64_t>(player)+delta);
}
}
return 0;
}
void complete_solo_client_exit() {
auto& s=state();
if(!s.solo_client_activity && !s.lifetime.host())return;
const auto uid=sole_offline_player_id();
const auto activity=s.lifetime.solo_activity(uid);
if(activity && activity!=s.solo_client_activity) {
if(const auto vars=client_activity_variables(uid)) {
s.solo_client_activity=activity;s.solo_client_player=uid;
s.solo_client_group=read<std::uint32_t>(vars+0x100);
}
}
const auto ended=s.lifetime.take_completed_solo();
if(!ended)return;
// A new queue or a changed group must not inherit this completed event's
// cleanup. The server's confirmed removal/client EnterEnd arms it once.
const auto vars=ended==uid && ended==s.solo_client_player && s.solo_client_activity &&
!s.lifetime.active() && !native_flag::registration_owned()?client_activity_variables(ended):0;
const auto group=vars?read<std::uint32_t>(vars+0x100):0;
if(vars && group==s.solo_client_group) {
const auto in_activity=read<std::uint8_t>(vars+0x104);
const auto idle=read<std::uint32_t>(vars+0x108);
if(in_activity || idle) {
// ClientPlayerVars defaults confirmed by its native constructor
// (5df4670). Offline AMP never supplies the final activity reset.
const bool inactive=false;
const std::uint32_t no_idle=0;
SIZE_T written{};
const bool reset_activity=WriteProcessMemory(GetCurrentProcess(),reinterpret_cast<void*>(vars+0x104),&inactive,sizeof(inactive),&written) && written==sizeof(inactive);
const bool reset_idle=WriteProcessMemory(GetCurrentProcess(),reinterpret_cast<void*>(vars+0x108),&no_idle,sizeof(no_idle),&written) && written==sizeof(no_idle);
logging::log(logging::Level::info,logging::Channel::progression,
"Throwdown: confirmed solo exit for player {:#x}; client activity {} -> {}, idle {} -> {} (reset {}/{}).",
ended,in_activity,read<std::uint8_t>(vars+0x104),idle,read<std::uint32_t>(vars+0x108),reset_activity,reset_idle);
}
} else logging::log(logging::Level::warning,logging::Channel::progression,
"Throwdown: solo exit for {:#x} did not match the captured client activity; reset skipped.",ended);
s.solo_client_activity=0;s.solo_client_player=0;s.solo_client_group=0;
}
// While a plugin match blocks markers (kit::match().markers) the session marker conditions read
// false and the pause menu routes to the activity, whose Quit the match's plugin hears.
void complete_match_controls(Address vm) {
if(!kit::match().markers || !vm || realm()!=client_realm)return;
const auto resource=read<Address>(vm+0x38),instance=read<Address>(vm+0x30);
if(!resource || !instance || read<Address>(instance)!=resource)return;
const auto hash=read<std::uint32_t>(resource+0x10);
const auto layout=read<std::array<std::uint32_t,10>>(resource+0x20);
unsigned output_offset{};
bool result=false,matched=false;
if(hash==0x32c0b460 && layout==std::array<std::uint32_t,10>{64,32,10,52,4,0,0,3,0,0}) {
output_offset=16;matched=true; // SessionMarkerReturn condition
} else if((hash==0x870491b0 || hash==0xa5d0ee41) && layout==std::array<std::uint32_t,10>{48,64,68,95,3,0,0,8,720896,0}) {
output_offset=8;matched=true; // Marker set/setting rumble conditions
} else if(hash==0x3a5e5108 && layout==std::array<std::uint32_t,10>{64,8,37,113,4,0,0,9,786432,0}) {
result=true;matched=true; // Native UI activity pause/marker routing
} else if(hash==0x6998ec4b && layout==std::array<std::uint32_t,10>{48,288,98,211,3,0,3,327693,1572864,16777472}) {
// QuitChallenge is the original pause modal's confirmed Quit Match
// action. Its native picker may have retired already; still end the match.
native_flag::quit_match();
}
if(!matched)return;
const auto arguments=read<Address>(instance+((layout[0]+15U)&~15U)+8);
const auto output=arguments?read<Address>(arguments+output_offset):0;
SIZE_T written{};
if(output)WriteProcessMemory(GetCurrentProcess(),reinterpret_cast<void*>(output),&result,sizeof(result),&written);
}
void complete_custom_details_presence(Address vm) {
// This is the authored ActivitiesDetailsScreen -> MenusClosed condition,
// not its OnEnd action. Let the native condition execute all its reads and
// preserve its selected-menu/navigation/layer close reasons (local 0x80).
// Its other term is !activity_present, local 0x81. Native presence means
// DynamicPhaseHandler.PhaseSetEntities has entries when the dynamic manager
// exists; otherwise it uses RuleActionManager.ActiveRuleActionHandles.
// A custom match supplies that activity ownership only for its own Start
// request. No fake native event, phase entity, camera or Page is created.
auto& s=state();
const auto selection=s.menu_trace_address.load(std::memory_order_acquire);
if(!selection || s.menu_trace_thread.load(std::memory_order_relaxed)!=GetCurrentThreadId() || !vm)return;
const auto resource=read<Address>(vm+0x38),instance=read<Address>(vm+0x30);
constexpr std::array<std::uint32_t,10> layout{48,296,140,332,3,0,3,655385,2293760,0};
if(!resource || !instance || read<Address>(instance)!=resource ||
read<std::uint32_t>(resource+0x10)!=0x05edc9f8 ||
read<std::array<std::uint32_t,10>>(resource+0x20)!=layout)return;
if(!active() || realm()!=client_realm || !kit::match().in_activity)return;
const auto pages=instance+48;
const auto constants=read<Address>(pages),arguments=read<Address>(pages+8),locals=read<Address>(pages+16);
if(!constants || !arguments || !locals ||
read<std::uint32_t>(constants+8)!=0xaf4c67d7 ||
read<std::uint32_t>(constants+0x28)!=0xcc4776b5 ||
read<std::uint32_t>(constants+0x48)!=0xd71ee0a1)return;
const auto output=read<Address>(arguments);
if(!output || read<std::int32_t>(selection)!=1)return;
std::array<std::uint8_t,140> values{};
if(!memory::peek_bytes(locals,values.data(),values.size()))return;
const auto original=read<std::uint8_t>(output);
if(!s.menu_details_check_reported.exchange(true,std::memory_order_relaxed))
logging::log(logging::Level::info,logging::Channel::ui,
"PanelTrace native Details exit predicate: graph={:#x}, selected={}, native_exit={}, close_requested={}, native_activity_present={}, frame_result={}, output={:#x}, locals={:#x}.",
0x05edc9f8,values[0x64],original,values[0x80],values[0x81],values[0x8b],output,locals);
// The exact final native copy must agree with its saved result. Reject an
// expired/nested frame or changed graph instead of borrowing its pointers.
if(values[0x8b]!=original || values[0x89]!=1 || values[0x8a]!=1 ||
!custom_details_presence_needed(true,values[0x64],original,values[0x80],values[0x81]))return;
{
std::lock_guard presence_lock(s.custom_ui_presence_mutex);
if(!s.custom_ui_presence.owned())return;
}
if(s.lifetime.active() || s.lifetime.has_activity())return;
const std::uint8_t keep_native_details=0;
SIZE_T written{};
if(WriteProcessMemory(GetCurrentProcess(),reinterpret_cast<void*>(output),&keep_native_details,
sizeof(keep_native_details),&written) && written==sizeof(keep_native_details) &&
!s.menu_details_presence_reported.exchange(true,std::memory_order_relaxed))
logging::log(logging::Level::info,logging::Channel::ui,
"Throwdowns: native Details recognized custom activity owner; graph={:#x}, close_requested={}, native_activity_present={}, native_exit={} -> 0; native Back/camera/input unchanged.",
0x05edc9f8,values[0x80],values[0x81],original);
}
std::uint64_t player_in_activity_hook(std::uint32_t uid) {
const auto original=state().player_in_activity(uid);
if(native_activity_flag(original) || !kit::match().in_activity)return original;
profile_runtime::PreserveError preserve;
try {
// The original pause/marker UI must treat the local match participant
// as playing an activity even after its temporary flag queue retires.
// The query adapter remains independent of the scoped client UI lease.
if(active() && realm()==client_realm) {
const auto ids=sole_offline_player_ids();
if(ids.matches_menu_player(uid) && client_activity_variables(ids.participant))return 1;
}
} catch(...) {}
return original;
}
// The throwdowner flow leaves its mode-select state in the same frame unless
@@ -181,17 +382,19 @@ void send_event_hook(Address signature, Address output, const Address* arguments
}
const auto name = throwdown_event(hash);
const auto current_realm = realm();
if (current_realm == client_realm) prepare_native_solo_throwdown_leave(hash);
if (current_realm == client_realm) observe_throwdown_send(hash, read<Address>(arguments[0]), arguments[1]);
if (current_realm == client_realm && consume_custom_throwdown_send(hash, read<Address>(arguments[0]), arguments[1])) return;
// CreateThrowdownQueue: the local player now hosts a throwdown
// (a plugin mode's flag is not one).
if (hash == 0x7fac81bb && !custom_throwdowns::registration_owned() && !state().throwdown_active.exchange(true))
// Keep the native registration and waiting HUD. Only its Start
// action is diverted to the plugin mode; ordinary Throwdowns are unchanged.
if(current_realm==client_realm && consume_flag_throwdown_send(hash,read<Address>(arguments[0]),arguments[1])) return;
// CreateThrowdownQueue: the local player now hosts a throwdown.
if (current_realm == client_realm && hash == 0x7fac81bb && state().lifetime.create(sole_offline_player_id()))
logging::log(logging::Level::info, logging::Channel::progression, "Throwdown: local player is hosting one.");
// AddParticipantToCommunityEvent (the Join button): in one someone else placed,
// which the Quit action must also leave. RemoveParticipant: left its queue.
if (current_realm == client_realm && hash == 0xdc10392b && !state().throwdown_joined.exchange(true))
if (current_realm == client_realm && hash == 0xdc10392b && state().lifetime.join(sole_offline_player_id()))
logging::log(logging::Level::info, logging::Channel::progression, "Throwdown: local player joined one.");
if (current_realm == client_realm && hash == 0xaa3f9957) state().throwdown_joined.store(false);
if (current_realm == client_realm && hash == 0xaa3f9957) state().lifetime.leave_queue();
loopback = !name.empty() && empty_target && current_realm == server_realm && sole_offline_player_id();
static std::atomic<unsigned> logged{};
if (loopback && logged.fetch_add(1) < 16)
@@ -266,8 +469,7 @@ void debug_variable(const char* const* name, const std::uint32_t* fallback, std:
std::array<char, wanted.size() + 1> value{};
if (!memory::peek(text, value) || value[wanted.size()] != 0 ||
std::string_view(value.data(), wanted.size()) != wanted) return;
// A plugin mode's flag queue must never count down and start alone.
*out = custom_throwdowns::registration_owned() ? 0U : 1U;
*out = native_flag::registration_owned() ? 0U : 1U;
static std::atomic<bool> logged{};
if (!logged.exchange(true))
logging::write(logging::Level::info, logging::Channel::progression,
@@ -359,15 +561,15 @@ namespace {
struct ModeParameter { const char* mode; const char* parameter; float minimum, maximum, fallback; };
constexpr std::array mode_parameters{
ModeParameter{"JamSession", "AllowOffBoard", 0, 1, 1}, ModeParameter{"JamSession", "AllowSessionMarkers", 0, 1, 1},
ModeParameter{"JamSession", "MaxPlayers", 2, 10, 4}, ModeParameter{"JamSession", "Privacy", 0, 1, 0},
ModeParameter{"JamSession", "MaxPlayers", 1, 10, 4}, ModeParameter{"JamSession", "Privacy", 0, 1, 0},
ModeParameter{"JamSession", "ScoreFlips", 0, 1, 1}, ModeParameter{"JamSession", "ScoreGrabs", 0, 1, 1},
ModeParameter{"JamSession", "ScoreGrinds", 0, 1, 1}, ModeParameter{"JamSession", "ScoreGroundTricks", 0, 1, 1},
ModeParameter{"JamSession", "ScoreSlams", 0, 1, 0}, ModeParameter{"JamSession", "Timer", 60, 300, 180},
ModeParameter{"JamSession", "WinCondition", 0, 3, 0}, ModeParameter{"JamSession", "Invite", 0, 2, 0},
ModeParameter{"SpotBattle", "Rounds", 1, 5, 3}, ModeParameter{"SpotBattle", "TurnDuration", 10, 60, 30},
ModeParameter{"SpotBattle", "Invite", 0, 2, 0}, ModeParameter{"SpotBattle", "MaxPlayers", 2, 10, 4},
ModeParameter{"SpotBattle", "Invite", 0, 2, 0}, ModeParameter{"SpotBattle", "MaxPlayers", 1, 10, 4},
ModeParameter{"SpotBattle", "Privacy", 0, 1, 0}, ModeParameter{"ThrowdownSkate", "TurnDuration", 10, 60, 30},
ModeParameter{"ThrowdownSkate", "Invite", 0, 2, 0}, ModeParameter{"ThrowdownSkate", "MaxPlayers", 2, 10, 4},
ModeParameter{"ThrowdownSkate", "Invite", 0, 2, 0}, ModeParameter{"ThrowdownSkate", "MaxPlayers", 1, 10, 4},
ModeParameter{"ThrowdownSkate", "Privacy", 0, 1, 0}};
// Builds the array exactly as the native response flattener
@@ -408,46 +610,241 @@ std::uint32_t local_native_player_id() noexcept {
profile_runtime::PreserveError preserve;
try { return active() ? sole_offline_player_id() : 0; } catch (...) { return 0; }
}
bool consume_match_marker_expression(Address vm,std::uint32_t pc) noexcept {
if(pc || !vm || !kit::match().markers)return false;
profile_runtime::PreserveError preserve;
try {
if(!active() || realm()!=client_realm)return false;
const auto resource=read<Address>(vm+0x38),instance=read<Address>(vm+0x30);
return resource && instance && read<Address>(instance)==resource &&
session_marker_action(read<std::uint32_t>(resource+0x10),
read<std::array<std::uint32_t,10>>(resource+0x20),pc);
} catch(...) {return false;}
}
bool local_throwdown_active() noexcept {
return state().throwdown_active.load(std::memory_order_acquire) || state().throwdown_joined.load(std::memory_order_acquire);
return state().lifetime.active();
}
bool local_throwdown_event_active() noexcept {
try {return state().lifetime.has_activity();} catch(...) {return true;}
}
void set_custom_activity_menu_request(Address address) noexcept {
auto& s=state();
s.menu_trace_address.store(0,std::memory_order_release);
s.menu_details_presence_reported.store(false,std::memory_order_relaxed);
s.menu_details_check_reported.store(false,std::memory_order_relaxed);
std::int32_t value{};
if(!address || !memory::peek(address,value))return;
s.menu_trace_selection.store(value,std::memory_order_relaxed);
s.menu_trace_thread.store(GetCurrentThreadId(),std::memory_order_relaxed);
s.menu_trace_address.store(address,std::memory_order_release);
}
void sync_custom_activity_ui_presence(bool participant) noexcept {
profile_runtime::PreserveError preserve;
auto& s=state();
try {
if(!active())return;
std::lock_guard presence_lock(s.custom_ui_presence_mutex);
if(!participant && !s.custom_ui_presence.owned() && s.custom_ui_diagnostic.empty())return;
const auto caller_realm=realm();
const auto uid=sole_offline_player_id();
std::uint32_t menu_player{};
const auto vars=uid?client_activity_variables(uid,&menu_player):0;
const auto group=vars?read<std::uint32_t>(vars+0x100):0;
const auto current=vars?read<std::uint8_t>(vars+0x104)!=0:false;
const bool native_flag=vars && s.player_in_activity && native_activity_flag(s.player_in_activity(menu_player));
const bool queued=s.lifetime.active(),native_activity=s.lifetime.has_activity();
const bool was_owned=s.custom_ui_presence.owned();
const auto diagnostic=[&](const char* result) {
if(!participant && !was_owned && s.custom_ui_diagnostic.empty())return;
const auto message=std::format(
"wanted={}, caller_realm={:#x}, participant={}, menu_player={}, variables={:#x}, group={}, flag={}, getter={}, idle={}, queue={}, native_activity={}, owned={}, result={}",
participant,caller_realm,uid,menu_player,vars,group,current,native_flag,
vars?read<std::uint32_t>(vars+0x108):0,queued,native_activity,s.custom_ui_presence.owned(),result);
const auto now=GetTickCount64();
if(message!=s.custom_ui_diagnostic || ((participant || was_owned) && now>=s.custom_ui_diagnostic_at+1000)) {
logging::log(logging::Level::info,logging::Channel::ui,"Throwdowns: activity UI handoff: {}.",message);
s.custom_ui_diagnostic=message;s.custom_ui_diagnostic_at=now;
}
};
// Verify the existing getter and reflected flag identify the same
// player before acquiring. No idle/group/backend values are changed.
if(participant && (!vars || !s.player_in_activity || native_flag!=current)) {
diagnostic(!uid?"no sole native player":!vars?"no verified native client player":
!s.player_in_activity?"native getter unavailable":"native getter disagrees with client player");
return;
}
bool write_failed{};
s.custom_ui_presence.update(participant && !queued,vars,uid,group,current,
native_activity,[&](bool value) {
SIZE_T written{};
const std::uint8_t flag=value?1:0;
const bool applied=WriteProcessMemory(GetCurrentProcess(),reinterpret_cast<void*>(vars+0x104),
&flag,sizeof(flag),&written) && written==sizeof(flag);
if(applied && (native_activity_flag(s.player_in_activity(menu_player))!=value)) {
// A same-id object from another context cannot be the
// native UI owner. Undo this one-byte attempt immediately.
const std::uint8_t original=current?1:0;
WriteProcessMemory(GetCurrentProcess(),reinterpret_cast<void*>(vars+0x104),&original,sizeof(original),&written);
write_failed=true;
return false;
}
if(applied)logging::log(logging::Level::info,logging::Channel::ui,
"Throwdowns: client activity UI presence {} -> {} for player {}, group {}, variables {:#x}; native idle/backend unchanged.",
current,value,uid,group,vars);
if(!applied)write_failed=true;
return applied;
});
diagnostic(write_failed?"write/getter verification failed":queued?"native queue owns activity":
native_activity?"native event owns activity":s.custom_ui_presence.owned()?"scoped client flag owned":
participant && current?"client flag already native":participant?"client flag not acquired":"released");
if(!participant && !s.custom_ui_presence.owned())s.custom_ui_diagnostic.clear();
} catch(const std::exception& error) {
static std::uint64_t next_log{};const auto now=GetTickCount64();
if(now>=next_log) {next_log=now+10000;logging::log(logging::Level::warning,logging::Channel::ui,
"Throwdowns: client activity UI presence unavailable: {}",error.what());}
} catch(...) {}
}
bool local_throwdown_host() noexcept { return state().lifetime.host(); }
void native_throwdown_player_limit(std::uint32_t limit) noexcept {
try { state().lifetime.player_limit(limit); } catch (...) {}
}
Address native_solo_throwdown_activity(std::uint32_t player) noexcept {
try { return state().lifetime.solo_activity(player); } catch (...) { return 0; }
}
bool native_solo_throwdown_menu_cleanup_pending() noexcept {
try { return !native_flag::registration_owned() && state().lifetime.menu_cleanup_pending(); } catch (...) { return false; }
}
void native_solo_throwdown_menu_cleaned() noexcept {
try { state().lifetime.menu_cleaned(); } catch (...) {}
}
void release_native_flag_placeholder() noexcept {
try { state().lifetime.clear(); } catch (...) {}
}
void native_throwdown_activity_started(Address activity, std::uint32_t player) noexcept {
try {
if (state().lifetime.activity_started(activity, player))
logging::log(logging::Level::info, logging::Channel::progression,
"Throwdown: local player {:#x} entered native activity {:#x}.", player, activity);
} catch (...) {}
}
void native_throwdown_participant_left(Address activity, std::uint32_t player) noexcept {
try {
if (state().lifetime.participant_left(activity, player))
logging::log(logging::Level::info, logging::Channel::progression,
"Throwdown: ended after player {:#x} left native activity {:#x}; ownership cleared.", player, activity);
} catch (...) {}
}
void native_throwdown_level_left() noexcept {
retire_native_queue_hud();
set_custom_activity_menu_request(0);
sync_custom_activity_ui_presence(false);
try {
state().solo_client_activity=0;state().solo_client_player=0;state().solo_client_group=0;
if (state().lifetime.clear())
logging::write(logging::Level::info, logging::Channel::progression,
"Throwdown: ownership cleared before leaving the level.");
} catch (...) {}
}
bool local_throwdown_host() noexcept { return state().throwdown_active.load(std::memory_order_acquire); }
void complete_native_throwdown_parameters(Address vm) noexcept {
pump_throwdown_lab(vm);
if (custom_throwdowns::registration_owned()) {
// Native expressions can select, then immediately retire, the menu in a
// single frame. Sample after the deepest completed call on its UI thread
// so the log identifies the writer instead of only the next 100ms snapshot.
{
profile_runtime::PreserveError preserve;
try {
const auto resource = vm ? read<Address>(vm + 0x38) : 0;
const auto instance = vm ? read<Address>(vm + 0x30) : 0;
const auto hash = resource ? read<std::uint32_t>(resource + 0x10) : 0;
if (resource && realm() == client_realm && custom_throwdowns::placement_active())
custom_throwdowns::observe_graph(hash);
// Authored predicate outputs (page 1 of the graph's registers): the
// waiting queue normally locks the Toolbox, and Start waits for a
// full queue. A plugin mode's flag is neither: its host starts it.
constexpr std::array<std::uint32_t, 10> disabled_layout{64, 104, 44, 177, 3, 0, 2, 14, 720896, 66560};
constexpr std::array<std::uint32_t, 10> start_layout{144, 1360, 1565, 1445, 18, 0, 7, 2293833, 5832773, 17434624};
if (instance && read<Address>(instance) == resource && realm() == client_realm &&
((hash == 0xc3999b06 && read<std::array<std::uint32_t, 10>>(resource + 0x20) == disabled_layout) ||
(hash == 0x1a0c8c31 && read<std::array<std::uint32_t, 10>>(resource + 0x20) == start_layout))) {
const auto frame = read<std::uint32_t>(resource + 0x20);
const auto arguments = read<Address>(instance + ((frame + 15U) & ~15U) + 8);
if (arguments) {
const bool result = hash == 0x1a0c8c31 && custom_throwdowns::flag_down();
// Both return through opcode 0x32's borrowed output pointer.
const auto output = read<Address>(arguments + (hash == 0xc3999b06 ? 8 : 96));
SIZE_T written{};
const bool wrote = output && WriteProcessMemory(GetCurrentProcess(), reinterpret_cast<void*>(output), &result,
sizeof(result), &written) && written == sizeof(result);
static std::atomic<unsigned> reports{};
if (hash == 0x1a0c8c31 && reports.fetch_add(1) < 40)
logging::log(logging::Level::info, logging::Channel::progression,
"Custom throwdown: waiting card Start {} (written {}).", result ? "offered" : "held back", wrote);
auto& s=state();const auto address=s.menu_trace_address.load(std::memory_order_acquire);
std::int32_t value{};
if(address && s.menu_trace_thread.load(std::memory_order_relaxed)==GetCurrentThreadId() &&
memory::peek(address,value)) {
const auto previous=s.menu_trace_selection.exchange(value,std::memory_order_relaxed);
if(previous!=value) {
const auto resource=vm?read<Address>(vm+0x38):0;
logging::log(logging::Level::info,logging::Channel::ui,
"PanelTrace selection changed {} -> {} after native graph {:#x}, resource={:#x}, VM={:#x}, field={:#x}, realm={:#x}.",
previous,value,resource?read<std::uint32_t>(resource+0x10):0,resource,vm,address,realm());
}
}
} catch (...) {}
} catch(...) {}
}
{
profile_runtime::PreserveError preserve;
try {
if(active() && realm()==client_realm)complete_solo_client_exit();
complete_match_controls(vm);
complete_custom_details_presence(vm);
const auto join_resource=vm?read<Address>(vm+0x38):0;
if(join_resource && read<std::uint32_t>(join_resource+0x10)==0xebb4ab69 && active() && realm()==client_realm)
observe_native_custom_join_details(vm);
// Replace presentation at the native publication boundary, rather
// than waiting for the next throttled UI/camera confirmation tick.
// Real activities, other realms/threads and unarmed requests keep
// their own contents. The native outer Page is never replaced.
auto& s=state();const auto selected=s.menu_trace_address.load(std::memory_order_acquire);
if(selected && s.menu_trace_thread.load(std::memory_order_relaxed)==GetCurrentThreadId()) {
const auto resource=vm?read<Address>(vm+0x38):0,instance=vm?read<Address>(vm+0x30):0;
if(resource && instance && read<Address>(instance)==resource &&
menu_data::native_activity_page_publish_graph(read<std::uint32_t>(resource+0x10),
read<std::array<std::uint32_t,10>>(resource+0x20)) &&
active() && realm()==client_realm && read<std::int32_t>(selected)==1)
prepare_native_throwdown_match_panel();
}
} catch(...) {}
}
try {
const auto resource=vm?read<Address>(vm+0x38):0;
if(resource && read<std::uint32_t>(resource+0x10)==0x627382a6 && active() && realm()==client_realm)
observe_native_flag_interaction(vm);
} catch(...) {}
const bool flag_queue=native_flag::registration_owned();
const bool solo_queue=!flag_queue && native_solo_throwdown_waiting();
if(flag_queue || solo_queue) {
profile_runtime::PreserveError preserve;
try {
const auto resource=vm?read<Address>(vm+0x38):0;
const auto instance=vm?read<Address>(vm+0x30):0;
const auto hash=resource?read<std::uint32_t>(resource+0x10):0;
// Authored predicate outputs (EBX GraphPortRegister page 1): the
// retail queue normally locks the Toolbox while waiting. A plugin
// mode's registration may reopen setup, and may start when its
// waiting card says so (can_start). A stock one-player host queue
// may start alone; other queues retain their authored predicates.
constexpr std::array<std::uint32_t,10> disabled_layout{64,104,44,177,3,0,2,14,720896,66560};
constexpr std::array<std::uint32_t,10> start_layout{144,1360,1565,1445,18,0,7,2293833,5832773,17434624};
if(instance && read<Address>(instance)==resource && realm()==client_realm &&
((flag_queue && hash==0xc3999b06 && read<std::array<std::uint32_t,10>>(resource+0x20)==disabled_layout) ||
(hash==0x1a0c8c31 && read<std::array<std::uint32_t,10>>(resource+0x20)==start_layout))) {
const auto frame=read<std::uint32_t>(resource+0x20);
const auto arguments=read<Address>(instance+((frame+15U)&~15U)+8);
if(arguments) {
bool result=false;
if(hash==0x1a0c8c31) {
if(solo_queue)result=true;
else if(native_flag::registration_waiting()) {
const auto docs=kit::documents_for(native_flag::mode());
result=docs && docs->waiting && docs->waiting->can_start;
}
}
// Both graphs return through opcode 0x32's borrowed
// output pointer on page 1, not their local scratch page.
const auto output=read<Address>(arguments+(hash==0xc3999b06?8:96));
SIZE_T written{};
if(output) WriteProcessMemory(GetCurrentProcess(),reinterpret_cast<void*>(output),&result,sizeof(result),&written);
}
}
} catch(...) {}
}
if(native_flag::placement_active()) {
profile_runtime::PreserveError preserve;
try {
const auto resource=vm?read<Address>(vm+0x38):0;
if(resource && realm()==client_realm) {
const auto hash=read<std::uint32_t>(resource+0x10);
native_flag::observe_graph(hash);
}
} catch(...) {}
}
// The throwdown is over once a client throwdown enters its end state or
// leaves in progress. (Starting the round destroys the registration point
@@ -459,8 +856,7 @@ void complete_native_throwdown_parameters(Address vm) noexcept {
const auto resource = vm ? read<Address>(vm + 0x38) : 0;
switch (resource ? read<std::uint32_t>(resource + 0x10) : 0) {
case 0x224de9ab: case 0xd373badf: case 0x2ea80cac: case 0x1d82a056: // Client*Throwdown.EnterEnd
case 0x91cd7c71: // ClientThrowdownBase.EnterLeaveInProgress
if (state().throwdown_active.exchange(false) | state().throwdown_joined.exchange(false))
if (realm() == client_realm && state().lifetime.ended())
logging::log(logging::Level::info, logging::Channel::progression,
"Throwdown: ended (graph {:#x}).", read<std::uint32_t>(resource + 0x10));
break;
@@ -636,6 +1032,7 @@ void initialize_native_throwdowns(Address base) noexcept {
install_parse_int(base);
struct Hook { Address rva; std::array<unsigned char, 19> prefix; void* replacement; void** original; };
const std::array hooks{
Hook{throwdowns::player_in_activity,throwdowns::player_in_activity_prefix,reinterpret_cast<void*>(&player_in_activity_hook),reinterpret_cast<void**>(&s.player_in_activity)},
Hook{throwdowns::mode_available, throwdowns::mode_available_prefix, reinterpret_cast<void*>(&mode_available_hook), reinterpret_cast<void**>(&s.mode_available)},
Hook{throwdowns::player_info, throwdowns::player_info_prefix, reinterpret_cast<void*>(&player_info_hook), reinterpret_cast<void**>(&s.player_info)},
Hook{throwdowns::service_player_info, throwdowns::service_player_info_prefix, reinterpret_cast<void*>(&service_player_info_hook), reinterpret_cast<void**>(&s.service_player_info)},
+31 -1
View File
@@ -17,12 +17,42 @@ bool native_throwdown_ready(unsigned predicate) noexcept;
// Call after an expression returns: delivers a parameter request that the
// absent throwdowns_v1 service left pending for that expression.
void complete_native_throwdown_parameters(std::uintptr_t vm) noexcept;
// Native player id of the hosted offline server's only real player, or zero.
// Before either native interpreter executes a marker input action. Restrict only verified
// local-client action entries while a plugin match blocks markers (kit::match().markers).
bool consume_match_marker_expression(std::uintptr_t vm,std::uint32_t pc) noexcept;
// MP participant/UniqueId of the hosted offline server's sole real player,
// or zero. This is NOT the native client PlayerId used by activity UI getters;
// native client PlayerId zero is valid and is resolved separately.
std::uint32_t local_native_player_id() noexcept;
// True from the moment the local player creates or joins a throwdown until it
// ends, is left or destroyed. Retail mirrors this onto UIPlayerInfo, where the
// throwdown Quit action reads it; offline the SDK owns that record.
bool local_throwdown_active() noexcept;
bool local_throwdown_event_active() noexcept;
// Client UI thread only. Match availability owns the observed native client
// activity flag; results, Quit and real world teardown relinquish it.
void sync_custom_activity_ui_presence(bool participant) noexcept;
// Arm the exact selection owned by this custom Start request. The Details
// exit predicate can recognize its custom activity owner, and native selection
// changes are traced. Zero releases it before the model/world may retire.
void set_custom_activity_menu_request(std::uintptr_t address) noexcept;
// True while that throwdown is the local player's own (it created it).
bool local_throwdown_host() noexcept;
// QueueFilled confirmed the server activity containing the local player. Only
// removal from that same activity may clear its offline ownership flags.
void native_throwdown_activity_started(std::uintptr_t activity, std::uint32_t player) noexcept;
void native_throwdown_participant_left(std::uintptr_t activity, std::uint32_t player) noexcept;
// Stock host's selected player limit, before QueueFilled creates the activity.
void native_throwdown_player_limit(std::uint32_t limit) noexcept;
// Exact running activity owned by this one-player host, or zero.
std::uintptr_t native_solo_throwdown_activity(std::uint32_t player) noexcept;
// Confirmed solo exit also retires the native Toolbox's IsPendingQueue model.
// Consume on the UI thread while holding its native model write lock.
bool native_solo_throwdown_menu_cleanup_pending() noexcept;
void native_solo_throwdown_menu_cleaned() noexcept;
// The level unloads all of its native queues and activities.
void native_throwdown_level_left() noexcept;
// Only the native flag registration (native_flag_session.cpp) calls this, after its temporary
// native queue exits.
void release_native_flag_placeholder() noexcept;
}
@@ -0,0 +1,21 @@
#pragma once
#include <array>
#include <cstdint>
namespace dingosdk::multiplayer {
// Native UI marker actions have no outputs. Denying the entry leaves both the saved marker and
// the skater untouched. A resumed/unrelated expression must always continue; never stop an
// arbitrary native interpreter cursor.
inline bool session_marker_action(std::uint32_t hash,const std::array<std::uint32_t,10>& layout,std::uint32_t pc) {
if(pc)return false;
struct Entry {std::uint32_t hash;std::array<std::uint32_t,10> layout;};
constexpr std::array entries{
Entry{0x12daba2a,{128,3792,2978,2958,13,0,50,1638558,7340060,17042434}}, // Set
Entry{0xda5d517a,{288,11248,8935,8877,34,0,150,1638875,11534380,17042434}}, // Setting
Entry{0x0d6708f4,{208,7528,5957,5920,23,0,100,1638717,9437220,17042434}}, // Undo
Entry{0x177dbf1c,{208,7512,5956,5915,24,0,100,1638716,9437220,17042434}} // Return
};
for(const auto& entry:entries)if(entry.hash==hash && entry.layout==layout)return true;
return false;
}
}
+560
View File
@@ -0,0 +1,560 @@
#include "throwdown_kit.h"
#include "Engine/Core/Json/json.h"
#include <algorithm>
#include <charconv>
#include <cmath>
#include <format>
#include <limits>
#include <map>
#include <mutex>
// The kit's store (throwdown_kit.h): every plugin's documents, parsed from what it publishes with
// tolerant readers (a missing or mistyped field is its default, never an error, so a plugin's
// typo blanks a label instead of its whole HUD), bounded so no document grows past what the
// native widgets show; and the events the native UI queues for each plugin. kit::flag lives with
// the native placement (native_flag_session.cpp).
namespace dingosdk::throwdowns::kit {
namespace {
constexpr std::size_t most_text = 256, most_long_text = 1024, most_rows = 32, most_events = 64, most_queues = 64, most_args = 16;
constexpr std::size_t most_arg = 70u << 10; // a dialog answer carries up to 64 KB of a file
constexpr std::size_t most_document = 512u << 10; // a publication, as JSON text
// ---- JSON, read tolerantly -----------------------------------------------------------------------
const Json* field(const Json& j, std::string_view key) {
if (!j.is_object() || !j.contains(key)) return nullptr;
const auto& value = j.at(key);
return value.is_null() ? nullptr : &value;
}
template<class T> T number(const Json& j, std::string_view key, T fallback = {}) {
const auto* v = field(j, key);
if (!v) return fallback;
if (v->is_number()) {
const double d = v->get<double>();
if (!std::isfinite(d)) return fallback;
if constexpr (std::is_integral_v<T>) {
if (d < static_cast<double>(std::numeric_limits<T>::lowest()) || d > static_cast<double>(std::numeric_limits<T>::max())) return fallback;
}
return static_cast<T>(d);
}
if (v->is_string() && std::is_integral_v<T>) {
T out{};
const auto& s = v->string();
if (std::from_chars(s.data(), s.data() + s.size(), out).ec == std::errc{}) return out;
}
return fallback;
}
// Steam IDs come as strings (a Lua number cannot hold one exactly), or numbers.
std::uint64_t id_of(const Json& v) {
if (v.is_string()) {
std::uint64_t out{};
const auto& s = v.string();
return std::from_chars(s.data(), s.data() + s.size(), out).ec == std::errc{} ? out : 0;
}
if (!v.is_number()) return 0;
const double d = v.get<double>();
return std::isfinite(d) && d > 0 && d < 1.8e19 ? static_cast<std::uint64_t>(d) : 0;
}
std::uint64_t id(const Json& j, std::string_view key) {
const auto* v = field(j, key);
return v ? id_of(*v) : 0;
}
bool flag(const Json& j, std::string_view key, bool fallback = false) {
const auto* v = field(j, key);
return v && v->is_boolean() ? v->get<bool>() : fallback;
}
// A label: a string, or a number as the plugin would print it (ranks and scores come as either).
std::string label_of(const Json& v, std::size_t most = most_text) {
std::string s;
if (v.is_string()) s = v.string();
else if (v.is_number_integer()) s = v.is_number_unsigned() ? std::to_string(v.get<std::uint64_t>()) : std::to_string(v.get<std::int64_t>());
else if (v.is_number()) {
const double d = v.get<double>();
if (std::isfinite(d)) s = std::format("{}", d);
} else if (v.is_boolean()) s = v.get<bool>() ? "true" : "false";
if (s.size() > most) s.resize(most);
return s;
}
std::string text(const Json& j, std::string_view key, std::size_t most = most_text) {
const auto* v = field(j, key);
return v ? label_of(*v, most) : std::string{};
}
Vec3 vec(const Json& v) {
Vec3 out{};
if (v.is_array() && v.size() >= 3)
for (std::size_t i = 0; i < 3; ++i) out[i] = v.at(i).is_number() ? static_cast<float>(v.at(i).get<double>()) : 0.f;
else if (v.is_object()) out = {number<float>(v, "x"), number<float>(v, "y"), number<float>(v, "z")};
for (auto& n : out)
if (!std::isfinite(n) || std::fabs(n) > 1e6f) n = 0;
return out;
}
Vec3 vec(const Json& j, std::string_view key) {
const auto* v = field(j, key);
return v ? vec(*v) : Vec3{};
}
template<class T, class F> std::vector<T> list(const Json& j, std::string_view key, std::size_t most, F&& each) {
std::vector<T> out;
const auto* v = field(j, key);
if (!v || !v->is_array()) return out;
for (std::size_t i = 0; i < v->size() && out.size() < most; ++i) out.push_back(each(v->at(i)));
return out;
}
std::vector<std::string> texts(const Json& j, std::string_view key, std::size_t most) {
return list<std::string>(j, key, most, [](const Json& v) { return label_of(v); });
}
std::uint32_t colour(const Json& j, std::string_view key, std::uint32_t fallback) {
const auto* v = field(j, key);
if (!v || !v->is_number()) return fallback;
const double d = v->get<double>();
return std::isfinite(d) && d >= 0 && d <= 4294967295.0 ? static_cast<std::uint32_t>(d) : fallback;
}
std::uint32_t ms(const Json& j, std::string_view key) {
const double d = number<double>(j, key);
return d > 0 ? static_cast<std::uint32_t>(std::min(d, 4294967295.0)) : 0;
}
// ---- documents -----------------------------------------------------------------------------------
// A document's `mode` is the plugin's own id; the store keeps the SDK's ("<plugin>/<id>").
std::string mode_key(std::string_view plugin, const Json& j) {
auto own = text(j, "mode", 64);
if (own.empty()) return {};
const std::string prefix = std::string(plugin) + "/";
return own.starts_with(prefix) ? own : prefix + own;
}
SetupSlider slider_of(const Json* j) {
SetupSlider s;
if (!j || !j->is_object()) return s;
s.present = true;
s.caption = text(*j, "caption", 64);
s.min = number<float>(*j, "min");
s.max = number<float>(*j, "max", s.min);
s.step = std::max(number<float>(*j, "step", 1), 0.0f);
s.value = std::clamp(number<float>(*j, "value", s.min), std::min(s.min, s.max), std::max(s.min, s.max));
return s;
}
SetupRow setup_row_of(const Json& j) {
SetupRow r;
r.id = text(j, "id", 64);
r.kind = text(j, "kind", 16);
if (r.kind.empty()) r.kind = "slider";
r.caption = text(j, "caption", 64);
r.label = text(j, "label", 64);
r.min = number<float>(j, "min");
r.max = number<float>(j, "max", r.min);
r.step = std::max(number<float>(j, "step", 1), 0.0f);
r.options = texts(j, "options", 16);
if (r.kind == "choice") {
// From 1 as Lua counts.
const int count = static_cast<int>(r.options.size());
r.selected = count ? std::clamp(number<int>(j, "value", 1) - 1, 0, count - 1) : 0;
r.value = static_cast<float>(r.selected);
} else r.value = number<float>(j, "value", r.min);
return r;
}
Setup setup_of(std::string_view plugin, const Json& j) {
Setup s;
s.mode = mode_key(plugin, j);
s.title = text(j, "title", 64);
s.art = text(j, "art");
s.description = text(j, "description", most_long_text);
s.back = text(j, "back", 32);
s.players = slider_of(field(j, "players"));
s.timer = slider_of(field(j, "timer"));
s.rows = list<SetupRow>(j, "rows", 12, setup_row_of);
if (field(j, "hide_native")) s.hide_native = texts(j, "hide_native", 8);
return s;
}
Waiting waiting_of(std::string_view plugin, const Json& j) {
Waiting w;
w.mode = mode_key(plugin, j);
w.title = text(j, "title", 64);
w.art = text(j, "art");
w.status = text(j, "status");
w.enrolled = number<unsigned>(j, "enrolled");
w.capacity = number<unsigned>(j, "capacity");
w.can_start = flag(j, "can_start");
if (const auto* c = field(j, "countdown"); c && c->is_object()) {
w.countdown = true;
w.remaining_ms = ms(*c, "remaining_ms");
w.total_ms = ms(*c, "total_ms");
}
return w;
}
// {remaining_ms, total_ms, sampled_ms, ticking, up}, or a stopwatch {base_ms, sampled_ms, running,
// cap_ms} (an elapsed time counting up).
Clock clock_of(const Json* j) {
Clock c;
if (!j || !j->is_object()) return c;
c.present = true;
c.sampled_ms = number<std::uint64_t>(*j, "sampled_ms");
if (field(*j, "base_ms")) {
c.up = true;
c.remaining_ms = ms(*j, "base_ms");
c.total_ms = ms(*j, "cap_ms");
c.ticking = flag(*j, "running");
return c;
}
c.remaining_ms = ms(*j, "remaining_ms");
c.total_ms = ms(*j, "total_ms");
c.ticking = flag(*j, "ticking", true);
c.up = flag(*j, "up");
return c;
}
BoardRow board_row_of(const Json& j) {
BoardRow r;
r.rank = text(j, "rank", 16);
r.name = text(j, "name", 64);
r.value = text(j, "value", 64);
r.color = colour(j, "color", 0xffffffffU);
r.self = flag(j, "self");
r.out = flag(j, "out");
r.marks = list<Mark>(j, "marks", 8, [](const Json& m) { return Mark{text(m, "text", 16), flag(m, "on")}; });
r.clock = clock_of(field(j, "clock"));
return r;
}
Prompt prompt_of(const Json& j) {
Prompt p;
p.inputs = texts(j, "inputs", 4);
if (const auto input = text(j, "input", 64); !input.empty() && p.inputs.size() < 4) p.inputs.push_back(input);
p.caption = text(j, "caption", 64);
p.glyph = text(j, "glyph", 64);
p.hold = std::clamp(number<float>(j, "hold"), 0.0f, 1.0f);
return p;
}
Hud::Text banner_of(const Json* j) {
Hud::Text t;
if (!j || !j->is_object()) return t;
t.text = text(*j, "title");
if (t.text.empty()) t.text = text(*j, "text");
t.style = text(*j, "style", 32);
t.subtitle = text(*j, "subtitle");
t.art = text(*j, "art");
t.serial = number<std::uint64_t>(*j, "serial");
t.ms = ms(*j, "ms");
return t;
}
Hud hud_of(const Json& j) {
Hud h;
h.active = flag(j, "active", true);
h.theme = text(j, "theme", 32);
if (const auto* header = field(j, "header")) {
h.header.title = text(*header, "title", 64);
h.header.art = text(*header, "art");
h.header.status = text(*header, "status");
const auto offset = vec(*header, "offset"); // {x, y} or {x, y, 0}
if (const auto* o = field(*header, "offset"); o && o->is_array() && o->size() == 2)
h.header.offset = {o->at(0).is_number() ? static_cast<float>(o->at(0).get<double>()) : 0.f,
o->at(1).is_number() ? static_cast<float>(o->at(1).get<double>()) : 0.f};
else h.header.offset = {offset[0], offset[1]};
for (auto& n : h.header.offset)
if (!std::isfinite(n) || std::fabs(n) > 1e4f) n = 0;
h.header.icon_size = std::min(number<unsigned>(*header, "icon_size"), 512u);
h.header.frame_width = std::min(number<unsigned>(*header, "frame_width"), 2048u);
}
h.layout = text(j, "layout", 16);
h.counters = list<std::string>(j, "counters", 8, [](const Json& c) { return c.is_object() ? text(c, "text") : label_of(c); });
h.score_width = std::clamp(number<float>(j, "score_width", 480), 0.0f, 4096.0f);
h.score_art = text(j, "score_art", 64);
h.clock = clock_of(field(j, "clock"));
if (const auto* b = field(j, "board"); b && b->is_object()) {
h.board.present = true;
h.board.title = text(*b, "title", 64);
h.board.variant = text(*b, "variant", 32);
h.board.style = text(*b, "style", 32);
h.board.name_art = text(*b, "name_art", 64);
h.board.out_art = text(*b, "out_art", 64);
h.board.rank_art = text(*b, "rank_art", 64);
h.board.value_art = text(*b, "value_art", 64);
h.board.reserve = text(*b, "reserve", 16);
h.board.value_width = std::clamp(number<float>(*b, "value_width", 0), 0.0f, 2048.0f);
h.board.heading = text(*b, "heading", 16);
h.board.columns = texts(*b, "columns", 8);
h.board.rows = list<BoardRow>(*b, "rows", most_rows, board_row_of);
}
if (const auto* r = field(j, "results"); r && r->is_object()) {
h.results.present = true;
h.results.title = text(*r, "title", 64);
h.results.heading_art = text(*r, "heading_art", 64);
h.results.title_width = std::clamp(number<float>(*r, "title_width", 0), 0.0f, 2048.0f);
h.results.notice = text(*r, "notice");
h.results.caption = text(*r, "caption");
h.results.style = text(*r, "style", 32);
h.results.rows = list<BoardRow>(*r, "rows", most_rows, board_row_of);
}
if (const auto* i = field(j, "intro"); i && i->is_object()) {
h.intro.present = true;
h.intro.title = text(*i, "title", 64);
h.intro.art_key = text(*i, "art_key");
h.intro.rules = text(*i, "rules", most_long_text);
h.intro.until_ms = number<std::uint64_t>(*i, "until_ms");
}
h.countdown = clock_of(field(j, "countdown"));
h.banner = banner_of(field(j, "banner"));
h.callout = banner_of(field(j, "callout"));
h.warning = banner_of(field(j, "warning"));
if (const auto* c = field(j, "card"); c && c->is_object()) {
h.card.present = true;
h.card.caption = text(*c, "caption", 64);
h.card.title = text(*c, "title");
h.card.image = text(*c, "image");
}
if (const auto* l = field(j, "list"); l && l->is_object()) {
h.list.present = true;
h.list.caption = text(*l, "caption", 64);
h.list.items = texts(*l, "items", 16);
}
if (const auto* p = field(j, "position"); p && p->is_object()) {
h.position.present = true;
h.position.place = number<unsigned>(*p, "place");
h.position.medal = text(*p, "medal", 16);
h.position.art = text(*p, "art", 64);
}
h.timer_art = text(j, "timer_art", 64);
h.timers = list<TimerText>(j, "timers", 6, [](const Json& t) {
TimerText out;
out.caption = text(t, "caption", 64);
if (const auto* value = field(t, "text"); value && value->is_object()) out.clock = clock_of(value);
else out.text = text(t, "text", 64);
if (const auto* c = field(t, "clock"); c && c->is_object()) out.clock = clock_of(c);
return out;
});
if (const auto* c = field(j, "compass"); c && c->is_object()) {
h.compass.present = true;
h.compass.at = vec(*c, "at");
h.compass.art = text(*c, "art");
}
h.markers = list<Marker>(j, "markers", most_rows, [](const Json& m) { return Marker{vec(m, "at"), text(m, "text", 64), flag(m, "distance")}; });
if (const auto* b = field(j, "blackout"); b && b->is_object()) {
h.blackout.present = true;
h.blackout.text = text(*b, "text");
}
if (const auto* e = field(j, "editor"); e && e->is_object()) {
h.editor.present = true;
h.editor.title = text(*e, "title", 64);
h.editor.variant = text(*e, "variant", 16);
h.editor.prompts = list<Prompt>(*e, "prompts", 12, prompt_of);
if (const auto* info = field(*e, "info")) {
h.editor.info_title = text(*info, "title", 64);
h.editor.info = list<std::pair<std::string, std::string>>(*info, "rows", 12,
[](const Json& r) { return std::pair{text(r, "caption", 64), text(r, "value", 64)}; });
}
if (const auto* toast = field(*e, "toast")) {
h.editor.toast = text(*toast, "text");
h.editor.toast_alpha = std::clamp(number<float>(*toast, "alpha", 1), 0.0f, 1.0f);
}
if (const auto* modal = field(*e, "modal")) {
h.editor.modal = text(*modal, "text", most_long_text);
h.editor.modal_actions = list<Prompt>(*modal, "actions", 4, prompt_of);
}
}
return h;
}
Panel panel_of(const Json& j) {
Panel p;
p.present = true;
p.title = text(j, "title", 64);
p.description = text(j, "description", most_long_text);
p.art = text(j, "art");
p.score_caption = text(j, "score_caption", 32);
p.quit = text(j, "quit", 32);
p.settings = list<PanelSetting>(j, "settings", 3, [](const Json& s) { return PanelSetting{text(s, "icon"), text(s, "caption", 64), text(s, "value", 64)}; });
// A prompt's command rides in its glyph (throwdown_kit.h).
p.prompts = list<Prompt>(j, "prompts", 8, [](const Json& s) {
auto prompt = prompt_of(s);
prompt.glyph = text(s, "command", 64);
return prompt;
});
p.players = list<PanelPlayer>(j, "players", most_rows, [](const Json& s) {
return PanelPlayer{id(s, "id"), text(s, "name", 64), text(s, "value", 64), text(s, "rank", 16), text(s, "outcome", 64), text(s, "portrait"),
flag(s, "self")};
});
return p;
}
Flag flag_of(std::string_view plugin, const Json& j) {
Flag f;
f.id = text(j, "id", 64);
f.mode = mode_key(plugin, j);
f.leader = id(j, "leader");
f.game = number<std::uint32_t>(j, "game");
f.at = vec(j, "at");
f.yaw = number<float>(j, "yaw");
f.players = number<unsigned>(j, "players", 1);
f.capacity = number<unsigned>(j, "capacity", 6);
f.open = flag(j, "open", true);
f.stats = list<std::pair<std::string, std::string>>(j, "stats", 3, [](const Json& s) { return std::pair{text(s, "title", 32), text(s, "value", 32)}; });
return f;
}
Match match_of(const Json& j) {
Match m;
m.markers = flag(j, "markers");
m.input_lock = flag(j, "input_lock");
m.offboard = flag(j, "offboard");
m.teleports = flag(j, "teleports");
m.in_activity = flag(j, "in_activity");
m.hidden = list<std::uint64_t>(j, "hidden", 16, id_of);
std::erase(m.hidden, 0);
return m;
}
bool asks(const Match& m) { return m.markers || m.input_lock || m.offboard || m.teleports || m.in_activity || !m.hidden.empty(); }
// ---- the store -----------------------------------------------------------------------------------
struct Entry {
Documents documents;
std::string text; // as last published: an unchanged publication is not parsed again
};
struct Store {
std::mutex mutex;
std::map<std::string, Entry, std::less<>> plugins;
std::map<std::string, std::vector<Event>, std::less<>> events;
std::string current;
std::uint64_t revision{};
};
Store& store() {
static auto* s = new Store; // never destroyed: the UI may read it during shutdown
return *s;
}
std::string_view plugin_of(std::string_view mode) {
const auto at = mode.find('/');
return at == std::string_view::npos ? std::string_view{} : mode.substr(0, at);
}
void queue(Store& s, std::string_view plugin, Event event) {
if (plugin.empty()) return;
auto found = s.events.find(plugin);
if (found == s.events.end()) {
if (s.events.size() >= most_queues) return;
found = s.events.emplace(std::string(plugin), std::vector<Event>{}).first;
}
if (event.args.size() > most_args) event.args.resize(most_args);
for (auto& a : event.args)
if (a.size() > most_arg) a.resize(most_arg);
event.plugin = std::string(plugin);
auto& list = found->second;
if (list.size() >= most_events) list.erase(list.begin()); // the newest matter more
list.push_back(std::move(event));
}
} // namespace
std::string publish(std::string_view plugin, std::string_view json_text) {
if (json_text.size() > most_document) return "a throwdown document is at most 512 KB as JSON";
try {
return publish(plugin, Json::parse(json_text));
} catch (const std::exception& e) {
return std::string("not a throwdown document: ") + e.what();
}
}
std::string publish(std::string_view plugin, const Json& j) {
if (plugin.empty()) return "a document belongs to a plugin";
if (!j.is_object()) return "a throwdown document is a table";
auto dumped = j.dump();
if (dumped.size() > most_document) return "a throwdown document is at most 512 KB as JSON";
{
auto& s = store();
std::lock_guard lock(s.mutex);
if (const auto found = s.plugins.find(plugin); found != s.plugins.end() && found->second.text == dumped) return {};
}
// Parsed outside the lock: the UI thread reads while the plugin thread publishes.
Documents d;
d.plugin = std::string(plugin);
if (const auto* v = field(j, "setup"); v && v->is_object()) d.setup = setup_of(plugin, *v);
if (const auto* v = field(j, "waiting"); v && v->is_object()) d.waiting = waiting_of(plugin, *v);
if (const auto* v = field(j, "hud"); v && v->is_object()) d.hud = hud_of(*v);
if (const auto* v = field(j, "panel"); v && v->is_object()) d.panel = panel_of(*v);
d.flags = list<Flag>(j, "flags", most_rows, [&](const Json& f) { return flag_of(plugin, f); });
if (const auto* v = field(j, "match"); v && v->is_object()) d.match = match_of(*v);
auto& s = store();
std::lock_guard lock(s.mutex);
d.revision = ++s.revision;
auto& entry = s.plugins[std::string(plugin)];
entry.documents = std::move(d);
entry.text = std::move(dumped);
return {};
}
void release(std::string_view plugin) {
auto& s = store();
std::lock_guard lock(s.mutex);
if (const auto found = s.plugins.find(plugin); found != s.plugins.end()) s.plugins.erase(found);
if (const auto found = s.events.find(plugin); found != s.events.end()) s.events.erase(found);
if (plugin_of(s.current) == plugin) s.current.clear();
++s.revision;
}
std::vector<Documents> documents() {
auto& s = store();
std::lock_guard lock(s.mutex);
std::vector<Documents> out;
out.reserve(s.plugins.size());
for (const auto& [plugin, entry] : s.plugins) out.push_back(entry.documents);
return out;
}
std::optional<Documents> documents_for(std::string_view mode) {
const auto plugin = plugin_of(mode);
if (plugin.empty()) return std::nullopt;
auto& s = store();
std::lock_guard lock(s.mutex);
const auto found = s.plugins.find(plugin);
if (found == s.plugins.end()) return std::nullopt;
return found->second.documents;
}
std::optional<Documents> active() {
auto& s = store();
std::lock_guard lock(s.mutex);
for (const auto& [plugin, entry] : s.plugins)
if (asks(entry.documents.match) || entry.documents.hud.active) return entry.documents;
return std::nullopt;
}
Match match() noexcept {
Match out;
auto& s = store();
std::lock_guard lock(s.mutex);
for (const auto& [plugin, entry] : s.plugins) {
const auto& m = entry.documents.match;
out.markers |= m.markers;
out.input_lock |= m.input_lock;
out.offboard |= m.offboard;
out.teleports |= m.teleports;
out.in_activity |= m.in_activity;
for (const auto who : m.hidden)
if (std::find(out.hidden.begin(), out.hidden.end(), who) == out.hidden.end()) out.hidden.push_back(who);
}
return out;
}
bool hidden(std::uint64_t player) noexcept {
if (!player) return false;
auto& s = store();
std::lock_guard lock(s.mutex);
for (const auto& [plugin, entry] : s.plugins) {
const auto& h = entry.documents.match.hidden;
if (std::find(h.begin(), h.end(), player) != h.end()) return true;
}
return false;
}
void post(std::string_view mode, std::string action, std::vector<std::string> args) {
auto& s = store();
std::lock_guard lock(s.mutex);
queue(s, plugin_of(mode), Event{{}, std::move(action), std::string(mode), std::move(args)});
}
void post_to(std::string_view plugin, std::string action, std::string mode, std::vector<std::string> args) {
auto& s = store();
std::lock_guard lock(s.mutex);
queue(s, plugin, Event{{}, std::move(action), std::move(mode), std::move(args)});
}
std::vector<Event> take(std::string_view plugin) {
auto& s = store();
std::lock_guard lock(s.mutex);
const auto found = s.events.find(plugin);
if (found == s.events.end()) return {};
auto out = std::move(found->second);
s.events.erase(found);
return out;
}
void set_current(std::string_view mode) {
auto& s = store();
std::lock_guard lock(s.mutex);
s.current = std::string(mode.substr(0, std::min<std::size_t>(mode.size(), 160)));
}
std::string current() {
auto& s = store();
std::lock_guard lock(s.mutex);
return s.current;
}
} // namespace dingosdk::throwdowns::kit
+268
View File
@@ -0,0 +1,268 @@
#pragma once
#include <array>
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
// The throwdown kit: throwdown modes made by plugins, shown with skate.'s native widgets. A plugin
// registers its modes (custom_modes.h) and publishes documents (the setup page, the waiting card,
// the in-game HUD, the match panel, its flags and what a running match asks of the game); the
// native UI renders whichever documents are current and reports what the player does as events
// for the plugin. Nothing here knows any particular mode: a mode is an id and the data its plugin
// sends. Documents arrive on the plugin thread and are read on the UI thread: every accessor
// returns a copy.
namespace dingosdk {
class Json;
}
namespace dingosdk::throwdowns::kit {
using Vec3 = std::array<float, 3>;
// ---- documents --------------------------------------------------------------------------------
struct SetupRow {
std::string id, kind; // "slider" | "choice" | "button" | "info"
std::string caption, label;
float min{}, max{}, step{1}, value{};
std::vector<std::string> options; // choice
int selected{}; // choice: 0-based
};
struct SetupSlider {
bool present{};
std::string caption;
float min{}, max{}, step{1}, value{};
};
struct Setup {
std::string mode; // the registered mode id ("<plugin>/<id>", custom_modes Mode::key())
std::string title, art, description, back;
SetupSlider players, timer;
std::vector<SetupRow> rows;
std::vector<std::string> hide_native{"rounds"};
};
struct Waiting {
std::string mode, title, art, status;
unsigned enrolled{}, capacity{};
bool can_start{};
bool countdown{};
std::uint32_t remaining_ms{}, total_ms{};
};
struct Mark {
std::string text;
bool on{};
};
// A clock the native HUD ticks itself between publications.
struct Clock {
bool present{};
std::uint32_t remaining_ms{}, total_ms{};
std::uint64_t sampled_ms{}; // GetTickCount64 when remaining_ms was true
bool ticking{}, up{}; // up: counts up from remaining_ms (an elapsed time) to total_ms
};
struct BoardRow {
std::string rank, name, value;
std::uint32_t color{0xffffffffU};
bool self{}, out{};
std::vector<Mark> marks;
Clock clock; // present: the value is this clock as mm:ss.mmm, ticked by the HUD
};
struct TimerText {
std::string caption, text;
Clock clock; // present: the text is this clock as m:ss.cc
};
struct Prompt {
std::vector<std::string> inputs; // "UI_DpadLeft", "UI_ShoulderL", ...
std::string caption, glyph;
float hold{}; // 0..1: a hold button's progress
};
struct Marker {
Vec3 at{};
std::string text;
bool distance{};
};
struct Hud {
bool active{};
std::string theme;
struct Header {
std::string title, art, status;
std::array<float, 2> offset{};
unsigned icon_size{}, frame_width{}; // 0: the theme's measured header
} header;
std::string layout; // "side" | "bottom"
std::vector<std::string> counters;
float score_width{480};
std::string score_art; // the counters' widget family ("" = HUD_<theme>_Score)
Clock clock;
struct Board {
bool present{};
std::string title, variant; // variant: "stamps" (a name and a stamp per mark) | ""
std::string style; // "outlined" | "plain"
std::string name_art, out_art; // widget families of the name cell ("" = HUD_<theme>_Name)
// The rank and value cells' widget families ("" = HUD_<theme>_Rank / HUD_<theme>_Value), the
// value cell's least width (0: 224 px) and a text its values may carry before a clock
// ("DNF "), reserved in its width.
std::string rank_art, value_art, reserve;
float value_width{};
std::string heading; // "list": a label row over the counters and rows, all one list; "shared"
std::vector<std::string> columns;
std::vector<BoardRow> rows;
} board;
struct Results {
bool present{};
std::string title, notice, caption;
// The heading's widget family ("" = ResultsHeading) and its title's width (0: fitted). The
// rows take the board's cell families (board.name_art, rank_art, value_art, ...).
std::string heading_art;
float title_width{};
std::string style; // "outlined" (as the board's: clean-outline cells, the red glow on tinted values) | "plain"
std::vector<BoardRow> rows;
} results;
struct Intro {
bool present{};
std::string title, art_key, rules;
std::uint64_t until_ms{};
} intro;
Clock countdown; // 3-2-1-GO
struct Text {
std::string text, style;
std::uint64_t serial{};
std::uint32_t ms{};
std::string subtitle, art; // the banner's (its text is the title); a warning's art is its widget family
} banner, callout, warning;
struct Card {
bool present{};
std::string caption, title, image;
} card;
struct List {
bool present{};
std::string caption;
std::vector<std::string> items;
} list;
struct Position {
bool present{};
unsigned place{};
std::string medal;
std::string art; // the widget family, "<art>_<Medal>_Widget" ("" = HUD_<theme>_Position)
} position;
std::vector<TimerText> timers;
std::string timer_art; // the timers' widget family ("" = HUD_<theme>_Elapsed)
struct Compass {
bool present{};
Vec3 at{};
std::string art;
} compass;
std::vector<Marker> markers;
struct Blackout {
bool present{};
std::string text;
} blackout;
struct Editor {
bool present{};
std::string title, variant; // variant: "builder" | "compact"
std::vector<Prompt> prompts;
std::string info_title;
std::vector<std::pair<std::string, std::string>> info;
std::string toast;
float toast_alpha{};
std::string modal;
std::vector<Prompt> modal_actions;
} editor;
};
struct PanelSetting {
std::string icon, caption, value;
};
struct PanelPlayer {
std::uint64_t id{};
std::string name, value, rank, outcome, portrait;
bool self{};
};
struct Panel {
bool present{};
std::string title, description, art, score_caption, quit;
std::vector<PanelSetting> settings;
std::vector<Prompt> prompts; // each prompt's glyph field carries its command
std::vector<PanelPlayer> players;
};
struct Flag {
std::string id, mode;
std::uint64_t leader{};
std::uint32_t game{};
Vec3 at{};
float yaw{};
unsigned players{1}, capacity{6};
bool open{true};
std::vector<std::pair<std::string, std::string>> stats;
};
struct Match {
bool markers{}, input_lock{}, offboard{}, teleports{}, in_activity{};
std::vector<std::uint64_t> hidden;
};
struct Documents {
std::string plugin;
std::optional<Setup> setup;
std::optional<Waiting> waiting;
Hud hud;
Panel panel;
std::vector<Flag> flags;
Match match;
std::uint64_t revision{};
};
// ---- the store --------------------------------------------------------------------------------
// A plugin's documents (JSON with the field names above; Steam IDs as strings). Empty when taken,
// else why not.
std::string publish(std::string_view plugin, std::string_view json_text);
// The same, already parsed (the plugin API has the table as JSON).
std::string publish(std::string_view plugin, const Json& document);
// The plugin stopped: its documents and its pending events go.
void release(std::string_view plugin);
// Every plugin's current documents (the UI reads the one whose mode is open or playing).
std::vector<Documents> documents();
// The documents of the plugin that registered `mode` ("<plugin>/<id>", custom_modes Mode::key()), if it published any.
std::optional<Documents> documents_for(std::string_view mode);
// The plugin whose match is running now (its `match` asks something, or its hud is active).
std::optional<Documents> active();
// What every running match asks, merged (any plugin's `true` holds).
Match match() noexcept;
bool hidden(std::uint64_t player) noexcept;
// ---- events -----------------------------------------------------------------------------------
struct Event {
std::string plugin, action, mode;
std::vector<std::string> args;
};
// From the native UI (any thread): queued for the plugin that registered `mode` (or `plugin`).
void post(std::string_view mode, std::string action, std::vector<std::string> args = {});
void post_to(std::string_view plugin, std::string action, std::string mode, std::vector<std::string> args = {});
// What a plugin should hear since the last call (the plugin host delivers them as `throwdown_ui`).
std::vector<Event> take(std::string_view plugin);
// The mode whose setup page or flag the player is working with now (set by the native UI on
// `open`, cleared when the page closes and no flag is down).
void set_current(std::string_view mode);
std::string current();
// ---- the native flag ---------------------------------------------------------------------------
// One native flag registration at a time, for one plugin mode: the stock Spot Battle setup page
// (relabelled from the mode's `setup` document), native flag placement, then the waiting card,
// until the match starts or the owner leaves. Events go to the mode's plugin: setup_confirm,
// flag_placed {game, x, y, z, yaw}, setup_back, start, leave, flag_left.
namespace flag {
struct Options {
unsigned players = 4, seconds = 120;
bool setup_page = true; // false: straight to placement (a re-placed start flag)
float cursor_height = 0; // > 0: the placement cursor may rise this far (metres)
};
// False when another registration is busy.
bool begin(std::string_view mode, std::uint32_t game, const Options& options);
struct State {
bool owned{}, waiting{}, placing{}, busy{};
std::string mode;
std::uint32_t game{};
};
State state() noexcept;
// The owner is done with it (refused, finished, cancelled): the registration goes.
void leave() noexcept;
// A guest joined another player's flag through the stock Join (throwdown_relay): the registration
// is adopted so the native waiting card and Leave stay in charge.
bool adopt(std::string_view mode, std::uint32_t game, std::uint32_t mmid, const Vec3& at, float yaw, unsigned seconds, unsigned capacity);
} // namespace flag
} // namespace dingosdk::throwdowns::kit
+392 -81
View File
@@ -1,5 +1,8 @@
#include "throwdown_lab.h"
#include "custom_placement.h"
#include "native_flag_session.h"
#include "native_flag_spawn.h"
#include "throwdown_kit.h"
#include "custom_modes.h"
#include "native_type_scan.h"
#include "Engine/Core/Hooks/hooks.h"
#include "Engine/Core/Log/logging.h"
@@ -18,6 +21,8 @@
#include "skate_trick_rule.h"
#include "graph_throttle.h"
#include "virtual_player_names.h"
#include "native_flag_preview.h"
#include "Extension/UI/NativeMenu/native_menu_data.h"
#include <array>
#include <atomic>
#include <charconv>
@@ -36,14 +41,23 @@
#include <vector>
// Research tools for multiplayer throwdowns. See analysis/throwdowns-native-mp.md §8
// for the experiments they serve; nothing here runs unless a console command asks,
// except the participant-change log and the GetPlayerName null fix.
// for the experiments they serve. Console commands opt into event injection;
// participant/ending diagnostics and the GetPlayerName null fix run automatically.
namespace dingosdk::multiplayer {
namespace {
using Address = std::uintptr_t;
namespace engine = addr::engine;
namespace throwdowns = addr::native_throwdowns;
namespace kit = dingosdk::throwdowns::kit;
constexpr std::uint32_t client_realm = 0xbf0f9789, server_realm = 0x98be5555;
// A plugin mode's flag InstanceId and placement preview (custom_modes Mode::flag); nothing when
// no plugin here registered the mode. custom_modes is thread-safe, the menu registry is not.
std::optional<native_flag::FlagAssets> mode_flag_assets(std::string_view key) {
if(key.empty())return {};
const auto mode=custom_throwdowns::find(key);
if(!mode)return {};
return native_flag::flag_assets(mode->flag);
}
// Runtime event hashes (== EBX TypeNameHash for events) and field NameHashes. MMId
// is hashed case-insensitively: MMID, MMid, MMId and Mmid all share one hash.
@@ -66,6 +80,10 @@ constexpr std::uint32_t event_slam_hit = 0x453fe31f, event_leave_in_progress = 0
// CoopChallengeCelebrationRuleData.Activate (client): builds CoopChallengeCelebrationLocation in its
// locals: +0 PlayerLocation, +0x90 PlayerCount, +0xa0/+0xd0/+0x100 Player2..4 (0x30 bytes each).
constexpr std::uint32_t graph_celebration = 0x4234de87;
// Compiled resource identities of the shipped controller and animation rules.
// Capture their live argument contracts without invoking or changing them.
constexpr std::array<std::uint32_t,4> native_ending_graphs{
0xb86f249d,0x84d7125e,0x32ca5df8U,0x3c26292fU};
// Party beacons (analysis/party-re/beacons.md): PlayerSpawnedEntityManager's requests
// {NetworkedEventPayload, +0x10 Location (LinearTransform), +0x50 EntityId} (Despawn: +8 EntityId),
// client -> server, route 5. The beacon's EntityId is djb2("BEACON").
@@ -92,6 +110,8 @@ constexpr std::uint32_t field_params = 3750769597U, field_parameters = 188648851
// while a handler graph of that event runs, the id natives answer the injected
// sender if that event is the oldest one not yet handled.
constexpr std::string_view sender_marker = "rsk:"; // still written, for traces
std::atomic<std::uint32_t> native_solo_queue{};
std::atomic<std::uint64_t> native_queue_revision{};
struct Handled { std::uint32_t event; std::array<std::uint32_t, 3> graphs; };
constexpr std::array handled_events{
Handled{0xb2fc396c, {0x04e2faaf}}, // RequestTurnEnd: TurnBasedServer.OnRequestTurnEnd
@@ -157,6 +177,7 @@ struct Job {
std::uint64_t token{};
std::vector<std::uint8_t> placement, settings;
std::array<std::uint8_t, 28> trick{}; // attempt (flag = landed)
float spawn_yaw{};
};
struct Lab {
Address base{};
@@ -221,6 +242,7 @@ struct Lab {
std::uint32_t hosted_mmid{}; // MMID of the local player's own drop (Update/HostSet)
std::atomic<bool> leaderboard_live{}; // leaderboards[0] belongs to the throwdown running now
std::atomic<bool> local_entered{}; // the server just made an event of the local player's queue
std::atomic<Address> local_entered_activity{}; // candidate until QueueFilled confirms it
std::atomic<std::uint32_t> server_active{}; // turn-based: whose turn the local server started last
std::array<std::atomic<std::uint32_t>, 16> players{};
// `throwdown trace`: every authored graph run in the window, per realm.
@@ -504,6 +526,7 @@ void participants_changed_hook(Address activity, const Address* added, const Add
auto& l = lab();
const auto current = realm();
const auto joined = id_list(added);
const auto left = id_list(removed);
if (l.participant_logs.fetch_add(1) < 400) {
const auto text = [](const std::vector<std::uint32_t>& ids) {
std::string out;
@@ -512,16 +535,18 @@ void participants_changed_hook(Address activity, const Address* added, const Add
};
logging::log(logging::Level::info, logging::Channel::progression,
"Throwdown lab: participants changed ({} realm, activity {:#x}): added [{}] removed [{}].",
realm_name(current), activity, text(joined), text(id_list(removed)));
realm_name(current), activity, text(joined), text(left));
}
// The server sets an event's participants when a queue the local player is
// in becomes the event (ThrowdownRegistration.QueueFilled), host first.
const auto local = local_native_player_id();
bool cooperative{};
// The coop challenge that just started: its activity, and its end for the local player.
if (current == server_realm && local) {
if (std::ranges::find(joined, local) != joined.end() && l.challenge_activity_pending.exchange(false))
if (std::ranges::find(joined, local) != joined.end() && l.challenge_activity_pending.exchange(false)) {
l.challenge_activity.store(activity, std::memory_order_release);
const auto left = id_list(removed);
cooperative = true;
}
if (activity && activity == l.challenge_activity.load(std::memory_order_acquire) &&
std::ranges::find(left, local) != left.end()) {
l.challenge_activity.store(0, std::memory_order_release);
@@ -529,7 +554,7 @@ void participants_changed_hook(Address activity, const Address* added, const Add
}
}
if (current == server_realm && local) {
const auto left = id_list(removed);
for (const auto player : left) native_throwdown_participant_left(activity, player);
if (std::ranges::find(left, local) != left.end()) {
// Quit (LeaveInProgress), or the event let the player go: its boards are
// no longer the local player's and nothing is relayed for it any more.
@@ -544,6 +569,8 @@ void participants_changed_hook(Address activity, const Address* added, const Add
}
if (current == server_realm && local && std::ranges::find(joined, local) != joined.end()) {
l.local_entered.store(true, std::memory_order_release);
if (!cooperative && local_throwdown_active())
l.local_entered_activity.store(activity, std::memory_order_release);
ThrowdownLocalAction action{ThrowdownLocalAction::Kind::entered};
action.participants = joined;
throwdown_relay_local(std::move(action));
@@ -1081,7 +1108,7 @@ std::string run_job(const Job& job) {
const auto vector = [&](std::uint32_t hash, std::size_t count) {
const auto f = find_field(p->type, hash);
if (!f || f->size < count * 4) throw std::runtime_error(std::format("Throwdown lab: vector field {:#x} missing", hash));
std::array<float, 4> v{pos[0], pos[1], pos[2], 0};
std::array<float, 4> v{pos[0], pos[1], pos[2], job.spawn_yaw};
std::memcpy(reinterpret_cast<void*>(at + f->offset), v.data(), count * 4);
};
vector(field_spawn, 4);
@@ -1338,7 +1365,7 @@ std::string run_job(const Job& job) {
Instance e(type_of(event_destroy));
e.set_u32(field_mmid, mmid);
e.send();
custom_throwdowns::observe_destroyed(mmid);
native_flag::observe_destroyed(mmid);
return std::format("RequestThrowdownDestroyGlobal sent for MMID {:#x}", mmid);
}
}
@@ -1442,6 +1469,7 @@ void initialize_throwdown_lab(Address base) noexcept {
throw std::runtime_error("Throwdown lab: cannot enable hooks");
l.installed.store(true, std::memory_order_release);
} catch (const std::exception& e) { logging::write(logging::Level::warning, logging::Channel::progression, e.what()); }
#if !defined(DINGOSDK_LEAN_PLAYTEST)
// The condition probe replaces the game's AND / OR / NOT, which every authored
// condition runs through; opt in with RESKATE_THROWDOWN_PROBE=1.
if (!GetEnvironmentVariableW(L"RESKATE_THROWDOWN_PROBE", nullptr, 0)) return;
@@ -1457,9 +1485,27 @@ void initialize_throwdown_lab(Address base) noexcept {
hook_enable(reinterpret_cast<void*>(base + op.rva)) == HookOk) ++count;
logging::log(logging::Level::info, logging::Channel::progression, "Throwdown lab: condition probe on {} of 3 boolean ops.", count);
} catch (...) {}
#endif
}
void observe_throwdown_send(std::uint32_t hash, Address type, Address payload) noexcept {
if(official_panel_trace_active()) {
try {
std::array<Address,12> words{};const bool readable=payload && memory::peek_bytes(payload,words.data(),sizeof(words));
std::string raw;if(readable)for(const auto word:words)raw+=std::format(" {:#x}",word);
logging::log(logging::Level::info,logging::Channel::ui,"PanelTrace network event: tick={}, hash={:#x}, type={:#x}, payload={:#x}, injected={}; words{}.",GetTickCount64(),hash,type,payload,injecting,raw);
} catch(...) {}
}
if(!injecting && type && payload && (hash==event_create || hash==event_add_participant) &&
!native_flag::registration_owned()) {
try {
const auto field=find_field(type,field_mmid);
const auto mmid=field && field->size==4?read<std::uint32_t>(payload+field->offset):0;
// A late participant event for our retired native donor is not a
// fresh stock challenge. Keep its waiting card suppressed.
if(!native_flag::known_queue(mmid))native_queue_revision.fetch_add(1,std::memory_order_release);
} catch(...) { }
}
auto& l = lab();
if (!l.installed.load(std::memory_order_acquire) || injecting || !type || !payload) return;
if (handled(hash)) { try { note_sent(hash, 0); } catch (...) {} }
@@ -1564,22 +1610,7 @@ void observe_throwdown_send(std::uint32_t hash, Address type, Address payload) n
ThrowdownLocalAction action{ThrowdownLocalAction::Kind::exited};
if (const auto f = find_field(type, field_is_cancelling); f && f->size == 1)
action.cancelling = read<std::uint8_t>(payload + f->offset) != 0;
// Leaving a plugin mode's flag placement. A flag that is down is offered to the other
// players as that mode's (throwdown_relay.cpp), never as a Spot Battle.
if (custom_throwdowns::placement_active()) {
custom_throwdowns::observe_exit(action.cancelling);
const auto offer = custom_throwdowns::flag_offer();
if (!offer) return;
{
std::lock_guard lock(l.mutex);
action.series = l.series; action.mmid = l.hosted_mmid;
action.placement = l.params; action.settings = l.host_params;
}
if (action.mmid != offer->mmid) return;
action.mode = offer->key; action.flag = offer->flag; action.mode_players = offer->players;
throwdown_relay_local(std::move(action));
return;
}
if(native_flag::placement_active()) { native_flag::observe_exit(action.cancelling); return; }
{
std::lock_guard lock(l.mutex);
action.series = l.series; action.mmid = l.hosted_mmid;
@@ -1661,26 +1692,31 @@ void observe_throwdown_send(std::uint32_t hash, Address type, Address payload) n
std::string series; std::uint32_t mmid{};
std::vector<std::uint8_t> params; std::string note;
bool placed{};
std::optional<std::array<float, 3>> selected_spawn;
float selected_yaw{};
std::uint32_t selected_players{};
std::optional<std::array<float,3>> selected_spawn;
std::optional<std::array<float,16>> selected_transform;
if (const auto f = find_field(type, field_mmid); f && f->size == 4) mmid = read<std::uint32_t>(payload + f->offset);
if (const auto f = find_field(type, field_series); f && f->kind == kind_string) series = read_string(payload + f->offset);
if (const auto p = find_field(type, field_params); p && p->kind == kind_struct) {
if(const auto limit=find_field(p->type,field_max_players);limit && limit->size==4)
selected_players=read<std::uint32_t>(payload+p->offset+limit->offset);
capture_value(p->type, payload + p->offset, params, 0);
note = describe_params(p->type, payload + p->offset);
if (const auto f = find_field(p->type, field_series); f && f->kind == kind_string)
series = read_string(payload + p->offset + f->offset);
std::array<float, 3> spawn{};
if (const auto f = find_field(p->type, field_spawn); f && f->size >= 12 && memory::read(payload + p->offset + f->offset, spawn)) {
selected_spawn = spawn;
// CreateThrowdownQueueParams.SpawnPoint is XYZ and a yaw in radians.
std::array<float, 4> pose{};
if (f->size == 16 && memory::read(payload + p->offset + f->offset, pose)) selected_yaw = pose[3];
else if (const auto t = find_field(p->type, field_teleport); t && t->size == 64) {
std::array<float, 16> transform{};
if (memory::read(payload + p->offset + t->offset, transform)) selected_yaw = std::atan2(transform[8], transform[10]);
selected_spawn=spawn;
if(f->size==16) {
std::array<float,4> pose{};
if(memory::read(payload+p->offset+f->offset,pose))
selected_transform=native_flag::native_spawn_transform(pose);
}
}
if(!selected_transform)if(const auto f=find_field(p->type,field_teleport);f && f->size==64) {
std::array<float,16> transform{};
if(memory::read(payload+p->offset+f->offset,transform))selected_transform=transform;
}
placed = spawn[0] != 0 || spawn[1] != 0 || spawn[2] != 0;
}
{
@@ -1701,13 +1737,19 @@ void observe_throwdown_send(std::uint32_t hash, Address type, Address payload) n
"Throwdown lab: local client sent {:#x}; series {} MMID {:#x}{}{}.", hash, l.series, l.mmid,
note.empty() ? "" : "; ", note);
}
if (custom_throwdowns::registration_owned()) {
// A plugin mode's temporary flag: its own news, never a drop to relay.
custom_throwdowns::observe_queue(mmid);
if (hash == event_create) custom_throwdowns::observe_created();
if (hash == event_update && selected_spawn) custom_throwdowns::observe_position(mmid, *selected_spawn, selected_yaw);
if(native_flag::registration_owned()) {
// Keep native type caching, but never advertise a plugin mode's
// temporary Spot Battle registration as a separate multiplayer match.
native_flag::observe_queue(mmid);
if(hash==event_create) native_flag::observe_created();
if(hash==event_update && selected_spawn) native_flag::observe_position(mmid,*selected_spawn,selected_transform?&*selected_transform:nullptr);
return;
}
if(hash==event_create)native_solo_queue.store(0);
if(hash==event_host_set && selected_players) native_throwdown_player_limit(selected_players);
if((hash==event_update || hash==event_host_set) && mmid && selected_players)
native_solo_queue.store(selected_players==1?mmid:0);
if((hash==event_force_start || hash==event_destroy) && mmid==native_solo_queue.load())native_solo_queue.store(0);
using Kind = ThrowdownLocalAction::Kind;
const auto kind = hash == event_create ? Kind::created : hash == event_add_participant ? Kind::joined
: hash == event_force_start ? Kind::force_started : hash == event_destroy ? Kind::destroy_requested
@@ -1721,12 +1763,107 @@ void observe_throwdown_send(std::uint32_t hash, Address type, Address payload) n
} catch (...) {}
}
void prepare_native_solo_throwdown_leave(std::uint32_t hash) noexcept {
if (hash != event_leave_in_progress || injecting || multiplayer_session_active() ||
native_flag::registration_owned() || kit::match().markers) return;
profile_runtime::PreserveError preserve;
try {
const auto player = local_native_player_id();
const auto activity = native_solo_throwdown_activity(player);
const auto type = activity ? type_of(event_force_destroy) : 0;
if (!type) return;
// Send while the player is still a participant. This native handler
// resolves the sender's current event; sending after RemoveParticipant
// has run cannot find it. Its normal destruction releases the client
// activity manager and emits the native lifecycle notifications.
// Keep the original leave! ForceDestroy schedules destruction, while
// LeaveInProgressRequested returns the participant confirmation that
// releases the client's manager immediately. Consuming Leave skipped
// that reset and stranded the native activity state.
Instance destroy(type);
destroy.send(5, player);
logging::log(logging::Level::info, logging::Channel::progression,
"Throwdown: solo host {:#x} quitting activity {:#x}; native event destruction requested.", player, activity);
} catch (...) {}
}
bool consume_flag_throwdown_send(std::uint32_t hash,Address type,Address payload) noexcept {
if(!injecting && (hash==event_leave_in_progress || hash==event_force_destroy) && kit::match().markers) {
// The native activity pause modal uses these same Quit Match requests. A plugin
// match's temporary flag is already gone: its plugin ends the match.
if(native_flag::quit_match())return true;
}
if(injecting || !native_flag::registration_owned() || !type || !payload)return false;
try {
if(hash==event_create || hash==event_update || hash==event_host_set) {
const auto mmid=find_field(type,field_mmid);
const bool owned=hash==event_create || (mmid && mmid->size==4 &&
native_flag::owns_registration(read<std::uint32_t>(payload+mmid->offset)));
const auto params=find_field(type,field_params);
if(owned && params && params->kind==kind_struct) {
const auto instance=find_field(params->type,field_instance);
const auto series=find_field(params->type,field_series);
if(instance && instance->kind==kind_string && series && series->kind==kind_string &&
read_string(payload+params->offset+series->offset)=="SpotBattle") {
// Native registry selects RegistrationPrefab by Series + InstanceId. Keep the
// Spot Battle backend protocol while choosing the mode's own flag mesh; a mode
// without one keeps the stock flag.
const auto key=native_flag::mode();
const auto assets=mode_flag_assets(key);
if(assets && !assets->instance.empty()) {
assign_string(payload+params->offset+instance->offset,assets->instance);
if(hash==event_create)logging::log(logging::Level::info,logging::Channel::ui,
"Custom Throwdown: {} uses the {} physical flag.",key,assets->instance);
}
}
}
if(hash==event_create || hash==event_update)return false;
}
if(hash!=event_force_start && hash!=event_destroy && hash!=event_host_set)return false;
const auto f=find_field(type,field_mmid);
if(!f || f->size!=4 || !native_flag::owns_registration(read<std::uint32_t>(payload+f->offset)))return false;
if(hash==event_host_set) {
// This registration supplies only the native flag and waiting card. Its queue must
// not fill from Players 1 and start Spot Battle before the host presses the mode's
// Start. The plugin keeps the selected capacity; the picker uses the stock maximum.
const auto params=find_field(type,field_params);
if(params && params->kind==kind_struct) {
const auto limit=find_field(params->type,field_max_players);
if(limit && limit->size==4) {
const auto at=payload+params->offset+limit->offset;
const auto selected=read<std::uint32_t>(at);
if(selected>=1 && selected<=10) {
const std::uint32_t picker_capacity=10;
SIZE_T written{};
if(WriteProcessMemory(GetCurrentProcess(),reinterpret_cast<void*>(at),&picker_capacity,sizeof(picker_capacity),&written) && written==sizeof(picker_capacity))
logging::log(logging::Level::info,logging::Channel::ui,"Native flag: held the queue open; the mode keeps Players {}.",selected);
}
}
}
return false;
}
// The waiting card's Start is the mode's: the Spot Battle donor never starts.
if(hash==event_force_start) {
native_flag::start();
return true;
}
// Destroy is the waiting card's Leave: the native teardown still runs.
native_flag::leave();
} catch(...) {}
return false;
}
bool native_solo_throwdown_waiting() noexcept { return native_solo_queue.load()!=0; }
std::uint64_t native_throwdown_queue_revision() noexcept {return native_queue_revision.load(std::memory_order_acquire);}
namespace {
std::uint64_t now_ms() {
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
}
void trace_expression(Address vm) {
#if defined(DINGOSDK_LEAN_PLAYTEST)
(void)vm;
#else
auto& l = lab();
const auto now = now_ms();
if (now >= l.trace_end_ms) {
@@ -1760,6 +1897,7 @@ void trace_expression(Address vm) {
auto& seen = l.trace[key];
if (!seen.count++) seen.first_ms = now;
seen.last_ms = now;
#endif
}
// The pump runs after every expression the game executes, thousands a frame, and almost all
@@ -1772,6 +1910,7 @@ bool lab_graph(Address vm) noexcept {
std::vector<std::uint32_t> v{graph_celebration, graph_criteria_attempt, graph_queue_filled, graph_round_end_update,
graph_turn_start, graph_skate_timer, graph_turn_end_begin,
0xc33322b2, 0xc4a37359, 0xcc6467cf, 0xf073dd02};
v.insert(v.end(),native_ending_graphs.begin(),native_ending_graphs.end());
for (const auto& h : handled_events) v.insert(v.end(), h.graphs.begin(), h.graphs.end());
std::ranges::sort(v);
v.erase(std::unique(v.begin(), v.end()), v.end());
@@ -1783,6 +1922,141 @@ bool lab_graph(Address vm) noexcept {
}
} // namespace
namespace {
#if !defined(DINGOSDK_LEAN_PLAYTEST)
struct OfficialPanelTrace {
std::atomic<bool> active{};
std::uint64_t until{},epoch{};
bool key_down{};
std::mutex mutex;
std::map<std::uint64_t,std::uint64_t> seen;
unsigned count{};
};
OfficialPanelTrace& official_trace() {static OfficialPanelTrace trace;return trace;}
void trace_official_expression(Address vm,Address resource,std::uint32_t hash,std::uint32_t pc) noexcept {
auto& trace=official_trace();
const bool expanded=trace.active.load(std::memory_order_relaxed);
static constexpr std::array rare{0x05df4a18U,0x08a3b8c5U,0x0f131312U,0x10f2a5c6U,0x159dd04cU,0x166520aeU,0x244f2b0dU,0x286f4326U,0x2c110143U,0x3a74a736U,0x3b76eec2U,0x3d87fb6bU,0x51ad4a6dU,0x52febaa3U,0x551d7071U,0x5809b5e7U,0x5ffae9baU,0x60d80d52U,0x67122a51U,0x6a24bd4aU,0x6fdbcb71U,0x73fd87efU,0x75062457U,0x7a9c4313U,0x8773c493U,0x8f02d506U,0x9371feafU,0x93b01eb9U,0x994b9db2U,0x9bfd7029U,0xa17442fdU,0xa6d46ee3U,0xa9782023U,0xa9b014d9U,0xaf97f9bfU,0xb200804aU,0xb60dd66dU,0xb624d980U,0xbc12f225U,0xc18609afU,0xcdaf878cU,0xcf03b69bU,0xd09d3a41U,0xd0ebcf79U,0xd770f2d4U,0xd944dd03U,0xdba685beU,0xdf9cfb9aU,0xe685e108U,0xe7cdd599U,0xeb4391eaU,0xee82ab76U,0xeeb9fbf3U,0xf19ac281U,0xf1d412d6U,0xf208ab5aU,0xf38d178fU,0xfb12c52eU};
const bool menu_entry=std::binary_search(rare.begin(),rare.end(),hash);
if(!expanded && !menu_entry)return;
try {
profile_runtime::PreserveError preserve;
const auto now=GetTickCount64();
std::unique_lock lock(trace.mutex,std::try_to_lock);if(!lock.owns_lock())return;
const auto key=(std::uint64_t{hash}<<32)|pc;
if(const auto found=trace.seen.find(key);found!=trace.seen.end() && (expanded || now<found->second+1000))return;
if(trace.count>=768 && expanded)return;
trace.seen[key]=now;++trace.count;
const auto layout=read<std::array<std::uint32_t,10>>(resource+0x20);
Address instance{},arguments{},locals{};
memory::peek(vm+0x30,instance);
if(instance && layout[0]<0x10000) {
const auto frame=(layout[0]+15U)&~15U;
memory::peek(instance+frame+8,arguments);memory::peek(instance+frame+16,locals);
}
std::string regs;
if(arguments)for(unsigned i=0;i<16;++i) {
Address value{};if(!memory::peek(arguments+i*8,value))break;
regs+=std::format(" {}:{:#x}",i,value);
}
std::array<void*,12> frames{};
const auto count=CaptureStackBackTrace(0,static_cast<DWORD>(frames.size()),frames.data(),nullptr);
std::string callers;for(unsigned i=0;i<count;++i)callers+=std::format(" {:#x}",reinterpret_cast<Address>(frames[i]));
Address context{},model_manager{};std::uint32_t context_realm{};
const auto base=lab().base;
if(base) {
reinterpret_cast<Address (*)(Address*)>(base+engine::current_context)(&context);
const auto manager_offset=read<std::uint32_t>(base+0x7122460);
const auto realm_offset=read<std::uint32_t>(base+engine::context_type_offset);
if(context && manager_offset<0x1000000)memory::peek(context+manager_offset,model_manager);
if(context && realm_offset<0x1000000)memory::peek(context+realm_offset,context_realm);
}
logging::log(logging::Level::info,logging::Channel::ui,
"PanelTrace expression: epoch={}, tick={}, thread={}, graph={:#x}, pc={:#x}, menu_entry={}, resource={:#x}, VM={:#x}, instance={:#x}, locals={:#x}, context={:#x}, realm={:#x}, model_manager={:#x}; args{}; callers{}.",
trace.epoch,now,GetCurrentThreadId(),hash,pc,menu_entry,resource,vm,instance,locals,context,context_realm,model_manager,regs,callers);
} catch(...) { /* Diagnostics never redirect or suppress an expression. */ }
}
}
void tick_official_panel_trace(bool start_down,std::uint64_t now) noexcept {
auto& trace=official_trace();
try {
std::lock_guard lock(trace.mutex);
if(start_down && !trace.key_down) {
++trace.epoch;trace.until=now+20000;trace.seen.clear();trace.count=0;
trace.active.store(true,std::memory_order_release);
logging::log(logging::Level::info,logging::Channel::ui,"PanelTrace input: Start/Escape down; epoch={}, tick={}, tracing native expressions and Quit for 20s.",trace.epoch,now);
} else if(!start_down && trace.key_down)
logging::log(logging::Level::info,logging::Channel::ui,"PanelTrace input: released; epoch={}, tick={}.",trace.epoch,now);
trace.key_down=start_down;
if(trace.active.load() && now>=trace.until) {
trace.active.store(false,std::memory_order_release);
logging::log(logging::Level::info,logging::Channel::ui,"PanelTrace complete: epoch={}, expression entries={}.",trace.epoch,trace.count);
trace.seen.clear();trace.count=0;
}
} catch(...) {}
}
bool official_panel_trace_active() noexcept {return official_trace().active.load(std::memory_order_acquire);}
#else
void trace_official_expression(Address,Address,std::uint32_t,std::uint32_t) noexcept {}
} // namespace
void tick_official_panel_trace(bool,std::uint64_t) noexcept {}
bool official_panel_trace_active() noexcept {return false;}
#endif
void execute_throwdown_preview_expression(Address vm,std::uint32_t pc,Address profiler,
void (*runner)(Address,std::uint32_t,Address)) {
FlagPreviewPage private_page;
try {
profile_runtime::PreserveError preserve;
const auto resource=read<Address>(vm+0x38);
const auto hash=read<std::uint32_t>(resource+0x10);
trace_official_expression(vm,resource,hash,pc);
// Most expressions need no layout read, asset lookup or placement lock.
if(hash==0x73fd87ef || hash==0x08a3b8c5 || hash==0x05df4a18 || hash==0x7a9c4313 ||
hash==0xbb2b2cdd || hash==0x67122a51 || hash==0xdba685be) {
const auto layout=read<std::array<std::uint32_t,10>>(resource+0x20);
const auto instance=read<Address>(vm+0x30);
if(flag_preview_graph(hash,layout) && instance && read<Address>(instance)==resource &&
native_flag::placement_active() && !native_flag::registration_retiring()) {
const auto key=native_flag::mode();
const auto assets=mode_flag_assets(key);
if(!assets)throw std::runtime_error("custom preview owner expired");
const auto slot=instance+((layout[0]+15U)&~15U);
const auto constants=read<Address>(slot);
std::array<std::byte,1024> bytes;
if(!memory::peek_bytes(constants,bytes.data(),layout[1]))throw std::runtime_error("preview constants unavailable");
// All seven native placement entries import this one prefab.
// Match the loaded asset's exact name rather than guessing an
// ABI at a component-query native or editing shared imports.
constexpr std::string_view stock="ecs/activities/throwdowns/spotbattlethrowdownerinteractablevalidator_ecsprefab";
// A mode without its own flag keeps the stock preview.
for(std::size_t offset=0;!assets->preview.empty() && offset+8<=layout[1];offset+=8) {
Address source{},name{};std::memcpy(&source,bytes.data()+offset,8);
std::array<char,stock.size()+1> text{};
if(!source || !memory::peek(source+0x18,name) || !name ||
!memory::peek_bytes(name,text.data(),text.size()) || !flag_preview_asset_name(std::string_view(text.data(),stock.size())) || text.back())continue;
const menu_data::Context context(lab().base,0);
const auto custom=menu_data::named_asset(context,source,assets->preview.c_str());
if(!custom || read<Address>(custom+8)!=read<Address>(source+8))throw std::runtime_error("custom preview prefab is not resident");
private_page.prepare(std::span(bytes).first(layout[1]),offset,custom);
private_page.publish(*reinterpret_cast<Address*>(slot));
static std::atomic<unsigned> reports{};
if(reports.fetch_add(1)<24)logging::log(logging::Level::info,logging::Channel::ui,
"Custom Throwdown: {} placement preview bound in graph {:#x}, constant offset {}.",key,hash,offset);
break;
}
}
}
} catch(const std::exception& e) {
static std::atomic<unsigned> reports{};
if(reports.fetch_add(1)<12)logging::log(logging::Level::warning,logging::Channel::ui,"Custom Throwdown preview: {}.",e.what());
}
execute_native_party_expression(vm,pc,profiler,runner);
}
void pump_throwdown_lab(Address vm) noexcept {
auto& l = lab();
if (vm && l.installed.load(std::memory_order_acquire) && lab_graph(vm)) {
@@ -1790,6 +2064,61 @@ void pump_throwdown_lab(Address vm) noexcept {
try {
const auto resource = *reinterpret_cast<const Address*>(vm + 0x38);
const auto graph = resource ? *reinterpret_cast<const std::uint32_t*>(resource + 0x10) : 0U;
if(std::ranges::find(native_ending_graphs,graph)!=native_ending_graphs.end() && realm()==client_realm) {
static std::array<std::atomic<unsigned>,native_ending_graphs.size()> ending_captures{};
const auto index=static_cast<std::size_t>(std::ranges::find(native_ending_graphs,graph)-native_ending_graphs.begin());
const auto occurrence=ending_captures[index].fetch_add(1,std::memory_order_relaxed);
if(occurrence<4) {
const auto layout=read<std::array<std::uint32_t,10>>(resource+0x20);
const auto instance=read<Address>(vm+0x30);
if(instance && layout[0]<0x10000) {
const auto frame=(layout[0]+15U)&~15U;
const auto arguments=read<Address>(instance+frame+8);
const auto locals=read<Address>(instance+frame+16);
std::string fields;
for(unsigned i=0;i<12;++i) {
Address value{};
if(!arguments || !memory::peek(arguments+i*8,value))break;
fields+=std::format(" {}:{:#x}",i*8,value);
}
logging::log(logging::Level::info,logging::Channel::progression,
"Native ending capture: graph {:#x}, context {:#x}, VM {:#x}, instance {:#x}, resource {:#x}, layout {}, arguments {:#x}, locals {:#x}; registers{}.",
graph,read<Address>(instance+frame),vm,instance,resource,
std::format("{},{},{},{},{},{},{},{},{},{}",layout[0],layout[1],layout[2],layout[3],layout[4],layout[5],layout[6],layout[7],layout[8],layout[9]),
arguments,locals,fields);
// Snapshot Self while its VM frame still belongs to
// this invocation. A separate process reading after
// the log arrives can see an already recycled frame.
// Reads only: bounded register pages and Self children,
// no controller calls or changes to the native ending.
for(unsigned page=0;page<4;++page) {
Address at{};
if(!memory::peek(instance+frame+page*8,at) || !at)continue;
std::array<std::byte,256> bytes{};
const auto length=page==3?bytes.size():std::size_t{96};
if(!memory::peek_bytes(at,bytes.data(),length))continue;
std::string hex;
for(std::size_t i=0;i<length;++i)hex+=std::format("{:02x}",std::to_integer<unsigned>(bytes[i]));
logging::log(logging::Level::info,logging::Channel::progression,
"Native ending page: graph {:#x}, capture {}, page {}, address {:#x}, bytes {}.",
graph,occurrence,page,at,hex);
if(page!=3)continue;
for(unsigned child=0;child<4;++child) {
Address target{};
std::memcpy(&target,bytes.data()+child*8,sizeof(target));
if(target<0x10000 || (target>=l.base && target-l.base<supported_build::game_image_size))continue;
std::array<std::byte,512> data{};
if(!memory::peek_bytes(target,data.data(),data.size()))continue;
std::string child_hex;
for(const auto b:data)child_hex+=std::format("{:02x}",std::to_integer<unsigned>(b));
logging::log(logging::Level::info,logging::Channel::progression,
"Native ending Self child: graph {:#x}, slot {}, address {:#x}, bytes {}.",
graph,child,target,child_hex);
}
}
}
}
}
if (graph == graph_celebration && realm() == client_realm) {
// CoopChallengeCelebrationLocation as the graph built it in its locals (page 2).
const auto frame = read<std::uint32_t>(resource + 0x20);
@@ -1829,6 +2158,9 @@ void pump_throwdown_lab(Address vm) noexcept {
const auto frame = read<std::uint32_t>(resource + 0x20);
const auto locals = read<Address>(read<Address>(vm + 0x30) + ((frame + 15U) & ~15U) + 16);
if (locals && graph == graph_queue_filled && l.local_entered.exchange(false, std::memory_order_acq_rel)) {
const auto activity = l.local_entered_activity.exchange(0, std::memory_order_acq_rel);
if (realm() == server_realm && activity)
native_throwdown_activity_started(activity, local_native_player_id());
const auto handle = read<std::uint32_t>(locals + queue_filled_handle_local);
const auto seed = read<std::uint32_t>(locals + queue_filled_seed_local);
{ std::lock_guard lock(l.mutex); l.event_handle = handle; }
@@ -1973,59 +2305,25 @@ void pump_throwdown_lab(Address vm) noexcept {
}
void throwdown_lab_before_level_transition(unsigned next) noexcept {
if(official_panel_trace_active())logging::log(logging::Level::info,logging::Channel::ui,
"PanelTrace native level transition: tick={}, next={}.",GetTickCount64(),next);
// 14, 22 and 3 leave a level or sublevel, 24 shuts down: the level's bundles are unloaded,
// and the events' types with them.
if (next != 14 && next != 22 && next != 3 && next != 24) return;
custom_throwdowns::abandon();
native_solo_queue.store(0);
native_throwdown_level_left();
native_flag::abandon();
try {
auto& l = lab();
l.local_entered.store(false, std::memory_order_release);
l.local_entered_activity.store(0, std::memory_order_release);
std::lock_guard lock(l.mutex);
l.world.fetch_add(1, std::memory_order_acq_rel);
l.types.clear();
} catch (...) {}
}
bool consume_custom_throwdown_send(std::uint32_t hash, Address type, Address payload) noexcept {
if (injecting || !type || !payload || !custom_throwdowns::registration_owned()) return false;
try {
const auto mmid_field = find_field(type, field_mmid);
const auto mmid = mmid_field && mmid_field->size == 4 ? read<std::uint32_t>(payload + mmid_field->offset) : 0;
const bool owned = hash == event_create || custom_throwdowns::owns_registration(mmid);
if (!owned) return false;
if (hash == event_create || hash == event_update || hash == event_host_set) {
const auto params = find_field(type, field_params);
if (params && params->kind == kind_struct) {
// The native registry picks RegistrationPrefab by Series and
// InstanceId: keep Spot Battle's protocol with the mode's flag.
const auto instance = find_field(params->type, field_instance);
const auto series = find_field(params->type, field_series);
const auto flag = custom_throwdowns::flag_key();
if (!flag.empty() && instance && instance->kind == kind_string && series && series->kind == kind_string &&
read_string(payload + params->offset + series->offset) == "SpotBattle") {
assign_string(payload + params->offset + instance->offset, "ReSkate." + flag);
if (hash == event_create)
logging::log(logging::Level::info, logging::Channel::ui, "Custom throwdown: the ReSkate.{} flag.", flag);
}
// The registration only carries the flag and the waiting card:
// never let its queue fill and start a Spot Battle. The mode's
// own player count stays with the plugin.
if (const auto limit = find_field(params->type, field_max_players); hash == event_host_set && limit && limit->size == 4) {
const std::uint32_t held = 10;
SIZE_T written{};
WriteProcessMemory(GetCurrentProcess(), reinterpret_cast<void*>(payload + params->offset + limit->offset),
&held, sizeof(held), &written);
}
}
return false;
}
if (hash == event_force_start) {
custom_throwdowns::waiting_start(); // the host held Start: the plugin starts its mode
return true;
}
if (hash == event_destroy) custom_throwdowns::waiting_left();
} catch (...) {}
return false;
}
std::uint64_t native_throwdown_world() noexcept { return lab().world.load(std::memory_order_acquire)+1; }
bool throwdown_lab_player(std::uint32_t player_id) noexcept {
if (!player_id) return false;
@@ -2117,6 +2415,19 @@ bool queue_throwdown_host(std::uint64_t token, const std::string& series, const
return queue_relay_job(std::move(job), event_debug_spawn);
} catch (...) { return false; }
}
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 {
if(!token || !host || !std::isfinite(yaw) ||
!std::all_of(at.begin(),at.end(),[](float n){return std::isfinite(n) && std::abs(n)<100000.f;}))return false;
try {
const auto mode=custom_throwdowns::find(key);
if(!mode)return false; // only a mode some plugin here registered
Job job{Job::Kind::spawn,host};job.token=token;job.series="SpotBattle";
job.instance=native_flag::flag_assets(mode->flag).instance;job.position=at;job.spawn_yaw=yaw*3.14159265f/180.f;
job.max_players=10; // A flag must never auto-start the Spot Battle donor.
return queue_relay_job(std::move(job),event_debug_spawn);
} catch(...) {return false;}
}
bool queue_throwdown_add(std::uint32_t player, const std::string& series, std::uint32_t mmid) noexcept {
if (!player || !mmid) return false;
try {
+25 -3
View File
@@ -17,14 +17,31 @@ void initialize_throwdown_lab(std::uintptr_t base) noexcept;
// Called after every authored expression (vm = the expression that just ran); sends
// queued events from the client realm and reads results off server graphs.
void pump_throwdown_lab(std::uintptr_t vm) noexcept;
// Diagnostic only: observe the real input/VM trigger without changing its flow.
void tick_official_panel_trace(bool start_down,std::uint64_t now) noexcept;
bool official_panel_trace_active() noexcept;
// Substitutes only the preview prefab in this invocation's constant page.
// The shipped graph resource and official Throwdowns remain unchanged.
void execute_throwdown_preview_expression(std::uintptr_t vm,std::uint32_t pc,std::uintptr_t profiler,
void (*runner)(std::uintptr_t,std::uint32_t,std::uintptr_t));
// Before the game changes level state to `next`: forgets the event types found in a level that
// is being left, which are freed with it.
void throwdown_lab_before_level_transition(unsigned next) noexcept;
// Native level generation, including offline play; never tied to Steam hosting.
std::uint64_t native_throwdown_world() noexcept;
// Called by the SendNetworkedEvent hook for every event the local client sends.
void observe_throwdown_send(std::uint32_t hash, std::uintptr_t type, std::uintptr_t payload) noexcept;
// After observe_throwdown_send: true when the event belongs to a plugin mode's
// flag and must not be sent (its waiting card's Start goes to the plugin).
bool consume_custom_throwdown_send(std::uint32_t hash, std::uintptr_t type, std::uintptr_t payload) noexcept;
// A plugin mode's native flag registration (native_flag_session.h): its flag mesh, its held-open
// queue, and the waiting card's Start (consumed: true) and Leave; a running plugin match's Quit.
bool consume_flag_throwdown_send(std::uint32_t hash, std::uintptr_t type, std::uintptr_t payload) noexcept;
// Ask the last solo host's native event to destroy itself, and then allow the
// original Leave request/confirmation to perform normal client teardown.
void prepare_native_solo_throwdown_leave(std::uint32_t hash) noexcept;
// True only for the local host's native Throwdown configured for one player.
// Its authored Start action can bypass the normal two-participant UI gate.
bool native_solo_throwdown_waiting() noexcept;
// A real native queue creation/join retires any hidden custom donor banner.
std::uint64_t native_throwdown_queue_revision() noexcept;
// True for ids the lab put into a throwdown; the UI player lookups answer them with
// the local player's record so rows and host lookups resolve.
bool throwdown_lab_player(std::uint32_t player_id) noexcept;
@@ -51,6 +68,11 @@ bool queue_throwdown_spawn(std::uint64_t token, std::uint32_t host, const std::s
// A plain `series` queue at `at`, hosted by the local player (a plugin's drop): its MMID comes back
// through throwdown_relay_spawned(token, ...), its parameters through throwdown_relay_host_placement.
bool queue_throwdown_host(std::uint64_t token, const std::string &series, const std::array<float, 3> &at, std::uint32_t most_players) noexcept;
// The native registration/POI for another player's flag of plugin mode `key` ("<plugin>/<id>",
// registered here), with that mode's flag mesh. Its donor queue remains open; membership and the
// start stay with the mode's plugin. False for a mode no plugin here registered.
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;
bool queue_throwdown_add(std::uint32_t player, const std::string &series, std::uint32_t mmid) noexcept;
bool queue_throwdown_remove(std::uint32_t player) noexcept; // RemoveParticipantFromCommunityEvent as `player`
bool queue_throwdown_start(const std::string &series, std::uint32_t mmid) noexcept;
+235 -95
View File
@@ -1,5 +1,7 @@
#include "throwdown_relay.h"
#include "custom_modes.h"
#include "native_flag_session.h"
#include "throwdown_kit.h"
#include "throwdown_lab.h"
#include "throwdown_wire.h"
#include "native_throwdowns.h"
@@ -28,6 +30,7 @@ namespace {
using Address = std::uintptr_t;
using Kind = ThrowdownMessage::Kind;
using Local = ThrowdownLocalAction::Kind;
namespace kit = dingosdk::throwdowns::kit;
// Remote players' ids on the local server: 0x300 + session slot. Participant, score
// and queue lists replicate ids in 10 bits and turn-based rules require 1..1022, so
// the range stays below 0x3ff and clear of the local player (2).
@@ -93,14 +96,6 @@ struct ChallengePlan {
std::optional<Leader> leader_start; // the hook's answer to a leader start, for the tick
};
// A plugin throwdown mode a drop is the flag of (ThrowdownMessage::Kind::mode).
struct ModeTag {
std::string key, flag; // "plugin/mode"; the InstanceId suffix of its flag, or empty
std::uint32_t players{};
};
// Every machine holds a plugin mode's flag copy open (it never fills and starts a Spot Battle):
// the mode's own player count decides who may still join.
constexpr std::uint32_t mode_queue_capacity = 10;
// The local player's own drop.
struct Hosted {
std::uint32_t id{}; // its MMID
@@ -112,7 +107,6 @@ struct Hosted {
// A plugin's drop (throwdown_relay_host): only these may join, and it starts once all have.
std::vector<std::uint64_t> invited;
bool plugin{}, start_asked{};
std::optional<ModeTag> mode; // the flag of a plugin's throwdown mode
};
// A plugin's drop on its way: spawned by the lab, its MMID not known yet.
struct PluginSpawn {
@@ -131,8 +125,31 @@ struct Mirror {
std::uint32_t mmid{}, max_players{};
bool joined{}; // the local player is in it
std::optional<std::vector<std::uint64_t>> start; // the leader started before the copy existed
std::optional<ModeTag> mode; // the flag of a plugin's throwdown mode
};
// Another player's plugin mode flag (throwdown_kit.h Flag), mirrored as a native registration.
struct CustomFlag {
kit::Flag offer;
std::uint64_t token{},spawning_since{},retry_at{};
std::uint32_t mmid{};
bool spawning{},claimed{};
};
// The same native registration: a changed card (players, stats) is updated in place, a moved
// flag is spawned again.
bool same_registration(const kit::Flag& a,const kit::Flag& b) {
return a.leader==b.leader && a.game==b.game && a.mode==b.mode && a.id==b.id && a.at==b.at && a.yaw==b.yaw;
}
// A flag another player's relay sent (Kind::flag), until withdrawn or no longer repeated.
struct RemoteFlag {
kit::Flag flag;
std::uint64_t seen{};
};
// The local plugins' own flags, as last sent to the other players.
struct OwnFlag {
kit::Flag flag;
std::vector<std::uint8_t> body;
std::uint64_t sent{};
};
constexpr std::uint64_t remote_flag_forget_ms = 3 * offer_interval_ms;
const char *state_name(Mirror::State s) {
switch (s) {
case Mirror::State::waiting: return "waiting";
@@ -184,9 +201,11 @@ struct Relay {
std::string host_status; // under `mutex`
// Drops shown only to chosen players: who may see each (an invite before its offer).
std::map<Key, std::vector<std::uint64_t>> invites;
std::map<Key, ModeTag> mode_tags; // plugin modes' flags: a mode message before each offer
std::map<std::uint32_t, std::vector<std::uint64_t>> mode_players; // own mode flag's players; under `mutex`
std::map<Key, Mirror> mirrors;
std::map<Key,CustomFlag> custom_flags; // game thread; separate from official match mirrors
std::map<Key,RemoteFlag> remote_flags; // game thread
std::map<std::uint32_t,OwnFlag> own_flags; // game thread, by the flag's game
std::map<std::uint32_t,CustomThrowdownFlagInfo> custom_flag_keys; // published under mutex for native UI
std::optional<Key> running;
std::string running_series;
std::map<std::uint64_t, Score> scores; // remote totals in `running`
@@ -211,6 +230,7 @@ struct Relay {
std::string timer, status_line; // status_line: under `mutex`
std::uint64_t next_timer{}, next_status{};
bool force_start_enabled{};
NativeQueueHudState queue_hud; // under mutex, including callbacks before the relay tick
std::optional<Challenge> challenge;
std::uint32_t next_challenge = 1;
ChallengePlan plan; // under `mutex`
@@ -302,13 +322,8 @@ void send_offer(Relay &r, std::uint64_t now) {
invite.order = h.invited;
send(r, invite);
}
if (h.mode) {
auto mode = message(Kind::mode, r.local, h.id);
mode.challenge = h.mode->key; mode.series = h.mode->flag; mode.value = static_cast<std::int32_t>(h.mode->players);
send(r, mode);
}
auto m = message(Kind::offer, r.local, h.id);
m.series = h.mode ? std::string(mode_series) : h.series;
m.series = h.series;
m.placement = h.placement; m.settings = h.settings; m.order = h.members;
const auto before = r.outgoing.size();
send(r, m);
@@ -319,27 +334,6 @@ void send_offer(Relay &r, std::uint64_t now) {
h.next_offer = now + offer_interval_ms;
}
// The drop is gone without having started (cancelled, left, replaced, world change).
// News for the plugin whose mode a flag is (custom_modes.h).
void mode_news(const ModeTag &mode, std::string action, std::uint64_t leader, bool host, std::uint64_t player = 0,
std::vector<std::uint64_t> players = {}) {
custom_throwdowns::News news;
news.action = std::move(action);
news.leader = leader;
news.host = host;
news.player = player;
news.players = std::move(players);
custom_throwdowns::post(mode.key, std::move(news));
}
void publish_mode_players(Relay &r) {
std::map<std::uint32_t, std::vector<std::uint64_t>> players;
if (r.hosted && r.hosted->mode) {
auto &list = players[r.hosted->id];
list.push_back(r.local);
list.insert(list.end(), r.hosted->members.begin(), r.hosted->members.end());
}
std::lock_guard lock(r.mutex);
r.mode_players = std::move(players);
}
void close_hosted(Relay &r) {
if (r.hosted && r.hosted->relayed && !r.hosted->started) {
send(r, message(Kind::close, r.local, r.hosted->id));
@@ -517,11 +511,11 @@ void on_local(Relay &r, ThrowdownLocalAction &a, std::uint64_t now) {
case Local::exited: {
if (a.cancelling) { close_hosted(r); break; }
if (!a.mmid || a.placement.empty()) break;
if(native_flag::known_queue(a.mmid)){close_hosted(r);break;}
if (!r.hosted || r.hosted->id != a.mmid) {
close_hosted(r);
Hosted h;
h.id = a.mmid; h.series = a.series; h.relayed = relayed_mode(a.series) || !a.mode.empty();
if (!a.mode.empty()) h.mode = ModeTag{a.mode, a.flag, std::clamp<std::uint32_t>(a.mode_players, 1, mode_queue_capacity)};
h.id = a.mmid; h.series = a.series; h.relayed = relayed_mode(a.series);
r.hosted = std::move(h);
if (r.hosted->relayed)
note("your {} (MMID {:#x}) is shown to the other players.", a.series, a.mmid);
@@ -535,6 +529,24 @@ void on_local(Relay &r, ThrowdownLocalAction &a, std::uint64_t now) {
break;
}
case Local::joined:
for(auto& [key,flag]:r.custom_flags)if(flag.mmid==a.mmid && !flag.claimed) {
// Native Join has already enrolled the local player in the flag's
// registration. Hand that registration to the custom lobby so its
// normal waiting/Leave flow and later teardown remain native.
bool joined{};
const auto mode=custom_throwdowns::find(flag.offer.mode);
if(mode && flag.offer.open &&
kit::flag::adopt(flag.offer.mode,flag.offer.game,flag.mmid,flag.offer.at,flag.offer.yaw,120,flag.offer.capacity)) {
kit::post_to(mode->plugin,"join",flag.offer.mode,
{flag.offer.id,std::to_string(flag.offer.leader),std::to_string(flag.offer.game)});
joined=true;
}
if(joined) {
close_hosted(r);leave_mirrors(r,a.mmid);flag.claimed=true;
}
else if(const auto local=local_native_player_id())queue_throwdown_remove(local);
return;
}
// Joining a queue removes the player from any other one and destroys a drop it hosts.
if (r.hosted && a.mmid != r.hosted->id) close_hosted(r);
leave_mirrors(r, a.mmid);
@@ -543,12 +555,9 @@ void on_local(Relay &r, ThrowdownLocalAction &a, std::uint64_t now) {
m.joined = true;
send(r, message(Kind::join, key.leader, key.id));
note("you joined a linked {} (MMID {:#x}).", m.series, m.mmid);
if (m.mode) mode_news(*m.mode, "joined", key.leader, false, r.local);
}
break;
case Local::left:
for (const auto &[key, m] : r.mirrors)
if (m.joined && m.mode) mode_news(*m.mode, "left", key.leader, false, r.local);
leave_mirrors(r, 0);
if (r.hosted && !r.hosted->started) close_hosted(r); // the host leaving destroys its queue
break;
@@ -557,8 +566,6 @@ void on_local(Relay &r, ThrowdownLocalAction &a, std::uint64_t now) {
std::vector<std::uint64_t> order{r.local};
order.insert(order.end(), r.hosted->members.begin(), r.hosted->members.end());
send_start(r, std::move(order));
// A mode's flag is done with: its plugin runs the match (custom_placement.cpp).
if (r.hosted->mode) r.hosted.reset();
}
break;
case Local::destroy_requested:
@@ -691,10 +698,26 @@ void on_local(Relay &r, ThrowdownLocalAction &a, std::uint64_t now) {
void on_challenge_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std::uint64_t now);
void on_beacon_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m);
// Another player's plugin mode flag: shown only where a plugin registered that mode too.
void on_flag_message(Relay& r,std::uint64_t sender,const ThrowdownMessage& m,std::uint64_t now) {
if(m.leader!=sender)return;
const auto body=decode_flag_body(m.placement);
if(!body)return;
const Key key{m.leader,m.id};
if(body->withdrawn || !custom_throwdowns::find(m.challenge)) {r.remote_flags.erase(key);return;}
if(!r.remote_flags.contains(key) && r.remote_flags.size()>=64)return;
kit::Flag flag;
flag.id=body->id;flag.mode=m.challenge;flag.leader=m.leader;flag.game=m.id;flag.at=body->at;flag.yaw=body->yaw;
flag.players=body->players;flag.capacity=body->capacity;flag.open=body->open;flag.stats=body->stats;
r.remote_flags[key]={std::move(flag),now};
}
void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std::uint64_t now) {
if (!r.peers.contains(sender)) return;
if (m.kind == Kind::beacon) { on_beacon_message(r, sender, m); return; }
if (m.kind >= Kind::challenge_start && m.kind != Kind::invite && m.kind != Kind::mode) { on_challenge_message(r, sender, m, now); return; }
if (m.kind == Kind::flag) { on_flag_message(r, sender, m, now); return; }
// A mode message tagged an older client's plugin mode flag; the kit's flags travel as flag messages.
if (m.kind == Kind::mode) return;
if (m.kind >= Kind::challenge_start && m.kind != Kind::invite) { on_challenge_message(r, sender, m, now); return; }
const Key key{m.leader, m.id};
const auto mirror = r.mirrors.find(key);
switch (m.kind) {
@@ -705,20 +728,10 @@ void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std::
if (!contains(m.order, r.local) && mirror != r.mirrors.end()) destroy_mirror(r, mirror->second, now);
return;
}
case Kind::mode: {
if (m.leader != sender) return;
r.mode_tags[key] = ModeTag{m.challenge, m.series, static_cast<std::uint32_t>(m.value)};
return;
}
case Kind::offer: {
if (m.leader != sender) return;
std::optional<ModeTag> mode;
if (m.series == mode_series) {
// A plugin mode's flag: shown only where that plugin has the mode too.
const auto tag = r.mode_tags.find(key);
if (tag == r.mode_tags.end() || !custom_throwdowns::find(tag->second.key)) return;
mode = tag->second;
} else if (!relayed_mode(m.series)) return;
// An older client's plugin mode flag (mode_series) is not shown: the kit's flags are flag messages.
if (!relayed_mode(m.series)) return;
if (const auto invite = r.invites.find(key); invite != r.invites.end() && !contains(invite->second, r.local)) return;
if (mirror != r.mirrors.end()) {
// A copy removed here (full, or left) stays removed while the leader keeps offering it.
@@ -732,18 +745,16 @@ void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std::
for (auto &[other, copy] : r.mirrors)
if (other.leader == m.leader) destroy_mirror(r, copy, now);
Mirror copy;
copy.series = mode ? std::string(spot_battle_mode) : m.series;
copy.series = m.series;
copy.placement = m.placement; copy.settings = m.settings;
copy.mode = mode;
for (const auto player : m.order)
if (player != r.local && player != m.leader && !contains(copy.members, player)) copy.members.push_back(player);
r.mirrors.emplace(key, std::move(copy));
note("{} is offered by another player (their MMID {:#x}); showing it here.", mode ? mode->key : m.series, m.id);
note("{} is offered by another player (their MMID {:#x}); showing it here.", m.series, m.id);
return;
}
case Kind::close:
if (m.leader != sender || mirror == r.mirrors.end()) return;
if (mirror->second.mode && mirror->second.joined) mode_news(*mirror->second.mode, "closed", key.leader, false, r.local);
destroy_mirror(r, mirror->second, now);
return;
case Kind::join:
@@ -751,12 +762,9 @@ void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std::
if (!r.hosted || r.hosted->id != m.id || r.hosted->started || !r.hosted->relayed ||
contains(r.hosted->members, sender) || (!r.hosted->invited.empty() && !contains(r.hosted->invited, sender)))
return;
// A mode's flag takes as many players as the mode does.
if (r.hosted->mode && 1 + r.hosted->members.size() >= r.hosted->mode->players) return;
r.hosted->members.push_back(sender);
add_to_hosted(r);
note("a player joined your {} (virtual id {:#x}).", r.hosted->series, virtual_id(r, sender));
if (r.hosted->mode) mode_news(*r.hosted->mode, "joined", r.local, true, sender);
} else if (mirror != r.mirrors.end()) {
mirror_member(r, mirror->second, sender, now);
}
@@ -778,10 +786,8 @@ void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std::
if (r.in_world) queue_throwdown_remove(virtual_id(r, sender));
}
};
if (m.leader == r.local && r.hosted && r.hosted->id == m.id && !r.hosted->started) {
if (r.hosted->mode && contains(r.hosted->members, sender)) mode_news(*r.hosted->mode, "left", r.local, true, sender);
if (m.leader == r.local && r.hosted && r.hosted->id == m.id && !r.hosted->started)
remove(r.hosted->members, r.hosted->added);
}
else if (mirror != r.mirrors.end() && mirror->second.state != Mirror::State::started)
remove(mirror->second.members, mirror->second.added);
return;
@@ -789,13 +795,6 @@ void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std::
case Kind::start: {
if (m.leader != sender || mirror == r.mirrors.end()) return;
auto &copy = mirror->second;
if (copy.mode) {
// The leader's plugin starts its match: the copy's flag goes, and a joined player's
// plugin hears who plays.
if (copy.joined) mode_news(*copy.mode, "start", key.leader, false, r.local, m.order);
destroy_mirror(r, copy, now);
return;
}
if (!copy.joined) { destroy_mirror(r, copy, now); return; }
if (copy.state == Mirror::State::open) start_mirror(r, key, copy, m.order);
else if (copy.state == Mirror::State::waiting || copy.state == Mirror::State::spawning) copy.start = m.order;
@@ -864,6 +863,10 @@ void on_spawned(Relay &r, std::uint64_t token, std::uint32_t mmid, std::uint32_t
set_host_status(r, "open");
return;
}
for(auto& [key,flag]:r.custom_flags)if(token && flag.token==token) {
flag.spawning=false;flag.mmid=mmid;flag.retry_at=mmid?0:now+1000;
return;
}
for (auto &[key, m] : r.mirrors) {
if (m.token != token) continue;
if (!mmid) {
@@ -873,7 +876,7 @@ void on_spawned(Relay &r, std::uint64_t token, std::uint32_t mmid, std::uint32_t
return;
}
m.mmid = mmid;
m.max_players = m.mode ? m.mode->players : capacity;
m.max_players = capacity;
if (m.state == Mirror::State::gone) { // closed while it was spawning
queue_throwdown_destroy(mmid);
return;
@@ -1193,8 +1196,13 @@ void reset(Relay &r, bool destroy_copies, std::uint64_t now) {
for (auto &[key, m] : r.mirrors) destroy_mirror(r, m, now);
r.mirrors.clear();
r.invites.clear();
r.mode_tags.clear();
r.plugin_spawn.reset();
if(destroy_copies && r.in_world && r.prepared)
for(const auto& [key,flag]:r.custom_flags)if(flag.mmid && !flag.claimed)queue_throwdown_destroy(flag.mmid);
r.custom_flags.clear();
r.remote_flags.clear();
r.own_flags.clear();
{std::lock_guard lock(r.mutex);r.custom_flag_keys.clear();}
r.spawn_refused.clear();
r.declined.clear();
r.running.reset();
@@ -1225,12 +1233,7 @@ void update_peers(Relay &r, const ThrowdownRelayInput &in, std::uint64_t now) {
if (r.in_world) queue_throwdown_remove(id);
}
};
if (r.hosted && !r.hosted->started) {
if (r.hosted->mode && contains(r.hosted->members, player)) mode_news(*r.hosted->mode, "left", r.local, true, player);
drop(r.hosted->members, r.hosted->added);
}
for (auto &[key, m] : r.mirrors)
if (key.leader == player && m.joined && m.mode) mode_news(*m.mode, "closed", key.leader, false, r.local);
if (r.hosted && !r.hosted->started) drop(r.hosted->members, r.hosted->added);
for (auto &[key, m] : r.mirrors) {
if (key.leader == player) destroy_mirror(r, m, now);
else if (m.state != Mirror::State::started) drop(m.members, m.added);
@@ -1385,6 +1388,95 @@ void pump_plugin_host(Relay &r, const ThrowdownRelayInput &input, std::uint64_t
r.plugin_spawn = std::move(request);
set_host_status(r, "spawning");
}
// The local plugins' own flags (leader: the local player) go to the other players when they
// change and every offer_interval_ms; one gone from the documents is withdrawn once.
void send_own_flags(Relay& r,const std::vector<kit::Flag>& own,std::uint64_t now) {
const auto message_for=[&](const kit::Flag& flag,bool withdrawn) -> std::optional<ThrowdownMessage> {
try {
ThrowdownFlagBody body;
body.id=flag.id.substr(0,max_flag_id);body.at=flag.at;body.yaw=flag.yaw;
body.players=static_cast<std::uint8_t>(std::min(flag.players,32U));
body.capacity=static_cast<std::uint8_t>(std::clamp(flag.capacity,1U,32U));
body.open=flag.open;body.withdrawn=withdrawn;
for(const auto& [title,value]:flag.stats) {
if(body.stats.size()==max_flag_stats)break;
body.stats.emplace_back(title.substr(0,max_flag_stat),value.substr(0,max_flag_stat));
}
auto m=message(Kind::flag,r.local,flag.game);
m.challenge=flag.mode;m.placement=encode_flag_body(body);
if(!valid_throwdown(m))return {};
return m;
} catch(...) {return {};}
};
std::set<std::uint32_t> current;
for(const auto& flag:own) {
if(!flag.game || current.size()>=8)continue;
current.insert(flag.game);
const auto m=message_for(flag,false);
if(!m)continue;
auto& sent=r.own_flags[flag.game];
if(sent.body==m->placement && sent.flag.mode==flag.mode && now<sent.sent+offer_interval_ms)continue;
sent.flag=flag;sent.body=m->placement;sent.sent=now;
send(r,*m);
}
for(auto it=r.own_flags.begin();it!=r.own_flags.end();) {
if(current.contains(it->first)) {++it;continue;}
if(const auto m=message_for(it->second.flag,true))send(r,*m);
it=r.own_flags.erase(it);
}
}
// Other players' plugin mode flags as native flags here: one mirrored registration per flag a
// plugin here publishes for another player (its `flags` document) or another player's relay sent,
// joined with the game's own Join.
void maintain_custom_flags(Relay& r,std::uint64_t now) {
std::map<Key,kit::Flag> wanted;
std::vector<kit::Flag> own;
std::vector<kit::Documents> documents;
try {documents=kit::documents();} catch(...) {}
for(const auto& d:documents)for(const auto& flag:d.flags) {
if(!flag.leader || flag.leader==r.local) {
auto mine=flag;mine.leader=r.local;
if(r.local && !mine.mode.empty())own.push_back(std::move(mine));
continue;
}
if(flag.open && flag.game && r.peers.contains(flag.leader) && custom_throwdowns::find(flag.mode))
wanted.try_emplace(Key{flag.leader,flag.game},flag);
}
std::erase_if(r.remote_flags,[&](const auto& row){
return !r.peers.contains(row.first.leader) || now<row.second.seen || now-row.second.seen>remote_flag_forget_ms;
});
for(const auto& [key,remote]:r.remote_flags)if(remote.flag.open)wanted.try_emplace(key,remote.flag);
if(!r.peers.empty())send_own_flags(r,own,now);
for(const auto& [key,offer]:wanted)
if(!r.custom_flags.contains(key) && r.custom_flags.size()<64)r.custom_flags[key].offer=offer;
for(auto it=r.custom_flags.begin();it!=r.custom_flags.end();) {
auto& flag=it->second;const auto desired=wanted.find(it->first);
const bool keep=desired!=wanted.end() && same_registration(desired->second,flag.offer);
if(keep)flag.offer=desired->second;
if(flag.claimed) {it=r.custom_flags.erase(it);continue;} // the flag registration now performs native teardown
if(flag.spawning && now-flag.spawning_since>spawning_wait_ms) {flag.spawning=false;flag.token=0;flag.retry_at=now+1000;}
if(!keep && !flag.spawning) {
if(flag.mmid && (!r.prepared || !queue_throwdown_destroy(flag.mmid))) {++it;continue;}
it=r.custom_flags.erase(it);continue;
}
++it;
}
const bool spawning=std::any_of(r.custom_flags.begin(),r.custom_flags.end(),[](const auto& row){return row.second.spawning;}) ||
std::any_of(r.mirrors.begin(),r.mirrors.end(),[](const auto& row){return row.second.state==Mirror::State::spawning;});
if(r.prepared && !spawning)for(auto& [key,flag]:r.custom_flags) {
if(flag.mmid || flag.claimed || now<flag.retry_at || !wanted.contains(key) || !same_registration(wanted.at(key),flag.offer))continue;
const auto host=virtual_id(r,key.leader);if(!host)continue;
const auto token=r.next_token++;
if(queue_custom_throwdown_flag(token,host,flag.offer.mode,flag.offer.at,flag.offer.yaw)) {
flag.token=token;flag.spawning=true;flag.spawning_since=now;
} else flag.retry_at=now+250;
break;
}
std::map<std::uint32_t,CustomThrowdownFlagInfo> published;
for(const auto& [key,flag]:r.custom_flags)if(flag.mmid && !flag.claimed)
published[flag.mmid]={flag.offer.mode,flag.offer.id,flag.offer.players,flag.offer.capacity,flag.offer.open,flag.offer.stats,key.leader,key.id};
{std::lock_guard lock(r.mutex);r.custom_flag_keys=std::move(published);}
}
void maintain(Relay &r, std::uint64_t now) {
if (!r.in_world) return;
// The event types belong to the level: each one's are found by a background heap scan,
@@ -1397,6 +1489,7 @@ void maintain(Relay &r, std::uint64_t now) {
}
std::erase_if(r.spawn_refused, [&](const auto &entry) { return now >= entry.second; });
std::erase_if(r.declined, [&](const auto &entry) { return now < entry.second || now - entry.second >= decline_interval_ms; });
maintain_custom_flags(r,now);
if (r.prepared) {
// A spawn nothing answered (the lab reports failures; this is the backstop) must not
// hold every other copy back.
@@ -1408,16 +1501,15 @@ void maintain(Relay &r, std::uint64_t now) {
note("a linked {} never spawned here; giving up on it.", m.series);
}
// One spawn at a time: the server reports MMIDs in order.
if (std::none_of(r.mirrors.begin(), r.mirrors.end(),
if (std::none_of(r.custom_flags.begin(),r.custom_flags.end(),[](const auto& row){return row.second.spawning;}) &&
std::none_of(r.mirrors.begin(), r.mirrors.end(),
[](const auto &entry) { return entry.second.state == Mirror::State::spawning; }))
for (auto &[key, m] : r.mirrors) {
if (m.state != Mirror::State::waiting) continue;
const auto host = virtual_id(r, key.leader);
if (!host) { m.state = Mirror::State::gone; m.gone_at = now; continue; }
m.token = r.next_token++;
if (queue_throwdown_spawn(m.token, host, m.series, m.placement, m.settings,
m.mode && !m.mode->flag.empty() ? "ReSkate." + m.mode->flag : std::string{},
m.mode ? mode_queue_capacity : 0)) {
if (queue_throwdown_spawn(m.token, host, m.series, m.placement, m.settings, std::string{}, 0)) {
m.state = Mirror::State::spawning;
m.spawning_since = now;
}
@@ -1501,7 +1593,15 @@ void maintain(Relay &r, std::uint64_t now) {
}
} // namespace
namespace {
std::atomic<std::uint64_t> published_world{};
std::atomic<bool> published_barred{};
} // namespace
std::uint64_t throwdown_relay_world() noexcept { return published_world.load(std::memory_order_acquire); }
bool throwdown_relay_barred() noexcept { return published_barred.load(std::memory_order_acquire); }
std::vector<std::vector<std::uint8_t>> tick_throwdown_relay(std::uintptr_t base, const ThrowdownRelayInput &input) {
published_world.store(input.world, std::memory_order_release);
published_barred.store(input.barred, std::memory_order_release);
auto &r = relay();
const auto now = now_ms();
std::deque<ThrowdownLocalAction> actions;
@@ -1590,6 +1690,14 @@ std::vector<std::vector<std::uint8_t>> tick_throwdown_relay(std::uintptr_t base,
}
update_peers(r, input, now);
refresh_player_info(r, now);
// Registration callbacks can arrive in the same tick as native Join.
// Resolve custom tokens first so that Join cannot miss its new MMID.
std::erase_if(spawned,[&](const auto& result) {
const auto token=std::get<0>(result);
if(!token)return false;
if(std::none_of(r.custom_flags.begin(),r.custom_flags.end(),[&](const auto& row){return row.second.token==token;}))return false;
on_spawned(r,token,std::get<1>(result),std::get<2>(result),now);return true;
});
for (auto &action : actions) on_local(r, action, now);
for (const auto &[token, mmid, max] : spawned) on_spawned(r, token, mmid, max, now);
pump_plugin_host(r, input, now);
@@ -1604,7 +1712,6 @@ std::vector<std::vector<std::uint8_t>> tick_throwdown_relay(std::uintptr_t base,
update_celebration(r, now);
publish_challenge_plan(r, input);
publish_order(r);
publish_mode_players(r);
follow_turn(r);
// Who is up as the local client shows it: it changes seconds before the turn
// start teleport, so that teleport already knows whether to land on foot.
@@ -1624,26 +1731,59 @@ std::vector<std::vector<std::uint8_t>> tick_throwdown_relay(std::uintptr_t base,
return std::exchange(r.outgoing, {});
}
std::string custom_throwdown_flag_key(std::uint32_t mmid) {
const auto info=custom_throwdown_flag_info(mmid);return info?info->key:std::string{};
}
bool has_custom_throwdown_flags() {
auto& r=relay();std::lock_guard lock(r.mutex);return !r.custom_flag_keys.empty();
}
bool custom_throwdown_flag_available(std::uint64_t leader,std::uint32_t game) {
auto& r=relay();std::lock_guard lock(r.mutex);
return std::any_of(r.custom_flag_keys.begin(),r.custom_flag_keys.end(),[&](const auto& row){
return row.second.leader==leader && row.second.game==game;
});
}
std::optional<CustomThrowdownFlagInfo> custom_throwdown_flag_info(std::uint32_t mmid) {
auto& r=relay();std::lock_guard lock(r.mutex);
const auto found=r.custom_flag_keys.find(mmid);
return found==r.custom_flag_keys.end()?std::optional<CustomThrowdownFlagInfo>{}:found->second;
}
void receive_throwdown_relay(std::uint64_t sender, std::span<const std::uint8_t> message) {
auto &r = relay();
std::lock_guard lock(r.mutex);
if (r.inbox.size() < 256) r.inbox.emplace_back(sender, std::vector<std::uint8_t>(message.begin(), message.end()));
}
std::vector<std::uint64_t> throwdown_relay_mode_players(std::uint32_t mmid) {
auto &r = relay();
std::lock_guard lock(r.mutex);
const auto found = r.mode_players.find(mmid);
return found == r.mode_players.end() ? std::vector<std::uint64_t>{} : found->second;
}
void throwdown_relay_local(ThrowdownLocalAction action) noexcept {
try {
auto &r = relay();
std::lock_guard lock(r.mutex);
// Only local Create/Enroll owns a waiting notice. Offers for someone
// else's unjoined flag do not. Custom donor queues have their own UI.
switch(action.kind) {
case Local::created: case Local::joined:
++r.queue_hud.generation;r.queue_hud.waiting=true;
break;
case Local::left: case Local::entered: case Local::ended: case Local::quit:
r.queue_hud.waiting=false;break;
case Local::exited:
if(action.cancelling)r.queue_hud.waiting=false;
break;
default:break;
}
if (r.actions.size() < 256) r.actions.push_back(std::move(action));
} catch (...) {}
}
NativeQueueHudState native_queue_hud_state() noexcept {
try {auto& r=relay();std::lock_guard lock(r.mutex);return r.queue_hud;}
catch(...) {return {};}
}
void retire_native_queue_hud() noexcept {
try {auto& r=relay();std::lock_guard lock(r.mutex);r.queue_hud.waiting=false;}catch(...) {}
}
void throwdown_relay_spawned(std::uint64_t token, std::uint32_t mmid, std::uint32_t capacity) noexcept {
try {
auto &r = relay();
+32 -7
View File
@@ -6,6 +6,7 @@
#include <span>
#include <string_view>
#include <string>
#include <utility>
#include <vector>
// Links the players' own throwdowns in a ReSkate multiplayer session
@@ -25,6 +26,7 @@ struct ThrowdownPeer {
// In the local player's party: only party members are invited into each other's coop
// challenges.
bool party{};
std::optional<std::array<float, 3>> position;
};
struct ThrowdownRelayInput {
std::uint64_t local{}; // this player's Steam ID; 0 without a session
@@ -37,10 +39,15 @@ struct ThrowdownRelayInput {
// their mods change scoring or physics: their linked throwdown or coop challenge ends for them (the
// others already dropped them), and no new one links.
bool barred{};
// Only a local-practice copy sets this. Never a network roster.
bool local_only{};
};
// Multiplayer session tick (game thread). Returns the encoded messages to send to
// every other player.
std::vector<std::vector<std::uint8_t>> tick_throwdown_relay(std::uintptr_t base, const ThrowdownRelayInput &input);
// The world and barred the relay last ticked with (any thread): game modes match offers by it.
std::uint64_t throwdown_relay_world() noexcept;
bool throwdown_relay_barred() noexcept;
// One message from another player, as relayed by the session (game thread).
void receive_throwdown_relay(std::uint64_t sender, std::span<const std::uint8_t> message);
std::string throwdown_relay_status();
@@ -55,6 +62,31 @@ void throwdown_relay_cancel_host() noexcept;
std::string throwdown_relay_host_status();
// The lab ran a plugin drop's spawn: its parameters as the stream the offer carries. Any thread.
void throwdown_relay_host_placement(std::uint64_t token, std::vector<std::uint8_t> placement) noexcept;
// Native queue notice lifetime. A queue's long host-controlled timer is not
// proof that its waiting notice should survive an activity start/leave/end.
struct NativeQueueHudState {
std::uint64_t generation{};
bool waiting{};
};
NativeQueueHudState native_queue_hud_state() noexcept;
void retire_native_queue_hud() noexcept;
// Other players' plugin mode flags (throwdown_kit.h Flag: their plugins' `flags` documents, here
// or sent by the leader's relay) use the same native physical flag/POI as retail. The local MMID
// identifies its mode for map/bubble branding before joining; the native Join adopts the flag
// (kit::flag::adopt) and its plugin hears join {flag id, leader, game}.
std::string custom_throwdown_flag_key(std::uint32_t mmid);
bool has_custom_throwdown_flags();
struct CustomThrowdownFlagInfo {
std::string key, id; // the mode ("<plugin>/<id>") and the plugin's own flag id
unsigned players{}, capacity{};
bool open = true;
std::vector<std::pair<std::string, std::string>> stats; // the card's {title, value}, at most 3
std::uint64_t leader{};
std::uint32_t game{};
};
std::optional<CustomThrowdownFlagInfo> custom_throwdown_flag_info(std::uint32_t mmid);
bool custom_throwdown_flag_available(std::uint64_t leader,std::uint32_t game);
// Lines for the local player's chat since the last call (a linked throwdown ended because
// everyone else left it). Game thread.
std::vector<std::string> take_throwdown_relay_notices();
@@ -101,15 +133,8 @@ struct ThrowdownLocalAction {
std::string challenge; // challenge_started: the challenge's Id
std::vector<std::uint8_t> criteria, indexes; // challenge_attempt
std::array<float, 16> location{}; // beacon_placed: the LinearTransform
// exited: the drop is a plugin mode's flag (custom_modes.h): its "plugin/mode", its flag
// (empty: Spot Battle's) and how many players it takes.
std::string mode, flag;
std::uint32_t mode_players{};
};
void throwdown_relay_local(ThrowdownLocalAction action) noexcept;
// The players in the local player's own plugin mode flag `mmid`: the local player first, then
// the others in the order they joined. Any thread.
std::vector<std::uint64_t> throwdown_relay_mode_players(std::uint32_t mmid);
// A mirror spawn queued with `token` ran on the local server (mmid 0: it did not).
void throwdown_relay_spawned(std::uint64_t token, std::uint32_t mmid, std::uint32_t capacity) noexcept;
// UIPlayerInfo model of a relayed player's virtual id (rows, host card, name), or 0.
+79 -1
View File
@@ -60,6 +60,69 @@ struct Reader {
};
} // namespace
namespace {
bool printable(const std::string &text, std::size_t limit, bool empty_ok) noexcept {
return (empty_ok || !text.empty()) && text.size() <= limit &&
std::all_of(text.begin(), text.end(), [](char c) { return static_cast<unsigned char>(c) >= 0x20 && c != 0x7f; });
}
bool valid_flag_body(const ThrowdownFlagBody &f) noexcept {
return printable(f.id, max_flag_id, false) && f.stats.size() <= max_flag_stats &&
std::all_of(f.stats.begin(), f.stats.end(), [](const auto &s) {
return printable(s.first, max_flag_stat, true) && printable(s.second, max_flag_stat, true);
}) &&
std::isfinite(f.yaw) && std::abs(f.yaw) <= 3600.f && f.players <= 32 && f.capacity >= 1 && f.capacity <= 32 &&
std::all_of(f.at.begin(), f.at.end(), [](float v) { return std::isfinite(v) && std::abs(v) < 100000.f; });
}
} // namespace
std::vector<std::uint8_t> encode_flag_body(const ThrowdownFlagBody &f) {
if (!valid_flag_body(f)) throw std::invalid_argument("Invalid throwdown flag");
Writer w;
const auto text = [&](const std::string &value) {
w.integer(value.size(), 1);
w.bytes.insert(w.bytes.end(), value.begin(), value.end());
};
text(f.id);
for (const auto v : f.at) w.integer(std::bit_cast<std::uint32_t>(v), 4);
w.integer(std::bit_cast<std::uint32_t>(f.yaw), 4);
w.integer(f.players, 1);
w.integer(f.capacity, 1);
w.integer((f.open ? 1U : 0U) | (f.withdrawn ? 2U : 0U), 1);
w.integer(f.stats.size(), 1);
for (const auto &[title, value] : f.stats) { text(title); text(value); }
return std::move(w.bytes);
}
std::optional<ThrowdownFlagBody> decode_flag_body(std::span<const std::uint8_t> bytes) noexcept {
try {
Reader r{bytes};
ThrowdownFlagBody f;
const auto text = [&](std::size_t limit, std::string &out) {
const auto length = r.integer(1);
if (!r.ok || length > limit || length > bytes.size() - r.at) return false;
out.assign(reinterpret_cast<const char *>(bytes.data() + r.at), static_cast<std::size_t>(length));
r.at += static_cast<std::size_t>(length);
return true;
};
if (!text(max_flag_id, f.id)) return {};
for (auto &v : f.at) v = std::bit_cast<float>(static_cast<std::uint32_t>(r.integer(4)));
f.yaw = std::bit_cast<float>(static_cast<std::uint32_t>(r.integer(4)));
f.players = static_cast<std::uint8_t>(r.integer(1));
f.capacity = static_cast<std::uint8_t>(r.integer(1));
const auto bits = r.integer(1);
if (bits > 3) return {};
f.open = (bits & 1) != 0;
f.withdrawn = (bits & 2) != 0;
const auto count = r.integer(1);
if (!r.ok || count > max_flag_stats) return {};
for (std::uint64_t i = 0; i < count; ++i) {
std::pair<std::string, std::string> stat;
if (!text(max_flag_stat, stat.first) || !text(max_flag_stat, stat.second)) return {};
f.stats.push_back(std::move(stat));
}
if (!r.ok || r.at != bytes.size() || !valid_flag_body(f)) return {};
return f;
} catch (...) { return {}; }
}
bool valid_throwdown(const ThrowdownMessage &m) noexcept {
if (!individual(m.leader) || !m.id) return false;
switch (m.kind) {
@@ -94,6 +157,8 @@ bool valid_throwdown(const ThrowdownMessage &m) noexcept {
[](float v) { return std::isfinite(v) && std::abs(v) < 1e6f; });
case ThrowdownMessage::Kind::mode:
return valid_mode_key(m.challenge) && (m.series.empty() || valid_series(m.series)) && m.value >= 1 && m.value <= 10;
case ThrowdownMessage::Kind::flag:
return valid_mode_key(m.challenge) && decode_flag_body(m.placement).has_value();
}
return false;
}
@@ -154,6 +219,11 @@ std::vector<std::uint8_t> encode_throwdown(const ThrowdownMessage &m) {
w.bytes.insert(w.bytes.end(), m.series.begin(), m.series.end());
w.integer(static_cast<std::uint32_t>(m.value), 1);
break;
case ThrowdownMessage::Kind::flag:
w.integer(m.challenge.size(), 1);
w.bytes.insert(w.bytes.end(), m.challenge.begin(), m.challenge.end());
w.blob(m.placement, 2);
break;
default: break;
}
return std::move(w.bytes);
@@ -164,7 +234,7 @@ std::optional<ThrowdownMessage> decode_throwdown(std::span<const std::uint8_t> b
Reader r{bytes};
ThrowdownMessage m;
const auto kind = r.integer(1);
if (kind < 1 || kind > 17) return {};
if (kind < 1 || kind > 18) return {};
m.kind = static_cast<ThrowdownMessage::Kind>(kind);
m.leader = r.integer(8);
m.id = static_cast<std::uint32_t>(r.integer(4));
@@ -245,6 +315,14 @@ std::optional<ThrowdownMessage> decode_throwdown(std::span<const std::uint8_t> b
m.value = static_cast<std::int32_t>(r.integer(1));
break;
}
case ThrowdownMessage::Kind::flag: {
const auto length = r.integer(1);
if (!r.ok || length > max_challenge_id || length > bytes.size() - r.at) return {};
m.challenge.assign(reinterpret_cast<const char *>(bytes.data() + r.at), static_cast<std::size_t>(length));
r.at += static_cast<std::size_t>(length);
m.placement = r.blob(2, max_throwdown_params);
break;
}
default: break;
}
if (!r.ok || r.at != bytes.size() || !valid_throwdown(m)) return {};
+23 -5
View File
@@ -5,6 +5,7 @@
#include <span>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
// Messages that link the players' own throwdowns (analysis/throwdowns-native-mp.md).
@@ -34,11 +35,14 @@ struct ThrowdownMessage {
// A drop for chosen players only (a plugin's, reskate.throwdowns.host): order = who may see
// and join the leader's drop `id`; sent before each of its offers.
invite = 16,
// The leader's drop `id` is the flag of a plugin's throwdown mode (custom_modes.h), sent
// before each of its offers: challenge = the mode ("plugin/mode"), series = its flag (may
// be empty), value = how many players it takes. Its offers' series is mode_series, which
// a ReSkate without plugin modes does not show.
mode = 17
// Older clients tagged a drop as a plugin mode's flag with this before each offer
// (challenge = the mode, series = its flag, value = its players; the offers' series was
// mode_series). Still decoded so the wire stays compatible; ignored and no longer sent.
mode = 17,
// The leader's flag of a plugin mode (throwdown_kit.h Flag), published by its plugin:
// id = the flag's game, challenge = the mode ("plugin/mode"), placement = the flag
// (encode_flag_body). Sent when it changes and every few seconds; withdrawn once.
flag = 18
};
Kind kind = Kind::offer;
std::uint64_t leader{}; // Steam ID of the player hosting the throwdown
@@ -65,6 +69,20 @@ inline constexpr std::string_view mode_series = "ReSkateMode";
constexpr std::size_t max_throwdown_series = 48, max_throwdown_params = 1536, max_throwdown_order = 32,
max_throwdown_boards = 16, max_challenge_id = 96, max_challenge_criteria = 64,
challenge_criteria_size = 0x14, max_challenge_players = 4;
// Kind::flag's body: the plugin's flag id, where it stands and what its card says.
struct ThrowdownFlagBody {
std::string id;
std::array<float, 3> at{};
float yaw{}; // degrees
std::uint8_t players = 1, capacity = 6;
bool open = true, withdrawn{};
std::vector<std::pair<std::string, std::string>> stats; // {title, value}
bool operator==(const ThrowdownFlagBody &) const = default;
};
constexpr std::size_t max_flag_id = 64, max_flag_stat = 48, max_flag_stats = 3;
// Throws std::invalid_argument for an invalid flag.
std::vector<std::uint8_t> encode_flag_body(const ThrowdownFlagBody &);
std::optional<ThrowdownFlagBody> decode_flag_body(std::span<const std::uint8_t>) noexcept;
bool valid_throwdown(const ThrowdownMessage &) noexcept;
// Throws std::invalid_argument for an invalid message.
std::vector<std::uint8_t> encode_throwdown(const ThrowdownMessage &);
+34 -8
View File
@@ -4,6 +4,7 @@
#include "trainer_presets.h"
#include "trainer_session.h"
#include "trainer_landing.h"
#include "trainer_motion.h"
#include "trainer_waypoint.h"
#include "Engine/Core/Json/json.h"
#include "Engine/Core/Log/logging.h"
@@ -18,6 +19,8 @@
#include "Extension/Skater/client_source_spawn_internal.h"
#include "Extension/Skater/no_bail.h"
#include "Extension/Skater/physics_tuning.h"
#include "Extension/Skater/skater_push.h"
#include "Extension/Throwdowns/throwdown_kit.h"
#include "Extension/UI/Overlay/overlay.h"
#include <mutex>
#include <thread>
@@ -38,6 +41,12 @@
namespace dingosdk::trainer {
namespace {
namespace tuning = dingosdk::physics_tuning;
namespace kit = dingosdk::throwdowns::kit;
// A plugin match blocks markers and returns, unless it lets teleports through (its own).
bool match_blocks_markers() noexcept {
const auto m = kit::match();
return m.markers && !m.teleports;
}
using Vec3 = std::array<float, 3>;
constexpr float max_coordinate = 1.0e6f;
constexpr std::uint32_t air_states_first = 200, air_states_end = 300;
@@ -141,7 +150,7 @@ struct State {
std::vector<std::size_t> class_entries; // the entries that are fields of those classes
// A session whose host sets everyone's physics (session_physics.h): what the host shares
// beyond its tuning, and what each class field holds for it. The player's own stand down.
bool enforced{};
bool enforced{}, competition{};
std::uint64_t host_revision{};
SessionExtras host;
std::array<float, class_field_count> host_classes{};
@@ -668,11 +677,14 @@ void publish_extras() {
void sync_session() {
auto &s = state();
const bool enforced = session_tuning_enforced();
const bool competition = competition_stock_physics();
const bool competition_changed = competition != s.competition;
std::vector<std::uint8_t> bytes;
bool fresh = false;
if (enforced) fresh = host_physics_extras(s.host_revision, bytes);
if (competition) { fresh = competition_changed; s.host_revision = 0; }
else if (enforced) fresh = host_physics_extras(s.host_revision, bytes);
else s.host_revision = 0; // read again the next time a host sets the physics
if (enforced == s.enforced && !fresh) return;
if (enforced == s.enforced && !fresh && !competition_changed) return;
if (fresh) {
auto decoded = decode_session_extras(bytes);
if (!decoded) say(logging::Level::warning, "Trainer: what the host shares beyond its tuning could not be read; the game's own is used for it.");
@@ -683,6 +695,7 @@ void sync_session() {
say(logging::Level::info, std::format("Trainer: skating with the host's trick multipliers and {} of its class values.", s.host.classes.size()));
}
s.enforced = enforced;
s.competition = competition;
for (const auto index : s.class_entries) want_class(s.entries[index]);
changed(false);
}
@@ -1022,7 +1035,11 @@ void enter_map(const std::string &level) {
}
MapData &current_map() { return state().maps[state().map]; }
bool teleport_allowed(std::string &why) {
if (multiplayer_session_active() && !session_noclip_allowed()) {
if (match_blocks_markers()) {
why = "Quit the active match before using markers or teleporting.";
return false;
}
if (multiplayer_session_active() && !session_noclip_allowed() && !kit::match().teleports) {
why = "The host has turned off teleporting in this session.";
return false;
}
@@ -1210,14 +1227,14 @@ void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) {
}
const auto seconds = m.valid ? clock_seconds(stamp - m.stamp) : 0.0;
const Vec3 step{position[0] - m.position[0], position[1] - m.position[1], position[2] - m.position[2]};
const bool jumped = std::sqrt(step[0] * step[0] + step[1] * step[1] + step[2] * step[2]) > 25.0f;
if (!m.valid || seconds <= 0.0005 || seconds > 0.25 || jumped) {
const float distance=std::sqrt(step[0]*step[0]+step[1]*step[1]+step[2]*step[2]);
if (reset_motion_segment(m.valid,seconds,distance,skater_push::holding() || skater_push::pending() || local_skater_teleport_pending())) {
// First sample, a hitch, or a teleport: nothing can be measured across it.
m = {};
m.valid = true;
m.position = position;
m.stamp = stamp;
t.airborne = false;
t.airborne = false;t.speed=0;t.vertical=0;
t.air_time = t.height = 0;
return;
}
@@ -1301,7 +1318,7 @@ void observe(std::uintptr_t base, std::uintptr_t client, std::uint64_t now) {
}
// A bail: back to the selected marker once the skater has had time to fall.
if (watch.valid && (!s.wipeouts_known || watch.wipeouts != s.seen_wipeouts)) {
if (s.wipeouts_known && s.auto_return) {
if (s.wipeouts_known && s.auto_return && !match_blocks_markers()) {
const bool marked = current_map().markers[static_cast<std::size_t>(s.slot)].set;
if (marked) s.return_at = now + static_cast<std::uint64_t>(s.return_delay * 1000.0f);
say(logging::Level::info, marked ? std::format("Trainer: bail seen; back to marker {} in {:.1f} s.", s.slot + 1, s.return_delay)
@@ -1315,6 +1332,9 @@ void set_logging(bool on) {
auto &s = state();
if (s.log.is_open()) s.log.close();
s.logging = false;
#if defined(DINGOSDK_LEAN_PLAYTEST)
(void)on;
#else
if (!on) return;
const auto directory = data_directory() / L"telemetry";
std::error_code error;
@@ -1331,6 +1351,7 @@ void set_logging(bool on) {
s.log << "seconds,x,y,z,speed_mps,vertical_mps,heading_deg,airborne,physics_state,board_turn_dps\n";
s.log_start = clock_now();
s.logging = true;
#endif
}
void shortcuts() {
auto &s = state();
@@ -1553,6 +1574,7 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool playing, const std::s
refresh_waypoint();
}
if (playing) {
if (match_blocks_markers()) s.return_at = 0;
observe(base, client, now);
shortcuts();
update_landing(now);
@@ -1591,6 +1613,8 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool playing, const std::s
// The speeds pushes aim for are the push class's (value_links ties them to the tuning's top
// pushing speed, which only gates whether a push may start).
const auto *top_speed = find_entry("physicspush.maxpushablespeed");
if(const auto push=skater_push::peek())
(void)queue_skater_push_velocity(client,s.entity,*push);
set_push_speed(client, s.entity, 1.0f, top_speed ? static_cast<float>(top_speed->stock) : 0.0f, s.boosts.cruise);
for (TrickLaunch launch; take_trick_launch(launch);)
if (launch.factor != 1.0f)
@@ -1734,6 +1758,8 @@ std::string run(std::string_view verb, const std::vector<std::string> &a) {
}
if (v == "marker") {
const auto action = lower(arg(0));
if ((action == "save" || action == "go") && match_blocks_markers())
return "error: markers are disabled during this match.";
const auto slot = slot_argument(a, 1);
if (!slot) return std::format("error: slots are 1 to {}.", marker_slots);
if (s.map.empty()) return "error: load a level first.";
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include <cmath>
namespace dingosdk::trainer {
// Short checkpoint teleports can be less than the old 25m jump threshold.
// Their displacement is never riding velocity, even at an ordinary frame time.
inline bool reset_motion_segment(bool valid,double seconds,float distance,bool teleported) noexcept {
return !valid || !std::isfinite(seconds) || seconds<=.0005 || seconds>.25 || !std::isfinite(distance) || distance>25.f || teleported;
}
}
@@ -0,0 +1,49 @@
#pragma once
#include <array>
#include <cstdint>
#include <optional>
namespace dingosdk::multiplayer::menu_data {
struct NativeFlagInteraction {std::uint32_t mmid{};std::uint64_t model{};};
// Exact native ThrowdownEventInteractable.OnInteractableFocusChanged graph.
// The event owns both MMID and UIInteractableHandle. Native field metadata
// supplies offsets; never infer ownership from the visible Spot Battle label.
template<class Read>
std::optional<NativeFlagInteraction> native_flag_interaction(std::uintptr_t vm,Read&& read) {
const auto pointer=[&](std::uintptr_t at){std::uintptr_t x{};return at && read(at,&x,sizeof(x))?x:0;};
const auto word=[&](std::uintptr_t at){std::uint32_t x{};return at && read(at,&x,sizeof(x))?x:0;};
const auto half=[&](std::uintptr_t at)->std::uint16_t{std::uint16_t x{};return at && read(at,&x,sizeof(x))?x:0;};
const auto resource=pointer(vm+0x38),instance=pointer(vm+0x30);
constexpr std::array<std::uint32_t,10> expected{80,1392,3539,760,7,0,8,3604513,6160395,1};
std::array<std::uint32_t,10> layout{};
if(!resource || !instance || pointer(instance)!=resource || word(resource+0x10)!=0x627382a6 ||
!read(resource+0x20,layout.data(),sizeof(layout)) || layout!=expected)return {};
const auto constants=pointer(instance+80),ports=pointer(instance+88);
const auto type=constants?pointer(constants+1056):0,meta=pointer(type);
if(!meta || word(meta)!=3388528466U || ((half(meta+4)>>5)&31)!=3)return {};
auto object=ports?pointer(ports+8):0;
for(unsigned depth=0;object && depth<3;++depth) {
if(pointer(object)==type || pointer(object+8)==type)break;
object=pointer(object);
}
if(!object || (pointer(object)!=type && pointer(object+8)!=type))return {};
const auto field=[&](std::uintptr_t metadata,std::uint32_t hash,unsigned width)->std::optional<unsigned> {
const auto count=half(metadata+0x2a),size=half(metadata+6);
const auto entries=pointer(metadata+0x38);
if(count>32 || !entries || size>8192)return {};
for(unsigned i=0;i<count;++i)if(word(entries+i*24)==hash) {
const auto offset=half(entries+i*24+8);
const auto ft=pointer(entries+i*24+16),fm=pointer(ft);
if(fm && half(fm+6)==width && offset+width<=size)return offset;
}
return {};
};
const auto handle=field(meta,936281207U,8); // UIInteractableHandle
// ClassInfoData's parent ClassInfo is a typed native reflection pointer.
const auto parent=pointer(meta+0x30),base=pointer(parent);
if(!handle || !base || word(base)!=2454258579U || ((half(base+4)>>5)&31)!=3)return {};
const auto mmid=field(base,0x66ba7ea4,4);
if(!mmid)return {};
return NativeFlagInteraction{word(object+*mmid),pointer(object+*handle)};
}
} // namespace dingosdk::multiplayer::menu_data
+7 -1
View File
@@ -107,7 +107,7 @@ void remove_hub_tiles(const Context &c) {
for (const auto &root : c.roots({tile_list_schema})) {
const auto list = c.field(root.model, tile_items);
unsigned count{}, stride{};
const auto bytes = c.array(list, 32, count, stride);
c.array(list, 32, count, stride);
std::vector<bool> unwanted(count);
unsigned last_kept = 0; // one past the last tile that stays
for (unsigned i = 0; i < count; ++i) {
@@ -127,6 +127,12 @@ void remove_hub_tiles(const Context &c) {
current_entry < static_cast<std::int32_t>(count) && unwanted[static_cast<unsigned>(current_entry)])
c.set(entry, first_kept);
if (last_kept < count && std::any_of(unwanted.begin() + last_kept, unwanted.end(), [](bool u) { return u; })) {
// Hiding an earlier tile clears its inline navigation CString,
// delegate, content and style refs. The earlier row snapshot would
// borrow released values and undo those edits when republished.
unsigned live_count{},live_stride{};
const auto bytes=c.array(list,32,live_count,live_stride);
require(live_count==count && live_stride==stride,"Native Hub rows changed while hiding tiles.");
std::vector<std::byte> kept(bytes.begin(), bytes.begin() + std::size_t{last_kept} * stride);
c.array(list, kept, last_kept);
}
@@ -0,0 +1,168 @@
#pragma once
#include <cstdint>
#include <array>
#include <optional>
#include <span>
namespace dingosdk::multiplayer::menu_data {
// Compiled native StackScreenController.ShowStackItemWithContent. Completion
// runs before rendering; only this exact ABI may prepare a pending Page body.
inline bool native_activity_page_publish_graph(std::uint32_t hash,const std::array<std::uint32_t,10>& layout) noexcept {
constexpr std::array<std::uint32_t,10> expected{112,288,1574,287,15,0,0,983055,2162688,16978432};
return hash==0x64c856aa && layout==expected;
}
struct HudRootCandidate {
std::uint64_t handle{};
bool active{};
unsigned gameplay_stacks{};
};
// An inactive parent is a native transition, not a hidden custom item. Its
// bindings can already be retired; do not repair children until it resumes.
inline bool hud_publication_allowed(bool parent_active,bool menu_open,bool panel_owned) noexcept {
// An unconfirmed request leaves gameplay visible. Actual native menu or
// parent inactivity still prevents unsafe publication during transitions.
return parent_active && !menu_open && !panel_owned;
}
// A cached active Page is not proof that the native menu owns gameplay.
// Confirm its menu state and camera focus continuously before borrowing it.
class ActivityMenuTransitionGate {
std::uint64_t page_{},since_{};
bool observed_{};
public:
bool ready(std::uint64_t page_handle,bool menu_open,bool selection_owned,bool camera_focused,std::uint64_t now) noexcept {
if(!page_handle || !menu_open || !selection_owned || !camera_focused) {
page_=0;observed_=false;return false;
}
if(!observed_ || page_!=page_handle) {page_=page_handle;since_=now;observed_=true;}
return now>=since_ && now-since_>=150;
}
};
// Wait briefly for a valid native parent on entry or after a real transition.
// Custom HUD curves are opaque, so no stock fade duration needs to elapse.
class HudPublicationGate {
std::uint64_t root_{},generation_{},since_{};
bool waiting_{true},observed_{};
public:
bool allow(std::uint64_t root,std::uint64_t generation,bool ready,std::uint64_t now) noexcept {
if(root_!=root || generation_!=generation) {root_=root;generation_=generation;observed_=false;waiting_=true;}
if(!root || !ready) {observed_=false;waiting_=true;return false;}
if(!observed_) {since_=now;observed_=true;}
if(waiting_ && now-since_<100)return false;
waiting_=false;return true;
}
};
// A retained native menu-layer item alone is not an open menu. Such cached
// selections must not dismiss a persistent gameplay shortcut.
inline bool hud_shortcut_visible(bool available,bool menu_open,bool panel_open,bool opening,bool quitting) noexcept {
return available && !menu_open && !panel_open && !opening && !quitting;
}
class HudResultsWindow {
std::uint64_t match_{},ended_{};
bool finished_{};
public:
bool visible(std::uint64_t match,bool finished,std::uint64_t now) noexcept {
if(match_!=match || !finished){match_=match;ended_=now;finished_=false;}
if(!match || !finished)return false;
if(!finished_){ended_=now;finished_=true;}
const auto age=now>=ended_?now-ended_:0;
return age>=4000 && age<16000;
}
};
struct HudCountdownVisibility { bool countdown{},intro{},go{}; };
// Timing belongs to the challenge phase, independently of optional artwork.
// A failed scoreboard build must not prevent the preceding GO from expiring.
class HudCountdownLifecycle {
std::uint64_t game_{},go_until_{};
bool was_counting_{};
public:
HudCountdownVisibility update(std::uint64_t game,bool counting,bool playing,
std::uint64_t remaining,std::uint64_t now) noexcept {
if(game_!=game) {game_=game;go_until_=0;was_counting_=false;}
if(!game) {go_until_=0;was_counting_=false;return {};}
if(playing && was_counting_)go_until_=now+900;
if(!counting && !playing)go_until_=0;
was_counting_=counting;
const bool intro=counting && remaining>3000;
const bool go=playing && now<go_until_;
return {(counting && !intro)||go,intro,go};
}
};
struct NotificationMountNode {
std::uint64_t model{};
std::uint32_t type{};
std::uint64_t content{};
};
struct NotificationMount {
std::uint64_t presentation{}, model{};
bool operator==(const NotificationMount&) const = default;
};
// Only a private model or a deliberately hidden slot belongs to our adapter.
// A new native notification in the same slot must keep its own presentation.
inline bool owns_waiting_presentation(std::uint64_t current,std::uint64_t custom,bool hidden) {
return custom && (current==custom || (!current && hidden));
}
// The backend reuses a waiting card and its input delegates across challenges.
// Resolve actions through its visible private presentation and current flag.
inline bool owns_waiting_action(std::uint64_t requested_source,std::uint64_t source,
std::uint64_t current,std::uint64_t custom,bool registration_owned,
bool retiring,bool lobby) noexcept {
return requested_source && requested_source==source && custom && current==custom &&
registration_owned && !retiring && lobby;
}
// Native navigation can move our private notification into a new presenter.
// Recover its source independently of the previous presenter's identity so
// the next challenge can update its title and callbacks.
template<class Copies> std::uint64_t waiting_notification_source(
std::uint64_t current,const Copies& copies) {
for(const auto& [source,copy]:copies)if(current==copy.handle)return source;
return current;
}
// The secondary HUD stack holds FoundationsNotificationViewModel wrappers,
// not NotificationViewModel directly. Resolve only mounted, known wrappers;
// scanning all registered notifications also includes unrendered defaults.
template<class Read> std::optional<NotificationMount> mounted_notification(
std::uint64_t presentation, Read&& read) {
for(unsigned depth=0; presentation && depth<8; ++depth) {
const auto node=read(presentation);
if(!node.model)return std::nullopt;
if(node.type==0x1053b9ec)return NotificationMount{presentation,node.model};
if(node.type!=0xffb83e48 || node.content==presentation)return std::nullopt;
presentation=node.content;
}
return std::nullopt;
}
// Authored defaults are registered as roots too. They have no game-owned
// compass/scoring/notification items and are not connected to a HUD widget.
inline std::uint64_t mounted_hud_root(std::span<const HudRootCandidate> roots,
std::uint64_t preferred = 0,
std::uint64_t authored = 0) {
// The native name alias identifies the authored default exactly. Once
// excluded, a unique active instance is live even with every optional
// stock HUD section hidden. Handles include generations, not type tags.
if(authored) {
std::uint64_t live{};unsigned count{};
for(const auto& root:roots)if(root.handle && root.handle!=authored && root.active) {
live=root.handle;++count;
}
if(count==1)return live;
}
std::uint64_t selected{};
std::uint64_t retained{};
unsigned most{};
for (const auto& root : roots) {
if (!root.handle || !root.active || root.handle==authored) continue;
// Native score/compass stacks briefly empty during activity changes.
// Retain only a previously verified live identity, never a new default.
if (!root.gameplay_stacks) {
if(root.handle==preferred)retained=root.handle;
continue;
}
if (root.handle == preferred) return root.handle;
if (root.gameplay_stacks > most) {
selected = root.handle;
most = root.gameplay_stacks;
}
}
return selected?selected:retained;
}
} // namespace dingosdk::multiplayer::menu_data
+150
View File
@@ -0,0 +1,150 @@
#pragma once
#include <cstdint>
#include <algorithm>
#include <cmath>
#include <string_view>
#include <format>
#include <string>
#include <array>
#include <optional>
namespace dingosdk::multiplayer::menu_data {
// The supported desktop gameplay screen lays out inside a 90% safe frame.
// Our anchors belong to that frame, so a small positive anchor alone still
// leaves the outer 5% margin visible. Convert only our screen-edge anchors;
// stock screens, centered notices and the stable HUD presenters stay intact.
inline constexpr float native_hud_screen_anchor(float screen) noexcept {
return (screen-.05f)/.90f;
}
inline constexpr float native_hud_side_center=.60f;
// Rime's start/end offsets are opposing insets. Equal values resize a
// rectangle; opposite values translate it without changing its bounds.
inline constexpr std::array<float,2> native_hud_translation(float offset) noexcept {
return {offset,-offset};
}
inline constexpr float native_hud_shortcut_width=672.f;
inline constexpr float native_hud_shortcut_edge=.998f;
// Approximate brush-font advance, including padding; round to an authored
// native frame. UTF-8 continuation bytes do not inflate a gamertag's width.
inline unsigned native_hud_name_width(std::string_view name) {
unsigned advance=32;
for(const unsigned char ch:name) {
if((ch&0xc0)==0x80)continue;
advance+=(ch=='I' || ch=='i' || ch=='l' || ch==' ' || ch=='.')?12:
(ch=='W' || ch=='M' || ch=='w' || ch=='m' || ch>=0x80)?36:26;
}
return std::clamp((advance+31u)/32u*32u,224u,1280u);
}
struct HudHeaderGeometry {
unsigned frame_width,content_width,title_width,icon=64,content_height=80,padding=48;
float badge_y_shift{},title_y_shift{};
static constexpr unsigned height=112,text_height=80,gap=24;
};
inline std::string native_hud_title_text(std::string_view title) {
std::string result(title);for(auto& character:result)if(character>='a' && character<='z')character=char(character-'a'+'A');return result;
}
inline HudHeaderGeometry native_hud_header_geometry(std::string_view title) {
// Brush title advances are distinct from H52 player-name cells. Reserve a
// small font allowance, then fit the authored frame to this title's row.
// Short names must not inherit a scoreboard-wide minimum background.
unsigned advance=12;
for(const unsigned char ch:native_hud_title_text(title)) {
if((ch&0xc0)==0x80)continue;
if(ch==' ' || ch=='.')advance+=16;
else if(ch=='-' || ch=='t' || ch=='f' || ch=='r')advance+=24;
else if(ch=='I' || ch=='i' || ch=='l' || ch=='j')advance+=18;
else if(ch=='W' || ch=='M')advance+=58;
else if(ch=='w' || ch=='m' || ch>=0x80)advance+=48;
else if(ch>='a' && ch<='z')advance+=34;
else if(ch=='E' || ch=='F' || ch=='T' || ch=='P' || ch=='S')advance+=40;
else advance+=44;
}
const auto scaled=(advance*39u+31u)/32u; // Native D78 brush title, with a conservative width allowance.
const auto text=std::clamp((scaled+7u)/8u*8u,144u,1600u);
const unsigned icon=64u;
const auto content=text+icon+HudHeaderGeometry::gap;
const auto frame=(std::max(320u,content+96u)+31u)/32u*32u;
return {frame,content,text,icon,80u};
}
// A header with a document's icon size and frame width (0: the measured default). A larger icon's
// paint baseline sits higher than the 64 px one's; a fixed frame keeps 120 px either side of its
// icon and title, the brush title fitted inside it (a 704 px frame holds a 344 px title beside a
// 96 px icon, as a captured mode header did).
inline HudHeaderGeometry native_hud_header_geometry(std::string_view title,unsigned icon_size,unsigned frame_width) {
auto g=native_hud_header_geometry(title);
if(!icon_size && !frame_width)return g;
if(icon_size) {
g.icon=icon_size;g.content_height=std::max(80u,icon_size);
if(icon_size>64u) {g.badge_y_shift=-6.f;g.title_y_shift=-2.f;}
}
if(frame_width) {
const auto used=240u+g.icon+HudHeaderGeometry::gap;
if(frame_width>used)g.title_width=std::min(g.title_width,frame_width-used);
}
g.content_width=g.icon+HudHeaderGeometry::gap+g.title_width;
g.frame_width=frame_width?std::max(frame_width,g.content_width):(std::max(320u,g.content_width+96u)+31u)/32u*32u;
g.padding=(g.frame_width-g.content_width)/2u;
return g;
}
// A clock as the native HUD shows it: mm:ss.mmm.
inline std::string native_hud_clock_text(std::uint64_t milliseconds) {
return std::format("{:02}:{:02}.{:03}",milliseconds/60000,(milliseconds/1000)%60,milliseconds%1000);
}
// A published clock keeps advancing from the time it was sampled, so a plugin
// that publishes less often than a frame still shows a running clock. A clock
// counting up never passes its cap; one counting down stops at zero.
inline std::uint64_t native_hud_clock_elapsed(std::uint64_t observed,std::uint64_t sampled,
std::uint64_t now,bool running,std::uint64_t cap) {
const auto extra=running && sampled && now>sampled?now-sampled:0;
if(!cap)return observed+extra;
return observed>=cap?cap:observed+std::min(extra,cap-observed);
}
inline std::uint64_t native_hud_clock_remaining(std::uint64_t observed,std::uint64_t sampled,
std::uint64_t now,bool running) noexcept {
const auto extra=running && sampled && now>sampled?now-sampled:0;
return extra>=observed?0:observed-extra;
}
// The native compass strip: ahead is its centre, +/-90 degrees either end of
// its 1184 units; a point behind stays at the nearer end. `camera` is the world
// matrix (rows: right, up, back, position).
inline constexpr float native_hud_compass_icon=96.f;
inline constexpr float native_hud_compass_center_y=209.f;
inline std::optional<float> native_hud_compass_offset(const std::array<float,3>& target,const std::array<float,16>& camera) {
const float dx=target[0]-camera[12],dz=target[2]-camera[14];
const float right=dx*camera[0]+dz*camera[2],forward=-dx*camera[8]-dz*camera[10];
if(!std::isfinite(right) || !std::isfinite(forward) || std::hypot(camera[8],camera[10])<.001f)return {};
constexpr float reach=(1184.f-native_hud_compass_icon)*.5f;
return std::clamp(std::atan2(right,forward)/(3.14159265f*.5f),-1.f,1.f)*reach;
}
// A toast's opacity: in over 180 ms, held, out over 300 ms.
inline float native_hud_toast_alpha(std::uint64_t now,std::uint64_t shown,std::uint64_t duration) noexcept {
if(!shown || now<shown)return 0.f;
const auto age=now-shown,hold=std::max<std::uint64_t>(duration,480);
if(age<180)return static_cast<float>(age)/180.f;
if(age<hold-300)return 1.f;
return age<hold?static_cast<float>(hold-age)/300.f:0.f;
}
inline unsigned native_hud_value_width(std::string_view text) {
unsigned advance=40;
for(const unsigned char ch:text) {
if((ch&0xc0)==0x80)continue;
advance+=(ch==':' || ch=='.' || ch==' ')?12:(ch=='W' || ch=='M' || ch>=0x80)?36:26;
}
return std::clamp((advance+31u)/32u*32u,224u,640u);
}
// CountdownViewModel stores whole seconds as Uint64. The match clock stores
// milliseconds. Round up so 2.9 seconds displays 3, and GO occurs only at zero.
constexpr std::uint64_t native_hud_seconds(std::uint64_t milliseconds) {
return milliseconds / 1000 + (milliseconds % 1000 != 0);
}
inline unsigned native_slider_choice(float normalized,unsigned minimum,unsigned maximum,unsigned step) {
if(!std::isfinite(normalized))return minimum;
const auto steps=(maximum-minimum)/step;
const auto index=static_cast<unsigned>(std::round(std::clamp(normalized,0.f,1.f)*steps));
return minimum+std::min(index,steps)*step;
}
inline float native_slider_position(unsigned choice,unsigned minimum,unsigned maximum) {
if(maximum<=minimum)return 0.f;
return float(std::clamp(choice,minimum,maximum)-minimum)/float(maximum-minimum);
}
} // namespace dingosdk::multiplayer::menu_data
@@ -0,0 +1,37 @@
#pragma once
#include <array>
#include <cstdint>
#include <optional>
namespace dingosdk::multiplayer::menu_data {
// Captured ThrowdownDetailsMenuRule.FillContent: its Instance input is a
// reference to the native rule which owns MMId. Class fields use +38 (struct
// fields use +60); never infer the selected registration from a donor title.
template<class Read> std::optional<std::uint32_t> native_join_mmid(std::uintptr_t vm,Read read) {
using P=std::uintptr_t;
const auto pointer=[&](P at) {P v{};return read(at,&v,sizeof(v))?v:P{};};
const auto word=[&](P at) {std::uint32_t v{};return read(at,&v,sizeof(v))?v:0U;};
const auto resource=pointer(vm+0x38),instance=pointer(vm+0x30);
constexpr std::array<std::uint32_t,10> expected{624,40656,76600,21866,312,0,292,8520953,54067355,17437442};
std::array<std::uint32_t,10> layout{};
if(!resource || !instance || pointer(instance)!=resource || word(resource+0x10)!=0xebb4ab69 ||
!read(resource+0x20,layout.data(),sizeof(layout)) || layout!=expected)return {};
const auto constants=pointer(instance+624),ports=pointer(instance+632);
const auto type=constants?pointer(constants+27912):0,meta=type?pointer(type):0;
std::uint16_t flags{},size{},count{};
if(!meta || word(meta)!=1847693693U || !read(meta+4,&flags,2) || ((flags>>5)&31)!=3 ||
!read(meta+6,&size,2) || size!=352 || !read(meta+0x2a,&count,2) || count!=4)return {};
const auto fields=pointer(meta+0x38);std::optional<unsigned> offset;
for(unsigned i=0;fields && i<count;++i)if(word(fields+i*24)==0x66ba7ea4) {
std::uint16_t at{};
if(read(fields+i*24+8,&at,2) && at==0x150)offset=at;
}
if(!offset || !ports)return {};
auto object=pointer(ports+168);
for(unsigned depth=0;object && depth<3;++depth) {
if(object!=type && (pointer(object)==type || pointer(object+8)==type))return word(object+*offset);
object=pointer(object);
}
return {};
}
} // namespace dingosdk::multiplayer::menu_data
+52 -12
View File
@@ -1,8 +1,9 @@
#include "native_menu.h"
#include "native_menu_internal.h"
#include "native_menu_dump.h"
#include "ui_sound.h"
#include "throwdown_menu.h"
#include "Extension/UI/own_ui_writes.h"
#include "ui_sound.h"
#include "Extension/Multiplayer/Session/session.h"
#include "Extension/Multiplayer/Hud/game_ui_state.h"
#include "Engine/Core/Log/logging.h"
@@ -26,7 +27,7 @@ constexpr std::string_view store_tab_label = "ID_MENU_STORE", social_tab_label =
constexpr unsigned social_tab_index = 4; // Before the Store is removed.
constexpr std::array<unsigned, page_count> page_slots{0, 1};
// The Store item taken out of the pause menu, its index there, and that menu.
struct RemovedStore { std::vector<std::byte> item; unsigned index{}; Address core{}; };
struct RemovedStore { Value item; unsigned index{}; Handle core{}; Address manager{};std::uint32_t key{}; };
RemovedStore removed_store;
// When insert() last left a copy of that Store item last in the menu (0: not there); trim_store drops it.
std::uint64_t store_last_since{};
@@ -140,6 +141,15 @@ void ensure_body(const Context& context) {
context.set(context.path(s.page, {content, 0x18f8355b}), static_cast<int>(s.section));
context.set(context.path(s.page, {content, 0x55511280}), static_cast<int>(s.section));
}
std::vector<std::byte> removed_store_bytes(const Context& context) {
require(removed_store.manager==context.manager && removed_store.item.handle &&
context.type_of(removed_store.item.handle)==removed_store.item.type,
"Retained native Store item has expired.");
std::vector<std::byte> result(stack_item.size);
require(memory::peek_bytes(context.address(removed_store.item),result.data(),result.size()),
"Retained native Store item is unavailable.");
return result;
}
void tab_label(const Context& context, Value item, const std::string& name, std::uint32_t key) {
const auto tab = context.field(item, tab_data);
context.text(context.path(tab, {label, text_field}), name);
@@ -278,7 +288,24 @@ void insert(const Context& context) {
if (const auto store = find_tab(context, list, count, store_tab_label);
store && !missing.empty() && count - 1 + missing.size() >= 5) {
const auto at = values.begin() + static_cast<std::ptrdiff_t>(*store) * stride;
removed_store = {std::vector<std::byte>(at, at + stride), *store, s.core.handle};
// The live Store row is about to disappear. Its TabData includes
// owned strings/delegates/texture arrays, so retain one typed row for
// this menu instead of keeping borrowed bytes until level teardown.
const auto checkpoint=s.owned_models.checkpoint();
auto retained=removed_store.item;
if(removed_store.manager!=context.manager || removed_store.core!=s.core.handle ||
!retained.handle || context.type_of(retained.handle)!=retained.type)retained={};
try {
if(!retained.handle)retained=make(context,stack_item);
context.copy(retained,context.address(context.element(list,*store)));
} catch(...) {
s.owned_models.rollback(checkpoint,[&](Value value){context.destroy(value);});
throw;
}
const auto key_offset=context.member(context.type(stack_item),key_field).offset;
std::uint32_t store_key{};
std::memcpy(&store_key,values.data()+std::size_t{*store}*stride+key_offset,sizeof(store_key));
removed_store = {retained, *store, s.core.handle, context.manager, store_key};
values.erase(at, at + stride);
--count;
// The game has already opened the page at its selected index, which it keeps
@@ -300,7 +327,8 @@ void insert(const Context& context) {
++count;
}
if (store_last) {
values.insert(values.end(), removed_store.item.begin(), removed_store.item.end());
const auto retained=removed_store_bytes(context);
values.insert(values.end(), retained.begin(), retained.end());
++count;
store_last_since = GetTickCount64();
}
@@ -321,8 +349,9 @@ void insert(const Context& context) {
}
// The copy is the item last in the stack with the removed Store's key.
bool is_store_copy(std::span<const std::byte> item) {
return removed_store.item.size() == item.size() &&
std::equal(item.begin() + 0x568, item.begin() + 0x56c, removed_store.item.begin() + 0x568);
if(!removed_store.item.handle || item.size()!=stack_item.size)return false;
std::uint32_t key{};std::memcpy(&key,item.data()+0x568,sizeof(key));
return key==removed_store.key;
}
// Drops the Store copy insert() left last, a few frames after the stack saw it there.
void trim_store(const Context& context, Value menu) {
@@ -332,7 +361,8 @@ void trim_store(const Context& context, Value menu) {
auto values = context.array(list, 16, count, stride);
store_last_since = 0;
// Not there: the game rebuilt its tabs, the Store back in place, as the menu reopened.
if (stride != stack_item.size || count < 6 || !is_store_copy(std::span(values).last(stride))) return;
if (stride != stack_item.size || count < 6 || removed_store.manager!=context.manager ||
removed_store.core!=menu.handle || !is_store_copy(std::span(values).last(stride))) return;
const auto target = context.path(menu, {content, 0x18f8355b});
const auto selected = read<int>(context.address(target));
if (selected == static_cast<int>(count - 1)) {
@@ -657,7 +687,7 @@ bool prepare_native_menu_level_load(std::uintptr_t base) noexcept {
for (unsigned i = 0; i < count; ++i) {
const auto first = values.data() + i * stride;
// A Store copy insert() left last: the Store goes back to its own index below.
if (removed_store.core == s.core.handle && i + 1 == count &&
if (removed_store.manager==manager && removed_store.core == s.core.handle && i + 1 == count &&
is_store_copy(std::span(first, stride))) continue;
std::uint32_t key{}; std::memcpy(&key, first + key_offset, sizeof(key));
if (key >= our_key && key < our_key + page_count * 0x100 && (key - our_key) % 0x100 == 0) {
@@ -667,11 +697,13 @@ bool prepare_native_menu_level_load(std::uintptr_t base) noexcept {
retained.insert(retained.end(), first, first + stride);
}
// Put the native Store item back at its original index: it is out while ours are in.
if (ours && removed_store.core == s.core.handle && removed_store.item.size() == stride &&
bool restored_store{};
if (ours && removed_store.manager==manager && removed_store.core == s.core.handle && removed_store.item.handle &&
removed_store.index * stride <= retained.size()) {
const auto store=removed_store_bytes(context);
retained.insert(retained.begin() + static_cast<std::ptrdiff_t>(removed_store.index) * stride,
removed_store.item.begin(), removed_store.item.end());
removed_store = {};
store.begin(), store.end());
restored_store=true;
}
if (retained.size() == values.size() && std::equal(retained.begin(), retained.end(), values.begin())) return;
// Return focus to the first native tab before removing our
@@ -679,6 +711,9 @@ bool prepare_native_menu_level_load(std::uintptr_t base) noexcept {
context.set(context.field(stack, 0x18f8355b), 0);
context.set(context.field(stack, 0x55511280), 0);
context.array(list, retained, static_cast<unsigned>(retained.size() / stride));
// Keep the retained source and restoration identity if
// publication failed, so a cleanup retry can still restore it.
if(restored_store) {removed_store={};store_last_since=0;}
}, [&](Value value) { context.destroy(value); });
s.core = {}; s.page = {}; s.menu_item = {};
s.rows.clear(); s.assets.clear();
@@ -716,13 +751,18 @@ overlay::HubPage native_menu_page() noexcept {
return {true, page_shown.load(std::memory_order_relaxed)};
}
void tick_native_menu(std::uintptr_t base, bool loading) noexcept {
const ui::OwnUiWrites own_writes; // Custom labels are not the native trick list.
run_pending_ui_dump(base);
// The menu sounds the overlay's pages asked for (ui_sound.h).
tick_ui_sounds(base, loading);
// (Read here: it takes the UI model's lock, which a page's tick then holds.)
game_in_menu.store(!loading && sample_game_ui_state(base).in_menu, std::memory_order_relaxed);
if (lifetime.blocked()) return;
tick_native_throwdown_menu(base, loading);
tick_native_throwdown_menu(base, loading, game_in_menu.load(std::memory_order_relaxed));
if (native_throwdown_match_active()) {
page_seen.store(0,std::memory_order_relaxed);
return;
}
for (unsigned slot = 0; slot < page_count; ++slot) {
current_state = &page_state(slot);
current_state->slot = slot;
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff. Show more