Copy Behemoth's validated texuv2, offset2, texuv3, and offset3 rows from Mech ID 11 to Behemoth II at Mech ID 12. Both variants intentionally share the same external MFD and Radar geometry. Add an inherited-mapping assertion to the J&J comparison generator so future runs fail if any Behemoth II coordinate row diverges from Behemoth. Record the passing inheritance check in the generated summary. Document the inherited variant relationship and effective mapping count in MFD-RADAR-MAPPINGS.md. Compare hud/behemothii.bmp and radar/hud/behemothii.bmp against the corresponding Behemoth runtime assets. Both 512x512 grayscale images are already pixel-identical, so no hsh art files are copied or replaced.
339 lines
17 KiB
Python
339 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
REPO = ROOT.parent
|
|
COORD = REPO / "Gameleap/code/CoreTech/Libraries/GameOS/coord.cpp"
|
|
ZONES = ("LL", "RL", "LA", "RA", "RT", "LT", "CT", "CTR", "HD", "S1", "S2")
|
|
COLORS = {"current": "#ff3030", "provided": "#20e060"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Display:
|
|
folder: str
|
|
mech_id: int
|
|
kind: str
|
|
output_dir: str
|
|
slug: str
|
|
unexploded_text: str
|
|
exploded_text: str
|
|
unexploded_image: str | None
|
|
exploded_image: str
|
|
source_image: str | None = None
|
|
runtime_stem: str | None = None
|
|
|
|
|
|
DISPLAYS = (
|
|
Display("Annihilator", 0, "mfd", "MFD", "annihilator", "MFD/Annihilator Unexploded.txt", "MFD/Annihilator Exploded.txt", "MFD/annihilator_unexploded.bmp", "MFD/annihilator_Exploded.bmp"),
|
|
Display("Annihilator", 0, "radar", "Radar", "annihilator", "Radar/Annihilator Unexploded.txt", "Radar/Annihilator Exploded.txt", "Radar/annihilator_unexploded.bmp", "Radar/annihilator_exploded.bmp"),
|
|
Display("Argus", 4, "mfd", "MFD", "argus", "MFD/Argus Cords.txt", "MFD/Argus Cords.txt", "MFD/argus_unexpanded.bmp", "MFD/argus_explode.bmp"),
|
|
Display("AssassinII", 5, "mfd", "HUD", "assian2", "HUD/assassinII_HUD_unexploded.txt", "HUD/assassinII_hud_exploded.txt", None, "HUD/assian2_hud_exploded.bmp", "assian2_color.png", "assassin2"),
|
|
Display("AssassinII", 5, "radar", "Radar", "assian2", "Radar/assassinII_radar_unexploded.txt", "Radar/assassinII_radar_exploded.txt", "Radar/assian2_radar_unexploded.bmp", "Radar/assian2_radar_exploded.bmp", None, "assassin2"),
|
|
Display("Avatar", 7, "mfd", "MFD", "avatar", "MFD/Avatar Coords.txt", "MFD/Avatar Coords.txt", "MFD/avatar_unexploded.bmp", "MFD/avatar_exploded.bmp"),
|
|
Display("Avatar", 7, "radar", "Radar", "avatar", "Radar/Avatar Coords.txt", "Radar/Avatar Coords.txt", "Radar/avatar_unexploded.bmp", "Radar/avatar_exploded.bmp"),
|
|
Display("Behemoth", 11, "mfd", "HUD", "behemoth", "HUD/Behemoth_HUD_unexploded.txt", "HUD/Behemoth_HUD_exploded.txt", "HUD/Behemoth_HUD_unexploded.bmp", "HUD/Behemoth_HUD_exploded.bmp"),
|
|
Display("Behemoth", 11, "radar", "Radar", "behemoth", "Radar/Behemoth_unexploded.txt", "Radar/Behemoth_exploded.txt", "Radar/Behemoth_unexploded_radar.bmp", "Radar/Behemoth_radar_exploded.bmp"),
|
|
Display("BlackHawk", 13, "mfd", "MFD", "blackhawk", "MFD/Black Hawk.txt", "MFD/Black Hawk.txt", "MFD/blackhawk_unexploded.bmp", "MFD/blackhawk_exploded.bmp"),
|
|
Display("BlackHawk", 13, "radar", "Radar", "blackhawk", "Radar/Black Hawk.txt", "Radar/Black Hawk.txt", "Radar/blackhawk_unexploded.bmp", "Radar/blackhawk_exploded.bmp"),
|
|
Display("Fafnir", 27, "mfd", "HUD", "fafnir", "HUD/fafnir_unexploded.txt", "HUD/fafnir_exploded.txt", "HUD/fafnir_unexploded.bmp", "HUD/fafnir_exploded.bmp"),
|
|
Display("Flea", 28, "mfd", "MFD", "flea", "MFD/Flea unexploded.txt", "MFD/Flea exploded.txt", "MFD/flea_unexploded copy.bmp", "MFD/flea_exploded copy.bmp"),
|
|
Display("Gladiator", 29, "mfd", "MFD", "gladiator", "MFD/Gladiator unexploded.txt", "MFD/Gladiator Exploded.txt", "MFD/gladiator_unexploded copy.bmp", "MFD/gladiator_exploded copy.bmp"),
|
|
Display("hellspawn", 33, "radar", "radar", "hellspawn", "radar/hellspawn_radar_unexploded.txt", "radar/hellspawn_radar_exploded.txt", "radar/hellspawn_radar_unexploded.bmp", "radar/hellspawn_radar_exploded.bmp"),
|
|
Display("Kodiak", 37, "mfd", "MFD", "kodiak", "MFD/Kodiak Cords.txt", "MFD/Kodiak Cords.txt", "MFD/kodiak_unexpanded.bmp", "MFD/kodiak_Explode.bmp"),
|
|
Display("Longbow", 39, "mfd", "MFD", "longbow", "MFD/Lomgbow unexploded.txt", "MFD/Longbow exploded.txt", "MFD/longbow_unexploded copy.bmp", "MFD/longbow_exploded copy.bmp"),
|
|
Display("Warhammer", 62, "mfd", "MFD", "warhammer", "MFD/Warhammer unexploded.txt", "MFD/Warhammer exploded.txt", "MFD/warhammer_unexploded copy.bmp", "MFD/warhammer_exploded.bmp"),
|
|
Display("Warhammer", 62, "radar", "Radar", "warhammer", "Radar/Warhammer unexploded.txt", "Radar/Warhammer exploded.txt", "Radar/warhammer_unexploded copy.bmp", "Radar/warhammer_exploded.bmp"),
|
|
)
|
|
|
|
INHERITED_MAPPINGS = (
|
|
("Behemoth II", 12, "Behemoth", 11),
|
|
)
|
|
|
|
|
|
def parse_coord_arrays() -> dict[str, list[list[tuple[int, ...]]]]:
|
|
text = COORD.read_bytes().decode("ascii")
|
|
arrays = {}
|
|
for name in ("texuv2", "offset2", "texuv3", "offset3"):
|
|
match = re.search(rf"\b{name}\[.*?\]=\{{\r?\n(.*?)\r?\n\}};", text, re.S)
|
|
if not match:
|
|
raise RuntimeError(f"Could not find {name} in {COORD}")
|
|
rows = []
|
|
for line in match.group(1).splitlines():
|
|
if not line.lstrip().startswith("{{"):
|
|
continue
|
|
values = [
|
|
tuple(map(int, group.split(",")))
|
|
for group in re.findall(r"\{\s*([0-9 ]+(?:,\s*[0-9 ]+){1,3})\s*\}", line)
|
|
]
|
|
if len(values) != len(ZONES):
|
|
raise RuntimeError(f"Expected 11 zones in {name} row: {line}")
|
|
rows.append(values)
|
|
if len(rows) != 65:
|
|
raise RuntimeError(f"Expected 65 active rows in {name}, found {len(rows)}")
|
|
arrays[name] = rows
|
|
return arrays
|
|
|
|
|
|
def parse_measurements(path: Path, wanted_section: str, warnings: list[str]) -> dict[str, tuple[int, ...]]:
|
|
rows = {}
|
|
section = None
|
|
for line_number, raw in enumerate(path.read_text(errors="replace").splitlines(), 1):
|
|
stripped = raw.strip()
|
|
lower = stripped.lower()
|
|
zone_match = re.match(r"^(LL|RL|LA|RA|RT|LT|CT|CTR|HD|S1|S2)\b(.*)$", stripped, re.I)
|
|
if not zone_match:
|
|
if "unexploded" in lower or "unexpanded" in lower:
|
|
section = "unexploded"
|
|
elif "exploded" in lower or "explode" in lower:
|
|
section = "exploded"
|
|
continue
|
|
if section and section != wanted_section:
|
|
continue
|
|
zone = zone_match.group(1).upper()
|
|
remainder = zone_match.group(2)
|
|
numbers = tuple(map(int, re.findall(r"\d+", remainder)))
|
|
if "." in remainder:
|
|
warnings.append(f"{path.relative_to(ROOT)}:{line_number}: treated '.' as a coordinate separator")
|
|
if not numbers:
|
|
warnings.append(f"{path.relative_to(ROOT)}:{line_number}: blank {zone} value treated as zero")
|
|
rows[zone] = numbers
|
|
return rows
|
|
|
|
|
|
def normalize_measurements(display: Display, warnings: list[str]) -> tuple[list[tuple[int, ...]], list[tuple[int, ...]]]:
|
|
base = ROOT / display.folder
|
|
unexploded_path = base / display.unexploded_text
|
|
exploded_path = base / display.exploded_text
|
|
unexploded = parse_measurements(unexploded_path, "unexploded", warnings)
|
|
exploded = parse_measurements(exploded_path, "exploded", warnings)
|
|
|
|
unexploded_lengths = {len(value) for value in unexploded.values() if value and any(value)}
|
|
exploded_lengths = {len(value) for value in exploded.values() if value and any(value)}
|
|
if unexploded_lengths == {4} and exploded_lengths == {2}:
|
|
mode = "modern"
|
|
elif unexploded_lengths == {2} and exploded_lengths == {4}:
|
|
mode = "legacy"
|
|
else:
|
|
raise RuntimeError(
|
|
f"Cannot determine format for {display.folder} {display.kind}: "
|
|
f"unexploded lengths={unexploded_lengths}, exploded lengths={exploded_lengths}"
|
|
)
|
|
|
|
rectangles = []
|
|
offsets = []
|
|
for zone in ZONES:
|
|
un = unexploded.get(zone, ())
|
|
ex = exploded.get(zone, ())
|
|
if not un:
|
|
un = (0, 0, 0, 0) if mode == "modern" else (0, 0)
|
|
if not ex:
|
|
ex = (0, 0) if mode == "modern" else (0, 0, 0, 0)
|
|
expected_un = 4 if mode == "modern" else 2
|
|
expected_ex = 2 if mode == "modern" else 4
|
|
if un and not any(un):
|
|
un = (0,) * expected_un
|
|
if ex and not any(ex):
|
|
ex = (0,) * expected_ex
|
|
if len(un) != expected_un or len(ex) != expected_ex:
|
|
raise RuntimeError(f"Invalid {zone} values for {display.folder} {display.kind}: {un}, {ex}")
|
|
if mode == "modern":
|
|
rectangle = un
|
|
offset = ex
|
|
else:
|
|
width = ex[2] - ex[0]
|
|
height = ex[3] - ex[1]
|
|
rectangle = (un[0], un[1], un[0] + width, un[1] + height) if any(ex) else (0, 0, 0, 0)
|
|
offset = ex[:2]
|
|
rectangles.append(rectangle)
|
|
offsets.append(offset)
|
|
return rectangles, offsets
|
|
|
|
|
|
def load_unexploded(display: Display) -> Image.Image:
|
|
base = ROOT / display.folder
|
|
if display.source_image:
|
|
source = Image.open(base / display.source_image).convert("RGB")
|
|
scaled = source.resize((320, 320), Image.Resampling.LANCZOS)
|
|
prepared = Image.new("RGB", (340, 340), "black")
|
|
prepared.paste(scaled, (10, 10))
|
|
else:
|
|
prepared = Image.open(base / display.unexploded_image).convert("RGB")
|
|
if prepared.size == (512, 512):
|
|
return prepared
|
|
if prepared.width > 512 or prepared.height > 512:
|
|
raise RuntimeError(f"Unexpected unexploded size {prepared.size} for {display.folder} {display.kind}")
|
|
canvas = Image.new("RGB", (512, 512), "black")
|
|
canvas.paste(prepared, (0, 0))
|
|
return canvas
|
|
|
|
|
|
def component_boxes(rectangles: list[tuple[int, ...]], offsets: list[tuple[int, ...]]) -> list[tuple[int, ...]]:
|
|
boxes = []
|
|
for rectangle, offset in zip(rectangles, offsets):
|
|
if not any(rectangle) or not any(offset):
|
|
boxes.append((0, 0, 0, 0))
|
|
continue
|
|
width = rectangle[2] - rectangle[0]
|
|
height = rectangle[3] - rectangle[1]
|
|
boxes.append((offset[0], offset[1], offset[0] + width, offset[1] + height))
|
|
return boxes
|
|
|
|
|
|
def draw_legend(draw: ImageDraw.ImageDraw, font: ImageFont.ImageFont) -> None:
|
|
draw.rectangle((365, 12, 505, 67), fill="black", outline="white", width=1)
|
|
draw.line((377, 30, 402, 30), fill=COLORS["current"], width=3)
|
|
draw.text((410, 22), "current", fill=COLORS["current"], font=font)
|
|
draw.line((377, 51, 402, 51), fill=COLORS["provided"], width=3)
|
|
draw.text((410, 43), "provided", fill=COLORS["provided"], font=font)
|
|
|
|
|
|
def draw_comparison(
|
|
image: Image.Image,
|
|
current: list[tuple[int, ...]],
|
|
provided: list[tuple[int, ...]],
|
|
output: Path,
|
|
mark_origins: bool,
|
|
) -> None:
|
|
draw = ImageDraw.Draw(image)
|
|
font = ImageFont.load_default(size=14)
|
|
for label, boxes in (("current", current), ("provided", provided)):
|
|
color = COLORS[label]
|
|
for zone, box in zip(ZONES, boxes):
|
|
if not any(box):
|
|
continue
|
|
draw.rectangle(box, outline=color, width=3)
|
|
if mark_origins:
|
|
x, y = box[:2]
|
|
draw.line((x - 4, y, x + 5, y), fill=color, width=2)
|
|
draw.line((x, y - 4, x, y + 5), fill=color, width=2)
|
|
draw.text((box[0] + 3, box[1] + 2), zone, fill=color, font=font, stroke_width=2, stroke_fill="black")
|
|
draw_legend(draw, font)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
image.save(output)
|
|
|
|
|
|
def validate_provided(
|
|
display: Display,
|
|
rectangles: list[tuple[int, ...]],
|
|
offsets: list[tuple[int, ...]],
|
|
warnings: list[str],
|
|
) -> None:
|
|
source_limit = 340 if display.kind == "mfd" else 512
|
|
for zone, rectangle, offset in zip(ZONES, rectangles, offsets):
|
|
if any(rectangle):
|
|
if rectangle[2] <= rectangle[0] or rectangle[3] <= rectangle[1]:
|
|
warnings.append(f"{display.folder} {display.kind} {zone}: non-positive source rectangle {rectangle}")
|
|
if min(rectangle) < 0 or max(rectangle) > source_limit:
|
|
warnings.append(f"{display.folder} {display.kind} {zone}: source rectangle outside 0..{source_limit}: {rectangle}")
|
|
if any(offset) and (offset[0] < 0 or offset[1] < 0 or offset[0] > 512 or offset[1] > 512):
|
|
warnings.append(f"{display.folder} {display.kind} {zone}: offset outside 512 canvas: {offset}")
|
|
|
|
|
|
def difference_count(left: list[tuple[int, ...]], right: list[tuple[int, ...]]) -> int:
|
|
return sum(a != b for a, b in zip(left, right))
|
|
|
|
|
|
def main() -> None:
|
|
arrays = parse_coord_arrays()
|
|
results = []
|
|
all_warnings = []
|
|
generated = []
|
|
|
|
for variant_name, variant_id, base_name, base_id in INHERITED_MAPPINGS:
|
|
for array_name in ("texuv2", "offset2", "texuv3", "offset3"):
|
|
if arrays[array_name][variant_id] != arrays[array_name][base_id]:
|
|
raise RuntimeError(
|
|
f"{variant_name} (ID {variant_id}) must inherit {array_name} "
|
|
f"from {base_name} (ID {base_id})"
|
|
)
|
|
|
|
for display in DISPLAYS:
|
|
warnings = []
|
|
provided_rectangles, provided_offsets = normalize_measurements(display, warnings)
|
|
validate_provided(display, provided_rectangles, provided_offsets, warnings)
|
|
current_rectangles = arrays["texuv2" if display.kind == "mfd" else "texuv3"][display.mech_id]
|
|
current_offsets = arrays["offset2" if display.kind == "mfd" else "offset3"][display.mech_id]
|
|
|
|
output_dir = ROOT / display.folder / display.output_dir
|
|
unexploded_output = output_dir / f"{display.slug}_{display.kind}_unexploded_coords_comparison.png"
|
|
exploded_output = output_dir / f"{display.slug}_{display.kind}_exploded_coords_comparison.png"
|
|
draw_comparison(
|
|
load_unexploded(display),
|
|
current_rectangles,
|
|
provided_rectangles,
|
|
unexploded_output,
|
|
mark_origins=False,
|
|
)
|
|
exploded_image = Image.open(ROOT / display.folder / display.exploded_image).convert("RGB")
|
|
draw_comparison(
|
|
exploded_image,
|
|
component_boxes(current_rectangles, current_offsets),
|
|
component_boxes(provided_rectangles, provided_offsets),
|
|
exploded_output,
|
|
mark_origins=True,
|
|
)
|
|
|
|
rectangle_differences = difference_count(current_rectangles, provided_rectangles)
|
|
offset_differences = difference_count(current_offsets, provided_offsets)
|
|
status = "EXACT" if rectangle_differences == 0 and offset_differences == 0 else "DIFFERENT"
|
|
results.append((display, status, rectangle_differences, offset_differences, warnings))
|
|
generated.extend((unexploded_output, exploded_output))
|
|
all_warnings.extend(warnings)
|
|
|
|
configured = {(display.folder.lower(), display.kind) for display in DISPLAYS}
|
|
missing = []
|
|
for folder in sorted(path.name for path in ROOT.iterdir() if path.is_dir()):
|
|
for kind in ("mfd", "radar"):
|
|
if (folder.lower(), kind) not in configured:
|
|
missing.append((folder, kind))
|
|
|
|
lines = [
|
|
"# J&J Coordinate Comparison Summary",
|
|
"",
|
|
"Generated by `generate_comparison_maps.py`. Red is the current `coord.cpp` mapping; green is the provided J&J mapping.",
|
|
"",
|
|
"`EXACT` means all eleven source rectangles and all eleven exploded offsets match numerically. `DIFFERENT` means at least one value differs; inspect both generated images.",
|
|
"",
|
|
"| Mech | Display | Status | Source rectangles different | Exploded offsets different |",
|
|
"|---|---:|---:|---:|---:|",
|
|
]
|
|
for display, status, rectangle_differences, offset_differences, _ in results:
|
|
lines.append(f"| {display.folder} | {display.kind.upper()} | **{status}** | {rectangle_differences}/11 | {offset_differences}/11 |")
|
|
|
|
lines.extend(("", "## Inherited mappings", ""))
|
|
for variant_name, variant_id, base_name, base_id in INHERITED_MAPPINGS:
|
|
lines.append(
|
|
f"- `{variant_name}` (Mech ID {variant_id}) inherits all four coordinate rows "
|
|
f"from `{base_name}` (Mech ID {base_id}); generator assertion passed"
|
|
)
|
|
|
|
lines.extend(("", "## Missing comparison inputs", ""))
|
|
for folder, kind in missing:
|
|
lines.append(f"- `{folder}` {kind.upper()}: no complete provided mapping/image set configured")
|
|
|
|
lines.extend(("", "## Input warnings", ""))
|
|
if all_warnings:
|
|
for warning in sorted(set(all_warnings)):
|
|
lines.append(f"- {warning}")
|
|
else:
|
|
lines.append("- None")
|
|
|
|
lines.extend(("", "## Generated files", ""))
|
|
for output in generated:
|
|
lines.append(f"- `{output.relative_to(ROOT)}`")
|
|
lines.append("")
|
|
(ROOT / "COMPARISON-SUMMARY.md").write_text("\n".join(lines))
|
|
|
|
exact = sum(status == "EXACT" for _, status, _, _, _ in results)
|
|
print(f"Generated {len(generated)} maps for {len(results)} display sets: {exact} exact, {len(results) - exact} different")
|
|
print(f"Warnings: {len(set(all_warnings))}; missing display sets: {len(missing)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |