import * as lib from "@clusterio/lib"; import { BaseControllerPlugin } from "@clusterio/controller"; import { TileDataEvent, GetInstanceBoundsRequest, ChartData, ChunkUpdateEvent, } from "./messages"; import * as fs from "fs-extra"; import * as path from "path"; import * as zlib from "zlib"; import { promisify } from "util"; import sharp from "sharp"; const CHUNK_SIZE = 32; const inflateAsync = promisify(zlib.inflate); export class ControllerPlugin extends BaseControllerPlugin { private tilesPath: string = ""; // Set to true if colors appear inverted - this will try big-endian byte order private useBigEndian: boolean = false; async init() { this.tilesPath = path.resolve( this.controller.config.get("controller.database_directory"), "minimap_chunks" ); await fs.ensureDir(this.tilesPath); this.controller.handle(TileDataEvent, this.handleTileDataEvent.bind(this)); this.controller.handle(GetInstanceBoundsRequest, this.handleGetInstanceBoundsRequest.bind(this)); // Set up HTTP routes for serving tiles this.setupTileRoutes(); } // Convert RGB565 to RGB888 values using Factorio's exact method private 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]; } private setupTileRoutes() { const app = this.controller.app; // Create black placeholder image for 512x512 tiles const createBlackTile = async () => { return await sharp({ create: { width: 512, height: 512, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 }, }, }).png().toBuffer(); }; // Serve chart tiles with surface and force support - now serving 512x512 tiles app.get("/api/minimap/chart/:instanceId/:surface/:force/:z/:x/:y.png", async (req, res) => { try { const { instanceId, surface, force, z, x, y } = req.params; const tileX = parseInt(x); const tileY = parseInt(y); // Each 512x512 tile contains 16x16 chunks of 32x32 pixels const chunksPerTile = 16; const tileSize = 512; // Calculate the range of chunk coordinates that make up this 512x512 tile const startChunkX = tileX * chunksPerTile; const startChunkY = tileY * chunksPerTile; const endChunkX = startChunkX + chunksPerTile; const endChunkY = startChunkY + chunksPerTile; // Create a 512x512 canvas const canvas = sharp({ create: { width: tileSize, height: tileSize, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 }, }, }); // Load all the 32x32 chunks and composite them const compositeImages = []; let chunksFound = 0; for (let chunkY = startChunkY; chunkY < endChunkY; chunkY++) { for (let chunkX = startChunkX; chunkX < endChunkX; chunkX++) { const filename = `${instanceId}/${surface}/${force}/${chunkX}_${chunkY}.png`; const filePath = path.resolve(this.tilesPath, filename); if (await fs.pathExists(filePath)) { const relativeX = (chunkX - startChunkX) * CHUNK_SIZE; const relativeY = (chunkY - startChunkY) * CHUNK_SIZE; compositeImages.push({ input: filePath, left: relativeX, top: relativeY, }); chunksFound++; } } } if(chunksFound > 0) { this.logger.info(`Tile ${tileX},${tileY}: Found ${chunksFound} chunks, chunk range: ${startChunkX}-${endChunkX-1}, ${startChunkY}-${endChunkY-1}`); } // If we have any chunks to composite, create the merged image if (compositeImages.length > 0) { const mergedImage = await canvas.composite(compositeImages).png().toBuffer(); res.setHeader("Content-Type", "image/png"); res.send(mergedImage); } else { // No chunks found, return black tile res.setHeader("Content-Type", "image/png"); res.send(await createBlackTile()); } } catch (err) { this.logger.error(`Error serving chart tile: ${err}`); // Return black tile on error res.setHeader("Content-Type", "image/png"); res.send(await createBlackTile()); } }); // List available surfaces and forces app.get("/api/minimap/surfaces", async (req, res) => { try { const instances = new Set(); const surfaces = new Set(); const forces = new Set(); // Traverse the hierarchical directory structure: instanceId/surface/force/ const instanceDirs = await fs.readdir(this.tilesPath, { withFileTypes: true }); for (const instanceDir of instanceDirs) { if (instanceDir.isDirectory()) { instances.add(instanceDir.name); const instancePath = path.join(this.tilesPath, instanceDir.name); const surfaceDirs = await fs.readdir(instancePath, { withFileTypes: true }); for (const surfaceDir of surfaceDirs) { if (surfaceDir.isDirectory()) { surfaces.add(surfaceDir.name); const surfacePath = path.join(instancePath, surfaceDir.name); const forceDirs = await fs.readdir(surfacePath, { withFileTypes: true }); for (const forceDir of forceDirs) { if (forceDir.isDirectory()) { forces.add(forceDir.name); } } } } } } res.json({ instances: Array.from(instances), surfaces: Array.from(surfaces), forces: Array.from(forces), }); } catch (err) { this.logger.error(`Error listing surfaces: ${err}`); res.status(500).json({ error: "Failed to list surfaces" }); } }); } async handleTileDataEvent(event: TileDataEvent) { try { const { type, data, position, instanceId } = event; if (type === "chart") { await this.processChartData(data as ChartData[], position, instanceId); } } catch (err) { this.logger.error(`Error processing tile data: ${err}`); } } private async processChartData(chartData: ChartData[], position: [number, number], instanceId: number) { if (!position || !instanceId) { this.logger.error("Chart data requires position and instanceId"); return; } const [worldX, worldY] = position; // Convert world coordinates to chunk coordinates const chunkX = Math.floor(worldX / 32); const chunkY = Math.floor(worldY / 32); // Process each surface/force combination for (const chart of chartData) { const { surface, force, chart_data } = chart; // Create filename using chunk coordinates const filename = `${instanceId}/${surface}/${force}/${chunkX}_${chunkY}.png`; // Process the 32x32 chunk directly and get the processed image data const imageDataBase64 = await this.saveChunkImage(filename, chart_data); // Send live update to connected web clients if (imageDataBase64) { const updateEvent = new ChunkUpdateEvent( instanceId, surface, force, chunkX, chunkY, imageDataBase64 ); // Broadcast to all connected control clients this.controller.sendEvent(updateEvent, new lib.Address(lib.Address.broadcast, lib.Address.control)); } } } private async saveChunkImage(filename: string, chartData: string): Promise { const imagePath = path.resolve(this.tilesPath, filename); // Ensure the directory structure exists await fs.ensureDir(path.dirname(imagePath)); // Create raw image buffer for 32x32 RGBA const raw = Buffer.alloc(CHUNK_SIZE * CHUNK_SIZE * 4); try { // Decode base64 to get the deflate-compressed data const compressedBuffer = Buffer.from(chartData, 'base64'); // Decompress the deflate data to get the original binary chart data const binaryBuffer = await inflateAsync(compressedBuffer); const dataLength = Math.min(binaryBuffer.length, CHUNK_SIZE * CHUNK_SIZE * 2); for (let i = 0; i < dataLength; i += 2) { // Read bytes properly from binary buffer const byte1 = binaryBuffer[i]; const byte2 = binaryBuffer[i + 1]; if (byte1 === undefined || byte2 === undefined) { continue; } // Choose endianness based on flag const rgb565Value = this.useBigEndian ? (byte1 << 8) + byte2 : // Big-endian: byte1 is high byte byte1 + (byte2 << 8); // Little-endian: byte1 is low byte const [r, g, b] = this.rgb565ToRgb888(rgb565Value); // Calculate pixel position within the chunk const pixelIndex = i / 2; const x = pixelIndex % CHUNK_SIZE; const y = Math.floor(pixelIndex / CHUNK_SIZE); // Set pixel in raw buffer (RGBA order - Sharp expects this) const bufferIndex = pixelIndex * 4; if (bufferIndex >= 0 && bufferIndex < raw.length - 3) { raw[bufferIndex] = r; // R raw[bufferIndex + 1] = g; // G raw[bufferIndex + 2] = b; // B raw[bufferIndex + 3] = 255; // A (full alpha) } } // Create the PNG buffer for both saving and live updates const pngBuffer = await sharp(raw, { raw: { width: CHUNK_SIZE, height: CHUNK_SIZE, channels: 4, }, }).png().toBuffer(); // Save the chunk image to disk await fs.writeFile(imagePath, pngBuffer); // Convert ImageData to base64 for live updates // Create ImageData-like structure that can be sent over WebSocket const imageDataArray = new Uint8ClampedArray(raw); const imageDataJson = { data: Array.from(imageDataArray), width: CHUNK_SIZE, height: CHUNK_SIZE, }; // Get the final PNG file size for comparison const stats = await fs.stat(imagePath); const pngSize = stats.size; this.logger.info(`Chunk ${filename}: raw ${raw.length}, decompressed ${binaryBuffer.length}, compressed ${compressedBuffer.length}, PNG ${pngSize} bytes`); // Return base64 encoded ImageData for live updates return Buffer.from(JSON.stringify(imageDataJson)).toString('base64'); } catch (err) { this.logger.error(`Error processing chunk image ${filename}: ${err}`); return null; } } async handleGetInstanceBoundsRequest(request: GetInstanceBoundsRequest) { const instances = Array.from(this.controller.instances.values()) .filter(instance => instance.status === "running") .map(instance => ({ instanceId: instance.config.get("instance.id"), name: instance.config.get("instance.name"), bounds: { x1: -512, // Default bounds, could be made configurable y1: -512, x2: 512, y2: 512, }, })); return { instances }; } }