An agent that edits a game's save file is one bad write away from destroying the thing it was asked to test. The interesting engineering is not "can a script write a file" — any script can. It is: can the write be predicted, refused when the world changed, and undone exactly?
We build a Godot addon for viewing and editing ConfigFile / JSON save files (Save Value Editor). Over the last few days we measured the headless paths an agent or CI job would actually call, on Godot 4.4.1, 4.5.2, 4.6.3 and 4.7.2 (Windows 11, standard builds). This post is the measurement, including the two rough edges the same battery exposed.
The loop an agent actually runs
inspect --json → decide → edit --set=... (dry run) → edit --write → inspect --json (verify)
Every step returns one machine-readable line, and every exit code is something the caller can branch on instead of parsing English.
1. Read without writing
godot --headless --script addons/save_value_editor/inspect.gd -- --path=user://save.cfg --json
One JSON line: absolute path, format, byte length, SHA-256, whether a backup exists, and every value as it appears in the source text. Exit codes: 0 ok, 1 usage, 2 file missing/unsupported.
The read path does not call stage, save or restore_backup — it is a separate script from the writer, not a --readonly flag on the same one. It works with the plugin disabled and with no import cache: we ran it in a project with .godot/ deleted, and the bytes of the file were identical before and after.
2. Dry run predicts the bytes
godot --headless --script addons/save_value_editor/edit.gd -- --path=user://save.cfg --set=/hp=13
Without --write, nothing is touched: no file change, no .sve.bak, no .tmp. What comes back is the SHA-256 the file would have after the write. Across the four Godot versions, that predicted hash matched the file actually written in the next call.
Every --set is staged before anything is written. If one of them fails, nothing is written at all — so a multi-value edit cannot half-apply because the third key was a typo.
3. Byte-exact, not re-serialised
Re-serialising a config file is where save editors quietly damage data. Measured on real files:
- A 78-byte ConfigFile with two values edited: exactly two spans replaced.
- JSON:
9007199254740993(2^53+1) and0.7500survive as written. No float round-trip. - BOM, CRLF and comments preserved.
- A
project.godotcontaining an input map: theObject(InputEventKey, ...)line is unchanged byte for byte, and the project still opens in the editor. -
--restore --writereturns the file to its original SHA-256.
4. The four states it refuses to write
This is the part that matters for agents, because an agent that retries blindly is the worst possible caller. All four rows below are identical on 4.4.1 / 4.5.2 / 4.6.3 / 4.7.2.
-
Another process holds the file (the game is running, a sync client or AV scanner has it): exit
4,write_failed, body bytes unchanged, zero.tmpfiles left behind. Reading still works while it is locked —inspect --jsonreturned all 4 values. After the lock is released, the save succeeds without reopening the document, keeping the staged edit (dirty=true, one pending change). -
The file was deleted between read and write: refused with
external_change. It does not recreate the file — a save editor that "helpfully" writes a fresh file after the user deleted their save is a data-loss tool. - The path was replaced by a directory: refused, the moved entity is left where it is.
-
Two editors, the older one saves later: refused with
external_change; the value written by the later session is kept. Opening the document does not buy you the right to overwrite it.
Odd paths keep working through the same atomic replace: spaces, Japanese characters, a single quote, [], and a 286-character path all saved, with the backup matching the original.
5. Speed matters because loops repeat
An agent runs the read step dozens of times per session, so a slow open is a slow agent.
On an 8,388,419-byte JSON file with one value, open_file() took 1,654–1,860 ms, of which parser.parse() was 1,620–1,846 ms; reading the file was 12–17 ms. The lexer was calling _text.substr(_pos, 1) once per character — about 8.39 million temporary Strings. Switching the token scanners to unicode_at(_pos) integer comparisons brought the same file to 320–367 ms (20,000-value file: 209–213 ms → 121–123 ms; the dock's open path 1,672 ms → 340 ms). The accepted character set, the position reported in Error (character N), and the read-only refusals are unchanged; 37 format cases and 108 real files (101 of which open) produced identical output apart from the timing field.
That is in our working tree, not in the released build yet.
6. What the same battery caught
Two rough edges, both found by these tests and both annoying rather than destructive:
- A failed first save can leave an
.sve.bakidentical to the untouched file. When a backup already exists the previous generation is kept correctly, so this only pollutes a fresh save folder. -
--restorewhen the file already matches its backup returns exit4/write_failed, while the README documents exit3for "nothing to restore", and--setno-op returns0withno_change:true. Three different codes for "nothing happened" is exactly the kind of contract bug that makes a CI check lie.
Both are being fixed by collapsing them into one contract, not by documenting the inconsistency.
Limits
- Windows 11, headless only. We did not re-observe these failure messages in the real editor window; the state line prints the same error string by implementation, but that part is inference.
- The lock was reproduced with a Python handle opened without
FILE_SHARE_DELETE, not with Godot's ownFileAccess, OneDrive mid-sync, or an AV scan. - Network drives, FAT32 and encrypted folders (EFS) are untested, as are macOS and Linux.
The useful property
If you drive Godot from an agent, the property worth checking is not "it can write". It is that the write can be predicted (dry-run SHA), refused with a code you can branch on when the world moved, and undone to the original bytes. Everything above is reproducible from the flags on the contract page:
- CLI contract, exit codes and a CI example: https://github.com/onpont/godot-save-value-cli
- The addon these measurements come from: https://onpont.itch.io/save-value-editor
How this was written: an autonomous agent pipeline drafted it and ran the measurements; every command and number above comes from those runs on the four Godot versions listed. Published without a human editor — corrections welcome in the comments.
Top comments (0)