-
Notifications
You must be signed in to change notification settings - Fork 0
How It Works
steamwand has no native code of its own. It loads Valve's steam_api
redistributable with koffi and calls the flat C API
directly. Everything above that is TypeScript: a generated binding layer plus
about four small hand-written modules.
-
Native loader (
src/runtime/native.ts). Loads the shared library and registers flat symbols on demand, caching each koffi function by symbol name. -
Dispatch pump (
src/runtime/dispatch.ts). Drives Valve's manual dispatch API on an interval, resolves call-result promises, and fans plain callbacks out to listeners. -
Struct decoder (
src/runtime/struct.ts). Turns a raw byte buffer into a plain object using an explicit offset table. -
Generated classes (
src/generated/). 25 interface classes, 807 functions, 215 struct layouts, 191 callbacks, 116 enums, 97 consts. See Flat API. -
Curated layers (
src/api/). Hand-written promises and helpers over five interfaces: Workshop overISteamUGC, Stats and Leaderboards overISteamUserStats, Cloud overISteamRemoteStorage, and Lobbies overISteamMatchmaking.
Steam in src/index.ts ties them together and is the only object most callers
touch.
init(opts) runs these steps in order:
- If
opts.appIdis set, writesprocess.env.SteamAppIdandprocess.env.SteamGameId. Without it, Steam falls back tosteam_appid.txtor the existing environment. - Constructs
SteamNative, which callskoffi.load()onopts.libPathor the bundled path fromdefaultLibPath():runtime/win64/steam_api64.dll,runtime/linux64/libsteam_api.so, orruntime/osx/libsteam_api.dylib, relative to the compiled module. - Registers the eight core symbols the runtime always needs:
SteamAPI_InitFlat,SteamAPI_Shutdown,SteamAPI_GetHSteamPipe,SteamAPI_ManualDispatch_Init,_RunFrame,_GetNextCallback,_FreeLastCallback, and_GetAPICallResult. Every other symbol is registered later, on first use, by a generated class. - Calls
SteamAPI_InitFlatwith a 1024-byte error buffer. On anything other thank_ESteamAPIInitResult_OKit throwsSteamInitErrorcarrying Valve's own diagnostic text read out of that buffer, plus the numericinitResult. - Calls
SteamAPI_ManualDispatch_Init, thenSteamAPI_GetHSteamPipeto get the pipe handle every dispatch call needs. - Constructs
SteamDispatchand starts the pump atopts.pumpIntervalMs, default 50ms. - Returns a
SteamwhoseappIdisopts.appId, orSteamAppIdfrom the environment, or 0.
Interface classes are constructed lazily after that: the first read of
steam.ugc calls SteamAPI_SteamUGC_v021, keeps the returned pointer, and
caches the instance.
steam.close() stops the pump, rejects every call still in flight, and calls
SteamAPI_Shutdown. It is idempotent.
SteamDispatch.start() is one setInterval and nothing else. The timer is
unref()'d, so an idle pump does not hold the Node process open, and ref()'d
again while any call result is pending, so an awaited Steam call cannot be cut
short by the event loop going empty. updateRef() flips between the two on
every pending-set change.
runFrame() is what the interval calls, and it is public. Call it yourself if
you want to drain Steam at a specific moment (a game loop, a test) instead of
waiting for the next tick.
One frame:
-
SteamAPI_ManualDispatch_RunFrame(pipe). - Loop
SteamAPI_ManualDispatch_GetNextCallback(pipe, msg)until it returns false.msgis aCallbackMsg_t:m_hSteamUserat 0,m_iCallbackat 4,m_pubParamat 8,m_cubParamat 16. Those offsets are the same on all supported 64-bit platforms, so this is the one struct read through a koffi struct type rather than an offset table. - Dispatch the message (below).
-
SteamAPI_ManualDispatch_FreeLastCallback(pipe)in afinally, so a throwing listener cannot leave the queue wedged.
Steam owns m_pubParam and frees it at FreeLastCallback. readParam()
therefore copies the bytes out first, with
koffi.decode(ptr, koffi.array('uint8', size)) into a fresh Buffer. Every
buffer a listener or a promise receives is that copy. Holding on to it is safe.
The copy only happens when someone is listening. A plain callback with no registered listener is freed without being read.
Any message whose id is not 703 is a plain callback. The dispatcher looks up the
listener set for that id and hands each listener the copied bytes.
steam.on(name, cb) sits on top: it finds the id and layout in
callbacksById by struct name, picks win64 or posix by
process.platform, and decodes before calling you. steam.dispatch.on(id, cb)
skips the decode.
Callback id 703 is SteamAPICallCompleted_t, Valve's "one of your async calls
finished" notice. Its 16 bytes are read directly: call handle at offset 0,
callback id at 8, payload size at 12.
completeCall() then looks the handle up in the pending map. A handle that is
not ours is ignored, which is what lets a second consumer of the same pipe
coexist. For a match it allocates size bytes plus a one-byte failed flag and
calls SteamAPI_ManualDispatch_GetAPICallResult(pipe, call, out, size, callbackId, failed). A false return, or a non-zero failed, rejects the
promise with SteamApiCallError carrying the callbackId. Otherwise it
resolves with the payload buffer, and callResultStruct decodes it.
callResult(0n) rejects immediately, without touching the pending map: Steam
returns k_uAPICallInvalid when it refuses a call outright.
Since 0.3.0 both callResult and callResultStruct take an optional expected
callback id as their last argument. When it is set and the completion carries a
different id, the promise rejects with SteamApiCallError before anything is
read or decoded, so a result struct never reaches decodeStruct with the wrong
layout. The generated async wrappers and all five curated layers pass it, taking
the value from callbackIdByName. Omit it and the pump accepts whatever Steam
sends, which is the old behaviour.
Valve offers two callback models. SteamAPI_RunCallbacks dispatches into C++
CCallback objects registered by vtable, which an FFI binding cannot create.
Manual dispatch hands you a message queue instead: ids, byte pointers, and
lengths. That is exactly what crosses an FFI boundary.
The consequence is a hard rule: one pump per process, and never mixed with
SteamAPI_RunCallbacks. Both drain the same queue, so a message read by one
is gone for the other. If another Steam binding is loaded in the same process,
they will steal callbacks from each other.
Steam callback structs are declared under #pragma pack(8) on Windows and
#pragma pack(4) on Linux and macOS (VALVE_CALLBACK_PACK_LARGE and
VALVE_CALLBACK_PACK_SMALL in steamclientpublic.h). koffi has no pack(4)
equivalent, so a koffi struct type would silently use native alignment and read
the wrong bytes on Linux and macOS.
Manual dispatch already gives us raw bytes, so the decoder does not need koffi at all. The generator computes both layouts ahead of time and emits them as data:
CreateItemResult_t: {
win64: { size: 24, fields: [
{ name: 'm_eResult', offset: 0, type: 'int32' },
{ name: 'm_nPublishedFileId', offset: 8, type: 'uint64' },
{ name: 'm_bUserNeedsToAcceptWorkshopLegalAgreement', offset: 16, type: 'bool' },
] },
posix: { size: 16, fields: [ /* same fields at 0, 4, 12 */ ] },
}layoutOf(name) picks one by process.platform, and decodeStruct(buf, layout)
walks the field list with Buffer.read* calls. The offsets are readable, they
diff cleanly across SDK bumps, and they are testable without a Steam client
running.
The generator lays fields out in declaration order. Each field is aligned to
min(natural alignment, pack), and the struct size is rounded up to the largest
alignment it used, with a floor of 1 byte. On top of that:
-
char[N]becomes{ cstring: N }and decodes to astringcut at the first NUL. Any other array becomes{ bytes: stride * count }and decodes to aBuffer. - Pointer and reference fields are 8 bytes, aligned 8, decoded as
uint64. -
CSteamIDandCGameIDare 8 bytes with alignment 1, not 8. They are declared under#pragma pack(push, 1)insteamclientpublic.h. Treating them as ordinaryuint64fields shifts every field after them on Linux and macOS. - The six Steam Input and Controller
*Data_tstructs carry their own#pragma pack(1)inisteaminput.handisteamcontroller.h, so the generator forces pack 1 for them through theFORCED_PACKtable. - Nested structs are laid out recursively and stored as opaque bytes.
Structs holding a C union are excluded outright through UNION_STRUCTS, because
steam_api.json lists union members as plain sequential fields and a layout
computed from that list would be wrong without saying so. The four seed entries
cascade to ten excluded structs, listed in
Flat API.
The emitted offsets were checked against steamworks-sys 0.13.0's bindgen layout
asserts, which come from a real C compiler: 428 comparisons, zero differences.
test/offsets.test.ts pins the workshop set (ids, sizes, and every field offset
for CreateItemResult_t, SubmitItemUpdateResult_t,
SteamUGCQueryCompleted_t, SteamUGCDetails_t, ItemInstalled_t,
DownloadItemResult_t) so an SDK bump cannot move one quietly. That test needs
no Steam client.
Two rules run through the whole binding.
Every 64-bit value is a bigint. Steam ids, published file ids, query
handles, update handles, and API call handles are all uint64. Number loses
precision above 2^53, and real workshop ids are past that, so returns are always
bigint. Parameters accept bigint | number for convenience, and struct fields
decode to bigint.
The caller owns every buffer. The flat API never allocates for you, so any
pointer parameter that is not a C string arrives as Buffer | null and you
allocate it: Buffer.alloc(8) for a uint64 *, Buffer.alloc(layout.size) for
a struct, Buffer.alloc(n) for a text buffer. The patterns are in
Flat API.
One process, one library handle, one pipe, one pump. Steam holds no global
state beyond that, but Steam itself does, so only one session may be open at a
time: init() throws while another one is running. close() releases the lock,
and an init() after it starts a fresh session in the same process.
An FFI mistake aborts the process. Passing a 4-byte buffer where Steam writes 8,
or null where it expects memory, is a segfault inside the Steam DLL. There is no
JavaScript stack, no exception, and no try/catch that helps. This is the
price of having no native wrapper code to validate arguments.
If steamwand is embedded in something that must survive a bad call (a VS Code extension, an editor plugin, a long-running server), run it in a child process and talk to it over IPC. A crash then costs you one worker instead of the host. That is how the CK3 modding toolkit uses it. See Recipes.
| Concern | File |
|---|---|
| Library load, symbol registration and cache | src/runtime/native.ts |
| Library file name and path per platform, callback pack rule | src/runtime/platform.ts |
Pump, callback fan-out, call-result promises, SteamApiCallError
|
src/runtime/dispatch.ts |
decodeStruct, FieldType, StructLayout
|
src/runtime/struct.ts |
SteamParamStringArray_t marshalling, stringArray()
|
src/runtime/types.ts |
init(), Steam, interface getters, steam.on()
|
src/index.ts |
SteamInitError, SteamResultError, eResultName
|
src/api/errors.ts |
| Curated layers | src/api/{workshop,stats,cloud,leaderboards,lobbies}.ts |
| Generated interface classes | src/generated/interfaces/*.ts |
| Generated layouts, callbacks, enums, consts | src/generated/{structs,callbacks,enums,consts}.ts |
| The generator | scripts/generate.ts |
| Live read-only smoke check | scripts/smoke.ts |
| Offset regression test | test/offsets.test.ts |
Nothing under src/generated/ is edited by hand. To change what comes out of it,
change the generator: see Regenerating.