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
5 changes: 1 addition & 4 deletions samples/movingsprite/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,5 @@
if ((pad & PAD.DOWN) != 0) y++;

// Draw 2x2 sprite (16x16 pixels)
oam_spr(x, y, 0xD8, 0, 0);
oam_spr((byte)(x + 8), y, 0xDA, 0, 4);
oam_spr(x, (byte)(y + 8), 0xD9, 0, 8);
oam_spr((byte)(x + 8), (byte)(y + 8), 0xDB, 0, 12);
oam_spr_2x2(x, y, 0xD8, 0xD9, 0xDA, 0xDB, 0, 0);
}
5 changes: 1 addition & 4 deletions samples/staticsprite/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,7 @@
];

pal_all(PALETTE);
oam_spr(40, 40, 0xD8, 0, 0);
oam_spr(48, 40, 0xDA, 0, 4);
oam_spr(40, 48, 0xD9, 0, 8);
oam_spr(48, 48, 0xDB, 0, 12);
oam_spr_2x2(40, 40, 0xD8, 0xD9, 0xDA, 0xDB, 0, 0);
ppu_on_all();

while (true) ;
2 changes: 1 addition & 1 deletion src/dotnes.tasks/ObjectModel/Program6502.cs
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,7 @@ static void ForEachOptionalBuiltIn(bool needsDecsp4, HashSet<string> usedMethods
action(BuiltInSubroutines.SRand());
if (usedMethods.Contains("rand16") || usedMethods.Contains("srand"))
action(BuiltInSubroutines.Rand());
if (usedMethods.Contains("oam_meta_spr"))
if (usedMethods.Contains("oam_meta_spr") || usedMethods.Contains("oam_spr_2x2"))
action(BuiltInSubroutines.OamMetaSpr());
if (usedMethods.Contains("oam_meta_spr_pal"))
action(BuiltInSubroutines.OamMetaSprPal());
Expand Down
5 changes: 5 additions & 0 deletions src/dotnes.tasks/Utilities/IL2NESWriter.ILDispatch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2000,6 +2000,11 @@ public void Write(ILInstruction instruction, string operand)
_lastByteArrayLabel = null;
_needsByteArrayLoadInCall = false;
break;
case nameof(NESLib.oam_spr_2x2):
EmitOamSpr2x2();
_lastByteArrayLabel = null;
_needsByteArrayLoadInCall = false;
break;
case nameof(NESLib.oam_meta_spr):
EmitOamMetaSpr();
break;
Expand Down
195 changes: 195 additions & 0 deletions src/dotnes.tasks/Utilities/IL2NESWriter.OamSprites.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Immutable;
using System.Reflection.Metadata;
using dotnes.ObjectModel;
using static NES.NESLib;
Expand Down Expand Up @@ -955,4 +956,198 @@ void EmitOamMetaSprPal()
_immediateInA = null;
_runtimeValueInA = false; // void return
}

/// <summary>
/// Emits oam_spr_2x2 call: builds a 17-byte metasprite array at compile time from
/// 4 tile constants + attr, then emits an oam_meta_spr call with x, y, sprid.
/// Args: x, y, topLeft, bottomLeft, topRight, bottomRight, attr, sprid (8 total).
/// </summary>
void EmitOamSpr2x2()
{
if (Instructions is null)
throw new InvalidOperationException("EmitOamSpr2x2 requires Instructions");

// Scan backward through IL to classify all 8 argument sources.
// Args in IL order: x, y, topLeft, bottomLeft, topRight, bottomRight, attr, sprid
// We scan in reverse: sprid, attr, bottomRight, topRight, bottomLeft, topLeft, y, x
//
// Roslyn's Release optimizer may interleave Stloc instructions between argument pushes
// (e.g., "ldc 40, ldc 40, stloc.0, ldloc.0" for inline constant+init).
// When scanning backward, we skip Stloc and the value it consumed.

int scan = Index - 1;

// Each arg: (isConst, constValue, localIdx)
var args = new (bool isConst, int value, int localIdx, bool isStaticField, string? staticFieldName)[8];
int firstArgILOffset = -1;
int skip = 0; // values to skip (consumed by Stloc interleaved in the arg sequence)

for (int argIdx = 7; argIdx >= 0 && scan >= 0;)
{
var il = Instructions[scan];

// Skip Stloc instructions — they consume one stack value that isn't a call arg
if (il.OpCode is ILOpCode.Stloc_0 or ILOpCode.Stloc_1 or ILOpCode.Stloc_2
or ILOpCode.Stloc_3 or ILOpCode.Stloc_s or ILOpCode.Stloc
or ILOpCode.Pop)
{
skip++; // The next value-producing instruction we find was consumed by this stloc
scan--;
continue;
}

bool isValueProducer = GetLdcValue(il) != null || GetLdlocIndex(il) != null
|| il.OpCode is ILOpCode.Ldsfld;

// Conv_u1 doesn't produce a new value (just converts top-of-stack), skip it
if (il.OpCode == ILOpCode.Conv_u1)
{
scan--;
continue;
}

if (skip > 0 && isValueProducer)
{
skip--;
scan--;
continue;
}

var ldcValue = GetLdcValue(il);
if (ldcValue != null)
{
args[argIdx] = (true, ldcValue.Value, -1, false, null);
if (argIdx == 0) firstArgILOffset = il.Offset;
scan--;
argIdx--;
}
else
{
var locIdx = GetLdlocIndex(il);
if (locIdx != null)
{
args[argIdx] = (false, 0, locIdx.Value, false, null);
if (argIdx == 0) firstArgILOffset = il.Offset;
scan--;
argIdx--;
}
else if (il.OpCode == ILOpCode.Ldsfld)
{
args[argIdx] = (false, 0, -1, true, il.String);
if (argIdx == 0) firstArgILOffset = il.Offset;
scan--;
argIdx--;
}
else
{
throw new TranspileException(
$"oam_spr_2x2 argument {argIdx} has unsupported IL opcode: {il.OpCode}",
MethodName);
}
}
}

// Extract tile and attr constants (args 2-6 must be compile-time constants)
for (int i = 2; i <= 6; i++)
{
if (!args[i].isConst)
throw new TranspileException(
$"oam_spr_2x2: argument {i} (tile/attr) must be a compile-time constant.",
MethodName);
}

int topLeft = args[2].value;
int bottomLeft = args[3].value;
int topRight = args[4].value;
int bottomRight = args[5].value;
int attr = args[6].value;

// Remove all previously emitted instructions for these 8 arguments
if (firstArgILOffset >= 0 && _blockCountAtILOffset.TryGetValue(firstArgILOffset, out int blockCount))
{
int instrToRemove = GetBufferedBlockCount() - blockCount;
if (instrToRemove > 0)
RemoveLastInstructions(instrToRemove);
}

// Build the 17-byte metasprite array (same layout as meta_spr_2x2)
byte[] data = new byte[]
{
0, 0, (byte)topLeft, (byte)attr,
0, 8, (byte)bottomLeft, (byte)attr,
8, 0, (byte)topRight, (byte)attr,
8, 8, (byte)bottomRight, (byte)attr,
128 // end marker
};

// Register as byte array data
string byteArrayLabel = $"bytearray_{_byteArrayLabelIndex}";
_byteArrayLabelIndex++;
_byteArrays.Add(data.ToImmutableArray());

// Emit oam_meta_spr calling convention:
// 1. Load x into TEMP
EmitOamSpr2x2Arg(args[0], (byte)NESConstants.TEMP);

// 2. Load y into TEMP2
EmitOamSpr2x2Arg(args[1], (byte)NESConstants.TEMP2);

// 3. Load data pointer into PTR
EmitWithLabel(Opcode.LDA, AddressMode.Immediate_LowByte, byteArrayLabel);
Emit(Opcode.STA, AddressMode.ZeroPage, (byte)NESConstants.ptr1);
EmitWithLabel(Opcode.LDA, AddressMode.Immediate_HighByte, byteArrayLabel);
Emit(Opcode.STA, AddressMode.ZeroPage, (byte)(NESConstants.ptr1 + 1));

// 4. Load sprid into A
if (args[7].isConst)
{
Emit(Opcode.LDA, AddressMode.Immediate, checked((byte)args[7].value));
}
else if (args[7].isStaticField)
{
EmitLdsfldForArg(args[7].staticFieldName);
}
else if (Locals.TryGetValue(args[7].localIdx, out var spridLocal) && spridLocal.Address != null)
{
Emit(Opcode.LDA, AddressMode.Absolute, (ushort)spridLocal.Address);
}
else
{
throw new TranspileException("oam_spr_2x2: unsupported sprid argument type.", MethodName);
}

// 5. Call oam_meta_spr
EmitWithLabel(Opcode.JSR, AddressMode.Absolute, nameof(NESLib.oam_meta_spr));
UsedMethods?.Add(nameof(NESLib.oam_meta_spr));
_immediateInA = null;
_runtimeValueInA = true; // oam_meta_spr returns next OAM offset in A
}

