Add new modules for Crusader map rendering and processing
- 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.
This commit is contained in:
parent
af5b77ea13
commit
82ae89865a
47 changed files with 1602 additions and 1562 deletions
261
tools/crusader_map/cli.py
Normal file
261
tools/crusader_map/cli.py
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue