sc4
Version:
A command line utility for automating SimCity 4 modding tasks & modifying savegames
569 lines (568 loc) • 19.8 kB
JavaScript
// # exemplar.js
import { isUint8Array } from 'uint8array-extras';
import Stream from './stream.js';
import WriteBuffer from './write-buffer.js';
import FileType from './file-types.js';
import LotObject, {} from './lot-object.js';
import { ExemplarProperty, kPropertyType } from './exemplar-properties.js';
import { hex, inspect } from 'sc4/utils';
import { kFileType } from './symbols.js';
import { isKey, } from './exemplar-properties-types.js';
import parseStringExemplar from './parse-string-exemplar.js';
import TGI from './tgi.js';
const LotObjectRange = [
+ExemplarProperty.LotConfigPropertyLotObject,
+ExemplarProperty.LotConfigPropertyLotObject + 1279,
];
// Invert the exemplar properties so that we can find the name by id easily.
const idToName = new Map(Object.entries(ExemplarProperty).map(([name, object]) => {
return [+object, name];
}));
let config = +ExemplarProperty.LotConfigPropertyLotObject;
for (let i = 1; i < 1280; i++) {
idToName.set(config + i, 'LotConfigPropertyLotObject');
}
const TypeInfo = {
Uint8: {
hex: 0x100,
bytes: 1,
read: (rs) => rs.uint8(),
write: (buff, value) => buff.writeUInt8(value),
},
Uint16: {
hex: 0x200,
bytes: 2,
read: (rs) => rs.uint16(),
write: (buff, value) => buff.writeUInt16LE(value),
},
Uint32: {
hex: 0x300,
bytes: 4,
read: (rs) => rs.uint32(),
write: (buff, value) => buff.writeUInt32LE(value),
},
Sint32: {
hex: 0x700,
bytes: 4,
read: (rs) => rs.int32(),
write: (buff, value) => buff.writeInt32LE(value),
},
Sint64: {
hex: 0x800,
bytes: 8,
read: (rs) => rs.bigint64(),
write: (buff, value) => buff.writeBigInt64LE(value),
},
Float32: {
hex: 0x900,
bytes: 4,
read: (rs) => rs.float(),
write: (buff, value) => buff.writeFloatLE(value),
},
Bool: {
hex: 0xb00,
bytes: 1,
read: (rs) => Boolean(rs.uint8()),
write: (buff, value) => buff.writeUInt8(Number(value)),
},
String: {
hex: 0xc00,
bytes: 1,
read: (rs, length) => rs.string(length),
write: (buff, str) => buff.string(str),
},
};
const HEX_TO_TYPE = Object.fromEntries(Object.entries(TypeInfo)
.map(([type, { hex }]) => [hex, type]));
// # Exemplar()
// See https://www.wiki.sc4devotion.com/index.php?title=EXMP for the spec.
class BaseExemplar {
id = 'EQZB1###';
parent;
properties = [];
#lotObjects;
#table = new Map();
// ## constructor(data = {})
// Creates a new exemplar. Note that this should support copy-constructing.
constructor(data = {}) {
if (isUint8Array(data)) {
this.parse(data);
return;
}
const isClone = data instanceof this.constructor;
this.id = data.id || 'EQZB1###';
this.parent = new TGI(data.parent || [0, 0, 0]);
this.properties = [...data.properties || []].map(def => {
return (isClone || !(def instanceof Property) ?
new Property(def) :
def);
});
Object.defineProperty(this, 'table', {
enumerable: false,
configurable: true,
writable: true,
value: null,
});
// Immediately create the table upon construction.
this.createTable();
}
// ## clone()
clone() {
const Constructor = this.constructor;
return new Constructor(this);
}
// ## get fileType()
get fileType() {
return FileType.Exemplar;
}
// ## *[Symbol.iterator]()
*[Symbol.iterator]() {
yield* this.properties;
}
// ## get lotObjects()
// Returns a list of all LotConfigPropertyLotObject properties in the
// exemplar. They always start at 0x88EDC900 and then continue one by one.
// Note that we don't parse them right away because parsing them might be
// expensive, so we'll only parse them "just in time". Once they are parsed,
// we'll use the objects instead of the raw values in the properties!
get lotObjects() {
if (this.#lotObjects)
return this.#lotObjects;
const table = this.#table;
let i = +ExemplarProperty.LotConfigPropertyLotObject;
let out = [];
let entry = table.get(i);
while (entry) {
out.push(new LotObject(entry.value));
entry = table.get(++i);
}
this.#lotObjects = out;
return out;
}
// ## set lotObjects()
// Sets the lot objects. Can be useful to clear a lot of all objects.
set lotObjects(lotObjects) {
this.#lotObjects = lotObjects;
}
// ## prop(key)
// Helper function for accessing a property.
prop(key) {
let id = normalizeId(key);
return this.#table.get(id);
}
// ## value(key)
// Helper function for directly accessing the value of a property. We also
// provide some syntactic sugar here over getting the *raw* property. If the
// property is an array, but it was not stored like that in the exemplar
// properties, then we'll automatically unwrap so that we always work with
// the correct data format when using known values!
value(key) {
let prop = this.prop(key);
if (!prop)
return undefined;
return prop.getSafeValue();
}
// ## get(key)
// Alias for `value(key)`
get(key) {
return this.value(key);
}
// ## set(key, value)
// Updates the value of a rop by key.
set(key, value) {
let prop = this.prop(key);
if (prop) {
prop.value = value;
}
return this;
}
addProperty(idOrPropOptions, value, typeHint = 'Uint32') {
let options;
if (isKey(idOrPropOptions)) {
if (typeof value === 'undefined') {
throw new TypeError(`You must specify a value for a property!`);
}
let id = normalizeId(idOrPropOptions);
let type = normalizeType(id, value, typeHint);
options = { id, value, type };
}
else {
let { id, ...rest } = idOrPropOptions;
options = {
id: normalizeId(id),
...rest,
};
}
let prop = new Property(options);
this.properties.push(prop);
this.#table.set(prop.id, prop);
return prop;
}
// ## parse(bufferOrStream)
// Parses an exemplar file from a buffer.
parse(bufferOrStream) {
const rs = new Stream(bufferOrStream);
let id = this.id = rs.string(8);
// Check the id's 4the byte. If this is "T", then we're reading in a
// text exemplar. Otherwise we're reading a binary one. Can't find any
// documentation on the textual representation though, but ok.
let isText = id[3] === 'T';
if (isText) {
this.parseFromString(rs.string(Infinity));
// Not parsing for now.
return this;
}
// Get the parent cohort TGI. Set to 0 in case of no parent.
this.parent = rs.tgi();
// Read all properties one by one.
const count = rs.uint32();
const props = this.properties = new Array(count);
for (let i = 0; i < count; i++) {
let prop = props[i] = new Property();
prop.parse(rs);
}
// Create the property table as well.
this.createTable();
return this;
}
// ## parseFromString(str)
parseFromString(str) {
let obj = parseStringExemplar(str);
this.parent = new TGI(obj.parent);
this.properties = obj.properties.map(def => {
return new Property({
id: def.id,
type: def.type,
value: def.value,
});
});
// Create the property table as well.
this.createTable();
return this;
}
// ## createTable()
createTable() {
const table = this.#table = new Map();
for (let prop of this.properties) {
table.set(prop.id, prop);
}
return this;
}
// ## toBuffer()
// Serializes the exemplar file into an uncompressed buffer. Can be used
// to save dbpf files again.
toBuffer() {
// Initialze the buffer.
let buffer = new WriteBuffer();
// Write away the id, but only use the binary format for now, so
// ensure the 4th byte is the character B.
buffer.writeString(this.id);
buffer.writeString('B', 3);
for (let tgi of this.parent) {
buffer.writeUInt32LE(tgi);
}
// IMPORTANT! If the lot objects have been parsed, then we have to
// filter them out from our raw props.
let { properties: props } = this;
if (this.#lotObjects) {
let [min, max] = LotObjectRange;
props = props.filter(prop => {
return !(min <= prop.id && prop.id <= max);
});
let i = +ExemplarProperty.LotConfigPropertyLotObject;
for (let lotObject of this.#lotObjects) {
let prop = new Property({
id: i++,
value: lotObject.toArray(),
});
props.push(prop);
}
}
// Write all properties to the buffer as well. Note that writing
// arrays is handled automatically.
buffer.array(props);
return buffer.toUint8Array();
}
// ## toJSON()
// Serializes the exemplar as json. Note that the json might actually
// include Bigints as well, so it's not pure json, but yaml is able to
// handle this properly.
toJSON() {
return {
parent: [...this.parent],
properties: this.properties.map(prop => {
let { name } = prop;
return {
id: prop.id,
...(name ? { name } : null),
type: prop.type,
value: prop.value,
};
}),
};
}
}
// # normalaizeId(idOrName)
// Looks up the *numeric* property id, but also allows looking up by name.
function normalizeId(idOrName) {
if (typeof idOrName === 'string') {
if (idOrName in ExemplarProperty) {
return +ExemplarProperty[idOrName];
}
else {
throw new Error(`Unknown exemplar property name ${idOrName}!`);
}
}
else {
return +idOrName;
}
}
// # normalizeType(id, value, typeHint)
// Tries to figure out the type of this property in an intelligent way. We first
// check whether the id is a known id. If that's the case, then we know the type
// right away. Otherwise we try to derive it from the value, and as a last
// resort we rely on the type hint, which defaults to uint32.
function normalizeType(id, value, typeHint = 'Uint32') {
let name = idToName.get(id);
if (name && name in ExemplarProperty) {
let object = ExemplarProperty[name];
if (typeof object === 'number')
return 'Uint32';
let [type] = [object[kPropertyType]].flat();
return type;
}
// The property is not a known property, so we're in uncharted territory.
// Now check if we can figure out the type from the value itself.
if (typeof value === 'string')
return 'String';
let [first] = [value].flat();
if (typeof first === 'boolean')
return 'Bool';
else if (typeof first === 'bigint')
return 'Sint64';
else
return typeHint;
}
// # Exemplar
export class Exemplar extends BaseExemplar {
static [kFileType] = FileType.Exemplar;
id = 'EQZB1###';
}
// # Cohort
// A cohort is a specific kind of exemplar. There are no differences, except for
// the type id and the id field.
export class Cohort extends BaseExemplar {
static [kFileType] = FileType.Cohort;
id = 'CQZB1###';
}
// # Property()
// Wrapper class around an Exemplar property.
class Property {
id = 0x00000000;
type = 'Uint32';
value;
// ## constructor({ id, type, value } = {})
// If the data passed is a property, then we'll use a *clone* strategy.
constructor(data) {
let isClone = data instanceof Property;
let { id = 0, type = getTypeFromId(id), value } = data || {};
this.id = +id;
this.type = type;
this.value = value !== undefined ? cast(type, isClone ? structuredClone(value) : value) : undefined;
}
// ## getSafeValue()
// This function handles the fact that sometimes a property can be stored as
// an array, while the schema as defined in new_properties.xml actually
// defines the property as a single-value property and vice versa. This can
// lead to runtime errors, so it is advised to use `getSafeValue()` instead
// as this performs the required checks. Also note that TypeScript can't
// really help us here: it's a runtime issue!
getSafeValue() {
let { name, value } = this;
if (name && value !== undefined) {
let info = ExemplarProperty[name];
let shouldBeArray = typeof info !== 'number' && Array.isArray(info[kPropertyType]);
// Note: we use any below because TypeScript knows that somethings
// wrong if this happens - which is indeed the case! However, there
// is no runtime guarantee for this, so our runtime correction is
// labeled as invalid by TypeScript. "any" to the rescue.
let isArray = Array.isArray(value);
if (shouldBeArray && !isArray) {
return [value];
}
else if (!shouldBeArray && isArray) {
return value[0];
}
else {
return value;
}
}
else {
return value;
}
}
// ## get name()
get name() {
return idToName.get(this.id) ?? '';
}
// ## [Symbol.toPrimitive]()
// Casting the prop to a number will return the numeric value.
[Symbol.toPrimitive](hint) {
return hint === 'number' ? this.value : this.hex;
}
// ## get hex()
// Computed property that shows the hex value of the property name. Useful
// when comparing this with Reader, because Reader shows everything in hex
// by default.
get hex() {
return hex(this.id);
}
// ## get hexType()
get hexType() {
return TypeInfo[this.type].hex;
}
// ## get keyType()
get keyType() {
const value = this.value;
return Array.isArray(value) || typeof value === 'string' ? 0x80 : 0x00;
}
// ## get multiple()
get multiple() {
return this.keyType === 0x80;
}
// ## get byteLength()
// Computes the byteLength of the **value** part of the property. This
// means that it depends on whether the property contains multiple values,
// or only a single value. Note that strings are considered to hold
// multiple values because we need to store the string length here!
get byteLength() {
let { type, value } = this;
let { bytes } = TypeInfo[type];
return this.multiple ? (4 + value.length * bytes) : bytes;
}
// ## parse(rs)
// Parses the property from a buffer wrapped up in a stream object that
// allows for easier reading.
parse(rs) {
// Parse value type & associated reader.
this.id = rs.uint32();
let nr = rs.uint16();
let type = this.type = HEX_TO_TYPE[nr];
let { read } = TypeInfo[type];
// Parse key type.
let keyType = rs.uint16();
if (keyType === 0) {
void rs.uint8();
this.value = read(rs);
}
else if (keyType === 0x80) {
void rs.uint8();
let reps = rs.uint32();
// If we're dealing with a string, read the string. Otherwise
// read the values using the repetitions. Note that this means
// that strings can't be repeated!
// Note: the "as Value<K>" expressions are needed because TypeScript
// doesn't have access to runtime information. This means that it
// can't guarantee us that Value<K> can hold a string at runtime
// because it might just as well evaluate to int32[] or something.
// Hence using "as" is justified here.
if (type === 'String') {
this.value = rs.string(reps);
}
else {
let values = [];
for (let i = 0; i < reps; i++) {
values.push(read(rs));
}
this.value = values;
}
}
// Return ourselves.
return this;
}
// ## toBuffer()
// Serializes the property to a binary buffer.
toBuffer() {
// A property is small enough to be returned as single buffer.
// Pre-calculate it's size.
let { type, value } = this;
let buff = new WriteBuffer();
// Write the property's numerical value.
buff.writeUInt32LE(this.id);
// Write the property's value type. Note that you should ensure
// yourself that the value type matches the actual type stored in the
// value!
buff.writeUInt16LE(this.hexType);
// Write away the key type. This depends on whether the value is an
// array or a string.
buff.writeUInt16LE(this.keyType);
// Unused flag.
buff.writeUInt8(0);
// Write away the values.
if (typeof value === 'string') {
buff.string(value);
}
else {
const { write } = TypeInfo[type];
if (Array.isArray(value)) {
buff.writeUInt32LE(value.length);
for (let entry of value) {
write(buff, entry);
}
}
else {
write(buff, value);
}
}
return buff.toUint8Array();
}
// ## [Symbol.for('nodejs.util.inspect.custom')](depth, opts, inspect)
// Allow custom inspection in Node.js
[Symbol.for('nodejs.util.inspect.custom')]() {
// The value to be inspected depends on the type.
let { type, value } = this;
let tf = (x) => x;
switch (type) {
case 'Uint8':
case 'Uint16':
case 'Uint32':
tf = (x) => inspect.hex(x);
}
if (value !== undefined) {
value = Array.isArray(value) ? value.map(tf) : tf(value);
}
return {
id: inspect.hex(this.id),
name: this.name,
type,
value,
};
}
}
// # cast(type, value)
// Ensures a value specified for a property matches its specified type.
function cast(type, value) {
if (typeof value === 'undefined')
return value;
if (Array.isArray(value)) {
return value.map(value => cast(type, value));
}
switch (type) {
case 'String': return String(value);
case 'Bool': return Boolean(value);
case 'Sint64': return BigInt(value);
default: return Number(value);
}
}
// # getTypeFromId()
function getTypeFromId(id) {
let name = idToName.get(id);
if (name !== undefined) {
let info = ExemplarProperty[name];
if (typeof info === 'number')
return 'Uint32';
let type = info[kPropertyType];
return Array.isArray(type) ? type[0] : type;
}
return 'Uint32';
}