Mijn Amiga-game uit 1993 naar Godot porten, met een LLM die de 68000 assembly leest — Babylonian Twins

Murtadha Salman verzorgde de art en Mahir AlSalman componeerde de muziek. We leefden onder sancties. Er was geen internet en geen toegang tot game-development bronnen; ik had slechts één kopie van de Amiga Hardware Reference Manual, die ik gebruikte om de hardware direct aan te sturen. Bovendien was er slechts een paar uur per dag elektriciteit. Het constante wisselen van diskettes (vanwege het kleine geheugen) en de zomers van 50°C zorgden ervoor dat mijn diskdrive drie keer kapotging.

Op de Amiga betekent "met de hand" dat de game tijdens het draaien niets aan het besturingssysteem vraagt. Bij het opstarten bewaart het programma de interrupt-vectoren, schakelt de OS-interrupts uit en neemt de volledige machine over:

move.l  #$dff000,a0         ; Base for hardware registers
lea     save(pc),a1         ; Get the system
move.w  #$4000,intena(A0)    ; from the AMIGA

"Get the system from the AMIGA" is mijn commentaar uit 1993. Vanaf dat moment is het display de eigen copper list van de game (de programmeerbare videocoprocessor van de Amiga), die on-the-fly wordt herschreven voor sprites en luchtkleuren. Tiles bewegen door direct naar de registers van de blitter te schrijven en te wachten op de done-flag. De joystick wordt direct uit de hardwarepoort gelezen, en de vuurknop is één pin op een CIA-chip. Het OS wordt alleen weer ingeschakeld tussen levels door, om de bestanden van het volgende level van disk te laden, waarna het weer wordt uitgeschakeld.

Het was het eerste commerciële spel dat in Irak werd gemaakt, maar lange tijd konden maar weinig mensen het spelen. Commodore stortte in en de sancties schrokken uitgevers af, waardoor het voltooide spel op een plank bleef liggen. In 2008 vond een Amiga-forum het spel via YouTube-uploads van mijn broer en zocht men mij op voor de diskettes; die thread bestaat nog steeds.

Het spel is eerder één keer geport, handmatig, in 2010. Hetzelfde team herbouwde het voor de iPhone met een engine die vanaf nul was geschreven in ongeveer 34.000 regels C++, wat maanden aan nachten en weekenden kostte. Apple en Google zetten het spel in de etalage, waarna het meer dan twee miljoen downloads behaalde.

Ik heb deze nieuwe port niet zelf gedaan. Ik heb erom gevraagd, het resultaat elke avond gespeeld, aangegeven wat niet goed voelde en de weinige beslissingen genomen waarvoor iemand nodig was die er in 1993 bij was. Het uitpluizen van de bestandsformaten en de assembly was het werk van de AI, evenals de beslissingen over hoe dertig jaar oude code overgezet kon worden. Het ging sneller dan ik kon volgen. Dit artikel is wat ik ontdekte toen ik weken later ging lezen wat er met mijn eigen game was gedaan. Sommige dingen waren fout, en dat merkte ik pas na weken.

Waarom ik het opnieuw probeerde

Ik had dit eerder geprobeerd. Ongeveer een jaar geleden gaf ik een eerder model dezelfde Amiga-materialen en vroeg ik het om zin te maken van mijn binaire level-maps. Het lukte uiteindelijk, maar het kostte meerdere rondes en veel hints van mijn kant.

Toen kwam Claude Fable 5 uit, en ik gaf het dezelfde bestanden. De test was bewust: mijn vermoeden was dat er weinig Amiga-assemblycode in LLM-trainingssets zit. Als het model beter was in het uitzoeken van zaken in plaats van het reproduceren van herinneringen, zou dit blijken.

