diff --git a/cmd/doodad/commands/levelpack.go b/cmd/doodad/commands/levelpack.go index f85f159..201705e 100644 --- a/cmd/doodad/commands/levelpack.go +++ b/cmd/doodad/commands/levelpack.go @@ -56,7 +56,7 @@ func init() { Name: "doodads", Aliases: []string{"D"}, Usage: "which doodads to embed: none, custom, all", - Value: "all", + Value: "custom", }, }, Action: levelpackCreate, @@ -233,7 +233,7 @@ func levelpackCreate(c *cli.Context) error { continue } - log.Info("New doodad: %s", actor.Filename) + log.Info("Adding doodad to zipfile: %s", actor.Filename) // Get this doodad from the game's built-ins or the user's // profile directory only. Pulling embedded doodads out of diff --git a/cmd/doodle-admin/command/sign_level.go b/cmd/doodle-admin/command/sign_level.go new file mode 100644 index 0000000..c49d0bf --- /dev/null +++ b/cmd/doodle-admin/command/sign_level.go @@ -0,0 +1,92 @@ +package command + +import ( + "fmt" + "strings" + + "git.kirsle.net/SketchyMaze/doodle/pkg/level" + "git.kirsle.net/SketchyMaze/doodle/pkg/levelpack" + "git.kirsle.net/SketchyMaze/doodle/pkg/license" + "git.kirsle.net/SketchyMaze/doodle/pkg/license/levelsigning" + "github.com/urfave/cli/v2" +) + +// SignLevel a license key for Sketchy Maze. +var SignLevel *cli.Command + +func init() { + SignLevel = &cli.Command{ + Name: "sign-level", + Usage: "sign a level file so that it may use embedded assets in free versions of the game.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "key", + Aliases: []string{"k"}, + Usage: "Private key .pem file for signing", + Required: true, + }, + &cli.StringFlag{ + Name: "input", + Aliases: []string{"i"}, + Usage: "Input file name (.level or .levelpack)", + Required: true, + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "Output file, default outputs to console", + }, + }, + Action: func(c *cli.Context) error { + key, err := license.AdminLoadPrivateKey(c.String("key")) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + var ( + filename = c.String("input") + output = c.String("output") + ) + if output == "" { + output = filename + } + + // Sign a level? + if strings.HasSuffix(filename, ".level") { + lvl, err := level.LoadJSON(filename) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + // Sign it. + if sig, err := levelsigning.SignLevel(key, lvl); err != nil { + return cli.Exit(fmt.Errorf("couldn't sign level: %s", err), 1) + } else { + lvl.Signature = sig + err := lvl.WriteFile(output) + if err != nil { + return cli.Exit(err.Error(), 1) + } + } + } else if strings.HasSuffix(filename, ".levelpack") { + lp, err := levelpack.LoadFile(filename) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + // Sign it. + if sig, err := levelsigning.SignLevelPack(key, lp); err != nil { + return cli.Exit(fmt.Errorf("couldn't sign levelpack: %s", err), 1) + } else { + lp.Signature = sig + err := lp.WriteZipfile(output) + if err != nil { + return cli.Exit(err.Error(), 1) + } + } + } + + return nil + }, + } +} diff --git a/cmd/doodle-admin/command/verify_level.go b/cmd/doodle-admin/command/verify_level.go new file mode 100644 index 0000000..b7a695b --- /dev/null +++ b/cmd/doodle-admin/command/verify_level.go @@ -0,0 +1,73 @@ +package command + +import ( + "strings" + + "git.kirsle.net/SketchyMaze/doodle/pkg/level" + "git.kirsle.net/SketchyMaze/doodle/pkg/levelpack" + "git.kirsle.net/SketchyMaze/doodle/pkg/license" + "git.kirsle.net/SketchyMaze/doodle/pkg/license/levelsigning" + "git.kirsle.net/SketchyMaze/doodle/pkg/log" + "github.com/urfave/cli/v2" +) + +// VerifyLevel a license key for Sketchy Maze. +var VerifyLevel *cli.Command + +func init() { + VerifyLevel = &cli.Command{ + Name: "verify-level", + Usage: "check the signature on a level or levelpack file.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "key", + Aliases: []string{"k"}, + Usage: "Public key .pem file that signed the level", + Required: true, + }, + &cli.StringFlag{ + Name: "filename", + Aliases: []string{"f"}, + Usage: "File name of the .level or .levelpack", + Required: true, + }, + }, + Action: func(c *cli.Context) error { + key, err := license.AdminLoadPublicKey(c.String("key")) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + filename := c.String("filename") + if strings.HasSuffix(filename, ".level") { + lvl, err := level.LoadJSON(filename) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + // Verify it. + if ok := levelsigning.VerifyLevel(key, lvl); !ok { + log.Error("Signature is not valid!") + return cli.Exit("", 1) + } else { + log.Info("Level signature is OK!") + } + } else if strings.HasSuffix(filename, ".levelpack") { + lp, err := levelpack.LoadFile(filename) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + // Verify it. + if ok := levelsigning.VerifyLevelPack(key, lp); !ok { + log.Error("Signature is not valid!") + return cli.Exit("", 1) + } else { + log.Info("Levelpack signature is OK!") + } + } + + return nil + }, + } +} diff --git a/cmd/doodle-admin/main.go b/cmd/doodle-admin/main.go index 4be328f..f1cef24 100644 --- a/cmd/doodle-admin/main.go +++ b/cmd/doodle-admin/main.go @@ -47,6 +47,8 @@ func main() { command.Key, command.Sign, command.Verify, + command.SignLevel, + command.VerifyLevel, } sort.Sort(cli.FlagsByName(app.Flags)) diff --git a/pkg/doodads/fmt_readwrite.go b/pkg/doodads/fmt_readwrite.go index 7654a29..16f6472 100644 --- a/pkg/doodads/fmt_readwrite.go +++ b/pkg/doodads/fmt_readwrite.go @@ -114,12 +114,20 @@ func ListBuiltin() ([]string, error) { return result, nil } -// LoadFromEmbeddable reads a doodad file, checking a level's embeddable -// file data in addition to the usual places. -func LoadFromEmbeddable(filename string, fs filesystem.Embeddable) (*Doodad, error) { +/* +LoadFromEmbeddable reads a doodad file, checking a level's embeddable +file data in addition to the usual places. + +Use a true value for `force` to always return the file if available. By +default it will do a license check and free versions of the game won't +read the asset and get an error instead. A "Signed Level" is allowed to +use embedded assets in free versions and the caller uses force=true to +communicate the signature status. +*/ +func LoadFromEmbeddable(filename string, fs filesystem.Embeddable, force bool) (*Doodad, error) { if bin, err := fs.GetFile(balance.EmbeddedDoodadsBasePath + filename); err == nil { log.Debug("doodads.LoadFromEmbeddable: found %s", filename) - if !license.IsRegistered() { + if !force && !license.IsRegistered() { return nil, license.ErrRegisteredFeature } return Deserialize(filename, bin) diff --git a/pkg/doodle.go b/pkg/doodle.go index d2fc31f..0a619c9 100644 --- a/pkg/doodle.go +++ b/pkg/doodle.go @@ -341,11 +341,11 @@ func (d *Doodle) PlayLevel(filename string) error { // PlayFromLevelpack initializes the Play Scene from a level as part of // a levelpack. -func (d *Doodle) PlayFromLevelpack(pack levelpack.LevelPack, which levelpack.Level) error { +func (d *Doodle) PlayFromLevelpack(pack *levelpack.LevelPack, which levelpack.Level) error { log.Info("Loading level %s from levelpack %s", which.Filename, pack.Title) scene := &PlayScene{ Filename: which.Filename, - LevelPack: &pack, + LevelPack: pack, } d.Goto(scene) return nil diff --git a/pkg/editor_scene.go b/pkg/editor_scene.go index e5c4510..db94e60 100644 --- a/pkg/editor_scene.go +++ b/pkg/editor_scene.go @@ -132,7 +132,9 @@ func (s *EditorScene) setupAsync(d *Doodle) error { "by "+s.Level.Author, ) s.UI.Canvas.LoadLevel(s.Level) - s.UI.Canvas.InstallActors(s.Level.Actors) + if err := s.installActors(); err != nil { + log.Error("InstallActors: %s", err) + } } else if s.filename != "" && s.OpenFile { log.Debug("EditorScene.Setup: Loading map from filename at %s", s.filename) loadscreen.SetSubtitle( @@ -141,7 +143,9 @@ func (s *EditorScene) setupAsync(d *Doodle) error { if err := s.LoadLevel(s.filename); err != nil { d.FlashError("LoadLevel error: %s", err) } else { - s.UI.Canvas.InstallActors(s.Level.Actors) + if err := s.installActors(); err != nil { + log.Error("InstallActors: %s", err) + } } } @@ -239,6 +243,25 @@ func (s *EditorScene) setupAsync(d *Doodle) error { return nil } +// Common function to install the actors into the level. +// +// InstallActors may return an error if doodads were not found - because the +// player is on the free version and can't load attached doodads from nonsigned +// files. +func (s *EditorScene) installActors() error { + if err := s.UI.Canvas.InstallActors(s.Level.Actors); err != nil { + summary := "This level references some doodads that were not found:" + if strings.Contains(err.Error(), license.ErrRegisteredFeature.Error()) { + summary = "This level contains embedded doodads, but this is not\n" + + "available in the free version of the game. The following\n" + + "doodads could not be loaded:" + } + modal.Alert("%s\n\n%s", summary, err).WithTitle("Level Errors") + return fmt.Errorf("EditorScene.LoadLevel: InstallActors: %s", err) + } + return nil +} + // Playtest switches the level into Play Mode. func (s *EditorScene) Playtest() { log.Info("Play Mode, Go!") diff --git a/pkg/editor_ui_doodad.go b/pkg/editor_ui_doodad.go index a847bd0..14303f6 100644 --- a/pkg/editor_ui_doodad.go +++ b/pkg/editor_ui_doodad.go @@ -34,7 +34,7 @@ func (u *EditorUI) startDragActor(doodad *doodads.Doodad, actor *level.Actor) { if doodad == nil { if actor != nil { - obj, err := doodads.LoadFromEmbeddable(actor.Filename, u.Scene.Level) + obj, err := doodads.LoadFromEmbeddable(actor.Filename, u.Scene.Level, false) if err != nil { log.Error("startDragExistingActor: actor doodad name %s not found: %s", actor.Filename, err) return diff --git a/pkg/level/filesystem.go b/pkg/level/filesystem.go index d3e6289..d34e938 100644 --- a/pkg/level/filesystem.go +++ b/pkg/level/filesystem.go @@ -2,6 +2,7 @@ package level import ( "archive/zip" + "crypto/sha256" "encoding/json" "errors" "fmt" @@ -93,6 +94,18 @@ func (fs *FileSystem) Delete(filename string) { } } +// Checksum returns a SHA-256 checksum of a file's data. +func (fs *FileSystem) Checksum(filename string) (string, error) { + data, err := fs.Get(filename) + if err != nil { + return "", err + } + + h := sha256.New() + h.Write(data) + return fmt.Sprintf("%x", h.Sum(nil)), nil +} + // List files in the FileSystem, including the ZIP file. // // In the ZIP file, attachments are under the "assets/" prefix so this diff --git a/pkg/level/giant_screenshot/giant_screenshot.go b/pkg/level/giant_screenshot/giant_screenshot.go index 27f03a7..88f26d4 100644 --- a/pkg/level/giant_screenshot/giant_screenshot.go +++ b/pkg/level/giant_screenshot/giant_screenshot.go @@ -95,7 +95,7 @@ func GiantScreenshot(lvl *level.Level) (image.Image, error) { // Render the doodads. log.Debug("GiantScreenshot: Render actors...") for _, actor := range lvl.Actors { - doodad, err := doodads.LoadFromEmbeddable(actor.Filename, lvl) + doodad, err := doodads.LoadFromEmbeddable(actor.Filename, lvl, false) if err != nil { log.Error("GiantScreenshot: Load doodad: %s", err) continue diff --git a/pkg/level/publishing/publishing.go b/pkg/level/publishing/publishing.go index c132067..9a39bff 100644 --- a/pkg/level/publishing/publishing.go +++ b/pkg/level/publishing/publishing.go @@ -52,7 +52,7 @@ func Publish(lvl *level.Level) error { log.Debug("Embed filename: %s", filename) names[filename] = nil - doodad, err := doodads.LoadFromEmbeddable(filename, lvl) + doodad, err := doodads.LoadFromEmbeddable(filename, lvl, false) if err != nil { return fmt.Errorf("couldn't load doodad %s: %s", filename, err) } diff --git a/pkg/level/types.go b/pkg/level/types.go index 6e40690..cffef0e 100644 --- a/pkg/level/types.go +++ b/pkg/level/types.go @@ -67,6 +67,9 @@ type Level struct { SaveDoodads bool `json:"saveDoodads"` SaveBuiltins bool `json:"saveBuiltins"` + // Signature for a level with embedded doodads to still play in free mode. + Signature []byte `json:"signature,omitempty"` + // Undo history, temporary live data not persisted to the level file. UndoHistory *drawtool.History `json:"-"` } diff --git a/pkg/levelpack/levelpack.go b/pkg/levelpack/levelpack.go index af0da74..9b96b4f 100644 --- a/pkg/levelpack/levelpack.go +++ b/pkg/levelpack/levelpack.go @@ -16,14 +16,16 @@ import ( "time" "git.kirsle.net/SketchyMaze/doodle/assets" + "git.kirsle.net/SketchyMaze/doodle/pkg/balance" "git.kirsle.net/SketchyMaze/doodle/pkg/enum" "git.kirsle.net/SketchyMaze/doodle/pkg/filesystem" + "git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/userdir" ) // LevelPack describes the contents of a levelpack file. type LevelPack struct { - Title string `json:"title` + Title string `json:"title"` Description string `json:"description"` Author string `json:"author"` Created time.Time `json:"created"` @@ -40,6 +42,10 @@ type LevelPack struct { // A reference to the original filename, not stored in json. Filename string `json:"-"` + + // Signature to allow free versions of the game to load embedded + // custom doodads inside this levelpack for its levels. + Signature []byte `json:"signature,omitempty"` } // Level holds metadata about the levels in the levelpack. @@ -50,7 +56,7 @@ type Level struct { } // LoadFile reads a .levelpack zip file. -func LoadFile(filename string) (LevelPack, error) { +func LoadFile(filename string) (*LevelPack, error) { var ( fh io.ReaderAt filesize int64 @@ -66,33 +72,33 @@ func LoadFile(filename string) (LevelPack, error) { if fh == nil { stat, err := os.Stat(filename) if err != nil { - return LevelPack{}, err + return nil, err } filesize = stat.Size() fh, err = os.Open(filename) if err != nil { - return LevelPack{}, err + return nil, err } } // No luck? if fh == nil { - return LevelPack{}, errors.New("no file found") + return nil, errors.New("no file found") } reader, err := zip.NewReader(fh, filesize) if err != nil { - return LevelPack{}, err + return nil, err } - lp := LevelPack{ + lp := &LevelPack{ Filename: filename, Zipfile: reader, } // Read the index.json. - lp.GetJSON(&lp, "index.json") + lp.GetJSON(lp, "index.json") return lp, nil } @@ -100,18 +106,18 @@ func LoadFile(filename string) (LevelPack, error) { // LoadAllAvailable loads every levelpack visible to the game. Returns // the sorted list of filenames as from ListFiles, plus a deeply loaded // hash map associating the filenames with their data. -func LoadAllAvailable() ([]string, map[string]LevelPack, error) { +func LoadAllAvailable() ([]string, map[string]*LevelPack, error) { filenames, err := ListFiles() if err != nil { return filenames, nil, err } - var dictionary = map[string]LevelPack{} + var dictionary = map[string]*LevelPack{} for _, filename := range filenames { // Resolve the filename to a definite path on disk. path, err := filesystem.FindFile(filename) if err != nil { - fmt.Errorf("LoadAllAvailable: FindFile(%s): %s", path, err) + log.Error("LoadAllAvailable: FindFile(%s): %s", path, err) return filenames, nil, err } @@ -185,12 +191,58 @@ func (l LevelPack) WriteFile(filename string) error { return ioutil.WriteFile(filename, out, 0655) } -// GetData returns file data from inside the loaded zipfile of a levelpack. -func (l LevelPack) GetData(filename string) ([]byte, error) { +// WriteZipfile saves a levelpack back into a zip file. +func (l LevelPack) WriteZipfile(filename string) error { + fh, err := os.Create(filename) + if err != nil { + return err + } + defer fh.Close() + + // Copy all of the levels and other files from the old zip to new zip. + zf := zip.NewWriter(fh) + defer zf.Close() + + // Copy attached doodads and levels. + for _, file := range l.Zipfile.File { + if !strings.HasPrefix(file.Name, "doodads/") && + !strings.HasPrefix(file.Name, "levels/") { + continue + } + + if err := zf.Copy(file); err != nil { + return err + } + } + + // Write the index.json metadata. + meta, err := json.Marshal(l) + if err != nil { + return err + } + + writer, err := zf.Create("index.json") + if err != nil { + return err + } + _, err = writer.Write(meta) + return err +} + +// GetFile returns file data from inside the loaded zipfile of a levelpack. +// +// This also implements the Embeddable interface. +func (l LevelPack) GetFile(filename string) ([]byte, error) { if l.Zipfile == nil { return []byte{}, errors.New("zipfile not loaded") } + // NOTE: levelpacks don't have an "assets/" prefix but the game + // might come looking for "assets/doodads" + if strings.HasPrefix(filename, balance.EmbeddedDoodadsBasePath) { + filename = strings.Replace(filename, balance.EmbeddedDoodadsBasePath, "doodads/", 1) + } + file, err := l.Zipfile.Open(filename) if err != nil { return []byte{}, err @@ -201,7 +253,7 @@ func (l LevelPack) GetData(filename string) ([]byte, error) { // GetJSON loads a JSON file from the zipfile and marshals it into your struct. func (l LevelPack) GetJSON(v interface{}, filename string) error { - data, err := l.GetData(filename) + data, err := l.GetFile(filename) if err != nil { return err } diff --git a/pkg/license/levelsigning/level_signing.go b/pkg/license/levelsigning/level_signing.go new file mode 100644 index 0000000..82584fe --- /dev/null +++ b/pkg/license/levelsigning/level_signing.go @@ -0,0 +1,193 @@ +package levelsigning + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "encoding/json" + "fmt" + "io/ioutil" + + "git.kirsle.net/SketchyMaze/doodle/pkg/level" + "git.kirsle.net/SketchyMaze/doodle/pkg/levelpack" + "git.kirsle.net/SketchyMaze/doodle/pkg/license" + "git.kirsle.net/SketchyMaze/doodle/pkg/log" +) + +// IsLevelSigned returns a quick answer. +func IsLevelSigned(lvl *level.Level) bool { + return VerifyLevel(license.Signer, lvl) +} + +// IsLevelPackSigned returns a quick answer. +func IsLevelPackSigned(lp *levelpack.LevelPack) bool { + return VerifyLevelPack(license.Signer, lp) +} + +/* +SignLevel creates a signature on a level file which allows it to load its +embedded doodads even for free versions of the game. + +Free versions will verify a level's signature before bailing out with the +"can't play levels w/ embedded doodads" response. + +NOTE: this only supported Zipfile levels and will assume the level you +pass has a Zipfile to access embedded assets. +*/ +func SignLevel(key *ecdsa.PrivateKey, lvl *level.Level) ([]byte, error) { + // Encode the attached files data to deterministic JSON. + certificate, err := StringifyAssets(lvl) + if err != nil { + return nil, err + } + + log.Info("Sign file tree: %s", certificate) + digest := shasum(certificate) + + signature, err := ecdsa.SignASN1(rand.Reader, key, digest) + if err != nil { + return nil, err + } + log.Info("Digest: %x Signature: %x", digest, signature) + + return signature, nil +} + +// VerifyLevel verifies a level's signature and returns if it is OK. +func VerifyLevel(publicKey *ecdsa.PublicKey, lvl *level.Level) bool { + // No signature = not verified. + if lvl.Signature == nil || len(lvl.Signature) == 0 { + return false + } + + // Encode the attached files data to deterministic JSON. + certificate, err := StringifyAssets(lvl) + if err != nil { + log.Error("VerifyLevel: couldn't stringify assets: %s", err) + return false + } + + digest := shasum(certificate) + + // Verify the signature against our public key. + return ecdsa.VerifyASN1(publicKey, digest, lvl.Signature) +} + +/* +SignLevelpack applies a signature to a levelpack as a whole, to allow its +shared custom doodads to be loaded by its levels in free games. +*/ +func SignLevelPack(key *ecdsa.PrivateKey, lp *levelpack.LevelPack) ([]byte, error) { + // Encode the attached files data to deterministic JSON. + certificate, err := StringifyLevelpackAssets(lp) + if err != nil { + return nil, err + } + + log.Info("Sign file tree: %s", certificate) + digest := shasum(certificate) + + signature, err := ecdsa.SignASN1(rand.Reader, key, digest) + if err != nil { + return nil, err + } + log.Info("Digest: %x Signature: %x", digest, signature) + + return signature, nil +} + +// VerifyLevelPack verifies a levelpack's signature and returns if it is OK. +func VerifyLevelPack(publicKey *ecdsa.PublicKey, lp *levelpack.LevelPack) bool { + // No signature = not verified. + if lp.Signature == nil || len(lp.Signature) == 0 { + return false + } + + // Encode the attached files data to deterministic JSON. + certificate, err := StringifyLevelpackAssets(lp) + if err != nil { + log.Error("VerifyLevelPack: couldn't stringify assets: %s", err) + return false + } + + digest := shasum(certificate) + + // Verify the signature against our public key. + return ecdsa.VerifyASN1(publicKey, digest, lp.Signature) +} + +// StringifyAssets creates the signing checksum of a level's attached assets. +func StringifyAssets(lvl *level.Level) ([]byte, error) { + // Get a listing of all embedded files. Note: gives us a conveniently + // sorted array of files too. + files := lvl.Files.List() + + // Pair each filename with its SHA256 sum. + var checksum = map[string]string{} + for _, filename := range files { + if sum, err := lvl.Files.Checksum(filename); err != nil { + return nil, fmt.Errorf("when checksum %s got error: %s", filename, err) + } else { + checksum[filename] = sum + } + } + + // Encode the payload to deterministic JSON. + certificate, err := json.Marshal(checksum) + if err != nil { + return nil, err + } + + return certificate, nil +} + +// StringifyLevelpackAssets creates the signing checksum of a level's attached assets. +func StringifyLevelpackAssets(lp *levelpack.LevelPack) ([]byte, error) { + var ( + files = []string{} + seen = map[string]struct{}{} + ) + + // Enumerate the files in the zipfile assets/ folder. + for _, file := range lp.Zipfile.File { + if file.Name == "index.json" { + continue + } + + if _, ok := seen[file.Name]; !ok { + files = append(files, file.Name) + seen[file.Name] = struct{}{} + } + } + + // Pair each filename with its SHA256 sum. + var checksum = map[string]string{} + for _, filename := range files { + file, err := lp.Zipfile.Open(filename) + if err != nil { + return nil, err + } + + bin, err := ioutil.ReadAll(file) + if err != nil { + return nil, err + } + + checksum[filename] = fmt.Sprintf("%x", shasum(bin)) + } + + // Encode the payload to deterministic JSON. + certificate, err := json.Marshal(checksum) + if err != nil { + return nil, err + } + + return certificate, nil +} + +// Common function to SHA-256 checksum a thing. +func shasum(data []byte) []byte { + h := sha256.New() + h.Write(data) + return h.Sum(nil) +} diff --git a/pkg/main_scene.go b/pkg/main_scene.go index dea157d..c1d5013 100644 --- a/pkg/main_scene.go +++ b/pkg/main_scene.go @@ -175,7 +175,7 @@ func (s *MainScene) Setup(d *Doodle) error { Supervisor: s.Supervisor, Engine: d.Engine, - OnPlayLevel: func(lp levelpack.LevelPack, which levelpack.Level) { + OnPlayLevel: func(lp *levelpack.LevelPack, which levelpack.Level) { if err := d.PlayFromLevelpack(lp, which); err != nil { shmem.FlashError(err.Error()) } @@ -353,7 +353,7 @@ func (s *MainScene) SetupDemoLevel(d *Doodle) error { log.Error("Error loading DemoLevelPack(%s): %s", balance.DemoLevelPack, err) } else { log.Debug("Loading selected level from pack: %s", levelName) - levelbin, err := lp.GetData("levels/" + levelName) + levelbin, err := lp.GetFile("levels/" + levelName) if err != nil { log.Error("Error getting level from DemoLevelpack(%s#%s): %s", balance.DemoLevelPack, diff --git a/pkg/play_scene.go b/pkg/play_scene.go index d65a732..8a539db 100644 --- a/pkg/play_scene.go +++ b/pkg/play_scene.go @@ -2,6 +2,7 @@ package doodle import ( "fmt" + "strings" "time" "git.kirsle.net/SketchyMaze/doodle/pkg/balance" @@ -12,6 +13,8 @@ import ( "git.kirsle.net/SketchyMaze/doodle/pkg/keybind" "git.kirsle.net/SketchyMaze/doodle/pkg/level" "git.kirsle.net/SketchyMaze/doodle/pkg/levelpack" + "git.kirsle.net/SketchyMaze/doodle/pkg/license" + "git.kirsle.net/SketchyMaze/doodle/pkg/license/levelsigning" "git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/modal" "git.kirsle.net/SketchyMaze/doodle/pkg/modal/loadscreen" @@ -242,11 +245,18 @@ func (s *PlayScene) setupAsync(d *Doodle) error { s.drawing.OnSetPlayerCharacter = s.SetPlayerCharacter s.drawing.OnResetTimer = s.ResetTimer + // If this level game from a signed LevelPack, inform the canvas. + if s.LevelPack != nil && levelsigning.IsLevelPackSigned(s.LevelPack) { + s.drawing.IsSignedLevelPack = s.LevelPack + } + // Given a filename or map data to play? if s.Level != nil { log.Debug("PlayScene.Setup: received level from scene caller") s.drawing.LoadLevel(s.Level) - s.drawing.InstallActors(s.Level.Actors) + if err := s.installActors(); err != nil { + log.Error("InstallActors: %s", err) + } } else if s.Filename != "" { loadscreen.SetSubtitle("Opening: " + s.Filename) log.Debug("PlayScene.Setup: loading map from file %s", s.Filename) @@ -258,7 +268,9 @@ func (s *PlayScene) setupAsync(d *Doodle) error { log.Debug("PlayScene.Setup: no grid given, initializing empty grid") s.Level = level.New() s.drawing.LoadLevel(s.Level) - s.drawing.InstallActors(s.Level.Actors) + if err := s.installActors(); err != nil { + log.Error("InstallActors: %s", err) + } } // Choose a death barrier in case the user falls off the map, @@ -316,6 +328,25 @@ func (s *PlayScene) setupAsync(d *Doodle) error { return nil } +// Common function to install the actors into the level. +// +// InstallActors may return an error if doodads were not found - because the +// player is on the free version and can't load attached doodads from nonsigned +// files. +func (s *PlayScene) installActors() error { + if err := s.drawing.InstallActors(s.Level.Actors); err != nil { + summary := "This level references some doodads that were not found:" + if strings.Contains(err.Error(), license.ErrRegisteredFeature.Error()) { + summary = "This level contains embedded doodads, but this is not\n" + + "available in the free version of the game. The following\n" + + "doodads could not be loaded:" + } + modal.Alert("%s\n\n%s", summary, err).WithTitle("Level Errors") + return fmt.Errorf("EditorScene.LoadLevel: InstallActors: %s", err) + } + return nil +} + // PlaceResizeCanvas updates the Canvas size and placement on the screen, // e.g. if an ultra HD monitor plays a Bounded level where the entirety of a // level bounds is on-screen, the drawing should be cut there and the @@ -472,7 +503,7 @@ func (s *PlayScene) setupPlayer(playerCharacterFilename string) { // centerIn is optional, ignored if zero. func (s *PlayScene) installPlayerDoodad(filename string, spawn render.Point, centerIn render.Rect) { // Load in the player character. - player, err := doodads.LoadFromEmbeddable(filename, s.Level) + player, err := doodads.LoadFromEmbeddable(filename, s.Level, false) if err != nil { log.Error("PlayScene.Setup: failed to load player doodad: %s", err) player = doodads.NewDummy(32) @@ -726,7 +757,7 @@ func (s *PlayScene) ShowEndLevelModal(success bool, title, message string) { config.OnNextLevel = func() { nextLevel := s.LevelPack.Levels[i+1] log.Info("Advance to next level: %s", nextLevel.Filename) - s.d.PlayFromLevelpack(*s.LevelPack, nextLevel) + s.d.PlayFromLevelpack(s.LevelPack, nextLevel) } } } @@ -897,7 +928,7 @@ func (s *PlayScene) LoadLevel(filename string) error { // Are we playing out of a levelpack? if s.LevelPack != nil { - levelbin, err := s.LevelPack.GetData("levels/" + filename) + levelbin, err := s.LevelPack.GetFile("levels/" + filename) if err != nil { log.Error("Error reading levels/%s from zip: %s", filename, err) } @@ -921,7 +952,9 @@ func (s *PlayScene) LoadLevel(filename string) error { s.Level = lvl s.drawing.LoadLevel(s.Level) - s.drawing.InstallActors(s.Level.Actors) + if err := s.installActors(); err != nil { + return err + } return nil } diff --git a/pkg/play_scene_menubar.go b/pkg/play_scene_menubar.go index 3b8ffd7..8cf77f1 100644 --- a/pkg/play_scene_menubar.go +++ b/pkg/play_scene_menubar.go @@ -23,7 +23,7 @@ func (u *PlayScene) setupMenuBar(d *Doodle) *ui.MenuBar { Supervisor: u.Supervisor, Engine: d.Engine, - OnPlayLevel: func(lp levelpack.LevelPack, which levelpack.Level) { + OnPlayLevel: func(lp *levelpack.LevelPack, which levelpack.Level) { if err := d.PlayFromLevelpack(lp, which); err != nil { shmem.FlashError(err.Error()) } diff --git a/pkg/uix/canvas.go b/pkg/uix/canvas.go index cee6321..e62751a 100644 --- a/pkg/uix/canvas.go +++ b/pkg/uix/canvas.go @@ -12,6 +12,7 @@ import ( "git.kirsle.net/SketchyMaze/doodle/pkg/drawtool" "git.kirsle.net/SketchyMaze/doodle/pkg/filesystem" "git.kirsle.net/SketchyMaze/doodle/pkg/level" + "git.kirsle.net/SketchyMaze/doodle/pkg/levelpack" "git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/scripting" "git.kirsle.net/SketchyMaze/doodle/pkg/wallpaper" @@ -78,6 +79,11 @@ type Canvas struct { doodad *doodads.Doodad modified bool // set to True when the drawing has been modified, like in Editor Mode. + // PlayScene can set whether the Levelpack has a valid signature on it, so that + // in InstallActors() we can allow a levelpack to load attached doodads on free + // mode for their levels. + IsSignedLevelPack *levelpack.LevelPack // filled in ONLY IF SIGNED. + // Actors to superimpose on top of the drawing. actor *Actor // if this canvas IS an actor actors []*Actor // if this canvas CONTAINS actors (i.e., is a level) diff --git a/pkg/uix/canvas_actors.go b/pkg/uix/canvas_actors.go index bae6f97..634e0fd 100644 --- a/pkg/uix/canvas_actors.go +++ b/pkg/uix/canvas_actors.go @@ -8,6 +8,7 @@ import ( "git.kirsle.net/SketchyMaze/doodle/pkg/doodads" "git.kirsle.net/SketchyMaze/doodle/pkg/level" + "git.kirsle.net/SketchyMaze/doodle/pkg/license/levelsigning" "git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/scripting" "git.kirsle.net/SketchyMaze/doodle/pkg/scripting/exceptions" @@ -33,13 +34,30 @@ func (w *Canvas) InstallActors(actors level.ActorMap) error { actor.Canvas.Destroy() } + // Signed Levels: the free version normally won't load embedded assets from + // a level and the call to LoadFromEmbeddable below returns the error. If the + // level is signed it is allowed to use its embedded assets. + isSigned := w.IsSignedLevelPack != nil || levelsigning.IsLevelSigned(w.level) + w.actors = make([]*Actor, 0) for _, id := range actorIDs { var actor = actors[id] - doodad, err := doodads.LoadFromEmbeddable(actor.Filename, w.level) + + // Try loading the doodad from the level's own attached files. + doodad, err := doodads.LoadFromEmbeddable(actor.Filename, w.level, isSigned) if err != nil { - errs = append(errs, fmt.Sprintf("%s: %s", actor.Filename, err.Error())) - continue + // If we have a signed levelpack, try loading from the levelpack. + if w.IsSignedLevelPack != nil { + if found, err := doodads.LoadFromEmbeddable(actor.Filename, w.IsSignedLevelPack, true); err == nil { + doodad = found + } + } + + // If not found, append the error and continue. + if doodad == nil { + errs = append(errs, fmt.Sprintf("%s: %s", actor.Filename, err.Error())) + continue + } } // Create the "live" Actor to exist in the world, and set its world diff --git a/pkg/windows/levelpack_open.go b/pkg/windows/levelpack_open.go index 9acf46b..d079287 100644 --- a/pkg/windows/levelpack_open.go +++ b/pkg/windows/levelpack_open.go @@ -20,7 +20,7 @@ type LevelPack struct { Engine render.Engine // Callback functions. - OnPlayLevel func(pack levelpack.LevelPack, level levelpack.Level) + OnPlayLevel func(pack *levelpack.LevelPack, level levelpack.Level) OnCloseWindow func() // Internal variables @@ -131,12 +131,13 @@ func NewLevelPackWindow(config LevelPack) *ui.Window { return window } -/* Index screen for the LevelPack window. +/* + Index screen for the LevelPack window. frame: a TabFrame to populate */ func (config LevelPack) makeIndexScreen(frame *ui.Frame, width, height int, - lpFiles []string, packmap map[string]levelpack.LevelPack, onChoose func(string)) { + lpFiles []string, packmap map[string]*levelpack.LevelPack, onChoose func(string)) { var ( buttonHeight = 60 // height of each LevelPack button buttonWidth = width - 40 @@ -268,7 +269,7 @@ func (config LevelPack) makeIndexScreen(frame *ui.Frame, width, height int, } // Detail screen for a given levelpack. -func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp levelpack.LevelPack) *ui.Frame { +func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp *levelpack.LevelPack) *ui.Frame { var ( buttonHeight = 40 buttonWidth = width - 40