Skip to content

Troubleshooting

Joël Deffner edited this page Aug 31, 2026 · 1 revision

Troubleshooting

Every error thrown by steamwand starts with steamwand:, except SteamInitError, which carries Valve's own diagnostic text, and SteamResultError, which reports an EResult name.

Symptom to cause

Message or symptom Likely cause Fix
SteamInitError with Valve's text Steam client not running, not logged in, or wrong app id Init fails
steamwand: SteamAPI_SteamUGC_v021 returned null (is Steam initialized?) An interface was constructed before or without a successful init() Interface accessor returns null
Process dies, no stack, no exception FFI mistake in the raw layer (wrong buffer size, out param not allocated) The process aborts
Promise never settles The pump is not running, or SteamAPI_RunCallbacks is competing with it Calls never resolve
Node exits before a callback arrives The pump timer is unref'd and nothing else keeps the loop alive Node exits early
steamwand: dispatch stopped while call was in flight steam.close() ran while a call result was pending Close during a call
SubmitItemUpdate failed: k_EResultAccessDenied Legal agreement not accepted, or item not owned by the signed-in account Workshop upload rejected
steamwand: SetItemTitle returned false (invalid handle or argument?) Bad update handle or an argument Steam refused A setter returned false
steamwand: content folder does not exist: C:/mods/x contentPath is wrong or relative Missing content or preview path
steamwand: preview image does not exist: C:/mods/x.png previewPath is wrong or relative Missing content or preview path
steamwand: unknown callback struct 'ItemInstaled_t' Typo, or the struct has no layout Unknown callback struct
steamwand: no generated layout for struct SteamNetworkingIdentity The struct contains a C union and is excluded on purpose No generated layout
steamwand: buffer too small for struct (16 < 24) A hand-allocated buffer is smaller than the layout size Buffer too small
steamwand: unsupported platform android process.platform is not win32, linux, or darwin Library will not load
koffi throws on koffi.load Missing C runtime, 32-bit Node, or a moved runtime/ folder Library will not load

Init fails

init() calls SteamAPI_InitFlat and throws SteamInitError when the result is not k_ESteamAPIInitResult_OK. The message is the 1024-byte diagnostic string Valve writes, so read it first. The numeric result is on the error:

import { init, SteamInitError } from 'steamwand.js';

try {
  const steam = init({ appId: 480 });
} catch (e) {
  if (e instanceof SteamInitError) console.error(e.initResult, e.message);
}

initResult is an ESteamAPIInitResult: 1 generic, 2 no Steam client, 3 version mismatch.

Checklist:

  • The Steam client must be running and logged in. Offline mode is not enough for workshop calls.
  • The signed-in account must own the app id. For testing use Spacewar (480), which every account owns.
  • Restart a Steam client that has been running for days. A stale client is a common cause of k_ESteamAPIInitResult_NoSteamClient.

App id problems

There are two ways to say which app you are:

  • init({ appId: 480 }) sets process.env.SteamAppId and process.env.SteamGameId before the native init runs.
  • Omit appId and Steam reads steam_appid.txt from the working directory. In that case steam.appId falls back to Number(process.env.SteamAppId ?? 0).

With no appId and no steam_appid.txt, steam.appId is 0, and every workshop method that defaults to it queries app 0 and returns nothing. Pass appId to init(), or per call:

await steam.workshop.getUserItems(1, steam.accountId(), { appId: 480 });

One process gets one app id. Calling init() a second time in the same process is unreliable; restart instead.

Interface accessor returns null

Each generated interface class calls its versioned accessor in the constructor, and throws when Steam hands back a null pointer:

steamwand: SteamAPI_SteamUGC_v021 returned null (is Steam initialized?)

Causes, in order of likelihood:

  • The interface was constructed before init() succeeded, or after steam.close().
  • The app id you initialized under does not have that interface. Game server interfaces are not wired up at all.
  • The bundled redistributable is older than the Steam client's interface version. Regenerate against a matching SDK: see Regenerating.

The process aborts with no stack

The raw generated layer passes your arguments straight to C. A wrong argument does not throw, it corrupts memory, and Node dies with no exception and no stack. The usual causes:

  • An out parameter passed as null instead of an allocated Buffer.
  • A buffer smaller than the size argument you passed next to it, for example Buffer.alloc(64) with a length of 260.
  • A bigint handle passed as a number, losing the high bits.

Allocate out params to the size you declare:

const buf = Buffer.alloc(260);
steam.apps.GetAppInstallDir(1158310, buf, 260);
const dir = buf.toString('utf8', 0, Math.max(buf.indexOf(0), 0));

If the crash must not take your host process down (a VS Code extension, an editor plugin, a long-lived server), run steamwand in a child process and talk to it over IPC. The curated workshop layer guards the paths it can check; the raw layer cannot be made crash-proof.

Calls never resolve

Async Steam calls return a bigint call handle that the dispatch pump turns into a promise. Two things break that:

  • Forgetting await. steam.ugc.CreateItem(...) returns a handle, not a result. Use steam.workshop.createItem(), or await the handle yourself with steam.dispatch.callResultStruct(call, layoutOf('CreateItemResult_t')).
  • Calling SteamAPI_RunCallbacks. steamwand uses Valve's manual dispatch (SteamAPI_ManualDispatch_*). Never combine the two in one process: the callback that would settle your promise gets consumed by the other pump and the promise hangs forever.

