Files
tpf3-overtake/docs/research/vehicle-probe.md
T

10 KiB
Raw Blame History

In-game vehicle probe

Ticket "Probe vehicle data and native passing in game" (part of map "Map: TpF3 overtaking mod"). Run 2026-10-05 on game build 40408, with the throwaway probe mod/content/overtake_probe.{gs.lua,script.tl} on branch prototype/vehicle-probe. Log excerpt: probe-logs/2026-10-05-vehicle-probe.txt.

How the probe works

  • A mod registers a per-tick game script by shipping content/<name>.gs.lua (data() returns updateScript = { fileName = "<name>.script@update" }), the same way the base game's vehicle/vehicle_modifier.gs.lua does. No mod.json entry is needed. Adding it to an existing save logs Creating entity for newly added GameScript haylan_tf3_overtake::/overtake_probe.gs.
  • update runs every 0.2 s of game time (dt = 0.2).
  • Module-level Lua variables don't persist between calls: the script runs in several or fresh Lua states. Persistent state has to go through GameScriptState.
  • No hot reload: an edited .tl takes effect only after the save is reloaded.
  • print goes to <Steam>/userdata/<id>/3493540/local/crash_dump/stdout.txt.

API facts (observed, overriding tealdef where they differ)

  • Player vehicles: api.engine.getEntitiesWithComponent(TRANSPORT_VEHICLE, { requireOwnedByPlayer = api.engine.util.getPlayer() }). Road vehicles also carry LAND_VEHICLE and MOVE_PATH.
  • MovePath.path.edges is a 1-based Lua table of EdgeIdDir userdata (.edgeId, .dir), not {EdgeId, boolean} as the tealdef says. The current edge is edges[dyn.pathPos.edgeIndex + 1].
  • EdgeId.entity is the street segment (it carries BASE_EDGE and TRANSPORT_NETWORK). EdgeId.index is the lane within that segment's TransportNetwork.edges.
  • TransportNetwork.turnaroundEdges[index + 1] is the opposite lane. TransportNetwork.edges[i] has transportModes, speedLimit and curSpeed.
  • BaseEdge.laneConfigs lists the lanes, with forward, offset, width and transportModes. Mode keys print as integers: sidewalks [0,1], driving lanes [2,3,4].
  • landVehicleMoveSystem.getVehicleInfo only returns availPower, traction, tractiveEffort and vehicleAvailPower.
  • MovePath.blocked stayed 0 for every vehicle the whole time, including vehicles clearly queued behind a slower one. It doesn't signal "held up by the vehicle ahead".
  • MovePath.dyn.timeToIgnore is negative and counts down for almost every vehicle. It was briefly positive (+9.4) once, right after a stop. Its meaning is unknown.

Observed traffic behaviour

