Skip to content

Add a Plugin API so SKSE plugins can sync their own state - #14

Closed
DJLegends1011 wants to merge 8 commits into
absol89:PR-forwardfrom
DJLegends1011:feat/plugin-api
Closed

DJLegends1011 wants to merge 8 commits into
absol89:PR-forwardfrom
DJLegends1011:feat/plugin-api

Conversation

@DJLegends1011

Copy link
Copy Markdown

What this adds

A small Plugin API so other SKSE plugins can sync their own state through Skyrim Together, without ST knowing
anything about their mod. A plugin registers a Channel and sends small Mod Messages on it. ST relays the
bytes to the sender's party without interpreting them and stamps who sent them (the Sender), and the same
plugin on the other clients receives them.

It's discovered the way other SKSE plugin APIs work (TrueHUD, Precision): an exported RequestPluginAPI(version)
that returns a versioned interface (IVSkyrimTogether1). Outside ST the export is absent and a plugin stays
inactive.

auto pRequest = reinterpret_cast<SkyrimTogetherAPI::RequestPluginAPI_t>(
    GetProcAddress(GetModuleHandleW(nullptr), "RequestPluginAPI"));
auto* pApi = static_cast<SkyrimTogetherAPI::IVSkyrimTogether1*>(pRequest(SkyrimTogetherAPI::InterfaceVersion::V1));
auto channel = pApi->RegisterChannel("Author.ModName", OnMessage, OnPartyChanged);
pApi->Send(channel, bytes, length);            // to the party, sender excluded
pApi->SendTo(channel, playerId, bytes, length); // to one party member

The v1 surface is RegisterChannel, Send, SendTo, GetLocalPlayerId, IsInParty, plus two callbacks:
MessageCallback(senderId, bytes, length) and PartyChangedCallback(playerId, joined). The public header is
Code/plugin_api/include/SkyrimTogetherAPI.h. It's self-contained, so plugin authors copy it. It's GPL-3.0 like
the rest of the repo.

Example mod: STCampfireSync, simpler than SkyParkour. One
member places a campfire with a hotkey and everyone in the party sees it. Anyone can pick it up, late joiners get
existing campfires resent, and nothing is written to saves. It's about 390 lines in one file.

Why

ST can't know every mod's state, and every mod that wants co-op support currently has to be hardcoded into ST.
This lets a mod sync what only it understands, through one generic, size-limited, server-validated relay.

Not in scope: spawned-object sync (ST replicating PlaceAtMe objects for every mod by itself) and Papyrus
bindings. Both are possible follow-ups.

Design notes

  • Wire format: a new message pair, SendModMessageRequest / NotifyModMessage (channel, target player ID,
    sender player ID, bytes). It follows the existing messages' naming, registration and serialization. There's
    no protocol compatibility concern because mismatched builds are already rejected.
  • Making the export visible: ExeLoader::Load copies SkyrimSE.exe's PE headers over the launcher's, and
    SkyrimSE.exe has no export directory, so until now nothing the launcher exports was reachable through
    GetModuleHandle(nullptr). The loader now saves and restores the launcher's IMAGE_DIRECTORY_ENTRY_EXPORT, next
    to the checksum, timestamp and debug-directory restores it already does. The game exports nothing, so nothing is
    lost; the launcher's existing GPU-hint exports become visible again as a side effect. Considered instead: a
    separate forwarding DLL (one more file to ship and keep in sync), and SKSE plugin messaging (unavailable,
    because ST is not itself an SKSE plugin).
  • Threading: Send/SendTo validate on the calling thread and return a result, then the send is queued
    through the runner service. Callbacks run during ST's World update, the same context ST's own sync code runs in.
    Testing showed that this is not reliably the game's main thread (the VM update hook runs on varying
    threads), so the header says that and points plugins at SKSE::GetTaskInterface()->AddTask for main-thread
    work. The example does all game work that way.
  • Party changes are worked out on the client by comparing PartyService's member list before and after
    NotifyPartyInfo/Joined/Left and DisconnectedEvent. No new protocol. They're symmetric: a newcomer gets
    joined for every existing member, and every existing member gets joined for the newcomer.
  • The server stores nothing per channel, and keeps no message history.

Security

The maintainer asked for this to be safe against bad actors, since it passes new messages:

  • Hard limits: payload ≤ 256 bytes (counted in bytes, not characters) and channel name 1–64 bytes of
    [A-Za-z0-9._-]
    . They're enforced on the client (returning TooLarge / BadChannel) and again on the server
    for clients that skip their own checks.
  • Server-side rate limit per player: a token bucket with a burst of 40 and a refill of about 20 per second.
    Excess messages are dropped with no feedback to the sender and logged once per burst.
  • The Sender is stamped by the server from the connection, never taken from the payload, so it can't be
    spoofed.
  • SendTo can only reach a member of the sender's own party. The client and the server both check.
  • ST never parses payloads, so the only exposure is length-checked bytes handed to the plugin registered on that
    channel.

Verified vs assumed

Verified

  • TPTests pass (83 assertions, 9 cases), including round trips for both new messages with an empty payload, a
    256-byte payload and a 64-byte channel name, plus a malformed-length case.
  • In-game, two clients plus a local server on one PC, using a test plugin and the example:
    • Export found with GetProcAddress(GetModuleHandleW(nullptr), "RequestPluginAPI"), an unknown version returns
      null, and a bad channel name and a duplicate channel are both refused.
    • Party broadcast works both ways, the Sender is correct, and nobody receives their own message. NotInParty /
      TooLarge are returned as expected.
    • SendTo reaches only its target, and a non-member is refused.
    • Rate limit: a burst of 100 sends delivered exactly 40, with one server warning.
    • Party changes: join, leave and disconnect fire on both sides.
    • The example: place, pick up, late join, the placer reconnecting, and save/reload (no duplicates).
  • Engine behaviour the example relies on (checked in Ghidra on SE 1.6.1170): setting inGameFormFlags bit 0x80
    makes TESObjectREFR::CheckSaveGame return false, so the reference's changeform is dropped from the save.

Assumed / not tested

  • These tests ran on SE 1.6.1170, using a local build that also carried the 1.6.1170 version string and the
    pre-SSE 1.7.99 and SKSE changes tiltedphoques/TiltedEvolution#885 SkyrimVM / PlayerCharacter offsets. That local commit is not part of this PR. The PR's own code
    is version-independent (no new address IDs or struct offsets), but this exact branch has not been run on
    1.7.104.
  • The export restore was verified by reading ExeLoader and in-game on 1.6.1170, but not with other loader setups
    (e.g. without MO2).
  • Party kick was not tested; it goes through the same member-list comparison as leave.
  • Only the example's save behaviour was checked in Ghidra. The API itself makes no engine-behaviour claims.

Size

19 files, +688 / −3. That includes the header and the TPTests cases, inside the requested 500–700 lines.

🤖 Generated with Claude Code

DJLegends1011 and others added 8 commits October 17, 2026 15:11
…uginAPI

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@absol89
absol89 changed the base branch from dev to PR-forward October 18, 2026 03:08
@DJLegends1011

Copy link
Copy Markdown
Author

Closing in favour of miredirex#1. Miredirex asked for the Plugin API to go straight to his repo. It's the same branch and the same 8 commits. Thanks for syncing PR-forward for this!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant