import { BinaryData, sliceData, readUInt8, readUInt16LE, readUInt16BE, readUInt32BE, isNode } from "./parsing-utils"; import * as zlib from "zlib"; // Cross-platform decompression async function inflateData(data: BinaryData): Promise { if (isNode) { // Node.js environment - use promisified inflate const { promisify } = await import("util"); const inflateAsync = promisify(zlib.inflate); return inflateAsync(data as Buffer); } // Browser environment - use promisified inflate return new Promise((resolve, reject) => { try { const buffer = data instanceof Buffer ? data : Buffer.from(data); zlib.inflate(buffer, (err, result) => { if (err) { reject(err); } else { resolve(result); } }); } catch (err) { reject(err); } }); } /** * Convert RGB565 to RGB888 values using Factorio's exact method * @param rgb565Value The RGB565 color value to convert * @returns Tuple of RGB values [r, g, b] */ export function rgb565ToRgb888(rgb565Value: number): [number, number, number] { // This matches ByteColor(uint16_t rgb5) constructor in Factorio source const r = ((rgb565Value >> 11) & 0x1F) << 3; // 5 bits -> 8 bits const g = ((rgb565Value >> 5) & 0x3F) << 2; // 6 bits -> 8 bits const b = (rgb565Value & 0x1F) << 3; // 5 bits -> 8 bits return [r, g, b]; } /** * Render chunk data to get RGB565 pixel array * @param chunkData The compressed chunk data to render * @returns RGB565 pixel array for 32x32 chunk */ export async function renderChunkToPixels(chunkData: BinaryData): Promise { try { const decompressed = await inflateData(chunkData); const pixels = new Uint16Array(32 * 32); // Ensure we don't read beyond available data const maxPixels = Math.min(decompressed.length / 2, 32 * 32); for (let i = 0; i < maxPixels; i++) { pixels[i] = readUInt16LE(decompressed, i * 2); } // Fill remaining pixels with black (0) if we have less data than expected for (let i = maxPixels; i < 32 * 32; i++) { pixels[i] = 0; } return pixels; } catch (decompressError) { // Log warning before falling back to black pixels console.warn(`Failed to decompress chunk data: ${decompressError}`); // Return black pixels on error return new Uint16Array(32 * 32); } } /** * Extract a 32x32 chunk area from a 256x256 tile * @param tilePixels The full tile pixel data * @param chunkX Chunk X coordinate within tile * @param chunkY Chunk Y coordinate within tile * @returns RGB565 pixel array for 32x32 chunk */ export function extractChunkFromTile(tilePixels: Uint16Array, chunkX: number, chunkY: number): Uint16Array { const chunkPixels = new Uint16Array(32 * 32); for (let y = 0; y < 32; y++) { for (let x = 0; x < 32; x++) { const tileX = chunkX * 32 + x; const tileY = chunkY * 32 + y; const tileIndex = tileY * 256 + tileX; const chunkIndex = y * 32 + x; chunkPixels[chunkIndex] = tilePixels[tileIndex]; } } return chunkPixels; } /** * Render tile data to RGB565 pixel array (256x256) * Supports rendering up to a specific tick for timelapse functionality * @param tileData The binary tile data to render * @param maxTick Optional maximum tick to render up to * @returns RGB565 pixel array for 256x256 tile */ export async function renderTileToPixels(tileData: BinaryData, maxTick?: number): Promise { const currentPixels = new Uint16Array(256 * 256); // 256x256 tile let offset = 0; while (offset < tileData.length) { if (offset + 1 > tileData.length) { break; } const type = readUInt8(tileData, offset); offset += 1; if (type === 1) { // Chunk const chunkResult = await processChunkData( tileData, offset, currentPixels, maxTick ); if (!chunkResult.success) { break; } offset = chunkResult.newOffset; } else if (type === 2) { // Pixels const pixelResult = processPixelData(tileData, offset, currentPixels, maxTick); if (!pixelResult.success) { break; } offset = pixelResult.newOffset; } else { break; } } return currentPixels; } /** * Process chunk data during tile rendering * @param tileData The binary tile data being processed * @param offset Current offset in the tile data * @param currentPixels The pixel array to update * @param maxTick Optional maximum tick to render up to * @returns Processing result with success status and new offset */ async function processChunkData( tileData: BinaryData, offset: number, currentPixels: Uint16Array, maxTick?: number ): Promise<{ success: boolean; newOffset: number }> { if (offset + 7 > tileData.length) { return { success: false, newOffset: offset }; } const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick offset += 4; // Break early if this record is beyond our target tick (append-only optimization) if (maxTick !== undefined && tick > maxTick) { return { success: false, newOffset: offset - 4 }; } const chunkCoordsByte = readUInt8(tileData, offset); offset += 1; const length = readUInt16BE(tileData, offset); offset += 2; if (offset + length > tileData.length) { return { success: false, newOffset: offset }; } const chunkX = chunkCoordsByte >> 4; const chunkY = chunkCoordsByte & 0x0F; const chunkData = sliceData(tileData, offset, offset + length); try { const chunkPixels = await renderChunkToPixels(chunkData); // Update current pixel state updateTilePixelsWithChunk(currentPixels, chunkPixels, chunkX, chunkY); } catch (decompressError) { // Continue processing other chunks on error } return { success: true, newOffset: offset + length }; } /** * Process pixel data during tile rendering * @param tileData The binary tile data being processed * @param offset Current offset in the tile data * @param currentPixels The pixel array to update * @param maxTick Optional maximum tick to render up to * @returns Processing result with success status and new offset */ function processPixelData( tileData: BinaryData, offset: number, currentPixels: Uint16Array, maxTick?: number ): { success: boolean; newOffset: number } { if (offset + 10 > tileData.length) { return { success: false, newOffset: offset }; } const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick offset += 4; const pixelCount = readUInt16BE(tileData, offset); offset += 2; const newColor = readUInt16BE(tileData, offset); offset += 2; const oldColor = readUInt16BE(tileData, offset); offset += 2; const expectedDataLength = pixelCount * 2; // x,y per pixel if (offset + expectedDataLength > tileData.length) { return { success: false, newOffset: offset }; } // Break early if this record is beyond our target tick (append-only optimization) if (maxTick !== undefined && tick > maxTick) { return { success: false, newOffset: offset - 4 }; } for (let i = 0; i < pixelCount; i++) { const pixelOffset = offset + i * 2; const x = readUInt8(tileData, pixelOffset); const y = readUInt8(tileData, pixelOffset + 1); if (x < 256 && y < 256) { currentPixels[y * 256 + x] = newColor; } } return { success: true, newOffset: offset + expectedDataLength }; } /** * Update tile pixels with chunk data * @param currentPixels The tile pixel array to update * @param chunkPixels The chunk pixel data to apply * @param chunkX Chunk X coordinate within tile * @param chunkY Chunk Y coordinate within tile */ function updateTilePixelsWithChunk( currentPixels: Uint16Array, chunkPixels: Uint16Array, chunkX: number, chunkY: number ): void { for (let y = 0; y < 32; y++) { for (let x = 0; x < 32; x++) { const chunkIndex = y * 32 + x; const tileX = chunkX * 32 + x; const tileY = chunkY * 32 + y; const tileIndex = tileY * 256 + tileX; currentPixels[tileIndex] = chunkPixels[chunkIndex]; } } } /** * Convert RGB565 pixel array to RGBA pixel data for canvas rendering * Returns Uint8ClampedArray that can be used to create ImageData in browser * @param pixels The RGB565 pixel array to convert * @returns RGBA pixel data as Uint8ClampedArray */ export function pixelsToRGBA(pixels: Uint16Array): Uint8ClampedArray { const imageData = new Uint8ClampedArray(256 * 256 * 4); for (let i = 0; i < 256 * 256; i++) { const rgb565Value = pixels[i]; const [r, g, b] = rgb565ToRgb888(rgb565Value); const bufferIndex = i * 4; imageData[bufferIndex] = r; imageData[bufferIndex + 1] = g; imageData[bufferIndex + 2] = b; imageData[bufferIndex + 3] = 255; // Alpha } return imageData; } /** * Convert RGB565 pixel array to ImageData for canvas rendering (browser only) * @param pixels The RGB565 pixel array to convert * @returns ImageData object for canvas rendering */ export function pixelsToImageData(pixels: Uint16Array): any { if (typeof (globalThis as any).ImageData === "undefined") { throw new Error("ImageData is not available in this environment. Use pixelsToRGBA() instead."); } const rgbaData = pixelsToRGBA(pixels); return new (globalThis as any).ImageData(rgbaData, 256, 256); } /** * Parse tile data and extract all available ticks for timelapse functionality * @param tileData The binary tile data to parse * @returns Array of available ticks sorted in ascending order */ export function extractAvailableTicks(tileData: BinaryData): number[] { const ticks = new Set(); let offset = 0; while (offset < tileData.length) { if (offset + 1 > tileData.length) { break; } const type = readUInt8(tileData, offset); offset += 1; if (type === 1) { // Chunk if (offset + 7 > tileData.length) { break; } const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick ticks.add(tick); offset += 4; const chunkCoordsByte = readUInt8(tileData, offset); offset += 1; const length = readUInt16BE(tileData, offset); offset += 2; if (offset + length > tileData.length) { break; } offset += length; // Skip chunk data } else if (type === 2) { // Pixels if (offset + 10 > tileData.length) { break; } const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick ticks.add(tick); offset += 4; const pixelCount = readUInt16BE(tileData, offset); offset += 2 + 2 + 2; // skip newColor oldColor too const expectedDataLength = pixelCount * 2; if (offset + expectedDataLength > tileData.length) { break; } offset += expectedDataLength; } else { break; } } return Array.from(ticks).sort((a, b) => a - b); } /** * Interface for tracking pixel changes in timelapse data */ export interface PixelChange { x: number; y: number; newColor: number; oldColor: number; } /** * Interface for chunk records in timelapse data */ export interface ChunkRecord { tick: number; chunkX: number; chunkY: number; data: BinaryData; } /** * Interface for pixel change records in timelapse data */ export interface PixelChangeRecord { tick: number; changes: PixelChange[]; } /** * Interface for parsed tile data with all change records */ export interface ParsedTileData { chunks: ChunkRecord[]; pixelChanges: PixelChangeRecord[]; allTicks: number[]; } /** * Parse tile data into structured change records for efficient timelapse navigation * @param tileData The binary tile data to parse * @returns Parsed tile data with chunks, pixel changes, and all ticks */ export function parseTileData(tileData: BinaryData): ParsedTileData { const chunks: ChunkRecord[] = []; const pixelChanges: PixelChangeRecord[] = []; const ticks = new Set(); let offset = 0; while (offset < tileData.length) { if (offset + 1 > tileData.length) { break; } const type = readUInt8(tileData, offset); offset += 1; if (type === 1) { // Chunk const result = parseChunkRecord(tileData, offset, chunks, ticks); if (!result.success) { break; } offset = result.newOffset; } else if (type === 2) { // Pixels const result = parsePixelChangeRecord(tileData, offset, pixelChanges, ticks); if (!result.success) { break; } offset = result.newOffset; } else { break; } } return { chunks, pixelChanges, allTicks: Array.from(ticks).sort((a, b) => a - b), }; } export interface ParsedChunkDataRecord { tick: number; chunkX: number; chunkY: number; data: BinaryData; } export interface ParsedPixelDataRecord { tick: number; pixelCount: number; newColor: number; oldColor: number; pixelDataOffset: number; } export type ParseRecordResult = | { success: false; newOffset: number } | { success: true; newOffset: number; record: T }; export function parseChunkDataRecord( tileData: BinaryData, offset: number ): ParseRecordResult { if (offset + 7 > tileData.length) { return { success: false, newOffset: offset }; } const tick = readUInt32BE(tileData, offset); offset += 4; const chunkCoordsByte = readUInt8(tileData, offset); offset += 1; const length = readUInt16BE(tileData, offset); offset += 2; if (offset + length > tileData.length) { return { success: false, newOffset: offset }; } const chunkX = chunkCoordsByte >> 4; const chunkY = chunkCoordsByte & 0x0F; const data = sliceData(tileData, offset, offset + length); return { success: true, newOffset: offset + length, record: { tick, chunkX, chunkY, data }, }; } export function parsePixelDataRecord( tileData: BinaryData, offset: number ): ParseRecordResult { if (offset + 10 > tileData.length) { return { success: false, newOffset: offset }; } const tick = readUInt32BE(tileData, offset); offset += 4; const pixelCount = readUInt16BE(tileData, offset); offset += 2; const newColor = readUInt16BE(tileData, offset); offset += 2; const oldColor = readUInt16BE(tileData, offset); offset += 2; const pixelDataLength = pixelCount * 2; if (offset + pixelDataLength > tileData.length) { return { success: false, newOffset: offset }; } return { success: true, newOffset: offset + pixelDataLength, record: { tick, pixelCount, newColor, oldColor, pixelDataOffset: offset, }, }; } /** * Parse chunk record from tile data * @param tileData The binary tile data being processed * @param offset Current offset in the tile data * @param chunks Array to add the parsed chunk to * @param ticks Set to add the chunk tick to * @returns Processing result with success status and new offset */ function parseChunkRecord( tileData: BinaryData, offset: number, chunks: ChunkRecord[], ticks: Set ): { success: boolean; newOffset: number } { const parsed = parseChunkDataRecord(tileData, offset); if (!parsed.success) { return parsed; } const tick = parsed.record.tick * 60; // Convert back to actual tick ticks.add(tick); chunks.push({ tick, chunkX: parsed.record.chunkX, chunkY: parsed.record.chunkY, data: parsed.record.data, }); return { success: true, newOffset: parsed.newOffset }; } /** * Parse pixel change record from tile data * @param tileData The binary tile data being processed * @param offset Current offset in the tile data * @param pixelChanges Array to add the parsed pixel changes to * @param ticks Set to add the pixel change tick to * @returns Processing result with success status and new offset */ function parsePixelChangeRecord( tileData: BinaryData, offset: number, pixelChanges: PixelChangeRecord[], ticks: Set ): { success: boolean; newOffset: number } { const parsed = parsePixelDataRecord(tileData, offset); if (!parsed.success) { return parsed; } const tick = parsed.record.tick * 60; // Convert back to actual tick ticks.add(tick); const changes: PixelChange[] = []; for (let i = 0; i < parsed.record.pixelCount; i++) { const pixelOffset = parsed.record.pixelDataOffset + i * 2; const x = readUInt8(tileData, pixelOffset); const y = readUInt8(tileData, pixelOffset + 1); changes.push({ x, y, newColor: parsed.record.newColor, oldColor: parsed.record.oldColor }); } pixelChanges.push({ tick, changes }); return { success: true, newOffset: parsed.newOffset }; } /** * Apply chunk data to RGB565 pixel array * @param pixels The tile pixel array to update * @param chunkRecord The chunk record to apply */ export async function applyChunkToPixels(pixels: Uint16Array, chunkRecord: ChunkRecord): Promise { try { const chunkPixels = await renderChunkToPixels(chunkRecord.data); updateTilePixelsWithChunk(pixels, chunkPixels, chunkRecord.chunkX, chunkRecord.chunkY); } catch (err) { // Error applying chunk - continue without this chunk } } /** * Clear chunk area to black (for rewinding past chunk creation) * @param pixels The tile pixel array to update * @param chunkRecord The chunk record to clear */ export function clearChunkInPixels(pixels: Uint16Array, chunkRecord: ChunkRecord): void { for (let y = 0; y < 32; y++) { for (let x = 0; x < 32; x++) { const tileX = chunkRecord.chunkX * 32 + x; const tileY = chunkRecord.chunkY * 32 + y; const tileIndex = tileY * 256 + tileX; pixels[tileIndex] = 0; // Black } } } /** * Apply pixel changes to RGB565 pixel array (forward direction) * @param pixels The tile pixel array to update * @param changes Array of pixel changes to apply */ export function applyPixelChanges(pixels: Uint16Array, changes: PixelChange[]): void { for (const change of changes) { const tileIndex = change.y * 256 + change.x; pixels[tileIndex] = change.newColor; } } /** * Revert pixel changes from RGB565 pixel array (backward direction) * @param pixels The tile pixel array to update * @param changes Array of pixel changes to revert */ export function revertPixelChanges(pixels: Uint16Array, changes: PixelChange[]): void { for (const change of changes) { const tileIndex = change.y * 256 + change.x; pixels[tileIndex] = change.oldColor; } } /** * Render tile data incrementally from one tick to another * Much more efficient than re-rendering from scratch * @param parsedData The parsed tile data * @param currentPixels The current pixel state * @param fromTick The starting tick * @param toTick The target tick */ export async function renderTileIncremental( parsedData: ParsedTileData, currentPixels: Uint16Array, fromTick: number, toTick: number ): Promise { const { chunks, pixelChanges } = parsedData; if (fromTick === toTick) { return; // No changes needed } const forward = toTick > fromTick; if (forward) { await applyChangesForward(chunks, pixelChanges, currentPixels, fromTick, toTick); } else { await applyChangesBackward(chunks, pixelChanges, currentPixels, fromTick, toTick); } } /** * Apply changes in forward direction * @param chunks Array of chunk records to process * @param pixelChanges Array of pixel change records to process * @param currentPixels The pixel array to update * @param fromTick The starting tick * @param toTick The target tick */ async function applyChangesForward( chunks: ChunkRecord[], pixelChanges: PixelChangeRecord[], currentPixels: Uint16Array, fromTick: number, toTick: number ): Promise { // Apply chunks that occur in this range for (const chunk of chunks) { if (chunk.tick > fromTick && chunk.tick <= toTick) { await applyChunkToPixels(currentPixels, chunk); } } // Apply pixel changes that occur in this range for (const record of pixelChanges) { if (record.tick > fromTick && record.tick <= toTick) { applyPixelChanges(currentPixels, record.changes); } } } /** * Apply changes in backward direction * @param chunks Array of chunk records to process * @param pixelChanges Array of pixel change records to process * @param currentPixels The pixel array to update * @param fromTick The starting tick * @param toTick The target tick */ async function applyChangesBackward( chunks: ChunkRecord[], pixelChanges: PixelChangeRecord[], currentPixels: Uint16Array, fromTick: number, toTick: number ): Promise { // Revert pixel changes in reverse order for (let i = pixelChanges.length - 1; i >= 0; i--) { const record = pixelChanges[i]; if (record.tick <= fromTick && record.tick > toTick) { revertPixelChanges(currentPixels, record.changes); } } // Revert chunks (clear to black if we're going before their creation time) for (let i = chunks.length - 1; i >= 0; i--) { const chunk = chunks[i]; if (chunk.tick <= fromTick && chunk.tick > toTick) { clearChunkInPixels(currentPixels, chunk); } } } /** * Interface for parsed recipe tile data */ export interface ParsedRecipeTile { ticks: number[]; // ticks present in this tile activeRecipes: Map; // posKey -> recipeName dictionary: Map; // id -> recipeName } /** * Parse binary recipe tile data and return timeline ticks and active recipes at target tick * @param tileX Tile X coordinate for world position calculation * @param tileY Tile Y coordinate for world position calculation * @param buf Binary recipe tile data * @param targetTick Target tick for filtering (null = latest state) * @returns Parsed recipe data with ticks and active recipes */ export function parseRecipeTileBinary( tileX: number, tileY: number, buf: BinaryData, targetTick: number | null ): ParsedRecipeTile { const ticks: number[] = []; const dict = new Map(); const active = new Map(); const targetSec = targetTick !== null ? Math.floor(targetTick / 60) : Infinity; let offset = 0; while (offset < buf.length) { const type = readUInt8(buf, offset); offset += 1; if (type === 0) { // Dictionary entry const result = parseDictionaryEntry(buf, offset, dict); offset = result.newOffset; } else if (type === 1 || type === 2) { // Set recipe (1) or Clear recipe (2) const result = parseRecipeEntry( buf, offset, type, tileX, tileY, ticks, active, targetSec, dict ); offset = result.newOffset; } else { // Unknown recipe record type - stop parsing break; } } return { ticks, activeRecipes: active, dictionary: dict }; } /** * Parse dictionary entry from recipe tile * @param buf The binary recipe tile data * @param offset Current offset in the data * @param dict Dictionary map to add the entry to * @returns Processing result with new offset */ function parseDictionaryEntry( buf: BinaryData, offset: number, dict: Map ): { newOffset: number } { const idx = readUInt16BE(buf, offset); offset += 2; const len = readUInt8(buf, offset); offset += 1; const nameBytes = sliceData(buf, offset, offset + len); offset += len; // Convert to string using cross-platform method let recipeName: string; if (isNode && nameBytes instanceof Buffer) { recipeName = nameBytes.toString("utf-8"); } else { recipeName = new TextDecoder().decode(nameBytes as Uint8Array); } dict.set(idx, recipeName); return { newOffset: offset }; } /** * Parse recipe entry from recipe tile * @param buf The binary recipe tile data * @param offset Current offset in the data * @param type Entry type (1 = set recipe, 2 = clear recipe) * @param tileX Tile X coordinate for world position calculation * @param tileY Tile Y coordinate for world position calculation * @param ticks Array to add the entry tick to * @param active Map of active recipes to update * @param targetSec Target second for filtering * @param dict Dictionary map for recipe name lookup * @returns Processing result with new offset */ function parseRecipeEntry( buf: BinaryData, offset: number, type: number, tileX: number, tileY: number, ticks: number[], active: Map, targetSec: number, dict: Map ): { newOffset: number } { const sec = readUInt32BE(buf, offset); offset += 4; const px = readUInt8(buf, offset); offset += 1; const py = readUInt8(buf, offset); offset += 1; let idx: number | undefined; if (type === 1) { idx = readUInt16BE(buf, offset); offset += 2; } const tickVal = sec * 60; ticks.push(tickVal); if (sec > targetSec) { // Future to target time – skip without applying return { newOffset: offset }; } const worldX = tileX * 256 + px; const worldY = tileY * 256 + py; const posKey = `${worldX},${worldY}`; if (type === 1 && idx !== undefined) { const name = dict.get(idx); if (name) { active.set(posKey, name); } } else if (type === 2) { active.delete(posKey); } return { newOffset: offset }; }