Files
openttd-client/docs/TIMETABLES.md
T
kovagoadiandClaude f7ca395a4f
Continuous Integration / lint-and-security (pull_request) Failing after 19s
Continuous Integration / tests-and-coverage (pull_request) Successful in 24s
Put the AdminBridge GameScript under version control
The last commit noted in passing that the server-side half of the admin
GameScript channel "is not in this repo -- docker/config is gitignored --
so it has to be updated separately for any of this to work." That was true
of all nine features the README documents: list_vehicles, list_stations,
list_cargo, get_timetable, get_station, get_station_cargo, get_dispatch and
the event stream all answer from 705 lines of Squirrel that no clone could
reproduce, no reviewer could see, and CI never touched.

The bridge now lives in gamescript/AdminBridge/ with its own README, the
same arrangement docker/patches/ uses for the local JGRPP patches, and
docker-compose.yml bind-mounts it read-only over the container's
game/AdminBridge. docker/config stays ignored -- it also holds savegames,
downloaded content and generated config -- so the copy under it is now
shadowed and can be deleted. main.nut is byte-identical to what was running,
apart from the version work below.

Adds a version handshake, because the channel gives no way to tell a stale
bridge from a hung one: a bridge that does not recognise a command drops it
silently, so a client ahead of the server sees nothing but timeouts. The
bridge now answers get_version with its protocol version plus its command
and event catalogues, and get_bridge_version() raises when that is below
GS_BRIDGE_VERSION. It is opt-in rather than checked on connect: GameScripts
do not tick while the game is paused, so an automatic check would refuse to
connect to a paused server. A bridge older than 4 predates get_version
itself and can only fail by timing out, so the E2E test catches that and
reports it by name instead.

tests/test_gamescript.py gives CI a foothold on the GameScript without a
Squirrel toolchain: it parses the .nut files and pins the protocol version
across info.nut, main.nut and protocol.py, the event catalogue against
GameEventType, and the command table against the commands client.py sends.
The version has to be declared three times because a GameScript cannot read
its own info.nut at runtime -- GSController.GetVersion() returns the OpenTTD
version, not the script's.

info.nut also gains MinVersionToLoad() { return 1; }. The engine defaults it
to GetVersion(), so without it this bump would orphan every savegame pinned
to version 3: the scanner finds no compatible script and falls back with a
warning. The bridge keeps no savegame state, so any version can take over.

HandleCommand now dispatches through the same table get_version reports,
rather than an if/else chain, so the catalogue a client feature-detects
against cannot drift from what is implemented.

Co-Authored-By: Claude <[email protected]>
2026-08-31 19:06:33 +02:00

411 lines
20 KiB
Markdown

