Modular constructions¶
Modular constructions (rail stations, airports, harbours, warehouses, the headquarter, and industries with their fields) can be changed after building: the player adds and removes modules in slots. The construction itself is an ordinary .con.lua with an update function, described on Constructions. This page covers slots, modules, the order in which their scripts run, and templates, which give a new construction its first modules. The official wiki covers the same ground on Modular Constructions and Construction Templates.
How the scripts run¶
Each time a modular construction is previewed or built, construction.constructWithModules in base/content/scripts/scripts/construction/construction.script.tl runs the scripts in this order:
- The construction's
updateFn(captureParams, params)builds the base result, includingresult.slots. - If
params.modulesis set, the modules are sorted by ascending slot id. For each one the slot with that id is looked up inresult.slots. If there is none andresult.callInvalidModulesis nottrue, the module is skipped. - The module's
updateFnis called with the result, the slot's transform and a tag"__module_<slotId>". While it runs,params.seedis the construction's seed plus the slot id. modulesutil.addCosts(result, module)adds the module'smetadata.price,metadata.maintenanceCostandmetadata.bulldozePriceto the result.- After the last module,
result.terminateConstructionHook()runs if the construction set one.
Because modules run after the construction and in slot id order, a module sees the slots and data that the construction and all earlier modules put into result, and it can add new slots for later modules. Constructions also store their own helper functions in result for the modules to call, as the rail station does with GetCoord and GetRoofAt.
The construction's update function¶
Next to the player's parameters, params.modules maps each slot id to the module placed there (ModuleUpdateFnParams.Module):
modules = {
[10001000] = {
metadata = {},
name = "airfield_main_building.module",
updateScript = { fileName = "", params = {} },
variant = 0,
},
},
metadata is a copy of the module's metadata table, name the module file and updateScript its update function. variant changes when the player presses M and N while placing a module and can be negative; the default is 0. The headquarter uses it as a rotation in quarter turns (base/content/landmarks/hq/hq/headquarter_addon.script.tl).
result.cost is the cost of the construction alone; the module prices are added in step 4 above.
Slots¶
result.slots lists the slots that currently exist (Slot):
| Field | Meaning |
|---|---|
id |
non-negative unique id; params.modules is keyed by it |
type |
only modules with the same type can be built here |
transf |
position relative to the construction origin |
spacing |
size of the slot around transf, { -x, x, -y, y } |
height |
height above transf; with spacing used for selection and bulldozing |
shape |
marker symbol: 0 square, 1 triangle, 2 transverse rectangle, 3 longitudinal rectangle |
autofill |
slot counts as filled when an industry is first built (fields and satellites) |
alignToTerrain |
slot follows the terrain height |
replaceable |
a module of the same type can replace the one in the slot |
The wiki and the shipped code (base/content/industries/fieldutil.lua) write autofill; the Teal definition spells it autoFill.
Many base constructions encode a grid position and the slot type into the id. The warehouse, from base/content/warehouses/warehouses/warehouse.script.lua:
local EncodeSlotId = function(coord, type)
return typesNum * (coordNum * (coord.y + coordOffset) + (coord.x + coordOffset)) + type2id[type]
end
and creates its slots in updateFn with table.insert(result.slots, { id = ..., transf = ..., type = type, spacing = {...}, height = ..., replaceable = true }).
result.slotConfig sets limits per slot type (SlotConfig): maxModules is the most modules of that type in the whole construction, message the text shown on the module in the menu when it can't be built, and skipCollisionCheck = true stops the slot markers turning red when something is in the way (the module's colliders still count). The wiki gives maxModules = -2 a special meaning: the module button is enabled while message is empty and disabled when a message is set. The airport uses this to require a second runway before terminal B (base/content/stations/air/air/airport.script.lua):
result.slotConfig = {
["2nd_runway"] = { maxModules = -1, message = "" },
terminal_B = {
maxModules = -2,
message = hasSecondRunway and "" or _("You Need a 2nd Runway"),
},
}
result.callInvalidModules = true lets modules run even if their slot id is not in result.slots; their transform is then nil.
Modules¶
A module is a .module.lua file (121 in the base game) described by ModuleDesc. From base/content/warehouses/warehouses/wh_bulk.module.lua (shortened):
function data()
return {
description = {
name = _("WAREHOUSE_BULK_NAME"),
description = _("WAREHOUSE_BULK_DESCRIPTION"),
icon = "::/warehouses/wh_bulk_module.tga",
},
availability = { yearFrom = 1850, yearTo = 0, },
type = "warehouse_large",
metadata = {
type = "bulk",
specialization = "BULK",
price = 300000,
maintenanceCost = 50000,
},
updateScript = {
fileName = "modules.script@bulk.updateFn",
params = {}
},
getModelsScript = {
fileName = "modules.script@bulk.getModelsFn",
params = {}
},
}
end
| Field | Meaning |
|---|---|
description, availability, menuCategory |
as for constructions; module tabs are the modules_* categories (see build menu) |
type |
"a module of a specific type can be built only on slots with the same type" (definition comment) |
metadata |
free data for the construction and other modules; price, maintenanceCost and bulldozePrice are added to the costs |
autoRemovable |
removed when something else is built over it, as for farm fields (default false) |
updateScript |
"Script function that gets called once per build module after the updateScript of the construction has run" |
getModelsScript |
"Script function used to generate module preview when building with module builder" |
The wiki's module example also has buildMode = "SINGLE". That field is not in ModuleDesc and no shipped module uses it.
getModelsFn¶
getModelsFn(captureParams) returns the models that follow the mouse cursor while the module is not over a slot: a list of { id = "<model>.mdl", transf = ... }. If the module's models are placed relative to a slot that is offset from the construction, shift them back here. The wiki's roundhouse example returns its three shed models with transf.transl(vec3.new(-30, 0, 0)) because its slots sit 30 m from the turntable centre.
updateFn¶
The game calls the module's update function with seven arguments:
| Argument | Content |
|---|---|
captureParams |
the params of the module's updateScript reference |
result |
the result of the construction and all modules before this one |
transform |
the slot transform; apply it to everything placed relative to the slot. nil with callInvalidModules |
tag |
"__module_<slotId>"; put it on the module's models so they are removed with the module |
slotId |
the slot id |
addModelFn |
addModel(name, transf, tag?, models?): multiplies transf with the slot transform, sets the tag and appends to models (default result.models); returns the index |
constrParams |
the params the construction's update function got, including modules |
Trailing arguments can be left out. The wiki says the function should return result; constructWithModules ignores the return value, so what counts is that the module changes result in place. The wiki's hall roof example for the rail station declares an eighth argument params, which constructWithModules does not pass.
A short version from the wiki, adding a hangar model and a ground face:
updateFn = function(captureParams, result, transform, tag)
local sub = result.subconstructions[1]
sub.models[#sub.models + 1] = { id = "hangar.mdl", transf = transform, tag = tag }
local faces = { {-25.0, -25.0, 0.0, 1.0}, {25.0, -25.0, 0.0, 1.0}, ... }
modulesutil.TransformFaces(transform, faces)
sub.groundFaces[#sub.groundFaces + 1] = {
face = faces,
modes = { { type = "FILL", key = "airfield_hangar.gtex.lua" } },
}
end,
In the warehouse modules the signature is (captureParams, result, transform, tag, slotId, addModelFn, params), and they add models and new slots to the result that the construction built.
Modules can react to their neighbours through constrParams.modules. The wiki's roundhouse example computes the neighbour slot ids from slotId and adds a side wall only where the neighbour is not a shed:
local moduleLeft = constrParams.modules[leftIndex]
if not (moduleLeft ~= nil and moduleLeft.metadata ~= nil and moduleLeft.metadata.type == "shed") then
addModel("shed_wall_left.mdl", transf.transl(vec3.new(-30, 0, 0)), tag, result.subconstructions[1].models)
end
A script file can hold the functions of several modules in a nested table. The module then names them as "roundhouse_modules.script@shed.updateFn":
function data()
return {
shed = { updateFn = updateFnShed, getModelsFn = getModelsFnShed },
empty = { updateFn = updateFnEmpty, getModelsFn = getModelsFnEmpty },
}
end
A new module for a base construction only has to use a slot type of that construction. The wiki's hall roof example has type = "passenger_platform_roof", the type of the vanilla platform roofs, so it can go wherever those go.
The preprocess function¶
When a module is added or removed, a preprocess function updates the construction's parameters before the update runs. Neither ConstructionDesc nor the wiki documents it; 60 base .con.lua files set preProcessScript. The default one is in base/content/base/base/base_config.lua:
defaultPreprocessFn = function(captureParams, params, change)
local modules = params.modules
if change.added then
modules[change.slotId] = change.module
else
modules[change.slotId] = nil
end
return params
end
warehouse.con.lua sets its own with preProcessScript = { fileName = "warehouse.script@preProcessFn" }, as do airport.con.lua and modular_terminal.con.lua.
Upgrade script¶
For a modular construction, upgradeFn(captureParams, params, slotId, constructionConfig) gets the id of the slot the cursor pointed at when the player applied the tool. Otherwise it works as described on Constructions.
Templates¶
A template is a menu entry that builds a construction with a starting set of modules. The game files have two forms.
A metaconstruction is a .metacon.tl file (26 in the base game) described by MetaConstructionDesc, whose comment says it "replaces the deprecated construction templates". It has the menu fields of a construction (availability, description, menuCategory, soundConfig, params, order, heightModView, ...) and a createTemplateScript. The airfields are one example; base/content/stations/air/air/airfield_era_a.metacon.tl:
local airfield = ug_require "::airfield.tl"
return airfield.makeTemplate(airfield.eraAStart, airfield.eraBStart)
airfield.tl returns one description table per era with createTemplateScript = { fileName = "airfield.script@createTemplateFn", params = { } }. The warehouse entries work the same way through warehouse.makeTemplate.
The older form is a constructionTemplates list inside the .con.lua, each entry a ConstructionTemplate with type = "DYNAMIC", availability, description, data.params and menuCategory. ConstructionDesc marks the field "[DEPRECATED] Use metaconstructions instead", but 11 base files still use it, among them the rail station (modular_station.con.lua), the depots and the modular street station. These .con.lua files also set createTemplateScript themselves, as do industries such as forest.con.lua; 68 base .con.lua files have one.
The wiki's Construction Templates page mixes the two: it says templates are defined in metacon.lua files and shows them as a list of entries with type = "DYNAMIC". In the base game the files are .metacon.tl, and each returns a single description table without type; the list with type = "DYNAMIC" is the shape of the deprecated constructionTemplates.
createTemplateFn¶
createTemplateFn(captureParams, params) decides which construction to build with which modules. After the wiki, params contains paramX and paramY (the hotkeys), a seed that increases with every placement or proposal, year, templateIndex (which template was used) and the template's own parameters. The airfield, from base/content/stations/air/air/airfield.script.lua:
createTemplateFn = function(captureParams, params)
local result = {}
local hangar = params.hangar == 1
local terminals = params.terminals and params.terminals or 1
if hangar then
result[terminalSlotId + 0 ] = "airfield/af_hangar.module"
end
local offset = hangar and 2 or 0
result[terminalSlotId + offset ] = "airfield/af_main.module"
result[terminalSlotId + offset + 2 ] = "airfield/af_terminal.module"
-- ... more terminals for params.terminals > 1 ...
return {
constructions = {
{
modules = result,
constructionFileName = "airfield.con",
},
}
}
end,
Each entry of constructions has modules (slot id to module file; {} for a construction without modules), the constructionFileName, and optionally transf (offset from the template origin, default none) and params (parameters for the construction, default the template's params). The warehouse's createTemplateFn in warehouse.script.lua puts one module from its captureParams into one slot:
Official wiki: Modular Constructions, Construction Templates, Construction Basics, Construction Menu.