# 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()) ``` ## 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.