Slap

Ik heb een ROM-patcher gemaakt: slap. Het is een goed hulpmiddel; het is gratis/FOSS, multiplatform, ondersteunt twintig formaten, is vriendelijk, snel en maakt kleine patches.

Ik heb het gebouwd omdat ik geen CLI-tool voor Linux kon vinden die me beviel en die goed samenwerkte met de patches die ik nodig had om mijn FXPak voor te bereiden. Daarom is het mede gebouwd voor gebruik in scripting. Tegelijkertijd is het zeer communicatief; het geeft gestructureerde fouten, waarschuwingen en observaties voor allerlei situaties.

De tool gaat geen uitgangspunten over een patch. Ik ben er vrij zeker van dat alles wat niet ongeldig is, correct wordt afgehandeld. Als iets verkeerd gevormd is, of coherent is maar iets verrassends dreigt te doen, geeft slap precies aan wat er aan de hand is en waar.

Je kunt het uitproberen in een CLI-versie of een webversie. De tool is geschreven in Haskell en Rust, wat het proces om het goed in de browser te laten werken interessant maakte.

De tool kent zes commando's (verbs):

  • apply — patch + originele ROM → gewijzigde ROM
  • create — twee ROM's → patch
  • undo — patch + gewijzigde ROM → originele ROM
  • convert — patch in het ene formaat → patch in een ander formaat
  • explain — patch → leesbare beschrijving van wat de patch doet
  • info — patch → metadata van de patch

Appendix

Correctheid

Voor elke realistische patch is "we kunnen deze correct toepassen" een gegeven. Maar waar liggen de grenzen van wat acceptabel is? Voor elk formaat moesten we ons per "eigenschap X" afvragen:

  • Is eigenschap X onderdeel van de specificatie ('spec')?
  • Is het een eigenaardigheid die afhangt van de specifieke implementatie door de gebruiker?
  • Is het een technische mogelijkheid die desondanks geen deel uitmaakt van de specificatie?

Enkele voorbeelden:

  • Wat geldt als een correct gevormde IPS-patch? Kunnen records overlappen? Kunnen ze niet-monotoon zijn? Als het antwoord op beide "ja" is, maakt dit het toepassen minder voor de hand liggend dan het aanvankelijk leek. En wat betekent het om de truncatie-marker te respecteren als deze zegt dat het bestand moet worden "getrunceerd" naar een grootte die groter is dan het inputbestand?
  • IPS kan niet adresseren voorbij 16MiB. Behalve in vreemde randgevallen waarbij alleen het begin van een bestand wordt gewijzigd, kan IPS dus alleen wijzigingen beschrijven in bestanden tot 16MiB. Het kan niet verder kijken dan 16MiB, maar het kan wel een record beschrijven dat binnen de grenzen begint maar schrijft tot voorbij die 16MiB. Dit is coherent en heeft voorspelbare resultaten, maar is erg vreemd. Is dit "volgens de specificatie"? We passen deze patches toe, maar we spreken af ze niet zelf te creëren.
  • EBP ondersteunt JSON-metadata, wat wordt gebruikt om vier strings op te slaan. De expressieve kracht hiervan is veel groter dan wat er in de praktijk mee gedaan wordt. Is de specificatie onjuist als er iets anders aanwezig is dan die strings? Of is het omgekeerde het geval: arbitrary oneindige nesting? Kunnen we er tenminste vanuit gaan dat het UTF-8 moet zijn?
  • NINJA2 heeft een interessante "normalisatie"-functie. Als de input ROM nog niet in een "normale vorm" is (gedeinterleaved, geen header, z64 byte-volgorde voor N64, etc.), moet de patcher deze in die vorm brengen. Als de patch zegt dat normalisatieprocedure 'foo' moet worden toegepast en de patcher kent procedure 'foo' niet, weigert hij de patch toe te passen. Het probleem is dat dit formaat ook checksums van de input opslaat, gebaseerd op de genormaliseerde vorm. Dit lijkt mij te conservatief; we zouden ten minste moeten controleren of het inputbestand al in de verwachte vorm is voordat we het opgeven.
  • BPS lijkt het toe te staan om "dingen te kopiëren uit andere delen van de output, voordat er iets is geplaatst". Is dit coherent of niet?
  • PPF3 houdt geen bestandsgroottes bij en staat undo toe. De datastroom kan groei beschrijven, maar op een manier waardoor undo incoherent wordt als dat gebeurt. De originele tool lijkt groei of krimp te willen blokkeren, maar ik vermoed dat dit niet goed werkte. Wat doen we als de gebruiker een PPF3 creëert die van grootte verandert en undo-data bevat? Vanwege structurele redenen in het formaat is het onmogelijk om te detecteren of een gebruiker probeert te trunceren via de undo-functie.

Bij het creëren zijn we conservatief: we genereren bestanden die door elk hulpmiddel correct kunnen worden toegepast. Bij het toepassen hebben we geprobeerd het volledige "expressieve bereik" van elk formaat te ondersteunen. Dit betekende veel tijd besteden aan nadenken over hoe er iets mis zou kunnen gaan, zoals een datastroom die incoherente instructies representeert. Een groot deel van die tijd ging naar het zorgen dat elke manier waarop een patch foutief gevormd kan zijn, met de juiste naam wordt aangeroepen.

Tekstcodering

Sommige formaten kunnen tekstmetadata opslaan. Ik verwachtte dat deze uitsluitend ASCII zouden zijn, maar dat is niet het geval. We outputten UTF-8 en zijn er vrij zeker van dat dit in alle gevallen "niet fout" is. De twee meest voorkomende regels die we tegenkomen zijn: "gebruik UTF-8" (prima) of "gebruik de systeempagina/codepage" (wat?). Dat laatste staat UTF-8 nog steeds toe, aangezien UTF-8 op moderne systemen de standaard systeempagina is.

Aan de lees- en weergavekant is het complexer. We bieden diverse alternatieve decoderingsopties aan voor het geval UTF-8 niet de juiste manier is om de patch te lezen.

We beveiligen en saniteren wat we decoderen/lezen. Een paar formaten hebben metadatavelden voor willekeurige data (niet willekeurige tekst, maar data). In de praktijk worden deze velden, indien gebruikt, gebruikt voor tekst. We willen deze velden niet volledig afsluiten van de gebruikelijke weergave en conversie-mechanismen, maar we willen ook geen controlekarakters uitvoeren uit een verdacht ingebed bestand. Onze aanpak: alles tonen, op niets reageren. Niet-printbare codepunten worden escaped weergegeven (bijv. <U+0007> in plaats van een beep), en een bytevolgorde die de gekozen codering niet kan decoderen, wordt U+FFFD met een waarschuwing over de locatie.

Benchmarks

Toelichting

Elk getal in de CLI-tabellen is inclusief het opstarten van het proces. De kleinste verschillen worden bepaald door een vloer van 10–25ms voor executie en runtime, niet door het patchen zelf.

We gebruiken vier paren bestanden: een GBC ROM (4MiB), een GBA ROM (16MiB), een N64 ROM (64MiB) en een disc-image (520MiB). Voor elk formaat waarbij we de referentietool kunnen scripten, maakt elke tool een patch van het paar en past deze vervolgens toe. De tijden zijn medianen van 'warme' runs. Elke output wordt byte-voor-byte gecontroleerd tegen het referentiepaar voordat de tijd wordt geteld.

In de cellen staat: slap / referentie.

  • Getallen boven de vijftien seconden worden genoteerd als 15s+.
  • Een lege cel betekent dat die combinatie niet is getest voor die grootte (IPS en EBP stoppen bij 16MiB).
  • betekent dat er een run was, maar dat er niets meetbaars is geproduceerd.
  • PPF3 wordt vergeleken met de undo-data uitgeschakeld, conform de modus van de referentietool.

Creëren (tijd om een patch te maken)

Tijd in milliseconden (ms) of seconden (s)

Formaat / Referentie4MiB16MiB64MiB520MiB
ips / flips18ms / 12ms9ms / 21ms
ips32 / sips15ms / 8ms11ms / 17ms38ms / 68ms150ms / 413ms
ups / goUps9ms / 12ms11ms / 24ms44ms / 99ms198ms / 588ms
bps / flips57ms / 327ms103ms / 1.29s1.55s / 11.94s1.88s / 15s+
ppf3 / makeppf311ms / 10ms19ms / 14ms74ms / 57ms487ms / 333ms
ninja1 / ninjaPhp17ms / 41ms45ms / 53ms130ms / 241ms
ninja2 / ninja2Php19ms / 145ms50ms / 103ms204ms / 685ms1.47s / 1.59s
gdiff / javaxdelta18ms / 136ms12ms / 249ms239ms / 1.27s733ms / 10.76s
bsdiff / bsdiff74ms / 560ms110ms / 2.65s682ms / 14.79s3.57s / 15s+
xdelta1 / xdelta144ms / 41ms45ms / 49ms386ms / 270ms1.62s / 1.33s
xdelta3 / xdelta376ms / 47ms21ms / 34ms398ms / 383ms596ms / 326ms

Patch-grootte (gegenereerde bestanden)

Grootte van de patches gemaakt door slap / referentie

Formaat / Referentie4MiB16MiB64MiB520MiB
ips / flips655KiB / 654KiB16KiB / 16KiB
ips32 / sips672KiB / 899KiB18KiB / 23KiB4.4MiB / 4.7MiB388KiB / 613KiB
ups / goUps888KiB / 888KiB11KiB / 11KiB4.5MiB / 4.5MiB467KiB / 467KiB
bps / flips276KiB / 272KiB10KiB / 12KiB1.9MiB / 1.8MiB280KiB / —
ppf3 / makeppf3922KiB / 937KiB24KiB / 33KiB4.6MiB / 4.9MiB481KiB / 728KiB
ninja1 / ninjaPhp888KiB / 899KiB18KiB / 23KiB4.4MiB / 4.7MiB
ninja2 / ninja2Php891KiB / 903KiB22KiB / 28KiB4.4MiB / 4.7MiB467KiB / 679KiB
gdiff / javaxdelta600KiB / 747KiB21KiB / 33KiB1.9MiB / 1.9MiB367KiB / 425KiB
bsdiff / bsdiff204KiB / 215KiB8KiB / 8KiB1.9MiB / 1.8MiB287KiB / —
xdelta1 / xdelta1239KiB / 239KiB13KiB / 17KiB1.7MiB / 1.8MiB275KiB / 279KiB
xdelta3 / xdelta3218KiB / 236KiB10KiB / 13KiB1.7MiB / 1.7MiB263KiB / 272KiB

Toepassen (snelheid van applicatie)

Tijd om de door slap gemaakte patch toe te passen: slap / referentie-applier

Formaat / Applier4MiB16MiB64MiB520MiB
ips / flips20ms / 10ms33ms / 30ms
ips32 / atmosphereIps21ms / 9ms32ms / 27ms103ms / 103ms835ms / 776ms
ups / goUps15ms / 12ms35ms / 30ms126ms / 123ms902ms / 918ms
bps / flips34ms / 38ms35ms / 136ms122ms / 532ms896ms / 4.2s
ppf3 / applyppf316ms / 30ms31ms / 19ms112ms / 109ms837ms / 109ms
ninja1 / ninjaPhp21ms / 15s+60ms / 15s+163ms / 15s+
ninja2 / ninja2Php23ms / 65ms64ms / 51ms232ms / 232ms1.82s / 1.48s
gdiff / javaxdelta18ms / 48ms33ms / 55ms110ms / 165ms830ms / 1.01s
bsdiff / bsdiff44ms / 27ms112ms / 59ms471ms / 279ms3.41s / 1.72s
xdelta1 / xdelta130ms / 19ms65ms / 46ms238ms / 174ms1.84s / 1.18s
xdelta3 / xdelta335ms / 16ms35ms / 32ms152ms / 134ms848ms / 843ms

Opmerking: sips en makeppf3 kunnen alleen patches creëren, dus voor ips32 en ppf3 wordt de referentie-applier van het formaat gebruikt.

Bij het toepassen verliezen we meer cellen dan we winnen; bij cartridge-groottes gaat het om verschillen van tientallen milliseconden. Elke applier hier past de patches van slap byte-perfect toe.

Kanttekeningen

  • javaxdelta start een JVM binnen elke getimede aanroep. Die kosten domineren de resultaten bij 4MiB en worden ruis bij 520MiB; de library zelf is snel.
  • applyppf3 en de ninja-appliers wijzigen een bestand in place in plaats van een nieuwe output te schrijven. Elke run krijgt daarom een vooraf gemaakte kopie, waarbij het maken van die kopie niet wordt getimed. Bij 520MiB bestaat het grootste deel van de applicatietijd van slap uit het schrijven van de 520MiB-output; een in-place applier schrijft alleen de gewijzigde bytes.

In de browser

De natuurlijke vergelijking voor web-slap is RomPatcher.js, de langdurige standaard binnen de community. We gebruiken drie paren: de 4MiB en 64MiB ROM's van hierboven, en een paar van 8MiB voor ips en ebp (die geen 64MiB kunnen adresseren). Beide kanten passen de patch van slap toe. De tijden zijn 'warm': één keer opgestart, bestanden al in het geheugen.

Creëren (web-slap / RomPatcher.js)

Formaat4MiB8MiB64MiB
ips45ms / 36ms17ms / 219ms
ups6ms / 43ms75ms / 620ms
bps60ms / 15s+2.0s / 560ms
ppf5ms / 31ms72ms / 421ms
ebp45ms / 37ms17ms / 220ms
aps6ms / 33ms74ms / 412ms
rup14ms / 61ms214ms / 789ms

Bij 4MiB doorloopt RomPatcher.js in pure JavaScript dezelfde zoekopdracht als onze Rust-differ; dat verklaart de 15s+, waarbij de patch bijna net zo klein uitvalt. Boven de 4MiB (een drempelwaarde in de broncode) schakelt het over op een lineaire pass, wat veel sneller is maar een grotere patch schrijft.

Patch-grootte (web-slap / RomPatcher.js)

Formaat4MiB8MiB64MiB
ips655KiB / 886KiB554KiB / 556KiB
ups888KiB / 888KiB4.5MiB / 4.5MiB
bps276KiB / 308KiB1.9MiB / 4.4MiB
ppf922KiB / 936KiB4.6MiB / 4.9MiB
ebp655KiB / 886KiB554KiB / 556KiB
aps905KiB / 908KiB4.5MiB / 4.6MiB
rup891KiB / 903KiB4.4MiB / 4.7MiB

Toepassen (web-slap / RomPatcher.js)

Formaat4MiB8MiB64MiB
ips22ms / 4ms9ms / 14ms
ups5ms / 12ms82ms / 98ms
bps29ms / 10ms68ms / 48ms
ppf3ms / 10ms43ms / 59ms
ebp22ms / 3ms8ms / 19ms
aps24ms / 11ms37ms / 66ms
rup12ms / 17ms176ms / 198ms