Working 42.20 MP

This commit is contained in:
2026-08-19 12:12:12 -04:00
parent e403323017
commit e99f3bbff9
22 changed files with 2262 additions and 825 deletions
+104
View File
@@ -0,0 +1,104 @@
if isServer() then return end
if not TowBarMod then TowBarMod = {} end
TowBarMod.RigidTow = TowBarMod.RigidTow or {}
local RigidTow = TowBarMod.RigidTow
local function isExactPairLinked(towingVehicle, towedVehicle)
return towingVehicle and towedVehicle
and towingVehicle:getVehicleTowing() == towedVehicle
and towedVehicle:getVehicleTowedBy() == towingVehicle
end
local function hasConflictingLink(vehicle, expectedOther)
if not vehicle or not expectedOther then return true end
local towing = vehicle:getVehicleTowing()
local towedBy = vehicle:getVehicleTowedBy()
return (towing ~= nil and towing ~= expectedOther)
or (towedBy ~= nil and towedBy ~= expectedOther)
end
local function attachmentExists(vehicle, attachmentId)
if not vehicle or type(attachmentId) ~= "string" then return false end
local script = vehicle:getScript()
return script ~= nil and script:getAttachmentById(attachmentId) ~= nil
end
local function breakExactPair(towingVehicle, towedVehicle)
if not towingVehicle or not towedVehicle then return end
if towingVehicle:getVehicleTowing() == towedVehicle
or towingVehicle:getVehicleTowedBy() == towedVehicle then
towingVehicle:breakConstraint(true, true)
elseif towedVehicle:getVehicleTowing() == towingVehicle
or towedVehicle:getVehicleTowedBy() == towingVehicle then
towedVehicle:breakConstraint(true, true)
end
end
function RigidTow.attach(towingVehicle, towedVehicle, attachmentA, attachmentB)
if not towingVehicle or not towedVehicle or towingVehicle == towedVehicle then return false end
if hasConflictingLink(towingVehicle, towedVehicle)
or hasConflictingLink(towedVehicle, towingVehicle) then
return false
end
if not attachmentExists(towingVehicle, attachmentA)
or not attachmentExists(towedVehicle, attachmentB) then
return false
end
if TowBarMod.Utils and TowBarMod.Utils.updateAttachmentsForRigidTow then
TowBarMod.Utils.updateAttachmentsForRigidTow(
towingVehicle, towedVehicle, attachmentA, attachmentB
)
end
-- Match the proven wrecker sequence exactly. The fake trailer script is
-- used only while Bullet creates the rigid constraint; leaving it active
-- until the broader post-attach hook is unreliable in multiplayer because
-- that hook may return early while vehicle state is still replicating.
local towedModData = towedVehicle:getModData()
local currentScript = towedVehicle:getScriptName()
local originalScript = towedModData and towedModData.towBarOriginalScriptName or nil
if originalScript == nil and currentScript ~= "notTowingA_Trailer" then
originalScript = currentScript
if towedModData then
towedModData.towBarOriginalScriptName = originalScript
end
end
if originalScript == nil or originalScript == "notTowingA_Trailer" then
return false
end
breakExactPair(towingVehicle, towedVehicle)
if TowBarMod.Hook and TowBarMod.Hook.applyFreeRollingTowState then
TowBarMod.Hook.applyFreeRollingTowState(towedVehicle)
elseif TowBarMod.Hook and TowBarMod.Hook.setVehiclePostAttach then
-- Compatibility fallback for older hook modules and the regression
-- harness. Current builds expose the narrower free-roll helper.
TowBarMod.Hook.setVehiclePostAttach(nil, towedVehicle, towingVehicle)
end
if TowBarMod.Hook and TowBarMod.Hook.setVehicleScriptWithTowBarHidden then
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, "notTowingA_Trailer")
else
towedVehicle:setScriptName("notTowingA_Trailer")
end
towingVehicle:addPointConstraint(nil, towedVehicle, attachmentA, attachmentB, true)
if TowBarMod.Hook and TowBarMod.Hook.setVehicleScriptWithTowBarHidden then
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, originalScript)
else
towedVehicle:setScriptName(originalScript)
end
if TowBarMod.Hook and TowBarMod.Hook.setVehiclePostAttach then
-- Multiplayer updates the reciprocal towing getters after this Lua
-- call. The authoritative sync already validated the pair, so finalize
-- with the known towing vehicle instead of treating that delay as a
-- failed constraint submission.
TowBarMod.Hook.setVehiclePostAttach(nil, towedVehicle, towingVehicle)
end
return true
end
RigidTow.isExactPairLinked = isExactPairLinked
RigidTow.breakExactPair = breakExactPair
+201 -156
View File
@@ -5,6 +5,34 @@ TowBarMod.Sync = TowBarMod.Sync or {}
if TowBarMod.Sync._towSyncClientLoaded then return end
TowBarMod.Sync._towSyncClientLoaded = true
TowBarMod.Sync.appliedPairs = TowBarMod.Sync.appliedPairs or {}
TowBarMod.Sync.desiredPairs = TowBarMod.Sync.desiredPairs or {}
if not TowBarMod.RigidTow or not TowBarMod.RigidTow.attach then
require("TowBar/RigidTow")
end
local Sync = TowBarMod.Sync
local function argsPairKey(args)
if type(args) ~= "table" then return nil end
local vehicleA = tonumber(args.vehicleA)
local vehicleB = tonumber(args.vehicleB)
if not vehicleA or not vehicleB then return nil end
return tostring(vehicleA) .. ":" .. tostring(vehicleB)
end
local function copyAttachArgs(args)
return {
vehicleA = tonumber(args.vehicleA),
vehicleB = tonumber(args.vehicleB),
attachmentA = args.attachmentA,
attachmentB = args.attachmentB
}
end
local function pairKey(vehicleA, vehicleB)
return tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId())
end
local function pairKeyContainsVehicle(key, vehicleId)
local id = tostring(vehicleId)
@@ -15,201 +43,218 @@ end
local function clearAppliedPairForVehicle(vehicle)
if not vehicle then return end
local vehicleId = vehicle:getId()
for key in pairs(TowBarMod.Sync.appliedPairs) do
for key in pairs(Sync.appliedPairs) do
if pairKeyContainsVehicle(key, vehicleId) then
TowBarMod.Sync.appliedPairs[key] = nil
Sync.appliedPairs[key] = nil
end
end
end
local function resolveVehicle(id)
if not id then return nil end
return getVehicleById(id)
local function resolvePair(args)
if type(args) ~= "table" then return nil, nil end
local vehicleA = args.vehicleA and getVehicleById(tonumber(args.vehicleA)) or nil
local vehicleB = args.vehicleB and getVehicleById(tonumber(args.vehicleB)) or nil
return vehicleA, vehicleB
end
local function ensureAttachment(vehicle, attachmentId)
if not vehicle or not attachmentId then return false end
local function isPairLinked(vehicleA, vehicleB)
return vehicleA and vehicleB
and vehicleA:getVehicleTowing() == vehicleB
and vehicleB:getVehicleTowedBy() == vehicleA
end
local script = vehicle:getScript()
if not script then return false end
if script:getAttachmentById(attachmentId) ~= nil then return true end
local function hasConflictingLink(vehicle, expectedOther)
if not vehicle or not expectedOther then return false end
local towing = vehicle:getVehicleTowing()
local towedBy = vehicle:getVehicleTowedBy()
return (towing ~= nil and towing ~= expectedOther)
or (towedBy ~= nil and towedBy ~= expectedOther)
end
local wheelCount = script:getWheelCount()
local yOffset = -0.5
if wheelCount > 0 then
local wheel = script:getWheel(0)
if wheel and wheel:getOffset() then
yOffset = wheel:getOffset():y() + 0.1
end
local function preparePairState(vehicleA, vehicleB, attachmentA, attachmentB)
local towingMd = vehicleA:getModData()
local towedMd = vehicleB:getModData()
if not towingMd or not towedMd then return false end
local currentScript = vehicleB:getScriptName()
if towedMd.towBarOriginalScriptName == nil and currentScript ~= "notTowingA_Trailer" then
towedMd.towBarOriginalScriptName = currentScript
end
if towedMd.towBarOriginalMass == nil then
towedMd.towBarOriginalMass = vehicleB:getMass()
end
if towedMd.towBarOriginalBrakingForce == nil then
towedMd.towBarOriginalBrakingForce = vehicleB:getBrakingForce()
end
local chassis = script:getPhysicsChassisShape()
if not chassis then return false end
local attach = ModelAttachment.new(attachmentId)
if attachmentId == "trailer" then
attach:getOffset():set(0, yOffset, -chassis:z() / 2 - 0.1)
attach:setZOffset(-1)
else
attach:getOffset():set(0, yOffset, chassis:z() / 2 + 0.1)
attach:setZOffset(1)
end
script:addAttachment(attach)
towingMd.isTowingByTowBar = true
towingMd.towed = false
towingMd.towBarTowedVehicleId = vehicleB:getId()
towingMd.towBarTowingVehicleId = nil
towingMd.towBarExpectedAttachment = attachmentA
towedMd.isTowingByTowBar = true
towedMd.towed = true
towedMd.towBarTowedVehicleId = nil
towedMd.towBarTowingVehicleId = vehicleA:getId()
towedMd.towBarExpectedAttachment = attachmentB
vehicleA:transmitModData()
vehicleB:transmitModData()
return true
end
local function isLinked(vehicleA, vehicleB)
local function isLocalDriver(vehicleA, playerObj)
if not vehicleA or not playerObj then return false end
return vehicleA:isDriver(playerObj)
end
local function applyAttachSync(args, playerObj, forceReattach)
local desiredKey = argsPairKey(args)
if not desiredKey then return false end
Sync.desiredPairs[desiredKey] = copyAttachArgs(args)
local vehicleA, vehicleB = resolvePair(args)
if not vehicleA or not vehicleB then return false end
return vehicleA:getVehicleTowing() == vehicleB and vehicleB:getVehicleTowedBy() == vehicleA
end
local function reconcilePairState(vehicleA, vehicleB, attachmentA, attachmentB)
if TowBarMod.Utils and TowBarMod.Utils.updateAttachmentsForRigidTow then
TowBarMod.Utils.updateAttachmentsForRigidTow(vehicleA, vehicleB, attachmentA, attachmentB)
end
local towingMd = vehicleA:getModData()
local towedMd = vehicleB:getModData()
local currentScript = vehicleB:getScriptName()
if towingMd then
towingMd["isTowingByTowBar"] = true
towingMd["towed"] = false
towingMd["towBarTowedVehicleId"] = vehicleB:getId()
towingMd["towBarTowingVehicleId"] = nil
towingMd["towBarExpectedAttachment"] = attachmentA
vehicleA:transmitModData()
end
if towedMd then
if towedMd.towBarOriginalScriptName == nil and currentScript ~= "notTowingA_Trailer" then
towedMd.towBarOriginalScriptName = currentScript
end
if towedMd.towBarOriginalMass == nil then
towedMd.towBarOriginalMass = vehicleB:getMass()
end
if towedMd.towBarOriginalBrakingForce == nil then
towedMd.towBarOriginalBrakingForce = vehicleB:getBrakingForce()
end
towedMd["isTowingByTowBar"] = true
towedMd["towed"] = true
towedMd["towBarTowedVehicleId"] = nil
towedMd["towBarTowingVehicleId"] = vehicleA:getId()
towedMd["towBarExpectedAttachment"] = attachmentB
vehicleB:transmitModData()
end
if TowBarMod.Hook and TowBarMod.Hook.setVehicleScriptWithTowBarHidden then
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(vehicleB, "notTowingA_Trailer")
end
if TowBarMod.Hook and TowBarMod.Hook.setVehiclePostAttach then
pcall(TowBarMod.Hook.setVehiclePostAttach, nil, vehicleB)
end
end
local breakTowBarPair
local function applyAttachSync(args)
if not args then return end
local vehicleA = resolveVehicle(args.vehicleA)
local vehicleB = resolveVehicle(args.vehicleB)
if not vehicleA or not vehicleB then return end
local attachmentA = args.attachmentA or "trailer"
local attachmentB = args.attachmentB or "trailerfront"
if not ensureAttachment(vehicleA, attachmentA) or not ensureAttachment(vehicleB, attachmentB) then
return
if not preparePairState(vehicleA, vehicleB, attachmentA, attachmentB) then return false end
local localPlayer = playerObj
if not localPlayer and type(getPlayer) == "function" then localPlayer = getPlayer() end
if not isLocalDriver(vehicleA, localPlayer) then
-- Portable installation happens while the player is outside. Keep the
-- server/native relation until this client becomes the towing vehicle's
-- physics owner; otherwise the local-only rigid add is discarded.
return true
end
if hasConflictingLink(vehicleA, vehicleB) or hasConflictingLink(vehicleB, vehicleA) then
return false
end
local key = tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId())
if TowBarMod.Sync.appliedPairs[key] and not isLinked(vehicleA, vehicleB) then
-- Vehicle streaming can remove the native constraint while the Lua
-- module remains loaded. Allow the authoritative recovery sync to
-- rebuild local physics for the returning pair.
TowBarMod.Sync.appliedPairs[key] = nil
end
if not TowBarMod.Sync.appliedPairs[key] then
-- The server initially creates a normal Build 42 tow relation. It may
-- also restore one from the save. Replace that native rope exactly
-- once with this mod's rigid fake-trailer constraint.
breakTowBarPair(vehicleA, vehicleB)
if TowBarMod.Hook and TowBarMod.Hook.setVehicleScriptWithTowBarHidden then
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(vehicleB, "notTowingA_Trailer")
end
-- The final true keeps this a local physics rebuild. The server owns
-- the persisted logical relation and must not receive a detach/attach race.
vehicleA:addPointConstraint(nil, vehicleB, attachmentA, attachmentB, true)
TowBarMod.Sync.appliedPairs[key] = true
end
reconcilePairState(vehicleA, vehicleB, attachmentA, attachmentB)
end
local function hasConflictingTowLink(vehicle, expectedOther)
if not vehicle or not expectedOther then return false end
local towing = vehicle:getVehicleTowing()
local towedBy = vehicle:getVehicleTowedBy()
if (towing ~= nil and towing ~= expectedOther)
or (towedBy ~= nil and towedBy ~= expectedOther) then
local key = pairKey(vehicleA, vehicleB)
if not forceReattach and isPairLinked(vehicleA, vehicleB) and Sync.appliedPairs[key] then
return true
end
-- Also reject a delayed detach while a new tow is being established but
-- has not created its physical constraint yet.
local modData = vehicle:getModData()
if not modData then return false end
local expectedOtherId = expectedOther:getId()
return (modData["towBarTowedVehicleId"] ~= nil
and modData["towBarTowedVehicleId"] ~= expectedOtherId)
or (modData["towBarTowingVehicleId"] ~= nil
and modData["towBarTowingVehicleId"] ~= expectedOtherId)
end
breakTowBarPair = function(vehicleA, vehicleB)
if not vehicleA or not vehicleB then return end
local vehicleAReferencesB = vehicleA:getVehicleTowing() == vehicleB
or vehicleA:getVehicleTowedBy() == vehicleB
local vehicleBReferencesA = vehicleB:getVehicleTowing() == vehicleA
or vehicleB:getVehicleTowedBy() == vehicleA
if vehicleAReferencesB then
vehicleA:breakConstraint(true, true)
elseif vehicleBReferencesA then
vehicleB:breakConstraint(true, true)
-- This is deliberately the same one-shot method as WreckerSyncClient:
-- replace any exact native relation with one client-local rigid relation.
if TowBarMod.RigidTow.attach(vehicleA, vehicleB, attachmentA, attachmentB) ~= true then
return false
end
Sync.appliedPairs[key] = true
return true
end
local function applyDetachSync(args)
if not args then return end
local vehicleA = resolveVehicle(args.vehicleA)
local vehicleB = resolveVehicle(args.vehicleB)
if not vehicleA or not vehicleB then return end
if hasConflictingTowLink(vehicleA, vehicleB) or hasConflictingTowLink(vehicleB, vehicleA) then
return
local desiredKey = argsPairKey(args)
local wasApplied = desiredKey and Sync.appliedPairs[desiredKey] == true
if desiredKey then
Sync.desiredPairs[desiredKey] = nil
Sync.appliedPairs[desiredKey] = nil
end
breakTowBarPair(vehicleA, vehicleB)
TowBarMod.Sync.appliedPairs[tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId())] = nil
local vehicleA, vehicleB = resolvePair(args)
if not vehicleA or not vehicleB then return end
if hasConflictingLink(vehicleA, vehicleB) or hasConflictingLink(vehicleB, vehicleA) then return end
local key = pairKey(vehicleA, vehicleB)
Sync.appliedPairs[key] = nil
if wasApplied and TowBarMod.RigidTow and TowBarMod.RigidTow.breakExactPair then
TowBarMod.RigidTow.breakExactPair(vehicleA, vehicleB)
elseif wasApplied and isPairLinked(vehicleA, vehicleB) then
vehicleA:breakConstraint(true, true)
end
if TowBarMod.Hook and TowBarMod.Hook.cleanupDetachedTowBar then
pcall(TowBarMod.Hook.cleanupDetachedTowBar, vehicleA, vehicleB)
TowBarMod.Hook.cleanupDetachedTowBar(vehicleA, vehicleB)
end
end
local function onServerCommand(module, command, args)
if module ~= "towbar" then return end
Sync.applyAttachSync = applyAttachSync
Sync.applyDetachSync = applyDetachSync
local function retryDesiredPairsForDriver(character)
if not character then return end
local desired = {}
for _, args in pairs(Sync.desiredPairs) do
local vehicleA = args.vehicleA and getVehicleById(tonumber(args.vehicleA)) or nil
if vehicleA and vehicleA:isDriver(character) then
desired[#desired + 1] = args
end
end
for i = 1, #desired do
applyAttachSync(desired[i], character)
end
end
local function getPersistedPairArgsForDriver(character)
if not character or type(character.getVehicle) ~= "function" then return nil end
local vehicleA = character:getVehicle()
if not vehicleA or not vehicleA:isDriver(character) then return nil end
local towingMd = vehicleA:getModData()
if not towingMd or towingMd.isTowingByTowBar ~= true or towingMd.towed == true then return nil end
local vehicleBId = tonumber(towingMd.towBarTowedVehicleId)
local vehicleB = vehicleBId and getVehicleById(vehicleBId) or nil
if not vehicleB then return nil end
local towedMd = vehicleB:getModData()
if not towedMd or towedMd.isTowingByTowBar ~= true or towedMd.towed ~= true
or tonumber(towedMd.towBarTowingVehicleId) ~= vehicleA:getId() then
return nil
end
return {
vehicleA = vehicleA:getId(),
vehicleB = vehicleB:getId(),
attachmentA = towingMd.towBarExpectedAttachment or "trailer",
attachmentB = towedMd.towBarExpectedAttachment or "trailerfront"
}
end
local function forceReattachForDriver(character)
if not character then return end
local persistedArgs = getPersistedPairArgsForDriver(character)
if persistedArgs then
applyAttachSync(persistedArgs, character, true)
return
end
-- Initial installation may have the authoritative command before all
-- reciprocal modData reaches this client. Retained server args are still
-- safe because only a driver-owned exact pair can pass applyAttachSync.
local desired = {}
for _, args in pairs(Sync.desiredPairs) do
local vehicleA = args.vehicleA and getVehicleById(tonumber(args.vehicleA)) or nil
if vehicleA and vehicleA:isDriver(character) then desired[#desired + 1] = args end
end
for i = 1, #desired do
applyAttachSync(desired[i], character, true)
end
end
local function onSpawnVehicle(vehicle)
clearAppliedPairForVehicle(vehicle)
if type(getPlayer) == "function" then
retryDesiredPairsForDriver(getPlayer())
end
end
Events.OnServerCommand.Add(function(module, command, args)
if module ~= "towbar" then return end
if command == "forceAttachSync" then
applyAttachSync(args)
elseif command == "forceDetachSync" or command == "spontaneousDetachSync" then
applyDetachSync(args)
end
end
end)
TowBarMod.Sync.applyAttachSync = applyAttachSync
TowBarMod.Sync.applyDetachSync = applyDetachSync
Events.OnServerCommand.Add(onServerCommand)
if Events.OnSpawnVehicleEnd then
Events.OnSpawnVehicleEnd.Add(clearAppliedPairForVehicle)
Events.OnSpawnVehicleEnd.Add(onSpawnVehicle)
end
if Events.OnEnterVehicle then
Events.OnEnterVehicle.Add(forceReattachForDriver)
end
if Events.OnSwitchVehicleSeat then
Events.OnSwitchVehicleSeat.Add(forceReattachForDriver)
end
+128 -285
View File
@@ -1,11 +1,9 @@
if not TowBarMod then TowBarMod = {} end
if not TowBarMod.Hook then TowBarMod.Hook = {} end
require("TowBar/VehicleCompatibility")
local DefaultTowBarTowMass = 200
local AutoReattachCooldownHours = 1 / 7200 -- 0.5 seconds
TowBarMod.Hook.lastAutoReattachAtByVehicle = TowBarMod.Hook.lastAutoReattachAtByVehicle or {}
local AutoReattachPlayerCooldownHours = 1 / 14400 -- 0.25 seconds
TowBarMod.Hook.lastAutoReattachAtByPlayer = TowBarMod.Hook.lastAutoReattachAtByPlayer or {}
local FreeRollTickInterval = 15
local freeRollTickCounter = 0
@@ -44,7 +42,7 @@ local function applyFreeRollingTowState(vehicle)
modData.towBarOriginalBrakingForce = vehicle:getBrakingForce()
end
storeOriginalVehicleCall(vehicle, modData, "towBarOriginalParkingBrakeOn", "isParkingBrakeOn")
storeOriginalVehicleCall(vehicle, modData, "towBarOriginalParkingBrake", "isParkingBrake")
storeOriginalVehicleCall(vehicle, modData, "towBarOriginalParkingBrake", "getParkingBrake")
storeOriginalVehicleCall(vehicle, modData, "towBarOriginalHandbrake", "isHandbrake")
local configuredTowMass = TowBarMod.Config and tonumber(TowBarMod.Config.towedVehicleRollingMass)
@@ -61,10 +59,13 @@ local function applyFreeRollingTowState(vehicle)
tryVehicleCall(vehicle, "setHandbrake", false)
end
vehicle:constraintChanged()
vehicle:updateTotalMass()
-- 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
@@ -88,25 +89,6 @@ local function restoreFreeRollingTowState(vehicle, modData)
vehicle:updateTotalMass()
end
local function isTowBarTowPair(towingVehicle, towedVehicle)
if not towingVehicle or not towedVehicle then return false end
local towingModData = towingVehicle:getModData()
local towedModData = towedVehicle:getModData()
if not towingModData or not towedModData then return false end
if towingModData["isTowingByTowBar"] and towedModData["isTowingByTowBar"] and towedModData["towed"] then
return true
end
-- Rejoin fallback: original towbar state on the towed vehicle is enough to reapply rigid spacing.
if towedModData.towBarOriginalScriptName ~= nil then
return true
end
return false
end
local function getTowBarItem(playerObj)
if not playerObj then return nil end
local inventory = playerObj:getInventory()
@@ -116,15 +98,8 @@ end
local function sendTowAttachCommand(playerObj, args)
if not playerObj or not args then return end
-- MP-safe/server-authoritative attach path (Landtrain style).
if isClient() and isMultiplayer() then
sendClientCommand(playerObj, "towbar", "attachTowBar", args)
return
end
-- Keep vanilla attach path for SP/local behavior.
sendClientCommand(playerObj, "vehicle", "attachTrailer", args)
-- SP and MP now share the same authoritative attach lifecycle.
sendClientCommand(playerObj, "towbar", "attachTowBar", args)
end
local TowbarVariantSize = 24
@@ -138,6 +113,8 @@ 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
@@ -178,16 +155,87 @@ local function getTowbarModelSlot(script)
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")
if normalPart == nil and largePart == nil then return end
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
@@ -201,8 +249,16 @@ local function setTowBarModelVisible(vehicle, isVisible)
return
end
local index = getTowbarModelSlot(script)
local part = normalPart
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
@@ -259,23 +315,6 @@ local function resolveTowAttachmentsForPair(towingVehicle, towedVehicle, towedMo
return attachmentA, attachmentB
end
local function hasTowBarTowState(modData)
if not modData then
return false
end
if modData["isTowingByTowBar"] and modData["towed"] then
return true
end
-- Rejoin fallback: legacy saves may only have the original-script marker.
if modData.towBarOriginalScriptName ~= nil then
return true
end
return false
end
local function isActiveTowBarTowedVehicle(vehicle, modData)
if not vehicle or not modData then
return false
@@ -293,142 +332,22 @@ local function isActiveTowBarTowedVehicle(vehicle, modData)
return false
end
local function reattachTowBarPair(playerObj, towingVehicle, towedVehicle, requireDriver)
if not playerObj or not towingVehicle or not towedVehicle then
return false
end
if requireDriver and not towingVehicle:isDriver(playerObj) then
return false
end
local towingModData = towingVehicle:getModData()
local towedModData = towedVehicle:getModData()
if not towingModData or not towedModData then
return false
end
if requireDriver then
if not isTowBarTowPair(towingVehicle, towedVehicle) then
return false
end
else
if not isActiveTowBarTowedVehicle(towedVehicle, towedModData) then
return false
end
end
local attachmentA, attachmentB = resolveTowAttachmentsForPair(towingVehicle, towedVehicle, towedModData)
if not attachmentA or not attachmentB then
return false
end
local towingScript = towingVehicle:getScript()
local towedScript = towedVehicle:getScript()
if not towingScript or not towedScript then
return false
end
if not towingScript:getAttachmentById(attachmentA) or not towedScript:getAttachmentById(attachmentB) then
return false
end
TowBarMod.Utils.updateAttachmentsForRigidTow(towingVehicle, towedVehicle, attachmentA, attachmentB)
towedModData.towBarOriginalScriptName = towedModData.towBarOriginalScriptName or towedVehicle:getScriptName()
applyFreeRollingTowState(towedVehicle)
towingModData["isTowingByTowBar"] = true
towingModData["towed"] = false
towingModData["towBarTowedVehicleId"] = towedVehicle:getId()
towingModData["towBarTowingVehicleId"] = nil
towingModData["towBarExpectedAttachment"] = attachmentA
towedModData["isTowingByTowBar"] = true
towedModData["towed"] = true
towedModData["towBarTowedVehicleId"] = nil
towedModData["towBarTowingVehicleId"] = towingVehicle:getId()
towedModData["towBarExpectedAttachment"] = attachmentB
towingVehicle:transmitModData()
towedVehicle:transmitModData()
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, "notTowingA_Trailer")
local args = {
vehicleA = towingVehicle:getId(),
vehicleB = towedVehicle:getId(),
attachmentA = attachmentA,
attachmentB = attachmentB
}
sendTowAttachCommand(playerObj, args)
ISTimedActionQueue.add(TowBarScheduleAction:new(playerObj, 10, TowBarMod.Hook.setVehiclePostAttach, towedVehicle))
return true
end
local function reattachTowBarPairAfterCleanDetach(playerObj, towingVehicle, towedVehicle, requireDriver)
if not playerObj or not towingVehicle or not towedVehicle then
return false
end
if requireDriver and not towingVehicle:isDriver(playerObj) then
return false
end
local detachArgs = {
towingVehicle = towingVehicle:getId(),
vehicle = towedVehicle:getId()
}
sendClientCommand(playerObj, "towbar", "detachTowBar", detachArgs)
-- World load/spawn can restore constraints in a bad state. Reattach one
-- short tick later so the detach is fully applied first.
ISTimedActionQueue.add(TowBarScheduleAction:new(
playerObj,
1,
reattachTowBarPair,
towingVehicle,
towedVehicle,
requireDriver
))
return true
end
local function recoverTowBarVehicleAfterLoad(playerObj, vehicle, retriesLeft)
if not vehicle then return end
local modData = vehicle:getModData()
if not hasTowBarTowState(modData) then
return
end
local retries = tonumber(retriesLeft) or 0
local localPlayer = playerObj or getPlayer()
local towingVehicle = vehicle:getVehicleTowedBy()
if towingVehicle then
-- Apply rigid spacing as soon as the tow link exists to avoid a visible
-- bumper-to-bumper snap. Server/SP reconciliation owns recovery and
-- never refunds or consumes an item while loading a save.
TowBarMod.Hook.setVehiclePostAttach(nil, vehicle)
return
end
if localPlayer and retries > 0 then
-- During world load, tow links can become available a few ticks later.
ISTimedActionQueue.add(TowBarScheduleAction:new(localPlayer, 10, recoverTowBarVehicleAfterLoad, vehicle, retries - 1))
return
end
-- Leave reciprocal saved state untouched. The authoritative audit will
-- reconnect it when both vehicle chunks are loaded.
end
function TowBarMod.Hook.setVehiclePostAttach(playerObj, towedVehicle, retriesLeft)
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
if towedModData and towedModData.towBarOriginalScriptName then
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
local towingVehicle = towedVehicle:getVehicleTowedBy()
if towingVehicle then
local attachmentA, attachmentB = resolveTowAttachmentsForPair(towingVehicle, towedVehicle, towedModData)
if attachmentA and attachmentB then
@@ -443,7 +362,6 @@ function TowBarMod.Hook.setVehiclePostAttach(playerObj, towedVehicle, retriesLef
towingVehicle:transmitModData()
towedVehicle:transmitModData()
end
TowBarMod.Utils.updateAttachmentsForRigidTow(towingVehicle, towedVehicle, attachmentA, attachmentB)
end
end
@@ -459,35 +377,9 @@ function TowBarMod.Hook.performAttachTowBar(playerObj, towingVehicle, towedVehic
if #(TowBarMod.Utils.getHookTypeVariants(towingVehicle, towedVehicle, true)) == 0 then return end
local towBarItem = getTowBarItem(playerObj)
if towBarItem ~= nil and not (isClient() and isMultiplayer()) then
sendClientCommand(playerObj, "towbar", "consumeTowBar", { itemId = towBarItem:getID() })
end
if towBarItem == nil then return end
playerObj:setPrimaryHandItem(nil)
TowBarMod.Utils.updateAttachmentsForRigidTow(towingVehicle, towedVehicle, attachmentA, attachmentB)
local towingModData = towingVehicle:getModData()
local towedModData = towedVehicle:getModData()
towedModData.towBarOriginalScriptName = towedVehicle:getScriptName()
applyFreeRollingTowState(towedVehicle)
towingModData["isTowingByTowBar"] = true
towingModData["towed"] = false
towingModData["towBarTowedVehicleId"] = towedVehicle:getId()
towingModData["towBarTowingVehicleId"] = nil
towingModData["towBarExpectedAttachment"] = attachmentA
towedModData["isTowingByTowBar"] = true
towedModData["towed"] = true
towedModData["towBarTowedVehicleId"] = nil
towedModData["towBarTowingVehicleId"] = towingVehicle:getId()
towedModData["towBarExpectedAttachment"] = attachmentB
towingVehicle:transmitModData()
towedVehicle:transmitModData()
-- Match the known-good rigid tow path: fake trailer + vanilla attach command.
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, "notTowingA_Trailer")
local args = {
vehicleA = towingVehicle:getId(),
vehicleB = towedVehicle:getId(),
@@ -496,7 +388,6 @@ function TowBarMod.Hook.performAttachTowBar(playerObj, towingVehicle, towedVehic
itemId = towBarItem and towBarItem:getID() or nil
}
sendTowAttachCommand(playerObj, args)
ISTimedActionQueue.add(TowBarScheduleAction:new(playerObj, 10, TowBarMod.Hook.setVehiclePostAttach, towedVehicle))
end
function TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle)
@@ -538,7 +429,6 @@ function TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle)
towingVehicle:transmitModData()
towedVehicle:transmitModData()
TowBarMod.Hook.lastAutoReattachAtByVehicle[towingVehicle:getId()] = nil
setTowBarModelVisible(towedVehicle, false)
end
@@ -547,46 +437,6 @@ function TowBarMod.Hook.performDetachTowBar(playerObj, towingVehicle, towedVehic
local args = { towingVehicle = towingVehicle:getId(), vehicle = towedVehicle:getId() }
sendClientCommand(playerObj, "towbar", "detachTowBar", args)
TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle)
end
function TowBarMod.Hook.reattachTowBarFromDriverSeat(playerObj, towingVehicle)
if not playerObj or not towingVehicle then return end
local towedVehicle = towingVehicle:getVehicleTowing()
if not towedVehicle then return end
reattachTowBarPair(playerObj, towingVehicle, towedVehicle, true)
end
local function tryAutoReattachFromCharacter(character)
if not character or not instanceof(character, "IsoPlayer") or not character:isLocalPlayer() then return end
local playerObj = character
local nowHours = getGameTime() and getGameTime():getWorldAgeHours() or 0
local playerNum = playerObj:getPlayerNum()
local lastPlayerHours = TowBarMod.Hook.lastAutoReattachAtByPlayer[playerNum]
if lastPlayerHours and (nowHours - lastPlayerHours) < AutoReattachPlayerCooldownHours then
return
end
local towingVehicle = playerObj:getVehicle()
if not towingVehicle then return end
if not towingVehicle:isDriver(playerObj) then return end
local towedVehicle = towingVehicle:getVehicleTowing()
if not towedVehicle then return end
if not isTowBarTowPair(towingVehicle, towedVehicle) then return end
local vehicleId = towingVehicle:getId()
local lastHours = TowBarMod.Hook.lastAutoReattachAtByVehicle[vehicleId]
if lastHours and (nowHours - lastHours) < AutoReattachCooldownHours then
return
end
TowBarMod.Hook.lastAutoReattachAtByPlayer[playerNum] = nowHours
TowBarMod.Hook.lastAutoReattachAtByVehicle[vehicleId] = nowHours
TowBarMod.Hook.reattachTowBarFromDriverSeat(playerObj, towingVehicle)
end
local function forEachCollectionItem(collection, callback)
@@ -634,14 +484,6 @@ local function keepTowBarVehiclesFreeRolling()
end)
end
function TowBarMod.Hook.OnEnterVehicle(character)
tryAutoReattachFromCharacter(character)
end
function TowBarMod.Hook.OnSwitchVehicleSeat(character)
tryAutoReattachFromCharacter(character)
end
function TowBarMod.Hook.attachByTowBarAction(playerObj, towingVehicle, towedVehicle)
if playerObj == nil or towingVehicle == nil or towedVehicle == nil then return end
@@ -725,20 +567,11 @@ function TowBarMod.Hook.deattachTowBarAction(playerObj, vehicle)
end
function TowBarMod.Hook.OnSpawnVehicle(vehicle)
recoverTowBarVehicleAfterLoad(nil, vehicle, 6)
-- Server persistence snapshots own attach recovery after vehicle streaming.
end
function TowBarMod.Hook.OnGameStart()
local cell = getCell()
if not cell then return end
local vehicles = cell:getVehicles()
if not vehicles then return end
local playerObj = getPlayer()
forEachCollectionItem(vehicles, function(vehicle)
recoverTowBarVehicleAfterLoad(playerObj, vehicle, 6)
end)
-- Server persistence broadcasts the same attach snapshot used by a fresh pair.
end
---------------------------------------------------------------------------
@@ -749,8 +582,9 @@ function TowBarMod.Hook.devShowAllTowbarModels(playerObj, vehicle)
if not vehicle then return end
local normalPart = vehicle:getPartById("towbar")
local largePart = vehicle:getPartById("towbarLarge")
if normalPart == nil and largePart == nil then
print("[TowBar DEV] No 'towbar' or 'towbarLarge' part found on vehicle " .. tostring(vehicle:getScriptName()))
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()
@@ -765,10 +599,11 @@ function TowBarMod.Hook.devShowAllTowbarModels(playerObj, vehicle)
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 both parts")
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
@@ -777,14 +612,16 @@ function TowBarMod.Hook.devHideAllTowbarModels(playerObj, vehicle)
if not vehicle then return end
local normalPart = vehicle:getPartById("towbar")
local largePart = vehicle:getPartById("towbarLarge")
if normalPart == nil and largePart == nil then
print("[TowBar DEV] No 'towbar' or 'towbarLarge' part found on vehicle " .. tostring(vehicle:getScriptName()))
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
@@ -793,24 +630,32 @@ function TowBarMod.Hook.devShowSingleTowbar(playerObj, vehicle, index)
if not vehicle then return end
local normalPart = vehicle:getPartById("towbar")
local largePart = vehicle:getPartById("towbarLarge")
if normalPart == nil and largePart == nil then
print("[TowBar DEV] No 'towbar' or 'towbarLarge' part found on vehicle " .. tostring(vehicle:getScriptName()))
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 useLargePart = 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 = useLargePart and largePart or normalPart
local part = selectedPartId == "towbarKI5" and ki5Part
or (selectedPartId == "towbarLarge" and largePart or normalPart)
if part == nil then
part = normalPart or largePart
part = normalPart or largePart or ki5Part
end
print("[TowBar DEV] Showing only towbar" .. tostring(localIndex) .. " on part " .. tostring(useLargePart and "towbarLarge" or "towbar") .. " (Z offset " .. tostring(1.0 + localIndex * 0.1) .. ") on " .. tostring(vehicle:getScriptName()))
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
@@ -821,6 +666,4 @@ Events.OnSpawnVehicleEnd.Add(TowBarMod.Hook.OnSpawnVehicle)
if Events.OnGameStart then
Events.OnGameStart.Add(TowBarMod.Hook.OnGameStart)
end
Events.OnEnterVehicle.Add(TowBarMod.Hook.OnEnterVehicle)
Events.OnSwitchVehicleSeat.Add(TowBarMod.Hook.OnSwitchVehicleSeat)
Events.OnTick.Add(keepTowBarVehiclesFreeRolling)
@@ -4,6 +4,26 @@ TowBarMod.WreckerSync = TowBarMod.WreckerSync or {}
local Sync = TowBarMod.WreckerSync
Sync.appliedLevels = Sync.appliedLevels or {}
Sync.desiredPairs = Sync.desiredPairs or {}
local function argsPairKey(args)
if type(args) ~= "table" then return nil end
local wreckerId = tonumber(args.wrecker)
local targetId = tonumber(args.target)
if not wreckerId or not targetId then return nil end
return tostring(wreckerId) .. ":" .. tostring(targetId)
end
local function copyAttachArgs(args)
return {
wrecker = tonumber(args.wrecker),
target = tonumber(args.target),
wreckerSqlId = tonumber(args.wreckerSqlId),
targetSqlId = tonumber(args.targetSqlId),
targetAttachment = args.targetAttachment,
heightLevel = tonumber(args.heightLevel) or 0
}
end
local function pairKeyContainsVehicle(key, vehicleId)
local id = tostring(vehicleId)
@@ -123,8 +143,10 @@ local function setScriptSafely(vehicle, scriptName)
return true
end
local function applyAttachSync(args)
local function applyAttachSync(args, forceReattach)
if not args then return end
local desiredKey = argsPairKey(args)
if desiredKey then Sync.desiredPairs[desiredKey] = copyAttachArgs(args) end
local wrecker = getVehicleById(args.wrecker)
local target = getVehicleById(args.target)
if not wrecker or not target then return end
@@ -138,7 +160,7 @@ local function applyAttachSync(args)
local key = pairKey(wrecker, target)
local canonicalLevel = tonumber(args.heightLevel) or 0
local appliedLevel = Sync.appliedLevels[key]
if isPairLinked(wrecker, target) and appliedLevel == canonicalLevel then
if not forceReattach and isPairLinked(wrecker, target) and appliedLevel == canonicalLevel then
Sync.appliedLevels[key] = canonicalLevel
applyFreeRollingState(target)
return
@@ -167,6 +189,8 @@ end
local function applyDetachSync(args)
if not args then return end
local desiredKey = argsPairKey(args)
if desiredKey then Sync.desiredPairs[desiredKey] = nil end
local wrecker = getVehicleById(args.wrecker)
local target = getVehicleById(args.target)
if not target then return end
@@ -203,6 +227,30 @@ end
Sync.applyAttachSync = applyAttachSync
Sync.applyDetachSync = applyDetachSync
local function forceReattachForDriver(character)
if not character or type(character.getVehicle) ~= "function" then return end
local wrecker = character:getVehicle()
if not wrecker or not wrecker:isDriver(character) then return end
local wreckerMd = wrecker:getModData()
if not wreckerMd or wreckerMd.wreckerTowActive ~= true then return end
local targetId = tonumber(wreckerMd.wreckerTowedVehicleId)
local target = targetId and getVehicleById(targetId) or nil
if not target then return end
local targetMd = target:getModData()
if not targetMd or tonumber(targetMd.wreckerTowingVehicleId) ~= wrecker:getId() then return end
applyAttachSync({
wrecker = wrecker:getId(),
target = target:getId(),
wreckerSqlId = wrecker:getSqlId(),
targetSqlId = wreckerMd.wreckerTowedVehicleSqlId,
targetAttachment = wreckerMd.wreckerTargetAttachment,
heightLevel = tonumber(wreckerMd.wreckerHeightLevel) or 0
}, true)
end
Events.OnServerCommand.Add(function(module, command, args)
if module ~= "towbar" then return end
if command == "wreckerAttachSync" then
@@ -214,3 +262,9 @@ end)
if Events.OnSpawnVehicleEnd then
Events.OnSpawnVehicleEnd.Add(clearAppliedLevelForVehicle)
end
if Events.OnEnterVehicle then
Events.OnEnterVehicle.Add(forceReattachForDriver)
end
if Events.OnSwitchVehicleSeat then
Events.OnSwitchVehicleSeat.Add(forceReattachForDriver)
end
+68 -2
View File
@@ -2,6 +2,8 @@ BTtow = {}
BTtow.Create = {}
BTtow.Init = {}
require("TowBar/VehicleCompatibility")
local TowbarVariantSize = 24
local TowbarMaxIndex = TowbarVariantSize - 1
local TowbarFirstZ = 1.0
@@ -13,6 +15,8 @@ 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
@@ -53,6 +57,59 @@ local function getTowbarModelSlot(script)
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)
return ok and type(result) == "number" and result or 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 end
return index, useNormalPart
end
function BTtow.Create.towbar(vehicle, part)
if part == nil then return end
for j=0, TowbarVariantSize - 1 do
@@ -72,8 +129,17 @@ function BTtow.Init.towbar(vehicle, part)
if modData and modData["isTowingByTowBar"] and modData["towed"] then
local script = vehicle:getScript()
if script then
local index = getTowbarModelSlot(script)
local shouldShowOnThisPart = part:getId() == "towbar"
local isKi5 = TowBarMod.Compatibility.isKi5Vehicle(vehicle)
local index, useNormalPart
if isKi5 then
index = getTowbarModelSlot(script)
else
index, useNormalPart = getLegacyTowbarModelSlot(script)
end
local partId = part:getId()
local shouldShowOnThisPart = (isKi5 and partId == "towbarKI5")
or (not isKi5 and useNormalPart and partId == "towbar")
or (not isKi5 and not useNormalPart and partId == "towbarLarge")
if shouldShowOnThisPart and index ~= nil then
part:setModelVisible("towbar" .. index, true)
end
+19 -150
View File
@@ -4,10 +4,8 @@ require("TowBar/Persistence")
local TowingCommands = {}
local Commands = {}
local TowBarItemType = "TowBar.TowBar"
local SyncDelayTicks = 2
local SnapshotIntervalTicks = 120
local BrokenPairAuditIntervalTicks = 5
local pendingSync = {}
local snapshotTickCounter = 0
local brokenPairAuditTickCounter = 0
local confirmedTowPairs = {}
@@ -24,57 +22,6 @@ local noise = function(msg)
end
end
local function queueSync(kind, player, args, reservedTowBar)
if not args then return end
table.insert(pendingSync, {
kind = kind,
ticks = SyncDelayTicks,
attempts = 0,
reservedTowBar = reservedTowBar == true,
player = player,
args = args
})
end
local function cancelPendingAttach(vehicleA, vehicleB)
if not vehicleA or not vehicleB then return end
local vehicleAId = vehicleA:getId()
local vehicleBId = vehicleB:getId()
local remaining = {}
for i = 1, #pendingSync do
local item = pendingSync[i]
local itemArgs = item.args or {}
local isThisAttach = item.kind == "attach"
and itemArgs.vehicleA == vehicleAId
and itemArgs.vehicleB == vehicleBId
if isThisAttach then
-- The reserved item is about to become the single ground drop.
item.reservedTowBar = false
else
table.insert(remaining, item)
end
end
pendingSync = remaining
end
local function hasPendingAttach(vehicleA, vehicleB)
if not vehicleA or not vehicleB then return false end
local vehicleAId = vehicleA:getId()
local vehicleBId = vehicleB:getId()
for i = 1, #pendingSync do
local item = pendingSync[i]
local itemArgs = item.args or {}
if item.kind == "attach"
and itemArgs.vehicleA == vehicleAId
and itemArgs.vehicleB == vehicleBId then
return true
end
end
return false
end
local function resolveAttachmentA(args, vehicleA)
if args and args.attachmentA then return args.attachmentA end
if vehicleA and vehicleA:getTowAttachmentSelf() then return vehicleA:getTowAttachmentSelf() end
@@ -369,10 +316,16 @@ local function broadcastAttach(vehicleA, vehicleB, attachmentA, attachmentB)
end
local function broadcastDetach(vehicleAId, vehicleBId)
sendServerCommand("towbar", "forceDetachSync", {
local args = {
vehicleA = vehicleAId,
vehicleB = vehicleBId
})
}
if isServer() then
sendServerCommand("towbar", "forceDetachSync", args)
elseif not isClient() and TowBarMod and TowBarMod.Sync
and TowBarMod.Sync.applyDetachSync then
TowBarMod.Sync.applyDetachSync(args)
end
end
local function broadcastSpontaneousDetach(vehicleA, vehicleB)
@@ -458,7 +411,6 @@ local function finalizeBrokenTowBarPair(towingVehicle, towedVehicle, reason)
return false
end
cancelPendingAttach(towingVehicle, towedVehicle)
forgetTowBarPair(towingVehicle, towedVehicle)
breakTowBarConstraint(towingVehicle, towedVehicle)
clearExpectedTowBarPair(towingVehicle, towedVehicle)
@@ -567,54 +519,6 @@ local function reconcileBrokenTowBarPairsServer()
end)
end
local function processAttachSync(item)
local args = item.args or {}
local vehicleA = args.vehicleA and getVehicleById(args.vehicleA) or nil
local vehicleB = args.vehicleB and getVehicleById(args.vehicleB) or nil
if not vehicleA or not vehicleB then
noise("attach sync skipped missing vehicles A=" .. tostring(args.vehicleA) .. " B=" .. tostring(args.vehicleB))
return "failed"
end
local attachmentA = resolveAttachmentA(args, vehicleA)
local attachmentB = resolveAttachmentB(args, vehicleB)
if not isLinked(vehicleA, vehicleB) then
if isTowBarPairConfirmed(vehicleA, vehicleB) then return "broken" end
if item.attempts >= 3 then return "failed" end
vehicleA:addPointConstraint(item.player, vehicleB, attachmentA, attachmentB)
return "retry"
end
markExpectedTowBarPair(vehicleA, vehicleB, attachmentA, attachmentB)
markTowBarPairConfirmed(vehicleA, vehicleB)
broadcastAttach(vehicleA, vehicleB, attachmentA, attachmentB)
return "complete"
end
local function failAttachSync(item)
local args = item.args or {}
local vehicleA = args.vehicleA and getVehicleById(args.vehicleA) or nil
local vehicleB = args.vehicleB and getVehicleById(args.vehicleB) or nil
if isLinked(vehicleA, vehicleB) then
vehicleA:breakConstraint(true, false)
end
clearExpectedTowBarPair(vehicleA, vehicleB)
broadcastDetach(args.vehicleA, args.vehicleB)
if item.reservedTowBar then
giveTowBar(item.player, true)
item.reservedTowBar = false
end
end
local function processDetachSync(item)
local args = item.args or {}
local vehicleAId = args.towingVehicle or args.vehicleA or args.vehicle
local vehicleBId = args.vehicleB or args.vehicle
broadcastDetach(vehicleAId, vehicleBId)
end
local function snapshotActiveTowbarLinksServer()
local cell = getCell()
if not cell then return end
@@ -643,7 +547,7 @@ local function snapshotActiveTowbarLinksServer()
end)
end
local function processPendingSync()
local function processTowBarServerTick()
reconcileBrokenTowBarPairsServer()
snapshotTickCounter = snapshotTickCounter + 1
@@ -651,37 +555,6 @@ local function processPendingSync()
snapshotTickCounter = 0
snapshotActiveTowbarLinksServer()
end
if #pendingSync == 0 then return end
local remaining = {}
for i = 1, #pendingSync do
local item = pendingSync[i]
item.ticks = item.ticks - 1
if item.ticks <= 0 then
if item.kind == "attach" then
local status = processAttachSync(item)
if status == "retry" and item.attempts < 3 then
item.attempts = item.attempts + 1
item.ticks = SyncDelayTicks
table.insert(remaining, item)
elseif status == "broken" then
finalizeBrokenTowBarPair(
item.args.vehicleA and getVehicleById(item.args.vehicleA) or nil,
item.args.vehicleB and getVehicleById(item.args.vehicleB) or nil,
"attach-confirmation-break"
)
elseif status ~= "complete" then
failAttachSync(item)
end
elseif item.kind == "detach" then
processDetachSync(item)
end
else
table.insert(remaining, item)
end
end
pendingSync = remaining
end
function Commands.attachTowBar(player, args)
@@ -707,9 +580,7 @@ function Commands.attachTowBar(player, args)
return
end
if isExpectedTowBarPair(vehicleA, vehicleB)
or isExpectedTowBarPair(vehicleB, vehicleA)
or hasPendingAttach(vehicleA, vehicleB)
or hasPendingAttach(vehicleB, vehicleA) then
or isExpectedTowBarPair(vehicleB, vehicleA) then
noise("rejected duplicate pending towbar attach")
return
end
@@ -724,12 +595,14 @@ function Commands.attachTowBar(player, args)
return
end
-- Constraint creation may complete on a later server tick in multiplayer.
-- Record and queue the reserved item first so an immediate Build 42.20
-- spontaneous break can resolve the pair and consume the reservation once.
-- Match the working wrecker path: persist the accepted logical pair, ask
-- the server for its native relation, then immediately tell clients to
-- replace that relation with the local rigid fake-trailer constraint.
markExpectedTowBarPair(vehicleA, vehicleB, args.attachmentA, args.attachmentB)
queueSync("attach", player, args, true)
vehicleA:addPointConstraint(player, vehicleB, args.attachmentA, args.attachmentB)
if isServer() then
vehicleA:addPointConstraint(player, vehicleB, args.attachmentA, args.attachmentB)
end
broadcastAttach(vehicleA, vehicleB, args.attachmentA, args.attachmentB)
if isLinked(vehicleA, vehicleB) then
markTowBarPairConfirmed(vehicleA, vehicleB)
end
@@ -749,10 +622,6 @@ function Commands.detachTowBar(player, args)
noise("rejected unauthorized towbar detach")
return
end
if not isLinked(towingVehicle, towedVehicle) then
noise("rejected mismatched or stale towbar detach")
return
end
if not isExpectedTowBarPair(towingVehicle, towedVehicle)
and not isLegacyTowBarPair(towingVehicle, towedVehicle) then
noise("rejected detach for a non-towbar or stale linked pair")
@@ -764,7 +633,7 @@ function Commands.detachTowBar(player, args)
breakTowBarConstraint(towingVehicle, towedVehicle)
forgetTowBarPair(towingVehicle, towedVehicle)
clearExpectedTowBarPair(towingVehicle, towedVehicle)
queueSync("detach", player, args)
broadcastDetach(towingVehicle:getId(), towedVehicle:getId())
if shouldRefund then
giveTowBar(player, true)
end
@@ -817,4 +686,4 @@ TowingCommands.OnClientCommand = function(module, command, player, args)
end
Events.OnClientCommand.Add(TowingCommands.OnClientCommand)
Events.OnTick.Add(processPendingSync)
Events.OnTick.Add(processTowBarServerTick)
@@ -0,0 +1,148 @@
if not TowBarMod then TowBarMod = {} end
TowBarMod.Compatibility = TowBarMod.Compatibility or {}
local Compatibility = TowBarMod.Compatibility
-- Exact Mod ID -> exact VehicleScript full-name records for KI5's vehicle
-- catalogue. This deliberately avoids model-scale and attachment heuristics:
-- unrelated vehicle mods can use the same scale and attachment names.
local Ki5VehiclesByModId = {
["04vwTouran"] = "Base.04vwTouran",
["49powerWagon"] = "Base.49powerWagon|Base.49powerWagonMP|Base.49powerWagonPA|Base.49powerWagonPD",
["59meteor"] = "Base.59ambulance|Base.59meteor|Base.ECTO1|Base.ECTO1Burnt",
["63beetle"] = "Base.63beetle|Base.63beetleBuggy|Base.63beetleHP",
["63Type2Van"] = "Base.63Type2Van|Base.63Type2VanApocalypse|Base.63Type2VanHippie|Base.63Type2VanMilitary",
["65banshee"] = "Base.65banshee400|Base.65bansheeSprint|Base.65bansheeXP",
["66pontiacLeMans"] = "Base.66pontiacGTO|Base.66pontiacGTOconv|Base.66pontiacLeMans|Base.66pontiacLeMansConv",
["67commando"] = "Base.67commando|Base.67commandoBurnt|Base.67commandoPolice|Base.67commandoT50",
["67gt500"] = "Base.67gt500|Base.67gt500e",
["68firebird"] = "Base.68firebird350|Base.68firebird400|Base.68firebirdRamAir|Base.68firebirdRamAirCustom",
["69camaro"] = "Base.69camaroRS|Base.69camaroSS",
["69charger"] = "Base.69charger440|Base.69charger500|Base.69chargerDaytona|Base.69chargerDemon|Base.69chargerRT",
["69fordMustang"] = "Base.69fordMustangBoss302|Base.69fordMustangBoss429|Base.69fordMustangEV6|Base.69fordMustangMach1|Base.69fordMustangTBC|Base.69fordMustangUBC",
["69mini"] = "Base.69mini|Base.69miniIJ|Base.69miniMrB|Base.69miniPS|Base.69miniUnionJack",
["70barracuda"] = "Base.70barracuda|Base.70barracudaAAR|Base.70cuda",
["70chevelle"] = "Base.70chevelleCoupe|Base.70chevelleCoupeSS|Base.70chevelleCoupeSSL6|Base.70chevelleSedan|Base.70chevelleWagon|Base.70chevelleWagonSS|Base.70elCamino|Base.70elCaminoSS",
["70dodge"] = "Base.70dodgeBG|Base.70dodgeOP|Base.70dodgePD|Base.70dodgeRT|Base.70dodgeTA",
["70fordEscort"] = "Base.70fordEscortCoupe|Base.70fordEscortRS|Base.70fordEscortSedan|Base.70fordEscortWagon",
["70roadRunner"] = "Base.70roadRunner",
["73fordFalcon"] = "Base.73fordFalconPS|Base.73fordFalconPSlhd|Base.73fordFalconXBGT|Base.73fordFalconXBGTlhd",
["73nissanGTR"] = "Base.73nissanGTR|Base.73nissanGTRlhd",
["75grandPrix"] = "Base.75grandPrixHurst|Base.75grandPrixLJ|Base.75grandPrixSJ",
["76chevyKseries"] = "Base.76chevyBlazer|Base.76chevyC30CCwrecker|Base.76chevyC30SCwrecker|Base.76chevyK10|Base.76chevyK10fd|Base.76chevyK10spirit|Base.76chevyK20|Base.76chevyK20BigRed|Base.76chevyK20fd|Base.76chevyK20utility|Base.76chevyK30CC|Base.76chevyK30CCdually|Base.76chevyK30CCduallyS|Base.76chevyK30CCfd|Base.76chevyK30CCutility|Base.76chevyK30CCwrecker|Base.76chevyK30SCdually|Base.76chevyK30SCwrecker|Base.76chevySuburban|Base.76chevySuburban2",
["76chryslerNewYorker"] = "Base.76chryslerNewYorker|Base.76chryslerNewYorkerTPB",
["77firebird"] = "Base.77firebird|Base.77firebirdES|Base.77firebirdFR|Base.77firebirdTA",
["78amgeneralM35A2"] = "Base.78amgeneralM35A2|Base.78amgeneralM35A2Burnt|Base.78amgeneralM49A2C|Base.78amgeneralM50A3|Base.78amgeneralM62",
["78amgeneralM49A2C"] = "Base.78amgeneralM49A2C",
["78amgeneralM50A3"] = "Base.78amgeneralM50A3",
["78amgeneralM62"] = "Base.78amgeneralM62",
["78lamboCountach"] = "Base.78lamboCountachLP400|Base.78lamboCountachLP400S|Base.78lamboCountachLP400Scb",
["79camaro"] = "Base.79camaro|Base.79camaroGhost|Base.79camaroRS|Base.79camaroZ28",
["80manKat1"] = "Base.80manKat1",
["81deloreanDMC12"] = "Base.81deloreanDMC12|Base.81deloreanDMC12BTTF",
["81deloreanDMC12BTTF"] = "Base.81deloreanDMC12BTTF",
["82firebird"] = "Base.82firebird|Base.82firebirdKARR|Base.82firebirdKITT|Base.82firebirdSE|Base.82firebirdTA",
["82firebirdKITT"] = "Base.82firebirdKARR|Base.82firebirdKITT",
["82jeepJ10"] = "Base.82jeepJ10|Base.82jeepJ10pd|Base.82jeepJ10ranger|Base.82jeepJ10t",
["82jeepJ10t"] = "Base.82jeepJ10t",
["82oshkoshM911"] = "Base.82oshkoshM911|Base.82oshkoshM911B|Base.82oshkoshM911Burnt",
["82porsche911"] = "Base.82porsche911rwb|Base.82porsche911sc|Base.82porsche911targa|Base.82porsche911turbo",
["83amgeneralM923"] = "Base.83amgeneralM923|Base.83amgeneralM923Burnt",
["84buickElectra"] = "Base.84buickElectraCoupe|Base.84buickElectraSedan",
["84cadillacDeVille"] = "Base.84cadillacDeVilleCoupe|Base.84cadillacDeVilleSedan",
["84corvette"] = "Base.84corvetteC4|Base.93corvetteC4",
["84jeepXJ"] = "Base.84jeepXJ2|Base.84jeepXJ4|Base.84jeepXJksp|Base.84jeepXJpd|Base.84jeepXJranger",
["84merc"] = "Base.84mercLWB2|Base.84mercLWB4|Base.84mercLWB4M|Base.84mercSWB",
["84oldsmobile98"] = "Base.84oldsmobile98Coupe|Base.84oldsmobile98Sedan",
["85buickLeSabre"] = "Base.85buickLeSabreCoupe|Base.85buickLeSabreSedan|Base.85buickLeSabreWagon|Base.85buickLeSabreWagon2",
["85chevyCaprice"] = "Base.85chevyCapriceCoupe|Base.85chevyCapriceSedan|Base.85chevyCapriceWagon|Base.85chevyCapriceWagon2|Base.85chevyImpalaSedanAirport|Base.85chevyImpalaSedanBCS|Base.85chevyImpalaSedanCLPD|Base.85chevyImpalaSedanFD|Base.85chevyImpalaSedanKSP|Base.85chevyImpalaSedanLCPD|Base.85chevyImpalaSedanMCS|Base.85chevyImpalaSedanMPD|Base.85chevyImpalaSedanPD|Base.85chevyImpalaSedanPDu|Base.85chevyImpalaSedanPrison|Base.85chevyImpalaSedanRanger|Base.85chevyImpalaSedanTaxi|Base.85chevyImpalaSedanWPPD",
["85chevyStepVan"] = "Base.85chevyStepVan|Base.85chevyStepVanSWAT",
["85chevyStepVanexpanded"] = "Base.85chevyStepVanBlacksmith|Base.85chevyStepVanButchers|Base.85chevyStepVanCitrusWave|Base.85chevyStepVanDelirosPlonkies|Base.85chevyStepVanFlorist|Base.85chevyStepVanGenuine|Base.85chevyStepVanHerald|Base.85chevyStepVanJorgensen|Base.85chevyStepVanLibrary|Base.85chevyStepVanLvAirportCatering|Base.85chevyStepVanLvMotorshop|Base.85chevyStepVanMarineBites|Base.85chevyStepVanMasonry|Base.85chevyStepVanMrHuangsLaundry|Base.85chevyStepVanPostal|Base.85chevyStepVanPropane|Base.85chevyStepVanRandys|Base.85chevyStepVanScarletOak|Base.85chevyStepVanSeHospitality|Base.85chevyStepVanSePaintingServices|Base.85chevyStepVanSmartCut|Base.85chevyStepVanSunBallz|Base.85chevyStepVanTheCompleteRepair|Base.85chevyStepVanTimelessGlass|Base.85chevyStepVanUsLogistics|Base.85chevyStepVanZippeeMarket",
["85oldsmobileDelta88"] = "Base.85oldsmobileDelta88Coupe|Base.85oldsmobileDelta88Sedan|Base.85oldsmobileDelta88Wagon|Base.85oldsmobileDelta88Wagon2",
["85pontiacParisienne"] = "Base.85pontiacParisienneSedan|Base.85pontiacParisienneWagon|Base.85pontiacParisienneWagon2",
["86chevyCUCV"] = "Base.86chevyK5blazer|Base.86chevyK5ksp|Base.86chevyK5pd|Base.86chevyM1008|Base.86chevyM1009|Base.86chevyM1009mp|Base.86chevyM1010|Base.86chevyM1028|Base.86chevyM1031",
["86fordE150"] = "Base.86fordE150|Base.86fordE150dnd|Base.86fordE150ksp|Base.86fordE150long|Base.86fordE150longW|Base.86fordE150mccoy|Base.86fordE150med|Base.86fordE150mm|Base.86fordE150pd|Base.86fordE150slide|Base.86fordE150slideSpiffo|Base.86fordE150so",
["86fordE150dnd"] = "Base.86fordE150dnd",
["86fordE150expanded"] = "Base.86fordE150beckmansBuilding|Base.86fordE150blacksmith|Base.86fordE150brewster|Base.86fordE150brushAndClay|Base.86fordE150bugWipers|Base.86fordE150ccconstruction|Base.86fordE150creatureCruiser|Base.86fordE150deerValley|Base.86fordE150fossoil|Base.86fordE150greenes|Base.86fordE150heritageTailors|Base.86fordE150jones|Base.86fordE150kerrHomes|Base.86fordE150knobCreek|Base.86fordE150knoxDistilery|Base.86fordE150knoxTelecom|Base.86fordE150korshunovs|Base.86fordE150kyTransit|Base.86fordE150LBMWradio|Base.86fordE150leatherwork|Base.86fordE150lectromax|Base.86fordE150locksmith|Base.86fordE150LVairportShuttle|Base.86fordE150lvLandscaping|Base.86fordE150massGenfac|Base.86fordE150McCoyWoodworking|Base.86fordE150meltingPointMetal|Base.86fordE150mesmerWagon|Base.86fordE150metalheads|Base.86fordE150michelesWoodshop|Base.86fordE150mobileMechanics|Base.86fordE150mooresMechanics|Base.86fordE150oldMillWaterCompany|Base.86fordE150oVoFarms|Base.86fordE150pennSham|Base.86fordE150perfick|Base.86fordE150plattAutoRepair|Base.86fordE150pluggedInElectrics|Base.86fordE150postal|Base.86fordE150quantumVessel|Base.86fordE150riversideFab|Base.86fordE150rosewoodWorking|Base.86fordE150schwab|Base.86fordE150stoneworksMasonry|Base.86fordE150tasteTheBrew|Base.86fordE150theGardenGods|Base.86fordE150theLadyDelighter|Base.86fordE150treyBaines|Base.86fordE150uncloggers|Base.86fordE150valkyriesSpear|Base.86fordE150voltMojo|Base.86fordE150wpCarpentry|Base.86fordE150zenith",
["86fordE150mm"] = "Base.86fordE150mm",
["86fordE150pd"] = "Base.86fordE150pd",
["86oshkoshP19A"] = "Base.86oshkoshFRTR55|Base.86oshkoshKYFD|Base.86oshkoshP19ABurnt|Base.86oshkoshUSMC",
["87buickRegal"] = "Base.87buickRegalGNX|Base.87buickRegalTurboT|Base.87buickRegalTurboTfbi",
["87chevySuburban"] = "Base.87chevySuburban|Base.87chevySuburbanCUCV|Base.87chevySuburbanOP",
["87fordB700"] = "Base.87fordB700military|Base.87fordB700prison|Base.87fordB700school|Base.87fordF700bank|Base.87fordF700box|Base.87fordF700swat",
["87toyotaCorolla"] = "Base.87toyotaCorollaAE92levin|Base.87toyotaCorollaAE92levinLhd|Base.87toyotaCorollaAE92trueno|Base.87toyotaCorollaAE92truenoLhd",
["87toyotaMR2"] = "Base.87toyotaMR2|Base.87toyotaMR2c",
["88chevyS10"] = "Base.88chevyS10",
["88toyotaHilux"] = "Base.88toyotaHiluxSC|Base.88toyotaHiluxXC|Base.88toyotaHiluxXCS",
["89defender"] = "Base.89defender110|Base.89defender110utility|Base.89defender130|Base.89defender90|Base.89defender90utility|Base.89defenderWolf",
["89dodgeCaravan"] = "Base.89dodgeCaravan|Base.89dodgeCaravanLE|Base.89dodgeCaravanNomad",
["89fordBronco"] = "Base.89fordBronco|Base.89fordBroncoPD|Base.89fordBroncoRanger",
["89trooper"] = "Base.89trooper|Base.89trooperOP|Base.89trooperRS",
["89volvo200"] = "Base.89volvo242turbo|Base.89volvo244sedan|Base.89volvo245wagon",
["90bmwE30"] = "Base.90bmwE30cabrio|Base.90bmwE30m3|Base.90bmwE30sedan2|Base.90bmwE30sedan4|Base.90bmwE30touring",
["90fordF350ambulance"] = "Base.90fordF350ambulance|Base.90fordF350SWAT",
["90pierceArrow"] = "Base.90pierceArrow|Base.90pierceArrowQuint",
["91fordLTD"] = "Base.91fordLTD|Base.91fordLTDksp|Base.91fordLTDksp2|Base.91fordLTDpd|Base.91fordLTDranger|Base.91fordLTDtaxi|Base.91fordLTDunmarked|Base.91fordLTDwagon",
["91fordRanger"] = "Base.91fordRangerPD|Base.91fordRangerRanger|Base.91fordRangerSC|Base.91fordRangerSClong|Base.91fordRangerXC|Base.91fordRangerXClong",
["91geoMetro"] = "Base.91geoMetro",
["91lexusLS400"] = "Base.91lexusLS400",
["91nissan240sx"] = "Base.91nissan240sx|Base.91nissan240sx2",
["91range"] = "Base.91range|Base.91range2",
["92amgeneralM998"] = "Base.92amgeneralM998|Base.92amgeneralM998Burnt",
["92fordCVPI"] = "Base.92fordCV|Base.92fordCVPI|Base.92fordCVPI2|Base.92fordCVPI2ksp|Base.92fordCVPI2kspst|Base.92fordCVPI2so|Base.92fordCVPI2sup|Base.92fordCVPIfd|Base.92fordCVPIpdu|Base.92fordCVPItaxi|Base.92fordCVPIunmarked",
["92jeepYJ"] = "Base.92jeepYJjp|Base.92jeepYJranger|Base.92jeepYJs|Base.92jeepYJse",
["92jeepYJJP18"] = "Base.92jeepYJjp",
["92nissanGTR"] = "Base.92nissanGTR|Base.92nissanGTRlhd",
["93chevySuburban"] = "Base.93chevySilveradoCC|Base.93chevySilveradoCCdually|Base.93chevySilveradoCClong|Base.93chevySilveradoCClongfd|Base.93chevySilveradoK3500flatbed|Base.93chevySilveradoK3500wrecker|Base.93chevySilveradoSC|Base.93chevySilveradoSCdually|Base.93chevySilveradoSClong|Base.93chevySilveradoSClongFossoil|Base.93chevySilveradoXC|Base.93chevySilveradoXCdually|Base.93chevySilveradoXClong|Base.93chevySilveradoXClongMcCoy|Base.93chevySilveradoXClongRanger|Base.93chevySuburban|Base.93chevySuburbanDually|Base.93chevySuburbanfbi|Base.93chevySuburbanfd|Base.93chevySuburbanksp|Base.93chevySuburbanpd|Base.93chevySuburbanpdu",
["93chevySuburbanExpanded"] = "Base.93chevySilveradoAirport|Base.93chevySilveradoK3500lvLandscaping|Base.93chevySilveradoK3500mechanic|Base.93chevySilveradoMcCoyWoodworking|Base.93chevySilveradoPennSham|Base.93chevySilveradoPoliceBCS|Base.93chevySilveradoPoliceMCS|Base.93chevySilveradoRiversideFab|Base.93chevySilveradoStoneworksMasonry|Base.93chevySilveradoUncloggers|Base.93chevySilveradoVoltMojo|Base.93chevySilveradoWpCarpentry|Base.93chevySuburbanAirportSec|Base.93chevySuburbanPoliceBCS|Base.93chevySuburbanPoliceCLPD|Base.93chevySuburbanPoliceLCPD|Base.93chevySuburbanPoliceMCS|Base.93chevySuburbanPoliceMPD|Base.93chevySuburbanPoliceWPPD|Base.93chevySuburbanPrison",
["93fordElgin"] = "Base.93fordElgin|Base.93fordElginSpec",
["93fordF350"] = "Base.93fordF150|Base.93fordF150S|Base.93fordF250|Base.93fordF350|Base.93fordF350dually|Base.93fordF350fd|Base.93fordF350pd|Base.93fordF350so|Base.93fordF350utility|Base.93fordF350utilityDpw|Base.93fordF350utilityFd",
["93fordTaurus"] = "Base.93fordTaurus|Base.93fordTaurusSHO|Base.93fordTaurusWagon",
["93mustangSSP"] = "Base.93mustangGT|Base.93mustangSSP|Base.93mustangSSPksp|Base.93mustangSSPksp2|Base.93mustangSSPkspCol|Base.93mustangSSPpd|Base.93mustangSSPpd2|Base.93mustangSSPunmarked|Base.93mustangSVTcobraR",
["93townCar"] = "Base.93townCar|Base.93townCarLimo",
["95impreza"] = "Base.95impreza|Base.95imprezalhd",
["96lancerEVO"] = "Base.96lancerEVO|Base.96lancerEVOlhd",
["96saturnSseries"] = "Base.96saturnSL2",
["97bushmaster"] = "Base.97bushAmbulance|Base.97bushmaster",
["98stagea"] = "Base.98stagea260RS|Base.98stagea260RSlhd",
["99fordCVPI"] = "Base.99fordCVPI|Base.99fordCVPIunmarked",
["cobbM540"] = "Base.cobbM540",
["lockMartM577"] = "Base.lockMartM577",
}
local function getScriptFullName(script)
if not script then return nil end
local ok, fullName = pcall(function()
return script:getFullName()
end)
if ok and type(fullName) == "string" then return fullName end
return nil
end
function Compatibility.isKi5Vehicle(vehicle)
if not vehicle then return false end
local ok, script = pcall(function()
return vehicle:getScript()
end)
if not ok or not script then return false end
local fullName = getScriptFullName(script)
if not fullName then return false end
local activeMods = getActivatedMods and getActivatedMods() or nil
if not activeMods or not activeMods.contains then return false end
for modId, vehicleNames in pairs(Ki5VehiclesByModId) do
if activeMods:contains(modId) then
for candidate in string.gmatch(vehicleNames, "[^|]+") do
if candidate == fullName then return true end
end
end
end
return false
end