Transpiler: support closure structs from local functions capturing variables#280
Conversation
When local functions capture outer variables, the C# compiler generates closure structs (DisplayClass). Instead of throwing TranspileException, the transpiler now: - Detects closure fields as byte[] (ROM data) or scalar (zero-page) - Maps byte[] fields to ROM data labels via ldtoken flow - Allocates shared zero-page addresses for scalar fields - Handles ldarg.0 + ldfld in closure methods for field access - Skips ldloca.s before calls to closure functions - Adjusts parameter counts for closure-capturing methods Co-authored-by: jonathanpeppers <840039+jonathanpeppers@users.noreply.github.com>
Co-authored-by: jonathanpeppers <840039+jonathanpeppers@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds closure (DisplayClass) support to the dotnes transpiler so local functions that capture outer variables can be transpiled instead of throwing, enabling more idiomatic C# samples (e.g., captured byte[] palette tables and captured scalar values).
Changes:
- Detect and catalog compiler-generated DisplayClass fields and identify closure methods / closure local in main.
- Pre-allocate RAM addresses for captured scalar fields and map captured
byte[]fields to ROM data labels. - Update IL→6502 emission to treat
ldarg.0/ldloca.sclosure patterns specially and to emitldfld/stfldagainst the mapped closure storage. - Update local frame estimation to avoid counting closure struct locals/fields, and update tests to validate closure compilation.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/dotnes.tests/RoslynTests.cs | Replaces the “closure throws” test with positive tests asserting closure byte[] + scalar captures transpile into ROM bytes/opcodes. |
| src/dotnes.tasks/Utilities/Transpiler.StructAnalysis.cs | Detects DisplayClass types, records closure field sizes, detects closure local + closure methods, and allocates closure-field storage. |
| src/dotnes.tasks/Utilities/Transpiler.LocalFrameAllocation.cs | Skips counting closure locals/fields when estimating per-method frame sizes. |
| src/dotnes.tasks/Utilities/Transpiler.cs | Wires closure detection + allocation into BuildProgram6502, passes closure maps into IL2NESWriter instances. |
| src/dotnes.tasks/Utilities/IL2NESWriter.StructFields.cs | Implements closure-aware ldfld/stfld to mapped addresses/labels. |
| src/dotnes.tasks/Utilities/IL2NESWriter.ILDispatch.cs | Adjusts ldarg.* behavior for closure methods and skips certain closure ldloca.s patterns. |
| src/dotnes.tasks/Utilities/IL2NESWriter.cs | Adds closure-related configuration/flags and internal state to support the new IL patterns. |
…t first - DetectClosureMethods now scans for any ldarg + ldfld pattern, not just ldarg.0 - Records per-method closure arg index instead of assuming arg 0 - Real params keep original indices (no shifting needed) - ldloca_s handler scans forward to distinguish init vs call patterns - HandleLdfld throws on missing byte[] label instead of silent failure - Added ClosureMethodWithRealParams test for multi-param closure methods Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…re-structs # Conflicts: # src/dotnes.tasks/Utilities/Transpiler.StructAnalysis.cs
jonathanpeppers
left a comment
There was a problem hiding this comment.
🤖 Reviewed and iterated on this PR. The original implementation from the Copilot agent was solid — I made the following fixes:
Critical fix: Closure parameter ordering
Roslyn places the closure struct ref as the last IL parameter, not the first. For a 0-param closure, arg 0 happens to be the closure ref (only param), but for N-param closures, arg N is the closure ref and args 0..N-1 are the real params.
Changes:
DetectClosureMethodsnow scans for anyldarg + ldfld closureFieldpattern (not justldarg.0) and records the per-method closure arg index- Real params keep their original indices — no shifting needed
isArrayParamadjustment removes the last element (closure ref) instead of the firstldloca_shandler scans forward to distinguish closure field init (stfld) from method callsHandleLdfldnow throws on missing byte[] label instead of silently producing wrong code- Added
ClosureMethodWithRealParamstest covering multi-param closure methods
All 547 tests pass. CI is green.
- DetectStructLayouts throws on closure field name collisions across multiple DisplayClass types (full qualified-name keying deferred since IL parsing produces simple names from FieldDefinition tokens) - HandleStfld: word-size closure scalars emit STA+STX (low/high bytes) - HandleLdfld: word-size closure scalars emit LDA+LDX and set _ushortInAX Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When local functions capture outer variables, the C# compiler generates
DisplayClassclosure structs. The transpiler threwTranspileExceptiononstfld/ldfldfor these types, blocking any sample using non-static local functions with captured variables.Closure detection (
Transpiler.StructAnalysis.cs)DetectStructLayouts()catalogs DisplayClass fields instead of throwing — byte[] fields (size -1) vs scalarsDetectClosureStructLocal()finds which main local holds the closure struct by scanning forldloca.s N … stfld closureFieldDetectClosureMethods()identifies user methods accessing closure fields vialdarg.0 + ldfld, adjusts their param count down by 1Address allocation (
Transpiler.cs)PreAllocateClosureFields(), allocated alongside static fieldsIL2NESWriterinstancesIL dispatch (
IL2NESWriter.ILDispatch.cs)ldarg.0in closure methods sets_pendingClosureAccessflag instead of normal arg handling;ldarg.1+shifted down by 1ldloca.s Nbefore acallwhen N is the closure struct local → skip (closure ref is implicit)Field access (
IL2NESWriter.StructFields.cs)HandleStfld: closure byte[] fields associate_lastByteArrayLabel; scalar fields emitSTAto pre-allocated addressHandleLdfld: closure byte[] fields emitLDA #lo / LDX #hi(label); scalar fields emitLDAfrom addressFrame allocation (
Transpiler.LocalFrameAllocation.cs)EstimateMethodLocalBytesskips closure struct locals and closure fields (pre-allocated separately)Original prompt
⚡ Quickly spin up Copilot coding agent tasks from anywhere on your macOS or Windows machine with Raycast.