Voor het weekend van 4 juli plande ik drie stappen, waarbij elke stap afhankelijk was van het succes van de vorige:

  1. De veilige vraag: mijn eigen engine uit 2010 (de 34.000 regels C++) overzetten naar Godot 4. Dit was de controle.
  2. De oneerlijke vraag: de originele 72.758 regels 68000 assembly van een machine die al lang uit productie is, zonder noemenswaardige commentaren en zonder overeenkomsten met de C++. Dit opnieuw bouwen in Godot, op de originele 50 Hz van de Amiga.
  3. De hebzuchtige vraag: de tweede versie in de eerste plaatsen, zodat iemand die de moderne game koopt, ook de originele versie uit 1993 kan lanceren.

Alle drie de stappen werkten. Het level-formaat waar ik een jaar eerder meerdere rondes en correcties voor nodig had, kwam nu in één keer correct uit, zonder hints.

Hoe het werd uitgevoerd

Ik draaide het in Claude Code, waardoor het toegang had tot een terminal en mijn bestandssysteem. Het kon bestanden bewerken, de assembler draaien, de game bouwen, de game lanceren en de output lezen. Wanneer ik hieronder schrijf dat het mijn binaries uit 1993 herbouwde en controleerde, gebeurde dat door vasm te draaien en de output te vergelijken via diff.

Vroeg in het proces voegde het een reeks command-line flags toe aan de game zodat het zonder mij kon testen:

  • --level=<name>: een level direct laden
  • --pose=<spec>: de tweelingen op exacte posities plaatsen
  • --drive=<spec>: knoppen indrukken via een script, frame voor frame
  • --probe: dump de status van switches / gates / doors / keys
  • --screenshot=<path>: een frame renderen en afsluiten

Hierdoor veranderde de vraag "voelt de sprong goed aan?" in iets dat een machine kan lezen: drive[btwjump:2.2] pos=(25.44, 24.04) vel=(0.00, -14.51) ground=false apexy=22.48

Daarnaast waren er twee headless checks: één die elk script compileert en één die elk level bouwt en fouten rapporteert. Aan de Amiga-kant stuurde het de echte toolchain aan: vasm voor de assembly en FS-UAE om het resultaat op te starten. Wat niet geautomatiseerd was: er was geen beeldvergelijking bij de moderne port (het maakte screenshots, ik bekeek ze) en er was geen controle of de game "goed voelde".

Stap 1: 34.000 regels C++ in één avond

Woensdagavond, de veilige vraag. Onbewerkte tijdstempels:

  • 22:23: Godot 4 project scaffold, asset sync, TMX level pipeline
  • 22:44: beide tweelingen speelbaar — collision, physics, camera, switching
  • 23:19: alle 38 entiteit-typen geport — volledige object-roster live
  • 00:35: volledige screen flow — menu's, map, story, save, game flows
  • 02:15: exporteren naar macOS, iOS en Android

Binnen eenentwintig minuten was er een speelbaar personage in een leeg project. Elke regel die die nacht werd overgezet, was een regel die ik in 2010 over maanden had geschreven. Ik ging verward naar bed.

Het duurde daarna ongeveer drie dagen om het "goed" te laten voelen: sprongbogen en trampoline-timing, en hit-detectie die beloont wanneer je snel op knoppen drukt; dit werd in batches gefixt op 2 juli, 3 juli en 4 juli.

Ik testte niet alleen. Mijn dertienjarige zoon speelde elke build met me mee. Hij wist altijd dat ik dit spel had gemaakt, maar hij had me nooit in actie gezien. Het testen werd een vader-zoon activiteit die ik niet had gepland, en het is een van mijn favoriete onderdelen van het project.

Dezelfde units, dezelfde tick

Alle gameplay-status leeft in tile units (1.0 = één tile van 48px), en de update draait op een vaste 60 Hz, omdat de iOS-build uit 2010 op 60 Hz draaide. Dit is cruciaal omdat de originele code drag multiplicatief toepast, elk frame:

static const float GROUND_DRAG_FACTOR = 0.85f;
this->velocity.x *= GROUND_DRAG_FACTOR;   // every tick!

