add visual comparison tool. command line option and gui

userinput for two emulations synced by RIOT ports. RIOT port
driver/passenger synchronisation ensures user input is seen by the
emulations at the same time (relative to the emulation's television)

does not yet handle RNGs (randomise on startup or the RNG in the DPC or
DPC+ formats yet). we need to add a context type first
This commit is contained in:
JetSetIlly 2021-11-23 19:44:53 +00:00
parent 56a0d6a4dd
commit 0e3ec6a5d5
26 changed files with 1025 additions and 112 deletions

378
comparison/comparison.go Normal file
View file

@ -0,0 +1,378 @@
// 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 comparison
import (
"fmt"
"image"
"image/color"
"strings"
"sync/atomic"
"github.com/jetsetilly/gopher2600/cartridgeloader"
"github.com/jetsetilly/gopher2600/curated"
"github.com/jetsetilly/gopher2600/emulation"
"github.com/jetsetilly/gopher2600/hardware"
"github.com/jetsetilly/gopher2600/hardware/memory/cartridge/mapper"
"github.com/jetsetilly/gopher2600/hardware/memory/cartridge/supercharger"
"github.com/jetsetilly/gopher2600/hardware/riot/ports"
"github.com/jetsetilly/gopher2600/hardware/television"
"github.com/jetsetilly/gopher2600/hardware/television/signal"
"github.com/jetsetilly/gopher2600/hardware/television/specification"
)
// Comparison type runs a parallel emulation with the intention of comparing
// the output with the driver emulation.
type Comparison struct {
VCS *hardware.VCS
frameInfo television.FrameInfo
img *image.RGBA
cropImg *image.RGBA
diffImg *image.RGBA
cropDiffImg *image.RGBA
isEmulating atomic.Value
emulationQuit chan bool
emulationCompleted chan bool
Render chan *image.RGBA
DiffRender chan *image.RGBA
// pixel renderer implementation for the "driver" emulation. ie. the
// emulation we'll be comparing against
driver driver
}
// NewComparison is the preferred method of initialisation for the Comparison type.
func NewComparison(driver *hardware.VCS) (*Comparison, error) {
cmp := &Comparison{
emulationQuit: make(chan bool, 1),
emulationCompleted: make(chan bool, 1),
Render: make(chan *image.RGBA, 1),
DiffRender: make(chan *image.RGBA, 1),
}
// emulation has completed, by definition, on startup
cmp.emulationCompleted <- true
// set isEmulating atomic as a boolean
cmp.isEmulating.Store(false)
// create a new television. this will be used during the initialisation of
// the VCS and not referred to directly again
tv, err := television.NewTelevision("AUTO")
if err != nil {
return nil, curated.Errorf("comparison: %v", err)
}
tv.AddPixelRenderer(cmp)
tv.SetFPSCap(true)
// create a new VCS instance
cmp.VCS, err = hardware.NewVCS(tv)
if err != nil {
return nil, curated.Errorf("comparison: %v", err)
}
cmp.img = image.NewRGBA(image.Rect(0, 0, specification.ClksScanline, specification.AbsoluteMaxScanlines))
cmp.diffImg = image.NewRGBA(image.Rect(0, 0, specification.ClksScanline, specification.AbsoluteMaxScanlines))
// start with a NTSC television as default
cmp.Resize(television.NewFrameInfo(specification.SpecNTSC))
cmp.Reset()
// create driver
cmp.driver = newDriver()
driver.TV.AddPixelRenderer(&cmp.driver)
// synchronise RIOT ports
sync := make(chan ports.DrivenEvent, 32)
err = cmp.VCS.RIOT.Ports.SynchroniseWithDriver(sync, tv)
if err != nil {
return nil, curated.Errorf("comparison: %v", err)
}
err = driver.RIOT.Ports.SynchroniseWithPassenger(sync, driver.TV)
if err != nil {
return nil, curated.Errorf("comparison: %v", err)
}
return cmp, nil
}
func (cmp *Comparison) String() string {
cart := cmp.VCS.Mem.Cart
s := strings.Builder{}
s.WriteString(fmt.Sprintf("%s (%s cartridge)", cart.ShortName, cart.ID()))
if cc := cart.GetContainer(); cc != nil {
s.WriteString(fmt.Sprintf(" [in %s]", cc.ContainerID()))
}
return s.String()
}
// IsEmulating returns true if the comparison emulator is working. Useful for
// testing whether the cartridgeloader was an emulatable file.
func (cmp *Comparison) IsEmulating() bool {
return cmp.isEmulating.Load().(bool)
}
// EndCreation ends a running emulation that is creating a stream of comparison
// images. Safe to use even when no emulation is running.
func (cmp *Comparison) EndCreation() {
select {
case cmp.emulationQuit <- true:
default:
}
}
// CreateFromLoader will cause images to be generated from a running emulation
// initialised with the specified cartridge loader.
func (cmp *Comparison) CreateFromLoader(cartload cartridgeloader.Loader) error {
if cmp.IsEmulating() {
return curated.Errorf("comparison: emulation already running")
}
// label cartridge loader as coming from the comparison emulation
cartload.EmulationLabel = emulation.ComparisonLabel
// loading hook support required for supercharger
cartload.VCSHook = func(cart mapper.CartMapper, event mapper.Event, args ...interface{}) error {
if _, ok := cart.(*supercharger.Supercharger); ok {
switch event {
case mapper.EventSuperchargerFastloadEnded:
// the supercharger ROM will eventually start execution from the PC
// address given in the supercharger file
// CPU execution has been interrupted. update state of CPU
cmp.VCS.CPU.Interrupted = true
// the interrupted CPU means it never got a chance to
// finalise the result. we force that here by simply
// setting the Final flag to true.
cmp.VCS.CPU.LastResult.Final = true
// call function to complete tape loading procedure
callback := args[0].(supercharger.FastLoaded)
err := callback(cmp.VCS.CPU, cmp.VCS.Mem.RAM, cmp.VCS.RIOT.Timer)
if err != nil {
return err
}
case mapper.EventSuperchargerSoundloadEnded:
return cmp.VCS.TV.Reset(true)
}
}
return nil
}
go func() {
defer func() {
cmp.emulationCompleted <- true
cmp.isEmulating.Store(false)
}()
err := cmp.VCS.AttachCartridge(cartload)
if err != nil {
cmp.driver.quit <- err
return
}
// if we get to this point then we can be reasonably sure that the
// cartridgeloader is emulatable
cmp.isEmulating.Store(true)
// we need the main emulation to be exactly one frame ahead of the
// comparison emulation. the best way of doing this is to not even
// start the comparison emulation immediately
select {
case <-cmp.driver.sync:
case <-cmp.emulationQuit:
return
}
select {
case cmp.driver.ack <- true:
case <-cmp.emulationQuit:
return
}
err = cmp.VCS.Run(func() (emulation.State, error) {
select {
case <-cmp.emulationQuit:
return emulation.Ending, nil
default:
}
return emulation.Running, nil
})
if err != nil {
cmp.driver.quit <- err
return
}
}()
return nil
}
// Resize implements the television.PixelRenderer interface.
func (cmp *Comparison) Resize(frameInfo television.FrameInfo) error {
cmp.frameInfo = frameInfo
crop := image.Rect(
specification.ClksHBlank, frameInfo.VisibleTop,
specification.ClksHBlank+specification.ClksVisible, frameInfo.VisibleBottom,
)
cmp.cropImg = cmp.img.SubImage(crop).(*image.RGBA)
cmp.cropDiffImg = cmp.diffImg.SubImage(crop).(*image.RGBA)
return nil
}
// NewFrame implements the television.PixelRenderer interface.
func (cmp *Comparison) NewFrame(frameInfo television.FrameInfo) error {
cmp.frameInfo = frameInfo
img := *cmp.cropImg
img.Pix = make([]uint8, len(cmp.cropImg.Pix))
copy(img.Pix, cmp.cropImg.Pix)
select {
case cmp.Render <- &img:
default:
}
select {
case <-cmp.driver.sync:
case <-cmp.emulationQuit:
return nil
}
select {
case cmp.driver.ack <- true:
case <-cmp.emulationQuit:
return nil
}
return cmp.diff()
}
// NewScanline implements the television.PixelRenderer interface.
func (cmp *Comparison) NewScanline(scanline int) error {
return nil
}
func (cmp *Comparison) diff() error {
if !cmp.frameInfo.Stable || !cmp.driver.frameInfo.Stable {
return nil
}
if cmp.frameInfo.FrameNum > cmp.driver.frameInfo.FrameNum {
return curated.Errorf("comparison: comparison emulation is running AHEAD of the driver emulation")
} else if cmp.frameInfo.FrameNum < cmp.driver.frameInfo.FrameNum-1 {
return curated.Errorf("comparison: comparison emulation is running BEHIND of the driver emulation")
}
var drvImg *image.RGBA
if cmp.driver.swapIdx {
drvImg = cmp.driver.cropImg[0]
} else {
drvImg = cmp.driver.cropImg[1]
}
if len(cmp.cropImg.Pix) != len(drvImg.Pix) {
return curated.Errorf("comparison: frames are different sizes")
}
if cmp.frameInfo.TotalScanlines != cmp.driver.frameInfo.TotalScanlines || cmp.frameInfo.VisibleTop != cmp.driver.frameInfo.VisibleTop || cmp.frameInfo.VisibleBottom != cmp.driver.frameInfo.VisibleBottom {
return curated.Errorf("comparison: unaligned frames")
}
for i := 0; i < len(cmp.cropImg.Pix); i += 4 {
a := cmp.cropImg.Pix[i : i+3 : i+3]
b := drvImg.Pix[i : i+3 : i+3]
c := cmp.cropDiffImg.Pix[i : i+4 : i+4]
if a[0] != b[0] || a[1] != b[1] || a[2] != b[2] {
c[0] = 0xff
c[1] = 0xff
c[2] = 0xff
} else {
c[0] = 0x00
c[1] = 0x00
c[2] = 0x00
}
c[3] = 0xff
}
cmp.DiffRender <- cmp.cropDiffImg
return nil
}
// SetPixels implements the television.PixelRenderer interface.
func (cmp *Comparison) 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 = cmp.frameInfo.Spec.GetColor(px)
}
// small cap improves performance, see https://golang.org/issue/27857
s := cmp.img.Pix[offset : offset+3 : offset+3]
s[0] = col.R
s[1] = col.G
s[2] = col.B
offset += 4
}
return nil
}
// Reset implements the television.PixelRenderer interface.
func (cmp *Comparison) Reset() {
// clear pixels. setting the alpha channel so we don't have to later (the
// alpha channel never changes)
for y := 0; y < cmp.img.Bounds().Size().Y; y++ {
for x := 0; x < cmp.img.Bounds().Size().X; x++ {
cmp.img.SetRGBA(x, y, color.RGBA{0, 0, 0, 255})
}
}
for y := 0; y < cmp.diffImg.Bounds().Size().Y; y++ {
for x := 0; x < cmp.diffImg.Bounds().Size().X; x++ {
cmp.diffImg.SetRGBA(x, y, color.RGBA{0, 0, 0, 255})
}
}
img := *cmp.cropImg
img.Pix = make([]uint8, len(cmp.cropImg.Pix))
copy(img.Pix, cmp.cropImg.Pix)
select {
case cmp.Render <- &img:
default:
}
}
// EndRendering implements the television.PixelRenderer interface.
func (cmp *Comparison) EndRendering() error {
return nil
}

35
comparison/doc.go Normal file
View file

@ -0,0 +1,35 @@
// 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 comparison facilitates the running of a comparison emulator
// alongside the main emulation.
//
// The package synchronises the two emulations and the main emulation will
// always be exactly one frame ahead of the comparison emulation. Either
// emulation will be stalled for the duration that the other emulation
// completes the next frame.
//
// User input is synchronised by setting the main emulation's RIOT Ports as a
// driver and the comparison emulation's as a passenger (see RIOT package).
//
// Note that the main emulation will be stalled and will not be able to service
// any of the normal communication channels for the duration that the
// comparison emulation is running.
//
// The comparison emulation will produce two streams of pixels. The first is
// the frame-by-frame video output of the emulation; and the second stream
// shows the differences (as white pixels) between corresponding frames from
// the two emulations. Each video stream has a one frame buffer.
package comparison

144
comparison/driver.go Normal file
View file

@ -0,0 +1,144 @@
// 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 comparison
import (
"image"
"image/color"
"github.com/jetsetilly/gopher2600/hardware/television"
"github.com/jetsetilly/gopher2600/hardware/television/signal"
"github.com/jetsetilly/gopher2600/hardware/television/specification"
)
type driver struct {
frameInfo television.FrameInfo
img [2]*image.RGBA
cropImg [2]*image.RGBA
swapIdx bool
sync chan bool
ack chan bool
quit chan error
}
func newDriver() driver {
drv := driver{
sync: make(chan bool),
ack: make(chan bool),
quit: make(chan error),
}
drv.img[0] = image.NewRGBA(image.Rect(0, 0, specification.ClksScanline, specification.AbsoluteMaxScanlines))
drv.img[1] = image.NewRGBA(image.Rect(0, 0, specification.ClksScanline, specification.AbsoluteMaxScanlines))
// start with a NTSC television as default
drv.Resize(television.NewFrameInfo(specification.SpecNTSC))
drv.Reset()
return drv
}
// Resize implements the television.PixelRenderer interface.
func (drv *driver) Resize(frameInfo television.FrameInfo) error {
drv.frameInfo = frameInfo
crop := image.Rect(
specification.ClksHBlank, frameInfo.VisibleTop,
specification.ClksHBlank+specification.ClksVisible, frameInfo.VisibleBottom,
)
drv.cropImg[0] = drv.img[0].SubImage(crop).(*image.RGBA)
drv.cropImg[1] = drv.img[1].SubImage(crop).(*image.RGBA)
return nil
}
// NewFrame implements the television.PixelRenderer interface.
func (drv *driver) NewFrame(frameInfo television.FrameInfo) error {
drv.frameInfo = frameInfo
drv.swapIdx = !drv.swapIdx
select {
case drv.sync <- true:
case err := <-drv.quit:
return err
}
select {
case <-drv.ack:
case err := <-drv.quit:
return err
}
return nil
}
// NewScanline implements the television.PixelRenderer interface.
func (drv *driver) NewScanline(scanline int) error {
return nil
}
// SetPixels implements the television.PixelRenderer interface.
func (drv *driver) SetPixels(sig []signal.SignalAttributes, last int) error {
var col color.RGBA
var offset int
var pix []uint8
if drv.swapIdx {
pix = drv.img[0].Pix
} else {
pix = drv.img[1].Pix
}
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 = drv.frameInfo.Spec.GetColor(px)
}
// small cap improves performance, see https://golang.org/issue/27857
s := pix[offset : offset+3 : offset+3]
s[0] = col.R
s[1] = col.G
s[2] = col.B
offset += 4
}
return nil
}
// Reset implements the television.PixelRenderer interface.
func (m *driver) Reset() {
// clear pixels. setting the alpha channel so we don't have to later (the
// alpha channel never changes)
for i := range m.img {
for y := 0; y < m.img[i].Bounds().Size().Y; y++ {
for x := 0; x < m.img[i].Bounds().Size().X; x++ {
m.img[i].SetRGBA(x, y, color.RGBA{0, 0, 0, 255})
}
}
}
}
// EndRendering implements the television.PixelRenderer interface.
func (drv *driver) EndRendering() error {
return nil
}

