Files
healer-man/scripts/runewaker-pipeline/refresh-paperdoll-appearances.mjs
T
2026-08-14 15:56:39 -04:00

233 lines
9.3 KiB
JavaScript

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 });
}