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
+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)