Als je zestig keer per seconde met 0.85 vermenigvuldigt, krijg je een andere wrijving dan wanneer je dat vijftig keer doet. Als je het port naar een andere tick-rate, verandert elke acceleratiecurve in het spel. Er crasht niets, maar het voelt voor altijd "fout", en je vindt dit niet door een diff te lezen. Op 60 Hz transplanteerde de AI de constanten letterlijk. Dit is ook waarom de herbouw uit 1993 op 50 Hz draait en de moderne op 60: twee sets handmatig afgestelde getallen, die elk alleen correct zijn bij hun eigen tick. De AI behield beide klokken; ik zou in de verleiding zijn geweest om ze te verenigen in één.

Geen gebruik van CharacterBody2D

Godot levert CharacterBody2D en moveandslide(), en elke tutorial adviseert deze te gebruiken. De port gebruikte geen van beide voor de speler. De originele game heeft eigen, handgeschreven movement-code, en het herbouwen daarvan op de physics van iemand anders zou subtiel fout voelen op een manier die verschrikkelijk is om te troubleshooten. De speler is een gewone Node2D, en de collision-routine van 150 regels werd regel voor regel overgenomen, inclusief de "fudge numbers" die ik vijftien jaar geleden op gevoel had gekozen en de commentaren aan mijn toekomstige zelf:

# Add 0.5 because we want the character's feet to be in the middle of the tile.
var bottom := pos.y + dim.y / 2 + 0.5 + i + fraction
if int(bottom) == int(pos.y + dim.y / 2 + 0.49):
    continue
var right := pos.x
var left := pos.x - dim.x / 4      # asymmetric probes!

Niets maakte de losse 0.49 netjes. Er zijn geen tests en geen documentatie; die commentaren zijn de specificatie.

Stap 2: De 68000 assembly

Zondagmiddag 5 juli overhandigde ik hetgeen ik daadwerkelijk wilde testen. 72.758 regels verspreid over 26 bestanden, geschreven voor een machine met 512 KB geheugen, door mij, voor mij, met de commentaar-gewoonten van iemand die nooit verwachtte dat een ander het zou lezen. Geen documentatie. Een transfer naar moderne opslag in 2008 had elke lange bestandsnaam ingekort, waardoor elke include wees naar namen die niet meer bestonden. Een van de vijf level-sourcebestanden is halverwege een datatabel afgekapt. Er is geen andere kopie.

Voordat er iets geport werd, zorgde de AI ervoor dat de bronnen uit 1993 weer compileerden met vasm op een Apple Silicon Mac, en ging door tot de output byte-identiek was aan de binaries die destijds werden uitgebracht.

  • 14:34: Amiga-bronnen, assets en referenties importeren
  • 14:49: vasm toolchain reproduceert de uitgebrachte binaries byte-identiek
  • 15:20: disk-images herbouwd
  • 15:42: de herbouwde demo boot en speelt in FS-UAE

Vijftien minuten van een map met bestanden naar de eerste rebuild die overeenkwam met de uitgebrachte bytes. Ik schreef deze oorspronkelijk in ASM-One, waarvan het dialect verschilt van dat van vasm op een manier die de bytes verandert: ASM-One codeert cmp #4,d0 als CMPI, terwijl vasm een andere, even valide codering kiest. Alleen aangeven dat het niet mag optimaliseren is dus niet voldoende.

In plaats van mijn bronnen te bewerken, schreef de AI een preprocessing-stap die vijf van dit soort verschillen overbrugt, en herbouwde het de kapotte bestandsnaam-mapping bestand voor bestand.

Het lastigste onderdeel was org. Omdat er geen linker en geen relocatie is, legt de level-source het geheugen van de Amiga handmatig uit, adres voor adres:

org $6a000                 ; this section lives at address $6a000
Mapadd:
incbin"btwins:binary/L1/Map1.b"     ;Game Map
org mapadd+73*1024         ; skip to 73 KB past the map's start
GLBtable:
dc.w $3333,50,20,100       ; one object record begins
dc.w SahamR-grb,26         ;Routine,Length
...
org glbtable+2*1024        ; the object table gets exactly 2 KB

