"""Recipe-driven wow.export OBJ -> browser GLB conversion (Blender 5+).""" from __future__ import annotations import json import math import os import re import shutil import struct import sys import builtins from contextlib import contextmanager from importlib import import_module from pathlib import Path from typing import IO, Iterator import bpy import bmesh from mathutils import Vector def texture_path_key(value: str | Path) -> str: return os.path.normcase(os.path.normpath(str(Path(value).resolve(strict=False)))) def mtl_texture_path_corrections(source_dir: Path) -> list[dict[str, str]]: """Recover exact, existing map_Kd paths truncated by wow.export's whitespace split.""" corrections: list[dict[str, str]] = [] for mtl_file in sorted(source_dir.rglob("*.mtl")): for line_number, line in enumerate( mtl_file.read_text(encoding="utf-8").splitlines(), start=1, ): fields = line.strip().split(maxsplit=1) if len(fields) != 2 or fields[0] != "map_Kd": continue exact_reference = fields[1].strip() split_reference = exact_reference.split(maxsplit=1)[0] if exact_reference else "" if not split_reference or split_reference == exact_reference: continue split_path = mtl_file.parent / split_reference exact_path = mtl_file.parent / exact_reference if not exact_path.is_file(): candidates = sorted( candidate for candidate in mtl_file.parent.iterdir() if candidate.is_file() and candidate.name.startswith(f"{split_reference} ") ) if len(candidates) > 1: names = ", ".join(repr(candidate.name) for candidate in candidates) raise RuntimeError( f"{mtl_file}:{line_number}: ambiguous whitespace-truncated texture " f"{split_reference!r}; exact reference {exact_reference!r} is absent " f"and candidates are {names}" ) continue corrections.append({ "mtl": mtl_file.relative_to(source_dir).as_posix(), "line": str(line_number), "splitReference": split_reference, "exactReference": exact_reference, "splitPath": split_path.relative_to(source_dir).as_posix(), "exactPath": exact_path.relative_to(source_dir).as_posix(), }) return corrections def _wow_export_face_tail(fields: list[str]) -> list[int] | None: if len(fields) != 4: return None valid_indices: list[int] = [] found_missing = False for point in fields[1:]: components = point.split("/") is_wow_export_index = ( len(components) == 3 and all(re.fullmatch(r"[+-]?\d+", component) for component in components) and len(set(components)) == 1 ) is_missing_index = len(components) == 3 and all( component.casefold() == "nan" for component in components ) if is_wow_export_index and not found_missing: valid_indices.append(int(components[0])) elif is_missing_index: found_missing = True else: return None return valid_indices if found_missing and len(valid_indices) in {1, 2} else None def obj_nonfinite_face_corrections(source_dir: Path) -> list[dict[str, object]]: """Identify wow.export's terminal render-batch index remainder without inventing a face.""" corrections: list[dict[str, object]] = [] nonfinite = re.compile(r"(?i)(?:^|[\s/])[+-]?(?:nan|inf(?:inity)?)(?=$|[\s/])") for obj_file in sorted(source_dir.rglob("*.obj")): pending: dict[str, object] | None = None group = "" wow_export_header = False with obj_file.open("r", encoding="utf-8") as stream: for line_number, line in enumerate(stream, start=1): fields = line.split() if not fields or fields[0].startswith("#"): if line_number == 1 and "wow.export" in line: wow_export_header = True continue directive = fields[0] if pending is not None: if directive != "g": raise RuntimeError( f"{obj_file}:{pending['line']}: non-finite face is not the terminal " "record of a wow.export mesh batch; source geometry cannot be repaired safely" ) corrections.append(pending) pending = None if directive == "g": group = fields[1] if len(fields) > 1 else "" if not nonfinite.search(line): continue if not wow_export_header: raise RuntimeError( f"{obj_file}:{line_number}: non-finite OBJ data is not from a recognized " "wow.export file and cannot be repaired safely" ) valid_indices = _wow_export_face_tail(fields) if directive == "f" else None if valid_indices is None: raise RuntimeError( f"{obj_file}:{line_number}: non-finite OBJ {directive!r} data cannot be " "reconstructed without fabricating source geometry" ) pending = { "obj": obj_file.relative_to(source_dir).as_posix(), "line": str(line_number), "group": group, "sourceRecord": line.strip(), "validVertexIndices": valid_indices, "danglingIndexCount": len(valid_indices), "repair": "omit-terminal-incomplete-triangle", } if pending is not None: corrections.append(pending) return corrections class _FullRemainderMtlLine(str): """Make wow.export's bare line.split() preserve the full map_Kd remainder.""" def split(self, sep=None, maxsplit=-1): if sep is None and maxsplit == -1: stripped = self.lstrip() fields = str.split(stripped, None, 1) if fields and fields[0] == "map_Kd": if len(fields) == 2: fields[1] = fields[1].strip() return fields return str.split(self, sep, maxsplit) class _FullRemainderMtlStream: def __init__(self, stream: IO[str]): self.stream = stream def __enter__(self): self.stream.__enter__() return self def __exit__(self, exc_type, exc_value, traceback): return self.stream.__exit__(exc_type, exc_value, traceback) def __iter__(self): return self def __next__(self): return _FullRemainderMtlLine(next(self.stream)) def __getattr__(self, name): return getattr(self.stream, name) class _SkippingObjLinesStream: def __init__(self, stream, skipped_lines: set[int]): self.stream = stream self.skipped_lines = skipped_lines self.line_number = 0 def __enter__(self): self.stream.__enter__() return self def __exit__(self, exc_type, exc_value, traceback): return self.stream.__exit__(exc_type, exc_value, traceback) def __iter__(self): return self def __next__(self): while True: line = next(self.stream) self.line_number += 1 if self.line_number not in self.skipped_lines: return line def __getattr__(self, name): return getattr(self.stream, name) @contextmanager def corrected_wowobj_texture_loader( importer_module: object, source_dir: Path, obj_face_corrections: list[dict[str, object]] | None = None, ) -> Iterator[list[dict[str, str]]]: corrections = mtl_texture_path_corrections(source_dir) obj_face_corrections = ( obj_nonfinite_face_corrections(source_dir) if obj_face_corrections is None else obj_face_corrections ) skipped_obj_lines: dict[str, set[int]] = {} for correction in obj_face_corrections: key = texture_path_key(source_dir / str(correction["obj"])) skipped_obj_lines.setdefault(key, set()).add(int(correction["line"])) had_module_open = hasattr(importer_module, "open") original_open = getattr(importer_module, "open", builtins.open) def open_with_full_mtl_remainders(file, mode="r", *args, **kwargs): stream = original_open(file, mode, *args, **kwargs) is_path = isinstance( file, (str, bytes, os.PathLike), ) file_suffix = Path(os.fsdecode(file)).suffix.casefold() if is_path else "" if "b" not in mode and file_suffix == ".mtl": return _FullRemainderMtlStream(stream) obj_lines = skipped_obj_lines.get(texture_path_key(os.fsdecode(file))) if is_path else None if "r" in mode and file_suffix == ".obj" and obj_lines: return _SkippingObjLinesStream(stream, obj_lines) return stream importer_module.open = open_with_full_mtl_remainders try: yield corrections finally: if had_module_open: importer_module.open = original_open else: delattr(importer_module, "open") def arguments() -> tuple[Path, Path, Path, Path]: if "--" not in sys.argv: raise SystemExit("expected: -- RECIPE_JSON SOURCE_DIR OUTPUT_DIR WOW_EXPORT_ADDON") values = [Path(value).resolve() for value in sys.argv[sys.argv.index("--") + 1 :]] if len(values) != 4: raise SystemExit(f"expected four arguments after --, received {len(values)}") return values[0], values[1], values[2], values[3] def select_only(objects: list[bpy.types.Object]) -> None: bpy.ops.object.select_all(action="DESELECT") for obj in objects: obj.hide_set(False) obj.hide_viewport = False obj.select_set(True) if objects: bpy.context.view_layer.objects.active = objects[0] def triangles(obj: bpy.types.Object) -> int: if obj.type != "MESH": return 0 obj.data.calc_loop_triangles() return len(obj.data.loop_triangles) def glb_triangle_count(file: Path) -> int: """Return the authoritative exported triangle count from GLB accessors.""" with file.open("rb") as handle: magic, version, _length = struct.unpack("<4sII", handle.read(12)) if magic != b"glTF" or version != 2: raise RuntimeError(f"{file.name} is not a glTF 2 GLB") json_length, json_type = struct.unpack(" tuple[list[float], list[float]]: low = Vector((math.inf, math.inf, math.inf)) high = Vector((-math.inf, -math.inf, -math.inf)) for obj in objects: if obj.type != "MESH": continue for corner in obj.bound_box: point = obj.matrix_world @ Vector(corner) for axis in range(3): low[axis] = min(low[axis], point[axis]) high[axis] = max(high[axis], point[axis]) if not all(math.isfinite(value) for value in (*low, *high)): raise RuntimeError("conversion produced no finite mesh bounds") return [round(value, 5) for value in low], [round(value, 5) for value in high] def tag_material_roles(objects: list[bpy.types.Object]) -> dict[str, str]: roles: dict[str, str] = {} for obj in objects: for slot in obj.material_slots: material = slot.material if material is None: continue name = material.name lowered = name.lower() role = "opaque" if "water" in lowered or "liquid" in lowered or name.endswith("_B2"): role = "blend" elif name.endswith("_B1"): role = "cutout" elif name.endswith("_B4"): role = "additive" material["semanticRole"] = role roles[name] = role return dict(sorted(roles.items())) def is_liquid_material(name: str) -> bool: lowered = name.lower() return "water" in lowered or "liquid" in lowered or name.endswith("_B2") def reconstruct_wmo_liquids(source_dir: Path) -> int: material = bpy.data.materials.get("DungeonWater_B2") or bpy.data.materials.new("DungeonWater_B2") material.diffuse_color = (0.035, 0.18, 0.17, 0.76) material["semanticRole"] = "blend" tile_count = 0 for group_file in sorted((source_dir / "raw").rglob("*.wmo")): if not re.search(r"_\d{3}\.wmo$", group_file.name, re.IGNORECASE): continue payload = group_file.read_bytes() cursor = 0 while True: chunk = payload.find(b"QILM", cursor) if chunk < 0: break size = struct.unpack_from(" 1e-5 else corner_z) flags_offset = vertex_offset + vert_x * vert_y * 8 flags = payload[flags_offset : flags_offset + tile_x * tile_y] vertices: list[tuple[float, float, float]] = [] faces: list[tuple[int, int, int, int]] = [] unit = 4.166666666666667 for y in range(tile_y): for x in range(tile_x): if flags[y * tile_x + x] & 0x0F == 0x0F: continue indices = (y * vert_x + x, y * vert_x + x + 1, (y + 1) * vert_x + x + 1, (y + 1) * vert_x + x) base = len(vertices) vertices.extend([ (corner_x + x * unit, corner_y + y * unit, heights[indices[0]]), (corner_x + (x + 1) * unit, corner_y + y * unit, heights[indices[1]]), (corner_x + (x + 1) * unit, corner_y + (y + 1) * unit, heights[indices[2]]), (corner_x + x * unit, corner_y + (y + 1) * unit, heights[indices[3]]), ]) faces.append((base, base + 1, base + 2, base + 3)) if faces: mesh = bpy.data.meshes.new(f"Liquid_{group_file.stem}_{cursor}") mesh.from_pydata(vertices, [], faces) mesh.materials.append(material) obj = bpy.data.objects.new(mesh.name, mesh) obj["source"] = "WMO_MLIQ" bpy.context.scene.collection.objects.link(obj) tile_count += len(faces) cursor = offset + size return tile_count def export_glb(file: Path, objects: list[bpy.types.Object], materials: str = "EXPORT") -> None: select_only(objects) result = bpy.ops.export_scene.gltf( filepath=str(file), export_format="GLB", use_selection=True, export_yup=True, export_apply=False, export_animations=False, export_materials=materials, export_cameras=False, export_lights=False, export_extras=True, ) if "FINISHED" not in result: raise RuntimeError(f"GLB export failed for {file.name}: {result}") def collision_copy( source: bpy.types.Object, slug: str, index: int, triangle_budget: int = 100_000, ) -> bpy.types.Object: copy = source.copy() copy.data = source.data.copy() copy.parent = None copy.matrix_world = source.matrix_world.copy() copy.name = f"COLLISION_{slug}_{index:03d}" bpy.context.scene.collection.objects.link(copy) select_only([copy]) bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) liquid_materials = { slot_index for slot_index, material in enumerate(copy.data.materials) if material is not None and is_liquid_material(material.name) } if liquid_materials: mesh = bmesh.new() mesh.from_mesh(copy.data) bmesh.ops.delete( mesh, geom=[face for face in mesh.faces if face.material_index in liquid_materials], context="FACES", ) mesh.to_mesh(copy.data) mesh.free() copy.data.materials.clear() before = triangles(copy) if before > triangle_budget: modifier = copy.modifiers.new("CollisionDecimate", "DECIMATE") modifier.ratio = triangle_budget / before modifier.use_collapse_triangulate = True bpy.ops.object.modifier_apply(modifier=modifier.name) copy["collider"] = "trimesh" copy["sourceChunk"] = source.get("sourcePath", source.name) return copy def is_adt_wmo_architecture(obj: bpy.types.Object) -> bool: """Keep placed WMO shells in collision without promoting decorative M2s.""" found_wmo_parent = False parent = obj.parent while parent is not None: base_name = re.sub(r"\.\d{3}$", "", parent.name).lower() if base_name == "doodads": return False if base_name == "wmos": found_wmo_parent = True parent = parent.parent return found_wmo_parent def normalize_adt_wmo_child_transforms( placement_root: bpy.types.Object, source_path: str, ) -> list[dict[str, object]]: """Repair wow.export state leakage when an M2 and WMO share an OBJ basename. A placed WMO's parent empty owns its map-space placement. The direct WMO OBJ mesh is therefore canonical at local origin, with only the OBJ importer's Blender Z-up correction. Decorative M2 branches are not direct children of these ``* parent`` WMO placement empties and are intentionally untouched. """ corrections: list[dict[str, object]] = [] for placement_container in placement_root.children: if re.sub(r"\.\d{3}$", "", placement_container.name).lower() != "wmos": continue for placement_parent in placement_container.children: if not re.sub(r"\.\d{3}$", "", placement_parent.name).lower().endswith(" parent"): continue for child in placement_parent.children: if child.type != "MESH": continue previous = { "location": list(child.location), "rotationEuler": list(child.rotation_euler), "scale": list(child.scale), } is_canonical = ( max(abs(value) for value in child.location) <= 1e-4 and abs(child.rotation_euler.x - math.pi / 2) <= 1e-4 and abs(child.rotation_euler.y) <= 1e-4 and abs(child.rotation_euler.z) <= 1e-4 and max(abs(value - 1) for value in child.scale) <= 1e-4 ) if is_canonical: continue child.location = (0.0, 0.0, 0.0) child.rotation_euler = (math.pi / 2, 0.0, 0.0) child.scale = (1.0, 1.0, 1.0) corrections.append({ "object": child.name, "mesh": child.data.name, "placementParent": placement_parent.name, "source": source_path, "previous": previous, "normalized": { "location": [0.0, 0.0, 0.0], "rotationEuler": [math.pi / 2, 0.0, 0.0], "scale": [1.0, 1.0, 1.0], }, }) return corrections def main() -> None: recipe_file, source_dir, output_dir, addon_parent = arguments() recipe = json.loads(recipe_file.read_text(encoding="utf-8")) slug = recipe["slug"] extraction_result_file = source_dir / "automation-result.json" extraction_result = json.loads(extraction_result_file.read_text(encoding="utf-8")) if extraction_result_file.is_file() else {} source_omissions = extraction_result.get("sourceOmissions", []) source_fallbacks = extraction_result.get("sourceFallbacks", []) environment_mode = extraction_result.get("environmentMode", recipe["environmentMode"]) if not (addon_parent / "io_scene_wowobj").is_dir(): raise FileNotFoundError(addon_parent / "io_scene_wowobj") # Root WMO/ADT OBJs carry placement CSVs that pull dependency OBJs in once. obj_files = sorted(file for file in source_dir.glob("adt_*.obj") if re.fullmatch(r"adt_\d+_\d+\.obj", file.name)) if not obj_files: obj_files = sorted(source_dir.glob("*.obj")) if not obj_files: raise RuntimeError("extraction produced no OBJ files to assemble") output_dir.mkdir(parents=True, exist_ok=True) for stale_collision in output_dir.glob(f"{slug}-collision*.glb"): stale_collision.unlink() sys.path.insert(0, str(addon_parent)) from io_scene_wowobj import Settings wowobj_importer = import_module("io_scene_wowobj.import_wowobj") importWoWOBJ = wowobj_importer.importWoWOBJ bpy.ops.object.select_all(action="SELECT") bpy.ops.object.delete(use_global=False) settings = Settings( useAlpha=True, createVertexGroups=False, allowDuplicates=False, importWMO=True, importWMOSets=True, importM2=True, importGOBJ=False, importTextures=True, useTerrainBlending=True, createEmissiveMaterials=False, createDoodadSetCollections=False, importLiquid=True, importUVAnimations=False, ) settings._import_cache_cleared = False architectural: list[bpy.types.Object] = [] imported_sources: list[str] = [] placement_coordinate_correction_count = 0 wmo_child_transform_corrections: list[dict[str, object]] = [] obj_face_corrections = obj_nonfinite_face_corrections(source_dir) with corrected_wowobj_texture_loader( wowobj_importer, source_dir, obj_face_corrections, ) as texture_path_corrections: for obj_file in obj_files: before = set(bpy.context.scene.objects) is_adt = re.fullmatch(r"adt_\d+_\d+\.obj", obj_file.name) is not None if is_adt: bpy.ops.wm.obj_import(filepath=str(obj_file), forward_axis="NEGATIVE_Z", up_axis="Y") created_terrain = [obj for obj in bpy.context.scene.objects if obj not in before and obj.type == "MESH"] architectural.extend(created_terrain) placement_csv = obj_file.with_name(obj_file.stem + "_ModelPlacementInformation.csv") if placement_csv.is_file(): placement_obj = obj_file.with_name(obj_file.stem + "_placements.obj") placement_mtl = obj_file.with_name(obj_file.stem + "_placements.mtl") placement_obj.write_text( f"mtllib {placement_mtl.name}\no PlacementAnchor\n" "v 0 0 0\nv 0.001 0 0\nv 0 0.001 0\n" "vn 0 1 0\nvn 0 1 0\nvn 0 1 0\n" "vt 0 0\nvt 0 0\nvt 0 0\n" "g PlacementAnchor\nusemtl PlacementAnchor\n" "f 1/1/1 2/2/2 3/3/3\n", encoding="utf-8", ) placement_mtl.write_text("newmtl PlacementAnchor\nKd 0 0 0\n", encoding="utf-8") shutil.copyfile( placement_csv, placement_obj.with_name(placement_obj.stem + "_ModelPlacementInformation.csv"), ) placement_root = importWoWOBJ(str(placement_obj), None, settings) if placement_root is not None: wmo_child_transform_corrections.extend( normalize_adt_wmo_child_transforms( placement_root, obj_file.relative_to(source_dir).as_posix(), ) ) # The wow.export ADT placement importer rotates its WMO and # doodad containers into Y-up space. Blender's glTF exporter # performs that conversion again, so retain Blender Z-up here. for placement_container in placement_root.children: base_name = re.sub(r"\.\d{3}$", "", placement_container.name) if base_name in {"WMOs", "Doodads", "GameObjects"}: placement_container.rotation_euler.x = 0.0 placement_coordinate_correction_count += 1 bpy.data.objects.remove(placement_root, do_unlink=True) root = created_terrain[0] if created_terrain else None else: root = importWoWOBJ(str(obj_file), None, settings) created = [obj for obj in bpy.context.scene.objects if obj not in before] for obj in created: obj["sourcePath"] = obj_file.relative_to(source_dir).as_posix() if is_adt: architectural.extend( obj for obj in created if obj.type == "MESH" and obj not in created_terrain and is_adt_wmo_architecture(obj) ) if not is_adt and root is not None and root.type == "MESH": architectural.append(root) imported_sources.append(obj_file.relative_to(source_dir).as_posix()) liquid_tiles = reconstruct_wmo_liquids(source_dir) visual = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"] if not visual or not architectural: raise RuntimeError("wow.export import did not create architectural mesh chunks") material_roles = tag_material_roles(visual) adt_liquid_meshes = sum( 1 for obj in visual if any(slot.material is not None and "adt_liquid" in slot.material.name.lower() for slot in obj.material_slots) ) low, high = bounds(visual) visual_file = output_dir / f"{slug}-visual.glb" export_glb(visual_file, visual) visual_exported_count = glb_triangle_count(visual_file) collision_files: list[dict[str, object]] = [] collision_groups: list[list[bpy.types.Object]] = [] collision_group: list[bpy.types.Object] = [] collision_group_triangles = 0 collision_total_triangle_target = 4_000_000 collision_mesh_triangle_target = min( 100_000, max(4_000, collision_total_triangle_target // max(1, len(architectural))), ) omitted_zero_triangle_collision_sources: list[str] = [] for source_index, source in enumerate(architectural): collision = collision_copy( source, slug, source_index, collision_mesh_triangle_target, ) count = triangles(collision) if count == 0: omitted_zero_triangle_collision_sources.append( str(source.get("sourcePath", source.name)) ) bpy.data.objects.remove(collision, do_unlink=True) continue if collision_group and collision_group_triangles + count > 100_000: collision_groups.append(collision_group) collision_group = [] collision_group_triangles = 0 collision_group.append(collision) collision_group_triangles += count if collision_group: collision_groups.append(collision_group) for collision_index, collision_objects in enumerate(collision_groups): suffix = "" if len(collision_groups) == 1 else f"-{collision_index:03d}" file = output_dir / f"{slug}-collision{suffix}.glb" export_glb(file, collision_objects, materials="NONE") exported_count = glb_triangle_count(file) if exported_count > 100_000: raise RuntimeError( f"{file.name} exceeds the 100000-triangle collision budget ({exported_count})" ) collision_files.append({"file": file.name, "triangles": exported_count}) blend_file = output_dir / f"{slug}.source.blend" bpy.ops.wm.save_as_mainfile(filepath=str(blend_file)) report = { "schemaVersion": 1, "dungeonId": slug, "status": "review-required", "environmentMode": environment_mode, "coordinateTransform": "three(x,y,z)=(-wow.x,wow.z,wow.y)", "placementCoordinateCorrection": { "applied": placement_coordinate_correction_count > 0, "containerCount": placement_coordinate_correction_count, "sourceConvention": "wow.export ADT placement containers are Y-up", "conversionConvention": "Blender Z-up before glTF Y-up export", "operation": "reset placement-container X rotation to 0 radians", }, "wmoChildTransformCorrections": { "count": len(wmo_child_transform_corrections), "provenance": "wow.export M2/WMO same-basename import-state normalization", "operation": "normalize direct placed-WMO OBJ child to local origin, X=pi/2 radians, unit scale", "sources": wmo_child_transform_corrections, }, "sources": imported_sources, "texturePathCorrections": texture_path_corrections, "objFaceCorrections": obj_face_corrections, "visual": {"file": visual_file.name, "meshObjects": len(visual), "triangles": visual_exported_count, "bounds": {"min": low, "max": high}}, "collision": collision_files, "materialRoles": material_roles, "collisionChunking": { "maximumTrianglesPerChunk": 100_000, "totalTriangleTarget": collision_total_triangle_target, "perMeshTriangleTarget": collision_mesh_triangle_target, "architecturalMeshes": len(architectural), "chunks": len(collision_files), }, "collisionOmissions": { "zeroTriangleSources": omitted_zero_triangle_collision_sources, }, "sourceOmissions": source_omissions, "sourceFallbacks": source_fallbacks, "liquids": {"adtMeshes": adt_liquid_meshes, "wmoTiles": liquid_tiles}, "editableSource": blend_file.name, "warnings": ( (["No ADT liquid geometry was reconstructed; visual review is required."] if environment_mode == "adt-hybrid" and adt_liquid_meshes == 0 else []) + ([f"Normalized {placement_coordinate_correction_count} ADT placement containers to Blender Z-up before glTF export."] if placement_coordinate_correction_count else []) + ([f"Normalized {len(wmo_child_transform_corrections)} direct placed-WMO OBJ child transform(s) after wow.export same-basename import-state leakage: {', '.join(correction['object'] for correction in wmo_child_transform_corrections)}."] if wmo_child_transform_corrections else []) + ([f"Omitted {len(omitted_zero_triangle_collision_sources)} architectural sources with no collision triangles."] if omitted_zero_triangle_collision_sources else []) + [ f"Authored decorative source omission: {omission['path']} ({omission['reason']})" for omission in source_omissions ] + [ f"Installed-client extraction fallback: {fallback['path']} ({fallback['reason']})" for fallback in source_fallbacks if fallback.get("provenance") != "client-extension-alias" ] + ([ f"Resolved {sum(1 for fallback in source_fallbacks if fallback.get('provenance') == 'client-extension-alias')} " "legacy MDX placement reference(s) to canonical installed-client M2 paths; " "exact alias provenance is retained in sourceFallbacks." ] if any(fallback.get("provenance") == "client-extension-alias" for fallback in source_fallbacks) else []) ), } (output_dir / "conversion-report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") print(json.dumps(report, indent=2)) if __name__ == "__main__": main()