diff --git a/CLAUDE.md b/CLAUDE.md index be10d78..7d231e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,22 @@ own 1 back, so read `exitCode` out of the report if you need to tell them apart. They are not one machine at two speeds: 312 scanlines against 262, 3.2 dots to a CPU cycle against 3, 50.0070 frames a second against 60.0988, and a different APU table for everything counted in CPU cycles. So **a PAL run and an NTSC run of the same ROM are not comparable** -- `run.region` in the -report is part of what to check before diffing two of them, alongside `run.state.startedFromPowerOn`. +report is part of what to check before diffing two of them, alongside `run.state.startedFromPowerOn` +and `run.hacks`. + +`run.hacks` is the third thing in that list, and it is there for the same reason: `--hack +unlimited-sprites` draws the sprites the chip would have dropped, so a scanline holding more than +eight of them stops flickering. Nothing a game can observe changes -- the overflow flag still rises, +$2004 still answers with what the sprite hardware is holding, and the cartridge sees the same address +bus, which is what keeps MMC3's scanline counter honest -- but the picture is not the one the +hardware would have drawn, so two runs that disagree about it are not two measurements of the same +thing. Every hack is off unless it is named. `hack unlimited-sprites on|off` does the same thing +inside an interactive session, which is how to take the same frame twice and diff the pictures. + +Do not go looking for a game to see it on. A cartridge is written to stay under eight sprites a line +and they mostly manage it -- Punch-Out!!'s first fight peaks at seven, Battletoads' first level at +eight -- so the demonstration is `sprite-limit/sprite-limit.nes`, which puts all sixty four on one +line and is assembled by `SpriteLimitROM` beside it rather than vendored as bytes. Everything that differs is in `Region`, including the PPU's OAM decay window, which has to outlast the machine's own blanking interval or every sprite in the game vanishes once a frame. Its tables diff --git a/README.md b/README.md index 05e0bc3..60b1ae5 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,11 @@ A CHR viewer shows every tile of a bank with a zoomed preview, coloured with any palettes the game is using, and it updates live as CHR RAM is rewritten and palettes change. There are also toggles to hide the background or the sprite layer without the game noticing. +A **Hacks** menu, for the things the console does not do. **Unlimited Sprites** draws the sprites the +chip would have dropped, so a scanline holding more than eight of them stops flickering; the game +cannot tell, since the overflow flag still rises and the cartridge still sees the same address bus. +It is off unless it is ticked, and the tick is remembered. + All of it is in headless mode too — `break`, `watch`, `step` and `disasm` are commands in the interactive session, so the same questions can be asked from a script. @@ -276,6 +281,12 @@ seconds to start up, the jar about a third of one. run without a patched file existing anywhere. The report's `cart.patches` says how many records each one held, and `cart.sha256` is the digest of the patched image rather than the file on disk — a patch that turns out to hold no records is one cut against a different dump of the game. +- **`--hack NAME`** switches on one of the things the console does not do, all of which are off + otherwise. There is one so far, `unlimited-sprites`, which draws the sprites the chip would have + dropped so that a scanline holding more than eight of them stops flickering. Nothing a game can + observe changes — the overflow flag still rises and the cartridge sees the same address bus — but + the picture is not the one the hardware would have produced, so `run.hacks` in the report is part + of what to check before diffing two of them. - **`--interactive`** reads commands on standard input and answers each with a line of JSON, for when you do not yet know the question well enough to write it down. It is also where the debugger lives without a window: `break`, `watch`, `step` and `disasm`, with `run` reporting back what diff --git a/mynes-core/src/main/java/com/github/dimiro1/mynes/PPU.java b/mynes-core/src/main/java/com/github/dimiro1/mynes/PPU.java index a623cab..749d4df 100644 --- a/mynes-core/src/main/java/com/github/dimiro1/mynes/PPU.java +++ b/mynes-core/src/main/java/com/github/dimiro1/mynes/PPU.java @@ -353,6 +353,12 @@ public class PPU { new SpriteUnit(), new SpriteUnit(), new SpriteUnit(), new SpriteUnit(), }; + /** + * The sprites the eight units above had no room for, which are not hardware and are drawn only + * when somebody has asked for them. + */ + private final ExtraSprites extraSprites = new ExtraSprites(); + // ---------------------------------------------------------------- open bus private final OpenBus openBus = new OpenBus(); @@ -828,6 +834,8 @@ private void spriteTick() { for (var unit : spriteUnits) { unit.halted = false; } + + extraSprites.release(); } if (dot < 257 || dot > 320) { @@ -867,6 +875,13 @@ private void spriteTick() { case 6 -> unit.patternHigh = fetchSpritePattern(slot, 8); default -> { /* the dots that only put an address out */ } } + + // The fetch window is over, so the eight units are loaded and the evaluation's answer for + // this line is final. Whatever it had to leave behind is picked up here, on a dot the + // hardware spends putting an address out and nothing else. + if (dot == 320) { + extraSprites.scan(); + } } /** @@ -961,6 +976,8 @@ private void clockSpriteCounters() { for (var unit : spriteUnits) { unit.clockCounter(); } + + extraSprites.clockCounters(); } /** @@ -979,6 +996,8 @@ private void shiftSpriteUnits() { for (var unit : spriteUnits) { unit.shift(); } + + extraSprites.shift(); } private void clearSecondaryOAM() { @@ -1319,30 +1338,41 @@ private int fetchSpritePattern(final int unit, final int plane) { row = 0; } - if ((attributes & 0x80) != 0) { - row = height - 1 - row; + var data = vram.read(spritePatternAddress(tile, attributes, row, height) + plane); + + if (!inRange) { + return 0; } - int address; + // A horizontally flipped sprite is loaded into the shift register back to front rather + // than shifted the other way. + return (attributes & 0x40) != 0 ? reverseBits(data) : data; + } + + /** + * Where the low bit plane of one row of one sprite lives. The high one is eight further on. + *

