From 90719bea73a1f720ab68d0c0f2a98a2f01acd711 Mon Sep 17 00:00:00 2001 From: DJLegends Date: Sat, 17 Oct 2026 15:11:13 -0500 Subject: [PATCH 1/8] Restore the launcher's export directory so plugins can find RequestPluginAPI ExeLoader::Load copies SkyrimSE.exe's PE headers over the launcher's, and SkyrimSE.exe has no export directory, so GetProcAddress on the main module could never see anything the launcher exports. Save the launcher's export directory entry before the copy and put it back afterwards, like the checksum, timestamp and debug directory. Add an exported RequestPluginAPI that returns nullptr for now, so SKSE plugins have a stable symbol to look up while the Plugin API is built out. Co-Authored-By: Claude Sonnet 5.5 --- Code/client/PluginAPI.cpp | 17 +++++++++++++++++ Code/immersive_launcher/loader/ExeLoader.cpp | 3 +++ 2 files changed, 20 insertions(+) create mode 100644 Code/client/PluginAPI.cpp diff --git a/Code/client/PluginAPI.cpp b/Code/client/PluginAPI.cpp new file mode 100644 index 000000000..e3898feb8 --- /dev/null +++ b/Code/client/PluginAPI.cpp @@ -0,0 +1,17 @@ +#include + +namespace SkyrimTogetherAPI +{ +enum class InterfaceVersion : uint8_t +{ + V1 = 1 +}; +} // namespace SkyrimTogetherAPI + +// 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) +{ + (void)aVersion; + return nullptr; +} 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. From c865c4ce1d38dd3938cc8320244af08a96427338 Mon Sep 17 00:00:00 2001 From: DJLegends Date: Sat, 17 Oct 2026 15:27:59 -0500 Subject: [PATCH 2/8] Add the Mod Message protocol for the Plugin API Plugins will relay opaque Mod Messages through the server, so the client and server need a request and a notify to carry the channel, target and bytes. The length prefixes are checked against the packet before allocating, since these messages come from untrusted clients. The public header lives in Code/plugin_api and is shared with the encoding library so the size limits have a single definition. Co-Authored-By: Claude Sonnet 5.5 --- Code/encoding/Messages/ClientMessageFactory.h | 3 +- Code/encoding/Messages/ModMessage.h | 44 ++++++++++++ Code/encoding/Messages/NotifyModMessage.cpp | 21 ++++++ Code/encoding/Messages/NotifyModMessage.h | 30 ++++++++ .../Messages/SendModMessageRequest.cpp | 21 ++++++ .../encoding/Messages/SendModMessageRequest.h | 31 ++++++++ Code/encoding/Messages/ServerMessageFactory.h | 3 +- Code/encoding/Opcodes.h | 2 + Code/encoding/xmake.lua | 2 +- Code/plugin_api/include/SkyrimTogetherAPI.h | 68 ++++++++++++++++++ Code/tests/encoding.cpp | 71 +++++++++++++++++++ 11 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 Code/encoding/Messages/ModMessage.h create mode 100644 Code/encoding/Messages/NotifyModMessage.cpp create mode 100644 Code/encoding/Messages/NotifyModMessage.h create mode 100644 Code/encoding/Messages/SendModMessageRequest.cpp create mode 100644 Code/encoding/Messages/SendModMessageRequest.h create mode 100644 Code/plugin_api/include/SkyrimTogetherAPI.h 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/plugin_api/include/SkyrimTogetherAPI.h b/Code/plugin_api/include/SkyrimTogetherAPI.h new file mode 100644 index 000000000..663911dac --- /dev/null +++ b/Code/plugin_api/include/SkyrimTogetherAPI.h @@ -0,0 +1,68 @@ +#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 + +// Called on the game's main thread. aSenderId is the player ID the server saw the message come from. +using MessageCallback = void (*)(uint32_t aSenderId, const char* apBytes, size_t aLength); +// Called on the game's main thread when a player joins (aJoined) or leaves 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/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") From 68b7ba786b714e6f50288c16fd15bca83e7ad197 Mon Sep 17 00:00:00 2001 From: DJLegends Date: Sat, 17 Oct 2026 15:27:59 -0500 Subject: [PATCH 3/8] Relay plugin Mod Messages to the party The server re-validates every Mod Message regardless of the client: channel format, payload size and party membership. The sender is taken from the connection, never from the payload, and is excluded from the relay. The rate limit and single-target sends are left for follow-ups. Co-Authored-By: Claude Sonnet 5.5 --- Code/server/Services/ModMessageService.cpp | 39 ++++++++++++++++++++++ Code/server/Services/ModMessageService.h | 25 ++++++++++++++ Code/server/World.cpp | 2 ++ 3 files changed, 66 insertions(+) create mode 100644 Code/server/Services/ModMessageService.cpp create mode 100644 Code/server/Services/ModMessageService.h diff --git a/Code/server/Services/ModMessageService.cpp b/Code/server/Services/ModMessageService.cpp new file mode 100644 index 000000000..2771f605c --- /dev/null +++ b/Code/server/Services/ModMessageService.cpp @@ -0,0 +1,39 @@ +#include +#include + +#include +#include +#include + +#include + +ModMessageService::ModMessageService(World& aWorld, entt::dispatcher& aDispatcher) noexcept + : m_world(aWorld) +{ + m_sendModMessageConnection = aDispatcher.sink>().connect<&ModMessageService::OnSendModMessage>(this); +} + +void ModMessageService::OnSendModMessage(const PacketEvent& acMessage) const 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 per-player rate limit goes here too. + if (!ModMessage::IsValidChannelName(cRequest.Channel) || cRequest.Data.size() > SkyrimTogetherAPI::kMaxMessageSize) + return; + + const auto& cParty = acMessage.pPlayer->GetParty(); + if (!cParty.JoinedPartyId.has_value()) + return; + + // Sending to a single party member is not supported yet + if (cRequest.TargetPlayerId != 0) + return; + + NotifyModMessage notify{}; + notify.Channel = cRequest.Channel; + notify.SenderPlayerId = acMessage.pPlayer->GetId(); + notify.Data = cRequest.Data; + + GameServer::Get()->SendToParty(notify, cParty, acMessage.GetSender()); +} diff --git a/Code/server/Services/ModMessageService.h b/Code/server/Services/ModMessageService.h new file mode 100644 index 000000000..e439558d9 --- /dev/null +++ b/Code/server/Services/ModMessageService.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +struct World; +struct SendModMessageRequest; + +/** + * @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) const noexcept; + +private: + World& m_world; + + entt::scoped_connection m_sendModMessageConnection; +}; 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. From 60c62052df4525475c1d2212ad425befb0c8863e Mon Sep 17 00:00:00 2001 From: DJLegends Date: Sat, 17 Oct 2026 15:27:59 -0500 Subject: [PATCH 4/8] Let SKSE plugins send and receive Mod Messages RequestPluginAPI now returns the real interface. Plugins register a channel, and Send validates on the calling thread before queuing the actual send onto the main thread. Received messages are handed to the plugin on the same channel on the main thread and dropped silently if no plugin registered it. The interface does not live in the World so plugins can ask for it before the World exists. Co-Authored-By: Claude Sonnet 5.5 --- Code/client/PluginAPI.cpp | 16 +-- .../client/Services/Generic/PluginService.cpp | 118 ++++++++++++++++++ Code/client/Services/PluginService.h | 47 +++++++ Code/client/World.cpp | 6 + 4 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 Code/client/Services/Generic/PluginService.cpp create mode 100644 Code/client/Services/PluginService.h diff --git a/Code/client/PluginAPI.cpp b/Code/client/PluginAPI.cpp index e3898feb8..7929960e4 100644 --- a/Code/client/PluginAPI.cpp +++ b/Code/client/PluginAPI.cpp @@ -1,17 +1,11 @@ -#include - -namespace SkyrimTogetherAPI -{ -enum class InterfaceVersion : uint8_t -{ - V1 = 1 -}; -} // namespace SkyrimTogetherAPI +#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) { - (void)aVersion; - return nullptr; + 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..4003cf5e2 --- /dev/null +++ b/Code/client/Services/Generic/PluginService.cpp @@ -0,0 +1,118 @@ +#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 +{ + // Not supported yet + return SendResult::NotInParty; +} + +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; + + // The state may have changed by the time the main thread 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()); +} diff --git a/Code/client/Services/PluginService.h b/Code/client/Services/PluginService.h new file mode 100644 index 000000000..20bd887b3 --- /dev/null +++ b/Code/client/Services/PluginService.h @@ -0,0 +1,47 @@ +#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 on the main thread. + */ + void OnNotifyModMessage(const NotifyModMessage& acMessage) noexcept; + +private: + PluginService() = default; + + struct Channel + { + String Name; + SkyrimTogetherAPI::MessageCallback OnMessage; + SkyrimTogetherAPI::PartyChangedCallback OnPartyChanged; + }; + + // Validates on the calling thread, then sends from the main thread. 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; +}; diff --git a/Code/client/World.cpp b/Code/client/World.cpp index b0596604b..c130bb79e 100644 --- a/Code/client/World.cpp +++ b/Code/client/World.cpp @@ -21,6 +21,9 @@ #include #include #include +#include + +#include #include #include @@ -55,6 +58,9 @@ 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()); + BehaviorVar::Get()->Init(); } From 49e90fdde7f125a5dcadb291eb45a20d650536c8 Mon Sep 17 00:00:00 2001 From: DJLegends Date: Sat, 17 Oct 2026 15:32:58 -0500 Subject: [PATCH 5/8] Let plugins send a Mod Message to one party member The client refuses targets outside its party and the server checks again that the target is in the sender's own party before relaying, so a client that skips its checks can't reach other parties. Co-Authored-By: Claude Sonnet 5.5 --- Code/client/Services/Generic/PluginService.cpp | 17 ++++++++++++++--- Code/server/Services/ModMessageService.cpp | 18 +++++++++++++----- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/Code/client/Services/Generic/PluginService.cpp b/Code/client/Services/Generic/PluginService.cpp index 4003cf5e2..78293e520 100644 --- a/Code/client/Services/Generic/PluginService.cpp +++ b/Code/client/Services/Generic/PluginService.cpp @@ -40,8 +40,11 @@ SendResult PluginService::Send(ChannelHandle aChannel, const char* apBytes, size SendResult PluginService::SendTo(ChannelHandle aChannel, uint32_t aPlayerId, const char* apBytes, size_t aLength) noexcept { - // Not supported yet - return SendResult::NotInParty; + // 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 @@ -75,9 +78,17 @@ SendResult PluginService::Queue(ChannelHandle aChannel, uint32_t aTargetPlayerId if (!entt::locator::has_value() || !World::Get().GetTransport().IsOnline()) return SendResult::NotConnected; - if (!World::Get().GetPartyService().IsInParty()) + const auto& cPartyService = World::Get().GetPartyService(); + if (!cPartyService.IsInParty()) return SendResult::NotInParty; + if (aTargetPlayerId != 0) + { + const auto& cMembers = cPartyService.GetPartyMembers(); + if (std::find(cMembers.begin(), cMembers.end(), aTargetPlayerId) == cMembers.end() || aTargetPlayerId == World::Get().GetTransport().GetLocalPlayerId()) + return SendResult::NotInParty; + } + // The state may have changed by the time the main thread 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)]() diff --git a/Code/server/Services/ModMessageService.cpp b/Code/server/Services/ModMessageService.cpp index 2771f605c..0eed4e9e9 100644 --- a/Code/server/Services/ModMessageService.cpp +++ b/Code/server/Services/ModMessageService.cpp @@ -6,6 +6,7 @@ #include #include +#include ModMessageService::ModMessageService(World& aWorld, entt::dispatcher& aDispatcher) noexcept : m_world(aWorld) @@ -26,14 +27,21 @@ void ModMessageService::OnSendModMessage(const PacketEventGetId(); notify.Data = cRequest.Data; - GameServer::Get()->SendToParty(notify, cParty, acMessage.GetSender()); + 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); } From 9b50009a0f72664210a60be23b1c3942c1cd6f71 Mon Sep 17 00:00:00 2001 From: DJLegends Date: Sat, 17 Oct 2026 15:35:30 -0500 Subject: [PATCH 6/8] Rate limit Mod Messages per player on the server A modded client can skip the Plugin API's own checks and flood the party. Each player gets a token bucket (20 per second, burst of 40); excess messages are dropped without feedback and logged once per burst. Co-Authored-By: Claude Sonnet 5.5 --- Code/server/Services/ModMessageService.cpp | 49 +++++++++++++++++++++- Code/server/Services/ModMessageService.h | 18 +++++++- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/Code/server/Services/ModMessageService.cpp b/Code/server/Services/ModMessageService.cpp index 0eed4e9e9..97ee8efcb 100644 --- a/Code/server/Services/ModMessageService.cpp +++ b/Code/server/Services/ModMessageService.cpp @@ -5,21 +5,32 @@ #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) const noexcept +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 per-player rate limit goes here too. + // 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; @@ -45,3 +56,37 @@ void ModMessageService::OnSendModMessage(const PacketEventSend(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 index e439558d9..db311eb7b 100644 --- a/Code/server/Services/ModMessageService.h +++ b/Code/server/Services/ModMessageService.h @@ -3,7 +3,9 @@ #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. @@ -16,10 +18,24 @@ struct ModMessageService TP_NOCOPYMOVE(ModMessageService); protected: - void OnSendModMessage(const PacketEvent& acMessage) const noexcept; + 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; }; From 8b05deee4c181f15f450b5ea79a8b9eb7b19a1ef Mon Sep 17 00:00:00 2001 From: DJLegends Date: Sat, 17 Oct 2026 15:37:37 -0500 Subject: [PATCH 7/8] Tell plugins when party members join or leave Plugins resend their state to late joiners, so they need to know who came and went. The client diffs PartyService's member list after each party event and calls every channel's PartyChangedCallback on the main thread, symmetrically for both sides. SendTo checks its target against the same snapshot so it never reads PartyService from another thread. Co-Authored-By: Claude Sonnet 5.5 --- .../client/Services/Generic/PluginService.cpp | 49 +++++++++++++++++-- Code/client/Services/PluginService.h | 10 ++++ Code/client/World.cpp | 9 ++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/Code/client/Services/Generic/PluginService.cpp b/Code/client/Services/Generic/PluginService.cpp index 78293e520..a48767d81 100644 --- a/Code/client/Services/Generic/PluginService.cpp +++ b/Code/client/Services/Generic/PluginService.cpp @@ -78,14 +78,14 @@ SendResult PluginService::Queue(ChannelHandle aChannel, uint32_t aTargetPlayerId if (!entt::locator::has_value() || !World::Get().GetTransport().IsOnline()) return SendResult::NotConnected; - const auto& cPartyService = World::Get().GetPartyService(); - if (!cPartyService.IsInParty()) + if (!World::Get().GetPartyService().IsInParty()) return SendResult::NotInParty; if (aTargetPlayerId != 0) { - const auto& cMembers = cPartyService.GetPartyMembers(); - if (std::find(cMembers.begin(), cMembers.end(), aTargetPlayerId) == cMembers.end() || aTargetPlayerId == World::Get().GetTransport().GetLocalPlayerId()) + std::lock_guard cLock(m_lock); + + if (!std::binary_search(m_members.begin(), m_members.end(), aTargetPlayerId)) return SendResult::NotInParty; } @@ -127,3 +127,44 @@ void PluginService::OnNotifyModMessage(const NotifyModMessage& acMessage) noexce 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 index 20bd887b3..da46595a9 100644 --- a/Code/client/Services/PluginService.h +++ b/Code/client/Services/PluginService.h @@ -28,9 +28,17 @@ struct PluginService final : SkyrimTogetherAPI::IVSkyrimTogether1 */ void OnNotifyModMessage(const NotifyModMessage& acMessage) noexcept; + /** + * @brief Any event after which PartyService's member list may have changed. Runs on the main thread. + */ + 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; @@ -44,4 +52,6 @@ struct PluginService final : SkyrimTogetherAPI::IVSkyrimTogether1 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 c130bb79e..e7c7a09c8 100644 --- a/Code/client/World.cpp +++ b/Code/client/World.cpp @@ -24,8 +24,12 @@ #include #include +#include +#include +#include #include +#include #include #include @@ -60,6 +64,11 @@ World::World() // 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(); } From 5ed2f0e897804e45ff43099ba3abede571d91766 Mon Sep 17 00:00:00 2001 From: DJLegends Date: Sat, 17 Oct 2026 16:24:41 -0500 Subject: [PATCH 8/8] Stop promising plugin callbacks run on the main thread In-game testing on two clients showed MessageCallback and PartyChangedCallback arriving on different OS threads, even within one burst. Both are dispatched from TransportService::HandleUpdate inside World::Update, which runs from the Papyrus VM update hook, and the game runs that on whichever worker thread picks it up. Say what is actually true (the same context as Skyrim Together's own sync code) and point plugins that need the main thread at SKSE's task interface. Co-Authored-By: Claude Opus 5.5 --- Code/client/Services/Generic/PluginService.cpp | 2 +- Code/client/Services/PluginService.h | 6 +++--- Code/plugin_api/include/SkyrimTogetherAPI.h | 8 ++++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Code/client/Services/Generic/PluginService.cpp b/Code/client/Services/Generic/PluginService.cpp index a48767d81..00c24b718 100644 --- a/Code/client/Services/Generic/PluginService.cpp +++ b/Code/client/Services/Generic/PluginService.cpp @@ -89,7 +89,7 @@ SendResult PluginService::Queue(ChannelHandle aChannel, uint32_t aTargetPlayerId return SendResult::NotInParty; } - // The state may have changed by the time the main thread gets to it, in which case the message is dropped + // 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)]() { diff --git a/Code/client/Services/PluginService.h b/Code/client/Services/PluginService.h index da46595a9..b66b3c74d 100644 --- a/Code/client/Services/PluginService.h +++ b/Code/client/Services/PluginService.h @@ -24,12 +24,12 @@ struct PluginService final : SkyrimTogetherAPI::IVSkyrimTogether1 bool IsInParty() const noexcept override; /** - * @brief Hands a relayed Mod Message to the plugin on its channel, if any. Runs on the main thread. + * @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 on the main thread. + * @brief Any event after which PartyService's member list may have changed. Runs during the World update. */ template void OnPartyEvent(const T&) noexcept { NotifyPartyChanges(); } @@ -46,7 +46,7 @@ struct PluginService final : SkyrimTogetherAPI::IVSkyrimTogether1 SkyrimTogetherAPI::PartyChangedCallback OnPartyChanged; }; - // Validates on the calling thread, then sends from the main thread. aTargetPlayerId 0 means the whole party. + // 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; diff --git a/Code/plugin_api/include/SkyrimTogetherAPI.h b/Code/plugin_api/include/SkyrimTogetherAPI.h index 663911dac..f05ed464d 100644 --- a/Code/plugin_api/include/SkyrimTogetherAPI.h +++ b/Code/plugin_api/include/SkyrimTogetherAPI.h @@ -38,9 +38,13 @@ constexpr size_t kMaxChannelNameSize = 64; using ChannelHandle = uint32_t; // 0 is invalid -// Called on the game's main thread. aSenderId is the player ID the server saw the message come from. +// 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); -// Called on the game's main thread when a player joins (aJoined) or leaves the local player's party. +// A player joined (aJoined) or left the local player's party. using PartyChangedCallback = void (*)(uint32_t aPlayerId, bool aJoined); class IVSkyrimTogether1