17 KiB
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.
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.
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:
{
"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 and the patched JGRPP
build with the GSOrder timetable getters (both included in this repo's docker/ setup — see
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
- You must join the vehicle's own company to write. The
change_timetable()family are real game commands (DoCommands) on the game port, not admin-network calls. Join withclient.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. get_vehicle_timetable()is a passive observer, not a query. It watches theServerCommandbroadcasts the server sends to every joined client whenever anyone changes a timetable. Use it for live change monitoring on the game port; preferget_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
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
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
# 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
# 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
# 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.
entry = client.get_vehicle_timetable(7)
Returns either None, or a dict shaped like:
{
"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
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())
See also
- PROTOCOL.md — Vehicle Timetables for the underlying wire format.
- ARCHITECTURE.md for how
OpenTTDClientfits into the rest of the library.