destogod/test/authoring.mjs
type-two 07977624ae DestoGod: replacement for the TP5 bus destination sign software
TP5 is a 2012 Windows application supplied with the Guangzhou-Tongda LED
destination signs fitted to Yutong buses. It has no preview, so building a
destination list is guess-and-check, and it only runs on Windows.

The bus never talks to TP5 — the sign controller reads a .td5 file off an SD
card, and that is the whole interface. So this replaces the software without
touching any hardware or protocol: it just has to write byte-correct .td5.

Formats reverse-engineered from the sample files and TP5(En).exe, then verified
byte-for-byte:

  .td5   the file the bus reads. Fixed-layout binary; each destination block
         carries a CRC-16/ARC over block[3..len] and a rand() block id, which
         together looked like one 4-byte field because RAND_MAX is 0x7fff.
  .tp5   the editable project. Line-based text, UTF-16BE hex strings.
  .font  the sign's own bitmap fonts, each glyph row XORed with its char code.

The app is one self-contained HTML file: live LED preview at the real sign size
with real scrolling, spreadsheet/CSV import, multi-page destinations, undoable
delete, and export to both .td5 and .tp5.

Verified:
  - rebuilds a real 46,080-byte TP5 export byte-for-byte with a recomputed CRC
  - all 36 stored CRCs verify against the implementation
  - driven through its own UI, re-exporting the real file differs in 7 bytes,
    all of them the export timestamp
  - running on a real bus: signs and driver's controller both correct

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:06:25 +10:00

84 lines
3.7 KiB
JavaScript

/**
* End-to-end: build a .td5 from nothing but text, then read it back and check
* the sign would accept it (CRC), and that the pixels survive intact.
*/
import { writeFileSync } from 'node:fs';
import { FONTS } from '../src/fonts.js';
import { fontFromJSON, renderText, trimX } from '../src/codec/bitfont.mjs';
import { parseTd5, buildTd5, pixelsToFrame, frameToPixels, crc16arc, EFFECT } from '../src/codec/td5.mjs';
const SCREEN = { width: 112, height: 16 };
const font = fontFromJSON(FONTS.ASC1609);
const LIST = [
'SCHOOL BUS', 'CHARTER', 'RAIL BUS', 'EXPRESS COACH LINES',
'CARMEL COLLEGE', 'OC1', 'NOT IN SERVICE', 'GO LIONS!',
];
function fit(bmp, height) {
if (bmp.height === height) return bmp;
const out = new Uint8Array(bmp.width * height);
const y0 = Math.floor((height - bmp.height) / 2);
for (let y = 0; y < bmp.height; y++) {
const ty = y + y0;
if (ty >= 0 && ty < height) out.set(bmp.pixels.subarray(y * bmp.width, (y + 1) * bmp.width), ty * bmp.width);
}
return { width: bmp.width, height, pixels: out };
}
const destinations = LIST.map((text) => {
const bmp = fit(trimX(renderText(font, text)), SCREEN.height);
const frame = pixelsToFrame(bmp.pixels, bmp.width, SCREEN.height);
const effect = bmp.width > SCREEN.width ? EFFECT.SCROLL : EFFECT.HOLD;
return { name: text.slice(0, 16), lineName: text.slice(0, 16), frames: [frame], effects: [effect, effect] };
});
// one two-page destination, to exercise the multi-frame path
const pages = ['MORETON BAY', 'BOYS COLLEGE'].map((t) => {
const b = fit(trimX(renderText(font, t)), SCREEN.height);
return pixelsToFrame(b.pixels, b.width, SCREEN.height);
});
destinations.push({ name: 'MORETON BAY', lineName: 'MORETON BAY', frames: pages, effects: [EFFECT.HOLD, EFFECT.HOLD, EFFECT.HOLD, EFFECT.HOLD] });
const bytes = buildTd5({ company: 'Test Coaches', screen: SCREEN, destinations });
writeFileSync(new URL('./authored.td5', import.meta.url), bytes);
// ---- read it back as if we were the sign
const back = parseTd5(bytes);
let fails = 0;
const check = (cond, msg) => { if (!cond) { fails++; console.log(' ❌ ' + msg); } };
check(back.company === 'Test Coaches', 'company survived');
check(back.destinations.length === destinations.length, 'destination count');
check(back.screen.width === 112 && back.screen.height === 16, 'screen size');
check(bytes.length % 0x200 === 0, 'file padded to a 512-byte boundary');
for (const d of back.destinations) {
check(d.crcOk, `${d.name}: CRC verifies`);
const src = destinations.find((x) => x.name === d.name);
check(src && d.frames.length === src.frames.length, `${d.name}: frame count`);
d.frames.forEach((f, i) => {
const a = Buffer.from(f.data), b = Buffer.from(src.frames[i].data);
check(a.equals(b), `${d.name}: frame ${i} pixels identical`);
});
}
// independent CRC verification, byte-for-byte against the stored value
const dv = new DataView(bytes.buffer);
for (let i = 0; i < back.destinations.length; i++) {
const ptr = dv.getUint32(0x200 + i * 4, true);
const off = dv.getUint32(ptr + 0x30, true);
const len = dv.getUint32(ptr + 0x34, true);
const stored = bytes[off + 1] | (bytes[off + 2] << 8);
check(stored === crc16arc(bytes.subarray(off + 3, off + len)), `block ${i}: CRC recomputes`);
}
console.log(`authored ${destinations.length} destinations -> ${bytes.length} bytes (test/authored.td5)`);
for (const d of back.destinations) {
const w = d.frames[0].width;
console.log(` ${d.name.padEnd(17)} ${String(w).padStart(3)}px ${w > 112 ? 'scrolls' : 'fits '} ` +
`${d.frames.length > 1 ? d.frames.length + ' pages' : ''}`);
}
console.log(fails === 0 ? '\n✅ every check passed — a sign-ready file built from scratch' : `\n${fails} checks failed`);
process.exit(fails ? 1 : 0);