From 844455340247aaffd07509ecdd16d534aa2c9d63 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Tue, 20 Oct 2026 01:04:54 -0500 Subject: [PATCH] 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__* 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. --- .../Game/Build/20260929/guid_table_lookup.h | 20 + .../Game/Build/20260929/native_throwdowns.h | 5 + Engine/Game/Build/20260929/no_bail.h | 9 +- Engine/Game/Build/20260929/skater_entities.h | 9 + Engine/Game/Input/owned_menu_input.h | 15 + Engine/Game/Multiplayer/session_competition.h | 14 + Engine/Game/Multiplayer/session_physics.h | 3 +- Engine/Game/Multiplayer/session_tools.h | 7 +- Engine/Game/World/load_completion.h | 13 + .../local_player_card_runtime.cpp | 9 +- Extension/Multiplayer/Hud/native_party.cpp | 9 + Extension/Multiplayer/Hud/native_party.h | 2 + .../Multiplayer/Hud/native_party_hooks.cpp | 9 + .../Multiplayer/Remote/native_skater.cpp | 2 + Extension/Multiplayer/Session/session.cpp | 50 +- .../Multiplayer/Session/session_commands.cpp | 5 + .../Multiplayer/Session/session_send.cpp | 5 + Extension/Multiplayer/Steam/steam_social.cpp | 35 +- Extension/Multiplayer/Test/protocol_tests.cpp | 8 +- Extension/Multiplayer/console_commands.cpp | 4 +- .../ParkEditor/park_editor_runtime.cpp | 81 + .../Objects/ParkEditor/park_editor_runtime.h | 13 + .../Objects/local_placements_runtime.cpp | 16 +- Extension/Objects/local_placements_runtime.h | 3 +- Extension/Plugins/Test/plugin_tests.cpp | 646 ++- Extension/Plugins/plugin_api.cpp | 13 +- Extension/Plugins/plugin_api.h | 7 + Extension/Plugins/plugin_api_services.cpp | 309 ++ Extension/Plugins/plugin_api_throwdowns.cpp | 402 ++ Extension/Plugins/plugin_api_ui.cpp | 2 +- Extension/Plugins/plugin_api_world.cpp | 214 +- Extension/Plugins/plugin_camera.h | 3 +- Extension/Plugins/plugin_game.h | 176 +- Extension/Plugins/plugin_game_runtime.cpp | 308 +- Extension/Plugins/plugin_host.cpp | 47 +- Extension/Plugins/plugin_manifest.cpp | 13 +- Extension/Plugins/plugin_runtime.cpp | 9 +- Extension/Plugins/plugin_vm.h | 4 + Extension/Plugins/plugin_wire.h | 2 +- Extension/Profile/local_profile_objects.cpp | 31 +- Extension/Profile/local_profile_runtime.cpp | 5 +- Extension/Profile/local_profile_runtime.h | 3 + .../local_entitlement_trigger_runtime.cpp | 7 +- .../Settings/local_gameplay_settings.cpp | 57 +- Extension/Settings/local_gameplay_settings.h | 3 + Extension/Settings/named_settings.cpp | 1 + Extension/Skater/Test/trick_watch_tests.cpp | 3 +- Extension/Skater/bone_cam.cpp | 724 ++++ Extension/Skater/bone_cam.h | 85 + Extension/Skater/bone_sprites.h | 37 + Extension/Skater/client_camera_activation.h | 52 + Extension/Skater/client_debug.cpp | 42 +- Extension/Skater/client_noclip.cpp | 73 + Extension/Skater/client_source_spawn.h | 7 + Extension/Skater/no_bail.cpp | 54 +- Extension/Skater/no_bail.h | 6 +- Extension/Skater/pad_flight.h | 10 + Extension/Skater/score_hud.h | 2 +- Extension/Skater/skater_push.cpp | 63 + Extension/Skater/skater_push.h | 27 + Extension/Skater/skater_skeleton.h | 39 + Extension/Skater/trick_feed.cpp | 126 + Extension/Skater/trick_feed.h | 22 + Extension/Skater/trick_gestures.cpp | 166 + Extension/Skater/trick_gestures.h | 23 + Extension/Skater/trick_watch.h | 4 +- .../Test/custom_throwdown_tests.cpp | 87 +- .../Throwdowns/Test/throwdown_relay_tests.cpp | 261 +- Extension/Throwdowns/challenge_registry.cpp | 99 + Extension/Throwdowns/challenge_registry.h | 53 + Extension/Throwdowns/custom_modes.cpp | 96 +- Extension/Throwdowns/custom_modes.h | 66 +- Extension/Throwdowns/custom_placement.cpp | 322 -- Extension/Throwdowns/custom_placement.h | 59 - Extension/Throwdowns/native_flag_bubble.h | 111 + .../Throwdowns/native_flag_cursor_height.h | 25 + Extension/Throwdowns/native_flag_preview.h | 50 + Extension/Throwdowns/native_flag_session.cpp | 507 +++ Extension/Throwdowns/native_flag_session.h | 61 + Extension/Throwdowns/native_flag_spawn.h | 60 + .../Throwdowns/native_throwdown_lifetime.h | 151 + Extension/Throwdowns/native_throwdowns.cpp | 505 ++- Extension/Throwdowns/native_throwdowns.h | 32 +- Extension/Throwdowns/session_marker_actions.h | 21 + Extension/Throwdowns/throwdown_kit.cpp | 560 +++ Extension/Throwdowns/throwdown_kit.h | 268 ++ Extension/Throwdowns/throwdown_lab.cpp | 473 ++- Extension/Throwdowns/throwdown_lab.h | 28 +- Extension/Throwdowns/throwdown_relay.cpp | 330 +- Extension/Throwdowns/throwdown_relay.h | 39 +- Extension/Throwdowns/throwdown_wire.cpp | 80 +- Extension/Throwdowns/throwdown_wire.h | 28 +- Extension/Trainer/trainer.cpp | 42 +- Extension/Trainer/trainer_motion.h | 9 + .../UI/NativeMenu/native_flag_interaction.h | 49 + Extension/UI/NativeMenu/native_hub.cpp | 8 +- .../UI/NativeMenu/native_hud_selection.h | 168 + Extension/UI/NativeMenu/native_hud_values.h | 150 + Extension/UI/NativeMenu/native_join_context.h | 37 + Extension/UI/NativeMenu/native_menu.cpp | 64 +- Extension/UI/NativeMenu/native_menu_data.cpp | 26 +- Extension/UI/NativeMenu/native_menu_data.h | 15 + .../UI/NativeMenu/native_menu_lifetime.h | 99 +- .../UI/NativeMenu/native_menu_ownership.h | 255 ++ Extension/UI/NativeMenu/native_setup_roles.h | 23 + Extension/UI/NativeMenu/throwdown_kit_hud.inl | 1239 ++++++ .../UI/NativeMenu/throwdown_map_identity.inl | 328 ++ .../UI/NativeMenu/throwdown_match_panel.inl | 1015 +++++ Extension/UI/NativeMenu/throwdown_menu.cpp | 3611 ++++++++++------- Extension/UI/NativeMenu/throwdown_menu.h | 19 +- .../UI/NativeMenu/throwdown_modes_ui.inl | 264 ++ Extension/UI/NativeMenu/ui_sound.cpp | 6 +- Extension/UI/NativeMenu/ui_sound.h | 4 +- Extension/UI/Overlay/input_capture.cpp | 104 +- Extension/UI/Overlay/input_capture.h | 2 + Extension/UI/Overlay/overlay.cpp | 2 +- Extension/UI/Overlay/overlay.h | 27 + Extension/UI/Overlay/overlay_input.cpp | 9 +- Extension/UI/Overlay/overlay_internal.h | 1 + Extension/UI/Overlay/overlay_render.cpp | 4 +- Extension/UI/Overlay/plugin_dialog.cpp | 256 ++ Extension/UI/Overlay/skate_hud_overlay.cpp | 44 +- Extension/UI/Overlay/skate_menu.cpp | 2 + Extension/UI/Overlay/skate_menu.h | 1 + Extension/UI/hud_corner.cpp | 8 +- Extension/UI/own_ui_writes.h | 12 + Extension/World/guid_table_guard.cpp | 84 + Extension/World/guid_table_guard.h | 10 + Extension/World/guid_table_probe.h | 33 + Extension/World/world_entities.cpp | 351 ++ Extension/World/world_entities.h | 33 + Extension/World/world_entities_contract.h | 99 + Runtime/client_tick.cpp | 34 +- Runtime/runtime_init.cpp | 7 + cmake/Core.cmake | 7 + cmake/Runtime.cmake | 9 +- cmake/UI.cmake | 1 + .../plugin/gates.lua | 134 + .../plugin/main.lua | 147 +- .../plugin/modes.lua | 109 +- .../plugin/sounds/cue-checkpoint.wav | Bin 0 -> 33118 bytes .../plugin/sounds/cue-finish.wav | Bin 0 -> 58696 bytes .../plugin/sounds/cue-invite.wav | Bin 0 -> 46348 bytes .../plugin/sounds/cue-missed.wav | Bin 0 -> 43702 bytes .../plugin/sounds/race-finish.wav | Bin 0 -> 363040 bytes .../plugin/sounds/race-gate.wav | Bin 0 -> 131568 bytes .../plugin/sounds/race-recovery.wav | Bin 0 -> 53932 bytes .../plugin/td/command.lua | 725 ++++ .../plugin/td/core.lua | 738 ++++ .../plugin/td/h/mode_announcements.lua | 58 + .../plugin/td/h/mode_lobby.lua | 41 + .../plugin/td/h/mode_participation.lua | 20 + .../plugin/td/h/placement_camera.lua | 51 + .../plugin/td/h/race_arrow_placement.lua | 176 + .../plugin/td/h/race_asset_contract.lua | 39 + .../plugin/td/h/race_editor.lua | 445 ++ .../plugin/td/h/race_group_reconciliation.lua | 27 + .../plugin/td/h/race_momentum.lua | 211 + .../plugin/td/h/race_navigation.lua | 38 + .../plugin/td/h/race_route.lua | 592 +++ .../plugin/td/h/race_sound.lua | 65 + .../plugin/td/hud.lua | 854 ++++ .../plugin/td/lobby.lua | 453 +++ .../plugin/td/oneup/match.lua | 312 ++ .../plugin/td/oneup/native.lua | 175 + .../plugin/td/oneup/runtime.lua | 595 +++ .../plugin/td/oneup/spawn.lua | 58 + .../plugin/td/oneup/wire.lua | 282 ++ .../plugin/td/placing.lua | 637 +++ .../plugin/td/play.lua | 674 +++ .../plugin/td/referee.lua | 811 ++++ .../plugin/td/routes.lua | 317 ++ .../plugin/td/rules.lua | 564 +++ .../plugin/td/rules_tests.lua | 761 ++++ .../plugin/td/skatepedia.lua | 338 ++ .../plugin/td/skater.lua | 426 ++ .../plugin/td/tick.lua | 301 ++ .../plugin/td/ui.lua | 268 ++ .../plugin/td/ui_events.lua | 335 ++ .../plugin/td/ui_hud.lua | 345 ++ .../plugin/td/ui_oneup.lua | 279 ++ .../plugin/td/wire.lua | 550 +++ .../plugin/td/world.lua | 535 +++ .../reskate-plugin.json | 25 +- contrib/plugins/hall-of-meat/plugin/main.lua | 49 +- .../throwdown-modes-test/plugin/main.lua | 86 +- 186 files changed, 28015 insertions(+), 2747 deletions(-) create mode 100644 Engine/Game/Build/20260929/guid_table_lookup.h create mode 100644 Engine/Game/Input/owned_menu_input.h create mode 100644 Engine/Game/Multiplayer/session_competition.h create mode 100644 Engine/Game/World/load_completion.h create mode 100644 Extension/Plugins/plugin_api_services.cpp create mode 100644 Extension/Plugins/plugin_api_throwdowns.cpp create mode 100644 Extension/Skater/bone_cam.cpp create mode 100644 Extension/Skater/bone_cam.h create mode 100644 Extension/Skater/bone_sprites.h create mode 100644 Extension/Skater/client_camera_activation.h create mode 100644 Extension/Skater/pad_flight.h create mode 100644 Extension/Skater/skater_push.cpp create mode 100644 Extension/Skater/skater_push.h create mode 100644 Extension/Skater/skater_skeleton.h create mode 100644 Extension/Skater/trick_feed.cpp create mode 100644 Extension/Skater/trick_feed.h create mode 100644 Extension/Skater/trick_gestures.cpp create mode 100644 Extension/Skater/trick_gestures.h create mode 100644 Extension/Throwdowns/challenge_registry.cpp create mode 100644 Extension/Throwdowns/challenge_registry.h delete mode 100644 Extension/Throwdowns/custom_placement.cpp delete mode 100644 Extension/Throwdowns/custom_placement.h create mode 100644 Extension/Throwdowns/native_flag_bubble.h create mode 100644 Extension/Throwdowns/native_flag_cursor_height.h create mode 100644 Extension/Throwdowns/native_flag_preview.h create mode 100644 Extension/Throwdowns/native_flag_session.cpp create mode 100644 Extension/Throwdowns/native_flag_session.h create mode 100644 Extension/Throwdowns/native_flag_spawn.h create mode 100644 Extension/Throwdowns/native_throwdown_lifetime.h create mode 100644 Extension/Throwdowns/session_marker_actions.h create mode 100644 Extension/Throwdowns/throwdown_kit.cpp create mode 100644 Extension/Throwdowns/throwdown_kit.h create mode 100644 Extension/Trainer/trainer_motion.h create mode 100644 Extension/UI/NativeMenu/native_flag_interaction.h create mode 100644 Extension/UI/NativeMenu/native_hud_selection.h create mode 100644 Extension/UI/NativeMenu/native_hud_values.h create mode 100644 Extension/UI/NativeMenu/native_join_context.h create mode 100644 Extension/UI/NativeMenu/native_menu_ownership.h create mode 100644 Extension/UI/NativeMenu/native_setup_roles.h create mode 100644 Extension/UI/NativeMenu/throwdown_kit_hud.inl create mode 100644 Extension/UI/NativeMenu/throwdown_map_identity.inl create mode 100644 Extension/UI/NativeMenu/throwdown_match_panel.inl create mode 100644 Extension/UI/NativeMenu/throwdown_modes_ui.inl create mode 100644 Extension/UI/Overlay/plugin_dialog.cpp create mode 100644 Extension/UI/own_ui_writes.h create mode 100644 Extension/World/guid_table_guard.cpp create mode 100644 Extension/World/guid_table_guard.h create mode 100644 Extension/World/guid_table_probe.h create mode 100644 Extension/World/world_entities.cpp create mode 100644 Extension/World/world_entities.h create mode 100644 Extension/World/world_entities_contract.h create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/gates.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/sounds/cue-checkpoint.wav create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/sounds/cue-finish.wav create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/sounds/cue-invite.wav create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/sounds/cue-missed.wav create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/sounds/race-finish.wav create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/sounds/race-gate.wav create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/sounds/race-recovery.wav create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/command.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/core.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/mode_announcements.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/mode_lobby.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/mode_participation.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/placement_camera.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/race_arrow_placement.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/race_asset_contract.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/race_editor.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/race_group_reconciliation.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/race_momentum.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/race_navigation.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/race_route.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/h/race_sound.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/hud.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/lobby.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/oneup/match.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/oneup/native.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/oneup/runtime.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/oneup/spawn.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/oneup/wire.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/placing.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/play.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/referee.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/routes.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/rules.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/rules_tests.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/skatepedia.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/skater.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/tick.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/ui.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/ui_events.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/ui_hud.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/ui_oneup.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/wire.lua create mode 100644 contrib/plugins/SmokeyDawg58-ReSkate_Throwdown/plugin/td/world.lua diff --git a/Engine/Game/Build/20260929/guid_table_lookup.h b/Engine/Game/Build/20260929/guid_table_lookup.h new file mode 100644 index 00000000..8d8bbc1b --- /dev/null +++ b/Engine/Game/Build/20260929/guid_table_lookup.h @@ -0,0 +1,20 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" +#include +// find(table, key, a, b): an open-addressed table of u16 record indices (+0x958, sized from the +// record count at +0x7c, at least 16 and 1.5 times it, rounded up to a power of two), probed from +// the key's first dword by (key & 0x3e) | 1. Each index is turned into its record's 16-byte key +// by a virtual call (+0xc0) on the source at +0x18. The probe stops only on a matching key: a key +// that is not there loops forever, the empty slots (0xffff) included. That froze the game joining +// custom-map servers with the Throwdown asset pack installed (key 53c67866-6592-9274-105c- +// eb835e72f8c9, in two hang dumps, 2026-10-17). On a match it goes on to record(table, index, a, b). +namespace dingosdk::game::build::v20260929::guid_table_lookup { +inline constexpr Fingerprint find{0x41d3a50, { + 0x40,0x53,0x55,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x83,0xec, + 0x38,0x44,0x8b,0x59,0x7c,0xb8,0x10,0x00,0x00,0x00,0x41,0xd1,0xeb,0x4d,0x8b,0xe8}}; +inline constexpr Fingerprint record_contract{0x41c7df0, { + 0x48,0x89,0x5c,0x24,0x20,0x4c,0x89,0x44,0x24,0x18,0x66,0x89,0x54,0x24,0x10,0x55,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x30,0x48,0x8b}}; +inline constexpr std::uintptr_t record = record_contract.rva; +inline constexpr std::uintptr_t count = 0x7c, slots = 0x958, source = 0x18, key_of = 0xc0; +inline constexpr std::uint16_t empty = 0xffff; +} diff --git a/Engine/Game/Build/20260929/native_throwdowns.h b/Engine/Game/Build/20260929/native_throwdowns.h index 5151b2f1..84328d07 100644 --- a/Engine/Game/Build/20260929/native_throwdowns.h +++ b/Engine/Game/Build/20260929/native_throwdowns.h @@ -4,6 +4,11 @@ namespace dingosdk::game::build::v20260929::native_throwdowns { // Hooked functions. +// GetPlayerIsInActivity (6dae8d15). The typed fast thunk calls this same +// implementation, including calls inlined into the activity pause menu. +inline constexpr std::uintptr_t player_in_activity = 0x834520; +inline constexpr std::array player_in_activity_prefix{ + 0x40,0x55,0x48,0x83,0xec,0x20,0x8b,0xe9,0xba,0x89,0x97,0x0f,0xbf,0x48,0x8d,0x4c,0x24,0x38,0xe8}; // Reports whether a throwdown mode exists in the backend catalogue. inline constexpr std::uintptr_t mode_available = 0x807130; inline constexpr std::array mode_available_prefix{ diff --git a/Engine/Game/Build/20260929/no_bail.h b/Engine/Game/Build/20260929/no_bail.h index 1667c049..c8625bd8 100644 --- a/Engine/Game/Build/20260929/no_bail.h +++ b/Engine/Game/Build/20260929/no_bail.h @@ -47,7 +47,7 @@ inline constexpr Fingerprint bail_publish_contract{0x47da450, { // Native consumers: skeleton caller's request test and ABI, animation's // +87 explicit request export, and +8d independent request export. No Bail // leaves the separate native recover/runout output at +9e alone. -inline constexpr std::array bail_consumer_contracts{{ +inline constexpr std::array bail_consumer_contracts{{ {0x47da40b, { 0x48,0x8b,0x8b,0xc0,0x03,0x00,0x00,0xf7,0x81,0xd4,0x13,0x00,0x00,0x00,0x00,0x00, 0x08,0x74,0x05,0x41,0xb0,0x01,0xeb,0x03,0x45,0x32,0xc0,0x48,0x8b,0x83,0xc0,0x03}}, @@ -60,6 +60,11 @@ inline constexpr std::array bail_consumer_contracts{{ {0x47da965, { 0x48,0x8b,0x87,0xc0,0x03,0x00,0x00,0xf7,0x80,0xd4,0x13,0x00,0x00,0x00,0x00,0x00, 0x08,0x74,0x04,0xb0,0x01,0xeb,0x02,0x32,0xc0,0x88,0x83,0x8d,0x00,0x00,0x00,0x48}}, + // The independent failed-landing/runout predicate is published to the + // trick state's animation output (+38) at +9e, including non-ragdoll saves. + {0x47da984, { + 0x48,0x8b,0x87,0xb8,0x03,0x00,0x00,0x48,0x8b,0x48,0x38,0x40,0x88,0xb1,0x9e,0x00, + 0x00,0x00,0x48,0x8b,0x87,0xb8,0x03,0x00,0x00,0x48,0x8b,0x97,0xc0,0x03,0x00,0x00}}, }}; // The sensitive body-contact report bypasses the cause collector. Native // collision processing sets +0x10fe for configured body bones hitting a solid @@ -120,4 +125,6 @@ inline constexpr std::uintptr_t bail_core_vtable = 0x6176d30; // Native recovery/stumble predicate. Its result is also exported to animation // at +0x9e, so No Bail never forces it. Reference only. inline constexpr std::uintptr_t recovery_predicate = 0x47d1100; +inline constexpr std::uintptr_t recovery_animation_output = 0x38; // core+3b8 -> trick state +inline constexpr std::uintptr_t recovery_animation_flag = 0x9e; // animation output, byte } diff --git a/Engine/Game/Build/20260929/skater_entities.h b/Engine/Game/Build/20260929/skater_entities.h index 630a2ad1..1843ed74 100644 --- a/Engine/Game/Build/20260929/skater_entities.h +++ b/Engine/Game/Build/20260929/skater_entities.h @@ -7,6 +7,9 @@ namespace dingosdk::game::build::v20260929::skater_entities { // Blueprint asset TypeInfo. Named character blueprints derive from it. inline constexpr std::uintptr_t blueprint_type = 0x7586ce8; +// ObjectBlueprint ClassInfo: a world object's blueprint (props, checkpoints), a different branch +// of the type hierarchy from the skater Blueprint above. +inline constexpr std::uintptr_t object_blueprint_type = 0x75043a8; // Secondary vtables of the skater animation component (engine::skater_component_vtable) at +0x38 and +0x40. inline constexpr std::array skater_component_base_vtables{0x61705e8, 0x6170650}; // ClientSkaterSource::applyAppearance. It enables the customization component and requests @@ -42,6 +45,12 @@ inline constexpr std::array release_reference_prefix{ inline constexpr std::uintptr_t place_entity = 0x53ed70; inline constexpr std::array place_entity_prefix{ 0x41,0xb1,0x01,0x45,0x0f,0xb6,0xc1,0xe9,0x44,0xfe,0xa3,0x02}; +// Generic SpatialEntity setter used by place_entity. Copies both matrix halves +// into the owned transform collection, then notifies components via +0x108. +inline constexpr std::uintptr_t move_spatial_entity = 0x2f7ebc0; +inline constexpr std::array move_spatial_entity_prefix{ + 0x48,0x83,0xec,0x48,0x4c,0x8b,0x59,0x70,0xc5,0xfc,0x10,0x02, + 0xc5,0xfc,0x10,0x4a,0x20,0x41,0x0f,0xb6,0x53,0x0a}; inline constexpr std::uintptr_t initialize_placement = 0x2f7d670; inline constexpr std::array initialize_placement_prefix{ 0x4c,0x8b,0xdc,0x53,0x55,0x56,0x57,0x48,0x83,0xec,0x68,0xf6,0x41,0x28,0x08}; diff --git a/Engine/Game/Input/owned_menu_input.h b/Engine/Game/Input/owned_menu_input.h new file mode 100644 index 00000000..25f5e40f --- /dev/null +++ b/Engine/Game/Input/owned_menu_input.h @@ -0,0 +1,15 @@ +#pragma once +#include +namespace dingosdk::game { +// Keep an opening press hidden until its physical release. Otherwise handing +// input to the newly opened native page replays that same Start and closes it. +constexpr bool owned_menu_release_latch(bool latched,bool capturing,bool down) noexcept { + return down && (capturing || latched); +} +constexpr std::uint16_t owned_menu_buttons(std::uint16_t buttons,bool suppress) noexcept { + return suppress?static_cast(buttons & ~0x0010u):buttons; +} +constexpr bool owned_menu_countdown_capture(bool countdown,bool native_panel) noexcept { + return countdown && !native_panel; +} +} diff --git a/Engine/Game/Multiplayer/session_competition.h b/Engine/Game/Multiplayer/session_competition.h new file mode 100644 index 00000000..7e848587 --- /dev/null +++ b/Engine/Game/Multiplayer/session_competition.h @@ -0,0 +1,14 @@ +#pragma once +#include + +namespace dingosdk { +namespace session_competition_detail { inline std::atomic stock{}; } +// Local participation, including hosts/admins, overrides session permissions. +// Their settings are retained for after Leave. +inline bool competition_stock_physics() noexcept { + return session_competition_detail::stock.load(std::memory_order_acquire); +} +inline void set_competition_stock_physics(bool locked) noexcept { + session_competition_detail::stock.store(locked,std::memory_order_release); +} +} diff --git a/Engine/Game/Multiplayer/session_physics.h b/Engine/Game/Multiplayer/session_physics.h index 18eb4cde..2fad7695 100644 --- a/Engine/Game/Multiplayer/session_physics.h +++ b/Engine/Game/Multiplayer/session_physics.h @@ -9,6 +9,7 @@ #include #include #include +#include "session_competition.h" namespace dingosdk { // Physics a player changes outside Gameplay/SkatePhysicsTuning: the trainer's tuning-class @@ -44,7 +45,7 @@ inline bool take(Box &box, std::uint64_t &seen, std::vector &bytes // physics (the roster's enforce_tuning): their own physics edits stand down, and what the host // shares is theirs. False outside a session and for a host. Set by the session every tick. inline bool session_tuning_enforced() noexcept { - return session_physics_detail::enforced.load(std::memory_order_acquire); + return competition_stock_physics() || session_physics_detail::enforced.load(std::memory_order_acquire); } inline void set_session_tuning_enforced(bool enforced) noexcept { session_physics_detail::enforced.store(enforced, std::memory_order_release); diff --git a/Engine/Game/Multiplayer/session_tools.h b/Engine/Game/Multiplayer/session_tools.h index 3363ff62..eba8fa47 100644 --- a/Engine/Game/Multiplayer/session_tools.h +++ b/Engine/Game/Multiplayer/session_tools.h @@ -1,5 +1,6 @@ #pragma once #include +#include "session_competition.h" namespace dingosdk { // Noclip (and teleporting), No Bail and the forward / up boosts as the session's host lets @@ -9,9 +10,9 @@ namespace session_tools_detail { inline std::atomic noclip{true}, no_bail{true}, boosts{true}; inline std::atomic session{}; } -inline bool session_noclip_allowed() noexcept { return session_tools_detail::noclip.load(std::memory_order_acquire); } -inline bool session_no_bail_allowed() noexcept { return session_tools_detail::no_bail.load(std::memory_order_acquire); } -inline bool session_boosts_allowed() noexcept { return session_tools_detail::boosts.load(std::memory_order_acquire); } +inline bool session_noclip_allowed() noexcept { return !competition_stock_physics() && session_tools_detail::noclip.load(std::memory_order_acquire); } +inline bool session_no_bail_allowed() noexcept { return !competition_stock_physics() && session_tools_detail::no_bail.load(std::memory_order_acquire); } +inline bool session_boosts_allowed() noexcept { return !competition_stock_physics() && session_tools_detail::boosts.load(std::memory_order_acquire); } // 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 { diff --git a/Engine/Game/World/load_completion.h b/Engine/Game/World/load_completion.h new file mode 100644 index 00000000..11294820 --- /dev/null +++ b/Engine/Game/World/load_completion.h @@ -0,0 +1,13 @@ +#pragma once +namespace dingosdk { +// Some stock worlds do not appear in the supplemental sublevel registries. +// An exact destination plus an initialized, controllable local world is an +// independent completion signal. Merely reaching state 13 (the splash also +// uses it), or retaining an actor from a different map, is insufficient. +inline constexpr bool playable_load_complete(bool transitioned, unsigned state, + bool exact_destination, bool context_ready, bool players_available, + unsigned local_players, unsigned controllables) noexcept { + return transitioned && (state == 13 || state == 21) && exact_destination && + context_ready && players_available && local_players == 1 && controllables == 1; +} +} diff --git a/Extension/Customization/local_player_card_runtime.cpp b/Extension/Customization/local_player_card_runtime.cpp index 56a7ac8d..9c22ad29 100644 --- a/Extension/Customization/local_player_card_runtime.cpp +++ b/Extension/Customization/local_player_card_runtime.cpp @@ -6,6 +6,8 @@ #include "Engine/Game/Build/20260929/engine.h" #include "Engine/Game/Build/20260929/local_player_card.h" #include "Extension/Throwdowns/native_throwdowns.h" +#include "Extension/Throwdowns/native_flag_session.h" +#include "Extension/Throwdowns/throwdown_kit.h" #include "Extension/Multiplayer/Steam/steam_social.h" namespace dingosdk::profile_runtime { @@ -211,8 +213,11 @@ bool publish_player_card(const profile::CosmeticLoadout& value) { // In-throwdown / throwdown-host flags. The throwdown Quit action does // nothing unless the first is set; the second is only for the local // player's own drop (a joined one is left, not cancelled). - const bool throwdown = multiplayer::local_throwdown_active(); - const bool host = multiplayer::local_throwdown_host(); + // A plugin match counts when it asks to be an activity (kit::match().in_activity); its + // host is whoever placed its flag. + const bool plugin_match = throwdowns::kit::match().in_activity; + const bool throwdown = multiplayer::local_throwdown_active() || plugin_match; + const bool host = multiplayer::local_throwdown_host() || (plugin_match && multiplayer::native_flag::hosting()); static bool logged_state{}; if (const auto field = n.field(model, handle, 11, UINT32_MAX, false)) n.publish(model, field, s.base + addr::engine::bool_type, &throwdown); diff --git a/Extension/Multiplayer/Hud/native_party.cpp b/Extension/Multiplayer/Hud/native_party.cpp index 6a175c17..fb9e73a0 100644 --- a/Extension/Multiplayer/Hud/native_party.cpp +++ b/Extension/Multiplayer/Hud/native_party.cpp @@ -512,6 +512,15 @@ std::string native_party_status() { auto &s = state(); std::lock_guard lock(s.status_mutex); return s.status + " Map party markers: " + std::to_string(s.map_count.load()) + "."; } +std::uint64_t native_party_player_info_by_id(std::uintptr_t manager, std::uint64_t id) noexcept { + try { + const auto published=state().published.load(std::memory_order_acquire); + if(!published || published->manager!=manager)return 0; + const auto record=published->record(id); + if(record && game::native_data().models.value(manager,record->handle,0,0))return record->handle; + } catch(...) {} + return 0; +} void set_native_party_map_markers(bool enabled) noexcept { state().map_markers.store(enabled, std::memory_order_release); } bool native_party_map_markers() noexcept { return state().map_markers.load(std::memory_order_acquire); } bool native_party_map_icon_visible(std::size_t slot) noexcept { diff --git a/Extension/Multiplayer/Hud/native_party.h b/Extension/Multiplayer/Hud/native_party.h index 7fbd0f17..064ad1d2 100644 --- a/Extension/Multiplayer/Hud/native_party.h +++ b/Extension/Multiplayer/Hud/native_party.h @@ -42,6 +42,8 @@ void spectate_party_member(std::uint64_t id) noexcept; bool party_spectating() noexcept; // Returns only a record owned by this adapter, validated against this manager. std::uint64_t native_party_player_info(std::uintptr_t manager, std::size_t slot) noexcept; +// Lookup by session identity for native activity leaderboard rows. +std::uint64_t native_party_player_info_by_id(std::uintptr_t manager, std::uint64_t id) noexcept; std::string native_party_status(); // Whether players can form parties: in a session, a lobby or a dedicated server. Outside one // the game's party buttons are refused. diff --git a/Extension/Multiplayer/Hud/native_party_hooks.cpp b/Extension/Multiplayer/Hud/native_party_hooks.cpp index 3c3e77a9..d47fdfa4 100644 --- a/Extension/Multiplayer/Hud/native_party_hooks.cpp +++ b/Extension/Multiplayer/Hud/native_party_hooks.cpp @@ -4,6 +4,7 @@ #include "Extension/Skater/client_source_spawn.h" #include "Extension/Throwdowns/native_type_scan.h" #include "Extension/Throwdowns/throwdown_relay.h" +#include "Extension/Throwdowns/throwdown_kit.h" #include "Engine/Core/Platform/memory.h" #include "follow_camera.h" #include "Extension/Customization/local_player_card_runtime.h" @@ -557,6 +558,14 @@ std::uint32_t teleport_hook(Address reason, const void *matrix, Address destinat // lip mount. While the local player waits for their S.K.A.T.E. turn, the turn start's // teleport of the local skater (id 0) lands it on foot. Kept in a static: the native // copies the options into its components, but must never see a dead buffer. + // A plugin match's countdown (kit::match().input_lock) lands the skater on the board. + if(!id && stance && dingosdk::throwdowns::kit::match().input_lock) { + static thread_local std::array on_board{}; + if(memory::peek_bytes(stance,on_board.data(),on_board.size())) { + on_board[0]=1; + return state().teleport(reason,matrix,destination,id,mode,reinterpret_cast
(on_board.data()),transition); + } + } if (!id && stance && throwdown_relay_waits_offboard()) { static thread_local std::array on_foot{}; if (memory::peek_bytes(stance, on_foot.data(), on_foot.size()) && on_foot[0]) { diff --git a/Extension/Multiplayer/Remote/native_skater.cpp b/Extension/Multiplayer/Remote/native_skater.cpp index 1e072604..5f298dee 100644 --- a/Extension/Multiplayer/Remote/native_skater.cpp +++ b/Extension/Multiplayer/Remote/native_skater.cpp @@ -1,6 +1,7 @@ #include "native_skater_internal.h" #include "native_pose_layout.h" #include "puppet_cost.h" +#include "Extension/World/world_entities.h" #include "Extension/Multiplayer/Session/monotonic_clock.h" #include "Extension/Multiplayer/Session/peer_slots.h" #include "Extension/Skater/board_pose.h" @@ -392,6 +393,7 @@ void animation_hook(std::uintptr_t component, std::uintptr_t update) { listener(component); } void destroy_hook(std::uintptr_t entity, std::uintptr_t owner) { + world_entities::entity_destroyed(entity); if (const auto listener = shared().destroyed_listener.load(std::memory_order_acquire)) listener(entity); // The first slot watching it as either skater or board. diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index a85d8136..54558cab 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -1,4 +1,7 @@ #include "session_internal.h" +#include "Extension/Throwdowns/throwdown_kit.h" +#include "Extension/Skater/skater_push.h" +#include "Extension/Throwdowns/throwdown_lab.h" #include "Engine/Core/Hooks/hooks.h" #include "Extension/Plugins/plugin_runtime.h" #include "Extension/Plugins/plugin_wire.h" @@ -358,7 +361,7 @@ void publish_party(Session &s) { // every tick, with no session too, so linked drops are removed when it ends. The input // persists: its player list is rebuilt when the admitted players change, and names // (Steam lookups) are refreshed once a second. -void relay_throwdowns(Session &s, bool in_world) { +void relay_throwdowns(Session &s, bool in_world, const NativeFrame* /*offline_skater*/ = nullptr) { auto &input = s.throwdown_input; const bool in_session = s.mode == Mode::host || s.mode == Mode::join; const auto local = in_session ? s.transport.status().local_id : 0; @@ -406,11 +409,17 @@ void relay_throwdowns(Session &s, bool in_world) { d2 = (other->position[0] - me->position[0]) * (other->position[0] - me->position[0]) + (other->position[1] - me->position[1]) * (other->position[1] - me->position[1]) + (other->position[2] - me->position[2]) * (other->position[2] - me->position[2]); + peer.position = other ? std::optional(other->position) : std::nullopt; peer.nearby = d2 < 60.f * 60.f; peer.party = party_member(s, peer.id); } - if (names) + if (names) { input.local_name = in_session ? s.transport.name(local) : std::string(steam_social_snapshot()->local.name); + if(!in_session) { + const auto card=local_profile_player_card(); + if(!card.custom_name.empty())input.local_name=card.custom_name; + } + } // Plugin messages ride in throwdown packets too, told apart by their first byte // (Extension/Plugins/plugin_wire.h); they go to the plugins, never to the relay. for (const auto &[sender, message] : s.throwdown_inbox) { @@ -619,8 +628,9 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui update_developer_board(s.base, remote_board_entity(), developer_id(p), remote_skater_generation(), p.developer_board, mark_styles(p)); present_audio(p); - update_party_position(&p.render_pose); - if (labels) label(p); + update_party_position(throwdowns::kit::hidden(p.member.id)?nullptr:&p.render_pose); + if(throwdowns::kit::hidden(p.member.id)) {update_player_ui(s.base,nullptr,{});p.ui_visible=false;} + if (labels && !throwdowns::kit::hidden(p.member.id)) label(p); return; } p.visible = false; @@ -645,7 +655,7 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui native_pass = true; p.applied_cosmetics = 0; // the new actor wears no recipe yet } - const bool hidden = throwdown_relay_hides(p.member.id); + const bool hidden = throwdown_relay_hides(p.member.id) || throwdowns::kit::hidden(p.member.id); const auto spot = hidden ? std::nullopt : throwdown_relay_celebration_offset(p.member.id, p.render_pose.root.position); p.visible = show_remote(s.base, client, local, @@ -680,9 +690,11 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui apply_cosmetics(p, spawning); // The spectate camera follows this every frame; the map and // indicators below only need network-rate updates. - update_party_position(&p.render_pose); - if (labels) label(p); - if (!p.ui_visible || now >= p.next_ui_update) { + update_party_position(throwdowns::kit::hidden(p.member.id)?nullptr:&p.render_pose); + if (labels && !throwdowns::kit::hidden(p.member.id)) label(p); + if(throwdowns::kit::hidden(p.member.id)) { + update_player_ui(s.base,nullptr,{});p.ui_visible=false; + } else if (!p.ui_visible || now >= p.next_ui_update) { update_player_ui(s.base, &p.render_pose, p.member.name); p.ui_visible = true; p.next_ui_update = now + network_tick_us; @@ -970,9 +982,19 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready, std::string_vi erase_password(s.lobby_password); expire_remote_collision(base, now_us()); if (s.mode == Mode::off) { - physics_tuning::release(base); // the player's own tuning again after a session + if (!competition_stock_physics()) physics_tuning::release(base); set_session_tuning_enforced(false); - relay_throwdowns(s, ready); + NativeFrame solo; + if(ready && !map_name.empty()) { + try {solo=capture_local(base,client,false);if(solo.ready)note_local_skater(solo.pose.root);} + catch(...) {} + } + relay_throwdowns(s, ready, &solo); + if (competition_stock_physics() && solo.ready) { + physics_tuning::prepare();physics_tuning::enforce(base,solo.entity,{}); + } else if (!competition_stock_physics()) physics_tuning::release(base); + if (solo.ready && (throwdowns::kit::match().input_lock || skater_push::holding())) + prepare_on_board_teleport(client, solo.entity); // Without a session the UI model follows at the same 10 Hz as in one; // commands still publish at once. if (const auto now = now_us(); now >= s.next_publish) { @@ -1071,9 +1093,13 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready, std::string_vi networking(s, local, receive_at); for (const auto ¬e : s.transport.take_direct_notes()) logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: {}", note); - relay_throwdowns(s, world_playing(s, local)); + // A menu or respawn can suspend the pose capture while this world and + // its Throwdown queue remain loaded. Do not cancel their coordinators. + relay_throwdowns(s, s.gameplay_ready && !s.travelling && !s.awaiting_map && s.host_world_ready); // A S.K.A.T.E. player waiting for their turn stays off the board. - update_board_lock(client, local.entity, local.ready && throwdown_relay_waits_offboard()); + update_board_lock(client, local.entity, local.ready && (throwdown_relay_waits_offboard() || throwdowns::kit::match().offboard)); + if (local.ready && (throwdowns::kit::match().input_lock || skater_push::holding())) + prepare_on_board_teleport(client, local.entity); update_physics_tuning(s, local, now); const auto send_at = now_us(); s.client_timing.record(ClientTiming::network, receive_at, send_at); diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index 2ae2f3f9..b3a535bb 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -430,6 +430,10 @@ std::string edit_player_distance(Session &s, std::string_view argument) { : "Players within " + std::to_string(static_cast(value)) + " m are shown as skaters."; } std::string start_pose_dump(Session &s, std::string_view argument) { +#if defined(DINGOSDK_LEAN_PLAYTEST) + (void)s; (void)argument; + return "Pose recording is not included in this playtest build."; +#else int seconds{}; const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), seconds); if (parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || seconds < 1 || seconds > 600) @@ -446,6 +450,7 @@ std::string start_pose_dump(Session &s, std::string_view argument) { s.pose_dump_until = now_us() + static_cast(seconds) * 1000000; s.pose_dump_count = 0; return "Recording your poses for " + std::to_string(seconds) + " s to " + path_utf8(file) + ". Skate as you normally would."; +#endif } std::string edit_direct_connections(Session &s, std::string_view argument) { const auto enabled = parse_switch(argument, s.prefer_direct); diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index 4c2833cb..5ea156d1 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -575,6 +575,11 @@ void update_physics_tuning(Session &s, const NativeFrame &local, std::uint64_t n set_session_tuning_enforced(enforced); if (enforced && s.host_extras && !dedicated_host(s)) set_host_physics_extras(*s.host_extras); else set_host_physics_extras({}); + if (competition_stock_physics()) { + physics_tuning::prepare(); + if (world_playing(s,local)) physics_tuning::enforce(s.base,local.entity,{}); + return; + } if (s.mode == Mode::host) { physics_tuning::release(s.base); if (!s.enforce_tuning) { diff --git a/Extension/Multiplayer/Steam/steam_social.cpp b/Extension/Multiplayer/Steam/steam_social.cpp index 04286ddc..99d310bc 100644 --- a/Extension/Multiplayer/Steam/steam_social.cpp +++ b/Extension/Multiplayer/Steam/steam_social.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -24,6 +25,35 @@ std::string name(const char *value) { if (static_cast(value[i]) >= 32 && value[i] != 127) result += value[i]; return result; } +// Offline still has a Steam account identity. Resolve that account's cached +// persona, never a different account's login name or a hard-coded player name. +std::string offline_persona_name() { + const auto incoming_error=GetLastError(); + struct RestoreError { DWORD value; ~RestoreError(){SetLastError(value);} } restore{incoming_error}; + try { + wchar_t path[32768]{}; DWORD bytes=sizeof(path); + if(RegGetValueW(HKEY_CURRENT_USER,L"Software\\Valve\\Steam",L"SteamPath",RRF_RT_REG_SZ,nullptr,path,&bytes)!=ERROR_SUCCESS)return {}; + std::ifstream file(std::filesystem::path(path)/L"config"/L"loginusers.vdf"); + const auto wanted=std::to_string(launcher::offline_steam_id()); + bool account{}; + for(std::string line;std::getline(file,line);) { + std::vector tokens; + for(std::size_t i=0;i steam_social_snapshot() { next = now + 10000; SteamSocialSnapshot result; if (launcher::offline_mode()) { - result.local = {launcher::offline_steam_id(), launcher::offline_player_name, false, true}; + auto persona=offline_persona_name(); + if(persona.empty() && current->local.id==launcher::offline_steam_id() && current->local.name!=launcher::offline_player_name) + persona=current->local.name; + result.local = {launcher::offline_steam_id(), persona.empty()?launcher::offline_player_name:persona, false, true}; if (result.local != current->local) { result.revision = current->revision + 1; current = std::make_shared(std::move(result)); diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index f524b548..a315fd0e 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -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; diff --git a/Extension/Multiplayer/console_commands.cpp b/Extension/Multiplayer/console_commands.cpp index 96c7e4f0..405415d6 100644 --- a/Extension/Multiplayer/console_commands.cpp +++ b/Extension/Multiplayer/console_commands.cpp @@ -9,6 +9,7 @@ #include "Extension/Throwdowns/throwdown_relay.h" #include "Extension/UI/NativeMenu/native_menu_dump.h" #include "Extension/Assets/live_mods.h" +#include "Engine/Game/Multiplayer/session_tools.h" #include #include @@ -222,10 +223,11 @@ void register_multiplayer_commands(Commands ®istry) { auto lab_args = argument("verb_and_arguments", Type::text, true); lab_args.rest = true; lab_args.complete = [](const Model &, auto) { - return std::vector{"status", "ai", "spawn", "join", "start", "destroy", "mmid", "trace", "endturn", "score", "mirror", "attempt", "relay"}; + return std::vector{"status", "ai", "spawn", "join", "start", "solo", "destroy", "mmid", "trace", "endturn", "score", "mirror", "attempt", "relay"}; }; auto lab = action("throwdown", "Throwdown lab: add virtual participants, spawn/start/destroy queues (research aid)", Group::console, {std::move(lab_args)}); + lab.aliases={"dingo throwdown"}; lab.run = [](const Model &model, const Values &values, const Output &out) { std::optional> skater; if (model.debug.skater_position_valid) skater = model.debug.skater_position; diff --git a/Extension/Objects/ParkEditor/park_editor_runtime.cpp b/Extension/Objects/ParkEditor/park_editor_runtime.cpp index f76042b6..a35986a3 100644 --- a/Extension/Objects/ParkEditor/park_editor_runtime.cpp +++ b/Extension/Objects/ParkEditor/park_editor_runtime.cpp @@ -256,6 +256,47 @@ void move_existing(std::uint64_t entity, const profile::PlacedObject &object) { } e.transform_move(query.data(), pose.data(), source); } +struct WorldRays { + struct Ray { + std::array origin{}, end{}; + }; + std::mutex mutex; + std::map pending; + std::map answers; +}; +WorldRays &world_rays() { + static auto *value = new WorldRays; + return *value; +} +// Casts the queued outside rays on the client update, where the client physics context resolves. +void cast_world_rays() { + auto &w = world_rays(); + std::map rays; + { + std::lock_guard lock(w.mutex); + rays.swap(w.pending); + } + if (rays.empty()) + return; + auto &e = editor_state(); + auto &r = placements_runtime(); + std::array context{}; + if (r.context) + r.context(context.data()); + const auto world = e.surface_api.ready && context[0] ? e.surface_api.world(context[0]) : 0; + const auto now = GetTickCount64(); + for (const auto &[id, ray] : rays) { + WorldRayHit answer{true, false, {}, now}; + answer.available = world != 0; + if (world) + if (const auto hit = editor::cast_surface(e.surface_api, world, ray.origin, ray.end)) { + answer.hit = true; + answer.at = *hit; + } + std::lock_guard lock(w.mutex); + w.answers[id] = answer; + } +} } // namespace void initialize_park_editor(const std::filesystem::path &directory) { auto &e = editor_state(); @@ -289,6 +330,12 @@ void set_park_mods_root(const std::filesystem::path &data_root) { } void reset_park_editor() { auto &e = editor_state(); + { + auto &w = world_rays(); + std::lock_guard lock(w.mutex); + w.pending.clear(); + w.answers.clear(); + } ++e.generation; ++e.revision; e.transaction.reset(); @@ -634,12 +681,46 @@ void update_park_editor() { } // namespace dingosdk::profile_runtime namespace dingosdk { using namespace profile_runtime; +void queue_world_ray(std::uint32_t id, const std::array &origin, const std::array &direction, float length) { + for (int i = 0; i < 3; ++i) + if (!std::isfinite(origin[i]) || !std::isfinite(direction[i]) || std::abs(origin[i]) > 100000) + return; + const float size = std::sqrt(direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2]); + if (size < 0.5f || !(length > 0) || length > 2000) + return; + std::array end{}; + for (int i = 0; i < 3; ++i) + end[i] = origin[i] + direction[i] / size * length; + auto &w = world_rays(); + std::lock_guard lock(w.mutex); + w.pending[id] = {origin, end}; +} +WorldRayHit world_ray(std::uint32_t id) { + auto &w = world_rays(); + std::lock_guard lock(w.mutex); + const auto it = w.answers.find(id); + return it == w.answers.end() ? WorldRayHit{} : it->second; +} +void tick_local_world_rays() noexcept { + PreserveError preserve; + try { + std::lock_guard lock(local_runtime().native_mutex); + if (!local_runtime().active || cosmetic_runtime().update_thread != GetCurrentThreadId()) + return; + cast_world_rays(); + } catch (...) { + // A mode's optional aim query must not fail or disable park editing. + } +} void tick_local_park_editor() noexcept { PreserveError preserve; try { std::lock_guard lock(local_runtime().native_mutex); if (!local_runtime().active || cosmetic_runtime().update_thread != GetCurrentThreadId()) return; + // Mode placement uses the client physics world even before the park + // placement store/server manager becomes ready. + cast_world_rays(); update_placement_restore(); } catch (...) { std::lock_guard lock(local_runtime().native_mutex); diff --git a/Extension/Objects/ParkEditor/park_editor_runtime.h b/Extension/Objects/ParkEditor/park_editor_runtime.h index 6facc2c2..092313ea 100644 --- a/Extension/Objects/ParkEditor/park_editor_runtime.h +++ b/Extension/Objects/ParkEditor/park_editor_runtime.h @@ -32,6 +32,19 @@ bool queue_local_park_paste(const EditorPasteRequest &); bool queue_local_park_object_set(const EditorObjectSetRequest &); bool queue_local_park_delete(const EditorDeleteRequest &); void tick_local_park_editor() noexcept; +struct WorldRayHit { + bool cast{}, hit{}; + std::array at{}; + std::uint64_t when{}; // GetTickCount64 of the cast + bool available{}; // Native client collision API and world were ready. +}; +void queue_world_ray(std::uint32_t id, const std::array &origin, const std::array &direction, float length); +WorldRayHit world_ray(std::uint32_t id); +// Client collision queries used by mode placement do not require the park +// editor, its placement store, or the server object manager to be enabled. +// Runs only on the verified local client update thread. +void tick_local_world_rays() noexcept; + // Invoked through the console's verified game-thread dispatcher. `texts` // carries a park mod's name, author, version and description (mod-create: // all four; mod-details: the same after the folder in `argument`). diff --git a/Extension/Objects/local_placements_runtime.cpp b/Extension/Objects/local_placements_runtime.cpp index 7fd21167..2935db76 100644 --- a/Extension/Objects/local_placements_runtime.cpp +++ b/Extension/Objects/local_placements_runtime.cpp @@ -630,18 +630,18 @@ void update_position_teleport() { if (!r.teleport || !r.transition_ctor || !r.transition_destroy || !placement_client_channel() || !read(local_runtime().base + addr::local_placements::teleport_manager, manager) || !manager || !read(manager, state) || state != 0) return; - const auto at = *r.position_teleport; - const auto heading = r.position_teleport_heading; // set only by teleport_local_skater_heading + alignas(16) const auto transform = *r.position_teleport; + const std::array at{transform[12],transform[13],transform[14]}; r.position_teleport.reset(); - r.position_teleport_heading.reset(); - // Upright at the spot. The native teleport manager handles streaming, ground checks and physics reset. - // With a heading h the root's rows are right (cos h, 0, -sin h), up, forward (sin h, 0, cos h): - // h = 0 is the identity, facing +Z (measured: the skater faces h within 0.002 degrees). - const float sh = heading ? std::sin(*heading) : 0.0f, ch = heading ? std::cos(*heading) : 1.0f; - alignas(16) const std::array transform{ch,0,-sh,0, 0,1,0,0, sh,0,ch,0, at[0], at[1], at[2], 1}; + // Preserve the native flag's complete facing basis. The teleport manager + // still handles streaming, ground checks and physics reset. alignas(16) std::array transition{}; r.transition_ctor(transition.data()); struct Destroy { decltype(r.transition_destroy) call; void* value; ~Destroy() { call(value); } } destroy{r.transition_destroy, transition.data()}; + // The local teleport manager works in offline play. RequestTeleport's + // social/player lookup can reject the same local skater with UINT32_MAX. + // The session tick prepares the validated skater's native board option + // before this queued move, so this path also starts a plugin's match on the board. const auto request = r.teleport(manager, transform.data(), 1, transition.data()); dingosdk::logging::event(dingosdk::logging::Channel::objects, dingosdk::Json{{"event", "position_teleport_requested"}, {"x", at[0]}, {"y", at[1]}, {"z", at[2]}, {"request", request}}.dump().c_str()); diff --git a/Extension/Objects/local_placements_runtime.h b/Extension/Objects/local_placements_runtime.h index 197c8faa..79820b94 100644 --- a/Extension/Objects/local_placements_runtime.h +++ b/Extension/Objects/local_placements_runtime.h @@ -76,11 +76,10 @@ struct PlacementsRuntime { std::vector rows; std::uint64_t next_token{1}, teleport_token{}, teleport_queued_at{}; // A teleport of the local skater to a position (console tp, the host's tpall). - std::optional> position_teleport; + std::optional> position_teleport; std::uint64_t position_teleport_at{}; // The heading the pending position teleport faces (teleport_local_skater_heading), radians about // world up; unset means the identity rotation the other teleports use (facing +Z). - std::optional position_teleport_heading; std::optional inflight; std::uint64_t sent_at{}, next_poll{}, map_since{}, map_generation{}, next_id{1}; bool failed{}, restore_failed{}; diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 91b73778..1df13956 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -4,6 +4,9 @@ #include "Extension/Plugins/plugin_host.h" #include "Extension/Plugins/plugin_manifest.h" #include "Extension/Plugins/plugin_wire.h" +#include "Extension/Throwdowns/challenge_registry.h" +#include "Extension/Throwdowns/custom_modes.h" +#include "Extension/Throwdowns/throwdown_kit.h" #include "Engine/Vfs/mod_list.h" #include #include @@ -58,6 +61,30 @@ struct FakeGame final : Game { } std::string board_hide(std::string_view plugin, int bone) override { calls.push_back(std::format("board hide {} {}", plugin, bone)); return {}; } void board_park(std::string_view plugin, std::vector bones) override { calls.push_back(std::format("board park {} {}", plugin, bones.size())); } + // The general services a game layer in Lua reads and drives (plugin_api_services.cpp). + std::optional telemetry; + std::optional skater_telemetry() override { return telemetry; } + std::uint64_t world_id() override { return 77; } + std::string world_map() override { return "test_map"; } + std::string pad_flight(std::string_view plugin, bool on) override { + calls.push_back(std::format("pad flight {} {}", plugin, on)); + return {}; + } + void competition(std::string_view plugin, bool on) override { calls.push_back(std::format("competition {} {}", plugin, on)); } + bool stock_physics_ready() override { return true; } + void prepare_physics() override { calls.push_back("prepare physics"); } + void trick_feed(std::string_view plugin, bool on) override { calls.push_back(std::format("trick feed {} {}", plugin, on)); } + std::vector> trick_gesture(std::string_view trick) override { + if (trick == "Kickflip") return {{0, 0}, {0.5f, 1}}; + return {}; + } + std::string bone_cam(std::string_view plugin, const std::vector& words) override { + std::string line = "bone cam " + std::string(plugin); + for (const auto& w : words) line += " " + w; + calls.push_back(line); + if (words.empty()) return "game"; + return words.front() == "ringing" ? "1" : ""; + } std::string board_toss(std::string_view plugin, std::vector bones, const Vec3& velocity, const Vec3&, float ground) override { calls.push_back(std::format("board toss {} {} {:.0f} {:.0f}", plugin, bones.size(), velocity[1], ground)); return {}; @@ -102,6 +129,24 @@ struct FakeGame final : Game { calls.push_back(std::format("{} {} {} {:.1f}", op, o.id, o.item, o.position[0])); return {}; } + // World objects as the fake keeps them: by plugin and id, ready at once unless hidden. + std::map, std::pair>> entities; + std::string entity_set(std::string_view plugin, std::uint32_t id, std::string_view asset, const std::array& m, bool hidden) override { + if (entities.size() >= 600) return "too many"; + entities[{std::string(plugin), id}] = {std::string(asset) + (hidden ? " hidden" : ""), m}; + calls.push_back(std::format("entity {} {} {} {:.2f} {:.2f} {:.2f} {:.2f}{}", plugin, id, asset, m[12], m[13], m[14], m[8], hidden ? " hidden" : "")); + return {}; + } + void entity_remove(std::string_view plugin, std::uint32_t id) override { entities.erase({std::string(plugin), id}); } + void entity_clear(std::string_view plugin) override { + std::erase_if(entities, [&](const auto& e) { return e.first.first == plugin; }); + calls.push_back("entities clear " + std::string(plugin)); + } + int entity_status(std::string_view plugin, std::uint32_t id) override { + const auto found = entities.find({std::string(plugin), id}); + return found == entities.end() ? 0 : found->second.first.ends_with(" hidden") ? 5 : 4; + } + bool entities_ready(std::string_view) override { return true; } std::string set_environment(std::string_view kind, std::string_view key, std::string_view value) override { calls.push_back(std::format("env {} {} {}", kind, key, value)); return key == "never" ? "refused" : ""; @@ -181,16 +226,85 @@ struct FakeGame final : Game { } std::string throwdown_status(std::string_view) override { return "open"; } std::vector modes; - std::vector mode_news; std::string set_throwdown_mode(std::string_view plugin, const ThrowdownMode& mode) override { - calls.push_back(std::format("mode {} {} {} rows {}{}", plugin, mode.id, mode.name, mode.rows.size(), mode.timer ? " timer" : "")); + calls.push_back(std::format("mode {} {} {}", plugin, mode.id, mode.name)); std::erase_if(modes, [&](const auto& m) { return m.id == mode.id; }); modes.push_back(mode); return {}; } void remove_throwdown_mode(std::string_view plugin, std::string_view id) override { calls.push_back(std::format("remove mode {} {}", plugin, id)); } - void release_throwdown_flag(std::string_view plugin) override { calls.push_back(std::format("release flag {}", plugin)); } - std::vector take_throwdown_news(std::string_view) override { return std::exchange(mode_news, {}); } + // The throwdown kit: documents go to the real store (so the tests read them parsed); the flag is + // the fake's own (kit::flag is the game's). + int kit_documents{}; + std::string publish_throwdown_ui(std::string_view plugin, const dingosdk::Json& document) override { + ++kit_documents; + return dingosdk::throwdowns::kit::publish(plugin, document); + } + dingosdk::throwdowns::kit::flag::State flag; + bool throwdown_flag_begin(std::string_view mode, std::uint32_t game, const dingosdk::throwdowns::kit::flag::Options& o) override { + calls.push_back(std::format("flag begin {} {} {} {} {} {:.1f}", mode, game, o.players, o.seconds, o.setup_page, o.cursor_height)); + if (flag.busy) return false; + flag = {true, false, true, true, std::string(mode), game}; + return true; + } + dingosdk::throwdowns::kit::flag::State throwdown_flag_state() override { return flag; } + void throwdown_flag_leave(std::string_view plugin) override { + calls.push_back(std::format("flag leave {}", plugin)); + if (flag.mode.starts_with(std::string(plugin) + "/")) flag = {}; + } + // A plugin's data folder, as a map by "/". + std::map files; + std::string files_list(std::string_view plugin, std::string_view folder, std::vector& names) override { + const auto prefix = std::string(plugin) + "/" + (folder.empty() ? "" : std::string(folder) + "/"); + for (const auto& [path, text] : files) { + if (!path.starts_with(prefix)) continue; + auto rest = path.substr(prefix.size()); + if (const auto slash = rest.find('/'); slash != std::string::npos) rest = rest.substr(0, slash + 1); + if (std::find(names.begin(), names.end(), rest) == names.end()) names.push_back(rest); + } + return {}; + } + std::string files_read(std::string_view plugin, std::string_view path, std::string& text) override { + const auto found = files.find(std::string(plugin) + "/" + std::string(path)); + if (found == files.end()) return "not found"; + text = found->second; + return {}; + } + std::string files_write(std::string_view plugin, std::string_view path, std::string_view text) override { + files[std::string(plugin) + "/" + std::string(path)] = std::string(text); + return {}; + } + std::string files_remove(std::string_view plugin, std::string_view path) override { + return files.erase(std::string(plugin) + "/" + std::string(path)) ? std::string{} : std::string("not found"); + } + std::optional dialog; + std::string dialog_open(std::string_view plugin, const DialogSpec& spec) override { + calls.push_back(std::format("dialog {} {} rows {} buttons {}", plugin, spec.id, spec.rows.size(), spec.buttons.size())); + dialog = spec; + return {}; + } + // SmokeyDawg58's editor reads the pad and the window's focus. + std::optional pad_bits_now; + InputFocus focus_now{}; + std::optional pad_bits(bool) override { return pad_bits_now; } + InputFocus input_focus() override { return focus_now; } + void dialog_close(std::string_view plugin, std::string_view id) override { calls.push_back(std::format("dialog close {} {}", plugin, id)); } + std::string skater_push(std::string_view plugin, const Vec3& v) override { + calls.push_back(std::format("push {} {:.0f} {:.0f} {:.0f}", plugin, v[0], v[1], v[2])); + return {}; + } + std::string skater_push_wait(std::string_view plugin) override { + calls.push_back(std::format("push wait {}", plugin)); + return {}; + } + std::string skater_push_reset(std::string_view plugin) override { + calls.push_back(std::format("push reset {}", plugin)); + return {}; + } + std::string skater_hold(std::string_view plugin, bool on) override { + calls.push_back(std::format("hold {} {}", plugin, on)); + return {}; + } std::string kick(std::uint64_t player, std::string_view why) override { calls.push_back(std::format("kick {} {}", player, why)); return {}; } std::string announce(std::string_view plugin, std::string_view text) override { calls.push_back(std::format("announce {} {}", plugin, text)); @@ -217,7 +331,7 @@ struct FakeGame final : Game { return {}; } std::vector> take_presses() override { return std::exchange(presses, {}); } - void release(std::string_view plugin) override { released.emplace_back(plugin); } + void release(std::string_view plugin) override { released.emplace_back(plugin); entity_clear(plugin); } bool called(std::string_view part) const { return std::any_of(calls.begin(), calls.end(), [&](const auto& c) { return c.find(part) != std::string::npos; }); } @@ -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{"rounds"}, + "Its setup page, its mode the SDK's id"); + check(setup.rows.size() == 2 && setup.rows[0].kind == "choice" && setup.rows[0].selected == 1 && setup.rows[1].kind == "button" && + setup.rows[1].label == "Set optional area", + "Its setup rows, a choice counted from 0"); + check(documents->waiting && documents->waiting->mode == "kit/lap" && documents->waiting->can_start && documents->waiting->capacity == 6, "Its waiting card"); + const auto& hud = documents->hud; + check(hud.active && hud.theme == "lap" && hud.counters == std::vector{"LEADER 1,200"} && hud.clock.present && hud.clock.remaining_ms == 60000, + "Its HUD"); + check(hud.board.present && hud.board.rows.size() == 1 && hud.board.rows[0].rank == "1" && hud.board.rows[0].value == "1200" && hud.board.rows[0].self && + hud.board.rows[0].marks.size() == 1 && hud.board.rows[0].marks[0].on && hud.board.columns.size() == 3, + "Its board, numbers as labels"); + check(hud.timers.size() == 1 && hud.timers[0].clock.present && hud.timers[0].clock.up && hud.timers[0].clock.remaining_ms == 1500 && + hud.timers[0].clock.total_ms == 600000 && hud.markers.size() == 1 && hud.markers[0].at[2] == 3, + "A stopwatch timer and a marker"); + check(documents->panel.present && documents->panel.prompts.size() == 1 && documents->panel.prompts[0].glyph == "area" && + documents->panel.players.size() == 1 && documents->panel.players[0].id == 76561198000000001ULL, + "Its match panel"); + check(documents->flags.size() == 1 && documents->flags[0].mode == "kit/lap" && documents->flags[0].leader == 76561198000000001ULL && + documents->flags[0].game == 7 && documents->flags[0].at[0] == 1 && documents->flags[0].stats.size() == 1, + "Its flags"); + check(kit::match().teleports && kit::hidden(76561198000000002ULL) && !kit::hidden(76561198000000001ULL) && kit::active() && kit::active()->plugin == "kit", + "What its match asks of the game"); + check(game.files.contains("kit/routes/a.json") && !game.files.contains("kit/old.json"), "Its files are in its own folder"); + check(game.called("dialog kit save rows 1 buttons 2") && game.dialog && game.dialog->field.text == "My route" && game.dialog->file.filter == "*.json" && + game.dialog->buttons[1].id == "cancel" && game.dialog->rows[0].buttons[0] == "rename", + "Its dialog"); + check(game.called("push kit 1 0 2") && game.called("push wait kit") && game.called("push reset kit") && game.called("hold kit true"), + "Push, its wait and reset, and hold reach the game"); + check(game.called("competition kit true") && game.called("prepare physics") && game.called("trick feed kit true") && + game.called("pad flight kit true") && game.called("bone cam kit game on"), + "The general services reach the game\n" + environment.all()); + // The native UI: a card clicked, a dialog answered. + kit::post("kit/lap", "open", {"7"}); + kit::post_to("kit", "dialog", "", {"save", "Save", "My route", "0", ""}); + kit::post("other/lap", "open", {"8"}); + ticks(host, environment, 1); + check(environment.logged("[kit] ui open lap 7") && game.called("flag begin kit/lap 7 4 90 true 0.0"), + "A card clicked reaches the plugin by its own id, which takes the flag\n" + environment.all()); + check(environment.logged("[kit] ui dialog save,Save,My route,0,"), "A dialog's answer reaches it\n" + environment.all()); + check(!environment.logged("ui open lap 8") && kit::take("other").size() == 1, "Another plugin's events stay its own"); + kit::post("kit/lap", "leave"); + ticks(host, environment, 1); + check(game.called("flag leave kit") && !game.flag.busy, "Leave lets the flag go"); + host.switch_off("kit"); + check(!kit::documents_for("kit/lap") && !kit::match().teleports && !kit::hidden(76561198000000002ULL), "Its documents go when it stops"); +} + +// The Throwdowns page's cards: the stock three (one replaced) and the plugin modes by order. +void the_throwdowns_page_lists_the_stock_cards_and_plugin_modes() { + namespace custom = dingosdk::multiplayer::custom_throwdowns; + using dingosdk::throwdowns::Adapter; + custom::Mode skate; + skate.plugin = "p"; + skate.id = "skate"; + skate.name = "Letters"; + skate.card = "skate"; + skate.replaces = "skate"; + custom::Mode late = skate; + late.id = "late"; + late.name = "Late"; + late.card = "jam"; + late.replaces.clear(); + late.order = 20; + late.color = 0xff00ff00U; + custom::Mode early = late; + early.id = "early"; + early.name = "Early"; + early.card = "spot"; + early.order = 10; + early.color = 0; + check(custom::set_mode(skate).empty() && custom::set_mode(late).empty() && custom::set_mode(early).empty(), "The modes register"); + check(!custom::set_mode([&] { auto bad = early; bad.card = "bowling"; return bad; }()).empty(), "A card is a stock one"); + const auto& registry = dingosdk::throwdowns::registry(); + std::vector ids; + for (const auto& d : registry.definitions()) ids.push_back(d.id); + check(ids == std::vector{"p/skate", "native.spot", "native.jam", "p/early", "p/late"}, "In the grid's order: " + [&] { + std::string all; + for (const auto& id : ids) all += id + " "; + return all; + }()); + const auto* late_card = registry.find("p/late"); + check(late_card && late_card->adapter == Adapter::plugin && late_card->donor == 2 && late_card->palette.primary == 0xff00ff00U && late_card->plugin == "p", + "A plugin card: its donor and colour"); + check(registry.find("p/early")->palette.primary == dingosdk::throwdowns::palette_for("p/early").primary, "a colour picked from its id"); + check(registry.find("native.spot")->donor == 1 && registry.find("native.jam")->adapter == Adapter::native_jam, "The stock cards"); + const auto before = registry.revision(); + custom::remove_plugin("p"); + const auto& again = dingosdk::throwdowns::registry(); + check(again.revision() > before && again.definitions().size() == 3 && again.definitions()[0].id == "native.skate", "S.K.A.T.E. is back when it goes"); +} + +void smokey_throwdowns_register_and_play_one_up() { + namespace kit = dingosdk::throwdowns::kit; + TestEnvironment environment; + auto& game = environment.fake; + const std::string plugin = "SmokeyDawg58-ReSkate_Throwdown"; + Host host(environment, Side::client); + host.apply({contributed(plugin)}); + running(host, plugin, environment); + // His challenge registry on the Throwdowns page: R.E.S.K.A.T.E. in S.K.A.T.E.'s place, 1-UP and his + // game modes after the stock cards, each with his donor card, colour, order and flag prefab. + check(game.modes.size() == 10, "His ten modes are on the Throwdowns page\n" + environment.all()); + check(game.modes.front().id == "skate" && game.modes.front().replaces == "skate" && game.modes.front().card == "skate", + "R.E.S.K.A.T.E. takes S.K.A.T.E.'s place"); + const auto registered = [&](const std::string& id) -> const ThrowdownMode& { + for (const auto& m : game.modes) if (m.id == id) return m; + throw std::runtime_error("no mode " + id); + }; + check(registered("oneup").card == "spot" && registered("oneup").order == 1 && registered("oneup").name == "1-UP" && + registered("oneup").flag == "World/ReSkate/Throwdowns/Flags/oneup_Placement", + "1-UP is one of his modes now"); + check(registered("race").card == "spot" && registered("race").color == 0xff3434ffU && registered("race").order == 4 && + registered("race").flag == "World/ReSkate/Throwdowns/Flags/race_Placement" && registered("race").cursor_height == 99999, + "Deathrace: his donor, colour, order, flag and a start flag that may stand on anything"); + check(registered("tag").card == "skate" && registered("hide").card == "skate" && registered("jam").card == "jam" && registered("meat").card == "spot", + "His donor rule: tag-like modes S.K.A.T.E.'s card, Spot Jam Skate Jam's, the rest Spot Battle's"); + SkaterTelemetry t; + t.skater = true; + t.position = {1, 2, 3}; + t.physics_state = 100; + game.telemetry = t; + environment.local_skater = SkaterState{{1, 2, 3}, 0, 0, 0, false, 0}; + environment.score_now = ScoreState{0, 1, false, {}, {}, false}; + host.set_level("sanvan"); + ticks(host, environment, 50); // its game layer loads a part a tick + running(host, plugin, environment); + check(game.kit_documents >= 5, "It publishes the kit's documents\n" + environment.all()); + { + const auto idle = kit::documents_for(plugin + "/jam"); + check(idle && !idle->hud.active && !idle->setup && !idle->waiting && !idle->panel.present, "Idle, no HUD and no pages"); + } + check(!environment.logged(".lua:") && !environment.logged("tick failed"), "Idle ticks run clean\n" + environment.all()); + // His rule checks (tdm test, his mode_rules_tests.cpp). + environment.lines.clear(); + check(host.run_console("tdm test"), "tdm test"); + check(environment.printed(" 0 failed") && environment.printed(" passed"), "His rule checks pass:\n" + printed_lines(environment)); + + const auto docs_of = [&](const std::string& key) { + const auto docs = kit::documents_for(key); + check(docs.has_value(), key + ": its documents are in the kit's store"); + return *docs; + }; + const auto flag_waiting = [&](const std::string& key, std::uint32_t id) { game.flag = {true, true, false, true, key, id}; }; + struct Expect { + const char* mode; + const char* title; + const char* timer; + std::vector rows; + }; + const std::vector expected{ + {"jam", "Spot Jam", "Timer", {"area"}}, {"meat", "Hall of Meat", "Timer", {"area"}}, + {"domination", "Domination", "Timer", {"capture-spots"}}, {"graffiti", "Graffiti", "Timer", {"area"}}, + {"tag", "Skate Tag", "Timer", {"area"}}, {"infection", "Infection", "Timer", {"area"}}, + {"hide", "Hide & Seek", "Duration", {"hide_time", "area"}}, {"skate", "R.E.S.K.A.T.E.", "Turn Timer", {"letters", "tricks", "zone"}}, + {"race", "Deathrace", "Finish deadline", {}}}; + std::uint32_t id = 101; + for (const auto& e : expected) { + const std::string mode = e.mode, key = plugin + "/" + mode; + id += 2; + // A card click: the kit begins the native flag with the stock setup page open, then says `open`. + check(game.throwdown_flag_begin(key, id, {6, 120, true, 0}), mode + ": the flag begins"); + game.flag.placing = false; + kit::post(key, "open", {std::to_string(id)}); + ticks(host, environment, 2); + auto docs = docs_of(key); + check(docs.setup && docs.setup->mode == key && docs.setup->title == e.title && docs.setup->timer.present && + docs.setup->timer.caption == e.timer && docs.setup->players.present && docs.setup->players.max == 6 && docs.setup->back == "BACK", + mode + ": his setup page\n" + environment.all()); + std::vector rows; + for (const auto& row : docs.setup->rows) rows.push_back(row.id); + check(rows == e.rows, mode + ": his setup rows (" + std::to_string(rows.size()) + ")"); + if (mode == "jam") check(docs.setup->rows[0].kind == "button" && docs.setup->rows[0].label == "Set optional area", "Spot Jam's Area button"); + if (mode == "skate") + check(docs.setup->rows[1].options.size() == 4 && docs.setup->rows[2].options.size() == 6 && docs.setup->rows[0].max == 7, + "R.E.S.K.A.T.E.'s Letters, Tricks and Trick Zone"); + kit::post(key, "setup_row", {"players", "3"}); + ticks(host, environment, 2); + check(docs_of(key).setup->players.value == 3, mode + ": the Players row sets his capacity"); + // Confirm, then the native flag placed. + kit::post(key, "setup_confirm", {"3", mode == "race" ? "120" : "300"}); + game.flag.placing = true; + ticks(host, environment, 1); + flag_waiting(key, id); + kit::post(key, "flag_placed", {std::to_string(id), "1", "2", "3", "90"}); + ticks(host, environment, 3); + docs = docs_of(key); + // Deathrace's route editor opens over its placed start flag: no waiting card until it closes. + check(mode == "race" ? !docs.waiting + : docs.waiting && docs.waiting->mode == key && docs.waiting->title == e.title && docs.waiting->capacity == 3, + mode + ": his waiting card\n" + environment.all()); + if (mode == "race") { + check(docs.hud.active && docs.hud.theme == "race" && docs.hud.editor.present && docs.hud.editor.title == "Deathrace builder" && + docs.hud.editor.prompts.size() == 11 && docs.hud.editor.info_title == "ROUTE INFO" && docs.hud.header.title == "DEATHRACE" && + docs.hud.header.frame_width == 704, + "Deathrace's start gate opens his route editor\n" + environment.all()); + // His route library: a route saved on this map, the load dialog (hold Start), a row picked. + game.files[plugin + "/routes/test_map/Loop.json"] = + R"({"format": "ReSkate Deathrace route", "version": 1, "id": 7, "name": "Loop", "map": "test_map", "settings": {)" + R"("duration_seconds": 120, "turn_seconds": 30, "strikes": 3, "radius": 6, "area_radius": 0, "corners": [],)" + R"("points": [[1, 2, 3], [1, 2, 23], [1, 2, 43]], "yaw_degrees": [0, 0, 0], "half_widths": [6, 6, 6],)" + R"("gate_types": ["required", "required", "required"], "arrow_roll_degrees": [], "pitch_degrees": [], "spawn": [1, 2, 3],)" + R"("has_spawn": true, "finish_gate": true, "trick_kinds": 15}})"; + game.focus_now = {true, false, true}; + game.pad_bits_now = 0u; + ticks(host, environment, 3); + game.pad_bits_now = 0x10u; + ticks(host, environment, 100); + check(game.dialog && game.dialog->id == "route-load" && game.dialog->title == "LOAD DEATHRACE ROUTE" && game.dialog->rows.size() == 1 && + game.dialog->rows[0].text == "Loop" && game.dialog->file.present && game.dialog->buttons[0].label == "Back to builder", + "Hold Start: his route library\n" + environment.all()); + game.pad_bits_now = 0u; + ticks(host, environment, 2); + kit::post_to(plugin, "dialog", {}, {"route-load", "row", "", "1", ""}); + ticks(host, environment, 3); + { + const auto info = docs_of(key).hud.editor.info; + check(!info.empty() && info[0].first == "Required gates: 1" && info[1].first == "Finish gate: Placed", "The route is loaded\n" + environment.all()); + } + // Tap Start: name and save it, then players can enroll. + game.pad_bits_now = 0x10u; + ticks(host, environment, 2); + game.pad_bits_now = 0u; + ticks(host, environment, 3); + check(game.dialog && game.dialog->id == "route-save" && game.dialog->title == "SAVE DEATHRACE ROUTE" && game.dialog->field.text == "Loop", + "Tap Start: his save dialog\n" + environment.all()); + kit::post_to(plugin, "dialog", {}, {"route-save", "save", "Loop Two", "0", ""}); + ticks(host, environment, 3); + check(game.files.contains(plugin + "/routes/test_map/Loop Two.json") && game.called("dialog close " + plugin + " route-save"), + "The route is saved as his JSON\n" + environment.all()); + check(game.files[plugin + "/routes/test_map/Loop Two.json"].find("\"format\": \"ReSkate Deathrace route\"") != std::string::npos, + "in his format"); + { + const auto saved = docs_of(key); + check(saved.waiting && saved.waiting->title == e.title && saved.waiting->capacity == 3 && saved.waiting->can_start && + !saved.hud.editor.present, + "A saved route closes the editor: his waiting card, and it can start\n" + environment.all()); + } + game.focus_now = {}; + game.pad_bits_now.reset(); + } else { + if (mode == "domination") check(host.run_console("tdm point"), "his Domination needs a spot: `mode point` where the skater is"); + ticks(host, environment, 2); + check(docs_of(key).waiting->can_start, mode + ": the host can start\n" + environment.all()); + kit::post(key, "start", {std::to_string(id)}); + ticks(host, environment, 400); + const auto ms = status_of(host, plugin).tick_ms; + std::cout << "SmokeyDawg58 " << mode << ": " << ms << " ms a tick playing (its budget is 2 ms)\n"; + check(ms < 2, mode + ": within its time"); + docs = docs_of(key); + const auto& hud = docs.hud; + check(hud.active && hud.theme == mode && !docs.waiting, mode + ": Start plays it alone\n" + environment.all()); + check(mode == "skate" ? hud.header.title.empty() && hud.card.present && hud.layout == "bottom" + : hud.header.title == e.title && hud.counters.size() == 2 && hud.counters[0].starts_with("LEADER"), + mode + ": his HUD's title and score block"); + check(hud.board.present && !hud.results.present + ? hud.board.title == "LEADERBOARD" && hud.board.rows.size() == 1 && hud.board.rows[0].self + : hud.results.present && hud.results.title == std::string(e.title) + " RESULTS", + mode + ": his leaderboard\n" + environment.all()); + if (!hud.results.present) + check(docs.panel.present && docs.panel.title == e.title && docs.panel.players.size() == 1 && docs.panel.settings.size() == 3, + mode + ": his Throwdown Panel"); + check(kit::match().markers || hud.results.present, mode + ": the match keeps the session markers"); + } + running(host, plugin, environment); + check(!environment.logged(".lua:") && !environment.logged("tick failed") && !environment.logged("failed:"), + mode + " runs without errors\n" + environment.all()); + kit::post(key, "quit", {}); + ticks(host, environment, 3); + game.flag = {}; + ticks(host, environment, 2); + docs = docs_of(key); + check(!docs.hud.active && !docs.waiting, mode + ": Quit ends it\n" + environment.all()); + } + // 1-UP: a practice match, alone at its flag. + const std::string oneup = plugin + "/oneup"; + check(game.throwdown_flag_begin(oneup, 501, {4, 120, true, 0}), "1-UP's flag begins"); + game.flag.placing = false; + kit::post(oneup, "open", {"501"}); + ticks(host, environment, 2); + auto docs = docs_of(oneup); + check(docs.setup && docs.setup->title == "1-UP" && docs.setup->timer.value == 20 && docs.setup->timer.max == 120, "1-UP's setup page\n" + environment.all()); + kit::post(oneup, "setup_row", {"players", "1"}); + kit::post(oneup, "setup_confirm", {"1", "20"}); + game.flag.placing = true; + ticks(host, environment, 1); + flag_waiting(oneup, 501); + kit::post(oneup, "flag_placed", {"501", "1", "2", "3", "0"}); + ticks(host, environment, 5); + docs = docs_of(oneup); + check(docs.waiting && docs.waiting->title == "1-UP" && docs.waiting->can_start && docs.waiting->capacity == 1, "1-UP's waiting card\n" + environment.all()); + kit::post(oneup, "start", {"501"}); + bool played = false; + for (int i = 0; i < 600 && !played; ++i) { + ticks(host, environment, 1); + docs = docs_of(oneup); + played = docs.hud.active && docs.hud.clock.present && docs.hud.clock.ticking; + } + const auto& hud = docs.hud; + check(played && hud.theme == "oneup" && hud.header.title == "1-UP" && hud.counters.size() == 2 && hud.counters[0].starts_with("TARGET") && + hud.board.present && hud.board.variant == "stamps" && hud.board.rows.size() == 1 && hud.board.rows[0].marks.size() == 3 && + hud.board.title.ends_with("'S TURN"), + "1-UP's practice plays, his score HUD up\n" + environment.all()); + check(docs.panel.present && docs.panel.title == "1-UP" && kit::match().markers, "1-UP's panel and markers"); + std::cout << "SmokeyDawg58 1-UP: " << status_of(host, plugin).tick_ms << " ms a tick playing\n"; + check(status_of(host, plugin).tick_ms < 2, "1-UP within its time"); + kit::post(oneup, "quit", {}); + ticks(host, environment, 3); + game.flag = {}; + ticks(host, environment, 2); + check(!docs_of(oneup).hud.active, "1-UP's Quit ends it\n" + environment.all()); + running(host, plugin, environment); + check(!environment.logged(".lua:") && !environment.logged("tick failed"), "1-UP runs without errors\n" + environment.all()); + // His ReSkate-menu 1-UP card, as the plugin's page: Place 1-Up here, then its lobby, then Exit. + environment.panels_open = true; + environment.shown = plugin; + ticks(host, environment, 5); + using K = plugin_ui::Widget::Kind; + check(!environment.panels.empty() && environment.panels[0].page && environment.panels[0].title == "1-UP" && + environment.panels[0].widgets[0].kind == K::card && environment.panels[0].widgets[0].text == "1-UP", + "His 1-UP card on the plugin's page\n" + environment.all()); + environment.actions = {plugin_ui::Action{plugin, "1-UP", "place", false}}; + ticks(host, environment, 30); + const auto heading = [&] { + for (const auto& w : environment.panels[0].widgets) if (w.kind == K::heading) return w.text; + return std::string{}; + }; + { + std::string widgets; + for (const auto& w : environment.panels[0].widgets) widgets += w.text + " | " + w.help + "\n"; + check(heading() == "READY UP", "Place 1-Up here opens its lobby: " + heading() + "\n" + widgets + environment.all()); + } + environment.actions = {plugin_ui::Action{plugin, "1-UP", "exit", false}}; + ticks(host, environment, 30); + check(heading().empty(), "and Exit 1-Up leaves it"); + environment.panels_open = false; + // His console verbs, under tdm. + check(host.run_console("tdm help"), "tdm help"); + check(!environment.logged(".lua:"), "tdm help answers\n" + environment.all()); } void grind_exits_pick_the_side() { @@ -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); diff --git a/Extension/Plugins/plugin_api.cpp b/Extension/Plugins/plugin_api.cpp index 242811c3..b320168f 100644 --- a/Extension/Plugins/plugin_api.cpp +++ b/Extension/Plugins/plugin_api.cpp @@ -17,14 +17,15 @@ namespace { struct Event { std::string_view name, permission; }; -constexpr std::array events{{ +constexpr std::array events{{ {"load", {}}, {"unload", {}}, {"tick", {}}, {"level_loaded", {}}, {"level_unloading", {}}, {"trick_started", "events.tricks"}, {"trick_landed", "events.tricks"}, {"line_broken", "events.tricks"}, {"draw", "overlay"}, {"player_joined", "session.read"}, {"player_left", "session.read"}, {"message", "net.session"}, {"skater_state", "events.skater"}, {"bail", "events.skater"}, {"chat", "chat.read"}, {"track_changed", "music"}, {"throwdown", "throwdowns"}, {"action", "input"}, {"screen_closed", "ui.screen"}, {"board_landed", "events.skater"}, {"body_hit", "events.skater"}, {"bail_end", "events.skater"}, - {"physics_step", "events.physics"}, {"grind_exit", "events.skater"}, {"throwdown_mode", "throwdowns"}, + {"physics_step", "events.physics"}, {"grind_exit", "events.skater"}, {"trick_notes", "events.tricks"}, + {"throwdown_ui", "throwdowns"}, }}; constexpr std::size_t maximum_key = 128; constexpr std::size_t maximum_command = 48; @@ -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(v[0]), static_cast(v[1]), static_cast(v[2])}); + } case LUA_TTABLE: { luaL_checkstack(L, 4, "tables nested too deep"); const int length = lua_objlen(L, index); @@ -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"); diff --git a/Extension/Plugins/plugin_api.h b/Extension/Plugins/plugin_api.h index 127a557e..92c1a606 100644 --- a/Extension/Plugins/plugin_api.h +++ b/Extension/Plugins/plugin_api.h @@ -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 = "", 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` diff --git a/Extension/Plugins/plugin_api_services.cpp b/Extension/Plugins/plugin_api_services.cpp new file mode 100644 index 00000000..395328ce --- /dev/null +++ b/Extension/Plugins/plugin_api_services.cpp @@ -0,0 +1,309 @@ +#include "plugin_api.h" +#include "plugin_game.h" +#include "lua.h" +#include "lualib.h" +#include +#include +#include +#include + +// General services a game layer in Lua reads and drives each tick: the skater's telemetry, stance +// and Bone Cam, the pad and keys, the free and pad-flown cameras, spectating, the session's world, +// the competition's stock physics, and skate.'s trick list and flick diagrams. None of them knows a +// throwdown mode; each checks its permission and the game (plugin_game.h) does the rest. +namespace dingosdk::plugins { +struct ServicesApi { + static Game& game(Plugin& plugin) { return plugin.services_.game(); } +}; +namespace { +Game& game_of(Plugin& plugin) { return ServicesApi::game(plugin); } +Plugin& plugin_of(lua_State* L) { + auto& plugin = *static_cast(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(lua_tonumber(L, index)); + // Steam IDs come as strings: a Lua number cannot hold one exactly. + const std::string text = luaL_checkstring(L, index); + std::uint64_t out{}; + for (const char c : text) { + if (c < '0' || c > '9' || out > (~0ULL - 9) / 10) luaL_argerror(L, index, "a Steam ID"); + out = out * 10 + static_cast(c - '0'); + } + return out; +} +int result(lua_State* L, const std::string& why) { + lua_pushboolean(L, why.empty()); + if (why.empty()) return 1; + lua_pushlstring(L, why.data(), why.size()); + return 2; +} +void field_number(lua_State* L, const char* name, double value) { + lua_pushnumber(L, value); + lua_setfield(L, -2, name); +} +void field_bool(lua_State* L, const char* name, bool value) { + lua_pushboolean(L, value); + lua_setfield(L, -2, name); +} +void field_vec(lua_State* L, const char* name, const Vec3& v) { + lua_pushvector(L, v[0], v[1], v[2]); + lua_setfield(L, -2, name); +} + +// ---- skater -------------------------------------------------------------------------------------- +// skater.telemetry(): the trainer's telemetry, its last jump, the physics watch and the bail's body. +int telemetry(lua_State* L) { + auto& plugin = allowed(L, "skater.read", "reskate.skater.telemetry"); + const auto t = game_of(plugin).skater_telemetry(); + if (!t) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, 0, 20); + field_bool(L, "skater", t->skater); + field_bool(L, "airborne", t->airborne); + field_vec(L, "position", t->position); + field_number(L, "heading", t->heading); + field_number(L, "speed", t->speed); + field_number(L, "vertical", t->vertical); + field_number(L, "air_time", t->air_time); + field_number(L, "height", t->height); + field_number(L, "top_speed", t->top_speed); + field_number(L, "physics_state", t->physics_state); + lua_createtable(L, 0, 16); + const auto& j = t->last; + field_number(L, "serial", static_cast(j.serial)); + field_number(L, "takeoff_speed", j.takeoff_speed); + field_number(L, "takeoff_angle", j.takeoff_angle); + field_number(L, "air_time", j.air_time); + field_number(L, "height", j.height); + field_number(L, "distance", j.distance); + field_number(L, "drop", j.drop); + field_number(L, "landing_speed", j.landing_speed); + field_number(L, "spin", j.spin); + field_number(L, "spin_rate", j.spin_rate); + field_number(L, "flip", j.flip); + field_number(L, "board_turn", j.board_turn); + field_number(L, "state", j.state); + field_vec(L, "takeoff", j.takeoff); + field_vec(L, "landing", j.landing); + lua_setfield(L, -2, "last"); + lua_createtable(L, 0, 9); + const auto& w = t->watch; + field_bool(L, "valid", w.valid); + field_bool(L, "ragdoll_known", w.ragdoll_known); + field_bool(L, "ragdoll", w.ragdoll); + field_number(L, "state", w.state); + field_number(L, "previous", w.previous); + field_number(L, "previous_seconds", w.previous_seconds); + field_number(L, "changes", static_cast(w.changes)); + field_number(L, "wipeouts", static_cast(w.wipeouts)); + field_number(L, "runouts", static_cast(w.runouts)); + lua_setfield(L, -2, "watch"); + field_bool(L, "teleport_pending", t->teleport_pending); + field_bool(L, "slam", t->slam); + field_bool(L, "struck", t->struck); + if (t->body) field_vec(L, "body", *t->body); + return 1; +} +int goofy(lua_State* L) { + auto& plugin = allowed(L, "skater.read", "reskate.skater.goofy"); + if (const auto g = game_of(plugin).stance_goofy()) lua_pushboolean(L, *g); + else lua_pushnil(L); + return 1; +} +// skater.bone_cam(word, ...): no words for its setting ("game", "on" or "off"), "status" its full +// status line, "bail" a bail to show, "ringing" "1" or "0", "game" "on"/"off" (a plugin's game +// wants it), or the `bonecam` console words. +int bone_cam(lua_State* L) { + auto& plugin = allowed(L, "camera", "reskate.skater.bone_cam"); + std::vector words; + for (int i = 1, n = std::min(lua_gettop(L), 8); i <= n; ++i) { + std::size_t length = 0; + const char* word = luaL_checklstring(L, i, &length); + words.emplace_back(word, std::min(length, 32)); + } + const auto text = game_of(plugin).bone_cam(plugin.id(), words); + lua_pushlstring(L, text.data(), text.size()); + return 1; +} + +// ---- input --------------------------------------------------------------------------------------- +// input.pad_bits(menu): XInput's button bits with LT 0x10000 and RT 0x20000, or nil without a pad; +// with `menu`, read while ReSkate's menu is open too. +int pad_bits(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.pad_bits"); + if (const auto bits = game_of(plugin).pad_bits(lua_toboolean(L, 1) != 0)) lua_pushnumber(L, *bits); + else lua_pushnil(L); + return 1; +} +int key_code(lua_State* L, int index) { + if (lua_isnumber(L, index)) { + const int vk = static_cast(lua_tonumber(L, index)); + if (vk < 1 || vk > 254) luaL_argerror(L, index, "a virtual key from 1 to 254"); + return vk; + } + const std::string name = luaL_checkstring(L, index); + static const std::pair names[] = { + {"Enter", 13}, {"Backspace", 8}, {"Escape", 27}, {"End", 35}, {"Home", 36}, {"PageUp", 33}, {"PageDown", 34}, {"Space", 32}, + {"Shift", 16}, {"Ctrl", 17}, {"Alt", 18}, {"Tab", 9}, {"Left", 37}, {"Up", 38}, {"Right", 39}, {"Down", 40}, + {"[", 0xDB}, {"]", 0xDD}, {"LeftMouse", 1}, {"RightMouse", 2}, {"MiddleMouse", 4}}; + for (const auto& [n, vk] : names) + if (name == n) return vk; + if (name.size() == 1 && ((name[0] >= 'A' && name[0] <= 'Z') || (name[0] >= '0' && name[0] <= '9'))) return name[0]; + luaL_argerror(L, index, "a key name (A-Z, 0-9, Enter, Backspace, Escape, End, PageUp, PageDown, [, ], ...) or a virtual key"); + return 0; +} +int key(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.key"); + lua_pushboolean(L, game_of(plugin).key_down(key_code(L, 1))); + return 1; +} +int wheel(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.wheel"); + lua_pushnumber(L, game_of(plugin).mouse_wheel()); + return 1; +} +// input.game_buttons(hidden, held, pause): the game buttons (XInput bits) kept from the game while +// asked, those held down for it, and all game input paused for half a second. +int game_buttons(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.game_buttons"); + const auto mask = [&](int index) { + const double v = luaL_optnumber(L, index, 0); + if (!(v >= 0 && v <= 0xffff)) luaL_argerror(L, index, "a 16-bit button mask"); + return static_cast(v); + }; + game_of(plugin).game_buttons(plugin.id(), mask(1), mask(2), lua_toboolean(L, 3) != 0); + return 0; +} +int input_allowed(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.allowed"); + lua_pushboolean(L, game_of(plugin).input_allowed()); + return 1; +} +int focus(lua_State* L) { + auto& plugin = allowed(L, "input", "reskate.input.focus"); + const auto f = game_of(plugin).input_focus(); + lua_createtable(L, 0, 3); + field_bool(L, "focused", f.focused); + field_bool(L, "interface_open", f.interface_open); + field_bool(L, "gameplay", f.gameplay); + return 1; +} + +// ---- camera -------------------------------------------------------------------------------------- +int free_camera(lua_State* L) { + auto& plugin = allowed(L, "camera", "reskate.camera.free"); + return result(L, game_of(plugin).free_camera(plugin.id(), lua_toboolean(L, 1) != 0)); +} +// camera.pad_flight(on): the free camera flown from the pad while placing things from the air (the +// bumpers or Q/E its height; the triggers stay the plugin's). Held by one plugin; released with it. +int pad_flight(lua_State* L) { + auto& plugin = allowed(L, "camera", "reskate.camera.pad_flight"); + return result(L, game_of(plugin).pad_flight(plugin.id(), lua_toboolean(L, 1) != 0)); +} + +// ---- session ------------------------------------------------------------------------------------- +int spectate(lua_State* L) { + auto& plugin = allowed(L, "session.read", "reskate.session.spectate"); + game_of(plugin).spectate(lua_isnoneornil(L, 1) ? 0 : id_at(L, 1)); + return 0; +} +// session.world(): {world = id (a string; it changes with the map or session), barred = the local +// player's mods change scoring, map = the map's name}. +int world(lua_State* L) { + auto& plugin = allowed(L, "session.read", "reskate.session.world"); + auto& g = game_of(plugin); + lua_createtable(L, 0, 3); + const auto id = std::to_string(g.world_id()); + lua_pushlstring(L, id.data(), id.size()); + lua_setfield(L, -2, "world"); + field_bool(L, "barred", g.world_barred()); + const auto map = g.world_map(); + lua_pushlstring(L, map.data(), map.size()); + lua_setfield(L, -2, "map"); + return 1; +} + +// ---- physics ------------------------------------------------------------------------------------- +// physics.competition(on): the game's stock physics while the local player competes (the player's +// own tuning comes back after); stock_ready(): the stock tuning has been read; prepare(): read it +// now, switching nothing. +int competition(lua_State* L) { + auto& plugin = allowed(L, "skater.control", "reskate.physics.competition"); + game_of(plugin).competition(plugin.id(), lua_toboolean(L, 1) != 0); + return 0; +} +int stock_ready(lua_State* L) { + auto& plugin = allowed(L, "skater.control", "reskate.physics.stock_ready"); + lua_pushboolean(L, game_of(plugin).stock_physics_ready()); + return 1; +} +int prepare(lua_State* L) { + auto& plugin = allowed(L, "skater.control", "reskate.physics.prepare"); + game_of(plugin).prepare_physics(); + return 0; +} + +// ---- tricks -------------------------------------------------------------------------------------- +// tricks.feed(on): skate.'s trick list, entry by entry, as trick_notes events while on. +int trick_feed(lua_State* L) { + auto& plugin = allowed(L, "events.tricks", "reskate.tricks.feed"); + game_of(plugin).trick_feed(plugin.id(), lua_toboolean(L, 1) != 0); + return 0; +} +// tricks.gesture(name): the flick diagram of a flip trick as skate. names it ({{x, y}, ...}: x to the +// left, y pulled back, the stick's gate the unit circle, a regular stance); empty until the game's +// gestures are read (the first call starts that) or for a trick without one. +int gesture(lua_State* L) { + auto& plugin = allowed(L, "events.tricks", "reskate.tricks.gesture"); + std::size_t length = 0; + const char* name = luaL_checklstring(L, 1, &length); + const auto path = game_of(plugin).trick_gesture(std::string_view(name, std::min(length, 96))); + lua_createtable(L, static_cast(path.size()), 0); + for (std::size_t i = 0; i < path.size(); ++i) { + lua_createtable(L, 2, 0); + lua_pushnumber(L, path[i][0]); + lua_rawseti(L, -2, 1); + lua_pushnumber(L, path[i][1]); + lua_rawseti(L, -2, 2); + lua_rawseti(L, -2, static_cast(i + 1)); + } + return 1; +} + +// Adds the functions to reskate. (on top of the stack), making the table when there is none. +void into_table(lua_State* L, const char* table, std::initializer_list> functions) { + lua_getfield(L, -1, table); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + lua_createtable(L, 0, static_cast(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 diff --git a/Extension/Plugins/plugin_api_throwdowns.cpp b/Extension/Plugins/plugin_api_throwdowns.cpp new file mode 100644 index 00000000..dbc8f9ae --- /dev/null +++ b/Extension/Plugins/plugin_api_throwdowns.cpp @@ -0,0 +1,402 @@ +#include "plugin_api.h" +#include "plugin_game.h" +#include "lua.h" +#include "lualib.h" +#include +#include +#include +#include + +// The throwdown kit's side of the reskate API (throwdown_kit.h): a plugin's documents for the +// native Throwdowns UI and the native flag registration, plus the general services its modes use: +// the plugin's own data files, overlay dialogs, and the skater's push and hold. Each checks its +// permission and its arguments; the game (plugin_game.h) does the rest. +namespace dingosdk::plugins { +namespace { +Plugin& plugin_of(lua_State* L) { + auto& plugin = *static_cast(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(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(most)); + value.assign(text, length); + } + lua_pop(L, 1); + return value; +} +double number_field(lua_State* L, int table, const char* name, double fallback, double low, double high) { + lua_getfield(L, table, name); + double value = fallback; + if (!lua_isnil(L, -1)) { + if (!lua_isnumber(L, -1)) luaL_error(L, "%s is a number", name); + value = lua_tonumber(L, -1); + if (!(value >= low && value <= high)) luaL_error(L, "%s is %g to %g", name, low, high); + } + lua_pop(L, 1); + return value; +} +bool bool_field(lua_State* L, int table, const char* name, bool fallback) { + lua_getfield(L, table, name); + const bool value = lua_isnil(L, -1) ? fallback : lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + return value; +} +// A mode id of the plugin's own ("duel"): the SDK's is "/". +std::string own_mode(lua_State* L, int index) { + auto id = checked_text(L, index, 32, "a mode id"); + if (id.empty() || id.find('/') != std::string::npos) luaL_argerror(L, index, "a mode id of 1 to 32 characters, without /"); + return id; +} +// A path in the plugin's data folder: relative, inside it, and no name Windows keeps for a device. +std::string data_path(lua_State* L, int index, bool folder) { + if (folder && lua_isnoneornil(L, index)) return {}; + auto path = checked_text(L, index, 200, "a data path"); + while (folder && path.ends_with('/')) path.pop_back(); + if (folder && path.empty()) return {}; + if (safe_relative_path(path).empty()) luaL_argerror(L, index, "a path inside the plugin's data folder (no .., no drive, / between folders)"); + std::size_t start = 0; + while (start <= path.size()) { + const auto end = std::min(path.find('/', start), path.size()); + std::string base(path.substr(start, std::min(path.find('.', start), end) - start)); + for (auto& c : base) c = static_cast(std::tolower(static_cast(c))); + static constexpr std::array devices{"con", "prn", "aux", "nul"}; + const bool numbered = base.size() == 4 && (base.starts_with("com") || base.starts_with("lpt")) && base[3] >= '0' && base[3] <= '9'; + if (numbered || std::find(devices.begin(), devices.end(), base) != devices.end()) luaL_argerror(L, index, "a name Windows keeps for a device"); + start = end + 1; + } + return path; +} +} // namespace + +struct KitApi { + static Game& game(Plugin& plugin) { return plugin.services_.game(); } + + // ---- throwdowns: the kit --------------------------------------------------------------------- + // ui(doc): the plugin's documents (setup, waiting, hud, panel, flags, match) for the native UI. + // Every tick is fine: an unchanged document costs a comparison. + static int ui(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "throwdowns", "reskate.throwdowns.ui"); + luaL_checktype(L, 1, LUA_TTABLE); + Json document; + try { + document = lua_to_json(L, 1); + } catch (const std::exception& e) { + luaL_error(L, "%s", e.what()); + } + return result(L, game(plugin).publish_throwdown_ui(plugin.id(), document)); + } + // flag.begin(mode, game, {players, seconds, setup_page, cursor_height}): the native flag for + // one of the plugin's modes; false while another registration is busy. + static int flag_begin(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "throwdowns", "reskate.throwdowns.flag.begin"); + const auto mode = own_mode(L, 1); + const double number = luaL_checknumber(L, 2); + if (!(number >= 1 && number <= 4294967295.0) || number != std::floor(number)) luaL_argerror(L, 2, "a game number"); + throwdowns::kit::flag::Options options; + if (!lua_isnoneornil(L, 3)) { + luaL_checktype(L, 3, LUA_TTABLE); + options.players = static_cast(number_field(L, 3, "players", options.players, 1, 10)); + options.seconds = static_cast(number_field(L, 3, "seconds", options.seconds, 0, 3600)); + options.setup_page = bool_field(L, 3, "setup_page", options.setup_page); + options.cursor_height = static_cast(number_field(L, 3, "cursor_height", options.cursor_height, 0, 100000)); + } + lua_pushboolean(L, game(plugin).throwdown_flag_begin(plugin.id() + "/" + mode, static_cast(number), options)); + return 1; + } + // flag.state(): {owned, waiting, placing, busy, mine, mode, game}; mode (the plugin's own id) and + // game only when the registration is one of its modes. + static int flag_state(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "throwdowns", "reskate.throwdowns.flag.state"); + const auto state = game(plugin).throwdown_flag_state(); + const auto prefix = plugin.id() + "/"; + const bool mine = state.mode.starts_with(prefix); + lua_createtable(L, 0, 7); + lua_pushboolean(L, state.owned); + lua_setfield(L, -2, "owned"); + lua_pushboolean(L, state.waiting); + lua_setfield(L, -2, "waiting"); + lua_pushboolean(L, state.placing); + lua_setfield(L, -2, "placing"); + lua_pushboolean(L, state.busy); + lua_setfield(L, -2, "busy"); + lua_pushboolean(L, mine); + lua_setfield(L, -2, "mine"); + if (mine) { + const auto own = state.mode.substr(prefix.size()); + lua_pushlstring(L, own.data(), own.size()); + lua_setfield(L, -2, "mode"); + lua_pushnumber(L, state.game); + lua_setfield(L, -2, "game"); + } + return 1; + } + static int flag_leave(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "throwdowns", "reskate.throwdowns.flag.leave"); + game(plugin).throwdown_flag_leave(plugin.id()); + return 0; + } + + // ---- files: the plugin's own data folder ---------------------------------------------------------- + static Plugin& files_call(lua_State* L, const char* what) { + auto& plugin = plugin_of(L); + need(L, plugin, "files", what); + if (!within(plugin.services_.now_ms(), plugin.files_second_, plugin.file_calls_, 64)) luaL_error(L, "more than 64 file calls in a second"); + return plugin; + } + // list(folder?): the names in it, folders ending in "/"; or nil, why. + static int files_list(lua_State* L) { + auto& plugin = files_call(L, "reskate.files.list"); + const auto folder = data_path(L, 1, true); + std::vector names; + if (const auto why = game(plugin).files_list(plugin.id(), folder, names); !why.empty()) return failed(L, why); + lua_createtable(L, static_cast(names.size()), 0); + for (std::size_t i = 0; i < names.size(); ++i) { + lua_pushlstring(L, names[i].data(), names[i].size()); + lua_rawseti(L, -2, static_cast(i + 1)); + } + return 1; + } + // read(path): the file's text, or nil, why ("not found" for none). + static int files_read(lua_State* L) { + auto& plugin = files_call(L, "reskate.files.read"); + const auto path = data_path(L, 1, false); + std::string text; + if (const auto why = game(plugin).files_read(plugin.id(), path, text); !why.empty()) return failed(L, why); + lua_pushlstring(L, text.data(), text.size()); + return 1; + } + // write(path, text): at most 1 MB a file and 1000 files; the folders it names are made. + static int files_write(lua_State* L) { + auto& plugin = files_call(L, "reskate.files.write"); + const auto path = data_path(L, 1, false); + std::size_t length = 0; + const char* text = luaL_checklstring(L, 2, &length); + if (length > files_most_bytes) return result(L, "a file is at most 1 MB"); + return result(L, game(plugin).files_write(plugin.id(), path, std::string_view(text, length))); + } + // remove(path): a file, or a folder with nothing in it. + static int files_remove(lua_State* L) { + auto& plugin = files_call(L, "reskate.files.remove"); + return result(L, game(plugin).files_remove(plugin.id(), data_path(L, 1, false))); + } + + // ---- ui.dialog ---------------------------------------------------------------------------------- + // dialog(id, {title, message, field = {text, max}, list = {rows = {{text, sub, buttons = {"rename"}}}, + // empty}, buttons = {"OK", {id = "save", label = "Save"}}, file = {label, filter = "*.json"}}). + // Answers come as throwdown_ui events: action "dialog", args {id, button, text, row, file_text}. + // Esc (or B) answers "cancel" and closes it. + static int dialog(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "ui.screen", "reskate.ui.dialog"); + need(L, plugin, "throwdowns", "reskate.ui.dialog (its answers are throwdown_ui events)"); + if (!within(plugin.services_.now_ms(), plugin.dialog_second_, plugin.dialog_calls_, 120)) luaL_error(L, "more than 120 dialog calls in a second"); + DialogSpec spec; + spec.id = checked_text(L, 1, 32, "a dialog id"); + if (spec.id.empty()) luaL_argerror(L, 1, "a dialog id of 1 to 32 characters"); + luaL_checktype(L, 2, LUA_TTABLE); + spec.title = text_field(L, 2, "title", 64); + spec.message = text_field(L, 2, "message", 1024); + lua_getfield(L, 2, "field"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int at = lua_gettop(L); + spec.field.present = true; + spec.field.max = static_cast(number_field(L, at, "max", 64, 1, 256)); + spec.field.text = text_field(L, at, "text", spec.field.max); + } + lua_pop(L, 1); + lua_getfield(L, 2, "list"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int at = lua_gettop(L); + spec.list = true; + spec.empty = text_field(L, at, "empty", 256); + lua_getfield(L, at, "rows"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int rows = lua_gettop(L); + const int count = lua_objlen(L, rows); + if (count > 64) luaL_error(L, "a dialog's list has at most 64 rows"); + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, rows, i); + luaL_checktype(L, -1, LUA_TTABLE); + const int row = lua_gettop(L); + DialogSpec::Row r{text_field(L, row, "text", 128), text_field(L, row, "sub", 128), {}}; + lua_getfield(L, row, "buttons"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int buttons = lua_objlen(L, -1); + if (buttons > 4) luaL_error(L, "a row has at most 4 buttons"); + for (int b = 1; b <= buttons; ++b) { + lua_rawgeti(L, -1, b); + std::size_t length = 0; + const char* name = lua_isstring(L, -1) ? lua_tolstring(L, -1, &length) : nullptr; + if (!name || !length || length > 24) luaL_error(L, "a row's buttons are names of 1 to 24 characters"); + r.buttons.emplace_back(name, length); + lua_pop(L, 1); + } + } + lua_pop(L, 2); + spec.rows.push_back(std::move(r)); + } + } + lua_pop(L, 1); + } + lua_pop(L, 1); + lua_getfield(L, 2, "buttons"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int at = lua_gettop(L); + const int count = lua_objlen(L, at); + if (count > 4) luaL_error(L, "a dialog has at most 4 buttons"); + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, at, i); + DialogSpec::Button button; + if (lua_istable(L, -1)) { + button.label = text_field(L, lua_gettop(L), "label", 32); + button.id = text_field(L, lua_gettop(L), "id", 24); + } else if (lua_isstring(L, -1)) { + std::size_t length = 0; + const char* label = lua_tolstring(L, -1, &length); + if (length > 32) luaL_error(L, "a button's label is at most 32 characters"); + button.label.assign(label, length); + } else luaL_error(L, "a dialog's buttons are labels or {id, label}"); + if (button.label.empty()) luaL_error(L, "a button needs a label"); + if (button.id.empty()) button.id = button.label.substr(0, 24); + lua_pop(L, 1); + spec.buttons.push_back(std::move(button)); + } + } + lua_pop(L, 1); + lua_getfield(L, 2, "file"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int at = lua_gettop(L); + spec.file.present = true; + spec.file.label = text_field(L, at, "label", 32); + if (spec.file.label.empty()) spec.file.label = "Open a file"; + spec.file.filter = text_field(L, at, "filter", 64); + if (spec.file.filter.empty()) spec.file.filter = "*.*"; + for (const char c : spec.file.filter) + if (!std::isalnum(static_cast(c)) && c != '*' && c != '.' && c != ';' && c != '_' && c != '-') + luaL_error(L, "a file filter is patterns like *.json, split by ;"); + } + lua_pop(L, 1); + return result(L, game(plugin).dialog_open(plugin.id(), spec)); + } + static int close_dialog(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "ui.screen", "reskate.ui.close_dialog"); + const auto id = lua_isnoneornil(L, 1) ? std::string{} : checked_text(L, 1, 32, "a dialog id"); + game(plugin).dialog_close(plugin.id(), id); + return 0; + } + + // ---- skater.push / hold ------------------------------------------------------------------------- + // push(velocity): the skater's velocity (m/s, world) once it is on its board again. + static int push(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.push"); + const float* v = luaL_checkvector(L, 1); + for (int i = 0; i < 3; ++i) + if (!std::isfinite(v[i]) || std::fabs(v[i]) > 100) luaL_argerror(L, 1, "a velocity within 100 m/s on each axis"); + return result(L, game(plugin).skater_push(plugin.id(), Vec3{v[0], v[1], v[2]})); + } + // push_wait(): the skater is still getting back on its board, so a pending push waits two seconds + // more; push_reset(): a pending push is dropped. + static int push_wait(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.push_wait"); + return result(L, game(plugin).skater_push_wait(plugin.id())); + } + static int push_reset(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.push_reset"); + return result(L, game(plugin).skater_push_reset(plugin.id())); + } + static int hold(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.hold"); + return result(L, game(plugin).skater_hold(plugin.id(), lua_toboolean(L, 1) != 0)); + } + + static constexpr std::size_t files_most_bytes = 1u << 20; + static void install(Plugin& plugin, lua_State* L); +}; + +void install_kit_api(Plugin& plugin, lua_State* L) { KitApi::install(plugin, L); } + +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> functions) { + lua_getfield(L, -1, field); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + lua_createtable(L, 0, static_cast(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}}); +} +} diff --git a/Extension/Plugins/plugin_api_ui.cpp b/Extension/Plugins/plugin_api_ui.cpp index d8aa489e..b4af440b 100644 --- a/Extension/Plugins/plugin_api_ui.cpp +++ b/Extension/Plugins/plugin_api_ui.cpp @@ -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; } diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 5e058784..19d72fe6 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -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(id); + } + static std::array axis_of(lua_State* L, int table, const char* field) { + lua_getfield(L, table, field); + const float* v = luaL_checkvector(L, -1); + std::array result{v[0], v[1], v[2]}; + lua_pop(L, 1); + return result; + } + static std::array transform_of(lua_State* L, int table) { + luaL_checktype(L, table, LUA_TTABLE); + std::array m{}; + lua_getfield(L, table, "matrix"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + if (lua_objlen(L, -1) != 16) luaL_error(L, "a matrix is 16 numbers"); + for (int i = 0; i < 16; ++i) { + lua_rawgeti(L, -1, i + 1); + m[i] = static_cast(luaL_checknumber(L, -1)); + lua_pop(L, 1); + } + lua_pop(L, 1); + return m; + } + lua_pop(L, 1); + lua_getfield(L, table, "position"); + const auto at = position_of(L, -1, "an object's position"); + lua_pop(L, 1); + lua_getfield(L, table, "right"); + const bool axes = !lua_isnil(L, -1); + lua_pop(L, 1); + std::array right{1, 0, 0}, up{0, 1, 0}, forward{0, 0, 1}; + if (axes) { + right = axis_of(L, table, "right"); + up = axis_of(L, table, "up"); + forward = axis_of(L, table, "forward"); + } else { + const auto degrees = [&](const char* field) { + lua_getfield(L, table, field); + const double value = luaL_optnumber(L, -1, 0); + lua_pop(L, 1); + if (!std::isfinite(value)) luaL_error(L, "%s is a finite number of degrees", field); + return value * 3.14159265358979323846 / 180; + }; + const double yaw = degrees("yaw"), pitch = degrees("pitch"), roll = degrees("roll"); + // Yaw about up (forward = sin, 0, cos at zero pitch), then pitch about right, then roll + // about forward, as the game lays out a world object's axes. + const double cy = std::cos(yaw), sy = std::sin(yaw), cp = std::cos(pitch), sp = std::sin(pitch), cr = std::cos(roll), sr = std::sin(roll); + const std::array f{sy * cp, sp, cy * cp}, r0{cy, 0, -sy}; + const std::array u0{f[1] * r0[2] - f[2] * r0[1], f[2] * r0[0] - f[0] * r0[2], f[0] * r0[1] - f[1] * r0[0]}; + for (int i = 0; i < 3; ++i) { + right[i] = static_cast(r0[i] * cr + u0[i] * sr); + up[i] = static_cast(u0[i] * cr - r0[i] * sr); + forward[i] = static_cast(f[i]); + } + lua_getfield(L, table, "scale"); + std::array scale{1, 1, 1}; + if (lua_isnumber(L, -1)) scale.fill(static_cast(lua_tonumber(L, -1))); + else if (lua_isvector(L, -1)) { + const float* v = lua_tovector(L, -1); + scale = {v[0], v[1], v[2]}; + } else if (!lua_isnil(L, -1)) luaL_error(L, "a scale is a number or a vector"); + lua_pop(L, 1); + for (int i = 0; i < 3; ++i) right[i] *= scale[0], up[i] *= scale[1], forward[i] *= scale[2]; + } + for (int i = 0; i < 3; ++i) m[i] = right[i], m[4 + i] = up[i], m[8 + i] = forward[i], m[12 + i] = at[i]; + m[15] = 1; + return m; + } + static int entities_set(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "world.entities", "reskate.entities.set"); + const auto id = entity_id(L, 1); + const auto asset = checked_text(L, 2, 200, "a blueprint's name"); + const auto matrix = transform_of(L, 3); + return result(L, game(plugin).entity_set(plugin.id(), id, asset, matrix, lua_toboolean(L, 4) != 0)); + } + static int entities_remove(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "world.entities", "reskate.entities.remove"); + game(plugin).entity_remove(plugin.id(), entity_id(L, 1)); + return 0; + } + static int entities_clear(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "world.entities", "reskate.entities.clear"); + game(plugin).entity_clear(plugin.id()); + return 0; + } + static int entities_status(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "world.entities", "reskate.entities.status"); + static constexpr const char* names[] = {nullptr, "waiting", "loading", "missing", "ready", "hidden"}; + const auto status = game(plugin).entity_status(plugin.id(), entity_id(L, 1)); + if (status <= 0 || status > 5) lua_pushnil(L); + else lua_pushstring(L, names[status]); + return 1; + } + static int entities_ready(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "world.entities", "reskate.entities.ready"); + lua_pushboolean(L, game(plugin).entities_ready(plugin.id())); + return 1; + } static int place(lua_State* L) { need(L, plugin_of(L), "world.objects", "reskate.objects.place"); auto object = pose(L, 2, 3, 4); @@ -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(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(c); + } + lua_pop(L, 1); + lua_getfield(L, 1, "cursor_height"); + if (!lua_isnil(L, -1)) { + const double h = luaL_checknumber(L, -1); + if (!(h >= 0 && h <= 100000)) luaL_argerror(L, 1, "cursor_height is 0 to 100000 metres"); + mode.cursor_height = static_cast(h); + } + lua_pop(L, 1); + const auto stock = [](const std::string& tile) { return tile == "skate" || tile == "spot" || tile == "spot_battle" || tile == "jam"; }; + if (!mode.replaces.empty() && !stock(mode.replaces)) luaL_argerror(L, 1, "replaces is \"skate\", \"spot\" or \"jam\""); + if (!stock(mode.card)) luaL_argerror(L, 1, "card is \"skate\", \"spot\" or \"jam\""); + if (mode.flag.size() > 256 || mode.art.size() > 256) luaL_argerror(L, 1, "art and flag are at most 256 characters"); + // An asset path: letters, digits, _ - . and /. for (const char c : mode.flag) - if (!std::isalnum(static_cast(c)) && c != '_') luaL_argerror(L, 1, "flag is letters, digits and _"); + if (!std::isalnum(static_cast(c)) && c != '_' && c != '/' && c != '-' && c != '.') + luaL_argerror(L, 1, "flag is an asset path: letters, digits, _, -, . and /"); lua_getfield(L, 1, "players"); if (!lua_isnil(L, -1)) { luaL_checktype(L, -1, LUA_TTABLE); @@ -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}}); diff --git a/Extension/Plugins/plugin_camera.h b/Extension/Plugins/plugin_camera.h index bb01df12..7fc4ce14 100644 --- a/Extension/Plugins/plugin_camera.h +++ b/Extension/Plugins/plugin_camera.h @@ -10,8 +10,7 @@ #include #include -// World positions on screen, through the camera as it is now (as the nametags and Domination's -// overlay read it). The plugin host projects with it on the client thread (world_to_screen) and the +// World positions on screen, through the camera as it is now (as the nametags read it). The plugin host projects with it on the client thread (world_to_screen) and the // overlay on the render thread (world shapes), each with the camera of its own moment. namespace dingosdk::plugin_camera { inline std::atomic& image_base() { diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index 8cbbebc8..bd3c719a 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -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 #include #include @@ -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 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 skate; // S.K.A.T.E.'s players and letters, while one is shown }; -// A throwdown mode on the game's Throwdowns page: a card on its picker, the game's own setup -// page with these rows, and the game's start flag. The plugin runs the mode itself. -struct ThrowdownRow { - std::string id, kind, caption, text; // kind "slider", "choice" or "button"; text: a button's label - int minimum{}, maximum{}, step{1}, value{}; - std::vector options; // a choice's; its value is the option's index (from 0) -}; +// A throwdown mode on the game's Throwdowns page: a card on its picker, the game's own setup page +// (relabelled from the plugin's setup document) and the game's start flag. The plugin runs it. struct ThrowdownMode { std::string id, name, description; - std::string art; // the logo: a texture or image asset the plugin's mod adds, by path; empty: Spot Battle's - std::string flag; // the mod's own flag (InstanceId ReSkate.); empty: Spot Battle's - std::string 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 timer; - std::vector rows; + float cursor_height{}; // > 0: its flag's placement cursor may rise this far (metres) }; -// "opened", "setting", "button", "placed", "start", "leave" or "cancelled" for one of the -// plugin's modes, with every setting (players, timer and the rows by id). -// 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 settings; - std::optional at; - float heading{}; // degrees - bool host = true; - std::uint64_t leader{}, player{}; - std::vector 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 buttons; // each answers with its name and the row + }; + bool list{}; + std::vector rows; + std::string empty; // shown in a list with no rows + struct Button { + std::string id, label; + }; + std::vector