|
#!/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(); |
|
} |
Thanks mate!