# Vehicle Timetables: Usage Guide
This guide covers the timetable API: writing via `OpenTTDClient`'s `change_timetable()`,
`autofill_timetable()`, `set_timetable_start()`, `set_vehicle_on_time()`, and reading via
`OpenTTDAdminClient.get_timetable()` (authoritative, recommended) or `OpenTTDClient`'s
`get_vehicle_timetable()` (passive change observer). For wire-format internals (packet layout,
varuint encoding, command IDs), see [PROTOCOL.md](PROTOCOL.md#vehicle-timetables-game-port-docommands).
This guide is about *how to call these methods and what their parameters mean*, with worked examples.
## Reading: `get_timetable()` — authoritative snapshot (recommended)
An **awaitable** method on `OpenTTDAdminClient` (admin port, TCP 3977) that queries the real
timetable state from the running game via the AdminBridge GameScript. Unlike the observer
approach below, it works for timetables set **before** you connected and returns the game's
**actual** state, not the last requested change. No `join_company` needed — it's read-only and
sees every company's vehicles.
```python
import asyncio
from openttd import OpenTTDAdminClient
async def main():
admin = OpenTTDAdminClient("127.0.0.1", admin_name="TimetableReader")
await admin.connect(admin_password="asd")
await admin.joined.wait()
data = await admin.get_timetable(7)
print(data)
await admin.quit()
asyncio.run(main())
```
Returns a `dict` shaped like:
```python
{
"command": "get_timetable",
"vehicle_id": 7,
"lateness": 0, # ticks late; negative = running early
"start_tick": 1000000, # absolute StateTicks the timetable starts at; 0 = not started
"current_order_time": 42, # ticks spent on the current order so far
"total_duration": 5400, # full timetable round-trip in ticks; -1 = timetable incomplete
"orders": [
{
"position": 0,
"wait_time": 120, # ticks (raw stored value)
"travel_time": 300, # ticks (raw stored value)
"wait_timetabled": 1, # 1 = wait time explicitly set, 0 = not timetabled
"travel_timetabled": 1,
"wait_fixed": 0, # 1 = locked against autofill
"travel_fixed": 0,
"leave_type": 0, # 0 normal, 1 leave early, 2 early if any cargo full, 3 early if all full
"max_speed": 65535, # order speed cap; 65535 = no cap
},
# ... one entry per order position
],
}
```
Errors: raises `ValueError` when the GameScript reports one (`invalid_vehicle` for a nonexistent
vehicle id; `response_too_large` if a very long order list overflows the admin packet limit),
`asyncio.TimeoutError` when no reply arrives within `timeout` (default 5.0s — note GameScripts
don't run while the game is **paused**, so a paused server always times out), and
`ConnectionError` if the admin connection drops mid-query.
Requirements: the server must run the bundled AdminBridge GameScript (from
`gamescript/AdminBridge/`, mounted into the container by this repo's `docker/` setup — see
[`gamescript/AdminBridge/README.md`](../gamescript/AdminBridge/README.md)) **and** the patched
JGRPP build with the `GSOrder` timetable getters (`docker/patches/README.md`). The Gamescript
update-frequency subscription it needs is set up automatically on first call.
## Writing (and the legacy observer): things you must know
1. **You must join the vehicle's own company to write.** The `change_timetable()` family are real
game commands (`DoCommand`s) on the game port, not admin-network calls. Join with
`client.join_company(company_id=<id>, company_password=...)` using the id of the company that
owns the vehicle — spectators (`company_id=255`, the default) are rejected. Sending a command
for a vehicle you don't own also fails.
2. **`get_vehicle_timetable()` is a passive observer, not a query.** It watches the
`ServerCommand` broadcasts the server sends to every joined client whenever *anyone* changes a
timetable. Use it for live change monitoring on the game port; prefer `get_timetable()` above
for reading actual state. Its limitations:
- It only reflects changes made **after your client joined**. A vehicle's pre-existing
timetable (set before you connected) is invisible until something changes it again.
- It reflects what was **requested**, not a confirmed result — the wire protocol has no
success/failure code, so a command that the server silently rejects (wrong owner, invalid
order, etc.) still updates your local view as if it succeeded.
## Quick start
```python
import asyncio
from openttd import OpenTTDClient
from openttd.protocol import ModifyTimetableFlags
async def main():
client = OpenTTDClient(host="127.0.0.1", username="TimetableBot")
await client.connect(server_password="asd")
# Must be a real company you own vehicles in -- not 255 (spectator).
await client.join_company(company_id=0, company_password="")
await client.joined.wait()
# Set order 0's wait time to 120 ticks for vehicle 7.
await client.change_timetable(7, 0, ModifyTimetableFlags.WaitTime, 120)
# Give the broadcast a moment to round-trip back to us.
await asyncio.sleep(1.0)
print(client.get_vehicle_timetable(7))
# -> {'orders': {0: {'wait_time': 120}}}
await client.quit()
asyncio.run(main())
```
## `change_timetable(vehicle_id, order_position, flag, value, clear_field=False)`
Changes one field of one order's timetable entry. This is the general-purpose "edit a cell in the
Timetable window" command — every other kind of edit (wait time, travel time, max speed, fixed
flags, leave type, dispatch schedule assignment) goes through this one method, distinguished by
`flag`.
| Parameter | Type | Meaning |
|---|---|---|
| `vehicle_id` | `int` | The `VehicleID` whose order list you're editing. You must own the company this vehicle belongs to. |
| `order_position` | `int` | Zero-based index into the vehicle's order list (order 0, order 1, ...). Must be a valid, existing order — you can't create orders with this method, only edit existing ones. |
| `flag` | `ModifyTimetableFlags` | Which field of the order to change (see table below). Import from `openttd.protocol`. |
| `value` | `int` | The new value. **Its meaning depends entirely on `flag`** — see below. |
| `clear_field` | `bool` | Only meaningful when `flag` is `WaitTime` or `TravelTime`. See "Clearing a field" below. Default `False`. |
### `ModifyTimetableFlags` values and what `value` means for each
| Flag | What it changes | `value` meaning |
|---|---|---|
| `ModifyTimetableFlags.WaitTime` | How long the vehicle waits at this order (e.g. at a station) | Wait time **in game ticks** |
| `ModifyTimetableFlags.TravelTime` | How long the vehicle takes to travel to this order | Travel time **in game ticks** |
| `ModifyTimetableFlags.TravelSpeed` | The order's max speed cap | Max speed in the order's internal speed unit (the same number shown in the Timetable window's speed column). Pass `0` to **remove** the speed cap entirely (no clamp) |
| `ModifyTimetableFlags.SetWaitFixed` | Whether the wait time is "fixed" (locked, so autofill won't overwrite it) | `1` to fix, `0` to unfix |
| `ModifyTimetableFlags.SetTravelFixed` | Whether the travel time is "fixed" (locked) | `1` to fix, `0` to unfix |
| `ModifyTimetableFlags.SetLeaveType` | When the vehicle is allowed to leave this order early | `0` = normal (leave when timetabled), `1` = leave as soon as possible, `2` = leave early if any cargo is fully loaded, `3` = leave early if all cargo is fully loaded |
| `ModifyTimetableFlags.AssignSchedule` | Which scheduled-dispatch schedule this order is tied to | A schedule index (`0`, `1`, ...), or `0xFFFFFFFF` (4294967295) to unassign (no schedule) |
A "tick" is the game's base simulation unit; how much real time it represents depends on the
server's day-length setting, so there's no fixed ticks-per-second conversion you can rely on
across servers.
### Clearing a field
`clear_field=True` only makes sense with `flag=WaitTime` or `flag=TravelTime`, and **you must also
pass `value=0`** — the server rejects the command (silently, as always — you'll only notice because
`get_vehicle_timetable()` won't show the change you expected) if `clear_field=True` and `value != 0`.
Clearing is different from just setting the time to `0`:
- `change_timetable(v, 0, ModifyTimetableFlags.WaitTime, 0)` — sets wait time to exactly 0 ticks,
but the order is still considered "timetabled" (has an explicit time).
- `change_timetable(v, 0, ModifyTimetableFlags.WaitTime, 0, clear_field=True)` — removes the
timetabled wait time entirely (back to "no time set").
### Examples
```python
from openttd.protocol import ModifyTimetableFlags
# Set order 0's wait time to 120 ticks.
await client.change_timetable(7, 0, ModifyTimetableFlags.WaitTime, 120)
# Set order 1's travel time to 300 ticks.
await client.change_timetable(7, 1, ModifyTimetableFlags.TravelTime, 300)
# Cap order 0's speed at 80 (speed units), then remove the cap again.
await client.change_timetable(7, 0, ModifyTimetableFlags.TravelSpeed, 80)
await client.change_timetable(7, 0, ModifyTimetableFlags.TravelSpeed, 0) # 0 = no cap
# Lock order 0's wait time so autofill won't touch it.
await client.change_timetable(7, 0, ModifyTimetableFlags.SetWaitFixed, 1)
# Let the vehicle leave order 2 as soon as it's loaded, instead of waiting for the timetabled time.
await client.change_timetable(7, 2, ModifyTimetableFlags.SetLeaveType, 1) # OLT_LEAVE_EARLY
# Assign order 0 to scheduled-dispatch schedule 0, then unassign it.
await client.change_timetable(7, 0, ModifyTimetableFlags.AssignSchedule, 0)
await client.change_timetable(7, 0, ModifyTimetableFlags.AssignSchedule, 0xFFFFFFFF)
# Clear order 0's wait time back to "not timetabled".
await client.change_timetable(7, 0, ModifyTimetableFlags.WaitTime, 0, clear_field=True)
```
## `autofill_timetable(vehicle_id, autofill=True, preserve_wait_time=False)`
Turns the "Autofill timetable" feature on or off for a vehicle. While autofill is active, the game
fills in wait/travel times automatically as the vehicle completes each order, instead of you
setting them manually with `change_timetable()`.
| Parameter | Type | Meaning |
|---|---|---|
| `vehicle_id` | `int` | The vehicle to enable/disable autofill for. |
| `autofill` | `bool` | `True` to start autofilling (also clears the "timetable has started" state — enabling autofill is how you (re)start building a timetable from scratch). `False` to stop. Default `True`. |
| `preserve_wait_time` | `bool` | Only relevant when `autofill=True`. If `True`, autofill only *increases* existing wait times, never shortens them, instead of overwriting them outright. Default `False`. |
### Examples
```python
# Start autofilling vehicle 7's timetable from scratch.
await client.autofill_timetable(7, autofill=True, preserve_wait_time=False)
# Start autofilling, but never shrink wait times the vehicle already has set.
await client.autofill_timetable(7, autofill=True, preserve_wait_time=True)
# Stop autofilling once you're happy with the result.
await client.autofill_timetable(7, autofill=False)
```
## `set_timetable_start(vehicle_id, timetable_all, start_date)`
Sets when a vehicle's timetable begins running.
| Parameter | Type | Meaning |
|---|---|---|
| `vehicle_id` | `int` | The vehicle whose timetable start to set. |
| `timetable_all` | `bool` | `True` to apply this start date to every vehicle that shares this vehicle's order list (a "vehicle group" running the same route); `False` to affect only this one vehicle. |
| `start_date` | `int` | An **absolute `StateTicks` value** — OpenTTD's internal tick counter that always advances at the same rate regardless of day-length settings. It is *not* a calendar date and *not* relative to "now". |
**About `start_date`:** this library doesn't currently expose "what is the current `StateTicks`
value" anywhere (the admin `ServerDate` packet reports a calendar date, which is a different,
day-length-dependent counter). In practice you'll usually either: read a `timetable_start` value
already observed via `get_vehicle_timetable()` on another vehicle in the same group and reuse it,
or coordinate the value out-of-band (e.g. from an in-game GameScript, or a known baseline) rather
than computing "now" purely from this client.
### Examples
```python
# Start vehicle 7's own timetable at StateTicks 1_000_000.
await client.set_timetable_start(7, timetable_all=False, start_date=1_000_000)
# Start the timetable for every vehicle sharing vehicle 7's orders, all at the same tick.
await client.set_timetable_start(7, timetable_all=True, start_date=1_000_000)
```
## `set_vehicle_on_time(vehicle_id, apply_to_group=False)`
Resets a vehicle's **lateness counter to zero** (marks it on-time). This command can only reduce
lateness to zero — there is no way to use it to mark a vehicle as *late*.
| Parameter | Type | Meaning |
|---|---|---|
| `vehicle_id` | `int` | The vehicle to reset lateness for. |
| `apply_to_group` | `bool` | `False` (default): reset only this vehicle. `True`: reset lateness for every vehicle sharing this vehicle's order list, by the same amount (so their relative spacing is preserved), instead of just this one. |
Note: if `apply_to_group=False` and the vehicle's timetable hasn't been started yet (see
`set_timetable_start()`), the server rejects the command — but since there's no success/failure
signal on the wire, you won't see an error, `get_vehicle_timetable()` will just show the request
was made without the underlying lateness actually having changed.
### Examples
```python
# Reset lateness for just this vehicle.
await client.set_vehicle_on_time(7, apply_to_group=False)
# Reset lateness for the whole group of vehicles sharing vehicle 7's orders.
await client.set_vehicle_on_time(7, apply_to_group=True)
```
## `get_vehicle_timetable(vehicle_id)`
A **synchronous** method (no `await`, no network round-trip) that returns whatever this client has
locally observed about a vehicle's timetable so far, or `None` if nothing has been observed for
that vehicle id yet.
```python
entry = client.get_vehicle_timetable(7)
```
Returns either `None`, or a `dict` shaped like:
```python
{
"orders": {
0: {"wait_time": 120, "wait_time_fixed": True},
2: {"travel_time": 300, "leave_type": 1},
# only order positions that have been touched by an observed change_timetable() appear here
},
"autofill": True, # present after an observed autofill_timetable()
"autofill_preserve_wait_time": False,
"timetable_start": 1000000, # present after an observed set_timetable_start()
"timetable_all": False,
"on_time_apply_to_group": False, # present after an observed set_vehicle_on_time()
}
```
Every top-level key is optional and only appears once the corresponding change has actually been
observed — a freshly-joined client that hasn't seen any broadcasts yet for a vehicle returns `None`
for it, and a vehicle that's only had its wait time changed won't have an `"autofill"` key at all.
Per-order fields inside `"orders"` follow the same rule: only fields that have been explicitly set
via `change_timetable()` appear; a cleared field (`clear_field=True`) is stored as `None` rather
than being removed, so you can distinguish "never touched" (key absent) from "explicitly cleared"
(key present, value `None`).
## Putting it together
```python
import asyncio
from openttd import OpenTTDClient
from openttd.protocol import ModifyTimetableFlags
async def build_timetable(client, vehicle_id):
# 1. Let autofill do a first pass, preserving anything already set.
await client.autofill_timetable(vehicle_id, autofill=True, preserve_wait_time=True)
await asyncio.sleep(1.0)
# 2. Manually lock in the wait time for a specific order once you're happy with it.
await client.change_timetable(vehicle_id, 0, ModifyTimetableFlags.WaitTime, 90)
await client.change_timetable(vehicle_id, 0, ModifyTimetableFlags.SetWaitFixed, 1)
await asyncio.sleep(1.0)
# 3. Turn autofill off and start the timetable running for the whole group.
await client.autofill_timetable(vehicle_id, autofill=False)
await client.set_timetable_start(vehicle_id, timetable_all=True, start_date=1_000_000)
await asyncio.sleep(1.0)
print(client.get_vehicle_timetable(vehicle_id))
async def main():
client = OpenTTDClient(host="127.0.0.1", username="TimetableBot")
await client.connect(server_password="asd")
await client.join_company(company_id=0, company_password="")
await client.joined.wait()
await build_timetable(client, vehicle_id=7)
await client.quit()
asyncio.run(main())
```
## Adding and removing orders
Beyond editing an order's timetable fields, you can change the order list itself. Both commands go
over the game port and require being joined to the company that owns the vehicle (like the timetable
methods above).
```python
# Append a "go to station" order (station id 6) to the end of vehicle 7's order list.
await client.add_order(7, 6)
# Insert one before position 0 instead of appending.
await client.add_order(7, 6, before_position=0)
# Non-stop / stop-location can be customised (defaults suit every vehicle type).
from openttd.protocol import OrderNonStopFlags
await client.add_order(7, 6, non_stop=OrderNonStopFlags.NoStopAtIntermediate)
# Delete the order at a given position.
await client.remove_order(7, 0)
```
`add_order()` builds "go to station" orders. `stop_location` defaults to `PlatformFarEnd` because the
other stop locations are train-only and rejected for road vehicles, ships and aircraft. There is no
game-port query for the resulting order list; confirm changes with the admin `get_timetable()` order
count (see [PROTOCOL.md](PROTOCOL.md#adding--removing-orders)).
## Scheduled dispatch (JGRPP)
Scheduled dispatch lets a vehicle depart on a fixed schedule of slots rather than purely by
timetable. A vehicle's order list can hold several dispatch schedules, each with a duration, a start
tick and a set of departure slots. The edit commands go over the game port and require being joined
to the owning company; the authoritative read is on the admin client.
```python
# Create a schedule (start tick 0, duration 3000 ticks) — it becomes the next schedule index.
await client.add_dispatch_schedule(7, 0, 3000)
# Add departure slots at offsets 500 and 1500 within schedule 0's duration.
await client.add_dispatch_slot(7, 0, 500)
await client.add_dispatch_slot(7, 0, 1500)
# Add several evenly spaced slots at once: offset 0, then +250 three more times.
await client.add_dispatch_slot(7, 0, 0, interval=250, extra_slots=3)
# Adjust the schedule, then turn scheduled dispatch on for the vehicle.
await client.set_dispatch_duration(7, 0, 4000)
await client.set_dispatch_start_date(7, 0, 1_000_000)
await client.set_scheduled_dispatch(7, True)
# Remove a slot, clear a schedule's slots, or remove the whole schedule.
await client.remove_dispatch_slot(7, 0, 1500)
await client.clear_dispatch_schedule(7, 0)
await client.remove_dispatch_schedule(7, 0)
```
Read the live state back over the admin connection (requires the patched JGRPP build, see
[docker/patches/README.md](../docker/patches/README.md)):
```python
data = await admin.get_dispatch(7)
# {"enabled": 1, "schedules": [{"index": 0, "duration": 4000, "start_tick": 1000000,
# "delay": 0, "reuse_slots": 0, "slots": [{"offset": 500, "flags": 0}, ...]}]}
```
The client implements a common core of the ~22 JGRPP dispatch commands; advanced operations
(departure routes/tags, per-slot flags, adjust/swap/duplicate) are not wrapped yet.
## See also
- [PROTOCOL.md — Vehicle Orders & Timetables](PROTOCOL.md#vehicle-orders--timetables-game-port-docommands) for the underlying wire format.
- [PROTOCOL.md — Dispatch Query](PROTOCOL.md#dispatch-query) for the admin `get_dispatch()` read.
- [ARCHITECTURE.md](ARCHITECTURE.md) for how `OpenTTDClient` fits into the rest of the library.