Gopher2600/gui/sdlimgui/win_timeline.go
JetSetIlly 9a99ff7a3e fixed searching for PAL60 in filename and PAL60 sizing
added PAL60 specification. even though it isn't a real specification it
is more convenient and provides better user feedback

moved FrameInfo into frameinfo package, now called Current as in
frameinfo.Current

clarified storage of requested specification by the television:

how the television is probed for the specification has changed, in
particular the current spec is retreived via the GetFrameInfo()
function. in fact, this was how most other packages did it but there
also existed a GetSpecID() which was uncessary

GetReqSpecID() and GetCreationSpecID() removed, replaced with
GetResetSpecID() and IsAutoSpec()

simplified SetSpec(). removed the force argument

removed reset option for vcs.AttachCartridge()
2025-02-23 17:39:08 +00:00

634 lines
20 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 sdlimgui
import (
"fmt"
"image"
"os"
"github.com/inkyblackness/imgui-go/v4"
"github.com/jetsetilly/gopher2600/gui/fonts"
"github.com/jetsetilly/gopher2600/hardware/television/specification"
"github.com/jetsetilly/gopher2600/logger"
"github.com/jetsetilly/gopher2600/resources/unique"
"github.com/jetsetilly/gopher2600/thumbnailer"
"golang.org/x/image/draw"
)
const winTimelineID = "Timeline"
type winTimeline struct {
debuggerWin
img *SdlImgui
// thumbnailer will be using emulation states created in the main emulation
// goroutine so we must thumbnail those states in the same goroutine.
thmb *thumbnailer.Image
thmbTexture texture
thmbImage *image.RGBA
// whether the thumbnail is being shown on the left of the timeline rather
// than the right
thumbLeft bool
// which frame was the last thumbnail generated for. used to prevent another
// thumbnail being generated for the same frame
thmbFrame int
// throttle the number of thumbnails that are being generated at once. if
// channel is full then the thumbnail is being generated. the channel is
// drained once the thumbnail creation has completed
thmbRunning chan bool
// mouse hover information
isHovered bool
hoverX float32
hoverIdx int
hoverFrame int
// mouse is being used to scrub the timeline area. see isScrubbingValid()
// for a function that is a more likely to provide a useful value
scrubbing bool
// the following two fields are used to help understand what to do when the
// timeline window is resized
//
// the trace offset from the previous frame
//
// pushing flag is true if the current frame indicator is near the right
// most limit of the trace area. it's used to
pushing bool
prevTraceOffset int
// height of toolbar
toolbarHeight float32
}
func newWinTimeline(img *SdlImgui) (window, error) {
win := &winTimeline{
img: img,
thmbRunning: make(chan bool, 1),
}
var err error
win.thmb, err = thumbnailer.NewImage(win.img.dbg.VCS().Env.Prefs, win.img.dbg.VCS().TV.GetFrameInfo().Spec.ID)
if err != nil {
return nil, fmt.Errorf("debugger: %w", err)
}
win.thmbTexture = img.rnd.addTexture(shaderColor, true, true, nil)
win.thmbImage = image.NewRGBA(image.Rect(0, 0, 0, 0))
return win, nil
}
func (win *winTimeline) isScrubbingValid() bool {
return win.scrubbing && win.hoverIdx >= 0 && win.hoverIdx < len(win.img.cache.Rewind.Timeline.FrameNum)
}
func (win *winTimeline) init() {
}
func (win *winTimeline) id() string {
return winTimelineID
}
const timelinePopupID = "timelinePopupID"
func (win *winTimeline) debuggerDraw() bool {
// receive new thumbnail data and copy to texture
select {
case newImage := <-win.thmb.Render:
if newImage != nil {
sz := newImage.Bounds().Size()
if sz != win.thmbImage.Bounds().Size() {
win.thmbImage = image.NewRGBA(image.Rect(0, 0, sz.X, sz.Y))
win.thmbTexture.markForCreation()
}
// copy new image so that it is centred in the thumbnail image
draw.Copy(win.thmbImage, image.Point{X: 0, Y: 0},
newImage, newImage.Bounds(), draw.Over, nil)
// render image
win.thmbTexture.render(win.thmbImage)
}
default:
}
if !win.debuggerOpen {
return false
}
const winHeightRatio = 0.05
const scanlineRatio = specification.AbsoluteMaxScanlines * winHeightRatio
imgui.SetNextWindowPosV(imgui.Vec2{X: 39, Y: 722}, imgui.ConditionFirstUseEver, imgui.Vec2{X: 0, Y: 0})
imgui.SetNextWindowSizeV(imgui.Vec2{X: 875, Y: 220}, imgui.ConditionFirstUseEver)
w, _ := win.img.plt.windowSize()
imgui.SetNextWindowSizeConstraints(imgui.Vec2{X: 750, Y: 200}, imgui.Vec2{X: w * 0.95, Y: 300})
if imgui.BeginV(win.debuggerID(win.id()), &win.debuggerOpen, imgui.WindowFlagsNone) {
// trace area
win.drawTrace()
// toolbar
win.toolbarHeight = imguiMeasureHeight(func() {
imguiSeparator()
win.drawToolbar()
})
// popup menu
if imgui.BeginPopup(timelinePopupID) {
if imgui.Selectable(fmt.Sprintf("%c Save Timeline to CSV", fonts.Disk)) {
win.saveToCSV()
}
if win.isHovered {
imgui.Spacing()
imgui.Separator()
imgui.Spacing()
if imgui.Selectable(fmt.Sprintf("Set Comparison to Frame %d", win.hoverFrame)) {
win.img.dbg.PushFunction(func() {
win.img.term.pushCommand(fmt.Sprintf("COMPARISON %d", win.hoverFrame))
})
}
var label string
var command string
if win.img.cache.Rewind.Comparison.Locked {
label = "Unlock Comparison Frame"
command = "COMPARISON UNLOCK"
} else {
label = fmt.Sprintf("Lock Comparison Frame (at frame %d)",
win.img.cache.Rewind.Comparison.State.TV.GetCoords().Frame)
command = "COMPARISON LOCK"
}
if imgui.Selectable(label) {
win.img.term.pushCommand(command)
}
}
imgui.EndPopup()
}
}
win.debuggerGeom.update()
imgui.End()
if (win.isHovered || win.isScrubbingValid()) && win.img.prefs.showTimelineThumbnail.Get().(bool) {
if win.thmbFrame != win.hoverFrame {
select {
case win.thmbRunning <- true:
win.thmbFrame = win.hoverFrame
hoverFrame := win.hoverFrame
win.img.dbg.PushFunction(func() {
// thumbnailer must be run in the same goroutine as the main emulation
win.thmb.Create(win.img.dbg.Rewind.GetState(hoverFrame))
<-win.thmbRunning
})
default:
// if a thumbnail is currently being generated then we need to
// carry on with the GUI thread without delay
}
}
}
return true
}
func (win *winTimeline) drawToolbar() {
timeline := win.img.cache.Rewind.Timeline
if timeline.AvailableStart == timeline.AvailableEnd && timeline.AvailableStart == 0 {
imgui.Text("No rewind history")
} else {
imgui.Text(fmt.Sprintf("Rewind between %d and %d", timeline.AvailableStart, timeline.AvailableEnd))
imgui.SameLineV(0, 15)
imguiColorLabelSimple(fmt.Sprintf("Comparing frame %d", win.img.cache.Rewind.Comparison.State.TV.GetCoords().Frame), win.img.cols.TimelineComparison)
if win.isHovered || win.isScrubbingValid() {
imgui.SameLineV(0, 15)
imgui.Text(fmt.Sprintf("Frame %d", win.img.cache.Rewind.Timeline.FrameNum[win.hoverIdx]))
imgui.SameLineV(0, 15)
imguiColorLabelSimple(fmt.Sprintf("%d Scanlines", win.img.cache.Rewind.Timeline.FrameInfo[win.hoverIdx].TotalScanlines), win.img.cols.TimelineScanlines)
imgui.SameLineV(0, 15)
if timeline.FrameInfo[win.hoverIdx].FromVSYNC {
imguiColorLabelSimple(fmt.Sprintf("%d+%d VSYNC", win.img.cache.Rewind.Timeline.FrameInfo[win.hoverIdx].VSYNCscanline, win.img.cache.Rewind.Timeline.FrameInfo[win.hoverIdx].VSYNCcount), win.img.cols.TimelineVSYNC)
} else {
imguiColorLabelSimple("No VSYNC", win.img.cols.TimelineVSYNC)
}
imgui.SameLineV(0, 15)
imguiColorLabelSimple(fmt.Sprintf("%.02f%% WSYNC", win.img.cache.Rewind.Timeline.Ratios[win.hoverIdx].WSYNC*100), win.img.cols.TimelineWSYNC)
if win.img.cache.VCS.Mem.Cart.GetCoProcBus() != nil {
imgui.SameLineV(0, 15)
imguiColorLabelSimple(fmt.Sprintf("%.02f%% Coproc", win.img.cache.Rewind.Timeline.Ratios[win.hoverIdx].CoProc*100), win.img.cols.TimelineCoProc)
}
}
}
}
func (win *winTimeline) drawTrace() {
timeline := win.img.cache.Rewind.Timeline
dl := imgui.WindowDrawList()
// size of trace area elements. the size of the graph depends on the size of
// the timeline window
const (
gap = 5
inputTrace = 2
rewindRangeTrace = 3
plotWidth = 4
plotHeight = 2
cursorWidth = 5
)
// the amount to allow for the icons when centering etc.
iconRadius := win.img.fonts.guiSize / 2
// the width that can be seen in the window at any one time. reduce by the
// iconRadius*2 value to allow for the TV icon (current frame icon) when it
// reaches the extreme left/right of the window
availableWidth := imgui.ContentRegionAvail().X - iconRadius*2
// the width of the timeline window in frames (ie. number of frames visible)
availableWidthInFrames := int(availableWidth / plotWidth)
// size of entire timeline trace area
traceSize := imgui.Vec2{X: availableWidth, Y: imgui.ContentRegionAvail().Y - win.toolbarHeight}
// height of the graph portion of the trace area
graphHeight := traceSize.Y - (gap*2 + inputTrace + gap + rewindRangeTrace + gap + iconRadius*2 + gap)
// check if end of timeline overflows the available width and adjust offset
// so that the trace is right-justified (for want of a better description)
//
// we also don't want to decrease the traceoffset from a previous high value
var traceOffset int
if len(timeline.FrameNum)*plotWidth >= int(availableWidth) {
traceOffset = len(timeline.FrameNum) - availableWidthInFrames
}
if win.pushing || traceOffset > win.prevTraceOffset {
win.prevTraceOffset = traceOffset
} else {
traceOffset = win.prevTraceOffset
}
// similar to traceOffset, rewindOffset adjusts the placement of the rewind
// range and frame indicators
rewindOffset := traceOffset
if len(timeline.FrameNum) > 0 {
rewindOffset += timeline.FrameNum[0]
}
// list of scanline jitter points to indicate. these will be found during
// the plot of the scanline trace and then used to draw the jitter
// indicators in a second loop
var scanlineJitter []int
scanlineJitter = append(scanlineJitter, 0)
// scanline/coproc/WSYNC trace
imgui.BeginChildV("##timelinetrace", traceSize, false, imgui.WindowFlagsNoMove)
// the position of the trace widget. move right slightly to create a margin
// of width iconRadius
rootPos := imgui.CursorScreenPos()
rootPos.X += iconRadius
imgui.SetCursorScreenPos(rootPos)
// the Y position of each trace area
yPos := rootPos.Y + gap
// rewind start/end X positions
rewindStartX := rootPos.X + float32((timeline.AvailableStart-rewindOffset)*plotWidth)
if rewindStartX < rootPos.X {
rewindStartX = rootPos.X
}
rewindEndX := rootPos.X + float32((timeline.AvailableEnd-rewindOffset)*plotWidth)
// draw frame guides
const guideFrameCount = 20
imgui.PushFont(win.img.fonts.diagram)
var guideStart int
if len(timeline.FrameNum) > 0 {
guideStart = timeline.FrameNum[traceOffset]
}
guideX := rootPos.X
for fn := guideStart; fn < guideStart+availableWidthInFrames; fn++ {
if fn%guideFrameCount == 0 {
// draw vertical frame guides
top := imgui.Vec2{X: guideX, Y: rootPos.Y}
bot := imgui.Vec2{X: guideX, Y: rootPos.Y + traceSize.Y}
dl.AddRectFilled(top, bot, win.img.cols.timelineGuides)
// label frame guides with frame numbers
bot.X += 5
bot.Y -= win.img.fonts.diagramSize / 2
dl.AddText(bot, win.img.cols.timelineGuidesLabel, fmt.Sprintf("%d", fn))
}
guideX += plotWidth
}
imgui.PopFont()
// show hover/scrubbing cursor
if win.isHovered || win.isScrubbingValid() {
dl.AddRectFilled(imgui.Vec2{X: win.hoverX - cursorWidth/2, Y: rootPos.Y},
imgui.Vec2{X: win.hoverX + cursorWidth/2, Y: rootPos.Y + traceSize.Y},
win.img.cols.timelineHoverCursor)
if win.img.prefs.showTimelineThumbnail.Get().(bool) {
// size of thumbnail is based on height of trace area
sz := imgui.Vec2{X: specification.WidthTV, Y: specification.HeightTV}
sz = sz.Times(traceSize.Y / sz.Y)
// show thumbnail on either the left or right of the timeline window
var pos imgui.Vec2
if win.thumbLeft {
pos = imgui.Vec2{X: rootPos.X + iconRadius*2, Y: rootPos.Y}
if win.hoverX <= pos.X+sz.X {
win.thumbLeft = false
pos.X = rootPos.X + availableWidth - sz.X - iconRadius*2
}
} else {
pos = imgui.Vec2{X: rootPos.X + availableWidth - sz.X - iconRadius*2, Y: rootPos.Y}
if win.hoverX >= pos.X {
win.thumbLeft = true
pos.X = rootPos.X + iconRadius*2
}
}
imgui.SetCursorScreenPos(pos)
imgui.ImageV(imgui.TextureID(win.thmbTexture.getID()), sz,
imgui.Vec2{}, imgui.Vec2{X: 1, Y: 1},
win.img.cols.TimelineThumbnailTint, imgui.Vec4{})
imgui.SetCursorScreenPos(rootPos)
}
}
// draw main trace plot
plotX := rootPos.X
for i := range timeline.FrameNum[traceOffset:] {
// adjust index by starting point
i += traceOffset
// SCANLINE TRACE
plotY := yPos + graphHeight
// scale TotalScanlines value so that it covers the entire height of traceSize
plotY -= float32(timeline.FrameInfo[i].TotalScanlines) * graphHeight / specification.AbsoluteMaxScanlines
// add jitter to trace to indicate changes in value through exaggeration
if i > 0 {
if timeline.FrameInfo[i].TotalScanlines != timeline.FrameInfo[i-1].TotalScanlines {
if timeline.FrameInfo[i].TotalScanlines < timeline.FrameInfo[i-1].TotalScanlines {
plotY++
} else if timeline.FrameInfo[i].TotalScanlines > timeline.FrameInfo[i-1].TotalScanlines {
plotY--
}
// add to jitter history if it hasn't been updated for a while
prev := scanlineJitter[len(scanlineJitter)-1]
j := i - traceOffset
if j-prev > 3 {
scanlineJitter = append(scanlineJitter, j)
}
}
}
// draw scanline trace
dl.AddRectFilled(imgui.Vec2{X: plotX, Y: plotY},
imgui.Vec2{X: plotX + plotWidth, Y: plotY + plotHeight},
win.img.cols.timelineScanlines)
// VSYNC TRACE
if timeline.FrameInfo[i].FromVSYNC {
vsyncCount := min(timeline.FrameInfo[i].VSYNCcount, 6)
plotY = yPos + (graphHeight * 0.75)
plotY -= float32(timeline.FrameInfo[i].VSYNCscanline) * (graphHeight * 0.75) / specification.AbsoluteMaxScanlines
// add jitter to trace to indicate changes in value through exaggeration
if i > 0 {
diff := timeline.FrameInfo[i].VSYNCscanline - timeline.FrameInfo[i-1].VSYNCscanline
if diff > 0 && diff < vsyncCount {
plotY--
} else if diff < 0 && diff > -vsyncCount {
plotY++
}
}
dl.AddRectFilled(imgui.Vec2{X: plotX, Y: plotY},
imgui.Vec2{X: plotX + plotWidth, Y: plotY + float32(vsyncCount)},
win.img.cols.timelineVSYNC)
}
// WSYNC TRACE
// plot WSYNC from the bottom
plotY = yPos + graphHeight
plotY -= float32(timeline.Counts[i].WSYNC) * graphHeight / specification.AbsoluteMaxClks
// add jitter to trace to indicate changes in value through exaggeration
if i > 0 {
if timeline.Counts[i].WSYNC < timeline.Counts[i-1].WSYNC {
plotY++
} else if timeline.Counts[i].WSYNC > timeline.Counts[i-1].WSYNC {
plotY--
}
}
dl.AddRectFilled(imgui.Vec2{X: plotX, Y: plotY},
imgui.Vec2{X: plotX + plotWidth, Y: plotY + plotHeight},
win.img.cols.timelineWSYNC)
// COPROCESSOR TRACE
// plot coprocessor from the top
if win.img.cache.VCS.Mem.Cart.GetCoProcBus() != nil {
plotY = yPos
plotY += float32(timeline.Counts[i].CoProc) * graphHeight / specification.AbsoluteMaxClks
// add jitter to trace to indicate changes in value through exaggeration
if i > 0 {
if timeline.Counts[i].CoProc < timeline.Counts[i-1].CoProc {
plotY++
} else if timeline.Counts[i].CoProc > timeline.Counts[i-1].CoProc {
plotY--
}
}
dl.AddRectFilled(imgui.Vec2{X: plotX, Y: plotY},
imgui.Vec2{X: plotX + plotWidth, Y: plotY + plotHeight},
win.img.cols.timelineCoProc)
}
plotX += plotWidth
}
yPos += graphHeight + gap
// input trace
// TODO: right player and panel input
plotX = rootPos.X
for i := range timeline.FrameNum[traceOffset:] {
i += traceOffset
if timeline.LeftPlayerInput[i] {
dl.AddRectFilled(imgui.Vec2{X: plotX, Y: yPos},
imgui.Vec2{X: plotX + plotWidth, Y: yPos + inputTrace},
win.img.cols.timelineLeftPlayer)
}
plotX += plotWidth
}
yPos += inputTrace + gap
// rewind range indicator
dl.AddRectFilled(imgui.Vec2{X: rewindStartX, Y: yPos},
imgui.Vec2{X: rewindEndX, Y: yPos + rewindRangeTrace},
win.img.cols.timelineRewindRange)
yPos += rewindRangeTrace + gap
// jitter indicators
for _, i := range scanlineJitter[1:] {
pos := imgui.Vec2{X: rootPos.X + float32(i*plotWidth), Y: yPos}
dl.AddText(pos, win.img.cols.timelineScanlines, string(fonts.TimelineJitter))
}
// comparison frame indicator
if win.img.cache.Rewind.Comparison.State != nil && len(timeline.FrameNum) > 0 {
fr := win.img.cache.Rewind.Comparison.State.TV.GetCoords().Frame - rewindOffset
if fr < 0 {
// indicate that the comparison frame is not visible
pos := imgui.Vec2{X: rootPos.X - iconRadius, Y: yPos}
dl.AddText(pos, win.img.cols.timelineComparison, string(fonts.TimelineOffScreen))
} else {
pos := imgui.Vec2{X: rootPos.X + float32(fr*plotWidth) - iconRadius, Y: yPos}
if win.img.cache.Rewind.Comparison.Locked {
dl.AddText(pos, win.img.cols.timelineComparison, string(fonts.TimelineComparisonLock))
} else {
dl.AddText(pos, win.img.cols.timelineComparison, string(fonts.TimelineComparison))
}
}
}
// current frame indicator
currentFrame := win.img.cache.TV.GetCoords().Frame - rewindOffset
if currentFrame < 0 {
// indicate that the current frame indicator is not visible
pos := imgui.Vec2{X: rootPos.X - iconRadius, Y: yPos}
dl.AddText(pos, win.img.cols.timelineRewindRange, string(fonts.TimelineOffScreen))
} else {
dl.AddText(imgui.Vec2{X: rootPos.X + float32(currentFrame*plotWidth) - iconRadius, Y: yPos},
win.img.cols.timelineRewindRange, string(fonts.TV))
}
win.pushing = float32(currentFrame*plotWidth) >= availableWidth*0.99
// end of trace area
imgui.EndChild()
// no mouse handling for timeline window if popup window is open
if imgui.IsPopupOpen(timelinePopupID) {
return
}
// detect mouse is in scrubbing areas
win.scrubbing = (win.scrubbing && imgui.IsMouseDown(0)) ||
(imgui.IsItemHovered() && imgui.IsMouseClicked(0))
// check for mouse hover over rewindable area
mouse := imgui.MousePos()
win.isHovered = (imgui.IsItemHovered() &&
mouse.X >= rewindStartX && mouse.X <= rewindEndX &&
mouse.Y >= rootPos.Y && mouse.Y <= rootPos.Y+traceSize.Y)
win.hoverX = mouse.X
// rewind support
rewindX := win.hoverX - rootPos.X
rewindStartFrame := win.img.cache.Rewind.Timeline.AvailableStart
rewindEndFrame := win.img.cache.Rewind.Timeline.AvailableEnd
// index and frame number for hover position
win.hoverIdx = int(rewindX/plotWidth) + traceOffset
win.hoverFrame = int(rewindX/plotWidth) + rewindOffset
// mouse handling
if imgui.IsMouseDown(1) && imgui.IsItemHovered() {
imgui.OpenPopup(timelinePopupID)
} else if win.isScrubbingValid() {
coords := win.img.cache.TV.GetCoords()
// 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 win.hoverFrame >= rewindEndFrame {
if coords.Frame < rewindEndFrame {
win.img.dbg.RewindToFrame(win.hoverFrame, true)
}
} else if win.hoverFrame <= rewindStartFrame {
if coords.Frame > rewindStartFrame {
win.img.dbg.RewindToFrame(win.hoverFrame, false)
}
} else if win.hoverFrame != coords.Frame {
win.img.dbg.RewindToFrame(win.hoverFrame, win.hoverFrame == rewindEndFrame)
}
}
}
func (win *winTimeline) saveToCSV() {
// open unique file
fn := unique.Filename("timeline", win.img.cache.VCS.Mem.Cart.ShortName)
fn = fmt.Sprintf("%s.csv", fn)
f, err := os.Create(fn)
if err != nil {
logger.Logf(logger.Allow, "sdlimgui", "could not save timeline CSV: %v", err)
return
}
defer func() {
err := f.Close()
if err != nil {
logger.Logf(logger.Allow, "sdlimgui", "error saving timeline CSV: %v", err)
}
}()
f.WriteString("FrameNum,")
f.WriteString("Scanlines,")
f.WriteString("CoProc,")
f.WriteString("WSYNC,")
f.WriteString("Left Player,")
f.WriteString("Right Player,")
f.WriteString("Panel")
f.WriteString("\n")
timeline := win.img.cache.Rewind.Timeline
for i, n := range timeline.FrameNum {
f.WriteString(fmt.Sprintf("%d,", n))
f.WriteString(fmt.Sprintf("%d,", timeline.FrameInfo[i].TotalScanlines))
f.WriteString(fmt.Sprintf("%d,", timeline.Counts[i].CoProc))
f.WriteString(fmt.Sprintf("%d,", timeline.Counts[i].WSYNC))
f.WriteString(fmt.Sprintf("%v,", timeline.LeftPlayerInput[i]))
f.WriteString(fmt.Sprintf("%v,", timeline.RightPlayerInput[i]))
f.WriteString(fmt.Sprintf("%v,", timeline.PanelInput[i]))
f.WriteString("\n")
}
}