Reorganizing things so texty is more like the real thing

This commit is contained in:
Zellyn Hunter 2013-03-27 21:49:38 -07:00
parent b9603add19
commit 95c9b2c3ee
6 changed files with 738 additions and 119 deletions

View File

@ -217,6 +217,7 @@ Apple II notes
- http://www.textfiles.com/apple/peeks.pokes.2
- http://www.applefritter.com/node/24236
- http://www.easy68k.com/paulrsm/6502/index.html - Information about firmware
- http://www-personal.umich.edu/~mressl/a2dp/index.html - Apple II documentation project
* Language card
Install in slot 0.
@ -326,3 +327,144 @@ and selects the second 4K bank for $D000-$DFFF.
| Z | DA | 9A | | |
| ESC | 9B | 9B | | |
* Character set
@ABCDEFGHIJKLMNO
PQRSTUVWXYZ[\]^_
!"#$%&'()*+,-./
0123456789:;<=>?
64 * 8 = 512
Revision 7: 256 * 8 = 2048
* Video scanning
H0,H1,H2,H3,H4,H5,HPE'
VA,VB,VC,V0,V1,V2,V3,V4,V5
** Horizontal scanning: H0-H5,HPE'
7 bits, 65 states. 0000000,1000000-1111111. HPE' is low only for one cycle, the long cycle.
40/65 states send picture output. 25/65 are blanked.
** Vertical scanning: VA-VC,V0-V5
Increments on overflow from the horizontal section.
9 bits, 262 states. 011111010-111111111.
192/262 states send picture information, 70/262 are blanked.
V4V3 divides the screen into thirds:
- V4'V3' - Top third
- V4'V3 - Middle third
- V4 V3' - Bottom third
- V4 V3 - Undisplayed (VBL)
6 cycles blanked: 011111010-011111111
64 cycles blanked: 111000000-111111111
| Location on TV screen | Least significant address bits |
|-----------------------+--------------------------------|
| Top | 0000000 - 0100111 (First 40) |
| Middle | 0101000 - 1001111 (Second 40) |
| Bottom | 1010000 - 1110111 (Third 40) |
Lowest 3 bits are easy: H2,H1,H0 == A2,A1,A0
Next four bits are H5-H4-H3 + offset = SUM-A{3..6}
- 0000-0100, 0101-1001, 1010-1110.
H5-H4-H3: 000..010 are undisplayed: right margin, retrace, left margin. 011 first displayed.
H543 -3 in First 40, +2 in second 40, +7 in Third 40.
1 1 0 1
+ H5 H4 H3
+ V4 V3 V4 V3
---------------------------
SUM-A6 SUM-A5 SUM-A4 SUM-A3
** Pages:
$0000-$3FFF - ! HIRES PAGE 2
$4000-$7FFF - HIRES PAGE 2
HIRES TIME:
- high when in HIRES, GRAPHICS, NO MIX mode
- high when in HIRES, GRAPHICS, MIX, !(V4•V2) (·)
| A0 | H0 |
| A1 | H1 |
| A2 | H2 |
| A3 | SUM-A3 |
| A4 | SUM-A4 |
| A5 | SUM-A5 |
| A6 | SUM-A6 |
| A7 | V0 |
| A8 | V1 |
| A9 | V2 |
| A10 | HIRES•VA + TEXT/LORES•PAGE1 |
| A11 | HIRES•VB + TEXT/LORES•PAGE2 |
| A12 | HIRES•VC + TEXT/LORES•HBL |
| A13 | HIRES•PAGE1 |
| A14 | HIRES•PAGE2 |
| A15 | - |
VA,VB,VC determine which part of text characters to draw.
VC determines which of two LORES blocks to draw.
* Colors
| Color Ref | 0011 |
| Dark Magenta | 0001 |
| Light Magenta | 1011 |
| HIRES Violet | 0011 |
| Dark Blue | 0010 |
| Light Blue | 0111 |
| HIRES Blue | 0110 |
| Dark Blue-Green | 0100 |
| Light Blue-Green | 1110 |
| HIRES Green | 1100 |
| Dark Brown | 1000 |
| Light Brown | 1101 |
| HIRES Orange | 1001 |
| $5 Grey | 0101 |
| $A Grey | 1010 |
| White | 1111 |
| Black | 0000 |
* Design
Event loop runs everything.
Key/mouse/resize/quit events come from somewhere (sdl)
Key events go to CPU.
Event loop can pause emulator and bring up menu.
- change disks
- save state, etc.
- change parameters
Video scanner scanning memory -> chroma/luma signal -> color interpreter -> plotting
Event loop hands colorplotter a pixelplotter
colorplotter does green / b/w / color choice
Some kind of menu subsystem for paused/config mode.
* Gospeccy design
spectrum.Application
- HasTerminated chan
- RequestExit()
spectrum.Spectrum48k (app, *rom)
- EmulatorLoop()
- CommandChannel
- speccy.Keyboard.Key{Down,Up}
sdl_output.Main():
- Get a spectrum.Application
- Get a spectrum.Spectrum48k
- r = NewSDLRenderer(app, speccy, ...)
- setUI(r)
- initCLI()
- go sdlEventLoop(app, speccy)
- wait for shutdown
- sdl.Quit()
sdlEventLoop():
- evtLoop = app.NewEventLoop()

