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>
63 lines
2.4 KiB
JavaScript
63 lines
2.4 KiB
JavaScript
/**
|
|
* Proof of format: parse a real TP5 export, rebuild it from the parsed model,
|
|
* and require the result to be byte-identical.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { parseTd5, buildTd5, frameToPixels, pixelsToFrame } from '../src/codec/td5.mjs';
|
|
|
|
const path = process.argv[2] ?? new URL('../samples/EXPRESS 1.td5', import.meta.url);
|
|
const original = new Uint8Array(readFileSync(path));
|
|
|
|
const doc = parseTd5(original);
|
|
console.log(`company : ${doc.company}`);
|
|
console.log(`version : ${doc.version} exported ${doc.timestamp}`);
|
|
console.log(`screen : ${doc.screen.width}x${doc.screen.height}`);
|
|
console.log(`dests : ${doc.destinations.length}`);
|
|
const multi = doc.destinations.filter((d) => d.frames.length > 1);
|
|
console.log(`multiframe: ${multi.map((d) => `${d.name}(${d.frames.length})`).join(', ') || 'none'}`);
|
|
|
|
const badCrc = doc.destinations.filter((d) => !d.crcOk);
|
|
console.log(`crc check : ${doc.destinations.length - badCrc.length}/${doc.destinations.length} verify` +
|
|
(badCrc.length ? ` — FAILED: ${badCrc.map((d) => d.name).join(', ')}` : ''));
|
|
|
|
const rebuilt = buildTd5(doc, { timestamp: doc.timestamp });
|
|
|
|
let ok = rebuilt.length === original.length;
|
|
const diffs = [];
|
|
if (!ok) {
|
|
console.log(`\nSIZE MISMATCH: original ${original.length}, rebuilt ${rebuilt.length}`);
|
|
} else {
|
|
for (let i = 0; i < original.length; i++) {
|
|
if (original[i] !== rebuilt[i]) {
|
|
diffs.push(i);
|
|
if (diffs.length > 40) break;
|
|
}
|
|
}
|
|
ok = diffs.length === 0;
|
|
}
|
|
|
|
if (ok) {
|
|
console.log('\n✅ BYTE-IDENTICAL round-trip over all ' + original.length + ' bytes');
|
|
} else {
|
|
console.log(`\n❌ ${diffs.length}${diffs.length > 40 ? '+' : ''} differing bytes`);
|
|
for (const off of diffs.slice(0, 24)) {
|
|
console.log(` 0x${off.toString(16).padStart(5, '0')}: orig ${hx(original[off])} != ${hx(rebuilt[off])}`);
|
|
}
|
|
}
|
|
|
|
// pixel round-trip
|
|
let pxOk = true;
|
|
for (const d of doc.destinations) {
|
|
for (const f of d.frames) {
|
|
const re = pixelsToFrame(frameToPixels(f), f.width, f.height);
|
|
if (Buffer.compare(Buffer.from(re.data), Buffer.from(f.data)) !== 0) {
|
|
pxOk = false;
|
|
console.log(` pixel round-trip failed: ${d.name}`);
|
|
}
|
|
}
|
|
}
|
|
console.log(pxOk ? '✅ pixel pack/unpack round-trips for every frame' : '❌ pixel round-trip failed');
|
|
|
|
function hx(b) { return '0x' + b.toString(16).padStart(2, '0'); }
|
|
process.exit(ok && pxOk ? 0 : 1);
|