Repository navigation
Add a Plugin API so SKSE plugins can sync their own state - #14
Closed
DJLegends1011 wants to merge 8 commits into
Closed
DJLegends1011 wants to merge 8 commits into
DJLegends1011 wants to merge 8 commits into
Conversation
…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>
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! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 staysinactive.
The v1 surface is
RegisterChannel,Send,SendTo,GetLocalPlayerId,IsInParty, plus two callbacks:MessageCallback(senderId, bytes, length)andPartyChangedCallback(playerId, joined). The public header isCode/plugin_api/include/SkyrimTogetherAPI.h. It's self-contained, so plugin authors copy it. It's GPL-3.0 likethe 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
PlaceAtMeobjects for every mod by itself) and Papyrusbindings. Both are possible follow-ups.
Design notes
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.
ExeLoader::LoadcopiesSkyrimSE.exe's PE headers over the launcher's, andSkyrimSE.exehas no export directory, so until now nothing the launcher exports was reachable throughGetModuleHandle(nullptr). The loader now saves and restores the launcher'sIMAGE_DIRECTORY_ENTRY_EXPORT, nextto 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).
Send/SendTovalidate on the calling thread and return a result, then the send is queuedthrough 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()->AddTaskfor main-threadwork. The example does all game work that way.
PartyService's member list before and afterNotifyPartyInfo/Joined/LeftandDisconnectedEvent. No new protocol. They're symmetric: a newcomer getsjoinedfor every existing member, and every existing member getsjoinedfor the newcomer.Security
The maintainer asked for this to be safe against bad actors, since it passes new messages:
[A-Za-z0-9._-]. They're enforced on the client (returningTooLarge/BadChannel) and again on the serverfor clients that skip their own checks.
Excess messages are dropped with no feedback to the sender and logged once per burst.
spoofed.
SendTocan only reach a member of the sender's own party. The client and the server both check.channel.
Verified vs assumed
Verified
TPTestspass (83 assertions, 9 cases), including round trips for both new messages with an empty payload, a256-byte payload and a 64-byte channel name, plus a malformed-length case.
GetProcAddress(GetModuleHandleW(nullptr), "RequestPluginAPI"), an unknown version returnsnull, and a bad channel name and a duplicate channel are both refused.
NotInParty/TooLargeare returned as expected.SendToreaches only its target, and a non-member is refused.inGameFormFlagsbit0x80makes
TESObjectREFR::CheckSaveGamereturn false, so the reference's changeform is dropped from the save.Assumed / not tested
pre-SSE 1.7.99 and SKSE changes tiltedphoques/TiltedEvolution#885
SkyrimVM/PlayerCharacteroffsets. That local commit is not part of this PR. The PR's own codeis version-independent (no new address IDs or struct offsets), but this exact branch has not been run on
1.7.104.
ExeLoaderand in-game on 1.6.1170, but not with other loader setups(e.g. without MO2).
Size
19 files, +688 / −3. That includes the header and the TPTests cases, inside the requested 500–700 lines.
🤖 Generated with Claude Code