// 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 . package regression import ( "bufio" "fmt" "io" "os" "strconv" "strings" "time" "github.com/jetsetilly/gopher2600/cartridgeloader" "github.com/jetsetilly/gopher2600/database" "github.com/jetsetilly/gopher2600/debugger/govern" "github.com/jetsetilly/gopher2600/digest" "github.com/jetsetilly/gopher2600/hardware" "github.com/jetsetilly/gopher2600/hardware/television" "github.com/jetsetilly/gopher2600/setup" ) const videoEntryType = "video" const ( videoFieldCartName int = iota videoFieldCartMapping videoFieldTVtype videoFieldNumFrames videoFieldState videoFieldStateOptions videoFieldStateFile videoFieldDigest videoFieldNotes numVideoFields ) // VideoRegression is the simplest regression type. it works by running the // emulation for N frames and the video recorded at that point. Regression // passes if subsequenct runs produce the same video value. type VideoRegression struct { CartLoad cartridgeloader.Loader TVtype string NumFrames int State StateType stateOptions string stateFile string Notes string digest string } func deserialiseVideoEntry(fields database.SerialisedEntry) (database.Entry, error) { reg := &VideoRegression{} // basic sanity check if len(fields) > numVideoFields { return nil, fmt.Errorf("video: too many fields") } if len(fields) < numVideoFields { return nil, fmt.Errorf("video: too few fields") } var err error // string fields need no conversion reg.CartLoad, err = cartridgeloader.NewLoader(fields[videoFieldCartName], fields[videoFieldCartMapping]) if err != nil { return nil, fmt.Errorf("video: %w", err) } reg.TVtype = fields[videoFieldTVtype] reg.digest = fields[videoFieldDigest] reg.Notes = fields[videoFieldNotes] // convert number of frames field reg.NumFrames, err = strconv.Atoi(fields[videoFieldNumFrames]) if err != nil { return nil, fmt.Errorf("video: invalid numFrames field [%s]", fields[videoFieldNumFrames]) } // handle state field switch fields[videoFieldState] { case "": reg.State = StateNone case "TV": reg.State = StateTV case "PORTS": reg.State = StatePorts case "TIMER": reg.State = StateTimer case "CPU": reg.State = StateCPU default: return nil, fmt.Errorf("video: invalid state field [%s]", fields[videoFieldState]) } // state options reg.stateOptions = fields[videoFieldStateOptions] // and state file field if fields[videoFieldStateFile] != "" { if reg.State == StateNone { return nil, fmt.Errorf("video: invalid state file field: no state type specifier") } reg.stateFile = fields[videoFieldStateFile] } return reg, nil } // EntryType implements the database.Entry interface. func (reg VideoRegression) EntryType() string { return videoEntryType } // Serialise implements the database.Entry interface. func (reg *VideoRegression) Serialise() (database.SerialisedEntry, error) { return database.SerialisedEntry{ reg.CartLoad.Filename, reg.CartLoad.RequestedMapping, reg.TVtype, strconv.Itoa(reg.NumFrames), reg.State.String(), reg.stateOptions, reg.stateFile, reg.digest, reg.Notes, }, nil } // CleanUp implements the database.Entry interface. func (reg VideoRegression) CleanUp() error { err := os.Remove(reg.stateFile) if _, ok := err.(*os.PathError); ok { return nil } return err } // String implements the regression.Regressor interface func (reg VideoRegression) String() string { s := strings.Builder{} state := "" switch reg.State { case StateNone: state = "" case StateTV: state = " [TV state]" case StatePorts: state = " [ports state]" case StateTimer: state = " [timer state]" case StateCPU: state = " [cpu state]" default: state = " [with state]" } s.WriteString(fmt.Sprintf("[%s] %s [%s] frames=%d%s", reg.EntryType(), reg.CartLoad.ShortName(), reg.TVtype, reg.NumFrames, state)) if reg.Notes != "" { s.WriteString(fmt.Sprintf(" [%s]", reg.Notes)) } return s.String() } // regress implements the regression.Regressor interface. func (reg *VideoRegression) regress(newRegression bool, output io.Writer, msg string) (_ bool, _ string, rerr error) { output.Write([]byte(msg)) // create headless television. we'll use this to initialise the digester tv, err := television.NewTelevision(reg.TVtype) if err != nil { return false, "", fmt.Errorf("video: %w", err) } defer tv.End() tv.SetFPSCap(false) dig, err := digest.NewVideo(tv) if err != nil { return false, "", fmt.Errorf("video: %w", err) } // create VCS and attach cartridge vcs, err := hardware.NewVCS(tv, nil) if err != nil { return false, "", fmt.Errorf("video: %w", err) } // we want the machine in a known state. the easiest way to do this is to // default the hardware preferences vcs.Env.Normalise() err = setup.AttachCartridge(vcs, reg.CartLoad, true) if err != nil { return false, "", fmt.Errorf("video: %w", err) } // list of state information. we'll either save this in the event of // newRegression being true; or we'll use it to compare to the entries in // the specified state file state := make([]string, 0, 1024) // add the starting state of the tv switch reg.State { case StateTV: state = append(state, tv.String()) case StatePorts: state = append(state, vcs.RIOT.Ports.String()) case StateTimer: state = append(state, vcs.RIOT.Timer.String()) case StateCPU: state = append(state, vcs.CPU.String()) } // display ticker for progress meter dur, _ := time.ParseDuration("1s") tck := time.NewTicker(dur) // run emulation err = vcs.RunForFrameCount(reg.NumFrames, func(frame int) (govern.State, error) { // if the CPU is in the KIL state then the test will never end normally if vcs.CPU.Killed { return govern.Ending, fmt.Errorf("CPU in KIL state") } // display progress meter every 1 second select { case <-tck.C: output.Write([]byte(fmt.Sprintf("\r%s [%d/%d (%.1f%%)]", msg, frame, reg.NumFrames, 100*(float64(frame)/float64(reg.NumFrames))))) default: } // store state. StateTV stores every video cycle. other State types // can (should?) choose to only store state if it is different to the // previous entry // // do not record state if CPU is not ready. this cuts down on needless // entries - the state of the machine won't have changed much if vcs.CPU.RdyFlg { switch reg.State { case StateTV: state = append(state, tv.String()) case StatePorts: state = append(state, vcs.RIOT.Ports.String()) case StateTimer: state = append(state, vcs.RIOT.Timer.String()) case StateCPU: state = append(state, vcs.CPU.String()) } } return govern.Running, nil }) if err != nil { return false, "", fmt.Errorf("video: %w", err) } if newRegression { reg.digest = dig.Hash() if reg.State != StateNone { // create a unique filename reg.stateFile, err = uniqueFilename("state", reg.CartLoad.ShortName()) if err != nil { return false, "", fmt.Errorf("video: %w", err) } // check that the filename is unique nf, _ := os.Open(reg.stateFile) // no need to bother with returned error. nf tells us everything we // need if nf != nil { return false, "", fmt.Errorf("video: state recording file already exists (%s)", reg.stateFile) } nf.Close() // create new file nf, err = os.Create(reg.stateFile) if err != nil { return false, "", fmt.Errorf("video: error creating state recording file: %w", err) } defer func() { err := nf.Close() if err != nil { rerr = fmt.Errorf("video: error creating state recording file: %w", err) } }() for i := range state { s := fmt.Sprintf("%s\n", state[i]) if n, err := nf.WriteString(s); err != nil || len(s) != n { return false, "", fmt.Errorf("video: error writing state recording file: %w", err) } } } // this is a new regression entry so we don't need to do the comparison // stage so we return early return true, "", nil } // only for replay of existing regression entries. compare new state // tracking with recorded state tracking if reg.State != StateNone { nf, err := os.Open(reg.stateFile) if err != nil { return false, "", fmt.Errorf("video: old state recording file not present (%s)", reg.stateFile) } defer nf.Close() reader := bufio.NewReader(nf) for i := range state { s, _ := reader.ReadString('\n') s = strings.TrimRight(s, "\n") // ignore blank lines if s == "" { continue } if s != state[i] { failm := fmt.Sprintf("state mismatch line %d: expected %s (%s)", i, s, state[i]) return false, failm, nil } } // check that we've consumed all the lines in the recorded state file _, err = reader.ReadString('\n') if err == nil || err != io.EOF { failm := "unexpected end of state. entries remaining in recorded state file" return false, failm, nil } } if dig.Hash() != reg.digest { return false, "digest mismatch", nil } return true, "", nil }