Close

Driving V9958 using Propeller

A project log for MSX2(+) video to VGA conversion (proof of concept)

V9938 and V9958 Video display processors were successors to TMS99X8 - this is an attempt to convert their video signal to VGA using FPGA

zpekiczpekic 03/29/2021 at 04:050 Comments

The Propeller spin code used to drive the design for test purposes has been written years ago, for a different project:

However, it could be repurposed here with only minimal changes. That was possible because:

Parallax Propeller is a very powerful chip - it contains 8 32-bit CPUs that can control 32-bit I/O pins. This allows direct interfacing with legacy chips in speed ranges below 10MHz or so. Beside VDPs, for example I was able to drive a Am9511 FPU too

This project has only 2 files:

TMS9918.spin

This is the VDP driver. It is interfacing the physical pins and drives them as if the VDP is on a bus of a microcomputer. 

CON
'Signal     Propeller pin   VDP pin ( == F18A pins)
nRESET =    27'12'             34 == pull low for reset
MODE =      26'11'             13 == memory/register mode
nCSW =      25'10'             14 == write to register or VDP memory
nCSR =      24'9'      '       15 == read from register or VDP memory
nINT =      23'8'              16 == input always, activated after each scan line if enabled
CD0 =       7'              24 == MSB (to keep with "reverse" TMS99XX family documentation)
CD1 =       6'              23
CD2 =       5'              22
CD3 =       4'              21
CD4 =       3'              20
CD5 =       2'              19
CD6 =       1'              18
CD7 =       0'              17 == LSB
'VSS                        12 == GND
'VCC                        33 == +5V

Programming the Propeller has many interesting aspects, one of the most important ones is how to make multiple CPUs ("cogs") work in parallel. Each cog can drive own pins, but when the cog is stopped, those pins are "released". To ensure the pins toward VDP are constantly driven, a cog is initialized and then kept in a "dead loop".

The public "Start" method communicates the shared memory (described later) and after some housekeeping kicks off the _vdpProcess() routine in a new cog. 

PUB Start(plCommandBuffer, initialMode, useInterrupt, enableTracing) : success

  longfill(@stack, 0, STACK_LEN)
  skipTrace := true
  if (enableTracing)
    pst.Start(115_200)
    pst.Clear
    skipTrace := false

  Stop

  plCommand := plCommandBuffer
  longfill(@spriteSpeed, 0, 32)
  colorGraphicsForeAndBack := byte[@GoodContrastColorsTable]

  _prompt(String("Press any key to continue with TMS9918 object start using command buffer at "), plCommand)

  lockCommandBuffer := locknew
  if (lockCommandBuffer == -1)
    _logError(String("No locks available to start object!"))
    return false
  else
    cogCurrent := cognew(_vdpProcess(initialMode, useInterrupt), @stack)
    if (cogCurrent == -1)
      _logError(String("No cogs available to start object!"))
      lockret(lockCommandBuffer~)
      return false
  waitcnt((clkfreq * 1) + cnt)
  _logTrace(String("TMS9918 object launched into cog "), cogCurrent, String(" using lock "), lockCommandBuffer, String(" at clkfreq "), clkfreq, 0)
  return true

 The cog now runs the routine until it exists or other cog kills it from outside. The _vdpProcess() does the following:

After that, it goes into an infinite loop of watching for a command and its parameters, and if received executes them. This is very similar to Window message processing paradigm: as long as the window exists, it has a "message pump" that accepts commands sent to it and execute them (one can even say that cog is the "hWnd"). 

The commands are "longs" (32-bit) values written to common RAM memory area. This is again similar to Windows CMD, lParam and wParam mechanism, but to simplify, the number of parameters here are flexible based on the command:

PRI _vdpProcess(initialMode, useInterrupt) |i, y, timer
  _logTrace(String("TMS9918 object starting in cog "), cogId, String(" using lock "), lockCommandBuffer, String(" at clkfreq "), clkfreq, 0)

  nextCharRow := 0
  nextCharCol := 0
  if (useInterrupt)
    vdpAccessWindow := ((((clkfreq / 60) * (262 - 192)) / 262) * 95) / 100 'see table 3.3 in TMS9918 documentation (we have 70 scan lines every 1/60s)
  else
    vdpAccessWindow := clkfreq / 60
  _logTrace(String("Initial mode is "), initialMode, String(" use interrupt is "), useInterrupt, String(" vdp access clock cycles is "), vdpAccessWindow, 0)

  outa[nReset .. CD7]~~         'set all to 1 (inactive)
  dira[nReset .. CD7]~          'set all to input first
  dira[nReset .. nCSR]~~        'these are always outputs
  _vdpReset
  _setReg(1, reg[1] & %1011_1111) 'blank screen
  lastStatus := _readStatus
  _fillVdpMem(0, 16 * 1024, 170, 0) '10101010 pattern
  'this is the first command that will be executed
  long[plCommand][0] := CMD_SETMODE
  long[plCommand][1] := initialMode
  displayMode := initialMode
  longfill(@lastSpritePositionUpdateCnt, cnt, 32)
  repeat  'keep executing commands until cog is stopped
    repeat until not lockset(lockCommandBuffer) 'wait for the free lock (don't execute while command buffer is updated)

    'update position of even numbered sprites according to their speed, if set
    _updateSpritePositions(0)

    timer := cnt
    case LONG[plCommand]
      CMD_SETSPRITEMODE:
        _setSpriteMode(long[plCommand][1] & %0000_0011)
        '_logCommand(String("CMD_SETSPRITEMODE in mode "), _interval(cnt, timer))