De map van level één gebruikt 74.400 van die 74.752 bytes, een marge van 352 bytes, en niets controleerde dit behalve ikzelf, in 1993. (SahamR-grb koppelt het gedrag van een object als een benoemde offset; saham is Arabisch voor pijl). De org van ASM-One kan de locatie-teller ook achteruit verplaatsen, wat vasm niet kan. De eerste workaround maakte één fout: een ds.b 800 in een teruggedraaid blok, wat ASM-One behandelt als "sla 800 bytes over", werd uitgeschreven als 800 bytes nullen. Alles na dat punt in het bestand, inclusief de copper list, stond 944 bytes verwijderd van waar de uitgebrachte binary het had. Het spel compileerde en bootte, maar tekende het verkeerde.

Zelfs daarna kwamen sommige delen nog niet overeen, met ongeveer 108 bytes verspreid over het variabele gebied. Die bytes legden uit waar de uitgebrachte bestanden vandaan kwamen. ASM-One assembleert in het geheugen, en het spel kwam op disk door dat geheugen op te slaan nadat het spel al had gedraaid. De uitgebrachte bestanden zijn dus een snapshot van een spel dat al in uitvoering was, geen schone assembler-output. Een verse assembly heeft nullen in die variabelen omdat er nog niets is ingesteld; de uitgebrachte disk heeft wat ze op dat moment in het geheugen hadden. De code schrijft ze voordat hij ze leest, dus de nullen zijn ongevaarlijk.

Toen ik die regel las, ging ik verder en wachtte ik op het eigenlijke spel. Het duurde weken voordat ik inzag dat dit het belangrijkste onderdeel van het project was, en dat niemand erom had gevraagd. Vanaf dat moment kon elke claim over dit spel worden beslist door bytes te vergelijken. Ik zou dit zelf niet hebben gedaan. Ik had de binaries al, en in achttien jaar leek het herbouwen vanuit bronnen nooit een middag waard.

De formaten

Voor elk formaat ging de AI naar de code die de bytes leest en werkte vanuit daar achteruit. De level-loader bestaat uit 1.652 regels ongecommenteerde 68000 assembly, wat de reden is dat ik zelf altijd naar een hex-editor greep.

De levels

Een level is een grid van tiles: een lange lijst getallen, waarbij elk getal betekent "plaats plaatje 47 hier", in mijn eigen private layout uit 1993. Dit is het formaat waar het oudere model en ik een jaar eerder doorheen hadden moeten worstelen.

De input is een lijst getallen zonder header en zonder dimensies, in een gecomprimeerd blok. Dit keer legde ik niets uit. De AI vond de tekenroutine, las hoe het grid werd doorlopen, leidde de breedte en hoogte af uit constanten elders in het bestand, en produceerde bij de eerste poging correcte maps voor alle vijf de levels.

Daarna renderde het elk level opnieuw vanuit de geëxtraheerde data en vergeleek het resultaat, pixel voor pixel, met full-level captures die ik in 2020 had gemaakt. Waar ze niet overeenkwamen, zocht het naar de oorzaak en vond het twee copper-effecten: de luchtgradiënt en de waterkleurencyclus. Met die twee meegerekend: vijf volledige level-afbeeldingen, nul afwijkende pixels. Level één alleen al is 600 tiles breed, 9.600 pixels.

Eigenschappen van map-cellen

Het tekenen van het level is slechts de helft van wat een map-cel doet. Elke cel is één 16-bit woord, en de afbeelding is het kleinere deel daarvan:

Eén map-cel, 16 bits:

  • bits 15..10: de eigenschap: wat dit vierkant DOET (6 bits)
  • bit 8: welke van de twee tile-banks gebruikt moet worden (1 bit)
  • bits 7..0: welke van de 256 tile-afbeeldingen getekend moet worden (8 bits)

De eigenschap is de onzichtbare physics van het level. 1 is vaste grond. 2 en 3 kunnen worden beklommen. 10 tot 13 betekenen allemaal "dit doet pijn", vier codes omdat knockback een richting nodig heeft. 14 doodt direct. 63 is een deur. Niets hiervan staat ergens opgeschreven. Het werd hersteld omdat twee routines hetzelfde woord lezen en elk een eigen helft onthullen: de draw-loop maskeert de lage byte, en de collision-check doet het tegenovergestelde:

move.w  (a1),d6             ; the same cell
and.w   #$fc00,d6           ; keep the top 6 bits
lsr.w   #2,d6
lsr.w   #8,d6               ; d6 = the property, 0..63
bsr     cbCheck             ; 2 or 3? you can climb this
bsr     Checkrmh            ; 10..13? this hurts, and from which side

Checkrmh stuurt de pijnlijke gevallen naar een label genaamd rmhEnjury, wat mijn spelling van "injury" in 1993 was.

Die bits werden in een editor geschilderd. Voordat het spel gebouwd kon worden, moest ik de tool bouwen die het spel bouwt: MEDITOR.S, 1.254 regels assembly, gedateerd door zijn eigen header in mijn Engels uit 1993:

; ***********************************************************************
; *		This Program was written in four days			*
; * 			1993-2-8/7/6/5					*
; *      I made it to help me to make a map to my first serious		*
; *				Game 					*
; ***********************************************************************

Vier dagen in februari 1993. Tiles schilderen met de muis, een eigenschapsnummer kiezen op de CURRENT FLAG teller van het paneel, dit stempelen op cellen met PUT FLAG, en een flag-view markeert elke cel met het geselecteerde nummer. Tijdens het schrijven van dit artikel vroeg ik het model om de map-editor te draaien en een screenshot te maken. Het assembleerde de bron uit 1993 met een moderne assembler, plaatste de uitgebrachte Level 2-data in het geheugen waar de editor het verwacht, en bootte het resultaat in een emulator.

De andere naam op het paneel, Udai, was mijn partner in Mesopotamia Software, zoals we onszelf noemden. Hij bouwde destijds zijn eigen spel. Ik schreef de editor voor ons beiden, maar het ontwerp werd samen bepaald zodat één tool voor beide games kon dienen. Zijn spel is nooit voltooid.

Object-tabellen

Vijanden zitten niet in de map. De wereld wordt per scherm opgeslagen, 25 tiles breed bij 20 hoog, en elk scherm heeft een kleine tabel met de objecten die erop staan. Mijn commentaren uit 1993 leggen de markers uit:

;	$1111=this is a Screen but it contain nothing or(End of Screen)
;	$2222=this is an object but do not draw it (dead)go to next
;	other=this is an object,draw it and go to the next
Scr0:	dc.w $3333,50,23,17		; a live object: frame, then x, y
dc.w hiddenwallR-lrb,20		; its behaviour: a routine, as an offset
dc.w 0
dc.w 0
dc.w 7
dc.w 10				; parameters only that routine understands
dc.w $3333,50,12,14
dc.w GreatTR-LRb,16,GkeyT-GTT,1
dc.w $1111			; end of this screen

Een vijand is een rij woorden: een marker, een frame, een positie binnen het scherm, en vervolgens het gedrag. hiddenwallR-lrb is de routine voor de afbrokkelende muur, gekoppeld als een offset vanaf een basislabel. De woorden daarna zijn parameters die betekenen wat die specifieke routine wil dat ze betekenen. Niets in het bestand zegt welk woord wat is, dus de AI vond de routine die deze tabellen elk frame doorloopt en liet die de velden benoemen, waarna elk object in alle vijf de levels werd omgezet naar wereldcoördinaten en gecontroleerd tegen de gerenderde maps.

GAME.S

