-
Notifications
You must be signed in to change notification settings - Fork 0
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.
| 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() 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.
There are two ways to say which app you are:
-
init({ appId: 480 })setsprocess.env.SteamAppIdandprocess.env.SteamGameIdbefore the native init runs. - Omit
appIdand Steam readssteam_appid.txtfrom the working directory. In that casesteam.appIdfalls back toNumber(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.
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 aftersteam.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 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
nullinstead of an allocatedBuffer. - A buffer smaller than the size argument you passed next to it, for example
Buffer.alloc(64)with a length of260. - A
biginthandle passed as anumber, 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.
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. Usesteam.workshop.createItem(), or await the handle yourself withsteam.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().
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 exitsIf 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.
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 was0, 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.
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.
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.
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.
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.
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.
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.
init() loads the redistributable bundled in runtime/<platform>/:
steam_api64.dll, libsteam_api.so, or libsteam_api.dylib.
-
steamwand: unsupported platform androidmeansprocess.platformis notwin32,linux, ordarwin. 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.loadfail. Install it, then retry. - A bundler that rewrites
__dirnamebreaksdefaultLibPath(). Copyruntime/next to your output and passinit({ libPath: '/abs/path/steam_api64.dll' }).
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.