Initial Commit

This commit is contained in:
2026-08-12 13:36:14 -04:00
parent cdedb1cb5e
commit 19e4ac0cce
22 changed files with 30278 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
*.lua text eol=lf
*.txt text eol=lf
*.md text eol=lf
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.py[cod]
.coverage
@@ -0,0 +1,211 @@
require "ISUI/ISCollapsableWindow"
require "ISUI/ISScrollingListBox"
require "ISUI/ISButton"
require "Vehicles/TimedActions/ISPathFindAction"
require "PaintMyKI5/PaintRequirements"
require "PaintMyKI5/PaintInventory"
require "PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle"
ISPaintMyKI5UI = ISCollapsableWindow:derive("ISPaintMyKI5UI")
ISPaintMyKI5UI.instances = ISPaintMyKI5UI.instances or {}
local FONT_SMALL = UIFont.Small
local FONT_MEDIUM = UIFont.Medium
local SMALL_HEIGHT = getTextManager():getFontHeight(FONT_SMALL)
local MEDIUM_HEIGHT = getTextManager():getFontHeight(FONT_MEDIUM)
local function textureLabel(textureData)
local name = string.match(textureData.texture or "", "([^/]+)$") or ""
return getText("IGUI_PaintMyKI5_Skin", textureData.skinIndex + 1) .. " - " .. name
end
local function getPaletteColor(itemType)
for _, paint in ipairs(PaintMyKI5.VehiclePaintData.paintCans or {}) do
if paint.item == itemType and paint.rgb then
return paint.rgb[1], paint.rgb[2], paint.rgb[3]
end
end
return 0.5, 0.5, 0.5
end
function ISPaintMyKI5UI.drawSkinItem(list, y, item, alt)
if item.itemindex == list.selected then
list:drawRect(0, y, list:getWidth(), item.height, 0.25, 0.3, 0.7, 1.0)
elseif alt then
list:drawRect(0, y, list:getWidth(), item.height, 0.08, 1, 1, 1)
end
list:drawText(item.text, 8, y + 3, 1, 1, 1, 1, FONT_SMALL)
return y + item.height
end
function ISPaintMyKI5UI:createChildren()
ISCollapsableWindow.createChildren(self)
local top = self:titleBarHeight() + 10
self.skinList = ISScrollingListBox:new(10, top, 275, self.height - top - 52)
self.skinList:initialise()
self.skinList:instantiate()
self.skinList.itemheight = SMALL_HEIGHT + 8
self.skinList.doDrawItem = ISPaintMyKI5UI.drawSkinItem
self.skinList.drawBorder = true
self:addChild(self.skinList)
for _, textureData in ipairs(self.vehicleData.textures or {}) do
self.skinList:addItem(textureLabel(textureData), textureData)
end
local currentIndex = self.vehicle:getSkinIndex()
for index, row in ipairs(self.skinList.items) do
if row.item.skinIndex == currentIndex then self.skinList.selected = index end
end
if self.skinList.selected <= 0 and #self.skinList.items > 0 then self.skinList.selected = 1 end
self.paintButton = ISButton:new(
self.width - 210, self.height - 38, 95, 28,
getText("IGUI_PaintMyKI5_Paint"), self, ISPaintMyKI5UI.onPaint
)
self.paintButton:initialise()
self.paintButton:instantiate()
self:addChild(self.paintButton)
self.cancelButton = ISButton:new(
self.width - 105, self.height - 38, 95, 28,
getText("UI_btn_close"), self, ISPaintMyKI5UI.close
)
self.cancelButton:initialise()
self.cancelButton:instantiate()
self:addChild(self.cancelButton)
end
function ISPaintMyKI5UI:getSelectedTexture()
local row = self.skinList and self.skinList.items[self.skinList.selected] or nil
return row and row.item or nil
end
function ISPaintMyKI5UI:prerender()
ISCollapsableWindow.prerender(self)
local textureData = self:getSelectedTexture()
local enabled = false
if textureData then
local area = PaintMyKI5.PaintRequirements.findInteractionArea(
self.vehicle, self.character
)
local requirements = PaintMyKI5.PaintRequirements.getRequirements(
self.vehicle, textureData.skinIndex
)
enabled = requirements ~= nil
and PaintMyKI5.PaintRequirements.isValidTarget(
self.character, self.vehicle, textureData.skinIndex, area
)
and PaintMyKI5.PaintInventory.checkRequirements(self.character, requirements)
end
self.paintButton:setEnable(enabled)
end
function ISPaintMyKI5UI:render()
ISCollapsableWindow.render(self)
local textureData = self:getSelectedTexture()
if not textureData then return end
local rightX = 300
local contentWidth = self.width - rightX - 10
local previewY = self:titleBarHeight() + 10
local requirements = PaintMyKI5.PaintRequirements.getRequirements(
self.vehicle, textureData.skinIndex
) or {}
local requirementHeight = #requirements * (SMALL_HEIGHT + 5)
local availablePreviewHeight = self.height - previewY - requirementHeight - 110
local previewHeight = math.max(120, math.min(245, availablePreviewHeight))
self:drawRectBorder(rightX, previewY, contentWidth, previewHeight, 0.7, 0.6, 0.6, 0.6)
local preview = getTexture("media/textures/" .. textureData.texture .. ".png")
or getTexture(textureData.texture)
if preview then
self:drawTextureScaledAspect(preview, rightX + 5, previewY + 5, contentWidth - 10, previewHeight - 10, 1, 1, 1, 1)
else
self:drawTextCentre(
getText("IGUI_PaintMyKI5_PreviewMissing"),
rightX + contentWidth / 2, previewY + previewHeight / 2,
0.8, 0.8, 0.8, 1, FONT_SMALL
)
end
local y = previewY + previewHeight + 12
self:drawText(getText("IGUI_PaintMyKI5_Requirements"), rightX, y, 1, 1, 1, 1, FONT_MEDIUM)
y = y + MEDIUM_HEIGHT + 6
for _, requirement in ipairs(requirements) do
local available = PaintMyKI5.PaintInventory.countUses(self.character, requirement.item)
local enough = available + 0.0001 >= requirement.uses
local red, green, blue = getPaletteColor(requirement.item)
self:drawRect(rightX, y + 2, 14, 14, 1, red, green, blue)
self:drawRectBorder(rightX, y + 2, 14, 14, 1, 0.9, 0.9, 0.9)
local itemName = getItemNameFromFullType(requirement.item)
available = math.floor(available * 100 + 0.5) / 100
local text = getText(
"IGUI_PaintMyKI5_RequirementLine",
itemName, requirement.percent, requirement.uses, available
)
if enough then
self:drawText(text, rightX + 22, y, 0.7, 1, 0.7, 1, FONT_SMALL)
else
self:drawText(text, rightX + 22, y, 1, 0.4, 0.4, 1, FONT_SMALL)
end
y = y + SMALL_HEIGHT + 5
end
self:drawText(
getText("IGUI_PaintMyKI5_OneBucket", PaintMyKI5.PaintRequirements.getBucketUses()),
rightX, y + 4, 1, 1, 1, 1, FONT_SMALL
)
end
function ISPaintMyKI5UI:onPaint()
local textureData = self:getSelectedTexture()
if not textureData then return end
local requirements = PaintMyKI5.PaintRequirements.getRequirements(
self.vehicle, textureData.skinIndex
)
if not requirements then return end
local enough = PaintMyKI5.PaintInventory.checkRequirements(self.character, requirements)
if not enough then return end
local area = PaintMyKI5.PaintRequirements.findInteractionArea(self.vehicle, self.character)
if not area then return end
if not PaintMyKI5.PaintRequirements.isValidTarget(
self.character, self.vehicle, textureData.skinIndex, area
) then return end
ISTimedActionQueue.add(ISPathFindAction:pathToVehicleArea(self.character, self.vehicle, area))
ISTimedActionQueue.add(ISPaintMyKI5Vehicle:new(
self.character, self.vehicle, textureData.skinIndex, area
))
self:close()
end
function ISPaintMyKI5UI:close()
ISPaintMyKI5UI.instances[self.playerNum] = nil
self:setVisible(false)
self:removeFromUIManager()
end
function ISPaintMyKI5UI.open(playerNum, vehicle)
local existing = ISPaintMyKI5UI.instances[playerNum]
if existing then existing:close() end
local character = getSpecificPlayer(playerNum)
local window = ISPaintMyKI5UI:new(character, vehicle, playerNum)
window:initialise()
window:addToUIManager()
ISPaintMyKI5UI.instances[playerNum] = window
end
function ISPaintMyKI5UI:new(character, vehicle, playerNum)
local width = math.min(800, getCore():getScreenWidth() - 40)
local height = math.min(600, getCore():getScreenHeight() - 40)
local x = (getCore():getScreenWidth() - width) / 2
local y = (getCore():getScreenHeight() - height) / 2
local window = ISCollapsableWindow:new(x, y, width, height)
setmetatable(window, self)
self.__index = self
window.title = getText("IGUI_PaintMyKI5_Title")
window.resizable = false
window.character = character
window.vehicle = vehicle
window.vehicleData = PaintMyKI5.PaintRequirements.getVehicleData(vehicle)
window.playerNum = playerNum
return window
end
@@ -0,0 +1,45 @@
require "Vehicles/ISUI/ISVehicleMenu"
require "PaintMyKI5/PaintRequirements"
require "PaintMyKI5/ISPaintMyKI5UI"
PaintMyKI5 = PaintMyKI5 or {}
PaintMyKI5.PaintVehicleContextMenu = PaintMyKI5.PaintVehicleContextMenu or {}
local PaintVehicleContextMenu = PaintMyKI5.PaintVehicleContextMenu
local function getTargetVehicle(playerObj)
if JoypadState.players[playerObj:getPlayerNum() + 1] then
return ISVehicleMenu.getVehicleToInteractWith(playerObj)
end
return IsoObjectPicker.Instance:PickVehicle(getMouseXScaled(), getMouseYScaled())
end
function PaintVehicleContextMenu.open(playerObj, vehicle)
ISPaintMyKI5UI.open(playerObj:getPlayerNum(), vehicle)
end
function PaintVehicleContextMenu.onFillWorldObjectContextMenu(player, context, worldobjects, test)
if test and ISWorldObjectContextMenu.Test then return true end
local playerObj = getSpecificPlayer(player)
if not playerObj or playerObj:getVehicle() then return false end
local vehicle = getTargetVehicle(playerObj)
local script = vehicle and vehicle:getScript() or nil
local vehicleId = script and script:getFullName() or nil
local vehicles = PaintMyKI5.VehiclePaintData and PaintMyKI5.VehiclePaintData.vehicles
local shortId = vehicleId and string.match(vehicleId, "[^.]+$") or nil
if not shortId or string.match(string.lower(shortId), "^trailer") then return false end
if not vehicles or not vehicles[vehicleId] then return false end
if math.abs(vehicle:getCurrentSpeedKmHour()) > 0.8 then return false end
if test then return ISWorldObjectContextMenu.setTest() end
local option = context:addOption(
getText("ContextMenu_PaintMyKI5"),
playerObj,
PaintVehicleContextMenu.open,
vehicle
)
option.iconTexture = getTexture("media/textures/PaintBrush.png")
return true
end
Events.OnFillWorldObjectContextMenu.Add(PaintVehicleContextMenu.onFillWorldObjectContextMenu)
@@ -0,0 +1,131 @@
PaintMyKI5 = PaintMyKI5 or {}
PaintMyKI5.PaintInventory = PaintMyKI5.PaintInventory or {}
local PaintInventory = PaintMyKI5.PaintInventory
local EPSILON = 0.0001
local EMPTY_BUCKET = "Base.PaintbucketEmpty"
local function getItems(character, itemType)
if not character or not character:getInventory() then return nil end
return character:getInventory():getAllTypeRecurse(itemType)
end
local function getUses(item)
local useDelta = item:getUseDelta()
if not useDelta or useDelta <= 0 then return 0 end
return item:getCurrentUsesFloat() / useDelta
end
function PaintInventory.countUses(character, itemType)
local items = getItems(character, itemType)
if not items then return 0 end
local total = 0
for index = 0, items:size() - 1 do
total = total + getUses(items:get(index))
end
return total
end
function PaintInventory.checkRequirements(character, requirements)
local missing = {}
for _, requirement in ipairs(requirements or {}) do
local available = PaintInventory.countUses(character, requirement.item)
if available + EPSILON < requirement.uses then
table.insert(missing, {
item = requirement.item,
required = requirement.uses,
available = available,
})
end
end
return #missing == 0, missing
end
local function buildPlan(character, requirements)
local enough = PaintInventory.checkRequirements(character, requirements)
if not enough then return nil end
local plan = {}
for _, requirement in ipairs(requirements) do
local remaining = requirement.uses
local items = getItems(character, requirement.item)
for index = 0, items:size() - 1 do
if remaining <= EPSILON then break end
local item = items:get(index)
local oldDelta = item:getCurrentUsesFloat()
local consumed = math.min(remaining, getUses(item))
local newDelta = oldDelta - consumed * item:getUseDelta()
local mutation = {
item = item,
container = item:getContainer(),
oldDelta = oldDelta,
newDelta = newDelta,
}
if not mutation.container then return nil end
if newDelta <= EPSILON then
mutation.replacement = instanceItem(EMPTY_BUCKET)
if not mutation.replacement then return nil end
end
table.insert(plan, mutation)
remaining = remaining - consumed
end
if remaining > EPSILON then return nil end
end
return plan
end
local function applyMutation(character, mutation)
if not mutation.replacement then
mutation.item:setUsedDelta(mutation.newDelta)
mutation.deltaApplied = true
sendItemStats(mutation.item)
return
end
local added = mutation.container:AddItem(mutation.replacement)
if not added then error("could not add empty paint bucket") end
mutation.replacementAdded = true
sendAddItemToContainer(mutation.container, mutation.replacement)
character:removeFromHands(mutation.item)
mutation.container:DoRemoveItem(mutation.item)
mutation.originalRemoved = true
sendRemoveItemFromContainer(mutation.container, mutation.item)
end
local function rollBackMutation(mutation)
if mutation.replacement then
if mutation.originalRemoved then
mutation.item:setUsedDelta(mutation.oldDelta)
local restored = mutation.container:AddItem(mutation.item)
if not restored then return false end
sendAddItemToContainer(mutation.container, mutation.item)
mutation.originalRemoved = false
end
if mutation.replacementAdded then
mutation.container:DoRemoveItem(mutation.replacement)
sendRemoveItemFromContainer(mutation.container, mutation.replacement)
mutation.replacementAdded = false
end
elseif mutation.deltaApplied then
mutation.item:setUsedDelta(mutation.oldDelta)
sendItemStats(mutation.item)
mutation.deltaApplied = false
end
return true
end
function PaintInventory.consumeRequirements(character, requirements)
local plan = buildPlan(character, requirements)
if not plan then return false end
local applied = {}
for _, mutation in ipairs(plan) do
local ok = pcall(applyMutation, character, mutation)
if not ok then
pcall(rollBackMutation, mutation)
for index = #applied, 1, -1 do
pcall(rollBackMutation, applied[index])
end
return false
end
table.insert(applied, mutation)
end
return true
end
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
require "PaintMyKI5/PaintMyKI5VehiclePaintData"
PaintMyKI5 = PaintMyKI5 or {}
PaintMyKI5.PaintRequirements = PaintMyKI5.PaintRequirements or {}
local PaintRequirements = PaintMyKI5.PaintRequirements
local INTERACTION_AREAS = {
"Engine", "TruckBed", "SeatFrontLeft", "SeatLeft", "SeatFrontRight", "SeatRight"
}
local function getVehicleId(vehicle)
if not vehicle or not vehicle.getScript then return nil end
local script = vehicle:getScript()
return script and script:getFullName() or nil
end
local function isCarId(vehicleId)
local shortId = vehicleId and string.match(vehicleId, "[^.]+$") or nil
return shortId and not string.match(string.lower(shortId), "^trailer")
end
function PaintRequirements.getVehicleData(vehicle)
local data = PaintMyKI5.VehiclePaintData
local vehicleId = getVehicleId(vehicle)
if not data or not data.vehicles or not isCarId(vehicleId) then return nil end
return data.vehicles[vehicleId]
end
function PaintRequirements.getTextureData(vehicle, skinIndex)
local vehicleData = PaintRequirements.getVehicleData(vehicle)
if not vehicleData or type(skinIndex) ~= "number" or skinIndex % 1 ~= 0 then return nil end
for _, textureData in ipairs(vehicleData.textures or {}) do
if textureData.skinIndex == skinIndex then return textureData end
end
return nil
end
function PaintRequirements.getBucketUses()
local data = PaintMyKI5.VehiclePaintData
local uses = data and tonumber(data.bucketUses) or nil
if not uses or uses <= 0 then return nil end
return uses
end
function PaintRequirements.getRequirements(vehicle, skinIndex)
local textureData = PaintRequirements.getTextureData(vehicle, skinIndex)
local bucketUses = PaintRequirements.getBucketUses()
if not textureData or not bucketUses then return nil end
local requirements = {}
local total = 0
for _, paint in ipairs(textureData.paints or {}) do
local uses = tonumber(paint.uses)
if type(paint.item) ~= "string" or not uses or uses <= 0 then return nil end
table.insert(requirements, {
item = paint.item,
percent = tonumber(paint.percent) or 0,
uses = uses,
})
total = total + uses
end
if math.abs(total - bucketUses) > 0.001 then return nil end
return requirements, textureData
end
function PaintRequirements.isSupported(vehicle)
return PaintRequirements.getVehicleData(vehicle) ~= nil
end
function PaintRequirements.findInteractionArea(vehicle, character)
if not vehicle or not character then return nil end
local part = vehicle:getUseablePart(character)
if part and part:getArea() then return part:getArea() end
local script = vehicle:getScript()
if not script then return nil end
for _, area in ipairs(INTERACTION_AREAS) do
if script:getAreaById(area) then return area end
end
return nil
end
function PaintRequirements.isValidTarget(character, vehicle, skinIndex, area)
if not character or not vehicle or vehicle:isRemovedFromWorld() then return false end
if character:getVehicle() then return false end
if math.abs(vehicle:getCurrentSpeedKmHour()) > 0.8 then return false end
if type(skinIndex) ~= "number" or skinIndex % 1 ~= 0 then return false end
if skinIndex < 0 or skinIndex >= vehicle:getSkinCount() then return false end
if vehicle:getSkinIndex() == skinIndex then return false end
if not PaintRequirements.getTextureData(vehicle, skinIndex) then return false end
if not area or not vehicle:isInArea(area, character) then return false end
return true
end
@@ -0,0 +1,115 @@
require "TimedActions/ISBaseTimedAction"
require "PaintMyKI5/PaintRequirements"
require "PaintMyKI5/PaintInventory"
ISPaintMyKI5Vehicle = ISBaseTimedAction:derive("ISPaintMyKI5Vehicle")
function ISPaintMyKI5Vehicle:isValid()
local area = PaintMyKI5.PaintRequirements.findInteractionArea(
self.vehicle, self.character
)
if not PaintMyKI5.PaintRequirements.isValidTarget(
self.character, self.vehicle, self.skinIndex, area
) then
return false
end
local requirements = PaintMyKI5.PaintRequirements.getRequirements(
self.vehicle, self.skinIndex
)
if not requirements then return false end
return PaintMyKI5.PaintInventory.checkRequirements(self.character, requirements)
end
function ISPaintMyKI5Vehicle:waitToStart()
self.character:faceThisObject(self.vehicle)
return self.character:shouldBeTurning()
end
function ISPaintMyKI5Vehicle:update()
self.character:faceThisObject(self.vehicle)
self.character:setMetabolicTarget(Metabolics.MediumWork)
end
function ISPaintMyKI5Vehicle:start()
self.originalSkinIndex = self.vehicle:getSkinIndex()
self:setActionAnim(CharacterActionAnims.Paint)
self:setOverrideHandModels("PaintBrush", nil)
self.sound = self.character:playSound("Painting")
end
function ISPaintMyKI5Vehicle:serverStart()
self.originalSkinIndex = self.vehicle and self.vehicle:getSkinIndex() or nil
end
function ISPaintMyKI5Vehicle:stop()
if self.sound then self.character:stopOrTriggerSound(self.sound) end
ISBaseTimedAction.stop(self)
end
function ISPaintMyKI5Vehicle:perform()
if self.sound then self.character:stopOrTriggerSound(self.sound) end
ISBaseTimedAction.perform(self)
end
local function applySkin(vehicle, skinIndex)
vehicle:setSkinIndex(skinIndex)
if isServer() then
vehicle:transmitSkinIndex()
else
vehicle:updateSkin()
end
end
function ISPaintMyKI5Vehicle:complete()
if isClient() then return true end
local area = PaintMyKI5.PaintRequirements.findInteractionArea(
self.vehicle, self.character
)
if not PaintMyKI5.PaintRequirements.isValidTarget(
self.character, self.vehicle, self.skinIndex, area
) then
return false
end
if self.originalSkinIndex == nil
or self.vehicle:getSkinIndex() ~= self.originalSkinIndex then
return false
end
local requirements = PaintMyKI5.PaintRequirements.getRequirements(
self.vehicle, self.skinIndex
)
if not requirements then return false end
local enough = PaintMyKI5.PaintInventory.checkRequirements(
self.character, requirements
)
if not enough then return false end
local vehicle = self.vehicle
local changed = pcall(applySkin, vehicle, self.skinIndex)
if not changed then
pcall(applySkin, vehicle, self.originalSkinIndex)
return false
end
if not PaintMyKI5.PaintInventory.consumeRequirements(self.character, requirements) then
pcall(applySkin, vehicle, self.originalSkinIndex)
return false
end
return true
end
function ISPaintMyKI5Vehicle:getDuration()
if self.character:isTimedActionInstant() then return 1 end
return 500
end
function ISPaintMyKI5Vehicle:new(character, vehicle, skinIndex, area)
local action = ISBaseTimedAction.new(self, character)
action.vehicle = vehicle
action.skinIndex = skinIndex
action.area = area
action.originalSkinIndex = vehicle and vehicle:getSkinIndex() or nil
action.maxTime = action:getDuration()
action.stopOnWalk = true
action.stopOnRun = true
action.caloriesModifier = 4
return action
end
@@ -0,0 +1,3 @@
{
"ContextMenu_PaintMyKI5": "Paint vehicle"
}
@@ -0,0 +1,9 @@
{
"IGUI_PaintMyKI5_Title": "Paint KI5 Vehicle",
"IGUI_PaintMyKI5_Skin": "Skin %1",
"IGUI_PaintMyKI5_Paint": "Paint",
"IGUI_PaintMyKI5_Requirements": "Paint requirements",
"IGUI_PaintMyKI5_RequirementLine": "%1: %2%%, %3 uses needed, %4 available",
"IGUI_PaintMyKI5_OneBucket": "Total: %1 uses — one full bucket",
"IGUI_PaintMyKI5_PreviewMissing": "Texture preview unavailable"
}
+11
View File
@@ -0,0 +1,11 @@
name=Paint my KI5
id=hrsys_paint_my_ki5
poster=../common/media/textures/preview.png
description=A mod allowing for painting of KI5 vehicles.
author=Riggs0
category=vehicle
require=damnlib
icon=../common/media/textures/paint_my_ki5_icon.png
url=https://hudsonriggs.systems
modversion=1.1.0
versionMin=42.20
+91 -1
View File
@@ -1,3 +1,93 @@
# PaintMyKI5 # PaintMyKI5
A mod that allows you to paint your KI5 Ride! A Project Zomboid Build 42 mod that lets players repaint supported KI5 vehicles
in singleplayer, hosted multiplayer, and dedicated servers.
## Painting vehicles
Stand outside a supported, stopped vehicle and right-click it, then choose
**Paint vehicle**. The window shows every scanned skin, its raw texture preview,
and the paint available in your carried inventory (including nested bags).
Every repaint costs exactly one full B42 paint bucket worth of material. B42
paint buckets use `UseDelta = 0.1`, giving 10 uses per bucket. Those 10 uses are
split fractionally across the selected texture's scanned colors. A
60% gray/20% green/20% white skin therefore costs 6 gray, 2 green, and 2 white
uses. Small color percentages are retained rather than rounded away.
Painting uses a networked timed action. On multiplayer servers, the server
rechecks the vehicle, selected skin, distance, movement, unchanged starting
skin, and the acting player's paint immediately before it consumes anything.
The server then applies and broadcasts the skin. The painting action never
accepts costs or inventory totals supplied by the client.
This protects the mod's normal painting flow. Build 42 also contains a generic
vanilla `vehicle/setSkinIndex` command used by other game tooling; hardening or
removing that unrelated base-game command is outside this mod's scope.
## Vehicle color scanner
The scanner:
- searches `D:\SteamLibrary\steamapps\workshop\content\108600` by default;
- deduplicates root and versioned `mod.info` files requiring `damnlib`;
- selects the newest installed `42.x` content layer;
- identifies self-propelled cars from their vehicle scripts;
- maps each `skin` texture to its exact in-game ID, such as
`Base.92nissanGTR`; and
- discovers the 15 full paint buckets from the installed B42 game data;
- prints pixel-derived percentages using exact item IDs such as
`Base.PaintGreen`, `Base.PaintGrey`, and `Base.PaintWhite`; and
- writes the exact proportional use requirements consumed by the in-game menu.
The tool reads the authoritative palette from `ISPaintMenu.lua` and verifies
each entry against the bucket definitions in `drainable.txt`. Empty buckets and
spray paint are excluded. Pixels are matched to the nearest paint color in
perceptual Lab color space.
The development tool and its tests live under [`scanner/`](scanner/). Install
Python 3.11 or newer, then run it from the repository root:
```powershell
python -m pip install -r scanner/requirements.txt
python -m scanner --quiet
```
By default it writes the complete runtime data table to:
```text
42.20/media/lua/shared/PaintMyKI5/PaintMyKI5VehiclePaintData.lua
```
The in-game menu reads `PaintMyKI5.VehiclePaintData`. The generated table
contains the palette, 10-use bucket capacity, exact vehicle IDs, zero-based skin
indices, texture references, Workshop/mod metadata, paint item IDs,
percentages, and proportional uses. It excludes local absolute paths and
timestamps, so identical inputs produce identical Lua.
To scan another Steam library:
```powershell
python -m scanner --quiet --workshop-root "E:\SteamLibrary\steamapps\workshop\content\108600"
```
If Project Zomboid itself is installed elsewhere, pass both locations:
```powershell
python -m scanner --quiet `
--game-root "E:\SteamLibrary\steamapps\common\ProjectZomboid" `
--workshop-root "E:\SteamLibrary\steamapps\workshop\content\108600"
```
Run the tests with:
```powershell
python -m unittest discover -s scanner/tests -v
```
The headless Lua logic and syntax checks use Lupa without adding a runtime
dependency to the mod:
```powershell
uv run --with lupa python scanner/tests/run_lua_tests.py
```
+11
View File
@@ -0,0 +1,11 @@
name=Paint my KI5
id=hrsys_paint_my_ki5
poster=/common/media/textures/preview.png
description=A mod allowing for painting of KI5 vehicles.
author=Riggs0
category=vehicle
require=damnlib
icon=/common/media/textures/paint_my_ki5_icon.png
url=https://hudsonriggs.systems
modversion=1.1.0
versionMin=42.20
+1
View File
@@ -0,0 +1 @@
"""Development scanner for generating PaintMyKI5 vehicle paint data."""
+4
View File
@@ -0,0 +1,4 @@
from scanner.scan_ki5_workshop import main
raise SystemExit(main())
+1
View File
@@ -0,0 +1 @@
Pillow>=10.0,<13.0
+976
View File
@@ -0,0 +1,976 @@
"""Scan Project Zomboid Workshop mods for KI5 vehicle skin colors."""
from __future__ import annotations
import argparse
import json
import math
import os
import re
import sys
import tempfile
import warnings
from collections import Counter
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from functools import lru_cache
from pathlib import Path
from typing import Iterable, Sequence, TextIO
from PIL import Image, UnidentifiedImageError
DEFAULT_WORKSHOP_ROOT = Path(r"D:\SteamLibrary\steamapps\workshop\content\108600")
DEFAULT_GAME_ROOT = Path(r"D:\SteamLibrary\steamapps\common\ProjectZomboid")
DEFAULT_OUTPUT_PATH = (
Path(__file__).resolve().parent.parent
/ "42.20/media/lua/shared/PaintMyKI5/PaintMyKI5VehiclePaintData.lua"
)
DEPENDENCY_SPLIT = re.compile(r"[,;\s]+")
MODULE_PATTERN = re.compile(r"(?im)^\s*module\s+([\w.-]+)\s*(?=\{)")
VEHICLE_PATTERN = re.compile(r"(?im)^\s*vehicle\s+([\w.-]+)\s*(?=\{)")
SKIN_PATTERN = re.compile(r"(?im)^\s*skin(?:\s+[\w.-]+)?\s*(?=\{)")
TEXTURE_PATTERN = re.compile(r"(?im)^\s*texture\s*=\s*([^,\r\n}]+)")
DRIVABLE_PATTERN = re.compile(
r"(?im)^\s*(?:engineForce|engineLoudness|engineQuality|engineRPMType|maxSpeed)\s*="
)
ITEM_PATTERN = re.compile(r"(?im)^\s*item\s+([\w.-]+)\s*(?=\{)")
PROPERTY_PATTERN = re.compile(r"(?im)(?:^|[,{\r\n])\s*([\w.-]+)\s*=\s*([^,\r\n}]+)")
PAINT_MENU_PATTERN = re.compile(
r'paint\s*=\s*["\']([\w.-]+)["\'][^}\r\n]*'
r'color\s*=\s*\{\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*,'
r'\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*,'
r'\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*\}',
flags=re.IGNORECASE,
)
MAX_TEXT_BYTES = 8 * 1024 * 1024
MAX_IMAGE_PIXELS = 4096 * 4096
MAX_DISCOVERED_FILES = 20_000
MAX_TREE_ENTRIES = 500_000
MAX_UNIQUE_COLORS = 262_144
MAX_TEXTURE_RESULTS = 20_000
MAX_PAINT_CANS = 256
MAX_VEHICLES = 20_000
MAX_SKINS_PER_VEHICLE = 2_000
MAX_WARNINGS = 20_000
MAX_METADATA_CHARS = 2_048
MAX_MANIFEST_BYTES = 64 * 1024 * 1024
class ScanFileError(ValueError):
"""Raised when an untrusted Workshop file exceeds scanner safety limits."""
@dataclass(frozen=True)
class ModCandidate:
workshop_id: str
mod_id: str
name: str
mod_root: Path
content_root: Path
@dataclass(frozen=True)
class SkinDefinition:
skin_index: int
texture_reference: str
@dataclass(frozen=True)
class VehicleDefinition:
vehicle_id: str
skins: tuple[SkinDefinition, ...]
@property
def texture_references(self) -> tuple[str, ...]:
return tuple(skin.texture_reference for skin in self.skins)
@dataclass(frozen=True)
class PaintCan:
item_id: str
display_name: str
rgb: tuple[float, float, float]
use_delta: float
@dataclass(frozen=True)
class PaintPercentage:
paint_can: PaintCan
percentage: float
@dataclass(frozen=True)
class TextureResult:
workshop_id: str
mod_id: str
mod_name: str
vehicle_id: str
skin_index: int
texture_reference: str
texture_path: Path
paints: tuple[PaintPercentage, ...]
@dataclass(frozen=True)
class ScanReport:
mods_scanned: int
cars_found: int
textures: tuple[TextureResult, ...]
warnings: tuple[str, ...]
def paint_bucket_uses(paint_cans: Sequence[PaintCan]) -> int:
"""Return the full-bucket capacity shared by the discovered B42 paints."""
if not paint_cans:
raise ValueError("The B42 paint palette is empty")
use_deltas = {Decimal(str(paint.use_delta)) for paint in paint_cans}
if len(use_deltas) != 1:
raise ValueError("B42 paint cans do not share one bucket capacity")
use_delta = use_deltas.pop()
if not use_delta.is_finite() or use_delta <= 0:
raise ValueError("B42 paint use delta must be finite and positive")
capacity = Decimal("1") / use_delta
rounded_capacity = capacity.to_integral_value(rounding=ROUND_HALF_UP)
if capacity != rounded_capacity or rounded_capacity <= 0:
raise ValueError("B42 paint use delta does not produce whole bucket uses")
return int(rounded_capacity)
def allocate_paint_uses(
paints: Sequence[PaintPercentage],
bucket_uses: int,
) -> list[tuple[PaintPercentage, float]]:
"""Spread one bucket's uses across every represented paint color."""
if bucket_uses <= 0:
raise ValueError("Paint bucket capacity must be positive")
combined: dict[str, tuple[PaintPercentage, Decimal]] = {}
for paint in paints:
percentage = Decimal(str(paint.percentage))
if not percentage.is_finite() or percentage < 0:
raise ValueError("Paint percentages must be finite and non-negative")
previous = combined.get(paint.paint_can.item_id)
total = percentage + (previous[1] if previous else Decimal("0"))
combined[paint.paint_can.item_id] = (paint, total)
percentage_total = sum((entry[1] for entry in combined.values()), Decimal("0"))
if percentage_total <= 0:
return []
allocations = [
(
entry,
(percentage * Decimal(bucket_uses) / percentage_total).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
),
)
for entry, percentage in combined.values()
]
difference = Decimal(bucket_uses) - sum(
(uses for _entry, uses in allocations), Decimal("0")
)
if difference:
target_index = min(
range(len(allocations)),
key=lambda index: (
-Decimal(str(allocations[index][0].percentage)),
allocations[index][0].paint_can.item_id,
),
)
entry, uses = allocations[target_index]
allocations[target_index] = (entry, uses + difference)
return [
(entry, float(uses))
for entry, uses in sorted(
allocations,
key=lambda allocation: (
-allocation[1],
allocation[0].paint_can.item_id,
),
)
if uses > 0
]
def _read_text(path: Path) -> str:
with path.open("rb") as stream:
contents = stream.read(MAX_TEXT_BYTES + 1)
if len(contents) > MAX_TEXT_BYTES:
raise ScanFileError(f"Text file exceeds {MAX_TEXT_BYTES} bytes: {path}")
return contents.decode("utf-8-sig", errors="replace")
def _bounded_files(
root: Path,
*,
name: str | None = None,
suffix: str | None = None,
) -> Iterable[Path]:
visited_entries = 0
yielded_files = 0
pending_directories = [root]
while pending_directories:
directory = pending_directories.pop()
try:
entries = os.scandir(directory)
except OSError:
continue
with entries:
for entry in entries:
visited_entries += 1
if visited_entries > MAX_TREE_ENTRIES:
raise ValueError(
f"File traversal exceeded the {MAX_TREE_ENTRIES}-entry safety limit"
)
try:
if entry.is_dir(follow_symlinks=False):
pending_directories.append(Path(entry.path))
continue
if not entry.is_file(follow_symlinks=False):
continue
except OSError:
continue
file_name = entry.name
if name is not None and file_name.casefold() != name.casefold():
continue
if suffix is not None and not file_name.casefold().endswith(suffix.casefold()):
continue
yielded_files += 1
if yielded_files > MAX_DISCOVERED_FILES:
raise ValueError(
f"File discovery exceeded the {MAX_DISCOVERED_FILES}-file safety limit"
)
yield Path(entry.path)
def _parse_mod_info(path: Path) -> dict[str, str]:
fields: dict[str, str] = {}
for raw_line in _read_text(path).splitlines():
line = raw_line.strip()
if not line or line.startswith(("#", ";", "//")) or "=" not in line:
continue
key, value = line.split("=", 1)
normalized_key = key.strip().casefold()
normalized_value = value.strip().strip("'\"")
if len(normalized_key) > 128 or len(normalized_value) > MAX_METADATA_CHARS:
raise ScanFileError(f"Oversized mod.info field: {path}")
fields[normalized_key] = normalized_value
return fields
def _requires_damnlib(fields: dict[str, str]) -> bool:
dependencies = fields.get("require", "")
tokens = (
token.strip("\\/'\"").casefold()
for token in DEPENDENCY_SPLIT.split(dependencies)
if token
)
return "damnlib" in tokens
def _find_mod_root(info_path: Path) -> tuple[Path, str] | None:
parts = info_path.parts
mod_indexes = [index for index, part in enumerate(parts) if part.casefold() == "mods"]
if not mod_indexes:
return None
mods_index = mod_indexes[-1]
if mods_index == 0 or mods_index + 1 >= len(parts):
return None
return Path(*parts[: mods_index + 2]), parts[mods_index - 1]
def _version_key(path: Path) -> tuple[int, ...] | None:
if not re.fullmatch(r"42(?:\.\d+)*", path.name, flags=re.IGNORECASE):
return None
return tuple(int(part) for part in path.name.split("."))
def _select_content_root(mod_root: Path) -> Path:
resolved_mod_root = mod_root.resolve(strict=True)
version_roots = [
child
for child in mod_root.iterdir()
if child.is_dir()
and _version_key(child) is not None
and not child.is_symlink()
and child.resolve(strict=True).is_relative_to(resolved_mod_root)
and (child / "media" / "scripts" / "vehicles").is_dir()
]
if version_roots:
return max(version_roots, key=lambda path: _version_key(path) or ())
return mod_root
def discover_mods(workshop_root: Path) -> list[ModCandidate]:
"""Return unique DamnLib mods, selecting their newest installed B42 overlay."""
workshop_root = workshop_root.expanduser()
if not workshop_root.is_dir():
raise ValueError(f"Workshop root does not exist or is not a directory: {workshop_root}")
grouped: dict[Path, tuple[str, Path]] = {}
for info_path in _bounded_files(workshop_root, name="mod.info"):
try:
fields = _parse_mod_info(info_path)
except (OSError, ScanFileError):
continue
if not _requires_damnlib(fields):
continue
location = _find_mod_root(info_path)
if location is None:
continue
mod_root, workshop_id = location
previous = grouped.get(mod_root)
fallback_info = (
info_path
if previous is None or len(info_path.parts) < len(previous[1].parts)
else previous[1]
)
grouped[mod_root] = (workshop_id, fallback_info)
candidates: list[ModCandidate] = []
for mod_root, (workshop_id, fallback_info) in grouped.items():
try:
content_root = _select_content_root(mod_root)
except OSError:
continue
selected_info = content_root / "mod.info"
if not selected_info.is_file():
selected_info = fallback_info
try:
fields = _parse_mod_info(selected_info)
except (OSError, ScanFileError):
continue
if not _requires_damnlib(fields):
continue
candidates.append(
ModCandidate(
workshop_id=workshop_id,
mod_id=fields.get("id", mod_root.name),
name=fields.get("name", fields.get("id", mod_root.name)),
mod_root=mod_root,
content_root=content_root,
)
)
return sorted(candidates, key=lambda mod: (mod.workshop_id.casefold(), mod.mod_id.casefold()))
def _without_comments(text: str) -> str:
text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
return re.sub(r"//.*$", "", text, flags=re.MULTILINE)
def _extract_braced_block(text: str, opening_brace: int) -> str | None:
if opening_brace < 0 or opening_brace >= len(text) or text[opening_brace] != "{":
return None
depth = 0
for index in range(opening_brace, len(text)):
if text[index] == "{":
depth += 1
elif text[index] == "}":
depth -= 1
if depth == 0:
return text[opening_brace + 1 : index]
return None
def _property_map(block: str) -> dict[str, str]:
return {
match.group(1).casefold(): match.group(2).strip().strip("'\"")
for match in PROPERTY_PATTERN.finditer(block)
}
def _parse_paint_item_ids(item_script: Path) -> dict[str, float]:
text = _without_comments(_read_text(item_script))
paint_items: dict[str, float] = {}
for item_match in ITEM_PATTERN.finditer(text):
opening_brace = text.find("{", item_match.end())
block = _extract_braced_block(text, opening_brace)
if block is None:
continue
properties = _property_map(block)
tags = {
tag.strip().casefold()
for tag in re.split(r"[,;\s]+", properties.get("tags", ""))
if tag.strip()
}
if properties.get("itemtype", "").casefold() != "base:drainable":
continue
if properties.get("pourtype", "").casefold() != "bucket":
continue
if properties.get("replaceondeplete", "").casefold() != "base.paintbucketempty":
continue
if "base:paint" not in tags:
continue
try:
use_delta = float(properties["usedelta"])
except (KeyError, ValueError):
continue
if not 0 < use_delta <= 1:
continue
module_name = _module_before(text, item_match.start())
short_id = item_match.group(1)
item_id = short_id if "." in short_id else f"{module_name}.{short_id}"
paint_items[item_id] = use_delta
return paint_items
def _parse_paint_menu(menu_path: Path) -> dict[str, tuple[float, float, float]]:
text = _without_comments(_read_text(menu_path))
colors: dict[str, tuple[float, float, float]] = {}
for match in PAINT_MENU_PATTERN.finditer(text):
short_id = match.group(1)
item_id = short_id if "." in short_id else f"Base.{short_id}"
rgb = tuple(float(match.group(index)) for index in range(2, 5))
if any(not math.isfinite(channel) or not 0 <= channel <= 1 for channel in rgb):
raise ValueError(f"Invalid B42 paint RGB for {item_id}: {rgb}")
if item_id in colors and colors[item_id] != rgb:
raise ValueError(f"Conflicting B42 paint RGB values for {item_id}")
colors[item_id] = rgb
return colors
def discover_paint_cans(game_root: Path) -> tuple[PaintCan, ...]:
"""Load full B42 paint buckets and canonical RGB values from the game files."""
game_root = game_root.expanduser()
if not game_root.is_dir():
raise ValueError(f"Game root does not exist or is not a directory: {game_root}")
item_script = game_root / "media/scripts/generated/items/drainable.txt"
menu_path = game_root / "media/lua/shared/BuildingObjects/ISPaintMenu.lua"
names_path = game_root / "media/lua/shared/Translate/EN/ItemName.json"
missing = [path for path in (item_script, menu_path) if not path.is_file()]
if missing:
raise ValueError(f"Missing B42 paint data: {missing[0]}")
try:
paint_items = _parse_paint_item_ids(item_script)
menu_colors = _parse_paint_menu(menu_path)
names = json.loads(_read_text(names_path)) if names_path.is_file() else {}
except (OSError, ScanFileError, json.JSONDecodeError) as error:
raise ValueError(f"Could not read B42 paint data: {error}") from error
if not isinstance(names, dict) or not all(
isinstance(key, str) and isinstance(value, str) for key, value in names.items()
):
raise ValueError(f"Invalid B42 item-name data: {names_path}")
cans = tuple(
PaintCan(
item_id=item_id,
display_name=str(names.get(item_id, item_id.removeprefix("Base.Paint") or item_id)),
rgb=menu_colors[item_id],
use_delta=use_delta,
)
for item_id, use_delta in sorted(paint_items.items())
if item_id in menu_colors
)
if not cans:
raise ValueError("No complete B42 paint cans were found in the game data")
missing_colors = sorted(set(paint_items) - set(menu_colors))
if missing_colors:
raise ValueError(f"B42 paint cans are missing RGB values: {', '.join(missing_colors)}")
if len(cans) > MAX_PAINT_CANS:
raise ValueError(f"B42 paint palette exceeds the {MAX_PAINT_CANS}-can safety limit")
return cans
def _module_before(text: str, position: int) -> str:
modules = [match.group(1) for match in MODULE_PATTERN.finditer(text, 0, position)]
return modules[-1] if modules else "Base"
def _vehicle_skins(vehicle_block: str) -> tuple[SkinDefinition, ...]:
skins: list[SkinDefinition] = []
for skin_match in SKIN_PATTERN.finditer(vehicle_block):
opening_brace = vehicle_block.find("{", skin_match.end())
skin_block = _extract_braced_block(vehicle_block, opening_brace)
if skin_block is None:
continue
texture_match = TEXTURE_PATTERN.search(skin_block)
if texture_match:
reference = texture_match.group(1).strip().strip("'\"")
if reference:
if len(reference) > MAX_METADATA_CHARS:
raise ValueError("Vehicle texture reference exceeds the safety limit")
skins.append(SkinDefinition(len(skins), reference))
if len(skins) > MAX_SKINS_PER_VEHICLE:
raise ValueError(
f"Vehicle exceeds the {MAX_SKINS_PER_VEHICLE}-skin safety limit"
)
return tuple(skins)
def parse_vehicle_scripts(scripts_root: Path) -> list[VehicleDefinition]:
"""Parse drivable vehicle IDs and skin texture references from PZ scripts."""
if not scripts_root.is_dir():
return []
vehicles: list[VehicleDefinition] = []
script_paths = sorted(
_bounded_files(scripts_root, suffix=".txt"),
key=lambda path: str(path).casefold(),
)
for script_path in script_paths:
try:
text = _without_comments(_read_text(script_path))
except (OSError, ScanFileError):
continue
for vehicle_match in VEHICLE_PATTERN.finditer(text):
short_vehicle_id = vehicle_match.group(1)
if short_vehicle_id.casefold().startswith("trailer"):
continue
opening_brace = text.find("{", vehicle_match.end())
block = _extract_braced_block(text, opening_brace)
if block is None or not DRIVABLE_PATTERN.search(block):
continue
skins = _vehicle_skins(block)
if not skins:
continue
module_name = _module_before(text, vehicle_match.start())
vehicles.append(
VehicleDefinition(
vehicle_id=f"{module_name}.{short_vehicle_id}",
skins=skins,
)
)
if len(vehicles) > MAX_VEHICLES:
raise ValueError(f"Script scan exceeds the {MAX_VEHICLES}-vehicle safety limit")
unique = {(vehicle.vehicle_id, vehicle.skins): vehicle for vehicle in vehicles}
return sorted(unique.values(), key=lambda vehicle: vehicle.vehicle_id.casefold())
def _case_insensitive_file(root: Path, relative_path: Path) -> Path | None:
current = root
for part in relative_path.parts:
if part in ("", ".", ".."):
return None
direct = current / part
if direct.exists():
current = direct
continue
try:
match = next(
(child for child in current.iterdir() if child.name.casefold() == part.casefold()),
None,
)
except OSError:
return None
if match is None:
return None
current = match
if not current.is_file():
return None
try:
resolved_root = root.resolve(strict=True)
resolved_file = current.resolve(strict=True)
except OSError:
return None
return resolved_file if resolved_file.is_relative_to(resolved_root) else None
def resolve_texture(mod: ModCandidate, reference: str) -> Path | None:
normalized = reference.replace("\\", "/").strip().lstrip("/")
if ":" in normalized:
return None
if not normalized.casefold().endswith(".png"):
normalized += ".png"
relative_path = Path(*normalized.split("/"))
if relative_path.is_absolute() or relative_path.drive or relative_path.anchor:
return None
texture_roots = (
mod.content_root / "media" / "textures",
mod.mod_root / "common" / "media" / "textures",
mod.mod_root / "media" / "textures",
)
for texture_root in texture_roots:
resolved = _case_insensitive_file(texture_root, relative_path)
if resolved is not None:
return resolved
return None
def _srgb_to_lab(rgb: tuple[float, float, float]) -> tuple[float, float, float]:
linear = tuple(
channel / 12.92
if channel <= 0.04045
else ((channel + 0.055) / 1.055) ** 2.4
for channel in rgb
)
red, green, blue = linear
x = (0.4124564 * red + 0.3575761 * green + 0.1804375 * blue) / 0.95047
y = 0.2126729 * red + 0.7151522 * green + 0.0721750 * blue
z = (0.0193339 * red + 0.1191920 * green + 0.9503041 * blue) / 1.08883
def transform(channel: float) -> float:
return channel ** (1 / 3) if channel > 216 / 24389 else (24389 / 27 * channel + 16) / 116
x_value, y_value, z_value = transform(x), transform(y), transform(z)
return 116 * y_value - 16, 500 * (x_value - y_value), 200 * (y_value - z_value)
@lru_cache(maxsize=131_072)
def _nearest_paint(
red: int,
green: int,
blue: int,
palette_labs: tuple[tuple[PaintCan, tuple[float, float, float]], ...],
) -> PaintCan:
pixel_lab = _srgb_to_lab((red / 255, green / 255, blue / 255))
return min(
palette_labs,
key=lambda entry: (
sum((pixel_lab[index] - entry[1][index]) ** 2 for index in range(3)),
entry[0].item_id,
),
)[0]
def analyze_colors(
texture_path: Path,
paint_cans: Sequence[PaintCan],
) -> list[PaintPercentage]:
"""Map non-transparent PNG pixels to their nearest available B42 paint cans."""
if not paint_cans:
raise ValueError("The B42 paint palette is empty")
palette_labs = tuple((paint, _srgb_to_lab(paint.rgb)) for paint in paint_cans)
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
image = Image.open(texture_path, formats=("PNG",))
with image:
if image.format != "PNG":
raise UnidentifiedImageError(f"Not a PNG image: {texture_path}")
width, height = image.size
if width <= 0 or height <= 0 or width * height > MAX_IMAGE_PIXELS:
raise ScanFileError(
f"Image exceeds the {MAX_IMAGE_PIXELS}-pixel safety limit: {texture_path}"
)
rgba = image.convert("RGBA")
counts: Counter[PaintCan] = Counter()
rgba_counts = rgba.getcolors(maxcolors=MAX_UNIQUE_COLORS)
if rgba_counts is None:
raise ScanFileError(
f"Image exceeds the {MAX_UNIQUE_COLORS}-unique-color safety limit: {texture_path}"
)
for pixel_count, (red, green, blue, opacity) in rgba_counts:
if not opacity:
continue
paint = _nearest_paint(red, green, blue, palette_labs)
counts[paint] += pixel_count * opacity
total = sum(counts.values())
if total == 0:
return []
sorted_counts = sorted(counts.items(), key=lambda item: (-item[1], item[0].item_id))
percentage_units = {paint: count * 1000 // total for paint, count in counts.items()}
remaining_units = 1000 - sum(percentage_units.values())
remainders = sorted(
counts,
key=lambda paint: (-(counts[paint] * 1000 % total), paint.item_id),
)
for paint in remainders[:remaining_units]:
percentage_units[paint] += 1
return [
PaintPercentage(paint_can=paint, percentage=percentage_units[paint] / 10)
for paint, _count in sorted_counts
if percentage_units[paint] > 0
]
def scan_workshop(workshop_root: Path, paint_cans: Sequence[PaintCan]) -> ScanReport:
mods = discover_mods(workshop_root)
results: list[TextureResult] = []
warnings: list[str] = []
cars_found = 0
paint_cache: dict[Path, tuple[PaintPercentage, ...]] = {}
for mod in mods:
scripts_root = mod.content_root / "media" / "scripts" / "vehicles"
vehicles = parse_vehicle_scripts(scripts_root)
cars_found += len(vehicles)
for vehicle in vehicles:
for skin in vehicle.skins:
reference = skin.texture_reference
texture_path = resolve_texture(mod, reference)
if texture_path is None:
if len(warnings) >= MAX_WARNINGS:
raise ValueError(f"Scan exceeded the {MAX_WARNINGS}-warning safety limit")
warnings.append(f"Missing texture for {vehicle.vehicle_id}: {reference}")
continue
try:
paints = paint_cache.get(texture_path)
if paints is None:
paints = tuple(analyze_colors(texture_path, paint_cans))
paint_cache[texture_path] = paints
except (
OSError,
ScanFileError,
UnidentifiedImageError,
Image.DecompressionBombError,
Image.DecompressionBombWarning,
) as error:
if len(warnings) >= MAX_WARNINGS:
raise ValueError(f"Scan exceeded the {MAX_WARNINGS}-warning safety limit")
warnings.append(f"Could not read {texture_path}: {error}")
continue
results.append(
TextureResult(
workshop_id=mod.workshop_id,
mod_id=mod.mod_id,
mod_name=mod.name,
vehicle_id=vehicle.vehicle_id,
skin_index=skin.skin_index,
texture_reference=reference,
texture_path=texture_path,
paints=paints,
)
)
if len(results) > MAX_TEXTURE_RESULTS:
raise ValueError(
f"Scan exceeded the {MAX_TEXTURE_RESULTS}-texture safety limit"
)
return ScanReport(
mods_scanned=len(mods),
cars_found=cars_found,
textures=tuple(results),
warnings=tuple(warnings),
)
def _format_paints(paints: Iterable[PaintPercentage]) -> str:
formatted = ", ".join(
f"{_safe_console_text(paint.paint_can.display_name)} "
f"[{_safe_console_text(paint.paint_can.item_id)}] {paint.percentage:.1f}%"
for paint in paints
)
return formatted or "no visible pixels"
def _lua_string(value: object) -> str:
text = str(value)
if len(text) > MAX_METADATA_CHARS:
raise ValueError("Lua manifest field exceeds the safety limit")
escaped = (
text
.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\r", "\\r")
.replace("\n", "\\n")
.replace("\t", "\\t")
)
escaped = re.sub(
r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]",
lambda match: f"\\{ord(match.group(0)):03d}",
escaped,
)
return f'"{escaped}"'
def _render_lua_manifest(
report: ScanReport,
paint_cans: Sequence[PaintCan],
) -> str:
bucket_uses = paint_bucket_uses(paint_cans)
lines = [
"-- Generated by scanner/scan_ki5_workshop.py. Do not edit by hand.",
"PaintMyKI5 = PaintMyKI5 or {}",
"PaintMyKI5.VehiclePaintData = {",
" schemaVersion = 2,",
f" bucketUses = {bucket_uses},",
f" modCount = {report.mods_scanned},",
f" carCount = {report.cars_found},",
f" textureCount = {len(report.textures)},",
" paintCans = {",
]
for paint in sorted(paint_cans, key=lambda entry: entry.item_id.casefold()):
red, green, blue = paint.rgb
lines.extend(
[
" {",
f" item = {_lua_string(paint.item_id)},",
f" name = {_lua_string(paint.display_name)},",
f" rgb = {{ {red:.2f}, {green:.2f}, {blue:.2f} }},",
f" useDelta = {paint.use_delta:.3f},",
" },",
]
)
lines.extend([" },", " vehicles = {"])
sorted_textures = sorted(
report.textures,
key=lambda texture: (
texture.vehicle_id.casefold(),
texture.skin_index,
texture.texture_reference.casefold(),
texture.workshop_id.casefold(),
texture.mod_id.casefold(),
),
)
current_vehicle: str | None = None
for texture in sorted_textures:
if texture.vehicle_id != current_vehicle:
if current_vehicle is not None:
lines.extend([" },", " },"])
current_vehicle = texture.vehicle_id
lines.extend(
[
f" [{_lua_string(texture.vehicle_id)}] = {{",
f" vehicleId = {_lua_string(texture.vehicle_id)},",
f" workshopId = {_lua_string(texture.workshop_id)},",
f" modId = {_lua_string(texture.mod_id)},",
f" modName = {_lua_string(texture.mod_name)},",
" textures = {",
]
)
lines.extend(
[
" {",
f" skinIndex = {texture.skin_index},",
f" texture = {_lua_string(texture.texture_reference)},",
" paints = {",
]
)
for paint, uses in allocate_paint_uses(texture.paints, bucket_uses):
lines.append(
" { item = "
f"{_lua_string(paint.paint_can.item_id)}, percent = {paint.percentage:.1f}, "
f"uses = {uses:.2f} }},"
)
lines.extend([" },", " },"])
if current_vehicle is not None:
lines.extend([" },", " },"])
lines.extend([" },", "}", ""])
manifest = "\n".join(lines)
if len(manifest.encode("utf-8")) > MAX_MANIFEST_BYTES:
raise ValueError(
f"Lua manifest exceeds the {MAX_MANIFEST_BYTES}-byte safety limit"
)
return manifest
def write_lua_manifest(
output_path: Path,
report: ScanReport,
paint_cans: Sequence[PaintCan],
) -> None:
"""Atomically write a deterministic shared-Lua manifest for the in-game menu."""
output_path = output_path.expanduser()
temporary_path: Path | None = None
try:
manifest = _render_lua_manifest(report, paint_cans)
output_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
newline="\n",
dir=output_path.parent,
prefix=f".{output_path.name}.",
suffix=".tmp",
delete=False,
) as temporary_file:
temporary_path = Path(temporary_file.name)
temporary_file.write(manifest)
temporary_file.flush()
os.fsync(temporary_file.fileno())
os.replace(temporary_path, output_path)
except OSError as error:
raise ValueError(f"Could not write Lua manifest {output_path}: {error}") from error
finally:
try:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
except OSError:
pass
def _safe_console_text(value: object) -> str:
text = str(value)
return "".join(
character
if character in "\t" or 0x20 <= ord(character) <= 0x7E or ord(character) >= 0xA0
else "?"
for character in text
)
def _print_report(report: ScanReport, paint_cans: Sequence[PaintCan], output: TextIO) -> None:
print(f"B42 paint cans: {len(paint_cans)}", file=output)
for paint in paint_cans:
red, green, blue = (round(channel * 255) for channel in paint.rgb)
print(
f" {_safe_console_text(paint.display_name)} "
f"[{_safe_console_text(paint.item_id)}] RGB({red}, {green}, {blue})",
file=output,
)
print(file=output)
previous_mod: tuple[str, str] | None = None
previous_vehicle: str | None = None
for texture in report.textures:
mod_key = (texture.workshop_id, texture.mod_id)
if mod_key != previous_mod:
if previous_mod is not None:
print(file=output)
print(
f"Workshop {_safe_console_text(texture.workshop_id)} | "
f"{_safe_console_text(texture.mod_name)} ({_safe_console_text(texture.mod_id)})",
file=output,
)
previous_mod = mod_key
previous_vehicle = None
if texture.vehicle_id != previous_vehicle:
print(f" Vehicle: {_safe_console_text(texture.vehicle_id)}", file=output)
previous_vehicle = texture.vehicle_id
print(f" Texture: {_safe_console_text(texture.texture_reference)}", file=output)
print(f" File: {_safe_console_text(texture.texture_path)}", file=output)
print(f" Nearest B42 paints: {_format_paints(texture.paints)}", file=output)
for warning in report.warnings:
print(f"WARNING: {_safe_console_text(warning)}", file=output)
print(
f"Scanned {report.mods_scanned} mods, {report.cars_found} cars, "
f"{len(report.textures)} textures; {len(report.warnings)} warnings.",
file=output,
)
def build_argument_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="List DamnLib KI5 car skin textures and their color percentages."
)
parser.add_argument(
"--workshop-root",
type=Path,
default=DEFAULT_WORKSHOP_ROOT,
help=f"Project Zomboid Workshop content directory (default: {DEFAULT_WORKSHOP_ROOT})",
)
parser.add_argument(
"--output",
type=Path,
default=DEFAULT_OUTPUT_PATH,
help=f"Generated shared-Lua data file (default: {DEFAULT_OUTPUT_PATH})",
)
parser.add_argument(
"--quiet",
action="store_true",
help="Only print the output path and final scan counts.",
)
parser.add_argument(
"--game-root",
type=Path,
default=DEFAULT_GAME_ROOT,
help=f"Project Zomboid game directory (default: {DEFAULT_GAME_ROOT})",
)
return parser
def main(arguments: Sequence[str] | None = None, *, output: TextIO = sys.stdout) -> int:
options = build_argument_parser().parse_args(arguments)
try:
paint_cans = discover_paint_cans(options.game_root)
report = scan_workshop(options.workshop_root, paint_cans)
write_lua_manifest(options.output, report, paint_cans)
except ValueError as error:
print(f"ERROR: {_safe_console_text(error)}", file=output)
return 2
if not options.quiet:
_print_report(report, paint_cans, output)
print(
f"Wrote {len(report.textures)} textures for {report.cars_found} cars to "
f"{_safe_console_text(options.output.resolve())}",
file=output,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -0,0 +1 @@
"""Tests for the PaintMyKI5 scanner package."""
+291
View File
@@ -0,0 +1,291 @@
local luaRoot = PROJECT_ROOT .. "/42.20/media/lua"
package.path = table.concat({
luaRoot .. "/shared/?.lua",
luaRoot .. "/shared/?/?.lua",
package.path,
}, ";")
local passed = 0
local function test(name, body)
local ok, errorMessage = pcall(body)
if not ok then error(name .. ": " .. tostring(errorMessage), 0) end
passed = passed + 1
end
local function assertNear(actual, expected)
assert(math.abs(actual - expected) < 0.0001,
"expected " .. tostring(expected) .. ", got " .. tostring(actual))
end
local function javaList(values)
return {
size = function() return #values end,
get = function(_, index) return values[index + 1] end,
}
end
PaintMyKI5 = {
VehiclePaintData = {
bucketUses = 10,
paintCans = {},
vehicles = {
["Base.TestCar"] = {
textures = {
{
skinIndex = 2,
texture = "Vehicles/TestCar_Green",
paints = {
{ item = "Base.PaintGreen", percent = 97.9, uses = 9.79 },
{ item = "Base.PaintWhite", percent = 2.1, uses = 0.21 },
},
},
},
},
},
},
}
package.loaded["PaintMyKI5/PaintMyKI5VehiclePaintData"] = true
require "PaintMyKI5/PaintRequirements"
require "PaintMyKI5/PaintInventory"
local function fakeVehicle()
return {
getScript = function()
return {
getFullName = function() return "Base.TestCar" end,
getAreaById = function() return true end,
}
end,
getSkinCount = function() return 3 end,
getSkinIndex = function() return 0 end,
getCurrentSpeedKmHour = function() return 0 end,
isRemovedFromWorld = function() return false end,
isInArea = function() return true end,
getUseablePart = function() return nil end,
}
end
test("requirements retain fractional proportional uses", function()
local requirements = PaintMyKI5.PaintRequirements.getRequirements(fakeVehicle(), 2)
assert(#requirements == 2)
assertNear(requirements[1].uses, 9.79)
assertNear(requirements[2].uses, 0.21)
assertNear(requirements[1].uses + requirements[2].uses, 10)
end)
test("invalid skin is rejected", function()
assert(PaintMyKI5.PaintRequirements.getRequirements(fakeVehicle(), 1) == nil)
end)
local syncCount = 0
local removedCount = 0
local addedCount = 0
sendItemStats = function() syncCount = syncCount + 1 end
sendRemoveItemFromContainer = function() removedCount = removedCount + 1 end
sendAddItemToContainer = function() addedCount = addedCount + 1 end
instanceItem = function() return {} end
local function fakeItem(itemType, delta, failAdd)
local item = { itemType = itemType, delta = delta, inContainer = true }
local container = {
DoRemoveItem = function(_, target) target.inContainer = false end,
AddItem = function(_, target)
if failAdd then return nil end
target.inContainer = true
return target
end,
}
item.getUseDelta = function() return 0.1 end
item.getCurrentUsesFloat = function(self) return self.delta end
item.setUsedDelta = function(self, value) self.delta = value end
item.getContainer = function() return container end
return item
end
test("inventory aggregates partial cans and consumes exact fractions", function()
local greenA = fakeItem("Base.PaintGreen", 0.5)
local greenB = fakeItem("Base.PaintGreen", 0.5)
local white = fakeItem("Base.PaintWhite", 1.0)
local byType = {
["Base.PaintGreen"] = javaList({ greenA, greenB }),
["Base.PaintWhite"] = javaList({ white }),
}
local character = {
getInventory = function()
return { getAllTypeRecurse = function(_, itemType) return byType[itemType] end }
end,
removeFromHands = function() end,
}
local requirements = {
{ item = "Base.PaintGreen", uses = 9.79 },
{ item = "Base.PaintWhite", uses = 0.21 },
}
assert(PaintMyKI5.PaintInventory.consumeRequirements(character, requirements))
assertNear(greenB.delta, 0.021)
assertNear(white.delta, 0.979)
assert(removedCount == 1 and addedCount == 1)
end)
test("a missing color prevents every mutation", function()
local green = fakeItem("Base.PaintGreen", 1.0)
local byType = {
["Base.PaintGreen"] = javaList({ green }),
["Base.PaintWhite"] = javaList({}),
}
local character = {
getInventory = function()
return { getAllTypeRecurse = function(_, itemType) return byType[itemType] end }
end,
removeFromHands = function() end,
}
assert(not PaintMyKI5.PaintInventory.consumeRequirements(character, {
{ item = "Base.PaintGreen", uses = 5 },
{ item = "Base.PaintWhite", uses = 1 },
}))
assertNear(green.delta, 1.0)
end)
test("replacement failure rolls back earlier paint", function()
local green = fakeItem("Base.PaintGreen", 1.0)
local white = fakeItem("Base.PaintWhite", 0.1, true)
local byType = {
["Base.PaintGreen"] = javaList({ green }),
["Base.PaintWhite"] = javaList({ white }),
}
local character = {
getInventory = function()
return { getAllTypeRecurse = function(_, itemType) return byType[itemType] end }
end,
removeFromHands = function() end,
}
assert(not PaintMyKI5.PaintInventory.consumeRequirements(character, {
{ item = "Base.PaintGreen", uses = 1 },
{ item = "Base.PaintWhite", uses = 1 },
}))
assertNear(green.delta, 1.0)
assert(white.inContainer)
end)
ISBaseTimedAction = {
derive = function(_, name)
local derived = { Type = name }
derived.__index = derived
return setmetatable(derived, { __index = ISBaseTimedAction })
end,
new = function(actionClass, character)
return setmetatable({ character = character }, actionClass)
end,
stop = function() end,
perform = function() end,
}
package.loaded["TimedActions/ISBaseTimedAction"] = true
isClient = function() return false end
isServer = function() return true end
dofile(luaRoot .. "/shared/PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle.lua")
PaintMyKI5.PaintInventory.checkRequirements = function() return true end
local function serverCharacter()
return {
getVehicle = function() return nil end,
isTimedActionInstant = function() return false end,
}
end
local function mutableVehicle()
local vehicle = fakeVehicle()
vehicle.skin = 0
vehicle.transmitted = 0
vehicle.updated = 0
vehicle.getSkinIndex = function(self) return self.skin end
vehicle.setSkinIndex = function(self, skin) self.skin = skin end
vehicle.transmitSkinIndex = function(self) self.transmitted = self.transmitted + 1 end
vehicle.updateSkin = function(self) self.updated = self.updated + 1 end
return vehicle
end
test("server completion consumes then transmits the selected skin", function()
local vehicle = mutableVehicle()
local character = serverCharacter()
local consumed = 0
PaintMyKI5.PaintInventory.consumeRequirements = function()
consumed = consumed + 1
return true
end
local action = ISPaintMyKI5Vehicle:new(character, vehicle, 2, "Engine")
action:serverStart()
assert(action:complete())
assert(consumed == 1 and vehicle.skin == 2 and vehicle.transmitted == 1)
end)
test("server rejects forged and raced skin changes without consuming", function()
local character = serverCharacter()
local consumed = 0
PaintMyKI5.PaintInventory.consumeRequirements = function()
consumed = consumed + 1
return true
end
local forgedVehicle = mutableVehicle()
local forged = ISPaintMyKI5Vehicle:new(character, forgedVehicle, 99, "Engine")
forged:serverStart()
assert(not forged:complete())
local racedVehicle = mutableVehicle()
local raced = ISPaintMyKI5Vehicle:new(character, racedVehicle, 2, "Engine")
raced:serverStart()
racedVehicle.skin = 1
assert(not raced:complete())
assert(consumed == 0)
end)
test("failed skin or inventory commit restores state without losing paint", function()
local character = serverCharacter()
local consumed = 0
PaintMyKI5.PaintInventory.consumeRequirements = function()
consumed = consumed + 1
return false
end
local vehicle = mutableVehicle()
local action = ISPaintMyKI5Vehicle:new(character, vehicle, 2, "Engine")
action:serverStart()
assert(not action:complete())
assert(vehicle.skin == 0 and vehicle.transmitted == 2 and consumed == 1)
local broken = mutableVehicle()
broken.setSkinIndex = function(self, skin)
if skin == 2 then error("skin mutation failed") end
self.skin = skin
end
consumed = 0
local brokenAction = ISPaintMyKI5Vehicle:new(character, broken, 2, "Engine")
brokenAction:serverStart()
assert(not brokenAction:complete())
assert(broken.skin == 0 and consumed == 0)
end)
test("singleplayer completion updates the local skin", function()
isServer = function() return false end
local vehicle = mutableVehicle()
PaintMyKI5.PaintInventory.consumeRequirements = function() return true end
local action = ISPaintMyKI5Vehicle:new(serverCharacter(), vehicle, 2, "Engine")
action:serverStart()
assert(action:complete())
assert(vehicle.skin == 2 and vehicle.updated == 1 and vehicle.transmitted == 0)
isServer = function() return true end
end)
test("client completion never mutates inventory or the vehicle", function()
isClient = function() return true end
local vehicle = mutableVehicle()
local consumed = 0
PaintMyKI5.PaintInventory.consumeRequirements = function()
consumed = consumed + 1
return true
end
local action = ISPaintMyKI5Vehicle:new(serverCharacter(), vehicle, 1, "Engine")
assert(action:complete())
assert(consumed == 0 and vehicle.skin == 0 and vehicle.transmitted == 0)
isClient = function() return false end
end)
print("Lua tests passed: " .. passed)
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from pathlib import Path
from lupa import LuaRuntime
project_root = Path(__file__).resolve().parents[2]
runner = Path(__file__).with_name("lua") / "run.lua"
runtime = LuaRuntime(unpack_returned_tuples=True)
runtime.globals().PROJECT_ROOT = project_root.as_posix()
compile_file = runtime.eval(
"function(path) local chunk, message = loadfile(path); "
"if not chunk then error(message) end end"
)
for lua_file in sorted((project_root / "42.20/media/lua").rglob("*.lua")):
compile_file(lua_file.as_posix())
runtime.execute(runner.read_text(encoding="utf-8"))
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
import json
import re
import tempfile
import unittest
from pathlib import Path
from scanner import scan_ki5_workshop as scanner
PROJECT_ROOT = Path(__file__).resolve().parents[2]
LUA_ROOT = PROJECT_ROOT / "42.20/media/lua"
class PaintUseAllocationTests(unittest.TestCase):
def test_allocates_one_full_bucket_proportionally(self) -> None:
paints = (
scanner.PaintPercentage(scanner.PaintCan("Base.PaintGrey", "Grey", (0.5,) * 3, 0.1), 60.0),
scanner.PaintPercentage(scanner.PaintCan("Base.PaintGreen", "Green", (0.4,) * 3, 0.1), 20.0),
scanner.PaintPercentage(scanner.PaintCan("Base.PaintWhite", "White", (0.9,) * 3, 0.1), 20.0),
)
allocated = scanner.allocate_paint_uses(paints, 10)
self.assertEqual(
[("Base.PaintGrey", 6.0), ("Base.PaintGreen", 2.0), ("Base.PaintWhite", 2.0)],
[(entry.paint_can.item_id, uses) for entry, uses in allocated],
)
self.assertAlmostEqual(10.0, sum(uses for _entry, uses in allocated))
def test_equal_remainders_are_deterministic(self) -> None:
paints = tuple(
scanner.PaintPercentage(
scanner.PaintCan(item_id, item_id, (0.5,) * 3, 0.1),
percentage,
)
for item_id, percentage in (
("Base.PaintRed", 33.3),
("Base.PaintBlue", 33.3),
("Base.PaintGreen", 33.4),
)
)
allocated = scanner.allocate_paint_uses(paints, 10)
self.assertEqual(
[("Base.PaintGreen", 3.34), ("Base.PaintBlue", 3.33), ("Base.PaintRed", 3.33)],
[(entry.paint_can.item_id, uses) for entry, uses in allocated],
)
self.assertAlmostEqual(10.0, sum(uses for _entry, uses in allocated))
def test_bucket_capacity_comes_from_game_use_delta(self) -> None:
paints = (
scanner.PaintCan("Base.PaintGreen", "Green", (0.4,) * 3, 0.1),
scanner.PaintCan("Base.PaintWhite", "White", (0.9,) * 3, 0.1),
)
self.assertEqual(10, scanner.paint_bucket_uses(paints))
class InGameLuaContractTests(unittest.TestCase):
EXPECTED_FILES = (
"shared/PaintMyKI5/PaintRequirements.lua",
"shared/PaintMyKI5/PaintInventory.lua",
"shared/PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle.lua",
"client/PaintMyKI5/ISPaintMyKI5UI.lua",
"client/PaintMyKI5/PaintVehicleContextMenu.lua",
)
def test_runtime_files_exist(self) -> None:
self.assertEqual([], [name for name in self.EXPECTED_FILES if not (LUA_ROOT / name).is_file()])
def test_server_authoritative_timed_action_contract(self) -> None:
source = (LUA_ROOT / self.EXPECTED_FILES[2]).read_text(encoding="utf-8")
self.assertIn("ISBaseTimedAction:derive", source)
self.assertIn("function ISPaintMyKI5Vehicle:complete()", source)
self.assertIn("PaintRequirements.getRequirements", source)
self.assertIn("PaintInventory.consumeRequirements", source)
self.assertIn("vehicle:setSkinIndex", source)
self.assertIn("vehicle:transmitSkinIndex", source)
self.assertNotRegex(source, r"sendClientCommand\([^\n]+setSkinIndex")
def test_context_menu_and_texture_preview_contract(self) -> None:
context_source = (LUA_ROOT / self.EXPECTED_FILES[4]).read_text(encoding="utf-8")
ui_source = (LUA_ROOT / self.EXPECTED_FILES[3]).read_text(encoding="utf-8")
self.assertIn("Events.OnFillWorldObjectContextMenu.Add", context_source)
self.assertIn("getFullName()", context_source)
self.assertIn("getTexture", ui_source)
self.assertIn("or getTexture(textureData.texture)", ui_source)
self.assertIn("drawTextureScaledAspect", ui_source)
self.assertGreaterEqual(ui_source.count("PaintRequirements.isValidTarget"), 2)
self.assertIn("ISTimedActionQueue.add", ui_source)
def test_b42_translation_files_are_valid_json(self) -> None:
context = json.loads(
(LUA_ROOT / "shared/Translate/EN/ContextMenu.json").read_text(encoding="utf-8")
)
ui = json.loads((LUA_ROOT / "shared/Translate/EN/IG_UI.json").read_text(encoding="utf-8"))
self.assertEqual("Paint vehicle", context["ContextMenu_PaintMyKI5"])
self.assertIn("IGUI_PaintMyKI5_Title", ui)
self.assertIn("IGUI_PaintMyKI5_OneBucket", ui)
def test_generated_manifest_contains_exact_use_requirements(self) -> None:
manifest = (
LUA_ROOT / "shared/PaintMyKI5/PaintMyKI5VehiclePaintData.lua"
).read_text(encoding="utf-8")
self.assertIn("schemaVersion = 2", manifest)
self.assertIn("bucketUses = 10", manifest)
self.assertRegex(manifest, r'item = "Base\.Paint\w+", percent = \d+\.\d, uses = \d+\.\d\d')
use_groups = re.findall(r"paints = \{(.*?)\n \},", manifest, re.S)
self.assertTrue(use_groups)
self.assertTrue(
all(
abs(sum(map(float, re.findall(r"uses = (\d+\.\d+)", group))) - 10.0) < 0.001
for group in use_groups
)
)
if __name__ == "__main__":
unittest.main()
+515
View File
@@ -0,0 +1,515 @@
from __future__ import annotations
import io
import json
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from PIL import Image
from scanner.scan_ki5_workshop import (
MAX_METADATA_CHARS,
ModCandidate,
PaintCan,
analyze_colors,
discover_paint_cans,
discover_mods,
main,
parse_vehicle_scripts,
resolve_texture,
scan_workshop,
write_lua_manifest,
)
class ScannerTests(unittest.TestCase):
TEST_PAINTS = (
PaintCan("Base.PaintGreen", "Paint - Green", (0.41, 0.80, 0.41), 0.1),
PaintCan("Base.PaintGrey", "Paint - Gray", (0.50, 0.50, 0.50), 0.1),
PaintCan("Base.PaintWhite", "Paint - White", (0.92, 0.92, 0.92), 0.1),
)
def test_discovers_full_b42_paint_cans_and_rgb_values(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
game_root = Path(temporary_directory)
self._write_game_palette(game_root)
paints = discover_paint_cans(game_root)
self.assertEqual(self.TEST_PAINTS, paints)
def test_paint_discovery_excludes_spray_paint_and_incomplete_buckets(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
game_root = Path(temporary_directory)
self._write_game_palette(game_root, include_decoys=True)
paints = discover_paint_cans(game_root)
self.assertEqual(self.TEST_PAINTS, paints)
def test_paint_discovery_handles_multiple_semicolon_separated_tags(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
game_root = Path(temporary_directory)
self._write_game_palette(game_root)
item_script = game_root / "media/scripts/generated/items/drainable.txt"
contents = item_script.read_text(encoding="utf-8")
item_script.write_text(
contents.replace("Tags = base:paint", "Tags = base:tool;base:paint"),
encoding="utf-8",
)
paints = discover_paint_cans(game_root)
self.assertEqual(self.TEST_PAINTS, paints)
def test_discovers_damnlib_once_and_selects_latest_b42_overlay(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
mod_root = root / "123" / "mods" / "testCar"
self._write(mod_root / "mod.info", "name=Test Car\nid=testCar\nrequire=other, DamnLib\n")
self._write(mod_root / "42.13" / "mod.info", "name=Test Car\nid=testCar\nrequire=\\damnlib\n")
self._write(mod_root / "42.20" / "mod.info", "name=Test Car\nid=testCar\nrequire=damnlib\n")
self._write(mod_root / "42.20" / "media/scripts/vehicles/car.txt", "module Base {}")
mods = discover_mods(root)
self.assertEqual(1, len(mods))
self.assertEqual("123", mods[0].workshop_id)
self.assertEqual("testCar", mods[0].mod_id)
self.assertEqual(mod_root / "42.20", mods[0].content_root)
def test_dependency_token_must_match_exactly(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
self._write(
root / "123/mods/notDamnLib/mod.info",
"name=No Match\nid=noMatch\nrequire=notdamnlib\n",
)
self.assertEqual([], discover_mods(root))
def test_maps_only_skin_textures_to_full_vehicle_id_and_filters_trailers(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
scripts = root / "media/scripts/vehicles"
self._write(
scripts / "cars.txt",
"""
module Base
{
model Interior { texture = Vehicles/Interior, }
vehicle ExampleCar
{
engineForce = 4000,
skin { texture = Vehicles/Example_Shell_green, }
skin { texture = Vehicles/Example_Shell_white, }
skin { texture = Vehicles/Example_Shell_green, }
textureDamage1Shell = Vehicles/Example_damage,
}
vehicle TrailerExample
{
engineForce = 10,
skin { texture = Vehicles/Trailer_Shell, }
attachment trailer { offset = 0 0 0, }
}
}
""",
)
vehicles = parse_vehicle_scripts(scripts)
self.assertEqual(1, len(vehicles))
self.assertEqual("Base.ExampleCar", vehicles[0].vehicle_id)
self.assertEqual(
(
"Vehicles/Example_Shell_green",
"Vehicles/Example_Shell_white",
"Vehicles/Example_Shell_green",
),
vehicles[0].texture_references,
)
self.assertEqual((0, 1, 2), tuple(skin.skin_index for skin in vehicles[0].skins))
def test_ignores_malformed_vehicle_blocks(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
scripts = root / "media/scripts/vehicles"
self._write(
scripts / "broken.txt",
"""
module Base
{
vehicle BrokenCar
engineForce = 4000,
skin { texture = Vehicles/Broken_Shell, }
}
""",
)
self.assertEqual([], parse_vehicle_scripts(scripts))
def test_analyzes_requested_color_mix(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
image_path = Path(temporary_directory) / "mix.png"
image = Image.new("RGB", (10, 1))
image.putdata([(128, 128, 128)] * 6 + [(105, 204, 105)] * 2 + [(235, 235, 235)] * 2)
image.save(image_path)
colors = analyze_colors(image_path, self.TEST_PAINTS)
self.assertEqual(
[
("Base.PaintGrey", 60.0),
("Base.PaintGreen", 20.0),
("Base.PaintWhite", 20.0),
],
[(color.paint_can.item_id, color.percentage) for color in colors],
)
def test_ignores_fully_transparent_pixels(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
image_path = Path(temporary_directory) / "alpha.png"
image = Image.new("RGBA", (2, 1))
image.putdata([(255, 0, 0, 255), (0, 0, 255, 0)])
image.save(image_path)
colors = analyze_colors(image_path, self.TEST_PAINTS)
self.assertEqual(
[("Base.PaintGrey", 100.0)],
[(color.paint_can.item_id, color.percentage) for color in colors],
)
def test_transparent_colors_do_not_influence_visible_palette(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
image_path = Path(temporary_directory) / "visible.png"
image = Image.new("RGBA", (257, 1))
transparent_noise = [(index, 255 - index, index // 2, 0) for index in range(256)]
image.putdata([*transparent_noise, (0, 160, 0, 255)])
image.save(image_path)
colors = analyze_colors(image_path, self.TEST_PAINTS)
self.assertEqual(
[("Base.PaintGreen", 100.0)],
[(color.paint_can.item_id, color.percentage) for color in colors],
)
def test_integration_resolves_common_texture_and_prints_mapping(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
mod_root = root / "999" / "mods" / "exampleCar"
self._write(
mod_root / "42.13/mod.info",
"name=Example Car\nid=exampleCar\nrequire=damnlib\ncategory=vehicle\n",
)
self._write(
mod_root / "42.13/media/scripts/vehicles/example.txt",
"""
module Base
{
vehicle ExampleCar
{
engineForce = 4000,
skin { texture = Vehicles/Example_Shell, }
}
}
""",
)
image_path = mod_root / "common/media/textures/Vehicles/Example_Shell.png"
image_path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (1, 1), (0, 160, 0)).save(image_path)
report = scan_workshop(root, self.TEST_PAINTS)
self.assertEqual(1, len(report.textures))
self.assertEqual("Base.ExampleCar", report.textures[0].vehicle_id)
self.assertEqual(image_path, report.textures[0].texture_path)
self.assertEqual("Base.PaintGreen", report.textures[0].paints[0].paint_can.item_id)
game_root = root / "game"
output_path = root / "generated.lua"
self._write_game_palette(game_root)
output = io.StringIO()
exit_code = main(
[
"--workshop-root",
str(root),
"--game-root",
str(game_root),
"--output",
str(output_path),
],
output=output,
)
console = output.getvalue()
self.assertEqual(0, exit_code)
self.assertIn("Workshop 999 | Example Car (exampleCar)", console)
self.assertIn("Vehicle: Base.ExampleCar", console)
self.assertIn("Paint - Green [Base.PaintGreen] 100.0%", console)
self.assertIn("B42 paint cans: 3", console)
self.assertIn("1 mods, 1 cars, 1 textures", console)
def test_missing_texture_adds_warning_and_keeps_scanning(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
mod_root = root / "999" / "mods" / "exampleCar"
self._write(
mod_root / "42.13/mod.info",
"name=Example Car\nid=exampleCar\nrequire=damnlib\ncategory=vehicle\n",
)
self._write(
mod_root / "42.13/media/scripts/vehicles/example.txt",
"""
module Base
{
vehicle ExampleCar
{
engineForce = 4000,
skin { texture = Vehicles/Missing_Shell, }
}
}
""",
)
report = scan_workshop(root, self.TEST_PAINTS)
self.assertEqual(1, report.cars_found)
self.assertEqual((), report.textures)
self.assertEqual(
("Missing texture for Base.ExampleCar: Vehicles/Missing_Shell",),
report.warnings,
)
def test_cli_rejects_missing_workshop_root(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
game_root = root / "game"
self._write_game_palette(game_root)
missing_root = root / "missing"
output = io.StringIO()
exit_code = main(
[
"--workshop-root",
str(missing_root),
"--game-root",
str(game_root),
],
output=output,
)
self.assertEqual(2, exit_code)
self.assertIn("Workshop root does not exist", output.getvalue())
def test_cli_rejects_missing_game_root(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
output = io.StringIO()
exit_code = main(
["--workshop-root", str(root), "--game-root", str(root / "missing")],
output=output,
)
self.assertEqual(2, exit_code)
self.assertIn("Game root does not exist", output.getvalue())
def test_rejects_drive_qualified_texture_reference(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
mod_root = Path(temporary_directory) / "123/mods/example"
mod_root.mkdir(parents=True)
mod = ModCandidate("123", "example", "Example", mod_root, mod_root)
self.assertIsNone(resolve_texture(mod, "C:outside.png"))
def test_nearest_paint_matching_uses_supplied_palette(self) -> None:
paints = (
PaintCan("Custom.Dark", "Dark", (0.1, 0.1, 0.1), 0.1),
PaintCan("Custom.Bright", "Bright", (0.9, 0.9, 0.9), 0.1),
)
with tempfile.TemporaryDirectory() as temporary_directory:
image_path = Path(temporary_directory) / "near.png"
image = Image.new("RGB", (2, 1))
image.putdata([(30, 30, 30), (225, 225, 225)])
image.save(image_path)
percentages = analyze_colors(image_path, paints)
self.assertEqual(
[("Custom.Bright", 50.0), ("Custom.Dark", 50.0)],
[(entry.paint_can.item_id, entry.percentage) for entry in percentages],
)
def test_partially_transparent_pixels_are_alpha_weighted(self) -> None:
paints = (
PaintCan("Custom.Dark", "Dark", (0.1, 0.1, 0.1), 0.1),
PaintCan("Custom.Bright", "Bright", (0.9, 0.9, 0.9), 0.1),
)
with tempfile.TemporaryDirectory() as temporary_directory:
image_path = Path(temporary_directory) / "alpha-weighted.png"
image = Image.new("RGBA", (2, 1))
image.putdata([(25, 25, 25, 255), (230, 230, 230, 64)])
image.save(image_path)
percentages = analyze_colors(image_path, paints)
self.assertEqual(
[("Custom.Dark", 79.9), ("Custom.Bright", 20.1)],
[(entry.paint_can.item_id, entry.percentage) for entry in percentages],
)
def test_empty_paint_palette_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
image_path = Path(temporary_directory) / "pixel.png"
Image.new("RGB", (1, 1), (0, 0, 0)).save(image_path)
with self.assertRaisesRegex(ValueError, "paint palette is empty"):
analyze_colors(image_path, ())
def test_writes_deterministic_lua_manifest_for_ingame_menu(self) -> None:
from scanner.scan_ki5_workshop import PaintPercentage, ScanReport, TextureResult
texture = TextureResult(
workshop_id="123",
mod_id="example\"mod",
mod_name="Example\nCar\x01",
vehicle_id="Base.ExampleCar",
skin_index=0,
texture_reference="Vehicles/Example_Shell",
texture_path=Path("ignored/absolute/path.png"),
paints=(PaintPercentage(self.TEST_PAINTS[0], 100.0),),
)
report = ScanReport(1, 1, (texture,), ())
with tempfile.TemporaryDirectory() as temporary_directory:
output_path = Path(temporary_directory) / "generated.lua"
write_lua_manifest(output_path, report, self.TEST_PAINTS)
first_contents = output_path.read_text(encoding="utf-8")
write_lua_manifest(output_path, report, self.TEST_PAINTS)
self.assertEqual(first_contents, output_path.read_text(encoding="utf-8"))
self.assertIn("PaintMyKI5.VehiclePaintData = {", first_contents)
self.assertIn('vehicleId = "Base.ExampleCar"', first_contents)
self.assertIn('texture = "Vehicles/Example_Shell"', first_contents)
self.assertIn("skinIndex = 0", first_contents)
self.assertIn('item = "Base.PaintGreen"', first_contents)
self.assertIn("percent = 100.0", first_contents)
self.assertIn('modId = "example\\\"mod"', first_contents)
self.assertIn('modName = "Example\\nCar\\001"', first_contents)
self.assertNotIn("ignored/absolute/path.png", first_contents)
oversized = replace(texture, mod_name="x" * (MAX_METADATA_CHARS + 1))
output_path.write_text("preserve me", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "field exceeds"):
write_lua_manifest(
output_path,
ScanReport(1, 1, (oversized,), ()),
self.TEST_PAINTS,
)
self.assertEqual("preserve me", output_path.read_text(encoding="utf-8"))
self.assertEqual([], list(output_path.parent.glob(".*.tmp")))
def test_cli_writes_requested_lua_manifest(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
game_root = root / "game"
workshop_root = root / "workshop"
output_path = root / "out/generated.lua"
self._write_game_palette(game_root)
mod_root = workshop_root / "999/mods/exampleCar"
self._write(
mod_root / "42.13/mod.info",
"name=Example Car\nid=exampleCar\nrequire=damnlib\n",
)
self._write(
mod_root / "42.13/media/scripts/vehicles/example.txt",
"""
module Base
{
vehicle ExampleCar
{
engineForce = 1,
skin { texture = Vehicles/Example_Shell, }
}
}
""",
)
image_path = mod_root / "common/media/textures/Vehicles/Example_Shell.png"
image_path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (1, 1), (105, 204, 105)).save(image_path)
output = io.StringIO()
exit_code = main(
[
"--game-root", str(game_root),
"--workshop-root", str(workshop_root),
"--output", str(output_path),
"--quiet",
],
output=output,
)
self.assertEqual(0, exit_code)
self.assertTrue(output_path.is_file())
self.assertIn("Wrote 1 textures for 1 cars", output.getvalue())
@staticmethod
def _write(path: Path, contents: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(contents, encoding="utf-8")
@classmethod
def _write_game_palette(cls, game_root: Path, *, include_decoys: bool = False) -> None:
item_blocks = []
lua_rows = []
names = {}
for paint in cls.TEST_PAINTS:
short_id = paint.item_id.split(".", 1)[1]
item_blocks.append(
f"""
item {short_id}
{{
ItemType = base:drainable,
PourType = Bucket,
ReplaceOnDeplete = Base.PaintbucketEmpty,
UseDelta = {paint.use_delta},
Tags = base:paint,
}}
"""
)
red, green, blue = paint.rgb
lua_rows.append(
f'{{ paint = "{short_id}", text = "unused", color = {{ {red},{green},{blue} }} }},'
)
names[paint.item_id] = paint.display_name
if include_decoys:
item_blocks.extend(
[
"item SprayPaint { ItemType=base:drainable, DisplayCategory=Paint, }",
"item PaintFake { ItemType=base:drainable, PourType=Bucket, Tags=base:paint, }",
]
)
lua_rows.extend(
[
'{ paint = "SprayPaint", text = "unused", color = { 1,0,0 } },',
'{ paint = "PaintFake", text = "unused", color = { 0,0,1 } },',
]
)
cls._write(
game_root / "media/scripts/generated/items/drainable.txt",
"module Base {\n" + "\n".join(item_blocks) + "\n}",
)
cls._write(
game_root / "media/lua/shared/BuildingObjects/ISPaintMenu.lua",
"ISPaintMenu.PaintMenuItems = {\n" + "\n".join(lua_rows) + "\n}",
)
item_names = game_root / "media/lua/shared/Translate/EN/ItemName.json"
item_names.parent.mkdir(parents=True, exist_ok=True)
item_names.write_text(json.dumps(names), encoding="utf-8")
if __name__ == "__main__":
unittest.main()