DEV Community

onpont
onpont

Posted on

An agent loop needs a save editor that refuses: byte-exact writes and four measured states

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) and 0.7500 survive as written. No float round-trip.
  • BOM, CRLF and comments preserved.
  • A project.godot containing an input map: the Object(InputEventKey, ...) line is unchanged byte for byte, and the project still opens in the editor.
  • --restore --write returns 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 .tmp files left behind. Reading still works while it is locked — inspect --json returned 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.bak identical to the untouched file. When a backup already exists the previous generation is kept correctly, so this only pollutes a fresh save folder.
  • --restore when the file already matches its backup returns exit 4 / write_failed, while the README documents exit 3 for "nothing to restore", and --set no-op returns 0 with no_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 own FileAccess, 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:

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)