UNPKG

snes-disassembler

Version:

A Super Nintendo (SNES) ROM disassembler for 65816 assembly

417 lines • 19.2 kB
"use strict"; /** * SNES Asset Extraction Handler * * Handles extraction of graphics, audio, and text assets from SNES ROMs * with improved error handling and AI-enhanced pattern recognition. */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.extractAssets = extractAssets; const fs_1 = require("fs"); const path_1 = __importDefault(require("path")); const asset_extractor_1 = require("./asset-extractor"); async function extractAssets(romFile, options, outputDir) { if (options.verbose) { console.log('\nšŸŽØ Extracting Assets...'); } try { // Read ROM data const romData = await fs_1.promises.readFile(romFile); // AI enhancement is enabled by default, can be disabled with --disable-ai const assetExtractor = new asset_extractor_1.AssetExtractor(!options.disableAI); // Parse asset types to extract const assetTypes = (options.assetTypes || 'graphics,audio,text').split(',').map(t => t.trim()); const graphicsFormats = (options.assetFormats || '4bpp').split(',').map(f => f.trim()); const baseName = path_1.default.basename(romFile, path_1.default.extname(romFile)); const assetDir = path_1.default.join(outputDir, `${baseName}_assets`); await fs_1.promises.mkdir(assetDir, { recursive: true }); const startTime = Date.now(); let totalAssetsExtracted = 0; // Extract graphics assets if (assetTypes.includes('graphics')) { const graphicsCount = await extractGraphicsAssets(romData, assetExtractor, assetDir, graphicsFormats, options.verbose || false); totalAssetsExtracted += graphicsCount; } // Extract audio assets if (assetTypes.includes('audio')) { const audioCount = await extractAudioAssets(romData, assetExtractor, assetDir, options.verbose || false); totalAssetsExtracted += audioCount; } // Extract text assets if (assetTypes.includes('text')) { const textCount = await extractTextAssets(romData, assetExtractor, assetDir, options.verbose || false); totalAssetsExtracted += textCount; } const extractionTime = Date.now() - startTime; if (options.verbose) { console.log(`āœ… Asset extraction completed in ${extractionTime}ms`); console.log(`šŸ“ Assets directory: ${assetDir}`); console.log(`šŸ“Š Total assets extracted: ${totalAssetsExtracted}`); } else { console.log(`Assets extracted: ${assetDir} (${totalAssetsExtracted} items)`); } } catch (error) { throw new Error(`Asset extraction failed: ${error instanceof Error ? error.message : error}`); } } async function extractGraphicsAssets(romData, assetExtractor, assetDir, graphicsFormats, verbose) { if (verbose) { console.log(' šŸ“Š Extracting graphics...'); } const graphicsDir = path_1.default.join(assetDir, 'graphics'); await fs_1.promises.mkdir(graphicsDir, { recursive: true }); const graphicsExtractor = assetExtractor.getGraphicsExtractor(); let totalExtracted = 0; // Extract tiles for each format for (const format of graphicsFormats) { const formatDir = path_1.default.join(graphicsDir, format); await fs_1.promises.mkdir(formatDir, { recursive: true }); // Use more sophisticated region detection for graphics data const graphicsRegions = detectGraphicsRegions(romData, format); for (const region of graphicsRegions) { const regionData = romData.slice(region.start, region.end); const tiles = await graphicsExtractor.extractTiles(regionData, format, region.start); if (tiles.length > 0) { // Save tile data as JSON with enhanced metadata const tilesData = { format, region: { start: region.start, end: region.end, type: region.type }, count: tiles.length, tiles: tiles.map(tile => ({ address: tile.address, width: tile.width, height: tile.height, bitsPerPixel: tile.bitsPerPixel, data: Array.from(tile.data), metadata: tile.metadata || {} })) }; const regionName = region.type.toLowerCase().replace(/\s+/g, '_'); await fs_1.promises.writeFile(path_1.default.join(formatDir, `tiles_${regionName}.json`), JSON.stringify(tilesData, null, 2)); totalExtracted += tiles.length; if (verbose && tiles.length > 0) { console.log(` - ${format} (${region.type}): ${tiles.length} tiles extracted`); } } } } // Extract palettes with improved detection if (romData.length > 0x1000) { const paletteCount = await extractPaletteData(romData, graphicsExtractor, graphicsDir, verbose); totalExtracted += paletteCount; } return totalExtracted; } function detectGraphicsRegions(romData, format) { const regions = []; // Standard graphics regions for different formats switch (format) { case '2bpp': regions.push({ start: 0x8000, end: 0x10000, type: 'Character Data' }, { start: 0x20000, end: 0x30000, type: 'Background Graphics' }); break; case '4bpp': regions.push({ start: 0x8000, end: 0x20000, type: 'Sprite Graphics' }, { start: 0x40000, end: 0x60000, type: 'Background Graphics' }, { start: 0x80000, end: 0xA0000, type: 'Character Data' }); break; case '8bpp': regions.push({ start: 0x10000, end: 0x30000, type: 'Mode 7 Graphics' }, { start: 0x60000, end: 0x80000, type: 'Full Color Graphics' }); break; default: regions.push({ start: 0x8000, end: Math.min(0x20000, romData.length), type: 'General Graphics' }); } // Filter regions that actually exist in the ROM return regions.filter(region => region.start < romData.length && region.end <= romData.length); } async function extractPaletteData(romData, graphicsExtractor, graphicsDir, verbose) { // Look for palette data in multiple locations const paletteRegions = [ { start: 0x0, end: 0x1000, type: 'Header Palettes' }, { start: 0x8000, end: 0x8200, type: 'Character Palettes' }, { start: 0x10000, end: 0x10200, type: 'Background Palettes' } ]; let totalPalettes = 0; const allPalettes = []; for (const region of paletteRegions) { if (region.start < romData.length) { const regionEnd = Math.min(region.end, romData.length); const paletteData = romData.slice(region.start, regionEnd); const palettes = graphicsExtractor.extractPalettes(paletteData, region.start); if (palettes.length > 0) { palettes.forEach((palette) => { palette.region = region.type; allPalettes.push(palette); }); totalPalettes += palettes.length; } } } if (allPalettes.length > 0) { const palettesData = { count: allPalettes.length, palettes: allPalettes.map(palette => ({ address: palette.address, format: palette.format, region: palette.region, colors: palette.colors.map((color) => `#${color.toString(16).padStart(6, '0')}`), metadata: palette.metadata || {} })) }; await fs_1.promises.writeFile(path_1.default.join(graphicsDir, 'palettes.json'), JSON.stringify(palettesData, null, 2)); if (verbose) { console.log(` - ${allPalettes.length} palettes extracted from ${paletteRegions.length} regions`); } } return totalPalettes; } async function extractAudioAssets(romData, assetExtractor, assetDir, verbose) { if (verbose) { console.log(' šŸŽµ Extracting audio...'); } const audioDir = path_1.default.join(assetDir, 'audio'); await fs_1.promises.mkdir(audioDir, { recursive: true }); const audioExtractor = assetExtractor.getAudioExtractor(); // Enhanced audio region detection based on common SNES patterns const audioRegions = detectAudioRegions(romData); const allSamples = []; const allSequences = []; for (const region of audioRegions) { if (romData.length > region.start && region.start < romData.length) { const regionEnd = Math.min(region.end, romData.length); const audioData = romData.slice(region.start, regionEnd); // Extract BRR samples const samples = await audioExtractor.extractBRRSamples(audioData, region.start); samples.forEach((sample) => { sample.region = region.type; allSamples.push(sample); }); // Extract music sequences if available try { const sequences = await audioExtractor.extractSequences(audioData, region.start); sequences.forEach((sequence) => { sequence.region = region.type; allSequences.push(sequence); }); } catch (error) { // Music sequence extraction is optional if (verbose) { console.log(` - No music sequences found in ${region.type}`); } } if (verbose && (samples.length > 0 || allSequences.length > 0)) { console.log(` - ${region.type}: ${samples.length} samples, ${allSequences.length || 0} sequences`); } } } let totalExtracted = 0; // Save sample data if (allSamples.length > 0) { const samplesData = { count: allSamples.length, samples: allSamples.map(sample => ({ name: sample.name, address: sample.address, region: sample.region, sampleRate: sample.sampleRate, loopStart: sample.loopStart, loopEnd: sample.loopEnd, dataLength: sample.data.length, metadata: sample.metadata || {} })) }; await fs_1.promises.writeFile(path_1.default.join(audioDir, 'samples.json'), JSON.stringify(samplesData, null, 2)); // Save individual sample files for (let i = 0; i < allSamples.length; i++) { const sample = allSamples[i]; const sampleName = sample.name || `sample_${i.toString().padStart(3, '0')}`; await fs_1.promises.writeFile(path_1.default.join(audioDir, `${sampleName}.brr`), sample.data); } totalExtracted += allSamples.length; } // Save sequence data if (allSequences.length > 0) { const sequencesData = { count: allSequences.length, sequences: allSequences.map(seq => ({ name: seq.name, address: seq.address, region: seq.region, length: seq.length, tracks: seq.tracks, metadata: seq.metadata || {} })) }; await fs_1.promises.writeFile(path_1.default.join(audioDir, 'sequences.json'), JSON.stringify(sequencesData, null, 2)); totalExtracted += allSequences.length; } if (verbose && totalExtracted > 0) { console.log(` - Total audio assets: ${totalExtracted} (${allSamples.length} samples, ${allSequences.length} sequences)`); } return totalExtracted; } function detectAudioRegions(romData) { const regions = []; // Common SNES audio regions - more comprehensive coverage // Early ROM regions (headers and initial data) if (romData.length > 0x8000) { regions.push({ start: 0x0000, end: 0x8000, type: 'ROM Header & Early Data' }); } // Standard graphics/audio boundary regions if (romData.length > 0x20000) { regions.push({ start: 0x8000, end: 0x20000, type: 'Low Bank Audio/Graphics' }); } // Common audio data regions if (romData.length > 0x40000) { regions.push({ start: 0x20000, end: 0x40000, type: 'Primary Audio Bank' }); } if (romData.length > 0x80000) { regions.push({ start: 0x40000, end: 0x80000, type: 'Secondary Audio Bank' }); } // High ROM regions (common for audio in larger ROMs) if (romData.length > 0xC0000) { regions.push({ start: 0x80000, end: 0xC0000, type: 'Extended Audio Bank' }); } if (romData.length > 0x100000) { regions.push({ start: 0xC0000, end: 0x100000, type: 'SPC Engine & Samples' }); } if (romData.length > 0x200000) { regions.push({ start: 0x100000, end: 0x200000, type: 'Large ROM Audio Data' }); } // Add smaller scanning regions for thorough coverage const scanRegions = []; for (let addr = 0; addr < Math.min(romData.length, 0x400000); addr += 0x10000) { if (addr + 0x1000 < romData.length) { scanRegions.push({ start: addr, end: addr + 0x1000, type: `Scan Region ${addr.toString(16).toUpperCase()}` }); } } regions.push(...scanRegions); console.log(`šŸ” Detected ${regions.length} audio regions to scan in ${romData.length} byte ROM`); return regions.filter(region => region.start < romData.length && region.end <= romData.length); } async function extractTextAssets(romData, assetExtractor, assetDir, verbose) { if (verbose) { console.log(' šŸ“ Extracting text...'); } const textDir = path_1.default.join(assetDir, 'text'); await fs_1.promises.mkdir(textDir, { recursive: true }); const textExtractor = assetExtractor.getTextExtractor(); const encoding = textExtractor.detectEncoding(romData); if (verbose) { console.log(` - Detected encoding: ${encoding}`); } // Extract text from multiple regions const textRegions = detectTextRegions(romData, encoding); const allStrings = []; for (const region of textRegions) { if (region.start < romData.length) { const regionEnd = Math.min(region.end, romData.length); const regionData = romData.slice(region.start, regionEnd); const strings = await textExtractor.extractStrings(regionData, encoding, region.start, 4); strings.forEach((str) => { str.region = region.type; allStrings.push(str); }); if (verbose && strings.length > 0) { console.log(` - ${region.type}: ${strings.length} strings`); } } } if (allStrings.length > 0) { // Remove duplicates based on text content const uniqueStrings = allStrings.filter((str, index, arr) => arr.findIndex(s => s.text === str.text) === index); const textData = { encoding, totalStrings: allStrings.length, uniqueStrings: uniqueStrings.length, regions: textRegions.map(r => r.type), strings: uniqueStrings.map(str => ({ text: str.text, address: str.address, region: str.region, length: str.length, context: str.context, metadata: str.metadata || {} })) }; await fs_1.promises.writeFile(path_1.default.join(textDir, 'strings.json'), JSON.stringify(textData, null, 2)); // Create categorized text files const categories = groupStringsByCategory(uniqueStrings); for (const [category, strings] of Object.entries(categories)) { const categoryText = strings .map((str) => `[${str.address.toString(16).toUpperCase().padStart(6, '0')}] ${str.text}`) .join('\n'); await fs_1.promises.writeFile(path_1.default.join(textDir, `${category.toLowerCase().replace(/\s+/g, '_')}.txt`), categoryText); } // Create comprehensive readable text file const readableText = uniqueStrings .map(str => `[${str.address.toString(16).toUpperCase().padStart(6, '0')}] (${str.region}) ${str.text}`) .join('\n'); await fs_1.promises.writeFile(path_1.default.join(textDir, 'all_strings.txt'), readableText); if (verbose) { console.log(` - Total: ${uniqueStrings.length} unique strings (${allStrings.length} total) in ${Object.keys(categories).length} categories`); } return uniqueStrings.length; } return 0; } function detectTextRegions(romData, encoding) { const regions = []; // Standard text regions based on encoding if (encoding === 'sjis' || encoding === 'custom') { regions.push({ start: 0x40000, end: 0x60000, type: 'Main Text' }, { start: 0x60000, end: 0x80000, type: 'Menu Text' }, { start: 0x20000, end: 0x40000, type: 'Character Names' }); } else { regions.push({ start: 0x20000, end: 0x40000, type: 'Dialogue' }, { start: 0x40000, end: 0x50000, type: 'Menu Text' }, { start: 0x50000, end: 0x60000, type: 'Item Names' }); } // Add regions for credits and miscellaneous text regions.push({ start: 0x80000, end: 0x90000, type: 'Credits' }, { start: romData.length - 0x8000, end: romData.length, type: 'End Text' }); return regions.filter(region => region.start < romData.length && region.end <= romData.length); } function groupStringsByCategory(strings) { const categories = { 'Dialogue': [], 'Menu': [], 'Items': [], 'Credits': [], 'System': [], 'Other': [] }; strings.forEach(str => { const text = str.text.toLowerCase(); const region = str.region.toLowerCase(); if (region.includes('dialogue') || text.includes('says') || text.includes(': ')) { categories['Dialogue'].push(str); } else if (region.includes('menu') || text.includes('select') || text.includes('option')) { categories['Menu'].push(str); } else if (region.includes('item') || text.includes('sword') || text.includes('potion')) { categories['Items'].push(str); } else if (region.includes('credit') || text.includes('staff') || text.includes('director')) { categories['Credits'].push(str); } else if (text.includes('load') || text.includes('save') || text.includes('error')) { categories['System'].push(str); } else { categories['Other'].push(str); } }); // Remove empty categories Object.keys(categories).forEach(key => { if (categories[key].length === 0) { delete categories[key]; } }); return categories; } //# sourceMappingURL=asset-handler.js.map