Stay byte-compatible with Godot
Writing a scene Godot can open is not hard. Writing the scene Godot itself would have written is harder, and it is what keeps generated files out of your diffs as noise.
What save preparation does
Every write goes through the same save path unless you pass --no-prepare-save. It repairs and renumbers ids that do not match the editor's shape, sorts ext_resource entries, recounts load_steps, and orders sections so resources come before nodes and parents before children.
godot-cli scene normalize scenes/main.tscn --output scenes/main.tscn \
--project-root . --resource-path res://scenes/main.tscn --godot-save-format
--godot-save-format aims for byte-identical output against what the editor would write for the same content. --normalize-properties goes further and rewrites every property value through the Variant parser, so float formatting and class aliases end up consistent across a file that several tools have edited.
Ids, and why seeding matters
Godot generates ext and sub resource ids from a per-file seed. Two runs over the same scene will not naturally produce the same ids, which shows up as a diff with no content change.
--project-root and --resource-path give the writer the res:// path to seed from. On top of that, the id session cache at .godot/scene_id_cache.json remembers which id was used for which referrer, so repeated edits keep the ids the editor last wrote.
To adopt the ids from a scene Godot just saved:
godot-cli uid session import --referrer res://scenes/main.tscn \
--from scenes/main_godot_saved.tscn --project-root .
Resource UIDs are separate: uid:// values come from a hash of the resource path, implemented from core/io/resource_uid.cpp.
godot-cli uid encode 1350303725746704497
godot-cli uid decode uid://tidkmw585t0t
godot-cli uid create-for-path --project-name MyGame --resource-path res://main.tscn main.tscn
godot-cli uid cache list --project-root .
.godot/uid_cache.bin is Godot's own index of UIDs to paths. godot-cli reads it to resolve references, to detect a stale uid://, and to repair a catalog manifest whose scene moved.
Check against the editor
The strongest check is a comparison with a file Godot wrote:
godot-cli scene compare-godot scenes/main.tscn scenes/main_godot_saved.tscn --json
{ "matches_godot_save": true, "summary": "matches: scenes/main.tscn vs Godot reference scenes/main_godot_saved.tscn" }
scene round-trip <path> --dry-run is the weaker, faster check: parse the file, write it back, parse again, and confirm the structure survived.
The project runs the strong version in CI, once per Godot version in a matrix. Godot saves a fixture scene headless, godot-cli normalizes the same scene, and the two files are compared with cmp. That test is why the parser follows Godot's source rather than a description of it: src/godot/hash.zig comes from core/templates/hashfuncs.h and core/string/ustring.cpp, src/godot/resource_uid.zig from core/io/resource_uid.cpp, and Variant text from core/variant/variant_parser.cpp, with a line map in src/godot/variant/godot_ref.zig pointing at the functions each rule came from.
Variant values
Property values are parsed into typed values, not carried around as strings, which is what makes scene inspect useful and what keeps 16.0 on a property while a component of the same value is written 2, matching the editor in both places. The parser covers booleans and numbers, strings and StringNames, vectors, rects, transforms, colors, node paths, Object(...) bodies, arrays and dictionaries including typed ones, packed arrays including base64 PackedByteArray, and ext or sub resource references.
Anything it cannot parse is preserved verbatim and reported with parse_error instead of being rewritten, so an unknown construct survives an edit untouched.
Version support
Godot 4.6 is the minimum. Every [node] line godot-cli writes carries a
unique_id, which the engine added in 4.6 (faddd60c40, first released in 4.6-stable) to support
refactoring base and instantiated scenes. Earlier 4.x releases never wrote that
field and are not supported.
Why a script's ProjectSettings.save() looks different
Calling ProjectSettings.save() from a GDScript can rewrite the [input]
section with each event property on its own line and a space after the colon.
That is Godot's Dictionary writer, not its object writer: the engine writes
an Object(...) inline, with no spaces, which is the form godot-cli produces
and the form the editor saves. The two parse identically — same events, same
values — so a section that looks reformatted is not a fidelity problem, and
matching the dictionary shape would move godot-cli away from what the editor
writes.
The round-trip suite runs against Godot 4.7 and 4.7.2, which must pass, and against the newest 4.8 prerelease, which is reported but does not block a push. It passes on all three today. Text scene format 3 is what Godot 4 writes. Binary .scn and .res files are not supported.