Initial commit
This commit is contained in:
+398
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user