77
goapple2.go Normal file
View File

@ -0,0 +1,77 @@
package goapple2
import (
"github.com/zellyn/go6502/cpu"
"github.com/zellyn/goapple2/videoscan"
)
// Memory for the tests. Satisfies the cpu.Memory interface.
type Apple2 struct {
mem [65536]byte
cpu cpu.Cpu
key byte // BUG(zellyn): make reads/writes atomic
keys chan byte
plotter videoscan.Plotter
scanner *videoscan.Scanner
}
// Cycle counter. Satisfies the cpu.Ticker interface.
type CycleCount uint64
func (c *CycleCount) Tick() {
*c += 1
}
func NewApple2(p videoscan.Plotter, rom []byte) *Apple2 {
var cc CycleCount
a2 := Apple2{
keys: make(chan byte, 16),
}
copy(a2.mem[len(a2.mem)-len(rom):len(a2.mem)], rom)
a2.scanner = videoscan.NewScanner(&a2, p, [2048]byte{})
a2.cpu = cpu.NewCPU(&a2, &cc, cpu.VERSION_6502)
a2.cpu.Reset()
return &a2
}
func (a2 *Apple2) Read(address uint16) byte {
// Keyboard read
if address == 0xC000 {
if a2.key&0x80 == 0 {
select {
case key := <-a2.keys:
a2.key = key
default:
}
}
return a2.key
}
if address == 0xC010 {
a2.key &= 0x7F
return 0 // BUG(zellyn): return proper value (keydown on IIe, not sure on II+)
}
return a2.mem[address]
}
func (a2 *Apple2) Write(address uint16, value byte) {
if address >= 0xD000 {
return
}
if address == 0xC010 {
// Clear keyboard strobe
a2.key &= 0x7F
}
a2.mem[address] = value
}
func (a2 *Apple2) Keypress(key byte) {
a2.keys <- key
}
func (a2 *Apple2) Step() error {
if err := a2.cpu.Step(); err != nil {
return err
}
a2.scanner.Scan1()
return nil
}

View File

