if not TowBarMod then TowBarMod = {} end if not TowBarMod.Hook then TowBarMod.Hook = {} end require("TowBar/VehicleCompatibility") local DefaultTowBarTowMass = 200 local FreeRollTickInterval = 15 local freeRollTickCounter = 0 local function tryVehicleCall(vehicle, methodName, arg) if not vehicle or not methodName then return false, nil end local method = vehicle[methodName] if method == nil then return false, nil end return pcall(function() if arg ~= nil then return method(vehicle, arg) end return method(vehicle) end) end local function storeOriginalVehicleCall(vehicle, modData, key, getterName) if not vehicle or not modData or modData[key] ~= nil then return end local ok, value = tryVehicleCall(vehicle, getterName) if ok and value ~= nil then modData[key] = value end end local function applyFreeRollingTowState(vehicle) if not vehicle then return end local modData = vehicle:getModData() if not modData then return end if modData.towBarOriginalMass == nil then modData.towBarOriginalMass = vehicle:getMass() end if modData.towBarOriginalBrakingForce == nil then modData.towBarOriginalBrakingForce = vehicle:getBrakingForce() end storeOriginalVehicleCall(vehicle, modData, "towBarOriginalParkingBrakeOn", "isParkingBrakeOn") storeOriginalVehicleCall(vehicle, modData, "towBarOriginalParkingBrake", "getParkingBrake") storeOriginalVehicleCall(vehicle, modData, "towBarOriginalHandbrake", "isHandbrake") local configuredTowMass = TowBarMod.Config and tonumber(TowBarMod.Config.towedVehicleRollingMass) vehicle:setMass(configuredTowMass or DefaultTowBarTowMass) vehicle:setBrakingForce(0) if modData.towBarOriginalParkingBrakeOn ~= nil then tryVehicleCall(vehicle, "setParkingBrakeOn", false) end if modData.towBarOriginalParkingBrake ~= nil then tryVehicleCall(vehicle, "setParkingBrake", false) end if modData.towBarOriginalHandbrake ~= nil then tryVehicleCall(vehicle, "setHandbrake", false) end -- Match the working wrecker path. Recalculating total mass here would -- immediately replace the temporary towing mass in multiplayer; creating -- the rigid constraint below notifies Bullet of the changed vehicle state. end TowBarMod.Hook.applyFreeRollingTowState = applyFreeRollingTowState local function restoreFreeRollingTowState(vehicle, modData) if not vehicle or not modData then return end if modData.towBarOriginalMass ~= nil then vehicle:setMass(modData.towBarOriginalMass) end if modData.towBarOriginalBrakingForce ~= nil then vehicle:setBrakingForce(modData.towBarOriginalBrakingForce) end if modData.towBarOriginalParkingBrakeOn ~= nil then tryVehicleCall(vehicle, "setParkingBrakeOn", modData.towBarOriginalParkingBrakeOn) end if modData.towBarOriginalParkingBrake ~= nil then tryVehicleCall(vehicle, "setParkingBrake", modData.towBarOriginalParkingBrake) end if modData.towBarOriginalHandbrake ~= nil then tryVehicleCall(vehicle, "setHandbrake", modData.towBarOriginalHandbrake) end vehicle:constraintChanged() vehicle:updateTotalMass() end local function getTowBarItem(playerObj) if not playerObj then return nil end local inventory = playerObj:getInventory() if not inventory then return nil end return inventory:getItemFromTypeRecurse("TowBar.TowBar") end local function sendTowAttachCommand(playerObj, args) if not playerObj or not args then return end -- SP and MP now share the same authoritative attach lifecycle. sendClientCommand(playerObj, "towbar", "attachTowBar", args) end local TowbarVariantSize = 24 local TowbarMaxIndex = TowbarVariantSize - 1 local TowbarFirstZ = 1.0 local TowbarSlotStep = 0.1 -- Measured from Towbar.fbx at its original 0.01 script scale. The visual is -- enlarged uniformly, so its center must move outward by the scaled half-length -- to keep the inner end on the same vehicle-hitbox contact point. local TowbarVisualScale = 2.5 local TowbarModelLength = 0.9714089036 local TowbarScaledModelLength = TowbarModelLength * TowbarVisualScale local TowbarModelHalfLength = TowbarScaledModelLength / 2 local VanillaScaleMin = 1.5 local VanillaScaleMax = 2.0 local function getTowbarFrontEdgeZ(script) if not script then return nil end local ok, shape = pcall(function() return script:getPhysicsChassisShape() end) if not ok or not shape then return nil end local shapeOk, shapeZ = pcall(function() return shape:z() end) if not shapeOk or type(shapeZ) ~= "number" or shapeZ <= 0 then return nil end local centerOk, center = pcall(function() return script:getCenterOfMassOffset() end) if not centerOk or not center then return nil end local zOk, centerZ = pcall(function() return center:z() end) if not zOk or type(centerZ) ~= "number" then return nil end return centerZ + shapeZ / 2 end local function getTowbarModelSlot(script) local frontEdgeZ = getTowbarFrontEdgeZ(script) if frontEdgeZ == nil then return nil end -- Model offsets position the mesh origin. Move its center outward so the -- towbar's inner end, rather than its center, meets the vehicle hitbox. local modelCenterZ = frontEdgeZ + TowbarModelHalfLength local index = math.floor(((modelCenterZ - TowbarFirstZ) / TowbarSlotStep) + 0.5) return math.max(0, math.min(TowbarMaxIndex, index)) end local function getVehicleModelScale(script) if not script then return nil end local ok, result = pcall(function() return script:getModelScale() end) if ok and type(result) == "number" then return result end ok, result = pcall(function() local model = script:getModel() return model and model:getScale() or nil end) if ok and type(result) == "number" then return result end return nil end local function isVanillaScale(script) local modelScale = getVehicleModelScale(script) if modelScale == nil then return true end local configuredMin = TowBarMod.Config and tonumber(TowBarMod.Config.vanillaTowbarModelScaleMin) local configuredMax = TowBarMod.Config and tonumber(TowBarMod.Config.vanillaTowbarModelScaleMax) return modelScale >= (configuredMin or VanillaScaleMin) and modelScale <= (configuredMax or VanillaScaleMax) end local function getTowbarIndexVanilla(script) if not script then return nil end local ok, shape = pcall(function() return script:getPhysicsChassisShape() end) if not ok or not shape then return nil end local zOk, shapeZ = pcall(function() return shape:z() end) if not zOk or type(shapeZ) ~= "number" then return nil end local z = shapeZ / 2 - 0.1 local index = math.floor((z * 2 / 3 - 1) * 10) return math.max(0, math.min(TowbarMaxIndex, index)) end local function getTowbarIndexSmallScale(script) if not script then return nil end local maxAbsTowZ = nil local trailer = script:getAttachmentById("trailer") if trailer then maxAbsTowZ = math.abs(trailer:getOffset():z()) end local trailerFront = script:getAttachmentById("trailerfront") if trailerFront then local frontAbsZ = math.abs(trailerFront:getOffset():z()) if not maxAbsTowZ or frontAbsZ > maxAbsTowZ then maxAbsTowZ = frontAbsZ end end if maxAbsTowZ == nil then return nil end local index = math.floor((maxAbsTowZ + 0.1 - 1.0) * 10) return math.max(0, math.min(TowbarMaxIndex, index)) end local function getLegacyTowbarModelSlot(script) local useNormalPart = isVanillaScale(script) local index = getTowbarIndexVanilla(script) if not useNormalPart then index = getTowbarIndexSmallScale(script) or index if index == nil then local offset = TowBarMod.Config and tonumber(TowBarMod.Config.smallScaleTowbarIndexOffset) or 2 index = math.max(0, math.min(TowbarMaxIndex, offset)) end end return index, useNormalPart end local function setTowBarModelVisible(vehicle, isVisible) if not vehicle then return end local normalPart = vehicle:getPartById("towbar") local largePart = vehicle:getPartById("towbarLarge") local ki5Part = vehicle:getPartById("towbarKI5") if normalPart == nil and largePart == nil and ki5Part == nil then return end for j = 0, TowbarVariantSize - 1 do if normalPart then normalPart:setModelVisible("towbar" .. j, false) end if largePart then largePart:setModelVisible("towbar" .. j, false) end if ki5Part then ki5Part:setModelVisible("towbar" .. j, false) end end if not isVisible then vehicle:doDamageOverlay() return end local script = vehicle:getScript() if not script then vehicle:doDamageOverlay() return end local isKi5 = TowBarMod.Compatibility.isKi5Vehicle(vehicle) local index, useNormalPart if isKi5 then index = getTowbarModelSlot(script) else index, useNormalPart = getLegacyTowbarModelSlot(script) end local part = isKi5 and ki5Part or (useNormalPart and normalPart or largePart) if part == nil and not isKi5 then part = normalPart or largePart end if part and index ~= nil then part:setModelVisible("towbar" .. index, true) end vehicle:doDamageOverlay() end function TowBarMod.Hook.setVehicleScriptWithTowBarHidden(vehicle, scriptName) if not vehicle or not scriptName then return false end local modData = vehicle:getModData() setTowBarModelVisible(vehicle, false) if modData then modData.towBarModelSwapInProgress = true end local ok, err = pcall(function() vehicle:setScriptName(scriptName) end) if modData then modData.towBarModelSwapInProgress = nil end -- Script initialization can reset part visibility, so hide it again before -- allowing the intended model to be shown by the caller. setTowBarModelVisible(vehicle, false) if not ok then error(err) end return true end local function resolveTowAttachmentsForPair(towingVehicle, towedVehicle, towedModData) if not towingVehicle or not towedVehicle then return nil, nil end local attachmentA = towingVehicle:getTowAttachmentSelf() or "trailer" local attachmentB = towingVehicle:getTowAttachmentOther() or (towedModData and towedModData["towBarChangedAttachmentId"]) or "trailerfront" if not towingVehicle:canAttachTrailer(towedVehicle, attachmentA, attachmentB) then if towingVehicle:canAttachTrailer(towedVehicle, "trailer", "trailerfront") then attachmentA = "trailer" attachmentB = "trailerfront" elseif towingVehicle:canAttachTrailer(towedVehicle, "trailerfront", "trailer") then attachmentA = "trailerfront" attachmentB = "trailer" end end return attachmentA, attachmentB end local function isActiveTowBarTowedVehicle(vehicle, modData) if not vehicle or not modData then return false end if modData["isTowingByTowBar"] and modData["towed"] then return true end -- Rejoin fallback: if the tow link exists, original-script marker is enough. if vehicle:getVehicleTowedBy() and modData.towBarOriginalScriptName ~= nil then return true end return false end function TowBarMod.Hook.setVehiclePostAttach(playerObj, towedVehicle, knownTowingVehicle) if not towedVehicle then return end local towedModData = towedVehicle:getModData() if not isActiveTowBarTowedVehicle(towedVehicle, towedModData) then return end local towingVehicle = knownTowingVehicle or towedVehicle:getVehicleTowedBy() if not towingVehicle then return end -- The rigid primitive passes the authoritative towing vehicle because MP -- reciprocal getters can lag behind accepted local constraint submission. if towedModData and towedModData.towBarOriginalScriptName and towedVehicle:getScriptName() ~= towedModData.towBarOriginalScriptName then TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, towedModData.towBarOriginalScriptName) end if towingVehicle then local attachmentA, attachmentB = resolveTowAttachmentsForPair(towingVehicle, towedVehicle, towedModData) if attachmentA and attachmentB then local towingModData = towingVehicle:getModData() if towingModData then towingModData["towBarTowedVehicleId"] = towedVehicle:getId() towingModData["towBarTowingVehicleId"] = nil towingModData["towBarExpectedAttachment"] = attachmentA towedModData["towBarTowingVehicleId"] = towingVehicle:getId() towedModData["towBarTowedVehicleId"] = nil towedModData["towBarExpectedAttachment"] = attachmentB towingVehicle:transmitModData() towedVehicle:transmitModData() end end end applyFreeRollingTowState(towedVehicle) -- Re-show the towbar model after the script name has been restored. -- setScriptName() resets model visibility, so we must set it again here. setTowBarModelVisible(towedVehicle, true) end function TowBarMod.Hook.performAttachTowBar(playerObj, towingVehicle, towedVehicle, attachmentA, attachmentB) if playerObj == nil or towingVehicle == nil or towedVehicle == nil then return end if #(TowBarMod.Utils.getHookTypeVariants(towingVehicle, towedVehicle, true)) == 0 then return end local towBarItem = getTowBarItem(playerObj) if towBarItem == nil then return end playerObj:setPrimaryHandItem(nil) local args = { vehicleA = towingVehicle:getId(), vehicleB = towedVehicle:getId(), attachmentA = attachmentA, attachmentB = attachmentB, itemId = towBarItem and towBarItem:getID() or nil } sendTowAttachCommand(playerObj, args) end function TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle) if towingVehicle == nil or towedVehicle == nil then return end local towingModData = towingVehicle:getModData() local towedModData = towedVehicle:getModData() if not towingModData or not towedModData then return end if towedModData.towBarOriginalScriptName then TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, towedModData.towBarOriginalScriptName) end -- A very fast break can happen while the temporary fake-trailer script is -- still active. Restore the real script first so the saved offsets are -- written back to the actual vehicle attachments, not the temporary script. TowBarMod.Utils.updateAttachmentsOnDefaultValues(towingVehicle, towedVehicle) restoreFreeRollingTowState(towedVehicle, towedModData) towingModData["isTowingByTowBar"] = false towingModData["towed"] = false towingModData["towBarTowedVehicleId"] = nil towingModData["towBarTowedVehicleSqlId"] = nil towingModData["towBarTowingVehicleId"] = nil towingModData["towBarTowingVehicleSqlId"] = nil towingModData["towBarExpectedAttachment"] = nil towedModData["isTowingByTowBar"] = false towedModData["towed"] = false towedModData["towBarTowedVehicleId"] = nil towedModData["towBarTowedVehicleSqlId"] = nil towedModData["towBarTowingVehicleId"] = nil towedModData["towBarTowingVehicleSqlId"] = nil towedModData["towBarExpectedAttachment"] = nil towedModData.towBarOriginalScriptName = nil towedModData.towBarOriginalMass = nil towedModData.towBarOriginalBrakingForce = nil towedModData.towBarOriginalParkingBrakeOn = nil towedModData.towBarOriginalParkingBrake = nil towedModData.towBarOriginalHandbrake = nil towingVehicle:transmitModData() towedVehicle:transmitModData() setTowBarModelVisible(towedVehicle, false) end function TowBarMod.Hook.performDetachTowBar(playerObj, towingVehicle, towedVehicle) if playerObj == nil or towingVehicle == nil or towedVehicle == nil then return end local args = { towingVehicle = towingVehicle:getId(), vehicle = towedVehicle:getId() } sendClientCommand(playerObj, "towbar", "detachTowBar", args) end local function forEachCollectionItem(collection, callback) if not collection then return end local ok, iterator = pcall(function() return collection:iterator() end) if ok and iterator then while iterator:hasNext() do callback(iterator:next()) end return end local size ok, size = pcall(function() return collection:size() end) if not ok or type(size) ~= "number" then return end for i = 0, size - 1 do callback(collection:get(i)) end end local function keepTowBarVehiclesFreeRolling() freeRollTickCounter = freeRollTickCounter + 1 if freeRollTickCounter < FreeRollTickInterval then return end freeRollTickCounter = 0 local cell = getCell() if not cell then return end local vehicles = cell:getVehicles() if not vehicles then return end forEachCollectionItem(vehicles, function(vehicle) local modData = vehicle and vehicle:getModData() or nil if isActiveTowBarTowedVehicle(vehicle, modData) then applyFreeRollingTowState(vehicle) end end) end function TowBarMod.Hook.attachByTowBarAction(playerObj, towingVehicle, towedVehicle) if playerObj == nil or towingVehicle == nil or towedVehicle == nil then return end local item = getTowBarItem(playerObj) if item == nil then return end if #(TowBarMod.Utils.getHookTypeVariants(towingVehicle, towedVehicle, true)) == 0 then return end local hookPoint = towedVehicle:getAttachmentWorldPos("trailerfront", TowBarMod.Utils.tempVector1) if hookPoint == nil then return end ISTimedActionQueue.add(TowBarCustomPathFind:pathToLocationF(playerObj, hookPoint:x(), hookPoint:y(), hookPoint:z())) if not playerObj:getInventory():contains("TowBar.TowBar") then ISTimedActionQueue.add(ISInventoryTransferAction:new(playerObj, item, item:getContainer(), playerObj:getInventory(), nil)) end local storePrim = playerObj:getPrimaryHandItem() if storePrim == nil or storePrim ~= item then ISTimedActionQueue.add(ISEquipWeaponAction:new(playerObj, item, 12, true)) end ISTimedActionQueue.add(TowBarHookVehicle:new(playerObj, 300, TowBarMod.Config.lowLevelAnimation)) hookPoint = towingVehicle:getAttachmentWorldPos("trailer", TowBarMod.Utils.tempVector1) if hookPoint == nil then return end ISTimedActionQueue.add(TowBarCustomPathFind:pathToLocationF(playerObj, hookPoint:x(), hookPoint:y(), hookPoint:z())) ISTimedActionQueue.add(TowBarHookVehicle:new( playerObj, 100, TowBarMod.Config.lowLevelAnimation, TowBarMod.Hook.performAttachTowBar, towingVehicle, towedVehicle, "trailer", "trailerfront" )) end function TowBarMod.Hook.deattachTowBarAction(playerObj, vehicle) local towingVehicle = vehicle local towedVehicle = vehicle and vehicle:getVehicleTowing() or nil if vehicle and vehicle:getVehicleTowedBy() then towingVehicle = vehicle:getVehicleTowedBy() towedVehicle = vehicle end if towingVehicle == nil or towedVehicle == nil then return end local localPoint = towingVehicle:getAttachmentLocalPos(towingVehicle:getTowAttachmentSelf(), TowBarMod.Utils.tempVector1) local shift = 0 if towingVehicle:getModData()["isChangedTowedAttachment"] then shift = localPoint:z() > 0 and -1 or 1 end local hookPoint = towingVehicle:getWorldPos(localPoint:x(), localPoint:y(), localPoint:z() + shift, TowBarMod.Utils.tempVector2) if hookPoint == nil then return end ISTimedActionQueue.add(TowBarCustomPathFind:pathToLocationF(playerObj, hookPoint:x(), hookPoint:y(), hookPoint:z())) local storePrim = playerObj:getPrimaryHandItem() if storePrim ~= nil then ISTimedActionQueue.add(ISUnequipAction:new(playerObj, storePrim, 12)) end ISTimedActionQueue.add(TowBarHookVehicle:new(playerObj, 100, TowBarMod.Config.lowLevelAnimation)) localPoint = towedVehicle:getAttachmentLocalPos(towedVehicle:getTowAttachmentSelf(), TowBarMod.Utils.tempVector1) shift = 0 if towedVehicle:getModData()["isChangedTowedAttachment"] then shift = localPoint:z() > 0 and -1 or 1 end hookPoint = towedVehicle:getWorldPos(localPoint:x(), localPoint:y(), localPoint:z() + shift, TowBarMod.Utils.tempVector2) if hookPoint == nil then return end ISTimedActionQueue.add(TowBarCustomPathFind:pathToLocationF(playerObj, hookPoint:x(), hookPoint:y(), hookPoint:z())) ISTimedActionQueue.add(TowBarHookVehicle:new( playerObj, 300, TowBarMod.Config.lowLevelAnimation, TowBarMod.Hook.performDetachTowBar, towingVehicle, towedVehicle )) end function TowBarMod.Hook.OnSpawnVehicle(vehicle) -- Server persistence snapshots own attach recovery after vehicle streaming. end function TowBarMod.Hook.OnGameStart() -- Server persistence broadcasts the same attach snapshot used by a fresh pair. end --------------------------------------------------------------------------- --- Dev / debug helpers --------------------------------------------------------------------------- function TowBarMod.Hook.devShowAllTowbarModels(playerObj, vehicle) if not vehicle then return end local normalPart = vehicle:getPartById("towbar") local largePart = vehicle:getPartById("towbarLarge") local ki5Part = vehicle:getPartById("towbarKI5") if normalPart == nil and largePart == nil and ki5Part == nil then print("[TowBar DEV] No towbar model part found on vehicle " .. tostring(vehicle:getScriptName())) return end local script = vehicle:getScript() local chassisZ = script and script:getPhysicsChassisShape():z() or 0 local halfZ = chassisZ / 2 local index = 0 if script then index = getTowbarModelSlot(script) end local selectedPart = "towbar" print("[TowBar DEV] Vehicle: " .. tostring(vehicle:getScriptName())) print("[TowBar DEV] chassisShape.z = " .. tostring(chassisZ) .. ", half = " .. tostring(halfZ)) print("[TowBar DEV] frontEdgeZ = " .. tostring(script and getTowbarFrontEdgeZ(script) or nil) .. ", part = " .. selectedPart) print("[TowBar DEV] Formula picks index = " .. tostring(index) .. " (towbar" .. tostring(index) .. " at Z offset " .. tostring(1.0 + index * 0.1) .. ")") print("[TowBar DEV] Showing towbar0..towbar23 on all parts") for j = 0, TowbarVariantSize - 1 do if normalPart then normalPart:setModelVisible("towbar" .. j, true) end if largePart then largePart:setModelVisible("towbar" .. j, true) end if ki5Part then ki5Part:setModelVisible("towbar" .. j, true) end end vehicle:doDamageOverlay() end function TowBarMod.Hook.devHideAllTowbarModels(playerObj, vehicle) if not vehicle then return end local normalPart = vehicle:getPartById("towbar") local largePart = vehicle:getPartById("towbarLarge") local ki5Part = vehicle:getPartById("towbarKI5") if normalPart == nil and largePart == nil and ki5Part == nil then print("[TowBar DEV] No towbar model part found on vehicle " .. tostring(vehicle:getScriptName())) return end print("[TowBar DEV] Hiding ALL towbar models on " .. tostring(vehicle:getScriptName())) for j = 0, TowbarVariantSize - 1 do if normalPart then normalPart:setModelVisible("towbar" .. j, false) end if largePart then largePart:setModelVisible("towbar" .. j, false) end if ki5Part then ki5Part:setModelVisible("towbar" .. j, false) end end vehicle:doDamageOverlay() end function TowBarMod.Hook.devShowSingleTowbar(playerObj, vehicle, index) if not vehicle then return end local normalPart = vehicle:getPartById("towbar") local largePart = vehicle:getPartById("towbarLarge") local ki5Part = vehicle:getPartById("towbarKI5") if normalPart == nil and largePart == nil and ki5Part == nil then print("[TowBar DEV] No towbar model part found on vehicle " .. tostring(vehicle:getScriptName())) return end local localIndex = math.max(0, math.min(TowbarMaxIndex, index % TowbarVariantSize)) local selectedPartId = "towbar" if index >= TowbarVariantSize * 2 then selectedPartId = "towbarKI5" elseif index >= TowbarVariantSize then selectedPartId = "towbarLarge" end for j = 0, TowbarVariantSize - 1 do if normalPart then normalPart:setModelVisible("towbar" .. j, false) end if largePart then largePart:setModelVisible("towbar" .. j, false) end if ki5Part then ki5Part:setModelVisible("towbar" .. j, false) end end local part = selectedPartId == "towbarKI5" and ki5Part or (selectedPartId == "towbarLarge" and largePart or normalPart) if part == nil then part = normalPart or largePart or ki5Part end print("[TowBar DEV] Showing only towbar" .. tostring(localIndex) .. " on part " .. selectedPartId .. " (Z offset " .. tostring(1.0 + localIndex * 0.1) .. ") on " .. tostring(vehicle:getScriptName())) if part then part:setModelVisible("towbar" .. localIndex, true) end vehicle:doDamageOverlay() end Events.OnSpawnVehicleEnd.Add(TowBarMod.Hook.OnSpawnVehicle) if Events.OnGameStart then Events.OnGameStart.Add(TowBarMod.Hook.OnGameStart) end Events.OnTick.Add(keepTowBarVehiclesFreeRolling)