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 +}