import * as lib from "@clusterio/lib"; import { BaseControllerPlugin } from "@clusterio/controller"; import { TileDataEvent, GetRawTileRequest, GetChartTagsRequest, ChartData, ChartTagDataEvent, ChartTagData, } 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"; import { renderChunkToPixels, extractChunkFromTile, renderTileToPixels, pixelsToRGBA } from "./tile-utils"; const CHUNK_SIZE = 32; const inflateAsync = promisify(zlib.inflate); interface PixelChange { x: number; y: number; newColor: number; oldColor: number; } export class ControllerPlugin extends BaseControllerPlugin { private tilesPath: string = ""; private chartTagsPath: string = ""; private chunkSavingQueue = new Map>(); private chartTagQueues = new Map(); private lastSeenTagContent = new Map>(); private savingTiles: boolean = false; private savingChartTags = new Set(); private saveInterval: NodeJS.Timeout | null = null; async init() { this.tilesPath = path.resolve( this.controller.config.get("controller.database_directory"), "minimap_tiles" ); this.chartTagsPath = path.resolve( this.controller.config.get("controller.database_directory"), "minimap_chart_tags" ); await fs.ensureDir(this.tilesPath); await fs.ensureDir(this.chartTagsPath); this.controller.handle(TileDataEvent, this.handleTileDataEvent.bind(this)); this.controller.handle(ChartTagDataEvent, this.handleChartTagDataEvent.bind(this)); this.controller.handle(GetRawTileRequest, this.handleGetRawTileRequest.bind(this)); this.controller.handle(GetChartTagsRequest, this.handleGetChartTagsRequest.bind(this)); // Set up HTTP routes for serving tiles this.setupTileRoutes(); // Load existing tag numbers for deduplication await this.loadExistingTagContent(); this.saveInterval = setInterval(() => { this.saveTiles().catch(err => this.logger.error(`Error saving tiles: ${err}`)); this.saveChartTags().catch(err => this.logger.error(`Error saving chart tags: ${err}`)); }, 5000); } async onShutdown() { if (this.saveInterval) { clearInterval(this.saveInterval); this.saveInterval = null; } // Perform final save before shutdown await this.saveTiles(); await this.saveChartTags(); } // Compare two pixel arrays and return changes private comparePixels(oldPixels: Uint16Array, newPixels: Uint16Array, chunkX: number, chunkY: number): PixelChange[] { const changes: PixelChange[] = []; for (let y = 0; y < 32; y++) { for (let x = 0; x < 32; x++) { const index = y * 32 + x; const oldColor = oldPixels[index]; const newColor = newPixels[index]; if (oldColor !== newColor) { // Calculate absolute position within the 256x256 tile const absoluteX = chunkX * 32 + x; const absoluteY = chunkY * 32 + y; changes.push({ x: absoluteX, y: absoluteY, newColor, oldColor, }); } } } return changes; } // Create pixel changeset data buffer private createPixelChangeset(changes: PixelChange[], tick: number): Buffer { if (changes.length === 0) { return Buffer.alloc(0); } // Type (1) + Tick (4) + Length (2) + Pixel data (6 bytes per pixel) const headerSize = 7; const pixelDataSize = changes.length * 6; // 6 bytes per pixel change const totalSize = headerSize + pixelDataSize; const buffer = Buffer.alloc(totalSize); let offset = 0; // Type 2 for pixels buffer.writeUInt8(2, offset); offset += 1; // Tick (converted to seconds) buffer.writeUInt32BE(Math.floor(tick / 60), offset); offset += 4; // Length (number of pixel changes, not bytes) buffer.writeUInt16BE(changes.length, offset); offset += 2; // Write pixel changes for (const change of changes) { buffer.writeUInt8(change.x, offset); offset += 1; buffer.writeUInt8(change.y, offset); offset += 1; buffer.writeUInt16BE(change.newColor, offset); offset += 2; buffer.writeUInt16BE(change.oldColor, offset); offset += 2; } return buffer; } private setupTileRoutes() { const app = this.controller.app; app.get("/api/minimap/tile/: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); const tileName = `${instanceId}_${surface}_${force}_${tileX}_${tileY}.bin`; const tilePath = path.join(this.tilesPath, tileName); if (!await fs.pathExists(tilePath)) { res.status(404).send("Tile not found"); return; } const tileData = await fs.readFile(tilePath); const image = await this.renderTile(tileData); res.setHeader("Content-Type", "image/png"); res.send(image); } catch (err) { this.logger.error(`Error serving tile: ${err}`); res.status(500).send("Error serving tile"); } }); app.get("/api/minimap/surfaces", async (req, res) => { try { const files = await fs.readdir(this.tilesPath); const surfaces = new Set(); const forces = new Set(); for (const file of files) { if (file.endsWith(".bin")) { const parts = file.split("_"); if (parts.length === 5) { surfaces.add(parts[1]); forces.add(parts[2]); } } } res.json({ surfaces: Array.from(surfaces), forces: Array.from(forces), }); } catch (err) { this.logger.error(`Error listing surfaces and forces: ${err}`); res.status(500).json({ error: "Failed to list surfaces and forces" }); } }); } async renderTile(tileData: Buffer): Promise { // Use renderTileToPixels to get the current state, then convert to image const currentPixels = await renderTileToPixels(tileData); // Convert RGB565 pixel data to RGBA buffer for Sharp const rgbaData = pixelsToRGBA(currentPixels); const raw = Buffer.from(rgbaData); // Create and return the image directly return sharp(raw, { raw: { width: 256, height: 256, channels: 4 } }).png().toBuffer(); } async saveTiles() { if (this.savingTiles) { return; } const queue = this.chunkSavingQueue; if (queue.size === 0) { return; } this.logger.info(`Starting save for ${queue.size} tiles.`); this.savingTiles = true; this.chunkSavingQueue = new Map(); try { const savePromises = []; for (const [tileName, chunks] of queue) { const promise = (async () => { const tilePath = path.join(this.tilesPath, tileName); let existingTile = Buffer.alloc(0); if (await fs.pathExists(tilePath)) { existingTile = await fs.readFile(tilePath); } const newTile = await this.updateTile(existingTile, chunks); if (newTile && newTile.length > 0) { await fs.writeFile(tilePath, newTile); } })(); savePromises.push(promise); } await Promise.all(savePromises); } catch (err) { this.logger.error(`Error saving tiles: ${err}`); } finally { this.savingTiles = false; } } async updateTile(existingTile: Buffer, newChunks: Map): Promise { const existingChunks = new Map(); // Parse existing tile data to find existing chunks let offset = 0; while (offset < existingTile.length) { if (offset + 1 > existingTile.length) { this.logger.error(`Invalid tile data: cannot read type at offset ${offset}`); break; } const type = existingTile.readUInt8(offset); offset += 1; if (type === 1) { // Chunk if (offset + 7 > existingTile.length) { this.logger.error(`Invalid tile data: insufficient chunk header data at offset ${offset}`); break; } const tick = existingTile.readUInt32BE(offset); offset += 4; const chunkCoordsByte = existingTile.readUInt8(offset); offset += 1; const length = existingTile.readUInt16BE(offset); offset += 2; if (offset + length > existingTile.length) { this.logger.error(`Invalid tile data: insufficient chunk data at offset ${offset}`); break; } const chunkX = chunkCoordsByte >> 4; const chunkY = chunkCoordsByte & 0x0F; const chunkName = `${chunkX}_${chunkY}`; const data = existingTile.slice(offset, offset + length); offset += length; existingChunks.set(chunkName, { tick, data }); } else if (type === 2) { // Pixels if (offset + 6 > existingTile.length) { this.logger.error(`Invalid tile data: insufficient pixel header data at offset ${offset}`); break; } const tick = existingTile.readUInt32BE(offset); offset += 4; const pixelCount = existingTile.readUInt16BE(offset); offset += 2; const pixelDataLength = pixelCount * 6; if (offset + pixelDataLength > existingTile.length) { this.logger.error(`Invalid tile data: insufficient pixel data at offset ${offset}`); break; } // Skip pixel data since we're not rebuilding the file offset += pixelDataLength; } else { this.logger.error(`Unknown tile data type: ${type} at offset ${offset}`); break; } } // Render the current state of the entire tile if we have existing data let currentTilePixels: Uint16Array | null = null; if (existingTile.length > 0) { currentTilePixels = await renderTileToPixels(existingTile); } // Process new chunks and determine what to append const appendBuffers: Buffer[] = []; let hasChanges = false; for (const [chunkName, newChunk] of newChunks) { const newChunkData = Buffer.from(newChunk.data.chart_data, "base64"); const [chunkX, chunkY] = chunkName.split("_").map(Number); if (!existingChunks.has(chunkName)) { // New chunk - append as chunk data (type 1) this.logger.info(`Adding new chunk ${chunkName} to tile`); const header = Buffer.alloc(8); header.writeUInt8(1, 0); // Type 1 (chunk) header.writeUInt32BE(Math.floor(newChunk.tick / 60), 1); // Tick header.writeUInt8((chunkX << 4) | chunkY, 5); // Coords header.writeUInt16BE(newChunkData.length, 6); // Length appendBuffers.push(header, newChunkData); hasChanges = true; } else { // Existing chunk - compare current tile state with new chunk data const newPixels = await renderChunkToPixels(newChunkData); let oldPixels: Uint16Array; if (currentTilePixels) { // Extract current chunk area from the rendered tile oldPixels = extractChunkFromTile(currentTilePixels, chunkX, chunkY); } else { // Fallback to black pixels if tile couldn't be rendered oldPixels = new Uint16Array(32 * 32); } const changes = this.comparePixels(oldPixels, newPixels, chunkX, chunkY); if (changes.length > 0) { this.logger.info(`Adding ${changes.length} pixel changes for chunk ${chunkName} to tile`); const changeset = this.createPixelChangeset(changes, newChunk.tick); if (changeset.length > 0) { appendBuffers.push(changeset); hasChanges = true; } } } } if (!hasChanges) { return; } // Append new data to existing tile (never modify existing data) return Buffer.concat([existingTile, ...appendBuffers]); } async loadExistingTagContent() { try { if (!await fs.pathExists(this.chartTagsPath)) { return; } const files = await fs.readdir(this.chartTagsPath); const tagFiles = files.filter(file => file.endsWith('_chart_tags.json')); for (const file of tagFiles) { const fileKey = file.replace('_chart_tags.json', ''); const filePath = path.join(this.chartTagsPath, file); if (!this.lastSeenTagContent.has(fileKey)) { this.lastSeenTagContent.set(fileKey, new Map()); } const seenTags = this.lastSeenTagContent.get(fileKey)!; try { const content = await fs.readFile(filePath, 'utf-8'); const lines = content.trim().split('\n').filter(line => line.trim()); for (const line of lines) { try { const tagData = JSON.parse(line); if (typeof tagData.tag_number === 'number' && !tagData.end_tick) { // Only store the latest content for tags that haven't been deleted seenTags.set(tagData.tag_number, { position: tagData.position, text: tagData.text, icon: tagData.icon, last_user: tagData.last_user }); } } catch (parseErr) { this.logger.warn(`Failed to parse chart tag line in ${file}: ${parseErr}`); } } this.logger.info(`Loaded ${seenTags.size} existing tag contents from ${file}`); } catch (readErr) { this.logger.warn(`Failed to read chart tag file ${file}: ${readErr}`); } } } catch (err) { this.logger.error(`Error loading existing tag content: ${err}`); } } async saveChartTags() { if (this.chartTagQueues.size === 0) { return; } const savePromises = []; for (const [fileKey, tags] of this.chartTagQueues) { if (tags.length === 0 || this.savingChartTags.has(fileKey)) { // Remove empty queues to prevent repeated processing if (tags.length === 0 && !this.savingChartTags.has(fileKey)) { this.chartTagQueues.delete(fileKey); } continue; } const promise = (async () => { this.savingChartTags.add(fileKey); const tagsToSave = [...tags]; tags.length = 0; // Clear the queue try { const filePath = path.join(this.chartTagsPath, `${fileKey}_chart_tags.json`); // Convert chart tags to newline-delimited JSON format const jsonLines = tagsToSave.map(tag => JSON.stringify(tag)).join('\n'); await fs.appendFile(filePath, jsonLines + '\n'); // Mark tags as successfully saved if (!this.lastSeenTagContent.has(fileKey)) { this.lastSeenTagContent.set(fileKey, new Map()); } const seenTags = this.lastSeenTagContent.get(fileKey)!; for (const tag of tagsToSave) { seenTags.set(tag.tag_number, { position: tag.position, text: tag.text, icon: tag.icon, last_user: tag.last_user }); } this.logger.info(`Saved ${tagsToSave.length} chart tag entries to ${fileKey}`); } catch (err) { this.logger.error(`Error saving chart tags for ${fileKey}: ${err}`); // Re-queue the failed tags tags.unshift(...tagsToSave); } finally { this.savingChartTags.delete(fileKey); } })(); savePromises.push(promise); } await Promise.all(savePromises); } async handleChartTagDataEvent(event: ChartTagDataEvent) { try { const { instance_id, tag_data } = event; // Create file key based on instance, surface, and force const fileKey = `${instance_id}_${tag_data.surface}_${tag_data.force}`; // Initialize seen tags map if it doesn't exist if (!this.lastSeenTagContent.has(fileKey)) { this.lastSeenTagContent.set(fileKey, new Map()); } // Check for duplicate content, but allow deletion events (end_tick set) even if we've seen the tag before const seenTags = this.lastSeenTagContent.get(fileKey)!; const lastSeenContent = seenTags.get(tag_data.tag_number); // For deletion events (end_tick set), always allow through if (tag_data.end_tick !== undefined) { // Don't skip deletion events } else if (lastSeenContent) { // For creation/modification events, check if content has actually changed const contentUnchanged = lastSeenContent.position[0] === tag_data.position[0] && lastSeenContent.position[1] === tag_data.position[1] && lastSeenContent.text === tag_data.text && JSON.stringify(lastSeenContent.icon) === JSON.stringify(tag_data.icon) && lastSeenContent.last_user === tag_data.last_user; if (contentUnchanged) { return; // Skip if content hasn't changed } } // Add instance_id to the tag data for storage const enrichedTagData = { ...tag_data, instance_id }; // Initialize queue if it doesn't exist if (!this.chartTagQueues.has(fileKey)) { this.chartTagQueues.set(fileKey, []); } // Queue for batch saving this.chartTagQueues.get(fileKey)!.push(enrichedTagData); // Send live update to connected web clients const updateEvent = new ChartTagDataEvent(instance_id, tag_data); // Broadcast to web clients (overriding the formal src/dst routing) this.controller.sendTo(new lib.Address(lib.Address.broadcast, lib.Address.control), updateEvent); } catch (err) { this.logger.error(`Error processing chart tag data: ${err}`); } } async handleTileDataEvent(event: TileDataEvent) { try { const { instance_id, surface, force, x, y, tick, chunk } = event; // Calculate tile coordinates for 8x8 chunks per tile (256x256 pixels) const tileX = Math.floor(x / 32 / 8); const tileY = Math.floor(y / 32 / 8); const tileName = `${instance_id}_${surface}_${force}_${tileX}_${tileY}.bin`; // Fix negative modulo results by using proper modulo operation (0-7 range for 8x8) const chunkX = ((Math.floor(x / 32) % 8) + 8) % 8; const chunkY = ((Math.floor(y / 32) % 8) + 8) % 8; const chunkName = `${chunkX}_${chunkY}`; if (!this.chunkSavingQueue.has(tileName)) { this.chunkSavingQueue.set(tileName, new Map()); } this.chunkSavingQueue.get(tileName)!.set(chunkName, { data: chunk, tick }); // Send live update to connected web clients const updateEvent = new TileDataEvent( instance_id, surface, force, x, y, tick, chunk ); // Broadcast to web clients (overriding the formal src/dst routing) this.controller.sendTo(new lib.Address(lib.Address.broadcast, lib.Address.control), updateEvent); } catch (err) { this.logger.error(`Error processing tile data: ${err}`); } } async handleGetRawTileRequest(request: GetRawTileRequest) { const { instance_id, surface, force, tile_x, tile_y, tick } = request; const tileName = `${instance_id}_${surface}_${force}_${tile_x}_${tile_y}.bin`; const tilePath = path.join(this.tilesPath, tileName); if (!await fs.pathExists(tilePath)) { return { tile_data: null, tick: tick || 0 }; } try { const tileData = await fs.readFile(tilePath); const base64Data = tileData.toString('base64'); return { tile_data: base64Data }; } catch (err) { this.logger.error(`Error reading tile file: ${err}`); return { tile_data: null }; } } async handleGetChartTagsRequest(request: GetChartTagsRequest) { const { instance_id, surface, force } = request; const fileKey = `${instance_id}_${surface}_${force}`; const filePath = path.join(this.chartTagsPath, `${fileKey}_chart_tags.json`); if (!await fs.pathExists(filePath)) { return { chart_tags: [] }; } try { const content = await fs.readFile(filePath, 'utf-8'); const lines = content.trim().split('\n').filter(line => line.trim()); const chartTags: ChartTagData[] = []; for (const line of lines) { try { const tagData = JSON.parse(line); chartTags.push(tagData); } catch (parseErr) { this.logger.warn(`Failed to parse chart tag line: ${parseErr}`); } } return { chart_tags: chartTags }; } catch (err) { this.logger.error(`Error reading chart tag file: ${err}`); return { chart_tags: [] }; } } }