Contents

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_7 branch and has no official Action.
  • Neither framework compares images. Image.compute_image_metrics provides the primitives.
  • Warnings as errors work, with two catches. Since 4.6, directory_rules exempts res://addons by default, including our own addon. And --check-only exits 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 -s cannot 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_cast and unsafe_call_argument are errors;
    • return_value_discarded is a warning;
    • directory_rules sets {"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 (--check in CI) plus a script-load check that fails on any SCRIPT ERROR in the output rather than trusting exit codes.
  • CLI entrypoint: godot --path engine res://cli/cli.tscn -- <command> …, which reads OS.get_cmdline_user_args() and exits with get_tree().quit(code). The user never types this; reelwright does.
  • Rig code:
    • two-bone IK written in GDScript;
    • Bone2D.rest set 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-access seek() 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_2d and msaa_3d = 2). Polygon2D.antialiased does 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_settings copies 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_draw with a frame step of 0, so shader TIME never advances. Shaders read the frame from a uniform that evaluate() 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()