Skip to content

Spec and first two build plans for a shared factorio-oracle CLI - #84

Merged
wormeyman merged 11 commits into
mainfrom
docs/shared-oracle-spec
Aug 17, 2026
Merged

Spec and first two build plans for a shared factorio-oracle CLI#84
wormeyman merged 11 commits into
mainfrom
docs/shared-oracle-spec

Conversation

@wormeyman

@wormeyman wormeyman commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Design docs only. No code here, and nothing in tools/ or src/ changes.

Four repos now run Factorio headless to ask it questions, and each wrote the same
plumbing from scratch. This branch writes down the shared Rust CLI that replaces
that plumbing, plus the first two of four build plans. Tracking issue: #82.

The tool itself now exists at FactoryGameFan/factorio-oracle.
Plan 1 is built and CI is green there.

What is in here

  • docs/superpowers/specs/2026-08-16-shared-factorio-oracle-design.md - the design.
    Commands, the five run modes, the probe spec, the output contract, determinism
    rules, provenance, testing, repo setup, prior art, build order, and a decision log.
  • docs/superpowers/plans/2026-08-16-factorio-oracle-runner-core.md - plan 1,
    build-order steps 1 to 3: the repo skeleton, installs list, and run. Built.
  • docs/superpowers/plans/2026-08-17-factorio-oracle-trimmer.md - plan 2, the
    trimmer and the byte-for-byte acceptance test. Written, not started.

Three things the first real run disproved

Plan 1 was built, then run against a real 2.1.14 install. Three claims in this
design turned out to be wrong, and all three had passed sixty unit tests.

  • Factorio writes nothing to stderr. Measured three ways: a control-stage
    error("DUMPED-OK"), a data-stage error, and an unknown command line flag all
    print to stdout and leave stderr at zero bytes. The spec said stderr, so
    sentinelSeen was false on every real run.
  • Omission in mod-list.json means enabled. The game rewrites the file and
    adds back every bundled mod it does not mention. A file naming only base came
    back naming five, and all five loaded. An explicit enabled: false is honoured,
    proven in one run where three named-disabled mods stayed out while recycler,
    unmentioned in the same file, was added and loaded.
  • The seed was hardcoded, so a spec's value went nowhere.

Two of the three were invisible to the unit tests by construction: the fake game
encoded the same wrong belief as the code. The testing section now says so
outright, and the integration test that caught them is no longer optional.

What plan 2 rests on, measured rather than assumed

The risky parts were spiked before the plan was written.

  • The capture half of capture-factorio-oracle.sh is already replaced. A
    dump-data run through the new tool produced a 28 MB dump in 2.9 seconds, and
    feeding that to the existing Python trimmer reproduced the committed fixture
    byte for byte.
  • A sixty-line Rust spike over the real dump produced entities and modules
    blocks byte identical to the fixture, so the port is achievable.
  • serde_json's number parser is one ULP out on Factorio's long decimal
    expansions, in both directions, while Rust's own f64::from_str agrees with
    CPython bit for bit. Round-tripping the whole 25 MB dump found 9,744 differing
    lines, none in a field the fixture keeps. Latent, and cheap to fix now.
  • captureInfo.loadedMods cannot come from the active-mods prelude, because the
    fixture lists core and script.active_mods never reports it.

A bug found on the way

runtime-api.json does not contain the values of defines. Across all 1,554
entries the only keys are name, order and description.
trim-factorio-oracle.py:150 reads order as if it were the value, which is
correct today only because Factorio declares directions clockwise from
north = 0. Tracked as #83.

A create probe has now read the real table out of the running game:
north=0, east=4, south=8, west=12. It matches, so fixing #83 provably changes no
fixture bytes. Plan 2 handles it by ordering - port the bug, prove the port, then
fix it as its own reviewable change with a test asserting the output did not move.

Still to come

Plans 3 and 4: provenance check with the unknown ratchet, and refs plus the
three knowledge documents.

wormeyman and others added 10 commits August 16, 2026 23:45
Four repos ask the real game questions, and each wrote the same plumbing.
Measured: about 1,320 lines of near-identical wiring across 23 copies, before
counting FactorioTools' own share. 14 of 18 blueprint-editor probes hardcode a
factorio_version that silently skips the mod when it is wrong, only 1 of 18
checks the binary exists, and none of the three repos has a timeout.

The design is a Rust CLI, JSON in and JSON out. It owns the plumbing and not
the questions, because a probe compares the game against the consumer's own
reimplementation - so analysis has to stay in the consumer's language. That
also rules out a shared probe framework in any single language.

Records what reading the repos changed: five run modes rather than one, a
per-mode success predicate, the runner returning a work directory instead of
a dump, Lua passed in opaque, and reading factorio-data at a tag rather than
checking one out in a clone three repos share.

Spec lives here because this repo has the specs convention and the oracle repo
does not exist yet. Move it there when it does.

Refs #82, #83.
Eleven TDD tasks from an empty directory to a working 'run' subcommand:
crate skeleton with Renovate in the first commit, version parsing, install
layout resolution, discovery, the probe spec types, Lua literal injection,
mod scaffolding, per-mode argument vectors, the per-mode success predicate,
the spawn boundary with a timeout, and the wiring plus a fake game.

Records a conflict plan 2 has to handle: the spec wants a byte-for-byte
reproduction of the committed fixture, but #83 shows that fixture's
directions come from reading a documentation index as if it were a value.
The port has to reproduce the bug first, then fix it as a visible diff.

Refs #82, #83.
Contamination reporting is now on by default, and the mechanism changed:
script.on_init takes exactly one handler, so a prelude using it would be
silently replaced by the consumer's own - which 17 of 18 blueprint-editor
probes register. A self-cancelling on_nth_tick collides with nothing.

Adds the f32 round-trip rule the map repo asked for, with its evidence:
two candidate kernels shared an identical worst absolute error of 2.682e-7
and differed by 42 exact matches of 512, so an error bound could not tell
them apart. Recorded as a test in a new task rather than a comment, because
a capture that loses precision still looks fine.

--instrument-mod is confirmed real in the 2.1.14 binary and gets a spike
before plan 2. Records MapWebUI#234 as the first customer.
The map seed can reach the game twice: inside the map-gen settings JSON and
as --map-gen-seed. MapWebUI sets both from the same variable, so they have
never disagreed - and nothing establishes which one the game honours.

Collapsing to one channel would be the bad kind of hazard. If the flag beat
the file, a CLI writing only the file would generate a different map from
the same request, with nothing erroring. That repo already paid for a
seed-provenance mistake once: a correct field scored 0.5% overlap against
the wrong convention where the right one scored 99.9%.

Taking one seed field and writing both channels makes the design correct
whichever wins, and makes disagreement structurally impossible.

Also softens the --map-gen-settings claim from 'required' to 'always
passed', which is what is actually established. Nobody has run --create
without it.
FactorioMapWebUI ran the probe: a mod reading back
game.surfaces[1].map_gen_settings.seed, four argument vectors, file seed
111111 against flag seed 222222.

--map-gen-settings is NOT required. Two arms generated a map, loaded the
mod and produced a dump with no settings file at all. 'Always passed' was
habit in the consumer repos, not a rule of the game, so the CLI now passes
it only when a caller supplies one.

--map-gen-seed OVERRIDES the file's seed: file 111111 plus flag 222222
gives a surface seed of 222222, and a file-only arm rules out the file
being ignored in general. So writing both channels from one field is now
the only shape that cannot bite - a tool writing only the file would be
silently overridden by a caller's flag.

Also records what that measurement found in MapWebUI itself: its settings
file's seed field has never done anything, because the harness always
passes the flag too. No fixture is wrong, but mapGenOverrides silently
discards a seed passed through it. Tracked on that repo's #232.
Ran a four-file probe mod on 2.1.14 logging from every stage.

--instrument-mod is real and gives earlier hooks: instrument-data.lua at
0.045s against data.lua at 0.129s, and instrument-control.lua toplevel
before control.lua toplevel. Without the flag neither instrument file
loads at all.

But instrument-control.lua's script.on_init handler NEVER fired, because
control.lua registered one too and the later registration replaced it. So
Instrument Mode hands a probe an earlier hook whose event registration the
consumer then silently destroys. Not adopting it.

That also turns the single-handler rule from an inference into a
measurement, which matters because the whole contamination design rested
on it.

The better answer came out of the same run: helpers.write_file works at
plain control.lua toplevel with no event, and script.active_mods is
populated there and includes the probe's own mod. So the prelude is now
one line with no event registration and no collision surface, replacing
the self-cancelling on_nth_tick. A test asserts the absence of on_init,
on_nth_tick and on_event so it cannot regress.

Also points the new repo at FactoryGameFan, following today's move.

Refs #82.
Two corrections from the FactorioMapWebUI session.

The toplevel write is for metadata, not sampling. game.surfaces[1] does
not exist at control-stage toplevel, so anything calling
calculate_tile_properties or get_tile still needs on_init. Those
registrations are load-bearing rather than habit, and the two
requirements compose: the prelude registers nothing, a sampling probe
registers exactly one on_init, and nothing contests the slot. The spec
now says not to read the finding as 'probes should stop using on_init',
because that is the wrong lesson the earlier wording invited.

Instrument Mode is actively dangerous rather than merely unhelpful: a
tool built on it works on every probe that does not register on_init and
fails silently on every probe that does. Keeping the entry despite not
adopting the feature, since a measured destruction of the handler is a
stronger claim than an inference from the docs.
The same document already said, under Repo setup, that all six repos moved
to FactoryGameFan. Open question 1 still called it undecided.

