diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 15db4660..a2f7ef58 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -114,9 +114,23 @@ struct MultiplayerModel { // Local: bad words in chat names and messages show as **** (on by default). bool chat_filter{true}; // Local: the tag the ReSkate backend gives this player ("Dev", "Creator" or "Homie"; empty - // for most players), and whether they show it and its animated items, to everyone. + // for most players) and its role colour, and whether they show it, and the animated items + // that come with it, to everyone. std::string identity_tag; - bool identity_marks{true}; + std::uint32_t identity_tag_colour{}; + bool identity_tag_shown{true}, identity_items_shown{true}; + // Local: how each of this player's marked cosmetics is coloured, for the Special page: what + // they wear in each slot ("Top", "Shoes", ...), then the parts of their board. mode: 0 what + // their list gives (`identity_animation`: "RAINBOW", "RED" or "GOLD"), 1 off, 2 a gradient + // between the two colours they picked, 3 the first of them alone. speed: 0 normal, 1 slow, + // 2 fast. + struct IdentityStyle { + std::string name; + int mode{}, speed{}; + std::array from{}, to{}; + }; + std::vector identity_styles; + std::string identity_animation; float voice_range = default_voice_range; // how far the host (or server) forwards proximity voice // In a dedicated server's session: the server is the host but not a player. // Admins it lists may change its settings, and its map through Levels. diff --git a/Extension/Customization/developer_board.cpp b/Extension/Customization/developer_board.cpp index 6e6d6c62..5b5442f9 100644 --- a/Extension/Customization/developer_board.cpp +++ b/Extension/Customization/developer_board.cpp @@ -20,12 +20,12 @@ void report_board(std::uintptr_t entity, std::uint64_t steam_id, std::string_vie } if (entry->entity == entity && (entry->message == hash || now - entry->at < 5000)) return; *entry = {entity, now, hash}; - logging::log(level, logging::Channel::customization, "Developer board (Steam {}, actor {:#x}): {}", steam_id, entity, message); + logging::log(level, logging::Channel::customization, "Board items (Steam {}, actor {:#x}): {}", steam_id, entity, message); } bool write_color(std::uintptr_t base, const developer_board_detail::Binding &binding, const developer_board_detail::Color &color) noexcept { __try { if (!binding.node) { - const developer_board_detail::NativeColor value{color, 0, binding.type}; + const developer_board_detail::NativeColor value{color, 0, base + addr::native_cosmetics::color_rgb_type}; // Let the engine allocate/copy/hash a missing typed parameter. Priority // zero allows later native appearance presets to replace this override. return reinterpret_cast; +Animations board_animations(std::uint64_t steam_id, const multiplayer::MarkStyles &styles) noexcept { + const auto mark = multiplayer::identity_mark(steam_id); + Animations out; + for (std::size_t i = 0; i < out.size(); ++i) + out[i] = developer_board_detail::item_animation(mark, styles[multiplayer::skater_mark_items + i]); + return out; +} +bool any_on(const Animations &animations) noexcept { + return std::any_of(animations.begin(), animations.end(), [](const auto &animation) { return animation.on; }); +} } // namespace void update_developer_board(std::uintptr_t base, std::uintptr_t board, std::uint64_t steam_id, - std::uint64_t generation, DeveloperBoardState &state) noexcept { - const auto animation = developer_board_detail::animation_for(steam_id); - const bool listed = animation != developer_board_detail::Animation::none; + std::uint64_t generation, DeveloperBoardState &state, + const multiplayer::MarkStyles &styles) noexcept { + const auto animations = board_animations(steam_id, styles); + const bool listed = any_on(animations); if (!board) { state = {}; return; } - if (!listed && !state.count) return; + if (!listed && state.colors.empty()) return; try { auto read = [](std::uintptr_t address, void *out, std::size_t size) { return memory::peek_bytes(address, out, size); }; std::array publisher{}; @@ -65,14 +78,16 @@ void update_developer_board(std::uintptr_t base, std::uintptr_t board, std::uint bool published = true; auto write = [base](const auto &binding, const auto &color) { return write_color(base, binding, color); }; auto publish = [base, &published](std::uintptr_t item) { if (!publish_materials(base, item)) published = false; }; - developer_board_detail::animate(state, live, board, generation, animation, GetTickCount64(), write, publish); - const auto wanted = static_cast(std::count_if(live.bindings.begin(), live.bindings.begin() + live.count, - [](const auto &binding) { return binding.eligible; })); + developer_board_detail::animate(state, live, board, generation, animations, GetTickCount64(), write, publish); + // The colours of the parts that are on: found, and taken. + const auto wanted = std::count_if(live.bindings.begin(), live.bindings.end(), + [&](const auto &binding) { return animations[binding.part].on; }); + const auto taken = std::count_if(state.colors.begin(), state.colors.end(), + [&](const auto &saved) { return animations[saved.binding.part].on; }); if (!published) report_board(board, steam_id, "native material publication failed", logging::Level::warning); - else if (listed && !live.pending && state.count < wanted) + else if (listed && !live.pending && taken < wanted) report_board(board, steam_id, "waiting for the remaining board color overrides", logging::Level::warning); - else if (listed && state.count) - report_board(board, steam_id, "animation active on the owned pink board cosmetics", logging::Level::info); + else if (listed && taken) report_board(board, steam_id, "animation active on the board", logging::Level::info); } catch (const std::exception &error) { report_board(board, steam_id, error.what(), logging::Level::warning); } catch (...) {} @@ -82,15 +97,16 @@ void tick_local_developer_board(std::uintptr_t base, std::uintptr_t client, bool if (!ready) return; try { const auto social = multiplayer::steam_social_snapshot(); - const auto id = social && multiplayer::own_marks_shown() ? social->local.id : 0; - if (developer_board_detail::animation_for(id) == developer_board_detail::Animation::none && !state.count) return; + const auto id = social && multiplayer::own_items_shown() ? social->local.id : 0; + const auto styles = developer_hoodie_detail::own_styles.load(); + if (!any_on(board_animations(id, styles)) && state.colors.empty()) return; const auto local = multiplayer::capture_local(base, client, false); if (!local.ready) return; auto read = [](std::uintptr_t address, void *out, std::size_t size) { return memory::peek_bytes(address, out, size); }; // Cosmetic changes can replace the board while retaining the skater. // Re-resolve the actual local association, rather than capture's cached board. const auto board = multiplayer::read_native_board(read, base, local.entity); - update_developer_board(base, board.entity, id, local.context, state); + update_developer_board(base, board.entity, id, local.context, state, styles); } catch (...) {} } } // namespace dingosdk diff --git a/Extension/Customization/developer_board.h b/Extension/Customization/developer_board.h index 678eb74a..fbb203e3 100644 --- a/Extension/Customization/developer_board.h +++ b/Extension/Customization/developer_board.h @@ -2,7 +2,10 @@ #include "developer_board_material.h" namespace dingosdk { +// Colours the board a listed player rides: `styles` is how they have each marked cosmetic, the +// board's deck, grip tape, trucks and wheels after the skater's. void update_developer_board(std::uintptr_t base, std::uintptr_t board, std::uint64_t steam_id, - std::uint64_t generation, DeveloperBoardState &state) noexcept; + std::uint64_t generation, DeveloperBoardState &state, + const multiplayer::MarkStyles &styles) noexcept; void tick_local_developer_board(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept; } // namespace dingosdk diff --git a/Extension/Customization/developer_board_material.h b/Extension/Customization/developer_board_material.h index bda9d0ae..fb0c95bd 100644 --- a/Extension/Customization/developer_board_material.h +++ b/Extension/Customization/developer_board_material.h @@ -4,11 +4,14 @@ namespace dingosdk { namespace developer_board_detail { +using developer_hoodie_detail::Binding; using developer_hoodie_detail::Color; -using developer_hoodie_detail::material_hash; -using developer_hoodie_detail::Animation; -using developer_hoodie_detail::animation_for; +using developer_hoodie_detail::ItemAnimation; +using developer_hoodie_detail::ParameterKey; +using developer_hoodie_detail::animate; +using developer_hoodie_detail::item_animation; using developer_hoodie_detail::item_color; +using developer_hoodie_detail::material_hash; using developer_hoodie_detail::settle_publishes; inline constexpr std::uint64_t graphic_color = 0x873f48af36e50d59ULL; inline constexpr std::uint64_t base_color = 0x8c46cd354b18a9e2ULL; @@ -17,59 +20,40 @@ inline constexpr std::array slots{ "Characters/Customization/Board/Slots/Skateboard_Deck_Slot", "Characters/Customization/Board/Slots/Skateboard_Trucks_Slot", "Characters/Customization/Board/Slots/Skateboard_Wheels_Slot"}; +// One colour of the board: a named material of the controller assigned one of `slots`, the +// shader key, and which of the board's marked cosmetics it colours (deck, grip tape, trucks, +// wheels: the order of their styles after the skater's, multiplayer::mark_item_names). struct Target { - std::string_view asset, material; - std::uint32_t recipe_slot; + std::string_view material; std::size_t controller; std::uint64_t key; + std::uint8_t part; }; +inline constexpr std::size_t part_count = multiplayer::mark_items - multiplayer::skater_mark_items; // The board loader registers Deck_Mat / DeckTop_Mat for the compiled sections -// Skateboard_base_mat / mat_skateboard_top. Use the live named map identities. -// Recipe identities, assigned board controllers and serialized shader keys from -// the four pink cosmetics. The deck's wood follows its Solid Pink graphic. +// Skateboard_base_mat / mat_skateboard_top, whatever deck is on it: use the live named map +// identities. The deck's wood follows its graphic. inline constexpr std::array targets{{ - {"Own_DeckGraphic_Gen_Popsicle_00021", "Deck_Mat", 0x66071628, 0, graphic_color}, - {"Own_DeckGraphic_Gen_Popsicle_00021", "Deck_Mat", 0x66071628, 0, base_color}, - {"Own_DeckGripColor_Gen_Popsicle_00006", "DeckTop_Mat", 0x755d5ee0, 0, graphic_color}, - {"Own_Truck_Gen_Default_00002", "Truck_Mat", 0x0add98be, 1, base_color}, - {"Own_WheelColor_Gen_Classic_00005", "Wheel_mat", 0xc83bb94e, 2, base_color}, + {"Deck_Mat", 0, graphic_color, 0}, + {"Deck_Mat", 0, base_color, 0}, + {"DeckTop_Mat", 0, graphic_color, 1}, + {"Truck_Mat", 1, base_color, 2}, + {"Wheel_mat", 2, base_color, 3}, }}; -// Native ParamDbKey and inline NativeValue layout used by addEsVector's setter. -// The key's first word is the final two serialized GUID bytes. Its type index -// comes from the live ColorRgb descriptor, not a serialized Vec3 or boxed value. -struct ParameterKey { - std::uint16_t identity{}, size{}, type{}, count{}; - std::uint64_t hash{}; -}; +// The inline NativeValue layout used by addEsVector's setter. Its type is the live ColorRgb +// descriptor, not a serialized Vec3 or boxed value. struct NativeColor { Color color{}; std::uint32_t padding{}; std::uintptr_t type{}; std::uint32_t flags{3}, reserved{}; }; -static_assert(sizeof(ParameterKey) == 16 && offsetof(ParameterKey, hash) == 8); static_assert(sizeof(NativeColor) == 32 && offsetof(NativeColor, type) == 16 && offsetof(NativeColor, flags) == 24); inline ParameterKey parameter_key(std::uint64_t hash, std::uint16_t type) noexcept { return {static_cast(hash == graphic_color ? 0xd442 : 0x8c54), sizeof(Color), type, 1, hash}; } -struct Binding { - std::uintptr_t item{}, material{}, node{}, type{}; - ParameterKey key{}; - Color color{}; - bool eligible{}; -}; -struct SavedColor { - Binding binding; - Color original{}, last{}; - std::uint8_t settling{}; // publishes still owed after going back to `original` -}; inline constexpr std::size_t max_bindings = targets.size() * 4; -struct Materials { - std::uintptr_t component{}, appearance{}; - std::array bindings{}; - std::size_t count{}; - bool pending{}; -}; +using developer_hoodie_detail::Materials; template std::uintptr_t parameter(Memory &m, std::uintptr_t material, std::uint64_t key) { const auto buckets = m.ptr(material, 0x268); @@ -89,21 +73,9 @@ template Materials materials(Read &read, std::uintptr_t base, std::u using namespace multiplayer; CosmeticMemory m{read, base}; Materials out; - m.check(m.ptr(entity) == base + addr::engine::board_entity_vtable, "RGB board actor type differs."); + m.check(m.ptr(entity) == base + addr::engine::board_entity_vtable, "Board actor type differs."); out.component = m.component(entity); out.pending = m.template get(out.component, 0x10a) || m.template get(out.component, 0x10d); - const auto recipe = m.ptr(out.component, 0x158); - const auto count = m.count(recipe, sizeof(NativeCosmeticItem), max_cosmetic_slots); - std::array eligible{}; - for (std::size_t i = 0; i < count; ++i) { - const auto item = m.template get(recipe, i * sizeof(NativeCosmeticItem)); - for (std::size_t t = 0; t < targets.size(); ++t) { - const auto &target = targets[t]; - if (item.slot != target.recipe_slot || item.hash != cosmetic_asset_hash(target.asset)) continue; - const auto name = item.asset ? m.text(reinterpret_cast(item.asset)) : std::string{}; - eligible[t] = name.empty() || name == target.asset; - } - } const auto type = base + addr::native_cosmetics::color_rgb_type; const auto info = m.ptr(type); m.check(info >= base && info < base + 0x09144000 && m.template get(info) == 0x885eff59 && @@ -130,8 +102,7 @@ template Materials materials(Read &read, std::uintptr_t base, std::u 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 (std::size_t t = 0; t < targets.size(); ++t) { - const auto &target = targets[t]; + for (const auto &target : targets) { if (target.controller != slot) continue; const auto hash = material_hash(target.material); auto node = m.ptr(buckets, (hash % size) * 8ULL); @@ -156,66 +127,17 @@ template Materials materials(Read &read, std::uintptr_t base, std::u color = m.template get(p, 0x10); m.check(std::all_of(color.begin(), color.end(), [](float c) { return std::isfinite(c); }), "Board color is invalid."); } - m.check(out.count < out.bindings.size(), "Board colors exceed bounds."); - out.bindings[out.count++] = {item, material, p, type, key, color, eligible[t]}; + m.check(out.bindings.size() < max_bindings, "Board colors exceed bounds."); + out.bindings.push_back({item, material, p, key, color, target.part}); } } } return out; } -inline bool same_binding(const Binding &live, const Binding &saved) noexcept { - // A successful native insertion acquires its node on the next fresh walk. - return live.item == saved.item && live.material == saved.material && live.type == saved.type && live.key.hash == saved.key.hash && - (saved.node ? live.node == saved.node : live.node != 0); -} } // namespace developer_board_detail -struct DeveloperBoardState { - std::uintptr_t entity{}, component{}, appearance{}; - std::uint64_t generation{}; - std::array colors{}; - std::size_t count{}; -}; -namespace developer_board_detail { -// Re-discover ownership before every write. Changing the recipe restores only -// colors that still equal our last write; native preset updates take precedence. -// Missing keys inherit .5 from these compiled board shaders. Keep inserted -// overrides at native preset priority and restore that default on deactivation. +// What was saved of a board's parts while they are coloured. A key a part's material does not +// have inherits .5 from the compiled board shaders: one is added at native preset priority, so +// a later native preset replaces it, and goes back to that default when the colouring stops. // Never clear a NativeValue: the renderer still copies the key's full payload. -template -void animate(DeveloperBoardState &state, const Materials &live, std::uintptr_t entity, std::uint64_t generation, - Animation animation, std::uint64_t milliseconds, Write &write, Publish &publish) { - if (state.entity != entity || state.generation != generation || state.component != live.component || state.appearance != live.appearance) - state = {}; - if (live.pending) return; - DeveloperBoardState next; - next.entity = entity; next.generation = generation; next.component = live.component; next.appearance = live.appearance; - const auto color = item_color(animation, milliseconds); - std::array changed_items{}; - std::size_t changed{}; - for (std::size_t i = 0; i < live.count; ++i) { - const auto &binding = live.bindings[i]; - const SavedColor *saved{}; - for (std::size_t j = 0; j < state.count; ++j) - if (same_binding(binding, state.colors[j].binding)) { saved = &state.colors[j]; break; } - bool wrote{}; - if (animation != Animation::none && binding.eligible) { - const auto original = saved && binding.color == saved->last ? saved->original : binding.color; - if ((binding.node && binding.color == color) || (wrote = write(binding, color))) - next.colors[next.count++] = {binding, original, color}; - else if (saved) next.colors[next.count++] = *saved; - } else if (saved && (binding.color == saved->last || binding.color == saved->original)) { - // Back to the part's own color, which takes a few publishes to show (settle_publishes): - // `wrote` has the part published for each of them. - wrote = binding.color == saved->original || write(binding, saved->original); - const std::uint8_t left = saved->settling ? saved->settling - 1 : settle_publishes; - if (!wrote) next.colors[next.count++] = *saved; - else if (left) next.colors[next.count++] = {binding, saved->original, saved->original, left}; - } - if (wrote && std::find(changed_items.begin(), changed_items.begin() + changed, binding.item) == changed_items.begin() + changed) - changed_items[changed++] = binding.item; - } - state = next; - for (std::size_t i = 0; i < changed; ++i) publish(changed_items[i]); -} -} // namespace developer_board_detail +struct DeveloperBoardState : developer_hoodie_detail::ItemState {}; } // namespace dingosdk diff --git a/Extension/Customization/developer_hoodie.cpp b/Extension/Customization/developer_hoodie.cpp index e07f6b78..a5541ac0 100644 --- a/Extension/Customization/developer_hoodie.cpp +++ b/Extension/Customization/developer_hoodie.cpp @@ -5,6 +5,7 @@ #include "Extension/Multiplayer/Remote/native_skater.h" #include #include +#include namespace dingosdk { namespace { @@ -23,7 +24,7 @@ void report_hoodie(std::uintptr_t entity, std::uint64_t steam_id, std::string_vi } if (entry->entity == entity && (entry->message == hash || now - entry->at < 5000)) return; *entry = {entity, now, hash}; - logging::log(level, logging::Channel::customization, "Developer hoodie (Steam {}, actor {:#x}): {}", + logging::log(level, logging::Channel::customization, "Skater items (Steam {}, actor {:#x}): {}", steam_id, entity, message); } // NativeValue is owned by this material. Preserve its padding, type, priority, @@ -41,13 +42,34 @@ bool publish_materials(std::uintptr_t base, std::uintptr_t item) noexcept { return true; } __except (EXCEPTION_EXECUTE_HANDLER) { return false; } } +// What each of the skater's marked cosmetics does for this player: top, bottoms, shoes, ... +using Animations = std::array; +Animations skater_animations(std::uint64_t steam_id, const multiplayer::MarkStyles &styles) noexcept { + const auto mark = multiplayer::identity_mark(steam_id); + Animations out; + for (std::size_t i = 0; i < out.size(); ++i) out[i] = developer_hoodie_detail::item_animation(mark, styles[i]); + return out; +} +bool any_on(const Animations &animations) noexcept { + return std::any_of(animations.begin(), animations.end(), [](const auto &animation) { return animation.on; }); +} +// Which cosmetics are coloured, and how many colours of each: what is equipped decides. +std::string coloured(const DeveloperHoodieState &state) { + std::array colors{}; + for (const auto &saved : state.colors) ++colors[saved.binding.part]; + std::string out; + for (std::size_t i = 0; i < colors.size(); ++i) + if (colors[i]) out += std::format("{}{} ({})", out.empty() ? "" : ", ", multiplayer::mark_item_names[i], colors[i]); + return out.empty() ? "nothing equipped has a color to change" : "animation active on " + out; +} } // namespace void update_developer_hoodie(std::uintptr_t base, std::uintptr_t entity, std::uint64_t steam_id, - std::uint64_t generation, DeveloperHoodieState &state) noexcept { - const auto animation = developer_hoodie_detail::animation_for(steam_id); - const bool listed = animation != developer_hoodie_detail::Animation::none; + std::uint64_t generation, DeveloperHoodieState &state, + const multiplayer::MarkStyles &styles) noexcept { + const auto animations = skater_animations(steam_id, styles); + const bool listed = any_on(animations); if (!entity) { state = {}; return; } - if (!listed && !state.count) return; + if (!listed && state.colors.empty()) return; try { auto read = [](std::uintptr_t address, void *out, std::size_t size) { return memory::peek_bytes(address, out, size); }; std::array prefix{}; @@ -58,13 +80,10 @@ void update_developer_hoodie(std::uintptr_t base, std::uintptr_t entity, std::ui } const auto live = developer_hoodie_detail::materials(read, base, entity); bool published = true; - auto publish = [base, &published](std::uintptr_t item) { published = publish_materials(base, item); }; - developer_hoodie_detail::animate(state, live, entity, generation, animation, GetTickCount64(), write_color, publish); + auto publish = [base, &published](std::uintptr_t item) { if (!publish_materials(base, item)) published = false; }; + developer_hoodie_detail::animate(state, live, entity, generation, animations, GetTickCount64(), write_color, publish); if (!published) report_hoodie(entity, steam_id, "native material publication failed", logging::Level::warning); - else if (listed && state.count) - report_hoodie(entity, steam_id, "animation active on the owned hoodie material", logging::Level::info); - else if (listed && live.eligible) - report_hoodie(entity, steam_id, "waiting for the assigned top slot's color parameters", logging::Level::warning); + else if (listed && !live.pending) report_hoodie(entity, steam_id, coloured(state), logging::Level::info); } catch (const std::exception &error) { report_hoodie(entity, steam_id, error.what(), logging::Level::warning); } catch (...) { @@ -79,10 +98,11 @@ void tick_local_developer_hoodie(std::uintptr_t base, std::uintptr_t client, boo if (!ready) return; try { const auto social = multiplayer::steam_social_snapshot(); - const auto id = social && multiplayer::own_marks_shown() ? social->local.id : 0; - if (developer_hoodie_detail::animation_for(id) == developer_hoodie_detail::Animation::none && !state.count) return; + const auto id = social && multiplayer::own_items_shown() ? social->local.id : 0; + const auto styles = developer_hoodie_detail::own_styles.load(); + if (!any_on(skater_animations(id, styles)) && state.colors.empty()) return; const auto local = multiplayer::capture_local(base, client, false); - if (local.ready) update_developer_hoodie(base, local.entity, id, local.context, state); + if (local.ready) update_developer_hoodie(base, local.entity, id, local.context, state, styles); else report_hoodie(client, id, local.detail, logging::Level::warning); } catch (...) {} } diff --git a/Extension/Customization/developer_hoodie.h b/Extension/Customization/developer_hoodie.h index 3842763a..97235e98 100644 --- a/Extension/Customization/developer_hoodie.h +++ b/Extension/Customization/developer_hoodie.h @@ -4,7 +4,10 @@ namespace dingosdk { // Verified client update thread only. Remote identity comes from the Steam roster, // generation from the current native actor; neither is a client cosmetic parameter. +// Colours what a listed player wears (developer_hoodie_material.h): `styles` is how they have +// each marked cosmetic, the skater's first. void update_developer_hoodie(std::uintptr_t base, std::uintptr_t entity, std::uint64_t steam_id, - std::uint64_t generation, DeveloperHoodieState &state) noexcept; + std::uint64_t generation, DeveloperHoodieState &state, + const multiplayer::MarkStyles &styles) noexcept; void tick_local_developer_hoodie(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept; } // namespace dingosdk diff --git a/Extension/Customization/developer_hoodie_material.h b/Extension/Customization/developer_hoodie_material.h index a649cd8b..29e0a48f 100644 --- a/Extension/Customization/developer_hoodie_material.h +++ b/Extension/Customization/developer_hoodie_material.h @@ -1,50 +1,93 @@ #pragma once #include "Extension/Multiplayer/developer_identity.h" +#include "Extension/Multiplayer/Remote/cosmetics.h" #include "Extension/Multiplayer/Remote/native_cosmetics_layout.h" #include #include +#include #include +#include #include +#include +#include +#include +#include +// The cosmetics of a player on one of the backend's lists (developer_identity.h) are coloured +// for them: whatever they wear and ride, on their own material instances, as they choose on the +// menu's Special page. It began as the developers' rainbow hoodie and pink board, which the +// names here still say. namespace dingosdk { namespace developer_hoodie_detail { -inline constexpr std::string_view asset = "Own_TopShirt_Gen_HoodieRelaxed_00001"; -// The compiled appearance preset names controllers by their assigned CAS slot. -// "TopItem" is a Lua construction name, not the live controller's name. -inline constexpr std::string_view top_slot = "Characters/MainCharacters/Generic/CAS/Common/Slots/CAS_Top_Slot"; -// Serialized ParamDbKey identities from this hoodie's AppearanceShaderExpressionPreset. -// Only the six fabric color regions: textures, stitches and other clothing stay intact. -// As seen in game: the first is the whole body, sleeves and hood; the fourth the cuffs and -// hem; the fifth the drawstrings and hood lining. The other three do not show. -inline constexpr std::array color_keys{ - 0x8c46cd354b18a9e2ULL, 0x1567b5742f4ec0b2ULL, 0x1564a942cb33c53bULL, - 0xf526305270d94cb3ULL, 0x99879bf202d1960aULL, 0x998450425dbd9044ULL}; +// The compiled appearance preset names a skater's item controllers by their assigned CAS slot +// ("TopItem" and the like are Lua construction names, not the live controllers' names). +inline constexpr std::string_view slot_folder = "Characters/MainCharacters/Generic/CAS/Common/Slots/"; +// The skater's marked cosmetics, in the order of their styles (multiplayer::mark_item_names): +// top, bottoms, shoes, socks, hat, glasses, outfit, costume. +inline constexpr std::array slots{ + "CAS_Top_Slot", "CAS_Bottom_Slot", "CAS_Footwear_Slot", "CAS_Sock_Slot", + "CAS_Headgear_Slot", "CAS_Eyewear_Slot", "CAS_Outfit_Slot", "CAS_CostumeFullBody_Slot"}; +// Serialized ParamDbKey identities of the ColorRgb parameters the game's clothing presets set +// (AppearanceShaderExpressionPreset.ParamInfo; read from 114 presets of every kind of item, +// 2026-10-04): the colours of an item's regions. Every kind of clothing draws on the same +// ones, the first thirteen by far the most, and each item colours some and leaves the rest at +// the shaders' neutral grey (`neutral`), where its textures show as they are. +// On the relaxed hoodie, as seen in game: the first is the whole body, sleeves and hood; the +// fourth the cuffs and hem; the fifth the drawstrings and hood lining. +inline constexpr std::array color_keys{ + 0x8c46cd354b18a9e2ULL, 0x1567b5742f4ec0b2ULL, 0x1564a942cb33c53bULL, 0xf526305270d94cb3ULL, + 0x99879bf202d1960aULL, 0x998450425dbd9044ULL, 0x18984e85c679da8dULL, 0x2bf96fc9d8da57bbULL, + 0x13cfb51bd8474129ULL, 0x7c06880748ded394ULL, 0xfca496001ea60718ULL, 0xfca79ddc325249ceULL, + 0x5e1943a7c20ab77cULL, 0x0adafe3e13df4057ULL, 0xe3ab5f6a33dd1684ULL, 0xc31ad6ca613dcf43ULL, + 0xc1c70aa67f8bde7cULL, 0xc1c4554f6b58eee8ULL, 0x24e6723cc9f17eccULL, 0xd98b435969485311ULL, + 0x27e785e804edea5cULL, 0x27e45bf8d67d959fULL, 0xae823ac5acb2abbaULL, 0x89cbc6b088ae9b1bULL, + 0x593aacb9e12dcd6bULL, 0x4ec6aeb742a2c383ULL, 0xa19416e977675f88ULL, 0x0a94b0e28d102968ULL}; using Color = std::array; +inline constexpr Color neutral{.5f, .5f, .5f}; +// Native ParamDbKey, as a material's parameter node holds it. The first word is the final two +// serialized GUID bytes; the type index is the live ColorRgb descriptor's. +struct ParameterKey { + std::uint16_t identity{}, size{}, type{}, count{}; + std::uint64_t hash{}; +}; +static_assert(sizeof(ParameterKey) == 16 && offsetof(ParameterKey, hash) == 8); +// One colour of one material instance. `part` is which marked cosmetic it belongs to. struct Binding { - std::uintptr_t material{}, node{}, type{}; - std::uint64_t key{}; + std::uintptr_t item{}, material{}, node{}; // node 0: no such parameter yet (the board adds one) + ParameterKey key{}; Color color{}; - bool operator==(const Binding &) const = default; + std::uint8_t part{}; }; struct SavedColor { Binding binding; Color original{}, last{}; + std::uint8_t settling{}; // publishes still owed for `last` (settle_publishes) }; -inline constexpr std::size_t max_bindings = color_keys.size() * 4; +// The colours found on an actor in one walk. struct Materials { - std::uintptr_t component{}, appearance{}, item{}; - std::array bindings{}; - std::size_t count{}; - bool eligible{}; + std::uintptr_t component{}, appearance{}; + std::vector bindings; + bool pending{}; + // Clothing: a colour at `neutral` is a region the item leaves to its textures, and is left + // alone, unless the item colours none of its regions: then all of them are coloured. + bool regions_only{}; }; +// What was saved of an actor's colours while they are coloured here. +struct ItemState { + std::uintptr_t entity{}, component{}, appearance{}; + std::uint64_t generation{}; + std::vector colors; +}; +// What is equipped can be anything: this many colours of a skater, and no more. +inline constexpr std::size_t max_bindings = 512; inline std::uint32_t material_hash(std::string_view name) noexcept { std::uint32_t hash = 5381; for (auto c : name) hash = hash * 33 ^ static_cast(c >= 'A' && c <= 'Z' ? c + 32 : c); return hash; } -inline Color rainbow(std::uint64_t milliseconds) noexcept { - // One continuous hue cycle every eight seconds, in the material's existing gamut. - const auto hue = static_cast(milliseconds % 8000) * (6.f / 8000.f); +inline Color rainbow(std::uint64_t milliseconds, std::uint64_t period = 8000) noexcept { + // One continuous hue cycle every eight seconds (or `period`), in the material's existing gamut. + const auto hue = static_cast(milliseconds % period) * (6.f / static_cast(period)); const auto sector = static_cast(hue); constexpr float high = .8f, low = .025f; const auto rising = low + (high - low) * (hue - static_cast(sector)); @@ -58,164 +101,267 @@ inline Color rainbow(std::uint64_t milliseconds) noexcept { default: return {high, low, falling}; } } -// What a listed player's hoodie and board do: a developer's cycle the rainbow, a content -// creator's go from deep red through bright red to a reddish pink and back, and a homie's -// from deep gold through bright gold to yellow and back. -enum class Animation : std::uint8_t { none, rainbow, red, gold }; -inline Animation animation_for(std::uint64_t steam_id) noexcept { - using multiplayer::IdentityList; - switch (multiplayer::identity_mark(steam_id).value_or(IdentityList::count)) { - case IdentityList::developer: return Animation::rainbow; - case IdentityList::content_creator: return Animation::red; - case IdentityList::homie: return Animation::gold; - default: return Animation::none; - } +// What one of a listed player's marked cosmetics does right now: nothing, the rainbow, or a +// walk through three colours and back (one colour three times stands still). +struct ItemAnimation { + bool on{}, rainbow{}; + std::array stops{}; + std::uint64_t period = 3000; // milliseconds for a round +}; +// A colour as picked on screen, as the material takes it: linear, and inside the gamut the +// rainbow keeps to. +inline Color material_color(const std::array &picked) noexcept { + Color out{}; + for (std::size_t i = 0; i < out.size(); ++i) + out[i] = std::max(.004f, std::pow(static_cast(picked[i]) / 255.f, 2.2f) * .8f); + return out; } -inline Color item_color(Animation animation, std::uint64_t milliseconds) noexcept { - if (animation == Animation::rainbow) return rainbow(milliseconds); - // One colour getting lighter and darker is hard to see, so each goes on into the colour - // next to it: a deep shade, a bright one, then a reddish pink or a yellow, and back. - constexpr std::array red{{{.2f, .004f, .01f}, {.8f, .03f, .03f}, {.8f, .05f, .13f}}}; - constexpr std::array gold{{{.34f, .12f, .006f}, {.8f, .5f, .06f}, {.8f, .64f, .11f}}}; - const auto &stops = animation == Animation::red ? red : gold; - // 0 to 2 and back, every three seconds. - const auto along = 1.f - std::cos(static_cast(milliseconds % 3000) * (6.2831853f / 3000.f)); - const auto &from = stops[along < 1.f ? 0 : 1], &to = stops[along < 1.f ? 1 : 2]; +// The colours the pickers start from: about where each list's own animation begins and ends. +inline std::pair, std::array> standard_picks(multiplayer::IdentityList mark) noexcept { + using multiplayer::IdentityList; + if (mark == IdentityList::content_creator) return {{0x88, 0x23, 0x23}, {0xff, 0x48, 0x70}}; + if (mark == IdentityList::homie) return {{0xad, 0x6c, 0x1c}, {0xff, 0xe6, 0x67}}; + return {{0x6e, 0x20, 0xff}, {0xb4, 0x78, 0xff}}; +} +// What a cosmetic does for a player on `mark`'s list (nobody else's does anything) with the +// style they chose on the Special page. Left standard, a developer's cycles the rainbow, a +// content creator's goes from deep red through bright red to a reddish pink and back, and a +// homie's from deep gold through bright gold to yellow and back. No style asks for the rainbow: +// it is a developer's standard, and nobody else's. +inline ItemAnimation item_animation(std::optional mark, const multiplayer::MarkStyle &style) noexcept { + using multiplayer::IdentityList; + using multiplayer::MarkMode; + ItemAnimation out; + if (!mark || style.mode == MarkMode::off) return out; + out.on = true; + out.rainbow = style.mode == MarkMode::standard && *mark == IdentityList::developer; + const std::uint64_t normal = out.rainbow ? 8000 : 3000; + out.period = style.speed == 1 ? normal * 2 : style.speed == 2 ? normal / 2 : normal; + if (style.mode == MarkMode::solid) { + out.stops.fill(material_color(style.from)); + } else if (style.mode == MarkMode::gradient) { + const auto from = material_color(style.from), to = material_color(style.to); + out.stops = {from, Color{(from[0] + to[0]) * .5f, (from[1] + to[1]) * .5f, (from[2] + to[2]) * .5f}, to}; + } else if (!out.rainbow) { + // One colour getting lighter and darker is hard to see, so each goes on into the colour + // next to it: a deep shade, a bright one, then a reddish pink or a yellow. + constexpr std::array red{{{.2f, .004f, .01f}, {.8f, .03f, .03f}, {.8f, .05f, .13f}}}; + constexpr std::array gold{{{.34f, .12f, .006f}, {.8f, .5f, .06f}, {.8f, .64f, .11f}}}; + out.stops = *mark == IdentityList::content_creator ? red : gold; + } + return out; +} +inline Color item_color(const ItemAnimation &animation, std::uint64_t milliseconds) noexcept { + if (animation.rainbow) return rainbow(milliseconds, animation.period); + // 0 to 2 and back, once a round. + const auto along = 1.f - std::cos(static_cast(milliseconds % animation.period) * + (6.2831853f / static_cast(animation.period))); + const auto &from = animation.stops[along < 1.f ? 0 : 1], &to = animation.stops[along < 1.f ? 1 : 2]; const auto amount = along < 1.f ? along : along - 1.f; return {from[0] + (to[0] - from[0]) * amount, from[1] + (to[1] - from[1]) * amount, from[2] + (to[2] - from[2]) * amount}; } +// The local player's own styles (the Special page): sent with their appearance, and kept in +// their profile as text, two hex digits a byte. +inline std::atomic own_styles{}; +inline std::string mark_styles_text(const multiplayer::MarkStyles &styles) { + constexpr char digits[] = "0123456789abcdef"; + std::string text; + const auto add = [&](std::uint8_t byte) { text += digits[byte >> 4], text += digits[byte & 15]; }; + for (const auto &style : styles) { + add(static_cast(style.mode)); + for (const auto part : style.from) add(part); + for (const auto part : style.to) add(part); + add(style.speed); + } + return text; +} +inline std::optional parse_mark_styles(std::string_view text) noexcept { + multiplayer::MarkStyles styles{}; + if (text.size() != styles.size() * 16) return std::nullopt; + std::size_t at = 0; + bool valid = true; + const auto next = [&]() -> std::uint8_t { + unsigned byte = 0; + for (int digit = 0; digit < 2; ++digit) { + const char c = text[at++]; + if (c >= '0' && c <= '9') byte = byte * 16 + static_cast(c - '0'); + else if (c >= 'a' && c <= 'f') byte = byte * 16 + static_cast(c - 'a' + 10); + else valid = false; + } + return static_cast(byte); + }; + for (auto &style : styles) { + style.mode = static_cast(next()); + for (auto &part : style.from) part = next(); + for (auto &part : style.to) part = next(); + style.speed = next(); + valid = valid && multiplayer::valid_mark_style(style); + } + return valid ? std::optional(styles) : std::nullopt; +} +// A material instance's own parameters: a hash map of typed nodes. +struct ParameterMap { + std::uintptr_t buckets{}, stop{}; + std::uint32_t size{}, total{}; +}; +template ParameterMap parameter_map(Memory &m, std::uintptr_t material) { + ParameterMap out{m.ptr(material, 0x268), 0, m.template get(material, 0x270), + m.template get(material, 0x274)}; + if (!out.buckets || !out.size || out.size > 1024 || !out.total || out.total > 512) return {}; + out.stop = m.ptr(out.buckets, out.size * 8ULL); + return out; +} +// The node of a material's ColorRgb parameter with this key, or 0. The EBX boxed ColorRgb has a +// serialized gamut word; the loaded native ColorRgb is three floats (12 bytes): this goes by +// the actual type descriptor, not the serialized asset size. A parameter of another shape is +// passed over, not reported: what is equipped can be anything. +template +std::uintptr_t color_parameter(Memory &m, std::uintptr_t base, const ParameterMap &map, std::uint64_t key, ParameterKey &native, + Color &color) { + if (!map.size) return 0; + auto node = m.ptr(map.buckets, (key % map.size) * 8ULL); + for (std::uint32_t visited = 0; node && node != map.stop && visited < map.total; ++visited, node = m.ptr(node, 0x40)) { + if (m.template get(node, 8) != key) continue; + native = m.template get(node); + const auto type = m.ptr(node, 0x20); + if (type < base || type >= base + 0x09144000 || native.size != sizeof(Color) || native.count != 1 || + !(m.template get(node, 0x28) & 1) || m.template get(type, 8) != native.type) + return 0; + const auto info = m.ptr(type); + if (m.template get(info) != 0x885eff59U || m.template get(info, 6) != sizeof(Color)) return 0; + color = m.template get(node, 0x10); + return std::all_of(color.begin(), color.end(), [](float c) { return std::isfinite(c); }) ? node : 0; + } + return 0; +} // All discovery is bounded and follows the actor's own component/controller/material // backlinks. Never edit the shared AppearanceShaderExpressionPreset or recipe. template Materials materials(Read &read, std::uintptr_t base, std::uintptr_t entity) { using namespace multiplayer; CosmeticMemory m{read, base}; Materials out; - m.check(m.ptr(entity) == base + addr::engine::skater_entity_vtable, "Hoodie actor type differs."); + out.regions_only = true; + m.check(m.ptr(entity) == base + addr::engine::skater_entity_vtable, "Skater actor type differs."); out.component = m.component(entity); - const bool pending = m.template get(out.component, 0x10a) || m.template get(out.component, 0x10d); - const auto recipe = m.ptr(out.component, 0x158); - const auto items = m.count(recipe, sizeof(NativeCosmeticItem), max_cosmetic_slots); - const auto wanted = cosmetic_asset_hash(asset); - for (std::size_t i = 0; i < items; ++i) { - const auto item = m.template get(recipe, i * sizeof(NativeCosmeticItem)); - if (item.hash == wanted) { - // Native recipes may store only the hash (an empty CString). - const auto name = m.text(reinterpret_cast(item.asset)); - out.eligible = !pending && (name.empty() || name == asset); - } - } + out.pending = m.template get(out.component, 0x10a) || m.template get(out.component, 0x10d); out.appearance = read_native_component(read, entity, base + addr::engine::skater_appearance_vtable); const auto begin = m.ptr(out.appearance, 0x90), end = m.ptr(out.appearance, 0x98), capacity = m.ptr(out.appearance, 0xa0); m.check(begin <= end && end <= capacity && (end - begin) % 8 == 0 && (capacity - begin) % 8 == 0 && - (capacity - begin) / 8 <= 64 && (begin || !capacity), "Hoodie controller array exceeds bounds."); + (capacity - begin) / 8 <= 64 && (begin || !capacity), "Skater controller array exceeds bounds."); for (auto entry = begin; entry < end; entry += 8) { const auto item = m.ptr(entry); - if (!item || m.ptr(item, 0x48) != out.appearance + 0x40 || m.text(m.ptr(item, 0x30)) != top_slot) continue; - m.check(!out.item, "Hoodie has multiple top controllers."); - out.item = item; - if (!(m.template get(item, 0xb0) & 2)) { out.eligible = false; continue; } + if (!item || m.ptr(item, 0x48) != out.appearance + 0x40) continue; + const auto name = m.text(m.ptr(item, 0x30)); + if (!name.starts_with(slot_folder)) continue; + const auto slot = std::find(slots.begin(), slots.end(), std::string_view(name).substr(slot_folder.size())); + if (slot == slots.end()) continue; + const auto part = static_cast(slot - slots.begin()); + // A slot with nothing in it, or with an item still on its way, has no materials. + if (!(m.template get(item, 0xb0) & 2)) continue; const auto buckets = m.ptr(item, 0x1f0); const auto size = m.template get(item, 0x1f8), total = m.template get(item, 0x1fc); if (!total) continue; - m.check(size && size <= 256 && total <= 128, "Hoodie material map exceeds bounds."); - const auto sentinel = m.ptr(buckets, size * 8ULL); - const auto hash = material_hash("Top_mat"); - auto node = m.ptr(buckets, (hash % size) * 8ULL); - for (std::uint32_t visited = 0; node && node != sentinel && visited < total; ++visited, node = m.ptr(node, 0x28)) { - if (m.template get(node) != hash) continue; - std::array seen{}; - for (std::size_t variant = 0; variant < seen.size(); ++variant) { - const auto material = m.ptr(node, 8 + variant * 8); - if (!material || std::find(seen.begin(), seen.begin() + variant, material) != seen.begin() + variant) continue; - seen[variant] = material; - const auto params = m.ptr(material, 0x268); - const auto n = m.template get(material, 0x270), count = m.template get(material, 0x274); - if (!count) continue; - m.check(n && n <= 1024 && count <= 512, "Hoodie parameter map exceeds bounds."); - const auto stop = m.ptr(params, n * 8ULL); - for (const auto key : color_keys) { - auto parameter = m.ptr(params, (key % n) * 8ULL); - for (std::uint32_t v = 0; parameter && parameter != stop && v < count; ++v, parameter = m.ptr(parameter, 0x40)) { - if (m.template get(parameter, 8) != key) continue; - // The EBX boxed ColorRgb has a serialized gamut word; the - // loaded native ColorRgb is three floats (12 bytes). Validate - // the actual type descriptor, not the serialized asset size. - const auto type = m.ptr(parameter, 0x20); - m.check(type >= base && type < base + 0x09144000 && - m.template get(parameter, 2) == sizeof(Color) && - m.template get(m.ptr(type)) == 0x885eff59U && - m.template get(m.ptr(type), 6) == sizeof(Color) && m.template get(parameter, 6) == 1 && - (m.template get(parameter, 0x28) & 1) && - m.template get(type, 8) == m.template get(parameter, 4), - "Hoodie color parameter type differs."); - const auto color = m.template get(parameter, 0x10); - m.check(std::all_of(color.begin(), color.end(), [](float c) { return std::isfinite(c); }) && - out.count < out.bindings.size(), "Hoodie color exceeds bounds."); - out.bindings[out.count++] = {material, parameter, type, key, color}; - break; + m.check(buckets && size && size <= 256 && total <= 128, "Skater material map exceeds bounds."); + // Every named material of the item, whatever its mesh calls them ("Top_mat", "Bottom_mat", ...). + std::array heads{}; + m.check(read(buckets, heads.data(), (size + 1ULL) * 8), "Skater material map cannot be read."); + std::uint32_t visited{}; + for (std::uint32_t bucket = 0; bucket < size; ++bucket) { + for (auto node = heads[bucket]; node && node != heads[size]; node = m.ptr(node, 0x28)) { + if (m.template get(node) % size != bucket) break; // the next bucket's + m.check(visited++ < total, "Skater material chain exceeds bounds."); + std::array seen{}; + for (std::size_t variant = 0; variant < seen.size(); ++variant) { + const auto material = m.ptr(node, 8 + variant * 8); + if (!material || std::find(seen.begin(), seen.begin() + variant, material) != seen.begin() + variant) continue; + seen[variant] = material; + const auto map = parameter_map(m, material); + for (const auto key : color_keys) { + if (out.bindings.size() == max_bindings) break; + ParameterKey native; + Color color{}; + if (const auto parameter = color_parameter(m, base, map, key, native, color)) + out.bindings.push_back({item, material, parameter, native, color, static_cast(part)}); } } } - break; } } return out; } -inline bool same_binding(const Binding &a, const Binding &b) noexcept { - return a.material == b.material && a.node == b.node && a.type == b.type && a.key == b.key; +inline bool same_binding(const Binding &live, const Binding &saved) noexcept { + // A parameter the board added has its node from the next fresh walk on. + return live.item == saved.item && live.material == saved.material && live.key.hash == saved.key.hash && + (saved.node ? live.node == saved.node : live.node != 0); } -} // namespace developer_hoodie_detail -struct DeveloperHoodieState { - std::uintptr_t entity{}, component{}, appearance{}, item{}; - std::uint64_t generation{}; - std::array colors{}; - std::size_t count{}; - std::uint8_t settling{}; // publishes still owed after the animation went off (settle_publishes) -}; -namespace developer_hoodie_detail { -// The renderer shows a color one publish late. While the animation runs the next tick's -// publish brings it; once it is off, the item's own colors need publishing again after the -// write that brought them back, or the item keeps the last animated color on screen (seen -// in game, 2026-10-04). The saved colors are kept for these extra publishes. +// The renderer shows a color one publish late. While a color keeps changing the next tick's +// publish brings it; one that stands still (a solid color, or the item's own once the +// animation is off) needs publishing again after the write that set it, or the item keeps the +// color before it on screen (seen in game, 2026-10-04). inline constexpr std::uint8_t settle_publishes = 3; -// Writes are only to freshly rediscovered bindings. Restoration additionally -// requires the current color to still be ours; a new native preset takes precedence. -template -void animate(DeveloperHoodieState &state, const Materials &live, std::uintptr_t entity, std::uint64_t generation, - Animation animation, std::uint64_t milliseconds, Write &write, Publish &publish) { - if (state.entity != entity || state.generation != generation || state.component != live.component || - state.appearance != live.appearance || state.item != live.item) state = {}; - const bool enabled = animation != Animation::none && live.eligible; - const auto color = item_color(animation, milliseconds); - const std::uint8_t settling = enabled ? 0 : state.settling ? state.settling - 1 : settle_publishes; - DeveloperHoodieState next; - next.settling = settling; - if (enabled || settling) { - next.entity = entity; next.generation = generation; next.component = live.component; - next.appearance = live.appearance; next.item = live.item; - } - bool changed{}; - for (std::size_t i = 0; i < live.count; ++i) { +// Ownership is discovered afresh before every write, and writes go only to what that walk +// found. A color is restored only while it is still the one written here: a new native +// preset takes precedence. `animations`: what each part does, or nothing. +template +void animate(ItemState &state, const Materials &live, std::uintptr_t entity, std::uint64_t generation, + const std::array &animations, std::uint64_t milliseconds, Write &write, Publish &publish) { + if (state.entity != entity || state.generation != generation || state.component != live.component || state.appearance != live.appearance) + state = {}; + // Pausing, loading and a recipe on its way keep what was saved until a walk succeeds. + if (live.pending) return; + // What was saved of each color found: a walk finds them in the same order as the last. + std::vector saved(live.bindings.size()); + std::array regions{}; + for (std::size_t i = 0, from = 0; i < live.bindings.size(); ++i) { const auto &binding = live.bindings[i]; - const SavedColor *saved{}; - for (std::size_t j = 0; j < state.count; ++j) - if (same_binding(binding, state.colors[j].binding)) { saved = &state.colors[j]; break; } - if (enabled) { - const auto original = saved && binding.color == saved->last ? saved->original : binding.color; - if (binding.color == color || write(binding, color)) { - next.colors[next.count++] = {binding, original, color}; - changed = changed || binding.color != color; - } - } else if (saved && (binding.color == saved->last || binding.color == saved->original)) { - if (binding.color != saved->original) write(binding, saved->original); - changed = true; - if (settling) next.colors[next.count++] = {binding, saved->original, saved->original}; + for (std::size_t n = 0; n < state.colors.size(); ++n) { + const auto at = (from + n) % state.colors.size(); + if (same_binding(binding, state.colors[at].binding)) { saved[i] = &state.colors[at], from = at + 1; break; } } + const auto &own = saved[i] && binding.color == saved[i]->last ? saved[i]->original : binding.color; + regions[binding.part] = regions[binding.part] || own != neutral; } - if (!next.count) next = {}; - state = next; - if (changed) publish(live.item); + ItemState next; + next.entity = entity; next.generation = generation; next.component = live.component; next.appearance = live.appearance; + next.colors.reserve(live.bindings.size()); + std::array changed_items{}; + std::size_t changed{}; + for (std::size_t i = 0; i < live.bindings.size(); ++i) { + const auto &binding = live.bindings[i]; + const auto *was = saved[i]; + const auto &animation = animations[binding.part]; + const bool ours = was && binding.color == was->last; + const auto original = ours ? was->original : binding.color; + bool show{}; + if (animation.on && !(live.regions_only && regions[binding.part] && original == neutral)) { + const auto color = item_color(animation, milliseconds); + if (binding.node && binding.color == color) { + // Already this color: nothing to write, but it may still be owed to the renderer. + const std::uint8_t left = ours ? was->settling : 0; + next.colors.push_back({binding, original, color, static_cast(left ? left - 1 : 0)}); + show = left != 0; + } else if (write(binding, color)) { + next.colors.push_back({binding, original, color, settle_publishes}); + show = true; + } else if (was) next.colors.push_back(*was); + } else if (was && (binding.color == was->last || binding.color == was->original)) { + // Back to the item's own color, and published until it shows. + const bool first = was->last != was->original; + show = binding.color == was->original || write(binding, was->original); + const std::uint8_t left = first ? settle_publishes : was->settling ? was->settling - 1 : 0; + if (!show) next.colors.push_back(*was); + else if (left) next.colors.push_back({binding, was->original, was->original, left}); + } + if (show && changed < changed_items.size() && + std::find(changed_items.begin(), changed_items.begin() + changed, binding.item) == changed_items.begin() + changed) + changed_items[changed++] = binding.item; + } + state.entity = next.entity, state.generation = next.generation, state.component = next.component, state.appearance = next.appearance; + state.colors = std::move(next.colors); + for (std::size_t i = 0; i < changed; ++i) publish(changed_items[i]); } } // namespace developer_hoodie_detail +// What was saved of a skater's items while they are coloured. +struct DeveloperHoodieState : developer_hoodie_detail::ItemState {}; } // namespace dingosdk diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index 5fa607f8..559ab567 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -237,7 +237,7 @@ bool valid_appearance(const Appearance &a) noexcept { if (a.skater.key != skater_recipe_key || a.skater.version != 2 || a.board.key != board_recipe_key || a.board.version != 1) return false; - std::size_t size = header_size + 13; + std::size_t size = header_size + 13 + mark_items * 8; for (const auto *r : {&a.skater, &a.board}) { if (r->scalars.size() > max_cosmetic_scalars || r->items.empty() || r->items.size() > max_cosmetic_slots) @@ -445,7 +445,13 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 w.integer(p.appearance.card.background, 4); w.integer(p.appearance.card.emblem, 4); w.integer(p.appearance.card.title, 4); - w.integer(p.appearance.unmarked ? 1 : 0, 1); + w.integer((p.appearance.hide_tag ? 1 : 0) | (p.appearance.hide_items ? 2 : 0), 1); + for (const auto &style : p.appearance.marks) { + w.integer(static_cast(style.mode), 1); + for (const auto part : style.from) w.integer(part, 1); + for (const auto part : style.to) w.integer(part, 1); + w.integer(style.speed, 1); + } } else if (p.kind == PacketKind::audio) { w.integer(p.audio.size(), 2); AudioState previous; @@ -676,8 +682,17 @@ std::optional decode(std::span bytes) noexcept { p.appearance.card.emblem = static_cast(r.integer(4)); p.appearance.card.title = static_cast(r.integer(4)); const auto flags = r.integer(1); - p.appearance.unmarked = flags == 1; - if (flags > 1 || !p.map || !valid_appearance(p.appearance)) + p.appearance.hide_tag = (flags & 1) != 0; + p.appearance.hide_items = (flags & 2) != 0; + bool styled = true; + for (auto &style : p.appearance.marks) { + style.mode = static_cast(r.integer(1)); + for (auto &part : style.from) part = static_cast(r.integer(1)); + for (auto &part : style.to) part = static_cast(r.integer(1)); + style.speed = static_cast(r.integer(1)); + styled = styled && valid_mark_style(style); + } + if (flags > 3 || !styled || !p.map || !valid_appearance(p.appearance)) return {}; } else if (p.kind == PacketKind::audio) { const auto count = r.integer(2); diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index a2fdb89f..a86d70ff 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -25,7 +25,7 @@ namespace dingosdk::multiplayer { constexpr std::size_t max_skater_bones = 512, max_board_bones = 64; constexpr std::size_t max_packet = 24576; constexpr std::size_t packet_header_size = 64; -constexpr std::uint16_t protocol_version = 40; +constexpr std::uint16_t protocol_version = 41; constexpr std::size_t max_throwdown_message = 4096; // Packet::tuning: the host's SkatePhysicsTuning differences (Extension/Skater/physics_tuning.h). constexpr std::size_t max_physics_tuning = 16384; diff --git a/Extension/Multiplayer/Remote/cosmetics.h b/Extension/Multiplayer/Remote/cosmetics.h index d49c3e44..7b152912 100644 --- a/Extension/Multiplayer/Remote/cosmetics.h +++ b/Extension/Multiplayer/Remote/cosmetics.h @@ -28,12 +28,31 @@ struct PlayerCard { std::uint32_t background{}, emblem{}, title{}; bool operator==(const PlayerCard &) const = default; }; +// How one of a listed player's marked cosmetics is coloured (developer_identity.h), chosen on +// the menu's Special page and carried in their Appearance, so everyone sees the same. +enum class MarkMode : std::uint8_t { standard, off, gradient, solid }; +struct MarkStyle { + MarkMode mode{}; // standard: what the player's list gives + std::array from{}, to{}; // as picked on screen. gradient: between the two; solid: `from` + std::uint8_t speed{}; // 0 normal, 1 slow, 2 fast + bool operator==(const MarkStyle &) const = default; +}; +// The marked cosmetics, in this order: whatever the skater has in each of these slots, then the +// parts of whatever board they ride. +inline constexpr std::array mark_item_names{ + "Top", "Bottoms", "Shoes", "Socks", "Hat", "Glasses", "Outfit", "Costume", "Deck", "Grip tape", "Trucks", "Wheels"}; +inline constexpr std::size_t mark_items = mark_item_names.size(), skater_mark_items = 8; +using MarkStyles = std::array; +inline bool valid_mark_style(const MarkStyle &style) noexcept { + return style.mode <= MarkMode::solid && style.speed <= 2; +} struct Appearance { CosmeticRecipe skater, board; PlayerCard card; - // The player has turned off the marks the ReSkate backend gives them (developer_identity.h): - // nobody shows their tag or animates their hoodie and board. - bool unmarked{}; + // The player has turned off what the ReSkate backend gives them (developer_identity.h): + // nobody shows their tag, or nobody animates their hoodie and board. + bool hide_tag{}, hide_items{}; + MarkStyles marks{}; bool operator==(const Appearance &) const = default; }; // Stand-ins for a peer's cosmetic that this PC does not have (a player's diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 0ba82454..2500dff2 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -257,7 +257,11 @@ void publish_party(Session &s) { s.custom_nametags = profile_runtime::local_preference("CustomNametags").value_or(true); s.chat_visible = profile_runtime::local_preference("ChatVisible").value_or(true); s.chat_filter = profile_runtime::local_preference("ChatFilter").value_or(true); - show_own_marks(profile_runtime::local_preference("IdentityMarks").value_or(true)); + show_own_tag(profile_runtime::local_preference("IdentityTag").value_or(true)); + show_own_items(profile_runtime::local_preference("IdentityItems").value_or(true)); + if (const auto saved = profile_runtime::local_value("IdentityStyles"); saved && saved->is_string()) + if (const auto styles = developer_hoodie_detail::parse_mark_styles(saved->string())) + developer_hoodie_detail::own_styles.store(*styles); apply_nametags(s); } // The party's limit: parties players form hold up to eight (the game's Party panel rows), @@ -427,7 +431,7 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui }; // The developer hoodie and board mark an identity: only one Steam vouches for (steam_vouched). const auto developer_id = [&](const Peer &p) { - return steam_vouched(s, p) && shows_marks(p) ? p.member.id : std::uint64_t{}; + return steam_vouched(s, p) && shows_items(p) ? p.member.id : std::uint64_t{}; }; each_active_peer(s, [&](Peer &p) { // A dedicated server has no skater to show. @@ -437,8 +441,10 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui // A far player between samples: their skater keeps its pose; sound, the label and // the spectate position are still refreshed (sound events are released one per frame). if (was_visible && local.ready && !p.render_failed && p.far_interval && now < p.next_far_sample) { - update_developer_hoodie(s.base, remote_skater_entity(), developer_id(p), remote_skater_generation(), p.developer_hoodie); - update_developer_board(s.base, remote_board_entity(), developer_id(p), remote_skater_generation(), p.developer_board); + update_developer_hoodie(s.base, remote_skater_entity(), developer_id(p), remote_skater_generation(), p.developer_hoodie, + mark_styles(p)); + update_developer_board(s.base, remote_board_entity(), developer_id(p), remote_skater_generation(), p.developer_board, + mark_styles(p)); present_audio(p); update_party_position(&p.render_pose); if (labels) label(p); @@ -515,9 +521,9 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui } } update_developer_hoodie(s.base, p.visible ? remote_skater_entity() : 0, developer_id(p), - remote_skater_generation(), p.developer_hoodie); + remote_skater_generation(), p.developer_hoodie, mark_styles(p)); update_developer_board(s.base, p.visible ? remote_board_entity() : 0, developer_id(p), - remote_skater_generation(), p.developer_board); + remote_skater_generation(), p.developer_board, mark_styles(p)); if (!p.visible) { stop_remote_audio(); p.presented_audio.reset(); diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index bf0c1f3b..c3f67082 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -324,14 +325,42 @@ std::string edit_nametag_style(Session &s, std::string_view argument) { profile_runtime::set_local_preference("CustomNametags", s.custom_nametags); return s.custom_nametags ? "ReSkate nametags: names, distances and dots." : "The game's own nametags."; } -// The Special page: a player on one of the backend's lists going without their tag and items. -// Their next appearance packet tells everyone (session_send.cpp). -std::string edit_marks(Session &, std::string_view argument) { - const auto shown = parse_switch(argument, own_marks_shown()); - if (!shown) return "Use on, off, or toggle for your tag and items."; - show_own_marks(*shown); - profile_runtime::set_local_preference("IdentityMarks", *shown); - return *shown ? "Your tag and items show." : "Your tag and items are hidden, for you and everyone you skate with."; +// The Special page: a player on one of the backend's lists going without their tag, or +// without the animation on their items. Their next appearance packet tells everyone +// (session_send.cpp). +std::string edit_own_tag(Session &, std::string_view argument) { + const auto shown = parse_switch(argument, own_tag_shown()); + if (!shown) return "Use on, off, or toggle for your tag."; + show_own_tag(*shown); + profile_runtime::set_local_preference("IdentityTag", *shown); + return *shown ? "Your tag shows." : "Your tag is hidden, for you and everyone you skate with."; +} +std::string edit_own_items(Session &, std::string_view argument) { + const auto shown = parse_switch(argument, own_items_shown()); + if (!shown) return "Use on, off, or toggle for your items."; + show_own_items(*shown); + profile_runtime::set_local_preference("IdentityItems", *shown); + return *shown ? "Your items animate." : "Your items are plain, for you and everyone you skate with."; +} +// The Special page: how one of the player's marked cosmetics is coloured, as +// " " (MarkStyle). It goes out with their next +// appearance packet and is kept in their profile. +std::string edit_mark_style(Session &, std::string_view argument) { + std::istringstream in{std::string(argument)}; + unsigned item{}, mode{}, from{}, to{}, speed{}; + if (!(in >> item >> mode >> std::hex >> from >> to >> std::dec >> speed) || item >= mark_items || mode > 3 || + from > 0xffffff || to > 0xffffff || speed > 2) + return "That is not a cosmetic style."; + const auto colour = [](unsigned value) { + return std::array{static_cast(value >> 16), static_cast(value >> 8), + static_cast(value)}; + }; + auto styles = developer_hoodie_detail::own_styles.load(); + styles[item] = {static_cast(mode), colour(from), colour(to), static_cast(speed)}; + developer_hoodie_detail::own_styles.store(styles); + profile_runtime::set_local_values({{"IdentityStyles", Json(developer_hoodie_detail::mark_styles_text(styles))}}); + return std::string(mark_item_names[item]) + + (mode == 1 ? ": off." : mode == 2 ? ": your gradient." : mode == 3 ? ": your color." : ": back to the usual."); } // Hidden chat still receives lines, so showing it again brings back the conversation. std::string edit_chat_visible(Session &s, std::string_view argument) { @@ -445,14 +474,18 @@ std::string send_admin(Session &s, std::string text) { if (!send_packet(s, s.host_id, request, true, false)) return "Could not reach the server."; return "Sent to the server."; } +// The Special page's commands. They are a player's own offline as well, where their hoodie and +// board still animate. +bool own_mark_command(std::string_view action) { + return action == "mark-tag" || action == "mark-items" || action == "mark-style"; +} } // namespace bool queue_command(std::string_view action, std::string_view argument, std::string_view password) { - // A player's own marks are theirs offline as well, where their hoodie and board still animate. - if (launcher::offline_mode() && action != "marks") return false; + if (launcher::offline_mode() && !own_mark_command(action)) return false; if ((action != "host" && action != "host-config" && action != "join" && action != "join-lobby" && action != "join-friend-lobby" && action != "stop" && action != "distances" && action != "object-placement" && action != "kick" && action != "clear-objects" && action != "nametags" && action != "nametag-style" && action != "chat-visible" && action != "chat-filter" && - action != "marks" && + !own_mark_command(action) && action != "voice" && action != "voice-mute" && action != "voice-volume" && action != "voice-allow" && action != "voice-range" && action != "chat" && action != "ban" && action != "unban" && action != "world-layer-sync" && action != "noclip-allow" && action != "nobail-allow" && action != "boosts-allow" && @@ -476,7 +509,7 @@ bool queue_command(std::string_view action, std::string_view argument, std::stri return true; } std::string command(std::string_view action, std::string_view argument, std::string_view password) { - if (launcher::offline_mode() && action != "marks") + if (launcher::offline_mode() && !own_mark_command(action)) return "Multiplayer is unavailable in offline mode. Start Steam and relaunch ReSkate."; const bool configured_host = action == "host-config"; if (configured_host) action = "host"; @@ -632,7 +665,7 @@ std::string command(std::string_view action, std::string_view argument, std::str {"world-layer-sync", edit_world_layer_sync}, {"distances", edit_distances}, {"nametags", edit_nametags}, {"nametag-style", edit_nametag_style}, - {"marks", edit_marks}, + {"mark-tag", edit_own_tag}, {"mark-items", edit_own_items}, {"mark-style", edit_mark_style}, {"chat-visible", edit_chat_visible}, {"chat-filter", edit_chat_filter}}; for (const auto &[name, edit] : settings) if (action == name) { diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index db57058c..46ca45ad 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -310,11 +310,22 @@ inline bool dedicated_host(const Session &s) { return s.mode == Mode::join && ga inline bool steam_vouched(const Session &s, const Peer &peer) { return s.mode != Mode::join || peer.member.id == s.host_id || peer.direct_ready; } -// Whether a player shows the marks the backend gives them: not until their appearance has -// arrived, and not when it says they turned them off (Appearance::unmarked). -inline bool shows_marks(const Peer &peer) { +// Whether a player shows the tag the backend gives them, and whether the items that come with +// it animate: not until their appearance has arrived, and not when it says they turned that +// off (Appearance::hide_tag, Appearance::hide_items). +inline bool shows_tag(const Peer &peer) { const auto &look = peer.appearance.value(); - return look && !look->unmarked; + return look && !look->hide_tag; +} +inline bool shows_items(const Peer &peer) { + const auto &look = peer.appearance.value(); + return look && !look->hide_items; +} +// How a player has each of their marked cosmetics animate: what their appearance says. +inline const MarkStyles &mark_styles(const Peer &peer) { + static const MarkStyles standard{}; + const auto &look = peer.appearance.value(); + return look ? look->marks : standard; } Peer *find_peer(Session &s, std::uint64_t id); unsigned player_count(const Session &s); diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index c37e961d..604092c0 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -596,9 +596,13 @@ void send_local(Session &s, const NativeFrame &local, std::uint64_t now, std::ui if (now - s.last_cosmetic_capture >= 500000) { s.last_cosmetic_capture = now; auto appearance = capture_cosmetics(s.base, local, s.cosmetic_capture_status); - // The player's choice to go without their marks travels with their outfit, so a change - // is sent like one and reaches players who join later. - if (appearance) appearance->unmarked = !own_marks_shown(); + // The player's choices to go without their tag or their animated items travel with their + // outfit, so a change is sent like one and reaches players who join later. + if (appearance) { + appearance->hide_tag = !own_tag_shown(); + appearance->hide_items = !own_items_shown(); + appearance->marks = developer_hoodie_detail::own_styles.load(); + } if (appearance && (!s.sent_appearance || *appearance != *s.sent_appearance)) { auto p = packet(s, PacketKind::cosmetics, now); p.appearance = *appearance; diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 11e5538e..21c9ac75 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -173,8 +173,28 @@ void publish(Session &s, const NativeFrame *local) { view.nametags = s.nametags; view.custom_nametags = s.custom_nametags; if (const auto social = steam_social_snapshot()) - if (const auto mark = identity_mark(social->local.id)) view.identity_tag = mark_role(*mark).second; - view.identity_marks = own_marks_shown(); + if (const auto mark = identity_mark(social->local.id)) { + std::tie(view.identity_tag_colour, view.identity_tag) = mark_role(*mark); + view.identity_animation = + *mark == IdentityList::developer ? "RAINBOW" : *mark == IdentityList::content_creator ? "RED" : "GOLD"; + const auto styles = developer_hoodie_detail::own_styles.load(); + const auto standard = developer_hoodie_detail::standard_picks(*mark); + view.identity_styles.resize(styles.size()); + for (std::size_t i = 0; i < styles.size(); ++i) { + // A cosmetic never given colours shows its list's own in the pickers. + const bool picked = styles[i].mode == MarkMode::gradient || styles[i].mode == MarkMode::solid || + styles[i].from != styles[i].to || styles[i].from != std::array{}; + const auto &from = picked ? styles[i].from : standard.first, &to = picked ? styles[i].to : standard.second; + auto &shown = view.identity_styles[i]; + shown.name = mark_item_names[i]; + shown.mode = static_cast(styles[i].mode); + shown.speed = styles[i].speed; + for (std::size_t part = 0; part < 3; ++part) + shown.from[part] = static_cast(from[part]) / 255.f, shown.to[part] = static_cast(to[part]) / 255.f; + } + } + view.identity_tag_shown = own_tag_shown(); + view.identity_items_shown = own_items_shown(); load_host_preferences(s); view.voice_range = s.mode == Mode::host ? s.voice_range : dedicated_host(s) ? s.roster_voice_range : s.host_preferences.voice_range; @@ -519,8 +539,8 @@ std::pair player_role(Session &s, std::uint64_t send const auto *peer = local ? nullptr : find_peer(s, sender); const bool vouched = local || (peer && steam_vouched(s, *peer)); // Who the backend says a player is comes before what they are in this lobby, unless they - // have turned their marks off (the Special page), which their appearance tells everyone. - if (vouched && (local ? own_marks_shown() : shows_marks(*peer))) + // have turned their tag off (the Special page), which their appearance tells everyone. + if (vouched && (local ? own_tag_shown() : shows_tag(*peer))) if (const auto mark = identity_mark(sender)) return mark_role(*mark); if (dedicated && (local ? s.server_admin : peer && peer->member.admin)) return {nametag_admin, "Admin"}; if (!dedicated && (local ? s.mode == Mode::host : sender == s.host_id)) return {nametag_host, "Host"}; diff --git a/Extension/Multiplayer/Test/developer_identity_tests.cpp b/Extension/Multiplayer/Test/developer_identity_tests.cpp index c6df8cdf..53df6ed7 100644 --- a/Extension/Multiplayer/Test/developer_identity_tests.cpp +++ b/Extension/Multiplayer/Test/developer_identity_tests.cpp @@ -82,33 +82,41 @@ void lookup() { identity_mark(creator) == L::content_creator && identity_mark(homie) == L::homie && !identity_mark(stranger) && !identity_mark(0), "A player's mark is not the first list they are on"); - check(own_marks_shown(), "A player's marks start hidden"); - show_own_marks(false); - check(!own_marks_shown() && identity_mark(dev) == L::developer, "Hiding their own marks did not take, or changed the lists"); - show_own_marks(true); + // Their tag and their items are each their own to hide. + check(own_tag_shown() && own_items_shown(), "A player's tag or items start hidden"); + show_own_tag(false); + check(!own_tag_shown() && own_items_shown() && identity_mark(dev) == L::developer, + "Hiding their own tag did not take, hid their items, or changed the lists"); + show_own_tag(true); + show_own_items(false); + check(own_tag_shown() && !own_items_shown(), "Hiding their own items did not take, or hid their tag"); + show_own_items(true); check(publish_identity_lists({{{dev, other_dev}, {homie}, {creator}}}), "The lists did not go back"); // Removing someone in the panel takes their mark away at the next refresh. check(publish_identity_lists({{{dev}, {}, {}}}) && reskate_developer(dev) && !reskate_developer(other_dev) && !identity_listed(homie, L::homie), "Replaced lists kept a player who was removed"); } -// The hoodie and board: a developer's rainbow, a content creator's red, a homie's gold. +// The hoodie and board: what a list gives a cosmetic, and what a player makes of it on the Special page. void items() { using namespace dingosdk::developer_hoodie_detail; - publish_identity_lists({{{dev}, {dev, homie, creator}, {dev, creator}}}); - check(animation_for(dev) == Animation::rainbow && animation_for(creator) == Animation::red && - animation_for(homie) == Animation::gold, - "A list did not give its animation, a developer's first and a content creator's before a homie's"); - check(animation_for(stranger) == Animation::none && animation_for(0) == Animation::none, "An unlisted player's items animate"); - check(item_color(Animation::rainbow, 0) == rainbow(0) && item_color(Animation::rainbow, 3000) == rainbow(3000), - "The developer's rainbow changed"); - // Each goes from a deep shade to a bright one, on into the colour next to it, and back + using dingosdk::multiplayer::MarkMode; + using dingosdk::multiplayer::MarkStyle; + using dingosdk::multiplayer::MarkStyles; + const MarkStyle standard{}; + const auto spectrum = item_animation(L::developer, standard), red = item_animation(L::content_creator, standard), + gold = item_animation(L::homie, standard); + check(spectrum.on && spectrum.rainbow && red.on && !red.rainbow && gold.on && !gold.rainbow && + !item_animation(std::nullopt, standard).on, + "A list did not give its animation, or a player on none got one"); + check(item_color(spectrum, 0) == rainbow(0) && item_color(spectrum, 3000) == rainbow(3000), "The developer's rainbow changed"); + // Red and gold go from a deep shade to a bright one, on into the colour next to it, and back // every three seconds. - for (const auto animation : {Animation::red, Animation::gold}) { - const auto deep = item_color(animation, 0), bright = item_color(animation, 750), far = item_color(animation, 1500); - check(item_color(animation, 3000) == deep && item_color(animation, 4500) == far, "An animation does not repeat every three seconds"); + for (const auto *animation : {&red, &gold}) { + const auto deep = item_color(*animation, 0), bright = item_color(*animation, 750), far = item_color(*animation, 1500); + check(item_color(*animation, 3000) == deep && item_color(*animation, 4500) == far, "An animation does not repeat every three seconds"); check(bright[0] > deep[0] * 2 && far[0] >= bright[0], "An animation does not go from deep to bright"); - if (animation == Animation::red) { + if (animation == &red) { // A content creator's: red, then a reddish pink. check(bright[1] < bright[0] * .1f && bright[2] < bright[0] * .1f, "Red is not red"); check(far[2] > bright[2] * 3 && far[2] < far[0] * .4f && far[1] < far[0] * .15f, "Red does not go on into a reddish pink"); @@ -119,81 +127,177 @@ void items() { } for (std::uint64_t at = 0; at < 6000; at += 125) { // The material's gamut, and red always the strongest part of the colour. - const auto color = item_color(animation, at); + const auto color = item_color(*animation, at); check(color[0] <= .801f && color[1] > 0 && color[2] > 0 && color[0] > color[1] && color[0] > color[2], "An animation leaves its colours"); } } + + // A cosmetic turned off, and one given the player's own two colours. + const MarkStyle off{MarkMode::off, {}, {}, 0}, own{MarkMode::gradient, {255, 0, 0}, {0, 0, 255}, 0}; + check(!item_animation(L::developer, off).on && !item_animation(L::homie, off).on, "A cosmetic turned off still animates"); + const auto custom = item_animation(L::homie, own); + const auto first = item_color(custom, 0), last = item_color(custom, 1500), half = item_color(custom, 750); + check(custom.on && !custom.rainbow && first[0] > .7f && first[2] < .01f && last[2] > .7f && last[0] < .01f && half[0] > .3f && + half[2] > .3f && item_color(custom, 3000) == first, + "A player's own colours are not what their cosmetic moves between"); + // A developer can leave the rainbow for colours of their own; no style gives anyone else the rainbow. + check(!item_animation(L::developer, own).rainbow && item_animation(L::developer, own).on, "A developer cannot have their own colours"); + // One colour of their own stands still, at any speed. + const auto still = item_animation(L::homie, {MarkMode::solid, {0, 255, 0}, {9, 9, 9}, 2}); + check(still.on && !still.rainbow && item_color(still, 0) == material_color({0, 255, 0}) && item_color(still, 0)[1] > .7f && + item_color(still, 777) == item_color(still, 0) && item_color(still, 1500) == item_color(still, 0), + "A solid colour is not the colour picked, or does not stand still"); + for (const auto mode : {MarkMode::standard, MarkMode::gradient, MarkMode::solid}) + check(!item_animation(L::content_creator, {mode, {}, {}, 0}).rainbow && !item_animation(L::homie, {mode, {}, {}, 0}).rainbow, + "A style gave the rainbow to someone who is not a developer"); + // Slow takes twice as long and fast half, for the rainbow too. + check(item_animation(L::homie, {MarkMode::gradient, {}, {}, 1}).period == 6000 && + item_animation(L::homie, {MarkMode::standard, {}, {}, 2}).period == 1500 && + item_animation(L::developer, {MarkMode::standard, {}, {}, 1}).period == 16000 && + item_animation(L::developer, {MarkMode::standard, {}, {}, 2}).period == 4000 && + item_color(item_animation(L::developer, {MarkMode::standard, {}, {}, 2}), 1000) == rainbow(2000), + "The speed is not half or twice the usual"); + // The pickers start from colours close to the list's own. + const auto picks = standard_picks(L::content_creator); + const auto start = material_color(picks.first), finish = material_color(picks.second); + check(std::abs(start[0] - .2f) < .02f && start[1] < .02f && std::abs(finish[0] - .8f) < .01f && std::abs(finish[2] - .13f) < .02f, + "A content creator's pickers do not start from their red"); + + // The styles are kept in the profile as text. + MarkStyles styles{}; + styles[0] = own, styles[2] = off, styles[4] = {MarkMode::gradient, {1, 2, 3}, {254, 253, 252}, 2}; + styles.back() = {MarkMode::solid, {7, 8, 9}, {}, 0}; + const auto text = mark_styles_text(styles); + check(text.size() == styles.size() * 16 && parse_mark_styles(text) == styles && parse_mark_styles(mark_styles_text({})) == MarkStyles{}, + "The styles did not come back from their text"); + check(!parse_mark_styles("") && !parse_mark_styles("00") && !parse_mark_styles(std::string(text.size(), 'f')) && + !parse_mark_styles(std::string(text.size(), 'g')) && !parse_mark_styles(text + "00") && + !parse_mark_styles(std::string_view(text).substr(16)), + "Text that is not styles was read as some"); + // Every marked cosmetic has a name, the skater's before the board's. + check(dingosdk::multiplayer::mark_item_names.size() == slots.size() + dingosdk::developer_board_detail::part_count && + dingosdk::multiplayer::mark_item_names[slots.size()] == "Deck" && slots[2] == "CAS_Footwear_Slot", + "The marked cosmetics and their names do not line up"); } -// Turning the animation off (the Special page, or a list that no longer has the player) puts an -// item's own colours back, and publishes them the extra times the renderer needs to show them. +// Whatever a player wears is coloured where the item colours itself, each cosmetic on its own; +// turning one off (the Special page, or a list that no longer has the player) puts its own +// colours back, and publishes them the extra times the renderer needs to show them. void settling() { using namespace dingosdk::developer_hoodie_detail; + using dingosdk::multiplayer::MarkMode; + const auto red = item_animation(L::content_creator, {}), gold = item_animation(L::homie, {}); + const ItemAnimation none; + using Skater = std::array; + const auto wearing = [&](const ItemAnimation &top, const ItemAnimation &shoes) { + Skater out{}; + out[0] = top, out[2] = shoes; + return out; + }; + const Skater bare{}; const Color black{.02f, .02f, .02f}, navy{.02f, .02f, .2f}, teal{.02f, .3f, .3f}; - // What the game holds for each colour region; the materials are read afresh each tick, as in game. - std::array held{black, navy}; + const auto bound = [](std::uintptr_t item, std::uintptr_t node, std::uint64_t key, const Color &color, std::uint8_t part) { + return Binding{item, item + 40, node, {0, sizeof(Color), 1, 1, key}, color, part}; + }; + // What the game holds for each colour, read afresh each tick as in game: a top with two + // regions it colours and one it leaves to its texture, and shoes that colour none of theirs. + std::array held{black, navy, neutral, neutral}; dingosdk::DeveloperHoodieState state; Materials live; - live.component = 1, live.appearance = 2, live.item = 3, live.eligible = true, live.count = 2; - live.bindings[0] = {10, 100, 7, color_keys[0], black}; - live.bindings[1] = {10, 101, 7, color_keys[3], navy}; - int publishes{}; + live.component = 1, live.appearance = 2, live.regions_only = true; + live.bindings = {bound(10, 100, color_keys[0], black, 0), bound(10, 101, color_keys[3], navy, 0), + bound(10, 102, color_keys[7], neutral, 0), bound(11, 103, color_keys[0], neutral, 2)}; + std::vector published; + int writes{}; auto write = [&](const Binding &binding, const Color &color) { held[binding.node - 100] = color; + ++writes; return true; }; - auto publish = [&](std::uintptr_t) { ++publishes; }; - const auto tick = [&](Animation animation, std::uint64_t at) { + auto publish = [&](std::uintptr_t item) { published.push_back(item); }; + const auto tick = [&](const Skater &animations, std::uint64_t at) { for (std::size_t i = 0; i < held.size(); ++i) live.bindings[i].color = held[i]; - animate(state, live, 5, 6, animation, at, write, publish); + animate(state, live, 5, 6, animations, at, write, publish); }; - tick(Animation::red, 0); - tick(Animation::red, 700); - check(state.count == 2 && held[0] == item_color(Animation::red, 700) && held[1] == held[0] && publishes == 2, - "The animation did not colour the hoodie"); - publishes = 0; - tick(Animation::none, 800); - check(held[0] == black && held[1] == navy && publishes == 1, "Turning it off did not bring the hoodie's own colours back"); - for (std::uint64_t at = 900; at < 910; ++at) tick(Animation::none, at); - check(publishes == 1 + settle_publishes && !state.count && held[0] == black && held[1] == navy, - "The own colours were not published the extra times, or the hoodie was not left alone after"); + using Items = std::vector; + tick(wearing(red, none), 0); + tick(wearing(red, none), 700); + check(state.colors.size() == 2 && held[0] == item_color(red, 700) && held[1] == held[0] && held[2] == neutral && + held[3] == neutral && published == Items{10, 10}, + "The top's own regions were not coloured, or more than them was"); + // Shoes that colour none of their regions take the colour on all of them. + tick(wearing(red, gold), 800); + check(held[3] == item_color(gold, 800) && held[2] == neutral && held[0] == item_color(red, 800) && state.colors.size() == 3, + "An item with no colours of its own was not coloured, or cosmetics did not each take their own"); + published.clear(); + tick(bare, 900); + check(held[0] == black && held[1] == navy && held[3] == neutral && published == Items{10, 11}, + "Turning it off did not bring the items' own colours back"); + for (std::uint64_t at = 901; at < 911; ++at) tick(bare, at); + check(published.size() == 2 * (1 + settle_publishes) && state.colors.empty() && held[0] == black && held[1] == navy, + "The own colours were not published the extra times, or the items were not left alone after"); // A colour the game itself sets meanwhile is the game's to keep. - tick(Animation::gold, 1000); + tick(wearing(gold, none), 1000); held[0] = teal; - tick(Animation::none, 1100); + tick(bare, 1100); check(held[0] == teal && held[1] == navy, "Turning it off overwrote a colour the game had set"); // And it comes back on with the own colours still known. - for (std::uint64_t at = 1200; at < 1210; ++at) tick(Animation::none, at); - tick(Animation::red, 2000); - tick(Animation::none, 2100); - check(held[0] == teal && held[1] == navy, "A second round lost the hoodie's own colours"); + for (std::uint64_t at = 1200; at < 1210; ++at) tick(bare, at); + tick(wearing(red, none), 2000); + tick(bare, 2100); + check(held[0] == teal && held[1] == navy, "A second round lost the top's own colours"); + for (std::uint64_t at = 2200; at < 2210; ++at) tick(bare, at); - // The board does the same, part by part. + // A colour that stands still is written once, and published until the renderer shows it. + const auto still = item_animation(L::homie, {MarkMode::solid, {0, 255, 0}, {}, 0}); + published.clear(), writes = 0; + for (std::uint64_t at = 3000; at < 3010; ++at) tick(wearing(still, none), at); + check(held[0] == item_color(still, 0) && held[1] == held[0] && writes == 2 && published == Items(1 + settle_publishes, 10), + "A solid colour was written again and again, or not published until it showed"); + // While a recipe is on its way nothing is touched, and what was saved is kept. + live.pending = true; + tick(bare, 4000); + check(held[0] == item_color(still, 0) && state.colors.size() == 2 && writes == 2, "A pending recipe was written to"); + live.pending = false; + tick(bare, 4100); + check(held[0] == teal && held[1] == navy, "The own colours were lost while a recipe was pending"); + + // The board does the same, each of its parts on its own: here the deck and the wheels, whose + // material has no colour of its own until one is added. namespace board = dingosdk::developer_board_detail; - std::array painted{black, navy}; + std::array painted{black, board::shader_default}; dingosdk::DeveloperBoardState deck; - board::Materials parts; - parts.component = 1, parts.appearance = 2, parts.count = 2; - parts.bindings[0] = {30, 10, 100, 7, board::parameter_key(board::base_color, 1), black, true}; - parts.bindings[1] = {31, 11, 101, 7, board::parameter_key(board::base_color, 1), navy, true}; + Materials parts; + parts.component = 1, parts.appearance = 2; + parts.bindings = {Binding{30, 10, 100, board::parameter_key(board::base_color, 1), black, 0}, + Binding{31, 11, 0, board::parameter_key(board::base_color, 1), board::shader_default, 3}}; int shown{}; - auto paint = [&](const board::Binding &binding, const Color &color) { - painted[binding.node - 100] = color; + bool added{}; + auto paint = [&](const Binding &binding, const Color &color) { + if (!binding.node) added = true; // the engine adds the missing parameter + painted[binding.part ? 1 : 0] = color; return true; }; auto show = [&](std::uintptr_t) { ++shown; }; - const auto roll = [&](Animation animation, std::uint64_t at) { - for (std::size_t i = 0; i < painted.size(); ++i) parts.bindings[i].color = painted[i]; - board::animate(deck, parts, 5, 6, animation, at, paint, show); + using Board = std::array; + const auto roll = [&](const ItemAnimation &on_deck, const ItemAnimation &on_wheels, std::uint64_t at) { + parts.bindings[0].color = painted[0], parts.bindings[1].color = painted[1]; + parts.bindings[1].node = added ? 101 : 0; + animate(deck, parts, 5, 6, Board{on_deck, none, none, on_wheels}, at, paint, show); }; - roll(Animation::gold, 0); - roll(Animation::gold, 700); - check(deck.count == 2 && painted[0] == item_color(Animation::gold, 700) && painted[1] == painted[0], "The animation did not colour the board"); + roll(gold, red, 0); + roll(gold, red, 700); + check(deck.colors.size() == 2 && added && painted[0] == item_color(gold, 700) && painted[1] == item_color(red, 700), + "The board's parts did not each take their own animation, or a missing colour was not added"); + // The wheels alone turned off: back to the shaders' default, while the deck goes on. + for (std::uint64_t at = 800; at < 810; ++at) roll(gold, none, at); + check(painted[0] == item_color(gold, 809) && painted[1] == board::shader_default && deck.colors.size() == 1, + "Turning one part off stopped another, or did not stop it"); shown = 0; - for (std::uint64_t at = 800; at < 810; ++at) roll(Animation::none, at); - check(painted[0] == black && painted[1] == navy && shown == 2 * (1 + settle_publishes) && !deck.count, - "Turning it off did not bring the board's own colours back and publish each part the extra times"); + for (std::uint64_t at = 900; at < 910; ++at) roll(none, none, at); + check(painted[0] == black && painted[1] == board::shader_default && shown == 1 + settle_publishes && deck.colors.empty(), + "Turning it off did not bring the board's own colours back and publish them the extra times"); } // --live : the deployed backend, read the way the game reads it. Not diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 42953737..e0895a89 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -35,8 +35,10 @@ bool clear_lobby_guest_objects() { ++simulated_guest_wipes; return true; } namespace dingosdk { bool teleport_local_skater(const std::array&) { return true; } void update_board_lock(std::uintptr_t, std::uintptr_t, bool) noexcept {} -void update_developer_hoodie(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperHoodieState &) noexcept {} -void update_developer_board(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperBoardState &) noexcept {} +void update_developer_hoodie(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperHoodieState &, + const multiplayer::MarkStyles &) noexcept {} +void update_developer_board(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperBoardState &, + const multiplayer::MarkStyles &) noexcept {} } namespace dingosdk::multiplayer { // The backend's lists are not read here: a check lists the players it means. @@ -675,23 +677,43 @@ void role_checks() { "A developer on every list is not shown as a developer"); check(player_role(host, id(third), false) == Role{nametag_creator, "Creator"}, "A content creator who is also a homie is not shown as a creator"); - // A player who has turned their marks off (the Special page) is whatever they are in the lobby, - // to everyone: their appearance carries the choice through the host. + // A player who has turned their tag off (the Special page) is whatever they are in the lobby, + // to everyone: their appearance carries the choice through the host. Their items are a + // separate choice, which leaves the tag alone. auto plain = packet(host, PacketKind::cosmetics, sim.now); plain.appearance = {{skater_recipe_key, 2, {}, {{1, "Outfit0", {}}}}, {board_recipe_key, 1, {}, {{2, "Deck", {}}}}}; - plain.appearance.unmarked = true; + plain.appearance.hide_items = true; + host.cosmetic_packet = encode_wire(plain); + broadcast(host, plain, true, false, sim.now); + sim.run(20); + const auto told = [&](const Session &s) { + const auto *peer = find_peer(const_cast(s), id(host)); + return peer && !shows_items(*peer) && shows_tag(*peer); + }; + check(told(first) && told(second) && player_role(first, id(host), false) == Role{nametag_homie, "Homie"}, + "A homie who turned their items off was not seen to, or lost their tag"); + // A later packet, as the host's game would send after the change. + const auto look = plain.appearance; + plain = packet(host, PacketKind::cosmetics, sim.now); + plain.appearance = look; + plain.appearance.hide_items = false, plain.appearance.hide_tag = true; host.cosmetic_packet = encode_wire(plain); broadcast(host, plain, true, false, sim.now); sim.run(20); check(player_role(first, id(host), false) == Role{nametag_host, "Host"} && player_role(second, id(host), false) == Role{nametag_host, "Host"}, - "A homie who turned their marks off still shows as a homie"); - check(player_role(host, id(first), false) == Role{nametag_creator, "Creator"}, "One player's choice hid another's marks"); - show_own_marks(false); + "A homie who turned their tag off still shows as a homie"); + const auto *shown = find_peer(first, id(host)); + check(shown && shows_items(*shown), "Turning their tag off turned a player's items off"); + check(player_role(host, id(first), false) == Role{nametag_creator, "Creator"}, "One player's choice hid another's tag"); + show_own_items(false); + check(player_role(first, id(first), true) == Role{nametag_creator, "Creator"}, "Turning their items off hid a player's own tag"); + show_own_items(true); + show_own_tag(false); check(player_role(first, id(first), true) == Role{nametag_white, {}} && player_role(host, id(first), false) == Role{nametag_creator, "Creator"}, - "A player's own choice did not hide their marks from themselves, or hid them from others before they were told"); - show_own_marks(true); + "A player's own choice did not hide their tag from themselves, or hid it from others before they were told"); + show_own_tag(true); // A chat line carries its sender's role. check(send_chat(first, "new video is up").empty(), "A guest's chat was refused"); sim.run(20); diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index 32bffce6..223296db 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -331,14 +331,30 @@ void cosmetics_codec() { {board_recipe_key, 1, {0x3f800000}, {{13, "Own_Deck", {7}}}}}; const auto bytes = encode(p); const auto decoded = decode(bytes); - check(decoded && decoded->appearance == p.appearance && !decoded->appearance.unmarked, + check(decoded && decoded->appearance == p.appearance && !decoded->appearance.hide_tag && !decoded->appearance.hide_items, "Cosmetic fields or opaque parameter bits were lost"); - // A player's choice to go without their backend marks travels with their outfit. - auto unmarked = p; - unmarked.appearance.unmarked = true; - const auto told = decode(encode(unmarked)); - check(told && told->appearance.unmarked && told->appearance == unmarked.appearance && !(told->appearance == p.appearance), - "A player's choice to hide their marks was lost"); + // A player's choices to go without their backend tag, or its animated items, travel with + // their outfit, each on its own. + for (const auto &[tag, items] : {std::pair{true, false}, std::pair{false, true}, std::pair{true, true}}) { + auto hidden = p; + hidden.appearance.hide_tag = tag, hidden.appearance.hide_items = items; + const auto told = decode(encode(hidden)); + check(told && told->appearance.hide_tag == tag && told->appearance.hide_items == items && + told->appearance == hidden.appearance && !(told->appearance == p.appearance), + "A player's choice to hide their tag or their items was lost"); + } + // So does how they have each marked cosmetic animate. + auto styled = p; + styled.appearance.marks[0] = {MarkMode::gradient, {1, 2, 3}, {250, 251, 252}, 2}; + styled.appearance.marks[4] = {MarkMode::off, {}, {}, 1}; + styled.appearance.marks.back() = {MarkMode::solid, {9, 8, 7}, {}, 0}; + const auto kept = decode(encode(styled)); + check(kept && kept->appearance == styled.appearance && kept->appearance.marks[0].to[2] == 252 && + kept->appearance.marks.back().mode == MarkMode::solid && kept->appearance.marks.back().from[0] == 9 && + kept->appearance.marks[1] == MarkStyle{} && !(kept->appearance == p.appearance), + "A player's cosmetic styles were lost"); + check(!valid_mark_style({static_cast(4), {}, {}, 0}) && !valid_mark_style({MarkMode::standard, {}, {}, 3}), + "A cosmetic style no menu can make was accepted"); for (std::size_t n = 0; n < bytes.size(); ++n) check(!decode(std::span(bytes).first(n)), "Truncated cosmetics accepted"); auto corrupt = bytes; diff --git a/Extension/Multiplayer/developer_identity.h b/Extension/Multiplayer/developer_identity.h index 183f3583..e03ceb87 100644 --- a/Extension/Multiplayer/developer_identity.h +++ b/Extension/Multiplayer/developer_identity.h @@ -33,11 +33,14 @@ inline std::optional identity_mark(std::uint64_t id) noexcept { if (identity_listed(id, list)) return list; return std::nullopt; } -// The local player's own choice to go without their marks (the menu's Special page, which -// only a listed player gets). Their appearance tells everyone else (Appearance::unmarked). -inline std::atomic own_marks{true}; -inline bool own_marks_shown() noexcept { return own_marks.load(std::memory_order_relaxed); } -inline void show_own_marks(bool shown) noexcept { own_marks.store(shown, std::memory_order_relaxed); } +// The local player's own choices to go without their tag, and without their animated items +// (the menu's Special page, which only a listed player gets). Their appearance tells everyone +// else (Appearance::hide_tag, Appearance::hide_items). +inline std::atomic own_tag{true}, own_items{true}; +inline bool own_tag_shown() noexcept { return own_tag.load(std::memory_order_relaxed); } +inline void show_own_tag(bool shown) noexcept { own_tag.store(shown, std::memory_order_relaxed); } +inline bool own_items_shown() noexcept { return own_items.load(std::memory_order_relaxed); } +inline void show_own_items(bool shown) noexcept { own_items.store(shown, std::memory_order_relaxed); } // A global ban is enforced by whoever a player would play with: a host and a dedicated server // turn them away, and a guest leaves a lobby they host, which their own game does not start // either (session_receive.cpp, Server/server_host.cpp). A server can opt out ("global_bans"), diff --git a/Extension/UI/Overlay/multiplayer_menu.cpp b/Extension/UI/Overlay/multiplayer_menu.cpp index 06f3b5e4..322360c5 100644 --- a/Extension/UI/Overlay/multiplayer_menu.cpp +++ b/Extension/UI/Overlay/multiplayer_menu.cpp @@ -1,8 +1,12 @@ #include "multiplayer_menu_internal.h" +#include "role_badge.h" #include "Engine/Game/World/world_names.h" #include #include +#include +#include #include +#include #include namespace dingosdk::overlay { @@ -267,21 +271,129 @@ void multiplayer_display_settings(SkateMenu &menu, const Model &model) { void multiplayer_network_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks) { debug_page(menu, model.multiplayer, callbacks); } -// SPECIAL: the page only a player on one of the backend's lists gets. Their tag, and the hoodie -// and board that come with it, on or off for everyone at once. +// SPECIAL: the page only a player on one of the backend's lists gets. Their tag as everyone +// sees it, and whether it shows; then the items that come with it, on or off together and +// each on its own: what their list gives it, two colours of their own, or nothing. void special_page(SkateMenu &menu, const Model &model, const CallbacksV3 &) { const auto &mp = model.multiplayer; + std::array unused{}; ImGui::BeginChild("special", ImVec2(0, page_body_height(menu))); - begin_card(menu, "special-marks", "YOUR TAG", mp.identity_tag.c_str()); - bool shown = mp.identity_marks; - if (toggle_row(menu, "Show my tag and items", - "Your tag in chat and on your nametag, and the animated hoodie and board that come with it. " - "Off: you look like any other player, to yourself and to everyone you skate with.", - shown)) { - std::array unused{}; - send_private(menu, "marks", shown ? "on" : "off", unused, false); + begin_card(menu, "special-tag", "YOUR TAG"); + { + // Drawn the way chat and nametags draw it (role_badge.h), and faint while it is off. + field(menu, "Looks like", "Your tag and name, as chat and your nametag show them."); + const std::string name = mp.local_name.empty() ? std::string("You") : mp.local_name; + const float size = px(18), height = ImGui::GetFrameHeight(), alpha = mp.identity_tag_shown ? 1.0f : .3f; + auto *draw = ImGui::GetWindowDrawList(); + const auto at = ImGui::GetCursorScreenPos(); + const float badge = overlay::detail::role_badge_width(menu.bold, size, mp.identity_tag) + px(6); + overlay::detail::draw_role_badge(draw, menu.bold, size, at, height, mp.identity_tag, mp.identity_tag_colour, alpha); + const auto extent = menu.bold->CalcTextSizeA(size, FLT_MAX, 0, name.c_str()); + const ImVec2 name_at(at.x + badge, at.y + (height - extent.y) * .5f); + const int name_vertices = draw->VtxBuffer.Size; + draw->PushClipRect(at, ImVec2(at.x + ImGui::GetContentRegionAvail().x, at.y + height), true); + draw->AddText(menu.bold, size, name_at, + (mp.identity_tag_colour & ~IM_COL32_A_MASK) | (static_cast(255.0f * alpha) << IM_COL32_A_SHIFT), name.c_str()); + draw->PopClipRect(); + overlay::detail::shade_nametag_gradient(draw, name_vertices, name_at.x, extent.x, mp.identity_tag_colour, ImGui::GetTime()); + ImGui::Dummy(ImVec2(std::min(badge + extent.x, ImGui::GetContentRegionAvail().x), height)); } + bool tag = mp.identity_tag_shown; + if (toggle_row(menu, "Show my tag", + "Your tag and the color of your name, in chat and on your nametag. " + "Off: your name looks like any other player's, to yourself and to everyone you skate with.", + tag)) + send_private(menu, "mark-tag", tag ? "on" : "off", unused, false); end_card(); + + begin_card(menu, "special-items", "YOUR ITEMS"); + bool shown = mp.identity_items_shown; + if (toggle_row(menu, "Show my items", + "The colors that come with your tag, on whatever you wear and ride. " + "Off: it all keeps its own colors, for you and for everyone you skate with.", + shown)) + send_private(menu, "mark-items", shown ? "on" : "off", unused, false); + note("Below, each one on its own: it colors whatever you have on in that slot. Everyone you skate with sees what you pick."); + end_card(); + + // The colours being picked: held here while the mouse has them, and after it lets go until + // the saved ones catch up, so a swatch never jumps back to the colour it had. + struct Picking { + std::array from{}, to{}; + bool changed{}; + int waiting{}; // frames + }; + static std::vector picking; + picking.resize(mp.identity_styles.size()); + for (int item = 0; item < static_cast(mp.identity_styles.size()); ++item) { + const auto &style = mp.identity_styles[static_cast(item)]; + auto &picked = picking[static_cast(item)]; + const auto same = [](const std::array &a, const std::array &b) { + return std::abs(a[0] - b[0]) < .003f && std::abs(a[1] - b[1]) < .003f && std::abs(a[2] - b[2]) < .003f; + }; + if (picked.waiting && same(picked.from, style.from) && same(picked.to, style.to)) picked.waiting = 0; + else if (picked.waiting) --picked.waiting; + if (!picked.changed && !picked.waiting) picked.from = style.from, picked.to = style.to; + const auto send = [&](int mode, int speed) { + const auto hex = [](const std::array &colour) { + unsigned value{}; + for (const float part : colour) value = value << 8 | static_cast(std::clamp(part, 0.0f, 1.0f) * 255.0f + 0.5f); + return value; + }; + send_private(menu, "mark-style", std::format("{} {} {:06x} {:06x} {}", item, mode, hex(picked.from), hex(picked.to), speed), + unused, false); + }; + // The card is headed by the cosmetic: whatever is in that slot is what gets coloured. + std::string title = style.name; + for (auto &letter : title) letter = static_cast(std::toupper(static_cast(letter))); + ImGui::PushID(item); + begin_card(menu, title.c_str(), title.c_str()); + // The options in the order shown; a style's mode is 0 what the list gives, 1 off, 2 a + // gradient between the player's two colours, 3 their one colour. + static constexpr std::array modes{0, 2, 3, 1}; + int option = static_cast(std::find(modes.begin(), modes.end(), style.mode) - modes.begin()) % 4; + field(menu, "Color"); + if (choice(menu, "mode", option, {mp.identity_animation.c_str(), "GRADIENT", "SOLID", "OFF"}, shown)) + send(modes[static_cast(option)], style.speed); + if (style.mode == 2 || style.mode == 3) { + const bool gradient = style.mode == 2; + field(menu, gradient ? "Colors" : "Pick", gradient ? "The two colors it moves between. Click one to change it." + : "Click the color to change it."); + ImGui::BeginDisabled(!shown); + constexpr auto flags = ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoLabel | ImGuiColorEditFlags_NoAlpha; + if (ImGui::ColorEdit3("##from", picked.from.data(), flags)) picked.changed = true; + // Beside the swatch, what it gives: the one colour, or all that lies between the two. + ImGui::SameLine(0, px(6)); + const auto at = ImGui::GetCursorScreenPos(); + const float swatch = gradient ? ImGui::GetFrameHeight() + px(6) : 0.0f; + const ImVec2 bar(std::max(px(20), ImGui::GetContentRegionAvail().x - swatch), ImGui::GetFrameHeight()); + const auto solid = [&](const std::array &colour) { + return ImGui::ColorConvertFloat4ToU32(ImVec4(colour[0], colour[1], colour[2], shown ? 1.0f : .4f)); + }; + const auto left = solid(picked.from), right = solid(gradient ? picked.to : picked.from); + ImGui::GetWindowDrawList()->AddRectFilledMultiColor(at, ImVec2(at.x + bar.x, at.y + bar.y), left, right, right, left); + ImGui::Dummy(bar); + if (gradient) { + ImGui::SameLine(0, px(6)); + if (ImGui::ColorEdit3("##to", picked.to.data(), flags)) picked.changed = true; + } + ImGui::EndDisabled(); + // One save when the mouse lets go, not one for every shade dragged through. + if (picked.changed && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + picked.changed = false; + picked.waiting = 120; + send(style.mode, style.speed); + } + } + // A colour that stands still has no speed. + if (style.mode == 0 || style.mode == 2) { + int speed = style.speed; + field(menu, "Speed"); + if (choice(menu, "speed", speed, {"NORMAL", "SLOW", "FAST"}, shown)) send(style.mode, speed); + } + end_card(); + ImGui::PopID(); + } ImGui::EndChild(); } void multiplayer_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks) {