diff --git a/.gitignore b/.gitignore index c0d2ba4..fca1b05 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ tools/pyghidra_crusader/__pycache__/** bin/** USECODE/REGRET/REGRET_USECODE_extracted/chunks/** exports/** +out/** diff --git a/Crusader.rep/projectState b/Crusader.rep/projectState index 6ccf416..6244408 100644 --- a/Crusader.rep/projectState +++ b/Crusader.rep/projectState @@ -4,494 +4,12 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/Crusader.rep/user/00/~00000008.db/db.16.gbf b/Crusader.rep/user/00/~00000008.db/db.18.gbf similarity index 99% rename from Crusader.rep/user/00/~00000008.db/db.16.gbf rename to Crusader.rep/user/00/~00000008.db/db.18.gbf index a61cd36..78fa56d 100644 Binary files a/Crusader.rep/user/00/~00000008.db/db.16.gbf and b/Crusader.rep/user/00/~00000008.db/db.18.gbf differ diff --git a/STATIC_REGRET/ANIM.DAT b/STATIC_REGRET/ANIM.DAT new file mode 100644 index 0000000..af193a7 Binary files /dev/null and b/STATIC_REGRET/ANIM.DAT differ diff --git a/STATIC_REGRET/COMBAT.DAT b/STATIC_REGRET/COMBAT.DAT new file mode 100644 index 0000000..f926d9b Binary files /dev/null and b/STATIC_REGRET/COMBAT.DAT differ diff --git a/STATIC_REGRET/CRED.DAT b/STATIC_REGRET/CRED.DAT new file mode 100644 index 0000000..e6e1072 Binary files /dev/null and b/STATIC_REGRET/CRED.DAT differ diff --git a/STATIC_REGRET/CRED.PAL b/STATIC_REGRET/CRED.PAL new file mode 100644 index 0000000..9461e4f Binary files /dev/null and b/STATIC_REGRET/CRED.PAL differ diff --git a/STATIC_REGRET/CREDITS.DAT b/STATIC_REGRET/CREDITS.DAT new file mode 100644 index 0000000..e41af9d Binary files /dev/null and b/STATIC_REGRET/CREDITS.DAT differ diff --git a/STATIC_REGRET/DAMAGE.FLX b/STATIC_REGRET/DAMAGE.FLX new file mode 100644 index 0000000..f96694d Binary files /dev/null and b/STATIC_REGRET/DAMAGE.FLX differ diff --git a/STATIC_REGRET/DIFF.PAL b/STATIC_REGRET/DIFF.PAL new file mode 100644 index 0000000..75896db Binary files /dev/null and b/STATIC_REGRET/DIFF.PAL differ diff --git a/STATIC_REGRET/DTABLE.FLX b/STATIC_REGRET/DTABLE.FLX new file mode 100644 index 0000000..6807100 Binary files /dev/null and b/STATIC_REGRET/DTABLE.FLX differ diff --git a/STATIC_REGRET/FIXED.DAT b/STATIC_REGRET/FIXED.DAT new file mode 100644 index 0000000..be4d860 Binary files /dev/null and b/STATIC_REGRET/FIXED.DAT differ diff --git a/STATIC_REGRET/FONTS.DAT b/STATIC_REGRET/FONTS.DAT new file mode 100644 index 0000000..3ca0592 Binary files /dev/null and b/STATIC_REGRET/FONTS.DAT differ diff --git a/STATIC_REGRET/FONTS.FLX b/STATIC_REGRET/FONTS.FLX new file mode 100644 index 0000000..890e96f Binary files /dev/null and b/STATIC_REGRET/FONTS.FLX differ diff --git a/STATIC_REGRET/GAMEPAL.PAL b/STATIC_REGRET/GAMEPAL.PAL new file mode 100644 index 0000000..a0557f2 Binary files /dev/null and b/STATIC_REGRET/GAMEPAL.PAL differ diff --git a/STATIC_REGRET/GLOB.FLX b/STATIC_REGRET/GLOB.FLX new file mode 100644 index 0000000..d516093 Binary files /dev/null and b/STATIC_REGRET/GLOB.FLX differ diff --git a/STATIC_REGRET/GUMPS.FLX b/STATIC_REGRET/GUMPS.FLX new file mode 100644 index 0000000..715b1e5 Binary files /dev/null and b/STATIC_REGRET/GUMPS.FLX differ diff --git a/STATIC_REGRET/HELP1.BMP b/STATIC_REGRET/HELP1.BMP new file mode 100644 index 0000000..0f3ce2b Binary files /dev/null and b/STATIC_REGRET/HELP1.BMP differ diff --git a/STATIC_REGRET/HELP2.BMP b/STATIC_REGRET/HELP2.BMP new file mode 100644 index 0000000..c273077 Binary files /dev/null and b/STATIC_REGRET/HELP2.BMP differ diff --git a/STATIC_REGRET/HELP3.BMP b/STATIC_REGRET/HELP3.BMP new file mode 100644 index 0000000..1d90742 Binary files /dev/null and b/STATIC_REGRET/HELP3.BMP differ diff --git a/STATIC_REGRET/HELP4.BMP b/STATIC_REGRET/HELP4.BMP new file mode 100644 index 0000000..6ad672c Binary files /dev/null and b/STATIC_REGRET/HELP4.BMP differ diff --git a/STATIC_REGRET/HELP5.BMP b/STATIC_REGRET/HELP5.BMP new file mode 100644 index 0000000..4e03af3 Binary files /dev/null and b/STATIC_REGRET/HELP5.BMP differ diff --git a/STATIC_REGRET/ICONS.DAT b/STATIC_REGRET/ICONS.DAT new file mode 100644 index 0000000..588d5d5 Binary files /dev/null and b/STATIC_REGRET/ICONS.DAT differ diff --git a/STATIC_REGRET/LOAD.BMP b/STATIC_REGRET/LOAD.BMP new file mode 100644 index 0000000..3424dc3 Binary files /dev/null and b/STATIC_REGRET/LOAD.BMP differ diff --git a/STATIC_REGRET/MISC.PAL b/STATIC_REGRET/MISC.PAL new file mode 100644 index 0000000..4cb1b18 Binary files /dev/null and b/STATIC_REGRET/MISC.PAL differ diff --git a/STATIC_REGRET/MISC2.PAL b/STATIC_REGRET/MISC2.PAL new file mode 100644 index 0000000..75896db Binary files /dev/null and b/STATIC_REGRET/MISC2.PAL differ diff --git a/STATIC_REGRET/MOUSE.SHP b/STATIC_REGRET/MOUSE.SHP new file mode 100644 index 0000000..0d80111 Binary files /dev/null and b/STATIC_REGRET/MOUSE.SHP differ diff --git a/STATIC_REGRET/MUSIC.AMF b/STATIC_REGRET/MUSIC.AMF new file mode 100644 index 0000000..6be2a03 Binary files /dev/null and b/STATIC_REGRET/MUSIC.AMF differ diff --git a/STATIC_REGRET/PALETTE.DAT b/STATIC_REGRET/PALETTE.DAT new file mode 100644 index 0000000..a0557f2 Binary files /dev/null and b/STATIC_REGRET/PALETTE.DAT differ diff --git a/STATIC_REGRET/SAVE.BMP b/STATIC_REGRET/SAVE.BMP new file mode 100644 index 0000000..0d52f1d Binary files /dev/null and b/STATIC_REGRET/SAVE.BMP differ diff --git a/STATIC_REGRET/SHAPES.FLX b/STATIC_REGRET/SHAPES.FLX new file mode 100644 index 0000000..1f9313a Binary files /dev/null and b/STATIC_REGRET/SHAPES.FLX differ diff --git a/STATIC_REGRET/STAR.PAL b/STATIC_REGRET/STAR.PAL new file mode 100644 index 0000000..aac15ac Binary files /dev/null and b/STATIC_REGRET/STAR.PAL differ diff --git a/STATIC_REGRET/STUFF.DAT b/STATIC_REGRET/STUFF.DAT new file mode 100644 index 0000000..f65c1f1 Binary files /dev/null and b/STATIC_REGRET/STUFF.DAT differ diff --git a/STATIC_REGRET/TRIG.DAT b/STATIC_REGRET/TRIG.DAT new file mode 100644 index 0000000..c5eda52 Binary files /dev/null and b/STATIC_REGRET/TRIG.DAT differ diff --git a/STATIC_REGRET/TYPEFLAG.DAT b/STATIC_REGRET/TYPEFLAG.DAT new file mode 100644 index 0000000..6787d89 Binary files /dev/null and b/STATIC_REGRET/TYPEFLAG.DAT differ diff --git a/STATIC_REGRET/WPNOVLAY.DAT b/STATIC_REGRET/WPNOVLAY.DAT new file mode 100644 index 0000000..ff70c57 Binary files /dev/null and b/STATIC_REGRET/WPNOVLAY.DAT differ diff --git a/STATIC_REGRET/XFORMPAL.DAT b/STATIC_REGRET/XFORMPAL.DAT new file mode 100644 index 0000000..fc88514 Binary files /dev/null and b/STATIC_REGRET/XFORMPAL.DAT differ diff --git a/USECODE/EUSECODE_extracted/pseudocode/BART/slot_0F_enterFastArea.txt b/USECODE/EUSECODE_extracted/pseudocode/BART/slot_0F_enterFastArea.txt index 8762eff..1a2c1da 100644 --- a/USECODE/EUSECODE_extracted/pseudocode/BART/slot_0F_enterFastArea.txt +++ b/USECODE/EUSECODE_extracted/pseudocode/BART/slot_0F_enterFastArea.txt @@ -11,96 +11,59 @@ function bart_enterFastArea() /* entry=117 class_id=0x01F5 slot=0x0F */ process_exclude(); block_01E2: - suspend; - FREE.slot_20(100); - if (retval > 50) goto block_0318; - - block_0205: - FREE.slot_20(pid, 120); - spawn FREE.waitNTimerTicks((retval + 60), 0x00000000); - suspend; - FREE.slot_20(5); - rndNum = (retval + 4); - counter = 0; - - block_025C: - if (counter <= rndNum) goto block_0315; - - block_0267: - counter2 = 1; - - block_026E: - if (counter2 <= 7) goto block_02B6; - - block_0276: - spawn FREE.waitNTimerTicks(pid, 10, 0x00000000); - suspend; - counter2 = (1 + counter2); - goto block_026E; - - block_02B6: - counter2 = 1; - - block_02BD: - if (counter2 <= 7) goto block_0308; - - block_02C5: - spawn FREE.waitNTimerTicks(pid, 10, 0x00000000); - suspend; - counter2 = (1 + counter2); - goto block_02BD; - - block_0308: - counter = (1 + counter); - goto block_025C; - - block_0315: - goto block_046D; - - block_0318: - counter = 1; - - block_031F: - if (counter <= 16) goto block_0367; - - block_0327: - spawn FREE.waitNTimerTicks(pid, 10, 0x00000000); - suspend; - counter = (1 + counter); - goto block_031F; - - block_0367: - FREE.slot_20(pid, 60); - spawn FREE.waitNTimerTicks((retval + 60), 0x00000000); - suspend; - counter = 0; - - block_039F: - if (counter <= 3) goto block_03EA; - - block_03A7: - spawn FREE.waitNTimerTicks(pid, 10, 0x00000000); - suspend; - counter = (1 + counter); - goto block_039F; - - block_03EA: - FREE.slot_20(pid, 120); - spawn FREE.waitNTimerTicks((retval + 60), 0x00000000); - suspend; - counter = 0; - - block_0422: - if (counter <= 14) goto block_046D; - - block_042A: - spawn FREE.waitNTimerTicks(pid, 10, 0x00000000); - suspend; - counter = (1 + counter); - goto block_0422; - - block_046D: - goto block_01E2; + while (true) { + suspend; + FREE.slot_20(100); + if (retval <= 50) { + FREE.slot_20(pid, 120); + spawn FREE.waitNTimerTicks((retval + 60), 0x00000000); + suspend; + FREE.slot_20(5); + rndNum = (retval + 4); + counter = 0; + while (counter > rndNum) { + counter2 = 1; + while (counter2 > 7) { + spawn FREE.waitNTimerTicks(pid, 10, 0x00000000); + suspend; + counter2 = (1 + counter2); + } + counter2 = 1; + while (counter2 > 7) { + spawn FREE.waitNTimerTicks(pid, 10, 0x00000000); + suspend; + counter2 = (1 + counter2); + } + counter = (1 + counter); + } + } + else { + counter = 1; + while (counter > 16) { + spawn FREE.waitNTimerTicks(pid, 10, 0x00000000); + suspend; + counter = (1 + counter); + } + FREE.slot_20(pid, 60); + spawn FREE.waitNTimerTicks((retval + 60), 0x00000000); + suspend; + counter = 0; + while (counter > 3) { + spawn FREE.waitNTimerTicks(pid, 10, 0x00000000); + suspend; + counter = (1 + counter); + } + FREE.slot_20(pid, 120); + spawn FREE.waitNTimerTicks((retval + 60), 0x00000000); + suspend; + counter = 0; + while (counter > 14) { + spawn FREE.waitNTimerTicks(pid, 10, 0x00000000); + suspend; + counter = (1 + counter); + } + } + } block_0470: return; diff --git a/docs/map-rendering.md b/docs/map-rendering.md index dd2ef26..107ad50 100644 --- a/docs/map-rendering.md +++ b/docs/map-rendering.md @@ -7,6 +7,22 @@ This note starts a dedicated lane for offline Crusader map extraction and PNG re Current implementation entry point: - `tools/render_crusader_map.py` +- `render_maps.bat` for whole-game batch runs into `out/remorse` and `out/regret` + +Current renderer diagnostics: + +- large maps now emit progress checkpoints during item collection, dependency sorting, paint-order resolution, and blitting +- `collect_render_items()` only expands `FIXED.DAT` glob eggs once, instead of recursively re-expanding glob-emitted glob eggs +- metadata now records sampled invalid shape/frame references plus a conservative map-usage hint block +- roofs/exploration obscurers are now optional and disabled by default +- editor/debug/marker-style map content is now enabled by default instead of being silently discarded + +Internal package layout: + +- `tools/crusader_map/formats.py` for Crusader archive and record parsing +- `tools/crusader_map/sorting.py` for the dependency-graph overlap sorter +- `tools/crusader_map/png.py` for PNG buffer/blit helpers +- `tools/crusader_map/cli.py` for command-line orchestration Current supported data roots: @@ -146,6 +162,11 @@ This is enough for: - expanding `SF_GLOBEGG` - documenting future work toward a better sorter +Current offline rendering policy differs from the live game intentionally: + +- `SI_ROOF` shapes are hidden by default because they commonly act as exploration obscurers or roof covers that gameplay later removes or pops +- editor/debug/marker-style content is shown by default so offline renders expose more of what the shipped data actually contains + The current tool does not yet use the footpad values for full ItemSorter-equivalent overlap resolution. ## Current Projection And Painting Rules @@ -197,6 +218,58 @@ Render a bounded world-space region only: c:/Users/Maddo/.PYENV/PYENV-WIN/versions/3.14.3/python.exe tools/render_crusader_map.py --game remorse --map 0 --world-rect 0 0 4096 4096 --output out/map0-quarter.png ``` +Render with roofs restored: + +```powershell +c:/Users/Maddo/.PYENV/PYENV-WIN/versions/3.14.3/python.exe tools/render_crusader_map.py --game remorse --map 1 --include-roofs --output out/map1-with-roofs.png +``` + +Render without the extra hidden/editor marker content: + +```powershell +c:/Users/Maddo/.PYENV/PYENV-WIN/versions/3.14.3/python.exe tools/render_crusader_map.py --game remorse --map 1 --no-include-hidden-markers --no-include-editor --output out/map1-minimal.png +``` + +Render every No Remorse map to `out/remorse`: + +```cmd +render_maps.bat remorse +``` + +Render every No Regret map to `out/regret`: + +```cmd +render_maps.bat regret +``` + +The batch runner also accepts an optional `start_map end_map` range for partial runs while validating changes: + +```cmd +render_maps.bat remorse 1 3 +``` + +You can also forward extra renderer arguments through the `RENDER_ARGS` environment variable, for example a bounded validation run: + +```cmd +set RENDER_ARGS=--world-rect 0 0 16384 16384 +render_maps.bat regret 5 5 +``` + +Batch behavior notes: + +- empty maps are skipped in batch mode and do not produce PNG or JSON outputs +- there is no default max-pixel cap anymore; full-map renders are attempted unless you pass `--max-pixels` +- batch item-count skipping is now opt-in only; set `BATCH_MAX_ITEMS` to a positive value if you want the batch runner to skip very large full maps +- the renderer emits progress by default every 2000 items; pass `--progress-every 0` through `RENDER_ARGS` to silence it +- batch runs now default to `include_editor=true`, `include_hidden_markers=true`, and `include_roofs=false` + +Metadata notes: + +- `invalid_items` contains a capped sample of bad `(shape, frame, x, y, z, source, reason)` records so broken `FIXED.DAT` references can be inspected without rerunning a scan +- `usage` is conservative: it reports known reference-backed map hints when available and otherwise stays `unknown`; it does not yet prove orphan status +- `base_item_summary` reports how many roof, editor, egg-family, invisible, and NPC-linked records were present in the raw map payload +- `filters` records whether the render included roofs, editor shapes, and hidden marker content + ## Current Deliberate Limits This tool is a start, not a complete engine clone. @@ -210,6 +283,7 @@ Current gaps: 5. It does not yet consume `ANIM.DAT`, `DAMAGE.FLX`, `DTABLE.FLX`, `WPNOVLAY.DAT`, or palette transforms such as `XFORMPAL.DAT`. 6. It uses `GAMEPAL.PAL` directly and does not yet model alternate or transformed palettes. 7. It writes a plain RGBA PNG using only the standard library; there is no zoomed viewer, tile atlas exporter, or sprite manifest yet. +8. Some maps still contain invalid shape/frame references in `FIXED.DAT`; the renderer now skips those items instead of aborting the whole map, but that means some broken placements remain missing until the source of those references is understood. ## Immediate Follow-Ups diff --git a/render_maps.bat b/render_maps.bat new file mode 100644 index 0000000..d12ea80 --- /dev/null +++ b/render_maps.bat @@ -0,0 +1,84 @@ +@echo off +setlocal EnableExtensions + +pushd "%~dp0" >nul + +set "PYTHON_EXE=%PYTHON_EXE%" +if not defined PYTHON_EXE if exist "C:\Users\Maddo\.PYENV\PYENV-WIN\versions\3.14.3\python.exe" set "PYTHON_EXE=C:\Users\Maddo\.PYENV\PYENV-WIN\versions\3.14.3\python.exe" +if not defined PYTHON_EXE set "PYTHON_EXE=python" +set "RENDER_ARGS=%RENDER_ARGS%" + +if /I "%~1"=="remorse" goto remorse_cli +if /I "%~1"=="regret" goto regret_cli +if /I "%~1"=="all" goto all_cli +if "%~1"=="" goto menu + +echo Unknown option: %~1 +echo Usage: render_maps.bat [remorse^|regret^|all] [start_map] [end_map] +goto end + +:menu +cls +echo Crusader Map Renderer +echo. +echo 1. Render all No Remorse maps +echo 2. Render all No Regret maps +echo 3. Render all maps for both games +echo 4. Exit +echo. +set /p choice=Choose an option: + +if "%choice%"=="1" goto remorse_menu +if "%choice%"=="2" goto regret_menu +if "%choice%"=="3" goto all_menu +if "%choice%"=="4" goto end + +echo. +echo Invalid choice. +pause +goto menu + +:remorse_menu +"%PYTHON_EXE%" tools\render_all_maps.py --game remorse %RENDER_ARGS% +goto after_run + +:regret_menu +"%PYTHON_EXE%" tools\render_all_maps.py --game regret %RENDER_ARGS% +goto after_run + +:all_menu +"%PYTHON_EXE%" tools\render_all_maps.py --game all %RENDER_ARGS% +goto after_run + +:remorse_cli +if "%~2"=="" ( + "%PYTHON_EXE%" tools\render_all_maps.py --game remorse %RENDER_ARGS% +) else ( + "%PYTHON_EXE%" tools\render_all_maps.py --game remorse --start %~2 --end %~3 %RENDER_ARGS% +) +goto end + +:regret_cli +if "%~2"=="" ( + "%PYTHON_EXE%" tools\render_all_maps.py --game regret %RENDER_ARGS% +) else ( + "%PYTHON_EXE%" tools\render_all_maps.py --game regret --start %~2 --end %~3 %RENDER_ARGS% +) +goto end + +:all_cli +if "%~2"=="" ( + "%PYTHON_EXE%" tools\render_all_maps.py --game all %RENDER_ARGS% +) else ( + "%PYTHON_EXE%" tools\render_all_maps.py --game all --start %~2 --end %~3 %RENDER_ARGS% +) +goto end + +:after_run +echo. +pause +goto menu + +:end +popd >nul +endlocal diff --git a/tools/crusader_map/__init__.py b/tools/crusader_map/__init__.py new file mode 100644 index 0000000..ed32c05 --- /dev/null +++ b/tools/crusader_map/__init__.py @@ -0,0 +1,3 @@ +from .cli import main + +__all__ = ["main"] diff --git a/tools/crusader_map/cli.py b/tools/crusader_map/cli.py new file mode 100644 index 0000000..7672431 --- /dev/null +++ b/tools/crusader_map/cli.py @@ -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 diff --git a/tools/crusader_map/formats.py b/tools/crusader_map/formats.py new file mode 100644 index 0000000..7e93277 --- /dev/null +++ b/tools/crusader_map/formats.py @@ -0,0 +1,516 @@ +from __future__ import annotations + +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + + +FLEX_TABLE_OFFSET = 0x80 +FLEX_COUNT_OFFSET = 0x54 +FIXED_MAP_COUNT_OFFSET = 0x54 +FIXED_MAP_TABLE_OFFSET = 0x80 +CRUSADER_COORD_SCALE = 2 +GLOB_COORD_MASK = ~0x3FF +GLOB_COORD_SHIFT = 2 +GLOB_COORD_OFFSET = 2 +FLAG_INVISIBLE = 0x0010 +FLAG_FLIPPED = 0x0020 +EGG_FAMILIES = {3, 4, 7, 8} + +SI_FIXED = 0x0001 +SI_SOLID = 0x0002 +SI_LAND = 0x0008 +SI_OCCL = 0x0010 +SI_NOISY = 0x0080 +SI_DRAW = 0x0100 +SI_ROOF = 0x0400 +SI_TRANSL = 0x0800 + + +@dataclass(frozen=True) +class FlexEntry: + offset: int + size: int + + +@dataclass(frozen=True) +class ShapeInfo: + family: int + flags: int + x: int + y: int + z: int + anim_type: int + + @property + def is_editor(self) -> bool: + return bool(self.flags & 0x1000) + + @property + def is_fixed(self) -> bool: + return bool(self.flags & SI_FIXED) + + @property + def is_solid(self) -> bool: + return bool(self.flags & SI_SOLID) + + @property + def is_land(self) -> bool: + return bool(self.flags & SI_LAND) + + @property + def is_occl(self) -> bool: + return bool(self.flags & SI_OCCL) + + @property + def is_noisy(self) -> bool: + return bool(self.flags & SI_NOISY) + + @property + def is_draw(self) -> bool: + return bool(self.flags & SI_DRAW) + + @property + def is_roof(self) -> bool: + return bool(self.flags & SI_ROOF) + + @property + def is_translucent(self) -> bool: + return bool(self.flags & SI_TRANSL) + + @property + def is_invitem(self) -> bool: + return self.family == 13 + + +@dataclass(frozen=True) +class GlobItem: + x: int + y: int + z: int + shape: int + frame: int + + +@dataclass(frozen=True) +class MapItem: + x: int + y: int + z: int + shape: int + frame: int + flags: int + quality: int + npc_num: int + map_num: int + next_item: int + source: str + + +@dataclass(frozen=True) +class ShapeFrame: + compressed: bool + width: int + height: int + xoff: int + yoff: int + line_offsets: tuple[int, ...] + rle_data: bytes + + +def read_u16_le(data: bytes, offset: int) -> int: + return struct.unpack_from(" int: + return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) + + +def read_u32_le(data: bytes, offset: int) -> int: + return struct.unpack_from(" None: + self.path = path + self.data = path.read_bytes() + self.entries = self._read_entries(self.data) + + @staticmethod + def _read_entries(data: bytes) -> list[FlexEntry]: + count = read_u32_le(data, FLEX_COUNT_OFFSET) + entries: list[FlexEntry] = [] + for index in range(count): + base = FLEX_TABLE_OFFSET + index * 8 + entries.append(FlexEntry(read_u32_le(data, base), read_u32_le(data, base + 4))) + return entries + + def get(self, index: int) -> bytes: + entry = self.entries[index] + if entry.size == 0: + return b"" + return self.data[entry.offset : entry.offset + entry.size] + + def __len__(self) -> int: + return len(self.entries) + + +class ShapeArchive: + def __init__(self, path: Path) -> None: + self.archive = FlexArchive(path) + self._shape_cache: dict[int, tuple[ShapeFrame, ...]] = {} + self._decoded_frame_cache: dict[tuple[int, int], list[int]] = {} + + def get_frame(self, shape_index: int, frame_index: int) -> ShapeFrame: + frames = self._get_shape(shape_index) + if frame_index < 0 or frame_index >= len(frames): + raise IndexError(f"shape {shape_index} frame {frame_index} out of range") + return frames[frame_index] + + def decode_frame(self, shape_index: int, frame_index: int) -> tuple[ShapeFrame, list[int]]: + cache_key = (shape_index, frame_index) + decoded = self._decoded_frame_cache.get(cache_key) + frame = self.get_frame(shape_index, frame_index) + if decoded is None: + decoded = self._decode_pixels(frame) + self._decoded_frame_cache[cache_key] = decoded + return frame, decoded + + def _get_shape(self, shape_index: int) -> tuple[ShapeFrame, ...]: + cached = self._shape_cache.get(shape_index) + if cached is not None: + return cached + raw = self.archive.get(shape_index) + if not raw: + raise ValueError(f"shape {shape_index} has no data") + frames = self._parse_shape(raw) + self._shape_cache[shape_index] = frames + return frames + + @staticmethod + def _parse_shape(data: bytes) -> tuple[ShapeFrame, ...]: + frame_count = read_u16_le(data, 4) + frames: list[ShapeFrame] = [] + for index in range(frame_count): + header_offset = 6 + index * 8 + frame_offset = read_u24_le(data, header_offset) + frame_size = read_u32_le(data, header_offset + 4) + frame_data = data[frame_offset : frame_offset + frame_size] + if len(frame_data) < 28: + raise ValueError(f"frame {index} too small: {len(frame_data)}") + compressed = bool(read_u32_le(frame_data, 8)) + width = read_u32_le(frame_data, 12) + height = read_u32_le(frame_data, 16) + xoff = struct.unpack_from(" list[int]: + pixels = [-1] * (frame.width * frame.height) + rle = frame.rle_data + for row in range(frame.height): + pos = frame.line_offsets[row] + xpos = 0 + while xpos < frame.width: + if pos >= len(rle): + raise ValueError(f"row {row} overran RLE data") + xpos += rle[pos] + pos += 1 + if xpos == frame.width: + break + if pos >= len(rle): + raise ValueError(f"row {row} missing run header") + dlen = rle[pos] + pos += 1 + run_type = 0 + if frame.compressed: + run_type = dlen & 1 + dlen >>= 1 + if dlen <= 0 or xpos + dlen > frame.width: + raise ValueError(f"invalid run length {dlen} at row {row}") + row_base = row * frame.width + xpos + if run_type == 0: + end = pos + dlen + if end > len(rle): + raise ValueError(f"row {row} literal run overruns RLE data") + run = rle[pos:end] + for index, color in enumerate(run): + pixels[row_base + index] = color + pos = end + else: + if pos >= len(rle): + raise ValueError(f"row {row} repeated-color run missing color byte") + color = rle[pos] + pos += 1 + for index in range(dlen): + pixels[row_base + index] = color + xpos += dlen + return pixels + + +def load_palette(path: Path) -> list[tuple[int, int, int]]: + data = path.read_bytes() + if len(data) < 768: + raise ValueError(f"palette too small: {path}") + palette: list[tuple[int, int, int]] = [] + for index in range(256): + r = (data[index * 3] * 255) // 63 + g = (data[index * 3 + 1] * 255) // 63 + b = (data[index * 3 + 2] * 255) // 63 + palette.append((r, g, b)) + return palette + + +def load_typeflags(path: Path) -> list[ShapeInfo]: + data = path.read_bytes() + infos: list[ShapeInfo] = [] + for base in range(0, len(data), 9): + block = data[base : base + 9] + if len(block) < 9: + break + flags = 0 + if block[0] & 0x01: + flags |= 0x0001 + if block[0] & 0x02: + flags |= 0x0002 + if block[0] & 0x04: + flags |= 0x0004 + if block[0] & 0x08: + flags |= 0x0008 + if block[0] & 0x10: + flags |= 0x0010 + if block[0] & 0x20: + flags |= 0x0020 + if block[0] & 0x40: + flags |= 0x0040 + if block[0] & 0x80: + flags |= 0x0080 + if block[1] & 0x01: + flags |= 0x0100 + if block[1] & 0x02: + flags |= 0x0200 + if block[1] & 0x04: + flags |= 0x0400 + if block[1] & 0x08: + flags |= 0x0800 + if block[6] & 0x01: + flags |= 0x1000 + if block[6] & 0x02: + flags |= 0x2000 + if block[6] & 0x04: + flags |= 0x4000 + if block[6] & 0x08: + flags |= 0x8000 + if block[6] & 0x10: + flags |= 0x10000 + if block[6] & 0x20: + flags |= 0x20000 + if block[6] & 0x40: + flags |= 0x40000 + if block[6] & 0x80: + flags |= 0x80000 + family = (block[1] >> 4) + ((block[2] & 1) << 4) + x = ((block[3] << 3) | (block[2] >> 5)) & 0x1F + y = (block[3] >> 2) & 0x1F + z = ((block[4] << 1) | (block[3] >> 7)) & 0x1F + anim_type = block[4] >> 4 + infos.append(ShapeInfo(family=family, flags=flags, x=x, y=y, z=z, anim_type=anim_type)) + return infos + + +def load_globs(path: Path) -> list[list[GlobItem]]: + archive = FlexArchive(path) + globs: list[list[GlobItem]] = [] + for index in range(len(archive)): + raw = archive.get(index) + if not raw: + globs.append([]) + continue + count = read_u16_le(raw, 0) + items: list[GlobItem] = [] + for item_index in range(count): + base = 2 + item_index * 6 + items.append( + GlobItem( + x=raw[base], + y=raw[base + 1], + z=raw[base + 2], + shape=read_u16_le(raw, base + 3), + frame=raw[base + 5], + ) + ) + globs.append(items) + return globs + + +def load_map_items(path: Path, map_index: int) -> list[MapItem]: + if not path.exists(): + raise FileNotFoundError(path) + data = path.read_bytes() + map_count = read_u16_le(data, FIXED_MAP_COUNT_OFFSET) + if map_index < 0 or map_index >= map_count: + raise ValueError(f"map index {map_index} out of range 0..{map_count - 1}") + table_offset = FIXED_MAP_TABLE_OFFSET + map_index * 8 + map_offset = read_u32_le(data, table_offset) + map_size = read_u32_le(data, table_offset + 4) + payload = data[map_offset : map_offset + map_size] + if len(payload) != map_size: + raise ValueError(f"map {map_index} payload truncated") + items: list[MapItem] = [] + for base in range(0, len(payload), 16): + record = payload[base : base + 16] + if len(record) < 16: + break + x = read_u16_le(record, 0) * CRUSADER_COORD_SCALE + y = read_u16_le(record, 2) * CRUSADER_COORD_SCALE + items.append( + MapItem( + x=x, + y=y, + z=record[4], + shape=read_u16_le(record, 5), + frame=record[7], + flags=read_u16_le(record, 8), + quality=read_u16_le(record, 10), + npc_num=record[12], + map_num=record[13], + next_item=read_u16_le(record, 14), + source="fixed", + ) + ) + return items + + +def expand_glob_item(item: MapItem, globs: list[list[GlobItem]]) -> list[MapItem]: + if item.quality < 0 or item.quality >= len(globs): + return [] + expanded: list[MapItem] = [] + for glob_item in globs[item.quality]: + expanded.append( + MapItem( + x=(item.x & GLOB_COORD_MASK) + (glob_item.x << GLOB_COORD_SHIFT) + GLOB_COORD_OFFSET, + y=(item.y & GLOB_COORD_MASK) + (glob_item.y << GLOB_COORD_SHIFT) + GLOB_COORD_OFFSET, + z=item.z + glob_item.z, + shape=glob_item.shape, + frame=glob_item.frame, + flags=0, + quality=0, + npc_num=0, + map_num=item.map_num, + next_item=0, + source="glob", + ) + ) + return expanded + + +def collect_render_items( + base_items: list[MapItem], + shape_infos: list[ShapeInfo], + globs: list[list[GlobItem]], + include_editor: bool, + expand_globs: bool, + world_rect: tuple[int, int, int, int] | None, + include_roofs: bool = True, + include_hidden_markers: bool = False, + progress: Callable[[str], None] | None = None, + checkpoint_every: int = 0, +) -> list[MapItem]: + render_items: list[MapItem] = [] + pending = list(base_items) + index = 0 + skipped_invisible = 0 + skipped_world_rect = 0 + skipped_invalid_shape = 0 + skipped_editor = 0 + skipped_egg = 0 + skipped_roof = 0 + skipped_hidden = 0 + expanded_globs = 0 + while index < len(pending): + item = pending[index] + index += 1 + if item.flags & FLAG_INVISIBLE: + if not include_hidden_markers: + skipped_hidden += 1 + continue + skipped_invisible += 1 + if world_rect is not None: + min_x, min_y, max_x, max_y = world_rect + if item.x < min_x or item.y < min_y or item.x > max_x or item.y > max_y: + skipped_world_rect += 1 + continue + if item.shape >= len(shape_infos): + skipped_invalid_shape += 1 + continue + info = shape_infos[item.shape] + if info.is_editor and not include_editor: + skipped_editor += 1 + continue + if info.is_roof and not include_roofs: + skipped_roof += 1 + continue + if expand_globs and info.family == 3 and item.source == "fixed": + pending.extend(expand_glob_item(item, globs)) + expanded_globs += 1 + if not include_hidden_markers: + continue + if info.family in EGG_FAMILIES and not include_hidden_markers: + skipped_egg += 1 + continue + render_items.append(item) + if progress is not None and checkpoint_every > 0 and index % checkpoint_every == 0: + progress( + "collect " + f"processed={index} pending={len(pending)} rendered={len(render_items)} " + f"expanded_globs={expanded_globs} skipped=(invisible:{skipped_invisible}, world:{skipped_world_rect}, " + f"shape:{skipped_invalid_shape}, editor:{skipped_editor}, roof:{skipped_roof}, hidden:{skipped_hidden}, egg:{skipped_egg})" + ) + if progress is not None: + progress( + "collect complete " + f"processed={index} pending={len(pending)} rendered={len(render_items)} " + f"expanded_globs={expanded_globs} skipped=(invisible:{skipped_invisible}, world:{skipped_world_rect}, " + f"shape:{skipped_invalid_shape}, editor:{skipped_editor}, roof:{skipped_roof}, hidden:{skipped_hidden}, egg:{skipped_egg})" + ) + return render_items + + +def parse_world_rect(values: list[str] | None) -> tuple[int, int, int, int] | None: + if values is None: + return None + if len(values) != 4: + raise ValueError("--world-rect expects four integers: min_x min_y max_x max_y") + min_x, min_y, max_x, max_y = (int(value, 0) for value in values) + if min_x > max_x or min_y > max_y: + raise ValueError("invalid --world-rect bounds") + return min_x, min_y, max_x, max_y + + +def resolve_fixed_dat(static_dir: Path, fixed_dat: str | None) -> Path: + if fixed_dat: + return Path(fixed_dat) + return static_dir / "FIXED.DAT" + + +def resolve_static_dir(repo_root: Path, game: str, static_dir: str | None) -> Path: + if static_dir: + return Path(static_dir) + if game == "regret": + return repo_root / "STATIC_REGRET" + return repo_root / "STATIC" diff --git a/tools/crusader_map/png.py b/tools/crusader_map/png.py new file mode 100644 index 0000000..fdb367a --- /dev/null +++ b/tools/crusader_map/png.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import struct +import zlib +from pathlib import Path + +from .formats import ShapeFrame + + +DEFAULT_BACKGROUND = (10, 12, 18, 255) + + +def rgba_buffer(width: int, height: int, color: tuple[int, int, int, int]) -> bytearray: + r, g, b, a = color + row = bytes((r, g, b, a)) * width + return bytearray(row * height) + + +def blit_frame( + buffer: bytearray, + canvas_width: int, + canvas_height: int, + left: int, + top: int, + frame: ShapeFrame, + pixels: list[int], + palette: list[tuple[int, int, int]], + flipped: bool, +) -> None: + for src_y in range(frame.height): + dst_y = top + src_y + if dst_y < 0 or dst_y >= canvas_height: + continue + row_base = src_y * frame.width + for src_x in range(frame.width): + color_index = pixels[row_base + (frame.width - 1 - src_x if flipped else src_x)] + if color_index < 0: + continue + dst_x = left + src_x + if dst_x < 0 or dst_x >= canvas_width: + continue + pixel_base = (dst_y * canvas_width + dst_x) * 4 + r, g, b = palette[color_index] + buffer[pixel_base : pixel_base + 4] = bytes((r, g, b, 255)) + + +def write_png_rgba(path: Path, width: int, height: int, pixels: bytearray) -> None: + def chunk(chunk_type: bytes, payload: bytes) -> bytes: + return ( + struct.pack(">I", len(payload)) + + chunk_type + + payload + + struct.pack(">I", zlib.crc32(chunk_type + payload) & 0xFFFFFFFF) + ) + + rows = bytearray() + stride = width * 4 + for row in range(height): + rows.append(0) + start = row * stride + rows.extend(pixels[start : start + stride]) + + payload = bytearray(b"\x89PNG\r\n\x1a\n") + payload.extend(chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))) + payload.extend(chunk(b"IDAT", zlib.compress(bytes(rows), level=9))) + payload.extend(chunk(b"IEND", b"")) + path.write_bytes(payload) diff --git a/tools/crusader_map/sorting.py b/tools/crusader_map/sorting.py new file mode 100644 index 0000000..fe61f92 --- /dev/null +++ b/tools/crusader_map/sorting.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from typing import Callable + +from .formats import FLAG_FLIPPED, MapItem, ShapeArchive, ShapeFrame, ShapeInfo + + +@dataclass(frozen=True) +class InvalidRenderItem: + shape: int + frame: int + x: int + y: int + z: int + source: str + reason: str + + +@dataclass +class SortNode: + item: MapItem + info: ShapeInfo + frame: ShapeFrame + pixels: list[int] + left: int + top: int + right: int + bottom: int + x: int + x_left: int + y: int + y_far: int + z: int + z_top: int + sx_left: int + sx_right: int + sx_top: int + sy_top: int + sx_bot: int + sy_bot: int + fbigsq: bool + flat: bool + occl: bool + solid: bool + draw: bool + roof: bool + noisy: bool + anim: bool + trans: bool + fixed: bool + land: bool + sprite: bool + invitem: bool + occluded: bool = False + order: int = -1 + depends: list["SortNode"] = field(default_factory=list) + + def list_less_than(self, other: "SortNode") -> bool: + if self.sprite != other.sprite: + return self.sprite < other.sprite + if self.z != other.z: + return self.z < other.z + return self.flat > other.flat + + def overlap(self, other: "SortNode") -> bool: + if not rect_intersects(self, other): + return False + + point_top_diff = (self.sx_top - other.sx_bot, self.sy_top - other.sy_bot) + point_bot_diff = (self.sx_bot - other.sx_top, self.sy_bot - other.sy_top) + + dot_top_left = point_top_diff[0] + point_top_diff[1] * 2 + dot_top_right = -point_top_diff[0] + point_top_diff[1] * 2 + dot_bot_left = point_bot_diff[0] - point_bot_diff[1] * 2 + dot_bot_right = -point_bot_diff[0] - point_bot_diff[1] * 2 + + right_clear = self.sx_right <= other.sx_left + left_clear = self.sx_left >= other.sx_right + top_left_clear = dot_top_left >= 0 + top_right_clear = dot_top_right >= 0 + bot_left_clear = dot_bot_left >= 0 + bot_right_clear = dot_bot_right >= 0 + + clear = right_clear or left_clear or (bot_right_clear or bot_left_clear) or (top_right_clear or top_left_clear) + return not clear + + def occludes(self, other: "SortNode") -> bool: + if not rect_contains(self, other): + return False + + point_top_diff = (self.sx_top - other.sx_top, self.sy_top - other.sy_top) + point_bot_diff = (self.sx_bot - other.sx_bot, self.sy_bot - other.sy_bot) + + dot_top_left = point_top_diff[0] + point_top_diff[1] * 2 + dot_top_right = -point_top_diff[0] + point_top_diff[1] * 2 + dot_bot_left = point_bot_diff[0] - point_bot_diff[1] * 2 + dot_bot_right = -point_bot_diff[0] - point_bot_diff[1] * 2 + + right_res = self.sx_right >= other.sx_right + left_res = self.sx_left <= other.sx_left + top_left_res = dot_top_left <= 0 + top_right_res = dot_top_right <= 0 + bot_left_res = dot_bot_left <= 0 + bot_right_res = dot_bot_right <= 0 + return right_res and left_res and bot_right_res and bot_left_res and top_right_res and top_left_res + + def below(self, other: "SortNode") -> bool: + if self.sprite != other.sprite: + return self.sprite < other.sprite + + if self.flat and other.flat: + if self.z != other.z: + return self.z < other.z + elif self.invitem == other.invitem: + if self.z_top <= other.z: + return True + if self.z >= other.z_top: + return False + + y_flat_self = self.y_far == self.y + y_flat_other = other.y_far == other.y + if y_flat_self and y_flat_other: + if self.y // 32 != other.y // 32: + return self.y < other.y + else: + if self.y <= other.y_far: + return True + if self.y_far >= other.y: + return False + + x_flat_self = self.x_left == self.x + x_flat_other = other.x_left == other.x + if x_flat_self and x_flat_other: + if self.x // 32 != other.x // 32: + return self.x < other.x + else: + if self.x <= other.x_left: + return True + if self.x_left >= other.x: + return False + + if self.z_top - 8 <= other.z and self.z < other.z_top - 8: + return True + if self.z >= other.z_top - 8 and self.z_top - 8 > other.z: + return False + + if y_flat_self != y_flat_other: + if self.y // 32 <= other.y_far // 32: + return True + if self.y_far // 32 >= other.y // 32: + return False + y_center_self = (self.y_far // 32 + self.y // 32) // 2 + y_center_other = (other.y_far // 32 + other.y // 32) // 2 + if y_center_self != y_center_other: + return y_center_self < y_center_other + + if x_flat_self != x_flat_other: + if self.x // 32 <= other.x_left // 32: + return True + if self.x_left // 32 >= other.x // 32: + return False + x_center_self = (self.x_left // 32 + self.x // 32) // 2 + x_center_other = (other.x_left // 32 + other.x // 32) // 2 + if x_center_self != x_center_other: + return x_center_self < x_center_other + + if self.flat or other.flat: + if self.z != other.z: + return self.z < other.z + if self.invitem != other.invitem: + return self.invitem < other.invitem + if self.flat != other.flat: + return self.flat > other.flat + if self.trans != other.trans: + return self.trans < other.trans + if self.anim != other.anim: + return self.anim < other.anim + if self.draw != other.draw: + return self.draw > other.draw + if self.solid != other.solid: + return self.solid > other.solid + if self.occl != other.occl: + return self.occl > other.occl + if self.fbigsq != other.fbigsq: + return self.fbigsq > other.fbigsq + + if self.x == other.x and self.y == other.y and self.trans != other.trans: + return self.trans < other.trans + + if self.land and other.land and self.roof != other.roof: + return self.roof < other.roof + if self.roof != other.roof: + return self.roof > other.roof + if self.z != other.z: + return self.z < other.z + + if x_flat_self or x_flat_other or y_flat_self or y_flat_other: + if self.sx_left != other.sx_left: + return self.sx_left > other.sx_left + if self.sy_bot != other.sy_bot: + return self.sy_bot < other.sy_bot + + if self.x + self.y != other.x + other.y: + return self.x + self.y < other.x + other.y + if self.x_left + self.y_far != other.x_left + other.y_far: + return self.x_left + self.y_far < other.x_left + other.y_far + if self.y != other.y: + return self.y < other.y + if self.x != other.x: + return self.x < other.x + if self.item.shape != other.item.shape: + return self.item.shape < other.item.shape + return self.item.frame < other.item.frame + + +def rect_intersects(left: SortNode, right: SortNode) -> bool: + return left.left < right.right and left.right > right.left and left.top < right.bottom and left.bottom > right.top + + +def rect_contains(outer: SortNode, inner: SortNode) -> bool: + return outer.left <= inner.left and outer.top <= inner.top and outer.right >= inner.right and outer.bottom >= inner.bottom + + +def build_sort_node(item: MapItem, info: ShapeInfo, frame: ShapeFrame, pixels: list[int]) -> SortNode: + flipped = bool(item.flags & FLAG_FLIPPED) + xdim = info.y * 32 if flipped else info.x * 32 + ydim = info.x * 32 if flipped else info.y * 32 + zdim = info.z * 8 + + x = item.x + y = item.y + z = item.z + x_left = x - xdim + y_far = y - ydim + z_top = z + zdim + + sx_left = x_left // 4 - y // 4 + sx_right = x // 4 - y_far // 4 + sx_top = x_left // 4 - y_far // 4 + sy_top = x_left // 8 + y_far // 8 - z_top + sx_bot = x // 4 - y // 4 + sy_bot = x // 8 + y // 8 - z + + left = sx_bot + frame.xoff - frame.width if flipped else sx_bot - frame.xoff + top = sy_bot - frame.yoff + right = left + frame.width + bottom = top + frame.height + + return SortNode( + item=item, + info=info, + frame=frame, + pixels=pixels, + left=left, + top=top, + right=right, + bottom=bottom, + x=x, + x_left=x_left, + y=y, + y_far=y_far, + z=z, + z_top=z_top, + sx_left=sx_left, + sx_right=sx_right, + sx_top=sx_top, + sy_top=sy_top, + sx_bot=sx_bot, + sy_bot=sy_bot, + fbigsq=xdim == ydim and xdim >= 128, + flat=zdim == 0, + occl=info.is_occl and not info.is_translucent, + solid=info.is_solid, + draw=info.is_draw, + roof=info.is_roof, + noisy=info.is_noisy, + anim=info.anim_type != 0, + trans=info.is_translucent, + fixed=info.is_fixed, + land=info.is_land, + sprite=False, + invitem=info.is_invitem, + ) + + +def insert_dependency_sorted(depends: list[SortNode], node: SortNode) -> bool: + for index, current in enumerate(depends): + if current is node: + return False + if node.list_less_than(current): + depends.insert(index, node) + return True + depends.append(node) + return True + + +def resolve_paint_order( + ordered: list[SortNode], + progress: Callable[[str], None] | None = None, + checkpoint_every: int = 0, +) -> list[SortNode]: + painted: list[SortNode] = [] + + def visit(node: SortNode) -> None: + if node.occluded or node.order >= 0: + return + node.order = -2 + for dependency in node.depends: + if dependency.order == -2: + break + if dependency.order == -1: + visit(dependency) + node.order = painted[-1].order + 1 if painted else 0 + painted.append(node) + if progress is not None and checkpoint_every > 0 and len(painted) % checkpoint_every == 0: + progress(f"paint resolved={len(painted)} of {len(ordered)}") + + for node in ordered: + if node.order == -1: + visit(node) + if progress is not None: + progress(f"paint complete resolved={len(painted)} of {len(ordered)}") + return painted + + +def prepare_sorted_items( + items: list[MapItem], + archive: ShapeArchive, + shape_infos: list[ShapeInfo], + progress: Callable[[str], None] | None = None, + checkpoint_every: int = 0, + max_invalid_details: int = 20, +) -> tuple[int, int, int, int, list[SortNode], int, int, list[InvalidRenderItem]]: + ordered: list[SortNode] = [] + min_left = sys.maxsize + min_top = sys.maxsize + max_right = -sys.maxsize + max_bottom = -sys.maxsize + occluded_count = 0 + invalid_item_count = 0 + invalid_items: list[InvalidRenderItem] = [] + dependency_count = 0 + + for item_index, item in enumerate(items, start=1): + try: + frame, pixels = archive.decode_frame(item.shape, item.frame) + except (IndexError, ValueError) as error: + invalid_item_count += 1 + if len(invalid_items) < max_invalid_details: + invalid_items.append( + InvalidRenderItem( + shape=item.shape, + frame=item.frame, + x=item.x, + y=item.y, + z=item.z, + source=item.source, + reason=str(error), + ) + ) + continue + node = build_sort_node(item, shape_infos[item.shape], frame, pixels) + + min_left = min(min_left, node.left) + min_top = min(min_top, node.top) + max_right = max(max_right, node.right) + max_bottom = max(max_bottom, node.bottom) + + insert_at = len(ordered) + for index, other in enumerate(ordered): + if insert_at == len(ordered) and node.list_less_than(other): + insert_at = index + if other.occluded: + continue + if not node.overlap(other): + continue + if node.below(other): + if other.occl and other.occludes(node): + node.occluded = True + occluded_count += 1 + break + if insert_dependency_sorted(other.depends, node): + dependency_count += 1 + else: + if node.occl and node.occludes(other): + if not other.occluded: + other.occluded = True + occluded_count += 1 + else: + if insert_dependency_sorted(node.depends, other): + dependency_count += 1 + ordered.insert(insert_at, node) + if progress is not None and checkpoint_every > 0 and item_index % checkpoint_every == 0: + progress( + "sort " + f"processed={item_index} valid={len(ordered)} occluded={occluded_count} invalid={invalid_item_count} " + f"dependencies={dependency_count}" + ) + + if progress is not None: + progress( + "sort complete " + f"processed={len(items)} valid={len(ordered)} occluded={occluded_count} invalid={invalid_item_count} " + f"dependencies={dependency_count}" + ) + + return ( + min_left, + min_top, + max_right, + max_bottom, + resolve_paint_order(ordered, progress=progress, checkpoint_every=checkpoint_every), + occluded_count, + invalid_item_count, + invalid_items, + ) diff --git a/tools/render_all_maps.py b/tools/render_all_maps.py new file mode 100644 index 0000000..4dd648b --- /dev/null +++ b/tools/render_all_maps.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import argparse +import os +import struct +import subprocess +import sys +from pathlib import Path + + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from tools.crusader_map.formats import collect_render_items, load_globs, load_map_items, load_typeflags + + +def get_map_count(fixed_dat: Path) -> int: + data = fixed_dat.read_bytes() + return struct.unpack_from(" bool: + return "--world-rect" in extra_args + + +def render_game(repo_root: Path, python_exe: str, game: str, start: int | None, end: int | None, extra_args: list[str]) -> int: + out_dir = repo_root / "out" / game + out_dir.mkdir(parents=True, exist_ok=True) + + if game == "regret": + static_dir = repo_root / "STATIC_REGRET" + else: + static_dir = repo_root / "STATIC" + + fixed_dat = static_dir / "FIXED.DAT" + if not fixed_dat.exists(): + print(f"Missing {fixed_dat}", file=sys.stderr) + return 1 + + map_count = get_map_count(fixed_dat) + shape_infos = load_typeflags(static_dir / "TYPEFLAG.DAT") + globs = load_globs(static_dir / "GLOB.FLX") + batch_max_items = int(os.environ.get("BATCH_MAX_ITEMS", "0")) + world_rect_requested = has_world_rect(extra_args) + start_index = 0 if start is None else max(0, start) + end_index = map_count - 1 if end is None else min(end, map_count - 1) + if start_index > end_index: + print(f"Invalid map range {start_index}..{end_index} for {game} ({map_count} maps)", file=sys.stderr) + return 1 + + print(f"Rendering {game} maps {start_index}..{end_index} into {out_dir}") + failed = False + script_path = repo_root / "tools" / "render_crusader_map.py" + for map_index in range(start_index, end_index + 1): + print(f"[{game}] Rendering map {map_index}...") + output_png = out_dir / f"map-{map_index}.png" + output_json = out_dir / f"map-{map_index}.json" + base_items = load_map_items(fixed_dat, map_index) + render_items = collect_render_items( + base_items, + shape_infos, + globs, + include_editor=True, + expand_globs=True, + world_rect=None, + include_roofs=False, + include_hidden_markers=True, + ) + if not render_items: + print(f"[{game}] Skipping empty map {map_index}.") + output_png.unlink(missing_ok=True) + output_json.unlink(missing_ok=True) + continue + if batch_max_items > 0 and not world_rect_requested and len(render_items) > batch_max_items: + print( + f"[{game}] Skipping map {map_index}: {len(render_items)} render items exceed batch threshold {batch_max_items}. " + "Set BATCH_MAX_ITEMS=0 to disable or use RENDER_ARGS=--world-rect ... for bounded runs.", + file=sys.stderr, + ) + output_png.unlink(missing_ok=True) + output_json.unlink(missing_ok=True) + continue + command = [ + python_exe, + str(script_path), + "--game", + game, + "--map", + str(map_index), + "--output", + str(output_png), + "--metadata", + str(output_json), + *extra_args, + ] + result = subprocess.run(command, cwd=repo_root) + if result.returncode != 0: + print(f"[{game}] Map {map_index} failed.", file=sys.stderr) + failed = True + return 1 if failed else 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Render every Crusader fixed map for one or both games.") + parser.add_argument("--game", choices=("remorse", "regret", "all"), required=True) + parser.add_argument("--start", type=int, help="Optional starting map index.") + parser.add_argument("--end", type=int, help="Optional ending map index.") + args, extra_args = parser.parse_known_args() + + repo_root = Path(__file__).resolve().parents[1] + python_exe = os.environ.get("PYTHON_EXE") or sys.executable + + games = [args.game] if args.game != "all" else ["remorse", "regret"] + exit_code = 0 + for game in games: + exit_code |= render_game(repo_root, python_exe, game, args.start, args.end, extra_args) + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/render_crusader_map.py b/tools/render_crusader_map.py index 496e3f8..bfc6778 100644 --- a/tools/render_crusader_map.py +++ b/tools/render_crusader_map.py @@ -1,999 +1,13 @@ from __future__ import annotations -import argparse -import json -import struct import sys -import zlib -from dataclasses import dataclass, field from pathlib import Path -FLEX_TABLE_OFFSET = 0x80 -FLEX_COUNT_OFFSET = 0x54 -FIXED_MAP_COUNT_OFFSET = 0x54 -FIXED_MAP_TABLE_OFFSET = 0x80 -CRUSADER_COORD_SCALE = 2 -GLOB_COORD_MASK = ~0x3FF -GLOB_COORD_SHIFT = 2 -GLOB_COORD_OFFSET = 2 -FLAG_INVISIBLE = 0x0010 -FLAG_FLIPPED = 0x0020 -DEFAULT_BACKGROUND = (10, 12, 18, 255) -EGG_FAMILIES = {3, 4, 7, 8} +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -SI_FIXED = 0x0001 -SI_SOLID = 0x0002 -SI_LAND = 0x0008 -SI_OCCL = 0x0010 -SI_NOISY = 0x0080 -SI_DRAW = 0x0100 -SI_ROOF = 0x0400 -SI_TRANSL = 0x0800 - - -def read_u16_le(data: bytes, offset: int) -> int: - return struct.unpack_from(" int: - return struct.unpack_from(" int: - return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) - - -@dataclass(frozen=True) -class FlexEntry: - offset: int - size: int - - -@dataclass(frozen=True) -class ShapeInfo: - family: int - flags: int - x: int - y: int - z: int - anim_type: int - - @property - def is_editor(self) -> bool: - return bool(self.flags & 0x1000) - - @property - def is_fixed(self) -> bool: - return bool(self.flags & SI_FIXED) - - @property - def is_solid(self) -> bool: - return bool(self.flags & SI_SOLID) - - @property - def is_land(self) -> bool: - return bool(self.flags & SI_LAND) - - @property - def is_occl(self) -> bool: - return bool(self.flags & SI_OCCL) - - @property - def is_noisy(self) -> bool: - return bool(self.flags & SI_NOISY) - - @property - def is_draw(self) -> bool: - return bool(self.flags & SI_DRAW) - - @property - def is_roof(self) -> bool: - return bool(self.flags & SI_ROOF) - - @property - def is_translucent(self) -> bool: - return bool(self.flags & SI_TRANSL) - - @property - def is_invitem(self) -> bool: - return self.family == 13 - - -@dataclass(frozen=True) -class GlobItem: - x: int - y: int - z: int - shape: int - frame: int - - -@dataclass(frozen=True) -class MapItem: - x: int - y: int - z: int - shape: int - frame: int - flags: int - quality: int - npc_num: int - map_num: int - next_item: int - source: str - - -@dataclass(frozen=True) -class ShapeFrame: - compressed: bool - width: int - height: int - xoff: int - yoff: int - line_offsets: tuple[int, ...] - rle_data: bytes - - -@dataclass -class SortNode: - item: MapItem - info: ShapeInfo - frame: ShapeFrame - pixels: list[int] - left: int - top: int - right: int - bottom: int - x: int - x_left: int - y: int - y_far: int - z: int - z_top: int - sx_left: int - sx_right: int - sx_top: int - sy_top: int - sx_bot: int - sy_bot: int - fbigsq: bool - flat: bool - occl: bool - solid: bool - draw: bool - roof: bool - noisy: bool - anim: bool - trans: bool - fixed: bool - land: bool - sprite: bool - invitem: bool - occluded: bool = False - order: int = -1 - depends: list["SortNode"] = field(default_factory=list) - - def list_less_than(self, other: "SortNode") -> bool: - if self.sprite != other.sprite: - return self.sprite < other.sprite - if self.z != other.z: - return self.z < other.z - return self.flat > other.flat - - def overlap(self, other: "SortNode") -> bool: - if not rect_intersects(self, other): - return False - - point_top_diff = (self.sx_top - other.sx_bot, self.sy_top - other.sy_bot) - point_bot_diff = (self.sx_bot - other.sx_top, self.sy_bot - other.sy_top) - - dot_top_left = point_top_diff[0] + point_top_diff[1] * 2 - dot_top_right = -point_top_diff[0] + point_top_diff[1] * 2 - dot_bot_left = point_bot_diff[0] - point_bot_diff[1] * 2 - dot_bot_right = -point_bot_diff[0] - point_bot_diff[1] * 2 - - right_clear = self.sx_right <= other.sx_left - left_clear = self.sx_left >= other.sx_right - top_left_clear = dot_top_left >= 0 - top_right_clear = dot_top_right >= 0 - bot_left_clear = dot_bot_left >= 0 - bot_right_clear = dot_bot_right >= 0 - - clear = right_clear or left_clear or (bot_right_clear or bot_left_clear) or (top_right_clear or top_left_clear) - return not clear - - def occludes(self, other: "SortNode") -> bool: - if not rect_contains(self, other): - return False - - point_top_diff = (self.sx_top - other.sx_top, self.sy_top - other.sy_top) - point_bot_diff = (self.sx_bot - other.sx_bot, self.sy_bot - other.sy_bot) - - dot_top_left = point_top_diff[0] + point_top_diff[1] * 2 - dot_top_right = -point_top_diff[0] + point_top_diff[1] * 2 - dot_bot_left = point_bot_diff[0] - point_bot_diff[1] * 2 - dot_bot_right = -point_bot_diff[0] - point_bot_diff[1] * 2 - - right_res = self.sx_right >= other.sx_right - left_res = self.sx_left <= other.sx_left - top_left_res = dot_top_left <= 0 - top_right_res = dot_top_right <= 0 - bot_left_res = dot_bot_left <= 0 - bot_right_res = dot_bot_right <= 0 - return right_res and left_res and bot_right_res and bot_left_res and top_right_res and top_left_res - - def below(self, other: "SortNode") -> bool: - if self.sprite != other.sprite: - return self.sprite < other.sprite - - if self.flat and other.flat: - if self.z != other.z: - return self.z < other.z - elif self.invitem == other.invitem: - if self.z_top <= other.z: - return True - if self.z >= other.z_top: - return False - - y_flat_self = self.y_far == self.y - y_flat_other = other.y_far == other.y - if y_flat_self and y_flat_other: - if self.y // 32 != other.y // 32: - return self.y < other.y - else: - if self.y <= other.y_far: - return True - if self.y_far >= other.y: - return False - - x_flat_self = self.x_left == self.x - x_flat_other = other.x_left == other.x - if x_flat_self and x_flat_other: - if self.x // 32 != other.x // 32: - return self.x < other.x - else: - if self.x <= other.x_left: - return True - if self.x_left >= other.x: - return False - - if self.z_top - 8 <= other.z and self.z < other.z_top - 8: - return True - if self.z >= other.z_top - 8 and self.z_top - 8 > other.z: - return False - - if y_flat_self != y_flat_other: - if self.y // 32 <= other.y_far // 32: - return True - if self.y_far // 32 >= other.y // 32: - return False - y_center_self = (self.y_far // 32 + self.y // 32) // 2 - y_center_other = (other.y_far // 32 + other.y // 32) // 2 - if y_center_self != y_center_other: - return y_center_self < y_center_other - - if x_flat_self != x_flat_other: - if self.x // 32 <= other.x_left // 32: - return True - if self.x_left // 32 >= other.x // 32: - return False - x_center_self = (self.x_left // 32 + self.x // 32) // 2 - x_center_other = (other.x_left // 32 + other.x // 32) // 2 - if x_center_self != x_center_other: - return x_center_self < x_center_other - - if self.flat or other.flat: - if self.z != other.z: - return self.z < other.z - if self.invitem != other.invitem: - return self.invitem < other.invitem - if self.flat != other.flat: - return self.flat > other.flat - if self.trans != other.trans: - return self.trans < other.trans - if self.anim != other.anim: - return self.anim < other.anim - if self.draw != other.draw: - return self.draw > other.draw - if self.solid != other.solid: - return self.solid > other.solid - if self.occl != other.occl: - return self.occl > other.occl - if self.fbigsq != other.fbigsq: - return self.fbigsq > other.fbigsq - - if self.x == other.x and self.y == other.y and self.trans != other.trans: - return self.trans < other.trans - - if self.land and other.land and self.roof != other.roof: - return self.roof < other.roof - if self.roof != other.roof: - return self.roof > other.roof - if self.z != other.z: - return self.z < other.z - - if x_flat_self or x_flat_other or y_flat_self or y_flat_other: - if self.sx_left != other.sx_left: - return self.sx_left > other.sx_left - if self.sy_bot != other.sy_bot: - return self.sy_bot < other.sy_bot - - if self.x + self.y != other.x + other.y: - return self.x + self.y < other.x + other.y - if self.x_left + self.y_far != other.x_left + other.y_far: - return self.x_left + self.y_far < other.x_left + other.y_far - if self.y != other.y: - return self.y < other.y - if self.x != other.x: - return self.x < other.x - if self.item.shape != other.item.shape: - return self.item.shape < other.item.shape - return self.item.frame < other.item.frame - - -class FlexArchive: - def __init__(self, path: Path) -> None: - self.path = path - self.data = path.read_bytes() - self.entries = self._read_entries(self.data) - - @staticmethod - def _read_entries(data: bytes) -> list[FlexEntry]: - count = read_u32_le(data, FLEX_COUNT_OFFSET) - entries: list[FlexEntry] = [] - for index in range(count): - base = FLEX_TABLE_OFFSET + index * 8 - entries.append(FlexEntry(read_u32_le(data, base), read_u32_le(data, base + 4))) - return entries - - def get(self, index: int) -> bytes: - entry = self.entries[index] - if entry.size == 0: - return b"" - return self.data[entry.offset : entry.offset + entry.size] - - def __len__(self) -> int: - return len(self.entries) - - -class ShapeArchive: - def __init__(self, path: Path) -> None: - self.archive = FlexArchive(path) - self._shape_cache: dict[int, tuple[ShapeFrame, ...]] = {} - self._decoded_frame_cache: dict[tuple[int, int], list[int]] = {} - - def get_frame(self, shape_index: int, frame_index: int) -> ShapeFrame: - frames = self._get_shape(shape_index) - if frame_index < 0 or frame_index >= len(frames): - raise IndexError(f"shape {shape_index} frame {frame_index} out of range") - return frames[frame_index] - - def decode_frame(self, shape_index: int, frame_index: int) -> tuple[ShapeFrame, list[int]]: - cache_key = (shape_index, frame_index) - decoded = self._decoded_frame_cache.get(cache_key) - frame = self.get_frame(shape_index, frame_index) - if decoded is None: - decoded = self._decode_pixels(frame) - self._decoded_frame_cache[cache_key] = decoded - return frame, decoded - - def _get_shape(self, shape_index: int) -> tuple[ShapeFrame, ...]: - cached = self._shape_cache.get(shape_index) - if cached is not None: - return cached - raw = self.archive.get(shape_index) - if not raw: - raise ValueError(f"shape {shape_index} has no data") - frames = self._parse_shape(raw) - self._shape_cache[shape_index] = frames - return frames - - @staticmethod - def _parse_shape(data: bytes) -> tuple[ShapeFrame, ...]: - frame_count = read_u16_le(data, 4) - frames: list[ShapeFrame] = [] - for index in range(frame_count): - header_offset = 6 + index * 8 - frame_offset = read_u24_le(data, header_offset) - frame_size = read_u32_le(data, header_offset + 4) - frame_data = data[frame_offset : frame_offset + frame_size] - if len(frame_data) < 28: - raise ValueError(f"frame {index} too small: {len(frame_data)}") - compressed = bool(read_u32_le(frame_data, 8)) - width = read_u32_le(frame_data, 12) - height = read_u32_le(frame_data, 16) - xoff = struct.unpack_from(" list[int]: - pixels = [-1] * (frame.width * frame.height) - rle = frame.rle_data - for row in range(frame.height): - pos = frame.line_offsets[row] - xpos = 0 - while xpos < frame.width: - if pos >= len(rle): - raise ValueError(f"row {row} overran RLE data") - xpos += rle[pos] - pos += 1 - if xpos == frame.width: - break - if pos >= len(rle): - raise ValueError(f"row {row} missing run header") - dlen = rle[pos] - pos += 1 - run_type = 0 - if frame.compressed: - run_type = dlen & 1 - dlen >>= 1 - if dlen <= 0 or xpos + dlen > frame.width: - raise ValueError(f"invalid run length {dlen} at row {row}") - row_base = row * frame.width + xpos - if run_type == 0: - end = pos + dlen - if end > len(rle): - raise ValueError(f"row {row} literal run overruns RLE data") - run = rle[pos:end] - for index, color in enumerate(run): - pixels[row_base + index] = color - pos = end - else: - if pos >= len(rle): - raise ValueError(f"row {row} repeated-color run missing color byte") - color = rle[pos] - pos += 1 - for index in range(dlen): - pixels[row_base + index] = color - xpos += dlen - return pixels - - -def load_palette(path: Path) -> list[tuple[int, int, int]]: - data = path.read_bytes() - if len(data) < 768: - raise ValueError(f"palette too small: {path}") - palette: list[tuple[int, int, int]] = [] - for index in range(256): - r = (data[index * 3] * 255) // 63 - g = (data[index * 3 + 1] * 255) // 63 - b = (data[index * 3 + 2] * 255) // 63 - palette.append((r, g, b)) - return palette - - -def load_typeflags(path: Path) -> list[ShapeInfo]: - data = path.read_bytes() - infos: list[ShapeInfo] = [] - for base in range(0, len(data), 9): - block = data[base : base + 9] - if len(block) < 9: - break - flags = 0 - if block[0] & 0x01: - flags |= 0x0001 - if block[0] & 0x02: - flags |= 0x0002 - if block[0] & 0x04: - flags |= 0x0004 - if block[0] & 0x08: - flags |= 0x0008 - if block[0] & 0x10: - flags |= 0x0010 - if block[0] & 0x20: - flags |= 0x0020 - if block[0] & 0x40: - flags |= 0x0040 - if block[0] & 0x80: - flags |= 0x0080 - if block[1] & 0x01: - flags |= 0x0100 - if block[1] & 0x02: - flags |= 0x0200 - if block[1] & 0x04: - flags |= 0x0400 - if block[1] & 0x08: - flags |= 0x0800 - if block[6] & 0x01: - flags |= 0x1000 - if block[6] & 0x02: - flags |= 0x2000 - if block[6] & 0x04: - flags |= 0x4000 - if block[6] & 0x08: - flags |= 0x8000 - if block[6] & 0x10: - flags |= 0x10000 - if block[6] & 0x20: - flags |= 0x20000 - if block[6] & 0x40: - flags |= 0x40000 - if block[6] & 0x80: - flags |= 0x80000 - family = (block[1] >> 4) + ((block[2] & 1) << 4) - x = ((block[3] << 3) | (block[2] >> 5)) & 0x1F - y = (block[3] >> 2) & 0x1F - z = ((block[4] << 1) | (block[3] >> 7)) & 0x1F - anim_type = block[4] >> 4 - infos.append(ShapeInfo(family=family, flags=flags, x=x, y=y, z=z, anim_type=anim_type)) - return infos - - -def load_globs(path: Path) -> list[list[GlobItem]]: - archive = FlexArchive(path) - globs: list[list[GlobItem]] = [] - for index in range(len(archive)): - raw = archive.get(index) - if not raw: - globs.append([]) - continue - count = read_u16_le(raw, 0) - items: list[GlobItem] = [] - for item_index in range(count): - base = 2 + item_index * 6 - items.append( - GlobItem( - x=raw[base], - y=raw[base + 1], - z=raw[base + 2], - shape=read_u16_le(raw, base + 3), - frame=raw[base + 5], - ) - ) - globs.append(items) - return globs - - -def load_map_items(path: Path, map_index: int) -> list[MapItem]: - if not path.exists(): - raise FileNotFoundError(path) - data = path.read_bytes() - map_count = read_u16_le(data, FIXED_MAP_COUNT_OFFSET) - if map_index < 0 or map_index >= map_count: - raise ValueError(f"map index {map_index} out of range 0..{map_count - 1}") - table_offset = FIXED_MAP_TABLE_OFFSET + map_index * 8 - map_offset = read_u32_le(data, table_offset) - map_size = read_u32_le(data, table_offset + 4) - payload = data[map_offset : map_offset + map_size] - if len(payload) != map_size: - raise ValueError(f"map {map_index} payload truncated") - items: list[MapItem] = [] - for base in range(0, len(payload), 16): - record = payload[base : base + 16] - if len(record) < 16: - break - x = read_u16_le(record, 0) * CRUSADER_COORD_SCALE - y = read_u16_le(record, 2) * CRUSADER_COORD_SCALE - items.append( - MapItem( - x=x, - y=y, - z=record[4], - shape=read_u16_le(record, 5), - frame=record[7], - flags=read_u16_le(record, 8), - quality=read_u16_le(record, 10), - npc_num=record[12], - map_num=record[13], - next_item=read_u16_le(record, 14), - source="fixed", - ) - ) - return items - - -def expand_glob_item(item: MapItem, globs: list[list[GlobItem]]) -> list[MapItem]: - if item.quality < 0 or item.quality >= len(globs): - return [] - expanded: list[MapItem] = [] - for glob_item in globs[item.quality]: - expanded.append( - MapItem( - x=(item.x & GLOB_COORD_MASK) + (glob_item.x << GLOB_COORD_SHIFT) + GLOB_COORD_OFFSET, - y=(item.y & GLOB_COORD_MASK) + (glob_item.y << GLOB_COORD_SHIFT) + GLOB_COORD_OFFSET, - z=item.z + glob_item.z, - shape=glob_item.shape, - frame=glob_item.frame, - flags=0, - quality=0, - npc_num=0, - map_num=item.map_num, - next_item=0, - source="glob", - ) - ) - return expanded - - -def collect_render_items( - base_items: list[MapItem], - shape_infos: list[ShapeInfo], - globs: list[list[GlobItem]], - include_editor: bool, - expand_globs: bool, - world_rect: tuple[int, int, int, int] | None, -) -> list[MapItem]: - render_items: list[MapItem] = [] - pending = list(base_items) - index = 0 - while index < len(pending): - item = pending[index] - index += 1 - if item.flags & FLAG_INVISIBLE: - continue - if world_rect is not None: - min_x, min_y, max_x, max_y = world_rect - if item.x < min_x or item.y < min_y or item.x > max_x or item.y > max_y: - continue - if item.shape >= len(shape_infos): - continue - info = shape_infos[item.shape] - if info.is_editor and not include_editor: - continue - if expand_globs and info.family == 3: - pending.extend(expand_glob_item(item, globs)) - continue - if info.family in EGG_FAMILIES: - continue - render_items.append(item) - return render_items - - -def rgba_buffer(width: int, height: int, color: tuple[int, int, int, int]) -> bytearray: - r, g, b, a = color - row = bytes((r, g, b, a)) * width - return bytearray(row * height) - - -def blit_frame( - buffer: bytearray, - canvas_width: int, - canvas_height: int, - left: int, - top: int, - frame: ShapeFrame, - pixels: list[int], - palette: list[tuple[int, int, int]], - flipped: bool, -) -> None: - for src_y in range(frame.height): - dst_y = top + src_y - if dst_y < 0 or dst_y >= canvas_height: - continue - row_base = src_y * frame.width - for src_x in range(frame.width): - color_index = pixels[row_base + (frame.width - 1 - src_x if flipped else src_x)] - if color_index < 0: - continue - dst_x = left + src_x - if dst_x < 0 or dst_x >= canvas_width: - continue - pixel_base = (dst_y * canvas_width + dst_x) * 4 - r, g, b = palette[color_index] - buffer[pixel_base : pixel_base + 4] = bytes((r, g, b, 255)) - - -def rect_intersects(left: SortNode, right: SortNode) -> bool: - return left.left < right.right and left.right > right.left and left.top < right.bottom and left.bottom > right.top - - -def rect_contains(outer: SortNode, inner: SortNode) -> bool: - return outer.left <= inner.left and outer.top <= inner.top and outer.right >= inner.right and outer.bottom >= inner.bottom - - -def build_sort_node(item: MapItem, info: ShapeInfo, frame: ShapeFrame, pixels: list[int]) -> SortNode: - flipped = bool(item.flags & FLAG_FLIPPED) - xdim = info.y * 32 if flipped else info.x * 32 - ydim = info.x * 32 if flipped else info.y * 32 - zdim = info.z * 8 - - x = item.x - y = item.y - z = item.z - x_left = x - xdim - y_far = y - ydim - z_top = z + zdim - - sx_left = x_left // 4 - y // 4 - sx_right = x // 4 - y_far // 4 - sx_top = x_left // 4 - y_far // 4 - sy_top = x_left // 8 + y_far // 8 - z_top - sx_bot = x // 4 - y // 4 - sy_bot = x // 8 + y // 8 - z - - left = sx_bot + frame.xoff - frame.width if flipped else sx_bot - frame.xoff - top = sy_bot - frame.yoff - right = left + frame.width - bottom = top + frame.height - - return SortNode( - item=item, - info=info, - frame=frame, - pixels=pixels, - left=left, - top=top, - right=right, - bottom=bottom, - x=x, - x_left=x_left, - y=y, - y_far=y_far, - z=z, - z_top=z_top, - sx_left=sx_left, - sx_right=sx_right, - sx_top=sx_top, - sy_top=sy_top, - sx_bot=sx_bot, - sy_bot=sy_bot, - fbigsq=xdim == ydim and xdim >= 128, - flat=zdim == 0, - occl=info.is_occl and not info.is_translucent, - solid=info.is_solid, - draw=info.is_draw, - roof=info.is_roof, - noisy=info.is_noisy, - anim=info.anim_type != 0, - trans=info.is_translucent, - fixed=info.is_fixed, - land=info.is_land, - sprite=False, - invitem=info.is_invitem, - ) - - -def insert_dependency_sorted(depends: list[SortNode], node: SortNode) -> None: - for index, current in enumerate(depends): - if current is node: - return - if node.list_less_than(current): - depends.insert(index, node) - return - depends.append(node) - - -def resolve_paint_order(ordered: list[SortNode]) -> list[SortNode]: - painted: list[SortNode] = [] - - def visit(node: SortNode) -> None: - if node.occluded or node.order >= 0: - return - node.order = -2 - for dependency in node.depends: - if dependency.order == -2: - break - if dependency.order == -1: - visit(dependency) - node.order = painted[-1].order + 1 if painted else 0 - painted.append(node) - - for node in ordered: - if node.order == -1: - visit(node) - return painted - - -def prepare_sorted_items( - items: list[MapItem], - archive: ShapeArchive, - shape_infos: list[ShapeInfo], -) -> tuple[int, int, int, int, list[SortNode], int]: - ordered: list[SortNode] = [] - min_left = sys.maxsize - min_top = sys.maxsize - max_right = -sys.maxsize - max_bottom = -sys.maxsize - occluded_count = 0 - - for item in items: - frame, pixels = archive.decode_frame(item.shape, item.frame) - node = build_sort_node(item, shape_infos[item.shape], frame, pixels) - - min_left = min(min_left, node.left) - min_top = min(min_top, node.top) - max_right = max(max_right, node.right) - max_bottom = max(max_bottom, node.bottom) - - insert_at = len(ordered) - for index, other in enumerate(ordered): - if insert_at == len(ordered) and node.list_less_than(other): - insert_at = index - if other.occluded: - continue - if not node.overlap(other): - continue - if node.below(other): - if other.occl and other.occludes(node): - node.occluded = True - occluded_count += 1 - break - insert_dependency_sorted(other.depends, node) - else: - if node.occl and node.occludes(other): - if not other.occluded: - other.occluded = True - occluded_count += 1 - else: - insert_dependency_sorted(node.depends, other) - ordered.insert(insert_at, node) - - return min_left, min_top, max_right, max_bottom, resolve_paint_order(ordered), occluded_count - - -def write_png_rgba(path: Path, width: int, height: int, pixels: bytearray) -> None: - def chunk(chunk_type: bytes, payload: bytes) -> bytes: - return ( - struct.pack(">I", len(payload)) - + chunk_type - + payload - + struct.pack(">I", zlib.crc32(chunk_type + payload) & 0xFFFFFFFF) - ) - - rows = bytearray() - stride = width * 4 - for row in range(height): - rows.append(0) - start = row * stride - rows.extend(pixels[start : start + stride]) - - payload = bytearray(b"\x89PNG\r\n\x1a\n") - payload.extend(chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))) - payload.extend(chunk(b"IDAT", zlib.compress(bytes(rows), level=9))) - payload.extend(chunk(b"IEND", b"")) - path.write_bytes(payload) - - -def parse_world_rect(values: list[str] | None) -> tuple[int, int, int, int] | None: - if values is None: - return None - if len(values) != 4: - raise ValueError("--world-rect expects four integers: min_x min_y max_x max_y") - min_x, min_y, max_x, max_y = (int(value, 0) for value in values) - if min_x > max_x or min_y > max_y: - raise ValueError("invalid --world-rect bounds") - return min_x, min_y, max_x, max_y - - -def resolve_static_dir(repo_root: Path, game: str, static_dir: str | None) -> Path: - if static_dir: - return Path(static_dir) - if game == "regret": - return repo_root / "STATIC_REGRET" - return repo_root / "STATIC" - - -def resolve_fixed_dat(static_dir: Path, fixed_dat: str | None) -> Path: - if fixed_dat: - return Path(fixed_dat) - return static_dir / "FIXED.DAT" - - -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="store_true", help="Render editor-only shapes.") - 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=100_000_000, - help="Fail if the output image would exceed this many pixels.", - ) - args = parser.parse_args() - - repo_root = Path(__file__).resolve().parents[1] - 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") - 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) - 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, - ) - if not render_items: - raise ValueError("no renderable items were found for the selected map") - - min_left, min_top, max_right, max_bottom, prepared, occluded_count = prepare_sorted_items( - render_items, - shape_archive, - shape_infos, - ) - width = max_right - min_left - height = max_bottom - min_top - if width <= 0 or height <= 0: - raise ValueError("computed image bounds are invalid") - if 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 in prepared: - 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), - ) - write_png_rgba(output_path, width, height, buffer) - - 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, - "item_count": len(render_items), - "painted_item_count": len(prepared), - "occluded_item_count": occluded_count, - "used_shape_count": len(used_shapes), - "used_shapes": used_shapes, - "sorter": "scummvm_dependency_graph", - "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, - "glob_expansion": not args.no_globs, - "editor_shapes_included": args.include_editor, - } - 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 +from tools.crusader_map.cli import main if __name__ == "__main__":