mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 23:14:53 +09:00
Calculate and store deltas for changes
This commit is contained in:
1 parent
a516b6b129
commit
626188dd16
2 files changed
+316
-121
No files matched your search
+314
-119
@@ -14,6 +14,12 @@ import sharp from "sharp";
|
||||
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 = "";
|
||||
@@ -49,6 +55,101 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
// Render chunk data to get RGB565 pixel array for comparison
|
||||
private async renderChunkToPixels(chunkData: Buffer): Promise<Uint16Array> {
|
||||
try {
|
||||
const decompressed = await inflateAsync(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] = decompressed.readUInt16LE(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) {
|
||||
this.logger.error(`Failed to decompress chunk data: ${decompressError}`);
|
||||
// Return black pixels on error
|
||||
return new Uint16Array(32 * 32);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -105,110 +206,25 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
}
|
||||
|
||||
async renderTile(tileData: Buffer): Promise<Buffer> {
|
||||
const tileSize = 256;
|
||||
const canvas = sharp({
|
||||
create: {
|
||||
width: tileSize,
|
||||
height: tileSize,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
// Use renderTileToPixels to get the current state, then convert to image
|
||||
const currentPixels = await this.renderTileToPixels(tileData);
|
||||
|
||||
// Convert RGB565 pixel data to RGBA buffer for Sharp
|
||||
const raw = Buffer.alloc(256 * 256 * 4);
|
||||
for (let i = 0; i < 256 * 256; i++) {
|
||||
const rgb565Value = currentPixels[i];
|
||||
const [r, g, b] = this.rgb565ToRgb888(rgb565Value);
|
||||
const bufferIndex = i * 4;
|
||||
raw[bufferIndex] = r;
|
||||
raw[bufferIndex + 1] = g;
|
||||
raw[bufferIndex + 2] = b;
|
||||
raw[bufferIndex + 3] = 255; // Alpha
|
||||
}
|
||||
|
||||
return canvas.composite(compositeImages).png().toBuffer();
|
||||
// Create and return the image directly
|
||||
return sharp(raw, {
|
||||
raw: { width: 256, height: 256, channels: 4 }
|
||||
}).png().toBuffer();
|
||||
}
|
||||
|
||||
async saveTiles() {
|
||||
@@ -235,7 +251,9 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
existingTile = await fs.readFile(tilePath);
|
||||
}
|
||||
const newTile = await this.updateTile(existingTile, chunks);
|
||||
await fs.writeFile(tilePath, newTile);
|
||||
if (newTile && newTile.length > 0) {
|
||||
await fs.writeFile(tilePath, newTile);
|
||||
}
|
||||
})();
|
||||
savePromises.push(promise);
|
||||
}
|
||||
@@ -247,14 +265,26 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
async updateTile(existingTile: Buffer, newChunks: Map<string, { data: ChartData, tick: number }>): Promise<Buffer> {
|
||||
async updateTile(existingTile: Buffer, newChunks: Map<string, { data: ChartData, tick: number }>): Promise<Buffer | undefined> {
|
||||
const existingChunks = new Map<string, { tick: number, data: Buffer }>();
|
||||
|
||||
// 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);
|
||||
@@ -262,6 +292,11 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
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}`;
|
||||
@@ -270,32 +305,192 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
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 length = existingTile.readUInt16BE(offset);
|
||||
const pixelCount = existingTile.readUInt16BE(offset);
|
||||
offset += 2;
|
||||
offset += length; // Skip pixel data
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
// Render the current state of the entire tile if we have existing data
|
||||
let currentTilePixels: Uint16Array | null = null;
|
||||
if (existingTile.length > 0) {
|
||||
currentTilePixels = await this.renderTileToPixels(existingTile);
|
||||
}
|
||||
|
||||
const newTileParts: Buffer[] = [];
|
||||
for (const [chunkName, chunk] of existingChunks) {
|
||||
// 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);
|
||||
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);
|
||||
|
||||
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 this.renderChunkToPixels(newChunkData);
|
||||
|
||||
let oldPixels: Uint16Array;
|
||||
if (currentTilePixels) {
|
||||
// Extract current chunk area from the rendered tile
|
||||
oldPixels = this.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Buffer.concat(newTileParts);
|
||||
if (!hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Append new data to existing tile (never modify existing data)
|
||||
return Buffer.concat([existingTile, ...appendBuffers]);
|
||||
}
|
||||
|
||||
// Render tile data to pixels
|
||||
private async renderTileToPixels(tileData: Buffer): Promise<Uint16Array> {
|
||||
const currentPixels = new Uint16Array(256 * 256); // 256x256 tile
|
||||
|
||||
let offset = 0;
|
||||
while (offset < tileData.length) {
|
||||
if (offset + 1 > tileData.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const type = tileData.readUInt8(offset);
|
||||
offset += 1;
|
||||
|
||||
if (type === 1) { // Chunk
|
||||
if (offset + 7 > tileData.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const tick = tileData.readUInt32BE(offset);
|
||||
offset += 4;
|
||||
const chunkCoordsByte = tileData.readUInt8(offset);
|
||||
offset += 1;
|
||||
const length = tileData.readUInt16BE(offset);
|
||||
offset += 2;
|
||||
|
||||
if (offset + length > tileData.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const chunkX = chunkCoordsByte >> 4;
|
||||
const chunkY = chunkCoordsByte & 0x0F;
|
||||
const chunkData = tileData.slice(offset, offset + length);
|
||||
|
||||
try {
|
||||
const chunkPixels = await this.renderChunkToPixels(chunkData);
|
||||
|
||||
// Update current pixel state
|
||||
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];
|
||||
}
|
||||
}
|
||||
} catch (decompressError) {
|
||||
this.logger.error(`Failed to decompress chunk data: ${decompressError}`);
|
||||
}
|
||||
|
||||
offset += length;
|
||||
} else if (type === 2) { // Pixels
|
||||
if (offset + 6 > tileData.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const tick = tileData.readUInt32BE(offset);
|
||||
offset += 4;
|
||||
const pixelCount = tileData.readUInt16BE(offset);
|
||||
offset += 2;
|
||||
|
||||
const expectedDataLength = pixelCount * 6;
|
||||
if (offset + expectedDataLength > tileData.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply pixel changes to current state
|
||||
for (let i = 0; i < pixelCount; i++) {
|
||||
const pixelOffset = offset + i * 6;
|
||||
const x = tileData.readUInt8(pixelOffset);
|
||||
const y = tileData.readUInt8(pixelOffset + 1);
|
||||
const newColor = tileData.readUInt16BE(pixelOffset + 2);
|
||||
// oldColor at pixelOffset + 4 is not needed for rendering
|
||||
|
||||
// Update current pixel state
|
||||
if (x < 256 && y < 256) {
|
||||
const tileIndex = y * 256 + x;
|
||||
currentPixels[tileIndex] = newColor;
|
||||
}
|
||||
}
|
||||
|
||||
offset += expectedDataLength;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return currentPixels;
|
||||
}
|
||||
|
||||
// Extract a 32x32 chunk area from a 256x256 tile
|
||||
private 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;
|
||||
}
|
||||
|
||||
async handleTileDataEvent(event: TileDataEvent) {
|
||||
|
||||
@@ -24,8 +24,8 @@ export type ChartData = Static<typeof ChartDataSchema>;
|
||||
export class TileDataEvent {
|
||||
declare ["constructor"]: typeof TileDataEvent;
|
||||
static type = "event" as const;
|
||||
static src = "instance" as const;
|
||||
static dst = "controller" as const;
|
||||
static src = ["instance", "controller"] as const;
|
||||
static dst = ["controller", "control"] as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = null;
|
||||
|
||||
|
||||
Reference in new issue
Block a user