import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pipelineDirectory, projectPath, projectRoot, resolveProject, sourceRootFor, } from "./lib/recipe.mjs"; const recipeDirectory = path.join(pipelineDirectory, "recipes"); const powershellScript = path.join(pipelineDirectory, "refresh-paperdoll-appearances.ps1"); const outputFile = path.join(pipelineDirectory, "snapshots", "runewaker-paperdoll-appearances.json"); function run(executable, argumentsList, label) { return new Promise((resolve, reject) => { console.log("\n[runewaker-paperdolls] " + label); const child = spawn(executable, argumentsList, { cwd: projectRoot, stdio: "inherit", windowsHide: true, }); child.once("error", reject); child.once("exit", (code) => { if (code === 0) resolve(); else reject(new Error(label + " exited with code " + code + ".")); }); }); } async function requireFile(file) { try { if (!(await stat(file)).isFile()) throw new Error(); } catch { throw new Error("Required RuneWaker paperdoll source is missing: " + file); } } async function sha256(file) { return createHash("sha256").update(await readFile(file)).digest("hex"); } function stableHash(value) { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); } function clean(value) { return String(value ?? "").replaceAll(String.fromCharCode(0), "").trim(); } function normalizeModelPath(value) { return clean(value).replaceAll("\\", "/").replace(/^\/+/, "").replace(/^resource\//i, "").toLowerCase(); } function unsignedColor(value) { return Number(value ?? 0) >>> 0; } const recipeFiles = (await readdir(recipeDirectory)) .filter((file) => file.endsWith("-population.json")) .sort() .map((file) => path.join(recipeDirectory, file)); if (!recipeFiles.length) throw new Error("No RuneWaker population recipes were found."); const recipes = await Promise.all(recipeFiles.map(async (file) => ({ file, recipe: JSON.parse(await readFile(file, "utf8")), }))); const paperdollProbe = new Map(); const requested = new Map(); const actorFiles = new Map(); for (const { recipe } of recipes) { const sourceRoot = sourceRootFor(recipe); const resourceRoot = path.join(sourceRoot, recipe.source.resourceRoot); const actors = new Map(recipe.actors.map((actor) => [actor.id, actor])); for (const template of recipe.templates.filter((entry) => ( ["combat", "boss"].includes(entry.classification) && entry.actor && Number(entry.source?.imageId) > 0 ))) { const actor = actors.get(template.actor); if (!actor) throw new Error(recipe.dungeonId + " template " + template.id + " references an absent actor."); const sourceModel = normalizeModelPath(actor.sourceModel); const actorFile = path.join(resourceRoot, ...sourceModel.split("/")); const actorFileKey = actorFile.toLowerCase(); let isPaperdoll = paperdollProbe.get(actorFileKey); if (isPaperdoll === undefined) { await requireFile(actorFile); isPaperdoll = (await readFile(actorFile)).toString("latin1").toLowerCase().includes("paperdoll.ros"); paperdollProbe.set(actorFileKey, isPaperdoll); if (isPaperdoll) actorFiles.set(sourceModel, actorFile); } if (!isPaperdoll) continue; const imageId = Number(template.source.imageId); const key = sourceModel + "\0" + imageId; const entry = requested.get(key) ?? { sourceModel, imageId, usage: [], }; entry.usage.push({ dungeonId: recipe.dungeonId, actorId: actor.id, templateId: template.id, name: template.name, }); requested.set(key, entry); } } const requests = [...requested.values()].sort((left, right) => ( left.sourceModel.localeCompare(right.sourceModel) || left.imageId - right.imageId )); if (!requests.length) throw new Error("No paperdoll-backed dungeon appearances were discovered."); const firstRecipe = recipes[0].recipe; const objectBackup = resolveProject(firstRecipe.source.backups.objects); const server = process.env[firstRecipe.source.serverEnvironmentVariable] ?? firstRecipe.source.serverDefault; const sourceRoot = sourceRootFor(firstRecipe); const imageCatalog = path.join(sourceRoot, "Resource", "data", "imageobject.db"); await Promise.all([ requireFile(objectBackup), requireFile(imageCatalog), requireFile(powershellScript), ]); const temporary = await mkdtemp(path.join(os.tmpdir(), "healerman-rw-paperdolls-")); const requestFile = path.join(temporary, "request.json"); const rawFile = path.join(temporary, "raw.json"); try { await writeFile(requestFile, JSON.stringify({ imageIds: [...new Set(requests.map((entry) => entry.imageId))].sort((a, b) => a - b), }, null, 2) + "\n", "utf8"); await run( "powershell.exe", [ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", powershellScript, "-ServerInstance", server, "-ObjectBackup", objectBackup, "-DatabaseSuffix", "paperdoll_" + process.pid, "-RequestFile", requestFile, "-OutputFile", rawFile, ], "restore ObjectEdit read-only, export appearance rows, and drop the temporary database", ); const raw = JSON.parse((await readFile(rawFile, "utf8")).replace(/^\uFEFF/, "")); const componentColumns = { head: ["paperdollfacename", "paperdollfacemaincolor", "paperdollfaceoffcolor"], hair: ["paperdollhairname", "paperdollhairmaincolor", "paperdollhairoffcolor"], helmet: ["paperdollheadname", "paperdollheadmaincolor", "paperdollheadoffcolor"], shoulder: ["paperdollshoudername", "paperdollshoudermaincolor", "paperdollshouderoffcolor"], torso: ["paperdollclothesname", "paperdollclothesmaincolor", "paperdollclothesoffcolor"], hand: ["paperdollglovesname", "paperdollglovesmaincolor", "paperdollglovesoffcolor"], belt: ["paperdollbeltname", "paperdollbeltmaincolor", "paperdollbeltoffcolor"], leg: ["paperdollpantsname", "paperdollpantsmaincolor", "paperdollpantsoffcolor"], foot: ["paperdollshoesname", "paperdollshoesmaincolor", "paperdollshoesoffcolor"], back: ["paperdollbackname", "paperdollbackmaincolor", "paperdollbackoffcolor"], }; const appearances = requests.map((request) => { const candidates = raw.rows.filter((row) => ( [Number(row.guid), Number(row.imageid)].includes(request.imageId) && normalizeModelPath(row.actworld) === request.sourceModel )).sort((left, right) => ( Number(Number(right.guid) === request.imageId) - Number(Number(left.guid) === request.imageId) || Number(left.guid) - Number(right.guid) )); const row = candidates[0]; if (!row) throw new Error("ImageObjectDB is missing " + request.sourceModel + " image " + request.imageId + "."); const components = Object.fromEntries(Object.entries(componentColumns).map(([part, columns]) => [ part, { name: clean(row[columns[0]]), mainColor: unsignedColor(row[columns[1]]), offColor: unsignedColor(row[columns[2]]), }, ])); const skinColor = unsignedColor(row.skincolor); const hairColor = unsignedColor(row.haircolor); const requiresColorLayerBake = Boolean(skinColor || hairColor || Object.values(components).some((component) => ( component.mainColor || component.offColor ))); return { sourceModel: request.sourceModel, imageId: request.imageId, sourceRow: { guid: Number(row.guid), imageId: Number(row.imageid) }, skinColor, hairColor, components, requiresColorLayerBake, usage: request.usage.sort((left, right) => ( left.dungeonId.localeCompare(right.dungeonId) || left.templateId - right.templateId )), }; }); const sourceHashes = { objectBackup: { [projectPath(objectBackup)]: await sha256(objectBackup) }, imageCatalog: { [projectPath(imageCatalog)]: await sha256(imageCatalog) }, actorRos: Object.fromEntries(await Promise.all([...actorFiles.entries()] .sort(([left], [right]) => left.localeCompare(right)) .map(async ([model, file]) => [model, await sha256(file)]))), recipes: Object.fromEntries(await Promise.all(recipes.map(async ({ file }) => [ projectPath(file), await sha256(file), ]))), }; const content = { schemaVersion: 1, status: "portable", queryVersion: Number(raw.queryVersion), safety: { runtimeSqlDependency: false, forensicDatabaseReadOnly: true, sourceBackupsModified: false, }, appearanceCount: appearances.length, sourceModelCount: new Set(appearances.map((appearance) => appearance.sourceModel)).size, colorLayerBakeCount: appearances.filter((appearance) => appearance.requiresColorLayerBake).length, sourceHashes, appearances, }; const snapshot = { ...content, contentSha256: stableHash(content) }; await mkdir(path.dirname(outputFile), { recursive: true }); await writeFile(outputFile, JSON.stringify(snapshot, null, 2) + "\n", "utf8"); console.log("\n[runewaker-paperdolls] wrote " + projectPath(outputFile) + " (" + appearances.length + " appearances across " + content.sourceModelCount + " actor families).\n" + "[runewaker-paperdolls] SQL Server is not used by the game or normal builds."); } finally { await rm(temporary, { recursive: true, force: true }); }