Feature Complete Shipping V1.0

This commit is contained in:
2026-08-12 22:57:52 -04:00
parent 19e4ac0cce
commit 56b6a46811
30 changed files with 9934 additions and 9361 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

@@ -4,6 +4,7 @@ require "ISUI/ISButton"
require "Vehicles/TimedActions/ISPathFindAction"
require "PaintMyKI5/PaintRequirements"
require "PaintMyKI5/PaintInventory"
require "PaintMyKI5/TextureName"
require "PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle"
ISPaintMyKI5UI = ISCollapsableWindow:derive("ISPaintMyKI5UI")
@@ -14,9 +15,9 @@ 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
local function textureLabel(textureData, displayName)
return getText("IGUI_PaintMyKI5_Skin", textureData.skinIndex + 1)
.. " - " .. displayName
end
local function getPaletteColor(itemType)
@@ -28,6 +29,24 @@ local function getPaletteColor(itemType)
return 0.5, 0.5, 0.5
end
local function buildMissingTooltip(missingPaint, missingTools)
local lines = { getText("IGUI_PaintMyKI5_MissingRequirements") }
for _, missing in ipairs(missingTools or {}) do
table.insert(lines, getText(
"IGUI_PaintMyKI5_MissingTool",
getItemNameFromFullType(missing.item)
))
end
for _, missing in ipairs(missingPaint or {}) do
local available = math.floor(missing.available * 100 + 0.5) / 100
table.insert(lines, getText(
"IGUI_PaintMyKI5_MissingPaint",
getItemNameFromFullType(missing.item), missing.required, available
))
end
return table.concat(lines, "\n")
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)
@@ -49,8 +68,10 @@ function ISPaintMyKI5UI:createChildren()
self.skinList.drawBorder = true
self:addChild(self.skinList)
for _, textureData in ipairs(self.vehicleData.textures or {}) do
self.skinList:addItem(textureLabel(textureData), textureData)
local textures = self.vehicleData.textures or {}
local textureNames = PaintMyKI5.TextureName.buildLabels(textures)
for index, textureData in ipairs(textures) do
self.skinList:addItem(textureLabel(textureData, textureNames[index]), textureData)
end
local currentIndex = self.vehicle:getSkinIndex()
for index, row in ipairs(self.skinList.items) do
@@ -84,6 +105,8 @@ function ISPaintMyKI5UI:prerender()
ISCollapsableWindow.prerender(self)
local textureData = self:getSelectedTexture()
local enabled = false
local missingPaint = {}
local missingTools = {}
if textureData then
local area = PaintMyKI5.PaintRequirements.findInteractionArea(
self.vehicle, self.character
@@ -91,13 +114,26 @@ function ISPaintMyKI5UI:prerender()
local requirements = PaintMyKI5.PaintRequirements.getRequirements(
self.vehicle, textureData.skinIndex
)
enabled = requirements ~= nil
and PaintMyKI5.PaintRequirements.isValidTarget(
self.character, self.vehicle, textureData.skinIndex, area
local ready = false
if requirements then
ready, missingPaint, missingTools =
PaintMyKI5.PaintInventory.checkPaintingRequirements(
self.character, requirements
)
end
enabled = ready and area ~= nil
and PaintMyKI5.PaintRequirements.isValidSelection(
self.character, self.vehicle, textureData.skinIndex
)
and PaintMyKI5.PaintInventory.checkRequirements(self.character, requirements)
end
self.paintButton:setEnable(enabled)
if enabled then
self.paintButton:setTooltip(nil)
elseif #missingPaint > 0 or #missingTools > 0 then
self.paintButton:setTooltip(buildMissingTooltip(missingPaint, missingTools))
else
self.paintButton:setTooltip(getText("IGUI_PaintMyKI5_Unavailable"))
end
end
function ISPaintMyKI5UI:render()
@@ -162,12 +198,14 @@ function ISPaintMyKI5UI:onPaint()
self.vehicle, textureData.skinIndex
)
if not requirements then return end
local enough = PaintMyKI5.PaintInventory.checkRequirements(self.character, requirements)
local enough = PaintMyKI5.PaintInventory.checkPaintingRequirements(
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
if not PaintMyKI5.PaintRequirements.isValidSelection(
self.character, self.vehicle, textureData.skinIndex
) then return end
ISTimedActionQueue.add(ISPathFindAction:pathToVehicleArea(self.character, self.vehicle, area))
@@ -38,7 +38,7 @@ function PaintVehicleContextMenu.onFillWorldObjectContextMenu(player, context, w
PaintVehicleContextMenu.open,
vehicle
)
option.iconTexture = getTexture("media/textures/PaintBrush.png")
option.iconTexture = getTexture("media/textures/PaintMyKI5_ContextMenu.png")
return true
end
@@ -0,0 +1,75 @@
PaintMyKI5 = PaintMyKI5 or {}
PaintMyKI5.SandpaperDistribution = PaintMyKI5.SandpaperDistribution or {}
local SandpaperDistribution = PaintMyKI5.SandpaperDistribution
local SANDPAPER_ITEM = "PaintMyKI5.Sandpaper"
local SANDPAPER_WEIGHT = 0.1
local FULL_PAINT_CANS = {
PaintBlack = true,
PaintBlue = true,
PaintBrown = true,
PaintCyan = true,
PaintGreen = true,
PaintGrey = true,
PaintLightBlue = true,
PaintLightBrown = true,
PaintOrange = true,
PaintPink = true,
PaintPurple = true,
PaintRed = true,
PaintTurquoise = true,
PaintWhite = true,
PaintYellow = true,
}
local function isFullPaintCan(itemType)
if type(itemType) ~= "string" then return false end
if FULL_PAINT_CANS[itemType] then return true end
local moduleName, shortType = string.match(itemType, "^([^.]+)%.([^.]+)$")
return moduleName == "Base" and FULL_PAINT_CANS[shortType] == true
end
local function inspectItems(items)
local hasPaint = false
local hasSandpaper = false
if type(items) ~= "table" then return hasPaint, hasSandpaper end
for index = 1, #items, 2 do
local itemType = items[index]
if itemType == SANDPAPER_ITEM then
hasSandpaper = true
elseif isFullPaintCan(itemType) then
hasPaint = true
end
end
return hasPaint, hasSandpaper
end
local function injectIntoItems(items)
local hasPaint, hasSandpaper = inspectItems(items)
if not hasPaint or hasSandpaper then return 0 end
table.insert(items, SANDPAPER_ITEM)
table.insert(items, SANDPAPER_WEIGHT)
return 1
end
function SandpaperDistribution.inject(distributions, clutterTables)
local list = distributions and distributions.list
local added = 0
if type(list) == "table" then
for _, distribution in pairs(list) do
if type(distribution) == "table" then
added = added + injectIntoItems(distribution.items)
if type(distribution.junk) == "table" then
added = added + injectIntoItems(distribution.junk.items)
end
end
end
end
if type(clutterTables) == "table" then
added = added + injectIntoItems(clutterTables.ClosetItems)
end
return added
end
SandpaperDistribution.inject(ProceduralDistributions, ClutterTables)
@@ -4,6 +4,16 @@ PaintMyKI5.PaintInventory = PaintMyKI5.PaintInventory or {}
local PaintInventory = PaintMyKI5.PaintInventory
local EPSILON = 0.0001
local EMPTY_BUCKET = "Base.PaintbucketEmpty"
local TOOL_REQUIREMENTS = {
{
item = "PaintMyKI5.Sandpaper",
alternatives = { "PaintMyKI5.Sandpaper" },
},
{
item = "Base.Paintbrush",
alternatives = { "Base.Paintbrush", "Base.PaintbrushCrafted" },
},
}
local function getItems(character, itemType)
if not character or not character:getInventory() then return nil end
@@ -41,6 +51,27 @@ function PaintInventory.checkRequirements(character, requirements)
return #missing == 0, missing
end
local function hasAnyItem(character, itemTypes)
for _, itemType in ipairs(itemTypes) do
local items = getItems(character, itemType)
if items and items:size() > 0 then return true end
end
return false
end
function PaintInventory.checkPaintingRequirements(character, requirements)
local hasPaint, missingPaint = PaintInventory.checkRequirements(
character, requirements
)
local missingTools = {}
for _, tool in ipairs(TOOL_REQUIREMENTS) do
if not hasAnyItem(character, tool.alternatives) then
table.insert(missingTools, { item = tool.item })
end
end
return hasPaint and #missingTools == 0, missingPaint, missingTools
end
local function buildPlan(character, requirements)
local enough = PaintInventory.checkRequirements(character, requirements)
if not enough then return nil end
File diff suppressed because it is too large Load Diff
@@ -79,7 +79,7 @@ function PaintRequirements.findInteractionArea(vehicle, character)
return nil
end
function PaintRequirements.isValidTarget(character, vehicle, skinIndex, area)
function PaintRequirements.isValidSelection(character, vehicle, skinIndex)
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
@@ -87,6 +87,13 @@ function PaintRequirements.isValidTarget(character, vehicle, skinIndex, area)
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
return true
end
function PaintRequirements.isValidTarget(character, vehicle, skinIndex, area)
if not PaintRequirements.isValidSelection(character, 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,65 @@
PaintMyKI5 = PaintMyKI5 or {}
PaintMyKI5.TextureName = PaintMyKI5.TextureName or {}
local TextureName = PaintMyKI5.TextureName
local function filename(textureData)
local reference = textureData and textureData.texture or ""
if type(reference) ~= "string" then return "" end
local name = string.match(reference, "([^/\\]+)$") or reference
if string.lower(string.sub(name, -4)) == ".png" then
name = string.sub(name, 1, -5)
end
return name
end
local function commonPrefix(names)
local prefix = names[1] or ""
for index = 2, #names do
local other = names[index]
local length = math.min(#prefix, #other)
local matching = 0
for character = 1, length do
if string.lower(string.sub(prefix, character, character))
~= string.lower(string.sub(other, character, character)) then
break
end
matching = character
end
prefix = string.sub(prefix, 1, matching)
if prefix == "" then break end
end
return prefix
end
local function shortenedLabels(names)
if #names < 2 then return nil end
local prefix = commonPrefix(names)
local separator = string.match(prefix, "^.*()[_%- ]")
if not separator then return nil end
prefix = string.sub(prefix, 1, separator)
local labels = {}
local distinct = {}
local distinctCount = 0
for index, name in ipairs(names) do
local label = string.sub(name, #prefix + 1)
if label == "" or #label > 32 then return nil end
label = string.gsub(label, "[_%-]+", " ")
if not distinct[string.lower(label)] then
distinct[string.lower(label)] = true
distinctCount = distinctCount + 1
end
labels[index] = label
end
return distinctCount > 1 and labels or nil
end
function TextureName.buildLabels(textures)
local names = {}
for index, textureData in ipairs(textures or {}) do
names[index] = filename(textureData)
end
return shortenedLabels(names) or names
end
@@ -17,7 +17,10 @@ function ISPaintMyKI5Vehicle:isValid()
self.vehicle, self.skinIndex
)
if not requirements then return false end
return PaintMyKI5.PaintInventory.checkRequirements(self.character, requirements)
local ready = PaintMyKI5.PaintInventory.checkPaintingRequirements(
self.character, requirements
)
return ready
end
function ISPaintMyKI5Vehicle:waitToStart()
@@ -78,7 +81,7 @@ function ISPaintMyKI5Vehicle:complete()
self.vehicle, self.skinIndex
)
if not requirements then return false end
local enough = PaintMyKI5.PaintInventory.checkRequirements(
local enough = PaintMyKI5.PaintInventory.checkPaintingRequirements(
self.character, requirements
)
if not enough then return false end
@@ -5,5 +5,9 @@
"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"
"IGUI_PaintMyKI5_PreviewMissing": "Texture preview unavailable",
"IGUI_PaintMyKI5_MissingRequirements": "Missing requirements:",
"IGUI_PaintMyKI5_MissingTool": "Missing %1",
"IGUI_PaintMyKI5_MissingPaint": "Missing %1 (%2 uses needed, %3 available)",
"IGUI_PaintMyKI5_Unavailable": "The vehicle must be parked and a different skin selected."
}
@@ -0,0 +1,3 @@
{
"PaintMyKI5.Sandpaper": "Sandpaper"
}
+10
View File
@@ -0,0 +1,10 @@
module PaintMyKI5
{
item Sandpaper
{
DisplayCategory = Tool,
ItemType = base:normal,
Weight = 0.1,
Icon = Sandpaper,
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+2 -2
View File
@@ -1,11 +1,11 @@
name=Paint my KI5
id=hrsys_paint_my_ki5
poster=../common/media/textures/preview.png
poster=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
icon=icon.png
url=https://hudsonriggs.systems
modversion=1.1.0
versionMin=42.20
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

+8 -2
View File
@@ -8,6 +8,9 @@ in singleplayer, hosted multiplayer, and dedicated servers.
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).
When sibling texture filenames share a prefix, the skin list shows only the
distinct suffix (`Green`, `Seafoam`, and so on); other entries keep their full
filename as a fallback.
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
@@ -42,8 +45,11 @@ The scanner:
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.
spray paint are excluded. The scanner compares sibling skins with matching UV
layouts to isolate body-sized areas that actually change between paints, which
removes fixed glass, trim, grilles, and interiors from the mix. Within those
areas it preserves hue through dark shading; single skins and small livery-only
differences retain the conservative perceptual Lab fallback.
The development tool and its tests live under [`scanner/`](scanner/). Install
Python 3.11 or newer, then run it from the repository root:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+2 -2
View File
@@ -1,11 +1,11 @@
name=Paint my KI5
id=hrsys_paint_my_ki5
poster=/common/media/textures/preview.png
poster=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
icon=icon.png
url=https://hudsonriggs.systems
modversion=1.1.0
versionMin=42.20
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

+209 -21
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import argparse
import colorsys
import json
import math
import os
@@ -17,7 +18,7 @@ from functools import lru_cache
from pathlib import Path
from typing import Iterable, Sequence, TextIO
from PIL import Image, UnidentifiedImageError
from PIL import Image, ImageChops, UnidentifiedImageError
DEFAULT_WORKSHOP_ROOT = Path(r"D:\SteamLibrary\steamapps\workshop\content\108600")
@@ -55,6 +56,14 @@ MAX_SKINS_PER_VEHICLE = 2_000
MAX_WARNINGS = 20_000
MAX_METADATA_CHARS = 2_048
MAX_MANIFEST_BYTES = 64 * 1024 * 1024
MAX_SCAN_DECODED_PIXELS = 3_000_000_000
MAX_VARIANT_MASK_PIXELS = 268_435_456
MAX_VARIANT_MASK_SKINS = 64
CHROMATIC_SATURATION_MIN = 0.10
CHROMATIC_RGB_RANGE_MIN = 8 / 255
MAX_CHROMATIC_HUE_DISTANCE = 0.16
VARIANT_RGB_RANGE_MIN = 16
MIN_VARIANT_MASK_COVERAGE = 0.20
class ScanFileError(ValueError):
@@ -607,11 +616,46 @@ def _nearest_paint(
red: int,
green: int,
blue: int,
palette_labs: tuple[tuple[PaintCan, tuple[float, float, float]], ...],
palette_colors: tuple[
tuple[PaintCan, tuple[float, float, float], tuple[float, float, float]], ...
],
preserve_chromatic_hue: bool,
) -> PaintCan:
pixel_lab = _srgb_to_lab((red / 255, green / 255, blue / 255))
pixel_rgb = red / 255, green / 255, blue / 255
pixel_hsv = colorsys.rgb_to_hsv(*pixel_rgb)
if (
preserve_chromatic_hue
and pixel_hsv[1] >= CHROMATIC_SATURATION_MIN
and max(pixel_rgb) - min(pixel_rgb) >= CHROMATIC_RGB_RANGE_MIN
):
chromatic = [
entry for entry in palette_colors if entry[2][1] >= CHROMATIC_SATURATION_MIN
]
if chromatic:
def chromatic_distance(
entry: tuple[
PaintCan,
tuple[float, float, float],
tuple[float, float, float],
],
) -> tuple[float, str]:
hue_distance = abs(pixel_hsv[0] - entry[2][0])
hue_distance = min(hue_distance, 1 - hue_distance)
return (
(hue_distance * 3) ** 2
+ (pixel_hsv[1] - entry[2][1]) ** 2
+ 0.02 * (pixel_hsv[2] - entry[2][2]) ** 2,
entry[0].item_id,
)
nearest_chromatic = min(chromatic, key=chromatic_distance)
hue_distance = abs(pixel_hsv[0] - nearest_chromatic[2][0])
if min(hue_distance, 1 - hue_distance) <= MAX_CHROMATIC_HUE_DISTANCE:
return nearest_chromatic[0]
pixel_lab = _srgb_to_lab(pixel_rgb)
return min(
palette_labs,
palette_colors,
key=lambda entry: (
sum((pixel_lab[index] - entry[1][index]) ** 2 for index in range(3)),
entry[0].item_id,
@@ -619,26 +663,116 @@ def _nearest_paint(
)[0]
def _read_rgba_png(texture_path: Path) -> Image.Image:
try:
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}"
)
return image.convert("RGBA")
except ValueError as error:
raise ScanFileError(f"Could not decode PNG {texture_path}: {error}") from error
def _png_pixel_count(texture_path: Path) -> int:
try:
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
with Image.open(texture_path, formats=("PNG",)) as image:
if image.format != "PNG":
raise UnidentifiedImageError(f"Not a PNG image: {texture_path}")
width, height = image.size
except ValueError as error:
raise ScanFileError(f"Could not decode PNG {texture_path}: {error}") from error
pixels = width * height
if width <= 0 or height <= 0 or pixels > MAX_IMAGE_PIXELS:
raise ScanFileError(
f"Image exceeds the {MAX_IMAGE_PIXELS}-pixel safety limit: {texture_path}"
)
return pixels
def _add_decoded_pixels(current: int, additional: int) -> int:
total = current + additional
if additional < 0 or total > MAX_SCAN_DECODED_PIXELS:
raise ScanFileError(
f"Scan exceeds the {MAX_SCAN_DECODED_PIXELS}-decoded-pixel safety limit"
)
return total
def build_variant_mask(texture_paths: Sequence[Path]) -> bytes | None:
"""Select shell pixels whose RGB changes between sibling skin textures."""
unique_paths = tuple(dict.fromkeys(texture_paths))
if len(unique_paths) < 2 or len(unique_paths) > MAX_VARIANT_MASK_SKINS:
return None
pixel_counts = tuple(_png_pixel_count(path) for path in unique_paths)
if sum(pixel_counts) > MAX_VARIANT_MASK_PIXELS:
return None
first = _read_rgba_png(unique_paths[0])
size = first.size
darkest = first.convert("RGB")
lightest = darkest.copy()
shared_opacity = first.getchannel("A")
first.close()
for path in unique_paths[1:]:
image = _read_rgba_png(path)
if image.size != size:
image.close()
return None
rgb = image.convert("RGB")
darkest = ImageChops.darker(darkest, rgb)
lightest = ImageChops.lighter(lightest, rgb)
shared_opacity = ImageChops.darker(shared_opacity, image.getchannel("A"))
image.close()
shared_alpha = shared_opacity.tobytes()
if not any(shared_alpha):
return None
difference = ImageChops.difference(lightest, darkest)
channels = difference.split()
maximum_difference = ImageChops.lighter(ImageChops.lighter(channels[0], channels[1]), channels[2])
mask = maximum_difference.point(
lambda value: 255 if value >= VARIANT_RGB_RANGE_MIN else 0
)
visible_in_every_skin = shared_opacity.point(lambda value: 255 if value else 0)
mask = ImageChops.multiply(mask, visible_in_every_skin)
mask_bytes = mask.tobytes()
visible_pixels = visible_in_every_skin.tobytes().count(255)
masked_pixels = mask_bytes.count(255)
if not visible_pixels or masked_pixels / visible_pixels < MIN_VARIANT_MASK_COVERAGE:
return None
return mask_bytes
def analyze_colors(
texture_path: Path,
paint_cans: Sequence[PaintCan],
*,
pixel_mask: bytes | None = None,
preserve_chromatic_hue: bool = False,
) -> 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")
palette_colors = tuple(
(paint, _srgb_to_lab(paint.rgb), colorsys.rgb_to_hsv(*paint.rgb))
for paint in paint_cans
)
rgba = _read_rgba_png(texture_path)
with rgba:
width, height = rgba.size
if pixel_mask is not None:
if len(pixel_mask) != width * height:
raise ValueError("Variant mask dimensions do not match the texture")
mask = Image.frombytes("L", (width, height), pixel_mask)
rgba.putalpha(ImageChops.multiply(rgba.getchannel("A"), mask))
counts: Counter[PaintCan] = Counter()
rgba_counts = rgba.getcolors(maxcolors=MAX_UNIQUE_COLORS)
if rgba_counts is None:
@@ -648,7 +782,13 @@ def analyze_colors(
for pixel_count, (red, green, blue, opacity) in rgba_counts:
if not opacity:
continue
paint = _nearest_paint(red, green, blue, palette_labs)
paint = _nearest_paint(
red,
green,
blue,
palette_colors,
preserve_chromatic_hue,
)
counts[paint] += pixel_count * opacity
total = sum(counts.values())
if total == 0:
@@ -675,11 +815,13 @@ def scan_workshop(workshop_root: Path, paint_cans: Sequence[PaintCan]) -> ScanRe
warnings: list[str] = []
cars_found = 0
paint_cache: dict[Path, tuple[PaintPercentage, ...]] = {}
decoded_pixels = 0
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:
resolved_skins: list[tuple[SkinDefinition, Path, int]] = []
for skin in vehicle.skins:
reference = skin.texture_reference
texture_path = resolve_texture(mod, reference)
@@ -689,10 +831,56 @@ def scan_workshop(workshop_root: Path, paint_cans: Sequence[PaintCan]) -> ScanRe
warnings.append(f"Missing texture for {vehicle.vehicle_id}: {reference}")
continue
try:
paints = paint_cache.get(texture_path)
pixel_count = _png_pixel_count(texture_path)
decoded_pixels = _add_decoded_pixels(decoded_pixels, pixel_count)
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
resolved_skins.append((skin, texture_path, pixel_count))
variant_mask = None
try:
mask_pixels = sum(pixel_count for _skin, _path, pixel_count in resolved_skins)
if (
len(resolved_skins) <= MAX_VARIANT_MASK_SKINS
and mask_pixels <= MAX_VARIANT_MASK_PIXELS
):
decoded_pixels = _add_decoded_pixels(decoded_pixels, mask_pixels)
variant_mask = build_variant_mask(
tuple(texture_path for _skin, texture_path, _pixels in resolved_skins)
)
except (
OSError,
ScanFileError,
UnidentifiedImageError,
Image.DecompressionBombError,
Image.DecompressionBombWarning,
):
pass
vehicle_paint_cache: dict[Path, tuple[PaintPercentage, ...]] = {}
for skin, texture_path, _pixel_count in resolved_skins:
reference = skin.texture_reference
try:
cache = vehicle_paint_cache if variant_mask is not None else paint_cache
paints = cache.get(texture_path)
if paints is None:
paints = tuple(analyze_colors(texture_path, paint_cans))
paint_cache[texture_path] = paints
paints = tuple(
analyze_colors(
texture_path,
paint_cans,
pixel_mask=variant_mask,
preserve_chromatic_hue=variant_mask is not None,
)
)
cache[texture_path] = paints
except (
OSError,
ScanFileError,
+161
View File
@@ -48,6 +48,39 @@ package.loaded["PaintMyKI5/PaintMyKI5VehiclePaintData"] = true
require "PaintMyKI5/PaintRequirements"
require "PaintMyKI5/PaintInventory"
require "PaintMyKI5/TextureName"
test("texture labels use the unique suffix of a shared filename prefix", function()
local labels = PaintMyKI5.TextureName.buildLabels({
{ texture = "Vehicles/Vehicles_93mustangGT_Shell_Green" },
{ texture = "Vehicles/Vehicles_93mustangGT_Shell_Seafoam" },
{ texture = "Vehicles/Vehicles_93mustangGT_Shell_SSP" },
})
assert(labels[1] == "Green")
assert(labels[2] == "Seafoam")
assert(labels[3] == "SSP")
end)
test("texture labels keep filenames when there is no useful shared prefix", function()
local labels = PaintMyKI5.TextureName.buildLabels({
{ texture = "Vehicles/Ford_Green.png" },
{ texture = "Vehicles/Chevrolet_Red.png" },
})
assert(labels[1] == "Ford_Green")
assert(labels[2] == "Chevrolet_Red")
local single = PaintMyKI5.TextureName.buildLabels({
{ texture = "Vehicles/Only_Skin" },
})
assert(single[1] == "Only_Skin")
local duplicates = PaintMyKI5.TextureName.buildLabels({
{ texture = "Vehicles/Repeated_Green" },
{ texture = "Vehicles/Repeated_Green" },
})
assert(duplicates[1] == "Repeated_Green")
assert(duplicates[2] == "Repeated_Green")
end)
local function fakeVehicle()
return {
@@ -78,6 +111,17 @@ test("invalid skin is rejected", function()
assert(PaintMyKI5.PaintRequirements.getRequirements(fakeVehicle(), 1) == nil)
end)
test("a paint selection is valid before the player reaches the vehicle area", function()
local vehicle = fakeVehicle()
vehicle.isInArea = function() return false end
local character = { getVehicle = function() return nil end }
assert(PaintMyKI5.PaintRequirements.isValidSelection(character, vehicle, 2))
assert(not PaintMyKI5.PaintRequirements.isValidTarget(
character, vehicle, 2, "Engine"
))
end)
local syncCount = 0
local removedCount = 0
local addedCount = 0
@@ -146,6 +190,35 @@ test("a missing color prevents every mutation", function()
assertNear(green.delta, 1.0)
end)
test("painting readiness requires reusable sandpaper and a paintbrush", function()
local green = fakeItem("Base.PaintGreen", 1.0)
local sandpaper = { marker = "sandpaper" }
local craftedBrush = { marker = "crafted brush" }
local byType = {
["Base.PaintGreen"] = javaList({ green }),
}
local inventory = {
getAllTypeRecurse = function(_, itemType)
return byType[itemType] or javaList({})
end,
}
local character = { getInventory = function() return inventory end }
local requirements = { { item = "Base.PaintGreen", uses = 1 } }
local ready, missingPaint, missingTools =
PaintMyKI5.PaintInventory.checkPaintingRequirements(character, requirements)
assert(not ready and #missingPaint == 0 and #missingTools == 2)
assert(missingTools[1].item == "PaintMyKI5.Sandpaper")
assert(missingTools[2].item == "Base.Paintbrush")
byType["PaintMyKI5.Sandpaper"] = javaList({ sandpaper })
byType["Base.PaintbrushCrafted"] = javaList({ craftedBrush })
ready, missingPaint, missingTools =
PaintMyKI5.PaintInventory.checkPaintingRequirements(character, requirements)
assert(ready and #missingPaint == 0 and #missingTools == 0)
assert(sandpaper.marker == "sandpaper" and craftedBrush.marker == "crafted brush")
end)
test("replacement failure rolls back earlier paint", function()
local green = fakeItem("Base.PaintGreen", 1.0)
local white = fakeItem("Base.PaintWhite", 0.1, true)
@@ -184,6 +257,7 @@ isClient = function() return false end
isServer = function() return true end
dofile(luaRoot .. "/shared/PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle.lua")
PaintMyKI5.PaintInventory.checkRequirements = function() return true end
PaintMyKI5.PaintInventory.checkPaintingRequirements = function() return true end
local function serverCharacter()
return {
@@ -204,6 +278,23 @@ local function mutableVehicle()
return vehicle
end
test("server completion rejects missing tools before changing skin or paint", function()
local vehicle = mutableVehicle()
local consumed = 0
PaintMyKI5.PaintInventory.checkPaintingRequirements = function()
return false, {}, { { item = "PaintMyKI5.Sandpaper" } }
end
PaintMyKI5.PaintInventory.consumeRequirements = function()
consumed = consumed + 1
return true
end
local action = ISPaintMyKI5Vehicle:new(serverCharacter(), vehicle, 2, "Engine")
action:serverStart()
assert(not action:complete())
assert(vehicle.skin == 0 and vehicle.transmitted == 0 and consumed == 0)
PaintMyKI5.PaintInventory.checkPaintingRequirements = function() return true end
end)
test("server completion consumes then transmits the selected skin", function()
local vehicle = mutableVehicle()
local character = serverCharacter()
@@ -288,4 +379,74 @@ test("client completion never mutates inventory or the vehicle", function()
isClient = function() return false end
end)
test("sandpaper is rare wherever full paint cans spawn", function()
ProceduralDistributions = {
list = {
PaintShelf = { items = { "PaintBlack", 10, "Paintbrush", 10 } },
PaintJunk = {
items = { "Hammer", 1 },
junk = { items = { "Base.PaintGreen", 0.4 } },
},
EmptyBuckets = { items = { "PaintbucketEmpty", 10, "Paintbrush", 10 } },
OtherModPaint = { items = { "OtherMod.PaintGreen", 10 } },
AlreadyAdded = {
items = { "PaintWhite", 1, "PaintMyKI5.Sandpaper", 0.1 },
},
},
}
ClutterTables = {
ClosetItems = { "PaintPurple", 0.1, "Pillow", 1 },
}
dofile(luaRoot .. "/server/Items/PaintMyKI5_SandpaperDistribution.lua")
local function countItem(items, itemType)
local count = 0
local weight = nil
for index = 1, #items, 2 do
if items[index] == itemType then
count = count + 1
weight = items[index + 1]
end
end
return count, weight
end
local shelfCount, shelfWeight = countItem(
ProceduralDistributions.list.PaintShelf.items,
"PaintMyKI5.Sandpaper"
)
assert(shelfCount == 1 and shelfWeight == 0.1)
local junkCount, junkWeight = countItem(
ProceduralDistributions.list.PaintJunk.junk.items,
"PaintMyKI5.Sandpaper"
)
assert(junkCount == 1 and junkWeight == 0.1)
local emptyCount = countItem(
ProceduralDistributions.list.EmptyBuckets.items,
"PaintMyKI5.Sandpaper"
)
assert(emptyCount == 0)
local otherModCount = countItem(
ProceduralDistributions.list.OtherModPaint.items,
"PaintMyKI5.Sandpaper"
)
assert(otherModCount == 0)
local existingCount = countItem(
ProceduralDistributions.list.AlreadyAdded.items,
"PaintMyKI5.Sandpaper"
)
assert(existingCount == 1)
local clutterCount, clutterWeight = countItem(
ClutterTables.ClosetItems,
"PaintMyKI5.Sandpaper"
)
assert(clutterCount == 1 and clutterWeight == 0.1)
end)
print("Lua tests passed: " .. passed)
+100 -1
View File
@@ -66,11 +66,66 @@ class InGameLuaContractTests(unittest.TestCase):
"shared/PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle.lua",
"client/PaintMyKI5/ISPaintMyKI5UI.lua",
"client/PaintMyKI5/PaintVehicleContextMenu.lua",
"shared/PaintMyKI5/TextureName.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_art_assets_are_packaged_for_mod_and_context_menus(self) -> None:
source_icon = (PROJECT_ROOT / "art/icon.png").read_bytes()
source_preview = (PROJECT_ROOT / "art/preview.png").read_bytes()
for directory in (PROJECT_ROOT, PROJECT_ROOT / "42.20"):
self.assertEqual(source_icon, (directory / "icon.png").read_bytes())
self.assertEqual(source_preview, (directory / "preview.png").read_bytes())
metadata = (directory / "mod.info").read_text(encoding="utf-8")
self.assertIn("icon=icon.png", metadata)
self.assertIn("poster=preview.png", metadata)
self.assertEqual(
source_icon,
(PROJECT_ROOT / "common/media/textures/PaintMyKI5_ContextMenu.png").read_bytes(),
)
context_source = (LUA_ROOT / self.EXPECTED_FILES[4]).read_text(encoding="utf-8")
self.assertIn(
'getTexture("media/textures/PaintMyKI5_ContextMenu.png")',
context_source,
)
def test_sandpaper_item_icon_translation_and_loot_loader_are_packaged(self) -> None:
item_script = (
PROJECT_ROOT / "42.20/media/scripts/PaintMyKI5_items.txt"
).read_text(encoding="utf-8")
sandpaper = re.search(r"item\s+Sandpaper\s*\{(.*?)\}", item_script, re.S)
self.assertIsNotNone(sandpaper)
self.assertIn("ItemType = base:normal", sandpaper.group(1))
self.assertIn("Icon = Sandpaper", sandpaper.group(1))
self.assertEqual(
(PROJECT_ROOT / "art/sandpaper.png").read_bytes(),
(PROJECT_ROOT / "common/media/textures/Item_Sandpaper.png").read_bytes(),
)
translations = json.loads(
(
PROJECT_ROOT
/ "42.20/media/lua/shared/Translate/EN/ItemName.json"
).read_text(encoding="utf-8")
)
self.assertEqual("Sandpaper", translations["ItemName_PaintMyKI5.Sandpaper"])
self.assertFalse(
(
PROJECT_ROOT
/ "42.20/media/lua/shared/Translate/EN/ItemName_EN.json"
).exists()
)
self.assertTrue(
(
PROJECT_ROOT
/ "42.20/media/lua/server/Items/PaintMyKI5_SandpaperDistribution.lua"
).is_file()
)
def test_server_authoritative_timed_action_contract(self) -> None:
source = (LUA_ROOT / self.EXPECTED_FILES[2]).read_text(encoding="utf-8")
@@ -78,6 +133,7 @@ class InGameLuaContractTests(unittest.TestCase):
self.assertIn("function ISPaintMyKI5Vehicle:complete()", source)
self.assertIn("PaintRequirements.getRequirements", source)
self.assertIn("PaintInventory.consumeRequirements", source)
self.assertGreaterEqual(source.count("checkPaintingRequirements"), 2)
self.assertIn("vehicle:setSkinIndex", source)
self.assertIn("vehicle:transmitSkinIndex", source)
self.assertNotRegex(source, r"sendClientCommand\([^\n]+setSkinIndex")
@@ -91,8 +147,26 @@ class InGameLuaContractTests(unittest.TestCase):
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("PaintMyKI5.TextureName.buildLabels", ui_source)
self.assertIn("paintButton:setTooltip", ui_source)
self.assertIn("checkPaintingRequirements", ui_source)
self.assertGreaterEqual(ui_source.count("PaintRequirements.isValidSelection"), 2)
self.assertNotIn("PaintRequirements.isValidTarget", ui_source)
self.assertIn("ISTimedActionQueue.add", ui_source)
self.assertLess(
ui_source.index("ISPathFindAction:pathToVehicleArea"),
ui_source.index("ISPaintMyKI5Vehicle:new"),
)
translations = json.loads(
(LUA_ROOT / "shared/Translate/EN/IG_UI.json").read_text(encoding="utf-8")
)
self.assertEqual(
"Missing requirements:",
translations["IGUI_PaintMyKI5_MissingRequirements"],
)
self.assertIn("%1", translations["IGUI_PaintMyKI5_MissingTool"])
self.assertIn("%1", translations["IGUI_PaintMyKI5_MissingPaint"])
def test_b42_translation_files_are_valid_json(self) -> None:
context = json.loads(
@@ -121,6 +195,31 @@ class InGameLuaContractTests(unittest.TestCase):
)
)
def test_generated_mustang_green_skin_is_classified_as_green(self) -> None:
manifest = (
LUA_ROOT / "shared/PaintMyKI5/PaintMyKI5VehiclePaintData.lua"
).read_text(encoding="utf-8")
vehicle = re.search(
r'\["Base\.93mustangGT"\] = \{(.*?)\n \},\n'
r' \["Base\.93mustangSSP"\]',
manifest,
re.S,
)
self.assertIsNotNone(vehicle)
green_skin = re.search(
r'texture = "Vehicles/Vehicles_93mustangGT_Shell_Green",'
r"(.*?)\n \},",
vehicle.group(1),
re.S,
)
self.assertIsNotNone(green_skin)
green_percentage = re.search(
r'item = "Base\.PaintGreen", percent = ([\d.]+)',
green_skin.group(1),
)
self.assertIsNotNone(green_percentage)
self.assertGreaterEqual(float(green_percentage.group(1)), 90.0)
if __name__ == "__main__":
unittest.main()
+164
View File
@@ -6,14 +6,20 @@ import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from unittest.mock import patch
from PIL import Image
from scanner.scan_ki5_workshop import (
MAX_METADATA_CHARS,
MAX_SCAN_DECODED_PIXELS,
MAX_VARIANT_MASK_SKINS,
ModCandidate,
PaintCan,
ScanFileError,
_add_decoded_pixels,
analyze_colors,
build_variant_mask,
discover_paint_cans,
discover_mods,
main,
@@ -168,6 +174,117 @@ class ScannerTests(unittest.TestCase):
[(color.paint_can.item_id, color.percentage) for color in colors],
)
def test_dark_chromatic_shading_preserves_the_paint_hue(self) -> None:
paints = (
PaintCan("Base.PaintBlack", "Paint - Black", (0.20, 0.20, 0.20), 0.1),
PaintCan("Base.PaintBlue", "Paint - Blue", (0.35, 0.35, 0.80), 0.1),
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),
)
with tempfile.TemporaryDirectory() as temporary_directory:
image_path = Path(temporary_directory) / "shaded-shell.png"
image = Image.new("RGB", (10, 1))
image.putdata([(20, 50, 30)] * 8 + [(20, 20, 20)] * 2)
image.save(image_path)
colors = analyze_colors(image_path, paints, preserve_chromatic_hue=True)
self.assertEqual(
[("Base.PaintGreen", 80.0), ("Base.PaintBlack", 20.0)],
[(color.paint_can.item_id, color.percentage) for color in colors],
)
def test_dark_chromatic_pixels_remain_conservative_without_a_body_mask(self) -> None:
paints = (
PaintCan("Base.PaintBlack", "Paint - Black", (0.20, 0.20, 0.20), 0.1),
PaintCan("Base.PaintGreen", "Paint - Green", (0.41, 0.80, 0.41), 0.1),
)
with tempfile.TemporaryDirectory() as temporary_directory:
image_path = Path(temporary_directory) / "dark-tinted-trim.png"
Image.new("RGB", (1, 1), (20, 50, 30)).save(image_path)
colors = analyze_colors(image_path, paints)
self.assertEqual("Base.PaintBlack", colors[0].paint_can.item_id)
def test_variant_mask_excludes_fixed_shell_trim_from_paint_percentages(self) -> None:
paints = (
PaintCan("Base.PaintBlack", "Paint - Black", (0.20, 0.20, 0.20), 0.1),
PaintCan("Base.PaintGreen", "Paint - Green", (0.41, 0.80, 0.41), 0.1),
PaintCan("Base.PaintRed", "Paint - Red", (0.63, 0.10, 0.10), 0.1),
)
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
green_path = root / "shell-green.png"
red_path = root / "shell-red.png"
green = Image.new("RGB", (10, 1))
green.putdata([(20, 50, 30)] * 8 + [(20, 20, 20)] * 2)
green.save(green_path)
red = Image.new("RGB", (10, 1))
red.putdata([(80, 20, 20)] * 8 + [(20, 20, 20)] * 2)
red.save(red_path)
mask = build_variant_mask((green_path, red_path))
colors = analyze_colors(
green_path,
paints,
pixel_mask=mask,
preserve_chromatic_hue=True,
)
self.assertEqual(
[("Base.PaintGreen", 100.0)],
[(color.paint_can.item_id, color.percentage) for color in colors],
)
def test_variant_mask_is_not_used_when_sibling_uv_layouts_do_not_overlap(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
left_path = root / "left.png"
right_path = root / "right.png"
left = Image.new("RGBA", (10, 1), (0, 0, 0, 0))
left.putdata([(20, 50, 30, 255)] * 5 + [(0, 0, 0, 0)] * 5)
left.save(left_path)
right = Image.new("RGBA", (10, 1), (0, 0, 0, 0))
right.putdata([(0, 0, 0, 0)] * 5 + [(80, 20, 20, 255)] * 5)
right.save(right_path)
self.assertIsNone(build_variant_mask((left_path, right_path)))
def test_variant_mask_is_not_used_for_small_livery_differences(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
plain_path = root / "plain.png"
decal_path = root / "decal.png"
Image.new("RGB", (100, 1), (20, 50, 30)).save(plain_path)
decal = Image.new("RGB", (100, 1), (20, 50, 30))
decal.putdata([(80, 20, 20)] * 10 + [(20, 50, 30)] * 90)
decal.save(decal_path)
self.assertIsNone(build_variant_mask((plain_path, decal_path)))
def test_variant_mask_rejects_excessive_skin_count_before_decoding(self) -> None:
missing_paths = tuple(
Path(f"missing-{index}.png") for index in range(MAX_VARIANT_MASK_SKINS + 1)
)
self.assertIsNone(build_variant_mask(missing_paths))
def test_scan_rejects_aggregate_decoded_pixel_budget(self) -> None:
with self.assertRaisesRegex(ScanFileError, "decoded-pixel safety limit"):
_add_decoded_pixels(MAX_SCAN_DECODED_PIXELS - 1, 2)
def test_png_metadata_value_error_is_normalized(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
texture = Path(temporary_directory) / "metadata.png"
texture.write_bytes(b"placeholder")
with patch(
"scanner.scan_ki5_workshop.Image.open",
side_effect=ValueError("compressed metadata is too large"),
):
with self.assertRaisesRegex(ScanFileError, "Could not decode PNG"):
analyze_colors(texture, self.TEST_PAINTS)
def test_ignores_fully_transparent_pixels(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
image_path = Path(temporary_directory) / "alpha.png"
@@ -252,6 +369,53 @@ class ScannerTests(unittest.TestCase):
self.assertIn("B42 paint cans: 3", console)
self.assertIn("1 mods, 1 cars, 1 textures", console)
def test_scan_uses_sibling_skins_to_ignore_fixed_shell_trim(self) -> None:
paints = (
PaintCan("Base.PaintBlack", "Paint - Black", (0.20, 0.20, 0.20), 0.1),
PaintCan("Base.PaintGreen", "Paint - Green", (0.41, 0.80, 0.41), 0.1),
PaintCan("Base.PaintRed", "Paint - Red", (0.63, 0.10, 0.10), 0.1),
)
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
mod_root = root / "999/mods/shadedCar"
self._write(
mod_root / "42.13/mod.info",
"name=Shaded Car\nid=shadedCar\nrequire=damnlib\ncategory=vehicle\n",
)
self._write(
mod_root / "42.13/media/scripts/vehicles/car.txt",
"""
module Base {
vehicle ShadedCar {
engineForce = 4000,
skin { texture = Vehicles/ShadedCar_Green, }
skin { texture = Vehicles/ShadedCar_Red, }
}
}
""",
)
texture_root = mod_root / "common/media/textures/Vehicles"
texture_root.mkdir(parents=True)
green = Image.new("RGB", (10, 1))
green.putdata([(20, 50, 30)] * 8 + [(20, 20, 20)] * 2)
green.save(texture_root / "ShadedCar_Green.png")
red = Image.new("RGB", (10, 1))
red.putdata([(80, 20, 20)] * 8 + [(20, 20, 20)] * 2)
red.save(texture_root / "ShadedCar_Red.png")
report = scan_workshop(root, paints)
self.assertEqual(
[
[("Base.PaintGreen", 100.0)],
[("Base.PaintRed", 100.0)],
],
[
[(paint.paint_can.item_id, paint.percentage) for paint in texture.paints]
for texture in report.textures
],
)
def test_missing_texture_adds_warning_and_keeps_scanning(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)