sc4
Version:
A command line utility for automating SimCity 4 modding tasks & modifying savegames
729 lines (728 loc) • 29.5 kB
JavaScript
// # city-manager.ts
import { path, fs, hex } from 'sc4/utils';
import { Savegame, Lot, Building, Prop, BaseTexture, Pointer, FileType, SimGrid, ExemplarProperty as Property, Box3, ExemplarProperty, Vector3, TGI, } from 'sc4/core';
import getOrientedPosition from './get-oriented-position.js';
const INSET = 0.1;
// # CityManager
// A class for performing operations on a certain city, such as plopping
// arbitrary lots etc. Have a look at https://sc4devotion.com/forums/
// index.php?topic=5656.0, contains a lot of relevant info.
export default class CityManager {
dbpf;
ctx;
index;
// ## constructor(opts)
// Sets up the city manager.
constructor(opts = {}) {
let { dbpf, index } = opts;
if (dbpf) {
this.dbpf = dbpf;
this.ctx = dbpf.createContext();
}
if (index)
this.index = index;
}
// ## get city()
// Alias the dbpf as a city.
get city() {
return this.dbpf;
}
// ## setFileIndex(index)
// Stores the file index to be used for looking up TGI's etc. That's
// required if you want to plop lot's etc. because in that case we need to
// know where to look for the resources!
setFileIndex(index) {
this.index = index;
}
// ## load(file)
// Loads the given savegame into the city manager.
load(file) {
// No extension given? Add .sc4
let full = path.resolve(process.env.SC4_REGIONS ?? process.cwd(), file);
let ext = path.extname(full);
if (ext !== '.sc4') {
full += '.sc4';
}
// Check if the file exists. If it doesn't exist, then try again
// with "City - " in front.
if (!fs.existsSync(full)) {
let name = path.basename(full);
let dir = path.dirname(full);
full = path.join(dir, 'City - ' + name);
if (!fs.existsSync(full)) {
throw new Error(`City "${file}" could not be found!`);
}
}
// Create the city.
this.dbpf = new Savegame({ file: full });
this.ctx = this.dbpf.createContext();
return this.dbpf;
}
// ## save(opts)
// Saves the city to the given file.
save(opts) {
return this.dbpf.save(opts);
}
// ## mem()
// Returns an unused memory address. This is useful if we add new stuff to
// a city - such as buildings etc. - because we need to make sure that the
// memory addresses for every record are unique.
mem() {
return this.ctx.mem();
}
// ## getProperty(file, key)
// Helper function for getting a property from an exemplar, taking into
// account the inheritance chain. It's the index that is actually
// responsible for this though.
getProperty(file, key) {
return this.index.getProperty(file, key);
}
// ## getPropertyValue(file, prop)
// Returns the direct value for the given property.
getPropertyValue(file, key) {
return this.index.getPropertyValue(file, key);
}
// ## plop(opts)
// Behold, the mother of all functions. This function allows to plop any
// lot anywhere in the city. Note that this function expects a *building*
// exemplar, which means it only works for *ploppable* buildings. For
// growable buildings the process is different, in that case you have to
// use the "grow" method.
plop(opts) {
// (1) First of all we need to find the T10 exemplar file with the
// information to plop the lot. Most of the time this resides in an
// .sc4lot file, but it doesn't have to.
let { tgi, building } = opts;
if (!building && tgi) {
building = this.index.find(tgi);
if (!building) {
throw new Error(`Exemplar ${JSON.stringify(tgi)} not found!`);
}
}
// Check what type of exemplar we're dealing with. As explained by
// RippleJet, there's a fundamental difference between ploppable and
// growable buildings. Apparently ploppable buildings start from a
// building exemplar and then we can look up according
// LotConfiguration exemplar.
let file = building.read();
if (this.getPropertyValue(file, 'ExemplarType') !== Property.ExemplarType.Buildings) {
throw new Error([
'The exemplar is not a building exemplar!',
'The `.plop()` function expects a ploppable building exemplar!',
].join(' '));
}
// Find the lot resource key, which is the IID where we can find the
// LotResourceKey & then based on that find the appropriate Building
// exemplar. Note that we currently have no other choice than finding
// everything with the same instance ID...
let IID = this.getPropertyValue(file, Property.LotResourceKey);
let lotExemplar = this.findExemplarOfType(IID, Property.ExemplarType.LotConfigurations);
// Cool, we have both the building & the lot exemplar. Create the lot.
this.build({
lot: lotExemplar,
building: building,
x: opts.x,
z: opts.z,
orientation: opts.orientation,
});
}
// ## grow(opts)
// This method is similar to the `plop()` method, but this time it starts
// from a *Lot Configurations* exemplar, not a ploppable building exemplar
// - which is how the game does it. From then on the logic is pretty much
// the same.
grow(opts) {
let { lot = this.index.find(opts.tgi), } = opts;
if (!lot) {
throw new Error(`Exemplar ${new TGI(opts.tgi)} not found!`);
}
// Ensure that the exemplar that was specified.
let props = lot.read();
if (props.get(Property.ExemplarType) !== Property.ExemplarType.LotConfigurations) {
throw new Error([
'The exemplar is not a lot configurations exemplar!',
'The `.grow()` function expects a lot exemplar!',
].join(' '));
}
// Find the appropriate building exemplar. Note that it's possible
// that the building belongs to a family. In that case we'll pick a
// random building from the family.
let { building } = opts;
if (!building) {
let { IIDs } = props.lotObjects.find(obj => obj.type === 0x00);
let IID = rand(IIDs);
building = this.findExemplarOfType(IID, 0x02);
if (!building) {
let name = props.value(0x20);
console.warn([
`Unable to find a building for ${name} (${hex(IID)})!`,
'You might be missing a dependency!',
].join(' '));
return false;
}
}
// Now that we have both the building exemplar and as well as the lot
// exemplar we can create the lot and insert everything on it into the
// city.
let { x, z, orientation } = opts;
return this.build({
lot,
building,
x,
z,
orientation,
});
}
// ## build(opts)
// This method is responsible for inserting all *physical* entities into
// the city such as a lot, a building, the props on the lot, the textures
// etc. It's not really meant for public use, you should use the `.plop()`
// or `.grow()` methods instead. It requires a lot exemplar and a building
// exemplar to be specified. It's the `.plop()` and `.grow()` methods that
// are responsible for deciding what building will be inserted.
build(opts) {
// First of all create the lot record & insert it into the city.
let { lot: lotExemplar, building, orientation = 0, } = opts;
let lot = this.createLot({
exemplar: lotExemplar,
building,
x: opts.x,
z: opts.z,
orientation,
});
// Loop all objects on the lot such and insert them.
let { lotObjects } = lotExemplar.read();
let textures = [];
for (let lotObject of lotObjects) {
switch (lotObject.type) {
case 0x00:
this.createBuilding({
lot,
lotObject,
exemplar: building,
});
break;
case 0x01: {
this.addLotObject({ lot, lotObject });
break;
}
case 0x02:
// Note: We can't handle textures right away because they
// need to be put in a *single* BaseTexture entry. As such
// we'll simply collect them for now.
textures.push(lotObject);
break;
}
}
// Create the textures.
this.createTexture({
lot,
textures,
});
// At last return the created lot so that the calling function can
// modify the properties such as capcity, zoneWealth, zoneDensity etc.
return lot;
}
// ## addLotObject()
// Carries out the logic of adding the most general type of lot objects:
// buildings, props and flora.
addLotObject({ lot, lotObject }) {
// If this is a prop or building family, then there will be multiple
// instance ids in the lotobject. Hence we'll first pick a random one.
let { OID, IIDs } = lotObject;
let instance = rand(IIDs);
// Look up the exemplar with the given instance. If it doesn't exist,
// it's a missing dependency, so we return "null".
let exemplarEntry = this.findExemplarOfType(instance, ExemplarProperty.ExemplarType.Prop);
if (!exemplarEntry)
return null;
// Calculate the city position of the object by using the position on
// the lot, orienting it and then adding the city offset to it.
let position = getOrientedPosition({ lot, lotObject })
.add([16 * lot.minX, 0, 16 * lot.minZ]);
// There are cases apparently where a prop has been misplaced, causing
// it to fall outside the city. Props like these won't be added
// obviously.
const { metricWidth, metricDepth } = this.city;
if (position.x < 0 ||
position.y < 0 ||
position.x > metricWidth ||
position.z > metricDepth)
return false;
// At last insert the prop as well.
let exemplar = this.index.getHierarchicExemplar(exemplarEntry.read());
this.createProp({
exemplar,
tgi: new TGI(exemplarEntry.tgi),
position,
orientation: (lot.orientation + lotObject.orientation) % 4,
OID,
lotType: 0x02,
});
return true;
}
// ## zone(opts)
// The function responsible for creating RCI zones. Note that we **don't**
// use the createLot function underneath as that
zone(opts) {
let { x = 0, z = 0, width = 1, depth = 1, orientation = 0, zoneType = 0x01, } = opts;
let { dbpf } = this;
let { lots, zones } = dbpf;
// Create the lot with the zone.
let lot = new Lot({
mem: this.mem(),
flag1: 0x10,
yPos: 270,
minX: x,
maxX: x + width - 1,
minZ: z,
maxZ: z + depth - 1,
commuteX: x,
commuteZ: z,
width,
depth,
orientation,
zoneType,
// An empty growable lot has this flag set to 0, but to 1 when
// it's powered. In theory we need to check hence if the lot is
// reachable by power, but apparently the game does this by
// itself! The only thing we need to make sure is that the second
// bit is **never** set to 1! Otherwise the lot is considered as
// being built!
flag2: 0b00000001,
jobCapacities: [{
demandSourceIndex: 0x00003320,
capacity: 0,
}],
});
lots.push(lot);
// Put in the zone developer file & update the Zone View Sim Grid.
let grid = dbpf.getSimGrid(SimGrid.ZoneData);
for (let x = lot.minX; x <= lot.maxX; x++) {
for (let z = lot.minZ; z <= lot.maxZ; z++) {
grid.set(x, z, zoneType);
zones.cells[x][z] = new Pointer(lot);
}
}
// At last update the com serializer.
let com = dbpf.COMSerializer;
com.set(FileType.Lot, lots.length);
// Return the created zone.
return lot;
}
// ## createLot(opts)
// Creates a new lot object from the given options when plopping a lot.
createLot(opts) {
// Read in the size of the lot because we'll still need it.
let { dbpf } = this;
let { lots } = dbpf;
let { exemplar, x, z, building, orientation = 0 } = opts;
let file = exemplar.read();
let [width, depth] = this.getPropertyValue(file, Property.LotConfigPropertySize);
// Determine the zone type.
let zoneTypes = this.getPropertyValue(file, Property.LotConfigPropertyZoneTypes) ?? [];
let zoneType = zoneTypes[0] || 0x0f;
// Determine the zoneWealth as well. Note that this is to be taken
// **from the building**.
let buildingFile = building.read();
let zoneWealth = this.getPropertyValue(buildingFile, Property.Wealth);
// Cool, we can now create a new lot entry. Note that we will need to
// take into account the
let lot = new Lot({
mem: this.mem(),
IID: exemplar.instance,
buildingIID: building.instance,
// For now, just put at y = 270. In the future we'll need to read
// in the terrain here.
yPos: 270,
minX: x,
maxX: x + (orientation % 2 === 1 ? depth : width) - 1,
minZ: z,
maxZ: z + (orientation % 2 === 1 ? width : depth) - 1,
commuteX: x,
commuteZ: z,
width,
depth,
orientation,
zoneWealth: zoneWealth || 0x00,
zoneType,
// Apparently jobCapacities is also required, otherwise CTD! The
// capacity is stored I guess in the LotConfig exemplar, or
// perhaps in the building exemplar.
jobCapacities: [{
demandSourceIndex: 0x00003320,
capacity: 0,
}],
});
// Push the lot in the lotFile.
lots.push(lot);
// Now put the lot in the zone developer file as well. TODO: We should
// actually check first and ensure that no building exists yet here!
let zones = dbpf.zoneDeveloper;
let grid = dbpf.getSimGrid(SimGrid.ZoneData);
for (let x = lot.minX; x <= lot.maxX; x++) {
for (let z = lot.minZ; z <= lot.maxZ; z++) {
zones.cells[x][z] = new Pointer(lot);
grid.set(x, z, zoneType);
}
}
// Don't forget to update the COMSerializer to include the updated
// length! Otherwise the lot won't show up!
let com = dbpf.COMSerializer;
com.set(FileType.Lot, lots.length);
// Return the lot that we've just created.
return lot;
}
// ## createBuilding(opts)
// Creates a new building record and inserts it into the savegame.
createBuilding(opts) {
let { lot, lotObject, exemplar } = opts;
let file = exemplar.read();
let [, height] = this.getPropertyValue(file, Property.OccupantSize);
let { orientation, y } = lotObject;
// Create the building.
let { terrain } = this.dbpf;
let { minX, maxX, minZ, maxZ } = position(lotObject, lot);
let yPos = terrain.query(0.5 * (minX + maxX), 0.5 * (minZ + maxZ));
let building = new Building({
mem: this.mem(),
// Now use the **rotated** building rectangle and use it to
// position the building appropriately.
bbox: new Box3([minX, yPos + y, minZ], [maxX, yPos + y + height, maxZ]),
orientation: (orientation + lot.orientation) % 4,
// Store the TGI of the building exemplar.
TID: exemplar.type,
GID: exemplar.group,
IID: exemplar.instance,
IID1: exemplar.instance,
});
building.tract.update(building);
// Put the building in the index at the correct spot.
let { dbpf } = this;
this.addToItemIndex(building, FileType.Building);
// Push in the file with all buildings.
let { buildings } = dbpf;
buildings.push(building);
// Add to the lot developer file as well.
let dev = dbpf.lotDeveloper;
dev.buildings.push(new Pointer(building));
// At last update the COMSerializer file.
let com = dbpf.COMSerializer;
com.set(FileType.Building, buildings.length);
return building;
}
// ## createProp(opts)
// Creates a new prop record in and inserts it into the save game. Takes
// into account the position it should take up in a lot.
createProp({ exemplar, tgi, position, orientation = 0, OID = 1, lotType = 0x01, }) {
// Get the dimensions of the prop bounding box.
let size = exemplar.get('OccupantSize');
if (!size) {
let name = exemplar.get('ExemplarName');
console.warn(`Prop ${name} is missing OccupantSize!`);
size = [0, 0, 0];
}
let [width, height, depth] = size;
// If the prop is used with a start date, we'll check the current date
// in the city to determine whether the prop should be active or not.
let condition = 0x00;
let startMonthDay = exemplar.get('SimulatorDateStart');
let timeOfDay = exemplar.get('PropTimeOfDay');
let nightTimeStateChange = exemplar.get('NighttimeStateChange');
let timing = null;
let state = 0;
let start = 0;
let stop = 0;
let powerNeeded = exemplar.get('RequiresPowerToAppear');
let powerFlag = powerNeeded ? 0x00 : 0x08;
if (startMonthDay) {
// Read in the current date of the city, and then we'll check if the
// prop should be active during this interval.
let duration = exemplar.get('SimulatorDateDuration') ?? 0;
let interval = exemplar.get('SimulatorDateInterval') ?? 0;
let [startMonth, startDay] = startMonthDay;
let { date } = this.dbpf.date;
let start = date.with({ month: startMonth, day: startDay });
let end = start.add({ days: duration });
if (date <= end) {
if (date < start) {
state = 1;
condition = 0x05 | powerFlag;
}
else {
start = start.add({ years: 1 });
state = 0;
condition = 0x0f;
}
}
else {
start = start.add({ years: 1 });
end = end.add({ years: 1 });
if (date >= start) {
start = start.add({ years: 1 });
state = 0;
condition = 0x0f;
}
else {
state = 1;
condition = 0x05 | powerFlag;
}
}
timing = {
interval,
duration,
start,
end,
};
}
else if (timeOfDay) {
let [startHour, stopHour] = timeOfDay;
start = startHour * 10;
stop = stopHour * 10;
// Figure out whether the prop should be active or not.
let { clock } = this.dbpf;
let dayHour = (clock.secondOfDay) / 360;
if (start < stop) {
if (start < dayHour && dayHour < stop) {
state = 0;
condition = 0x0f;
}
else {
state = 1;
condition = 0x06 | powerFlag;
}
}
else {
if (dayHour > start || dayHour < stop) {
state = 0;
condition = 0x0f;
}
else {
state = 1;
condition = 0x06 | powerFlag;
}
}
}
else if (nightTimeStateChange) {
// Night time is apparently between 9pm and 4am. Could be random as
// well, we don't really know. It isn't really relevant either.
let { clock } = this.dbpf;
let dayHour = (clock.secondOfDay) / 3600;
if (dayHour > 21 || dayHour < 3.75)
state = 1;
}
// The bounding box of the prop depends on its orientation of course.
if (orientation % 4 === 1) {
[width, depth] = [depth, width];
}
// Create the prop & position correctly.
let { terrain } = this.dbpf;
let yPos = terrain?.query(position.x, position.z) ?? 270;
let y = position.y + yPos;
let prop = new Prop({
mem: this.mem(),
bbox: new Box3([position.x - 0.5 * width, y, position.z - 0.5 * depth], [position.x + 0.5 * width, y + height, position.z + 0.5 * depth]),
orientation,
tgi,
OID,
appearance: 5,
start,
stop,
state,
condition,
timing,
lotType,
});
prop.tract.update(prop);
// Push in the file with all props.
let { dbpf } = this;
let { props } = dbpf;
props.push(prop);
// If it's a timed prop, we have to reference it in the prop developer
// as well.
if (startMonthDay) {
dbpf.propDeveloper.dateTimedProps.push(new Pointer(prop));
}
else if (timeOfDay) {
dbpf.propDeveloper.hourTimedProps.push(new Pointer(prop));
}
else if (nightTimeStateChange) {
dbpf.propDeveloper.nightTimedProps.push(new Pointer(prop));
}
// Put the prop in the index.
this.addToItemIndex(prop, FileType.Prop);
// Update the COM serializer and we're done.
let com = dbpf.COMSerializer;
com.set(FileType.Prop, props.length);
return prop;
}
// ## createTexture(opts)
// Creates a texture entry in the BaseTexture file of the city for the
// given lot.
createTexture(opts) {
// Apparently the game requires "insets" on the texture - which it
// sets to 0.1, which get rounded to Float32's by the way.
let { lot, textures } = opts;
let minX = 16 * lot.minX + INSET;
let maxX = 16 * (lot.maxX + 1) - INSET;
let minZ = 16 * lot.minZ + INSET;
let maxZ = 16 * (lot.maxZ + 1) - INSET;
// TODO: This is only for flat cities, should use terrain queries
// later on!
let minY = lot.yPos;
let maxY = lot.yPos + INSET;
// Create a new texture instance and copy some lot properties in it.
let texture = new BaseTexture({
mem: this.mem(),
bbox: new Box3([minX, minY, minZ], [maxX, maxY, maxZ]),
});
texture.tract.update(texture);
// Add all required textures.
for (let def of textures) {
let { orientation, x, z, IID } = def;
let [xx, zz] = orient([x, z], lot, { bare: true });
// Note: the orientation is given in **lot** coordinates,
// but orientation 0 in the city is 2 in the lot, so add 2 to it.
// Additionally we'll also need to handle mirroring.
let mirrored = orientation >= 0x80000000;
orientation %= 0x80000000;
orientation = (lot.orientation + orientation) % 4;
if (mirrored) {
orientation += 4;
}
// Create the texture at last.
texture.add({
IID,
x: lot.minX + Math.floor(xx),
z: lot.minZ + Math.floor(zz),
orientation,
});
}
// Cool, now push the base texture in the city & update the
// COMSerializer as well.
let { dbpf } = this;
dbpf.textures.push(texture);
let com = dbpf.COMSerializer;
com.set(FileType.BaseTexture, dbpf.textures.length);
// Update the item index as well.
this.addToItemIndex(texture, FileType.BaseTexture);
// Return the base texture that we've created.
return texture;
}
// ## addToItemIndex(obj)
// Helper function for adding the given object - that exposes the tract
// coordinates - to the item index.
addToItemIndex(obj, type) {
this.dbpf.itemIndex.add(obj, type);
}
// ## findExemplarOfType(IID, type)
// Helper function that can find an exemplar with the given instance of
// the given type. It will make use of the families we have as well.
findExemplarOfType(IID, type) {
let { index } = this;
let family = index.family(IID);
const filter = (entry) => {
let file = entry.read();
return index.getPropertyValue(file, 0x10) === type;
};
if (family) {
let exemplars = family.filter(filter);
return exemplars[Math.random() * exemplars.length | 0];
}
else {
return index
.findAll({ type: FileType.Exemplar, instance: IID })
.filter(filter)
.at(-1);
}
}
// ## clear()
// Clears the entire city from lots, props textures and flora. Note that
// this function is incomplete as it is not fully understood yet how all the
// pieces in a city work together. For example, when clearing a city, the
// terrain textures won't show up again. That's probably because a flag is
// set somewhere that terrain should not be shown, but we haven't figured it
// out yet where this is stored.
clear() {
const { city } = this;
const index = city.itemIndex;
const com = city.COMSerializer;
const clear = (type) => {
let file = city.readByType(type);
if (file) {
file.length = 0;
index.rebuild(type, file);
com.set(type, 0);
}
};
clear(FileType.Lot);
clear(FileType.Building);
clear(FileType.Prop);
clear(FileType.Flora);
clear(FileType.BaseTexture);
// Clear both the lot and zone developer files as well.
city.lotDeveloper.clear();
city.zoneDeveloper.clear();
city.propDeveloper.clear();
// Clear some simgrids.
city.getSimGrid(SimGrid.ZoneData)?.clear();
city.getSimGrid(SimGrid.Power)?.clear();
// Render all terrain tiles again.
city.terrainFlags.clear();
}
}
// ## orient([x, y], lot, opts)
// Helper function for transforming the point [x, y] that is given in
// **local** lot coordinates into global **city** coordinates. Note that local
// lot coordinates use an origin in the bottom-left corner of the lot with an
// y axis that is going up. This means that we'll need to invert properly!
function orient([x, y], lot, opts = {}) {
let { width, depth } = lot;
// First of all we need to swap because orientation 0 in the city is "up",
// while orientation 0 in the is "down", and that's also how the
// coordinates are expressed!
[x, y] = [width - x, depth - y];
// Based on the lot orientation, position correctly.
switch (lot.orientation) {
case 0x01:
[x, y] = [depth - y, x];
break;
case 0x02:
[x, y] = [width - x, depth - y];
break;
case 0x03:
[x, y] = [y, width - x];
break;
}
// If we didn't request bare coordinates explicitly, transform to city
// coordinates.
if (opts.bare) {
return [x, y];
}
else {
return [
16 * lot.minX + x,
16 * lot.minZ + y,
];
}
}
// ## position(lotObject, lot)
// Returns the rectangle we need to position the given lotObject on, taken
// into account it's positioned on the given lot.
function position(lotObject, lot) {
let { minX, maxX, minZ, maxZ } = lotObject;
[minX, minZ] = orient([minX, minZ], lot);
[maxX, maxZ] = orient([maxX, maxZ], lot);
if (minX > maxX) {
[minX, maxX] = [maxX, minX];
}
if (minZ > maxZ) {
[minZ, maxZ] = [maxZ, minZ];
}
return { minX, maxX, minZ, maxZ };
}
// ## rand(arr)
// Helper function that randomly selects a value from a given array.
function rand(arr) {
return arr[Math.random() * arr.length | 0];
}