diff --git a/internal/app/app_test.go b/internal/app/app_test.go index f7c064a..af020e2 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2,6 +2,8 @@ package app_test import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "os" "path/filepath" @@ -378,11 +380,17 @@ func TestPS1ReadinessAndImport(t *testing.T) { // Importing user-supplied runtime files makes it ready, and files that are // not part of the runtime are left alone. importDir := t.TempDir() + const placeholder = "user supplied" for _, n := range []string{"POPS.ELF", "IOPRP252.IMG"} { - if err := os.WriteFile(filepath.Join(importDir, n), []byte("user supplied"), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(importDir, n), []byte(placeholder), 0o600); err != nil { t.Fatal(err) } } + // The runtime is verified by content, and no test can produce Sony's + // actual binaries. Point the manifest at the placeholder's hash for the + // rest of this test so the ready path is still exercised end to end. + sum := sha256.Sum256([]byte(placeholder)) + defer swapRuntimeHashes(t, hex.EncodeToString(sum[:]))() if err := os.WriteFile(filepath.Join(importDir, "notes.txt"), []byte("x"), 0o600); err != nil { t.Fatal(err) } @@ -397,6 +405,9 @@ func TestPS1ReadinessAndImport(t *testing.T) { if len(rep.Imported) != 2 { t.Errorf("imported %v", rep.Imported) } + if len(rep.Readiness.Wrong) != 0 { + t.Errorf("files that match their published hash were reported wrong: %v", rep.Readiness.Wrong) + } if len(rep.Ignored) != 1 || rep.Ignored[0] != "notes.txt" { t.Errorf("ignored = %v, want just notes.txt", rep.Ignored) } @@ -947,3 +958,71 @@ func TestCatalogWithoutADeviceStillListsSources(t *testing.T) { t.Errorf("no warning that there is no device: %v", warnings) } } + +// swapRuntimeHashes points every hashed runtime file at want for the duration +// of a test, and returns the restore. It exists because the runtime is checked +// by content and a test cannot hold Sony's binaries; the alternative is not +// exercising the ready path at all. +func swapRuntimeHashes(t *testing.T, want string) func() { + t.Helper() + saved := make([]string, len(ps1.RuntimeFiles)) + for i := range ps1.RuntimeFiles { + saved[i] = ps1.RuntimeFiles[i].SHA256 + if ps1.RuntimeFiles[i].SHA256 != "" { + ps1.RuntimeFiles[i].SHA256 = want + } + } + return func() { + for i := range ps1.RuntimeFiles { + ps1.RuntimeFiles[i].SHA256 = saved[i] + } + } +} + +// A runtime file that is present but is not the file it should be must be +// reported. This is the failure that cost an entire debugging session: a +// POPS.ELF that was a different file altogether, with the drive reporting +// PS1 READY, and every single title black-screening identically because the +// emulator was not the emulator. +func TestReadinessRejectsAWrongRuntimeFile(t *testing.T) { + ctx := context.Background() + svc, _ := newTestServices(t) + + importDir := t.TempDir() + for _, n := range []string{"POPS.ELF", "IOPRP252.IMG"} { + if err := os.WriteFile(filepath.Join(importDir, n), []byte("not the real thing"), 0o600); err != nil { + t.Fatal(err) + } + } + // Only POPS.ELF is given the hash it actually has, so IOPRP252.IMG is the + // odd one out and the check has to single it out rather than condemn both. + sum := sha256.Sum256([]byte("not the real thing")) + restore := swapRuntimeHashes(t, hex.EncodeToString(sum[:])) + defer restore() + for i := range ps1.RuntimeFiles { + if ps1.RuntimeFiles[i].Name == "IOPRP252.IMG" { + ps1.RuntimeFiles[i].SHA256 = strings.Repeat("00", 32) + } + } + + rep, err := svc.SetupPS1(ctx, app.SetupPS1Options{ImportDir: importDir}) + if err != nil { + t.Fatalf("SetupPS1: %v", err) + } + if rep.Readiness.Ready() { + t.Error("a drive whose IOPRP252.IMG is the wrong file reported READY") + } + if len(rep.Readiness.Wrong) != 1 || rep.Readiness.Wrong[0] != "IOPRP252.IMG" { + t.Fatalf("wrong = %v, want just IOPRP252.IMG", rep.Readiness.Wrong) + } + if len(rep.Readiness.Missing) != 0 { + t.Errorf("a wrong file was also reported missing: %v", rep.Readiness.Missing) + } + explain := strings.Join(rep.Readiness.Explain(), "\n") + if !strings.Contains(explain, "not the right file") { + t.Errorf("the explanation does not say the file is wrong:\n%s", explain) + } + if !strings.Contains(explain, "every PS1 title fails") && !strings.Contains(explain, "Every PS1 title fails") { + t.Errorf("the explanation does not say what the consequence is:\n%s", explain) + } +} diff --git a/internal/catalog/installed.go b/internal/catalog/installed.go index 70d9e72..1c8c873 100644 --- a/internal/catalog/installed.go +++ b/internal/catalog/installed.go @@ -144,11 +144,11 @@ func (r InstalledReader) Readiness(ctx context.Context) (ps1.Readiness, error) { // than reported as missing; saying "missing" would send the user chasing a // file that may well be there. mountErr := r.Mounts.With(ctx, ps1.CommonPartition, func(mp string) error { - present, missing, err := ps1.CheckRuntime(mp) + present, missing, wrong, err := ps1.CheckRuntime(mp) if err != nil { return err } - out.Runtime, out.Missing, out.RuntimeChecked = present, missing, true + out.Runtime, out.Missing, out.Wrong, out.RuntimeChecked = present, missing, wrong, true return nil }) if mountErr != nil { diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 6418f81..f215214 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -111,10 +111,19 @@ back afterwards.`, {ps1.POPSPartition, boolLabel(rep.Readiness.POPSPartition, "OK", "missing")}, {ps1.CommonPartition, boolLabel(rep.Readiness.CommonPartition, "OK", "missing")}, } + wrong := map[string]bool{} + for _, n := range rep.Readiness.Wrong { + wrong[n] = true + } for _, f := range ps1.RuntimeFiles { switch { case !rep.Readiness.RuntimeChecked: pairs = append(pairs, [2]string{f.Name, dim("unknown")}) + // A file that is present but is not the right file must not + // read as OK: that is precisely the state this check exists to + // expose, and it looks identical to a working one otherwise. + case wrong[f.Name]: + pairs = append(pairs, [2]string{f.Name, red("WRONG FILE")}) case rep.Readiness.Runtime[f.Name]: pairs = append(pairs, [2]string{f.Name, green("OK")}) default: diff --git a/internal/platform/ps1/pops.go b/internal/platform/ps1/pops.go index 794d2fd..01b62a3 100644 --- a/internal/platform/ps1/pops.go +++ b/internal/platform/ps1/pops.go @@ -1,7 +1,10 @@ package ps1 import ( + "crypto/sha256" + "encoding/hex" "fmt" + "io" "os" "path/filepath" "sort" @@ -84,21 +87,35 @@ type RuntimeFile struct { Copyrighted bool // Required marks a file without which PS1 playback cannot work. Required bool + // SHA256 is the file's known content, lower-case hex, or "" when there is + // no single right answer. POPS.ELF and the files beside it are one fixed + // release that does not vary between POPStarter revisions, so they can be + // checked exactly; POPSTARTER.ELF itself changes with every revision and + // cannot be. + SHA256 string } // RuntimeFiles is what a working POPStarter installation needs. +// +// The hashes are the published ones for the POPS release POPStarter is built +// against. They matter more than they look: a POPS.ELF that is the wrong file +// fails every PS1 title identically, whatever is done to the VCD, the launcher +// or its name, and there is nothing in the symptom to say so. Checking only +// that the files exist reports READY for a drive that cannot run anything. var RuntimeFiles = []RuntimeFile{ { Name: "POPS.ELF", Description: "the POPS PlayStation emulator", Copyrighted: true, Required: true, + SHA256: "59df3389c4df88a572daa720b05507c52c34eddfa0031a6fbeec55e0c2d0fcb1", }, { Name: "IOPRP252.IMG", Description: "the IOP replacement image POPS loads", Copyrighted: true, Required: true, + SHA256: "3338b238d84d7d586b716677e3a1c03b2088b882ecfa17f91fc33798931ca3ba", }, { Name: POPStarterELF, @@ -106,6 +123,23 @@ var RuntimeFiles = []RuntimeFile{ Copyrighted: false, Required: true, }, + // The two packages ship with POPS and belong beside it. They are not + // marked required, because a setup missing them is not necessarily broken + // and calling a working drive NOT READY would be worse than saying + // nothing -- but they are imported and reported, which is what makes an + // incomplete runtime visible. + { + Name: "POPS.PAK", + Description: "the POPS support package", + Copyrighted: true, + SHA256: "a3973bc4d177f65dd3201afe508aa9b59dd8a4d3374369bff14fb01f920aacad", + }, + { + Name: "POPS_IOX.PAK", + Description: "the POPS I/O support package", + Copyrighted: true, + SHA256: "9fa120429a73b632029b4f0fd554cd45c1e770f8ec020ecc3120b38a2b983e6e", + }, } // Readiness reports whether PS1 support is usable. @@ -120,11 +154,16 @@ type Readiness struct { RuntimeChecked bool `json:"runtime_checked"` // Missing lists the required runtime files that were not found. Missing []string `json:"missing,omitempty"` + // Wrong lists runtime files that are present but are not the file they + // should be. A wrong POPS.ELF is indistinguishable from a right one until + // a game is launched, and then every game fails the same way. + Wrong []string `json:"wrong,omitempty"` } // Ready reports whether a PS1 game could be launched right now. func (r Readiness) Ready() bool { - return r.POPSPartition && r.CommonPartition && r.RuntimeChecked && len(r.Missing) == 0 + return r.POPSPartition && r.CommonPartition && r.RuntimeChecked && + len(r.Missing) == 0 && len(r.Wrong) == 0 } // Status renders READY or NOT READY. @@ -150,6 +189,23 @@ func (r Readiness) Explain() []string { } return out } + for _, name := range r.Wrong { + f, _ := findRuntimeFile(name) + out = append(out, fmt.Sprintf( + "%s (%s) is present but is not the right file. Every PS1 title fails identically "+ + "with the wrong one, and nothing else in the setup will show why. Replace it and "+ + "re-import with `ps2hdd setup ps1 --import