From 5dd13cd6adbeb9cdd99542c254656bbe20256fa5 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Tue, 11 Aug 2026 15:38:43 +0100 Subject: [PATCH 01/11] Preserve AutoDrive helper continuity --- scripts/HP_Compatibility.lua | 226 ++++++++++++++++++++++++++++++++++- 1 file changed, 225 insertions(+), 1 deletion(-) diff --git a/scripts/HP_Compatibility.lua b/scripts/HP_Compatibility.lua index ce69f43..6e2c62a 100644 --- a/scripts/HP_Compatibility.lua +++ b/scripts/HP_Compatibility.lua @@ -274,4 +274,228 @@ function HP_Compatibility:deleteMap() self.startupCheckRemainingMs = 0 end -addModEventListener(HP_Compatibility) \ No newline at end of file +addModEventListener(HP_Compatibility) + +---------------------------------------------------------------------- +-- AutoDrive helper continuity compatibility +---------------------------------------------------------------------- +-- AutoDrive can synchronously stop/restart a running mode. Its stop path releases +-- and clears the GIANTS helper, then startAutoDrive() asks getRandomHelper() for a +-- helper again. Preserve the vehicle's existing helper identity across that +-- restart window without changing normal HelperProfiles selection behaviour. + +HP_AutoDriveContinuity = HP_AutoDriveContinuity or { + _helperHookInstalled = false, + _activityHookInstalled = false, + _baseGetRandomHelper = nil, + _baseIsHelperActive = nil, + _reservations = setmetatable({}, {__mode = "k"}) +} + +local AD_LOG = "[FS25_HelperProfiles/AutoDrive] " + +local function adLog(message, ...) + print(AD_LOG .. string.format(tostring(message), ...)) +end + +local function getVehicleLabel(vehicle) + if vehicle ~= nil and vehicle.getName ~= nil then + local ok, value = pcall(vehicle.getName, vehicle) + if ok and value ~= nil and tostring(value) ~= "" then + return tostring(value) + end + end + return tostring(vehicle or "unknown-vehicle") +end + +local function getAutoDriveState(vehicle) + if vehicle == nil or vehicle.ad == nil or vehicle.ad.stateModule == nil then + return false, nil + end + + local stateModule = vehicle.ad.stateModule + local active = false + if stateModule.isActive ~= nil then + local ok, value = pcall(stateModule.isActive, stateModule) + active = ok and value == true + end + + local helperIndex = 0 + if stateModule.getCurrentHelperIndex ~= nil then + local ok, value = pcall(stateModule.getCurrentHelperIndex, stateModule) + if ok then helperIndex = tonumber(value) or 0 end + end + + return active, helperIndex +end + +local function isEngineAvailable(helper) + if helper == nil or helper.inUse == true or g_helperManager == nil then return false end + for _, candidate in ipairs(g_helperManager.availableHelpers or {}) do + if candidate == helper then return true end + end + return false +end + +function HP_AutoDriveContinuity:isEnabled() + return rawget(_G, "AutoDrive") ~= nil +end + +function HP_AutoDriveContinuity:isReserved(helper) + if helper == nil then return false end + for _, reservation in pairs(self._reservations or {}) do + if reservation ~= nil and reservation.helper == helper then + return true + end + end + return false +end + +function HP_AutoDriveContinuity:_remember(vehicle, helper, helperIndex) + if vehicle == nil or helper == nil then return end + + local existing = self._reservations[vehicle] + if existing ~= nil and existing.helper == helper then + existing.helperIndex = tonumber(helperIndex) or tonumber(helper.index) or existing.helperIndex or 0 + return + end + + self._reservations[vehicle] = { + helper = helper, + helperIndex = tonumber(helperIndex) or tonumber(helper.index) or 0 + } + + adLog( + "Driver reserved: vehicle='%s' helper='%s' index=%d", + getVehicleLabel(vehicle), + tostring(helper.name or "?"), + tonumber(helperIndex) or tonumber(helper.index) or 0 + ) +end + +function HP_AutoDriveContinuity:_release(vehicle, reason) + local reservation = self._reservations[vehicle] + if reservation == nil then return end + + adLog( + "Driver reservation cleared: vehicle='%s' helper='%s' reason=%s", + getVehicleLabel(vehicle), + tostring(reservation.helper and reservation.helper.name or "?"), + tostring(reason or "unknown") + ) + self._reservations[vehicle] = nil +end + +function HP_AutoDriveContinuity:_findReacquireCandidate() + if not self:isEnabled() or g_currentMission == nil then return nil, nil end + + local matchedVehicle = nil + local matchedHelper = nil + local matches = 0 + + for _, vehicle in pairs(g_currentMission.vehicles or {}) do + local reservation = self._reservations[vehicle] + if reservation ~= nil and reservation.helper ~= nil then + local active, helperIndex = getAutoDriveState(vehicle) + local adHelper = vehicle.ad ~= nil and vehicle.ad.currentHelper or nil + + -- AutoDrive:startAutoDrive sets active=true before calling + -- g_helperManager:getRandomHelper(). During a restart this creates a + -- distinctive active + no-current-helper window for the calling vehicle. + if active and (adHelper == nil or (tonumber(helperIndex) or 0) <= 0) and isEngineAvailable(reservation.helper) then + matches = matches + 1 + matchedVehicle = vehicle + matchedHelper = reservation.helper + end + end + end + + -- getRandomHelper has no vehicle argument, so only override when exactly one + -- AutoDrive vehicle has an unambiguous reserved-helper reacquisition window. + if matches == 1 then + return matchedHelper, matchedVehicle + end + + if matches > 1 then + adLog("Continuity skipped: %d AutoDrive vehicles are simultaneously awaiting reserved helpers", matches) + end + return nil, nil +end + +function HP_AutoDriveContinuity:_installActivityHook() + if self._activityHookInstalled then return true end + if HelperProfiles == nil or HelperProfiles.isHelperActive == nil then return false end + + self._baseIsHelperActive = HelperProfiles.isHelperActive + HelperProfiles.isHelperActive = function(helperProfilesSelf, helper) + if HP_AutoDriveContinuity ~= nil and HP_AutoDriveContinuity:isReserved(helper) then + return true + end + return HP_AutoDriveContinuity._baseIsHelperActive(helperProfilesSelf, helper) + end + + self._activityHookInstalled = true + adLog("Installed reserved-helper activity bridge") + return true +end + +function HP_AutoDriveContinuity:_installHelperHook() + if self._helperHookInstalled then return true end + if not self:isEnabled() then return false end + if HelperProfiles == nil or HelperProfiles._hooksDone ~= true then return false end + if HelperManager == nil or HelperManager.getRandomHelper == nil then return false end + + self._baseGetRandomHelper = HelperManager.getRandomHelper + HelperManager.getRandomHelper = function(manager, ...) + local helper, vehicle = HP_AutoDriveContinuity:_findReacquireCandidate() + if helper ~= nil then + adLog( + "Driver continuity reacquire: vehicle='%s' helper='%s' index=%d", + getVehicleLabel(vehicle), + tostring(helper.name or "?"), + tonumber(helper.index) or 0 + ) + return helper + end + return HP_AutoDriveContinuity._baseGetRandomHelper(manager, ...) + end + + self._helperHookInstalled = true + adLog("Installed AutoDrive getRandomHelper continuity hook") + return true +end + +function HP_AutoDriveContinuity:loadMap() + self._reservations = setmetatable({}, {__mode = "k"}) +end + +function HP_AutoDriveContinuity:update(dt) + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then return end + + self:_installActivityHook() + self:_installHelperHook() + + if not self:isEnabled() or g_currentMission == nil then return end + + for _, vehicle in pairs(g_currentMission.vehicles or {}) do + if vehicle ~= nil and vehicle.ad ~= nil and vehicle.ad.stateModule ~= nil then + local active, helperIndex = getAutoDriveState(vehicle) + local currentHelper = vehicle.ad.currentHelper + + if active and currentHelper ~= nil then + self:_remember(vehicle, currentHelper, helperIndex) + elseif not active and self._reservations[vehicle] ~= nil then + -- AutoDrive's RestartADTask performs stopAutoDrive() and mode:start() + -- synchronously, so an internal restart does not reach this update in + -- the inactive state. A vehicle observed inactive here is a genuine stop. + self:_release(vehicle, "autodrive-stopped") + end + end + end +end + +function HP_AutoDriveContinuity:deleteMap() + self._reservations = setmetatable({}, {__mode = "k"}) +end + +addModEventListener(HP_AutoDriveContinuity) From d5bd0a592f1271281ceec40b3da92022ee2bba57 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Wed, 12 Aug 2026 13:00:27 +0100 Subject: [PATCH 02/11] Add event-driven AutoDrive continuity bridge --- scripts/HP_AutoDriveContinuity.lua | 231 +++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 scripts/HP_AutoDriveContinuity.lua diff --git a/scripts/HP_AutoDriveContinuity.lua b/scripts/HP_AutoDriveContinuity.lua new file mode 100644 index 0000000..a7679cb --- /dev/null +++ b/scripts/HP_AutoDriveContinuity.lua @@ -0,0 +1,231 @@ +-- HP_AutoDriveContinuity.lua (FS25_HelperProfiles) +-- Event-driven AutoDrive helper continuity bridge. +-- Loaded after HelperProfiles.lua so its hooks wrap the final HelperProfiles picker. + +HP_AutoDriveContinuityV2 = HP_AutoDriveContinuityV2 or { + installed = false, + leaseMs = 3000, + reservations = setmetatable({}, {__mode = "k"}), + originalGetRandomHelper = nil, + originalReleaseHelper = nil, + originalIsHelperActive = nil +} + +local LOG = "[FS25_HelperProfiles/AutoDriveV2] " + +local function log(message, ...) + print(LOG .. string.format(tostring(message), ...)) +end + +local function nowMs() + return tonumber(g_time) or 0 +end + +local function vehicleName(vehicle) + if vehicle ~= nil and vehicle.getName ~= nil then + local ok, value = pcall(vehicle.getName, vehicle) + if ok and value ~= nil and tostring(value) ~= "" then + return tostring(value) + end + end + return tostring(vehicle or "unknown-vehicle") +end + +local function getAutoDriveState(vehicle) + if vehicle == nil or vehicle.ad == nil or vehicle.ad.stateModule == nil then + return false, 0 + end + + local stateModule = vehicle.ad.stateModule + local active = false + if stateModule.isActive ~= nil then + local ok, value = pcall(stateModule.isActive, stateModule) + active = ok and value == true + end + + local helperIndex = 0 + if stateModule.getCurrentHelperIndex ~= nil then + local ok, value = pcall(stateModule.getCurrentHelperIndex, stateModule) + if ok then helperIndex = tonumber(value) or 0 end + end + + return active, helperIndex +end + +local function isEngineAvailable(helper) + if helper == nil or helper.inUse == true or g_helperManager == nil then return false end + for _, candidate in ipairs(g_helperManager.availableHelpers or {}) do + if candidate == helper then return true end + end + return false +end + +function HP_AutoDriveContinuityV2:isEnabled() + return rawget(_G, "AutoDrive") ~= nil +end + +function HP_AutoDriveContinuityV2:_pruneExpired() + local now = nowMs() + for vehicle, reservation in pairs(self.reservations or {}) do + if reservation == nil or now > (tonumber(reservation.expiresAt) or 0) then + if reservation ~= nil then + log( + "Driver reservation expired: vehicle='%s' helper='%s'", + vehicleName(vehicle), + tostring(reservation.helper and reservation.helper.name or "?") + ) + end + self.reservations[vehicle] = nil + end + end +end + +function HP_AutoDriveContinuityV2:isReserved(helper) + if helper == nil then return false end + self:_pruneExpired() + for _, reservation in pairs(self.reservations or {}) do + if reservation ~= nil and reservation.helper == helper then + return true + end + end + return false +end + +function HP_AutoDriveContinuityV2:_findOwningVehicle(helper) + if helper == nil or g_currentMission == nil then return nil end + + local match = nil + for _, vehicle in pairs(g_currentMission.vehicles or {}) do + if vehicle ~= nil and vehicle.ad ~= nil and vehicle.ad.currentHelper == helper then + if match ~= nil and match ~= vehicle then + log("Release capture skipped: helper='%s' is referenced by multiple AutoDrive vehicles", tostring(helper.name or "?")) + return nil + end + match = vehicle + end + end + return match +end + +function HP_AutoDriveContinuityV2:captureRelease(helper) + if not self:isEnabled() or helper == nil then return false end + + local vehicle = self:_findOwningVehicle(helper) + if vehicle == nil then return false end + + local active, helperIndex = getAutoDriveState(vehicle) + local expiresAt = nowMs() + (tonumber(self.leaseMs) or 3000) + self.reservations[vehicle] = { + helper = helper, + helperIndex = tonumber(helper.index) or tonumber(helperIndex) or 0, + expiresAt = expiresAt + } + + log( + "Driver release captured: vehicle='%s' helper='%s' index=%d adActive=%s leaseMs=%d", + vehicleName(vehicle), + tostring(helper.name or "?"), + tonumber(helper.index) or tonumber(helperIndex) or 0, + tostring(active), + tonumber(self.leaseMs) or 3000 + ) + return true +end + +function HP_AutoDriveContinuityV2:getReacquireHelper() + if not self:isEnabled() then return nil, nil end + self:_pruneExpired() + + local matchedVehicle = nil + local matchedHelper = nil + local matches = 0 + + for vehicle, reservation in pairs(self.reservations or {}) do + if reservation ~= nil and reservation.helper ~= nil then + local active, helperIndex = getAutoDriveState(vehicle) + local currentHelper = vehicle.ad ~= nil and vehicle.ad.currentHelper or nil + + -- AutoDrive start/restart sets itself active before asking HelperManager + -- for another helper. Only reclaim a reservation for that exact vehicle. + if active + and (currentHelper == nil or (tonumber(helperIndex) or 0) <= 0) + and isEngineAvailable(reservation.helper) then + matches = matches + 1 + matchedVehicle = vehicle + matchedHelper = reservation.helper + end + end + end + + if matches ~= 1 then + if matches > 1 then + log("Continuity skipped: %d AutoDrive vehicles are simultaneously awaiting reserved helpers", matches) + end + return nil, nil + end + + self.reservations[matchedVehicle] = nil + log( + "Driver continuity reacquire: vehicle='%s' helper='%s' index=%d", + vehicleName(matchedVehicle), + tostring(matchedHelper.name or "?"), + tonumber(matchedHelper.index) or 0 + ) + return matchedHelper, matchedVehicle +end + +function HP_AutoDriveContinuityV2:install() + if self.installed then return true end + if not self:isEnabled() then return false end + if HelperProfiles == nil or HelperProfiles._hooksDone ~= true then return false end + if HelperManager == nil or HelperManager.getRandomHelper == nil or HelperManager.releaseHelper == nil then return false end + + self.originalGetRandomHelper = HelperManager.getRandomHelper + HelperManager.getRandomHelper = function(manager, ...) + local helper = HP_AutoDriveContinuityV2:getReacquireHelper() + if helper ~= nil then + print(("[FS25_HelperProfiles] getRandomHelper -> '%s' (autodrive-continuity)"):format(tostring(helper.name))) + return helper + end + return HP_AutoDriveContinuityV2.originalGetRandomHelper(manager, ...) + end + + self.originalReleaseHelper = HelperManager.releaseHelper + HelperManager.releaseHelper = function(manager, helper, ...) + HP_AutoDriveContinuityV2:captureRelease(helper) + return HP_AutoDriveContinuityV2.originalReleaseHelper(manager, helper, ...) + end + + if HelperProfiles.isHelperActive ~= nil then + self.originalIsHelperActive = HelperProfiles.isHelperActive + HelperProfiles.isHelperActive = function(helperProfilesSelf, helper) + if HP_AutoDriveContinuityV2:isReserved(helper) then + return true + end + return HP_AutoDriveContinuityV2.originalIsHelperActive(helperProfilesSelf, helper) + end + end + + self.installed = true + log("Installed event-driven getRandomHelper/releaseHelper continuity hooks") + return true +end + +function HP_AutoDriveContinuityV2:loadMap() + self.reservations = setmetatable({}, {__mode = "k"}) +end + +function HP_AutoDriveContinuityV2:update(dt) + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then return end + if not self.installed then + self:install() + else + self:_pruneExpired() + end +end + +function HP_AutoDriveContinuityV2:deleteMap() + self.reservations = setmetatable({}, {__mode = "k"}) +end + +addModEventListener(HP_AutoDriveContinuityV2) From 9ef6fd7ed3b281c207d24ada8d8601e8f6b314d5 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Wed, 12 Aug 2026 13:01:02 +0100 Subject: [PATCH 03/11] Load AutoDrive continuity bridge after HelperProfiles --- modDesc.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/modDesc.xml b/modDesc.xml index 8bebc44..7052167 100644 --- a/modDesc.xml +++ b/modDesc.xml @@ -217,6 +217,7 @@ Version 2.0.26.0 : + From e2913129e07db44b533d0d9cf06e6d6c9791227f Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Wed, 12 Aug 2026 13:01:43 +0100 Subject: [PATCH 04/11] Use event-driven AutoDrive continuity only --- scripts/HP_AutoDriveContinuity.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/HP_AutoDriveContinuity.lua b/scripts/HP_AutoDriveContinuity.lua index a7679cb..d56a5b5 100644 --- a/scripts/HP_AutoDriveContinuity.lua +++ b/scripts/HP_AutoDriveContinuity.lua @@ -2,6 +2,12 @@ -- Event-driven AutoDrive helper continuity bridge. -- Loaded after HelperProfiles.lua so its hooks wrap the final HelperProfiles picker. +-- The first continuity prototype lives in HP_Compatibility.lua. Disable its polling +-- update path for this test build so only the event-driven bridge owns continuity. +if HP_AutoDriveContinuity ~= nil then + HP_AutoDriveContinuity.update = function() end +end + HP_AutoDriveContinuityV2 = HP_AutoDriveContinuityV2 or { installed = false, leaseMs = 3000, From 5d546470ae6f37959b8d8649e13bdf2f4641890a Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Wed, 12 Aug 2026 13:33:27 +0100 Subject: [PATCH 05/11] Fix AutoDrive continuity runtime manager hooks --- scripts/HP_AutoDriveContinuity.lua | 71 ++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/scripts/HP_AutoDriveContinuity.lua b/scripts/HP_AutoDriveContinuity.lua index d56a5b5..167e895 100644 --- a/scripts/HP_AutoDriveContinuity.lua +++ b/scripts/HP_AutoDriveContinuity.lua @@ -2,6 +2,8 @@ -- Event-driven AutoDrive helper continuity bridge. -- Loaded after HelperProfiles.lua so its hooks wrap the final HelperProfiles picker. +print("[FS25_HelperProfiles/AutoDriveV2] Source loaded (runtime-manager hook build)") + -- The first continuity prototype lives in HP_Compatibility.lua. Disable its polling -- update path for this test build so only the event-driven bridge owns continuity. if HP_AutoDriveContinuity ~= nil then @@ -14,7 +16,10 @@ HP_AutoDriveContinuityV2 = HP_AutoDriveContinuityV2 or { reservations = setmetatable({}, {__mode = "k"}), originalGetRandomHelper = nil, originalReleaseHelper = nil, - originalIsHelperActive = nil + originalIsHelperActive = nil, + runtimeManager = nil, + _lastWaitReason = nil, + _lastWaitLogMs = -100000 } local LOG = "[FS25_HelperProfiles/AutoDriveV2] " @@ -66,8 +71,14 @@ local function isEngineAvailable(helper) return false end -function HP_AutoDriveContinuityV2:isEnabled() - return rawget(_G, "AutoDrive") ~= nil +function HP_AutoDriveContinuityV2:_logInstallWait(reason) + local now = nowMs() + reason = tostring(reason or "unknown") + if self._lastWaitReason ~= reason or (now - (tonumber(self._lastWaitLogMs) or 0)) >= 1000 then + self._lastWaitReason = reason + self._lastWaitLogMs = now + log("Waiting to install: %s", reason) + end end function HP_AutoDriveContinuityV2:_pruneExpired() @@ -114,7 +125,7 @@ function HP_AutoDriveContinuityV2:_findOwningVehicle(helper) end function HP_AutoDriveContinuityV2:captureRelease(helper) - if not self:isEnabled() or helper == nil then return false end + if helper == nil then return false end local vehicle = self:_findOwningVehicle(helper) if vehicle == nil then return false end @@ -139,7 +150,6 @@ function HP_AutoDriveContinuityV2:captureRelease(helper) end function HP_AutoDriveContinuityV2:getReacquireHelper() - if not self:isEnabled() then return nil, nil end self:_pruneExpired() local matchedVehicle = nil @@ -182,12 +192,40 @@ end function HP_AutoDriveContinuityV2:install() if self.installed then return true end - if not self:isEnabled() then return false end - if HelperProfiles == nil or HelperProfiles._hooksDone ~= true then return false end - if HelperManager == nil or HelperManager.getRandomHelper == nil or HelperManager.releaseHelper == nil then return false end - self.originalGetRandomHelper = HelperManager.getRandomHelper - HelperManager.getRandomHelper = function(manager, ...) + if HelperProfiles == nil then + self:_logInstallWait("HelperProfiles global unavailable") + return false + end + if HelperProfiles._hooksDone ~= true then + self:_logInstallWait("HelperProfiles getRandomHelper hook not ready") + return false + end + + local runtimeManager = rawget(_G, "g_helperManager") + if runtimeManager == nil then + self:_logInstallWait("g_helperManager unavailable") + return false + end + + local runtimeGetRandomHelper = runtimeManager.getRandomHelper + local runtimeReleaseHelper = runtimeManager.releaseHelper + + if type(runtimeGetRandomHelper) ~= "function" then + self:_logInstallWait("g_helperManager.getRandomHelper unavailable (type=" .. tostring(type(runtimeGetRandomHelper)) .. ")") + return false + end + if type(runtimeReleaseHelper) ~= "function" then + self:_logInstallWait("g_helperManager.releaseHelper unavailable (type=" .. tostring(type(runtimeReleaseHelper)) .. ")") + return false + end + + -- Hook the live manager instance rather than assuming both methods are exposed + -- directly on the HelperManager class. AutoDrive invokes g_helperManager with + -- colon syntax, so these instance wrappers intercept the exact runtime calls. + self.runtimeManager = runtimeManager + self.originalGetRandomHelper = runtimeGetRandomHelper + runtimeManager.getRandomHelper = function(manager, ...) local helper = HP_AutoDriveContinuityV2:getReacquireHelper() if helper ~= nil then print(("[FS25_HelperProfiles] getRandomHelper -> '%s' (autodrive-continuity)"):format(tostring(helper.name))) @@ -196,8 +234,8 @@ function HP_AutoDriveContinuityV2:install() return HP_AutoDriveContinuityV2.originalGetRandomHelper(manager, ...) end - self.originalReleaseHelper = HelperManager.releaseHelper - HelperManager.releaseHelper = function(manager, helper, ...) + self.originalReleaseHelper = runtimeReleaseHelper + runtimeManager.releaseHelper = function(manager, helper, ...) HP_AutoDriveContinuityV2:captureRelease(helper) return HP_AutoDriveContinuityV2.originalReleaseHelper(manager, helper, ...) end @@ -213,12 +251,19 @@ function HP_AutoDriveContinuityV2:install() end self.installed = true - log("Installed event-driven getRandomHelper/releaseHelper continuity hooks") + self._lastWaitReason = nil + log( + "Installed event-driven runtime-manager continuity hooks (getRandomHelper=%s releaseHelper=%s)", + tostring(type(runtimeGetRandomHelper)), + tostring(type(runtimeReleaseHelper)) + ) return true end function HP_AutoDriveContinuityV2:loadMap() self.reservations = setmetatable({}, {__mode = "k"}) + self._lastWaitReason = nil + self._lastWaitLogMs = -100000 end function HP_AutoDriveContinuityV2:update(dt) From b21940905c2bb59802b4037e09ed707cefea6b40 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Wed, 12 Aug 2026 13:50:12 +0100 Subject: [PATCH 06/11] Fix AutoDrive continuity manager lookup --- scripts/HP_AutoDriveContinuity.lua | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/HP_AutoDriveContinuity.lua b/scripts/HP_AutoDriveContinuity.lua index 167e895..e7ee99b 100644 --- a/scripts/HP_AutoDriveContinuity.lua +++ b/scripts/HP_AutoDriveContinuity.lua @@ -74,7 +74,7 @@ end function HP_AutoDriveContinuityV2:_logInstallWait(reason) local now = nowMs() reason = tostring(reason or "unknown") - if self._lastWaitReason ~= reason or (now - (tonumber(self._lastWaitLogMs) or 0)) >= 1000 then + if self._lastWaitReason ~= reason or (now - (tonumber(self._lastWaitLogMs) or 0)) >= 10000 then self._lastWaitReason = reason self._lastWaitLogMs = now log("Waiting to install: %s", reason) @@ -202,7 +202,10 @@ function HP_AutoDriveContinuityV2:install() return false end - local runtimeManager = rawget(_G, "g_helperManager") + -- IMPORTANT: use normal global lookup here. GIANTS mod environments can expose + -- engine globals through the environment metatable, so rawget(_G, ...) may + -- incorrectly report a valid engine global as missing. + local runtimeManager = g_helperManager if runtimeManager == nil then self:_logInstallWait("g_helperManager unavailable") return false From 073151451811c1a957a5d14b83a5da1cbe3b8a19 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Wed, 12 Aug 2026 14:18:41 +0100 Subject: [PATCH 07/11] Bind AutoDrive helper for session continuity --- scripts/HP_AutoDriveContinuity.lua | 333 +++++++++++++++++++---------- 1 file changed, 225 insertions(+), 108 deletions(-) diff --git a/scripts/HP_AutoDriveContinuity.lua b/scripts/HP_AutoDriveContinuity.lua index e7ee99b..049a6cc 100644 --- a/scripts/HP_AutoDriveContinuity.lua +++ b/scripts/HP_AutoDriveContinuity.lua @@ -1,28 +1,31 @@ -- HP_AutoDriveContinuity.lua (FS25_HelperProfiles) --- Event-driven AutoDrive helper continuity bridge. --- Loaded after HelperProfiles.lua so its hooks wrap the final HelperProfiles picker. +-- AutoDrive helper continuity bridge. +-- +-- Ownership is established when an active AutoDrive vehicle first acquires a helper. +-- That vehicle keeps the same helper across GIANTS/AutoDrive release/reacquire cycles +-- until AutoDrive has been genuinely inactive for a short sustained interval. -print("[FS25_HelperProfiles/AutoDriveV2] Source loaded (runtime-manager hook build)") +print("[FS25_HelperProfiles/AutoDriveV3] Source loaded (session-ownership build)") --- The first continuity prototype lives in HP_Compatibility.lua. Disable its polling --- update path for this test build so only the event-driven bridge owns continuity. +-- Disable the first polling prototype in HP_Compatibility.lua. This module owns +-- AutoDrive continuity for this test branch. if HP_AutoDriveContinuity ~= nil then HP_AutoDriveContinuity.update = function() end end -HP_AutoDriveContinuityV2 = HP_AutoDriveContinuityV2 or { +HP_AutoDriveContinuityV3 = HP_AutoDriveContinuityV3 or { installed = false, - leaseMs = 3000, reservations = setmetatable({}, {__mode = "k"}), originalGetRandomHelper = nil, originalReleaseHelper = nil, originalIsHelperActive = nil, runtimeManager = nil, + stopGraceMs = 500, _lastWaitReason = nil, _lastWaitLogMs = -100000 } -local LOG = "[FS25_HelperProfiles/AutoDriveV2] " +local LOG = "[FS25_HelperProfiles/AutoDriveV3] " local function log(message, ...) print(LOG .. string.format(tostring(message), ...)) @@ -42,6 +45,10 @@ local function vehicleName(vehicle) return tostring(vehicle or "unknown-vehicle") end +local function helperName(helper) + return tostring(helper ~= nil and helper.name or "?") +end + local function getAutoDriveState(vehicle) if vehicle == nil or vehicle.ad == nil or vehicle.ad.stateModule == nil then return false, 0 @@ -57,21 +64,28 @@ local function getAutoDriveState(vehicle) local helperIndex = 0 if stateModule.getCurrentHelperIndex ~= nil then local ok, value = pcall(stateModule.getCurrentHelperIndex, stateModule) - if ok then helperIndex = tonumber(value) or 0 end + if ok then + helperIndex = tonumber(value) or 0 + end end return active, helperIndex end local function isEngineAvailable(helper) - if helper == nil or helper.inUse == true or g_helperManager == nil then return false end + if helper == nil or helper.inUse == true or g_helperManager == nil then + return false + end + for _, candidate in ipairs(g_helperManager.availableHelpers or {}) do - if candidate == helper then return true end + if candidate == helper then + return true + end end return false end -function HP_AutoDriveContinuityV2:_logInstallWait(reason) +function HP_AutoDriveContinuityV3:_logInstallWait(reason) local now = nowMs() reason = tostring(reason or "unknown") if self._lastWaitReason ~= reason or (now - (tonumber(self._lastWaitLogMs) or 0)) >= 10000 then @@ -81,25 +95,81 @@ function HP_AutoDriveContinuityV2:_logInstallWait(reason) end end -function HP_AutoDriveContinuityV2:_pruneExpired() - local now = nowMs() - for vehicle, reservation in pairs(self.reservations or {}) do - if reservation == nil or now > (tonumber(reservation.expiresAt) or 0) then - if reservation ~= nil then - log( - "Driver reservation expired: vehicle='%s' helper='%s'", - vehicleName(vehicle), - tostring(reservation.helper and reservation.helper.name or "?") - ) +function HP_AutoDriveContinuityV3:_findAwaitingVehicle() + if g_currentMission == nil then + return nil, 0 + end + + local match = nil + local matches = 0 + + for _, vehicle in pairs(g_currentMission.vehicles or {}) do + if vehicle ~= nil and vehicle.ad ~= nil then + local active, helperIndex = getAutoDriveState(vehicle) + local currentHelper = vehicle.ad.currentHelper + + -- AutoDrive sets itself active before calling getRandomHelper(). During + -- both initial allocation and an internal restart, currentHelper/index + -- are empty while the vehicle is awaiting its driver. + if active and (currentHelper == nil or (tonumber(helperIndex) or 0) <= 0) then + matches = matches + 1 + match = vehicle end - self.reservations[vehicle] = nil end end + + if matches == 1 then + return match, matches + end + + return nil, matches +end + +function HP_AutoDriveContinuityV3:_reserve(vehicle, helper, reason) + if vehicle == nil or helper == nil then + return false + end + + local previous = self.reservations[vehicle] + self.reservations[vehicle] = { + helper = helper, + helperIndex = tonumber(helper.index) or 0, + inactiveSince = nil + } + + if previous == nil or previous.helper ~= helper then + log( + "Driver session reserved: vehicle='%s' helper='%s' index=%d reason=%s", + vehicleName(vehicle), + helperName(helper), + tonumber(helper.index) or 0, + tostring(reason or "unknown") + ) + end + return true end -function HP_AutoDriveContinuityV2:isReserved(helper) - if helper == nil then return false end - self:_pruneExpired() +function HP_AutoDriveContinuityV3:_clear(vehicle, reason) + local reservation = vehicle ~= nil and self.reservations[vehicle] or nil + if reservation == nil then + return false + end + + log( + "Driver session cleared: vehicle='%s' helper='%s' reason=%s", + vehicleName(vehicle), + helperName(reservation.helper), + tostring(reason or "unknown") + ) + self.reservations[vehicle] = nil + return true +end + +function HP_AutoDriveContinuityV3:isReserved(helper) + if helper == nil then + return false + end + for _, reservation in pairs(self.reservations or {}) do if reservation ~= nil and reservation.helper == helper then return true @@ -108,90 +178,134 @@ function HP_AutoDriveContinuityV2:isReserved(helper) return false end -function HP_AutoDriveContinuityV2:_findOwningVehicle(helper) - if helper == nil or g_currentMission == nil then return nil end - - local match = nil - for _, vehicle in pairs(g_currentMission.vehicles or {}) do - if vehicle ~= nil and vehicle.ad ~= nil and vehicle.ad.currentHelper == helper then - if match ~= nil and match ~= vehicle then - log("Release capture skipped: helper='%s' is referenced by multiple AutoDrive vehicles", tostring(helper.name or "?")) - return nil - end - match = vehicle +function HP_AutoDriveContinuityV3:getReservedReacquire() + local vehicle, awaitingCount = self:_findAwaitingVehicle() + if vehicle == nil then + if awaitingCount > 1 then + log("Continuity skipped: %d AutoDrive vehicles are simultaneously awaiting helpers", awaitingCount) end + return nil, nil end - return match -end - -function HP_AutoDriveContinuityV2:captureRelease(helper) - if helper == nil then return false end - local vehicle = self:_findOwningVehicle(helper) - if vehicle == nil then return false end + local reservation = self.reservations[vehicle] + if reservation == nil or reservation.helper == nil then + return nil, vehicle + end - local active, helperIndex = getAutoDriveState(vehicle) - local expiresAt = nowMs() + (tonumber(self.leaseMs) or 3000) - self.reservations[vehicle] = { - helper = helper, - helperIndex = tonumber(helper.index) or tonumber(helperIndex) or 0, - expiresAt = expiresAt - } + local helper = reservation.helper + if not isEngineAvailable(helper) then + log( + "Reserved helper not engine-available yet: vehicle='%s' helper='%s'", + vehicleName(vehicle), + helperName(helper) + ) + return nil, vehicle + end + reservation.inactiveSince = nil log( - "Driver release captured: vehicle='%s' helper='%s' index=%d adActive=%s leaseMs=%d", + "Driver continuity reacquire: vehicle='%s' helper='%s' index=%d", vehicleName(vehicle), - tostring(helper.name or "?"), - tonumber(helper.index) or tonumber(helperIndex) or 0, - tostring(active), - tonumber(self.leaseMs) or 3000 + helperName(helper), + tonumber(helper.index) or tonumber(reservation.helperIndex) or 0 ) - return true + return helper, vehicle end -function HP_AutoDriveContinuityV2:getReacquireHelper() - self:_pruneExpired() +function HP_AutoDriveContinuityV3:captureInitialAllocation(helper, awaitingVehicle) + if helper == nil then + return false + end - local matchedVehicle = nil - local matchedHelper = nil - local matches = 0 + local vehicle = awaitingVehicle + local matches = 1 + if vehicle == nil then + vehicle, matches = self:_findAwaitingVehicle() + end + + if vehicle == nil then + if matches > 1 then + log("Initial reservation skipped: %d AutoDrive vehicles are simultaneously awaiting helpers", matches) + end + return false + end + + -- If a reservation already exists, do not replace it with the currently + -- highlighted helper. The existing reservation owns this AutoDrive session. + local existing = self.reservations[vehicle] + if existing ~= nil and existing.helper ~= nil then + return true + end + + return self:_reserve(vehicle, helper, "initial-allocation") +end + +function HP_AutoDriveContinuityV3:observeRelease(helper) + if helper == nil then + return false + end for vehicle, reservation in pairs(self.reservations or {}) do - if reservation ~= nil and reservation.helper ~= nil then + if reservation ~= nil and reservation.helper == helper then local active, helperIndex = getAutoDriveState(vehicle) - local currentHelper = vehicle.ad ~= nil and vehicle.ad.currentHelper or nil + log( + "Reserved driver released by engine: vehicle='%s' helper='%s' adActive=%s helperIndex=%d; session ownership retained", + vehicleName(vehicle), + helperName(helper), + tostring(active), + tonumber(helperIndex) or 0 + ) + return true + end + end - -- AutoDrive start/restart sets itself active before asking HelperManager - -- for another helper. Only reclaim a reservation for that exact vehicle. - if active - and (currentHelper == nil or (tonumber(helperIndex) or 0) <= 0) - and isEngineAvailable(reservation.helper) then + -- Fallback for unusual call ordering: if AutoDrive still references the helper + -- at release time, establish the session reservation here. + if g_currentMission ~= nil then + local match = nil + local matches = 0 + for _, vehicle in pairs(g_currentMission.vehicles or {}) do + if vehicle ~= nil and vehicle.ad ~= nil and vehicle.ad.currentHelper == helper then matches = matches + 1 - matchedVehicle = vehicle - matchedHelper = reservation.helper + match = vehicle end end + if matches == 1 then + return self:_reserve(match, helper, "release-fallback") + end end - if matches ~= 1 then - if matches > 1 then - log("Continuity skipped: %d AutoDrive vehicles are simultaneously awaiting reserved helpers", matches) + return false +end + +function HP_AutoDriveContinuityV3:_updateReservations() + local now = nowMs() + local grace = tonumber(self.stopGraceMs) or 500 + + for vehicle, reservation in pairs(self.reservations or {}) do + if reservation ~= nil then + local active = false + if vehicle ~= nil and vehicle.ad ~= nil then + active = select(1, getAutoDriveState(vehicle)) + end + + if active then + reservation.inactiveSince = nil + else + if reservation.inactiveSince == nil then + reservation.inactiveSince = now + elseif (now - (tonumber(reservation.inactiveSince) or now)) >= grace then + self:_clear(vehicle, "autodrive-inactive") + end + end end - return nil, nil end - - self.reservations[matchedVehicle] = nil - log( - "Driver continuity reacquire: vehicle='%s' helper='%s' index=%d", - vehicleName(matchedVehicle), - tostring(matchedHelper.name or "?"), - tonumber(matchedHelper.index) or 0 - ) - return matchedHelper, matchedVehicle end -function HP_AutoDriveContinuityV2:install() - if self.installed then return true end +function HP_AutoDriveContinuityV3:install() + if self.installed then + return true + end if HelperProfiles == nil then self:_logInstallWait("HelperProfiles global unavailable") @@ -202,9 +316,6 @@ function HP_AutoDriveContinuityV2:install() return false end - -- IMPORTANT: use normal global lookup here. GIANTS mod environments can expose - -- engine globals through the environment metatable, so rawget(_G, ...) may - -- incorrectly report a valid engine global as missing. local runtimeManager = g_helperManager if runtimeManager == nil then self:_logInstallWait("g_helperManager unavailable") @@ -223,63 +334,69 @@ function HP_AutoDriveContinuityV2:install() return false end - -- Hook the live manager instance rather than assuming both methods are exposed - -- directly on the HelperManager class. AutoDrive invokes g_helperManager with - -- colon syntax, so these instance wrappers intercept the exact runtime calls. self.runtimeManager = runtimeManager self.originalGetRandomHelper = runtimeGetRandomHelper runtimeManager.getRandomHelper = function(manager, ...) - local helper = HP_AutoDriveContinuityV2:getReacquireHelper() + local reservedHelper, awaitingVehicle = HP_AutoDriveContinuityV3:getReservedReacquire() + if reservedHelper ~= nil then + print(("[FS25_HelperProfiles] getRandomHelper -> '%s' (autodrive-session-continuity)"):format(helperName(reservedHelper))) + return reservedHelper + end + + local helper = HP_AutoDriveContinuityV3.originalGetRandomHelper(manager, ...) if helper ~= nil then - print(("[FS25_HelperProfiles] getRandomHelper -> '%s' (autodrive-continuity)"):format(tostring(helper.name))) - return helper + HP_AutoDriveContinuityV3:captureInitialAllocation(helper, awaitingVehicle) end - return HP_AutoDriveContinuityV2.originalGetRandomHelper(manager, ...) + return helper end self.originalReleaseHelper = runtimeReleaseHelper runtimeManager.releaseHelper = function(manager, helper, ...) - HP_AutoDriveContinuityV2:captureRelease(helper) - return HP_AutoDriveContinuityV2.originalReleaseHelper(manager, helper, ...) + HP_AutoDriveContinuityV3:observeRelease(helper) + return HP_AutoDriveContinuityV3.originalReleaseHelper(manager, helper, ...) end if HelperProfiles.isHelperActive ~= nil then self.originalIsHelperActive = HelperProfiles.isHelperActive HelperProfiles.isHelperActive = function(helperProfilesSelf, helper) - if HP_AutoDriveContinuityV2:isReserved(helper) then + if HP_AutoDriveContinuityV3:isReserved(helper) then return true end - return HP_AutoDriveContinuityV2.originalIsHelperActive(helperProfilesSelf, helper) + return HP_AutoDriveContinuityV3.originalIsHelperActive(helperProfilesSelf, helper) end end self.installed = true self._lastWaitReason = nil log( - "Installed event-driven runtime-manager continuity hooks (getRandomHelper=%s releaseHelper=%s)", + "Installed session-ownership continuity hooks (getRandomHelper=%s releaseHelper=%s stopGraceMs=%d)", tostring(type(runtimeGetRandomHelper)), - tostring(type(runtimeReleaseHelper)) + tostring(type(runtimeReleaseHelper)), + tonumber(self.stopGraceMs) or 500 ) return true end -function HP_AutoDriveContinuityV2:loadMap() +function HP_AutoDriveContinuityV3:loadMap() self.reservations = setmetatable({}, {__mode = "k"}) self._lastWaitReason = nil self._lastWaitLogMs = -100000 end -function HP_AutoDriveContinuityV2:update(dt) - if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then return end +function HP_AutoDriveContinuityV3:update(dt) + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then + return + end + if not self.installed then self:install() else - self:_pruneExpired() + self:_updateReservations() end end -function HP_AutoDriveContinuityV2:deleteMap() +function HP_AutoDriveContinuityV3:deleteMap() self.reservations = setmetatable({}, {__mode = "k"}) end -addModEventListener(HP_AutoDriveContinuityV2) +addModEventListener(HP_AutoDriveContinuityV3) From 9def747293a11fa59b4f47c6a7fdb102e6b7fddf Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Wed, 12 Aug 2026 14:35:09 +0100 Subject: [PATCH 08/11] Use AutoDrive event context for helper continuity --- scripts/HP_AutoDriveContinuity.lua | 329 ++++++++++++----------------- 1 file changed, 136 insertions(+), 193 deletions(-) diff --git a/scripts/HP_AutoDriveContinuity.lua b/scripts/HP_AutoDriveContinuity.lua index 049a6cc..e015045 100644 --- a/scripts/HP_AutoDriveContinuity.lua +++ b/scripts/HP_AutoDriveContinuity.lua @@ -1,11 +1,11 @@ -- HP_AutoDriveContinuity.lua (FS25_HelperProfiles) -- AutoDrive helper continuity bridge. -- --- Ownership is established when an active AutoDrive vehicle first acquires a helper. --- That vehicle keeps the same helper across GIANTS/AutoDrive release/reacquire cycles --- until AutoDrive has been genuinely inactive for a short sustained interval. +-- V4 uses AutoDrive's own start/stop event functions as the authoritative vehicle +-- context. This avoids scanning g_currentMission.vehicles and guessing which AD +-- vehicle is currently asking HelperManager for a worker. -print("[FS25_HelperProfiles/AutoDriveV3] Source loaded (session-ownership build)") +print("[FS25_HelperProfiles/AutoDriveV4] Source loaded (AutoDrive-event-context build)") -- Disable the first polling prototype in HP_Compatibility.lua. This module owns -- AutoDrive continuity for this test branch. @@ -13,19 +13,20 @@ if HP_AutoDriveContinuity ~= nil then HP_AutoDriveContinuity.update = function() end end -HP_AutoDriveContinuityV3 = HP_AutoDriveContinuityV3 or { +HP_AutoDriveContinuityV4 = HP_AutoDriveContinuityV4 or { installed = false, reservations = setmetatable({}, {__mode = "k"}), + pendingRestartVehicle = nil, originalGetRandomHelper = nil, - originalReleaseHelper = nil, originalIsHelperActive = nil, + originalSendStartEvent = nil, + originalSendStopEvent = nil, runtimeManager = nil, - stopGraceMs = 500, _lastWaitReason = nil, _lastWaitLogMs = -100000 } -local LOG = "[FS25_HelperProfiles/AutoDriveV3] " +local LOG = "[FS25_HelperProfiles/AutoDriveV4] " local function log(message, ...) print(LOG .. string.format(tostring(message), ...)) @@ -49,34 +50,22 @@ local function helperName(helper) return tostring(helper ~= nil and helper.name or "?") end -local function getAutoDriveState(vehicle) +local function isAutoDriveActive(vehicle) if vehicle == nil or vehicle.ad == nil or vehicle.ad.stateModule == nil then - return false, 0 + return false end - local stateModule = vehicle.ad.stateModule - local active = false - if stateModule.isActive ~= nil then - local ok, value = pcall(stateModule.isActive, stateModule) - active = ok and value == true - end - - local helperIndex = 0 - if stateModule.getCurrentHelperIndex ~= nil then - local ok, value = pcall(stateModule.getCurrentHelperIndex, stateModule) - if ok then - helperIndex = tonumber(value) or 0 - end + if type(stateModule.isActive) ~= "function" then + return false end - - return active, helperIndex + local ok, value = pcall(stateModule.isActive, stateModule) + return ok and value == true end local function isEngineAvailable(helper) if helper == nil or helper.inUse == true or g_helperManager == nil then return false end - for _, candidate in ipairs(g_helperManager.availableHelpers or {}) do if candidate == helper then return true @@ -85,7 +74,7 @@ local function isEngineAvailable(helper) return false end -function HP_AutoDriveContinuityV3:_logInstallWait(reason) +function HP_AutoDriveContinuityV4:_logInstallWait(reason) local now = nowMs() reason = tostring(reason or "unknown") if self._lastWaitReason ~= reason or (now - (tonumber(self._lastWaitLogMs) or 0)) >= 10000 then @@ -95,37 +84,7 @@ function HP_AutoDriveContinuityV3:_logInstallWait(reason) end end -function HP_AutoDriveContinuityV3:_findAwaitingVehicle() - if g_currentMission == nil then - return nil, 0 - end - - local match = nil - local matches = 0 - - for _, vehicle in pairs(g_currentMission.vehicles or {}) do - if vehicle ~= nil and vehicle.ad ~= nil then - local active, helperIndex = getAutoDriveState(vehicle) - local currentHelper = vehicle.ad.currentHelper - - -- AutoDrive sets itself active before calling getRandomHelper(). During - -- both initial allocation and an internal restart, currentHelper/index - -- are empty while the vehicle is awaiting its driver. - if active and (currentHelper == nil or (tonumber(helperIndex) or 0) <= 0) then - matches = matches + 1 - match = vehicle - end - end - end - - if matches == 1 then - return match, matches - end - - return nil, matches -end - -function HP_AutoDriveContinuityV3:_reserve(vehicle, helper, reason) +function HP_AutoDriveContinuityV4:_reserve(vehicle, helper, reason) if vehicle == nil or helper == nil then return false end @@ -133,8 +92,7 @@ function HP_AutoDriveContinuityV3:_reserve(vehicle, helper, reason) local previous = self.reservations[vehicle] self.reservations[vehicle] = { helper = helper, - helperIndex = tonumber(helper.index) or 0, - inactiveSince = nil + helperIndex = tonumber(helper.index) or 0 } if previous == nil or previous.helper ~= helper then @@ -149,9 +107,12 @@ function HP_AutoDriveContinuityV3:_reserve(vehicle, helper, reason) return true end -function HP_AutoDriveContinuityV3:_clear(vehicle, reason) +function HP_AutoDriveContinuityV4:_clear(vehicle, reason) local reservation = vehicle ~= nil and self.reservations[vehicle] or nil if reservation == nil then + if self.pendingRestartVehicle == vehicle then + self.pendingRestartVehicle = nil + end return false end @@ -162,14 +123,16 @@ function HP_AutoDriveContinuityV3:_clear(vehicle, reason) tostring(reason or "unknown") ) self.reservations[vehicle] = nil + if self.pendingRestartVehicle == vehicle then + self.pendingRestartVehicle = nil + end return true end -function HP_AutoDriveContinuityV3:isReserved(helper) +function HP_AutoDriveContinuityV4:isReserved(helper) if helper == nil then return false end - for _, reservation in pairs(self.reservations or {}) do if reservation ~= nil and reservation.helper == helper then return true @@ -178,33 +141,80 @@ function HP_AutoDriveContinuityV3:isReserved(helper) return false end -function HP_AutoDriveContinuityV3:getReservedReacquire() - local vehicle, awaitingCount = self:_findAwaitingVehicle() +function HP_AutoDriveContinuityV4:_onAutoDriveStartEvent(vehicle) + if vehicle == nil or vehicle.ad == nil then + return + end + + local helper = vehicle.ad.currentHelper + if helper ~= nil then + self:_reserve(vehicle, helper, "autodrive-start-event") + else + log("Start event without current helper: vehicle='%s'", vehicleName(vehicle)) + end + + if self.pendingRestartVehicle == vehicle then + self.pendingRestartVehicle = nil + end +end + +function HP_AutoDriveContinuityV4:_onAutoDriveStopEvent(vehicle) + if vehicle == nil or vehicle.ad == nil then + return + end + + -- The stop event is sent before AutoDrive:onStopAutoDrive releases and clears + -- currentHelper. Capture/confirm ownership while the exact vehicle context is + -- still available. + local helper = vehicle.ad.currentHelper + if self.reservations[vehicle] == nil and helper ~= nil then + self:_reserve(vehicle, helper, "autodrive-stop-fallback") + end + + local reservation = self.reservations[vehicle] + if reservation ~= nil and reservation.helper ~= nil then + self.pendingRestartVehicle = vehicle + log( + "Stop transition captured: vehicle='%s' helper='%s'; retaining ownership until next update frame", + vehicleName(vehicle), + helperName(reservation.helper) + ) + end +end + +function HP_AutoDriveContinuityV4:_getSynchronousRestartHelper() + local vehicle = self.pendingRestartVehicle if vehicle == nil then - if awaitingCount > 1 then - log("Continuity skipped: %d AutoDrive vehicles are simultaneously awaiting helpers", awaitingCount) - end return nil, nil end local reservation = self.reservations[vehicle] if reservation == nil or reservation.helper == nil then + self.pendingRestartVehicle = nil + return nil, nil + end + + -- RestartADTask stops AD and immediately starts the mode again in the same call. + -- startAutoDrive() sets AD active before it asks HelperManager for a helper, so + -- an active pending vehicle here is the exact synchronous-restart case. + if not isAutoDriveActive(vehicle) then return nil, vehicle end local helper = reservation.helper if not isEngineAvailable(helper) then log( - "Reserved helper not engine-available yet: vehicle='%s' helper='%s'", + "Synchronous restart found but reserved helper is not engine-available: vehicle='%s' helper='%s' inUse=%s", vehicleName(vehicle), - helperName(helper) + helperName(helper), + tostring(helper.inUse) ) return nil, vehicle end - reservation.inactiveSince = nil + self.pendingRestartVehicle = nil log( - "Driver continuity reacquire: vehicle='%s' helper='%s' index=%d", + "Driver continuity reacquire: vehicle='%s' helper='%s' index=%d reason=synchronous-restart", vehicleName(vehicle), helperName(helper), tonumber(helper.index) or tonumber(reservation.helperIndex) or 0 @@ -212,97 +222,26 @@ function HP_AutoDriveContinuityV3:getReservedReacquire() return helper, vehicle end -function HP_AutoDriveContinuityV3:captureInitialAllocation(helper, awaitingVehicle) - if helper == nil then - return false - end - - local vehicle = awaitingVehicle - local matches = 1 - if vehicle == nil then - vehicle, matches = self:_findAwaitingVehicle() - end - +function HP_AutoDriveContinuityV4:_expireUnrestartedStop() + local vehicle = self.pendingRestartVehicle if vehicle == nil then - if matches > 1 then - log("Initial reservation skipped: %d AutoDrive vehicles are simultaneously awaiting helpers", matches) - end - return false - end - - -- If a reservation already exists, do not replace it with the currently - -- highlighted helper. The existing reservation owns this AutoDrive session. - local existing = self.reservations[vehicle] - if existing ~= nil and existing.helper ~= nil then - return true - end - - return self:_reserve(vehicle, helper, "initial-allocation") -end - -function HP_AutoDriveContinuityV3:observeRelease(helper) - if helper == nil then - return false - end - - for vehicle, reservation in pairs(self.reservations or {}) do - if reservation ~= nil and reservation.helper == helper then - local active, helperIndex = getAutoDriveState(vehicle) - log( - "Reserved driver released by engine: vehicle='%s' helper='%s' adActive=%s helperIndex=%d; session ownership retained", - vehicleName(vehicle), - helperName(helper), - tostring(active), - tonumber(helperIndex) or 0 - ) - return true - end - end - - -- Fallback for unusual call ordering: if AutoDrive still references the helper - -- at release time, establish the session reservation here. - if g_currentMission ~= nil then - local match = nil - local matches = 0 - for _, vehicle in pairs(g_currentMission.vehicles or {}) do - if vehicle ~= nil and vehicle.ad ~= nil and vehicle.ad.currentHelper == helper then - matches = matches + 1 - match = vehicle - end - end - if matches == 1 then - return self:_reserve(match, helper, "release-fallback") - end + return end - return false -end - -function HP_AutoDriveContinuityV3:_updateReservations() - local now = nowMs() - local grace = tonumber(self.stopGraceMs) or 500 - - for vehicle, reservation in pairs(self.reservations or {}) do - if reservation ~= nil then - local active = false - if vehicle ~= nil and vehicle.ad ~= nil then - active = select(1, getAutoDriveState(vehicle)) - end - - if active then - reservation.inactiveSince = nil - else - if reservation.inactiveSince == nil then - reservation.inactiveSince = now - elseif (now - (tonumber(reservation.inactiveSince) or now)) >= grace then - self:_clear(vehicle, "autodrive-inactive") - end - end - end + -- If RestartADTask was going to restart this AD session, it would already have + -- done so synchronously before this update frame. Reaching update while still + -- pending therefore means this was a genuine stop. + if not isAutoDriveActive(vehicle) then + self:_clear(vehicle, "autodrive-stopped-no-synchronous-restart") + else + -- Defensive fallback: an active vehicle should have consumed the token from + -- getRandomHelper/sendStartEvent. Keep ownership but drop the stale token. + log("Pending restart token expired while vehicle is active: vehicle='%s'", vehicleName(vehicle)) + self.pendingRestartVehicle = nil end end -function HP_AutoDriveContinuityV3:install() +function HP_AutoDriveContinuityV4:install() if self.installed then return true end @@ -321,69 +260,72 @@ function HP_AutoDriveContinuityV3:install() self:_logInstallWait("g_helperManager unavailable") return false end + if type(runtimeManager.getRandomHelper) ~= "function" then + self:_logInstallWait("g_helperManager.getRandomHelper unavailable (type=" .. tostring(type(runtimeManager.getRandomHelper)) .. ")") + return false + end - local runtimeGetRandomHelper = runtimeManager.getRandomHelper - local runtimeReleaseHelper = runtimeManager.releaseHelper - - if type(runtimeGetRandomHelper) ~= "function" then - self:_logInstallWait("g_helperManager.getRandomHelper unavailable (type=" .. tostring(type(runtimeGetRandomHelper)) .. ")") + if AutoDriveStartStopEvent == nil then + self:_logInstallWait("AutoDriveStartStopEvent unavailable") return false end - if type(runtimeReleaseHelper) ~= "function" then - self:_logInstallWait("g_helperManager.releaseHelper unavailable (type=" .. tostring(type(runtimeReleaseHelper)) .. ")") + if type(AutoDriveStartStopEvent.sendStartEvent) ~= "function" then + self:_logInstallWait("AutoDriveStartStopEvent.sendStartEvent unavailable") + return false + end + if type(AutoDriveStartStopEvent.sendStopEvent) ~= "function" then + self:_logInstallWait("AutoDriveStartStopEvent.sendStopEvent unavailable") return false end self.runtimeManager = runtimeManager - self.originalGetRandomHelper = runtimeGetRandomHelper - runtimeManager.getRandomHelper = function(manager, ...) - local reservedHelper, awaitingVehicle = HP_AutoDriveContinuityV3:getReservedReacquire() - if reservedHelper ~= nil then - print(("[FS25_HelperProfiles] getRandomHelper -> '%s' (autodrive-session-continuity)"):format(helperName(reservedHelper))) - return reservedHelper - end - local helper = HP_AutoDriveContinuityV3.originalGetRandomHelper(manager, ...) + self.originalGetRandomHelper = runtimeManager.getRandomHelper + runtimeManager.getRandomHelper = function(manager, ...) + local helper = HP_AutoDriveContinuityV4:_getSynchronousRestartHelper() if helper ~= nil then - HP_AutoDriveContinuityV3:captureInitialAllocation(helper, awaitingVehicle) + print(("[FS25_HelperProfiles] getRandomHelper -> '%s' (autodrive-event-continuity)"):format(helperName(helper))) + return helper end - return helper - end - - self.originalReleaseHelper = runtimeReleaseHelper - runtimeManager.releaseHelper = function(manager, helper, ...) - HP_AutoDriveContinuityV3:observeRelease(helper) - return HP_AutoDriveContinuityV3.originalReleaseHelper(manager, helper, ...) + return HP_AutoDriveContinuityV4.originalGetRandomHelper(manager, ...) end if HelperProfiles.isHelperActive ~= nil then self.originalIsHelperActive = HelperProfiles.isHelperActive HelperProfiles.isHelperActive = function(helperProfilesSelf, helper) - if HP_AutoDriveContinuityV3:isReserved(helper) then + if HP_AutoDriveContinuityV4:isReserved(helper) then return true end - return HP_AutoDriveContinuityV3.originalIsHelperActive(helperProfilesSelf, helper) + return HP_AutoDriveContinuityV4.originalIsHelperActive(helperProfilesSelf, helper) end end + self.originalSendStartEvent = AutoDriveStartStopEvent.sendStartEvent + AutoDriveStartStopEvent.sendStartEvent = function(eventSelf, vehicle, ...) + HP_AutoDriveContinuityV4:_onAutoDriveStartEvent(vehicle) + return HP_AutoDriveContinuityV4.originalSendStartEvent(eventSelf, vehicle, ...) + end + + self.originalSendStopEvent = AutoDriveStartStopEvent.sendStopEvent + AutoDriveStartStopEvent.sendStopEvent = function(eventSelf, vehicle, ...) + HP_AutoDriveContinuityV4:_onAutoDriveStopEvent(vehicle) + return HP_AutoDriveContinuityV4.originalSendStopEvent(eventSelf, vehicle, ...) + end + self.installed = true self._lastWaitReason = nil - log( - "Installed session-ownership continuity hooks (getRandomHelper=%s releaseHelper=%s stopGraceMs=%d)", - tostring(type(runtimeGetRandomHelper)), - tostring(type(runtimeReleaseHelper)), - tonumber(self.stopGraceMs) or 500 - ) + log("Installed AutoDrive event-context continuity hooks") return true end -function HP_AutoDriveContinuityV3:loadMap() +function HP_AutoDriveContinuityV4:loadMap() self.reservations = setmetatable({}, {__mode = "k"}) + self.pendingRestartVehicle = nil self._lastWaitReason = nil self._lastWaitLogMs = -100000 end -function HP_AutoDriveContinuityV3:update(dt) +function HP_AutoDriveContinuityV4:update(dt) if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then return end @@ -391,12 +333,13 @@ function HP_AutoDriveContinuityV3:update(dt) if not self.installed then self:install() else - self:_updateReservations() + self:_expireUnrestartedStop() end end -function HP_AutoDriveContinuityV3:deleteMap() +function HP_AutoDriveContinuityV4:deleteMap() self.reservations = setmetatable({}, {__mode = "k"}) + self.pendingRestartVehicle = nil end -addModEventListener(HP_AutoDriveContinuityV3) +addModEventListener(HP_AutoDriveContinuityV4) From f188c2503fcad6b734afd30c97b3c7278b87b359 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Wed, 12 Aug 2026 14:58:26 +0100 Subject: [PATCH 09/11] Use worker appearance assignments for AutoDrive continuity --- scripts/HP_AutoDriveContinuity.lua | 338 ++++++++++++++++------------- 1 file changed, 185 insertions(+), 153 deletions(-) diff --git a/scripts/HP_AutoDriveContinuity.lua b/scripts/HP_AutoDriveContinuity.lua index e015045..824277b 100644 --- a/scripts/HP_AutoDriveContinuity.lua +++ b/scripts/HP_AutoDriveContinuity.lua @@ -1,32 +1,34 @@ -- HP_AutoDriveContinuity.lua (FS25_HelperProfiles) -- AutoDrive helper continuity bridge. -- --- V4 uses AutoDrive's own start/stop event functions as the authoritative vehicle --- context. This avoids scanning g_currentMission.vehicles and guessing which AD --- vehicle is currently asking HelperManager for a worker. +-- V5 uses HelperProfiles' proven worker-appearance assignment hook as the +-- authoritative vehicle<->helper relationship. HP_WorkerAppearance already sees +-- Enterable.setRandomVehicleCharacter(vehicle, helper) in the live game and stores +-- that exact pair in vehicleAssignments. We retain that assignment across +-- AutoDrive's internal release/reacquire cycle without depending on AutoDrive's +-- private event globals. -print("[FS25_HelperProfiles/AutoDriveV4] Source loaded (AutoDrive-event-context build)") +print("[FS25_HelperProfiles/AutoDriveV5] Source loaded (worker-assignment continuity build)") --- Disable the first polling prototype in HP_Compatibility.lua. This module owns --- AutoDrive continuity for this test branch. +-- Disable the original polling prototype in HP_Compatibility.lua. This module owns +-- AutoDrive continuity on this branch. if HP_AutoDriveContinuity ~= nil then HP_AutoDriveContinuity.update = function() end end -HP_AutoDriveContinuityV4 = HP_AutoDriveContinuityV4 or { +HP_AutoDriveContinuityV5 = HP_AutoDriveContinuityV5 or { installed = false, reservations = setmetatable({}, {__mode = "k"}), - pendingRestartVehicle = nil, + pendingByVehicle = setmetatable({}, {__mode = "k"}), originalGetRandomHelper = nil, + originalReleaseHelper = nil, originalIsHelperActive = nil, - originalSendStartEvent = nil, - originalSendStopEvent = nil, runtimeManager = nil, _lastWaitReason = nil, _lastWaitLogMs = -100000 } -local LOG = "[FS25_HelperProfiles/AutoDriveV4] " +local LOG = "[FS25_HelperProfiles/AutoDriveV5] " local function log(message, ...) print(LOG .. string.format(tostring(message), ...)) @@ -37,7 +39,13 @@ local function nowMs() end local function vehicleName(vehicle) - if vehicle ~= nil and vehicle.getName ~= nil then + if vehicle ~= nil and type(vehicle.getFullName) == "function" then + local ok, value = pcall(vehicle.getFullName, vehicle) + if ok and value ~= nil and tostring(value) ~= "" then + return tostring(value) + end + end + if vehicle ~= nil and type(vehicle.getName) == "function" then local ok, value = pcall(vehicle.getName, vehicle) if ok and value ~= nil and tostring(value) ~= "" then return tostring(value) @@ -50,31 +58,37 @@ local function helperName(helper) return tostring(helper ~= nil and helper.name or "?") end +local function isAutoDriveVehicle(vehicle) + return vehicle ~= nil and vehicle.ad ~= nil and vehicle.ad.stateModule ~= nil +end + local function isAutoDriveActive(vehicle) - if vehicle == nil or vehicle.ad == nil or vehicle.ad.stateModule == nil then + if not isAutoDriveVehicle(vehicle) then return false end + local stateModule = vehicle.ad.stateModule if type(stateModule.isActive) ~= "function" then return false end + local ok, value = pcall(stateModule.isActive, stateModule) return ok and value == true end -local function isEngineAvailable(helper) - if helper == nil or helper.inUse == true or g_helperManager == nil then +local function helperIsFree(helper) + if helper == nil then return false end - for _, candidate in ipairs(g_helperManager.availableHelpers or {}) do - if candidate == helper then - return true - end - end - return false + + -- releaseHelper() has already completed before AutoDrive synchronously calls + -- getRandomHelper() again. Do not require membership in availableHelpers here: + -- HelperProfiles/roster filtering may proxy that table, while helper.inUse is + -- the direct ownership state we need for this tiny transition window. + return helper.inUse ~= true end -function HP_AutoDriveContinuityV4:_logInstallWait(reason) +function HP_AutoDriveContinuityV5:_logInstallWait(reason) local now = nowMs() reason = tostring(reason or "unknown") if self._lastWaitReason ~= reason or (now - (tonumber(self._lastWaitLogMs) or 0)) >= 10000 then @@ -84,35 +98,37 @@ function HP_AutoDriveContinuityV4:_logInstallWait(reason) end end -function HP_AutoDriveContinuityV4:_reserve(vehicle, helper, reason) +function HP_AutoDriveContinuityV5:_reserve(vehicle, helper, reason) if vehicle == nil or helper == nil then return false end local previous = self.reservations[vehicle] + if previous ~= nil and previous.helper == helper then + previous.helperIndex = tonumber(helper.index) or previous.helperIndex or 0 + return true + end + self.reservations[vehicle] = { helper = helper, - helperIndex = tonumber(helper.index) or 0 + helperIndex = tonumber(helper.index) or 0, + observedAt = nowMs() } - if previous == nil or previous.helper ~= helper then - log( - "Driver session reserved: vehicle='%s' helper='%s' index=%d reason=%s", - vehicleName(vehicle), - helperName(helper), - tonumber(helper.index) or 0, - tostring(reason or "unknown") - ) - end + log( + "Driver session reserved: vehicle='%s' helper='%s' index=%d reason=%s", + vehicleName(vehicle), + helperName(helper), + tonumber(helper.index) or 0, + tostring(reason or "unknown") + ) return true end -function HP_AutoDriveContinuityV4:_clear(vehicle, reason) +function HP_AutoDriveContinuityV5:_clear(vehicle, reason) local reservation = vehicle ~= nil and self.reservations[vehicle] or nil if reservation == nil then - if self.pendingRestartVehicle == vehicle then - self.pendingRestartVehicle = nil - end + self.pendingByVehicle[vehicle] = nil return false end @@ -122,17 +138,17 @@ function HP_AutoDriveContinuityV4:_clear(vehicle, reason) helperName(reservation.helper), tostring(reason or "unknown") ) + self.reservations[vehicle] = nil - if self.pendingRestartVehicle == vehicle then - self.pendingRestartVehicle = nil - end + self.pendingByVehicle[vehicle] = nil return true end -function HP_AutoDriveContinuityV4:isReserved(helper) +function HP_AutoDriveContinuityV5:isReserved(helper) if helper == nil then return false end + for _, reservation in pairs(self.reservations or {}) do if reservation ~= nil and reservation.helper == helper then return true @@ -141,107 +157,131 @@ function HP_AutoDriveContinuityV4:isReserved(helper) return false end -function HP_AutoDriveContinuityV4:_onAutoDriveStartEvent(vehicle) - if vehicle == nil or vehicle.ad == nil then +function HP_AutoDriveContinuityV5:_syncWorkerAssignments() + if HP_WorkerAppearance == nil or type(HP_WorkerAppearance.vehicleAssignments) ~= "table" then return end - local helper = vehicle.ad.currentHelper - if helper ~= nil then - self:_reserve(vehicle, helper, "autodrive-start-event") - else - log("Start event without current helper: vehicle='%s'", vehicleName(vehicle)) - end - - if self.pendingRestartVehicle == vehicle then - self.pendingRestartVehicle = nil + for vehicle, assignment in pairs(HP_WorkerAppearance.vehicleAssignments) do + local helper = assignment ~= nil and assignment.helper or nil + if vehicle ~= nil and helper ~= nil and isAutoDriveVehicle(vehicle) and isAutoDriveActive(vehicle) then + local existing = self.reservations[vehicle] + local pending = self.pendingByVehicle[vehicle] + + -- During a continuity transition, never let a later appearance update + -- replace the reserved owner before getRandomHelper has had a chance to + -- return that owner. In the normal path this branch is never needed, + -- because getRandomHelper is intercepted first. + if existing ~= nil and pending ~= nil and existing.helper ~= helper then + log( + "Ignoring replacement assignment while continuity is pending: vehicle='%s' reserved='%s' observed='%s'", + vehicleName(vehicle), + helperName(existing.helper), + helperName(helper) + ) + else + self:_reserve(vehicle, helper, "worker-appearance-assignment") + end + end end end -function HP_AutoDriveContinuityV4:_onAutoDriveStopEvent(vehicle) - if vehicle == nil or vehicle.ad == nil then - return +function HP_AutoDriveContinuityV5:_findReservedVehicleForHelper(helper) + if helper == nil then + return nil end - -- The stop event is sent before AutoDrive:onStopAutoDrive releases and clears - -- currentHelper. Capture/confirm ownership while the exact vehicle context is - -- still available. - local helper = vehicle.ad.currentHelper - if self.reservations[vehicle] == nil and helper ~= nil then - self:_reserve(vehicle, helper, "autodrive-stop-fallback") + local match = nil + local matches = 0 + for vehicle, reservation in pairs(self.reservations or {}) do + if reservation ~= nil and reservation.helper == helper then + match = vehicle + matches = matches + 1 + end end - local reservation = self.reservations[vehicle] - if reservation ~= nil and reservation.helper ~= nil then - self.pendingRestartVehicle = vehicle - log( - "Stop transition captured: vehicle='%s' helper='%s'; retaining ownership until next update frame", - vehicleName(vehicle), - helperName(reservation.helper) - ) + if matches == 1 then + return match end + if matches > 1 then + log("Release mapping ambiguous: helper='%s' has %d reserved AutoDrive vehicles", helperName(helper), matches) + end + return nil end -function HP_AutoDriveContinuityV4:_getSynchronousRestartHelper() - local vehicle = self.pendingRestartVehicle +function HP_AutoDriveContinuityV5:_observeRelease(helper) + local vehicle = self:_findReservedVehicleForHelper(helper) if vehicle == nil then - return nil, nil - end - - local reservation = self.reservations[vehicle] - if reservation == nil or reservation.helper == nil then - self.pendingRestartVehicle = nil - return nil, nil - end - - -- RestartADTask stops AD and immediately starts the mode again in the same call. - -- startAutoDrive() sets AD active before it asks HelperManager for a helper, so - -- an active pending vehicle here is the exact synchronous-restart case. - if not isAutoDriveActive(vehicle) then - return nil, vehicle + return false end - local helper = reservation.helper - if not isEngineAvailable(helper) then - log( - "Synchronous restart found but reserved helper is not engine-available: vehicle='%s' helper='%s' inUse=%s", - vehicleName(vehicle), - helperName(helper), - tostring(helper.inUse) - ) - return nil, vehicle - end + self.pendingByVehicle[vehicle] = { + helper = helper, + releasedAt = nowMs() + } - self.pendingRestartVehicle = nil log( - "Driver continuity reacquire: vehicle='%s' helper='%s' index=%d reason=synchronous-restart", + "Driver release captured: vehicle='%s' helper='%s' adActive=%s; retaining reservation for synchronous restart", vehicleName(vehicle), helperName(helper), - tonumber(helper.index) or tonumber(reservation.helperIndex) or 0 + tostring(isAutoDriveActive(vehicle)) ) - return helper, vehicle + return true end -function HP_AutoDriveContinuityV4:_expireUnrestartedStop() - local vehicle = self.pendingRestartVehicle - if vehicle == nil then - return +function HP_AutoDriveContinuityV5:_getPendingReacquire() + local matchedVehicle = nil + local matchedHelper = nil + local matches = 0 + + for vehicle, pending in pairs(self.pendingByVehicle or {}) do + local reservation = self.reservations[vehicle] + local helper = pending ~= nil and pending.helper or nil + + if reservation ~= nil and helper ~= nil and reservation.helper == helper and isAutoDriveActive(vehicle) then + if helperIsFree(helper) then + matchedVehicle = vehicle + matchedHelper = helper + matches = matches + 1 + else + log( + "Pending restart found but helper still in use: vehicle='%s' helper='%s'", + vehicleName(vehicle), + helperName(helper) + ) + end + end + end + + if matches == 1 then + self.pendingByVehicle[matchedVehicle] = nil + log( + "Driver continuity reacquire: vehicle='%s' helper='%s' index=%d reason=release-restart", + vehicleName(matchedVehicle), + helperName(matchedHelper), + tonumber(matchedHelper.index) or 0 + ) + return matchedHelper, matchedVehicle end - -- If RestartADTask was going to restart this AD session, it would already have - -- done so synchronously before this update frame. Reaching update while still - -- pending therefore means this was a genuine stop. - if not isAutoDriveActive(vehicle) then - self:_clear(vehicle, "autodrive-stopped-no-synchronous-restart") - else - -- Defensive fallback: an active vehicle should have consumed the token from - -- getRandomHelper/sendStartEvent. Keep ownership but drop the stale token. - log("Pending restart token expired while vehicle is active: vehicle='%s'", vehicleName(vehicle)) - self.pendingRestartVehicle = nil + if matches > 1 then + log("Continuity skipped: %d released AutoDrive vehicles are simultaneously requesting helpers", matches) end + return nil, nil end -function HP_AutoDriveContinuityV4:install() +function HP_AutoDriveContinuityV5:_expireStoppedPending() + for vehicle, pending in pairs(self.pendingByVehicle or {}) do + if pending ~= nil and not isAutoDriveActive(vehicle) then + -- AutoDrive's internal RestartADTask restarts synchronously. If we have + -- reached a later update frame and the vehicle is still inactive, this + -- was a genuine stop rather than the temporary release/reacquire cycle. + self:_clear(vehicle, "autodrive-stopped-no-synchronous-restart") + end + end +end + +function HP_AutoDriveContinuityV5:install() if self.installed then return true end @@ -254,6 +294,10 @@ function HP_AutoDriveContinuityV4:install() self:_logInstallWait("HelperProfiles getRandomHelper hook not ready") return false end + if HP_WorkerAppearance == nil or type(HP_WorkerAppearance.vehicleAssignments) ~= "table" then + self:_logInstallWait("HP_WorkerAppearance.vehicleAssignments unavailable") + return false + end local runtimeManager = g_helperManager if runtimeManager == nil then @@ -264,17 +308,8 @@ function HP_AutoDriveContinuityV4:install() self:_logInstallWait("g_helperManager.getRandomHelper unavailable (type=" .. tostring(type(runtimeManager.getRandomHelper)) .. ")") return false end - - if AutoDriveStartStopEvent == nil then - self:_logInstallWait("AutoDriveStartStopEvent unavailable") - return false - end - if type(AutoDriveStartStopEvent.sendStartEvent) ~= "function" then - self:_logInstallWait("AutoDriveStartStopEvent.sendStartEvent unavailable") - return false - end - if type(AutoDriveStartStopEvent.sendStopEvent) ~= "function" then - self:_logInstallWait("AutoDriveStartStopEvent.sendStopEvent unavailable") + if type(runtimeManager.releaseHelper) ~= "function" then + self:_logInstallWait("g_helperManager.releaseHelper unavailable (type=" .. tostring(type(runtimeManager.releaseHelper)) .. ")") return false end @@ -282,64 +317,61 @@ function HP_AutoDriveContinuityV4:install() self.originalGetRandomHelper = runtimeManager.getRandomHelper runtimeManager.getRandomHelper = function(manager, ...) - local helper = HP_AutoDriveContinuityV4:_getSynchronousRestartHelper() + local helper = HP_AutoDriveContinuityV5:_getPendingReacquire() if helper ~= nil then - print(("[FS25_HelperProfiles] getRandomHelper -> '%s' (autodrive-event-continuity)"):format(helperName(helper))) + print(("[FS25_HelperProfiles] getRandomHelper -> '%s' (autodrive-worker-continuity)"):format(helperName(helper))) return helper end - return HP_AutoDriveContinuityV4.originalGetRandomHelper(manager, ...) + return HP_AutoDriveContinuityV5.originalGetRandomHelper(manager, ...) + end + + self.originalReleaseHelper = runtimeManager.releaseHelper + runtimeManager.releaseHelper = function(manager, helper, ...) + HP_AutoDriveContinuityV5:_observeRelease(helper) + return HP_AutoDriveContinuityV5.originalReleaseHelper(manager, helper, ...) end - if HelperProfiles.isHelperActive ~= nil then + if type(HelperProfiles.isHelperActive) == "function" then self.originalIsHelperActive = HelperProfiles.isHelperActive HelperProfiles.isHelperActive = function(helperProfilesSelf, helper) - if HP_AutoDriveContinuityV4:isReserved(helper) then + if HP_AutoDriveContinuityV5:isReserved(helper) then return true end - return HP_AutoDriveContinuityV4.originalIsHelperActive(helperProfilesSelf, helper) + return HP_AutoDriveContinuityV5.originalIsHelperActive(helperProfilesSelf, helper) end end - self.originalSendStartEvent = AutoDriveStartStopEvent.sendStartEvent - AutoDriveStartStopEvent.sendStartEvent = function(eventSelf, vehicle, ...) - HP_AutoDriveContinuityV4:_onAutoDriveStartEvent(vehicle) - return HP_AutoDriveContinuityV4.originalSendStartEvent(eventSelf, vehicle, ...) - end - - self.originalSendStopEvent = AutoDriveStartStopEvent.sendStopEvent - AutoDriveStartStopEvent.sendStopEvent = function(eventSelf, vehicle, ...) - HP_AutoDriveContinuityV4:_onAutoDriveStopEvent(vehicle) - return HP_AutoDriveContinuityV4.originalSendStopEvent(eventSelf, vehicle, ...) - end - self.installed = true self._lastWaitReason = nil - log("Installed AutoDrive event-context continuity hooks") + log("Installed worker-assignment continuity hooks (getRandomHelper + releaseHelper + activity bridge)") return true end -function HP_AutoDriveContinuityV4:loadMap() +function HP_AutoDriveContinuityV5:loadMap() self.reservations = setmetatable({}, {__mode = "k"}) - self.pendingRestartVehicle = nil + self.pendingByVehicle = setmetatable({}, {__mode = "k"}) self._lastWaitReason = nil self._lastWaitLogMs = -100000 end -function HP_AutoDriveContinuityV4:update(dt) +function HP_AutoDriveContinuityV5:update(dt) if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then return end if not self.installed then - self:install() - else - self:_expireUnrestartedStop() + if not self:install() then + return + end end + + self:_expireStoppedPending() + self:_syncWorkerAssignments() end -function HP_AutoDriveContinuityV4:deleteMap() +function HP_AutoDriveContinuityV5:deleteMap() self.reservations = setmetatable({}, {__mode = "k"}) - self.pendingRestartVehicle = nil + self.pendingByVehicle = setmetatable({}, {__mode = "k"}) end -addModEventListener(HP_AutoDriveContinuityV4) +addModEventListener(HP_AutoDriveContinuityV5) From d1591a5931882aa33f8b35d9f566c0714c759ec9 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Fri, 14 Aug 2026 11:48:46 +0100 Subject: [PATCH 10/11] Bridge AutoDrive continuity sessions to HelperPayroll --- scripts/HP_AutoDrivePayrollBridge.lua | 236 ++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 scripts/HP_AutoDrivePayrollBridge.lua diff --git a/scripts/HP_AutoDrivePayrollBridge.lua b/scripts/HP_AutoDrivePayrollBridge.lua new file mode 100644 index 0000000..796c992 --- /dev/null +++ b/scripts/HP_AutoDrivePayrollBridge.lua @@ -0,0 +1,236 @@ +-- HP_AutoDrivePayrollBridge.lua (FS25_HelperProfiles) +-- Optional HelperPayroll bridge for AutoDrive continuity sessions. +-- +-- HP_AutoDriveContinuityV5 owns the authoritative AutoDrive vehicle/helper +-- reservation. This bridge mirrors only that logical reservation lifecycle into +-- HelperPayroll's generic external-worker-session API. AutoDrive's internal +-- release/reacquire cycles never end the payroll session because the V5 +-- reservation deliberately survives those transitions. + +HP_AutoDrivePayrollBridge = HP_AutoDrivePayrollBridge or { + activeByVehicle = setmetatable({}, {__mode = "k"}), + sessionSequence = 0, + _lastWaitReason = nil, + _lastWaitLogMs = -100000 +} + +local LOG = "[FS25_HelperProfiles/AutoDrivePayroll] " + +local function log(message, ...) + print(LOG .. string.format(tostring(message), ...)) +end + +local function nowMs() + return tonumber(g_time) or 0 +end + +local function vehicleName(vehicle) + if vehicle ~= nil and type(vehicle.getFullName) == "function" then + local ok, value = pcall(vehicle.getFullName, vehicle) + if ok and value ~= nil and tostring(value) ~= "" then return tostring(value) end + end + if vehicle ~= nil and type(vehicle.getName) == "function" then + local ok, value = pcall(vehicle.getName, vehicle) + if ok and value ~= nil and tostring(value) ~= "" then return tostring(value) end + end + return tostring(vehicle or "unknown-vehicle") +end + +local function helperName(helper) + return tostring(helper ~= nil and helper.name or "?") +end + +local function helperSlot(helper) + local index = math.floor(tonumber(helper ~= nil and helper.index or 0) or 0) + if index < 1 or index > 20 then return nil end + return string.char(string.byte("A") + index - 1) +end + +local function ownerFarmId(vehicle) + if vehicle ~= nil and type(vehicle.getOwnerFarmId) == "function" then + local ok, value = pcall(vehicle.getOwnerFarmId, vehicle) + if ok and tonumber(value) ~= nil then return tonumber(value) end + end + if vehicle ~= nil and tonumber(vehicle.ownerFarmId) ~= nil then + return tonumber(vehicle.ownerFarmId) + end + return nil +end + +function HP_AutoDrivePayrollBridge:_logWait(reason) + local now = nowMs() + reason = tostring(reason or "unknown") + if self._lastWaitReason ~= reason or now - (tonumber(self._lastWaitLogMs) or 0) >= 10000 then + self._lastWaitReason = reason + self._lastWaitLogMs = now + log("Waiting: %s", reason) + end +end + +function HP_AutoDrivePayrollBridge:_getPayrollAPI() + local api = nil + + if g_currentMission ~= nil then + api = g_currentMission.fs25HelperPayrollAPI or g_currentMission.helperPayrollAPI + end + + if type(api) ~= "table" then + local ok, value = pcall(function() + return FS25_HelperPayroll_API or FS25_HelperPayrollAPI + end) + if ok then api = value end + end + + if type(api) ~= "table" then return nil, "HelperPayroll API unavailable" end + if type(api.beginExternalWorkerSession) ~= "function" then return nil, "HelperPayroll external-session API unavailable" end + if type(api.endExternalWorkerSession) ~= "function" then return nil, "HelperPayroll external-session finish API unavailable" end + if type(api.capabilities) == "table" and api.capabilities.externalWorkerSessions == false then + return nil, "HelperPayroll external sessions disabled" + end + + return api, nil +end + +function HP_AutoDrivePayrollBridge:_begin(vehicle, reservation) + if vehicle == nil or reservation == nil or reservation.helper == nil then return false end + + local api, reason = self:_getPayrollAPI() + if api == nil then + self:_logWait(reason) + return false + end + + local helper = reservation.helper + local slot = helperSlot(helper) + if slot == nil then + self:_logWait("reserved helper has no A-T slot") + return false + end + + self.sessionSequence = (tonumber(self.sessionSequence) or 0) + 1 + local sessionId = string.format("helperprofiles-autodrive-%d", self.sessionSequence) + local request = { + sessionId = sessionId, + source = "FS25_HelperProfiles", + controller = "AutoDrive", + jobType = "AutoDrive", + label = "AutoDrive worker", + helperSlot = slot, + helperIndex = tonumber(helper.index), + helperName = helperName(helper), + helperSlotSource = "HelperProfiles-AutoDrive-reservation", + vehicleName = vehicleName(vehicle), + farmId = ownerFarmId(vehicle) + } + + local ok, accepted, result = pcall(api.beginExternalWorkerSession, api, request) + if not ok then + self:_logWait("HelperPayroll beginExternalWorkerSession raised an error: " .. tostring(accepted)) + return false + end + if accepted ~= true then + local status = type(result) == "table" and result.status or "rejected" + self:_logWait("HelperPayroll rejected external session: " .. tostring(status)) + return false + end + + self.activeByVehicle[vehicle] = { + helper = helper, + helperIndex = tonumber(helper.index) or 0, + sessionId = sessionId, + api = api, + startedAt = nowMs() + } + self._lastWaitReason = nil + + log( + "Payroll session started: id=%s vehicle='%s' helper='%s' slot=%s", + tostring(sessionId), + vehicleName(vehicle), + helperName(helper), + tostring(slot) + ) + return true +end + +function HP_AutoDrivePayrollBridge:_finish(vehicle, active, reason) + if active == nil then return false end + + local api = active.api + if type(api) ~= "table" or type(api.endExternalWorkerSession) ~= "function" then + api = select(1, self:_getPayrollAPI()) + end + + local finished = false + local status = "api-unavailable" + if type(api) == "table" and type(api.endExternalWorkerSession) == "function" then + local ok, accepted, result = pcall(api.endExternalWorkerSession, api, active.sessionId, reason) + if ok then + finished = accepted == true + status = type(result) == "table" and tostring(result.status or (finished and "finished" or "rejected")) or tostring(accepted) + else + status = "error:" .. tostring(accepted) + end + end + + log( + "Payroll session ended: id=%s vehicle='%s' helper='%s' reason=%s accepted=%s status=%s", + tostring(active.sessionId), + vehicleName(vehicle), + helperName(active.helper), + tostring(reason or "reservation-ended"), + tostring(finished), + tostring(status) + ) + + self.activeByVehicle[vehicle] = nil + return finished +end + +function HP_AutoDrivePayrollBridge:_sync() + if HP_AutoDriveContinuityV5 == nil or type(HP_AutoDriveContinuityV5.reservations) ~= "table" then + self:_logWait("AutoDrive V5 reservation table unavailable") + return + end + + local reservations = HP_AutoDriveContinuityV5.reservations + + -- End sessions whose logical V5 reservation has genuinely disappeared or + -- changed owner. Internal AutoDrive release/reacquire transitions retain the + -- reservation, so they do not pass through this branch. + for vehicle, active in pairs(self.activeByVehicle or {}) do + local reservation = reservations[vehicle] + if reservation == nil then + self:_finish(vehicle, active, "autodrive-reservation-ended") + elseif reservation.helper ~= active.helper then + self:_finish(vehicle, active, "autodrive-reservation-owner-changed") + end + end + + -- Start payroll for any live V5 reservation that does not yet have a mirrored + -- external session. If HelperPayroll loads later, this naturally retries on a + -- subsequent update without disturbing AutoDrive continuity. + for vehicle, reservation in pairs(reservations) do + if reservation ~= nil and reservation.helper ~= nil and self.activeByVehicle[vehicle] == nil then + self:_begin(vehicle, reservation) + end + end +end + +function HP_AutoDrivePayrollBridge:loadMap() + self.activeByVehicle = setmetatable({}, {__mode = "k"}) + self.sessionSequence = 0 + self._lastWaitReason = nil + self._lastWaitLogMs = -100000 +end + +function HP_AutoDrivePayrollBridge:update(dt) + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then return end + self:_sync() +end + +function HP_AutoDrivePayrollBridge:deleteMap() + self.activeByVehicle = setmetatable({}, {__mode = "k"}) +end + +addModEventListener(HP_AutoDrivePayrollBridge) From 6896fb5116545ec906602cec72a3c025868abf45 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Fri, 14 Aug 2026 11:49:30 +0100 Subject: [PATCH 11/11] Load optional AutoDrive payroll bridge --- scripts/HP_AutoDriveContinuity.lua | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/HP_AutoDriveContinuity.lua b/scripts/HP_AutoDriveContinuity.lua index 824277b..961f0e8 100644 --- a/scripts/HP_AutoDriveContinuity.lua +++ b/scripts/HP_AutoDriveContinuity.lua @@ -374,4 +374,14 @@ function HP_AutoDriveContinuityV5:deleteMap() self.pendingByVehicle = setmetatable({}, {__mode = "k"}) end +-- Keep payroll accounting optional and outside the continuity algorithm. The +-- bridge is sourced here so older modDesc files on this feature branch do not +-- need a new load-order dependency; failure to load it must never disable V5. +if source ~= nil and g_currentModDirectory ~= nil then + local ok, err = pcall(source, g_currentModDirectory .. "scripts/HP_AutoDrivePayrollBridge.lua") + if not ok then + log("Optional HelperPayroll bridge failed to load: %s", tostring(err)) + end +end + addModEventListener(HP_AutoDriveContinuityV5)