A C# binding for the Steamworks SDK, with an emphasis on making things easy: real C# types,
await instead of callback registration, and a single managed DLL with no third-party
native dependency.
This is AnkleBreaker Studio's fork of Facepunch/Facepunch.Steamworks. It started life as a set of dedicated-server fixes and has since grown a good deal further.
New here? Read Getting Started rather than this
file. It explains the IPC model, why Init throwing is normal, and the steam_appid.txt
traps that catch everyone once. This README is the overview and the honest inventory.
Upstream is a good library and this fork does not diverge from its design. What it does is fix things that only show up when you actually ship a dedicated server, and then keep going.
| Upstream | This fork | |
|---|---|---|
| Dedicated servers | Supported, lightly exercised | The reason the fork exists. Auth-ticket path on the server, QueryEndReason from server-browser queries, a full audit of interface routing (04) |
| Steam Datagram Relay | Relay sockets only | The complete public surface: poll groups, hosted dedicated servers, ping locations, POPs, certificates, FakeIP |
| PS5 DualSense adaptive triggers | Not bound — the generator cannot emit Sony's C union |
Hand-written binding with a typed effect API |
| Steam Deck floating keyboard | Bound but unreachable (the wrapper was commented out) | Exposed, along with both text-input dismiss calls |
| Native binaries | Per-platform folders were ~11 SDK releases stale, including every Linux and macOS library | All 17 refreshed and checked on every build |
| Struct layout | 16 generated structs mis-laid-out; Marshal.PtrToStructure read past the end of Steam's callback buffer on one live path |
Fixed at the generator, and pinned by a CI-enforced baseline |
| Allocation | Per-callback and per-message garbage on the hot paths | Measured and largely eliminated — see Performance |
| Offline verification | A build | verify.ps1: build + native export conformance + struct-layout baseline, none of which needs Steam |
| Documentation | A README and a community wiki | Task-oriented guides and six evidence-backed audit reports |
Everything upstream does, this still does. The public API is source-compatible except where a defect fix changed behaviour — those are listed, not hidden.
| State | |
|---|---|
| Windows x64 / x86 | Supported |
| Linux x64 / x86 | Supported — but see known limitations about packaging the .so |
| macOS | Supported |
| Unity | Supported (net46 build, UnityPlugin/) |
| Unity IL2CPP | Supported. Note: the performance numbers below are CoreCLR; IL2CPP is not re-measured |
| Target frameworks | net46, netstandard2.1, net6.0 |
Async call results (await) |
Yes |
| Events (Steam callbacks) | Yes |
| Single managed DLL, no third-party native library | Yes — you ship Valve's steam_api and nothing else |
Client (ISteamUser, friends, apps, utils, screenshots, music, parental, remote play, video) |
Yes |
Dedicated server (ISteamGameServer, ISteamGameServerStats) |
Yes |
Server browser (ISteamMatchmakingServers) |
Yes — client-side only, which is correct |
| Lobbies & matchmaking | Yes |
| Achievements, stats, leaderboards | Yes |
| Workshop / UGC (query, subscribe, publish) | Yes |
| Steam Cloud | Yes |
| Steam Inventory | Yes |
| Voice | Yes |
| Steam Input, incl. PS5 DualSense adaptive triggers | Yes |
| Steam Deck floating keyboard | Yes |
| Steam Timeline | Yes |
| Parties, Remote Play | Yes |
Networking: ISteamNetworkingSockets |
Yes |
Networking: ISteamNetworkingMessages |
Yes |
Networking: legacy ISteamNetworking P2P |
Yes — Valve has superseded it; prefer the two above |
| Steam Datagram Relay — relay sockets, poll groups, hosted dedicated servers, ping locations, POPs, certificates, FakeIP | Yes |
ISteamHTTP |
Not exposed. The P/Invoke layer is generated, but there is no managed facade |
ISteamAppList |
Removed. Valve deleted the interface; the bindings threw EntryPointNotFoundException |
| MIT licensed | Yes |
Coverage, measured rather than claimed: 1,019 P/Invoke entry points, 100% of which resolve against all 17 committed native binaries. 906 of 946 flat-API functions are bound (95.8%); of the 40 unbound, 38 are legacy surface Valve itself superseded. 52.9% of bound internal methods are reachable from the public API — the gap is catalogued per interface in audit 03.
using Steamworks;
const uint AppId = 480; // 480 = Spacewar, Valve's public test app. Replace with yours.
try
{
SteamClient.Init( AppId, asyncCallbacks: false );
}
catch ( Exception e )
{
// Steam isn't running, the player doesn't own the app, steam_appid.txt is wrong,
// or the native library couldn't be loaded. Your game must still be able to start.
Console.WriteLine( $"Steam unavailable: {e.Message}" );
RunWithoutSteam();
return;
}
Console.WriteLine( $"Logged in as {SteamClient.Name} ({SteamClient.SteamId})" );
// ... every frame, from your main loop ...
SteamClient.RunCallbacks();
// ... when you close ...
SteamClient.Shutdown();Three things this snippet is deliberately doing:
-
Initis inside atry. It throws a plainSystem.Exceptionwhen Steam is not running, and Steam not running is not exceptional — it is Tuesday. TreatingInitas infallible is the single most common shipping bug in a Steam integration. Decide up front whether your game continues without Steam, exits cleanly, or relaunches through Steam (SteamClient.RestartAppIfNecessary), and never carry on as ifInitsucceeded. -
asyncCallbacks: false, plusRunCallbacks()in your loop. The default (true) starts a background pump, and your callbacks andawaitcontinuations then resume on a background thread. In a game engine, touching engine objects off the main thread is undefined behaviour that usually crashes somewhere else, later. Passfalsein a game and pump it yourself; leave the default for servers, tools and console apps.If you pass
falseand forgetRunCallbacks(), nothing breaks loudly — yourawaits just never complete. A hang with no exception is the signature of this mistake. -
steam_appid.txt. During development, put a file calledsteam_appid.txtnext to your executable containing only the AppID — no newline, no BOM, no quotes. Without it Steam cannot tell which app your process is. In a shipped build you normally delete it and let Steam tell your process what it is at launch; if you ship it, any player can edit it and claim to be a different app.The traps: the file must sit beside the built executable, not the project file; some editors silently add a BOM or trailing newline; and the account you are signed in as must own that AppID. Use 480 while prototyping — every account can use it.
A dedicated server is a different entry point. It does not log in as a user.
var init = new SteamServerInit( "mygame", "My Game" )
{
GamePort = 28015,
QueryPort = 28016,
Secure = true, // enable VAC
VersionString = "1.0.0"
};
try
{
SteamServer.Init( AppId, init );
}
catch ( Exception e )
{
Console.WriteLine( $"Server init failed: {e.Message}" );
return;
}
SteamServer.ServerName = "My Server";
SteamServer.MapName = "de_dust";
SteamServer.LogOnAnonymous(); // Init does NOT do this for you.
// ... every tick ...
SteamServer.RunCallbacks();Two traps in that snippet:
Initdoes not log you on. It sets up the interfaces and nothing else. Without an explicitLogOnAnonymous()(orLogOn(token)with a Game Server Login Token) the server runs, accepts nothing, and never appears anywhere.Initalso leavesServerName,MapNameandGameTagsunset.GamePortandQueryPortmust both be reachable from the internet.QueryPortis what the Steam server browser talks to — with onlyGamePortopen your server runs correctly and never appears in the browser, which is a miserable thing to debug.
Full walkthrough: Getting Started and Dedicated Servers.
SDR is Valve's private overlay for game traffic. Players connect to a nearby Valve relay instead of to each other, so neither end learns the other's IP address (nothing to DDoS), and traffic crosses Valve's backbone, which frequently beats public internet routing. Upstream exposed relay sockets. This fork exposes the rest.
Poll groups — drain many connections in one native call instead of one call per connection. At 100 players and 60 Hz, per-connection polling is 6,000 managed→native transitions per second, most of which return nothing. A poll group is 60, regardless of player count.
var group = SteamNetworkingSockets.CreatePollGroup();
group.Add( connection );
// Hoist the delegate into a field — a lambda written at the call site
// allocates a closure every tick and throws away the point of the API.
int processed = group.Receive( OnMessage );
group.Destroy();Hosted dedicated servers, ticketed and ticketless. A ticketed server accepts only clients holding a ticket your backend issued, and a cached ticket lets a client reconnect even while disconnected from Steam, because the ticket is already on disk.
ushort port = SteamNetworkingSockets.HostedDedicatedServerPort;
NetPOPID pop = SteamNetworkingSockets.HostedDedicatedServerPopId;
var address = SteamNetworkingSockets.GetHostedDedicatedServerAddress();
var login = SteamNetworkingSockets.GetGameCoordinatorServerLogin();
var socket = SteamNetworkingSockets.CreateHostedDedicatedServerSocket<SocketManager>();These route through the game server interface, not the shared one — Valve's header is
explicit that they must, and on a listen server the shared accessor would silently pick the
user interface and answer the wrong question. They throw with a message naming the actual
cause (SDR_LISTEN_PORT unset; no cached auth ticket) rather than handing back an invalid
handle, and they surface Valve's own diagnostic string, which is otherwise unobtainable.
Ping locations and POPs — estimate latency between two hosts without sending a packet,
so matchmaking does not need O(n²) round trips. Failure is a negative return, not an
exception (-1 failed, -2 unknown); sort without filtering those and every unmeasured
candidate looks like the best match.
Certificates — for a process that cannot get one from Steam, such as an anonymous dedicated server with no Steam account.
Both SDR documents are worth reading in full before using any of this: poll groups & hosted servers, ping locations & certificates. They record which behaviours were inferred from the headers rather than documented by Valve.
Motorised trigger resistance — the thing that makes a trigger feel like a gun, a bowstring,
or a stiff brake pedal. Valve exports SetDualSenseTriggerEffect and the SDK declares it,
but its parameter is a C union, which the code generator cannot emit, so no C# Steamworks
binding exposed it. This one hand-writes the binding and wraps it in a typed API.
foreach ( var controller in SteamInput.Controllers )
{
SteamInput.SetDualSenseTriggerEffect(
controller,
DualSenseTriggerEffect.Weapon( startPosition: 2, endPosition: 7, strength: 8 ),
DualSenseTrigger.Right );
}Effects are built with factory methods — Off, Feedback, Weapon, Vibration,
MultiplePositionFeedback, SlopeFeedback, MultiplePositionVibration — which validate
their arguments instead of packing an out-of-range byte into the native struct.
SetDualSenseTriggerEffects sets the two triggers differently in one call.
Two things to know. An effect is a state, not a one-shot: it stays applied until you
replace it or send Off, so clear it when the player holsters the weapon or the trigger
stays stiff in the menu. And it fails silently by design — on anything that is not a
DualSense the call does nothing, and the native API returns no status, so there is no way
to detect that it was ignored.
The on-screen keyboard that floats over your game instead of replacing the screen with the Steam overlay. The wrapper existed in upstream, commented out, with the wrong return type.
SteamUtils.ShowFloatingGamepadTextInput(
TextInputMode.SingleLine, fieldLeft, fieldTop, fieldWidth, fieldHeight );
// ... when your field loses focus ...
SteamUtils.DismissFloatingGamepadTextInput();This works differently from ShowGamepadTextInput, and the difference catches people out:
the full-screen version takes the text from the player and hands it back through a callback,
whereas the floating keyboard types into your field as if it were a hardware keyboard, and
you position it yourself so it does not cover the field. DismissGamepadTextInput is the
counterpart to the full-screen one; DismissFloatingGamepadTextInput to this one.
These are measured numbers from harnesses on real hardware, not estimates. The full method, the harness bug that was found and corrected mid-audit, and every number is in audit 06. Runtime: .NET 6.0.36, x64, workstation GC, Release.
| Path | Before | After |
|---|---|---|
SteamUGCDetails_t marshal (one workshop item) |
19,616 B/op | 0 B/op |
| Dedicated-server receive loop, 100 players @ 20 msg/player/s | ~460 KB/s of garbage | zero |
| Seven list enumerators (friends, lobby members, achievements, DLC, cloud files, …) | 2N+1 interop transitions |
N+1 |
Why those numbers were what they were:
-
SteamUGCDetails_tcarries five[MarshalAs(ByValArray)] byte[]fields totalling 9,670 bytes —Descriptionalone is 8,000. Five fresh arrays were allocated on every marshal regardless of content, and becauseGetQueryUGCResulttakes the struct byref, a non-blittablerefmarshals[In,Out], so it happened in both directions. A 50-item workshop page cost roughly half a megabyte in those arrays alone. The fix converts them tofixed bytebuffers decoded on demand, which is the pattern the generator already used elsewhere. 51ByValArray byte[]fields across the assembly got the same treatment. -
The dedicated-server receive loop.
SocketManager.Receivewas the un-optimised twin ofConnectionManager.Receive: aMarshal.AllocHGlobal/FreeHGlobalpair per call (58 ns), aMarshal.PtrToStructure<NetMsg>per message (232 B + 84 ns), and recursion rather than a loop to drain. The client path had already been made zero-allocation; the server path — this fork's flagship area — had not. It now mirrors it exactly:stackalloc, rawNetMsg*, awhileloop, and release-on-throw so a throwing handler no longer leaks the rest of the batch back to Steam. -
Marshal.PtrToStructure<T>does not avoid boxing. This overturned a fix made earlier in the same session. Both the generic and theTypeoverload allocate exactlysizeof(T) + 16bytes on CoreCLR — the generic one creates anobject, marshals into it and unboxes on return — so swapping between them changes nothing, and the generic form is slower for small structs. Callback delivery now uses a plain pointer dereference behind anunmanagedconstraint, which the compiler checks rather than trusts: it refuses any type carrying a managed reference, which is exactly the set that genuinely needs marshalling. Zero allocation per delivered callback, for the 222 of 440 value types that qualify.
Also verified and genuinely fine, so nobody re-litigates it: the dispatch pump's own
bookkeeping allocates 0 bytes per frame; string marshalling is at the theoretical
floor (a returned string costs exactly the string and nothing more); and the Ugc.Query
builder chain allocates 0 bytes despite being a 192-byte struct returned by value at
each step.
One honest caveat. Every number above is CoreCLR. Under IL2CPP/Mono some of these are
expected to be worse and have not been re-measured on device — in particular
Enum.HasFlag (Mono has no JIT expansion) and enum-keyed Dictionary lookups, which on
older Mono fall back to a boxing comparer and would put an allocation on the callback hot
path. That is listed as the single most important thing to verify on device.
Steam does not need to be installed to check a great deal of this library's correctness. Run this before every commit:
powershell -ExecutionPolicy Bypass -File verify.ps1
It builds every target framework of every platform project, then runs two gates. Exit code 0 means everything passed.
Local is the primary gate; CI is the backstop. CI runs the same scripts, but if
verify.ps1 passes on your machine the change is good, and if it fails, CI will not save
you. Nothing here needs a Steam client, a Steam account, a login, or a network connection —
which is the only reason it actually gets run.
powershell -ExecutionPolicy Bypass -File verify-native-conformance.ps1
What it proves: every DllImport entry point in the managed code exists in all 17
committed native binaries, across Windows x86/x64, Linux x86/x64 and macOS.
Why that matters: entry points are plain strings the C# compiler never checks. If a name
is stale or refers to a function Valve has since removed, the code compiles perfectly and
throws EntryPointNotFoundException the first time a player's machine calls it. This is
exactly how the six dead ISteamAppList bindings were found — and how the eight stale native
binaries were found, one of which would have stopped a Linux dedicated server from
initialising at all.
powershell -ExecutionPolicy Bypass -File verify-struct-layout.ps1
What it proves: the marshalled size, pack and every field offset of all 343 ABI structs
still match Tools/baselines/layout-win64.txt.
Why that matters: Steam writes callback structs directly into memory that managed code
reads back with Marshal.PtrToStructure. If a managed layout disagrees with the C++ one,
nothing throws — you silently read the wrong fields, or read past the end of Steam's
buffer. It is the worst failure mode in this codebase: invisible, non-deterministic, and
nearly impossible to attribute afterwards. The layouts are produced by the code generator,
where one small change can move dozens of structs at once, so the baseline turns that into
an explicit, reviewable diff.
Pass -Record to deliberately accept an intended layout change, and commit the new baseline
alongside the change that caused it.
What the baseline does not prove: it pins what the layout is, not what it should
be. It catches drift, but would happily accept a wrong layout that was recorded
deliberately. The proof that the current layouts are correct comes from a header-derived
MSVC-x64 layout model diffed against Marshal.SizeOf/OffsetOf, which still lives outside
this repository — so that argument is reproducible by hand but not yet by CI.
Runtime behaviour against a live Steam client is a separate thing: see
run-live-steam-tests.ps1, which needs Steam running and signed in.
This library is pure C#, but it P/Invokes into Valve's native steam_api library, which you
must ship alongside your game.
| Target | File | Comes from |
|---|---|---|
| Windows x64 | steam_api64.dll |
Facepunch.Steamworks/steam_api64.dll |
| Windows x86 | steam_api.dll |
Facepunch.Steamworks/steam_api.dll |
| Linux x64 | libsteam_api.so |
Facepunch.Steamworks/linux64/libsteam_api.so |
| Linux x86 | libsteam_api.so |
Facepunch.Steamworks/linux32/libsteam_api.so |
| macOS | libsteam_api.dylib |
Facepunch.Steamworks/osx/libsteam_api.dylib |
The binary must sit beside the executable, and must match the version this library was
built against. A mismatch does not fail gracefully — you get EntryPointNotFoundException
at the first call using a function the older binary lacks, potentially deep into a play
session.
Every binary in this repository is checked against the library's P/Invoke declarations by
verify-native-conformance.ps1 on every build, so the copies above are known good. If you
source a steam_api binary from anywhere else, run that script against it before shipping.
Linux and macOS need manual staging today. The two Linux .so files above are genuine,
current and verified — but the build does not copy them to the output directory. See the
next section.
A fork that documents its own defects is easier to trust than one that does not. These are the findings the audit has verified and not yet fixed, ranked by severity. Each links to the report that reproduces it.
Will bite you in production
MatchMakingKeyValuePairand 20const char*callback fields decode as ANSI, not UTF-8. Server-browser filters marshal as ANSI, so"café"goes out as63 61 66 E9and"日本語"becomes three literal?— unrecoverably. Among the 20 callback fields typed as rawstringisHTML_NeedsPaint_t.PBGRA, which is a BGRA framebuffer pointer being scanned for a NUL terminator. (01)- Any
SteamApps.*call on a pure dedicated server is an access violation, not an exception.SteamAppsis registered server-side, butISteamAppshas no game-server accessor, soSelfstaysIntPtr.ZeroandSteamServer.AddInterface<T>ignores the failure (unlikeSteamClient's). Masked on a listen server, fatal on a dedicated one. (04) - Shutdown races the async callback pump.
Dispatch.LoopClientAsyncisasync voidwith no cancellation or join, so an in-flight frame can call into a destroyed pipe — crash-on-exit for headless servers. (02) - Pending call results are silently abandoned on shutdown. Proven: 5,000 registered
continuations, 0 invoked. Every awaiting
Taskhangs forever. (02) Dispatch.runningFrameis a non-volatile check-then-set. 872,129 simultaneous entries measured in a 2M-iteration race. ConcurrentFreeLastCallbackis something Valve explicitly forbids. (02)
Server-side gaps
- Server auth tickets cannot be cancelled.
AuthTicket.Cancel()hard-codesSteamUser.Internal, which is never registered server-side, soDispose()throws. (04) SteamServer.Shutdown()does not reset cached statics, so a secondInitin the same process silently skipsModDir/GameDescription/MaxPlayers— properties the SDK requires beforeLogOn. The server then lists incorrectly, with no error anywhere. (04)- The Posix build produces no deployable Linux artifact. The project has no item group
copying
libsteam_api.so, and sharesbin/with the Windows projects. Copy the.soyourself when staging a Linux server. (04) - Shared interfaces resolve client-first on a listen server.
SteamSharedClass<T>.InterfaceisInterfaceClient ?? InterfaceServer, so with both a client and a server initialised in one process,SteamNetworkingSockets,SteamUtils,SteamUGC,SteamInventoryandSteamNetworkingMessagesoperate on the user's interface. No error is raised; the behaviour is just wrong. The SDR hosted-server calls added by this fork deliberately bypass this. (04)
Smaller, but real
- Callback exceptions are swallowed and permanently drop the remaining handlers for that callback. (02)
SteamInputActionEvent_tis 16 bytes against a native 33 — the generator drops itsunion. Unreachable today (EnableActionEventCallbacksis filtered out as deprecated), but it is a compiled-in landmine. (01)Achievement.GlobalUnlockedalways returns-1— its prerequisite call is never made. (03)SteamNetworkingSockets.CreateFakeUDPPortreturns an unusable handle — the backing class is empty and itsSelfis permanently zero. (03)GSStatsUnloaded_tis unreachable — a genuine upstream callback-ID collision at 1108 that the generator resolves inconsistently with the identical one at 1112. (05)
Test and documentation coverage, stated plainly
- 118 of 118 tests fail offline, all on one
[AssemblyInitialize], in 227 ms. Separately, 53 of the 118 contain no assertions at all. The offline gates inverify.ps1exist because the test suite cannot currently carry that weight. - 36.2% of the public API has any XML documentation,
<param>sits at 11.8%, and there is not one<example>or<remarks>in the library. For a P/Invoke binding, the arguments are exactly where the danger is. Documentation is one of this fork's two stated priorities and this number is the reason.
If you are migrating from upstream, these change what your existing code observes:
OnMessagereceivedmessageNumandrecvTimetransposed on bothSocketManagerandConnectionManager— every consumer got a microsecond timestamp as the message number and a sequence counter as the receive time. This is now fixed, so anyone who compensated for the swap in their own handler must remove that compensation.ServerList.Base.RunQueryAsyncreturnsTask<QueryEndReason>, notTask<bool>— you can now distinguish a timeout from a cancellation from an invalid client. Source-breaking.SteamUserStats.RequestCurrentStats()is an obsolete no-op. Valve deleted the native function. Stats now arrive automatically at startup; see the achievements guide.
Start at docs/README.md. The short version:
- Guides — task-oriented, written for someone who has not shipped a Steam game before. Getting started, achievements & stats, lobbies & matchmaking, choosing a networking transport, workshop & cloud, dedicated servers.
- SDR notes — what was added and why each design decision went the way it did.
- Audit reports — six evidence-backed workstreams. Every finding cites a header quote, a measured number, or a reproduction; suspicions that did not survive verification are recorded as such rather than quietly dropped.
Where the docs assume rather than know, they say so. Valve leaves 30.9% of SDK methods with
no comment at all, and 91.7% of functions returning const char* document no pointer
lifetime, so silence in Valve's headers is treated as a reason to state our assumption, not
to stay quiet.
Pull requests, bug reports — yes, please. Run verify.ps1 before you send one; it is the
gate, and it needs nothing but a checkout and the .NET SDK.
For upstream's community resources, the Steamworks wiki and the Steamworks thread are still the best places for general Steamworks discussion.
MIT — do whatever you want.
This is a fork of Facepunch.Steamworks, copyright © 2016 Facepunch Studios LTD, created by Garry Newman and contributors, and used here under the MIT licence. The original licence text is preserved verbatim in LICENSE. The overwhelming majority of this library is still their work; the fork adds to it rather than replacing it.
Steamworks itself is © Valve Corporation. The steam_api binaries in this repository are
Valve's redistributables and are subject to the Steamworks
SDK terms, not the MIT licence above.