* "Entex Adventure Vision system" by Rik1138 is licensed under CC BY-SA 4.0
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:
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:
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
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:
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:
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:
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.