from __future__ import annotations import argparse import json import sys import time from pathlib import Path from .formats import ( FLAG_FLIPPED, ShapeArchive, collect_render_items, load_globs, load_map_items, load_palette, load_typeflags, parse_world_rect, resolve_fixed_dat, resolve_static_dir, ) from .png import DEFAULT_BACKGROUND, blit_frame, rgba_buffer, write_png_rgba from .sorting import prepare_sorted_items KNOWN_MAP_USAGE_HINTS = { "remorse": { 0: [ "ScummVM CruGame::startGame() calls World::switchMap(0) for a new No Remorse game.", "The same startup path comments the initial player placement as 'Map 1 (mission 1)', so this is a confirmed mission-start map anchor.", ], }, "regret": {}, } def summarize_render_classes(base_items: list, shape_infos: list) -> dict[str, int]: summary = { "roof_items": 0, "editor_items": 0, "egg_family_items": 0, "invisible_flagged_items": 0, "npc_linked_items": 0, } for item in base_items: if item.flags & 0x0010: summary["invisible_flagged_items"] += 1 if item.npc_num != 0: summary["npc_linked_items"] += 1 if item.shape >= len(shape_infos): continue info = shape_infos[item.shape] if info.is_roof: summary["roof_items"] += 1 if info.is_editor: summary["editor_items"] += 1 if info.family in (3, 4, 7, 8): summary["egg_family_items"] += 1 return summary def map_usage_info(game: str, map_index: int, base_items: list, render_items: list) -> dict[str, object]: hints = KNOWN_MAP_USAGE_HINTS.get(game, {}).get(map_index, []) item_map_nums = sorted({item.map_num for item in base_items}) nonzero_item_map_nums = [value for value in item_map_nums if value != 0] npc_count = sum(1 for item in base_items if item.npc_num != 0) return { "status": "known_used" if hints else "unknown", "confidence": "commented_reference" if hints else "unknown", "known_hints": hints, "item_map_nums": item_map_nums, "nonzero_item_map_nums": nonzero_item_map_nums, "npc_linked_item_count": npc_count, "note": "No authoritative mission-to-map ownership table has been extracted yet. Unknown does not imply orphaned.", "has_renderable_content": bool(render_items), } def main() -> int: parser = argparse.ArgumentParser(description="Render a Crusader fixed map to PNG.") parser.add_argument("--game", choices=("remorse", "regret"), default="remorse") parser.add_argument("--static-dir", help="Override the static asset directory.") parser.add_argument("--fixed-dat", help="Override the FIXED.DAT path when it does not live under the selected static directory.") parser.add_argument("--map", dest="map_index", type=int, required=True, help="Map index to render.") parser.add_argument("--output", required=True, help="PNG output path.") parser.add_argument("--metadata", help="Optional JSON metadata output path.") parser.add_argument("--no-globs", action="store_true", help="Disable GLOB.FLX expansion.") parser.add_argument( "--include-editor", action=argparse.BooleanOptionalAction, default=True, help="Render editor-only shapes. Enabled by default to keep debug/editor map content visible.", ) parser.add_argument( "--include-roofs", action=argparse.BooleanOptionalAction, default=False, help="Render roof/exploration-obscurer shapes. Disabled by default.", ) parser.add_argument( "--include-hidden-markers", action=argparse.BooleanOptionalAction, default=True, help="Render hidden markers such as egg-family placements, editor/debug objects, and invisible marker shapes when they have visible frames.", ) parser.add_argument( "--world-rect", nargs=4, metavar=("MIN_X", "MIN_Y", "MAX_X", "MAX_Y"), help="Restrict rendering to a world-space rectangle.", ) parser.add_argument( "--max-pixels", type=int, default=0, help="Fail if the output image would exceed this many pixels. Non-positive values disable the limit.", ) parser.add_argument( "--progress-every", type=int, default=2000, help="Emit collection and sorting progress every N items. Non-positive values disable progress logging.", ) parser.add_argument( "--invalid-detail-limit", type=int, default=20, help="Maximum number of invalid shape/frame records to include in metadata.", ) args = parser.parse_args() repo_root = Path(__file__).resolve().parents[2] static_dir = resolve_static_dir(repo_root, args.game, args.static_dir) fixed_dat_path = resolve_fixed_dat(static_dir, args.fixed_dat) world_rect = parse_world_rect(args.world_rect) shape_infos = load_typeflags(static_dir / "TYPEFLAG.DAT") palette = load_palette(static_dir / "GAMEPAL.PAL") globs = load_globs(static_dir / "GLOB.FLX") shape_archive = ShapeArchive(static_dir / "SHAPES.FLX") progress_enabled = args.progress_every > 0 start_time = time.monotonic() def log_progress(message: str) -> None: if not progress_enabled: return elapsed = time.monotonic() - start_time print(f"[map {args.map_index} +{elapsed:7.1f}s] {message}", file=sys.stderr, flush=True) if not fixed_dat_path.exists(): raise FileNotFoundError( f"missing FIXED.DAT at {fixed_dat_path}; copy the matching game map file into place or pass --fixed-dat" ) base_items = load_map_items(fixed_dat_path, args.map_index) log_progress(f"loaded {len(base_items)} fixed records from {fixed_dat_path}") base_item_summary = summarize_render_classes(base_items, shape_infos) render_items = collect_render_items( base_items, shape_infos, globs, include_editor=args.include_editor, expand_globs=not args.no_globs, world_rect=world_rect, include_roofs=args.include_roofs, include_hidden_markers=args.include_hidden_markers, progress=log_progress if progress_enabled else None, checkpoint_every=args.progress_every, ) if not render_items: raise ValueError("no renderable items were found for the selected map") usage_info = map_usage_info(args.game, args.map_index, base_items, render_items) min_left, min_top, max_right, max_bottom, prepared, occluded_count, invalid_item_count, invalid_items = prepare_sorted_items( render_items, shape_archive, shape_infos, progress=log_progress if progress_enabled else None, checkpoint_every=args.progress_every, max_invalid_details=args.invalid_detail_limit, ) if not prepared: raise ValueError("no valid shape/frame pairs were renderable for the selected map") width = max_right - min_left height = max_bottom - min_top if width <= 0 or height <= 0: raise ValueError("computed image bounds are invalid") if args.max_pixels > 0 and width * height > args.max_pixels: raise ValueError( f"image would be {width}x{height} = {width * height} pixels; use --world-rect or raise --max-pixels" ) output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) buffer = rgba_buffer(width, height, DEFAULT_BACKGROUND) for node_index, node in enumerate(prepared, start=1): blit_frame( buffer, width, height, node.left - min_left, node.top - min_top, node.frame, node.pixels, palette, flipped=bool(node.item.flags & FLAG_FLIPPED), ) if progress_enabled and args.progress_every > 0 and node_index % args.progress_every == 0: log_progress(f"blit painted={node_index} of {len(prepared)}") write_png_rgba(output_path, width, height, buffer) log_progress(f"wrote PNG {output_path} ({width}x{height})") used_shapes = sorted({item.shape for item in render_items}) metadata = { "game": args.game, "static_dir": str(static_dir), "fixed_dat": str(fixed_dat_path), "map": args.map_index, "raw_item_count": len(base_items), "item_count": len(render_items), "painted_item_count": len(prepared), "occluded_item_count": occluded_count, "invalid_item_count": invalid_item_count, "invalid_items": [ { "shape": item.shape, "frame": item.frame, "x": item.x, "y": item.y, "z": item.z, "source": item.source, "reason": item.reason, } for item in invalid_items ], "used_shape_count": len(used_shapes), "used_shapes": used_shapes, "usage": usage_info, "base_item_summary": base_item_summary, "sorter": "scummvm_dependency_graph", "filters": { "glob_expansion": not args.no_globs, "editor_shapes_included": args.include_editor, "roofs_included": args.include_roofs, "hidden_markers_included": args.include_hidden_markers, }, "bounds": { "screen_left": min_left, "screen_top": min_top, "screen_right": max_right, "screen_bottom": max_bottom, "width": width, "height": height, }, "world_rect": list(world_rect) if world_rect else None, } if args.metadata: metadata_path = Path(args.metadata) metadata_path.parent.mkdir(parents=True, exist_ok=True) metadata_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8") print(json.dumps(metadata, indent=2)) return 0