Gopher2600/cartridgeloader/loader.go
JetSetIlly 24f3f32342 simplified notifications package
notifications interface instance moved to environment from
cartridgeloader. the cartridgeloader package predates the environment
package and had started to be used inappropriately

simplified how notifications.Notify() is called. in particular the
supercharger fastload starter no longer bundles a function hook. nor is
the cartridge instance sent with the notification
2024-04-06 10:12:55 +01:00

366 lines
10 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 cartridgeloader
import (
"bytes"
"crypto/md5"
"crypto/sha1"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"github.com/jetsetilly/gopher2600/hardware/television/specification"
"github.com/jetsetilly/gopher2600/logger"
"github.com/jetsetilly/gopher2600/resources/fs"
)
// Loader is used to specify the cartridge to use when Attach()ing to
// the VCS. it also permits the called to specify the mapping of the cartridge
// (if necessary. fingerprinting is pretty good).
type Loader struct {
// filename of cartridge to load. In the case of embedded data, this field
// will contain the name of the data provided to the the
// NewLoaderFromEmbed() function.
Filename string
// empty string or "AUTO" indicates automatic fingerprinting
Mapping string
// the Mapping value that was used to initialise the loader
RequestedMapping string
// any detected TV spec in the filename. will be the empty string if
// nothing is found. note that the empty string is treated like "AUTO" by
// television.SetSpec().
Spec string
// expected hash of the loaded cartridge. empty string indicates that the
// hash is unknown and need not be validated. after a load operation the
// value will be the hash of the loaded data
//
// in the case of sound data (IsSoundData is true) then the hash is of the
// original binary file not he decoded PCM data
//
// the value of Hash will be checked on a call to Loader.Load(). if the
// string is empty then that check passes.
Hash string
// HashMD5 is an alternative to hash for use with the properties package
HashMD5 string
// does the Data field consist of sound (PCM) data
IsSoundData bool
// cartridge data. empty until Load() is called unless the loader was
// created by NewLoaderFromEmbed()
//
// the pointer-to-a-slice construct allows the cartridge to be
// loaded/changed by a Loader instance that has been passed by value.
Data *[]byte
// whether the data was assigned during NewLoaderFromEmbed()
embedded bool
// for some file types streaming is necessary. nil until Load() is called
// and the cartridge format requires streaming.
StreamedData *os.File
// pointer to pointer of StreamedData. this is a tricky construct but it
// allows us to pass an instance of Loader by value but still be able to
// close an opened stream at an "earlier" point in the code.
//
// if stream is nil then the data will not be streamed. if *stream is nil
// then the stream is not open. although use the IsStreamed() function for
// this information.
stream **os.File
}
// sentinal error for when it is attempted to create a loader with no filename
var NoFilename = errors.New("no filename")
// NewLoader is the preferred method of initialisation for the Loader type.
//
// The mapping argument will be used to set the Mapping field, unless the
// argument is either "AUTO" or the empty string. In which case the file
// extension is used to set the field.
//
// File extensions should be the same as the ID of the intended mapper, as
// defined in the cartridge package. The exception is the DPC+ format which
// requires the file extension "DP+"
//
// File extensions ".BIN" and "A26" will set the Mapping field to "AUTO".
//
// Alphabetic characters in file extensions can be in upper or lower case or a
// mixture of both.
//
// Filenames can contain whitespace, including leading and trailing whitespace,
// but cannot consists only of whitespace.
func NewLoader(filename string, mapping string) (Loader, error) {
// check filename but don't change it. we don't want to allow the empty
// string or a string only consisting of whitespace, but we do want to
// allow filenames with leading/trailing spaces
if strings.TrimSpace(filename) == "" {
return Loader{}, fmt.Errorf("catridgeloader: %w", NoFilename)
}
// absolute path of filename
var err error
filename, err = fs.Abs(filename)
if err != nil {
return Loader{}, fmt.Errorf("catridgeloader: %w", err)
}
mapping = strings.TrimSpace(strings.ToUpper(mapping))
if mapping == "" {
mapping = "AUTO"
}
ld := Loader{
Filename: filename,
Mapping: mapping,
RequestedMapping: mapping,
}
// create an empty slice for the Data field to refer to
data := make([]byte, 0)
ld.Data = &data
// decide what mapping to use if the requested mapping is AUTO
if mapping == "AUTO" {
extension := strings.ToUpper(filepath.Ext(filename))
if slices.Contains(autoFileExtensions, extension) {
ld.Mapping = "AUTO"
} else if slices.Contains(explicitFileExtensions, extension) {
ld.Mapping = extension[1:]
} else if slices.Contains(audioFileExtensions, extension) {
ld.Mapping = "AR"
ld.IsSoundData = true
}
}
// if mapping value is still AUTO, make a special check for moviecart data.
// we want to do this now so we can initialise the stream
if ld.Mapping == "AUTO" {
ok, err := fingerprintMovieCart(filename)
if err != nil {
return Loader{}, fmt.Errorf("catridgeloader: %w", err)
}
if ok {
ld.Mapping = "MVC"
}
}
// create stream pointer only for streaming sources. these file formats are
// likely to be very large by comparison to regular cartridge files.
if ld.Mapping == "MVC" || (ld.Mapping == "AR" && ld.IsSoundData) {
ld.stream = new(*os.File)
}
ld.Spec = specification.SearchSpec(filename)
return ld, nil
}
// special handling for MVC files without the MVC file extension
func fingerprintMovieCart(filename string) (bool, error) {
f, err := os.Open(filename)
if err != nil {
return false, fmt.Errorf("cartridgeloader: %w", err)
}
b := make([]byte, 4)
f.Read(b)
f.Close()
if bytes.Compare(b, []byte{'M', 'V', 'C', 0x00}) == 0 {
return true, nil
}
return false, nil
}
// NewLoaderFromEmbed initialises a loader with an array of bytes. Suitable for
// loading embedded data (using go:embed for example) into the emulator.
//
// The mapping argument should indicate the format of the data or "AUTO" to
// indicate that the emulator can perform a fingerprint.
//
// The name argument should not include a file extension because it won't be
// used.
func NewLoaderFromEmbed(name string, data []byte, mapping string) (Loader, error) {
if len(data) == 0 {
return Loader{}, fmt.Errorf("catridgeloader: emebedded data is empty")
}
name = strings.TrimSpace(name)
if name == "" {
return Loader{}, fmt.Errorf("catridgeloader: no name for embedded data")
}
mapping = strings.TrimSpace(strings.ToUpper(mapping))
if mapping == "" {
mapping = "AUTO"
}
return Loader{
Filename: name,
Mapping: mapping,
RequestedMapping: mapping,
Data: &data,
embedded: true,
Hash: fmt.Sprintf("%x", sha1.Sum(data)),
HashMD5: fmt.Sprintf("%x", md5.Sum(data)),
}, nil
}
// Close should be called before disposing of a Loader instance.
func (ld Loader) Close() error {
if ld.stream == nil || *ld.stream == nil {
return nil
}
err := (**ld.stream).Close()
*ld.stream = nil
if err != nil {
return fmt.Errorf("cartridgeloader: %w", err)
}
logger.Logf("cartridgeloader", "stream closed (%s)", ld.Filename)
return nil
}
// ShortName returns a shortened version of the CartridgeLoader filename field.
// In the case of embedded data, the filename field will be returned unaltered.
func (ld Loader) ShortName() string {
if ld.embedded {
return ld.Filename
}
// return the empty string if filename is undefined
if len(strings.TrimSpace(ld.Filename)) == 0 {
return ""
}
sn := filepath.Base(ld.Filename)
sn = strings.TrimSuffix(sn, filepath.Ext(ld.Filename))
return sn
}
// IsStreaming returns two booleans. The first will be true if Loader was
// created for what appears to be a streaming source, and the second will be
// true if the stream has been open.
func (ld Loader) IsStreaming() (bool, bool) {
return ld.stream != nil, ld.stream != nil && *ld.stream != nil
}
// IsEmbedded returns true if Loader was created from embedded data. If data
// has a length of zero then this function will return false.
func (ld Loader) IsEmbedded() bool {
return ld.embedded && len(*ld.Data) > 0
}
// Load the cartridge data and return as a byte array. Loader filenames with a
// valid schema will use that method to load the data. Currently supported
// schemes are HTTP and local files.
func (ld *Loader) Load() error {
// data is already "loaded" when using embedded data
if ld.embedded {
return nil
}
if ld.stream != nil {
err := ld.Close()
if err != nil {
return fmt.Errorf("cartridgeloader: %w", err)
}
ld.StreamedData, err = os.Open(ld.Filename)
if err != nil {
return fmt.Errorf("cartridgeloader: %w", err)
}
logger.Logf("cartridgeloader", "stream open (%s)", ld.Filename)
*ld.stream = ld.StreamedData
return nil
}
if ld.Data != nil && len(*ld.Data) > 0 {
return nil
}
scheme := "file"
url, err := url.Parse(ld.Filename)
if err == nil {
scheme = url.Scheme
}
switch scheme {
case "http":
fallthrough
case "https":
resp, err := http.Get(ld.Filename)
if err != nil {
return fmt.Errorf("cartridgeloader: %w", err)
}
defer resp.Body.Close()
*ld.Data, err = io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("cartridgeloader: %w", err)
}
case "file":
fallthrough
case "":
fallthrough
default:
f, err := os.Open(ld.Filename)
if err != nil {
return fmt.Errorf("cartridgeloader: %w", err)
}
defer f.Close()
*ld.Data, err = io.ReadAll(f)
if err != nil {
return fmt.Errorf("cartridgeloader: %w", err)
}
}
// generate hash and check for consistency
hash := fmt.Sprintf("%x", sha1.Sum(*ld.Data))
if ld.Hash != "" && ld.Hash != hash {
return fmt.Errorf("cartridgeloader: unexpected hash value")
}
ld.Hash = hash
// generate md5 hash and check for consistency
hashmd5 := fmt.Sprintf("%x", md5.Sum(*ld.Data))
if ld.HashMD5 != "" && ld.HashMD5 != hashmd5 {
return fmt.Errorf("cartridgeloader: unexpected hash value")
}
ld.HashMD5 = hashmd5
return nil
}