diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9532e98 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,188 @@ +# CLAUDE.md + +Guidance for Claude Code (claude.ai/code) working in this repository. + +## What this is + +`factorio-oracle` runs a real Factorio install headless and reports what it did. +It owns discovery, mod scaffolding, launching and reading results back. It owns +**none** of the analysis. + +That split is the whole design. A probe compares the game against a consumer's +own reimplementation, so the comparison has to run in the consumer's language. +The interface is therefore JSON in and JSON out, not a probe framework. Four +repos rely on it: FactorioTools, factorio-blueprint-editor, FactorioMapWebUI and +FactorioWikiDamageThresholds. + +**The migration rule is new probes only.** Nothing already working in those +repos gets rewritten to use this. Adoption happens when someone writes a probe +they did not have before. + +## Prerequisites + +- Rust **1.97.1**, pinned in `rust-toolchain.toml`. The pin exists so a shared + tool four repos depend on does not change behaviour because a contributor has + a different rustup default. +- A real Factorio install for the integration tests. Without one they skip + rather than fail, so a green run on a machine with no game proves less than it + looks. Check which happened before trusting it. + +## Common commands + +```bash +# Exactly what CI runs, in order. Run all three before claiming anything passes. +cargo fmt --all -- --check && cargo clippy --all-targets -- -D warnings && cargo test --all-targets + +# Find installs +cargo run -- installs list + +# Run a probe and read the result +cargo run -- run --probe examples/pumpjack-terminals/probe.json --work-dir /tmp/pj +cat /tmp/pj/write/script-output/oracle-dump.json + +# Reproduce FactorioTools' committed fixture +cargo run -- run --probe dump-data.json --work-dir /tmp/w > /tmp/run.json +cargo run -- trim --run /tmp/run.json --spec trim-spec.json --out fixture.json [--check] +``` + +Test counts to expect: **113 unit tests**, plus **6 install-gated integration +tests** (3 in `tests/acceptance.rs`, 3 in `tests/real_game.rs`). + +## Layout + +| Module | Job | +| --- | --- | +| `install.rs` | Finding installs and working out where their pieces live | +| `version.rs` | Reading a version out of `factorio --version` | +| `probe.rs` | The JSON document a consumer hands in | +| `scaffold.rs` | Writing the throwaway mod's files and the isolated config | +| `lua.rs` | The only place this tool writes Lua on a consumer's behalf | +| `args.rs` | The argument vector, which differs per mode | +| `spawn.rs` | The one boundary that touches processes, behind a trait | +| `outcome.rs` | Deciding whether a run succeeded, per mode | +| `run.rs` | Wiring the pure builders to disk and a spawner | +| `numbers.rs` | Preserving the bits the game produced | +| `trim/` | Cutting a full `data.raw` dump down to a consumer's slice | + +Five run modes, and **the success predicate differs per mode**: `dump-data` +(no mod at all, the mod dir exists only to be empty), `create`, `interactive` +(a human at the keyboard), `preview` (**exits 0** on success), and `read-only` +(files on disk, no binary). + +## Measured facts + +**Read this section before changing anything that talks to the game.** Every +item cost a failed run to learn, and none is visible from the code. Three real +defects once survived 60 unit tests here and were caught by the first run +against the actual game, because the fake encoded the same wrong beliefs the +code did. **A fake can only be wrong in the ways its author already considered.** + +- **Factorio writes nothing to stderr.** Everything, including an + `error("DUMPED-OK")` sentinel, goes to stdout. Measured three ways on 2.1.14, + and again on Windows: stderr was zero bytes every time. The original code + checked stderr, so the sentinel was never seen on any real run. +- **Omission in `mod-list.json` means enabled.** Factorio rewrites the file at + startup and adds back every bundled mod the file does not mention, with + `enabled: true`. A file naming only `base` comes back naming five, all loaded. + An explicit `enabled: false` **is** honoured, so naming a mod is the only way + to get a smaller game than the install ships with. An empty mod directory + keeps out *user* mods and nothing else. +- **`read-data` is layout-dependent, so never write it as a relative token.** + `__PATH__executable__/../data` is right for a macOS bundle and wrong + everywhere else: Windows and Linux put the binary at `bin/x64/`, so it + resolves to `bin/data` and the game exits 1 with "There is no package core + in". Use the absolute `data_dir` that `resolve_layout` already computes. + Windows accepts either slash style, so `Path::display()` needs no rewriting. +- **`--write-data` is not a CLI flag on any platform.** It is a `config.ini` + `[path]` key reached with `-c`. `--dump-data` honours it, and the dump lands + in that directory's own `script-output`, which is what makes a stale dump from + an earlier capture impossible to pick up. +- **`script.on_init` takes exactly one handler.** A prelude registering it is + silently replaced by the consumer's own - no error, the handler simply never + runs. 17 of 18 probes in factorio-blueprint-editor register an `on_init`, so a + prelude must not. `helpers.write_file` works at `control.lua` toplevel with no + event at all, which has no collision surface and does not wait a tick. +- **`serde_json` mis-parses long decimal literals by one ULP**, in both + directions, on values like + `0.394500000000000028421709430404007434844970703125`. The crate uses + `arbitrary_precision` and re-parses through `std`'s `f64::from_str`, which is + correctly rounded and agrees with CPython bit for bit. **Do not remove that.** +- **`preserve_order` must stay off** so `serde_json::Map` is a `BTreeMap` and + output sorts to match Python's `sort_keys=True`. Byte-for-byte determinism is + a hard requirement, because `--check` is a diff. +- **`runtime-api.json` publishes no `defines` values.** Across all 1,554 entries + the only keys are `name`, `order` and `description`. Reading `order` as the + value is right today only because Factorio declares directions clockwise from + `north = 0` with no gaps. Read the real values with a probe instead + (`defines_from: "probe"`). +- **`--dump-data` is byte-identical across architectures.** Same build, macOS + arm64 Steam and Windows x64 standalone both produce sha256 `acc944e6...` from + byte-identical inputs. So the acceptance test is not platform-specific. +- **`--create` needs no map-gen settings file**, and `--map-gen-seed` overrides + a seed inside the settings file. The CLI takes one `seed` and writes both + channels, which makes the precedence irrelevant. +- **`loadedMods` cannot come from the active-mods prelude**, because + `script.active_mods` never reports `core` and the fixtures list it. Grep the + game's stdout for `Loading mod `. + +### Writing Lua for a probe + +- **Factorio 2.1 deleted `LuaEntity.fluidbox` and the whole `LuaFluidBox` + class**, flattening it onto `LuaEntity` as `get_fluid_box_*`. So + `entity.fluidbox.get_pipe_connections(i)` became + `entity.get_fluid_box_pipe_connections(i)`. + `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) + ``` + +- **Run a probe against two versions when you can.** The older one reproduces + the known-good answer, which is what earns trust in the new one. See + `examples/pumpjack-terminals`. + +### Running the game from a shell + +- **`factorio.exe` is a GUI-subsystem binary on Windows.** Launched from + PowerShell it returns immediately, does not wait, and captures no stdout - the + run looks like a 0.03 second success that produced nothing. Use + `Start-Process -Wait -NoNewWindow -PassThru -RedirectStandardOutput`. Rust's + `Command::spawn` plus `wait_with_output` waits correctly, so the tool itself + is fine; this bites shell invocations. +- **Nothing here has a timeout by default in the consumer repos**, which is one + of the things this tool exists to fix. A hung game hangs the capture forever. + +## Sources + +Two kinds, and the difference decides how each may be used. `README.md` has the +full version. + +**Authoritative** sources say what the game accepts, ship with the install, read +offline byte-identically, and are the only things a capture may depend on: +`--dump-data`, `data/*/migrations/*.json`, `doc-html/runtime-api.json`. + +**Reference** sources say *why* something changed, which is what tells you which +captured value now needs review. Nothing automated reads them and nothing +should: `data/changelog.txt`, and the Friday Facts blog. + +Search the blog at `https://factorio.com/blog/search/`. It covers the +whole archive. Word choice matters: `mirroring` does not return FFF #442, the +post that introduced entity mirroring, while `flip` and `fluid` both do. + +## Conventions + +- **Hyphens only.** No em dashes or en dashes, in files or commit messages. +- **Comments carry the reasoning, not the mechanics.** The measured facts above + live beside the code that depends on them, with the measurement and its date. + That is deliberate: a reader who does not know why `preserve_order` is off + will turn it on. +- **Prefer measuring the game over reasoning about it.** If a claim about + Factorio is not backed by a run, say so. +- Do not push or commit unless asked. Branch first if on `main`. diff --git a/README.md b/README.md index 22f55d8..f408060 100644 --- a/README.md +++ b/README.md @@ -58,17 +58,25 @@ Fresh Paint", is the post that explains it, and the same release added `use_mirroring` and `migrate_horizontal_mirroring` to that prototype. Reading it is how you learn the change is about entity mirroring rather than a typo fix. +**Search is the way in**: `https://factorio.com/blog/search/`. It covers +the whole archive, not just recent posts - a search for `mirroring` returns +FFF #80, from 2015. + Practical notes, measured 2026-08-17: -- The feed is Atom, at , served as - `application/atom+xml`. -- It carries the **10 most recent posts only**. It is a what-changed-lately - feed, not an archive, so it cannot answer "what shipped in version X" on its - own. -- Each entry embeds the full post body, so recent posts need no second fetch. -- Older posts live at `https://factorio.com/blog/post/fff-`. -- 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. +- Search returns HTML, not JSON, with about 10 results and no total shown. +- **Word choice matters more than you would expect.** Searching `mirroring` + does *not* return FFF #442, the post that introduced entity mirroring, while + `flip` and `fluid` both do. Try two or three wordings before concluding a post + does not exist. A bare number works too: `search/442`. +- Direct post URLs are `https://factorio.com/blog/post/fff-`. +- There is an Atom feed at , served as + `application/atom+xml`, with each entry embedding the full post body. It + carries the **10 most recent posts only**, so it answers "what changed lately" + rather than "what shipped in version X". Use the search for that. +- The blog 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