From 11b735d5786331d2e1020b27233ec60ebb36c6ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emilio=20Castej=C3=B3n?= <107069576+ect2000@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:21:20 +0100 Subject: [PATCH 1/2] fix: escape user strings in SDK guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Emilio Castejón <107069576+ect2000@users.noreply.github.com> --- src/tools/__tests__/sdk-guides.test.ts | 84 ++++++++++++++++++++++++++ src/tools/createLobby.ts | 7 ++- src/tools/uploadWorkshopItem.ts | 17 +++--- src/utils/string-literal.ts | 19 ++++++ 4 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 src/tools/__tests__/sdk-guides.test.ts create mode 100644 src/utils/string-literal.ts diff --git a/src/tools/__tests__/sdk-guides.test.ts b/src/tools/__tests__/sdk-guides.test.ts new file mode 100644 index 0000000..939533c --- /dev/null +++ b/src/tools/__tests__/sdk-guides.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { register as registerLobby } from "../createLobby.js"; +import { register as registerWorkshop } from "../uploadWorkshopItem.js"; + +async function callGuide(name: string, args: Record) { + const server = new McpServer({ name: "sdk-guide-test", version: "1.0.0" }); + registerLobby(server); + registerWorkshop(server); + const client = new Client({ name: "sdk-guide-client", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + try { + const result = await client.callTool({ name, arguments: args }); + expect(result.isError).not.toBe(true); + const content = result.content as Array<{ type: string; text: string }>; + return JSON.parse(content[0].text) as Record; + } finally { + await client.close(); + await server.close(); + } +} + +describe("SDK guides", () => { + it("escapes Workshop text, Windows paths and tags in every generated language", async () => { + const title = 'A "quoted" title\nnext line'; + const description = 'Line one\r\nLine two\twith "quotes"'; + const content_path = "C:\\mods\\new"; + const preview_path = 'C:\\images\\my "preview".png'; + const tag = 'tag"\\value'; + const guide = await callGuide("steam_uploadWorkshopItem", { + appid: 480, title, description, content_path, preview_path, tags: [tag], + }); + + expect(guide.title).toBe(title); + expect(guide.description).toBe(description); + expect(guide.content_path).toBe(content_path); + expect(guide.preview_path).toBe(preview_path); + expect(guide.tags).toEqual([tag]); + for (const language of ["cpp", "gdscript"]) { + const code = guide[language] as string; + expect(code).toContain('A \\"quoted\\" title'); + expect(code).toContain('C:\\\\mods\\\\new'); + expect(code).toContain('C:\\\\images\\\\my \\"preview\\".png'); + expect(code).not.toContain(title); + } + expect(guide.cpp).toContain('tag\\"\\\\value'); + expect(guide.cpp).toContain('Line one\\015\\012Line two\\011with \\"quotes\\"'); + expect(guide.gdscript).toContain('title\\nnext line'); + }); + + it("escapes lobby metadata keys and values without changing metadata", async () => { + const metadata = { 'map"\\name': 'C:\\maps\\new\n"arena"' }; + const guide = await callGuide("steam_createLobby", { + type: "public", max_members: 4, metadata, + }); + + expect(guide.metadata).toEqual(metadata); + for (const language of ["cpp", "csharp", "gdscript"]) { + const code = guide[language] as string; + expect(code).toContain('"map\\"\\\\name"'); + expect(code).toContain('C:\\\\maps\\\\new'); + expect(code).toContain('\\"arena\\"'); + expect(code).not.toContain(metadata['map"\\name']); + } + expect(guide.cpp).toContain('new\\012\\"arena\\"'); + expect(guide.csharp).toContain('new\\n\\"arena\\"'); + expect(guide.gdscript).toContain('new\\n\\"arena\\"'); + }); + + it("retains ordinary defaults and empty strings in guides", async () => { + const workshop = await callGuide("steam_uploadWorkshopItem", { appid: 480, title: "Example" }); + expect(workshop.cpp).toContain('SetItemTitle(handle, "Example")'); + expect(workshop.cpp).toContain('SetItemContent(handle, "/path/to/content")'); + expect(workshop.gdscript).toContain('"res://workshop_content"'); + const lobby = await callGuide("steam_createLobby", { + type: "private", max_members: 2, metadata: { empty: "" }, + }); + expect(lobby.csharp).toContain('SetLobbyData(lobbyId, "empty", "")'); + }); +}); diff --git a/src/tools/createLobby.ts b/src/tools/createLobby.ts index c619858..e5e160b 100644 --- a/src/tools/createLobby.ts +++ b/src/tools/createLobby.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { escapeStringLiteral } from "../utils/string-literal.js"; const inputSchema = { type: z @@ -37,19 +38,19 @@ export function register(server: McpServer): void { const metaCpp = metaEntries .map( ([k, v]) => - ` SteamMatchmaking()->SetLobbyData(lobbyId, "${k}", "${v}");`, + ` SteamMatchmaking()->SetLobbyData(lobbyId, ${escapeStringLiteral(k, "cpp")}, ${escapeStringLiteral(v, "cpp")});`, ) .join("\n"); const metaCs = metaEntries .map( ([k, v]) => - ` SteamMatchmaking.SetLobbyData(lobbyId, "${k}", "${v}");`, + ` SteamMatchmaking.SetLobbyData(lobbyId, ${escapeStringLiteral(k, "csharp")}, ${escapeStringLiteral(v, "csharp")});`, ) .join("\n"); const metaGd = metaEntries .map( ([k, v]) => - ` Steam.setLobbyData(lobby_id, "${k}", "${v}")`, + ` Steam.setLobbyData(lobby_id, ${escapeStringLiteral(k, "gdscript")}, ${escapeStringLiteral(v, "gdscript")})`, ) .join("\n"); diff --git a/src/tools/uploadWorkshopItem.ts b/src/tools/uploadWorkshopItem.ts index 0552565..f05bd6b 100644 --- a/src/tools/uploadWorkshopItem.ts +++ b/src/tools/uploadWorkshopItem.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { escapeStringLiteral } from "../utils/string-literal.js"; const inputSchema = { appid: z @@ -74,17 +75,17 @@ export function register(server: McpServer): void { " PublishedFileId_t fileId = pResult->m_nPublishedFileId;", "", ` UGCUpdateHandle_t handle = SteamUGC()->StartItemUpdate(${appid}, fileId);`, - ` SteamUGC()->SetItemTitle(handle, "${title}");`, + ` SteamUGC()->SetItemTitle(handle, ${escapeStringLiteral(title, "cpp")});`, description - ? ` SteamUGC()->SetItemDescription(handle, "${description}");` + ? ` SteamUGC()->SetItemDescription(handle, ${escapeStringLiteral(description, "cpp")});` : " // SteamUGC()->SetItemDescription(handle, \"...\");", - ` SteamUGC()->SetItemContent(handle, "${content_path ?? "/path/to/content"}");`, - ` SteamUGC()->SetItemPreview(handle, "${preview_path ?? "/path/to/preview.jpg"}");`, + ` SteamUGC()->SetItemContent(handle, ${escapeStringLiteral(content_path ?? "/path/to/content", "cpp")});`, + ` SteamUGC()->SetItemPreview(handle, ${escapeStringLiteral(preview_path ?? "/path/to/preview.jpg", "cpp")});`, ` SteamUGC()->SetItemVisibility(handle, ${visEnum});`, ...(tags && tags.length > 0 ? [ " SteamParamStringArray_t tagArray;", - ` const char* tagStrings[] = { ${tags.map((t) => `"${t}"`).join(", ")} };`, + ` const char* tagStrings[] = { ${tags.map((t) => escapeStringLiteral(t, "cpp")).join(", ")} };`, ` tagArray.m_ppStrings = tagStrings;`, ` tagArray.m_nNumStrings = ${tags.length};`, " SteamUGC()->SetItemTags(handle, &tagArray);", @@ -108,9 +109,9 @@ export function register(server: McpServer): void { ' print("Failed to create item")', " return", " var handle = Steam.startItemUpdate(app_id, file_id)", - ` Steam.setItemTitle(handle, "${title}")`, - ` Steam.setItemContent(handle, "${content_path ?? "res://workshop_content"}")`, - ` Steam.setItemPreview(handle, "${preview_path ?? "res://preview.png"}")`, + ` Steam.setItemTitle(handle, ${escapeStringLiteral(title, "gdscript")})`, + ` Steam.setItemContent(handle, ${escapeStringLiteral(content_path ?? "res://workshop_content", "gdscript")})`, + ` Steam.setItemPreview(handle, ${escapeStringLiteral(preview_path ?? "res://preview.png", "gdscript")})`, ` Steam.setItemVisibility(handle, Steam.REMOTE_STORAGE_PUBLISHED_FILE_VISIBILITY_${vis.toUpperCase()})`, ' Steam.submitItemUpdate(handle, "Initial upload")', ].join("\n"), diff --git a/src/utils/string-literal.ts b/src/utils/string-literal.ts new file mode 100644 index 0000000..2ee6120 --- /dev/null +++ b/src/utils/string-literal.ts @@ -0,0 +1,19 @@ +type GuideLanguage = "cpp" | "csharp" | "gdscript"; + +/** Return a quoted source literal, not a JSON-encoded code fragment. */ +export function escapeStringLiteral(value: string, language: GuideLanguage): string { + if (language !== "cpp") { + return JSON.stringify(value) + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); + } + + // Fixed-width octal preserves UTF-8 bytes without consuming a following digit. + const escaped = Array.from(Buffer.from(value, "utf8"), (byte) => { + if (byte === 0x22) return '\\"'; + if (byte === 0x5c) return "\\\\"; + if (byte >= 0x20 && byte <= 0x7e) return String.fromCharCode(byte); + return `\\${byte.toString(8).padStart(3, "0")}`; + }).join(""); + return `"${escaped}"`; +} From 4801702c1637b1daf3884b21baa8848795186379 Mon Sep 17 00:00:00 2001 From: TM Hospitality Strategies Date: Sun, 25 Oct 2026 18:51:25 -0400 Subject: [PATCH 2/2] fix(sdk-guides): Steamworks.NET enum names, C# Workshop guide, input parity, registered call results - createLobby: C# uses ELobbyType.k_ELobbyType* (Steamworks.NET keeps the native names); C++/C# register their CallResult; GDScript connects the signal and renames the shadowing connect parameter to this_connect - uploadWorkshopItem: adds the promised Steamworks.NET C# block; GDScript sets description and tags; C++ registers CreateItem and SubmitItemUpdate call results and checks the Workshop legal agreement flag - Tests for each, on top of #51's escaping Fixes #54 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + src/tools/__tests__/sdk-guides.test.ts | 47 +++++++++ src/tools/createLobby.ts | 53 +++++++---- src/tools/uploadWorkshopItem.ts | 126 +++++++++++++++++++------ 4 files changed, 180 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ddf855..7057c3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- SDK guide tools escape titles, descriptions, paths, tags, and lobby metadata as proper string literals in C++, C#, and GDScript, so quotes, Windows backslashes, and line breaks no longer break the generated code. Contributed by @ect2000. (#36) +- SDK guides compile against their bindings and use every input. `steam_createLobby`'s C# uses Steamworks.NET's real enum names (`ELobbyType.k_ELobbyTypePublic`), and its GDScript callback no longer shadows `connect`. `steam_uploadWorkshopItem` now returns the C# (Steamworks.NET) block its description promised, and its GDScript sets the description and tags. The C++ and C# examples register their call results. (#54) - `steam_getReviews` and `steam_getAppReviewSummary` now call `IUserReviewsService/GetAppReviews`. Valve disabled the Store `/appreviews` endpoint on 2026-10-22, so both tools failed on every call. Both remain keyless. `steam_getReviews` adds a `funny` sort and accepts a comma-separated `language` list. `steam_getAppReviewSummary` now counts all purchase types, matching the store page total. (#27) - `steam_setAchievement` and `steam_clearAchievement` report success only when Steam returns EResult 1. A response with no `result` object used to read as success. Failures now include the EResult. (#52) - `steam_grantInventoryItem` sends the documented AddItem parameters: one `itemdefid[n]` per copy (`quantity` capped at 100), `notify` as `1`/`0`, and a `requestid` so Steam executes a grant at most once. The dry run generates a `requestid`; pass it to the live call. New optional `itempropsjson` input. (#28) diff --git a/src/tools/__tests__/sdk-guides.test.ts b/src/tools/__tests__/sdk-guides.test.ts index 939533c..fb62291 100644 --- a/src/tools/__tests__/sdk-guides.test.ts +++ b/src/tools/__tests__/sdk-guides.test.ts @@ -82,3 +82,50 @@ describe("SDK guides", () => { expect(lobby.csharp).toContain('SetLobbyData(lobbyId, "empty", "")'); }); }); + +describe("SDK guide correctness (#54)", () => { + it("lobby guide uses Steamworks.NET enum names, registers call results, and avoids shadowing connect", async () => { + const lobby = await callGuide("steam_createLobby", { type: "friends_only", max_members: 8 }); + expect(lobby.csharp).toContain("ELobbyType.k_ELobbyTypeFriendsOnly"); + expect(lobby.csharp).not.toMatch(/ELobbyType\.FriendsOnly\b/); + expect(lobby.csharp).toContain("CallResult.Create(OnLobbyCreated)"); + expect(lobby.csharp).toContain("m_LobbyCreated.Set(call);"); + expect(lobby.cpp).toContain("m_LobbyCreated.Set(call, this, &LobbyCreator::OnLobbyCreated);"); + expect(lobby.cpp).toContain("CCallResult m_LobbyCreated;"); + expect(lobby.gdscript).toContain("Steam.lobby_created.connect(_on_lobby_created)"); + expect(lobby.gdscript).toContain("this_connect: int"); + expect(lobby.gdscript).not.toMatch(/\(connect: int/); + }); + + it("Workshop guide returns every promised language and uses every input", async () => { + const guide = await callGuide("steam_uploadWorkshopItem", { + appid: 480, + title: "Map pack", + description: "Three maps", + tags: ["Maps", "Co-op"], + visibility: "unlisted", + }); + for (const language of ["cpp", "csharp", "gdscript"]) { + const code = guide[language] as string; + expect(code, language).toContain("Map pack"); + expect(code, language).toContain("Three maps"); + expect(code, language).toContain("Co-op"); + } + expect(guide.csharp).toContain("EWorkshopFileType.k_EWorkshopFileTypeCommunity"); + expect(guide.csharp).toContain( + "ERemoteStoragePublishedFileVisibility.k_ERemoteStoragePublishedFileVisibilityUnlisted", + ); + expect(guide.csharp).toContain('new List { "Maps", "Co-op" }'); + expect(guide.cpp).toContain("m_ItemCreated.Set(call, this, &WorkshopUploader::OnItemCreated);"); + expect(guide.cpp).toContain("m_ItemSubmitted.Set(submit, this, &WorkshopUploader::OnItemSubmitted);"); + expect(guide.gdscript).toContain('Steam.setItemTags(handle, ["Maps", "Co-op"])'); + expect(guide.gdscript).toContain('Steam.setItemDescription(handle, "Three maps")'); + }); + + it("omits optional calls when inputs are absent", async () => { + const guide = await callGuide("steam_uploadWorkshopItem", { appid: 480, title: "Solo" }); + expect(guide.csharp).not.toContain("SetItemTags"); + expect(guide.gdscript).not.toContain("setItemTags"); + expect(guide.gdscript).toContain('# Steam.setItemDescription(handle, "...")'); + }); +}); diff --git a/src/tools/createLobby.ts b/src/tools/createLobby.ts index 052228c..a2a3abb 100644 --- a/src/tools/createLobby.ts +++ b/src/tools/createLobby.ts @@ -67,39 +67,52 @@ export function register(server: McpServer): void { max_members, metadata: metadata ?? {}, cpp: [ - "// Include Steam headers", '#include "steam/steam_api.h"', "", - `// Create lobby (async — result via CCallResult)`, - `SteamAPICall_t call = SteamMatchmaking()->CreateLobby(${enumValue}, ${max_members});`, - "// Register callback for LobbyCreated_t", + "// Call SteamAPI_RunCallbacks() every frame so the call result fires.", + "class LobbyCreator {", + "public:", + " void Create() {", + ` SteamAPICall_t call = SteamMatchmaking()->CreateLobby(${enumValue}, ${max_members});`, + " m_LobbyCreated.Set(call, this, &LobbyCreator::OnLobbyCreated);", + " }", "", - "// In callback handler:", - "void OnLobbyCreated(LobbyCreated_t* pResult, bool bIOFailure) {", - " if (pResult->m_eResult != k_EResultOK) { /* handle error */ }", - " CSteamID lobbyId = pResult->m_ulSteamIDLobby;", - metaCpp, - "}", + "private:", + " void OnLobbyCreated(LobbyCreated_t* pResult, bool bIOFailure) {", + " if (bIOFailure || pResult->m_eResult != k_EResultOK) { /* handle error */ return; }", + " CSteamID lobbyId(pResult->m_ulSteamIDLobby);", + metaCpp.replace(/^ {2}/gm, " "), + " }", + "", + " CCallResult m_LobbyCreated;", + "};", ].join("\n"), csharp: [ - "// Using Steamworks.NET", - `var call = SteamMatchmaking.CreateLobby(ELobbyType.${enumValue.replace("k_ELobbyType", "")}, ${max_members});`, - "// Use CallResult to handle the result", + "// Using Steamworks.NET (keep a reference to the CallResult so it is not collected)", + "using Steamworks;", + "", + "private CallResult m_LobbyCreated;", + "", + "void CreateLobby() {", + " m_LobbyCreated = CallResult.Create(OnLobbyCreated);", + ` SteamAPICall_t call = SteamMatchmaking.CreateLobby(ELobbyType.${enumValue}, ${max_members});`, + " m_LobbyCreated.Set(call);", + "}", "", "void OnLobbyCreated(LobbyCreated_t result, bool bIOFailure) {", - " if (result.m_eResult != EResult.k_EResultOK) { /* handle error */ }", + " if (bIOFailure || result.m_eResult != EResult.k_EResultOK) { /* handle error */ return; }", " var lobbyId = new CSteamID(result.m_ulSteamIDLobby);", metaCs, "}", ].join("\n"), gdscript: [ - "# Using GodotSteam", - `var lobby_type = Steam.LOBBY_TYPE_${type.toUpperCase()}`, - `Steam.createLobby(lobby_type, ${max_members})`, - "# Connect to lobby_created signal", + "# Using GodotSteam (Godot 4)", + "func _ready() -> void:", + " Steam.lobby_created.connect(_on_lobby_created)", + ` Steam.createLobby(Steam.LOBBY_TYPE_${type.toUpperCase()}, ${max_members})`, "", - "func _on_lobby_created(connect: int, lobby_id: int) -> void:", - " if connect != 1:", + "func _on_lobby_created(this_connect: int, lobby_id: int) -> void:", + " if this_connect != 1:", " print(\"Failed to create lobby\")", " return", metaGd, diff --git a/src/tools/uploadWorkshopItem.ts b/src/tools/uploadWorkshopItem.ts index c5b0535..9ce88be 100644 --- a/src/tools/uploadWorkshopItem.ts +++ b/src/tools/uploadWorkshopItem.ts @@ -51,6 +51,11 @@ export function register(server: McpServer): void { }; const vis = visibility ?? "public"; const visEnum = visMap[vis]; + const tagList = tags ?? []; + const cppContent = escapeStringLiteral(content_path ?? "/path/to/content", "cpp"); + const cppPreview = escapeStringLiteral(preview_path ?? "/path/to/preview.jpg", "cpp"); + const csContent = escapeStringLiteral(content_path ?? "C:/path/to/content", "csharp"); + const csPreview = escapeStringLiteral(preview_path ?? "C:/path/to/preview.jpg", "csharp"); const guide = { note: "Workshop uploads are a two-step client-side SDK operation: CreateItem then SubmitItemUpdate. There is no HTTP endpoint for file uploads. Use the code below in a game client or upload tool.", @@ -71,55 +76,120 @@ export function register(server: McpServer): void { cpp: [ '#include "steam/steam_api.h"', "", - `// Step 1: Create the item`, - `SteamAPICall_t call = SteamUGC()->CreateItem(${appid}, k_EWorkshopFileTypeCommunity);`, - "// Handle CreateItemResult_t callback...", + "// Call SteamAPI_RunCallbacks() every frame so the call results fire.", + "class WorkshopUploader {", + "public:", + " void Upload() {", + ` SteamAPICall_t call = SteamUGC()->CreateItem(${appid}, k_EWorkshopFileTypeCommunity);`, + " m_ItemCreated.Set(call, this, &WorkshopUploader::OnItemCreated);", + " }", "", - "// Step 2-4: In the CreateItem callback:", - "void OnItemCreated(CreateItemResult_t* pResult, bool bIOFailure) {", - " if (pResult->m_eResult != k_EResultOK) { /* handle error */ }", - " PublishedFileId_t fileId = pResult->m_nPublishedFileId;", + "private:", + " void OnItemCreated(CreateItemResult_t* pResult, bool bIOFailure) {", + " if (bIOFailure || pResult->m_eResult != k_EResultOK) { /* handle error */ return; }", + " if (pResult->m_bUserNeedsToAcceptWorkshopLegalAgreement) { /* show the Workshop legal agreement */ }", "", - ` UGCUpdateHandle_t handle = SteamUGC()->StartItemUpdate(${appid}, fileId);`, - ` SteamUGC()->SetItemTitle(handle, ${escapeStringLiteral(title, "cpp")});`, + ` UGCUpdateHandle_t handle = SteamUGC()->StartItemUpdate(${appid}, pResult->m_nPublishedFileId);`, + ` SteamUGC()->SetItemTitle(handle, ${escapeStringLiteral(title, "cpp")});`, description - ? ` SteamUGC()->SetItemDescription(handle, ${escapeStringLiteral(description, "cpp")});` - : " // SteamUGC()->SetItemDescription(handle, \"...\");", - ` SteamUGC()->SetItemContent(handle, ${escapeStringLiteral(content_path ?? "/path/to/content", "cpp")});`, - ` SteamUGC()->SetItemPreview(handle, ${escapeStringLiteral(preview_path ?? "/path/to/preview.jpg", "cpp")});`, - ` SteamUGC()->SetItemVisibility(handle, ${visEnum});`, - ...(tags && tags.length > 0 + ? ` SteamUGC()->SetItemDescription(handle, ${escapeStringLiteral(description, "cpp")});` + : " // SteamUGC()->SetItemDescription(handle, \"...\");", + ` SteamUGC()->SetItemContent(handle, ${cppContent});`, + ` SteamUGC()->SetItemPreview(handle, ${cppPreview});`, + ` SteamUGC()->SetItemVisibility(handle, ${visEnum});`, + ...(tagList.length > 0 ? [ - " SteamParamStringArray_t tagArray;", - ` const char* tagStrings[] = { ${tags.map((t) => escapeStringLiteral(t, "cpp")).join(", ")} };`, - ` tagArray.m_ppStrings = tagStrings;`, - ` tagArray.m_nNumStrings = ${tags.length};`, - " SteamUGC()->SetItemTags(handle, &tagArray);", + ` const char* tagStrings[] = { ${tagList.map((t) => escapeStringLiteral(t, "cpp")).join(", ")} };`, + " SteamParamStringArray_t tagArray;", + " tagArray.m_ppStrings = tagStrings;", + ` tagArray.m_nNumStrings = ${tagList.length};`, + " SteamUGC()->SetItemTags(handle, &tagArray);", ] : []), "", - ' SteamUGC()->SubmitItemUpdate(handle, "Initial upload");', - " // Handle SubmitItemUpdateResult_t callback...", + ' SteamAPICall_t submit = SteamUGC()->SubmitItemUpdate(handle, "Initial upload");', + " m_ItemSubmitted.Set(submit, this, &WorkshopUploader::OnItemSubmitted);", + " }", + "", + " void OnItemSubmitted(SubmitItemUpdateResult_t* pResult, bool bIOFailure) {", + " if (bIOFailure || pResult->m_eResult != k_EResultOK) { /* handle error */ }", + " }", + "", + " CCallResult m_ItemCreated;", + " CCallResult m_ItemSubmitted;", + "};", + ].join("\n"), + csharp: [ + "// Using Steamworks.NET (keep references to the CallResults so they are not collected)", + "using System.Collections.Generic;", + "using Steamworks;", + "", + "private CallResult m_ItemCreated;", + "private CallResult m_ItemSubmitted;", + "", + "void Upload() {", + " m_ItemCreated = CallResult.Create(OnItemCreated);", + " m_ItemSubmitted = CallResult.Create(OnItemSubmitted);", + ` SteamAPICall_t call = SteamUGC.CreateItem(new AppId_t(${appid}), EWorkshopFileType.k_EWorkshopFileTypeCommunity);`, + " m_ItemCreated.Set(call);", + "}", + "", + "void OnItemCreated(CreateItemResult_t result, bool bIOFailure) {", + " if (bIOFailure || result.m_eResult != EResult.k_EResultOK) { /* handle error */ return; }", + " if (result.m_bUserNeedsToAcceptWorkshopLegalAgreement) { /* show the Workshop legal agreement */ }", + "", + ` UGCUpdateHandle_t handle = SteamUGC.StartItemUpdate(new AppId_t(${appid}), result.m_nPublishedFileId);`, + ` SteamUGC.SetItemTitle(handle, ${escapeStringLiteral(title, "csharp")});`, + description + ? ` SteamUGC.SetItemDescription(handle, ${escapeStringLiteral(description, "csharp")});` + : " // SteamUGC.SetItemDescription(handle, \"...\");", + ` SteamUGC.SetItemContent(handle, ${csContent});`, + ` SteamUGC.SetItemPreview(handle, ${csPreview});`, + ` SteamUGC.SetItemVisibility(handle, ERemoteStoragePublishedFileVisibility.${visEnum});`, + ...(tagList.length > 0 + ? [ + ` SteamUGC.SetItemTags(handle, new List { ${tagList.map((t) => escapeStringLiteral(t, "csharp")).join(", ")} });`, + ] + : []), + "", + ' m_ItemSubmitted.Set(SteamUGC.SubmitItemUpdate(handle, "Initial upload"));', + "}", + "", + "void OnItemSubmitted(SubmitItemUpdateResult_t result, bool bIOFailure) {", + " if (bIOFailure || result.m_eResult != EResult.k_EResultOK) { /* handle error */ }", "}", ].join("\n"), gdscript: [ - "# Using GodotSteam", - `var app_id = ${appid}`, + "# Using GodotSteam (Godot 4)", + `var app_id: int = ${appid}`, "", - "# Step 1: Create item", - "Steam.createItem(app_id, Steam.WORKSHOP_FILE_TYPE_COMMUNITY)", - "# Connect to item_created signal", + "func _ready() -> void:", + " Steam.item_created.connect(_on_item_created)", + " Steam.item_updated.connect(_on_item_updated)", + " Steam.createItem(app_id, Steam.WORKSHOP_FILE_TYPE_COMMUNITY)", "", "func _on_item_created(result: int, file_id: int, accept_tos: bool) -> void:", " if result != Steam.RESULT_OK:", ' print("Failed to create item")', " return", - " var handle = Steam.startItemUpdate(app_id, file_id)", + " if accept_tos:", + " pass # Show the Workshop legal agreement", + " var handle: int = Steam.startItemUpdate(app_id, file_id)", ` Steam.setItemTitle(handle, ${escapeStringLiteral(title, "gdscript")})`, + description + ? ` Steam.setItemDescription(handle, ${escapeStringLiteral(description, "gdscript")})` + : ' # Steam.setItemDescription(handle, "...")', ` Steam.setItemContent(handle, ${escapeStringLiteral(content_path ?? "res://workshop_content", "gdscript")})`, ` Steam.setItemPreview(handle, ${escapeStringLiteral(preview_path ?? "res://preview.png", "gdscript")})`, ` Steam.setItemVisibility(handle, Steam.REMOTE_STORAGE_PUBLISHED_FILE_VISIBILITY_${vis.toUpperCase()})`, + ...(tagList.length > 0 + ? [` Steam.setItemTags(handle, [${tagList.map((t) => escapeStringLiteral(t, "gdscript")).join(", ")}])`] + : []), ' Steam.submitItemUpdate(handle, "Initial upload")', + "", + "func _on_item_updated(result: int, accept_tos: bool) -> void:", + " if result != Steam.RESULT_OK:", + ' print("Failed to submit item")', ].join("\n"), references: [ "https://partner.steamgames.com/doc/api/ISteamUGC#CreateItem",