Record the answer rather than deleting the question. GitHub documents what
happens to a fork on deletion and on detachment, but never on transfer, and
that gap was the stated blocker. A transfer rewrites a cross-repo pull
request's head to the new owner and keeps the pull request - verified on
PR #10, which now reads head: FactoryGameFan:main and is still closed with
both comments intact. It was closed before the transfer, so this covers the
closed case only.

Also note the two things that bite afterwards: the transfer API is
asynchronous, and old wormeyman URLs 301 correctly, so stale references
survive without complaining.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jij2RRpAh3qNARvhxM3PUP
Build-order steps 1 to 3 are built, and running them against a real 2.1.14
install contradicted this document three times.

The sentinel is on stdout. Factorio writes nothing to stderr at all: a
control-stage error("DUMPED-OK"), a data-stage error and an unknown command
line flag all printed to stdout with stderr at zero bytes. sentinelSeen was
false on every real run, which is worse than not reporting it, because a
create run keys success off the dump existing and the sentinel is the only
thing that catches a probe which wrote its dump and then died.

Omission in mod-list.json means enabled. The game rewrites the file and adds
back every bundled mod it does not mention. A file naming only base came
back naming five, and all five loaded. A control arm settled both halves at
once: three mods listed as disabled stayed out, while recycler, unmentioned
in the same file, was added and loaded. So an empty mod directory keeps out
user mods and nothing else.

Both defects were invisible to sixty unit tests, because the fake game
encoded the same wrong belief as the code. That is the part worth carrying
into the remaining plans, so the testing section now says it outright.

Two claims survived and are now measured: --dump-data does honour write-data
for script-output, which closes an open item, and --create still needs no
settings file.

The plan's task bodies are left alone, since they record what was planned. A
corrections section names the three tasks that carry a wrong premise, and
flags for plan 2 that the byte-for-byte fixture comparison only holds
against the default mod set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jij2RRpAh3qNARvhxM3PUP
Covers build-order step 4, which is the gate on whether the shared tool can
replace tools/capture-factorio-oracle.sh.

Written after spiking the risky parts rather than guessing at them, so four
things the plan would otherwise have had to hedge on are settled:

The capture half is already done. A dump-data run through the new tool
produced a 28 MB data-raw-dump.json in 2.9 seconds, and feeding that to the
existing Python trimmer reproduced the committed fixture byte for byte. Only
the trim half is left.

The port is achievable. A sixty-line Rust spike over the real dump produced
entities and modules blocks byte identical to the fixture. The float
formatting the spec called the trap is not one here: serde_json's printer
emits shortest round-trip, same as Python's repr.

But serde_json's number PARSER is one ULP out on Factorio's long decimal
expansions, in both directions, while Rust's own f64::from_str agrees with
CPython bit for bit. Round-tripping the whole 25 MB dump found 9,744
differing lines, none in a field the fixture keeps. So it is latent, and
cheap to fix now. Task 4 does.

captureInfo.loadedMods cannot come from the active-mods prelude, because the
fixture lists core and script.active_mods never reports it. Task 7 reads it
off the game's stdout instead.

The #83 conflict plan 1 flagged is handled by ordering: port the order bug
first, prove the port with the acceptance test, then fix it as its own
change. A create probe already showed the game agrees with order today, so
that fix provably changes no bytes - and an acceptance test asserts it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jij2RRpAh3qNARvhxM3PUP
@wormeyman wormeyman changed the title Spec and first build plan for a shared factorio-oracle CLI Spec and first two build plans for a shared factorio-oracle CLI Aug 17, 2026
The table said four sources and listed data/changelog.txt. The capture reads
three, and there is no changelog handling anywhere in tools/. The changelog
is a research source a human reads; it had been written into the table as
though it were automated.

Records that FactoryGameFan/factorio-oracle now reproduces this fixture byte
for byte, with an acceptance test that proves it against a real 2.1.14
install. The script stays: the agreed migration rule is new probes only.

Two things that tool measured, written down where they matter. An empty mod
directory keeps out user mods and nothing else - Factorio rewrites
mod-list.json and re-enables every bundled mod the file omits, so the
base-only list this script writes still loads all six. And the fixture's
directions come from `order`, which is a documentation index rather than the
runtime value, so a note now sits beside the runtime-api.json row instead of
that row implying it publishes numbers. Issue #83.

--help printed a hardcoded line range, so growing the header truncated it
mid-sentence. It now prints the whole leading comment block.

Verified: --check still reports the fixture up to date against 2.1.14, and
--help reaches the Usage section again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jij2RRpAh3qNARvhxM3PUP
@wormeyman
wormeyman merged commit 818908e into main Aug 17, 2026
7 of 8 checks passed
@wormeyman
wormeyman deleted the docs/shared-oracle-spec branch August 17, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant