RFC-0014: @@[main] — Module-Level Primary System

Prompt Engineer: Mark Truluck mark@frame-lang.org Status: Wave 1 shipped (2026-05-02) Created: 2026-05-02


Motivation

A .fgd source file can declare multiple @@system blocks. Today, the codegen for several targets (notably GDScript and Java) needs to pick one of those systems to be the “primary” — the one users instantiate via the natural module-level convention (preload(file).new() in GDScript, public class in Java, export default in TypeScript). The current rule is implicit: the first system listed in the file is the primary.

That rule has three problems:

  1. It contradicts natural authoring order. Frame source files are usually written primitives-first, composer-last (Ball, BrickField, then Breakout that owns them). The composer is the system users care about — but it’s last, not first. The chapters in frame-arcade all hit this: breakout.fgd ends with @@system Breakout and the driver does preload("breakout.gd").new() expecting a Breakout instance, but framec hands them a Ball. The bug is silent until the driver tries to use a field the wrong system doesn’t have.

  2. It has no semantic justification. “First in the file” is just lexical position — nothing about authorial intent. Re-ordering systems for editorial reasons (move the primitive next to its consumer for readability) silently breaks the calling convention.

  3. It’s invisible in some targets, visible in others. Python users never notice — all module-level classes are equally accessible by name. GDScript and Java users hit it constantly, because their target languages privilege one class per file. Frame shouldn’t bake a target-specific convention into the source semantics.

Adjacent RFCs already use the attribute system (RFC-0013) to express this kind of declarative module-level metadata: @@[target(...)] selects per-target conditional emit, @@[persist] marks systems with serialization. @@[main] extends the same pattern to module-level visibility.

The fix this RFC proposes was discovered while debugging GDScript multi-system emission: the codegen attempted a class_name <FirstSystem> post-pass that turned out not to work in practice (Godot’s inner classes can’t resolve their own script’s class_name from inside their bodies). The attempt revealed that the underlying question — “which system is the primary?” — has no good implicit answer; the only good answer is to ask the developer.


Design — open for discussion

1. Syntax

A new attribute attached to a system declaration:

@@[main]
@@system Breakout : RefCounted {
    domain:
        var ball: Ball = @@Ball()
        var bricks: BrickField = @@BrickField()

    interface:
        ...
}

Bare @@[main] — boolean attribute, no args. Matches the function-call grammar from RFC-0013 §2.

2. Per-target semantics

Target @@[main] system emits as Non-main systems emit as
GDScript Script-level extends X + script-owned _init, fields, methods. Optional class_name <Name> for inter-script use. Inner class: class <Name> extends <Base>: indented one level.
Java public class <Name> (matches filename). Package-private class <Name> in the same .java file.
C# Top-level partial class. internal class <Name> nested under the file’s namespace.
TypeScript export default class <Name> export class <Name>
Python class <Name>(<Base>): at module scope. class <Name>(<Base>): at module scope. No-op — Python has no module-level “primary” idiom.
Rust pub struct <Name> at crate root. pub struct <Name> (same — Rust has no per-file privileged class). May still be no-op.

The pattern: @@[main] only does work in targets where the host language has a “privileged class per file” notion. Targets without that notion treat it as metadata-only; the source remains portable.

3. Resolution rules

Condition Result
Single-system .fgd file Implicit @@[main] on the lone system. No annotation required.
Multi-system .fgd, exactly one @@[main] Use the marked system as the file’s primary.
Multi-system .fgd, no @@[main] E805 (proposed): “multi-system module requires exactly one @@[main]
Multi-system .fgd, multiple @@[main] E806 (proposed): “multi-system module declares multiple @@[main] attributes”

Pre-1.0 hard cut, same playbook as RFC-0013 wave 1+2: the validator rejects under-spec and over-spec at compile time, with a one-line migration message.

4. Cross-system reference rules

@@[main] doesn’t change Frame’s existing @@SystemName(args) instantiation grammar. The only thing it changes is where in the emitted target file the system lives. The generated cross-references resolve as follows:

Reference direction Resolves as Status
Main → non-main (e.g., @@Ball() inside Breakout) Inner class accessible from script-level scope ✓ works in GDScript, Java, C#
Non-main → non-main (e.g., @@BrickField() inside Ball) Sibling inner class ✓ works
Non-main → main (e.g., @@Breakout() inside Ball) Script-level main class — not accessible by bare name from inner scope E807 (proposed): non-main system cannot reference main system

E807’s underlying message: a non-main system referencing the main system is a circular dependency in disguise — primitives shouldn’t reference their composer. Frame catches this at the source level rather than emitting code that fails at the target compiler.

5. Default emission for @@[main]-less single-system files

Unchanged. A .fgd file with one @@system and no @@[main] annotation continues to emit at script-level / public class. This preserves backwards compatibility for the common case (most demos and tutorials are single-system).

6. class_name decision (GDScript)

The GDScript backend should NOT emit class_name <SystemName> by default. Reasons:

  • class_name registers the name in Godot’s project-global namespace. Repeated names across user projects collide.
  • Cross-script imports already work via preload("res://path.gd") without class_name.
  • A future opt-in flag (--gdscript-class-name=on) can add it for users who want global-namespace registration.

