sc4
Version:
A command line utility for automating SimCity 4 modding tasks & modifying savegames
186 lines (185 loc) • 7.55 kB
JavaScript
// # create-submenu-patch.js
import { fs, path } from 'sc4/utils';
import chalk from 'chalk';
import { DBPF, Cohort, FileType, ExemplarProperty, TGI } from 'sc4/core';
import FileScanner from './file-scanner.js';
// # random()
// Returns a random number between 0x00000001 and 0xffffffff. Useful for
// generating unique ids.
function random() {
return Math.floor(Math.random() * 0xffffffff) + 1;
}
// # createMenuPatch(menu, globsOrFiles, options = {})
export default async function createMenuPatch(options) {
const patcher = new SubmenuPatcher();
return await patcher.createPatch(options);
}
// # getPatchList(targets)
function getPatchList(targets) {
return targets.flatMap(({ tgi }) => [tgi.group, tgi.instance]);
}
export class SubmenuPatcher {
directory;
constructor(opts = {}) {
if (opts.directory)
this.directory = opts.directory;
}
// ## createPatch(options)
async createPatch(options) {
// We'll first find the files to read in. This is a bit complicated because
// we support both globbing, or specifying files and directories explicitly.
let { menu, directory = this.directory, logger, } = options;
let patcher = new SubmenuPatcher();
let targets = await patcher.findPatchTargets(options);
// If nothing was found, log a warning.
let { lots, flora } = targets;
if (lots.length + flora.length === 0) {
const { logger } = options;
logger?.warn('No lots or flora found to put in a submenu');
return null;
}
// Create a fresh Cohort file and add the Exemplar Patch Targets
// (0x0062e78a) and Building Submenus (0xAA1DD399)
let dbpf = new DBPF();
if (lots.length > 0) {
let cohort = new Cohort();
cohort.addProperty('ExemplarPatchTargets', getPatchList(lots));
cohort.addProperty('BuildingSubmenus', [menu].flat());
let { instance = random() } = options;
dbpf.add([FileType.Cohort, 0xb03697d1, instance], cohort);
}
// Do the same for Flora.
if (flora.length > 0) {
let cohort = new Cohort();
cohort.addProperty('ExemplarPatchTargets', getPatchList(flora));
cohort.addProperty('ItemSubmenuParentId', [menu].flat().at(0));
cohort.addProperty('ItemButtonClass', ExemplarProperty.ItemButtonClass.FloraItemInSubmenu);
let tgi = TGI.random(FileType.Cohort, 0xb03697d1);
dbpf.add(tgi, cohort);
}
// Serialize and write away if the save option is set.
if (options.save) {
let buffer = dbpf.toBuffer();
let { output = 'Submenu patch.dat' } = options;
let outputPath = path.resolve(directory, output);
await fs.promises.writeFile(outputPath, buffer);
logger?.ok(`Saved to ${outputPath}`);
}
return dbpf;
}
// ## findPatchTargets(opts)
// Finds all exemplar patch targets as their tgi and exemplar name and
// report them as lots and flora.
async findPatchTargets(options) {
// If the group/instance pairs are directly specified as "targets"
// array, then we return it as is, and assume it's a "lots" array.
if (options.targets) {
if (Array.isArray(options.targets)) {
let { targets } = options;
return {
lots: Array.from({ length: targets.length / 2 }, (_, i) => {
return targets.slice(2 * i, 2 * i + 2);
}).map(([group, instance]) => {
return {
tgi: new TGI(FileType.Exemplar, group, instance),
};
}),
flora: [],
};
}
else {
return {
lots: [],
flora: [],
...options.targets
};
}
}
// Check if a list of dbpfs was specified. If not, then we'll try to
// read in from files instead.
let { logger, dbpfs, files: globsOrFiles = ['**/*'], directory = this.directory, } = options;
if (!dbpfs) {
if (!directory) {
throw new TypeError(`No patch targets found. Neither a directory, dbpfs or targets list was specified!`);
}
// Read in all dbpfs from the files that we've collected.
dbpfs = [];
let glob = new FileScanner(globsOrFiles, { cwd: directory });
let files = await glob.walk();
for (let file of files) {
// We won't try to read in anything else than .dat or .sc4lot
// files.
let basePath = path.relative(process.cwd(), file);
let ext = path.extname(file).toLowerCase();
if (!(ext === '.dat' || ext.startsWith('.sc4'))) {
logger?.info(`Skipping ${basePath}`);
continue;
}
// Read in as dbpf and collect the relevant exemplars.
logger?.info(chalk.gray(`Reading ${basePath}`));
let buffer = await fs.promises.readFile(file);
let dbpf = new DBPF({ buffer, file });
dbpfs.push(dbpf);
}
}
// Now that we have the list of dbpfs to read the lots from, actually
// collect the targets list.
let targets = {
lots: [],
flora: [],
};
for (let dbpf of dbpfs) {
let { lots = [], flora = [] } = collect(dbpf, logger);
targets.lots.push(...lots);
targets.flora.push(...flora);
}
return targets;
}
}
// # collect(dbpf)
// Collect all [group, instance] pairs from the lots that appear in a lot. Note
// that this is harder than it sounds because the properties we're looking for
// might actually be stored in a parent cohort. We'll hence look for the
// LotResourceKey, which is more or less guaranteed to not be stored in a parent
// cohort - though it technically could be.
function collect(dbpf, logger) {
let lots = [];
let flora = [];
let entries = dbpf.findAll({ type: FileType.Exemplar });
for (let entry of entries) {
let exemplar;
try {
exemplar = entry.read();
}
catch (e) {
logger?.warn(`Failed to parse exemplar ${entry.id} from ${dbpf.file}: ${e.message}`);
continue;
}
// If this is a flora exemplar, we'll put it in the flora list obviously.
let type = exemplar.get('ExemplarType');
if (type === ExemplarProperty.ExemplarType.Flora) {
let name = exemplar.get('ExemplarName');
logger?.info(chalk.gray(`Using ${name ?? 'Nameless flora'} (${entry.id})`));
flora.push({
tgi: new TGI(entry.tgi),
name,
});
continue;
}
// Check if the LotResourceKey exists.
let lrk = exemplar.get('LotResourceKey');
if (lrk) {
let name = exemplar.get('ExemplarName');
logger?.info(chalk.gray(`Using ${name ?? 'Nameless lot'} (${entry.id})`));
lots.push({
tgi: new TGI(entry.tgi),
name,
});
continue;
}
}
return {
lots,
flora,
};
}