diff --git a/plugins/minimap/controller.ts b/plugins/minimap/controller.ts index 7b1aa982..1dacd45a 100644 --- a/plugins/minimap/controller.ts +++ b/plugins/minimap/controller.ts @@ -9,23 +9,18 @@ import * as fs from "fs-extra"; import * as path from "path"; import sharp from "sharp"; -const TILE_SIZE = 512; +const CHUNK_SIZE = 32; -interface PixelUpdate { - x: number; - y: number; - rgba: [number, number, number, number]; -} export class ControllerPlugin extends BaseControllerPlugin { private tilesPath: string = ""; - private fileLocks = new Map[]>(); - private pendingUpdates = new Map>(); + // 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_tiles" + "minimap_chunks" ); await fs.ensureDir(this.tilesPath); @@ -37,50 +32,100 @@ export class ControllerPlugin extends BaseControllerPlugin { this.setupTileRoutes(); } - // Convert RGB565 to RGB888 values + // Convert RGB565 to RGB888 values using Factorio's exact method private rgb565ToRgb888(rgb565Value: number): [number, number, number] { - const r = Math.floor(((rgb565Value >> 11) & 0x1F) * 255 / 31); - const g = Math.floor(((rgb565Value >> 5) & 0x3F) * 255 / 63); - const b = Math.floor((rgb565Value & 0x1F) * 255 / 31); + // 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 - const createBlackImage = async () => { + // Create black placeholder image for 512x512 tiles + const createBlackTile = async () => { return await sharp({ create: { - width: 256, - height: 256, + 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 + // Serve chart tiles with surface and force support - now serving 512x512 tiles app.get("/api/minimap/chart/:surface/:force/:z/:x/:y.png", async (req, res) => { try { const { surface, force, z, x, y } = req.params; - const filename = `${surface}_${force}_z${z}x${x}y${y}.png`; - const filePath = path.resolve(this.tilesPath, filename); + const tileX = parseInt(x); + const tileY = parseInt(y); - if (await fs.pathExists(filePath)) { - const file = await fs.readFile(filePath); + // Each 512x512 tile contains 16x16 chunks of 32x32 pixels + const chunksPerTile = 16; + const tileSize = 512; + + // Calculate the range of 32x32 chunks 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 = `${surface}_${force}_${chunkX*32}_${chunkY*32}.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(file); + res.send(mergedImage); } else { - const blackImage = await createBlackImage(); + // No chunks found, return black tile res.setHeader("Content-Type", "image/png"); - res.send(blackImage); + res.send(await createBlackTile()); } } catch (err) { this.logger.error(`Error serving chart tile: ${err}`); - const blackImage = await createBlackImage(); + + // Return black tile on error res.setHeader("Content-Type", "image/png"); - res.send(blackImage); + res.send(await createBlackTile()); } }); @@ -129,125 +174,80 @@ export class ControllerPlugin extends BaseControllerPlugin { return; } - const size = 32; - const [originX, originY] = position; - const updates = new Map>(); + const [chunkX, chunkY] = position; // Process each surface/force combination for (const chart of chartData) { const { surface, force, chart_data } = chart; - // Process each pixel in the 32x32 chunk - for (let i = 0; i < chart_data.length; i += 2) { - const byte1 = chart_data.charCodeAt(i); - const byte2 = chart_data.charCodeAt(i + 1); - - if (isNaN(byte1) || isNaN(byte2)) { - continue; - } - - const rgb565Value = byte1 + (byte2 << 8); - const [r, g, b] = this.rgb565ToRgb888(rgb565Value); - - // Calculate pixel position within the chunk - const pixelIndex = i / 2; - const x = pixelIndex % size; - const y = Math.floor(pixelIndex / size); - - const worldX = originX + x; - const worldY = originY + y; - - // Calculate which tile this pixel belongs to - const tileX = Math.floor(worldX / TILE_SIZE) + (worldX < 0 ? -1 : 0); - const tileY = Math.floor(worldY / TILE_SIZE) + (worldY < 0 ? -1 : 0); - - // Use surface and force in the filename for differentiation - const filename = `${surface}_${force}_z10x${tileX}y${tileY}.png`; - - if (!updates.has(filename)) { - updates.set(filename, new Set()); - } - - updates.get(filename)!.add({ - x: ((worldX % TILE_SIZE) + TILE_SIZE) % TILE_SIZE, - y: ((worldY % TILE_SIZE) + TILE_SIZE) % TILE_SIZE, - rgba: [r, g, b, 255], // Full alpha for chart data - }); - } - } - - // Apply updates to tiles - for (const [filename, pixels] of updates) { - await this.updateTileImage(filename, pixels); + // Create filename for this chunk + const filename = `${surface}_${force}_${chunkX}_${chunkY}.png`; + + // Process the 32x32 chunk directly + await this.saveChunkImage(filename, chart_data); } } - private async updateTileImage(filename: string, pixels: Set) { + private async saveChunkImage(filename: string, chartData: string) { const imagePath = path.resolve(this.tilesPath, filename); - - // Handle file locking to prevent concurrent writes - if (!this.fileLocks.has(imagePath)) { - this.fileLocks.set(imagePath, []); - } - - const locks = this.fileLocks.get(imagePath)!; - if (locks.length > 0) { - await Promise.all(locks); - } - - const updatePromise = this.performTileUpdate(imagePath, pixels); - locks.push(updatePromise); - - try { - await updatePromise; - } finally { - const index = locks.indexOf(updatePromise); - if (index > -1) { - locks.splice(index, 1); + + // Create raw image buffer for 32x32 RGBA + const raw = Buffer.alloc(CHUNK_SIZE * CHUNK_SIZE * 4); + + // Convert string to proper binary buffer first + const binaryBuffer = Buffer.from(chartData, 'latin1'); // or 'latin1' + const dataLength = Math.min(binaryBuffer.length, CHUNK_SIZE * CHUNK_SIZE * 2); + + this.logger.info(`Processing chunk ${filename}, buffer length: ${binaryBuffer.length}, expected: ${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; } - if (locks.length === 0) { - this.fileLocks.delete(imagePath); + + // 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); + + // Debug first few pixels + if (i < 10) { + this.logger.info(`Pixel ${i/2}: bytes[${byte1.toString(16)}, ${byte2.toString(16)}] -> RGB565: 0x${rgb565Value.toString(16)} -> RGB[${r}, ${g}, ${b}]`); + } + + // 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) } } - } - - private async performTileUpdate(imagePath: string, pixels: Set) { - let raw: Buffer; + // Save the chunk image directly try { - // Load existing image or create new one - raw = await sharp(imagePath).raw().toBuffer(); - } catch (err) { - // Create new blank tile - raw = await sharp({ - create: { - width: TILE_SIZE, - height: TILE_SIZE, + await sharp(raw, { + raw: { + width: CHUNK_SIZE, + height: CHUNK_SIZE, channels: 4, - background: { r: 20, g: 20, b: 20, alpha: 0 }, }, - }).raw().toBuffer(); + }).png().toFile(imagePath); + } catch (err) { + this.logger.error(`Error saving chunk image ${filename}: ${err}`); } - - // Apply pixel updates - for (const pixel of pixels) { - const index = (pixel.y * TILE_SIZE + pixel.x) * 4; - if (index >= 0 && index < raw.length - 3) { - raw[index] = pixel.rgba[0]; // R - raw[index + 1] = pixel.rgba[1]; // G - raw[index + 2] = pixel.rgba[2]; // B - raw[index + 3] = pixel.rgba[3]; // A - } - } - - // Save updated image - await sharp(raw, { - raw: { - width: TILE_SIZE, - height: TILE_SIZE, - channels: 4, - }, - }).png().toFile(imagePath); } async handleGetInstanceBoundsRequest(request: GetInstanceBoundsRequest) { diff --git a/plugins/minimap/web/MinimapPage.tsx b/plugins/minimap/web/MinimapPage.tsx index 802aaf46..2850646c 100644 --- a/plugins/minimap/web/MinimapPage.tsx +++ b/plugins/minimap/web/MinimapPage.tsx @@ -150,6 +150,7 @@ export default function MinimapPage() { maxNativeZoom={10} minNativeZoom={10} opacity={1} + tileSize={512} />