From 43292ead8292b6f67a6abe7fe21131dc510e431c Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Tue, 22 Apr 2025 13:32:25 +0200 Subject: [PATCH] Initial commit --- README.md | 55 ++++++ cmd/nca-to-nsp/main.go | 106 +++++++++++ go.mod | 3 + pkg/nsp/nsp.go | 398 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 562 insertions(+) create mode 100644 README.md create mode 100644 cmd/nca-to-nsp/main.go create mode 100644 go.mod create mode 100644 pkg/nsp/nsp.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b2514f --- /dev/null +++ b/README.md @@ -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 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. diff --git a/cmd/nca-to-nsp/main.go b/cmd/nca-to-nsp/main.go new file mode 100644 index 0000000..fc7a266 --- /dev/null +++ b/cmd/nca-to-nsp/main.go @@ -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 [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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..09a6d9a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module nca-to-nsp + +go 1.24.2 diff --git a/pkg/nsp/nsp.go b/pkg/nsp/nsp.go new file mode 100644 index 0000000..4f8103e --- /dev/null +++ b/pkg/nsp/nsp.go @@ -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] +}