/// <summary>
/// Emits code to load an oam_spr_2x2 argument (constant or local) into a zero-page target.
/// </summary>
void EmitOamSpr2x2Arg(
(bool isConst, int value, int localIdx, bool isStaticField, string? staticFieldName) arg,
byte target)
{
if (arg.isConst)
{
Emit(Opcode.LDA, AddressMode.Immediate, checked((byte)arg.value));
Emit(Opcode.STA, AddressMode.ZeroPage, target);
}
else if (arg.isStaticField)
{
EmitLdsfldForArg(arg.staticFieldName);
Emit(Opcode.STA, AddressMode.ZeroPage, target);
}
else if (Locals.TryGetValue(arg.localIdx, out var local) && local.Address != null)
{
Emit(Opcode.LDA, AddressMode.Absolute, (ushort)local.Address);
Emit(Opcode.STA, AddressMode.ZeroPage, target);
}
else
{
throw new TranspileException("oam_spr_2x2: unsupported x/y argument type.", MethodName);
}
}
}
52 changes: 52 additions & 0 deletions src/dotnes.tests/RoslynTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2703,6 +2703,58 @@ public void OamMetaSprPalWithMixedArgs()
Assert.Contains("852B", hex); // STA ptr1+1
}

[Fact]
public void OamSpr2x2WithConstantArgs()
{
// oam_spr_2x2 with all constant arguments
var bytes = GetProgramBytes(
"""
oam_spr_2x2(40, 40, 0xD8, 0xD9, 0xDA, 0xDB, 0, 0);
while (true) ;
""");
Assert.NotNull(bytes);
Assert.NotEmpty(bytes);

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

// x=40 (0x28) stored to TEMP ($17): LDA #$28 = A928, STA $17 = 8517
Assert.Contains("A9288517", hex);
// y=40 (0x28) stored to TEMP2 ($19): LDA #$28 = A928, STA $19 = 8519
Assert.Contains("A9288519", hex);
// Data pointer setup: STA ptr1 ($2A) and STA ptr1+1 ($2B)
Assert.Contains("852A", hex); // STA ptr1
Assert.Contains("852B", hex); // STA ptr1+1
// sprid=0 loaded into A and followed by JSR oam_meta_spr: LDA #$00 = A900, JSR = 20
Assert.Contains("A90020", hex);
}

[Fact]
public void OamSpr2x2WithLocalArgs()
{
// oam_spr_2x2 with local x, y, and constant tiles/attr/sprid
var bytes = GetProgramBytes(
"""
byte x = 40;
byte y = 40;
oam_spr_2x2(x, y, 0xD8, 0xD9, 0xDA, 0xDB, 0, 0);
while (true) ;
""");
Assert.NotNull(bytes);
Assert.NotEmpty(bytes);

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

// x from local stored to TEMP ($17): LDA abs = AD...., STA $17 = 8517
Assert.Contains("8517", hex);
// y from local stored to TEMP2 ($19): STA $19 = 8519
Assert.Contains("8519", hex);
// Data pointer setup
Assert.Contains("852A", hex); // STA ptr1
Assert.Contains("852B", hex); // STA ptr1+1
}

[Fact]
public void MultiFile_StaticHelperClass()
{
Expand Down
8 changes: 8 additions & 0 deletions src/neslib/NESLib.cs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,14 @@ public static class NESLib
/// <returns>returns sprid+4, which is offset for a next sprite</returns>
public static byte oam_spr(byte x, byte y, byte chrnum, byte attr, byte sprid) => throw null!;

/// <summary>
/// Draw a 2×2 (16×16 pixel) sprite from four 8×8 tiles.
/// Writes 4 entries into the OAM buffer with standard 8-pixel offsets.
/// Parameters: topLeft, bottomLeft, topRight, bottomRight to match NES tile layout convention.
/// </summary>
/// <returns>returns sprid+16, which is offset for the next sprite</returns>
public static byte oam_spr_2x2(byte x, byte y, byte topLeft, byte bottomLeft, byte topRight, byte bottomRight, byte attr, byte sprid) => throw null!;
Comment thread
jonathanpeppers marked this conversation as resolved.

/// <summary>
/// poll controller and return enum like PAD.LEFT, etc.
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions src/neslib/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,5 @@ NES.OamFrame.Dispose() -> void
NES.OamFrame.OamFrame() -> void
static NES.NESLib.meta_spr_2x2(byte topLeft, byte bottomLeft, byte topRight, byte bottomRight, byte attr = 0) -> byte[]!
static NES.NESLib.meta_spr_2x2_flip(byte topLeft, byte bottomLeft, byte topRight, byte bottomRight, byte attr = 0) -> byte[]!
static NES.NESLib.oam_spr_2x2(byte x, byte y, byte topLeft, byte bottomLeft, byte topRight, byte bottomRight, byte attr, byte sprid) -> byte
static NES.NESLib.oam_begin() -> NES.OamFrame