Destinations were one flat list, so building a second set meant overwriting the
first. They now live in named groups, listed down the left: click a group and
its destinations appear.
This is not a new concept bolted on. The .td5 header carries a company count
with room for twelve, each with its own name and its own pointer table, the
.tp5 has "Company Sum:", and TP5's manual has you create a company before you
can add any destinations. A group is that company.
- a saved project (.tp5) holds every group, which the old software can still
open, and reloading one restores them all
- a .td5 is written from the selected group, since one file is what goes on
one SD card. That keeps every exported file the exact shape already proven
against the bus, rather than a multi-company layout with no reference file
to check against
- deleting a group takes its destinations with it, so it is undoable too
state.destinations and state.company are now accessors onto the active group,
so everything built on the flat list keeps working untouched.
Also fixes the custom sign size inputs showing when a preset was selected:
.field sets display:flex, which beats the [hidden] attribute's display:none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
255 lines
12 KiB
Markdown
255 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
|
||
|
||
- **Groups.** Destinations live in named groups — one per contract, depot or
|
||
bus — listed down the left; click one and its destinations appear. This is not
|
||
an invention of the editor: the file format calls them companies, holds up to
|
||
twelve, and TP5's own manual has you create one before adding any
|
||
destinations. A saved project keeps every group; a `.td5` for the bus is
|
||
written from the group you have selected, since that is what goes on one SD
|
||
card.
|
||
- **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
|
||
```
|