sc4
Version:
A command line utility for automating SimCity 4 modding tasks & modifying savegames
145 lines (144 loc) • 5.39 kB
JavaScript
// # dbpf-add-command.ts
import path from 'node:path';
import fs from 'node:fs';
import { Glob } from 'glob';
import { DBPF, DBPFStream, Exemplar, FileType, LTEXT, TGI } from 'sc4/core';
import { attempt } from 'sc4/utils';
import { SmartBuffer } from 'smart-arraybuffer';
import cliLogger from '#cli/logger.js';
import { parse } from 'yaml';
import { Minimatch } from 'minimatch';
export async function dbpfAdd(patterns, options) {
return await new AddOperation(options).add([patterns].flat());
}
class AddOperation {
options;
file;
cwd;
stream;
warnings = [];
spinner;
counter = 0;
shouldCompress = () => false;
constructor(options) {
this.options = options;
let { directory = process.cwd(), output, logger = cliLogger, compress, } = options;
this.cwd = directory;
this.file = path.resolve(this.cwd, output);
this.stream = new DBPFStream(this.file, options.force ? 'w' : 'wx');
this.spinner = logger?.progress;
if (typeof compress === 'boolean') {
this.shouldCompress = () => true;
}
else if (typeof compress === 'string') {
let mm = new Minimatch(compress);
this.shouldCompress = (file) => {
return mm.match(path.relative(this.cwd, file));
};
}
}
// The actual entry point for adding files to a dbpf.
async add(patterns) {
// First we'll scan for all dbpf files that match the patterns.
this.spinner?.start();
let glob = new Glob(patterns, {
cwd: this.cwd,
nocase: true,
nodir: true,
absolute: true,
});
let files = await glob.walk();
if (files.length === 0) {
this.spinner?.warn(`No files matching pattern ${patterns} found.`);
return this;
}
// Ensure that the directory for our output file exists.
await fs.promises.mkdir(path.dirname(this.file), { recursive: true });
// Loop all files sequentially. As we have to write to the DBPF
// sequentially anyway, it doesn't make sense to read them in parallel.
for (let file of files) {
// If this is a TGI file, it won't be added as is, it's meta
// information.
let ext = path.extname(file).toLowerCase();
if (ext === '.tgi') {
continue;
}
// If we expect this to be a DBPF file, then treat them as such.
switch (ext) {
case '.dat':
case '.sc4lot':
case '.sc4desc':
case '.sc4model': {
await this.addDbpfFile(file);
break;
}
default: await this.addSingleFile(file);
}
}
// At last seal the dbpf.
this.spinner?.update('Sealing dbpf');
await this.stream.seal();
this.spinner?.succeed(`Added ${this.counter} files to ${this.file}`);
return this;
}
// Adds a non-DBPF file to the output dbpf.
async addSingleFile(file) {
// Adding this file to the DBPF means that we need to know the tgi
// for it. This is stored in a .TGI file metadata - which is how the
// reader works as well.
let meta = `${file}.TGI`;
const [err, contents] = await attempt(() => fs.promises.readFile(meta));
if (err) {
if (err.code === 'ENOENT') {
this.warnings.push(`${file} has no .TGI meta file, skipping`);
}
else
throw err;
}
// Read in the file from disk and parse the TGI as well. Then we'll
// check whether certain
this.spinner?.update(`Adding ${file}`);
let buffer = await fs.promises.readFile(file);
let tgi = parseTGI(contents);
buffer = transform(buffer, tgi, file);
// Parse the tgi.
await this.stream.add(tgi, buffer, {
compress: this.shouldCompress(file),
});
this.counter++;
}
// Adds an entire dbpf file to the destination dbpf. Note that this is also
// known as *dat packing*.
async addDbpfFile(file) {
this.spinner?.update(`Adding ${file}`);
let dbpf = new DBPF({ file, parse: false });
await dbpf.parseAsync();
await this.stream.addDbpf(dbpf);
this.counter += dbpf.length;
}
}
function parseTGI(buffer) {
let reader = SmartBuffer.fromBuffer(buffer);
let contents = reader.readString('utf8');
return new TGI(contents
.split('\n')
.map(line => line.trim())
.filter(line => !!line)
.map(line => line.replace(/^0x/, ''))
.map(line => Number(`0x${line}`))
.slice(0, 3));
}
function transform(buffer, tgi, file) {
let ext = path.extname(file).toLowerCase();
if (tgi.type === FileType.LTEXT && ext === '.txt') {
let reader = SmartBuffer.fromBuffer(buffer);
return new LTEXT(reader.readString('utf8')).toBuffer();
}
else if ((tgi.type === FileType.Exemplar || tgi.type === FileType.Cohort) &&
ext.match(/\.ya?ml$/)) {
let reader = SmartBuffer.fromBuffer(buffer);
let json = parse(reader.readString('utf8'));
return new Exemplar(json).toBuffer();
}
return buffer;
}