Field notes · work in progress · not yet linked from the site

Transport Fever Modder Field Notes

The things the docs don't tell you about modding Transport Fever 2 and 3, learned the hard way.

Building Blueprint Exchange, Pay Your Tolls, Insider Trader and the PYT family meant hundreds of crashes, refused builds and silent failures. Each one was written down with what caused it and what fixed it. This page collects the lessons that apply to any Transport Fever mod: engine rules, fatal assertions with their exact text, and the API calls that really work.

TF2 / TF3 which game Fatal crashes the game; pcall can't catch it Inferred a diagnosis not yet confirmed in game. Everything else was seen in game, in a log or in a crash dump.

Debugging & reading crashes

The first step after any crash is reading the file that names the real assertion.

The real error is in stdout.txt TF2TF3

The engine writes the native assertion (C++ file, line and condition) to <steam userdata>\<app id>\local\crash_dump\stdout.txt, with older runs in stdout_old.txt or <guid>_stdout.txt. Nothing else contains it. App ids are 1066780 for TF2 and 3493540 for TF3. Count the .dmp files before and after a quit to catch crashes that happen on exit.

Don't trust the thread or mod named in a crash dump TF2

A dump blamed another mod's guiUpdate thread. That was only the thread running when the game aborted. A log line printed by an unrelated mod just before the assertion was a red herring too, and so were bursts of terrain_util::GetHeightAt: pos not valid: (-nan(ind)…). The cause was an assertion further back in an older log rotation.

Why was my build refused? Read errorState TF2TF3

tostring(res) prints only userdata: 0000…. The reasons are in res.resultProposalData.errorState.messages and .critical. What was hit is in collisionInfo.collisionEntities: its entries are records with an .entity field, not bare ids.

Dry run first: makeProposalData TF2TF3

api.engine.util.proposal.makeProposalData(proposal, context) builds nothing. It returns the costs (era-scaled in TF3), errorState and collisionInfo synchronously, while the async build callback can hide the messages.

  • Critical with 0 messages and 0 entities usually means an invalid reference: a dead node id, or a type lookup that returned "not found".
  • "Construction not possible" is the engine's catch-all. Dry-run each piece on its own to name the culprit. Pieces that pass alone can still fail together.
  • TF3 build 40408: the tealdef declares Proposal, but the game takes a SimpleProposal.

A dry run on top of existing geometry lies TF2

makeProposalData makes every node fresh, with no snapping. An edge ending on a construction it will join reports "Collision", or "Construction not possible" over open grass. A proposal lying exactly on an identical existing piece is judged fine, and the same pieces are then refused elsewhere. To get the game's verdict on a piece, probe it about 400 m to the side, clear of its original.

Listen to the game's own verdict while the player aims TF2

While a construction is being aimed, the placement tool sends builder.proposalCreate to guiHandleEvent, carrying errorState and collisionInfo. Negative collision ids are entities inside the proposal itself (it is colliding with itself); positive ids are real world entities.

builder.apply shows how the game's own tools work TF2

Log guiHandleEvent for builder.apply and you can read the game's recipes. The track upgrade tool, for example, arrives as streetTrackModifier. It re-submits the same pieces in addedSegments, with the same ids and nodes and the new trackEdge.catenary/trackType, and its signals still in comp.objects. It does not delete and rebuild.

Player builds reach guiHandleEvent only after you subscribe TF3

builder.proposalApply arrives only if the script has subscribed to it (LISTEN_ONLY is enough). On the GUI side the param is a list {proposal, data, result}: use param[1]. Ids seen: constructionBuilder, streetTerminalBuilder (a hand-placed signal), streetTrackModifier. Town road rebuilds send no builder event at all, so re-check periodically.

"Thread did not respond to ping" can be the engine, not you TF2

The watchdog fired while mod descriptions were loading, before any script had run. It wasn't a mod bug.

Test pure-Lua modules outside the game TF2TF3

Modules with no game API calls (data, serialisers, geometry) run unmodified in lupa, Python's embedded Lua. Give the harness a global table that refuses new globals to catch strict-global errors offline.

Threads & Lua states

The game runs your code in several Lua states, and what is legal in each one differs.

game.interface write calls from the GUI thread are fatal TF2Fatal

game.interface.sendScriptEvent called from GUI code died with !gameState.GetGameScriptStack().entries.empty() (cmd_interface.cpp). pcall does not catch it. The same applies to upgradeConstruction, setPlayer and bulldoze. Send an event from the GUI instead and do the work in the game script:

-- GUI side
api.cmd.sendCommand(api.cmd.make.sendScriptEvent("my_script.lua", "my_id", "", { ids = ids }))
-- game script
handleEvent = function(src, id, name, param)
    if src == "my_script.lua" and id == "my_id" then ... end
end

Read-only calls (getEntity, getEntities, getGameTime) are fine from the GUI.

A callback on sendCommand crashes the game at quit TF2Fatal

api.cmd.sendCommand(cmd, callback) hands the engine a Lua function, which it frees at exit after the Lua state is gone. The result is an access violation on every quit, and "Internal error" on an in-game load. It was found by bisection. Where you can, send without a callback, remember what you sent, and check the engine state on a later tick with a timeout.

update() doesn't run while the game is paused TF2

Work queued for the game script's update() never ran on a paused map. Do event-triggered engine work directly inside handleEvent. Background schedules belong in the game script's guiUpdate/update with your own throttles, never in code that only runs while a window is open.

TF3: one Lua state per sim thread TF3

On the engine side, update() runs on many "Sim Pool" threads, each with its own Lua state, so module-level locals are per thread: a "run once" flag ran 22 times. Keep engine-side state in the game script's state, which TF3 saves inside the savegame.

TF3: what exists where TF3

  • app is GUI-side only. On the engine side: attempt to index global 'app' (a boolean value).
  • io is nil on both sides. os is a table, and require/ug_require exist.
  • The GUI reads the engine state read-only and changes it only with api.cmd.makeScriptingSendEventCmd("", "<ns>", name, param).
  • Any content/**/X.gs.lua becomes a game script with no registration (log: Creating entity for GameScript …).

TF3: a plain-Lua game script must return its table from data() TF3

A .script.tl (Teal) game script ends with return ret. A .script.lua one must wrap it: function data() return ret end, the same way construction scripts do. With a bare return, stdout.txt fills with "Error while running game script … Expected table at path: game <mod>::/<file>.script", from every Sim Pool, on every step. Those errors can also upset other things the engine is doing. (We earlier guessed plain-Lua game scripts don't run at all; this was the cause.) A game script change is picked up by loading a save, without a full restart.

TF3: subscribe to events on the start-up events TF3

Call state:subscribeToEvent inside handleEvent on the start-up events (id "" with initNewGame / handleLegacy / initNewGameFromMap), as the base company script does. A mod added to an existing save does get handleLegacy. An old save's subscriptions don't cover events added in a later mod version, so track which ones the save has and subscribe the missing ones.

Component params are an engine wrapper, not a table TF2

api.engine.getComponent(id, CONSTRUCTION).params refused edits with cannot call erase on lua::Table. game.interface.getEntity(id).params is a plain Lua table you can copy and change.

Strict globals and declaration order TF2

Assigning an undeclared global fails with res/scripts/init.lua:130: creating globals by assignment is not allowed. That usually means a file-level local was declared below the function that uses it. Likewise, a local function defined below its caller is nil at the call: attempt to call global '…' (a nil value).

Files, saves & persistence

TF2: data() save/load hooks crashed when used for mod state TF2Fatal

The game script's save/load functions exist, and base scripts use them, but eight attempts to keep mod state in them crashed. The engine calls load() more than once inside CGame::StartGameSim (Game.cpp:330) and asserts that the saved outputs match. Plain io.open files survived quit and relaunch, so that is the route.

TF2 file sandbox: what works TF2

  • io.open works in the game script, the loader state and a .con updateFn. Paths resolve from the game exe folder (steamapps/common/Transport Fever 2); relative paths like ../../workshop/… work.
  • os.rename and os.remove silently do nothing. To "delete", overwrite the file with a one-line tombstone. To rotate, copy by read and write into a fixed ring of slots.
  • There is no directory listing.
  • loadstring/setfenv were missing in a .con updateFn; parse plain text with string.gmatch. Elsewhere, run untrusted data files under setfenv(chunk, {}).
  • The engine-thread game script could not write the mod's log file, but its print() reaches stdout.txt.

Your files are global, not per save, so key them per map TF2

The TF2 API exposes no save or map id, and a new game inherited stale data from another save. A fix that works: hash the sorted set of town id=name pairs (fixed for a map) into the file name, for example djb2 to 8 hex digits. Files can also run ahead of a save reloaded without saving, so record the last game date written and roll back when the loaded save is older.

The first ticks after a load can see no towns TF2Inferred

Gate loading on a readiness check. Never prune or overwrite saved data against an empty town list in the first ticks.

TF3 userdata: app.saveUserdata and friends TF3

app.saveUserdata / loadUserdata / getAllUserdata / removeUserdata work on the GUI side only. Files land in …\3493540\local\<dir>\ as function data() return {…} end. Sparse integer keys are dropped, so store lists of pairs. Check getAllUserdata before loading to avoid log errors. Engine-side state lives in the game script's state, inside the savegame.

Never wrap a save in a silent pcall. On build 40420 one mod's app.saveUserdata("pay_your_tolls", …) threw "The directory you trying to access is not available or invalid", while another mod's "bpxdata" folder kept saving. The pcall hid it for days, and the player's "Don't show again" never stuck. Log the error. The cause (a folder-name rule?) is still unconfirmed; short folder names without underscores have kept working. Update: "bpxdata" was refused too once the game restarted. Since 40420 every folder a mod made is refused (save, load, list, remove); the game's own "mod_presets" (and "heightmaps") still work, so save there with a file-name prefix of your own.

Use "heightmaps", not "mod_presets". Every .lua in mod_presets shows in the game's Load Preset menu, with a delete button: BPX's 30 working files filled a player's preset list. The map editor's heightmap list (app.getAllHeightmaps) ignores .lua files, and its .lua list reads towns_industries, so heightmaps shows in no player list. Subfolders (mod_presets/x, heightmaps/x) and other extensions don't help: a subfolder is refused, and a name like x.bpx is saved as x.bpx.lua.

A construction that rebuilds in place must OWN its roads. A road piece in a construction's edgeLists belongs to the construction only if it touches a fixed node, one listed in neither snapNodes nor freeNodes. Mark every node free and the roads become ordinary public roads once built. A later createProposalReplaceConstruction then adds a fresh set on top and removes none: "Construction Not Possible", even with ignoreErrors = true. So make only the outer ends snap nodes and leave the rest fixed. Node tags ({ pos, tangent, tag }, ends with one tag join even metres apart) work on owned roads, as the base cloverleaf shows. (PYT Harbour City: its plaza's speed, lamps and town name only rebuilt once it owned its roads.)

A road stop as a module on the vanilla train station (PYT - Interchange's Truck Bay, seen working in TF3 40420: a truck loads at the train station itself). What it took:

  • Inside a mod, every base-game path needs ::: require "::/scripts/mat4.tl", and models, textures and street types too. Without it the game looks in your own mod, and a missing model crashed the module builder.
  • Keep a module's STREET edges inside its own slot. The builder tries neighbouring slots too, and two modules' road ends on one spot is a fatal nodeInfo.edgeCount == 1 in GetNodes2snap. Only the outer ends go in snapNodes (indices count the {pos, tangent} entries).
  • Don't push your stop into the train station's own stop list: that crashed the engine. Wrap result.terminateConstructionHook, call the original, then append your OWN station subconstruction ({ models = {}, laneLists = {...}, station = {...}, tag = n }). It is one station group with the trains, and the window shows it as a Road Station tab.
  • Build the lanes with laneutil.createLane (two-point curves are fine). The truck lane needs TWO pieces with the stop on the middle node (vehicleNode = {0, 2}, vehicleEdges = {{0,0},{0,1}}). A stop on a lane's end node is a fatal findBestDepotForLine (s)>=(1) when buying a vehicle.
  • The stop needs tag, vehicleEdges and cargoTransferSpeeds (with a cargo class), or the station window's TerminalTitle fails with "argument #2 can't be cast".
  • Make it 20 m long: at 10 m the warning is "Terminal is too short for some vehicles" and nothing loads. For catchment, copy the train station's catchmentAreas in, or the stop reports "not functional".
  • addModel in a module already applies the module's transform, so pass local transforms, not world ones.

Open a folder in the player's file explorer: app.openUserDataSubFolder("mod_presets") and app.openUserDataFolder() (the game's local folder) work from a GUI button (seen on Windows, 40420). Like saving, the sub-folder version opens nothing for a mod-made folder, so send players to local for those. Mac/Linux untested.

TF3 translations: strings.json TF3

TF3 reads strings.json in the mod root: { "ru": { "English text": "translation" } }, for the 13 game languages. TF2-style strings.lua and .mo files are not read. The game language is api.util.getLanguage().code. For a language the game lacks, carry your own table and look it up yourself.

TF3: a script copy can find its own mod; another mod can be detected TF3

debug.getinfo(1, "S").source gives "<modId>::/path" in UI scripts, so a file shipped byte-identical in several mods knows which copy it is. api.res.constructionRep.find("<modId>::/<path>.con") on another mod's key entry tells you whether that mod is installed.

Entity ids are reused TF2TF3

A record keyed by id alone matched a quarry that had taken an old town's id ("% Owned 9434.40%"). An undo "new since my snapshot" diff picked up four of the player's rebuilt track pieces. Never cache "what this id is" across passes. Store the type, and the position where it stood, with the id, and re-check both before acting.

Building from script

A TF2 Context that works TF2

Copied field for field from Copy It: cleanupStreetGraph = false, checkTerrainAlignment = false, gatherBuildings = true, gatherFields = true, player = api.engine.util.getPlayer(). Build with api.cmd.make.buildProposal(p, ctx, ignoreErrors), then api.cmd.sendCommand.

Building from script in TF3 TF3

Send a SimpleProposal through api.cmd.makeWorldBuildProposalCmd(proposal, context, ignoreErrors, playerInitiated) with a callback. It also accepts a full api.type.Proposal. Errors are in resultProposalData.errorState. It works from the GUI side for track, roads and constructionsToAdd.

Transforms: userdata, not tables TF2

transf.rotZTransl returns a plain 16-number table, and ConstructionEntity.transf rejects it ("expected userdata"). Build four api.type.Vec4f columns into api.type.Mat4f.new. Where a position must be vec3.new(…), a plain {x,y,z} gives no error and silently drops the translation. An explicit transf is respected exactly (moved 0.000 m).

A big script can hit Lua's 200-local limit TF2TF3

Lua allows at most 200 local variables at a file's top level. One more and the file doesn't compile: too many local variables (limit is 200) in main function. The game then logs Expected table at path: game <mod>::/<file>.script every frame, and nothing in that script runs. It doesn't crash, so it's easy to miss. Teal's top-level record and type declarations count as locals too, so the limit comes sooner than a count of local lines suggests. In a big script, keep new state in an existing table (PYT keeps its one-per-session "already asked" sets in a single ASKED["name"] table) instead of adding a new top-level local each time.

A function type in a Teal table literal eats the next comma TF3

In a table literal, step1Busy = nil as function(): boolean, doesn't parse. Teal reads the comma after boolean as the start of a second return type, and stops with syntax error, expected one of: '}', ','. TF3 then loads none of the script: you get Expected table at path every frame, as with the 200-local limit. Give the field a plain default instead (a boolean, or nil as function(number) with no return type), or put the type in parentheses. TF3 doesn't check a .tl until it loads the game, so it pays to parse-check locally first. tl.lua is a single file and runs in any Lua 5.4, including Python's lupa. Call tl.lex, then tl.parse_program, and it reports the same line and column as the game.

A comment after a field swallows its comma TF2TF3

Adding a note to the end of a line in a table: type = "my_lamp" -- lamp slots too,. The comma is now inside the comment, so Lua sees type = "my_lamp" followed directly by the next field, and the whole file fails to parse. The module never appears, and nothing in the menu tells you why. Put the comment after the comma: type = "my_lamp", -- lamp slots too. A batch edit can repeat the slip across many files, so after one, parse every .lua locally: Python's lupa can run load(source) on each file and print any that fail, in a second.

A comment can swallow a table's comma TF2TF3

