├── Makefile ├── README.md ├── recipes-all.pdf ├── recipes-filtered.pdf ├── recipes.lua ├── screenshot.png ├── techtree-all.pdf ├── techtree.lua └── utils.lua /Makefile: -------------------------------------------------------------------------------- 1 | # Where to find Factorio files 2 | FACTORIO_ROOT="$(HOME)/Library/Application Support/Steam/SteamApps/common/Factorio/factorio.app/Contents/" 3 | 4 | # Output file format. PDF recommended. 5 | # SVG can render tooltips - but they don't contain anything other than debug information. 6 | OUTFORMAT=pdf 7 | 8 | # LUA interpreter 9 | LUA=lua 10 | 11 | # GraphViz commands (brew install graphviz) 12 | DOT=dot 13 | UNFLATTEN=unflatten 14 | 15 | 16 | all: recipes-all.$(OUTFORMAT) recipes-filtered.$(OUTFORMAT) techtree-all.$(OUTFORMAT) 17 | 18 | recipes-all.$(OUTFORMAT): recipes.lua utils.lua 19 | $(LUA) -e 'FACTORIO_ROOT=$(FACTORIO_ROOT)' recipes.lua | $(UNFLATTEN) | $(DOT) -T $(OUTFORMAT) -o $@ 20 | 21 | recipes-filtered.$(OUTFORMAT): recipes.lua utils.lua 22 | $(LUA) -e 'FACTORIO_ROOT=$(FACTORIO_ROOT)' -e 'FILTER=true' recipes.lua | $(UNFLATTEN) | $(DOT) -T $(OUTFORMAT) -o $@ 23 | 24 | techtree-all.$(OUTFORMAT): techtree.lua utils.lua 25 | $(LUA) -e 'FACTORIO_ROOT=$(FACTORIO_ROOT)' $? | $(DOT) -T $(OUTFORMAT) -o $@ 26 | 27 | clean: 28 | rm -f recipes-filtered.* recipes-all.* techtree-all.* 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Factorio Trees 2 | 3 | This is a collection of Lua scripts that parse Factorio's data files and then use GraphViz to generate trees for recipe and technology dependencies. It does not add woody perennial plants to Factorio, sorry. 4 | 5 | ![Partial screenshot](screenshot.png "Example") 6 | 7 | 8 | # Prerequisites 9 | 10 | * Lua 11 | * GraphViz 12 | 13 | ## Mac OS X 14 | 15 | brew install lua graphviz 16 | 17 | # Run 18 | 19 | git clone https://github.com/ingmar/factorio-trees.git 20 | cd factorio-trees 21 | # Check FACTORIO_ROOT in the Makefile 22 | make 23 | open *.pdf 24 | -------------------------------------------------------------------------------- /recipes-all.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ingmar/factorio-trees/4196fbbb7d6dfa0ce7121ee4596e5ec7ec08e487/recipes-all.pdf -------------------------------------------------------------------------------- /recipes-filtered.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ingmar/factorio-trees/4196fbbb7d6dfa0ce7121ee4596e5ec7ec08e487/recipes-filtered.pdf -------------------------------------------------------------------------------- /recipes.lua: -------------------------------------------------------------------------------- 1 | -- Factorio recipe tree 2 | 3 | require "utils" 4 | 5 | 6 | -- Recipes to load 7 | RECIPE_FILES = { 8 | "ammo.lua", 9 | "capsule.lua", 10 | "demo-furnace-recipe.lua", 11 | "demo-recipe.lua", 12 | "demo-turret.lua", 13 | "equipment.lua", 14 | "fluid-recipe.lua", 15 | "furnace-recipe.lua", 16 | "inserter.lua", 17 | "module.lua", 18 | "recipe.lua", 19 | "turret.lua", 20 | } 21 | -- Which string translation sections to use (now always in base.cfg) 22 | LANGUAGE_SECTIONS = { 23 | ["item-name"] = true, 24 | ["entity-name"] = true, 25 | ["fluid-name"] = true, 26 | ["equipment-name"] = true, 27 | } 28 | -- Recipes to exclude from graph 29 | RECIPE_EXCLUDE = { 30 | -- Recipes that have better alternatives 31 | ["basic-armor"] = true, 32 | ["basic-bullet-magazine"] = true, 33 | ["heavy-armor"] = true, 34 | ["iron-axe"] = true, 35 | ["iron-chest"] = true, 36 | ["shotgun"] = true, 37 | ["shotgun-shell"] = true, 38 | ["small-electric-pole"] = true, 39 | ["wooden-chest"] = true, 40 | ["power-armor"] = true, 41 | ["basic-modular-armor"] = true, 42 | 43 | -- Outdated tech 44 | ["burner-mining-drill"] = true, 45 | ["pistol"] = true, 46 | ["steel-furnace"] = true, 47 | ["stone-furnace"] = true, 48 | 49 | -- Unknown Use 50 | ["player-port"] = true, 51 | ["railgun-dart"] = true, 52 | ["railgun"] = true, 53 | ["small-plane"] = true, 54 | 55 | -- Filter away for factory plan 56 | ["wood"] = true, 57 | ["combat-shotgun"] = true, 58 | ["basic-oil-processing"] = true, 59 | ["fill-crude-oil-barrel"] = true, 60 | ["iron-plate"] = true, 61 | ["copper-plate"] = true, 62 | ["flame-thrower-ammo"] = true, 63 | ["steel-axe"] = true, 64 | ["storage-tank"] = true, 65 | ["boiler"] = true, 66 | ["cargo-wagon"] = true, 67 | ["steel-chest"] = true, 68 | ["oil-refinery"] = true, 69 | ["flame-thrower"] = true, 70 | ["chemical-plant"] = true, 71 | ["small-lamp"] = true, 72 | ["steam-engine"] = true, 73 | ["pumpjack"] = true, 74 | ["train-stop"] = true, 75 | ["offshore-pump"] = true, 76 | ["combat-shotgun"] = true, 77 | ["rocket-launcher"] = true, 78 | ["basic-mining-drill"] = true, 79 | ["radar"] = true, 80 | ["submachine-gun"] = true, 81 | ["assembling-machine-1"] = true, 82 | ["gun-turret"] = true, 83 | ["basic-electric-discharge-defense-remote"] = true, 84 | ["green-wire"] = true, 85 | ["red-wire"] = true, 86 | ["rail-signal"] = true, 87 | ["car"] = true, 88 | ["basic-transport-belt-to-ground"] = true, 89 | ["diesel-locomotive"] = true, 90 | ["smart-chest"] = true, 91 | ["lab"] = true, 92 | ["basic-splitter"] = true, 93 | ["assembling-machine-2"] = true, 94 | ["land-mine"] = true, 95 | ["electric-furnace"] = true, 96 | ["fast-transport-belt-to-ground"] = true, 97 | ["roboport"] = true, 98 | --["substation"] = true, 99 | ["basic-beacon"] = true, 100 | ["energy-shield-equipment"] = true, 101 | ["deconstruction-planner"] = true, 102 | ["blueprint"] = true, 103 | ["night-vision-equipment"] = true, 104 | ["logistic-chest-requester"] = true, 105 | ["logistic-chest-storage"] = true, 106 | ["logistic-chest-active-provider"] = true, 107 | ["logistic-chest-passive-provider"] = true, 108 | ["fast-splitter"] = true, 109 | ["small-pump"] = true, 110 | ["basic-exoskeleton-equipment"] = true, 111 | ["solar-panel-equipment"] = true, 112 | ["express-transport-belt-to-ground"] = true, 113 | ["energy-shield-mk2-equipment"] = true, 114 | ["express-splitter"] = true, 115 | ["battery-equipment"] = true, 116 | ["assembling-machine-3"] = true, 117 | ["fusion-reactor-equipment"] = true, 118 | ["battery-mk2-equipment"] = true, 119 | ["basic-laser-defense-equipment"] = true, 120 | ["basic-electric-discharge-defense-equipment"] = true, 121 | ["power-armor-mk2"] = true, 122 | ["rocket-defense"] = true, 123 | ["arithmetic-combinator"] = true, 124 | ["decider-combinator"] = true, 125 | ["constant-combinator"] = true, 126 | ["arithmetic-combinator"] = true, 127 | ["arithmetic-combinator"] = true, 128 | ["arithmetic-combinator"] = true, 129 | ["arithmetic-combinator"] = true, 130 | } 131 | 132 | -- Ingredients that are basic resources 133 | RESOURCES = { 134 | ["copper-ore"] = true, 135 | ["crude-oil"] = true, 136 | ["iron-ore"] = true, 137 | ["stone"] = true, 138 | ["raw-wood"] = true, 139 | ["water"] = true, 140 | } 141 | 142 | -- Try and map recipe categories to the (minimum) type of crafting station needed 143 | CATEGORY_LABEL = { 144 | default = Img(FACTORIO_ROOT.."data/base/graphics/icons/assembling-machine-1.png"), 145 | crafting = Img(FACTORIO_ROOT.."data/base/graphics/icons/assembling-machine-1.png"), 146 | ["crafting-with-fluid"] = Img(FACTORIO_ROOT.."data/base/graphics/icons/assembling-machine-2.png"), 147 | ["advanced-crafting"] = Img(FACTORIO_ROOT.."data/base/graphics/icons/assembling-machine-2.png"), 148 | smelting = Img(FACTORIO_ROOT.."data/base/graphics/icons/stone-furnace.png"), 149 | ["oil-processing"] = Img(FACTORIO_ROOT.."data/base/graphics/icons/oil-refinery.png"), 150 | chemistry = Img(FACTORIO_ROOT.."data/base/graphics/icons/chemical-plant.png"), 151 | } 152 | 153 | 154 | load_data(RECIPE_FILES, "data/base/prototypes/recipe/") 155 | load_translations(LANGUAGE_SECTIONS) 156 | 157 | 158 | -- Graphviz output 159 | print('strict digraph factorio {') 160 | -- Change rankdir to LR or TB to change direction of graph 161 | print('layout=dot; splines=polyline; rankdir=LR; color="#ffffff"; bgcolor="#332200"; ratio=auto; ranksep=2.0; nodesep=0.15;') 162 | -- Node default attributes 163 | node_default = {} 164 | node_default.color = '"#e4e4e4"' 165 | node_default.fontname = '"TitilliumWeb-SemiBold"' 166 | node_default.fontcolor = '"#ffffff"' 167 | node_default.shape = 'box' 168 | node_default.style = 'filled' 169 | print(string.format('node [%s]', VizAttr(node_default))) 170 | -- Edge default attributes 171 | edge_default = {} 172 | edge_default.penwidth = 2 173 | edge_default.color = '"#DDDD22"' 174 | edge_default.fontname = node_default.fontname 175 | edge_default.fontcolor = node_default.fontcolor 176 | print(string.format('edge [%s]', VizAttr(edge_default))) 177 | 178 | -- Raw resources go on the top/leftmost rank on their own 179 | print('{ rank=source;') 180 | for res in pairs(RESOURCES) do 181 | print(string.format('"%s";', res)) 182 | end 183 | print('}') 184 | 185 | for id, recipe in pairs(data) do 186 | -- First do a few sanity checks 187 | if recipe.type ~= "recipe" then 188 | io.stderr:write(string.format('Found unknown type "%s" instead of "recipe" for %s', recipe.type, recipe.name), "\n") 189 | os.exit(1) 190 | end 191 | if recipe.enabled ~= "false" then 192 | print("// ENABLED:", recipe.name) -- Initially unlocked recipes? 193 | end 194 | 195 | if not FILTER or (FILTER and not RECIPE_EXCLUDE[recipe.name]) then 196 | -- Some recipes have multiple sets of ingredients based on difficulty level. 197 | if recipe.ingredients == nil then 198 | -- Assume "normal"" difficulty instead of "expensive" 199 | recipe.ingredients = recipe.normal.ingredients 200 | recipe.result = recipe.normal.result 201 | end 202 | 203 | -- Recipe has .result data, convert to new .results format for easier handling 204 | if recipe.result ~= nil then 205 | recipe.results = {{name = recipe.result, amount = recipe.result_count}} 206 | end 207 | 208 | -- Define the recipe node first 209 | attr = {} 210 | -- If energy_required isn't specified, it defaults to 0.5 211 | if recipe.energy_required == nil then recipe.energy_required = 0.5 end 212 | attr.label = HtmlLabel(CATEGORY_LABEL[recipe.category or 'default'], nil, recipe.energy_required) 213 | attr.tooltip = string.format('"%s"', recipe.name) -- Put the untranslated name into the tooltip 214 | attr.fillcolor = '"#6d7235"' 215 | attr.color = attr.fillcolor 216 | attr.shape = "cds" 217 | print(string.format('"Recipe: %s" [%s];', recipe.name, VizAttr(attr))) 218 | 219 | -- Make edges from each ingredient to the recipe 220 | print(string.format(" // Ingredients")) 221 | for ing_id, ing in pairs(recipe.ingredients) do 222 | -- Convert old array syntax into new descriptive one 223 | if ing.type == nil then 224 | ing.name = ing[1] 225 | ing.amount = ing[2] 226 | end 227 | 228 | -- Define ingredient node 229 | attr = {} 230 | attr.label = HtmlLabel(T(ing.name), GetIcon(ing)) 231 | if ing.type == "fluid" then 232 | attr.shape = "ellipse" 233 | attr.fillcolor = '"#3d3c6e"' 234 | else 235 | attr.fillcolor = '"#8f8f90"' 236 | end 237 | attr.color = attr.fillcolor 238 | print(string.format('"%s" [%s];', ing.name, VizAttr(attr))) 239 | 240 | -- Ingredient -> Recipe edge 241 | attr = {} 242 | attr.label = string.format('"x%d"', ing.amount) 243 | if ing.type == "fluid" then 244 | attr.color = '"#45A7F3"' 245 | elseif ing.name == "copper-plate" then 246 | attr.color = '"#C77362"' 247 | elseif ing.name == "iron-plate" then 248 | attr.color = '"#838588"' 249 | elseif ing.name == "steel-plate" then 250 | attr.color = '"#96ff8B"' 251 | else 252 | attr.color = edge_default.color 253 | end 254 | -- For raw resources, set fixed rank 255 | if RESOURCES[ing.name] ~= nil then 256 | attr.rank="source" 257 | end 258 | print(string.format(' "%s" -> "Recipe: %s" [%s];', ing.name, recipe.name, VizAttr(attr))) 259 | end 260 | 261 | -- And from the recipe to each result 262 | print(" // Results") 263 | for res_id, res in pairs(recipe.results) do 264 | -- Define result node 265 | attr = {} 266 | attr.label = HtmlLabel(T(res.name), GetIcon(res)) 267 | if res.type == "fluid" then 268 | attr.fillcolor = '"#3d3c6e"' 269 | else 270 | attr.fillcolor = '"#8f8f90"' 271 | end 272 | attr.color = attr.fillcolor 273 | print(string.format('"%s" [%s];', res.name, VizAttr(attr))) 274 | 275 | -- Recipe -> Result edge 276 | attr = {} 277 | attr.weight = 100 -- Shorten result edges so results are close to recipe/factory 278 | attr.label = string.format('x%d', res.amount or 1) 279 | if res.type == "fluid" then 280 | attr.color = '"#9999ff"' 281 | else 282 | attr.color = edge_default.color 283 | end 284 | print(string.format(' "Recipe: %s" -> "%s" [%s];', recipe.name, res.name, VizAttr(attr))) 285 | end 286 | print("") 287 | end 288 | end 289 | print("}") -- Done! 290 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ingmar/factorio-trees/4196fbbb7d6dfa0ce7121ee4596e5ec7ec08e487/screenshot.png -------------------------------------------------------------------------------- /techtree-all.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ingmar/factorio-trees/4196fbbb7d6dfa0ce7121ee4596e5ec7ec08e487/techtree-all.pdf -------------------------------------------------------------------------------- /techtree.lua: -------------------------------------------------------------------------------- 1 | -- Factorio tech tree 2 | 3 | require "utils" 4 | 5 | -- Recipes to load 6 | TECH_FILES = { 7 | "inserter.lua", 8 | "technology.lua", 9 | } 10 | -- Which string translation sections to use (now always in base.cfg) 11 | LANGUAGE_SECTIONS = { 12 | ["technology-name"] = true, 13 | } 14 | 15 | 16 | load_data(TECH_FILES, "data/base/prototypes/technology/") 17 | load_translations(LANGUAGE_SECTIONS) 18 | 19 | 20 | -- Graphviz output 21 | print('strict digraph factorio {') 22 | -- Change rankdir to RL or BT or omit entirely to change direction of graph 23 | print('layout=dot; rankdir=LR; color="#ffffff"; bgcolor="#002233"; ratio=auto; ranksep=2.0; nodesep=0.15;') 24 | -- Node default attributes 25 | node_default = {} 26 | node_default.color = '"#a8a9a8"' 27 | node_default.fontname = '"TitilliumWeb-SemiBold"' 28 | node_default.fontcolor = '"#ffffff"' 29 | node_default.shape = 'box' 30 | node_default.style = 'filled' 31 | print(string.format('node [%s]', VizAttr(node_default))) 32 | -- Edge default attributes 33 | edge_default = {} 34 | edge_default.penwidth = 2 35 | edge_default.color = '"#808080"' 36 | edge_default.fontname = node_default.fontname 37 | edge_default.fontcolor = node_default.fontcolor 38 | print(string.format('edge [%s]', VizAttr(edge_default))) 39 | 40 | for id, tech in pairs(data) do 41 | -- Define the technology node first 42 | if tech.name:match('.*-%d+$') then 43 | -- Multi-level technologies don't have separate string translations, so we have to extract the level and reassemble 44 | translation_name, level = tech.name:match('(.*)-(%d+)$') 45 | label_name = string.format("%s %d", T(translation_name), level) 46 | else 47 | label_name = T(tech.name) 48 | end 49 | attr = {} 50 | attr.label = HtmlLabel(label_name, GetIcon(tech)) 51 | attr.tooltip = string.format('"%s\nenergy_required: %s"', tech.name, tech.energy_required or "nil") -- Put the untranslated name into the tooltip 52 | attr.fillcolor = '"#6d7235"' 53 | attr.color = attr.fillcolor 54 | attr.shape = "cds" 55 | print(string.format('"Tech: %s" [%s];', tech.name, VizAttr(attr))) 56 | 57 | if tech.prerequisites ~= nil then 58 | -- Make edges from each prerequisite to this technology 59 | print(string.format(" // Prerequisites")) 60 | for prereq_id, prereq in pairs(tech.prerequisites) do 61 | -- Prerequisite -> Tech edge 62 | attr = {} 63 | print(string.format(' "Tech: %s" -> "Tech: %s" [%s];', prereq, tech.name, VizAttr(attr))) 64 | end 65 | end 66 | 67 | if tech.effects ~= nil then 68 | -- Recipes unlocked by researching this tech 69 | print(string.format(" // Unlocks")) 70 | for effect_id, effect in pairs(tech.effects) do 71 | if effect.type == "unlock-recipe" then 72 | attr = {} 73 | print(string.format('// "Tech: %s" -> "Recipe: %s" [%s];', tech.name, effect.recipe, VizAttr(attr))) 74 | else 75 | --io.stderr:write("Unknown effect.type: "..effect.type.."\n") 76 | end 77 | end 78 | end 79 | 80 | end 81 | print("}") -- Done! 82 | -------------------------------------------------------------------------------- /utils.lua: -------------------------------------------------------------------------------- 1 | ---- These defaults can be overwritten from the command line using lua -e 'BLA=foo' (or just set them in Makefile) 2 | -- Where the Factorio data/ folder lives 3 | FACTORIO_ROOT = FACTORIO_ROOT or "/Applications/factorio.app/Contents/" 4 | -- Choose your language here 5 | LANGUAGE = LANGUAGE or "en" 6 | -- Set to false to disable string translation 7 | TRANSLATE = TRANSLATE or true 8 | 9 | translations = {} 10 | 11 | function Img(src) 12 | -- Convenience function for IMG tags 13 | return string.format([[]], src) 14 | end 15 | 16 | function VizAttr(tbl) 17 | -- Convert a table of attributes and values into Graphviz syntax 18 | pair_array = {} 19 | for attr, value in pairs(tbl) do 20 | table.insert(pair_array, string.format('%s=%s', attr, value)) 21 | end 22 | return table.concat(pair_array, ",") 23 | end 24 | 25 | -- Extendable table (for the data:extend calls in Factorio LUA) 26 | ExtendTable = {} 27 | function ExtendTable:new(o) 28 | o = o or {} -- create object if user does not provide one 29 | setmetatable(o, self) 30 | self.__index = self 31 | return o 32 | end 33 | function ExtendTable:extend(other) 34 | -- Merge tables 35 | for key, value in pairs(other) do 36 | while self[key] ~= nil do 37 | key = key + math.random(1024) -- Nasty hack to resolve key conflicts. 38 | end 39 | self[key] = value 40 | end 41 | end 42 | 43 | function trim(s) 44 | return s:match('^%s*(.*%S)') or '' 45 | end 46 | 47 | function file_exists(name) 48 | local f = io.open(name, "r") 49 | if f ~= nil then 50 | f:close() 51 | return true 52 | else 53 | return false 54 | end 55 | end 56 | 57 | data = ExtendTable:new({}) 58 | function load_data(files, rel_path) 59 | for _, filename in ipairs(files) do 60 | path = FACTORIO_ROOT..rel_path..filename 61 | dofile(path) 62 | end 63 | end 64 | 65 | -- Load string translations 66 | function load_translations(language_sections) 67 | language_files = { 68 | "base.cfg", 69 | } 70 | for _, filename in ipairs(language_files) do 71 | path = FACTORIO_ROOT.."data/base/locale/"..LANGUAGE.."/"..filename 72 | local f = io.open(path, "r") 73 | local section 74 | while true do 75 | local line = f:read("*line") 76 | if line == nil then break end 77 | line = trim(line) 78 | if line ~= "" then 79 | if line:sub(1, 1) == "[" then 80 | section = line:sub(2, -2) 81 | -- io.stderr:write("[", section, "]\n") 82 | else 83 | -- io.stderr:write("-- ", line, "\n") 84 | if language_sections[section] then 85 | local eq_pos = line:find("=") 86 | local lookup = line:sub(1, eq_pos-1) 87 | local translation = line:sub(eq_pos+1, -1) 88 | translations[lookup] = translation 89 | -- io.stderr:write(lookup, "=", translation, "\n") 90 | end 91 | end 92 | end 93 | end 94 | f:close() 95 | end 96 | end 97 | 98 | function T(lookup) 99 | if not TRANSLATE then 100 | return lookup 101 | end 102 | -- Look up translation for string, returning lookup if none was found 103 | local translation = translations[lookup] 104 | if translation == nil then 105 | io.stderr:write('No translation found for ', lookup, "\n") 106 | return lookup 107 | end 108 | return translation 109 | end 110 | 111 | function HtmlLabel(name, img, energy) 112 | -- Cough up a Graphviz label in their HTML-like syntax 113 | local clock_icon = FACTORIO_ROOT.."data/core/graphics/clock-icon.png" 114 | local img_tag = "" 115 | local energy_tag = "" 116 | if img ~= nil then img_tag = string.format([[]], img) end 117 | -- energy_required seems to refer to the time it needs (clock icon) 118 | if energy ~= nil then energy_tag = string.format([[%g]], clock_icon, energy) end 119 | return trim(string.format([[ 120 | < 121 | 122 | %s 123 |
%s%s
124 | > 125 | ]], img_tag, name or "nil", energy_tag)) 126 | end 127 | 128 | function GetIcon(tbl) 129 | -- Try and find an icon for the recipe/ingredient/result in tbl. 130 | if tbl == nil then return nil end 131 | if tbl.icon ~= nil then return tbl.icon:gsub("__base__", FACTORIO_ROOT.."data/base") end 132 | png_path = FACTORIO_ROOT.."data/base/graphics/icons/"..tbl.name..".png" 133 | if file_exists(png_path) then return png_path end 134 | -- Not found? Try the fluid folder 135 | png_path = FACTORIO_ROOT.."data/base/graphics/icons/fluid/"..tbl.name..".png" 136 | if file_exists(png_path) then return png_path end 137 | io.stderr:write('Icon not found for ', tbl.name, " at ", png_path, "\n") 138 | return nil 139 | end 140 | --------------------------------------------------------------------------------