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>
84 lines
3.7 KiB
JavaScript
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);
|