-
Notifications
You must be signed in to change notification settings - Fork 0
Recipes
Working snippets for the tasks a mod tool actually performs. Every block is a
complete file: copy it, change the app id and the paths, run it with tsx. The
Steam client must be running and logged in. steam.close() stops the dispatch
pump and shuts the API down, so call it before the process ends.
createItem gives you an empty item, submitUpdate fills it. Upload it as
private first so a half finished mod never appears in the public listing, then
flip the visibility when you are done. onProgress polls GetItemUpdateProgress
every 500 ms by default and p.status is an EItemUpdateStatus member. Keep the
returned fileId, a bigint: it is the only handle to the item from now on.
import { init, flat, type UpdateProgress } from 'steamwand.js';
const VISIBILITY = flat.ERemoteStoragePublishedFileVisibility;
const onProgress = (p: UpdateProgress) => {
const pct = p.bytesTotal > 0n ? Number((p.bytesProcessed * 100n) / p.bytesTotal) : 0;
console.log(`status ${p.status} ${pct}%`);
};
async function main() {
const steam = init({ appId: 480 });
try {
const created = await steam.workshop.createItem();
if (created.legalAgreementRequired) console.warn('accept the Workshop legal agreement first');
await steam.workshop.submitUpdate(
created.fileId,
{
title: 'Custom Name Lists',
description: 'Adds name lists for every culture.',
contentPath: 'C:/mods/name-lists',
previewPath: 'C:/mods/name-lists/preview.png',
tags: ['gameplay', 'localization'],
visibility: VISIBILITY.k_ERemoteStoragePublishedFileVisibilityPrivate,
changeNote: 'first upload',
},
{ onProgress, progressIntervalMs: 250 },
);
console.log('published as', created.fileId.toString());
} finally {
steam.close();
}
}
void main();submitUpdate only writes the fields you pass, so a content-only update leaves
title, description and tags alone. Do not pass contentPath when you only change
text, because it re-uploads the whole folder. A non-OK result throws
SteamResultError, which carries the operation and the raw EResult.
import { init, SteamResultError, eResultName } from 'steamwand.js';
const FILE_ID = 3786319531n;
async function main() {
const steam = init({ appId: 480 });
try {
// Text only. The uploaded content is untouched.
await steam.workshop.submitUpdate(FILE_ID, {
description: 'Adds name lists for every culture, now including Iberia.',
changeNote: '1.4.0: Iberian name lists',
});
// Content only, later.
await steam.workshop.submitUpdate(FILE_ID, {
contentPath: 'C:/mods/name-lists',
changeNote: '1.4.1: fixed a typo in the Castilian list',
});
} catch (err) {
if (err instanceof SteamResultError) console.error(err.operation, eResultName(err.result));
else throw err;
} finally {
steam.close();
}
}
void main();Steam keeps one title and one description per language, and an update handle
writes to exactly one language. A translated item therefore needs one
submitUpdate per language, run in sequence, never in parallel. Submit the
default text first, because that is what Steam shows to anyone whose client
language has no translation.
import { init } from 'steamwand.js';
const FILE_ID = 3786319531n;
const translations: Record<string, { title: string; description: string }> = {
german: { title: 'Eigene Namenslisten', description: 'Namenslisten fuer jede Kultur.' },
french: { title: 'Listes de noms', description: 'Des listes de noms pour chaque culture.' },
schinese: { title: '自定义名称列表', description: '为每种文化添加名称列表。' },
};
async function main() {
const steam = init({ appId: 480 });
try {
// Default (English) text first.
await steam.workshop.submitUpdate(FILE_ID, {
title: 'Custom Name Lists',
description: 'Adds name lists for every culture.',
changeNote: 'text update',
});
for (const [language, text] of Object.entries(translations)) {
await steam.workshop.submitUpdate(FILE_ID, { language, ...text });
console.log('wrote', language);
}
// Read one back to confirm.
const de = await steam.workshop.getItem(FILE_ID, { language: 'german' });
console.log(de?.title, '|', de?.description);
} finally {
steam.close();
}
}
void main();language takes Steam's API language code (german, schinese, brazilian),
not the display name and not an ISO code. See
Workshop.
getUserItems takes a 1-based page and a 32-bit account id, which is the lower
half of the Steam id. steam.accountId() gives you the local user's. Each page
holds at most flat.kNumUGCResultsPerPage (50) items, and totalResults counts
the whole list, so stop when you have collected that many or when a page comes
back empty.
import { init, flat, type WorkshopItem } from 'steamwand.js';
async function main() {
const steam = init({ appId: 480 });
try {
const all: WorkshopItem[] = [];
let total = 0;
for (let page = 1; ; page++) {
const result = await steam.workshop.getUserItems(page, steam.accountId(), {
sortOrder: flat.EUserUGCListSortOrder.k_EUserUGCListSortOrder_CreationOrderDesc,
});
total = result.totalResults;
all.push(...result.items);
if (result.items.length === 0 || all.length >= total) break;
}
console.log(`${all.length} of ${total} items`);
for (const item of all) {
const updated = new Date(item.timeUpdated * 1000).toISOString().slice(0, 10);
console.log(item.fileId.toString(), updated, item.title, item.statistics.numSubscriptions);
}
} finally {
steam.close();
}
}
void main();Change listType to read another list, for example
flat.EUserUGCList.k_EUserUGCList_Subscribed or k_EUserUGCList_Favorited.
getItem queries by published file id, and the file id is global. A tool
initialized on Spacewar (480) can therefore read a Crusader Kings III item
without owning or running that game. longDescription: true gives the full
description instead of the truncated preview text, and language picks which
translation you get back.
import { init } from 'steamwand.js';
const CK3_ITEM = 3786319531n;
async function main() {
const steam = init({ appId: 480 });
try {
const item = await steam.workshop.getItem(CK3_ITEM, {
language: 'japanese',
longDescription: true,
});
if (!item) return console.log('no such item');
console.log(item.title, 'in app', item.consumerAppId);
console.log('owner', item.ownerSteamId.toString());
console.log('preview', item.previewUrl);
console.log('subscriptions', item.statistics.numSubscriptions ?? 0n);
} finally {
steam.close();
}
}
void main();For a user's items from another app, pass appId to getUserItems. It is used
as both the creator app id and the consumer app id of the query.
listSubscribed is a local read, so it is cheap to call at every startup.
getState says whether the content is on disk and whether Steam has a newer
version, and getInstallInfo gives the absolute content path. An item that is
subscribed but not installed yet is one Steam is still downloading; download
forces it now and resolves once the files are there.
import { init } from 'steamwand.js';
async function main() {
const steam = init({ appId: 480 });
try {
const mods: string[] = [];
for (const fileId of steam.workshop.listSubscribed()) {
const state = steam.workshop.getState(fileId);
if (!state.installed || state.needsUpdate) {
await steam.workshop.download(fileId, {
onProgress: (p) => console.log(fileId, `${p.bytesDownloaded} / ${p.bytesTotal}`),
});
}
const info = steam.workshop.getInstallInfo(fileId);
if (info) mods.push(info.path);
}
console.log('mod folders', mods);
await steam.workshop.startPlaytimeTracking(steam.workshop.listSubscribed());
} finally {
steam.close();
}
}
void main();onInstalled fires for downloads the Steam client runs on its own, which is how
a running game notices that a subscribed mod just updated. See
Workshop.
browse is the cursor-paged query behind a mod browser. Rank by trend, votes,
or date, filter by tags and text, and hand the nextCursor back for the next
page. subscribe is one call; after it the item behaves like any other
subscribed mod in the recipe above.
import { init, flat } from 'steamwand.js';
async function main() {
const steam = init({ appId: 480 });
try {
const page = await steam.workshop.browse({
queryType: flat.EUGCQuery.k_EUGCQuery_RankedByTrend,
trendDays: 7,
searchText: 'map',
requiredTags: ['gameplay'],
});
for (const item of page.items) {
console.log(item.title, item.statistics.numSubscriptions ?? 0n, item.previewUrl);
}
console.log(`${page.items.length} of ${page.totalResults}, next cursor ${page.nextCursor}`);
if (page.items[0]) await steam.workshop.subscribe(page.items[0].fileId);
} finally {
steam.close();
}
}
void main();SetAchievement writes to the local cache only. Nothing reaches Steam, and no
toast appears, until StoreStats succeeds. Both return false when the
achievement name is not defined for this app id. GetAchievement reports the
current state through a one-byte out param, and ClearAchievement plus
StoreStats puts it back while you test.
import { init } from 'steamwand.js';
const ACHIEVEMENT = 'ACH_WIN_ONE_GAME';
function main() {
const steam = init({ appId: 480 });
try {
const achieved = Buffer.alloc(1);
if (steam.userStats.GetAchievement(ACHIEVEMENT, achieved)) {
console.log('already unlocked?', achieved[0] === 1);
}
if (!steam.userStats.SetAchievement(ACHIEVEMENT)) {
return console.error('unknown achievement name for this app id');
}
if (!steam.userStats.StoreStats()) return console.error('StoreStats failed');
console.log('unlocked', ACHIEVEMENT);
} finally {
steam.close();
}
}
main();steam.dlc answers the ownership and install questions per DLC and can start an
install that resolves once the files are there. steam.system carries the facts
about the running app: where it is installed, which build it is, which beta
branch is active.
import { init } from 'steamwand.js';
async function main() {
const steam = init({ appId: 480 });
try {
console.log('installed at', steam.system.installDir(), 'build', steam.system.buildId());
console.log('beta', steam.system.currentBeta() ?? 'default', 'language', steam.system.gameLanguage());
for (const dlc of steam.dlc.listDlc()) {
const owned = steam.dlc.isOwned(dlc.appId);
console.log(dlc.appId, dlc.name, owned ? 'owned' : 'not owned', steam.dlc.isInstalled(dlc.appId));
if (owned && !steam.dlc.isInstalled(dlc.appId)) await steam.dlc.install(dlc.appId);
}
} finally {
steam.close();
}
}
void main();listDlc reports the DLC of the app id the process was initialized with, so run
this under the app id you care about.
An FFI mistake, a wrong buffer size or a bad handle, faults inside the native library and takes the whole process down. There is no exception to catch. If the host must survive (a VS Code extension, an editor plugin, a build server), run steamwand in a forked child and treat a non-zero exit as a failed job. This is how the CK3 modding toolkit uses it. The child does one job and exits:
// steam-worker.ts
import { init } from 'steamwand.js';
type Job = { fileId: string; title: string; contentPath: string };
process.on('message', async (job: Job) => {
const steam = init({ appId: 1158310 });
try {
await steam.workshop.submitUpdate(BigInt(job.fileId), {
title: job.title,
contentPath: job.contentPath,
changeNote: 'published from the toolkit',
});
process.send?.({ ok: true });
} catch (err) {
process.send?.({ ok: false, error: (err as Error).message });
} finally {
steam.close();
process.exit(0);
}
});The parent never loads steamwand at all:
// host.ts
import { fork } from 'node:child_process';
function publish(job: { fileId: string; title: string; contentPath: string }) {
return new Promise<void>((resolve, reject) => {
const child = fork(require.resolve('./steam-worker'));
let settled = false;
child.on('message', (msg: { ok: boolean; error?: string }) => {
settled = true;
if (msg.ok) resolve();
else reject(new Error(msg.error));
child.kill();
});
child.on('exit', (code) => {
if (!settled) reject(new Error(`steam worker died (exit code ${code})`));
});
child.send(job);
});
}
publish({ fileId: '3786319531', title: 'Names', contentPath: 'C:/mods/name-lists' })
.then(() => console.log('published'))
.catch((err) => console.error(err.message));Send bigint file ids across the process boundary as strings, because the IPC
channel uses JSON, which cannot carry a bigint. Convert back with BigInt(...)
in the child. Keep the child short-lived, one job per fork, so a fault can only
lose that job.