lights
'hello world' on the entex adventure vision
* "Entex Adventure Vision system" by Rik1138 is licensed under CC BY-SA 4.0
Introduction

The Entex Adventure Vision is a tabletop videogame system that was introduced in 1982 and was apparently sold for only around a year or so before being discontinued - for that reason it's considered a relatively obscure system and mostly remembered only amongst collector circles, where it's considered by some to be a 'holy grail' of sorts. It was unique for it's time in that, not only was it one of the first portable videogame systems that made use of interchangable ROM cartridges, but it also had a very novel display technology:

Inside the plastic case, there is a 40-element LED strip mounted vertically, and across from it a double-sided rotating mirror. Once the unit is powered on, the rotating mirror spins up to 7 1/2 revolutions per second, and through the use of carefully timed writes to LED registers, a 150x40 pixel raster image is made to appear on the rotating mirror's surface thanks to persistence of vision effects. The following Youtube video demonstrates this in action:

The CPU

Inside the Adventure Vision is an Intel 8048 running at 733kHz (yes, kilohertz). The 8048 is an 8-bit microcontroller released by Intel in 1976 - being a microcontroller, that means that it has RAM, I/O, and ROM capabilities built into the chip. It does not have a unified memory architecture - RAM and ROM are each addressed in separate memory spaces; there's also a third 'external RAM' memory space separate from the main RAM memory space. This makes the instruction set somewhat more convoluted than some other 8-bit processors of the era (IMHO), because it necessitates having different instructions for manipulating RAM vs. ROM vs. External RAM. What the instruction set lacks in elegance, though, it makes up for in efficiency, because all of the instructions are guaranteed to only take 1 or 2 clock cycles to complete.

The System Architecture

The 8048 supports a 4KB addressable ROM space, but only 2KB is addressable at any given time - consequently, the instruction set provides for a special instruction specifically to switch between memory banks.

The cartridge ROMs themselves are also 4KB, so it would seem at first glance that the cartridge ROM should map neatly into the 4KB of addressable ROM space. Unfortunately, that's not exactly the case.

The problem is that the system itself also has 1KB of built-in ROM which contains BIOS routines. Under normal circmstances, the BIOS is accessible at addresses 0-1023, and only the upper 3KB of the cartridge ROM is accessible at addresses 1024-4095. So if you need access to the first 1KB of cartridge ROM, you have to first write to a '1' to a certain bit on one of the I/O ports which then causes the full 4KB of the cartridge ROM to be mapped into the address space at the expense of the BIOS. The takeaway from this setup is twofold:

  1. The first 1KB of cartridge ROM has no way to directly access the built-in BIOS routines,
  2. If your program is 3KB or less, you shouldn't really need to worry much about all this.

As far as RAM goes, the 8048 has only 64 bytes of it built in. This obviously isn't enough to build a video framebuffer of any sort. As mentioned previously, though, the 8048 also supports external RAM. It's here in the external RAM memory space that we find an additional 1KB of RAM, which is used as a 1-bit-per-pixel video framebuffer by the BIOS routine that draws the screen raster image. And since the display is 150x40 pixels, we also end up with 274 bytes of external RAM leftover to use for other things if we want (since 1024 - (150 * 40/8) = 274).

The 'Hello World' Demo
Initialization

When the system first starts up, program execution begins at address 0. Recall, though, that the first 1K of the address space is shared between the cartridge ROM and BIOS - and since the system starts up in a random state, we have to assume that either the cartridge ROM or the BIOS could be mapped into this location at startup.

To account for this ambiguity, the BIOS has a set of instructions at address 0 that will immediately switch execution to the upper 2KB memory bank. The 'Hello World' program described here does a similar thing - that way no matter which one executes, we end up in a consistent state with our program execution beginning at address 2048 of the cartridge ROM (technically address 0 of the upper 2KB memory bank):

        ; 0x0000-0x03FF is shared between the first 1k of program ROM and the BIOS.  Only one can be loaded in at a time.
        .org 0x0000

        ; Jump to 0x8000 if we happen to start here so we can load the BIOS into this area
        SEL MB1
        JMP Startup

        ; The BIOS should jump to here if it was loaded on startup
        .org 0x0800