De meeste databestanden versleutelen hun 16-byte headers met een sleutel die in het bestand zelf is opgeslagen, een truc uit 1993 om disk-editors buiten te houden. De retail-loader, GAME.S, heeft geen ontsleutelingstap. De AI las dit als een aanwijzing: GAME.S was geschreven voordat de versleuteling werd toegevoegd, en is dus een ouder bestand. Die aanwijzing stelde het later in staat om de verloren tweevoudige retail-set te herstellen via een sector-map in datzelfde bestand.

De deuren zitten niet in de map

Ik was ervan overtuigd dat ze dat wel waren. Wanneer je de tile-map van een level laadt, zijn er gaten waar elke deur zou moeten zitten, zonder door-tile (open of dicht). Een object-record van 18 bytes stempelt ze tijdens runtime op de map, als een kolom van 1×4 tiles, vanuit een tabel:

  • closed $528 $53C $550 $564: solide, blokkeert de weg
  • open $129 $13D $151 $165: begaanbaar — precies één sheet-kolom rechts

De map-data zegt dat er geen deur is. De level-code zegt dat er wel een is. Dertig drie jaar lang zou ik je hebben verteld dat de map de bron van waarheid is en dat deuren map-data zijn, en ik zou nooit verder hebben gezocht. De AI hield beide feiten vast, vond de routine die ze verzoent, en kwam terug met het ontwerp: deuren worden tijdens runtime door code getekend; ze zijn nooit in de editor in de map geschilderd. Dat is waarom een directe port van de level-data resulteert in een toren met deuropeningen vol lucht.

De copper-lucht

In elk level is kleurindex 31 de lucht, en niets in de tile-art tekent dit. De tile-atlas rendert dit als transparant, en daarachter herschildert de copper de achtergrondkleur op gekozen scanlines om een verticale gradiënt te maken. De gradiënt staat in de level-source als een simpele lijst kleuren. Dit is de volledige lucht van het tweede level:

backgndcol:
col1:   dc.w    $09FF,$09FF,$09FF,$09FF,$09EF,$09EF,$0ADF,$0ADF
dc.w    $0ACF,$0ACF,$0ABF,$0BBF,$0BBF,$0CBF,$0CBF,$0DCF
dc.w    $0DCF,$0ECF,$0DCF,$0DCF,$0CCF,$0CCF,$0CDF,$0CDF

Als je de lijst naar beneden leest, gaat de lucht van lichtblauw naar warm bij de horizon.

De eerste rebuild miste dit, en de levels zagen er prima uit. Vlak, op een manier die ik niet kon benoemen. De pixelvergelijking weigerde echter "groen" te worden, en de gradiënt werd toegevoegd.

Ambiguïteit van sprite-sheets

Amiga sprite-sheets zijn planair (vijf aparte 1-bit bitplanes in plane-major strips, plus een transparantiemasker), en dat alles werd afgeleid uit de draw-routines en de org-rekenkunde. Sheet-groottes van de vorm frames width height 2 5 zijn ambigu: die 2 zou kunnen betekenen dubbele breedte-frames, of twee gestapelde rijen, één per kijkrichting. Beide interpretaties passen bij elke byte in het bestand. Het zijn twee rijen; dat was mijn keuze in 1993.

Het model vlagde de ambiguïteit en vroeg om opheldering. Dat was het laatste formaat. Vanaf daar ging de game uit 1993 naar Godot op dezelfde manier als de C++ versie, met gedrag herschreven in GDScript op de originele 50 Hz.

Stap 3: Het oude spel in het nieuwe

De hebzuchtige vraag kostte één avond, van 21:58 tot 23:43. De retro-game draait als een gast, met zijn eigen namespace en scene-host, en de engine schakelt bij binnenkomst naar 50 Hz en bij vertrek terug naar 60 Hz. Het is een precies klusje, en het werd in één zitting gedaan. Ik had aangenomen dat deze feature een week zou kosten en geschrapt zou worden. Het is de reden dat de Steam-versie wordt geleverd met de game uit 1993 erin.

