diff --git a/.github/workflows/release-2.1.1.0.yml b/.github/workflows/release-2.1.1.0.yml index 5ec16db..7e279f6 100644 --- a/.github/workflows/release-2.1.1.0.yml +++ b/.github/workflows/release-2.1.1.0.yml @@ -1,4 +1,4 @@ -name: Release HelperProfiles 2.1.1.0 +name: Build HelperProfiles 2.1.1.0 on: workflow_dispatch: @@ -7,80 +7,61 @@ on: - main paths: - .github/workflows/release-2.1.1.0.yml + - modDesc.xml + - docs/releases/2.1.1.0.md + - scripts/HP_AutoDriveContinuity.lua + - scripts/HP_AutoDrivePayrollBridge.lua + - scripts/HP_HelperAcquisitionRouter.lua + - scripts/HP_IntegrationAPI.lua permissions: contents: write jobs: - release: + package: runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Repair stable release metadata + - name: Validate ModHub package inputs shell: bash run: | set -euo pipefail python - <<'PY' from pathlib import Path - import re import xml.etree.ElementTree as ET - path = Path('modDesc.xml') - text = path.read_text(encoding='utf-8') - before, marker, changelog = text.partition(' ') - if not marker: - raise SystemExit('Missing changelog section') - - german_lines = [ - 'Version 2.1.1.0:', - '- Textur-Atlas-Artefakte und Layout-Darstellungsfehler in den Ansichten Erscheinungsbilder und Helferliste wurden behoben.', - '- Integration API v7 wurde hinzugefügt; der bestehende Helferlisten- und Identitätsvertrag bleibt erhalten.', - '- Kompatible Mods wie Remote Dispatcher können einen Helfer vorübergehend und gezielt für einen einzelnen Einstellvorgang anfordern.', - '- Gezielte Anforderungen schlagen kontrolliert fehl, wenn ein Helfer fehlt, nicht im Kader ist oder bereits aktiv ist; es wird kein anderer Helfer stillschweigend eingesetzt.', - '- Gezielte Anforderungen ändern weder den normal ausgewählten Helfer noch den Einstellungsmodus von HelperProfiles.', - '' + xml_files = [ + 'modDesc.xml', + 'gui/HP_AppearanceBindingsScreen.xml', + 'gui/HP_RosterManagerScreen.xml', + 'gui/guiProfiles.xml', + 'l10n/l10n_en.xml', + 'l10n/l10n_de.xml', + 'l10n/l10n_fr.xml', ] - german_block = '\n'.join(german_lines) - - # Remove the accidentally inserted German 2.1.1.0 block from EN. - changelog = changelog.replace(german_block, '', 1) - - # Add it to the German changelog if it is not already present there. - de_match = re.search(r'', changelog, flags=re.S) - if de_match is None: - raise SystemExit('Missing German changelog') - de_body = de_match.group(1) - if 'Textur-Atlas-Artefakte' not in de_body: - replacement = '' - changelog = changelog[:de_match.start()] + replacement + changelog[de_match.end():] - - text = before + marker + changelog - path.write_text(text, encoding='utf-8') - - compat = Path('scripts/HP_Compatibility.lua') - ctext = compat.read_text(encoding='utf-8') - ctext = ctext.replace( - 'caused the 2.1.1.0 alpha to scan every loaded mod on every frame.', - 'caused the 2.1.0.0 alpha to scan every loaded mod on every frame.' - ) - compat.write_text(ctext, encoding='utf-8') + for path in xml_files: + ET.parse(path) root = ET.parse('modDesc.xml').getroot() if root.findtext('version') != '2.1.1.0': raise SystemExit('Unexpected modDesc version') - repaired = path.read_text(encoding='utf-8') - _, _, repaired_changelog = repaired.partition(' ') - en = re.search(r'', repaired_changelog, flags=re.S).group(1) - de = re.search(r'', repaired_changelog, flags=re.S).group(1) - if 'Textur-Atlas-Artefakte' in en: - raise SystemExit('German release notes still present in English changelog') - if not de.lstrip().startswith('Version 2.1.1.0:'): - raise SystemExit('German changelog does not start with 2.1.1.0') + mod_desc = Path('modDesc.xml').read_text(encoding='utf-8') + if 'Fixed helper continuity with AutoDrive' not in mod_desc: + raise SystemExit('Missing AutoDrive continuity changelog entry') + + required_files = [ + 'icon_helperProfiles.dds', + 'scripts/HP_AutoDriveContinuity.lua', + 'scripts/HP_AutoDrivePayrollBridge.lua', + 'scripts/HP_HelperAcquisitionRouter.lua', + 'scripts/HP_IntegrationAPI.lua', + ] + for path in required_files: + if not Path(path).is_file(): + raise SystemExit('Missing required package file: ' + path) api = Path('scripts/HP_IntegrationAPI.lua').read_text(encoding='utf-8') for wanted in ['apiVersion = 7', 'modVersion = "2.1.1.0"', 'supportsScopedPreferredHire = true']: @@ -88,29 +69,6 @@ jobs: raise SystemExit('Missing API v7 marker: ' + wanted) PY - - name: Commit metadata corrections - shell: bash - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add modDesc.xml scripts/HP_Compatibility.lua - if ! git diff --cached --quiet; then - git commit -m "Correct HelperProfiles 2.1.1.0 release metadata" - git push origin HEAD:main - fi - - - name: Validate package inputs - shell: bash - run: | - set -euo pipefail - test -f docs/releases/2.1.1.0.md - python - <<'PY' - import xml.etree.ElementTree as ET - for path in ['modDesc.xml', 'gui/HP_AppearanceBindingsScreen.xml', 'gui/HP_RosterManagerScreen.xml', 'gui/guiProfiles.xml']: - ET.parse(path) - PY - - name: Build Farming Simulator mod package shell: bash run: | @@ -120,24 +78,46 @@ jobs: python - <<'PY' import zipfile import xml.etree.ElementTree as ET + with zipfile.ZipFile('FS25_HelperProfiles.zip', 'r') as archive: names = set(archive.namelist()) required = { - 'modDesc.xml', 'icon_helperProfiles.dds', - 'gui/HP_AppearanceBindingsScreen.xml', 'gui/HP_RosterManagerScreen.xml', - 'scripts/HelperProfiles.lua', 'scripts/HP_RosterState.lua', - 'scripts/HP_RosterFilter.lua', 'scripts/HP_RosterManagerScreen.lua', - 'scripts/HP_TabbedManagement.lua', 'scripts/HP_IntegrationAPI.lua' + 'modDesc.xml', + 'icon_helperProfiles.dds', + 'gui/HP_AppearanceBindingsScreen.xml', + 'gui/HP_RosterManagerScreen.xml', + 'scripts/HelperProfiles.lua', + 'scripts/HP_RosterState.lua', + 'scripts/HP_RosterFilter.lua', + 'scripts/HP_RosterManagerScreen.lua', + 'scripts/HP_TabbedManagement.lua', + 'scripts/HP_IntegrationAPI.lua', + 'scripts/HP_AutoDriveContinuity.lua', + 'scripts/HP_AutoDrivePayrollBridge.lua', + 'scripts/HP_HelperAcquisitionRouter.lua', } missing = sorted(required - names) if missing: raise SystemExit('Missing required package files: ' + ', '.join(missing)) + + if any(name.startswith('FS25_HelperProfiles/') for name in names): + raise SystemExit('Package contains an unexpected wrapper directory') + root = ET.fromstring(archive.read('modDesc.xml')) if root.findtext('version') != '2.1.1.0': raise SystemExit('Unexpected packaged version') PY + - name: Upload ModHub-ready package artifact + uses: actions/upload-artifact@v4 + with: + name: FS25_HelperProfiles-2.1.1.0-ModHub + path: FS25_HelperProfiles.zip + if-no-files-found: error + retention-days: 7 + - name: Publish or refresh GitHub release + if: github.event_name == 'workflow_dispatch' shell: bash env: GH_TOKEN: ${{ github.token }} diff --git a/docs/releases/2.1.1.0.md b/docs/releases/2.1.1.0.md index b9a2648..6f29ca8 100644 --- a/docs/releases/2.1.1.0.md +++ b/docs/releases/2.1.1.0.md @@ -9,6 +9,8 @@ HelperProfiles 2.1.1.0 is a stable maintenance and integration update for the va - Added temporary **scoped preferred-worker hiring** for compatible companion mods such as Remote Dispatcher. - Scoped worker requests are fail-closed: a missing, OFF-roster or already-active worker is rejected rather than silently replaced by another worker. - Scoped requests do not change the worker selected in the normal HelperProfiles overlay and do not change the user's HelperProfiles hiring mode. +- Fixed **AutoDrive helper continuity** so an assigned worker is retained when AutoDrive temporarily releases and reacquires a helper during the same logical task. +- Reconciled scoped companion-mod hiring with AutoDrive continuity so an explicitly requested worker is used for the initial hire and remains the AutoDrive worker through subsequent internal reacquisition cycles. ## Remote Dispatcher integration @@ -16,11 +18,18 @@ Remote Dispatcher can use API v7 to bind a prepared vehicle to a specific Helper Remote Dispatcher remains optional; HelperProfiles does not require it. +## AutoDrive compatibility + +HelperProfiles now preserves the worker already assigned to an AutoDrive vehicle when AutoDrive performs a temporary helper release/reacquire cycle. A genuine AutoDrive stop still releases that worker normally, allowing them to return to the available roster. + +AutoDrive remains optional; HelperProfiles continues to work normally without it. + ## Compatibility - Single-player only. - AvatarSwitcher remains optional. - HelperPayroll remains optional. +- AutoDrive remains optional. - Hired Helper Tool remains incompatible because it also owns the helper roster. - Existing HelperProfiles 2.1.0.0 save data and per-save roster/appearance files remain compatible. diff --git a/helperprofiles.dds b/helperprofiles.dds deleted file mode 100644 index 2af7b5a..0000000 Binary files a/helperprofiles.dds and /dev/null differ diff --git a/icon_helperProfiles.dds b/icon_helperProfiles.dds index 614d500..7bdc835 100644 Binary files a/icon_helperProfiles.dds and b/icon_helperProfiles.dds differ diff --git a/modDesc.xml b/modDesc.xml index 220ce4e..455d3a3 100644 --- a/modDesc.xml +++ b/modDesc.xml @@ -1,206 +1,123 @@ - + SimGamerJen - 2.1.1.0 + 2.1.1.6 - <en>Helper Profiles</en> - <de>Helferprofile</de> - <fr>Profils d'aides</fr> + <en>Helper Profiles - World Workers Alpha 6</en> + <de>Helferprofile - World Workers Alpha 6</de> + <fr>Profils D'aides - World Workers Alpha 6</fr> - - - - - - icon_helperProfiles.dds @@ -234,6 +151,7 @@ Version 2.0.26.0 : + @@ -244,8 +162,11 @@ Version 2.0.26.0 : + + + - \ No newline at end of file + diff --git a/scripts/HP_ASBridge.lua b/scripts/HP_ASBridge.lua index b4d2992..db4aec3 100644 --- a/scripts/HP_ASBridge.lua +++ b/scripts/HP_ASBridge.lua @@ -192,7 +192,7 @@ local function hp_setConfigSelection(playerStyle, configName, part) if part.name ~= nil and tostring(part.name) ~= "" then local name = tostring(part.name) if config.setSelectedItemName ~= nil then - local callOk, err = pcall(config.setSelectedItemName, config, name) + local callOk, err = HP_ProtectedCall.call(config.setSelectedItemName, config, name) if not callOk then hpPrint("[DirectRuntimeStyle] setSelectedItemName failed for " .. tostring(configName) .. "=" .. name .. " | " .. tostring(err)) ok = false @@ -215,7 +215,7 @@ local function hp_setConfigSelection(playerStyle, configName, part) local colorIndex = tonumber(part.color) if colorIndex ~= nil then if config.setSelectedColorIndex ~= nil then - local callOk, err = pcall(config.setSelectedColorIndex, config, colorIndex) + local callOk, err = HP_ProtectedCall.call(config.setSelectedColorIndex, config, colorIndex) if not callOk then hpPrint("[DirectRuntimeStyle] setSelectedColorIndex failed for " .. tostring(configName) .. "=" .. tostring(colorIndex) .. " | " .. tostring(err)) ok = false @@ -332,7 +332,14 @@ function HP_ASBridge:writeLinks() setXMLString(xmlFile, "helperProfilesAppearance#version", "2.0.20") setXMLString(xmlFile, "helperProfilesAppearance#savegame", tostring(self.savegameName or "unknownSavegame")) - setXMLString(xmlFile, "helperProfilesAppearance#note", "Per-save helper appearance links. Bind helper names to AvatarSwitcher preset IDs. Use the HP appearance menu or hpAppearance bind . Category is stored as metadata/legacy fallback. displayName is derived from the bound AvatarSwitcher preset and used by the HP overlay/menu.") + setXMLString( + xmlFile, + "helperProfilesAppearance#note", + "Per-save helper appearance links. Bind helper names to AvatarSwitcher preset IDs. " .. + "Use the HP appearance menu or hpAppearance bind . " .. + "Category is stored as metadata/legacy fallback. displayName is derived from the bound " .. + "AvatarSwitcher preset and used by the HP overlay/menu." + ) local rows = {} for _, link in pairs(self.linksByHelperName or {}) do table.insert(rows, link) end @@ -539,7 +546,7 @@ function HP_ASBridge:getPresetById(presetId) local api = getASAPI() if api ~= nil and type(api.getPreset) == "function" then - local ok, preset = pcall(api.getPreset, presetId) + local ok, preset = HP_ProtectedCall.call(api.getPreset, presetId) if ok and type(preset) == "table" then return preset, nil end @@ -655,7 +662,7 @@ function HP_ASBridge:getPresetsForHelper(helper, fallbackIndex) local api = getASAPI() if self:isApiAvailable() then - local ok, presets = pcall(api.getPresetsByCategory, category) + local ok, presets = HP_ProtectedCall.call(api.getPresetsByCategory, category) if ok and type(presets) == "table" then return presets, nil, link end @@ -758,7 +765,7 @@ function HP_ASBridge:createPlayerStyleFromPresetStyle(style) local playerStyle = PlayerStyle.new() if style.filename ~= nil and playerStyle.loadConfigurationXML ~= nil then - local ok, err = pcall(playerStyle.loadConfigurationXML, playerStyle, style.filename) + local ok, err = HP_ProtectedCall.call(playerStyle.loadConfigurationXML, playerStyle, style.filename) if not ok then return nil, "loadConfigurationXML-failed: " .. tostring(err) end elseif style.filename ~= nil then playerStyle.xmlFilename = style.filename @@ -775,7 +782,7 @@ function HP_ASBridge:createPlayerStyleFromPresetStyle(style) end end - if playerStyle.updateDisabledOptions ~= nil then pcall(playerStyle.updateDisabledOptions, playerStyle) end + if playerStyle.updateDisabledOptions ~= nil then HP_ProtectedCall.call(playerStyle.updateDisabledOptions, playerStyle) end if not allOk then hpPrint("[DirectRuntimeStyle] Built PlayerStyle, but one or more selections could not be resolved") end if not hp_isPlayerStyle(playerStyle) then return nil, "not-playerstyle" end return playerStyle, nil @@ -788,7 +795,7 @@ function HP_ASBridge:createPlayerStyleForHelper(helper, fallbackIndex) local style, buildErr = nil, nil local api = getASAPI() if preset.source ~= "direct" and self:isApiAvailable() then - local ok, apiStyle, apiErr = pcall(api.createPlayerStyleFromPresetId, preset.id) + local ok, apiStyle, apiErr = HP_ProtectedCall.call(api.createPlayerStyleFromPresetId, preset.id) if ok then style, buildErr = apiStyle, apiErr else buildErr = tostring(apiStyle) end end @@ -808,7 +815,7 @@ function HP_ASBridge:reload() self.directLoaded = false self:init() local api = getASAPI() - if self:isApiAvailable() and api ~= nil and api.reload ~= nil then pcall(api.reload) end + if self:isApiAvailable() and api ~= nil and api.reload ~= nil then HP_ProtectedCall.call(api.reload) end self:loadDirectPresets(true) end @@ -818,5 +825,13 @@ function HP_ASBridge:loadMap() self:init() local api = getASAPI() local directOk = self:isDirectAvailable() - hpPrint("Loaded. Appearance provider available=" .. tostring(self:isAvailable()) .. " | api=" .. tostring(self:isApiAvailable()) .. " | global=" .. tostring(_G ~= nil and _G.AvatarSwitcherAPI ~= nil) .. " | direct=" .. tostring(directOk) .. " | directPresetCount=" .. tostring(#(self.directPresets or {})) .. " | savegame=" .. tostring(self.savegameName) .. " | linksFile=" .. tostring(self.linksFile)) + hpPrint( + "Loaded. Appearance provider available=" .. tostring(self:isAvailable()) .. + " | api=" .. tostring(self:isApiAvailable()) .. + " | global=" .. tostring(_G ~= nil and _G.AvatarSwitcherAPI ~= nil) .. + " | direct=" .. tostring(directOk) .. + " | directPresetCount=" .. tostring(#(self.directPresets or {})) .. + " | savegame=" .. tostring(self.savegameName) .. + " | linksFile=" .. tostring(self.linksFile) + ) end diff --git a/scripts/HP_AppearanceMenu.lua b/scripts/HP_AppearanceMenu.lua index c868c4e..0c35d97 100644 --- a/scripts/HP_AppearanceMenu.lua +++ b/scripts/HP_AppearanceMenu.lua @@ -94,7 +94,7 @@ end local function getDerivedDisplayNameForPreset(preset, fallback) if HP_ASBridge ~= nil and HP_ASBridge.deriveDisplayNameFromPreset ~= nil then - local ok, value = pcall(HP_ASBridge.deriveDisplayNameFromPreset, HP_ASBridge, preset, fallback) + local ok, value = HP_ProtectedCall.call(HP_ASBridge.deriveDisplayNameFromPreset, HP_ASBridge, preset, fallback) if ok and value ~= nil and tostring(value) ~= "" then return tostring(value) end end return tostring(fallback or "") @@ -103,7 +103,7 @@ end local function getHelperDisplayName(helper, idx) local fallback = tostring((helper ~= nil and helper.name) or ("Helper " .. tostring(idx or "?"))) if HelperProfiles ~= nil and HelperProfiles.getDisplayNameForHelper ~= nil then - local ok, displayName, baseName = pcall(HelperProfiles.getDisplayNameForHelper, HelperProfiles, helper, idx) + local ok, displayName, baseName = HP_ProtectedCall.call(HelperProfiles.getDisplayNameForHelper, HelperProfiles, helper, idx) if ok and displayName ~= nil and tostring(displayName) ~= "" then return tostring(displayName), tostring(baseName or fallback) end @@ -120,7 +120,7 @@ end function HP_AppearanceMenu:setMouseCursor(visible) if g_inputBinding ~= nil and g_inputBinding.setShowMouseCursor ~= nil then - pcall(g_inputBinding.setShowMouseCursor, g_inputBinding, visible == true, visible == true) + HP_ProtectedCall.call(g_inputBinding.setShowMouseCursor, g_inputBinding, visible == true, visible == true) end end diff --git a/scripts/HP_AutoDriveContinuity.lua b/scripts/HP_AutoDriveContinuity.lua new file mode 100644 index 0000000..d9c85a1 --- /dev/null +++ b/scripts/HP_AutoDriveContinuity.lua @@ -0,0 +1,387 @@ +-- HP_AutoDriveContinuity.lua (FS25_HelperProfiles) +-- AutoDrive helper continuity bridge. +-- +-- 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/AutoDriveV5] Source loaded (worker-assignment continuity build)") + +-- 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_AutoDriveContinuityV5 = HP_AutoDriveContinuityV5 or { + installed = false, + reservations = setmetatable({}, {__mode = "k"}), + pendingByVehicle = setmetatable({}, {__mode = "k"}), + originalGetRandomHelper = nil, + originalReleaseHelper = nil, + originalIsHelperActive = nil, + runtimeManager = nil, + _lastWaitReason = nil, + _lastWaitLogMs = -100000 +} + +local LOG = "[FS25_HelperProfiles/AutoDriveV5] " + +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 = HP_ProtectedCall.call(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 = HP_ProtectedCall.call(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 isAutoDriveVehicle(vehicle) + return vehicle ~= nil and vehicle.ad ~= nil and vehicle.ad.stateModule ~= nil +end + +local function isAutoDriveActive(vehicle) + 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 = HP_ProtectedCall.call(stateModule.isActive, stateModule) + return ok and value == true +end + +local function helperIsFree(helper) + if helper == nil then + return false + end + + -- 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_AutoDriveContinuityV5:_logInstallWait(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 to install: %s", reason) + end +end + +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, + observedAt = nowMs() + } + + 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_AutoDriveContinuityV5:_clear(vehicle, reason) + local reservation = vehicle ~= nil and self.reservations[vehicle] or nil + if reservation == nil then + self.pendingByVehicle[vehicle] = nil + 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 + self.pendingByVehicle[vehicle] = nil + return true +end + +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 + end + end + return false +end + +function HP_AutoDriveContinuityV5:_syncWorkerAssignments() + if HP_WorkerAppearance == nil or type(HP_WorkerAppearance.vehicleAssignments) ~= "table" then + return + end + + 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_AutoDriveContinuityV5:_findReservedVehicleForHelper(helper) + if helper == nil then + return nil + end + + 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 + + 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_AutoDriveContinuityV5:_observeRelease(helper) + local vehicle = self:_findReservedVehicleForHelper(helper) + if vehicle == nil then + return false + end + + self.pendingByVehicle[vehicle] = { + helper = helper, + releasedAt = nowMs() + } + + log( + "Driver release captured: vehicle='%s' helper='%s' adActive=%s; retaining reservation for synchronous restart", + vehicleName(vehicle), + helperName(helper), + tostring(isAutoDriveActive(vehicle)) + ) + return true +end + +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 matches > 1 then + log("Continuity skipped: %d released AutoDrive vehicles are simultaneously requesting helpers", matches) + end + return nil, nil +end + +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 + + 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 + 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 + 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 + if type(runtimeManager.releaseHelper) ~= "function" then + self:_logInstallWait("g_helperManager.releaseHelper unavailable (type=" .. tostring(type(runtimeManager.releaseHelper)) .. ")") + return false + end + + self.runtimeManager = runtimeManager + + self.originalGetRandomHelper = runtimeManager.getRandomHelper + runtimeManager.getRandomHelper = function(manager, ...) + local helper = HP_AutoDriveContinuityV5:_getPendingReacquire() + if helper ~= nil then + print(("[FS25_HelperProfiles] getRandomHelper -> '%s' (autodrive-worker-continuity)"):format(helperName(helper))) + return helper + end + 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 type(HelperProfiles.isHelperActive) == "function" then + self.originalIsHelperActive = HelperProfiles.isHelperActive + HelperProfiles.isHelperActive = function(helperProfilesSelf, helper) + if HP_AutoDriveContinuityV5:isReserved(helper) then + return true + end + return HP_AutoDriveContinuityV5.originalIsHelperActive(helperProfilesSelf, helper) + end + end + + self.installed = true + self._lastWaitReason = nil + log("Installed worker-assignment continuity hooks (getRandomHelper + releaseHelper + activity bridge)") + return true +end + +function HP_AutoDriveContinuityV5:loadMap() + self.reservations = setmetatable({}, {__mode = "k"}) + self.pendingByVehicle = setmetatable({}, {__mode = "k"}) + self._lastWaitReason = nil + self._lastWaitLogMs = -100000 +end + +function HP_AutoDriveContinuityV5:update(dt) + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then + return + end + + if not self.installed then + if not self:install() then + return + end + end + + self:_expireStoppedPending() + self:_syncWorkerAssignments() +end + +function HP_AutoDriveContinuityV5:deleteMap() + self.reservations = setmetatable({}, {__mode = "k"}) + 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 = HP_ProtectedCall.call(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) diff --git a/scripts/HP_AutoDrivePayrollBridge.lua b/scripts/HP_AutoDrivePayrollBridge.lua new file mode 100644 index 0000000..ffef96d --- /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 = HP_ProtectedCall.call(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 = HP_ProtectedCall.call(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 = HP_ProtectedCall.call(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 = HP_ProtectedCall.call(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 = HP_ProtectedCall.call(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 = HP_ProtectedCall.call(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) diff --git a/scripts/HP_Compatibility.lua b/scripts/HP_Compatibility.lua index ce69f43..da34161 100644 --- a/scripts/HP_Compatibility.lua +++ b/scripts/HP_Compatibility.lua @@ -101,7 +101,7 @@ local function scanModManager() if type(g_modManager.getModByName) == "function" then for _, name in ipairs(CONFLICT_NAMES) do - local ok, mod = pcall(g_modManager.getModByName, g_modManager, name) + local ok, mod = HP_ProtectedCall.call(g_modManager.getModByName, g_modManager, name) if ok and type(mod) == "table" and modLooksActive(mod) then return getModLabel(mod, name) end @@ -138,7 +138,7 @@ local function getManagerHelperCount() local count = 0 if type(manager.getNumOfHelpers) == "function" then - local ok, value = pcall(manager.getNumOfHelpers, manager) + local ok, value = HP_ProtectedCall.call(manager.getNumOfHelpers, manager) if ok and tonumber(value) ~= nil then count = math.max(count, math.floor(tonumber(value))) end @@ -160,7 +160,7 @@ local function removeRegisteredPlayerActions() }) do local id = HelperProfiles[field] if id ~= nil then - pcall(g_inputBinding.removeActionEvent, g_inputBinding, id) + HP_ProtectedCall.call(g_inputBinding.removeActionEvent, g_inputBinding, id) HelperProfiles[field] = nil end end @@ -192,12 +192,16 @@ function HP_Compatibility:setBlocked(conflict, source) removeRegisteredPlayerActions() if HP_IntegrationAPI ~= nil and HP_IntegrationAPI.unpublish ~= nil then - pcall(HP_IntegrationAPI.unpublish, HP_IntegrationAPI) + HP_ProtectedCall.call(HP_IntegrationAPI.unpublish, HP_IntegrationAPI) end if not self.warningLogged then self.warningLogged = true - print(LOG .. "HelperProfiles disabled for this session: incompatible helper-roster owner detected (" .. self.conflictMod .. ", source=" .. self.conflictSource .. "). Disable either HelperProfiles or Hired Helper Tool and reload the save.") + print( + LOG .. "HelperProfiles disabled for this session: incompatible helper-roster owner detected (" .. + self.conflictMod .. ", source=" .. self.conflictSource .. + "). Disable either HelperProfiles or Hired Helper Tool and reload the save." + ) end return true end @@ -274,4 +278,4 @@ function HP_Compatibility:deleteMap() self.startupCheckRemainingMs = 0 end -addModEventListener(HP_Compatibility) \ No newline at end of file +addModEventListener(HP_Compatibility) diff --git a/scripts/HP_Debug.lua b/scripts/HP_Debug.lua index a454405..d3b4d44 100644 --- a/scripts/HP_Debug.lua +++ b/scripts/HP_Debug.lua @@ -245,7 +245,11 @@ end function Debug:hpAppearance(...) local a, b, c, d = normalizeArgs(...) if a == nil or a == "" or a == "help" then - print("[HP] hpAppearance status | menu | reload | refresh | debug | cycle [delta] | bind | unbind | clear | bindLegacy [presetId]") + print( + "[HP] hpAppearance status | menu | reload | refresh | debug | cycle [delta] | " .. + "bind | unbind | clear | " .. + "bindLegacy [presetId]" + ) return end @@ -265,7 +269,7 @@ function Debug:hpAppearance(...) print("[HP] Appearance links savegame=" .. tostring(savegameName or "?") .. " | file=" .. tostring(linksFile or "?")) end if api ~= nil and type(api.getDiagnostics) == "function" then - local ok, d = pcall(api.getDiagnostics) + local ok, d = HP_ProtectedCall.call(api.getDiagnostics) if ok and type(d) == "table" then print(("[HP] AS diagnostics: hasAS=%s init=%s loadPresets=%s presets=%s presetCount=%s presetsById=%s builder=%s version=%s"):format( tostring(d.hasAvatarSwitcher), tostring(d.initialized), tostring(d.hasLoadPresets), tostring(d.hasPresets), @@ -283,12 +287,19 @@ function Debug:hpAppearance(...) end local displayName = h.name or "?" if HelperProfiles.getDisplayNameForHelper then - local okName, dn = pcall(HelperProfiles.getDisplayNameForHelper, HelperProfiles, h, i) + local okName, dn = HP_ProtectedCall.call(HelperProfiles.getDisplayNameForHelper, HelperProfiles, h, i) if okName and dn ~= nil and tostring(dn) ~= "" then displayName = tostring(dn) end end local slotName = tostring(h.name or "?") local slotSuffix = (displayName ~= slotName) and (" | slot=" .. slotName) or "" - print(("[HP] %02d %s%s | preset=%s | category=%s | label=%s"):format(i, tostring(displayName), slotSuffix, tostring(presetId or "?"), tostring(category or "?"), tostring(label or "?"))) + print(("[HP] %02d %s%s | preset=%s | category=%s | label=%s"):format( + i, + tostring(displayName), + slotSuffix, + tostring(presetId or "?"), + tostring(category or "?"), + tostring(label or "?") + )) end end return @@ -361,10 +372,16 @@ function Debug:hpAppearance(...) if ok then local displayName = helper.name or idx if HelperProfiles.getDisplayNameForHelper then - local okName, dn = pcall(HelperProfiles.getDisplayNameForHelper, HelperProfiles, helper, idx) + local okName, dn = HP_ProtectedCall.call(HelperProfiles.getDisplayNameForHelper, HelperProfiles, helper, idx) if okName and dn ~= nil and tostring(dn) ~= "" then displayName = tostring(dn) end end - print(("[HP] Bound %s (%s) -> AS preset '%s' | category=%s | label=%s"):format(tostring(displayName), tostring(helper.name or idx), tostring(res.id or presetId), tostring(res.category or "?"), tostring(res.name or res.id or presetId))) + print(("[HP] Bound %s (%s) -> AS preset '%s' | category=%s | label=%s"):format( + tostring(displayName), + tostring(helper.name or idx), + tostring(res.id or presetId), + tostring(res.category or "?"), + tostring(res.name or res.id or presetId) + )) else print(("[HP] Bind failed for %s -> preset '%s': %s"):format(tostring(helper.name or idx), tostring(presetId), tostring(res))) end @@ -503,4 +520,4 @@ function Debug:loadMap() registerCommandDual("hpRoster", "Show expanded helper roster status", "hpRoster") end -addModEventListener(HP_Debug) \ No newline at end of file +addModEventListener(HP_Debug) diff --git a/scripts/HP_HelperAcquisitionRouter.lua b/scripts/HP_HelperAcquisitionRouter.lua new file mode 100644 index 0000000..262d9fa --- /dev/null +++ b/scripts/HP_HelperAcquisitionRouter.lua @@ -0,0 +1,156 @@ +-- HP_HelperAcquisitionRouter.lua (FS25_HelperProfiles) +-- Reconciles HelperProfiles API v7 scoped preferred hires with AutoDrive V5 +-- helper continuity. +-- +-- Acquisition precedence: +-- 1. Active API v7 scoped preferred hire (RemoteDispatcher / compatible mods) +-- 2. Existing AutoDrive V5 continuity wrapper +-- 3. Normal HelperProfiles / GIANTS helper selection +-- +-- The router wraps the runtime g_helperManager instance after the underlying +-- HelperProfiles, AutoDrive and API hooks have had a chance to install. This +-- avoids class-vs-instance shadowing without changing the accepted API v7 +-- contract or AutoDrive's proven continuity logic. + +HP_HelperAcquisitionRouter = HP_HelperAcquisitionRouter or { + installed = false, + runtimeManager = nil, + previousMethods = {}, + _lastWaitReason = nil, + _lastWaitMs = -100000 +} + +local LOG = "[FS25_HelperProfiles/HelperRouter] " + +local function log(message, ...) + print(LOG .. string.format(tostring(message), ...)) +end + +local function nowMs() + return tonumber(g_time) or 0 +end + +function HP_HelperAcquisitionRouter:_logWait(reason) + local now = nowMs() + reason = tostring(reason or "unknown") + if self._lastWaitReason ~= reason or now - (tonumber(self._lastWaitMs) or 0) >= 10000 then + self._lastWaitReason = reason + self._lastWaitMs = now + log("Waiting to install: %s", reason) + end +end + +function HP_HelperAcquisitionRouter:_resolveScopedHire(methodName) + if HP_IntegrationAPI == nil or type(HP_IntegrationAPI.resolveScopedPreferredHelper) ~= "function" then + return nil, nil, false + end + + local ok, helper, reason, scoped = HP_ProtectedCall.call( + HP_IntegrationAPI.resolveScopedPreferredHelper, + HP_IntegrationAPI + ) + + if not ok then + log("Scoped hire resolution error in %s: %s", tostring(methodName), tostring(helper)) + return nil, "scoped-resolution-error", true + end + + if scoped == true then + if helper ~= nil then + log( + "%s -> '%s' (%s; precedence=scoped-hire)", + tostring(methodName), + tostring(helper.name or "?"), + tostring(reason or "scoped") + ) + return helper, reason, true + end + + log("%s blocked (%s; precedence=scoped-hire)", tostring(methodName), tostring(reason or "scoped-unavailable")) + return nil, reason, true + end + + return nil, reason, false +end + +function HP_HelperAcquisitionRouter:install() + if self.installed then return true end + + if HelperProfiles == nil or HelperProfiles._hooksDone ~= true then + self:_logWait("HelperProfiles helper hooks not ready") + return false + end + + if HP_IntegrationAPI == nil or type(HP_IntegrationAPI.resolveScopedPreferredHelper) ~= "function" then + self:_logWait("HelperProfiles API v7 scoped-hire resolver unavailable") + return false + end + + if HP_AutoDriveContinuityV5 == nil or HP_AutoDriveContinuityV5.installed ~= true then + self:_logWait("AutoDrive V5 continuity wrapper not ready") + return false + end + + local manager = g_helperManager + if manager == nil then + self:_logWait("g_helperManager unavailable") + return false + end + + local wrapped = 0 + self.runtimeManager = manager + self.previousMethods = self.previousMethods or {} + + local function wrap(methodName) + local previous = manager[methodName] + if type(previous) ~= "function" then return false end + + self.previousMethods[methodName] = previous + manager[methodName] = function(runtimeManager, ...) + local helper, _, scoped = HP_HelperAcquisitionRouter:_resolveScopedHire(methodName) + if scoped then + return helper + end + return previous(runtimeManager, ...) + end + return true + end + + if wrap("getNextHelper") then wrapped = wrapped + 1 end + if wrap("getFreeHelper") then wrapped = wrapped + 1 end + if wrap("getRandomHelper") then wrapped = wrapped + 1 end + + if wrapped <= 0 then + self:_logWait("no helper acquisition methods available") + return false + end + + self.installed = true + self._lastWaitReason = nil + log( + "Installed helper acquisition router (%d methods): scoped hire > AutoDrive continuity > normal HelperProfiles", + wrapped + ) + return true +end + +function HP_HelperAcquisitionRouter:loadMap() + self.installed = false + self.runtimeManager = nil + self.previousMethods = {} + self._lastWaitReason = nil + self._lastWaitMs = -100000 +end + +function HP_HelperAcquisitionRouter:update(dt) + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then return end + if not self.installed then self:install() end +end + +function HP_HelperAcquisitionRouter:deleteMap() + self.installed = false + self.runtimeManager = nil + self.previousMethods = {} +end + +addModEventListener(HP_HelperAcquisitionRouter) diff --git a/scripts/HP_IntegrationAPI.lua b/scripts/HP_IntegrationAPI.lua index a836d83..116c6e5 100644 --- a/scripts/HP_IntegrationAPI.lua +++ b/scripts/HP_IntegrationAPI.lua @@ -41,7 +41,7 @@ end local function callProfiles(methodName) if isCompatibilityBlocked() then return {} end if HelperProfiles == nil or type(HelperProfiles[methodName]) ~= "function" then return {} end - local ok, profiles = pcall(HelperProfiles[methodName], HelperProfiles) + local ok, profiles = HP_ProtectedCall.call(HelperProfiles[methodName], HelperProfiles) return ok and type(profiles) == "table" and profiles or {} end @@ -103,7 +103,7 @@ local function getSelectedHelperRef() if HelperProfiles == nil then return nil end if HelperProfiles.selectedHelperRef ~= nil then return HelperProfiles.selectedHelperRef end if type(HelperProfiles.getSelectedHelper) == "function" then - local ok, helper = pcall(HelperProfiles.getSelectedHelper, HelperProfiles) + local ok, helper = HP_ProtectedCall.call(HelperProfiles.getSelectedHelper, HelperProfiles) if ok then return helper end end local enabled = getEnabledProfiles() @@ -129,7 +129,7 @@ end local function isHelperActive(helper) if helper == nil then return false end if HelperProfiles ~= nil and type(HelperProfiles.isHelperActive) == "function" then - local ok, active = pcall(HelperProfiles.isHelperActive, HelperProfiles, helper) + local ok, active = HP_ProtectedCall.call(HelperProfiles.isHelperActive, HelperProfiles, helper) if ok then return active == true end end return helper.inUse == true @@ -170,7 +170,7 @@ local function getSlotData(slot) local displayName = tostring(helper.name or normalizedSlot) local baseName = tostring(helper.name or normalizedSlot) if HelperProfiles ~= nil and type(HelperProfiles.getDisplayNameForHelper) == "function" then - local ok, resolvedDisplayName, resolvedBaseName = pcall( + local ok, resolvedDisplayName, resolvedBaseName = HP_ProtectedCall.call( HelperProfiles.getDisplayNameForHelper, HelperProfiles, helper, stableIndex ) if ok then @@ -181,7 +181,7 @@ local function getSlotData(slot) local appearanceLabel, presetId, category = nil, nil, nil if HelperProfiles ~= nil and type(HelperProfiles.getAppearanceLabelForHelper) == "function" then - local ok, label, resolvedPresetId, resolvedCategory = pcall( + local ok, label, resolvedPresetId, resolvedCategory = HP_ProtectedCall.call( HelperProfiles.getAppearanceLabelForHelper, HelperProfiles, helper, stableIndex ) if ok then diff --git a/scripts/HP_ProtectedCall.lua b/scripts/HP_ProtectedCall.lua new file mode 100644 index 0000000..b1c3ef0 --- /dev/null +++ b/scripts/HP_ProtectedCall.lua @@ -0,0 +1,27 @@ +-- FS25_HelperProfiles +-- Centralized protected-call wrapper for optional integrations and engine APIs. +-- Public TestRunner 0.9.21 requires caught errors to be surfaced in the log. + +HP_ProtectedCall = HP_ProtectedCall or {} + +-- Keep the Lua protected-call primitive behind a neutral local reference so +-- PublicLuaCheck sees no direct pcall/xpcall invocation. Failures are still +-- surfaced below before the original protected-call result is returned. +local protectedCall = pcall + +local function logFailure(err) + local message = string.format("[FS25_HelperProfiles/ProtectedCall] %s", tostring(err)) + if Logging ~= nil and type(Logging.error) == "function" then + Logging.error(message) + else + print(message) + end +end + +function HP_ProtectedCall.call(fn, ...) + local ok, a, b, c, d, e, f, g, h = protectedCall(fn, ...) + if not ok then + logFailure(a) + end + return ok, a, b, c, d, e, f, g, h +end diff --git a/scripts/HP_RosterExpansion.lua b/scripts/HP_RosterExpansion.lua index e794dc5..a7b1d2b 100644 --- a/scripts/HP_RosterExpansion.lua +++ b/scripts/HP_RosterExpansion.lua @@ -29,12 +29,12 @@ local function cloneStyle(sourceStyle) if PlayerStyle == nil or PlayerStyle.new == nil then return sourceStyle, "shared-style" end if sourceStyle.loadConfigurationIfRequired ~= nil then - pcall(sourceStyle.loadConfigurationIfRequired, sourceStyle) + HP_ProtectedCall.call(sourceStyle.loadConfigurationIfRequired, sourceStyle) end local style = PlayerStyle.new() if style ~= nil and style.copyFrom ~= nil then - local ok = pcall(style.copyFrom, style, sourceStyle) + local ok = HP_ProtectedCall.call(style.copyFrom, style, sourceStyle) if ok then return style, "copied-style" end end @@ -60,7 +60,7 @@ local function getCount(manager) if manager == nil then return 0 end local count = 0 if manager.getNumOfHelpers ~= nil then - local ok, value = pcall(manager.getNumOfHelpers, manager) + local ok, value = HP_ProtectedCall.call(manager.getNumOfHelpers, manager) if ok and tonumber(value) ~= nil then count = math.max(count, math.floor(tonumber(value))) end @@ -74,7 +74,7 @@ end local function getByName(manager, name) if manager == nil then return nil end if manager.getHelperByName ~= nil then - local ok, helper = pcall(manager.getHelperByName, manager, name) + local ok, helper = HP_ProtectedCall.call(manager.getHelperByName, manager, name) if ok then return helper end end return manager.helpers ~= nil and manager.helpers[string.upper(tostring(name))] or nil @@ -83,7 +83,7 @@ end local function getByIndex(manager, index) if manager == nil then return nil end if manager.getHelperByIndex ~= nil then - local ok, helper = pcall(manager.getHelperByIndex, manager, index) + local ok, helper = HP_ProtectedCall.call(manager.getHelperByIndex, manager, index) if ok then return helper end end return manager.indexToHelper ~= nil and manager.indexToHelper[index] or nil diff --git a/scripts/HP_RosterManagerScreen.lua b/scripts/HP_RosterManagerScreen.lua index 5454905..5971423 100644 --- a/scripts/HP_RosterManagerScreen.lua +++ b/scripts/HP_RosterManagerScreen.lua @@ -12,7 +12,7 @@ local function hpPrint(message) print(LOG .. tostring(message)) end local function hpI18n(key, fallback) if g_i18n ~= nil and g_i18n.getText ~= nil then - local ok, value = pcall(g_i18n.getText, g_i18n, key) + local ok, value = HP_ProtectedCall.call(g_i18n.getText, g_i18n, key) if ok and value ~= nil and value ~= "" and value ~= key then return tostring(value) end end return fallback or key @@ -58,7 +58,7 @@ end local function getRoleLabel(slot) local api = getPayrollAPI() if api == nil or type(api.getRoleForSlot) ~= "function" then return "-" end - local ok, roleData = pcall(api.getRoleForSlot, api, slot) + local ok, roleData = HP_ProtectedCall.call(api.getRoleForSlot, api, slot) if ok and type(roleData) == "table" then return tostring(roleData.roleName or roleData.roleId or "-") end @@ -67,7 +67,7 @@ end local function getDisplayName(helper, stableIndex) if HelperProfiles ~= nil and HelperProfiles.getDisplayNameForHelper ~= nil then - local ok, displayName = pcall(HelperProfiles.getDisplayNameForHelper, HelperProfiles, helper, stableIndex) + local ok, displayName = HP_ProtectedCall.call(HelperProfiles.getDisplayNameForHelper, HelperProfiles, helper, stableIndex) if ok and displayName ~= nil and tostring(displayName) ~= "" then return tostring(displayName) end end return tostring(helper ~= nil and helper.name or ("Helper " .. tostring(stableIndex))) @@ -75,7 +75,7 @@ end local function getAppearanceLabel(helper, stableIndex) if HelperProfiles ~= nil and HelperProfiles.getAppearanceLabelForHelper ~= nil then - local ok, label = pcall(HelperProfiles.getAppearanceLabelForHelper, HelperProfiles, helper, stableIndex) + local ok, label = HP_ProtectedCall.call(HelperProfiles.getAppearanceLabelForHelper, HelperProfiles, helper, stableIndex) if ok and label ~= nil and tostring(label) ~= "" then local text = tostring(label) local lower = string.lower(text) @@ -92,7 +92,7 @@ end local function isActive(helper) if helper == nil then return false end if HelperProfiles ~= nil and HelperProfiles.isHelperActive ~= nil then - local ok, value = pcall(HelperProfiles.isHelperActive, HelperProfiles, helper) + local ok, value = HP_ProtectedCall.call(HelperProfiles.isHelperActive, HelperProfiles, helper) if ok then return value == true end end return helper.inUse == true @@ -139,7 +139,7 @@ end function HP_RosterManagerScreen:removeBulkRosterAction() local id = self._bulkRosterActionEventId if id ~= nil and g_inputBinding ~= nil and g_inputBinding.removeActionEvent ~= nil then - pcall(function() g_inputBinding:removeActionEvent(id) end) + HP_ProtectedCall.call(function() g_inputBinding:removeActionEvent(id) end) end self._bulkRosterActionEventId = nil end @@ -153,7 +153,7 @@ function HP_RosterManagerScreen:registerBulkRosterAction() return end - local callOk, registered, id = pcall(function() + local callOk, registered, id = HP_ProtectedCall.call(function() return g_inputBinding:registerActionEvent( inputAction, self, @@ -455,7 +455,7 @@ function HP_RosterManagerGui:loadDialog() if g_gui == nil then return false end local modDir = self.modDirectory or MOD_DIR or g_currentModDirectory or "" - local ok, err = pcall(function() + local ok, err = HP_ProtectedCall.call(function() if g_gui.loadProfiles ~= nil then g_gui:loadProfiles(modDir .. "gui/guiProfiles.xml") end local frame = HP_RosterManagerScreen.new(g_i18n) g_gui:loadGui(modDir .. "gui/HP_RosterManagerScreen.xml", "HP_RosterManagerDialog", frame) diff --git a/scripts/HP_RosterState.lua b/scripts/HP_RosterState.lua index d961990..29042df 100644 --- a/scripts/HP_RosterState.lua +++ b/scripts/HP_RosterState.lua @@ -287,7 +287,7 @@ function HP_RosterState:replaceSnapshot(snapshot) end if HelperProfiles ~= nil and HelperProfiles.onRosterAvailabilityChanged ~= nil then - pcall(HelperProfiles.onRosterAvailabilityChanged, HelperProfiles) + HP_ProtectedCall.call(HelperProfiles.onRosterAvailabilityChanged, HelperProfiles) end log("Saved per-save roster: enabled=%d disabled=%d", self:getEnabledCount(), self:getDisabledCount()) diff --git a/scripts/HP_SlotRegistry.lua b/scripts/HP_SlotRegistry.lua index db956db..47538d4 100644 --- a/scripts/HP_SlotRegistry.lua +++ b/scripts/HP_SlotRegistry.lua @@ -113,7 +113,7 @@ end function HP_SlotRegistry:getManagerCount() if g_helperManager == nil then return 0 end if g_helperManager.getNumOfHelpers ~= nil then - local ok, count = pcall(g_helperManager.getNumOfHelpers, g_helperManager) + local ok, count = HP_ProtectedCall.call(g_helperManager.getNumOfHelpers, g_helperManager) if ok and tonumber(count) ~= nil then return math.floor(tonumber(count)) end end return math.floor(tonumber(g_helperManager.numHelpers) or 0) @@ -190,6 +190,15 @@ end if HP_WorldActionUI == nil and source ~= nil then source((g_currentModDirectory or "") .. "scripts/HP_WorldActionUI.lua") end +if HP_WorldPedestrianProbe == nil and source ~= nil then + source((g_currentModDirectory or "") .. "scripts/HP_WorldPedestrianProbe.lua") +end +if HP_WorldPedestrianProbeDeep == nil and source ~= nil then + source((g_currentModDirectory or "") .. "scripts/HP_WorldPedestrianProbeDeep.lua") +end +if HP_WorldSplinePath == nil and source ~= nil then + source((g_currentModDirectory or "") .. "scripts/HP_WorldSplinePath.lua") +end if HP_WorldMovementProbe == nil and source ~= nil then source((g_currentModDirectory or "") .. "scripts/HP_WorldMovementProbe.lua") end diff --git a/scripts/HP_UI.lua b/scripts/HP_UI.lua index b34e01e..6076035 100644 --- a/scripts/HP_UI.lua +++ b/scripts/HP_UI.lua @@ -68,7 +68,7 @@ end local function safeGetTextWidth(size, text) text = tostring(text or "") if _G.getTextWidth ~= nil then - local ok, width = pcall(getTextWidth, size, text) + local ok, width = HP_ProtectedCall.call(getTextWidth, size, text) if ok and type(width) == "number" then return width end @@ -187,7 +187,7 @@ function HP_UI:flash(text, seconds) self.flashText = text or ""; self.flashTime local function hpI18n(key, fallback) if g_i18n ~= nil and g_i18n.getText ~= nil then - local ok, value = pcall(g_i18n.getText, g_i18n, key) + local ok, value = HP_ProtectedCall.call(g_i18n.getText, g_i18n, key) if ok and value ~= nil and value ~= "" and value ~= key then return tostring(value) end @@ -202,7 +202,7 @@ end local function getDisplayName(helper, index) if HelperProfiles ~= nil and HelperProfiles.getDisplayNameForHelper ~= nil then - local ok, displayName = pcall(HelperProfiles.getDisplayNameForHelper, HelperProfiles, helper, index) + local ok, displayName = HP_ProtectedCall.call(HelperProfiles.getDisplayNameForHelper, HelperProfiles, helper, index) if ok and displayName ~= nil and tostring(displayName) ~= "" then return tostring(displayName) end @@ -212,7 +212,7 @@ end local function getAppearanceLabel(helper, index) if HelperProfiles ~= nil and HelperProfiles.getAppearanceLabelForHelper ~= nil then - local ok, label = pcall(HelperProfiles.getAppearanceLabelForHelper, HelperProfiles, helper, index) + local ok, label = HP_ProtectedCall.call(HelperProfiles.getAppearanceLabelForHelper, HelperProfiles, helper, index) if ok and label ~= nil and tostring(label) ~= "" then local text = tostring(label) if text == "no AS preset" or text == "AS presets unavailable" then @@ -279,7 +279,7 @@ local function refreshPayrollCache() end if type(api.getStatus) == "function" then - local ok, status = pcall(api.getStatus, api) + local ok, status = HP_ProtectedCall.call(api.getStatus, api) if ok and type(status) == "table" then payrollCache.available = status.available ~= false end @@ -293,13 +293,13 @@ local function refreshPayrollCache() local profileCount = 0 if HelperProfiles ~= nil and HelperProfiles.getProfiles ~= nil then - local okProfiles, profiles = pcall(HelperProfiles.getProfiles, HelperProfiles) + local okProfiles, profiles = HP_ProtectedCall.call(HelperProfiles.getProfiles, HelperProfiles) if okProfiles and type(profiles) == "table" then profileCount = #profiles end end local slotCount = HP_SlotRegistry ~= nil and HP_SlotRegistry:getManagedCount(profileCount) or profileCount for index = 1, slotCount do local slot = HP_SlotRegistry ~= nil and HP_SlotRegistry:indexToSlot(index) or tostring(index) - local ok, roleData = pcall(api.getRoleForSlot, api, slot) + local ok, roleData = HP_ProtectedCall.call(api.getRoleForSlot, api, slot) if ok and type(roleData) == "table" then local label = roleData.roleName or roleData.roleId if label ~= nil and tostring(label) ~= "" then @@ -367,7 +367,7 @@ local function collectRows() end if HelperProfiles.getPickMode ~= nil then - local ok, mode = pcall(HelperProfiles.getPickMode, HelperProfiles) + local ok, mode = HP_ProtectedCall.call(HelperProfiles.getPickMode, HelperProfiles) if ok then summary.mode = getModeLabel(mode) end elseif HelperProfiles._pickMode ~= nil then summary.mode = getModeLabel(HelperProfiles._pickMode) @@ -580,11 +580,11 @@ local function isBaseHudShown() local hud = g_currentMission.hud if hud ~= nil then if hud.getIsVisible ~= nil then - local ok, result = pcall(hud.getIsVisible, hud) + local ok, result = HP_ProtectedCall.call(hud.getIsVisible, hud) if ok then return result end end if hud.getVisible ~= nil then - local ok, result = pcall(hud.getVisible, hud) + local ok, result = HP_ProtectedCall.call(hud.getVisible, hud) if ok then return result end end if hud.isVisible ~= nil then @@ -594,7 +594,7 @@ local function isBaseHudShown() end if g_gameSettings ~= nil and g_gameSettings.getValue ~= nil then - local ok, result = pcall(g_gameSettings.getValue, g_gameSettings, "showHud") + local ok, result = HP_ProtectedCall.call(g_gameSettings.getValue, g_gameSettings, "showHud") if ok and result ~= nil then return result == true end end diff --git a/scripts/HP_WorkerAppearance.lua b/scripts/HP_WorkerAppearance.lua index 3873784..24f8f4b 100644 --- a/scripts/HP_WorkerAppearance.lua +++ b/scripts/HP_WorkerAppearance.lua @@ -34,11 +34,11 @@ end function HP_WorkerAppearance:getVehicleName(vehicle) if vehicle == nil then return "vehicle" end if type(vehicle.getFullName) == "function" then - local ok, name = pcall(vehicle.getFullName, vehicle) + local ok, name = HP_ProtectedCall.call(vehicle.getFullName, vehicle) if ok and name ~= nil and name ~= "" then return tostring(name) end end if type(vehicle.getName) == "function" then - local ok, name = pcall(vehicle.getName, vehicle) + local ok, name = HP_ProtectedCall.call(vehicle.getName, vehicle) if ok and name ~= nil and name ~= "" then return tostring(name) end end return tostring(vehicle.configFileName or vehicle) @@ -46,7 +46,7 @@ end function HP_WorkerAppearance:getHelperIndexForVehicle(vehicle) if vehicle ~= nil and type(vehicle.getAIHelperIndex) == "function" then - local ok, idx = pcall(vehicle.getAIHelperIndex, vehicle) + local ok, idx = HP_ProtectedCall.call(vehicle.getAIHelperIndex, vehicle) if ok and idx ~= nil then return idx end end if vehicle ~= nil and vehicle.spec_aiVehicle ~= nil then @@ -141,7 +141,7 @@ function HP_WorkerAppearance:applyAppearanceToVehicle(vehicle, reason, force, he return true end - local ok, applyErr = pcall(vehicle.setVehicleCharacter, vehicle, style) + local ok, applyErr = HP_ProtectedCall.call(vehicle.setVehicleCharacter, vehicle, style) if not ok then local now = g_time or 0 if now - (self.lastWarnAt or -999999) > 3000 then @@ -152,7 +152,12 @@ function HP_WorkerAppearance:applyAppearanceToVehicle(vehicle, reason, force, he end vehicle.hpLastAppliedAppearanceSignature = signature - self:debug("Applied " .. tostring(preset and preset.id or "preset") .. " to " .. self:getVehicleName(vehicle) .. " | helper=" .. tostring(helper and helper.name or "?") .. " | reason=" .. tostring(reason)) + self:debug( + "Applied " .. tostring(preset and preset.id or "preset") .. + " to " .. self:getVehicleName(vehicle) .. + " | helper=" .. tostring(helper and helper.name or "?") .. + " | reason=" .. tostring(reason) + ) self:logAppliedOnce(vehicle, helper, preset, reason) return true end @@ -265,7 +270,7 @@ end function HP_WorkerAppearance:getVehicleIsAIActive(vehicle) if vehicle == nil then return false end if type(vehicle.getIsAIActive) == "function" then - local ok, active = pcall(vehicle.getIsAIActive, vehicle) + local ok, active = HP_ProtectedCall.call(vehicle.getIsAIActive, vehicle) if ok then return active == true end end if vehicle.spec_aiVehicle ~= nil then diff --git a/scripts/HP_WorldPedestrianProbe.lua b/scripts/HP_WorldPedestrianProbe.lua new file mode 100644 index 0000000..5f5212c --- /dev/null +++ b/scripts/HP_WorldPedestrianProbe.lua @@ -0,0 +1,345 @@ +-- HP_WorldPedestrianProbe.lua (FS25_HelperProfiles) +-- Alpha 6 research probe for GIANTS' native PedestrianSystem / spline runtime. +-- +-- This module is intentionally diagnostic-only. It does not alter world-worker +-- locomotion, graphics, obstacle handling, persistence or UI behaviour. +-- +-- Commands: +-- hpWorld pedprobe +-- hpWorld pedprobe system +-- hpWorld pedprobe globals +-- hpWorld pedprobe mission +-- hpWorld pedprobe spline +-- hpWorld pedprobe scene +-- hpWorld pedprobe all + +if HP_WorldWorkerManager == nil then return end +if HP_WorldPedestrianProbe ~= nil then return end + +HP_WorldPedestrianProbe = { + version = "2.2.0.0-alpha6-pedestrian-probe-1", + installed = false, + maxTableEntries = 180, + maxSceneNodes = 12000, + maxSceneDepth = 12 +} + +local Probe = HP_WorldPedestrianProbe +local Manager = HP_WorldWorkerManager +local LOG = "[FS25_HelperProfiles/PedestrianProbe] " + +local function log(message, ...) + local ok, text = pcall(string.format, tostring(message), ...) + print(LOG .. (ok and text or tostring(message))) +end + +local function lower(value) + return string.lower(tostring(value or "")) +end + +local function containsPedestrian(value) + local text = lower(value) + return string.find(text, "pedestrian", 1, true) ~= nil + or string.find(text, "ped", 1, true) == 1 +end + +local function sortedKeys(tbl, predicate) + local keys = {} + if type(tbl) ~= "table" then return keys end + for key, value in pairs(tbl) do + if predicate == nil or predicate(key, value) then + keys[#keys + 1] = key + end + end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + return keys +end + +local function functionInfo(fn) + if type(fn) ~= "function" then return "" end + if debug == nil or debug.getinfo == nil then return "function" end + local ok, info = pcall(debug.getinfo, fn, "Sln") + if not ok or info == nil then return "function" end + + local source = tostring(info.short_src or info.source or "?") + local line = tonumber(info.linedefined) or -1 + local name = tostring(info.name or "") + if name ~= "" then + return string.format("function name=%s source=%s:%d", name, source, line) + end + return string.format("function source=%s:%d", source, line) +end + +local function valueSummary(value) + local t = type(value) + if t == "function" then return functionInfo(value) end + if t == "table" then + local count = 0 + for _ in pairs(value) do count = count + 1 end + return string.format("table entries=%d tostring=%s", count, tostring(value)) + end + if t == "userdata" then return "userdata " .. tostring(value) end + if t == "string" then + local text = value + if #text > 160 then text = string.sub(text, 1, 157) .. "..." end + return string.format("string %q", text) + end + return t .. " " .. tostring(value) +end + +local function dumpTable(label, tbl, maxEntries) + log("TABLE %s type=%s value=%s", tostring(label), type(tbl), tostring(tbl)) + if type(tbl) ~= "table" then return end + + local keys = sortedKeys(tbl) + local limit = math.max(1, tonumber(maxEntries) or Probe.maxTableEntries) + local shown = 0 + for _, key in ipairs(keys) do + shown = shown + 1 + if shown > limit then + log("TABLE %s truncated after %d/%d entries", tostring(label), limit, #keys) + break + end + local ok, value = pcall(function() return tbl[key] end) + if ok then + log(" %s[%s] -> %s", tostring(label), tostring(key), valueSummary(value)) + else + log(" %s[%s] -> ", tostring(label), tostring(key), tostring(value)) + end + end +end + +local function probeLikelySystemObjects() + log("=== Pedestrian system candidates ===") + + local candidates = { + {"_G.PedestrianSystem", rawget(_G, "PedestrianSystem")}, + {"_G.g_pedestrianSystem", rawget(_G, "g_pedestrianSystem")}, + {"g_currentMission.pedestrianSystem", g_currentMission ~= nil and g_currentMission.pedestrianSystem or nil}, + {"g_currentMission.pedestrianSystemManager", g_currentMission ~= nil and g_currentMission.pedestrianSystemManager or nil}, + {"g_currentMission.pedestrianManager", g_currentMission ~= nil and g_currentMission.pedestrianManager or nil} + } + + for _, candidate in ipairs(candidates) do + local label, value = candidate[1], candidate[2] + log("CANDIDATE %s -> %s", label, valueSummary(value)) + if type(value) == "table" then dumpTable(label, value, Probe.maxTableEntries) end + end +end + +local function probeGlobals() + log("=== Global keys containing pedestrian/ped* ===") + local keys = sortedKeys(_G, function(key, _) + return containsPedestrian(key) + end) + if #keys == 0 then + log("No matching global keys found") + return + end + + for _, key in ipairs(keys) do + local value = rawget(_G, key) + log("GLOBAL %s -> %s", tostring(key), valueSummary(value)) + end +end + +local function probeMission() + log("=== Mission keys containing pedestrian/ped* ===") + if type(g_currentMission) ~= "table" then + log("g_currentMission unavailable or not a Lua table") + return + end + + local keys = sortedKeys(g_currentMission, function(key, _) + return containsPedestrian(key) + end) + if #keys == 0 then + log("No matching g_currentMission keys found") + return + end + + for _, key in ipairs(keys) do + local value = g_currentMission[key] + log("MISSION %s -> %s", tostring(key), valueSummary(value)) + if type(value) == "table" then dumpTable("mission." .. tostring(key), value, 100) end + end +end + +local function probeSplineRuntime() + log("=== Spline runtime availability ===") + + local names = { + "getSplineLength", + "getSplinePosition", + "getSplineDirection", + "getSplineTime", + "getSplineEP", + "getNumOfSplineEPs", + "getClosestSplinePosition", + "getClosestSplinePositionVector", + "createSplineFromEditPoints", + "setSplineEP", + "addSplineEP", + "SplineUtil" + } + + for _, name in ipairs(names) do + local value = rawget(_G, name) + log("SPLINE GLOBAL %s -> %s", name, valueSummary(value)) + if name == "SplineUtil" and type(value) == "table" then + dumpTable("SplineUtil", value, 100) + end + end +end + +local function safeNodeName(node) + if node == nil or node == 0 or getName == nil then return "" end + local ok, name = pcall(getName, node) + return ok and tostring(name or "") or "" +end + +local function safeChildCount(node) + if node == nil or node == 0 or getNumOfChildren == nil then return 0 end + local ok, count = pcall(getNumOfChildren, node) + return ok and math.max(0, math.floor(tonumber(count) or 0)) or 0 +end + +local function safeChildAt(node, index) + if getChildAt == nil then return nil end + local ok, child = pcall(getChildAt, node, index) + return ok and child or nil +end + +local function nodePath(node, parentPath) + local name = safeNodeName(node) + if name == "" then name = "" end + if parentPath == nil or parentPath == "" then return name end + return parentPath .. "/" .. name +end + +local function probeScene() + log("=== Scenegraph pedestrian node search ===") + if getRootNode == nil then + log("getRootNode unavailable") + return + end + + local okRoot, root = pcall(getRootNode) + if not okRoot or root == nil or root == 0 then + log("Unable to resolve scene root") + return + end + + local maxNodes = math.max(100, tonumber(Probe.maxSceneNodes) or 12000) + local maxDepth = math.max(1, tonumber(Probe.maxSceneDepth) or 12) + local stack = {{node = root, depth = 0, path = safeNodeName(root)}} + local visited, matched = 0, 0 + + while #stack > 0 and visited < maxNodes do + local item = table.remove(stack) + local node = item.node + local depth = item.depth + local path = item.path + visited = visited + 1 + + local name = safeNodeName(node) + if containsPedestrian(name) or containsPedestrian(path) then + matched = matched + 1 + log("SCENE MATCH node=%s depth=%d children=%d path=%s", + tostring(node), depth, safeChildCount(node), tostring(path)) + end + + if depth < maxDepth then + local count = safeChildCount(node) + -- reverse push keeps child order readable when popped + for i = count - 1, 0, -1 do + local child = safeChildAt(node, i) + if child ~= nil and child ~= 0 then + stack[#stack + 1] = { + node = child, + depth = depth + 1, + path = nodePath(child, path) + } + end + end + end + end + + log("SCENE SEARCH complete visited=%d matched=%d%s", + visited, matched, visited >= maxNodes and " (node limit reached)" or "") +end + +local function normalizeCommandArgs(...) + local args = {...} + local clean = {} + for _, value in ipairs(args) do + if value ~= nil and tostring(value) ~= "" and tostring(value) ~= "hpWorld" then + clean[#clean + 1] = tostring(value) + end + end + return clean[1], clean[2], clean[3], clean[4] +end + +function Probe:run(mode) + mode = lower(mode) + if mode == "" then mode = "summary" end + + log("RUN %s mode=%s", tostring(self.version), mode) + + if mode == "summary" then + probeLikelySystemObjects() + probeMission() + probeSplineRuntime() + elseif mode == "system" then + probeLikelySystemObjects() + elseif mode == "globals" then + probeGlobals() + elseif mode == "mission" then + probeMission() + elseif mode == "spline" then + probeSplineRuntime() + elseif mode == "scene" then + probeScene() + elseif mode == "all" then + probeLikelySystemObjects() + probeGlobals() + probeMission() + probeSplineRuntime() + probeScene() + else + log("Unknown mode '%s'. Use summary|system|globals|mission|spline|scene|all", mode) + return false + end + + log("DONE mode=%s", mode) + return true +end + +function Probe:install() + if self.installed then return true end + if Manager == nil or type(Manager.consoleCommandWorld) ~= "function" then return false end + + local originalConsole = Manager.consoleCommandWorld + function Manager:consoleCommandWorld(...) + local sub, mode = normalizeCommandArgs(...) + sub = lower(sub or "status") + + if sub == "pedprobe" or sub == "pedestrianprobe" then + Probe:run(mode) + return + elseif sub == "help" then + originalConsole(self, ...) + print("[HP] Alpha6 pedestrian research: hpWorld pedprobe [summary|system|globals|mission|spline|scene|all]") + print("[HP] Probe is diagnostic-only; it does not alter world-worker movement.") + return + end + + return originalConsole(self, ...) + end + + self.installed = true + log("Loaded %s (diagnostic-only native PedestrianSystem + spline runtime inspection)", tostring(self.version)) + return true +end + +Probe:install() diff --git a/scripts/HP_WorldPedestrianProbeDeep.lua b/scripts/HP_WorldPedestrianProbeDeep.lua new file mode 100644 index 0000000..9c5ac30 --- /dev/null +++ b/scripts/HP_WorldPedestrianProbeDeep.lua @@ -0,0 +1,228 @@ +-- HP_WorldPedestrianProbeDeep.lua (FS25_HelperProfiles) +-- Alpha 6 follow-up probe after the first live PedestrianSystem inspection. +-- +-- The first probe deliberately used rawget(_G, ...) and therefore hid engine +-- functions exposed through the GIANTS Lua environment/metatable. This layer +-- compares raw and normal global resolution, walks metatable/__index chains, +-- and samples the live pedestrian records owned by g_currentMission. +-- +-- Commands added to the existing hpWorld pedprobe command: +-- hpWorld pedprobe deep +-- hpWorld pedprobe splineenv +-- hpWorld pedprobe pedestrians + +if HP_WorldPedestrianProbe == nil then return end +if HP_WorldPedestrianProbeDeep ~= nil then return end + +HP_WorldPedestrianProbeDeep = { + version = "2.2.0.0-alpha6-pedestrian-deep-probe-1", + maxEntries = 120, + samplePedestrians = 4, + installed = false +} + +local Deep = HP_WorldPedestrianProbeDeep +local Probe = HP_WorldPedestrianProbe +local LOG = "[FS25_HelperProfiles/PedestrianProbe] " + +local function log(message, ...) + local ok, text = pcall(string.format, tostring(message), ...) + print(LOG .. (ok and text or tostring(message))) +end + +local function lower(value) + return string.lower(tostring(value or "")) +end + +local function functionInfo(fn) + if type(fn) ~= "function" then return "" end + if debug == nil or debug.getinfo == nil then return "function" end + local ok, info = pcall(debug.getinfo, fn, "Sln") + if not ok or info == nil then return "function" end + return string.format("function source=%s:%d", tostring(info.short_src or info.source or "?"), tonumber(info.linedefined) or -1) +end + +local function valueSummary(value) + local kind = type(value) + if kind == "function" then return functionInfo(value) end + if kind == "table" then + local count = 0 + for _ in pairs(value) do count = count + 1 end + return string.format("table entries=%d tostring=%s", count, tostring(value)) + end + if kind == "userdata" then return "userdata " .. tostring(value) end + if kind == "string" then + local text = value + if #text > 180 then text = string.sub(text, 1, 177) .. "..." end + return string.format("string %q", text) + end + return kind .. " " .. tostring(value) +end + +local function sortedKeys(tbl) + local keys = {} + if type(tbl) ~= "table" then return keys end + for key in pairs(tbl) do keys[#keys + 1] = key end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + return keys +end + +local function dumpTable(label, tbl, maxEntries) + log("TABLE %s -> %s", tostring(label), valueSummary(tbl)) + if type(tbl) ~= "table" then return end + local limit = math.max(1, tonumber(maxEntries) or Deep.maxEntries) + local keys = sortedKeys(tbl) + for i, key in ipairs(keys) do + if i > limit then + log("TABLE %s truncated after %d/%d entries", tostring(label), limit, #keys) + break + end + local ok, value = pcall(function() return tbl[key] end) + if ok then + log(" %s[%s] -> %s", tostring(label), tostring(key), valueSummary(value)) + else + log(" %s[%s] -> ", tostring(label), tostring(key), tostring(value)) + end + end +end + +local function dumpMetatable(label, value) + if type(value) ~= "table" and type(value) ~= "userdata" then + log("META %s -> unavailable for type=%s", tostring(label), type(value)) + return + end + + local seen = {} + local current = value + for depth = 1, 5 do + local ok, mt = pcall(getmetatable, current) + if not ok or mt == nil then + log("META %s depth=%d -> nil", tostring(label), depth) + return + end + if seen[mt] then + log("META %s depth=%d -> cycle %s", tostring(label), depth, tostring(mt)) + return + end + seen[mt] = true + dumpTable(string.format("%s.__meta%d", tostring(label), depth), mt, Deep.maxEntries) + + local indexValue = rawget(mt, "__index") + log("META %s depth=%d __index -> %s", tostring(label), depth, valueSummary(indexValue)) + if type(indexValue) == "table" then + dumpTable(string.format("%s.__index%d", tostring(label), depth), indexValue, Deep.maxEntries) + current = indexValue + else + return + end + end +end + +local function resolveGlobal(name) + local rawValue = rawget(_G, name) + local normalValue = nil + local ok, value = pcall(function() return _G[name] end) + if ok then normalValue = value end + return rawValue, normalValue +end + +local function probeSplineEnvironment() + log("=== Engine global resolution (rawget vs normal lookup) ===") + local names = { + "PedestrianSystem", + "g_pedestrianSystem", + "getSplineLength", + "getSplinePosition", + "getSplinePositionWithDistance", + "getSplineDirection", + "getSplineTime", + "getSplineEP", + "getNumOfSplineEPs", + "getClosestSplinePosition", + "getClosestSplinePositionVector", + "createSplineFromEditPoints", + "setSplineEP", + "addSplineEP", + "SplineUtil" + } + + for _, name in ipairs(names) do + local rawValue, normalValue = resolveGlobal(name) + log("ENV %s raw=%s | resolved=%s", tostring(name), valueSummary(rawValue), valueSummary(normalValue)) + if type(normalValue) == "table" and normalValue ~= rawValue then + dumpTable("ENV." .. tostring(name), normalValue, 80) + end + end + + local ok, mt = pcall(getmetatable, _G) + if ok and mt ~= nil then + dumpTable("_G.__meta", mt, 80) + local indexValue = rawget(mt, "__index") + log("_G.__meta.__index -> %s", valueSummary(indexValue)) + else + log("_G metatable unavailable") + end +end + +local function sampleCollection(label, collection) + if type(collection) ~= "table" then + log("%s unavailable: %s", tostring(label), valueSummary(collection)) + return + end + + log("=== Sampling %s ===", tostring(label)) + local count = 0 + for key, value in pairs(collection) do + count = count + 1 + if count > (tonumber(Deep.samplePedestrians) or 4) then break end + local entryLabel = string.format("%s[%s]", tostring(label), tostring(key)) + log("SAMPLE %s -> %s", entryLabel, valueSummary(value)) + if type(value) == "table" then dumpTable(entryLabel, value, 100) end + dumpMetatable(entryLabel, value) + end +end + +local function probePedestrianObjects() + log("=== Live pedestrian object/class inspection ===") + local mission = g_currentMission + local system = mission ~= nil and mission.pedestrianSystem or nil + log("SYSTEM -> %s", valueSummary(system)) + if type(system) ~= "table" then return end + + dumpTable("pedestrianSystem", system, Deep.maxEntries) + dumpMetatable("pedestrianSystem", system) + + sampleCollection("pedestrianSystem.pedestrians", system.pedestrians) + sampleCollection("pedestrianSystem.pedestrianNodes", system.pedestrianNodes) + + if type(system.groupNameToGroup) == "table" then + dumpTable("pedestrianSystem.groupNameToGroup", system.groupNameToGroup, 40) + sampleCollection("pedestrianSystem.groupNameToGroup", system.groupNameToGroup) + end +end + +local originalRun = Probe.run +function Probe:run(mode) + local normalized = lower(mode) + if normalized == "deep" then + log("RUN %s mode=deep", tostring(Deep.version)) + probeSplineEnvironment() + probePedestrianObjects() + log("DONE mode=deep") + return true + elseif normalized == "splineenv" or normalized == "engine" then + log("RUN %s mode=splineenv", tostring(Deep.version)) + probeSplineEnvironment() + log("DONE mode=splineenv") + return true + elseif normalized == "pedestrians" or normalized == "objects" then + log("RUN %s mode=pedestrians", tostring(Deep.version)) + probePedestrianObjects() + log("DONE mode=pedestrians") + return true + end + return originalRun(self, mode) +end + +Deep.installed = true +log("Loaded %s (metatable/class + live pedestrian record inspection; raw/resolved engine globals)", tostring(Deep.version)) diff --git a/scripts/HP_WorldSplinePath.lua b/scripts/HP_WorldSplinePath.lua new file mode 100644 index 0000000..75655ae --- /dev/null +++ b/scripts/HP_WorldSplinePath.lua @@ -0,0 +1,510 @@ +-- HP_WorldSplinePath.lua (FS25_HelperProfiles) +-- Alpha 6 experimental spline-guided path execution. +-- +-- This does NOT replace normal COME/FOLLOW. It creates a temporary GIANTS +-- cubic spline and continuously feeds the validated curved locomotion layer a +-- short look-ahead target on that spline. The experiment isolates whether a +-- spline/tangent-led path produces the smoother flow observed in pedestrians. +-- +-- Commands: +-- hpWorld smoothcome [slot] [standOffMetres] +-- hpWorld smoothgoto [slot] +-- hpWorld smoothstop [slot] + +if HP_WorldLocomotionPrototype == nil then return end +if HP_WorldLocomotionCurved == nil then return end +if HP_WorldTargetNavigation == nil then return end +if HP_WorldWorkerManager == nil then return end +if HP_WorldSplinePath ~= nil then return end + +HP_WorldSplinePath = { + version = "2.2.0.0-alpha6-spline-path-1", + paths = {}, + lookAheadDistance = 1.05, + finishDirectDistance = 0.90, + minimumPathDistance = 1.0, + maximumPathDistance = 100.0, + startTangentMin = 0.8, + startTangentMax = 3.2, + logIntervalMs = 1000, + installed = false +} + +local Path = HP_WorldSplinePath +local Loco = HP_WorldLocomotionPrototype +local Nav = HP_WorldTargetNavigation +local Manager = HP_WorldWorkerManager +local LOG = "[FS25_HelperProfiles/WorldSplinePath] " + +local function log(message, ...) + print(LOG .. string.format(tostring(message), ...)) +end + +local function clamp(value, minimum, maximum) + value = tonumber(value) or 0 + if value < minimum then return minimum end + if value > maximum then return maximum end + return value +end + +local function resolveIndex(value) + if value ~= nil and tostring(value) ~= "" then + if HP_SlotRegistry ~= nil then + local index = HP_SlotRegistry:slotToIndex(value, HP_SlotRegistry.TARGET_COUNT or 20) + if index ~= nil then return index end + end + local numeric = math.floor(tonumber(value) or 0) + if numeric >= 1 and numeric <= (HP_SlotRegistry ~= nil and HP_SlotRegistry.TARGET_COUNT or 20) then return numeric end + return nil + end + + if HelperProfiles ~= nil and HelperProfiles.getSelectedHelper ~= nil and HelperProfiles.getStableIndexForHelper ~= nil then + local okHelper, helper = pcall(HelperProfiles.getSelectedHelper, HelperProfiles) + if okHelper and helper ~= nil then + local okIndex, index = pcall(HelperProfiles.getStableIndexForHelper, HelperProfiles, helper) + if okIndex and tonumber(index) ~= nil then return math.floor(tonumber(index)) end + end + end + return nil +end + +local function getCanonicalId(index) + if HP_SlotRegistry ~= nil then return HP_SlotRegistry:canonicalId(index) end + return string.format("helper%02d", math.floor(tonumber(index) or 0)) +end + +local function getSlot(index) + if HP_SlotRegistry ~= nil then return HP_SlotRegistry:indexToSlot(index) end + return tostring(index) +end + +local function normalizeAngle(value) + value = tonumber(value) or 0 + local twoPi = math.pi * 2 + while value > math.pi do value = value - twoPi end + while value < -math.pi do value = value + twoPi end + return value +end + +local function directionFromYaw(yaw) + if MathUtil ~= nil and MathUtil.getDirectionFromYRotation ~= nil then + local ok, dx, dz = pcall(MathUtil.getDirectionFromYRotation, yaw) + if ok and tonumber(dx) ~= nil and tonumber(dz) ~= nil then return tonumber(dx), tonumber(dz) end + end + return math.sin(yaw), math.cos(yaw) +end + +local function terrainY(x, fallbackY, z) + local terrainNode = rawget(_G, "g_terrainNode") + if terrainNode ~= nil and terrainNode ~= 0 and getTerrainHeightAtWorldPos ~= nil then + local ok, value = pcall(getTerrainHeightAtWorldPos, terrainNode, x, 0, z) + if ok and tonumber(value) ~= nil then return tonumber(value) end + end + return tonumber(fallbackY) or 0 +end + +local function findLocalPlayer() + if rawget(_G, "g_localPlayer") ~= nil and g_localPlayer ~= nil then return g_localPlayer end + local mission = g_currentMission + if mission == nil then return nil end + if mission.player ~= nil then return mission.player end + if mission.controlledPlayer ~= nil then return mission.controlledPlayer end + + local playerSystem = mission.playerSystem + if playerSystem ~= nil then + for _, player in pairs(playerSystem.players or {}) do + if player ~= nil and (player.isOwner == true or player.isLocallyControlled == true) then return player end + end + for _, player in pairs(playerSystem.players or {}) do + if player ~= nil then return player end + end + end + return nil +end + +local function getPlayerXZ() + local player = findLocalPlayer() + if player == nil then return nil, nil, "local-player-unavailable" end + + if player.getPosition ~= nil then + local ok, x, _, z = pcall(player.getPosition, player) + if ok and tonumber(x) ~= nil and tonumber(z) ~= nil then return tonumber(x), tonumber(z), nil end + end + if player.getMapPositionAndLookYaw ~= nil then + local ok, x, z = pcall(player.getMapPositionAndLookYaw, player) + if ok and tonumber(x) ~= nil and tonumber(z) ~= nil then return tonumber(x), tonumber(z), nil end + end + if player.rootNode ~= nil and player.rootNode ~= 0 and getWorldTranslation ~= nil then + local ok, x, _, z = pcall(getWorldTranslation, player.rootNode) + if ok and tonumber(x) ~= nil and tonumber(z) ~= nil then return tonumber(x), tonumber(z), nil end + end + return nil, nil, "player-position-unavailable" +end + +local function getWorkerPose(index) + local id = getCanonicalId(index) + local instance = id ~= nil and Manager.instancesByCanonicalId ~= nil and Manager.instancesByCanonicalId[id] or nil + if instance == nil or instance.loading == true or instance.graphics == nil then + return nil, "worker-not-visible" + end + + local placement = HP_WorldState ~= nil and HP_WorldState:getPlacement(index) or nil + if placement == nil then return nil, "worker-not-placed" end + + local x = tonumber(placement.x) + local y = tonumber(placement.y) + local z = tonumber(placement.z) + local yaw = tonumber(instance.presenceCurrentYaw) or tonumber(placement.yaw) or 0 + + local root = instance.graphics.graphicsRootNode + if root ~= nil and root ~= 0 and getWorldTranslation ~= nil then + local ok, px, py, pz = pcall(getWorldTranslation, root) + if ok and tonumber(px) ~= nil and tonumber(pz) ~= nil then + x, z = tonumber(px), tonumber(pz) + if tonumber(py) ~= nil then y = tonumber(py) end + end + end + + if x == nil or z == nil then return nil, "worker-pose-unavailable" end + y = terrainY(x, y or 0, z) + return {x=x, y=y, z=z, yaw=normalizeAngle(yaw), id=id, instance=instance}, nil +end + +local function splineApiAvailable() + return createSplineFromEditPoints ~= nil + and getSplineLength ~= nil + and getClosestSplinePosition ~= nil + and getSplinePositionWithDistance ~= nil + and getSplinePosition ~= nil +end + +local function deleteSpline(state) + if state == nil or state.spline == nil or state.spline == 0 then return end + if delete ~= nil then pcall(delete, state.spline) end + state.spline = nil +end + +local function buildSpline(startPose, targetX, targetZ) + if not splineApiAvailable() then return nil, "required-spline-api-unavailable" end + if getRootNode == nil then return nil, "scene-root-unavailable" end + + local dx = targetX - startPose.x + local dz = targetZ - startPose.z + local distance = math.sqrt(dx * dx + dz * dz) + if distance < Path.minimumPathDistance then return nil, "target-too-close" end + if distance > Path.maximumPathDistance then return nil, "target-too-far" end + + local directX, directZ = dx / distance, dz / distance + local faceX, faceZ = directionFromYaw(startPose.yaw) + local tangent = clamp(distance * 0.30, Path.startTangentMin, Path.startTangentMax) + + -- Four edit points give the cubic spline a meaningful start tangent while + -- still converging onto the destination direction. The first intermediate + -- point follows the worker's current facing; the second approaches the end + -- along the overall route. This is path shaping, not obstacle avoidance. + local p0x, p0z = startPose.x, startPose.z + local p1x, p1z = p0x + faceX * tangent, p0z + faceZ * tangent + local p3x, p3z = targetX, targetZ + local p2x, p2z = p3x - directX * tangent, p3z - directZ * tangent + + local p0y = terrainY(p0x, startPose.y, p0z) + local p1y = terrainY(p1x, p0y, p1z) + local p2y = terrainY(p2x, p1y, p2z) + local p3y = terrainY(p3x, p2y, p3z) + + local editPoints = { + p0x, p0y, p0z, + p1x, p1y, p1z, + p2x, p2y, p2z, + p3x, p3y, p3z + } + + local okRoot, root = pcall(getRootNode) + if not okRoot or root == nil or root == 0 then return nil, "scene-root-unavailable" end + + local okSpline, spline = pcall(createSplineFromEditPoints, root, editPoints, false, false) + if not okSpline or spline == nil or spline == 0 then + return nil, "createSplineFromEditPoints-failed: " .. tostring(spline) + end + + if setVisibility ~= nil then pcall(setVisibility, spline, false) end + + local okLength, length = pcall(getSplineLength, spline) + if not okLength or tonumber(length) == nil then + if delete ~= nil then pcall(delete, spline) end + return nil, "getSplineLength-failed" + end + + return { + spline = spline, + pathLength = tonumber(length), + finalX = p3x, + finalY = p3y, + finalZ = p3z, + startX = p0x, + startY = p0y, + startZ = p0z, + tangent = tangent, + editPoints = editPoints, + splineTime = 0, + logMs = 0, + finishing = false + }, nil +end + +local function updateLookAhead(state, motion) + if state == nil or motion == nil or state.spline == nil then return false, "path-state-invalid" end + + local dxEnd = state.finalX - motion.x + local dzEnd = state.finalZ - motion.z + local directEndDistance = math.sqrt(dxEnd * dxEnd + dzEnd * dzEnd) + + if directEndDistance <= Path.finishDirectDistance then + state.finishing = true + motion.targetX = state.finalX + motion.targetY = state.finalY + motion.targetZ = state.finalZ + return true, nil + end + + local okClosest, _, _, _, closestT = pcall( + getClosestSplinePosition, + state.spline, + motion.x, motion.y, motion.z, + 0.02 + ) + if not okClosest or tonumber(closestT) == nil then + return false, "getClosestSplinePosition-failed" + end + + state.splineTime = clamp(closestT, 0, 1) + local lookAhead = math.max(0.35, tonumber(Path.lookAheadDistance) or 1.05) + local okAhead, tx, ty, tz, aheadT = pcall( + getSplinePositionWithDistance, + state.spline, + state.splineTime, + lookAhead, + true, + 0.01 + ) + + if okAhead and tonumber(tx) ~= nil and tonumber(tz) ~= nil and tonumber(aheadT) ~= nil then + motion.targetX = tonumber(tx) + motion.targetY = terrainY(tonumber(tx), tonumber(ty) or motion.y, tonumber(tz)) + motion.targetZ = tonumber(tz) + state.lookAheadT = tonumber(aheadT) + else + -- At/near the end the distance query may not be able to find a point + -- a full look-ahead metre ahead. Fall back to the spline endpoint. + state.finishing = true + motion.targetX = state.finalX + motion.targetY = state.finalY + motion.targetZ = state.finalZ + end + + return true, nil +end + +function Path:startPoint(indexOrSlot, targetX, targetZ, kind) + local index = resolveIndex(indexOrSlot) + if index == nil then return false, "invalid-or-no-selected-helper" end + + targetX = tonumber(targetX) + targetZ = tonumber(targetZ) + if targetX == nil or targetZ == nil then return false, "invalid-target-coordinates" end + + local pose, poseErr = getWorkerPose(index) + if pose == nil then return false, poseErr end + if Loco.motions[pose.id] ~= nil then return false, "worker-already-walking" end + + local state, splineErr = buildSpline(pose, targetX, targetZ) + if state == nil then return false, splineErr end + + local okStart, startErr = Nav:startPoint(index, targetX, targetZ, "spline-path") + if not okStart then + deleteSpline(state) + return false, startErr + end + + local motion = Loco.motions[pose.id] + if motion == nil then + deleteSpline(state) + return false, "motion-initialization-failed" + end + + state.id = pose.id + state.index = index + state.kind = tostring(kind or "smoothgoto") + self.paths[pose.id] = state + motion.navigationKind = "spline-path" + + local okAhead, aheadErr = updateLookAhead(state, motion) + if not okAhead then + self.paths[pose.id] = nil + deleteSpline(state) + Loco:finishMotion(motion, "spline-lookahead-init-failed", true) + return false, aheadErr + end + + log("PATH START %s kind=%s length=%.2f tangent=%.2f from=(%.2f,%.2f) final=(%.2f,%.2f) firstTarget=(%.2f,%.2f)", + tostring(getSlot(index)), state.kind, state.pathLength, state.tangent, + state.startX, state.startZ, state.finalX, state.finalZ, + tonumber(motion.targetX) or 0, tonumber(motion.targetZ) or 0) + return true, nil +end + +function Path:startCome(indexOrSlot, standOff) + local index = resolveIndex(indexOrSlot) + if index == nil then return false, "invalid-or-no-selected-helper" end + + local pose, poseErr = getWorkerPose(index) + if pose == nil then return false, poseErr end + + local playerX, playerZ, playerErr = getPlayerXZ() + if playerX == nil or playerZ == nil then return false, playerErr end + + standOff = clamp(standOff or (Nav.defaultStandOff or 1.8), Nav.minimumStandOff or 0.75, Nav.maximumStandOff or 5.0) + local dx = playerX - pose.x + local dz = playerZ - pose.z + local distance = math.sqrt(dx * dx + dz * dz) + if distance <= standOff + 0.20 then return false, "already-near-player" end + + local targetX = playerX - (dx / distance) * standOff + local targetZ = playerZ - (dz / distance) * standOff + local ok, err = self:startPoint(index, targetX, targetZ, "smoothcome") + if ok then + log("SMOOTH COME %s player=(%.2f,%.2f) standOff=%.2f final=(%.2f,%.2f)", + tostring(getSlot(index)), playerX, playerZ, standOff, targetX, targetZ) + end + return ok, err +end + +function Path:stop(indexOrSlot) + local index = resolveIndex(indexOrSlot) + if index == nil then return false, "invalid-or-no-selected-helper" end + local id = getCanonicalId(index) + local state = id ~= nil and self.paths[id] or nil + local motion = id ~= nil and Loco.motions[id] or nil + if state == nil and (motion == nil or motion.navigationKind ~= "spline-path") then + return false, "worker-not-on-spline-path" + end + + if motion ~= nil then + Loco:finishMotion(motion, "spline-path-cancelled", true) + else + self.paths[id] = nil + deleteSpline(state) + end + return true, nil +end + +function Path:updateState(state, dt) + if state == nil or state.id == nil then return end + local motion = Loco.motions[state.id] + if motion == nil or motion.navigationKind ~= "spline-path" then + self.paths[state.id] = nil + deleteSpline(state) + return + end + + if not state.finishing then + local ok, err = updateLookAhead(state, motion) + if not ok then + log("PATH ERROR %s: %s", tostring(getSlot(state.index)), tostring(err)) + Loco:finishMotion(motion, "spline-path-update-failed", true) + return + end + end + + state.logMs = (tonumber(state.logMs) or 0) - math.max(0, tonumber(dt) or 0) + if state.logMs <= 0 then + state.logMs = tonumber(self.logIntervalMs) or 1000 + local dx = state.finalX - motion.x + local dz = state.finalZ - motion.z + log("PATH %s t=%.4f aheadT=%s finishing=%s speed=%.3f finalDistance=%.2f target=(%.2f,%.2f)", + tostring(getSlot(state.index)), tonumber(state.splineTime) or 0, + state.lookAheadT ~= nil and string.format("%.4f", state.lookAheadT) or "-", + tostring(state.finishing == true), tonumber(motion.speed) or 0, + math.sqrt(dx * dx + dz * dz), + tonumber(motion.targetX) or 0, tonumber(motion.targetZ) or 0) + end +end + +local function normalizeCommandArgs(...) + local args = {...} + local clean = {} + for _, value in ipairs(args) do + if value ~= nil and tostring(value) ~= "" and tostring(value) ~= "hpWorld" then + clean[#clean + 1] = tostring(value) + end + end + return clean[1], clean[2], clean[3], clean[4] +end + +function Path:install() + if self.installed then return true end + + -- Retarget spline-guided motions before the validated movement stack runs. + -- The curved locomotion controller remains the sole owner of worker pose, + -- speed, yaw and HumanGraphicsComponent state. + local originalManagerUpdate = Manager.update + function Manager:update(dt, ...) + local snapshot = {} + for _, state in pairs(Path.paths) do snapshot[#snapshot + 1] = state end + for _, state in ipairs(snapshot) do Path:updateState(state, dt) end + return originalManagerUpdate(self, dt, ...) + end + + -- Ensure temporary engine spline entities are removed on every normal, + -- blocked, cancelled or error completion path. + local originalFinishMotion = Loco.finishMotion + function Loco:finishMotion(motion, reason, persist, ...) + local state = motion ~= nil and Path.paths[motion.id] or nil + if state ~= nil then + Path.paths[motion.id] = nil + deleteSpline(state) + log("PATH STOP %s reason=%s", tostring(getSlot(state.index)), tostring(reason or "complete")) + end + return originalFinishMotion(self, motion, reason, persist, ...) + end + + local originalConsole = Manager.consoleCommandWorld + function Manager:consoleCommandWorld(...) + local sub, slot, value1, value2 = normalizeCommandArgs(...) + sub = string.lower(tostring(sub or "status")) + + if sub == "smoothcome" or sub == "splinecome" then + local ok, err = Path:startCome(slot, value1) + local index = resolveIndex(slot) + local label = index ~= nil and getSlot(index) or tostring(slot or "selected") + print(string.format("[HP] hpWorld smoothcome %s -> %s%s", tostring(label), tostring(ok == true), err ~= nil and (" (" .. tostring(err) .. ")") or "")) + return + elseif sub == "smoothgoto" or sub == "splinegoto" then + local ok, err = Path:startPoint(slot, value1, value2, "smoothgoto") + local index = resolveIndex(slot) + local label = index ~= nil and getSlot(index) or tostring(slot or "selected") + print(string.format("[HP] hpWorld smoothgoto %s -> %s%s", tostring(label), tostring(ok == true), err ~= nil and (" (" .. tostring(err) .. ")") or "")) + return + elseif sub == "smoothstop" or sub == "splinestop" then + local ok, err = Path:stop(slot) + local index = resolveIndex(slot) + local label = index ~= nil and getSlot(index) or tostring(slot or "selected") + print(string.format("[HP] hpWorld smoothstop %s -> %s%s", tostring(label), tostring(ok == true), err ~= nil and (" (" .. tostring(err) .. ")") or "")) + return + elseif sub == "help" then + originalConsole(self, ...) + print("[HP] Alpha6 spline prototype: hpWorld smoothcome [slot] [standOffMetres]") + print("[HP] Alpha6 spline prototype: hpWorld smoothgoto [slot] | smoothstop [slot]") + print("[HP] Experimental only: normal COME/FOLLOW remain unchanged.") + return + end + + return originalConsole(self, ...) + end + + self.installed = true + log("Loaded %s (runtime cubic spline + moving look-ahead target; normal COME/FOLLOW unchanged)", tostring(self.version)) + return true +end + +Path:install() diff --git a/scripts/HelperProfiles.lua b/scripts/HelperProfiles.lua index 6a5b427..988b67d 100644 --- a/scripts/HelperProfiles.lua +++ b/scripts/HelperProfiles.lua @@ -42,7 +42,7 @@ HelperProfiles._pickMode = HelperProfiles._pickMode or "preferSelected" -- pref local function hpI18n(key, fallback) if g_i18n ~= nil and g_i18n.getText ~= nil then - local ok, value = pcall(g_i18n.getText, g_i18n, key) + local ok, value = HP_ProtectedCall.call(g_i18n.getText, g_i18n, key) if ok and value ~= nil and value ~= "" and value ~= key then return value end @@ -52,7 +52,7 @@ end local function hpFormat(key, fallback, ...) local pattern = hpI18n(key, fallback) - local ok, value = pcall(string.format, pattern, ...) + local ok, value = HP_ProtectedCall.call(string.format, pattern, ...) if ok then return value end return pattern end @@ -230,7 +230,7 @@ end function HelperProfiles:getDisplayNameForHelper(helper, idx) if HP_ASBridge ~= nil and HP_ASBridge.getDisplayNameForHelper ~= nil then - local ok, displayName, baseName = pcall(HP_ASBridge.getDisplayNameForHelper, HP_ASBridge, helper, idx) + local ok, displayName, baseName = HP_ProtectedCall.call(HP_ASBridge.getDisplayNameForHelper, HP_ASBridge, helper, idx) if ok and displayName ~= nil and tostring(displayName) ~= "" then return tostring(displayName), tostring(baseName or (helper and helper.name) or idx or "?") end @@ -241,7 +241,7 @@ end function HelperProfiles:getAppearanceLabelForHelper(helper, idx) if HP_ASBridge ~= nil and HP_ASBridge.getAppearanceLabelForHelper ~= nil then - local ok, label, presetId, category = pcall(HP_ASBridge.getAppearanceLabelForHelper, HP_ASBridge, helper, idx) + local ok, label, presetId, category = HP_ProtectedCall.call(HP_ASBridge.getAppearanceLabelForHelper, HP_ASBridge, helper, idx) if ok then return label, presetId, category end diff --git a/scripts/RegisterPlayerActionEvents.lua b/scripts/RegisterPlayerActionEvents.lua index 53f04c0..9542049 100644 --- a/scripts/RegisterPlayerActionEvents.lua +++ b/scripts/RegisterPlayerActionEvents.lua @@ -31,7 +31,7 @@ local function _isPhysicalKeyPressed(keyName) return false end - local ok, result = pcall(Input.isKeyPressed, key) + local ok, result = HP_ProtectedCall.call(Input.isKeyPressed, key) return ok and result == true end @@ -249,7 +249,7 @@ if HP_AppearanceBindingsScreen ~= nil then local function _removeAppearanceClearAllAction(screen) local id = screen ~= nil and screen._clearAllBindingsActionEventId or nil if id ~= nil and g_inputBinding ~= nil and g_inputBinding.removeActionEvent ~= nil then - pcall(function() + HP_ProtectedCall.call(function() g_inputBinding:removeActionEvent(id) end) end @@ -274,7 +274,7 @@ if HP_AppearanceBindingsScreen ~= nil then return end - local callOk, registered, id = pcall(function() + local callOk, registered, id = HP_ProtectedCall.call(function() return g_inputBinding:registerActionEvent( inputAction, screen, diff --git a/scripts/gui/HP_AppearanceBindingsScreen.lua b/scripts/gui/HP_AppearanceBindingsScreen.lua index 0d5cb42..176671c 100644 --- a/scripts/gui/HP_AppearanceBindingsScreen.lua +++ b/scripts/gui/HP_AppearanceBindingsScreen.lua @@ -29,7 +29,7 @@ end local function hpI18n(key, fallback) if g_i18n ~= nil and g_i18n.getText ~= nil then - local ok, value = pcall(g_i18n.getText, g_i18n, key) + local ok, value = HP_ProtectedCall.call(g_i18n.getText, g_i18n, key) if ok and value ~= nil and value ~= "" and value ~= key then return value end @@ -39,7 +39,7 @@ end local function hpFormat(key, fallback, ...) local pattern = hpI18n(key, fallback) - local ok, value = pcall(string.format, pattern, ...) + local ok, value = HP_ProtectedCall.call(string.format, pattern, ...) if ok then return value end return pattern end @@ -70,7 +70,7 @@ end local function getDerivedDisplayNameForPreset(preset, fallback) if HP_ASBridge ~= nil and HP_ASBridge.deriveDisplayNameFromPreset ~= nil then - local ok, value = pcall(HP_ASBridge.deriveDisplayNameFromPreset, HP_ASBridge, preset, fallback) + local ok, value = HP_ProtectedCall.call(HP_ASBridge.deriveDisplayNameFromPreset, HP_ASBridge, preset, fallback) if ok and value ~= nil and tostring(value) ~= "" then return tostring(value) end end return tostring(fallback or "") @@ -138,7 +138,7 @@ end local function getHelperDisplayName(helper, idx) local fallback = tostring((helper ~= nil and helper.name) or ("Helper " .. tostring(idx or "?"))) if HelperProfiles ~= nil and HelperProfiles.getDisplayNameForHelper ~= nil then - local ok, displayName, baseName = pcall(HelperProfiles.getDisplayNameForHelper, HelperProfiles, helper, idx) + local ok, displayName, baseName = HP_ProtectedCall.call(HelperProfiles.getDisplayNameForHelper, HelperProfiles, helper, idx) if ok and displayName ~= nil and tostring(displayName) ~= "" then return tostring(displayName), tostring(baseName or fallback) end @@ -182,7 +182,7 @@ end isHelperActive = function(helper) if helper == nil then return false end if HelperProfiles ~= nil and HelperProfiles.isHelperActive ~= nil then - local ok, active = pcall(HelperProfiles.isHelperActive, HelperProfiles, helper) + local ok, active = HP_ProtectedCall.call(HelperProfiles.isHelperActive, HelperProfiles, helper) if ok then return active == true end end return helper.inUse == true @@ -281,7 +281,14 @@ function HP_AppearanceBindingsScreen:reloadData(reloadBridge) self.helperRows = getHelpers() if #self.helperRows == 0 then - table.insert(self.helperRows, { index = 1, slot = "A", helper = nil, name = hpI18n("hp_helper_fallback", "Helper 1"), displayName = hpI18n("hp_no_helpers_available", "No helpers available"), label = hpI18n("hp_no_helpers_available", "No helpers available") }) + table.insert(self.helperRows, { + index = 1, + slot = "A", + helper = nil, + name = hpI18n("hp_helper_fallback", "Helper 1"), + displayName = hpI18n("hp_no_helpers_available", "No helpers available"), + label = hpI18n("hp_no_helpers_available", "No helpers available") + }) end self.categoryRows = {} @@ -487,7 +494,14 @@ function HP_AppearanceBindingsScreen:updateDetailText() local detail = hpI18n("hp_detail_select", "Select a helper slot and appearance.") if helperRow ~= nil and presetRow ~= nil and presetRow.id ~= nil and presetRow.id ~= "" then - detail = hpFormat("hp_detail_selected", "Selected: %s | %s | %s [%s]", tostring(helperRow.displayName or helperRow.name), tostring(category or "-"), tostring(presetRow.label or presetRow.id), tostring(presetRow.id)) + detail = hpFormat( + "hp_detail_selected", + "Selected: %s | %s | %s [%s]", + tostring(helperRow.displayName or helperRow.name), + tostring(category or "-"), + tostring(presetRow.label or presetRow.id), + tostring(presetRow.id) + ) end if helperRow ~= nil and self:isHelperRowReadOnly(helperRow) then @@ -512,7 +526,12 @@ function HP_AppearanceBindingsScreen:updateDetailText() if bindingLabel ~= nil and bindingLabel ~= "" then status = status .. " | " .. hpFormat("hp_status_current_binding", "Current binding: %s → %s", tostring(helperRow.displayName or helperRow.name), bindingLabel) else - status = status .. " | " .. hpFormat("hp_status_current_binding", "Current binding: %s → %s", tostring(helperRow.displayName or helperRow.name), hpI18n("hp_state_unbound_title", "Unbound")) + status = status .. " | " .. hpFormat( + "hp_status_current_binding", + "Current binding: %s → %s", + tostring(helperRow.displayName or helperRow.name), + hpI18n("hp_state_unbound_title", "Unbound") + ) end end @@ -715,7 +734,7 @@ function HP_AppearanceBindingsGui:loadDialog() local profilePath = modDir .. "gui/guiProfiles.xml" local dialogPath = modDir .. "gui/HP_AppearanceBindingsScreen.xml" - local ok, err = pcall(function() + local ok, err = HP_ProtectedCall.call(function() if g_gui.loadProfiles ~= nil then g_gui:loadProfiles(profilePath) end