Startup:

       ; (0x0802-0x80B is reserved for certain BIOS routine callbacks so we should probably skip it)
        JMP Main

; ********
; * MAIN *
; ********

        .org 0x080C
Main:

        ...

From there we have to intialize the system. Again, we have to assume that on real hardware, everything will start up in a random state, so the first thing we want to do is make sure the BIOS is mapped at addresses 0-1023. After that we need to reset the stack pointer and clear all the processor flags:

; ******************
; * INITIALIZATION *
; ******************

        CLR A

        ; Load BIOS into 0x0000-0x07FF if not already loaded
        OUTL P1, A

        ; Reset the stack pointer, select register bank 0, and clear flags
        MOV PSW, A

We also need to clear all the memory - both the internal RAM and the external RAM:

        ; Clear the internal RAM (64 bytes)
        MOV R0, #63                 ; Start at the highest address and decrement with each loop
ClearInternalRAMLoopTop:
        MOV @R0, A                  ; Clear the current memory location
        DJNZ R0, ClearInternalRAMLoopTop
        MOV @R0, A                  ; Clear final memory location

        ; Clear the external RAM (4 banks, each one 256 bytes)
        MOV R0, #4                  ; Start at the highest bank and decrement with each loop
ClearExternalRAMLoopTop:
        DEC R0
        MOV A, R0
        OUTL P1, A                  ; Set the current bank
        MOV R1, #255                ; Start at the highest address and decrement with each loop
        CLR A
ClearExternalRAMInnerLoopTop:
        MOVX @R1, A                 ; Clear the current memory location for this bank
        DJNZ R1, ClearExternalRAMInnerLoopTop
        MOVX @R1, A                 ; Clear final memory location for this bank
        MOV A, R0
        JNZ ClearExternalRAMLoopTop

We should probably also clear any sounds that might be randomly playing on startup (which would be highly annoying). To do this, we switch memory banks real quick to the lower one in order to call the BIOS routine that handles sound:

        ; Clear any sounds that might be playing
        MOV R1, #0x00               
        SEL MB0
        CALL WriteSound
        SEL MB1

At this point, the system should be in a consistent state. After setting up a few variables, we're ready to begin our main program loop.

Blanking the Video

The main program loop in the 'Hello World' program is designed to run 15 times a second, in sync with the rotating mirror (more later on how this is achieved). The loop updates the display with each iteration, and then takes input from the joystick before starting over.

There are two different strategies for updating the display: one is to update only the parts of the screen that have changed. This is the most efficient approach in terms of CPU cycles. The other approach is to just erase and update the entire display. This approach is limited in the total number of sprites that can be written, but is simpler to implement, and so consequently will be the approach used in this demo.

Since we're updating the entire display, the first thing that needs to be done is to blank the video RAM. This is accomplished by calling a custom routine called 'BlankVideoRAM'. You'll notice below that it actually writes 0xFF to each byte in the video RAM - that's because on the Adventure Vision, an 'on' pixel is a zero, and an 'off' pixel is a one - consequently, we have to fill the video memory with all ones:

BlankVideoRAM:
        ; Blank video RAM (aka external RAM banks 1-3 addresses 6-255) by filling with 0xFFs.
        ; 0xFF is actually an 'off' pixel and 0x00 is 'on' for whatever reason.
        MOV R0, #3                  ; Start at the highest bank and decrement with each loop
BlankVideoRAMLoopTop:
        MOV A, R0
        OUTL P1, A                  ; Set the current bank
        MOV R1, #255                ; Start at the highest address and decrement with each loop
BlankVideoRAMInnerLoopTop:
        MOV A, #0xFF
        MOVX @R1, A                 ; Fill the current memory location for this bank with 0xFF
        DEC R1
        MOV A, R1
        XRL A, #5                   ; XOR mask current loop iteration with 5 so that we stop at 5
        JNZ BlankVideoRAMInnerLoopTop
        DJNZ R0, BlankVideoRAMLoopTop

        RET
Copying the Sprites