... (OTHER COMMANDS)

 This mechanism could allow:

Let's see how a sample command is executed, for example drawing a circle:

      CMD_DRAWCIRCLE:
        _drawCircle(long[plCommand][1], long[plCommand][2], long[plCommand][3], long[plCommand][4])
        '_logCommand(String("CMD_DRAWCIRCLE in mode "), _interval(cnt, timer))

Circle takes 4 parameters which are the coordinates of the center, radius, and color (which can be 0 or 1 in hi-res, or 0-3 in multicolor modes)

PRI _drawCircle(xc, yc, radius, color) |x, y, x2, y2, r2, x2m, pixCount
  '_logTrace(String("Drawing circle in color "), color, String(" at "), xc << 16 | yc , String(" with radius "), radius, 8)
  if (radius < 1)
    return 0
  pixCount := 0
  x := radius
  y := 0
  r2 := radius * radius
  x2 := r2
  y2 := 0
  repeat while (y =< x)
    pixCount += _drawPixel(xc + x, yc + y, color)
    pixCount += _drawPixel(xc + x, yc - y, color)
    pixCount += _drawPixel(xc - x, yc + y, color)
    pixCount += _drawPixel(xc - x, yc - y, color)
    pixCount += _drawPixel(xc + y, yc + x, color)
    pixCount += _drawPixel(xc + y, yc - x, color)
    pixCount += _drawPixel(xc - y, yc + x, color)
    pixCount += _drawPixel(xc - y, yc - x, color)
    y2 := y2 + y + y + 1
    y++
    x2m := x2 - x - x + 1
    if (_circleError(x2m, y2, r2) < _circleError(x2, y2, r2))
      x--
      x2 := x2m

On the bottom of the execution stack are the routines that drive the VDP signals in order to write command or data, or read status or data, including generating a reset:

 

{{ interfacing with VDP chip }}
PRI _readStatus
  return _vdpRead(1)

PRI _vdpRead(modeVal)
  if (modeVal == 0)             'only wait if reading from vdp memory, not status reg
    _waitForScan
  outa[MODE] := modeVal         'set mode
  outa[nCSW]~~                  'write inactive
  outa[nCSR]~~                  'read inactive
  dira[CD0 .. CD7]~             'data bus is input
  outa[nCSR]~                   'pulse nCSR

  result := ina[CD0 .. CD7]
  outa[nCSR]~~

PRI _vdpWrite(byteVal, modeVal)
  if (modeVal == 0)             'only wait if writing to vdp memory, not register
    _waitForScan
  outa[MODE] := modeVal         'set mode
  outa[nCSW]~~                  'write inactive
  outa[nCSR]~~                  'read inactive
  dira[CD0 .. CD7]~~            'data bus is output
  outa[CD0 .. CD7] := byteVal
  outa[nCSW]~                   'delay
  outa[nCSW]~~

PRI _vdpReset
  outa[nReset]~
  waitcnt((clkfreq / 2) + cnt) '500ms
  outa[nReset]~~

TMS9918_test.spin

In Propeller parlance, this is the "top level" object code, that is started up at boot time. Its purpose is to exercise various modes and options of the VDP to show its working on the screen. As a parameter, it takes the state of 4 switches on the Propeller demo board to either run all the demos or generate test picture to adjust the colors (== screwdriver and potentiometers!) or timings (== switches on FPGA board):