class_name is orthogonal to @@[main]@@[main] decides “which system gets the script-level slot,” class_name decides “do we publish that to Godot’s global type registry.” Default off; explicit opt-in.


Migration

For users with multi-system .fgd files

Add @@[main] to the system that drivers should instantiate via .new(). For the frame-arcade chapters:

 @@system Ball : RefCounted { ... }
 @@system BrickField : RefCounted { ... }
+@@[main]
 @@system Breakout : RefCounted { ... }

Mechanical sed is not safe here — author intent is required. The validator’s E805 message tells the user exactly what to do:

E805: 'breakout.fgd' declares 3 systems but no @@[main] attribute.
Add @@[main] above the system that callers should instantiate via the
module's primary entry point. For GDScript this is the system returned
by `preload("breakout.gd").new()`.

For users with single-system .fgd files

No change. The implicit @@[main] rule preserves today’s behavior.

For framec test corpus

Sweep the matrix for multi-system .fgd fixtures. Each one adds @@[main] to its intended primary. For fixtures where the primary was ambiguous in the original test intent (probably few), pick whichever system the runtime assertions instantiate.


Wave plan

Wave 1 — @@[main] on system declarations, GDScript only

Scope:

  • Lexer: @@[main] already tokenizes as a generic attribute (RFC-0013 wave 1 machinery).
  • Parser: attach as Attribute { name: "main", args: None } on SystemAst.
  • Validator: E800 (unknown attribute on non-system positions, already exists); E805/E806 (new — count @@[main] per module).
  • GDScript codegen: refactor the per-system emit pass to use the @@[main] flag instead of “first system in module” as the primary-selection criterion. The existing gdscript_multisys/ assembler module already wraps non-main systems as inner classes — change is which system is “non-main.”
  • Test corpus: add multi-system fixtures with explicit @@[main]. Single-system fixtures unchanged.

Out of scope for Wave 1:

  • Java / C# / TypeScript codegen (separate waves).
  • E807 (cross-reference validation; can land separately).
  • Per-target attribute emission flag (class_name, etc.).

Effort estimate: ~1-2 days (most of the assembler machinery is already in place from the RFC-0013 wave 2 / multi-system extends fix).

Wave 2 — Java + C# + TypeScript

Each target’s per-system emit pass adopts the same “primary = @@[main]-marked or only system” rule. Java and C# need additional changes for public class / nested-class emission. TypeScript: export default vs named export.

Wave 3 — E807 (non-main → main reference validation)

Add the cross-reference walk to the validator. Reports E807 with a hint pointing at the specific instantiation site.


Open questions

  1. Module-level @@[main] syntax: any reason to consider a module-level form instead of system-attached, e.g., @@[main(Breakout)] at the top of the file? Pro: keeps system bodies free of attributes. Con: separates the annotation from the thing being annotated; harder to spot when reading the system definition. Recommendation: stick with system-attached.

  2. Single-system files with explicit @@[main]: allowed (redundant but harmless), or warn (W4xx)? Recommendation: allowed silently — explicit-is-fine principle.

  3. @@[main] interaction with @@[persist]: orthogonal. A system can be both @@[main] and @@[persist]. No special semantics.

  4. Empty multi-system file (zero @@system blocks): not a new concern; existing validation should already reject. No interaction with this RFC.

  5. Per-target opt-out: should @@[main(target="java")] be a thing — i.e., “this system is the main when targeting Java but not when targeting GDScript”? Probably not. If a file has different “main” intents per target, it should be split into per-target files. Adding per-target @@[main] complicates the resolution rules without an obvious motivating use case.


References

  • docs/rfcs/rfc-0013.md — the attribute syntax this RFC builds on.
  • docs/per_language_guides/gdscript.md lines 263–275 — current convention statement (“first as the script’s main class via class_name, the rest as inner class Bar:”) that this RFC supersedes.
  • frame-arcade book demo — the reference user-facing project that motivated this RFC. All 6 multi-system chapters have Breakout/Invaders/Asteroids/GhostGame/ Player/Shooter as the intended primary, all in last position.

Implementation status

  • 2026-05-02 — RFC drafted; awaiting design review.
  • 2026-05-02 — Wave 1 shipped. @@[main] recognized by the segmenter (PragmaKind::Main), threaded through the parser into a generic attributes: Vec<Attribute> field on SystemAst, validated at module scope (E805 zero-main, E806 multi-main), and consumed by the GDScript assembler to select the primary system. The main system’s extends Base line is hoisted to the top of the file so the developer-natural “primitives first, composer last” source order produces a valid script. The earlier class_name post-pass from D22 is reverted — it didn’t actually fix the cross-reference problem (Godot’s inner classes can’t resolve their own script’s class_name) and added namespace pollution. End-to-end repro runs clean in Godot 4.6.2 (got=42).
  • 204 multi-system test fixtures migrated to add @@[main] to the intended primary (the lexically-last system in every case). tests/common/positive/primary/91_main_attr_cross_ref.fgd is the new fixture covering the cross-reference shape that the bug report exposed.