From a516b6b12992ce8a336aecd891bcba978e8d1a8d Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Fri, 18 Jul 2025 17:32:45 +0200 Subject: [PATCH] Implement new tile storage format with tentative timelapse support --- plugins/minimap/README.md | 4 +- plugins/minimap/controller.ts | 461 ++++++++++++---------- plugins/minimap/index.ts | 2 - plugins/minimap/instance.ts | 11 +- plugins/minimap/messages.ts | 82 ++-- plugins/minimap/web/CanvasMinimapPage.tsx | 83 +++- plugins/minimap/web/index.tsx | 22 +- 7 files changed, 358 insertions(+), 307 deletions(-) diff --git a/plugins/minimap/README.md b/plugins/minimap/README.md index e4fe258c..dd637bdc 100644 --- a/plugins/minimap/README.md +++ b/plugins/minimap/README.md @@ -39,17 +39,17 @@ The tile format is as follows: - 2 = pixels For chunk: -- 2 byte: length, number of data bytes - 4 byte: math.floor(tick / 60) - 4 bits: chunk relative X coordinate - 4 bits: chunk relative Y coordinate +- 2 byte: length, number of data bytes - n bytes of 32x32 rgb565, with deflate compression Type 1 chunks are assumed to only occur upon first exploration - further changes usually happen as individual pixels. For that reason, there is no rewind information tied to chunks, and they revert to black when rewinded. For pixels: -- 2 byte: length, number of pixels - 4 byte: math.floor(tick / 60) +- 2 byte: length, number of pixels - Each pixel: - 1 byte X coordinate - 1 byte Y coordinate diff --git a/plugins/minimap/controller.ts b/plugins/minimap/controller.ts index 3d71b817..1de98b53 100644 --- a/plugins/minimap/controller.ts +++ b/plugins/minimap/controller.ts @@ -4,7 +4,6 @@ import { TileDataEvent, GetInstanceBoundsRequest, ChartData, - ChunkUpdateEvent, } from "./messages"; import * as fs from "fs-extra"; import * as path from "path"; @@ -18,22 +17,27 @@ const inflateAsync = promisify(zlib.inflate); export class ControllerPlugin extends BaseControllerPlugin { private tilesPath: string = ""; + private chunkSavingQueue = new Map>(); + private savingTiles: boolean = false; // 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" + "minimap_tiles" ); 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(); + + setInterval(() => { + this.saveTiles().catch(err => this.logger.error(`Error saving tiles: ${err}`)); + }, 5000); } // Convert RGB565 to RGB888 values using Factorio's exact method @@ -48,268 +52,287 @@ export class ControllerPlugin extends BaseControllerPlugin { 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) => { + 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); - - // 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++; - } - } + + 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; } - - 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 + + const tileData = await fs.readFile(tilePath); + const image = await this.renderTile(tileData); res.setHeader("Content-Type", "image/png"); - res.send(await createBlackTile()); + res.send(image); + + } catch (err) { + this.logger.error(`Error serving tile: ${err}`); + res.status(500).send("Error serving tile"); } }); - // List available surfaces and forces app.get("/api/minimap/surfaces", async (req, res) => { try { - const instances = new Set(); + const files = await fs.readdir(this.tilesPath); 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); - } - } - } + + 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({ - 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" }); + this.logger.error(`Error listing surfaces and forces: ${err}`); + res.status(500).json({ error: "Failed to list surfaces and forces" }); } }); } - async handleTileDataEvent(event: TileDataEvent) { - try { - const { type, data, position, instanceId } = event; + async renderTile(tileData: Buffer): Promise { + const tileSize = 256; + const canvas = sharp({ + create: { + width: tileSize, + height: tileSize, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }); - if (type === "chart") { - await this.processChartData(data as ChartData[], position, instanceId); + const compositeImages: any[] = []; + let offset = 0; + while (offset < tileData.length) { + if (offset + 1 > tileData.length) { + this.logger.error(`Invalid tile data: cannot read type at offset ${offset}`); + break; + } + + const type = tileData.readUInt8(offset); + offset += 1; // After type + + if (type === 1) { // Chunk + // Check if we have enough bytes for the header + if (offset + 7 > tileData.length) { + this.logger.error(`Invalid tile data: insufficient header data at offset ${offset}`); + break; + } + + const tick = tileData.readUInt32BE(offset); // Read tick + offset += 4; // After tick + const chunkCoordsByte = tileData.readUInt8(offset); // Read coords + offset += 1; // After coords + const length = tileData.readUInt16BE(offset); // Read length + offset += 2; // After length + + // Check if we have enough bytes for the data + if (offset + length > tileData.length) { + this.logger.error(`Invalid tile data: insufficient chunk data at offset ${offset}, need ${length} bytes but only ${tileData.length - offset} available`); + break; + } + + const chunkX = chunkCoordsByte >> 4; + const chunkY = chunkCoordsByte & 0x0F; + const chunkData = tileData.slice(offset, offset + length); + + try { + const decompressed = await inflateAsync(chunkData); + + const raw = Buffer.alloc(32 * 32 * 4); + // Initialize buffer to black (all zeros with alpha=255) + for (let i = 0; i < 32 * 32; i++) { + raw[i * 4 + 3] = 255; // Set alpha to 255 for all pixels + } + + // Ensure we don't read beyond available data + const maxPixels = Math.min(decompressed.length / 2, 32 * 32); + + for (let i = 0; i < maxPixels * 2; i += 2) { + const rgb565Value = decompressed.readUInt16LE(i); + const [r, g, b] = this.rgb565ToRgb888(rgb565Value); + const pixelIndex = i / 2; + const bufferIndex = pixelIndex * 4; + raw[bufferIndex] = r; + raw[bufferIndex + 1] = g; + raw[bufferIndex + 2] = b; + raw[bufferIndex + 3] = 255; + } + + compositeImages.push({ + input: await sharp(raw, { raw: { width: 32, height: 32, channels: 4 } }).png().toBuffer(), + left: chunkX * 32, + top: chunkY * 32, + }); + } catch (decompressError) { + this.logger.error(`Failed to decompress chunk data: ${decompressError}`); + } + + offset += length; // Advance offset by the data block length + } else if (type === 2) { // Pixels + // Check if we have enough bytes for the header + if (offset + 6 > tileData.length) { + this.logger.error(`Invalid tile data: insufficient pixel header data at offset ${offset}`); + break; + } + + // Read tick (4 bytes) and length (2 bytes) first + offset += 4; // Skip tick + const length = tileData.readUInt16BE(offset); // Read length + offset += 2; // After length + + // Check if we have enough bytes for the data + if (offset + length > tileData.length) { + this.logger.error(`Invalid tile data: insufficient pixel data at offset ${offset}, need ${length} bytes but only ${tileData.length - offset} available`); + break; + } + + offset += length; // Advance offset by the data block length + } else { + this.logger.error(`Unknown tile data type: ${type} at offset ${offset}`); + break; } - } catch (err) { - this.logger.error(`Error processing tile data: ${err}`); } + + return canvas.composite(compositeImages).png().toBuffer(); } - private async processChartData(chartData: ChartData[], position: [number, number], instanceId: number) { - if (!position || !instanceId) { - this.logger.error("Chart data requires position and instanceId"); + async saveTiles() { + if (this.savingTiles) { return; } - const [worldX, worldY] = position; - - // Convert world coordinates to chunk coordinates - const chunkX = Math.floor(worldX / 32); - const chunkY = Math.floor(worldY / 32); + const queue = this.chunkSavingQueue; + if (queue.size === 0) { + return; + } + this.logger.info(`Starting save for ${queue.size} tiles.`); - // 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)); + 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); + 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; } } - 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'); + async updateTile(existingTile: Buffer, newChunks: Map): Promise { + const existingChunks = new Map(); + let offset = 0; + while (offset < existingTile.length) { + const type = existingTile.readUInt8(offset); + offset += 1; - // 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 (type === 1) { // Chunk + const tick = existingTile.readUInt32BE(offset); + offset += 4; + const chunkCoordsByte = existingTile.readUInt8(offset); + offset += 1; + const length = existingTile.readUInt16BE(offset); + offset += 2; - if (byte1 === undefined || byte2 === undefined) { - continue; - } + 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 }); - // 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) - } + } else if (type === 2) { // Pixels + const tick = existingTile.readUInt32BE(offset); + offset += 4; + const length = existingTile.readUInt16BE(offset); + offset += 2; + offset += length; // Skip pixel data } + } - // 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`); + for (const [chunkName, chunk] of newChunks) { + // chart_data is already compressed by Factorio, just decode base64 + const chunkData = Buffer.from(chunk.data.chart_data, "base64"); + existingChunks.set(chunkName, { tick: chunk.tick, data: chunkData }); + } - // Return base64 encoded ImageData for live updates - return Buffer.from(JSON.stringify(imageDataJson)).toString('base64'); + const newTileParts: Buffer[] = []; + for (const [chunkName, chunk] of existingChunks) { + const [chunkX, chunkY] = chunkName.split("_").map(Number); + const header = Buffer.alloc(8); // Type (1) + Tick (4) + Coords (1) + Length (2) + header.writeUInt8(1, 0); // Type + header.writeUInt32BE(Math.floor(chunk.tick / 60), 1); // Tick + header.writeUInt8((chunkX << 4) | chunkY, 5); // Coords + header.writeUInt16BE(chunk.data.length, 6); // Length of data block only + newTileParts.push(header, chunk.data); + } + + return Buffer.concat(newTileParts); + } + + 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 chunk image ${filename}: ${err}`); - return null; + this.logger.error(`Error processing tile data: ${err}`); } } diff --git a/plugins/minimap/index.ts b/plugins/minimap/index.ts index 499e610c..3a0c2626 100644 --- a/plugins/minimap/index.ts +++ b/plugins/minimap/index.ts @@ -3,7 +3,6 @@ import * as lib from "@clusterio/lib"; import { TileDataEvent, GetInstanceBoundsRequest, - ChunkUpdateEvent, } from "./messages"; // Define permissions @@ -25,7 +24,6 @@ export const plugin: lib.PluginDeclaration = { messages: [ TileDataEvent, GetInstanceBoundsRequest, - ChunkUpdateEvent, ], routes: ["/minimap"], }; diff --git a/plugins/minimap/instance.ts b/plugins/minimap/instance.ts index 68e4b1a8..89ca7968 100644 --- a/plugins/minimap/instance.ts +++ b/plugins/minimap/instance.ts @@ -39,10 +39,13 @@ export class InstancePlugin extends BaseInstancePlugin { // Send chart data to controller const event = new TileDataEvent( - "chart", - chartData, - position!, - this.instance.config.get("instance.id") + this.instance.config.get("instance.id"), + chartData[0].surface, + chartData[0].force, + position[0], + position[1], + 0, // TODO: Get tick from lua + chartData[0] ); return this.instance.sendTo("controller", event); diff --git a/plugins/minimap/messages.ts b/plugins/minimap/messages.ts index 79ded760..48233238 100644 --- a/plugins/minimap/messages.ts +++ b/plugins/minimap/messages.ts @@ -10,6 +10,17 @@ export const ChartDataSchema = Type.Object({ export type ChartData = Static; +/** + * TileDataEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients + * + * Contains compressed RGB565 chart data from Factorio's internal mapping system. + * When sent from instance to controller: triggers storage in persistent tile files. + * When sent from controller to web clients: enables real-time minimap updates. + * + * Data flow: + * - Lua module -> Instance plugin -> Controller plugin (for persistence) + * - Controller plugin -> Web UI plugin -> Canvas renderer (for live updates) + */ export class TileDataEvent { declare ["constructor"]: typeof TileDataEvent; static type = "event" as const; @@ -19,65 +30,34 @@ export class TileDataEvent { static permission = null; constructor( - public type: "chart", - public data: ChartData[], - public position: [number, number], - public instanceId: number - ) { - } + public instance_id: number, + public surface: string, + public force: string, + public x: number, + public y: number, + public tick: number, + public chunk: ChartData, + ) { } static jsonSchema = Type.Object({ - "type": Type.Union([Type.Literal("chart")]), - "data": Type.Array(ChartDataSchema), - "position": Type.Array(Type.Number(), { minItems: 2, maxItems: 2 }), - "instanceId": Type.Number(), + "instance_id": Type.Number(), + "surface": Type.String(), + "force": Type.String(), + "x": Type.Number(), + "y": Type.Number(), + "tick": Type.Number(), + "chunk": ChartDataSchema, }); static fromJSON(json: Static) { return new this( - json.type, - json.data, - json.position as [number, number], - json.instanceId, - ); - } -} - -export class ChunkUpdateEvent { - declare ["constructor"]: typeof ChunkUpdateEvent; - static type = "event" as const; - static src = "controller" as const; - static dst = "control" as const; - static plugin = "minimap" as const; - static permission = "minimap.view"; - - constructor( - public instanceId: number, - public surface: string, - public force: string, - public chunkX: number, - public chunkY: number, - public imageData: string, // base64 encoded ImageData - ) { - } - - static jsonSchema = Type.Object({ - "instanceId": Type.Number(), - "surface": Type.String(), - "force": Type.String(), - "chunkX": Type.Number(), - "chunkY": Type.Number(), - "imageData": Type.String(), - }); - - static fromJSON(json: Static) { - return new this( - json.instanceId, + json.instance_id, json.surface, json.force, - json.chunkX, - json.chunkY, - json.imageData, + json.x, + json.y, + json.tick, + json.chunk ); } } diff --git a/plugins/minimap/web/CanvasMinimapPage.tsx b/plugins/minimap/web/CanvasMinimapPage.tsx index 41d587ca..a8583101 100644 --- a/plugins/minimap/web/CanvasMinimapPage.tsx +++ b/plugins/minimap/web/CanvasMinimapPage.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect, useContext, useRef, useCallback } from "react"; import { Row, Col, Button, Card, Space, Select } from "antd"; import { ControlContext } from "@clusterio/web_ui"; -import { GetInstanceBoundsRequest, ChunkUpdateEvent } from "../messages"; +import { GetInstanceBoundsRequest, TileDataEvent } from "../messages"; +import * as zlib from "zlib"; interface Instance { instanceId: number; @@ -26,8 +27,53 @@ interface ViewState { } const CHUNK_SIZE = 32; // 32x32 pixels per chunk -const TILE_SIZE = 512; // 512x512 pixels per tile (16x16 chunks) -const CHUNKS_PER_TILE = 16; +const TILE_SIZE = 256; // 256x256 pixels per tile (8x8 chunks) +const CHUNKS_PER_TILE = 8; + +// Convert RGB565 to RGB888 values using Factorio's exact method +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]; +} + +// Convert raw chart data to ImageData +async function chartDataToImageData(chartData: string): Promise { + // Decode base64 and decompress + const compressedData = Buffer.from(chartData, "base64"); + const decompressed = zlib.inflateSync(compressedData); + + // Create RGBA image data (32x32 pixels) + const imageData = new Uint8ClampedArray(32 * 32 * 4); + + // Ensure we don't read beyond available data + const maxPixels = Math.min(decompressed.length / 2, 32 * 32); + + for (let i = 0; i < maxPixels * 2; i += 2) { + const rgb565Value = decompressed.readUInt16LE(i); + const [r, g, b] = rgb565ToRgb888(rgb565Value); + const pixelIndex = i / 2; + const bufferIndex = pixelIndex * 4; + + imageData[bufferIndex] = r; + imageData[bufferIndex + 1] = g; + imageData[bufferIndex + 2] = b; + imageData[bufferIndex + 3] = 255; // Alpha + } + + // Fill remaining pixels with black if we have less data than expected + for (let pixelIndex = maxPixels; pixelIndex < 32 * 32; pixelIndex++) { + const bufferIndex = pixelIndex * 4; + imageData[bufferIndex] = 0; // R + imageData[bufferIndex + 1] = 0; // G + imageData[bufferIndex + 2] = 0; // B + imageData[bufferIndex + 3] = 255; // A + } + + return new ImageData(imageData, 32, 32); +} export default function CanvasMinimapPage() { const [instances, setInstances] = useState([]); @@ -310,7 +356,7 @@ export default function CanvasMinimapPage() { try { // Load the entire tile from server const response = await fetch( - `${window.location.origin}/api/minimap/chart/${selectedInstance}/${selectedSurface}/${selectedForce}/10/${tileX}/${tileY}.png` + `${window.location.origin}/api/minimap/tile/${selectedInstance}/${selectedSurface}/${selectedForce}/10/${tileX}/${tileY}.png` ); if (!response.ok) { @@ -703,23 +749,24 @@ export default function CanvasMinimapPage() { return; } - const handleChunkUpdate = (event: ChunkUpdateEvent) => { + const handleChunkUpdate = (event: TileDataEvent) => { // Only process updates for the currently selected instance/surface/force - if (event.instanceId === selectedInstance && + if (event.instance_id === selectedInstance && event.surface === selectedSurface && event.force === selectedForce) { try { - // Decode the base64 ImageData - const imageDataJson = JSON.parse(atob(event.imageData)); - const imageData = new ImageData( - new Uint8ClampedArray(imageDataJson.data), - imageDataJson.width, - imageDataJson.height - ); - - // Update the chunk in our cache - updateChunk(event.chunkX, event.chunkY, imageData); + // Calculate chunk coordinates from world coordinates + const chunkX = Math.floor(event.x / 32); + const chunkY = Math.floor(event.y / 32); + + // Convert raw chart data to ImageData + chartDataToImageData(event.chunk.chart_data).then(imageData => { + // Update the chunk in our cache + updateChunk(chunkX, chunkY, imageData); + }).catch(err => { + console.error("Failed to convert chart data to ImageData:", err); + }); } catch (err) { console.error("Failed to process live chunk update:", err); } @@ -727,10 +774,10 @@ export default function CanvasMinimapPage() { }; // Subscribe to chunk update events through the plugin - plugin.onChunkUpdate(handleChunkUpdate); + plugin.onTileUpdate(handleChunkUpdate); return () => { - plugin.offChunkUpdate(handleChunkUpdate); + plugin.offTileUpdate(handleChunkUpdate); }; }, [selectedInstance, selectedSurface, selectedForce, updateChunk, control]); diff --git a/plugins/minimap/web/index.tsx b/plugins/minimap/web/index.tsx index 74a6e831..8aeb44b8 100644 --- a/plugins/minimap/web/index.tsx +++ b/plugins/minimap/web/index.tsx @@ -1,10 +1,10 @@ import React from "react"; import { BaseWebPlugin } from "@clusterio/web_ui"; import CanvasMinimapPage from "./CanvasMinimapPage"; -import { ChunkUpdateEvent } from "../messages"; +import { TileDataEvent } from "../messages"; export class WebPlugin extends BaseWebPlugin { - chunkUpdateCallbacks: Array<(event: ChunkUpdateEvent) => void> = []; + tileUpdateCallbacks: Array<(event: TileDataEvent) => void> = []; async init() { this.pages = [ @@ -16,26 +16,26 @@ export class WebPlugin extends BaseWebPlugin { }, ]; - // Handle chunk update events from the controller - this.control.handle(ChunkUpdateEvent, this.handleChunkUpdateEvent.bind(this)); + // Handle tile update events from the controller + this.control.handle(TileDataEvent, this.handleTileDataEvent.bind(this)); } - async handleChunkUpdateEvent(event: ChunkUpdateEvent) { + async handleTileDataEvent(event: TileDataEvent) { // Notify all registered callbacks - for (let callback of this.chunkUpdateCallbacks) { + for (let callback of this.tileUpdateCallbacks) { callback(event); } } - onChunkUpdate(callback: (event: ChunkUpdateEvent) => void) { - this.chunkUpdateCallbacks.push(callback); + onTileUpdate(callback: (event: TileDataEvent) => void) { + this.tileUpdateCallbacks.push(callback); } - offChunkUpdate(callback: (event: ChunkUpdateEvent) => void) { - let index = this.chunkUpdateCallbacks.lastIndexOf(callback); + offTileUpdate(callback: (event: TileDataEvent) => void) { + let index = this.tileUpdateCallbacks.lastIndexOf(callback); if (index === -1) { throw new Error("callback is not registered"); } - this.chunkUpdateCallbacks.splice(index, 1); + this.tileUpdateCallbacks.splice(index, 1); } }