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/crusader_decompilation_notes.md b/crusader_decompilation_notes.md
index b6c3a7a..aee61ca 100644
--- a/crusader_decompilation_notes.md
+++ b/crusader_decompilation_notes.md
@@ -8,6 +8,8 @@ Recent verified localized-build batch: [docs/spanish-cheat-differences.md](docs/
Recent verified batch: [docs/retail-debug-arg.md](docs/retail-debug-arg.md) now records the live NE proof that retail `CRUSADER.EXE` still recognizes and executes a real `-debug` command-line branch. That branch prints `Debugging mode ON.`, sets `g_debugMsgLevel` at `1478:87e0`, and toggles two debug globals at `1478:0845/0859`. The later sink pass also closes the text-output target more tightly: `ProbablyPrintDebugMessage` formats through the static stdio-style table at `1478:6c32..6c81` and writes to the handle-`1` entry at `1478:6c46`, so the non-video side is ordinary DOS `stdout` gated by the debug threshold, plus the already-confirmed AVI timing overlay. Current best read remains `surviving debug-output / instrumentation switch`, not `the missing bootstrap for the hidden seg109/seg1408 usecode debugger`. The same batch also leaves the earlier `-laurie` and `0x659c/659e` debugger-state conclusions intact: `-debug` is a separate switch and is not currently evidenced as constructing the hidden usecode-debugger break-state object.
+Recent tooling batch: [docs/map-rendering.md](docs/map-rendering.md) now starts a dedicated offline map-rendering lane. `tools/render_crusader_map.py` can load `FIXED.DAT`, expand `GLOB.FLX`, decode the required `SHAPES.FLX` entries with Crusader frame headers, apply `GAMEPAL.PAL`, and write a first-pass PNG, with a `--fixed-dat` override so the same pipeline can be pointed at either game's map file. The current renderer is intentionally limited to fixed-map content and a simple deterministic painter rather than the full Pentagram/ScummVM dependency sorter, and the current workspace caveat is that `STATIC_REGRET` still lacks a copied `FIXED.DAT`, so No Regret rendering needs that file supplied explicitly.
+
Latest doc-reconciliation batch: [docs/ne-segment1.md](docs/ne-segment1.md) now has a combined hidden-debugger component table that explicitly separates the seg109/raw-reference UI wrappers (`000b:9a86`, `000b:9c0d`, `000b:b3b1`, `000b:b62c`, `000b:2882`) from the live seg1408 breakpoint-state helpers (`1408:0000`, `1408:0053`, `1408:00dd`, `1408:029e`, `1408:03b0`, `1408:03f7`, `1408:0419`, `1408:0432`, `1408:0444`) and the interpreter hook at `1418:04aa..04b5`. Current best read remains `two connected layers of one hidden usecode debugger`, not `conflicting address claims for the same function family`.
Follow-up cheat-key correction pass: [docs/ne-segment1.md](docs/ne-segment1.md) now also records a live NE cleanup of several folklore keyboard-cheat claims. `~` is a real runtime cheat-latch toggle at `13e8:203d`, `Ctrl+C` is wrong for this build and should be `Ctrl+L` for the coordinate popup at `13e8:255e`, and the third F7-family overlay really does exist as a separate `Ctrl+F7` path at `13e8:1a20` alongside the other two cheat-gated F7 overlay toggles.
@@ -36,6 +38,7 @@ The same `docs/ne-segment1.md` note now also has the first consolidated cheat/de
| [docs/retail-debug-arg.md](docs/retail-debug-arg.md) | Focused note on the retail `-debug` command-line switch: live parser evidence, exact startup message, surviving globals, segment `1468` instrumentation path, and why it is currently separate from the hidden usecode debugger bootstrap |
| [docs/scummvm-crusader-reference.md](docs/scummvm-crusader-reference.md) | ScummVM Ultima8/Pentagram Crusader integration survey: USECODE/event tables, FLEX/resource formats, world/map loaders, HUD/media, and RE follow-up priorities |
| [docs/pentagram-crusader-reference.md](docs/pentagram-crusader-reference.md) | Pentagram-source Crusader/U8 reference: direct Crusader USECODE parser and VM evidence, U8 usecode docs, runtime-confidence limits, and cross-checks against the ScummVM note |
+| [docs/map-rendering.md](docs/map-rendering.md) | Offline map-rendering lane: `FIXED.DAT`/`GLOB.FLX`/`SHAPES.FLX`/`GAMEPAL.PAL` format notes, current Python renderer, supported inputs, and fidelity gaps |
| [docs/usecode-roundtrip-ir.md](docs/usecode-roundtrip-ir.md) | ScummVM-to-binary USECODE cross-walk, owner-loaded class-layout and header/event-count reconciliation, conservative IR v0 plan, and the generated class-event/body-window outputs that now ground reversible `_BOOT`, `SURCAM*`, and environmental family decompile artifacts plus repeated-family regression checks |
| [docs/usecode-pentagram-ghidra-path.md](docs/usecode-pentagram-ghidra-path.md) | Pentagram-derived Crusader USECODE parser plan, proof-of-concept workflow, canonical IR v1 goals, and the Ghidra-side annotation import path |
| [docs/usecode-tooling-comparison.md](docs/usecode-tooling-comparison.md) | Comparison of Pentagram's converter/disassembler, the local `crusader-disasm` corpus/scripts, and the current workspace parser/pseudocode exporter, with emphasis on assumptions, strengths, and repo-specific differences |
diff --git a/docs/map-rendering.md b/docs/map-rendering.md
new file mode 100644
index 0000000..107ad50
--- /dev/null
+++ b/docs/map-rendering.md
@@ -0,0 +1,295 @@
+# Crusader Map Rendering Workbench
+
+## Purpose
+
+This note starts a dedicated lane for offline Crusader map extraction and PNG rendering from the shipped data files in this workspace.
+
+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:
+
+- `STATIC` for No Remorse
+- `STATIC_REGRET` for No Regret
+
+Current asset note:
+
+- `STATIC_REGRET` in this workspace now includes `FIXED.DAT`
+- the renderer still accepts `--fixed-dat` so alternate map copies can be tested without changing the rest of the static asset path
+
+The immediate goal is practical and narrow: load a fixed map, expand glob placements, decode the required shapes from `SHAPES.FLX`, apply `GAMEPAL.PAL`, and render a deterministic PNG.
+
+## Source Cross-Checks Used
+
+The first renderer is grounded in the overlapping parts of three sources rather than in ad hoc guesses.
+
+1. Pentagram Crusader shape/map loaders
+ - `convert/crusader/ConvertShapeCrusader.cpp`
+ - `graphics/Shape.cpp`
+ - `graphics/ShapeFrame.cpp`
+ - `world/Map.cpp`
+ - `world/MapGlob.cpp`
+ - `graphics/Palette.cpp`
+ - `graphics/TypeFlags.cpp`
+
+2. ScummVM Ultima8 Crusader paths
+ - `gfx/shape_archive.cpp`
+ - `gfx/type_flags.cpp`
+ - `world/map.cpp`
+ - `world/glob_egg.cpp`
+ - `world/coord_utils.h`
+ - `world/item_sorter.cpp`
+ - `world/sort_item.cpp`
+
+3. Local workspace evidence
+ - `docs/scummvm-crusader-reference.md`
+ - `docs/pentagram-crusader-reference.md`
+ - `docs/raw-0007-rendering.md`
+ - `crusader-disasm/shapedata.txt`
+ - `crusader-disasm/mapdump/mapdump.py`
+
+## File Formats Used By The First Tool
+
+### `FIXED.DAT`
+
+The map container is treated as a header plus a map table:
+
+- map count at file offset `0x54`
+- map table at file offset `0x80`
+- each table row is ``
+
+Each map payload is read as packed 16-byte item records:
+
+- `x: u16`
+- `y: u16`
+- `z: u8`
+- `shape: u16`
+- `frame: u8`
+- `flags: u16`
+- `quality: u16`
+- `npc_num: u8`
+- `map_num: u8`
+- `next: u16`
+
+Crusader-specific coordinate adjustment matches the Pentagram and ScummVM runtime loaders:
+
+- world `x = disk_x * 2`
+- world `y = disk_y * 2`
+
+### `GLOB.FLX`
+
+`GLOB.FLX` is handled as a normal FLEX archive, not as a one-off format.
+
+Each non-empty glob object contains:
+
+- object count: `u16`
+- repeated entries of `x:u8 y:u8 z:u8 shape:u16 frame:u8`
+
+Glob expansion matches the Crusader `GlobEgg::enterFastArea()` rule in ScummVM/Pentagram:
+
+- `coordmask = ~0x3ff`
+- `coordshift = 2`
+- `offset = 2`
+- `itemx = (parent_x & coordmask) + (glob_x << 2) + 2`
+- `itemy = (parent_y & coordmask) + (glob_y << 2) + 2`
+- `itemz = parent_z + glob_z`
+
+The first renderer expands glob contents and skips drawing the source glob egg itself.
+
+### `SHAPES.FLX`
+
+World shapes use the Crusader shape layout documented by Pentagram/ScummVM:
+
+- shape header: 6 bytes
+ - 4 bytes unknown
+ - 2-byte frame count
+- frame header table: 8 bytes per frame
+ - 3-byte frame offset
+ - 1 unknown byte
+ - 4-byte frame length
+- frame body header: 28 bytes
+ - 8 unknown bytes
+ - 4-byte compression flag
+ - 4-byte width
+ - 4-byte height
+ - 4-byte x offset
+ - 4-byte y offset
+- then `height` 4-byte line offsets
+- then per-line RLE data
+
+The current decoder follows the runtime line walker used in `ShapeFrame::getPixelAtPoint()`:
+
+- each line is a series of skip/run pairs
+- compressed runs use the low bit to choose literal versus repeated-color mode
+- pixels absent from the RLE stream are treated as transparent
+
+### `GAMEPAL.PAL`
+
+`GAMEPAL.PAL` is read as 768 bytes of VGA-style palette data.
+
+Each component is promoted from `0..63` to `0..255` using the same scaling used by Pentagram:
+
+- `rgb8 = (rgb6 * 255) / 63`
+
+### `TYPEFLAG.DAT`
+
+The renderer currently uses Crusader's 9-byte records to extract:
+
+- family id
+- shape footpad dimensions (`x`, `y`, `z`)
+- editor flag
+
+This is enough for:
+
+- skipping known egg families in the first pass
+- 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
+
+The renderer anchors each shape at the same world-to-screen bottom point used by the runtime shape painter:
+
+$$
+screen_x = \frac{x - y}{4}
+$$
+
+$$
+screen_y = \frac{x + y}{8} - z
+$$
+
+Frame placement then follows the shape-frame offsets used by the runtime sorter:
+
+- unflipped: `left = screen_x - xoff`
+- flipped: `left = screen_x + xoff - width`
+- `top = screen_y - yoff`
+
+The renderer now uses a ScummVM/Pentagram-style dependency graph sorter rather than a plain scalar key.
+
+The current implementation ports the crucial parts of `SortItem` and `ItemSorter`:
+
+- footpad-derived world boxes from `TYPEFLAG.DAT`
+- screen-diamond overlap and containment checks
+- `below()` ordering rules for flat pieces, tall pieces, roofs, translucent items, and Crusader inventory-item families
+- dependency expansion so overlapping items are painted only after everything behind them
+
+This is materially better than the initial `z / x+y` heuristic and is the main path for reducing wall and prop overdraw artifacts, though it still omits some of the engine's more specialized runtime-only cases.
+
+## Command Examples
+
+Render No Remorse map `0`:
+
+```powershell
+c:/Users/Maddo/.PYENV/PYENV-WIN/versions/3.14.3/python.exe tools/render_crusader_map.py --game remorse --map 0 --output out/map0-remorse.png
+```
+
+Render No Regret map `0` and emit metadata:
+
+```powershell
+c:/Users/Maddo/.PYENV/PYENV-WIN/versions/3.14.3/python.exe tools/render_crusader_map.py --game regret --fixed-dat K:/path/to/REGRET/FIXED.DAT --map 0 --output out/map0-regret.png --metadata out/map0-regret.json
+```
+
+Render a bounded world-space region only:
+
+```powershell
+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.
+
+Current gaps:
+
+1. It renders `FIXED.DAT` only. It does not yet merge save-state or `NONFIXED.DAT` style movable items.
+2. It expands globs, but it does not yet emulate broader fast-area/runtime-driven materialization behavior.
+3. It skips several egg-family placements instead of trying to visualize their hidden runtime helpers.
+4. It now implements the core dependency graph sorter, but it still omits experimental occlusion grouping and some runtime-only sprite/highlight cases.
+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
+
+1. Validate and tune the dependency sorter against representative Remorse and Regret rooms, especially tall wall seams and dense prop clusters.
+2. Add optional atlas export for all shapes touched by a chosen map.
+3. Add a second path for movable/dynamic content once the relevant Crusader save/runtime files are pinned down for both games.
+4. Compare a few rendered regions against known in-game screenshots to tighten projection and ordering errors.
+5. Add optional per-item manifest output with `(shape, frame, x, y, z, source)` rows for debugging bad composites.
+6. Revisit raw `0007` rendering notes and the live executable only if the current Pentagram/ScummVM overlap model proves insufficient for specific remaining errors.
\ No newline at end of file
diff --git a/docs/usecode-roundtrip-ir.md b/docs/usecode-roundtrip-ir.md
index c3a6746..f72b5a3 100644
--- a/docs/usecode-roundtrip-ir.md
+++ b/docs/usecode-roundtrip-ir.md
@@ -790,4 +790,19 @@ The strongest present path to a usable compiler/decompiler is:
5. Attach ScummVM event and intrinsic names as hints, not as truth.
6. Recompile by rebuilding the original class header and event table layout first, then re-emitting decoded and opaque ops together.
-That gets to a reversible editor sooner than waiting for a full semantic VM recovery.
\ No newline at end of file
+That gets to a reversible editor sooner than waiting for a full semantic VM recovery.
+
+## **Recent Research (2026-03-26)**
+
+- **Root Cause:**: The structuring pass left forward/back-edge loops and counted-loop headers detached in fallback output, which produced unstructured pseudocode for some bodies (notably BART slot 0x0F).
+- **Renderer Fixes:**: Added a conservative loop-lifting helper and a restricted infinite-loop lift in the partial fallback renderer to fold loops into structured blocks where safe. See the modified renderer at [tools/poc_crusader_usecode_parser.py](tools/poc_crusader_usecode_parser.py).
+- **Validator Added:**: A lightweight pseudocode syntax/label validator was added to detect brace mismatches and missing goto/label targets before exporting pseudocode.
+- **Tests:**: Added and adjusted unit tests in [tools/tests/test_usecode_structuring.py](tools/tests/test_usecode_structuring.py) to guard loop-lifting behavior and fallback conservatism.
+- **Corpus Validation:**: Ran a corpus-wide render+validator pass over 977 decoded bodies; result: `TOTAL_BODIES=977, FAILURES=0` (no syntax/label failures).
+- **Real-World Output:**: Regenerated the BART pseudocode file — [USECODE/EUSECODE_extracted/pseudocode/BART/slot_0F_enterFastArea.txt](USECODE/EUSECODE_extracted/pseudocode/BART/slot_0F_enterFastArea.txt) now shows an outer `while(true)` with nested structured branches and counted loops instead of detached labels.
+- **Scope & Safety:**: Fully-structured renderer remains conservative; the loop-lifting helper is reused where safe. The outer infinite-loop lift was narrowed to partial fallback after tests revealed regressions when it was too broad.
+- **Remaining Semantic Gap:**: Expression/comparison operand polarity still needs correction (some counted-loop conditions show inverted comparisons). Next work: fix operand ordering in the expression builder so loop headers reflect correct comparison direction.
+- **Next Steps:**: (1) Implement compare-direction fix in the expression builder and add small semantic regression tests, (2) re-run unit tests and a corpus-wide render+validate sweep, (3) regenerate affected pseudocode files for inspection.
+- **Files of Interest:**: [tools/poc_crusader_usecode_parser.py](tools/poc_crusader_usecode_parser.py), [tools/tests/test_usecode_structuring.py](tools/tests/test_usecode_structuring.py), [USECODE/EUSECODE_extracted/pseudocode/BART/slot_0F_enterFastArea.txt](USECODE/EUSECODE_extracted/pseudocode/BART/slot_0F_enterFastArea.txt).
+
+If you want, I can (a) implement the comparison/operand polarity fix next, (b) run the unit tests and a fresh corpus sweep, and (c) open a PR-ready commit with these doc and code updates.
\ No newline at end of file
diff --git a/plan-mid.md b/plan-mid.md
index dd8e639..788f0ba 100644
--- a/plan-mid.md
+++ b/plan-mid.md
@@ -49,6 +49,7 @@ Detailed completed analysis belongs in the files under `docs/`, not in this plan
- 000a/000d tracked-handle, cache, allocator, dispatch-entry, and startup/display support lanes now have a coherent partial map.
- 000e parser and animation subsystems have a real partial map.
- The auxiliary local disassembly corpus at `K:/ghidra/crusader-disasm` is now inventoried and integrated as a separate evidence source for shape metadata, static map/object dumps, opcode names, and older Remorse/Regret intrinsic-function vocabularies; its safe-reuse rules and porting implications are captured in `docs/crusader-disasm-reference.md`.
+- The workspace now also has a first dedicated offline map-rendering/tooling lane: `tools/render_crusader_map.py` can load a chosen `FIXED.DAT`, expand `GLOB.FLX`, decode required `SHAPES.FLX` frames, apply `GAMEPAL.PAL`, and emit a first-pass PNG from either static set, while `docs/map-rendering.md` captures the current format contracts, the `--fixed-dat` override, and the intentionally limited compositor model.
- The USECODE/VM owner/resource/runtime lane now has a workable partial model, a named sequencer entry, paired external file-family loader evidence, and supporting extraction/reporting tooling.
- The USECODE/VM tooling lane now also has a concrete near-term implementation path: a Pentagram-derived proof-of-concept parser can reuse opcode decoding while swapping in the locally verified owner-loaded class and slot arithmetic, with a hybrid Ghidra comment/bookmark import path instead of a premature custom processor module.
- The USECODE tooling lane now also has a first full readable corpus export: `tools/export_usecode_pseudocode.py` writes `977` current pseudocode bodies into `USECODE/EUSECODE_extracted/pseudocode`, and the first focused read of that corpus now shows `JELYHACK::use` / `JELYH2::use` as tiny shared `set_info(0x0207) -> process_exclude -> return` stubs rather than hidden active event cores.
@@ -162,6 +163,7 @@ Detailed completed analysis belongs in the files under `docs/`, not in this plan
3. Refine the coverage ledger from already-verified notes before broadening into fresh segment sweeps.
4. Use boundary repair only on active blockers with clear payoff, with `000c:db68` now downgraded to optional hygiene unless it blocks adjacent work again.
5. Revisit the `0x4588` callback object only when caller-side evidence is strong enough to support behavioral naming.
+6. Use the new offline map-rendering lane to cross-check shape ids, map placements, and visible world composition against `crusader-disasm` shape/map notes before promoting additional rendering- or static-object-related names in `CRUSADER.EXE`.
## Next Resume Point
@@ -172,16 +174,17 @@ Detailed completed analysis belongs in the files under `docs/`, not in this plan
5. Refine the coverage ledger from already-verified notes before broadening into fresh segment sweeps.
6. Use boundary repair only on active blockers with clear payoff, with `000c:db68` now downgraded to optional hygiene unless it blocks adjacent work again.
7. Revisit the `0x4588` callback object only when caller-side evidence is strong enough to support behavioral naming.
+8. Exercise `tools/render_crusader_map.py` on a few representative No Remorse and No Regret maps, then tighten the paint order using `TYPEFLAG.DAT` footpads and any mismatches visible against in-game screenshots or `crusader-disasm` map evidence.
-8. Recover the real upstream caller/selector path into `entity_vm_opcode_sequence_run`, most likely by finding the first non-recursive `0x6714` context-method caller or vtable dispatch site rather than by repeating raw xref queries that still return no direct edges.
-9. Recover real caller roles for `entity_vm_context_try_create_mask_0400_slot0a_with_offset` and `entity_vm_context_try_create_mask_0800_slot0b_with_offset` by treating them as the remaining dark members of the now-verified signed-additive masked-materializer subfamily and comparing them against the newly anchored slot-`0x12` caller pattern.
-10. Tighten the newly surfaced higher-slot wrapper ladder around `0005:3115..31da`, especially the two slot-`0x12` caller sites at `0005:1776` / `0005:1945` and the slot-`0x10` guarded callsite, so any future promotion to `leaveFastArea` / `func11|cast` / `justMoved` / `AvatarStoleSomething` / `animGetHit` is driven by binary caller behavior rather than by external tables alone.
-11. Tighten the outward caller chains around the renamed seg006 masked helpers `entity_vm_context_try_create_mask_0008_slot30_with_offset` (`0006:0ba4`) and `entity_vm_context_try_create_mask_0010_slot08_with_offset_if_ready` (`0006:108c`) so the local state-selector lane and the adjacent class-linked value family can be tied back to concrete gameplay subsystems rather than only to class-detail fields.
-12. Tighten the paired-file-family reading of the seg070 twin loops at `0009:67b6` and `0009:6916` by recovering which temporary buffer and record schema each family populates behind `entity_vm_runtime_owner_resource_create`.
-13. Promote additional ledger rows where the current docs already justify `Foothold`, `Partial`, or `Deep`.
-14. If the VM lane stalls again, revisit `000e:ffb0` from the now-verified `00db/00dc` caller windows and try to recover an adjacent non-overlapped helper before attempting any boundary repair.
-15. If the immortality lane is revisited, stay focused on `NPCTRIG` slot `0x0a` first, with slot `0x20` still treated as the typed/setup companion and `EVENT` only as the generic hub baseline; the three currently recovered direct `0005:295f` caller families are now all closed and comment-backed in the live NE program at `10f0:02d9`, `10f0:0379`, `10f0:03c3`, `10f0:03e5`, `1128:0ff0`, and `1138:1384`, so the next defensible step is an earlier producer that assigns subtype `0x20b/0x20c` into field `+0x3c` or otherwise chooses the owner-loaded class family before these generic damage consumers run.
-16. Use the new Pentagram-derived parser proof of concept as the first tooling bridge for raw class/slot bodies: extend opcode coverage conservatively, emit IR v1 artifacts, and only then prototype a Ghidra-side annotation importer against compiled anchors like `000d:51fd`, `000d:5572`, `000d:46ec`, `000d:22bc`, and `000d:ebe3`.
+9. Recover the real upstream caller/selector path into `entity_vm_opcode_sequence_run`, most likely by finding the first non-recursive `0x6714` context-method caller or vtable dispatch site rather than by repeating raw xref queries that still return no direct edges.
+10. Recover real caller roles for `entity_vm_context_try_create_mask_0400_slot0a_with_offset` and `entity_vm_context_try_create_mask_0800_slot0b_with_offset` by treating them as the remaining dark members of the now-verified signed-additive masked-materializer subfamily and comparing them against the newly anchored slot-`0x12` caller pattern.
+11. Tighten the newly surfaced higher-slot wrapper ladder around `0005:3115..31da`, especially the two slot-`0x12` caller sites at `0005:1776` / `0005:1945` and the slot-`0x10` guarded callsite, so any future promotion to `leaveFastArea` / `func11|cast` / `justMoved` / `AvatarStoleSomething` / `animGetHit` is driven by binary caller behavior rather than by external tables alone.
+12. Tighten the outward caller chains around the renamed seg006 masked helpers `entity_vm_context_try_create_mask_0008_slot30_with_offset` (`0006:0ba4`) and `entity_vm_context_try_create_mask_0010_slot08_with_offset_if_ready` (`0006:108c`) so the local state-selector lane and the adjacent class-linked value family can be tied back to concrete gameplay subsystems rather than only to class-detail fields.
+13. Tighten the paired-file-family reading of the seg070 twin loops at `0009:67b6` and `0009:6916` by recovering which temporary buffer and record schema each family populates behind `entity_vm_runtime_owner_resource_create`.
+14. Promote additional ledger rows where the current docs already justify `Foothold`, `Partial`, or `Deep`.
+15. If the VM lane stalls again, revisit `000e:ffb0` from the now-verified `00db/00dc` caller windows and try to recover an adjacent non-overlapped helper before attempting any boundary repair.
+16. If the immortality lane is revisited, stay focused on `NPCTRIG` slot `0x0a` first, with slot `0x20` still treated as the typed/setup companion and `EVENT` only as the generic hub baseline; the three currently recovered direct `0005:295f` caller families are now all closed and comment-backed in the live NE program at `10f0:02d9`, `10f0:0379`, `10f0:03c3`, `10f0:03e5`, `1128:0ff0`, and `1138:1384`, so the next defensible step is an earlier producer that assigns subtype `0x20b/0x20c` into field `+0x3c` or otherwise chooses the owner-loaded class family before these generic damage consumers run.
+17. Use the new Pentagram-derived parser proof of concept as the first tooling bridge for raw class/slot bodies: extend opcode coverage conservatively, emit IR v1 artifacts, and only then prototype a Ghidra-side annotation importer against compiled anchors like `000d:51fd`, `000d:5572`, `000d:46ec`, `000d:22bc`, and `000d:ebe3`.
## Remaining Work To Reach A Reasonably Complete Decompilation State
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/poc_crusader_usecode_parser.py b/tools/poc_crusader_usecode_parser.py
index 4095472..a36fa3f 100644
--- a/tools/poc_crusader_usecode_parser.py
+++ b/tools/poc_crusader_usecode_parser.py
@@ -2544,6 +2544,110 @@ def render_selector_chain(
return rendered, label_to_index[join_label]
+def render_loop_construct(
+ blocks: list[tuple[str, list[str]]],
+ label_to_index: dict[str, int],
+ index: int,
+ end_index: int,
+ return_labels: set[str],
+ active_regions: set[tuple[int, int, tuple[str, ...]]] | None = None,
+ render_cache: dict[tuple[int, int, tuple[str, ...]], tuple[list[str], bool] | None] | None = None,
+) -> tuple[list[str], int] | None:
+ _, statements = blocks[index]
+ if not statements:
+ return None
+
+ terminal = parse_terminal_statement(statements[-1])
+ if terminal is None or terminal.kind != "if":
+ return None
+
+ target_label = terminal.target or ""
+ target_index = label_to_index.get(target_label)
+ if target_index is None or target_index <= index or target_index > end_index:
+ return None
+
+ loop_tail_index = last_nonempty_block_index(blocks, index + 1, target_index)
+ if loop_tail_index is None:
+ return None
+
+ loop_tail_terminal = parse_terminal_statement(blocks[loop_tail_index][1][-1])
+ if loop_tail_terminal is None or loop_tail_terminal.kind != "goto" or loop_tail_terminal.target != blocks[index][0]:
+ return None
+
+ loop_body = render_structured_region(
+ blocks,
+ label_to_index,
+ index + 1,
+ target_index,
+ return_labels,
+ {blocks[index][0]},
+ active_regions,
+ render_cache,
+ )
+ if loop_body is None:
+ return None
+
+ loop_lines, _ = loop_body
+ loop_selector = None
+ if index > 0 and is_loop_selector_only_block(blocks[index - 1][1]):
+ loop_selector = parse_loop_selector_statement(blocks[index - 1][1][0])
+
+ rendered: list[str] = []
+ if loop_selector is not None:
+ rendered.append(f"for {loop_selector} {{")
+ else:
+ rendered.append(f"while ({invert_condition_text(terminal.condition or 'condition')}) {{")
+ rendered.extend(indent_lines(loop_lines))
+ rendered.append("}")
+ return rendered, target_index
+
+
+def render_infinite_loop_construct(
+ blocks: list[tuple[str, list[str]]],
+ label_to_index: dict[str, int],
+ index: int,
+ end_index: int,
+ return_labels: set[str],
+ active_regions: set[tuple[int, int, tuple[str, ...]]] | None = None,
+ render_cache: dict[tuple[int, int, tuple[str, ...]], tuple[list[str], bool] | None] | None = None,
+) -> tuple[list[str], int] | None:
+ if index + 1 >= end_index:
+ return None
+
+ loop_label = blocks[index][0]
+ loop_tail_index: int | None = None
+ for candidate in range(end_index - 1, index, -1):
+ statements = blocks[candidate][1]
+ if not statements:
+ continue
+ terminal = parse_terminal_statement(statements[-1])
+ if terminal is not None and terminal.kind == "goto" and terminal.target == loop_label:
+ loop_tail_index = candidate
+ break
+
+ if loop_tail_index is None:
+ return None
+
+ loop_body = render_structured_region(
+ blocks,
+ label_to_index,
+ index,
+ loop_tail_index + 1,
+ return_labels,
+ {loop_label},
+ active_regions,
+ render_cache,
+ )
+ if loop_body is None:
+ return None
+
+ loop_lines, _ = loop_body
+ rendered = ["while (true) {"]
+ rendered.extend(indent_lines(loop_lines))
+ rendered.append("}")
+ return rendered, loop_tail_index + 1
+
+
def render_structured_region(
blocks: list[tuple[str, list[str]]],
label_to_index: dict[str, int],
@@ -2635,34 +2739,20 @@ def render_structured_region(
index = selector_join_index
continue
- if target_index <= end_index:
- loop_tail_index = last_nonempty_block_index(blocks, index + 1, target_index)
- if loop_tail_index is not None:
- loop_tail_terminal = parse_terminal_statement(blocks[loop_tail_index][1][-1])
- if loop_tail_terminal is not None and loop_tail_terminal.kind == "goto" and loop_tail_terminal.target == blocks[index][0]:
- loop_body = render_structured_region(
- blocks,
- label_to_index,
- index + 1,
- target_index,
- return_labels,
- {blocks[index][0]},
- active_regions,
- render_cache,
- )
- if loop_body is not None:
- loop_lines, _ = loop_body
- loop_selector = None
- if index > start_index:
- loop_selector = parse_loop_selector_statement(blocks[index - 1][1][0]) if is_loop_selector_only_block(blocks[index - 1][1]) else None
- if loop_selector is not None:
- lines.append(f"for {loop_selector} {{")
- else:
- lines.append(f"while ({invert_condition_text(terminal.condition or 'condition')}) {{")
- lines.extend(indent_lines(loop_lines))
- lines.append("}")
- index = target_index
- continue
+ loop_construct = render_loop_construct(
+ blocks,
+ label_to_index,
+ index,
+ end_index,
+ return_labels,
+ active_regions,
+ render_cache,
+ )
+ if loop_construct is not None:
+ loop_lines, loop_join_index = loop_construct
+ lines.extend(loop_lines)
+ index = loop_join_index
+ continue
true_tail_index = last_nonempty_block_index(blocks, index + 1, target_index)
if true_tail_index is not None:
@@ -2817,6 +2907,38 @@ def render_partially_structured_blocks(blocks: list[tuple[str, list[str]]]) -> l
index = selector_join_index
continue
+ loop_construct = render_loop_construct(
+ blocks,
+ label_to_index,
+ index,
+ len(blocks),
+ return_labels,
+ )
+ if loop_construct is not None:
+ loop_lines, loop_join_index = loop_construct
+ lines.append(f" {label}:")
+ for statement in loop_lines:
+ lines.append(f" {statement}" if statement else "")
+ lines.append("")
+ index = loop_join_index
+ continue
+
+ infinite_loop_construct = render_infinite_loop_construct(
+ blocks,
+ label_to_index,
+ index,
+ len(blocks),
+ return_labels,
+ )
+ if infinite_loop_construct is not None:
+ loop_lines, loop_join_index = infinite_loop_construct
+ lines.append(f" {label}:")
+ for statement in loop_lines:
+ lines.append(f" {statement}" if statement else "")
+ lines.append("")
+ index = loop_join_index
+ continue
+
lines.append(f" {label}:")
for statement in statements:
lines.append(f" {statement}")
@@ -2855,6 +2977,47 @@ def render_pseudocode(ir: dict[str, Any], shape_catalog: ShapeCatalog | None = N
return apply_shape_catalog_to_pseudocode("\n".join(lines) + "\n", shape_catalog)
+def validate_pseudocode_text(text: str) -> list[str]:
+ errors: list[str] = []
+ label_lines: dict[str, int] = {}
+ goto_targets: list[tuple[str, int]] = []
+ brace_depth = 0
+
+ for line_number, raw_line in enumerate(text.splitlines(), start=1):
+ stripped = raw_line.strip()
+ if not stripped:
+ continue
+
+ if stripped.endswith("{"):
+ brace_depth += 1
+ if stripped == "}":
+ brace_depth -= 1
+ if brace_depth < 0:
+ errors.append(f"line {line_number}: unexpected closing brace")
+ brace_depth = 0
+
+ label_match = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*):", stripped)
+ if label_match is not None:
+ label = label_match.group(1)
+ previous_line = label_lines.get(label)
+ if previous_line is not None:
+ errors.append(f"line {line_number}: duplicate label {label} (first at line {previous_line})")
+ else:
+ label_lines[label] = line_number
+
+ for match in re.finditer(r"\bgoto ([A-Za-z_][A-Za-z0-9_]*)\s*;", stripped):
+ goto_targets.append((match.group(1), line_number))
+
+ if brace_depth != 0:
+ errors.append(f"unbalanced braces: final depth {brace_depth}")
+
+ for target, line_number in goto_targets:
+ if target not in label_lines:
+ errors.append(f"line {line_number}: goto target {target} has no label")
+
+ return errors
+
+
def render_text(ir: dict[str, Any]) -> str:
labels = build_listing_labels(ir)
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
new file mode 100644
index 0000000..bfc6778
--- /dev/null
+++ b/tools/render_crusader_map.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+
+if __package__ in (None, ""):
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from tools.crusader_map.cli import main
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/tests/test_usecode_structuring.py b/tools/tests/test_usecode_structuring.py
index 8b501d2..e5a6d54 100644
--- a/tools/tests/test_usecode_structuring.py
+++ b/tools/tests/test_usecode_structuring.py
@@ -9,6 +9,7 @@ from tools.poc_crusader_usecode_parser import (
render_partially_structured_blocks,
render_structured_pseudocode,
try_decode_loop_selector,
+ validate_pseudocode_text,
)
@@ -222,6 +223,58 @@ class UsecodeStructuringTests(unittest.TestCase):
self.assertNotIn("block_0358:", text)
self.assertNotIn("goto block_0469;", text)
+ def test_generic_loop_renders_in_partial_fallback(self) -> None:
+ blocks = [
+ ("entry", ["goto block_01E2;"]),
+ ("block_01E2", ["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", ["counter = (1 + counter);", "goto block_025C;"]),
+ ("block_0315", ["goto block_01E2;"]),
+ ]
+
+ rendered = render_partially_structured_blocks(blocks)
+
+ text = "\n".join(rendered)
+ self.assertIn("while (true) {", text)
+ self.assertIn("while (counter > rndNum) {", text)
+ self.assertIn("while (counter2 > 7) {", text)
+ self.assertNotIn("block_026E:", text)
+ self.assertNotIn("goto block_025C;", text)
+
+ def test_infinite_loop_region_renders_as_while_true(self) -> None:
+ blocks = [
+ ("entry", ["set_info(0x021B, *(arg_06));"]),
+ ("block_01E2", ["suspend;", "FREE.slot_20(100);", "if (retval > 50) goto block_0318;"]),
+ ("block_0205", ["FREE.slot_20(pid, 120);", "goto block_046D;"]),
+ ("block_0318", ["FREE.slot_20(pid, 60);"]),
+ ("block_046D", ["goto block_01E2;"]),
+ ("block_0470", ["return;"]),
+ ]
+
+ rendered = render_partially_structured_blocks(blocks)
+
+ text = "\n".join(rendered)
+ self.assertIn("while (true) {", text)
+ self.assertNotIn("goto block_01E2;", text)
+ self.assertNotIn("block_046D:", text)
+
+ def test_pseudocode_validator_reports_missing_label(self) -> None:
+ errors = validate_pseudocode_text(
+ "function sample()\n{\n entry:\n goto missing;\n}\n"
+ )
+
+ self.assertEqual(errors, ["line 4: goto target missing has no label"])
+
+ def test_pseudocode_validator_accepts_balanced_text(self) -> None:
+ errors = validate_pseudocode_text(
+ "function sample()\n{\n entry:\n while (true) {\n goto entry;\n }\n}\n"
+ )
+
+ self.assertEqual(errors, [])
+
if __name__ == "__main__":
unittest.main()
\ No newline at end of file