PUB Main | mode, rnd, switches
  waitcnt((clkfreq * 4) + cnt) 'wait 4s before start

  if vdp.Start(@CommandBuffer, vdp#GRAPHICS1, false, true)

    repeat true
      'read switches and if color is TRANSPARENT (== 0) continue with demo, otherwise show solid color screen
      dira[13..10]~ 'set as input
      switches := ina[13..10]
      if (switches < 8)
        vdp.Trace(String("Switches are in COLOR (< 8) mode, displaying 8 vertical color bars for calibration "), switches)
        vdp.SetMode(vdp#MULTICOLOR)
        _colorfulBlocks(byte[@ColorPalette8][switches])
      else
        if (switches > 8)
          vdp.Trace(String("Switches are in TICK (> 8) mode, tick lines (every 8 pixels) "), switches)
          vdp.SetMode(vdp#GRAPHICS2)
          _tickLines(byte[@ColorPalette8][switches - 8])
        else
          vdp.Trace(String("Switches are in DEMO (== 8) mode, all running demos "), switches)
          repeat mode from vdp#TEXT to vdp#GRAPHICS1

(demo cases)

 Here is for example a demo that generated 8 sprites and sets them wandering across the screen in various directions:

PRI _spriteDemo(char, waitSecs) |dx, dy, i, rnd
  vdp.SetSpriteMode(vdp#SPRITESIZE_16X16 | vdp#SPRITEMAGNIFICATION_2X)
  repeat i from 0 to 7
    vdp.GenerateSpritePatternFromChar(@SpriteTestPattern16, char + i, 32)
    vdp.SetSpritePattern(i * 4, @SpriteTestPattern16, 32)
    vdp.SetSprite(i, vdp#SPRITEMASK_SETPATTERN | vdp#SPRITEMASK_SETCOLOR | vdp#SPRITEMASK_SETX | vdp#SPRITEMASK_SETY, i * 4, vdp.SpriteHPixelCount / 2 - 16, vdp.SpriteVPixelCount / 2 - 16, 15 - i)
  'give speed vectors to sprites and let send them off autonomously
  vdp.SetSprite(0, vdp#SPRITEMASK_VX | vdp#SPRITEMASK_VY, 0,  1,  0, 0)
  vdp.SetSprite(1, vdp#SPRITEMASK_VX | vdp#SPRITEMASK_VY, 0,  1, -1, 0)
  vdp.SetSprite(2, vdp#SPRITEMASK_VX | vdp#SPRITEMASK_VY, 0,  0, -1, 0)
  vdp.SetSprite(3, vdp#SPRITEMASK_VX | vdp#SPRITEMASK_VY, 0, -1, -1, 0)
  vdp.SetSprite(4, vdp#SPRITEMASK_VX | vdp#SPRITEMASK_VY, 0, -1,  0, 0)
  vdp.SetSprite(5, vdp#SPRITEMASK_VX | vdp#SPRITEMASK_VY, 0, -1,  1, 0)
  vdp.SetSprite(6, vdp#SPRITEMASK_VX | vdp#SPRITEMASK_VY, 0,  0,  1, 0)
  vdp.SetSprite(7, vdp#SPRITEMASK_VX | vdp#SPRITEMASK_VY, 0,  1,  1, 0)
  repeat waitSecs
    vdp.WaitASecond

It is interesting to note that vdp.SetSprite() function is executed by the "current cog", not the one driving the VDP. But the execution is really just preparing the command and parameters to be written to common RAM (all cogs share common RAM, accessed on round-robin basis), after which the SetSprite() function exists. The VDP cog then reads the command from common RAM and drives the sprite across the screen:

PRI _setSprite(spriteId, mask, patternId, x, y, color) |spriteAttributeAddress
  spriteAttributeAddress := SpriteAttributeTable + (spriteId << 2)
  _copyFromVdpMem(spriteAttributeAddress, @SpriteBuff, 4)
  '_logSprite(String("Sprite before "), spriteAttributeAddress, @SpriteBuff)
  if (mask & SPRITEMASK_SETY)
    byte[@SpriteBuff][0] := y
  else
    if (mask & SPRITEMASK_DY)
      byte[@SpriteBuff][0] += y
    else
      if (mask & SPRITEMASK_VY)
        byte[@spriteSpeed + (spriteId << 1)][1] := y
  if (mask & SPRITEMASK_SETX)
    byte[@SpriteBuff][1] := x
  else
    if (mask & SPRITEMASK_DX)
      byte[@SpriteBuff][1] += x
    else
      if (mask & SPRITEMASK_VX)
        byte[@spriteSpeed + (spriteId << 1)][0] := x
  if (mask & SPRITEMASK_SETPATTERN)
    byte[@SpriteBuff][2] := patternId
  if (mask & SPRITEMASK_SETCOLOR)
    byte[@SpriteBuff][3] := (byte[@SpriteBuff][3] & $F0) | (color & $0F)
  '_logSprite(String("Sprite after  "), spriteAttributeAddress, @SpriteBuff)
  _copyToVdpMem(spriteAttributeAddress, @SpriteBuff, 4)

Discussions