<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: onpont</title>
    <description>The latest articles on DEV Community by onpont (@onpont).</description>
    <link>https://dev.to/onpont</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4207482%2Fd0ce0b6e-c20b-4c37-85e8-fc5c974f04de.png</url>
      <title>DEV Community: onpont</title>
      <link>https://dev.to/onpont</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/onpont"/>
    <language>en</language>
    <item>
      <title>Editing a Godot save file from outside the editor: what actually breaks</title>
      <dc:creator>onpont</dc:creator>
      <pubDate>Mon, 26 Oct 2026 01:08:27 +0000</pubDate>
      <link>https://dev.to/onpont/editing-a-godot-save-file-from-outside-the-editor-what-actually-breaks-4ebk</link>
      <guid>https://dev.to/onpont/editing-a-godot-save-file-from-outside-the-editor-what-actually-breaks-4ebk</guid>
      <description>&lt;p&gt;An agent (Codex, Claude Code, a CI job) can run a shell command, but it cannot click inside the Godot editor. So when the task is "put the game into this state" — &lt;code&gt;progress/stage = 7&lt;/code&gt;, &lt;code&gt;tutorial_done = true&lt;/code&gt;, &lt;code&gt;audio/master = 0.42&lt;/code&gt; — the agent ends up editing the save file directly. The editor itself can do the same thing through a dock; a terminal cannot.&lt;/p&gt;

&lt;p&gt;The naive way is &lt;code&gt;json.load&lt;/code&gt; → change → &lt;code&gt;json.dump&lt;/code&gt;. On a Godot project that quietly changes data you did not ask to change. Below is what actually moved in the files I measured, and what an edit has to preserve to be safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Numbers in a Godot save are not "numbers"
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;JSON.parse_string()&lt;/code&gt; returns every JSON number as a float. A test file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"big_int"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;9007199254740993&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"float_one"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1e10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"neg_zero"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;-0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"imprecise"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.30000000000000004&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tiny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;1.0000000000000002e-300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"long_i64"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;9223372036854775807&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any round trip through a float parser turns &lt;code&gt;1.0&lt;/code&gt; into &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;1e10&lt;/code&gt; into &lt;code&gt;10000000000&lt;/code&gt;, &lt;code&gt;-0.0&lt;/code&gt; into &lt;code&gt;0&lt;/code&gt;, and &lt;code&gt;9007199254740993&lt;/code&gt; (2^53+1) into a neighbour. &lt;code&gt;9223372036854775807&lt;/code&gt; does not survive at all. A game that compares &lt;code&gt;-0.0&lt;/code&gt;, prints a value back, or hashes its own save will notice the difference.&lt;/p&gt;

&lt;p&gt;The safe rule: read numbers as &lt;strong&gt;source literals&lt;/strong&gt; (text), not as floats, and write back the literals you did not touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. ConfigFile is not INI
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ConfigFile.save()&lt;/code&gt; writes sections and &lt;code&gt;;&lt;/code&gt; comments, but if the file is &lt;code&gt;project.godot&lt;/code&gt; with an input map it also contains whole &lt;code&gt;Object(InputEventKey,...)&lt;/code&gt; values. Measured: a 1040-byte &lt;code&gt;project.godot&lt;/code&gt; with two input events. An INI library re-serialises the file without those &lt;code&gt;Object(...)&lt;/code&gt; lines, and the project no longer opens in the editor.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. BOM, CRLF, and the bytes nobody looks at
&lt;/h2&gt;

&lt;p&gt;A 73-byte ConfigFile with a UTF-8 BOM, CRLF line endings and a &lt;code&gt;;&lt;/code&gt; comment: &lt;code&gt;od -c&lt;/code&gt; starts with &lt;code&gt;357 273 277&lt;/code&gt; and every line ends &lt;code&gt;\r\n&lt;/code&gt;. Parsers that normalise newlines or strips the BOM change lines you never selected. Keep them or the diff is a lie.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Binary saves are out of scope
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;var_to_bytes()&lt;/code&gt; files and &lt;code&gt;.res&lt;/code&gt; are not text. An agent should refuse them, not "repair" them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The approach that held up: move the byte span, don't re-serialise
&lt;/h2&gt;

