thumbnail preview on timeline hover

introduced PlumbFromDifferentEmulation interface. the ARM emulation
doesn't like being moved between emulation instances so the ARM is
recreated when plumbing a state that originated in a different
emulation. not all mappers need to implement this interface.
This commit is contained in:
JetSetIlly 2021-11-15 22:57:10 +00:00
parent 98b7b13c0b
commit 4f4bee0b36
13 changed files with 197 additions and 45 deletions

View file

@ -451,8 +451,8 @@ frame is indicated by the orange circle.
<img src=".screenshots/timeline_window.png" width="500" alt="timeline window"/>
Hovering over the timeline will show details of the frame (number of scanlines,
percentage of WSYNC usage, etc.). Clicking on the timeline will instantly take
the emulation to that state.
percentage of WSYNC usage, etc.) and thumbnail preview. Clicking on the
timeline will instantly take the emulation to that state.
The `TV Screen` window is fully interactive and clicking or dragging on any
portion of the screen will take the emulation to that scanline/clock of the

View file

@ -277,6 +277,8 @@ func (rnd *glsl) render() {
shader = rnd.shaders[playscrShaderID]
case rnd.img.wm.selectROM.thmbTexture:
shader = rnd.shaders[colorShaderID]
case rnd.img.wm.timeline.thmbTexture:
shader = rnd.shaders[colorShaderID]
default:
shader = rnd.shaders[guiShaderID]
}

View file

@ -56,6 +56,7 @@ type manager struct {
// the following fields are provided for convenience.
dbgScr *winDbgScr
selectROM *winSelectROM
timeline *winTimeline
// the position of the screen on the current display. the SDL function
// Window.GetPosition() is unsuitable for use in conjunction with imgui
@ -173,6 +174,7 @@ func newManager(img *SdlImgui) (*manager, error) {
// get references to specific windows that need to be referenced elsewhere in the system
wm.dbgScr = wm.windows[winDbgScrID].(*winDbgScr)
wm.selectROM = wm.windows[winSelectROMID].(*winSelectROM)
wm.timeline = wm.windows[winTimelineID].(*winTimeline)
return wm, nil
}

View file

@ -335,5 +335,5 @@ func (win *winSelectROM) setSelectedFile(filename string) {
}
cartload.EmulationLabel = thumbnailer.EmulationLabel
win.thmbReceive = win.thmb.Create(cartload, thumbnailer.UndefinedNumFrames)
win.thmbReceive = win.thmb.CreateFromLoader(cartload, thumbnailer.UndefinedNumFrames)
}

View file

