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.
1000 lines
37 KiB
1000 lines
37 KiB
// =============================================================================
|
|
// MonoDisplay Library — binary format parser + canvas renderer
|
|
//
|
|
// Spec: Mono Display File Format Specification (see adjacent .rst)
|
|
//
|
|
// File layout (little-endian throughout):
|
|
//
|
|
// [FILE HEADER — 12 bytes]
|
|
// 4 bytes magic 0xAF 0x7E 0x2B 0x63
|
|
// 4 bytes version uint32 LE; 0=illegal, 1=current
|
|
// 2 bytes numSections uint16 LE
|
|
// 2 bytes reserved must be 0
|
|
//
|
|
// [SECTION — repeated numSections times]
|
|
// 1 byte sectionType uint8
|
|
// 3 bytes sectionSize uint24 LE, INCLUDES these 4 header bytes
|
|
// <data> sectionSize-4 bytes of section-specific data
|
|
// <pad> to 32-bit boundary (included in sectionSize)
|
|
//
|
|
// SECTION TYPES:
|
|
// 1 ElementsAlways — flags(u16) numElements(u16) elements…
|
|
// 2 ElementsTimespan — flags(u16) numElements(u16) start(u64) end(u64) elements…
|
|
// 32 CustomFont — actualSize(u24) reserved(u8) fontData…
|
|
//
|
|
// ELEMENT TYPES (uint16, all fields uint16 unless noted, 32-bit aligned):
|
|
// 1 Image2D — type xOff yOff w h reserved [packed 1bpp pixels]
|
|
// 2 Animation — type xOff yOff w h nFrames updateInterval reserved [N×frame]
|
|
// 3 HorizontalScroll — type xOff yOff w h contentW flags(u8) speed(u8) reserved [pixels]
|
|
// 4 VerticalScroll — type xOff yOff w h contentH flags(u8) speed(u8) reserved [pixels]
|
|
// 5 Line — type xO yO xT yT lineStyle(u8) flags(u8)
|
|
// 16 ClippedText — type xOff yOff w h fontIdx textLen [utf8 text + pad]
|
|
// 17 HScrollText — type xOff yOff w h flags(u8) speed(u8) fontIdx textLen [utf8 + pad]
|
|
//
|
|
// PIXEL DATA: packed 1bpp, size = ceil(w*h/8).
|
|
// px_i = y*w + x; byte = data[px_i>>3]; bit = (byte >> (px_i&7)) & 1
|
|
// Frames aligned to octet boundary. Excess bits SHOULD be 0, MUST be ignored.
|
|
//
|
|
// ALIGNMENT: all sections and elements aligned to 32-bit (4-byte) boundaries.
|
|
//
|
|
// FONT INDEX: 0..32767 = built-in; 0x8000+ = custom font in file (0x8000=first).
|
|
// =============================================================================
|
|
|
|
export * from "./types.js";
|
|
import {
|
|
SectionType,
|
|
ElementType,
|
|
type SectionFlags,
|
|
type ScrollElementFlags,
|
|
type Image2DElement,
|
|
type AnimationElement,
|
|
type HScrollElement,
|
|
type VScrollElement,
|
|
type LineElement,
|
|
type ClippedTextElement,
|
|
type HScrollTextElement,
|
|
type DrawElement,
|
|
type ElementsAlwaysDescriptor,
|
|
type MonoDisplayFileDescriptor,
|
|
type MonoFormatPixelImage,
|
|
type MonoFormatImage2D,
|
|
type MonoFormatAnimation,
|
|
type MonoFormatScrollFlags,
|
|
type MonoFormatHScroll,
|
|
type MonoFormatVScroll,
|
|
type MonoFormatLine,
|
|
type MonoFormatClippedText,
|
|
type MonoFormatHScrollText,
|
|
type MonoFormatElement,
|
|
type MonoFormatSectionFlags,
|
|
type MonoFormatElementsAlways,
|
|
type MonoFormatElementsTimespan,
|
|
type MonoFormatCustomFont,
|
|
type MonoFormatSection,
|
|
type MonoFormatFile,
|
|
} from "./types.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pixel pack/unpack helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Pack 1-byte-per-pixel array into 1bpp.
|
|
* Excess bits in the last byte are set to 0.
|
|
* Size = ceil(width * height / 8).
|
|
*/
|
|
function packPixels(pixels: Uint8Array, width: number, height: number): Uint8Array {
|
|
const total = width * height;
|
|
const packed = new Uint8Array((total + 7) >> 3);
|
|
for (let i = 0; i < total; i++) {
|
|
if (pixels[i]) packed[i >> 3] |= 1 << (i & 7);
|
|
}
|
|
return packed;
|
|
}
|
|
|
|
/**
|
|
* Unpack 1bpp data to 1-byte-per-pixel. Excess bits beyond width*height are ignored.
|
|
*/
|
|
function unpackPixels(packed: Uint8Array, width: number, height: number): Uint8Array {
|
|
const total = width * height;
|
|
const pixels = new Uint8Array(total);
|
|
for (let i = 0; i < total; i++) {
|
|
pixels[i] = (packed[i >> 3] >> (i & 7)) & 1;
|
|
}
|
|
return pixels;
|
|
}
|
|
|
|
/** Byte count of packed 1bpp image */
|
|
function packedSize(width: number, height: number): number {
|
|
return (width * height + 7) >> 3;
|
|
}
|
|
|
|
/** Padding needed to align `byteCount` to a 4-byte boundary */
|
|
function pad32(byteCount: number): number {
|
|
return (4 - (byteCount & 3)) & 3;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BinaryReader — little-endian cursor over an ArrayBuffer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class BinaryReader {
|
|
private view: DataView;
|
|
private pos: number = 0;
|
|
|
|
constructor(buffer: ArrayBuffer) {
|
|
this.view = new DataView(buffer);
|
|
}
|
|
|
|
get offset(): number { return this.pos; }
|
|
get byteLength(): number { return this.view.byteLength; }
|
|
get remaining(): number { return this.view.byteLength - this.pos; }
|
|
|
|
readByte(): number { this.#need(1); return this.view.getUint8(this.pos++); }
|
|
readUint16(): number { this.#need(2); const v = this.view.getUint16(this.pos, true); this.pos += 2; return v; }
|
|
readShort(): number { return this.readUint16(); }
|
|
readInt16(): number { this.#need(2); const v = this.view.getInt16(this.pos, true); this.pos += 2; return v; }
|
|
|
|
readUint24(): number {
|
|
this.#need(3);
|
|
const v = this.view.getUint8(this.pos) | (this.view.getUint8(this.pos+1) << 8) | (this.view.getUint8(this.pos+2) << 16);
|
|
this.pos += 3;
|
|
return v;
|
|
}
|
|
|
|
readUint32(): number { this.#need(4); const v = this.view.getUint32(this.pos, true); this.pos += 4; return v; }
|
|
|
|
readUint64(): bigint {
|
|
this.#need(8);
|
|
const lo = BigInt(this.view.getUint32(this.pos, true));
|
|
const hi = BigInt(this.view.getUint32(this.pos+4, true));
|
|
this.pos += 8;
|
|
return (hi << 32n) | lo;
|
|
}
|
|
|
|
readBytes(n: number): Uint8Array {
|
|
this.#need(n);
|
|
const s = new Uint8Array(this.view.buffer, this.pos, n);
|
|
this.pos += n;
|
|
return s;
|
|
}
|
|
|
|
readUtf8(n: number): string { return new TextDecoder().decode(this.readBytes(n)); }
|
|
|
|
seek(pos: number): void {
|
|
if (pos < 0 || pos > this.view.byteLength) {
|
|
throw new RangeError(`seek(${pos}) out of [0, ${this.view.byteLength}]`);
|
|
}
|
|
this.pos = pos;
|
|
}
|
|
|
|
#need(n: number): void {
|
|
if (this.remaining < n) throw new RangeError(
|
|
`BinaryReader: need ${n} byte(s) at offset ${this.pos}, ${this.remaining} remain`
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// File format constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Magic 0xAF 0x7E 0x2B 0x63 read as uint32 LE
|
|
const MAGIC = 0x632B7EAF;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MonoDisplayParser
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class MonoDisplayParser {
|
|
parse(buffer: ArrayBuffer): MonoFormatFile {
|
|
const r = new BinaryReader(buffer);
|
|
|
|
// File header (12 bytes)
|
|
const magic = r.readUint32();
|
|
if (magic !== MAGIC) 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;
|
|
|
|
switch (sectionType) {
|
|
case SectionType.ElementsAlways:
|
|
sections.push(this.#parseElementsSection(r, SectionType.ElementsAlways, false));
|
|
break;
|
|
case SectionType.ElementsTimespan:
|
|
sections.push(this.#parseElementsSection(r, SectionType.ElementsTimespan, true));
|
|
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,
|
|
): 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++) {
|
|
const el = this.#parseElement(r);
|
|
if (el) 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);
|
|
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 };
|
|
}
|
|
|
|
#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,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MonoDisplayRenderer — tick-based canvas renderer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Optional constructor arguments for MonoDisplayDriver */
|
|
export interface MonoDisplayDriverOptions {
|
|
/** CSS colour for on-pixels. Default "#ffffff" */
|
|
onColor?: string;
|
|
/** CSS colour for off-pixels / background. Default "#000000" */
|
|
offColor?: string;
|
|
/**
|
|
* Scale factor — each logical pixel → NxN canvas pixels.
|
|
* Keep at 1 and use CSS to stretch for crisp upscaling.
|
|
*/
|
|
scale?: number;
|
|
/** Default display width for elements without inherent size. Default 160. */
|
|
displayWidth?: number;
|
|
/** Default display height. Default 80. */
|
|
displayHeight?: number;
|
|
/**
|
|
* Render rate in ticks per second. All animations and scrolls are driven by this.
|
|
* Animation updateInterval, scroll speed, etc. are relative to this tick rate.
|
|
* Default 25.
|
|
*/
|
|
fps?: number;
|
|
/** Loop animations. Default true. */
|
|
loop?: boolean;
|
|
/** Error handler. Default: throw. */
|
|
onError?: (err: Error) => void;
|
|
}
|
|
|
|
/**
|
|
* Renders a MonoFormatFile onto an HTMLCanvasElement.
|
|
* Uses setInterval at 1000/fps ms per tick. All element state (animation
|
|
* frame counters, scroll positions) is keyed by element index within the file.
|
|
*
|
|
* PLAN: ClippedText and HScrollText currently render via canvas fillText (stub).
|
|
* Replace with U8G2 bitmap font renderer once font loading is implemented.
|
|
* PLAN: Line element uses Bresenham stub; lineStyle field is reserved and ignored.
|
|
*/
|
|
export class MonoDisplayRenderer {
|
|
private ctx: CanvasRenderingContext2D;
|
|
private opts: Required<MonoDisplayDriverOptions>;
|
|
|
|
private tickTimer: number | null = null;
|
|
private tickCount: number = 0;
|
|
// Per-element state: keyed by stable element index
|
|
private animState: Map<number, { frame: number; counter: number }> = new Map();
|
|
private hScrollPos: Map<number, number> = new Map(); // pixel offset (may be fractional)
|
|
private vScrollPos: Map<number, number> = new Map();
|
|
|
|
constructor(canvas: HTMLCanvasElement, opts: Required<MonoDisplayDriverOptions>) {
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) throw new Error("Cannot get 2D canvas context");
|
|
this.ctx = ctx;
|
|
this.opts = opts;
|
|
}
|
|
|
|
stop(): void {
|
|
if (this.tickTimer !== null) {
|
|
clearInterval(this.tickTimer);
|
|
this.tickTimer = null;
|
|
}
|
|
this.tickCount = 0;
|
|
this.animState.clear();
|
|
this.hScrollPos.clear();
|
|
this.vScrollPos.clear();
|
|
}
|
|
|
|
render(file: MonoFormatFile): void {
|
|
this.stop();
|
|
|
|
const { displayWidth: dw, displayHeight: dh, scale: s } = this.opts;
|
|
this.ctx.canvas.width = dw * s;
|
|
this.ctx.canvas.height = dh * s;
|
|
|
|
const tick = () => {
|
|
this.#renderFile(file);
|
|
this.tickCount++;
|
|
};
|
|
|
|
tick(); // immediate first frame
|
|
this.tickTimer = setInterval(tick, 1000 / this.opts.fps) as unknown as number;
|
|
}
|
|
|
|
#renderFile(file: MonoFormatFile): void {
|
|
const now = BigInt(Math.floor(Date.now() / 1000));
|
|
let cleared = false;
|
|
|
|
let elementId = 0;
|
|
|
|
for (const section of file.sections) {
|
|
if (section.sectionType === SectionType.ElementsTimespan) {
|
|
if (now < section.startTimestamp || now >= section.endTimestamp) {
|
|
// Count elements so IDs stay stable even for skipped sections
|
|
elementId += section.elements.length;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (section.sectionType !== SectionType.ElementsAlways &&
|
|
section.sectionType !== SectionType.ElementsTimespan) {
|
|
continue;
|
|
}
|
|
|
|
const elSection = section as MonoFormatElementsAlways | MonoFormatElementsTimespan;
|
|
|
|
if (elSection.flags.clearBuffer && !cleared) {
|
|
this.ctx.fillStyle = this.opts.offColor;
|
|
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
|
|
cleared = true;
|
|
}
|
|
|
|
for (const el of elSection.elements) {
|
|
this.#renderElement(el, elementId++);
|
|
}
|
|
}
|
|
}
|
|
|
|
#renderElement(el: MonoFormatElement, id: number): void {
|
|
switch (el.type) {
|
|
case ElementType.Image2D: this.#blitImage(el.image, el.xOffset, el.yOffset); break;
|
|
case ElementType.Animation: this.#renderAnimation(el, id); break;
|
|
case ElementType.HorizontalScroll: this.#renderHScroll(el, id); break;
|
|
case ElementType.VerticalScroll: this.#renderVScroll(el, id); break;
|
|
case ElementType.Line: this.#renderLine(el); break;
|
|
case ElementType.ClippedText: this.#renderClippedText(el); break;
|
|
case ElementType.HScrollText: this.#renderHScrollText(el, id); break;
|
|
}
|
|
}
|
|
|
|
/** Blit a pixel image onto the canvas at (ox, oy) in display coordinates */
|
|
#blitImage(img: MonoFormatPixelImage, ox: number, oy: number): void {
|
|
const { ctx, opts: { onColor, scale: s } } = this;
|
|
ctx.fillStyle = onColor;
|
|
for (let y = 0; y < img.height; y++) {
|
|
for (let x = 0; x < img.width; x++) {
|
|
if (img.pixels[y * img.width + x]) {
|
|
ctx.fillRect((ox + x) * s, (oy + y) * s, s, s);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Blit a clipped sub-region of a pixel image. srcX is fractional scroll offset. */
|
|
#blitClipped(
|
|
img: MonoFormatPixelImage, ox: number, oy: number,
|
|
viewW: number, viewH: number, srcX: number, srcY: number,
|
|
): void {
|
|
const { ctx, opts: { onColor, scale: s } } = this;
|
|
ctx.fillStyle = onColor;
|
|
for (let y = 0; y < viewH; y++) {
|
|
for (let x = 0; x < viewW; x++) {
|
|
const sx = Math.floor(srcX) + x;
|
|
const sy = Math.floor(srcY) + y;
|
|
if (sx < 0 || sx >= img.width || sy < 0 || sy >= img.height) continue;
|
|
if (img.pixels[sy * img.width + sx]) {
|
|
ctx.fillRect((ox + x) * s, (oy + y) * s, s, s);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#renderAnimation(el: MonoFormatAnimation, id: number): void {
|
|
if (el.frames.length === 0) return;
|
|
let state = this.animState.get(id);
|
|
if (!state) { state = { frame: 0, counter: 0 }; this.animState.set(id, state); }
|
|
|
|
this.#blitImage(el.frames[state.frame], el.xOffset, el.yOffset);
|
|
|
|
state.counter++;
|
|
if (state.counter > el.updateInterval) {
|
|
state.counter = 0;
|
|
state.frame = (state.frame + 1) % el.frames.length;
|
|
if (!this.opts.loop && state.frame === 0) state.frame = el.frames.length - 1;
|
|
}
|
|
}
|
|
|
|
#renderHScroll(el: MonoFormatHScroll, id: number): void {
|
|
let pos = this.hScrollPos.get(id) ?? 0;
|
|
// Draw visible slice
|
|
this.#blitClipped(el.content, el.xOffset, el.yOffset, el.width, el.height, pos, 0);
|
|
// Advance scroll: (SS+1)/16 pixels per tick
|
|
const speed = (el.scrollSpeed + 1) / 16;
|
|
pos += el.flags.invertDirection ? -speed : speed;
|
|
if (el.flags.endless) {
|
|
if (pos >= el.contentWidth) pos -= el.contentWidth;
|
|
if (pos < 0) pos += el.contentWidth;
|
|
} else {
|
|
pos = Math.max(0, Math.min(pos, el.contentWidth - el.width));
|
|
}
|
|
this.hScrollPos.set(id, pos);
|
|
}
|
|
|
|
#renderVScroll(el: MonoFormatVScroll, id: number): void {
|
|
let pos = this.vScrollPos.get(id) ?? 0;
|
|
this.#blitClipped(el.content, el.xOffset, el.yOffset, el.width, el.height, 0, pos);
|
|
const speed = (el.scrollSpeed + 1) / 16;
|
|
pos += el.flags.invertDirection ? -speed : speed;
|
|
if (el.flags.endless) {
|
|
if (pos >= el.contentHeight) pos -= el.contentHeight;
|
|
if (pos < 0) pos += el.contentHeight;
|
|
} else {
|
|
pos = Math.max(0, Math.min(pos, el.contentHeight - el.height));
|
|
}
|
|
this.vScrollPos.set(id, pos);
|
|
}
|
|
|
|
#renderLine(el: MonoFormatLine): void {
|
|
// Bresenham's line algorithm
|
|
const { ctx, opts: { onColor, offColor, scale: s } } = this;
|
|
ctx.fillStyle = el.invertPixels ? offColor : onColor;
|
|
let [x0, y0, x1, y1] = [el.xOrigin, el.yOrigin, el.xTarget, el.yTarget];
|
|
const dx = Math.abs(x1-x0), sx = x0 < x1 ? 1 : -1;
|
|
const dy = -Math.abs(y1-y0), sy = y0 < y1 ? 1 : -1;
|
|
let err = dx + dy;
|
|
while (true) {
|
|
ctx.fillRect(x0*s, y0*s, s, s);
|
|
if (x0 === x1 && y0 === y1) break;
|
|
const e2 = 2 * err;
|
|
if (e2 >= dy) { err += dy; x0 += sx; }
|
|
if (e2 <= dx) { err += dx; y0 += sy; }
|
|
}
|
|
}
|
|
|
|
#renderClippedText(el: MonoFormatClippedText): void {
|
|
// PLAN: replace with U8G2 bitmap font renderer using el.fontIndex
|
|
const { ctx, opts: { onColor, scale: s } } = this;
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(el.xOffset * s, el.yOffset * s, el.width * s, el.height * s);
|
|
ctx.clip();
|
|
ctx.fillStyle = onColor;
|
|
const fontSize = Math.max(6, Math.floor(el.height * s * 0.75));
|
|
ctx.font = `${fontSize}px monospace`;
|
|
ctx.textBaseline = "top";
|
|
ctx.fillText(el.text, el.xOffset * s, el.yOffset * s);
|
|
ctx.restore();
|
|
}
|
|
|
|
#renderHScrollText(el: MonoFormatHScrollText, id: number): void {
|
|
// PLAN: replace with U8G2 bitmap font renderer using el.fontIndex
|
|
const { ctx, opts: { onColor, scale: s } } = this;
|
|
const fontSize = Math.max(6, Math.floor(el.height * s * 0.75));
|
|
ctx.font = `${fontSize}px monospace`;
|
|
const textPx = ctx.measureText(el.text).width;
|
|
let pos = this.hScrollPos.get(id) ?? el.width * s;
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(el.xOffset * s, el.yOffset * s, el.width * s, el.height * s);
|
|
ctx.clip();
|
|
ctx.fillStyle = onColor;
|
|
ctx.textBaseline = "top";
|
|
ctx.fillText(el.text, el.xOffset * s + pos, el.yOffset * s);
|
|
ctx.restore();
|
|
|
|
const speed = (el.scrollSpeed + 1) / 16 * s;
|
|
pos += el.flags.invertDirection ? speed : -speed;
|
|
if (el.flags.endless) {
|
|
if (pos < -textPx) pos = el.width * s;
|
|
if (pos > el.width * s) pos = -textPx;
|
|
}
|
|
this.hScrollPos.set(id, pos);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MonoDisplayDriver — public API surface
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Top-level driver. Attach to a canvas by ID, call load() with an async
|
|
* factory that returns a .bin ArrayBuffer.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const driver = new MonoDisplayDriver("canvas_root", { fps: 25 });
|
|
* driver.load(() => loadBinFile("display.bin"));
|
|
* ```
|
|
*/
|
|
export class MonoDisplayDriver {
|
|
private canvas: HTMLCanvasElement;
|
|
private opts: Required<MonoDisplayDriverOptions>;
|
|
private parser: MonoDisplayParser;
|
|
private renderer: MonoDisplayRenderer | null = null;
|
|
|
|
constructor(canvasId: string, options: MonoDisplayDriverOptions = {}) {
|
|
const el = document.getElementById(canvasId);
|
|
if (!el || el.tagName !== "CANVAS") throw new Error(`#${canvasId} is not a <canvas>`);
|
|
this.canvas = el as HTMLCanvasElement;
|
|
this.opts = {
|
|
onColor: options.onColor ?? "#ffffff",
|
|
offColor: options.offColor ?? "#000000",
|
|
scale: options.scale ?? 1,
|
|
displayWidth: options.displayWidth ?? 160,
|
|
displayHeight: options.displayHeight ?? 80,
|
|
fps: options.fps ?? 25,
|
|
loop: options.loop ?? true,
|
|
onError: options.onError ?? ((e: Error) => { throw e; }),
|
|
};
|
|
this.parser = new MonoDisplayParser();
|
|
}
|
|
|
|
async load(loader: () => Promise<ArrayBuffer>): Promise<void> {
|
|
try {
|
|
const buffer = await loader();
|
|
const file = this.parser.parse(buffer);
|
|
if (!this.renderer) this.renderer = new MonoDisplayRenderer(this.canvas, this.opts);
|
|
this.renderer.render(file);
|
|
} catch (err) {
|
|
this.opts.onError(err instanceof Error ? err : new Error(String(err)));
|
|
}
|
|
}
|
|
|
|
stop(): void { this.renderer?.stop(); }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MonoDisplayFile — JSON descriptor → binary .bin builder
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Encodes a MonoDisplayFileDescriptor to a valid .bin ArrayBuffer.
|
|
*
|
|
* elements_always → Section type 1 (ElementsAlways).
|
|
* Pass a DrawElement[] or { flags, elements }.
|
|
* Default flags: drawFront=true, drawBack=false, clearBuffer=false.
|
|
*
|
|
* PLAN: encode ElementsTimespan sections, CustomFont sections.
|
|
*/
|
|
export class MonoDisplayFile {
|
|
private desc: MonoDisplayFileDescriptor;
|
|
|
|
constructor(descriptor: MonoDisplayFileDescriptor) {
|
|
this.desc = descriptor;
|
|
}
|
|
|
|
toBuffer(): ArrayBuffer {
|
|
const sections: Uint8Array[] = [];
|
|
|
|
// Section 1: elements always
|
|
const alwaysDesc = this.desc.elements_always;
|
|
if (alwaysDesc) {
|
|
const isArray = Array.isArray(alwaysDesc);
|
|
const elements = isArray ? alwaysDesc as DrawElement[] : (alwaysDesc as ElementsAlwaysDescriptor).elements;
|
|
const flags = isArray ? undefined : (alwaysDesc as ElementsAlwaysDescriptor).flags;
|
|
sections.push(this.#encodeElementsSection(SectionType.ElementsAlways, flags, elements));
|
|
}
|
|
|
|
// File header (12 bytes)
|
|
const hdrBuf = new ArrayBuffer(12);
|
|
const hdrView = new DataView(hdrBuf);
|
|
hdrView.setUint32(0, MAGIC, true);
|
|
hdrView.setUint32(4, 1, true); // version
|
|
hdrView.setUint16(8, sections.length, true);
|
|
hdrView.setUint16(10, 0, true); // reserved
|
|
|
|
return this.#concat(new Uint8Array(hdrBuf), ...sections);
|
|
}
|
|
|
|
// --- section encoders ---
|
|
|
|
#encodeElementsSection(
|
|
type: SectionType.ElementsAlways | SectionType.ElementsTimespan,
|
|
flags: SectionFlags | undefined,
|
|
elements: DrawElement[],
|
|
): Uint8Array {
|
|
const flagBits =
|
|
(flags?.drawFront ?? true ? 0x01 : 0) |
|
|
(flags?.drawBack ?? false ? 0x02 : 0) |
|
|
(flags?.clearBuffer ?? false ? 0x04 : 0);
|
|
|
|
const encodedEls = elements.map(el => this.#encodeElement(el));
|
|
const sectionDataSize = 4 + encodedEls.reduce((s, e) => s + e.byteLength, 0);
|
|
// sectionSize (in header) = 4 (header) + sectionDataSize
|
|
const sectionSize = 4 + sectionDataSize;
|
|
|
|
const hdr = new Uint8Array(4 + 4); // section header (4) + section sub-header (4)
|
|
const v = new DataView(hdr.buffer);
|
|
v.setUint8(0, type);
|
|
v.setUint8(1, sectionSize & 0xFF);
|
|
v.setUint8(2, (sectionSize >> 8) & 0xFF);
|
|
v.setUint8(3, (sectionSize >> 16) & 0xFF);
|
|
v.setUint16(4, flagBits, true); // flags
|
|
v.setUint16(6, elements.length, true); // numElements
|
|
|
|
return this.#concat(hdr, ...encodedEls);
|
|
}
|
|
|
|
// --- element encoders ---
|
|
|
|
#encodeElement(el: DrawElement): Uint8Array {
|
|
switch (el.type) {
|
|
case ElementType.Image2D: return this.#encodeImage2D(el);
|
|
case ElementType.Animation: return this.#encodeAnimation(el);
|
|
case ElementType.HorizontalScroll: return this.#encodeHScroll(el);
|
|
case ElementType.VerticalScroll: return this.#encodeVScroll(el);
|
|
case ElementType.Line: return this.#encodeLine(el);
|
|
case ElementType.ClippedText: return this.#encodeClippedText(el);
|
|
case ElementType.HScrollText: return this.#encodeHScrollText(el);
|
|
}
|
|
}
|
|
|
|
#encodeImage2D(el: Image2DElement): Uint8Array {
|
|
const packed = packPixels(el.pixels, el.width, el.height);
|
|
const fixed = 12; // bytes before pixel data
|
|
const rawSize = fixed + packed.byteLength;
|
|
const total = rawSize + pad32(rawSize);
|
|
const out = new Uint8Array(total);
|
|
const v = new DataView(out.buffer);
|
|
v.setUint16(0, ElementType.Image2D, true);
|
|
v.setUint16(2, el.xOffset ?? 0, true);
|
|
v.setUint16(4, el.yOffset ?? 0, true);
|
|
v.setUint16(6, el.width, true);
|
|
v.setUint16(8, el.height, true);
|
|
v.setUint16(10, 0, true); // reserved
|
|
out.set(packed, 12);
|
|
return out;
|
|
}
|
|
|
|
#encodeAnimation(el: AnimationElement): Uint8Array {
|
|
const frameBytes = packedSize(el.width, el.height);
|
|
const fixed = 16;
|
|
const rawSize = fixed + el.frames.length * frameBytes;
|
|
const total = rawSize + pad32(rawSize);
|
|
const out = new Uint8Array(total);
|
|
const v = new DataView(out.buffer);
|
|
v.setUint16(0, ElementType.Animation, true);
|
|
v.setUint16(2, el.xOffset ?? 0, true);
|
|
v.setUint16(4, el.yOffset ?? 0, true);
|
|
v.setUint16(6, el.width, true);
|
|
v.setUint16(8, el.height, true);
|
|
v.setUint16(10, el.frames.length, true);
|
|
v.setUint16(12, el.updateInterval ?? 0, true);
|
|
v.setUint16(14, 0, true); // reserved
|
|
let off = 16;
|
|
for (const f of el.frames) {
|
|
out.set(packPixels(f.pixels, el.width, el.height), off);
|
|
off += frameBytes;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
#encodeScrollFlags(f: ScrollElementFlags | undefined): number {
|
|
return (
|
|
(f?.endless ? 0x01 : 0) |
|
|
(f?.invertDirection ? 0x02 : 0) |
|
|
(f?.padStart ? 0x04 : 0) |
|
|
(f?.padEnd ? 0x08 : 0)
|
|
);
|
|
}
|
|
|
|
#encodeHScroll(el: HScrollElement): Uint8Array {
|
|
const packed = packPixels(el.pixels, el.contentWidth, el.height);
|
|
const fixed = 16;
|
|
const rawSize = fixed + packed.byteLength;
|
|
const total = rawSize + pad32(rawSize);
|
|
const out = new Uint8Array(total);
|
|
const v = new DataView(out.buffer);
|
|
v.setUint16(0, ElementType.HorizontalScroll, true);
|
|
v.setUint16(2, el.xOffset ?? 0, true);
|
|
v.setUint16(4, el.yOffset ?? 0, true);
|
|
v.setUint16(6, el.width, true);
|
|
v.setUint16(8, el.height, true);
|
|
v.setUint16(10, el.contentWidth, true);
|
|
v.setUint8( 12, this.#encodeScrollFlags(el.flags));
|
|
v.setUint8( 13, el.scrollSpeed ?? 0);
|
|
v.setUint16(14, 0, true); // reserved
|
|
out.set(packed, 16);
|
|
return out;
|
|
}
|
|
|
|
#encodeVScroll(el: VScrollElement): Uint8Array {
|
|
const packed = packPixels(el.pixels, el.width, el.contentHeight);
|
|
const fixed = 16;
|
|
const rawSize = fixed + packed.byteLength;
|
|
const total = rawSize + pad32(rawSize);
|
|
const out = new Uint8Array(total);
|
|
const v = new DataView(out.buffer);
|
|
v.setUint16(0, ElementType.VerticalScroll, true);
|
|
v.setUint16(2, el.xOffset ?? 0, true);
|
|
v.setUint16(4, el.yOffset ?? 0, true);
|
|
v.setUint16(6, el.width, true);
|
|
v.setUint16(8, el.height, true);
|
|
v.setUint16(10, el.contentHeight, true);
|
|
v.setUint8( 12, this.#encodeScrollFlags(el.flags));
|
|
v.setUint8( 13, el.scrollSpeed ?? 0);
|
|
v.setUint16(14, 0, true);
|
|
out.set(packed, 16);
|
|
return out;
|
|
}
|
|
|
|
#encodeLine(el: LineElement): Uint8Array {
|
|
// 12 bytes, already 32-bit aligned
|
|
const out = new Uint8Array(12);
|
|
const v = new DataView(out.buffer);
|
|
v.setUint16(0, ElementType.Line, true);
|
|
v.setUint16(2, el.xOrigin, true);
|
|
v.setUint16(4, el.yOrigin, true);
|
|
v.setUint16(6, el.xTarget, true);
|
|
v.setUint16(8, el.yTarget, true);
|
|
v.setUint8(10, el.lineStyle ?? 0);
|
|
v.setUint8(11, el.invertPixels ? 1 : 0);
|
|
return out;
|
|
}
|
|
|
|
#encodeClippedText(el: ClippedTextElement): Uint8Array {
|
|
const enc = new TextEncoder().encode(el.text);
|
|
const fixed = 14; // 2+2+2+2+2+2+2
|
|
const rawSize = fixed + enc.byteLength;
|
|
const total = rawSize + pad32(rawSize);
|
|
const out = new Uint8Array(total);
|
|
const v = new DataView(out.buffer);
|
|
v.setUint16(0, ElementType.ClippedText, true);
|
|
v.setUint16(2, el.xOffset ?? 0, true);
|
|
v.setUint16(4, el.yOffset ?? 0, true);
|
|
v.setUint16(6, el.width, true);
|
|
v.setUint16(8, el.height, true);
|
|
v.setUint16(10, el.fontIndex ?? 0, true);
|
|
v.setUint16(12, enc.byteLength, true);
|
|
out.set(enc, 14);
|
|
return out;
|
|
}
|
|
|
|
#encodeHScrollText(el: HScrollTextElement): Uint8Array {
|
|
const enc = new TextEncoder().encode(el.text);
|
|
const fixed = 16; // 2+2+2+2+2+1+1+2+2
|
|
const rawSize = fixed + enc.byteLength;
|
|
const total = rawSize + pad32(rawSize);
|
|
const out = new Uint8Array(total);
|
|
const v = new DataView(out.buffer);
|
|
v.setUint16(0, ElementType.HScrollText, true);
|
|
v.setUint16(2, el.xOffset ?? 0, true);
|
|
v.setUint16(4, el.yOffset ?? 0, true);
|
|
v.setUint16(6, el.width, true);
|
|
v.setUint16(8, el.height, true);
|
|
v.setUint8( 10, this.#encodeScrollFlags(el.flags));
|
|
v.setUint8( 11, el.scrollSpeed ?? 0);
|
|
v.setUint16(12, el.fontIndex ?? 0, true);
|
|
v.setUint16(14, enc.byteLength, true); // PLAN: confirm with spec
|
|
out.set(enc, 16);
|
|
return out;
|
|
}
|
|
|
|
// --- utilities ---
|
|
|
|
#concat(...parts: Uint8Array[]): Uint8Array {
|
|
const total = parts.reduce((s, p) => s + p.byteLength, 0);
|
|
const out = new Uint8Array(total);
|
|
let off = 0;
|
|
for (const p of parts) { out.set(p, off); off += p.byteLength; }
|
|
return out;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function loadBinFile(url: string): Promise<ArrayBuffer> {
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`loadBinFile: HTTP ${res.status} for ${url}`);
|
|
return res.arrayBuffer();
|
|
}
|
|
|
|
/**
|
|
* Build a single-element-always .bin buffer containing one Image2D element.
|
|
* Convenience for tests; for production use MonoDisplayFile directly.
|
|
*/
|
|
export function buildBinBuffer(el: Image2DElement): ArrayBuffer {
|
|
return new MonoDisplayFile({ elements_always: [el] }).toBuffer();
|
|
}
|
|
|