#+TITLE: Forts Data & Modding Reference (extracted from the shipped game) #+AUTHOR: Forts Clone Project #+DATE: 2026-06-30 #+OPTIONS: toc:3 num:t * Source Extracted directly from a local Forts install (unpacked, NOT decompiled): - Base data: =SteamLibrary/steamapps/common/Forts/data/= (~2650 .lua files) - Workshop mods: =SteamLibrary/steamapps/workshop/content/410900/= (~80 mods) The game ships its game-logic data as plain Lua. This file records the bits that matter for our clone: the canonical physics/material/fire numbers, and the modding architecture. Numbers here are the GROUND TRUTH that validate (and in a few places correct) our research notes. * Canonical physics constants (=data/db/constants.lua=, table =Physics=/=Structure=) | Constant | Value | Meaning / our equivalent | |-----------------------------------+-----------+--------------------------------------------------| | Gravity | 981 | cm/s^2 (game works in cm; 1 grid = 37.5..150 cm) | | Oversamples | 14 | constraint solver iterations (we use 8) | | SpringDamping | 800 | global spring damping | | MinStiffness / MaxStiffness | 1e4 / 1e6 | per-material Stiffness clamps | | MinimumMass | 15 | | | Limits.AngleStressPrimaryThreshold| 30 | strut snaps if rotated >30 deg from built angle | | Limits.AngleStressSecondaryThreshold | 15 | secondary (softer) angle threshold | | Limits.MinimumStrutDivergenceAngle| 15 | min angle between two struts at a node | | StressWarning.CompressionThreshold| 0.2 | when the strut turns red (warning, not break) | | StressWarning.ExpansionThreshold | 0.2 | when the strut turns blue (warning, not break) | | TempBracing.Duration | 16 | temp rigid hold on fresh struts (our SETTLE_TIME)| | TempBracing.Scale | 1.1 | | | Break.Effect | structure_break.lua | strut snap FX | ** IMPORTANT correction to our research Our research notes claimed "stress is axial only, there is no angular stress term." The shipped data DISPROVES that: =AngleStressPrimaryThreshold = 30= and =AngleStressSecondaryThreshold = 15= are real break conditions. So Forts has BOTH an axial deformation break (MaxCompression/MaxExpansion, see below) AND an angle-from-built-angle break (30/15 deg). We currently use a force-based axial break only — adding an angle-stress break is a faithful future option. ** Stress colours (=Structure.Colours=) — matches the in-game strut colouring Compressed = red, Expanded = blue, AtRest = white, Braced = yellow. (Our build-grace radial is yellow = "Braced"; our snap is force-based.) * Material schema (=data/materials/building_materials.lua=) Materials inherit from base templates =Bracing= and =Armor= via =InheritMaterial(base, {overrides})=. Key per-material fields: ** Structural / physics - =Stiffness= spring stiffness (bracing 200000, armor 250000) - =MaxCompression= / =MaxExpansion= axial break thresholds (bracing 0.90/1.10 = +/-10%!) - =MinLength= / =MaxLength= / =MaxLinkLength= / =MaxSegmentLength= - =Mass= (bracing 0.25, armor 0.50 -> 2x heavier, rope 0.001) - =AirDrag=, =SpringDamping=, =Pretension= - =AngleStressPrimaryThreshold= / =Secondary= (rope/fuse = 360 -> never break on angle) - =Node= = StandardNode | CableNode ** Health / combat - =HitPoints= (bracing 150, backbracing 100, armor 400, rope 50, shield 60, solar 200) - =AbsorptionMomentumThreshold=, =ReflectionMomentumThreshold=, =PenetrationMomentumThreshold= - =CollidesWithFriendlyProjectiles= / =CollidesWithEnemyProjectiles= - =CollidesWithFriendlyBeams= / =CollidesWithEnemyBeams= - =ReflectsBeams=, =BeamPenetrationBlockDist= - =CatchesFire=, =DegreesPerSecondMin/Max= (fire spread speed ALONG the strut) ** Economy - =MetalBuildCost=, =EnergyBuildCost=, =MetalRepairCost=, =EnergyRepairCost= - =MetalReclaim=, =EnergyReclaim=, =EnergyRunCost=, =BuildTime=, =ScrapTime= ** Concrete values that confirm our model | Material | HP | Stiffness | Mass | MaxComp/Exp | Metal/Energy build | Fire deg/s | |---------------+-----+-----------+------+-------------+--------------------+------------| | bracing (wood)| 150 | 200000 | 0.25 | 0.90 / 1.10 | 0.1 / 0.5 | 20..32 | | backbracing | 100 | 200000 | 0.25 | 0.92 / 1.08 | 0.1 / 0.5 | 64..92 | | armor | 400 | 250000 | 0.50 | 0.90 / 1.10 | 1.8 / 1.5 | n/a | | rope | 50 | 50000 | 0.001| 0.60 / 1.50 | 0.1 / 0.5 | 20..100 | Notes: - Wood +/-10% deformation break == our axial snap rule. Armor 25% stiffer + 2x mass == our research. 3 wood (450 HP) slightly > 1 armor (400 HP) == research. - *Background bracing* has =CollidesWith{Friendly,Enemy}Projectiles = false= and =CollidesWithFriendlyBeams = false= but =CollidesWithEnemyBeams = true=: projectiles pass straight through it; enemy LASERS still hit it. And it burns 3x faster (64..92 vs 20..32 deg/s) — exactly why fire counters the "hide supports as background bracing" trick. - Rope: =AngleStressPrimaryThreshold = 360= (no angle break), tension element. * Fire system (=data/db/constants.lua=, table =Fire=) | Field | Value | Meaning | |-------------------------------+-------+---------------------------------------| | DamageHitpointsPerSecond | 0.5 | burn DoT on struts | | DamageHitpointsPerSecondDevice| 2.5 | burn DoT on devices (5x faster) | | DegreesPerSecondMin/Max | 20/32 | base spread speed (per-material overrides) | | IgnitionTemp / MaxTemp | 100/150 | | | SegmentLength | 15 | fire is segmented along the strut | | Alarm.Delay | 7 | "fort on fire" alarm after 7s | Fire is a per-strut DoT that propagates strut-to-strut at a per-material rate, independent of the HP/stress systems. Background bracing burns fastest. * Extrusion (drag-build) constants (=data/db/constants.lua=, table =Extrusion=) | Field | Value | Our equivalent / note | |-----------------+-----------+------------------------------------------| | DefaultMaterial | "bracing" | | | MinAngle | 20 | min divergence angle of the new box | | MinOffset | 40 | min drag distance to start a box (cm) | | SnapAngle | 6 | drag direction snaps to 6-deg increments | | MaxLengthGrace | 500 | | Confirms drag-build is angle-flexible (not forced perpendicular) with a snap and a minimum size — matches what we built (free drag offset + MIN_BRACE_LENGTH). * Modding architecture ** Layered loading 1. Base =data/...= loads first, populating global tables (=Materials=, =Weapons=, =Devices=, =Projectiles=, =Physics=, =Fire=, ...). 2. Each active mod runs ON TOP, mutating those globals. 3. Priority (=mod.lua=, 1..10, default 5; higher = loads later = final say), then alphabetical. (Some workshop mods use larger numbers, e.g. 99.) 4. =RegisterApplyMod(fn)= defers =fn= until AFTER all mods load, so a mod can modify content added by later-loading mods. ** Mod file layout (mirrors the base data tree) #+begin_src text / ├── mod.lua # Selectable=true, Priority=N, Category="..." [, AIFortSpecialisation] ├── displayname.lua # DisplayName = { ['English']=L"...", ... } (often UTF-16) ├── publishedfileid.lua, itemversion.lua, preview.jpg # Steam Workshop metadata ├── db/constants.lua # override physics/fire constants ├── materials/building_materials.lua # add/modify materials ├── weapons/weapon_list.lua, projectile_list.lua, .lua ├── devices/device_list.lua, .lua, /*.dds|png # + art └── ... #+end_src The loader injects a =path= variable = the mod's root, used for asset/file refs (e.g. =FileName = path.."/devices/ballast.lua"=). ** mod.lua manifest (minimal) #+begin_src lua Selectable = true Priority = 6 Category = "Devices" -- e.g. "Combat", "Physics", "Disable/Weapons" #+end_src ** The mod API (global Lua functions exposed by the engine) | Function | Purpose | |--------------------------------------------+----------------------------------------| | =IndexOfMaterial/Weapon/Device(saveName)= | find an item's position in its list | | =FindMaterial/FindWeapon/FindProjectile(n)=| get an item table to mutate | | =InsertMaterialBefore/Behind(saveName, t)= | insert relative to an existing item | | =table.insert(Devices, IndexOfDevice(x)+1, t)= | the raw add pattern | | =MergeLists(t1, t2)= | concat list tables | | =RegisterApplyMod(fn)= / =DeregisterApplyMod=| run fn after all mods load | | =InheritMaterial(base, overrides)= | clone a base material with overrides | | =L"..."= | localized string literal | ** Three concrete mod patterns observed 1. *Disable* (=disable_weapon_cannon=): override file does =local w = FindWeapon("cannon"); w.Enabled = false=. 2. *Tweak* (commander mods): set globals + =RegisterApplyMod(fn)= where =fn= scales the already-loaded weapon's fields (=FireStdDev=, =KickbackMean=...). 3. *Add* (=fsballast= device mod): =table.insert(Devices, IndexOfDevice( "repairstation")+1, { SaveName=..., FileName=path.."/devices/ballast.lua", MetalCost=50, EnergyCost=250, ... })= plus the device's own definition file (sets =Mass=, =HitPoints=, =Sprites=, =Root= scene-graph) and art. * What to adopt for our clone - *Data-driven materials/weapons/devices*: move the hard-coded material table in =app.cpp= into Lua data (sol2), keyed by =SaveName=, with the field schema above. Engine reads the tables at startup. (Tech-stack already plans sol2.) - *Numbers*: adopt the relative relationships (HP 150/100/400/50, armor 25% stiffer + 2x mass, bg-brace burns ~3x faster, wood +/-10% break) even if our absolute scale differs (we use world units, not cm). - *Layered loader*: base tables -> mods by (priority, name) -> =RegisterApplyMod= pass. Inject a =path= per mod. Mirror the base directory tree. - *Reconsider an angle-stress break* (30/15 deg) alongside our force-based axial break — it is in the real game and cheap to add. - *Fire (M4)*: per-strut DoT (0.5 hp/s, 2.5 for devices) propagating along edges at a per-material deg/s rate; bg-brace fastest. - *Background bracing collision flags*: projectiles pass through, enemy beams hit, fire reaches it — model these as per-material booleans, not a hard-coded special case. #+begin_src text (Do NOT copy Forts' Lua/assets into the repo — reference only. Our own data files should be written from scratch using this schema.) #+end_src