From 736aebb9498dc14742f8c2753a7c9f4cb28ebb6a Mon Sep 17 00:00:00 2001 From: dimiro1 Date: Mon, 24 Aug 2026 12:20:05 +0200 Subject: [PATCH] Give the game more time a frame, from a Hacks menu or --hack overclock A NES game does a frame's worth of work between one NMI and the next, and when that work does not fit the main loop overruns: the next NMI finds it unfinished, a lag frame goes by, and the picture stutters. Super Mario Bros. 3 and Gradius under sprite load are the cases everybody knows, and nothing an accurate emulator does will fix them, because it is not a fault in the console -- it is a cartridge asking for more cycles than 29780. What every mature emulator offers instead is this, which Mesen calls "additional scanlines before NMI / after NMI": the PPU idles through extra scanlines, the CPU gets ~113.67 more cycles a line on NTSC and ~106.56 on PAL, and the game finishes in time. The whole of it is four lines in `PPU.advance`. On the wrap of the post-render line the beam runs line 240 again, and on the wrap of the last line of blanking it runs that one again -- so the extra post-render lines land between the picture and the VBlank flag going up, and the extra vblank lines between the end of blanking and the pre-render line with the flag still up. **The scanline numbers never change**, which is what makes this small: a great deal of the chip keys on 240, 241 and the pre-render line by number, and a repeated 240 is indistinguishable from a longer idle one. The rejected alternative was Mesen's, which shifts the line numbers instead and touches every comparison against them. Two clocks are deliberately not stretched. `clock++` is skipped on a repeat, because what that counter is measured against is OAM losing its charge and the charge leaks in the television's time -- without the skip, anything past 270 lines on NTSC would push a frame's blanking past `Region.oamDecayDots` and every sprite in the game would vanish once a frame. And `NES.tick` calls a new `APU.idle` instead of `APU.tick` on those cycles, which is what keeps pitch, tempo and the samples-per-frame count the hardware's; Mesen stops its APU on the extra lines for the same reason. `idle` still does `cycles++`, and that is mandatory rather than tidy: the parity of that counter is what `CPUBus.isGetCycle` reads, the MMU asks the same question of the CPU's counter when it starts a DMA, and the two only agree because both advance once per `NES.tick`. A counter that stood still for 131 scanlines would come back inverted and a sprite DMA would take 513 cycles where the hardware takes 514. **Unlike unlimited-sprites this is a timing hack, so it changes what the game does**, and everything else follows from that. It rides inside a movie in a new `OVCK` chunk and is put back on replay, exactly as the Game Genie codes are and for a sharper reason -- a take recorded with it and replayed without diverges within a second. Both front ends refuse to change it while a recording is running. The setting is not in a save state, being the Hacks menu's tick like `ExtraSprites.enabled` and `MMU.genie`; the count of repeats the beam is part way through *is*, so a state taken mid-line runs on the way the machine it came from did, and one loaded into a machine with the hack off moves on at the next wrap. Reach for the before-NMI number. Extra post-render lines break nothing a game observes except that the frame is longer; extra vblank lines move the pre-render line, and so the picture, relative to the NMI, which is what code counting cycles down to a mid-screen split is measuring. Either way the pre-render line arrives later in CPU cycles, so a program that waits out the warm-up by counting 29658 cycles rather than by waiting for two VBlanks has its first $2000/$2001 writes dropped -- the same class of difference PAL's fifty extra lines make. The desktop therefore offers percentages of the region's own frame and puts all of them before the NMI; after-NMI is reachable from the command line and the REPL. There is no game to demonstrate this on. A cartridge only lags where it is loaded, which is not somewhere a test can reliably reach, so `overclock.nes` lags on purpose and lags every time: a lap of its main loop takes 42500 cycles, which is 1.43 NTSC frames, so it finishes one lap every two frames on the hardware and one a frame at `overclock=131`. It counts frames at $00-$01 and laps at $02-$03 and recolours the whole screen once a lap through the background palette hack, so `--dump ram` and `video.frameChanges` both answer. `OverclockROM` assembles it beside `SpriteLimitROM`, with an asm6 twin and a `PROVENANCE` block. Measured on it over 300 frames: 149 laps plain, 149 at `overclock=66` -- a longer frame that is still not long enough buys nothing at all -- and 298 at `overclock=131`. The cycle deltas are exact, `run.apuCycles` still equals `run.cpuCycles`, and `audio.samples` moves by 3 in 220138. On Super Mario Bros. at 900 frames the picture hash and the sample count are identical with the hack and without it, and only the cycle count and `frameChanges` differ. `run.hacks.overclock` is always present with both halves, so two reports still compare key for key, and `ppu.onExtraLine` is there to explain a run that stopped on line 240 and looks stuck. No new dependencies, no new fields on any chip outside the PPU, and `--play` refuses `--hack overclock` while still combining with `--hack unlimited-sprites`. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 53 ++- README.md | 34 +- .../java/com/github/dimiro1/mynes/APU.java | 49 ++- .../java/com/github/dimiro1/mynes/NES.java | 14 +- .../com/github/dimiro1/mynes/Overclock.java | 120 +++++++ .../java/com/github/dimiro1/mynes/PPU.java | 106 +++++- .../com/github/dimiro1/mynes/state/Movie.java | 71 +++- .../dimiro1/mynes/state/MovieRecorder.java | 22 +- .../github/dimiro1/mynes/OverclockTests.java | 69 ++++ .../dimiro1/mynes/ppu/NESOverclockTests.java | 232 ++++++++++++ .../dimiro1/mynes/ppu/PPUOverclockTests.java | 334 ++++++++++++++++++ .../dimiro1/mynes/state/MovieTests.java | 112 ++++++ .../state/SaveStateCompletenessTests.java | 19 + .../mynes/state/SaveStateDivergenceTests.java | 60 ++++ .../com/github/dimiro1/mynes/ui/Config.java | 38 +- .../github/dimiro1/mynes/ui/GameUIFrame.java | 82 ++++- .../dimiro1/mynes/ui/OverclockSetting.java | 108 ++++++ .../github/dimiro1/mynes/ui/ConfigTests.java | 40 +++ .../mynes/ui/OverclockSettingTests.java | 69 ++++ .../dimiro1/mynes/headless/Headless.java | 14 + .../dimiro1/mynes/headless/Options.java | 123 ++++++- .../github/dimiro1/mynes/headless/Repl.java | 114 +++++- .../github/dimiro1/mynes/headless/Report.java | 10 + .../mynes/headless/HeadlessRunTests.java | 90 +++++ .../dimiro1/mynes/headless/OptionsTests.java | 107 ++++++ .../dimiro1/mynes/headless/OverclockROM.java | 310 ++++++++++++++++ .../mynes/headless/OverclockRunTests.java | 273 ++++++++++++++ .../dimiro1/mynes/headless/ReplTests.java | 69 ++++ mynes-headless/src/test/resources/PROVENANCE | 24 +- .../test/resources/overclock/overclock.nes | Bin 0 -> 24592 bytes .../src/test/resources/overclock/overclock.s | 199 +++++++++++ 31 files changed, 2894 insertions(+), 71 deletions(-) create mode 100644 mynes-core/src/main/java/com/github/dimiro1/mynes/Overclock.java create mode 100644 mynes-core/src/test/java/com/github/dimiro1/mynes/OverclockTests.java create mode 100644 mynes-core/src/test/java/com/github/dimiro1/mynes/ppu/NESOverclockTests.java create mode 100644 mynes-core/src/test/java/com/github/dimiro1/mynes/ppu/PPUOverclockTests.java create mode 100644 mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/OverclockSetting.java create mode 100644 mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/OverclockSettingTests.java create mode 100644 mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/OverclockROM.java create mode 100644 mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/OverclockRunTests.java create mode 100644 mynes-headless/src/test/resources/overclock/overclock.nes create mode 100644 mynes-headless/src/test/resources/overclock/overclock.s diff --git a/CLAUDE.md b/CLAUDE.md index ad3c238..990d3bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,34 @@ and they mostly manage it -- Punch-Out!!'s first fight peaks at seven, Battletoa 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. +**The other hack is a timing hack, and that is a different kind of thing.** `--hack overclock=131` +makes the PPU idle through 131 extra scanlines a frame, which is about 113.67 CPU cycles each on +NTSC, so a game whose main loop overruns its frame stops dropping one -- the every-other-frame +stutter Super Mario Bros. 3 and Gradius get under load. The picture is the hardware's, dot for dot, +because the extra lines are lines the beam is already idle on; the sound is a hardware frame's worth +too, because `APU.idle()` holds the sound chip still through them, so `audio.samples` and the music's +pitch and tempo do not move. What does move is **what the game does**, so two runs that disagree +about `run.hacks.overclock` are two different games rather than two views of one -- which is why, +unlike unlimited-sprites, it rides inside a movie, is refused while one is recording, and is refused +alongside `--play`. + +`--hack overclock=131+20` puts twenty of the lines after the NMI instead. **Reach for the +before-NMI number.** Extra post-render lines break nothing a game observes except that the frame is +longer; extra vblank lines move the pre-render line -- and so the picture -- relative to the NMI, +which is exactly what code that cycle-counts down to a mid-screen split is measuring. Either way the +pre-render line arrives later in CPU cycles, so a program that waits out the PPU's warm-up by +counting 29658 cycles rather than by waiting for two VBlanks has its first `$2000`/`$2001` writes +dropped -- the same class of difference PAL's fifty extra lines make. `hack overclock LINES [MORE]` +and `hack overclock off` do it inside an interactive session, and `Overclock.percentOf` is what turns +the desktop's percentages into lines. + +Do not go looking for a game to see this one on either. A game only lags where it is loaded, which +is not somewhere a test can reliably reach, so the demonstration is `overclock/overclock.nes`: a lap +of its main loop takes 42500 cycles, which is 1.43 NTSC frames, so it finishes one lap every two +frames on the hardware and one a frame at `overclock=131`. It counts frames at `$00-$01` and laps at +`$02-$03`, so `--dump ram` reads the answer, and it recolours the whole screen once a lap so +`video.frameChanges` says the same thing. `OverclockROM` assembles it, beside `SpriteLimitROM`. + 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 are `static` on purpose: `SaveStateCompletenessTests` vandalises every primitive array it can reach @@ -216,14 +244,16 @@ composes out of the rewind claim above: a rewound machine is byte for byte the m went forward, so there is nothing lost by truncating the log to match. `--play` is the input, so it refuses `--record`, `--input`, `--input-file`, `--reset-at`, `--genie`, -`--load-state`, `--sram-in` and `--interactive` -- each of those would be a second answer to a -question the movie has already answered. It defaults `--frames` to the movie's own length; asking for -more runs past the end with nothing held down. +`--hack overclock`, `--load-state`, `--sram-in` and `--interactive` -- each of those would be a +second answer to a question the movie has already answered. `--hack unlimited-sprites` still combines +with it, being a change to the picture and to nothing the replay depends on. It defaults `--frames` +to the movie's own length; asking for more runs past the end with nothing held down. `record`, `record start` and `record stop [PATH]` do the same inside an interactive session -- the shape of `rewind` rather than of `hack`, since the interesting form is the one that takes a file. -Mutating `genie`/`ungenie`/`genie clear` are refused while recording, because a movie pins the codes -at the moment it starts and a file naming one set that was played against another cannot be replayed. +Mutating `genie`/`ungenie`/`genie clear` and `hack overclock` are refused while recording, because a +movie pins both at the moment it starts and a file naming one set of codes, or one number of extra +scanlines, that was played against another cannot be replayed. **`run.record` and `run.replay` join the comparability checklist**, beside `run.state`, `run.region`, `run.hacks` and `run.genie`. Both are always present with explicit nulls. A run that started at power @@ -234,13 +264,15 @@ a `--sram-in`, a loaded state mid-session, or a rewind that went back past the s The Game Genie codes ride inside the movie and are put back on replay. They have to: a cheated cartridge is byte for byte an honest one, so `cart.sha256` cannot tell them apart and nothing else in -the file would. +the file would. **The overclock rides in an `OVCK` chunk beside them**, for the same reason and a +sharper one: it decides how much of its work the game gets through in a frame, so a replay at the +hardware's timing is a replay of a different game rather than of the same game seen differently. The desktop has **Machine > Record Movie... / Play Movie...**. While either is running the pad is latched once a frame on the emulation thread rather than reaching the controller the moment a key -moves, and Power Cycle, Region and the Game Genie item are greyed out -- the first two would build a -new machine and take the recorder with it. Rewinding during playback stops it and hands the game -back. +moves, and Power Cycle, Region, the Game Genie item and the Overclock submenu are greyed out -- the +first two would build a new machine and take the recorder with it. Rewinding during playback stops it +and hands the game back, and the menu's own overclock goes back on when it does. ### Running a romhack @@ -357,7 +389,8 @@ Four Maven modules, and the arrows between them only point one way. ``` mynes-core/ depends on nothing - mynes/ the console: CPU, PPU, APU, BUS, MMU, VRAM, Cart, Region, controllers + mynes/ the console: CPU, PPU, APU, BUS, MMU, VRAM, Cart, Region, Overclock, + controllers mynes/mappers/ mappers 0 to 4 mynes/state/ save states, battery .sav files, and .mnm session recordings mynes/debug/ the disassembler and the breakpoints, shared by the window and the REPL diff --git a/README.md b/README.md index 084a3ef..2790860 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,15 @@ chip would have dropped, so a scanline holding more than eight of them stops fli 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. -**Game Genie codes**, from **Hacks > Game Genie...**, which is the other thing in that menu and the +**Overclock** is the other one, and it is not that kind of hack: it gives the game extra idle +scanlines a frame — +25%, +50%, +100% or +200% — so that a main loop which overruns its frame stops +dropping one, which is what the every-other-frame stutter in Super Mario Bros. 3 and Gradius under +load actually is. The picture is drawn exactly as the hardware draws it, and the music keeps its +pitch and tempo, because the sound chip stands still through the extra lines. But the game does get +more done between one frame and the next, so this is not the game as it shipped. It is remembered +like the tick above, and it is greyed out while a movie is recording or playing. + +**Game Genie codes**, from **Hacks > Game Genie...**, which is the last thing in that menu and the one thing in it the console really did do. Six letters or eight, from the sixteen a code is spelled with; eight-letter codes carry a byte the cartridge has to answer with before they fire, which is what pins one to a single bank. Nothing is patched — the device sat between the cartridge and the @@ -333,22 +341,26 @@ seconds to start up, the jar about a third of one. 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. + otherwise, and `run.hacks` in the report is part of what to check before diffing two runs. There + are two. `unlimited-sprites` 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. `overclock=N[+M]` adds N idle scanlines a frame before the + NMI and M after it, which is about 113.67 CPU cycles each on NTSC, so a game whose main loop + overruns its frame stops dropping one. That one is a *timing* hack: the picture is the hardware's + and so is the sound, but the game gets more done between frames, which makes an overclocked run + and a plain one two different games rather than two views of one. - **`--genie CODE`** puts a Game Genie code in the cartridge slot. Repeatable, and comma separated. Unlike `--patch` the cartridge is not modified at all — the device answered the bus in its place — so `cart.sha256` is the plain one and `run.genie` is the only thing in the report that tells a cheated run from an honest one. Which also means a save state taken with codes in will load into a machine with them out, without a word of complaint. - **`--record FILE` and `--play FILE`** write and replay a session. `--play` is the input, so it - refuses `--input`, `--reset-at`, `--genie`, `--load-state` and the rest of what a movie already - says, and it defaults `--frames` to the movie's own length -- ask for more and the run carries on - past the end with nothing held down. `run.record` and `run.replay` in the report join the list of - things to check before diffing two runs. `record start` and `record stop` are commands in the - interactive session as well. + refuses `--input`, `--reset-at`, `--genie`, `--hack overclock`, `--load-state` and the rest of + what a movie already says, and it defaults `--frames` to the movie's own length -- ask for more + and the run carries on past the end with nothing held down. `run.record` and `run.replay` in the + report join the list of things to check before diffing two runs. `record start` and `record stop` + are commands in the interactive session as well. - **`--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/APU.java b/mynes-core/src/main/java/com/github/dimiro1/mynes/APU.java index 2fefeb1..1f9105e 100644 --- a/mynes-core/src/main/java/com/github/dimiro1/mynes/APU.java +++ b/mynes-core/src/main/java/com/github/dimiro1/mynes/APU.java @@ -272,6 +272,49 @@ public void tick() { sample(); } + /** + * Advances the chip's parity and nothing else, for a CPU cycle the {@link Overclock} hack has + * added to the frame. + * + *

Why the sound stands still

+ * + * An overclocked frame is longer in CPU cycles but is still one frame of the game. If the APU + * ran through the extra lines, everything it counts in cycles would run with them: the frame + * counter's quarter and half frames -- so every envelope, sweep and length counter -- the five + * timers, and the decimator that turns 1.79MHz into 44100 samples a second. The music would come + * out slow, the pitches flat and the frame a thousand samples instead of 735, which a front end + * pacing itself on a blocking write to the sound card would then run at the wrong speed. Mesen + * holds its APU on the extra lines for exactly this reason, and so does this. + * + *

Why the counter still moves

