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
+188 -143
View File
@@ -5,6 +5,34 @@ TowBarMod.Sync = TowBarMod.Sync or {}
if TowBarMod.Sync._towSyncClientLoaded then return end if TowBarMod.Sync._towSyncClientLoaded then return end
TowBarMod.Sync._towSyncClientLoaded = true TowBarMod.Sync._towSyncClientLoaded = true
TowBarMod.Sync.appliedPairs = TowBarMod.Sync.appliedPairs or {} 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 function pairKeyContainsVehicle(key, vehicleId)
local id = tostring(vehicleId) local id = tostring(vehicleId)
@@ -15,72 +43,40 @@ end
local function clearAppliedPairForVehicle(vehicle) local function clearAppliedPairForVehicle(vehicle)
if not vehicle then return end if not vehicle then return end
local vehicleId = vehicle:getId() local vehicleId = vehicle:getId()
for key in pairs(TowBarMod.Sync.appliedPairs) do for key in pairs(Sync.appliedPairs) do
if pairKeyContainsVehicle(key, vehicleId) then if pairKeyContainsVehicle(key, vehicleId) then
TowBarMod.Sync.appliedPairs[key] = nil Sync.appliedPairs[key] = nil
end end
end end
end end
local function resolveVehicle(id) local function resolvePair(args)
if not id then return nil end if type(args) ~= "table" then return nil, nil end
return getVehicleById(id) 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 end
local function ensureAttachment(vehicle, attachmentId) local function isPairLinked(vehicleA, vehicleB)
if not vehicle or not attachmentId then return false end return vehicleA and vehicleB
and vehicleA:getVehicleTowing() == vehicleB
local script = vehicle:getScript() and vehicleB:getVehicleTowedBy() == vehicleA
if not script then return false end
if script:getAttachmentById(attachmentId) ~= nil then return true 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
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)
return true
end end
local function isLinked(vehicleA, vehicleB) local function hasConflictingLink(vehicle, expectedOther)
if not vehicleA or not vehicleB then return false end if not vehicle or not expectedOther then return false end
return vehicleA:getVehicleTowing() == vehicleB and vehicleB:getVehicleTowedBy() == vehicleA local towing = vehicle:getVehicleTowing()
local towedBy = vehicle:getVehicleTowedBy()
return (towing ~= nil and towing ~= expectedOther)
or (towedBy ~= nil and towedBy ~= expectedOther)
end end
local function reconcilePairState(vehicleA, vehicleB, attachmentA, attachmentB) local function preparePairState(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 towingMd = vehicleA:getModData()
local towedMd = vehicleB:getModData() local towedMd = vehicleB:getModData()
local currentScript = vehicleB:getScriptName() if not towingMd or not towedMd then return false end
if towingMd then local currentScript = vehicleB:getScriptName()
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 if towedMd.towBarOriginalScriptName == nil and currentScript ~= "notTowingA_Trailer" then
towedMd.towBarOriginalScriptName = currentScript towedMd.towBarOriginalScriptName = currentScript
end end
@@ -90,126 +86,175 @@ local function reconcilePairState(vehicleA, vehicleB, attachmentA, attachmentB)
if towedMd.towBarOriginalBrakingForce == nil then if towedMd.towBarOriginalBrakingForce == nil then
towedMd.towBarOriginalBrakingForce = vehicleB:getBrakingForce() towedMd.towBarOriginalBrakingForce = vehicleB:getBrakingForce()
end 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 towingMd.isTowingByTowBar = true
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(vehicleB, "notTowingA_Trailer") towingMd.towed = false
end towingMd.towBarTowedVehicleId = vehicleB:getId()
if TowBarMod.Hook and TowBarMod.Hook.setVehiclePostAttach then towingMd.towBarTowingVehicleId = nil
pcall(TowBarMod.Hook.setVehiclePostAttach, nil, vehicleB) towingMd.towBarExpectedAttachment = attachmentA
end towedMd.isTowingByTowBar = true
towedMd.towed = true
towedMd.towBarTowedVehicleId = nil
towedMd.towBarTowingVehicleId = vehicleA:getId()
towedMd.towBarExpectedAttachment = attachmentB
vehicleA:transmitModData()
vehicleB:transmitModData()
return true
end end
local breakTowBarPair local function isLocalDriver(vehicleA, playerObj)
if not vehicleA or not playerObj then return false end
return vehicleA:isDriver(playerObj)
end
local function applyAttachSync(args) local function applyAttachSync(args, playerObj, forceReattach)
if not args then return end local desiredKey = argsPairKey(args)
if not desiredKey then return false end
Sync.desiredPairs[desiredKey] = copyAttachArgs(args)
local vehicleA = resolveVehicle(args.vehicleA) local vehicleA, vehicleB = resolvePair(args)
local vehicleB = resolveVehicle(args.vehicleB) if not vehicleA or not vehicleB then return false end
if not vehicleA or not vehicleB then return end
local attachmentA = args.attachmentA or "trailer" local attachmentA = args.attachmentA or "trailer"
local attachmentB = args.attachmentB or "trailerfront" local attachmentB = args.attachmentB or "trailerfront"
if not ensureAttachment(vehicleA, attachmentA) or not ensureAttachment(vehicleB, attachmentB) then if not preparePairState(vehicleA, vehicleB, attachmentA, attachmentB) then return false end
return
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 end
local key = tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId()) local key = pairKey(vehicleA, vehicleB)
if TowBarMod.Sync.appliedPairs[key] and not isLinked(vehicleA, vehicleB) then if not forceReattach and isPairLinked(vehicleA, vehicleB) and Sync.appliedPairs[key] 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
return true return true
end end
-- Also reject a delayed detach while a new tow is being established but -- This is deliberately the same one-shot method as WreckerSyncClient:
-- has not created its physical constraint yet. -- replace any exact native relation with one client-local rigid relation.
local modData = vehicle:getModData() if TowBarMod.RigidTow.attach(vehicleA, vehicleB, attachmentA, attachmentB) ~= true then
if not modData then return false end return false
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)
end end
Sync.appliedPairs[key] = true
return true
end end
local function applyDetachSync(args) local function applyDetachSync(args)
if not args then return end local desiredKey = argsPairKey(args)
local vehicleA = resolveVehicle(args.vehicleA) local wasApplied = desiredKey and Sync.appliedPairs[desiredKey] == true
local vehicleB = resolveVehicle(args.vehicleB) if desiredKey then
if not vehicleA or not vehicleB then return end Sync.desiredPairs[desiredKey] = nil
if hasConflictingTowLink(vehicleA, vehicleB) or hasConflictingTowLink(vehicleB, vehicleA) then Sync.appliedPairs[desiredKey] = nil
return
end end
breakTowBarPair(vehicleA, vehicleB) local vehicleA, vehicleB = resolvePair(args)
TowBarMod.Sync.appliedPairs[tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId())] = nil 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 if TowBarMod.Hook and TowBarMod.Hook.cleanupDetachedTowBar then
pcall(TowBarMod.Hook.cleanupDetachedTowBar, vehicleA, vehicleB) TowBarMod.Hook.cleanupDetachedTowBar(vehicleA, vehicleB)
end end
end end
local function onServerCommand(module, command, args) Sync.applyAttachSync = applyAttachSync
if module ~= "towbar" then return end 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 if command == "forceAttachSync" then
applyAttachSync(args) applyAttachSync(args)
elseif command == "forceDetachSync" or command == "spontaneousDetachSync" then elseif command == "forceDetachSync" or command == "spontaneousDetachSync" then
applyDetachSync(args) applyDetachSync(args)
end end
end end)
TowBarMod.Sync.applyAttachSync = applyAttachSync
TowBarMod.Sync.applyDetachSync = applyDetachSync
Events.OnServerCommand.Add(onServerCommand)
if Events.OnSpawnVehicleEnd then 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 end
+127 -284
View File
@@ -1,11 +1,9 @@
if not TowBarMod then TowBarMod = {} end if not TowBarMod then TowBarMod = {} end
if not TowBarMod.Hook then TowBarMod.Hook = {} end if not TowBarMod.Hook then TowBarMod.Hook = {} end
require("TowBar/VehicleCompatibility")
local DefaultTowBarTowMass = 200 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 FreeRollTickInterval = 15
local freeRollTickCounter = 0 local freeRollTickCounter = 0
@@ -44,7 +42,7 @@ local function applyFreeRollingTowState(vehicle)
modData.towBarOriginalBrakingForce = vehicle:getBrakingForce() modData.towBarOriginalBrakingForce = vehicle:getBrakingForce()
end end
storeOriginalVehicleCall(vehicle, modData, "towBarOriginalParkingBrakeOn", "isParkingBrakeOn") storeOriginalVehicleCall(vehicle, modData, "towBarOriginalParkingBrakeOn", "isParkingBrakeOn")
storeOriginalVehicleCall(vehicle, modData, "towBarOriginalParkingBrake", "isParkingBrake") storeOriginalVehicleCall(vehicle, modData, "towBarOriginalParkingBrake", "getParkingBrake")
storeOriginalVehicleCall(vehicle, modData, "towBarOriginalHandbrake", "isHandbrake") storeOriginalVehicleCall(vehicle, modData, "towBarOriginalHandbrake", "isHandbrake")
local configuredTowMass = TowBarMod.Config and tonumber(TowBarMod.Config.towedVehicleRollingMass) local configuredTowMass = TowBarMod.Config and tonumber(TowBarMod.Config.towedVehicleRollingMass)
@@ -61,10 +59,13 @@ local function applyFreeRollingTowState(vehicle)
tryVehicleCall(vehicle, "setHandbrake", false) tryVehicleCall(vehicle, "setHandbrake", false)
end end
vehicle:constraintChanged() -- Match the working wrecker path. Recalculating total mass here would
vehicle:updateTotalMass() -- immediately replace the temporary towing mass in multiplayer; creating
-- the rigid constraint below notifies Bullet of the changed vehicle state.
end end
TowBarMod.Hook.applyFreeRollingTowState = applyFreeRollingTowState
local function restoreFreeRollingTowState(vehicle, modData) local function restoreFreeRollingTowState(vehicle, modData)
if not vehicle or not modData then return end if not vehicle or not modData then return end
@@ -88,25 +89,6 @@ local function restoreFreeRollingTowState(vehicle, modData)
vehicle:updateTotalMass() vehicle:updateTotalMass()
end 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) local function getTowBarItem(playerObj)
if not playerObj then return nil end if not playerObj then return nil end
local inventory = playerObj:getInventory() local inventory = playerObj:getInventory()
@@ -116,15 +98,8 @@ end
local function sendTowAttachCommand(playerObj, args) local function sendTowAttachCommand(playerObj, args)
if not playerObj or not args then return end if not playerObj or not args then return end
-- SP and MP now share the same authoritative attach lifecycle.
-- MP-safe/server-authoritative attach path (Landtrain style).
if isClient() and isMultiplayer() then
sendClientCommand(playerObj, "towbar", "attachTowBar", args) sendClientCommand(playerObj, "towbar", "attachTowBar", args)
return
end
-- Keep vanilla attach path for SP/local behavior.
sendClientCommand(playerObj, "vehicle", "attachTrailer", args)
end end
local TowbarVariantSize = 24 local TowbarVariantSize = 24
@@ -138,6 +113,8 @@ local TowbarVisualScale = 2.5
local TowbarModelLength = 0.9714089036 local TowbarModelLength = 0.9714089036
local TowbarScaledModelLength = TowbarModelLength * TowbarVisualScale local TowbarScaledModelLength = TowbarModelLength * TowbarVisualScale
local TowbarModelHalfLength = TowbarScaledModelLength / 2 local TowbarModelHalfLength = TowbarScaledModelLength / 2
local VanillaScaleMin = 1.5
local VanillaScaleMax = 2.0
local function getTowbarFrontEdgeZ(script) local function getTowbarFrontEdgeZ(script)
if not script then return nil end if not script then return nil end
@@ -178,16 +155,87 @@ local function getTowbarModelSlot(script)
return math.max(0, math.min(TowbarMaxIndex, index)) return math.max(0, math.min(TowbarMaxIndex, index))
end 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) local function setTowBarModelVisible(vehicle, isVisible)
if not vehicle then return end if not vehicle then return end
local normalPart = vehicle:getPartById("towbar") local normalPart = vehicle:getPartById("towbar")
local largePart = vehicle:getPartById("towbarLarge") 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 for j = 0, TowbarVariantSize - 1 do
if normalPart then normalPart:setModelVisible("towbar" .. j, false) end if normalPart then normalPart:setModelVisible("towbar" .. j, false) end
if largePart then largePart:setModelVisible("towbar" .. j, false) end if largePart then largePart:setModelVisible("towbar" .. j, false) end
if ki5Part then ki5Part:setModelVisible("towbar" .. j, false) end
end end
if not isVisible then if not isVisible then
@@ -201,8 +249,16 @@ local function setTowBarModelVisible(vehicle, isVisible)
return return
end end
local index = getTowbarModelSlot(script) local isKi5 = TowBarMod.Compatibility.isKi5Vehicle(vehicle)
local part = normalPart 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 if part and index ~= nil then
part:setModelVisible("towbar" .. index, true) part:setModelVisible("towbar" .. index, true)
end end
@@ -259,23 +315,6 @@ local function resolveTowAttachmentsForPair(towingVehicle, towedVehicle, towedMo
return attachmentA, attachmentB return attachmentA, attachmentB
end 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) local function isActiveTowBarTowedVehicle(vehicle, modData)
if not vehicle or not modData then if not vehicle or not modData then
return false return false
@@ -293,142 +332,22 @@ local function isActiveTowBarTowedVehicle(vehicle, modData)
return false return false
end end
local function reattachTowBarPair(playerObj, towingVehicle, towedVehicle, requireDriver) function TowBarMod.Hook.setVehiclePostAttach(playerObj, towedVehicle, knownTowingVehicle)
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)
if not towedVehicle then return end if not towedVehicle then return end
local towedModData = towedVehicle:getModData() local towedModData = towedVehicle:getModData()
if not isActiveTowBarTowedVehicle(towedVehicle, towedModData) then return end 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) TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, towedModData.towBarOriginalScriptName)
end end
local towingVehicle = towedVehicle:getVehicleTowedBy()
if towingVehicle then if towingVehicle then
local attachmentA, attachmentB = resolveTowAttachmentsForPair(towingVehicle, towedVehicle, towedModData) local attachmentA, attachmentB = resolveTowAttachmentsForPair(towingVehicle, towedVehicle, towedModData)
if attachmentA and attachmentB then if attachmentA and attachmentB then
@@ -443,7 +362,6 @@ function TowBarMod.Hook.setVehiclePostAttach(playerObj, towedVehicle, retriesLef
towingVehicle:transmitModData() towingVehicle:transmitModData()
towedVehicle:transmitModData() towedVehicle:transmitModData()
end end
TowBarMod.Utils.updateAttachmentsForRigidTow(towingVehicle, towedVehicle, attachmentA, attachmentB)
end end
end end
@@ -459,35 +377,9 @@ function TowBarMod.Hook.performAttachTowBar(playerObj, towingVehicle, towedVehic
if #(TowBarMod.Utils.getHookTypeVariants(towingVehicle, towedVehicle, true)) == 0 then return end if #(TowBarMod.Utils.getHookTypeVariants(towingVehicle, towedVehicle, true)) == 0 then return end
local towBarItem = getTowBarItem(playerObj) local towBarItem = getTowBarItem(playerObj)
if towBarItem ~= nil and not (isClient() and isMultiplayer()) then if towBarItem == nil then return end
sendClientCommand(playerObj, "towbar", "consumeTowBar", { itemId = towBarItem:getID() })
end
playerObj:setPrimaryHandItem(nil) 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 = { local args = {
vehicleA = towingVehicle:getId(), vehicleA = towingVehicle:getId(),
vehicleB = towedVehicle:getId(), vehicleB = towedVehicle:getId(),
@@ -496,7 +388,6 @@ function TowBarMod.Hook.performAttachTowBar(playerObj, towingVehicle, towedVehic
itemId = towBarItem and towBarItem:getID() or nil itemId = towBarItem and towBarItem:getID() or nil
} }
sendTowAttachCommand(playerObj, args) sendTowAttachCommand(playerObj, args)
ISTimedActionQueue.add(TowBarScheduleAction:new(playerObj, 10, TowBarMod.Hook.setVehiclePostAttach, towedVehicle))
end end
function TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle) function TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle)
@@ -538,7 +429,6 @@ function TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle)
towingVehicle:transmitModData() towingVehicle:transmitModData()
towedVehicle:transmitModData() towedVehicle:transmitModData()
TowBarMod.Hook.lastAutoReattachAtByVehicle[towingVehicle:getId()] = nil
setTowBarModelVisible(towedVehicle, false) setTowBarModelVisible(towedVehicle, false)
end end
@@ -547,46 +437,6 @@ function TowBarMod.Hook.performDetachTowBar(playerObj, towingVehicle, towedVehic
local args = { towingVehicle = towingVehicle:getId(), vehicle = towedVehicle:getId() } local args = { towingVehicle = towingVehicle:getId(), vehicle = towedVehicle:getId() }
sendClientCommand(playerObj, "towbar", "detachTowBar", args) 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 end
local function forEachCollectionItem(collection, callback) local function forEachCollectionItem(collection, callback)
@@ -634,14 +484,6 @@ local function keepTowBarVehiclesFreeRolling()
end) end)
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) function TowBarMod.Hook.attachByTowBarAction(playerObj, towingVehicle, towedVehicle)
if playerObj == nil or towingVehicle == nil or towedVehicle == nil then return end if playerObj == nil or towingVehicle == nil or towedVehicle == nil then return end
@@ -725,20 +567,11 @@ function TowBarMod.Hook.deattachTowBarAction(playerObj, vehicle)
end end
function TowBarMod.Hook.OnSpawnVehicle(vehicle) function TowBarMod.Hook.OnSpawnVehicle(vehicle)
recoverTowBarVehicleAfterLoad(nil, vehicle, 6) -- Server persistence snapshots own attach recovery after vehicle streaming.
end end
function TowBarMod.Hook.OnGameStart() function TowBarMod.Hook.OnGameStart()
local cell = getCell() -- Server persistence broadcasts the same attach snapshot used by a fresh pair.
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)
end end
--------------------------------------------------------------------------- ---------------------------------------------------------------------------
@@ -749,8 +582,9 @@ function TowBarMod.Hook.devShowAllTowbarModels(playerObj, vehicle)
if not vehicle then return end if not vehicle then return end
local normalPart = vehicle:getPartById("towbar") local normalPart = vehicle:getPartById("towbar")
local largePart = vehicle:getPartById("towbarLarge") local largePart = vehicle:getPartById("towbarLarge")
if normalPart == nil and largePart == nil then local ki5Part = vehicle:getPartById("towbarKI5")
print("[TowBar DEV] No 'towbar' or 'towbarLarge' part found on vehicle " .. tostring(vehicle:getScriptName())) if normalPart == nil and largePart == nil and ki5Part == nil then
print("[TowBar DEV] No towbar model part found on vehicle " .. tostring(vehicle:getScriptName()))
return return
end end
local script = vehicle:getScript() 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] chassisShape.z = " .. tostring(chassisZ) .. ", half = " .. tostring(halfZ))
print("[TowBar DEV] frontEdgeZ = " .. tostring(script and getTowbarFrontEdgeZ(script) or nil) .. ", part = " .. selectedPart) 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] 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 for j = 0, TowbarVariantSize - 1 do
if normalPart then normalPart:setModelVisible("towbar" .. j, true) end if normalPart then normalPart:setModelVisible("towbar" .. j, true) end
if largePart then largePart:setModelVisible("towbar" .. j, true) end if largePart then largePart:setModelVisible("towbar" .. j, true) end
if ki5Part then ki5Part:setModelVisible("towbar" .. j, true) end
end end
vehicle:doDamageOverlay() vehicle:doDamageOverlay()
end end
@@ -777,14 +612,16 @@ function TowBarMod.Hook.devHideAllTowbarModels(playerObj, vehicle)
if not vehicle then return end if not vehicle then return end
local normalPart = vehicle:getPartById("towbar") local normalPart = vehicle:getPartById("towbar")
local largePart = vehicle:getPartById("towbarLarge") local largePart = vehicle:getPartById("towbarLarge")
if normalPart == nil and largePart == nil then local ki5Part = vehicle:getPartById("towbarKI5")
print("[TowBar DEV] No 'towbar' or 'towbarLarge' part found on vehicle " .. tostring(vehicle:getScriptName())) if normalPart == nil and largePart == nil and ki5Part == nil then
print("[TowBar DEV] No towbar model part found on vehicle " .. tostring(vehicle:getScriptName()))
return return
end end
print("[TowBar DEV] Hiding ALL towbar models on " .. tostring(vehicle:getScriptName())) print("[TowBar DEV] Hiding ALL towbar models on " .. tostring(vehicle:getScriptName()))
for j = 0, TowbarVariantSize - 1 do for j = 0, TowbarVariantSize - 1 do
if normalPart then normalPart:setModelVisible("towbar" .. j, false) end if normalPart then normalPart:setModelVisible("towbar" .. j, false) end
if largePart then largePart:setModelVisible("towbar" .. j, false) end if largePart then largePart:setModelVisible("towbar" .. j, false) end
if ki5Part then ki5Part:setModelVisible("towbar" .. j, false) end
end end
vehicle:doDamageOverlay() vehicle:doDamageOverlay()
end end
@@ -793,24 +630,32 @@ function TowBarMod.Hook.devShowSingleTowbar(playerObj, vehicle, index)
if not vehicle then return end if not vehicle then return end
local normalPart = vehicle:getPartById("towbar") local normalPart = vehicle:getPartById("towbar")
local largePart = vehicle:getPartById("towbarLarge") local largePart = vehicle:getPartById("towbarLarge")
if normalPart == nil and largePart == nil then local ki5Part = vehicle:getPartById("towbarKI5")
print("[TowBar DEV] No 'towbar' or 'towbarLarge' part found on vehicle " .. tostring(vehicle:getScriptName())) if normalPart == nil and largePart == nil and ki5Part == nil then
print("[TowBar DEV] No towbar model part found on vehicle " .. tostring(vehicle:getScriptName()))
return return
end end
local localIndex = math.max(0, math.min(TowbarMaxIndex, index % TowbarVariantSize)) 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 for j = 0, TowbarVariantSize - 1 do
if normalPart then normalPart:setModelVisible("towbar" .. j, false) end if normalPart then normalPart:setModelVisible("towbar" .. j, false) end
if largePart then largePart:setModelVisible("towbar" .. j, false) end if largePart then largePart:setModelVisible("towbar" .. j, false) end
if ki5Part then ki5Part:setModelVisible("towbar" .. j, false) end
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 if part == nil then
part = normalPart or largePart part = normalPart or largePart or ki5Part
end 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 if part then
part:setModelVisible("towbar" .. localIndex, true) part:setModelVisible("towbar" .. localIndex, true)
end end
@@ -821,6 +666,4 @@ Events.OnSpawnVehicleEnd.Add(TowBarMod.Hook.OnSpawnVehicle)
if Events.OnGameStart then if Events.OnGameStart then
Events.OnGameStart.Add(TowBarMod.Hook.OnGameStart) Events.OnGameStart.Add(TowBarMod.Hook.OnGameStart)
end end
Events.OnEnterVehicle.Add(TowBarMod.Hook.OnEnterVehicle)
Events.OnSwitchVehicleSeat.Add(TowBarMod.Hook.OnSwitchVehicleSeat)
Events.OnTick.Add(keepTowBarVehiclesFreeRolling) Events.OnTick.Add(keepTowBarVehiclesFreeRolling)
@@ -4,6 +4,26 @@ TowBarMod.WreckerSync = TowBarMod.WreckerSync or {}
local Sync = TowBarMod.WreckerSync local Sync = TowBarMod.WreckerSync
Sync.appliedLevels = Sync.appliedLevels or {} 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 function pairKeyContainsVehicle(key, vehicleId)
local id = tostring(vehicleId) local id = tostring(vehicleId)
@@ -123,8 +143,10 @@ local function setScriptSafely(vehicle, scriptName)
return true return true
end end
local function applyAttachSync(args) local function applyAttachSync(args, forceReattach)
if not args then return end 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 wrecker = getVehicleById(args.wrecker)
local target = getVehicleById(args.target) local target = getVehicleById(args.target)
if not wrecker or not target then return end if not wrecker or not target then return end
@@ -138,7 +160,7 @@ local function applyAttachSync(args)
local key = pairKey(wrecker, target) local key = pairKey(wrecker, target)
local canonicalLevel = tonumber(args.heightLevel) or 0 local canonicalLevel = tonumber(args.heightLevel) or 0
local appliedLevel = Sync.appliedLevels[key] 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 Sync.appliedLevels[key] = canonicalLevel
applyFreeRollingState(target) applyFreeRollingState(target)
return return
@@ -167,6 +189,8 @@ end
local function applyDetachSync(args) local function applyDetachSync(args)
if not args then return end if not args then return end
local desiredKey = argsPairKey(args)
if desiredKey then Sync.desiredPairs[desiredKey] = nil end
local wrecker = getVehicleById(args.wrecker) local wrecker = getVehicleById(args.wrecker)
local target = getVehicleById(args.target) local target = getVehicleById(args.target)
if not target then return end if not target then return end
@@ -203,6 +227,30 @@ end
Sync.applyAttachSync = applyAttachSync Sync.applyAttachSync = applyAttachSync
Sync.applyDetachSync = applyDetachSync 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) Events.OnServerCommand.Add(function(module, command, args)
if module ~= "towbar" then return end if module ~= "towbar" then return end
if command == "wreckerAttachSync" then if command == "wreckerAttachSync" then
@@ -214,3 +262,9 @@ end)
if Events.OnSpawnVehicleEnd then if Events.OnSpawnVehicleEnd then
Events.OnSpawnVehicleEnd.Add(clearAppliedLevelForVehicle) Events.OnSpawnVehicleEnd.Add(clearAppliedLevelForVehicle)
end 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.Create = {}
BTtow.Init = {} BTtow.Init = {}
require("TowBar/VehicleCompatibility")
local TowbarVariantSize = 24 local TowbarVariantSize = 24
local TowbarMaxIndex = TowbarVariantSize - 1 local TowbarMaxIndex = TowbarVariantSize - 1
local TowbarFirstZ = 1.0 local TowbarFirstZ = 1.0
@@ -13,6 +15,8 @@ local TowbarVisualScale = 2.5
local TowbarModelLength = 0.9714089036 local TowbarModelLength = 0.9714089036
local TowbarScaledModelLength = TowbarModelLength * TowbarVisualScale local TowbarScaledModelLength = TowbarModelLength * TowbarVisualScale
local TowbarModelHalfLength = TowbarScaledModelLength / 2 local TowbarModelHalfLength = TowbarScaledModelLength / 2
local VanillaScaleMin = 1.5
local VanillaScaleMax = 2.0
local function getTowbarFrontEdgeZ(script) local function getTowbarFrontEdgeZ(script)
if not script then return nil end if not script then return nil end
@@ -53,6 +57,59 @@ local function getTowbarModelSlot(script)
return math.max(0, math.min(TowbarMaxIndex, index)) return math.max(0, math.min(TowbarMaxIndex, index))
end 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) function BTtow.Create.towbar(vehicle, part)
if part == nil then return end if part == nil then return end
for j=0, TowbarVariantSize - 1 do 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 if modData and modData["isTowingByTowBar"] and modData["towed"] then
local script = vehicle:getScript() local script = vehicle:getScript()
if script then if script then
local index = getTowbarModelSlot(script) local isKi5 = TowBarMod.Compatibility.isKi5Vehicle(vehicle)
local shouldShowOnThisPart = part:getId() == "towbar" 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 if shouldShowOnThisPart and index ~= nil then
part:setModelVisible("towbar" .. index, true) part:setModelVisible("towbar" .. index, true)
end end
+18 -149
View File
@@ -4,10 +4,8 @@ require("TowBar/Persistence")
local TowingCommands = {} local TowingCommands = {}
local Commands = {} local Commands = {}
local TowBarItemType = "TowBar.TowBar" local TowBarItemType = "TowBar.TowBar"
local SyncDelayTicks = 2
local SnapshotIntervalTicks = 120 local SnapshotIntervalTicks = 120
local BrokenPairAuditIntervalTicks = 5 local BrokenPairAuditIntervalTicks = 5
local pendingSync = {}
local snapshotTickCounter = 0 local snapshotTickCounter = 0
local brokenPairAuditTickCounter = 0 local brokenPairAuditTickCounter = 0
local confirmedTowPairs = {} local confirmedTowPairs = {}
@@ -24,57 +22,6 @@ local noise = function(msg)
end end
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) local function resolveAttachmentA(args, vehicleA)
if args and args.attachmentA then return args.attachmentA end if args and args.attachmentA then return args.attachmentA end
if vehicleA and vehicleA:getTowAttachmentSelf() then return vehicleA:getTowAttachmentSelf() end if vehicleA and vehicleA:getTowAttachmentSelf() then return vehicleA:getTowAttachmentSelf() end
@@ -369,10 +316,16 @@ local function broadcastAttach(vehicleA, vehicleB, attachmentA, attachmentB)
end end
local function broadcastDetach(vehicleAId, vehicleBId) local function broadcastDetach(vehicleAId, vehicleBId)
sendServerCommand("towbar", "forceDetachSync", { local args = {
vehicleA = vehicleAId, vehicleA = vehicleAId,
vehicleB = vehicleBId 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 end
local function broadcastSpontaneousDetach(vehicleA, vehicleB) local function broadcastSpontaneousDetach(vehicleA, vehicleB)
@@ -458,7 +411,6 @@ local function finalizeBrokenTowBarPair(towingVehicle, towedVehicle, reason)
return false return false
end end
cancelPendingAttach(towingVehicle, towedVehicle)
forgetTowBarPair(towingVehicle, towedVehicle) forgetTowBarPair(towingVehicle, towedVehicle)
breakTowBarConstraint(towingVehicle, towedVehicle) breakTowBarConstraint(towingVehicle, towedVehicle)
clearExpectedTowBarPair(towingVehicle, towedVehicle) clearExpectedTowBarPair(towingVehicle, towedVehicle)
@@ -567,54 +519,6 @@ local function reconcileBrokenTowBarPairsServer()
end) end)
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 function snapshotActiveTowbarLinksServer()
local cell = getCell() local cell = getCell()
if not cell then return end if not cell then return end
@@ -643,7 +547,7 @@ local function snapshotActiveTowbarLinksServer()
end) end)
end end
local function processPendingSync() local function processTowBarServerTick()
reconcileBrokenTowBarPairsServer() reconcileBrokenTowBarPairsServer()
snapshotTickCounter = snapshotTickCounter + 1 snapshotTickCounter = snapshotTickCounter + 1
@@ -651,37 +555,6 @@ local function processPendingSync()
snapshotTickCounter = 0 snapshotTickCounter = 0
snapshotActiveTowbarLinksServer() snapshotActiveTowbarLinksServer()
end 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 end
function Commands.attachTowBar(player, args) function Commands.attachTowBar(player, args)
@@ -707,9 +580,7 @@ function Commands.attachTowBar(player, args)
return return
end end
if isExpectedTowBarPair(vehicleA, vehicleB) if isExpectedTowBarPair(vehicleA, vehicleB)
or isExpectedTowBarPair(vehicleB, vehicleA) or isExpectedTowBarPair(vehicleB, vehicleA) then
or hasPendingAttach(vehicleA, vehicleB)
or hasPendingAttach(vehicleB, vehicleA) then
noise("rejected duplicate pending towbar attach") noise("rejected duplicate pending towbar attach")
return return
end end
@@ -724,12 +595,14 @@ function Commands.attachTowBar(player, args)
return return
end end
-- Constraint creation may complete on a later server tick in multiplayer. -- Match the working wrecker path: persist the accepted logical pair, ask
-- Record and queue the reserved item first so an immediate Build 42.20 -- the server for its native relation, then immediately tell clients to
-- spontaneous break can resolve the pair and consume the reservation once. -- replace that relation with the local rigid fake-trailer constraint.
markExpectedTowBarPair(vehicleA, vehicleB, args.attachmentA, args.attachmentB) markExpectedTowBarPair(vehicleA, vehicleB, args.attachmentA, args.attachmentB)
queueSync("attach", player, args, true) if isServer() then
vehicleA:addPointConstraint(player, vehicleB, args.attachmentA, args.attachmentB) vehicleA:addPointConstraint(player, vehicleB, args.attachmentA, args.attachmentB)
end
broadcastAttach(vehicleA, vehicleB, args.attachmentA, args.attachmentB)
if isLinked(vehicleA, vehicleB) then if isLinked(vehicleA, vehicleB) then
markTowBarPairConfirmed(vehicleA, vehicleB) markTowBarPairConfirmed(vehicleA, vehicleB)
end end
@@ -749,10 +622,6 @@ function Commands.detachTowBar(player, args)
noise("rejected unauthorized towbar detach") noise("rejected unauthorized towbar detach")
return return
end end
if not isLinked(towingVehicle, towedVehicle) then
noise("rejected mismatched or stale towbar detach")
return
end
if not isExpectedTowBarPair(towingVehicle, towedVehicle) if not isExpectedTowBarPair(towingVehicle, towedVehicle)
and not isLegacyTowBarPair(towingVehicle, towedVehicle) then and not isLegacyTowBarPair(towingVehicle, towedVehicle) then
noise("rejected detach for a non-towbar or stale linked pair") noise("rejected detach for a non-towbar or stale linked pair")
@@ -764,7 +633,7 @@ function Commands.detachTowBar(player, args)
breakTowBarConstraint(towingVehicle, towedVehicle) breakTowBarConstraint(towingVehicle, towedVehicle)
forgetTowBarPair(towingVehicle, towedVehicle) forgetTowBarPair(towingVehicle, towedVehicle)
clearExpectedTowBarPair(towingVehicle, towedVehicle) clearExpectedTowBarPair(towingVehicle, towedVehicle)
queueSync("detach", player, args) broadcastDetach(towingVehicle:getId(), towedVehicle:getId())
if shouldRefund then if shouldRefund then
giveTowBar(player, true) giveTowBar(player, true)
end end
@@ -817,4 +686,4 @@ TowingCommands.OnClientCommand = function(module, command, player, args)
end end
Events.OnClientCommand.Add(TowingCommands.OnClientCommand) 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
@@ -263,6 +263,36 @@ module Base
create = BTtow.Create.towbar, create = BTtow.Create.towbar,
init = BTtow.Init.towbar, init = BTtow.Init.towbar,
} }
}
part towbarKI5
{
model towbar0 { file = towbarModelKI5, offset = 0 -0.3 1.0, }
model towbar1 { file = towbarModelKI5, offset = 0 -0.3 1.1, }
model towbar2 { file = towbarModelKI5, offset = 0 -0.3 1.2, }
model towbar3 { file = towbarModelKI5, offset = 0 -0.3 1.3, }
model towbar4 { file = towbarModelKI5, offset = 0 -0.3 1.4, }
model towbar5 { file = towbarModelKI5, offset = 0 -0.3 1.5, }
model towbar6 { file = towbarModelKI5, offset = 0 -0.3 1.6, }
model towbar7 { file = towbarModelKI5, offset = 0 -0.3 1.7, }
model towbar8 { file = towbarModelKI5, offset = 0 -0.3 1.8, }
model towbar9 { file = towbarModelKI5, offset = 0 -0.3 1.9, }
model towbar10 { file = towbarModelKI5, offset = 0 -0.3 2.0, }
model towbar11 { file = towbarModelKI5, offset = 0 -0.3 2.1, }
model towbar12 { file = towbarModelKI5, offset = 0 -0.3 2.2, }
model towbar13 { file = towbarModelKI5, offset = 0 -0.3 2.3, }
model towbar14 { file = towbarModelKI5, offset = 0 -0.3 2.4, }
model towbar15 { file = towbarModelKI5, offset = 0 -0.3 2.5, }
model towbar16 { file = towbarModelKI5, offset = 0 -0.3 2.6, }
model towbar17 { file = towbarModelKI5, offset = 0 -0.3 2.7, }
model towbar18 { file = towbarModelKI5, offset = 0 -0.3 2.8, }
model towbar19 { file = towbarModelKI5, offset = 0 -0.3 2.9, }
model towbar20 { file = towbarModelKI5, offset = 0 -0.3 3.0, }
model towbar21 { file = towbarModelKI5, offset = 0 -0.3 3.1, }
model towbar22 { file = towbarModelKI5, offset = 0 -0.3 3.2, }
model towbar23 { file = towbarModelKI5, offset = 0 -0.3 3.3, }
area = Engine,
mechanicRequireKey = false,
lua { create = BTtow.Create.towbar, init = BTtow.Init.towbar, }
} }
part Battery part Battery
{ {
@@ -1,6 +1,13 @@
module Base module Base
{ {
model towbarModel model towbarModel
{
mesh = vehicles/Towbar,
texture = Vehicles/Towbar_Texture,
scale = 0.01,
}
model towbarModelKI5
{ {
mesh = vehicles/Towbar, mesh = vehicles/Towbar,
texture = Vehicles/Towbar_Texture, texture = Vehicles/Towbar_Texture,
@@ -280,5 +287,36 @@ module Base
} }
} }
part towbarKI5
{
model towbar0 { file = towbarModelKI5, offset = 0 -0.3 1.0, }
model towbar1 { file = towbarModelKI5, offset = 0 -0.3 1.1, }
model towbar2 { file = towbarModelKI5, offset = 0 -0.3 1.2, }
model towbar3 { file = towbarModelKI5, offset = 0 -0.3 1.3, }
model towbar4 { file = towbarModelKI5, offset = 0 -0.3 1.4, }
model towbar5 { file = towbarModelKI5, offset = 0 -0.3 1.5, }
model towbar6 { file = towbarModelKI5, offset = 0 -0.3 1.6, }
model towbar7 { file = towbarModelKI5, offset = 0 -0.3 1.7, }
model towbar8 { file = towbarModelKI5, offset = 0 -0.3 1.8, }
model towbar9 { file = towbarModelKI5, offset = 0 -0.3 1.9, }
model towbar10 { file = towbarModelKI5, offset = 0 -0.3 2.0, }
model towbar11 { file = towbarModelKI5, offset = 0 -0.3 2.1, }
model towbar12 { file = towbarModelKI5, offset = 0 -0.3 2.2, }
model towbar13 { file = towbarModelKI5, offset = 0 -0.3 2.3, }
model towbar14 { file = towbarModelKI5, offset = 0 -0.3 2.4, }
model towbar15 { file = towbarModelKI5, offset = 0 -0.3 2.5, }
model towbar16 { file = towbarModelKI5, offset = 0 -0.3 2.6, }
model towbar17 { file = towbarModelKI5, offset = 0 -0.3 2.7, }
model towbar18 { file = towbarModelKI5, offset = 0 -0.3 2.8, }
model towbar19 { file = towbarModelKI5, offset = 0 -0.3 2.9, }
model towbar20 { file = towbarModelKI5, offset = 0 -0.3 3.0, }
model towbar21 { file = towbarModelKI5, offset = 0 -0.3 3.1, }
model towbar22 { file = towbarModelKI5, offset = 0 -0.3 3.2, }
model towbar23 { file = towbarModelKI5, offset = 0 -0.3 3.3, }
area = Engine,
mechanicRequireKey = false,
lua { create = BTtow.Create.towbar, init = BTtow.Init.towbar, }
}
} }
} }
+2 -2
View File
@@ -1,11 +1,11 @@
name=Towbars name=Towbars
id=hrsys_towbars_testing id=hrsys_towbars
poster=../common/media/textures/preview.png poster=../common/media/textures/preview.png
description=Tow bars for vehicle-to-vehicle towing. description=Tow bars for vehicle-to-vehicle towing.
author=Riggs0 author=Riggs0
category=vehicle category=vehicle
icon=../common/media/textures/tow_bar_icon.png icon=../common/media/textures/tow_bar_icon.png
url=https://hudsonriggs.systems url=https://hudsonriggs.systems
modversion=1.0.12 modversion=1.0.22
versionMin=42.20.0 versionMin=42.20.0
incompatible=\STowTruck_B42 incompatible=\STowTruck_B42
+2 -2
View File
@@ -1,11 +1,11 @@
name=Towbars name=Towbars
id=hrsys_towbars_testing id=hrsys_towbars
poster=common/media/textures/preview.png poster=common/media/textures/preview.png
description=Tow bars for vehicle-to-vehicle towing. description=Tow bars for vehicle-to-vehicle towing.
author=Riggs0 author=Riggs0
category=vehicle category=vehicle
versionMin=42.13.0 versionMin=42.13.0
url=https://hudsonriggs.systems url=https://hudsonriggs.systems
modversion=1.0.12 modversion=1.0.22
icon=common/media/textures/tow_bar_icon.png icon=common/media/textures/tow_bar_icon.png
incompatible=\STowTruck_B42 incompatible=\STowTruck_B42
@@ -0,0 +1,249 @@
local failures = 0
local function expect(condition, message)
if not condition then
failures = failures + 1
io.stderr:write("FAIL: " .. message .. "\n")
end
end
package.path = table.concat({
"42.20/media/lua/client/?.lua",
"42.20/media/lua/shared/?.lua",
package.path
}, ";")
isServer = function() return false end
isClient = function() return true end
local serverCommandCallbacks = {}
local enterVehicleCallbacks = {}
local spawnVehicleCallbacks = {}
local tickCallbacks = {}
Events = {
OnServerCommand = {
Add = function(callback)
serverCommandCallbacks[#serverCommandCallbacks + 1] = callback
end
},
OnEnterVehicle = {
Add = function(callback)
enterVehicleCallbacks[#enterVehicleCallbacks + 1] = callback
end
},
OnSwitchVehicleSeat = { Add = function() end },
OnSpawnVehicleEnd = {
Add = function(callback)
spawnVehicleCallbacks[#spawnVehicleCallbacks + 1] = callback
end
},
OnTick = {
Add = function(callback)
tickCallbacks[#tickCallbacks + 1] = callback
end
}
}
local localPlayer = { vehicle = nil }
function localPlayer:getVehicle() return self.vehicle end
local attachmentIds = {
trailer = true,
trailerfront = true,
towbarWreckerHookLow = true,
towbarWreckerHookMid = true,
towbarWreckerHookHigh = true
}
local function unlink(vehicleA, vehicleB)
vehicleA.towing, vehicleA.towedBy = nil, nil
vehicleB.towing, vehicleB.towedBy = nil, nil
end
local function link(vehicleA, vehicleB)
vehicleA.towing, vehicleA.towedBy = vehicleB, nil
vehicleB.towing, vehicleB.towedBy = nil, vehicleA
end
local function newVehicle(id, sqlId)
local value = {
id = id,
sqlId = sqlId,
modData = {},
scriptName = "Base.DriverEntry" .. tostring(id),
mass = 1000,
brakingForce = 20,
towing = nil,
towedBy = nil,
driver = nil,
addCalls = 0,
breakCalls = 0
}
local script = {
getAttachmentById = function(_, attachmentId)
return attachmentIds[attachmentId] and {} or nil
end
}
function value:getId() return self.id end
function value:getSqlId() return self.sqlId end
function value:getModData() return self.modData end
function value:transmitModData() end
function value:getScript() return script end
function value:getScriptName() return self.scriptName end
function value:setScriptName(scriptName) self.scriptName = scriptName end
function value:getMass() return self.mass end
function value:setMass(mass) self.mass = mass end
function value:getBrakingForce() return self.brakingForce end
function value:setBrakingForce(force) self.brakingForce = force end
function value:isParkingBrakeOn() return false end
function value:setParkingBrakeOn() end
function value:getParkingBrake() return false end
function value:setParkingBrake() end
function value:isHandbrake() return false end
function value:setHandbrake() end
function value:getVehicleTowing() return self.towing end
function value:getVehicleTowedBy() return self.towedBy end
function value:isDriver(player) return self.driver == player end
function value:attachmentExist(attachmentId)
return attachmentIds[attachmentId] == true
end
function value:addPointConstraint(_, other)
self.addCalls = self.addCalls + 1
link(self, other)
end
function value:breakConstraint()
self.breakCalls = self.breakCalls + 1
local other = self.towing or self.towedBy
if other then unlink(self, other) end
end
return value
end
local portableTowing = newVehicle(101, 1001)
local portableTowed = newVehicle(102, 1002)
local wrecker = newVehicle(201, 2001)
local wreckerTarget = newVehicle(202, 2002)
local unrelated = newVehicle(301, 3001)
local allVehicles = {
portableTowing,
portableTowed,
wrecker,
wreckerTarget,
unrelated
}
local vehiclesById = {}
for _, vehicle in ipairs(allVehicles) do vehiclesById[vehicle:getId()] = vehicle end
getPlayer = function() return localPlayer end
getVehicleById = function(id) return vehiclesById[tonumber(id)] end
getCell = function()
return { getVehicles = function() return allVehicles end }
end
TowBarMod = {
Utils = { updateAttachmentsForRigidTow = function() end },
Hook = {
applyFreeRollingTowState = function() end,
setVehiclePostAttach = function() end,
setVehicleScriptWithTowBarHidden = function(vehicle, scriptName)
vehicle:setScriptName(scriptName)
return true
end,
cleanupDetachedTowBar = function() end
},
Wrecker = {
getWorldVehicles = function() return allVehicles end,
getHeightAttachmentId = function(level)
return ({
[0] = "towbarWreckerHookLow",
[1] = "towbarWreckerHookMid",
[2] = "towbarWreckerHookHigh"
})[tonumber(level) or 0]
end
}
}
-- Model a fully persisted portable pair whose transient sync cache was lost
-- during reload. The exact native relation is deliberately present: entry
-- must replace it with one fresh client-owned rigid constraint.
portableTowing.modData = {
isTowingByTowBar = true,
towed = false,
towBarTowedVehicleId = portableTowed:getId(),
towBarTowedVehicleSqlId = portableTowed:getSqlId(),
towBarExpectedAttachment = "trailer"
}
portableTowed.modData = {
isTowingByTowBar = true,
towed = true,
towBarTowingVehicleId = portableTowing:getId(),
towBarTowingVehicleSqlId = portableTowing:getSqlId(),
towBarExpectedAttachment = "trailerfront",
towBarOriginalScriptName = portableTowed:getScriptName()
}
link(portableTowing, portableTowed)
-- Model the equivalent persisted wrecker pair, also without applied-level
-- cache state. Both members carry the reciprocal identity written by the
-- authoritative wrecker attach path.
wrecker.modData = {
wreckerTowActive = true,
wreckerTowedVehicleId = wreckerTarget:getId(),
wreckerTowedVehicleSqlId = wreckerTarget:getSqlId(),
wreckerTargetAttachment = "trailerfront",
wreckerHeightLevel = 2
}
wreckerTarget.modData = {
wreckerTowingVehicleId = wrecker:getId(),
wreckerTowingVehicleSqlId = wrecker:getSqlId(),
wreckerOriginalScriptName = wreckerTarget:getScriptName()
}
link(wrecker, wreckerTarget)
dofile("42.20/media/lua/client/TowBar/TowSyncClient.lua")
dofile("42.20/media/lua/client/TowBar/WreckerSyncClient.lua")
expect(next(TowBarMod.Sync.desiredPairs) == nil,
"portable regression setup must not depend on transient desired-pair sync")
expect(next(TowBarMod.Sync.appliedPairs) == nil,
"portable regression setup must begin without an applied-pair cache")
expect(next(TowBarMod.WreckerSync.appliedLevels) == nil,
"wrecker regression setup must begin without an applied-level cache")
local function enterAsDriver(vehicle)
for _, candidate in ipairs(allVehicles) do candidate.driver = nil end
vehicle.driver = localPlayer
localPlayer.vehicle = vehicle
for i = 1, #enterVehicleCallbacks do
enterVehicleCallbacks[i](localPlayer)
end
end
enterAsDriver(portableTowing)
expect(portableTowing.breakCalls == 1 and portableTowing.addCalls == 1,
"portable driver entry must force exactly one rebuild from persisted modData")
enterAsDriver(portableTowing)
expect(portableTowing.breakCalls == 2 and portableTowing.addCalls == 2,
"each later portable driver entry must force one new rebuild, not remain cache-idempotent")
enterAsDriver(wrecker)
expect(wrecker.breakCalls == 1 and wrecker.addCalls == 1,
"wrecker driver entry must force exactly one rebuild from persisted modData")
enterAsDriver(wrecker)
expect(wrecker.breakCalls == 2 and wrecker.addCalls == 2,
"each later wrecker driver entry must force one new rebuild, not remain cache-idempotent")
local portableCounts = portableTowing.breakCalls + portableTowing.addCalls
local wreckerCounts = wrecker.breakCalls + wrecker.addCalls
enterAsDriver(unrelated)
expect(portableTowing.breakCalls + portableTowing.addCalls == portableCounts
and wrecker.breakCalls + wrecker.addCalls == wreckerCounts,
"entering an unrelated vehicle must not rebuild any persisted tow pair")
expect(#tickCallbacks == 0,
"driver-entry recovery must remain event-driven and must not install a tick retry loop")
if failures > 0 then os.exit(1) end
print("PASS: driver entry force-reattaches persisted portable and wrecker pairs once")
+84
View File
@@ -0,0 +1,84 @@
local failures = 0
local function expect(condition, message)
if not condition then
failures = failures + 1
io.stderr:write("FAIL: " .. message .. "\n")
end
end
local activeModIds = {}
getActivatedMods = function()
return {
contains = function(_, modId)
return activeModIds[modId] == true
end
}
end
local function vehicle(fullName, modelScale)
return {
getScript = function()
return {
getFullName = function() return fullName end,
getModelScale = function() return modelScale end
}
end
}
end
local classifierPath = "42.20/media/lua/shared/TowBar/VehicleCompatibility.lua"
local loaded, loadError = pcall(dofile, classifierPath)
expect(loaded, "dual-mode classifier must load from " .. classifierPath .. ": " .. tostring(loadError))
local Compatibility = TowBarMod and TowBarMod.Compatibility
expect(Compatibility ~= nil, "classifier must expose TowBarMod.Compatibility")
expect(Compatibility and type(Compatibility.isKi5Vehicle) == "function",
"classifier must expose isKi5Vehicle(vehicle)")
if Compatibility and type(Compatibility.isKi5Vehicle) == "function" then
local function isKi5(fullName, modelScale)
return Compatibility.isKi5Vehicle(vehicle(fullName, modelScale))
end
-- Positive contracts use exact mod IDs and exact script full names verified
-- from the locally installed KI5 workshop items.
activeModIds = { ["91range"] = true }
expect(isKi5("Base.91range", 0.01), "active KI5 Range Rover must use KI5 mode")
activeModIds = { ["87toyotaCorolla"] = true }
expect(isKi5("Base.87toyotaCorollaAE92levin", 1.75),
"active KI5 Corolla must use KI5 mode regardless of model scale")
activeModIds = { ["76chevyKseries"] = true }
expect(isKi5("Base.76chevyK30CCwrecker", 1.75),
"active KI5 Chevrolet variant must use KI5 mode")
-- Exact identity is intentional. Scale, name fragments, attachment shape,
-- and an active mod ID by itself must never capture another vehicle.
activeModIds = {}
expect(not isKi5("Base.91range", 0.01),
"a KI5-looking script without its exact active mod must use legacy mode")
activeModIds = { ["91range"] = true }
expect(not isKi5("Base.91rangeBurnt", 0.01),
"a suffixed KI5 lookalike must use legacy mode")
expect(not isKi5("Base.CarNormal", 0.01),
"vanilla must not be classified by KI5-like model scale")
expect(not isKi5("Base.SmallCar", 1.75),
"vanilla must not be classified by legacy vanilla model scale")
expect(not isKi5("Base.Chevalier_Rhino_TowTruck", 0.01),
"a non-KI5 tow-truck mod must use legacy mode")
activeModIds = { ["91range-copy"] = true }
expect(not isKi5("Base.91range", 0.01),
"a prefix or suffix match on mod ID must not enable KI5 mode")
expect(not Compatibility.isKi5Vehicle(nil), "nil vehicle must safely use legacy mode")
expect(not Compatibility.isKi5Vehicle({ getScript = function() return nil end }),
"vehicle without a script must safely use legacy mode")
end
if failures > 0 then os.exit(1) end
print("PASS: exact KI5 dual-mode classifier")
+30 -4
View File
@@ -156,14 +156,24 @@ local function runPortableHandlerSpec()
fireTicks(callbacks.tick, 5) fireTicks(callbacks.tick, 5)
expect(metrics.addConstraint == 1, "portable restart must request one native restore") expect(metrics.addConstraint == 1, "portable restart must request one native restore")
expect(metrics.attachSync == 1, "portable restart must broadcast one restore") expect(metrics.attachSync == 1,
"portable restart must immediately synchronize one wrecker-style rigid restore")
-- The restore is queued before the native call. Once Build 42 exposes the
-- reciprocal relation, the pending confirmation broadcasts exactly once.
linkPair(towing, towed)
now = 1100
fireTicks(callbacks.tick, 1)
expect(metrics.addConstraint == 1,
"portable confirmation must adopt the first native add without re-adding")
expect(metrics.attachSync == 1,
"portable native acknowledgement must not introduce a second sync phase")
now = 1500 now = 1500
fireTicks(callbacks.tick, 5) fireTicks(callbacks.tick, 4)
expect(metrics.addConstraint == 1, "portable pending restore must not duplicate its constraint") expect(metrics.addConstraint == 1, "portable pending restore must not duplicate its constraint")
expect(metrics.attachSync == 1, "portable pending restore must not duplicate its sync") expect(metrics.attachSync == 1, "portable pending restore must not duplicate its sync")
linkPair(towing, towed)
now = 2000 now = 2000
fireTicks(callbacks.tick, 5) fireTicks(callbacks.tick, 5)
expect(metrics.addConstraint == 1, "portable delayed acknowledgement must be adopted without re-adding") expect(metrics.addConstraint == 1, "portable delayed acknowledgement must be adopted without re-adding")
@@ -187,7 +197,23 @@ local function runPortableHandlerSpec()
now = 17000 now = 17000
fireTicks(callbacks.tick, 5) fireTicks(callbacks.tick, 5)
expect(metrics.addConstraint == 2, "a reloaded portable peer must re-enter recovery") expect(metrics.addConstraint == 2, "a reloaded portable peer must re-enter recovery")
expect(metrics.attachSync == 2, "a reloaded portable peer must receive one fresh restore sync") expect(metrics.attachSync == 2,
"a reloaded portable peer must receive one fresh wrecker-style restore sync")
linkPair(towing, towed)
now = 17100
fireTicks(callbacks.tick, 1)
expect(metrics.addConstraint == 2,
"reloaded portable confirmation must adopt without another native add")
expect(metrics.attachSync == 2,
"reloaded portable native acknowledgement must not duplicate its sync")
now = 17500
fireTicks(callbacks.tick, 4)
expect(metrics.addConstraint == 2,
"confirmed reloaded pair must not duplicate its native add")
expect(metrics.attachSync == 2,
"confirmed reloaded pair must not duplicate its authoritative sync")
expect(metrics.worldDrops == 0, "portable peer reload must not be mistaken for a break") expect(metrics.worldDrops == 0, "portable peer reload must not be mistaken for a break")
expect(metrics.breakConstraint == 0, "portable peer reload must not break an unrelated constraint") expect(metrics.breakConstraint == 0, "portable peer reload must not break an unrelated constraint")
end end
@@ -0,0 +1,224 @@
local failures = 0
local function expect(condition, message)
if not condition then
failures = failures + 1
io.stderr:write("FAIL: " .. message .. "\n")
end
end
package.path = table.concat({
"42.20/media/lua/client/?.lua",
"42.20/media/lua/shared/?.lua",
package.path
}, ";")
isServer = function() return false end
isClient = function() return true end
local serverCommandCallbacks = {}
local enterVehicleCallbacks = {}
local spawnVehicleCallbacks = {}
Events = {
OnServerCommand = {
Add = function(callback)
serverCommandCallbacks[#serverCommandCallbacks + 1] = callback
end
},
OnEnterVehicle = {
Add = function(callback)
enterVehicleCallbacks[#enterVehicleCallbacks + 1] = callback
end
},
OnSpawnVehicleEnd = {
Add = function(callback)
spawnVehicleCallbacks[#spawnVehicleCallbacks + 1] = callback
end
}
}
local localPlayer = { onlineId = 7 }
local function attachmentScript()
local attachments = { trailer = {}, trailerfront = {} }
return {
getAttachmentById = function(_, attachmentId)
return attachments[attachmentId]
end
}
end
local function unlink(a, b)
a.towing, a.towedBy = nil, nil
b.towing, b.towedBy = nil, nil
end
local function vehicle(id)
local value = {
id = id,
script = attachmentScript(),
scriptName = "Base.DriverOwnership" .. tostring(id),
modData = {},
driver = nil,
towing = nil,
towedBy = nil,
addCalls = 0,
breakCalls = 0,
freeRolling = false,
freeRollingAtAdd = {}
}
function value:getId() return self.id end
function value:getScript() return self.script end
function value:getScriptName() return self.scriptName end
function value:setScriptName(scriptName) self.scriptName = scriptName end
function value:getModData() return self.modData end
function value:transmitModData() end
function value:getMass() return 1000 end
function value:getBrakingForce() return 20 end
function value:getVehicleTowing() return self.towing end
function value:getVehicleTowedBy() return self.towedBy end
function value:getDriver() return self.driver end
function value:isDriver(player) return self.driver == player end
function value:addPointConstraint(player, other, attachmentA, attachmentB, localOnly)
self.addCalls = self.addCalls + 1
self.freeRollingAtAdd[#self.freeRollingAtAdd + 1] = other.freeRolling
expect(player == nil, "client rigid attach must not impersonate a player")
expect(attachmentA == "trailer" and attachmentB == "trailerfront",
"deferred attach must preserve its authoritative endpoints")
expect(localOnly == true, "deferred attach must remain client-local")
self.towing = other
other.towedBy = self
end
function value:breakConstraint()
self.breakCalls = self.breakCalls + 1
local other = self.towing or self.towedBy
if other then unlink(self, other) end
end
return value
end
local vehicles = {
[101] = vehicle(101),
[102] = vehicle(102),
[201] = vehicle(201),
[202] = vehicle(202)
}
getVehicleById = function(id) return vehicles[tonumber(id)] end
getPlayer = function() return localPlayer end
TowBarMod = {
Utils = { updateAttachmentsForRigidTow = function() end },
Hook = {
setVehicleScriptWithTowBarHidden = function(target, scriptName)
target:setScriptName(scriptName)
return true
end,
setVehiclePostAttach = function(_, target)
-- This stands in for applyFreeRollingTowState(). The ownership
-- handoff must run it before Bullet receives the local constraint,
-- matching the working wrecker order.
target.freeRolling = true
end,
cleanupDetachedTowBar = function() end
}
}
dofile("42.20/media/lua/client/TowBar/TowSyncClient.lua")
local function serverCommand(command, vehicleA, vehicleB)
local args = {
vehicleA = vehicleA:getId(),
vehicleB = vehicleB:getId(),
attachmentA = "trailer",
attachmentB = "trailerfront"
}
for i = 1, #serverCommandCallbacks do
serverCommandCallbacks[i]("towbar", command, args)
end
end
local function enterVehicle(player)
for i = 1, #enterVehicleCallbacks do
enterVehicleCallbacks[i](player)
end
end
local function spawnVehicle(value)
for i = 1, #spawnVehicleCallbacks do
spawnVehicleCallbacks[i](value)
end
end
-- Portable installation finishes while the player is standing beside the two
-- cars. At this point the local client does not own either vehicle's physics.
local towing, towed = vehicles[101], vehicles[102]
-- The server's ordinary towing relation may reach this client before the mod
-- command. It is usable as a visual snap, but replacing it before driver
-- ownership is precisely the multiplayer race this regression covers.
towing.towing = towed
towed.towedBy = towing
serverCommand("forceAttachSync", towing, towed)
expect(type(TowBarMod.Sync.desiredPairs) == "table",
"authoritative sync must retain desired pairs until this client owns the towing vehicle")
expect(towing.addCalls == 0 and towing.breakCalls == 0,
"sync received outside the driver seat must not create or replace local physics")
-- Entering as driver transfers physics ownership to this client. That event is
-- the first safe point to replace the server/native relation with rigid towing.
towing.driver = localPlayer
expect(#enterVehicleCallbacks == 1,
"portable sync must register one driver-entry ownership handoff")
enterVehicle(localPlayer)
expect(towing.addCalls == 1,
"becoming driver must apply the deferred rigid constraint exactly once")
expect(towing.breakCalls == 1,
"driver ownership handoff must replace the pre-existing native relation exactly once")
expect(towing:getVehicleTowing() == towed and towed:getVehicleTowedBy() == towing,
"driver ownership handoff must leave the authoritative pair linked")
expect(towing.freeRollingAtAdd[1] == true,
"towed free-roll state must be applied before the rigid constraint is created")
-- The user explicitly chose driver entry as a forced repair point. A later
-- entry rebuilds once, while the ordinary server snapshot remains idempotent.
enterVehicle(localPlayer)
serverCommand("forceAttachSync", towing, towed)
expect(towing.addCalls == 2 and towing.breakCalls == 2,
"each driver entry must force one rebuild while snapshots do not add another")
-- A detach can race the later driver entry. It must cancel the desired pair so
-- that entering the vehicle cannot resurrect a server-rejected tow.
local canceledTowing, canceledTowed = vehicles[201], vehicles[202]
serverCommand("forceAttachSync", canceledTowing, canceledTowed)
expect(canceledTowing.addCalls == 0 and canceledTowing.breakCalls == 0,
"a second outside-seat sync must also remain pending without touching physics")
-- The dedicated server may already have replicated its native relation. A
-- non-owning client must not break that relation while canceling its handoff.
canceledTowing.towing = canceledTowed
canceledTowed.towedBy = canceledTowing
serverCommand("forceDetachSync", canceledTowing, canceledTowed)
expect(TowBarMod.Sync.desiredPairs["201:202"] == nil,
"detach must remove the pending desired pair before any later seat event")
canceledTowing.driver = localPlayer
enterVehicle(localPlayer)
expect(canceledTowing.addCalls == 0 and canceledTowing.breakCalls == 0,
"detach must cancel pending ownership handoff without a non-owner break")
-- A restart snapshot can arrive before either vehicle has streamed in. Keep
-- that desired state and retry it from vehicle spawn when this player already
-- owns the towing vehicle.
local streamedTowing, streamedTowed = vehicle(301), vehicle(302)
serverCommand("forceAttachSync", streamedTowing, streamedTowed)
expect(TowBarMod.Sync.desiredPairs["301:302"] ~= nil,
"missing streamed peers must not discard the authoritative attach snapshot")
streamedTowing.driver = localPlayer
vehicles[301], vehicles[302] = streamedTowing, streamedTowed
spawnVehicle(streamedTowed)
expect(streamedTowing.addCalls == 1,
"vehicle streaming must apply one saved rigid handoff for an existing local driver")
if failures > 0 then os.exit(1) end
print("PASS: portable multiplayer rigid tow waits for driver physics ownership")
@@ -0,0 +1,290 @@
local failures = 0
local function expect(condition, message)
if not condition then
failures = failures + 1
io.stderr:write("FAIL: " .. message .. "\n")
end
end
package.path = table.concat({
"42.20/media/lua/client/?.lua",
"42.20/media/lua/shared/?.lua",
package.path
}, ";")
local runtime = "client"
isServer = function() return runtime == "server" end
isClient = function() return runtime == "client" end
getDebug = function() return false end
getTimestampMs = function() return 1000 end
local serverCommandCallbacks = {}
local clientPlayer = { onlineId = 17 }
Events = {
OnServerCommand = {
Add = function(callback)
serverCommandCallbacks[#serverCommandCallbacks + 1] = callback
end
},
OnSpawnVehicleEnd = { Add = function() end }
}
local function attachmentScript()
local attachments = { trailer = {}, trailerfront = {} }
return {
getAttachmentById = function(_, attachmentId)
return attachments[attachmentId]
end
}
end
local function vehicle(id, sqlId, acknowledgeConstraint)
local value = {
id = id,
sqlId = sqlId,
script = attachmentScript(),
scriptName = "Base.MultiplayerRegression" .. tostring(id),
modData = {},
towing = nil,
towedBy = nil,
constraintKind = nil,
acknowledgeConstraint = acknowledgeConstraint,
constraintCalls = 0
}
function value:getId() return self.id end
function value:getSqlId() return self.sqlId end
function value:getScript() return self.script end
function value:getScriptName() return self.scriptName end
function value:setScriptName(scriptName) self.scriptName = scriptName end
function value:getModData() return self.modData end
function value:transmitModData() end
function value:getMass() return 1000 end
function value:getBrakingForce() return 20 end
function value:getVehicleTowing() return self.towing end
function value:getVehicleTowedBy() return self.towedBy end
function value:isDriver(player) return self.driver == player end
function value:getTowAttachmentSelf() return nil end
function value:attachmentExist(attachmentId)
return self.script:getAttachmentById(attachmentId) ~= nil
end
function value:getX() return 0 end
function value:getY() return 0 end
function value:addPointConstraint(_, other)
self.constraintCalls = self.constraintCalls + 1
if self.acknowledgeConstraint then
self.towing = other
other.towedBy = self
self.constraintKind = runtime == "client" and "rigid" or "native"
other.constraintKind = self.constraintKind
end
end
function value:breakConstraint()
local other = self.towing or self.towedBy
self.towing, self.towedBy = nil, nil
self.constraintKind = nil
if other then
other.towing, other.towedBy = nil, nil
other.constraintKind = nil
end
end
return value
end
local clientVehicles = {
[101] = vehicle(101, 1001, true),
[102] = vehicle(102, 1002, true),
[201] = vehicle(201, 2001, false),
[202] = vehicle(202, 2002, false)
}
clientVehicles[101].driver = clientPlayer
clientVehicles[201].driver = clientPlayer
local serverVehicles = {
-- This models the dedicated-server behavior behind the live regression:
-- addPointConstraint is accepted, but the server getters do not expose the
-- relation before the authoritative client sync must be sent.
[101] = vehicle(101, 1001, false),
[102] = vehicle(102, 1002, false)
}
getVehicleById = function(id)
local vehicles = runtime == "server" and serverVehicles or clientVehicles
return vehicles[tonumber(id)]
end
getPlayer = function() return clientPlayer end
TowBarMod = {
Utils = {
updateAttachmentsForRigidTow = function() end
},
Hook = {
applyFreeRollingTowState = function() end,
setVehicleScriptWithTowBarHidden = function(target, scriptName)
target:setScriptName(scriptName)
return true
end,
setVehiclePostAttach = function() end,
cleanupDetachedTowBar = function() end
}
}
-- Load the real portable client sync and rigid-constraint implementation.
dofile("42.20/media/lua/client/TowBar/TowSyncClient.lua")
local globalData = {}
ModData = {
getOrCreate = function(name)
globalData[name] = globalData[name] or {}
return globalData[name]
end,
transmit = function() end
}
local serverCallbacks = {}
Events = {
OnClientCommand = { Add = function(callback) serverCallbacks.clientCommand = callback end },
OnTick = { Add = function(callback) serverCallbacks.tick = callback end }
}
runtime = "server"
local item = { id = 7001 }
function item:getID() return self.id end
function item:getFullType() return "TowBar.TowBar" end
local inventory = { items = { item } }
function inventory:getItemWithID(id)
return self.items[1] and self.items[1]:getID() == id and self.items[1] or nil
end
function inventory:getFirstTypeRecurse(fullType)
return self.items[1] and self.items[1]:getFullType() == fullType and self.items[1] or nil
end
function inventory:contains(candidate) return self.items[1] == candidate end
function inventory:Remove(candidate)
if self.items[1] == candidate then self.items[1] = nil end
end
function inventory:AddItem() return nil end
local player = { vehicle = serverVehicles[101] }
function player:getInventory() return inventory end
function player:getVehicle() return self.vehicle end
function player:getX() return 0 end
function player:getY() return 0 end
function player:isPrimaryHandItem() return false end
function player:isSecondaryHandItem() return false end
function player:removeFromHands() end
function player:setPrimaryHandItem() end
sendRemoveItemFromContainer = function() end
sendAddItemToContainer = function() end
sendEquip = function() end
local attachSyncCount = 0
local attachSyncPhases = {}
sendServerCommand = function(module, command, args)
if module == "towbar" and command == "forceAttachSync" then
attachSyncCount = attachSyncCount + 1
attachSyncPhases[#attachSyncPhases + 1] = args.phase
end
local previousRuntime = runtime
runtime = "client"
for i = 1, #serverCommandCallbacks do
serverCommandCallbacks[i](module, command, args)
end
runtime = previousRuntime
end
getCell = function()
return { getVehicles = function() return nil end }
end
-- Load the real server command handler after the client event handler has been
-- captured. Both halves now communicate through sendServerCommand above.
dofile("42.20/media/lua/server/TowingCommands.lua")
serverCallbacks.clientCommand("towbar", "attachTowBar", player, {
vehicleA = 101,
vehicleB = 102,
attachmentA = "trailer",
attachmentB = "trailerfront",
itemId = item:getID()
})
expect(serverVehicles[101].modData.isTowingByTowBar == true,
"accepted MP attach must publish towbar state for its visual")
expect(serverVehicles[101].constraintCalls == 1,
"accepted MP attach must still request the server-side logical constraint")
expect(attachSyncCount == 1,
"accepted MP attach must immediately send one forceAttachSync even when server towing getters lag")
expect(attachSyncPhases[1] == nil,
"portable MP attach must use the same single unphased sync as the working wrecker")
expect(clientVehicles[101]:getVehicleTowing() == clientVehicles[102]
and clientVehicles[102]:getVehicleTowedBy() == clientVehicles[101],
"the same command that shows the MP towbar visual must create the client rigid constraint")
expect(clientVehicles[101].constraintKind == "rigid",
"the one-shot sync must create rigid rather than native towing physics")
-- Like the working wrecker, server acknowledgement is bookkeeping only. It
-- must not introduce a second portable-only attach protocol or rebuild.
serverVehicles[101].towing = serverVehicles[102]
serverVehicles[102].towedBy = serverVehicles[101]
serverCallbacks.tick()
serverCallbacks.tick()
expect(attachSyncCount == 1,
"server acknowledgement must not send a second portable-only attach phase")
expect(clientVehicles[101].constraintCalls == 1,
"the wrecker-style attach must submit one local rigid constraint")
expect(clientVehicles[101].constraintKind == "rigid",
"the single attach sync must leave the client using rigid towing physics")
sendServerCommand("towbar", "forceAttachSync", {
vehicleA = 101,
vehicleB = 102,
attachmentA = "trailer",
attachmentB = "trailerfront"
})
expect(clientVehicles[101].constraintCalls == 1,
"duplicate snapshots must not repeatedly rebuild rigid physics")
-- The local add itself can also precede the engine's towing-getter update.
-- Reconciliation must adopt the now-visible exact pair without duplicating
-- the working wrecker-style single attach.
runtime = "client"
local delayedProvisional = {
vehicleA = 201,
vehicleB = 202,
attachmentA = "trailer",
attachmentB = "trailerfront"
}
for i = 1, #serverCommandCallbacks do
serverCommandCallbacks[i]("towbar", "forceAttachSync", delayedProvisional)
end
expect(clientVehicles[201].constraintCalls == 1
and clientVehicles[201]:getVehicleTowing() == nil,
"a rigid add may be accepted before its local getters update")
clientVehicles[201].towing = clientVehicles[202]
clientVehicles[202].towedBy = clientVehicles[201]
clientVehicles[201].constraintKind = "rigid"
clientVehicles[202].constraintKind = "rigid"
for i = 1, #serverCommandCallbacks do
serverCommandCallbacks[i]("towbar", "forceAttachSync", delayedProvisional)
end
expect(clientVehicles[201].constraintCalls == 1,
"delayed local getter acknowledgement must make the next snapshot idempotent")
clientVehicles[201].acknowledgeConstraint = true
for i = 1, #serverCommandCallbacks do
serverCommandCallbacks[i]("towbar", "forceAttachSync", delayedProvisional)
end
expect(clientVehicles[201].constraintCalls == 1
and clientVehicles[201].constraintKind == "rigid",
"duplicate sync after delayed local acceptance must remain idempotent")
runtime = "server"
if failures > 0 then os.exit(1) end
print("PASS: multiplayer portable towbar immediately synchronizes its rigid client constraint")
+178
View File
@@ -0,0 +1,178 @@
local failures = 0
local function expect(condition, message)
if not condition then
failures = failures + 1
io.stderr:write("FAIL: " .. message .. "\n")
end
end
isServer = function() return false end
local metrics = {
offsets = 0,
breaks = 0,
adds = 0,
hides = 0,
postAttach = 0,
scriptSwaps = 0,
freeRolling = 0
}
local trace = {}
local function record(event) trace[#trace + 1] = event end
local function script()
return {
getAttachmentById = function(_, id)
if id == "trailer" or id == "trailerfront" then return {} end
return nil
end
}
end
local acknowledgeAdd = true
local function unlink(a, b)
a.towing, a.towedBy = nil, nil
b.towing, b.towedBy = nil, nil
end
local function link(a, b)
a.towing, a.towedBy = b, nil
b.towing, b.towedBy = nil, a
end
local function vehicle(id)
local value = {
id = id,
towing = nil,
towedBy = nil,
script = script(),
scriptName = "Base.TestVehicle",
modData = {}
}
function value:getId() return self.id end
function value:getScript() return self.script end
function value:getScriptName() return self.scriptName end
function value:getModData() return self.modData end
function value:getVehicleTowing() return self.towing end
function value:getVehicleTowedBy() return self.towedBy end
function value:breakConstraint()
metrics.breaks = metrics.breaks + 1
record("break")
local other = self.towing or self.towedBy
if other then unlink(self, other) end
end
function value:addPointConstraint(player, other, attachmentA, attachmentB, localOnly)
metrics.adds = metrics.adds + 1
record("add")
expect(player == nil, "rigid primitive must not impersonate a player")
expect(attachmentA == "trailer" and attachmentB == "trailerfront",
"rigid primitive must preserve selected attachment IDs")
expect(localOnly == true, "rigid primitive must create the client-local rigid constraint")
if acknowledgeAdd then link(self, other) end
end
return value
end
TowBarMod = {
Utils = {
updateAttachmentsForRigidTow = function(a, b, attachmentA, attachmentB)
metrics.offsets = metrics.offsets + 1
expect(a ~= nil and b ~= nil, "rigid offset update must receive both vehicles")
expect(attachmentA == "trailer" and attachmentB == "trailerfront",
"rigid offset update must receive the selected endpoints")
end
},
Hook = {
applyFreeRollingTowState = function(vehicle)
metrics.freeRolling = metrics.freeRolling + 1
record("free")
expect(vehicle ~= nil, "rigid primitive must receive the towed vehicle for free-roll setup")
end,
setVehicleScriptWithTowBarHidden = function(vehicle, scriptName)
metrics.hides = metrics.hides + 1
metrics.scriptSwaps = metrics.scriptSwaps + 1
vehicle.scriptName = scriptName
record("script:" .. tostring(scriptName))
expect(vehicle ~= nil,
"rigid primitive must receive a vehicle for each hidden script swap")
end,
setVehiclePostAttach = function(player, vehicle, towingVehicle)
metrics.postAttach = metrics.postAttach + 1
record("post")
expect(player == nil and vehicle ~= nil and towingVehicle ~= nil,
"rigid primitive must restore post-attach state after acknowledgement")
expect(vehicle:getScriptName() == "Base.TestVehicle",
"rigid primitive must restore the real script before post-attach finalization")
end
}
}
dofile("42.20/media/lua/client/TowBar/RigidTow.lua")
expect(TowBarMod.RigidTow ~= nil and type(TowBarMod.RigidTow.attach) == "function",
"production rigid module must expose attach")
-- First attach with no physical relation uses the exact rigid path once.
local a, b = vehicle(101), vehicle(102)
trace = {}
local attached = TowBarMod.RigidTow.attach(a, b, "trailer", "trailerfront")
expect(attached == true, "acknowledged initial rigid add must succeed")
expect(a:getVehicleTowing() == b and b:getVehicleTowedBy() == a,
"acknowledged rigid add must leave an exact reciprocal relation")
expect(metrics.offsets == 1 and metrics.adds == 1 and metrics.breaks == 0,
"initial rigid add must update offsets and add once without a needless break")
expect(metrics.scriptSwaps == 2 and metrics.postAttach == 1,
"successful rigid add must select the fake script and restore the real script exactly once")
expect(table.concat(trace, ",") == "free,script:notTowingA_Trailer,add,script:Base.TestVehicle,post",
"initial rigid sequence must be free-roll, fake script, local add, real script, then post-attach")
-- A restored native relation is replaced inside this primitive. The lifecycle
-- controller must not grow its own second copy of this physics sequence.
local restoredA, restoredB = vehicle(201), vehicle(202)
link(restoredA, restoredB)
local beforeOffsets, beforeAdds = metrics.offsets, metrics.adds
local beforeBreaks, beforePost = metrics.breaks, metrics.postAttach
trace = {}
attached = TowBarMod.RigidTow.attach(restoredA, restoredB, "trailer", "trailerfront")
expect(attached == true, "restored native relation must be replaced by rigid mechanics")
expect(metrics.offsets == beforeOffsets + 1 and metrics.adds == beforeAdds + 1,
"native replacement must update offsets and add one rigid constraint")
expect(metrics.breaks == beforeBreaks + 1,
"native replacement must break the exact old pair once")
expect(metrics.postAttach == beforePost + 1,
"native replacement must run post-attach only after the new link exists")
expect(table.concat(trace, ",") == "break,free,script:notTowingA_Trailer,add,script:Base.TestVehicle,post",
"native replacement sequence must break, free-roll, use the fake script, add, restore the real script, then finalize")
-- A link to a different vehicle is never collateral damage.
local conflictA, expectedB, unrelated = vehicle(301), vehicle(302), vehicle(399)
link(conflictA, unrelated)
local beforeConflictBreaks, beforeConflictAdds = metrics.breaks, metrics.adds
attached = TowBarMod.RigidTow.attach(conflictA, expectedB, "trailer", "trailerfront")
expect(attached == false, "conflicting physical link must reject rigid attachment")
expect(metrics.breaks == beforeConflictBreaks and metrics.adds == beforeConflictAdds,
"conflicting physical link must not break or layer another constraint")
-- Invalid endpoints are rejected before modifying a valid existing pair.
local missingA, missingB = vehicle(351), vehicle(352)
link(missingA, missingB)
local beforeMissingBreaks, beforeMissingAdds = metrics.breaks, metrics.adds
attached = TowBarMod.RigidTow.attach(missingA, missingB, "missing", "trailerfront")
expect(attached == false, "missing attachment must reject rigid attachment")
expect(metrics.breaks == beforeMissingBreaks and metrics.adds == beforeMissingAdds,
"missing attachment must not break the existing pair or attempt an add")
-- In multiplayer Java/Bullet may accept the local constraint before the Lua
-- reciprocal getters update. Submission is success; waiting for those getters
-- here strands an already-streamed pair with only its visual state applied.
local delayedA, delayedB = vehicle(401), vehicle(402)
acknowledgeAdd = false
local beforeDelayedPost = metrics.postAttach
attached = TowBarMod.RigidTow.attach(delayedA, delayedB, "trailer", "trailerfront")
expect(attached == true, "submitted rigid add must tolerate delayed multiplayer getters")
expect(metrics.postAttach == beforeDelayedPost + 1,
"delayed multiplayer getters must not block post-attach restoration")
if failures > 0 then os.exit(1) end
print("PASS: single rigid-tow contract preserves exact-pair mechanics and delayed MP submission")
+271
View File
@@ -0,0 +1,271 @@
local failures = 0
local function expect(condition, message)
if not condition then
failures = failures + 1
io.stderr:write("FAIL: " .. message .. "\n")
end
end
package.path = "42.20/media/lua/shared/?.lua;" .. package.path
local globalData = {}
ModData = {
getOrCreate = function(name)
globalData[name] = globalData[name] or {}
return globalData[name]
end,
transmit = function() end
}
TowBarMod = {}
require("TowBar/Persistence")
local callbacks = {}
Events = {
OnClientCommand = { Add = function(callback) callbacks.clientCommand = callback end },
OnTick = { Add = function(callback) callbacks.tick = callback end }
}
isClient = function() return false end
isServer = function() return true end
getDebug = function() return false end
getTimestampMs = function() return 1000 end
local metrics = {
removals = 0,
refunds = 0,
attachSyncs = 0,
detachSyncs = 0
}
local nextItemId = 700
local function towbarItem()
nextItemId = nextItemId + 1
local item = { id = nextItemId }
function item:getID() return self.id end
function item:getFullType() return "TowBar.TowBar" end
return item
end
local inventory = { items = { towbarItem() } }
function inventory:getItemWithID(id)
for i = 1, #self.items do
if self.items[i]:getID() == id then return self.items[i] end
end
end
function inventory:getFirstTypeRecurse(fullType)
for i = 1, #self.items do
if self.items[i]:getFullType() == fullType then return self.items[i] end
end
end
function inventory:contains(item)
for i = 1, #self.items do
if self.items[i] == item then return true end
end
return false
end
function inventory:Remove(item)
for i = #self.items, 1, -1 do
if self.items[i] == item then table.remove(self.items, i) end
end
end
function inventory:AddItem(fullType)
expect(fullType == "TowBar.TowBar", "refund must create the correct item type")
local item = towbarItem()
table.insert(self.items, item)
return item
end
local player = { primary = nil }
function player:getInventory() return inventory end
function player:getVehicle() return self.vehicle end
function player:getX() return 0 end
function player:getY() return 0 end
function player:isPrimaryHandItem(item) return self.primary == item end
function player:isSecondaryHandItem() return false end
function player:removeFromHands(item)
if self.primary == item then self.primary = nil end
end
function player:setPrimaryHandItem(item) self.primary = item end
sendRemoveItemFromContainer = function()
metrics.removals = metrics.removals + 1
end
sendAddItemToContainer = function()
metrics.refunds = metrics.refunds + 1
end
sendEquip = function() end
local function vehicle(id, sqlId, acknowledgeAttach)
local value = {
id = id,
sqlId = sqlId,
modData = {},
towing = nil,
towedBy = nil,
acknowledgeAttach = acknowledgeAttach
}
function value:getId() return self.id end
function value:getSqlId() return self.sqlId end
function value:getModData() return self.modData end
function value:transmitModData() end
function value:getVehicleTowing() return self.towing end
function value:getVehicleTowedBy() return self.towedBy end
function value:getTowAttachmentSelf() return nil end
function value:attachmentExist() return true end
function value:getX() return 0 end
function value:getY() return 0 end
function value:addPointConstraint(_, other)
if self.acknowledgeAttach then
self.towing = other
other.towedBy = self
end
end
function value:breakConstraint()
local other = self.towing or self.towedBy
self.towing, self.towedBy = nil, nil
if other then other.towing, other.towedBy = nil, nil end
end
return value
end
local vehicles = {}
local loaded = {}
getVehicleById = function(id) return vehicles[tonumber(id)] end
getCell = function()
return {
getVehicles = function()
return {
iterator = function()
local index = 0
return {
hasNext = function() return index < #loaded end,
next = function()
index = index + 1
return loaded[index]
end
}
end
}
end
}
end
sendServerCommand = function(module, command)
expect(module == "towbar", "accounting sync must use the towbar module")
if command == "forceAttachSync" then
metrics.attachSyncs = metrics.attachSyncs + 1
elseif command == "forceDetachSync" then
metrics.detachSyncs = metrics.detachSyncs + 1
end
end
dofile("42.20/media/lua/server/TowingCommands.lua")
local function command(name, args)
callbacks.clientCommand("towbar", name, player, args)
end
local function ticks(count)
for _ = 1, count do callbacks.tick() end
end
local function attachArgs(a, b, item)
return {
vehicleA = a:getId(),
vehicleB = b:getId(),
attachmentA = "trailer",
attachmentB = "trailerfront",
itemId = item and item:getID() or nil
}
end
-- A successful attach consumes exactly one physical item. Duplicate commands
-- and confirmation ticks must never consume another one.
local a, b = vehicle(101, 1001, true), vehicle(102, 1002, true)
vehicles[101], vehicles[102] = a, b
loaded = { a, b }
player.vehicle = a
local firstItem = inventory.items[1]
command("attachTowBar", attachArgs(a, b, firstItem))
command("attachTowBar", attachArgs(a, b, firstItem))
ticks(8)
expect(#inventory.items == 0, "successful attach must consume exactly one towbar")
expect(metrics.removals == 1, "duplicate attach must not transmit a second item removal")
expect(metrics.attachSyncs == 1,
"successful pair must broadcast one immediate wrecker-style attach without consuming twice")
-- A normal detach refunds exactly that one reserved/installed item. Duplicate
-- detaches are stale and cannot duplicate the refund.
local detachArgs = { towingVehicle = a:getId(), vehicle = b:getId(), vehicleA = a:getId(), vehicleB = b:getId() }
command("detachTowBar", detachArgs)
command("detachTowBar", detachArgs)
ticks(4)
expect(#inventory.items == 1, "successful detach must refund exactly one towbar")
expect(metrics.refunds == 1, "duplicate detach must not transmit a second refund")
-- The refunded object can be used immediately on the same pair. No enter/exit
-- event or stale completion marker may prevent the second lifecycle.
local secondItem = inventory.items[1]
command("attachTowBar", attachArgs(a, b, secondItem))
ticks(4)
expect(#inventory.items == 0, "direct reattach must consume the one refunded towbar")
expect(metrics.removals == 2, "direct reattach must account for one new removal")
command("detachTowBar", detachArgs)
ticks(4)
expect(#inventory.items == 1, "second detach must return one towbar, not lose it")
expect(metrics.refunds == 2, "second lifecycle must produce one new refund")
-- An immediate detach after the one-shot attach must remain detached. There is
-- no portable-only queued confirmation allowed to recreate a ghost tow.
local fastA, fastB = vehicle(151, 1501, true), vehicle(152, 1502, true)
vehicles[151], vehicles[152] = fastA, fastB
loaded = { fastA, fastB }
player.vehicle = fastA
local fastItem = inventory.items[1]
local syncsBeforeFastDetach = metrics.attachSyncs
local refundsBeforeFastDetach = metrics.refunds
command("attachTowBar", attachArgs(fastA, fastB, fastItem))
command("detachTowBar", {
towingVehicle = fastA:getId(), vehicle = fastB:getId(),
vehicleA = fastA:getId(), vehicleB = fastB:getId()
})
ticks(8)
expect(fastA:getVehicleTowing() == nil and fastB:getVehicleTowedBy() == nil,
"detach before confirmation must not be undone by a stale queued attach")
expect(metrics.attachSyncs == syncsBeforeFastDetach + 1,
"immediate detach must not be followed by another attach sync")
expect(metrics.refunds == refundsBeforeFastDetach + 1 and #inventory.items == 1,
"detach before confirmation must refund exactly one reserved towbar")
-- Match the wrecker's server-owned persistence model: once a validated attach
-- is accepted, the towbar is installed and the saved pair keeps retrying. A
-- delayed native getter must not refund the installed item behind the player.
local failedA, failedB = vehicle(201, 2001, false), vehicle(202, 2002, false)
vehicles[201], vehicles[202] = failedA, failedB
loaded = { failedA, failedB }
player.vehicle = failedA
local failedItem = inventory.items[1]
local refundsBeforeFailure = metrics.refunds
command("attachTowBar", attachArgs(failedA, failedB, failedItem))
ticks(30)
expect(#inventory.items == 0, "accepted delayed attach must keep its towbar installed")
expect(metrics.refunds == refundsBeforeFailure,
"persistence retries must not mint an inventory refund")
expect(failedA.modData.isTowingByTowBar == true
and failedB.modData.isTowingByTowBar == true,
"accepted delayed attach must preserve its recoverable logical pair")
command("detachTowBar", {
towingVehicle = failedA:getId(), vehicle = failedB:getId(),
vehicleA = failedA:getId(), vehicleB = failedB:getId()
})
expect(#inventory.items == 1 and metrics.refunds == refundsBeforeFailure + 1,
"wrecker-style detach must refund an expected pair even while server getters lag")
expect(failedA.modData.isTowingByTowBar == false
and failedB.modData.isTowingByTowBar == false,
"logical detach must clear delayed pair metadata")
if failures > 0 then os.exit(1) end
print("PASS: towbar attach, retry, detach, and direct reattach account for exactly one item")
-183
View File
@@ -1,183 +0,0 @@
local failures = 0
local function expect(condition, message)
if not condition then
failures = failures + 1
io.stderr:write("FAIL: " .. message .. "\n")
end
end
local serverCommand
local vehicleSpawned
Events = {
OnServerCommand = {
Add = function(callback) serverCommand = callback end
},
OnSpawnVehicleEnd = {
Add = function(callback) vehicleSpawned = callback end
}
}
isServer = function() return false end
local trace = {}
local function record(value) trace[#trace + 1] = value end
local function script()
return {
getAttachmentById = function(_, id)
if id == "trailer" or id == "trailerfront" then return {} end
return nil
end,
getWheelCount = function() return 0 end,
getPhysicsChassisShape = function()
return { z = function() return 4 end }
end,
addAttachment = function() end
}
end
local rigidAdds = 0
local breaks = 0
local towing
local towed
local function vehicle(id)
local value = {
id = id,
modData = {},
script = script(),
towing = nil,
towedBy = nil
}
function value:getId() return self.id end
function value:getScript() return self.script end
function value:getScriptName() return "Base.TestVehicle" end
function value:getModData() return self.modData end
function value:transmitModData() end
function value:getMass() return 1200 end
function value:getBrakingForce() return 25 end
function value:getVehicleTowing() return self.towing end
function value:getVehicleTowedBy() return self.towedBy end
function value:addPointConstraint(_, other, attachmentA, attachmentB, localOnly)
rigidAdds = rigidAdds + 1
record("rigid-add")
expect(self == towing and other == towed, "rigid rebuild must use the original pair")
expect(attachmentA == "trailer" and attachmentB == "trailerfront", "rigid rebuild must preserve endpoints")
expect(localOnly == true, "rigid rebuild must be local-only to avoid an attach/detach command race")
self.towing = other
other.towedBy = self
end
function value:breakConstraint()
breaks = breaks + 1
record("break-native")
if self == towing or self == towed then
towing.towing = nil
towing.towedBy = nil
towed.towing = nil
towed.towedBy = nil
end
end
return value
end
towing = vehicle(101)
towed = vehicle(202)
local vehicles = { [101] = towing, [202] = towed }
getVehicleById = function(id) return vehicles[id] end
TowBarMod = {
Utils = {
updateAttachmentsForRigidTow = function()
record("rigid-offsets")
end
},
Hook = {
cleanupDetachedTowBar = function()
record("cleanup")
towing.modData = {}
towed.modData = {}
end,
setVehicleScriptWithTowBarHidden = function(_, scriptName)
record("script:" .. tostring(scriptName))
return true
end,
setVehiclePostAttach = function()
record("post-attach")
end
}
}
dofile("42.20/media/lua/client/TowBar/TowSyncClient.lua")
expect(type(serverCommand) == "function", "client sync must register its server-command handler")
-- Begin with the old physical pair so spontaneous cleanup exercises the same
-- path as a real Build 42 break notification.
towing.towing = towed
towed.towedBy = towing
towing.modData.towBarTowedVehicleId = towed:getId()
towed.modData.towBarTowingVehicleId = towing:getId()
serverCommand("towbar", "spontaneousDetachSync", {
vehicleA = towing:getId(),
vehicleB = towed:getId()
})
expect(breaks > 0, "spontaneous cleanup must remove the old native relation")
expect(towing:getVehicleTowing() == nil and towed:getVehicleTowedBy() == nil, "spontaneous cleanup must leave the pair unlinked")
-- A direct reattach can arrive from the server as a native B42 relationship
-- before forceAttachSync reaches the client. That native relationship is the
-- rope-like constraint; forceAttachSync must replace it with the rigid path.
towing.towing = towed
towed.towedBy = towing
local breaksBeforeReattach = breaks
serverCommand("towbar", "forceAttachSync", {
vehicleA = towing:getId(),
vehicleB = towed:getId(),
attachmentA = "trailer",
attachmentB = "trailerfront"
})
expect(breaks == breaksBeforeReattach + 1, "direct reattach must replace the native B42 rope relation")
expect(rigidAdds == 1, "direct reattach must create exactly one rigid local constraint")
local fakeScriptIndex
local rigidAddIndex
for index, value in ipairs(trace) do
if value == "script:notTowingA_Trailer" and not fakeScriptIndex then fakeScriptIndex = index end
if value == "rigid-add" and not rigidAddIndex then rigidAddIndex = index end
end
expect(fakeScriptIndex ~= nil, "rigid reattach must select the fake-trailer script")
expect(rigidAddIndex ~= nil and fakeScriptIndex < rigidAddIndex, "fake-trailer selection must precede rigid constraint creation")
expect(towing:getVehicleTowing() == towed and towed:getVehicleTowedBy() == towing, "rigid rebuild must finish with a reciprocal tow relation")
local rigidAddsAfterFirstSync = rigidAdds
local breaksAfterFirstSync = breaks
serverCommand("towbar", "forceAttachSync", {
vehicleA = towing:getId(),
vehicleB = towed:getId(),
attachmentA = "trailer",
attachmentB = "trailerfront"
})
expect(rigidAdds == rigidAddsAfterFirstSync, "a repeated attach snapshot must not layer a second rigid constraint")
expect(breaks == breaksAfterFirstSync, "a repeated attach snapshot must adopt the already-rigid pair")
-- Streaming can recreate a native relationship with the same runtime IDs
-- while the Lua module and its applied cache survive. The spawn notification
-- must invalidate that stale cache so the next snapshot replaces the rope.
expect(type(vehicleSpawned) == "function", "client sync must observe vehicle streaming")
vehicleSpawned(towed)
local rigidAddsBeforeStreamRestore = rigidAdds
local breaksBeforeStreamRestore = breaks
serverCommand("towbar", "forceAttachSync", {
vehicleA = towing:getId(),
vehicleB = towed:getId(),
attachmentA = "trailer",
attachmentB = "trailerfront"
})
expect(rigidAdds == rigidAddsBeforeStreamRestore + 1, "stream-restored native rope must be rebuilt rigid")
expect(breaks == breaksBeforeStreamRestore + 1, "stream-restored native rope must be removed exactly once")
if failures > 0 then os.exit(1) end
print("PASS: spontaneous break followed by direct attach rebuilds a rigid towbar")
+47 -37
View File
@@ -29,10 +29,10 @@ Assert-True (Test-Path -LiteralPath $releaseRoot) "Missing 42.20 release folder.
$releaseInfo = Get-Content -LiteralPath (Join-Path $releaseRoot "mod.info") $releaseInfo = Get-Content -LiteralPath (Join-Path $releaseRoot "mod.info")
$rootInfo = Get-Content -LiteralPath (Join-Path $repositoryRoot "mod.info") $rootInfo = Get-Content -LiteralPath (Join-Path $repositoryRoot "mod.info")
Assert-True ($releaseInfo -contains "id=hrsys_towbars_testing") "42.20 must retain the current testing mod id." Assert-True ($releaseInfo -contains "id=hrsys_towbars") "42.20 must retain the stable root mod id."
Assert-True ($releaseInfo -contains "versionMin=42.20.0") "42.20 must require Build 42.20." Assert-True ($releaseInfo -contains "versionMin=42.20.0") "42.20 must require Build 42.20."
Assert-True ($releaseInfo -contains "modversion=1.0.12") "42.20 must declare mod version 1.0.12." Assert-True ($releaseInfo -contains "modversion=1.0.22") "42.20 must declare mod version 1.0.22."
Assert-True ($rootInfo -contains "modversion=1.0.12") "Root and release mod versions must match." Assert-True ($rootInfo -contains "modversion=1.0.22") "Root and release mod versions must match."
$baselineFiles = Get-RelativeFileNames $baselineRoot $baselineFiles = Get-RelativeFileNames $baselineRoot
$releaseFiles = Get-RelativeFileNames $releaseRoot $releaseFiles = Get-RelativeFileNames $releaseRoot
@@ -40,8 +40,10 @@ $allowedAdditions = @(
"media/lua/client/TowBar/WreckerSyncClient.lua", "media/lua/client/TowBar/WreckerSyncClient.lua",
"media/lua/client/TowBar/WreckerTimedAction.lua", "media/lua/client/TowBar/WreckerTimedAction.lua",
"media/lua/client/TowBar/WreckerUI.lua", "media/lua/client/TowBar/WreckerUI.lua",
"media/lua/client/TowBar/RigidTow.lua",
"media/lua/server/WreckerCommands.lua", "media/lua/server/WreckerCommands.lua",
"media/lua/shared/TowBar/Persistence.lua", "media/lua/shared/TowBar/Persistence.lua",
"media/lua/shared/TowBar/VehicleCompatibility.lua",
"media/lua/shared/TowBar/WreckerUtils.lua" "media/lua/shared/TowBar/WreckerUtils.lua"
) )
$expectedReleaseFiles = @($baselineFiles) + $allowedAdditions | Sort-Object $expectedReleaseFiles = @($baselineFiles) + $allowedAdditions | Sort-Object
@@ -59,6 +61,7 @@ foreach ($jsonFile in Get-ChildItem -LiteralPath $releaseRoot -Recurse -Filter "
$serverCommandsPath = Join-Path $releaseRoot "media/lua/server/TowingCommands.lua" $serverCommandsPath = Join-Path $releaseRoot "media/lua/server/TowingCommands.lua"
$btTowPath = Join-Path $releaseRoot "media/lua/server/BTTow.lua" $btTowPath = Join-Path $releaseRoot "media/lua/server/BTTow.lua"
$clientSyncPath = Join-Path $releaseRoot "media/lua/client/TowBar/TowSyncClient.lua" $clientSyncPath = Join-Path $releaseRoot "media/lua/client/TowBar/TowSyncClient.lua"
$rigidTowPath = Join-Path $releaseRoot "media/lua/client/TowBar/RigidTow.lua"
$hookingPath = Join-Path $releaseRoot "media/lua/client/TowBar/TowingHooking.lua" $hookingPath = Join-Path $releaseRoot "media/lua/client/TowBar/TowingHooking.lua"
$towbarTemplatePath = Join-Path $releaseRoot "media/scripts/vehicles/template_towbar.txt" $towbarTemplatePath = Join-Path $releaseRoot "media/scripts/vehicles/template_towbar.txt"
$itemNamePath = Join-Path $releaseRoot "media/lua/shared/Translate/EN/ItemName.json" $itemNamePath = Join-Path $releaseRoot "media/lua/shared/Translate/EN/ItemName.json"
@@ -67,6 +70,7 @@ $legacyItemNamePath = Join-Path $repositoryRoot "common/media/lua/shared/Transla
$serverCommands = Get-Content -LiteralPath $serverCommandsPath -Raw $serverCommands = Get-Content -LiteralPath $serverCommandsPath -Raw
$btTow = Get-Content -LiteralPath $btTowPath -Raw $btTow = Get-Content -LiteralPath $btTowPath -Raw
$clientSync = Get-Content -LiteralPath $clientSyncPath -Raw $clientSync = Get-Content -LiteralPath $clientSyncPath -Raw
$rigidTow = Get-Content -LiteralPath $rigidTowPath -Raw
$hooking = Get-Content -LiteralPath $hookingPath -Raw $hooking = Get-Content -LiteralPath $hookingPath -Raw
$towbarTemplate = Get-Content -LiteralPath $towbarTemplatePath -Raw $towbarTemplate = Get-Content -LiteralPath $towbarTemplatePath -Raw
$itemNames = Get-Content -LiteralPath $itemNamePath -Raw | ConvertFrom-Json $itemNames = Get-Content -LiteralPath $itemNamePath -Raw | ConvertFrom-Json
@@ -79,7 +83,7 @@ $towbarScaledScriptScale = $towbarBaseScriptScale * $towbarVisualScale
$towbarMeasuredLengthAtBaseScale = 0.9714089036 $towbarMeasuredLengthAtBaseScale = 0.9714089036
$towbarScaledHalfLength = ($towbarMeasuredLengthAtBaseScale * $towbarVisualScale) / 2 $towbarScaledHalfLength = ($towbarMeasuredLengthAtBaseScale * $towbarVisualScale) / 2
Assert-True ($towbarTemplate -match ('(?s)model towbarModel\s*\{{.*?scale = {0},' -f [regex]::Escape($towbarScaledScriptScale.ToString('0.000', [Globalization.CultureInfo]::InvariantCulture)))) "The normal towbar visual must render at 2.5x its measured 0.01 script scale." Assert-True ($towbarTemplate -match ('(?s)model towbarModelKI5\s*\{{.*?scale = {0},' -f [regex]::Escape($towbarScaledScriptScale.ToString('0.000', [Globalization.CultureInfo]::InvariantCulture)))) "The isolated KI5 towbar visual must render at 2.5x its measured 0.01 script scale."
Assert-True ($hooking -match 'local TowbarVisualScale = 2\.5') "Client placement must declare the 2.5x visual scale." Assert-True ($hooking -match 'local TowbarVisualScale = 2\.5') "Client placement must declare the 2.5x visual scale."
Assert-True ($btTow -match 'local TowbarVisualScale = 2\.5') "Part initialization must declare the 2.5x visual scale." Assert-True ($btTow -match 'local TowbarVisualScale = 2\.5') "Part initialization must declare the 2.5x visual scale."
Assert-True ($hooking -match '(?s)local TowbarScaledModelLength = TowbarModelLength \* TowbarVisualScale.*?local TowbarModelHalfLength = TowbarScaledModelLength / 2') "Client placement must compensate offsets using the scaled mesh length." Assert-True ($hooking -match '(?s)local TowbarScaledModelLength = TowbarModelLength \* TowbarVisualScale.*?local TowbarModelHalfLength = TowbarScaledModelLength / 2') "Client placement must compensate offsets using the scaled mesh length."
@@ -102,47 +106,55 @@ Assert-True ($serverCommands -match '(?s)isLegacyTowBarPair.*?hasTowBarState\(ve
Assert-True ($serverCommands -match '(?s)local function resolveExpectedTowBarPair.*?towingVehicle and towedVehicle.*?isExpectedTowBarPair\(towingVehicle, towedVehicle\)') "Current-link spontaneous resolution must still require reciprocal towbar identity." Assert-True ($serverCommands -match '(?s)local function resolveExpectedTowBarPair.*?towingVehicle and towedVehicle.*?isExpectedTowBarPair\(towingVehicle, towedVehicle\)') "Current-link spontaneous resolution must still require reciprocal towbar identity."
Assert-True ($serverCommands -match '(?s)local function snapshotActiveTowbarLinksServer.*?isExpectedTowBarPair\(towingVehicle, towedVehicle\).*?isLegacyTowBarPair\(towingVehicle, towedVehicle\)') "Server snapshots must only preserve reciprocal or clean legacy towbar pairs." Assert-True ($serverCommands -match '(?s)local function snapshotActiveTowbarLinksServer.*?isExpectedTowBarPair\(towingVehicle, towedVehicle\).*?isLegacyTowBarPair\(towingVehicle, towedVehicle\)') "Server snapshots must only preserve reciprocal or clean legacy towbar pairs."
Assert-True ($serverCommands -match 'spontaneousDetachSync') "Server must broadcast spontaneous towbar cleanup." Assert-True ($serverCommands -match 'spontaneousDetachSync') "Server must broadcast spontaneous towbar cleanup."
Assert-True ($serverCommands -match 'local vehicleBId = args\.vehicleB or args\.vehicle') "Manual detach sync must include the towed vehicle id." Assert-True ($serverCommands -match '(?s)function Commands\.detachTowBar.*?broadcastDetach\(towingVehicle:getId\(\), towedVehicle:getId\(\)\)') "Manual detach sync must immediately include both vehicle ids."
Assert-True ($serverCommands -notmatch 'function Commands\.giveTowBar') "Clients must not be able to invoke the towbar refund helper." Assert-True ($serverCommands -notmatch 'function Commands\.giveTowBar') "Clients must not be able to invoke the towbar refund helper."
Assert-True ($serverCommands -match 'isPlayerAuthorizedForPair') "Server refunds must validate the requesting player." Assert-True ($serverCommands -match 'isPlayerAuthorizedForPair') "Server refunds must validate the requesting player."
Assert-True ($serverCommands -match 'rejected towbar attach without item') "Multiplayer attach must require server-side towbar ownership." Assert-True ($serverCommands -match 'rejected towbar attach without item') "Multiplayer attach must require server-side towbar ownership."
Assert-True ($serverCommands -match '(?s)function Commands\.attachTowBar.*?if hasAnyTowLink\(vehicleA\) or hasAnyTowLink\(vehicleB\) then.*?return.*?if isExpectedTowBarPair\(vehicleA, vehicleB\).*?or isExpectedTowBarPair\(vehicleB, vehicleA\).*?or hasPendingAttach\(vehicleA, vehicleB\).*?or hasPendingAttach\(vehicleB, vehicleA\).*?then.*?return.*?local towBarItem = findTowBarItem') "Existing or reversed pending tow pairs must be rejected before consuming another towbar." Assert-True ($serverCommands -match '(?s)function Commands\.attachTowBar.*?if hasAnyTowLink\(vehicleA\) or hasAnyTowLink\(vehicleB\) then.*?return.*?if isExpectedTowBarPair\(vehicleA, vehicleB\).*?or isExpectedTowBarPair\(vehicleB, vehicleA\).*?then.*?return.*?local towBarItem = findTowBarItem') "Existing or reversed logical tow pairs must be rejected before consuming another towbar."
Assert-True ($serverCommands -match 'queueSync\("attach", player, args, true\)') "Multiplayer attach must reserve the towbar for delayed confirmation." Assert-True ($serverCommands -match '(?s)function Commands\.attachTowBar.*?markExpectedTowBarPair\(vehicleA, vehicleB, args\.attachmentA, args\.attachmentB\).*?vehicleA:addPointConstraint.*?broadcastAttach\(vehicleA, vehicleB, args\.attachmentA, args\.attachmentB\)') "Portable attach must use the working wrecker one-shot server add and immediate sync sequence."
Assert-True ($serverCommands -match '(?s)function Commands\.attachTowBar.*?markExpectedTowBarPair\(vehicleA, vehicleB, args\.attachmentA, args\.attachmentB\).*?queueSync\("attach", player, args, true\).*?vehicleA:addPointConstraint') "Attach must record and queue the pair before constraint creation can break spontaneously." Assert-True ($serverCommands -notmatch 'pendingSync|queueSync|processAttachSync|failAttachSync|phase =') "Portable attach must not retain its failed provisional/confirmed controller."
Assert-True ($serverCommands -match 'local function failAttachSync') "Failed multiplayer attach must have a cleanup/refund path."
Assert-True ($serverCommands -match '(?s)local function processAttachSync.*?if isTowBarPairConfirmed\(vehicleA, vehicleB\) then return "broken" end') "A confirmed pair that breaks during attach confirmation must not be reattached."
Assert-True ($serverCommands -match '(?s)local function processPendingSync.*?status == "broken".*?finalizeBrokenTowBarPair') "A break during attach confirmation must use the shared ground-drop finalizer."
Assert-True ($serverCommands -match 'if item\.reservedTowBar then') "Failed multiplayer attach must refund the reserved towbar exactly once."
Assert-True ($serverCommands -match 'Constraint creation may complete on a later server tick') "Multiplayer attach must preserve delayed constraint confirmation."
Assert-True ($serverCommands -match 'local function finalizeBrokenTowBarPair') "Broken towbar cleanup must use one idempotent finalizer." Assert-True ($serverCommands -match 'local function finalizeBrokenTowBarPair') "Broken towbar cleanup must use one idempotent finalizer."
Assert-True ($serverCommands -match 'local function reconcileBrokenTowBarPairsServer') "Server and single-player must audit confirmed towbar constraints for silent breaks." Assert-True ($serverCommands -match 'local function reconcileBrokenTowBarPairsServer') "Server and single-player must audit confirmed towbar constraints for silent breaks."
Assert-True ($serverCommands -match '(?s)local function reconcileBrokenTowBarPairsServer.*?Persistence\.advanceRecoveryState.*?RestoreRetryMs, SustainedBreakMs.*?action == "break".*?finalizeBrokenTowBarPair.*?action == "restore".*?restorePersistedTowBarPair') "The physical-link audit must use the shared bounded recovery state machine." Assert-True ($serverCommands -match '(?s)local function reconcileBrokenTowBarPairsServer.*?Persistence\.advanceRecoveryState.*?RestoreRetryMs, SustainedBreakMs.*?action == "break".*?finalizeBrokenTowBarPair.*?action == "restore".*?restorePersistedTowBarPair') "The physical-link audit must use the shared bounded recovery state machine."
Assert-True ($persistence -match '(?s)function Persistence\.advanceRecoveryState.*?state\.unlinkedSince = state\.unlinkedSince or now.*?now - state\.unlinkedSince >= breakMs.*?return state, "break"' -and $persistence -match '(?s)if not state\.pendingUntil or now >= state\.pendingUntil then.*?state\.pendingUntil = now \+ retryMs.*?return state, "restore"') "Persistent-pair recovery must debounce restores and require a sustained break before cleanup." Assert-True ($persistence -match '(?s)function Persistence\.advanceRecoveryState.*?state\.unlinkedSince = state\.unlinkedSince or now.*?now - state\.unlinkedSince >= breakMs.*?return state, "break"' -and $persistence -match '(?s)if not state\.pendingUntil or now >= state\.pendingUntil then.*?state\.pendingUntil = now \+ retryMs.*?return state, "restore"') "Persistent-pair recovery must debounce restores and require a sustained break before cleanup."
Assert-True ($serverCommands -match '(?s)local function restorePersistedTowBarPair.*?towBarExpectedAttachment.*?addPointConstraint.*?broadcastAttach') "A saved towbar pair must rebuild its physical constraint without consuming another item." Assert-True ($serverCommands -match '(?s)local function restorePersistedTowBarPair.*?towBarExpectedAttachment.*?addPointConstraint.*?broadcastAttach') "A saved towbar pair must use the same immediate wrecker-style restore without consuming another item."
Assert-True ($persistence -match '(?s)if not state\.pendingUntil or now >= state\.pendingUntil then.*?return state, "restore"') "An unconfirmed saved pair must schedule a bounded restoration attempt." Assert-True ($persistence -match '(?s)if not state\.pendingUntil or now >= state\.pendingUntil then.*?return state, "restore"') "An unconfirmed saved pair must schedule a bounded restoration attempt."
Assert-True ($clientSync -match 'TowBarMod\.Sync\.applyAttachSync = applyAttachSync') "Single-player must expose the existing attach synchronizer for saved-pair recovery." Assert-True ($clientSync -match 'TowBarMod\.Sync\.appliedPairs') "Client must retain the wrecker-style applied-pair cache."
Assert-True ($clientSync -match '(?s)local key = tostring\(vehicleA:getId\(\)\).*?if TowBarMod\.Sync\.appliedPairs\[key\] and not isLinked\(vehicleA, vehicleB\) then.*?TowBarMod\.Sync\.appliedPairs\[key\] = nil.*?if not TowBarMod\.Sync\.appliedPairs\[key\] then.*?breakTowBarPair\(vehicleA, vehicleB\).*?setVehicleScriptWithTowBarHidden\(vehicleB, "notTowingA_Trailer"\).*?addPointConstraint\(nil, vehicleB, attachmentA, attachmentB, true\)') "Every fresh or recovered towbar pair must replace the native rope with one local rigid constraint." Assert-True ($clientSync -match '(?s)local function applyAttachSync.*?isPairLinked\(vehicleA, vehicleB\) and Sync\.appliedPairs\[key\].*?TowBarMod\.RigidTow\.attach.*?Sync\.appliedPairs\[key\] = true') "Portable client sync must use one idempotent wrecker-style rigid conversion."
Assert-True ($clientSync -notmatch '(?s)if linked then\s*--.*?appliedPairs\[key\] = true') "A newly observed native tow link must not be accepted as rigid without rebuilding it." Assert-True ($clientSync -match 'TowBarMod\.Sync\.desiredPairs = TowBarMod\.Sync\.desiredPairs or \{\}') "Portable MP sync must retain an authoritative pair until its driver owns local physics."
Assert-True ($clientSync -match '(?s)local function clearAppliedPairForVehicle.*?TowBarMod\.Sync\.appliedPairs\[key\] = nil.*?Events\.OnSpawnVehicleEnd\.Add\(clearAppliedPairForVehicle\)') "Vehicle streaming must invalidate cached rigid towbar constraints." Assert-True ($clientSync -match '(?s)local function applyAttachSync\(args, playerObj, forceReattach\).*?if not isLocalDriver\(vehicleA, localPlayer\) then.*?return true.*?not forceReattach and isPairLinked.*?TowBarMod\.RigidTow\.attach') "Portable MP sync must defer ordinary conversion until driver ownership and allow an explicit forced entry rebuild."
Assert-True ($clientSync -match '(?s)local function getPersistedPairArgsForDriver.*?isTowingByTowBar.*?towed == true.*?towBarTowingVehicleId') "Portable driver entry must require reciprocal persisted towbar identity."
Assert-True ($clientSync -match 'Events\.OnEnterVehicle\.Add\(forceReattachForDriver\)') "Driver entry must force the one-shot portable rigid rebuild."
Assert-True ($clientSync -match 'Events\.OnSpawnVehicleEnd\.Add\(onSpawnVehicle\)') "Vehicle streaming must replay a retained authoritative pair for an existing local driver."
Assert-True ($clientSync -match '(?s)local function clearAppliedPairForVehicle.*?Sync\.appliedPairs\[key\] = nil') "Vehicle streaming must invalidate the applied cache for the next server snapshot."
Assert-True ($clientSync -match '(?s)local function onSpawnVehicle\(vehicle\).*?clearAppliedPairForVehicle\(vehicle\).*?retryDesiredPairsForDriver\(getPlayer\(\)\).*?Events\.OnSpawnVehicleEnd\.Add\(onSpawnVehicle\)') "Vehicle streaming must replay a retained authoritative pair for an existing local driver."
Assert-True ($clientSync -notmatch 'Lifecycle|pending|provisional|confirmed|Events\.OnTick\.Add') "Portable client sync must not retain timing or phase controllers."
Assert-True ($rigidTow -match 'function RigidTow\.attach') "Rigid physics must be preserved behind one public primitive."
Assert-True ($rigidTow -match '(?s)function RigidTow\.attach.*?updateAttachmentsForRigidTow.*?breakExactPair.*?applyFreeRollingTowState\(towedVehicle\).*?setVehicleScriptWithTowBarHidden\(towedVehicle, "notTowingA_Trailer"\).*?addPointConstraint\(nil, towedVehicle, attachmentA, attachmentB, true\).*?setVehicleScriptWithTowBarHidden\(towedVehicle, originalScript\).*?setVehiclePostAttach') "The rigid primitive must preserve the wrecker sequence: free-roll, fake script, rigid add, immediate original-script restore, then finalization."
Assert-True ([regex]::Matches($hooking, 'updateAttachmentsForRigidTow\(').Count -eq 0) "Post-attach presentation must not mutate rigid attachment geometry a second time."
Assert-True ($hooking -match 'storeOriginalVehicleCall\(vehicle, modData, "towBarOriginalParkingBrake", "getParkingBrake"\)') "Portable towing must use the same parking-brake getter as the working wrecker path."
$applyFreeStart = $hooking.IndexOf('local function applyFreeRollingTowState(vehicle)')
$applyFreeEnd = $hooking.IndexOf('local function restoreFreeRollingTowState', $applyFreeStart)
$applyFreeBody = if ($applyFreeStart -ge 0 -and $applyFreeEnd -gt $applyFreeStart) { $hooking.Substring($applyFreeStart, $applyFreeEnd - $applyFreeStart) } else { '' }
Assert-True ($applyFreeBody -notmatch 'updateTotalMass\(|constraintChanged\(') "Portable free-roll must not recalculate away its towing mass before or during rigid movement."
Assert-True ($rigidTow -notmatch 'if not isExactPairLinked\(towingVehicle, towedVehicle\) then return false end') "Rigid submission must not fail solely because MP reciprocal getters update later."
Assert-True ($rigidTow -notmatch 'sendClientCommand|sendServerCommand|TowBarItem|Persistence') "Rigid physics must remain separate from commands, item accounting, and persistence."
Assert-True ($serverCommands -match '(?s)local function broadcastAttach.*?if isServer\(\).*?sendServerCommand.*?elseif not isClient\(\).*?TowBarMod\.Sync\.applyAttachSync') "Saved towbar recovery must synchronize both dedicated-server and single-player sessions." Assert-True ($serverCommands -match '(?s)local function broadcastAttach.*?if isServer\(\).*?sendServerCommand.*?elseif not isClient\(\).*?TowBarMod\.Sync\.applyAttachSync') "Saved towbar recovery must synchronize both dedicated-server and single-player sessions."
Assert-True ($serverCommands -match 'towBarTowedVehicleSqlId' -and $serverCommands -match 'towBarTowingVehicleSqlId' -and $serverCommands -match 'getSqlId') "Towbar persistence must use stable save IDs in addition to live network IDs." Assert-True ($serverCommands -match 'towBarTowedVehicleSqlId' -and $serverCommands -match 'towBarTowingVehicleSqlId' -and $serverCommands -match 'getSqlId') "Towbar persistence must use stable save IDs in addition to live network IDs."
Assert-True ($serverCommands -match 'Persistence\.savePair\("towbar"' -and $serverCommands -match 'Persistence\.forEachPair\("towbar"') "Towbar persistence must use the server-owned saved-pair registry." Assert-True ($serverCommands -match 'Persistence\.savePair\("towbar"' -and $serverCommands -match 'Persistence\.forEachPair\("towbar"') "Towbar persistence must use the server-owned saved-pair registry."
Assert-True ($hooking -match '(?s)local function recoverTowBarVehicleAfterLoad.*?authoritative audit will.*?reconnect') "Client load recovery must defer missing constraints to the authoritative saved-pair audit." Assert-True ($hooking -notmatch 'Events\.OnEnterVehicle\.Add|Events\.OnSwitchVehicleSeat\.Add') "Entering or switching seats must never repair or restart towing."
$loadRecoveryStart = $hooking.IndexOf('local function recoverTowBarVehicleAfterLoad') Assert-True ($hooking -notmatch 'reattachTowBarPair|recoverTowBarVehicleAfterLoad|tryAutoReattachFromCharacter|reattachTowBarFromDriverSeat|lastAutoReattach') "The rewritten attach lifecycle must not retain the old seat-event repair helpers."
$loadRecoveryEnd = $hooking.IndexOf('function TowBarMod.Hook.setVehiclePostAttach', $loadRecoveryStart) Assert-True ($hooking -match '(?s)function TowBarMod\.Hook\.performAttachTowBar.*?sendTowAttachCommand\(playerObj, args\).*?end') "The radial attach action must only submit the authoritative request."
if ($loadRecoveryStart -ge 0 -and $loadRecoveryEnd -gt $loadRecoveryStart) { Assert-True ($hooking -notmatch 'sendClientCommand\(playerObj, "towbar", "consumeTowBar"') "The client must not consume the towbar before server attach validation."
$loadRecovery = $hooking.Substring($loadRecoveryStart, $loadRecoveryEnd - $loadRecoveryStart) Assert-True ($serverCommands -match '(?s)local function processTowBarServerTick.*?reconcileBrokenTowBarPairsServer\(\)') "The physical-link audit must run from the server/SP tick handler."
Assert-True ($loadRecovery -notmatch 'detachTowBar|attachTowBar|reattachTowBarPairAfterCleanDetach') "Loading a saved towbar pair must not refund or consume the item." Assert-True ($serverCommands -match '(?s)local function finalizeBrokenTowBarPair.*?dropTowBarOnGround.*?forgetTowBarPair.*?breakTowBarConstraint.*?clearExpectedTowBarPair.*?broadcastSpontaneousDetach') "Broken-pair finalization must drop, disconnect, clear state, and synchronize cleanup."
}
Assert-True ($serverCommands -match '(?s)local function processPendingSync.*?reconcileBrokenTowBarPairsServer\(\)') "The physical-link audit must run from the server/SP tick handler."
Assert-True ($serverCommands -match '(?s)local function finalizeBrokenTowBarPair.*?dropTowBarOnGround.*?cancelPendingAttach.*?breakTowBarConstraint.*?clearExpectedTowBarPair.*?broadcastSpontaneousDetach') "Broken-pair finalization must drop, disconnect, clear state, and synchronize cleanup."
Assert-True ($serverCommands -match '(?s)local function finalizeBrokenTowBarPair.*?if isServer\(\) then.*?elseif not isClient\(\).*?TowBarMod\.Hook\.cleanupDetachedTowBar') "Single-player breaks must directly restore the vehicle without relying on a server command." Assert-True ($serverCommands -match '(?s)local function finalizeBrokenTowBarPair.*?if isServer\(\) then.*?elseif not isClient\(\).*?TowBarMod\.Hook\.cleanupDetachedTowBar') "Single-player breaks must directly restore the vehicle without relying on a server command."
Assert-True ($clientSync -match 'spontaneousDetachSync') "Client sync must receive spontaneous towbar cleanup." Assert-True ($clientSync -match 'spontaneousDetachSync') "Client sync must receive spontaneous towbar cleanup."
Assert-True ($clientSync -match 'local function hasConflictingTowLink') "Client detach sync must detect links to unrelated vehicles." Assert-True ($clientSync -match 'local function hasConflictingLink') "Client detach sync must detect links to unrelated vehicles."
Assert-True ($clientSync -match 'local breakTowBarPair' -and $clientSync -match 'breakTowBarPair = function') "Client detach sync must break only the requested pair." Assert-True ($clientSync -match 'TowBarMod\.RigidTow\.breakExactPair\(vehicleA, vehicleB\)') "Client detach sync must break only the requested pair."
Assert-True ($clientSync -notmatch 'local function safeBreak') "Client detach sync must not use an unrestricted per-vehicle constraint break." Assert-True ($clientSync -notmatch 'local function safeBreak') "Client detach sync must not use an unrestricted per-vehicle constraint break."
Assert-True ($clientSync -match '(?s)local function applyDetachSync.*?hasConflictingTowLink\(vehicleA, vehicleB\).*?return.*?breakTowBarPair\(vehicleA, vehicleB\).*?cleanupDetachedTowBar') "Client detach sync must reject conflicting links before cleanup."
Assert-True ($hooking -match 'function TowBarMod\.Hook\.cleanupDetachedTowBar') "Client must expose idempotent towbar cleanup." Assert-True ($hooking -match 'function TowBarMod\.Hook\.cleanupDetachedTowBar') "Client must expose idempotent towbar cleanup."
Assert-True ($hooking -match '(?s)function TowBarMod\.Hook\.setVehiclePostAttach\(playerObj, towedVehicle, knownTowingVehicle\).*?local towingVehicle = knownTowingVehicle or towedVehicle:getVehicleTowedBy\(\).*?if not towingVehicle then return end.*?setVehicleScriptWithTowBarHidden\(towedVehicle, towedModData\.towBarOriginalScriptName\)') "Post-attach must accept the authoritative towing vehicle while MP reciprocal getters lag."
Assert-True ($hooking -match '(?s)if modData\.towBarOriginalParkingBrakeOn ~= nil then.*?setParkingBrakeOn.*?end.*?if modData\.towBarOriginalParkingBrake ~= nil then.*?setParkingBrake.*?end.*?if modData\.towBarOriginalHandbrake ~= nil then.*?setHandbrake.*?end') "Free-rolling state may only change brake controls whose original values were captured." Assert-True ($hooking -match '(?s)if modData\.towBarOriginalParkingBrakeOn ~= nil then.*?setParkingBrakeOn.*?end.*?if modData\.towBarOriginalParkingBrake ~= nil then.*?setParkingBrake.*?end.*?if modData\.towBarOriginalHandbrake ~= nil then.*?setHandbrake.*?end') "Free-rolling state may only change brake controls whose original values were captured."
Assert-True ($hooking -notmatch 'tryVehicleCall\(vehicle, "setBrake"') "Free-rolling state must not change an unrestorable brake control." Assert-True ($hooking -notmatch 'tryVehicleCall\(vehicle, "setBrake"') "Free-rolling state must not change an unrestorable brake control."
Assert-True ($hooking -notmatch 'tryVehicleCall\(vehicle, "setBraking"') "Free-rolling state must not change an unrestorable braking control." Assert-True ($hooking -notmatch 'tryVehicleCall\(vehicle, "setBraking"') "Free-rolling state must not change an unrestorable braking control."
@@ -161,13 +173,11 @@ Assert-True ($hooking -match '(?s)local centerOk, center = pcall.*?if not center
Assert-True ($btTow -match '(?s)local centerOk, center = pcall.*?if not centerOk or not center then return nil end.*?if not zOk or type\(centerZ\) ~= "number" then return nil end') "Part initialization must not show a towbar when center-of-mass geometry is invalid." Assert-True ($btTow -match '(?s)local centerOk, center = pcall.*?if not centerOk or not center then return nil end.*?if not zOk or type\(centerZ\) ~= "number" then return nil end') "Part initialization must not show a towbar when center-of-mass geometry is invalid."
Assert-True ($hooking -match '(?s)local function getTowbarModelSlot\(script\).*?local frontEdgeZ = getTowbarFrontEdgeZ\(script\).*?local modelCenterZ = frontEdgeZ \+ TowbarModelHalfLength.*?math\.floor\(\(\(modelCenterZ - TowbarFirstZ\) / TowbarSlotStep\) \+ 0\.5\).*?math\.max\(0, math\.min\(TowbarMaxIndex, index\)\)') "Client must place the towbar's inner end at the hitbox edge." Assert-True ($hooking -match '(?s)local function getTowbarModelSlot\(script\).*?local frontEdgeZ = getTowbarFrontEdgeZ\(script\).*?local modelCenterZ = frontEdgeZ \+ TowbarModelHalfLength.*?math\.floor\(\(\(modelCenterZ - TowbarFirstZ\) / TowbarSlotStep\) \+ 0\.5\).*?math\.max\(0, math\.min\(TowbarMaxIndex, index\)\)') "Client must place the towbar's inner end at the hitbox edge."
Assert-True ($btTow -match '(?s)local function getTowbarModelSlot\(script\).*?local frontEdgeZ = getTowbarFrontEdgeZ\(script\).*?local modelCenterZ = frontEdgeZ \+ TowbarModelHalfLength.*?math\.floor\(\(\(modelCenterZ - TowbarFirstZ\) / TowbarSlotStep\) \+ 0\.5\).*?math\.max\(0, math\.min\(TowbarMaxIndex, index\)\)') "Part initialization must place the towbar's inner end at the hitbox edge." Assert-True ($btTow -match '(?s)local function getTowbarModelSlot\(script\).*?local frontEdgeZ = getTowbarFrontEdgeZ\(script\).*?local modelCenterZ = frontEdgeZ \+ TowbarModelHalfLength.*?math\.floor\(\(\(modelCenterZ - TowbarFirstZ\) / TowbarSlotStep\) \+ 0\.5\).*?math\.max\(0, math\.min\(TowbarMaxIndex, index\)\)') "Part initialization must place the towbar's inner end at the hitbox edge."
Assert-True ($hooking -notmatch 'isVanillaScale|modelScale') "Dynamic hitbox placement must not apply a second model-scale correction." Assert-True ($hooking -match 'isKi5 and ki5Part') "Client rendering must isolate KI5 visuals from legacy parts."
Assert-True ($btTow -notmatch 'isVanillaScale|modelScale') "Part initialization must not apply a second model-scale correction." Assert-True ($btTow -match 'isKi5 and partId == "towbarKI5"') "Part initialization must isolate KI5 visuals from legacy parts."
Assert-True ($hooking -match '(?s)local function setTowBarModelVisible.*?local part = normalPart.*?part:setModelVisible\("towbar" \.\. index, true\)') "Automatic rendering must use the normal towbar part."
Assert-True ($btTow -match '(?s)function BTtow\.Init\.towbar.*?local shouldShowOnThisPart = part:getId\(\) == "towbar"') "Automatic part initialization must only show the normal towbar part."
Assert-True ($hooking -notmatch 'local part = isVanilla and normalPart or largePart') "KI5 automatic rendering must not select the large towbar part." Assert-True ($hooking -notmatch 'local part = isVanilla and normalPart or largePart') "KI5 automatic rendering must not select the large towbar part."
$normalTowbarPart = $towbarTemplate.Substring($towbarTemplate.IndexOf('part towbar'), $towbarTemplate.IndexOf('part towbarLarge') - $towbarTemplate.IndexOf('part towbar')) $normalTowbarPart = $towbarTemplate.Substring($towbarTemplate.IndexOf('part towbar'), $towbarTemplate.IndexOf('part towbarLarge') - $towbarTemplate.IndexOf('part towbar'))
Assert-True ($towbarTemplate -match '(?s)model towbarModel\s*\{.*?scale = 0\.025,') "The rendered towbar mesh must be exactly 2.5 times its original 0.01 scale." Assert-True ($towbarTemplate -match '(?s)model towbarModelKI5\s*\{.*?scale = 0\.025,') "The KI5 towbar mesh must be exactly 2.5 times its original 0.01 scale."
Assert-True ($normalTowbarPart -match '(?s)model towbar0\s*\{\s*file = towbarModel,\s*offset = 0 -0\.3 1\.0,') "Normal towbar0 must retain the radial-menu Z=1.0 geometry." Assert-True ($normalTowbarPart -match '(?s)model towbar0\s*\{\s*file = towbarModel,\s*offset = 0 -0\.3 1\.0,') "Normal towbar0 must retain the radial-menu Z=1.0 geometry."
$normalTowbarModels = [regex]::Matches($normalTowbarPart, '(?s)model towbar(\d+)\s*\{\s*file = towbarModel,\s*offset = ([\d.-]+) ([\d.-]+) ([\d.-]+),') $normalTowbarModels = [regex]::Matches($normalTowbarPart, '(?s)model towbar(\d+)\s*\{\s*file = towbarModel,\s*offset = ([\d.-]+) ([\d.-]+) ([\d.-]+),')
Assert-True ($normalTowbarModels.Count -eq 24) "Normal towbar part must provide all 24 dynamic Z slots." Assert-True ($normalTowbarModels.Count -eq 24) "Normal towbar part must provide all 24 dynamic Z slots."
@@ -209,7 +219,7 @@ Assert-True ($hooking -match '(?s)setVehicleScriptWithTowBarHidden.*?setTowBarMo
Assert-True ($btTow -match '(?s)function BTtow\.Init\.towbar.*?setModelVisible\("towbar" \.\. j, false\).*?towBarModelSwapInProgress.*?return') "Part initialization must not show a towbar during a script swap." Assert-True ($btTow -match '(?s)function BTtow\.Init\.towbar.*?setModelVisible\("towbar" \.\. j, false\).*?towBarModelSwapInProgress.*?return') "Part initialization must not show a towbar during a script swap."
Assert-True ($hooking -notmatch 'towedVehicle:setScriptName\(') "All hook script swaps must use the anti-flicker helper." Assert-True ($hooking -notmatch 'towedVehicle:setScriptName\(') "All hook script swaps must use the anti-flicker helper."
Assert-True ($clientSync -notmatch 'vehicleB:setScriptName\(') "Client reconciliation must not bypass the anti-flicker helper." Assert-True ($clientSync -notmatch 'vehicleB:setScriptName\(') "Client reconciliation must not bypass the anti-flicker helper."
Assert-True ($clientSync -match 'TowBarMod\.Hook\.setVehicleScriptWithTowBarHidden\(vehicleB, "notTowingA_Trailer"\)') "Client reconciliation must hide towbar models while changing scripts." Assert-True ($rigidTow -match 'TowBarMod\.Hook\.setVehicleScriptWithTowBarHidden\(towedVehicle, "notTowingA_Trailer"\)') "The rigid primitive must hide towbar models while changing scripts."
$spontaneousStart = $serverCommands.IndexOf('if module == "vehicle" and command == "detachTrailerSpontaneous" then') $spontaneousStart = $serverCommands.IndexOf('if module == "vehicle" and command == "detachTrailerSpontaneous" then')
$spontaneousEnd = $serverCommands.IndexOf('if module == "towbar" and Commands[command] then', $spontaneousStart) $spontaneousEnd = $serverCommands.IndexOf('if module == "towbar" and Commands[command] then', $spontaneousStart)
@@ -241,7 +251,7 @@ $manualDetachEnd = $serverCommands.IndexOf('function Commands.consumeTowBar', $m
Assert-True ($manualDetachStart -ge 0 -and $manualDetachEnd -gt $manualDetachStart) "Could not isolate the manual detach handler." Assert-True ($manualDetachStart -ge 0 -and $manualDetachEnd -gt $manualDetachStart) "Could not isolate the manual detach handler."
if ($manualDetachStart -ge 0 -and $manualDetachEnd -gt $manualDetachStart) { if ($manualDetachStart -ge 0 -and $manualDetachEnd -gt $manualDetachStart) {
$manualDetachBranch = $serverCommands.Substring($manualDetachStart, $manualDetachEnd - $manualDetachStart) $manualDetachBranch = $serverCommands.Substring($manualDetachStart, $manualDetachEnd - $manualDetachStart)
Assert-True ($manualDetachBranch -match 'if not isLinked\(towingVehicle, towedVehicle\) then') "Manual detach must reject mismatched or stale vehicle pairs." Assert-True ($manualDetachBranch -notmatch 'if not isLinked\(towingVehicle, towedVehicle\) then') "Manual detach must follow wrecker behavior and allow a reciprocal saved pair while MP getters lag."
Assert-True ($manualDetachBranch -match '(?s)not isExpectedTowBarPair\(towingVehicle, towedVehicle\).*?not isLegacyTowBarPair\(towingVehicle, towedVehicle\)') "Manual detach must reject non-towbar or stale linked pairs." Assert-True ($manualDetachBranch -match '(?s)not isExpectedTowBarPair\(towingVehicle, towedVehicle\).*?not isLegacyTowBarPair\(towingVehicle, towedVehicle\)') "Manual detach must reject non-towbar or stale linked pairs."
Assert-True ($manualDetachBranch -match 'breakTowBarConstraint\(towingVehicle, towedVehicle\)') "Manual detach must only break the validated tow pair." Assert-True ($manualDetachBranch -match 'breakTowBarConstraint\(towingVehicle, towedVehicle\)') "Manual detach must only break the validated tow pair."
Assert-True ($manualDetachBranch -match 'giveTowBar\(player, true\)') "Manual detach must continue returning the towbar to inventory." Assert-True ($manualDetachBranch -match 'giveTowBar\(player, true\)') "Manual detach must continue returning the towbar to inventory."
+88
View File
@@ -0,0 +1,88 @@
$ErrorActionPreference = "Stop"
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$releaseRoot = Join-Path $repositoryRoot "42.20"
$failures = [Collections.Generic.List[string]]::new()
function Assert-True {
param([bool]$Condition, [string]$Message)
if (-not $Condition) { $script:failures.Add($Message) }
}
function Read-Source {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
$script:failures.Add("Missing dual-mode source: $Path")
return ""
}
return Get-Content -LiteralPath $Path -Raw
}
$compatibilityPath = Join-Path $releaseRoot "media/lua/shared/TowBar/VehicleCompatibility.lua"
$hooking = Read-Source (Join-Path $releaseRoot "media/lua/client/TowBar/TowingHooking.lua")
$btTow = Read-Source (Join-Path $releaseRoot "media/lua/server/BTTow.lua")
$template = Read-Source (Join-Path $releaseRoot "media/scripts/vehicles/template_towbar.txt")
$compatibility = Read-Source $compatibilityPath
# The boundary must be an exact mod-ID + exact VehicleScript full-name map.
# Model scale remains part of the legacy renderer only; it cannot decide KI5 ownership.
Assert-True ($compatibility -match 'TowBarMod\.Compatibility') "Classifier must expose TowBarMod.Compatibility."
Assert-True ($compatibility -match 'function Compatibility\.isKi5Vehicle\(vehicle\)') "Classifier must expose isKi5Vehicle(vehicle)."
foreach ($identity in @(
'91range', 'Base.91range',
'87toyotaCorolla', 'Base.87toyotaCorollaAE92levin',
'76chevyKseries', 'Base.76chevyK30CCwrecker'
)) {
Assert-True ($compatibility.Contains($identity)) "Classifier is missing verified exact KI5 identity: $identity"
}
Assert-True ($compatibility -match '(?s)getActivatedMods\(\).*?contains') "KI5 mode must require an exact active mod ID."
Assert-True ($compatibility -match 'getFullName\(\)') "KI5 mode must match the complete VehicleScript name."
Assert-True ($compatibility -notmatch 'getModelScale|modelScale|attachmentExist|string\.find|string\.match') "KI5 ownership must not be inferred from scale, attachments, or substring patterns."
# The visual carrier is classified independently. KI5 keeps current B42.20
# dynamic hitbox placement; every other vehicle uses the exact B42.13 selector.
foreach ($source in @($hooking, $btTow)) {
Assert-True ($source -match 'Compatibility\.isKi5Vehicle\(vehicle\)') "Each visual path must classify the vehicle carrying the towbar."
Assert-True ($source -match 'getTowbarFrontEdgeZ') "KI5 visual path must retain hitbox-derived placement."
Assert-True ($source -match 'TowbarVisualScale\s*=\s*2\.5') "KI5 visual path must retain 2.5x scale compensation."
Assert-True ($source -match 'getTowbarIndexVanilla') "Legacy visual path must restore the B42.13 vanilla slot formula."
Assert-True ($source -match 'getTowbarIndexSmallScale') "Legacy non-KI5 path must restore B42.13 small-scale slot selection."
Assert-True ($source -match 'VanillaScaleMin\s*=\s*1\.5' -and $source -match 'VanillaScaleMax\s*=\s*2\.0') "Legacy visual classification must preserve the B42.13 scale window."
}
# One bank cannot represent both scales. Restore the original `towbar` bank for
# legacy vehicles and isolate today's enlarged KI5 mesh in its own part.
Assert-True ($template -match '(?s)model towbarModel\s*\{.*?scale\s*=\s*0\.01,') "Vanilla and normal-scale non-KI5 vehicles must restore the B42.13 0.01 model."
Assert-True ($template -match '(?s)model towbarModelKI5\s*\{.*?scale\s*=\s*0\.025,') "KI5 towbar bank must remain exactly 2.5x its original 0.01 scale."
Assert-True ($template -match '(?s)model towbarModelLarge\s*\{.*?scale\s*=\s*0\.02022,') "Small-scale non-KI5 vehicles must retain the B42.13 large model scale."
Assert-True ($template -match 'part towbarKI5') "Template must provide an isolated KI5 part without resizing the legacy towbar part."
function Get-Ki5DynamicSlot {
param([double]$ShapeZ, [double]$CenterZ)
$meshHalfLength = (0.9714089036 * 2.5) / 2
$center = $CenterZ + ($ShapeZ / 2) + $meshHalfLength
return [Math]::Max(0, [Math]::Min(23, [int][Math]::Floor((($center - 1.0) / 0.1) + 0.5)))
}
function Get-LegacyVanillaSlot {
param([double]$ShapeZ)
$z = ($ShapeZ / 2) - 0.1
return [Math]::Max(0, [Math]::Min(23, [int][Math]::Floor((($z * 2 / 3) - 1) * 10)))
}
function Get-LegacySmallScaleSlot {
param([double]$MaxAbsAttachmentZ)
return [Math]::Max(0, [Math]::Min(23, [int][Math]::Floor(($MaxAbsAttachmentZ + 0.1 - 1.0) * 10)))
}
Assert-True ((Get-Ki5DynamicSlot 4.3111 -0.0444) -eq 23) "KI5 Corolla must retain B42.20 dynamic slot 23."
Assert-True ((Get-Ki5DynamicSlot 4.4444 -0.2556) -eq 22) "KI5 Range Rover must retain B42.20 dynamic slot 22."
Assert-True ((Get-LegacyVanillaSlot 2.6044) -eq 0) "B42 Ranger must return to the B42.13 legacy-normal slot 0, not dynamic slot 15."
Assert-True ((Get-LegacySmallScaleSlot 2.3222) -eq 14) "Non-KI5 small-scale geometry must retain the B42.13 attachment-derived slot."
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "FAIL: $_" -ForegroundColor Red }
exit 1
}
Write-Output "PASS: Build 42.20 exact-KI5 dual-mode contracts."
+5 -2
View File
@@ -98,10 +98,13 @@ Assert-True ($server -match '(?s)onClientCommand.*?getTimestampMs.*?CommandCoold
Assert-True ($sync -match 'notTowingA_Trailer') "Client sync must use the existing fake-trailer path to select vanilla rigid constraints." Assert-True ($sync -match 'notTowingA_Trailer') "Client sync must use the existing fake-trailer path to select vanilla rigid constraints."
Assert-True ($sync -match '(?s)applyAttachSync.*?breakWreckerPair.*?setScriptSafely\(target, "notTowingA_Trailer"\).*?addPointConstraint.*?setScriptSafely\(target, originalScript\)') "Client sync must rebuild the constraint while the target uses the fake trailer script, then restore it." Assert-True ($sync -match '(?s)applyAttachSync.*?breakWreckerPair.*?setScriptSafely\(target, "notTowingA_Trailer"\).*?addPointConstraint.*?setScriptSafely\(target, originalScript\)') "Client sync must rebuild the constraint while the target uses the fake trailer script, then restore it."
Assert-True ($sync -match 'addPointConstraint\(nil, target, hookAttachment, targetAttachment, true\)') "Wrecker local physics rebuilds must suppress server detach/attach traffic." Assert-True ($sync -match 'addPointConstraint\(nil, target, hookAttachment, targetAttachment, true\)') "Wrecker local physics rebuilds must suppress server detach/attach traffic."
Assert-True ($sync -match '(?s)local appliedLevel = Sync\.appliedLevels\[key\].*?if isPairLinked\(wrecker, target\).*?and appliedLevel == canonicalLevel then.*?return') "Only a locally applied wrecker constraint at the requested height may be reused." Assert-True ($sync -match '(?s)local appliedLevel = Sync\.appliedLevels\[key\].*?if not forceReattach and isPairLinked\(wrecker, target\).*?and appliedLevel == canonicalLevel then.*?return') "Only an ordinary sync may reuse a locally applied wrecker constraint at the requested height."
Assert-True ($sync -match '(?s)local function forceReattachForDriver.*?wreckerTowActive.*?wreckerTowedVehicleId.*?wreckerTowingVehicleId.*?applyAttachSync\(\{.*?\}, true\)') "Wrecker driver entry must force a rebuild only for reciprocal persisted wrecker state."
Assert-True ($sync -match 'Events\.OnEnterVehicle\.Add\(forceReattachForDriver\)') "Entering the wrecker as driver must force one rigid reattach."
Assert-True ($sync -notmatch 'Events\.OnTick\.Add') "Wrecker entry repair must remain event-driven without a retry loop."
Assert-True ($sync -match '(?s)local appliedLevel = Sync\.appliedLevels\[key\].*?appliedLevel == canonicalLevel.*?return\s+end.*?breakWreckerPair\(wrecker, target\).*?addPointConstraint') "A native-restored wrecker rope without a locally applied height must be rebuilt through the rigid local constraint path." Assert-True ($sync -match '(?s)local appliedLevel = Sync\.appliedLevels\[key\].*?appliedLevel == canonicalLevel.*?return\s+end.*?breakWreckerPair\(wrecker, target\).*?addPointConstraint') "A native-restored wrecker rope without a locally applied height must be rebuilt through the rigid local constraint path."
Assert-True ($sync -match '(?s)local function clearAppliedLevelForVehicle.*?Sync\.appliedLevels\[key\] = nil.*?Events\.OnSpawnVehicleEnd\.Add\(clearAppliedLevelForVehicle\)') "Vehicle streaming must invalidate cached local wrecker constraints." Assert-True ($sync -match '(?s)local function clearAppliedLevelForVehicle.*?Sync\.appliedLevels\[key\] = nil.*?Events\.OnSpawnVehicleEnd\.Add\(clearAppliedLevelForVehicle\)') "Vehicle streaming must invalidate cached local wrecker constraints."
Assert-True ($sync -match '(?s)if isPairLinked\(wrecker, target\).*?appliedLevel.*?canonicalLevel.*?breakWreckerPair\(wrecker, target\).*?addPointConstraint') "A requested wrecker height change must rebuild the local constraint at the new hook attachment." Assert-True ($sync -match '(?s)if not forceReattach and isPairLinked\(wrecker, target\).*?appliedLevel.*?canonicalLevel.*?breakWreckerPair\(wrecker, target\).*?addPointConstraint') "A requested height or forced driver entry must rebuild the local wrecker constraint."
Assert-True ($sync -match '(?s)local function applyFreeRollingState.*?wreckerOriginalMass.*?wreckerOriginalBrakingForce.*?setMass\(200\).*?setBrakingForce\(0\)') "Attached vehicles must be made free-rolling like trailers." Assert-True ($sync -match '(?s)local function applyFreeRollingState.*?wreckerOriginalMass.*?wreckerOriginalBrakingForce.*?setMass\(200\).*?setBrakingForce\(0\)') "Attached vehicles must be made free-rolling like trailers."
Assert-True ($sync -match '(?s)local function restoreFreeRollingState.*?setMass.*?setBrakingForce.*?wreckerOriginalMass = nil') "Detach must restore the target vehicle's pre-tow mass and braking state." Assert-True ($sync -match '(?s)local function restoreFreeRollingState.*?setMass.*?setBrakingForce.*?wreckerOriginalMass = nil') "Detach must restore the target vehicle's pre-tow mass and braking state."
Assert-True ($sync -match '(?s)local function applyDetachSync.*?if not target then return end.*?if wrecker then breakWreckerPair.*?restoreFreeRollingState\(target\)') "Detach sync must restore a reloaded target even when the former wrecker is unavailable." Assert-True ($sync -match '(?s)local function applyDetachSync.*?if not target then return end.*?if wrecker then breakWreckerPair.*?restoreFreeRollingState\(target\)') "Detach sync must restore a reloaded target even when the former wrecker is unavailable."