Gopher2600/bots/chess/videochess.go
JetSetIlly 3cff5da258 updated vidchess bot
can now work with vidchess level 2-8 as well as level 1

image searching now works with sha1 hashes throughout
2023-09-23 20:12:12 +01:00

804 lines
21 KiB
Go

// This file is part of Gopher2600.
//
// Gopher2600 is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Gopher2600 is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Gopher2600. If not, see <https://www.gnu.org/licenses/>.
package chess
import (
"crypto/sha1"
"fmt"
"image"
"image/color"
"time"
"golang.org/x/image/draw"
"github.com/jetsetilly/gopher2600/bots"
"github.com/jetsetilly/gopher2600/bots/chess/uci"
"github.com/jetsetilly/gopher2600/hardware/riot/ports"
"github.com/jetsetilly/gopher2600/hardware/riot/ports/plugging"
"github.com/jetsetilly/gopher2600/hardware/television"
"github.com/jetsetilly/gopher2600/hardware/television/signal"
"github.com/jetsetilly/gopher2600/hardware/television/specification"
)
const (
boardMarginLeft = 44
boardMarginTop = 70
squareWidth = 8
squareHeight = 17
)
var startingImage = [sha1.Size]byte{206, 150, 33, 111, 96, 82, 105, 37, 18, 218, 111, 99, 43, 70, 231, 84, 152, 24, 218, 150}
var cursorImage = [...][sha1.Size]byte{
{83, 220, 207, 9, 64, 158, 200, 51, 169, 237, 1, 196, 0, 207, 32, 77, 219, 49, 35, 217}, // d4
{255, 122, 114, 141, 91, 68, 233, 75, 220, 69, 73, 26, 168, 108, 231, 177, 255, 176, 111, 189}, // c4
{201, 133, 210, 171, 75, 83, 176, 152, 72, 255, 249, 14, 51, 126, 184, 76, 175, 103, 219, 212}, // d5
{240, 230, 144, 234, 5, 94, 222, 71, 209, 76, 140, 97, 1, 222, 83, 77, 171, 68, 240, 123}, // c5
}
var levelIndicator = [...][sha1.Size]byte{
{99, 52, 243, 80, 35, 157, 46, 32, 23, 100, 35, 174, 34, 63, 2, 28, 203, 126, 39, 141}, // level 1
{98, 49, 142, 237, 198, 6, 223, 99, 47, 61, 44, 208, 237, 9, 148, 145, 20, 23, 160, 192}, // level 2
{170, 135, 34, 218, 84, 20, 54, 57, 16, 204, 66, 40, 206, 187, 7, 222, 249, 241, 172, 255}, // level 3
{226, 47, 71, 132, 32, 2, 218, 84, 11, 25, 99, 146, 244, 173, 49, 255, 236, 232, 106, 56}, // level 4
{189, 91, 225, 111, 23, 183, 93, 14, 13, 209, 74, 47, 128, 112, 182, 117, 25, 134, 3, 55}, // level 5
{255, 145, 27, 1, 59, 53, 77, 197, 209, 189, 82, 90, 199, 159, 105, 186, 15, 20, 50, 89}, // level 6
{23, 33, 105, 26, 4, 182, 183, 51, 48, 154, 103, 213, 148, 20, 220, 218, 122, 25, 197, 50}, // level 7
{216, 88, 112, 235, 56, 55, 121, 175, 73, 16, 98, 50, 230, 208, 22, 83, 124, 237, 193, 212}, // level 8
}
type observer struct {
frameInfo television.FrameInfo
img *image.RGBA
analysis chan *image.RGBA
audioFeedback chan bool
}
func newObserver() *observer {
obs := &observer{
img: image.NewRGBA(image.Rect(0, 0, specification.ClksScanline, specification.AbsoluteMaxScanlines)),
analysis: make(chan *image.RGBA, 1),
audioFeedback: make(chan bool, 1),
}
obs.Resize(television.NewFrameInfo(specification.SpecNTSC))
obs.Reset()
return obs
}
const audioSilence = 15
func (o *observer) SetAudio(sig []signal.SignalAttributes) error {
for _, s := range sig {
if uint8((s&signal.AudioChannel0)>>signal.AudioChannel0Shift) != audioSilence {
select {
case o.audioFeedback <- true:
default:
}
}
}
return nil
}
func (o *observer) EndMixing() error {
return nil
}
func (obs *observer) Resize(frameInfo television.FrameInfo) error {
obs.frameInfo = frameInfo
return nil
}
func (obs *observer) NewFrame(frameInfo television.FrameInfo) error {
obs.frameInfo = frameInfo
img := *obs.img
img.Pix = make([]uint8, len(obs.img.Pix))
copy(img.Pix, obs.img.Pix)
select {
case obs.analysis <- &img:
default:
}
return nil
}
func (obs *observer) NewScanline(scanline int) error {
return nil
}
func (obs *observer) SetPixels(sig []signal.SignalAttributes, last int) error {
var col color.RGBA
var offset int
for i := range sig {
// handle VBLANK by setting pixels to black
if sig[i]&signal.VBlank == signal.VBlank {
col = color.RGBA{R: 0, G: 0, B: 0}
} else {
px := signal.ColorSignal((sig[i] & signal.Color) >> signal.ColorShift)
col = obs.frameInfo.Spec.GetColor(px)
}
// small cap improves performance, see https://golang.org/issue/27857
s := obs.img.Pix[offset : offset+3 : offset+3]
s[0] = col.R
s[1] = col.G
s[2] = col.B
offset += 4
}
return nil
}
func (obs *observer) Reset() {
// clear pixels. setting the alpha channel so we don't have to later (the
// alpha channel never changes)
for y := 0; y < obs.img.Bounds().Size().Y; y++ {
for x := 0; x < obs.img.Bounds().Size().X; x++ {
obs.img.SetRGBA(x, y, color.RGBA{0, 0, 0, 255})
}
}
}
func (obs *observer) EndRendering() error {
return nil
}
type chessColors struct {
blackSquare color.RGBA
whiteSquare color.RGBA
blackPiece color.RGBA
whitePiece color.RGBA
}
type videoChessBot struct {
obs *observer
input bots.Input
tv bots.TV
// quit as soon as possible when a value appears on the channel
quit chan bool
quitting bool
// the various colors used by video chess
colors chessColors
// the most recent position
currentPosition *image.RGBA
// a copy of the board at the end of the previous move by white
prevPosition *image.RGBA
// the square last looked at in detail
inspectionSquare *image.RGBA
cmpSquare *image.RGBA
moveFrom image.Rectangle
moveTo image.Rectangle
feedback bots.Feedback
// the detected video chess level
level int
}
// composites a simple debugging image and forward it onto the feeback.Images channel.
func (bot *videoChessBot) commitDebuggingRender() {
img := *bot.currentPosition
img.Pix = make([]uint8, len(bot.currentPosition.Pix))
copy(img.Pix, bot.currentPosition.Pix)
col := color.RGBA{200, 50, 50, 100}
draw.Draw(&img, bot.moveFrom, &image.Uniform{col}, image.Point{}, draw.Over)
col = color.RGBA{50, 200, 50, 100}
draw.Draw(&img, bot.moveTo, &image.Uniform{col}, image.Point{}, draw.Over)
r := bot.inspectionSquare.Bounds()
r.Max = r.Max.Mul(3)
draw.NearestNeighbor.Scale(&img, r.Add(image.Point{X: 10, Y: 10}), bot.inspectionSquare, bot.inspectionSquare.Bounds(), draw.Src, nil)
select {
case bot.feedback.Images <- &img:
default:
}
}
// returns the image.Rectangle for a square on the chess board
func (bot *videoChessBot) getRect(col int, row int) image.Rectangle {
minX := specification.ClksHBlank + boardMarginLeft + (col * squareWidth)
minY := boardMarginTop + (row * squareHeight)
maxX := minX + squareWidth
maxY := minY + squareHeight
return image.Rect(minX, minY, maxX, maxY)
}
// checks for board by looking for level indicator - board disappears during
// "thinking" so it's important that we can detect the presence of the board,
// indicating that it's our turn
//
// returns true if level indicator is visible
func (bot *videoChessBot) lookForBoard() bool {
clip := bot.getRect(2, -1)
draw.Draw(bot.inspectionSquare, bot.inspectionSquare.Bounds(), bot.currentPosition.SubImage(clip), clip.Min, draw.Src)
return levelIndicator[bot.level] == sha1.Sum(bot.inspectionSquare.Pix)
}
// get level indicator for future calls to lookForBoard
func (bot *videoChessBot) getLevel() bool {
clip := bot.getRect(2, -1)
draw.Draw(bot.inspectionSquare, bot.inspectionSquare.Bounds(), bot.currentPosition.SubImage(clip), clip.Min, draw.Src)
hash := sha1.Sum(bot.inspectionSquare.Pix)
for b := range levelIndicator {
if levelIndicator[b] == hash {
bot.level = b
return true
}
}
return false
}
// compares currentPosition with prevPosition for a change. returns fromCol,
// fromRow and toCol, toRow - indicating the move
//
// returned row value is flipped to normal chess orientation. rows are counted from the
// bottom (white side) in chess.
func (bot *videoChessBot) lookForVCSMove() (int, int, int, int) {
fromCol := -1
fromRow := -1
toCol := -1
toRow := -1
for row := 0; row < 8; row++ {
for col := 0; col < 8; col++ {
b := bot.isCursor(bot.prevPosition, col, row)
a := bot.isCursor(bot.currentPosition, col, row)
if a && !b {
fromCol = col
fromRow = row
// end for loops
col = 8
row = 8
}
}
}
if fromCol == -1 && fromRow == -1 {
return -1, -1, -1, -1
}
for row := 0; row < 8; row++ {
for col := 0; col < 8; col++ {
if !bot.isCursor(bot.currentPosition, col, row) {
if !bot.isCursor(bot.prevPosition, col, row) {
if !bot.cmpBlackPosition(col, row) {
toCol = col
toRow = row
// end for loops
col = 8
row = 8
}
}
}
}
}
return fromCol, 8 - fromRow, toCol, 8 - toRow
}
// returns false if equivalent squares in the currentPosition and prevPosition
// images are different. only empty sqaures and black pieces are considered.
func (bot *videoChessBot) cmpBlackPosition(col int, row int) bool {
clip := bot.getRect(col, row)
draw.Draw(bot.inspectionSquare, bot.inspectionSquare.Bounds(), bot.currentPosition.SubImage(clip), clip.Min, draw.Src)
draw.Draw(bot.cmpSquare, bot.inspectionSquare.Bounds(), bot.prevPosition.SubImage(clip), clip.Min, draw.Src)
// it just happens to be that the red component of all the possible colors
// are different so we can restrict our inspection just to that component.
for i := 0; i < len(bot.inspectionSquare.Pix); i += 4 {
// limit inspection to the black pieces. we do this by returning true,
// indicating that the square hasn't changed
r := bot.inspectionSquare.Pix[i]
if r == bot.colors.whitePiece.R {
return true
}
// value is different. return false to indicate difference
if bot.inspectionSquare.Pix[i] != bot.cmpSquare.Pix[i] {
return false
}
}
// nothing has changed
return true
}
// searches currentPosition image for the cursor. returns col and row of found
// cursor. -1 if it is not found.
//
// returned row value is flipped to normal chess orientation. rows are counted
// from the bottom (white side) in chess.
func (bot *videoChessBot) lookForCursor() (int, int) {
// counting from top-to-bottom and left-to-right
for row := 0; row < 8; row++ {
for col := 0; col < 8; col++ {
if bot.isCursor(bot.currentPosition, col, row) {
return col, 8 - row
}
}
}
return -1, -1
}
// checks the seearchImage at the square indicated by col/row to see if it is
// contains the cursor
func (bot *videoChessBot) isCursor(searchImage *image.RGBA, col int, row int) bool {
clip := bot.getRect(col, row)
draw.Draw(bot.inspectionSquare, bot.inspectionSquare.Bounds(), searchImage.SubImage(clip), clip.Min, draw.Src)
hash := sha1.Sum(bot.inspectionSquare.Pix)
for _, c := range cursorImage {
if c == hash {
return true
}
}
return false
}
func (bot *videoChessBot) isEmptySquare(searchImage *image.RGBA, col int, row int) bool {
clip := bot.getRect(col, row)
draw.Draw(bot.inspectionSquare, bot.inspectionSquare.Bounds(), searchImage.SubImage(clip), clip.Min, draw.Src)
seq := bot.inspectionSquare.Pix[:4]
for i := 4; i < len(bot.inspectionSquare.Pix); i += 4 {
if bot.inspectionSquare.Pix[i] != seq[0] || bot.inspectionSquare.Pix[i+1] != seq[1] || bot.inspectionSquare.Pix[i+2] != seq[2] {
return false
}
}
return true
}
// number of frames to leave stick down. too long and the cursor will move
// more than one square
const downDuration = 20
// moves cursor once in the suppied direction. tries as often as necessary
// until it hears the audible feedback.
func (bot *videoChessBot) moveCursorOnceStep(portid plugging.PortID, direction ports.Event) {
bot.waitForFrames(1)
draining := true
for draining {
select {
case <-bot.obs.audioFeedback:
default:
draining = false
}
}
waiting := true
for waiting {
bot.input.PushEvent(ports.InputEvent{Port: portid, Ev: direction, D: ports.DataStickTrue})
bot.waitForFrames(downDuration)
bot.input.PushEvent(ports.InputEvent{Port: portid, Ev: direction, D: ports.DataStickFalse})
select {
case <-bot.obs.audioFeedback:
waiting = false
default:
}
}
}
// move cursor by the number of columns/rows indicated. negative columns
// indicate right and negative rows indicate up.
func (bot *videoChessBot) moveCursor(moveCol int, moveRow int, shortcut bool) {
if moveCol == moveRow || -moveCol == moveRow {
bot.feedback.Diagnostic <- bots.Diagnostic{
Group: "VideoChess",
Diagnostic: fmt.Sprintf("moving cursor (diagonally) %d %d\n", moveCol, moveRow),
}
move := moveCol
if move < 0 {
move = -move
}
var direction ports.Event
if moveCol > 0 && moveRow > 0 {
direction = ports.LeftDown
} else if moveCol > 0 && moveRow < 0 {
direction = ports.LeftUp
} else if moveCol < 0 && moveRow > 0 {
direction = ports.RightDown
} else if moveCol < 0 && moveRow < 0 {
direction = ports.RightUp
}
for i := 0; i < move; i++ {
bot.moveCursorOnceStep(plugging.PortLeft, direction)
}
} else {
bot.feedback.Diagnostic <- bots.Diagnostic{
Group: "VideoChess",
Diagnostic: fmt.Sprintf("moving cursor %d %d\n", moveCol, moveRow),
}
if shortcut {
if moveCol > 4 {
moveCol -= 8
moveRow-- // correct moveRow caused by board wrap
} else if moveCol < -4 {
moveCol += 8
moveRow++ // correct moveRow caused by board wrap
}
if moveRow > 4 {
moveRow -= 8
} else if moveRow < -4 {
moveRow += 8
}
}
if moveCol > 0 {
for i := 0; i < moveCol; i++ {
bot.moveCursorOnceStep(plugging.PortLeft, ports.Left)
}
} else if moveCol < 0 {
for i := 0; i > moveCol; i-- {
bot.moveCursorOnceStep(plugging.PortLeft, ports.Right)
}
}
if moveRow > 0 {
for i := 0; i < moveRow; i++ {
bot.moveCursorOnceStep(plugging.PortLeft, ports.Down)
}
} else if moveRow < 0 {
for i := 0; i > moveRow; i-- {
bot.moveCursorOnceStep(plugging.PortLeft, ports.Up)
}
}
}
bot.waitForFrames(1)
draining := true
for draining {
select {
case <-bot.obs.audioFeedback:
case <-bot.quit:
bot.quitting = true
return
default:
draining = false
}
}
waiting := true
for waiting {
bot.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Fire, D: true})
bot.waitForFrames(downDuration)
bot.input.PushEvent(ports.InputEvent{Port: plugging.PortLeft, Ev: ports.Fire, D: false})
select {
case <-bot.obs.audioFeedback:
waiting = false
case <-bot.quit:
bot.quitting = true
return
default:
}
}
}
// block goroutine until n frames have passed
func (bot *videoChessBot) waitForFrames(n int) {
for i := 0; i < n; i++ {
select {
case <-bot.obs.analysis:
case <-bot.quit:
bot.quitting = true
return
}
}
}
// NewVideoChess creates a new bot able to play chess (via a UCI engine).
func NewVideoChess(vcs bots.Input, tv bots.TV, specID string) (bots.Bot, error) {
if specID != "NTSC" {
return nil, fmt.Errorf("videochess: television spec %s is unsupported", specID)
}
bot := &videoChessBot{
obs: newObserver(),
input: vcs,
tv: tv,
quit: make(chan bool),
prevPosition: image.NewRGBA(image.Rect(0, 0, specification.ClksScanline, specification.AbsoluteMaxScanlines)),
inspectionSquare: image.NewRGBA(image.Rect(0, 0, squareWidth, squareHeight)),
cmpSquare: image.NewRGBA(image.Rect(0, 0, squareWidth, squareHeight)),
feedback: bots.Feedback{
Images: make(chan *image.RGBA, 1),
Diagnostic: make(chan bots.Diagnostic, 64),
},
}
// the colors used by VideoChess. this is for the NTSC version of VideoChess
//
// 130 = black squares
// 132 = white squares
// 38 = black pieces
// 142 = white pieces
bot.colors.blackSquare = specification.SpecNTSC.GetColor(130)
bot.colors.whiteSquare = specification.SpecNTSC.GetColor(132)
bot.colors.blackPiece = specification.SpecNTSC.GetColor(38)
bot.colors.whitePiece = specification.SpecNTSC.GetColor(142)
tv.AddPixelRenderer(bot.obs)
tv.AddAudioMixer(bot.obs)
ucii, err := uci.NewUCI("/usr/local/bin/stockfish", bot.feedback.Diagnostic)
if err != nil {
ucii, err = uci.NewUCI("/usr/bin/stockfish", bot.feedback.Diagnostic)
if err != nil {
ucii, err = uci.NewUCI("/usr/games/stockfish", bot.feedback.Diagnostic)
if err != nil {
return nil, fmt.Errorf("videochess: %w", err)
}
}
}
ucii.Start()
go func() {
defer func() {
ucii.Quit <- true
<-bot.quit
}()
// look for board
started := false
for !started {
select {
case bot.currentPosition = <-bot.obs.analysis:
case <-bot.quit:
return
}
imgHash := sha1.Sum(bot.currentPosition.Pix)
started = startingImage == imgHash
}
bot.feedback.Diagnostic <- bots.Diagnostic{
Group: "VideoChess",
Diagnostic: "video chess recognised",
}
bot.commitDebuggingRender()
// wait for a short period to give time for user to set play level
bot.feedback.Diagnostic <- bots.Diagnostic{
Group: "VideoChess",
Diagnostic: "waiting 5 seconds before beginning",
}
dur, _ := time.ParseDuration("5s")
select {
case <-time.After(dur):
case <-bot.quit:
return
}
// consume two frames before checking video chess level. the first one
// will have been waiting for a while and will be stale
select {
case bot.currentPosition = <-bot.obs.analysis:
case <-bot.quit:
return
}
select {
case bot.currentPosition = <-bot.obs.analysis:
case <-bot.quit:
return
}
// get video chess level
if bot.getLevel() {
bot.feedback.Diagnostic <- bots.Diagnostic{
Group: "VideoChess",
Diagnostic: fmt.Sprintf("playing at level %d", bot.level+1),
}
} else {
bot.feedback.Diagnostic <- bots.Diagnostic{
Group: "VideoChess",
Diagnostic: "unrecognised level",
}
return
}
// bot is playing white so ask for first move by submitting an empty move
ucii.SubmitMove <- ""
for {
var cursorCol, cursorRow int
waiting := true
for waiting {
select {
case bot.currentPosition = <-bot.obs.analysis:
case <-bot.quit:
return
}
cursorCol, cursorRow = bot.lookForCursor()
waiting = cursorCol == -1 || cursorRow == -1
bot.commitDebuggingRender()
}
// wait for move from UCI engine
var move string
select {
case move = <-ucii.GetMove:
bot.feedback.Diagnostic <- bots.Diagnostic{
Group: "VideoChess",
Diagnostic: fmt.Sprintf("playing move %s\n", move),
}
case <-bot.quit:
return
}
// convert move into row/col numbers
fromCol := int(move[0]) - 97
fromRow := int(move[1]) - 48
toCol := int(move[2]) - 97
toRow := int(move[3]) - 48
// move to piece being moved
moveCol := cursorCol - fromCol
moveRow := cursorRow - fromRow
bot.moveCursor(moveCol, moveRow, true)
if bot.quitting {
return
}
bot.commitDebuggingRender()
// move piece to new position
moveCol = fromCol - toCol
moveRow = fromRow - toRow
bot.moveCursor(moveCol, moveRow, false)
if bot.quitting {
return
}
// correct from/to row values after moving
fromRow = 8 - fromRow
toRow = 8 - toRow
// move rectangles for move we just made
bot.moveFrom = bot.getRect(fromCol, fromRow)
bot.moveTo = bot.getRect(toCol, toRow)
bot.commitDebuggingRender()
// moved piece flashes so we should wait for the correct frame and
// then remember the white position
waiting = true
for waiting {
select {
case bot.currentPosition = <-bot.obs.analysis:
case <-bot.quit:
return
}
waiting = bot.isEmptySquare(bot.currentPosition, toCol, toRow)
bot.commitDebuggingRender()
}
copy(bot.prevPosition.Pix, bot.currentPosition.Pix)
// wait for board to disappear to indicate the the VCS is thinking
waiting = true
for waiting {
select {
case bot.currentPosition = <-bot.obs.analysis:
case <-bot.quit:
return
}
waiting = bot.lookForBoard()
}
bot.feedback.Diagnostic <- bots.Diagnostic{
Group: "VideoChess",
Diagnostic: "vcs is thinking",
}
// wait for it to reappear to indicate that the VCS move has been made
waiting = true
for waiting {
select {
case bot.currentPosition = <-bot.obs.analysis:
case <-bot.quit:
return
}
waiting = !bot.lookForBoard()
}
// figure out which piece has been moved
waiting = true
for waiting {
select {
case bot.currentPosition = <-bot.obs.analysis:
case <-bot.quit:
return
}
fromCol, fromRow, toCol, toRow = bot.lookForVCSMove()
waiting = fromCol == -1 || fromRow == -1 || toCol == -1 || toRow == -1
}
// highlight 'from' and 'to' squanre
bot.moveFrom = bot.getRect(fromCol, fromRow)
bot.moveTo = bot.getRect(toCol, toRow)
move = fmt.Sprintf("%c%d%c%d", rune(fromCol+97), fromRow, rune(toCol+97), toRow)
// convert VCS move information to UCI notation
bot.feedback.Diagnostic <- bots.Diagnostic{
Group: "VideoChess",
Diagnostic: fmt.Sprintf("vcs played %s\n", move),
}
// submit to UCI engine
ucii.SubmitMove <- move
// draw move just made by the VCS
bot.moveFrom = bot.getRect(fromCol, 8-fromRow)
bot.moveTo = bot.getRect(toCol, 8-toRow)
bot.commitDebuggingRender()
}
}()
return bot, nil
}
// BotID implements the bots.Bot interface.
func (bot *videoChessBot) BotID() string {
return "videochess"
}
// Quit implements the bots.Bot interface.
func (bot *videoChessBot) Quit() {
// wait until quit has been honoured
bot.quit <- true
bot.tv.RemovePixelRenderer(bot.obs)
bot.tv.RemoveAudioMixer(bot.obs)
}
// Feedback implements the bots.Bot interface.
func (bot *videoChessBot) Feedback() *bots.Feedback {
return &bot.feedback
}