You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
282 lines
11 KiB
282 lines
11 KiB
|
|
// ---------------------------------------------------------------------------
|
|
// File format constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { packedSize, packPixels, pad32, unpackPixels, BinaryReader } from "./helper";
|
|
import { type MonoFormatFile } from "./types";
|
|
import { ElementType, SectionType, type MonoFormatAnimation, type MonoFormatClippedText, type MonoFormatCurrentTime, type MonoFormatCustomFont, type MonoFormatElement, type MonoFormatElementsAlways, type MonoFormatElementsTimespan, type MonoFormatHScroll, type MonoFormatHScrollText, type MonoFormatImage2D, type MonoFormatLine, type MonoFormatPixelImage, type MonoFormatSection, type MonoFormatSectionFlags, type MonoFormatVScroll } from "./types";
|
|
|
|
// Magic 0xAF 0x7E 0x2B 0x63 read as uint32 LE
|
|
export const MONOFORMAT_MAGIC_HEADER = 0x632B7EAF;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MonoDisplayParser
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class MonoDisplayParser {
|
|
parse(buffer: ArrayBuffer | ArrayBufferView): MonoFormatFile {
|
|
const r = new BinaryReader(buffer);
|
|
|
|
// File header (12 bytes)
|
|
const magic = r.readUint32();
|
|
if (magic !== MONOFORMAT_MAGIC_HEADER) throw new Error(`Bad magic 0x${magic.toString(16)} - expected 0x632B7EAF`);
|
|
|
|
const version = r.readUint32();
|
|
if (version === 0 || version > 1) throw new Error(`Unsupported version ${version}`);
|
|
|
|
const numSections = r.readUint16();
|
|
r.readUint16(); // reserved
|
|
|
|
const sections: MonoFormatSection[] = [];
|
|
|
|
for (let i = 0; i < numSections; i++) {
|
|
const sectionType = r.readByte();
|
|
const sectionSize = r.readUint24(); // INCLUDES 4-byte header
|
|
const sectionStart = r.offset; // start of section DATA
|
|
const dataSize = sectionSize - 4;
|
|
|
|
const sectionEnd = sectionStart + dataSize;
|
|
switch (sectionType) {
|
|
case SectionType.ElementsAlways:
|
|
sections.push(this.#parseElementsSection(r, SectionType.ElementsAlways, false, sectionEnd));
|
|
break;
|
|
case SectionType.ElementsTimespan:
|
|
sections.push(this.#parseElementsSection(r, SectionType.ElementsTimespan, true, sectionEnd));
|
|
break;
|
|
case SectionType.CustomFont:
|
|
sections.push(this.#parseCustomFont(r, dataSize));
|
|
break;
|
|
default:
|
|
// Unknown - skip
|
|
break;
|
|
}
|
|
|
|
// Seek past full section data (handles padding, unknown fields, future extensions)
|
|
r.seek(sectionStart + dataSize);
|
|
}
|
|
|
|
return { sections };
|
|
}
|
|
|
|
#parseFlags(raw: number): MonoFormatSectionFlags {
|
|
return {
|
|
drawFront: !!(raw & 0x01),
|
|
drawBack: !!(raw & 0x02),
|
|
clearBuffer: !!(raw & 0x04),
|
|
};
|
|
}
|
|
|
|
#parseElementsSection(
|
|
r: BinaryReader,
|
|
type: SectionType.ElementsAlways | SectionType.ElementsTimespan,
|
|
hasTimestamp: boolean,
|
|
sectionEnd: number,
|
|
): MonoFormatElementsAlways | MonoFormatElementsTimespan {
|
|
const flags = this.#parseFlags(r.readUint16());
|
|
const numElements = r.readUint16();
|
|
|
|
let startTimestamp = 0n, endTimestamp = 0n;
|
|
if (hasTimestamp) {
|
|
startTimestamp = r.readUint64();
|
|
endTimestamp = r.readUint64();
|
|
}
|
|
|
|
const elements: MonoFormatElement[] = [];
|
|
for (let i = 0; i < numElements; i++) {
|
|
// Stop if fewer than 2 bytes remain in section (can't read element type)
|
|
if (r.offset + 2 > sectionEnd) break;
|
|
const el = this.#parseElement(r);
|
|
if (el === null) break; // unknown type - can't determine size, stop
|
|
elements.push(el);
|
|
}
|
|
|
|
if (hasTimestamp) {
|
|
return { sectionType: SectionType.ElementsTimespan, flags, startTimestamp, endTimestamp, elements };
|
|
}
|
|
return { sectionType: SectionType.ElementsAlways, flags, elements };
|
|
}
|
|
|
|
#parseCustomFont(r: BinaryReader, dataSize: number): MonoFormatCustomFont {
|
|
const actualSize = r.readUint24();
|
|
r.readByte(); // reserved
|
|
const fontData = new Uint8Array(r.readBytes(Math.min(actualSize, dataSize - 4)));
|
|
return { sectionType: SectionType.CustomFont, fontData };
|
|
}
|
|
|
|
#parseElement(r: BinaryReader): MonoFormatElement | null {
|
|
const elementStart = r.offset;
|
|
const type = r.readUint16();
|
|
|
|
switch (type) {
|
|
case ElementType.Image2D: return this.#parseImage2D(r, elementStart);
|
|
case ElementType.Animation: return this.#parseAnimation(r, elementStart);
|
|
case ElementType.HorizontalScroll: return this.#parseHScroll(r, elementStart);
|
|
case ElementType.VerticalScroll: return this.#parseVScroll(r, elementStart);
|
|
case ElementType.Line: return this.#parseLine(r);
|
|
case ElementType.ClippedText: return this.#parseClippedText(r, elementStart);
|
|
case ElementType.HScrollText: return this.#parseHScrollText(r, elementStart);
|
|
case ElementType.CurrentTime: return this.#parseCurrentTime(r);
|
|
default:
|
|
// Unknown element type - cannot safely skip without knowing size; stop parsing section elements
|
|
return null;
|
|
}
|
|
}
|
|
|
|
#alignTo4(start: number, current: number): void {
|
|
// elements are 32-bit aligned; seek past padding if any
|
|
const end = start + (((current - start) + 3) & ~3);
|
|
// no-op if already aligned (handled by caller via section seek)
|
|
}
|
|
|
|
#parseImage2D(r: BinaryReader, start: number): MonoFormatImage2D {
|
|
const xOffset = r.readUint16();
|
|
const yOffset = r.readUint16();
|
|
const width = r.readUint16();
|
|
const height = r.readUint16();
|
|
r.readUint16(); // reserved
|
|
// Fixed header: 2(type)+2+2+2+2+2 = 12 bytes
|
|
const packed = r.readBytes(packedSize(width, height));
|
|
const pixels = unpackPixels(new Uint8Array(packed), width, height);
|
|
// Skip padding to 32-bit boundary for the element
|
|
const dataRead = 12 + packed.byteLength;
|
|
r.seek(start + dataRead + pad32(dataRead));
|
|
return { type: ElementType.Image2D, xOffset, yOffset, image: { pixels, width, height } };
|
|
}
|
|
|
|
#parseAnimation(r: BinaryReader, start: number): MonoFormatAnimation {
|
|
const xOffset = r.readUint16();
|
|
const yOffset = r.readUint16();
|
|
const width = r.readUint16();
|
|
const height = r.readUint16();
|
|
const numFrames = r.readUint16();
|
|
const updateInterval = r.readUint16();
|
|
r.readUint16(); // reserved
|
|
// Fixed header: 2+2+2+2+2+2+2+2 = 16 bytes
|
|
const frameBytes = packedSize(width, height);
|
|
const frames: MonoFormatPixelImage[] = [];
|
|
for (let f = 0; f < numFrames; f++) {
|
|
const packed = r.readBytes(frameBytes);
|
|
frames.push({ pixels: unpackPixels(new Uint8Array(packed), width, height), width, height });
|
|
}
|
|
const dataRead = 16 + numFrames * frameBytes;
|
|
r.seek(start + dataRead + pad32(dataRead));
|
|
return { type: ElementType.Animation, xOffset, yOffset, width, height, updateInterval, frames };
|
|
}
|
|
|
|
#parseScrollFlags(raw: number) {
|
|
return {
|
|
endless: !!(raw & 0x01),
|
|
invertDirection: !!(raw & 0x02),
|
|
padStart: !!(raw & 0x04),
|
|
padEnd: !!(raw & 0x08),
|
|
};
|
|
}
|
|
|
|
#parseHScroll(r: BinaryReader, start: number): MonoFormatHScroll {
|
|
const xOffset = r.readUint16();
|
|
const yOffset = r.readUint16();
|
|
const width = r.readUint16();
|
|
const height = r.readUint16();
|
|
const contentWidth = r.readUint16();
|
|
const flagsByte = r.readByte();
|
|
const scrollSpeed = r.readByte();
|
|
r.readUint16(); // reserved
|
|
// Fixed header: 2+2+2+2+2+2+1+1+2 = 16 bytes
|
|
const packed = r.readBytes(packedSize(contentWidth, height));
|
|
const pixels = unpackPixels(new Uint8Array(packed), contentWidth, height);
|
|
const dataRead = 16 + packed.byteLength;
|
|
r.seek(start + dataRead + pad32(dataRead));
|
|
return {
|
|
type: ElementType.HorizontalScroll, xOffset, yOffset, width, height, contentWidth,
|
|
scrollSpeed, flags: this.#parseScrollFlags(flagsByte),
|
|
content: { pixels, width: contentWidth, height },
|
|
};
|
|
}
|
|
|
|
#parseVScroll(r: BinaryReader, start: number): MonoFormatVScroll {
|
|
const xOffset = r.readUint16();
|
|
const yOffset = r.readUint16();
|
|
const width = r.readUint16();
|
|
const height = r.readUint16();
|
|
const contentHeight = r.readUint16();
|
|
const flagsByte = r.readByte();
|
|
const scrollSpeed = r.readByte();
|
|
r.readUint16(); // reserved
|
|
const packed = r.readBytes(packedSize(width, contentHeight));
|
|
const pixels = unpackPixels(new Uint8Array(packed), width, contentHeight);
|
|
const dataRead = 16 + packed.byteLength;
|
|
r.seek(start + dataRead + pad32(dataRead));
|
|
return {
|
|
type: ElementType.VerticalScroll, xOffset, yOffset, width, height, contentHeight,
|
|
scrollSpeed, flags: this.#parseScrollFlags(flagsByte),
|
|
content: { pixels, width, height: contentHeight },
|
|
};
|
|
}
|
|
|
|
#parseLine(r: BinaryReader): MonoFormatLine {
|
|
const xOrigin = r.readUint16();
|
|
const yOrigin = r.readUint16();
|
|
const xTarget = r.readUint16();
|
|
const yTarget = r.readUint16();
|
|
const lineStyle = r.readByte();
|
|
const flagsByte = r.readByte();
|
|
// 2+2+2+2+2+1+1 = 12 bytes, already 32-bit aligned
|
|
return { type: ElementType.Line, xOrigin, yOrigin, xTarget, yTarget, lineStyle, invertPixels: !!(flagsByte & 1) };
|
|
}
|
|
|
|
#parseClippedText(r: BinaryReader, start: number): MonoFormatClippedText {
|
|
const xOffset = r.readUint16();
|
|
const yOffset = r.readUint16();
|
|
const width = r.readUint16();
|
|
const height = r.readUint16();
|
|
const fontIndex = r.readUint16();
|
|
const textLen = r.readUint16();
|
|
// Fixed header: 2+2+2+2+2+2+2 = 14 bytes
|
|
const text = r.readUtf8(textLen);
|
|
const dataRead = 14 + textLen;
|
|
r.seek(start + dataRead + pad32(dataRead));
|
|
return { type: ElementType.ClippedText, xOffset, yOffset, width, height, fontIndex, text };
|
|
}
|
|
|
|
#parseCurrentTime(r: BinaryReader): MonoFormatCurrentTime {
|
|
// Layout after type field (already consumed): 14 bytes, total element = 16 bytes (aligned)
|
|
const xOffset = r.readUint16();
|
|
const yOffset = r.readUint16();
|
|
const width = r.readUint16();
|
|
const height = r.readUint16();
|
|
const fontIndex = r.readUint16();
|
|
const utcOffsetMinutes = r.readInt16();
|
|
const flagsByte = r.readByte();
|
|
r.readByte(); // reserved
|
|
return {
|
|
type: ElementType.CurrentTime,
|
|
xOffset, yOffset, width, height, fontIndex, utcOffsetMinutes,
|
|
flags: {
|
|
clock12h: !!(flagsByte & 0x01),
|
|
showHours: !!(flagsByte & 0x02),
|
|
showMinutes: !!(flagsByte & 0x04),
|
|
showSeconds: !!(flagsByte & 0x08),
|
|
},
|
|
};
|
|
}
|
|
|
|
#parseHScrollText(r: BinaryReader, start: number): MonoFormatHScrollText {
|
|
const xOffset = r.readUint16();
|
|
const yOffset = r.readUint16();
|
|
const width = r.readUint16();
|
|
const height = r.readUint16();
|
|
const flagsByte = r.readByte();
|
|
const scrollSpeed = r.readByte();
|
|
const fontIndex = r.readUint16();
|
|
const textLen = r.readUint16(); // PLAN: confirm with spec (assumed, not explicit in diagram)
|
|
// Fixed header: 2+2+2+2+2+1+1+2+2 = 16 bytes
|
|
const text = r.readUtf8(textLen);
|
|
const dataRead = 16 + textLen;
|
|
r.seek(start + dataRead + pad32(dataRead));
|
|
return {
|
|
type: ElementType.HScrollText, xOffset, yOffset, width, height,
|
|
scrollSpeed, fontIndex, flags: this.#parseScrollFlags(flagsByte), text,
|
|
};
|
|
}
|
|
}
|
|
|