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:
-
It contradicts natural authoring order. Frame source files are usually written primitives-first, composer-last (
Ball,BrickField, thenBreakoutthat owns them). The composer is the system users care about — but it’s last, not first. The chapters inframe-arcadeall hit this:breakout.fgdends with@@system Breakoutand the driver doespreload("breakout.gd").new()expecting aBreakoutinstance, but framec hands them aBall. The bug is silent until the driver tries to use a field the wrong system doesn’t have. -
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.
-
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_nameregisters the name in Godot’s project-global namespace. Repeated names across user projects collide.- Cross-script imports already work via
preload("res://path.gd")withoutclass_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 }onSystemAst. - 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 existinggdscript_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
-
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. -
Single-system files with explicit
@@[main]: allowed (redundant but harmless), or warn (W4xx)? Recommendation: allowed silently — explicit-is-fine principle. -
@@[main]interaction with@@[persist]: orthogonal. A system can be both@@[main]and@@[persist]. No special semantics. -
Empty multi-system file (zero
@@systemblocks): not a new concern; existing validation should already reject. No interaction with this RFC. -
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.mdlines 263–275 — current convention statement (“first as the script’s main class viaclass_name, the rest as innerclass Bar:”) that this RFC supersedes.frame-arcadebook demo — the reference user-facing project that motivated this RFC. All 6 multi-system chapters haveBreakout/Invaders/Asteroids/GhostGame/Player/Shooteras 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 genericattributes: Vec<Attribute>field onSystemAst, validated at module scope (E805 zero-main, E806 multi-main), and consumed by the GDScript assembler to select the primary system. The main system’sextends Baseline is hoisted to the top of the file so the developer-natural “primitives first, composer last” source order produces a valid script. The earlierclass_namepost-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.fgdis the new fixture covering the cross-reference shape that the bug report exposed.