+ * + * {@link #cycles} is not only a count of work done; its parity is what + * {@link CPUBus#isGetCycle} reads, and two chips have to agree about it. The MMU asks that + * question of the CPU's counter when it starts a DMA cycle and this chip asks it of its own, and + * the two answers only match because both counters advance once per {@link NES#tick()}. A + * counter that stood still for 131 scanlines would come back inverted, and a sprite DMA would + * take 513 cycles where the hardware takes 514. + *

+ * A pending $4015 clear is honoured for the same reason: it is due on the next get cycle, and + * that cycle may well be one of these. + * + *

What the phase costs

+ * + * A block of held cycles is a gap in the middle of the chip's sequences rather than a stretch of + * them, so the two one-cycle write delays -- {@code FrameCounter.writeDelay} and + * {@code DMC.loadDelay} -- land after the block rather than inside it, and a block of odd length + * moves which CPU cycle the pulse and noise dividers fall on by one. Neither is audible and + * neither accumulates: the divider keeps its own period, so what shifts is the phase of a + * waveform and not its frequency. + */ + public void idle() { + if (frameIRQClearPending && CPUBus.isGetCycle(cycles)) { + setFrameIRQFlag(false); + } + + cycles++; + } + /** * Takes this cycle's output into the running average, and finishes a sample when one falls * due. @@ -372,11 +415,13 @@ public int availableSamples() { } /** - * How many CPU cycles the chip has been clocked for since power on. + * How many CPU cycles the chip has been driven or held for since power on. *

* The APU's own clock, in the same sense that {@link PPU#getFrame()} is the PPU's: it is what * says the chip is being driven at the rate it should be, including through the cycles an OAM - * DMA transfer holds the CPU off the bus. + * DMA transfer holds the CPU off the bus. "Or held", because an {@link #idle()} cycle counts + * here too -- this is the parity two chips agree on before it is a measure of work done, and it + * is what keeps {@code run.apuCycles} equal to {@code run.cpuCycles} however long a frame is. */ public long getCycles() { return cycles; diff --git a/mynes-core/src/main/java/com/github/dimiro1/mynes/NES.java b/mynes-core/src/main/java/com/github/dimiro1/mynes/NES.java index aecf43a..b911f42 100644 --- a/mynes-core/src/main/java/com/github/dimiro1/mynes/NES.java +++ b/mynes-core/src/main/java/com/github/dimiro1/mynes/NES.java @@ -110,6 +110,13 @@ public void reset() { * dots do: a $4015 read has to see the interrupt flag the frame counter raised in the cycle * doing the reading. It is clocked here rather than from the CPU because it keeps running * through an OAM DMA transfer, which the CPU spends held off the bus. + *

+ * It is the one chip the {@link Overclock} hack holds still. A cycle the PPU spends on a line it + * is running again is a cycle the game gets and the sound does not: the APU keeps its parity, + * which two chips have to agree on, and counts nothing else -- so an overclocked frame is longer + * for the program and exactly as long as a hardware one for the music. {@link APU#idle()} has + * the whole of why. Asked once per CPU cycle, which on a machine nobody is overclocking is one + * boolean read. * * @see CPU#sampleNMI() */ @@ -127,7 +134,12 @@ public void tick() { ppu.tick(); } - apu.tick(); + if (ppu.isOnExtraLine()) { + apu.idle(); + } else { + apu.tick(); + } + cpu.tick(); } diff --git a/mynes-core/src/main/java/com/github/dimiro1/mynes/Overclock.java b/mynes-core/src/main/java/com/github/dimiro1/mynes/Overclock.java new file mode 100644 index 0000000..8cb7b72 --- /dev/null +++ b/mynes-core/src/main/java/com/github/dimiro1/mynes/Overclock.java @@ -0,0 +1,120 @@ +package com.github.dimiro1.mynes; + +/** + * How many idle scanlines to add to a frame, so that the program gets more time in it. + * + *

What this is for

+ * + * A NES game does a frame's worth of work between one NMI and the next, and when that work does not + * fit the main loop overruns: the next NMI arrives with the last frame unfinished, the game skips a + * turn, and the picture stutters. Super Mario Bros. 3 under sprite load and Gradius under anything + * at all are the cases everybody knows. It is not a fault in the console -- it is a cartridge asking + * for more cycles than 29780 -- so nothing an accurate emulator does will fix it. + *

+ * What every mature emulator offers instead is this, which Mesen calls "additional scanlines before + * NMI / after NMI": the PPU idles through extra scanlines, the CPU gets + * {@code 341 / 3} = ~113.67 cycles per line on NTSC and ~106.56 on PAL, and the game finishes its + * work in time. The picture is drawn exactly as the hardware draws it; what changes is how long the + * beam spends not drawing it. + * + *

Extra lines rather than a faster CPU

+ * + * The other way to give a program more cycles is to run its clock faster, which is what Mesen's + * older "overclock rate" did. That breaks every piece of code timed against the beam inside the + * visible frame -- a mid-screen scroll split, a raster bar, an MMC3 counting scanlines -- because + * the CPU and the PPU no longer agree about where the picture is. Extra blanking lines + * cannot: they land where the beam is already idle, so every cycle-counted trick inside the picture + * still lines up dot for dot. + * + *

Before the NMI, or after it

+ * + * {@link #beforeNmi()} lines are run after the post-render line and before the VBlank flag goes up; + * {@link #afterNmi()} lines are run at the end of vertical blank, with the flag still up, before the + * pre-render line. Before is the knob to reach for. Extra post-render lines change + * nothing a game can observe except that the frame is longer. Extra vblank lines move the + * pre-render line -- and so the picture -- relative to the NMI, which is exactly what code that + * cycle-counts from the NMI down to a split is measuring. + *

+ * Either way the pre-render line arrives later in CPU cycles than the hardware puts it, so a program + * that waits out the PPU's warm-up by counting 29658 cycles rather than by waiting for two VBlanks + * has its first $2000/$2001 writes dropped. That is the same class of difference PAL's fifty extra + * lines make to an NTSC game, and it is worth knowing about before blaming the cartridge. + * + *

The two clocks that are deliberately not stretched

+ * + * The APU stands still on an extra line rather than running through it -- {@link APU#idle()} -- + * which is what keeps pitch, tempo and the samples-per-frame count the hardware's. And the PPU's own + * dot clock does not advance on one either, so OAM decay is measured in hardware time; otherwise a + * setting past 270 lines on NTSC would push a frame's blanking past the charge and every sprite in + * the game would vanish once a frame. + * + *

This is a hack, and the report says so

+ * + * Unlike the sprite limit, which changes only pixels, this changes the machine's timing and so what + * the game does. Two runs that disagree about it are two different games rather than two + * views of one, which is why it rides inside a movie the way Game Genie codes do and is refused + * while one is recording. + * + * @param beforeNmi extra scanlines between the post-render line and the VBlank flag. + * @param afterNmi extra scanlines between the end of vertical blank and the pre-render line. + * @see NESdev: PPU rendering + */ +public record Overclock(int beforeNmi, int afterNmi) { + + /** + * The most extra lines either half will take, which is Mesen's limit and is chosen for the same + * reason: it is far past anything a game needs -- four frames' worth on NTSC -- and it is a + * guard against a typo rather than a considered ceiling. + */ + public static final int MAX_SCANLINES = 1000; + + /** + * The hardware: no extra lines at all. What a machine nobody has asked to overclock runs at. + */ + public static final Overclock NONE = new Overclock(0, 0); + + public Overclock { + check(beforeNmi, "before"); + check(afterNmi, "after"); + } + + private static void check(final int lines, final String which) { + if (lines < 0 || lines > MAX_SCANLINES) { + throw new IllegalArgumentException( + "an overclock is 0 to " + MAX_SCANLINES + " scanlines " + which + + " the NMI, and " + lines + " is not."); + } + } + + /** + * Whether this is the hardware, and so whether anything below the front end has to know about it + * at all. + */ + public boolean isNone() { + return beforeNmi == 0 && afterNmi == 0; + } + + /** + * That many percent of the region's frame, added before the NMI. + *

+ * The conversion a menu needs, because the two ends think in different units: a player wants + * "half as long again to do the work in" and the chip wants a number of scanlines, and which + * number that is depends on whether the frame is 262 lines or 312. Fifty percent is 131 lines on + * NTSC and 156 on PAL. + *

+ * Before the NMI rather than split, for the reason in the class Javadoc: it is the half that + * breaks nothing a game can observe. + */ + public static Overclock percentOf(final Region region, final int percent) { + return new Overclock((int) Math.round(region.scanlinesPerFrame() * percent / 100.0), 0); + } + + @Override + public String toString() { + if (isNone()) { + return "no extra scanlines"; + } + + return beforeNmi + " extra scanlines before the NMI and " + afterNmi + " after it"; + } +} 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 749d4df..38b8d38 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 @@ -4,6 +4,7 @@ import com.github.dimiro1.mynes.state.StateIO; import java.util.Arrays; +import java.util.Objects; /** * PPU implements the picture processing unit: the 2C02 of the NTSC NES, or the 2C07 of the PAL one. @@ -29,6 +30,14 @@ *

* With rendering enabled, an NTSC odd frame drops the last dot of the pre-render line, so a frame * is 89342 dots normally and 89341 then. PAL does not, and is 106392 dots every time. + *

+ * An {@link Overclock} lengthens that frame, and is the one thing here that is not the hardware: the + * beam runs the post-render line or the last line of blanking again, as many times as it is asked + * to, so a game gets ~113.67 more CPU cycles a line to finish its work in. The line numbers never + * change, {@link #getScanline()} never names a line the chip does not have, and the picture is drawn + * dot for dot as it always was -- but the pre-render line arrives later in CPU cycles than it would + * on a console, which is a difference a program can measure. See {@link Overclock} for what that + * costs and which half of it to reach for. * * @see NESdev: PPU rendering * @see NESdev: PPU scrolling @@ -198,6 +207,29 @@ public class PPU { */ private boolean oddFrame = false; + /** + * How many extra idle scanlines to give the program per frame, which is a hack and not a + * machine: see {@link Overclock}. + *

+ * Never null. {@code SaveStateCompletenessTests} walks the console field by + * field and names what it finds after the class it lives in, so a record here contributes + * {@code Overclock.beforeNmi} and {@code Overclock.afterNmi} to that walk -- and a null would + * contribute {@code PPU.overclock} instead, which is a different pair of names for its + * exclusion list to have to know. + */ + private Overclock overclock = Overclock.NONE; + + /** + * How many times the current scanline has already been run again, and 0 on a real one. + *

+ * Counted up and compared at each line wrap against whatever {@link #overclock} says + * now, rather than latched when a frame starts. That is what makes switching the hack + * off mean off: a machine part way through a repeat simply moves on at the next wrap, and a + * state taken mid-repeat loads into a machine with no overclock without either of them having + * to know what the other was set to. + */ + private int extraLine; + /** * True while the PPU's internal reset signal is still held over $2000, $2001, $2005 and * $2006. @@ -419,6 +451,11 @@ public void reset() { dot = 0; oddFrame = false; + // The beam has been put back to the top left, so whatever repeats it was part way through + // are over. How many there are to run is the hack's setting and not the button's business, + // so that is left exactly as it was. + extraLine = 0; + // The VBlank flag is untouched, but the NMI enable bit has just gone, so the line has to // be settled again. updateNMILine(); @@ -455,7 +492,14 @@ public void tick() { // idle counts this dot as idle if nothing on it touches the bus. mapper.ppuTick(); - clock++; + // Not on a line the overclock is running again. What this clock is measured against is OAM + // losing its charge, and the charge leaks in the television's time rather than the + // emulator's -- an extra line takes none of it. Skipping it is also what stops a large + // setting wiping every sprite in the game once a frame: past 270 lines on NTSC a frame's + // blanking would otherwise outlast the window in Region.oamDecayDots. + if (extraLine == 0) { + clock++; + } settle(); @@ -571,6 +615,15 @@ private void endVBlank() { * PAL does not do it. Its burst phase is corrected by alternating it every line -- which is * what the P in PAL is -- so the 2C07 has nothing left to fix, and every frame of it is the * same length. + *

+ * An {@link Overclock} is a line the beam runs again rather than a line number the chip does not + * have, and this is the whole of where it happens. Repeating 240 or the last vblank line is + * indistinguishable from a longer idle one: nothing below {@link #tick()} keys on either of them + * -- rendering and sprite work are gated on {@link #isRenderingLine()}, the odd-frame skip and + * the warm-up release key on {@link #preRenderLine}, and the VBlank flag on 241 and on the + * pre-render line. So the extra post-render lines land between the picture and the flag going + * up, and the extra vblank lines between the end of blanking and the pre-render line, with the + * flag still up. */ private void advance() { if (region.skipsDotOnOddFrames() @@ -584,6 +637,15 @@ && oddFrame && isRenderingEnabled()) { if (dot > LAST_DOT) { dot = 0; + + if ((scanline == POST_RENDER_LINE && extraLine < overclock.beforeNmi()) + || (scanline == preRenderLine - 1 && extraLine < overclock.afterNmi())) { + // Run this line again: the beam stays where it is and the CPU gets the line. + extraLine++; + return; + } + + extraLine = 0; scanline++; if (scanline > preRenderLine) { @@ -2265,9 +2327,11 @@ 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, 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 layer switches, and {@link ExtraSprites#enabled} and + * {@link #overclock} 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. {@link #extraLine} is on the list, being where the beam is rather + * than what anybody asked for.
  • * * 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. @@ -2375,6 +2439,13 @@ public void serialize(final StateIO io) { // whether anybody asked for it does not, any more than the layer switches do. extraSprites.serialize(io); + // And appended after it for the same reason again. This is the whole of the overclock that + // travels: how many repeats of this line have run, which is a beam position and belongs + // with the rest of one. How many there are meant to be is the Hacks menu's, so a state + // taken mid-repeat loads into a machine with the hack off and simply moves on at the next + // line wrap. + extraLine = io.u16(extraLine); + if (!io.saving()) { updateNMILine(); } @@ -2485,6 +2556,33 @@ public boolean isUnlimitedSprites() { return extraSprites.enabled; } + /** + * Gives the program extra idle scanlines a frame, so that a main loop which overruns its frame + * stops dropping one. Off at power on, and unlike the sprite limit above the game can + * tell: see {@link Overclock}. + * + * @param overclock how many lines, and which side of the NMI. Never null; {@link Overclock#NONE} + * is how to say none. + */ + public void setOverclock(final Overclock overclock) { + this.overclock = Objects.requireNonNull( + overclock, "there is no overclock at all; Overclock.NONE is how to say none"); + } + + public Overclock getOverclock() { + return overclock; + } + + /** + * Whether the beam is on a line it is running again rather than one the console would have run. + *

    + * {@link NES#tick()} asks once per CPU cycle, so that the APU can stand still through the extra + * lines and keep making a hardware frame's worth of sound. + */ + public boolean isOnExtraLine() { + return extraLine != 0; + } + /** * 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/main/java/com/github/dimiro1/mynes/state/Movie.java b/mynes-core/src/main/java/com/github/dimiro1/mynes/state/Movie.java index bdd8abf..5783bbd 100644 --- a/mynes-core/src/main/java/com/github/dimiro1/mynes/state/Movie.java +++ b/mynes-core/src/main/java/com/github/dimiro1/mynes/state/Movie.java @@ -1,6 +1,7 @@ package com.github.dimiro1.mynes.state; import com.github.dimiro1.mynes.NES; +import com.github.dimiro1.mynes.Overclock; import com.github.dimiro1.mynes.Region; import com.github.dimiro1.mynes.cheat.GameGenieCode; import com.github.dimiro1.mynes.cheat.InvalidGameGenieCodeException; @@ -40,8 +41,10 @@ *

    * Those outside facts are the ones worth naming, because getting any of them wrong is a replay that * quietly diverges rather than one that refuses: the ROM's digest (of the patched image, so - * a romhack is pinned for free), the region, and the Game Genie codes -- which are the sharp case, - * since a cheated cartridge is byte for byte the honest one and nothing else in a file would say so. + * a romhack is pinned for free), the region, the Game Genie codes -- which are the sharp case, since + * a cheated cartridge is byte for byte the honest one and nothing else in a file would say so -- and + * the {@link Overclock}, which is here for the same reason and one more: unlike every other hack it + * changes how much of its work the game gets through in a frame. * *

    Rewinds are not in it

    * @@ -154,6 +157,17 @@ public final class Movie { */ private static final String TAG_GENIE = "GENI"; + /** + * How many idle scanlines the machine was being given a frame: two u16s, before the NMI and + * after it. Absent altogether when there were none, which is the ordinary case. + *

    + * In a movie for the reason the codes are, and more sharply. The overclock changes what the game + * does -- a main loop that fits in a longer frame runs a different number of times -- + * so a take recorded with it and replayed without it diverges within a second, and nothing else + * in the file would say why. + */ + private static final String TAG_OVERCLOCK = "OVCK"; + private static final int TAG_BYTES = 4; /** @@ -186,6 +200,8 @@ public final class Movie { private final List genie; + private final Overclock overclock; + /** * What a file says about itself, without inflating it. * @@ -222,7 +238,8 @@ public record Header( final byte[] player1, final byte[] player2, final long[] resets, - final List genie + final List genie, + final Overclock overclock ) { this.header = header; this.anchor = anchor; @@ -230,6 +247,7 @@ public record Header( this.player2 = player2; this.resets = resets; this.genie = List.copyOf(genie); + this.overclock = overclock; } public Header header() { @@ -300,6 +318,16 @@ public List genie() { return genie; } + /** + * How many idle scanlines a frame the machine was being given, and {@link Overclock#NONE} for a + * take played on the hardware's own timing. A replay has to put this back for a stronger reason + * than the codes: the overclock decides how much of its work the game gets through in a frame, + * so a replay at the wrong setting is a replay of a different game. + */ + public Overclock overclock() { + return overclock; + } + /** * Puts the machine where the recording started. *

    @@ -430,6 +458,15 @@ private byte[] body() throws IOException { chunk(body, TAG_GENIE, codes.toByteArray()); } + if (!overclock.isNone()) { + var lines = new byte[4]; + + SaveState.putShort(lines, 0, overclock.beforeNmi()); + SaveState.putShort(lines, 2, overclock.afterNmi()); + + chunk(body, TAG_OVERCLOCK, lines); + } + return body.toByteArray(); } @@ -533,7 +570,8 @@ public static Movie read(final InputStream in) throws IOException { player1, header.ports() >= 2 ? chunks.get(TAG_CONTROLLER2) : null, resets(chunks.get(TAG_RESETS), header.frameCount()), - codes(chunks.get(TAG_GENIE))); + codes(chunks.get(TAG_GENIE)), + overclock(chunks.get(TAG_OVERCLOCK))); } /** @@ -704,4 +742,29 @@ private static List codes(final byte[] payload) { return List.copyOf(codes); } + + /** + * The pinned overclock, checked here rather than on the way into a machine, for the reason the + * codes are: a file naming a line count no machine will accept is a damaged movie, and saying so + * beats a replay that quietly ran on the hardware's timing instead. + */ + private static Overclock overclock(final byte[] payload) { + if (payload == null || payload.length == 0) { + return Overclock.NONE; + } + + if (payload.length != 4) { + throw new MovieException( + "that movie is damaged: its overclock is " + payload.length + + " bytes, where a line count before the NMI and one after it are" + + " four."); + } + + try { + return new Overclock( + SaveState.readShort(payload, 0), SaveState.readShort(payload, 2)); + } catch (IllegalArgumentException e) { + throw new MovieException("that movie is damaged: " + e.getMessage(), e); + } + } } diff --git a/mynes-core/src/main/java/com/github/dimiro1/mynes/state/MovieRecorder.java b/mynes-core/src/main/java/com/github/dimiro1/mynes/state/MovieRecorder.java index bd1d5d7..bca5bfa 100644 --- a/mynes-core/src/main/java/com/github/dimiro1/mynes/state/MovieRecorder.java +++ b/mynes-core/src/main/java/com/github/dimiro1/mynes/state/MovieRecorder.java @@ -1,6 +1,7 @@ package com.github.dimiro1.mynes.state; import com.github.dimiro1.mynes.NES; +import com.github.dimiro1.mynes.Overclock; import com.github.dimiro1.mynes.Region; import com.github.dimiro1.mynes.cheat.GameGenieCode; @@ -36,12 +37,12 @@ * *

    What is pinned, and when

    * - * The cartridge digest, the mapper number, the region and the Game Genie codes are read once, at - * construction. The first three cannot change under a running machine: a save state from another - * cartridge or another region is refused, and the chips are built around their region. The codes - * can, which is why both front ends refuse to change them while a recording is running -- a movie - * whose header pinned one set of codes and whose frames were played against another is a file that - * cannot be replayed and does not say so. + * The cartridge digest, the mapper number, the region, the Game Genie codes and the overclock are + * read once, at construction. The first three cannot change under a running machine: a save state + * from another cartridge or another region is refused, and the chips are built around their region. + * The last two can, which is why both front ends refuse to change either while a recording is + * running -- a movie whose header pinned one set of codes, or one number of extra scanlines, and + * whose frames were played against another is a file that cannot be replayed and does not say so. */ public final class MovieRecorder { @@ -62,6 +63,7 @@ public final class MovieRecorder { private final int mapperNumber; private final Region region; private final List genie; + private final Overclock overclock; /** * The whole save state the movie starts from, or null when it starts at power on. @@ -91,6 +93,11 @@ private MovieRecorder( this.mapperNumber = nes.getCart().mapperNumber(); this.region = nes.getRegion(); this.genie = List.copyOf(codes); + + // Off the machine rather than out of a parameter, so that neither front end has to be told + // about it twice: it is already on the PPU, and a caller that passed one could pass a + // different one. + this.overclock = nes.getPPU().getOverclock(); this.anchor = anchor; this.anchorFrame = nes.getPPU().getFrame(); } @@ -241,7 +248,8 @@ public Movie movie() { Arrays.copyOf(buttons, recorded), null, Arrays.copyOf(resets, resetCount), - genie); + genie, + overclock); } private void reanchor(final NES nes) { diff --git a/mynes-core/src/test/java/com/github/dimiro1/mynes/OverclockTests.java b/mynes-core/src/test/java/com/github/dimiro1/mynes/OverclockTests.java new file mode 100644 index 0000000..70d4667 --- /dev/null +++ b/mynes-core/src/test/java/com/github/dimiro1/mynes/OverclockTests.java @@ -0,0 +1,69 @@ +package com.github.dimiro1.mynes; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The arithmetic between what a player asks for and what the chip is told. + *

    + * A percentage is the useful unit at one end and a scanline count at the other, and the conversion + * goes through the region -- so the same setting is a different number of lines on the two machines + * and has to be, because half of 262 lines is not half of 312. + */ +class OverclockTests { + + @Test + void noneIsNoLinesAtAll() { + assertEquals(0, Overclock.NONE.beforeNmi()); + assertEquals(0, Overclock.NONE.afterNmi()); + assertTrue(Overclock.NONE.isNone()); + + assertTrue(new Overclock(0, 0).isNone(), "a count of nothing is nothing"); + assertFalse(new Overclock(0, 1).isNone(), "and a line after the NMI is still a line"); + } + + @Test + void aPresetIsThatManyPercentOfTheRegionsScanlines() { + assertEquals(new Overclock(131, 0), Overclock.percentOf(Region.NTSC, 50)); + assertEquals(new Overclock(156, 0), Overclock.percentOf(Region.PAL, 50)); + + assertEquals(new Overclock(524, 0), Overclock.percentOf(Region.NTSC, 200)); + assertEquals(new Overclock(624, 0), Overclock.percentOf(Region.PAL, 200)); + } + + @Test + void aPresetPutsEveryLineBeforeTheNmi() { + // The half that changes nothing a game can observe except that the frame is longer. Lines + // after the NMI move the picture relative to it, which is what a mid-screen split measures. + assertEquals(0, Overclock.percentOf(Region.NTSC, 100).afterNmi()); + } + + @Test + void aCountOutsideNoughtToAThousandIsRefused() { + assertThrows(IllegalArgumentException.class, () -> new Overclock(-1, 0)); + assertThrows(IllegalArgumentException.class, () -> new Overclock(0, -1)); + assertThrows(IllegalArgumentException.class, + () -> new Overclock(Overclock.MAX_SCANLINES + 1, 0)); + assertThrows(IllegalArgumentException.class, + () -> new Overclock(0, Overclock.MAX_SCANLINES + 1)); + + var refused = assertThrows( + IllegalArgumentException.class, () -> new Overclock(0, 2000)); + + assertTrue(refused.getMessage().contains("0 to " + Overclock.MAX_SCANLINES), + "the message has to say what the range is: " + refused.getMessage()); + assertTrue(refused.getMessage().contains("after"), + "and which of the two numbers was wrong: " + refused.getMessage()); + } + + @Test + void theLimitItselfIsAllowed() { + assertEquals( + Overclock.MAX_SCANLINES, + new Overclock(Overclock.MAX_SCANLINES, Overclock.MAX_SCANLINES).beforeNmi()); + } +} diff --git a/mynes-core/src/test/java/com/github/dimiro1/mynes/ppu/NESOverclockTests.java b/mynes-core/src/test/java/com/github/dimiro1/mynes/ppu/NESOverclockTests.java new file mode 100644 index 0000000..3bb9203 --- /dev/null +++ b/mynes-core/src/test/java/com/github/dimiro1/mynes/ppu/NESOverclockTests.java @@ -0,0 +1,232 @@ +package com.github.dimiro1.mynes.ppu; + +import com.github.dimiro1.mynes.APU; +import com.github.dimiro1.mynes.Cart; +import com.github.dimiro1.mynes.NES; +import com.github.dimiro1.mynes.Overclock; +import com.github.dimiro1.mynes.Region; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The whole machine on a longer frame: who gets the extra cycles, and who does not. + *

    + * The CPU does, which is the entire point -- a program with more cycles between one NMI and the next + * is a program that finishes its work. The APU does not, and that is the part worth a test rather + * than a comment: everything it counts is counted in CPU cycles, so an APU that ran through the + * extra lines would play the music slow and hand a front end a frame and a half of samples to fit + * into a frame. + * + * @see Overclock + */ +class NESOverclockTests { + private static final int NOP = 0xEA; + + /** + * How long the four step sequence is, in CPU cycles, which is what "the frame counter's own + * time" means below. + */ + private static final int FOUR_STEP_PERIOD = 29830; + + /** + * Bit 6 of $4015: the frame counter has been round once more. + */ + private static final int FRAME_IRQ = 0x40; + + @Test + void theCpuGetsTheLinesAsCycles() { + // 30 scanlines is 10230 dots, which is 3410 CPU cycles at three dots each -- so a frame + // becomes 33190.67 rather than 29780.67, and two of them twice that. + var nes = nesRunning(NOP, NOP, NOP, NOP); + nes.getPPU().setOverclock(new Overclock(30, 0)); + + var cycles = cyclesOverFrames(nes, 2); + + assertEquals(2 * (89342 + 30 * 341) / 3.0, cycles, 2, + "two overclocked frames of CPU cycles"); + } + + @Test + void bothHalvesReachTheCpu() { + var nes = nesRunning(NOP, NOP, NOP, NOP); + nes.getPPU().setOverclock(new Overclock(10, 20)); + + assertEquals((89342 + 30 * 341) / 3.0, cyclesOverFrames(nes, 1), 2); + } + + @Test + void aFrameStillMakesTheSameNumberOfSamples() { + // The claim a front end depends on: the desktop paces itself on a blocking write to the + // sound card, so a frame that handed over half as many samples again would run the game at + // two thirds speed however fast the machine underneath it was going. + var plain = samplesOverFrames(machine(Overclock.NONE), 40); + var overclocked = samplesOverFrames(machine(new Overclock(131, 0)), 40); + + assertEquals(plain, overclocked, 1, + "an overclocked frame is a hardware frame's worth of sound"); + assertEquals(40 * APU.SAMPLE_RATE / 60.0988, plain, 2, "which is about 734 a frame"); + } + + @Test + void apuCyclesStillEqualCpuCycles() { + // Not an accounting detail: the parity of the APU's counter is what CPUBus.isGetCycle reads, + // and the MMU asks the same question of the CPU's when it starts a sprite DMA. A counter + // that stood still through the extra lines would come back inverted and a DMA would take + // 513 cycles where the hardware takes 514. + var nes = machine(new Overclock(77, 33)); + + cyclesOverFrames(nes, 5); + + assertEquals(nes.getCPU().getState().cycles(), nes.getAPU().getCycles()); + } + + @Test + void theFrameCounterRunsInHardwareTime() { + // 60 frames is 1786840 CPU cycles of hardware time, which is 59.9 times round the four step + // sequence. At +50% the machine spends 2680260 cycles in those frames -- an APU that ran + // through them would go round 89 times, and every envelope, sweep and length counter with + // it, which is music at two thirds tempo. + var nes = machine(new Overclock(131, 0)); + + var sequences = sequencesOverFrames(nes, 60); + var hardware = 60 * (89342 / 3.0) / FOUR_STEP_PERIOD; + + assertEquals(hardware, sequences, 1.0, + "the frame counter went round " + sequences + " times in 60 overclocked frames"); + } + + @Test + void aMachineNobodyOverclockedIsUntouched() { + var nes = machine(Overclock.NONE); + + assertEquals(89342 / 3.0, cyclesOverFrames(nes, 1), 2); + assertEquals(60 * (89342 / 3.0) / FOUR_STEP_PERIOD, sequencesOverFrames(machine( + Overclock.NONE), 60), 1.0); + } + + // ================================================================================== internals + + private NES machine(final Overclock overclock) { + var nes = nesRunning(NOP, NOP, NOP, NOP); + nes.getPPU().setOverclock(overclock); + + return nes; + } + + /** + * How many CPU cycles {@code frames} whole frames take, measured from one frame boundary to + * another so that the reset sequence's own few cycles are not in it. + */ + private long cyclesOverFrames(final NES nes, final int frames) { + var ppu = nes.getPPU(); + + advanceFrame(nes); + + var started = nes.getCPU().getState().cycles(); + var target = ppu.getFrame() + frames; + + while (ppu.getFrame() < target) { + nes.tick(); + } + + return nes.getCPU().getState().cycles() - started; + } + + /** + * How many finished samples come out over {@code frames} whole frames. Drained as it goes: the + * chip's ring holds 8192 and forty frames make about thirty thousand. + */ + private long samplesOverFrames(final NES nes, final int frames) { + var ppu = nes.getPPU(); + var apu = nes.getAPU(); + var buffer = new short[4096]; + + advanceFrame(nes); + apu.drainSamples(buffer); + + var target = ppu.getFrame() + frames; + var samples = 0L; + + while (ppu.getFrame() < target) { + nes.tick(); + samples += apu.drainSamples(buffer); + } + + return samples + apu.drainSamples(buffer); + } + + /** + * How many times the four step sequence comes round over {@code frames} whole frames. + *

    + * Counted through $4015, which is the only window onto the frame counter a program has. The flag + * is raised on three consecutive cycles and a read only asks for it to be cleared, so a + * sighting is followed by a few cycles of reading before the next one is looked for -- otherwise + * one lap would be counted three times. + */ + private int sequencesOverFrames(final NES nes, final int frames) { + var ppu = nes.getPPU(); + var apu = nes.getAPU(); + + advanceFrame(nes); + + var target = ppu.getFrame() + frames; + var sequences = 0; + + while (ppu.getFrame() < target) { + nes.tick(); + + if ((apu.readStatus() & FRAME_IRQ) == 0) { + continue; + } + + sequences++; + + for (var i = 0; i < 8; i++) { + nes.tick(); + apu.readStatus(); + } + } + + assertTrue(sequences > 0, "the frame counter never came round at all"); + + return sequences; + } + + private void advanceFrame(final NES nes) { + var ppu = nes.getPPU(); + var frame = ppu.getFrame(); + + while (ppu.getFrame() == frame) { + nes.tick(); + } + } + + /** + * Builds a machine sitting at the first instruction of {@code code}, past the reset sequence. + * The same helper {@code NESIntegrationTests} uses, copied rather than shared: it is four lines + * of iNES header and neither test wants the other's changes to it. + */ + private NES nesRunning(final int... code) { + var image = new byte[16 + 0x4000]; + + image[0] = 'N'; + image[1] = 'E'; + image[2] = 'S'; + image[3] = 0x1A; + image[4] = 1; // one PRG bank, mirrored into both $8000 and $C000 + + for (var i = 0; i < code.length; i++) { + image[16 + i] = (byte) code[i]; + } + + image[16 + 0x3FFC] = 0x00; + image[16 + 0x3FFD] = (byte) 0xC0; + + var nes = new NES(Cart.load(image, "overclock.nes"), Region.NTSC); + nes.step(); // the reset sequence + + return nes; + } +} diff --git a/mynes-core/src/test/java/com/github/dimiro1/mynes/ppu/PPUOverclockTests.java b/mynes-core/src/test/java/com/github/dimiro1/mynes/ppu/PPUOverclockTests.java new file mode 100644 index 0000000..4ead4f7 --- /dev/null +++ b/mynes-core/src/test/java/com/github/dimiro1/mynes/ppu/PPUOverclockTests.java @@ -0,0 +1,334 @@ +package com.github.dimiro1.mynes.ppu; + +import com.github.dimiro1.mynes.Overclock; +import com.github.dimiro1.mynes.Region; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The extra scanlines, and the four things about them that are easy to get wrong. + *

    + * A frame has to grow by exactly the lines asked for; the lines have to land where the beam is + * already idle, so nothing drawn moves; the scanline counter must never name a line the chip does + * not have, because a great deal of this class keys on 240, 241 and the pre-render line by number; + * and the dot clock must not grow with the frame, because OAM decay is measured against it + * and a frame's blanking that outlasted the charge would wipe every sprite in the game. + * + * @see Overclock + */ +class PPUOverclockTests extends PPUFixture { + private static final int VBLANK_FLAG = 0x80; + + @BeforeEach + void setUp() { + createPPU(); + } + + @Nested + @DisplayName("how long a frame becomes") + class FrameLength { + @Test + void aFrameIsLongerByExactlyThatManyLines() { + ppu.setOverclock(new Overclock(131, 0)); + + assertEquals(DOTS_PER_FRAME + 131 * 341, measureFrame()); + assertEquals(DOTS_PER_FRAME + 131 * 341, measureFrame(), "and every frame after it"); + } + + @Test + void bothHalvesCount() { + ppu.setOverclock(new Overclock(20, 30)); + + assertEquals(DOTS_PER_FRAME + 50 * 341, measureFrame()); + } + + @Test + void anOddFrameStillDropsItsDot() { + // The skip keys on the pre-render line, which is exactly where the repeats are not, so + // the two are independent and a frame is the sum of both. + ppu.setOverclock(new Overclock(7, 0)); + startRenderingOnTheFirstOddFrame(); + + var extra = 7 * 341; + + assertEquals( + List.of(DOTS_PER_FRAME + extra - 1, DOTS_PER_FRAME + extra, + DOTS_PER_FRAME + extra - 1, DOTS_PER_FRAME + extra), + List.of(measureFrame(), measureFrame(), measureFrame(), measureFrame())); + } + + @Test + void switchingItOffMidRepeatStopsAtTheNextLineWrap() { + ppu.setOverclock(new Overclock(50, 0)); + + // Part way through the repeats, which is where the count is not a beam position anybody + // can name from outside. + runTo(240, 0); + run(10 * 341 + 100); + assertTrue(ppu.isOnExtraLine()); + + ppu.setOverclock(Overclock.NONE); + + // The line being run finishes -- there are 341 dots in it whatever anybody says half way + // through -- and then the beam moves on. + run(341 - 100); + + assertFalse(ppu.isOnExtraLine()); + assertEquals(241, ppu.getScanline(), "off means off from the next wrap"); + } + + @Test + void aResetForgetsTheRepeatsItWasIn() { + ppu.setOverclock(new Overclock(50, 0)); + + runTo(240, 0); + run(3 * 341); + assertTrue(ppu.isOnExtraLine()); + + ppu.reset(); + + assertFalse(ppu.isOnExtraLine(), "the beam is back at the top left"); + assertEquals(new Overclock(50, 0), ppu.getOverclock(), + "and the setting is the Hacks menu's, which the button does not reach"); + } + } + + @Nested + @DisplayName("where the lines go") + class Placement { + @Test + void theExtraLinesSitBetweenThePostRenderLineAndTheVBlankFlag() { + ppu.setOverclock(new Overclock(4, 0)); + + runTo(240, 0); + + // Five runs of line 240 -- the real one and four repeats -- with the flag down for all + // of them, and then 241 with the flag going up on dot 1 as it always does. + for (var i = 0; i < 5; i++) { + assertEquals(240, ppu.getScanline(), "run " + i + " of the post-render line"); + assertFalse(vblankSet(), "the flag cannot go up before line 241"); + run(341); + } + + assertEquals(241, ppu.getScanline()); + assertFalse(vblankSet(), "dot 0 has not done its work yet"); + + run(2); + assertTrue(vblankSet()); + } + + @Test + void theFlagStaysUpThroughTheLinesAfterNmiAndClearsOnThePreRenderLine() { + ppu.setOverclock(new Overclock(0, 4)); + + runTo(260, 0); + + for (var i = 0; i < 5; i++) { + assertEquals(260, ppu.getScanline(), "run " + i + " of the last line of blanking"); + assertTrue(vblankSet(), "still vertical blank, however long it lasts"); + run(341); + } + + assertEquals(261, ppu.getScanline()); + assertTrue(vblankSet(), "right to the end of it"); + + run(2); + assertFalse(vblankSet(), "and down on dot 1 of the pre-render line"); + } + + @Test + void theScanlineCounterNeverNamesALineTheChipDoesNotHave() { + // The whole design in one assertion. Everything else in PPU keys on 240, 241 and the + // pre-render line by number, so a repeat that shifted them would be a different chip. + ppu.setOverclock(new Overclock(30, 30)); + + var seen = new boolean[262]; + var frames = ppu.getFrame() + 2; + + while (ppu.getFrame() < frames) { + assertTrue(ppu.getScanline() >= 0 && ppu.getScanline() <= 261, + "the beam is on line " + ppu.getScanline()); + seen[ppu.getScanline()] = true; + ppu.tick(); + } + + for (var line = 0; line < seen.length; line++) { + assertTrue(seen[line], "line " + line + " never happened"); + } + } + + @Test + void nothingIsDrawnOnAnExtraLine() { + // The picture is 240 lines whatever the frame's length: the repeats are of lines the + // beam is already idle on, so the framebuffer is written exactly as often as before. + ppu.setOverclock(new Overclock(60, 0)); + warmUp(); + + ppu.write(PPUMASK, 0x1E); + renderFrames(2); + + var before = ppu.getFrameBuffer().clone(); + + runTo(240, 0); + run(30 * 341); + + assertArrayEquals(before, ppu.getFrameBuffer(), + "a repeated post-render line drew something"); + } + } + + @Nested + @DisplayName("what the extra lines cost") + class Cost { + @Test + void oamDoesNotDecayAcrossTheExtraLines() { + // A thousand lines is 341000 dots, which is three and a half times the charge. If the + // dot clock ran through them the row would read back as zeroes and every sprite in the + // game would vanish once a frame -- which is why tick() skips clock++ on a repeat. + // The warm-up first: runTo gives up after two frames' worth of dots, and two frames of + // this are four hundred thousand. + warmUp(); + ppu.setOverclock(new Overclock(500, 500)); + + ppu.write(OAMADDR, 0x10); + ppu.write(OAMDATA, 0xAA); + + runTo(240, 0); + run(1000 * 341); + + ppu.write(OAMADDR, 0x10); + assertEquals(0xAA, ppu.read(OAMDATA), "the charge leaked away in the emulator's time"); + } + + @Test + void theMapperSeesEveryExtraDot() { + // An MMC3 clocked by $2006 writes during vertical blank counts the idle dots between + // them, so a line the mapper never heard about would be a line its counter lost. + var recorder = new CountingMapper(); + createPPU(recorder); + + ppu.setOverclock(new Overclock(9, 0)); + runTo(240, 0); + recorder.clear(); + + run(10 * 341); + + assertEquals(10 * 341, recorder.dots(), "the real line and its nine repeats"); + } + + @Test + void aSecondAddressWriteStillLandsOnAnExtraLine() { + // The write delay is counted in dots and the dots are still happening, so the address + // reaches the counter -- and the cartridge -- on a repeated line exactly as it would on + // any other. + var recorder = new CountingMapper(); + createPPU(recorder); + + ppu.setOverclock(new Overclock(9, 0)); + warmUp(); + runTo(240, 0); + run(3 * 341); + + assertTrue(ppu.isOnExtraLine(), "on a repeat, which is the point of the test"); + recorder.clear(); + + ppu.read(PPUSTATUS); + ppu.write(PPUADDR, 0x21); + ppu.write(PPUADDR, 0x08); + run(ADDRESS_UPDATE_DOTS); + + assertEquals(List.of(0x2108), recorder.addresses()); + } + } + + /** + * The 2C07, whose extra lines go after line 310 rather than after 260 -- the last line of a much + * longer vertical blank. + */ + @Nested + @DisplayName("a PAL machine") + class PALTiming { + @BeforeEach + void setUp() { + createPPU(Region.PAL); + } + + @Test + void onPalTheLinesGoAfterLine310() { + ppu.setOverclock(new Overclock(0, 3)); + + runTo(310, 0); + + for (var i = 0; i < 4; i++) { + assertEquals(310, ppu.getScanline(), "run " + i + " of the last line of blanking"); + run(341); + } + + assertEquals(311, ppu.getScanline(), "the pre-render line, which is 311 here"); + } + + @Test + void aPalFrameGrowsByTheSameLines() { + ppu.setOverclock(new Overclock(156, 0)); + + assertEquals(PAL_DOTS_PER_FRAME + 156 * 341, measureFrame()); + } + } + + private boolean vblankSet() { + return (ppu.peek(PPUSTATUS) & VBLANK_FLAG) != 0; + } + + /** + * Turns rendering on and leaves the beam at the start of frame 1, the first odd one. + *

    + * $2001 is ignored until the PPU has worked the first dot of the pre-render line, so the write + * has to wait until then. + */ + private void startRenderingOnTheFirstOddFrame() { + runTo(261, 1); + ppu.write(PPUMASK, 0x08); + runTo(0, 0); + } + + /** + * A cartridge that writes down which dots and which addresses it was shown. + */ + private static final class CountingMapper extends StubMapper { + private final List addresses = new ArrayList<>(); + private int dots; + + @Override + public void ppuAddress(final int address) { + addresses.add(address); + } + + @Override + public void ppuTick() { + dots++; + } + + List addresses() { + return addresses; + } + + int dots() { + return dots; + } + + void clear() { + addresses.clear(); + dots = 0; + } + } +} diff --git a/mynes-core/src/test/java/com/github/dimiro1/mynes/state/MovieTests.java b/mynes-core/src/test/java/com/github/dimiro1/mynes/state/MovieTests.java index 5133350..8137e14 100644 --- a/mynes-core/src/test/java/com/github/dimiro1/mynes/state/MovieTests.java +++ b/mynes-core/src/test/java/com/github/dimiro1/mynes/state/MovieTests.java @@ -3,6 +3,7 @@ import com.github.dimiro1.mynes.Cart; import com.github.dimiro1.mynes.Controller; import com.github.dimiro1.mynes.NES; +import com.github.dimiro1.mynes.Overclock; import com.github.dimiro1.mynes.Region; import com.github.dimiro1.mynes.cheat.GameGenieCode; import org.junit.jupiter.api.Test; @@ -502,6 +503,117 @@ void aMovieCarryingSomethingThatIsNotACodeIsRefused() throws IOException { assertThrows(MovieException.class, () -> Movie.read(new ByteArrayInputStream(file))); } + // ================================================================================ the overclock + + /** + * The sharper half of what a movie carries beside the buttons. Game Genie codes change what the + * cartridge answers with; this changes how much of its work the game gets through in a frame, so + * a replay at the wrong setting is a replay of a different game -- and, like the codes, there is + * nothing about the cartridge that could say which it was. + */ + @Test + void theOverclockIsPinnedInTheMovie() throws IOException { + var nes = load(); + nes.getPPU().setOverclock(new Overclock(131, 20)); + + var recorder = MovieRecorder.atPowerOn(nes, List.of()); + play(nes, recorder, 10, 0); + + assertEquals(new Overclock(131, 20), roundTrip(recorder.movie()).overclock()); + } + + @Test + void theOverclockIsPinnedWhenTheRecordingStartsAndNotAfterwards() throws IOException { + var nes = load(); + nes.getPPU().setOverclock(new Overclock(40, 0)); + + var recorder = MovieRecorder.atPowerOn(nes, List.of()); + play(nes, recorder, 5, 0); + + // Which neither front end allows while a take is running, and this is why: the file already + // says 40, and the frames after this one would have been played at 90. + nes.getPPU().setOverclock(new Overclock(90, 0)); + play(nes, recorder, 5, 0); + + assertEquals(new Overclock(40, 0), roundTrip(recorder.movie()).overclock()); + } + + @Test + void aMovieWithoutTheChunkMeansNoOverclock() throws IOException { + // Which is also every movie any earlier build wrote: the chunk is absent rather than zero, + // so the format did not have to change meaning for this to arrive. + assertEquals( + Overclock.NONE, + Movie.read(new ByteArrayInputStream(recorded(5))).overclock()); + } + + @Test + void aMovieCarryingAnImpossibleLineCountIsRefused() throws IOException { + // 0xFFFF lines, which no machine will accept. Refused as a damaged movie rather than + // clamped, for the reason a bad code is: a replay that quietly ran on some other timing + // would look exactly like one that worked. + var file = withExtraChunk( + recorded(5), "OVCK", new byte[]{(byte) 0xFF, (byte) 0xFF, 0, 0}); + + var refused = assertThrows( + MovieException.class, () -> Movie.read(new ByteArrayInputStream(file))); + + assertTrue(refused.getMessage().contains("damaged")); + } + + @Test + void aMovieWhoseOverclockIsTheWrongLengthIsRefused() throws IOException { + var file = withExtraChunk(recorded(5), "OVCK", new byte[]{0, 30}); + + assertThrows(MovieException.class, () -> Movie.read(new ByteArrayInputStream(file))); + } + + /** + * The claim the whole feature rests on, end to end: a take recorded on a longer frame replays to + * the same bytes when the movie's own setting is put back. + */ + @Test + void anOverclockedRecordingReplaysToByteIdenticalState() throws IOException { + var overclock = new Overclock(131, 0); + + var nes = load(); + nes.getPPU().setOverclock(overclock); + + var recorder = MovieRecorder.atPowerOn(nes, List.of()); + play(nes, recorder, 20, Controller.BUTTON_START); + play(nes, recorder, 20, 0); + + var movie = roundTrip(recorder.movie()); + + var other = load(); + other.getPPU().setOverclock(movie.overclock()); + replay(other, movie, movie.frameCount()); + + assertArrayEquals(save(nes), save(other)); + } + + /** + * And the same take replayed without it is not the same run, which is what makes the chunk worth + * carrying rather than merely tidy. + */ + @Test + void thatSameTakeReplayedWithoutItDivergesFromWhatWasRecorded() throws IOException { + var nes = load(); + nes.getPPU().setOverclock(new Overclock(131, 0)); + + var recorder = MovieRecorder.atPowerOn(nes, List.of()); + play(nes, recorder, 40, Controller.BUTTON_START); + + var movie = roundTrip(recorder.movie()); + + var other = load(); + replay(other, movie, movie.frameCount()); + + assertFalse( + Arrays.equals(save(nes), save(other)), + "the two runs agreed, so the movie would not have needed to carry the setting"); + } + // ================================================================================== internals private static NES load() throws IOException { 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 a8c9475..ddee3e8 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 @@ -3,6 +3,7 @@ import com.github.dimiro1.mynes.Cart; import com.github.dimiro1.mynes.Controller; import com.github.dimiro1.mynes.NES; +import com.github.dimiro1.mynes.Overclock; import org.junit.jupiter.api.Test; import java.io.ByteArrayInputStream; @@ -68,6 +69,19 @@ class SaveStateCompletenessTests { + " 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("Overclock.beforeNmi", + "how many idle scanlines a frame the machine is being given, which is the Hacks" + + " menu's tick rather than anything the machine holds -- so a state" + + " taken with the hack on loads into a machine with it off without" + + " complaining, exactly as one taken with Game Genie codes in does." + + " PPU.extraLine, the count of repeats the beam is part way through," + + " does travel: it is where the beam is, so a state taken mid-repeat" + + " runs on the way the machine it came from did, and one loaded into a" + + " machine with the hack off moves on at the next line wrap. Named" + + " after the record rather than after the field because the walk below" + + " steps into it -- which means the PPU's field must never be null, or" + + " these two become one entry called PPU.overclock"), + Map.entry("Overclock.afterNmi", "the other half of the same setting"), 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" @@ -169,6 +183,7 @@ void whatIsLeftOutOfTheStateStaysOutOfIt() throws IOException { original.getController1().setButtons(Controller.BUTTON_START | Controller.BUTTON_B); original.getPPU().setBackgroundLayerVisible(false); original.getPPU().setUnlimitedSprites(true); + original.getPPU().setOverclock(new Overclock(131, 0)); var state = save(original); @@ -177,6 +192,7 @@ void whatIsLeftOutOfTheStateStaysOutOfIt() throws IOException { other.getController1().setButtons(0); other.getPPU().setBackgroundLayerVisible(true); other.getPPU().setUnlimitedSprites(false); + other.getPPU().setOverclock(Overclock.NONE); SaveState.read(other, new ByteArrayInputStream(state)); @@ -188,6 +204,9 @@ void whatIsLeftOutOfTheStateStaysOutOfIt() throws IOException { "a state overrode the Debug menu"); assertEquals("false", fields.get("ExtraSprites.enabled"), "and a state overrode the Hacks menu"); + assertEquals("0", fields.get("Overclock.beforeNmi"), + "and a state overrode the Hacks menu's other tick, which would have been the" + + " sharper one: it decides how much of its work the game gets done"); } /** diff --git a/mynes-core/src/test/java/com/github/dimiro1/mynes/state/SaveStateDivergenceTests.java b/mynes-core/src/test/java/com/github/dimiro1/mynes/state/SaveStateDivergenceTests.java index 6aafaf2..4f6a30b 100644 --- a/mynes-core/src/test/java/com/github/dimiro1/mynes/state/SaveStateDivergenceTests.java +++ b/mynes-core/src/test/java/com/github/dimiro1/mynes/state/SaveStateDivergenceTests.java @@ -3,6 +3,7 @@ import com.github.dimiro1.mynes.Cart; import com.github.dimiro1.mynes.Controller; import com.github.dimiro1.mynes.NES; +import com.github.dimiro1.mynes.Overclock; import com.github.dimiro1.mynes.video.FrameAnalysis; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -179,6 +180,45 @@ void aMachineLoadedFromAStateWritesTheSameStateBackOut() throws IOException { assertArrayEquals(state, save(other), "the machine did not come back the same"); } + /** + * The one field the overclock puts in the state: how many repeats of the current line the beam + * is part way through. + *

    + * The setting itself is deliberately left out, being the Hacks menu's rather than the machine's + * -- so this is what stops a state taken on the twentieth of 131 identical lines from resuming + * as though it were on the first. Both machines are told the same number of lines, because a + * replay of a run is a replay of the run that happened; what is being proved is that the count + * travelled, not that the setting did. + */ + @Test + void aStateTakenOnAnExtraLineRunsOnExactlyAsTheMachineItCameFromDid() throws IOException { + var rom = "/ppu-sprite-overflow/04-obscure.nes"; + var overclock = new Overclock(60, 0); + + var original = load(rom); + original.getPPU().setOverclock(overclock); + runToSavePoint(original); + runOntoAnExtraLine(original); + + var state = save(original); + var expected = traceOf(original, 40); + + var other = load(rom); + other.getPPU().setOverclock(overclock); + runElsewhere(other); + + SaveState.read(other, new ByteArrayInputStream(state)); + + var actual = traceOf(other, 40); + + for (var i = 0; i < expected.size(); i++) { + assertEquals( + expected.get(i).state(), + actual.get(i).state(), + "the machine diverged " + (i + 1) + " frames after the state was loaded"); + } + } + /** * The picture is not needed to put the machine back -- every visible pixel is rewritten every * frame -- but it is needed for the machine to look like it came back, which is what @@ -227,6 +267,26 @@ private static void runToSavePoint(final NES nes) { } } + /** + * Runs on until the beam is on a line the overclock is running again, and then some way into it + * -- so that the state is taken where the count of repeats is a number nothing else could + * reconstruct. + */ + private static void runOntoAnExtraLine(final NES nes) { + var ppu = nes.getPPU(); + + while (!ppu.isOnExtraLine()) { + nes.tick(); + } + + // Twenty lines in, counted in CPU cycles because that is what a tick is: three dots each. + for (var i = 0; i < 20 * 341 / 3; i++) { + nes.tick(); + } + + assertTrue(ppu.isOnExtraLine(), "twenty lines on and still repeating, which is the point"); + } + /** * Puts a machine somewhere that is not the save point, with buttons the original never saw. */ 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 0986355..c537b6d 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 @@ -45,6 +45,7 @@ public final class Config { 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 OVERCLOCK_KEY = "hacks.overclock"; private static final String REWIND_SECONDS_KEY = "rewind.seconds"; private static final String REWIND_KEY_KEY = "rewind.key"; @@ -119,10 +120,17 @@ public final class Config { """; 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. + # Things the console does not do, from the Hacks menu, and both off unless this says + # otherwise. Unlimited sprites draws the sprites the chip would have dropped, so a + # scanline holding more than eight of them stops flickering -- a change to the picture + # and to nothing the game can see. It is true or false; anything that is not true is + # off. + # + # The overclock is off, 25, 50, 100 or 200: that many percent of a frame in extra idle + # scanlines, so a game whose main loop overruns its frame stops dropping one. A + # percentage rather than a line count because a frame is 262 lines on NTSC and 312 on + # PAL, and the setting should mean the same on both. Unlike the tick above this changes + # the machine's timing and so what the game does. """; private static final String REWIND_HEADER = """ @@ -145,6 +153,7 @@ public final class Config { private EmulationSpeed fastForwardSpeed; private boolean muted; private boolean unlimitedSprites; + private OverclockSetting overclock; private int rewindSeconds; private int rewindKey; @@ -158,6 +167,7 @@ private Config( final EmulationSpeed fastForwardSpeed, final boolean muted, final boolean unlimitedSprites, + final OverclockSetting overclock, final int rewindSeconds, final int rewindKey) { this.keyBindings = keyBindings; @@ -169,6 +179,7 @@ private Config( this.fastForwardSpeed = fastForwardSpeed; this.muted = muted; this.unlimitedSprites = unlimitedSprites; + this.overclock = overclock; this.rewindSeconds = rewindSeconds; this.rewindKey = rewindKey; } @@ -206,6 +217,8 @@ public static Config load(final Path path) { fastForwardSpeedFrom(properties), flagFrom(properties, MUTED_KEY), flagFrom(properties, UNLIMITED_SPRITES_KEY), + OverclockSetting.byId( + properties.getProperty(OVERCLOCK_KEY, OverclockSetting.OFF.id()).trim()), rewindSecondsFrom(properties), KeyBindings.codeOf( properties.getProperty(REWIND_KEY_KEY), @@ -361,6 +374,10 @@ public void save(final Path path) throws IOException { .append(UNLIMITED_SPRITES_KEY) .append('=') .append(unlimitedSprites) + .append('\n') + .append(OVERCLOCK_KEY) + .append('=') + .append(overclock.id()) .append("\n\n"); text.append(REWIND_HEADER) @@ -491,6 +508,19 @@ public void setUnlimitedSprites(final boolean unlimitedSprites) { this.unlimitedSprites = unlimitedSprites; } + /** + * How much extra time a frame Hacks > Overclock is giving the game. Remembered for the reason + * the tick above is, and kept as a percentage rather than as a number of scanlines so that it + * means the same thing after a region switch. + */ + public OverclockSetting overclock() { + return overclock; + } + + public void setOverclock(final OverclockSetting overclock) { + this.overclock = overclock; + } + /** * How many seconds of the game to keep so it can be run backwards, or 0 for a machine that keeps * none and so costs nothing. 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 da7a765..6cc4315 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 @@ -149,6 +149,14 @@ public class GameUIFrame extends JFrame { */ private JMenu machineMenuRegion; + /** + * The Overclock submenu, built there for the same reason and kept for a second one: it is greyed + * out while a movie is running, exactly as the Game Genie item is. A movie pins the overclock + * when it starts, and this is the one hack that decides how much of its work the game gets + * through in a frame. + */ + private JMenu hacksMenuOverclock; + /** * Screenshot, kept because it is the one item in an always-enabled menu that needs a machine. * There is nothing to photograph until one is running, and a File menu greyed out as a whole @@ -401,6 +409,9 @@ private void init() { hacksMenuUnlimitedSprites.setSelected(config.unlimitedSprites()); hacksMenu.add(hacksMenuUnlimitedSprites); + hacksMenuOverclock = overclockMenu(); + hacksMenu.add(hacksMenuOverclock); + hacksMenuGameGenie.setMnemonic(KeyEvent.VK_G); hacksMenuGameGenie.setEnabled(false); hacksMenu.add(hacksMenuGameGenie); @@ -748,6 +759,51 @@ private JMenu fastForwardSpeedMenu() { return menu; } + /** + * Builds the Overclock submenu, one item per percentage {@link OverclockSetting} offers. + *

    + * A percentage rather than a number of scanlines because that is the question a player is + * asking: a game that drops frames wants more time to do its work in, and how many lines that + * comes to depends on which machine it turns out to be running on. Picking one applies it to the + * machine already running, so a scene that slows down can be watched doing it and then not. + *

    + * Unlike the tick above it, this one changes what the game does. Nothing about + * the picture is faked -- the beam idles through extra blanking lines and draws the same frame + * -- but the main loop gets more cycles between one NMI and the next, so a game whose logic + * overran its frame stops skipping one. Which means an overclocked run is not the game as it + * shipped, and the every-other-frame stutter some of them were written around goes with it. + */ + private JMenu overclockMenu() { + var menu = new JMenu("Overclock"); + menu.setMnemonic(KeyEvent.VK_O); + + // The group is what makes them one choice rather than five independent ticks. + var group = new ButtonGroup(); + + for (var setting : OverclockSetting.values()) { + var item = new JRadioButtonMenuItem(setting.label(), setting == config.overclock()); + + item.addActionListener(e -> { + config.setOverclock(setting); + saveConfig(); + + if (runner != null) { + // Resolved here rather than on the emulation thread: the region is what turns a + // percentage into scanlines, and it belongs to the machine this thread owns. + var overclock = setting.resolve(nes.getRegion()); + var ppu = nes.getPPU(); + + runner.post(() -> ppu.setOverclock(overclock)); + } + }); + + group.add(item); + menu.add(item); + } + + return menu; + } + /** * Builds the Region submenu: believe the cartridge, or insist on one machine or the other. *

    @@ -1213,7 +1269,9 @@ private void playMovie() { logger.log(Level.INFO, "playing " + path.getFileName() + ", " + movie.frameCount() + " frames" + (movie.anchored() ? " from a state inside it" : " from power on") + (movie.genie().isEmpty() ? "" - : ", with " + movie.genie().size() + " Game Genie codes it was recorded with")); + : ", with " + movie.genie().size() + " Game Genie codes it was recorded with") + + (movie.overclock().isNone() ? "" + : ", and " + movie.overclock() + ", which it was recorded with")); // Consumed by startMachine, after the codes have been replayed and before the thread starts. pendingMovie = movie; @@ -1249,6 +1307,15 @@ private void playbackEnded(final EmulatorRunner from) { keyboardInput.setPlaybackMuted(false); keyboardInput.setLatching(false); + // The machine spent the replay on the movie's overclock, which may not be the menu's. The + // runner is still alive and the game is somebody's again from the next frame, so the menu's + // answer goes back on -- which is also what makes the greyed-out submenu tell the truth + // about what is running the moment it comes back. + var overclock = config.overclock().resolve(nes.getRegion()); + var ppu = nes.getPPU(); + + runner.post(() -> ppu.setOverclock(overclock)); + updateMovieItems(); updateTitle(); } @@ -1270,7 +1337,8 @@ private Path defaultMoviePath() { * Power Cycle and Region both build a new machine, and the take lives in the runner that would * be torn down. The Game Genie goes with them for a different reason: a movie pins the codes * when it starts, so changing them half way through would leave a file that cannot be replayed - * and does not say so. + * and does not say so. Overclock is pinned the same way and greyed out for the same reason, + * with more at stake -- it is the one hack a replay's frames actually depend on. */ private void updateMovieItems() { var busy = movieRecording || moviePlaying; @@ -1283,6 +1351,7 @@ private void updateMovieItems() { machineMenuPowerCycle.setEnabled(!busy); machineMenuRegion.setEnabled(!busy); hacksMenuGameGenie.setEnabled(cart != null && !busy); + hacksMenuOverclock.setEnabled(!busy); } /** @@ -1515,6 +1584,15 @@ private void startMachine(final Cart cart, final Path rom, final Path patch) { nes.getPPU().setSpriteLayerVisible(debugMenuSprites.isSelected()); nes.getPPU().setUnlimitedSprites(hacksMenuUnlimitedSprites.isSelected()); + // A movie carries its own, for the reason it carries the codes and a sharper one: this is + // how much of its work the game gets through in a frame, so a replay at another setting is + // a replay of a different game. Off the config rather than off the last machine because the + // region can have changed under it, and a percentage is only scanlines once there is a + // machine to ask. + nes.getPPU().setOverclock(pendingMovie != null + ? pendingMovie.overclock() + : config.overclock().resolve(nes.getRegion())); + // 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. debugger.attach(nes); diff --git a/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/OverclockSetting.java b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/OverclockSetting.java new file mode 100644 index 0000000..1a835ec --- /dev/null +++ b/mynes-desktop/src/main/java/com/github/dimiro1/mynes/ui/OverclockSetting.java @@ -0,0 +1,108 @@ +package com.github.dimiro1.mynes.ui; + +import com.github.dimiro1.mynes.Overclock; +import com.github.dimiro1.mynes.Region; + +import java.lang.System.Logger; +import java.lang.System.Logger.Level; + +/** + * How much extra time a frame to give the game, as a menu offers it. + *

    + * Separate from {@link Overclock} because the two ends of this are counted in different units. A + * player thinks in time per frame -- "half as long again to get the work done" -- and the chip + * thinks in scanlines, and which number of scanlines a percentage comes to depends on whether the + * frame is 262 lines or 312. So the menu remembers a percentage and the region decides the + * conversion, which also means the same setting survives a region switch and means the same thing + * on the other side of it. + *

    + * Every one of these adds its lines before the NMI, which is the half that changes nothing + * a game can observe except that the frame is longer. Lines after the NMI move the picture relative + * to it and can break a mid-screen split, so they are not something to hand somebody a menu item + * for; the command line and the REPL can still ask for them. + * + * @see Overclock + */ +public enum OverclockSetting { + + /** + * The hardware, and what a machine nobody has asked runs at. + */ + OFF("off", "Off", 0), + + PLUS_25("25", "+25%", 25), + PLUS_50("50", "+50%", 50), + PLUS_100("100", "+100%", 100), + PLUS_200("200", "+200%", 200); + + private static final Logger logger = System.getLogger("UI"); + + private final String id; + private final String label; + private final int percent; + + OverclockSetting(final String id, final String label, final int percent) { + this.id = id; + this.label = label; + this.percent = percent; + } + + /** + * How this setting is spelled in the config file. The bare number, since that is what somebody + * editing the file by hand would write. + */ + public String id() { + return id; + } + + /** + * How it is spelled in the menu. + */ + public String label() { + return label; + } + + /** + * How much longer a frame this asks for, as a percentage of the region's own. + */ + public int percent() { + return percent; + } + + /** + * What that comes to in scanlines on this machine: 131 for half an NTSC frame again, 156 for + * half a PAL one. + */ + public Overclock resolve(final Region region) { + return percent == 0 ? Overclock.NONE : Overclock.percentOf(region, percent); + } + + /** + * What the emulator does when nothing has said otherwise: the hardware's own timing. This is a + * hack that changes what the game does, so nobody gets it without asking. + */ + @SuppressWarnings("SameReturnValue") + public static OverclockSetting defaultSetting() { + return OFF; + } + + /** + * The setting {@code id} names, or the default if nothing does. + *

    + * A misspelling costs the setting rather than the startup, like every other entry in the file -- + * and falling back to {@link #OFF} is the mildest way to be wrong, since it is the machine the + * cartridge was written for. + */ + public static OverclockSetting byId(final String id) { + for (var setting : values()) { + if (setting.id().equals(id)) { + return setting; + } + } + + logger.log(Level.WARNING, + id + " is not an overclock, falling back to " + defaultSetting().id()); + + return defaultSetting(); + } +} 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 e1ea3a7..d179549 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 @@ -304,6 +304,44 @@ void surroundingSpaceIsIgnored() throws IOException { void anythingElseLeavesItOff() throws IOException { assertFalse(Config.load(write("hacks.unlimited-sprites=yes\n")).unlimitedSprites()); } + + @Test + void aMissingOverclockLeavesTheFrameAsLongAsItWas() throws IOException { + assertEquals( + OverclockSetting.OFF, + Config.load(write("video.palette=nesdev\n")).overclock()); + } + + @Test + void aPercentageNamesItsPreset() throws IOException { + assertEquals( + OverclockSetting.PLUS_50, + Config.load(write("hacks.overclock=50\n")).overclock()); + assertEquals( + OverclockSetting.PLUS_200, + Config.load(write("hacks.overclock= 200 \n")).overclock()); + } + + @Test + void aPercentageNobodyOffersFallsBackToOff() throws IOException { + // The mildest way to be wrong: the machine the cartridge was written for, which is what + // somebody who had not thought about it would have got anyway. + assertEquals( + OverclockSetting.OFF, + Config.load(write("hacks.overclock=lots\n")).overclock()); + assertEquals( + OverclockSetting.OFF, + Config.load(write("hacks.overclock=33\n")).overclock()); + } + + @Test + void anOverclockSurvivesTheRoundTrip() throws IOException { + var config = Config.load(config()); + config.setOverclock(OverclockSetting.PLUS_100); + config.save(config()); + + assertEquals(OverclockSetting.PLUS_100, Config.load(config()).overclock()); + } } @Nested @@ -523,6 +561,7 @@ void aSaveWritesEverySection() throws IOException { config.setFastForwardSpeed(EmulationSpeed.TWO_TIMES); config.setMuted(true); config.setUnlimitedSprites(true); + config.setOverclock(OverclockSetting.PLUS_50); config.setRewindSeconds(45); config.setRewindKey(KeyEvent.VK_BACK_SPACE); config.save(config()); @@ -537,6 +576,7 @@ void aSaveWritesEverySection() throws IOException { 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("hacks.overclock=50"), text); assertTrue(text.contains("rewind.seconds=45"), text); assertTrue(text.contains("rewind.key=VK_BACK_SPACE"), text); assertTrue(text.contains("controller1.a=VK_L"), text); diff --git a/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/OverclockSettingTests.java b/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/OverclockSettingTests.java new file mode 100644 index 0000000..47edb69 --- /dev/null +++ b/mynes-desktop/src/test/java/com/github/dimiro1/mynes/ui/OverclockSettingTests.java @@ -0,0 +1,69 @@ +package com.github.dimiro1.mynes.ui; + +import com.github.dimiro1.mynes.Overclock; +import com.github.dimiro1.mynes.Region; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +/** + * The menu's percentages, and what they come to on each machine. + *

    + * A percentage is remembered rather than a line count precisely so that this conversion happens + * late: the region can change under a setting -- Machine > Region builds a new console -- and + * "half as long again" has to go on meaning that on the other side of it. + */ +class OverclockSettingTests { + + @Test + void eachPresetResolvesAgainstTheRegionItIsRunOn() { + assertEquals(new Overclock(131, 0), OverclockSetting.PLUS_50.resolve(Region.NTSC)); + assertEquals(new Overclock(156, 0), OverclockSetting.PLUS_50.resolve(Region.PAL)); + + assertNotEquals( + OverclockSetting.PLUS_50.resolve(Region.NTSC), + OverclockSetting.PLUS_50.resolve(Region.PAL), + "half of 262 lines is not half of 312, which is why this is not a line count"); + } + + @Test + void offIsTheHardwareOnEitherMachine() { + assertEquals(Overclock.NONE, OverclockSetting.OFF.resolve(Region.NTSC)); + assertEquals(Overclock.NONE, OverclockSetting.OFF.resolve(Region.PAL)); + } + + @Test + void thePresetsCoverAQuarterToDoubleTheFrame() { + assertEquals(new Overclock(66, 0), OverclockSetting.PLUS_25.resolve(Region.NTSC)); + assertEquals(new Overclock(262, 0), OverclockSetting.PLUS_100.resolve(Region.NTSC)); + assertEquals(new Overclock(524, 0), OverclockSetting.PLUS_200.resolve(Region.NTSC)); + } + + @Test + void everyPresetPutsItsLinesBeforeTheNmi() { + // The half that changes nothing a game can observe except that the frame is longer. Lines + // after the NMI are reachable from the command line and the REPL and not from a menu. + for (var setting : OverclockSetting.values()) { + assertEquals(0, setting.resolve(Region.NTSC).afterNmi(), setting.label()); + } + } + + @Test + void anIdNobodyOffersFallsBackToOff() { + assertEquals(OverclockSetting.OFF, OverclockSetting.byId("lots")); + assertEquals(OverclockSetting.OFF, OverclockSetting.byId("33")); + assertEquals(OverclockSetting.defaultSetting(), OverclockSetting.byId("")); + } + + @Test + void anIdIsWhatSomebodyEditingTheFileWouldWrite() { + assertEquals("off", OverclockSetting.OFF.id()); + assertEquals("50", OverclockSetting.PLUS_50.id()); + assertEquals("+50%", OverclockSetting.PLUS_50.label()); + + for (var setting : OverclockSetting.values()) { + assertEquals(setting, OverclockSetting.byId(setting.id()), setting.label()); + } + } +} 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 6d30d76..03779d4 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 @@ -169,6 +169,20 @@ private static int runCartridge(final Options options) throws IOException { session.nes().getPPU().setUnlimitedSprites( options.hacks().contains(Options.UNLIMITED_SPRITES)); + // The other hack, and the one that has to come off the movie when there is one: it + // changes how much of its work the game gets through in a frame, so a replay at another + // setting is a replay of a different game. --play and --hack overclock refuse each + // other, so these two are never both asking for something. + var overclock = movie != null ? movie.overclock() : options.overclock(); + + session.nes().getPPU().setOverclock(overclock); + + if (!overclock.isNone()) { + logger.log(Level.INFO, "running with " + overclock + + "; the game gets more time a frame, so run.hacks rather than the picture" + + " is what tells this run from a plain one"); + } + // And a Game Genie is not machine state either, for the same reason and one more: the // cartridge it is plugged into is untouched, so a state taken with codes in has nothing // in it to say so. 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 785a2d6..fe823ce 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 @@ -1,6 +1,7 @@ package com.github.dimiro1.mynes.headless; import com.github.dimiro1.mynes.Cart; +import com.github.dimiro1.mynes.Overclock; import com.github.dimiro1.mynes.Region; import com.github.dimiro1.mynes.cheat.GameGenieCode; import com.github.dimiro1.mynes.cheat.InvalidGameGenieCodeException; @@ -45,6 +46,8 @@ * 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 overclock how many idle scanlines a frame to add, which is the one hack that takes + * a number rather than a yes. {@link Overclock#NONE} unless one was named. * @param genie Game Genie codes to put in the cartridge slot, already decoded. * @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. @@ -86,6 +89,7 @@ public record Options( NESPalette palette, boolean audio, Set hacks, + Overclock overclock, List genie, List dumps, Path loadState, @@ -121,10 +125,20 @@ public enum Format { */ public static final String UNLIMITED_SPRITES = "unlimited-sprites"; + /** + * Extra idle scanlines a frame, so that a game whose main loop overruns stops dropping frames. + * The one hack that takes a number: {@code --hack overclock=131}, or {@code =131+20} to put some + * of them after the NMI as well. + *

    + * Unlike {@link #UNLIMITED_SPRITES} this one changes the machine's timing and so what the game + * does, which is why {@code --play} refuses it and a movie carries its own. + */ + public static final String OVERCLOCK = "overclock"; + /** * Every hack there is, which is also what an unknown {@code --hack} is answered with. */ - public static final Set HACKS = Set.of(UNLIMITED_SPRITES); + public static final Set HACKS = Set.of(UNLIMITED_SPRITES, OVERCLOCK); /** * Ten seconds of emulated time, which is about a second of real time and long enough for most @@ -246,6 +260,27 @@ The second is worth building once (mvn -B package -DskipTests) for anything run game can see changes: the overflow flag still rises and the cartridge sees the same address bus. + overclock=N[+M] Give the program N extra idle scanlines a + frame before the NMI, and M after it, so + that a game whose main loop overruns its + frame stops dropping one. A line is about + 113.67 CPU cycles on NTSC and 106.56 on + PAL; 0 to 1000 each, and 0 is off. + --hack overclock=131 is half an NTSC + frame again. + Unlike the one above this changes the + machine's timing and so what the game + does, which makes an overclocked run and + a plain one two different games rather + than two views of one. Reach for the + before-NMI number: extra lines after the + NMI move the picture relative to it, and + break code that counts cycles down to a + mid-screen split. The picture is drawn + exactly as the hardware draws it either + way, and the sound is a hardware frame's + worth, since the APU stands still through + the extra lines. Game Genie, which is a thing the console did do --genie CODE[,CODE..] Put a code in the cartridge slot. Repeatable. Six letters or @@ -303,8 +338,11 @@ The second is worth building once (mvn -B package -DskipTests) for anything run with nothing held down, which is how to see what the game does when the player stops playing. The movie is the input, so --play refuses --record, --input, - --input-file, --reset-at, --genie, --load-state, --sram-in and - --interactive rather than letting one of them quietly win. + --input-file, --reset-at, --genie, --hack overclock, + --load-state, --sram-in and --interactive rather than letting + one of them quietly win. --hack unlimited-sprites still + combines with it, being a change to the picture and to nothing + the replay depends on. It has to be the same cartridge and the same region; anything else exits 2. run.replay in the report says what was played. @@ -363,6 +401,7 @@ public static Options parse(final String[] args) { NESPalette palette = null; var audio = false; var hacks = new LinkedHashSet(); + var overclock = Overclock.NONE; var genie = new ArrayList(); var dumps = new LinkedHashSet(); Path loadState = null; @@ -411,7 +450,8 @@ 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 "--hack" -> overclock = + parseHacks(value(args, ++i, flag), hacks, overclock); case "--genie" -> parseGenie(value(args, ++i, flag), genie); case "--dump" -> parseDumps(value(args, ++i, flag), dumps); case "--load-state" -> loadState = Path.of(value(args, ++i, flag)); @@ -452,6 +492,9 @@ public static Options parse(final String[] args) { "the movie is the input"); refuseWithPlay(!resetAt.isEmpty(), "--reset-at", "the movie carries the frames Reset was pressed at"); + refuseWithPlay(!overclock.isNone(), "--hack overclock", + "the movie carries the overclock it was recorded with, and running it with" + + " another would be a different run"); refuseWithPlay(!genie.isEmpty(), "--genie", "the movie carries the codes it was recorded with, and putting others in would" + " be a different run"); @@ -488,6 +531,7 @@ public static Options parse(final String[] args) { palette, audio, Set.copyOf(hacks), + overclock, List.copyOf(genie), List.copyOf(dumps), loadState, @@ -660,8 +704,20 @@ private static boolean parseScreenshots(final String text, final TreeSet f * 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. + *

    + * One of them takes a value and the rest do not, which is why a token is split on its first + * {@code =} before the name is looked up: {@code overclock} without a line count is a wish + * nobody can act on, and {@code unlimited-sprites=3} is somebody with the wrong idea of what + * it does. Both are refused by name rather than by shape. + * + * @param overclock what the overclock was before this list, so that several {@code --hack} + * flags accumulate rather than the last one wiping the rest. + * @return what it is after it. */ - private static void parseHacks(final String text, final Set hacks) { + private static Overclock parseHacks( + final String text, final Set hacks, final Overclock overclock) { + var result = overclock; + for (var token : text.split(",")) { var trimmed = token.trim().toLowerCase(); @@ -669,13 +725,64 @@ private static void parseHacks(final String text, final Set hacks) { continue; } - if (!HACKS.contains(trimmed)) { + var equals = trimmed.indexOf('='); + var name = equals < 0 ? trimmed : trimmed.substring(0, equals); + var value = equals < 0 ? null : trimmed.substring(equals + 1); + + if (!HACKS.contains(name)) { throw new UsageException( - "--hack does not know \"" + trimmed + "\". It knows " + "--hack does not know \"" + name + "\". It knows " + String.join(", ", new TreeSet<>(HACKS)) + "."); } - hacks.add(trimmed); + if (OVERCLOCK.equals(name)) { + if (value == null) { + throw new UsageException( + "--hack overclock wants a number of scanlines, as in" + + " \"--hack overclock=131\", or \"=131+20\" to put some of" + + " them after the NMI as well."); + } + + result = parseOverclock(value); + } else if (value != null) { + throw new UsageException( + "--hack " + name + " is switched on by naming it and takes no value, so" + + " \"=" + value + "\" is not something it can do."); + } + + hacks.add(name); + } + + return result; + } + + /** + * Reads {@code LINES} or {@code LINES+MORE}: how many idle scanlines to add before the NMI, and + * how many after it. + *

    + * Two numbers rather than one because they are not interchangeable -- extra post-render lines + * change nothing a game can observe, where extra vblank lines move the picture relative to the + * NMI -- and the shorter form is the one to reach for. {@code 0} is the hardware, and is how to + * write "off" in a script that builds its own command line. + */ + private static Overclock parseOverclock(final String text) { + var plus = text.indexOf('+'); + var before = plus < 0 ? text : text.substring(0, plus); + var after = plus < 0 ? "0" : text.substring(plus + 1); + + try { + return new Overclock(scanlines(before), scanlines(after)); + } catch (IllegalArgumentException e) { + throw new UsageException("--hack overclock: " + e.getMessage()); + } + } + + private static int scanlines(final String text) { + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException e) { + throw new UsageException( + "--hack overclock wants a number of scanlines, not \"" + text + "\"."); } } 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 fd2f405..2e0acd5 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 @@ -1,5 +1,6 @@ package com.github.dimiro1.mynes.headless; +import com.github.dimiro1.mynes.Overclock; import com.github.dimiro1.mynes.cheat.GameGenie; import com.github.dimiro1.mynes.cheat.GameGenieCode; import com.github.dimiro1.mynes.cheat.InvalidGameGenieCodeException; @@ -17,6 +18,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Locale; +import java.util.TreeSet; /** * The machine, driven a command at a time. @@ -54,6 +56,9 @@ public final class Repl { 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 + hack overclock LINES [MORE] + extra scanlines a frame before the NMI, and after it; off + takes them away genie [CODE] list the Game Genie codes, or put one in ungenie CODE take one out genie clear take them all out @@ -518,40 +523,115 @@ private void dump(final String[] words) throws IOException { } /** - * Switches one of the things the console does not do on or off, mid-session. + * Switches one of the things the console does not do on, mid-session -- or, for the one that + * takes a number, sets how much of it. *

    * 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. + * turn it off, take another. Nothing about the machine changes for that one, so the two pictures + * are of the same moment. + *

    + * The overclock is not like that, and the command is here for a different reason. It changes the + * machine's timing, so the frame after it is set is not the frame that would have been drawn -- + * what a session is for is watching a game that lags stop lagging, at whatever setting it takes. */ private void hack(final String[] words) { - if (words.length < 3) { + if (words.length < 2) { throw new UsageException( - "hack wants a name and on or off, as in \"hack " - + Options.UNLIMITED_SPRITES + " on\"."); + "hack wants a name, as in \"hack " + Options.UNLIMITED_SPRITES + " on\"."); } var name = words[1].toLowerCase(Locale.ROOT); + var ppu = session.nes().getPPU(); + + // The name first, and the rest of the line read according to it: one of these is a switch + // and the other takes a number of scanlines, so a shape checked before the name was known + // would refuse one of the two forms whichever shape it insisted on. + switch (name) { + case Options.UNLIMITED_SPRITES -> { + ppu.setUnlimitedSprites(onOrOff(words)); + + reply("hack", node -> { + node.put("hack", name); + node.put("on", ppu.isUnlimitedSprites()); + }); + } + case Options.OVERCLOCK -> { + // A movie pins the overclock when it starts, for the reason it pins the codes and + // a sharper one: this decides how much of its work the game gets through in a + // frame, so a file naming one setting whose frames were played at another cannot + // be replayed and would not say so. + if (session.recording()) { + throw new UsageException( + "a movie is being recorded, and it pinned the overclock when it" + + " started. Stop the recording first, or set the overclock" + + " before starting one."); + } + + ppu.setOverclock(overclock(words)); + + reply("hack", node -> { + node.put("hack", name); + node.put("on", !ppu.getOverclock().isNone()); + node.put("beforeNmi", ppu.getOverclock().beforeNmi()); + node.put("afterNmi", ppu.getOverclock().afterNmi()); + }); + } + default -> throw new UsageException( + "hack does not know \"" + words[1] + "\". It knows " + + String.join(", ", new TreeSet<>(Options.HACKS)) + "."); + } + } + + /** + * The two-position form: {@code hack NAME on|off}. + */ + private static boolean onOrOff(final String[] words) { + if (words.length < 3) { + throw new UsageException( + "hack " + words[1] + " is switched on or off, as in \"hack " + words[1] + + " on\"."); + } - var on = switch (words[2].toLowerCase(Locale.ROOT)) { + return 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] + "\"."); + "hack " + words[1] + " 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) + "."); + /** + * The form that takes a number: {@code hack overclock LINES [MORE]}, or {@code off}. + *

    + * {@code on} is not one of the answers, and that is the point of spelling this out: how many + * lines is the whole of the question, and a bare "on" is somebody who has not been asked it yet. + */ + private static Overclock overclock(final String[] words) { + if (words.length < 3) { + throw new UsageException( + "hack overclock wants a number of scanlines, as in \"hack overclock 131\"," + + " or \"hack overclock off\" to take them away."); } - reply("hack", node -> { - node.put("hack", name); - node.put("on", on); - }); + if ("off".equalsIgnoreCase(words[2])) { + return Overclock.NONE; + } + + try { + return new Overclock(scanlines(words[2]), words.length > 3 ? scanlines(words[3]) : 0); + } catch (IllegalArgumentException e) { + throw new UsageException("hack overclock: " + e.getMessage()); + } + } + + private static int scanlines(final String text) { + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException e) { + throw new UsageException( + "hack overclock wants a number of scanlines, not \"" + text + "\"."); + } } /** 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 70ebec8..69d9f63 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 @@ -145,6 +145,13 @@ public static String write( var hacks = run.putObject("hacks"); hacks.put("unlimitedSprites", ppu.isUnlimitedSprites()); + // An object rather than a number, because "how many lines" is two questions with different + // answers -- and because a key that was sometimes a boolean and sometimes a count would not + // compare. Always present, and both zero on a machine nobody overclocked. + var overclock = hacks.putObject("overclock"); + overclock.put("beforeNmi", ppu.getOverclock().beforeNmi()); + overclock.put("afterNmi", ppu.getOverclock().afterNmi()); + // And which Game Genie codes were in, which is the fourth -- and the one that matters most, // because it is the only one of the four a digest cannot stand in for. A patched run has its // own cart.sha256; a run with codes in has the cartridge's, since the cartridge really is @@ -247,6 +254,9 @@ public static String write( picture.put("frame", ppu.getFrame()); picture.put("scanline", ppu.getScanline()); picture.put("dot", ppu.getDot()); + // Whether the beam is on a line the overclock is running again, which is what explains a + // report that stopped on line 240 or the last line of blanking and looks stuck there. + picture.put("onExtraLine", ppu.isOnExtraLine()); picture.put("v", ppu.getV()); picture.put("t", ppu.getT()); picture.put("fineX", ppu.getFineX()); 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 9586542..330810a 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 @@ -513,6 +513,96 @@ void aHackNobodyHasWrittenIsACommandLineError() { assertEquals(2, run("--hack", "infinite-lives")); } + @Test + void theReportSaysHowManyLinesWereAdded() throws Exception { + run(); + + assertEquals(0, report().at("/run/hacks/overclock/beforeNmi").asInt(), + "always present, so two reports compare key for key"); + assertEquals(0, report().at("/run/hacks/overclock/afterNmi").asInt()); + + run("--hack", "overclock=30+10"); + + assertEquals(30, report().at("/run/hacks/overclock/beforeNmi").asInt()); + assertEquals(10, report().at("/run/hacks/overclock/afterNmi").asInt()); + } + + /** + * The whole of what the hack buys: cycles. 30 scanlines is 341 dots each and three dots to a + * cycle, so a frame is 3410 CPU cycles longer and sixty of them are 204600. + */ + @Test + void anOverclockedFrameCostsTheCpuMoreCycles() throws Exception { + run(); + + var plain = report().at("/run/cpuCycles").asLong(); + + run("--hack", "overclock=30"); + + assertEquals(plain + 60L * 30 * 341 / 3, report().at("/run/cpuCycles").asLong(), 2.0); + } + + /** + * And the whole of what it does not buy. The APU stands still through the extra lines, so a + * frame is a hardware frame's worth of sound however long the program had in it -- which is what + * keeps the music at pitch and what lets the desktop go on pacing itself on the sound card. + */ + @Test + void anOverclockedRunStillMakesAHardwareFramesWorthOfSound() throws Exception { + run("--audio"); + + var plain = report().at("/audio/samples").asLong(); + + run("--audio", "--hack", "overclock=131"); + + // Within one: where the decimator's fractional cycle count happens to sit when the run + // stops decides whether the last sample was finished. + assertEquals(plain, report().at("/audio/samples").asLong(), 1.0, + "an overclocked frame is a hardware frame's worth of sound"); + assertTrue(plain > 0, "and there was some sound to compare in the first place"); + } + + /** + * The APU's counter keeps moving even while the chip does nothing, because its parity is what + * {@code CPUBus.isGetCycle} reads and the MMU asks the same question of the CPU's. + */ + @Test + void theTwoCycleCountersStillAgree() throws Exception { + run("--hack", "overclock=131+40"); + + assertEquals( + report().at("/run/cpuCycles").asLong(), + report().at("/run/apuCycles").asLong()); + } + + /** + * nestest keeps up with its frame comfortably, so the extra cycles have nothing to do with them + * -- which makes it the right cartridge for showing that the lines on their own change no + * pixels. {@code OverclockRunTests} is where a game that cannot keep up is run. + */ + @Test + void anOverclockOnAGameThatKeepsUpLeavesThePictureExactlyAsItWas() throws Exception { + run(); + + var withoutIt = report().at("/video/finalFrame/hash").asText(); + + run("--hack", "overclock=131"); + + assertEquals(withoutIt, report().at("/video/finalFrame/hash").asText()); + } + + @Test + void anOverclockSetInTheReplIsInTheReport() throws Exception { + var script = Files.writeString( + out.resolve("overclock.txt"), "run 5\nhack overclock 40 20\nquit\n"); + + run("--script", script.toString()); + + assertEquals(40, report().at("/run/hacks/overclock/beforeNmi").asInt(), + "nobody put it on the command line, and it is on all the same"); + assertEquals(20, report().at("/run/hacks/overclock/afterNmi").asInt()); + } + /** * 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. 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 6dacecf..c606c47 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 @@ -1,5 +1,6 @@ package com.github.dimiro1.mynes.headless; +import com.github.dimiro1.mynes.Overclock; import com.github.dimiro1.mynes.Region; import com.github.dimiro1.mynes.palette.Palettes; import org.junit.jupiter.api.Test; @@ -222,6 +223,82 @@ void aHackNameThatIsNotOnTheListIsRejected() { assertTrue(message.contains("unlimited-sprites"), "the message should offer the real ids"); } + @Test + void anOverclockIsTakenAsLines() { + var options = parse("--rom", "x.nes", "--hack", "overclock=131"); + + assertEquals(new Overclock(131, 0), options.overclock()); + assertEquals(Set.of(Options.OVERCLOCK), options.hacks(), + "it is named under --hack like every other one, so run.hacks reports it"); + } + + @Test + void anOverclockTakesLinesAfterTheNmiToo() { + assertEquals( + new Overclock(131, 20), + parse("--rom", "x.nes", "--hack", "overclock=131+20").overclock()); + } + + @Test + void noOverclockIsAskedForUnlessOneIsNamed() { + assertEquals(Overclock.NONE, parse("--rom", "x.nes").overclock(), + "the console's own frame is the default"); + } + + /** + * The one hack that takes a value, so the one that can be half typed. "overclock" on its own is + * a wish nobody can act on -- there is no obvious number of lines to pick -- and a run that + * quietly chose one would look like it had worked. + */ + @Test + void anOverclockWithoutALineCountIsRejected() { + var message = refused("--rom", "x.nes", "--hack", "overclock").getMessage(); + + assertTrue(message.contains("scanlines"), message); + assertTrue(message.contains("overclock=131"), "and the message should show the form"); + } + + @Test + void anOverclockOutsideTheRangeIsRejected() { + assertTrue(refused("--rom", "x.nes", "--hack", "overclock=1001") + .getMessage().contains("0 to " + Overclock.MAX_SCANLINES)); + + assertTrue(refused("--rom", "x.nes", "--hack", "overclock=-1") + .getMessage().contains("0 to " + Overclock.MAX_SCANLINES)); + + assertTrue(refused("--rom", "x.nes", "--hack", "overclock=lots") + .getMessage().contains("lots")); + } + + @Test + void zeroLinesIsHowToWriteOffOnACommandLineSomethingElseBuilt() { + assertEquals(Overclock.NONE, parse("--rom", "x.nes", "--hack", "overclock=0").overclock()); + } + + @Test + void aHackThatTakesNoValueRefusesOne() { + var message = refused("--rom", "x.nes", "--hack", "unlimited-sprites=3").getMessage(); + + assertTrue(message.contains("unlimited-sprites"), message); + } + + @Test + void theHacksShareOneList() { + var options = parse("--rom", "x.nes", "--hack", "unlimited-sprites,overclock=30"); + + assertEquals(Set.of(Options.UNLIMITED_SPRITES, Options.OVERCLOCK), options.hacks()); + assertEquals(new Overclock(30, 0), options.overclock()); + } + + @Test + void severalHackFlagsAccumulateRatherThanReplacingEachOther() { + var options = parse( + "--rom", "x.nes", "--hack", "overclock=30", "--hack", "unlimited-sprites"); + + assertEquals(new Overclock(30, 0), options.overclock(), + "the second flag said nothing about the overclock, so it kept the first's"); + } + @Test void noGameGenieCodeIsInUnlessOneIsGiven() { assertTrue(parse("--rom", "x.nes").genie().isEmpty(), "the cartridge slot is the default"); @@ -359,6 +436,7 @@ void playRefusesTheFlagsTheMovieReplaces() { List.of("--input", "60:start"), List.of("--reset-at", "100"), List.of("--genie", "SXIOPO"), + List.of("--hack", "overclock=131"), List.of("--load-state", "in.mn"), List.of("--sram-in", "in.sav"), List.of("--interactive")); @@ -375,6 +453,35 @@ void playRefusesTheFlagsTheMovieReplaces() { } } + /** + * The two hacks are not the same kind of thing to a replay, and this is the difference. The + * sprite limit changes only pixels, so a replay with it on is still the recorded session seen + * more clearly; the overclock changes how much of its work the game gets through in a frame, + * which makes it a different session. + */ + @Test + void playRefusesAnOverclockButNotTheOtherHack() { + var refused = refused( + "--rom", "x.nes", "--play", "take.mnm", "--hack", "overclock=131"); + + assertTrue(refused.getMessage().contains("--hack overclock"), refused.getMessage()); + + assertEquals( + Set.of(Options.UNLIMITED_SPRITES), + parse("--rom", "x.nes", "--play", "take.mnm", "--hack", "unlimited-sprites") + .hacks()); + } + + @Test + void theUsageSaysWhatAnOverclockedRunIsNotComparableWith() { + assertTrue(Options.usage().contains("--hack overclock"), + "somebody reading --help should learn that --play refuses it"); + assertTrue(Options.usage().contains("overclock=N[+M]"), + "and what the flag looks like"); + assertTrue(Options.usage().contains("stands still"), + "and that the sound is a hardware frame's worth however long the frame is"); + } + /** * Everything else combines. A recorded run is an ordinary run with somebody taking notes. */ diff --git a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/OverclockROM.java b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/OverclockROM.java new file mode 100644 index 0000000..768d65b --- /dev/null +++ b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/OverclockROM.java @@ -0,0 +1,310 @@ +package com.github.dimiro1.mynes.headless; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * A cartridge whose game logic does not fit in a frame, assembled here rather than vendored. + *

    + * It exists because {@code --hack overclock} cannot be demonstrated on a game that keeps up. What + * the hack undoes is a main loop overrunning its frame -- the next NMI arrives with the last frame's + * work unfinished, the game skips a turn, and the picture stutters. Super Mario Bros. 3 and Gradius + * do it under load, but only under load, in places a test cannot reliably reach and only for a + * handful of frames at a time. So this does it on purpose and does it every time. + *

    + * The program is a game with one job: count how many times it can get through a fixed pile of work. + * The pile is about 42500 cycles, which is 1.43 NTSC frames -- so on the hardware it finishes one + * iteration every two frames, because the wait at the end of one always ends on an NMI and + * the loop is phase-locked to them. Give it 131 extra scanlines a frame and the frame becomes 44671 + * cycles, the pile fits, and it manages one iteration per frame. Give it 66 -- half as many -- and + * the frame is 37282 cycles, which is not enough, and it is back to one every two. + *

    + * Two counters in zero page say so, both sixteen bit and little endian, and {@code --dump ram} is + * how to read them: + *

      + *
    • {@code $00-$01} frames, counted by the NMI handler.
    • + *
    • {@code $02-$03} iterations, counted by the main loop.
    • + *
    + * The screen says the same thing without a debugger. Rendering is never switched on, so the whole + * picture is the backdrop -- and with rendering off the backdrop is read from wherever the VRAM + * address happens to point rather than from $3F00, which is the "background palette hack" real games + * use to flash the screen. So the NMI leaves the address at {@code $3F00 + (iterations & 7)} and the + * screen changes colour once per finished iteration, with no $2007 write to undo. A report's + * {@code video.frameChanges} then counts iterations for free. + *

    + * 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.OverclockROM \
    + *     mynes-headless/src/test/resources/overclock/overclock.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: PPU palettes + */ +final class OverclockROM { + /** + * 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, and where the NMI handler sits after it. Both on + * round boundaries well past the end of the code, so the addresses in the instructions below + * read as themselves and so that nothing here can grow into anything else. + */ + private static final int PALETTE_AT = 0xC100; + private static final int NMI_AT = 0xC200; + + /** + * A lone {@code RTI}, which the IRQ vector points at. + *

    + * Nothing can reach it: the program starts with {@code SEI} and switches the APU's frame + * interrupt off, and the cartridge is NROM and has no interrupt of its own. It is here so that + * an interrupt nobody can explain returns instead of running the NMI handler and counting a + * frame that did not happen. + */ + private static final int IRQ_AT = NMI_AT - 1; + + /** + * How many times round the outer delay loop, which is the whole of what makes the program too + * slow. 33 comes to 42439 cycles, and the rest of an iteration brings it to about 42500 -- + * 1.43 NTSC frames, which is comfortably over one and comfortably under two. + *

    + * Changing it changes what the cartridge demonstrates. Under 29780 cycles it keeps up on the + * hardware and there is nothing to fix; over 59561 no overclock this side of the limit would let + * it manage a frame. + */ + static final int OUTER_ITERATIONS = 33; + + /** + * Where the frame counter lives, and the iteration counter after it. Both sixteen bit and little + * endian, so {@code $00-$01} and {@code $02-$03}. + */ + static final int FRAMES_AT = 0x00; + static final int ITERATIONS_AT = 0x02; + + /** + * The flag the NMI raises and the main loop waits on: 1 when a frame has been drawn since the + * loop last cleared it. + */ + private static final int TICK_AT = 0x04; + + /** + * Eight background colours, one per palette cell the NMI can point the VRAM address at. Distinct + * on any television so that {@code video.topColours} in a report can be read, and so that + * {@code frameChanges} counts every iteration -- consecutive values of {@code iterations & 7} + * always differ, so the screen changes every time round. + */ + private static final int[] PALETTE = { + 0x0F, 0x16, 0x2A, 0x12, 0x28, 0x24, 0x1C, 0x30, + }; + + private OverclockROM() { + } + + /** + * The whole .nes file: a sixteen byte header, one 16KB program bank and one 8KB character bank. + *

    + * The character bank is empty and stays that way. Rendering is never switched on -- the picture + * is the backdrop and nothing else -- so there is no tile to put in it; it is there because a + * cartridge with no character bank at all is a cartridge with character RAM, which is a + * different thing to have to explain. + */ + 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); + + 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. + * + * @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: OverclockROM "); + 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 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. None of them crosses a page, which is load bearing rather than incidental -- a taken + * branch that crossed one would cost an extra cycle every time round the inner loop, and the + * whole point of this cartridge is how many cycles a lap takes. + */ + private static byte[] program() { + var setup = 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 rendering stays off for good + 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 #8 + 0xD0, 0xF5, // BNE -11 + + // Both counters and the flag between the two halves of the program. + 0xA9, 0x00, // LDA #$00 + 0x85, FRAMES_AT, // STA $00 + 0x85, FRAMES_AT + 1, // STA $01 + 0x85, ITERATIONS_AT, // STA $02 + 0x85, ITERATIONS_AT + 1, // STA $03 + 0x85, TICK_AT, // STA $04 + + 0xA9, 0x80, // LDA #$80 NMI on; rendering is still off + 0x8D, 0x00, 0x20, // STA $2000 + }; + + // One lap of the game, in its own array so that the address the jump at the end goes back to + // is where the setup happens to stop rather than a number counted by hand. + // + // The pile of work is a delay loop because what the work is does not matter -- only that it + // is the same every lap and that it does not fit in a frame. 33 laps of 1283 cycles, plus + // the branches, is 42439; the rest of the lap brings it to about 42500, and an NTSC frame is + // 29780. + var lap = new int[]{ + 0xA0, OUTER_ITERATIONS, // LDY #33 main: + 0xA2, 0x00, // LDX #$00 outer: + 0xCA, // DEX inner: + 0xD0, 0xFD, // BNE -3 256 times round, 1279 cycles + 0x88, // DEY + 0xD0, 0xF8, // BNE -8 + + 0xE6, ITERATIONS_AT, // INC $02 one more lap finished + 0xD0, 0x02, // BNE +2 + 0xE6, ITERATIONS_AT + 1, // INC $03 + + // Wait for the next picture. Clearing the flag before waiting is what makes this a + // wait for the *next* NMI rather than an acknowledgement of the last one -- and it + // is why a lap takes a whole number of frames however long the work took. + 0xA9, 0x00, // LDA #$00 + 0x85, TICK_AT, // STA $04 + 0xA5, TICK_AT, // LDA $04 wait: + 0xF0, 0xFC, // BEQ -4 + }; + + var bank = new byte[0x4000]; + + for (var i = 0; i < setup.length; i++) { + bank[i] = (byte) setup[i]; + } + + for (var i = 0; i < lap.length; i++) { + bank[setup.length + i] = (byte) lap[i]; + } + + // Back to the top of the lap. Where the jump sits and where it goes both fall out of how + // long the two arrays turned out to be, so nothing here has to be counted again when either + // of them changes. + var loopStart = PRG_BASE + setup.length; + var jump = setup.length + lap.length; + + bank[jump] = (byte) 0x4C; // JMP main + bank[jump + 1] = (byte) loopStart; + bank[jump + 2] = (byte) (loopStart >> 8); + + for (var i = 0; i < PALETTE.length; i++) { + bank[PALETTE_AT - PRG_BASE + i] = (byte) PALETTE[i]; + } + + bank[IRQ_AT - PRG_BASE] = (byte) 0x40; // RTI + + var nmi = new int[]{ + 0x48, // PHA X and Y are the main loop's; A is not + 0xE6, FRAMES_AT, // INC $00 one more frame + 0xD0, 0x02, // BNE +2 + 0xE6, FRAMES_AT + 1, // INC $01 + 0xA9, 0x01, // LDA #$01 + 0x85, TICK_AT, // STA $04 let the main loop go on + + // Leave the VRAM address inside palette RAM, at the cell this lap's number names. + // With rendering off that cell *is* the backdrop, so the whole screen becomes that + // colour and stays it until the next lap -- and nothing has to be written back. + 0x2C, 0x02, 0x20, // BIT $2002 and put the $2006 latch back to first + 0xA9, 0x3F, // LDA #$3F + 0x8D, 0x06, 0x20, // STA $2006 + 0xA5, ITERATIONS_AT, // LDA $02 + 0x29, 0x07, // AND #$07 + 0x8D, 0x06, 0x20, // STA $2006 + + 0x68, // PLA + 0x40, // RTI which puts the flags back too + }; + + for (var i = 0; i < nmi.length; i++) { + bank[NMI_AT - PRG_BASE + i] = (byte) nmi[i]; + } + + // The three vectors, at the top of the bank. + var vectors = 0x4000 - 6; + + bank[vectors] = (byte) NMI_AT; + bank[vectors + 1] = (byte) (NMI_AT >> 8); + bank[vectors + 2] = (byte) PRG_BASE; + bank[vectors + 3] = (byte) (PRG_BASE >> 8); + bank[vectors + 4] = (byte) IRQ_AT; + bank[vectors + 5] = (byte) (IRQ_AT >> 8); + + return bank; + } + +} diff --git a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/OverclockRunTests.java b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/OverclockRunTests.java new file mode 100644 index 0000000..f0b0a45 --- /dev/null +++ b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/OverclockRunTests.java @@ -0,0 +1,273 @@ +package com.github.dimiro1.mynes.headless; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +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; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +/** + * What {@code --hack overclock} does to a game that cannot keep up, from a command line to the RAM + * it leaves behind. + *

    + * The core's tests pin the timing to the dot; what they cannot say is that a flag typed on a command + * line turns into a game that stops dropping frames. So this runs {@link OverclockROM} -- a cartridge + * whose main loop takes 1.43 frames on purpose -- three ways, and reads the counters out of zero + * page with {@code --dump ram}. + *

    + * Three ways rather than two, because "more is better" is not the claim. The claim is that the + * frame has to be long enough for the work: at +25% it is not, and the game lags exactly as it did + * on the hardware. + */ +class OverclockRunTests { + private static final String ROM = "src/test/resources/overclock/overclock.nes"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * Long enough for the difference to be unmistakable and short enough to run a handful of times + * in a test suite. The cartridge spends its first two frames waiting for the PPU to warm up, so + * the counts below are of 298 rather than 300. + */ + private static final String FRAMES = "300"; + + /** + * How many frames the cartridge actually gets through -- {@link #FRAMES} less the two it spends + * waiting for the PPU's warm-up window to close. + */ + private static final int PLAYED = 298; + + /** + * Half an NTSC frame again, which is enough: 44671 cycles against the 42500 a lap takes. + */ + private static final String ENOUGH = "overclock=131"; + + /** + * A quarter, which is not: 37282 cycles, and the lap still does not fit. + */ + private static final String NOT_ENOUGH = "overclock=66"; + + @TempDir + private Path out; + + /** + * The cartridge on disk and the generator beside it cannot drift apart, which is the whole + * reason it is safe to have both. + */ + @Test + void theCheckedInCartridgeIsExactlyWhatTheGeneratorProduces() throws Exception { + assertArrayEquals( + OverclockROM.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 " + + OverclockROM.class.getName() + " \\\n" + + " mynes-headless/" + ROM + "\n" + + "and change overclock.s to match, since nothing checks that one"); + } + + @Test + void onTheHardwareTheGameLagsEveryOtherFrame() throws Exception { + var into = run("plain"); + + assertEquals(PLAYED, frames(into)); + assertEquals(PLAYED / 2, iterations(into), + "the lap takes 1.43 frames, so it finishes on every other NMI"); + } + + @Test + void withTheLinesAddedItKeepsUp() throws Exception { + var into = run("enough", "--hack", ENOUGH); + + assertEquals(PLAYED, frames(into), "the same number of pictures either way"); + assertEquals(PLAYED, iterations(into), "and one lap of the game in each of them"); + } + + /** + * The other half of the claim, and the reason there are three runs here. A frame that is longer + * but still not long enough buys nothing at all -- the lap still misses its NMI and still waits + * for the one after. + */ + @Test + void aQuarterIsNotEnough() throws Exception { + var into = run("not-enough", "--hack", NOT_ENOUGH); + + assertEquals(PLAYED, frames(into)); + assertEquals(PLAYED / 2, iterations(into), "37282 cycles is still short of the 42500"); + } + + /** + * The same thing without a debugger. The cartridge leaves the VRAM address on a palette cell + * chosen by the lap counter, and with rendering off that cell is the whole screen -- so the + * picture changes once per finished lap and {@code frameChanges} counts them. + */ + @Test + void theReportSeesTwiceAsManyFrameChanges() throws Exception { + var plain = report(run("plain")).at("/video/frameChanges").asInt(); + var overclocked = report(run("enough", "--hack", ENOUGH)) + .at("/video/frameChanges").asInt(); + + // Within one either way: the run stops mid-lap, so whether the last change landed inside + // the frame count depends on where the beam was when it did. + assertEquals(PLAYED / 2, plain, 1.0, "one change of colour per finished lap"); + assertEquals(PLAYED, overclocked, 1.0, "and twice as many laps finished"); + } + + /** + * The picture is drawn exactly as the hardware draws it -- the extra lines are lines the beam is + * already idle on -- so the difference between the two runs is what the game did, not how it was + * rendered. + */ + @Test + void bothRunsDrawTheSameKindOfPicture() throws Exception { + var plain = report(run("plain")).at("/video/finalFrame/uniqueColours").asInt(); + var overclocked = report(run("enough", "--hack", ENOUGH)) + .at("/video/finalFrame/uniqueColours").asInt(); + + assertEquals(1, plain, "the whole screen is the backdrop, and nothing else is drawn"); + assertEquals(1, overclocked, "and the extra lines drew nothing on top of it"); + } + + @Test + void theReportSaysHowManyLinesWereAdded() throws Exception { + var plain = report(run("plain")).at("/run/hacks/overclock"); + + assertEquals(0, plain.get("beforeNmi").asInt(), "always present, even when it is nothing"); + assertEquals(0, plain.get("afterNmi").asInt()); + + var both = report(run("both", "--hack", "overclock=90+40")).at("/run/hacks/overclock"); + + assertEquals(90, both.get("beforeNmi").asInt()); + assertEquals(40, both.get("afterNmi").asInt()); + } + + /** + * Read off the machine rather than off the command line, so a session that changed its mind mid + * way through is reported as it ended. + */ + @Test + void theReportSaysWhatTheMachineHoldsRatherThanWhatWasAskedFor() throws Exception { + var into = out.resolve("repl"); + + assertEquals(Headless.EXIT_OK, Headless.run(new String[]{ + "--rom", ROM, + "--out", into.toString(), + "--quiet", + "--frames", "30", + "--hack", ENOUGH, + "--script", script("hack overclock 40 20", "quit").toString()})); + + var overclock = report(into).at("/run/hacks/overclock"); + + assertEquals(40, overclock.get("beforeNmi").asInt()); + assertEquals(20, overclock.get("afterNmi").asInt()); + } + + /** + * The claim the movie chunk exists for. The overclock decides how much of its work the game gets + * through in a frame, so a replay that took the hardware's timing would be a replay of a + * different game -- and nothing about the cartridge would say which had happened. + */ + @Test + void aReplayPutsTheMoviesOverclockBack() throws Exception { + var take = out.resolve("take.mnm"); + var recorded = out.resolve("a.mn"); + var replayed = out.resolve("b.mn"); + + var first = run("recorded", "--hack", ENOUGH, + "--record", take.toString(), "--save-state", recorded.toString()); + + var into = out.resolve("replayed"); + + // Nobody types a hack here, and --play would refuse one if they tried. + assertEquals(Headless.EXIT_OK, Headless.run(new String[]{ + "--rom", ROM, + "--out", into.toString(), + "--quiet", + "--play", take.toString(), + "--save-state", replayed.toString(), + "--dump", "ram"})); + + assertEquals(131, report(into).at("/run/hacks/overclock/beforeNmi").asInt(), + "the machine was set from the movie"); + assertEquals(iterations(first), iterations(into), "the same game happened"); + assertArrayEquals( + Files.readAllBytes(recorded), + Files.readAllBytes(replayed), + "byte-identical end state, which is the whole claim"); + } + + /** + * And a movie of an overclocked take really does need it, which is what makes carrying the chunk + * worth the four bytes rather than merely tidy. + */ + @Test + void thatSameTakeWouldHaveBeenADifferentGameWithoutIt() throws Exception { + var overclocked = run("enough", "--hack", ENOUGH); + var plain = run("plain"); + + assertNotEquals(iterations(plain), iterations(overclocked)); + } + + // ================================================================================== internals + + private Path run(final String name, final String... extra) { + var into = out.resolve(name); + var args = new String[extra.length + 9]; + + args[0] = "--rom"; + args[1] = ROM; + args[2] = "--out"; + args[3] = into.toString(); + args[4] = "--quiet"; + args[5] = "--frames"; + args[6] = FRAMES; + args[7] = "--dump"; + args[8] = "ram"; + + System.arraycopy(extra, 0, args, 9, extra.length); + + assertEquals(Headless.EXIT_OK, Headless.run(args)); + + return into; + } + + private Path script(final String... commands) throws IOException { + var path = out.resolve("session.txt"); + + Files.writeString(path, String.join(System.lineSeparator(), commands)); + + return path; + } + + /** + * The cartridge's frame counter, out of $00-$01. + */ + private static int frames(final Path into) throws IOException { + return word(into, OverclockROM.FRAMES_AT); + } + + /** + * Its lap counter, out of $02-$03. + */ + private static int iterations(final Path into) throws IOException { + return word(into, OverclockROM.ITERATIONS_AT); + } + + private static int word(final Path into, final int address) throws IOException { + var ram = Files.readAllBytes(into.resolve("ram.bin")); + + return Byte.toUnsignedInt(ram[address]) | Byte.toUnsignedInt(ram[address + 1]) << 8; + } + + private static JsonNode report(final Path into) throws IOException { + return MAPPER.readTree(Files.readString(into.resolve("report.json"))); + } +} 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 e9939eb..b647bb2 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 @@ -247,6 +247,75 @@ void aHackThatIsMisspeltOrHalfTypedIsAnError() throws Exception { assertTrue(replies.get(3).get("error").asText().contains("maybe")); } + /** + * The other hack, which takes a number rather than a switch -- so the arity of the command + * depends on which one was named, and "hack overclock on" is not a thing anybody can mean. + */ + @Test + void theOverclockCanBeSetAndClearedMidSession() throws Exception { + var replies = session( + "run 5", + "hack overclock 131", + "hack overclock 40 20", + "hack overclock off", + "quit"); + + replies.forEach(reply -> assertTrue(reply.get("ok").asBoolean(), reply.toString())); + + assertEquals("overclock", replies.get(1).get("hack").asText()); + assertTrue(replies.get(1).get("on").asBoolean(), "so jq .on works for both hacks"); + assertEquals(131, replies.get(1).get("beforeNmi").asInt()); + assertEquals(0, replies.get(1).get("afterNmi").asInt()); + + assertEquals(40, replies.get(2).get("beforeNmi").asInt()); + assertEquals(20, replies.get(2).get("afterNmi").asInt()); + + assertFalse(replies.get(3).get("on").asBoolean()); + assertEquals(0, replies.get(3).get("beforeNmi").asInt()); + } + + @Test + void anOverclockThatIsNotANumberIsAnError() throws Exception { + var replies = session( + "hack overclock", + "hack overclock on", + "hack overclock lots", + "hack overclock 2000", + "quit"); + + for (var i = 0; i < 4; i++) { + assertFalse(replies.get(i).get("ok").asBoolean(), replies.get(i).toString()); + } + + assertTrue(replies.get(1).get("error").asText().contains("scanlines"), + "\"on\" is somebody who has not been asked how many yet"); + assertTrue(replies.get(2).get("error").asText().contains("lots")); + assertTrue(replies.get(3).get("error").asText().contains("0 to 1000")); + } + + /** + * A movie pins the overclock at the moment it starts, for the reason it pins the codes and a + * sharper one: this is the one hack a replay's frames actually depend on. + */ + @Test + void anOverclockCannotChangeWhileAMovieIsRecording() throws Exception { + var replies = session( + "hack overclock 40", + "record start", + "hack unlimited-sprites on", + "hack overclock 90", + "hack overclock off", + "quit"); + + assertTrue(replies.get(2).get("ok").asBoolean(), + "the sprite limit changes only pixels, so a movie does not care"); + + for (var refused : List.of(replies.get(3), replies.get(4))) { + assertFalse(refused.get("ok").asBoolean(), refused.toString()); + assertTrue(refused.get("error").asText().contains("pinned")); + } + } + /** * Which is what the command is for: run to the frame that matters, put the code in, look, take it * out, look again. The cartridge underneath never changed, so the two are of the same moment. diff --git a/mynes-headless/src/test/resources/PROVENANCE b/mynes-headless/src/test/resources/PROVENANCE index aa30527..9a86799 100644 --- a/mynes-headless/src/test/resources/PROVENANCE +++ b/mynes-headless/src/test/resources/PROVENANCE @@ -1,5 +1,5 @@ Two cartridges copied from mynes-core/src/test/resources, where the originals live alongside the -readmes and expected-output logs that came with them, one that has no original here, and one with no +readmes and expected-output logs that came with them, one that has no original here, and two with no original anywhere. They are here rather than reached for across the module boundary because of what these tests do @@ -55,3 +55,25 @@ change it. Only the first two can drift, and a test stops them. 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. + +The fifth is the same arrangement for the same reason, and it is a fifth rather than a variation on +the fourth because the two demonstrate opposite things. Sprite-limit is a picture nothing changes the +timing of; this is timing nothing changes the picture of. What --hack overclock undoes is a main loop +overrunning its frame, which shipped games do only under load, in places a test cannot reliably reach +and only for a handful of frames -- Super Mario Bros. 3 and Gradius are the ones everybody names. So +this cartridge overruns on purpose and does it every time: its lap of work takes 42500 cycles, which +is 1.43 NTSC frames, so on the hardware it finishes one lap every two frames and with 131 extra +scanlines it finishes one a frame. + + overclock/overclock.nes Written here, no licence to worry about. NROM, 24592 bytes: a game + whose logic does not fit in a frame, which is what --hack overclock + gives it room for. It counts frames at $00-$01 and finished laps at + $02-$03, both sixteen bit, so --dump ram is how to read the result; + it also recolours the whole screen once a lap, so video.frameChanges + counts the same thing without a debugger. + OverclockROM.java In this module's test sources, and the assembler, exactly as + SpriteLimitROM is for the cartridge above. OverclockRunTests asserts + the two agree and its failure says the command. + overclock/overclock.s The same program for asm6, on the same terms as sprite-limit.s: + reference, checked through the emulator's own disassembler rather + than by assembling it, and nothing enforces that. diff --git a/mynes-headless/src/test/resources/overclock/overclock.nes b/mynes-headless/src/test/resources/overclock/overclock.nes new file mode 100644 index 0000000000000000000000000000000000000000..4a722ef8dfbae63bf39614b8c9b84ae42b66cfa7 GIT binary patch literal 24592 zcmeIvu}Z^G6b9g%G*~x_gMv6ja8PmZ4a}lAh>I`bBRINTaA_XENdg`65JKzdl+n>C zbaW8}l^Q$r4GI;$I~?wC;6Hr-)!Fq$NA>uB>}fA?fHuUq<6#YG#s!jd|PS=acy?@@Z#?;5`;mYyzv009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0Rja61fIiqHbFsv009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF{C|O8TO&1M literal 0 HcmV?d00001 diff --git a/mynes-headless/src/test/resources/overclock/overclock.s b/mynes-headless/src/test/resources/overclock/overclock.s new file mode 100644 index 0000000..9758ebd --- /dev/null +++ b/mynes-headless/src/test/resources/overclock/overclock.s @@ -0,0 +1,199 @@ +; overclock.nes -- a game whose logic does not fit in a frame. +; +; The cartridge --hack overclock is demonstrated against, in the form a person reads rather than the +; form a program builds. What the hack undoes is a main loop overrunning its frame: the next NMI +; arrives with the last frame's work unfinished, the game skips a turn, and the picture stutters. +; Super Mario Bros. 3 and Gradius do it under load, but only under load, in places a test cannot +; reliably reach and only for a handful of frames at a time. So this does it on purpose and does it +; every time. +; +; The program is a game with one job: count how many times it can get through a fixed pile of work. +; The pile is about 42500 cycles, which is 1.43 NTSC frames -- so on the hardware it finishes one lap +; every *two* frames, because the wait at the end of a lap always ends on an NMI and the loop is +; phase-locked to them. Give it 131 extra scanlines a frame and the frame becomes 44671 cycles, the +; pile fits, and it manages a lap per frame. Give it 66 -- half as many -- and the frame is 37282 +; cycles, which is not enough, and it is back to one every two. +; +; java -jar mynes.jar --headless --rom overclock.nes --frames 300 --dump ram +; java -jar mynes.jar --headless --rom overclock.nes --frames 300 --dump ram \ +; --hack overclock=131 +; +; Two counters in zero page say what happened, both sixteen bit and little endian: +; +; $00-$01 frames, counted by the NMI handler +; $02-$03 laps, counted by the main loop +; +; The screen says the same thing without a debugger. Rendering is never switched on, so the whole +; picture is the backdrop -- and with rendering off the backdrop is read from wherever the VRAM +; address happens to point rather than from $3F00, which is the "background palette hack" real games +; use to flash the screen. So the NMI leaves the address at $3F00 + (laps & 7) and the screen changes +; colour once per finished lap, with no $2007 write to undo. A report's video.frameChanges then +; counts laps for free. +; +; Nothing assembles this, and the .nes beside it was not built from it. OverclockROM, in this +; module's test sources, is the assembler: it emits the same program as bytes, OverclockRunTests +; 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 OverclockROM 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 overclock.s overclock.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, empty -- nothing is ever rendered + .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 + +OUTER = 33 ; laps of the delay loop; 33 comes to 42439 cycles + +frames = $00 ; sixteen bit, counted by the NMI +laps = $02 ; sixteen bit, counted by the main loop +tick = $04 ; 1 when a frame has been drawn since the loop cleared it + +reset: + sei + cld + ldx #$40 + stx $4017 ; no APU frame interrupt + ldx #$FF + txs + inx ; X = 0 from here down + stx $2000 + stx $2001 ; rendering stays off for good + 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 + +; Eight background colours, one per palette cell the NMI can point the VRAM address at. + lda #$3F + sta $2006 + lda #$00 + sta $2006 + ldx #$00 +copypalette: + lda palette,x + sta $2007 + inx + cpx #8 + bne copypalette + +; Both counters and the flag between the two halves of the program. + lda #$00 + sta frames + sta frames+1 + sta laps + sta laps+1 + sta tick + + lda #$80 ; NMI on; rendering is still off + sta $2000 + +; ---------------------------------------------------------------- main, one lap of the game + +; The pile of work is a delay loop because what the work is does not matter -- only that it is the +; same every lap and that it does not fit in a frame. 33 laps of 1283 cycles, plus the branches, is +; 42439; the rest of the lap brings it to about 42500, and an NTSC frame is 29780. +; +; None of the branches below crosses a page, which is load bearing rather than incidental: a taken +; branch that crossed one would cost an extra cycle every time round the inner loop. +main: + ldy #OUTER +outer: + ldx #$00 +inner: + dex + bne inner ; 256 times round, 1279 cycles + dey + bne outer + + inc laps ; one more lap finished + bne waitforframe + inc laps+1 + +; Wait for the next picture. Clearing the flag before waiting is what makes this a wait for the +; *next* NMI rather than an acknowledgement of the last one -- and it is why a lap takes a whole +; number of frames however long the work took. +waitforframe: + lda #$00 + sta tick +waitloop: + lda tick + beq waitloop + jmp main + +; ---------------------------------------------------------------- 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, $16, $2A, $12, $28, $24, $1C, $30 + +; ---------------------------------------------------------------- interrupts + +; Nothing can reach this: the program starts with sei and switches the APU's frame interrupt off, +; and an NROM cartridge has no interrupt of its own. It is here so that an interrupt nobody can +; explain returns instead of running the NMI handler and counting a frame that did not happen. + .pad $C1FF +irq: + rti + + .pad $C200 +nmi: + pha ; X and Y are the main loop's; A is not + inc frames ; one more frame + bne setframeflag + inc frames+1 +setframeflag: + lda #$01 + sta tick ; let the main loop go on + +; Leave the VRAM address inside palette RAM, at the cell this lap's number names. With rendering off +; that cell *is* the backdrop, so the whole screen becomes that colour and stays it until the next +; lap -- and nothing has to be written back. + bit $2002 ; and put the $2006 latch back to first + lda #$3F + sta $2006 + lda laps + and #$07 + sta $2006 + + pla + rti ; which puts the flags back too + + .pad $FFFA + .dw nmi + .dw reset + .dw irq + +; ---------------------------------------------------------------- character bank + +; Empty, and it stays that way: rendering is never switched on, so there is no tile to put in it. It +; is here because a cartridge with no character bank at all is a cartridge with character RAM, which +; is a different thing to have to explain. + .base $0000 + .dsb 8192, $00