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__":