from __future__ import annotations import io import json 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, 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_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" 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_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) 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()