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..1247f84 100644
--- a/modDesc.xml
+++ b/modDesc.xml
@@ -9,198 +9,112 @@
-
-
-
-
-
-
icon_helperProfiles.dds
diff --git a/scripts/HP_SlotRegistry.lua b/scripts/HP_SlotRegistry.lua
index db956db..f987144 100644
--- a/scripts/HP_SlotRegistry.lua
+++ b/scripts/HP_SlotRegistry.lua
@@ -190,6 +190,12 @@ 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_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_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_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()