The BIOS has a built in routine at address 0x71 to copy sprite data into the video RAM, but it's fairly low-level so we won't bother with it here. Instead we'll use a custom higher-level routine called 'CopySprite' that takes an X and Y screen coordinate, a sprite pointer, and a sprite width. The four parameters are:

R1: Sprite byte offset within the sprite data page
R2: Destination X location from 0-149
R3: Destination Y location from 0-39
R4: Sprite width in bytes

The CopySprite Routine

It should be noted that 'CopySprite' assigns location 0,0 to the lower left corner of the display, so increasing values of R3 will mean that the sprite is positioned more towards to the top of the display. Before explaining how the routine works in detail, though, let's first go over how the video memory is laid out in the Adventure Vision.

As mentioned before, there's 1KB of external RAM in the system. It's organized in 4 banks of 256 bytes each. The video memory area comprises bytes 6-255 in each of the first 3 banks. Each bank stores the data for 1/3rd of the screen:

  • Bank 0 for columns 0-49
  • Bank 1 for columns 50-99
  • Bank 2 for columns 100-149

The first thing that needs to be done in 'CopySprite' is to figure out the appropriate RAM bank to use and the proper column index within the RAM bank.

To do this, first we add 206 (256 - 50) to the X coordinate parameter that was passed in via R2. If it overflows, we know that the current column of sprite data doesn't belong in bank 0. In that case, we try adding 156 (206 - 50) to the result of the overflow. If that also overflows, then we know that the current column of sprite data doesn't belong in bank 1 either.

In this way, we're able to calculate the proper bank as well as the column offset within that bank:

CopySpriteTop:

        ; Select the appropriate RAM bank for the given X position.  Also get the RAM bank column index and save it in R0
        MOV A, R2
        MOV R0, A                   ; The default column index will just be the original X position.
        MOV A, #206
        CLR C
        ADD A, R2                   ; Add X position to 206 to see if it overflows
        JC SpriteBankNotBank1       ; If carry is set, then X position was >= 50, so we don't want bank 1
        MOV A, RAM_Bank_1_Enable    ; Select external RAM bank 1
        JMP SpriteBankEndIf
SpriteBankNotBank1:
        MOV R0, A                   ; Update the column index to the outcome of the last addition (which presumably overflowed and wrapped around)
        MOV A, #156
        CLR C
        ADD A, R2                   ; Add X position to 156 to see if it overflows
        JC SpriteBankNotBank2       ; If carry is set, then X position was >= 100, so we don't want bank 2
        MOV A, RAM_Bank_2_Enable    ; Select external RAM bank 2
        JMP SpriteBankEndIf
SpriteBankNotBank2:
        MOV R0, A                   ; Update the column index to the outcome of the last addition (which presumably overflowed and wrapped around)
        MOV A, RAM_Bank_3_Enable    ; Select external RAM bank 3
SpriteBankEndIf:
        OUTL P1, A                  ; Do the actual RAM bank selection

Once we know the column index, we then have to convert that to a byte offset.

There's 40 pixels to a column, which means that 5 bytes of RAM are used to represent each column (since 40/8 = 5). That means we need to multiply the column index by 5. This is accomplished by bitshifting the column index to the left twice (which gives us the column index times 4), then adding the column index to the result (which gives us the column index times 5):

        ; Take the column index and multiply it by 5 to compute the column byte offset.  Store it in R4
        MOV A, R0
        RL A
        RL A
        ANL A, #0xFC                ; First multiply it by 4 using bit shifts
        CLR C
        ADD A, R0                   ; Then add R0 to the result of that to get 'multiply by 5'
        MOV R4, A

At this point, we have the byte offset for the 'top' of the column. Now we need to factor in the vertical location of the sprite within the column.

First we take the Y coordinate that was passed in via R3, and divide it by 8 using bitshift operations. This will give us the byte offset from 0-4 within the column where the sprite needs to go. Then we have to add 6, since the video memory area actually starts at byte 6 and not byte 0. Then we also add in the byte offset for the 'top' of the column calculated previously:

        ; Compute the sprite's vertical positioning byte offset and add that to the column byte offset
        MOV A, R3                   ; Load the sprite Y position
        RR A                        ; Divide TextStringY by 8 to determine the starting byte index to place the sprite at
        RR A
        RR A
        ANL A, #0x07
        CLR C
        ADD A, #6                   ; Add 6 to the starting byte index, since video RAM goes from 6-255 in each bank
        ADD A, R4                   ; Add this to the column byte offset computed above
        MOV R4, A

Now we know the exact byte where the sprite needs to go in RAM, but unless the sprite is positioned vertically on an exact byte boundary, it will actually occupy 2 bytes in RAM - the byte just calculated and the one after that. In other words, the sprite still needs to be shifted into it's exact vertical pixel position within the column.

To do this, first we get the fine positioning offset, which will just be the lower 3 bits of the Y position (i.e. the remainer of Y position divided by 8):

        ; Now get the fine positioning bit offset and copy that into R0
        MOV A, R3                   ; Load the sprite Y position again
        ANL A, #0x07                ; AND with the low 3 bytes to get the fine positioning offset
        MOV R0, A

At this point we are about ready to actually fetch our byte of sprite data. This is done by calling another custom routine called 'GetSpriteData'.

A separate routine is needed here due to the architectural constraints of the 8048 - namely, the instruction set only allows loading data from ROM from within the current 256 byte ROM page. That means in order to have the maximum room for our sprites, we have to store them within their own dedicated page. The 'GetSpriteData' routine lives in that same page.

After the sprite data is fetched, it's copied into R5. We then fill R6 with 0xFF. These two registers will now serve as our 2-byte bitshifting register for moving the sprite into it's exact pixel position within the column. To do this, we iterate 'fine positioning offset' number of times, shifting the sprite data one bit per iteration:

        ; Load the sprite data into R5.  Fill R6 with 0xFF - it will be the upper sprite register we rotate bits into
        MOV R5, A
        MOV A, #0xFF
        MOV R6, A

        ; Fine positioning while loop - bit shift the sprite data left by one bit for each iteration
FinePositioningLoopTop:
        MOV A, R0
        JZ FinePositioningLoopEnd
        MOV A, R5                   ; Load the sprite data lower register
        CLR C
        CPL C                       ; '1' is an off pixel so we want to make sure the carry is set
        RLC A                       ; Shift it left one bit
        MOV R5, A                   ; Save the data back into the sprite data lower register
        MOV A, R6                   ; Load the sprite data upper register
        RLC A                       ; Shift it left one bit, shifting the carry from the previous shift into bit 0
        MOV R6, A                   ; Save the data back into the sprite data upper register
        DEC R0
        JMP FinePositioningLoopTop
FinePositioningLoopEnd:

Now we have the sprite data adjusted to the exact vertical position we want within the two-byte register combination of R5 and R6. We logically 'AND' R5 and R6 with whatever is already in video memory at the destination address. Doing a logical 'AND' here has the effect of compositing the new sprite on top of any existing sprites, since 0xFF represents a blank byte in video memory:

        ; Write the shifted sprite registers to the video display at the proper locations
        MOV A, R4
        MOV R0, A                   ; Load the video memory starting byte index
        MOVX A, @R0                 ; Get the current value in video memory
        ANL A, R5                   ; AND it with the sprite data lower register
        MOVX @R0, A                 ; Store it in video memory
        INC R0
        MOVX A, @R0                 ; Get the current value in video memory
        ANL A, R6                   ; AND it with the sprite data upper register
        MOVX @R0, A                 ; Store it in video memory

At this point, one column of sprite data has now been copied into video memory. We then increment the X coordinate and repeat the entire procedure for each additional of column of sprite data that needs to be copied into memory.

Back to the Main Loop

The letters that constitute the 'Hello World' message are each stored in ROM as separate sprites. So getting back to the main loop, that means we need to populate the input parameters for 'CopySprite' (R1-R4) and then call 'CopySprite' once for each letter.

