Skip to content

Instantly share code, notes, and snippets.

@lardratboy
Created September 26, 2025 00:50
Show Gist options
  • Select an option

  • Save lardratboy/c1e8361696040d7fbd5097216e63a183 to your computer and use it in GitHub Desktop.

Select an option

Save lardratboy/c1e8361696040d7fbd5097216e63a183 to your computer and use it in GitHub Desktop.
IFF/RIFF-like data explorer, processes chunks and produces a large amount of png images
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VOMIT PNG's 0.5.1 - CHUNKY DATA EXPLORER</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
color: #333;
}
h1, h2, h3 {
margin-top: 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.panel {
background: white;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
padding: 20px;
margin-bottom: 20px;
}
.control-row {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-bottom: 15px;
}
.control-group {
flex: 1;
min-width: 200px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input[type="text"], input[type="number"] {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
background: #4285f4;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:hover {
background: #3367d6;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.button-group {
display: flex;
gap: 10px;
}
#cancel-btn {
background: #d93025;
display: none;
}
#cancel-btn:hover {
background: #b52d20;
}
.checkbox-group {
display: flex;
align-items: center;
}
.checkbox-group label {
margin: 0 0 0 5px;
}
#log {
background-color: #f8f8f8;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
height: 200px;
overflow-y: auto;
font-family: monospace;
white-space: pre-wrap;
margin-bottom: 20px;
}
#images {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.image-container {
background: #f9f9f9;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.image-container canvas {
max-width: 100%;
display: block;
}
.image-title {
font-weight: bold;
margin-bottom: 5px;
}
.image-info {
font-size: 14px;
margin-top: 5px;
color: #666;
}
.bundle {
width: 100%;
}
.cancelled {
color: #d93025;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<div class="panel">
<h1>VOMIT PNG's 0.5.1 - CHUNKY DATA EXPLORER</h1>
<p>This tool is able to dump IFF-like chunk files (4 byte ascii id, followed by a big endian 32 bit integer, followed by the contained data. Each chunk header includes the size of data and the size of the header, making this slightly different than standard IFF/RIFF chunks).</p>
</div>
<div class="panel">
<h2>Settings</h2>
<div class="control-row">
<div class="control-group">
<label for="file-input">Select File:</label>
<input type="file" id="file-input">
</div>
<div class="control-group">
<label for="output-format">Output Format:</label>
<input type="text" id="output-format" value="%4s_%08d.png">
</div>
<div class="control-group">
<label for="xor-key">XOR Key:</label>
<input type="number" id="xor-key" value="105">
</div>
</div>
<div class="control-row">
<div class="control-group">
<label for="page-width">Page Width:</label>
<input type="number" id="page-width" value="1024">
</div>
<div class="control-group">
<label for="page-height">Page Height:</label>
<input type="number" id="page-height" value="1024">
</div>
<div class="control-group">
<div class="checkbox-group">
<input type="checkbox" id="bundle">
<label for="bundle">Bundle</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="gray">
<label for="gray">Grayscale</label>
</div>
</div>
<div class="control-group">
<div class="button-group">
<button id="process-btn">Process File</button>
<button id="cancel-btn">Cancel</button>
</div>
</div>
</div>
</div>
<div class="panel">
<h2>Progress</h2>
<div id="progress-container" style="display: none;">
<progress id="progress-bar" value="0" max="100" style="width: 100%; height: 20px;"></progress>
<div id="progress-text">Starting...</div>
</div>
<h2>Log</h2>
<pre id="log"></pre>
</div>
<div class="panel">
<h2>Results</h2>
<div id="stats"></div>
<div id="images"></div>
</div>
</div>
<script>
// Global cancellation flag
let processingCancelled = false;
// Rectangle packing system
class Rect {
constructor(left, top, right, bottom) {
this.left = left;
this.top = top;
this.right = right;
this.bottom = bottom;
}
width() {
return this.right - this.left;
}
height() {
return this.bottom - this.top;
}
}
class Page {
constructor(width, height) {
this.width = width;
this.height = height;
this.free_rects = [new Rect(0, 0, width, height)];
this.occupied_rects = [];
}
*external_clipped_rects(a, b) {
let top = a.top, bottom = a.bottom;
if (a.top < b.top) {
top = b.top;
yield new Rect(a.left, a.top, a.right, b.top);
}
if (a.bottom > b.bottom) {
bottom = b.bottom;
yield new Rect(a.left, b.bottom, a.right, a.bottom);
}
if (a.left < b.left) {
yield new Rect(a.left, top, b.left, bottom);
}
if (a.right > b.right) {
yield new Rect(b.right, top, a.right, bottom);
}
}
insert(width, height) {
for (let i = 0; i < this.free_rects.length; i++) {
const free_rect = this.free_rects[i];
if (free_rect.width() < width || free_rect.height() < height) continue;
const rect = new Rect(
free_rect.left,
free_rect.top,
free_rect.left + width,
free_rect.top + height
);
this.occupied_rects.push(rect);
this.free_rects.splice(i, 1);
const free_count = this.free_rects.length;
for (const clipped_rect of this.external_clipped_rects(free_rect, rect)) {
this.free_rects.push(clipped_rect);
}
if (free_count !== this.free_rects.length) {
this.free_rects.sort((a, b) => a.height() - b.height());
}
return rect;
}
return null;
}
calculate_efficiency() {
const total_area = this.width * this.height;
const used_area = this.occupied_rects.reduce((sum, rect) =>
sum + rect.width() * rect.height(), 0);
return used_area / total_area;
}
}
class Packer {
constructor(width, height) {
this.pages = [new Page(width, height)];
this.page_width = width;
this.page_height = height;
}
insert(width, height) {
for (const page of this.pages) {
const rect = page.insert(width, height);
if (rect) return [page, rect];
}
const new_page = new Page(this.page_width, this.page_height);
this.pages.push(new_page);
return [new_page, new_page.insert(width, height)];
}
}
// Chunk class for data parsing
class Chunk {
constructor(id, offset, depth = 0) {
this.id = id;
this.offset = offset;
this.depth = depth;
this.children = [];
this.data = null;
}
}
// DataChunkCollector equivalent
class DataChunkCollector {
constructor(outputFormatter = null, saveAsGrayscale = false, bundle = false, pageWidth = 1024, pageHeight = 1024) {
this.outputFormatter = outputFormatter;
this.nextNumber = 1;
this.saveAsGrayscale = saveAsGrayscale;
this.savePng = outputFormatter !== null;
this.bundle = bundle;
this.imagesByTypes = {};
this.bundledImages = [];
this.pageWidth = pageWidth;
this.pageHeight = pageHeight;
}
// Break up image processing into smaller tasks to prevent browser freezing
async processImageData(chunk, canvas, ctx, width, height, isGrayscale) {
if (processingCancelled) return null;
const actualSize = chunk.data.length;
const imgData = ctx.createImageData(width, height);
const data = imgData.data;
// Process in chunks to avoid UI freezing for large images
const pixelsPerBatch = 100000; // Adjust based on performance testing
if (isGrayscale) {
// Grayscale image processing
for (let i = 0; i < actualSize; i += pixelsPerBatch) {
if (processingCancelled) return null;
const end = Math.min(i + pixelsPerBatch, actualSize);
for (let j = i; j < end; j++) {
const val = chunk.data[j];
const idx = j * 4;
data[idx] = val; // R
data[idx + 1] = val; // G
data[idx + 2] = val; // B
data[idx + 3] = 255; // A
}
if (actualSize > pixelsPerBatch) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
// Fill the rest with black pixels (already alpha 255)
for (let i = actualSize; i < width * height; i++) {
const idx = i * 4;
data[idx + 3] = 255; // A for remaining pixels
}
} else {
// RGB image processing
let j = 0;
for (let i = 0; i < actualSize; i += 3) {
if (processingCancelled) return null;
if (j % pixelsPerBatch === 0 && j > 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
const r = i < actualSize ? chunk.data[i] : 0;
const g = (i + 1) < actualSize ? chunk.data[i + 1] : 0;
const b = (i + 2) < actualSize ? chunk.data[i + 2] : 0;
const idx = j * 4;
data[idx] = r; // R
data[idx + 1] = g; // G
data[idx + 2] = b; // B
data[idx + 3] = 255; // A
j++;
}
// Fill the rest with black pixels (with alpha 255)
for (let i = j; i < width * height; i++) {
const idx = i * 4;
data[idx + 3] = 255; // A
}
}
if (processingCancelled) return null;
// Put the image data on the canvas
ctx.putImageData(imgData, 0, 0);
return canvas;
}
async collect(chunk) {
if (processingCancelled) return;
// Filter chunks by ID
if (!['AWIZ', 'AKOS', 'SDAT', 'SOUN', 'DIGI', 'RMIM', 'COST', 'FORM'].includes(chunk.id)) return;
const actualSize = chunk.data.length;
if (this.savePng) {
const filename = this.outputFormatter
.replace('%4s', chunk.id)
.replace('%08d', String(this.nextNumber).padStart(8, '0'));
this.nextNumber++;
let side, width, height;
let canvas, ctx;
if (this.saveAsGrayscale) {
// Follow Python's logic for grayscale
const alignedSize = actualSize;
side = Math.floor(Math.max(Math.ceil(Math.sqrt(alignedSize)), 1));
width = side;
height = side;
canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
ctx = canvas.getContext('2d');
} else {
// Follow Python's logic for RGB
const alignedSize = Math.ceil((actualSize + 2) / 3);
side = Math.floor(Math.max(Math.ceil(Math.sqrt(alignedSize)), 1));
width = side;
height = side;
canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
ctx = canvas.getContext('2d');
}
// Process image data asynchronously for large chunks
const result = await this.processImageData(chunk, canvas, ctx, width, height, this.saveAsGrayscale);
if (!result) return; // Cancelled during processing
// Store image info
if (this.bundle) {
if (!this.imagesByTypes[chunk.id]) {
this.imagesByTypes[chunk.id] = [];
}
// Save image and its data
const img = {
canvas: canvas,
chunk: chunk,
width: width,
height: height,
filename: filename
};
this.imagesByTypes[chunk.id].push(img);
} else {
// For non-bundled, display directly
this.displayImage(canvas, filename, chunk.id);
}
}
}
displayImage(canvas, filename, chunkId) {
const container = document.createElement('div');
container.className = 'image-container';
const title = document.createElement('div');
title.className = 'image-title';
title.textContent = filename;
container.appendChild(title);
container.appendChild(canvas);
const info = document.createElement('div');
info.className = 'image-info';
info.textContent = `Chunk ID: ${chunkId}, Size: ${canvas.width}x${canvas.height}`;
container.appendChild(info);
document.getElementById('images').appendChild(container);
}
async saveBundles() {
if (!this.bundle || processingCancelled) return;
log("Saving bundles...");
const packers = [];
// Pack images by type
for (const id in this.imagesByTypes) {
if (processingCancelled) return;
log(`Processing ${id} images...`);
// Sort by height, largest first, as in Python
this.imagesByTypes[id].sort((a, b) => b.height - a.height);
const packer = new Packer(this.pageWidth, this.pageHeight);
packers.push(packer);
packer.rectsByImage = {};
packer.id = id;
// Process in batches to avoid UI freezing
const images = this.imagesByTypes[id];
const batchSize = 100;
for (let i = 0; i < images.length; i += batchSize) {
if (processingCancelled) return;
const end = Math.min(i + batchSize, images.length);
for (let j = i; j < end; j++) {
const img = images[j];
const [page, rect] = packer.insert(img.width, img.height);
if (!rect) {
console.error(`Failed to insert ${img.width}x${img.height}`);
log(`ERROR:: failed to insert width=${img.width}, height=${img.height} ::ERROR`);
continue;
}
rect.img = img;
}
// Yield to browser after each batch
updateProgress(end, images.length, `Packing ${id} images`);
await new Promise(resolve => setTimeout(resolve, 0));
}
}
if (processingCancelled) return;
log("Creating bundle images...");
// Build and save bundles for each packer
for (let packerIndex = 0; packerIndex < packers.length; packerIndex++) {
if (processingCancelled) return;
const packer = packers[packerIndex];
for (let i = 0; i < packer.pages.length; i++) {
if (processingCancelled) return;
const page = packer.pages[i];
updateProgress(i + 1, packer.pages.length, `Creating ${packer.id} bundle images`);
// Create canvas for the page
const canvas = document.createElement('canvas');
canvas.width = packer.page_width;
canvas.height = packer.page_height;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw images in batches
const rects = page.occupied_rects;
const rectBatchSize = 100;
for (let j = 0; j < rects.length; j += rectBatchSize) {
if (processingCancelled) return;
const endRect = Math.min(j + rectBatchSize, rects.length);
for (let k = j; k < endRect; k++) {
const rect = rects[k];
ctx.drawImage(rect.img.canvas, rect.left, rect.top);
}
// Yield to browser after each batch
if (rects.length > rectBatchSize) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
// Generate filename
const filename = this.outputFormatter
.replace('%4s', packer.id)
.replace('%08d', String(i + 1).padStart(8, '0'));
// Save canvas info
canvas.filename = filename;
canvas.page = page;
this.bundledImages.push(canvas);
// Display the bundle
this.displayBundle(canvas, filename, packer.id, page);
// Yield to browser after each page
await new Promise(resolve => setTimeout(resolve, 0));
}
}
if (!processingCancelled) {
// Display stats
this.displayStats();
}
}
displayBundle(canvas, filename, id, page) {
const container = document.createElement('div');
container.className = 'image-container bundle';
const title = document.createElement('div');
title.className = 'image-title';
title.textContent = filename;
container.appendChild(title);
container.appendChild(canvas);
const info = document.createElement('div');
info.className = 'image-info';
const efficiency = page.calculate_efficiency();
info.textContent = `Chunk ID: ${id}, Efficiency: ${(efficiency * 100).toFixed(2)}%`;
container.appendChild(info);
document.getElementById('images').appendChild(container);
}
displayStats() {
const stats = document.getElementById('stats');
stats.innerHTML = `<h3>Results</h3>`;
stats.innerHTML += `<p>Total number of bundles: ${this.bundledImages.length}</p>`;
let statsList = '<ul>';
for (let i = 0; i < this.bundledImages.length; i++) {
const canvas = this.bundledImages[i];
const efficiency = canvas.page.calculate_efficiency();
statsList += `<li>page ${canvas.filename} efficiency=${efficiency.toFixed(6)}</li>`;
}
statsList += '</ul>';
stats.innerHTML += statsList;
}
}
// Function to skip possible garbage data
function skipPossibleGarbageData(data, chunk, offset, remainingData) {
if (chunk.id !== 'DIGI') return 0;
const startOffset = offset;
let skipped = 0;
while (skipped < remainingData) {
try {
// Try to read 4 bytes as ASCII
if (offset + skipped + 4 <= data.length) {
// Check if we can form a valid ID
let validAscii = true;
for (let i = 0; i < 4; i++) {
const charCode = data[offset + skipped + i];
if (charCode < 32 || charCode > 126) {
validAscii = false;
break;
}
}
if (validAscii) {
return 0;
}
}
} catch (e) {
console.log('Error trying to skip garbage:', e);
}
skipped++;
}
return skipped;
}
// Function to process chunks asynchronously
async function processChunk(data, parentChunk, offset, remainingData, depth = 0, collector = null, bundle = false, progressCallback = null) {
if (processingCancelled) return offset;
const validParentIDs = ['MULT', 'WRAP', 'TALK', 'TLKB', 'LECF', 'LFLF', 'SONG', 'NEST', 'RMDA', 'OBIM', 'ROOM'];
if (parentChunk.id !== null && !validParentIDs.includes(parentChunk.id)) {
log(' '.repeat(depth) + `${parentChunk.id} size=${remainingData}`);
// Extract the chunk data
const startPos = offset - 8;
const endPos = offset + remainingData;
parentChunk.data = data.slice(startPos, endPos);
if (collector && !processingCancelled) await collector.collect(parentChunk);
return offset + remainingData;
}
log(' '.repeat(depth) + `processing ${parentChunk.id || 'root'}`);
let currentOffset = offset;
let chunksProcessed = 0;
while (remainingData > 8 && !processingCancelled) {
// Yield to the browser every 50 chunks to prevent UI freezing
if (chunksProcessed % 50 === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
if (progressCallback) {
progressCallback(currentOffset, data.length);
}
}
// Read chunk ID (4 bytes)
const id = String.fromCharCode(
data[currentOffset],
data[currentOffset + 1],
data[currentOffset + 2],
data[currentOffset + 3]
);
// Read chunk size (4 bytes, big endian)
const size = (data[currentOffset + 4] << 24) |
(data[currentOffset + 5] << 16) |
(data[currentOffset + 6] << 8) |
data[currentOffset + 7];
const chunk = new Chunk(id, currentOffset, depth);
parentChunk.children.push(chunk);
currentOffset += 8;
// Process the chunk asynchronously
const newOffset = await processChunk(data, chunk, currentOffset, size - 8, depth + 1, collector, bundle, progressCallback);
// If newOffset was returned, use it, otherwise calculate
currentOffset = newOffset || (currentOffset + size - 8);
remainingData -= size;
// Skip possible garbage data
const skipped = skipPossibleGarbageData(data, chunk, currentOffset, remainingData);
remainingData -= skipped;
currentOffset += skipped;
chunksProcessed++;
}
return currentOffset;
}
// Main function to parse chunks from a file asynchronously
async function parseChunksForFile(data, xorKey = null, collector = null, bundle = false, progressCallback = null) {
if (processingCancelled) return null;
// Apply XOR if needed (this will also be done asynchronously for large files)
let processedData = new Uint8Array(data);
if (xorKey !== null && xorKey !== 0) {
log('Applying XOR decryption...');
const xorValue = xorKey & 0xFF;
processedData = new Uint8Array(data.length);
// Process in chunks of 1MB to avoid UI freezing
const chunkSize = 1024 * 1024;
for (let i = 0; i < data.length; i += chunkSize) {
if (processingCancelled) return null;
const end = Math.min(i + chunkSize, data.length);
for (let j = i; j < end; j++) {
processedData[j] = data[j] ^ xorValue;
}
// Yield to the browser after each chunk
if (progressCallback) {
progressCallback(end, data.length, 'Decrypting');
}
await new Promise(resolve => setTimeout(resolve, 0));
}
}
if (processingCancelled) return null;
log('Processing chunks...');
const rootChunk = new Chunk(null, 0);
await processChunk(processedData, rootChunk, 0, processedData.length, 0, collector, bundle, progressCallback);
return rootChunk;
}
// Helper function for logging
function log(message) {
const logElement = document.getElementById('log');
logElement.innerHTML += message + '\n';
logElement.scrollTop = logElement.scrollHeight;
console.log(message);
}
// Progress tracking
function updateProgress(current, total, stage = 'Processing') {
const percent = Math.floor((current / total) * 100);
const progressElement = document.getElementById('progress-bar');
const progressTextElement = document.getElementById('progress-text');
if (progressElement && progressTextElement) {
progressElement.value = percent;
progressTextElement.textContent = `${stage}: ${percent}% (${current}/${total})`;
}
}
// Cancel function
function cancelProcessing() {
processingCancelled = true;
log('<span class="cancelled">Processing cancelled by user</span>');
// Reset UI state
const processBtn = document.getElementById('process-btn');
const cancelBtn = document.getElementById('cancel-btn');
processBtn.disabled = false;
processBtn.textContent = 'Process File';
cancelBtn.style.display = 'none';
updateProgress(0, 100, 'Cancelled');
}
// Main process function (async)
async function processFile() {
// Reset cancellation flag
processingCancelled = false;
// Clear previous output
document.getElementById('log').innerHTML = '';
document.getElementById('stats').innerHTML = '';
document.getElementById('images').innerHTML = '';
// Get form values
const fileInput = document.getElementById('file-input');
const outputFormat = document.getElementById('output-format').value;
const xorKey = parseInt(document.getElementById('xor-key').value, 10);
const pageWidth = parseInt(document.getElementById('page-width').value, 10);
const pageHeight = parseInt(document.getElementById('page-height').value, 10);
const bundle = document.getElementById('bundle').checked;
const gray = document.getElementById('gray').checked;
if (!fileInput.files.length) {
log('Error: No file selected');
return;
}
const file = fileInput.files[0];
log(`Processing file: ${file.name} (${(file.size / (1024 * 1024)).toFixed(2)} MB)`);
// Update UI state
const processBtn = document.getElementById('process-btn');
const cancelBtn = document.getElementById('cancel-btn');
processBtn.disabled = true;
processBtn.textContent = 'Processing...';
cancelBtn.style.display = 'inline-block';
document.getElementById('progress-container').style.display = 'block';
try {
// Read the file
const arrayBuffer = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => resolve(e.target.result);
reader.onerror = e => reject(e);
reader.readAsArrayBuffer(file);
});
if (processingCancelled) return;
const data = new Uint8Array(arrayBuffer);
log(`File loaded, size: ${(data.length / (1024 * 1024)).toFixed(2)} MB`);
// Create collector
const collector = new DataChunkCollector(outputFormat, gray, bundle, pageWidth, pageHeight);
// Parse chunks asynchronously
log('Parsing chunks...');
const fileChunks = await parseChunksForFile(data, xorKey, collector, bundle, updateProgress);
if (processingCancelled) return;
// Save bundles if needed
if (bundle) {
await collector.saveBundles();
}
if (!processingCancelled) {
log('Processing complete!');
updateProgress(100, 100, 'Complete');
}
} catch (error) {
if (!processingCancelled) {
log(`Error: ${error.message}`);
console.error(error);
}
} finally {
// Reset UI state
processBtn.disabled = false;
processBtn.textContent = 'Process File';
cancelBtn.style.display = 'none';
}
}
// Add event listeners
document.getElementById('process-btn').addEventListener('click', processFile);
document.getElementById('cancel-btn').addEventListener('click', cancelProcessing);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment