Een Demoscene Intro schrijven voor de ZX Spectrum
Inleiding
Eind jaren 80 bracht ik een beschamend aantal uren door voorovergebogen over mijn ZX Spectrum. Ik typte Sinclair BASIC-programma's en rudimentaire assembler-routines over uit tijdschriften, in een poging de Amiga crack-intro's die ik bij een vriend had gezien na te bootsen. Natuurlijk kwamen ze in de verste verte niet in de buurt, maar nu, vele decennia later, is het me eindelijk gelukt!
Ik ben nu de trotse auteur van een handgeschreven retro demoscene-intro die draait op een standaard 128Kb Spectrum. Het bevat alle tropes uit de late jaren 80: een vloeiende scrolltext (met 'leet' aangepaste lettertypen!), een driekanaals AY-soundtrack, bewegende VU-meters en het essentiële parallax-sterrenveld. De code is momenteel 10.287 bytes, of 19.552 bytes als compleet tape-image. Aangezien ik hier sinds 13 december vorig jaar aan werk, is mijn gemiddelde productiesnelheid een schamele ~455 bytes per week!
Dit artikel is een technisch overzicht van hoe ik dit heb bereikt, enkele coole trucs die ik heb geleerd, en alles wat ik moest leren (en herleren) over de Speccy en Z80-assembly.
Terugblik naar de jaren '80
In de wereld van 8-bit computers die vanaf cassettes lieten laden, was het zien van een Amiga voor het eerst alsof je iets uit de ruimte tegenkwam. Ik was verbijsterd door de geweldige graphics en het geluid, maar ik raakte vooral gefascineerd door de mysterieuze "intro's" aan het begin van gepirateerde spellen.
Deze werden gemaakt door software-piraterijgroepen die de kopieerbeveiliging hadden gekraakt en de spellen verspreidden. Het waren mini-kunstwerken op zich: muziek, graphics en meestal een cool effect om de vaardigheden van de groep te tonen. Deze "cracktros" deelden bijna allemaal dezelfde designtropes: een edgy logo bovenaan het scherm, pronkzuchtige tekst bovenop een effect (meestal een scrollend parallax-sterrenveld of een roterend 3D-object) en een scroll-tekst vol met groeten aan mede-crackers en advertenties voor illegale BBS-systemen.
De demoscene is voortgekomen uit deze intro's. Het kraken verdween uiteindelijk naar de achtergrond, en coders, "graphicians" en muzikanten gingen over op het maken van volledige producties om de machine tot het uiterste te pushen. Hoewel ik die enorme vaardigheden bewonder, zijn het juist de vroege "single screen" cracktros met scrolly-text en sterrenvelden die ik altijd het meest fascinerend vond.
De Terugkeer naar Assembler
Ik ben in de loop der jaren overgestapt op diverse Amiga-systemen en heb zelf in enkele "scene"-groepen gezeten. Ik heb destijds met Z80-code gespeeld en kende genoeg 68k-assembly om me erdoorheen te slaan, mede door modules "embedded systems" op de universiteit. Omdat ik dit in mijn dagelijkse werk niet gebruik, was dit grotendeels uit mijn geheugen verdwenen.
Ik ben in mijn vrije tijd blijven werken aan projecten, waaronder mijn TNFS-site (een soort old-school BBS voor de Spectrum). Door contact met "Desertkun", die een nieuwe WiFi-netwerkkaart voor de Speccy produceert, werd ik geïnspireerd. Tijdens een videocall vroeg hij me waarom ik zelf geen Z80-assembly schreef. De uitdaging was geaccepteerd! Ik besloot een zelfstandige intro te maken om mijn site te promoten in de stijl van de vroege "BBStros".
Ontwikkelomgeving
Mijn ontwikkelomgeving was vrij eenvoudig:
- Build-systeem: Makefile-gestuurd.
- Assembler: Pasmo.
- BASIC-build: Een fork van
zmakebas. - Emulator: FUSE voor testen.
- Hardware: Mijn Spectrum +3 voor finale tests via mijn lokale TNFS-server.
Daarnaast gebruikte ik een web-gebaseerde "asm-editor" om Z80-instructies te refereren en voorbeeldcode stap voor stap uit te voeren (zoals de djnz instructie of het begrijpen van de carry-flag bij een add instructie).
Mijn belangrijkste debugging-tools waren echter een notitieblok, een pen en mijn twee honden. Omdat ik decennia lang in high-level talen heb geprogrammeerd, begreep ik mijn Z80-code vaak pas nadat ik de bytes en bitmasks had uitgetekend, tabellen van register-flags had bijgehouden en delen van het schermgeheugen handmatig had getekend.
Het Startpunt
Ik begon met een "Hello, world!" voorbeeld van futurice.com om mijn omgeving te valideren. Daarna wilde ik mijn logo en achtergrondafbeelding laden als "podium" voor de rest van de intro.
Een Spectrum SCREEN$ bestand is een dump van het schermgeheugen, beginnend bij byte 16384 en met een lengte van 6912 bytes. Om dit weer te geven, hoef je het bestand simpelweg naar die geheugenlocatie te laden.
Hier is het eenvoudige startpunt van mijn demo:
org $8000
screen EQU $4000 ; adres van de eerste byte van het schermgeheugen (16384 dec)
scrlen EQU 6912 ; totale lengte van het schermgeheugen incl. attributen
ula EQU $FE ; I/O poort voor de ULA om de border te besturen
black EQU 0 ; Spectrum kleur nummer
start:
ld a,black ; Stel de border kleur in op zwart via de ULA
out (ula),a
; Kopieer mijn logo header afbeelding naar het schermgeheugen
ld hl,header ; HL = locatie van mijn screen file in deze code
ld de,screen ; DE = start van schermgeheugen
ld bc,scrlen ; BC = 6912 bytes om te kopiëren
ldir ; blokkopie
hold:
halt ; slaap tot de volgende interrupt (1/50 seconde)
jr hold ; spring terug en slaap opnieuw
header:
incbin "assets/header.scr" ; mijn logo header/achtergrond
end start
Dit introduceerde me in belangrijke concepten zoals het aansturen van hardware via de out instructie, het importeren van binaire bestanden met INCBIN, de ldir instructie voor blokkopieën, en het gebruik van registerparen voor 16-bit adressering.
"Hello World"
Vervolgens heb ik gekeken naar de ingebouwde ROM-routines van de Spectrum, die vergelijkbaar zijn met syscalls in moderne besturingssystemen. Met behulp van referentie-documentatie (zoals skoolkid.github.io/rom/) leerde ik hoe ik tekst kon printen:
at EQU 22 ; "at" controle karakter voor cursor positie
row EQU 11 ; coördinaten voor de boodschap
col EQU 9
prstring EQU $203C ; locatie van de pr_string routine in de ROM
ld de,msg ; DE = adres van het eerste karakter
ld bc,msglen ; BC = aantal karakters om te printen
call prstring ; print de tekst
msg:
db at,row,col ; equivalent aan "PRINT AT 11,9" in BASIC
db "Hello, world!"
msglen EQU $-msg ; 16 bytes in totaal
Hoewel ik ROM-routines uiteindelijk weinig heb gebruikt omdat ze onnodige zaken meeslepen, was het essentieel om te begrijpen hoe ze werken. Zo laat de PR_STRING routine zien hoe je een loop over elk karakter bouwt.
Stijl Toevoegen
Het standaardlettertype van de Speccy heeft niet echt "demoscene-credibility". Ik zocht naar stijlvolle lettertypen en vond de collectie van Jim Blimey. Het aanpassen van het lettertype bleek eenvoudig: het is een kwestie van het overschrijven van het adres waar de ROM-printroutine het lettertype zoekt.
; CHARS is het adres dat de ROM-printroutine leest om het lettertype te vinden.
chars EQU $5C36
ld hl,myfont-256 ; HL = lettertype adres
ld (chars),hl ; vanaf nu tekent de ROM hiermee!
myfont:
include "textfont.asm" ; "Cartoon" lettertype van jimblimey.com
De Vervloekte Schermindeling
Het grootste probleem en de oorzaak van 90% van mijn frustratie is de vreemde schermindeling van de Spectrum. In plaats van een standaard bitmap-display, is het scherm verdeeld in drie secties met elkaar verweven (interleaved) rijen. Als je 1 optelt bij een schermadres, kom je niet bij de volgende rij, maar eindig je 8 rijen lager.
Daarna volgt een blok "attribuut"-bytes die de kleuren van elk 8x8 blok bepalen. Dit is de reden voor de beruchte "colour-clash": elke 8x8 cel kan slechts twee kleuren hebben (PAPER en INK).
Voor wie alleen bytes verschuift, biedt deze indeling echter enkele "cheap moves" om snel te navigeren:
| Beweging | Instructie |
|---|---|
| 8 pixels naar rechts | inc l |
| 8 pixels naar links | dec l |
| 1 pixel lijn omlaag | inc h |
| 1 pixel lijn omhoog | dec h |
| 1 karakter rij omlaag | add 32 to l |
Vanwege deze complexiteit is een routine die X,Y-coördinaten omzet naar geheugenadressen onmisbaar.
De Scroller
Zonder scrolltext is het geen echte intro. Omdat de Spectrum geen hardware-sprites of blitters heeft, moest ik alles handmatig doen door bits in het schermgeheugen te verschuiven.
Er is een handige truc: de RL instructie (rotate left). Deze roteert een byte naar links en plaatst de carry-flag in de meest rechtse bit. Door dit 32 keer te herhalen over een lijn, kun je een pixel (of set pixels) over het scherm bewegen.
linestart EQU $4880 ; meest linkse byte van lijn 12
lineend EQU linestart+31 ; meest rechtse byte kolom
linelen EQU 32 ; één hele lijn is 32 bytes breed
dot EQU %00000001 ; de "dot" vorm, één pixel aanrechterkant
newdot:
ld a,dot ; laad de dot vorm in a
ld (lineend),a ; zet hem op de meest rechtse positie
frame:
halt ; wacht op volgende frame
ld hl,lineend ; hl = laatste byte van de lijn
ld b,linelen ; loop counter
or a ; wis de carry flag
shift:
rl (hl) ; roteer byte naar links
dec hl ; volgende byte naar links
djnz shift ; herhaal tot b=0
jr c,newdot ; als carry set is, is de dot van het scherm
jr frame ; anders doorgaan met verschuiven
Om een echte scrolltext te maken waarbij letters vloeiend verschijnen, moest ik elk karakter pixel voor pixel instreamen met behulp van de lettertype-data. Ik gebruikte de truc add a,a (wat sneller is dan sla) om pixels in de carry-flag te duwen en deze vervolgens via de RL truc in het schermgeheugen te plaatsen. Om een simpele boodschap te scrollen, moet de CPU dus elke frame 256 pixels opnieuw tekenen.
Muziek
Een goede intro heeft muziek nodig. Op de Amiga was het "tracker"-formaat de standaard. Een nummer bestaat uit patterns met instructies voor toonhoogte, sample-nummer, volume en effecten.
De ZX Spectrum 128K gebruikt de AY-3-8912 soundchip. Hoewel deze simpel is, maakt hij meerkanaals "chip music" mogelijk.
De Conversie
Ik wilde een Amiga-module porten: "Milkman" door Glue Master. Het was een ideale track omdat hij kort is en 4 kanalen konden worden teruggebracht naar de 3 kanalen van de Spectrum.
Ik gebruikte Vortex Tracker II om de conversie te maken. Een grote uitdaging was om het geluid zo dicht mogelijk bij het origineel te brengen. Hierbij maakte ik gebruik van "ornaments". Een ornament (bijv. 0,4) laat een kanaal zeer snel wisselen tussen twee noten (25 keer per seconde). Hierdoor klinkt het alsof er twee noten tegelijk spelen, waardoor je volledige akkoorden kunt simuleren met slechts twee kanalen. Dit geeft Spectrum-muziek ook dat karakteristieke "warbelende" geluid.
Weergave
Voor de weergave gebruikte ik een pre-compiled binary player (VTII10bG) die op adres $C000 moet worden geladen. Mijn hoofdloop ziet er als volgt uit:
player EQU $C000 ; hard-coded adres van de binary player
plinit EQU player+3 ; init functie, module adres in hl
plplay EQU player+5 ; speel één frame
ld hl,moduleimg
call plinit
hold:
halt
call plplay
call scroll
jr hold
moduleimg:
incbin "assets/music.pt3"
Sterrenveld
Geen cracktro is compleet zonder sterrenveld. Mijn eerste poging was om willekeurige sterren te plaatsen en hele rijen te verschuiven met de RL truc. Dit veroorzaakte echter twee problemen: het "smeerde" andere graphics uit en het was veel te traag.
Ik stapte over op het bijhouden van individuele sterren. Om rekenkracht te besparen, gebruikte ik geen Random Number Generator in assembly, maar een tabel met vooraf berekende X/Y-posities, gegenereerd door een Ruby-script:
#!/usr/bin/env ruby
PAIRS = 32
PER_LINE = 4
Y_RANGE = 40..175 # sluit rijen uit van logo & balken
X_RANGE = 0..255
ys = Y_RANGE.to_a.sample(PAIRS)
pairs = ys.map { |y| [y, rand(X_RANGE)] }
pairs.each_slice(PER_LINE) do |row|
body = row.map { |y, x| format('%3d,%3d,', y, x) }.join(' ')
puts "defb #{body.chomp(',')}"
end
In plaats van X/Y-coördinaten op te slaan, bewaarde ik per ster het directe schermadres (STSCR), een pointer naar een bezettings-tabel (STOCC), het bitpatroon (STMASK) en de snelheid (STSTEP).
De collision-check (om te voorkomen dat sterren over tekst heen tekenen) gebeurt via de bezettings-tabel:
ld l,(ix+STOCC)
ld h,(ix+STOCC+1)
ld a,(hl) ; A = bezetting van deze cel
or a ; test A op nul
jr nz,stmove ; bezet, laat het scherm ongemoeid
Parallax wordt bereikt door elke ster een verschillend aantal pixels per frame te laten bewegen.
Diversen
VU-Meters
Ik voegde springende meterbalken toe die reageren op de audio. Dit deed ik door de amplitude-registers (R8, R9, R10) van de AY-chip uit te lezen via de in instructie:
ayread:
ld bc,$FFFD ; Register select poort
out (c),a
in a,(c)
ret
Twinkle, Twinkle, Little Star
Voor het logo maakte ik een "twinkel"-effect. Ik koos vier sterren in de achtergrond van het logo en animeerde deze. Voor de pseudo-random volgorde gebruikte ik een hack waarbij ik willekeurige bytes uit het begin van de Spectrum ROM las.
twkrand:
push hl
ld hl,(twkseed)
ld a,h
and 31 ; houd het binnen de eerste 8k van de ROM
ld h,a
ld a,(hl) ; de "random" byte
inc hl
ld (twkseed),hl
pop hl
ret
Geheugentekort
Halverwege het project raakte ik door mijn geheugen heen. Omdat ik alle assets in één blok code stopte en deze met ldir kopieerde, nam elk blok effectief dubbele ruimte in. De oplossing was om de assets te splitsen en een multi-stage BASIC-loader te gebruiken:
10 CLEAR 32767
20 BORDER 0: PAPER 0: INK 0: CLS
30 LOAD "" CODE
40 LOAD "" CODE
50 LOAD "" CODE
60 RANDOMIZE USR 32768
De Zen van Z80
Dit project was een vreemde ervaring. Enerzijds heb ik een jeugddroom vervuld en heb ik een binair bestand waarbij ik precies weet wat elke byte doet. Anderzijds weet ik nu waarom ik niet in assembly programmeer: ik koester mijn mentale gezondheid!
Om dit te schrijven, moet je denken als de machine. Je moet registers, status-flags en graphics als lijnen van bits visualiseren. Het heeft me een grotere hechting gegeven aan de hardware van mijn jeugd, maar ik heb geen haast om mijn huidige werk in assembly te herschrijven.
Ik heb enorme bewondering voor de coders uit de jaren 80 die dit allemaal door experimenten hebben uitgevonden, zonder de online tutorials en GitHub-repositories die ik nu tot mijn beschikking had.
Groetjes,