View file

@ -23,6 +23,7 @@ import (
"time"
"github.com/jetsetilly/gopher2600/cartridgeloader"
"github.com/jetsetilly/gopher2600/comparison"
"github.com/jetsetilly/gopher2600/curated"
"github.com/jetsetilly/gopher2600/debugger/dbgmem"
"github.com/jetsetilly/gopher2600/debugger/script"
@ -82,6 +83,9 @@ type Debugger struct {
// sure Close() is called on end
loader *cartridgeloader.Loader
// comparison emulator
comparison *comparison.Comparison
// GUI, terminal and controllers
gui gui.GUI
term terminal.Terminal
@ -563,9 +567,14 @@ func (dbg *Debugger) end() {
}
// Starts the main emulation sequence.
func (dbg *Debugger) Start(mode emulation.Mode, initScript string, cartload cartridgeloader.Loader) error {
func (dbg *Debugger) Start(mode emulation.Mode, initScript string, cartload cartridgeloader.Loader, comparisonROM string) error {
err := dbg.addComparisonEmulation(comparisonROM)
if err != nil {
return curated.Errorf("debugger: %v", err)
}
defer dbg.end()
err := dbg.start(mode, initScript, cartload)
err = dbg.start(mode, initScript, cartload)
if err != nil {
if curated.Has(err, terminal.UserQuit) {
return nil
@ -861,6 +870,29 @@ func (dbg *Debugger) attachCartridge(cartload cartridgeloader.Loader) (e error)
return nil
}
func (dbg *Debugger) addComparisonEmulation(comparisonROM string) error {
if comparisonROM == "" {
return nil
}
var err error
dbg.comparison, err = comparison.NewComparison(dbg.vcs)
if err != nil {
return curated.Errorf("debugger: %v", err)
}
dbg.gui.SetFeature(gui.ReqComparison, dbg.comparison.Render, dbg.comparison.DiffRender)
cartload, err := cartridgeloader.NewLoader(comparisonROM, "AUTO")
if err != nil {
return curated.Errorf("debugger: %v", err)
}
dbg.comparison.CreateFromLoader(cartload)
return nil
}
func (dbg *Debugger) hotload() (e error) {
// tell GUI that we're in the initialistion phase
dbg.setState(emulation.Initialising)

View file

@ -156,7 +156,7 @@ func TestDebugger_withNonExistantInitScript(t *testing.T) {
go trm.testSequence()
err = dbg.Start(emulation.ModeDebugger, "non_existent_script", cartridgeloader.Loader{})
err = dbg.Start(emulation.ModeDebugger, "non_existent_script", cartridgeloader.Loader{}, "")
if err != nil {
t.Fatalf(err.Error())
}
@ -179,7 +179,7 @@ func TestDebugger(t *testing.T) {
go trm.testSequence()
err = dbg.Start(emulation.ModeDebugger, "", cartridgeloader.Loader{})
err = dbg.Start(emulation.ModeDebugger, "", cartridgeloader.Loader{}, "")
if err != nil {
t.Fatalf(err.Error())
}

View file

@ -112,4 +112,7 @@ const (
// Label that can be used in the EmulationLabel field of the cartridgeloader.Loader type. to indicate
// that the cartridge has been loaded into the thumbnailer emulation
const ThumbnailerLabel = "thumbnailer"
const (
ThumbnailerLabel = "thumbnailer"
ComparisonLabel = "comparison"
)

View file

@ -423,6 +423,7 @@ func emulate(emulationMode emulation.Mode, md *modalflag.Modes, sync *mainSync)
useSavekey := md.AddBool("savekey", false, "use savekey in player 1 port")
profile := md.AddString("profile", "none", "run performance check with profiling: command separated CPU, MEM, TRACE or ALL")
log := md.AddBool("log", false, "echo debugging log to stdout")
comparison := md.AddString("comparison", "", "ROM to run in parallel for comparison")
stats := &[]bool{false}[0]
if statsview.Available() {
@ -529,7 +530,7 @@ func emulate(emulationMode emulation.Mode, md *modalflag.Modes, sync *mainSync)
// set up a launch function
dbgLaunch := func() error {
err := dbg.Start(emulationMode, *initScript, cartload)
err := dbg.Start(emulationMode, *initScript, cartload, *comparison)
if err != nil {
return err
}

View file

@ -76,7 +76,10 @@ const (
// open ROM selector and return selection over channel. channel is unused
// if emulation is a debugging emulation, in which case the 'chan string'
// can be nil
ReqROMSelector FeatureReq = "ReqROMSelector" // chan string
ReqROMSelector FeatureReq = "ReqROMSelector" // nil
// request for a comparison window to be opened
ReqComparison FeatureReq = "ReqComparison" // chan *image.RGBA, chan *image.RGBA
)
// Sentinal error returned if GUI does no support requested feature.

View file

@ -279,6 +279,10 @@ func (rnd *glsl) render() {
shader = rnd.shaders[colorShaderID]
case rnd.img.wm.timeline.thmbTexture:
shader = rnd.shaders[colorShaderID]
case rnd.img.wm.windows[winComparisonID].(*winComparison).cmpTexture:
shader = rnd.shaders[colorShaderID]
case rnd.img.wm.windows[winComparisonID].(*winComparison).diffTexture:
shader = rnd.shaders[colorShaderID]
default:
shader = rnd.shaders[guiShaderID]
}

View file

@ -110,6 +110,7 @@ var windowDefs = [...]windowDef{
{create: newWinOscilloscope, menu: menuEntry{group: menuTools}, open: true},
{create: newWinTracker, menu: menuEntry{group: menuTools}, open: false},
{create: newWinTimeline, menu: menuEntry{group: menuTools}, open: true},
{create: newWinComparison, menu: menuEntry{group: menuTools}, open: false},
// windows that appear in cartridge specific menu
{create: newWinDPCregisters, menu: menuEntry{group: menuCart, restrictBus: menuRestrictRegister, restrictMapper: []string{"DPC"}}},
@ -137,6 +138,7 @@ var playmodeWindows = [...]string{
winPrefsID,
winSelectROMID,
winTrackerID,
winComparisonID,
}
func newManager(img *SdlImgui) (*manager, error) {

View file

@ -16,6 +16,8 @@
package sdlimgui
import (
"image"
"github.com/jetsetilly/gopher2600/curated"
"github.com/jetsetilly/gopher2600/emulation"
"github.com/jetsetilly/gopher2600/gui"
@ -126,6 +128,14 @@ func (img *SdlImgui) serviceSetFeature(request featureRequest) {
img.wm.windows[winSelectROMID].setOpen(true)
}
case gui.ReqComparison:
err = argLen(request.args, 2)
if err == nil {
img.wm.windows[winComparisonID].(*winComparison).render = request.args[0].(chan *image.RGBA)
img.wm.windows[winComparisonID].(*winComparison).diffRender = request.args[1].(chan *image.RGBA)
img.wm.windows[winComparisonID].(*winComparison).setOpen(true)
}
default:
err = curated.Errorf(gui.UnsupportedGuiFeature, request.request)
}

View file

@ -0,0 +1,136 @@
// 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 sdlimgui
import (
"image"
"github.com/go-gl/gl/v3.2-core/gl"
"github.com/inkyblackness/imgui-go/v4"
"github.com/jetsetilly/gopher2600/hardware/television/specification"
)
const winComparisonID = "Comparison"
type winComparison struct {
img *SdlImgui
open bool
cmpTexture uint32
diffTexture uint32
// render channels are given to use by the main emulation through a GUI request
render chan *image.RGBA
diffRender chan *image.RGBA
}
func newWinComparison(img *SdlImgui) (window, error) {
win := &winComparison{
img: img,
}
gl.GenTextures(1, &win.cmpTexture)
gl.BindTexture(gl.TEXTURE_2D, win.cmpTexture)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.GenTextures(1, &win.diffTexture)
gl.BindTexture(gl.TEXTURE_2D, win.diffTexture)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
return win, nil
}
func (win *winComparison) init() {
}
func (win winComparison) id() string {
return winComparisonID
}
func (win *winComparison) isOpen() bool {
return win.open
}
func (win *winComparison) setOpen(open bool) {
if win.render == nil {
return
}
win.open = open
if win.open {
// clear texture
gl.BindTexture(gl.TEXTURE_2D, win.cmpTexture)
gl.TexImage2D(gl.TEXTURE_2D, 0,
gl.RGBA, 1, 1, 0,
gl.RGBA, gl.UNSIGNED_BYTE,
gl.Ptr([]uint8{0}))
gl.BindTexture(gl.TEXTURE_2D, win.diffTexture)
gl.TexImage2D(gl.TEXTURE_2D, 0,
gl.RGBA, 1, 1, 0,
gl.RGBA, gl.UNSIGNED_BYTE,
gl.Ptr([]uint8{0}))
}
}
func (win *winComparison) draw() {
// receive new thumbnail data and copy to texture
select {
case img := <-win.render:
if img != nil {
gl.PixelStorei(gl.UNPACK_ROW_LENGTH, int32(img.Stride)/4)
defer gl.PixelStorei(gl.UNPACK_ROW_LENGTH, 0)
gl.BindTexture(gl.TEXTURE_2D, win.cmpTexture)
gl.TexImage2D(gl.TEXTURE_2D, 0,
gl.RGBA, int32(img.Bounds().Size().X), int32(img.Bounds().Size().Y), 0,
gl.RGBA, gl.UNSIGNED_BYTE,
gl.Ptr(img.Pix))
}
default:
}
// receive new thumbnail data and copy to texture
select {
case img := <-win.diffRender:
if img != nil {
gl.PixelStorei(gl.UNPACK_ROW_LENGTH, int32(img.Stride)/4)
defer gl.PixelStorei(gl.UNPACK_ROW_LENGTH, 0)
gl.BindTexture(gl.TEXTURE_2D, win.diffTexture)
gl.TexImage2D(gl.TEXTURE_2D, 0,
gl.RGBA, int32(img.Bounds().Size().X), int32(img.Bounds().Size().Y), 0,
gl.RGBA, gl.UNSIGNED_BYTE,
gl.Ptr(img.Pix))
}
default:
}
if !win.open {
return
}
imgui.SetNextWindowPosV(imgui.Vec2{75, 75}, imgui.ConditionFirstUseEver, imgui.Vec2{0, 0})
if imgui.BeginV(win.id(), &win.open, imgui.WindowFlagsAlwaysAutoResize) {
imgui.Image(imgui.TextureID(win.cmpTexture), imgui.Vec2{specification.ClksVisible * 3, specification.AbsoluteMaxScanlines})
imgui.Image(imgui.TextureID(win.diffTexture), imgui.Vec2{specification.ClksVisible * 3, specification.AbsoluteMaxScanlines})
imgui.End()
}
}

View file

@ -59,8 +59,8 @@ type ARMPreferences struct {
// NOTE: this may be superceded in the future to allow for more flexibility
Model prefs.String
// whether the ARM coprocessor (as found in Harmony cartridges) execute
// instantly or if the cycle accurate steppint is attempted
// whether the ARM coprocessor (as found in Harmony cartridges) executes
// instantly
Immediate prefs.Bool
// a value of MAMDriver says to use the driver supplied MAM value. any

View file

@ -20,5 +20,13 @@
// Input from "real" devices is handled by HandleEvent() which passes the event
// to peripherals in the specified PortID.
//
// Peripherals write back to the VCS through the PeripheralBus.
// Emulations can share user input through the DrivenEvent mechanism. The driver
// emulation should call SynchroniseWithPassenger() and the passenger emulation
// should call SynchroniseWithDriver().
//
// With the DrivenEvent mechanism, the driver sends events to the passenger.
// Both emulations will receive the same user input at the same time, relative
// to television coordinates, so it is important that the driver is running
// ahead of the passenger at all time. See comparison package for model
// implementation.
package ports

View file

@ -15,7 +15,10 @@
package ports
import "github.com/jetsetilly/gopher2600/hardware/riot/ports/plugging"
import (
"github.com/jetsetilly/gopher2600/hardware/riot/ports/plugging"
"github.com/jetsetilly/gopher2600/hardware/television/coords"
)
// Event represents the actions that can be performed at one of the VCS ports,
// either the panel or one of the two player ports.
@ -120,3 +123,12 @@ type EventPlayback interface {
type EventRecorder interface {
RecordEvent(plugging.PortID, Event, EventData) error
}
// DrivenEvent is the data passed from a "driver" emulation to a "passenger"
// emulation.
type DrivenEvent struct {
time coords.TelevisionCoords
id plugging.PortID
ev Event
d EventData
}

View file

@ -0,0 +1,136 @@
// 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 ports
import (
"github.com/jetsetilly/gopher2600/curated"
"github.com/jetsetilly/gopher2600/hardware/riot/ports/plugging"
"github.com/jetsetilly/gopher2600/hardware/television/coords"
)
// HandleDrivenEvents checks for input driven from a another emulation.
func (p *Ports) HandleDrivenEvents() error {
if p.checkForDriven {
f := p.drivenInputData
done := false
for !done {
c := p.tv.GetCoords()
if coords.Equal(c, f.time) {
_, err := p.HandleEvent(f.id, f.ev, f.d)
if err != nil {
return err
}
} else if coords.GreaterThan(c, f.time) {
return curated.Errorf("ports: driven input seen too late. emulations not synced correctly.")
} else {
return nil
}
select {
case p.drivenInputData = <-p.fromDriver:
if p.checkForDriven {
curated.Errorf("ports: driven input received before previous input was processed")
}
default:
done = true
p.checkForDriven = false
}
f = p.drivenInputData
}
}
if p.fromDriver != nil {
select {
case p.drivenInputData = <-p.fromDriver:
if p.checkForDriven {
curated.Errorf("ports: driven input received before previous input was processed")
}
p.checkForDriven = true
default:
}
}
return nil
}
// HandlePlaybackEvents requests playback events from all attached and eligible peripherals.
func (p *Ports) HandlePlaybackEvents() error {
if p.playback == nil {
return nil
}
// loop with GetPlayback() until we encounter a NoPortID or NoEvent
// condition. there might be more than one entry for a particular
// frame/scanline/horizpas state so we need to make sure we've processed
// them all.
//
// this happens in particular with recordings that were made of ROMs with
// panel setup configurations (see setup package) - where the switches are
// set when the TV state is at fr=0 sl=0 cl=0
morePlayback := true
for morePlayback {
id, ev, v, err := p.playback.GetPlayback()
if err != nil {
return err
}
morePlayback = id != plugging.PortUnplugged && ev != NoEvent
if morePlayback {
_, err := p.HandleEvent(id, ev, v)
if err != nil {
return err
}
}
}
return nil
}
// HandleEvent implements userinput.HandleInput interface.
func (p *Ports) HandleEvent(id plugging.PortID, ev Event, d EventData) (bool, error) {
var handled bool
var err error
switch id {
case plugging.PortPanel:
handled, err = p.Panel.HandleEvent(ev, d)
case plugging.PortLeftPlayer:
handled, err = p.LeftPlayer.HandleEvent(ev, d)
case plugging.PortRightPlayer:
handled, err = p.RightPlayer.HandleEvent(ev, d)
}
if handled && p.toPassenger != nil {
select {
case p.toPassenger <- DrivenEvent{time: p.tv.GetCoords(), id: id, ev: ev, d: d}:
default:
return handled, curated.Errorf("ports: %v", err)
}
}
// if error was because of an unhandled event then return without error
if err != nil {
return handled, curated.Errorf("ports: %v", err)
}
// record event with the EventRecorder
for _, r := range p.recorder {
return handled, r.RecordEvent(id, ev, d)
}
return handled, nil
}

View file

@ -23,6 +23,7 @@ import (
"github.com/jetsetilly/gopher2600/hardware/memory/addresses"
"github.com/jetsetilly/gopher2600/hardware/memory/bus"
"github.com/jetsetilly/gopher2600/hardware/riot/ports/plugging"
"github.com/jetsetilly/gopher2600/hardware/television/signal"
)
// Input implements the input/output part of the RIOT (the IO in RIOT).
@ -78,6 +79,16 @@ type Ports struct {
//
// we use it to mux the Player0 and Player 1 nibbles into the single register
swchaMux uint8
// the following fields all relate to driven input, for either the driver
// or for the passenger (the driven)
fromDriver chan DrivenEvent
toPassenger chan DrivenEvent
checkForDriven bool
drivenInputData DrivenEvent
// the time of driven events are measured by television coordinates
tv signal.TelevisionCoords
}
// NewPorts is the preferred method of initialisation of the Ports type.
@ -123,7 +134,7 @@ func (p *Ports) Plumb(riotMem bus.ChipBus, tiaMem bus.ChipBus) {
func (p *Ports) Plug(port plugging.PortID, c NewPeripheral) error {
periph := c(port, p)
// notify monitor of pluggin
// notify monitor of plug event
if p.monitor != nil {
p.monitor.Plugged(port, periph.ID())
}
@ -245,14 +256,41 @@ func (p *Ports) Step() {
p.Panel.Step()
}
// AttachPlayback attaches an EventPlayback implementation to all ports that
// implement RecordablePort.
func (p *Ports) AttachPlayback(b EventPlayback) {
p.playback = b
// SynchroniseWithDriver implies that the emulation will receive driven events
// from another emulation.
func (p *Ports) SynchroniseWithDriver(driver chan DrivenEvent, tv signal.TelevisionCoords) error {
if p.toPassenger != nil {
return curated.Errorf("ports: cannot sync with driver: emulation already defined as a driver of input")
}
if p.playback != nil {
return curated.Errorf("ports: cannot sync with driver: emulation is already receiving input from a playback")
}
p.tv = tv
p.fromDriver = driver
return nil
}
// AttachEventRecorder attaches an EventRecorder implementation to all ports
// that implement RecordablePort.
// SynchroniseWithPassenger connects the emulation to a second emulation (the
// passenger) to which user input events will be "driven".
func (p *Ports) SynchroniseWithPassenger(passenger chan DrivenEvent, tv signal.TelevisionCoords) error {
if p.fromDriver != nil {
return curated.Errorf("ports: cannot sync with passenger: emulation already defined as being driven")
}
p.tv = tv
p.toPassenger = passenger
return nil
}
// AttachPlayback attaches an EventPlayback implementation.
func (p *Ports) AttachPlayback(b EventPlayback) error {
if p.fromDriver != nil {
return curated.Errorf("ports: cannot attach playback: emulation already defined as being driven")
}
p.playback = b
return nil
}
// AttachEventRecorder attaches an EventRecorder implementation.
func (p *Ports) AttachEventRecorder(r EventRecorder) {
p.recorder = append(p.recorder, r)
}
@ -279,66 +317,6 @@ func (p *Ports) AttachPlugMonitor(m plugging.PlugMonitor) {
}
}
// GetPlayback requests playback events from all attached and eligible peripherals.
func (p *Ports) GetPlayback() error {
if p.playback == nil {
return nil
}
// loop with GetPlayback() until we encounter a NoPortID or NoEvent
// condition. there might be more than one entry for a particular
// frame/scanline/horizpas state so we need to make sure we've processed
// them all.
//
// this happens in particular with recordings that were made of ROMs with
// panel setup configurations (see setup package) - where the switches are
// set when the TV state is at fr=0 sl=0 cl=0
morePlayback := true
for morePlayback {
id, ev, v, err := p.playback.GetPlayback()
if err != nil {
return err
}
morePlayback = id != plugging.PortUnplugged && ev != NoEvent
if morePlayback {
_, err := p.HandleEvent(id, ev, v)
if err != nil {
return err
}
}
}
return nil
}
// HandleEvent implements userinput.HandleInput interface.
func (p *Ports) HandleEvent(id plugging.PortID, ev Event, d EventData) (bool, error) {
var handled bool
var err error
switch id {
case plugging.PortPanel:
handled, err = p.Panel.HandleEvent(ev, d)
case plugging.PortLeftPlayer:
handled, err = p.LeftPlayer.HandleEvent(ev, d)
case plugging.PortRightPlayer:
handled, err = p.RightPlayer.HandleEvent(ev, d)
}
// if error was because of an unhandled event then return without error
if err != nil {
return handled, curated.Errorf("ports: %v", err)
}
// record event with the EventRecorder
for _, r := range p.recorder {
return handled, r.RecordEvent(id, ev, d)
}
return handled, nil
}
// PeripheralID implements userinput.HandleInput interface.
func (p *Ports) PeripheralID(id plugging.PortID) plugging.PeripheralID {
switch id {

View file

@ -50,13 +50,23 @@ func (vcs *VCS) Run(continueCheck func() (emulation.State, error)) error {
// see the equivalient videoCycle() in the VCS.Step() function for an
// explanation for what's going on here:
videoCycle := func() error {
if err := vcs.RIOT.Ports.GetPlayback(); err != nil {
if err := vcs.RIOT.Ports.HandleDrivenEvents(); err != nil {
return err
}
vcs.TIA.Step(false)
vcs.TIA.Step(false)
vcs.TIA.Step(true)
if err := vcs.RIOT.Ports.HandlePlaybackEvents(); err != nil {
return err
}
if err := vcs.TIA.Step(false); err != nil {
return err
}
if err := vcs.TIA.Step(false); err != nil {
return err
}
if err := vcs.TIA.Step(true); err != nil {
return err
}
vcs.RIOT.Step()
vcs.Mem.Cart.Step(vcs.Clock)

View file

@ -57,25 +57,30 @@ func (vcs *VCS) Step(videoCycleCallback func() error) error {
// I don't believe any visual or audible artefacts of the VCS (undocumented
// or not) rely on the details of the CPU-TIA relationship.
videoCycle := func() error {
// probe for playback events
err := vcs.RIOT.Ports.GetPlayback()
if err != nil {
if err := vcs.RIOT.Ports.HandleDrivenEvents(); err != nil {
return err
}
vcs.TIA.Step(false)
err = videoCycleCallback()
if err != nil {
if err := vcs.RIOT.Ports.HandlePlaybackEvents(); err != nil {
return err
}
vcs.TIA.Step(false)
err = videoCycleCallback()
if err != nil {
if err := vcs.TIA.Step(false); err != nil {
return err
}
vcs.TIA.Step(true)
err = videoCycleCallback()
if err != nil {
if err := videoCycleCallback(); err != nil {
return err
}
if err := vcs.TIA.Step(false); err != nil {
return err
}
if err := videoCycleCallback(); err != nil {
return err
}
if err := vcs.TIA.Step(true); err != nil {
return err
}
if err := videoCycleCallback(); err != nil {
return err
}

View file

@ -16,8 +16,12 @@
// Package coords represents and can work with television coorindates
//
// Coordinates represent the state of the emulation from the point of the
// television and are used throughout the emulation for rewinding,
// recording/playback and general information.
// television. A good way to think about them is as a measurement of time. They
// define *when* something happened (this pixel was drawn, this user input was
// received, etc.) relative to the start of the emulation.
//
// They are used throughout the emulation for rewinding, recording/playback and
// many other sub-systems.
package coords
import "fmt"

View file

@ -85,7 +85,8 @@ type TelevisionTIA interface {
GetCoords() coords.TelevisionCoords
}
// TelevisionSprite exposes only the functions required by the video sprites.
type TelevisionSprite interface {
// TelevisionCoords allows probing of the current "coordinates" of the
// television. Useful for measuring time.
type TelevisionCoords interface {
GetCoords() coords.TelevisionCoords
}

View file

@ -19,6 +19,7 @@ import (
"fmt"
"strings"
"github.com/jetsetilly/gopher2600/curated"
"github.com/jetsetilly/gopher2600/hardware/cpu"
"github.com/jetsetilly/gopher2600/hardware/memory/bus"
"github.com/jetsetilly/gopher2600/hardware/television/signal"
@ -354,7 +355,7 @@ func (tia *TIA) resolveDelayedEvents() {
}
// Step moves the state of the tia forward one video cycle.
func (tia *TIA) Step(readMemory bool) {
func (tia *TIA) Step(readMemory bool) error {
// update debugging information
tia.videoCycles++
@ -604,6 +605,8 @@ func (tia *TIA) Step(readMemory bool) {
// send signal to television
if err := tia.tv.Signal(tia.sig); err != nil {
// TODO: handle error
return
return curated.Errorf("TIA: %v", err)
}
return nil
}

View file

@ -100,7 +100,7 @@ type Video struct {
// tia is a convenient packaging of TIA state that is required by the playfield/sprites.
type tia struct {
tv signal.TelevisionSprite
tv signal.TelevisionCoords
rev *revision.TIARevision
pclk *phaseclock.PhaseClock
hsync *polycounter.Polycounter
@ -121,7 +121,7 @@ type tia struct {
// The references to the TIA's HBLANK state and whether HMOVE is latched, are
// required to tune the delays experienced by the various sprite events (eg.
// reset position).
func NewVideo(mem bus.ChipBus, tv signal.TelevisionSprite, rev *revision.TIARevision,
func NewVideo(mem bus.ChipBus, tv signal.TelevisionCoords, rev *revision.TIARevision,
pclk *phaseclock.PhaseClock, hsync *polycounter.Polycounter,
hblank *bool, hmove *hmove.Hmove) *Video {
tia := tia{
@ -159,7 +159,7 @@ func (vd *Video) Snapshot() *Video {
}
// Plumb ChipBus into TIA/Video components. Update pointers that refer to parent TIA.
func (vd *Video) Plumb(mem bus.ChipBus, tv signal.TelevisionSprite, rev *revision.TIARevision,
func (vd *Video) Plumb(mem bus.ChipBus, tv signal.TelevisionCoords, rev *revision.TIARevision,
pclk *phaseclock.PhaseClock, hsync *polycounter.Polycounter,
hblank *bool, hmove *hmove.Hmove) {
vd.Collisions.Plumb(mem)

View file

@ -208,7 +208,10 @@ func (plb *Playback) AttachToVCS(vcs *hardware.VCS) error {
}
// attach playback to all vcs ports
vcs.RIOT.Ports.AttachPlayback(plb)
err = vcs.RIOT.Ports.AttachPlayback(plb)
if err != nil {
return curated.Errorf("playback: %v", err)
}
return nil
}

View file

@ -435,6 +435,10 @@ func (c *Controllers) ClearInputHandlers() {
// Add HandleInput implementation to list of input handlers. Each input handler
// will receive the ports.Event and ports.EventData.
//
// In many instances when running two parallel emulators that require the same
// user input it is better to add the "driver" and "passenger" emulations to
// RIOT.Ports
func (c *Controllers) AddInputHandler(h HandleInput) {
c.inputHandlers = append(c.inputHandlers, h)
}

View file

@ -13,14 +13,15 @@
// You should have received a copy of the GNU General Public License
// along with Gopher2600. If not, see <https://www.gnu.org/licenses/>.
// Package userinput handles input from real hardware that the the user of the
// emulator is using to control the emualted console.
// Package userinput translates real hardware input from the user's computer,
// to the emulated console. It abstracts real user interface to the emulated
// interface.
//
// It can be thought of as a translation layer between the GUI implementation
// and the hardware riot.ports package. As such, this package attempts to hide
// details of the GUI implementation while protecting the ports package from
// complication.
// The Controllers type processes userinput Events (input from a gamepad for
// example) with the HandleUserInput function. That input is translated into
// an event understood by the emulation (paddle left for example).
//
// The GUI implementation in use during development was SDL and so there will
// be a bias towards that system.
// Many emulations can be attached to a single Controllers type with the
// AddInputHandler(). However, the DrivenEvent mechanism in the riot.ports
// package is a better way to do this in many instances.
package userinput