@ -17,9 +17,13 @@ package sdlimgui
import (
"fmt"
"image"
"github.com/go-gl/gl/v3.2-core/gl"
"github.com/inkyblackness/imgui-go/v4"
"github.com/jetsetilly/gopher2600/curated"
"github.com/jetsetilly/gopher2600/hardware/television/specification"
"github.com/jetsetilly/gopher2600/thumbnailer"
)
const winTimelineID = "Timeline"
@ -30,12 +34,32 @@ type winTimeline struct {
// whether the rewind "slider" is active
rewindingActive bool
thmb *thumbnailer.Thumbnailer
thmbReceive chan *image.RGBA
thmbTexture uint32
thumbnailFrame int
}
func newWinTimeline(img *SdlImgui) (window, error) {
win := &winTimeline{
img: img,
}
var err error
// create a new thumbnailer instance
win.thmb, err = thumbnailer.NewThumbnailer()
if err != nil {
return nil, curated.Errorf("debugger: %v", err)
}
gl.GenTextures(1, &win.thmbTexture)
gl.BindTexture(gl.TEXTURE_2D, win.thmbTexture)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
return win, nil
}
@ -55,6 +79,21 @@ func (win *winTimeline) setOpen(open bool) {
}
func (win *winTimeline) draw() {
select {
case img := <-win.thmbReceive:
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.thmbTexture)
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
}
@ -266,57 +305,90 @@ func (win *winTimeline) drawTimeline() {
hovered := imgui.IsItemHoveredV(imgui.HoveredFlagsAllowWhenOverlapped)
hoverX := imgui.MousePos().X - pos.X
rewindStartFrame := win.img.lz.Rewind.Timeline.AvailableStart
rewindEndFrame := win.img.lz.Rewind.Timeline.AvailableEnd
rewindHoverFrame := int(hoverX/traceWidth) + rewindOffset
// rewind "slider" is attached to scanline trace
if imgui.IsMouseDown(0) && (hovered || win.rewindingActive) {
s := win.img.lz.Rewind.Timeline.AvailableStart
e := win.img.lz.Rewind.Timeline.AvailableEnd
fr := int(hoverX/traceWidth) + rewindOffset
win.rewindingActive = true
// making sure we only call PushRewind() when we need to. also,
// allowing mouse to travel beyond the rewind boundaries (and without
// calling PushRewind() too often)
if fr >= e {
if win.img.lz.TV.Coords.Frame < e {
win.img.dbg.RewindToFrame(fr, true)
if rewindHoverFrame >= rewindEndFrame {
if win.img.lz.TV.Coords.Frame < rewindEndFrame {
win.img.dbg.RewindToFrame(rewindHoverFrame, true)
}
} else if fr <= s {
if win.img.lz.TV.Coords.Frame > s {
win.img.dbg.RewindToFrame(fr, false)
} else if rewindHoverFrame <= rewindStartFrame {
if win.img.lz.TV.Coords.Frame > rewindStartFrame {
win.img.dbg.RewindToFrame(rewindHoverFrame, false)
}
} else if fr != win.img.lz.TV.Coords.Frame {
win.img.dbg.RewindToFrame(fr, fr == e)
} else if rewindHoverFrame != win.img.lz.TV.Coords.Frame {
win.img.dbg.RewindToFrame(rewindHoverFrame, rewindHoverFrame == rewindEndFrame)
}
} else {
win.rewindingActive = false
if hovered && len(win.img.lz.Rewind.Timeline.FrameNum) > 0 {
fr := int(hoverX/traceWidth) + traceOffset
s := win.img.lz.Rewind.Timeline.FrameNum[0]
e := win.img.lz.Rewind.Timeline.FrameNum[len(win.img.lz.Rewind.Timeline.FrameNum)-1]
traceHoverIdx := int(hoverX/traceWidth) + traceOffset
traceStartFrame := win.img.lz.Rewind.Timeline.FrameNum[0]
traceEndFrame := win.img.lz.Rewind.Timeline.FrameNum[len(win.img.lz.Rewind.Timeline.FrameNum)-1]
traceHoverFrame := traceHoverIdx + traceStartFrame
if traceHoverFrame >= traceStartFrame && traceHoverFrame <= traceEndFrame {
thumbnail := rewindHoverFrame >= rewindStartFrame && rewindHoverFrame <= rewindEndFrame
if fr >= s && fr <= e {
imgui.BeginTooltip()
imgui.Text(fmt.Sprintf("Frame: %d", fr))
if fr >= s && fr <= e {
flgs := imgui.TableFlagsNone
if thumbnail {
flgs = imgui.TableFlagsPadOuterX
}
if imgui.BeginTableV("timelineTooltip", 2, flgs, imgui.Vec2{}, 10.0) {
imgui.TableNextRow()
imgui.TableNextColumn()
imgui.Text(fmt.Sprintf("Frame: %d", traceHoverFrame))
// adjust text color slightly - the colors we use for the
// plots are too dark
textColAdj := imgui.Vec4{0.2, 0.2, 0.2, 0.0}
imgui.Text("Scanlines:")
imgui.PushStyleColor(imgui.StyleColorText, win.img.cols.TimelineScanlines.Plus(textColAdj))
imgui.Text(fmt.Sprintf("Scanlines: %d", win.img.lz.Rewind.Timeline.TotalScanlines[fr]))
imgui.SameLine()
imgui.Text(fmt.Sprintf("%d", win.img.lz.Rewind.Timeline.TotalScanlines[traceHoverIdx]))
imgui.PopStyleColor()
imgui.Text("WSYNC:")
imgui.PushStyleColor(imgui.StyleColorText, win.img.cols.TimelineWSYNC.Plus(textColAdj))
imgui.Text(fmt.Sprintf("WSYNC %%: %.01f%%", win.img.lz.Rewind.Timeline.Ratios[fr].WSYNC*100))
imgui.SameLine()
imgui.Text(fmt.Sprintf("%.01f%%", win.img.lz.Rewind.Timeline.Ratios[traceHoverIdx].WSYNC*100))
imgui.PopStyleColor()
if win.img.lz.CoProc.HasCoProcBus {
imgui.Text(win.img.lz.CoProc.ID)
imgui.PushStyleColor(imgui.StyleColorText, win.img.cols.TimelineCoProc.Plus(textColAdj))
imgui.Text(fmt.Sprintf("%s %%: %.01f%%", win.img.lz.CoProc.ID, win.img.lz.Rewind.Timeline.Ratios[fr].CoProc*100))
imgui.SameLine()
imgui.Text(fmt.Sprintf("%.01f%%", win.img.lz.Rewind.Timeline.Ratios[traceHoverIdx].CoProc*100))
imgui.PopStyleColor()
}
// show rewind thumbnail if hover is in range of rewind
if thumbnail {
imgui.TableNextColumn()
// selecting the correct thumbnail requires different indexing than the timline
if win.thumbnailFrame != rewindHoverFrame {
win.thmbReceive = win.thmb.CreateFromState(win.img.dbg.Rewind.GetState(rewindHoverFrame), 1)
win.thumbnailFrame = rewindHoverFrame
}
imgui.Image(imgui.TextureID(win.thmbTexture), imgui.Vec2{specification.ClksVisible * 3, specification.AbsoluteMaxScanlines}.Times(0.3))
}
imgui.EndTable()
}
imgui.EndTooltip()
}

View file

@ -67,7 +67,13 @@ func (cart *Cartridge) Snapshot() *Cartridge {
return &n
}
func (cart *Cartridge) Plumb() {
func (cart *Cartridge) Plumb(fromDifferentEmulation bool) {
if fromDifferentEmulation {
if m, ok := cart.mapper.(mapper.PlumbFromDifferentEmulation); ok {
m.PlumbFromDifferentEmulation()
return
}
}
cart.mapper.Plumb()
}

View file

@ -107,7 +107,7 @@ func NewCDF(prefs *preferences.Preferences, version string, data []byte) (mapper
//
// if bank0 has any ARM code then it will start at offset 0x08. first eight
// bytes are the ARM header
cart.arm = arm7tdmi.NewARM(cart.version.mmap, &prefs.ARM, cart.state.static, cart)
cart.arm = arm7tdmi.NewARM(cart.version.mmap, &cart.prefs.ARM, cart.state.static, cart)
return cart, nil
}
@ -144,6 +144,11 @@ func (cart *cdf) Plumb() {
cart.arm.Plumb(cart.state.static, cart)
}
// Plumb implements the mapper.CartMapper interface.
func (cart *cdf) PlumbFromDifferentEmulation() {
cart.arm = arm7tdmi.NewARM(cart.version.mmap, &cart.prefs.ARM, cart.state.static, cart)
}
// Reset implements the mapper.CartMapper interface.
func (cart *cdf) Reset(_ *rand.Rand) {
bank := len(cart.banks) - 1

View file

@ -138,6 +138,11 @@ func (cart *dpcPlus) Plumb() {
cart.arm.Plumb(cart.state.static, cart)
}
// Plumb implements the mapper.CartMapper interface.
func (cart *dpcPlus) PlumbFromDifferentEmulation() {
cart.arm = arm7tdmi.NewARM(cart.version.mmap, &cart.prefs.ARM, cart.state.static, cart)
}
// Reset implements the mapper.CartMapper interface.
func (cart *dpcPlus) Reset(randSrc *rand.Rand) {
cart.state.initialise(randSrc, len(cart.banks)-1)

View file

@ -68,6 +68,14 @@ type CartMapper interface {
CopyBanks() []BankContent
}
// PlumbFromDifferentEmulation is for mappers that are sensitive to being
// transferred from one emulation to another. When it is known that a state is
// being copied from one instance of hardware.VCS to another then this
// interface should be used if available.
type PlumbFromDifferentEmulation interface {
PlumbFromDifferentEmulation()
}
// OptionalSuperchip are implemented by cartMappers that have an optional
// superchip. This shouldn't be used to decide if a cartridge has additional
// RAM or not. Use the CartRAMbus interface for that.

View file

@ -81,8 +81,8 @@ func (mem *Memory) Snapshot() *Memory {
}
// Plumb makes sure everythin is ship-shape after a rewind event.
func (mem *Memory) Plumb() {
mem.Cart.Plumb()
func (mem *Memory) Plumb(fromDifferentEmulation bool) {
mem.Cart.Plumb(fromDifferentEmulation)
}
// Reset contents of memory.

View file

@ -376,7 +376,7 @@ func (r *Rewind) append(s *State) {
}
// Plumb state into VCS.
func Plumb(vcs *hardware.VCS, state *State) {
func Plumb(vcs *hardware.VCS, state *State, fromDifferentEmulation bool) {
// tv plumbing works a bit different to other areas because we're only
// recording the state of the TV not the entire TV itself.
vcs.TV.PlumbState(vcs, state.TV.Snapshot())
@ -390,7 +390,7 @@ func Plumb(vcs *hardware.VCS, state *State) {
vcs.TIA = state.TIA.Snapshot()
vcs.CPU.Plumb(vcs.Mem)
vcs.Mem.Plumb()
vcs.Mem.Plumb(fromDifferentEmulation)
vcs.RIOT.Plumb(vcs.Mem.RIOT, vcs.Mem.TIA)
vcs.TIA.Plumb(vcs.TV, vcs.Mem.TIA, vcs.RIOT.Ports, vcs.CPU)
@ -404,7 +404,7 @@ func Plumb(vcs *hardware.VCS, state *State) {
//
// note that this will not change the splice point. use setSplicePoint() for that
func (r *Rewind) runFromStateToCoords(fromState *State, toCoords coords.TelevisionCoords) error {
Plumb(r.vcs, fromState)
Plumb(r.vcs, fromState, false)
// if this is a reset entry then TV must be reset
if fromState.level == levelReset {
@ -611,3 +611,10 @@ func (r *Rewind) NewFrame(frameInfo television.FrameInfo) error {
r.newFrame = true
return nil
}
func (r *Rewind) GetState(frame int) *State {
// get nearest index of entry from which we can being to (re)generate the
// current frame
res := r.findFrameIndex(frame)
return r.entries[res.fromIdx]
}

View file

@ -63,7 +63,7 @@ func (r *Rewind) SearchMemoryWrite(tgt *State, addr uint16, value uint8, valueMa
// find a recent state from the rewind history and plumb it our searchVCS
idx := r.findFrameIndex(endCoords.Frame).fromIdx
Plumb(searchVCS, r.entries[idx])
Plumb(searchVCS, r.entries[idx], false)
// loop until we reach (or just surpass) the target State
done := false
@ -127,7 +127,7 @@ func (r *Rewind) SearchRegisterWrite(tgt *State, reg rune, value uint8, valueMas
// find a recent state and plumb it into searchVCS
idx := r.findFrameIndex(endCoords.Frame).fromIdx
Plumb(searchVCS, r.entries[idx])
Plumb(searchVCS, r.entries[idx], false)
// onLoad() is called whenever a CPU register is loaded with a new value
match := false

View file

@ -29,6 +29,7 @@ import (
"github.com/jetsetilly/gopher2600/hardware/television/signal"
"github.com/jetsetilly/gopher2600/hardware/television/specification"
"github.com/jetsetilly/gopher2600/logger"
"github.com/jetsetilly/gopher2600/rewind"
)
// Label that can be used in the EmulationLabel field of the cartridgeloader.Loader type. to indicate
@ -102,17 +103,7 @@ func (thmb *Thumbnailer) EndCreation() {
}
}
// UndefinedNumFrames indicates the that the thumbnailing emulation should run
// until it is explicitely stopped with the EndCreation() function (or
// implicitely with a second call to Create())
const UndefinedNumFrames = -1
// Create will cause images to be returned from a running emulation initialised
// with the specified cartridge loader. The emulation will run for a number of
// frames before ending.
//
// It returns the channel over which new frames will be sent.
func (thmb *Thumbnailer) Create(loader cartridgeloader.Loader, numFrames int) chan *image.RGBA {
func (thmb *Thumbnailer) wait() {
// drain existing emulationQuit channel
select {
case <-thmb.emulationQuit:
@ -127,6 +118,20 @@ func (thmb *Thumbnailer) Create(loader cartridgeloader.Loader, numFrames int) ch
thmb.emulationQuit <- true
<-thmb.emulationCompleted
}
}
// UndefinedNumFrames indicates the that the thumbnailing emulation should run
// until it is explicitely stopped with the EndCreation() function (or
// implicitely with a second call to Create())
const UndefinedNumFrames = -1
// CreateFromLoader will cause images to be returned from a running emulation initialised
// with the specified cartridge loader. The emulation will run for a number of
// frames before ending.
//
// It returns the channel over which new frames will be sent.
func (thmb *Thumbnailer) CreateFromLoader(loader cartridgeloader.Loader, numFrames int) chan *image.RGBA {
thmb.wait()
go func() {
defer func() {
@ -139,6 +144,8 @@ func (thmb *Thumbnailer) Create(loader cartridgeloader.Loader, numFrames int) ch
return
}
tgtFrame := thmb.vcs.TV.GetCoords().Frame + numFrames
err = thmb.vcs.Run(func() (emulation.State, error) {
select {
case <-thmb.emulationQuit:
@ -146,7 +153,45 @@ func (thmb *Thumbnailer) Create(loader cartridgeloader.Loader, numFrames int) ch
default:
}
if numFrames != UndefinedNumFrames && thmb.vcs.TV.GetCoords().Frame >= numFrames {
if numFrames != UndefinedNumFrames && thmb.vcs.TV.GetCoords().Frame >= tgtFrame {
return emulation.Ending, nil
}
return emulation.Running, nil
})
if err != nil {
logger.Logf("thumbnailer", err.Error())
return
}
}()
return thmb.renderChannel
}
// CreateFromState will cause images to be returned from a running emulation initialised
// with the specified cartridge loader. The emulation will run for a number of
// frames before ending.
//
// It returns the channel over which new frames will be sent.
func (thmb *Thumbnailer) CreateFromState(state *rewind.State, numFrames int) chan *image.RGBA {
thmb.wait()
go func() {
defer func() {
thmb.emulationCompleted <- true
}()
rewind.Plumb(thmb.vcs, state, true)
tgtFrame := thmb.vcs.TV.GetCoords().Frame + numFrames
err := thmb.vcs.Run(func() (emulation.State, error) {
select {
case <-thmb.emulationQuit:
return emulation.Ending, nil
default:
}
if numFrames != UndefinedNumFrames && thmb.vcs.TV.GetCoords().Frame >= tgtFrame {
return emulation.Ending, nil
}
return emulation.Running, nil