Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,17 @@ The Transpile target automatically creates `.nes` from `.dll` + `*.s` files.
**⚠️ When adding new public MSBuild properties**, always update [docs/msbuild-properties.md](docs/msbuild-properties.md) with the property name, type, default value, description, and an XML example.

## Testing Patterns
Tests in [src/dotnes.tests/](src/dotnes.tests/) use **Verify snapshots**:
Tests in [src/dotnes.tests/](src/dotnes.tests/) use **Verify snapshots** and **Roslyn-based** tests:
- Test data DLLs live in `Data/` folder (pre-compiled debug/release)
- `TranspilerTests.Write` verifies entire ROM output byte-for-byte
- `TranspilerTests.ReadStaticVoidMain` verifies IL parsing
- `RoslynTests` compile C# source at test time via Roslyn and assert on emitted 6502 bytes

**⚠️ CRITICAL: The `.verified.bin` files are the source of truth for existing samples. Any code change that causes `TranspilerTests.Write` to produce different bytes for an unchanged sample is WRONG — fix the code, not the verified file. When adding or modifying a sample (e.g., changing `Program.cs`), rebuild its test DLLs and update the verified.bin to match.**

**Adding new test cases:** Compile sample code, copy `.dll` to `Data/`, add `[InlineData("name", true/false)]`.
**⚠️ Prefer RoslynTests for new transpiler features.** When adding or testing new IL opcode support, write `RoslynTests` instead of creating new `samples/` directories and pre-compiled DLLs. RoslynTests are self-contained (C# source + assertions in one test method), don't require pre-compiled DLLs, and are easier to maintain. Use `GetProgramBytes(source)` to compile and transpile, then assert on the hex output with `Assert.Contains`. Only create new samples for features that need to be run in an emulator or showcased as standalone projects.

**Adding new test cases (legacy):** Compile sample code, copy `.dll` to `Data/`, add `[InlineData("name", true/false)]`.

**Per-sample CHR ROM:** Tests look for `chr_{name}.s` in the Data folder first, falling back to `chr_generic.s`. The music sample uses an empty CHR (no graphics).

Expand All @@ -95,7 +98,7 @@ Tests in [src/dotnes.tests/](src/dotnes.tests/) use **Verify snapshots**:
## Adding IL Opcode Support
1. Add case in `IL2NESWriter.Write(ILInstruction)` switch
2. Emit via `Write(NESInstruction.*, value)`
3. Test with new sample in `Data/`
3. Test with a `RoslynTests` method using `GetProgramBytes` and hex assertions

## 6502 Assembly Basics

Expand Down
12 changes: 7 additions & 5 deletions samples/peekpoke/Program.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/*
Peek-Poke Demo: Direct NES hardware register access.
The screen scrolls using poke() to write the PPU scroll
register directly, and toggles grayscale every ~2 seconds
register directly, and toggles grayscale every ~6 seconds
via ppu_mask(). peek() resets the PPU address latch each frame.
Uses a ushort frame counter (exceeds byte range).

PPU Registers used:
$2001 = PPU_MASK (rendering control)
Expand All @@ -26,7 +27,7 @@ via ppu_mask(). peek() resets the PPU address latch each frame.
ppu_on_all();

byte scroll_x = 0;
byte frame_count = 0;
ushort frame_count = 0;
byte grayscale = 0;

while (true)
Expand All @@ -43,9 +44,10 @@ via ppu_mask(). peek() resets the PPU address latch each frame.

scroll_x = (byte)(scroll_x + 1);

// toggle grayscale every ~120 frames (~2 seconds)
frame_count = (byte)(frame_count + 1);
if (frame_count == 120)
// toggle grayscale every ~360 frames (~6 seconds)
// uses ushort because the count exceeds byte range (255)
frame_count++;
if (frame_count == 360)
{
frame_count = 0;
if (grayscale != 0)
Expand Down
610 changes: 610 additions & 0 deletions src/dotnes.tasks/Utilities/IL2NESWriter.ArrayHandling.cs

Large diffs are not rendered by default.

48 changes: 46 additions & 2 deletions src/dotnes.tasks/Utilities/IL2NESWriter.ILDispatch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -297,20 +297,32 @@ public void Write(ILInstruction instruction)
_lastStaticFieldAddress = null;
break;
case ILOpCode.Stelem_i1:
case ILOpCode.Stelem_i2:
case ILOpCode.Stelem_i4:
case ILOpCode.Stelem_i8:
// stelem.i*(stack: arrayref, index, value → stack: )
// stelem.i1/i4/i8: byte array store (stack: arrayref, index, value → stack: )
HandleStelemI1();
break;
case ILOpCode.Stelem_i2:
// stelem.i2 always represents a 16-bit element store.
HandleStelemI2();
break;
case ILOpCode.Ldind_u1:
// ldind.u1: load byte through pointer (from ldelema System.Byte)
HandleLdindU1();
break;
case ILOpCode.Ldind_u2:
case ILOpCode.Ldind_i2:
// ldind.u2/i2: load ushort through pointer (from ldelema System.UInt16)
HandleLdindU2();
break;
case ILOpCode.Stind_i1:
// stind.i1: store byte through pointer (from ldelema System.Byte)
HandleStindI1();
break;
case ILOpCode.Stind_i2:
// stind.i2: store ushort through pointer (from ldelema System.UInt16)
HandleStindI2();
break;
case ILOpCode.Add:
HandleAddSub(isAdd: true);
break;
Expand Down Expand Up @@ -1098,6 +1110,11 @@ or ILOpCode.Ldc_i4_3 or ILOpCode.Ldc_i4_4 or ILOpCode.Ldc_i4_5
// Pattern: Ldloc_N (array), Ldloc_M (index), Ldelem_u1
HandleLdelemU1();
break;
case ILOpCode.Ldelem_u2:
case ILOpCode.Ldelem_i2:
// ldelem.u2/i2: pop array ref and index, push ushort array[index]
HandleLdelemU2();
break;
case ILOpCode.Endfinally:
{
// End of a finally block — jump to the instruction after the handler,
Expand Down Expand Up @@ -1215,6 +1232,7 @@ public void Write(ILInstruction instruction, int operand)
case ILOpCode.Newarr:
{
bool isStructArray = instruction.String != null && StructLayouts.ContainsKey(instruction.String);
bool isUshortArray = instruction.String is "UInt16";
if (isStructArray)
{
// Struct array: remove the LDA for the count (purely compile-time allocation)
Expand All @@ -1232,6 +1250,32 @@ public void Write(ILInstruction instruction, int operand)
_pendingStructArrayBase = (ushort)(local + LocalCount);
LocalCount += totalBytes;
}
else if (isUshortArray)
{
// Remove the LDA for the count
bool isLdcPrevious = previous == ILOpCode.Ldc_i4_s || previous == ILOpCode.Ldc_i4
|| (previous >= ILOpCode.Ldc_i4_0 && previous <= ILOpCode.Ldc_i4_8);
if (isLdcPrevious)
{
int toRemove = Stack.Count > 0 && Stack.Peek() > byte.MaxValue ? 2 : 1;
RemoveLastInstructions(toRemove);
}

// Check if this is a music note table (newarr; dup; ldtoken; InitializeArray)
// vs a regular ushort array (newarr; dup; ldc; ldc; stelem).
// Only pre-allocate zero-page space for regular arrays.
bool isMusicTable = Instructions != null
&& Index + 2 < Instructions.Length
&& Instructions[Index + 1].OpCode == ILOpCode.Dup
&& Instructions[Index + 2].OpCode == ILOpCode.Ldtoken;

if (!isMusicTable)
{
_pendingUshortArrayCount = Stack.Count > 0 ? Stack.Peek() : 0;
_pendingUshortArrayBase = (ushort)(local + LocalCount);
LocalCount += _pendingUshortArrayCount * 2;
}
}
else if (previous == ILOpCode.Ldc_i4_s || previous == ILOpCode.Ldc_i4
|| (previous >= ILOpCode.Ldc_i4_0 && previous <= ILOpCode.Ldc_i4_8))
{
Expand Down
17 changes: 16 additions & 1 deletion src/dotnes.tasks/Utilities/IL2NESWriter.LocalVariables.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ namespace dotnes;
partial class IL2NESWriter
{
/// <summary>
/// Handles stloc after newarr: allocates a runtime array (byte[] or struct[]).
/// Handles stloc after newarr: allocates a runtime array (byte[], ushort[], or struct[]).
/// For struct arrays, allocates count * structSize bytes and records the struct type.
/// For ushort arrays, allocates count * 2 bytes and marks the local as a word array.
/// </summary>
void HandleStlocAfterNewarr(int localIdx)
{
bool isStructArray = _pendingArrayType != null && StructLayouts.ContainsKey(_pendingArrayType);
bool isUshortArray = _pendingArrayType is "UInt16";
if (isStructArray)
{
int count = _pendingStructArrayCount;
Expand All @@ -31,6 +33,19 @@ void HandleStlocAfterNewarr(int localIdx)
Locals[localIdx] = new Local(count, arrayAddr, ArraySize: totalBytes, StructArrayType: _pendingArrayType);
_pendingStructArrayCount = 0;
_pendingStructArrayBase = null;
_pendingArrayType = null;
}
else if (isUshortArray)
{
int count = _pendingUshortArrayCount;
int totalBytes = count * 2;
ushort arrayAddr = _pendingUshortArrayBase ?? (ushort)(local + LocalCount);
if (_pendingUshortArrayBase == null)
LocalCount += totalBytes;
Locals[localIdx] = new Local(count, arrayAddr, ArraySize: totalBytes, IsWord: true);
_pendingUshortArrayCount = 0;
_pendingUshortArrayBase = null;
_pendingArrayType = null;
}
Comment thread
jonathanpeppers marked this conversation as resolved.
else
{
Expand Down
20 changes: 20 additions & 0 deletions src/dotnes.tasks/Utilities/IL2NESWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ public IL2NESWriter(Stream stream, bool leaveOpen = false, ILogger? logger = nul
string? _pendingArrayType;
int _pendingStructArrayCount;
ushort? _pendingStructArrayBase; // Pre-allocated base address from newarr for struct arrays
int _pendingUshortArrayCount;
ushort? _pendingUshortArrayBase; // Pre-allocated base address from newarr for ushort arrays
readonly Dictionary<string, Local> _staticFieldArrayLocals = new(); // Maps static field names to their array Local entries
ImmutableArray<byte>? _pendingUShortArray;

Expand Down Expand Up @@ -424,6 +426,13 @@ bool _dupPendingSave
/// </summary>
PendingByteArrayElement? _pendingByteArrayElement;

/// <summary>
/// Pending ushort array element access state from ldelema System.UInt16.
/// Null when no ushort array ldelema is pending.
/// Used for compound assignments: arr[i]++, arr[i] += expr, etc.
/// </summary>
PendingUshortArrayElement? _pendingUshortArrayElement;

/// <summary>
/// State for a pending struct array element access (from ldelema).
/// </summary>
Expand All @@ -446,6 +455,17 @@ readonly record struct PendingByteArrayElement(
ushort? ConstantElementAddress
);

/// <summary>
/// State for a pending ushort array element access (from ldelema System.UInt16).
/// For variable-index access, Y holds the byte offset (index * 2).
/// </summary>
readonly record struct PendingUshortArrayElement(
/// <summary>Array base address for AbsoluteY addressing (runtime index).</summary>
ushort ArrayBase,
/// <summary>Element address for constant-index access; null for runtime-index.</summary>
ushort? ConstantElementAddress
);

/// <summary>
/// Base address of the struct array for variable-index ldelema.
/// stfld/ldfld computes field address as this + fieldOffset, then uses ,X.
Expand Down
Binary file modified src/dotnes.tests/Data/peekpoke.debug.dll
Binary file not shown.
Binary file modified src/dotnes.tests/Data/peekpoke.release.dll
Binary file not shown.
124 changes: 124 additions & 0 deletions src/dotnes.tests/RoslynTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5510,6 +5510,130 @@ public void TryCatch_Throws()
Assert.Contains("try/catch", ex.Message);
}

[Fact]
public void UshortArray_NewarrAndConstantStore()
{
// ushort[] newarr allocates count*2 bytes; constant-index stelem.i2 stores lo/hi at computed addresses
var bytes = GetProgramBytes(
"""
ushort[] arr = new ushort[4];
arr[0] = 100;
arr[1] = 300;
arr[2] = 1000;
arr[3] = 50000;
ppu_on_all();
while (true) ;
""");
Assert.NotNull(bytes);
Assert.NotEmpty(bytes);

var hex = Convert.ToHexString(bytes);
_logger.WriteLine($"UshortArray_NewarrAndConstantStore hex: {hex}");

// arr[0] = 100 (0x0064): STA base+0 with 0x64, STA base+1 with 0x00
Assert.Contains("A964", hex); // LDA #$64 (lo byte of 100)
Assert.Contains("A900", hex); // LDA #$00 (hi byte of 100)

// arr[1] = 300 (0x012C): lo=0x2C, hi=0x01
Assert.Contains("A92C", hex); // LDA #$2C (lo byte of 300)
Assert.Contains("A901", hex); // LDA #$01 (hi byte of 300)

// arr[3] = 50000 (0xC350): lo=0x50, hi=0xC3
Assert.Contains("A950", hex); // LDA #$50 (lo byte of 50000)
Assert.Contains("A9C3", hex); // LDA #$C3 (hi byte of 50000)
}

[Fact]
public void UshortArray_VariableIndexLoad()
{
// Variable-index ldelem.u2 uses ASL A, TAY, LDA base,Y / LDA base+1,Y
var bytes = GetProgramBytes(
"""
byte idx = rand8();
ushort[] arr = new ushort[4];
arr[0] = 100;
arr[1] = 300;
arr[2] = 1000;
arr[3] = 50000;
ushort loaded = arr[idx];
pal_col(0, (byte)loaded);
ppu_on_all();
while (true) ;
""");
Assert.NotNull(bytes);
Assert.NotEmpty(bytes);

var hex = Convert.ToHexString(bytes);
_logger.WriteLine($"UshortArray_VariableIndexLoad hex: {hex}");

// Variable index load pattern: ASL A (0A), TAY (A8)
Assert.Contains("0AA8", hex); // ASL A; TAY (double index for 16-bit elements)

// AbsoluteY addressing: LDA abs,Y (B9) for both lo and hi bytes
Assert.Contains("B9", hex);
}

[Fact]
public void UshortArray_VariableIndexStore()
{
// Variable-index stelem.i2 saves value, computes Y offset, stores both bytes
var bytes = GetProgramBytes(
"""
ushort[] arr = new ushort[4];
arr[0] = 100;
byte idx = rand8();
arr[idx] = 310;
ppu_on_all();
while (true) ;
""");
Assert.NotNull(bytes);
Assert.NotEmpty(bytes);

var hex = Convert.ToHexString(bytes);
_logger.WriteLine($"UshortArray_VariableIndexStore hex: {hex}");

// 310 = 0x0136: lo=0x36, hi=0x01
Assert.Contains("A936", hex); // LDA #$36 (lo byte of 310)
Assert.Contains("A901", hex); // LDA #$01 (hi byte of 310)

// Variable index store uses ASL A + TAY pattern
Assert.Contains("0A", hex); // ASL A
Assert.Contains("A8", hex); // TAY

// Store pattern uses STA absolute,Y (opcode 99)
Assert.Contains("99", hex); // STA absolute,Y
}

[Fact]
public void UshortArray_LoadStoresIn16BitLocal()
{
// ldelem.u2 result stored to ushort local uses STA $xxxx + STX $xxxx+1
var bytes = GetProgramBytes(
"""
byte i = rand8();
ushort[] arr = new ushort[2];
arr[0] = 500;
arr[1] = 1000;
ushort val = arr[i];
pal_col(0, (byte)val);
ppu_on_all();
while (true) ;
""");
Assert.NotNull(bytes);
Assert.NotEmpty(bytes);

var hex = Convert.ToHexString(bytes);
_logger.WriteLine($"UshortArray_LoadStoresIn16BitLocal hex: {hex}");

// arr[0] = 500 (0x01F4): lo=0xF4, hi=0x01
Assert.Contains("A9F4", hex); // LDA #$F4
Assert.Contains("A901", hex); // LDA #$01

// arr[1] = 1000 (0x03E8): lo=0xE8, hi=0x03
Assert.Contains("A9E8", hex); // LDA #$E8
Assert.Contains("A903", hex); // LDA #$03
}

[Fact]
public void OamBegin_EmitsLdaStaJsrInMainBlock()
{
Expand Down
Binary file modified src/dotnes.tests/TranspilerTests.Write.peekpoke.verified.bin
Binary file not shown.
4 changes: 2 additions & 2 deletions src/dotnes.tests/TranspilerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,8 @@ public void BuildProgram6502(string name, bool debug)
[Theory]
[InlineData("tint", false, 3)] // 2 byte locals + 1 pad_poll temp
[InlineData("tint", true, 3)]
[InlineData("peekpoke", false, 3)] // 3 byte locals
[InlineData("peekpoke", true, 3)]
[InlineData("peekpoke", false, 4)] // 2 byte locals + 1 ushort local (2 bytes)
[InlineData("peekpoke", true, 4)]
public void LocalCountNotInflatedByReassignment(string name, bool debug, int expectedLocals)
{
var configuration = debug ? "debug" : "release";
Expand Down