@ -3,24 +3,14 @@ package main
import (
"fmt"
"io/ioutil"
"time"
"github.com/nsf/termbox-go"
"github.com/zellyn/go6502/asm"
"github.com/zellyn/go6502/cpu"
"github.com/zellyn/goapple2"
"github.com/zellyn/goapple2/util"
"github.com/zellyn/goapple2/videoscan"
)
// Memory for the tests. Satisfies the cpu.Memory interface.
type Apple2 struct {
mem [65536]byte
events chan termbox.Event
done bool
key byte // BUG(zellyn): make reads/writes atomic
keys chan byte
debug bool // Set true and close termbox to start tracing out instructions
}
// Mapping of screen bytes to character values
var AppleChars = "@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_ !\"#$%&'()*+,-./0123456789:;<=>?"
@ -55,136 +45,76 @@ func termboxToAppleKeyboard(ev termbox.Event) (key byte, err error) {
return 0, fmt.Errorf("hi")
}
func (a2 *Apple2) Read(address uint16) byte {
// Keyboard read
if address == 0xC000 {
return a2.key
}
if address == 0xC010 {
a2.key &= 0x7F
return 0 // BUG(zellyn): return proper value (keydown on IIe, not sure on II+)
}
return a2.mem[address]
}
// func Write(address uint16, value byte) {
// a2.mem[address] = value
// if address >= 0x0400 && address < 0x0800 {
// offset := int(address - 0x0400)
// count := offset & 0x7f
// if count <= 119 {
// x := count % 40
// segment := offset / 128
// which40 := count / 40
// y := which40*8 + segment
// ch, fg, bg := translateToTermbox(value)
// termbox.SetCell(x+1, y+1, ch, fg, bg)
// termbox.Flush()
// }
// }
// }
func (a2 *Apple2) Write(address uint16, value byte) {
if address >= 0xD000 {
termbox.Close()
panic(fmt.Sprintln("Write to ROM at address $%04X", address))
}
if address == 0xC010 {
// Clear keyboard strobe
a2.key &= 0x7F
}
a2.mem[address] = value
if !a2.debug && address >= 0x0400 && address < 0x0800 {
offset := int(address - 0x0400)
count := offset & 0x7f
if count <= 119 {
x := count % 40
segment := offset / 128
which40 := count / 40
y := which40*8 + segment
ch, fg, bg := translateToTermbox(value)
termbox.SetCell(x+1, y+1, ch, fg, bg)
termbox.Flush()
}
}
}
func (a2 *Apple2) Init() error {
if err := termbox.Init(); err != nil {
return err
}
a2.events = make(chan termbox.Event)
a2.keys = make(chan byte, 16)
go func() {
for {
a2.events <- termbox.PollEvent()
}
}()
return nil
}
func (a2 *Apple2) Close() {
termbox.Close()
}
func (a2 *Apple2) ProcessEvents() {
func ProcessEvents(events chan termbox.Event, a2 *goapple2.Apple2) bool {
select {
case ev := <-a2.events:
case ev := <-events:
if ev.Type == termbox.EventKey && ev.Ch == '~' {
a2.done = true
return true
}
if ev.Type == termbox.EventKey {
if key, err := termboxToAppleKeyboard(ev); err == nil {
a2.keys <- key | 0x80
a2.Keypress(key | 0x80)
}
}
default:
}
if a2.key&0x80 == 0 {
select {
case key := <-a2.keys:
a2.key = key
default:
}
return false
}
type TextPlotter int
func (p TextPlotter) Plot(data videoscan.PlotData) {
y := int(data.Row / 8)
x := int(data.Column)
value := data.RawData
ch, fg, bg := translateToTermbox(value)
termbox.SetCell(x+1, y+1, ch, fg, bg)
if x == 39 && data.Row == 191 {
termbox.Flush()
}
}
// Cycle counter for the tests. Satisfies the cpu.Ticker interface.
type CycleCount uint64
func (c *CycleCount) Tick() {
*c += 1
}
// printStatus prints out the current CPU instruction and register status.
func printStatus(c cpu.Cpu, m *[65536]byte) {
bytes, text, _ := asm.Disasm(c.PC(), m[c.PC()], m[c.PC()+1], m[c.PC()+2])
fmt.Printf("$%04X: %s %s A=$%02X X=$%02X Y=$%02X SP=$%02X P=$%08b\n",
c.PC(), bytes, text, c.A(), c.X(), c.Y(), c.SP(), c.P())
}
// Run the emulator
func RunEmulator() {
bytes, err := ioutil.ReadFile("../data/roms/apple2+.rom")
if err != nil {
panic("Cannot read ROM file")
rom := util.ReadRomOrDie("../data/roms/apple2+.rom")
plotter := TextPlotter(0)
a2 := goapple2.NewApple2(plotter, rom)
if err := termbox.Init(); err != nil {
panic(err)
}
var a2 Apple2
ROM_OFFSET := 0xD000
copy(a2.mem[ROM_OFFSET:ROM_OFFSET+len(bytes)], bytes)
a2.Init()
var cc CycleCount
c := cpu.NewCPU(&a2, &cc, cpu.VERSION_6502)
c.Reset()
for !a2.done {
// // LIST
// if c.PC() == 0xD6A5 {
// termbox.Close()
// a2.debug = true
// }
// // End of LIST
// if c.PC() == 0xD729 {
// break
// }
if !a2.debug {
a2.ProcessEvents()
events := make(chan termbox.Event)
go func() {
for {
events <- termbox.PollEvent()
}
if a2.debug {
printStatus(c, &a2.mem)
}
err := c.Step()
}()
for !ProcessEvents(events, a2) {
err := a2.Step()
if err != nil {
fmt.Println(err)
break
}
time.Sleep(1 * time.Nanosecond) // So the keyboard-reading goroutines can run
}
if !a2.debug {
a2.Close()
}
termbox.Close()
}
func main() {

13
util/util.go Normal file
View File

@ -0,0 +1,13 @@
package util
import (
"io/ioutil"
)
func ReadRomOrDie(filename string) []byte {
bytes, err := ioutil.ReadFile(filename)
if err != nil {
panic("Cannot read ROM file: " + filename)
}
return bytes
}

233
videoscan/videoscan.go Normal file
View File

@ -0,0 +1,233 @@
package videoscan
import (
"fmt"
"github.com/zellyn/go6502/cpu"
)
const FLASH_CYCLES = 15 // 15/60 = four per second
// PlotData is the struct that holds plotting
// information. Essentially, it holds the (binary) waveform of the
// color pattern.
type PlotData struct {
Row byte "The row (0-191)"
Column byte "The column (0-39)"
ColorBurst bool "Whether the color signal is active"
Data uint16 "14 half color cycles of information"
RawData byte "The underlying raw byte"
}
type Plotter interface {
Plot(PlotData)
}
type Scanner struct {
m cpu.Memory
h uint16
v uint16
plotter Plotter
rom [2048]byte
graphics bool // TEXT/GRAPHICS
mix bool // NOMIX/MIX
hires bool // LORES/HIRES
page2 bool // PAGE1/PAGE2
hbl bool // Horizontal blanking
vbl bool // Vertical blanking
lastFour bool // Are we in the last 4 lines of the screen?
lastBit uint16 // Last bit of previous 14-cycle color data
flasher byte // if high bit is set, invert flashing text
flashCount int // count up, and toggle flasher
}
func NewScanner(m cpu.Memory, p Plotter, rom [2048]byte) *Scanner {
s := &Scanner{
m: m,
plotter: p,
rom: rom,
h: 0x7f,
v: 0x1ff,
}
s.inc()
return s
}
func (s *Scanner) inc() {
// Increment H0..H5,HPE'
switch s.h {
case 0:
s.h = 0x40
case 0x7f:
s.h = 0
// Increment VA-VC,V0-V5
switch s.v {
case 0x1ff:
s.v = 250
if s.flashCount++; s.flashCount >= FLASH_CYCLES {
s.flashCount = 0
s.flasher ^= 0x80
}
default:
s.v++
}
// VBL = V4 & V3
s.vbl = (s.v>>6)&3 == 3
// Last four lines of the screen?
s.lastFour = ((s.v>>5)&5 == 5)
default:
s.h++
}
// HBL = H5' & (H3' + H4')
s.hbl = ((s.h >> 3) & 7) <= 2
}
func (s *Scanner) Scan1() {
m := s.m.Read(s.address())
row, column := s.row(), s.column()
_, _, _ = m, row, column
var data uint16
color := s.graphics
switch {
case !s.graphics || (s.mix && s.lastFour):
data = s.textData(m, row, column)
case s.hires:
data = s.hiresData(m, row, column)
default: // lores
data = s.loresData(m, row, column)
}
s.lastBit = (data >> 13) & 1
if !s.hbl && !s.vbl {
s.plotter.Plot(PlotData{
Row: byte(row),
Column: byte(column),
ColorBurst: color,
Data: data,
RawData: m,
})
}
s.inc()
}
func (s *Scanner) column() int {
return int(s.h) - 0x58 // 0x1011000
}
func (s *Scanner) row() int {
return int(s.v) - 0x100
}
func (s *Scanner) address() uint16 {
// Low three bits are just H0-H2
addr := s.h & 7
// Next four bits are H5,H4,H3 + offset = SUM-A6,SUM-A5,SUM-A4,SUM-A3
bias := uint16(0xD) // 1 1 0 1
hsum := (s.h >> 3) & 7 // 0 H5 H4 H3
vsum := (s.v >> 6) & 3 // V4 V3 V4 V3
vsum = vsum | (vsum << 2)
suma36 := (bias + hsum + vsum) & 0xF
addr |= (suma36 << 3)
// Next three are V0,V1,V2
addr |= ((s.v >> 3 & 7) << 7)
page := uint16(1)
if s.page2 {
page = 2
}
// HIRES TIME when HIRES,GRAPHICS,NOMIX or HIRES,GRAPHICS,MIX,!(V4&V2)
hiresTime := s.hires && s.graphics
if hiresTime && s.mix && s.lastFour {
hiresTime = false
}
if hiresTime {
// A10-A12 = VA-VC
addr |= ((s.v & 7) << 10)
// A13=PAGE1, A14=PAGE2
addr |= page << 13
} else {
// A10=PAGE1, A11=PAGE2
addr |= page << 10
// A12 = HBL
if s.hbl {
addr |= (1 << 12)
}
}
return addr
}
func (s *Scanner) SetGraphics(graphics bool) {
s.graphics = graphics
}
func (s *Scanner) SetMix(mix bool) {
s.mix = mix
}
func (s *Scanner) SetHires(hires bool) {
s.hires = hires
}
func (s *Scanner) SetPage(page int) {
switch page {
case 1:
s.page2 = false
case 2:
s.page2 = true
default:
panic(fmt.Sprint("Page must be 1 or 2, got", page))
}
}
func (s *Scanner) textData(m byte, row int, column int) uint16 {
line := s.rom[int(m)*8+((row+800)%8)]
// Invert if flash
if (m^0x80)&line&s.flasher > 0 {
line ^= 0xff
}
line &= 0x7f // Mask out high bit
// Now it's just like hires data
return s.hiresData(line, row, column)
}
func (s *Scanner) hiresData(m byte, row int, column int) uint16 {
// Double each bit
var data uint16
mm := uint16(m)
// BUG(zellyn): Use bitmagic to do this without looping
for i := byte(6); i != 0xff; i-- {
data |= ((mm >> i) & 1) * 3
data <<= 2
}
// High bit set delays the signal by 1/4 color cycle = 1 bit,
// and extends the last bit to fill in the delay.
if m > 127 {
data <<= 1
data |= s.lastBit
}
return data & 0x3fff
}
func (s *Scanner) loresData(m byte, row int, column int) uint16 {
var data uint16
// First four rows get low nybble, second four high
if row%8 < 4 {
data = uint16(m & 0x0f)
} else {
data = uint16(m >> 4)
}
data = data * 0x1111 // Repeat lower nybble four times
if column%2 == 1 {
data >>= 2
}
return data & 0x3fff
}

224
videoscan/videoscan_test.go Normal file
View File

@ -0,0 +1,224 @@
package videoscan
import (
"testing"
"github.com/zellyn/go6502/tests"
apple2 "github.com/zellyn/goapple2"
)
type fakePlotter struct {
}
func (f *fakePlotter) Plot(apple2.PlotData) {
}
func TestHorizontal(t *testing.T) {
var m tests.Memorizer
var f fakePlotter
s := NewScanner(&m, &f, [2048]byte{})
// 1000 Increments
for i := 0; i < 1000; i++ {
for j := 0; j < 25; j++ {
if s.column() >= 0 {
t.Errorf("Expected s.column() < 0, got %v", s.column())
}
s.Scan1()
}
for j := 0; j < 40; j++ {
if s.column() != j {
t.Errorf("Expected s.column()==%d, got %v", j, s.column())
}
s.Scan1()
}
}
}
func TestVertical(t *testing.T) {
var m tests.Memorizer
var f fakePlotter
s := NewScanner(&m, &f, [2048]byte{})
scan65 := func() {
for i := 0; i < 65; i++ {
s.Scan1()
}
}
// 1000 * 65 (per row) Increments
for i := 0; i < 1000; i++ {
for j := 0; j < 6; j++ {
if s.row() > 0 {
t.Errorf("Expected s.row() <= 0, got %v", s.row())
}
scan65()
}
for j := 0; j < 256; j++ {
if s.row() != j {
t.Errorf("Expected s.row() == %d, got %v", j, s.row())
}
scan65()
}
}
}
func TestBlanking(t *testing.T) {
var m tests.Memorizer
var f fakePlotter
s := NewScanner(&m, &f, [2048]byte{})
for i := 0; i < 1000000; i++ {
if s.column() < 0 || s.column() >= 40 {
if !s.hbl {
t.Errorf("s.column()==%d, but s.hbl=%v", s.column(), s.hbl)
}
} else {
if s.hbl {
t.Errorf("s.column()==%d, but s.hbl=%v", s.column(), s.hbl)
}
}
if s.row() < 0 || s.row() >= 192 {
if !s.vbl {
t.Errorf("s.row()==%d, but s.vbl=%v", s.row(), s.vbl)
}
} else {
if s.vbl {
t.Errorf("s.row()==%d, but s.vbl=%v", s.row(), s.vbl)
}
}
s.Scan1()
}
}
func TestSpecificLocationAddresses(t *testing.T) {
var m tests.Memorizer
var f fakePlotter
s := NewScanner(&m, &f, [2048]byte{})
// TEXT/LORES Page 1
text_lores_page1 := map[int]uint16{
0: 0x400,
5: 0x680,
8: 0x428,
16: 0x450,
14: 0x728,
19: 0x5D0,
20: 0x650,
21: 0x6D0,
22: 0x750,
23: 0x7D0,
}
// HIRES Page 1
hires := map[int]uint16{
0: 0x2000,
8: 0x2080,
64: 0x2028,
99: 0x2E28,
127: 0x3FA8,
128: 0x2050,
100: 0x3228,
159: 0x3DD0,
160: 0x2250,
170: 0x2AD0,
183: 0x3F50,
191: 0x3FD0,
}
s.SetMix(false)
s.SetGraphics(false)
s.SetHires(false)
for i := 0; i < 1000000; i++ {
s.Scan1()
row := s.row()
if addr, ok := text_lores_page1[row/8]; ok {
col := s.column()
if row >= 0 && col >= 0 && col < 40 {
addrplus := addr + uint16(col)
s.SetPage(1)
s.SetGraphics(false)
if addrplus != s.address() {
t.Fatalf("Row %d (%d), col %d, expected $%04X+$%02X=$%04X, got $%04X",
row, row/8, col, addr, col, addrplus, s.address())
}
s.SetGraphics(true)
if addrplus != s.address() {
t.Fatalf("Row %d (%d), col %d, expected $%04X+$%02X=$%04X, got $%04X",
row, row/8, col, addr, col, addrplus, s.address())
}
s.SetMix(true)
if addrplus != s.address() {
t.Fatalf("Row %d (%d), col %d, expected $%04X+$%02X=$%04X, got $%04X",
row, row/8, col, addr, col, addrplus, s.address())
}
addr += 0x400
addrplus += 0x400
s.SetPage(2)
if addrplus != s.address() {
t.Fatalf("Row %d (%d), col %d, expected $%04X+$%02X=$%04X, got $%04X",
row, row/8, col, addr, col, addrplus, s.address())
}
}
}
}
s.SetMix(false)
s.SetGraphics(true)
s.SetHires(true)
for i := 0; i < 1000000; i++ {
s.Scan1()
row := s.row()
if addr, ok := hires[row]; ok {
col := s.column()
if col >= 0 && col < 40 {
addrplus := addr + uint16(col)
s.SetPage(1)
if addrplus != s.address() {
t.Fatalf("Row %d, col %d, expected $%04X+$%02X=$%04X, got $%04X",
row, col, addr, col, addrplus, s.address())
}
s.SetPage(2)
addr += 0x2000
addrplus += 0x2000
if addrplus != s.address() {
t.Fatalf("Row %d, col %d, expected $%04X+$%02X=$%04X, got $%04X",
row, col, addr, col, addrplus, s.address())
}
}
}
}
// Mixed hires/text
s.SetPage(1)
s.SetMix(true)
s.SetGraphics(true)
s.SetHires(true)
for i := 0; i < 1000000; i++ {
s.Scan1()
row := s.row()
if row >= 160 {
if addr, ok := text_lores_page1[row/8]; ok {
col := s.column()
if row >= 0 && col >= 0 && col < 40 {
addrplus := addr + uint16(col)
if addrplus != s.address() {
t.Fatalf("Row %d (%d), col %d, expected $%04X+$%02X=$%04X, got $%04X",
row, row/8, col, addr, col, addrplus, s.address())
}
}
}
} else {
if addr, ok := hires[row]; ok {
col := s.column()
if col >= 0 && col < 40 {
addrplus := addr + uint16(col)
if addrplus != s.address() {
t.Fatalf("Row %d, col %d, expected $%04X+$%02X=$%04X, got $%04X",
row, col, addr, col, addrplus, s.address())
}
}
}
}
}
}