From 02bc8674a08b19fe9f75bdab08e7ba97ffc0d82b Mon Sep 17 00:00:00 2001 From: Eric J Date: Mon, 17 Aug 2026 11:52:36 -0700 Subject: [PATCH] Add a worked create-mode probe example The repo had no example of a probe mod, only integration tests. This is a real one that earned its place: it settled FactorioTools issue #81 and caught a breaking runtime API change nobody had noticed. It places pumpjacks in all four rotations and reads PipeConnection.target_position - the tile the connecting pipe actually occupies - from the running game. That matters because a --dump-data capture answers a different question. The prototype's output_fluid_box.pipe_connections gives the connection point inside the entity, and the pipe sits one tile further out in the facing direction, so deriving one from the other means trusting a convention rather than reading an answer. Measured against real 2.0.77 and 2.1.14 installs: 2.0.77 2.1.14 north (1,-2) (1,-2) east (2,-1) (2,1) south (-1,2) (-1,2) west (-2,1) (-2,-1) Running both versions is the part worth copying. The 2.0.77 column reproduces what FactorioTools has hardcoded, so the probe is shown to give the known-good answer before being believed about the new one. The 2.1.14 column was also produced twice, on Windows and macOS builds of 87180, and matches. Two traps the example documents, both hit while writing it: Factorio 2.1 deleted LuaEntity.fluidbox and the entire LuaFluidBox class, flattening it onto LuaEntity as get_fluid_box_*. And feature detection needs a pcall, because reading an unknown key on LuaEntity raises in 2.0 rather than returning nil, so the obvious guard throws on the older version. Verified end to end through the CLI on macOS: run --probe reports ok with oracle-dump.json written and all six bundled mods loaded. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P9FADuTnjE7SFEQWnpNhfc --- README.md | 7 ++ examples/pumpjack-terminals/README.md | 87 +++++++++++++++ examples/pumpjack-terminals/control.lua | 141 ++++++++++++++++++++++++ examples/pumpjack-terminals/probe.json | 11 ++ 4 files changed, 246 insertions(+) create mode 100644 examples/pumpjack-terminals/README.md create mode 100644 examples/pumpjack-terminals/control.lua create mode 100644 examples/pumpjack-terminals/probe.json diff --git a/README.md b/README.md index c07453c..22f55d8 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,13 @@ Practical notes, measured 2026-08-17: - It is the only source here that needs the network. That is why it can never gate a capture: captures must stay reproducible offline and byte for byte. +## Examples + +- [`examples/pumpjack-terminals`](examples/pumpjack-terminals) - a `create` probe + that places pumpjacks in all four rotations and asks the game where the pipe + goes. Run against 2.0.77 and 2.1.14, it settled FactorioTools issue #81 and + caught a breaking runtime API change on the way. + ## Scope Behavioural reverse engineering for interoperability - understanding what the diff --git a/examples/pumpjack-terminals/README.md b/examples/pumpjack-terminals/README.md new file mode 100644 index 0000000..2bfac13 --- /dev/null +++ b/examples/pumpjack-terminals/README.md @@ -0,0 +1,87 @@ +# Where does a pumpjack's pipe actually go? + +A worked example of `mode: "create"`. It places pumpjacks in all four rotations +and asks the running game where the connecting pipe goes, rather than working it +out from prototype data. + +```bash +factorio-oracle run --probe examples/pumpjack-terminals/probe.json --work-dir /tmp/pj +cat /tmp/pj/write/script-output/oracle-dump.json +``` + +## Why it exists + +FactorioTools hardcodes four pipe offsets, one per pumpjack rotation. A +`--dump-data` capture shows the prototype's `output_fluid_box.pipe_connections` +changing between versions, but it does not say where the *pipe* goes. Those are +different questions: `positions` is the connection point inside the entity, and +the pipe sits one tile further out in the direction the entity faces. + +Deriving one from the other means trusting a convention. `target_position` is the +answer with no derivation in it, so this probe reads that. + +## What it found + +Run against real 2.0.77 and 2.1.14 installs on 2026-08-17. Offsets are relative +to the pumpjack's center tile. + +| Facing | 2.0.77 | 2.1.14 | +| --- | --- | --- | +| north | `(1,-2)` | `(1,-2)` | +| east | `(2,-1)` | **`(2,1)`** | +| south | `(-1,2)` | `(-1,2)` | +| west | `(-2,1)` | **`(-2,-1)`** | + +2.0 gave four rotations only two distinct corners: east reused north's and west +reused south's. 2.1 gives each rotation its own corner. FFF #442, "Flip, Flow, +and Fresh Paint", is the release that did it, along with `use_mirroring` and +`migrate_horizontal_mirroring` on the same prototype. + +Running both versions is what makes the result trustworthy. The 2.0.77 numbers +match what FactorioTools has hardcoded, so the probe reproduces the known-good +answer before being believed about the new one. A probe that only runs against +the version you care about cannot tell you that. + +Also read from the running game, and identical on both: `defines.direction` is +`north=0, east=4, south=8, west=12`. + +The 2.1.14 row was produced twice, on a Windows standalone install and on a +macOS Steam install of the same build, and the numbers match. That is consistent +with `--dump-data` being byte-identical across those two platforms for a given +build, which was measured the same day. + +## Two traps this probe hit + +**Factorio 2.1 deleted `LuaEntity.fluidbox` and the whole `LuaFluidBox` class.** +It is flattened onto `LuaEntity` as `get_fluid_box_*`: + +```lua +-- 2.0 +entity.fluidbox.get_pipe_connections(index) +-- 2.1 +entity.get_fluid_box_pipe_connections(index) +``` + +`entity.prototype.fluidbox_prototypes[i].pipe_connections` still works in both, +so prototype-level reads need no branching. + +**Feature detection needs a `pcall`.** Reading an unknown key on `LuaEntity` +*raises* in 2.0 rather than returning nil, so the obvious guard throws on the +older version: + +```lua +-- throws on 2.0 +if entity.get_fluid_box_pipe_connections then ... end +-- works on both +local has_new = pcall(function() return entity.get_fluid_box_pipe_connections end) +``` + +## Notes on the shape + +- `on_init` is fine here because this mod owns its own `control.lua`. A shared + prelude could not use it, since `script.on_init` takes exactly one handler. +- No `error("DUMPED-OK")` sentinel. `--create` exits 0 on its own, and create + mode is judged by whether the dump exists. +- Pumpjacks need oil under them, so the probe lays a 3x3 `crude-oil` patch first, + and calls `force_generate_chunk_requests()` because the placements sit outside + the small area a fresh map generates. diff --git a/examples/pumpjack-terminals/control.lua b/examples/pumpjack-terminals/control.lua new file mode 100644 index 0000000..7d6f819 --- /dev/null +++ b/examples/pumpjack-terminals/control.lua @@ -0,0 +1,141 @@ +-- Measures where a pumpjack's fluid output actually is, per rotation, by +-- placing four of them in a real game and asking the runtime API. +-- +-- The dump tells us the prototype's pipe_connections.positions changed between +-- 2.0.77 and 2.1.14. It does not tell us where the connecting pipe goes, which +-- is what the planner hardcodes. target_position answers that directly, so +-- nothing has to be derived from the tile-offset convention. + +local function collect() + local surface = game.surfaces[1] + local force = game.forces.player + + local dirs = { + { name = "north", value = defines.direction.north }, + { name = "east", value = defines.direction.east }, + { name = "south", value = defines.direction.south }, + { name = "west", value = defines.direction.west }, + } + + local out = { + direction_values = { + north = defines.direction.north, + east = defines.direction.east, + south = defines.direction.south, + west = defines.direction.west, + }, + active_mods = script.active_mods, + pumpjacks = {}, + errors = {}, + } + + -- The default generated area is small, and these sit outside it. + for i = 1, #dirs do + surface.request_to_generate_chunks({ i * 16, 0 }, 2) + end + surface.force_generate_chunk_requests() + + for i, d in ipairs(dirs) do + local cx, cy = i * 16, 0 + + -- A pumpjack needs oil under it, so lay a 3x3 patch first. + for dx = -1, 1 do + for dy = -1, 1 do + pcall(function() + surface.create_entity { + name = "crude-oil", + position = { cx + dx, cy + dy }, + amount = 100000, + } + end) + end + end + + local ok, entity = pcall(function() + return surface.create_entity { + name = "pumpjack", + position = { cx, cy }, + direction = d.value, + force = force, + } + end) + + if not ok or not entity then + table.insert(out.errors, d.name .. ": " .. tostring(entity)) + else + local ex, ey = entity.position.x, entity.position.y + local entry = { + direction_name = d.name, + direction_value = d.value, + requested_direction = d.value, + actual_direction = entity.direction, + position = { x = ex, y = ey }, + mirroring = entity.mirroring, + connections = {}, + prototype_positions = {}, + } + + -- 2.1 deleted LuaEntity.fluidbox and the whole LuaFluidBox class, + -- flattening it onto LuaEntity as get_fluid_box_*. Ask for the + -- accessor rather than branching on a version string. + -- Reading an unknown key on LuaEntity raises in 2.0 rather than + -- returning nil, so the feature check itself has to be guarded. + local boxes = entity.prototype.fluidbox_prototypes + local get_connections + local has_new = pcall(function() + return entity.get_fluid_box_pipe_connections + end) + if has_new then + entry.api = "2.1 get_fluid_box_pipe_connections" + get_connections = function(i) + return entity.get_fluid_box_pipe_connections(i) + end + else + entry.api = "2.0 fluidbox.get_pipe_connections" + local box = entity.fluidbox + get_connections = function(i) + return box.get_pipe_connections(i) + end + end + + for idx = 1, #boxes do + for _, c in ipairs(get_connections(idx)) do + table.insert(entry.connections, { + box_index = idx, + flow_direction = c.flow_direction, + connection_type = c.connection_type, + -- Offsets are what the planner cares about; absolute + -- coordinates depend on where we happened to build. + connection_offset = { + x = c.position.x - ex, + y = c.position.y - ey, + }, + target_offset = { + x = c.target_position.x - ex, + y = c.target_position.y - ey, + }, + }) + end + end + + -- Prototype-level view, to cross-check against the data.raw dump. + for _, proto in ipairs(entity.prototype.fluidbox_prototypes) do + for _, pc in ipairs(proto.pipe_connections) do + local rec = { flow_direction = pc.flow_direction, positions = {} } + if pc.positions then + for _, p in ipairs(pc.positions) do + table.insert(rec.positions, { x = p.x, y = p.y }) + end + end + table.insert(entry.prototype_positions, rec) + end + end + + table.insert(out.pumpjacks, entry) + end + end + + helpers.write_file("oracle-dump.json", helpers.table_to_json(out), false) +end + +script.on_init(collect) diff --git a/examples/pumpjack-terminals/probe.json b/examples/pumpjack-terminals/probe.json new file mode 100644 index 0000000..4cced32 --- /dev/null +++ b/examples/pumpjack-terminals/probe.json @@ -0,0 +1,11 @@ +{ + "mode": "create", + "mod": { + "name": "pumpjack_terminals", + "version": "0.0.1", + "dependencies": ["base"], + "control_lua_file": "examples/pumpjack-terminals/control.lua" + }, + "seed": 1, + "timeout_seconds": 120 +}