# 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 **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 (`DoCommand`s) on the game port, not admin-network calls. Join with `client.join_company(company_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()) ``` ## See also - [PROTOCOL.md — Vehicle Timetables](PROTOCOL.md#vehicle-timetables-game-port-docommands) for the underlying wire format. - [ARCHITECTURE.md](ARCHITECTURE.md) for how `OpenTTDClient` fits into the rest of the library.