Skip to content
Closed
11 changes: 11 additions & 0 deletions Code/client/PluginAPI.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#include <Services/PluginService.h>

// 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<SkyrimTogetherAPI::IVSkyrimTogether1*>(&PluginService::Get());
}
170 changes: 170 additions & 0 deletions Code/client/Services/Generic/PluginService.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
#include <Services/PluginService.h>

#include <World.h>

#include <Messages/ModMessage.h>
#include <Messages/NotifyModMessage.h>
#include <Messages/SendModMessageRequest.h>

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<ChannelHandle>(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<World>::has_value() || !World::Get().GetTransport().IsOnline())
return 0;

return World::Get().GetTransport().GetLocalPlayerId();
}

bool PluginService::IsInParty() const noexcept
{
return entt::locator<World>::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<World>::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<uint32_t> 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<uint32_t> joined, left;
Vector<PartyChangedCallback> 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);
}
}
57 changes: 57 additions & 0 deletions Code/client/Services/PluginService.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#pragma once

#include <SkyrimTogetherAPI.h>

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 <class T> 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<Channel> m_channels;
// The other party members as of the last change, sorted. Lets any thread check a SendTo target.
Vector<uint32_t> m_members;
};
15 changes: 15 additions & 0 deletions Code/client/World.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,15 @@
#include <Services/CombatService.h>
#include <Services/WeatherService.h>
#include <Services/MapService.h>
#include <Services/PluginService.h>

#include <Messages/NotifyModMessage.h>
#include <Messages/NotifyPartyInfo.h>
#include <Messages/NotifyPartyJoined.h>
#include <Messages/NotifyPartyLeft.h>

#include <Events/PreUpdateEvent.h>
#include <Events/DisconnectedEvent.h>
#include <Events/UpdateEvent.h>

#include <ModCompat/BehaviorVar.h>
Expand Down Expand Up @@ -55,6 +62,14 @@ World::World()
ctx().emplace<WeatherService>(*this, m_transport, m_dispatcher);
ctx().emplace<MapService>(*this, m_dispatcher, m_transport);

// The Plugin API outlives the World, so there is nothing to disconnect
m_dispatcher.sink<NotifyModMessage>().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<NotifyPartyInfo>().connect<&PluginService::OnPartyEvent<NotifyPartyInfo>>(PluginService::Get());
m_dispatcher.sink<NotifyPartyJoined>().connect<&PluginService::OnPartyEvent<NotifyPartyJoined>>(PluginService::Get());
m_dispatcher.sink<NotifyPartyLeft>().connect<&PluginService::OnPartyEvent<NotifyPartyLeft>>(PluginService::Get());
m_dispatcher.sink<DisconnectedEvent>().connect<&PluginService::OnPartyEvent<DisconnectedEvent>>(PluginService::Get());

BehaviorVar::Get()->Init();
}

Expand Down
3 changes: 2 additions & 1 deletion Code/encoding/Messages/ClientMessageFactory.h
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
#include <Messages/TeleportCommandRequest.h>
#include <Messages/SetTimeCommandRequest.h>
#include <Messages/RequestActionEvents.h>
#include <Messages/SendModMessageRequest.h>
#include <Messages/PlayerRespawnRequest.h>
#include <Messages/DialogueRequest.h>
#include <Messages/SubtitleRequest.h>
Expand All @@ -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<T>(func));
}
Expand Down
44 changes: 44 additions & 0 deletions Code/encoding/Messages/ModMessage.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#pragma once

#include "Message.h"

#include <SkyrimTogetherAPI.h>

// 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<const uint8_t*>(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<uint8_t*>(aBytes.data()), cLength);
}
} // namespace ModMessage
21 changes: 21 additions & 0 deletions Code/encoding/Messages/NotifyModMessage.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#include <Messages/NotifyModMessage.h>
#include <Messages/ModMessage.h>

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();
}
30 changes: 30 additions & 0 deletions Code/encoding/Messages/NotifyModMessage.h
Original file line number Diff line number Diff line change
@@ -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;
};
Loading