t2-mapper/scripts/generate-manifest.ts

203 lines
6.3 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { parseArgs } from "node:util";
import orderBy from "lodash.orderby";
import ignore from "ignore";
import { normalizePath } from "@/src/stringUtils";
import { walkDirectory } from "@/src/fileUtils";
import { parseMissionScript } from "@/src/mission";
const baseDir = process.env.BASE_DIR || "docs/base";
// Most files we're not interested in would have already been ignored by the
// `extract-assets` script - but some extra files still may have popped up from
// the host sytem.
const ignoreList = ignore().add(`
.DS_Store
*.glb
`);
type SourceTuple =
// If casing of the path within this source is the same as "first seen" casing
| [sourcePath: string]
// If casing of the path within this source is different
| [sourceName: string, actualPath: string];
// Resource entry: [firstSeenActualPath, ...sourceTuples]
type ResourceEntry = [firstSeenActualPath: string, ...SourceTuple[]];
/**
* Log and return the manifest of files for the given game asset directory.
* The assets used to build the mapper are a filtered set of relevant files
* (map related assets) from the `Tribes2/GameData/base` folder. The manifest
* consists of the set of unique paths represented by the file tree AND the vl2
* files as if they had been unzipped. Keys are normalized (lowercased) paths
* for case-insensitive lookup.
*
* Values are arrays where the first element is the first-seen casing of the
* path, followed by source tuples. Each source tuple is either:
* - [sourcePath] if the file has the same casing as firstSeenPath
* - [sourcePath, actualPath] if the file has different casing in that source
*
* If the file appears outside of a vl2, the source path will be the empty
* string. Each vl2 containing the file will then be listed in order. To resolve
* an asset, the engine uses a layering approach where paths inside
* lexicographically-higher vl2 files win over the same path outside of a vl2
* or in a lexicographically-lower vl2 file. So, to choose the same final asset
* as the engine, choose the last source in the list for any given path.
*
* Example:
*
* ```
* {
* "textures/terraintiles/green.png": [
* "textures/terrainTiles/green.png",
* ["textures.vl2"],
* ["otherTextures.vl2", "Textures/TerrainTiles/Green.PNG"]
* ]
* }
* ```
*/
async function buildManifest() {
// Map from normalized (lowercased) path to [firstSeenActualPath, ...sourceTuples]
const fileSources = new Map<string, ResourceEntry>();
const looseFiles: string[] = [];
await walkDirectory(baseDir, {
onFile: ({ entry }) => {
const resourcePath = normalizePath(
path.relative(baseDir, path.join(entry.parentPath, entry.name)),
);
if (!ignoreList.ignores(resourcePath)) {
looseFiles.push(resourcePath);
}
},
onDir: ({ entry }) => {
return entry.name !== "@vl2";
},
});
for (const resourcePath of looseFiles) {
const normalizedKey = resourcePath.toLowerCase();
const existing = fileSources.get(normalizedKey);
if (existing) {
const [firstSeenPath] = existing;
if (resourcePath === firstSeenPath) {
existing.push([""]);
} else {
existing.push(["", resourcePath]);
}
} else {
fileSources.set(normalizedKey, [resourcePath, [""]]);
}
}
let archiveDirs: string[] = [];
await walkDirectory(`${baseDir}/@vl2`, {
onFile: () => {},
onDir: ({ entry }) => {
if (/\.vl2$/i.test(entry.name)) {
const archivePath = path.join(entry.parentPath, entry.name);
archiveDirs.push(archivePath);
}
return true;
},
});
archiveDirs = orderBy(
archiveDirs,
[(archivePath) => path.basename(archivePath).toLowerCase()],
["asc"],
);
for (const archivePath of archiveDirs) {
const relativeArchivePath = normalizePath(
path.relative(`${baseDir}/@vl2`, archivePath),
);
await walkDirectory(archivePath, {
onFile: ({ entry }) => {
const resourcePath = normalizePath(
path.relative(archivePath, path.join(entry.parentPath, entry.name)),
);
if (ignoreList.ignores(resourcePath)) {
return;
}
const normalizedKey = resourcePath.toLowerCase();
const existing = fileSources.get(normalizedKey);
if (existing) {
const [firstSeenPath] = existing;
if (resourcePath === firstSeenPath) {
existing.push([relativeArchivePath]);
} else {
existing.push([relativeArchivePath, resourcePath]);
}
} else {
fileSources.set(normalizedKey, [resourcePath, [relativeArchivePath]]);
}
},
});
}
const resources: Record<string, ResourceEntry> = {};
const missions: Record<
string,
{ resourcePath: string; displayName: string | null; missionTypes: string[] }
> = {};
const sortedResourceKeys = Array.from(fileSources.keys()).sort();
for (const resourceKey of sortedResourceKeys) {
const entry = fileSources.get(resourceKey)!;
resources[resourceKey] = entry;
const [firstSeenPath, ...sourceTuples] = entry;
const lastSourceTuple = sourceTuples[sourceTuples.length - 1];
const lastSource = lastSourceTuple[0];
const lastActualPath = lastSourceTuple[1] ?? firstSeenPath;
console.log(
`${firstSeenPath}${sourceTuples[0][0] ? ` 📦 ${sourceTuples[0][0]}` : ""}${
sourceTuples.length > 1
? sourceTuples
.slice(1)
.map((tuple) => ` ❗️ ${tuple[0]}`)
.join("")
: ""
}`,
);
const resolvedPath = lastSource
? path.join(baseDir, "@vl2", lastSource, lastActualPath)
: path.join(baseDir, lastActualPath);
if (resourceKey.endsWith(".mis")) {
const missionScript = await fs.readFile(resolvedPath, "utf8");
const mission = parseMissionScript(missionScript);
const baseName = path.basename(firstSeenPath, ".mis");
missions[baseName] = {
resourcePath: resourceKey,
displayName: mission.displayName,
missionTypes: mission.missionTypes,
};
}
}
return { resources, missions };
}
const { values } = parseArgs({
options: {
output: {
type: "string",
short: "o",
},
},
});
const manifest = await buildManifest();
if (values.output) {
await fs.writeFile(values.output, JSON.stringify(manifest), "utf8");
}