diff --git a/Code/client/PluginAPI.cpp b/Code/client/PluginAPI.cpp new file mode 100644 index 000000000..7929960e4 --- /dev/null +++ b/Code/client/PluginAPI.cpp @@ -0,0 +1,11 @@ +#include + +// Looked up by SKSE plugins with GetProcAddress(GetModuleHandleW(nullptr), "RequestPluginAPI"). +// ExeLoader::Load restores the export directory so the main module still exposes it. +extern "C" __declspec(dllexport) void* RequestPluginAPI(SkyrimTogetherAPI::InterfaceVersion aVersion) +{ + if (aVersion != SkyrimTogetherAPI::InterfaceVersion::V1) + return nullptr; + + return static_cast(&PluginService::Get()); +} diff --git a/Code/client/Services/Generic/PluginService.cpp b/Code/client/Services/Generic/PluginService.cpp new file mode 100644 index 000000000..00c24b718 --- /dev/null +++ b/Code/client/Services/Generic/PluginService.cpp @@ -0,0 +1,170 @@ +#include + +#include + +#include +#include +#include + +using namespace SkyrimTogetherAPI; + +PluginService& PluginService::Get() noexcept +{ + static PluginService s_instance; + return s_instance; +} + +ChannelHandle PluginService::RegisterChannel(const char* apName, MessageCallback aOnMessage, PartyChangedCallback aOnPartyChanged) noexcept +{ + // Reading up to one byte past the limit is enough to tell the name is too long + const String cName = apName ? String(apName, strnlen(apName, kMaxChannelNameSize + 1)) : String(); + if (!ModMessage::IsValidChannelName(cName)) + return 0; + + std::lock_guard cLock(m_lock); + + for (const Channel& cChannel : m_channels) + { + if (cChannel.Name == cName) + return 0; + } + + m_channels.push_back({cName, aOnMessage, aOnPartyChanged}); + return static_cast(m_channels.size()); +} + +SendResult PluginService::Send(ChannelHandle aChannel, const char* apBytes, size_t aLength) noexcept +{ + return Queue(aChannel, 0, apBytes, aLength); +} + +SendResult PluginService::SendTo(ChannelHandle aChannel, uint32_t aPlayerId, const char* apBytes, size_t aLength) noexcept +{ + // 0 means the whole party, so it is not a valid target + if (aPlayerId == 0) + return SendResult::NotInParty; + + return Queue(aChannel, aPlayerId, apBytes, aLength); +} + +uint32_t PluginService::GetLocalPlayerId() const noexcept +{ + if (!entt::locator::has_value() || !World::Get().GetTransport().IsOnline()) + return 0; + + return World::Get().GetTransport().GetLocalPlayerId(); +} + +bool PluginService::IsInParty() const noexcept +{ + return entt::locator::has_value() && World::Get().GetPartyService().IsInParty(); +} + +SendResult PluginService::Queue(ChannelHandle aChannel, uint32_t aTargetPlayerId, const char* apBytes, size_t aLength) noexcept +{ + String channelName; + { + std::lock_guard cLock(m_lock); + + if (aChannel == 0 || aChannel > m_channels.size()) + return SendResult::BadChannel; + + channelName = m_channels[aChannel - 1].Name; + } + + if (aLength > kMaxMessageSize) + return SendResult::TooLarge; + + if (!entt::locator::has_value() || !World::Get().GetTransport().IsOnline()) + return SendResult::NotConnected; + + if (!World::Get().GetPartyService().IsInParty()) + return SendResult::NotInParty; + + if (aTargetPlayerId != 0) + { + std::lock_guard cLock(m_lock); + + if (!std::binary_search(m_members.begin(), m_members.end(), aTargetPlayerId)) + return SendResult::NotInParty; + } + + // The state may have changed by the time the World update gets to it, in which case the message is dropped + World::Get().GetRunner().Queue( + [channelName = std::move(channelName), aTargetPlayerId, data = String(apBytes ? apBytes : "", apBytes ? aLength : 0)]() + { + auto& world = World::Get(); + if (!world.GetTransport().IsOnline() || !world.GetPartyService().IsInParty()) + return; + + SendModMessageRequest request{}; + request.Channel = channelName; + request.TargetPlayerId = aTargetPlayerId; + request.Data = data; + world.GetTransport().Send(request); + }); + + return SendResult::Ok; +} + +void PluginService::OnNotifyModMessage(const NotifyModMessage& acMessage) noexcept +{ + MessageCallback onMessage = nullptr; + { + std::lock_guard cLock(m_lock); + + for (const Channel& cChannel : m_channels) + { + if (cChannel.Name == acMessage.Channel) + { + onMessage = cChannel.OnMessage; + break; + } + } + } + + // No plugin registered that channel on this client: nothing to do + if (onMessage) + onMessage(acMessage.SenderPlayerId, acMessage.Data.data(), acMessage.Data.size()); +} + +void PluginService::NotifyPartyChanges() noexcept +{ + const uint32_t cLocalId = GetLocalPlayerId(); + const auto& cParty = World::Get().GetPartyService(); + + Vector members; + if (cParty.IsInParty()) + { + for (const uint32_t cId : cParty.GetPartyMembers()) + { + if (cId != cLocalId) + members.push_back(cId); + } + } + std::sort(members.begin(), members.end()); + + Vector joined, left; + Vector callbacks; + { + std::lock_guard cLock(m_lock); + + std::set_difference(members.begin(), members.end(), m_members.begin(), m_members.end(), std::back_inserter(joined)); + std::set_difference(m_members.begin(), m_members.end(), members.begin(), members.end(), std::back_inserter(left)); + m_members = members; + + for (const Channel& cChannel : m_channels) + { + if (cChannel.OnPartyChanged) + callbacks.push_back(cChannel.OnPartyChanged); + } + } + + for (const PartyChangedCallback cCallback : callbacks) + { + for (const uint32_t cId : joined) + cCallback(cId, true); + for (const uint32_t cId : left) + cCallback(cId, false); + } +} diff --git a/Code/client/Services/PluginService.h b/Code/client/Services/PluginService.h new file mode 100644 index 000000000..b66b3c74d --- /dev/null +++ b/Code/client/Services/PluginService.h @@ -0,0 +1,57 @@ +#pragma once + +#include + +struct NotifyModMessage; + +/** + * @brief The Plugin API that SKSE plugins use to sync their own state through Skyrim Together. + * + * Not a World service, as plugins may ask for the interface before the World exists. The World forwards + * NotifyModMessage to it. + */ +struct PluginService final : SkyrimTogetherAPI::IVSkyrimTogether1 +{ + [[nodiscard]] static PluginService& Get() noexcept; + + TP_NOCOPYMOVE(PluginService); + + SkyrimTogetherAPI::ChannelHandle + RegisterChannel(const char* apName, SkyrimTogetherAPI::MessageCallback aOnMessage, SkyrimTogetherAPI::PartyChangedCallback aOnPartyChanged) noexcept override; + SkyrimTogetherAPI::SendResult Send(SkyrimTogetherAPI::ChannelHandle aChannel, const char* apBytes, size_t aLength) noexcept override; + SkyrimTogetherAPI::SendResult SendTo(SkyrimTogetherAPI::ChannelHandle aChannel, uint32_t aPlayerId, const char* apBytes, size_t aLength) noexcept override; + uint32_t GetLocalPlayerId() const noexcept override; + bool IsInParty() const noexcept override; + + /** + * @brief Hands a relayed Mod Message to the plugin on its channel, if any. Runs during the World update. + */ + void OnNotifyModMessage(const NotifyModMessage& acMessage) noexcept; + + /** + * @brief Any event after which PartyService's member list may have changed. Runs during the World update. + */ + template void OnPartyEvent(const T&) noexcept { NotifyPartyChanges(); } + +private: + PluginService() = default; + + // Compares the party members with the last ones seen and tells every channel about the difference + void NotifyPartyChanges() noexcept; + + struct Channel + { + String Name; + SkyrimTogetherAPI::MessageCallback OnMessage; + SkyrimTogetherAPI::PartyChangedCallback OnPartyChanged; + }; + + // Validates on the calling thread, then sends during the World update. aTargetPlayerId 0 means the whole party. + SkyrimTogetherAPI::SendResult Queue(SkyrimTogetherAPI::ChannelHandle aChannel, uint32_t aTargetPlayerId, const char* apBytes, size_t aLength) noexcept; + + mutable std::mutex m_lock; + // A handle is the index in here plus one + Vector m_channels; + // The other party members as of the last change, sorted. Lets any thread check a SendTo target. + Vector m_members; +}; diff --git a/Code/client/World.cpp b/Code/client/World.cpp index b0596604b..e7c7a09c8 100644 --- a/Code/client/World.cpp +++ b/Code/client/World.cpp @@ -21,8 +21,15 @@ #include #include #include +#include + +#include +#include +#include +#include #include +#include #include #include @@ -55,6 +62,14 @@ World::World() ctx().emplace(*this, m_transport, m_dispatcher); ctx().emplace(*this, m_dispatcher, m_transport); + // The Plugin API outlives the World, so there is nothing to disconnect + m_dispatcher.sink().connect<&PluginService::OnNotifyModMessage>(PluginService::Get()); + // These come after PartyService's own handlers, so its member list is up to date by then + m_dispatcher.sink().connect<&PluginService::OnPartyEvent>(PluginService::Get()); + m_dispatcher.sink().connect<&PluginService::OnPartyEvent>(PluginService::Get()); + m_dispatcher.sink().connect<&PluginService::OnPartyEvent>(PluginService::Get()); + m_dispatcher.sink().connect<&PluginService::OnPartyEvent>(PluginService::Get()); + BehaviorVar::Get()->Init(); } diff --git a/Code/encoding/Messages/ClientMessageFactory.h b/Code/encoding/Messages/ClientMessageFactory.h index 4f94d6d10..639570e0c 100644 --- a/Code/encoding/Messages/ClientMessageFactory.h +++ b/Code/encoding/Messages/ClientMessageFactory.h @@ -45,6 +45,7 @@ #include #include #include +#include #include #include #include @@ -70,7 +71,7 @@ struct ClientMessageFactory PartyChangeLeaderRequest, PartyKickRequest, RequestActorValueChanges, RequestActorMaxValueChanges, EnterExteriorCellRequest, RequestHealthChangeBroadcast, ActivateRequest, LockChangeRequest, AssignObjectsRequest, RequestDeathStateChange, ShiftGridCellRequest, RequestOwnershipTransfer, RequestOwnershipClaim, RequestObjectInventoryChanges, SpellCastRequest, ProjectileLaunchRequest, InterruptCastRequest, AddTargetRequest, ScriptAnimationRequest, DrawWeaponRequest, MountRequest, NewPackageRequest, RequestRespawn, SyncExperienceRequest, RequestEquipmentChanges, SendChatMessageRequest, TeleportCommandRequest, PlayerRespawnRequest, DialogueRequest, SubtitleRequest, PlayerDialogueRequest, PlayerLevelRequest, TeleportRequest, RequestPlayerHealthUpdate, RequestWeatherChange, RequestCurrentWeather, RequestSetWaypoint, - RequestRemoveWaypoint, RemoveSpellRequest, SetTimeCommandRequest, RequestActionEvents>; + RequestRemoveWaypoint, RemoveSpellRequest, SetTimeCommandRequest, RequestActionEvents, SendModMessageRequest>; return s_visitor(std::forward(func)); } diff --git a/Code/encoding/Messages/ModMessage.h b/Code/encoding/Messages/ModMessage.h new file mode 100644 index 000000000..d58d5ca78 --- /dev/null +++ b/Code/encoding/Messages/ModMessage.h @@ -0,0 +1,44 @@ +#pragma once + +#include "Message.h" + +#include + +// Helpers shared by the two Plugin API messages and the services that handle them. +namespace ModMessage +{ +// 1 to kMaxChannelNameSize bytes of [A-Za-z0-9._-] +inline bool IsValidChannelName(const TiltedPhoques::String& acName) noexcept +{ + if (acName.empty() || acName.size() > SkyrimTogetherAPI::kMaxChannelNameSize) + return false; + + for (const char c : acName) + { + const bool cIsValid = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; + if (!cIsValid) + return false; + } + return true; +} + +inline void WriteBytes(TiltedPhoques::Buffer::Writer& aWriter, const TiltedPhoques::String& acBytes) noexcept +{ + Serialization::WriteVarInt(aWriter, acBytes.size()); + aWriter.WriteBytes(reinterpret_cast(acBytes.data()), acBytes.size()); +} + +// Unlike Serialization::ReadString, a length prefix larger than what is left in the packet is rejected before +// anything is allocated, as these messages come from untrusted clients. Returns false on a malformed length. +inline bool ReadBytes(TiltedPhoques::Buffer::Reader& aReader, TiltedPhoques::String& aBytes) noexcept +{ + aBytes.clear(); + + const uint64_t cLength = Serialization::ReadVarInt(aReader); + if (cLength > aReader.GetBuffer()->GetSize() - aReader.GetBytePosition()) + return false; + + aBytes.resize(cLength); + return aReader.ReadBytes(reinterpret_cast(aBytes.data()), cLength); +} +} // namespace ModMessage diff --git a/Code/encoding/Messages/NotifyModMessage.cpp b/Code/encoding/Messages/NotifyModMessage.cpp new file mode 100644 index 000000000..168c811bc --- /dev/null +++ b/Code/encoding/Messages/NotifyModMessage.cpp @@ -0,0 +1,21 @@ +#include +#include + +void NotifyModMessage::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + ModMessage::WriteBytes(aWriter, Channel); + Serialization::WriteVarInt(aWriter, SenderPlayerId); + ModMessage::WriteBytes(aWriter, Data); +} + +void NotifyModMessage::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + // A malformed message gets an empty channel, which no plugin has registered + if (!ModMessage::ReadBytes(aReader, Channel)) + Channel.clear(); + SenderPlayerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + if (!ModMessage::ReadBytes(aReader, Data)) + Channel.clear(); +} diff --git a/Code/encoding/Messages/NotifyModMessage.h b/Code/encoding/Messages/NotifyModMessage.h new file mode 100644 index 000000000..ff3de65ae --- /dev/null +++ b/Code/encoding/Messages/NotifyModMessage.h @@ -0,0 +1,30 @@ +#pragma once + +#include "Message.h" + +/** + * @brief A Mod Message relayed to a plugin. The sender is stamped by the server, never taken from the payload. + */ +struct NotifyModMessage final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kNotifyModMessage; + + NotifyModMessage() + : ServerMessage(Opcode) + { + } + + virtual ~NotifyModMessage() = default; + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const NotifyModMessage& acRhs) const noexcept + { + return GetOpcode() == acRhs.GetOpcode() && Channel == acRhs.Channel && SenderPlayerId == acRhs.SenderPlayerId && Data == acRhs.Data; + } + + TiltedPhoques::String Channel; + uint32_t SenderPlayerId{0}; + TiltedPhoques::String Data; +}; diff --git a/Code/encoding/Messages/SendModMessageRequest.cpp b/Code/encoding/Messages/SendModMessageRequest.cpp new file mode 100644 index 000000000..03c39b6a0 --- /dev/null +++ b/Code/encoding/Messages/SendModMessageRequest.cpp @@ -0,0 +1,21 @@ +#include +#include + +void SendModMessageRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + ModMessage::WriteBytes(aWriter, Channel); + Serialization::WriteVarInt(aWriter, TargetPlayerId); + ModMessage::WriteBytes(aWriter, Data); +} + +void SendModMessageRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + // A malformed message gets an empty channel, which every handler rejects + if (!ModMessage::ReadBytes(aReader, Channel)) + Channel.clear(); + TargetPlayerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + if (!ModMessage::ReadBytes(aReader, Data)) + Channel.clear(); +} diff --git a/Code/encoding/Messages/SendModMessageRequest.h b/Code/encoding/Messages/SendModMessageRequest.h new file mode 100644 index 000000000..0f391ff9f --- /dev/null +++ b/Code/encoding/Messages/SendModMessageRequest.h @@ -0,0 +1,31 @@ +#pragma once + +#include "Message.h" + +/** + * @brief A Mod Message from a plugin, for the sender's party or one party member. Opaque to Skyrim Together. + */ +struct SendModMessageRequest final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kSendModMessageRequest; + + SendModMessageRequest() + : ClientMessage(Opcode) + { + } + + virtual ~SendModMessageRequest() = default; + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const SendModMessageRequest& acRhs) const noexcept + { + return GetOpcode() == acRhs.GetOpcode() && Channel == acRhs.Channel && TargetPlayerId == acRhs.TargetPlayerId && Data == acRhs.Data; + } + + TiltedPhoques::String Channel; + // 0 sends to the whole party + uint32_t TargetPlayerId{0}; + TiltedPhoques::String Data; +}; diff --git a/Code/encoding/Messages/ServerMessageFactory.h b/Code/encoding/Messages/ServerMessageFactory.h index 516878b1d..8f3b1b8dd 100644 --- a/Code/encoding/Messages/ServerMessageFactory.h +++ b/Code/encoding/Messages/ServerMessageFactory.h @@ -59,6 +59,7 @@ #include #include #include +#include using TiltedPhoques::UniquePtr; @@ -73,7 +74,7 @@ struct ServerMessageFactory NotifyActorValueChanges, NotifyPartyJoined, NotifyPartyLeft, NotifyActorMaxValueChanges, NotifyHealthChangeBroadcast, NotifyActivate, NotifyLockChange, AssignObjectsResponse, NotifyDeathStateChange, NotifyOwnershipTransfer, NotifyObjectInventoryChanges, NotifySpellCast, NotifyProjectileLaunch, NotifyInterruptCast, NotifyAddTarget, NotifyScriptAnimation, NotifyDrawWeapon, NotifyMount, NotifyNewPackage, NotifyRespawn, NotifySyncExperience, NotifyEquipmentChanges, NotifyChatMessageBroadcast, TeleportCommandResponse, NotifyPlayerRespawn, NotifyDialogue, NotifySubtitle, NotifyPlayerDialogue, NotifyActorTeleport, NotifyPlayerLeft, NotifyPlayerJoined, NotifyDialogue, NotifySubtitle, NotifyPlayerDialogue, NotifyPlayerLevel, NotifyPlayerCellChanged, NotifyTeleport, NotifyPlayerHealthUpdate, NotifySettingsChange, - NotifyWeatherChange, NotifySetWaypoint, NotifyRemoveWaypoint, NotifySetTimeResult, NotifyRemoveSpell, NotifyActionEvents>; + NotifyWeatherChange, NotifySetWaypoint, NotifyRemoveWaypoint, NotifySetTimeResult, NotifyRemoveSpell, NotifyActionEvents, NotifyModMessage>; return s_visitor(std::forward(func)); } diff --git a/Code/encoding/Opcodes.h b/Code/encoding/Opcodes.h index baab493cc..8326bbaf5 100644 --- a/Code/encoding/Opcodes.h +++ b/Code/encoding/Opcodes.h @@ -54,6 +54,7 @@ enum ClientOpcode : unsigned char kRequestRemoveWaypoint, kSetTimeCommandRequest, kRequestActionEvents, + kSendModMessageRequest, kClientOpcodeMax }; @@ -114,5 +115,6 @@ enum ServerOpcode : unsigned char kNotifyRemoveWaypoint, kNotifySetTimeResult, kNotifyActionEvents, + kNotifyModMessage, kServerOpcodeMax }; diff --git a/Code/encoding/xmake.lua b/Code/encoding/xmake.lua index dff8d9b0c..bc7326945 100644 --- a/Code/encoding/xmake.lua +++ b/Code/encoding/xmake.lua @@ -2,7 +2,7 @@ local function build_encoding(name) target(name) set_kind("static") set_group("common") - add_includedirs(".", "../", {public = true}) + add_includedirs(".", "../", "../plugin_api/include", {public = true}) add_headerfiles("**.h|Structs/Skyrim/**", {prefixdir = "Encoding"}) add_files("**.cpp|Structs/Skyrim/**") set_pcxxheader("EncodingPch.h") diff --git a/Code/immersive_launcher/loader/ExeLoader.cpp b/Code/immersive_launcher/loader/ExeLoader.cpp index 31e89d146..66cc922a3 100644 --- a/Code/immersive_launcher/loader/ExeLoader.cpp +++ b/Code/immersive_launcher/loader/ExeLoader.cpp @@ -300,6 +300,8 @@ bool ExeLoader::Load(const uint8_t* apProgramBuffer) auto sourceChecksum = sourceNtHeader->OptionalHeader.CheckSum; auto sourceTimestamp = sourceNtHeader->FileHeader.TimeDateStamp; auto sourceDebugDir = sourceNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG]; + // SkyrimSE.exe has no exports, but SKSE plugins need to find RequestPluginAPI in the main module. + auto sourceExportDir = sourceNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; LoadSections(ntHeader); @@ -327,6 +329,7 @@ bool ExeLoader::Load(const uint8_t* apProgramBuffer) sourceNtHeader->OptionalHeader.CheckSum = sourceChecksum; sourceNtHeader->FileHeader.TimeDateStamp = sourceTimestamp; sourceNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG] = sourceDebugDir; + sourceNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT] = sourceExportDir; m_pBinary = nullptr; // Set a hook to check if anything loaded messes with critical hooks. diff --git a/Code/plugin_api/include/SkyrimTogetherAPI.h b/Code/plugin_api/include/SkyrimTogetherAPI.h new file mode 100644 index 000000000..f05ed464d --- /dev/null +++ b/Code/plugin_api/include/SkyrimTogetherAPI.h @@ -0,0 +1,72 @@ +#pragma once + +// Skyrim Together Plugin API, version 1. +// +// Lets an SKSE plugin sync its own state through Skyrim Together: the plugin registers a Channel and sends small +// Mod Messages on it, Skyrim Together relays them to the sender's party without interpreting them, and the same +// plugin on the other clients receives them. This header is self-contained, copy it into your plugin. +// +// Usage: +// auto pRequest = reinterpret_cast( +// GetProcAddress(GetModuleHandleW(nullptr), "RequestPluginAPI")); +// // nullptr: not running under Skyrim Together, stay inactive. +// auto* pApi = static_cast(pRequest(SkyrimTogetherAPI::InterfaceVersion::V1)); + +#include +#include + +namespace SkyrimTogetherAPI +{ +enum class InterfaceVersion : uint8_t +{ + V1 = 1 +}; + +enum class SendResult : uint8_t +{ + Ok, // Queued for sending. Delivery is not guaranteed. + NotConnected, // Not connected to a server + NotInParty, // Not in a party, or the target is not in the local party + TooLarge, // Payload is longer than kMaxMessageSize + BadChannel // Not a handle returned by RegisterChannel +}; + +// A Mod Message is opaque bytes counted by length. It need not be text or null-terminated. +constexpr size_t kMaxMessageSize = 256; +// Channel names are 1 to kMaxChannelNameSize bytes of [A-Za-z0-9._-]. +constexpr size_t kMaxChannelNameSize = 64; + +using ChannelHandle = uint32_t; // 0 is invalid + +// Both callbacks run during Skyrim Together's per-frame update, where its own sync code runs. That is not +// necessarily the game's main thread, and the thread can differ between calls. To change game state from the +// main thread, hand the work over with SKSE's task interface (SKSE::GetTaskInterface()->AddTask). + +// aSenderId is the player ID the server saw the message come from. +using MessageCallback = void (*)(uint32_t aSenderId, const char* apBytes, size_t aLength); +// A player joined (aJoined) or left the local player's party. +using PartyChangedCallback = void (*)(uint32_t aPlayerId, bool aJoined); + +class IVSkyrimTogether1 +{ +public: + virtual ~IVSkyrimTogether1() = default; + + // Returns 0 if the name is invalid or already registered. Channels live until the process exits. + // Either callback may be null. + virtual ChannelHandle RegisterChannel(const char* apName, MessageCallback aOnMessage, PartyChangedCallback aOnPartyChanged) noexcept = 0; + + // Sends to the local player's party, sender excluded. Callable from any thread. + virtual SendResult Send(ChannelHandle aChannel, const char* apBytes, size_t aLength) noexcept = 0; + + // Sends to one member of the local player's party. Callable from any thread. + virtual SendResult SendTo(ChannelHandle aChannel, uint32_t aPlayerId, const char* apBytes, size_t aLength) noexcept = 0; + + // 0 while not connected. + virtual uint32_t GetLocalPlayerId() const noexcept = 0; + virtual bool IsInParty() const noexcept = 0; +}; + +// Exported by SkyrimTogether.exe. Returns nullptr for a version it does not know. +using RequestPluginAPI_t = void* (*)(InterfaceVersion aVersion); +} // namespace SkyrimTogetherAPI diff --git a/Code/server/Services/ModMessageService.cpp b/Code/server/Services/ModMessageService.cpp new file mode 100644 index 000000000..97ee8efcb --- /dev/null +++ b/Code/server/Services/ModMessageService.cpp @@ -0,0 +1,92 @@ +#include +#include + +#include +#include +#include + +#include +#include +#include + +namespace +{ +constexpr double kMessagesPerSecond = 20.0; +constexpr double kBurstSize = 40.0; +} // namespace + +ModMessageService::ModMessageService(World& aWorld, entt::dispatcher& aDispatcher) noexcept + : m_world(aWorld) +{ + m_sendModMessageConnection = aDispatcher.sink>().connect<&ModMessageService::OnSendModMessage>(this); + m_playerLeaveConnection = aDispatcher.sink().connect<&ModMessageService::OnPlayerLeave>(this); +} + +void ModMessageService::OnSendModMessage(const PacketEvent& acMessage) noexcept +{ + const auto& cRequest = acMessage.Packet; + + // Clients are not trusted: a modded one can send anything, so drop whatever the Plugin API would not have sent. + // The rate limit comes first so that malformed messages count against it too. + if (!TryConsume(*acMessage.pPlayer)) + return; + + if (!ModMessage::IsValidChannelName(cRequest.Channel) || cRequest.Data.size() > SkyrimTogetherAPI::kMaxMessageSize) + return; + + const auto& cParty = acMessage.pPlayer->GetParty(); + if (!cParty.JoinedPartyId.has_value()) + return; + + NotifyModMessage notify{}; + notify.Channel = cRequest.Channel; + notify.SenderPlayerId = acMessage.pPlayer->GetId(); + notify.Data = cRequest.Data; + + if (cRequest.TargetPlayerId == 0) + { + GameServer::Get()->SendToParty(notify, cParty, acMessage.GetSender()); + return; + } + + // The target must be in the sender's own party, whatever the client claims + const Player* pTarget = m_world.GetPlayerManager().GetById(cRequest.TargetPlayerId); + if (!pTarget || pTarget == acMessage.pPlayer || pTarget->GetParty().JoinedPartyId != cParty.JoinedPartyId) + return; + + pTarget->Send(notify); +} + +void ModMessageService::OnPlayerLeave(const PlayerLeaveEvent& acEvent) noexcept +{ + m_buckets.erase(acEvent.pPlayer->GetId()); +} + +bool ModMessageService::TryConsume(const Player& acPlayer) noexcept +{ + const auto cNow = std::chrono::steady_clock::now(); + auto [itor, cIsNew] = m_buckets.try_emplace(acPlayer.GetId(), Bucket{kBurstSize, cNow}); + Bucket& bucket = itor.value(); + + if (!cIsNew) + { + const double cElapsed = std::chrono::duration(cNow - bucket.LastRefill).count(); + bucket.Tokens = std::min(kBurstSize, bucket.Tokens + cElapsed * kMessagesPerSecond); + bucket.LastRefill = cNow; + } + + if (bucket.Tokens >= 1.0) + { + bucket.Tokens -= 1.0; + bucket.Dropping = false; + return true; + } + + // Log once per burst, not once per dropped message + if (!bucket.Dropping) + { + bucket.Dropping = true; + spdlog::warn("[ModMessageService]: Player {} ('{}') is sending Mod Messages too fast, dropping the excess", acPlayer.GetId(), acPlayer.GetUsername()); + } + return false; +} diff --git a/Code/server/Services/ModMessageService.h b/Code/server/Services/ModMessageService.h new file mode 100644 index 000000000..db311eb7b --- /dev/null +++ b/Code/server/Services/ModMessageService.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +struct World; +struct Player; +struct SendModMessageRequest; +struct PlayerLeaveEvent; + +/** + * @brief Relays Mod Messages sent through the Plugin API. The payload is never interpreted, only length-checked. + */ +struct ModMessageService +{ + ModMessageService(World& aWorld, entt::dispatcher& aDispatcher) noexcept; + ~ModMessageService() noexcept = default; + + TP_NOCOPYMOVE(ModMessageService); + +protected: + void OnSendModMessage(const PacketEvent& acMessage) noexcept; + void OnPlayerLeave(const PlayerLeaveEvent& acEvent) noexcept; + +private: + // A token bucket per player: refills at a steady rate up to a burst size + struct Bucket + { + double Tokens; + std::chrono::steady_clock::time_point LastRefill; + bool Dropping = false; + }; + + // False when the player is over the rate limit, in which case the message must be dropped + [[nodiscard]] bool TryConsume(const Player& acPlayer) noexcept; + + World& m_world; + Map m_buckets; + + entt::scoped_connection m_sendModMessageConnection; + entt::scoped_connection m_playerLeaveConnection; +}; diff --git a/Code/server/World.cpp b/Code/server/World.cpp index 8bfd51049..ac92f992a 100644 --- a/Code/server/World.cpp +++ b/Code/server/World.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include @@ -37,6 +38,7 @@ World::World() ctx().emplace(*this, m_dispatcher); ctx().emplace(*this, m_dispatcher); ctx().emplace(*this, m_dispatcher); + ctx().emplace(*this, m_dispatcher); ESLoader::ESLoader loader; // emplace loaded mods into modscomponent. diff --git a/Code/tests/encoding.cpp b/Code/tests/encoding.cpp index defb80841..fe47fc44e 100644 --- a/Code/tests/encoding.cpp +++ b/Code/tests/encoding.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #include @@ -529,6 +530,76 @@ TEST_CASE("Packets", "[encoding.packets]") } } +TEST_CASE("Mod messages", "[encoding.mod_message]") +{ + String maxPayload(SkyrimTogetherAPI::kMaxMessageSize, 'p'); + maxPayload[10] = '\0'; // payloads are opaque bytes, not text + + // Empty payload, 256-byte payload, 64-byte channel name + const auto [channel, payload] = GENERATE_COPY(std::make_pair(String("a"), String()), std::make_pair(String("campfire"), maxPayload), std::make_pair(String(SkyrimTogetherAPI::kMaxChannelNameSize, 'c'), String("hi"))); + + SECTION("SendModMessageRequest") + { + SendModMessageRequest sendMessage; + sendMessage.Channel = channel; + sendMessage.TargetPlayerId = 7; + sendMessage.Data = payload; + + Buffer buff(1000); + Buffer::Writer writer(&buff); + sendMessage.Serialize(writer); + + Buffer::Reader reader(&buff); + auto pMessage = ClientMessageFactory{}.Extract(reader); + + REQUIRE(pMessage); + REQUIRE(*CastUnique(std::move(pMessage)) == sendMessage); + } + + SECTION("NotifyModMessage") + { + NotifyModMessage sendMessage; + sendMessage.Channel = channel; + sendMessage.SenderPlayerId = 3; + sendMessage.Data = payload; + + Buffer buff(1000); + Buffer::Writer writer(&buff); + sendMessage.Serialize(writer); + + Buffer::Reader reader(&buff); + auto pMessage = ServerMessageFactory{}.Extract(reader); + + REQUIRE(pMessage); + REQUIRE(*CastUnique(std::move(pMessage)) == sendMessage); + } +} + +TEST_CASE("Mod message with a bogus length", "[encoding.mod_message]") +{ + // A length prefix far larger than the packet must not be allocated, the message just comes out empty + Buffer buff(1000); + Buffer::Writer writer(&buff); + Serialization::WriteVarInt(writer, 0xFFFFFFFFFFull); + + Buffer::Reader reader(&buff); + SendModMessageRequest request; + request.DeserializeRaw(reader); + + REQUIRE(request.Channel.empty()); + REQUIRE(request.Data.empty()); +} + +TEST_CASE("Mod message channel names", "[encoding.mod_message]") +{ + REQUIRE(ModMessage::IsValidChannelName("Campfire_Sync-1.0")); + REQUIRE(ModMessage::IsValidChannelName(String(SkyrimTogetherAPI::kMaxChannelNameSize, 'a'))); + REQUIRE_FALSE(ModMessage::IsValidChannelName("")); + REQUIRE_FALSE(ModMessage::IsValidChannelName(String(SkyrimTogetherAPI::kMaxChannelNameSize + 1, 'a'))); + REQUIRE_FALSE(ModMessage::IsValidChannelName("has space")); + REQUIRE_FALSE(ModMessage::IsValidChannelName(String("nul\0", 4))); +} + TEST_CASE("StringCache", "[encoding.string_cache]") { SECTION("Messages")