iobroker.roborock
Version:
1,508 lines (1,368 loc) • 56.2 kB
text/typescript
/**
* Q10 (ss09 / YxMap) real-time map format.
* App plugin: parserPublicRealTimeMap – header 28 bytes (version, mapId, type, width, height, ox, oy, resolution, charge*, pixLen, pixLzLen), then raw or LZ4 pixels.
* Version 1: 1 byte per pixel, high 6 bits = room ID (0–31), low 2 bits = type (0=floor, 1=wall, 3=outWall).
*/
import type { B01MapData, B01Area, B01Carpet, B01EntityPosition, B01PathPoint, B01Point } from "../b01/types";
import type {
Q10DevicePoint,
Q10RuntimeStatePatch,
Q10SourceArea,
Q10SourceData,
Q10SourcePathPoint,
Q10SourceRoom,
Q10SourceSuspectedPoint
} from "./types";
const Q10_HEADER_LEN = 28;
function readU16BE(buf: Buffer, offset: number): number {
return buf.readUInt16BE(offset);
}
function readU16LE(buf: Buffer, offset: number): number {
return buf.readUInt16LE(offset);
}
function readU32BE(buf: Buffer, offset: number): number {
return buf.readUInt32BE(offset);
}
/** JX byteArrToPointArr / parserObstacle / parserSkipClean: DataView.getInt16 ohne littleEndian → big-endian. */
function readI16BE(buf: Buffer, offset: number): number {
return buf.readInt16BE(offset);
}
const MAX_MAP_PIXELS = 2000 * 2000; // sanity cap
const Q10_COMPAT_FALLBACKS_ENABLED = process.env.ROBOROCK_Q10_COMPAT_FALLBACKS === "1";
interface Q10RoomModel {
roomID: number;
roomType: number;
roomMaterial: number;
roomNameDataStr: string;
cleanOrder: number;
cleanCount: number;
funLevel: number;
waterLevel: number;
cleanType: number;
cleanLine: number;
}
function u8ToHex(arr: Uint8Array): string {
return Buffer.from(arr).toString("hex");
}
function fixLikelyUtf8Mojibake(value: string): string {
if (!value || !/[ÃÂÐ]/u.test(value)) return value;
try {
const repaired = Buffer.from(value, "latin1").toString("utf8").replace(/\0+$/g, "").trim();
return repaired || value;
} catch {
return value;
}
}
function decodeRoomNameFromRaw(raw: Uint8Array): string {
// Original Q10 app/plugin decodes roomNameDataStr directly from the raw byte field.
// The first byte usually contains the string length; the app retries with shorter
// lengths until a non-empty string can be decoded.
try {
let len = raw.length > 0 ? raw[0] : 0;
while (len > 0 && len <= 19 && raw.length >= 1 + len) {
const decoded = fixLikelyUtf8Mojibake(Buffer.from(raw.slice(1, 1 + len)).toString("utf8").replace(/\0+$/g, "").trim());
if (decoded) return decoded;
len -= 1;
}
return "";
} catch {
return "";
}
}
/** JX parserRoomData1 port: parse room properties block right after pix area for v1/v2/v3. */
function parseRoomData1(data: Buffer): Q10RoomModel[] {
const roomModels: Q10RoomModel[] = [];
if (!data || data.length < 2) return roomModels;
let pos = 0;
pos += 1; // region_id (unused)
const regionNum = data[pos];
pos += 1;
if (data.length < 2 + regionNum * (26 + 20 + 1)) return roomModels;
for (let i = 0; i < regionNum; i++) {
if (pos + 26 > data.length) break;
const props = data.subarray(pos, pos + 26);
let p = 0;
const roomID = props.readUInt16BE(p);
p += 2;
const roomType = props[p];
p += 1;
let cleanOrder = props.readUInt16BE(p);
p += 2;
if (cleanOrder === 0xffff) cleanOrder = -1;
const cleanCount = props.readUInt16BE(p);
p += 2;
let cleanType = props[p];
p += 1;
if (cleanType === 0xff) cleanType = -1;
let funLevel = props[p];
p += 1;
if (funLevel === 0xff) funLevel = -1;
let waterLevel = props[p];
p += 1;
if (waterLevel === 0xff) waterLevel = -1;
const roomMaterial = props[p];
p += 1;
let cleanLine = props[p];
p += 1;
if (cleanLine < 0 || cleanLine > 2) cleanLine = 0;
pos += 26;
if (pos + 20 > data.length) break;
const roomNameData = new Uint8Array(data.subarray(pos, pos + 20));
pos += 20;
if (pos + 1 > data.length) break;
const verticesNum = data[pos];
pos += 1;
const verticesBytes = verticesNum * 2 * 2;
if (pos + verticesBytes > data.length) break;
pos += verticesBytes; // vertices not used in current renderer
roomModels.push({
roomID,
roomType,
roomMaterial,
roomNameDataStr: u8ToHex(roomNameData),
cleanOrder,
cleanCount,
funLevel,
waterLevel,
cleanType,
cleanLine
});
}
return roomModels;
}
/** Returns width×height = pixLen with a plausible aspect (prefer width ≥ height). Used when header dimensions are wrong (e.g. 4439×1). */
function factorPairForPixelCount(pixLen: number): { width: number; height: number } | null {
if (pixLen <= 0 || pixLen > MAX_MAP_PIXELS) return null;
const sqrt = Math.sqrt(pixLen);
for (let h = Math.max(1, Math.floor(sqrt) - 10); h <= Math.min(pixLen, Math.ceil(sqrt) + 10); h++) {
if (pixLen % h !== 0) continue;
const w = pixLen / h;
if (w > 0 && w <= 2000 && h <= 2000) return { width: w, height: h };
}
// fallback: try small factors
for (let h = 1; h <= Math.min(100, pixLen); h++) {
if (pixLen % h !== 0) continue;
const w = pixLen / h;
if (w <= 2000 && h <= 2000) return { width: w, height: h };
}
return null;
}
/** Prefer near-square dimensions for room maps when exact factor pair is too strip-like (e.g. 503×7). Returns width×height >= pixLen. */
function nearSquareDimensions(pixLen: number): { width: number; height: number } {
const h = Math.max(1, Math.ceil(Math.sqrt(pixLen)));
const w = Math.ceil(pixLen / h);
return { width: Math.min(w, 2000), height: Math.min(h, 2000) };
}
/** Decompress LZ4 block without optional native dependencies. */
function tryDecompressQ10Lz4Block(compressed: Uint8Array, expectedSize: number, reasonOut?: { reason: string }): Buffer | null {
const input = Buffer.isBuffer(compressed) ? compressed : Buffer.from(compressed);
const output = Buffer.alloc(expectedSize);
const minMatch = 4;
const canFast = typeof output.copyWithin === "function" && typeof output.fill === "function";
let inPos = 0;
let outPos = 0;
try {
while (inPos < input.length) {
const token = input[inPos++];
let literalLength = token >> 4;
if (literalLength === 0x0f) {
while (inPos < input.length) {
const extension = input[inPos++];
literalLength += extension;
if (extension !== 0xff) break;
}
}
if (inPos + literalLength > input.length || outPos + literalLength > output.length) {
if (reasonOut) reasonOut.reason = "invalid literal block";
return null;
}
input.copy(output, outPos, inPos, inPos + literalLength);
inPos += literalLength;
outPos += literalLength;
if (inPos >= input.length) break;
if (inPos + 2 > input.length) {
if (reasonOut) reasonOut.reason = "invalid match offset";
return null;
}
let matchLength = token & 0x0f;
const matchOffset = input[inPos++] | (input[inPos++] << 8);
if (matchOffset <= 0 || matchOffset > outPos) {
if (reasonOut) reasonOut.reason = "invalid match distance";
return null;
}
if (matchLength === 0x0f) {
while (inPos < input.length) {
const extension = input[inPos++];
matchLength += extension;
if (extension !== 0xff) break;
}
}
matchLength += minMatch;
if (outPos + matchLength > output.length) {
if (reasonOut) reasonOut.reason = "invalid match length";
return null;
}
if (canFast && matchOffset === 1) {
output.fill(output[outPos - 1] | 0, outPos, outPos + matchLength);
outPos += matchLength;
continue;
}
if (canFast && matchOffset > matchLength && matchLength > 31) {
output.copyWithin(outPos, outPos - matchOffset, outPos - matchOffset + matchLength);
outPos += matchLength;
continue;
}
let copyPos = outPos - matchOffset;
const copyEnd = copyPos + matchLength;
while (copyPos < copyEnd) {
output[outPos++] = output[copyPos++] | 0;
}
}
return output;
} catch (e: any) {
if (reasonOut) reasonOut.reason = e?.message || "decodeBlock threw";
return null;
}
}
export function decompressQ10Lz4Block(compressed: Uint8Array, expectedSize: number): Buffer {
const reasonOut = { reason: "unknown error" };
const decompressed = tryDecompressQ10Lz4Block(compressed, expectedSize, reasonOut);
if (!decompressed) {
throw new Error(`Invalid Q10 LZ4 block: ${reasonOut.reason}`);
}
return decompressed;
}
/**
* Canonical Q10 map header layout from the original app:
* 0 version, 1-4 mapId, 5 type, 6-7 width, 8-9 height, 10-13 ox/oy,
* 14-15 resolution, 16-21 charge*, 22-25 pixLen, 26-27 pixLzLen.
*
* The original parser reads multi-byte fields big-endian. Additional header
* candidates are retained only for explicit compatibility mode.
*/
interface Q10Header {
version: number;
mapId: number;
mapWidth: number;
mapHeight: number;
pixLen: number;
pixLzLen: number;
mapOx: number;
mapOy: number;
mapResolution: number;
chargeX: number;
chargeY: number;
chargerDirection: number;
offset: number;
}
function parseQ10HeaderAt(buf: Buffer, offset: number, heightEndian: "le" | "be"): Q10Header {
if (buf.length < offset + Q10_HEADER_LEN) throw new Error("YxMap buffer too short");
const version = buf[offset];
const mapWidth = readU16BE(buf, offset + 6);
const mapHeight = heightEndian === "be" ? readU16BE(buf, offset + 8) : readU16LE(buf, offset + 8);
const chargeX = readU16BE(buf, offset + 16);
const chargeY = readU16BE(buf, offset + 18);
let chargerDirection = readU16BE(buf, offset + 20);
if (chargerDirection === 0xffff) chargerDirection = 0;
return {
version,
mapId: readU32BE(buf, offset + 1),
mapWidth,
mapHeight,
pixLen: readU32BE(buf, offset + 22),
pixLzLen: readU16BE(buf, offset + 26),
mapOx: readU16BE(buf, offset + 10) / 10,
mapOy: readU16BE(buf, offset + 12) / 10,
mapResolution: readU16BE(buf, offset + 14) / 100,
chargeX,
chargeY,
chargerDirection,
offset
};
}
function unwrapQ10OriginalMapPayload(buf: Buffer): Buffer | null {
if (!buf || buf.length < Q10_HEADER_LEN) return null;
if (
buf.length >= 1 + Q10_HEADER_LEN &&
(buf[0] === 1 || buf[0] === 3 || buf[0] === 4) &&
buf[1] >= 0 &&
buf[1] <= 3
) {
return buf.subarray(1);
}
if (buf[0] >= 0 && buf[0] <= 3) {
return buf;
}
return null;
}
function isStrictOriginalHeader(hdr: Q10Header, totalLen: number): boolean {
if (!hdr || hdr.version < 0 || hdr.version > 3) return false;
if (hdr.mapWidth <= 0 || hdr.mapWidth > 4096) return false;
if (hdr.mapHeight <= 0 || hdr.mapHeight > 4096) return false;
if (hdr.mapWidth * hdr.mapHeight > MAX_MAP_PIXELS) return false;
if (hdr.pixLen <= 0 || hdr.pixLen > MAX_MAP_PIXELS) return false;
if (hdr.pixLzLen < 0) return false;
if (hdr.mapResolution <= 0 || hdr.mapResolution > 1) return false;
const pixelStart = hdr.offset + Q10_HEADER_LEN;
if (pixelStart >= totalLen) return false;
if (hdr.pixLzLen > 0) {
return pixelStart + hdr.pixLzLen <= totalLen;
}
return pixelStart + hdr.pixLen <= totalLen;
}
function isPlausibleHeader(hdr: Q10Header, totalLen: number): boolean {
if (!hdr || hdr.version < 0 || hdr.version > 3) return false;
// Wie decode_q10_hex_to_png.js: innere Header (offset>0) dürfen kleinere Dim haben; pixLzLen>rem zulassen (Frames 9/10)
const minDim = hdr.offset > 0 ? 2 : 16;
if (hdr.mapWidth < minDim || hdr.mapWidth > 4096) return false;
if (hdr.mapHeight < minDim || hdr.mapHeight > 4096) return false;
if (hdr.pixLen <= 0 || hdr.pixLen > MAX_MAP_PIXELS) return false;
const rem = totalLen - (hdr.offset + Q10_HEADER_LEN);
if (rem <= 0) return false;
if (hdr.pixLzLen < 0) return false;
return true;
}
/** Build mapGrid from decompressed pixel buffer; version 0/1/2/3 (1 und 3 = gleiches Format). */
function buildGrid(hdr: Q10Header, src: Buffer): Buffer | null {
const { version, mapWidth, mapHeight } = hdr;
const size = mapWidth * mapHeight;
if (size > MAX_MAP_PIXELS) return null;
const mapGrid = Buffer.alloc(size);
if (version === 0) {
const pixByteCount = Math.floor(size / 4) + (size % 4 !== 0 ? 1 : 0);
for (let i = 0; i < pixByteCount && i < src.length; i++) {
const b = src[i];
const p0 = (b & 0xc0) >> 6, p1 = (b & 0x30) >> 4, p2 = (b & 0x0c) >> 2, p3 = b & 0x03;
const toVal = (p: number) => (p === 0 ? 1 : p === 1 ? 128 : 127);
if (i * 4 < size) mapGrid[i * 4] = toVal(p0);
if (i * 4 + 1 < size) mapGrid[i * 4 + 1] = toVal(p1);
if (i * 4 + 2 < size) mapGrid[i * 4 + 2] = toVal(p2);
if (i * 4 + 3 < size) mapGrid[i * 4 + 3] = toVal(p3);
}
} else if (version === 2) {
// Floor material / room variant: roomID = (b & 0xf8) >> 3, low 3 bits = type
for (let i = 0; i < src.length && i < size; i++) {
const b = src[i];
const roomID = (b & 0xf8) >> 3;
const s = b & 0x07;
if (roomID > 0 && roomID < 27) {
mapGrid[i] = roomID + 1;
} else {
if (s === 0b000) mapGrid[i] = 0;
else if (s === 0b001) mapGrid[i] = 128;
else if (s === 0b010 || s === 0b111) mapGrid[i] = 1;
else mapGrid[i] = 1;
}
}
} else {
// 1 und 3: gleiches Format (Partition, 1 Byte = 1 Pixel)
for (let i = 0; i < src.length && i < size; i++) {
const b = src[i];
const roomID = (b & 0xfc) >> 2;
const pointType = b & 0x03;
if (roomID >= 1 && roomID <= 31) {
mapGrid[i] = roomID + 1;
} else {
if (pointType === 0) mapGrid[i] = 1;
else if (pointType === 1) mapGrid[i] = 128;
else if (pointType === 3) mapGrid[i] = 127;
else mapGrid[i] = 1;
}
}
}
return mapGrid;
}
function pixelDataLengthForVersion(version: number, mapWidth: number, mapHeight: number): number {
if (version === 0) {
let pix = Math.floor((mapWidth * mapHeight) / 4);
if ((mapWidth * mapHeight) % 4 !== 0) pix += 1;
return pix;
}
return mapWidth * mapHeight;
}
/** Try to decode one candidate: decompress from hdr.offset and build grid. */
function tryDecodeCandidate(buf: Buffer, hdr: Q10Header, compatFallbacks = false): B01MapData | null {
const pixelStart = hdr.offset + Q10_HEADER_LEN;
const actualPayloadLen = buf.length - pixelStart;
let pixLen = hdr.pixLen;
let pixLzLen = hdr.pixLzLen;
let mapWidth = hdr.mapWidth;
let mapHeight = hdr.mapHeight;
// Compatibility mode only: recover malformed field captures we saw in older reverse-engineering samples.
if (compatFallbacks && hdr.offset === 0 && actualPayloadLen >= 100 && actualPayloadLen <= 500000) {
const expectedPixels = mapWidth * mapHeight;
if (pixLen === 0 || pixLen > 500000 || (pixLzLen !== 0 && pixLen > 500000)) {
pixLen = actualPayloadLen;
pixLzLen = 0;
} else if (
pixLzLen > 0 &&
pixLzLen > actualPayloadLen &&
expectedPixels >= 100 &&
expectedPixels <= MAX_MAP_PIXELS &&
(pixLen !== expectedPixels || pixLen < 100)
) {
pixLen = expectedPixels;
pixLzLen = actualPayloadLen;
}
// If dimensions still wrong (e.g. 1×124), try factor pair / near-square.
if (mapWidth * mapHeight !== pixLen || mapHeight === 1 || mapWidth === 1) {
const pair = factorPairForPixelCount(pixLen);
const ratio = pair ? Math.max(pair.width / pair.height, pair.height / pair.width) : Infinity;
if (pair && ratio <= 15) {
mapWidth = pair.width;
mapHeight = pair.height;
} else {
const ns = nearSquareDimensions(pixLen);
mapWidth = ns.width;
mapHeight = ns.height;
}
}
}
if (mapWidth === 0 || mapHeight === 0 || pixLen === 0) return null;
if (mapWidth * mapHeight > MAX_MAP_PIXELS) return null;
const actualCompressedLen = Math.min(pixLzLen, buf.length - pixelStart);
let src: Buffer;
if (pixLzLen !== 0 && actualCompressedLen > 0) {
const compressed = buf.subarray(pixelStart, pixelStart + actualCompressedLen);
const lz4Reason = { reason: "" };
const decompressed = tryDecompressQ10Lz4Block(compressed, pixLen, lz4Reason);
// Compatibility mode only: accept shortened decompressed payloads from malformed captures.
if (compatFallbacks && decompressed && decompressed.length > 0 && decompressed.length !== pixLen && hdr.offset === 0) {
const expectedPixels = mapWidth * mapHeight;
if (decompressed.length < pixLen && expectedPixels >= 100 && decompressed.length <= MAX_MAP_PIXELS) {
pixLen = decompressed.length;
const n = pixLen;
if (n % 124 === 0 && n / 124 <= 2000) {
mapWidth = 124;
mapHeight = n / 124;
} else if (n % 256 === 0 && n / 256 <= 2000) {
mapWidth = 256;
mapHeight = n / 256;
} else {
const pair = factorPairForPixelCount(n);
if (pair) {
mapWidth = pair.width;
mapHeight = pair.height;
}
}
}
}
if (!decompressed || decompressed.length !== pixLen) return null;
src = decompressed;
} else {
if (pixelStart + pixLen > buf.length) return null;
src = buf.subarray(pixelStart, pixelStart + pixLen);
}
const size = mapWidth * mapHeight;
let mapGrid = buildGrid(
{ ...hdr, mapWidth, mapHeight, pixLen, pixLzLen },
src
);
if (!mapGrid || mapGrid.length !== size) return null;
// Compatibility mode only: recover obviously broken dimensions.
if (compatFallbacks && src.length > 0 && src.length < 0.1 * size) {
const n = src.length;
let pair: { width: number; height: number } | null = null;
if (n % 124 === 0 && n / 124 <= 2000) pair = { width: 124, height: n / 124 };
else if (n % 256 === 0 && n / 256 <= 2000) pair = { width: 256, height: n / 256 };
if (!pair) pair = factorPairForPixelCount(n);
const ratio = pair ? Math.max(pair.width / pair.height, pair.height / pair.width) : Infinity;
if (pair && ratio <= 15 && pair.width * pair.height === n) {
const newGrid = buildGrid(
{ ...hdr, mapWidth: pair.width, mapHeight: pair.height, pixLen: n, pixLzLen: hdr.pixLzLen },
src
);
if (newGrid && newGrid.length === n) {
mapWidth = pair.width;
mapHeight = pair.height;
pixLen = n;
mapGrid = newGrid;
}
}
}
const mapResolution = hdr.mapResolution;
const minX = hdr.mapOx;
const maxY = hdr.mapOy;
const minY = maxY - mapHeight * mapResolution;
const maxX = minX + mapWidth * mapResolution;
const q10SourceData: Q10SourceData = {
version: hdr.version,
mapId: hdr.mapId,
mapWidth,
mapHeight,
mapRate: mapResolution > 0 ? 1 / mapResolution : 0,
resolution: mapResolution,
xMin: minX,
yMin: maxY,
rooms: [],
eraseAreas: [],
virtualWalls: [],
forbidAreas: [],
mopAreas: [],
thresholdAreas: [],
carpetAreas: [],
pathPoints: [],
obstacles: [],
skipPoints: [],
suspectedPoints: [],
hasSelfIdentificationCarpet: false
};
const result: B01MapData = {
sourceFormat: "q10-raw",
header: {
sizeX: mapWidth,
sizeY: mapHeight,
minX,
minY,
maxX,
maxY,
resolution: mapResolution
},
mapGrid,
q10SourceData
};
// JX roomData1 is embedded after pixel area in decompressed payload for version != 0.
if (hdr.version !== 0) {
const pixDataLen = pixelDataLengthForVersion(hdr.version, mapWidth, mapHeight);
if (pixDataLen > 0 && pixDataLen < src.length) {
const roomData = src.subarray(pixDataLen, pixLen);
const roomModels = parseRoomData1(roomData);
if (roomModels.length) {
q10SourceData.rooms = roomModels.map((rm): Q10SourceRoom => ({
roomID: rm.roomID,
roomName: decodeRoomNameFromRaw(Buffer.from(rm.roomNameDataStr, "hex")),
roomNameDataStr: rm.roomNameDataStr,
roomType: rm.roomType,
roomMaterial: rm.roomMaterial,
cleanOrder: rm.cleanOrder,
cleanCount: rm.cleanCount,
funLevel: rm.funLevel,
waterLevel: rm.waterLevel,
cleanType: rm.cleanType,
cleanLine: rm.cleanLine
}));
result.rooms = roomModels.map((rm) => ({
roomId: rm.roomID,
roomName: decodeRoomNameFromRaw(Buffer.from(rm.roomNameDataStr, "hex")),
roomTypeId: rm.roomType,
gridValue: rm.roomID + 1,
cleanOrder: rm.cleanOrder,
cleanCount: rm.cleanCount,
cleanType: rm.cleanType,
funLevel: rm.funLevel,
waterLevel: rm.waterLevel,
material: rm.roomMaterial,
cleanLine: rm.cleanLine
}));
}
}
}
// module_973 / module_951:
// chargePosition stays in device coordinates (relative to x_min/y_min), while the
// shared B01 fields keep world coordinates for robotToPixel compatibility.
if (
(hdr.chargeX !== 0 || hdr.chargeY !== 0) &&
hdr.chargeX !== 0xffff &&
hdr.chargeY !== 0xffff
) {
const chargeDevicePoint = {
x: hdr.chargeX / 10 - hdr.mapOx,
y: hdr.mapOy - hdr.chargeY / 10,
phi: -hdr.chargerDirection
};
q10SourceData.chargePosition = chargeDevicePoint;
result.chargerPos = {
x: hdr.chargeX / 10,
y: hdr.chargeY / 10,
phi: -hdr.chargerDirection
};
}
// Trailing blocks after pixel data (erases, carpet, obstacle, skipClean)
const payloadEnd = pixelStart + (pixLzLen !== 0 && actualCompressedLen > 0 ? actualCompressedLen : pixLen);
if (payloadEnd < buf.length) {
try {
const trailing = parseQ10TrailingBlocks(buf, payloadEnd, hdr.mapOx, hdr.mapOy);
// 抹除区域 → wie App „Forbidden“ (rot); robotToPixel erwartet Weltkoordinaten (wie chargerPos)
if (trailing.eraseAreas?.length) {
q10SourceData.eraseAreas = trailing.eraseAreas;
result.recmForbitZone = trailing.eraseAreas.map((area) => q10DeviceAreaToWorld(minX, maxY, area));
}
if (trailing.obstaclePoints?.length) {
q10SourceData.obstacles = trailing.obstaclePoints.map((point) => ({ point, type: "obstacle" as const }));
result.obstaclePoints = trailing.obstaclePoints.map((point) => q10DevicePointToWorld(minX, maxY, point));
}
if (trailing.skipCleanPoints?.length) {
q10SourceData.skipPoints = trailing.skipCleanPoints.map((point) => ({ point, type: "skip" as const }));
result.skipCleanPoints = trailing.skipCleanPoints.map((point) => q10DevicePointToWorld(minX, maxY, point));
}
if (trailing.carpetGrid) {
q10SourceData.carpetGrid = trailing.carpetGrid;
q10SourceData.hasSelfIdentificationCarpet = true;
result.carpetGrid = trailing.carpetGrid;
}
q10SourceData.dataReadIdx = trailing.nextOffset;
const editTail = parseQ10EditTailSections(buf, trailing.nextOffset, hdr.mapOx, hdr.mapOy);
if (editTail.pathPoints.length) {
q10SourceData.pathPoints = editTail.pathPoints;
result.history = editTail.pathPoints.map((point) => ({
x: q10DevicePointToWorld(minX, maxY, point).x,
y: q10DevicePointToWorld(minX, maxY, point).y,
update: point.update
}));
if (editTail.pathPoints.length >= 2) {
const previous = editTail.pathPoints[editTail.pathPoints.length - 2];
const current = editTail.pathPoints[editTail.pathPoints.length - 1];
const heading = (Math.atan2(20, 0) - Math.atan2(current.x - previous.x, current.y - previous.y)) * 180 / Math.PI;
q10SourceData.robotPosition = { x: current.x, y: current.y, phi: heading };
result.robotPos = { ...q10DevicePointToWorld(minX, maxY, current), phi: heading };
}
}
if (editTail.virtualWalls.length) {
q10SourceData.virtualWalls = editTail.virtualWalls;
result.virtualWalls = editTail.virtualWalls.map((area) => q10DeviceAreaToWorld(minX, maxY, area));
}
if (editTail.forbidAreas.length) {
q10SourceData.forbidAreas = editTail.forbidAreas;
const forbidWorld = editTail.forbidAreas.map((area) => q10DeviceAreaToWorld(minX, maxY, area));
result.virtualWalls = [...(result.virtualWalls ?? []), ...forbidWorld];
}
if (editTail.mopZones.length) {
q10SourceData.mopAreas = editTail.mopZones;
result.areasInfo = editTail.mopZones.map((area) => q10DeviceAreaToWorld(minX, maxY, area));
}
if (editTail.thresholdZones.length) {
q10SourceData.thresholdAreas = editTail.thresholdZones;
const thresholdWorld = editTail.thresholdZones.map((area) => q10DeviceAreaToWorld(minX, maxY, area));
result.recmForbitZone = [...(result.recmForbitZone ?? []), ...thresholdWorld];
}
if (editTail.carpetAreas.length) {
q10SourceData.carpetAreas = editTail.carpetAreas;
result.carpetInfo = editTail.carpetAreas.map((area) => q10DeviceCarpetToWorld(minX, maxY, area));
}
} catch {
// Optional tail sections vary across firmware revisions.
// Keep the decoded raster/map usable even if a trailing edit block is malformed.
q10SourceData.dataReadIdx = payloadEnd;
}
}
result.q10RawOverlayCounts = {
virtualWalls: q10SourceData.virtualWalls.length,
forbidAreas: q10SourceData.forbidAreas.length,
mopAreas: q10SourceData.mopAreas.length,
thresholdAreas: q10SourceData.thresholdAreas.length,
eraseAreas: q10SourceData.eraseAreas.length,
carpetAreas: q10SourceData.carpetAreas.length
};
result.q10SourceData = q10SourceData;
const overlayFields = rebuildQ10OverlayFields(result, q10SourceData);
result.virtualWalls = overlayFields.virtualWalls;
result.areasInfo = overlayFields.areasInfo;
result.recmForbitZone = overlayFields.recmForbitZone;
result.carpetInfo = overlayFields.carpetInfo;
return result;
}
/** Scan buffer for inner YxMap block (container frames: map at offset 1, 2, ...). */
function findInnerMapCandidate(buf: Buffer): B01MapData | null {
const endianModes: Array<"le" | "be"> = ["le", "be"];
let best: { mapData: B01MapData; score: number } | null = null;
for (let offset = 0; offset <= buf.length - Q10_HEADER_LEN; offset++) {
for (const heightEndian of endianModes) {
const decoded = tryDecodeHeaderCandidate(buf, offset, heightEndian);
if (!decoded) continue;
if (!best || decoded.score > best.score) {
best = decoded;
}
}
}
return best?.mapData ?? null;
}
/** Trailing blocks after pixel data (app order: erasesArea, carpet, obstacle, skipClean). */
interface Q10Trailing {
eraseAreas?: Q10SourceArea[];
obstaclePoints?: Q10DevicePoint[];
skipCleanPoints?: Q10DevicePoint[];
carpetGrid?: Buffer;
nextOffset: number;
}
interface Q10EditTailSections {
pathPoints: Q10SourcePathPoint[];
virtualWalls: Q10SourceArea[];
forbidAreas: Q10SourceArea[];
mopZones: Q10SourceArea[];
thresholdZones: Q10SourceArea[];
carpetAreas: Q10SourceArea[];
}
const MAX_Q10_PATH_POINTS = 100_000;
function q10DevicePoint(dx: number, dy: number): Q10DevicePoint {
return {
x: dx,
y: dy
};
}
function q10PathTypeToHistoryUpdate(type: number | undefined): number {
if (type === 0) return 6;
if (type === 1) return 4;
if (type === 2 || type === 4) return 5;
return 0;
}
function byte2ToShortBE(high: number, low: number): number {
const bytes = Buffer.from([high & 0xff, low & 0xff]);
return bytes.readInt16BE(0);
}
function yxPathRleDecompress(compressed: Buffer, compressedLen: number, outLen: number): Buffer {
const inputLen = Math.min(compressedLen >>> 0, compressed.length);
const output = Buffer.alloc(outLen);
const matchMinLength = 4;
let inPos = 0;
let outPos = 0;
const canFast = typeof output.copyWithin === "function" && typeof output.fill === "function";
while (inPos < inputLen) {
const token = compressed[inPos++];
let literalLength = token >> 4;
if (literalLength > 0) {
if (literalLength === 15) {
do {
literalLength += compressed[inPos];
} while (compressed[inPos++] === 255);
}
const literalEnd = inPos + literalLength;
while (inPos < literalEnd) output[outPos++] = compressed[inPos++];
}
if (inPos >= inputLen) break;
let matchLength = token & 15;
const offset = compressed[inPos++] | (compressed[inPos++] << 8);
if (matchLength === 15) {
do {
matchLength += compressed[inPos];
} while (compressed[inPos++] === 255);
}
matchLength += matchMinLength;
if (canFast && offset === 1) {
output.fill(output[outPos - 1] | 0, outPos, outPos + matchLength);
outPos += matchLength;
} else if (canFast && offset > matchLength && matchLength > 31) {
output.copyWithin(outPos, outPos - offset, outPos - offset + matchLength);
outPos += matchLength;
} else {
let srcPos = outPos - offset;
const srcEnd = srcPos + matchLength;
while (srcPos < srcEnd) output[outPos++] = output[srcPos++] | 0;
}
}
return output;
}
function parseQ10PathBlock(
buf: Buffer,
offset: number
): { points: Q10SourcePathPoint[]; nextOffset: number } | null {
try {
const payloadLen = getQ10PathPayloadLengthAt(buf, offset);
if (payloadLen === 0 || offset + 13 > buf.length) return null;
let pos = offset;
const pathVersion = buf[pos];
pos += 1;
pos += 2;
pos += 1;
const pathMode = buf[pos];
pos += 1;
const pointCount = readU32BE(buf, pos);
pos += 4;
pos += 2;
const compressedLen = readU16BE(buf, pos);
pos += 2;
if (pointCount > MAX_Q10_PATH_POINTS) return null;
const rawPointBytes = pointCount * 4;
let payload: Buffer;
if (compressedLen === 0) {
if (pos + rawPointBytes > buf.length) return null;
payload = buf.subarray(pos, pos + rawPointBytes);
} else {
if (pos + compressedLen > buf.length) return null;
payload = yxPathRleDecompress(buf.subarray(pos, pos + compressedLen), compressedLen, rawPointBytes);
if (!payload || payload.length < rawPointBytes) return null;
}
const points: Q10SourcePathPoint[] = [];
for (let index = 0; index < pointCount; index++) {
const pointOffset = index * 4;
let x = byte2ToShortBE(payload[pointOffset], payload[pointOffset + 1]);
let y = byte2ToShortBE(payload[pointOffset + 2], payload[pointOffset + 3]);
if (pathVersion === 0) {
x /= 10;
y /= 10;
} else if (pathVersion === 1) {
x = x / 10 / 2;
y = y / 10 / 2;
} else {
x /= 10;
y /= 10;
}
const type = pathMode === 2 ? ((payload[pointOffset + 1] & 0x03) << 2) + (payload[pointOffset + 3] & 0x03) : 0;
points.push({
x,
y,
type,
update: q10PathTypeToHistoryUpdate(type)
});
}
return {
points,
nextOffset: offset + payloadLen
};
} catch {
return null;
}
}
function q10DevicePointToWorld(mapXMin: number, mapYMin: number, point: Q10DevicePoint): B01Point {
return {
x: mapXMin + point.x,
y: mapYMin - point.y
};
}
function q10DeviceAreaToWorld(mapXMin: number, mapYMin: number, area: Q10SourceArea): B01Area {
return {
type: area.areaType,
area_type: area.areaType,
name: area.name,
points: area.points.map((point) => q10DevicePointToWorld(mapXMin, mapYMin, point))
};
}
function q10DeviceCarpetToWorld(mapXMin: number, mapYMin: number, area: Q10SourceArea): B01Carpet {
return {
id: area.id,
points: area.points.map((point) => q10DevicePointToWorld(mapXMin, mapYMin, point))
};
}
function cloneDevicePoint(point: Q10DevicePoint): Q10DevicePoint {
return {
x: point.x,
y: point.y
};
}
function clonePathPoint(point: Q10SourcePathPoint): Q10SourcePathPoint {
return {
x: point.x,
y: point.y,
type: point.type,
update: point.update
};
}
function cloneSuspectedPoint(point: Q10SourceSuspectedPoint): Q10SourceSuspectedPoint {
return {
type: point.type,
point: cloneDevicePoint(point.point)
};
}
function cloneSourceArea(area: Q10SourceArea): Q10SourceArea {
return {
id: area.id,
type: area.type,
areaType: area.areaType,
name: area.name,
points: area.points.map((point) => cloneDevicePoint(point))
};
}
function buildQ10PathState(
mapData: B01MapData,
pathPoints: Q10SourcePathPoint[]
): {
history: B01PathPoint[];
robotPos?: B01EntityPosition;
robotPosition?: Q10DevicePoint & { phi?: number };
} {
const history = pathPoints.map((point) => ({
x: q10DevicePointToWorld(mapData.header.minX, mapData.header.maxY, point).x,
y: q10DevicePointToWorld(mapData.header.minX, mapData.header.maxY, point).y,
update: point.update
}));
if (pathPoints.length < 2) {
return {
history
};
}
const previous = pathPoints[pathPoints.length - 2];
const current = pathPoints[pathPoints.length - 1];
const heading = (Math.atan2(20, 0) - Math.atan2(current.x - previous.x, current.y - previous.y)) * 180 / Math.PI;
return {
history,
robotPos: {
...q10DevicePointToWorld(mapData.header.minX, mapData.header.maxY, current),
phi: heading
},
robotPosition: {
x: current.x,
y: current.y,
phi: heading
}
};
}
function rebuildQ10OverlayFields(mapData: B01MapData, source: Q10SourceData): Pick<B01MapData, "virtualWalls" | "areasInfo" | "recmForbitZone" | "carpetInfo"> {
const eraseWorld = source.eraseAreas.map((area) => q10DeviceAreaToWorld(mapData.header.minX, mapData.header.maxY, area));
const virtualWallWorld = source.virtualWalls.map((area) => q10DeviceAreaToWorld(mapData.header.minX, mapData.header.maxY, area));
const forbidWorld = source.forbidAreas.map((area) => q10DeviceAreaToWorld(mapData.header.minX, mapData.header.maxY, area));
const mopWorld = source.mopAreas.map((area) => q10DeviceAreaToWorld(mapData.header.minX, mapData.header.maxY, area));
const thresholdWorld = source.thresholdAreas.map((area) => q10DeviceAreaToWorld(mapData.header.minX, mapData.header.maxY, area));
const carpetWorld = source.carpetAreas.map((area) => q10DeviceCarpetToWorld(mapData.header.minX, mapData.header.maxY, area));
return {
virtualWalls: virtualWallWorld.length || forbidWorld.length ? [...virtualWallWorld, ...forbidWorld] : undefined,
areasInfo: mopWorld.length ? mopWorld : undefined,
recmForbitZone: eraseWorld.length || thresholdWorld.length ? [...eraseWorld, ...thresholdWorld] : undefined,
carpetInfo: carpetWorld.length ? carpetWorld : undefined
};
}
export function parseQ10PathOnlyToSourcePoints(buf: Buffer): Q10SourcePathPoint[] | null {
if (!buf || buf.length < 13) return null;
// Original app path parser receives `parserPathData(l)` with the leading blob byte
// already stripped (`l = a.slice(1)`). We unwrap the transport payload here so the
// parser itself stays aligned with the original data shape.
const stripped =
getQ10PathPayloadLengthAt(buf, 0) > 0
? buf
: buf[0] === 2 && getQ10PathPayloadLengthAt(buf, 1) > 0
? buf.subarray(1)
: null;
if (!stripped) return null;
const pathBlock = parseQ10PathBlock(stripped, 0);
return pathBlock?.points?.length ? pathBlock.points : null;
}
export function applyQ10PathOnlyToB01(previous: B01MapData, pathPoints: Q10SourcePathPoint[]): B01MapData {
if (!previous.q10SourceData || !pathPoints.length) return previous;
const nextSource: Q10SourceData = {
...previous.q10SourceData,
pathPoints: pathPoints.map((point) => clonePathPoint(point))
};
const pathState = buildQ10PathState(previous, nextSource.pathPoints);
return {
...previous,
history: pathState.history,
robotPos: pathState.robotPos,
q10SourceData: {
...nextSource,
robotPosition: pathState.robotPosition
}
};
}
export function mergeQ10PersistentState(current: B01MapData, previous?: B01MapData | null): B01MapData {
if (!current?.q10SourceData || !previous?.q10SourceData) return current;
const currentSource = current.q10SourceData;
const previousSource = previous.q10SourceData;
if (!currentSource.mapId) return current;
if (previousSource.mapId !== 0 && currentSource.mapId !== previousSource.mapId) return current;
let changed = false;
const nextSource: Q10SourceData = {
...currentSource
};
if (!nextSource.virtualWalls.length && previousSource.virtualWalls.length) {
nextSource.virtualWalls = previousSource.virtualWalls.map((area) => cloneSourceArea(area));
changed = true;
}
if (!nextSource.forbidAreas.length && previousSource.forbidAreas.length) {
nextSource.forbidAreas = previousSource.forbidAreas.map((area) => cloneSourceArea(area));
changed = true;
}
if (!nextSource.mopAreas.length && previousSource.mopAreas.length) {
nextSource.mopAreas = previousSource.mopAreas.map((area) => cloneSourceArea(area));
changed = true;
}
if (!nextSource.thresholdAreas.length && previousSource.thresholdAreas.length) {
nextSource.thresholdAreas = previousSource.thresholdAreas.map((area) => cloneSourceArea(area));
changed = true;
}
if (!nextSource.carpetAreas.length && previousSource.carpetAreas.length) {
nextSource.carpetAreas = previousSource.carpetAreas.map((area) => cloneSourceArea(area));
changed = true;
}
if (!nextSource.tempRoomColorPlanStr && previousSource.tempRoomColorPlanStr) {
nextSource.tempRoomColorPlanStr = previousSource.tempRoomColorPlanStr;
changed = true;
}
if (!nextSource.tempClipEraseRoomColorPlanStr && previousSource.tempClipEraseRoomColorPlanStr) {
nextSource.tempClipEraseRoomColorPlanStr = previousSource.tempClipEraseRoomColorPlanStr;
changed = true;
}
if (!changed) return current;
const overlayFields = rebuildQ10OverlayFields(current, nextSource);
const pathState = nextSource.pathPoints.length ? buildQ10PathState(current, nextSource.pathPoints) : null;
return {
...current,
history: pathState?.history ?? current.history,
robotPos: pathState?.robotPos ?? current.robotPos,
virtualWalls: overlayFields.virtualWalls,
areasInfo: overlayFields.areasInfo,
recmForbitZone: overlayFields.recmForbitZone,
carpetInfo: overlayFields.carpetInfo,
q10SourceData: {
...nextSource,
robotPosition: pathState?.robotPosition ?? nextSource.robotPosition
}
};
}
export function mergeQ10RuntimeState(current: B01MapData, previous?: B01MapData | null): B01MapData {
if (!current?.q10SourceData || !previous?.q10SourceData) return current;
const currentSource = current.q10SourceData;
const previousSource = previous.q10SourceData;
if (!currentSource.mapId) return current;
if (previousSource.mapId !== 0 && currentSource.mapId !== previousSource.mapId) return current;
let changed = false;
const nextSource: Q10SourceData = {
...currentSource
};
if (!nextSource.pathPoints.length && previousSource.pathPoints.length) {
nextSource.pathPoints = previousSource.pathPoints.map((point) => clonePathPoint(point));
changed = true;
}
if (!nextSource.virtualWalls.length && previousSource.virtualWalls.length) {
nextSource.virtualWalls = previousSource.virtualWalls.map((area) => cloneSourceArea(area));
changed = true;
}
if (!nextSource.forbidAreas.length && previousSource.forbidAreas.length) {
nextSource.forbidAreas = previousSource.forbidAreas.map((area) => cloneSourceArea(area));
changed = true;
}
if (!nextSource.mopAreas.length && previousSource.mopAreas.length) {
nextSource.mopAreas = previousSource.mopAreas.map((area) => cloneSourceArea(area));
changed = true;
}
if (!nextSource.thresholdAreas.length && previousSource.thresholdAreas.length) {
nextSource.thresholdAreas = previousSource.thresholdAreas.map((area) => cloneSourceArea(area));
changed = true;
}
if (!nextSource.carpetAreas.length && previousSource.carpetAreas.length) {
nextSource.carpetAreas = previousSource.carpetAreas.map((area) => cloneSourceArea(area));
changed = true;
}
if (!nextSource.suspectedPoints.length && previousSource.suspectedPoints.length) {
nextSource.suspectedPoints = previousSource.suspectedPoints.map((point) => cloneSuspectedPoint(point));
changed = true;
}
if (!nextSource.tempRoomColorPlanStr && previousSource.tempRoomColorPlanStr) {
nextSource.tempRoomColorPlanStr = previousSource.tempRoomColorPlanStr;
changed = true;
}
if (!nextSource.tempClipEraseRoomColorPlanStr && previousSource.tempClipEraseRoomColorPlanStr) {
nextSource.tempClipEraseRoomColorPlanStr = previousSource.tempClipEraseRoomColorPlanStr;
changed = true;
}
if (!changed) return current;
const overlayFields = rebuildQ10OverlayFields(current, nextSource);
const pathState = nextSource.pathPoints.length ? buildQ10PathState(current, nextSource.pathPoints) : null;
return {
...current,
history: pathState?.history ?? current.history,
robotPos: pathState?.robotPos ?? current.robotPos,
virtualWalls: overlayFields.virtualWalls,
areasInfo: overlayFields.areasInfo,
recmForbitZone: overlayFields.recmForbitZone,
carpetInfo: overlayFields.carpetInfo,
q10SourceData: {
...nextSource,
robotPosition: pathState?.robotPosition ?? nextSource.robotPosition
}
};
}
export function applyQ10RuntimeStatePatch(current: B01MapData, patch: Q10RuntimeStatePatch): B01MapData {
if (!current?.q10SourceData) return current;
let changed = false;
const nextSource: Q10SourceData = {
...current.q10SourceData
};
if (patch.pathPoints) {
nextSource.pathPoints = patch.pathPoints.map((point) => clonePathPoint(point));
changed = true;
}
if (patch.virtualWalls) {
nextSource.virtualWalls = patch.virtualWalls.map((area) => cloneSourceArea(area));
changed = true;
}
if (patch.forbidAreas) {
nextSource.forbidAreas = patch.forbidAreas.map((area) => cloneSourceArea(area));
changed = true;
}
if (patch.mopAreas) {
nextSource.mopAreas = patch.mopAreas.map((area) => cloneSourceArea(area));
changed = true;
}
if (patch.thresholdAreas) {
nextSource.thresholdAreas = patch.thresholdAreas.map((area) => cloneSourceArea(area));
changed = true;
}
if (patch.carpetAreas) {
nextSource.carpetAreas = patch.carpetAreas.map((area) => cloneSourceArea(area));
changed = true;
}
if (patch.suspectedPoints) {
nextSource.suspectedPoints = patch.suspectedPoints.map((point) => cloneSuspectedPoint(point));
changed = true;
}
if (!changed) return current;
const overlayFields = rebuildQ10OverlayFields(current, nextSource);
const pathState = nextSource.pathPoints.length ? buildQ10PathState(current, nextSource.pathPoints) : null;
return {
...current,
history: pathState?.history ?? current.history,
robotPos: pathState?.robotPos ?? current.robotPos,
virtualWalls: overlayFields.virtualWalls,
areasInfo: overlayFields.areasInfo,
recmForbitZone: overlayFields.recmForbitZone,
carpetInfo: overlayFields.carpetInfo,
q10SourceData: {
...nextSource,
robotPosition: pathState?.robotPosition ?? nextSource.robotPosition
}
};
}
export function parseQ10VirtualWallDpPayload(buf: Buffer): Q10SourceArea[] {
if (!buf || buf.length < 1) return [];
const count = buf[0] ?? 0;
if (buf.length < 1 + count * 8) return [];
const walls: Q10SourceArea[] = [];
for (let index = 0; index < count; index++) {
const off = 1 + index * 8;
walls.push({
type: "virtualWall",
areaType: 1,
points: [
q10DevicePoint(readI16BE(buf, off) / 10, readI16BE(buf, off + 2) / 10),
q10DevicePoint(readI16BE(buf, off + 4) / 10, readI16BE(buf, off + 6) / 10)
]
});
}
return walls;
}
export function parseQ10RestrictedZoneDpPayload(
buf: Buffer
): Required<Pick<Q10RuntimeStatePatch, "forbidAreas" | "mopAreas" | "thresholdAreas">> {
const result: Required<Pick<Q10RuntimeStatePatch, "forbidAreas" | "mopAreas" | "thresholdAreas">> = {
forbidAreas: [],
mopAreas: [],
thresholdAreas: []
};
if (!buf || buf.length < 2) return result;
const count = buf[1] ?? 0;
if (buf.length < 2 + count * 38) return result;
for (let index = 0; index < count; index++) {
const off = 2 + index * 38;
const areaType = buf[off] ?? 0;
const points: Q10DevicePoint[] = [];
for (let pointIndex = 0; pointIndex < 4; pointIndex++) {
const pointOff = off + 2 + pointIndex * 4;
points.push(q10DevicePoint(readI16BE(buf, pointOff) / 10, readI16BE(buf, pointOff + 2) / 10));
}
const nameLen = buf[off + 18] ?? 0;
let name: string | undefined;
if (nameLen > 0 && nameLen <= 19) {
const nameBytes = buf.subarray(off + 19, Math.min(off + 19 + nameLen, off + 38));
name = fixLikelyUtf8Mojibake(nameBytes.toString("utf8").replace(/\0+$/g, "").trim()) || undefined;
}
const area: Q10SourceArea = {
type: areaType === 2 ? "mop" : areaType === 3 ? "threshold" : "forbid",
areaType,
name,
points
};
if (areaType === 2) result.mopAreas.push(area);
else if (areaType === 3) result.thresholdAreas.push(area);
else result.forbidAreas.push(area);
}
return result;
}
export function parseQ10CarpetDpPayload(data: unknown): Q10SourceArea[] {
if (!Array.isArray(data)) return [];
const carpets: Q10SourceArea[] = [];
for (const entry of data) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
const item = entry as Record<string, unknown>;
if (!Array.isArray(item.vertexs) || item.vertexs.length < 4) continue;
const points: Q10DevicePoint[] = [];
for (const vertex of item.vertexs) {
if (!Array.isArray(vertex) || vertex.length < 2) continue;
const x = Number(vertex[0]);
const y = Number(vertex[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
points.push(q10DevicePoint(x / 10, y / 10));
}
if (points.length < 4) continue;
const id = Number(item.id);
carpets.push({
id: Number.isFinite(id) ? id : undefined,
type: "carpet",
points
});
}
return carpets;
}
export function parseQ10SuspectedPointsDpPayload(
data: unknown,
type: Q10SourceSuspectedPoint["type"]
): Q10SourceSuspectedPoint[] {
if (!Array.isArray(data)) return [];
const points: Q10SourceSuspectedPoint[] = [];
for (const entry of data) {
if (!Array.isArray(entry) || entry.length < 2) continue;
const x = Number(entry[0]);
const y = Number(entry[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
points.push({
type,
point: q10DevicePoint(x / 10, y / 10)
});
}
return points;
}
function parseQ10TrailingBlocks(
buf: Buffer,
start: number,
mapXMin: number,
mapYMin: number
): Q10Trailing {
void mapXMin;
void mapYMin;
const out: Q10Trailing = { nextOffset: start };
if (!buf || start >= buf.length) return out;
// Erase areas (JX parserErasesArea): count; if count>0: vertsPerPoly (1) + count × vertsPerPoly × 4 bytes (int16 BE x,y /10)
let pos = start;
if (pos + 1 > buf.length) return out;
const erasesCount = buf[pos];
pos += 1;
if (erasesCount > 0) {
if (pos + 1 > buf.length) return out;
const vertsPerPoly = buf[pos];
pos += 1;
if (vertsPerPoly < 2 || vertsPerPoly > 32) return out;
const bytesPerPoly = vertsPerPoly * 4;
const erasesBlockLen = erasesCount * bytesPerPoly;
if (pos + erasesBlockLen <= buf.length) {
const areas: Q10SourceArea[] = [];
for (let i = 0; i < erasesCount; i++) {
const points: Q10DevicePoint[] = [];
for (let p = 0; p < vertsPerPoly; p++) {
const rx = readI16BE(buf, pos) / 10;
const ry = readI16BE(buf, pos + 2) / 10;
pos += 4;
points.push(q10DevicePoint(rx, ry));
}
areas.push({ type: "erase", points });
}
out.eraseAreas = areas;
} else {
return out;
}
}
// Carpet: pixLen (4), pixLzLen (2), then LZ4 or raw
if (pos + 6 > buf.length) return out;
const carpetPixLen = readU32BE(buf, pos);
pos += 4;
const carpetPixLzLen = readU16BE(buf, pos);
pos += 2;
if (carpetPixLen > 0 && carpetPixLen <= MAX_MAP_PIXELS) {
if (carpetPixLzLen === 0) {
if (pos + carpetPixLen <= buf.length) {
out.carpetGrid = Buffer.from(buf.subarray(pos, pos + carpetPixLen));
}
pos += carpetPixLen;
} else {
if (pos + carpetPixLzLen <= buf.length) {
const compressed = buf.subarray(pos, pos + carpetPixLzLen);
const decompressed = tryDecompressQ10Lz4Block(compressed, carpetPixLen);
if (decompressed && decompressed.length === carpetPixLen) {
out.carpetGrid = decompressed;
}
}
pos += carpetPixLzLen;
}
}
// Obstacle points: n (1), n × 4 bytes, Int16LE, unit 1/50 (app: /10/5)
if (pos + 1 > buf.length) return out;
const obstacleN = buf[pos];
pos += 1;
if (obstacleN > 0 && pos + obstacleN * 4 <= buf.length) {
const points: Q10DevicePoint[] = [];
for (let i = 0; i < obstacleN; i++) {
const gx = readI16BE(buf, pos) / 10 / 5;
const gy = readI16BE(buf, pos + 2) / 10 / 5;
pos += 4;
points.push(q10DevicePoint(gx, gy));
}
out.obstaclePoints = points;
}
// Skip-clean points: n (1), n × 4 bytes, int16 BE, unit 1/10 → Weltkoordinaten
if (pos + 1 > buf.length) return out;
const skipN = buf[pos];
pos += 1;
if (skipN > 0 && pos + skipN * 4 <= buf.length) {
const points: Q10DevicePoint[] = [];
for (let i = 0; i < skipN; i++) {
const rx = readI16BE(buf, pos) / 10;
const ry = readI16BE(buf, pos + 2) / 10;
pos += 4;
points.push(q10DevicePoint(rx, ry));
}
out.skipCleanPoints = points;
}
out.nextOffset = pos;
return out;
}
/**
* module_973 can place an embedded path block at the beginning of the edit tail
* for clean-record/live frames. Multi-map frames start directly with virtualWall.
*/
function getQ10PathPayloadLengthAt(buf: Buffer, offset: number): number {
if (!buf || offset + 13 > buf.length) return 0;
const version = buf[offset];
const pathMode = buf[offset + 4];
const pointCount = readU32BE(buf, offset + 5);
const compressedLen = readU16BE(buf, offset + 11);
if (version < 0 || version > 3) return 0;
if (pathMode < 0 || pathMode > 4) return 0;
if (pointCount < 2 || pointCount > MAX_Q10_PATH_POINTS) return 0;
const payloadLen = 13 + (compressedLen === 0 ? pointCount * 4 : compressedLen);
if (offset + payloadLen > buf.length) return 0;
return payloadLen;
}
/**
* Tail after erases/carpet/obstacle/skip (native module_973 order):
* optional path -> virtualWall -> forbidArea -> carpetArea.
* These coordinates still use devicePointToOrigMap semantics and must be mapped like path points.
*/
function parseQ10EditTailSections(
buf: Buffer,
start: number,
mapXMin: number,
mapYMin: number
): Q10EditTailSections {
void mapXMin;
void mapYMin;
const out: Q10EditTailSections = {
pathPoints: [],
virtualWalls: [],
forbidAreas: [],
mopZones: [],
thresholdZones: [],
carpetAreas: []
};
if (!buf || start >= buf.length) return out;
let pos = start;
const leadingPath = parseQ10PathBlock(buf, pos);
if (leadingPath) {
out.pathPoints = leadingPath.points;
pos = leadingPath.nextOffset;
}
if (pos + 1 > buf.length) return out;
const virtualWallCount = buf[pos];
const virtualWallLen = 1 + virtualWallCount * 8;
if (pos + virtualWallLen > buf.length) return out;
for (let i = 0; i < virtualWallCount; i++) {
const off = pos + 1 + i * 8;
const p0 = q10DevicePoint(readI16BE(buf, off) / 10, readI16BE(buf, off + 2) / 10);
const p1 = q10DevicePoint(readI16BE(buf, off + 4) / 10, readI16BE(buf, off + 6) / 10);
out.virtualWalls.push({ type: "virtualWall", areaType: 1, points: [p0, p1] });
}
pos += virtualWallLen;
if (pos + 2 > buf.length) return out;
const forbidCount = buf[pos + 1];
const forbidLen = 2 + forbidCount * 38;
if (pos + forbidLen > buf.length) return out;
for (let i = 0; i < forbidCount; i++) {
const off = pos + 2 + i * 38;
const areaType = buf[off];
const points: Q10DevicePoint[] = [];
for (let p = 0; p <