From 40035ea258ff9f9dd357723f45f73f28a73e78d9 Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH] first rework --- ts/src/library.ts | 1726 ++++++++++++++++++++++++++------------------- 1 file changed, 1018 insertions(+), 708 deletions(-) diff --git a/ts/src/library.ts b/ts/src/library.ts index b80fad7..5996463 100644 --- a/ts/src/library.ts +++ b/ts/src/library.ts @@ -1,398 +1,388 @@ // ============================================================================= // MonoDisplay Library — binary format parser + canvas renderer // -// Binary format (little-endian throughout): -// [FILE HEADER] -// 4 bytes magic "MONO" (0x4D 0x4F 0x4E 0x4F) -// 1 byte version format version (currently 1) -// 1 byte content_type 0=static 1=animation 2=text 3=scrolltext -// 2 bytes width display width in pixels -// 2 bytes height display height in pixels +// Spec: Mono Display File Format Specification (see adjacent .rst) // -// [CONTENT — varies by content_type] -// static: -// (width*height) bytes raw 1bpp pixel rows, MSB-first within each byte +// File layout (little-endian throughout): // -// animation: -// 2 bytes frame_count -// per frame: -// 4 bytes duration_ms -// (width*height) bytes pixel rows same as static +// [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 // -// text: -// 2 bytes text_len -// text_len bytes UTF-8 string (no null terminator) -// 1 byte font_id reserved, ignored for now -// 1 byte align 0=left 1=center 2=right +// [SECTION — repeated numSections times] +// 1 byte sectionType uint8 +// 3 bytes sectionSize uint24 LE, INCLUDES these 4 header bytes +// sectionSize-4 bytes of section-specific data +// to 32-bit boundary (included in sectionSize) // -// scrolltext: -// 2 bytes text_len -// text_len bytes UTF-8 string -// 2 bytes speed_pps pixels per second (scroll rate) -// 1 byte direction 0=left 1=right +// 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… // -// PLAN: once the real spec arrives, swap out header offsets / field widths here. -// All parsing is centralised in MonoDisplayParser.parse() so callers never -// touch raw offsets. +// 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). // ============================================================================= // --------------------------------------------------------------------------- -// Public types — all exported so browser users can import them +// Enums // --------------------------------------------------------------------------- -/** Supported content types embedded in a .bin display file */ -export type DisplayContentType = "static" | "animation" | "text" | "scrolltext"; - -/** A single monochrome bitmap. Pixels are row-major, 1 byte per pixel (0/1). */ -export interface StaticFrame { - type: "static"; - width: number; - height: number; - /** Unpacked: 1 byte per pixel, 0 = off, 1 = on */ - pixels: Uint8Array; -} - -/** One frame inside an animation */ -export interface AnimFrame { - /** How long this frame is displayed */ - durationMs: number; - pixels: Uint8Array; +export enum SectionType { + ElementsAlways = 1, + ElementsTimespan = 2, + CustomFont = 32, } -export interface Animation { - type: "animation"; - width: number; - height: number; - frames: AnimFrame[]; +export enum ElementType { + Image2D = 1, + Animation = 2, + HorizontalScroll = 3, + VerticalScroll = 4, + Line = 5, + ClippedText = 16, + HScrollText = 17, + // NOTE: spec shows CurrentTime as type 16 — likely a spec typo; treat as reserved until clarified } -export interface TextDisplay { - type: "text"; - text: string; - /** 0=left 1=center 2=right — used by renderer */ - align: 0 | 1 | 2; - /** Reserved font index; renderer maps this to an embedded bitmap font */ - fontId: number; -} +// --------------------------------------------------------------------------- +// Section flags (section types 1 and 2) +// --------------------------------------------------------------------------- -export interface ScrollText { - type: "scrolltext"; - text: string; - /** Scroll speed in pixels per second */ - speedPps: number; - /** 0=left 1=right */ - direction: 0 | 1; +export interface SectionFlags { + /** Bit 0: draw on the front side (default true for elements_always) */ + drawFront?: boolean; + /** Bit 1: draw on the back side */ + drawBack?: boolean; + /** Bit 2: clear the draw buffer(s) before drawing elements in this section */ + clearBuffer?: boolean; + // bits 3-15: reserved, must be 0 when writing } -export type DisplayContent = StaticFrame | Animation | TextDisplay | ScrollText; - // --------------------------------------------------------------------------- -// MonoDisplayFile — JSON-descriptor → binary builder +// Scroll flags (HorizontalScroll / VerticalScroll elements) // --------------------------------------------------------------------------- -export enum ElementType { - /** Positioned 1bpp bitmap. Composited with OR-blend onto the display canvas. */ - Image2D = "Image2D", - /** Multi-frame bitmap animation. Each frame has its own pixel data and duration. */ - Animation = "Animation", - /** Static text rendered with the built-in bitmap font (stub until font spec lands). */ - Text = "Text", - /** Horizontally scrolling text ticker. */ - ScrollText = "ScrollText", +export interface ScrollElementFlags { + endless?: boolean; // bit 0 + invertDirection?: boolean; // bit 1 + padStart?: boolean; // bit 2: padLeft (H) / padTop (V) + padEnd?: boolean; // bit 3: padRight (H) / padBottom (V) } -/** Positioned 1bpp image, composited onto the display canvas */ +// --------------------------------------------------------------------------- +// User-facing element descriptors (MonoDisplayFile input) +// Pixels are always 1 byte/pixel (0=off, 1=on) in the API; packed at write time. +// --------------------------------------------------------------------------- + export interface Image2DElement { type: ElementType.Image2D; - /** 1 byte per pixel (0=off 1=on), row-major */ + /** 1 byte per pixel (0=off 1=on), row-major. Packed to 1bpp in binary. */ pixels: Uint8Array; - width: number; - height: number; - /** Pixel offset from left edge. Default 0. */ - xoffset?: number; - /** Pixel offset from top edge. Default 0. */ - yoffset?: number; + width: number; + height: number; + xOffset?: number; // default 0 + yOffset?: number; // default 0 } -/** One frame in an AnimationElement */ -export interface AnimationFrame { +export interface AnimationFrameDescriptor { + /** 1 byte per pixel, same dimensions as parent AnimationElement */ pixels: Uint8Array; - durationMs: number; } -/** Multi-frame animation section. width/height shared by all frames. */ export interface AnimationElement { type: ElementType.Animation; - width: number; + width: number; height: number; - frames: AnimationFrame[]; + frames: AnimationFrameDescriptor[]; + /** + * Advance frame every (updateInterval+1) ticks. + * 0 = every tick, 1 = every 2nd tick, etc. Default 0. + */ + updateInterval?: number; + xOffset?: number; + yOffset?: number; +} + +export interface HScrollElement { + type: ElementType.HorizontalScroll; + width: number; // viewport width + height: number; // viewport height + /** Pixel content to scroll horizontally; must be (contentWidth × height) pixels */ + pixels: Uint8Array; // 1 byte/pixel + contentWidth: number; + /** + * Scroll speed byte SS; moves (SS+1)/16 pixels per tick. Default 0 (1/16 px/tick). + * Range 0-255. + */ + scrollSpeed?: number; + flags?: ScrollElementFlags; + xOffset?: number; + yOffset?: number; +} + +export interface VScrollElement { + type: ElementType.VerticalScroll; + width: number; + height: number; // viewport height + pixels: Uint8Array; // 1 byte/pixel + contentHeight: number; + scrollSpeed?: number; + flags?: ScrollElementFlags; + xOffset?: number; + yOffset?: number; } -/** Static text rendered by the driver's bitmap font */ -export interface TextElement { - type: ElementType.Text; - text: string; - /** 0=left 1=center 2=right. Default 0. */ - align?: 0 | 1 | 2; - /** Font index (reserved). Default 0. */ - fontId?: number; +export interface LineElement { + type: ElementType.Line; + xOrigin: number; + yOrigin: number; + xTarget: number; + yTarget: number; + lineStyle?: number; // reserved, default 0 + invertPixels?: boolean; // flags bit 0 } -/** Scrolling text ticker */ -export interface ScrollTextElement { - type: ElementType.ScrollText; - text: string; - /** Pixels per second. Default 60. */ - speedPps?: number; - /** 0=scroll left (→off left edge) 1=scroll right. Default 0. */ - direction?: 0 | 1; +export interface ClippedTextElement { + type: ElementType.ClippedText; + text: string; + width: number; + height: number; + /** 0-32767 built-in font, 0x8000+ custom font in file. Default 0. */ + fontIndex?: number; + xOffset?: number; + yOffset?: number; } -export type DisplayElement = +export interface HScrollTextElement { + type: ElementType.HScrollText; + text: string; + width: number; + height: number; + scrollSpeed?: number; // SS byte, default 0 + fontIndex?: number; + flags?: ScrollElementFlags; + xOffset?: number; + yOffset?: number; +} + +export type DrawElement = | Image2DElement | AnimationElement - | TextElement - | ScrollTextElement; + | HScrollElement + | VScrollElement + | LineElement + | ClippedTextElement + | HScrollTextElement; -/** - * JSON descriptor for MonoDisplayFile. - * - * elements_always — rendered on every frame regardless of state. - * PLAN: elements_conditional — state-machine-driven visibility (alerts, overrides). - * - * width / height — display canvas in pixels. Omit to infer from Image2D element bounds. - * Text/ScrollText/Animation ignore canvas size (section has no position). - */ -export interface MonoDisplayFileDescriptor { - width?: number; - height?: number; - elements_always?: DisplayElement[]; +// --------------------------------------------------------------------------- +// MonoDisplayFile descriptor +// --------------------------------------------------------------------------- + +export interface ElementsAlwaysDescriptor { + flags?: SectionFlags; + elements: DrawElement[]; } /** - * Builds a binary .bin file from a JSON descriptor. + * JSON descriptor passed to MonoDisplayFile. * - * Dispatch rules (first element_always wins for section type): - * Image2D only → static section (0x01), composited with OR-blend - * Animation → animation section (0x02) - * Text → text section (0x03) - * ScrollText → scrolltext section (0x04) + * elements_always — section type 1; drawn on every render tick. + * Pass a DrawElement[] as shorthand (flags default to drawFront=true). * - * PLAN: multiple sections per file once spec defines multi-section rendering. + * PLAN: elements_timespan (section 2), custom_fonts (section 32) */ -export class MonoDisplayFile { - private desc: MonoDisplayFileDescriptor; +export interface MonoDisplayFileDescriptor { + elements_always?: DrawElement[] | ElementsAlwaysDescriptor; +} - constructor(descriptor: MonoDisplayFileDescriptor) { - this.desc = descriptor; - } +// --------------------------------------------------------------------------- +// MonoFormat types (parser output → renderer input) +// Pixels are always unpacked (1 byte/pixel) after parsing. +// --------------------------------------------------------------------------- - toBuffer(): ArrayBuffer { - const elements = this.desc.elements_always ?? []; +export interface MonoFormatPixelImage { + /** Unpacked: 1 byte per pixel, 0=off 1=on, row-major */ + pixels: Uint8Array; + width: number; + height: number; +} - // Dispatch on first element's type (or default to blank static) - const first = elements[0]; - if (!first || first.type === ElementType.Image2D) { - return this.#encodeStatic(); - } - switch (first.type) { - case ElementType.Animation: return this.#encodeAnimation(first); - case ElementType.Text: return this.#encodeText(first); - case ElementType.ScrollText: return this.#encodeScrollText(first); - } - } +export interface MonoFormatImage2D { + type: ElementType.Image2D; + xOffset: number; + yOffset: number; + image: MonoFormatPixelImage; +} - // --- static (section 0x01) --- - - #encodeStatic(): ArrayBuffer { - const { width, height } = this.#resolveSize(); - const pixels = this.#composite(width, height); - return this.#wrapSection(0x01, (view, off) => { - view.setUint16(off, width, true); off += 2; - view.setUint16(off, height, true); off += 2; - new Uint8Array(view.buffer, off).set(pixels); - return off + pixels.byteLength; - }, 2 + 2 + width * height); - } - - // --- animation (section 0x02) --- - - #encodeAnimation(el: AnimationElement): ArrayBuffer { - const pixelBytes = el.width * el.height; - // width(2) + height(2) + frameCount(2) + frames*(durationMs(4) + pixels(w*h)) - const dataSize = 2 + 2 + 2 + el.frames.length * (4 + pixelBytes); - return this.#wrapSection(0x02, (view, off) => { - view.setUint16(off, el.width, true); off += 2; - view.setUint16(off, el.height, true); off += 2; - view.setUint16(off, el.frames.length, true); off += 2; - for (const frame of el.frames) { - view.setUint32(off, frame.durationMs, true); off += 4; - new Uint8Array(view.buffer, off).set(frame.pixels); - off += pixelBytes; - } - return off; - }, dataSize); - } +export interface MonoFormatAnimation { + type: ElementType.Animation; + xOffset: number; + yOffset: number; + width: number; + height: number; + updateInterval: number; + frames: MonoFormatPixelImage[]; +} - // --- text (section 0x03) --- +export interface MonoFormatHScroll { + type: ElementType.HorizontalScroll; + xOffset: number; + yOffset: number; + width: number; + height: number; + contentWidth: number; + scrollSpeed: number; + flags: { endless: boolean; invertDirection: boolean; padStart: boolean; padEnd: boolean }; + content: MonoFormatPixelImage; +} - #encodeText(el: TextElement): ArrayBuffer { - const enc = new TextEncoder().encode(el.text); - // textLen(2) + utf8 + fontId(1) + align(1) - const dataSize = 2 + enc.byteLength + 1 + 1; - return this.#wrapSection(0x03, (view, off) => { - view.setUint16(off, enc.byteLength, true); off += 2; - new Uint8Array(view.buffer, off).set(enc); off += enc.byteLength; - view.setUint8(off, el.fontId ?? 0); off += 1; - view.setUint8(off, el.align ?? 0); off += 1; - return off; - }, dataSize); - } +export interface MonoFormatVScroll { + type: ElementType.VerticalScroll; + xOffset: number; + yOffset: number; + width: number; + height: number; + contentHeight: number; + scrollSpeed: number; + flags: { endless: boolean; invertDirection: boolean; padStart: boolean; padEnd: boolean }; + content: MonoFormatPixelImage; +} - // --- scrolltext (section 0x04) --- +export interface MonoFormatLine { + type: ElementType.Line; + xOrigin: number; + yOrigin: number; + xTarget: number; + yTarget: number; + lineStyle: number; + invertPixels: boolean; +} - #encodeScrollText(el: ScrollTextElement): ArrayBuffer { - const enc = new TextEncoder().encode(el.text); - // textLen(2) + utf8 + speedPps(2) + direction(1) - const dataSize = 2 + enc.byteLength + 2 + 1; - return this.#wrapSection(0x04, (view, off) => { - view.setUint16(off, enc.byteLength, true); off += 2; - new Uint8Array(view.buffer, off).set(enc); off += enc.byteLength; - view.setUint16(off, el.speedPps ?? 60, true); off += 2; - view.setUint8(off, el.direction ?? 0); off += 1; - return off; - }, dataSize); - } +export interface MonoFormatClippedText { + type: ElementType.ClippedText; + xOffset: number; + yOffset: number; + width: number; + height: number; + fontIndex: number; + text: string; +} - // --- shared file/section envelope --- +export interface MonoFormatHScrollText { + type: ElementType.HScrollText; + xOffset: number; + yOffset: number; + width: number; + height: number; + scrollSpeed: number; + fontIndex: number; + flags: { endless: boolean; invertDirection: boolean; padStart: boolean; padEnd: boolean }; + text: string; +} - /** - * Allocates a buffer with a 12-byte file header + 4-byte section header + - * sectionDataSize bytes, then calls write(view, dataOffset) to fill data. - */ - #wrapSection( - sectionType: number, - write: (view: DataView, dataOffset: number) => number, - sectionDataSize: number, - ): ArrayBuffer { - const buf = new ArrayBuffer(12 + 4 + sectionDataSize); - const view = new DataView(buf); - let off = 0; +export type MonoFormatElement = + | MonoFormatImage2D + | MonoFormatAnimation + | MonoFormatHScroll + | MonoFormatVScroll + | MonoFormatLine + | MonoFormatClippedText + | MonoFormatHScrollText; + +export interface MonoFormatSectionFlags { + drawFront: boolean; + drawBack: boolean; + clearBuffer: boolean; +} - // File header - view.setUint32(off, MAGIC, true); off += 4; - view.setUint32(off, 1, true); off += 4; // version - view.setUint16(off, 1, true); off += 2; // numSections - view.setUint16(off, 0, true); off += 2; // reserved +export interface MonoFormatElementsAlways { + sectionType: SectionType.ElementsAlways; + flags: MonoFormatSectionFlags; + elements: MonoFormatElement[]; +} - // Section header: type(1) + size uint24 LE(3) - view.setUint8(off, sectionType); off += 1; - view.setUint8(off, sectionDataSize & 0xFF); - view.setUint8(off + 1, (sectionDataSize >> 8) & 0xFF); - view.setUint8(off + 2, (sectionDataSize >> 16) & 0xFF); - off += 3; +export interface MonoFormatElementsTimespan { + sectionType: SectionType.ElementsTimespan; + flags: MonoFormatSectionFlags; + startTimestamp: bigint; // POSIX seconds + endTimestamp: bigint; + elements: MonoFormatElement[]; +} - write(view, off); - return buf; - } +export interface MonoFormatCustomFont { + sectionType: SectionType.CustomFont; + /** Raw U8G2 font data */ + fontData: Uint8Array; +} - // --- size inference for Image2D compositing --- +export type MonoFormatSection = MonoFormatElementsAlways | MonoFormatElementsTimespan | MonoFormatCustomFont; - #resolveSize(): { width: number; height: number } { - const elements = (this.desc.elements_always ?? []) - .filter((el): el is Image2DElement => el.type === ElementType.Image2D); +/** Top-level parser output */ +export interface MonoFormatFile { + sections: MonoFormatSection[]; +} - let width = this.desc.width; - let height = this.desc.height; +// --------------------------------------------------------------------------- +// Pixel pack/unpack helpers +// --------------------------------------------------------------------------- - if (width === undefined || height === undefined) { - let maxX = 0, maxY = 0; - for (const el of elements) { - maxX = Math.max(maxX, (el.xoffset ?? 0) + el.width); - maxY = Math.max(maxY, (el.yoffset ?? 0) + el.height); - } - width ??= maxX || 160; - height ??= maxY || 80; - } +/** + * 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; +} - return { width, height }; - } - - #composite(width: number, height: number): Uint8Array { - const canvas = new Uint8Array(width * height); - for (const el of this.desc.elements_always ?? []) { - if (el.type !== ElementType.Image2D) continue; - const ox = el.xoffset ?? 0; - const oy = el.yoffset ?? 0; - for (let row = 0; row < el.height; row++) { - const destY = oy + row; - if (destY < 0 || destY >= height) continue; - for (let col = 0; col < el.width; col++) { - const destX = ox + col; - if (destX < 0 || destX >= width) continue; - // OR-blend — PLAN: add blend mode enum once spec covers it - if (el.pixels[row * el.width + col]) canvas[destY * width + destX] = 1; - } - } - } - return canvas; +/** + * 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; } -/** 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 becomes NxN canvas pixels. - * Default 1. When CSS stretches the canvas for display, keep this at 1 - * and let the browser handle upscaling (use image-rendering: pixelated). - */ - scale?: number; - /** - * Logical display width in pixels. Used as canvas resolution baseline for - * content types that have no inherent size (text, scrolltext). - * Also used when static/animation content overflows this width — clipped. - * Default 160. - */ - displayWidth?: number; - /** - * Logical display height in pixels. Default 80. - */ - displayHeight?: number; - /** - * If true, driver loops animations forever. Default true. - * Set false to stop after one pass. - */ - loop?: boolean; - /** - * Target render rate in frames per second for continuous content - * (scrolltext, animation). Default 25. The browser rAF / setTimeout loop - * skips drawing if the frame would land earlier than 1000/fps ms after the - * previous draw, keeping CPU/GPU load close to the target rate. - * Note: animation frame *duration* from the file takes precedence when it - * is longer than the render interval (slow animations stay slow). - */ - fps?: number; - /** - * Callback fired whenever the driver encounters a parse or render error. - * If omitted, errors are thrown. - */ - onError?: (err: Error) => void; +/** 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 // --------------------------------------------------------------------------- -/** - * Stateful cursor that reads little-endian primitives from an ArrayBuffer. - * - * PLAN: if the spec ever needs signed types (int16, int32) or floats, add - * readInt16 / readFloat32 etc. here — all other code stays unchanged. - */ export class BinaryReader { private view: DataView; private pos: number = 0; @@ -401,420 +391,569 @@ export class BinaryReader { this.view = new DataView(buffer); } - get offset(): number { - return this.pos; - } - - get byteLength(): number { - return this.view.byteLength; - } + get offset(): number { return this.pos; } + get byteLength(): number { return this.view.byteLength; } + get remaining(): number { return this.view.byteLength - this.pos; } - 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; } - /** Read one unsigned 8-bit integer */ - readByte(): number { - this.#assertRemaining(1); - return this.view.getUint8(this.pos++); - } - - /** Read unsigned 16-bit integer, little-endian */ - readUint16(): number { - this.#assertRemaining(2); - const v = this.view.getUint16(this.pos, true); - this.pos += 2; + 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; } - /** Alias kept for API clarity ("short" in spec language) */ - readShort(): number { - return this.readUint16(); - } - - /** Read unsigned 32-bit integer, little-endian */ - readUint32(): number { - this.#assertRemaining(4); - const v = this.view.getUint32(this.pos, true); - this.pos += 4; - return v; - } + readUint32(): number { this.#need(4); const v = this.view.getUint32(this.pos, true); this.pos += 4; return v; } - /** - * Read unsigned 64-bit integer, little-endian. - * Returns bigint because JS number cannot represent all uint64 values. - */ readUint64(): bigint { - this.#assertRemaining(8); - const lo = BigInt(this.view.getUint32(this.pos, true)); - const hi = BigInt(this.view.getUint32(this.pos + 4, true)); + 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; } - /** Read N raw bytes as a Uint8Array (zero-copy view) */ readBytes(n: number): Uint8Array { - this.#assertRemaining(n); - const slice = new Uint8Array(this.view.buffer, this.pos, n); + this.#need(n); + const s = new Uint8Array(this.view.buffer, this.pos, n); this.pos += n; - return slice; + return s; } - /** Read N bytes and decode as UTF-8 */ - readUtf8(n: number): string { - return new TextDecoder().decode(this.readBytes(n)); - } + readUtf8(n: number): string { return new TextDecoder().decode(this.readBytes(n)); } - /** Read unsigned 24-bit integer, little-endian (3-byte field in section headers) */ - readUint24(): number { - this.#assertRemaining(3); - const lo = this.view.getUint8(this.pos); - const mid = this.view.getUint8(this.pos + 1); - const hi = this.view.getUint8(this.pos + 2); - this.pos += 3; - return lo | (mid << 8) | (hi << 16); - } - - /** Move cursor to absolute byte position */ seek(pos: number): void { if (pos < 0 || pos > this.view.byteLength) { - throw new RangeError(`seek(${pos}) out of bounds [0, ${this.view.byteLength}]`); + throw new RangeError(`seek(${pos}) out of [0, ${this.view.byteLength}]`); } this.pos = pos; } - #assertRemaining(n: number): void { - if (this.remaining < n) { - throw new RangeError( - `BinaryReader: need ${n} byte(s) at offset ${this.pos}, only ${this.remaining} remain` - ); - } + #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 (real spec) +// File format constants // --------------------------------------------------------------------------- -// File magic: 0xAF 0x7E 0x2B 0x63 (read as uint32 LE = 0x632B7EAF) +// Magic 0xAF 0x7E 0x2B 0x63 read as uint32 LE const MAGIC = 0x632B7EAF; -/** - * File header layout (12 bytes total): - * - * offset size field - * ────── ──── ───────────────────────────────────────────── - * 0 4 Magic (0xAF 0x7E 0x2B 0x63) - * 4 4 Version (uint32 LE; 0=illegal, 1=current, 2+=reserved) - * PLAN: spec shows full 4-byte row — confirm if uint8+padding or uint32 - * 8 2 Number of sections (uint16 LE) - * 10 2 Reserved (must be 0) - * - * Section header layout (4 bytes): - * - * offset size field - * ────── ──── ───────────────────────────────────────────── - * 0 1 Section type (uint8) - * 1 3 Section data size in bytes (uint24 LE), excludes header - * - * Section types (placeholder values — PLAN: confirm with full spec): - * 0x01 static frame width(u16) height(u16) pixels[w*h] - * 0x02 animation width(u16) height(u16) frameCount(u16) frames… - * 0x03 text textLen(u16) utf8… fontId(u8) align(u8) - * 0x04 scrolltext textLen(u16) utf8… speedPps(u16) direction(u8) - */ - -const SectionType: Record = { - 0x01: "static", - 0x02: "animation", - 0x03: "text", - 0x04: "scrolltext", -}; - // --------------------------------------------------------------------------- // MonoDisplayParser // --------------------------------------------------------------------------- -/** - * Parses a .bin ArrayBuffer into a strongly-typed DisplayContent value. - * - * Returns the first recognised section's content. Files with numSections=0 - * throw. Multiple sections: only the first supported type is returned for now. - * PLAN: expose all sections as DisplayContent[] once the full spec is clear. - */ export class MonoDisplayParser { - parse(buffer: ArrayBuffer): DisplayContent { + parse(buffer: ArrayBuffer): MonoFormatFile { const r = new BinaryReader(buffer); - // --- file header (12 bytes) --- + // File header (12 bytes) const magic = r.readUint32(); - if (magic !== MAGIC) { - throw new Error( - `Invalid magic 0x${magic.toString(16).toUpperCase()} — expected 0x632B7EAF` - ); - } + if (magic !== MAGIC) throw new Error(`Bad magic 0x${magic.toString(16)} — expected 0x632B7EAF`); - const version = r.readUint32(); // PLAN: confirm uint32 vs uint8+3pad in spec - if (version === 0 || version > 1) { - throw new Error(`Unsupported format version ${version}`); - } + const version = r.readUint32(); + if (version === 0 || version > 1) throw new Error(`Unsupported version ${version}`); const numSections = r.readUint16(); - r.readUint16(); // reserved — ignored + r.readUint16(); // reserved - if (numSections === 0) { - throw new Error("File contains 0 sections — nothing to render"); - } + const sections: MonoFormatSection[] = []; - // --- section loop --- for (let i = 0; i < numSections; i++) { - // section header (4 bytes) - const sectionType = r.readByte(); - const sectionSize = r.readUint24(); - const sectionStart = r.offset; - - const contentType = SectionType[sectionType]; - if (!contentType) { - // Unknown section — skip it and continue to next - r.seek(sectionStart + sectionSize); - continue; + 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; } - // Parse this section; width/height read from section data for bitmap types - const content = this.#parseSection(r, contentType); + // 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), + }; + } - // Seek past any remaining section bytes (handles padding / future fields) - r.seek(sectionStart + sectionSize); + #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(); + } - return content; // return first recognised section + const elements: MonoFormatElement[] = []; + for (let i = 0; i < numElements; i++) { + const el = this.#parseElement(r); + if (el) elements.push(el); } - throw new Error("No recognised sections found in file"); + if (hasTimestamp) { + return { sectionType: SectionType.ElementsTimespan, flags, startTimestamp, endTimestamp, elements }; + } + return { sectionType: SectionType.ElementsAlways, flags, elements }; } - #parseSection(r: BinaryReader, contentType: DisplayContentType): DisplayContent { - switch (contentType) { - case "static": { - const width = r.readUint16(); - const height = r.readUint16(); - return this.#parseStatic(r, width, height); - } - case "animation": { - const width = r.readUint16(); - const height = r.readUint16(); - return this.#parseAnimation(r, width, height); - } - case "text": return this.#parseText(r); - case "scrolltext": return this.#parseScrollText(r); + #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; } } - #parseStatic(r: BinaryReader, width: number, height: number): StaticFrame { - // PLAN: spec may use packed 1bpp; for now each byte = 1 pixel (0 or 1) - const raw = r.readBytes(width * height); - return { type: "static", width, height, pixels: new Uint8Array(raw) }; + #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) } - #parseAnimation(r: BinaryReader, width: number, height: number): Animation { - const frameCount = r.readUint16(); - const frames: AnimFrame[] = []; + #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 } }; + } - for (let i = 0; i < frameCount; i++) { - const durationMs = r.readUint32(); - const raw = r.readBytes(width * height); - frames.push({ durationMs, pixels: new Uint8Array(raw) }); + #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 }; + } - return { type: "animation", width, height, 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 }, + }; } - #parseText(r: BinaryReader): TextDisplay { - const textLen = r.readUint16(); + #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 fontId = r.readByte(); - const alignByte = r.readByte(); - const align = (alignByte === 1 ? 1 : alignByte === 2 ? 2 : 0) as 0 | 1 | 2; - return { type: "text", text, fontId, align }; + const dataRead = 14 + textLen; + r.seek(start + dataRead + pad32(dataRead)); + return { type: ElementType.ClippedText, xOffset, yOffset, width, height, fontIndex, text }; } - #parseScrollText(r: BinaryReader): ScrollText { - const textLen = r.readUint16(); + #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 speedPps = r.readUint16(); - const dirByte = r.readByte(); - const direction = dirByte === 1 ? 1 : 0 as 0 | 1; - return { type: "scrolltext", text, speedPps, direction }; + 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 — draws DisplayContent onto an HTMLCanvasElement +// 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; +} + /** - * Internal renderer; MonoDisplayDriver owns one instance. + * 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: text/scrolltext rendering currently draws a placeholder box. - * Replace #renderText / #renderScrollText with a real embedded bitmap font - * (5x7 or similar) once the font spec is finalised. + * 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; - // Animation state - private animFrame: number = 0; - private animTimer: number | null = null; - // ScrollText state - private scrollX: number = 0; - private scrollRaf: number | null = null; - // Tracks last rAF timestamp for motion integration (runs every rAF) - private scrollLastTs: number | null = null; - // Tracks last actual draw timestamp for fps throttle - private scrollLastDrawTs: number | null = null; + private tickTimer: number | null = null; + private tickCount: number = 0; + // Per-element state: keyed by stable element index + private animState: Map = new Map(); + private hScrollPos: Map = new Map(); // pixel offset (may be fractional) + private vScrollPos: Map = new Map(); constructor(canvas: HTMLCanvasElement, opts: Required) { const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Cannot get 2D canvas context"); - this.ctx = ctx; + this.ctx = ctx; this.opts = opts; } - /** Stop any running animation / scroll before loading new content */ stop(): void { - if (this.animTimer !== null) { - clearTimeout(this.animTimer); - this.animTimer = null; - } - if (this.scrollRaf !== null) { - cancelAnimationFrame(this.scrollRaf); - this.scrollRaf = null; + if (this.tickTimer !== null) { + clearInterval(this.tickTimer); + this.tickTimer = null; } - this.scrollLastTs = null; - this.scrollLastDrawTs = null; + this.tickCount = 0; + this.animState.clear(); + this.hScrollPos.clear(); + this.vScrollPos.clear(); } - render(content: DisplayContent): void { + render(file: MonoFormatFile): void { this.stop(); - switch (content.type) { - case "static": this.#renderStatic(content); break; - case "animation": this.#startAnimation(content); break; - case "text": this.#renderText(content); break; - case "scrolltext": this.#startScrollText(content); break; + + 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++); + } } } - #resizeCanvas(w: number, h: number): void { - const s = this.opts.scale; - this.ctx.canvas.width = w * s; - this.ctx.canvas.height = h * s; + #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; + } } - #renderPixels(pixels: Uint8Array, width: number, height: number): void { - const { onColor, offColor, scale: s } = this.opts; - const ctx = this.ctx; - ctx.fillStyle = offColor; - ctx.fillRect(0, 0, width * s, height * s); + /** 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 < height; y++) { - for (let x = 0; x < width; x++) { - if (pixels[y * width + x]) { - ctx.fillRect(x * s, y * s, s, s); + 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); } } } } - #renderStatic(content: StaticFrame): void { - this.#resizeCanvas(content.width, content.height); - this.#renderPixels(content.pixels, content.width, content.height); + /** 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); + } + } + } } - #startAnimation(content: Animation): void { - this.#resizeCanvas(content.width, content.height); - this.animFrame = 0; + #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); } - const tick = () => { - if (content.frames.length === 0) return; - const frame = content.frames[this.animFrame]; - this.#renderPixels(frame.pixels, content.width, content.height); - - this.animFrame++; - if (this.animFrame >= content.frames.length) { - if (!this.opts.loop) return; - this.animFrame = 0; - } - // Clamp to fps budget: never draw faster than 1000/fps ms per frame - const minInterval = 1000 / this.opts.fps; - this.animTimer = setTimeout(tick, Math.max(frame.durationMs, minInterval)) as unknown as number; - }; - tick(); + 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; + } } - #renderText(content: TextDisplay): void { - // PLAN: replace with bitmap font renderer; align/fontId will be honoured then - const ctx = this.ctx; - const { displayWidth: dw, displayHeight: dh, scale: s, offColor, onColor } = this.opts; - ctx.canvas.width = dw * s; - ctx.canvas.height = dh * s; - ctx.fillStyle = offColor; - ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); - ctx.fillStyle = onColor; - // Scale font to roughly fill display height; real bitmap font will replace this - const fontSize = Math.max(8, Math.floor(dh * s * 0.6)); - ctx.font = `${fontSize}px monospace`; - ctx.textBaseline = "middle"; - const padding = 4 * s; - ctx.fillText(content.text, padding, ctx.canvas.height / 2); + #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); } - #startScrollText(content: ScrollText): void { - // PLAN: replace inner draw with bitmap font; direction/speed wired in - const ctx = this.ctx; - const { displayWidth: dw, displayHeight: dh, scale: s } = this.opts; - ctx.canvas.width = dw * s; - ctx.canvas.height = dh * s; - // Start position: off-screen on the entry side - this.scrollX = content.direction === 1 ? -(content.text.length * 8 * s) : ctx.canvas.width; - - const tick = (ts: number) => { - // --- motion integration runs every rAF for smooth position accumulation --- - const dt = this.scrollLastTs !== null ? (ts - this.scrollLastTs) / 1000 : 0; - this.scrollLastTs = ts; - - const { displayWidth: dw, displayHeight: dh, scale: s, offColor, onColor, fps } = this.opts; - const frameInterval = 1000 / fps; // e.g. 40ms at 25fps - - // speedPps is in logical pixels/s; scale to canvas pixels - const delta = content.speedPps * s * dt * (content.direction === 1 ? 1 : -1); - this.scrollX += delta; - - // codepoint count (not UTF-16 units) for width estimate - const cpCount = [...content.text].length; - const textWidth = cpCount * 8 * s; // rough 8px/cp until bitmap font lands - if (content.direction === 0 && this.scrollX < -textWidth) { - this.scrollX = ctx.canvas.width; - } else if (content.direction === 1 && this.scrollX > ctx.canvas.width) { - this.scrollX = -textWidth; - } + #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); + } - // --- draw only when fps budget allows (~25Hz) --- - const sinceLastDraw = this.scrollLastDrawTs !== null ? ts - this.scrollLastDrawTs : Infinity; - if (sinceLastDraw >= frameInterval) { - this.scrollLastDrawTs = ts; - ctx.fillStyle = offColor; - ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); - ctx.fillStyle = onColor; - const fontSize = Math.max(8, Math.floor(dh * s * 0.6)); - ctx.font = `${fontSize}px monospace`; - ctx.textBaseline = "middle"; - ctx.fillText(content.text, this.scrollX, ctx.canvas.height / 2); - } + #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; } + } + } - this.scrollRaf = requestAnimationFrame(tick); - }; + #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(); + } - this.scrollRaf = requestAnimationFrame(tick); + #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); } } @@ -823,86 +962,294 @@ export class MonoDisplayRenderer { // --------------------------------------------------------------------------- /** - * Top-level driver. Attach to a canvas element by ID, then call load() with - * a function that returns a Promise of the .bin file. + * 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"); - * driver.load(() => loadBinFile("default_test.bin")); + * const driver = new MonoDisplayDriver("canvas_root", { fps: 25 }); + * driver.load(() => loadBinFile("display.bin")); * ``` - * - * PLAN: add driver.stop() / driver.play() / driver.setContent(DisplayContent) - * to allow external playback control without reloading. */ export class MonoDisplayDriver { - private canvas: HTMLCanvasElement; - private opts: Required; - private parser: MonoDisplayParser; + private canvas: HTMLCanvasElement; + private opts: Required; + 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(`MonoDisplayDriver: element #${canvasId} is not a `); - } + if (!el || el.tagName !== "CANVAS") throw new Error(`#${canvasId} is not a `); this.canvas = el as HTMLCanvasElement; - - // Apply defaults 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; }), + 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(); } - /** - * Load binary data from the provided async factory, parse, and render. - * Calling load() again stops any current playback and starts fresh. - * - * @param loader Async function returning the raw .bin ArrayBuffer - */ async load(loader: () => Promise): Promise { try { const buffer = await loader(); - const content = this.parser.parse(buffer); - - // Lazy-create renderer on first load, reuse after - if (!this.renderer) { - this.renderer = new MonoDisplayRenderer(this.canvas, this.opts); - } - this.renderer.render(content); + 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 current animation / scroll without unloading */ - stop(): void { - this.renderer?.stop(); - } + stop(): void { this.renderer?.stop(); } } // --------------------------------------------------------------------------- -// Helpers exposed for browser convenience +// MonoDisplayFile — JSON descriptor → binary .bin builder // --------------------------------------------------------------------------- /** - * Fetch a .bin file by URL and return its ArrayBuffer. - * Intended as the `loader` argument to driver.load(). + * Encodes a MonoDisplayFileDescriptor to a valid .bin ArrayBuffer. * - * @example - * ```ts - * driver.load(() => loadBinFile("/assets/default_test.bin")); - * ``` + * 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 { const res = await fetch(url); if (!res.ok) throw new Error(`loadBinFile: HTTP ${res.status} for ${url}`); @@ -910,46 +1257,9 @@ export async function loadBinFile(url: string): Promise { } /** - * Build a valid .bin ArrayBuffer for testing and demos. - * Encodes one section using the real file format. - * - * PLAN: expand into a full BinaryWriter for all section types once spec is complete. - * Currently only "static" section is encoded; others throw. + * Build a single-element-always .bin buffer containing one Image2D element. + * Convenience for tests; for production use MonoDisplayFile directly. */ -export function buildBinBuffer(content: DisplayContent): ArrayBuffer { - if (content.type !== "static") { - throw new Error(`buildBinBuffer: encoding "${content.type}" not yet implemented`); - } - - // Section data: width(u16) + height(u16) + pixels[w*h] - const sectionDataSize = 2 + 2 + content.width * content.height; - - // File header: magic(4) + version(4) + numSections(2) + reserved(2) = 12 - // Section header: type(1) + size(3) = 4 - const totalSize = 12 + 4 + sectionDataSize; - - const buf = new ArrayBuffer(totalSize); - const view = new DataView(buf); - let off = 0; - - // File header - view.setUint32(off, MAGIC, true); off += 4; // magic - view.setUint32(off, 1, true); off += 4; // version = 1 - view.setUint16(off, 1, true); off += 2; // numSections = 1 - view.setUint16(off, 0, true); off += 2; // reserved = 0 - - // Section header - view.setUint8(off, 0x01); off += 1; // section type = static - // uint24 LE for section size - view.setUint8(off, sectionDataSize & 0xFF); - view.setUint8(off + 1, (sectionDataSize >> 8) & 0xFF); - view.setUint8(off + 2, (sectionDataSize >> 16) & 0xFF); - off += 3; - - // Section data - view.setUint16(off, content.width, true); off += 2; - view.setUint16(off, content.height, true); off += 2; - new Uint8Array(buf, off).set(content.pixels); - - return buf; +export function buildBinBuffer(el: Image2DElement): ArrayBuffer { + return new MonoDisplayFile({ elements_always: [el] }).toBuffer(); }