snes-disassembler
Version:
A Super Nintendo (SNES) ROM disassembler for 65816 assembly
417 lines ⢠19.2 kB
JavaScript
"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