+ * Address arithmetic and nothing else, which is why {@link ExtraSprites} can share it: no bus + * cycle happens here, so the caller decides whether the cartridge is told about the address. + * + * @param tile the tile number out of OAM. + * @param attributes the attribute byte, of which only the vertical flip bit matters here. + * @param row which row of the sprite, 0 to {@code height - 1}, before any flip. + * @param height 8 or 16. + */ + private int spritePatternAddress( + final int tile, final int attributes, final int row, final int height) { + var line = (attributes & 0x80) != 0 ? height - 1 - row : row; if (height == 16) { // A tall sprite ignores $2000's table bit: the tile number's low bit picks the table // and the rest of it picks a pair of tiles, the second being the bottom half. - address = ((tile & 1) << 12) | ((tile & 0xFE) << 4); - address += row >= 8 ? 16 + (row & 7) : row; - } else { - address = ((ctrl & CTRL_SPRITE_TABLE) != 0 ? 0x1000 : 0x0000) | (tile << 4) | row; - } + var address = ((tile & 1) << 12) | ((tile & 0xFE) << 4); - var data = vram.read(address + plane); - - if (!inRange) { - return 0; + return address + (line >= 8 ? 16 + (line & 7) : line); } - // A horizontally flipped sprite is loaded into the shift register back to front rather - // than shifted the other way. - return (attributes & 0x40) != 0 ? reverseBits(data) : data; + return ((ctrl & CTRL_SPRITE_TABLE) != 0 ? 0x1000 : 0x0000) | (tile << 4) | line; } /** @@ -1354,6 +1384,188 @@ private static int reverseBits(final int value) { return Integer.reverse(value) >>> 24; } + /** + * The sprites the hardware ran out of output units for, drawn anyway. + *

+ * This is not a chip. The 2C02 has eight sprite output units and a scanline that wants a ninth + * gets the overflow flag instead, which is why so many games flicker their sprites -- rotating + * which of them is dropped, so that all of them are visible half the time. Switching this on + * puts the dropped ones on screen as well, and the flicker stops. + *

+ * The reason a game cannot tell is that nothing here touches anything a game can reach. The + * evaluation, secondary OAM, the eight real units and every bus cycle they make are left + * exactly as they were, and this runs afterwards on the results. The overflow flag still rises, + * $2004 still answers with whatever the sprite hardware is holding, and sprite 0 hit is still + * sprite 0's. OAM is read through {@link OAM#peek} and the patterns through {@link VRAM#peek}, + * so no row of OAM is refreshed that would have decayed and MMC3's counter never sees an + * address that would not have been there. What changes is the picture and nothing else. + *

+ * Inner rather than static because it is all borrowed: the beam position, $2000, OAM, the PPU + * bus, and the evaluation's own answer for the line. + */ + private final class ExtraSprites { + /** + * Whether anybody has asked for this. Default off, and not part of the machine -- it + * belongs to whoever is watching, like the two layer switches. + */ + private boolean enabled; + + /** + * Sixty four sprites in OAM, less the eight the hardware has units of its own for, which is + * as many as can ever be left over on one scanline. + */ + private final SpriteUnit[] units = new SpriteUnit[56]; + + /** + * How many of them the line being drawn is using. Everything below loops to here rather + * than over the array, so a machine with the hack switched off spends one comparison a dot + * on it. + */ + private int count; + + private ExtraSprites() { + Arrays.setAll(units, i -> new SpriteUnit()); + } + + /** + * Picks up whatever the evaluation had to leave behind, once per scanline at dot 320. + *

+ * Only when eight sprites were found: fewer means nothing was dropped, and the flag the + * hardware raises when it drops one is exactly the condition being undone here. The + * pre-render line evaluates nothing, so it has nothing to leave behind either -- the stale + * secondary OAM that lets a sprite reach scanline 0 is the real units' business and stays + * theirs. + */ + private void scan() { + count = 0; + + if (!enabled || scanline == preRenderLine || evaluation.spritesFound < 8) { + return; + } + + var height = spriteHeight(); + + // The first eight matches are already in the real units, and the walk starts wherever + // OAMADDR pointed when the evaluation did, so a game that moved it does not have + // sprites resurrected from in front of where the hardware began looking. Four bytes at + // a time, which is an approximation only for a game that left OAMADDR misaligned: the + // hardware would have read those bytes out of step, and this reads sprites. + var skip = 8; + + for (var address = evaluation.firstAddressExamined & 0xFC; + address < 0x100; + address += 4) { + var row = scanline - oam.peek(address); + + if (row < 0 || row >= height) { + continue; + } + + if (skip > 0) { + skip--; + continue; + } + + var tile = oam.peek(address + 1); + var attributes = oam.peek(address + 2); + var unit = units[count++]; + + unit.counter = oam.peek(address + 3); + unit.attributes = attributes; + unit.patternLow = pattern(tile, attributes, row, height, 0); + unit.patternHigh = pattern(tile, attributes, row, height, 8); + } + } + + /** + * One bit plane of one sprite, read past the cartridge rather than through it. + * + * @see VRAM#peek(int) + */ + private int pattern( + final int tile, + final int attributes, + final int row, + final int height, + final int plane) { + var data = vram.peek(spritePatternAddress(tile, attributes, row, height) + plane); + + return (attributes & 0x40) != 0 ? reverseBits(data) : data; + } + + private void clockCounters() { + for (var i = 0; i < count; i++) { + units[i].clockCounter(); + } + } + + private void shift() { + for (var i = 0; i < count; i++) { + units[i].shift(); + } + } + + /** + * Puts them back to counting, on the same dot the real units are put back to counting on. + */ + private void release() { + for (var i = 0; i < count; i++) { + units[i].halted = false; + } + } + + /** + * @return the first of these putting out an opaque pixel, or null. Only asked once all + * eight real units have come out transparent, which is what keeps the answer in OAM order: + * every sprite here is later in OAM than every sprite there. + */ + private SpriteUnit firstOpaque() { + for (var i = 0; i < count; i++) { + if (units[i].pixel() != 0) { + return units[i]; + } + } + + return null; + } + + /** + * The units travel, and {@link #enabled} does not. + *

+ * Not because a save state cares what the picture looked like, but because a state can be + * taken half way down a scanline -- a REPL breakpoint, or the debugger's step -- and + * resuming from one has to draw the rest of that line the same way running straight through + * would have. {@link #count} rather than {@code enabled} is what everything above reads, so + * a state loaded into a machine with the hack switched off still finishes the line it was + * in the middle of and then quietly stops finding any. + */ + private void serialize(final StateIO io) { + count = Math.min(io.u8(count), units.length); + + // One field across all of them at a time, which is how the eight real units are + // written a few lines above this. + for (var unit : units) { + unit.counter = io.u8(unit.counter); + } + + for (var unit : units) { + unit.attributes = io.u8(unit.attributes); + } + + for (var unit : units) { + unit.patternLow = io.u8(unit.patternLow); + } + + for (var unit : units) { + unit.patternHigh = io.u8(unit.patternHigh); + } + + for (var unit : units) { + unit.halted = io.bool(unit.halted); + } + } + } + // ================================================================ pixel output /** @@ -1391,18 +1603,28 @@ private void renderPixel() { * @return the low five bits of the palette address to draw, zero meaning the backdrop. */ private int multiplex(final int x, final int background) { - var unit = -1; + SpriteUnit winner = null; + var isSpriteZero = false; var colour = 0; if ((mask & MASK_SHOW_SPRITES) != 0 && (x >= 8 || (mask & MASK_SHOW_SPRITES_LEFT) != 0)) { - for (var i = 0; i < spriteUnits.length; i++) { - colour = spriteUnits[i].pixel(); - - if (colour != 0) { - unit = i; - break; + for (var i = 0; i < spriteUnits.length && winner == null; i++) { + if (spriteUnits[i].pixel() != 0) { + winner = spriteUnits[i]; + isSpriteZero = i == 0; } } + + // Only once the hardware's own eight have all come out transparent, which is what + // keeps first-opaque-wins meaning the same thing: every extra sprite is later in OAM + // than every real one, so a real unit would have won anyway. + if (winner == null) { + winner = extraSprites.firstOpaque(); + } + + if (winner != null) { + colour = winner.pixel(); + } } // From here down the debug layer switches take part, but only in what is returned: the @@ -1410,18 +1632,18 @@ private int multiplex(final int x, final int background) { // background pixel, so a hidden layer stays invisible to the game itself. var drawnBackground = backgroundLayerVisible ? background : 0; - if (unit < 0) { + if (winner == null) { return drawnBackground; } // The hit is about two opaque pixels meeting, not about which of them is drawn, so a // sprite hidden behind the background still sets it. The last pixel of the line never // does, for reasons lost with the hardware. - if (unit == 0 && spriteZeroOnThisLine && background != 0 && x != SCREEN_WIDTH - 1) { + if (isSpriteZero && spriteZeroOnThisLine && background != 0 && x != SCREEN_WIDTH - 1) { spriteZeroHit = true; } - var attributes = spriteUnits[unit].attributes; + var attributes = winner.attributes; if (background != 0 && (attributes & 0x20) != 0) { return drawnBackground; @@ -2043,9 +2265,9 @@ private void updateNMILine() { * computes it from {@link #vblankFlag} and {@link #ctrl}, so it is recomputed on the way * in rather than restored. The CPU's own latches are a different matter and are in its * chunk. - *

  • The two layer switches, which belong to whoever is watching rather than - * to the machine. Restoring them would hide a layer while the Debug menu still said it was - * showing.
  • + *
  • The two layer switches, and {@link ExtraSprites#enabled} beside them, + * which belong to whoever is watching rather than to the machine. Restoring them would + * hide a layer while the Debug menu still said it was showing.
  • * * The two decay tables come with {@link #clock} and {@link #frame}, which is what they are * measured against -- a table restored without its clock would decay at the wrong time. @@ -2053,7 +2275,8 @@ private void updateNMILine() { * It stays one flat list even where the fields now belong to a sub-unit, because the order of * this method is the file format and it was settled before there were sub-units to group them * into. {@link Background} is the exception, and only because its eight fields already sat - * together in exactly the order it writes them. + * together in exactly the order it writes them; {@link ExtraSprites} is the other, and only + * because it arrived after the end of the list and so had nowhere else to go. * * @see com.github.dimiro1.mynes.state.SaveState */ @@ -2140,13 +2363,18 @@ public void serialize(final StateIO io) { // than in a chunk of their own because nothing else can reach them. vram.serialize(io); - // Last, rather than up with the beam position it belongs to, because the order of this - // method is the file format. A field inserted in the middle would be read out of a state - // written before it existed as whatever byte happened to be at that offset; appended, it - // is simply missing from an older file, and StateIO hands back what the machine already - // had -- which for a state written before there was a PAL machine to write one is right. + // Appended rather than put up with the beam position it belongs to, because the order of + // this method is the file format. A field inserted in the middle would be read out of a + // state written before it existed as whatever byte happened to be at that offset; + // appended, it is simply missing from an older file, and StateIO hands back what the + // machine already had -- which for a state written before there was a PAL machine to write + // one is right. masterClockRemainder = io.u8(masterClockRemainder); + // Last for the same reason, and it is the whole of the sprite limit hack that travels: + // whether anybody asked for it does not, any more than the layer switches do. + extraSprites.serialize(io); + if (!io.saving()) { updateNMILine(); } @@ -2244,6 +2472,19 @@ public boolean isSpriteLayerVisible() { return spriteLayerVisible; } + /** + * Draws the sprites the real chip would have dropped, so that a scanline holding more than + * eight of them stops flickering. Off at power on, and the game cannot tell it has been thrown: + * see {@link ExtraSprites}. + */ + public void setUnlimitedSprites(final boolean unlimited) { + extraSprites.enabled = unlimited; + } + + public boolean isUnlimitedSprites() { + return extraSprites.enabled; + } + /** * Reads a palette RAM cell without side effects, for debug UIs. Mirroring is folded the same * way a real access folds it, so both a full $3F00 style address and a bare 0 to 31 index diff --git a/mynes-core/src/test/java/com/github/dimiro1/mynes/ppu/PPUAddressBusTests.java b/mynes-core/src/test/java/com/github/dimiro1/mynes/ppu/PPUAddressBusTests.java index 37f3cf0..be9e03e 100644 --- a/mynes-core/src/test/java/com/github/dimiro1/mynes/ppu/PPUAddressBusTests.java +++ b/mynes-core/src/test/java/com/github/dimiro1/mynes/ppu/PPUAddressBusTests.java @@ -173,6 +173,51 @@ private void enableRendering(final int ctrl, final int mask) { ppu.write(PPUMASK, mask); run(4); } + + /** + * The sprite limit hack draws sprites the chip never fetched, and it has to do it without + * the cartridge noticing: MMC3 counts the rises of A12 in this traffic to decide when to + * raise its scanline interrupt, so one extra pattern read would move a game's status bar. + *

    + * Compared as a whole sequence rather than counted, because the failure this guards against + * is an address in the wrong place as much as one too many. Two scanlines of it: + * the one whose fetch window loads the output units, and the one they are drawn across, + * which is the whole of where the hack does anything. + */ + @Test + void extraSpriteFetchesPutNoAddressOnTheBus() { + var withoutTheHack = addressesAroundNineSpritesOnALine(false); + var withIt = addressesAroundNineSpritesOnALine(true); + + assertEquals(withoutTheHack, withIt, "the cartridge saw a different scanline"); + } + + private List addressesAroundNineSpritesOnALine(final boolean unlimited) { + recorder = new RecordingMapper(); + createPPU(recorder); + warmUp(); + + // Nine sprites on scanline 11, which is one more than the hardware has units for. + for (var i = 0; i < 9; i++) { + ppu.write(OAMADDR, i * 4); + ppu.write(OAMDATA, 10); + ppu.write(OAMDATA, 1); + ppu.write(OAMDATA, 0); + ppu.write(OAMDATA, i * 8); + } + + ppu.setUnlimitedSprites(unlimited); + enableRendering(0x00, 0x1E); + + // Scanline 10 is where the units for scanline 11 are loaded, and 11 is where they are + // drawn. A second frame first, so the scroll counters have settled. + renderFrames(2); + runTo(10, 0); + recorder.clear(); + runTo(12, 0); + + return List.copyOf(recorder.addresses()); + } } @Nested diff --git a/mynes-core/src/test/java/com/github/dimiro1/mynes/ppu/PPUSpriteTests.java b/mynes-core/src/test/java/com/github/dimiro1/mynes/ppu/PPUSpriteTests.java index 3ec0a8a..afb20b6 100644 --- a/mynes-core/src/test/java/com/github/dimiro1/mynes/ppu/PPUSpriteTests.java +++ b/mynes-core/src/test/java/com/github/dimiro1/mynes/ppu/PPUSpriteTests.java @@ -568,6 +568,176 @@ void spritesBehindTheBackgroundStayHiddenWhenItIsHidden() { } } + @Nested + @DisplayName("the unlimited sprites hack") + class UnlimitedSprites { + @Test + void theNinthSpriteOnAScanlineIsDrawnWhenTheLimitIsOff() { + for (var i = 0; i < 9; i++) { + writeSprite(i, 10, SOLID_TILE, 0, 20 + i * 10); + } + + ppu.setUnlimitedSprites(true); + startRendering(); + renderFrames(2); + + assertEquals(colour(SPRITE_COLOUR), pixelAt(20 + 7 * 10, 11), "the eighth still is"); + assertEquals(colour(SPRITE_COLOUR), pixelAt(20 + 8 * 10, 11), "and now the ninth too"); + } + + @Test + void allSixtyFourSpritesOnOneLineAreDrawn() { + // Four pixels apart, so all sixty four fit across the 256 the beam draws. + for (var i = 0; i < 64; i++) { + writeSprite(i, 10, LEFT_HALF_TILE, 0, i * 4); + } + + ppu.setUnlimitedSprites(true); + startRendering(); + renderFrames(2); + + for (var i = 0; i < 64; i++) { + assertEquals(colour(SPRITE_COLOUR), pixelAt(i * 4, 11), "sprite " + i); + } + } + + @Test + void anEarlierSpriteStillCoversALaterOneAmongTheExtras() { + // Eight to fill the real units, then two more in the same place. The ninth is earlier + // in OAM than the tenth, so it is the one drawn. + for (var i = 0; i < 8; i++) { + writeSprite(i, 10, SOLID_TILE, 0, i * 8); + } + + writeSprite(8, 10, SOLID_TILE, 0x00, 100); // sprite palette 0 + writeSprite(9, 10, SOLID_TILE, 0x01, 100); // sprite palette 1, same place + + ppu.setUnlimitedSprites(true); + startRendering(); + renderFrames(2); + + assertEquals(colour(SPRITE_COLOUR), pixelAt(100, 11), "the ninth wins, not the tenth"); + } + + @Test + void theOverflowFlagStillRisesWithTheLimitOff() { + for (var i = 0; i < 9; i++) { + writeSprite(i, 10, SOLID_TILE, 0, i * 8); + } + + ppu.setUnlimitedSprites(true); + startRendering(); + renderThrough(11); + + assertTrue(overflowSet(), "the game is told it lost a sprite it did not lose"); + } + + @Test + void theLimitOffChangesNothingWhenEightOrFewerShareALine() { + for (var i = 0; i < 8; i++) { + writeSprite(i, 10, SOLID_TILE, 0, i * 10); + } + + ppu.setUnlimitedSprites(true); + startRendering(); + renderThrough(11); + + assertEquals(colour(SPRITE_COLOUR), pixelAt(70, 11), "the eighth is where it was"); + assertEquals(colour(BACKDROP), pixelAt(80, 11), "and nothing has been invented"); + assertFalse(overflowSet()); + } + + @Test + void theLeftColumnClipAppliesToExtraSprites() { + for (var i = 0; i < 8; i++) { + writeSprite(i, 10, SOLID_TILE, 0, 100 + i * 8); + } + + writeSprite(8, 10, SOLID_TILE, 0, 4); + + ppu.setUnlimitedSprites(true); + startRendering(SHOW_BACKGROUND | SHOW_SPRITES | SHOW_BACKGROUND_LEFT); + renderFrames(2); + + assertEquals(colour(BACKDROP), pixelAt(4, 11), "inside the clipped strip"); + assertEquals(colour(SPRITE_COLOUR), pixelAt(8, 11), "and out the other side of it"); + } + + @Test + void tallAndFlippedExtrasFetchTheRowsTheRealUnitsWould() { + // Tile 3 is odd, so an 8x16 sprite using it comes from the second pattern table + // whatever $2000 says. The top half is solid and the bottom half is empty. + for (var row = 0; row < 8; row++) { + writeVRAM(0x1000 + 2 * 16 + row, 0xF0); + writeVRAM(0x1000 + 3 * 16 + row, 0x00); + } + + for (var i = 0; i < 8; i++) { + writeSprite(i, 10, SOLID_TILE, 0, i * 8); + } + + writeSprite(8, 10, 0x03, 0, 100); + writeSprite(9, 10, 0x03, FLIP_HORIZONTALLY | FLIP_VERTICALLY, 150); + + ppu.setUnlimitedSprites(true); + startRendering(SHOW_EVERYTHING, 0x20); // tall sprites + renderFrames(2); + + assertEquals(colour(SPRITE_COLOUR), pixelAt(100, 11), "unflipped: the top left is on"); + assertEquals(colour(BACKDROP), pixelAt(107, 11)); + assertEquals(colour(BACKDROP), pixelAt(100, 20), "and the bottom half is empty"); + + assertEquals(colour(BACKDROP), pixelAt(150, 11), "flipped both ways: the top is empty"); + assertEquals(colour(BACKDROP), pixelAt(150, 20)); + assertEquals(colour(SPRITE_COLOUR), pixelAt(157, 20), "and the bottom right is solid"); + } + + @Test + void anExtraSpriteBehindTheBackgroundStaysBehind() { + fillBackground(); + + for (var i = 0; i < 8; i++) { + writeSprite(i, 10, SOLID_TILE, 0, i * 8); + } + + writeSprite(8, 10, SOLID_TILE, BEHIND_BACKGROUND, 100); + + ppu.setUnlimitedSprites(true); + startRendering(); + renderFrames(2); + + assertEquals(colour(BACKGROUND_COLOUR), pixelAt(100, 11), + "the priority bit means the same thing here as anywhere else"); + } + + @Test + void aSpriteInFrontOfWhereTheHardwareStartedLookingIsNotResurrected() { + // Nine sprites from number 8 on, and one at number 0 that is on the same line. + for (var i = 8; i < 17; i++) { + writeSprite(i, 10, SOLID_TILE, 0, (i - 8) * 8); + } + + writeSprite(0, 10, SOLID_TILE, 0, 200); + + ppu.setUnlimitedSprites(true); + startRendering(); + renderFrames(2); + + // OAMADDR pointed at sprite 8, so the evaluation starts there and sprite 0 is never + // examined at all. Set here rather than before rendering because the fetch phase holds + // it at zero from dot 257 to dot 320 of every line, and the evaluation for the line + // below this one starts at dot 65. + runTo(10, 10); + ppu.write(OAMADDR, 8 * 4); + advanceFrames(1); + + assertEquals(colour(SPRITE_COLOUR), pixelAt(64, 11), + "the ninth of the nine it did look at is drawn"); + assertEquals(colour(BACKDROP), pixelAt(200, 11), + "and the one in front of where it began looking is not"); + } + } + @Nested @DisplayName("what rendering does to OAMADDR") class OamAddress { diff --git a/mynes-core/src/test/java/com/github/dimiro1/mynes/state/SaveStateCompletenessTests.java b/mynes-core/src/test/java/com/github/dimiro1/mynes/state/SaveStateCompletenessTests.java index 135c60e..768f2f8 100644 --- a/mynes-core/src/test/java/com/github/dimiro1/mynes/state/SaveStateCompletenessTests.java +++ b/mynes-core/src/test/java/com/github/dimiro1/mynes/state/SaveStateCompletenessTests.java @@ -63,6 +63,11 @@ class SaveStateCompletenessTests { "a debug switch belonging to whoever is watching, not to the machine"), Map.entry("PPU.spriteLayerVisible", "the same, and restoring it would contradict the Debug menu's tick"), + Map.entry("ExtraSprites.enabled", + "whether anybody asked for the sprite limit to be lifted, which is the Hacks" + + " menu's tick rather than anything the machine holds. The units it" + + " loads do travel, so a state taken mid-scanline still draws the rest" + + " of that line the way running straight through would have"), Map.entry("MMU.writeListener", "where a debugger's watchpoints wire in -- whoever is watching the machine" + " rather than the machine, and a state that put one back would be" @@ -156,6 +161,7 @@ void whatIsLeftOutOfTheStateStaysOutOfIt() throws IOException { run(original, 5); original.getController1().setButtons(Controller.BUTTON_START | Controller.BUTTON_B); original.getPPU().setBackgroundLayerVisible(false); + original.getPPU().setUnlimitedSprites(true); var state = save(original); @@ -163,6 +169,7 @@ void whatIsLeftOutOfTheStateStaysOutOfIt() throws IOException { run(other, 20); other.getController1().setButtons(0); other.getPPU().setBackgroundLayerVisible(true); + other.getPPU().setUnlimitedSprites(false); SaveState.read(other, new ByteArrayInputStream(state)); @@ -172,6 +179,8 @@ void whatIsLeftOutOfTheStateStaysOutOfIt() throws IOException { "the buttons came across, and the release will never arrive"); assertEquals("true", fields.get("PPU.backgroundLayerVisible"), "a state overrode the Debug menu"); + assertEquals("false", fields.get("ExtraSprites.enabled"), + "and a state overrode the Hacks menu"); } /** @@ -265,9 +274,14 @@ private static void collect( /** * What to call the {@code i}th element of an array of the emulator's own objects. + *

    + * Named after the field rather than after the class, because the PPU now holds two arrays of + * {@code SpriteUnit} -- its own eight and the fifty six the sprite limit hack draws with -- and + * a label taken from the class would give both the same eight names. The second walked would + * overwrite the first, and eight units would go silently uncompared. */ private static String elementLabel(final java.lang.reflect.Field field, final int i) { - return field.getType().getComponentType().getSimpleName() + "[" + i + "]"; + return field.getName() + "[" + i + "]"; } /** diff --git a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/Config.java b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/Config.java index a0da16a..497ba00 100644 --- a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/Config.java +++ b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/Config.java @@ -43,6 +43,7 @@ public final class Config { private static final String REGION_KEY = "emulation.region"; private static final String FAST_FORWARD_KEY = "emulation.fast-forward"; private static final String MUTED_KEY = "audio.muted"; + private static final String UNLIMITED_SPRITES_KEY = "hacks.unlimited-sprites"; private static final String HEADER = """ # MyNES settings. @@ -92,6 +93,13 @@ public final class Config { # is sound switched on. """; + private static final String HACKS_HEADER = """ + # Things the console does not do, from the Hacks menu. All of them off unless this + # says true; anything that is not true is off. Unlimited sprites draws the sprites the + # chip would have dropped, so a scanline holding more than eight of them stops + # flickering -- which is a change to the picture and to nothing the game can see. + """; + private KeyBindings keyBindings; private NESPalette palette; private NESPalette palPalette; @@ -100,6 +108,7 @@ public final class Config { private RegionSetting region; private EmulationSpeed fastForwardSpeed; private boolean muted; + private boolean unlimitedSprites; private Config( final KeyBindings keyBindings, @@ -109,7 +118,8 @@ private Config( final ScreenScale screenshotScale, final RegionSetting region, final EmulationSpeed fastForwardSpeed, - final boolean muted) { + final boolean muted, + final boolean unlimitedSprites) { this.keyBindings = keyBindings; this.palette = palette; this.palPalette = palPalette; @@ -118,6 +128,7 @@ private Config( this.region = region; this.fastForwardSpeed = fastForwardSpeed; this.muted = muted; + this.unlimitedSprites = unlimitedSprites; } /** @@ -151,16 +162,17 @@ public static Config load(final Path path) { screenScaleFrom(properties, SCREENSHOT_SCALE_KEY, ScreenScale.defaultScreenshotScale()), regionFrom(properties), fastForwardSpeedFrom(properties), - mutedFrom(properties)); + flagFrom(properties, MUTED_KEY), + flagFrom(properties, UNLIMITED_SPRITES_KEY)); } /** - * Whether the sound is off. Unlike the other entries there is nothing to fall back to and - * nothing to warn about: anything that is not {@code true} is somebody who wants to hear the - * game, which is also what a missing entry means. + * One of the plain yes-or-no entries. Unlike the others there is nothing to fall back to and + * nothing to warn about: anything that is not {@code true} is somebody who wants the ordinary + * behaviour, which is also what a missing entry means. */ - private static boolean mutedFrom(final Properties properties) { - return Boolean.parseBoolean(properties.getProperty(MUTED_KEY, "").trim()); + private static boolean flagFrom(final Properties properties, final String key) { + return Boolean.parseBoolean(properties.getProperty(key, "").trim()); } private static NESPalette paletteFrom( @@ -261,6 +273,12 @@ public void save(final Path path) throws IOException { .append(muted) .append("\n\n"); + text.append(HACKS_HEADER) + .append(UNLIMITED_SPRITES_KEY) + .append('=') + .append(unlimitedSprites) + .append("\n\n"); + keyBindings.appendTo(text); var parent = path.getParent(); @@ -365,4 +383,17 @@ public boolean muted() { public void setMuted(final boolean muted) { this.muted = muted; } + + /** + * Whether Hacks > Unlimited Sprites is on. Remembered for the reason Mute is: it is a + * preference about how the emulator should behave rather than something a particular game did, + * and somebody who wants the flicker gone wants it gone tomorrow as well. + */ + public boolean unlimitedSprites() { + return unlimitedSprites; + } + + public void setUnlimitedSprites(final boolean unlimitedSprites) { + this.unlimitedSprites = unlimitedSprites; + } } diff --git a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/GameUIFrame.java b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/GameUIFrame.java index cd6e8b6..9eb73b6 100644 --- a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/GameUIFrame.java +++ b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/GameUIFrame.java @@ -95,6 +95,8 @@ public class GameUIFrame extends JFrame { private final JCheckBoxMenuItem machineMenuMute = new JCheckBoxMenuItem("Mute"); private final JCheckBoxMenuItem debugMenuBackground = new JCheckBoxMenuItem("Show Background", true); private final JCheckBoxMenuItem debugMenuSprites = new JCheckBoxMenuItem("Show Sprites", true); + private final JCheckBoxMenuItem hacksMenuUnlimitedSprites = + new JCheckBoxMenuItem("Unlimited Sprites"); /** * The Load State items, kept so the menu can relabel them with what is in each slot and grey out @@ -277,6 +279,16 @@ private void init() { debugMenuSprites.setMnemonic(KeyEvent.VK_S); debugMenu.add(debugMenuSprites); + // Not gated on a machine, unlike Debug: these are preferences that are remembered and + // re-applied to whatever runs next, so there is something to change before a ROM is open. + // Mnemonic A rather than H, which is Help's. + JMenu hacksMenu = new JMenu("Hacks"); + hacksMenu.setMnemonic(KeyEvent.VK_A); + + hacksMenuUnlimitedSprites.setMnemonic(KeyEvent.VK_U); + hacksMenuUnlimitedSprites.setSelected(config.unlimitedSprites()); + hacksMenu.add(hacksMenuUnlimitedSprites); + JMenu settingsMenu = new JMenu("Settings"); settingsMenu.setMnemonic(KeyEvent.VK_S); @@ -298,6 +310,7 @@ private void init() { menuBar.add(fileMenu); menuBar.add(machineMenu); menuBar.add(debugMenu); + menuBar.add(hacksMenu); menuBar.add(settingsMenu); menuBar.add(helpMenu); @@ -447,6 +460,19 @@ public void menuCanceled(final MenuEvent e) { } }); + // Remembered between runs, unlike the two layer switches above: those are a debug view of + // the machine that is running, and this is how somebody wants their games to look. + hacksMenuUnlimitedSprites.addActionListener(e -> { + config.setUnlimitedSprites(hacksMenuUnlimitedSprites.isSelected()); + saveConfig(); + + if (runner != null) { + var ppu = nes.getPPU(); + var unlimited = hacksMenuUnlimitedSprites.isSelected(); + runner.post(() -> ppu.setUnlimitedSprites(unlimited)); + } + }); + // The viewer reads the mapper's character memory and the PPU's palette RAM from this // thread while the emulation thread runs. Deliberately unsynchronised: reading an array // element cannot tear, so the worst case is a debug window showing a tile a frame out of @@ -1134,10 +1160,11 @@ private void startMachine(final Cart cart, final Path rom, final Path patch) { // palette is chosen, because this is the one moment the kind of machine can change. screen.setPalette(config.palette(nes.getRegion())); - // A fresh PPU has both layers on, but the menu remembers what the last one was told. - // The runner has not started yet, so the machine is still this thread's to touch. + // A fresh PPU has both layers on and no hacks, but the menus remember what the last one was + // told. The runner has not started yet, so the machine is still this thread's to touch. nes.getPPU().setBackgroundLayerVisible(debugMenuBackground.isSelected()); nes.getPPU().setSpriteLayerVisible(debugMenuSprites.isSelected()); + nes.getPPU().setUnlimitedSprites(hacksMenuUnlimitedSprites.isSelected()); // The watchpoints have to be wired to this machine's MMU rather than the last one's. Same // window as the two lines above: the runner does not exist yet, so this thread owns it. diff --git a/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/ConfigTests.java b/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/ConfigTests.java index 7061801..55d426e 100644 --- a/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/ConfigTests.java +++ b/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/ConfigTests.java @@ -282,6 +282,30 @@ void anythingElseLeavesTheSoundOn() throws IOException { } } + @Nested + @DisplayName("loading the hacks") + class LoadingHacks { + @Test + void aMissingEntryLeavesTheConsoleAsItWas() throws IOException { + assertFalse(Config.load(write("video.palette=nesdev\n")).unlimitedSprites()); + } + + @Test + void trueMeansTheSpriteLimitIsLifted() throws IOException { + assertTrue(Config.load(write("hacks.unlimited-sprites=true\n")).unlimitedSprites()); + } + + @Test + void surroundingSpaceIsIgnored() throws IOException { + assertTrue(Config.load(write("hacks.unlimited-sprites= true \n")).unlimitedSprites()); + } + + @Test + void anythingElseLeavesItOff() throws IOException { + assertFalse(Config.load(write("hacks.unlimited-sprites=yes\n")).unlimitedSprites()); + } + } + @Nested @DisplayName("saving") class Saving { @@ -370,6 +394,15 @@ void theMuteSettingSurvivesTheRoundTrip() throws IOException { assertTrue(Config.load(config()).muted()); } + @Test + void theSpriteLimitHackSurvivesTheRoundTrip() throws IOException { + var config = Config.load(config()); + config.setUnlimitedSprites(true); + config.save(config()); + + assertTrue(Config.load(config()).unlimitedSprites()); + } + @Test void createsTheDirectory() throws IOException { var path = directory.resolve("nested").resolve("config.properties"); @@ -405,6 +438,7 @@ void aSaveWritesEverySection() throws IOException { config.setRegion(RegionSetting.PAL); config.setFastForwardSpeed(EmulationSpeed.TWO_TIMES); config.setMuted(true); + config.setUnlimitedSprites(true); config.save(config()); var text = Files.readString(config()); @@ -416,6 +450,7 @@ void aSaveWritesEverySection() throws IOException { assertTrue(text.contains("emulation.region=pal"), text); assertTrue(text.contains("emulation.fast-forward=2x"), text); assertTrue(text.contains("audio.muted=true"), text); + assertTrue(text.contains("hacks.unlimited-sprites=true"), text); assertTrue(text.contains("controller1.a=VK_L"), text); } diff --git a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Headless.java b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Headless.java index b2b388b..0489493 100644 --- a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Headless.java +++ b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Headless.java @@ -148,6 +148,12 @@ private static int runCartridge(final Options options) throws IOException { var session = new Session( new NES(cart, region), palette.colours(), wav); + // Before either of the two below it, because a hack is not machine state: a save state + // carries none of these, so switching one on afterwards would leave it depending on + // whether the run started from power on. + session.nes().getPPU().setUnlimitedSprites( + options.hacks().contains(Options.UNLIMITED_SPRITES)); + // The cartridge RAM first and the save state second, because a state carries its own copy // of that RAM and is the more specific answer of the two. if (options.sramIn() != null) { diff --git a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Options.java b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Options.java index 04a46c4..c138f82 100644 --- a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Options.java +++ b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Options.java @@ -39,6 +39,7 @@ * @param palette which measurement of the chip's colours to draw with, or null to let the * region decide. * @param audio whether to write the sound to a file as well as counting it. + * @param hacks which of the things the hardware does not do to switch on. * @param dumps which memories to write out when the run ends. * @param loadState a save state to start from instead of power on, or null. * @param saveState where to write a save state when the run ends, or null. @@ -73,6 +74,7 @@ public record Options( Region region, NESPalette palette, boolean audio, + Set hacks, List dumps, Path loadState, Path saveState, @@ -97,6 +99,19 @@ public enum Format { AUTO, JSON, TEXT } + /** + * Drawing the sprites the eight output units had no room for, so a scanline holding more than + * eight of them stops flickering. Not hardware, which is the point of the flag: a run with this + * on and a run without it are two different machines, and {@code run.hacks} in the report says + * which one happened. + */ + public static final String UNLIMITED_SPRITES = "unlimited-sprites"; + + /** + * Every hack there is, which is also what an unknown {@code --hack} is answered with. + */ + public static final Set HACKS = Set.of(UNLIMITED_SPRITES); + /** * Ten seconds of emulated time, which is about a second of real time and long enough for most * cartridges to have drawn something. @@ -207,6 +222,17 @@ The second is worth building once (mvn -B package -DskipTests) for anything run 44100Hz. The report's peak, RMS and silent frame counts are there either way; this only adds the file. + Hacks, which are things the console does not do + --hack NAME[,NAME..] Switch one on. All of them are off unless named here, and + run.hacks in the report says which were on, so a run with one + and a run without it can be told apart. + unlimited-sprites Draw the sprites the chip would have + dropped, so a scanline holding more than + eight of them stops flickering. Nothing a + game can see changes: the overflow flag + still rises and the cartridge sees the + same address bus. + Memory, dumped once the run has finished --dump LIST Comma separated, from: ram (2KB), oam (256B), palette (32B), nametables (4KB), prgram (8KB), chr (8KB), or all. Raw binary, @@ -282,6 +308,7 @@ public static Options parse(final String[] args) { Region region = null; NESPalette palette = null; var audio = false; + var hacks = new LinkedHashSet(); var dumps = new LinkedHashSet(); Path loadState = null; Path saveState = null; @@ -324,6 +351,7 @@ public static Options parse(final String[] args) { case "--region" -> region = parseRegion(value(args, ++i, flag)); case "--palette" -> palette = parsePalette(value(args, ++i, flag)); case "--audio" -> audio = true; + case "--hack" -> parseHacks(value(args, ++i, flag), hacks); case "--dump" -> parseDumps(value(args, ++i, flag), dumps); case "--load-state" -> loadState = Path.of(value(args, ++i, flag)); case "--save-state" -> saveState = Path.of(value(args, ++i, flag)); @@ -372,6 +400,7 @@ public static Options parse(final String[] args) { region, palette, audio, + Set.copyOf(hacks), List.copyOf(dumps), loadState, saveState, @@ -520,6 +549,31 @@ private static boolean parseScreenshots(final String text, final TreeSet f return last; } + /** + * Reads a hack list, adding what it names to {@code hacks}. + *

    + * An unknown name is refused rather than ignored, for the reason a misspelled palette is: a run + * that quietly happened without the hack somebody asked for would look like it had worked, and + * the picture is the only place the difference shows. + */ + private static void parseHacks(final String text, final Set hacks) { + for (var token : text.split(",")) { + var trimmed = token.trim().toLowerCase(); + + if (trimmed.isEmpty()) { + continue; + } + + if (!HACKS.contains(trimmed)) { + throw new UsageException( + "--hack does not know \"" + trimmed + "\". It knows " + + String.join(", ", new TreeSet<>(HACKS)) + "."); + } + + hacks.add(trimmed); + } + } + private static void parseDumps(final String text, final Set dumps) { for (var token : text.split(",")) { var trimmed = token.trim().toLowerCase(); diff --git a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Repl.java b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Repl.java index 0177b0f..2ce2dd6 100644 --- a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Repl.java +++ b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Repl.java @@ -46,6 +46,7 @@ public final class Repl { read-ppu ADDR [COUNT] PPU bus: pattern tables and nametables oam [START] [COUNT] object attribute memory dump WHAT PATH ram, oam, palette, nametables, prgram or chr + hack NAME on|off unlimited-sprites, which --hack also switches on save-state PATH write the whole machine to a file load-state PATH put one back, from this same ROM audio peak, RMS and silence since the last audio command @@ -162,6 +163,7 @@ private void dispatch(final String[] words) throws IOException { case "read", "read-ppu" -> read(name, words); case "oam" -> oam(words); case "dump" -> dump(words); + case "hack" -> hack(words); case "save-state" -> saveState(words); case "load-state" -> loadState(words); case "audio" -> audio(); @@ -466,6 +468,43 @@ private void dump(final String[] words) throws IOException { }); } + /** + * Switches one of the things the console does not do on or off, mid-session. + *

    + * Worth having as a command rather than only as a flag because the difference a hack makes is + * something to look at: run to the frame the sprites flicker on, turn it on, take a screenshot, + * turn it off, take another. Nothing about the machine changes, so the two pictures are of the + * same moment. + */ + private void hack(final String[] words) { + if (words.length < 3) { + throw new UsageException( + "hack wants a name and on or off, as in \"hack " + + Options.UNLIMITED_SPRITES + " on\"."); + } + + var name = words[1].toLowerCase(Locale.ROOT); + + var on = switch (words[2].toLowerCase(Locale.ROOT)) { + case "on" -> true; + case "off" -> false; + default -> throw new UsageException( + "hack is switched on or off, not \"" + words[2] + "\"."); + }; + + switch (name) { + case Options.UNLIMITED_SPRITES -> session.nes().getPPU().setUnlimitedSprites(on); + default -> throw new UsageException( + "hack does not know \"" + words[1] + "\". It knows " + + String.join(", ", Options.HACKS) + "."); + } + + reply("hack", node -> { + node.put("hack", name); + node.put("on", on); + }); + } + /** * A bookmark, which is what makes trying two things from the same place cheap: save, try one, * load, try the other. The reply carries the frame and the picture hash like every other, so the diff --git a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Report.java b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Report.java index c54300c..29708c2 100644 --- a/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Report.java +++ b/mynes-headless/src/main/java/com/github/dimiro1/mynes/headless/Report.java @@ -131,6 +131,13 @@ public static String write( run.put("region", region.id()); run.put("regionForced", options.region() != null); + // And which things the console does not do were switched on, which is the third. Read back + // off the machine rather than off the command line, so that a REPL session that flipped one + // half way through is reported as it ended rather than as it started. Always present, and + // one key per hack rather than a list, so two reports compare key for key. + var hacks = run.putObject("hacks"); + hacks.put("unlimitedSprites", ppu.isUnlimitedSprites()); + // Where the run started, which decides whether it is comparable with another one at all. A // run that began from a save state and one that began at power on are not two measurements // of the same thing, and telling them apart is the whole job of this document. diff --git a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/HeadlessRunTests.java b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/HeadlessRunTests.java index 22fd1bb..30784d3 100644 --- a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/HeadlessRunTests.java +++ b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/HeadlessRunTests.java @@ -476,6 +476,58 @@ void aPalRunIsADifferentMachineFromTopToBottom() throws Exception { assertTrue(report().at("/run/cpuCycles").asLong() > ntscCycles); } + /** + * The flag reaches the PPU, and the report says so either way -- which is the point of it, + * since a run with the hack on and a run without it are not two measurements of the same + * machine even when the picture happens to come out the same. + */ + @Test + void theReportSaysWhichHacksWereOn() throws Exception { + run(); + + assertFalse(report().at("/run/hacks/unlimitedSprites").asBoolean(), + "off unless somebody asks"); + + run("--hack", "unlimited-sprites"); + + assertTrue(report().at("/run/hacks/unlimitedSprites").asBoolean()); + } + + /** + * nestest never puts nine sprites on a scanline, so switching the hack on has nothing to do -- + * which makes it the right cartridge for showing that the flag on its own changes no pixels. + */ + @Test + void aHackWithNothingToDoLeavesThePictureExactlyAsItWas() throws Exception { + run(); + + var withoutIt = report().at("/video/finalFrame/hash").asText(); + + run("--hack", "unlimited-sprites"); + + assertEquals(withoutIt, report().at("/video/finalFrame/hash").asText()); + } + + @Test + void aHackNobodyHasWrittenIsACommandLineError() { + assertEquals(2, run("--hack", "infinite-lives")); + } + + /** + * The report reads the hacks back off the machine rather than off the command line, so a + * session that switched one on half way through is described as it ended. + */ + @Test + void aHackSwitchedOnInTheReplIsInTheReport() throws Exception { + var script = Files.writeString( + out.resolve("session.txt"), "run 5\nhack unlimited-sprites on\nquit\n"); + + run("--script", script.toString()); + + assertTrue(report().at("/run/hacks/unlimitedSprites").asBoolean(), + "nobody put it on the command line, and it is on all the same"); + } + @Test void theReportSaysWhatTheCartridgeAskedFor() throws Exception { run(); diff --git a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/OptionsTests.java b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/OptionsTests.java index 42828ce..bb0ea95 100644 --- a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/OptionsTests.java +++ b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/OptionsTests.java @@ -6,6 +6,7 @@ import java.nio.file.Path; import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -195,6 +196,31 @@ void allIsEveryDump() { assertEquals(Session.DUMPS, parse("--rom", "x.nes", "--dump", "all").dumps()); } + @Test + void noHackIsAskedForUnlessOneIsNamed() { + assertTrue(parse("--rom", "x.nes").hacks().isEmpty(), "the console is the default"); + } + + @Test + void aHackIsTakenAsAsked() { + assertEquals( + Set.of(Options.UNLIMITED_SPRITES), + parse("--rom", "x.nes", "--hack", "unlimited-sprites").hacks()); + } + + /** + * Refused rather than ignored, for the reason a misspelled region is: the only place the + * difference shows is the picture, so a run that quietly did not switch it on would look like + * it had worked. + */ + @Test + void aHackNameThatIsNotOnTheListIsRejected() { + var message = refused("--rom", "x.nes", "--hack", "infinite-lives").getMessage(); + + assertTrue(message.contains("infinite-lives")); + assertTrue(message.contains("unlimited-sprites"), "the message should offer the real ids"); + } + @Test void helpDoesNotNeedARom() { assertTrue(parse("--help").help()); diff --git a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/ReplTests.java b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/ReplTests.java index 08e3032..ec5685e 100644 --- a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/ReplTests.java +++ b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/ReplTests.java @@ -215,6 +215,38 @@ void aCommandThatIsMissingItsArgumentIsAnsweredTheSameWay() throws Exception { } } + @Test + void aHackCanBeSwitchedOnAndOffMidSession() throws Exception { + var replies = session( + "run 5", + "hack unlimited-sprites on", + "hack unlimited-sprites off", + "quit"); + + replies.forEach(reply -> assertTrue(reply.get("ok").asBoolean(), reply.toString())); + + assertEquals("unlimited-sprites", replies.get(1).get("hack").asText()); + assertTrue(replies.get(1).get("on").asBoolean()); + assertFalse(replies.get(2).get("on").asBoolean()); + } + + @Test + void aHackThatIsMisspeltOrHalfTypedIsAnError() throws Exception { + var replies = session( + "hack", + "hack unlimited-sprites", + "hack infinite-lives on", + "hack unlimited-sprites maybe", + "quit"); + + for (var i = 0; i < 4; i++) { + assertFalse(replies.get(i).get("ok").asBoolean(), replies.get(i).toString()); + } + + assertTrue(replies.get(2).get("error").asText().contains("infinite-lives")); + assertTrue(replies.get(3).get("error").asText().contains("maybe")); + } + @Test void blankLinesAndCommentsAreIgnored() throws Exception { var replies = session("", "# a note", "run 5", "quit"); diff --git a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/SpriteLimitROM.java b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/SpriteLimitROM.java new file mode 100644 index 0000000..bb0913b --- /dev/null +++ b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/SpriteLimitROM.java @@ -0,0 +1,247 @@ +package com.github.dimiro1.mynes.headless; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * A cartridge with all sixty four sprites on one scanline, assembled here rather than vendored. + *

    + * It exists because no real game would do this. The hardware draws eight sprites a scanline and + * every cartridge is written around that, so the overflow a test of the sprite limit needs is + * exactly what a shipped game spends its effort avoiding -- Punch-Out!!'s first fight peaks at + * seven sprites on a line and Battletoads' first level at eight. Nothing in a ROM collection + * reliably overflows anywhere a test can reach. + *

    + * Built rather than vendored because a generator is cheaper to keep than a binary: forty lines that + * say what they do, against twenty four kilobytes nobody can read, with no question about where it + * came from or who owns it. It is also the only way this file can stay honest -- {@code mvn test} + * assembles and runs it, so a mistake in it fails the build rather than sitting there. + *

    + * What it draws is one row of sixty four solid blocks four pixels apart, cycling the four sprite + * palettes so that the individual sprites can be counted. On hardware the row is + * {@value #DRAWN_WIDTH_WITH_THE_LIMIT} pixels wide, because only the first eight are drawn. With + * the limit lifted it crosses the screen. + *

    + * This is the assembler. The {@code .s} beside the cartridge is the same program + * written for asm6, and it is there to be read and changed; nothing assembles it, and the build + * needs no assembler because of that. So a change to the program is made here, and the file it + * produces is rewritten with {@link #main}: + *

    + * mvn -q -pl mynes-headless -am test-compile
    + * java -cp mynes-headless/target/test-classes \
    + *     com.github.dimiro1.mynes.headless.SpriteLimitROM \
    + *     mynes-headless/src/test/resources/sprite-limit/sprite-limit.nes
    + * 
    + * Nothing outside the JDK is on that class path, which is the point: regenerating the cartridge + * costs a compile and nothing else. + * + * @see NESdev: init code + * @see NESdev: sprite evaluation + */ +final class SpriteLimitROM { + /** + * Where the one program bank lands. An NROM cartridge with a single 16KB bank mirrors it into + * both halves of $8000-$FFFF, and the 6502 reads its vectors from the top of memory, so this is + * the address the code below has to be assembled for. + */ + private static final int PRG_BASE = 0xC000; + + /** + * Where {@link #PALETTE} sits inside that bank. Far enough past the code that the two cannot + * meet, and on a page boundary so the address in the load instruction reads as itself. + */ + private static final int PALETTE_AT = 0xC100; + + /** + * The scanline every sprite is given as its Y coordinate. A sprite is drawn on the line + * below the one it names, so the row to look at is one more than this. + */ + static final int SPRITE_Y = 100; + + /** + * How far apart they are put, in pixels. Four, so that all sixty four fit across a 256 pixel + * line with room to spare, and so that each one still shows past the one before it. + */ + static final int SPRITE_SPACING = 4; + + static final int SPRITES = 64; + + /** + * How wide the row comes out when the hardware's limit applies: the first eight sprites, each + * eight pixels wide and four pixels along from the one before, which is 7 * 4 + 8. + */ + static final int DRAWN_WIDTH_WITH_THE_LIMIT = 36; + + /** + * Thirty two bytes of palette RAM, copied in wholesale. Only the sprite half matters: colour 1 + * of each of the four palettes is a colour of its own, so that a row of blocks cycling through + * them can be told apart from one long block. + */ + private static final int[] PALETTE = { + 0x0F, 0x00, 0x10, 0x30, + 0x0F, 0x00, 0x10, 0x30, + 0x0F, 0x00, 0x10, 0x30, + 0x0F, 0x00, 0x10, 0x30, + 0x0F, 0x16, 0x27, 0x18, + 0x0F, 0x1A, 0x2A, 0x3A, + 0x0F, 0x12, 0x22, 0x32, + 0x0F, 0x14, 0x24, 0x34, + }; + + private SpriteLimitROM() { + } + + /** + * The whole .nes file: a sixteen byte header, one 16KB program bank and one 8KB character bank. + */ + static byte[] image() { + var image = new byte[16 + 0x4000 + 0x2000]; + + // "NES", the end-of-file byte the format is named after, then one bank of each. Everything + // after that is zero, which is mapper 0, horizontal mirroring, no battery and no trainer. + image[0] = 'N'; + image[1] = 'E'; + image[2] = 'S'; + image[3] = 0x1A; + image[4] = 1; + image[5] = 1; + + var program = program(); + System.arraycopy(program, 0, image, 16, program.length); + + // Tile 1, eight rows of the low bit plane switched on and the high one left off, which is + // colour 1 of whichever palette the sprite's attribute byte names. + for (var row = 0; row < 8; row++) { + image[16 + 0x4000 + 0x10 + row] = (byte) 0xFF; + } + + return image; + } + + /** + * Writes it somewhere. + */ + static Path writeTo(final Path path) throws IOException { + return Files.write(path, image()); + } + + /** + * Rewrites the checked-in cartridge, which is the only reason a test fixture has a main. + *

    + * The alternative is a note in a comment saying to run something that cannot be run, which is + * what this replaced: {@code SpriteLimitTests} compares the file against {@link #image()} and + * has to be able to say what to do when they disagree. + * + * @param args where to write it. The class Javadoc has the whole command. + */ + public static void main(final String[] args) throws IOException { + if (args.length != 1) { + System.err.println("usage: SpriteLimitROM "); + System.exit(2); + } + + System.out.println("wrote " + writeTo(Path.of(args[0])) + ", " + image().length + " bytes"); + } + + /** + * The program bank. + *

    + * Hand assembled, with the disassembly beside each instruction. The four branch offsets are the + * only numbers here that cannot be read off the source: each is counted in bytes from the + * instruction after the branch, so inserting anything inside one of these loops means + * counting it again. The jump at the end is not one of them -- it lands on itself, and where + * that is falls out of how long everything before it turned out to be. + */ + private static byte[] program() { + var body = new int[]{ + 0x78, // SEI + 0xD8, // CLD + 0xA2, 0x40, // LDX #$40 + 0x8E, 0x17, 0x40, // STX $4017 no APU frame interrupt + 0xA2, 0xFF, // LDX #$FF + 0x9A, // TXS + 0xE8, // INX X = 0 from here down + 0x8E, 0x00, 0x20, // STX $2000 + 0x8E, 0x01, 0x20, // STX $2001 + 0x8E, 0x10, 0x40, // STX $4010 + + // The two VBlanks every cartridge waits for: the PPU ignores $2000, $2001, $2005 + // and $2006 until the beam first reaches the pre-render line, so anything written + // before this would be dropped. + 0x2C, 0x02, 0x20, // BIT $2002 first + 0x10, 0xFB, // BPL -5 + 0x2C, 0x02, 0x20, // BIT $2002 second + 0x10, 0xFB, // BPL -5 + + 0xA9, 0x3F, // LDA #$3F point $2007 at palette RAM + 0x8D, 0x06, 0x20, // STA $2006 + 0xA9, 0x00, // LDA #$00 + 0x8D, 0x06, 0x20, // STA $2006 + 0xA2, 0x00, // LDX #$00 + 0xBD, PALETTE_AT & 0xFF, PALETTE_AT >> 8, + // LDA PALETTE,X + 0x8D, 0x07, 0x20, // STA $2007 + 0xE8, // INX + 0xE0, PALETTE.length, // CPX #32 + 0xD0, 0xF5, // BNE -11 + + 0xA9, 0x00, // LDA #$00 + 0x8D, 0x03, 0x20, // STA $2003 OAMADDR at the start of OAM + 0x85, 0x10, // STA $10 and the X coordinate at the left + 0xA2, 0x00, // LDX #$00 + + // Sixty four sprites, written a byte at a time through $2004 rather than by DMA, + // because the address walks itself and there is nothing here in a hurry. + 0xA9, SPRITE_Y, // LDA #100 the same line for every one of them + 0x8D, 0x04, 0x20, // STA $2004 + 0xA9, 0x01, // LDA #$01 tile 1, the solid block + 0x8D, 0x04, 0x20, // STA $2004 + 0x8A, // TXA + 0x29, 0x03, // AND #$03 a different palette each time round + 0x8D, 0x04, 0x20, // STA $2004 + 0xA5, 0x10, // LDA $10 + 0x8D, 0x04, 0x20, // STA $2004 X + 0x18, // CLC + 0x69, SPRITE_SPACING, // ADC #4 + 0x85, 0x10, // STA $10 + 0xE8, // INX + 0xE0, SPRITES, // CPX #64 + 0xD0, 0xE1, // BNE -31 + + 0xA9, 0x14, // LDA #$14 sprites on, left column included + 0x8D, 0x01, 0x20, // STA $2001 + }; + + var bank = new byte[0x4000]; + + for (var i = 0; i < body.length; i++) { + bank[i] = (byte) body[i]; + } + + // Where the jump that follows the body sits, and equally where it goes: there is nothing + // left to do, and a 6502 with nothing to do has to be given somewhere to do it. + var forever = PRG_BASE + body.length; + + bank[body.length] = (byte) 0x4C; // JMP forever + bank[body.length + 1] = (byte) forever; + bank[body.length + 2] = (byte) (forever >> 8); + + for (var i = 0; i < PALETTE.length; i++) { + bank[PALETTE_AT - PRG_BASE + i] = (byte) PALETTE[i]; + } + + // The three vectors, at the top of the bank. NMI is never enabled and IRQ never fires, so + // both of those point at the same loop the program ends in. + var vectors = 0x4000 - 6; + + bank[vectors] = (byte) forever; + bank[vectors + 1] = (byte) (forever >> 8); + bank[vectors + 2] = (byte) PRG_BASE; + bank[vectors + 3] = (byte) (PRG_BASE >> 8); + bank[vectors + 4] = (byte) forever; + bank[vectors + 5] = (byte) (forever >> 8); + + return bank; + } +} diff --git a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/SpriteLimitTests.java b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/SpriteLimitTests.java new file mode 100644 index 0000000..6e055d2 --- /dev/null +++ b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/SpriteLimitTests.java @@ -0,0 +1,149 @@ +package com.github.dimiro1.mynes.headless; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * What {@code --hack unlimited-sprites} does to the picture, from a command line to a PNG. + *

    + * The core's own tests pin the behaviour far more precisely than this does, by driving the PPU + * through its registers and reading the framebuffer. What they cannot say is that the flag on the + * command line reaches those pixels: that is several classes away from the chip, and the report + * only proves the flag reached the machine. So this runs the whole thing twice over + * {@link SpriteLimitROM}'s cartridge and counts what came out. + */ +class SpriteLimitTests { + private static final String ROM = "src/test/resources/sprite-limit/sprite-limit.nes"; + + /** + * The row the sprites land on, which is one below the coordinate they were given. + */ + private static final int ROW = SpriteLimitROM.SPRITE_Y + 1; + + /** + * How tall a sprite is, and so how many rows the change is allowed to touch. + */ + private static final int SPRITE_HEIGHT = 8; + + @TempDir + private Path directory; + + /** + * The cartridge on disk and the generator beside it cannot drift apart, which is the whole + * reason it is safe to have both. Without this, a change to the assembly would leave a .nes + * nobody could account for, and the comments in {@link SpriteLimitROM} would be describing a + * file that no longer matched them. + */ + @Test + void theCheckedInCartridgeIsExactlyWhatTheGeneratorProduces() throws Exception { + assertArrayEquals( + SpriteLimitROM.image(), + Files.readAllBytes(Path.of(ROM)), + "the cartridge and the code that describes it have come apart. To rewrite it:\n" + + " mvn -q -pl mynes-headless -am test-compile\n" + + " java -cp mynes-headless/target/test-classes " + + SpriteLimitROM.class.getName() + " \\\n" + + " mynes-headless/" + ROM + "\n" + + "and change sprite-limit.s to match, since nothing checks that one"); + } + + @Test + void theHardwareLimitCutsTheRowToItsFirstEightSprites() throws Exception { + var picture = run(); + + assertEquals( + SpriteLimitROM.DRAWN_WIDTH_WITH_THE_LIMIT, + drawnWidth(picture), + "eight sprites four pixels apart, the last of them eight pixels wide"); + } + + @Test + void liftingItDrawsAllSixtyFourRightAcrossTheLine() throws Exception { + var picture = run("--hack", "unlimited-sprites"); + + assertEquals( + picture.getWidth(), + drawnWidth(picture), + "sixty four of them reach further than the beam does"); + } + + /** + * The hack adds sprites and does nothing else. Anything appearing on another row would mean it + * had found sprites the evaluation never looked at, or drawn them on the wrong line. + */ + @Test + void nothingOutsideThatRowOfSpritesChangesAtAll() throws Exception { + var without = run(); + var with = run("--hack", "unlimited-sprites"); + + for (var y = 0; y < without.getHeight(); y++) { + if (y >= ROW && y < ROW + SPRITE_HEIGHT) { + continue; + } + + for (var x = 0; x < without.getWidth(); x++) { + assertEquals(without.getRGB(x, y), with.getRGB(x, y), + "the picture differs at " + x + "," + y); + } + } + } + + /** + * How many pixels of {@link #ROW} are not the backdrop. + *

    + * Measured against the corner of the picture rather than against a colour written down here, + * since which RGB a palette entry comes out as is a property of the palette rather than of the + * emulator, and this test has no opinion about televisions. + */ + private static int drawnWidth(final BufferedImage picture) { + var backdrop = picture.getRGB(0, 0); + var drawn = 0; + + for (var x = 0; x < picture.getWidth(); x++) { + if (picture.getRGB(x, ROW) != backdrop) { + drawn++; + } + } + + return drawn; + } + + /** + * Runs the cartridge and hands back the last frame. + *

    + * {@code --full-frame} so that {@link #ROW} means the scanline it says: the crop a television + * would apply takes eight lines off the top, and correcting for it here would be one more thing + * to get wrong. + */ + private BufferedImage run(final String... extra) throws IOException { + var out = directory.resolve(extra.length == 0 ? "plain" : "hacked"); + + var args = new String[extra.length + 10]; + + args[0] = "--rom"; + args[1] = ROM; + args[2] = "--out"; + args[3] = out.toString(); + args[4] = "--quiet"; + args[5] = "--frames"; + args[6] = "60"; + args[7] = "--screenshot"; + args[8] = "last"; + args[9] = "--full-frame"; + + System.arraycopy(extra, 0, args, 10, extra.length); + + assertEquals(Headless.EXIT_OK, Headless.run(args)); + + return ImageIO.read(out.resolve("frame-000060.png").toFile()); + } +} diff --git a/mynes-headless/src/test/resources/PROVENANCE b/mynes-headless/src/test/resources/PROVENANCE index 850766f..aa30527 100644 --- a/mynes-headless/src/test/resources/PROVENANCE +++ b/mynes-headless/src/test/resources/PROVENANCE @@ -1,5 +1,6 @@ Two cartridges copied from mynes-core/src/test/resources, where the originals live alongside the -readmes and expected-output logs that came with them, and one that has no original here. +readmes and expected-output logs that came with them, one that has no original here, and one with no +original anywhere. They are here rather than reached for across the module boundary because of what these tests do with them. Everything in the core reads a ROM off the classpath; the headless mode takes a --rom @@ -29,3 +30,28 @@ quietly tracking somebody else's. of "Hello World!" at 0x398 to "MyNES Patch!". A real patch file rather than one the test builds, since the file format is half of what is being tested. + +The fourth was written here, because nothing anybody has shipped does what it does. It puts all +sixty four sprites on one scanline, which is the overflow --hack unlimited-sprites exists to undo -- +and a real game is written to avoid exactly that, so a collection of them is no help: Punch-Out!!'s +first fight peaks at seven sprites on a line and Battletoads' first level at eight. + +It is checked in three times over, which is a duplication on purpose, because the three are for +different readers. The .nes is what the tests run, since handing the headless mode a path is the +point of this directory. The Java beside it is what builds that file, so the bytes are accounted for +without an assembler in the build. And the .s is the program as a program, for the next person to +change it. Only the first two can drift, and a test stops them. + + sprite-limit/sprite-limit.nes Written here, no licence to worry about. NROM, 24592 bytes: one + row of sixty four sprites on one scanline, which is the overflow + --hack unlimited-sprites undoes. + SpriteLimitROM.java In this module's test sources, and the assembler: it emits the + bytes above, with the disassembly beside each instruction, and its + main rewrites the file. SpriteLimitTests asserts the two agree and + its failure says the command, so the binary cannot outlive the + code that explains it. + sprite-limit/sprite-limit.s The same program for asm6, which nothing in the build reads and + nothing here has run -- it is reference, checked against the .nes + instruction for instruction through the emulator's own + disassembler rather than by assembling it. Nothing enforces that, + so a change to the program has to be made here as well by hand. diff --git a/mynes-headless/src/test/resources/sprite-limit/sprite-limit.nes b/mynes-headless/src/test/resources/sprite-limit/sprite-limit.nes new file mode 100644 index 0000000..ad58ef9 Binary files /dev/null and b/mynes-headless/src/test/resources/sprite-limit/sprite-limit.nes differ diff --git a/mynes-headless/src/test/resources/sprite-limit/sprite-limit.s b/mynes-headless/src/test/resources/sprite-limit/sprite-limit.s new file mode 100644 index 0000000..741a261 --- /dev/null +++ b/mynes-headless/src/test/resources/sprite-limit/sprite-limit.s @@ -0,0 +1,143 @@ +; sprite-limit.nes -- all sixty four sprites on one scanline. +; +; The cartridge --hack unlimited-sprites is demonstrated against, in the form a person reads rather +; than the form a program builds. No shipped game does this: the hardware draws eight sprites a +; scanline and every cartridge is written to stay under that, so the overflow the hack exists to +; undo is exactly what a real game spends its effort avoiding. Punch-Out!!'s first fight peaks at +; seven sprites on a line and Battletoads' first level at eight. +; +; What it draws is one row of sixty four solid blocks four pixels apart, cycling the four sprite +; palettes so the individual sprites can be counted. With the sprite limit in force the row is +; thirty six pixels wide, because only the first eight are drawn. With it lifted the row crosses +; the screen. +; +; Nothing assembles this, and the .nes beside it was not built from it. SpriteLimitROM, in this +; module's test sources, is the assembler: it emits the same program as bytes, SpriteLimitTests +; holds the cartridge against it, and running it is how the cartridge is rewritten. That is why the +; build needs no assembler and why this file has no way to break it. +; +; So this is here to be read, and to be changed by somebody who would rather edit a program than +; count branch offsets by hand. Doing that means changing SpriteLimitROM to match and rerunning it; +; its Javadoc has the command. +; +; To assemble this file instead, the syntax is asm6's -- .base, .pad and .dsb are its directives -- +; and it writes the .nes in one pass, with no linker config: +; +; asm6 sprite-limit.s sprite-limit.nes +; +; asm6 is one C file by loopy, maintained as asm6f at github.com/freem/asm6f, and builds with +; "cc -O2 -o asm6 asm6f.c". Another assembler will want this file rewritten: ca65 in particular +; spells the directives differently and needs a linker config to place the bank. + +; ---------------------------------------------------------------- iNES header + + .db "NES", $1A + .db 1 ; one 16KB program bank + .db 1 ; one 8KB character bank + .dsb 10, $00 ; mapper 0, horizontal mirroring, no battery, no trainer + +; ---------------------------------------------------------------- program bank + +; An NROM cartridge with a single bank mirrors it into both halves of $8000-$FFFF, and the 6502 +; reads its vectors from the top of memory, so the bank is assembled for the upper copy. + .base $C000 + +SPRITES = 64 +SPRITE_Y = 100 ; the same line for all of them; a sprite is drawn on the one below +SPRITE_SPACING = 4 ; four pixels apart, so all sixty four fit across the 256 + +xpos = $10 ; the X coordinate, counted up as the loop goes + +reset: + sei + cld + ldx #$40 + stx $4017 ; no APU frame interrupt + ldx #$FF + txs + inx ; X = 0 from here down + stx $2000 + stx $2001 + stx $4010 + +; The two VBlanks every cartridge waits for. The PPU ignores $2000, $2001, $2005 and $2006 until +; the beam first reaches the pre-render line, about 29658 CPU cycles in, so anything written before +; this would be dropped on the floor. +vblank1: + bit $2002 + bpl vblank1 +vblank2: + bit $2002 + bpl vblank2 + +; Thirty two bytes of palette RAM, copied in wholesale. + lda #$3F + sta $2006 + lda #$00 + sta $2006 + ldx #$00 +copypalette: + lda palette,x + sta $2007 + inx + cpx #32 + bne copypalette + +; Sixty four sprites, written a byte at a time through $2004 rather than by DMA, because the +; address walks itself and there is nothing here in a hurry. + lda #$00 + sta $2003 ; OAMADDR at the start of OAM + sta xpos ; and the first sprite at the left edge + ldx #$00 +sprite: + lda #SPRITE_Y + sta $2004 ; Y + lda #$01 + sta $2004 ; tile 1, the solid block + txa + and #$03 ; a different palette every fourth sprite + sta $2004 ; attributes + lda xpos + sta $2004 ; X + clc + adc #SPRITE_SPACING + sta xpos + inx + cpx #SPRITES + bne sprite + + lda #$14 ; sprites on, left column included + sta $2001 + +; Nothing left to do, and a 6502 with nothing to do has to be given somewhere to do it. +forever: + jmp forever + +; ---------------------------------------------------------------- data + +; On a page of its own, far enough past the code that the two cannot meet however the code grows. + .pad $C100 +palette: + .db $0F, $00, $10, $30 + .db $0F, $00, $10, $30 + .db $0F, $00, $10, $30 + .db $0F, $00, $10, $30 + .db $0F, $16, $27, $18 ; only the sprite half matters: colour 1 of each palette is a + .db $0F, $1A, $2A, $3A ; colour of its own, so that a row of blocks cycling through them + .db $0F, $12, $22, $32 ; can be told apart from one long block + .db $0F, $14, $24, $34 + + .pad $FFFA + .dw forever ; NMI, which is never enabled + .dw reset + .dw forever ; IRQ, which never fires + +; ---------------------------------------------------------------- character bank + +; Not addressed by the CPU at all, so the base goes back to zero for it. + .base $0000 + + .dsb 16, $00 ; tile 0, blank + .db $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF ; tile 1, the low bit plane solid + .dsb 8, $00 ; and the high one left off, which is colour 1 + .dsb 8192 - 32, $00