UNPKG

sc4

Version:

A command line utility for automating SimCity 4 modding tasks & modifying savegames

244 lines (243 loc) 9.33 kB
// # unpack-submenu.ts import path from 'node:path'; import fs from 'node:fs'; import os from 'node:os'; import { FileType, LTEXT } from 'sc4/core'; import { Document, parse, Scalar } from 'yaml'; import PluginIndex from './plugin-index.node.js'; import { hex } from 'sc4/utils'; import { Glob } from 'glob'; // # unpackSubmenu() export default async function unpackSubmenu(opts) { return await new Unpacker().unpack(opts); } ; // # Unpacker class Unpacker { index; menus = new Map(); // ## unpack() // Entry point for unpacking a directory containing a bunch of submenus. async unpack(opts) { let { patterns = '**/*.dat', directory = process.cwd(), output = process.cwd(), logger, } = opts; // Build up the plugin index first. this.index = new PluginIndex({ scan: patterns, plugins: directory, core: false, }); await this.index.build(); // Read in the exemplars first and look for the menus configurations. let exemplars = this.index.findAll({ type: FileType.Exemplar, group: 0x2a3858e4, }); let had = new Set(); for (let entry of exemplars) { let { file } = entry.dbpf; if (!had.has(file)) { had.add(file); logger?.info(`Reading ${path.relative(directory, file)}`); } this.parseExemplar(entry.read()); } // Parse any existing menus in the output dirctory as well. await this.parseExistingMenus(output); // Loop all menus and fill in their paths. outer: for (let menu of this.menus.values()) { if (menu.path) continue; if (!menu.parent) { logger?.warn(`Menu ${menu.name} has no parent set!`); continue; } let chain = [menu.dirname]; let parent = this.menus.get(menu.parent); while (parent && !parent.path) { chain.unshift(parent.dirname); if (!parent.parent) continue outer; parent = this.menus.get(parent.parent); } if (parent && parent.path) { chain.unshift(parent.path); } else { chain.unshift(path.join(output, 'orphans')); } menu.path = path.join(...chain); } // Next we'll create the actual folders with the unpacked _menu.yaml and // _icon.png. These need to be present before we can loop the cohorts // and look for patches. for (let menu of this.menus.values()) { if (menu.existing) continue; // Check if we can find the parent menu folder. If not, this is an // orphaned menu and we should add it to that folder as well. let dir = menu.path; if (!dir) continue; await fs.promises.mkdir(dir, { recursive: true }); // Write away the _menu.yaml file. Note that the parent menu gets // included here if it's an orphan! let parent = this.menus.get(menu.parent); let doc = stylize(new Document({ id: menu.id, ...!parent ? { parent: menu.parent } : null, name: menu.name, description: menu.description, })); await fs.promises.writeFile(path.join(dir, '_menu.yaml'), String(doc)); // Unpack the png icon as well, if it exists. if (menu.icon) { let png = this.index.find(menu.icon).read(); await fs.promises.writeFile(path.join(dir, '_icon.png'), png); } } // Next we'll read in the cohorts to look for patches. let cohorts = this.index.findAll({ type: FileType.Cohort, group: 0xb03697d1, }); for (let entry of cohorts) { // Find out what menu the patch belongs to. If no parent was found, // we shortcut obviously. let cohort = entry.read(); let [menuId] = cohort.get('BuildingSubmenus') ?? []; if (!menuId) continue; let parent = this.menus.get(menuId); if (!parent) continue; // Log that we're reading the cohort. let file = entry.dbpf.file; if (!had.has(file)) { had.add(file); logger?.info(`Reading ${path.relative(directory, file)}`); } // Read in the target file if it already exists so that we can // ensure we don't add pairs twice. let { dbpf } = entry; let basename = path.basename(dbpf.file, path.extname(dbpf.file)); let fullPath = path.join(parent.path, `${basename}.txt`); let existingTargets = new Set(); try { let contents = String(await fs.promises.readFile(fullPath)); let lines = contents.trim().split('\n'); for (let line of lines) { let [group, instance] = line.split(','); existingTargets.add(`${hex(+group)}, ${hex(+instance)}`); } } catch (e) { if (e.code !== 'ENOENT') throw e; } // Serialize the patch targets as hex numbers, make sure we don't // duplicate and then write away. let targets = cohort.get('ExemplarPatchTargets') ?? []; while (targets.length > 0) { let group = targets.shift(); let instance = targets.shift(); if (instance === undefined) break; existingTargets.add(`${hex(+group)}, ${hex(+instance)}`); } let contents = [...existingTargets].join(os.EOL) + os.EOL; await fs.promises.writeFile(fullPath, contents); } } // ## parseExemplar(exemplar) // Parses an exemplar and extracts the basic menu information from it. parseExemplar(exemplar) { let buttonId = exemplar.get('ItemButtonID'); if (!buttonId) return; // Find the description for this menu. We require this to be in this // file, though this is not strictly require by the game obviously! // It's the convention though. let name; let uvnk = exemplar.get('UserVisibleNameKey'); if (uvnk) { let [type, group, instance] = uvnk; let ltext = this.index.find({ type, group, instance }); if (ltext) { name = String(ltext.read()); } } // Check for the description. Note that if the description was not // found, we store it as a tgi instead. let description; let idk = exemplar.get('ItemDescriptionKey'); if (idk) { let [type, group, instance] = idk; let ltext = this.index.find({ type, group, instance }); if (ltext) { description = String(ltext.read()); } else { description = [type, group, instance]; } } // Store the icon as well if it exists. let icon; let iconInstance = exemplar.get('ItemIcon'); if (iconInstance) { let entry = this.index.find({ type: FileType.PNG, group: 0x6a386d26, instance: iconInstance, }); if (entry) { let { type, group, instance } = entry; icon = { type, group, instance }; } } // Determine the basename for the menu's directory. let order = exemplar.get('ItemOrder') ?? 0; let prefix = '0x' + order.toString(16).padStart(8, '0').toUpperCase(); let exemplarName = exemplar.get('ExemplarName') ?? ''; if (order >= 0x80000000) prefix = `_${prefix}`; this.menus.set(buttonId, { existing: false, id: buttonId, parent: exemplar.get('ItemSubmenuParentId'), name, dirname: `${prefix}-${exemplarName}`, order, description, icon, }); } async parseExistingMenus(directory) { let glob = new Glob('**/_menu.yaml', { cwd: directory, absolute: true, }); let files = await glob.walk(); for (let file of files) { let yaml = String(await fs.promises.readFile(file)); let parsed = parse(yaml); this.menus.set(parsed.id, { existing: true, id: parsed.id, dirname: path.basename(path.dirname(file)), path: path.dirname(file), }); } } } function stylize(doc) { (doc.get('id', true) || {}).format = 'HEX'; (doc.get('parent', true) || {}).format = 'HEX'; let desc = doc.get('description', true); if (desc?.items) { desc.flow = true; for (let item of desc.items) { item.format = 'HEX'; } } return doc; }