The pump runs every 50 ms by default. Change it with init({ pumpIntervalMs: 16 }), or drain the queue by hand with steam.dispatch.runFrame().

Node exits early

SteamDispatch.start() calls timer.unref() so an idle pump does not keep a CLI alive. The timer is re-ref'd only while a call result is pending. So this script exits immediately and prints nothing:

const steam = init({ appId: 480 });
steam.on('ItemInstalled_t', (d) => console.log(d)); // never fires: node exits

If you only listen for plain callbacks, keep the process alive yourself, for example with a ref'd timer, a server, or an await on something long-lived.

Close during a call

steam.close() stops the pump and rejects everything still in flight with:

steamwand: dispatch stopped while call was in flight

Await your workshop operations before closing. In tests, close in afterAll, not in the test body.

Other dispatch failures come back as SteamApiCallError, which carries the callbackId it was waiting for:

  • Steam returned an invalid API call handle: the handle was 0, so the flat call never started. Check the arguments that produced it.
  • SteamAPI_ManualDispatch_GetAPICallResult failed: Steam had no result for that handle, usually a handle awaited twice.
  • Steam reported an IO failure for this API call: Steam set its IO failure flag. Retry.

Workshop upload rejected

createItem() and submitUpdate() both return legalAgreementRequired. When it is true, the account has not accepted the Steam Workshop legal agreement and the item stays invisible on the workshop page until it does:

const { fileId, legalAgreementRequired } = await steam.workshop.createItem();
if (legalAgreementRequired) {
  console.warn('this account must still accept the Steam Workshop legal agreement');
}

That flag is not an error. A hard rejection arrives as SteamResultError:

SubmitItemUpdate failed: k_EResultAccessDenied

SteamResultError has operation (CreateItem, SubmitItemUpdate, DeleteItem, SendQueryUGCRequest) and the numeric result. Common ones:

  • k_EResultAccessDenied: the signed-in account does not own the item, or the app id in the update does not match the item's app id.
  • k_EResultLimitExceeded: item or upload size limit hit.
  • k_EResultFileNotFound: the item was deleted, or the file id is wrong.
  • k_EResultTimeout: Steam did not answer in time. Retry.

Use eResultName(n) to turn any raw EResult number into its name.

A setter returned false

Every submitUpdate setter is checked, and a false return becomes:

steamwand: SetItemUpdateLanguage returned false (invalid handle or argument?)

The named function tells you which field was refused. Causes: a title over Steam's length limit, an empty tag, a visibility outside 0 to 3, or an unknown language code. Language codes are Steam API codes (german, schinese, brazilian), not locale ids like de-DE.

Missing content or preview path

The native layer aborts the process on a missing path, so the workshop layer checks first:

steamwand: content folder does not exist: C:/mods/my-mod
steamwand: preview image does not exist: C:/mods/my-mod/preview.png

Both paths must be absolute and must exist when submitUpdate runs. contentPath is a folder, previewPath is a file.

Unknown callback struct

steam.on(name, listener) looks the name up in the generated callback table:

steamwand: unknown callback struct 'ItemInstaled_t'

Check the spelling against flat.callbackId, which lists every name the build knows:

import { flat } from 'steamwand.js';
console.log(Object.keys(flat.callbackId).filter((n) => n.includes('Item')));

191 of the SDK's 196 callback structs are registered. The missing ones are the networking callbacks that embed a union: see the next section.

No generated layout

steamwand: no generated layout for struct SteamNetworkingIdentity

Ten structs get no layout on purpose, because steam_api.json cannot express C unions and a guessed layout would decode garbage: SteamNetworkingIdentity, SteamNetworkingIPAddr, SteamNetworkingMessage_t, SteamInputActionEvent_t, and the six structs that embed them (SteamNetConnectionInfo_t, SteamDatagramGameCoordinatorServerLogin, SteamNetworkingMessagesSessionRequest_t, SteamNetworkingMessagesSessionFailed_t, SteamNetConnectionStatusChangedCallback_t, SteamNetworkingFakeIPResult_t). There is no workaround inside steamwand: decode those bytes yourself from the raw buffer. Regenerating has the exclusion rules.

Buffer too small

steamwand: buffer too small for struct (16 < 24)

decodeStruct refuses to read past the end of the buffer. Size the buffer from the layout rather than from a constant:

import { flat, decodeStruct } from 'steamwand.js';

const layout = flat.layoutOf('SteamUGCDetails_t');
const buf = Buffer.alloc(layout.size);

Windows and POSIX layouts differ (pack(8) versus pack(4)), so a hard-coded size that works on Windows is wrong on Linux. layoutOf() picks the right one for process.platform.

Library will not load

init() loads the redistributable bundled in runtime/<platform>/: steam_api64.dll, libsteam_api.so, or libsteam_api.dylib.

  • steamwand: unsupported platform android means process.platform is not win32, linux, or darwin. Only those three are wired up.
  • 32-bit Node cannot load the 64-bit redistributable. Use x64 Node.
  • On Windows a missing Visual C++ redistributable makes koffi.load fail. Install it, then retry.
  • A bundler that rewrites __dirname breaks defaultLibPath(). Copy runtime/ next to your output and pass init({ libPath: '/abs/path/steam_api64.dll' }).

Still stuck

pnpm smoke runs 30 read-only checks over 10 interfaces against the live client, one printed line each. It changes nothing, and it separates "steamwand is broken" from "this one call is wrong". To hand-drive a single call with real arguments, use the workbench: see Development.

Clone this wiki locally