Compare commits

...
12 Commits
Author SHA1 Message Date
haylanandClaude Bot 0465a93e04 chore(prototype): point probe header at the position-flip ticket
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:25:10 +02:00
haylanandClaude Bot bfe5a22d1d chore(prototype): remove segment rebuild and dead engine-side experiments
Rebuild confirmed ineffective on single-lane roads (log + in game). Engine
side is logging only again; the GUI-thread position flip is the one
experiment left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:23:53 +02:00
haylanandClaude Bot 5d3a254e55 chore(prototype): flip follower/leader positions via MovePath exchange on GUI thread
Rebuild switched off for this run. Callbacks log success per exchange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:22:58 +02:00
haylanandClaude Bot 90829ee9b9 chore(prototype): per-tick trace after rebuild, multilane only; record two-way result
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:14:52 +02:00
haylanandClaude Bot d4fab92fd4 docs(research): segment rebuild splits lanes; component exchange dead in engine state
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:01:00 +02:00
haylanandClaude Bot 949462566e chore(prototype): rebuild via replaceSegment; record mod/game-folder findings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:56:58 +02:00
haylanandClaude Bot 39f2c63667 chore(prototype): try jumping follower past leader via MovePath component exchange
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:55:36 +02:00
haylanandClaude Bot e77fabb772 chore(prototype): try re-path via makeVehicleSetLineCmd; record failed rebuild
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:51:26 +02:00
haylanandClaude Bot ea23aa1dc5 chore(prototype): allow rebuild on any lane count to observe vehicle relocation sooner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:45:56 +02:00
haylanandClaude Bot 69b94552c2 fix(prototype): count driving lanes from laneConfigs, require moving leader
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:43:46 +02:00
haylanandClaude Bot 2a08067a6d chore(prototype): dump every 2s for rebuild before/after detail
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:40:42 +02:00
haylanandClaude Bot dcc4b4afae chore(prototype): rebuild two-way segment under a held-back vehicle
Replaces the timeToIgnore experiment (still in history) with the
segment-rebuild probe for "Can rebuilding a road segment move a passer
past its blocker?". Capped at 3 rebuilds per save via GameScriptState.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:37:57 +02:00
4 changed files with 141 additions and 49 deletions

No files matched your search