Road Lanes per direction What happened
country_new_medium (segments 6527 → 29697) 2 (lanes 3 and 4 forward) 29251 (power 243) followed 29852 (power 160) in lane 4 over 6 segments, 24–30 m behind. It accelerated to 17.7 m/s and then dropped to 15.1 m/s to match the vehicle ahead. Lane 3 was free and stayed unused.
country_new_medium (segment 28894) 2 The two vehicles were in different lanes (3 and 4), but their order never changed. This looks like lane choice for the next turn, not passing.
country_new_small (segments 26513 → 28566) 1 (lane 2 forward, lane 1 opposite) 29251 was held at exactly 16.67 m/s (29851's top speed) behind 29851, below the road's 22.2 m/s limit, for 6+ segments.

Verdict

  • The engine does not pass natively, on either multilane or two-way roads. Bus lanes weren't tested.
  • The read side is sufficient: a script can tell, per vehicle, which segment, lane and direction it's in, where it is, how fast it's going, and what the opposite lane is. So both the "held back" condition and an oncoming-traffic check can be computed. "Held back" must be derived (same segment and lane, small gap, speed below the vehicle's capability), because blocked doesn't report it.
  • No documented command can make a vehicle ignore the one ahead (see overtake-feasibility.md).

Searching for a way to make a vehicle ignore the one ahead

  • Script commands: none of the commands in api.cmd moves a vehicle, changes its lane, or turns off the queueing. The full list was checked.
  • Model metadata: landVehicle (engines, topSpeed, weights) and extent have no following or blocking parameters.
  • Transformator.LandVehicleScriptingApi / TransfOutput.setUserTransf: a per-model animation script. It can offset the model on screen, which could make a vehicle look like it changes lanes, but it doesn't touch the simulation.
  • Executable strings: LandVehicleMoveSystem.cpp contains GetNextEdgeTrafficPenaltyAndAlternatives, existingPathLaneSwitch and switchIndices. So the engine itself switches lanes at segment boundaries based on a traffic penalty, which matches the lane split seen on segment 28894. None of it is exposed to scripts.
  • api.cmd.debug.makeComponentExchangeCmd(entity, MovePath) (save loaded with "Debug mode is active"): building the command with a MovePath succeeds. api.cmd.sendCommand(cmd, callback) fails with Error decoding argument #3, because engine-state scripts can't pass a callback (base scripts never do). The version without a callback was committed but never ran in game. Whether the swap is applied, and what a positive timeToIgnore does, are both untested.

Decision

Overtaking on two-way roads (one lane per direction) is dropped (decided by the user, 2026-10-05). The effort continues on multilane roads only: a held-back vehicle should switch to a lane the slower vehicle isn't in.

Segment rebuild (ticket "Can rebuilding a road segment move a passer past its blocker?")

  • The script sent 3 makeWorldBuildProposalCmd proposals (remove segment + re-add a copy of its BaseEdge/BaseEdgeStreet, ignoreErrors = true) for segments 29701 and 29699 (country_new_medium). None took effect: both segment ids stayed in use by vehicles until the end of the log, and 29699 was "rebuilt" twice. The game logged no error, and there's no callback in engine state. The copied BaseEdge.distance read 0.0, so the copy is probably incomplete. A next attempt would need api.engine.util.proposal.makeProposalData to see the proposal's errors.
  • The human's manual segment deletion left no trace in the probe data: every vehicle stayed tvState=1 (en route), with only normal path trimming.
  • Related find: api.cmd.makeVehicleSendToDepotCmd(vehicle, sellOnArrival, jumpToDepotEntity) "teleports" a vehicle to a depot. The engine can relocate vehicles instantly, but only to depots.

Lessons from downloaded mods and the game folder (2026-10-05)

Scanned: the 9 mods in C:\Users\Public\mod.io\10640\mods\ (e.g. devers_1, move_it_probe_overpass, zhenya_auto_assign_terminals, celmi_timetables, burntnuggie_line_priority_1), and every .lua/.tl in the install's base, mods/release and dlcs, including inside the zips.

  • Callbacks only work on the GUI thread. Mods send commands from a guiUpdateScript/guiHandleEventScript (declared in the same .gs.lua) and get function(res, success, resultEntities) back. Errors are readable via res.resultProposalData.errorState.messages. From update (engine thread), callbacks fail with Error decoding argument #3, which is what we hit.
  • Rebuilding a segment: api.engine.util.proposal.replaceSegment(entity, streetTemplate?) returns a ready Proposal. The official campaign (mission 03 init.script.tl) sends it from engine state with makeWorldBuildProposalCmd(proposal, nil, true, true). devers_1 uses it the same way. It keeps lane links, edge objects (signals) and electrification. api.engine.util.proposal.makeProposalData(proposal) validates a proposal (.costs, .errorState) without sending it.
  • Teleport: only makeVehicleSendToDepotCmd(vehicle, sellOnArrival, jumpToDepotEntity). Mission 05 uses it for a ship, then makeVehicleSetLineCmd(vehicle, newLine, 0) and a restart. No coordinate or lane-position teleport exists for line vehicles. makeCustomVehicleCreateOrUpdateCmd is aircraft-only ("carrier currently must be Air").
  • api.engine.util.transport.calcPosition(edge.geometry, t) gives a world position along an edge geometry.
  • burntnuggie_line_priority_1 sets line reservation priority via makeLineUpdateCmd. That's path reservation for rail, not road traffic.
  • makeVehicleSetLineCmd to the vehicle's own line: the lanes planned ahead change, but the engine re-plans them on its own all the time (the leader's changed too, without a command). It doesn't force a lane change, and with stopIndex=0 it re-targets the line's first stop.

Segment rebuild via replaceSegment (run 2026-10-05 ~20:55 UTC)

  • api.engine.util.proposal.replaceSegment(seg) plus makeWorldBuildProposalCmd(p, nil, true, true) from engine state works: segments 29702, 21304 and 29012 never appeared again after their rebuild (they got new ids).
  • After all 3 rebuilds, the follower and leader ended up in different lanes (country_new_medium, 2 lanes per direction). Each time the leader moved from lane 4 to lane 3, either right away or one segment later, and the follower stayed in lane 4.
    • #1 (seg 29702): follower 29251 at 50.4 m behind leader 29852 at 76.7 m. Afterwards, on seg 29699, 29251 is at 29.1 m (lane 4) and 29852 at 20.2 m (lane 3). 29251 drives 18.6–19.6 m/s, above its earlier 16.67, and stays a segment ahead. The follower got past.
    • #2 (seg 21304): 29851 (leader) moved to lane 3 on seg 27833. The lanes split, but the order was unchanged.
    • #3 (seg 29012): 29251 (leader) moved to lane 3 on seg 29322.
    • Without rebuilds, the same pair stayed together in lane 4 for 6 segments (first probe). The engine also switches lanes by itself at segment ends sometimes, so 3/3 is suggestive, not proof.
    • Likely cause (human's hypothesis): the vehicles' MovePath refers to lanes of the removed segment, so the engine must re-attach them to the new segment and re-plan, and a vehicle can land in a different lane.
  • Debug MovePath exchange is unusable from engine state: api.cmd.sendCommand(api.cmd.debug.makeComponentExchangeCmd(v, movePath)) fails with Error decoding argument #3 even without a callback. So the earlier error wasn't caused by the callback. The only untried route is the GUI thread.
  • Not yet tested: a rebuild on a two-way (one lane per direction) road.