Initial Commit

This commit is contained in:
2026-08-12 13:36:14 -04:00
parent cdedb1cb5e
commit 19e4ac0cce
22 changed files with 30278 additions and 1 deletions
+515
View File
@@ -0,0 +1,515 @@
from __future__ import annotations
import io
import json
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from PIL import Image
from scanner.scan_ki5_workshop import (
MAX_METADATA_CHARS,
ModCandidate,
PaintCan,
analyze_colors,
discover_paint_cans,
discover_mods,
main,
parse_vehicle_scripts,
resolve_texture,
scan_workshop,
write_lua_manifest,
)
class ScannerTests(unittest.TestCase):
TEST_PAINTS = (
PaintCan("Base.PaintGreen", "Paint - Green", (0.41, 0.80, 0.41), 0.1),
PaintCan("Base.PaintGrey", "Paint - Gray", (0.50, 0.50, 0.50), 0.1),
PaintCan("Base.PaintWhite", "Paint - White", (0.92, 0.92, 0.92), 0.1),
)
def test_discovers_full_b42_paint_cans_and_rgb_values(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
game_root = Path(temporary_directory)
self._write_game_palette(game_root)
paints = discover_paint_cans(game_root)
self.assertEqual(self.TEST_PAINTS, paints)
def test_paint_discovery_excludes_spray_paint_and_incomplete_buckets(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
game_root = Path(temporary_directory)
self._write_game_palette(game_root, include_decoys=True)
paints = discover_paint_cans(game_root)
self.assertEqual(self.TEST_PAINTS, paints)
def test_paint_discovery_handles_multiple_semicolon_separated_tags(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
game_root = Path(temporary_directory)
self._write_game_palette(game_root)
item_script = game_root / "media/scripts/generated/items/drainable.txt"
contents = item_script.read_text(encoding="utf-8")
item_script.write_text(
contents.replace("Tags = base:paint", "Tags = base:tool;base:paint"),
encoding="utf-8",
)
paints = discover_paint_cans(game_root)
self.assertEqual(self.TEST_PAINTS, paints)
def test_discovers_damnlib_once_and_selects_latest_b42_overlay(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
mod_root = root / "123" / "mods" / "testCar"
self._write(mod_root / "mod.info", "name=Test Car\nid=testCar\nrequire=other, DamnLib\n")
self._write(mod_root / "42.13" / "mod.info", "name=Test Car\nid=testCar\nrequire=\\damnlib\n")
self._write(mod_root / "42.20" / "mod.info", "name=Test Car\nid=testCar\nrequire=damnlib\n")
self._write(mod_root / "42.20" / "media/scripts/vehicles/car.txt", "module Base {}")
mods = discover_mods(root)
self.assertEqual(1, len(mods))
self.assertEqual("123", mods[0].workshop_id)
self.assertEqual("testCar", mods[0].mod_id)
self.assertEqual(mod_root / "42.20", mods[0].content_root)
def test_dependency_token_must_match_exactly(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
self._write(
root / "123/mods/notDamnLib/mod.info",
"name=No Match\nid=noMatch\nrequire=notdamnlib\n",
)
self.assertEqual([], discover_mods(root))
def test_maps_only_skin_textures_to_full_vehicle_id_and_filters_trailers(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
scripts = root / "media/scripts/vehicles"
self._write(
scripts / "cars.txt",
"""
module Base
{
model Interior { texture = Vehicles/Interior, }
vehicle ExampleCar
{
engineForce = 4000,
skin { texture = Vehicles/Example_Shell_green, }
skin { texture = Vehicles/Example_Shell_white, }
skin { texture = Vehicles/Example_Shell_green, }
textureDamage1Shell = Vehicles/Example_damage,
}
vehicle TrailerExample
{
engineForce = 10,
skin { texture = Vehicles/Trailer_Shell, }
attachment trailer { offset = 0 0 0, }
}
}
""",
)
vehicles = parse_vehicle_scripts(scripts)
self.assertEqual(1, len(vehicles))
self.assertEqual("Base.ExampleCar", vehicles[0].vehicle_id)
self.assertEqual(
(
"Vehicles/Example_Shell_green",
"Vehicles/Example_Shell_white",
"Vehicles/Example_Shell_green",
),
vehicles[0].texture_references,
)
self.assertEqual((0, 1, 2), tuple(skin.skin_index for skin in vehicles[0].skins))
def test_ignores_malformed_vehicle_blocks(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
scripts = root / "media/scripts/vehicles"
self._write(
scripts / "broken.txt",
"""
module Base
{
vehicle BrokenCar
engineForce = 4000,
skin { texture = Vehicles/Broken_Shell, }
}
""",
)
self.assertEqual([], parse_vehicle_scripts(scripts))
def test_analyzes_requested_color_mix(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
image_path = Path(temporary_directory) / "mix.png"
image = Image.new("RGB", (10, 1))
image.putdata([(128, 128, 128)] * 6 + [(105, 204, 105)] * 2 + [(235, 235, 235)] * 2)
image.save(image_path)
colors = analyze_colors(image_path, self.TEST_PAINTS)
self.assertEqual(
[
("Base.PaintGrey", 60.0),
("Base.PaintGreen", 20.0),
("Base.PaintWhite", 20.0),
],
[(color.paint_can.item_id, color.percentage) for color in colors],
)
def test_ignores_fully_transparent_pixels(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
image_path = Path(temporary_directory) / "alpha.png"
image = Image.new("RGBA", (2, 1))
image.putdata([(255, 0, 0, 255), (0, 0, 255, 0)])
image.save(image_path)
colors = analyze_colors(image_path, self.TEST_PAINTS)
self.assertEqual(
[("Base.PaintGrey", 100.0)],
[(color.paint_can.item_id, color.percentage) for color in colors],
)
def test_transparent_colors_do_not_influence_visible_palette(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
image_path = Path(temporary_directory) / "visible.png"
image = Image.new("RGBA", (257, 1))
transparent_noise = [(index, 255 - index, index // 2, 0) for index in range(256)]
image.putdata([*transparent_noise, (0, 160, 0, 255)])
image.save(image_path)
colors = analyze_colors(image_path, self.TEST_PAINTS)
self.assertEqual(
[("Base.PaintGreen", 100.0)],
[(color.paint_can.item_id, color.percentage) for color in colors],
)
def test_integration_resolves_common_texture_and_prints_mapping(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
mod_root = root / "999" / "mods" / "exampleCar"
self._write(
mod_root / "42.13/mod.info",
"name=Example Car\nid=exampleCar\nrequire=damnlib\ncategory=vehicle\n",
)
self._write(
mod_root / "42.13/media/scripts/vehicles/example.txt",
"""
module Base
{
vehicle ExampleCar
{
engineForce = 4000,
skin { texture = Vehicles/Example_Shell, }
}
}
""",
)
image_path = mod_root / "common/media/textures/Vehicles/Example_Shell.png"
image_path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (1, 1), (0, 160, 0)).save(image_path)
report = scan_workshop(root, self.TEST_PAINTS)
self.assertEqual(1, len(report.textures))
self.assertEqual("Base.ExampleCar", report.textures[0].vehicle_id)
self.assertEqual(image_path, report.textures[0].texture_path)
self.assertEqual("Base.PaintGreen", report.textures[0].paints[0].paint_can.item_id)
game_root = root / "game"
output_path = root / "generated.lua"
self._write_game_palette(game_root)
output = io.StringIO()
exit_code = main(
[
"--workshop-root",
str(root),
"--game-root",
str(game_root),
"--output",
str(output_path),
],
output=output,
)
console = output.getvalue()
self.assertEqual(0, exit_code)
self.assertIn("Workshop 999 | Example Car (exampleCar)", console)
self.assertIn("Vehicle: Base.ExampleCar", console)
self.assertIn("Paint - Green [Base.PaintGreen] 100.0%", console)
self.assertIn("B42 paint cans: 3", console)
self.assertIn("1 mods, 1 cars, 1 textures", console)
def test_missing_texture_adds_warning_and_keeps_scanning(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
mod_root = root / "999" / "mods" / "exampleCar"
self._write(
mod_root / "42.13/mod.info",
"name=Example Car\nid=exampleCar\nrequire=damnlib\ncategory=vehicle\n",
)
self._write(
mod_root / "42.13/media/scripts/vehicles/example.txt",
"""
module Base
{
vehicle ExampleCar
{
engineForce = 4000,
skin { texture = Vehicles/Missing_Shell, }
}
}
""",
)
report = scan_workshop(root, self.TEST_PAINTS)
self.assertEqual(1, report.cars_found)
self.assertEqual((), report.textures)
self.assertEqual(
("Missing texture for Base.ExampleCar: Vehicles/Missing_Shell",),
report.warnings,
)
def test_cli_rejects_missing_workshop_root(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
game_root = root / "game"
self._write_game_palette(game_root)
missing_root = root / "missing"
output = io.StringIO()
exit_code = main(
[
"--workshop-root",
str(missing_root),
"--game-root",
str(game_root),
],
output=output,
)
self.assertEqual(2, exit_code)
self.assertIn("Workshop root does not exist", output.getvalue())
def test_cli_rejects_missing_game_root(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
output = io.StringIO()
exit_code = main(
["--workshop-root", str(root), "--game-root", str(root / "missing")],
output=output,
)
self.assertEqual(2, exit_code)
self.assertIn("Game root does not exist", output.getvalue())
def test_rejects_drive_qualified_texture_reference(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
mod_root = Path(temporary_directory) / "123/mods/example"
mod_root.mkdir(parents=True)
mod = ModCandidate("123", "example", "Example", mod_root, mod_root)
self.assertIsNone(resolve_texture(mod, "C:outside.png"))
def test_nearest_paint_matching_uses_supplied_palette(self) -> None:
paints = (
PaintCan("Custom.Dark", "Dark", (0.1, 0.1, 0.1), 0.1),
PaintCan("Custom.Bright", "Bright", (0.9, 0.9, 0.9), 0.1),
)
with tempfile.TemporaryDirectory() as temporary_directory:
image_path = Path(temporary_directory) / "near.png"
image = Image.new("RGB", (2, 1))
image.putdata([(30, 30, 30), (225, 225, 225)])
image.save(image_path)
percentages = analyze_colors(image_path, paints)
self.assertEqual(
[("Custom.Bright", 50.0), ("Custom.Dark", 50.0)],
[(entry.paint_can.item_id, entry.percentage) for entry in percentages],
)
def test_partially_transparent_pixels_are_alpha_weighted(self) -> None:
paints = (
PaintCan("Custom.Dark", "Dark", (0.1, 0.1, 0.1), 0.1),
PaintCan("Custom.Bright", "Bright", (0.9, 0.9, 0.9), 0.1),
)
with tempfile.TemporaryDirectory() as temporary_directory:
image_path = Path(temporary_directory) / "alpha-weighted.png"
image = Image.new("RGBA", (2, 1))
image.putdata([(25, 25, 25, 255), (230, 230, 230, 64)])
image.save(image_path)
percentages = analyze_colors(image_path, paints)
self.assertEqual(
[("Custom.Dark", 79.9), ("Custom.Bright", 20.1)],
[(entry.paint_can.item_id, entry.percentage) for entry in percentages],
)
def test_empty_paint_palette_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
image_path = Path(temporary_directory) / "pixel.png"
Image.new("RGB", (1, 1), (0, 0, 0)).save(image_path)
with self.assertRaisesRegex(ValueError, "paint palette is empty"):
analyze_colors(image_path, ())
def test_writes_deterministic_lua_manifest_for_ingame_menu(self) -> None:
from scanner.scan_ki5_workshop import PaintPercentage, ScanReport, TextureResult
texture = TextureResult(
workshop_id="123",
mod_id="example\"mod",
mod_name="Example\nCar\x01",
vehicle_id="Base.ExampleCar",
skin_index=0,
texture_reference="Vehicles/Example_Shell",
texture_path=Path("ignored/absolute/path.png"),
paints=(PaintPercentage(self.TEST_PAINTS[0], 100.0),),
)
report = ScanReport(1, 1, (texture,), ())
with tempfile.TemporaryDirectory() as temporary_directory:
output_path = Path(temporary_directory) / "generated.lua"
write_lua_manifest(output_path, report, self.TEST_PAINTS)
first_contents = output_path.read_text(encoding="utf-8")
write_lua_manifest(output_path, report, self.TEST_PAINTS)
self.assertEqual(first_contents, output_path.read_text(encoding="utf-8"))
self.assertIn("PaintMyKI5.VehiclePaintData = {", first_contents)
self.assertIn('vehicleId = "Base.ExampleCar"', first_contents)
self.assertIn('texture = "Vehicles/Example_Shell"', first_contents)
self.assertIn("skinIndex = 0", first_contents)
self.assertIn('item = "Base.PaintGreen"', first_contents)
self.assertIn("percent = 100.0", first_contents)
self.assertIn('modId = "example\\\"mod"', first_contents)
self.assertIn('modName = "Example\\nCar\\001"', first_contents)
self.assertNotIn("ignored/absolute/path.png", first_contents)
oversized = replace(texture, mod_name="x" * (MAX_METADATA_CHARS + 1))
output_path.write_text("preserve me", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "field exceeds"):
write_lua_manifest(
output_path,
ScanReport(1, 1, (oversized,), ()),
self.TEST_PAINTS,
)
self.assertEqual("preserve me", output_path.read_text(encoding="utf-8"))
self.assertEqual([], list(output_path.parent.glob(".*.tmp")))
def test_cli_writes_requested_lua_manifest(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
game_root = root / "game"
workshop_root = root / "workshop"
output_path = root / "out/generated.lua"
self._write_game_palette(game_root)
mod_root = workshop_root / "999/mods/exampleCar"
self._write(
mod_root / "42.13/mod.info",
"name=Example Car\nid=exampleCar\nrequire=damnlib\n",
)
self._write(
mod_root / "42.13/media/scripts/vehicles/example.txt",
"""
module Base
{
vehicle ExampleCar
{
engineForce = 1,
skin { texture = Vehicles/Example_Shell, }
}
}
""",
)
image_path = mod_root / "common/media/textures/Vehicles/Example_Shell.png"
image_path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (1, 1), (105, 204, 105)).save(image_path)
output = io.StringIO()
exit_code = main(
[
"--game-root", str(game_root),
"--workshop-root", str(workshop_root),
"--output", str(output_path),
"--quiet",
],
output=output,
)
self.assertEqual(0, exit_code)
self.assertTrue(output_path.is_file())
self.assertIn("Wrote 1 textures for 1 cars", output.getvalue())
@staticmethod
def _write(path: Path, contents: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(contents, encoding="utf-8")
@classmethod
def _write_game_palette(cls, game_root: Path, *, include_decoys: bool = False) -> None:
item_blocks = []
lua_rows = []
names = {}
for paint in cls.TEST_PAINTS:
short_id = paint.item_id.split(".", 1)[1]
item_blocks.append(
f"""
item {short_id}
{{
ItemType = base:drainable,
PourType = Bucket,
ReplaceOnDeplete = Base.PaintbucketEmpty,
UseDelta = {paint.use_delta},
Tags = base:paint,
}}
"""
)
red, green, blue = paint.rgb
lua_rows.append(
f'{{ paint = "{short_id}", text = "unused", color = {{ {red},{green},{blue} }} }},'
)
names[paint.item_id] = paint.display_name
if include_decoys:
item_blocks.extend(
[
"item SprayPaint { ItemType=base:drainable, DisplayCategory=Paint, }",
"item PaintFake { ItemType=base:drainable, PourType=Bucket, Tags=base:paint, }",
]
)
lua_rows.extend(
[
'{ paint = "SprayPaint", text = "unused", color = { 1,0,0 } },',
'{ paint = "PaintFake", text = "unused", color = { 0,0,1 } },',
]
)
cls._write(
game_root / "media/scripts/generated/items/drainable.txt",
"module Base {\n" + "\n".join(item_blocks) + "\n}",
)
cls._write(
game_root / "media/lua/shared/BuildingObjects/ISPaintMenu.lua",
"ISPaintMenu.PaintMenuItems = {\n" + "\n".join(lua_rows) + "\n}",
)
item_names = game_root / "media/lua/shared/Translate/EN/ItemName.json"
item_names.parent.mkdir(parents=True, exist_ok=True)
item_names.write_text(json.dumps(names), encoding="utf-8")
if __name__ == "__main__":
unittest.main()