An edit that puts -- note after key = value but before the line's comma comments the comma out. The resource is then skipped with '}' expected (to close '{' at line N) near '<next key>', and the build entry or module just disappears from the game. Keep the comma before the comment: key = value, -- note.

TF3: a glow with no time range lights only at night TF3

An EMISSIVE material's emissive_scale can carry fromTime / toTime. With fromTime = 0, toTime = 24 it glows all day. The base game uses that only for airport and runway lights. The base street lights (streets/mat/street_lights_emissive*.mtl) give no time range at all, and the game then lights them only at night. So a lamp, lantern, window or lightbox should leave the time range out. Keep 0-24 for things that really shine in daylight: signals and warning lamps. PYT's lamps had copied the all-day form, and their lanterns glowed at noon.

TF3: text the game writes onto a model (labelList types) TF3From the base files

A model's metadata.labelList.labels reserves a spot where the game writes text. Each label has a type, plus size, transf, color, alignment, fitting and childId (the node it rides on). The base game's own models use these types:

  • NAME: the vehicle's own name, which the player can rename. Used by the LNER A4's nameplate.
  • COMPANY_NAME: the player's company, used on an old tram wagon.
  • LINE_NAME and NEXT_STOP: the bus destination boards.
  • STATION_NAME: station name boards.
  • CUSTOM: text a construction sets itself, such as Pay Your Tolls' town names over plaza doors.

Units: the label's transf scales by 0.01, so size is in centimetres. The A4's plate is size = { 145.8, 13.6 }, about 1.46 × 0.14 m. So a vehicle can carry player-chosen text: give it a NAME label and the player sets the text by renaming the vehicle.

Seen working on a helicopter-type vehicle (a blimp's side billboard): a NAME label with childId on the board's node, size = { 2500, 250 } (25 × 2.5 m), placed about 1.3 cm off the board's face (4 cm made it vanish), alignment and verticalAlignment CENTER, fitting = "CUT". The player renamed the blimp and the board showed it, about 30 characters across. Seen (TF3 40420): label text is NOT lit at night. On a glowing (emissive) board it shows dark, even at dusk, and a color above white (4, 4, 4) changes nothing. For a sign that must read at night, paint the text into an emissive texture instead.

Every construction's params need a seed TF2TF3Fatal

Without it: ConstructionRep.cpp:659 Assertion params.GetPtr("seed") failed. TF3's makeProposalData asserts the same. seed = 0 is fine. Add it in one sanitise step that every build path goes through, because a fix on one path alone gets missed.

TF3: every subconstruction needs its own tag TF3Fatal

When a construction is rebuilt in place (createProposalReplaceConstruction, or the player's Configure), the engine pairs the old and new subconstructions by their tag. Two subconstructions with the same tag crash the game: create_proposal_data.cpp:365 PopulateOld2NewSubconstructions: Assertion 'it2.second' failed. The first build works, so the bug only shows on the first rebuild. Give each piece a distinct tag (0, 1, 2…). PYT hit this when it added a second piece (a maintenance depot block) to its Toll Way.

A tag worked out from part of a module's slot id is not unique either. PYT Interchange gave each station module's own piece 900 + slotId % 1000: slots 3399945 and 3614945 on one train station both made 1845, and Configure crashed with the same assertion. Use the whole slotId: it is different for every module of the construction.

TF3: old2new only in a proposal with one construction TF3Fatal

The base game moves an industry by rebuilding it in place: one SimpleProposal that removes the construction, adds it again with a new transf, and sets old2new = { [entity] = 0 }. The industry keeps its id, stock, station and lines. That works. But BPX put a moving industry into one proposal together with a player's station (industry at index 1), and the game died with Assertion 'old2newConstruction.second < (int)toAdd.size()' failed in create_proposal_data.cpp. That was even just in makeProposalData for a preview. The index evidently counts within a group the engine splits off (world-owned and player-owned constructions look like separate groups). Keep every old2new in a proposal of its own with that one construction at index 0, as the base game does.

TF3: a window's onClose can arrive after you closed it yourself TF3

The X on a window only calls its onClose; the mod removes the window. But removing it from your own code also triggers onClose, sometimes a moment later rather than straight away. A re-entry flag set around the removal doesn't catch that late call. BPX's paste panel treated it as the player pressing X and cancelled a Move that had already been placed. The fix is your own "is it open" flag: clear it when you remove the window, and have onClose do nothing when it's already clear. Also give each window its own id. Opening a window with the id of one that is still being removed got the new one closed the moment it opened.

TF3: is this a sandbox game? TF3

The game ships its own sandbox mod (mods/release/urbangames_sandbox), and all it does is one line in its preRunFn: baseConfig.sandboxMode = true. Placing industries in sandbox is the base game's own build menu, unlocked by that flag. A script can read the same flag at run time with api.res.getBaseConfig().sandboxMode (and noCosts beside it). BPX uses it so its "copy industries too" switch starts on in sandbox games and off in normal ones.

TF3: asking for a removed entity throws TF3Fatal

api.engine.getComponent(e, …) on an entity that no longer exists does not return nil: it throws Invalid entity. Inside a sendCommand callback that is fatal (Engine.cpp:1136 GetRevisionExisting: Assertion 'engine.EntityExists(entity)' failed). BPX removed an industry, then checked it was gone with a bare getComponent, and the game crashed even though the removal had worked. Wrap every lookup of something you may just have removed in pcall, and treat a throw as "gone".

The same applies to the entity list a build command hands back. After an in-place rebuild (a proposal that removes a construction and re-adds it with old2new, the base game's way of moving an industry), that list also holds entities the command removed. BPX looked up every entity in it, hit one of those, and crashed. Treat a command's result entities as "may be gone" too.

TF3: any construction can be a road maintenance post TF3

The base Road Maintenance Building (depots/road_maint_station.script.lua) is a subconstruction carrying depot = { catchmentArea = { radius = 600, position = { 0, 0, 0 } }, transportModes = { "BUS", "TRUCK", "TRAM", "ELECTRIC_TRAM" }, maintenancePool = 16 }, with no road link. Another construction can carry the same block on its own piece (with its own tag, see above). The game's pool bar is entity_window_util.MaintenanceStationPoolProgressBar{ entityId = … }.

Captured params: clear upgrade, clamp dropdowns TF2

A modular station's captured params can carry upgrade = true. Clear it to nil for a fresh build (Move It's choice). A .con combobox built from installed content (for example listTrackTypes) stores an index into the creator's list. On a machine with fewer mods, index 213 pointed past the end, and proposal creation threw the error object false. Clamp every param to this machine's declared range from constructionRep.get.

A pasted station is "just models" until registered TF2

After the build, set playerEntity (the current player) on each ConstructionEntity. Then run an engine-side game.interface.upgradeConstruction with the seed removed (Copy It's "dummy upgrade"). Without it the station has no icon and can't be selected. The construction id survives the upgrade, but its free node ids die (it is removed and re-added internally), so build edges that use them before the upgrade.

upgradeConstruction can refuse, and pcall catches that TF2

On one save every attempt was refused with Assertion !proposalData.errorState.critical, raised as "internal error". pcall caught it, with no crash. It also crashed on an airfield, which carries Construction + FIELD (two identities). To upgrade a station in place, rename its platform-track modules in getEntity(id).params and call upgradeConstruction from the engine side. The id stays the same.

An industry built without a name crashes 2 s later TF2Fatal

ecs::Engine::GetComponentDataIndex: Assertion 'it != components.end()' failed, Component Type 19 (Name), on its SimBuilding: the new-industry notice reads the name. Always set construction.name. Industry type check: constructionRep.get(i).type, with INDUSTRY = 10 and TOWN_BUILDING = 13.

Industries: what the engine allows TF2

  • "Players can't build industries" is only a build-menu rule. A proposal is accepted.
  • Move an industry in ONE proposal: the original in constructionsToRemove and the copy in constructionsToAdd (same file, params with upgrade = nil, name kept, no playerEntity). The engine returns the same entity id.
  • Farm fields are only made for farms the game spawns. No mod can create fields.
  • makeProposalData says "Construction not possible" for industries every time (and takes 1-1.8 s), yet the real build succeeds.

Station names live on the station group TF2

A station's getEntities record says "no name". The real name is reached through CONSTRUCTION.stations, then stationGroupSystem.getStationGroup, then game.interface.getName. ConstructionEntity.name doesn't survive the upgrade pass. Re-apply it with api.cmd.make.setName on the group about 30 ticks later. Cyrillic names survive.

TF3: one construction per proposal TF3

Several constructions in one proposal collided with each other (collidesWith shows the same-proposal, negative ids). Send them one at a time.

TF3: road stations need a refresh after their road TF3

A road station or depot sets up its entrance when it is built. If its road comes later, send api.engine.util.proposal.refreshConstruction(entity) through makeWorldBuildProposalCmd, otherwise you get "Could Not Connect Stations".

The engine does not auto-join roads to a new construction TF2

After build and upgrade, each free node still had exactly one segment. Joining the edges is your job.

Ghosts with mixed passenger and cargo terminals crash TF2Fatal

A construction or build ghost holding passenger and cargo terminal-defining models died with Assertion allPersonEdgesHaveTransportModePerson != allPersonEdgesHaveTransportModeCargo failed. A ghost carrying terminal models plus rail station track died with StationSystem.cpp:113 EntityAdded: Assertion pr.second failed. Any new kind of model in a ghost is a crash risk. Model metadata (modelRep.get(i).metadata.transportNetworkProvider.terminals) is readable from the GUI to screen them.

Track, roads, nodes & signals

The street proposal shape TF2

In proposal.streetProposal: NodeAndEntity and SegmentAndEntity with negative ids, and type 0 = street, 1 = track. Resolve types with trackTypeRep / streetTypeRep / bridgeTypeRep / tunnelTypeRep .find. New edges join existing nodes by their plain positive id. buildProposal(p, ctx, true) ignores non-critical errors and returns the expanded proposal (.proposal.proposal.addedNodes / frozenNodes / addedSegments).

Rep lookups return uint32 "not found" TF2

A missing name returns 4294967295, not −1, so == -1 never fires. Check for a sane index and that getName(index) answers. Offsets differ too: streetTypeRep.find() is used as-is, while modelRep needed −1 in one mod (two street types shared a record otherwise).

Signals must point at the new segments TF2

A signal's edgeEntity must be in −1…−N for N added segments, or the build fails with eo.edgeEntity.GetId() < 0 && eo.edgeEntity.GetId() >= -(int)result.proposal.addedSegments.size(). Number segments −1, −2, … and keep node ids in a separate range. EdgeObject.model is documented as an int, but the binding wants the model name (a string): stack index 3, expected string, received number. A dry run does not check edge-object types.

TF3: placing signals from a script TF3Fatal

What crashed: a SimpleStreetProposal.EdgeObject in the same proposal as new track, with the piece's comp.objects placeholders numbered −1, −2… (the new pieces' own ids). That was fatal: map_util.h:22 Get "it != map.end()".

What works (seen in game; the method of the Automatic Signal Spacing mod): build the track first, then add the signals in a second proposal that rebuilds each built piece. Put the built piece in edgesToRemove. In edgesToAdd, add a SegmentAndEntity with entity −1, −2…, type = 1, comp = the built BaseEdge and playerOwned copied. Set comp.objects = its existing objects plus {placeholder, EdgeObjectType.SIGNAL} per signal, with placeholders counting down from −400000000. Each EdgeObject takes edgeEntity = that −k, param (0…1 along the piece), oneWay, left, model = the original's EDGE_OBJECT.edgeObjectConstruction unchanged, and playerEntity. Send makeWorldBuildProposalCmd(proposal, context with .player, false, true). A makeProposalData dry run in pcall first turns a bad lookup into an error you can catch.

left sets both the side the signal stands on and the way it faces. The direction flag in the original's SIGNAL_LIST (signals[1].edgePr[2]) is the opposite of left on a piece built the same way round. Copied straight across, every signal stood on the wrong side.

Upgrading signals to a newer era: a signal's look is not its build year. Each era is its own construction: signal_path_a.con (to 1950) and signal_path_c.con (from 1950). Rebuilding a signal with the same edgeObjectConstruction gives the same old semaphore. To modernise, find the sibling constructions (same folder and name stem, a track edge object, read through api.res.constructionRep.getAll(true) and .get(id).availability) and pick the one available this year. Rebuild the piece with edgeObjectsToRemove = the old signals and edgeObjectsToAdd = the new ones at the same param/side/one-way. replaceSegment (a track type swap) keeps the old signals as they were.

Stops work the same way, once the road rebuild is right. A roadside bus or tram stop is an edge object too (EdgeObjectType.STOP_LEFT = 0 / STOP_RIGHT = 1; the side is in the type, and EdgeObject.left must agree), a small station built from stations/street/small_stops/*.con. For days it was refused at makeProposalData with "Unknown exception", whatever we sent: a name, the game's hidden params {seed, year}, a player context. The control that finally explained it: rebuilding the road piece with no stop at all threw the same error. The cause is the road, not the stop. A street piece's two end nodes carry BASE_NODE_CONFIG lane tables that reference the piece by id; remove the piece and they point at nothing. Track nodes carry no such tables, which is why the signal recipe never hit this.

The fix, for every street piece you rebuild: for each of its end nodes that has a BASE_NODE_CONFIG, put the node in nodeConfigsToRemove and add a BaseNodeLaneConnectionAndEntity (entity = the node, comp = the old config) whose laneConnections[].segment0/segment1 and crosswalks[] have the old piece id replaced by the new negative id. With that, a stop goes on exactly like a signal: {placeholder, STOP_LEFT} in comp.objects, EdgeObject.model = the stop's .con, left = the side, oneWay = false. A two-sided stop is one construction (wantSymmetric = true in its script): send one edge object and the engine makes the partner. Pick the stop of the current era (small_old to 1920, small_mid to 1980, small_new after) or the proposal is refused on availability.

Joining new road onto a map road's dead end without a no-entry sign TF3

Pointing a new piece's node0/node1 at an existing map node joins it, but the node keeps its old BASE_NODE_CONFIG, which only knows the old road, so a no-entry sign appears. An empty fresh config left the sign in place. So did replaceSegment on the map piece afterwards: it keeps the node's lane table.

What works: in the same proposal, put the node in nodeConfigsToRemove, and add a BaseNodeLaneConnectionAndEntity for it (entity = the map node, comp = the old config, so crosswalks and traffic lights stay). Give it a new laneConnections list. A dead end holds U-turns {old, a, old, b}. Turn each one through to the new piece, both ways: {old, a, new, b} and {new, a, old, b}, keeping its withRoad/withTram. Lane numbers are zero-based, left to right as seen from the node looking along that segment, so for the same road type they carry across unchanged. Track junctions have no lane table: leave track nodes alone.

construction_tool: params.isActive is true for every tool in the open tab TF3

With several construction_tool tiles in one build-menu tab, each tile's recipe sees params.isActive == true as soon as the tab is open, not only the tile the player picked. Logged: all nine of our tiles "became active" in the same second. So it can't tell you which tile is in use, and anything you do on it (consuming a shared setting, setting a module-level "active" variable) happens for every tile.

What tells you: only the picked tile's action function (the one passed to params.setActionFn) is mounted. Put a react.onMount at the top of the action function, and mark the tile active there. If the other tiles need to know, fire a react event from it.

The node-to-segment map: index it, never walk it TF2Fatal

api.engine.system.streetSystem.getNode2SegmentMap() is a native map. pairs() over it died natively ("Internal error", ignoring pcall). What works: fetch it once, then index it per node id you want, with a pcall on each access, and copy the list out.

Snap nodes need exactly one edge TF2TF3Fatal

TF2: Assertion it->second.second == 1 failed when two edges ended at a construction snap node. TF3: Assertion nodeInfo.edgeCount == 1. Very short snap pieces in TF3 also crashed on hover: it2 != nodeSegment2minDist.end().

Curvature and slope: the real limits TF2

  • Track (track/standard.lua): maxSlopeBuild 0.075, maxSlope 0.12, maxSlopeShape ×1.25.
  • Curve radius: minCurveRadiusBuild 60 is only what the player may draw. minCurveRadius is 44, and the engine refuses at about 44.0 m. The local radius inside a Hermite piece can be much tighter than its end-to-end arc.
  • Curve speed (m/s) = a·(radius + b)^c: standard .85/30/.6, high-speed .9/15/.63.
  • Streets state no limits. Seen live: accepted 28.8% while turning 88°; refused ("Too much curvature") at 32.8% with a 90° turn.
  • Two roads leaving a node at 0° are refused: "Narrow angle".
  • Signals near switches: a signal inside a switch's diverging span blocks the curve. Place it span + 5-10 m clear.

Bridges: piers depend on drawing direction TF2

"Bridge pillar collision" is non-critical: the game's tool and buildProposal(…, true) place the piers anyway, while an ignoreErrors = false bundle is refused. The end a bridge piece is drawn from decides where piers go: the same 208 m bridge drawn one way cleared a station, and drawn the other way it collided. stone.lua: pillarMinDist 12, pillarMaxDist 48, pillarTargetDist 12.

Upgrade track and roads in place, as the game does TF2

Urban Games' own replace_street.lua recipe: one SimpleProposal with edgesToRemove[k] = the old piece and edgesToAdd[k] = a SegmentAndEntity copying its BASE_EDGE plus a trackEdge/streetEdge with the new type. The pieces stay on their nodes and signals ride along. Swap signals with edgeObjectsToRemove + edgeObjectsToAdd. The price comes from makeProposalData's costs.

Street era twins TF2

Base streets: _new has yearFrom 1925 and _old has yearTo 1925 (from inclusive, to exclusive). A _new road in a pre-1925 save is refused with "Construction not possible". To hide a street, use a forward but unreachable range (yearFrom 1 / yearTo 2). A backwards range (yearFrom 1925 / yearTo 1) survived a fresh launch but broke every in-game load.

TF3: a stop's Fill Level (stopConfig.maxLoad) TF3

Read 2026-10-18 in the game's gui/line_vehicle_mgmt/cargofilter_window.tl. A line stop's stopConfig.load (ticks) and stopConfig.maxLoad are 1-based IdVectors (index = cargo id + 1). maxLoad is a fraction 0..1: the window shows round(v * 100) and writes the slider value back. It is the share of each vehicle the stop may fill with that cargo; the station keeps the rest. The game already edits it: click the cargo tile in a stop's Load box and a Fill Level slider appears (step 1). The window also keeps unload / maxUnload in its own row data, but the engine's StopConfig it writes carries only load, maxLoad, forceUnload, destroyForConfigChange and destroyForRefresh, so there is no per-stop drop-off share to set. The game only writes maxLoad after setting customFilters = true; without it every stop loads everything. A drop-off share CAN be made from it, though: tick the cargo at a delivery stop and give it a Fill Level, and vehicles leave that stop with at most that share, dropping the rest. Seen 2026-10-18 (50% at a town stop, the next stop's truck arrived 5/10). Careful: the tick also lets vehicles pick the cargo up there from any store in reach. Used by PYT - Supply Planner's line plan.

TF3: never tag a mod "Map" on mod.io -- it vanishes from the mod list TF3

On mod.io the Map tag marks a custom map (700+ terrain maps carry it). A script mod tagged Map installs and loads normally -- the log shows new ModHubMod was installed and will be added to filesystem, its content cache read and its strings.json loaded -- but it never appears in the mod list when you start or load a game, so nobody can switch it on. Seen 2026-10-21 with PYT - Minimap (a mini map window, tagged Map + Script Mod in the upload screen): a player reported it, the mod.io download matched the upload byte for byte, and the fix was only to remove the tag (DELETE /games/{game}/mods/{mod}/tags with tags[]=Map). The upload screen's tags can differ from _metadata/modinfo.json, so check the live tags after every first upload. A map tool is a Script Mod (other minimap mods use Script Mod + Misc).

mod.io: at most 10 pictures per media upload -- upload first, or in batches TF3

POST /games/{game}/mods/{mod}/media with more than 10 files is refused: 422, error_ref 13003, "You cannot submit more than 10 file(s) at a time". Our gallery tool deleted the old pictures first and then sent 12, which left the page with an EMPTY gallery until a second run. Send batches of 10 (each POST adds to the gallery). mod.io renames a re-uploaded file (1.png becomes 1.1.png), so links to gallery pictures break; the logo 0.png keeps its name, and only a game upload replaces the logo. 2026-10-22, PYT Minimap 1.1.

TF3: never sell with "sell on arrival" -- use the Sell command TF3Fatal

api.cmd.makeVehicleSendToDepotCmd(v, true) (the second argument = sell on arrival) from a mod script crashed the game when the vehicle reached the depot and was sold: simulation thread, Engine.h:323 it != components.end(), Component Type 67 (TransportVehicle) of the sold vehicle. Seen twice on 2026-10-18 -- once with the order sent twice, then with it sent ONCE, so a guard against double orders is not enough. The game's own GUI never passes true: its Send to Depot passes false, and its Sell button sells at once with api.cmd.makeVehicleSellCmd({ v }). Do the same. Still send each vehicle only once, and only while its TRANSPORT_VEHICLE exists. PYT - Fleet Control F22c.

TF3: lane-splitting nodes need node configs TF3Fatal

A node joining a multi-lane road to several roads without a BaseNodeConfig crashed: Engine.h:323 it != components.end(), Component Type 35. Add one nodeConfigsToAdd entry (api.type.BaseNodeLaneConnectionAndEntity, entity = the node's temporary id) per new node. An empty config gives a red no-entry sign, so copy the real laneConnections. Removing pieces while far-end nodes still list them was fatal too: lc.segment0 / lc.segment1 not found (StreetShapeFactory).

The dry run (makeProposalData) does not catch a missing config: it reports "no critical error" and the real build is fatal (ComponentManager.h: it != m_compType2index.end(), Component Type BaseNodeConfig). If you don't want to write lane tables yourself, build in steps: first a road where every new node joins only two pieces, then add the third piece onto the now existing node, then rebuild each piece at that node with api.engine.util.proposal.replaceSegment so the engine writes the table.

TF3: road ends sit off the node TF3

The street builder leaves road ends 5-6 m off the node. Copy those offsets or junctions pinch. Never offset an end that joins an existing node: the game answers "Construction Not Possible".

TF3 road timeline: 1940 is the big switch TF3

From the base street.zip templates:

  • The _old country and town roads run until 1940, and the _new ones start in 1940.
  • All one-way roads, the highways (highway_new_small/medium/large) and the town_new_one_way_* streets, appear in 1940. Before that there is no one-way road at all.

Anything built for one-way traffic (a one-way toll plaza, a motorway junction) has nothing to connect to before 1940.

Valid(result.laneConfigs) can kill a paste TF3Fatal

Pasting a copy taken round a landmark (an arena) crashed TF3: Assertion Valid(result.laneConfigs) failed (StreetShapeFactory.cpp, CreateStreetContext) in makeWorldBuildProposalCmd; a makeProposalData dry run had already thrown it on worker threads, non-fatally. It is not simply a lane-count mismatch: ordinary town roads carry 6 lanes on a 4-lane template, and rebuilding exactly those lanes is what makes them paste ("Unknown exception" when we forced the template's 4). Cause still open; keep landmarks and their own roads out of copies.

TF3 BaseEdge and lane facts TF3

  • BaseEdge.distance is the edge-decoration (noise barrier) offset, not a length. Normal pieces store typeIndex −1.
  • Tracks are street templates; electrified is a separate *_catenary.street_template. TF3 keeps roadTemplate/roadStyle on the edge.
  • laneConfigs transportModes: {0,1} pavements, {2,3,4} road vehicles, {7,8,14,15} track. laneConfig.offset is not in metres. Lanes run left to right looking node0 → node1.
  • Town roads have 3 m pavements; country roads a 5 m outer lane and a 2 m verge.

TF3 base road traffic lanes, measured TF3

From street.zip laneConfigs, in metres, left to right; the _old twins match.

  • Town (3 m pavements): xsmall 4 / 4 · small 5 / 5 · medium 5 4 / 4 5 · large 5 4 4 / 4 4 5
  • Country (2 m verges): small 5 / 5 · medium 5 4 / 4 5 · large 5 4 4 / 4 4 5
  • highway_new, one-way: small 5 (2 m shoulders) · medium 5 5 · large 5 5 5 (4 m shoulders, no vehicles)
  • town_new_one_way: xsmall 4 4 · small 5 5 · medium 5 4 4 5 (four lanes), all with 3 m pavements

Base-game facts read from the game files TF2

  • No traffic-side setting: the base game always drives on the right.
  • Station module eras: a < 1920, b 1920-1980, c ≥ 1980.
  • Bridge eras: iron 1910, suspension 1940, cement 1970, cable 2000. One-way streets arrive in 1925.
  • Signals: signal_path_a until 1925, then signal_path_c, each with a _one_way twin. left = false applies to trains running the edge's n0 → n1 direction.
  • Runtime street speed is m/s (27.78 = 100 km/h), not the config's km/h.
  • Modular station layout: each unbroken run of track modules is its own stop. Cargo platforms are two slots wide. The maximum length is 840 m.

Removing things

Removal is where most of the fatal assertions are.

constructionsToRemove: exactly one identity per id TF2Fatal

Every id must have exactly ONE identity: Construction, AssetGroup or Field. Fatal cases seen: an asset/ construction (two identities), an airfield (Construction + FIELD), a dead id (none), and a double removal. Screen every id first: exists, one identity, not already removed.

Assign whole tables to constructionsToRemove TF2

p.constructionsToRemove[i] = id silently did nothing (it evidently writes into a copy). p.constructionsToRemove = { ids } works. The street lists do accept indexed writes.

Nodes: remove only when all their segments go TF2Fatal

List a node in nodesToRemove only if every segment at it is also removed. Otherwise: !ContainsEntity(m_proposal->removedNodes, entity) in ProposalStreetGraph::GetNode. Never list frozen edges or nodes, since they go with their construction. Remove track first, then constructions, as two commands.

Remove signals with their edges TF2Fatal

Removing an edge that still has objects on it: Assertion 'it != object2edge.end()' failed. Put each edge's BASE_EDGE.objects into streetProposal.edgeObjectsToRemove in the same proposal.

ignoreErrors on removal: use with care TF2Fatal

  • ignoreErrors = true can report success for a removal the engine didn't do.
  • Pieces built with ignoreErrors can't be removed without it ("Internal error"), because the state already holds tolerated overlaps.
  • Forcing removal of arbitrary world objects (served stations, streets with buildings) was fatal: "Assertion found failed".
  • "Vehicle in the way" is only reported at the buildings stage, after the roads have gone.
  • Town-street parcels are leftParcels/rightParcels (not leftEntities).

Removal is asynchronous TF2

After sending a removal, the entity can still exist on the next scan (more than 5 ticks at a station). Wait on api.engine.entityExists, and claim a caught entity at once: catching it twice removed an already-removed entity, which was fatal.

Build results are a native vector; diff instead TF2

res.resultEntities is a native vector. Read it with # and [i], because pairs can yield nothing. An edge build reported 0 created entities. To know what a build made, snapshot the area before and after and diff the ids, checking position too, because ids get reused.

Constructions (.con)

TF3: models draw only inside subconstructions TF3

A top-level result.models draws nothing, even beside edgeLists. You get no model, or the pink placeholder cube with "Collision".

A model-only construction becomes a decoration TF2TF3

A construction holding only a model, with no ground paint, roads or snap patch, is flattened into an ASSET_GROUP (a decoration), not a CONSTRUCTION. It can't be clicked, and its .con is never read again. Only position and rotation are kept, so scale is lost. Adding groundFaces or a snapPoint ground patch, or owning roads, keeps it a construction. This is a trap for toll booths, and the key to pasting decorations (see below).

It's also a trap for scripts that place such a .con and later look for it again, like PYT's speed boards. A CONSTRUCTION search never finds it, and any custom params are gone. Search ASSET_GROUP instead, match the model name from MODEL_INSTANCE_LIST fatInstances (api.res.modelRep.getName), and remove it with a full api.type.Proposal (toRemove), not SimpleProposal.constructionsToRemove. Every rebuild we did the other way stacked another copy.

The held tool caches its preview TF2

The game runs a construction's updateFn when the tool is picked up and caches the result. It never re-reads your data file. Click another asset, then this one, to refresh. No API opens or pokes the placement tool from script. Construction icon textures load only at game launch (160×100, 32-bit uncompressed TGA).

edgeLists in a .con: bridges need their type TF2

edgeLists render native ghosts with native collision and snapping, and snapNodes are 0-based row indices. Bridge or tunnel geometry needs edgeType = "BRIDGE"/"TUNNEL" plus edgeTypeName (for example cement.lua), as cloverleaf/t_interchange do. Without it, elevated track counts as a ground crossing and the ghost collides with itself.

TF3: a construction with its own roads (edgeLists that belong to it) TF3

Learned from the TF3 mod Tollgate (by GreatRM), whose script explains it well. A road piece in edgeLists counts as the construction's own (one of its frozenEdges) only if it touches a fixed node, one that is in neither snapNodes nor freeNodes. A single piece whose two ends are both snap nodes gives 0 frozen edges; split it in the middle and both halves are frozen. If the pieces aren't the construction's, road and structure get bulldozed separately. Where a piece meets a narrower ordinary road, the game wipes that piece's lane markings to blend the widths, so build it as approach / main / approach and let the short approach pieces take that. Two lists (for example an approach road type and a toll-lane type) join at nodes on the same spot. A speed limit inside the construction is simply the lane speed in that road type. To let the player drop it onto an existing road like a depot, give it a snapPoint a few metres inside its front end with snapToBaseEdgeTypes = { "STREET" } and allowSnapToBaseEdgeEnds = true.

TF3: a construction's own road types — keep them out of the menu and away from towns TF3

A construction's own road pieces need their own street templates. The easy way is to copy a base one, so the pieces match the player's road with no seam: the 1900 dirt roads are country_old_small / country_old_medium (street style country_old.street). A whole copy, though, keeps two fields meant for player roads. menuCategory puts every copy in the road build menu, and simBuildable = true lets towns build it as their own streets, including your slow toll pieces. Delete menuCategory and set simBuildable = false. With availability = { yearFrom = 0, yearTo = 0 } the copy also never expires, where the base 1900 roads end in 1940. Change an option on a placed one (a speed step, say) from its window as Tollgate does: createProposalReplaceConstruction(entity, params), then api.cmd.makeWorldBuildProposalCmd(proposal, nil, false, true). It rebuilds in place and is safe from the GUI thread. Construction options arrive in the script 1-based, and defaultIndex counts from 1.

TF3: give a construction a job — real staff with personCapacity TF3

Also from Tollgate (by GreatRM). Its author notes that every clickable building in the base game has a function (depot, station, industry, workplace), and a construction without one wasn't treated as a building. Their toll gate is a workplace, like the company headquarters: the construction's result carries personCapacity = { type = "INDUSTRIAL", capacity = N } plus a footpath in laneLists so workers can walk in. Residents then take jobs there and commute to it, and the window shows "Staff 4/4". To read it, find the subconstruction with a PERSON_CAPACITY component in construction.subconstructions.

Only STREET_CONSTRUCTION joins the road graph TF2

An ASSET_DEFAULT with snapping = { road = true } only snaps the placement ghost; it has no road-graph membership. skipCollision is fixed per file, so a dropdown can't toggle it; ship twin .con files instead.

TF3: snapping to roads TF3

The con-level snapping = { road = true } did nothing. result.snapPoint = { transf, snapToBaseEdgeTypes = {"STREET"}, allowSnapToBaseEdgeEnds = true } works, but snaps to the road's edge, so offset by half the road width.

TF3: edge objects snap only as stops or signals TF3

edgeObject = { snapToStreet = true } with result.edgeModels snaps only if the object is a stop or a signal (EdgeObjectType has only STOP_LEFT / STOP_RIGHT / SIGNAL). The result is an EDGE_OBJECT entity that the game treats as a bus stop. The placement preview ignores an edgeModels translation.

TF3: roads inside constructions TF3

Use your own street templates in result.edgeLists: base highway templates came out as dirt roads. Roads join only at an identical end point. An edge entry can carry a third value, a tag ({pos, tangent, tag}): ends with the same tag become ONE node even at different positions, which fixed a pinched lane split. At a construction-built junction, the first piece added is wired as the through road.

TF3: script lanes need a station or depot TF3

laneLists in a subconstruction, at the top level, or in model metadata were dropped (no lanes in route view), even with a dummy station.

TF3: collision can depend on rotation TF3

Construction junctions collided at some angles only. Sweep angles with dry runs (makeProposalData per angle step and params variant) to find them, and space pieces off the junction point.

TF3 .con basics that bite TF3

  • A .con's menu params reach updateFn as 1-based indexes of their values -- the 1st choice arrives as 1 (seen 2026-10-17: the Mega Warehouse's sizes 250 / 500 / 1,000 / 2,000 read 0-based built one size too big, and 2,000 fell off the end). A DYNAMIC template's createTemplateFn gets them 1-based too. Log the raw value once before relying on it.
  • Model paths are case-sensitive; a wrong case gives the pink cube. Renaming a model breaks old saves ("N models are missing").
  • Top-level result.cost is multiplied by costs.infrastructureCostScale (era inflation: ×1 in 1900, ×6 later).
  • A maintenanceCost of 0 or missing becomes cost/6 a year; set 1 to make it negligible.
  • No construction script sees the game year, so era variants are separate entries with yearFrom/yearTo. yearTo 1 hides an entry but keeps it for old saves.

TF3: build-menu tabs are data TF3

gui/construction/menu/menu_categories/category_<x>.res.lua returns { type = "menu_category", data = { category, order, name, icon, menu = "ROADS" } }. The icon is 48×48 white-on-black, @2x. Put your entries in that category and they get their own tab. The icon must be an 8-bit greyscale TGA (type 3): white is the glyph, black is see-through, and the game tints it like its own tabs. A full-colour RGBA icon shows its background as a dark square. menu = "LANDSCAPING" works the same way, and a tile there can open a window: fire a react event when its action mounts.

TF3: a template whose preview fails crashes the build menu TF3

For a constructionTemplates (DYNAMIC) entry, the build menu builds a preview result while you hover it (api.engine.util.construction.getConstructionResult). If your script fails, the game's own code crashes the UI: bad argument #1 to 'pairs' (table expected, got nil) in table_util.tl(107): shallowCopy, from construction.tl(3674): getNameAndDescription. Follow the base modular pieces (headquarter.script.tl): put everything (models, groundFaces, terrainAlignmentLists, colliders) in result.subconstructions[1], with only slots at the top, and give the subconstruction a metadata table, because the menu reads subconstruction.metadata.

TF3: a UI script can open a web page in the player's browser TF3

app.getUserProfile():openUrl("https://example.com/page?x=1") opens the page in the player's own browser. The game's mod-upload window uses it for its mod.io terms link. Seen working from a mod's button (2026-10-08). Gone since build 40420 (2026-10-09): UserProfile has no openUrl any more (not in api/tealdef, not in the game's exe); calling it errors with 'attempt to call method openUrl (a nil value)'. Point players to the in-game mod browser instead (search the mod's name). The profile still has subscribeMod(id). Put whatever the page needs in the address (?speed=313&noise=quiet) and let the page read location.search. Players can't copy text out of the game, so this is how a mod hands data to a website. Wrap the call in pcall.

TF3: a construction's own extra values are hidden from c.params in UI scripts TF3

A construction can carry values its .con doesn't declare (a script sets them when it rebuilds it with createProposalReplaceConstruction), and the game keeps them. An engine script reads them as con.params.yourKey. In a UI script, though, getComponent(e, CONSTRUCTION).params only shows the declared options, so those keys read as nil. Read them with c.params_native:asTable(). A rebuild started from the UI must copy its params from params_native:asTable() too; copying c.params silently drops the undeclared values.

TF3: a hand-written .mdl needs version = 2 TF3

A model file written by hand (not exported) with version = 1 stopped the game at start-up, before the menu: Assertion `legacyLocation || version >= 2' failed, naming the .mdl. Only models in the old (legacy) folders may be version 1. End every model you write with version = 2, as the game's own and the Model Editor's exports do. The model doesn't even have to be used: every .mdl in a mod is read when the game loads.

TF3: a module's addModel doesn't work TF3

A .module.lua's updateScript gets (captureParams, result, transform, tag, slotId, addModel, params), but the base game names that addModel argument _addModelFnInvalid and never calls it. A module inserts its models into result.subconstructions[1].models itself, with its tag, usually at the slot's transform from result.slots.

TF3: a modular construction's base needs a model of its own TF3

The module bulldozer (key B) refuses to remove the last model of a construction: "Empty Construction". If the base only has ground paint and one module holds the only model, that module can never be swapped. Give the base a small permanent model (for example the base game's ::/assets/streets/cable_junction.mdl). In the module builder, swapping is remove (B), then pick the new module and click the slot.

TF3: a live build ghost with ProposalViewer, and loading your own scripts TF3

Inside a tool's action, builtin.ProposalViewer { simpleProposal = sp } draws a whole planned build as the game's own see-through preview: roads, track and stations. Rebuild sp when the cursor moves a few metres. The game colours it red when the proposal has errors. Several stations in one proposal collide with each other, so check with makeProposalData(sp).errorState / collisionInfo.

Fatal: every viewer must be a direct builtin child of the tool's ActionDescriptor. Wrapping each ProposalViewer in a recipe of your own crashed the game with "Assertion it != storage.frontend.identity2node.end()" (BuiltinActionDescriptor CollectActionConfig). And there can be only one ProposalViewer per tool: a second one is fatal, with "Must not specify more than 1 ProposalViewer". To stop a ghost going red from colliding with itself, trim it instead. makeProposalData(sp).collisionInfo.collisionEntities is a list of records whose .entity is the id. A negative id is one of the proposal's own new pieces, so drop those pieces from the ghost and check again.

Fatal: do not put builtin.ActionTooltip { recipe = construction_react_util.SimpleTooltipRecipe, param = ... } among an ActionDescriptor's children to get a message at the cursor. The game died with "Assertion (recipeId) < recipeNames.size()", actual 1634886000: those are the bytes "para", the start of param, read as a recipe id. The base game only hands an ActionTooltipParam back from a selector-based action's hover function. Show your messages somewhere safe instead, such as a text beside your button in the mod button area.

Read the cursor from the Selector's mouse Moved events. A step timer mostly saw no terrain position, so the outline it drew kept vanishing.

Loading code from your own mod in a UI script:

  • ug_require "/bpx/bpx_select_core.tl" found a plain module.
  • ug_require "/bpx/bpx_dev.script.tl" was looked up in the base game (::/bpx/..., "not found").
  • A .script file loads with the full mod path: ug_require "yourmod_1::/bpx/bpx_dev.script.tl".

TF3: change vanilla data live with a postRunScript TF3

Learned by reading SteveB's "Country Roads 100 km/h" (mod.io; method only). In mod.json, set "postRunScript": { "fileName": "yourmod_1::/mod.script@postRunFn" }. That function runs once every resource has loaded. To change a road type:

  1. Get it with api.res.streetTemplateRep.find(name).
  2. Read it as a table with getAsTable(id).
  3. Change it (here every laneConfigs[i].speed, in metres per second: 100 / 3.6).
  4. Write it back with setAsTable(id, t).

The game's own roads change, including ones already built, with no new road types. Read the template back to check, since some builds don't return the advertised success value.

TF3: timers run on the simulation threads; userdata has no sub-folders TF3

The function you give engine_react_util.useStepStateTimer(fn, seconds) runs on the simulation threads ("Main Pool N" in the log), not the UI thread. There, app.loadUserdata fails with "API is currently restricted". Keep the timer's function to reading state and maths, return a value, and do file work only from a mouse or keyboard event (UI thread). We saw a tool's render run on the simulation threads too, so the render isn't safe either. For anything rebuilt while the mouse moves, load the data once and keep it in memory.

The same goes for api.gui.fireReactEvent: called from those threads it does nothing, and it gives no error inside a pcall. A message we sent from a tool's render, after a check there, never arrived. Sent from the tool's Selector.onProcessMouseEvent on a "Moved" event instead (the UI thread), it arrives.

Declare before you assign. In a UI script, a function that sets a variable whose local line comes later in the file is setting an undeclared global, and TF3 throws. Ours did it after sending a build command, so the build happened and everything after it in the same mouse handler silently stopped. Move the local above every function that uses it.

The log you are looking for. TF3 writes its whole running log to <Steam userdata>/<id>/3493540/local/crash_dump/stdout.txt: every debugPrint, every caught Lua error with its file and line, "Failed to load userdata", and at a native crash the MinidumpCallback line. The last of your own lines before that line names the step that crashed. Read it before building trace files or asking testers for screenshots.

Fatal: stacking an ImageView and text with builtin.AbsoluteLayout { AbsoluteLayoutChild {...}, ... } inside a window crashed TF3 natively the moment the window opened ("Internal error", no Lua message, so a pcall fallback never runs). Our text box was a BoxLayout given a meta.styleSheet, a field BoxLayoutParam doesn't declare. That is the likeliest cause, but unconfirmed: try overlapping layouts on a spare save, one change at a time.

app.saveUserdata(folder, name, data) refuses a slash in the folder: "Forbidden file name (blueprint_exchange/saved) detected, another valid version could be 'saved'". Use side-by-side folders such as mymod_saved.

Cursors are a fixed engine list. Set mouseCursor on the tool's ActionDescriptor to one of gui_react_util.getMouseCursorInteger's numbers, for example Default 0, Camera 1004, Bulldoze 1008, Grab 1010, Cut 1014, PlaceAsset 1023. The images are gui/cursors/*.tga. Replacing one changes it everywhere in the game, so a mod can't have its own cursor. A build-menu tile can open a window: on mount, fire a react event that an always-mounted plugin (a mod button area) handles.

The game's own rotate keys for building are input actions. From settings_keys_v3.lua: constructOpt2 = N / D-pad left and constructOpt1 = M / D-pad right. Hook them with react.useInputAction("constructOpt1", react.iaHandler(fn)) in your tool's top-level recipe, where the base tools do it, and wrap each in pcall. That gives PC keys and console D-pad in one go. The base game's own step is 10 degrees per press, 1 while holding Shift (IA_PRECISION_MODE), from construction_react_util's rotation stepValueFn. It applies the value negated, so M turns clockwise. IA_APPLY, the click, never reached our tool, so take clicks from the Selector's mouse events.

TF3: removing road / track by script -- use the engine's helper TF3

SimpleProposal.constructionsToRemove removes stations and other constructions fine. Road and track pieces are different: a SimpleProposal with streetProposal.edgesToRemove, with or without nodesToRemove and edgeObjectsToRemove, made makeWorldBuildProposalCmd throw "Unknown exception", even with nothing else in it. The engine has a helper for it: api.engine.util.proposal.makeSegmentsRemoveProposal({ pieces }) returns a full Proposal to pass to makeWorldBuildProposalCmd.

In the same table:

  • createProposalReplaceConstruction(entity, params, newResName) rebuilds a construction with new parameters.
  • refreshConstruction(entity) refreshes a construction.
  • A segment refresh that takes a new street template rebuilds a road or track piece with a different template.

Never remove a construction's own pieces (its frozenEdges) by hand.

TF3: a custom tool's mouse, and a fatal zero-length line TF3

A construction_tool (a .res.lua of that type whose getDefinition returns action = "ACTION_CUSTOM" plus customAction = { recipe }, as in the base game's Marketing Campaign tool) can put a builtin.Selector on the map. Its onProcessMouseEvent(evt) receives events: Entered 3, Moved 5, PressedOutside 8, Pressed 0, but after a Pressed we saw no Released. Three more findings:

  • The tealdef's record Mouse exists at runtime as api.gui.mouse (lower case). api.gui.Mouse is nil.
  • Shift is api.gui.inputAction.modifierOnlyActionIsActive("IA_PRECISION_MODE").
  • An EdgeGeometry of type CUBIC_SPLINE drew a straight chord: its tangents were ignored. To follow a curved piece, sample its Hermite curve yourself and draw short STRAIGHT steps. Fatal: handing builtin.EdgeRenderable a straight edge of zero length ends the game with "Assertion len > .0f failed". Never draw a line until it has length.

Fatal: putting that selection layer (an ActionDescriptor with a Selector) inside an ordinary UI strip, such as a mod button area plugin, ends the game with "Assertion !IsTransformWithContext(node.recipeId)". It only runs as a build-menu tool's action. highlightedEntities on the descriptor lit nothing for us, so we draw the selection with EdgeRenderable. To send the player to your tool from your own button, fire react.fireEvent(nil, "constructionMenuSelectTabForConstruction", { resName = ... }). The build menu opens on the tab holding it, even from closed; it's how the entity windows' Configure button opens the menu. No event switches a tool on: the player still clicks it. Fatal too: the tool's action function (params.setActionFn(fn)) must return the ActionDescriptor itself. Wrapping it in a recipe of your own gives "Assertion descriptorNodeId.has_value()". Share code as plain functions called inside the action function; hooks still work there.

TF3: a looping model animation needs no script ("forever") TF3

The town buildings' rotating shop logos (for example assets/buildings/com.zip, com/logos_b/b_cof_rot_l.mdl) give the moving node animations = { forever = { type = "FILE_REF", params = { id = "ani/forever/<name>.ani" } } }. The id is relative to the model's folder. The .ani is { times = { 0, 100, ... } (ms), transfs = { 16-float matrices } }, and the coffee logo uses 41 keys of 9 degrees for one turn every 4 s. The key transform applies on top of the node's own transf, so the pivot is the node's origin. It loops with no script or metadata, inside constructions too. To make part of a model move, split it into its own node with its origin on the pivot. Splitting by material alone isn't enough. The same trick switches lights on and off: scale a node to near zero and back, with each key time repeated so the switch is instant. Give three nodes the same 1 s loop with offset switch times, and a lit chevron sign's arrows chase in sequence. This works on a static construction model, as seen in game.

Seen in our own model too (a searchlight sweeping 35 degrees each way over 11 s, 45 keys): the moving nodes can have an identity node transf with the pivot at the model origin. Several nodes (one per material) can share the same .ani. A metadata spot light doesn't need to move with it. On another point: an EMISSIVE material ignores its texture's alpha (its COLOR pass is color.prog, not color_alpha.prog), so a glowing "light beam" made of planes draws as solid panels. Use PHYS_TRANSPARENT for anything see-through.

TF3: a construction that owns roads can't take modules TF3

A road-owning construction (a toll plaza that builds its booth lanes through result.edgeLists) with a module slot shows its modules in Configure, but placing one is refused: "Construction Not Possible", with the construction's own lane pieces drawn red. This happened joined to roads or not, and with collider-free models. The module builder won't rebuild a construction's own road pieces. The fix that works: two constructions. The road one owns the roads, and a road-free one placed over it carries the slots and swaps freely.

The road-free layer can find exact spots (lamps, barriers) without copying them: in the same .script.lua, keep the returned table in a local (local T; T = { ... }; return T). The layer's updateFn calls T.updateFnRoad(captureParams, {its size params}) and reads the models it would place, then puts a slot at each one. An empty slot can still draw the default model, so the layer looks finished before anyone configures it. One module can fit several kinds of spot if the base stores per-slot data in an extra result field (keyed by slotId) that the module reads.

TF3: react-replacement-config in practice -- exported recipes only, and a local one by its id TF3

The resource { type = "react-replacement-config", data = { filePath = "<mod>::/x/y.script", doReplaceFn = "doReplaceFn" } } makes the game call doReplaceFn(replacementApi); replacementApi.ReplaceRecipe(recipeFn, replacement) swaps an EXPORTED recipe of the base GUI for your function, which can call the original (_react.originalRecipeFn[id]) and wrap its result -- a bar under it, a button beside it. No vanilla file is overridden. A recipe a base file keeps local cannot be named: 'Save Your Train' (DysonHeluck) finds the Buy Vehicles cart by registration order (ids are handed out in order: the cart is two before the exported VehicleStoreWindow, found with a marker replacement), checks its parameters on the first call and disables itself if they don't match -- clever, but a game update that reorders the file silently turns it off. The line manager's action row (A*, colour, Buy Vehicles) is such a local recipe. 2026-10-17, read from Save Your Train 1.0

TF3: GUI save data -- savegame storage from the GUI side TF3

api.gui.game.getGuiSaveData(modId) / api.gui.game.setGuiSaveData(modId, table) keep a table in the savegame from the GUI thread, no game script or events needed (wrap both in pcall). Store vehicles by model FILE name (api.res.modelRep.getName(modelId), back with api.res.modelRep.find(name)) so a saved consist survives sessions and fails cleanly when a mod is removed; vehicle_util.makePart(modelId, forward, colour) (gui/line_vehicle_mgmt) rebuilds a part, its load settings as compartment2loadConfig of api.type.LoadConfig with loadConfigIndex. 2026-10-17, read from Save Your Train 1.0

TF3: place a paired sign relative to its partner, not by left / right arithmetic TF3

Harbour City's proven rule for a board's readers -- they drive against its face, so their right is (-face.y, face.x) -- gave a Town Sign pair that came out on the wrong side in game (SEEN 2026-10-17), and two rounds of formula flipping cost an hour. What settles it for a PAIR (Welcome / See you soon): keep the one the player placed as the reference and never move it; put the partner on the OPPOSITE side of the road from it (its offset across the road's line, sign reversed) and face it the OPPOSITE way (its face along the road, reversed). No left / right convention involved. 2026-10-17, PYT - Town Signs (TS5d)

TF3: a terminal's stop node needs a station edge AFTER it -- or the game asserts and dies TF3

A custom station terminal with vehicleEdges set to the lane's last edge only, and vehicleNode on the edge before it, loaded fine and showed in every window -- and the first time a vehicle computed a path into that bay the game crashed: transport_vehicle_util_path_common.cpp: AppendUntilEndOfStation, Assertion (s)>=(1) failed, 0 < 1, a fatal error with a crash save. The path builder walks from the stop node to the end of the terminal's edges and wants at least one. Rule: the stop node must lie on one of the terminal's vehicleEdges with at least one of them still ahead -- the safe shape is two station edges with the node between them (the base game's bays). To make a queue lane (vehicles waiting behind a one-vehicle bay), split the lane into approach + bay + bay-end: only the two bay edges are station edges, the node between them. 2026-10-17, PYT Fleet Control (H21 -> H27)

TF3: the warehouse window's cargo pin needs a stock tag and a param read-back TF3

A custom warehouse construction (warehouse = {} + a STORAGE_STOCK) shows the vanilla stock-type buttons, but the choice never sticks. The window (entity_window_util.tl) pins a type by writing constructionParams.tagToCargoType[stock.tag] = cargoResName for every stock whose tag >= 0, then createProposalReplaceConstruction + makeWorldBuildProposalCmd. So the stock needs a tag (an integer) and the script must read it back: cargoType = params.tagToCargoType and params.tagToCargoType[tag] or nil, as the base warehouse's addStock does (warehouses/warehouse.script.lua). A universal (mixedTypes = true) stock is not pinnable. A building placed before the tag existed never gets the pin -- it has to be placed again. 2026-10-17, PYT Fleet Control (S6)

TF3: a painted-on detail that casts no shadow TF3

A badge, letter or emblem modelled as a thin plate in front of a sign has to keep the flicker gap (20 mm here), and in sunlight it then throws its own shadow on the board: it reads as a stuck-on object, not paint. Give its material params.light_receiver.disableShadowMapWrite = true (the base game does this on some street materials) and it casts no shadow while still being lit. Use a COPY of the material for the badge only; the board keeps its shadows. 2026-10-17, PYT Distance Signs (DS19)

TF3: real road distance, and 'what is down THIS road' TF3

The pathfinder answers both. api.engine.util.pathfinding.findPathNodeToNode(fromNodes, toNodes, { api.type.enum.TransportMode.CAR }) returns the route as { {EdgeId, dir} }; sum getComponent(edgeId.entity, TRANSPORT_NETWORK).edges[edgeId.index + 1].geometry.length for metres (nodes from api.engine.util.octree.findTransportNetworkNodesInCircle). About 20 ms per sign for 5 towns. For direction: findPath(starts, toNodes, modes, maxLength) with starts api.type.EdgeIdDirAndLength.new(api.type.EdgeId.new(roadEdgeEntity, i), true, 0) on the car lanes of the road piece under you (the BASE_EDGE entity's own TRANSPORT_NETWORK; a lane is one-way, start to end of its geometry). Route once from the lanes running your way and once from those running back: a town behind comes out LONGER from your lanes, but only by a short loop (cars turn round at a piece's end, turnaroundEdges), so compare the sign of the difference, not a big margin. Searching for nodes 60 m ahead of you instead fails on long country pieces (their nodes are only at the piece ends). Seen: a dead-end road's board read just its own town. Construction scripts and mesh-less models can't do any of this: the fill runs in a GUI-thread game script, and labels draw only on a model with a surface. 2026-10-17, PYT Distance Signs (DS4-DS11)

TF3: a class store takes one cargo at a time -- the first that arrives, unless pinned TF3

Every non-universal warehouse store is mixedTypes = false with cargoType = params.tagToCargoType[tag] (base warehouses/warehouse.script.lua addStock). Unpinned (cargoType = nil) it holds whichever cargo of its class arrives first and stays that cargo until it is empty again -- one unit of the wrong cargo blocks it. The window pins one exact cargo: clicking a cargo icon on the store (entity_window_util.tl) writes tagToCargoType[tag] = cargo res name and rebuilds the construction (createProposalReplaceConstruction); clicking the pinned icon clears it. The icons offered are only cargos api.engine.util.stock.isCargoTypeCurrentlyProduced -- a cargo nobody makes on the map yet cannot be pinned. Adding or removing a module clears that slot's pin (preProcessFn). The window carries a commented-out direct route, api.cmd.makeStockListSetStocksCargoTypeCmd(stockListEntity, stockIds, cargoTypeId) -- untested, and with no param behind it, a rebuild would likely drop it. Read from the game's source 2026-10-17, PYT Fleet Control

TF3: resizing a warehouse store by rebuilding keeps its stock -- unless it shrinks below it TF3

A store's capacity comes from the construction script, so a player can resize stores by writing new params and rebuilding (createProposalReplaceConstruction(con, params) + makeWorldBuildProposalCmd, as the vanilla window pins a cargo). Seen in game: the rebuild keeps every store's amount and the same entity, and the new capacities show at once. A store rebuilt SMALLER than its contents is cut to the new size and the rest is destroyed (175 logs into 174 left 174). Block shrinking below the stock in your UI. The warehouse window takes cards through react-plugin ::WarehouseEowExtensionPoint. 2026-10-17, PYT Mega Warehouse (MW3)

TF3: what sets how fast cargo loads -- vehicle x cargo x terminal x stock TF3

The engine multiplies: the vehicle part's transportVehicle.loadSpeed (.mdl metadata: trucks 4-10, a universal truck about half its specialised twin, wagons 2-8 rising by era, ships 10-35) x the cargo's loadSpeedFactor (every freight cargo 0.0625, passengers 1) x the terminal's cargoTransferSpeeds[].loadSpeedModifier (a Bulk / Goods / Flatbed / Liquid platform or street cargo terminal = 2, universal = 1; modular_station.script.lua, modular_terminal.script.lua) x the stock's loadSpeedModifier (vanilla warehouse modules addStock(class, 500, 2.0), the universal pool 1.0). Penalties, 0.25x each (gui/main/cargo_react_util.tl): the part of a vehicle hanging past the terminal (each rail platform segment = 40 m of terminal, a street piece 10 m) and a terminal whose specialisation doesn't match the cargo. No speed comes from the distance inside the catchment, the number of terminals (only how many load at once) or the station buildings (they set the catchment radius only). A vehicle also stalls on 'Waiting For Cargo' when the stock is empty. So for a warehouse feeding trains: matching specialised platforms long enough for the whole train, and STORAGE_STOCK loadSpeedModifier = 2.0. Read from the game's files 2026-10-17, PYT Mega Warehouse

TF3: a station's reach is its catchment circles -- the warehouse has none TF3

Cargo coverage belongs to the STATION: its station.catchmentAreas, a list of { position, radius } circles (api.type.CatchmentArea), added from each module's catchmentCargo.radius by constructionutil.addCatchmentCircles (rail main building 150-200 m, street entrance 175 m). A warehouse or industry only declares its STORAGE_STOCKs -- no radius, no position per stock. A mod can widen a station's reach by appending circles to catchmentAreas in the station's terminateConstructionHook (the 'Warehouse Station Coverage' mod adds one over every occupied vanilla warehouse module). Which part of a big warehouse a circle must touch (its footprint or its origin) is decided in the engine -- that mod tests footprint contact; not yet seen for a 720 m camp. Read 2026-10-17, PYT Mega Warehouse

TF3: text on a model in the player's language -- labels, the game's translator and its own words TF3

Painted words can't follow the language, CUSTOM labels can: put the text in labelText from the construction's params and let a GUI-side script fill those params with api.util.getLanguage().code (en de fr it es nl pl ru ja ko pt_BR zh_CN zh_TW). Seen 2026-10-17: a German game turned the same sign into WILLKOMMEN IN / AUF WIEDERSEHEN. Three traps. (1) A cargo's api.res.cargoTypeRep.get(id).name read at LOAD (postRunFn) is the untranslated English key -- the game's window translates when it draws, so a German window said Wolle while our boards said Wool. Pass it through the translator _(), and catch _ in a local first: for id, _ in pairs(...) hides it inside the loop. (2) The game's own translations live in base/strings/<lang>/LC_MESSAGES/base.mo (gettext; Python's gettext.GNUTranslations reads it) -- copy the words you need into your strings.json and you print exactly the game's names in all its languages. (3) A label's transf is 16 numbers; 17 (one stray 0) gives no error, it shifts the translation into the wrong slot and the text vanishes or squashes into a line. string.upper only knows A-Z: upper-case accented and Cyrillic names yourself (UTF-8 C3 A0-BE minus 0x20, D0 B0-BF / D1 80-8F to D0 90-AF) and cut long names at a whole letter. PYT - Town Signs International (TSI1-2), PYT - Mega Warehouse (MW18)

TF3: specialised vs Universal warehouse stores -- loading speed and the delivery timer TF3

Read 2026-10-17 (base warehouses/warehouse.script.lua + transportfever.guide 'Warehouses and transfers'): a specialised store (Bulk / Flatbed / Goods / Liquid) is STORAGE_STOCK with mixedTypes = false, one cargo class, loadSpeedModifier = 2.0 -- handled at 2x and the cargo's delivery timer pauses while it is stored. The Universal store is mixedTypes = true, class UNIVERSAL, loadSpeedModifier = 1.0 (the vanilla module passes 2.0 to addStock, but all Universal modules merge into one stock that keeps 1.0) -- 1x, and the timer keeps running. There is no timer field in the stock: it follows mixedTypes. Every module is 500 units. A mod's own stores get the specialised behaviour by copying those three fields (PYT Mega Warehouse does: one locked store per cargo, 2.0). Storage never completes a delivery and towns / industries never draw from it -- an onward line is always needed.

TF3: coloured lines over the map, what a station reaches, industry productivity -- read from another mod TF3

Read 2026-10-17 in a mod.io mod ('Town & Industry Controller'), for the PYT map, Fleet Control and the Control Room: (1) Lines drawn in the 3D world, over the terrain: a GUI recipe returns builtin.ActionDescriptor { terrainCirclePolicy = "Never", children = { builtin.EdgeRenderable { edges = edges, ignoreDepth = true }, builtin.Selector { filter = fn, onSelect = fn }, builtin.LineViewer { showLines = lines, ... } } }; each edge builtin.type.EdgeRenderable.Edge.new(shape) (shape = an EdgeGeometry: positions, tangent, length, width) with edge.colors = { vec4, vec4 }, edge.width, edge.offsetZ = 4, edge.stepSize = 8. LineViewer highlights the player's own lines; Selector makes chosen entities clickable. (2) What a station reaches: api.engine.system.catchmentAreaSystem.getStationCatchables(station, false) (its catchment's buildings / industries / warehouses), and getStation2stationsAndDistancesMap() (stations within walking reach of each other). (3) An industry's productivity: api.cmd.makeStockListSetModifiersCmd(entity, modifiers) with modifiers = api.type.StockList.Modifiers.new(stockList.modifiers) and modifiers.productivity = x. (4) Where people travel: api.engine.system.townBuildingSystem.getPersonCapacity2townBuildingMap() plus each SIM_PERSON's destinations (1 = home) -- private cars are SimPersons, the player's fleet TransportVehicles. Also there: makeTownCreateCmd, makeTownDestroyCmd, makeTownSetDevelopmentActiveCmd. Not yet tried in a PYT mod.

TF3: fill a store for a screenshot (or a test) with the debug stock command TF3

api.cmd.sendCommand(api.cmd.debug.makeStockSetCargoAmountCmd(stockListEntity, stockId, amount)) sets one store's amount outright. stockListEntity is the entity with the STOCK_LIST component (the warehouse window's entityId); stockId is the 0-based id from cargo_util.calculateStockConfigs(...)[i].ids[1] (list.stocks[id + 1]). The store's own locked cargo fills; the window's Stocks card and the map labels show it at once. It sits under api.cmd.debug, not api.cmd.make -- keep it on a DEV button and take it out before release. Seen 2026-10-17: every store of a 5,000 Mega Warehouse to 5,000/5,000 from the GUI thread, no crash. PYT Mega Warehouse (MW16)

TF3: copy the game's require paths with '::/' in front TF3

The game's own GUI scripts write ug_require "/gui/main/cargo_util.tl". Copied into a mod, that bare /gui/... path resolves inside the MOD (yourmod_1::/gui/main/cargo_util.tl): "module not found", and the window that loads the script reloads the UI. Write ug_require "::/gui/main/cargo_util.tl" for a game file. 2026-10-17, PYT Mega Warehouse (MW3b)

TF3: a store locked to a cargo the climate lacks is fatal TF3Fatal

Placing a warehouse whose STORAGE_STOCK had cargoType set to a cargo this game does not have killed the game: Assertion `stk.m_cargoTypeRep->IsVisible(stock.cargoType)' failed (create_proposal_data.cpp). Cargos belong to climates through their categoryList (temperate.eco, dry.eco, tropic.eco, subarctic.eco): Rubber and Tires are tropical only; Books, Cement, Paper and Sawdust subarctic only; Clay is temperate and dry; Beverages, Bricks, Fish and Vegetables each miss one climate. A construction script has no api.res to ask. The base game solves the same problem for industries in its postRunFn (base/mod.script.tl): it keeps only cargos where api.res.cargoTypeRep.isVisible(id) and writes a new api.type.ScriptRef (same fileName, edited params) onto con.updateScript; the construction script then reads the list from its first updateFn argument. 2026-10-17, PYT Mega Warehouse (MW2)

TF3: a construction with no collider is free ground to the game -- an industry spawned on it TF3

A construction whose models all have collider NONE (they stand over lanes) and whose subconstructions carry no colliders occupies nothing as far as the game is concerned: the map's trees stood through its buildings, and later the industry spawner put a steel mill on top of it. Vegetation and spawning are both governed by the construction's colliders, not its ground faces. The base game's fields do it with one flat POINT_CLOUD over the whole extent (industries/fieldutil.lua): colliders = { { type = "POINT_CLOUD", transf = IDENT, params = { points = { the site's corners at z 0 and z 14 } } } } on a subconstruction that is actually emitted (we first put it on a helper sub whose models and lanes were merged into another sub -- the collider was left behind and the hub had none). The collider must not overlap the construction's own street stubs (those are refused "Construction Not Possible"). 2026-10-17, PYT Fleet Control (H20 / H25)

TF3: a module's model collider counts against its own construction TF3

A module whose model still had a BOX collider (from the Model Editor's default) was refused on a road-owning construction: "Construction Not Possible", with the construction's own lane pieces drawn red where the box overlapped them. Set the collider to NONE on models that stand over roads or inside other pieces, modules included. The Model Editor resets it to BOX on every re-save.

TF3: a red slot means the slot itself collides TF3

An empty module slot drawn red, with the module ghost refusing to snap anywhere, meant the slot's own volume collided with something nearby: the base's own model inside it, a street light. result.slotConfig = { <slotType> = { skipCollisionCheck = true } } fixed it (the finished construction is still collision-checked), and the slot turned blue and took the module. Slot fields from api/tealdef/api/type.d.tl: id, type, transf, spacing (left, right, south, north), height, shape (0 square, 1 triangle, 2 rectangle), alignToTerrain, autoFill, replaceable. Setting replaceable = true alone did not let a module be placed over an occupied slot in our test.

TF3: templates: 1-based params, and marks don't persist TF3

A DYNAMIC template's data.params are the menu's parameters. createTemplateFn(captureParams, params) gets them 1-based (the default 4th choice arrived as 4), plus params.year, and returns the starting modules { [slotId] = "path.module" }. A value you set on params inside createTemplateFn (a "this one is new" mark) did not reach the construction's later updateFn.

TF3: loading a save doesn't re-run construction scripts TF3

Changing a .con's script (for example making it modular) doesn't change constructions already in a save: they keep the result saved with them until something rebuilds them. Existing pieces only get the new behaviour (here, a module slot) after a rebuild, for example api.engine.util.proposal.refreshConstruction or an upgrade. New placements use the new script at once.

TF3: a react-plugin's condition hides the whole plugin TF3

A react-plugin resource can name a condition function. When it returns false, the plugin isn't built at all (no log line, nothing). Don't let the condition do double duty, such as also flagging something for a UI replacement: change one meaning and you silently lose the other.

TF3: Configure is the module builder TF3Inferred

The Configure button opens TF3's module builder (setModuleBuilderEntity). Its panel lists only params marked postConstructionModifiable, and it has no close button (Esc only). Modular constructions use constructionTemplates (type DYNAMIC), createTemplateScript, result.slots and .module.lua files. Untested beyond that.

Fix off-centre models in the .con, not in Blender TF2

A "skewed" gantry was a model authored with its origin at one edge. Fix it with per-model dx/dy offsets in the .con transform (and the terrain patch). Check the bounding box and named parts in the compiled .mdl before blaming the mesh. Helper transforms are tricky too: transf.rotZYXTransl(rot, transl) takes yaw from rot.x. Read res/scripts/transf.lua.

Stretching one model to fit any road TF3

A model scaled across the road in a .con transf (a Y scale) moves every part about its origin. Per-lane parts land on the lanes only if they are symmetric about the origin and the scale is measured from the road the model was drawn for, not from its bounding box (posts outside the pavement make the box wider than the traffic). Two one-way gantries with an off-centre lane layout missed the lanes on every road until their heads were centred.

Never point a .con at a model that isn't there yet TF2Fatal

A .con offering a .mdl that doesn't exist yet is a hard "material/model not found" crash. Wire a dropdown option only after its model is compiled.

Ship shared assets byte-identical TF2TF3

A save refers to constructions and models by file name. If two mods ship identical files, either mod loads the other's saves, and removing one keeps the placed objects. Any difference forks behaviour. Keep a checker script that compares the copies.

Upgrading a station in place: createProposalReplaceConstruction TF3

To change a placed station's modules (for example its platform tracks to a newer track type), take its params from params_native:asTable(), rename the modules, and build api.engine.util.proposal.createProposalReplaceConstruction(entity, params). Send it with api.cmd.makeWorldBuildProposalCmd(proposal, nil, false, true). The engine keeps the track joined to the station, and its lines. A SimpleProposal that removes the station and adds the new one (with old2new = { [entity] = 0 }, as the base game's mission helper does for buildings) was refused "Construction Not Possible" for a station with track joined to it, in the dry run and the real build alike. TF3's own track upgrade renames each track module to "::/trainstation_" .. resName:gsub("::", "__"), so ::/trainstation___/infrastructure/track/standard/standard.street_template. The signature is in api/tealdef/api/engine/util.d.tl, beside refreshConstruction.

A road bridge over a station's platforms is a collision in TF3 TF3

TF2 layouts with roads crossing over a modular station's platform ends on a bridge (and under them in a tunnel) paste in TF2, but in TF3 the road pieces are refused with a non-critical "Collision" against the station (an entity that isn't the construction, a piece or a node). Checking the pieces and the station separately against the map says it fits, so a preview built that way gives no warning. What worked: build the station first, check the pieces against the map before anything is built, and if the pieces are then refused with only a non-critical "Collision", send the same proposal once more with makeWorldBuildProposalCmd(proposal, nil, true, true) (errors ignored). The result looked right in game.

TF2 modular-station slots carry over to TF3 TF2TF3

Both games build a rail station's slot ids with the same GetId: type base (3400000 buildings, 6400000 cargo platforms, 7400000 passenger platforms, 8400000 track, 10400000 roofs) + 1000 i + 10 j for platforms and track, + 3000 i + 40 j + 10 k + o for buildings. So a TF2 station's modules map slot for slot. Three changes: module paths gain ::/stations/rail/modular_station/; a track slot holds a track type, not platform_track.module (::/trainstation___/infrastructure/track/standard/standard.street_template); and the building-size digit o (the slot id's last digit) differs. TF2 uses 0 / 1 for main buildings and 5 / 6 / 7 for side buildings of size 1 / 2 / 3; TF3 keeps 0 / 1 / 5 for size 2 / 3 / 1, main and side alike. The digit must match the TF3 building's own size (each module file says rail_building_sizeN): main 1 and 2 and side 2 are size 2, main 3 and side 3 are size 3, side 1 is size 1, and side_building_3_cargo is size 2. Building sizes differ between the games: a community TF2 station had side_building_3 in size-2 slots. Mapped across digit for digit, TF3 logged No main building data for slotId ... with offset 1 and crashed on Duplicate edges found. Moving it to a size-3 slot crashed too: it overlapped its neighbours and their footpaths doubled up. What works: keep TF2's size (its digit) and use the TF3 building of that size, so the layout keeps its spacing. A dry run did not catch either crash. Station params.length is one higher in TF3 (its length table starts one earlier).

TF3's era years (base content) TF3

Track: simple always, simple catenary from 1921, standard 1930, high speed 1980. Roads: _old_ to 1940, _new_ from 1940, and one-way roads are new-only (from 1940; town small and medium only, no large). Signals: signal_path_a to 1950, _c from 1950. Rail station modules: _era_a to 1950, _b to 1990, _c after. Bridges: stone always, steel and suspension 1940, concrete 1970, cable 2000. Road tunnel tunnel_a_car from 1900. Read them from the availability blocks in base/content/*/*.zip; anything placed from a script for another era should be swapped to the year's version first.

Decorations & trees

For years this was "the game doesn't allow it". It turns out a mod can capture, plant and remove decorations and trees.

Decorations and trees are ASSET_GROUPs TF2TF3

Assets-menu props (fences, benches, lamps, kiosks, rocks) and trees are ASSET_GROUP entities, not constructions. Each group's MODEL_INSTANCE_LIST lists its models: fatInstances[i].transf (translation at [13], [14], [15]; yaw = atan2(t[2], t[1])) or thinInstances[i].pos (position only). The name comes from api.res.modelRep.getName(modelId); cache it per id. A natural forest can be one group of thousands of trees.

TF2: find them with getEntities, with includeData TF2

game.interface.getEntities({ pos = p, radius = r }, { type = "ASSET_GROUP", includeData = true }). Without includeData = true it returns a LIST, so pairs() walks the indexes 1, 2, 3 instead of entity ids.

TF3: find them with the octree TF3

getEntitiesWithComponent/forEachEntityWithComponent refuse ASSET_GROUP ("Cannot loop over this component type"). api.engine.util.octree.findEntitiesInCircle(Vec2f, r, ComponentType.ASSET_GROUP) works. Note it is in api.engine.util.octree, not api.engine.util.

LINE is refused the same way. List lines with api.engine.system.lineSystem.getLinesForPlayer(api.engine.util.getPlayer()), then read each with getComponent(l, ComponentType.LINE). 2026-10-19, PYT Mega Warehouse (harbour mode).

TF3: a game character inside a construction (a driver on a forklift) TF3

No base building has people in it -- vehicle drivers are spawned by the game -- but a construction model can carry one. Graft the character's own node tree from characters.zip (e.g. characters/era_b_driver_road/era_b_driver_road.mdl: man01 > pelvis > 22 bones) under your model's node, and give every bone a single animation named forever pointing at the pose's per-bone file, e.g. ::/characters/shared/ani/man01/driving_upright/<bone>.ani. That is the structure the base game's farm cows use (animal/cow/cow_idle.mdl). Seen working: the man sits, hands on the wheel, and rides the forklift's own keyframed loop. Reference the game's paths (meshes, material, .ani) -- copy nothing, override nothing.

Poses that exist (man01 / wom01): driving (reclined, feet up), driving_upright, idle, sitting, walk -- no carrying or pushing. The characters' LOD 3 is a static standing figure, so drop the character from LODs past ~93 m. Cost: about +1,700 tris close up. 2026-10-20, PYT Mega Warehouse / Factory Buffer (Blender Claude's graft).

TF3: left-hand traffic -- how to tell, and what it flips TF3

TF3 itself only drives on the right; the Left Hand Traffic mod flips it as the game loads (a loadStreetTemplate modifier on every street type, other mods' too): each two-way road type's traffic lanes get forward reversed (and busAndTramRight = false); one-way road types keep their direction; station / depot / industry / warehouse interiors are converted after the construction pipeline. So a cheap test anywhere api.res works: country_new_medium's lane 2 (its first traffic lane; lane 1 is the pavement) has forward == false on a right-hand game and true once flipped. What it does NOT fix in your mod: anything you place by a rule like "on the drivers' right" (signs, boards -- turn them half round, or move them to the other kerb), and any construction that builds a two-way layout from one-way pieces on a fixed half (+X traffic on -Y) -- mirror those halves. A construction script may not be able to read api.res; let a GUI / game script that can read it write a param and rebuild the construction in place (createProposalReplaceConstruction). 2026-10-20, every PYT road mod (pyt_shared/pyt_drive_side.lua).

TF3: tram track hides in the lanes, not the road type TF3

The road tool's tram option (tramTrackType on the street builder) keeps the street's own template: a town street with electric tram track still reads roadTemplate = town_new_small. Only its lanes change: BASE_EDGE.laneConfigs[i].transportModes gains TRAM (5) and ELECTRIC_TRAM (6) on the outer vehicle lane each way -- read it as lc.transportModes[api.type.enum.TransportMode.TRAM] (SEEN: modes [1,0][2,3,4,5,6][2,3,4,5,6][1,0] on a 1-lane-each-way tram street). So a test like roadTemplate:find("_tram") only catches the game's ready-made tram streets (town_*_tram[_electrified]). For your own construction roads to carry trams, copy your street template and add "TRAM", "ELECTRIC_TRAM" to the lane the tram should use -- that is the whole difference between town_new_medium and town_new_medium_tram_electrified; the game draws the rails and the overhead wires from those lane modes (wires showed on highway-style plaza roads too). 2026-10-20, PYT Toll Way / plazas (P483).

But for a construction's own road, and for any road you build by script, do it the game's way: a construction puts tramTrackType = "YES" / "ELECTRIC" in its edge list's params next to the plain street type (as tram_depot.script and underground_station.script do). Tram copies of a street template built into a construction drew no rails (its pieces came out with no tram lanes at all). And never hand-write TRAM / ELECTRIC_TRAM into the laneConfigs of a street proposal's SegmentAndEntity: the game crashed 12 s later, TransportNetworkSystem IsValidTransition: person0 == person1 (the joints' lane connections, BaseNodeLaneConnection, carry their own "supports tram" flag that the hand-written lanes didn't match). SEEN 2026-10-20 (Blueprint Exchange T230, reverted).

TF3: where tramTrackType lays the track -- and never re-lane a road type a save holds TF3

A construction's tramTrackType = "YES"/"ELECTRIC" adds TRAM / ELECTRIC_TRAM to the outermost lane of each direction that is not a footway. The game's roads edge with footways (transportModes = { "PERSON", "CARGO" }), so the track lands on the outer driving lane; a highway-style template's shoulders are bare (transportModes = { }), so the track went onto the 2 m SHOULDER and the rails ran along the kerb, off the driving lane (SEEN, lane dump F5[2] F2[5,6]). Give the shoulders { "PERSON" } in a copy used with trams and it lands on the driving lane. busAndTramRight made no difference on a one-way road. And never change the lanes of a road type that may be in a save -- reordering a template's lanes froze the game loading a save built with it (the load hung right before "No resources are missing!"); put a new layout under a new name and keep the old one exactly as it was. SEEN 2026-10-21 (PYT plazas P499d / P500).

TF3: a two-way road splitting into two one-way roads -- each leaves its own side, straight ahead TF3

In a construction, a two-way road ending in one joint where two one-way roads leave (a toll plaza's end, a dual carriageway start): don't start both one-way roads on the centre line angled apart -- the game bulges the joint into a pinched "waist" (and tram rails S-bend through it). Do what the road tool does (measured from a hand-built 2w3l split): give the three pieces one node tag, start each one-way road on its OWN side of the joint -- 6.5 m off the centre line for 3 lanes each way (the middle of that direction's lanes) -- with its tangent pointing straight along the road, then S-bend out to where it needs to be. For one road fanning into single-lane roads at one joint, the road tool set the outer ones 5 m to the side (one lane) and 9-14 m back. Built that way, a Harbour City 2w3l plaza carried a tram line straight through. SEEN 2026-10-21 (PYT P495c / P498).

TF3: a street stop has a stopping place per lane -- trams only stop on the tram lane TF3

A kerb stop on a 2-lanes-each-way street is one station with 4 terminals (stopping places 1-4 in the line window's Terminals list; 6 on a 3-lane street, numbered from the kerb inward each way). A line uses one of them (line.stops[k].terminal, 0-based). Bus lanes push the tram track to the middle lanes, so a tram line on place 1 (the kerb lane) has no way through -- "Missing tram tracks" -- although the street has track. The fix is in the line, not the road: make the tram lane's place Preferred (api.type.Line.new(comp), set the stop's terminal, rebuild alternativeTerminals with api.type.StationTerminal.new(station, terminal), api.cmd.makeLineUpdateCmd -- not yet SEEN switching). Don't work out the lane from terminals[n].vehicleNodeId.index -- index / 2 landed on the pavement on one street; ask the game instead: findPathNodeToNode({ placeNode }, { nextStopNode }, { TRAM }) for each place, from the stop before and on to the stop after -- the place that routes is the one (it also picks the right direction). And fetch those nodes fresh (getComponent(station, STATION).terminals[n].vehicleNodeId) right before the call: handing the pathfinder nodes kept from an earlier read (with several start nodes per call) hard-crashed the game; fresh nodes, one start and one end per call, worked. SEEN 2026-10-21, PYT Mini Map 'Fix my tram line' (MM97-MM98).

TF3: drawing your own picture at run time -- ImageView pixel data TF3

builtin.ImageView{ data = {bytes...}, size = api.type.Vec2i.new(w, h), scaling = Stretch } shows a picture you build in Lua: 4 bytes a pixel (R, G, B, A, 0-255) -- 3 (RGB) works too; the game works the channels out from the length. Row 0 is the BOTTOM row, so for a north-up map the first row is the map's minimum y. Passing data copies the whole table into the game each time the node is rebuilt (a 256 x 256 map = 262,144 numbers) -- keep the picture in its own recipe so the rest of the window can redraw without it. Terrain from api.engine.terrain.getBaseHeightAt + isOnWater: 256 x 256 samples of a 16 km map in about 0.2 s. And a recipe must return a layout: a recipe returning a bare ImageView or Button reloads the UI with "Recipe child must be a layout" -- wrap it in a BoxLayout. 2026-10-20, PYT Mini Map.

TF3: reading every road and rail piece -- not with getEntitiesWithComponent TF3

api.engine.getEntitiesWithComponent(api.type.ComponentType.BASE_EDGE) throws "Cannot loop over this component type". Read the network with api.engine.util.octree.findEntitiesInCircle(centre, radius, BASE_EDGE) over a grid of circles (de-duplicate), or api.engine.system.streetSystem.getNode2StreetEdgeMap() / getNode2TrackEdgeMap(). Road or rail: the edge's own roadType == api.type.enum.RoadType.TRACK -- getComponent(e, ComponentType.BASE_EDGE_TRACK) failed on every piece ("Error decoding argument #3"). 2026-10-20, PYT Mini Map (2,272 pieces in 16 circles, 30 ms).

TF3: where did the player click on my picture? TF3

In a recipe, react.onMouseEvent(function(evt) ... end) gets Pressed / Released / Clicked / Moved / Wheel; evt.x, evt.y are screen pixels. react.useSelfRef():get():getPosition(0,0) / (1,1) give the recipe's corners as 0..1 of the screen. Convert with the screen's pixel size: api.gui.camera.getSize() divided by api.gui.byId.getSize("mainView") (as the TF3 'Minimap' mod does) -- then u = (evt.x / pixelsX - left) / (right - left). Move the camera in react.enqueueJoin(fn, "name"). Remember the picture's row 0 is the bottom: the top of a north-up map is the maximum y. FloatingLayoutChild h / v are 0..1 gravity (not -1..1). 2026-10-20, PYT Mini Map.

TF3: a build-menu option that only exists in its years -- how a construction learns the era TF3

A construction's own script can't read the game year (api.engine.util.getYear() fails there), so a camp placed in 1900 guessed "modern" until something rebuilt it. Fix: a param (not a whole entry) may carry yearFrom / yearTo -- the build menu shows it only in those years (gui/construction/construction.tl, getNewParamIndices). Two one-value rows, { key = "crew1900", yearFrom = 0, yearTo = 1925 } and { key = "crew1925", yearFrom = 1925, yearTo = 0 }: whichever key reaches updateFn tells it the era, from the preview on. The values stay in the saved params, so a later rebuild still knows. 2026-10-20, PYT Mega Warehouse (porters in 1900, forklifts from 1925).

TF3: upgrading a construction in place -- free, so charge the difference; turning and moving it keep the stock TF3

createProposalReplaceConstruction with new params rebuilds in place and keeps the stock -- and costs $0 (seen with the game paused: 1,000 → 2,000 per cargo, balance unchanged). To charge fairly, price both versions the game's own way: a dry run (api.engine.util.proposal.makeProposalData) of a fresh SimpleProposal.ConstructionEntity at the construction's own transf (copy c.transf[1..16] into a Mat4f) gives era-scaled pd.costs; charge new minus old after the rebuild succeeds, with a JournalEntry of negative amount through makeJournalBookAssetCmd (the Marketing Campaign pattern).

Moving or turning by remove + add loses the stock. Instead, keep the construction where it is and turn / slide everything inside its own frame in updateFn (models' transf, lane nodes and tangents, ground and alignment faces, collider points, catchment circles -- a depot's too -- edge positions and tangents, the snap point; map each shared table once). It stays an in-place replace: stock, lines and settings kept. A refused build's collisionInfo.collisionEntities with negative ids means the new version collides with itself. 2026-10-20, PYT Mega Warehouse 1.3 (Plan / Commit, Turn, Move).

TF3: a wrong path in a .res.lua is a fatal load crash TF3

A react-plugin .res.lua whose filePath names a script that isn't there (copied from another mod, the folder still the old one's) is no Lua error: the game dies while drawing its UI root -- GetRecipeName: Assertion (recipeId) < recipeNames.size(), a nonsense id like 1634886000. Check every "<modId>::/path" your Lua names against the mod's files before shipping. 2026-10-20, PYT Factory Buffer (its first launch).

TF3: the catchment highlight paints the warehouse subconstruction's ground TF3

When a warehouse window is open, the blue catchment highlight is drawn on the groundFaces of the subconstruction that holds the warehouse and its stocks -- ground painted on another sub (a station's, a yard's) stays its normal colour. Put the site's ground on the warehouse sub if you want the whole plot to light up; extra paint there (a plan outline) turns blue too while the window is open. 2026-10-19/20, PYT Factory Buffer / Mega Warehouse.

TF3: a cargo's delivery deadline in game days TF3

Every cargo has its own deadline: timeToDeliverInSeconds in its .cargo.lua (meat 900, fish / vegetables 1125, grain 1286, bricks 3000, steel / sand / stone / ore 4500) -- yes, bricks expire too. Convert with the world's GAME_SPEED.millisPerDay (the game's own duration text does) and show it with api.util.formatDays(n). A threshold in days left means the same for fish and bricks; a percentage doesn't. 2026-10-20, PYT Mega Warehouse (Clear expired cargo).

TF3: the UI font has no degree sign TF3

"90°" in a TextView shows as "90~". Write the word ("90 degrees") or plain numbers under a "(degrees)" label; don't gamble on other symbol glyphs (arrows) either -- test them first. 2026-10-20, PYT Mega Warehouse (Turn).

A ground face with no area kills the game TF2TF3

A groundFaces entry the engine can't triangulate is a fatal error, not a Lua error: ground_tex_util::Fill: Assertion '!result.triangles.empty()' failed. Seen with a 0.2 m strip whose corners ran backwards (y 170 → 169.8) -- a rounding gap between two measured plots. Build every face through one helper that sorts the corners anticlockwise (x0 < x1, y0 < y1) and skips anything under about 1 m either way. pcall does not save you: the build itself succeeds, the crash comes when the game paints it. 2026-10-20, PYT Mega Warehouse (upgrade plan paint).

TF3: spoiled stock -- counting it and binning only it TF3

cargo_util.calculateStockConfigs(entity, max, calculateQuality, ...) fills numSpoiled and quality (average time left, %) only when calculateQuality is true; with false they read 0 / nil and you'll think nothing has gone off. Per stock the source is api.engine.util.cargo.getCargoQualityDataForStock(entity, stockId) (countBad, countTotal, averageQuality).

The window's bin sends api.cmd.makeStockListDiscardCargoCmd(stockListEntity, stockIds, 1.0): every crate with up to 100 % of its delivery time left, i.e. the whole store. At 0.0 it removes only the crates whose time has run out -- seen: grain 197 with 24 grey → 173, the bar clean afterwards. The game has no such button; a mod can. 2026-10-19, PYT Mega Warehouse 1.3 ("Bin the spoiled").

Planting a decoration: a one-model construction TF2TF3

There is no plant-a-tree or place-an-asset command. A generic ASSET_DEFAULT construction holding ONE model chosen by a param, with no ground paint and skipCollision, does the job. Built by proposal, the game turns it into a real decoration. Each one becomes its own new group, never merged into the player's groups. Trees work exactly the same way. You don't need to know the original asset's .con.

-- res/construction/asset/my_prop.con (sketch)
type = "ASSET_DEFAULT", skipCollision = true,
updateFn = function(params)
    return { models = { { id = params.my_model, transf = IDENTITY } },
             terrainAlignmentLists = { { type = "EQUAL", faces = {} } }, cost = 0 }
end
-- and build it with params = { my_model = "<model>.mdl", seed = 0 }

TF2: removing a decoration TF2

A group id in SimpleProposal.constructionsToRemove is refused at command creation (no crash). What works is game.interface.bulldoze(id), called one id at a time from the game script (send the ids from the GUI as a script event).

TF3: removing a decoration TF3

These fail: SimpleProposal constructionsToRemove ("Unknown exception"), and createProposalRemove(group) with or without a context. The bulldozer's own shape works:

local p = api.type.Proposal.new()
p.toRemove = { group }
api.cmd.sendCommand(api.cmd.makeWorldBuildProposalCmd(p, nil, false, true))

Rules for copying decorations safely TF2TF3

  • Remove a group only when every model in it is selected. Otherwise copy it and leave the original.
  • Take trees only inside the selection circle, never whole groups, or you sweep the forest.
  • For an undo, a group counts as "ours" only if it is new and none of its models stood there before. Keep its models and spots, and remove it only if it still holds exactly those.
  • Building a new road through a decoration removes it, but widening an existing road does not (TF3).

Entities, queries & vehicles

getEntity for data; raw components are opaque TF2

game.interface.getEntity(id) returns rich plain data, for example industry itemsProduced/itemsShipped with _sum/_lastYear/_lastMonth per cargo, and player balance and loan. Raw api.engine.getComponent objects are userdata that pairs() can't enumerate. Named-field access may still work, so a failed dump doesn't prove a field is missing.

Where a construction's models live TF2

The construction's MODEL_INSTANCE_LIST.fatInstances holds only the furniture (plus .module names that aren't models). The bulk (platform slabs, station bodies) is in thinInstances: modelId, position, yaw, scale. No component exposes groundFaces. tn_* helpers are mesh-less lane carriers. BOUNDING_VOLUME.bbox is world-axis-aligned, which is wrong for rotated stations. Re-read the CONSTRUCTION transform for positions, because the getEntities record can be more than 2 m off.

Cars, buses and people are not what you'd think TF2TF3

TF2 has no bus carrier: buses are ROAD like trucks, so classify by capacities.PASSENGERS. Private cars are not vehicle entities. They are SIM_PERSON with lastMoveMode == 1 (0 walk, 1 car, 2 line). lastMoveMode stays "car" after the person parks and walks, so also require no MODEL_PERSON component, or pedestrians get counted (TF3 uses MOVE_PATH and no MODEL_PERSON).

Finding vehicles in an area TF2TF3

TF2: getEntities({pos, radius}, {type = "VEHICLE"}) is a cheap circle query for line vehicles, and getVehicles({carrier = …}) gives counts per carrier. TF3: the octree returns no moving transport vehicles, and transportVehicleSystem.getVehicles returned one. api.engine.util.vehicle.getVehiclesByCarrier(Carrier.ROAD) plus getPosition works. Neither game has a vehicle-passed event, so polling is the only way.

TF3: a vehicle's noise and pollution are the engine's sums; scale them with Modifiers TF3

A vehicle card's "Current Noise / Pollution" adds up each carriage's EMISSION_EMITTER noisePower / pollutionPower. For aircraft, the engine computes these from how hard the vehicle is working. On a modded helicopter, the .mdl's emissions.noise.score (20 → 2), maxThrust, weightEmpty and topSpeed moved the figure very little (278–725 against a truck's ~50). Lowering thrust while the weight stayed the same raised it. The fix is per-vehicle Modifiers: topSpeedScale, noiseScale, pollutionScale, comfortScale. The engine multiplies them in. Set them from an engine-thread game script: local m = api.type.TransportVehicle.Modifiers.new(tv.modifiers); m.noiseScale = 0.1; api.cmd.sendCommand(api.cmd.makeVehicleSetModifiersCmd(vehicle, m)). Find your vehicles with getVehiclesByCarrier, read TRANSPORT_VEHICLE, and match modelRep.getName(transportVehicleConfig.vehicles[1].part.modelId). The modifiers are saved on the vehicle, so each one needs a single command. Skip any vehicle whose scale already matches.

Poll intervals must scale with game speed TF2

A car crosses an 18 m circle in about 1.2 game seconds, which is about 0.3 real seconds at 4×. Polling on real seconds missed 19-45% of cars at fast-forward. Divide the interval by the measured game rate.

Events and line commands worth knowing TF2

  • handleEvent receives SimCargoSystem / OnToArriveAtDestination and SimPersonSystem / OnCompletedLineUsage.
  • api.cmd.make.createLine / updateLine / deleteLine work.
  • api.engine.system.stationSystem.forEach lists every station.
  • Writing Line.Stop.terminal and calling updateLine moves a terminal; the UI shows the raw value + 1.
  • The native road-ownership tool makes PLAYER_OWNED (75) components. BASE_EDGE_STREET tells streets from track.

TF3: another mod can replace api.cmd functions under you TF3

A subscribed mod's postRunFn assigned its own function over api.cmd.makeVehicleSetLineCmd, and that replacement was what our game script called. Its rule: a second "send to stop 0" of the same line within 5 s of the first becomes stop 1, then 2 (it means to spread a new line's vehicles). Our trucks sent home to stop 0 arrived with stopIndex 1 and drove a lap. The log showed it only as a timeline: command at :01, "stop 1" at :05. When a command does something other than what you asked, suspect a wrapper before suspecting the engine: print(api.cmd.makeVehicleSetLineCmd) shows a Lua function instead of a C function. Make-functions only build the command object; the send is api.cmd.sendCommand. So a decoy call such as makeVehicleSetLineCmd(v, -1, 1) (result discarded) resets such a wrapper's burst state at no cost, and then your real call goes through untouched. Keep the guard generic and never read the other mod's files.

TF3: whole-map octree queries spike TF3

One whole-map circle (83,559 groups) took about 51 ms, with 50-93 ms spikes. Cutting the map into 2048 m patches, one per look, brought it to 4.6 ms per look (worst 34 ms, 1.1% of the time). "Invalid entity" for something removed mid-scan is normal.

Money, time & game data

TF2: moving money with bookJournalEntry TF2Fatal

  • Build it with api.type.JournalEntry.new(). .time must be −1, or you get the fatal Assertion 'cmd.journalEntry.time == -1'.
  • Position must be vec3.new(x, y, z).
  • Category must be api.type.JournalEntryCategory.new(); a table gives "bad argument into 'void(JournalEntry::Category)'".
  • .amount must be a whole integer. A fractional amount fails silently (success = false). Negative amounts subtract.
  • The money shows under "Other" in Finances.

The balance from getEntity(player).balance is stale for a tick after the command (inferred), so track pending amounts yourself.

TF3: paying money from script TF3

Use api.type.JournalEntry.new() with amount, time = -1 and category.type = JournalEntry.Type.INCOME, then send api.cmd.makeJournalBookAssetCmd(api.engine.util.getPlayer(), e, Vec3f). It shows as company revenue.

getPlayerJournal can hard-crash a save TF2Fatal

game.interface.getPlayerJournal(playerId, timeMillis, short) is undocumented (learned from Advanced Statistics). Its _sum is cumulative profit, so two calls can be differenced. On a save carrying many mod-written journal entries, it hit Assertion '(int)entry.category.carrier < numCarrier' in legacy/interface.cpp:1726.

Game time is not the calendar TF2TF3

TF2: getGameTime().time tracks roughly real seconds of play. The calendar is getGameTime().date = {day, month, year}. TF3: os.clock is real time at any speed, the year is api.engine.util.getYear(), and the speed is the world's GAME_SPEED speedup (which may not read 2 at 2×).

math.randomseed seemed to do nothing TF2Inferred

Two fresh games rolled identical values even when seeded from os.time, os.clock and a heap address. The workaround derives variation from map data. Also guard math.random() == 0 before taking a log.

TF2 GUI

Don't mix gui.lua and raw api.gui trees TF2Fatal

Putting a native api.gui.comp.* object into a gui.lua-managed layout threw inside boxLayout_addItem, then crashed at game close with Assertion 'CComponent::NumInstances() == 0' failed. Windows built only from raw api.gui.comp.*/api.gui.layout.* close cleanly. A component can have only one parent. Raw Slider and ComboBox are safe.

Mouse listeners must always return a boolean TF2Fatal

A listener added with getGameUI():getMainRendererComponent():insertMouseListener(fn) that returns nil causes an unprotected native error (SIGABRT). Always pcall-wrap it and return a real boolean. A left click is type == 2, button == 0. The listener also sees clicks on menus and windows, so test api.gui.util.getMouseScreenPos() against the UI first. A click on the game menu once started a 398-segment build.

No keyboard, no modifier keys TF2

insertKeyListener doesn't exist, and MouseEvent has no shift/ctrl fields (eight candidates probed). Rotating with keys is only possible in the game's own placement tool.

TextView doesn't wrap; sizes aren't what you set TF2

  • TextView has no word wrap and clips. Wrap by hand (about 7.8 px per character).
  • The default style sheet pads every TextView 20 px wider than its width, plus 5 px between items.
  • ScrollArea scrolls vertically only. The gui.lua scrollArea wrapper renders blank; api.gui.comp.ScrollArea.new(content, id) works.
  • On a raw window, setMinimumSize/setMaximumSize change nothing; use setSize. setSize is in unscaled units, while setPosition is in physical pixels.
  • api.gui.util.Size.new rejects non-integers.

Text colour only through a style sheet TF2

The only text-colour route is a style sheet in res/config/style_sheet/*.lua plus setStyleClassList; fontSize works too. Buttons take backgroundColor/borderColor. New textures and style sheets need a full restart. For toolbar icons, cap both the ImageView and the Button, or the button stretches.

setZone: 0-1 colours, and the only overlay TF2

game.interface.setZone's drawColor takes 0-1 floats; 0-255 values clamp to solid white. There is no model tinting or ghost API, so flat zones are the only overlay. Hundreds of zones per redraw are expensive, so cap them and re-send only after the cursor moves.

Guard NaN before moving the camera TF2

A non-finite position passed to game.gui.setCamera can feed the engine bad terrain lookups. Check x, y and z are finite first.

TF3 React UI

TF3's UI is React-style, written in Teal (.tl). Nearly everything from TF2's GUI is different.

A toolbar button: the mod button area plugin TF3

A .res.lua of type "react-plugin ::MainModButtonAreaExtension" whose data.filePath points at a react.RegisterPluginRecipe. It shows at the top left, beside the layers button, in one row shared with other mods. ModEntryPointExtension drew nothing visible.

A plugin must return a layout TF3

Otherwise the UI stops with Recipe child must be a layout. That happens with a bare Button, and also when the plugin calls a separately registered recipe. Wrap the content in a BoxLayout and build the body inline.

Windows must be wrapper recipes TF3Fatal

Register with react.RegisterWrapperRecipe(name, builtin.Window, fn) and open through game_react_globals.getDefaultWindowApi().addSingletonWindow. A plain RegisterRecipe crashed TF3: Assertion id >= 0 && id < (int)m_data->nodes.size() failed while transforming WindowContainer. initialX/initialY (0-1) set the window's top-left corner.

How the game's own track tool builds a junction (recorded from builder.proposalApply) TF3

Recorded 2026-10-22 with a dev-only listener (the game script subscribes to builder.proposalApply; guiHandleEvent gets the name and its param). The param is a list of three things:

  • param[1] is the Proposal (userdata). Its .proposal is a StreetProposal with plain Lua tables addedNodes, addedSegments, removedSegments and removedNodes (each entry has entity and comp; segments also have type, 1 = track). .toAdd / .toRemove are the constructions.
  • param[2] is the ProposalData: errorState (critical, messages), costs, tpNetLinkProposal. param[3] is the result entities.
  • One request does the whole edit. Dragging a branch off a track removes the old pieces and nodes AND adds the new ones in the same proposal (e.g. 3 pieces and 2 nodes out, 1 node and 3 pieces in, plus 3 node configs). Removed entities are listed as -(id + 1): piece 156536 shows as -156537.
  • Points are one node where three pieces meet. The tool re-cuts the straight track at the branch, drops the in-between nodes, adds the junction node and its node configs (the lane links).
  • Tangents match exactly across every node, including their height part, so slopes flow through the points with no kink. A tangent's length is about its piece's chord length.
  • The script API's SimpleProposal.edgesToRemove threw on the same kind of swap, but api.engine.util.proposal.makeSegmentsRemoveProposal(pieces) returns a FULL Proposal that makeWorldBuildProposalCmd accepts. The game's own shape -- PROVEN 2026-10-22 (BPX TF3 Clean in place, Epod's hand-drawn junction that twice lost its track): build the removal with that helper, add the new nodes / segments / node configs to its StreetProposal (sp.addedNodes etc.: read the list out, extend it, write it back), and send ONCE with makeWorldBuildProposalCmd(full, ctx, false, true). With ignoreErrors = false the game checks it on arrival and makes all of it or refuses all of it.
  • Two catches. makeProposalData refuses a full Proposal in build 40420 ('SimpleProposal expected, got Proposal' -- the tealdef signature is out of date), so it can't be dry-run: treat that as 'unknown', never as a pass. And pass a Context like the game's builders (ctx.player = api.engine.util.getPlayer(), checkTerrainAlignment, cleanupStreetGraph, gatherBuildings = true): sent with nil, the rebuild was accepted but the ground beside the track was dug into a cutting.

Rebuilding a player's track by script: hand-drawn track can break the BUILD limits, and a take-up can't be dry-run TF3Data loss

Seen 2026-10-22, BPX TF3 Clean in place. It took a hand-drawn junction up and rebuilt it. The game refused the neat version AND the original ("Too Much Incline"), and the player's track was gone.

  • Two sets of limits. Every TF3 track template (infrastructure/track/*/, in base/content/infrastructure/track.zip) has hard limits (minCurveRadius = 40, maxSlope = 0.13, maxSlopeShape = 0.16) and stricter BUILD limits (minCurveRadiusBuild = 55, maxSlopeBuild = 0.085). That's the same for simple, standard and high-speed track. The player's hand-drawn junction had 42 m curves and slopes up to 4.4%: legal on the map, but a script rebuild is judged harder. Don't assume what's on the map can be rebuilt.
  • A take-up can't be dry-run. makeProposalData on a SimpleProposal with streetProposal.edgesToRemove plus new edges threw "Unknown exception", and the engine logged StreetShapeFactory.cpp:979 ... Assertion '!cc.empty()' failed (a stutter). A dry run of the new pieces while the old ones still stand says "Construction Not Possible" with 0 collisions. So there's no safe way to ask the game "would you accept this swap?" before doing it.
  • Pieces tested one by one pass, yet the whole fails. Each piece dry-run on its own, away from the layout, drew no incline or curvature objection. The refusal comes from how pieces meet, at points and at the joins to the untouched track. An off-site copy can't reproduce those joins.
  • So: never remove before a rebuild is certain, and make guards fail closed (an error or no answer means stop). The likely safe route is to change pieces the way the game's own track tool does, on the same nodes. TF2's Upgrade learned that by recording the game's builder (TF2 notes, Decision 321).

A window that remembers: the pin, its place, the title bar's own slot, and a copy in the save TF3

All seen in game on the PYT Minimap, 2026-10-22.

  • The pin. builtin.Window { pinnable = true, pinned = ..., onPinned = function(on) ... end }: a pinned window stays open when ESC closes the others. Whenever the window recipe redraws, the game applies its params again, so pinned must always be the CURRENT pin. Keep it as the wrapper recipe's own state (local pin = react.useState(saved); onPinned sets it). A value read once at opening un-pinned the window at the next redraw.
  • Its place. initialX / initialY (0..1 of the screen) open it there. To learn where the player dragged it, read the body's react.useSelfRef():get():getPosition(0, 0) in react.onStep, but only from about the 15th step on: before layout it reads ~(0, 0), and a frame offset measured then pushed every saved spot into the corner. The body sits ~0.041 of the screen below the window's top (the title bar).
  • The title bar's slot. WindowParam.header takes a recipe, drawn in the title bar at the left, before the title. That's where small buttons can live, e.g. a fold arrow that folds the window down to its map alone. Another mod sets title = "" and puts its own row there. The base style helper has no font size, so your own title text won't match the game's title font. WindowParam.compact ("the content fills the window, the title bar reduced to buttons") is also listed in the game's tealdef. Older platforms may lack a field, and an unknown field fails the whole UI, so test it once on a spare api.gui.react.params.builtin.Window.new() inside a pcall.
  • Per save. Module state is session-only: a reload of the save resets it. To keep size, place, pin and open/closed with each save, the window sends api.cmd.makeScriptingSendEventCmd("", "<id>", "<event>", payload) when they change (only when they differ from the last one sent). A game script stores them in state, and on load the GUI reads them back from api.engine.getComponent(gameScriptSystem.getEntityForGameScript(gs), GAME_SCRIPT).state. Try that from the ~30th GUI step on (the state lags the first frames), and never send before it has been read, or the defaults overwrite the save's copy. A game script that adds a new event must subscribe to it in update as well, for saves made before it existed.

The mouse wheel in a recipe: zoom in on a positive yrel, and take the spot from the last move TF3

react.onMouseEvent gets api.gui.mouse.Event.Type.Wheel with evt.yrel = +1 / -1 per notch. To zoom the same way as the game's camera, zoom IN on a positive yrel. Our first guess, flipped the other way, read as backwards. The wheel event's own x / y were not where the mouse was: the computed spot fell outside the map and the zoom was refused. Remember the spot from the last Moved event over your element and zoom there. Return true to consume the wheel, so the game's camera doesn't zoom as well. 2026-10-22, PYT Minimap.

Unattached recipes flood the log TF3

Building recipes that never get attached floods stdout.txt with Preserved props … a recipe was called but not attached to the tree: 1.1 million lines, 573 MB in about 10 minutes. Build only the rows actually shown, and build each element once, without replacing it afterwards.

Restyled elements keep their old fields TF3

The UI reuses elements and applies only the style fields you give, so styleSheet = nil does not clear an earlier background. Always set every field explicitly (for example a transparent {0,0,0,0} background).

Sizing, scrolling and tables TF3

  • Put the size style on the TextView or Button itself; a sized Component wrapper hid its content.
  • StyleSheet has no text alignment.
  • Buttons are wider than set unless padding and borderWidth are Vec4f(0,0,0,0).
  • ScrollArea content must be builtin.Component { layout = … } ("ScrollArea does not accept a Layout as content"), and it needs a size. Scrolling was unreliable, so pages are safer.
  • TableLayout lines columns up when written as the game does: builtin.TableLayout { rows = { builtin.Row { cells = { … } } } } placed straight in a BoxLayout (an earlier attempt drew nothing). Every cell must be a widget, never a layout: a cell that is a BoxLayout reloads the whole UI with "TableLayout cell must not be a Layout". Wrap such a cell as builtin.Component { layout = … }. Seen 2026-10-24.
  • Never put a TableLayout inside a ScrollArea that has a fixed size. The game crashed outright (no Lua error; an access violation in TransportFever3.exe, build 40425) the moment a 31-row table appeared in a 1000 × 620 ScrollArea. The same table in an unsized ScrollArea was fine, and a sized ScrollArea with plain BoxLayout rows is fine. For columns inside a sized scroll area, give each column's TextView a size = { w, h } and use plain rows. Seen and fixed 2026-10-25.
  • A TextInputField with its own "Add" button: never clear the typed text in onCancel. With acceptOnFocusLoss = false, clicking the button takes the focus from the field first, and the game reports that as a cancel, so the button then reads an empty string. Keep the text from onTyping in a react.useRef and leave onCancel empty. Seen 2026-10-25: the log showed every keystroke arriving and the button getting ''.

Live data into the UI, and other hooks TF3

  • Push data with api.gui.fireReactEvent("name", data) and listen with react.onEvent. useStepStateTimer polling didn't refresh in the mod area.
  • A "react-plugin ::EmptyConstructionEowExtensionPoint" adds a card to a construction's window.
  • A react-replacement-config resource wraps game UI modules at load. Always chain to the previous function, because other mods may wrap it too.
  • react.fireEvent(nil, "constructionMenuSelectTabForConstruction", { resName = "<modId>::/x.con" }) opens the build menu on an entry. Avoid setMenuFilter, which sticks.
  • Camera: api.gui.camera.getCameraData() and api.gui.camera.focusPosition. Rename things with api.cmd.makeEntitySetNameCmd.
  • UI scripts reload with "Reload UI" on the UI error screen. Loading a save re-reads scripts and .con files.

Models, textures & images

TF3 reads TGAs bottom-up and ignores the origin flag TF3

The first stored row is the bottom. Bottom-up files (flag 0x08, Pillow's default) are right as they are; top-down files (0x28) must be flipped. UI images and build-menu icons follow the same rule. Some textures on meshes also show flipped vertically, so store those upside down.

TF2 TGA quirks TF2

In one pipeline, grayscale (type 3) TGAs needed a horizontal mirror and colour (type 2) TGAs needed their rows reversed, with the same 0x28 descriptor. Measure a PNG's real alpha with a histogram before converting, because "whiteness to alpha" destroyed it. image_00.tga is the in-game mod icon (768×768).

Material texture paths: relative or ::/, never a mod path TF3Fatal

A texture fileName pointing into another model's folder by mod path (<modId>::/…) failed the Validation Report ("Model verification: Texture …") and crashed a tester's game. Use relative paths or game paths (::/…). To share textures, share whole .mtl files: an .mdl can point at another folder's material.

DDS headers must match the Model Editor's TF3

Pillow's DDS headers fail model verification. Match the Model Editor's: mips down to 1×1, pitchOrLinearSize = the top level's byte size, depth 1, RGB bit count 0 for DXT, and the 'A2XY' marker for BC5. Every texture side must be a power of two, and paths should be lower case (Linux is case-sensitive, and the validator warns).

TF3 Model Editor gotchas TF3

  • It only accepts *_lod0.fbx.
  • Textures are matched by name (<material>_albedo / _metal_gloss_ao / _normal). With "overwrite materials" off, it reuses old placeholders.
  • A re-save resets the collider to BOX. Set NONE for things that stand over roads or inside other models.
  • Glow materials import as PHYSICAL; set EMISSIVE. EMISSIVE quads are opaque, so a full-panel glow quad blacks out the panel.
  • Blender light strengths arrive in the thousands; TF3 uses about 1-3. Base street lights: 60-70° cones, strength 0.5-1, radius 20-25.
  • It saves into the mod chosen in the top dropdown, and copies sources into content.
  • It reopens on the mod used last (pathOptions.lastOpenedMod in its settings), so imports kept landing in the wrong mod. Check the top dropdown before every import, and close the editor normally so the choice is saved.
  • Give glow its own mesh: shaped like the letters (a few mm proud of the panel) or only over the lamp faces.

You can write a TF3 model without the Model Editor TF3

An imported model is just <name>.mdl plus, per part, msh\<part>_lod<n>.msh (a short text header) and a binary .msh.blob.

  • The .blob: de-duplicated float32 value streams (position xyz, uv0, normal xyz, tangent xyz + bitangent sign), then one uint32 index stream per attribute, three per triangle.
  • The header: each attribute's count and byte offset, keys in alphabetical order.
  • Mesh space is Y-up: Blender (x, y, z) becomes (x, −z, y). Each .mdl node carries the transf the editor writes: { 1,0,0,0, 0,-1.62921e-07,-1,0, 0,1,-1.62921e-07,0, 0,0,0,1 }.

A writer built from this matched the editor's output (one test mesh decoded identical; elsewhere only the quad diagonal and tangent averaging differed), and its models work in game. It's also the way to update a model in place, which the editor won't do.

The Model Editor won't overwrite an existing model TF3

Importing under a name that already exists doesn't replace the model. Either save the new version under a new name and repoint the .con, or rewrite the model's files directly (above). Delete old model folders only with TF3 and the editor closed.

Worst LOD over 256 KB: join meshes, add a far LOD TF3

The editor warns Worst LOD (LOD 0) has mesh data of 346.9 KB, exceeding the limit of 256.0 KB. The limit applies to the lowest-detail LOD, and every mesh adds overhead: a gantry imported as 71 small meshes hit it at 3,516 triangles. Joining meshes by material (71 to about a dozen) and adding a silhouette <name>_lod1.fbx (about 60 KB) cleared it, with fewer draw calls as a bonus.

Blender to Model Editor: FBX settings that import cleanly TF3

Export with export_scene.fbx(axis_forward="-Z", axis_up="Y", global_scale=1, apply_scale_options="FBX_SCALE_ALL", mesh_smooth_type="FACE", use_tspace=True, add_leaf_bones=False, bake_anim=False), and tick "Blender coordinates" in the editor. Before export, apply object transforms, triangulate n-gons, and join objects that share a material into one mesh.

Road-edge models run along X; boundingInfo ignores node transforms TF3Inferred

An .edge.lua repeats its repModel along the road edge (length, stretch, verticalAlignment = "Shear", snapMode = "Side"). The base models run along X: barrier_a.mdl reuses a fence mesh that runs along Y, with a node transf turning it 90° and scaling it 1.5. A .mdl's boundingInfo is computed from the raw mesh without node transforms, so reading it can point you the wrong way. Untested: a node transf of { -1,0,0,0, 0,-1,0,0, 0,0,1,0, 0,0,0,1 } should turn a model 180° without a re-import.

TF3 build-menu art sizes TF3

Icon <name>@2x.tga 240×150 plus a 1× 120×75. Preview <name>_preview@2x.tga 720×405 plus 360×202. A missing preview leaves flat blue. Base glyphs in gui.zip are white and tinted by the UI; reference them without "@2x".

Blender bake gotchas TF2

Put the backdrop behind the content, and un-hide the light for the render. World strength can wash out materials. An RGBA TGA can render blank under the DITHERED render method, so strip the alpha. A decal inside another solid bakes blank.

Performance

Pace a live preview by what it costs TF3Fatal

A ghost that rebuilds a whole proposal (and its makeProposalData check) on a fixed throttle is fine for a small layout and a freeze for a big one: a 224-piece station with hundreds of modules took longer than the 0.35 s gap, so the UI thread never caught up and TF3 logged "Thread did not respond to ping. Possible hang detected!". Time each build with os.clock() and wait a multiple of that before the next. Rebuild a costly one only when the mouse rests, and switch the ghost off (outline only) past a ceiling. Work out an outline's geometry once per blueprint and rotation, not on every mouse move.

Share materials across a set TF3

One pack's textures dropped from 494 MB to 145 MB by giving each design set ONE shared material set (the same .mtl and .dds files) instead of a copy per model. Keep prop textures at 512 px or less, and flat colours at 64 px.

Dry runs on the GUI thread are expensive TF2

makeProposalData grows steeply with layout size and map density: about 8 s for 79 segments on a full map, and it froze the game on 398. Never re-validate what the game's own tool just validated.

Drive scans from events, not timers TF2

Scanning every construction within 20 km every 20 ticks stuttered a map of 734k people. Scanning in a short burst after the game's builder.apply GUI event, plus a slow safety scan, fixed it.

Spread work over frames, and build a meter TF2TF3

Staggering per-object due times, slicing scans and saves, and re-serialising only what changed took the worst frame on a 60-town map from 394 ms to 77 ms. Build an in-mod meter (share of real time, worst frame, and which section caused it), and skip the first 20 s after a load. For stress tests, experimentalMapSizes = true in settings.lua gives a 24×24 km map with 60 towns.

Give a per-vehicle pass a budget TF3

A game script that renamed and re-tuned every new vehicle in one pass (makeEntitySetNameCmd + makeVehicleSetModifiersCmd, plus a print each) froze the game for about 20 seconds when a player bought 3,367 drones at once: about 6,700 commands and 6,700 log lines in a single tick, logged as "Thread did not respond to ping. Possible hang detected!". Cap the commands per pass (50 worked), stop there, and run the pass again a couple of seconds later while work is waiting. Log one summary line per pass, not one per vehicle. A player buying in bulk is normal, not an edge case.

Workshop, mod.io & release

Watch your comments -- and start the fix as soon as one arrives TF3

Most players who hit a bug comment once, then either wait a day or leave a thumbs-down. So we don't wait for someone to look. A small script reads every mod's page through the public mod.io API (GET /games/{game}/mods/{mod}/comments, read-only, the read key) every 10 minutes from a Windows scheduled task. Each new comment becomes a Windows pop-up, a line in a log file and an "inbox" page in the browser. The AI assistant keeps a live watch on that log, so a bug report reaches it within minutes.

What happens next: the game's own log (stdout.txt) is read first, the bug is reproduced, fixed and shipped. A short "thanks, we're on it" goes back about five minutes after the report, with a question if something is unclear. When the fix is live, a second reply says so, and the player is thanked by name in the release note. Seen: a Distance Signs report ("the sign keeps saying 'reading the roads'") was fixed and live about an hour later. One lesson: if the fix will be out within the hour, skip the "we're on it" and answer once with "fixed". Our early reply looked odd next to an update that had already arrived. And when you release a new mod, add it to the watch list, or its first comments go unseen. 2026-10-18 / 2026-10-20, the PYT family.

The folder suffix is the mod id: never rename it TF2

The _1 folder suffix is the mod id stored in every save, and the major version. The game shows <folder number>.<minorVersion>. Put a "version 2" in the title only. Set severityRemove = "WARNING" if removing the mod loses placed objects.

Move non-mod files out before uploading TF2TF3

The uploader sends the whole folder: one mod went from 33 MB to 15 MB once art_source, documents, tools and .git were moved out. Move them outside staging_area (inside it, the game scans them as a mod), publish, then move them back with a move-only script. Keep workshop_fileid.txt, which is how the uploader updates the same item.

TF3: the Validation Report refuses working files — and a hotfix ships from the release branch, not master TF3

Anything left in the mod folder that isn't game content fails TF3's Validation Report for PC and Console, one ERROR line per file: “File extension is not allowed: .blend” (also .blend1, .fbx, .pyc, .tsv…), so the upload can't go. Clear the folder down to what the game loads (_content.json, _metadata, content, mod.json, strings.json) before validating — seen 2026-10-29 when we skipped that step.

When a live version needs an urgent fix but master already holds unreleased work (and the staging folder holds someone's uncommitted art), don't switch branches inside it. With TF3 closed: fix on the release branch in a separate git worktree; git archive just the game files from it; move the whole master folder out of staging_area; unpack the archive under the same folder name; validate and upload; then, TF3 closed again, put master back. The upload carries the folder's _metadata, so mod.io updates the same mod. Afterwards tag the release, and push the page text from master's copy with the new note added. PYT - Pay Your Tolls 3.0.1, 2026-10-29.

Steam page details TF2

  • The uploader replaces the page description with mod.lua's short one, so re-paste the full BBCode after each upload.
  • Change notes are plain text; Steam ignores Markdown.
  • The preview is capped at 1 MB. A JPEG saved by PIL with jfif_unit=0 was refused ("JPEG couldn't be read"); dpi=(96, 96) fixed it.
  • Save a creator map with only the mods it needs, or subscribers inherit the rest.

Where installed mods live (TF2) TF2

Steam Workshop: steamapps/workshop/content/1066780/<id>/, with installed ids in appworkshop_1066780.acf. mod.io: C:\Users\Public\mod.io\6791\mods\<id>\ (6791 = TF2), with names in metadata\state.json. Resource reps hold only active mods, so "installed" and "enabled" can be told apart. In the loader, addModifier("loadConstruction", fn) (and loadModel, loadStreet…) fires lazily, and getCurrentModId() names the owning mod.

TF3: publishing on mod.io TF3

  • Publish from TF3's mod manager: Validate (PC + Console), then Upload. TF3 builds the console version, so this can't be skipped. Run the Validation Report before any zip or upload.
  • A missing summary shows a generic "check your network" box (error 13009). The log says "The summary field is required" (250 characters max).
  • Unaccepted mod.io terms give 403 (error_ref 11074).
  • Keep _metadata/mod.io_fileid.txt so the button reads Update. An update replaces the page's tags with modinfo.json's.
  • The mod picture is _metadata/0.png at 1920×1080.

TF3: _content.json decides which files players get TF3Fatal

A mod installed from mod.io is loaded through the file list in its root _content.json (the log says "Reading content cache from json"). A file that is in the mod but not in that list doesn't exist for players. Your staging copy loads the real folder, so everything works on your PC. Pay Your Tolls shipped two versions with a list from an older release: 47 newer files were invisible, so a whole window add-on and a set of model materials were missing for everyone, and a hidden button came back and crashed games. Regenerate the list from the content folder before every upload, and compare it first whenever a player reports something missing that works for you.

TF3: reading a userdata file that isn't there is a Lua error, even inside pcall TF3

app.loadUserdata(folder, name) on a file that doesn't exist yet makes the game log Failed to load userdata — cannot open .../<folder>/<name>.lua as a Lua error, and players see red messages, even though your pcall catches it and your code carries on. You'll never see it on your own PC, where those files exist: a new player has none. Blueprint Exchange 1.0 logged 38 of them on a fresh install, most while its tab opened, and a player reported red messages and a crash there. Listing a folder is silent, even a missing one (it creates the folder), so look before you read:

local function loadUD(folder, name)
    local ok, all = pcall(function() return app.getAllUserdata(folder) end)
    if not ok or all == nil then return nil end
    for _, f in ipairs(all) do
        if f == name then return app.loadUserdata(folder, name) end
    end
    return nil
end

Test as a brand-new player before each release: move your mod’s userdata folders aside and play the mod.io download, not your staging copy.

TF3: don't remove mod folders while the game runs TF3Fatal

Deleting or renaming a staging mod folder while TF3 or the Model Editor is running crashed: Assertion 'inAddMods && "cannot remove mods in game"'. Close both first.

TF3: declaring that your mod needs another mod (mod.json dependencies) TF3

The dependencies list in mod.json takes the shape of Mod.ModDependency in the game's API files. This worked first time: the mod window's Dependencies box listed the other mod by name, with its own Activate button, and the log stayed clean. Use the other mod's id from its own mod.json. The full TF3 restart is needed, since mod.json is read at start-up.

"dependencies": [ {
    "mod": { "modId": "other_mod_id_1", "revisionMin": 1, "revisionMax": 1000 },
    "optional": false,
    "loadBefore": false,
    "modInfo": { "displayName": "Other Mod", "url": "https://mod.io/g/transportfever3/m/other-mod" }
} ]

The in-game mod browser also reads mod.io's own dependencies (hasDependencies, and a dependency list in the mod details), so set them on the mod.io page too. Neither forces a subscribe; say what's needed in the first line of the description as well.

How to work

These habits did more than any single trick.

Never invent an API call; prove it first TF2TF3

Read the base game's own scripts and well-used mods (Advanced Statistics, AI Builder, Copy It, Move It, Automatic Signal Spacing) to learn the real calls and which thread they run on. TF3 ships its API as Teal definitions in api/tealdef. Build a throwaway in-game probe and record the result, positive or negative. Save before any probe that writes game state, because native assertions can't be caught.

Keep a decision log, and say what was seen TF2TF3

Every change gets a numbered entry: what, why, and "not yet seen in game" until it is. When something breaks three times in one function, that log shows the pattern. It's also where this page came from.

Render it from the player's side before import TF2TF3

Render each model from where the player will see it: the drivers' side for anything facing traffic, at night for anything that glows. One pass caught 12 gantries built facing away from their drivers before any import.

Read the log before guessing TF2TF3

Give your mod its own log with a clear line for every step that matters, and a diagnostic line when something comes back empty. More than once the fix was obvious from one log line, for example asset-group ids coming back as 1, 2, 3.

Same file in two mods: a tab merges, a construction doesn't TF3

Several mods can each ship the same menu tab (menu_categories/*.res.lua, same category id) and the game shows one tab. A construction is different. A .con at the same path in two mods becomes two separate entries (modA::/x.con and modB::/x.con), so shared buttons show twice. To share buttons between mods without a common dependency, let each mod register itself in its runFn (all of them run before any construction loads). Pick one owner deterministically, for example the first mod id alphabetically. Then hide the other copies from a loadConstruction modifier. Have each mod decide only for its own copy, so a failure shows a duplicate rather than nothing.

Sharing state between mods without "Global variable in mod" errors (and without rawset) TF3

Mods' scripts run in one Lua state, so a plain global (MY_FLAG = true) works as a shared flag between mods. But TF3's base/init.lua guards _G: every new global a mod creates logs ERROR Global variable in mod with the mod id and key. One flag set in a runFn showed up 199 times a session, once per loading thread. Don't reach for rawset: TF3's mod environment has no rawget or rawset, so calling them is a fatal "attempt to call global 'rawget' (a nil value)" in your runScript. Instead, hang ONE shared table on an object that already exists, such as the standard string library: local t = string.my_shared; if t == nil then t = {}; string.my_shared = t end. That's a field, not a new global, so nothing is logged, and every mod in the same Lua state reads and writes the same table.

A hover menu in the top bar, and mods joining each other's buttons TF3

A builtin.Button in the mod button area (top left, main_mod_button_area) can react to the mouse: put onMouseOver = function(hover) ... end inside its meta, as the base game's line manager does. It gets true when the mouse arrives and false when it leaves. Moving along a row of buttons fires OUT of one and IN to the next in the same frame, so a counter (react.useRef, +1 on IN, -1 on OUT, close at 0) keeps a fold-out row open with no timer. Mods' GUI scripts share the same string table too (seen 2026-10-12): one mod can put { label, icon, open = function } on string.my_family at load, and another mod's toolbar can draw a button for it and call open, with no global and no dependency between the two.

Live text on a model: a town's name and population on a sign TF3

A model can carry text the game draws for you. In the .mdl, put metadata = { labelList = { labels = { ... } } }. Each label has type = "CUSTOM", a size = { w, h } in metres, a color, an optional fitting = "SCALE" (long names shrink to fit) and renderMode = "STD" or "EMISSIVE". Its transf puts the label's bottom-left corner on the surface, with x along the text, y up and z out of the face. Keep it about 1 cm proud: 2 mm can flicker, and 4 cm can vanish. The construction's updateFn fills the labels per model with sub.labelText = { [0] = { "BARNES", "POP. 672" } }: index 0 is the subconstruction's first model, and the strings go to its labels in order. For the data, a GUI-side script finds the nearest town with api.engine.system.streetConnectorSystem.getConstructionClosestTown(entity) and its name with api.engine.util.getEntityName(town). Population is the town's residential capacity, api.engine.util.town.getTownCapacityUsage(town)[api.type.enum.LandUseType.RESIDENTIAL + 1].capacity, the same number the town statistics show. The script then rebuilds the construction with those strings in its params (createProposalReplaceConstruction), only when they change. Two warnings: a Model Editor import rebuilds the .mdl and drops the labelList, so add it with a script you can re-run. If metadata already holds a lightSourceList (lamps), add to it rather than replacing the block, or the lights go dark. Seen working 2026-10-12: "WELCOME TO BARNES / POP. 672".

The Lua trap: x and nil or y is always y TF2TF3

Lua's a and b or c only works as a one-line if when b can't be nil or false. st.counting = (on == 1) and nil or 0 reads like "nil when on, else 0", but nil counts as false, so the or always wins and it is always 0. In PYT 2.6's test that meant a decoration switch that could be turned Off and never back On. When one branch is nil or false, write a plain if ... then ... else ... end.

"The native floating point can't fit inside a lua number" = you passed NaN TF3

This crash (a hard Lua error box, the game closes) comes from handing the game a number that is NaN or infinite, for example into api.type.Vec3f.new for a road's position or tangent. The message never says NaN. The usual cause is a divide by a length that can be 0: two points on the same spot, or a zero-length tangent you normalise. Guard every such division (if len > 0.01), and pass every number you build geometry from through a check before it reaches the game: if v ~= v or v == math.huge or v == -math.huge then v = 0 end (v ~= v is only true for NaN). Seen 2026-10-12 in Blueprint Exchange's paste.

Match file names by path, never by a bare name TF2TF3

A construction's fileName carries its mod prefix, for example epod_pyt_harbour_city_1::/pyt_pack/pyt_pack_sign.con. Searching it for a bare word like "pyt_harbour_city" to find one particular construction matches every file in that mod. A "nearest toll" search took each roadside sign for a toll 0 m away (itself), so no sign ever turned. Search for the folder and file together ("/pyt_harbour_city", "pyt_plaza/pyt_harbour_city"). Also ignore anything within a few metres of the thing doing the search.

Hearing the player's builds: subscribe, and remember it yourself TF3

A game script's guiHandleEvent only receives builder.proposalApply after the game side has called state:subscribeToEvent("builder.proposalApply"). Do it in update as well as on the start-up events, because a mod added to an existing save doesn't see those. Don't guard it with state:hasEventSubscriptions(): that can already be true, so the call is skipped and the instant reaction never comes. Keep your own "subscribed" flag in the script's state instead. The entities just built are in param[3].

Place roadside signs from the road's measured edges, not by turning TF3

A one-armed sign took many rounds of "turn it 180 degrees" before it sat right. Three things fixed it. First, read the model itself: a .msh.blob holds float32 positions at offset × 4, and our sign's origin turned out to be under the board, with the pole 2.55 m to one side. Second, if your script turns the whole construction to face its traffic, the road can be on either side of the sign's local frame, so any fixed turn is right on one road and wrong on the next. Third, one rule from the road's real width (the base edge's lane widths, pavements included) puts every sign a set distance outside its own edge, which balances both sides.

A window can't tell when the mouse leaves it: onMouseEvent only fires while the mouse is over it TF3

We wanted the Minimap to hide its buttons when the mouse is away. A react.onMouseEvent on the window's body does get its children's events (they bubble up; evt.handled says a child already took it), but only while the mouse is over the window. Every event we logged was inside it, and leaving it sent nothing: the Exited events all came from moving between child elements, at a position still inside. What works: treat "a mouse event in the last ~1.5 s" as "over", counted in react.onStep (about 90 steps). 2026-10-22, PYT Minimap.

Steering lanes through a junction: write the node's laneConnections yourself TF3Inferred

Read in a third-party road mod (Roadform's turbo roundabout), not yet tried by us. The engine picks which lane feeds which lane at a junction, but a script can overrule it. Read the node's BASE_NODE_CONFIG and clone() it. Fill comp.laneConnections with api.type.LaneConnection.new() records (segment0/lane0 = the edge and lane coming in, segment1/lane1 = going out, withRoad = true, withTram = false). Send it as streetProposal.nodeConfigsToAdd (a BaseNodeLaneConnectionAndEntity) with the node also in nodeConfigsToRemove. The lane index is the edge's laneConfigs index (0-based) counted from the end the node is at: from node0 it is the index, from node1 it is #laneConfigs - 1 - index. Only the CAR+BUS lanes are counted when choosing a lane by its place. The mod keeps the old config to build a restore proposal, sends with context.cleanupStreetGraph = false so the nodes survive, and reads the config back after the callback to check the links really landed.

A script can post a game notification; a dry run before every proposal; tagged construction road ends TF3Inferred

Also read in Roadform, not yet tried by us. Notifications: api.cmd.sendCommand(api.cmd.makeScriptingSendEventCmd("", "Notifications", "add", { type = "<mod>::/game_mechanics/x/my_note.script", params = {...}, autoDismissDuration = 60000 })) pops a message in the game's notification list. It needs a .res.lua ({ type = "notification", data = { guiType = "Problem", label = ..., initiallyIgnoredType = false } }) and a .script.lua whose useDataState(params) returns { title, description, icon }. Dry run: before makeWorldBuildProposalCmd, pcall(api.engine.util.proposal.makeProposalData, proposal, context) and don't send when pd.errorState.critical. Native lists: when pairs() fails on an engine collection, pairs_native(t) walks it. Construction roads: each edgeLists end can carry a third field, a tag string ({ pos, tangent, "n12" }). Ends with the same tag become one node even when they lie a little apart, and an untagged end never merges with a tagged one. With every end in freeNodes the roads stay as ordinary streets. To stop the game's clean-up merging short pieces into one curve, the mod uses a hidden copy of a street template (empty menuCategory, simBuildable = false) for the middle piece.

The sandbox mod is one flag; an industry's road comes from its construction, and the engine never links it to the network TF3

Wanting to learn how the game places an industry "with a road attached", we read the sandbox mod that ships with TF3 (mods/release/urbangames_sandbox). It is eight lines: preRunFn sets baseConfig.sandboxMode = true, and everything else is the engine. The road comes from the industry itself: industries/industryutil.lua adds the entrance stub and a short country-road "street cross" to the construction (constructionutil.addEdges(data.curves.street, ..., "STREET", ind_entrance_*, snap node {0})). The engine offers a complete proposal through api.engine.util.proposal.makeIndustrySpawnProposal(typeTag, town), which the game's own prospection sends as it is. We dumped one for a coal mine: 16 nodes and 14 segments added, none removed. It holds the construction's pieces plus two straight road pieces across the entrance (tangent = chord), and it does not split or join any road already on the map. The join to the network is left to town growth, to the player, or, in sandbox, to the construction tool's snap, which is C++. So a mod that wants a pasted road to T into a map road still has to cut that road itself (remove the piece, add two halves and a node, remap the lane tables at its far ends). One format detail worth copying: the engine adds a lane table for every new node, an empty one for each dead end. Also found while looking: api.engine.util.proposal.checkProposalConnectsNodes(startNode, startEdge, targetNode, streetProposal, isApply) reports whether a street proposal joins two nodes, a ready-made connectivity check.

Moving an industry: rebuild it in place, build its roads, then refresh it once more TF3

An industry moved with the in-place rebuild (remove + add + old2new, the same entity, stock and name kept) comes back with a fresh entrance junction, but the road you then build onto that junction stays a dead end: a no-entry sign at the junction, and no route through it. The engine laid the junction's lane table when it rebuilt the industry, before your road existed, and adding a segment to a node does not relay it. Two things do not help. Rebuilding your own piece with api.engine.util.proposal.replaceSegment succeeds and changes nothing. Rebuilding the industry's own entrance piece the same way hangs the game: a construction's edges are frozen, and the call never returns, with no error in the log. And rebuilding a piece the same paste just built at that junction is fatal: TransportNetworkSystem::EntityToBeRemoved asserts AreAllNodesEmpty, because the new piece's network nodes are already in use. replaceSegment is for a piece the map already had, with your new piece beside it. What works is what a road station needs after its road arrives: api.engine.util.proposal.refreshConstruction(entity), sent with makeWorldBuildProposalCmd after the roads are built. The engine rebuilds the industry in place with the same params, hooks its entrance onto the roads that now touch it and lays the junction itself. Seen 2026-10-13: a logging camp moved with its access road, a truck line still attached, and the route ran down the new road, through the entrance and round the yard. A copied industry never needs this, because the copy and its roads go into one proposal and the engine lays the junction as it builds.

A vehicle's cargo, capacity, load and age: lists are 1-based, age is a percent TF3

Every per-cargo list the engine hands a GUI script is a plain Lua list: index i is cargo type i - 1. That holds for a line stop's stopConfig.load, for api.engine.util.vehicle.getVehicleCapacities(vehicle) and for the TRANSPORT_VEHICLE component's config.capacities. Read one as a map keyed by cargo id and every id comes out one too high, so nothing a vehicle carries ever matches what its line loads. What a vehicle holds right now is api.engine.system.simEntityAtVehicleSystem.getVehicleSimEntitiesCountForCargoType(vehicle, cargoId), summed over the cargo types it has capacity for; that plus the capacities gives a load percentage per vehicle, and summed over a line's vehicles (transportVehicleSystem.getLine2VehicleMap(), one call for the whole map) a load per line. The line-wide figure is api.engine.util.line.getLineCapacityUsages(line, false), under util.line like util.vehicle and util.proposal, though its tealdef record reads as if it sat on util itself; called there it is nil. Keep the per-vehicle sums as the fallback. A train is transportVehicleConfig.vehicles, several parts: read every part's model for cargo, or the locomotive alone 'carries nothing'. vehicle_util.getAge(vehicle).agePercent is a percentage of lifespan (24 means 24 %), not a fraction; vehicle_util.getVehicleTypeFromEntity(vehicle) is the game's own Bus / Truck / Tram / Train / Ship / Airplane / Helicopter, the split its line list never shows. The game's own Replace is api.cmd.makeVehicleReplaceCmd(vehicle, config) per vehicle, which vehicle_react_util.HandleVehicleChanges sends for you with the price handled; a config is vehicle_util.makePart(modelId, true, nil) in vehicles, vehicleGroups = { 1 }, muFileNames = {}. Seen 2026-10-13 in PYT - Advanced Management's Fleet page, and the same reads in two other fleet mods on mod.io

A construction that leaves ordinary track behind: free every node, add the signals afterwards TF3

A construction's edge list can mark its nodes free: freeNodes = { 0, 1, 2, ... }, every node of the list, with snapNodes for the open ends. The engine then builds the edges as the player's own track, not the construction's: it can be extended, upgraded and bulldozed piece by piece, and it stays in the save when the mod is removed. That is how Advanced Track Patterns (mod.io, 13,500 subscribers in its first five days) lays crossovers, ladders and flyovers from a parametric construction. Two things follow from the track being free. The engine joins and re-cuts free edges as it likes: in its author's logs it dropped nodes 2.5 m beside another track and kept those 5 m beside a parallel one, so a layout should hand over only nodes the engine will leave alone (that mod keeps 6.5 m from a non-parallel track), and anything addressed by edge number is unsafe: an edgeObjects signal on edge 7 ends up on two edges ('Construction Not Possible'), on the wrong one, or past the last one (a crash). So the signals are not in the construction at all. A game script hears builder.proposalApply (the event the Pay Your Tolls notes already use) on the GUI side, notes the layout's signal positions, waits until track is found at the first of them, then does what the Auto Signals mod does: for each signal, find the built edge nearest the position, send a proposal that removes it and adds a copy carrying the signal in its objects with an edgeObjectsToAdd entry (param = the position along the edge, oneWay, left, model, playerEntity). Known limit: a signal added that way carries no tool settings, so the game's signal tool calls it 'Not Compatible' when another signal is held over it; object.params is accepted and ignored. Double slip switches cannot be asked for by a construction either: after the build, api.engine.util.proposal.createDoubleSlipSwitchProposal(node, true) on each four-track node does what the node's own window does (the same test as view_manager_util.baseNodeCanToggleDoubleSlipSwitch: four track edges, two pairs crossing at up to 22 degrees), one at a time because each one replaces the track around it. Read from that mod's code on 2026-10-13; not yet tried in a PYT mod

A line stop's max-load share: of all-configuration capacity, only where loading is on; the vehicle events a game script hears TF3

A line stop's stopConfig.maxLoad[cargo + 1] is the largest share of capacity a vehicle may still hold for that cargo when it leaves, so it is the share to keep aboard for later stops. Three things about it, all from the Cargo Distribution mod's runtime (2,600 subscribers, 92 to 3), which sets it per arrival. The share is of the vehicle's capacity across all its configurations (TRANSPORT_VEHICLE.config.allCaps, a 1-based list by cargo id), not of the compartments currently set (config.capacities) and not of what is aboard: a half-full train told to keep 74 % keeps everything. It acts on a cargo only where that cargo's loading is on at the stop (stopConfig.load[cargo + 1] = true); with loading off the stop unloads everything, so a keep-share needs loading on, and the cap then also stops the vehicle picking any up beyond its share. Cargo the station cannot accept stays aboard; nothing is destroyed. Writing a stop: api.type.Line.new(lineComponent), replace stops[i].stopConfig with a fresh api.type.Line.StopConfig.new() carrying full load and maxLoad lists and the three flags, set customFilters = true, send makeLineUpdateCmd. A game script can hear vehicles: subscribe to OnArriveAtStop (id TransportVehicleSystem, param vehicleEntity, lineEntity, stopIndex), OnCargoLoaded and OnCargoUnloaded (a map of cargo entity to target). Those events run inside a native transaction and update runs in parallel, so that mod sends its commands only from postUpdateScript (a fourth game-script entry beside update / handleEvent / guiUpdate) and never sets state from update; it documents an arrival-time crash and a callback error from doing otherwise. Read 2026-10-14; the share rules used in PYT Advanced Management's Distribute, not yet seen in game there

A mod's own style sheet: a .css.lua beside the scripts, classes comma-separated, a Component is R::Component!x, a scroll area wants a component TF3

A mod can style its window the way the base game styles its own: a name.css.lua anywhere under content/, listed in _content.json, whose data() builds a table with stylesheetutil.makeAdder and returns it. Selectors are the game's: Window#id (the id you give builtin.Window), then a component type with a class after an exclamation mark, Component!cr-card, ScrollArea!cr-scroll, TextView!cr-title, ImageView!cr-icon, Button!cr-town; Window::Content for the window's inner padding. Properties seen working: size, maxSize, padding, margin, innerSpacing, gravity, backgroundColor, borderWidth, borderColor, textAutoWrap, backgroundColor1 on a button. Three things that cost a reload each. Several classes on one element are separated by COMMAS, not spaces (react.reference: styleClasses = table.concat(..., ",")); class = "a b" matches nothing. A type selector works for the native widgets only (ImageView!x, TextView!x, Button!x, ScrollArea); a builtin.Component is a recipe and the game's own sheets address it as R::Component!x or a bare !x, so Component!x never matches; and an inline styleSheet on a Component is ignored for size and background (it works on a TextView), so a layout component gets its box from the sheet. Seen drawing with both Window#id R::Component!x and Window#id !x in the sheet, 2026-10-14. And a ScrollArea's content must be a Component (with layout = ...), never a layout itself: 'ScrollArea does not accept a Layout as content' reloads the whole UI. For a one-off look, meta = { styleSheet = styleutil.makeStyle { size = ..., backgroundColor = ... } } on any element needs no class at all; a coloured bar is two sized text views side by side. Seen 2026-10-14 in PYT - Control Room

Flicker in first-person and driver view: delete the hidden face, keep layers 20 mm apart, labels 30 mm, no material depth tricks TF3

A printed sign face, a window on a wall, a glow panel on a frame, a town-name label on a board: two surfaces facing the same way a few millimetres apart look fine from the map camera but fight in driver and drone view. Players see the texture 'come and go'; a video shows straight-edged chunks of the surface behind winning, and label text losing letters frame by frame. What failed, each seen in game: faces 0.5–2 mm, 4 mm and 4.5 mm proud; labels 10 mm proud (vanilla station names sit 7–10 mm); and every material depth trick — a negative polygon_offset pulled the board through its own label (and without forceDepthWrite = true a PHYSICAL material given an offset is not drawn at all), a positive one on the surface behind changed nothing. What fixed it (SEEN 2026-10-14, a 1900 toll plaza in driver view): (1) a surface fully covered by a layer in front is never seen, so delete it — in a .msh.blob set the triangle's three position indices equal, the GPU drops zero-area triangles and the layout and UVs stay; (2) every layer that sits on another surface at least 20 mm off it, moving the front face's own vertices along its normal (a thin board box just gets thicker; stacked layers, pane on frame on wall, each 20 mm, so a pane ends 4 cm out, which reads as a deep frame); (3) labels 30 mm in front of their board, moving the label's transf along its z. Layers of the same material inside one mesh need the modeller. 2026-10-14, PYT and its packs (P482–P482e)

Road-drawn walls (edge decorations) run into junctions and can't tell town from country TF3

A rail or noise wall drawn with the road tool (an .edge.lua: repModel repeated along the edge, snapMode = "Side") is laid the full length of each road piece, and a piece runs to the junction's centre, so at a crossing the rail carries on past the kerb corner into the junction. Nothing in the definition trims it (the base noise walls use the same fields). Where one may be built is decided only by tags: requiredTags / incompatibleTags against the edge's tags, which are just street / track, bridge / tunnel, plus decorated once something is on it (api tealdef EdgeDecoration). A street type's country flag is not a tag, so 'country roads only' can't be said there (the base noise wall even carries a commented-out requiredTags = {"withSidewalk"}). An edge tool also gets its own menu icon: it can't sit inside a construction button. What we did instead: a construction wall whose GUI script lays 4 m sections along the road piece's Hermite curve offset to the kerb, stopping a crossing road's half width short of a junction, and snapping only where streetTemplateRep says country = true; the drawn rails were hidden (availability.yearTo = 1, files kept so built ones load). 2026-10-15, PYT packs (P487–P490)

A town's demand and its ratings: the town window's own calls TF3

The figure in the town window's Supplies tab ('0/33') is not the town's storage: summing its buildings' stocks (or getTownStockCargo) gives a smaller number (28 or 31 where the window says 33 or 37), because the window shows DEMAND. It comes from one engine call: api.engine.system.townBuildingSystem.getCargoSupplyAndLimit(town, landUse?) → { [cargoType] = { supply, limit, n } }; the window shows min(ceil(supply), floor(limit)) / floor(limit) (base game_mechanics/towns/town_util.tl, getCargoSupplyDemandsLandUse; no landUse = town-wide). The town's ratings come from the same file, loadable from a GUI mod with ug_require "/game_mechanics/towns/town_util.tl": Supplies rating = getRatingLabel(getRatingLevel(getRatingDeliveries(town).ratingLevelNumber)); the Delivery tab's 'Cargo Supply Rating %' and 'On Time Delivery Rating' are ONE number, getRatingCargoDelivery(town, externalGetTownState(town)) (0..1, from half a year of getTownDeliveriesStats), shown ×100 and as a band (six levels, floor(f × 6): Very Poor .. Excellent). 2026-10-15, PYT Control Room (C9)

ug_require a base-game file: start the path with ::/ TF3

From a mod script, ug_require "/game_mechanics/towns/town_util.tl" looks INSIDE the mod (the log: module 'epod_..._1::/game_mechanics/towns/town_util.tl' not found). A base-game file needs the ::/ prefix: ug_require "::/game_mechanics/towns/town_util.tl", as "::/gui/main/react.lua". Wrapped in pcall the failure is silent, so log the error once. 2026-10-15, PYT Control Room (C9c)

Cargo classes and warehouse stores TF3

Every base cargo carries cargoClasses = { "UNIVERSAL", <class> } (base cargos/*.cargo.lua): LIQUID (fuel, crude oil, chemicals, dyes, rubber), GOODS (meat, vegetables, fish, clothes, tools …), BULK (coal, grain, sand, stone, iron ore …), FLATBED (steel, logs, planks, machines, sheet metal, vehicles); passengers are PASSENGERS. Each warehouse module adds ONE 500-unit store of a class (warehouses/modules.script.lua addStock("LIQUID", 500, …)); UNIVERSAL takes anything. Read a cargo's class from api.res.cargoTypeRep.get(ct).cargoClasses. api.engine.util.stock.getStockCargoTypes(stockList, i) on a warehouse store lists what it HOLDS, not all it accepts — match a store to a cargo by class. 2026-10-15, PYT Control Room (C10)

‘No electric path’ on a tram line can mean it has nowhere to turn TF3

A tram line whose track is electric all the way still reported no electric path: the line ended at a plain street stop and the tram had to U-turn there. Trams can't turn on a street (buses can); a line end needs a station, a depot or a loop. The game's line manager finds the path by relaxing modes one at a time (line_util getRelaxationType: Catenary, Road, Train, TramLanes, TramTrack) and names the first that makes a path exist — here ‘Catenary’, which sends you looking for missing wires. Check the line's ends first. 2026-10-15, seen by Epod in a PYT test game

Menu filter chips: filterCategories on the entry's menu category TF3

The small toggle chips across the top of a build tab come from menuCategory.categories[i].filterCategories = { "tree" } on each entry (base Landscaping > Vegetation; gui menu_category_util.tl collects them), each chip a menu_filter_category resource (base has barrier, tree, gauges ...). Put on the entries of a mod's own tab (four edge rails and a construction) it showed no chip there; not chased further (one button was the better answer). 2026-10-14, PYT (P486a)

TF3: one construction = several stations + a depot + a warehouse (subconstructions) TF3Fatal

TF3 has no result.stations: a station, a depot or a warehouse store is a field on an entry of result.subconstructions. A plain (non-industry) construction built two truck stations (5 bays each: a drive-through BUS/TRUCK lane of 2 edges with the stop at node 1, a PERSON/CARGO walk beside it, station = { terminals, catchmentAreas, pool }), a catchment-only maintenance depot (depot = { catchmentArea, transportModes, maintenancePool }, no lanes) and a store (warehouse = {} + a STORAGE_STOCK with cargoClassesIncluded) — the window shows Road Station, Road Station, Maintenance Building and Warehouse tabs. Rules learned the hard way: (1) "Empty Subconstruction" = a subconstruction without models, AND one with models but no job (no station / depot / warehouse / industry) — hang the yard's scenery and roads on a station sub; (2) a bay needs cargoTransferSpeeds with a class (or passengers = true), or the station window and the line editor's bay picker crash in TerminalTitle / TerminalSelection with "argument #2 can't be cast" (getCargoClassId(nil)); (3) a built construction KEEPS the bays it was built with — after a script fix bulldoze it (with the bulldozer, never by opening its window) and place a new one, or you chase a crash the code no longer has; (4) lanes link across subconstructions where end points coincide; the street stubs are edgeLists with snapNodes. Template: the mod.io "Shopping Mall and Auto Dealership" mod and the vanilla airport. 2026-10-16, PYT Fleet Control (H0–H6)

TF3: 200 locals per file — the mod fails to START TF3Fatal

TF3's Lua refuses a script whose main chunk declares more than 200 local variables ("too many local variables (limit is 200) in main function"), and the whole mod then fails at startup. Every local x, local function f and each name in local a, b, c counts, so a long single-file GUI script gets there without noticing (ours did at ~2,600 lines). Fix: keep state in one table (local S = {}; S.cache = ...) and split big files. Test with a real Lua 5.4 (lupa.lua54); newer Lua builds allow more and will not warn you. 2026-10-16, PYT Fleet Control (D39)

TF3: an open window can't be moved — and never change its id TF3Fatal

We tried to keep a window's right edge in place when it grows (so a window parked in the top-right corner doesn't run off the screen). What we found: (1) redrawing the builtin.Window with a new initialX does NOT move a window that is already open — initialX/initialY only count at the first open; (2) closing and reopening it brings it back where it LAST was — the game keeps a window's spot by its id for the rest of the session; (3) reopening under a different id is fatal: removeAllWindows + addSingletonWindow in the same frame reuses the live component, and the game asserts (comp->GetId())==(params->id) ("pytMiniMapWindow != pytMiniMapWindow_1"). Keep a window's id a constant. There is no supported way to move an open window that we know of. (4) Also fatal: an inline style sheet on the window itself — meta = { styleSheet = styleutil.makeStyle { anchorPoint = { 1, 0 } } } on a builtin.Window crashes in WindowContainer ("id >= 0 && id < nodes.size()"). Inline styles are fine on ImageView / TextView; a window's anchorPoint (the game's entity windows use { 1, 0 } to hang from their top-right corner) only from a .css.lua rule such as Window#yourWindowId — and that is fixed, not chosen per open. (5) What works: close the window, wait ~10 frames (count them down in a step that keeps running while it is closed — e.g. your game-bar button's plugin recipe), then open it again with the new initialX/initialY — same id, nothing else changed. The game also pulls a window back inside the screen when it opens. Trigger it only right after a click on one of your own buttons (never while the player drags it), wait until its size has stopped changing, and don't let your position-saver run in the frame you schedule the reopen. One quick blink per click; fine on a heavy map. 2026-10-22/23, PYT - Minimap (MM136–MM138e)

TF3: left-hand traffic — detect it from the road types, but a construction script can't TF3

The Left Hand Traffic mod flips every two-way street template's traffic lanes as the game loads them (a loadStreetTemplate modifier: each road lane's forward inverted, and busAndTramRight = false; one-way roads keep their direction). So any mod can tell the side from one vanilla road: local rep = api.res.streetTemplateRep; local id = rep.find("::/infrastructure/street/country/country_new_medium.street_template") — note the name has no .lua — then rep.getAsTable(id).laneConfigs[2].forward (the first car lane) is false right-hand and true left-hand. Our first try searched for a name ending .lua, never found the road and always said "right". The catch: this works in GUI / game scripts, but not inside a construction's updateFn — there the repository isn't available and the read quietly fails. Read the side in your GUI script and write it into the construction's params (then rebuild it once, e.g. createProposalReplaceConstruction); the builder reads the param. A placed construction keeps what it was built with until it is rebuilt. Signs only need turning (mirror their angle along the road), not a mirrored model; lane lights over the road need a mirrored mesh copy (negate the across-road axis, flip the bitangent sign, reverse the triangle winding). 2026-10-23, PYT left-hand traffic (LHT2–LHT2d)

TF3: draw a circle on the terrain from a GUI script — api.gui.mission.setZoneCircle TF3Inferred

api.gui.mission.setZoneCircle(key, api.type.Vec2f.new(x, y), radius, true, api.type.Vec4f.new(r, g, b, a), false, false) draws a soft coloured circle on the ground; api.gui.mission.removeZone(key) takes it away. GUI only: it never blocks building and is not saved. Call it on the GUI main thread (the mod queues it with react.enqueueJoin). Useful for any reach / catchment / radius display (a drone station's reach, a depot's maintenance circle, a planned footprint). Read in another mod's code (Warehouse Drone Delivery, mod.io 6481617, 2026-10-26) — not yet tried in a PYT mod.

TF3: entity ids are reused — check the revision before you delete TF3Inferred

The engine hands out a freed entity id again. A script that stored an id earlier (a pad it built, a line it made) should also store api.engine.getRevision(entity).num[1] and only act on the entity while the revision still matches — otherwise it can delete the player's new thing that got the old number. Read in another mod's code (Warehouse Drone Delivery, mod.io 6481617, 2026-10-26) — not yet tried in a PYT mod.

TF3: a script-made line stop loads NOTHING until you say so TF3Inferred

A new api.type.Line.Stop gets a StopConfig whose load flags are all false (the base game's makeNewStop does that). A script that builds a delivery line must fill api.type.Line.StopConfig.new() itself: the loading stop loads the cargo types up to 100 %, the unloading stop loads nothing (and force-unloads). Then api.type.Line.new() + makeLineCreateCmd, a vehicle via TransportVehicleConfig and api.engine.util.vehicle.findBestDepotForLine; clean up with makeVehicleSendToDepotCmd and makeLineDestroyCmd. Read in another mod's code (Warehouse Drone Delivery, mod.io 6481617, 2026-10-26) — not yet tried in a PYT mod.

TF3: a vehicle VARIANT without shipping any files — copy a model at load TF3Inferred

In a postRunFn: api.res.modelRep.getAsTable(api.res.modelRep.find(name)) gives a vanilla vehicle model as a table; change its metadata (price, running cost, emissions, capacity, years) and api.res.modelRep.addAsTable(name .. "_mymod", model) registers it as a new vehicle that reuses the game's meshes. api.res.modelRep.forEachModelWithMetadata("transportVehicle", fn) walks the vehicles. Game assets first, zero download size. Read in another mod's code (Warehouse Drone Delivery, mod.io 6481617, 2026-10-26) — not yet tried in a PYT mod.

TF3: what a house or factory is waiting for — its INPUT stocks TF3Inferred

A TOWN_BUILDING or INDUSTRY component has a stockList; in its STOCK_LIST, entries with type == api.type.enum.StockListType.InputStock are what it receives, each with a cargoType and capacity; the amount is api.engine.system.simEntityAtStockSystem.getStockCount(stockList, index). House stocks are tiny (1–4) and not every listed cargo is really consumed — the mod checks the building's consumption rules before counting a house. That is the town's demand signal, building by building. Note: walking every TOWN_BUILDING on the map each scan is costly on big maps — filter by area first. Read in another mod's code (Warehouse Drone Delivery, mod.io 6481617, 2026-10-26) — not yet tried in a PYT mod.

TF3: a component read from the world is read-only — edit a copy TF3

Writing a field of a component returned by api.engine.getComponent fails with cannot write to read only member 'roadTemplate'. Put it into the new object first and change the copy there (seg.comp = be, then seg.comp.roadTemplate = ...), or be:clone() it. When you swap a street's type, take the new template's laneConfigs and streetStyle too (api.res.streetTemplateRep.get(find(name))) — tram track lives in the lanes' transport modes, so a renamed piece with the old lanes stays bare. Seen 2026-10-28 in PYT - Tram Doctor (Lay tram track).

TF3: the game picks a line's best stop bays itself — getBestLineAssignment TF3Inferred

Copy the line (api.type.Line.new(comp)), set every stop's station and terminal to -1, and call api.engine.system.lineSystem.getBestLineAssignment(-1, copy, true): it returns, per stop (1-based), the station and terminal the game would use — the right side of the road for the direction the vehicle is travelling. Useful to build a line, or to find stops a player put on the wrong side (the line still works, the vehicle just loops round a block to reach them, and the game says nothing). Check each pick serves your vehicles: api.engine.system.stationGroupSystem.getCarriers(group, station, terminal) lists a bay's carriers — a bus-only bay can come back for a tram line; keep the old bay then. Read in two mods' code (Auto City Bus Lines, mod.io 6423708; Easy Reverse-Line maker, mod.io 6453250 — it reverses a line by blanking every stop and asking this, 2026-10-28); PYT - Tram Doctor cross-checks it against its own measured plan.

TF3: a wrong-side stop on a line that works — measure, don't just route TF3

api.engine.util.pathfinding.findPathNodeToNode({a},{b},{mode}) finds ANY route, however long, so ‘is there a path’ passes a stop on the far side of the road whenever the town's streets or track let the vehicle loop round. Sum the route's road pieces (each BASE_EDGE once) and compare: the stretch in from the stop before plus out to the stop after, for each bay. When many stops are wrong, judging one stop against its (also wrong) neighbours fails — plan the whole line at once (every stretch for every pair of bays, shortest round trip). Then a stretch still 3× the straight-line distance is the vehicle driving round the block to turn. A stop's bays: every terminal of every station in its STATION_GROUP; the line stop names them by station and terminal (0-based). Seen 2026-10-28 in PYT - Tram Doctor on Epod's test lines.

TF3: rebuilding road pieces — never drop a junction's node config, remap it TF3Fatal

A street proposal that rebuilt pieces and put their end nodes in nodeConfigsToRemove without adding configs back (hoping the game would make fresh ones) passed makeProposalData, was accepted — and then the simulation thread died: ecs::Engine::GetComponentDataIndex: Assertion 'it != components.end()' failed on two entities, just after transition not valid: entity ..., type class StreetGeometry::Crossing. The node config owns the junction's crossings; drop it and they dangle. Remove AND re-add: copy each node's BASE_NODE_CONFIG into a BaseNodeLaneConnectionAndEntity, point every laneConnections segment0/segment1 (and crosswalks) at the new piece ids, then list the node in both nodeConfigsToRemove and nodeConfigsToAdd. When the rebuilt piece gains tram lanes (a town street swapped for its _tram twin — same lane count, tram on the outer driving lanes), set withTram = true on the copied turns between two tram-carrying lanes or the new track won't join up. A dry run does not catch this. Seen 2026-10-28 (build 40425), PYT - Tram Doctor (Lay tram track).

TF3 on PS5: a Window with closable = true breaks the whole UI — test the field first TF3Fatal

The PS5 build's builtin.Window has no closable field, and an unknown field there fails the entire interface, not just your window. A player reported “crashes the UI on PS5 … when you click the button at the top left” — the PYT button, whose windows all asked for the close X. Test it once on a spare param object and only ask where it exists: local made, ud = pcall(function() return api.gui.react.params.builtin.Window.new() end); ok = (not made) or pcall(function() ud.closable = true end); then closable = ok and true or nil. Without the X, your own button opens and closes the window. First found by the ‘Better Minimap’ mod (PYT Mini Map MM45); fixed across every PYT mod 2026-10-29 (P532) and shipped first in PYT - Pay Your Tolls 3.0.1 the same day; we have no PS5 copy, so the reporting player is our test — until a PS5 player confirms, treat it as Inferred. Any other optional Window field (header…) deserves the same test.

Written by Epod and Claude from the decision logs of Blueprint Exchange, Pay Your Tolls, Insider Trader and the PYT mods, October 2026. Transport Fever builds change, so treat version-specific details (TF3 build 40408) as a starting point and re-check.