mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-27 20:57:08 +01:00
Merge remote-tracking branch 'origin/main' into uninstall-pack-dependencies
# Conflicts: # Launcher/Test/mod_manager_tests.cpp # Launcher/mod_manager.cpp # Launcher/mod_manager.h
This commit is contained in:
commit
14eef02a91
48 files changed
+3251
-126
No files matched your search
@@ -151,6 +151,25 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS)
|
||||
dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dingosdk_plugins dl pthread)
|
||||
endif()
|
||||
add_test(NAME server_locks COMMAND dingosdk_server_lock_tests)
|
||||
# What the server's plugins see of the server and do there: its players' skaters, its map, its park mods.
|
||||
add_executable(dingosdk_server_plugin_tests Server/Test/server_plugin_tests.cpp Server/server_host.cpp Server/server_party.cpp
|
||||
Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/server_update.cpp Server/server_release.cpp
|
||||
$<$<BOOL:${WIN32}>:Launcher/updater.cpp> Server/global_bans.cpp Server/server_words.cpp Extension/Multiplayer/developer_identity.cpp
|
||||
Extension/Multiplayer/word_lists.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp
|
||||
Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Remote/playback_buffers.cpp
|
||||
Extension/Multiplayer/Session/password.cpp Server/server_activity.cpp Server/server_votes.cpp Server/server_commands.cpp
|
||||
Server/server_parks.cpp Extension/Throwdowns/throwdown_wire.cpp Server/server_plugins.cpp)
|
||||
target_include_directories(dingosdk_server_plugin_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
target_include_directories(dingosdk_server_plugin_tests SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking")
|
||||
if(WIN32)
|
||||
target_link_libraries(dingosdk_server_plugin_tests PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json
|
||||
dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_miniz dingosdk_word_filter dingosdk_https winhttp bcrypt winmm
|
||||
dingosdk_plugins)
|
||||
else()
|
||||
target_link_libraries(dingosdk_server_plugin_tests PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json
|
||||
dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dingosdk_plugins dl pthread)
|
||||
endif()
|
||||
add_test(NAME server_plugins COMMAND dingosdk_server_plugin_tests "${PROJECT_SOURCE_DIR}/Server/Test/fixtures/park_mods")
|
||||
add_executable(dingosdk_server_release_tests Server/Test/server_release_tests.cpp Server/server_release.cpp)
|
||||
target_include_directories(dingosdk_server_release_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
target_link_libraries(dingosdk_server_release_tests PRIVATE dingosdk_json)
|
||||
@@ -387,6 +406,12 @@ if(DINGOSDK_BUILD_ANIMATION_STUDIO_TESTS)
|
||||
target_include_directories(dingosdk_arm_reach_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
target_link_libraries(dingosdk_arm_reach_tests PRIVATE dingosdk_json)
|
||||
add_test(NAME arm_reach COMMAND dingosdk_arm_reach_tests)
|
||||
# Something carried (reskate.skater.carry): held out, aimed, both hands, crouched with the feet kept.
|
||||
add_executable(dingosdk_carry_tests Extension/Skater/Test/carry_tests.cpp Extension/Skater/carry.cpp Extension/Skater/arm_reach.cpp
|
||||
Extension/AnimationStudio/anim_core.cpp)
|
||||
target_include_directories(dingosdk_carry_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
target_link_libraries(dingosdk_carry_tests PRIVATE dingosdk_json)
|
||||
add_test(NAME carry COMMAND dingosdk_carry_tests)
|
||||
endif()
|
||||
option(DINGOSDK_BUILD_REPLAY_KEYFRAMES_TESTS "Build replay keyframe smoothing regression tests" OFF)
|
||||
if(DINGOSDK_BUILD_REPLAY_KEYFRAMES_TESTS)
|
||||
@@ -431,6 +456,11 @@ if(DINGOSDK_BUILD_PLUGIN_TESTS AND WIN32)
|
||||
add_executable(dingosdk_trick_watch_tests Extension/Skater/Test/trick_watch_tests.cpp Extension/Skater/trick_watch.cpp)
|
||||
target_include_directories(dingosdk_trick_watch_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME trick_watch COMMAND dingosdk_trick_watch_tests)
|
||||
# The board's shader parameters (Extension/Customization/board_material.h): the walk over a board
|
||||
# laid out as the game holds it, what plugins may set, and holding and putting back.
|
||||
add_executable(dingosdk_board_material_tests Extension/Customization/Test/board_material_tests.cpp)
|
||||
target_include_directories(dingosdk_board_material_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
add_test(NAME board_material COMMAND dingosdk_board_material_tests)
|
||||
endif()
|
||||
get_property(project_targets DIRECTORY PROPERTY BUILDSYSTEM_TARGETS)
|
||||
foreach(target IN LISTS project_targets)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
// Remote skater and skateboard cosmetics.
|
||||
@@ -26,6 +27,27 @@ inline constexpr std::array<unsigned char, 24> set_shader_parameter_prefix{
|
||||
0x55,0x48,0x8d,0x6c,0x24,0xc9,0x48,0x81};
|
||||
// Initialized ColorRgb type descriptor; validate its type hash, size and index.
|
||||
inline constexpr std::uintptr_t color_rgb_type = 0x7780c50;
|
||||
// A material instance's own shader parameters (an AppearanceItem's named material): a hash map at
|
||||
// +0x268 of buckets, +0x270 its bucket count, +0x274 how many it holds. A node holds its ParamDbKey
|
||||
// at +0 (identity, size, type index and count words, then the key's 64-bit hash at +8), the value
|
||||
// at +0x10, the value's type object at +0x20, flags at +0x28 (bit 0 set on every live one) and the
|
||||
// next node at +0x40. A value written there shows once the material is marked changed at +0x2f6 and
|
||||
// its item publishes its materials (publish_materials). Read from the running game's board, 2026-10-22.
|
||||
inline constexpr std::size_t material_parameters = 0x268;
|
||||
inline constexpr std::size_t material_parameter_buckets = 0x270;
|
||||
inline constexpr std::size_t material_parameter_count = 0x274;
|
||||
inline constexpr std::size_t material_changed = 0x2f6;
|
||||
inline constexpr std::size_t parameter_value = 0x10;
|
||||
inline constexpr std::size_t parameter_type = 0x20;
|
||||
inline constexpr std::size_t parameter_flags = 0x28;
|
||||
inline constexpr std::size_t parameter_next = 0x40;
|
||||
// The type hash (the first word of the TypeInfo a type object points to; its size is the word at
|
||||
// +6) of each kind of value the board's materials hold: ColorRgb, Float32, an integer (values like
|
||||
// 13 on the deck, which pick how it is drawn) and a boolean (a switch the shader was compiled with).
|
||||
inline constexpr std::uint32_t color_rgb_type_hash = 0x885eff59;
|
||||
inline constexpr std::uint32_t float_type_hash = 0x2bb65c2d;
|
||||
inline constexpr std::uint32_t integer_type_hash = 0x8e4a817e;
|
||||
inline constexpr std::uint32_t boolean_type_hash = 0x36ed3444;
|
||||
// Finds a named entry in a loaded table by the FNV-1a hash of its name:
|
||||
// (out, name, table, flag, context) -> out, the fifth passed on the stack.
|
||||
// The skater loader's preset lookups reach it through a Lua binding that
|
||||
|
||||
@@ -58,12 +58,49 @@ void presses() {
|
||||
expect(high && !first_person, "the third press of each round returns to the high camera");
|
||||
}
|
||||
}
|
||||
|
||||
// With a plugin's camera in the cycle: high -> low -> first person -> the plugin's camera -> high, and
|
||||
// without first person (or on foot, where it cannot start) high -> low -> the plugin's camera -> high.
|
||||
void plugin() {
|
||||
for (const bool with_first_person : {true, false}) {
|
||||
bool high = true, first_person = false, on = false;
|
||||
const auto press = [&] {
|
||||
CycleState state{with_first_person, first_person, !first_person && !on, true, on, !on};
|
||||
const auto write = cycle_height_write(state, !high);
|
||||
high = write.high;
|
||||
if (write.step == CycleStep::enter) first_person = true;
|
||||
if (write.step == CycleStep::leave) first_person = false;
|
||||
if (write.plugin == CycleStep::enter) on = true;
|
||||
if (write.plugin == CycleStep::leave) on = false;
|
||||
};
|
||||
for (int round = 0; round < 3; ++round) {
|
||||
expect(high && !first_person && !on, "each round starts on the high camera");
|
||||
press();
|
||||
expect(!high && !first_person && !on, "first press: low camera");
|
||||
if (with_first_person) {
|
||||
press();
|
||||
expect(!high && first_person && !on, "second press: first person");
|
||||
}
|
||||
press();
|
||||
expect(!high && !first_person && on, "then the plugin's camera, the low camera behind it");
|
||||
press();
|
||||
}
|
||||
expect(high && !first_person && !on, "out of the plugin's camera: the high camera");
|
||||
}
|
||||
// No plugin in the cycle: the first-person cycle as before.
|
||||
expect(same(cycle_height_write({true, true, false, false, false, true}, true), true, CycleStep::leave),
|
||||
"without a plugin's camera, first person -> high");
|
||||
// Turned on by the plugin itself over the high camera: the button still takes it out to high.
|
||||
const auto out = cycle_height_write({false, false, true, true, true, false}, false);
|
||||
expect(out.high && out.plugin == CycleStep::leave, "a plugin's camera on by itself: the button leaves it for high");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
off();
|
||||
steps();
|
||||
presses();
|
||||
plugin();
|
||||
if (failures) {
|
||||
std::printf("%d first-person cycle test(s) failed\n", failures);
|
||||
return 1;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::first_person {
|
||||
@@ -7,20 +8,46 @@ namespace dingosdk::first_person {
|
||||
// (true is the high camera). With the option on, ReSkate turns that into high -> low -> first
|
||||
// person -> high by changing the value written: the step into first person keeps the low camera
|
||||
// behind it, and the step out of first person always lands on the high camera.
|
||||
//
|
||||
// A plugin's camera can be in the cycle too, last (reskate.camera.cycle): high -> low -> first
|
||||
// person -> the plugin's camera -> high (without first person in it, or where first person cannot
|
||||
// start: high -> low -> the plugin's camera -> high). Its step keeps the low camera behind it as well.
|
||||
enum class CycleStep : std::uint8_t { none, enter, leave };
|
||||
struct CycleState {
|
||||
bool enabled{}; // the option is on and the local skater is in control
|
||||
bool first_person{}; // first person has the camera
|
||||
bool can_enter{}; // first person can take the camera now: on the board, no Freecam
|
||||
bool plugin_enabled{}; // a plugin's camera is in the cycle now
|
||||
bool plugin{}; // that camera is on
|
||||
bool plugin_can_enter{}; // that camera is off and can start
|
||||
};
|
||||
struct CycleWrite {
|
||||
bool high{}; // the height the game keeps
|
||||
CycleStep step{}; // what first person does
|
||||
bool high{}; // the height the game keeps
|
||||
CycleStep step{}; // what first person does
|
||||
CycleStep plugin{}; // what the plugin's camera does
|
||||
};
|
||||
constexpr CycleWrite cycle_height_write(const CycleState& state, bool high) noexcept {
|
||||
if (!state.enabled) return {high, CycleStep::none};
|
||||
if (state.first_person) return {true, CycleStep::leave};
|
||||
if (high && state.can_enter) return {false, CycleStep::enter};
|
||||
return {high, CycleStep::none};
|
||||
// The plugin's camera is last: out of it, the high camera.
|
||||
if (state.plugin_enabled && state.plugin) return {true, CycleStep::none, CycleStep::leave};
|
||||
if (state.enabled && state.first_person) {
|
||||
if (state.plugin_enabled && state.plugin_can_enter) return {false, CycleStep::leave, CycleStep::enter};
|
||||
return {true, CycleStep::leave, CycleStep::none};
|
||||
}
|
||||
if (high) { // the game steps low -> high: first person, else the plugin's camera, comes in between
|
||||
if (state.enabled && state.can_enter) return {false, CycleStep::enter, CycleStep::none};
|
||||
if (state.plugin_enabled && state.plugin_can_enter) return {false, CycleStep::none, CycleStep::enter};
|
||||
}
|
||||
return {high, CycleStep::none, CycleStep::none};
|
||||
}
|
||||
|
||||
// The plugin's half of the link (the plugin runtime keeps it). Each tick the plugin in the cycle has
|
||||
// its flags published; the height write (on the thread that handles the button) leaves the step it
|
||||
// asks of the plugin's camera in `request`, taken on the plugin runtime's next tick.
|
||||
enum PluginFlag : std::uint8_t { plugin_flag_enabled = 1, plugin_flag_on = 2, plugin_flag_can_enter = 4 };
|
||||
struct PluginLink {
|
||||
std::atomic<std::uint8_t> flags{};
|
||||
std::atomic<std::uint8_t> request{}; // a CycleStep
|
||||
std::atomic<std::uint64_t> published{}; // GetTickCount64 when the flags were published
|
||||
};
|
||||
inline PluginLink plugin_link{};
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
#include "Extension/Customization/board_material.h"
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
|
||||
namespace {
|
||||
using namespace dingosdk;
|
||||
using namespace dingosdk::board_material;
|
||||
namespace n = addr::native_cosmetics;
|
||||
|
||||
void check(bool ok, const std::string& message) {
|
||||
if (ok) return;
|
||||
std::cerr << "FAILED: " << message << '\n';
|
||||
std::exit(1);
|
||||
}
|
||||
|
||||
// Memory as the game lays a board out, by pages; a read of a page never written fails.
|
||||
struct Memory {
|
||||
std::map<std::uintptr_t, std::array<unsigned char, 4096>> pages;
|
||||
void put_bytes(std::uintptr_t at, const void* data, std::size_t size) {
|
||||
for (std::size_t i = 0; i < size; ++i, ++at) pages[at & ~std::uintptr_t{4095}][at & 4095] = static_cast<const unsigned char*>(data)[i];
|
||||
}
|
||||
template <class T> void put(std::uintptr_t at, const T& value) { put_bytes(at, &value, sizeof(value)); }
|
||||
bool operator()(std::uintptr_t at, void* out, std::size_t size) const {
|
||||
for (std::size_t i = 0; i < size; ++i, ++at) {
|
||||
const auto page = pages.find(at & ~std::uintptr_t{4095});
|
||||
if (page == pages.end()) return false;
|
||||
static_cast<unsigned char*>(out)[i] = page->second[at & 4095];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
constexpr std::uintptr_t base = 0x140000000;
|
||||
constexpr std::uint64_t number_key = 0x841c7d74837f1d39ULL, integer_key = 0xb00abec403499469ULL, switch_key = 0xa5f9a6b8098279adULL;
|
||||
|
||||
struct Board {
|
||||
Memory memory;
|
||||
std::uintptr_t entity = 0x10000000;
|
||||
std::uintptr_t next = 0x20000000; // where the next allocation goes
|
||||
std::uintptr_t grip{}, deck{}, trucks{};
|
||||
std::uintptr_t deck_item{};
|
||||
std::map<std::uint64_t, std::uintptr_t> grip_nodes;
|
||||
std::uintptr_t take(std::size_t size) {
|
||||
const auto at = next;
|
||||
next += (size + 0xfff) & ~std::size_t{0xfff};
|
||||
std::vector<unsigned char> zero(size);
|
||||
memory.put_bytes(at, zero.data(), zero.size());
|
||||
return at;
|
||||
}
|
||||
// A type object and its information, in the game's image as the real ones are.
|
||||
std::uintptr_t type(std::uintptr_t object, std::uint32_t hash, std::uint16_t size) {
|
||||
const auto info = object + 0x100;
|
||||
memory.put(base + object, base + info);
|
||||
memory.put(base + info, hash);
|
||||
memory.put<std::uint16_t>(base + info + 6, size);
|
||||
return base + object;
|
||||
}
|
||||
struct Value {
|
||||
std::uint64_t key;
|
||||
std::uintptr_t type;
|
||||
std::uint16_t size;
|
||||
std::array<std::uint32_t, 4> raw{};
|
||||
std::uint32_t flags = 3;
|
||||
};
|
||||
std::uintptr_t material(const std::vector<Value>& values, std::map<std::uint64_t, std::uintptr_t>* nodes = nullptr) {
|
||||
const auto at = take(0x300), buckets = take(16);
|
||||
memory.put(at + n::material_parameters, buckets);
|
||||
memory.put<std::uint32_t>(at + n::material_parameter_buckets, 1);
|
||||
memory.put<std::uint32_t>(at + n::material_parameter_count, static_cast<std::uint32_t>(values.size()));
|
||||
memory.put<std::uintptr_t>(buckets + 8, 0xdead0000); // the map's end
|
||||
std::uintptr_t previous{};
|
||||
for (const auto& v : values) {
|
||||
const auto node = take(0x48);
|
||||
memory.put(node, developer_hoodie_detail::ParameterKey{0x1234, v.size, 1, 1, v.key});
|
||||
memory.put(node + n::parameter_value, v.raw);
|
||||
memory.put(node + n::parameter_type, v.type);
|
||||
memory.put(node + n::parameter_flags, v.flags);
|
||||
memory.put(previous ? previous + n::parameter_next : buckets, node);
|
||||
previous = node;
|
||||
if (nodes) (*nodes)[v.key] = node;
|
||||
}
|
||||
return at;
|
||||
}
|
||||
std::uintptr_t item(std::uintptr_t appearance, std::string_view slot, const std::vector<std::pair<std::string_view, std::uintptr_t>>& materials) {
|
||||
const auto at = take(0x200), name = take(256), buckets = take(16);
|
||||
memory.put_bytes(name, slot.data(), slot.size());
|
||||
memory.put(at + 0x30, name);
|
||||
memory.put(at + 0x48, appearance + 0x40);
|
||||
memory.put<std::uint32_t>(at + 0xb0, 2);
|
||||
memory.put(at + 0x1f0, buckets);
|
||||
memory.put<std::uint32_t>(at + 0x1f8, 1);
|
||||
memory.put<std::uint32_t>(at + 0x1fc, static_cast<std::uint32_t>(materials.size()));
|
||||
memory.put<std::uintptr_t>(buckets + 8, 0xdead0000);
|
||||
std::uintptr_t previous{};
|
||||
for (const auto& [material_name, material] : materials) {
|
||||
const auto node = take(0x30);
|
||||
memory.put(node, developer_hoodie_detail::material_hash(material_name));
|
||||
memory.put(node + 8, material);
|
||||
memory.put(previous ? previous + 0x28 : buckets, node);
|
||||
previous = node;
|
||||
}
|
||||
return at;
|
||||
}
|
||||
Board() {
|
||||
const auto colour = type(0x7780c50, n::color_rgb_type_hash, 12), number = type(0x765db28, n::float_type_hash, 4),
|
||||
integer = type(0x765da68, n::integer_type_hash, 4), toggle = type(0x7780f50, n::boolean_type_hash, 4);
|
||||
const auto bits = [](float a, float b = 0, float c = 0) {
|
||||
std::array<std::uint32_t, 4> raw{};
|
||||
const std::array<float, 3> f{a, b, c};
|
||||
std::memcpy(raw.data(), f.data(), sizeof(f));
|
||||
return raw;
|
||||
};
|
||||
grip = material({{graphic_colour, colour, 12, bits(.2f, .3f, .4f)},
|
||||
{number_key, number, 4, bits(1)},
|
||||
{integer_key, integer, 4, {13}},
|
||||
{switch_key, toggle, 4, {1}},
|
||||
{0x1111, colour, 12, bits(.5f, .5f, .5f), 0}}, // not live: bit 0 of its flags clear
|
||||
&grip_nodes);
|
||||
deck = material({{base_colour, colour, 12, bits(.5f, .5f, .5f)}});
|
||||
trucks = material({{base_colour, colour, 12, bits(.1f, .1f, .1f)}});
|
||||
const auto collection = take(0x80), cosmetic = take(0x200), appearance = take(0x100), list = take(24);
|
||||
memory.put(entity, base + addr::engine::board_entity_vtable);
|
||||
memory.put(entity + 0x70, collection);
|
||||
memory.put(collection, entity);
|
||||
memory.put<std::uint8_t>(collection + 8, 2);
|
||||
memory.put(collection + 0x20, cosmetic);
|
||||
memory.put(collection + 0x40, appearance);
|
||||
memory.put(cosmetic, base + n::cosmetic_component_vtable);
|
||||
memory.put(cosmetic + 0x18, collection);
|
||||
memory.put(appearance, base + addr::engine::skater_appearance_vtable);
|
||||
memory.put(appearance + 0x18, collection);
|
||||
deck_item = item(appearance, developer_board_detail::slots[0], {{"Deck_Mat", deck}, {"DeckTop_Mat", grip}});
|
||||
memory.put(list, deck_item);
|
||||
memory.put(list + 8, item(appearance, developer_board_detail::slots[1], {{"Truck_Mat", trucks}}));
|
||||
memory.put(appearance + 0x90, list);
|
||||
memory.put(appearance + 0x98, list + 16);
|
||||
memory.put(appearance + 0xa0, list + 24);
|
||||
}
|
||||
detail::Found walk() { return detail::walk(memory, base, entity); }
|
||||
Colour colour_at(std::uintptr_t node) {
|
||||
Colour c{};
|
||||
memory(node + n::parameter_value, c.data(), sizeof(c));
|
||||
return c;
|
||||
}
|
||||
};
|
||||
|
||||
const detail::Material* part_of(const detail::Found& found, Part part) {
|
||||
for (const auto& m : found.materials)
|
||||
if (m.part == part) return &m;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void walks_the_board() {
|
||||
Board board;
|
||||
const auto found = board.walk();
|
||||
check(!found.pending && found.materials.size() == 3, "The board's three materials are found");
|
||||
const auto* grip = part_of(found, Part::grip);
|
||||
const auto* deck = part_of(found, Part::deck);
|
||||
check(grip && deck && part_of(found, Part::trucks) && !part_of(found, Part::wheels), "each as its part, and no wheels without their slot");
|
||||
check(grip->address == board.grip && grip->item == board.deck_item && grip->nodes.size() == 5, "The grip is the deck slot's DeckTop_Mat");
|
||||
const auto& p = grip->nodes;
|
||||
check(p[0].parameter.kind == Kind::colour && p[0].parameter.key == graphic_colour && p[0].parameter.value == Colour{.2f, .3f, .4f},
|
||||
"A colour reads as one");
|
||||
check(p[1].parameter.kind == Kind::number && p[1].parameter.value[0] == 1, "a number as one");
|
||||
check(p[2].parameter.kind == Kind::integer && p[2].parameter.integer == 13, "an integer as one");
|
||||
check(p[3].parameter.kind == Kind::toggle && p[3].parameter.integer == 1, "a switch as one");
|
||||
check(p[4].parameter.kind == Kind::other, "and a value not live is of no kind plugins set");
|
||||
}
|
||||
|
||||
void refuses_what_picks_the_look() {
|
||||
Board board;
|
||||
const auto found = board.walk();
|
||||
Kind kind{};
|
||||
check(detail::refusal(found, Part::grip, graphic_colour, {1, 0, 0}, true, kind).empty() && kind == Kind::colour, "A colour may be held");
|
||||
check(detail::refusal(found, Part::grip, number_key, {-3, 0, 0}, false, kind).empty() && kind == Kind::number, "and a number");
|
||||
check(detail::refusal(found, Part::grip, integer_key, {13, 0, 0}, false, kind).find("only read") != std::string::npos,
|
||||
"An integer, which picks how the board is drawn, is not");
|
||||
check(detail::refusal(found, Part::grip, switch_key, {0, 0, 0}, false, kind).find("only read") != std::string::npos, "nor a switch");
|
||||
check(!detail::refusal(found, Part::grip, 0x1111, {.5f, .5f, .5f}, true, kind).empty(), "nor a value that is not live");
|
||||
check(!detail::refusal(found, Part::grip, graphic_colour, {1.5f, 0, 0}, true, kind).empty(), "A colour is 0 to 1");
|
||||
check(!detail::refusal(found, Part::grip, graphic_colour, {1, 0, 0}, false, kind).empty(), "and a number is not a colour");
|
||||
check(!detail::refusal(found, Part::grip, number_key, {1000, 0, 0}, false, kind).empty(), "A number has bounds");
|
||||
check(detail::refusal(found, Part::wheels, base_colour, {1, 0, 0}, true, kind).find("no parameter") != std::string::npos,
|
||||
"A part without the key says so");
|
||||
}
|
||||
|
||||
void holds_and_puts_back() {
|
||||
Board board;
|
||||
std::vector<detail::Saved> saved;
|
||||
std::vector<detail::Hold> holds{{"glow", Part::grip, graphic_colour, Kind::colour, {1, 0, 0}}};
|
||||
int writes{}, publishes{};
|
||||
auto write = [&](std::uintptr_t material, std::uintptr_t node, Kind kind, const Colour& value) {
|
||||
++writes;
|
||||
board.memory.put_bytes(node + n::parameter_value, value.data(), kind == Kind::colour ? sizeof(Colour) : sizeof(float));
|
||||
board.memory.put<std::uint8_t>(material + n::material_changed, 1);
|
||||
return true;
|
||||
};
|
||||
auto publish = [&](std::uintptr_t item) {
|
||||
check(item == board.deck_item, "The grip's item publishes its materials");
|
||||
++publishes;
|
||||
};
|
||||
const auto tick = [&] {
|
||||
const auto found = board.walk();
|
||||
detail::apply(saved, holds, found, write, publish);
|
||||
};
|
||||
const auto node = board.grip_nodes[graphic_colour];
|
||||
tick();
|
||||
check(board.colour_at(node) == Colour{1, 0, 0} && writes == 1 && publishes == 1, "A held colour is written and published");
|
||||
std::uint8_t changed{};
|
||||
board.memory(board.grip + n::material_changed, &changed, 1);
|
||||
check(changed == 1, "and its material marked changed");
|
||||
for (int i = 0; i < 6; ++i) tick();
|
||||
check(writes == 1 && publishes == 1 + developer_hoodie_detail::settle_publishes, "A colour standing still is published until it shows, then left");
|
||||
holds[0].value = {0, 1, 0};
|
||||
tick();
|
||||
check(board.colour_at(node) == Colour{0, 1, 0} && writes == 2, "A new value is written");
|
||||
// The game sets its own while it is held: held again.
|
||||
board.memory.put(node + n::parameter_value, Colour{.7f, .7f, .7f});
|
||||
tick();
|
||||
check(board.colour_at(node) == Colour{0, 1, 0} && writes == 3, "What is held wins over a change");
|
||||
holds.clear();
|
||||
publishes = 0;
|
||||
for (int i = 0; i < 8; ++i) tick();
|
||||
check(board.colour_at(node) == Colour{.7f, .7f, .7f} && writes == 4, "Let go, it goes back to the board's own (the game's latest)");
|
||||
check(saved.empty() && publishes == 1 + developer_hoodie_detail::settle_publishes, "and is published until that shows");
|
||||
|
||||
// A number, let go after the game changed it: left as the game has it.
|
||||
holds = {{"glow", Part::grip, number_key, Kind::number, {4, 0, 0}}};
|
||||
tick();
|
||||
float number{};
|
||||
board.memory(board.grip_nodes[number_key] + n::parameter_value, &number, 4);
|
||||
check(number == 4, "A held number is written");
|
||||
holds.clear();
|
||||
board.memory.put(board.grip_nodes[number_key] + n::parameter_value, 9.0f);
|
||||
tick();
|
||||
board.memory(board.grip_nodes[number_key] + n::parameter_value, &number, 4);
|
||||
check(number == 9 && saved.empty(), "and one the game changed is not put back");
|
||||
|
||||
// A recipe on its way: nothing is written.
|
||||
holds = {{"glow", Part::grip, graphic_colour, Kind::colour, {0, 0, 1}}};
|
||||
auto found = board.walk();
|
||||
found.pending = true;
|
||||
const auto before = writes;
|
||||
detail::apply(saved, holds, found, write, publish);
|
||||
check(writes == before, "Nothing is written while the board is being dressed");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
walks_the_board();
|
||||
refuses_what_picks_the_look();
|
||||
holds_and_puts_back();
|
||||
std::cout << "board material tests passed\n";
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
#include "board_material.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include "Engine/Core/Platform/memory.h"
|
||||
#include "Extension/Multiplayer/Remote/native_pose_layout.h"
|
||||
#include "Extension/Multiplayer/Remote/native_skater.h"
|
||||
#include <Windows.h>
|
||||
#include <format>
|
||||
|
||||
namespace dingosdk::board_material {
|
||||
namespace {
|
||||
struct State {
|
||||
std::uintptr_t base{}, client{};
|
||||
std::vector<detail::Hold> holds;
|
||||
std::vector<detail::Saved> saved;
|
||||
std::optional<bool> functions; // the material publisher's bytes, once checked
|
||||
std::uint64_t reported{};
|
||||
};
|
||||
State& state() {
|
||||
static auto* value = new State; // what is held may still be out at exit
|
||||
return *value;
|
||||
}
|
||||
const auto peek = [](std::uintptr_t address, void* out, std::size_t size) { return memory::peek_bytes(address, out, size); };
|
||||
|
||||
bool write_value(std::uintptr_t material, std::uintptr_t node, const float* value, std::size_t bytes) noexcept {
|
||||
__try {
|
||||
std::memcpy(reinterpret_cast<void*>(node + addr::native_cosmetics::parameter_value), value, bytes);
|
||||
*reinterpret_cast<std::uint8_t*>(material + addr::native_cosmetics::material_changed) = 1;
|
||||
return true;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
|
||||
}
|
||||
bool publish_materials(std::uintptr_t base, std::uintptr_t item) noexcept {
|
||||
__try {
|
||||
reinterpret_cast<void (*)(std::uintptr_t, int)>(base + addr::native_cosmetics::publish_materials)(item, 0);
|
||||
return true;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return false; }
|
||||
}
|
||||
bool functions_match(State& s) {
|
||||
if (!s.functions) {
|
||||
std::array<unsigned char, addr::native_cosmetics::publish_materials_prefix.size()> bytes{};
|
||||
s.functions = peek(s.base + addr::native_cosmetics::publish_materials, bytes.data(), bytes.size()) &&
|
||||
bytes == addr::native_cosmetics::publish_materials_prefix;
|
||||
if (!*s.functions)
|
||||
logging::log(logging::Level::warning, logging::Channel::customization,
|
||||
"Board parameters: the material publisher differs from the supported build; plugins cannot set them.");
|
||||
}
|
||||
return *s.functions;
|
||||
}
|
||||
// The local player's board as it is now (a change of cosmetics can replace it); none out of play.
|
||||
std::optional<detail::Found> board(State& s) {
|
||||
if (!s.base || !s.client) return std::nullopt;
|
||||
try {
|
||||
const auto local = multiplayer::capture_local(s.base, s.client, false);
|
||||
if (!local.ready) return std::nullopt;
|
||||
const auto entity = multiplayer::read_native_board(peek, s.base, local.entity).entity;
|
||||
if (!entity) return std::nullopt;
|
||||
return detail::walk(peek, s.base, entity);
|
||||
} catch (const std::exception& error) {
|
||||
// (Once in a while: a board being built reads as unfinished for a few ticks.)
|
||||
if (const auto now = GetTickCount64(); now - s.reported > 10000) {
|
||||
s.reported = now;
|
||||
logging::log(logging::Level::debug, logging::Channel::customization, "Board parameters: {}", error.what());
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::optional<std::vector<Parameter>> read(Part part) {
|
||||
auto& s = state();
|
||||
const auto found = board(s);
|
||||
if (!found) return std::nullopt;
|
||||
std::vector<Parameter> out;
|
||||
// The first of the part's materials: another (a variant) holds the same keys.
|
||||
for (const auto& material : found->materials)
|
||||
if (material.part == part) {
|
||||
for (const auto& node : material.nodes) out.push_back(node.parameter);
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string set(std::string_view owner, Part part, std::uint64_t key, const Colour& value, bool colour) {
|
||||
auto& s = state();
|
||||
const auto name = part_names[static_cast<std::size_t>(part)];
|
||||
const auto found = board(s);
|
||||
if (!found) return "there is no board in play";
|
||||
Kind kind{};
|
||||
if (auto why = detail::refusal(*found, part, key, value, colour, kind); !why.empty()) return why;
|
||||
if (!functions_match(s)) return "board parameters are unavailable with this game build";
|
||||
const auto held = std::find_if(s.holds.begin(), s.holds.end(), [&](const detail::Hold& h) { return h.part == part && h.key == key; });
|
||||
if (held != s.holds.end() && held->owner != owner) return std::format("{} holds the {}'s {:016x}", held->owner, name, key);
|
||||
if (held == s.holds.end() &&
|
||||
static_cast<std::size_t>(std::count_if(s.holds.begin(), s.holds.end(), [&](const detail::Hold& h) { return h.owner == owner; })) >= most_held)
|
||||
return std::format("a plugin holds at most {} board parameters", most_held);
|
||||
const Colour kept = colour ? value : Colour{value[0], 0, 0};
|
||||
if (held != s.holds.end()) held->value = kept;
|
||||
else s.holds.push_back({std::string(owner), part, key, kind, kept});
|
||||
return {};
|
||||
}
|
||||
|
||||
void clear(std::string_view owner, std::optional<Part> part, std::optional<std::uint64_t> key) {
|
||||
auto& holds = state().holds;
|
||||
std::erase_if(holds, [&](const detail::Hold& h) { return h.owner == owner && (!part || h.part == *part) && (!key || h.key == *key); });
|
||||
}
|
||||
|
||||
void tick(std::uintptr_t base, std::uintptr_t client) noexcept {
|
||||
try {
|
||||
auto& s = state();
|
||||
s.base = base, s.client = client;
|
||||
if (s.holds.empty() && s.saved.empty()) return;
|
||||
if (!functions_match(s)) return;
|
||||
const auto found = board(s);
|
||||
if (!found) return;
|
||||
auto write = [](std::uintptr_t material, std::uintptr_t node, Kind kind, const Colour& value) {
|
||||
return write_value(material, node, value.data(), kind == Kind::colour ? sizeof(Colour) : sizeof(float));
|
||||
};
|
||||
auto publish = [&](std::uintptr_t item) { (void)publish_materials(s.base, item); };
|
||||
detail::apply(s.saved, s.holds, *found, write, publish);
|
||||
} catch (...) {}
|
||||
}
|
||||
} // namespace dingosdk::board_material
|
||||
@@ -0,0 +1,265 @@
|
||||
#pragma once
|
||||
#include "developer_board_material.h"
|
||||
#include <cstring>
|
||||
#include <format>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// The shader parameters of the local player's own board as plugins read and set them
|
||||
// (reskate.board.parameters): each part's material instance holds a few values of its own (read
|
||||
// from the running game, 2026-10-22: the deck and its grip eight each, the trucks and wheels three),
|
||||
// which the renderer takes for that board alone. A plugin may hold a colour or a number there; an
|
||||
// integer or a switch picks how the board is drawn (the foil looks the store sells among them), so
|
||||
// those are only read. Nothing of the board's items changes and nobody else sees it: other players
|
||||
// draw this board from its cosmetics, as they are.
|
||||
namespace dingosdk::board_material {
|
||||
enum class Part : std::uint8_t { deck, grip, trucks, wheels };
|
||||
inline constexpr std::array<std::string_view, 4> part_names{"deck", "grip", "trucks", "wheels"};
|
||||
enum class Kind : std::uint8_t { colour, number, integer, toggle, other };
|
||||
using Colour = std::array<float, 3>;
|
||||
struct Parameter {
|
||||
std::uint64_t key{};
|
||||
Kind kind{Kind::other};
|
||||
Colour value{}; // a colour, or a number in [0]
|
||||
std::int32_t integer{}; // an integer, or a switch's 0 or 1
|
||||
};
|
||||
// The two colours known by what they colour (the developer board animates them): the graphic's and
|
||||
// the base's.
|
||||
inline constexpr std::uint64_t graphic_colour = developer_board_detail::graphic_color;
|
||||
inline constexpr std::uint64_t base_colour = developer_board_detail::base_color;
|
||||
// What a plugin may set: a colour of 0 to 1 a channel, as the board's own are, or a number.
|
||||
inline constexpr float most_number = 100.0f;
|
||||
inline constexpr std::size_t most_held = 32;
|
||||
|
||||
// Client thread. The part's parameters as its material holds them now; none without a board.
|
||||
std::optional<std::vector<Parameter>> read(Part part);
|
||||
// Held by `owner` and written every tick until cleared: a colour (all three), or a number
|
||||
// (`value[0]`, `colour` false). Empty when held, else why not.
|
||||
std::string set(std::string_view owner, Part part, std::uint64_t key, const Colour& value, bool colour);
|
||||
// What `owner` holds of that part and key (all its parts, all its keys) put back.
|
||||
void clear(std::string_view owner, std::optional<Part> part = std::nullopt, std::optional<std::uint64_t> key = std::nullopt);
|
||||
// Each client tick while the skater is in play: what is held written, what is let go put back.
|
||||
void tick(std::uintptr_t base, std::uintptr_t client) noexcept;
|
||||
|
||||
namespace detail {
|
||||
struct Node {
|
||||
std::uintptr_t address{};
|
||||
Parameter parameter;
|
||||
};
|
||||
struct Material {
|
||||
std::uintptr_t item{}, address{};
|
||||
Part part{};
|
||||
std::vector<Node> nodes;
|
||||
};
|
||||
struct Found {
|
||||
std::vector<Material> materials;
|
||||
bool pending{}; // a recipe on its way: nothing is written
|
||||
};
|
||||
// Each part's named material on the board's slot controllers; the deck slot holds two.
|
||||
struct Target {
|
||||
std::string_view material;
|
||||
std::size_t slot;
|
||||
Part part;
|
||||
};
|
||||
inline constexpr std::array<Target, 4> targets{{
|
||||
{"Deck_Mat", 0, Part::deck},
|
||||
{"DeckTop_Mat", 0, Part::grip},
|
||||
{"Truck_Mat", 1, Part::trucks},
|
||||
{"Wheel_mat", 2, Part::wheels},
|
||||
}};
|
||||
inline constexpr std::size_t most_nodes = 512;
|
||||
|
||||
inline Kind kind_of(std::uint32_t type_hash, std::uint16_t type_size, const developer_hoodie_detail::ParameterKey& key, bool live) noexcept {
|
||||
namespace n = addr::native_cosmetics;
|
||||
if (!live || key.count != 1 || key.size != type_size) return Kind::other;
|
||||
if (type_hash == n::color_rgb_type_hash && type_size == sizeof(Colour)) return Kind::colour;
|
||||
if (type_hash == n::float_type_hash && type_size == 4) return Kind::number;
|
||||
if (type_hash == n::integer_type_hash && type_size == 4) return Kind::integer;
|
||||
if (type_hash == n::boolean_type_hash && type_size == 4) return Kind::toggle;
|
||||
return Kind::other;
|
||||
}
|
||||
template <class Memory> std::vector<Node> parameters(Memory& m, std::uintptr_t material) {
|
||||
namespace n = addr::native_cosmetics;
|
||||
const auto buckets = m.ptr(material, n::material_parameters);
|
||||
const auto size = m.template get<std::uint32_t>(material, n::material_parameter_buckets);
|
||||
const auto total = m.template get<std::uint32_t>(material, n::material_parameter_count);
|
||||
std::vector<Node> out;
|
||||
if (!buckets || !size) return out;
|
||||
m.check(size <= 1024 && total <= most_nodes, "Board parameter map exceeds bounds.");
|
||||
const auto sentinel = m.ptr(buckets, size * 8ULL);
|
||||
for (std::uint32_t b = 0; b < size; ++b) {
|
||||
std::uint32_t visited{};
|
||||
for (auto node = m.ptr(buckets, b * 8ULL); node && node != sentinel; node = m.ptr(node, n::parameter_next)) {
|
||||
m.check(++visited <= total && out.size() < total, "Board parameter chain exceeds bounds.");
|
||||
const auto key = m.template get<developer_hoodie_detail::ParameterKey>(node);
|
||||
const auto raw = m.template get<std::array<std::uint32_t, 4>>(node, n::parameter_value);
|
||||
const auto type = m.ptr(node, n::parameter_type);
|
||||
Parameter p;
|
||||
p.key = key.hash;
|
||||
if (type) {
|
||||
const auto info = m.ptr(type);
|
||||
// A type's information is in the game's image, as the board's colour descriptor is.
|
||||
if (info >= m.base && info < m.base + 0x09144000)
|
||||
p.kind = kind_of(m.template get<std::uint32_t>(info), m.template get<std::uint16_t>(info, 6), key,
|
||||
(m.template get<std::uint32_t>(node, n::parameter_flags) & 1) != 0);
|
||||
}
|
||||
if (p.kind == Kind::colour || p.kind == Kind::number) {
|
||||
std::memcpy(p.value.data(), raw.data(), p.kind == Kind::colour ? sizeof(Colour) : sizeof(float));
|
||||
m.check(std::all_of(p.value.begin(), p.value.end(), [](float v) { return std::isfinite(v); }), "Board parameter is invalid.");
|
||||
} else if (p.kind == Kind::integer) {
|
||||
p.integer = static_cast<std::int32_t>(raw[0]);
|
||||
} else if (p.kind == Kind::toggle) {
|
||||
p.integer = raw[0] != 0;
|
||||
}
|
||||
out.push_back({node, p});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// The board actor's parts (developer_board_detail::materials walks the same way to its colours).
|
||||
template <class Read> Found walk(Read& read, std::uintptr_t base, std::uintptr_t entity) {
|
||||
using namespace multiplayer;
|
||||
CosmeticMemory<Read&> m{read, base};
|
||||
Found out;
|
||||
m.check(m.ptr(entity) == base + addr::engine::board_entity_vtable, "Board actor type differs.");
|
||||
const auto component = m.component(entity);
|
||||
out.pending = m.template get<std::uint8_t>(component, 0x10a) || m.template get<std::uint8_t>(component, 0x10d);
|
||||
const auto appearance = read_native_component(read, entity, base + addr::engine::skater_appearance_vtable);
|
||||
const auto begin = m.ptr(appearance, 0x90), end = m.ptr(appearance, 0x98), capacity = m.ptr(appearance, 0xa0);
|
||||
m.check(begin <= end && end <= capacity && (end - begin) % 8 == 0 && (capacity - begin) / 8 <= 64 && (begin || !capacity),
|
||||
"Board controller array exceeds bounds.");
|
||||
const auto& slots = developer_board_detail::slots;
|
||||
std::array<bool, developer_board_detail::slots.size()> seen{};
|
||||
for (auto entry = begin; entry < end; entry += 8) {
|
||||
const auto item = m.ptr(entry);
|
||||
if (!item) continue;
|
||||
m.check(m.ptr(item, 0x48) == appearance + 0x40, "Board controller ownership differs.");
|
||||
const auto assigned = std::find(slots.begin(), slots.end(), m.text(m.ptr(item, 0x30)));
|
||||
if (assigned == slots.end()) continue;
|
||||
const auto slot = static_cast<std::size_t>(assigned - slots.begin());
|
||||
m.check(!seen[slot], "Board has duplicate slot controllers.");
|
||||
seen[slot] = true;
|
||||
if (!(m.template get<std::uint32_t>(item, 0xb0) & 2)) { out.pending = true; continue; }
|
||||
const auto buckets = m.ptr(item, 0x1f0);
|
||||
const auto size = m.template get<std::uint32_t>(item, 0x1f8), total = m.template get<std::uint32_t>(item, 0x1fc);
|
||||
if (!total) { out.pending = true; continue; }
|
||||
m.check(buckets && size && size <= 256 && total <= 128, "Board material map exceeds bounds.");
|
||||
const auto sentinel = m.ptr(buckets, size * 8ULL);
|
||||
for (const auto& target : targets) {
|
||||
if (target.slot != slot) continue;
|
||||
const auto hash = developer_hoodie_detail::material_hash(target.material);
|
||||
auto node = m.ptr(buckets, (hash % size) * 8ULL);
|
||||
std::uint32_t visited{};
|
||||
for (; node && node != sentinel && visited < total; ++visited, node = m.ptr(node, 0x28))
|
||||
if (m.template get<std::uint32_t>(node) == hash) break;
|
||||
if (!node || node == sentinel || visited == total) continue;
|
||||
std::array<std::uintptr_t, 4> variants{};
|
||||
for (std::size_t v = 0; v < variants.size(); ++v) {
|
||||
const auto material = m.ptr(node, 8 + v * 8);
|
||||
if (!material || std::find(variants.begin(), variants.begin() + v, material) != variants.begin() + v) continue;
|
||||
variants[v] = material;
|
||||
out.materials.push_back({item, material, target.part, parameters(m, material)});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
inline const char* kind_name(Kind kind) noexcept {
|
||||
switch (kind) {
|
||||
case Kind::colour: return "a colour";
|
||||
case Kind::number: return "a number";
|
||||
case Kind::integer: return "an integer";
|
||||
case Kind::toggle: return "a switch";
|
||||
default: return "of a kind plugins do not set";
|
||||
}
|
||||
}
|
||||
// Whether `value` may be held on that key of the part: empty, and its kind in `kind`, else why not.
|
||||
inline std::string refusal(const Found& found, Part part, std::uint64_t key, const Colour& value, bool colour, Kind& kind) {
|
||||
const auto name = part_names[static_cast<std::size_t>(part)];
|
||||
std::optional<Kind> has;
|
||||
for (const auto& material : found.materials)
|
||||
if (material.part == part)
|
||||
for (const auto& node : material.nodes)
|
||||
if (node.parameter.key == key) has = node.parameter.kind;
|
||||
if (!has) return std::format("the {} has no parameter {:016x}", name, key);
|
||||
if (*has != Kind::colour && *has != Kind::number)
|
||||
return std::format("{:016x} is {}, which picks how the board is drawn; plugins only read it", key, kind_name(*has));
|
||||
if ((*has == Kind::colour) != colour) return std::format("{:016x} is {}", key, kind_name(*has));
|
||||
if (colour && !std::all_of(value.begin(), value.end(), [](float c) { return c >= 0 && c <= 1; }))
|
||||
return "a colour's channels are 0 to 1";
|
||||
if (!colour && !(std::abs(value[0]) <= most_number)) return std::format("a number is -{0} to {0}", most_number);
|
||||
kind = *has;
|
||||
return {};
|
||||
}
|
||||
// A value a plugin holds, and what was found where it was written.
|
||||
struct Hold {
|
||||
std::string owner;
|
||||
Part part{};
|
||||
std::uint64_t key{};
|
||||
Kind kind{};
|
||||
Colour value{};
|
||||
};
|
||||
struct Saved {
|
||||
std::uintptr_t material{}, node{};
|
||||
Kind kind{};
|
||||
Colour original{}, last{};
|
||||
std::uint8_t settling{}; // publishes still owed for `last`
|
||||
};
|
||||
inline bool same(Kind kind, const Colour& a, const Colour& b) noexcept {
|
||||
return kind == Kind::colour ? a == b : a[0] == b[0];
|
||||
}
|
||||
// One tick, as the developer board's colours go (developer_hoodie_detail::animate): what is held
|
||||
// written where it is not yet, and published until it shows (the renderer shows a value one publish
|
||||
// late); what is no longer held put back while it is still ours, a value changed by the game since
|
||||
// left as the game has it. write(material, node, kind, value) -> written; publish(item).
|
||||
template <class Write, class Publish>
|
||||
void apply(std::vector<Saved>& saved, const std::vector<Hold>& holds, const Found& live, Write& write, Publish& publish) {
|
||||
if (live.pending) return;
|
||||
using developer_hoodie_detail::settle_publishes;
|
||||
std::vector<Saved> next;
|
||||
std::vector<std::uintptr_t> shown;
|
||||
for (const auto& material : live.materials) {
|
||||
bool show{};
|
||||
for (const auto& node : material.nodes) {
|
||||
const auto& p = node.parameter;
|
||||
if (p.kind != Kind::colour && p.kind != Kind::number) continue;
|
||||
const auto hold = std::find_if(holds.begin(), holds.end(), [&](const Hold& h) {
|
||||
return h.part == material.part && h.key == p.key && h.kind == p.kind;
|
||||
});
|
||||
const auto at = std::find_if(saved.begin(), saved.end(), [&](const Saved& s) {
|
||||
return s.material == material.address && s.node == node.address && s.kind == p.kind;
|
||||
});
|
||||
const Saved* was = at == saved.end() ? nullptr : &*at;
|
||||
const bool ours = was && same(p.kind, p.value, was->last);
|
||||
const auto original = ours ? was->original : p.value;
|
||||
if (hold != holds.end()) {
|
||||
if (same(p.kind, p.value, hold->value)) {
|
||||
const std::uint8_t left = ours ? was->settling : 0;
|
||||
next.push_back({material.address, node.address, p.kind, original, hold->value, static_cast<std::uint8_t>(left ? left - 1 : 0)});
|
||||
show = show || left != 0;
|
||||
} else if (write(material.address, node.address, p.kind, hold->value)) {
|
||||
next.push_back({material.address, node.address, p.kind, original, hold->value, settle_publishes});
|
||||
show = true;
|
||||
} else if (was) {
|
||||
next.push_back(*was);
|
||||
}
|
||||
} else if (was && (ours || same(p.kind, p.value, was->original))) {
|
||||
// Back to the board's own value, and published until it shows.
|
||||
const bool first = !same(p.kind, was->last, was->original);
|
||||
const bool back = same(p.kind, p.value, was->original) || write(material.address, node.address, p.kind, was->original);
|
||||
const std::uint8_t left = first ? settle_publishes : was->settling ? was->settling - 1 : 0;
|
||||
if (!back) next.push_back(*was);
|
||||
else if (left) next.push_back({material.address, node.address, p.kind, was->original, was->original, left});
|
||||
show = show || back;
|
||||
}
|
||||
}
|
||||
if (show && std::find(shown.begin(), shown.end(), material.item) == shown.end()) shown.push_back(material.item);
|
||||
}
|
||||
saved = std::move(next);
|
||||
for (const auto item : shown) publish(item);
|
||||
}
|
||||
} // namespace detail
|
||||
} // namespace dingosdk::board_material
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "Extension/UI/Overlay/overlay.h"
|
||||
#include "Extension/Multiplayer/developer_identity.h"
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -64,6 +65,9 @@ void publish_custom_nametags(std::uintptr_t base, std::vector<NametagPlayer> pla
|
||||
bool show_bubbles, float bubble_distance, float name_distance = 120.f,
|
||||
bool dots = true) noexcept;
|
||||
void set_custom_nametags_enabled(bool enabled) noexcept;
|
||||
// Held off for now (a plugin's reskate.session.nametags(false), filming): no names drawn over the
|
||||
// players while it lasts, whatever the nametag setting is; chat bubbles still show.
|
||||
inline std::atomic<bool> nametags_held_off{false};
|
||||
bool custom_nametags_enabled() noexcept;
|
||||
// The overlay's feed (any thread): nothing while off, stale, or while the game hides nametags.
|
||||
overlay::Nametags custom_nametags();
|
||||
|
||||
@@ -556,6 +556,9 @@ NativeFrame capture_local(std::uintptr_t base, std::uintptr_t client, bool captu
|
||||
cache = {base, client, frame.context, player, frame.entity, frame.parent, component,
|
||||
frame.board_entity};
|
||||
frame.ready = true;
|
||||
// The pose as it is sent, after anyone who changes only what is drawn of the board has had it.
|
||||
if (capture_pose)
|
||||
if (const auto filter = shared().sent_pose_filter.load(std::memory_order_acquire)) filter(component, frame.pose);
|
||||
} catch (const std::exception &e) {
|
||||
cache = {};
|
||||
frame.detail = e.what();
|
||||
@@ -612,6 +615,9 @@ void set_render_pose_listener(RenderPosePublished listener) noexcept {
|
||||
void set_animation_evaluated_listener(AnimationEvaluated listener) noexcept {
|
||||
shared().evaluated_listener.store(listener, std::memory_order_release);
|
||||
}
|
||||
void set_local_sent_pose_filter(LocalSentPoseFilter filter) noexcept {
|
||||
shared().sent_pose_filter.store(filter, std::memory_order_release);
|
||||
}
|
||||
void set_local_pose_filter(LocalPoseFilter filter) noexcept {
|
||||
shared().pose_filter.store(filter, std::memory_order_release);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,11 @@ void set_animation_evaluated_listener(AnimationEvaluated listener) noexcept;
|
||||
// what other players receive of the local skater.
|
||||
using LocalPoseFilter = void (*)(std::uintptr_t component, std::vector<Transform> &skater) noexcept;
|
||||
void set_local_pose_filter(LocalPoseFilter filter) noexcept;
|
||||
// Then, with the whole pose capture_local returns (the skater and the board), so a change to what is
|
||||
// drawn of the board (Extension/Skater/board_pose.h, a bone hidden from everyone) is in what the
|
||||
// others see too. One listener; nullptr for none. Client thread.
|
||||
using LocalSentPoseFilter = void (*)(std::uintptr_t component, Pose &pose) noexcept;
|
||||
void set_local_sent_pose_filter(LocalSentPoseFilter filter) noexcept;
|
||||
// A pose override of the local skater (Extension/Skater/skater_animation.cpp): called after the
|
||||
// listener above, so it sees (and may change) the pose first person left.
|
||||
void set_pose_override_listener(AnimationEvaluated listener) noexcept;
|
||||
|
||||
@@ -58,6 +58,7 @@ struct HookState {
|
||||
std::atomic<EntityDestroyed> destroyed_listener{};
|
||||
std::atomic<AnimationEvaluated> evaluated_listener{};
|
||||
std::atomic<LocalPoseFilter> pose_filter{};
|
||||
std::atomic<LocalSentPoseFilter> sent_pose_filter{};
|
||||
// A pose override of the local skater (Extension/Skater/skater_animation.cpp), after the one above.
|
||||
std::atomic<AnimationEvaluated> override_listener{};
|
||||
std::atomic<RenderPosePublished> override_render_listener{};
|
||||
|
||||
@@ -485,7 +485,8 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui
|
||||
// each frame.
|
||||
std::vector<NametagPlayer> nametags;
|
||||
const bool bubbles = s.chat_bubbles;
|
||||
const bool labels = s.nametags || bubbles;
|
||||
const bool names = s.nametags && !nametags_held_off.load(std::memory_order_relaxed);
|
||||
const bool labels = s.nametags || bubbles; // (published even while names are held off, so none linger)
|
||||
if (labels) refresh_friends(s);
|
||||
// The newest chat lines from `sender` still inside the bubble duration, oldest first,
|
||||
// filtered the same way the chat panel filters them, each with the opacity it has left
|
||||
@@ -821,7 +822,7 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui
|
||||
if (labels)
|
||||
publish_custom_nametags(s.base, std::move(nametags),
|
||||
local.ready ? std::optional(local.pose.root.position) : std::nullopt,
|
||||
s.nametags, bubbles, s.chat_bubbles_distance, s.nametag_distance,
|
||||
names, bubbles, s.chat_bubbles_distance, s.nametag_distance,
|
||||
s.nametag_dots);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
@@ -45,6 +45,13 @@ struct FakeGame final : Game {
|
||||
return {};
|
||||
}
|
||||
void outfit_clear(std::string_view plugin, std::string_view slot) override { calls.push_back(std::format("outfit clear {} {}", plugin, slot)); }
|
||||
std::string camera_cycle(std::string_view plugin, CycleCamera place) override {
|
||||
calls.push_back(std::format("cycle {} {}", plugin, place == CycleCamera::out ? "out" : place == CycleCamera::ready ? "ready" : "active"));
|
||||
return {};
|
||||
}
|
||||
std::vector<CycleNews> cycle_news;
|
||||
std::vector<CycleNews> take_camera_cycle() override { return std::exchange(cycle_news, {}); }
|
||||
void hide_nametags(std::string_view plugin, bool hidden) override { calls.push_back(std::format("nametags {} {}", plugin, hidden ? "off" : "on")); }
|
||||
std::set<std::string, std::less<>> installed; // items found by their whole ending, as Pack/<ending>
|
||||
std::string outfit_find(std::string_view ending) override {
|
||||
if (installed.contains(ending)) return "Pack/" + std::string(ending);
|
||||
@@ -60,8 +67,25 @@ struct FakeGame final : Game {
|
||||
calls.push_back(std::format("board {} {} {:.1f} {}", plugin, bone, value.position[1], world ? "world" : "local"));
|
||||
return {};
|
||||
}
|
||||
std::string board_hide(std::string_view plugin, int bone) override { calls.push_back(std::format("board hide {} {}", plugin, bone)); return {}; }
|
||||
std::string board_hide(std::string_view plugin, int bone, bool everyone) override {
|
||||
calls.push_back(std::format("board hide {} {}{}", plugin, bone, everyone ? " everyone" : ""));
|
||||
return {};
|
||||
}
|
||||
void board_park(std::string_view plugin, std::vector<int> bones) override { calls.push_back(std::format("board park {} {}", plugin, bones.size())); }
|
||||
std::optional<std::vector<BoardParameter>> board_parameters(int part) override {
|
||||
if (part != 1) return std::vector<BoardParameter>{};
|
||||
return std::vector<BoardParameter>{{0x873f48af36e50d59ULL, "colour", {.2f, .3f, .4f}, 0},
|
||||
{0x841c7d74837f1d39ULL, "number", {1, 0, 0}, 0},
|
||||
{0xb00abec403499469ULL, "integer", {}, 13},
|
||||
{0xa5f9a6b8098279adULL, "switch", {}, 1}};
|
||||
}
|
||||
std::string board_set_parameter(std::string_view plugin, int part, std::uint64_t key, std::array<float, 3> value, bool colour) override {
|
||||
calls.push_back(std::format("board parameter {} {} {:016x} {} {:.1f}", plugin, part, key, colour ? "colour" : "number", value[0]));
|
||||
return {};
|
||||
}
|
||||
void board_clear_parameter(std::string_view plugin, int part, std::optional<std::uint64_t> key) override {
|
||||
calls.push_back(std::format("board parameter clear {} {} {}", plugin, part, key ? std::format("{:016x}", *key) : "all"));
|
||||
}
|
||||
// The general services a game layer in Lua reads and drives (plugin_api_services.cpp).
|
||||
std::optional<SkaterTelemetry> telemetry;
|
||||
std::optional<SkaterTelemetry> skater_telemetry(std::string_view) override { return telemetry; }
|
||||
@@ -150,8 +174,18 @@ struct FakeGame final : Game {
|
||||
return {};
|
||||
}
|
||||
std::optional<MouseMotion> mouse_motion(std::string_view) override { return MouseMotion{3, -2, 1}; }
|
||||
std::string skater_pose(std::string_view plugin, const std::vector<JointOffset>& offsets, float weight) override {
|
||||
static const char* shown_name(Shown shown) { return shown == Shown::others ? "others" : shown == Shown::me ? "me" : "everyone"; }
|
||||
std::string skater_carry(std::string_view plugin, std::optional<Carry> c) override {
|
||||
calls.push_back(c ? std::format("carry {} {}{} {:.1f} {:.1f} {:.1f} front {:.2f} grip {} w {:.2f} crouch {:.2f} hunch {:.2f} turn {:.2f} step {:.2f}{}{} {}",
|
||||
plugin, c->left ? "left" : "right", c->both ? " both" : "", c->front[0], c->front[1], c->front[2],
|
||||
c->front_from_grip[2], c->worn.has_value(), c->weight, c->crouch, c->hunch, c->turn, c->step,
|
||||
c->still ? " still" : "", c->push_straightens ? " push" : "", shown_name(c->shown))
|
||||
: std::format("carry {} none", plugin));
|
||||
return {};
|
||||
}
|
||||
std::string skater_pose(std::string_view plugin, const std::vector<JointOffset>& offsets, float weight, Shown shown) override {
|
||||
std::string text = std::format("pose {} {:.2f}", plugin, weight);
|
||||
if (shown != Shown::everyone) text += std::format(" {}", shown_name(shown));
|
||||
auto sorted = offsets;
|
||||
std::sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { return a.joint < b.joint; });
|
||||
for (const auto& o : sorted) text += std::format(" {}:{:.2f},{:.2f},{:.2f},{:.2f}x{:.2f}", o.joint, o.rotation[0], o.rotation[1], o.rotation[2], o.rotation[3], o.scale);
|
||||
@@ -181,7 +215,8 @@ struct FakeGame final : Game {
|
||||
}
|
||||
std::string skater_reach(std::string_view plugin, std::optional<Reach> r) override {
|
||||
calls.push_back(r ? std::format("reach {} {} {:.1f} {:.1f} {:.1f} {} {:.2f}", plugin, r->left ? "left" : "right", r->at[0], r->at[1], r->at[2],
|
||||
r->rotation.has_value(), r->weight)
|
||||
r->rotation.has_value(), r->weight) +
|
||||
(r->shown != Shown::everyone ? std::format(" {}", shown_name(r->shown)) : std::string{})
|
||||
: std::format("reach {} none", plugin));
|
||||
return {};
|
||||
}
|
||||
@@ -227,6 +262,7 @@ struct FakeGame final : Game {
|
||||
return {};
|
||||
}
|
||||
std::optional<Vec3> board_velocity() override { return Vec3{1, -2, 3}; }
|
||||
std::optional<Vec3> board_spin() override { return Vec3{0, 4, 0}; }
|
||||
std::optional<SlamNews> last_slam() override { return SlamNews{7.5f, 12, 100}; }
|
||||
std::string wipeout(float force) override {
|
||||
calls.push_back(std::format("wipeout {:.1f}", force));
|
||||
@@ -510,6 +546,15 @@ struct FakeGame final : Game {
|
||||
calls.push_back(std::format("announce {} {}", plugin, text));
|
||||
return {};
|
||||
}
|
||||
std::vector<ServerPark> server_parks() override { return {{"street", "San Vansterdam", true, true, 12, 5}, {"plaza", "Isle of Grom", false, false, 0, 0}}; }
|
||||
std::string spawn_park(std::string_view plugin, std::string_view name, unsigned minutes) override {
|
||||
calls.push_back(std::format("park spawn {} {} {}", plugin, name, minutes));
|
||||
return name == "plaza" ? "plaza belongs on Isle of Grom, and the server is on San Vansterdam." : std::string{};
|
||||
}
|
||||
std::string remove_park(std::string_view plugin, std::string_view name) override {
|
||||
calls.push_back(std::format("park remove {} {}", plugin, name));
|
||||
return {};
|
||||
}
|
||||
std::optional<CameraView> camera_view() override { return CameraView{{1, 2, 3}, {0, 0, 1}, 60}; }
|
||||
std::string set_camera(std::string_view plugin, const std::array<float, 16>& pose, float fov) override {
|
||||
calls.push_back(std::format("camera {} {:.1f} {:.1f} {:.1f} back {:.2f} {:.2f} {:.2f} fov {:.0f}", plugin, pose[12], pose[13], pose[14],
|
||||
@@ -834,6 +879,10 @@ void a_permission_must_be_declared() {
|
||||
assert(not ok and err:find("needs the \"skater.control\" permission"), err)
|
||||
ok, err = pcall(reskate.mods.content, "parks")
|
||||
assert(not ok and err:find("needs the \"mods.read\" permission"), err)
|
||||
ok, err = pcall(reskate.board.set_parameter, "grip", "graphic", {r = 1, g = 0, b = 0})
|
||||
assert(not ok and err:find("needs the \"board\" permission"), err)
|
||||
ok, err = pcall(reskate.board.parameters, "deck")
|
||||
assert(not ok and err:find("needs the \"board\" permission"), err)
|
||||
reskate.console.print("never")
|
||||
)")});
|
||||
stopped(host, "bare", "raised an error");
|
||||
@@ -1348,6 +1397,71 @@ void plugins_reach_cruise_and_read_the_mouse() {
|
||||
"skater.animation, skater.read, skater.control, input and overlay are needed\n" + environment.all());
|
||||
}
|
||||
|
||||
// What the Filmer's port needs besides: something carried (seen by the others only), a pose only you
|
||||
// see, a step in the camera height cycle and its event, the nametags held off, the board hidden from
|
||||
// everyone, and a camcorder in the hand wear slot.
|
||||
void plugins_carry_and_take_a_step_in_the_camera_cycle() {
|
||||
TestEnvironment environment;
|
||||
auto& game = environment.fake;
|
||||
Host host(environment, Side::client);
|
||||
const std::string main = R"(
|
||||
local steps = {}
|
||||
reskate.on("camera_cycle", function(step) table.insert(steps, step) end)
|
||||
assert(reskate.skater.carry({at = vector.create(1, 2, 3), rotation = {0, 1, 0, 0}, both = true, front = vector.create(0, -0.143, -0.248),
|
||||
grip = {rotation = {0.5, -0.5, 0.5, 0.5}, offset = vector.create(0.12, 0.04, -0.03)}, crouch = 0.22, hunch = 1.0, turn = 0.5,
|
||||
step = 0.5, step_phase = 1.25, still = true, push_straightens = true, weight = 0.75, shown = "others"}))
|
||||
assert(reskate.skater.carry({hand = "left", at = vector.create(0, 1, 0), rotation = {0, 0, 0, 1}}))
|
||||
assert(reskate.skater.carry(nil))
|
||||
assert(not pcall(reskate.skater.carry, {at = vector.create(0, 1, 0)}), "a carry needs a rotation")
|
||||
assert(not pcall(reskate.skater.carry, {at = vector.create(0, 1, 0), rotation = {0, 0, 0, 1}, crouch = 1}), "a crouch is 0 to 0.6 m")
|
||||
assert(not pcall(reskate.skater.carry, {at = vector.create(0, 1, 0), rotation = {0, 0, 0, 1}, shown = "them"}), "shown is everyone, others or me")
|
||||
assert(reskate.skater.pose({[48] = {scale = 0}}, 1, {shown = "me"}))
|
||||
assert(reskate.skater.reach({at = vector.create(1, 2, 3), shown = "others"}))
|
||||
assert(reskate.camera.cycle("ready") and reskate.camera.cycle("active") and reskate.camera.cycle(nil))
|
||||
assert(not pcall(reskate.camera.cycle, "on"), "ready or active")
|
||||
reskate.session.nametags(false)
|
||||
reskate.session.nametags(true)
|
||||
assert(reskate.board.hide(2, {others = true}))
|
||||
assert(reskate.outfit.set("handwear_l", "Own_HandwearGlove_Gen_GloveFingerless_00001"))
|
||||
local ok, why = reskate.outfit.set("shoes", "Own_Shoes_00001")
|
||||
assert(not ok and why == "only tattoo, moles, eyewear and hand wear slots", why)
|
||||
assert(reskate.input.bind("toggle") and reskate.input.bind("slap", "Pad.LB"), "an action with no key of its own, and one with")
|
||||
reskate.on("tick", function()
|
||||
if #steps > 0 then reskate.log("steps " .. table.concat(steps, " ")) steps = {} end
|
||||
end)
|
||||
reskate.log("carried")
|
||||
)";
|
||||
host.apply({package("filmer", {{manifest_file, manifest(R"("skater.animation", "camera", "session.nametags", "board", "outfit", "input")")},
|
||||
{"plugin/main.lua", main}})});
|
||||
ticks(host, environment, 1);
|
||||
running(host, "filmer", environment);
|
||||
check(environment.logged("[filmer] carried"), "The calls work\n" + environment.all());
|
||||
check(game.called("carry filmer right both 1.0 2.0 3.0 front -0.25 grip true w 0.75 crouch 0.22 hunch 1.00 turn 0.50 step 0.50 still push others") &&
|
||||
game.called("carry filmer left 0.0 1.0 0.0 front 0.00 grip false w 1.00 crouch 0.00 hunch 0.00 turn 0.00 step 0.00 everyone") &&
|
||||
game.called("carry filmer none"),
|
||||
"Carried in both hands for the others, in one, and let go\n" + environment.all());
|
||||
check(game.called("pose filmer 1.00 me 48:0.00,0.00,0.00,1.00x0.00") && game.called("reach filmer right 1.0 2.0 3.0 false 1.00 others"),
|
||||
"A pose only you see, a reach only the others see\n" + environment.all());
|
||||
check(game.called("cycle filmer ready") && game.called("cycle filmer active") && game.called("cycle filmer out"), "In the cycle, on, and out");
|
||||
check(game.called("nametags filmer off") && game.called("nametags filmer on"), "The nametags held off and given back");
|
||||
check(game.called("board hide filmer 2 everyone"), "The board hidden from everyone");
|
||||
check(game.called("outfit filmer handwear_l Own_HandwearGlove_Gen_GloveFingerless_00001 0 false"), "A camcorder in the hand wear slot");
|
||||
check(game.binds.contains("filmer/toggle") && game.binds["filmer/toggle"] == 0 && game.binds["filmer/slap"] == combo_of("Pad.LB"),
|
||||
"An action the player puts on a key themselves");
|
||||
// The button's steps reach the plugin in the cycle, and nobody else.
|
||||
game.cycle_news = {{"filmer", true}, {"someone", false}, {"filmer", false}};
|
||||
ticks(host, environment, 2);
|
||||
check(environment.logged("[filmer] steps enter leave"), "The camera cycle's steps as events\n" + environment.all());
|
||||
// Each needs its permission.
|
||||
host.apply({package("sneaky", {{manifest_file, manifest()}, {"plugin/main.lua", R"(
|
||||
reskate.log(tostring(pcall(reskate.skater.carry, {at = vector.create(0, 0, 0), rotation = {0, 0, 0, 1}})) .. " " ..
|
||||
tostring(pcall(reskate.camera.cycle, "ready")) .. " " .. tostring(pcall(reskate.session.nametags, false)) .. " " ..
|
||||
tostring(pcall(reskate.on, "camera_cycle", function() end)))
|
||||
)"}})});
|
||||
ticks(host, environment, 1);
|
||||
check(environment.logged("[sneaky] false false false false"), "skater.animation, camera and session.nametags are needed\n" + environment.all());
|
||||
}
|
||||
|
||||
// Reading a skater's joints, and the physics tuning read and set.
|
||||
void plugins_read_joints_and_tune_physics() {
|
||||
TestEnvironment environment;
|
||||
@@ -1954,6 +2068,46 @@ void plugins_server_copies_are_the_authority() {
|
||||
running(player_host, "mode-p", player);
|
||||
}
|
||||
|
||||
// A dedicated server's park mods, for a server copy with server.parks: listed, spawned and removed
|
||||
// as the server's park-mod does. Not for a copy without the permission, nor for a player's copy.
|
||||
void plugins_server_copies_spawn_park_mods() {
|
||||
TestEnvironment server, player, blind;
|
||||
const std::string main = R"(
|
||||
reskate.console.command("parks", "x", function()
|
||||
local list = reskate.server.parks()
|
||||
assert(#list == 2 and list[1].name == "street" and list[1].here and list[1].spawned and list[1].objects == 12 and list[1].minutes_left == 5)
|
||||
assert(list[2].map == "Isle of Grom" and not list[2].here and not list[2].spawned)
|
||||
assert(reskate.server.park_spawn("street", 30))
|
||||
assert(reskate.server.park_spawn("street"))
|
||||
local ok, why = reskate.server.park_spawn("plaza")
|
||||
assert(not ok and why:find("belongs on Isle of Grom"), why)
|
||||
assert(reskate.server.park_remove("street"))
|
||||
assert(not pcall(reskate.server.park_spawn, "Street"), "a park mod's name, lower case")
|
||||
assert(not pcall(reskate.server.park_spawn, "street 5; map grom"), "one name and nothing else")
|
||||
assert(not pcall(reskate.server.park_spawn, "street", 0), "minutes 1 to 1440")
|
||||
assert(not pcall(reskate.server.park_spawn, "street", 1441), "minutes 1 to 1440")
|
||||
reskate.log("parks done")
|
||||
end)
|
||||
)";
|
||||
const auto parks = [&](const char* id, const char* permissions, const char* side) {
|
||||
return package(id, {{manifest_file, manifest(permissions, side)}, {"plugin/main.lua", main}});
|
||||
};
|
||||
Host server_host(server, Side::server), player_host(player, Side::client), blind_host(blind, Side::server);
|
||||
server_host.apply({parks("parks-s", R"("console", "server.parks")", "server")});
|
||||
player_host.apply({parks("parks-p", R"("console", "server.parks")", "client")});
|
||||
blind_host.apply({parks("parks-b", R"("console", "server")", "server")});
|
||||
for (auto* host : {&server_host, &player_host, &blind_host}) host->run_console("parks");
|
||||
check(server.logged("[parks-s] parks done"), "A server copy could not use the park mods\n" + server.all());
|
||||
check(server.fake.called("park spawn parks-s street 30") && server.fake.called("park spawn parks-s street 0") &&
|
||||
server.fake.called("park spawn parks-s plaza 0") && server.fake.called("park remove parks-s street"),
|
||||
"The spawns and removal did not reach the server, by the plugin's id");
|
||||
check(!server.fake.called("park spawn parks-s Street") && !server.fake.called("park spawn parks-s street 1441"), "A refused call reached the server");
|
||||
check(!player.logged("parks done") && player.all().find("server copy") != std::string::npos,
|
||||
"A player's copy used the server's park mods\n" + player.all());
|
||||
check(!blind.logged("parks done") && blind.all().find("server.parks") != std::string::npos,
|
||||
"A plugin without server.parks used the park mods\n" + blind.all());
|
||||
}
|
||||
|
||||
// Clips on the skater's body: from the package or recorded, played, replacing a trick.
|
||||
void plugins_animate_the_skater() {
|
||||
TestEnvironment environment;
|
||||
@@ -2110,10 +2264,27 @@ void the_wider_set_reaches_the_game() {
|
||||
assert(reskate.board.hide(3))
|
||||
reskate.board.park({12, 13, 14, 15})
|
||||
assert(reskate.board.toss({12, 4}, {velocity = vector.create(1, 3, 0), spin = vector.create(0, 5, 0), ground = -2}))
|
||||
local grip = reskate.board.parameters("grip")
|
||||
assert(#grip == 4 and grip[1].key == "873f48af36e50d59" and grip[1].name == "graphic" and grip[1].type == "colour", "the grip's parameters")
|
||||
assert(math.abs(grip[1].value.g - 0.3) < 1e-6 and grip[2].value == 1 and grip[2].name == nil, "a colour and a number")
|
||||
assert(grip[3].type == "integer" and grip[3].value == 13 and grip[4].type == "switch" and grip[4].value == true, "an integer and a switch")
|
||||
assert(#reskate.board.parameters("wheels") == 0)
|
||||
assert(reskate.board.set_parameter("grip", "graphic", {r = 1, g = 0, b = 0}))
|
||||
assert(reskate.board.set_parameter("deck", "841C7D74837F1D39", 2.5))
|
||||
assert(not pcall(reskate.board.parameters, "rail"), "a part is one of four")
|
||||
assert(not pcall(reskate.board.set_parameter, "grip", "123", 1), "a key is 16 hex digits")
|
||||
assert(not pcall(reskate.board.set_parameter, "grip", "graphic", {r = 1}), "a colour has three channels")
|
||||
assert(not pcall(reskate.board.set_parameter, "grip", "graphic", math.huge), "a value is finite")
|
||||
reskate.board.clear_parameter("grip", "base")
|
||||
reskate.board.clear_parameter()
|
||||
reskate.log("deck fine")
|
||||
end)
|
||||
reskate.console.command("physics", "x", function()
|
||||
assert(reskate.board.motion().velocity.y == -2)
|
||||
local m = reskate.board.motion()
|
||||
assert(m.spin.y == 4, "the board's spin")
|
||||
assert(m.place.position.y == 0 and m.place.rotation[4] == 1, "where the board is")
|
||||
assert(math.abs(m.deck.position.y - 0.1) < 1e-6, "and its deck, on the board's place")
|
||||
assert(reskate.skater.wipeout(2))
|
||||
local again, two = reskate.skater.wipeout(2)
|
||||
assert(not again and two:find("2 s"), "one wipeout every 2 s")
|
||||
@@ -2236,6 +2407,9 @@ void the_wider_set_reaches_the_game() {
|
||||
check(environment.logged("[wide] deck fine") && game.called("board wide 12 2.0 world") && game.called("board hide wide 3") &&
|
||||
game.called("board park wide 4") && game.called("board toss wide 2 3 -2"),
|
||||
"A plugin changes what is drawn of the board\n" + environment.all());
|
||||
check(game.called("board parameter wide 1 873f48af36e50d59 colour 1.0") && game.called("board parameter wide 0 841c7d74837f1d39 number 2.5") &&
|
||||
game.called("board parameter clear wide 1 8c46cd354b18a9e2") && game.called("board parameter clear wide -1 all"),
|
||||
"and holds its shader parameters\n" + environment.all());
|
||||
host.run_console("physics");
|
||||
check(environment.logged("[wide] physics fine") && game.called("wipeout 2.0"), "The board's motion reads, and a wipeout is asked for\n" + environment.all());
|
||||
ticks(host, environment, 10);
|
||||
@@ -3178,6 +3352,77 @@ void the_throwdowns_page_lists_the_stock_cards_and_plugin_modes() {
|
||||
check(again.revision() > before && again.definitions().size() == 3 && again.definitions()[0].id == "native.skate", "S.K.A.T.E. is back when it goes");
|
||||
}
|
||||
|
||||
// The Filmer (contrib/plugins/filmer): an AI filmer follows a skater riding a carving line, then you film
|
||||
// someone, with your skater carrying the camcorder for the others; all within the 2 ms a tick a plugin has.
|
||||
void filmer_follows_and_films() {
|
||||
TestEnvironment environment;
|
||||
auto& game = environment.fake;
|
||||
const std::string plugin = "filmer";
|
||||
Host host(environment, Side::client);
|
||||
host.apply({contributed(plugin)});
|
||||
running(host, plugin, environment);
|
||||
SkaterTelemetry t;
|
||||
t.skater = true;
|
||||
t.physics_state = 100;
|
||||
t.speed = 6;
|
||||
game.telemetry = t;
|
||||
environment.local_skater = SkaterState{{0, 0.9f, 0}, 0, 6, 0, false, 0};
|
||||
host.set_level("sanvan");
|
||||
const auto ride = [&](int count) {
|
||||
for (int i = 0; i < count; ++i) {
|
||||
auto& skater = *environment.local_skater;
|
||||
static int step = 0;
|
||||
skater.heading = 0.4f * std::sin(static_cast<float>(++step) * 0.012f);
|
||||
skater.position[0] += std::sin(skater.heading) * 6.0f * 0.0167f;
|
||||
skater.position[2] += std::cos(skater.heading) * 6.0f * 0.0167f;
|
||||
game.telemetry->position = skater.position;
|
||||
game.telemetry->heading = skater.heading * 57.29578f;
|
||||
ticks(host, environment, 1);
|
||||
}
|
||||
};
|
||||
check(host.run_console("filmer mode follow"), "filmer mode follow");
|
||||
ride(900);
|
||||
running(host, plugin, environment);
|
||||
check(!environment.logged(".lua:"), "The Filmer follows without an error\n" + environment.all());
|
||||
check(game.called("camera filmer"), "It shows its camera\n" + environment.all());
|
||||
check(game.called("look filmer 1.00 100"), "with the VX look over the fisheye\n" + environment.all());
|
||||
check(game.called("cycle filmer active"), "in the camera height cycle, on");
|
||||
{
|
||||
// Where the AI filmer's lens is: behind the skater (never in front), a filmer's distance off, low.
|
||||
float x{}, y{}, z{};
|
||||
bool found = false;
|
||||
for (auto it = game.calls.rbegin(); it != game.calls.rend() && !found; ++it)
|
||||
if (it->starts_with("camera filmer ")) found = static_cast<bool>(std::istringstream(it->substr(14)) >> x >> y >> z);
|
||||
const auto& at = environment.local_skater->position;
|
||||
const float heading = environment.local_skater->heading;
|
||||
const float dx = x - at[0], dz = z - at[2];
|
||||
const float away = std::sqrt(dx * dx + dz * dz), along = dx * std::sin(heading) + dz * std::cos(heading);
|
||||
std::cout << "Filmer follow: the lens " << away << " m from the skater, " << along << " m along, " << y << " m up\n";
|
||||
check(found && away > 0.8f && away < 6.0f && along < 0.0f && y > 0.0f && y < 1.5f,
|
||||
"The AI filmer rides behind the skater, a filmer's distance off, low\n" + environment.all());
|
||||
}
|
||||
const auto follow_ms = status_of(host, plugin).tick_ms;
|
||||
std::cout << "Filmer follow: " << follow_ms << " ms a tick (its budget is 2 ms)\n";
|
||||
check(follow_ms < 2.0, "The Filmer follows within its 2 ms a tick");
|
||||
// You film the skater ahead of you, holding the camcorder out for the others to see.
|
||||
environment.lines.clear();
|
||||
check(host.run_console("filmer mode film"), "filmer mode film");
|
||||
ride(600);
|
||||
running(host, plugin, environment);
|
||||
check(!environment.logged(".lua:"), "You film without an error\n" + environment.all());
|
||||
check(game.called("carry filmer right both") && game.called(" push others") && game.called("hide skater filmer on") &&
|
||||
game.called("board hide filmer 2"),
|
||||
"Your skater carries the camcorder for the others, and you, looking through it, do not see your skater or board\n" + environment.all());
|
||||
check(game.called("hide filmer false false false stick true false true"), "The right stick and its click are the camera's");
|
||||
const auto film_ms = status_of(host, plugin).tick_ms;
|
||||
std::cout << "Filmer film: " << film_ms << " ms a tick\n";
|
||||
check(film_ms < 2.0, "You film within the 2 ms a tick");
|
||||
check(host.run_console("filmer slap") && environment.printed("Clip 1 marked"), "A slap marks a clip\n" + printed_lines(environment));
|
||||
check(host.run_console("filmer mode off"), "filmer mode off");
|
||||
ticks(host, environment, 30);
|
||||
check(game.called("carry filmer none") && game.called("camera release filmer"), "Off: the camcorder put down and the camera given back\n" + environment.all());
|
||||
}
|
||||
|
||||
void smokey_throwdowns_register_and_play_one_up() {
|
||||
namespace kit = dingosdk::throwdowns::kit;
|
||||
TestEnvironment environment;
|
||||
@@ -3616,6 +3861,7 @@ int main() {
|
||||
run(plugins_place_world_entities);
|
||||
run(plugins_have_what_a_prop_hunt_needs);
|
||||
run(plugins_reach_cruise_and_read_the_mouse);
|
||||
run(plugins_carry_and_take_a_step_in_the_camera_cycle);
|
||||
run(plugins_read_joints_and_tune_physics);
|
||||
run(plugins_type_and_use_the_room);
|
||||
run(plugins_touch_hide_and_ray_at_players);
|
||||
@@ -3623,6 +3869,7 @@ int main() {
|
||||
run(plugins_edit_clips);
|
||||
run(animator_studio_records_and_saves);
|
||||
run(plugins_server_copies_are_the_authority);
|
||||
run(plugins_server_copies_spawn_park_mods);
|
||||
run(plugins_animate_the_skater);
|
||||
run(plugins_play_their_own_sounds);
|
||||
run(plugins_have_menu_panels);
|
||||
@@ -3647,6 +3894,7 @@ int main() {
|
||||
run(hall_of_meat_scores_a_bail);
|
||||
run(grind_exits_pick_the_side);
|
||||
run(smokey_throwdowns_register_and_play_one_up);
|
||||
run(filmer_follows_and_films);
|
||||
run(plugins_drive_the_throwdown_kit);
|
||||
run(fit_check_turns_the_board_and_saves_it);
|
||||
run(item_tabs_keep_tabs_and_wear_on_top);
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace {
|
||||
struct Event {
|
||||
std::string_view name, permission;
|
||||
};
|
||||
constexpr std::array<Event, 28> events{{
|
||||
constexpr std::array<Event, 29> 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"},
|
||||
@@ -25,7 +25,7 @@ constexpr std::array<Event, 28> events{{
|
||||
{"throwdown", "throwdowns"}, {"action", "input"}, {"screen_closed", "ui.screen"},
|
||||
{"board_landed", "events.skater"}, {"body_hit", "events.skater"}, {"bail_end", "events.skater"},
|
||||
{"physics_step", "events.physics"}, {"launch", "events.skater"}, {"trick_notes", "events.tricks"},
|
||||
{"throwdown_ui", "throwdowns"}, {"touch", "session.skaters"}, {"touch_end", "session.skaters"},
|
||||
{"throwdown_ui", "throwdowns"}, {"touch", "session.skaters"}, {"touch_end", "session.skaters"}, {"camera_cycle", "camera"},
|
||||
}};
|
||||
constexpr std::size_t maximum_key = 128;
|
||||
constexpr std::size_t maximum_command = 48;
|
||||
|
||||
@@ -141,7 +141,9 @@ struct WorldApi {
|
||||
if (!shape.across && !shape.along && !shape.up) luaL_argerror(L, 2, "a launch's shape sets across, along or up");
|
||||
return result(L, game(plugin).shape_launch(plugin.id(), from, shape));
|
||||
}
|
||||
// board.motion(): the board's velocity in the last physics step, or nil.
|
||||
// board.motion(): the board's velocity and spin in the last physics step, and where it and its deck
|
||||
// were last drawn in the world (bone 1, the board's place, is drawn in the world; the deck is bone 3
|
||||
// on bone 2 on it), or nil.
|
||||
static int board_motion(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "skater.read", "reskate.board.motion");
|
||||
@@ -150,9 +152,41 @@ struct WorldApi {
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
lua_createtable(L, 0, 1);
|
||||
lua_createtable(L, 0, 4);
|
||||
push_position(L, *v);
|
||||
lua_setfield(L, -2, "velocity");
|
||||
if (const auto spin = game(plugin).board_spin()) {
|
||||
push_position(L, *spin);
|
||||
lua_setfield(L, -2, "spin");
|
||||
}
|
||||
if (const auto bones = game(plugin).board_bones()) {
|
||||
const auto push_place = [&](const anim_studio::Joint& at, const char* field) {
|
||||
lua_createtable(L, 0, 2);
|
||||
push_position(L, at.position);
|
||||
lua_setfield(L, -2, "position");
|
||||
push_quaternion(L, at.rotation);
|
||||
lua_setfield(L, -2, "rotation");
|
||||
lua_setfield(L, -2, field);
|
||||
};
|
||||
const auto joint = [&](int b) {
|
||||
const auto& bone = (*bones)[static_cast<std::size_t>(b)];
|
||||
anim_studio::Joint j;
|
||||
j.position = bone.position;
|
||||
j.rotation = anim_studio::normalised(bone.rotation);
|
||||
return j;
|
||||
};
|
||||
// A child placed on its parent: turned by it, and moved by it from its own position.
|
||||
const auto on = [](const anim_studio::Joint& parent, const anim_studio::Joint& child) {
|
||||
anim_studio::Joint out;
|
||||
const auto moved = anim_studio::rotate(parent.rotation, child.position);
|
||||
out.position = {parent.position[0] + moved[0], parent.position[1] + moved[1], parent.position[2] + moved[2]};
|
||||
out.rotation = anim_studio::normalised(anim_studio::multiply(parent.rotation, child.rotation));
|
||||
return out;
|
||||
};
|
||||
const auto place = joint(1);
|
||||
push_place(place, "place");
|
||||
push_place(on(on(place, joint(2)), joint(3)), "deck");
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
// ---- camera -------------------------------------------------------------------------------------
|
||||
@@ -225,6 +259,20 @@ struct WorldApi {
|
||||
need(L, plugin, "camera", "reskate.camera.held");
|
||||
return result(L, game(plugin).camera_status(plugin.id()));
|
||||
}
|
||||
// camera.cycle(state): the plugin's camera as a step of the camera height button's cycle: nil (out
|
||||
// of it), "ready" (in it, off) or "active" (in it, on). For 300 ms, so every tick.
|
||||
static int camera_cycle(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "camera", "reskate.camera.cycle");
|
||||
auto state = Game::CycleCamera::out;
|
||||
if (!lua_isnoneornil(L, 1)) {
|
||||
const std::string_view s = luaL_checkstring(L, 1);
|
||||
if (s == "ready") state = Game::CycleCamera::ready;
|
||||
else if (s == "active") state = Game::CycleCamera::active;
|
||||
else luaL_argerror(L, 1, "a camera's place in the cycle is nil, \"ready\" or \"active\"");
|
||||
}
|
||||
return result(L, game(plugin).camera_cycle(plugin.id(), state));
|
||||
}
|
||||
static int camera_get(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "camera", "reskate.camera.get");
|
||||
@@ -727,6 +775,15 @@ struct WorldApi {
|
||||
game(plugin).set_nametag(plugin.id(), player, std::move(text), colour_at(L, 3, 0xffffffff));
|
||||
return 0;
|
||||
}
|
||||
// session.nametags(shown): false holds the names over the other players off (until true, or the
|
||||
// plugin stops); chat bubbles still show.
|
||||
static int nametags(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "session.nametags", "reskate.session.nametags");
|
||||
luaL_checktype(L, 1, LUA_TBOOLEAN);
|
||||
game(plugin).hide_nametags(plugin.id(), !lua_toboolean(L, 1));
|
||||
return 0;
|
||||
}
|
||||
// 0xRRGGBBAA from Lua, as the overlay draws it (IM_COL32: 0xAABBGGRR).
|
||||
static std::uint32_t colour_at(lua_State* L, int index, std::uint32_t fallback) {
|
||||
if (lua_isnoneornil(L, index)) return fallback;
|
||||
@@ -1223,9 +1280,100 @@ struct WorldApi {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// skater.reach({hand = "right"|"left", at = vector, rotation = {x, y, z, w}?, weight = 0..1}): the
|
||||
// hand joint taken to `at` in the world (turned to `rotation`), in the pose drawn and sent; for
|
||||
// 0.3 s, so every tick. nil lets the arm go.
|
||||
// A `shown` field: who sees a pose, a carry or a reach ("everyone", "others" or "me").
|
||||
static Game::Shown shown_field(lua_State* L, int table, const char* what) {
|
||||
lua_getfield(L, table, "shown");
|
||||
Game::Shown shown = Game::Shown::everyone;
|
||||
if (!lua_isnil(L, -1)) {
|
||||
const std::string_view s = luaL_checkstring(L, -1);
|
||||
if (s == "others") shown = Game::Shown::others;
|
||||
else if (s == "me") shown = Game::Shown::me;
|
||||
else if (s != "everyone") luaL_error(L, "%s's shown is \"everyone\", \"others\" or \"me\"", what);
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
return shown;
|
||||
}
|
||||
// A number field from 0 or `low` to `high`, `fallback` when not given.
|
||||
static float number_field(lua_State* L, int table, const char* key, float fallback, double low, double high, const char* message) {
|
||||
lua_getfield(L, table, key);
|
||||
float value = fallback;
|
||||
if (!lua_isnil(L, -1)) {
|
||||
const double v = luaL_checknumber(L, -1);
|
||||
if (!(v >= low && v <= high)) luaL_error(L, "%s", message);
|
||||
value = static_cast<float>(v);
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
return value;
|
||||
}
|
||||
// skater.carry({at = vector, rotation = {x, y, z, w}, hand, both, front, grip, weight, crouch, hunch,
|
||||
// still, turn, step, step_phase, push_straightens, shown}): something held out in the hands and
|
||||
// aimed, its front at `at`; for 0.3 s, so every tick. nil lets it go.
|
||||
static int carry(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "skater.animation", "reskate.skater.carry");
|
||||
if (lua_isnoneornil(L, 1)) return result(L, game(plugin).skater_carry(plugin.id(), std::nullopt));
|
||||
luaL_checktype(L, 1, LUA_TTABLE);
|
||||
Game::Carry goal;
|
||||
lua_getfield(L, 1, "hand");
|
||||
if (!lua_isnil(L, -1)) {
|
||||
const std::string hand = luaL_checkstring(L, -1);
|
||||
if (hand != "right" && hand != "left") luaL_error(L, "a carry's hand is \"right\" or \"left\"");
|
||||
goal.left = hand == "left";
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, 1, "both");
|
||||
goal.both = lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, 1, "at");
|
||||
goal.front = position_of(L, lua_gettop(L), "the carry's point");
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, 1, "rotation");
|
||||
if (lua_isnil(L, -1)) luaL_error(L, "a carry needs a rotation {x, y, z, w}: the way the thing held points");
|
||||
goal.aim = quaternion_at(L, lua_gettop(L));
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, 1, "front");
|
||||
if (!lua_isnil(L, -1)) {
|
||||
goal.front_from_grip = position_of(L, lua_gettop(L), "a carry's front");
|
||||
for (const auto v : goal.front_from_grip)
|
||||
if (std::abs(v) > 1) luaL_error(L, "a carry's front is at most 1 m from its grip on each axis");
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, 1, "grip");
|
||||
if (!lua_isnil(L, -1)) {
|
||||
luaL_checktype(L, -1, LUA_TTABLE);
|
||||
const int grip = lua_gettop(L);
|
||||
std::pair<std::array<float, 4>, Vec3> worn{{0, 0, 0, 1}, {}};
|
||||
lua_getfield(L, grip, "rotation");
|
||||
if (!lua_isnil(L, -1)) worn.first = quaternion_at(L, lua_gettop(L));
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, grip, "offset");
|
||||
if (!lua_isnil(L, -1)) {
|
||||
worn.second = position_of(L, lua_gettop(L), "a grip's offset");
|
||||
for (const auto v : worn.second)
|
||||
if (std::abs(v) > 0.5f) luaL_error(L, "a grip's offset is at most 0.5 m from the hand on each axis");
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
goal.worn = worn;
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
goal.weight = number_field(L, 1, "weight", 1, 0, 1, "a carry's weight is 0 to 1");
|
||||
goal.crouch = number_field(L, 1, "crouch", 0, 0, 0.6, "a carry's crouch is 0 to 0.6 m");
|
||||
goal.hunch = number_field(L, 1, "hunch", 0, 0, 1.2, "a carry's hunch is 0 to 1.2 radians");
|
||||
goal.turn = number_field(L, 1, "turn", 0, -3.2, 3.2, "a carry's turn is -pi to pi radians");
|
||||
goal.step = number_field(L, 1, "step", 0, 0, 1, "a carry's step is 0 to 1");
|
||||
goal.step_phase = number_field(L, 1, "step_phase", 0, -1e6, 1e6, "a carry's step_phase is a finite number");
|
||||
lua_getfield(L, 1, "still");
|
||||
goal.still = lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, 1, "push_straightens");
|
||||
goal.push_straightens = lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
goal.shown = shown_field(L, 1, "a carry");
|
||||
return result(L, game(plugin).skater_carry(plugin.id(), goal));
|
||||
}
|
||||
// skater.reach({hand = "right"|"left", at = vector, rotation = {x, y, z, w}?, weight = 0..1, shown}):
|
||||
// the hand joint taken to `at` in the world (turned to `rotation`), in the pose drawn and sent (or
|
||||
// one of them); for 0.3 s, so every tick. nil lets the arm go.
|
||||
static int reach(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "skater.animation", "reskate.skater.reach");
|
||||
@@ -1252,6 +1400,7 @@ struct WorldApi {
|
||||
goal.weight = static_cast<float>(w);
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
goal.shown = shown_field(L, 1, "a reach");
|
||||
return result(L, game(plugin).skater_reach(plugin.id(), goal));
|
||||
}
|
||||
// A skater's joint: a number 0..394 of the animation skeleton, or one of a few names.
|
||||
@@ -1334,7 +1483,13 @@ struct WorldApi {
|
||||
}
|
||||
const double weight = luaL_optnumber(L, 2, 1);
|
||||
if (!(weight >= 0 && weight <= 1)) luaL_argerror(L, 2, "a weight is 0 to 1");
|
||||
return result(L, game(plugin).skater_pose(plugin.id(), offsets, static_cast<float>(weight)));
|
||||
// options = {shown = "everyone" | "others" | "me"}
|
||||
auto shown = Game::Shown::everyone;
|
||||
if (!lua_isnoneornil(L, 3)) {
|
||||
luaL_checktype(L, 3, LUA_TTABLE);
|
||||
shown = shown_field(L, 3, "a pose");
|
||||
}
|
||||
return result(L, game(plugin).skater_pose(plugin.id(), offsets, static_cast<float>(weight), shown));
|
||||
}
|
||||
// skater.joint(joint): {position, rotation, scale} of one of the local skater's joints, or nil.
|
||||
static int skater_joint(lua_State* L) {
|
||||
@@ -1540,7 +1695,15 @@ struct WorldApi {
|
||||
static int board_hide(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "board", "reskate.board.hide");
|
||||
return result(L, game(plugin).board_hide(plugin.id(), bone_number(L, 1)));
|
||||
// options = {others = true}: hidden from the other players too
|
||||
bool everyone = false;
|
||||
if (!lua_isnoneornil(L, 2)) {
|
||||
luaL_checktype(L, 2, LUA_TTABLE);
|
||||
lua_getfield(L, 2, "others");
|
||||
everyone = lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
return result(L, game(plugin).board_hide(plugin.id(), bone_number(L, 1), everyone));
|
||||
}
|
||||
static int board_clear(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
@@ -1589,6 +1752,109 @@ struct WorldApi {
|
||||
if (!within(plugin.services_.now_ms(), plugin.control_second_, plugin.controls_, 4)) return result(L, "asked more than 4 times in a second");
|
||||
return result(L, game(plugin).board_toss(plugin.id(), std::move(bones), velocity, spin, static_cast<float>(ground)));
|
||||
}
|
||||
// ---- board parameters: the shader values of a part of the local board (board_material.h) ---------------
|
||||
static constexpr std::array<std::string_view, 4> board_parts{"deck", "grip", "trucks", "wheels"};
|
||||
// The two colours known by what they colour (board_material.h graphic_colour and base_colour).
|
||||
static constexpr std::array<std::pair<std::string_view, std::uint64_t>, 2> named_board_parameters{{
|
||||
{"graphic", 0x873f48af36e50d59ULL}, {"base", 0x8c46cd354b18a9e2ULL}}};
|
||||
static int board_part(lua_State* L, int index) {
|
||||
const std::string_view name = luaL_checkstring(L, index);
|
||||
for (std::size_t i = 0; i < board_parts.size(); ++i)
|
||||
if (name == board_parts[i]) return static_cast<int>(i);
|
||||
luaL_argerror(L, index, "a part is \"deck\", \"grip\", \"trucks\" or \"wheels\"");
|
||||
return 0;
|
||||
}
|
||||
static std::uint64_t board_parameter_key(lua_State* L, int index) {
|
||||
std::size_t length{};
|
||||
const char* text = luaL_checklstring(L, index, &length);
|
||||
for (const auto& [name, key] : named_board_parameters)
|
||||
if (std::string_view(text, length) == name) return key;
|
||||
std::uint64_t key{};
|
||||
const auto [end, error] = std::from_chars(text, text + length, key, 16);
|
||||
if (length != 16 || error != std::errc{} || end != text + length) luaL_argerror(L, index, "a key is 16 hex digits, \"graphic\" or \"base\"");
|
||||
return key;
|
||||
}
|
||||
// parameters(part): {{key = "873f48af36e50d59", name = "graphic", type = "colour", value = {r, g, b}}, ...}
|
||||
// as the part's material holds them now, or nil without a board. A number's value is a number, an
|
||||
// integer's an integer, a switch's true or false.
|
||||
static int board_parameters(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "board", "reskate.board.parameters");
|
||||
const auto found = game(plugin).board_parameters(board_part(L, 1));
|
||||
if (!found) {
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
lua_createtable(L, static_cast<int>(found->size()), 0);
|
||||
int at = 0;
|
||||
for (const auto& p : *found) {
|
||||
lua_createtable(L, 0, 4);
|
||||
const auto key = std::format("{:016x}", p.key);
|
||||
lua_pushlstring(L, key.data(), key.size());
|
||||
lua_setfield(L, -2, "key");
|
||||
for (const auto& [name, known] : named_board_parameters)
|
||||
if (known == p.key) {
|
||||
lua_pushlstring(L, name.data(), name.size());
|
||||
lua_setfield(L, -2, "name");
|
||||
}
|
||||
lua_pushlstring(L, p.type.data(), p.type.size());
|
||||
lua_setfield(L, -2, "type");
|
||||
if (p.type == "colour") {
|
||||
lua_createtable(L, 0, 3);
|
||||
const char* channels[] = {"r", "g", "b"};
|
||||
for (std::size_t c = 0; c < 3; ++c) {
|
||||
lua_pushnumber(L, p.value[c]);
|
||||
lua_setfield(L, -2, channels[c]);
|
||||
}
|
||||
lua_setfield(L, -2, "value");
|
||||
} else if (p.type == "number") {
|
||||
lua_pushnumber(L, p.value[0]);
|
||||
lua_setfield(L, -2, "value");
|
||||
} else if (p.type == "integer") {
|
||||
lua_pushinteger(L, p.integer);
|
||||
lua_setfield(L, -2, "value");
|
||||
} else if (p.type == "switch") {
|
||||
lua_pushboolean(L, p.integer != 0);
|
||||
lua_setfield(L, -2, "value");
|
||||
}
|
||||
lua_rawseti(L, -2, ++at);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
// set_parameter(part, key, value): a colour {r, g, b} (0 to 1) or a number held on the part until
|
||||
// cleared, on this screen; an integer or a switch is only read.
|
||||
static int board_set_parameter(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "board", "reskate.board.set_parameter");
|
||||
const int part = board_part(L, 1);
|
||||
const auto key = board_parameter_key(L, 2);
|
||||
std::array<float, 3> value{};
|
||||
bool colour{};
|
||||
if (lua_type(L, 3) == LUA_TTABLE) {
|
||||
colour = true;
|
||||
const char* channels[] = {"r", "g", "b"};
|
||||
for (std::size_t c = 0; c < 3; ++c) {
|
||||
lua_getfield(L, 3, channels[c]);
|
||||
if (!lua_isnumber(L, -1)) luaL_argerror(L, 3, "a colour is {r, g, b}, each 0 to 1");
|
||||
value[c] = static_cast<float>(lua_tonumber(L, -1));
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
} else {
|
||||
value[0] = static_cast<float>(luaL_checknumber(L, 3));
|
||||
}
|
||||
if (!std::all_of(value.begin(), value.end(), [](float v) { return std::isfinite(v); })) luaL_argerror(L, 3, "a value is finite");
|
||||
return result(L, game(plugin).board_set_parameter(plugin.id(), part, key, value, colour));
|
||||
}
|
||||
// clear_parameter([part [, key]]): what the plugin holds there put back (every part, every key).
|
||||
static int board_clear_parameter(lua_State* L) {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "board", "reskate.board.clear_parameter");
|
||||
const int part = lua_isnoneornil(L, 1) ? -1 : board_part(L, 1);
|
||||
std::optional<std::uint64_t> key;
|
||||
if (!lua_isnoneornil(L, 2)) key = board_parameter_key(L, 2);
|
||||
game(plugin).board_clear_parameter(plugin.id(), part, key);
|
||||
return 0;
|
||||
}
|
||||
// ---- outfit ------------------------------------------------------------------------------------------
|
||||
// set(slot, item, {placement = {...}, covers = bool}): the item worn over the outfit in that slot.
|
||||
static int outfit_set(lua_State* L) {
|
||||
@@ -1596,11 +1862,13 @@ struct WorldApi {
|
||||
need(L, plugin, "outfit", "reskate.outfit.set");
|
||||
const auto slot = checked_text(L, 1, 48, "a slot's name");
|
||||
const auto item = checked_text(L, 2, 200, "an item's name");
|
||||
// Only the face and skin layers, and eyewear (Extension/Skater/outfit_overlay.h).
|
||||
// Only the face and skin layers, eyewear and hand wear (Extension/Skater/outfit_overlay.h): the
|
||||
// game has no menu for hand wear, so a camera or a glove a plugin puts in the hand goes there.
|
||||
std::string lowered = slot;
|
||||
for (auto& c : lowered) c = static_cast<char>(c >= 'A' && c <= 'Z' ? c + 32 : c);
|
||||
if (lowered.find("tattoo") == std::string::npos && lowered.find("moles") == std::string::npos && lowered.find("eyewear") == std::string::npos)
|
||||
return result(L, "only tattoo, moles and eyewear slots");
|
||||
if (lowered.find("tattoo") == std::string::npos && lowered.find("moles") == std::string::npos && lowered.find("eyewear") == std::string::npos &&
|
||||
lowered.find("hand") == std::string::npos)
|
||||
return result(L, "only tattoo, moles, eyewear and hand wear slots");
|
||||
std::vector<float> placement;
|
||||
bool covers{};
|
||||
if (!lua_isnoneornil(L, 3)) {
|
||||
@@ -1752,9 +2020,9 @@ struct WorldApi {
|
||||
return 1;
|
||||
}
|
||||
// ---- server: what only the session's authority does ----------------------------------------------
|
||||
static Plugin& authority(lua_State* L, const char* what) {
|
||||
static Plugin& authority(lua_State* L, const char* what, const char* permission = "server") {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "server", what);
|
||||
need(L, plugin, permission, what);
|
||||
if (!plugin.services_.authority())
|
||||
luaL_error(L, "%s is for the plugin's server copy (reskate.side is \"server\": \"side\" \"server\" or \"both\" in reskate-plugin.json)", what);
|
||||
if (!within(plugin.services_.now_ms(), plugin.server_second_, plugin.server_calls_, 20)) luaL_error(L, "more than 20 server calls in a second");
|
||||
@@ -1795,6 +2063,51 @@ struct WorldApi {
|
||||
auto& plugin = authority(L, "reskate.server.unban");
|
||||
return result(L, game(plugin).unban(player_of(L, 1)));
|
||||
}
|
||||
// ---- server.parks: a dedicated server's park mods -----------------------------------------------
|
||||
// parks(): {name, map, here (fits the map running now), spawned, objects, minutes_left}[];
|
||||
// park_spawn(name, minutes?): for that long (1-1440), or until removed; park_remove(name).
|
||||
static std::string park_name(lua_State* L, int index) {
|
||||
const std::string name = luaL_checkstring(L, index);
|
||||
const bool ok = !name.empty() && name.size() <= 16 && std::all_of(name.begin(), name.end(), [](char c) {
|
||||
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_';
|
||||
});
|
||||
if (!ok) luaL_argerror(L, index, "a park mod's name: 1 to 16 of a-z 0-9 - _");
|
||||
return name;
|
||||
}
|
||||
static int server_parks(lua_State* L) {
|
||||
auto& plugin = authority(L, "reskate.server.parks", "server.parks");
|
||||
const auto parks = game(plugin).server_parks();
|
||||
lua_createtable(L, static_cast<int>(parks.size()), 0);
|
||||
for (std::size_t i = 0; i < parks.size(); ++i) {
|
||||
const auto& p = parks[i];
|
||||
lua_createtable(L, 0, 6);
|
||||
lua_pushlstring(L, p.name.data(), p.name.size());
|
||||
lua_setfield(L, -2, "name");
|
||||
lua_pushlstring(L, p.map.data(), p.map.size());
|
||||
lua_setfield(L, -2, "map");
|
||||
lua_pushboolean(L, p.here);
|
||||
lua_setfield(L, -2, "here");
|
||||
lua_pushboolean(L, p.spawned);
|
||||
lua_setfield(L, -2, "spawned");
|
||||
lua_pushinteger(L, static_cast<int>(p.objects));
|
||||
lua_setfield(L, -2, "objects");
|
||||
lua_pushinteger(L, static_cast<int>(p.minutes_left));
|
||||
lua_setfield(L, -2, "minutes_left");
|
||||
lua_rawseti(L, -2, static_cast<int>(i + 1));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
static int server_park_spawn(lua_State* L) {
|
||||
auto& plugin = authority(L, "reskate.server.park_spawn", "server.parks");
|
||||
const auto name = park_name(L, 1);
|
||||
const auto minutes = lua_isnoneornil(L, 2) ? 0 : luaL_checkinteger(L, 2);
|
||||
if (!lua_isnoneornil(L, 2) && (minutes < 1 || minutes > 1440)) luaL_argerror(L, 2, "minutes, 1 to 1440 (none: until removed)");
|
||||
return result(L, game(plugin).spawn_park(plugin.id(), name, static_cast<unsigned>(minutes)));
|
||||
}
|
||||
static int server_park_remove(lua_State* L) {
|
||||
auto& plugin = authority(L, "reskate.server.park_remove", "server.parks");
|
||||
return result(L, game(plugin).remove_park(plugin.id(), park_name(L, 1)));
|
||||
}
|
||||
// A player's permissions: player.permissions is what the server holds for them now, read-only;
|
||||
// permissions:clone() is a copy to change; player:apply_permissions(copy) makes it theirs.
|
||||
static constexpr std::array<std::pair<const char*, bool Game::Permissions::*>, 6> permission_fields{{
|
||||
@@ -2134,10 +2447,11 @@ struct WorldApi {
|
||||
auto& plugin = plugin_of(L);
|
||||
need(L, plugin, "input", "reskate.input.bind");
|
||||
const auto action = checked_text(L, 1, 48, "an action's name");
|
||||
const auto key = checked_text(L, 2, 60, "a key");
|
||||
if (action.empty()) luaL_argerror(L, 1, "an action needs a name");
|
||||
const auto combo = combo_of(key);
|
||||
if (!combo) luaL_error(L, "\"%s\" is not a key: 0-9, A-Z, Space, F1-F12 with Ctrl, Shift or Alt; or Pad.A, Pad.LB+Pad.RB...", key.c_str());
|
||||
// nil: no key of its own; the player can put it on one in the Binds page.
|
||||
const auto key = lua_isnoneornil(L, 2) ? std::string{} : checked_text(L, 2, 60, "a key");
|
||||
const auto combo = key.empty() ? 0u : combo_of(key);
|
||||
if (!key.empty() && !combo) luaL_error(L, "\"%s\" is not a key: 0-9, A-Z, Space, F1-F12 with Ctrl, Shift or Alt; or Pad.A, Pad.LB+Pad.RB...", key.c_str());
|
||||
if (!plugin.binds_.contains(action) && plugin.binds_.size() >= 16) luaL_error(L, "at most 16 actions");
|
||||
if (const auto why = game(plugin).bind(plugin.id(), action, combo); !why.empty()) return result(L, why);
|
||||
plugin.binds_.insert(action);
|
||||
@@ -2455,10 +2769,11 @@ void WorldApi::install(Plugin& plugin, lua_State* L) {
|
||||
lua_setfield(L, -2, "__metatable");
|
||||
lua_setfield(L, LUA_REGISTRYINDEX, "reskate.permissions");
|
||||
add_functions(L, "skater", {{"state", state}, {"noclip", noclip}, {"boost", boost}, {"freeze", freeze}, {"wipeout", wipeout}, {"xray", xray},
|
||||
{"shape_launch", shape_launch}, {"hide", hide_skater}, {"reach", reach}, {"hand", hand}, {"joint", skater_joint}, {"pose", skater_pose}});
|
||||
{"shape_launch", shape_launch}, {"hide", hide_skater}, {"reach", reach}, {"carry", carry}, {"hand", hand}, {"joint", skater_joint}, {"pose", skater_pose}});
|
||||
add_functions(L, "board", {{"motion", board_motion}, {"bones", board_bones}, {"set", board_set}, {"hide", board_hide}, {"clear", board_clear},
|
||||
{"park", board_park}, {"fit", board_fit}, {"toss", board_toss}});
|
||||
add_functions(L, "camera", {{"look", camera_look}, {"set", camera_set}, {"follow", camera_follow}, {"release", camera_release}, {"held", camera_held}, {"get", camera_get}});
|
||||
{"park", board_park}, {"fit", board_fit}, {"toss", board_toss}, {"parameters", board_parameters},
|
||||
{"set_parameter", board_set_parameter}, {"clear_parameter", board_clear_parameter}});
|
||||
add_functions(L, "camera", {{"look", camera_look}, {"set", camera_set}, {"follow", camera_follow}, {"release", camera_release}, {"held", camera_held}, {"get", camera_get}, {"cycle", camera_cycle}});
|
||||
add_functions(L, "trainer", {{"set_point", set_point}, {"back", back}});
|
||||
add_functions(L, "hud", {{"hide", hide_hud}, {"corner", hud_corner}});
|
||||
add_functions(L, "time", {{"slow", slow}, {"speed", speed}});
|
||||
@@ -2469,7 +2784,7 @@ void WorldApi::install(Plugin& plugin, lua_State* L) {
|
||||
add_functions(L, "world", {{"set_time", set_time}, {"set_control", set_control}, {"set_park", set_park}, {"pick", world_pick}});
|
||||
add_functions(L, "travel", {{"locations", locations}, {"levels", levels}, {"go", go}, {"load", load}});
|
||||
add_functions(L, "ai", {{"spawn", ai_spawn}, {"clear", ai_clear}, {"count", ai_count}});
|
||||
add_functions(L, "session", {{"skaters", skaters}, {"tag", tag}, {"joint", session_joint}, {"touch", session_touch},
|
||||
add_functions(L, "session", {{"skaters", skaters}, {"tag", tag}, {"nametags", nametags}, {"joint", session_joint}, {"touch", session_touch},
|
||||
{"touching", session_touching}, {"hide", session_hide}, {"hidden", session_hidden},
|
||||
{"raycast", session_raycast}});
|
||||
add_functions(L, "voice", {{"set_volume", voice_volume}});
|
||||
@@ -2483,7 +2798,8 @@ void WorldApi::install(Plugin& plugin, lua_State* L) {
|
||||
add_functions(L, "outfit", {{"set", outfit_set}, {"clear", outfit_clear}, {"find", outfit_find}, {"slots", outfit_slots}, {"status", outfit_status}});
|
||||
add_functions(L, "audio", {{"play", audio_play}, {"set", audio_set}, {"stop", audio_stop}, {"playing", audio_playing}});
|
||||
add_functions(L, "server", {{"kick", server_kick}, {"say", server_say}, {"tell", server_tell}, {"move", server_move},
|
||||
{"ban", server_ban}, {"unban", server_unban}});
|
||||
{"ban", server_ban}, {"unban", server_unban}, {"parks", server_parks}, {"park_spawn", server_park_spawn},
|
||||
{"park_remove", server_park_remove}});
|
||||
add_functions(L, "throwdowns", {{"state", throwdown_state}, {"host", throwdown_host}, {"cancel", throwdown_cancel}, {"hosting", throwdown_hosting},
|
||||
{"mode", throwdown_mode}, {"remove_mode", throwdown_remove_mode}});
|
||||
add_functions(L, "music", {{"current", current}, {"play", play}, {"pause", pause}, {"next", next}, {"previous", previous},
|
||||
|
||||
@@ -333,6 +333,8 @@ public:
|
||||
virtual std::vector<std::uint64_t> hidden_players(std::string_view /*plugin*/) { return {}; }
|
||||
// A short tag beside a player's nametag, the plugin's own (empty text takes it away).
|
||||
virtual void set_nametag(std::string_view /*plugin*/, std::uint64_t, std::string, std::uint32_t) {}
|
||||
// The names over the other players held off while any plugin asks (chat bubbles still show).
|
||||
virtual void hide_nametags(std::string_view /*plugin*/, bool /*hidden*/) {}
|
||||
// 0 mutes, 1 is as heard, up to 10. The local player's ears only.
|
||||
virtual std::string voice_volume(std::uint64_t, float) { return unavailable; }
|
||||
// Chat lines after `sequence`, as this player sees them (filtered when they filter).
|
||||
@@ -564,6 +566,17 @@ public:
|
||||
virtual void release_camera(std::string_view /*plugin*/) {}
|
||||
// "" while the plugin's view is the one shown, otherwise why not.
|
||||
virtual std::string camera_status(std::string_view /*plugin*/) { return unavailable; }
|
||||
// The plugin's camera as a step of the game's camera height button cycle (Engine/Game/Skater/
|
||||
// first_person_cycle.h): out of it, in it and off (ready to start), or in it and on. Held 300 ms,
|
||||
// so said every tick; one plugin at a time. The steps the button asks for come back from
|
||||
// take_camera_cycle, for the plugin in the cycle.
|
||||
enum class CycleCamera : std::uint8_t { out, ready, active };
|
||||
virtual std::string camera_cycle(std::string_view /*plugin*/, CycleCamera) { return unavailable; }
|
||||
struct CycleNews {
|
||||
std::string plugin;
|
||||
bool enter{};
|
||||
};
|
||||
virtual std::vector<CycleNews> take_camera_cycle() { return {}; }
|
||||
// Holds the skater where it is (noclip, with no input), while any plugin asks.
|
||||
virtual std::string freeze(std::string_view /*plugin*/, bool) { return unavailable; }
|
||||
// ---- the session's authority (plugins' server copies) --------------------------------------------
|
||||
@@ -583,6 +596,18 @@ public:
|
||||
};
|
||||
virtual std::optional<Permissions> permissions(std::uint64_t) { return std::nullopt; }
|
||||
virtual std::string apply_permissions(std::string_view /*plugin*/, std::uint64_t, const Permissions&) { return unavailable; }
|
||||
// A dedicated server's park mods (server.parks): those it has set up, whether each fits the map
|
||||
// running now and is spawned (its objects, and minutes left; 0 until removed). Spawning one (for
|
||||
// `minutes`, 0 until removed) or removing it goes as the server's own park-mod does.
|
||||
struct ServerPark {
|
||||
std::string name, map;
|
||||
bool here{}, spawned{};
|
||||
std::size_t objects{};
|
||||
unsigned minutes_left{};
|
||||
};
|
||||
virtual std::vector<ServerPark> server_parks() { return {}; }
|
||||
virtual std::string spawn_park(std::string_view /*plugin*/, std::string_view /*name*/, unsigned /*minutes*/) { return unavailable; }
|
||||
virtual std::string remove_park(std::string_view /*plugin*/, std::string_view /*name*/) { return unavailable; }
|
||||
// ---- skater.animation: clips on the local skater's body (Extension/Skater/skater_animation.h) ------
|
||||
// One plugin plays at a time; none while a throwdown runs. A replacement plays when the game does
|
||||
// `trick` ("kickflip") with that pop ("" any, "low", "mid", "high"); a null clip takes it away.
|
||||
@@ -616,6 +641,9 @@ public:
|
||||
virtual void pause_clip(std::string_view /*plugin*/, bool /*paused*/) {}
|
||||
virtual std::optional<float> clip_time(std::string_view /*plugin*/) { return std::nullopt; }
|
||||
virtual bool clip_playing(std::string_view /*plugin*/) { return false; }
|
||||
// Who sees a pose, a carry or a reach of the local skater (Extension/Skater/skater_animation.h
|
||||
// Shown): everyone, only the other players (sent, not drawn), or only this player (drawn, not sent).
|
||||
enum class Shown : std::uint8_t { everyone, others, me };
|
||||
// An arm of the local skater reaching (Extension/Skater/arm_reach.h): its hand joint to `at` in the
|
||||
// world, turned to `rotation` when given, `weight` of the way; held 0.3 s, set every tick. One
|
||||
// plugin at a time; none ends that plugin's. Empty when done, else why not.
|
||||
@@ -624,18 +652,36 @@ public:
|
||||
Vec3 at{};
|
||||
std::optional<std::array<float, 4>> rotation;
|
||||
float weight{1};
|
||||
Shown shown{};
|
||||
};
|
||||
virtual std::string skater_reach(std::string_view /*plugin*/, std::optional<Reach>) { return unavailable; }
|
||||
// Something held out in the local skater's hands and aimed (Extension/Skater/carry.h): its front
|
||||
// to `front` in the world, pointing as `aim` says (its -Z the way it points); held 0.3 s, set every
|
||||
// tick. One plugin at a time; none ends that plugin's. Empty when done, else why not.
|
||||
struct Carry {
|
||||
bool left{}, both{};
|
||||
Vec3 front{};
|
||||
std::array<float, 4> aim{0, 0, 0, 1};
|
||||
Vec3 front_from_grip{};
|
||||
// Worn on the hand: the item's rotation in the hand joint's frame, and its grip from the hand joint.
|
||||
std::optional<std::pair<std::array<float, 4>, Vec3>> worn;
|
||||
float weight{1}, crouch{}, hunch{}, turn{}, step{}, step_phase{};
|
||||
bool still{}, push_straightens{};
|
||||
Shown shown{};
|
||||
};
|
||||
virtual std::string skater_carry(std::string_view /*plugin*/, std::optional<Carry>) { return unavailable; }
|
||||
// Joints of the local skater turned (in their own frame) and scaled on top of the animation, in
|
||||
// the pose drawn and sent (Extension/Skater/skater_animation.h pose); held 0.3 s, so set every
|
||||
// tick; empty ends it. One plugin at a time.
|
||||
// the pose drawn and sent (Extension/Skater/skater_animation.h pose) or one of them (`shown`); held
|
||||
// 0.3 s, so set every tick; empty ends it. One plugin at a time.
|
||||
struct JointOffset {
|
||||
std::uint16_t joint{};
|
||||
std::array<float, 4> rotation{0, 0, 0, 1};
|
||||
float scale{1};
|
||||
Vec3 offset{}; // added to its position, in its parent's frame
|
||||
};
|
||||
virtual std::string skater_pose(std::string_view /*plugin*/, const std::vector<JointOffset>&, float /*weight*/) { return unavailable; }
|
||||
virtual std::string skater_pose(std::string_view /*plugin*/, const std::vector<JointOffset>&, float /*weight*/, Shown /*shown*/) {
|
||||
return unavailable;
|
||||
}
|
||||
// Where a hand joint of the local skater is in the world, as last evaluated (after any reach).
|
||||
struct HandPlace {
|
||||
Vec3 position{};
|
||||
@@ -711,7 +757,8 @@ public:
|
||||
};
|
||||
virtual std::optional<std::array<BoardBone, 16>> board_bones() { return std::nullopt; }
|
||||
virtual std::string board_set(std::string_view /*plugin*/, int, const BoardBone&, bool /*world*/) { return unavailable; }
|
||||
virtual std::string board_hide(std::string_view /*plugin*/, int) { return unavailable; }
|
||||
// `everyone`: hidden from the other players too, not only on this screen.
|
||||
virtual std::string board_hide(std::string_view /*plugin*/, int, bool /*everyone*/) { return unavailable; }
|
||||
virtual void board_clear(std::string_view /*plugin*/, int /*bone, -1: all*/) {}
|
||||
virtual void board_park(std::string_view /*plugin*/, std::vector<int>) {}
|
||||
virtual std::string board_toss(std::string_view /*plugin*/, std::vector<int>, const Vec3&, const Vec3&, float) { return unavailable; }
|
||||
@@ -721,6 +768,23 @@ public:
|
||||
float axle = 1.0f;
|
||||
};
|
||||
virtual std::string board_fit(std::string_view /*plugin*/, std::optional<BoardFit>) { return unavailable; }
|
||||
// The shader parameters of a part of the local board (Extension/Customization/board_material.h):
|
||||
// part 0 the deck, 1 its grip, 2 the trucks, 3 the wheels. `type` is "colour", "number",
|
||||
// "integer", "switch" or "other"; `value` a colour's three channels or a number in [0], and
|
||||
// `integer` an integer's or a switch's. None without a board.
|
||||
struct BoardParameter {
|
||||
std::uint64_t key{};
|
||||
std::string type;
|
||||
std::array<float, 3> value{};
|
||||
std::int32_t integer{};
|
||||
};
|
||||
virtual std::optional<std::vector<BoardParameter>> board_parameters(int /*part*/) { return std::nullopt; }
|
||||
// A colour (`colour`, all three) or a number (value[0]) held there until cleared; empty when held.
|
||||
virtual std::string board_set_parameter(std::string_view /*plugin*/, int /*part*/, std::uint64_t, std::array<float, 3>, bool /*colour*/) {
|
||||
return unavailable;
|
||||
}
|
||||
// Part -1: every part; no key: every key.
|
||||
virtual void board_clear_parameter(std::string_view /*plugin*/, int /*part*/, std::optional<std::uint64_t>) {}
|
||||
// ---- variants: of the plugin's own mod (Engine/Vfs/mod_list.h Variants) -----------------------------
|
||||
struct VariantState {
|
||||
std::vector<std::string> installed; // its "order" first
|
||||
@@ -736,6 +800,8 @@ public:
|
||||
// ---- the skater's physics -----------------------------------------------------------------------
|
||||
virtual SkaterNews take_skater_news() { return {}; }
|
||||
virtual std::optional<Vec3> board_velocity() { return std::nullopt; }
|
||||
// Its spin in that step: angular velocity in the world, radians a second.
|
||||
virtual std::optional<Vec3> board_spin() { return std::nullopt; }
|
||||
virtual std::optional<SlamNews> last_slam() { return std::nullopt; }
|
||||
// Every physics step since the last tick (kept once asked for: at most 512).
|
||||
virtual std::vector<PhysicsStep> take_physics_steps() { return {}; }
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "Extension/Skater/skater_events.h"
|
||||
#include "Extension/Skater/outfit_overlay.h"
|
||||
#include "Extension/Skater/board_pose.h"
|
||||
#include "Extension/Customization/board_material.h"
|
||||
#include "Engine/Vfs/mod_catalog.h"
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
#include <mutex>
|
||||
@@ -25,6 +26,8 @@
|
||||
#include "Extension/Skater/game_speed.h"
|
||||
#include "Extension/Skater/launches.h"
|
||||
#include "Extension/Skater/pad_hide.h"
|
||||
#include "Engine/Game/Skater/first_person_cycle.h"
|
||||
#include "Extension/Multiplayer/Hud/custom_nametags.h"
|
||||
#include "Extension/Skater/xray.h"
|
||||
#include "Extension/UI/hud_corner.h"
|
||||
#include "Extension/Throwdowns/custom_modes.h"
|
||||
@@ -376,6 +379,11 @@ public:
|
||||
void set_nametag(std::string_view plugin, std::uint64_t player, std::string text, std::uint32_t colour) override {
|
||||
plugin_feed::set_tag(std::string(plugin), player, std::move(text), colour);
|
||||
}
|
||||
void hide_nametags(std::string_view plugin, bool hidden) override {
|
||||
if (hidden) nametag_hiders_.emplace(plugin);
|
||||
else if (const auto at = nametag_hiders_.find(plugin); at != nametag_hiders_.end()) nametag_hiders_.erase(at);
|
||||
multiplayer::nametags_held_off.store(!nametag_hiders_.empty(), std::memory_order_relaxed);
|
||||
}
|
||||
std::string voice_volume(std::uint64_t player, float volume) override {
|
||||
const auto id = std::to_string(player);
|
||||
const auto mute = multiplayer::command("voice-mute", id + (volume <= 0 ? " on" : " off"));
|
||||
@@ -456,7 +464,7 @@ public:
|
||||
|
||||
// ---- input -------------------------------------------------------------------------------------------
|
||||
std::string bind(std::string_view plugin, std::string_view action, std::uint32_t combo) override {
|
||||
if (!valid_action_binding(combo)) return "that is not a key or pad combo";
|
||||
if (combo && !valid_action_binding(combo)) return "that is not a key or pad combo"; // (0: none of its own)
|
||||
binds_[{std::string(plugin), std::string(action)}].own = combo;
|
||||
publish_binds();
|
||||
return {};
|
||||
@@ -501,7 +509,7 @@ public:
|
||||
std::string board_set(std::string_view plugin, int bone, const BoardBone& value, bool world) override {
|
||||
return board_pose::set(plugin, bone, {value.position, value.rotation, value.scale}, world ? board_pose::Space::world : board_pose::Space::local);
|
||||
}
|
||||
std::string board_hide(std::string_view plugin, int bone) override { return board_pose::hide(plugin, bone); }
|
||||
std::string board_hide(std::string_view plugin, int bone, bool everyone) override { return board_pose::hide(plugin, bone, everyone); }
|
||||
void board_clear(std::string_view plugin, int bone) override { board_pose::clear(plugin, bone); }
|
||||
void board_park(std::string_view plugin, std::vector<int> bones) override { board_pose::park(plugin, std::move(bones)); }
|
||||
std::string board_toss(std::string_view plugin, std::vector<int> bones, const Vec3& velocity, const Vec3& spin, float ground) override {
|
||||
@@ -511,6 +519,24 @@ public:
|
||||
if (!fit) return board_pose::fit(plugin, std::nullopt);
|
||||
return board_pose::fit(plugin, board_pose::Fit{fit->plates, fit->axle});
|
||||
}
|
||||
std::optional<std::vector<BoardParameter>> board_parameters(int part) override {
|
||||
const auto found = board_material::read(static_cast<board_material::Part>(part));
|
||||
if (!found) return std::nullopt;
|
||||
std::vector<BoardParameter> out;
|
||||
for (const auto& p : *found) {
|
||||
using board_material::Kind;
|
||||
const char* type = p.kind == Kind::colour ? "colour" : p.kind == Kind::number ? "number" : p.kind == Kind::integer ? "integer"
|
||||
: p.kind == Kind::toggle ? "switch" : "other";
|
||||
out.push_back({p.key, type, p.value, p.integer});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
std::string board_set_parameter(std::string_view plugin, int part, std::uint64_t key, std::array<float, 3> value, bool colour) override {
|
||||
return board_material::set(plugin, static_cast<board_material::Part>(part), key, value, colour);
|
||||
}
|
||||
void board_clear_parameter(std::string_view plugin, int part, std::optional<std::uint64_t> key) override {
|
||||
board_material::clear(plugin, part < 0 ? std::nullopt : std::optional(static_cast<board_material::Part>(part)), key);
|
||||
}
|
||||
// ---- variants ------------------------------------------------------------------------------------
|
||||
// The mod's folder is read again at most every 2 s: a menu page asks every frame.
|
||||
std::optional<VariantState> variants(std::string_view plugin, const std::filesystem::path& package) override {
|
||||
@@ -805,6 +831,7 @@ public:
|
||||
pad_hide::hide(plugin, {h.left_trigger, h.right_trigger, h.left_x_grinding, h.right_stick, h.left_stick_click, h.right_stick_click});
|
||||
return {};
|
||||
}
|
||||
std::optional<Vec3> board_spin() override { return skater_events::board_spin(); }
|
||||
std::optional<Vec3> board_velocity() override {
|
||||
skater_events::want();
|
||||
return skater_events::board_velocity();
|
||||
@@ -978,7 +1005,10 @@ public:
|
||||
screen_look::slap();
|
||||
return {};
|
||||
}
|
||||
std::string skater_pose(std::string_view plugin, const std::vector<JointOffset>& offsets, float weight) override {
|
||||
static skater_animation::Shown shown_of(Shown shown) {
|
||||
return shown == Shown::others ? skater_animation::Shown::others : shown == Shown::me ? skater_animation::Shown::me : skater_animation::Shown::everyone;
|
||||
}
|
||||
std::string skater_pose(std::string_view plugin, const std::vector<JointOffset>& offsets, float weight, Shown shown) override {
|
||||
const auto now = GetTickCount64();
|
||||
if (!poser_.empty() && poser_ != plugin && now < pose_at_ + 300) return offsets.empty() ? std::string{} : poser_ + " poses the skater";
|
||||
if (offsets.empty()) {
|
||||
@@ -992,7 +1022,65 @@ public:
|
||||
pose_at_ = now;
|
||||
std::vector<skater_animation::PoseOffset> out;
|
||||
for (const auto& o : offsets) out.push_back({o.joint, o.rotation, o.scale, o.offset});
|
||||
skater_animation::pose(std::move(out), weight);
|
||||
skater_animation::pose(std::move(out), weight, shown_of(shown));
|
||||
return {};
|
||||
}
|
||||
std::string camera_cycle(std::string_view plugin, CycleCamera state) override {
|
||||
const auto now = GetTickCount64();
|
||||
auto& link = first_person::plugin_link;
|
||||
if (!cycler_.empty() && cycler_ != plugin && now < cycle_at_ + 300)
|
||||
return state == CycleCamera::out ? std::string{} : cycler_ + " is in the camera cycle";
|
||||
if (state == CycleCamera::out) {
|
||||
if (cycler_ == plugin) {
|
||||
link.flags.store(0, std::memory_order_release);
|
||||
cycler_.clear();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
if (cycler_ != plugin) link.request.store(0, std::memory_order_release); // (a step asked of another plugin)
|
||||
cycler_ = plugin;
|
||||
cycle_at_ = now;
|
||||
link.flags.store(static_cast<std::uint8_t>(first_person::plugin_flag_enabled |
|
||||
(state == CycleCamera::active ? first_person::plugin_flag_on : first_person::plugin_flag_can_enter)),
|
||||
std::memory_order_release);
|
||||
link.published.store(now, std::memory_order_release);
|
||||
return {};
|
||||
}
|
||||
std::vector<CycleNews> take_camera_cycle() override {
|
||||
const auto step = static_cast<first_person::CycleStep>(first_person::plugin_link.request.exchange(0, std::memory_order_acq_rel));
|
||||
if (step == first_person::CycleStep::none || cycler_.empty() || GetTickCount64() >= cycle_at_ + 300) return {};
|
||||
return {{cycler_, step == first_person::CycleStep::enter}};
|
||||
}
|
||||
std::string skater_carry(std::string_view plugin, std::optional<Carry> goal) override {
|
||||
const auto now = GetTickCount64();
|
||||
if (!carrier_.empty() && carrier_ != plugin && now < carry_at_ + 300) {
|
||||
if (!goal) return {};
|
||||
return carrier_ + " has the skater carrying";
|
||||
}
|
||||
if (!goal) {
|
||||
if (carrier_ == plugin) {
|
||||
skater_animation::carry(std::nullopt);
|
||||
carrier_.clear();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
carrier_ = plugin;
|
||||
carry_at_ = now;
|
||||
carry::Goal g;
|
||||
g.hand = goal->left ? arm_reach::Hand::left : arm_reach::Hand::right;
|
||||
g.both = goal->both;
|
||||
g.front = goal->front;
|
||||
g.aim = goal->aim;
|
||||
g.front_from_grip = goal->front_from_grip;
|
||||
if (goal->worn) g.worn = carry::Grip{goal->worn->first, goal->worn->second};
|
||||
g.weight = goal->weight;
|
||||
g.crouch = goal->crouch;
|
||||
g.hunch = goal->hunch;
|
||||
g.still = goal->still;
|
||||
g.turn = goal->turn;
|
||||
g.step = goal->step;
|
||||
g.step_phase = goal->step_phase;
|
||||
skater_animation::carry(g, goal->push_straightens, shown_of(goal->shown));
|
||||
return {};
|
||||
}
|
||||
std::string skater_reach(std::string_view plugin, std::optional<Reach> goal) override {
|
||||
@@ -1015,7 +1103,7 @@ public:
|
||||
g.at = goal->at;
|
||||
if (goal->rotation) g.rotation = *goal->rotation;
|
||||
g.weight = goal->weight;
|
||||
skater_animation::reach(g);
|
||||
skater_animation::reach(g, shown_of(goal->shown));
|
||||
return {};
|
||||
}
|
||||
static Tuning tuning_of(const trainer::TuningValue& v) {
|
||||
@@ -1085,6 +1173,7 @@ public:
|
||||
launches::tick(client);
|
||||
outfit_overlay::tick();
|
||||
board_pose::tick(base, local_skater_component(base, client));
|
||||
board_material::tick(base, client);
|
||||
const bool allowed = !multiplayer::local_throwdown_active();
|
||||
const auto component = local_skater_component(base, client);
|
||||
skater_animation::tick(base, component, allowed);
|
||||
@@ -1582,6 +1671,7 @@ public:
|
||||
board_pose::clear(plugin);
|
||||
board_pose::park(plugin, {});
|
||||
(void)board_pose::fit(plugin, std::nullopt);
|
||||
board_material::clear(plugin);
|
||||
cancel_throwdown(plugin);
|
||||
multiplayer::custom_throwdowns::remove_plugin(plugin);
|
||||
if (capturer_ == plugin) capturer_.clear();
|
||||
@@ -1611,6 +1701,15 @@ public:
|
||||
skater_animation::pose({}, 1);
|
||||
poser_.clear();
|
||||
}
|
||||
if (carrier_ == plugin) {
|
||||
skater_animation::carry(std::nullopt);
|
||||
carrier_.clear();
|
||||
}
|
||||
if (cycler_ == plugin) {
|
||||
first_person::plugin_link.flags.store(0, std::memory_order_release);
|
||||
cycler_.clear();
|
||||
}
|
||||
hide_nametags(plugin, false);
|
||||
if (room_owner_ == plugin) room_leave(plugin);
|
||||
if (const auto seen = telemetry_seen_.find(plugin); seen != telemetry_seen_.end()) telemetry_seen_.erase(seen);
|
||||
plugin_feed::release_tags(std::string(plugin));
|
||||
@@ -1645,6 +1744,11 @@ private:
|
||||
std::vector<EnabledMod> enabled_mods_;
|
||||
std::string reacher_; // the plugin holding the skater's arm (skater_reach)
|
||||
std::string poser_; // the plugin turning the skater's joints (skater_pose)
|
||||
std::string carrier_; // the plugin having the skater carry something (skater_carry)
|
||||
std::uint64_t carry_at_{};
|
||||
std::set<std::string, std::less<>> nametag_hiders_; // the plugins holding the nametags off (hide_nametags)
|
||||
std::string cycler_; // the plugin whose camera is in the camera height cycle (camera_cycle)
|
||||
std::uint64_t cycle_at_{};
|
||||
std::string room_owner_; // the plugin in the character room (room_open)
|
||||
std::uint64_t pose_at_{};
|
||||
std::string looker_; // the plugin whose look is on the screen (screen_look)
|
||||
|
||||
@@ -316,6 +316,11 @@ void Host::follow_game() {
|
||||
}
|
||||
if (wants("events.skater"))
|
||||
for (const auto& launch : game.take_launches()) each([&](Plugin& plugin, const std::string&) { plugin.launched(launch); });
|
||||
// The camera height button asking the plugin in its cycle to start or stop its camera.
|
||||
for (const auto& news : game.take_camera_cycle())
|
||||
each([&](Plugin& plugin, const std::string&) {
|
||||
if (plugin.id() == news.plugin) plugin.game_event("camera_cycle", "camera", Json(news.enter ? "enter" : "leave"));
|
||||
});
|
||||
// Touching (reskate.session.touch): the bodies read once for every plugin that has it on.
|
||||
{
|
||||
std::optional<std::vector<Body>> list;
|
||||
|
||||
@@ -12,15 +12,16 @@ namespace {
|
||||
// "camera" the free and pad-flown cameras and the Bone Cam (reskate.skater.bone_cam);
|
||||
// "session.read" spectating and the session's world (reskate.session.world); "events.tricks" the
|
||||
// trick events, the trick list's feed and flick diagrams (reskate.tricks); "throwdowns" the
|
||||
// throwdown kit (modes, documents, the native flag); "variants" choosing which of its own mod's
|
||||
// throwdown kit (modes, documents, the native flag); "server.parks" a dedicated server's park mods
|
||||
// (reskate.server.parks, park_spawn, park_remove); "variants" choosing which of its own mod's
|
||||
// variants is in the game (reskate.package.choose). A manifest's "resident" list and "variants" are
|
||||
// the merge's (Engine/Vfs/mod_list.h ResidentAssets, Variants), not the plugin host's.
|
||||
constexpr std::array<std::string_view, 48> permissions{
|
||||
constexpr std::array<std::string_view, 49> permissions{
|
||||
"console", "storage", "overlay", "menu", "ui.screen", "input", "hud", "events.tricks", "events.skater",
|
||||
"skater.read", "skater.teleport", "skater.control", "skater.animation", "trainer", "camera",
|
||||
"world.raycast", "world.objects", "world.entities", "world.environment", "world.ai", "travel", "session.read",
|
||||
"session.skaters", "session.nametags", "net.session", "net.http", "voice.control", "chat.read",
|
||||
"chat.send", "chat.local", "server", "throwdowns", "replay", "music", "progression.read", "settings.read", "audio.ui", "audio", "outfit", "board", "mods.read",
|
||||
"chat.send", "chat.local", "server", "server.parks", "throwdowns", "replay", "music", "progression.read", "settings.read", "audio.ui", "audio", "outfit", "board", "mods.read",
|
||||
"events.physics", "game.speed", "files", "variants", "settings.write", "room", "capture"};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// Something carried apart from the game: held out in front of the chest when it points level, higher
|
||||
// when it points up, turned the way it is aimed when worn on the hand, the other hand under it, the
|
||||
// hips lowered with the feet kept where they were, the body turned, and a broken pose left alone.
|
||||
#include "Extension/Skater/carry.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include <numbers>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
using namespace dingosdk;
|
||||
using namespace dingosdk::carry;
|
||||
namespace {
|
||||
void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); }
|
||||
float distance(const Vec3& a, const Vec3& b) {
|
||||
return std::sqrt((a[0] - b[0]) * (a[0] - b[0]) + (a[1] - b[1]) * (a[1] - b[1]) + (a[2] - b[2]) * (a[2] - b[2]));
|
||||
}
|
||||
bool same_turn(const Quat& a, const Quat& b) { return std::abs(a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]) > 0.999f; }
|
||||
|
||||
// A skater standing, facing +Z (its right at -X), arms hanging out to the sides: upper arms 0.3 m,
|
||||
// forearms 0.28 m, thighs and shins 0.45 m, the knees a little forward.
|
||||
constexpr float upper_length = 0.3f, fore_length = 0.28f;
|
||||
std::vector<Joint> standing() {
|
||||
std::vector<Joint> pose(395);
|
||||
pose[0].position = {4, 0, -2};
|
||||
pose[1].position = {0, 0.95f, 0};
|
||||
for (const std::size_t j : {42u, 43u, 44u, 45u}) pose[j].position = {0, 0.1f, 0};
|
||||
pose[101].position = {0, 0.1f, 0};
|
||||
pose[102].position = {0, 0.05f, 0};
|
||||
pose[103].position = {0, 0.1f, 0};
|
||||
for (const auto hand : {Hand::right, Hand::left}) {
|
||||
const auto joints = arm_reach::chain(hand);
|
||||
const float x = hand == Hand::right ? -1.0f : 1.0f;
|
||||
pose[joints[7]].position = {0.15f * x, 0, 0};
|
||||
pose[joints[8]].position = {0.05f * x, 0, 0};
|
||||
pose[joints[9]].position = {0, -upper_length, 0};
|
||||
pose[joints[10]].position = {0, -fore_length, 0};
|
||||
}
|
||||
for (const auto& leg : {right_leg, left_leg}) {
|
||||
const float x = leg[3] == 8 ? -1.0f : 1.0f;
|
||||
pose[leg[3]].position = {0.1f * x, 0, 0};
|
||||
pose[leg[4]].position = {0, -0.45f, 0.03f};
|
||||
pose[leg[5]].position = {0, -0.45f, -0.03f};
|
||||
}
|
||||
return pose;
|
||||
}
|
||||
std::array<Placed, 11> arm(const std::vector<Joint>& pose, Hand hand) {
|
||||
std::array<Placed, 11> w{};
|
||||
check(arm_reach::compose(pose, arm_reach::chain(hand), w), "The arm composes");
|
||||
return w;
|
||||
}
|
||||
Placed placed(const std::vector<Joint>& pose, std::span<const std::uint16_t> path) {
|
||||
std::array<Placed, 11> w{};
|
||||
check(arm_reach::compose(pose, path, w), "The joints compose");
|
||||
return w[path.size() - 1];
|
||||
}
|
||||
// Pointing at +Z, tipped up by `up` radians.
|
||||
Quat aimed(float up) { return anim_studio::multiply(anim_studio::axis_angle({0, 1, 0}, std::numbers::pi_v<float>), anim_studio::axis_angle({1, 0, 0}, up)); }
|
||||
|
||||
void held_level_in_front_of_the_chest() {
|
||||
auto pose = standing();
|
||||
const Vec3 shoulder = arm(pose, Hand::right)[8].position;
|
||||
Goal goal;
|
||||
goal.front = {shoulder[0], shoulder[1], shoulder[2] + 2.0f}; // asked for far off: held where a person holds it
|
||||
goal.aim = aimed(0);
|
||||
goal.front_from_grip = {0, -0.143f, -0.248f};
|
||||
goal.worn = Grip{};
|
||||
const auto held = carry::carry(pose, goal);
|
||||
check(held.valid, "Carried");
|
||||
const auto w = arm(pose, Hand::right);
|
||||
check(held.front[2] - w[8].position[2] > 0.25f, "out in front of the shoulder");
|
||||
check(held.front[1] < w[8].position[1], "and below it");
|
||||
check(std::abs(distance(w[8].position, w[9].position) - upper_length) < 1e-4f, "The upper arm keeps its length");
|
||||
check(std::abs(distance(w[9].position, w[10].position) - fore_length) < 1e-4f, "and the forearm its length");
|
||||
check(same_turn(held.item.rotation, goal.aim), "Worn, it points the way it is aimed");
|
||||
}
|
||||
|
||||
void aimed_up_it_comes_up() {
|
||||
auto level = standing(), up = standing();
|
||||
Goal goal;
|
||||
goal.front = {4, 1.4f, 0};
|
||||
goal.front_from_grip = {0, -0.143f, -0.248f};
|
||||
goal.aim = aimed(0);
|
||||
const auto low = carry::carry(level, goal);
|
||||
goal.aim = aimed(1.2f);
|
||||
const auto high = carry::carry(up, goal);
|
||||
check(low.valid && high.valid, "Carried both ways");
|
||||
check(high.front[1] > low.front[1] + 0.2f, "Aimed up, it is held higher");
|
||||
}
|
||||
|
||||
void the_other_hand_holds_it_too() {
|
||||
auto pose = standing();
|
||||
const auto before = arm(pose, Hand::left)[10].position;
|
||||
Goal goal;
|
||||
goal.front = {4, 1.3f, 0.5f};
|
||||
goal.aim = aimed(0);
|
||||
goal.both = true;
|
||||
const auto held = carry::carry(pose, goal);
|
||||
check(held.valid, "Carried in both hands");
|
||||
const auto left = arm(pose, Hand::left)[10].position;
|
||||
check(distance(left, before) > 0.2f, "The other hand comes up to it");
|
||||
check(distance(left, held.item.position) < 0.3f, "and holds it");
|
||||
}
|
||||
|
||||
void the_feet_stay_as_the_hips_go_down() {
|
||||
auto pose = standing();
|
||||
const auto right_foot = placed(pose, right_leg).position, left_foot = placed(pose, left_leg).position;
|
||||
const float hips = placed(pose, std::span<const std::uint16_t>(right_leg).first(3)).position[1];
|
||||
Goal goal;
|
||||
goal.front = {4, 1.2f, 0.5f};
|
||||
goal.aim = aimed(-0.3f);
|
||||
goal.crouch = 0.2f;
|
||||
goal.hunch = 0.8f;
|
||||
check(carry::carry(pose, goal).valid, "Carried, crouched");
|
||||
check(std::abs(placed(pose, std::span<const std::uint16_t>(right_leg).first(3)).position[1] - (hips - 0.2f)) < 1e-3f, "The hips go down");
|
||||
check(distance(placed(pose, right_leg).position, right_foot) < 2e-3f, "the right foot stays where it was");
|
||||
check(distance(placed(pose, left_leg).position, left_foot) < 2e-3f, "and the left");
|
||||
const Vec3 head = placed(pose, head_chain).position;
|
||||
check(head[2] > -2.0f + 0.05f, "The back curls over toward it");
|
||||
}
|
||||
|
||||
void the_body_turns() {
|
||||
auto pose = standing();
|
||||
Goal goal;
|
||||
goal.front = {4, 1.3f, 0.5f};
|
||||
goal.aim = aimed(0);
|
||||
goal.turn = std::numbers::pi_v<float> / 2;
|
||||
check(carry::carry(pose, goal).valid, "Carried, turned");
|
||||
const auto facing = body_facing(pose);
|
||||
check(std::abs(facing[0]) > 0.99f, "The body turns a quarter round");
|
||||
}
|
||||
|
||||
void no_weight_and_a_broken_pose() {
|
||||
auto pose = standing();
|
||||
const auto before = pose;
|
||||
Goal goal;
|
||||
goal.front = {4, 1.3f, 0.5f};
|
||||
goal.aim = aimed(0);
|
||||
goal.crouch = 0.3f;
|
||||
goal.weight = 0;
|
||||
check(carry::carry(pose, goal).valid, "Carried with no weight");
|
||||
for (std::size_t i = 0; i < pose.size(); ++i)
|
||||
check(same_turn(pose[i].rotation, before[i].rotation) && distance(pose[i].position, before[i].position) < 1e-5f, "leaves the pose as it was");
|
||||
auto again = standing();
|
||||
goal.weight = 1;
|
||||
goal.both = true;
|
||||
goal.hunch = 0.6f;
|
||||
goal.turn = 0.5f;
|
||||
check(carry::carry(again, goal).valid, "Carried with everything");
|
||||
for (std::size_t i = 0; i < again.size(); ++i)
|
||||
if (std::find(written.begin(), written.end(), i) == written.end())
|
||||
check(same_turn(again[i].rotation, before[i].rotation) && distance(again[i].position, before[i].position) < 1e-5f,
|
||||
"A carry writes only the joints it says it writes");
|
||||
auto broken = standing();
|
||||
broken[48].rotation[0] = NAN;
|
||||
check(!carry::carry(broken, goal).valid, "A broken arm carries nothing");
|
||||
goal.front = {NAN, 0, 0};
|
||||
auto fine = standing();
|
||||
check(!carry::carry(fine, goal).valid, "A point that is not a number is refused");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
try {
|
||||
held_level_in_front_of_the_chest();
|
||||
aimed_up_it_comes_up();
|
||||
the_other_hand_holds_it_too();
|
||||
the_feet_stay_as_the_hips_go_down();
|
||||
the_body_turns();
|
||||
no_weight_and_a_broken_pose();
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "FAILED: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
std::cout << "carry: all passed\n";
|
||||
return 0;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "board_pose.h"
|
||||
#include "Extension/AnimationStudio/anim_core.h"
|
||||
#include "Extension/Multiplayer/Remote/native_pose_layout.h"
|
||||
#include "Extension/Multiplayer/Remote/native_skater.h"
|
||||
#include <Windows.h>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
@@ -60,6 +61,7 @@ Quat about(const Vec3& axis, float angle) {
|
||||
// The pose a bone is written with, as the client tick asked it.
|
||||
struct Wanted {
|
||||
bool hidden{};
|
||||
bool everyone{}; // hidden from the other players too
|
||||
Quat rotation{0, 0, 0, 1};
|
||||
Vec3 position{};
|
||||
Space space{};
|
||||
@@ -90,6 +92,8 @@ Shared& shared() {
|
||||
}
|
||||
std::atomic<std::uintptr_t> base_now{}, holder_now{};
|
||||
std::atomic<bool> any{};
|
||||
// The bones hidden from everyone, as bits (bone b is bit b), for the sent pose filter without the lock.
|
||||
std::atomic<std::uint32_t> hidden_from_all{};
|
||||
// How every board is fitted (fit): the baseplates held on the deck, and the wheels' spread. Read by
|
||||
// the render hook without the lock; the owner is under it.
|
||||
std::atomic<bool> fitted{}, fit_plates{};
|
||||
@@ -117,7 +121,24 @@ bool store_float(std::uintptr_t address, float value) noexcept {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
void refresh_any(Shared& s) { any.store(!s.wanted.empty() || !s.parked.empty() || s.piece.has_value(), std::memory_order_release); }
|
||||
void refresh_any(Shared& s) {
|
||||
any.store(!s.wanted.empty() || !s.parked.empty() || s.piece.has_value(), std::memory_order_release);
|
||||
std::uint32_t bits = 0;
|
||||
for (const auto& [b, want] : s.wanted)
|
||||
if (want.hidden && want.everyone) bits |= 1u << b;
|
||||
hidden_from_all.store(bits, std::memory_order_release);
|
||||
}
|
||||
// The local skater's pose as it is sent: the bones hidden from everyone out of sight in it too, as they
|
||||
// are drawn here. board[0] is the board entity's place in the world, then its rig's bones from 0.
|
||||
void on_sent_pose(std::uintptr_t, multiplayer::Pose& pose) noexcept {
|
||||
const auto bits = hidden_from_all.load(std::memory_order_acquire);
|
||||
if (!bits) return;
|
||||
for (int b = first_writable; b < bones; ++b)
|
||||
if ((bits & (1u << b)) && static_cast<std::size_t>(b + 1) < pose.board.size()) {
|
||||
auto& at = pose.board[static_cast<std::size_t>(b + 1)].position;
|
||||
if (length(at) < 100.0f) at[1] -= 2000.0f;
|
||||
}
|
||||
}
|
||||
std::string take(Shared& s, std::string_view owner) {
|
||||
if (!s.owner.empty() && s.owner != owner) return s.owner + " holds the board";
|
||||
s.owner = owner;
|
||||
@@ -175,16 +196,16 @@ std::string set(std::string_view owner, int bone, const Bone& value, Space space
|
||||
auto& s = shared();
|
||||
std::lock_guard lock(s.mutex);
|
||||
if (auto why = take(s, owner); !why.empty()) return why;
|
||||
s.wanted[bone] = {false, normalised(value.rotation), value.position, space};
|
||||
s.wanted[bone] = {false, false, normalised(value.rotation), value.position, space};
|
||||
refresh_any(s);
|
||||
return {};
|
||||
}
|
||||
std::string hide(std::string_view owner, int bone) {
|
||||
std::string hide(std::string_view owner, int bone, bool everyone) {
|
||||
if (bone < first_writable || bone >= bones) return "a board's bone is 2 to 15";
|
||||
auto& s = shared();
|
||||
std::lock_guard lock(s.mutex);
|
||||
if (auto why = take(s, owner); !why.empty()) return why;
|
||||
s.wanted[bone] = {true};
|
||||
s.wanted[bone] = {true, everyone};
|
||||
refresh_any(s);
|
||||
return {};
|
||||
}
|
||||
@@ -265,6 +286,11 @@ void tick(std::uintptr_t base, std::uintptr_t component) noexcept {
|
||||
} catch (...) {}
|
||||
base_now.store(base, std::memory_order_release);
|
||||
holder_now.store(holder, std::memory_order_release);
|
||||
static bool sending{};
|
||||
if (!sending) {
|
||||
multiplayer::set_local_sent_pose_filter(&on_sent_pose);
|
||||
sending = true;
|
||||
}
|
||||
}
|
||||
|
||||
void on_render_pose(std::uintptr_t animation_interface) noexcept {
|
||||
|
||||
@@ -28,7 +28,9 @@ inline constexpr int bones = 16, first_writable = 2;
|
||||
|
||||
// Client thread. One owner holds the local board's bones at a time; empty when taken, else why not.
|
||||
std::string set(std::string_view owner, int bone, const Bone& value, Space space);
|
||||
std::string hide(std::string_view owner, int bone);
|
||||
// `everyone`: hidden from the other players too (the board as you are sent: Extension/Multiplayer/
|
||||
// Remote/native_skater.h's sent pose filter), not only on this screen.
|
||||
std::string hide(std::string_view owner, int bone, bool everyone = false);
|
||||
void clear(std::string_view owner, int bone = -1);
|
||||
// These bones go out of sight on every board, other players' included (a breakable deck's
|
||||
// stretches, which the game would draw as cracks).
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
#include "carry.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace dingosdk::carry {
|
||||
namespace {
|
||||
using anim_studio::conjugate;
|
||||
using anim_studio::multiply;
|
||||
using anim_studio::normalised;
|
||||
using anim_studio::rotate;
|
||||
using arm_reach::chain;
|
||||
using arm_reach::compose;
|
||||
|
||||
Vec3 add(const Vec3& a, const Vec3& b) { return {a[0] + b[0], a[1] + b[1], a[2] + b[2]}; }
|
||||
Vec3 sub(const Vec3& a, const Vec3& b) { return {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; }
|
||||
Vec3 scaled(const Vec3& a, float k) { return {a[0] * k, a[1] * k, a[2] * k}; }
|
||||
float dot(const Vec3& a, const Vec3& b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
|
||||
Vec3 cross(const Vec3& a, const Vec3& b) { return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]}; }
|
||||
float length(const Vec3& a) { return std::sqrt(dot(a, a)); }
|
||||
Vec3 unit(const Vec3& a, const Vec3& fallback) {
|
||||
const float l = length(a);
|
||||
return l > 1e-6f && std::isfinite(l) ? scaled(a, 1.0f / l) : fallback;
|
||||
}
|
||||
bool finite(const Vec3& v) { return std::isfinite(v[0]) && std::isfinite(v[1]) && std::isfinite(v[2]); }
|
||||
bool finite(const Quat& q) { return std::isfinite(q[0]) && std::isfinite(q[1]) && std::isfinite(q[2]) && std::isfinite(q[3]); }
|
||||
// The shortest turn taking direction `from` onto direction `to`.
|
||||
Quat arc(const Vec3& from, const Vec3& to) {
|
||||
const Vec3 a = unit(from, {0, 0, 1}), b = unit(to, {0, 0, 1});
|
||||
const float d = dot(a, b);
|
||||
if (d < -0.9999f) { // half a turn: about any axis square to `from`
|
||||
Vec3 axis = cross({1, 0, 0}, a);
|
||||
if (length(axis) < 1e-3f) axis = cross({0, 1, 0}, a);
|
||||
axis = unit(axis, {0, 1, 0});
|
||||
return {axis[0], axis[1], axis[2], 0.0f};
|
||||
}
|
||||
const Vec3 c = cross(a, b);
|
||||
return normalised({c[0], c[1], c[2], 1.0f + d});
|
||||
}
|
||||
// Part way from a to b, the short way round.
|
||||
Quat blend(const Quat& a, Quat b, float t) {
|
||||
t = std::clamp(t, 0.0f, 1.0f);
|
||||
if (a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3] < 0.0f)
|
||||
for (auto& v : b) v = -v;
|
||||
return normalised({a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t});
|
||||
}
|
||||
Quat about_up(float radians) { return {0.0f, std::sin(radians / 2), 0.0f, std::cos(radians / 2)}; }
|
||||
// The world turn `world` as the local rotation of a joint whose parent is placed at `parent`.
|
||||
Quat local_of(const Placed& parent, const Quat& world) { return normalised(multiply(conjugate(parent.rotation), world)); }
|
||||
// The way an aimed thing faces round the upright (level, unit), steady however steeply it points up or
|
||||
// down: straight down, its forward has no level part left, but its up does (the top of it is the way
|
||||
// it faces).
|
||||
Vec3 level_heading(const Quat& aim) {
|
||||
const Quat q = normalised(aim);
|
||||
const Vec3 forward = rotate(q, {0.0f, 0.0f, -1.0f}), up = rotate(q, {0.0f, 1.0f, 0.0f});
|
||||
const float sin_p = std::clamp(forward[1], -1.0f, 1.0f), cos_p = std::sqrt(std::max(0.0f, 1.0f - sin_p * sin_p));
|
||||
return unit(Vec3{forward[0] * cos_p - up[0] * sin_p, 0.0f, forward[2] * cos_p - up[2] * sin_p}, {0.0f, 0.0f, 1.0f});
|
||||
}
|
||||
|
||||
// In an arm's chain: chest, shoulder, upper arm, forearm, hand.
|
||||
constexpr std::size_t chest = 6, shoulder = 7, upper = 8, fore = 9, hand_at = 10;
|
||||
// Held, not worn: from the wrist joint to the middle of the palm along the hand.
|
||||
constexpr float palm_from_wrist = 0.075f;
|
||||
// Where the other hand takes it, in the item's own frame (right, up, back; the grip at the origin), for
|
||||
// a right-hand grip: the left side of it, underneath. Mirrored for a left grip.
|
||||
constexpr Vec3 support_from_grip{-0.08f, -0.16f, -0.04f};
|
||||
// The body as a column the arm keeps out of: about the hips' and chest's line, this far out at most
|
||||
// (and inside the shoulders).
|
||||
constexpr float body_radius = 0.24f;
|
||||
// Its front is held at least this far out in front of the shoulder (along the aim), and at least this
|
||||
// far below it.
|
||||
constexpr float min_reach_out = 0.4f, below_shoulder = 0.05f;
|
||||
// Toward the holding hand's own side it comes up this much (all the way square to that side).
|
||||
constexpr float side_lift = 0.2f;
|
||||
|
||||
// One foot lifted `up` metres and moved by `move` (a step), the knee bending to it the way it already
|
||||
// bends; the foot keeps its turn.
|
||||
bool step_foot(std::span<Joint> pose, std::span<const std::uint16_t> path, float up, const Vec3& move) noexcept {
|
||||
std::array<Placed, 6> now{};
|
||||
if (!compose(pose, path, now)) return false;
|
||||
const Vec3 hip = now[3].position, knee = now[4].position;
|
||||
const Vec3 ankle = add(now[5].position, add(Vec3{0.0f, up, 0.0f}, move));
|
||||
const float a = length(sub(knee, hip)), b = length(sub(now[5].position, knee));
|
||||
if (!(a > 0.1f && b > 0.1f)) return true;
|
||||
const float d = std::clamp(length(sub(ankle, hip)), std::abs(a - b) + 0.01f, (a + b) * 0.999f);
|
||||
const Vec3 along = unit(sub(ankle, hip), {0, -1, 0});
|
||||
Vec3 pole = sub(knee, add(hip, scaled(along, dot(sub(knee, hip), along))));
|
||||
pole = unit(pole, Vec3{0.0f, 0.0f, 1.0f});
|
||||
const float cos_a = std::clamp((a * a + d * d - b * b) / (2.0f * a * d), -1.0f, 1.0f);
|
||||
const float sin_a = std::sqrt(std::max(0.0f, 1.0f - cos_a * cos_a));
|
||||
const Vec3 new_knee = add(hip, add(scaled(along, a * cos_a), scaled(pole, a * sin_a)));
|
||||
const Quat foot_world = now[5].rotation;
|
||||
const Quat thigh_world = normalised(multiply(arc(sub(knee, hip), sub(new_knee, hip)), now[3].rotation));
|
||||
pose[path[3]].rotation = local_of(now[2], thigh_world);
|
||||
if (!compose(pose, path, now)) return false;
|
||||
const Quat shin_world = normalised(multiply(arc(sub(now[5].position, now[4].position), sub(ankle, now[4].position)), now[4].rotation));
|
||||
pose[path[4]].rotation = local_of(now[3], shin_world);
|
||||
if (!compose(pose, path, now)) return false;
|
||||
pose[path[5]].rotation = local_of(now[4], foot_world);
|
||||
return true;
|
||||
}
|
||||
|
||||
struct One {
|
||||
Hand hand = Hand::right;
|
||||
Vec3 front{};
|
||||
Quat aim{0, 0, 0, 1};
|
||||
Vec3 front_from_grip{};
|
||||
std::optional<Grip> worn;
|
||||
float weight = 1.0f;
|
||||
// The front reached for where it is (no holding it out in front and below the shoulder): the
|
||||
// supporting hand's reach.
|
||||
bool free{};
|
||||
};
|
||||
|
||||
// One arm holding the item (its upper arm and forearm, and the hand when it is worn).
|
||||
Held hold_one(std::span<Joint> pose, const One& goal) noexcept {
|
||||
const auto joints = chain(goal.hand);
|
||||
std::array<Placed, arm_reach::right_chain.size()> w{};
|
||||
if (!compose(pose, joints, w)) return {};
|
||||
const Quat aim = normalised(goal.aim);
|
||||
const Vec3 s = w[upper].position, e = w[fore].position, h = w[hand_at].position;
|
||||
const float a = length(sub(e, s)), b = length(sub(h, e));
|
||||
if (!(a > 0.05f && b > 0.05f && a < 1.0f && b < 1.0f)) return {};
|
||||
const float scale = std::max(w[hand_at].scale, 0.1f);
|
||||
// Where its front is shown held: where it was asked for, but out in front of the shoulder along
|
||||
// the aim and no higher than the shoulder, as a person holds a thing they point (a front up by the
|
||||
// head or tucked against the body would fold the arm up into the face).
|
||||
Vec3 front = goal.front;
|
||||
float to_own_side = 0.0f;
|
||||
if (!goal.free) {
|
||||
const Vec3 ahead = level_heading(aim);
|
||||
const float out = dot(sub(front, s), ahead);
|
||||
// Toward the holding hand's own side the arm would stretch across to its full length: it comes
|
||||
// in closer and up a little there, the elbow bent.
|
||||
Vec3 own_side = sub(s, w[chest].position);
|
||||
own_side[1] = 0.0f;
|
||||
own_side = unit(own_side, ahead);
|
||||
const float toward_side = std::clamp(dot(ahead, own_side), 0.0f, 1.0f);
|
||||
to_own_side = toward_side;
|
||||
// Where it is held follows the aim in one smooth sweep: level, out in front at the chest, under
|
||||
// the eyes; aimed down, lower and a little closer, all the way down to the stomach; aimed up,
|
||||
// higher and closer to the face, up over the head.
|
||||
const float pointing_y = std::clamp(rotate(aim, {0.0f, 0.0f, -1.0f})[1], -1.0f, 1.0f);
|
||||
const float up_part = std::max(pointing_y, 0.0f), down_part = std::max(-pointing_y, 0.0f);
|
||||
const float height = -below_shoulder - 0.13f * (1.0f - up_part) + 0.55f * up_part - 0.45f * down_part;
|
||||
const float reach_out = (min_reach_out + 0.08f * (1.0f - up_part)) * (1.0f - 0.35f * toward_side) * (1.0f - 0.55f * up_part) *
|
||||
(1.0f - 0.15f * down_part);
|
||||
const Vec3 across = cross({0.0f, 1.0f, 0.0f}, ahead);
|
||||
const float lateral = std::clamp(dot(sub(front, s), across), -0.3f, 0.3f);
|
||||
const float out_now = std::max(out, 0.0f);
|
||||
// (Further out than that only toward the other side, across the body.)
|
||||
const float out_held = toward_side > 0.0f && out_now > reach_out + 0.1f ? out_now - (out_now - reach_out - 0.1f) * toward_side : reach_out;
|
||||
front = add(add(s, scaled(ahead, out_held)), scaled(across, lateral));
|
||||
front[1] = s[1] + height + side_lift * toward_side;
|
||||
}
|
||||
const Grip worn = goal.worn.value_or(Grip{});
|
||||
const Quat hand_world = normalised(multiply(aim, conjugate(worn.item)));
|
||||
const Vec3 grip_offset = scaled(rotate(hand_world, worn.from_wrist), scale);
|
||||
Vec3 palm_goal = goal.worn ? sub(sub(front, rotate(aim, goal.front_from_grip)), grip_offset) : sub(front, rotate(aim, goal.front_from_grip));
|
||||
if (!goal.free) {
|
||||
// The hand itself out in front of the shoulder and not up over it: with the hand tucked in over
|
||||
// the shoulder the arm can only fold up, the elbow rising by the head. Aimed up it comes up and
|
||||
// in a little, smoothly with the tilt.
|
||||
const Vec3 forward = level_heading(aim);
|
||||
const float up_part = std::clamp(rotate(aim, {0.0f, 0.0f, -1.0f})[1], 0.0f, 1.0f);
|
||||
const float out_min = (0.24f - 0.06f * up_part) * (1.0f - 0.35f * to_own_side);
|
||||
const float out_now = dot(sub(palm_goal, s), forward);
|
||||
if (out_now < out_min) palm_goal = add(palm_goal, scaled(forward, out_min - out_now));
|
||||
const float top = s[1] + 0.04f + 0.22f * up_part + side_lift * to_own_side;
|
||||
if (palm_goal[1] > top) palm_goal[1] = top;
|
||||
}
|
||||
{
|
||||
// The body as a column through the hips and chest: the hand's target, and the straight reach to
|
||||
// it from the shoulder, kept out of it (pushed out along the way they came closest).
|
||||
const Vec3 low = w[2].position, high = w[chest].position;
|
||||
const float bottom = low[1] - 0.35f, top = high[1] + 0.15f;
|
||||
const auto axis_at = [&](float y) {
|
||||
const float t = std::clamp((y - low[1]) / std::max(high[1] - low[1], 0.05f), 0.0f, 1.0f);
|
||||
return Vec3{low[0] + (high[0] - low[0]) * t, y, low[2] + (high[2] - low[2]) * t};
|
||||
};
|
||||
// As wide as the body is: inside the shoulders (the arm hangs from the column's edge).
|
||||
Vec3 shoulder_out = sub(s, axis_at(s[1]));
|
||||
shoulder_out[1] = 0.0f;
|
||||
const float radius = std::min(body_radius, length(shoulder_out) * 0.85f);
|
||||
for (int round = 0; round < 3; ++round) {
|
||||
float nearest = radius;
|
||||
Vec3 out_dir{};
|
||||
for (int k = 3; k <= 8; ++k) {
|
||||
const Vec3 p = add(s, scaled(sub(palm_goal, s), static_cast<float>(k) / 8.0f));
|
||||
if (p[1] < bottom || p[1] > top) continue;
|
||||
Vec3 radial = sub(p, axis_at(p[1]));
|
||||
radial[1] = 0.0f;
|
||||
const float r = length(radial);
|
||||
if (r < nearest) {
|
||||
nearest = r;
|
||||
Vec3 side_out = sub(s, w[chest].position);
|
||||
side_out[1] = 0.0f;
|
||||
out_dir = unit(radial, unit(side_out, {goal.hand == Hand::left ? -1.0f : 1.0f, 0, 0}));
|
||||
}
|
||||
}
|
||||
if (nearest >= radius) break;
|
||||
palm_goal = add(palm_goal, scaled(out_dir, radius - nearest + 0.01f));
|
||||
}
|
||||
}
|
||||
const float palm = goal.worn ? 0.0f : palm_from_wrist * scale, b_palm = b + palm;
|
||||
// Never held at full stretch: the elbow keeps a bend (it comes in a little rather than the arm
|
||||
// locking straight).
|
||||
const float reach = (a + b_palm) * (goal.free ? 0.995f : 0.93f);
|
||||
const float wanted = length(sub(palm_goal, s));
|
||||
const float d = std::clamp(wanted, std::abs(a - b_palm) + 0.02f, reach);
|
||||
const Vec3 direction = unit(sub(palm_goal, s), {0, -1, 0});
|
||||
const Vec3 target = add(s, scaled(direction, d));
|
||||
// The elbow out from the body and a little down and back: the shoulder's side of the chest.
|
||||
Vec3 side = sub(s, w[chest].position);
|
||||
side[1] = 0.0f;
|
||||
side = unit(side, {goal.hand == Hand::left ? -1.0f : 1.0f, 0, 0});
|
||||
// (Forward for the elbow: the way the arm reaches, level; reaching nearly straight up it has almost
|
||||
// none, and would flip about, so it leans on the way the item points then.)
|
||||
const Vec3 forward = level_heading(aim);
|
||||
Vec3 ahead{direction[0], 0.0f, direction[2]};
|
||||
ahead = add(ahead, scaled(forward, std::max(0.0f, 0.6f - length(ahead))));
|
||||
ahead = unit(ahead, forward);
|
||||
Vec3 pole = add(add(scaled(side, 0.8f), Vec3{0.0f, -0.45f, 0.0f}), scaled(ahead, -0.35f));
|
||||
{
|
||||
// Raised (up to the eye, up over the head): the elbow comes round to the front and a little out,
|
||||
// as an arm does lifting something up in front of the face, gradually with the aim; never back
|
||||
// behind the shoulder, never winged straight out.
|
||||
const float lifted = std::clamp((rotate(aim, {0.0f, 0.0f, -1.0f})[1] - 0.1f) / 0.8f, 0.0f, 1.0f);
|
||||
const float ease_in = lifted * lifted * (3.0f - 2.0f * lifted);
|
||||
if (ease_in > 0.0f) {
|
||||
const Vec3 lifted_pole = add(add(scaled(side, 0.85f), Vec3{0.0f, -0.4f, 0.0f}), scaled(forward, 0.35f));
|
||||
pole = add(scaled(pole, 1.0f - ease_in), scaled(lifted_pole, ease_in));
|
||||
}
|
||||
}
|
||||
pole = sub(pole, scaled(direction, dot(pole, direction)));
|
||||
{
|
||||
// The elbow hangs: whatever way the arm reaches, it bends with the elbow below the line from
|
||||
// shoulder to hand (and out to the side), never folding up over the shoulder. (Reaching up high,
|
||||
// straight down from that line would put it back behind the shoulder: there it only comes down
|
||||
// as far as it can without going back.)
|
||||
Vec3 hang = sub(Vec3{0.0f, -1.0f, 0.0f}, scaled(direction, -direction[1]));
|
||||
const float back = dot(hang, forward);
|
||||
if (back < 0.0f) hang = sub(hang, scaled(forward, back));
|
||||
hang = sub(hang, scaled(direction, dot(hang, direction)));
|
||||
const float hang_len = length(hang);
|
||||
if (hang_len > 0.05f) pole = add(unit(pole, side), scaled(hang, 0.6f / hang_len));
|
||||
}
|
||||
pole = unit(pole, side);
|
||||
const float cos_a = std::clamp((a * a + d * d - b_palm * b_palm) / (2.0f * a * d), -1.0f, 1.0f);
|
||||
const float sin_a = std::sqrt(std::max(0.0f, 1.0f - cos_a * cos_a));
|
||||
const Vec3 elbow = add(s, add(scaled(direction, a * cos_a), scaled(pole, a * sin_a)));
|
||||
const Vec3 wrist = add(elbow, scaled(unit(sub(target, elbow), direction), b));
|
||||
// The upper arm turned onto the new elbow, then the forearm (as the upper arm left it) onto the wrist.
|
||||
const Quat turn_upper = arc(sub(e, s), sub(elbow, s));
|
||||
const Quat upper_world = normalised(multiply(turn_upper, w[upper].rotation));
|
||||
const Quat turn_fore = arc(rotate(turn_upper, sub(h, e)), sub(wrist, elbow));
|
||||
const Quat fore_world = normalised(multiply(turn_fore, multiply(turn_upper, w[fore].rotation)));
|
||||
// Back to each joint's own rotation, part way by the weight.
|
||||
const float weight = std::clamp(goal.weight, 0.0f, 1.0f);
|
||||
auto& upper_bone = pose[joints[upper]];
|
||||
auto& fore_bone = pose[joints[fore]];
|
||||
upper_bone.rotation = blend(upper_bone.rotation, normalised(multiply(conjugate(w[shoulder].rotation), upper_world)), weight);
|
||||
fore_bone.rotation = blend(fore_bone.rotation, normalised(multiply(conjugate(upper_world), fore_world)), weight);
|
||||
if (goal.worn) {
|
||||
// The hand, from the forearm as it now is, turned so the worn item points along the aim.
|
||||
if (!compose(pose, joints, w)) return {};
|
||||
auto& hand_bone = pose[joints[hand_at]];
|
||||
hand_bone.rotation = blend(hand_bone.rotation, normalised(multiply(conjugate(w[fore].rotation), hand_world)), weight);
|
||||
}
|
||||
// Where the hand ended up, from the pose as it now is.
|
||||
if (!compose(pose, joints, w)) return {};
|
||||
Held held;
|
||||
if (goal.worn) {
|
||||
held.item.rotation = normalised(multiply(w[hand_at].rotation, worn.item));
|
||||
held.item.position = add(w[hand_at].position, scaled(rotate(w[hand_at].rotation, worn.from_wrist), scale));
|
||||
} else {
|
||||
// Held: the grip lies on the forearm's line, past the wrist.
|
||||
const Vec3 along = unit(sub(w[hand_at].position, w[fore].position), {0, -1, 0});
|
||||
held.item.position = add(w[hand_at].position, scaled(along, palm_from_wrist * scale));
|
||||
held.item.rotation = aim;
|
||||
}
|
||||
held.front = add(held.item.position, rotate(held.item.rotation, goal.front_from_grip));
|
||||
held.valid = finite(held.item.position) && finite(held.front);
|
||||
held.reached = wanted <= reach + 0.01f;
|
||||
return held;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool crouch(std::span<Joint> pose, float depth, float twist) noexcept {
|
||||
if (!((depth > 0.001f || std::abs(twist) > 0.001f) && std::isfinite(depth) && std::isfinite(twist))) return true;
|
||||
depth = std::clamp(depth, 0.0f, 0.6f);
|
||||
struct Leg {
|
||||
std::span<const std::uint16_t> path;
|
||||
std::array<Placed, 6> before{};
|
||||
};
|
||||
std::array<Leg, 2> legs{{{right_leg}, {left_leg}}};
|
||||
for (auto& leg : legs)
|
||||
if (!compose(pose, leg.path, leg.before)) return false;
|
||||
// The hips down, in their parent's frame (the skater's placement).
|
||||
const auto& parent = legs[0].before[1];
|
||||
auto& hips = pose[hips_joint];
|
||||
const Vec3 drop = rotate(conjugate(parent.rotation), Vec3{0.0f, -depth, 0.0f});
|
||||
hips.position = add(hips.position, scaled(drop, 1.0f / std::max(parent.scale, 0.1f)));
|
||||
// Turned round the upright, the feet staying where they are.
|
||||
if (std::abs(twist) > 0.001f) {
|
||||
std::array<Placed, 6> now{};
|
||||
if (!compose(pose, legs[0].path, now)) return false;
|
||||
hips.rotation = local_of(parent, normalised(multiply(about_up(twist), now[2].rotation)));
|
||||
}
|
||||
for (auto& leg : legs) {
|
||||
std::array<Placed, 6> now{};
|
||||
if (!compose(pose, leg.path, now)) return false;
|
||||
const Vec3 hip = now[3].position, knee = leg.before[4].position, ankle = leg.before[5].position;
|
||||
const float a = length(sub(leg.before[4].position, leg.before[3].position)), b = length(sub(ankle, knee));
|
||||
if (!(a > 0.1f && b > 0.1f)) continue;
|
||||
const float d = std::clamp(length(sub(ankle, hip)), std::abs(a - b) + 0.01f, (a + b) * 0.999f);
|
||||
const Vec3 along = unit(sub(ankle, hip), {0, -1, 0});
|
||||
// The knee bends the way it already does (forward of the hip-to-ankle line).
|
||||
const Vec3 old_line = unit(sub(ankle, leg.before[3].position), along);
|
||||
const Vec3 old_mid = add(leg.before[3].position, scaled(old_line, dot(sub(knee, leg.before[3].position), old_line)));
|
||||
Vec3 pole = sub(knee, old_mid);
|
||||
pole = sub(pole, scaled(along, dot(pole, along)));
|
||||
pole = unit(pole, Vec3{0.0f, 0.0f, 1.0f});
|
||||
const float cos_a = std::clamp((a * a + d * d - b * b) / (2.0f * a * d), -1.0f, 1.0f);
|
||||
const float sin_a = std::sqrt(std::max(0.0f, 1.0f - cos_a * cos_a));
|
||||
const Vec3 new_knee = add(hip, add(scaled(along, a * cos_a), scaled(pole, a * sin_a)));
|
||||
// Thigh, then shin, turned onto the new knee and the ankle; the foot keeps its turn in the world.
|
||||
const Quat thigh_world = normalised(multiply(arc(sub(now[4].position, hip), sub(new_knee, hip)), now[3].rotation));
|
||||
pose[leg.path[3]].rotation = local_of(now[2], thigh_world);
|
||||
if (!compose(pose, leg.path, now)) return false;
|
||||
const Quat shin_world = normalised(multiply(arc(sub(now[5].position, now[4].position), sub(ankle, now[4].position)), now[4].rotation));
|
||||
pose[leg.path[4]].rotation = local_of(now[3], shin_world);
|
||||
if (!compose(pose, leg.path, now)) return false;
|
||||
pose[leg.path[5]].rotation = local_of(now[4], leg.before[5].rotation);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool hunch(std::span<Joint> pose, const Vec3& toward, float angle, float look, float twist) noexcept {
|
||||
if (!(std::abs(angle) + std::abs(look) + std::abs(twist) > 1e-4f) || !finite(toward)) return true;
|
||||
Vec3 level{toward[0], 0.0f, toward[2]};
|
||||
if (length(level) < 1e-4f) return true;
|
||||
level = unit(level, {0, 0, 1});
|
||||
// Turning about this bends the top of the body over toward `level`.
|
||||
const Vec3 axis = unit(cross(Vec3{0.0f, 1.0f, 0.0f}, level), {1, 0, 0});
|
||||
// Spine, Spine1, Spine2, Spine3 share the back's angle; Neck, Neck1 and Head the look.
|
||||
constexpr std::array<float, 7> share{0.15f, 0.25f, 0.3f, 0.3f, 0.35f, 0.25f, 0.4f};
|
||||
constexpr std::array<float, 7> twist_share{0.1f, 0.1f, 0.15f, 0.2f, 0.15f, 0.1f, 0.2f};
|
||||
for (std::size_t i = 0; i < back_joints.size(); ++i) {
|
||||
std::array<Placed, head_chain.size()> w{};
|
||||
if (!compose(pose, head_chain, w)) return false;
|
||||
const std::size_t at = 3 + i; // its place in head_chain
|
||||
const float turn = (i < 4 ? angle : look) * share[i];
|
||||
const Quat bend{axis[0] * std::sin(turn / 2), axis[1] * std::sin(turn / 2), axis[2] * std::sin(turn / 2), std::cos(turn / 2)};
|
||||
const Quat world = normalised(multiply(bend, multiply(about_up(twist * twist_share[i]), w[at].rotation)));
|
||||
pose[back_joints[i]].rotation = local_of(w[at - 1], world);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Held carry(std::span<Joint> pose, const Goal& goal) noexcept {
|
||||
if (pose.size() <= 343 || !finite(goal.front) || !finite(goal.aim) || !finite(goal.front_from_grip)) return {};
|
||||
const float body_weight = std::clamp(goal.weight, 0.0f, 1.0f);
|
||||
if (std::abs(goal.turn) > 1e-4f && std::isfinite(goal.turn)) {
|
||||
// The whole body turned round the upright through the hips: the hips (everything hangs from
|
||||
// them) turned in place.
|
||||
std::array<Placed, 3> w3{};
|
||||
if (!compose(pose, std::span<const std::uint16_t>(right_leg).first(3), w3)) return {};
|
||||
pose[hips_joint].rotation = local_of(w3[1], normalised(multiply(about_up(goal.turn * body_weight), w3[2].rotation)));
|
||||
}
|
||||
// Turning on the spot: the feet step round with it, one then the other, each lifted a little and
|
||||
// set down a little in under the hips.
|
||||
if (goal.step > 0.01f && std::isfinite(goal.step_phase)) {
|
||||
const float phase = goal.step_phase - std::floor(goal.step_phase);
|
||||
const bool right_foot = phase < 0.5f;
|
||||
const float half = right_foot ? phase * 2.0f : (phase - 0.5f) * 2.0f;
|
||||
const float lift = std::sin(half * 3.14159265f) * 0.09f * std::clamp(goal.step, 0.0f, 1.0f) * body_weight;
|
||||
std::array<Placed, 6> leg{};
|
||||
const auto path = right_foot ? std::span<const std::uint16_t>(right_leg) : std::span<const std::uint16_t>(left_leg);
|
||||
if (lift > 0.002f && compose(pose, path, leg)) {
|
||||
Vec3 in = sub(leg[2].position, leg[5].position);
|
||||
in[1] = 0.0f;
|
||||
const float inward = std::min(length(in), 0.06f) * std::sin(half * 3.14159265f);
|
||||
if (!step_foot(pose, path, lift, scaled(unit(in, {0, 0, 0}), inward * 0.5f))) return {};
|
||||
}
|
||||
}
|
||||
// Which way it points against the way the body faces (round the upright), for the body turning to
|
||||
// it: the hips a quarter, the back and shoulders and head the rest.
|
||||
float full = 0.0f, pitch = 0.0f;
|
||||
Vec3 facing{0, 0, 1};
|
||||
if (goal.hunch > 0.0f || goal.crouch > 0.0f) {
|
||||
facing = body_facing(pose);
|
||||
const Vec3 level = level_heading(goal.aim);
|
||||
full = std::clamp(std::atan2(dot(cross(facing, level), {0.0f, 1.0f, 0.0f}), dot(facing, level)), -1.6f, 1.6f);
|
||||
pitch = std::asin(std::clamp(rotate(normalised(goal.aim), {0.0f, 0.0f, -1.0f})[1], -1.0f, 1.0f)); // down is negative
|
||||
}
|
||||
if (goal.still)
|
||||
for (std::size_t i = 0; i < back_joints.size(); ++i)
|
||||
pose[back_joints[i]].rotation = blend(pose[back_joints[i]].rotation, standing_back[i], body_weight);
|
||||
constexpr float hips_share = 0.25f;
|
||||
const bool turning = goal.hunch > 0.0f;
|
||||
if ((goal.crouch > 0.0f || turning) && !crouch(pose, goal.crouch * body_weight, turning ? full * hips_share * body_weight : 0.0f)) return {};
|
||||
if (turning) {
|
||||
// Over it, the way the body faces: toward the aim, but no more than 60 degrees off the body's own
|
||||
// facing (never folded backwards).
|
||||
const Vec3 toward = rotate(about_up(std::clamp(full, -1.05f, 1.05f)), facing);
|
||||
// Pointed well up, it is held up to the eye instead: no curl over it, the head tilted back to
|
||||
// it (eased in with the tilt).
|
||||
const float up_t = std::clamp((pitch - 0.3f) / 0.6f, 0.0f, 1.0f);
|
||||
// The back over it as it goes down with the aim, a little at level, more aimed down; the head
|
||||
// down to it the same way, a touch at level, more as it goes down; aimed up, the head back.
|
||||
const float amount = goal.hunch * std::clamp(0.45f - pitch * (pitch < 0.0f ? 0.55f : 0.7f), 0.2f, 1.2f) * body_weight * (1.0f - up_t);
|
||||
const float look = std::clamp(0.2f - pitch * (pitch < 0.0f ? 0.18f : 0.45f), 0.1f, 0.65f) * body_weight * (1.0f - up_t) -
|
||||
up_t * std::min(pitch, 1.2f) * 0.45f * body_weight;
|
||||
// Up to the eye: the shoulders turn a little, the holding arm's shoulder coming forward, so the
|
||||
// arm reaches out in front of the face.
|
||||
float shoulders = 0.0f;
|
||||
if (up_t > 0.0f) {
|
||||
std::array<Placed, arm_reach::right_chain.size()> arm{};
|
||||
if (compose(pose, chain(goal.hand), arm)) {
|
||||
const Vec3 r = sub(arm[upper].position, arm[chest].position);
|
||||
const float way = dot(cross({0.0f, 1.0f, 0.0f}, r), facing) >= 0.0f ? 1.0f : -1.0f;
|
||||
shoulders = way * 0.35f * up_t * body_weight;
|
||||
}
|
||||
}
|
||||
// The shoulders and head turn the rest of the way to it off to the side.
|
||||
if (!hunch(pose, toward, amount, look, full * (1.0f - hips_share) * body_weight + shoulders)) return {};
|
||||
}
|
||||
One first;
|
||||
first.hand = goal.hand;
|
||||
first.front = goal.front;
|
||||
first.aim = goal.aim;
|
||||
first.front_from_grip = goal.front_from_grip;
|
||||
first.worn = goal.worn;
|
||||
first.weight = goal.weight;
|
||||
const auto held = hold_one(pose, first);
|
||||
if (!held.valid || !goal.both) return held;
|
||||
// The other hand to its side, underneath, where the first one put it.
|
||||
Vec3 support = support_from_grip;
|
||||
if (goal.hand == Hand::left) support[0] = -support[0];
|
||||
const Vec3 point = add(held.item.position, rotate(held.item.rotation, support));
|
||||
One other;
|
||||
other.hand = goal.hand == Hand::left ? Hand::right : Hand::left;
|
||||
other.aim = normalised(goal.aim);
|
||||
other.weight = goal.weight;
|
||||
other.free = true;
|
||||
other.front = point; // (front_from_grip 0: its grip goes to `point`)
|
||||
(void)hold_one(pose, other);
|
||||
return held;
|
||||
}
|
||||
|
||||
float pushing(std::span<const Joint> pose) noexcept {
|
||||
std::array<Placed, 6> r{}, l{};
|
||||
if (!compose(pose, right_leg, r) || !compose(pose, left_leg, l)) return 0.0f;
|
||||
const float apart = std::abs(r[5].position[1] - l[5].position[1]);
|
||||
return std::clamp((apart - 0.04f) / 0.06f, 0.0f, 1.0f);
|
||||
}
|
||||
|
||||
Vec3 body_facing(std::span<const Joint> pose) noexcept {
|
||||
std::array<Placed, 6> right_thigh{}, left_thigh{};
|
||||
if (!compose(pose, right_leg, right_thigh) || !compose(pose, left_leg, left_thigh)) return {0, 0, 1};
|
||||
Vec3 across = sub(left_thigh[3].position, right_thigh[3].position);
|
||||
across[1] = 0.0f;
|
||||
return unit(cross(across, {0.0f, 1.0f, 0.0f}), {0, 0, 1});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#pragma once
|
||||
#include "Extension/Skater/arm_reach.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
|
||||
// Something held out in the hands and aimed, the way a person holds a camera or a phone they point:
|
||||
// level, out in front at the chest; aimed down, lower and closer, down to the stomach; aimed up, up to
|
||||
// the eye and over the head. The elbows keep a bend and the arms go round the body; the other hand can
|
||||
// hold it too, from the side and underneath. The body can crouch over it (the feet kept where they
|
||||
// are), curl its back over it and look down at it, turn on the spot after it and step round as it does.
|
||||
// Pure maths on the 395-joint AnimBase pose (arm_reach.h), made from the Filmer's arm holding its
|
||||
// camera (PR 343, filmer_hold.cpp); skater_animation.cpp applies it as the pose is evaluated, drawn and sent.
|
||||
namespace dingosdk::carry {
|
||||
using anim_studio::Joint;
|
||||
using anim_studio::Quat;
|
||||
using anim_studio::Vec3;
|
||||
using arm_reach::Hand;
|
||||
using arm_reach::Placed;
|
||||
|
||||
// The legs from the root: the last three are thigh, shin and foot. The hips (7) carry them and the spine.
|
||||
inline constexpr std::array<std::uint16_t, 6> right_leg{0, 1, 7, 8, 9, 10};
|
||||
inline constexpr std::array<std::uint16_t, 6> left_leg{0, 1, 7, 341, 342, 343};
|
||||
inline constexpr std::uint16_t hips_joint = 7;
|
||||
// The back and head from the root: Spine, Spine1, Spine2, Spine3, Neck, Neck1, Head.
|
||||
inline constexpr std::array<std::uint16_t, 10> head_chain{0, 1, 7, 42, 43, 44, 45, 101, 102, 103};
|
||||
inline constexpr std::array<std::uint16_t, 7> back_joints{42, 43, 44, 45, 101, 102, 103};
|
||||
// The back, neck and head as the skeleton stands (each joint's own turn, from its bind pose).
|
||||
inline constexpr std::array<Quat, 7> standing_back{{{0.0f, 0.0f, 0.16592f, 0.98614f},
|
||||
{0.0f, 0.0f, -0.19038f, 0.98171f},
|
||||
{0.0f, 0.0f, -0.10393f, 0.99458f},
|
||||
{0.0f, 0.0f, 0.22027f, 0.97544f},
|
||||
{0.0f, 0.0f, 0.11357f, 0.99353f},
|
||||
{0.0f, 0.0f, -0.19607f, 0.98059f},
|
||||
{0.0f, 0.0f, -0.00959f, 0.99995f}}};
|
||||
// Every joint carry() may write, lowest first: the hips and legs, the back and head, both arms.
|
||||
inline constexpr std::array<std::uint16_t, 22> written{7, 8, 9, 10, 42, 43, 44, 45, 46, 47, 48, 49, 101, 102, 103, 275, 276, 277, 278, 341, 342, 343};
|
||||
|
||||
// Worn on the hand (a cosmetic weighted wholly to the hand): the item's rows (right, up, back) in the
|
||||
// hand joint's own frame, and where its grip is from the hand joint, in that frame, both from the
|
||||
// skeleton's bind pose. The hand turns so the item points along the aim.
|
||||
struct Grip {
|
||||
Quat item{0, 0, 0, 1};
|
||||
Vec3 from_wrist{};
|
||||
};
|
||||
struct Goal {
|
||||
Hand hand = Hand::right;
|
||||
Vec3 front{}; // where the item's front (a lens, a screen) should be, in the world
|
||||
Quat aim{0, 0, 0, 1}; // the item's rotation in the world: its -Z the way it points, +Y its top
|
||||
Vec3 front_from_grip{}; // its front from where the hand grips it, in its own frame
|
||||
std::optional<Grip> worn;
|
||||
float weight = 1.0f; // 0: the body as the animation has it, 1: carrying
|
||||
bool both{}; // the other hand holds it too, from the side and underneath
|
||||
float crouch{}; // how far the hips go down (metres), the feet kept where they are
|
||||
float hunch{}; // how far the back curls over it (radians), the head looking down at it
|
||||
bool still{}; // the back, neck and head from a still standing pose (no idle fidgets)
|
||||
float turn{}; // the whole body turned round the upright through the hips (radians)
|
||||
float step{}, step_phase{}; // turning on the spot: how much the feet step (0..1), where in the stride (one per 1)
|
||||
};
|
||||
struct Held {
|
||||
bool valid{};
|
||||
Placed item; // where the item's grip is, turned the way it points
|
||||
Vec3 front{}; // where its front ended up (short of the goal when out of reach)
|
||||
bool reached{};
|
||||
};
|
||||
// Bends the pose (in place) to carry the item as the goal says. Leaves the pose alone and returns
|
||||
// invalid when the joints are not there, or the arm's bones are not an arm's length.
|
||||
Held carry(std::span<Joint> pose, const Goal& goal) noexcept;
|
||||
// How much a pose is pushing on a board (0 to 1): one foot down on the ground well below the other.
|
||||
float pushing(std::span<const Joint> pose) noexcept;
|
||||
// Which way the body faces (level, in the world), from the line across its hips; {0, 0, 1} when the
|
||||
// legs are not there.
|
||||
Vec3 body_facing(std::span<const Joint> pose) noexcept;
|
||||
// Lowers the hips by `depth` (metres) and turns them round the upright by `twist` (radians), bending
|
||||
// the knees so each foot stays where it was, turned as it was. False when the legs are not there.
|
||||
bool crouch(std::span<Joint> pose, float depth, float twist = 0.0f) noexcept;
|
||||
// Curls the back over toward `toward` (a direction in the world, level) by `angle` radians spread
|
||||
// down the spine, and the neck and head on down by `look` more; first turns the upper body and the
|
||||
// head round the upright by `twist` radians (most of it in the neck and head).
|
||||
bool hunch(std::span<Joint> pose, const Vec3& toward, float angle, float look, float twist = 0.0f) noexcept;
|
||||
}
|
||||
@@ -1276,12 +1276,22 @@ constexpr ULONGLONG cycle_flags_lifetime_ms = 250;
|
||||
bool first_person_camera_height_write(bool high) noexcept {
|
||||
const auto published = cycle_published.load(std::memory_order_acquire);
|
||||
const auto flags = cycle_flags.load(std::memory_order_acquire);
|
||||
// A plugin's camera in the cycle (reskate.camera.cycle), last; its flags come from the plugin runtime.
|
||||
auto& plugin = first_person::plugin_link;
|
||||
const auto plugin_published = plugin.published.load(std::memory_order_acquire);
|
||||
const auto plugin_flags = plugin.flags.load(std::memory_order_acquire);
|
||||
const auto now = GetTickCount64();
|
||||
if (now < published || now - published > cycle_flags_lifetime_ms) return high;
|
||||
const bool fresh = now >= published && now - published <= cycle_flags_lifetime_ms;
|
||||
const bool plugin_fresh = now >= plugin_published && now - plugin_published <= cycle_flags_lifetime_ms;
|
||||
if (!fresh && !plugin_fresh) return high;
|
||||
const auto write = first_person::cycle_height_write(
|
||||
{(flags & cycle_enabled) != 0, (flags & cycle_first_person) != 0, (flags & cycle_can_enter) != 0}, high);
|
||||
{fresh && (flags & cycle_enabled) != 0, fresh && (flags & cycle_first_person) != 0, fresh && (flags & cycle_can_enter) != 0,
|
||||
plugin_fresh && (plugin_flags & first_person::plugin_flag_enabled) != 0, plugin_fresh && (plugin_flags & first_person::plugin_flag_on) != 0,
|
||||
plugin_fresh && (plugin_flags & first_person::plugin_flag_can_enter) != 0},
|
||||
high);
|
||||
if (write.step != first_person::CycleStep::none)
|
||||
cycle_request.store(static_cast<std::uint8_t>(write.step), std::memory_order_release);
|
||||
if (write.plugin != first_person::CycleStep::none) plugin.request.store(static_cast<std::uint8_t>(write.plugin), std::memory_order_release);
|
||||
return write.high;
|
||||
}
|
||||
void first_person_cycle_tick(SourceTrial& trial, std::uintptr_t client, bool can_control, bool phase, DWORD error) {
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace dingosdk::outfit_overlay {
|
||||
namespace {
|
||||
using skater_items::SkaterItems;
|
||||
using skater_items::lower;
|
||||
constexpr std::string_view allowed[] = {"tattoo", "moles", "eyewear"};
|
||||
constexpr std::string_view allowed[] = {"tattoo", "moles", "eyewear", "hand"};
|
||||
|
||||
struct Wanted {
|
||||
std::string owner, item;
|
||||
@@ -387,7 +387,7 @@ bool allowed_slot(std::string_view slot) noexcept {
|
||||
std::string set(std::string owner, std::string slot, std::string item, std::vector<float> placement, bool covers) {
|
||||
auto& s = state();
|
||||
slot = lower(slot);
|
||||
if (!allowed_slot(slot)) return "only tattoo, moles and eyewear slots";
|
||||
if (!allowed_slot(slot)) return "only tattoo, moles, eyewear and hand wear slots";
|
||||
if (const auto taken = s.wanted.find(slot); taken != s.wanted.end() && taken->second.owner != owner) return taken->second.owner + " has that slot";
|
||||
if (!content_cache::catalogs().available) return "the paid items are not known yet; try again in a moment";
|
||||
if (profile_runtime::reserved_cosmetic(item)) return "that is a paid store item";
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
// owner puts an item in a slot of the skater's recipe (by the slot's name: "tattoo_legl", "moles",
|
||||
// "eyewear"); the skater wears them over the outfit through the game's own recipe copy, and is given
|
||||
// back to the game while one of its menus is up (the menus change the outfit), when nothing is worn,
|
||||
// and when a plugin stops. Only the face and skin layers (tattoos, moles) and eyewear, and never a
|
||||
// paid store item. Others see what the skater wears. Client thread throughout.
|
||||
// and when a plugin stops. Only the face and skin layers (tattoos, moles), eyewear and hand wear (the
|
||||
// game has no menu for it: a camera in the hand), and never a paid store item. Others see what the
|
||||
// skater wears. Client thread throughout.
|
||||
namespace dingosdk::outfit_overlay {
|
||||
// The slots items may go in: a slot's name holds one of these.
|
||||
bool allowed_slot(std::string_view slot) noexcept;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <format>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
|
||||
// The pose paths of Animation Studio (PR 297, anim_studio.cpp: on_animation, on_render,
|
||||
@@ -62,6 +63,8 @@ double now_seconds() noexcept {
|
||||
}
|
||||
|
||||
// ---- transforms ------------------------------------------------------------------------------------
|
||||
// The smallest scale a posed joint is given: as small as hiding the skater makes its placement joint.
|
||||
constexpr float smallest_scale = 0.001f;
|
||||
// One transform after another (parent, then child in the parent's frame); uniform scale.
|
||||
Joint compose(const Joint& parent, const Joint& child) noexcept {
|
||||
const float k = (parent.scale[0] + parent.scale[1] + parent.scale[2]) / 3.0f;
|
||||
@@ -265,10 +268,16 @@ struct Plan {
|
||||
std::uint64_t record_serial{};
|
||||
std::optional<arm_reach::Goal> reach;
|
||||
double reach_until{}, joints_until{}; // now_seconds() until which the reach holds, and the joints are read
|
||||
Shown reach_shown{};
|
||||
std::optional<carry::Goal> carry; // carry(), until carry_until
|
||||
double carry_until{};
|
||||
bool carry_push{};
|
||||
Shown carry_shown{};
|
||||
std::vector<std::uint16_t> joints_read; // joint(): the joints asked for, in order
|
||||
std::vector<PoseOffset> offsets; // pose(), until offsets_until
|
||||
float offsets_weight{1};
|
||||
double offsets_until{};
|
||||
Shown offsets_shown{};
|
||||
};
|
||||
// The game menu's character room (PR 297's studio room): the spot of the menu's skater, how far from
|
||||
// it a rig counts as in the room, and the room's floor (measured 2026-10-10 on Stadium 1 and San
|
||||
@@ -310,6 +319,15 @@ struct Live {
|
||||
std::atomic<std::uintptr_t> base{}, component{}, board_holder{};
|
||||
std::vector<NativeBone> bones;
|
||||
std::vector<Joint> pose, custom, scratch;
|
||||
std::vector<Joint> sent; // the pose the others are sent, when it is not the one drawn (Shown)
|
||||
float carry_push{}; // how much a foot is down pushing, eased (carry's push_straightens)
|
||||
// The game's own scale of each joint a pose() scaled in the pose drawn. The game never sets a joint's
|
||||
// scale again, so the scale the last render wrote is still in the buffer at the next update: pose()
|
||||
// scaled it again on top (a forearm at 0.001 was at 1e-6 the next frame, and on to 0), and it stayed
|
||||
// when the pose ended (Filmer's own-view forearm, 2026-10-22: the arms gone after Filmer stopped,
|
||||
// and on foot the body physics, which read the buffer, lost the skater). Put back at every update.
|
||||
std::map<std::uint16_t, std::array<float, 3>> own_scale;
|
||||
std::uintptr_t own_scale_buffer{};
|
||||
// In the room: the placement written for the last render, and the game's own one before it.
|
||||
Joint room_written, game_placement;
|
||||
bool room_written_valid{}, game_placement_valid{};
|
||||
@@ -544,8 +562,10 @@ void on_animation(std::uintptr_t component) noexcept {
|
||||
const bool reaching = snap->reach && now < snap->reach_until;
|
||||
const bool joints_wanted = now < snap->joints_until && !snap->joints_read.empty();
|
||||
const bool posing = !snap->offsets.empty() && now < snap->offsets_until;
|
||||
const bool carrying = snap->carry && now < snap->carry_until;
|
||||
const bool in_room = room_shown.load(std::memory_order_acquire);
|
||||
if (!busy && !reaching && !joints_wanted && !posing && !in_room && (!snap->allowed || (!snap->play && snap->replacements.empty()))) {
|
||||
if (!busy && !reaching && !carrying && !joints_wanted && !posing && !in_room && l.own_scale.empty() &&
|
||||
(!snap->allowed || (!snap->play && snap->replacements.empty()))) {
|
||||
std::lock_guard lock(pending().mutex);
|
||||
pending().bones.clear();
|
||||
SetLastError(error);
|
||||
@@ -584,6 +604,18 @@ void on_animation(std::uintptr_t component) noexcept {
|
||||
l.game_placement_valid = true;
|
||||
}
|
||||
if (!room_shown.load(std::memory_order_acquire)) l.room_written_valid = false;
|
||||
// Scaled joints get the game's own scale back first, in the buffer too (the body physics read it
|
||||
// after this update; only the render gets the posed scale), and are posed again from it.
|
||||
if (l.own_scale_buffer != layout.buffer) {
|
||||
l.own_scale.clear(); // another skater's buffer: the game's scales
|
||||
l.own_scale_buffer = layout.buffer;
|
||||
}
|
||||
for (const auto& [i, scale] : l.own_scale) {
|
||||
auto& b = l.bones[i];
|
||||
if (std::memcmp(b.scale.data(), scale.data(), sizeof(scale)) == 0) continue;
|
||||
std::memcpy(b.scale.data(), scale.data(), sizeof(scale));
|
||||
(void)poke(layout.buffer + i * sizeof(NativeBone), b.scale.data(), sizeof(scale));
|
||||
}
|
||||
l.pose.resize(layout.count);
|
||||
for (std::size_t i = 0; i < layout.count; ++i) l.pose[i] = joint_of(l.bones[i]);
|
||||
if (snap->play) {
|
||||
@@ -724,39 +756,78 @@ void on_animation(std::uintptr_t component) noexcept {
|
||||
first = 2;
|
||||
last = layout.count;
|
||||
}
|
||||
// Joints turned and scaled, after the clip and before any reach: they join what is written.
|
||||
if (posing) {
|
||||
// [first, last) of a pose grown to take in [from, to).
|
||||
const auto widen = [](std::size_t& first, std::size_t& last, std::size_t from, std::size_t to) {
|
||||
if (last > first) {
|
||||
first = std::min(first, from);
|
||||
last = std::max(last, to);
|
||||
} else {
|
||||
first = from;
|
||||
last = to;
|
||||
}
|
||||
};
|
||||
// Joints turned and scaled, after the clip; then something carried; then an arm reaching. Each
|
||||
// goes into the pose drawn, the pose sent, or both (Shown), and its joints join what is written.
|
||||
std::vector<std::uint16_t> scaled_now; // joints scaled in the pose drawn this update
|
||||
const auto pose_joints = [&](std::vector<Joint>& pose, std::size_t& first, std::size_t& last) {
|
||||
const float w = std::clamp(snap->offsets_weight, 0.0f, 1.0f);
|
||||
for (const auto& o : snap->offsets) {
|
||||
if (o.joint < 2 || o.joint >= layout.count) continue;
|
||||
auto& j = l.pose[o.joint];
|
||||
auto& j = pose[o.joint];
|
||||
j.rotation = normalised(multiply(j.rotation, slerp(Quat{0, 0, 0, 1}, o.rotation, w)));
|
||||
const float s = 1.0f + (o.scale - 1.0f) * w;
|
||||
// Never quite 0: a joint at scale 0 has no inverse. This small, it is as good as gone.
|
||||
const float s = std::max(1.0f + (o.scale - 1.0f) * w, smallest_scale);
|
||||
if (s != 1.0f && &pose == &l.pose) {
|
||||
l.own_scale.try_emplace(o.joint, j.scale);
|
||||
scaled_now.push_back(o.joint);
|
||||
}
|
||||
for (auto& k : j.scale) k *= s;
|
||||
for (std::size_t a = 0; a < 3; ++a) j.position[a] += o.offset[a] * w;
|
||||
if (last > first) {
|
||||
first = std::min<std::size_t>(first, o.joint);
|
||||
last = std::max<std::size_t>(last, o.joint + 1u);
|
||||
} else {
|
||||
first = o.joint;
|
||||
last = o.joint + 1u;
|
||||
}
|
||||
widen(first, last, o.joint, o.joint + 1u);
|
||||
}
|
||||
};
|
||||
if (carrying && snap->carry_push) {
|
||||
// Pushing on the board: the knees mostly straighten while the foot is down (eased, from the
|
||||
// pose as the animation left it).
|
||||
const float pushing = carry::pushing(std::span<const Joint>(l.pose.data(), l.pose.size()));
|
||||
l.carry_push += (pushing - l.carry_push) * 0.256f;
|
||||
} else {
|
||||
l.carry_push = 0.0f;
|
||||
}
|
||||
// An arm reaching, after the clip: its joints join what is written.
|
||||
if (reaching) {
|
||||
const auto carry_item = [&](std::vector<Joint>& pose, std::size_t& first, std::size_t& last) {
|
||||
auto goal = *snap->carry;
|
||||
goal.crouch *= 1.0f - 0.65f * l.carry_push;
|
||||
if (carry::carry(std::span<Joint>(pose.data(), pose.size()), goal).valid)
|
||||
widen(first, last, carry::written.front(), carry::written.back() + 1u);
|
||||
};
|
||||
const auto reach_arm = [&](std::vector<Joint>& pose, std::size_t& first, std::size_t& last) {
|
||||
const auto& goal = *snap->reach;
|
||||
if (arm_reach::reach(std::span<Joint>(l.pose.data(), l.pose.size()), goal).valid) {
|
||||
const std::size_t from = arm_reach::first_written(goal.hand), to = arm_reach::last_written(goal.hand) + 1u;
|
||||
if (last > first) {
|
||||
first = std::min(first, from);
|
||||
last = std::max(last, to);
|
||||
} else {
|
||||
first = from;
|
||||
last = to;
|
||||
}
|
||||
if (arm_reach::reach(std::span<Joint>(pose.data(), pose.size()), goal).valid)
|
||||
widen(first, last, arm_reach::first_written(goal.hand), arm_reach::last_written(goal.hand) + 1u);
|
||||
};
|
||||
const auto for_everyone = [](bool on, Shown shown) { return on && shown == Shown::everyone; };
|
||||
if (for_everyone(posing, snap->offsets_shown)) pose_joints(l.pose, first, last);
|
||||
if (for_everyone(carrying, snap->carry_shown)) carry_item(l.pose, first, last);
|
||||
if (for_everyone(reaching, snap->reach_shown)) reach_arm(l.pose, first, last);
|
||||
// What only the others see goes on a copy that is sent; what only you see, on the pose drawn.
|
||||
const bool split = (posing && snap->offsets_shown != Shown::everyone) || (carrying && snap->carry_shown != Shown::everyone) ||
|
||||
(reaching && snap->reach_shown != Shown::everyone);
|
||||
std::size_t sent_from = first, sent_to = last;
|
||||
if (split) {
|
||||
l.sent = l.pose;
|
||||
const auto apply = [&](Shown who, bool on, Shown shown, const auto& step) {
|
||||
if (!on || shown != who) return;
|
||||
if (who == Shown::others) step(l.sent, sent_from, sent_to);
|
||||
else step(l.pose, first, last);
|
||||
};
|
||||
for (const auto who : {Shown::others, Shown::me}) {
|
||||
apply(who, posing, snap->offsets_shown, pose_joints);
|
||||
apply(who, carrying, snap->carry_shown, carry_item);
|
||||
apply(who, reaching, snap->reach_shown, reach_arm);
|
||||
}
|
||||
}
|
||||
// A joint no longer scaled has had its own scale back since the top of this update: forgotten.
|
||||
std::erase_if(l.own_scale, [&](const auto& entry) { return std::ranges::find(scaled_now, entry.first) == scaled_now.end(); });
|
||||
if (joints_wanted) {
|
||||
const std::span<const Joint> pose(l.pose.data(), l.pose.size());
|
||||
thread_local std::vector<std::pair<std::uint16_t, Joint>> placed;
|
||||
@@ -821,21 +892,27 @@ void on_animation(std::uintptr_t component) noexcept {
|
||||
pr.last_size = size;
|
||||
Probe::step(l.pose, pr.last_out, pr.out_turn, pr.out_move);
|
||||
}
|
||||
// Other players are never sent the room: from joint 2 on only.
|
||||
const std::size_t sent_first = std::max<std::size_t>(first, 2);
|
||||
auto& sp = sent_pose();
|
||||
std::lock_guard sent_lock(sp.mutex);
|
||||
sp.component = component;
|
||||
sp.first = static_cast<std::uint32_t>(sent_first);
|
||||
sp.last = static_cast<std::uint32_t>(std::max(last, sent_first));
|
||||
sp.joints.assign(l.pose.begin() + static_cast<std::ptrdiff_t>(sent_first),
|
||||
l.pose.begin() + static_cast<std::ptrdiff_t>(std::max(last, sent_first)));
|
||||
sp.at = at;
|
||||
} else {
|
||||
auto& p = pending();
|
||||
std::lock_guard lock(p.mutex);
|
||||
p.bones.clear();
|
||||
}
|
||||
// What the others are sent: the pose drawn, or its own copy (Shown). Other players are never
|
||||
// sent the room: from joint 2 on only.
|
||||
const auto& sent = split ? l.sent : l.pose;
|
||||
if (!split) sent_from = first, sent_to = last;
|
||||
if (sent_to > sent_from) {
|
||||
for (std::size_t i = sent_from; i < sent_to; ++i)
|
||||
if (!finite(sent[i])) throw std::runtime_error("a pose for the others was broken; nothing was sent");
|
||||
const std::size_t sent_first = std::max<std::size_t>(sent_from, 2);
|
||||
auto& sp = sent_pose();
|
||||
std::lock_guard sent_lock(sp.mutex);
|
||||
sp.component = component;
|
||||
sp.first = static_cast<std::uint32_t>(sent_first);
|
||||
sp.last = static_cast<std::uint32_t>(std::max(sent_to, sent_first));
|
||||
sp.joints.assign(sent.begin() + static_cast<std::ptrdiff_t>(sent_first), sent.begin() + static_cast<std::ptrdiff_t>(std::max(sent_to, sent_first)));
|
||||
sp.at = at;
|
||||
}
|
||||
auto& pb = pending_board();
|
||||
std::lock_guard lock(pb.mutex);
|
||||
pb.weight = board_weight > 0 && finite(board_target) ? board_weight : 0;
|
||||
@@ -1074,17 +1151,26 @@ void pause(bool paused) {
|
||||
bool playing() noexcept {
|
||||
return plan_draft.play.has_value() && finished_serial.load(std::memory_order_acquire) != plan_draft.serial;
|
||||
}
|
||||
void pose(std::vector<PoseOffset> offsets, float weight) {
|
||||
void pose(std::vector<PoseOffset> offsets, float weight, Shown shown) {
|
||||
plan_draft.offsets = std::move(offsets);
|
||||
plan_draft.offsets_weight = weight;
|
||||
plan_draft.offsets_shown = shown;
|
||||
plan_draft.offsets_until = plan_draft.offsets.empty() ? 0 : now_seconds() + 0.3;
|
||||
publish();
|
||||
}
|
||||
void reach(std::optional<arm_reach::Goal> goal) {
|
||||
void reach(std::optional<arm_reach::Goal> goal, Shown shown) {
|
||||
plan_draft.reach = std::move(goal);
|
||||
plan_draft.reach_shown = shown;
|
||||
plan_draft.reach_until = plan_draft.reach ? now_seconds() + 0.3 : 0;
|
||||
publish();
|
||||
}
|
||||
void carry(std::optional<carry::Goal> goal, bool push_straightens, Shown shown) {
|
||||
plan_draft.carry = std::move(goal);
|
||||
plan_draft.carry_push = push_straightens;
|
||||
plan_draft.carry_shown = shown;
|
||||
plan_draft.carry_until = plan_draft.carry ? now_seconds() + 0.3 : 0;
|
||||
publish();
|
||||
}
|
||||
std::optional<Joint> joint(std::uint16_t index) {
|
||||
if (index >= 395) return std::nullopt;
|
||||
const double now = now_seconds();
|
||||
@@ -1181,7 +1267,8 @@ void tick(std::uintptr_t base, std::uintptr_t component, bool allowed) {
|
||||
}
|
||||
const double now = now_seconds();
|
||||
const bool wanted = plan_draft.play || !plan_draft.replacements.empty() || recording() ||
|
||||
(plan_draft.reach && now < plan_draft.reach_until) || now < plan_draft.joints_until ||
|
||||
(plan_draft.reach && now < plan_draft.reach_until) || (plan_draft.carry && now < plan_draft.carry_until) ||
|
||||
now < plan_draft.joints_until ||
|
||||
(!plan_draft.offsets.empty() && now < plan_draft.offsets_until) || room_watch.load(std::memory_order_acquire) ||
|
||||
room_shown.load(std::memory_order_acquire) || now < room_restore_until.load(std::memory_order_acquire);
|
||||
if (wanted && !hooked && base) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "Extension/AnimationStudio/anim_core.h"
|
||||
#include "Extension/Skater/arm_reach.h"
|
||||
#include "Extension/Skater/carry.h"
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
@@ -55,19 +56,26 @@ void set_replacements(std::vector<Replacement> list);
|
||||
bool record(float seconds);
|
||||
bool recording() noexcept;
|
||||
std::shared_ptr<const Clip> take_recording();
|
||||
// An arm reaching (arm_reach.h), after any clip, in the pose drawn and sent to the other players;
|
||||
// none ends it. It holds for 0.3 s: set it every tick. Unlike clips it is allowed in a throwdown.
|
||||
void reach(std::optional<arm_reach::Goal> goal);
|
||||
// Who sees a pose, a carry or a reach: everyone (the pose drawn here and sent), only the other players
|
||||
// (sent, not drawn: you look through a camera your skater is seen holding), or only you (drawn, not sent).
|
||||
enum class Shown : std::uint8_t { everyone, others, me };
|
||||
// An arm reaching (arm_reach.h), after any clip and carry; none ends it. It holds for 0.3 s: set it
|
||||
// every tick. Unlike clips it is allowed in a throwdown.
|
||||
void reach(std::optional<arm_reach::Goal> goal, Shown shown = Shown::everyone);
|
||||
// Something held out in the hands (carry.h), after any pose and before any reach; none ends it. It
|
||||
// holds for 0.3 s: set it every tick. `push_straightens`: riding, the knees straighten (most of the
|
||||
// crouch let go) while a foot is down pushing.
|
||||
void carry(std::optional<carry::Goal> goal, bool push_straightens = false, Shown shown = Shown::everyone);
|
||||
// Joints turned (in their own frame) and scaled on top of the animation and any clip, before any
|
||||
// reach, in the pose drawn and sent; `weight` 0..1 of the way. Joints 0 and 1 (the skater's world
|
||||
// placement) are left alone. It holds for 0.3 s: set it every tick; empty ends it.
|
||||
// carry and reach; `weight` 0..1 of the way. Joints 0 and 1 (the skater's world placement) are left
|
||||
// alone. It holds for 0.3 s: set it every tick; empty ends it.
|
||||
struct PoseOffset {
|
||||
std::uint16_t joint{};
|
||||
anim_studio::Quat rotation{0, 0, 0, 1};
|
||||
float scale{1};
|
||||
anim_studio::Vec3 offset{}; // added to its position, in its parent's frame
|
||||
};
|
||||
void pose(std::vector<PoseOffset> offsets, float weight);
|
||||
void pose(std::vector<PoseOffset> offsets, float weight, Shown shown = Shown::everyone);
|
||||
// Where a joint (of the 395) was in the world in the last pose evaluated, after any clip and reach;
|
||||
// empty without a skater. Asking keeps that joint read for two seconds, so the first ask comes back
|
||||
// empty; at most 32 joints are read at once.
|
||||
|
||||
@@ -30,6 +30,7 @@ struct Shared {
|
||||
std::deque<Fall> falls;
|
||||
std::deque<StepNews> steps;
|
||||
std::array<float, 3> velocity{};
|
||||
std::optional<std::array<float, 3>> spin;
|
||||
std::uint64_t velocity_at{};
|
||||
};
|
||||
Shared& shared() {
|
||||
@@ -65,8 +66,9 @@ bool right_limb(skater_body::Bone bone) {
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
// The board's root body velocity (as the grind exit control and Board Break read it).
|
||||
bool read_board_velocity(const NoBailSkater& skater, std::array<float, 3>& velocity) noexcept {
|
||||
// The board's root body velocity (as the grind exit control and Board Break read it), and its spin
|
||||
// (angular velocity, radians a second) when that reads as one.
|
||||
bool read_board_velocity(const NoBailSkater& skater, std::array<float, 3>& velocity, std::optional<std::array<float, 3>>* spin = nullptr) noexcept {
|
||||
std::uintptr_t holder{}, physics{}, type{}, bodies{}, owner{};
|
||||
std::uint32_t count{};
|
||||
if (!memory::peek(skater.core + sstate::board_holder_offset, holder) || !memory::peek(holder + sstate::board_physics_offset, physics) ||
|
||||
@@ -76,7 +78,13 @@ bool read_board_velocity(const NoBailSkater& skater, std::array<float, 3>& veloc
|
||||
const auto body = bodies + sstate::board_root_body * sstate::physics_body_size;
|
||||
if (!memory::peek(body + sstate::body_owner_offset, owner) || owner != physics || !memory::peek(body + sstate::body_velocity_offset, velocity))
|
||||
return false;
|
||||
return std::all_of(velocity.begin(), velocity.end(), [](float v) { return std::isfinite(v) && std::abs(v) < 1000; });
|
||||
const auto sane = [](const std::array<float, 3>& value) { return std::all_of(value.begin(), value.end(), [](float v) { return std::isfinite(v) && std::abs(v) < 1000; }); };
|
||||
if (spin) {
|
||||
std::array<float, 3> turning{};
|
||||
spin->reset();
|
||||
if (memory::peek(body + sstate::body_spin_offset, turning) && sane(turning)) *spin = turning;
|
||||
}
|
||||
return sane(velocity);
|
||||
}
|
||||
|
||||
// The physics thread's own.
|
||||
@@ -98,12 +106,14 @@ void on_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept {
|
||||
std::uint32_t physics{};
|
||||
if (memory::peek(skater.context + sstate::physics_state_offset, physics)) {
|
||||
std::array<float, 3> v{};
|
||||
const bool known = read_board_velocity(skater, v);
|
||||
std::optional<std::array<float, 3>> spin;
|
||||
const bool known = read_board_velocity(skater, v, &spin);
|
||||
const bool air = physics == 200 || physics == 201, grinding = physics >= 400 && physics < 500,
|
||||
rolling = physics >= 100 && physics < 200;
|
||||
if (known) {
|
||||
std::lock_guard lock(s.mutex);
|
||||
s.velocity = v;
|
||||
s.spin = spin;
|
||||
s.velocity_at = now;
|
||||
}
|
||||
if (air && known) {
|
||||
@@ -218,6 +228,12 @@ std::optional<std::array<float, 3>> board_velocity() noexcept {
|
||||
if (!s.velocity_at || GetTickCount64() - s.velocity_at > 250) return std::nullopt;
|
||||
return s.velocity;
|
||||
}
|
||||
std::optional<std::array<float, 3>> board_spin() noexcept {
|
||||
auto& s = shared();
|
||||
std::lock_guard lock(s.mutex);
|
||||
if (!s.velocity_at || GetTickCount64() - s.velocity_at > 250) return std::nullopt;
|
||||
return s.spin;
|
||||
}
|
||||
Slam last_slam() noexcept {
|
||||
auto& l = slam();
|
||||
return {l.force.load(std::memory_order_relaxed), l.cause.load(std::memory_order_relaxed), l.at.load(std::memory_order_acquire)};
|
||||
|
||||
@@ -50,6 +50,8 @@ std::vector<BodyHit> take_hits();
|
||||
std::vector<Fall> take_falls();
|
||||
// The board's velocity in the last step (m/s), while it is known.
|
||||
std::optional<std::array<float, 3>> board_velocity() noexcept;
|
||||
// Its spin then (angular velocity in the world, radians a second), while it is known.
|
||||
std::optional<std::array<float, 3>> board_spin() noexcept;
|
||||
Slam last_slam() noexcept;
|
||||
// From No Bail's hooks (no_bail.cpp), beside Board Break's: a cause recorded, a wipeout chosen.
|
||||
void note_cause(std::int32_t reason, float magnitude) noexcept;
|
||||
|
||||
@@ -228,7 +228,7 @@ std::vector<std::byte> Context::array(Value value, unsigned maximum, unsigned& c
|
||||
return result;
|
||||
}
|
||||
void Context::array(Value target, std::span<const std::byte> bytes, unsigned count) const {
|
||||
require(kind(target.type) == 4 && count <= 1024, "Native menu list is invalid.");
|
||||
require(kind(target.type) == 4 && count <= 4096, "Native menu list is invalid.");
|
||||
const auto stride = size(read<Address>(read<Address>(target.type) + 0x30));
|
||||
require(bytes.size() == static_cast<std::size_t>(count) * stride, "Native menu list element size differs.");
|
||||
address(target);
|
||||
|
||||
@@ -506,6 +506,31 @@ int main() {
|
||||
"A dependency with a long name was not matched to its cut folder");
|
||||
}
|
||||
|
||||
{
|
||||
// What the mods give the game's data, for emptying the game's shader cache when it changes: the mods
|
||||
// with a layout, in load order, with their files, and the variants in play.
|
||||
const auto park = root / L"signature" / L"Park", skin = root / L"signature" / L"Skin";
|
||||
write(park / L"layout.toc", "toc");
|
||||
write(skin / L"layout.toc", "toc");
|
||||
const std::vector<launcher_mods::DataMod> both{{"Park", park}, {"Skin", skin}}, swapped{{"Skin", skin}, {"Park", park}};
|
||||
const auto before = launcher_mods::game_data_signature(both, {});
|
||||
check(before == launcher_mods::game_data_signature(both, {}), "The same mods give the same signature");
|
||||
check(before != launcher_mods::game_data_signature(swapped, {}), "Another load order is another signature");
|
||||
check(before != launcher_mods::game_data_signature({{"Park", park}}, {}), "A mod taken out is another");
|
||||
check(before != launcher_mods::game_data_signature(both, {{"Skin", "red"}}), "Another variant is another");
|
||||
write(skin / L"Win32" / L"skin.sb", "bundle");
|
||||
check(before != launcher_mods::game_data_signature(both, {}), "A mod's files changing is another");
|
||||
// Emptying the cache: everything in it goes, the folder stays.
|
||||
const auto cache = root / L"cache";
|
||||
write(cache / L"6803938.PcDx12", "shaders");
|
||||
write(cache / L"nested" / L"cache.manifest", "manifest");
|
||||
check(launcher_mods::empty_folder(cache).empty() && fs::is_directory(cache) && fs::is_empty(cache), "The shader cache is emptied, its folder kept");
|
||||
check(launcher_mods::empty_folder(root / L"no such folder").empty(), "No cache folder is nothing to do");
|
||||
check(launcher_mods::game_shader_cache().filename() == L"cache" &&
|
||||
launcher_mods::game_shader_cache().parent_path().filename() == L"Skate",
|
||||
"The game's shader cache is ReSkate's own, under Game/Skate");
|
||||
}
|
||||
|
||||
std::error_code ignored;
|
||||
fs::remove_all(root, ignored);
|
||||
if (failures) {
|
||||
|
||||
@@ -87,6 +87,21 @@ int main() {
|
||||
check(desert.latest().file_size == 5368709120ull, "Sizes over 4 GB survive");
|
||||
check(desert.downloads == 15 && desert.in_category("Maps") && !desert.in_category("Audio"),
|
||||
"Downloads add up across versions and categories are listed");
|
||||
{
|
||||
// The GET MODS list's categories: hidden ones win, and with any shown only, a package needs one.
|
||||
thunderstore::Package both;
|
||||
both.categories = {"Maps", "Tools"};
|
||||
thunderstore::Package plain;
|
||||
check(thunderstore::shown_by(both, {}) && thunderstore::shown_by(plain, {}), "No filter shows everything");
|
||||
check(thunderstore::shown_by(both, {{"Audio", "Maps"}, {}}) && !thunderstore::shown_by(plain, {{"Maps"}, {}}),
|
||||
"Shown only: a package in any of them shows, one in none does not");
|
||||
check(!thunderstore::shown_by(both, {{}, {"Tools"}}) && thunderstore::shown_by(plain, {{}, {"Tools"}}),
|
||||
"Hidden: a package in it is left out, one without it shows");
|
||||
check(!thunderstore::shown_by(both, {{"Maps"}, {"Tools"}}), "A hidden category wins over one shown only");
|
||||
const std::vector<std::string> none{"No category"};
|
||||
check(thunderstore::shown_by(none, {{"No category"}, {}}) && !thunderstore::shown_by(none, {{}, {"No category"}}),
|
||||
"An installed mod's own categories go by the same rule (MY MODS)");
|
||||
}
|
||||
const auto& cam = packages.at(1);
|
||||
check(cam.deprecated && cam.latest().icon.empty(), "Flags are read and non-HTTPS icons are dropped");
|
||||
check(!error_of([] { thunderstore::parse_listing(R"({"error": "No cache available"})"); }).empty(),
|
||||
|
||||
+16
-1
@@ -218,6 +218,14 @@ struct Settings {
|
||||
std::uint64_t accepted_privacy{}, accepted_terms{};
|
||||
// The version of the home screen's announcement the player hid (0: none): one newer shows.
|
||||
std::uint64_t announcement_hidden{};
|
||||
// GET MODS: the Thunderstore categories shown only, and those hidden (thunderstore.h CategoryFilter).
|
||||
std::vector<std::string> mod_categories_only, mod_categories_hidden;
|
||||
// MY MODS: the same for the installed mods, by their Thunderstore categories ("No category" for a
|
||||
// mod Thunderstore does not list, or lists in none).
|
||||
std::vector<std::string> installed_categories_only, installed_categories_hidden;
|
||||
// What the mods gave the game's data at the last launch (launcher_mods::game_data_signature): a
|
||||
// launch with other mods empties the game's shader cache first.
|
||||
std::string shader_cache_mods;
|
||||
};
|
||||
|
||||
inline constexpr std::array<const char*, 7> log_levels{"trace", "debug", "info", "warning", "error", "critical", "off"};
|
||||
@@ -495,7 +503,6 @@ struct Store {
|
||||
|
||||
// The GET MODS page.
|
||||
std::array<char, 96> search{};
|
||||
std::string category; // empty = all
|
||||
int sort{};
|
||||
std::string selected; // full_name, while its overview is open
|
||||
bool overview{}; // the overview popup is showing
|
||||
@@ -570,6 +577,8 @@ struct ModsPanel {
|
||||
std::array<char, 96> search{};
|
||||
int filter{};
|
||||
int order{};
|
||||
// Each Thunderstore package's categories by the folder it installs into, made again when a listing arrives.
|
||||
std::map<std::string, std::vector<std::string>, std::less<>> categories_by_folder;
|
||||
std::set<std::string, std::less<>> marked;
|
||||
std::string anchor; // the last mod ticked: where a Shift-click range starts
|
||||
int place{}; // typed in a row's "move to position" popup
|
||||
@@ -704,6 +713,12 @@ ImTextureID package_icon(ModsPanel& panel, const thunderstore::Package& package)
|
||||
// when the mod never came from Thunderstore.
|
||||
void mod_icon(ModsPanel& panel, const thunderstore::Package* package, ImVec2 position, float size);
|
||||
void pump_icons(ModsPanel& panel);
|
||||
// A category menu for a list of mods (GET MODS, MY MODS): each category shown only, hidden, or either,
|
||||
// a click going round them, with how many mods are in each (category_count adds to that). One chosen
|
||||
// before that no mod has now stays in the menu. True when the player changed what is chosen.
|
||||
int& category_count(std::vector<std::pair<std::string, int>>& categories, const std::string& category);
|
||||
bool category_menu(const char* id, std::vector<std::string>& only, std::vector<std::string>& hidden,
|
||||
std::vector<std::pair<std::string, int>> categories, float width);
|
||||
// The GET MODS page body, under the page header.
|
||||
void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float height, bool installing);
|
||||
// Everything Thunderstore knows about one package, in a popup over the page.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "gui_internal.h"
|
||||
|
||||
#include "depot_output.h"
|
||||
#include "mod_manager.h"
|
||||
#include "text_encoding.h"
|
||||
#include "start_maps.h"
|
||||
|
||||
#include "Engine/Core/Json/json.h"
|
||||
@@ -76,6 +78,20 @@ Settings load_settings(const fs::path& path) {
|
||||
settings.accepted_privacy = json.value("accepted_privacy_version", settings.accepted_privacy);
|
||||
settings.accepted_terms = json.value("accepted_terms_version", settings.accepted_terms);
|
||||
settings.announcement_hidden = json.value("announcement_hidden", settings.announcement_hidden);
|
||||
// At most 64 categories each, every one a short name.
|
||||
const auto categories = [&](const char* key) {
|
||||
std::vector<std::string> list;
|
||||
if (const auto found = json.find(key); found != json.end() && found->is_array())
|
||||
for (const auto& item : *found)
|
||||
if (item.is_string() && !item.get<std::string>().empty() && item.get<std::string>().size() <= 64 && list.size() < 64)
|
||||
list.push_back(item.get<std::string>());
|
||||
return list;
|
||||
};
|
||||
settings.mod_categories_only = categories("mod_categories_only");
|
||||
settings.mod_categories_hidden = categories("mod_categories_hidden");
|
||||
settings.installed_categories_only = categories("installed_categories_only");
|
||||
settings.installed_categories_hidden = categories("installed_categories_hidden");
|
||||
settings.shader_cache_mods = json.value("shader_cache_mods", std::string());
|
||||
} catch (const std::exception& exception) {
|
||||
logging::log(logging::Level::warning, logging::Channel::launcher, "Ignoring launcher settings: {}", exception.what());
|
||||
}
|
||||
@@ -107,6 +123,11 @@ void save_settings(const fs::path& path, const Settings& settings) {
|
||||
json["accepted_privacy_version"] = settings.accepted_privacy;
|
||||
json["announcement_hidden"] = settings.announcement_hidden;
|
||||
json["accepted_terms_version"] = settings.accepted_terms;
|
||||
json["mod_categories_only"] = settings.mod_categories_only;
|
||||
json["mod_categories_hidden"] = settings.mod_categories_hidden;
|
||||
json["installed_categories_only"] = settings.installed_categories_only;
|
||||
json["installed_categories_hidden"] = settings.installed_categories_hidden;
|
||||
json["shader_cache_mods"] = settings.shader_cache_mods;
|
||||
std::ofstream output(path, std::ios::binary | std::ios::trunc);
|
||||
output << json.dump(2) << '\n';
|
||||
}
|
||||
@@ -509,13 +530,35 @@ void Launcher::run_download(bool validate) {
|
||||
fail(std::format("The Steam download did not finish (exit code {}). {}", code, output.message));
|
||||
}
|
||||
|
||||
// Shaders the game built for one set of mods, kept in its shader cache and reused by the next launch,
|
||||
// were found to break the picture once the mods had changed many times: a launch with other mods than
|
||||
// the last one's (what they give the game's data: launcher_mods::game_data_signature) starts the game
|
||||
// with its shader cache empty, and it builds the shaders again.
|
||||
void clear_shader_cache_if_mods_changed(Settings& settings, const std::string& signature) {
|
||||
if (signature == settings.shader_cache_mods) return;
|
||||
const auto cache = launcher_mods::game_shader_cache();
|
||||
const auto failed = launcher_mods::empty_folder(cache);
|
||||
if (failed.empty()) {
|
||||
logging::log(logging::Level::info, logging::Channel::launcher, "The mods changed since the last launch: the game's shader cache is emptied ({}).",
|
||||
launcher_text::utf8(cache.wstring()));
|
||||
settings.shader_cache_mods = signature;
|
||||
return;
|
||||
}
|
||||
// (Tried again at the next launch.)
|
||||
for (const auto& line : failed)
|
||||
logging::log(logging::Level::warning, logging::Channel::launcher, "The game's shader cache could not be emptied: {}", line);
|
||||
}
|
||||
|
||||
// The merge the game does behind its splash, done here instead, so a mod that
|
||||
// cannot be merged is a question the launcher can ask rather than a surprise
|
||||
// three minutes into a loading screen. The game reuses what this builds.
|
||||
bool Launcher::run_mod_merge() {
|
||||
const auto root = mods_data_root();
|
||||
std::error_code error;
|
||||
if (!fs::is_directory(root / mods::mods_folder, error)) return true;
|
||||
if (!fs::is_directory(root / mods::mods_folder, error)) {
|
||||
clear_shader_cache_if_mods_changed(settings_, {});
|
||||
return true;
|
||||
}
|
||||
set(Phase::merging, "Preparing mods", "Merging what is installed", 0);
|
||||
trace::Scope timing("launcher.mod_merge", "task");
|
||||
auto catalog = mods::load_catalog(root, [this](const mods::MergeProgress& progress) {
|
||||
@@ -529,6 +572,12 @@ bool Launcher::run_mod_merge() {
|
||||
logging::write(logging::Level::info, logging::Channel::launcher, note);
|
||||
for (const auto& warning : catalog.warnings)
|
||||
logging::write(logging::Level::warning, logging::Channel::launcher, warning);
|
||||
{
|
||||
std::vector<launcher_mods::DataMod> data;
|
||||
for (const auto& mod : catalog.mods)
|
||||
if (mod.provides_layout) data.push_back({mod.name, mod.directory});
|
||||
clear_shader_cache_if_mods_changed(settings_, launcher_mods::game_data_signature(data, catalog.variants_in_play));
|
||||
}
|
||||
|
||||
std::vector<ModProblem> problems;
|
||||
if (!catalog.issue.empty())
|
||||
|
||||
+22
-3
@@ -401,7 +401,16 @@ std::string kind_of(const mods::Mod& mod) {
|
||||
|
||||
// The rows the list shows, as places in the load order: what the search and the
|
||||
// filter leave, in the order asked for.
|
||||
std::vector<int> visible_mods(const ModsPanel& panel, const thunderstore::Installed& installed) {
|
||||
// An installed mod's categories, as Thunderstore lists its package ("No category" when it lists none,
|
||||
// or does not list the mod at all).
|
||||
constexpr const char* no_category = "No category";
|
||||
std::vector<std::string> categories_of(const ModsPanel& panel, const mods::Mod& mod) {
|
||||
const auto found = panel.categories_by_folder.find(mod.name);
|
||||
if (found == panel.categories_by_folder.end() || found->second.empty()) return {no_category};
|
||||
return found->second;
|
||||
}
|
||||
|
||||
std::vector<int> visible_mods(const ModsPanel& panel, const thunderstore::Installed& installed, const thunderstore::CategoryFilter& categories) {
|
||||
const auto& entries = panel.list.entries;
|
||||
const auto query = folded(panel.search.data());
|
||||
std::vector<int> view;
|
||||
@@ -419,6 +428,7 @@ std::vector<int> visible_mods(const ModsPanel& panel, const thunderstore::Instal
|
||||
case 7: keep = mod.outdated.empty() && (!conflict_winner(panel, mod.name).empty() || conflict_beats(panel, mod.name)); break;
|
||||
default: break;
|
||||
}
|
||||
if (keep && !categories.empty()) keep = thunderstore::shown_by(categories_of(panel, mod), categories);
|
||||
if (keep && !query.empty())
|
||||
keep = folded(mod.title).find(query) != std::string::npos ||
|
||||
folded(mod.name).find(query) != std::string::npos ||
|
||||
@@ -1124,7 +1134,7 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co
|
||||
|
||||
// ------------------------------------------------ search, filter, order, check, refresh
|
||||
const float combo = S(150), check = S(160), refresh = S(96), gap = S(10);
|
||||
ImGui::SetNextItemWidth(std::max(S(140), ImGui::GetContentRegionAvail().x - (combo + gap) * 2 - check - gap - refresh - gap));
|
||||
ImGui::SetNextItemWidth(std::max(S(140), ImGui::GetContentRegionAvail().x - (combo + gap) * 3 - check - gap - refresh - gap));
|
||||
// Ctrl+F jumps to the search, as it does in a browser.
|
||||
if (!installing && ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_F, false) &&
|
||||
!ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId))
|
||||
@@ -1142,6 +1152,15 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co
|
||||
};
|
||||
choice("##installed_filter", filter_names, panel.filter);
|
||||
ImGui::SameLine(0, gap);
|
||||
{
|
||||
std::vector<std::pair<std::string, int>> categories;
|
||||
for (const auto& entry : entries)
|
||||
for (const auto& category : categories_of(panel, entry.mod)) ++category_count(categories, category);
|
||||
auto& settings = launcher.settings();
|
||||
if (category_menu("##installed_category", settings.installed_categories_only, settings.installed_categories_hidden, std::move(categories), combo))
|
||||
launcher.save();
|
||||
}
|
||||
ImGui::SameLine(0, gap);
|
||||
choice("##installed_order", order_names, panel.order);
|
||||
ImGui::SameLine(0, gap);
|
||||
{
|
||||
@@ -1156,7 +1175,7 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co
|
||||
ImGui::SameLine(0, gap);
|
||||
if (ImGui::Button("Refresh", ImVec2(refresh, 0))) refresh_mods(launcher, panel);
|
||||
|
||||
const auto view = visible_mods(panel, installed);
|
||||
const auto view = visible_mods(panel, installed, {launcher.settings().installed_categories_only, launcher.settings().installed_categories_hidden});
|
||||
// Dragging, and typing a place, move a mod among the others in the load order: the order the
|
||||
// list must be shown in for where it lands to be what it looks like.
|
||||
const bool reorder = panel.order == 0 && !installing;
|
||||
|
||||
@@ -208,7 +208,7 @@ bool nsfw(const ts::Package& package) {
|
||||
[](const std::string& category) { return lower(category) == "nsfw"; });
|
||||
}
|
||||
|
||||
std::vector<const ts::Package*> visible_packages(const Store& store, const ts::Installed& installed) {
|
||||
std::vector<const ts::Package*> visible_packages(const Store& store, const ts::Installed& installed, const ts::CategoryFilter& categories) {
|
||||
const auto query = lower(store.search.data());
|
||||
std::vector<const ts::Package*> result;
|
||||
for (const auto& package : store.packages) {
|
||||
@@ -217,7 +217,7 @@ std::vector<const ts::Package*> visible_packages(const Store& store, const ts::I
|
||||
// or a category of that name. A deprecated one only shows once installed.
|
||||
if (nsfw(package)) continue;
|
||||
if (package.deprecated && !have) continue;
|
||||
if (!store.category.empty() && !package.in_category(store.category)) continue;
|
||||
if (!ts::shown_by(package, categories)) continue;
|
||||
if (!query.empty() && lower(package.title()).find(query) == std::string::npos &&
|
||||
lower(package.owner).find(query) == std::string::npos &&
|
||||
lower(package.latest().description).find(query) == std::string::npos) continue;
|
||||
@@ -240,6 +240,17 @@ std::vector<const ts::Package*> visible_packages(const Store& store, const ts::I
|
||||
|
||||
constexpr std::array<const char*, 5> sort_names{"Last updated", "Most downloaded", "Newest", "Top rated", "Name"};
|
||||
|
||||
// GET MODS's categories: Thunderstore's, with how many mods are in each, kept in the launcher's settings.
|
||||
void category_filter(Launcher& launcher, const Store& store, float width) {
|
||||
std::vector<std::pair<std::string, int>> categories;
|
||||
for (const auto& package : store.packages) {
|
||||
if (nsfw(package)) continue; // (and no category that only they have)
|
||||
for (const auto& category : package.categories) ++category_count(categories, category);
|
||||
}
|
||||
if (category_menu("##category", launcher.settings().mod_categories_only, launcher.settings().mod_categories_hidden, std::move(categories), width))
|
||||
launcher.save();
|
||||
}
|
||||
|
||||
// What installing `package` does now: install, update or reinstall.
|
||||
std::string action_label(const ts::Package& package, const ts::Installed& installed) {
|
||||
const auto found = installed.find(ts::folder_for(package.full_name));
|
||||
@@ -250,6 +261,75 @@ std::string action_label(const ts::Package& package, const ts::Installed& instal
|
||||
|
||||
} // namespace
|
||||
|
||||
int& category_count(std::vector<std::pair<std::string, int>>& categories, const std::string& category) {
|
||||
for (auto& entry : categories)
|
||||
if (entry.first == category) return entry.second;
|
||||
return categories.emplace_back(category, 0).second;
|
||||
}
|
||||
|
||||
bool category_menu(const char* id, std::vector<std::string>& only, std::vector<std::string>& hidden,
|
||||
std::vector<std::pair<std::string, int>> categories, float width) {
|
||||
// One chosen before that no mod has now stays in the list, so it can be put back.
|
||||
for (const auto* list : {&only, &hidden})
|
||||
for (const auto& category : *list) (void)category_count(categories, category);
|
||||
std::sort(categories.begin(), categories.end(), [](const auto& a, const auto& b) { return lower(a.first) < lower(b.first); });
|
||||
const auto in = [](const std::vector<std::string>& list, const std::string& name) { return std::find(list.begin(), list.end(), name) != list.end(); };
|
||||
std::string shown;
|
||||
if (only.empty() && hidden.empty()) shown = "All categories";
|
||||
else if (hidden.empty()) shown = only.size() == 1 ? "Only " + only.front() : std::format("Only {} categories", only.size());
|
||||
else if (only.empty()) shown = hidden.size() == 1 ? "Hiding " + hidden.front() : std::format("Hiding {} categories", hidden.size());
|
||||
else shown = std::format("Only {}, hiding {}", only.size(), hidden.size());
|
||||
ImGui::SetNextItemWidth(width);
|
||||
if (!ImGui::BeginCombo(id, shown.c_str(), ImGuiComboFlags_HeightLarge)) {
|
||||
if (ImGui::IsItemHovered() && !(only.empty() && hidden.empty())) {
|
||||
std::string tip;
|
||||
if (!only.empty()) {
|
||||
tip = "Showing only:";
|
||||
for (const auto& category : only) tip += "\n " + category;
|
||||
}
|
||||
if (!hidden.empty()) {
|
||||
tip += std::string(tip.empty() ? "" : "\n") + "Hiding:";
|
||||
for (const auto& category : hidden) tip += "\n " + category;
|
||||
}
|
||||
ImGui::SetTooltip("%s", tip.c_str());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool changed = false;
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, color::muted);
|
||||
ImGui::TextUnformatted("Click a category to show only it,");
|
||||
ImGui::TextUnformatted("again to hide it, again for either.");
|
||||
ImGui::PopStyleColor();
|
||||
if (ImGui::Selectable("Every category", only.empty() && hidden.empty(), ImGuiSelectableFlags_DontClosePopups)) {
|
||||
changed = !only.empty() || !hidden.empty();
|
||||
only.clear();
|
||||
hidden.clear();
|
||||
}
|
||||
ImGui::Separator();
|
||||
for (const auto& [category, mods] : categories) {
|
||||
const bool is_only = in(only, category), is_hidden = in(hidden, category);
|
||||
const auto label = std::format("{} {} ({})##{}", is_only ? "+" : is_hidden ? "-" : " ", category, mods, category);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, is_only ? color::good : is_hidden ? color::danger : color::text);
|
||||
if (ImGui::Selectable(label.c_str(), is_only || is_hidden, ImGuiSelectableFlags_DontClosePopups)) {
|
||||
// Either, then shown only, then hidden, then either again.
|
||||
if (is_only) {
|
||||
std::erase(only, category);
|
||||
hidden.push_back(category);
|
||||
} else if (is_hidden) {
|
||||
std::erase(hidden, category);
|
||||
} else {
|
||||
only.push_back(category);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
ImGui::PopStyleColor();
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("%s", is_only ? "Shown only. Click to hide it." : is_hidden ? "Hidden. Click to show it as any other." : "Click to show only this category (and any others shown only).");
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
return changed;
|
||||
}
|
||||
|
||||
// Placed by hand: a row lays its own icon, text and badges out.
|
||||
void mod_icon(ModsPanel& panel, const thunderstore::Package* package, ImVec2 position, float size) {
|
||||
const ImVec2 end(position.x + size, position.y + size);
|
||||
@@ -300,6 +380,8 @@ void collect_listing(ModsPanel& panel) {
|
||||
if (store.incoming_error.empty()) {
|
||||
store.packages = std::move(store.incoming);
|
||||
store.loaded = true;
|
||||
panel.categories_by_folder.clear();
|
||||
for (const auto& package : store.packages) panel.categories_by_folder[ts::folder_for(package.full_name)] = package.categories;
|
||||
store.error.clear();
|
||||
} else {
|
||||
store.error = std::move(store.incoming_error);
|
||||
@@ -506,20 +588,7 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float
|
||||
ImGui::SetKeyboardFocusHere();
|
||||
ImGui::InputTextWithHint("##search", "Search mods (Ctrl+F)", store.search.data(), store.search.size());
|
||||
ImGui::SameLine();
|
||||
std::vector<std::string> categories;
|
||||
for (const auto& package : store.packages) {
|
||||
if (nsfw(package)) continue; // (and no category that only they have)
|
||||
for (const auto& category : package.categories)
|
||||
if (std::find(categories.begin(), categories.end(), category) == categories.end()) categories.push_back(category);
|
||||
}
|
||||
std::sort(categories.begin(), categories.end());
|
||||
ImGui::SetNextItemWidth(combo);
|
||||
if (ImGui::BeginCombo("##category", store.category.empty() ? "All categories" : store.category.c_str())) {
|
||||
if (ImGui::Selectable("All categories", store.category.empty())) store.category.clear();
|
||||
for (const auto& category : categories)
|
||||
if (ImGui::Selectable(category.c_str(), store.category == category)) store.category = category;
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
category_filter(launcher, store, combo);
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(combo);
|
||||
if (ImGui::BeginCombo("##sort", sort_names[static_cast<std::size_t>(std::clamp(store.sort, 0, 4))])) {
|
||||
@@ -572,7 +641,7 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float
|
||||
}
|
||||
ImGui::Spacing();
|
||||
const float body = std::max(S(120), height - (ImGui::GetCursorPosY() - top));
|
||||
const auto packages = visible_packages(store, installed);
|
||||
const auto packages = visible_packages(store, installed, {launcher.settings().mod_categories_only, launcher.settings().mod_categories_hidden});
|
||||
|
||||
// ------------------------------------------------ the list
|
||||
ImGui::BeginChild("##store_list", ImVec2(0, body), ImGuiChildFlags_NavFlattened);
|
||||
|
||||
@@ -570,6 +570,33 @@ std::vector<std::string> orphaned_dependencies(const fs::path& mods, const std::
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string game_data_signature(const std::vector<DataMod>& mods, const std::map<std::string, std::string, std::less<>>& variants) {
|
||||
std::string text;
|
||||
for (const auto& mod : mods) text += mod.name + '=' + mods::mod_fingerprint(mod.directory) + ';';
|
||||
for (const auto& [mod, variant] : variants) text += "variant " + mod + '=' + variant + ';';
|
||||
return text;
|
||||
}
|
||||
|
||||
std::filesystem::path game_shader_cache() {
|
||||
std::array<wchar_t, 32768> local{};
|
||||
const auto size = GetEnvironmentVariableW(L"LOCALAPPDATA", local.data(), static_cast<DWORD>(local.size()));
|
||||
if (!size || size >= local.size()) return {};
|
||||
return fs::path(local.data()) / L"ReSkate" / L"Game" / L"Skate" / L"cache";
|
||||
}
|
||||
|
||||
std::vector<std::string> empty_folder(const std::filesystem::path& folder) {
|
||||
std::vector<std::string> failed;
|
||||
std::error_code error;
|
||||
if (folder.empty() || !fs::is_directory(folder, error)) return failed;
|
||||
for (fs::directory_iterator it(folder, error), end; !error && it != end; it.increment(error)) {
|
||||
std::error_code removing;
|
||||
fs::remove_all(it->path(), removing);
|
||||
if (removing) failed.push_back(utf8(it->path().filename().wstring()) + ": " + removing.message());
|
||||
}
|
||||
if (error) failed.push_back("the folder could not be read: " + error.message());
|
||||
return failed;
|
||||
}
|
||||
|
||||
void remove(const fs::path& mods, const std::string& name) {
|
||||
if (!mods::valid_mod_name(name)) fail("\"" + name + "\" is not a mod folder.");
|
||||
const auto target = mods / from_utf8(name);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <atomic>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -72,5 +73,21 @@ void forget_brought_in(const std::filesystem::path& mods_root, const std::vector
|
||||
std::vector<std::string> orphaned_dependencies(const std::filesystem::path& mods_root,
|
||||
const std::vector<std::string>& removing, const std::vector<std::string>& installed);
|
||||
|
||||
// What the mods give the game's data, as one line: each mod that ships a layout, in load order, with
|
||||
// its folder name and its files' fingerprint (Engine/Vfs/mod_list.h mod_fingerprint), then the
|
||||
// variants in play. A mod that only holds a plugin, or loose files the game does not draw with, is not
|
||||
// in it. When it changes between launches, the launcher empties the game's shader cache: shaders built
|
||||
// for the last set of mods, kept and reused, were found to break the picture after many changes.
|
||||
struct DataMod {
|
||||
std::string name;
|
||||
std::filesystem::path directory;
|
||||
};
|
||||
std::string game_data_signature(const std::vector<DataMod>& mods, const std::map<std::string, std::string, std::less<>>& variants);
|
||||
// The game's own shader cache, kept apart from the normal game's (Extension/Boot/user_data_redirect.cpp):
|
||||
// %LOCALAPPDATA%\ReSkate\Game\Skate\cache. Not the shader_cache folder the game ships with.
|
||||
std::filesystem::path game_shader_cache();
|
||||
// Removes everything in `folder`, never the folder itself. What could not be removed, a line each.
|
||||
std::vector<std::string> empty_folder(const std::filesystem::path& folder);
|
||||
|
||||
|
||||
} // namespace dingosdk::launcher_mods
|
||||
@@ -203,6 +203,13 @@ bool Package::in_category(std::string_view category) const {
|
||||
return std::find(categories.begin(), categories.end(), category) != categories.end();
|
||||
}
|
||||
|
||||
bool shown_by(const Package& package, const CategoryFilter& filter) { return shown_by(package.categories, filter); }
|
||||
bool shown_by(const std::vector<std::string>& categories, const CategoryFilter& filter) {
|
||||
const auto in = [&](const std::string& category) { return std::find(categories.begin(), categories.end(), category) != categories.end(); };
|
||||
if (std::any_of(filter.hidden.begin(), filter.hidden.end(), in)) return false;
|
||||
return filter.only.empty() || std::any_of(filter.only.begin(), filter.only.end(), in);
|
||||
}
|
||||
|
||||
std::vector<Package> parse_listing(std::string_view json) {
|
||||
const auto root = Json::parse(json, listing_limits);
|
||||
if (!root.is_array()) fail("The Thunderstore listing is not a JSON array.");
|
||||
|
||||
@@ -63,6 +63,15 @@ struct Package {
|
||||
Package at_version(std::string_view number) const;
|
||||
};
|
||||
|
||||
// The GET MODS list's categories: those shown only (a package needs one of them, when there are
|
||||
// any) and those hidden (a package in any of them is left out, whatever else it is in).
|
||||
struct CategoryFilter {
|
||||
std::vector<std::string> only, hidden;
|
||||
bool empty() const { return only.empty() && hidden.empty(); }
|
||||
};
|
||||
bool shown_by(const Package& package, const CategoryFilter& filter);
|
||||
bool shown_by(const std::vector<std::string>& categories, const CategoryFilter& filter);
|
||||
|
||||
// "Team-Mod-1.2.3" as {"Team-Mod", "1.2.3"}: how a dependency is named. The version is
|
||||
// empty when the text does not end in one.
|
||||
std::pair<std::string, std::string> split_dependency(std::string_view text);
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
// The dedicated server's plugins (server_plugins.cpp) with the server's host, over a transport
|
||||
// that is not Steam's and simulated players: what a server plugin sees of the players' skaters.
|
||||
//
|
||||
// dingosdk_server_plugin_tests <Server/Test/fixtures/park_mods>
|
||||
#include "Server/server_host.h"
|
||||
#include "Extension/Multiplayer/Net/pose_batch.h"
|
||||
#include "Extension/Multiplayer/Net/pose_codec.h"
|
||||
#include "Extension/Multiplayer/Net/wire_codec.h"
|
||||
#include "Extension/Multiplayer/Steam/steam_transport.h"
|
||||
#include "Engine/Game/Build/supported_build.h"
|
||||
#include "Engine/Game/World/world_names.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <deque>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dingosdk::multiplayer {
|
||||
namespace {
|
||||
// What the fake transport holds: what each player is sent, and what they send.
|
||||
struct Wire {
|
||||
TransportStatus status;
|
||||
std::vector<TransportMessage> inbound;
|
||||
std::map<std::uint64_t, std::vector<std::vector<std::uint8_t>>> sent;
|
||||
};
|
||||
Wire &wire() {
|
||||
static Wire value;
|
||||
return value;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
struct SteamTransport::Impl {};
|
||||
SteamTransport::SteamTransport() = default;
|
||||
SteamTransport::~SteamTransport() = default;
|
||||
bool SteamTransport::open() { return true; }
|
||||
bool SteamTransport::open_game_server(void *) { return true; }
|
||||
bool SteamTransport::host(unsigned) {
|
||||
wire().status.ready = wire().status.hosting = true;
|
||||
wire().status.local_id = (1ULL << 56) | (4ULL << 52) | 1; // a game server's SteamID, never a player's
|
||||
return true;
|
||||
}
|
||||
bool SteamTransport::join(std::uint64_t, std::uint32_t, std::uint16_t) { return false; }
|
||||
bool SteamTransport::listen_direct(std::uint16_t) { return true; }
|
||||
void SteamTransport::set_packing(unsigned) {}
|
||||
std::vector<std::string> SteamTransport::take_direct_notes() { return {}; }
|
||||
bool SteamTransport::set_steam_debug(bool) { return false; }
|
||||
bool SteamTransport::connect_peer(std::uint64_t) { return false; }
|
||||
void SteamTransport::allow_peers(std::span<const Member>) {}
|
||||
bool SteamTransport::socket_test() { return true; }
|
||||
void SteamTransport::stop() {}
|
||||
void SteamTransport::disconnect(std::uint64_t id, const char *) {
|
||||
std::erase_if(wire().status.peers, [&](const TransportPeer &peer) { return peer.id == id; });
|
||||
}
|
||||
void SteamTransport::poll() {}
|
||||
bool SteamTransport::send(std::uint64_t id, std::span<const std::uint8_t> bytes, bool, bool, TrafficLane) {
|
||||
if (std::none_of(wire().status.peers.begin(), wire().status.peers.end(), [&](const auto &peer) { return peer.id == id; })) return false;
|
||||
wire().sent[id].emplace_back(bytes.begin(), bytes.end());
|
||||
return true;
|
||||
}
|
||||
void SteamTransport::send_batch(std::span<TransportSend> messages) {
|
||||
for (auto &message : messages) message.sent = send(message.id, message.bytes, message.reliable, message.fresh, message.lane);
|
||||
}
|
||||
std::vector<TransportMessage> SteamTransport::receive() { return std::exchange(wire().inbound, {}); }
|
||||
std::string SteamTransport::name(std::uint64_t) { return {}; }
|
||||
const TransportStatus &SteamTransport::status() const { return wire().status; }
|
||||
std::string SteamTransport::take_closed(std::uint64_t) { return {}; }
|
||||
std::string SteamTransport::link_report(std::uint64_t) { return {}; }
|
||||
std::int64_t SteamTransport::pending(std::uint64_t) const { return 0; }
|
||||
std::string SteamTransport::relay_status() const { return "OK"; }
|
||||
bool SteamTransport::set_send_rate(int) { return true; }
|
||||
int SteamTransport::send_rate() const { return 900 * 1024; }
|
||||
std::vector<TransportLink> SteamTransport::links() { return {}; }
|
||||
bool SteamTransport::bind(void *, void *, void *) { return true; }
|
||||
} // namespace dingosdk::multiplayer
|
||||
|
||||
namespace {
|
||||
using namespace dingosdk;
|
||||
using namespace dingosdk::multiplayer;
|
||||
using namespace dingosdk::server;
|
||||
|
||||
int failures{};
|
||||
void check(bool ok, const std::string &what) {
|
||||
if (!ok) {
|
||||
std::fprintf(stderr, "FAIL: %s\n", what.c_str());
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
bool has(const std::vector<std::string> &lines, std::string_view text) {
|
||||
return std::any_of(lines.begin(), lines.end(), [&](const auto &line) { return line.find(text) != std::string::npos; });
|
||||
}
|
||||
void write(const std::filesystem::path &file, const std::string &text) {
|
||||
std::filesystem::create_directories(file.parent_path());
|
||||
std::ofstream(file, std::ios::binary | std::ios::trunc) << text;
|
||||
}
|
||||
|
||||
// One player's game: its greeting, its world being ready, and its poses sent as a game sends
|
||||
// them (pose_batch.h), standing at `at` and facing `heading` about world up.
|
||||
struct Player {
|
||||
std::uint64_t id{}, epoch{}, secret{}, map{};
|
||||
std::string name;
|
||||
std::array<float, 3> at{};
|
||||
float heading{};
|
||||
std::uint32_t sequence{};
|
||||
struct Kept {
|
||||
std::uint32_t sequence{};
|
||||
std::uint64_t time_us{};
|
||||
pose_codec::QuantPose pose;
|
||||
};
|
||||
std::deque<Kept> kept;
|
||||
pose_batch::Sender upload;
|
||||
|
||||
Packet header(PacketKind kind, std::uint64_t now) {
|
||||
Packet p;
|
||||
p.kind = kind;
|
||||
p.session = secret;
|
||||
p.map = map;
|
||||
p.world = 1;
|
||||
p.epoch = epoch;
|
||||
p.source = id;
|
||||
p.time_us = now;
|
||||
p.sequence = ++sequence;
|
||||
p.build = supported_build::game_sha256_bytes;
|
||||
return p;
|
||||
}
|
||||
void hello(std::uint64_t now) {
|
||||
wire().status.peers.push_back({id, true});
|
||||
auto p = header(PacketKind::hello, now);
|
||||
p.text = name;
|
||||
wire().inbound.push_back({id, encode_wire(p), now});
|
||||
}
|
||||
void ready(std::uint64_t now) {
|
||||
auto p = header(PacketKind::world_ready, now);
|
||||
p.world_ready = true;
|
||||
wire().inbound.push_back({id, encode_wire(p), now});
|
||||
}
|
||||
void pose(std::uint64_t now) {
|
||||
for (const auto &bytes : std::exchange(wire().sent[id], {}))
|
||||
if (const auto read = pose_batch::Ack::read(bytes)) upload.ack(*read);
|
||||
auto p = header(PacketKind::pose, now);
|
||||
p.pose.root.position = at;
|
||||
p.pose.root.rotation = {0.f, std::sin(heading * 0.5f), 0.f, std::cos(heading * 0.5f)};
|
||||
p.pose.skater.resize(pose_codec::finger_skeleton);
|
||||
p.pose.skater[1].position = at;
|
||||
p.pose.board.resize(16);
|
||||
p.pose.board[0].position = at;
|
||||
kept.push_back({p.sequence, p.time_us, pose_codec::quantize(p.pose)});
|
||||
while (kept.size() > 64) kept.pop_front();
|
||||
const auto find = [&](std::uint32_t sequence) -> std::optional<pose_batch::KeptView> {
|
||||
for (auto it = kept.rbegin(); it != kept.rend(); ++it)
|
||||
if (it->sequence == sequence) return pose_batch::KeptView{it->sequence, it->time_us, &it->pose};
|
||||
return {};
|
||||
};
|
||||
const auto emit = [&](std::span<const std::uint8_t> message, bool) {
|
||||
wire().inbound.push_back({id, {message.begin(), message.end()}, now});
|
||||
return true;
|
||||
};
|
||||
upload.begin(1, map);
|
||||
upload.add(p.source, p.epoch, *find(p.sequence), find, false, false, pose_batch::Rate::full, now, emit);
|
||||
upload.flush(emit);
|
||||
}
|
||||
};
|
||||
|
||||
int run(const std::filesystem::path &fixtures) {
|
||||
const auto root = std::filesystem::temp_directory_path() / "reskate_server_plugin_tests";
|
||||
std::filesystem::remove_all(root);
|
||||
std::filesystem::create_directories(root / "Mods");
|
||||
for (const auto *folder : {"popular skate 2 street park", "mixed park"})
|
||||
std::filesystem::copy(fixtures / folder, root / "Mods" / folder, std::filesystem::copy_options::recursive);
|
||||
load_levels(root / "Mods"); // the retail maps
|
||||
ServerConfig config;
|
||||
config.file = root / "server" / "ReSkateServer.json";
|
||||
config.mods = root / "Mods";
|
||||
config.auto_update = false;
|
||||
config.activity_log = false;
|
||||
config.roles = default_roles();
|
||||
config.park_mods = {{"street", "popular skate 2 street park", "San Vansterdam", "bam"}, {"plaza", "mixed park", "Isle of Grom", "grom"}};
|
||||
check(config_error(config).empty(), "The test's config was refused: " + config_error(config));
|
||||
|
||||
// A plugin that prints the skaters it sees every 20th tick (a second here), and one without
|
||||
// session.skaters. Ticks, not reskate.time: its clock is the real one, and this test's is not.
|
||||
write(root / "Plugins" / "watcher" / "reskate-plugin.json",
|
||||
R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "session.skaters"]})");
|
||||
write(root / "Plugins" / "watcher" / "plugin" / "main.lua", R"(
|
||||
local ticks = 0
|
||||
reskate.on("tick", function()
|
||||
ticks += 1
|
||||
if ticks % 20 ~= 0 then return end
|
||||
local list = reskate.session.skaters()
|
||||
reskate.console.print("skaters " .. #list)
|
||||
for _, s in ipairs(list) do
|
||||
reskate.console.print(string.format("skater %s at %.1f %.1f %.1f facing %.2f, %s", s.id, s.position.x, s.position.y,
|
||||
s.position.z, s.heading, s.age_ms >= 0 and s.age_ms < 1000 and "fresh" or ("stale " .. s.age_ms)))
|
||||
end
|
||||
end)
|
||||
)");
|
||||
write(root / "Plugins" / "blind" / "reskate-plugin.json",
|
||||
R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console"]})");
|
||||
write(root / "Plugins" / "blind" / "plugin" / "main.lua", R"(
|
||||
local ticks = 0
|
||||
reskate.on("tick", function()
|
||||
ticks += 1
|
||||
if ticks % 20 ~= 0 then return end
|
||||
local ok, why = pcall(reskate.session.skaters)
|
||||
reskate.console.print(ok and "blind saw them" or "blind refused: " .. tostring(why))
|
||||
end)
|
||||
reskate.console.command("blind parks", "park mods without server.parks", function()
|
||||
local ok, why = pcall(reskate.server.parks)
|
||||
reskate.console.print(ok and "blind listed parks" or "blind parks refused: " .. tostring(why))
|
||||
end)
|
||||
)");
|
||||
|
||||
// One that says what the level is, and when it changes.
|
||||
write(root / "Plugins" / "follower" / "reskate-plugin.json",
|
||||
R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console"]})");
|
||||
write(root / "Plugins" / "follower" / "plugin" / "main.lua", R"(
|
||||
reskate.on("level_unloading", function(name) reskate.console.print("unloading " .. name) end)
|
||||
reskate.on("level_loaded", function(name) reskate.console.print("loaded " .. name) end)
|
||||
local ticks = 0
|
||||
reskate.on("tick", function()
|
||||
ticks += 1
|
||||
if ticks % 20 == 0 then reskate.console.print("level " .. reskate.level.current().name) end
|
||||
end)
|
||||
)");
|
||||
|
||||
// One that lists, spawns and removes park mods when the console asks it to.
|
||||
write(root / "Plugins" / "parker" / "reskate-plugin.json",
|
||||
R"({"api": 1, "entry": "plugin/main.lua", "side": "server", "permissions": ["console", "server.parks"]})");
|
||||
write(root / "Plugins" / "parker" / "plugin" / "main.lua", R"(
|
||||
reskate.console.command("parker", "park mods, for the test", function(args)
|
||||
local verb, name = args[1], args[2]
|
||||
if verb == "list" then
|
||||
for _, p in ipairs(reskate.server.parks()) do
|
||||
reskate.console.print(string.format("park %s on %s, here %s, spawned %s, %d objects, %d min left", p.name, p.map,
|
||||
tostring(p.here), tostring(p.spawned), p.objects, p.minutes_left))
|
||||
end
|
||||
elseif verb == "spawn" then
|
||||
local ok, why = reskate.server.park_spawn(name, tonumber(args[3]))
|
||||
reskate.console.print("spawn " .. name .. ": " .. (ok and "done" or why))
|
||||
elseif verb == "remove" then
|
||||
local ok, why = reskate.server.park_remove(name)
|
||||
reskate.console.print("remove " .. name .. ": " .. (ok and "done" or why))
|
||||
elseif verb == "bad" then
|
||||
local named = pcall(reskate.server.park_spawn, "Street; map grom")
|
||||
local timed = pcall(reskate.server.park_spawn, "street", 5000)
|
||||
reskate.console.print("bad " .. (named and "name taken" or "name refused") .. ", " .. (timed and "minutes taken" or "minutes refused"))
|
||||
end
|
||||
end)
|
||||
)");
|
||||
|
||||
std::vector<std::string> log;
|
||||
SteamTransport transport;
|
||||
Host host(config, transport, [&](const std::string &line) { log.push_back(line); });
|
||||
std::string error;
|
||||
if (!host.start(error)) {
|
||||
std::fprintf(stderr, "The server did not start: %s\n", error.c_str());
|
||||
return 1;
|
||||
}
|
||||
const auto invite = parse_invite(host.invite());
|
||||
if (!invite) {
|
||||
std::fprintf(stderr, "No invite.\n");
|
||||
return 1;
|
||||
}
|
||||
std::vector<Player> players(2);
|
||||
for (std::size_t i = 0; i < players.size(); ++i) {
|
||||
auto &p = players[i];
|
||||
p.id = 76561198000000001ULL + i;
|
||||
p.epoch = 5000 + i;
|
||||
p.secret = invite->secret;
|
||||
p.map = map_hash(map_destination(config.map));
|
||||
p.name = "Player " + std::to_string(i + 1);
|
||||
}
|
||||
players[0].at = {10.f, 2.f, -30.f};
|
||||
players[0].heading = 1.f;
|
||||
players[1].at = {-4.f, 7.5f, 12.f};
|
||||
|
||||
std::uint64_t now = 1000000000ULL, next_ready = now;
|
||||
bool posing{};
|
||||
const auto run_for = [&](std::uint64_t microseconds) {
|
||||
for (const auto end = now + microseconds; now < end; now += 50000) {
|
||||
if (now >= next_ready) {
|
||||
next_ready = now + 1000000;
|
||||
for (auto &p : players) p.ready(now);
|
||||
}
|
||||
if (posing)
|
||||
for (auto &p : players) p.pose(now);
|
||||
host.tick(now);
|
||||
}
|
||||
};
|
||||
// Before anyone is in the world, the plugin sees nobody.
|
||||
run_for(3000000);
|
||||
check(has(log, "skaters 0"), "The plugin did not run, or saw skaters on an empty server");
|
||||
check(std::any_of(log.begin(), log.end(),
|
||||
[](const auto &line) { return line.starts_with("blind refused: ") && line.find("session.skaters") != std::string::npos; }),
|
||||
"A plugin without session.skaters was not refused");
|
||||
check(!has(log, "blind saw them"), "A plugin without session.skaters saw the skaters");
|
||||
|
||||
// Joined and in the world, but no pose yet: still nobody to see.
|
||||
for (auto &p : players) p.hello(now);
|
||||
run_for(3000000);
|
||||
log.clear();
|
||||
run_for(1100000);
|
||||
check(has(log, "skaters 0"), "A player with no pose yet was a skater");
|
||||
|
||||
// Posing: each where their latest pose put them, facing as it faces.
|
||||
posing = true;
|
||||
run_for(3000000);
|
||||
log.clear();
|
||||
run_for(1100000);
|
||||
check(has(log, "skaters 2"), "The plugin did not see both skaters");
|
||||
check(has(log, "skater 76561198000000001 at 10.0 2.0 -30.0 facing 1.00, fresh"), "The first skater's place, heading or age is wrong");
|
||||
check(has(log, "skater 76561198000000002 at -4.0 7.5 12.0 facing 0.00, fresh"), "The second skater's place, heading or age is wrong");
|
||||
// A skater who stopped sending stays where they were, older.
|
||||
posing = false;
|
||||
run_for(2000000);
|
||||
log.clear();
|
||||
run_for(1100000);
|
||||
check(has(log, "skater 76561198000000001 at 10.0 2.0 -30.0 facing 1.00, stale"), "A skater's age did not grow without poses");
|
||||
|
||||
// Park mods: listed with whether they fit this map; spawned and removed through park-mod's own
|
||||
// checks, logged by the plugin's name.
|
||||
const auto plugin_command = [&](std::string_view line) {
|
||||
log.clear();
|
||||
check(host.command(line) == "Ran by a plugin.", std::string(line) + " did not reach the plugin");
|
||||
};
|
||||
plugin_command("parker list");
|
||||
check(has(log, "park street on San Vansterdam, here true, spawned false, 0 objects, 0 min left"), "street is not listed as it is");
|
||||
check(has(log, "park plaza on Isle of Grom, here false, spawned false"), "plaza is not listed as on another map");
|
||||
plugin_command("parker spawn street 5");
|
||||
check(has(log, "spawn street: done"), "The plugin could not spawn street");
|
||||
check(has(log, "[parks] street spawned (") && has(log, "5 min) by plugin parker"), "The spawn was not logged as the plugin's");
|
||||
plugin_command("parker list");
|
||||
check(has(log, "park street on San Vansterdam, here true, spawned true, ") && has(log, " objects, 5 min left"),
|
||||
"street is not listed as spawned for 5 min");
|
||||
plugin_command("parker spawn street");
|
||||
check(has(log, "spawn street: street is already spawned."), "A spawned park was spawned again");
|
||||
plugin_command("parker spawn plaza");
|
||||
check(has(log, "spawn plaza: plaza belongs on Isle of Grom"), "A park for another map was spawned");
|
||||
plugin_command("parker spawn nothing");
|
||||
check(has(log, "spawn nothing: there is no park mod called \"nothing\""), "A park that is not set up was spawned");
|
||||
plugin_command("parker bad");
|
||||
check(has(log, "bad name refused, minutes refused"), "A bad park name or time was taken");
|
||||
plugin_command("parker remove street");
|
||||
check(has(log, "remove street: done") && has(log, "[parks] street removed (by plugin parker)"), "The plugin could not remove street");
|
||||
plugin_command("parker remove street");
|
||||
check(has(log, "remove street: street is not spawned."), "A park not spawned was removed");
|
||||
plugin_command("blind parks");
|
||||
check(has(log, "blind parks refused: ") && has(log, "server.parks"), "A plugin without server.parks listed the parks");
|
||||
|
||||
// The level, as players' games name it, and its change heard when the server changes it.
|
||||
const auto *first = find_level(config.map);
|
||||
const auto other = std::find_if(levels().begin(), levels().end(), [&](const auto &l) { return l.asset != first->asset; });
|
||||
check(first && other != levels().end(), "The test needs two retail maps");
|
||||
log.clear();
|
||||
run_for(1100000);
|
||||
check(has(log, "level " + first->asset), "level.current() is not the server's map: " + first->asset);
|
||||
host.change_map(other->name);
|
||||
log.clear();
|
||||
run_for(1100000);
|
||||
check(has(log, "unloading " + first->asset) && has(log, "loaded " + other->asset), "The plugin did not hear the map change");
|
||||
check(has(log, "level " + other->asset) && !has(log, "level " + first->asset), "level.current() did not follow the map change");
|
||||
check(!has(log, "skaters 2"), "Skaters stayed on the old map's places after the map changed");
|
||||
if (failures)
|
||||
for (const auto &line : log) std::fprintf(stderr, "log: %s\n", line.c_str());
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc < 2) {
|
||||
std::fprintf(stderr, "Usage: dingosdk_server_plugin_tests <Server/Test/fixtures/park_mods>\n");
|
||||
return 2;
|
||||
}
|
||||
const int result = run(argv[1]);
|
||||
if (result == 0) std::printf("server plugin tests passed\n");
|
||||
return result;
|
||||
}
|
||||
@@ -215,6 +215,14 @@ std::optional<std::array<float, 3>> Host::player_position(std::uint64_t steam_id
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<Host::SkaterPose> Host::skater_poses() const {
|
||||
std::vector<SkaterPose> list;
|
||||
for (const auto &[id, guest] : guests_)
|
||||
if (guest && guest->handshaken && guest->world_ready && guest->latest_root && guest->pose_arrival && now_ >= guest->pose_arrival)
|
||||
list.push_back({id, *guest->latest_root, now_ - guest->pose_arrival});
|
||||
return list;
|
||||
}
|
||||
|
||||
// A player without the map is downloading it: the clock they are loading against starts over
|
||||
// each time they say so, for as long as map_fetch_limit_us from the first time. A slot is still
|
||||
// never held for good, and a player who stops saying it has the usual time to load.
|
||||
@@ -232,6 +240,11 @@ void Host::fetching(Guest &guest) {
|
||||
}
|
||||
std::string Host::invite() const { return format_invite({id_, secret_}); }
|
||||
std::string Host::map_name() const { return map_label(config_.map); }
|
||||
std::string Host::map_level() const {
|
||||
const auto destination = map_destination(config_.map);
|
||||
const auto split = destination.find('|');
|
||||
return split == std::string::npos ? destination : destination.substr(split + 1);
|
||||
}
|
||||
std::string Host::wire_map_label() const { // players without the map's mod still see its name
|
||||
auto label = map_name();
|
||||
cut_text(label, max_member_name);
|
||||
|
||||
@@ -47,6 +47,9 @@ class Host {
|
||||
|
||||
std::string invite() const;
|
||||
std::string map_name() const;
|
||||
// The level players load for the map, as their games name it: a destination's level, after
|
||||
// its "|". What the server's plugins see as reskate.level.current().
|
||||
std::string map_level() const;
|
||||
// "maps reload": reads Mods again and says what changed. Also after a map was updated.
|
||||
std::string reload_maps();
|
||||
// The mod folder of the map in play while anyone is on (an update leaves it alone), or
|
||||
@@ -82,8 +85,29 @@ class Host {
|
||||
std::uint8_t player_locks(std::uint64_t id) const { return locks_of(id); }
|
||||
std::uint8_t set_locks(std::uint64_t id, std::uint8_t locks, const std::string &source);
|
||||
std::optional<std::array<float, 3>> player_position(std::uint64_t steam_id) const;
|
||||
// Where each player in the world last said their skater was (their latest pose's root), and
|
||||
// how long ago that arrived: what the server's plugins see of the skaters.
|
||||
struct SkaterPose {
|
||||
std::uint64_t id{};
|
||||
Transform root;
|
||||
std::uint64_t age_us{};
|
||||
};
|
||||
std::vector<SkaterPose> skater_poses() const;
|
||||
void drop(std::uint64_t id, const std::string &reason, const std::string &detail = {});
|
||||
void change_map(std::string_view map); // a level name, level path or destination
|
||||
// The park mods set up (maps.park_mods), for the server's plugins: which fit the map running
|
||||
// now, and which are spawned, with how many objects and minutes left (0: until removed).
|
||||
struct ParkStatus {
|
||||
std::string name, map;
|
||||
bool here{}, spawned{};
|
||||
std::size_t objects{};
|
||||
unsigned minutes_left{};
|
||||
};
|
||||
std::vector<ParkStatus> park_status() const;
|
||||
// One spawned (for `minutes`, 0 until removed) or removed for a server plugin, as "park-mod add"
|
||||
// and "remove" do, with their checks and limits and their "[parks]" lines logged "by plugin
|
||||
// <id>": "" when it is done, else why not.
|
||||
std::string plugin_park(std::string_view plugin, bool spawn, std::string_view name, unsigned minutes);
|
||||
// A plugin message from the server's plugins (server_plugins.h) to one player, or everyone (0).
|
||||
void send_plugin_message(std::uint64_t to, const std::vector<std::uint8_t> &bytes);
|
||||
|
||||
@@ -527,6 +551,7 @@ class Host {
|
||||
ObjectState park_objects_; // never reset: its revisions only grow, so no game holds a newer one
|
||||
std::uint64_t park_object_ids_{};
|
||||
bool by_vote_{}; // a passed custom vote's command is running (as the console)
|
||||
std::string by_plugin_; // a server plugin's park-mod is running (plugin_park): "plugin <id>"
|
||||
std::string park_mod_command(std::string_view argument, std::uint64_t admin);
|
||||
void publish_parks();
|
||||
void remove_parks(std::string_view reason); // all of them, with the reason for the log
|
||||
|
||||
+37
-1
@@ -167,6 +167,42 @@ void Host::tick_parks() {
|
||||
publish_parks();
|
||||
}
|
||||
|
||||
std::vector<Host::ParkStatus> Host::park_status() const {
|
||||
std::vector<ParkStatus> list;
|
||||
for (const auto &park : config_.park_mods) {
|
||||
ParkStatus status{park.name, park.map, same_map(park.map)};
|
||||
const auto up = std::find_if(parks_spawned_.begin(), parks_spawned_.end(), [&](const auto &s) { return s.name == park.name; });
|
||||
if (up != parks_spawned_.end()) {
|
||||
status.spawned = true;
|
||||
status.objects = up->objects.size();
|
||||
if (up->expires) status.minutes_left = static_cast<unsigned>((up->expires - std::min(up->expires, now_) + 59999999) / 60000000);
|
||||
}
|
||||
list.push_back(std::move(status));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
std::string Host::plugin_park(std::string_view plugin, bool spawn, std::string_view name, unsigned minutes) {
|
||||
const auto up = [&] {
|
||||
return std::any_of(parks_spawned_.begin(), parks_spawned_.end(), [&](const auto &park) { return park.name == name; });
|
||||
};
|
||||
const bool was = up();
|
||||
by_plugin_ = "plugin " + std::string(plugin);
|
||||
struct Reset { std::string &by; ~Reset() { by.clear(); } } reset{by_plugin_};
|
||||
const auto answer = park_mod_command(std::string(spawn ? "add " : "remove ") + std::string(name) +
|
||||
(spawn && minutes ? " " + std::to_string(minutes) : std::string{}), 0);
|
||||
// The console logs what park-mod answers; for a plugin it is logged here.
|
||||
for (std::size_t at = 0; at < answer.size();) {
|
||||
const auto end = std::min(answer.find('\n', at), answer.size());
|
||||
log_(answer.substr(at, end - at));
|
||||
at = end + 1;
|
||||
}
|
||||
if (was != spawn && up() == spawn) return {};
|
||||
// "[parks] <name> could not be spawned: why", or a usage line
|
||||
const auto colon = answer.find(": ");
|
||||
return answer.starts_with("[parks] ") && colon != std::string::npos ? answer.substr(colon + 2) : answer;
|
||||
}
|
||||
|
||||
// "park-mod list | add <name> [<name>...] [minutes] | remove <name> [<name>...] | clear |
|
||||
// reload <name> [<name>...] | config add <name> | <folder> | <map> [| <key>] | config remove <name>".
|
||||
// What it did is a "[parks]" line: the console and a vote log the reply, so it is logged here only
|
||||
@@ -175,7 +211,7 @@ std::string Host::park_mod_command(std::string_view argument, std::uint64_t admi
|
||||
const auto [action, rest] = split(argument);
|
||||
const auto verb = lower(action);
|
||||
const auto *by = admin ? find(admin) : nullptr;
|
||||
const std::string who = by_vote_ ? "a vote" : by ? guest_name(*by) : admin ? std::to_string(admin) : "console";
|
||||
const std::string who = !by_plugin_.empty() ? by_plugin_ : by_vote_ ? "a vote" : by ? guest_name(*by) : admin ? std::to_string(admin) : "console";
|
||||
const auto done = [&](const std::vector<std::string> &lines) {
|
||||
std::string text;
|
||||
for (const auto &line : lines) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <format>
|
||||
#include <map>
|
||||
|
||||
@@ -79,6 +80,27 @@ class ServerGame final : public Game {
|
||||
host_.set_locks(player, locks, "plugin " + std::string(plugin));
|
||||
return {};
|
||||
}
|
||||
std::vector<ServerPark> server_parks() override {
|
||||
std::vector<ServerPark> result;
|
||||
for (auto &p : host_.park_status()) result.push_back({std::move(p.name), std::move(p.map), p.here, p.spawned, p.objects, p.minutes_left});
|
||||
return result;
|
||||
}
|
||||
std::string spawn_park(std::string_view plugin, std::string_view name, unsigned minutes) override {
|
||||
return host_.plugin_park(plugin, true, name, minutes);
|
||||
}
|
||||
std::string remove_park(std::string_view plugin, std::string_view name) override { return host_.plugin_park(plugin, false, name, 0); }
|
||||
// The skaters of the players in the world, as their latest poses put them. The server has
|
||||
// no skater of its own, so this is every player's.
|
||||
std::vector<PeerSkater> peer_skaters() override {
|
||||
std::vector<PeerSkater> result;
|
||||
for (const auto &s : host_.skater_poses()) {
|
||||
const auto &q = s.root.rotation;
|
||||
// The rotation's +Z about world up, as a client's plugin_game_runtime.cpp reads it.
|
||||
const float heading = std::atan2(2 * (q[0] * q[2] + q[3] * q[1]), 1 - 2 * (q[0] * q[0] + q[1] * q[1]));
|
||||
result.push_back({s.id, s.root.position, heading, static_cast<double>(s.age_us) / 1000.0});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Through the console's own ban and unban, so they keep its checks and save the bans file.
|
||||
std::string ban(std::uint64_t player) override {
|
||||
const auto answer = host_.command("ban " + std::to_string(player));
|
||||
@@ -197,6 +219,9 @@ void ServerPlugins::tick(std::uint64_t now_us) {
|
||||
}
|
||||
const double dt = s.last_tick ? static_cast<double>(now_us - s.last_tick) / 1000.0 : 0;
|
||||
s.last_tick = now_us;
|
||||
// The map the server runs, so plugins hear level_unloading and level_loaded when it
|
||||
// changes, as a game's plugins do (when the server changes it; players load it after).
|
||||
s.plugins.set_level(s.server.map_level());
|
||||
s.plugins.tick(dt);
|
||||
for (auto &bytes : std::exchange(s.environment.outbox, {})) s.server.send_plugin_message(wire::to_of(bytes), bytes);
|
||||
} catch (const std::exception &error) {
|
||||
|
||||
@@ -151,6 +151,7 @@ add_library(dingosdk_runtime SHARED
|
||||
Extension/Skater/pad_hide.cpp
|
||||
Extension/Skater/trick_rewrite.cpp
|
||||
Extension/Skater/arm_reach.cpp
|
||||
Extension/Skater/carry.cpp
|
||||
Extension/UI/hud_corner.cpp
|
||||
Extension/Assets/morph_memory_pool.cpp
|
||||
Extension/Assets/native_render_resource_pool.cpp
|
||||
@@ -172,6 +173,7 @@ add_library(dingosdk_runtime SHARED
|
||||
Extension/Boot/discord_presence.cpp
|
||||
Extension/Customization/developer_hoodie.cpp
|
||||
Extension/Customization/developer_board.cpp
|
||||
Extension/Customization/board_material.cpp
|
||||
Extension/Skater/skater_model.cpp
|
||||
Extension/Skater/skater_observer.cpp
|
||||
Extension/Skater/client_source_spawn.cpp
|
||||
|
||||
Reference in new issue
Block a user