Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 80 additions & 1 deletion internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package app_test

import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
}
4 changes: 2 additions & 2 deletions internal/catalog/installed.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions internal/cli/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
111 changes: 98 additions & 13 deletions internal/platform/ps1/pops.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package ps1

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"sort"
Expand Down Expand Up @@ -84,28 +87,59 @@ 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,
Description: "the POPStarter launcher",
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.
Expand All @@ -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.
Expand All @@ -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 <dir>`; the expected SHA-256 is %s.",
name, f.Description, f.SHA256))
}
for _, f := range RuntimeFiles {
if f.Required || f.SHA256 == "" || r.Runtime[f.Name] {
continue
}
out = append(out, fmt.Sprintf(
"%s (%s) is not installed. It ships with POPS and belongs beside it; "+
"PS1 support may work without it, but the runtime is incomplete.",
f.Name, f.Description))
}
for _, name := range r.Missing {
f, ok := findRuntimeFile(name)
if !ok {
Expand Down Expand Up @@ -181,40 +237,69 @@ func findRuntimeFile(name string) (RuntimeFile, bool) {

// CheckRuntime inspects a mounted __common partition and fills in the runtime
// half of a Readiness.
func CheckRuntime(commonMount string) (map[string]bool, []string, error) {
func CheckRuntime(commonMount string) (present map[string]bool, missing, wrong []string, err error) {
dir := filepath.Join(commonMount, POPSDir)
present := map[string]bool{}
present = map[string]bool{}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
// No POPS directory at all: everything required is missing, which
// is a definite answer rather than an error.
var missing []string
for _, f := range RuntimeFiles {
present[f.Name] = false
if f.Required {
missing = append(missing, f.Name)
}
}
return present, missing, nil
return present, missing, nil, nil
}
return nil, nil, fmt.Errorf("read %s: %w", dir, err)
return nil, nil, nil, fmt.Errorf("read %s: %w", dir, err)
}
have := map[string]bool{}
// The name on disk may differ in case from the manifest's.
have := map[string]string{}
for _, e := range entries {
if !e.IsDir() {
have[strings.ToUpper(e.Name())] = true
have[strings.ToUpper(e.Name())] = e.Name()
}
}
var missing []string
for _, f := range RuntimeFiles {
ok := have[strings.ToUpper(f.Name)]
actual, ok := have[strings.ToUpper(f.Name)]
present[f.Name] = ok
if !ok && f.Required {
missing = append(missing, f.Name)
if !ok {
if f.Required {
missing = append(missing, f.Name)
}
continue
}
if f.SHA256 == "" {
continue
}
sum, err := fileSHA256(filepath.Join(dir, actual))
if err != nil {
// Unreadable is not the same as wrong, and saying it is would send
// a user replacing a file that may be fine.
continue
}
if !strings.EqualFold(sum, f.SHA256) {
wrong = append(wrong, f.Name)
}
}
return present, missing, wrong, nil
}

// fileSHA256 hashes a runtime file. The whole runtime is a few megabytes, so
// this is cheap enough to do on every readiness check.
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return present, missing, nil
return hex.EncodeToString(h.Sum(nil)), nil
}

// VCDName builds the filename an installed disc gets inside __.POPS.
Expand Down
Loading