De game die je downloadt bevat geen Amiga-code. De data, de packed chunks, planar graphics en de muziek werden eenmalig op mijn machine door Python-scripts gedecodeerd naar gewone PNG, WAV en JSON. Het gedrag (hoe een wachter patrouilleert, wanneer een deur opent) is herschreven in de taal van de engine. Als je het echte origineel wilt, is er een gratis disk-image op itch.io en een emulator.

Waar het misging

De wachter-bug

In level 2 loop je door een gang. Waterval links, stenen pilaar voor je. Geen vijand in beeld, niets dat nadert, en toch krijg je een hit. Wat je raakte, was een speer-soldaat die dertien tiles boven je stond, op een grasveldje naast een palmboom, met massief gesteente ertussen.

Hij is een deurwachter. Hij duwt iedereen die aan zijn voeten staat. In het origineel is die check aan beide kanten begrensd:

sub.w   d1,d4           ; d4 = vertical distance to the kid
cmp.w   #4,d4
bpl     Sg.Far          ; 4 or more rows below? not my problem
cmp.w   #-2,d4
bmi     SG.far          ; too far above? also not my problem

De port behield de ondergrens maar liet de bovengrens vallen. Een duw die bedoeld was voor de drie rijen van de wachter zelf, liep nu over de hele hoogte van de map-kolom onder hem, door de vloer, in een gang waar hij niet eens in voorkomt.

Kleinere fouten

Elk level heeft een tweede tile-laag die het origineel nooit rendert; dit is de verborgen artwork die onthuld wordt wanneer een deur opent of een valse muur afbrokkelt. Als je dit "getrouw" rendert, staan alle geheime passages vanaf het begin open. Als vijanden vóór spelers worden verplaatst in plaats van erna, wordt een trampoline-sprong twee keer geteld, waardoor je twintig tiles de lucht in schiet. Een deur die gelabeld was als "p1,p2,p3,p4" (alle vier de palmen), werd gelezen als één sleutel met een vreemde naam, waardoor de tutorial-exit nooit openging. Een sound-loop lengte die berekend werd als stereo terwijl de effecten mono zijn, zorgde ervoor dat elk geluid halverwege stopte en opnieuw startte.

De fout die het meeste kostte, was een feature waar ik in de 1993-build om had gevraagd: laat de tweelingen van plek wisselen over elke afstand. De proximity-check kwam eruit, en de standbeelden begonnen te corrumperen. De AI ging terug naar de routine en kwam met het echte antwoord, wat niet was wat ik verwachtte: die check was nooit een afstandsbeperking. De inactieve tweeling wordt in de map zelf als een standbeeld gestempeld, en twee standbeelden die op elkaar gestempeld worden, overschrijven elkaars tiles. De AI ging terug in de code, en ik heb de feature in de 1993-build verwijderd.

Ik maakte hetzelfde soort fouten zelf in 2010, maar dan langzaam, over maanden.

De release

Daarna volgde het release-werk: screenshots in vijf pixelmaten in elf talen, een preview-video, store-teksten, iconen in zes vormen, geüpload naar drie stores die over alles verschillen. Ik heb dit spel eerder uitgebracht, dus ik weet hoeveel avonden dat kost.

De screenshots komen uit de game zelf. De AI lanceert de game in de pixelmaat van de store, in de juiste taal, loopt met het personage naar een gekozen plek, maakt de foto en tekent vervolgens de caption-band met de eigen fonts van de game. AI rendert nooit de tekst in een store-image. De captions zijn echte fonts en vertaalde strings, anders wordt de afbeelding niet verzonden. Eén keer ging het mis, en de Russische en Koreaanse captions kwamen uit als rijen lege vakjes, wat ik zag in de output-folder voordat ik ze uploadde.

