- Implemented `formats.py` to define data structures and functions for handling map data, including reading and decoding shape and map items. - Created `png.py` for generating PNG images from shape frames and pixel data. - Developed `sorting.py` to manage the sorting and rendering order of map items based on their properties and spatial relationships. - Introduced `render_all_maps.py` to facilitate the rendering of all maps for specified games, including command-line argument parsing and subprocess management for rendering tasks.
261 lines
9.9 KiB
Python
261 lines
9.9 KiB
Python
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
|