Files
2026-08-14 15:56:39 -04:00

445 lines
21 KiB
JavaScript

import { createHash } from "node:crypto";
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import {
camelCase,
optionValue,
pipelineDirectory,
projectPath,
projectRoot,
readJson,
slugify,
} from "./lib/recipe.mjs";
import { inspectWdb } from "./lib/wdb.mjs";
const argv = process.argv.slice(2);
const rawFile = path.resolve(projectRoot, optionValue(argv, "--raw", "runewaker-export-work/all-instances-forensic-raw.json"));
const sourceRoot = path.resolve(process.env.RUNEWAKER_ROOT ?? path.join(projectRoot, "../rom-pvt-server-backups/Runewaker"));
const resourceRoot = path.join(sourceRoot, "Resource");
const configFile = path.join(sourceRoot, "Tools", "dungeon_config.json");
const entryScriptFile = path.join(resourceRoot, "luascript", "03246.lua");
const overridesFile = path.join(pipelineDirectory, "discovery-overrides.json");
const recipesDirectory = path.join(pipelineDirectory, "recipes");
const snapshotsDirectory = path.join(pipelineDirectory, "snapshots");
const SOURCE_ANCHOR_SUPPORT_DUNGEONS = new Set([
"mystic_altar",
"treasure_trove",
"the_origin",
"demon_stronghold",
"raksha_temple",
"aeternal_circle",
]);
const clean = (value) => String(value ?? "").replaceAll(String.fromCharCode(0), "").trim();
const normalizeModelPath = (value) => clean(value)
.replaceAll(String.fromCharCode(92), "/")
.replace(/^\/+/, "")
.replace(/^resource\//i, "")
.toLowerCase();
const stableHash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
const sha256 = async (file) => createHash("sha256").update(await readFile(file)).digest("hex");
function entrySpawns(source) {
const result = new Map();
const number = "(-?\\d+(?:\\.\\d+)?)";
const pattern = new RegExp(
"id=\\\"([^\\\"]+)\\\"[^\\n]*spawn=\\{\\s*x=" + number
+ ",\\s*y=" + number + ",\\s*z=" + number + ",\\s*dir=" + number,
"g",
);
for (const match of source.matchAll(pattern)) {
result.set(match[1], {
position: match.slice(2, 5).map(Number),
direction: Number(match[5]),
evidence: "Resource/luascript/03246.lua:MS_TEST_DUNGEONS",
});
}
return result;
}
function normalizedWdb(value) {
return clean(value).replaceAll(String.fromCharCode(92), "/").replace(/^\/+/, "").replace(/^wdb\//i, "");
}
function actorIdFor(model, used) {
const base = slugify(path.parse(model).name.replace(/^act[_-]?/i, "")) || "actor";
if (!used.has(base) || used.get(base) === model) return base;
return base + "-" + stableHash(model).slice(0, 8);
}
function colorsFor(id) {
const hue = (Number(id) * 47) % 360;
return {
primaryColor: `hsl(${hue} 22% 38%)`,
accentColor: `hsl(${(hue + 28) % 360} 38% 64%)`,
};
}
function archetypeFor(model) {
if (/slime|ooze/.test(model)) return "ooze";
if (/bat|bird|harpy|dragon|wing/.test(model)) return "winged";
if (/serpent|snake|naga/.test(model)) return "serpent";
if (/turtle|tortoise/.test(model)) return "turtle";
if (/raptor/.test(model)) return "raptor";
if (/crocolisk|crocodile/.test(model)) return "crocolisk";
if (/plant|myconid|fung|flower|tree/.test(model)) return "plant";
if (/murloc/.test(model)) return "murloc";
if (/spider|lizard|salamander|frog|bug|scarab|ant/.test(model)) return "lizard";
return "humanoid";
}
function preferredModel(rows) {
const counts = new Map();
for (const row of rows) {
const model = normalizeModelPath(row.modelPath);
if (!/^model\/character\/.+\.ros$/i.test(model)) continue;
counts.set(model, (counts.get(model) ?? 0) + 1);
}
return [...counts].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))[0]?.[0] ?? null;
}
function templateDefinition(templateId, rows, model, actor) {
const first = rows[0];
const bossFlag = rows.some((row) => Number(row.sex) === 3);
const level = Math.max(...rows.map((row) => Number(row.nativeLevel ?? 0)));
const combat = Boolean(model) && level > 0;
// sex=3 is authoritative crown/boss evidence only for an actual character
// display. Several scripted doors and invisible FX controllers also carry
// sex=3; promoting those props to combat entities creates fake bosses and
// impossible objective routes.
const boss = bossFlag && combat;
const classification = boss ? "boss" : combat ? "combat" : "deferred-object";
const sourceName = clean(first.localizedName) || clean(first.roleName);
const name = sourceName && !/^sys\d+_name$/i.test(sourceName) ? sourceName : "Template " + templateId;
const proceduralFallback = boss && !actor;
return {
...colorsFor(templateId),
id: Number(templateId),
name,
level,
classification,
archetype: archetypeFor(model ?? name.toLowerCase()),
...(actor ? { actor } : {}),
...(boss ? { title: name } : {}),
...(proceduralFallback ? {
proceduralFallback: true,
fallbackReason: "Authoritative NPCObjectDB.sex=3 boss uses a runtime/dynamic display and has no static actor ROS.",
} : {}),
...(classification === "deferred-object" ? {
reason: level === 0
? "Level-zero quest, trigger, prop, or noncombat NPC retained for a later interaction pass."
: "Positive-level source object has no static character ROS and is retained for mechanics review.",
} : {}),
source: {
imageId: Number(first.imageId ?? 0),
modelPath: model,
classificationRule: boss
? "NPCObjectDB.sex=3 plus static model/character ROS"
: combat
? "positive native level plus static model/character ROS"
: bossFlag
? "NPCObjectDB.sex=3 scripted non-character object retained for mechanics review"
: "noncombat/dynamic-display source object",
},
};
}
const [raw, config, entrySource, existingOverrides] = await Promise.all([
readJson(rawFile),
readJson(configFile),
readFile(entryScriptFile, "utf8"),
readJson(overridesFile),
]);
if (raw.schemaVersion !== 1 || !Array.isArray(raw.zones)) throw new Error("Unsupported all-instance forensic export.");
const rawByZone = new Map(raw.zones.map((zone) => [Number(zone.zoneId), zone]));
const entries = entrySpawns(entrySource);
const sourceHashes = {
backups: {
"../rom-pvt-server-backups/OneForAll/SQL/databases/ROM_Global.bak": await sha256(path.join(projectRoot, "../rom-pvt-server-backups/OneForAll/SQL/databases/ROM_Global.bak")),
"../rom-pvt-server-backups/OneForAll/SQL/databases/ObjectEdit.bak": await sha256(path.join(projectRoot, "../rom-pvt-server-backups/OneForAll/SQL/databases/ObjectEdit.bak")),
},
catalogs: Object.fromEntries(await Promise.all([
"Resource/data/npcobject.db",
"Resource/data/imageobject.db",
"Resource/data/string_enus.db",
].map(async (file) => [file, await sha256(path.join(sourceRoot, file))]))),
};
await Promise.all([mkdir(recipesDirectory, { recursive: true }), mkdir(snapshotsDirectory, { recursive: true })]);
const overrides = { ...existingOverrides };
const prepared = [];
for (const dungeon of config.dungeons ?? []) {
const slug = slugify(dungeon.id);
const zone = rawByZone.get(Number(dungeon.base_zone));
if (!zone) throw new Error("Forensic export is missing Zone " + dungeon.base_zone + ".");
const zoneRows = Array.isArray(zone.rows) ? zone.rows : [];
const wdb = normalizedWdb(zone.zoneObject?.mapFile);
if (!wdb) throw new Error("Zone " + dungeon.base_zone + " has no authoritative mapfile.");
overrides[dungeon.id] = {
wdb,
source: "ObjectEdit.dbo.ZoneObjectDB.mapfile",
zoneObjectTemplateId: Number(zone.zoneObjectTemplateId),
};
if (dungeon.id === "forsaken_abbey") {
prepared.push({ id: dungeon.id, zoneId: dungeon.base_zone, rows: zone.rowCount, wdb, status: "preserved-existing-pilot" });
continue;
}
const wdbFile = path.join(resourceRoot, "wdb", ...wdb.split("/"));
const workRoot = path.join(projectRoot, "runewaker-export-work", slug);
const inspection = await inspectWdb({ wdbFile, reportFile: path.join(workRoot, "inspection", "wdb-report.json") });
const hasDungeonModel = inspection.candidates.some((entry) => entry.isDungeonModel);
const candidate = inspection.candidates.find((entry) => entry.isDungeonModel && entry.placements === 1)
?? inspection.candidates.find((entry) => entry.isDungeonModel)
?? inspection.candidates[0];
if (!candidate) throw new Error(dungeon.name + " WDB contains no ROS environment candidates.");
const model = candidate.resource.replaceAll(String.fromCharCode(92), "/").replace(/^\/+/, "");
await stat(path.join(resourceRoot, ...model.split("/")));
const entry = entries.get(dungeon.id);
if (!entry) throw new Error("03246.lua has no entrance for " + dungeon.id + ".");
const normalizedModel = normalizeModelPath(model);
const modelPlacements = (inspection.report.descriptors ?? []).filter((descriptor) => (
normalizeModelPath(descriptor.resource) === normalizedModel
));
const selectedPlacement = [...modelPlacements].sort((left, right) => {
const distanceSquared = (descriptor) => descriptor.translation.reduce((sum, value, index) => (
sum + (Number(value) - entry.position[index]) ** 2
), 0);
return distanceSquared(left) - distanceSquared(right);
})[0];
if (!selectedPlacement) throw new Error(dungeon.name + " primary model has no WDB placement.");
const proceduralTerrainFallback = !hasDungeonModel ? (() => {
const extents = (inspection.report.descriptors ?? []).map((descriptor) => {
const center = descriptor.worldBounds?.center ?? descriptor.translation;
const radius = Number(descriptor.worldBounds?.radius ?? 0);
return { center: center.map(Number), radius };
});
const xValues = extents.flatMap(({ center, radius }) => [center[0] - radius, center[0] + radius]);
const zValues = extents.flatMap(({ center, radius }) => [center[2] - radius, center[2] + radius]);
const combatY = zoneRows.filter((row) => Number(row.nativeLevel) > 0).map((row) => Number(row.sourcePosition[1]));
return {
kind: "flat-source-evidence-arena",
reason: "The authoritative WDB contains placed props but no model/dungeon terrain ROS.",
sourceBounds: {
min: [Math.min(...xValues, entry.position[0]), 0, Math.min(...zValues, entry.position[2])],
max: [Math.max(...xValues, entry.position[0]), 0, Math.max(...zValues, entry.position[2])],
},
floorYSource: Math.min(Number(entry.position[1]), ...combatY),
paddingSourceUnits: 20,
thicknessSourceUnits: 4,
evidence: ["ObjectEdit.dbo.ZoneObjectDB.mapfile", "WDB descriptor world bounds", entry.evidence],
reviewRequired: true,
};
})() : null;
const assemblyPlacementTranslations = dungeon.id === "ystra_labyrinth_boss1"
? modelPlacements.map((placement) => placement.translation.map(Number))
: null;
const sourceAnchorSupport = SOURCE_ANCHOR_SUPPORT_DUNGEONS.has(dungeon.id) ? (() => {
const unique = new Map();
for (const position of [
entry.position,
...zoneRows.filter((row) => Number(row.nativeLevel) > 0).map((row) => row.sourcePosition),
]) {
const point = position.map(Number);
unique.set(point.map((value) => value.toFixed(3)).join(":"), point);
}
return {
kind: "source-anchor-support-pads",
reason: "The authoritative WDB is modular or its primary ROS collision has navigation gaps at source spawn anchors.",
points: [...unique.values()],
halfExtentSourceUnits: 60,
thicknessSourceUnits: 3,
evidence: ["ROM_Global.dbo.NPCData source coordinates", entry.evidence],
reviewRequired: true,
};
})() : null;
const camel = camelCase(slug);
const environmentRecipeFile = path.join(recipesDirectory, slug + ".json");
const populationRecipeFile = path.join(recipesDirectory, slug + "-population.json");
const environmentRecipe = {
schemaVersion: 2,
slug,
title: dungeon.name,
source: {
rootEnvironmentVariable: "RUNEWAKER_ROOT",
relativeDefault: "../rom-pvt-server-backups/Runewaker",
resourceRoot: "Resource",
wdb: "Resource/wdb/" + wdb,
model,
primaryPlacementTranslation: selectedPlacement.translation.map(Number),
...(assemblyPlacementTranslations ? { assemblyPlacementTranslations } : {}),
...(proceduralTerrainFallback ? { proceduralTerrainFallback } : {}),
...(sourceAnchorSupport ? { sourceAnchorSupport } : {}),
modelSelection: {
rule: proceduralTerrainFallback
? "largest WDB prop retained as the coordinate anchor; playable terrain generated from authoritative WDB bounds"
: candidate.placements === 1
? "largest unique model/dungeon ROS"
: "largest model/dungeon ROS placement nearest the authoritative entrance",
score: candidate.score,
placements: candidate.placements,
dominance: inspection.recommendation?.dominance ?? null,
},
dungeonConfig: "Tools/dungeon_config.json",
dungeonConfigId: dungeon.id,
entryCoordinates: { position: entry.position, direction: entry.direction, reviewRequired: false, evidence: entry.evidence },
},
coordinateSystem: {
source: "left-handed Y-up",
nativeBridge: "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)",
metersPerSourceUnit: 0.1,
directionUnitsPerTurn: 360,
calibration: "model/character/pc/dummy/fake_pc.ros mesh height is 16.4977 source units",
},
navigation: {
cellSize: 0.3, cellHeight: 0.1, agentHeight: 1.8, agentRadius: 0.36,
agentMaxClimb: 0.5, agentMaxSlope: 50, tileSize: 64, retainTileIntermediates: false,
},
review: {
environment: proceduralTerrainFallback
? "authoritative-wdb-has-no-terrain-procedural-fallback-review-required"
: "authoritative-wdb-largest-model-selected",
collision: sourceAnchorSupport
? "primary-ros-plus-source-anchor-support-review-required"
: "generated-review-required",
entrance: "source-script",
bossAnchors: "source-population",
wdbPlacements: assemblyPlacementTranslations
? "all-repeated-primary-room-placements-assembled"
: candidate.placements === 1 ? "unique-primary-model" : "nearest-authoritative-entrance",
},
exceptions: [
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
"Collision initially includes decorative primary-ROS geometry.",
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work.",
...(proceduralTerrainFallback ? [
"The source WDB has no terrain ROS; a flat playable arena is derived from source descriptor bounds and must be visually reviewed.",
] : []),
...(sourceAnchorSupport ? [
"Source-anchor support pads preserve combat and entrance coordinates where modular WDB sections or primary-ROS collision leave navmesh gaps; full secondary placement assembly remains a visual follow-up.",
] : []),
],
};
const rowsByTemplate = new Map();
for (const row of zoneRows) {
const bucket = rowsByTemplate.get(Number(row.templateId)) ?? [];
bucket.push(row);
rowsByTemplate.set(Number(row.templateId), bucket);
}
const actorsByModel = new Map();
const actorIds = new Map();
const templates = [];
for (const [templateId, templateRows] of [...rowsByTemplate].sort(([left], [right]) => left - right)) {
const sourceModel = preferredModel(templateRows);
let actor = null;
if (sourceModel) {
let definition = actorsByModel.get(sourceModel);
if (!definition) {
const id = actorIdFor(sourceModel, actorIds);
definition = { id, sourceModel, runtimeRotationY: 0 };
actorsByModel.set(sourceModel, definition);
actorIds.set(id, sourceModel);
}
actor = definition.id;
}
templates.push(templateDefinition(templateId, templateRows, sourceModel, actor));
}
const templateMap = new Map(templates.map((template) => [template.id, template]));
const snapshotRows = zoneRows.map((row) => {
const template = templateMap.get(Number(row.templateId));
const modelPath = normalizeModelPath(row.modelPath) || template.source.modelPath;
return {
spawnId: Number(row.spawnId),
templateId: Number(row.templateId),
name: template.name,
sourceName: clean(row.localizedName) || clean(row.roleName) || template.name,
classification: template.classification,
level: Number(row.nativeLevel),
sex: Number(row.sex),
imageId: Number(row.imageId ?? 0),
modelPath: modelPath || null,
sourcePosition: row.sourcePosition.map(Number),
direction: Number(row.direction),
source: {
roomId: Number(row.roomId), roleName: clean(row.roleName), autoPlot: clean(row.autoPlot),
plotClassName: clean(row.plotClassName),
templateScripts: Object.fromEntries(Object.entries(row.templateScripts ?? {}).map(([key, value]) => [key, clean(value)])),
spells: row.spells ?? [],
},
};
}).sort((left, right) => left.spawnId - right.spawnId);
const content = {
schemaVersion: 2, dungeonId: slug, zoneId: Number(dungeon.base_zone), queryVersion: raw.queryVersion,
rowCount: snapshotRows.length, sourceHashes, rows: snapshotRows,
};
const snapshot = { ...content, contentSha256: stableHash(content) };
const snapshotFile = path.join(snapshotsDirectory, slug + "-zone-" + dungeon.base_zone + ".json");
const bossSpawns = snapshotRows.filter((row) => row.classification === "boss").length;
const populationRecipe = {
schemaVersion: 2,
dungeonId: slug,
sourceDungeonConfigId: dungeon.id,
zoneId: Number(dungeon.base_zone),
expectedActiveRows: snapshotRows.length,
expectedBossSpawns: bossSpawns,
runtimeIdPrefix: "rw-" + slug,
coordinateSystem: { metersPerSourceUnit: 0.1, directionUnitsPerTurn: 360, maximumProjectionDistance: 4 },
files: {
environmentRecipe: projectPath(environmentRecipeFile),
snapshot: projectPath(snapshotFile),
generatedSource: "src/game/generated/" + camel + "Population.generated.ts",
report: "src/assets/game/dungeons/" + slug + "/population-import-report.json",
mechanicsReport: "src/assets/game/dungeons/" + slug + "/mechanics-audit.json",
mechanicsSource: "src/game/generated/" + camel + "Mechanics.generated.ts",
environmentMetadata: "src/assets/game/dungeons/" + slug + "/import-metadata.json",
navigation: "src/assets/game/dungeons/" + slug + "/" + slug + "-navigation.glb",
actorManifest: "public/assets/creatures/" + slug + "/manifest.json",
actorOutput: "public/assets/creatures/" + slug,
},
source: {
rootEnvironmentVariable: "RUNEWAKER_ROOT", relativeDefault: "../rom-pvt-server-backups/Runewaker",
resourceRoot: "Resource", dungeonConfig: "Tools/dungeon_config.json", dungeonConfigId: dungeon.id,
luaRoot: "Resource/luascript",
catalogs: ["Resource/data/npcobject.db", "Resource/data/imageobject.db", "Resource/data/string_enus.db"],
backups: {
global: "../rom-pvt-server-backups/OneForAll/SQL/databases/ROM_Global.bak",
objects: "../rom-pvt-server-backups/OneForAll/SQL/databases/ObjectEdit.bak",
},
serverEnvironmentVariable: "RUNEWAKER_SQL_SERVER", serverDefault: ".\\HEALERMAN_RW",
},
actors: [...actorsByModel.values()].sort((left, right) => left.id.localeCompare(right.id)),
templates,
deferred: [
"Doors, summons, patrols, phases, interactables, and loot require reviewed mechanics mappings.",
"Direct GR2 actors and exact paperdoll appearances use recovered native animation when validated; explicit pose-only/proxy exceptions retain procedural presentation.",
],
};
await Promise.all([
writeFile(environmentRecipeFile, JSON.stringify(environmentRecipe, null, 2) + "\n", "utf8"),
writeFile(populationRecipeFile, JSON.stringify(populationRecipe, null, 2) + "\n", "utf8"),
writeFile(snapshotFile, JSON.stringify(snapshot, null, 2) + "\n", "utf8"),
]);
prepared.push({
id: dungeon.id, slug, zoneId: dungeon.base_zone, rows: snapshotRows.length, bosses: bossSpawns,
actors: actorsByModel.size, wdb, model, modelPlacements: candidate.placements,
modelSelection: candidate.placements === 1 ? "automatic-unique" : "automatic-nearest-entrance",
});
}
await writeFile(overridesFile, JSON.stringify(overrides, null, 2) + "\n", "utf8");
const reportFile = path.join(projectRoot, "runewaker-export-work", "all-instances-preparation-report.json");
await writeFile(reportFile, JSON.stringify({
schemaVersion: 1,
generatedAt: new Date().toISOString(),
source: projectPath(rawFile),
counts: {
configured: prepared.length,
generated: prepared.filter((entry) => entry.status !== "preserved-existing-pilot").length,
sourceRows: prepared.reduce((sum, entry) => sum + entry.rows, 0),
bossSpawns: prepared.reduce((sum, entry) => sum + Number(entry.bosses ?? 0), 0),
},
dungeons: prepared,
}, null, 2) + "\n", "utf8");
console.log("[runewaker] prepared " + (prepared.length - 1) + " remaining instance recipes and portable snapshots.");
console.log("[runewaker] report: " + projectPath(reportFile));