Skip to content

How It Works

Joël Deffner edited this page Sep 1, 2026 · 2 revisions

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.

The layers

  1. Native loader (src/runtime/native.ts). Loads the shared library and registers flat symbols on demand, caching each koffi function by symbol name.
  2. 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.
  3. Struct decoder (src/runtime/struct.ts). Turns a raw byte buffer into a plain object using an explicit offset table.
  4. Generated classes (src/generated/). 25 interface classes, 807 functions, 215 struct layouts, 191 callbacks, 116 enums, 97 consts. See Flat API.
  5. Curated layers (src/api/). Hand-written promises and helpers over five interfaces: Workshop over ISteamUGC, Stats and Leaderboards over ISteamUserStats, Cloud over ISteamRemoteStorage, and Lobbies over ISteamMatchmaking.

Steam in src/index.ts ties them together and is the only object most callers touch.

What init() does

init(opts) runs these steps in order:

  1. If opts.appId is set, writes process.env.SteamAppId and process.env.SteamGameId. Without it, Steam falls back to steam_appid.txt or the existing environment.
  2. Constructs SteamNative, which calls koffi.load() on opts.libPath or the bundled path from defaultLibPath(): runtime/win64/steam_api64.dll, runtime/linux64/libsteam_api.so, or runtime/osx/libsteam_api.dylib, relative to the compiled module.
  3. 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.
  4. Calls SteamAPI_InitFlat with a 1024-byte error buffer. On anything other than k_ESteamAPIInitResult_OK it throws SteamInitError carrying Valve's own diagnostic text read out of that buffer, plus the numeric initResult.
  5. Calls SteamAPI_ManualDispatch_Init, then SteamAPI_GetHSteamPipe to get the pipe handle every dispatch call needs.
  6. Constructs SteamDispatch and starts the pump at opts.pumpIntervalMs, default 50ms.
  7. Returns a Steam whose appId is opts.appId, or SteamAppId from 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.

The pump

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:

  1. SteamAPI_ManualDispatch_RunFrame(pipe).
  2. Loop SteamAPI_ManualDispatch_GetNextCallback(pipe, msg) until it returns false. msg is a CallbackMsg_t: m_hSteamUser at 0, m_iCallback at 4, m_pubParam at 8, m_cubParam at 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.
  3. Dispatch the message (below).
  4. SteamAPI_ManualDispatch_FreeLastCallback(pipe) in a finally, 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.

Plain callbacks

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.

Call results

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.

Why manual dispatch

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.

Why offset tables, not koffi structs

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 packing rules

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 a string cut at the first NUL. Any other array becomes { bytes: stride * count } and decodes to a Buffer.
  • Pointer and reference fields are 8 bytes, aligned 8, decoded as uint64.
  • CSteamID and CGameID are 8 bytes with alignment 1, not 8. They are declared under #pragma pack(push, 1) in steamclientpublic.h. Treating them as ordinary uint64 fields shifts every field after them on Linux and macOS.
  • The six Steam Input and Controller *Data_t structs carry their own #pragma pack(1) in isteaminput.h and isteamcontroller.h, so the generator forces pack 1 for them through the FORCED_PACK table.
  • 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.

bigint and Buffer conventions

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.

Process model

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.

Where each concern lives

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.

Clone this wiki locally