From cd0c08dc7ce754473178ad3f32f7740a1dddc1eb Mon Sep 17 00:00:00 2001 From: Vaino Kauppila Date: Thu, 2 Jul 2026 22:58:25 +0300 Subject: Initial import: LibreForts (M0-M5 in progress) Open-source Forts clone: custom C++20 engine + data-driven (Lua) 2D physics artillery RTS. - Building: node/strut graph on a stiff mass-spring solver (canon Forts model), triangulation rigidity, axial + 30-degree angle-stress breaking, cascading collapse, fire (DoT + spread), ground destroys debris. - Weapons (M4): cannon (ballistic) + laser (beam/ignite) with select-and-aim-in- arc UX; splash / beam damage; bg-brace passthrough. - Devices + economy (M5): reactors + win/loss + restart, mines/turbines/battery, metal deposits, storage caps, per-shot energy cost. - Data-driven via Lua/sol2 with a layered mod loader; enemy-fort scenario mod. - Renderer: SDL3 + textures + ImGui dev UI. stb_image + ImGui vendored. - Assets: CC0 placeholders only; real game art loaded at runtime from the user's own install via forts: paths (bring-your-own; nothing copyrighted committed). See README.md / CLAUDE.md and research/ + requirements/ for detail. --- research/forts-data-reference.org | 193 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 research/forts-data-reference.org (limited to 'research/forts-data-reference.org') diff --git a/research/forts-data-reference.org b/research/forts-data-reference.org new file mode 100644 index 0000000..5d4ecec --- /dev/null +++ b/research/forts-data-reference.org @@ -0,0 +1,193 @@ +#+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 -- cgit v1.3