Initial commit

This commit is contained in:
2025-04-22 13:32:25 +02:00
parent eb66c60286
commit 43292ead82
4 changed files with 562 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
# nca-to-nsp
A utility to package NCA files into a NSP using the PFS0 format.
This tool works by:
1. Reading the specified NCA files
2. Creating a PFS0 header
3. Combining the header with the NCA files into a single NSP file
## Installation
### Prerequisites
- Go 1.24.2 or higher
### Building from source
```bash
git clone https://github.com/yourusername/nca-to-nsp.git
cd nca-to-nsp
go build -o nca-to-nsp ./cmd/nca-to-nsp
```
## Usage
Basic usage:
```bash
nca-to-nsp -o <output-file.nsp> file1.nca [file2.nca ...]
```
### Command-line Options
| Flag | Default | Description |
|------|---------|-------------|
| `-o` | `out.nsp` | NSP output file name |
| `-buffer` | `4096` | Buffer size for file copying operations |
| `-progress` | `false` | Show progress bar |
| `-h` | `false` | Display help information |
| `-v` | `false` | Display version information |
### Example
Create an NSP file from NCA files with progress bar enabled:
```bash
./nca-to-nsp -o out.nsp -progress path/to/dir/*.nca
```
## License
BSD-3-Clause, see [LICENSE](LICENSE) for more information.
## Acknowledgments
- [nspBuild](https://github.com/CVFireDragon/nspBuild) for inspiration on how to pack NCAs into NSP.
+106
View File
@@ -0,0 +1,106 @@
package main
import (
"flag"
"fmt"
"nca-to-nsp/pkg/nsp"
"os"
)
// Application information
const (
appName = "nca-to-nsp"
appVersion = "1.0.0"
appDescription = "A utility to package Nintendo Content Archive (NCA) " +
"files into a Nintendo Submission Package (NSP)."
)
// exitWithError prints an error message to stderr and exits the program with
// code 1.
// Accepts either a single value or a format string with arguments.
func exitWithError(arg any, args ...any) {
var msg string
if len(args) == 0 {
msg = fmt.Sprintf("%v", arg)
} else {
msg = fmt.Sprintf(arg.(string), args...)
}
fmt.Fprintf(os.Stderr, "Error: %s\n", msg)
os.Exit(1)
}
// printDescription displays application description to stdout
func printDescription() {
fmt.Printf("%s v%s - %s\n\n", appName, appVersion, appDescription)
}
// Display the application usage information and exits.
func printUsage() {
fmt.Println("Usage:")
fmt.Printf(
" %s -o <output.nsp> [options] file1.nca [file2.nca ...]\n\n",
os.Args[0],
)
fmt.Println("Options:")
flag.PrintDefaults()
}
// printVersion displays the application version information to stdout
func printVersion() {
fmt.Printf("%s v%s\n", appName, appVersion)
}
func main() {
helpFlag := flag.Bool("h", false, "Display help information")
versionFlag := flag.Bool("v", false, "Display version information")
outputName := flag.String("o", "out.nsp", "NSP output file name")
bufferSize := flag.Int(
"buffer",
nsp.DefaultBufferSize,
"Buffer size for file copying operations",
)
showProgress := flag.Bool(
"progress",
false,
"Show progress bar during NSP creation",
)
flag.Parse()
if *helpFlag {
printDescription()
printUsage()
os.Exit(0)
}
if *versionFlag {
printVersion()
os.Exit(0)
}
ncaFiles := flag.Args()
if len(ncaFiles) == 0 {
fmt.Fprintln(os.Stderr, "Error: no NCA files specified")
printUsage()
os.Exit(1)
}
builder := nsp.Builder{
OutputPath: *outputName,
BufferSize: *bufferSize,
ShowProgress: *showProgress,
ProgressUpdateFrequency: 100,
}
if err := builder.AddFiles(ncaFiles); err != nil {
exitWithError("Failed to add files to NSP builder: %v", err)
}
if err := builder.Build(); err != nil {
exitWithError("Failed to build NSP file: %v", err)
}
fmt.Printf("Successfully built NSP file: %s\n", *outputName)
}
+3
View File
@@ -0,0 +1,3 @@
module nca-to-nsp
go 1.24.2
+398
View File
@@ -0,0 +1,398 @@
package nsp
import (
"encoding/binary"
"fmt"
"io"
"os"
"sort"
"strings"
"time"
)
const (
// Magic number that identifies the PFS0 file format
PFS0Magic = "PFS0"
// Default buffer size (in bytes) for file I/O operations
DefaultBufferSize = 4096
)
// Builder provides functionality for creating Nintendo Submission Package (NSP)
// files. The Builder collects input files and handles the creation of a
// properly formatted NSP.
type Builder struct {
// Path where the NSP file will be created
OutputPath string
// Controls the buffer size used for file I/O operations.
// Larger values may improve performance at the cost of memory usage.
BufferSize int
// Determines whether to display progress indicators
ShowProgress bool
// Progress update frequency in milliseconds
ProgressUpdateFrequency int
// Track last output length for clean line clearing
lastProgressWidth int
// The collection of files to be included in the NSP
partEntries []partitionEntry
}
// partitionEntry contains metadata about a file to be included in the NSP
type partitionEntry struct {
// Filesystem path to the source file
path string
// Filename to be stored in the NSP
name string
// Size of the file in bytes
size uint64
// Absolute position of file data in the NSP
dataOffset uint64
// Offset of this file's name in the string table
stringOffset uint32
}
// AddFile adds a file to be included in the NSP
func (b *Builder) AddFile(path string) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("failed to add file %s: %w", path, err)
}
b.partEntries = append(b.partEntries, partitionEntry{
path: path,
name: info.Name(),
size: uint64(info.Size()),
})
return nil
}
// AddFiles adds multiple files to be included in the NSP.
func (b *Builder) AddFiles(paths []string) error {
for _, path := range paths {
if err := b.AddFile(path); err != nil {
return err
}
}
return nil
}
// Build creates the NSP file with all the added files.
func (b *Builder) Build() error {
if len(b.partEntries) == 0 {
return fmt.Errorf("no input files provided")
}
sort.Slice(b.partEntries, func(i, j int) bool {
return b.partEntries[i].name < b.partEntries[j].name
})
header := b.generateHeader()
outFile, err := os.Create(b.OutputPath)
if err != nil {
return fmt.Errorf(
"failed to create output file %s: %w",
b.OutputPath,
err,
)
}
defer outFile.Close()
bytesWritten, err := outFile.Write(header)
if err != nil {
return fmt.Errorf("failed to write header: %w", err)
} else if bytesWritten != len(header) {
return fmt.Errorf(
"size mismatch for file %s during write: "+
"expected %d bytes, wrote %d bytes",
b.OutputPath,
len(header),
bytesWritten,
)
}
var totalSize uint64
for _, file := range b.partEntries {
totalSize += file.size
}
// Initialize progress tracking
var processedSize uint64
buffer := make([]byte, b.BufferSize)
if b.ShowProgress {
fmt.Printf("Building NSP: %s\n", b.OutputPath)
}
for i, file := range b.partEntries {
if b.ShowProgress {
fmt.Printf(
"Processing (%d/%d): %s\n",
i+1,
len(b.partEntries),
file.name,
)
}
err := b.copyFileToNSP(outFile, file, buffer, &processedSize, totalSize)
if b.ShowProgress {
fmt.Print("\r" + strings.Repeat(" ", 80) + "\r")
}
if err != nil {
return err
}
}
return nil
}
// generateHeader creates the PFS0 header for the NSP file.
// The header consists of:
// 0x0-0x4: Magic ("PFS0")
// 0x4-0x8: EntryCount (number of files)
// 0x8-0xC: StringTableSize (including padding)
// 0xC-0x10: Reserved (zeros)
// 0x10-X: PartitionEntryTable (24 bytes per file)
// X-Y: StringTable with null-terminated filenames
func (b *Builder) generateHeader() []byte {
stringTableSize := 0
for _, file := range b.partEntries {
stringTableSize += len(file.name) + 1 // +1 for null terminator
}
// Magic(4) + EntryCount(4) + StringTableSize(4) + Reserved(4)
headerMetadataSize := 0x10
entryCount := len(b.partEntries)
partitionTableSize := entryCount * 0x18 // 24 bytes per entry
headerSize := headerMetadataSize + partitionTableSize + stringTableSize
// Align to 16 bytes if needed
padding := 0
if remainder := headerSize % 0x10; remainder > 0 {
padding = 0x10 - remainder
headerSize += padding
}
header := make([]byte, headerSize)
// Write magic "PFS0" at 0x0
copy(header[0:], PFS0Magic)
// Write EntryCount at 0x4
binary.LittleEndian.PutUint32(header[4:], uint32(entryCount))
// Write StringTableSize at 0x8 (including padding)
binary.LittleEndian.PutUint32(
header[8:],
uint32(stringTableSize+padding),
)
// Reserved field at 0xC (4 bytes of zeros)
binary.LittleEndian.PutUint32(header[12:], 0)
// Write PartitionEntryTable starting at 0x10
headerPosition := 0x10
stringOffset := uint32(0)
fileDataOffset := uint64(0)
// Calculate the base offset for file data (after header)
fileDataBaseOffset := uint64(headerSize)
for i := range b.partEntries {
file := &b.partEntries[i]
// File data offset (relative to start of file data section)
binary.LittleEndian.PutUint64(
header[headerPosition:],
fileDataOffset,
)
// Store absolute position for later use
file.dataOffset = fileDataBaseOffset + fileDataOffset
fileDataOffset += file.size
headerPosition += 8
// File size
binary.LittleEndian.PutUint64(
header[headerPosition:],
file.size,
)
headerPosition += 8
// StringOffset (position in string table)
binary.LittleEndian.PutUint32(
header[headerPosition:],
stringOffset,
)
file.stringOffset = stringOffset
stringOffset += uint32(
len(file.name) + 1, // +1 for null terminator
)
headerPosition += 4
// Reserved field (4 bytes of zeros)
binary.LittleEndian.PutUint32(header[headerPosition:], 0)
headerPosition += 4
}
// Write string table (filenames with null terminators)
stringTableOffset := headerMetadataSize + partitionTableSize
for _, entry := range b.partEntries {
nameOffset := stringTableOffset + int(entry.stringOffset)
copy(header[nameOffset:], entry.name)
// The buffer is already filled with zeros, so null terminators are
// implicit
}
return header
}
// copyFileToNSP copies a file to the NSP output at the position indicated by
// its dataOffset.
func (b *Builder) copyFileToNSP(
outFile *os.File,
fileInfo partitionEntry,
buffer []byte,
processedSize *uint64,
totalSize uint64,
) error {
inFile, err := os.Open(fileInfo.path)
if err != nil {
return fmt.Errorf(
"failed to open input file %s: %w",
fileInfo.path,
err,
)
}
defer inFile.Close()
// Seek to the correct position in the output file
if _, err := outFile.Seek(int64(fileInfo.dataOffset), io.SeekStart); err != nil {
return fmt.Errorf(
"failed to seek in output file %s: %w",
fileInfo.path,
err,
)
}
bytesWritten := uint64(0)
lastUpdateTime := time.Now()
total := float64(0)
totalUnit := ""
if b.ShowProgress {
total, totalUnit = formatSize(totalSize)
}
// Copy file data in chunks
for {
n, err := inFile.Read(buffer)
if err != nil && err != io.EOF {
return fmt.Errorf(
"error reading input file %s: %w",
fileInfo.path,
err,
)
}
if n == 0 {
break
}
if _, err := outFile.Write(buffer[:n]); err != nil {
return fmt.Errorf(
"error writing to output file %s: %w",
b.OutputPath,
err,
)
}
bytesWritten += uint64(n)
*processedSize += uint64(n)
if b.ShowProgress &&
time.Since(lastUpdateTime).
Milliseconds() >=
int64(
b.ProgressUpdateFrequency,
) {
b.drawProgressBar(*processedSize, totalSize, total, totalUnit, 50)
lastUpdateTime = time.Now()
}
}
if bytesWritten != fileInfo.size {
return fmt.Errorf(
"size mismatch for file %s during write: expected %d bytes, wrote %d bytes",
fileInfo.path,
fileInfo.size,
bytesWritten,
)
}
return nil
}
// clearLine clears the current line in the terminal
func (b *Builder) clearLine() {
fmt.Print("\r" + strings.Repeat(" ", b.lastProgressWidth) + "\r")
b.lastProgressWidth = 0
}
// drawProgressBar displays a progress bar showing the current copying progress
func (b *Builder) drawProgressBar(
currentSize uint64,
totalSize uint64,
total float64,
totalUnit string,
width int,
) {
if !b.ShowProgress {
return
}
if totalSize == 0 {
totalSize = 1 // Avoid division by zero
}
percent := float64(currentSize) / float64(totalSize)
filled := min(int(percent*float64(width)), width)
bar := strings.Repeat("=", filled) + strings.Repeat(" ", width-filled)
current, currentUnit := formatSize(currentSize)
progressString := fmt.Sprintf("\r[%s] %5.1f%% (%3.2f %s/%3.2f %s)",
bar,
percent*100,
current,
currentUnit,
total,
totalUnit,
)
b.clearLine()
fmt.Print(progressString)
b.lastProgressWidth = len(progressString)
}
// formatSize converts a byte size to a human-readable format
func formatSize(bytes uint64) (float64, string) {
units := []string{"B", "KB", "MB", "GB", "TB"}
size := float64(bytes)
unitIndex := 0
for size >= 1000 && unitIndex < len(units)-1 {
size /= 1000
unitIndex++
}
return size, units[unitIndex]
}