# Tickrail city binary format v2 (v1 remains readable)

Unencrypted OSM derivative database (ODbL 1.0); optional Wikidata labels are CC0.
The public `.json` is the exact **expanded** binary document. Numbers are
little-endian. No executable code, object deserialisation or private dictionary.

## Header and primitive encoding

| Offset | Type | Meaning |
|---|---|---|
| 0 | 8 ASCII bytes | `TICKRAIL` |
| 8 | uint16 | version 2; readers also accept version 1 |
| 10 | uint16 | flags 1 (legacy zlib), 3 (compact values), 7 (prefix strings + compact values) |
| 12 | uint32 | compressed body bytes, exactly the remaining file size |
| 16 | uint32 | uncompressed body bytes, at most 16 MiB |
| 20 | uint32 | IEEE CRC32 of the uncompressed body |
| 24 | uint32 | UTF-8 string table count, at most 200000 |

The body is zlib RFC1950, then a shared, sorted, deduplicated UTF-8 string table
and one typed root value. A table entry has unsigned LEB128 common-prefix byte
length (flags 7 only), suffix byte length, and suffix bytes. The first prefix is
zero; validate UTF-8 **after** concatenation. Expanded string bytes are bounded
to 16 MiB. Flags 1/3 instead store length + full UTF-8 bytes. Unsigned LEB128 is
at most nine bytes (63 bits). Signed values use zigzag: 2n if n≥0, −2n−1 otherwise.

| Tag | Payload |
|---|---|
| 0 | null |
| 1 / 2 | false / true |
| 3 | zigzag integer |
| 4 | finite IEEE754 float64 |
| 5 | unsigned string table index |
| 6 | unsigned count, that many typed values |
| 7 | unsigned count, (string-key index, typed value) pairs |
| 8 | scale byte 0–7, zigzag integer / 10^scale |
| 9 | scale byte, unsigned count, delta zigzag integers / 10^scale |
| 10 | unsigned count, delta zigzag integers |
| 11 | row count, field count, (key index, typed column array) per field |
| 12 | row count, length per row, typed flat array with sum(lengths) values |
| 13 | row count, width 2–4, typed column array per column |
| 14 | unsigned count, delta string-table indices |
| 15 (v2) | count, typed dictionary array, typed integer reference array of count elements |
| 16 (v2) | prefix string index, unsigned numeric ID, suffix string index |
| 17 (v2) | count, three typed arrays: prefix strings, numeric IDs, suffix strings |

Tags 8–17 require compact flags. Tags 15–17 additionally require header version 2.
Delta accumulators start at zero and are bounded to ±2^53. Numeric ID strings are
`prefix + decimal(number) + suffix`; strings with leading zeros are kept ordinary
strings, preserving them exactly. IDs are varints, never converted through float.
Tag 15 is a column-local dictionary with independent expanded values; references
cannot escape its dictionary or refer forward to external state.

Every column length must agree. Duplicate map/column keys, invalid UTF-8, wrong
indices, nonfinite numbers, overflowing lengths, invalid flags, bad checksum,
extra compressed or uncompressed bytes, and depth >32 are rejected. Expanded
collection slots and dictionary copies are bounded to 16 Mi values; numeric
string expansions also consume the budget. Decimal storage is selected only
when division reproduces the original double exactly, including negative zero.

## V2 relational storage projection

The expanded root has `format: 2`. It preserves all routing, names, aliases,
search keys, exits, source metadata and coordinate precision. Before primitive
encoding the writer pools repeated stop records into `stopTable`, with fields
`station`, `id`, `lat`, `lon`, `pickup`, `dropoff`. Each pattern substitutes
`stopRefs` for `stops`, `platforms`, `platformCoordinates`, `pickup`, `dropoff`.
The decoder restores those five aligned arrays and removes the storage table.
Duplicated expanded/storage representations and invalid references are rejected.

Stop-table coordinates and station-exit coordinates use `dlat` / `dlon` whenever
exactly representable at 10^-7 degrees. These are integer differences from that
record's station coordinate, in 10^-7 degrees. Reconstruction is
`(round(stationAxis * 10^7) + delta) / 10^7`; this is lossless, not quantisation.
Other doubles retain their original representation. A row cannot contain both
an absolute and a delta version of the same axis.

