ADR-0010: Engine code standards and test tooling
- Status: Accepted (2026-09-29)
- Date: 2026-09-29
Context
The brief asks for statically typed GDScript, gdUnit4 or GUT, golden-frame tests, lint in CI, and a CLI. What we verified on Godot 4.7.2:
- gdUnit4 6.2.1
- Runs headless.
- Writes HTML and JUnit reports.
- Has meaningful exit codes: 0 pass, 100 failures, 101 warnings, 105 script errors.
- Has an official GitHub Action that runs under xvfb with real rendering.
- GUT 9.7.1 works, but needs its
godot_4_7branch and has no official Action. - Neither framework compares images.
Image.compute_image_metricsprovides the primitives. - Warnings as errors work, with two catches. Since 4.6,
directory_rulesexemptsres://addonsby default, including our own addon. And--check-onlyexits 0 on parse errors on macOS. - gdtoolkit 4.5.0 parses 4.7 syntax but has had no commits for a year. The GDQuest GDScript formatter (0.27, Rust) is active and has a lint mode.
- The CLI entrypoint must be a scene. A script run with
-scannot reference autoloads. - 2D IK is still experimental in 4.7, with open bugs.
Decision
- Typed GDScript, enforced. In
project.godot:untyped_declaration,unsafe_property_access,unsafe_method_access,unsafe_castandunsafe_call_argumentare errors;return_value_discardedis a warning;directory_rulessets{"res://addons/reelwright": 1, "res://addons": 0}so our addon is checked and vendored addons are not.
- Tests: gdUnit4 (vendored, pinned) and golden-frame helpers of our own (metrics plus diff images written on failure). Core tests use Vitest.
- Format and lint: the GDQuest formatter (
--checkin CI) plus a script-load check that fails on anySCRIPT ERRORin the output rather than trusting exit codes. - CLI entrypoint:
godot --path engine res://cli/cli.tscn -- <command> …, which readsOS.get_cmdline_user_args()and exits withget_tree().quit(code). The user never types this;reelwrightdoes. - Rig code:
- two-bone IK written in GDScript;
Bone2D.restset explicitly;- automatic bone length turned off;
- skin weights written for every vertex, internal ones included.
- Animation: Animation resources built from the Timeline, played through
AnimationMixer in manual mode with
DISCRETE_FORCE_CONTINUOUS, so random-accessseek()is exact. A purity test renders frame N by seeking and by playing through, and requires identical results. - Naming:
class_name Rw*, one class per file, snake_case files, no autoload state in runtime code.
Consequences
- Warnings as errors only apply in debug builds, which is where tests and renders run.
- The first engine PR carries more scaffolding (vendored gdUnit4, project settings, golden helpers), and every later PR benefits.
Addendum (2026-09-29): anti-aliasing and history-free rendering
Decided with the M0 engine foundation.
- MSAA 4× for 2D and 3D (
rendering/anti_aliasing/quality/msaa_2dandmsaa_3d= 2).Polygon2D.antialiaseddoes nothing in 4.7.2, so vector parts and Line2D ink are smooth only with 2D MSAA. 3D MSAA smooths set geometry and, with alpha-to-coverage, the alpha-scissored edges of character cards. MSAA resolves inside one frame, so frame N never depends on frame N−1. 4× is the usual cost/quality point, and it matters under lavapipe's software rendering; raise it only if golden frames show a need. - No screen-space AA (FXAA/SMAA): it softens ink lines and flat color edges.
- Nothing that depends on earlier frames: no TAA, no temporal upscalers (FSR 2,
MetalFX temporal), no SDFGI, no auto exposure, no volumetric fog temporal reprojection,
no GPU particles. Each would make a frame depend on the frames drawn before it, which
breaks
evaluate(frame)purity and chunked renders. In 4.7.2 TAA also turns on camera jitter and per-draw shadow-filter noise (taa_frame_count); with it off, both are fixed. - SubViewports do not inherit the project MSAA values.
RwFrameServer.apply_quality_settingscopies them onto every viewport the frame server or a stage creates, so the frame server and the Movie Maker path (root viewport) render alike. 2D MSAA is set only on viewports that draw 2D; on the 3D stage viewport it only adds a resolve pass. - The frame server calls
force_drawwith a frame step of 0, so shaderTIMEnever advances. Shaders read the frame from a uniform thatevaluate()sets.
Addendum (2026-10-10): const typed containers of packed arrays
In 4.7.2 a const Dictionary[K, Packed…Array] or Array[Packed…Array] keeps the array
literals of its values as Arrays (a packed array's constructor is not a constant expression,
so a literal is the only way to write one). A read by a key or index known only at run time,
var words: PackedStringArray = WORDS[view], then puts that Array in the typed variable
unconverted, and its size() and items read memory that is not a PackedStringArray's: the
grid-or-list buttons were named from 24 empty words. for row: PackedStringArray in ROWS does
the same. A literal key (WORDS["grid"], folded when the script compiles) and .get(key)
convert, as any Variant read into a typed variable does. The rule: read such a dictionary with
.get(), never [...], and declare no const typed Array of packed arrays.
engine/tests/util/rw_godot_gotchas_test.gd pins the behaviour by typeof() (safe to look at),
so it fails once Godot converts, and scans the engine's code for any [...] read of such a
const. Minimal repro:
extends SceneTree
const WORDS: Dictionary[String, PackedStringArray] = { "k": ["a", "b", "c"] }
func _init() -> void:
var key: String = "k"
var read: PackedStringArray = WORDS[key]
print(typeof(read) == TYPE_ARRAY, " ", read.size()) # true 24 (garbage)
var got: PackedStringArray = WORDS.get(key)
print(typeof(got) == TYPE_PACKED_STRING_ARRAY, " ", got.size()) # true 3
quit()