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
49 changes: 48 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ go-gameid/
├── gameid.go # Main API: Identify(), IdentifyWithConsole(), DetectConsole()
├── console.go # Console detection from file extensions/headers
├── database.go # GameDatabase for metadata lookup (gob.gz format)
├── archive/ # Archive support (ZIP, 7z, RAR)
│ ├── archive.go # Archive interface and factory
│ ├── zip.go # ZIP implementation
│ ├── sevenzip.go # 7z implementation
│ ├── rar.go # RAR implementation
│ ├── path.go # MiSTer-style path parsing
│ ├── detect.go # Game file detection
│ └── errors.go # Error types
├── identifier/ # Console-specific identification logic
│ ├── identifier.go # Identifier interface, Result type, Console constants
│ ├── gb.go # Game Boy / Game Boy Color
Expand Down Expand Up @@ -256,6 +264,41 @@ if gameid.IsDiscBased(console) {
}
```

### Identify game from archive

The library supports MiSTer-style archive paths for cartridge-based games:

```go
// Explicit path inside archive
result, err := gameid.Identify("/games/roms.zip/gba/game.gba", nil)

// Auto-detect game file in archive
result, err := gameid.Identify("/games/roms.7z", nil)

// Also works with RAR
result, err := gameid.Identify("/games/collection.rar/game.nes", nil)
```

### Work with archives directly

```go
import "github.com/ZaparooProject/go-gameid/archive"

// Parse MiSTer-style path
path, err := archive.ParsePath("/games/roms.zip/game.gba")
// path.ArchivePath = "/games/roms.zip"
// path.InternalPath = "game.gba"

// Open and list archive contents
arc, err := archive.Open("/games/roms.zip")
defer arc.Close()
files, err := arc.List()

// Read file from archive
reader, size, err := arc.Open("game.gba")
defer reader.Close()
```

## Platform-Specific Code

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

## Dependencies

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

## Debugging Tips

Expand All @@ -282,3 +327,5 @@ The project has zero external dependencies (stdlib only).
- GBC uses the same identifier as GB (header format is identical)
- Some disc formats (.bin, .iso, .cue) are ambiguous - detection relies on header magic and filesystem analysis
- Block device support allows reading directly from physical disc drives
- Archive support (ZIP, 7z, RAR) only works for cartridge-based games - disc images in archives return an error
- Archive paths use MiSTer-style format: `/path/to/archive.zip/internal/path/game.gba`
124 changes: 124 additions & 0 deletions archive/archive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Copyright (c) 2025 Niema Moshiri and The Zaparoo Project.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This file is part of go-gameid.
//
// go-gameid is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// go-gameid is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with go-gameid. If not, see <https://www.gnu.org/licenses/>.

// Package archive provides support for reading game files from archives.
// It supports ZIP, 7z, and RAR formats.
package archive

import (
"fmt"
"io"
"path/filepath"
"strings"
)

// FileInfo contains information about a file in an archive.
type FileInfo struct {
Name string // Full path within archive
Size int64 // Uncompressed size
}

// Archive provides read access to files within an archive.
type Archive interface {
// List returns all files in the archive.
List() ([]FileInfo, error)

// Open opens a file within the archive for reading.
// Returns the reader, uncompressed size, and any error.
Open(internalPath string) (io.ReadCloser, int64, error)

// OpenReaderAt opens a file and returns an io.ReaderAt interface.
// The file contents are buffered in memory to support random access.
// The returned Closer must be called to release resources.
OpenReaderAt(internalPath string) (io.ReaderAt, int64, io.Closer, error)

// Close closes the archive.
Close() error
}

// Open opens an archive file based on its extension.
// Supported formats: .zip, .7z, .rar
func Open(path string) (Archive, error) {
ext := strings.ToLower(filepath.Ext(path))

switch ext {
case ".zip":
return OpenZIP(path)
case ".7z":
return OpenSevenZip(path)
case ".rar":
return OpenRAR(path)
default:
return nil, FormatError{Format: ext}
}
}

// IsArchiveExtension checks if an extension is a supported archive format.
func IsArchiveExtension(ext string) bool {
ext = strings.ToLower(ext)
switch ext {
case ".zip", ".7z", ".rar":
return true
default:
return false
}
}

// nopCloser wraps a value that doesn't need closing.
type nopCloser struct{}

func (nopCloser) Close() error { return nil }

// bufferFile reads the entire file into memory and returns a ReaderAt.
//
//nolint:revive // 4 return values is necessary for this interface pattern
func bufferFile(arc Archive, internalPath string) (io.ReaderAt, int64, io.Closer, error) {
reader, size, err := arc.Open(internalPath)
if err != nil {
return nil, 0, nil, fmt.Errorf("open file in archive: %w", err)
}
defer func() { _ = reader.Close() }()

data := make([]byte, size)
bytesRead, err := io.ReadFull(reader, data)
if err != nil {
return nil, 0, nil, fmt.Errorf("read file from archive: %w", err)
}

return &byteReaderAt{data: data}, int64(bytesRead), nopCloser{}, nil
}

// byteReaderAt implements io.ReaderAt for a byte slice.
type byteReaderAt struct {
data []byte
}

func (br *byteReaderAt) ReadAt(buf []byte, off int64) (int, error) {
if off < 0 {
return 0, fmt.Errorf("negative offset: %d", off)
}
if off >= int64(len(br.data)) {
return 0, io.EOF
}

bytesRead := copy(buf, br.data[off:])
if bytesRead < len(buf) {
return bytesRead, io.EOF
}
return bytesRead, nil
}
Loading