Segment `ground` coordinates that exactly equal the two endpoint stations are
omitted on the wire and restored by the decoder, in GeoJSON [longitude, latitude]
order. SPEC §7 draws straight station chords. Interior track points are therefore
not shipped; the immutable OSM pins retain the source geometry, and track-derived
metres/times are retained. `groundSource` explicitly records the drawn geometry.

## Expanded schema and labels

- Root: `id`, `name`, `date`, `month`, `scope`, attribution/license/data URL,
  source hash, IANA time zone, speed assumption/source, patches and diagnostics.
- Stations: stable OSM ID, local/Latin names and Latin source, aliases, codes,
  folded search/Hangul initials, lat/lon, accessibility, areas, exits, line indices,
  schematic x/y and grid cell. Unknown accessibility stays unknown.
- Lines: ID, name, colour/source, casing, raw hours, interval seconds, termini,
  connector flag (Marmaray never inflates Istanbul core coverage).
- Patterns: stable relation/part ID, directed stops/platforms, pickup/dropoff,
  seconds/metres, interval/hours, loop/clockwise/direction, terminus, OSM endpoint
  labels, patch, closure/base-service/branch flags and aligned direction termini.
  `estimated:` platforms cannot establish verified cross-platform transfers.
- Transfers: from/to station indices, metres, source, estimated flag, wheelchair.
- Segments: from/to, octilinear points, ordered bundle line indices, ground and
  groundSource. An empty path is an embedding failure, never a valid railway.
- `layout`: bounds, displacement/order/spacing defects, local cell-split evidence,
  label scales/coverage and independently recomputed label metrics.

V2 station `label` contains local/Latin wrapped text, Latin scale .85, and three
`placements` arrays in **fit, interchange, city** order. A placement is either
empty (LOD-hidden) or `[left, top, right, bottom]` in **screen dp relative to the
station**. Values use quarter dp; type stays at 13/11 dp at every zoom. `plates`
is the corresponding boolean array. Only termini/interchanges may use a 94%
paper plate over a track; no leader lines. `layout.labelLevels` supplies scale,
origin, atlas/viewport dimensions, visible/eligible counts and hidden IDs.

Fit eligibility: termini + ≥3-line interchanges. At 1.5×: all interchanges.
City (at least 2.2×, normally 30dp/cell): every station is a candidate, with
conflicting labels explicitly hidden as SPEC §7 requires. Atlas placement covers
the whole network; phone panning must rerun placement against the current viewport
and controls. These compiler sheets do not claim Compose gesture/UI completion.

V1 fixtures retain their original `label.box` world coordinates and schema 1.
The Kotlin loader accepts both, exposing v2 screen-space placements separately.
A complete v1 DC graph is tested under `app/src/test/resources/cities/legacy-v1.bin`.

Indices are zero-based; metres/seconds are SI; estimates require the UI's “about”.
`catalog.json` binds G1–G9 to each exact binary hash. Only all-PASS cities are
release eligible. Designer sign-off and reference-emulator evidence are external;
no format change or compiler run fabricates either.

## LOOM-produced v2 layouts (2026-10-27)

The wire format is unchanged. `segments[].lines` is an oriented drawing order,
not a sorted set; reversing segment direction reverses the bundle order. Source
membership remains the set of lines carried by the original passenger edge.
`layout.overviewStyle = "schematic-overview-v1"` specifies 1.5dp overview rails,
2dp pitch and 2.5dp symbols only on labelled fit stations. Detailed city rails
retain 5dp width / 6dp pitch. Missing marker means the historical v2 recipe.
`layout.labelLevels.city.scale` is the actual pan scale, not a phone fit scale;
`cityLabelScale` may retain a larger scale needed for complete long-name coverage.
Every level stores its own placements and physical 13/11dp typography.

The writer selects the smallest standard zlib stream from level 9 with memory
levels 6, 7 and 8. The checksum, uncompressed body, v2 flags and decoder contract
are identical; this is lossless compression tuning, not a schema change.