+38
View File
@@ -46,3 +46,41 @@ Ticket "Probe vehicle data and native passing in game" (part of map "Map: TpF3 o
## Decision ## 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. 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.
## Rebuild on a two-way road (run 2026-10-05 ~21:12 UTC)
- 3 `replaceSegment` rebuilds on `country_new_small` (one lane per direction), segments 30213, 29776 and 30215, follower 25.8 m behind leader. **No effect**: the next snapshot had the same gap (22.6/48.4 → 49.3/75.1), both vehicles stayed in lane 2, same speeds, same order for 10+ snapshots. Rebuilding does not move vehicles past each other on single-lane roads.
- How the multilane lane change happens is still uncaught: snapshots every 2 s only show before (both lane 4) and after (the leader in lane 3 two segments later). The probe now traces the two vehicles on every update for ~30 s after a rebuild (`TRACE` lines), and rebuilds only multilane segments.
## Segment rebuild dropped (2026-10-05)
The human confirmed in game what the log showed: rebuilding the segment under a held-back vehicle does not get it past the blocker on a single-lane road. The rebuild code is removed from the probe (it's still in git history, last in commit 5d3a254). The lane split after rebuilds on multilane roads (see above) is left as an observation for the multilane ticket, not pursued here.
+4 -2
View File
@@ -9,7 +9,9 @@
"summary": "Vehicle overtaking (WIP)", "summary": "Vehicle overtaking (WIP)",
"name": "Overtake", "name": "Overtake",
"tags": [ "tags": [
"script" "Script Mod",
"Car",
"Road"
], ],
"url": "" "url": "https://git.arthurerlich.de/haylan/tpf3-overtake"
} }
+3
View File
@@ -4,5 +4,8 @@ function data()
updateScript = { updateScript = {
fileName = "overtake_probe.script@update", fileName = "overtake_probe.script@update",
}, },
guiUpdateScript = {
fileName = "overtake_probe.script@guiUpdate",
},
} }
end end
+96 -47
View File
@@ -1,10 +1,14 @@
-- PROTOTYPE (ticket "Probe vehicle data and native passing in game"): throwaway, lives only on branch prototype/vehicle-probe. -- PROTOTYPE (ticket "Can a script swap the positions of a follower and its leader?"): throwaway,
-- Every INTERVAL it dumps every player-owned road vehicle, then groups them by street segment -- lives only on branch prototype/vehicle-probe.
-- so lanes (edge index) and oncoming traffic (opposite direction / turnaround edge) are visible in stdout.txt. -- Engine thread: every INTERVAL it logs every player-owned road vehicle (segment, lane, position, speed).
-- ponytail: module-local timer, not GameScriptState; fine for a probe, resets on reload. -- GUI thread: EXPERIMENT flipping the positions of a follower and its leader (see below).
-- Earlier experiments (timeToIgnore, engine-side jump, set-line, segment rebuild) are in git history and
-- docs/research/vehicle-probe.md; none moved a vehicle past another on a single-lane road.
-- ponytail: module-local timer, not GameScriptState; fine for a probe (state resets between calls anyway).
local TAG = "[overtake_probe] " local TAG = "[overtake_probe] "
local INTERVAL = 5.0 local INTERVAL = 2.0
local MAX_GAP = 40.0
local elapsed = 0.0 local elapsed = 0.0
local ticks = 0 local ticks = 0
@@ -25,79 +29,121 @@ local function turnaround(edge : EdgeId) : integer
return tn.turnaroundEdges[edge.index + 1] return tn.turnaroundEdges[edge.index + 1]
end end
-- EXPERIMENT: does api.cmd.debug.makeComponentExchangeCmd accept a MovePath, and does a positive
-- dyn.timeToIgnore make the follower ignore the vehicle ahead? Hypothesis only; nothing documents either.
local IGNORE_SECONDS = 10.0
local MAX_GAP = 40.0
local record Car local record Car
v : integer v : integer
pos : number pos : number
pos01 : number
speed : number speed : number
dir : boolean dir : boolean
mp : Engine.Component.MovePath
end
local function tryIgnore(follower : Car, leader : Car)
local mp = follower.mp
mp.dyn.timeToIgnore = IGNORE_SECONDS
p(string.format("IGNORE_TEST send veh=%d leader=%d gap=%.1f v=%.2f leaderV=%.2f", follower.v, leader.v, math.abs(leader.pos - follower.pos), follower.speed, leader.speed))
local cmd = api.cmd.debug.makeComponentExchangeCmd(follower.v, mp as Engine.Component)
-- engine-state scripts can't pass a callback ("Error decoding argument #3"); the next dump shows the effect
api.cmd.sendCommand(cmd)
end end
local function dump(dt : number) local function dump(dt : number)
local player = api.engine.util.getPlayer() local player = api.engine.util.getPlayer()
local vehicles = api.engine.getEntitiesWithComponent(api.type.ComponentType.TRANSPORT_VEHICLE, { requireOwnedByPlayer = player }) local vehicles = api.engine.getEntitiesWithComponent(api.type.ComponentType.TRANSPORT_VEHICLE, { requireOwnedByPlayer = player })
p(string.format("tick=%d dt=%s playerVehicles=%d", ticks, tostring(dt), #vehicles)) p(string.format("tick=%d dt=%s playerVehicles=%d", ticks, tostring(dt), #vehicles))
local bySegment : {integer : {string}} = {}
local byLane : {string : {Car}} = {}
for _, v in ipairs(vehicles) do for _, v in ipairs(vehicles) do
local lv = api.engine.getComponent(v, api.type.ComponentType.LAND_VEHICLE) as Engine.Component.LandVehicle local lv = api.engine.getComponent(v, api.type.ComponentType.LAND_VEHICLE) as Engine.Component.LandVehicle
local mp = api.engine.getComponent(v, api.type.ComponentType.MOVE_PATH) as Engine.Component.MovePath local mp = api.engine.getComponent(v, api.type.ComponentType.MOVE_PATH) as Engine.Component.MovePath
local tv = api.engine.getComponent(v, api.type.ComponentType.TRANSPORT_VEHICLE) as Engine.Component.TransportVehicle
if lv ~= nil and not lv.isTram and mp ~= nil then if lv ~= nil and not lv.isTram and mp ~= nil then
local edge, dir = edgeAt(mp) local edge, dir = edgeAt(mp)
local info = api.engine.system.landVehicleMoveSystem.getVehicleInfo(v) p(string.format(
local seg = edge and edge.entity or -1 "veh=%d seg=%d lane=%s dir=%s turnaround=%s edgeIndex=%d/%d pos=%.1f speed=%.2f state=%s tvState=%s line=%s standing=%.1f",
local line = string.format( v, edge and edge.entity or -1, tostring(edge and edge.index), tostring(dir), tostring(edge and turnaround(edge)),
"veh=%d seg=%d lane=%s dir=%s turnaround=%s pos=%.1f speed=%.2f accel=%.2f blocked=%s state=%s standing=%.1f timeToIgnore=%.2f timeUntilAccel=%.2f occ=%s..%s brake=%.1f power=%s traction=%s", mp.dyn.pathPos.edgeIndex, #(mp.path.edges as {any}), mp.dyn.pathPos.pos, mp.dyn.speed, tostring(mp.state),
v, seg, tostring(edge and edge.index), tostring(dir), tostring(edge and turnaround(edge)), tostring(tv and tv.state), tostring(tv and tv.line), mp.dyn.timeStanding))
mp.dyn.pathPos.pos, mp.dyn.speed, mp.dyn.accel, tostring(mp.blocked), tostring(mp.state),
mp.dyn.timeStanding, mp.dyn.timeToIgnore, mp.dyn.timeUntilAccel,
tostring(mp.occupiedBackIndex), tostring(mp.occupiedFrontIndex), lv.dyn.brakingTimer,
tostring(info and info.availPower), tostring(info and info.traction))
p(line)
if edge ~= nil then
local key = seg .. ":" .. edge.index
byLane[key] = byLane[key] or {}
table.insert(byLane[key], { v = v, pos = mp.dyn.pathPos.pos, speed = mp.dyn.speed, dir = dir, mp = mp })
end
bySegment[seg] = bySegment[seg] or {}
table.insert(bySegment[seg], string.format("%d(lane=%s,dir=%s,v=%.1f,blk=%s)", v, tostring(edge and edge.index), tostring(dir), mp.dyn.speed, tostring(mp.blocked)))
end end
end end
for seg, list in pairs(bySegment) do end
if #list > 1 then
p(string.format("SHARED seg=%d: %s", seg, table.concat(list, " "))) -- EXPERIMENT (GUI thread, where sendCommand callbacks work): flip the positions of a follower and its leader by
-- exchanging their MovePath components (route + position) with api.cmd.debug.makeComponentExchangeCmd.
local MAX_FLIPS = 2
local FLIP_SCAN_FRAMES = 60 -- scan for a pair about once per second
local FLIP_TRACE_FRAMES = 600 -- then log both vehicles every 10 frames for ~10 s
local record GuiState
frames : integer
flips : integer
traceLeft : integer
a : integer
b : integer
end
local function movePathOf(v : integer) : Engine.Component.MovePath
return api.engine.getComponent(v, api.type.ComponentType.MOVE_PATH) as Engine.Component.MovePath
end
local function flipLine(v : integer) : string
local mp = movePathOf(v)
if mp == nil then return "veh=" .. v .. " no MovePath" end
local edge, _dir = edgeAt(mp)
return string.format("veh=%d seg=%s lane=%s pos=%.1f v=%.2f", v, tostring(edge and edge.entity), tostring(edge and edge.index), mp.dyn.pathPos.pos, mp.dyn.speed)
end
local function sendFlip(gs : GuiState, follower : integer, leader : integer)
local mpF, mpL = movePathOf(follower), movePathOf(leader)
p("FLIP_TEST before " .. flipLine(follower) .. " | " .. flipLine(leader))
local function done(label : string) : function(any, boolean)
return function(res : any, success : boolean)
p(string.format("FLIP_TEST result %s success=%s res=%s", label, tostring(success), tostring(res)))
end
end
api.cmd.sendCommand(api.cmd.debug.makeComponentExchangeCmd(follower, mpL as Engine.Component), done("follower<-leader"))
api.cmd.sendCommand(api.cmd.debug.makeComponentExchangeCmd(leader, mpF as Engine.Component), done("leader<-follower"))
gs.flips = gs.flips + 1
gs.traceLeft = FLIP_TRACE_FRAMES
gs.a, gs.b = follower, leader
end
-- same pair rule as the engine-side probe: same segment + lane, follower < MAX_GAP behind a moving leader
local function findPair() : integer, integer
local player = api.engine.util.getPlayer()
local byLane : {string : {Car}} = {}
for _, v in ipairs(api.engine.getEntitiesWithComponent(api.type.ComponentType.TRANSPORT_VEHICLE, { requireOwnedByPlayer = player })) do
local lv = api.engine.getComponent(v, api.type.ComponentType.LAND_VEHICLE) as Engine.Component.LandVehicle
local mp = movePathOf(v)
if lv ~= nil and not lv.isTram and mp ~= nil then
local edge, dir = edgeAt(mp)
if edge ~= nil then
local key = edge.entity .. ":" .. edge.index
byLane[key] = byLane[key] or {}
table.insert(byLane[key], { v = v, pos = mp.dyn.pathPos.pos, pos01 = mp.dyn.pathPos.pos01, speed = mp.dyn.speed, dir = dir })
end
end end
end end
for _, cars in pairs(byLane) do for _, cars in pairs(byLane) do
for i = 1, #cars do for i = 1, #cars do
for j = 1, #cars do for j = 1, #cars do
local a, b = cars[i], cars[j] local a, b = cars[i], cars[j]
-- ponytail: assumes pos grows in driving direction when dir=true; the log shows whether that holds
local ahead = (a.dir and b.pos > a.pos) or (not a.dir and b.pos < a.pos) local ahead = (a.dir and b.pos > a.pos) or (not a.dir and b.pos < a.pos)
if i ~= j and ahead and math.abs(b.pos - a.pos) < MAX_GAP then if i ~= j and ahead and math.abs(b.pos - a.pos) < MAX_GAP and b.speed > 1.0 then
tryIgnore(a, b) return a.v, b.v
end end
end end
end end
end end
return nil, nil
end end
local ret : GameScript<table> = { local function guiStep(gsState : GameScriptState<GuiState>)
local gs = gsState:get()
gs.frames = (gs.frames or 0) + 1
gs.flips = gs.flips or 0
gs.traceLeft = gs.traceLeft or 0
if gs.traceLeft > 0 then
if gs.traceLeft % 10 == 0 then
p(string.format("FLIP_TRACE f=%d %s | %s", FLIP_TRACE_FRAMES - gs.traceLeft, flipLine(gs.a), flipLine(gs.b)))
end
gs.traceLeft = gs.traceLeft - 1
elseif gs.flips < MAX_FLIPS and gs.frames % FLIP_SCAN_FRAMES == 0 then
local follower, leader = findPair()
if follower ~= nil then sendFlip(gs, follower, leader) end
end
gsState:set(gs)
end
local ret : GameScriptWithGui<table, GuiState> = {
update = function(_userParams : table, _state : GameScriptState<table>, dt : number) : nil update = function(_userParams : table, _state : GameScriptState<table>, dt : number) : nil
ticks = ticks + 1 ticks = ticks + 1
elapsed = elapsed + dt elapsed = elapsed + dt
@@ -106,6 +152,9 @@ local ret : GameScript<table> = {
elapsed = 0.0 elapsed = 0.0
xpcall(dump, function(err : any) p("ERROR " .. tostring(err)) end, dt) xpcall(dump, function(err : any) p("ERROR " .. tostring(err)) end, dt)
end, end,
guiUpdate = function(_userParams : table, _state : GameScriptStateReadOnly<table>, guiState : GameScriptState<GuiState>)
xpcall(guiStep, function(err : any) p("ERROR gui " .. tostring(err)) end, guiState)
end,
} }
return ret return ret