diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..289042d --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.tools/ + +# Logs and local patch leftovers +*.log +*.tmp +*.bak +*.orig +*.rej + +# Editor/IDE +.vscode/ +.idea/ +*.iml + +# OS metadata +.DS_Store +Thumbs.db +desktop.ini diff --git a/42.13/media/lua/client/Landtrain/UnlimitedTowbarChains.lua b/42.13/media/lua/client/Landtrain/UnlimitedTowbarChains.lua new file mode 100644 index 0000000..2dd76d1 --- /dev/null +++ b/42.13/media/lua/client/Landtrain/UnlimitedTowbarChains.lua @@ -0,0 +1,483 @@ +if not TowBarMod then TowBarMod = {} end +if not TowBarMod.Utils then TowBarMod.Utils = {} end +if not TowBarMod.UI then TowBarMod.UI = {} end + +local LANDTRAIN_DEBUG = false +local LANDTRAIN_ATTACH_LABEL = "Landtrain attach" +local LANDTRAIN_FALLBACK_MAX_DIST_SQ = 3.25 -- ~1.8 tiles +local LANDTRAIN_FALLBACK_MAX_DZ = 0.9 + +local function ltLog(msg) + if not LANDTRAIN_DEBUG then return end + print("[Landtrain] " .. tostring(msg)) +end + +local function vehLabel(vehicle) + if vehicle == nil then return "nil" end + local name = vehicle:getScriptName() or "unknown" + return tostring(vehicle:getId()) .. ":" .. tostring(name) +end + +local function dumpTowState(prefix, vehicle) + if not LANDTRAIN_DEBUG then return end + if vehicle == nil then + ltLog(prefix .. " vehicle=nil") + return + end + local modData = vehicle:getModData() or {} + local front = vehicle:getVehicleTowedBy() + local rear = vehicle:getVehicleTowing() + ltLog(prefix + .. " v=" .. vehLabel(vehicle) + .. " front=" .. vehLabel(front) + .. " rear=" .. vehLabel(rear) + .. " md.isTowingByTowBar=" .. tostring(modData["isTowingByTowBar"]) + .. " md.towed=" .. tostring(modData["towed"])) +end + +local function refreshTowBarState(vehicle) + if vehicle == nil then return end + + local modData = vehicle:getModData() + if modData == nil then return end + + local frontVehicle = vehicle:getVehicleTowedBy() + local rearVehicle = vehicle:getVehicleTowing() + + local isTowedByTowBar = false + if frontVehicle ~= nil then + local frontModData = frontVehicle:getModData() + if modData["towed"] == true or (frontModData and frontModData["isTowingByTowBar"] == true) then + isTowedByTowBar = true + end + end + + local isTowingByTowBar = false + if rearVehicle ~= nil then + local rearModData = rearVehicle:getModData() + if rearModData and rearModData["towed"] == true and rearModData["isTowingByTowBar"] == true then + isTowingByTowBar = true + end + end + + modData["towed"] = isTowedByTowBar + modData["isTowingByTowBar"] = (isTowedByTowBar or isTowingByTowBar) + vehicle:transmitModData() +end + +local function setTowBarModelVisibleForVehicle(vehicle, visible) + if vehicle == nil then return end + local part = vehicle:getPartById("towbar") + if part == nil then return end + + for j = 0, 23 do + part:setModelVisible("towbar" .. j, false) + end + + if visible then + local z = vehicle:getScript():getPhysicsChassisShape():z() / 2 - 0.1 + local index = math.floor((z * 2 / 3 - 1) * 10) + if index < 0 then index = 0 end + if index > 23 then index = 23 end + part:setModelVisible("towbar" .. index, true) + end + + vehicle:doDamageOverlay() +end + +local _landtrainHookPosA = Vector3f.new() +local _landtrainHookPosB = Vector3f.new() + +local function hasTowAttachment(vehicle, attachmentId) + if vehicle == nil or attachmentId == nil then return false end + local script = vehicle:getScript() + if script == nil then return false end + return script:getAttachmentById(attachmentId) ~= nil +end + +local function isAttachmentSideFree(vehicle, attachmentId) + if vehicle == nil then return false end + if attachmentId == "trailer" then + return vehicle:getVehicleTowing() == nil + end + if attachmentId == "trailerfront" then + return vehicle:getVehicleTowedBy() == nil + end + return true +end + +local function canTowByLandtrain(vehicleA, vehicleB, attachmentA, attachmentB) + if vehicleA == nil or vehicleB == nil then return false end + if vehicleA == vehicleB then return false end + if not hasTowAttachment(vehicleA, attachmentA) then return false end + if not hasTowAttachment(vehicleB, attachmentB) then return false end + if not isAttachmentSideFree(vehicleA, attachmentA) then return false end + if not isAttachmentSideFree(vehicleB, attachmentB) then return false end + if vehicleA:getVehicleTowing() == vehicleB or vehicleA:getVehicleTowedBy() == vehicleB then + return false + end + + -- Keep vanilla behavior when possible. + if vehicleA:canAttachTrailer(vehicleB, attachmentA, attachmentB) then + ltLog("canTowByLandtrain vanilla=true A=" .. vehLabel(vehicleA) .. " B=" .. vehLabel(vehicleB) .. " attA=" .. tostring(attachmentA) .. " attB=" .. tostring(attachmentB)) + return true + end + + -- Vanilla blocks chained towing here; allow only near-identical close-range hookups. + local posA = vehicleA:getAttachmentWorldPos(attachmentA, _landtrainHookPosA) + local posB = vehicleB:getAttachmentWorldPos(attachmentB, _landtrainHookPosB) + if posA == nil or posB == nil then return false end + + local dx = posA:x() - posB:x() + local dy = posA:y() - posB:y() + local dz = posA:z() - posB:z() + local distSq = dx * dx + dy * dy + dz * dz + local allow = distSq <= LANDTRAIN_FALLBACK_MAX_DIST_SQ and math.abs(dz) <= LANDTRAIN_FALLBACK_MAX_DZ + ltLog("canTowByLandtrain fallback=" .. tostring(allow) + .. " A=" .. vehLabel(vehicleA) + .. " B=" .. vehLabel(vehicleB) + .. " attA=" .. tostring(attachmentA) + .. " attB=" .. tostring(attachmentB) + .. " distSq=" .. string.format("%.3f", distSq) + .. " dz=" .. string.format("%.3f", dz)) + return allow +end + +local function getAttachLabel(vehicleA, vehicleB) + local isLandtrainLink = (vehicleA and (vehicleA:getVehicleTowing() or vehicleA:getVehicleTowedBy())) + or (vehicleB and (vehicleB:getVehicleTowing() or vehicleB:getVehicleTowedBy())) + if isLandtrainLink then + return LANDTRAIN_ATTACH_LABEL + end + return getText("UI_Text_Towing_byTowBar") +end + +local function captureTowbarFrontLink(towingVehicle) + if towingVehicle == nil then + ltLog("captureTowbarFrontLink towingVehicle=nil") + return nil + end + + local frontVehicle = towingVehicle:getVehicleTowedBy() + if frontVehicle == nil then + ltLog("captureTowbarFrontLink no front link for " .. vehLabel(towingVehicle)) + return nil + end + + local link = { + frontVehicle = frontVehicle, + towingVehicle = towingVehicle, + attachmentA = frontVehicle:getTowAttachmentSelf() or "trailer", + attachmentB = towingVehicle:getTowAttachmentSelf() or "trailerfront" + } + ltLog("captureTowbarFrontLink captured front=" .. vehLabel(frontVehicle) .. " middle=" .. vehLabel(towingVehicle) + .. " attA=" .. tostring(link.attachmentA) .. " attB=" .. tostring(link.attachmentB)) + return link +end + +local function restoreTowbarFrontLink(playerObj, link) + if link == nil then + ltLog("restoreTowbarFrontLink skipped (no captured link)") + return + end + local frontVehicle = link.frontVehicle + local towingVehicle = link.towingVehicle + if frontVehicle == nil or towingVehicle == nil then + ltLog("restoreTowbarFrontLink invalid captured refs") + return + end + if towingVehicle:getVehicleTowedBy() ~= nil then + ltLog("restoreTowbarFrontLink not needed; front still connected for middle=" .. vehLabel(towingVehicle)) + return + end + + ltLog("restoreTowbarFrontLink restoring front=" .. vehLabel(frontVehicle) .. " middle=" .. vehLabel(towingVehicle)) + TowBarMod.Utils.updateAttachmentsForRigidTow(frontVehicle, towingVehicle, link.attachmentA, link.attachmentB) + towingVehicle:setScriptName("notTowingA_Trailer") + + local args = { + vehicleA = frontVehicle:getId(), + vehicleB = towingVehicle:getId(), + attachmentA = link.attachmentA, + attachmentB = link.attachmentB + } + sendClientCommand(playerObj, "vehicle", "attachTrailer", args) + ISTimedActionQueue.add(TowBarScheduleAction:new(playerObj, 10, TowBarMod.Hook.setVehiclePostAttach, towingVehicle)) + + local frontModData = frontVehicle:getModData() + local towingModData = towingVehicle:getModData() + frontModData["isTowingByTowBar"] = true + towingModData["isTowingByTowBar"] = true + towingModData["towed"] = true + frontVehicle:transmitModData() + towingVehicle:transmitModData() + dumpTowState("restoreTowbarFrontLink after front", frontVehicle) + dumpTowState("restoreTowbarFrontLink after middle", towingVehicle) +end + +local function queueTowbarFrontLinkRestore(playerObj, link, delayTicks) + if link == nil or playerObj == nil then return end + local delay = delayTicks or 15 + ISTimedActionQueue.add(TowBarScheduleAction:new(playerObj, delay, function(character, linkArg) + ltLog("queueTowbarFrontLinkRestore fire delay=" .. tostring(delay) .. " middle=" .. vehLabel(linkArg and linkArg.towingVehicle or nil)) + restoreTowbarFrontLink(character, linkArg) + end, link)) +end + +local function ensureTowAttachmentsForTrailers() + local scriptManager = getScriptManager() + if scriptManager == nil then return end + + local vehicleScripts = scriptManager:getAllVehicleScripts() + if vehicleScripts == nil then return end + + for i = 0, vehicleScripts:size() - 1 do + local script = vehicleScripts:get(i) + local scriptName = script and script:getName() or nil + if script and scriptName and string.match(string.lower(scriptName), "trailer") then + local wheelCount = script:getWheelCount() + local attachHeightOffset = -0.5 + if wheelCount > 0 then + attachHeightOffset = script:getWheel(0):getOffset():y() + 0.1 + end + + local rearTow = script:getAttachmentById("trailer") + if rearTow == nil then + local attach = ModelAttachment.new("trailer") + attach:getOffset():set(0, attachHeightOffset, -script:getPhysicsChassisShape():z() / 2 - 0.1) + attach:setZOffset(-1) + script:addAttachment(attach) + end + + local frontTow = script:getAttachmentById("trailerfront") + if frontTow == nil then + local attach = ModelAttachment.new("trailerfront") + attach:getOffset():set(0, attachHeightOffset, script:getPhysicsChassisShape():z() / 2 + 0.1) + attach:setZOffset(1) + script:addAttachment(attach) + end + end + end +end + +local function menuHasTowbarAttachSlice(menu) + if menu == nil or menu.slices == nil then return false end + for _, slice in ipairs(menu.slices) do + local command = slice.command and slice.command[1] + if command == TowBarMod.Hook.attachByTowBarAction or command == TowBarMod.UI.showChooseVehicleMenu then + return true + end + end + return false +end + +local function getLandtrainHookTypeVariants(vehicleA, vehicleB) + local hookTypeVariants = {} + if vehicleA == nil or vehicleB == nil or vehicleA == vehicleB then + return hookTypeVariants + end + + if canTowByLandtrain(vehicleA, vehicleB, "trailerfront", "trailer") then + local hookType = {} + hookType.name = getText("UI_Text_Towing_attach") .. "\n" .. ISVehicleMenu.getVehicleDisplayName(vehicleB) .. "\n" .. LANDTRAIN_ATTACH_LABEL + hookType.func = TowBarMod.Hook.attachByTowBarAction + hookType.towingVehicle = vehicleB + hookType.towedVehicle = vehicleA + hookType.textureName = "tow_bar_icon" + table.insert(hookTypeVariants, hookType) + elseif canTowByLandtrain(vehicleA, vehicleB, "trailer", "trailerfront") then + local hookType = {} + hookType.name = getText("UI_Text_Towing_attach") .. "\n" .. ISVehicleMenu.getVehicleDisplayName(vehicleB) .. "\n" .. LANDTRAIN_ATTACH_LABEL + hookType.func = TowBarMod.Hook.attachByTowBarAction + hookType.towingVehicle = vehicleA + hookType.towedVehicle = vehicleB + hookType.textureName = "tow_bar_icon" + table.insert(hookTypeVariants, hookType) + end + + return hookTypeVariants +end + +local function getNearbyLandtrainTargets(mainVehicle) + local vehicles = {} + local square = mainVehicle and mainVehicle:getSquare() or nil + if square == nil then return vehicles end + + for y = square:getY() - 6, square:getY() + 6 do + for x = square:getX() - 6, square:getX() + 6 do + local square2 = getCell():getGridSquare(x, y, square:getZ()) + if square2 then + for i = 1, square2:getMovingObjects():size() do + local obj = square2:getMovingObjects():get(i - 1) + if obj ~= nil and instanceof(obj, "BaseVehicle") and obj ~= mainVehicle then + local variants = getLandtrainHookTypeVariants(mainVehicle, obj) + if #variants > 0 then + table.insert(vehicles, { vehicle = obj, variants = variants }) + end + end + end + end + end + end + return vehicles +end + +local function addLandtrainHookOptionToMenu(playerObj, vehicle) + if playerObj == nil or vehicle == nil then return end + if not playerObj:getInventory():getItemFromTypeRecurse("TowBar.TowBar") then return end + + local menu = getPlayerRadialMenu(playerObj:getPlayerNum()) + if menu == nil then return end + + local targets = getNearbyLandtrainTargets(vehicle) + if #targets == 0 then + ltLog("addLandtrainHookOptionToMenu no nearby valid targets for " .. vehLabel(vehicle)) + return + end + + if #targets == 1 then + local hookType = targets[1].variants[1] + menu:addSlice( + hookType.name, + getTexture("media/textures/tow_bar_attach.png"), + hookType.func, + playerObj, + hookType.towingVehicle, + hookType.towedVehicle, + hookType.towingPoint, + hookType.towedPoint + ) + return + end + + -- Reuse Towbar's chooser UI by passing only the candidate vehicles. + local vehicleList = {} + for _, entry in ipairs(targets) do + table.insert(vehicleList, entry.vehicle) + end + menu:addSlice( + LANDTRAIN_ATTACH_LABEL .. "...", + getTexture("media/textures/tow_bar_attach.png"), + TowBarMod.UI.showChooseVehicleMenu, + playerObj, + vehicle, + vehicleList, + true + ) +end + +local function installLandtrainTowbarPatch() + if not TowBarMod or not TowBarMod.Utils then + return + end + + if TowBarMod.Utils._landtrainUnlimitedChainsInstalled then + return + end + + -- Override Towbar's single-link limitation so a vehicle can be part of a chain. + function TowBarMod.Utils.getHookTypeVariants(vehicleA, vehicleB, hasTowBar) + local hookTypeVariants = {} + if not hasTowBar then return hookTypeVariants end + if vehicleA == nil or vehicleB == nil then return hookTypeVariants end + if vehicleA == vehicleB then return hookTypeVariants end + + -- Allow trailer <-> vehicle and trailer <-> trailer links for landtrains. + if canTowByLandtrain(vehicleA, vehicleB, "trailerfront", "trailer") then + local hookType = {} + hookType.name = getText("UI_Text_Towing_attach") .. "\n" .. ISVehicleMenu.getVehicleDisplayName(vehicleB) .. "\n" .. getAttachLabel(vehicleA, vehicleB) + hookType.func = TowBarMod.Hook.attachByTowBarAction + hookType.towingVehicle = vehicleB + hookType.towedVehicle = vehicleA + hookType.textureName = "tow_bar_icon" + table.insert(hookTypeVariants, hookType) + elseif canTowByLandtrain(vehicleA, vehicleB, "trailer", "trailerfront") then + local hookType = {} + hookType.name = getText("UI_Text_Towing_attach") .. "\n" .. ISVehicleMenu.getVehicleDisplayName(vehicleB) .. "\n" .. getAttachLabel(vehicleA, vehicleB) + hookType.func = TowBarMod.Hook.attachByTowBarAction + hookType.towingVehicle = vehicleA + hookType.towedVehicle = vehicleB + hookType.textureName = "tow_bar_icon" + table.insert(hookTypeVariants, hookType) + end + + return hookTypeVariants + end + + -- Keep towbar state valid for middle links in a chain after detach/attach. + local originalPerformAttach = TowBarMod.Hook and TowBarMod.Hook.performAttachTowBar + if originalPerformAttach then + TowBarMod.Hook.performAttachTowBar = function(playerObj, towingVehicle, towedVehicle, attachmentA, attachmentB) + ltLog("performAttachTowBar begin towing=" .. vehLabel(towingVehicle) .. " towed=" .. vehLabel(towedVehicle) + .. " attA=" .. tostring(attachmentA) .. " attB=" .. tostring(attachmentB)) + dumpTowState("performAttachTowBar pre towing", towingVehicle) + dumpTowState("performAttachTowBar pre towed", towedVehicle) + local frontLink = captureTowbarFrontLink(towingVehicle) + originalPerformAttach(playerObj, towingVehicle, towedVehicle, attachmentA, attachmentB) + dumpTowState("performAttachTowBar post-original towing", towingVehicle) + dumpTowState("performAttachTowBar post-original towed", towedVehicle) + restoreTowbarFrontLink(playerObj, frontLink) + queueTowbarFrontLinkRestore(playerObj, frontLink, 12) + queueTowbarFrontLinkRestore(playerObj, frontLink, 30) + dumpTowState("performAttachTowBar post-restore towing", towingVehicle) + dumpTowState("performAttachTowBar post-restore towed", towedVehicle) + + setTowBarModelVisibleForVehicle(towedVehicle, true) + refreshTowBarState(towingVehicle) + refreshTowBarState(towedVehicle) + if towingVehicle then + refreshTowBarState(towingVehicle:getVehicleTowedBy()) + end + if towedVehicle then + refreshTowBarState(towedVehicle:getVehicleTowing()) + end + end + end + + local originalPerformDetach = TowBarMod.Hook and TowBarMod.Hook.performDeattachTowBar + if originalPerformDetach then + TowBarMod.Hook.performDeattachTowBar = function(playerObj, towingVehicle, towedVehicle) + originalPerformDetach(playerObj, towingVehicle, towedVehicle) + + setTowBarModelVisibleForVehicle(towedVehicle, false) + refreshTowBarState(towingVehicle) + refreshTowBarState(towedVehicle) + if towingVehicle then + refreshTowBarState(towingVehicle:getVehicleTowedBy()) + refreshTowBarState(towingVehicle:getVehicleTowing()) + end + if towedVehicle then + refreshTowBarState(towedVehicle:getVehicleTowedBy()) + refreshTowBarState(towedVehicle:getVehicleTowing()) + end + end + end + + -- Towbar UI only adds attach when fully unlinked; add attach option for linked vehicles too. + if ISVehicleMenu and ISVehicleMenu.showRadialMenu and not TowBarMod.UI._landtrainShowRadialPatched then + local originalShowRadialMenu = ISVehicleMenu.showRadialMenu + ISVehicleMenu.showRadialMenu = function(playerObj) + originalShowRadialMenu(playerObj) + + if playerObj == nil or playerObj:getVehicle() then return end + local vehicle = ISVehicleMenu.getVehicleToInteractWith(playerObj) + if vehicle == nil then return end + + local menu = getPlayerRadialMenu(playerObj:getPlayerNum()) + if menuHasTowbarAttachSlice(menu) then + return + end + + if (vehicle:getVehicleTowing() or vehicle:getVehicleTowedBy()) then + addLandtrainHookOptionToMenu(playerObj, vehicle) + end + end + TowBarMod.UI._landtrainShowRadialPatched = true + end + + TowBarMod.Utils._landtrainUnlimitedChainsInstalled = true +end + +Events.OnGameBoot.Add(ensureTowAttachmentsForTrailers) +Events.OnGameBoot.Add(installLandtrainTowbarPatch) +Events.OnGameStart.Add(installLandtrainTowbarPatch) diff --git a/42.13/mod.info b/42.13/mod.info new file mode 100644 index 0000000..e983b0b --- /dev/null +++ b/42.13/mod.info @@ -0,0 +1,9 @@ +name=Landtrain +id=hrsys_landtrain +require=\hrsys_towbars +description=Extends Towbars to allow chained vehicle towing. +author=Riggs0 +category=vehicle +versionMin=42.13.0 +url=https://hudsonriggs.systems +modversion=1.0.0 diff --git a/README.md b/README.md index 4e89afd..a6218ae 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,30 @@ # Landtrain -Yargh I be a land pirate, or g'day chap wanna vb with that land train \ No newline at end of file +Landtrain extends Towbars to support chained towing. + +## Important + +Project Zomboid base `BaseVehicle.addPointConstraint()` force-breaks existing constraints. +To keep `1 -> 2` while attaching `2 -> 3`, Landtrain includes a Java class override: + +- `zombie/vehicles/BaseVehicle.class` + +This is the same override pattern used by mods like Realistic Car Physics (manual `zombie` folder copy). + +## Apply patch to game + +1. Run: + +```powershell +.\tools\patch-game-basevehicle.ps1 +``` + +2. Ensure both mods are enabled: + - `hrsys_towbars` + - `hrsys_landtrain` + +## Restore vanilla class + +```powershell +.\tools\restore-game-basevehicle.ps1 +``` diff --git a/mod.info b/mod.info new file mode 100644 index 0000000..e983b0b --- /dev/null +++ b/mod.info @@ -0,0 +1,9 @@ +name=Landtrain +id=hrsys_landtrain +require=\hrsys_towbars +description=Extends Towbars to allow chained vehicle towing. +author=Riggs0 +category=vehicle +versionMin=42.13.0 +url=https://hudsonriggs.systems +modversion=1.0.0 diff --git a/tools/java/BaseVehicleConstraintPatch.java b/tools/java/BaseVehicleConstraintPatch.java new file mode 100644 index 0000000..b3dc692 --- /dev/null +++ b/tools/java/BaseVehicleConstraintPatch.java @@ -0,0 +1,105 @@ +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.InsnNode; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.Opcodes; + +/** + * Patches zombie.vehicles.BaseVehicle so addPointConstraint() no longer force-breaks + * both vehicles before creating a new constraint. + */ +public final class BaseVehicleConstraintPatch { + private static final String TARGET_NAME = "addPointConstraint"; + private static final String BREAK_DESC_OBJECT_BOOL = "(ZLjava/lang/Boolean;)V"; + private static final String BREAK_DESC_PRIMITIVE_BOOL = "(ZZ)V"; + private static final String BASE_VEHICLE_OWNER = "zombie/vehicles/BaseVehicle"; + + private BaseVehicleConstraintPatch() { + } + + public static void main(String[] args) throws Exception { + if (args.length != 2) { + System.err.println("Usage: BaseVehicleConstraintPatch "); + System.exit(2); + } + + Path input = Paths.get(args[0]); + Path output = Paths.get(args[1]); + byte[] original = Files.readAllBytes(input); + + ClassNode classNode = new ClassNode(); + new ClassReader(original).accept(classNode, 0); + + int removedCalls = 0; + int inspectedAddPointMethods = 0; + for (MethodNode method : classNode.methods) { + if (!TARGET_NAME.equals(method.name) || !isTargetAddPointConstraint(method.desc)) { + continue; + } + inspectedAddPointMethods++; + removedCalls += patchAddPointConstraint(method); + } + + if (removedCalls < 2) { + throw new IllegalStateException( + "Expected to remove 2 breakConstraint calls, removed " + + removedCalls + + " (inspected addPoint methods: " + + inspectedAddPointMethods + + ")"); + } + + ClassWriter writer = new ClassWriter(0); + classNode.accept(writer); + + Files.createDirectories(output.getParent()); + Files.write(output, writer.toByteArray()); + System.out.println("Patched BaseVehicle.class; removed breakConstraint calls: " + removedCalls); + } + + private static boolean isTargetAddPointConstraint(String methodDesc) { + // We only want the 5-arg overload: + // (IsoPlayer, BaseVehicle, String, String, boolean|Boolean) -> void + return "(Lzombie/characters/IsoPlayer;Lzombie/vehicles/BaseVehicle;Ljava/lang/String;Ljava/lang/String;Z)V" + .equals(methodDesc) + || "(Lzombie/characters/IsoPlayer;Lzombie/vehicles/BaseVehicle;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;)V" + .equals(methodDesc); + } + + private static int patchAddPointConstraint(MethodNode method) { + int patched = 0; + InsnList insns = method.instructions; + + for (AbstractInsnNode node = insns.getFirst(); node != null; ) { + AbstractInsnNode next = node.getNext(); + if (node instanceof MethodInsnNode call + && BASE_VEHICLE_OWNER.equals(call.owner) + && "breakConstraint".equals(call.name)) { + if (!(BREAK_DESC_OBJECT_BOOL.equals(call.desc) + || BREAK_DESC_PRIMITIVE_BOOL.equals(call.desc))) { + node = next; + continue; + } + // Keep stack-map frames valid by preserving stack effect: + // breakConstraint(...) consumes objectref + 2 args and returns void. + // Replace invoke with POP2 + POP (consume 3 category-1 stack slots). + InsnList replacement = new InsnList(); + replacement.add(new InsnNode(Opcodes.POP2)); + replacement.add(new InsnNode(Opcodes.POP)); + insns.insert(node, replacement); + insns.remove(node); + patched++; + } + node = next; + } + + return patched; + } +} diff --git a/tools/patch-game-basevehicle.ps1 b/tools/patch-game-basevehicle.ps1 new file mode 100644 index 0000000..dd46d5e --- /dev/null +++ b/tools/patch-game-basevehicle.ps1 @@ -0,0 +1,80 @@ +param( + [string]$GameRoot = "D:\SteamLibrary\steamapps\common\ProjectZomboid" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$toolsDir = Join-Path $repoRoot ".tools" +$classPatchDir = Join-Path $toolsDir "classpatch" +$buildDir = Join-Path $toolsDir "patcher-build" + +New-Item -ItemType Directory -Force -Path $toolsDir | Out-Null +New-Item -ItemType Directory -Force -Path $classPatchDir | Out-Null +New-Item -ItemType Directory -Force -Path $buildDir | Out-Null + +$javaExe = Join-Path $GameRoot "jre64\bin\java.exe" +$gameJar = Join-Path $GameRoot "projectzomboid.jar" +if (-not (Test-Path $javaExe)) { throw "java.exe not found at $javaExe" } +if (-not (Test-Path $gameJar)) { throw "projectzomboid.jar not found at $gameJar" } + +$ecjJar = Join-Path $toolsDir "ecj.jar" +$asmJar = Join-Path $toolsDir "asm.jar" +$asmTreeJar = Join-Path $toolsDir "asm-tree.jar" + +if (-not (Test-Path $ecjJar)) { + Invoke-WebRequest -Uri "https://repo1.maven.org/maven2/org/eclipse/jdt/ecj/3.38.0/ecj-3.38.0.jar" -OutFile $ecjJar +} +if (-not (Test-Path $asmJar)) { + Invoke-WebRequest -Uri "https://repo1.maven.org/maven2/org/ow2/asm/asm/9.9/asm-9.9.jar" -OutFile $asmJar +} +if (-not (Test-Path $asmTreeJar)) { + Invoke-WebRequest -Uri "https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.9/asm-tree-9.9.jar" -OutFile $asmTreeJar +} + +$patcherSource = Join-Path $PSScriptRoot "java\BaseVehicleConstraintPatch.java" +if (-not (Test-Path $patcherSource)) { throw "Missing patcher source: $patcherSource" } + +& $javaExe -jar $ecjJar -17 -cp "$asmJar;$asmTreeJar" -d $buildDir $patcherSource +if ($LASTEXITCODE -ne 0) { throw "Failed to compile BaseVehicleConstraintPatch.java" } + +$inputClass = Join-Path $classPatchDir "BaseVehicle.original.class" +$patchedClass = Join-Path $classPatchDir "BaseVehicle.patched.class" + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$zip = [System.IO.Compression.ZipFile]::OpenRead($gameJar) +try { + $entry = $zip.GetEntry("zombie/vehicles/BaseVehicle.class") + if ($null -eq $entry) { throw "zombie/vehicles/BaseVehicle.class not found in $gameJar" } + + $entryStream = $entry.Open() + $fileStream = [System.IO.File]::Create($inputClass) + try { + $entryStream.CopyTo($fileStream) + } finally { + $fileStream.Close() + $entryStream.Close() + } +} finally { + $zip.Dispose() +} + +& $javaExe -cp "$buildDir;$asmJar;$asmTreeJar" BaseVehicleConstraintPatch $inputClass $patchedClass +if ($LASTEXITCODE -ne 0) { throw "BaseVehicle class patch failed" } + +$targetDir = Join-Path $GameRoot "zombie\vehicles" +$targetClass = Join-Path $targetDir "BaseVehicle.class" +$backupClass = "$targetClass.landtrain.original" + +New-Item -ItemType Directory -Force -Path $targetDir | Out-Null +if (-not (Test-Path $backupClass)) { + if (Test-Path $targetClass) { + Copy-Item $targetClass $backupClass -Force + } else { + Copy-Item $inputClass $backupClass -Force + } +} + +Copy-Item $patchedClass $targetClass -Force +Write-Output "Patched BaseVehicle.class deployed to $targetClass" +Write-Output "Backup stored at $backupClass" diff --git a/tools/restore-game-basevehicle.ps1 b/tools/restore-game-basevehicle.ps1 new file mode 100644 index 0000000..6221915 --- /dev/null +++ b/tools/restore-game-basevehicle.ps1 @@ -0,0 +1,18 @@ +param( + [string]$GameRoot = "D:\SteamLibrary\steamapps\common\ProjectZomboid" +) + +$ErrorActionPreference = "Stop" + +$targetClass = Join-Path $GameRoot "zombie\vehicles\BaseVehicle.class" +$backupClass = "$targetClass.landtrain.original" + +if (Test-Path $backupClass) { + Copy-Item $backupClass $targetClass -Force + Write-Output "Restored BaseVehicle.class from $backupClass" +} elseif (Test-Path $targetClass) { + Remove-Item $targetClass -Force + Write-Output "Removed override class at $targetClass (game will use class from projectzomboid.jar)" +} else { + Write-Output "No override or backup found. Nothing to restore." +} diff --git a/zombie/vehicles/BaseVehicle.class b/zombie/vehicles/BaseVehicle.class new file mode 100644 index 0000000..541ab17 Binary files /dev/null and b/zombie/vehicles/BaseVehicle.class differ