One of the nicer things about the 2600 is the hardware collision detection. Having written software sprite-based engines where collision detection had to be done with software this is a nice feature. As the TIA must know what to draw in the pixel, and there are only 6 things that can collide, the hardware simply needs to do a series of and operations with the information it has and store that information in the appropriate register. As each of the 6 thing that can collide can’t collide with themselves and are reversible (missile1 hitting player0 is the same as player0 hitting missile1) that means that there are only 15 possible collisions which I outline in the table below.
By assigning each collision with a bit we can put all the possible TIA collisions into a 15 bit word or split it into 2 bytes. This is the way a modern system would do this if the API designer didn’t add a hundred layers of abstraction to the system. For some reason, the TIA designers decided to have the collision bits divided into 8 registers as shown in table \ref{tbl:collisionregs}. This lets you use the BIT instruction which sets the Negative flag if bit seven is set so BPL/BMI can be used to check this bit as well as setting the overflow flag if bit 6 is set allowing for BVC/BVS branching.
Once a collision register has been set it stays set until the CXCLR is written to. As you generally want the collision for the whole frame this is not a big issue but something that needs to be kept in mind if you want to figure out where on the screen the collision occurred.
The TIA emulator handles collisions by tracking the flags as a single integer with bits set up as per the first table. This is a global collisonState variable which is cleared by CXCLR. Whenever a pixel is being written, we create a mask that will be logically ORed to the collision state. It assumes that all collisions are going to happen.
var collisionMask = 32767
As we draw each sprite, we check to see if the element is drawn. If it is, the mask is unchanged, but if it is not then no collision with that component could have happened so we remove those bits using the collision mask as an indication of which bits should be ANDed out.
pixelColor = if (sprites[0].isPixelDrawn(column)) playfieldBallColor else {
collisionMask = collisionMask and sprites[0].collisionMask
pixelColor}
pixelColor = if (sprites[1].isPixelDrawn(column)) playerMissile1Color else {
collisionMask = collisionMask and sprites[1].collisionMask
pixelColor}
pixelColor = if (sprites[2].isPixelDrawn(column)) playerMissile1Color else {
collisionMask = collisionMask and sprites[2].collisionMask
pixelColor}
pixelColor = if (sprites[3].isPixelDrawn(column)) playerMissile0Color else {
collisionMask = collisionMask and sprites[3].collisionMask
pixelColor}
pixelColor = if (sprites[4].isPixelDrawn(column)) playerMissile0Color else {
collisionMask = collisionMask and sprites[4].collisionMask
pixelColor}
collisonState = collisonState or collisionMask
With that done, we now have all the information we need to read the collision registers so we implement the readRegister method to return the requested pair of collision bits as outlined in our second table.
private fun buildCollisionByte(bit7mask:Int, bit6mask:Int):Int {
val bit7 = if ((collisonState and bit7mask) == bit7mask) 128 else 0
val bit6 = if ((collisonState and bit6mask) == bit6mask) 64 else 0
return bit7 or bit6
}
fun readRegister(address:Int):Int {
return when(address) {
TIAPIARegs.CXBLPF -> buildCollisionByte(TIAPIARegs.ICOL_PFBL,0)
TIAPIARegs.CXM0FB -> buildCollisionByte(TIAPIARegs.ICOL_PFM0, TIAPIARegs.ICOL_BLM0)
TIAPIARegs.CXM1FB -> buildCollisionByte(TIAPIARegs.ICOL_PFM1, TIAPIARegs.ICOL_BLM1)
TIAPIARegs.CXM0P -> buildCollisionByte(TIAPIARegs.ICOL_P0M0, TIAPIARegs.ICOL_M0P1)
TIAPIARegs.CXM1P -> buildCollisionByte(TIAPIARegs.ICOL_P0M1, TIAPIARegs.ICOL_P1M1)
TIAPIARegs.CXP0FB -> buildCollisionByte(TIAPIARegs.ICOL_PFP0, TIAPIARegs.ICOL_BLP0)
TIAPIARegs.CXP1FB -> buildCollisionByte(TIAPIARegs.ICOL_PFP1, TIAPIARegs.ICOL_BLP1)
TIAPIARegs.CXPPMM -> buildCollisionByte(TIAPIARegs.ICOL_P0P1, TIAPIARegs.ICOL_M0M1)
// Unknown or unimplemented registers print warning
else -> { println("TIA register $address not implemented!"); 0}
}
}
This means that we now have collisions. Next fortnight things are changing as I make up my mind about the future of this blog.
Saturday, September 28, 2019
Saturday, September 14, 2019
TIA Sprite Implementation
Now that we have a sprite class for the internal processing of the TIA chip Player Missile Graphics (which I will refer to as sprites), we need to implement the TIA registers that handle the various sprite activity and adjust our sprites to reflect the register settings. First, we create the 5 sprites that the TIA uses. This is done in an array class variable with constants set up in the TIAPIARegs singleton class that hold the index values of each sprite.
val sprites = arrayOf(PlayerMissileGraphic(1, 30570), // Ball
PlayerMissileGraphic(8, 15423), // Player 1
PlayerMissileGraphic(1, 1023), // Missile 1
PlayerMissileGraphic(8, 28377), // Player 0
PlayerMissileGraphic(1, 24007) ) // Missile 0
The most important thing for a sprite is to be able to be shown. The ball and missiles have an enable register which when bit 1 is set will show the sprite otherwise will hide the sprite. For the player sprites, set the byte that represents the image to 0 for the sprite not to be drawn and fill it with the display data to have the sprite draw something.
TIAPIARegs.ENABL -> sprites[TIAPIARegs.ISPRITE_BALL].drawingBits = if ((value and 2) == 2) 1 else 0
TIAPIARegs.ENAM0 -> sprites[TIAPIARegs.ISPRITE_MISSILE0].drawingBits = if ((value and 2) == 2) 1 else 0
TIAPIARegs.ENAM1 -> sprites[TIAPIARegs.ISPRITE_MISSILE1].drawingBits = if ((value and 2) == 2) 1 else 0
TIAPIARegs.GRP0 -> sprites[TIAPIARegs.ISPRITE_PLAYER0].drawingBits = value and 255
TIAPIARegs.GRP1 -> sprites[TIAPIARegs.ISPRITE_PLAYER1].drawingBits = value and 255
Being able to see the sprite is nice, but we need to control where on the scan-line the sprite will be displayed. It would be nice if we just had a register that held the position information of the sprites, but for some reason that is beyond me this is not how sprite positioning works on the TIA. To position a sprite you simply write anything into the RESXX register and it will position the sprite at the current color clock position or position 0 if called in the horizontal blanking interval.
TIAPIARegs.RESBL -> sprites[TIAPIARegs.ISPRITE_BALL].x = column
TIAPIARegs.RESP0 -> sprites[TIAPIARegs.ISPRITE_PLAYER0].x = column
TIAPIARegs.RESP1 -> sprites[TIAPIARegs.ISPRITE_PLAYER1].x = column
TIAPIARegs.RESM0 -> sprites[TIAPIARegs.ISPRITE_MISSILE0].x = column
TIAPIARegs.RESM1 -> sprites[TIAPIARegs.ISPRITE_MISSILE1].x = column
TIAPIARegs.RESMP0 -> sprites[TIAPIARegs.ISPRITE_MISSILE0].x = sprites[TIAPIARegs.ISPRITE_PLAYER0].x
TIAPIARegs.RESMP1 -> sprites[TIAPIARegs.ISPRITE_MISSILE1].x = sprites[TIAPIARegs.ISPRITE_PLAYER1].x
The problem with this is that each CPU cycle is 3 color clock cycles and a STA instruction takes at least 3 cycles to complete. This means that we need to adjust the horizontal position using the move delta. This is done at the start of the scanline by writing to HMOVE with any value. The amount each sprite moves is a signed nibble set by HMxx registers with a HMCLR register that sets all the HMxx registers to zero. We have a utility method to convert a nibble into a signed integer as follows
private fun convertNibbleToSignedInt(nibble:Int):Int {
val nib = nibble and 15
return if (nib > 7) nib - 16 else nib
}
And within the writeRegister when statement we have the following registers implemented:
TIAPIARegs.HMP0 -> sprites[TIAPIARegs.ISPRITE_PLAYER0].deltaX = convertNibbleToSignedInt(value shr 4)
TIAPIARegs.HMP1 -> sprites[TIAPIARegs.ISPRITE_PLAYER1].deltaX = convertNibbleToSignedInt(value shr 4)
TIAPIARegs.HMM0 -> sprites[TIAPIARegs.ISPRITE_MISSILE0].deltaX = convertNibbleToSignedInt(value shr 4)
TIAPIARegs.HMM1 -> sprites[TIAPIARegs.ISPRITE_MISSILE1].deltaX = convertNibbleToSignedInt(value shr 4)
TIAPIARegs.HMBL -> sprites[TIAPIARegs.ISPRITE_BALL].deltaX = convertNibbleToSignedInt(value shr 4)
TIAPIARegs.HMOVE -> {
for (cntr in 0..4)
sprites[cntr].hmove()
}
TIAPIARegs.HMCLR -> {
for (cntr in 0..4)
sprites[cntr].deltaX = 0
}
Sprite scaling and mirroring is done through the NUSIZx register with the copy/scale mode of the player sprites being the lower 3 bits and the scaling of missile for that player being two to the power of the two bit number occupying bits 4 and 5. In other words, we take the last three bits to form a number between 0 and 7 which is used to set the player sprite scaling and copies via the setePlayerScaleCopy method that we wrote in the previous article. The missile scaling is bits 4 and 5 which when shifted right become a number between 0 and 3, this is used as a power of two which can be done by left-shifting a 1 by the given number which would result in 1, 2, 4, or 8.
TIAPIARegs.NUSIZ0 -> {
sprites[TIAPIARegs.ISPRITE_PLAYER0].setPlayerScaleCopy(value and 7)
sprites[TIAPIARegs.ISPRITE_MISSILE0].scale = 1 shl ((value shr 4) and 3)
}
TIAPIARegs.NUSIZ1 -> {
sprites[TIAPIARegs.ISPRITE_PLAYER1].setPlayerScaleCopy(value and 7)
sprites[TIAPIARegs.ISPRITE_MISSILE1].scale = 1 shl ((value shr 4) and 3)
}
Finally, mirroring is simply setting the sprite mirror variable based on the state of bit 3 of the value passed to the REFx register.
TIAPIARegs.REFP0 -> sprites[TIAPIARegs.ISPRITE_PLAYER0].mirror = (value and 8) == 8
TIAPIARegs.REFP1 -> sprites[TIAPIARegs.ISPRITE_PLAYER1].mirror = (value and 8) == 8
That just about covers all the write registers for manipulating sprites, but there are also a set of eight readable registers that are used for handling collision detection. This is a bit complex of a subject, though is a lot easier to use than one would expect and will be covered next time.
val sprites = arrayOf(PlayerMissileGraphic(1, 30570), // Ball
PlayerMissileGraphic(8, 15423), // Player 1
PlayerMissileGraphic(1, 1023), // Missile 1
PlayerMissileGraphic(8, 28377), // Player 0
PlayerMissileGraphic(1, 24007) ) // Missile 0
The most important thing for a sprite is to be able to be shown. The ball and missiles have an enable register which when bit 1 is set will show the sprite otherwise will hide the sprite. For the player sprites, set the byte that represents the image to 0 for the sprite not to be drawn and fill it with the display data to have the sprite draw something.
TIAPIARegs.ENABL -> sprites[TIAPIARegs.ISPRITE_BALL].drawingBits = if ((value and 2) == 2) 1 else 0
TIAPIARegs.ENAM0 -> sprites[TIAPIARegs.ISPRITE_MISSILE0].drawingBits = if ((value and 2) == 2) 1 else 0
TIAPIARegs.ENAM1 -> sprites[TIAPIARegs.ISPRITE_MISSILE1].drawingBits = if ((value and 2) == 2) 1 else 0
TIAPIARegs.GRP0 -> sprites[TIAPIARegs.ISPRITE_PLAYER0].drawingBits = value and 255
TIAPIARegs.GRP1 -> sprites[TIAPIARegs.ISPRITE_PLAYER1].drawingBits = value and 255
Being able to see the sprite is nice, but we need to control where on the scan-line the sprite will be displayed. It would be nice if we just had a register that held the position information of the sprites, but for some reason that is beyond me this is not how sprite positioning works on the TIA. To position a sprite you simply write anything into the RESXX register and it will position the sprite at the current color clock position or position 0 if called in the horizontal blanking interval.
TIAPIARegs.RESBL -> sprites[TIAPIARegs.ISPRITE_BALL].x = column
TIAPIARegs.RESP0 -> sprites[TIAPIARegs.ISPRITE_PLAYER0].x = column
TIAPIARegs.RESP1 -> sprites[TIAPIARegs.ISPRITE_PLAYER1].x = column
TIAPIARegs.RESM0 -> sprites[TIAPIARegs.ISPRITE_MISSILE0].x = column
TIAPIARegs.RESM1 -> sprites[TIAPIARegs.ISPRITE_MISSILE1].x = column
TIAPIARegs.RESMP0 -> sprites[TIAPIARegs.ISPRITE_MISSILE0].x = sprites[TIAPIARegs.ISPRITE_PLAYER0].x
TIAPIARegs.RESMP1 -> sprites[TIAPIARegs.ISPRITE_MISSILE1].x = sprites[TIAPIARegs.ISPRITE_PLAYER1].x
The problem with this is that each CPU cycle is 3 color clock cycles and a STA instruction takes at least 3 cycles to complete. This means that we need to adjust the horizontal position using the move delta. This is done at the start of the scanline by writing to HMOVE with any value. The amount each sprite moves is a signed nibble set by HMxx registers with a HMCLR register that sets all the HMxx registers to zero. We have a utility method to convert a nibble into a signed integer as follows
private fun convertNibbleToSignedInt(nibble:Int):Int {
val nib = nibble and 15
return if (nib > 7) nib - 16 else nib
}
And within the writeRegister when statement we have the following registers implemented:
TIAPIARegs.HMP0 -> sprites[TIAPIARegs.ISPRITE_PLAYER0].deltaX = convertNibbleToSignedInt(value shr 4)
TIAPIARegs.HMP1 -> sprites[TIAPIARegs.ISPRITE_PLAYER1].deltaX = convertNibbleToSignedInt(value shr 4)
TIAPIARegs.HMM0 -> sprites[TIAPIARegs.ISPRITE_MISSILE0].deltaX = convertNibbleToSignedInt(value shr 4)
TIAPIARegs.HMM1 -> sprites[TIAPIARegs.ISPRITE_MISSILE1].deltaX = convertNibbleToSignedInt(value shr 4)
TIAPIARegs.HMBL -> sprites[TIAPIARegs.ISPRITE_BALL].deltaX = convertNibbleToSignedInt(value shr 4)
TIAPIARegs.HMOVE -> {
for (cntr in 0..4)
sprites[cntr].hmove()
}
TIAPIARegs.HMCLR -> {
for (cntr in 0..4)
sprites[cntr].deltaX = 0
}
Sprite scaling and mirroring is done through the NUSIZx register with the copy/scale mode of the player sprites being the lower 3 bits and the scaling of missile for that player being two to the power of the two bit number occupying bits 4 and 5. In other words, we take the last three bits to form a number between 0 and 7 which is used to set the player sprite scaling and copies via the setePlayerScaleCopy method that we wrote in the previous article. The missile scaling is bits 4 and 5 which when shifted right become a number between 0 and 3, this is used as a power of two which can be done by left-shifting a 1 by the given number which would result in 1, 2, 4, or 8.
TIAPIARegs.NUSIZ0 -> {
sprites[TIAPIARegs.ISPRITE_PLAYER0].setPlayerScaleCopy(value and 7)
sprites[TIAPIARegs.ISPRITE_MISSILE0].scale = 1 shl ((value shr 4) and 3)
}
TIAPIARegs.NUSIZ1 -> {
sprites[TIAPIARegs.ISPRITE_PLAYER1].setPlayerScaleCopy(value and 7)
sprites[TIAPIARegs.ISPRITE_MISSILE1].scale = 1 shl ((value shr 4) and 3)
}
Finally, mirroring is simply setting the sprite mirror variable based on the state of bit 3 of the value passed to the REFx register.
TIAPIARegs.REFP0 -> sprites[TIAPIARegs.ISPRITE_PLAYER0].mirror = (value and 8) == 8
TIAPIARegs.REFP1 -> sprites[TIAPIARegs.ISPRITE_PLAYER1].mirror = (value and 8) == 8
That just about covers all the write registers for manipulating sprites, but there are also a set of eight readable registers that are used for handling collision detection. This is a bit complex of a subject, though is a lot easier to use than one would expect and will be covered next time.
Subscribe to:
Posts (Atom)