Mijn klacht over Steam is dat er veel velden in de formulieren staan, veel meer dan bij Apple of Google. Daarnaast is er geen API om metadata-updates makkelijk te maken. Voor iOS en Android zijn er proper API's, dus de AI gebruikte die. Steam heeft een web-dashboard, dus de AI stuurde de browser aan: store-pagina velden, achievements, de demo-checklist, artwork-uploads, klikken door Steamworks. Ik doe de login, en ik druk op alles wat verzendt, publiceert, prijst of release. De AI vult de formulieren in.

Het leest ook mijn reviews. De officiële Google Play API geeft alleen de laatste zeven dagen, wat nutteloos is voor een game met vijftien jaar aan reviews. De AI trok de rest binnen via een publieke scraper, taal voor taal. Daarna las het ze allemaal en maakte een lijst van welke reviews echte defecten beschreven. Ik keurde de lijst goed.

Een van deze reviews was een een-ster review op Google Play, met slechte spelling: "cant get through door on level one. opens but level dowsnt end"

Letterlijk gelezen is dit een bug-rapport, en het was correct. In mijn game zijn het openen van de uitgang en het erdoorheen lopen twee aparte acties, en de prompt die dat uitlegt bestaat in alle elf talen, maar is in slechts twee van mijn achttien levels geplaatst. Een van de levels waar hij in ontbrak, was het laatste gratis level. Dus de speler die besloot of dit spel het waard was om te kopen, stond voor een open deur zonder te weten wat hij moest doen, en concludeerde dat het spel kapot was.

Ik heb dit in vijftien jaar nooit ontdekt, en mijn testers of twee rebuilds ook niet. Het kostte een review van een vreemde. De fix ging eruit als 2.0.3 op beide stores. Ik bewaar die review.

De trampoline-bug

"De trampoline voelt te hoog." Dat was het hele rapport, van mij, terwijl ik 's nachts de build speelde. De constanten klopten: een simulatie van de integrator van het origineel voorspelde 19.1 tiles, en de build mat 19.5. De physics waren correct.

Het was de input-semantiek. De 2010-build was event-driven, en vanwege een workaround voor een tvOS-quirk die we jaren geleden hebben verschept, werd een ingedrukte sprongknop gelezen als "losgelaten" totdat je fysiek opnieuw drukte. Godot pollt input en bleef de "hold" rapporteren. Het reproduceren van dat ongeluk is precies wat ervoor zorgt dat de hoge bounce een nieuwe, goed getimede druk nodig heeft, wat is hoe het spel op een telefoon speelde, en wat mijn handen verwachtten.

De 2010-bron registreert dit niet, want vanuit het perspectief van de bron gebeurt er niets ongebruikelijks. Je had er fysiek bij moeten zijn, de telefoon in je hand, terwijl je een bug in een televisie omzeilde.

Het origineel, uitgebracht

Na dertig drie jaar is het volledige origineel nu gratis beschikbaar op itch.io. Start het in FS-UAE, WinUAE, of op echte hardware. De Definitive Edition is nu beschikbaar op iOS en Android, heeft een gratis demo op Steam, en de volledige Steam-release (Windows, Mac, Linux) komt dit najaar, met de game uit 1993 erin als tweede lanceeroptie.

De port werd gedaan door Claude Fable 5 in Claude Code; ik vroeg, speelde en besliste. Dit artikel is op dezelfde manier ontstaan. Ik gaf het mijn aantekeningen van de port, wat ik me herinner van de belangrijkste delen van het oude spel, en de repo's van zowel de Amiga-versie als de port, waarna het een eerste concept schreef. Ik heb een week besteed aan het regel voor regel bewerken ervan. De code, tijdstempels en screenshots zijn echt. Het deel waar ik het minst zeker van ben, zijn de 108 bytes: het model vertelde me dat de uitgebrachte bestanden een geheugen-snapshot waren, opgeslagen na een run, en dat de code die variabelen schrijft voordat hij ze leest. Ik las dat, ging verder, en heb het zelf nooit gecontroleerd.