&lt;p&gt;The tool I built opens the file with the engine's own parser to get labels and types, records the &lt;strong&gt;byte span of the literal being changed&lt;/strong&gt;, and replaces exactly that span. Everything else is copied through untouched. Measured on the packaged build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ConfigFile, two values changed: 78 → 78 bytes, only the two spans replaced, read-back SHA-256 matched the reported one.&lt;/li&gt;
&lt;li&gt;JSON with an i64 literal and Japanese text: &lt;code&gt;9007199254740993&lt;/code&gt; → &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;"Player One"&lt;/code&gt; → &lt;code&gt;"ニュー 名前"&lt;/code&gt;, rest of the file byte-identical.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;project.godot&lt;/code&gt; with an input map: 1040 → 1038 bytes, both &lt;code&gt;Object(InputEventKey,...)&lt;/code&gt; lines byte-identical afterwards, editor opened the project with no error.&lt;/li&gt;
&lt;li&gt;Same values, &lt;code&gt;--restore --write&lt;/code&gt;: original SHA-256 back, exactly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What an agent needs in order to trust the result
&lt;/h2&gt;

&lt;p&gt;Exit codes that separate the failure modes, so the agent can branch instead of guessing from prose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;0&lt;/code&gt; wrote (or dry run OK), &lt;code&gt;1&lt;/code&gt; usage / relative path, &lt;code&gt;2&lt;/code&gt; missing or unreadable (binary) file, &lt;code&gt;3&lt;/code&gt; label missing, &lt;code&gt;null&lt;/code&gt; leaf, or a type change, &lt;code&gt;4&lt;/code&gt; file is read-only.&lt;/li&gt;
&lt;li&gt;Dry run by default. It prints the SHA-256 of the bytes it &lt;em&gt;would&lt;/em&gt; write; &lt;code&gt;--write&lt;/code&gt; then produces exactly those bytes. I checked that equality on Godot 4.4.1, 4.5.2, 4.6.3 and 4.7.2 with files that each engine wrote itself.&lt;/li&gt;
&lt;li&gt;A backup (&lt;code&gt;.sve.bak&lt;/code&gt;) only on a real write, and no second backup when re-running the same edit (&lt;code&gt;no_change&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--json&lt;/code&gt; output. Note: the engine banner is the first line of stdout, so read the &lt;strong&gt;last&lt;/strong&gt; line, not the whole output. &lt;code&gt;--quiet&lt;/code&gt; is not a workaround because it takes the JSON with it.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;godot &lt;span class="nt"&gt;--headless&lt;/span&gt; &lt;span class="nt"&gt;--path&lt;/span&gt; &amp;lt;project&amp;gt; &lt;span class="nt"&gt;--script&lt;/span&gt; res://addons/save_value_editor/edit.gd &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;user://settings.cfg &lt;span class="nt"&gt;--set&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;audio/master&lt;span class="o"&gt;=&lt;/span&gt;0.42 &lt;span class="nt"&gt;--set&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;progress/stage&lt;span class="o"&gt;=&lt;/span&gt;7 &lt;span class="nt"&gt;--write&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Limits I would hold against it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Windows only, standard (non-.NET) builds only. macOS, Linux and .NET were not run.&lt;/li&gt;
&lt;li&gt;8 MiB file / 20,000 entry limits in the document layer; not re-derived in the agent path.&lt;/li&gt;
&lt;li&gt;A concurrent writer is refused by comparing the on-disk bytes with the opened snapshot, but two writers in the same instant are still unsafe.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--set&lt;/code&gt; writes the literal as given, so a string needs its own quotes. That is deliberate (no number reformatting) and it is the one place where the agent has to know the format.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you would rather click
&lt;/h2&gt;

&lt;p&gt;The same document code runs as a dock inside the editor, Godot 4.4+ — read a value list, filter it, change a value, see the diff before saving: &lt;a href="https://onpont.itch.io/save-value-editor" rel="noopener noreferrer"&gt;Save Value Editor for Godot&lt;/a&gt;. The command/exit-code contract and its limits are public here: &lt;a href="https://github.com/onpont/godot-save-value-cli" rel="noopener noreferrer"&gt;onpont/godot-save-value-cli&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>tooling</category>
      <category>ai</category>
      <category>godot</category>
      <category>gamedev</category>
    </item>
  </channel>
</rss>
