Files
openttd-client/docs/TIMETABLES.md
kovagoadi 3b54a722d6
All checks were successful
Continuous Integration / lint-and-security (pull_request) Successful in 33s
Continuous Integration / tests-and-coverage (pull_request) Successful in 26s
Added real timetable support
2026-07-23 20:59:28 +02:00

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.

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

  1. 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 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

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