One nice thing about 'CopySprite' is that it automaticaly increments R2 (the sprite X coordinate), so if you are drawing a row of tiled sprites from left to right on the display, R2 generally doesn't need to be updated between each call. R3 and R4 don't need to be updated either, since every letter is at the same Y position and is the same size in bytes. Only R1 (the sprite pointer) needs to be updated, and sometimes not even that if we want to repeat a letter:

        ...

        ; Copy the 'e' (sprite byte offset 8) into video RAM
        MOV R1, #8
        CALL CopySprite

        ; Copy the two 'l's (sprite byte offset 16) into video RAM
        MOV R1, #16
        CALL CopySprite
        CALL CopySprite

        ...

After the letters have all been copied to video memory, the main loop calls the BIOS 'DisplayVideo' routine, which writes the contents of video memory to the LED registers in realtime to generate the display:

        ; Call the BIOS routine to draw the screen
        SEL MB0
        CALL DisplayVideo
        SEL MB1

At this point it's worth mentioning that the CPU synchronization with the rotating mirror inside the unit is accomplished via a hardware sensor - the sensor detects when the edge of the mirror is passing by and asserts a 'low' on the 8048's built-in 'test' line when this occurs.

The 'DisplayVideo' BIOS routine works by checking this test signal and going into a busy wait loop until the signal goes low. Once that happens, it begins its writes to the LED registers in realtime. The result is a (fairly) stable 15 frames-per-second raster on the mirror.

Moving the Text Around

The final function call in the main loop is to a routine called 'UpdateTextPositioning'. This routine checks the joystick input and updates the position of the 'Hello World' text on-screen depending on how the joystick is being pressed.

The joystick and button inputs on the Adventure Vision are mapped to pins 3-7 of I/O port 1. This port happens to be bidrectional (it supports both input and output). To poll the port for input, the CPU requires that '1s' first be written to said port. After we do that, we can then read from it:

UpdateTextPositioning:

        MOV A, Controller_Read
        OUTL P1, A                   ; Write to port 1 bits 3-7 to ready them for input
        IN A, P1                     ; Read port 1 to get the controller input
        ANL A, Controller_Read       ; (AND the lower 3 bits out just to be safe)
        MOV R0, A                    ; Store the controller input in R0

        ...

When nothing is being pressed, the input from pins 3-7 should be all high. Pressing a button or moving the joystick causes some combination of these pins to go low. We can check for each joystick direction by doing an exclusive OR between the value read in from the port and a corresponding detection bitmask. If the two values match exactly, then all the bits will cancel out, leaving a zero in the accumulator:

        ...

        MOV A, R0                    ; Load the controller input again
        XRL A, Stick_Up              ; XOR with the bit pattern for 'stick up' to see if it's being pressed
        JNZ NoStickUp
        
        (stick is being pressed up if we get to here)
        
        ...
        
NoStickUp:

If the joystick was indeed being pressed up, then we increment the 'TextStringY' variable and store it back into memory. We then also check to see if the new value is equal to 33 - if so, then our text string has gone off the top of the screen. In that case, we need to reset 'TextStringY' back to 32:

        ...

        MOV R1, TextStringY
        MOV A, @R1                   ; Load the Y position
        INC A                        ; Add 1
        MOV @R1, A                   ; Store the Y position
        XRL A, #33                   ; XOR with 33 to see if we've gone off the top edge of the screen
        JNZ EndUpdateTextPositioning
        MOV A, #32
        MOV @R1, A                   ; If so, just reset the Y position to 32
        JMP EndUpdateTextPositioning

        ....

EndUpdateTextPositioning:

The other 3 joystick directions are checked in a similar manner, adjusting the position of the text string as needed, and verifying each time that it hasn't gone off the edge of the screen.

When all 4 directions have been checked, the 'UpdateTextPositioning' routine returns to the main loop. The main loop then starts over again and the whole process repeats.

Ideas for Improvement

To make use of some of this code in a game, a couple things could possibly be changed or improved:

  1. An 'exclusive or' drawing mode would be useful for 'CopySprite' - then instead of redrawing the entire screen each time, you could simply erase the parts that have changed by writing to the old sprite location with the 'XOR' mode turned on.
  2. 'CopySprite' could also be expanded to support pulling sprite data from more than one page.

Conclusion and Thanks

Hope you enjoyed this writeup. Github link is here with instructions on how to build and run.

None of this would have been possible without Dan Boris' excellent technical writeup of the Adventure Vision, which can be found here.