Files
2025-04-22 13:47:00 +02:00

399 lines
9.0 KiB
Go

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]
}