As I was happily writing the article for Viboritas, I noticed a game called Cubos sitting along the same disk but I couldn't remember what was it about.
A few days later when I added the missing drawings for Karateka, I noticed I had a full draft of the screen for Cubos. And again, I started remembering things and fixing bugs from more than 30 years ago.
Televideo
The year was 1986, and my father rebuilt a Televideo PC from parts. We had a few floppy disks, and one of the games was J-Bird by Greg Kuperberg. It displayed a bird jumping on a pyramid built of cubes, and you need to fill every cube. In advanced levels, you were required to jump several times over the same cube to get the right color while avoiding balls and snakes.
Of course, I'm describing Q*Bert, but at the time I didn't know it, and it was a novel concept for me. It took me years to discover that J-Bird was a clone of the arcade.
I played a lot of J-Bird and other early PC games that year. I remember being mesmerized by the pseudo-3D look but I had no idea how to get that effect on my games.
However, already I had drawn the following idea for my own cube-jumping game:
Early draft of Cubos on my notebook.
Early draft of player sprite for Cubos.
You can see my kid's mind hasn't yet figured that the pyramid could be easily constrained to the borders of the squares (i.e. the character grid on the screen), and doing that I could have figured that the whole pyramid could be drawn using predefined characters. My idea was that the cassette man was fighting again the evil disks, and the snake would be a joystick.
J-Bird was too advanced for my knowledge at the time, so the idea stood there for some years until early 1991 when I was age 12.
The game
The binary comes from a disk I used while I worked on one of the homebrew computers built by my father. The disk per se doesn't have an operating system, instead, it showed up a menu to choose games, and it commanded the disk subroutines to retrieve a track of the disk and load it at the address $8000.
I worked directly in machine code using a monitor program, so one of the first steps is disassembling the full game. In order to make it to work on a MSX or Colecovision we'll reuse the translation layer I made for my first Z80 game.
;
; Cubos
;
; by Oscar Toledo G.
; https://nanochess.org/
;
; Creation date: Early 1991. I was age 12.
; Revision date: Feb/12/2024. Ported to MSX/Colecovision.
;
COLECO: EQU 1 ; Define this to 0 for MSX, 1 for Colecovision
RAM_BASE: EQU $E000-$7000*COLECO
VDP: EQU $98+$26*COLECO
PSG: EQU $FF ; Colecovision
PSG_ADDR: EQU $A0 ; MSX
PSG_DATA: EQU $A1 ; MSX
KEYSEL: EQU $80
JOYSEL: EQU $C0
JOY1: EQU $FC
JOY2: EQU $FF
if COLECO
fname "cubos_cv.ROM"
org $8000,$9fff
dw $aa55 ; No BIOS title screen
dw 0
dw 0
dw 0
dw 0
dw START
jp 0 ; RST $08
jp 0 ; RST $10
jp 0 ; RST $18
jp 0 ; RST $20
jp 0 ; RST $28
jp 0 ; RST $30
jp 0 ; RST $38
jp 0 ; No NMI handler
else
fname "cubos_msx.ROM"
org $4000,$5fff
dw $4241
dw START
dw 0
dw 0
dw 0
dw 0
dw 0
dw 0
WRTPSG: equ $0093
SNSMAT: equ $0141
endif
WRTVDP:
ld a,b
out (VDP+1),a
ld a,c
or $80
out (VDP+1),a
ret
SETWRT:
ld a,l
out (VDP+1),a
ld a,h
or $40
out (VDP+1),a
ret
SETRD:
ld a,l
out (VDP+1),a
ld a,h
and $3f
out (VDP+1),a
ret
WRTVRM:
push af
call SETWRT
pop af
out (VDP),a
ret
RDVRM:
push af
call SETRD
pop af
ex (sp),hl
ex (sp),hl
in a,(VDP)
ret
FILVRM:
push af
call SETWRT
.1: pop af
out (VDP),a
push af
dec bc
ld a,b
or c
jp nz,.1
pop af
ret
; Setup VDP before game
setup_vdp:
LD BC,$0200
CALL WRTVDP
LD BC,$8201 ; No interrupts, disable screen.
CALL WRTVDP
LD BC,$0602 ; $1800 for pattern table
CALL WRTVDP
LD BC,$FF03 ; $2000 for color table
CALL WRTVDP
LD BC,$0304 ; $0000 for bitmap table
CALL WRTVDP
LD BC,$3605 ; $1b00 for sprite attribute table
CALL WRTVDP
LD BC,$0706 ; $3800 for sprites bitmaps
CALL WRTVDP
LD BC,$0107 ; Black border
CALL WRTVDP
LD HL,$0000
LD BC,$1800
XOR A
CALL FILVRM
LD HL,$2000
LD BC,$1800
LD A,$F1
CALL FILVRM
LD HL,$1B00
LD A,$D0
CALL WRTVRM
LD HL,$1800
.1:
LD A,L
CALL WRTVRM
INC HL
LD A,H
CP $1B
JP NZ,.1
LD BC,$C201 ; No interrupts, enable screen
CALL WRTVDP
RET
;
; Gets the joystick direction
; 0 - No movement
; 1 - Up
; 2 - Up + right
; 3 - Right
; 4 - Right + down
; 5 - Down
; 6 - Down + left
; 7 - Left
; 8 - Left + Up
;
GTSTCK:
if COLECO
out (JOYSEL),a
ex (sp),hl
ex (sp),hl
in a,(JOY1)
ld b,a
in a,(JOY2)
and b
and $0f
ld c,a
ld b,0
ld hl,joy_map
add hl,bc
ld a,(hl)
ret
joy_map:
db 0,0,0,6,0,0,8,7,0,4,0,5,2,3,1,0
else
xor a
call $00d5
or a
ret nz
ld a,1
call $00d5
or a
ret nz
ld a,2
jp $00d5
endif
; ROM routines I forgot
; Select address or register in VDP
L0100:
LD A,L
OUT (VDP+1),A
LD A,H
ADD A,$40
OUT (VDP+1),A
RET
L0309:
CALL SETWRT
.1:
LD A,(DE)
OUT (VDP),A
INC DE
DEC BC
LD A,B
OR C
JP NZ,.1
RET
L0099: EQU setup_vdp
L0AE8: ; ???
RET
L0109: ; ???
RET
L0454: EQU RDVRM
L0447: EQU WRTVRM
START:
LD SP,STACK
The disassembled game goes up to 1292 assembler lines. Each label is named after its original address in the game. We'll need to follow the code along several labels because my heavily patched style of programming at the time.
The game uses only 4 bytes of RAM to keep work data, because I took a terrible design decision of keeping the X,Y coordinates of the player and enemies in the VRAM and this takes a lot more code than having these directly in normal RAM.
The call to L836A appears to initialize three variables, two of these to zero, and the other to one, then it ends by calling L0099 and L0AE8, fortunately, I remember these were used to set the high-resolution video mode. Well, at least L0099, because I don't remember the function of L0AE8.
For some reason, I didn't notice that L8003 again calls L0099 to setup the VDP, and it proceeds to clean the color space with white and black. My love for MSX is shown here using the port $98 to write data to the VDP, but instead, it was $90 in my father's computer, and for this game port I'm going to replace this with the label VDP to be able to assemble this game both for Colecovision and MSX.
Next, it puts the border as black color by calling L0100 at L86E0.
L86E0: CALL L0100
LD DE,L8700
LD B,$12
L86E8: LD A,(DE)
LD H,A
INC DE
LD A,(DE)
LD L,A
INC DE
PUSH BC
LD B,$08
L86F1: LD A,(DE)
CALL L0447
INC HL
INC DE
DJNZ L86F1
POP BC
DJNZ L86E8
JP L87B4
L8700:
db $00,$00,$00
db $00,$00,$00,$03 ; $8703
db $03,$0F,$0F,$00 ; $8707
db $08,$07,$3F,$FF ; $870B
db $FF,$FF,$FF,$FF ; $870F
db $FF,$00,$10,$C0 ; $8713
db $F6,$FF,$F3,$FF ; $8717
db $FC,$FF,$F0,$00 ; $871B
db $18,$00,$00,$00 ; $871F
db $00,$60,$C0,$D0 ; $8723
db $50,$01,$00,$0F ; $8727
db $0F,$0F,$0F,$0F ; $872B
db $0F,$0E,$0E,$01 ; $872F
db $08,$FF,$FF,$FF ; $8733
db $FF,$C3,$00,$00 ; $8737
db $00,$01,$10,$F5 ; $873B
db $FC,$E5,$CD,$FE ; $873F
db $00,$00,$00,$01 ; $8743
db $18,$A0,$50,$A0 ; $8747
db $60,$50,$60,$70 ; $874B
db $60,$02,$00,$0E ; $874F
db $0E,$0E,$0E,$07 ; $8753
db $06,$04,$04,$02 ; $8757
db $08,$0F,$31,$C1 ; $875B
db $3D,$4F,$3D,$01 ; $875F
db $01,$02,$10,$0F ; $8763
db $30,$C0,$3C,$4E ; $8767
db $3C,$00,$00,$02 ; $876B
db $18,$A0,$A0,$50 ; $876F
db $40,$60,$80,$C0 ; $8773
db $C0,$03,$00,$03 ; $8777
db $03,$03,$03,$03 ; $877B
db $01,$01,$01,$03 ; $877F
db $08,$02,$0C,$04 ; $8783
db $03,$00,$00,$00 ; $8787
db $03,$03,$10,$00 ; $878B
db $60,$80,$00,$00 ; $878F
db $00,$00,$C0,$03 ; $8793
db $18,$40,$80,$40 ; $8797
db $80,$80,$00,$80 ; $879B
db $00,$04,$08,$07 ; $879F
db $03,$00,$80,$C0 ; $87A3
db $F0,$FE,$FF,$04 ; $87A7
db $10,$E0,$C0,$00 ; $87AB
db $01,$03,$0F,$7F ; $87AF
db $FF ; $87B3
It draws a series of eighteen characters on the screen taking a VRAM address and the 8 bytes of data. For some reason, I decided to keep the target VRAM address in big-endian order instead of the more common little-endian order used in Z80.
This draws a bitmap face on the top left of the screen. I think this is something I added after getting the game to work because of the memory address.
Face bitmap drawn by Cubos.
It then repeats the same code to draw the same face on the right side of the screen.
L87B4: LD DE,L87D0 ; Pointer to bitmap data for face.
LD B,$12 ; 18 characters.
L87B9: LD A,(DE) ; Read target VRAM address.
LD H,A
INC DE
LD A,(DE)
LD L,A
INC DE
PUSH BC
LD B,$08 ; 8 bytes.
L87C2: LD A,(DE) ; Read byte.
CALL L0447 ; Copy to VRAM.
INC HL
INC DE
DJNZ L87C2
POP BC
DJNZ L87B9 ; Repeat until all characters are copied.
JP L8884
L87D0: db $00,$E0,$00,$00 ; $87D0
db $00,$00,$03,$03 ; $87D4
db $0F,$0F,$00,$E8 ; $87D8
db $07,$3F,$FF,$FF ; $87DC
db $FF,$FF,$FF,$FF ; $87E0
db $00,$F0,$C0,$F6 ; $87E4
db $FF,$F3,$FF,$FC ; $87E8
db $FF,$F0,$00,$F8 ; $87EC
db $00,$00,$00,$00 ; $87F0
db $60,$C0,$D0,$50 ; $87F4
db $01,$E0,$0F,$0F ; $87F8
db $0F,$0F,$0F,$0F ; $87FC
db $0E,$0E,$01,$E8 ; $8800
db $FF,$FF,$FF,$FF ; $8804
db $C3,$00,$00,$00 ; $8808
db $01,$F0,$F5,$FC ; $880C
db $E5,$CD,$FE,$00 ; $8810
db $00,$00,$01,$F8 ; $8814
db $A0,$50,$A0,$60 ; $8818
db $50,$60,$70,$60 ; $881C
db $02,$E0,$0E,$0E ; $8820
db $0E,$0E,$07,$06 ; $8824
db $04,$04,$02,$E8 ; $8828
db $0F,$31,$C1,$3D ; $882C
db $4F,$3D,$01,$01 ; $8830
db $02,$F0,$0F,$30 ; $8834
db $C0,$3C,$DF,$3C ; $8838
db $00,$00,$02,$F8 ; $883C
db $A0,$A0,$50,$40 ; $8840
db $60,$80,$C0,$C0 ; $8844
db $03,$E0,$03,$03 ; $8848
db $03,$03,$03,$01 ; $884C
db $01,$01,$03,$E8 ; $8850
db $02,$0C,$04,$03 ; $8854
db $00,$00,$00,$03 ; $8858
db $03,$F0,$00,$60 ; $885C
db $80,$00,$00,$00 ; $8860
db $00,$C0,$03,$F8 ; $8864
db $40,$80,$40,$80 ; $8868
db $80,$00,$80,$00 ; $886C
db $04,$E8,$07,$03 ; $8870
db $00,$80,$C0,$F0 ; $8874
db $FE,$FF,$04,$F0 ; $8878
db $E0,$C0,$00,$01 ; $887C
db $03,$0F,$7F,$FF ; $8880
I could have saved more than 200 bytes just by making it a subroutine and keeping an offset to repeat the drawing. I think I wanted to put a different face on the right of the screen, but I never made it.
L8884: LD HL,$2000
LD DE,L88A9
LD B,$05
L888C: PUSH BC
LD B,$04
L888F: PUSH BC
LD B,$08
L8892: LD A,(DE)
CALL L0447
INC HL
DJNZ L8892
INC DE
POP BC
DJNZ L888F
LD A,H
INC A
LD H,A
LD A,$00
LD L,A
POP BC
DJNZ L888C
JP L88BD
L88A9: db $1A,$1A,$1A,$1A ; $88A9
db $1A,$16,$18,$1A ; $88AD
db $1A,$16,$18,$1A ; $88B1
db $1A,$16,$18,$1A ; $88B5
db $1A,$16,$18,$1A ; $88B9
Now it proceeds to put color to the face. It looks like finally I figured out how to go over a rectangle of the screen as you can see LD B,$05 to draw five rows, then LD B,$04 to draw four columns, and finally LD B,$08 to repeat the color for a whole 8x8 character.
Any experienced Z80 coders will cringe looking at the whole five assembler lines that could have been replaced with INC H / LD L,0.
Face bitmap with color drawn by Cubos.
Then I repeated almost the same code to give color to the face at the right:
L88BD: LD HL,$20E0 ; Point to the color data (right side)
LD DE,L88A9 ; Color data (reused)
LD B,$05 ; 5 rows.
L88C5: PUSH BC
LD B,$04 ; 4 columns.
L88C8: PUSH BC
LD B,$08 ; 8x8 pixels.
L88CB: LD A,(DE) ; Color data.
CALL L0447 ; Write to VRAM.
INC HL
DJNZ L88CB
INC DE
POP BC
DJNZ L88C8
LD A,$E0 ; Prepare for next row.
LD L,A
POP BC
DJNZ L88C5
JP L8984
At least this time I reused the color data at L88A9. The linked code continues:
Because of the value $f1, I can distinguish that LEF13 contains a color code (white + black). The values in HL and DE look strange, nothing like VRAM addresses, or RAM addresses, but we have repeated values, and of course, this is calling a line drawing subroutine in ROM (H=y1, L=x1, D=y2, E=x2)
I remember vaguely that it was in early 1991 when finally I had a line-drawing subroutine in the ROM of my father's computer.
It draws four squares and connects these with diagonal lines. On the inside, it draws letters to represent the keys T, Y, G, and H. A little visual manual.
Key map drawn by Cubos.
Just a tiny caveat: I don't have the ROM for this computer, so for this game I had to code yet-another Bresenham line algorithm in Z80.
;
; Draw colored pixel
; D = Y-coordinate.
; E = X-coordinate.
;
L12DA:
call coor2vdp ; Get coordinate and bitmask.
ld c,a ; Save bitmask.
call RDVRM ; Read VRAM.
or c ; Set pixel.
call WRTVRM ; Write VRAM.
set 5,h ; Go to color area.
ld a,(LEF13) ; Get current color.
jp WRTVRM ; Set color for 8 pixels.
;
; Bresenham line drawing
; by Oscar Toledo G.
; Feel free to use it in your own projects.
; Just put my credit somewhere.
;
; H = Y1 coordinate.
; L = X1 coordinate.
; D = Y2 coordinate.
; E = X2 coordinate.
;
L1500:
ld a,d
sub h
ld b,1
jr nc,$+6
ld b,-1
neg
exx
ld b,a ; dy = abs(y2 - y1)
exx
ld a,e
sub l
ld c,1
jr nc,$+6
ld c,-1
neg
exx
ld c,a ; dx = abs(x2 - x1)
exx
exx
ld a,c
cp b ; dx >= dy?
jr c,.7 ; No, jump.
ld l,b
ld h,0
add hl,hl ; 2*dy
ld e,c
ld d,0
sbc hl,de ; -dx
exx
.1:
push bc
push de
push hl
ex de,hl
call L12DA ; Draw a colored pixel on the screen.
pop hl
pop de
pop bc
ld a,h
cp d
jr nz,.2
ld a,l
cp e ; Reached endpoint?
ret z ; Yes, return.
.2: exx
bit 7,h
exx
jr nz,.5
ld a,h
add a,b ; Displace Y-coordinate in Y direction.
ld h,a
exx
ld e,c
ld d,0
sla e
rl d
sbc hl,de ; d -= dx * 2
exx
.5: ld a,l
add a,c ; Displace X-coordinate in X direction.
ld l,a
exx
ld e,b
ld d,0
sla e
rl d
add hl,de ; d += dy * 2
exx
jr .1
.7:
ld l,c
ld h,0
add hl,hl ; 2*dx
ld e,b
ld d,0
sbc hl,de ; -dy
exx
.3:
push bc
push de
push hl
ex de,hl
call L12DA ; Draw a colored pixel on the screen.
pop hl
pop de
pop bc
ld a,h
cp d
jr nz,.4
ld a,l
cp e ; Reached endpoint?
ret z ; Yes, return.
.4: exx
bit 7,h ; d < 0?
exx
jr nz,.6 ; Yes, jump.
ld a,l
add a,c ; Displace X-coordinate in X direction.
ld l,a
exx
ld e,b
ld d,0
sla e
rl d
sbc hl,de ; d -= dy * 2
exx
.6: ld a,h
add a,b ; Displace Y-coordinate in Y direction.
ld h,a
exx
ld e,c
ld d,0
sla e
rl d
add hl,de ; d += dx * 2
exx
jr .3
The code ended with the last line drawn, but somehow I decided to insert yet more code. This time to show the enemy for level 1.
L8B97: CALL L1500
LD HL,$07D0
LD DE,L8270
CALL L8BA6
JP L8BE2
L8BA6: LD B,$08
L8BA8: LD A,(DE)
CALL L8C06
INC HL
INC DE
DJNZ L8BA8
LD A,L
LD B,$08
SUB B
LD L,A
LD A,H
INC A
LD H,A
LD B,$08
L8BBA: LD A,(DE)
CALL L8C06
INC HL
INC DE
DJNZ L8BBA
LD A,H
DEC A
LD H,A
LD B,$08
L8BC7: LD A,(DE)
CALL L8C06
INC HL
INC DE
DJNZ L8BC7
LD A,L
LD B,$08
SUB B
LD L,A
LD A,H
INC A
LD H,A
LD B,$08
L8BD9: LD A,(DE)
CALL L8C06
INC HL
INC DE
DJNZ L8BD9
RET
L8C06: CALL L0447
PUSH HL
PUSH DE
LD DE,$2000
ADD HL,DE
LD A,$C1
AND $F0
OR $01
CALL L0447
POP DE
POP HL
RET
L8270:
db $03,$0F,$1F,$30 ; $8270
db $37,$69,$6F,$6C ; $8274
db $6B,$37,$30,$DF ; $8278
db $CF,$63,$60,$90 ; $827C
db $C0,$F0,$F8,$0C ; $8280
db $EC,$96,$F6,$36 ; $8284
db $D6,$EC,$0C,$FB ; $8288
db $F3,$C6,$06,$09 ; $828C
The register HL points to the coordinate to draw the enemy, and the register DE points to the bitmap for the enemy. The enemy bitmap is designed for a sprite, so the subroutine L8BA6 reads the sprite as four separate characters to display (you can see the four separate LD B,$08).
I think I wanted the first enemy to look like a lion, but instead, it looked like a monster with a coat... a big coat.
When writing the graphic byte it also sets the color to dark green using the L8C06 subroutine. This subroutine loads the color into the A register ($c1 for dark green plus black background) and then does a crazy operation... to get the same value.
L8BE2: LD HL,$0AD0
LD DE,L85F8
CALL L8BA6
LD HL,$0DD0
LD DE,L8620
CALL L8BA6
LD HL,$10D0
LD DE,L8648
CALL L8BA6
LD HL,$13D0
LD DE,L8670
JP L8BA6
L85F8: db $06
db $0F,$07,$05,$07 ; $85F9
db $03,$01,$0F,$1B ; $85FD
db $1B,$1B,$03,$06 ; $8601
db $06,$06,$0E,$60 ; $8605
db $F0,$E0,$A0,$E0 ; $8609
db $C0,$80,$F0,$D8 ; $860D
db $D8,$D8,$C0,$60 ; $8611
db $60,$60,$70 ; $8615
L8620:
db $0F,$3F
db $09,$39,$0F,$3F ; $8622
db $0B,$3C,$0F,$3F ; $8626
db $0F,$3F,$0F,$3F ; $862A
db $0F,$3F,$F0,$FC ; $862E
db $90,$9C,$F0,$FC ; $8632
db $D0,$3C,$F0,$FC ; $8636
db $F0,$FC,$F0,$FC ; $863A
db $F0,$FC ; $863E
L8648:
db $01,$03
db $05,$07,$0F,$01 ; $864A
db $3F,$77,$77,$73 ; $864E
db $73,$F3,$0F,$06 ; $8652
db $06,$0E,$80,$C0 ; $8656
db $A0,$E0,$F0,$80 ; $865A
db $FC,$EE,$EE,$CE ; $865E
db $CE,$CF,$F0,$60 ; $8662
db $60,$70 ; $8666
L8670:
db $81,$41
db $21,$11,$09,$05 ; $8672
db $03,$FE,$03,$05 ; $8676
db $09,$11,$21,$41 ; $867A
db $81,$00,$02,$04 ; $867E
db $08,$10,$20,$40 ; $8682
db $80,$FF,$80,$40 ; $8686
db $20,$10,$08,$04 ; $868A
db $02,$00 ; $868E
Surely I thought that it would be nice to show all the enemies that can appear, so I repeated the calls to L8BA6 with the pointers to the graphics of the other enemies.
Enemies appearing in Cubos.
The enemy for the second level is a chef (probably my homage to Burgertime), the third one is the Z80 chip, the fourth one is an executioner (something related to the guillotine), and the fifth one is a star, maybe a symbol of the omega, the end of everything, or more clearly I was lacking ideas for further enemies.
After all this epic chain patched code, it returns to where it is called L86E0, and sets DE with $1080 and A with $05 and calls L8027.
L8027: LD B,A
L8028: PUSH BC
LD B,$06
L802B: NOP
CALL L80FD
NOP
INC D
NOP
DEC E
DJNZ L802B
LD B,$0C
L8037: PUSH DE
CALL L09B9
POP DE
INC D
DJNZ L8037
POP BC
DJNZ L8028
RET
L80FD: PUSH DE
CALL L09B9
POP DE
DEC E
PUSH DE
CALL L09B9
POP DE
RET
This subroutine draws 5 steps going down and to the left at the coordinate given in DE (D is the Y-coordinate, E is the X-coordinate). Each pixel is drawn using L09B9. The line going to left is drawn as a sloped line with two pixels each time.
I can remember vividly when I coded the pixel drawing subroutine, I cannot remember the exact details, but for this article I coded a pixel coordinate calculator (coor2vdp), a write pixel subroutine, an erase pixel subroutine, and a read pixel subroutine.
;
; Convert an X,Y coordinate to a bitmap VRAM address.
; D = Y-coordinate.
; E = X-coordinate.
;
; HL = Final VRAM address.
; A = Pixel bitmask.
;
coor2vdp:
ld a,d
rrca
rrca
rrca
and $1f
ld h,a
ld a,e
and $f8
ld l,a
ld a,d
and $07
or l
ld l,a
ld a,e
and $07
add a,pixel and 255
ld e,a
adc a,pixel>>8
sub e
ld d,a
ld a,(de)
ret
pixel: db $80,$40,$20,$10,$08,$04,$02,$01
; Set pixel.
L09B9:
call coor2vdp
ld c,a
call RDVRM
or c
call WRTVRM
RET
; Erase pixel.
L09E4: call coor2vdp
cpl
ld c,a
call RDVRM
and c
jp WRTVRM
; Test pixel.
L09FC: call coor2vdp
ld c,a
call RDVRM
and c
RET
First 'stair' drawn in Cubos.
The code continues at L8043 and does a call to a similar subroutine.
L8043: LD DE,$1674
LD A,$05
CALL L804E
JP L806A
L804E: LD B,A
L804F: PUSH BC
LD B,$06
L8052: NOP
CALL L8109
NOP
INC D
NOP
INC E
DJNZ L8052
LD B,$0C
L805E: PUSH DE
CALL L09B9
POP DE
INC D
DJNZ L805E
POP BC
DJNZ L804F
RET
L8109: PUSH DE
CALL L09B9
POP DE
INC E
PUSH DE
CALL L09B9
POP DE
RET
The subroutine L804E does exactly the same thing as L8027, except the sloped line is drawn going to the right.
Second 'stair' drawn in Cubos.
L806A: LD B,$0C
L806C: PUSH DE
CALL L09E4
POP DE
DEC D
DJNZ L806C
LD DE,$1080
LD A,$05
CALL L804E
LD DE,$168C
LD A,$05
CALL L8027
NOP
NOP
NOP
JP L8095
Now it erases the last vertical line drawn (12 pixels). Then draws two internal ladder steps, one going to the left, and one going to the right.
Further two 'stairs' drawn in Cubos.
I continue drawing the pyramid using more patched code. A symptom of retesting and rearrangement.
At this point, the pyramid is only missing one diagonal line on each bottom side.
L8157: LD DE,$6A44
LD A,$01 ; Draw one step to the right.
CALL L804E
CALL L808A ; Erase last vertical line.
LD DE,$6ABC
LD A,$01
CALL L8027 ; Draw one step to the left.
CALL L808A ; Erase last vertical line.
This code finishes the pyramid, and it draws steps (one diagonal line + one vertical line) but on each one erases the vertical line to not leave dangling lines.
After this embarrassing code, the kid thought: Why not do the title letters with lines?
It sets the color to $74 (7 - Cyan foreground, 4 - Blue background) when doing this on the VDP it creates a line bleeding color, but I used it for a pseudo 3D effect.
Title letters for Cubos.
The lines show the title "CUBOS" (Spanish for cubes).
LD A,(LFF0D)
CP $01
JP NZ,L8290
NOP
LD HL,$3800
LD DE,L8250
LD BC,$0060
CALL L0309
JP L8290
L8250: db $03,$05,$07,$03 ; $8250
db $01,$3F,$EF,$EF ; $8254
db $C7,$C7,$03,$0F ; $8258
db $0C,$0C,$0C,$3C ; $825C
db $80,$40,$C0,$80 ; $8260
db $00,$F8,$EE,$EE ; $8264
db $C6,$C6,$80,$E0 ; $8268
db $60,$60,$60,$78 ; $826C
L8270:
db $03,$0F,$1F,$30 ; $8270
db $37,$69,$6F,$6C ; $8274
db $6B,$37,$30,$DF ; $8278
db $CF,$63,$60,$90 ; $827C
db $C0,$F0,$F8,$0C ; $8280
db $EC,$96,$F6,$36 ; $8284
db $D6,$EC,$0C,$FB ; $8288
db $F3,$C6,$06,$09 ; $828C
Now if the level is 1, it loads the full game sprites bitmaps into VRAM. However, the BC counter is for 3 sprites, and there is only data for 2 sprites. Never mind!
Now it sets the 16x16 pixels mode for sprites, and it puts the initial position for the player and two enemies. It does a small optimization to setup the VDP, calling L8918 to write to VRAM and also to increment the VDP address. The two enemies have a pseudorandom color generated from the R register of the Z80 (the DRAM refresh counter). Very interestingly, I apparently forgotten that I already read the R register, and inside L8C1B it copies against the value of R into A, and it extracts the lower 4-bit for the color; if the color is 0 or 1 (transparent or black) it reads again R until it gets a valid color.
Cubos after sprites are set up.
Player movement
We enter now the main loop, and the first thing handled is the player's movement.
This is a part where my young self disappointed my oldest self. I could distinguish immediately the ASCII codes for the letters T, Y, G, and H, meaning it is the main movement code, and replaced it with joystick reading (even in MSX where a keyboard exists), only to see that the game finished almost immediately.
After a brief analysis, I discovered the game does a few things and goes back to L82E0, so it played at a very high speed moving the enemies over the player, then I added a big delay, and this way I found that L02AA was a keyboard decoding subroutine that waits for the key. So this Cubos game is a turn-based game. Once you type a key, the computer answers back.
So I had to code this joystick reading routine that blocks until a movement is done.
; Read keyboard
L02AA:
.0:
ld bc,$0800 ; Delay.
dec bc
ld a,b
or c
jr nz,$-3
xor a ; Read keyboard movement.
call GTSTCK
or a
jr z,.2
ld a,1 ; Read joystick 1 movement.
call GTSTCK
.2:
ld b,a
ld a,(debounce)
or a ; Is it debouncing?
jr z,.1 ; No, jump.
dec a ; Yes, countdown.
ld (debounce),a
ld b,$ff ; No movement accepted.
.1:
ld a,$00
dec b ; 1-Up?
jr nz,$+4
ld a,$59 ; ASCII Y
dec b
dec b ; 3-Right?
jr nz,$+4
ld a,$48 ; ASCII H
dec b
dec b ; 5-Down?
jr nz,$+4
ld a,$47 ; ASCII G
dec b
dec b ; 7-Left?
jr nz,$+4
ld a,$54 ; ASCII T
or a ; Any key pressed?
jr z,.0 ; No, jump and wait.
push af
ld a,15 ; Start debouncing delay.
ld (debounce),a
pop af
RET
Each of the four movement subroutines reads the player sprite X,Y coordinates, displaces these by the right amount of pixels (a combination of 17 pixels to the left or right, and 12 pixels upward or downward), and saves the new X,Y coordinates in the VRAM. It jumps to L8311 and reads the new X,Y coordinates, calling L8345 to determine if a new pyramid square is filled.
L8345: PUSH DE
CALL L09FC
POP DE
PUSH AF
CALL L09B9
POP AF
RET NZ
JP L8371
L8371: LD HL,LFF0F
INC (HL)
RET
To determine if a new pyramid cube is filled, it calls L09FC to read the pixel under the D,E coordinate, and then it calls L09B9 to draw a pixel on the same position. It returns if the pixel was already filled, else it jumps to L8371 and increases a counter located at LFF0F (probably counting how many cubes are filled).
L86A8: INC E
CALL L869C
INC E
CALL L869C
INC E
CALL L869C
INC E
CALL L869C
INC D
NOP
CALL L869C
DEC E
CALL L869C
DEC E
CALL L869C
DEC E
CALL L869C
INC D
DEC E
CALL L869C
INC E
CALL L869C
INC E
CALL L869C
INC E
CALL L869C
RET
L869C: LD A,$F1
LD (LEF13),A
PUSH DE
CALL L12DA
POP DE
RET
It draws something similar to a footprint in the cube, pixel by pixel, and the L869C subroutine sets the color for the footprint and draws a colored pixel.
Now for the great embarrassing moment: It never checks if the target square of the player is a valid one! The player can easily walk over the sky and win without being bothered by the enemies. Sigh!
Enemy movement
After moving the player, it jumps to L82F7 to move the enemies:
In a terrifying demonstration of patched code, it does three chain-linked calls until it reaches L83A4 and if the player has filled 15 cubes of the pyramid then it has won the current level and jumps to L85C1 (later to be seen). Again, a patch jumps to L83EB and complements the contents of LFF0E, if it is $ff then it doesn't move the enemies in this turn (doing RET Z). This means the player can move two times before the enemies move. It jumps back to L83AF with HL set to $1b00 (VRAM sprite attribute data for player)
It gets the Y-coordinate for the player and the Y-coordinate for the enemy 1, and if the enemy is below the player, it goes upwards (SUB $11), else it goes downwards (ADD A,$11). It updates the enemy Y-coordinate in L83BF, and then jumps to L83CA.
L83CA gets the X-coordinate for the player and the X-coordinate for the enemy 1, and if the enemy is at the right of the player, it goes to the left (SUB $0C), else it goes to the right (ADD A,$0C). It updates the enemy X-coordinate in L83DD, and jumps to L8402.
The final result is that the code moves the enemy in a diagonal following the player.
And then the code is repeated shamelessly to move the second enemy.
The code ending jumps directly to L0447 and it uses a feature of the stack pointer where L0447 contains the RET instruction to get back to the calling point.
The third enemy
The game doesn't return directly to the calling point at L82F7, instead it jumps to L88E0.
L88E0: LD A,(LFF10)
CP $FE
JP Z,L8926
LD C,$7F
CALL L891D
NOP
NOP
NOP
LD HL,$1B0C
LD A,$08
CALL L0447
INC HL
LD A,$78
CALL L0447
INC HL
LD A,$04
CALL L0447
INC HL
LD A,$0E
JP L890F
L890F: CALL L0447
LD A,$FE
LD (LFF10),A
RET
L891D: CALL L8333
CP $40
RET NC
POP IX
RET
L8333: LD A,R
CP C
RET C
SRA A
CP C
RET C
PUSH BC
LD B,A
L833D: DJNZ L833D
POP BC
JR L8333
It first checks if the LFF10 variable is $fe to jump to L8926, but as it is initialized to zero, it sets C to $7f and calls L891D and further chain-linking to L8333 to check if a random number is greater than or equal to $40 to create a third enemy appearing at the top of the pyramid (the snake in Q*Bert), if it is less than $40 it pops the return address from the stack (POP IX) and returns without doing anything. This means the third enemy has a 50% chance of appearing at the top of the pyramid on each player's move.
As the value of the R register is compared against C containing $7f, it almost always returns unchanged at L8333 with RET C because R is $00-$7f, and the remaining of the code is only used if R is $7f in an amazing lack of understanding on my side of how R works.
The enemy is created at the top of the pyramid with the same sprite frame for enemies (a lost opportunity to show another type of enemy) and fixed gray color, it also chain-links to L890F to mark the enemy as initialized (setting LFF10 to $fe).
Third enemy movement
If the third enemy is present (LFF10 contains $fe), the code jumps to L8926.
L8926: LD HL,$1B0C
CALL L0454
ADD A,$11
CALL L8971
LD C,$7F
CALL L8333
CP $3F
JR C,L8945
LD HL,$1B0D
CALL L0454
SUB $0C
JP L894D
L8945: LD HL,$1B0D
CALL L0454
ADD A,$0C
L894D: CALL L0447
LD HL,$1B00
CALL L0454
LD B,A
LD HL,$1B0C
CALL L0454
CP B
RET NZ
LD HL,$1B01
CALL L0454
LD B,A
LD HL,$1B0D
CALL L0454
CP B
RET NZ
JP L8480
L8971: CALL L0447
CP $5D
RET NZ
LD A,$D1
CALL L0447
LD A,$00
LD (LFF10),A
POP IX
RET
The Y-coordinate for the third enemy is read from VRAM $1b0c and added $11 to make it to advance vertically, and if it reaches $5d (comparison at L8971), it is made to disappear by setting the Y-coordinate to $d1 (outside of the visual range), setting LFF10 to $00, and pops the return address (POP IX) and returns to the caller.
If it hasn't reached $5d, then it generates a random number and if it is less than $3f then it adds $0c to the X-coordinate, else it subtracts $0c to the X-coordinate, making it to oscillate horizontally in the pyramid.
In L894D it does a comparison of the Y-coordinate of the player against the Y-coordinate of the enemy, and if both are the same then it does the comparison of X-coordinates, and in case of collision it jumps to L8480.
Enemy collision
After completing the enemies and returning to the calling point at L82F7, it jumps to L843E.
This code is looking like the collision detection with enemies.
L843E: LD HL,$1B00
CALL L0454
LD B,A
LD HL,$1B04
CALL L0454
CP B
CALL Z,L845C
LD HL,$1B08
CALL L0454
CP B
CALL Z,L846E
JP L82E0
L845C: LD HL,$1B01
CALL L0454
LD C,A
LD HL,$1B05
CALL L0454
CP C
JP Z,L8480
RET
L846E: LD HL,$1B01
CALL L0454
LD C,A
LD HL,$1B09
CALL L0454
CP C
JP Z,L8480
RET
It reads the Y-coordinate of the player from VRAM and does a comparison against the Y-coordinates of enemies, if any of these is the same then it calls a further subroutine at L845C and L846E to do a comparison of the respective X-coordinate, and if any of these is the same it jumps to L8480 (player defeated).
Player defeated
When any enemy gets over the player, the game jumps to L8480.
It draws the letters "PIERDES!" (Spanish for You lost!) using vectors (or for the same meaning, tons of lines) in white with red background, again for the pseudo-3D effect.
It has a lengthy delay (L85A7) and then it jumps back to the start of the game at L8000.
Level completion
When all pyramid cubes are filled, it increases the level number at LFF0D.
Depending on the level number, it selects the sprite for the next enemies to be displayed, resets the number of filled cubes, and the turn for enemies. When the sprite is changed a bug happens because you can see briefly how the current enemies convert into the new enemies.
In an obvious afterthought, it jumps to L8AA9 to show another vectorial message:
Player completes level in Cubos. You can see the bug where the enemies for the next level are redefined too soon.
The message is GANAS! (Spanish for You won!) It does another big delay at L8B88, and jumps back to re-display the pyramid.
However, for the last level it never shows the GANAS! message, because it jumps directly to L8690 where it sets the level number to 1 and jumps to the game start! (facepalm)
Memory variables
The memory variables used for this game are admirably sparse.
ORG RAM_BASE
LEF13: RB 1 ; Color for drawing pixels.
LFF0D: RB 1 ; Level number.
LFF0E: RB 1 ; Turn for enemies.
LFF0F: RB 1 ; Number of filled cubes.
LFF10: RB 1 ; Status of the third enemy.
; ($00- Not created, $fe- Created)
debounce: rb 1
STACK: EQU RAM_BASE+1024
The coordinates for the player and the enemies are preserved in VRAM.
Interlude
It is pretty clear that I still didn't have a plan for writing the game, so the patches accumulated, making it very difficult to try to follow the code.
Many long subroutines could have been reused to simplify the game. Having the player and enemies coordinates in RAM could have simplified the code, and allowed to do a real-time game including the characteristic jumping of the player and enemies.
The lack of frontier detection for the player was a tragic mistake that could have been solved simply by having a table of each available cube coordinate in the pyramid, however, I'm pretty sure the patched code worked against me making each time harder to see where I should patch the code. The obvious solution would have been that each key loaded a register with the required displacement, and a single routine would have been in charge of moving the player and checking if the target position was valid.
The vectorial line drawing for letters could have benefited from an origin-target-target table saving a ton of calls to the line drawing subroutine.
It was a missed chance to not have a special frame for the enemy falling from the top, or different frames per the directions of the player, or a different frame for when the player is touched by the enemies. At least this time all the graphics were my originals.
Also there is no sound effects, probably because the game stops each time to wait for movement.
This was going to be an epilogue. However, I wasn't satisfied by just getting out my old buggy game, I needed to correct it! So here we go.
Drawing the pyramid
Instead of drawing the pyramid using the stair-like code, I preferred to use a single pseudo-3D cube subroutine to draw every one of the fifteen cubes in the pyramid.
;
; Draw a row of cubes.
;
.2:
push bc
push de
call draw_cube
pop de
ld a,e
add a,$18 ; Move X-coordinate.
ld e,a
pop bc
djnz .2
pop de
ld a,e
sub $0c ; Half to the left.
ld e,a
ld a,d
add a,$11 ; Next row.
ld d,a
pop bc
inc b ; Increase number of cubes.
ld a,b
cp 6 ; Reached six cubes?
jp nz,.1 ; No, continue drawing.
The first step is drawing the pyramid in rows. The first row contains one cube, the second row contains two cubes, and iteratively until it draws five cubes in the fifth row. The code for drawing a pseudo-3D cube is this one:
draw_cube:
push de ; Drawing schematic:
call draw_left ; 1 1/ \5
push de ; / \
call draw_vertical ; 2 2|\4 /|6
call draw_right ; 3 | \ /8|
pop de ; 3\ I9/7
call draw_right ; 4 \I/
pop de
call draw_right ; 5
push de
call draw_vertical ; 6
call draw_left ; 7
pop de
call draw_left ; 8
jp draw_vertical ; 9
;
; Draw a diagonal line to the left.
;
draw_left:
ld b,$06
.1: call draw_pixel
dec e
call draw_pixel
dec e
inc d
djnz .1
ret
;
; Draw a diagonal line to the right.
;
draw_right:
ld b,$06
.1: call draw_pixel
inc e
call draw_pixel
inc e
inc d
djnz .1
ret
;
; Draw a vertical line.
;
draw_vertical:
ld b,$0b
.1: call draw_pixel
inc d
djnz .1
ret
draw_pixel:
push de
call L09B9
pop de
ret
Just with this change, we saved 243 bytes.
My next change was to define the enemy sprite at the start of the level and put together in memory all the enemy sprites bitmaps so loading the enemy into the VRAM is a matter of multiplying the level by 32 and adding an offset, added also a new sprite drawing for the enemy falling from the top (guess what? A snake! The sprite comes from Viboritas). This also removes the bug of not showing the winning message in the last level. Even though we added a new sprite, the game size was reduced more because of the removal of inefficient code.
I coded a list of valid positions over the pyramid (the whole fifteen cubes), and replaced the movement code with a selection of offsets for movement, then it reads the current coordinates of the player, displaces these by the offset, and checks if it is a valid movement. If the movement is valid then it updates the player position, and checks if it should draw the footprint. If the movement isn't valid then the player isn't moved.
L82E0:
call L02AA ; Read the keyboard.
cp $54
jr nz,$+5
ld de,$eff4 ; y = -17, x = -12
cp $59
jr nz,$+5
ld de,$ef0c ; y = -17, x = +12
cp $47
jr nz,$+5
ld de,$11f4 ; y = +17, x = -12
cp $48
jr nz,$+5
ld de,$110c ; y = +17, x = +12
ld hl,$1b00
call L0454 ; Read Y-coordinate of the player.
add a,d ; Add Y offset.
ld d,a
inc hl
call L0454 ; Read X-coordinate of the player.
add a,e ; Add X offset.
ld e,a
ld hl,valid_positions
ld b,15
.1:
ld a,(hl)
cp d
inc hl
jr nz,.2
ld a,(hl)
cp e
jr z,.3
.2:
inc hl
djnz .1
jp L82F7 ; Invalid movement.
; Valid movement.
.3:
ld hl,$1b00
ld a,d
call L0447 ; Update Y-coordinate of the player.
inc hl
ld a,e
call L0447 ; Update X-coordinate of the player.
ld a,d
add a,$0e
ld d,a
ld a,e
add a,$06
ld e,a
push de
call L8345 ; Check if it filled another pyramid square.
pop de
call L86A8 ; Draw footprint.
L82F7:
CALL L832A
jp L843E
valid_positions:
db $08,$78
db $19,$6c,$19,$84
db $2a,$60,$2a,$78,$2a,$90
db $3b,$54,$3b,$6c,$3b,$84,$3b,$9c
db $4c,$48,$4c,$60,$4c,$78,$4c,$90,$4c,$a8
Each of the four possible movements is coded as two offsets in the register DE, and added to the current X,Y position of the player. Now it does a comparison against each one of the valid positions over the pyramid (the table is valid_positions). If the movement is invalid, it simply jumps to move the enemies. If the movement is valid, it updates the X,Y coordinates of the player.
As bonus points for doing this change, I could remove the confusing replicated movement code as each one read the X,Y coordinates of the player! Also saved a ton of bytes.
Finally, I never really liked the face I draw for the corners, so I made another and replaced completely the drawing code with a single subroutine for copying a rectangle (used four times).
;
; Draw faces.
;
ld hl,face_bitmaps
ld de,$0000
call copy_rectangle
ld hl,face_bitmaps
ld de,$00e0
call copy_rectangle
ld hl,face_colors
ld de,$2000
call copy_rectangle
ld hl,face_colors
ld de,$20e0
call copy_rectangle
copy_rectangle:
ld b,4
.1: push bc
push de
ex de,hl
ld b,32
.2: ld a,(de)
call L0447
inc hl
inc de
djnz .2
ex de,hl
pop de
inc d
pop bc
djnz .1
ret
;
; Bitmaps and color for the face.
;
face_bitmaps:
db $ff,$ff,$fc,$fe,$ff,$f9,$fd,$fe
db $f1,$a4,$a9,$52,$54,$55,$3f,$7f
db $2d,$09,$22,$00,$92,$20,$ff,$fc
db $ff,$3f,$2f,$8f,$1f,$4f,$1f,$2f
db $f9,$fc,$fe,$f9,$fa,$f8,$fa,$fa
db $3f,$ff,$3f,$bf,$c7,$81,$1f,$c1
db $ff,$fc,$ff,$ff,$e3,$81,$fc,$83
db $8f,$0f,$af,$1f,$5f,$1f,$5f,$5f
db $fa,$fa,$fc,$fe,$fe,$fe,$fe,$ff
db $b3,$ff,$fe,$fe,$fd,$fd,$fe,$7f
db $e5,$ff,$ff,$ff,$ff,$7f,$ff,$fe
db $5f,$5f,$3f,$7f,$7f,$7f,$7f,$ff
db $ff,$ff,$ff,$ff,$ff,$ff,$ff,$ff
db $7c,$7c,$bf,$df,$ef,$f7,$f8,$f8
db $3e,$3e,$fc,$f8,$f0,$e0,$ff,$ff
db $ff,$ff,$ff,$ff,$ff,$ff,$ff,$ff
face_colors:
db $a1,$a1,$a1,$a1,$a1,$a1,$a1,$a1
db $a1,$a1,$a1,$a1,$a1,$a1,$b1,$b1
db $a1,$a1,$a1,$f1,$a1,$a1,$91,$91
db $a1,$a1,$a1,$a1,$a1,$a1,$a1,$a1
db $a1,$a1,$a1,$a1,$a1,$a1,$a1,$a1
db $b1,$b1,$b1,$b1,$b1,$b1,$b1,$b1
db $91,$91,$91,$91,$91,$91,$91,$91
db $a1,$a1,$a1,$a1,$a1,$a1,$a1,$a1
db $a1,$a1,$a1,$a1,$a1,$a1,$a1,$a1
db $b1,$b1,$b1,$b1,$b1,$b1,$b1,$b1
db $91,$91,$91,$91,$91,$91,$91,$91
db $a1,$a1,$a1,$a1,$a1,$a1,$a1,$a1
db $a1,$a1,$a1,$a1,$a1,$a1,$a1,$a1
db $b1,$b1,$b1,$b1,$b1,$b1,$b1,$b9
db $91,$91,$98,$98,$98,$98,$81,$81
db $a1,$a1,$a1,$a1,$a1,$a1,$a1,$a1
I removed also a few chain-linked jumps, and the updated version of the game is 547 bytes shorter and easier to understand.
Cubos updated and corrected (v2).
Epilogue
Having a peek into code written in my early years gave me a great view of how I solved the design problems.
My kid's enthusiasm kept me focused in a problem, and if I couldn't find a solution, I simply went ahead coding other parts of the game.
But this same enthusiasm prevented me from using some time to make a plan for the game. This single thing could have saved me tons of hours of mistakes in development, and it would have helped me to write more professional games sooner.
I see now that coming from BASIC language, I coded like in BASIC language: Added things until it worked, except in BASIC you could insert lines of code, and renumber source code lines. We can see BASIC like a pencil, you can erase it in order to insert things or rewrite them, but machine code is like a pen, you cannot correct easily your mistakes.
Downloads
cubos.zip (34.3 kb), includes original binary, first disassembly, first port, and second enhanced port.