|
#!/usr/bin/env node |
|
|
|
const fs = require('fs').promises; |
|
const path = require('path'); |
|
const os = require('os'); |
|
const { TextEncoder, TextDecoder } = require('util'); |
|
|
|
const colors = { |
|
reset: '\x1b[0m', |
|
bright: '\x1b[1m', |
|
dim: '\x1b[2m', |
|
red: '\x1b[31m', |
|
green: '\x1b[32m', |
|
yellow: '\x1b[33m', |
|
blue: '\x1b[34m', |
|
magenta: '\x1b[35m', |
|
cyan: '\x1b[36m', |
|
white: '\x1b[37m', |
|
gray: '\x1b[90m' |
|
}; |
|
|
|
const log = { |
|
info: msg => console.log(`${colors.blue}[INFO]${colors.reset} ${msg}`), |
|
success: msg => console.log(`${colors.green}[SUCCESS]${colors.reset} ${msg}`), |
|
warn: msg => console.log(`${colors.yellow}[WARNING]${colors.reset} ${msg}`), |
|
error: msg => console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`), |
|
status: msg => console.log(`${colors.cyan}[STATUS]${colors.reset} ${msg}`), |
|
progress: msg => console.log(`${colors.magenta}[PROGRESS]${colors.reset} ${msg}`), |
|
|
|
section: title => console.log(`\n${colors.bright}${colors.cyan}=== ${title.toUpperCase()} ===${colors.reset}`), |
|
line: () => console.log(`${colors.gray}----------------------------------------${colors.reset}`), |
|
path: msg => console.log(`${colors.dim} ${msg}${colors.reset}`), |
|
highlight: msg => console.log(`${colors.bright}${colors.white}${msg}${colors.reset}`) |
|
}; |
|
|
|
// thx @bloodorca <https://github.com/bloodorca/hollow/blob/master/src/functions.js#L4-L105> |
|
const aes = require('aes-js'); |
|
const ENCRYPTION_KEY = 'UKu52ePUBwetZ9wNX88o54dnfKRu0T1l'; |
|
const SAVE_HEADER = [0, 1, 0, 0, 0, 255, 255, 255, 255, 1, 0, 0, 0, 0, 0, 0, 0, 6, 1, 0, 0, 0]; |
|
|
|
const gameKey = new TextEncoder().encode(ENCRYPTION_KEY); |
|
const cipher = new aes.ModeOfOperation.ecb(gameKey); |
|
|
|
function encrypt(data) { |
|
const padding = 16 - data.length % 16; |
|
const padded = new Uint8Array(data.length + padding); |
|
padded.fill(padding); |
|
padded.set(data); |
|
return cipher.encrypt(padded); |
|
} |
|
|
|
function decrypt(data) { |
|
const decrypted = cipher.decrypt(data); |
|
const padding = decrypted[decrypted.length - 1]; |
|
return decrypted.subarray(0, -padding); |
|
} |
|
|
|
function createLengthPrefix(length) { |
|
const bytes = []; |
|
while (length >= 0x80) { |
|
bytes.push((length & 0x7F) | 0x80); |
|
length >>>= 7; |
|
} |
|
bytes.push(length & 0x7F); |
|
return bytes; |
|
} |
|
|
|
function addSaveHeader(data) { |
|
const lengthBytes = createLengthPrefix(data.length); |
|
const result = new Uint8Array(SAVE_HEADER.length + lengthBytes.length + data.length + 1); |
|
|
|
result.set(SAVE_HEADER); |
|
result.set(lengthBytes, SAVE_HEADER.length); |
|
result.set(data, SAVE_HEADER.length + lengthBytes.length); |
|
result[result.length - 1] = 11; |
|
|
|
return result; |
|
} |
|
|
|
function removeSaveHeader(data) { |
|
let headerEnd = SAVE_HEADER.length; |
|
|
|
// skip variable length prefix |
|
for (let i = 0; i < 5; i++) { |
|
headerEnd++; |
|
if ((data[SAVE_HEADER.length + i] & 0x80) === 0) break; |
|
} |
|
|
|
return data.subarray(headerEnd, data.length - 1); |
|
} |
|
|
|
// save format conversion |
|
function pcToSwitch(pcSaveData) { |
|
const withoutHeader = removeSaveHeader(pcSaveData); |
|
const base64Data = new TextDecoder().decode(withoutHeader); |
|
const encryptedData = new Uint8Array(Buffer.from(base64Data, 'base64')); |
|
const jsonData = decrypt(encryptedData); |
|
return new TextDecoder().decode(jsonData); |
|
} |
|
|
|
function switchToPC(jsonString) { |
|
const jsonData = new TextEncoder().encode(jsonString); |
|
const encryptedData = encrypt(jsonData); |
|
const base64String = Buffer.from(encryptedData).toString('base64'); |
|
const base64Data = new TextEncoder().encode(base64String); |
|
return addSaveHeader(base64Data); |
|
} |
|
|
|
// JKSV backup handling |
|
async function createBackup(saveFile, outputDir) { |
|
await fs.mkdir(outputDir, { recursive: true }); |
|
|
|
const saveData = await fs.readFile(saveFile); |
|
const outputPath = path.join(outputDir, path.basename(saveFile)); |
|
await fs.writeFile(outputPath, saveData); |
|
} |
|
|
|
async function extractBackup(backupDir, outputDir) { |
|
await fs.mkdir(outputDir, { recursive: true }); |
|
|
|
const extractedFiles = []; |
|
const files = await fs.readdir(backupDir); |
|
|
|
for (const fileName of files) { |
|
if (fileName.includes('user') || fileName.endsWith('.dat')) { |
|
const sourcePath = path.join(backupDir, fileName); |
|
const outputPath = path.join(outputDir, fileName); |
|
await fs.copyFile(sourcePath, outputPath); |
|
extractedFiles.push(outputPath); |
|
} |
|
} |
|
|
|
return extractedFiles; |
|
} |
|
|
|
// game definitions |
|
const GAMES = { |
|
hk: { |
|
name: 'Hollow Knight', |
|
displayName: 'Hollow Knight', |
|
path: 'Hollow Knight', |
|
jksvPath: 'Hollow Knight', |
|
configSuffix: 'hk' |
|
}, |
|
silksong: { |
|
name: 'Silksong', |
|
displayName: 'Silksong', |
|
path: 'Hollow Knight Silksong', |
|
jksvPath: 'Hollow Knight Silksong', |
|
configSuffix: 'silksong' |
|
} |
|
}; |
|
|
|
// save file detection |
|
function getGamePaths(gameKey = null) { |
|
const allPaths = []; |
|
const gamesToCheck = gameKey ? [gameKey] : Object.keys(GAMES); |
|
|
|
for (const game of gamesToCheck) { |
|
const gamePaths = []; |
|
|
|
// first try: os.homedir() (most reliable) |
|
try { |
|
const homeDir = os.homedir(); |
|
if (homeDir && homeDir !== '/') { |
|
gamePaths.push(path.join(homeDir, 'AppData', 'LocalLow', 'Team Cherry', GAMES[game].path)); |
|
} |
|
} catch { } |
|
|
|
// second try: os.userInfo() (if available) |
|
try { |
|
const userInfo = os.userInfo(); |
|
if (userInfo && userInfo.username) { |
|
gamePaths.push(path.join('C:', 'Users', userInfo.username, 'AppData', 'LocalLow', 'Team Cherry', GAMES[game].path)); |
|
} |
|
} catch { } |
|
|
|
// last try: environment variables (fallback) |
|
const envUsername = process.env.USERNAME || process.env.USER; |
|
if (envUsername) { |
|
gamePaths.push(path.join('C:', 'Users', envUsername, 'AppData', 'LocalLow', 'Team Cherry', GAMES[game].path)); |
|
} |
|
|
|
// deduplicate paths for this game and add to result |
|
const uniquePaths = [...new Set(gamePaths)]; |
|
uniquePaths.forEach(gamePath => { |
|
allPaths.push({ |
|
game, |
|
path: gamePath |
|
}); |
|
}); |
|
} |
|
|
|
return allPaths; |
|
} |
|
|
|
async function scanForSaves(scanPath, results = []) { |
|
try { |
|
const files = await fs.readdir(scanPath); |
|
const saveFiles = files.filter(file => file.match(/^user[1-4]\.dat$/)); |
|
|
|
for (const file of saveFiles) { |
|
const filePath = path.join(scanPath, file); |
|
const stats = await fs.stat(filePath); |
|
const slot = parseInt(file.replace('user', '').replace('.dat', '')); |
|
|
|
results.push({ |
|
slot, |
|
file, |
|
path: filePath, |
|
modified: stats.mtime, |
|
size: stats.size, |
|
directory: scanPath |
|
}); |
|
} |
|
} catch { } |
|
} |
|
|
|
async function findSaveFiles(basePath, gameKey) { |
|
const results = []; |
|
|
|
// check base directory |
|
await scanForSaves(basePath, results); |
|
|
|
// check numbered subdirectories (user IDs) |
|
try { |
|
const items = await fs.readdir(basePath, { withFileTypes: true }); |
|
for (const item of items) { |
|
if (item.isDirectory() && item.name.match(/^\d+$/)) { |
|
const subPath = path.join(basePath, item.name); |
|
await scanForSaves(subPath, results); |
|
} |
|
} |
|
} catch { } |
|
|
|
// add game info to each result |
|
results.forEach(save => { |
|
save.game = gameKey; |
|
save.gameName = GAMES[gameKey].name; |
|
save.gameDisplayName = GAMES[gameKey].displayName; |
|
}); |
|
|
|
return results.sort((a, b) => b.modified - a.modified); |
|
} |
|
|
|
async function detectAllSaves(gameFilter = null) { |
|
const searchPaths = getGamePaths(gameFilter); |
|
const allSaves = []; |
|
const seenPaths = new Set(); |
|
|
|
for (const {game, path: searchPath} of searchPaths) { |
|
try { |
|
await fs.access(searchPath); |
|
const saves = await findSaveFiles(searchPath, game); |
|
saves.forEach(save => { |
|
// deduplicate by absolute path |
|
const normalizedPath = path.resolve(save.path).toLowerCase(); |
|
if (!seenPaths.has(normalizedPath)) { |
|
seenPaths.add(normalizedPath); |
|
save.basePath = searchPath; |
|
allSaves.push(save); |
|
} |
|
}); |
|
} catch { } |
|
} |
|
|
|
return allSaves.sort((a, b) => b.modified - a.modified); |
|
} |
|
|
|
class SaveManager { |
|
constructor(gameKey = 'silksong') { |
|
this.gameKey = gameKey; |
|
this.game = GAMES[gameKey]; |
|
this.configFile = `${gameKey}-sync.json`; |
|
this.config = null; |
|
} |
|
|
|
async loadConfig() { |
|
try { |
|
const data = await fs.readFile(this.configFile, 'utf8'); |
|
this.config = JSON.parse(data); |
|
} catch { |
|
this.config = { |
|
pcSave: '', |
|
switchSave: '', |
|
lastSync: null |
|
}; |
|
} |
|
} |
|
|
|
async saveConfig() { |
|
await fs.writeFile(this.configFile, JSON.stringify(this.config, null, 2)); |
|
} |
|
|
|
async getFileInfo(filePath) { |
|
try { |
|
const stats = await fs.stat(filePath); |
|
return { exists: true, modified: stats.mtime }; |
|
} catch { |
|
return { exists: false, modified: null }; |
|
} |
|
} |
|
|
|
async isJKSVFormat(filePath) { |
|
try { |
|
const stat = await fs.stat(filePath); |
|
return stat.isDirectory(); |
|
} catch { |
|
return false; |
|
} |
|
} |
|
|
|
async autoSetup() { |
|
log.progress(`Scanning for ${this.game.displayName} save files...`); |
|
const saves = await detectAllSaves(this.gameKey); |
|
|
|
if (saves.length === 0) { |
|
log.error(`No ${this.game.displayName} save files found`); |
|
log.info(`Make sure you have played ${this.game.displayName} and saved at least once`); |
|
return; |
|
} |
|
|
|
log.success(`Found ${saves.length} save file(s)`); |
|
log.line(); |
|
|
|
saves.forEach((save, index) => { |
|
const recent = index === 0 ? colors.green + ' (most recent)' + colors.reset : ''; |
|
log.info(`${save.gameDisplayName} - Slot ${save.slot}${recent}`); |
|
log.path(`Modified: ${save.modified.toLocaleString()}`); |
|
log.path(`Path: ${save.path}`); |
|
|
|
const saveDir = path.basename(save.directory); |
|
const baseDir = path.basename(save.basePath); |
|
if (saveDir !== baseDir) { |
|
log.path(`Directory: ${saveDir}`); |
|
} |
|
}); |
|
|
|
const selectedSave = saves[0]; |
|
const outputName = `${this.gameKey}-slot${selectedSave.slot}-backup`; |
|
|
|
const success = await this.configure(selectedSave.path, outputName); |
|
if (success !== false) { |
|
log.success(`Configured sync for ${selectedSave.gameDisplayName} slot ${selectedSave.slot}`); |
|
} |
|
} |
|
|
|
async setupSlot(slot, outputPath = null) { |
|
const slotNum = parseInt(slot); |
|
if (slotNum < 1 || slotNum > 4) { |
|
log.error('Save slot must be between 1 and 4'); |
|
return; |
|
} |
|
|
|
log.progress(`Looking for ${this.game.displayName} save slot ${slotNum}...`); |
|
const saves = await detectAllSaves(this.gameKey); |
|
const slotSaves = saves.filter(save => save.slot === slotNum); |
|
|
|
if (slotSaves.length === 0) { |
|
log.error(`No ${this.game.displayName} save found for slot ${slotNum}`); |
|
log.info('Available slots:'); |
|
saves.forEach(save => { |
|
log.info(` Slot ${save.slot}: ${save.gameDisplayName}`); |
|
}); |
|
return; |
|
} |
|
|
|
if (slotSaves.length > 1) { |
|
log.info(`Multiple saves found for slot ${slotNum}:`); |
|
slotSaves.forEach((save, index) => { |
|
log.info(` ${index + 1}. ${save.gameDisplayName}`); |
|
}); |
|
log.info('Using most recent save'); |
|
} |
|
|
|
const selectedSave = slotSaves[0]; |
|
const defaultOutput = outputPath || `${this.gameKey}-slot${slotNum}-backup`; |
|
|
|
const success = await this.configure(selectedSave.path, defaultOutput); |
|
if (success !== false) { |
|
log.success(`Configured sync for ${selectedSave.gameDisplayName} slot ${slotNum}`); |
|
} |
|
} |
|
|
|
async configure(pcPath, switchPath) { |
|
await this.loadConfig(); |
|
|
|
const switchExt = path.extname(switchPath).toLowerCase(); |
|
let isValidSwitchPath = false; |
|
|
|
try { |
|
const stats = await fs.stat(switchPath); |
|
if (stats.isDirectory()) { |
|
isValidSwitchPath = true; |
|
} else if (switchExt === '.json') { |
|
isValidSwitchPath = true; |
|
} |
|
} catch { |
|
if (switchExt === '.json' || !switchExt) { |
|
isValidSwitchPath = true; |
|
} |
|
} |
|
|
|
if (!isValidSwitchPath) { |
|
log.error('Switch save path must be a folder (JKSV) or .json file'); |
|
log.info('Examples:'); |
|
log.info(' silksong-slot1-backup (JKSV backup folder - recommended)'); |
|
log.info(' silksong-slot1.json (Switch JSON format)'); |
|
return false; |
|
} |
|
|
|
this.config.pcSave = path.resolve(pcPath); |
|
this.config.switchSave = path.resolve(switchPath); |
|
await this.saveConfig(); |
|
|
|
log.success(`${this.game.displayName} save sync configured`); |
|
log.path(`PC save: ${this.config.pcSave}`); |
|
log.path(`Switch save: ${this.config.switchSave}`); |
|
} |
|
|
|
async sync(forceDirection = null) { |
|
await this.loadConfig(); |
|
|
|
if (!this.config.pcSave || !this.config.switchSave) { |
|
log.error(`${this.game.displayName} save sync not configured`); |
|
log.info('Run "auto-setup" or "setup" command first'); |
|
return; |
|
} |
|
|
|
const pcInfo = await this.getFileInfo(this.config.pcSave); |
|
const switchInfo = await this.getFileInfo(this.config.switchSave); |
|
|
|
if (!pcInfo.exists && !switchInfo.exists) { |
|
log.error('No save files found at configured paths'); |
|
return; |
|
} |
|
|
|
let direction = forceDirection; |
|
|
|
if (!direction) { |
|
if (!pcInfo.exists) { |
|
direction = 'switch-to-pc'; |
|
} else if (!switchInfo.exists) { |
|
direction = 'pc-to-switch'; |
|
} else { |
|
direction = pcInfo.modified > switchInfo.modified ? 'pc-to-switch' : 'switch-to-pc'; |
|
|
|
if (this.config.lastSync) { |
|
const lastSync = new Date(this.config.lastSync); |
|
const pcNewer = pcInfo.modified > lastSync; |
|
const switchNewer = switchInfo.modified > lastSync; |
|
|
|
if (pcNewer && switchNewer) { |
|
log.warn('Conflict: Both saves modified since last sync'); |
|
log.info(`PC save: ${pcInfo.modified.toLocaleString()}`); |
|
log.info(`Switch save: ${switchInfo.modified.toLocaleString()}`); |
|
log.line(); |
|
log.info('Choose which save to keep:'); |
|
log.info(' Use "sync --pc" to keep PC save'); |
|
log.info(' Use "sync --switch" to keep Switch save'); |
|
return; |
|
} |
|
} |
|
} |
|
} |
|
|
|
if (direction === 'pc-to-switch') { |
|
await this.syncPCToSwitch(); |
|
} else { |
|
await this.syncSwitchToPC(); |
|
} |
|
|
|
this.config.lastSync = new Date().toISOString(); |
|
await this.saveConfig(); |
|
log.success('Sync completed'); |
|
} |
|
|
|
async syncPCToSwitch() { |
|
log.progress('Syncing PC save to Switch format'); |
|
|
|
if (await this.isJKSVFormat(this.config.switchSave)) { |
|
await createBackup(this.config.pcSave, this.config.switchSave); |
|
log.success('JKSV backup created'); |
|
log.line(); |
|
log.highlight('NEXT STEPS:'); |
|
log.info('1. Insert your Switch SD card into your computer'); |
|
log.info(`2. Navigate to: /JKSV/${this.game.jksvPath}/`); |
|
log.info(`3. Copy the folder: ${path.basename(this.config.switchSave)}`); |
|
log.info('4. Safely eject SD card and insert back into Switch'); |
|
log.info('5. Use JKSV on Switch to restore the backup'); |
|
} else { |
|
const pcData = await fs.readFile(this.config.pcSave); |
|
const switchData = pcToSwitch(new Uint8Array(pcData)); |
|
|
|
await fs.mkdir(path.dirname(this.config.switchSave), { recursive: true }); |
|
await fs.writeFile(this.config.switchSave, switchData, 'utf8'); |
|
log.success('PC save converted to Switch format'); |
|
} |
|
} |
|
|
|
async syncSwitchToPC() { |
|
log.progress('Syncing Switch save to PC format'); |
|
|
|
if (await this.isJKSVFormat(this.config.switchSave)) { |
|
const tempDir = path.join(path.dirname(this.config.pcSave), 'temp-extract'); |
|
await fs.mkdir(tempDir, { recursive: true }); |
|
|
|
const extracted = await extractBackup(this.config.switchSave, tempDir); |
|
if (extracted.length === 0) { |
|
throw new Error('No save files found in backup'); |
|
} |
|
|
|
await fs.copyFile(extracted[0], this.config.pcSave); |
|
await fs.rm(tempDir, { recursive: true }); |
|
log.success('Save extracted from JKSV backup'); |
|
} else { |
|
const switchData = await fs.readFile(this.config.switchSave, 'utf8'); |
|
const pcData = switchToPC(switchData); |
|
|
|
await fs.mkdir(path.dirname(this.config.pcSave), { recursive: true }); |
|
await fs.writeFile(this.config.pcSave, pcData); |
|
log.success('Switch save converted to PC format'); |
|
} |
|
} |
|
|
|
async showStatus() { |
|
await this.loadConfig(); |
|
|
|
if (!this.config.pcSave) { |
|
log.error(`${this.game.displayName} save sync not configured`); |
|
log.info('Run "auto-setup" command first'); |
|
return; |
|
} |
|
|
|
log.section(`${this.game.displayName} Save Sync Status`); |
|
|
|
const pcInfo = await this.getFileInfo(this.config.pcSave); |
|
const switchInfo = await this.getFileInfo(this.config.switchSave); |
|
|
|
const pcStatus = pcInfo.exists ? colors.green + 'EXISTS' + colors.reset : colors.red + 'NOT FOUND' + colors.reset; |
|
const switchStatus = switchInfo.exists ? colors.green + 'EXISTS' + colors.reset : colors.red + 'NOT FOUND' + colors.reset; |
|
const switchType = this.isJKSVFormat(this.config.switchSave) ? '(JKSV Backup)' : '(JSON Format)'; |
|
|
|
log.status(`PC save: ${pcStatus}`); |
|
log.path(`Path: ${this.config.pcSave}`); |
|
if (pcInfo.exists) { |
|
log.path(`Last modified: ${pcInfo.modified.toLocaleString()}`); |
|
} |
|
|
|
log.status(`Switch save: ${switchStatus} ${switchType}`); |
|
log.path(`Path: ${this.config.switchSave}`); |
|
if (switchInfo.exists) { |
|
log.path(`Last modified: ${switchInfo.modified.toLocaleString()}`); |
|
} |
|
|
|
log.line(); |
|
if (this.config.lastSync) { |
|
log.info(`Last sync: ${new Date(this.config.lastSync).toLocaleString()}`); |
|
} else { |
|
log.info('Last sync: Never'); |
|
} |
|
} |
|
} |
|
|
|
function showHelp() { |
|
log.highlight('\nHollow Knight Save Manager'); |
|
log.line(); |
|
|
|
log.section('Commands'); |
|
console.log(' auto-setup Auto-detect and setup save sync'); |
|
console.log(' setup <slot> [output] Setup save sync for specific slot'); |
|
console.log(' sync Sync saves between PC and Switch'); |
|
console.log(' status Show current configuration'); |
|
console.log(' list List all available save files\n'); |
|
|
|
console.log(' to-switch <save.dat> [output] Convert PC save to Switch format'); |
|
console.log(' to-pc <save.json> [output] Convert Switch save to PC format\n'); |
|
|
|
console.log(' backup <save.dat> <backup-folder> Create JKSV backup'); |
|
console.log(' restore <backup-folder> <folder> Extract JKSV backup'); |
|
|
|
log.section('Examples'); |
|
console.log(`${colors.dim}# Auto-detect and setup${colors.reset}`); |
|
console.log(`node ${path.basename(process.argv[1])} auto-setup`); |
|
|
|
console.log(`${colors.dim}# Manual setup for save slot 1${colors.reset}`); |
|
console.log(`node ${path.basename(process.argv[1])} setup 1`); |
|
|
|
console.log(`${colors.dim}# Skip game selection with --game flag${colors.reset}`); |
|
console.log(`node ${path.basename(process.argv[1])} sync --game hk`); |
|
|
|
console.log(`${colors.dim}# Sync your saves${colors.reset}`); |
|
console.log(`node ${path.basename(process.argv[1])} sync`); |
|
|
|
log.section('Save Slots'); |
|
console.log(' Slot 1: user1.dat Slot 2: user2.dat'); |
|
console.log(' Slot 3: user3.dat Slot 4: user4.dat'); |
|
|
|
log.section('Save Locations'); |
|
console.log(`${colors.bright}Windows:${colors.reset}`); |
|
log.path('C:/Users/[Username]/AppData/LocalLow/Team Cherry/Hollow Knight/'); |
|
log.path('C:/Users/[Username]/AppData/LocalLow/Team Cherry/Hollow Knight Silksong/[UserID]/'); |
|
console.log(`${colors.bright}Switch (JKSV):${colors.reset}`); |
|
log.path('/JKSV/Hollow Knight/'); |
|
log.path('/JKSV/Hollow Knight Silksong/'); |
|
} |
|
|
|
async function selectGame(availableGames = null) { |
|
const readline = require('readline'); |
|
const rl = readline.createInterface({ |
|
input: process.stdin, |
|
output: process.stdout |
|
}); |
|
|
|
return new Promise((resolve) => { |
|
if (availableGames && availableGames.length === 1) { |
|
// only one game available -> auto-select |
|
rl.close(); |
|
resolve(availableGames[0]); |
|
return; |
|
} |
|
|
|
const gamesToShow = availableGames || Object.keys(GAMES); |
|
|
|
log.section('Select Game'); |
|
gamesToShow.forEach((gameKey, index) => { |
|
log.info(`${index + 1}. ${GAMES[gameKey].displayName}`); |
|
}); |
|
|
|
rl.question(`\nChoose game (1-${gamesToShow.length}): `, (answer) => { |
|
const choice = parseInt(answer) - 1; |
|
rl.close(); |
|
|
|
if (choice >= 0 && choice < gamesToShow.length) { |
|
resolve(gamesToShow[choice]); |
|
} else { |
|
log.error('Invalid choice'); |
|
process.exit(1); |
|
} |
|
}); |
|
}); |
|
} |
|
|
|
function parseGameArg(args) { |
|
const gameIndex = args.indexOf('--game'); |
|
if (gameIndex !== -1 && gameIndex + 1 < args.length) { |
|
const gameArg = args[gameIndex + 1].toLowerCase(); |
|
if (GAMES[gameArg]) { |
|
// remove --game and its value from args |
|
args.splice(gameIndex, 2); |
|
return gameArg; |
|
} else { |
|
log.error(`Unknown game: ${gameArg}`); |
|
log.info('Available games: hk, silksong'); |
|
process.exit(1); |
|
} |
|
} |
|
return null; // no game specified |
|
} |
|
|
|
async function main() { |
|
const args = process.argv.slice(2); |
|
|
|
if (args.length === 0 || args.includes('help') || args.includes('--help')) { |
|
showHelp(); |
|
return; |
|
} |
|
|
|
const command = args[0]; |
|
let gameKey = parseGameArg(args); |
|
|
|
try { |
|
if (command === 'auto-setup') { |
|
if (gameKey === null) { |
|
log.progress('Scanning for all save files...'); |
|
const allSaves = await detectAllSaves(); |
|
|
|
if (allSaves.length === 0) { |
|
log.error('No Hollow Knight or Silksong save files found'); |
|
log.info('Make sure you have played at least one of the games and saved at least once'); |
|
return; |
|
} |
|
|
|
log.success(`Found ${allSaves.length} save file(s)`); |
|
log.line(); |
|
|
|
// group saves by game |
|
const gameGroups = {}; |
|
allSaves.forEach(save => { |
|
if (!gameGroups[save.game]) gameGroups[save.game] = []; |
|
gameGroups[save.game].push(save); |
|
}); |
|
|
|
// display all saves grouped by game |
|
Object.keys(gameGroups).forEach(gameKey => { |
|
const saves = gameGroups[gameKey]; |
|
log.section(`${GAMES[gameKey].displayName}`); |
|
saves.forEach((save, index) => { |
|
const recent = index === 0 ? colors.green + ' (most recent in game)' + colors.reset : ''; |
|
log.info(`Slot ${save.slot}${recent}`); |
|
log.path(`Modified: ${save.modified.toLocaleString()}`); |
|
log.path(`Path: ${save.path}`); |
|
}); |
|
log.line(); |
|
}); |
|
|
|
gameKey = await selectGame(Object.keys(gameGroups)); |
|
} |
|
|
|
const manager = new SaveManager(gameKey); |
|
await manager.autoSetup(); |
|
|
|
} else if (command === 'setup') { |
|
if (args.length < 2) { |
|
log.error('Save slot number required'); |
|
log.info('Usage: setup <slot-number> [output-file]'); |
|
return; |
|
} |
|
|
|
if (gameKey === null) { |
|
gameKey = await selectGame(); |
|
} |
|
|
|
const manager = new SaveManager(gameKey); |
|
await manager.setupSlot(args[1], args[2]); |
|
|
|
} else if (command === 'sync') { |
|
if (gameKey === null) { |
|
gameKey = await selectGame(); |
|
} |
|
|
|
const manager = new SaveManager(gameKey); |
|
let direction = null; |
|
if (args.includes('--pc')) direction = 'pc-to-switch'; |
|
if (args.includes('--switch')) direction = 'switch-to-pc'; |
|
await manager.sync(direction); |
|
|
|
} else if (command === 'status') { |
|
if (gameKey === null) { |
|
gameKey = await selectGame(); |
|
} |
|
|
|
const manager = new SaveManager(gameKey); |
|
await manager.showStatus(); |
|
|
|
} else if (command === 'list') { |
|
log.progress('Scanning for all save files...'); |
|
const allSaves = await detectAllSaves(); |
|
|
|
if (allSaves.length === 0) { |
|
log.error('No save files found'); |
|
log.info('Make sure you have played Hollow Knight or Silksong and saved at least once'); |
|
return; |
|
} |
|
|
|
log.success(`Found ${allSaves.length} save file(s)`); |
|
log.line(); |
|
|
|
const gameGroups = {}; |
|
allSaves.forEach(save => { |
|
if (!gameGroups[save.game]) gameGroups[save.game] = []; |
|
gameGroups[save.game].push(save); |
|
}); |
|
|
|
Object.keys(gameGroups).forEach(gameKey => { |
|
const saves = gameGroups[gameKey]; |
|
log.section(`${GAMES[gameKey].displayName}`); |
|
saves.forEach((save, index) => { |
|
const recent = index === 0 ? colors.green + ' (most recent)' + colors.reset : ''; |
|
log.info(`Slot ${save.slot}${recent}`); |
|
log.path(`Modified: ${save.modified.toLocaleString()}`); |
|
log.path(`Path: ${save.path}`); |
|
}); |
|
log.line(); |
|
}); |
|
|
|
} else if (command === 'to-switch') { |
|
if (args.length < 2) { |
|
log.error('PC save file required'); |
|
log.info('Usage: to-switch <save.dat> [output.json]'); |
|
return; |
|
} |
|
const input = args[1]; |
|
const output = args[2] || input.replace('.dat', '.json'); |
|
|
|
log.progress('Converting PC save to Switch format'); |
|
const pcData = await fs.readFile(input); |
|
const jsonData = pcToSwitch(new Uint8Array(pcData)); |
|
|
|
JSON.parse(jsonData); |
|
|
|
await fs.writeFile(output, jsonData, 'utf8'); |
|
log.success('Conversion completed'); |
|
log.info(`Switch save: ${output}`); |
|
|
|
} else if (command === 'to-pc') { |
|
if (args.length < 2) { |
|
log.error('Switch save file required'); |
|
log.info('Usage: to-pc <save.json> [output.dat]'); |
|
return; |
|
} |
|
const input = args[1]; |
|
const output = args[2] || input.replace('.json', '.dat'); |
|
|
|
log.progress('Converting Switch save to PC format'); |
|
const jsonContent = await fs.readFile(input, 'utf8'); |
|
|
|
JSON.parse(jsonContent); |
|
|
|
const pcData = switchToPC(jsonContent); |
|
await fs.writeFile(output, pcData); |
|
log.success('Conversion completed'); |
|
log.info(`PC save: ${output}`); |
|
|
|
} else if (command === 'backup') { |
|
if (args.length < 3) { |
|
log.error('Save file and backup name required'); |
|
log.info('Usage: backup <save.dat> <backup-folder>'); |
|
return; |
|
} |
|
log.progress('Creating JKSV backup'); |
|
await createBackup(args[1], args[2]); |
|
log.success('JKSV backup created'); |
|
log.info(`Backup: ${args[2]}`); |
|
log.line(); |
|
log.highlight('NEXT STEPS:'); |
|
log.info('1. Insert your Switch SD card into your computer'); |
|
log.info(`2. Navigate to: /switch/JKSV/${GAMES.silksong.jksvPath}/`); |
|
log.info(`3. Copy the file: ${path.basename(args[2])}`); |
|
log.info('4. Safely eject SD card and insert back into Switch'); |
|
log.info('5. Use JKSV on Switch to restore the backup'); |
|
|
|
} else if (command === 'restore') { |
|
if (args.length < 3) { |
|
log.error('Backup file and output folder required'); |
|
log.info('Usage: restore <backup-folder> <output-folder>'); |
|
return; |
|
} |
|
log.progress('Extracting JKSV backup'); |
|
await fs.mkdir(args[2], { recursive: true }); |
|
const extracted = await extractBackup(args[1], args[2]); |
|
log.success(`Extracted ${extracted.length} save file(s)`); |
|
log.info(`Output: ${args[2]}`); |
|
extracted.forEach(file => { |
|
log.info(` - ${path.basename(file)}`); |
|
}); |
|
|
|
} else { |
|
log.error(`Unknown command: ${command}`); |
|
log.info('Run without arguments to see available commands'); |
|
} |
|
|
|
} catch (error) { |
|
if (error.message.includes('ENOENT')) { |
|
log.error('File not found'); |
|
log.info('Check the file path and try again'); |
|
} else if (error.message.includes('JSON')) { |
|
log.error('Invalid save file format'); |
|
log.info('Make sure the file is a valid save file'); |
|
} else { |
|
log.error(`Operation failed: ${error.message}`); |
|
} |
|
process.exit(1); |
|
} |
|
} |
|
|
|
process.on('uncaughtException', (error) => { |
|
log.error(`Unexpected error: ${error.message}`); |
|
process.exit(1); |
|
}); |
|
|
|
process.on('unhandledRejection', (error) => { |
|
log.error(`Unexpected error: ${error.message}`); |
|
process.exit(1); |
|
}); |
|
|
|
if (require.main === module) { |
|
main(); |
|
} |
I’ll check on this today and push a fix for it. Did you use your first slot or another save slot? Also did you try to open the generated .zip file? Can it be open or does it show any error?