diff --git a/.gitignore b/.gitignore index 95e0749..fc8987b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,7 @@ TODO.txt testdata/ # Editor -.idea \ No newline at end of file +.idea + +# Finder, which leaves one of these in every directory it is asked to look at +.DS_Store \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index ee9dd96..be10d78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # Working on MyNES -A NES emulator in Java 25, built with Maven. Three modules -- `mynes-core`, `mynes-headless`, -`mynes-desktop` -- and `mvn -B test` at the root still runs everything. +A NES emulator in Java 25, built with Maven. Four modules -- `mynes-core`, `mynes-patch`, +`mynes-headless`, `mynes-desktop` -- and `mvn -B test` at the root still runs everything. ## Seeing what the emulator does @@ -156,6 +156,32 @@ exit 2. so `run.state.startedFromPowerOn` in the report is part of what to check before diffing two of them. `--sram-in`/`--sram-out` do the same for battery RAM, in the `.sav` format other emulators read. +### Running a romhack + +`--patch FILE` applies an IPS patch to the ROM before anything reads it as a cartridge. Repeatable, +applied in the order given. + +```sh +java -jar $JAR --headless --rom ROM.nes --patch hack.ips --frames 120 --screenshot last +``` + +**Nothing is written back.** The patch happens to the copy in memory, so the `.nes` on disk is +untouched and there is no patched file to tidy up afterwards. Two consequences worth knowing. The +patch is applied *before* `Cart.load`, so it may rewrite the iNES header and change the mapper, the +bank count or the size of the cartridge. And `cart.sha256` in the report is the digest of the +**patched** image, since that is what ran -- so a patched run and an unpatched one are two different +cartridges as far as the report and a save state are concerned, which is the answer that keeps a +hack's save states out of the original. + +`cart.patches` lists each one with the number of records it held. **Zero records is the thing to +look for**: a patch cut against a different dump of the same game applies without complaining and +changes nothing anybody can see. So does one cut against a headerless dump, which will write +everything sixteen bytes early instead -- offsets count from the front of the file, header included. + +`RomHackTests` is the worked example: a public-domain hello-world cartridge, a checked-in `.ips` that +rewrites the string it draws, and the two pictures compared. `src/test/resources/PROVENANCE` says +where the cartridge came from and why that one. + ## What gets released `mvn package` also writes `mynes-desktop/target/mynes-.zip` -- the jar, a launcher for each @@ -170,8 +196,8 @@ build would notice one going missing. And `THIRD-PARTY.md` names the two librari carries, which is the file to write in if a third ever earns its place. Releasing is a tag and nothing else. `.github/workflows/release.yml` refuses one whose name disagrees -with the pom, so the version moves first and `git tag v` follows it. There are four poms to -move it in now, which is a job for the tool rather than for four edits: +with the pom, so the version moves first and `git tag v` follows it. There are five poms to +move it in now, which is a job for the tool rather than for five edits: ```sh mvn -B versions:set -DnewVersion=0.3.0 -DprocessAllModules -DgenerateBackupPoms=false @@ -202,7 +228,7 @@ The code has a strong voice. Match it rather than the language's defaults. ## Layout -Three Maven modules, and the arrows between them only point one way. +Four Maven modules, and the arrows between them only point one way. ``` mynes-core/ depends on nothing @@ -213,13 +239,22 @@ mynes-core/ depends on nothing mynes/video/ colour indices to pixels: the overscan crop and the frame renderer mynes/palette/ the measured RGB tables, and the loader that reads them out of /palettes -mynes-headless/ depends on core +mynes-patch/ depends on nothing either, core included + mynes/patch/ IPS patches, applied to a byte[] before anyone reads it as a cartridge + +mynes-headless/ depends on core and patch mynes/headless/ the command line mode -mynes-desktop/ depends on core and headless; FlatLaf and MigLayout live here +mynes-desktop/ depends on core, patch and headless; FlatLaf and MigLayout live here mynes/ui/ the Swing window, Main, the key bindings, the CHR viewer, the debugger ``` +`mynes-patch` is beside the console rather than inside it because IPS says nothing about what it +patches -- a ROM, a save file, a disk image -- and a patcher that could see a `Cart` would sooner or +later be handed one. It is the front ends that join the two together, both by reading the file, +patching the bytes and handing the result to `Cart.load`. A patch is entitled to rewrite the iNES +header, so it has to be applied *before* the cartridge is parsed rather than after. + The core knows nothing about the front end, and now it *cannot*: `Cart.load` takes a `byte[]`, `NES` has no UI dependency, `nes.tick()` is the only clock, and the PPU emits colour *indices* -- never RGB, because which RGB is a question about televisions. What used to be a rule about imports is a @@ -230,6 +265,7 @@ Which makes one check worth running when the dependencies change: ```sh mvn dependency:tree -pl mynes-core # nothing but the two test artifacts +mvn dependency:tree -pl mynes-patch # nothing but JUnit mvn dependency:tree -pl mynes-headless # no FlatLaf, no MigLayout ``` diff --git a/README.md b/README.md index a4b0cbb..05e0bc3 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,10 @@ mvn -B package -DskipTests java -jar mynes-desktop/target/mynes.jar ``` -One jar, whichever way: it is three Maven modules -- `mynes-core` for the console, `mynes-headless` -for the command line, `mynes-desktop` for the window -- flattened into one file with its -dependencies. The core depends on nothing at all, which is the point of it being separate. +One jar, whichever way: it is four Maven modules -- `mynes-core` for the console, `mynes-patch` for +IPS patches, `mynes-headless` for the command line, `mynes-desktop` for the window -- flattened into +one file with its dependencies. The core depends on nothing at all, which is the point of it being +separate, and neither does the patcher. The same `mvn package` also writes the release zip into `mynes-desktop/target/`, so what the releases page carries is never anything a build here has not already made. @@ -156,6 +157,12 @@ are also toggles to hide the background or the sprite layer without the game not All of it is in headless mode too — `break`, `watch`, `step` and `disasm` are commands in the interactive session, so the same questions can be asked from a script. +**IPS patches**, from **File > Open with Patch...**, which is how a romhack is handed out. The patch +is applied to the bytes on their way into the emulator, so the ROM on disk is left exactly as it was +and there is no patched copy of it to keep anywhere. A patched game keeps its own save states and +battery file, named after the patch rather than the ROM, so an afternoon with a hack cannot write +over fifty hours of the original. `--patch` does the same thing from the command line. + **Save states and battery saves**, and a **headless mode** for running with no window at all. Both have a section of their own below. @@ -265,6 +272,10 @@ seconds to start up, the jar about a third of one. - **`--save-state` and `--load-state`** cut the wait when the same two hundred frames of title screen are in the way of every run. `--sram-in` and `--sram-out` do the same for battery RAM, in the `.sav` format other emulators read. +- **`--patch`** applies an IPS patch to the ROM before it is read as a cartridge, so a romhack can be + run without a patched file existing anywhere. The report's `cart.patches` says how many records + each one held, and `cart.sha256` is the digest of the patched image rather than the file on disk — + a patch that turns out to hold no records is one cut against a different dump of the game. - **`--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-desktop/pom.xml b/mynes-desktop/pom.xml index 35ababa..c029a8e 100644 --- a/mynes-desktop/pom.xml +++ b/mynes-desktop/pom.xml @@ -40,6 +40,13 @@ ${project.version} + + + com.github.dimiro1 + mynes-patch + ${project.version} + + com.formdev flatlaf @@ -72,7 +79,7 @@ Run from the root and without naming a module, which is why the parent declares this same plugin and this same execution id skipped: the goal is invoked on every - project in the reactor, and three of the four have no main class to give it. + project in the reactor, and four of the five have no main class to give it. Both forks of the same goal, and deliberately not exec:java for the second, which would be the obvious choice. exec:java cannot read this plugin's configuration at @@ -130,8 +137,8 @@ + + com.github.dimiro1 + mynes-patch + ${project.version} + + 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 e851d3e..b2b388b 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 @@ -2,6 +2,8 @@ import com.github.dimiro1.mynes.Cart; import com.github.dimiro1.mynes.NES; +import com.github.dimiro1.mynes.patch.IPSPatch; +import com.github.dimiro1.mynes.patch.InvalidPatchException; import com.github.dimiro1.mynes.state.BatteryRAM; import com.github.dimiro1.mynes.state.SaveStateException; import com.github.dimiro1.mynes.palette.Palettes; @@ -91,10 +93,22 @@ private static int runCartridge(final Options options) throws IOException { return EXIT_ROM; } + final Patched patched; + + try { + patched = patch(options, image); + } catch (IOException e) { + System.err.println("a patch could not be read: " + e.getMessage()); + return EXIT_ROM; + } catch (InvalidPatchException e) { + System.err.println(e.getMessage()); + return EXIT_ROM; + } + final Cart cart; try { - cart = Cart.load(image, options.rom().toString()); + cart = Cart.load(patched.image(), options.rom().toString()); } catch (RuntimeException e) { // Everything Cart.load throws is unchecked, and a file that is not a cartridge can // fail in several ways -- a bad magic number, a mapper nobody has written, a truncated @@ -103,6 +117,12 @@ private static int runCartridge(final Options options) throws IOException { return EXIT_ROM; } + if (!patched.applied().isEmpty()) { + // The digest is of the patched image, so it names what ran rather than the file on disk. + // Worth saying out loud, since it is the number somebody comparing two runs reads first. + logger.log(Level.INFO, "running a patched image, sha256 " + cart.sha256()); + } + var region = options.regionFor(cart); var palette = options.paletteFor(region); @@ -187,6 +207,7 @@ private static int runCartridge(final Options options) throws IOException { outcome.stoppedBecause(), wallClockMillis, startedAt, + patched.applied(), outcome.screenshots(), dumps, expectations, @@ -209,6 +230,38 @@ private record Outcome( long frames, Report.StoppedBecause stoppedBecause, List screenshots) { } + /** + * A ROM image with whatever {@code --patch} asked for already in it, and what to say about how + * it got that way. + */ + private record Patched(byte[] image, List applied) { + } + + /** + * Applies the patches, in the order they were named. + *

+ * Before the cartridge is parsed rather than after, because a patch may change the header: one + * that adds a bank moves the mapper number, the PRG count and everything downstream of them. The + * file on disk is never opened for writing -- the whole point of patching here is that the ROM + * somebody owns stays the ROM they own, and a run leaves nothing behind to clean up. + */ + private static Patched patch(final Options options, final byte[] image) throws IOException { + var patched = image; + var applied = new ArrayList(); + + for (var path : options.patches()) { + var patch = IPSPatch.read(Files.readAllBytes(path), path.toString()); + + patched = patch.applyTo(patched); + applied.add(new Report.Patch(path, patch.records(), patch.bytes())); + + logger.log(Level.INFO, "applied " + patch.records() + " records from " + + path.getFileName() + ", leaving " + patched.length + " bytes"); + } + + return new Patched(patched, List.copyOf(applied)); + } + /** * Plays the schedule. */ 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 b32092f..04a46c4 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 @@ -20,6 +20,8 @@ * What the command line asked for. * * @param rom the cartridge to run. + * @param patches IPS patches to apply to it, in the order they were named, before it is + * read as a cartridge at all. * @param frames how many frames to run, when nothing stops it sooner. * @param timeout how much real time to allow. * @param resetAt frames to press the console's Reset button at the start of. @@ -54,6 +56,7 @@ */ public record Options( Path rom, + List patches, long frames, Duration timeout, List resetAt, @@ -138,6 +141,12 @@ The second is worth building once (mvn -B package -DskipTests) for anything run Cartridge and length --rom FILE The .nes file to run. Required. + --patch FILE Apply an IPS patch to it before running it, which is how a + romhack is handed out. Repeatable, and applied in the order + given. Nothing is written back: the .nes file on disk is left + exactly as it was, and only the copy in memory is patched. The + cart.sha256 in the report is the digest of the patched image, + since that is what actually ran. --frames N Stop after N completed frames. Default 600, which is ten seconds of emulated time and about a second of real time. --timeout SECONDS Give up after this much real time and write what there is so @@ -256,6 +265,7 @@ public static String usage() { */ public static Options parse(final String[] args) { Path rom = null; + var patches = new ArrayList(); var frames = DEFAULT_FRAMES; var timeout = DEFAULT_TIMEOUT; var resetAt = new ArrayList(); @@ -293,6 +303,7 @@ public static Options parse(final String[] args) { case "--help", "-h" -> help = true; case "--list-palettes" -> listPalettes = true; case "--rom" -> rom = Path.of(value(args, ++i, flag)); + case "--patch" -> patches.add(Path.of(value(args, ++i, flag))); case "--frames" -> frames = positive(value(args, ++i, flag), flag); case "--timeout" -> timeout = Duration.ofSeconds( positive(value(args, ++i, flag), flag)); @@ -344,6 +355,7 @@ public static Options parse(final String[] args) { return new Options( rom, + List.copyOf(patches), frames, timeout, List.copyOf(resetAt), 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 24ad138..c54300c 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 @@ -54,6 +54,14 @@ public enum StoppedBecause { public record Dump(String what, Path path, int bytes) { } + /** + * A patch that was applied, and what it turned out to contain. The counts are here because a + * patch cut against a different dump of the same game applies without complaint and does nothing + * useful, and "0 records" is the only sign of it the report can offer. + */ + public record Patch(Path path, int records, int bytes) { + } + public record Expectation(String name, boolean passed, String detail) { } @@ -64,6 +72,7 @@ public record Expectation(String name, boolean passed, String detail) { * @param stoppedBecause why it stopped. * @param wallClockMillis how long that took in real time. * @param startedAt when it started. + * @param patches the patches applied to the ROM image before it was read as a cartridge. * @param screenshots the frames photographed. * @param dumps the memories written out. * @param expectations what was asked of the run, and whether it held. @@ -74,6 +83,7 @@ public record Outcome( StoppedBecause stoppedBecause, long wallClockMillis, Instant startedAt, + List patches, List screenshots, List dumps, List expectations, @@ -132,7 +142,20 @@ public static String write( var cartridge = report.putObject("cart"); cartridge.put("file", cart.filename()); cartridge.put("name", Path.of(cart.filename()).getFileName().toString()); + + // Of the image the machine actually ran, which is the patched one when anything below this + // is non-empty. It names what ran rather than what is on disk, which is what a digest in + // this document is for. cartridge.put("sha256", cart.sha256()); + + var patches = cartridge.putArray("patches"); + for (var patch : outcome.patches()) { + var node = patches.addObject(); + node.put("path", patch.path().toString()); + node.put("records", patch.records()); + node.put("bytes", patch.bytes()); + } + cartridge.put("mapper", cart.mapperNumber()); cartridge.put("prgROMBytes", cart.prgROM().length); cartridge.put("chrROMBytes", cart.chrROM().length); 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 99fe26e..22fd1bb 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 @@ -6,7 +6,9 @@ import org.junit.jupiter.api.io.TempDir; import javax.imageio.ImageIO; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; @@ -31,9 +33,64 @@ class HeadlessRunTests { private static final String ROM = "src/test/resources/nestest/nestest.nes"; private static final ObjectMapper MAPPER = new ObjectMapper(); + /** + * Where nestest's PRG-ROM and CHR-ROM start in the file: after the sixteen byte header, and + * after the one 16KB bank of program that follows it. Patch offsets count from the front of the + * file, header included, which is what these two are here to say out loud. + */ + private static final int PRG_AT = 16; + private static final int CHR_AT = PRG_AT + 0x4000; + @TempDir private Path out; + /** + * An .ips file, assembled here because a patch for a vendored ROM is not something to vendor. + * Every number in the format is big endian. + */ + private final class Patch { + private final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + private Patch() { + bytes.writeBytes("PATCH".getBytes(StandardCharsets.US_ASCII)); + } + + private Patch record(final int offset, final int... data) { + offset(offset); + bytes.write(data.length >> 8); + bytes.write(data.length); + + for (var value : data) { + bytes.write(value); + } + + return this; + } + + private Patch run(final int offset, final int count, final int value) { + offset(offset); + bytes.write(0); + bytes.write(0); + bytes.write(count >> 8); + bytes.write(count); + bytes.write(value); + + return this; + } + + private Path write(final String name) throws IOException { + bytes.writeBytes("EOF".getBytes(StandardCharsets.US_ASCII)); + + return Files.write(out.resolve(name), bytes.toByteArray()); + } + + private void offset(final int offset) { + bytes.write(offset >> 16); + bytes.write(offset >> 8); + bytes.write(offset); + } + } + private JsonNode reportAt(final Path path) throws IOException { return MAPPER.readTree(Files.readString(path)); } @@ -178,6 +235,106 @@ void anExpectationThatHoldsDoesNot() throws Exception { assertTrue(report().get("expectations").get(0).get("passed").asBoolean()); } + /** + * The bytes a patch writes are the bytes the machine runs. + *

+ * The record lands on the front of nestest's CHR-ROM, which the PPU sees at $0000 and + * {@code --dump chr} hands straight back -- so this follows a patch all the way through the + * emulator rather than stopping at the report's word for it. + */ + @Test + void aPatchReachesTheMachineThatRuns() throws Exception { + var patch = new Patch().record(CHR_AT, 0xAB, 0xCD).write("one.ips"); + + assertEquals(Headless.EXIT_OK, run("--patch", patch.toString(), "--dump", "chr")); + + var chr = Files.readAllBytes(out.resolve("chr.bin")); + + assertEquals((byte) 0xAB, chr[0]); + assertEquals((byte) 0xCD, chr[1]); + } + + @Test + void patchesAreAppliedInTheOrderTheyWereNamed() throws Exception { + var first = new Patch().record(CHR_AT, 0x11).write("first.ips"); + var second = new Patch().record(CHR_AT, 0x22).write("second.ips"); + + run("--patch", first.toString(), "--patch", second.toString(), "--dump", "chr"); + assertEquals((byte) 0x22, Files.readAllBytes(out.resolve("chr.bin"))[0]); + + run("--patch", second.toString(), "--patch", first.toString(), "--dump", "chr"); + assertEquals((byte) 0x11, Files.readAllBytes(out.resolve("chr.bin"))[0]); + } + + /** + * The point of patching at load: the ROM somebody owns is still the ROM they own afterwards. + */ + @Test + void theRomOnDiskIsLeftAlone() throws Exception { + var before = Files.readAllBytes(Path.of(ROM)); + + run(); + var unpatched = report().at("/cart/sha256").asText(); + + run("--patch", new Patch().record(CHR_AT, 0xAB).write("one.ips").toString()); + + assertArrayEquals(before, Files.readAllBytes(Path.of(ROM))); + assertNotEquals(unpatched, report().at("/cart/sha256").asText(), + "the digest is of the image that ran, which is the patched one"); + } + + @Test + void theReportNamesEveryPatchAndWhatItHeld() throws Exception { + var patch = new Patch().record(CHR_AT, 0xAB, 0xCD).run(CHR_AT + 16, 32, 0xFF) + .write("one.ips"); + + run("--patch", patch.toString()); + + var patches = report().at("/cart/patches"); + + assertEquals(1, patches.size()); + assertEquals(patch.toString(), patches.get(0).get("path").asText()); + assertEquals(2, patches.get(0).get("records").asInt()); + assertEquals(34, patches.get(0).get("bytes").asInt()); + } + + @Test + void aRunWithoutPatchesSaysSoRatherThanLeavingTheKeyOut() throws Exception { + run(); + + assertTrue(report().at("/cart/patches").isArray()); + assertEquals(0, report().at("/cart/patches").size()); + } + + /** + * Patching happens before the cartridge is read, which is the whole reason it happens where it + * does: this one rewrites the iNES header into a 32KB single-bank cartridge with no CHR-ROM at + * all, and the emulator builds that cartridge rather than nestest. + */ + @Test + void aPatchThatRewritesTheHeaderChangesTheCartridge() throws Exception { + var patch = new Patch() + .record(4, 0x02) + .record(5, 0x00) + .run(PRG_AT + 0x4000, 0x4000, 0xEA) + .write("bigger.ips"); + + assertEquals(Headless.EXIT_OK, run("--patch", patch.toString())); + + assertEquals(32768, report().at("/cart/prgROMBytes").asInt()); + assertEquals(0, report().at("/cart/chrROMBytes").asInt()); + } + + @Test + void aFileThatIsNotAPatchExitsFive() { + assertEquals(Headless.EXIT_ROM, run("--patch", "README.md")); + } + + @Test + void aPatchThatIsNotThereExitsFiveToo() { + assertEquals(Headless.EXIT_ROM, run("--patch", out.resolve("nowhere.ips").toString())); + } + @Test void aFileThatIsNotARomExitsFive() { assertEquals(Headless.EXIT_ROM, Headless.run(new String[]{"--rom", "README.md"})); 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 91cf7f3..42828ce 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 @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import java.nio.file.Path; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -33,6 +34,20 @@ void aRomIsRequired() { assertTrue(refused("--frames", "10").getMessage().contains("--rom")); } + @Test + void patchesAreKeptInTheOrderTheyWereNamed() { + var options = parse("--rom", "x.nes", "--patch", "first.ips", "--patch", "second.ips"); + + assertEquals( + List.of(Path.of("first.ips"), Path.of("second.ips")), + options.patches()); + } + + @Test + void aRunWithNoPatchesHasNone() { + assertTrue(parse("--rom", "x.nes").patches().isEmpty()); + } + @Test void anUnknownFlagIsRejectedByName() { assertTrue(refused("--rom", "x.nes", "--frobnicate").getMessage().contains("--frobnicate")); diff --git a/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/RomHackTests.java b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/RomHackTests.java new file mode 100644 index 0000000..14b00ab --- /dev/null +++ b/mynes-headless/src/test/java/com/github/dimiro1/mynes/headless/RomHackTests.java @@ -0,0 +1,183 @@ +package com.github.dimiro1.mynes.headless; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.dimiro1.mynes.patch.IPSPatch; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +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.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * An actual romhack, applied to an actual cartridge, checked at the screen. + *

+ * The cartridge draws "Hello World!" and two lines of Russian, and it is here because it is public + * domain and because nobody has touched it since 2015: a fixture whose upstream still changes would + * make this test a report on somebody else's repository. The patch beside it is a real {@code .ips} + * file rather than one built at run time, for the same reason -- what is being tested is the file + * format a romhack arrives in, so the test is handed one. + *

+ * The hack is the oldest kind there is, a text edit: the twelve ASCII bytes of {@code Hello World!} + * in the PRG-ROM become {@code MyNES Patch!}. The two are the same length on purpose, so nothing + * after them moves and the cartridge is the same size, the same mapper and the same everything else + * -- which is what makes the changed picture attributable to those twelve bytes. That picture was + * checked by eye when this was written: the first line reads MyNES Patch!, and the Russian below it + * is untouched. + */ +class RomHackTests { + private static final String ROM = "src/test/resources/hello-world/hello-world.nes"; + private static final String PATCH = "src/test/resources/hello-world/mynes.ips"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final String ORIGINAL = "Hello World!"; + private static final String HACKED = "MyNES Patch!"; + + /** + * Long enough for the text to be on the screen, which is all this needs of the cartridge. + */ + private static final String FRAMES = "60"; + + @TempDir + private Path out; + + /** + * The fixture is a binary, so nothing about it can be read in a diff. This is where it says what + * it is: one record, twelve bytes, no truncation -- and a patch that arrived corrupted fails + * here rather than as five puzzling failures below it. + */ + @Test + void theFixtureIsThePatchItClaimsToBe() throws Exception { + var patch = IPSPatch.read(Files.readAllBytes(Path.of(PATCH)), PATCH); + + assertEquals(1, patch.records()); + assertEquals(HACKED.length(), patch.bytes()); + assertEquals(IPSPatch.NO_TRUNCATION, patch.truncateTo()); + } + + @Test + void theHackReplacesTheStringItNames() throws Exception { + var image = Files.readAllBytes(Path.of(ROM)); + var patched = IPSPatch.read(Files.readAllBytes(Path.of(PATCH)), PATCH).applyTo(image); + + assertTrue(text(image).contains(ORIGINAL), "the cartridge still says what it used to"); + assertEquals(image.length, patched.length, "a same-length edit does not resize the file"); + assertTrue(text(patched).contains(HACKED)); + assertFalse(text(patched).contains(ORIGINAL)); + } + + /** + * The twelve bytes reach the television, which is the only claim worth making about a patcher. + */ + @Test + void theHackShowsOnTheScreen() throws Exception { + var before = run("plain"); + var after = run("hacked", "--patch", PATCH); + + assertNotEquals( + hashIn(before), hashIn(after), + "the patched cartridge draws a different picture"); + assertFalse( + Arrays.equals(shotIn(before), shotIn(after)), + "and the two PNGs are not the same file either"); + } + + /** + * The cartridge on disk is not the one that ran, and is exactly as it was. + */ + @Test + void theRomItselfIsNeverTouched() throws Exception { + var before = Files.readAllBytes(Path.of(ROM)); + + var plain = run("plain"); + var hacked = run("hacked", "--patch", PATCH); + + assertArrayEquals(before, Files.readAllBytes(Path.of(ROM))); + assertNotEquals( + report(plain).at("/cart/sha256").asText(), + report(hacked).at("/cart/sha256").asText(), + "the digest names the image that ran, so a hack has its own"); + } + + /** + * A patched run is as deterministic as an unpatched one, which is what lets a hack be regression + * tested the same way everything else here is. + */ + @Test + void twoRunsOfTheSameHackAgree() throws Exception { + var first = run("first", "--patch", PATCH); + var second = run("second", "--patch", PATCH); + + assertEquals(hashIn(first), hashIn(second)); + assertArrayEquals(shotIn(first), shotIn(second)); + } + + @Test + void theReportSaysWhichHackItWas() throws Exception { + var patches = report(run("hacked", "--patch", PATCH)).at("/cart/patches"); + + assertEquals(1, patches.size()); + assertEquals(PATCH, patches.get(0).get("path").asText()); + assertEquals(1, patches.get(0).get("records").asInt()); + assertEquals(HACKED.length(), patches.get(0).get("bytes").asInt()); + } + + // ================================================================================== internals + + /** + * Runs the cartridge into a directory of its own, so that two runs can be compared file by file + * rather than one after the other. + * + * @return where the artifacts went. + */ + 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] = "--screenshot"; + args[8] = "last"; + + System.arraycopy(extra, 0, args, 9, extra.length); + + assertEquals(Headless.EXIT_OK, Headless.run(args)); + + return into; + } + + /** + * The whole file as characters, for asking whether a string is in it. ISO 8859-1 because it is + * the one encoding that maps every byte to exactly one character, so nothing in a ROM full of + * code and tiles can throw the search off. + */ + private static String text(final byte[] image) { + return new String(image, StandardCharsets.ISO_8859_1); + } + + private static JsonNode report(final Path into) throws IOException { + return MAPPER.readTree(Files.readString(into.resolve("report.json"))); + } + + private static String hashIn(final Path into) throws IOException { + return report(into).at("/video/finalFrame/hash").asText(); + } + + private static byte[] shotIn(final Path into) throws IOException { + return Files.readAllBytes(into.resolve("frame-0000" + FRAMES + ".png")); + } +} diff --git a/mynes-headless/src/test/resources/PROVENANCE b/mynes-headless/src/test/resources/PROVENANCE index a3f2127..850766f 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. +Two cartridges copied from mynes-core/src/test/resources, where the originals live alongside the +readmes and expected-output logs that came with them, and one that has no original here. They are here rather than reached for across the module boundary because of what these tests do with them. Everything in the core reads a ROM off the classpath; the headless mode takes a --rom @@ -13,3 +13,19 @@ cheaper than that, and honest about being a copy. nestest/nestest.nes Kevtris, 2005. Draws its menu unprompted, which most games do not. mmc3-test-2/1-clocking.nes blargg. Mapper 4, so the report has a mapper with banking in it. + +The third is the one RomHackTests patches, and it was chosen for what a fixture for that test has to +be. It may be modified and the modification handed round, which is not true of a test ROM +redistributed on its author's terms; it holds the string it draws as plain ASCII, so the hack is a +text edit somebody can read; and nobody has touched it in ten years, so this repository is not +quietly tracking somebody else's. + + hello-world/hello-world.nes Andrey Kun, 2015, public domain under the Unlicense, which is beside + it as UNLICENSE. Built as "out/Example 1.nes" in + github.com/andrey-kun/FaNES-Example-1-Hello-World, at commit 34f50b4, + and renamed here for what it is. NROM, 40976 bytes, + sha256 d44032253b1abaf3e5b48781e8f69a9f9efb5633c91b1734fa23653bb5c7d1f0. + hello-world/mynes.ips The hack itself, written here: one record, rewriting the twelve bytes + of "Hello World!" at 0x398 to "MyNES Patch!". A real patch file rather + than one the test builds, since the file format is half of what is + being tested. diff --git a/mynes-headless/src/test/resources/hello-world/UNLICENSE b/mynes-headless/src/test/resources/hello-world/UNLICENSE new file mode 100644 index 0000000..cf1ab25 --- /dev/null +++ b/mynes-headless/src/test/resources/hello-world/UNLICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/mynes-headless/src/test/resources/hello-world/hello-world.nes b/mynes-headless/src/test/resources/hello-world/hello-world.nes new file mode 100644 index 0000000..d5f6fb0 Binary files /dev/null and b/mynes-headless/src/test/resources/hello-world/hello-world.nes differ diff --git a/mynes-headless/src/test/resources/hello-world/mynes.ips b/mynes-headless/src/test/resources/hello-world/mynes.ips new file mode 100644 index 0000000..39d0e07 Binary files /dev/null and b/mynes-headless/src/test/resources/hello-world/mynes.ips differ diff --git a/mynes-patch/pom.xml b/mynes-patch/pom.xml new file mode 100644 index 0000000..1fe5025 --- /dev/null +++ b/mynes-patch/pom.xml @@ -0,0 +1,18 @@ + + + 4.0.0 + + + com.github.dimiro1 + mynes + 0.2.0 + + + mynes-patch + jar + + mynes-patch + IPS patches, applied to a file's bytes before anything else reads them. + diff --git a/mynes-patch/src/main/java/com/github/dimiro1/mynes/patch/IPSPatch.java b/mynes-patch/src/main/java/com/github/dimiro1/mynes/patch/IPSPatch.java new file mode 100644 index 0000000..c2a08b5 --- /dev/null +++ b/mynes-patch/src/main/java/com/github/dimiro1/mynes/patch/IPSPatch.java @@ -0,0 +1,236 @@ +package com.github.dimiro1.mynes.patch; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * An IPS patch: read once, applied to as many images as you like. + *

+ * The format is from 1990 and is about as simple as a binary format gets. Five bytes of + * {@code PATCH}, then records until three bytes of {@code EOF}. A record is a three byte offset and + * a two byte length, both big endian, followed by that many bytes to write there; a length of zero + * instead means a run, and is followed by a two byte count and the one byte to repeat. Offsets are + * counted from the front of the file, header and all. + *

+ * Three things about it are worth knowing before reading the code. + *

+ * A record may write past the end of the image, and the file grows to fit. That is + * how a hack that adds data ships, so it is not an error, and it is why {@link #applyTo} works out + * the size before it copies anything rather than writing into an array the size of the original. + *

+ * {@code EOF} is also a legal offset -- 0x454F46, four and a half megabytes in -- + * and there is nothing in the format to tell a record there from the end of the patch. The end wins, + * because that is the reading every patcher ever written has taken, and a patch relying on the other + * one would already be broken everywhere else. + *

+ * Three bytes after the {@code EOF} are the truncation extension: the length to cut + * the patched file down to. It is not in the original description of the format, but it is in Lunar + * IPS and in everything written since. Growing a file to reach that length is not what it means, so a + * truncation longer than the image is ignored rather than obeyed. + * + * @see Zerosoft: the IPS file format + * @see SnesLab: IPS file format + */ +public final class IPSPatch { + private static final byte[] MAGIC = {'P', 'A', 'T', 'C', 'H'}; + private static final byte[] END = {'E', 'O', 'F'}; + + private static final int OFFSET_BYTES = 3; + private static final int LENGTH_BYTES = 2; + + /** + * What {@link #truncateTo()} answers for a patch that does not ask for a truncation, which is + * nearly all of them. + */ + public static final int NO_TRUNCATION = -1; + + /** + * One record, still in the shape the file wrote it. + *

+ * A run keeps its count and its one byte rather than being expanded when it is read: eight bytes + * of patch can ask for sixty-four kilobytes of run, and a patch that is nothing but runs would + * otherwise cost eight thousand times its own size to hold on to. + * + * @param data the bytes to write, or null for a run of {@code fill}. + */ + private record Change(int offset, int length, byte[] data, byte fill) { + } + + private final String filename; + private final List changes; + private final int bytes; + private final int truncateTo; + + private IPSPatch( + final String filename, final List changes, final int truncateTo) { + var written = 0; + + for (var change : changes) { + written += change.length(); + } + + this.filename = filename; + this.changes = changes; + this.bytes = written; + this.truncateTo = truncateTo; + } + + /** + * Reads a patch, checking it all the way through. + *

+ * Everything is parsed here rather than while it is being applied, so that a file which is not a + * patch at all is found out before a single byte of anybody's ROM has been touched. + * + * @param bytes the {@code .ips} file. + * @param filename what to call it in an error message. + * @throws InvalidPatchException if it is not a patch, or is one that has been cut short. + */ + public static IPSPatch read(final byte[] bytes, final String filename) { + if (bytes.length < MAGIC.length + END.length + || !Arrays.equals(bytes, 0, MAGIC.length, MAGIC, 0, MAGIC.length)) { + throw new InvalidPatchException(filename, "it does not begin with PATCH"); + } + + var changes = new ArrayList(); + var at = MAGIC.length; + + while (true) { + if (at + END.length > bytes.length) { + throw new InvalidPatchException(filename, "it stops before its EOF marker"); + } + + if (Arrays.equals(bytes, at, at + END.length, END, 0, END.length)) { + at += END.length; + break; + } + + if (at + OFFSET_BYTES + LENGTH_BYTES > bytes.length) { + throw new InvalidPatchException( + filename, "the record at byte " + at + " runs off the end of it"); + } + + var offset = read(bytes, at, OFFSET_BYTES); + var length = read(bytes, at + OFFSET_BYTES, LENGTH_BYTES); + at += OFFSET_BYTES + LENGTH_BYTES; + + if (length == 0) { + if (at + LENGTH_BYTES + 1 > bytes.length) { + throw new InvalidPatchException( + filename, "the run at byte " + at + " runs off the end of it"); + } + + var run = read(bytes, at, LENGTH_BYTES); + var fill = bytes[at + LENGTH_BYTES]; + at += LENGTH_BYTES + 1; + + // A run of nothing is dropped rather than kept, because a kept one would be a record + // that writes no bytes and still grows the image to reach its offset. + if (run > 0) { + changes.add(new Change(offset, run, null, fill)); + } + } else { + if (at + length > bytes.length) { + throw new InvalidPatchException( + filename, "the record at byte " + at + " runs off the end of it"); + } + + changes.add( + new Change(offset, length, Arrays.copyOfRange(bytes, at, at + length), + (byte) 0)); + at += length; + } + } + + var trailing = bytes.length - at; + + if (trailing != 0 && trailing != OFFSET_BYTES) { + throw new InvalidPatchException( + filename, trailing + " bytes follow its EOF marker, which is neither nothing" + + " nor the three of a truncation"); + } + + return new IPSPatch( + filename, + List.copyOf(changes), + trailing == OFFSET_BYTES ? read(bytes, at, OFFSET_BYTES) : NO_TRUNCATION); + } + + /** + * Applies the patch, leaving what it was given alone. + *

+ * A copy rather than a rewrite in place because the caller's array is the file it read off disk, + * and the whole point of patching at load is that the file is not modified. The result is as long + * as the furthest record reaches, or as long as a truncation asked for, whichever the patch says. + * + * @param image the file as it came off disk. + * @return a new array. The same patch applied to the same image always gives the same bytes. + */ + public byte[] applyTo(final byte[] image) { + var size = image.length; + + for (var change : changes) { + size = Math.max(size, change.offset() + change.length()); + } + + var patched = Arrays.copyOf(image, size); + + // In the order the file wrote them: two records are allowed to overlap, and the format says + // nothing about which wins beyond the fact that the second one is applied second. + for (var change : changes) { + if (change.data() == null) { + Arrays.fill(patched, change.offset(), change.offset() + change.length(), + change.fill()); + } else { + System.arraycopy(change.data(), 0, patched, change.offset(), change.length()); + } + } + + return truncateTo == NO_TRUNCATION || truncateTo >= patched.length + ? patched + : Arrays.copyOf(patched, truncateTo); + } + + /** + * What this patch is called, for the front end that has to report on it. + */ + public String filename() { + return filename; + } + + /** + * How many records it holds. Zero is a legal patch and a useless one, and telling somebody that + * is more use than silently changing nothing. + */ + public int records() { + return changes.size(); + } + + /** + * How many bytes those records write, runs counted at their full length. + */ + public int bytes() { + return bytes; + } + + /** + * What the patched file is to be cut down to, or {@link #NO_TRUNCATION}. + */ + public int truncateTo() { + return truncateTo; + } + + /** + * A big endian unsigned integer of {@code count} bytes, which is the only way this format spells + * a number. + */ + private static int read(final byte[] bytes, final int at, final int count) { + var value = 0; + + for (var i = 0; i < count; i++) { + value = (value << 8) | Byte.toUnsignedInt(bytes[at + i]); + } + + return value; + } +} diff --git a/mynes-patch/src/main/java/com/github/dimiro1/mynes/patch/InvalidPatchException.java b/mynes-patch/src/main/java/com/github/dimiro1/mynes/patch/InvalidPatchException.java new file mode 100644 index 0000000..21a72e0 --- /dev/null +++ b/mynes-patch/src/main/java/com/github/dimiro1/mynes/patch/InvalidPatchException.java @@ -0,0 +1,18 @@ +package com.github.dimiro1.mynes.patch; + +/** + * Thrown for a file that is not an IPS patch, or is one that has been cut short. + *

+ * Unchecked, because every caller is a front end that has just been handed a filename by a person, + * and the only thing any of them can do about a bad one is say so and carry on with whatever was + * already loaded. The message is a whole sentence, meant to be printed on its own. + */ +public class InvalidPatchException extends RuntimeException { + /** + * @param filename the patch that could not be read. + * @param because what is wrong with it, as a clause: it is printed after a colon. + */ + public InvalidPatchException(final String filename, final String because) { + super(filename + " is not a valid IPS patch: " + because + "."); + } +} diff --git a/mynes-patch/src/main/java/com/github/dimiro1/mynes/patch/package-info.java b/mynes-patch/src/main/java/com/github/dimiro1/mynes/patch/package-info.java new file mode 100644 index 0000000..316b1fc --- /dev/null +++ b/mynes-patch/src/main/java/com/github/dimiro1/mynes/patch/package-info.java @@ -0,0 +1,20 @@ +/** + * Binary patches, applied to a file's bytes before anything else looks at them. + *

+ * A romhack is handed out as a patch rather than as a ROM, because the ROM is somebody else's + * copyright and the difference between it and the hack is not. + * {@link com.github.dimiro1.mynes.patch.IPSPatch} applies one to an array of bytes and hands back + * another array, which is the whole of the API: the file on disk is never written to, and there is + * no patched copy of it for anybody to keep track of afterwards. + *

+ * Nothing here knows what it is patching. IPS is a diff format from 1990 that says nothing about its + * subject -- a ROM, a save file, a disk image -- and that is why this is a module of its own rather + * than a package inside the console: a patcher that could see a cartridge would eventually be handed + * one. + *

+ * The one thing a caller has to know is that offsets are counted from the front of the file. A patch + * cut against a dump with a header must be applied to a dump with that header, and one cut against a + * headerless dump written sixteen bytes into a headered one will corrupt it quietly and thoroughly. + * Which of the two a patch was made for is nowhere in the patch, so it is not guessed at here. + */ +package com.github.dimiro1.mynes.patch; diff --git a/mynes-patch/src/test/java/com/github/dimiro1/mynes/patch/IPSPatchTests.java b/mynes-patch/src/test/java/com/github/dimiro1/mynes/patch/IPSPatchTests.java new file mode 100644 index 0000000..a2c7901 --- /dev/null +++ b/mynes-patch/src/test/java/com/github/dimiro1/mynes/patch/IPSPatchTests.java @@ -0,0 +1,253 @@ +package com.github.dimiro1.mynes.patch; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The format, record by record. + *

+ * The patches are built here rather than vendored, because the interesting ones are the malformed + * ones and no patcher will write those. + */ +class IPSPatchTests { + private static final String NAME = "hack.ips"; + + /** + * A patch file, assembled the way a patcher assembles one. Every number in the format is big + * endian, which is what {@link #offset} and the two length writes are being careful about. + */ + private static final class Builder { + private final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + Builder() { + bytes.writeBytes("PATCH".getBytes(StandardCharsets.US_ASCII)); + } + + Builder record(final int offset, final int... data) { + offset(offset); + bytes.write(data.length >> 8); + bytes.write(data.length); + + for (var value : data) { + bytes.write(value); + } + + return this; + } + + Builder run(final int offset, final int count, final int value) { + offset(offset); + bytes.write(0); + bytes.write(0); + bytes.write(count >> 8); + bytes.write(count); + bytes.write(value); + + return this; + } + + byte[] end() { + bytes.writeBytes("EOF".getBytes(StandardCharsets.US_ASCII)); + + return bytes.toByteArray(); + } + + byte[] endTruncatingTo(final int length) { + end(); + offset(length); + + return bytes.toByteArray(); + } + + private void offset(final int offset) { + bytes.write(offset >> 16); + bytes.write(offset >> 8); + bytes.write(offset); + } + } + + private static byte[] image(final int... values) { + var image = new byte[values.length]; + + for (var i = 0; i < values.length; i++) { + image[i] = (byte) values[i]; + } + + return image; + } + + private static IPSPatch read(final byte[] bytes) { + return IPSPatch.read(bytes, NAME); + } + + @Test + void aRecordWritesItsBytesWhereItSaysItWill() { + var patch = read(new Builder().record(2, 0xAA, 0xBB).end()); + + assertArrayEquals( + image(0, 1, 0xAA, 0xBB, 4), + patch.applyTo(image(0, 1, 2, 3, 4))); + } + + @Test + void aRunRepeatsOneByte() { + var patch = read(new Builder().run(1, 3, 0xFF).end()); + + assertArrayEquals( + image(0, 0xFF, 0xFF, 0xFF, 4), + patch.applyTo(image(0, 1, 2, 3, 4))); + } + + @Test + void recordsAreAppliedInTheOrderTheyWereWritten() { + var patch = read(new Builder() + .record(0, 0x11, 0x11, 0x11) + .record(1, 0x22) + .end()); + + assertArrayEquals(image(0x11, 0x22, 0x11), patch.applyTo(image(0, 0, 0))); + } + + @Test + void aRecordPastTheEndOfTheImageGrowsIt() { + var patch = read(new Builder().record(4, 0xAA, 0xBB).end()); + + assertArrayEquals(image(0, 1, 0, 0, 0xAA, 0xBB), patch.applyTo(image(0, 1))); + } + + @Test + void aRunPastTheEndOfTheImageGrowsItToo() { + var patch = read(new Builder().run(2, 2, 0xEE).end()); + + assertArrayEquals(image(0, 1, 0xEE, 0xEE), patch.applyTo(image(0, 1))); + } + + @Test + void theTruncationExtensionCutsTheImageShort() { + var patch = read(new Builder().record(0, 0xAA).endTruncatingTo(3)); + + assertEquals(3, patch.truncateTo()); + assertArrayEquals(image(0xAA, 1, 2), patch.applyTo(image(0, 1, 2, 3, 4))); + } + + @Test + void aTruncationLongerThanTheImageLeavesItAlone() { + // Truncating is all the extension means, so a length that would grow the file is not one. + var patch = read(new Builder().record(0, 0xAA).endTruncatingTo(99)); + + assertArrayEquals(image(0xAA, 1, 2), patch.applyTo(image(0, 1, 2))); + } + + @Test + void aPatchWithoutATruncationSaysSo() { + assertEquals(IPSPatch.NO_TRUNCATION, read(new Builder().record(0, 0xAA).end()).truncateTo()); + } + + @Test + void theImageItIsHandedIsLeftAlone() { + var original = image(0, 1, 2, 3); + var patch = read(new Builder().record(1, 0xAA).end()); + + patch.applyTo(original); + + assertArrayEquals(image(0, 1, 2, 3), original); + } + + @Test + void thePatchCanBeAppliedMoreThanOnce() { + var patch = read(new Builder().record(1, 0xAA).run(3, 2, 0xBB).end()); + + assertArrayEquals(patch.applyTo(image(0, 1, 2, 3, 4)), patch.applyTo(image(0, 1, 2, 3, 4))); + } + + @Test + void aPatchWithNoRecordsChangesNothing() { + var patch = read(new Builder().end()); + + assertEquals(0, patch.records()); + assertEquals(0, patch.bytes()); + assertArrayEquals(image(0, 1, 2), patch.applyTo(image(0, 1, 2))); + } + + @Test + void aRunOfNothingIsNotARecord() { + // Kept, it would be a record that writes no bytes and still grew the image to reach its + // offset -- which is the one thing a record of zero length must not do. + var patch = read(new Builder().run(9, 0, 0xFF).end()); + + assertEquals(0, patch.records()); + assertArrayEquals(image(0, 1), patch.applyTo(image(0, 1))); + } + + @Test + void itCountsWhatItRead() { + var patch = read(new Builder() + .record(0, 0xAA, 0xBB) + .run(8, 16, 0x00) + .end()); + + assertEquals(2, patch.records()); + assertEquals(18, patch.bytes()); + assertEquals(NAME, patch.filename()); + } + + @Test + void aFileThatDoesNotBeginWithPATCHIsRefused() { + var refused = assertThrowsExactly( + InvalidPatchException.class, + () -> read("NESand so on".getBytes(StandardCharsets.US_ASCII))); + + assertTrue(refused.getMessage().contains(NAME)); + } + + @Test + void anEmptyFileIsRefused() { + assertThrowsExactly(InvalidPatchException.class, () -> read(new byte[0])); + } + + @Test + void aPatchThatStopsBeforeItsEOFIsRefused() { + var whole = new Builder().record(0, 0xAA).end(); + + assertThrowsExactly( + InvalidPatchException.class, + () -> read(Arrays.copyOf(whole, whole.length - 1))); + } + + @Test + void aRecordThatRunsOffTheEndIsRefused() { + var whole = new Builder().record(0, 0xAA, 0xBB, 0xCC).end(); + + // Header, offset and length, then one byte of the three the record promised. + assertThrowsExactly( + InvalidPatchException.class, + () -> read(Arrays.copyOf(whole, 5 + 3 + 2 + 1))); + } + + @Test + void aRunThatRunsOffTheEndIsRefused() { + var whole = new Builder().run(0, 4, 0xAA).end(); + + // The count arrived; the byte to repeat did not. + assertThrowsExactly( + InvalidPatchException.class, + () -> read(Arrays.copyOf(whole, 5 + 3 + 2 + 2))); + } + + @Test + void bytesAfterTheEOFThatAreNotATruncationAreRefused() { + var whole = new Builder().record(0, 0xAA).endTruncatingTo(3); + + assertThrowsExactly( + InvalidPatchException.class, + () -> read(Arrays.copyOf(whole, whole.length - 1))); + } +} diff --git a/pom.xml b/pom.xml index 6ff8567..554218a 100644 --- a/pom.xml +++ b/pom.xml @@ -14,9 +14,13 @@ + and the zip are built there: it is the module with a main class. + + mynes-patch depends on nothing, mynes-core included: IPS says nothing about what it patches, + so the front ends are what join the two together. --> mynes-core + mynes-patch mynes-headless mynes-desktop @@ -130,7 +134,7 @@