Skip to content

Commit 3e25eba

Browse files
authored
Merge pull request #3 from ZaparooProject/feature/archive-support
feat: add archive support for ZIP, 7z, and RAR formats
2 parents 0a28cb3 + 83de2e0 commit 3e25eba

24 files changed

+2486
-1
lines changed

AGENTS.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ go-gameid/
3535
├── gameid.go # Main API: Identify(), IdentifyWithConsole(), DetectConsole()
3636
├── console.go # Console detection from file extensions/headers
3737
├── database.go # GameDatabase for metadata lookup (gob.gz format)
38+
├── archive/ # Archive support (ZIP, 7z, RAR)
39+
│ ├── archive.go # Archive interface and factory
40+
│ ├── zip.go # ZIP implementation
41+
│ ├── sevenzip.go # 7z implementation
42+
│ ├── rar.go # RAR implementation
43+
│ ├── path.go # MiSTer-style path parsing
44+
│ ├── detect.go # Game file detection
45+
│ └── errors.go # Error types
3846
├── identifier/ # Console-specific identification logic
3947
│ ├── identifier.go # Identifier interface, Result type, Console constants
4048
│ ├── gb.go # Game Boy / Game Boy Color
@@ -256,6 +264,41 @@ if gameid.IsDiscBased(console) {
256264
}
257265
```
258266

267+
### Identify game from archive
268+
269+
The library supports MiSTer-style archive paths for cartridge-based games:
270+
271+
```go
272+
// Explicit path inside archive
273+
result, err := gameid.Identify("/games/roms.zip/gba/game.gba", nil)
274+
275+
// Auto-detect game file in archive
276+
result, err := gameid.Identify("/games/roms.7z", nil)
277+
278+
// Also works with RAR
279+
result, err := gameid.Identify("/games/collection.rar/game.nes", nil)
280+
```
281+
282+
### Work with archives directly
283+
284+
```go
285+
import "github.com/ZaparooProject/go-gameid/archive"
286+
287+
// Parse MiSTer-style path
288+
path, err := archive.ParsePath("/games/roms.zip/game.gba")
289+
// path.ArchivePath = "/games/roms.zip"
290+
// path.InternalPath = "game.gba"
291+
292+
// Open and list archive contents
293+
arc, err := archive.Open("/games/roms.zip")
294+
defer arc.Close()
295+
files, err := arc.List()
296+
297+
// Read file from archive
298+
reader, size, err := arc.Open("game.gba")
299+
defer reader.Close()
300+
```
301+
259302
## Platform-Specific Code
260303

261304
Block device detection has platform-specific implementations:
@@ -264,7 +307,9 @@ Block device detection has platform-specific implementations:
264307

265308
## Dependencies
266309

267-
The project has zero external dependencies (stdlib only).
310+
Production dependencies:
311+
- `github.com/bodgit/sevenzip` - 7z archive support (BSD-3-Clause)
312+
- `github.com/nwaples/rardecode/v2` - RAR archive support (BSD-2-Clause)
268313

269314
## Debugging Tips
270315

@@ -282,3 +327,5 @@ The project has zero external dependencies (stdlib only).
282327
- GBC uses the same identifier as GB (header format is identical)
283328
- Some disc formats (.bin, .iso, .cue) are ambiguous - detection relies on header magic and filesystem analysis
284329
- Block device support allows reading directly from physical disc drives
330+
- Archive support (ZIP, 7z, RAR) only works for cartridge-based games - disc images in archives return an error
331+
- Archive paths use MiSTer-style format: `/path/to/archive.zip/internal/path/game.gba`

archive/archive.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright (c) 2025 Niema Moshiri and The Zaparoo Project.
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
//
4+
// This file is part of go-gameid.
5+
//
6+
// go-gameid is free software: you can redistribute it and/or modify
7+
// it under the terms of the GNU General Public License as published by
8+
// the Free Software Foundation, either version 3 of the License, or
9+
// (at your option) any later version.
10+
//
11+
// go-gameid is distributed in the hope that it will be useful,
12+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
// GNU General Public License for more details.
15+
//
16+
// You should have received a copy of the GNU General Public License
17+
// along with go-gameid. If not, see <https://www.gnu.org/licenses/>.
18+
19+
// Package archive provides support for reading game files from archives.
20+
// It supports ZIP, 7z, and RAR formats.
21+
package archive
22+
23+
import (
24+
"fmt"
25+
"io"
26+
"path/filepath"
27+
"strings"
28+
)
29+
30+
// FileInfo contains information about a file in an archive.
31+
type FileInfo struct {
32+
Name string // Full path within archive
33+
Size int64 // Uncompressed size
34+
}
35+
36+
// Archive provides read access to files within an archive.
37+
type Archive interface {
38+
// List returns all files in the archive.
39+
List() ([]FileInfo, error)
40+
41+
// Open opens a file within the archive for reading.
42+
// Returns the reader, uncompressed size, and any error.
43+
Open(internalPath string) (io.ReadCloser, int64, error)
44+
45+
// OpenReaderAt opens a file and returns an io.ReaderAt interface.
46+
// The file contents are buffered in memory to support random access.
47+
// The returned Closer must be called to release resources.
48+
OpenReaderAt(internalPath string) (io.ReaderAt, int64, io.Closer, error)
49+
50+
// Close closes the archive.
51+
Close() error
52+
}
53+
54+
// Open opens an archive file based on its extension.
55+
// Supported formats: .zip, .7z, .rar
56+
func Open(path string) (Archive, error) {
57+
ext := strings.ToLower(filepath.Ext(path))
58+
59+
switch ext {
60+
case ".zip":
61+
return OpenZIP(path)
62+
case ".7z":
63+
return OpenSevenZip(path)
64+
case ".rar":
65+
return OpenRAR(path)
66+
default:
67+
return nil, FormatError{Format: ext}
68+
}
69+
}
70+
71+
// IsArchiveExtension checks if an extension is a supported archive format.
72+
func IsArchiveExtension(ext string) bool {
73+
ext = strings.ToLower(ext)
74+
switch ext {
75+
case ".zip", ".7z", ".rar":
76+
return true
77+
default:
78+
return false
79+
}
80+
}
81+
82+
// nopCloser wraps a value that doesn't need closing.
83+
type nopCloser struct{}
84+
85+
func (nopCloser) Close() error { return nil }
86+
87+
// bufferFile reads the entire file into memory and returns a ReaderAt.
88+
//
89+
//nolint:revive // 4 return values is necessary for this interface pattern
90+
func bufferFile(arc Archive, internalPath string) (io.ReaderAt, int64, io.Closer, error) {
91+
reader, size, err := arc.Open(internalPath)
92+
if err != nil {
93+
return nil, 0, nil, fmt.Errorf("open file in archive: %w", err)
94+
}
95+
defer func() { _ = reader.Close() }()
96+
97+
data := make([]byte, size)
98+
bytesRead, err := io.ReadFull(reader, data)
99+
if err != nil {
100+
return nil, 0, nil, fmt.Errorf("read file from archive: %w", err)
101+
}
102+
103+
return &byteReaderAt{data: data}, int64(bytesRead), nopCloser{}, nil
104+
}
105+
106+
// byteReaderAt implements io.ReaderAt for a byte slice.
107+
type byteReaderAt struct {
108+
data []byte
109+
}
110+
111+
func (br *byteReaderAt) ReadAt(buf []byte, off int64) (int, error) {
112+
if off < 0 {
113+
return 0, fmt.Errorf("negative offset: %d", off)
114+
}
115+
if off >= int64(len(br.data)) {
116+
return 0, io.EOF
117+
}
118+
119+
bytesRead := copy(buf, br.data[off:])
120+
if bytesRead < len(buf) {
121+
return bytesRead, io.EOF
122+
}
123+
return bytesRead, nil
124+
}

0 commit comments

Comments
 (0)