233 lines
9.3 KiB
JavaScript
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 });
|
|
}
|