Importing a sheet guessed which column was the controller name and which was
the sign text, then silently acted on the guess. On a sheet whose headers it
did not recognise, the guess fell through to "first column", which is usually
the row number — so the destinations came out named 1, 2, 3.
Now the import opens a preview: the two columns are dropdowns, seeded with the
guess and showing a sample of each, above a table of exactly what will be
created. Nothing is imported until it looks right.
The guessing itself is better too:
- a top row of words above rows containing numbers is treated as a header
even when none of its labels are recognised
- with no recognisable columns, the column carrying the longest words wins
- one recognised column stands in for the other, since a destination's name
is usually exactly what the sign shows, rather than picking at random
- a truncated name no longer keeps a trailing space, which would also stop it
matching an existing destination on a later import
Also adds custom sign sizes, for boards that are not one of the three fitted
models. Widths snap to a multiple of 8 since the record stores width in bytes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
248 lines
12 KiB
Markdown
248 lines
12 KiB
Markdown
# DestoGod
|
||
|
||
A replacement for **TP5**, the 2012 Windows program used to program the LED
|
||
destination signs on Yutong buses.
|
||
|
||
`dist/DestoGod.html` is the whole application — one file, no installer, no
|
||
internet. Double-click it and it opens in any browser.
|
||
|
||
---
|
||
|
||
## Why this exists
|
||
|
||
TP5 is a 417 KB Chinese MFC application from November 2012, supplied by
|
||
Guangzhou Tongda with the signs Yutong fits to its buses. It has no live
|
||
preview, so building a destination list is guess-and-check, and the workflow is
|
||
Windows-only.
|
||
|
||
The bus never talks to TP5. The sign controller reads a `.td5` file off an SD
|
||
card, and that is the entire interface. So a replacement does not need to touch
|
||
any hardware or protocol — it only has to write byte-correct `.td5` files.
|
||
|
||
That is what this does.
|
||
|
||
## What it does
|
||
|
||
- **Opens the files the company already has.** Drop in a `.td5` (what goes on
|
||
the SD card) or a `.tp5` (the TP5 project). Drop in both and it uses the text
|
||
from the project with the exact artwork from the bus file.
|
||
- **Live sign preview.** The message is drawn as an LED matrix at the real size
|
||
of the real sign, and scrolls exactly as it will on the bus. TP5 shows you
|
||
nothing until you export.
|
||
- **Tells you when a message will not fit** and by how many pixels.
|
||
- **Any sign size**, not just the three fitted models — pick "Custom size…" and
|
||
type it. Widths snap to a multiple of 8, because the record stores the width
|
||
in whole bytes.
|
||
- **Imports a spreadsheet.** Drop in an `.xlsx` (or CSV) and it shows you which
|
||
column it thinks is the controller name and which is the sign text, with a
|
||
preview of the result, before importing anything — both are dropdowns you can
|
||
change. It understands the `Line | Line Name | Description | Content (Display)`
|
||
template these buses ship with, and copes with sheets that look nothing like
|
||
it. Names that already exist are updated rather than duplicated. No library
|
||
involved: the `.xlsx` is unzipped and parsed by the browser itself.
|
||
|
||
Guessing silently is how you end up with a list of destinations called "1",
|
||
"2", "3" — the row-number column winning over the name column. Hence showing
|
||
the guess rather than trusting it.
|
||
- **Paste a whole list at once** instead of typing destinations one at a time.
|
||
- **Delete a destination** with the × on its row in the list, or clear the whole
|
||
list at once. Both offer an Undo instead of asking you to confirm first — a
|
||
confirm box is friction while tidying a list and still leaves you stuck if you
|
||
confirm by mistake.
|
||
- **Refuses to ship a blank destination.** Tapping "+" one time too many leaves
|
||
an entry with nothing in it; those are flagged in the list and the export
|
||
stops with a one-click "Remove and export". A new destination starts genuinely
|
||
empty rather than pre-filled with placeholder text, because placeholder text
|
||
is worse than blank here — "NEW DESTINATION" will happily print itself on the
|
||
front of a bus.
|
||
- **Keeps the original artwork** for any destination you have not edited, so
|
||
re-exporting an existing list changes nothing about it.
|
||
- **Exports `.td5`** for the SD card, and **`.tp5`** so the old software can
|
||
still open the list during the changeover.
|
||
|
||
## How the file formats were worked out
|
||
|
||
Everything below was derived from the sample files and from `TP5(En).exe`
|
||
itself, then verified byte-for-byte.
|
||
|
||
### `.td5` — the file the bus reads
|
||
|
||
```
|
||
FILE
|
||
0x000 16 magic, GBK "广州通达图形线路"
|
||
0x010 4 "V5.0"
|
||
0x018 12 export timestamp, ASCII YYMMDDhhmmss
|
||
0x030 2 company count
|
||
0x040 2 sign height, sign width in bytes (16, 14 = 112x16)
|
||
0x080 16 company name \ repeats every 0x20 per company
|
||
0x090 6 pointer-table offset, destination count
|
||
0x200 .. u32 offset per destination, terminated by 0xffffffff
|
||
0x400 .. destination records; unused space is filled with 0xff
|
||
|
||
DESTINATION record header is 0x80 bytes; its block always starts at +0x200
|
||
+0x00 16 name
|
||
+0x10 16 name again — THIS is the copy the driver's controller displays,
|
||
and TP5 always writes it identical to the first (verified across
|
||
all 36 records). If the two are allowed to differ, the sign shows
|
||
the right words but the controller shows the wrong ones. See
|
||
setName() in app/main.mjs; export forces them equal, and opening
|
||
a file repairs a pair that has already drifted.
|
||
+0x30 8 block offset, block length
|
||
records are padded up to the next 0x200 boundary
|
||
|
||
BLOCK
|
||
+0x00 1 0x43 'C'
|
||
+0x01 2 CRC-16/ARC over block[3 .. blockLength]
|
||
+0x03 4 block id — TP5 stores rand(), so it never exceeds 0x7fff
|
||
+0x07 4 block length
|
||
+0x0b 3 frame count, three times
|
||
+0x0f 1 0x84
|
||
+0x10 6 per frame: u32 bitmap offset, u8 width in bytes, u8 height
|
||
.. two 0x80-byte action sections per frame
|
||
.. the frame bitmaps
|
||
header length = 0x10 + frames*6 + frames*0x100
|
||
|
||
BITMAP one 16-byte chunk per 8 pixels of width; each chunk is 8 columns x 16
|
||
rows, row-major, high bit leftmost. Wider than the sign means it scrolls.
|
||
```
|
||
|
||
The checksum was the last piece. Bytes `+5` and `+6` are always zero, which hid
|
||
a field boundary: `+1` is a 2-byte CRC and `+3` is a 4-byte random id whose top
|
||
half is always zero because `RAND_MAX` is `0x7fff`. The CRC is CRC-16/ARC
|
||
(reflected, polynomial `0xa001`, init 0), taken over everything from `+3` to the
|
||
end of the block. TP5 computes it in `sub_409150`, picking polynomial index 1
|
||
out of the table at `0x447020` = `{0x8480, 0xa001, 0x8621, 0xe950}`, and stores
|
||
it in `SetTypeAndCrc` at `0x4353c0`.
|
||
|
||
Because the id is random and sits inside the CRC's range, two exports of the
|
||
same list are never byte-identical — but any id you choose is valid.
|
||
|
||
### `.tp5` — the editable project
|
||
|
||
Line-based text, CRLF, one statement per line. Strings are UTF-16BE hex. Each
|
||
destination has five screens (front, rear, side, inner, backside), each with
|
||
"up" and "down" action lists, each action holding five icons that can carry text
|
||
objects. Only the front screen is used in practice.
|
||
|
||
### `.font` — the sign's built-in fonts
|
||
|
||
`name`, then `cellWidth,height`, then per glyph: character code, advance width,
|
||
and `height * ceil(cellWidth/8)` bytes — **each byte XORed with the character
|
||
code**. All 18 shipped fonts are included in the app.
|
||
|
||
### Rendering to match the existing signs
|
||
|
||
The company's current signs were made with the Windows font Impact, which TP5
|
||
hands to GDI. A browser canvas rasterises the same font differently, so the
|
||
renderer is calibrated against the real output: TP5's size 20 corresponds to
|
||
**15.77 canvas pixels** (scale 0.789, measured across all 36 destinations), and
|
||
the alpha threshold is **225**, because GDI lays down far less antialiasing than
|
||
canvas does. At those settings the rendered stroke weight, width and height all
|
||
land within 2% of TP5's own output.
|
||
|
||
Those numbers were measured on a Mac, and macOS, Windows and Linux all rasterise
|
||
fonts slightly differently — so they are only the starting point. When both a
|
||
`.tp5` and a `.td5` are loaded the app knows the text *and* the exact artwork TP5
|
||
produced for it, so it solves for the size and threshold that reproduce that
|
||
artwork **on whatever computer it is running on**, and says so in the preview.
|
||
On the development Mac it settles on 0.804 / 245 — a closer match than the
|
||
hand-tuned constants. Nothing about this depends on the machine being the one it
|
||
was written on.
|
||
|
||
## Which computers it runs on
|
||
|
||
Any reasonably current browser — Edge, Chrome, Brave or Firefox — on Windows,
|
||
macOS or Linux. Edge is already on every Windows 10/11 machine, so on a depot PC
|
||
there is nothing to install. Verified opening straight off the disk with no
|
||
server (`file://`).
|
||
|
||
Two things are worth knowing:
|
||
|
||
- **Nothing about the exported file depends on the operating system.** The
|
||
`.td5` writer is pure integer work: same list in, same bytes out, on any
|
||
machine. The sign's own built-in fonts are bitmaps, so they are identical
|
||
everywhere too. Only the system-font (Impact) path touches the OS rasteriser,
|
||
and that is what the calibration above corrects for.
|
||
- **Impact ships with Windows**, and always has. It is the font TP5 was using
|
||
via GDI, so on a Windows machine the lettering has a shorter distance to
|
||
travel than it did here.
|
||
|
||
The floor is a browser from roughly 2020 (Chrome/Edge 80+, Firefox 75+). It
|
||
deliberately avoids the newest APIs — no `structuredClone`, and it falls back
|
||
gracefully if `<dialog>` is unsupported — so an older depot PC is fine.
|
||
|
||
## Verification
|
||
|
||
```bash
|
||
node test/roundtrip.mjs # re-build a real TP5 export and compare byte-for-byte
|
||
node test/tp5roundtrip.mjs # same for the project format
|
||
node test/authoring.mjs # build a file from scratch, read it back, check CRCs
|
||
node test/sheet.mjs # spreadsheet column mapping
|
||
node test/fontpreview.mjs "SCHOOL BUS" # render sample text in every built-in font
|
||
```
|
||
|
||
`roundtrip.mjs` parses the real `EXPRESS 1.td5`, rebuilds all 46,080 bytes from
|
||
the parsed model with a **recomputed** CRC, and requires an exact match. All 36
|
||
stored CRCs verify against the implementation.
|
||
|
||
Driven through its own interface — open the project, merge the artwork, press
|
||
Export — the app reproduces the company's real file with **7 differing bytes,
|
||
all of them the export timestamp**, and no other difference anywhere.
|
||
|
||
Retyping a single destination and exporting again changes **only that
|
||
destination's block** (plus the timestamp). The other 35 stay byte-identical, so
|
||
editing one entry cannot disturb the rest of the list.
|
||
|
||
## On the road
|
||
|
||
DestoGod files have been loaded onto a real bus. **The signs display correctly
|
||
and the driver's controller lists the destinations correctly** — the format is
|
||
confirmed against the actual hardware, not just against TP5's output.
|
||
|
||
The first road test found the one thing no amount of byte-comparison would have
|
||
caught: the destination name is stored twice, and the driver's controller reads
|
||
the *second* copy. Renaming a destination updated only the first, so the signs
|
||
were right while the controller showed nothing useful. Fixed, forced to stay in
|
||
step on both export and import, and confirmed on the bus afterwards.
|
||
|
||
Still worth doing when convenient: export the same list from DestoGod and from
|
||
TP5 and compare. They should differ only in the timestamp and the random block
|
||
ids (and the CRCs that follow from them).
|
||
|
||
### What is on the SD card
|
||
|
||
Only the `.td5`. TP5 also leaves a folder tree next to it —
|
||
`<export name>/<company>/<destination>/`, one folder per destination — but
|
||
every one of those 36 folders is empty, so it is scratch output, not something
|
||
the controller reads. Checked directly rather than assumed, because it was the
|
||
obvious suspect when the controller names went missing.
|
||
|
||
## Building
|
||
|
||
```bash
|
||
node tools/bundle.mjs # everything -> dist/DestoGod.html
|
||
```
|
||
|
||
`src/fonts.js` is committed, so a fresh clone builds and runs without anything
|
||
else. Regenerating it needs the original `.font` files that ship beside TP5,
|
||
which are not in this repo:
|
||
|
||
```bash
|
||
node tools/build-fonts.mjs # ../../yutongapp/*.font -> src/fonts.js
|
||
```
|
||
|
||
## Layout
|
||
|
||
```
|
||
src/codec/td5.mjs read and write the file the bus reads
|
||
src/codec/tp5.mjs read and write the TP5 project file
|
||
src/codec/bitfont.mjs the sign's built-in bitmap fonts
|
||
src/codec/sheet.mjs .xlsx / .csv reading, no dependencies
|
||
src/fonts.js generated from the 18 shipped .font files
|
||
app/ the interface
|
||
tools/ font conversion, single-file bundler
|
||
test/ the checks described above
|
||
re/ the reverse-engineering scripts, kept as working notes
|
||
dist/DestoGod.html ← the thing to actually use
|
||
```
|