Skip to content

Transpiler: support closure structs from local functions capturing variables#280

Merged
jonathanpeppers merged 6 commits intomainfrom
copilot/support-closure-structs
Mar 20, 2026
Merged

Transpiler: support closure structs from local functions capturing variables#280
jonathanpeppers merged 6 commits intomainfrom
copilot/support-closure-structs

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 20, 2026

When local functions capture outer variables, the C# compiler generates DisplayClass closure structs. The transpiler threw TranspileException on stfld/ldfld for these types, blocking any sample using non-static local functions with captured variables.

// Previously failed — now works
byte[] palette = [0x0F, 0x10, 0x20, 0x30];
byte color = 0x15;
apply_palette();
ppu_on_all();
while (true) ;

void apply_palette()
{
    pal_bg(palette);      // captured byte[] → ROM data label
    pal_col(0, color);    // captured scalar → zero-page address
}

Closure detection (Transpiler.StructAnalysis.cs)

  • DetectStructLayouts() catalogs DisplayClass fields instead of throwing — byte[] fields (size -1) vs scalars
  • DetectClosureStructLocal() finds which main local holds the closure struct by scanning for ldloca.s N … stfld closureField
  • DetectClosureMethods() identifies user methods accessing closure fields via ldarg.0 + ldfld, adjusts their param count down by 1

Address allocation (Transpiler.cs)

  • Scalar closure fields get shared zero-page addresses via PreAllocateClosureFields(), allocated alongside static fields
  • Byte[] closure fields use ROM data labels (no RAM allocation needed)
  • Closure info passed to both main and user method IL2NESWriter instances

IL dispatch (IL2NESWriter.ILDispatch.cs)

  • ldarg.0 in closure methods sets _pendingClosureAccess flag instead of normal arg handling; ldarg.1+ shifted down by 1
  • ldloca.s N before a call when N is the closure struct local → skip (closure ref is implicit)

Field access (IL2NESWriter.StructFields.cs)

  • HandleStfld: closure byte[] fields associate _lastByteArrayLabel; scalar fields emit STA to pre-allocated address
  • HandleLdfld: closure byte[] fields emit LDA #lo / LDX #hi (label); scalar fields emit LDA from address

Frame allocation (Transpiler.LocalFrameAllocation.cs)

  • EstimateMethodLocalBytes skips closure struct locals and closure fields (pre-allocated separately)
Original prompt

This section details on the original issue you should resolve

<issue_title>Transpiler: support closure structs from local functions capturing variables</issue_title>
<issue_description>## Problem

When local functions in top-level statements reference outer byte[] variables, the C# compiler generates closure structs (display classes). The transpiler encounters stfld/ldfld on these compiler-generated types and throws TranspileException.

Closures are not supported. The compiler generated a closure struct
'<>c__DisplayClass0_0' capturing variable(s): collision_top_left, ...

This blocks the crypto sample (PR #211) which has 7 local functions capturing ~120 byte[] arrays.

Expected behavior

The transpiler should handle closure structs by recognizing that captured byte[] arrays are ROM data tables (same as top-level locals) and captured scalar variables map to zero-page addresses. The ldfld/stfld on the display class should resolve to the same addresses as the original variables.

Workaround

Inline all local functions that capture variables, eliminating closures entirely. This works but loses code structure and causes duplication for functions called from multiple sites (e.g., isBlocked has 9 call sites).

Implementation

  1. In DetectStructLayouts(), recognize compiler-generated display classes (<>c__DisplayClass*) as closure structs rather than throwing
  2. Map each captured field back to its original variable's transpiler allocation (ROM table address for byte[], zero-page address for scalars)
  3. Emit ldfld/stfld on closure fields as regular LDA/STA to the mapped addresses
  4. Handle the implicit this parameter (ldarg.0 + ldfld pattern) that the compiler uses to access captured variables inside local functions

Context

Comments on the Issue (you are @copilot in this section)


⚡ Quickly spin up Copilot coding agent tasks from anywhere on your macOS or Windows machine with Raycast.

Copilot AI and others added 2 commits March 20, 2026 01:02
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>
Copilot AI changed the title [WIP] Fix transpiler to support closure structs from local functions Transpiler: support closure structs from local functions capturing variables Mar 20, 2026
Copilot AI requested a review from jonathanpeppers March 20, 2026 01:08
@jonathanpeppers jonathanpeppers marked this pull request as ready for review March 20, 2026 01:09
Copilot AI review requested due to automatic review settings March 20, 2026 01:09
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.s closure patterns specially and to emit ldfld/stfld against 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.

jonathanpeppers and others added 2 commits March 19, 2026 20:34
…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
Copy link
Copy Markdown
Owner

@jonathanpeppers jonathanpeppers left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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:

  • DetectClosureMethods now scans for any ldarg + ldfld closureField pattern (not just ldarg.0) and records the per-method closure arg index
  • Real params keep their original indices — no shifting needed
  • isArrayParam adjustment removes the last element (closure ref) instead of the first
  • ldloca_s handler scans forward to distinguish closure field init (stfld) from method calls
  • HandleLdfld now throws on missing byte[] label instead of silently producing wrong code
  • Added ClosureMethodWithRealParams test 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>
@jonathanpeppers jonathanpeppers merged commit 6830e6d into main Mar 20, 2026
1 check passed
@jonathanpeppers jonathanpeppers deleted the copilot/support-closure-structs branch March 20, 2026 02:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transpiler: support closure structs from local functions capturing variables

3 participants