Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions examples/pumpjack-terminals/README.md
Original file line number Diff line number Diff line change
@@ -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.
141 changes: 141 additions & 0 deletions examples/pumpjack-terminals/control.lua
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 11 additions & 0 deletions examples/pumpjack-terminals/probe.json
Original file line number Diff line number Diff line change
@@ -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
}
Loading