Crusader_Decomp/tools/crusader_map/cli.py

261 lines
9.9 KiB
Python
Raw Permalink Normal View History

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