From 1a9bb6535d2ecf8c8bcfa4d64a0180595c6df9e3 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Sat, 5 Sep 2026 10:34:01 -0500 Subject: [PATCH] fix: match a cuesheet's filename however it is capitalised Three Final Fantasy VII discs failed to install with invalid cuesheet: Final Fantasy VII Disc 1.cue references FINAL FANTASY VII DISC 1.BIN, which is missing against a file the user could plainly see was there. The rip is from 2003 and its sheet says FILE "FINAL FANTASY VII DISC 1.BIN" BINARY beside a file called "Final Fantasy VII Disc 1.bin". Cuesheets are routinely written on Windows, where the case of a filename is not information, so this rip works everywhere except a case-sensitive filesystem. A FILE reference now falls back to a case-insensitive match in the sheet's own directory. The exact name always wins and the directory is only read when the exact name does not resolve, so nothing is slower and no ambiguity is introduced where the filesystem itself has none. A name that matches nothing is returned unchanged, so the error still quotes what the sheet actually said. BinName keeps the name as written, because that is what an error should quote and what an archive's own listing agrees with; only the resolved path changes. This also fixes a quieter symptom. The scanner excludes the tracks a cuesheet names so that one rip is one title, and that exclusion is by path -- so a sheet whose case did not match left its .bin unexcluded and listed as a title of its own, beside the game it belongs to. Only the on-disk path was affected. Inside an archive the data track is chosen from the listing rather than by name, which is why these titles scanned correctly and then failed at conversion. Claude-Session: https://claude.ai/code/session_018eBAB3V9GRxNwgpQcpGje4 --- internal/platform/ps1/cue.go | 55 +++++++++++++++++- internal/platform/ps1/cue_test.go | 94 +++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 2 deletions(-) diff --git a/internal/platform/ps1/cue.go b/internal/platform/ps1/cue.go index 7729661..a75e1ba 100644 --- a/internal/platform/ps1/cue.go +++ b/internal/platform/ps1/cue.go @@ -131,15 +131,66 @@ func ParseCueFile(path string) (Cue, error) { } c.Path = path dir := filepath.Dir(path) + resolve := fileResolver(dir) for _, n := range c.Files { - c.FilePaths = append(c.FilePaths, filepath.Join(dir, n)) + c.FilePaths = append(c.FilePaths, resolve(n)) } if c.BinName != "" { - c.BinPath = filepath.Join(dir, c.BinName) + c.BinPath = resolve(c.BinName) } return c, nil } +// fileResolver returns a function that turns a FILE name from a cuesheet into +// a path in dir, matching case-insensitively when it has to. +// +// Cuesheets are routinely written on Windows, where the case of a filename is +// not information. A sheet from 2003 says +// +// FILE "FINAL FANTASY VII DISC 1.BIN" BINARY +// +// beside a file actually called "Final Fantasy VII Disc 1.bin". That rip is +// perfectly good and works everywhere except a case-sensitive filesystem, +// where it failed with "references FINAL FANTASY VII DISC 1.BIN, which is +// missing" -- a file the user can plainly see is there. +// +// The exact name always wins, and the directory is only read when it does not +// resolve, so nothing is slower and no ambiguity is introduced where the +// filesystem itself has none. A name that matches nothing is returned +// unchanged, so the error still quotes what the sheet actually said. +func fileResolver(dir string) func(string) string { + var lower map[string]string // lower-cased name -> real name, read once + return func(name string) string { + exact := filepath.Join(dir, name) + if _, err := os.Stat(exact); err == nil { + return exact + } + if lower == nil { + lower = map[string]string{} + entries, err := os.ReadDir(dir) + if err != nil { + return exact + } + for _, e := range entries { + if !e.IsDir() { + // First match wins. Two files differing only in case is + // possible here and there is no way to tell which the + // sheet meant, so the one the directory lists first is + // used rather than guessing. + k := strings.ToLower(e.Name()) + if _, seen := lower[k]; !seen { + lower[k] = e.Name() + } + } + } + } + if real, ok := lower[strings.ToLower(name)]; ok { + return filepath.Join(dir, real) + } + return exact + } +} + // ParseCue parses cuesheet text. // // Only the subset that matters for PS1 images is understood: FILE, TRACK, diff --git a/internal/platform/ps1/cue_test.go b/internal/platform/ps1/cue_test.go index 0af4342..0c97d5c 100644 --- a/internal/platform/ps1/cue_test.go +++ b/internal/platform/ps1/cue_test.go @@ -3,6 +3,7 @@ package ps1_test import ( "errors" "fmt" + "github.com/casmith/ps2hdd/internal/iso9660/isosynth" "os" "path/filepath" "strings" @@ -404,3 +405,96 @@ func TestGapSectorsMatchesTheConversion(t *testing.T) { t.Errorf("a CDRWIN sheet reported %d gap sectors, want 150", got) } } + +// Cuesheets are routinely written on Windows, where the case of a filename is +// not information. This one is from a 2003 rip that works everywhere except a +// case-sensitive filesystem: +// +// FILE "FINAL FANTASY VII DISC 1.BIN" BINARY +// +// beside a file called "Final Fantasy VII Disc 1.bin". +func TestParseCueFileMatchesTheFilenameCaseInsensitively(t *testing.T) { + dir := t.TempDir() + img, err := isosynth.BuildMode2352(isosynth.Image{ + VolumeID: "SCUS_941.63", + CDXA: true, + Files: map[string][]byte{"SYSTEM.CNF": isosynth.PS1SystemCNF("SCUS_941.63")}, + }) + if err != nil { + t.Fatal(err) + } + const real = "Final Fantasy VII Disc 1.bin" + if err := os.WriteFile(filepath.Join(dir, real), img, 0o600); err != nil { + t.Fatal(err) + } + cue := filepath.Join(dir, "Final Fantasy VII Disc 1.cue") + if err := os.WriteFile(cue, []byte( + "FILE \"FINAL FANTASY VII DISC 1.BIN\" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n"), 0o600); err != nil { + t.Fatal(err) + } + + c, err := ps1.ParseCueFile(cue) + if err != nil { + t.Fatalf("ParseCueFile: %v", err) + } + if filepath.Base(c.BinPath) != real { + t.Errorf("BinPath = %q, want the file that is actually there (%q)", c.BinPath, real) + } + if len(c.FilePaths) != 1 || filepath.Base(c.FilePaths[0]) != real { + t.Errorf("FilePaths = %v, want [%q]", c.FilePaths, real) + } + // BinName keeps what the sheet said: it is what an error message should + // quote, and what the archive's own listing will agree with. + if c.BinName != "FINAL FANTASY VII DISC 1.BIN" { + t.Errorf("BinName = %q, want the name as written in the sheet", c.BinName) + } + if err := c.Validate(); err != nil { + t.Errorf("a rip whose only fault is the case of a filename was rejected: %v", err) + } +} + +// An exact match is always preferred, so a directory holding two files that +// differ only in case still resolves to the one the sheet names. +func TestParseCueFilePrefersTheExactFilename(t *testing.T) { + dir := t.TempDir() + sectors := make([]byte, 2352*4) + for _, n := range []string{"game.bin", "GAME.BIN"} { + if err := os.WriteFile(filepath.Join(dir, n), sectors, 0o600); err != nil { + t.Skipf("this filesystem cannot hold two names differing only in case: %v", err) + } + } + cue := filepath.Join(dir, "game.cue") + if err := os.WriteFile(cue, []byte( + "FILE \"GAME.BIN\" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n"), 0o600); err != nil { + t.Fatal(err) + } + c, err := ps1.ParseCueFile(cue) + if err != nil { + t.Fatal(err) + } + if filepath.Base(c.BinPath) != "GAME.BIN" { + t.Errorf("BinPath = %q, want the exactly named GAME.BIN", c.BinPath) + } +} + +// A file that is genuinely absent must still be reported, quoting the name the +// sheet used rather than something invented while looking for it. +func TestParseCueFileStillReportsAMissingTrack(t *testing.T) { + dir := t.TempDir() + cue := filepath.Join(dir, "game.cue") + if err := os.WriteFile(cue, []byte( + "FILE \"absent.bin\" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n"), 0o600); err != nil { + t.Fatal(err) + } + c, err := ps1.ParseCueFile(cue) + if err != nil { + t.Fatal(err) + } + err = c.Validate() + if err == nil { + t.Fatal("a cuesheet naming a file that does not exist was accepted") + } + if !strings.Contains(err.Error(), "absent.bin") { + t.Errorf("the error does not name the missing file: %v", err) + } +}