diff --git a/.gitignore b/.gitignore
index fca1b05..c0d2ba4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,4 +43,3 @@ tools/pyghidra_crusader/__pycache__/**
bin/**
USECODE/REGRET/REGRET_USECODE_extracted/chunks/**
exports/**
-out/**
diff --git a/Crusader.rep/projectState b/Crusader.rep/projectState
index 6244408..6ccf416 100644
--- a/Crusader.rep/projectState
+++ b/Crusader.rep/projectState
@@ -4,12 +4,494 @@
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Crusader.rep/user/00/~00000008.db/db.18.gbf b/Crusader.rep/user/00/~00000008.db/db.16.gbf
similarity index 99%
rename from Crusader.rep/user/00/~00000008.db/db.18.gbf
rename to Crusader.rep/user/00/~00000008.db/db.16.gbf
index 78fa56d..a61cd36 100644
Binary files a/Crusader.rep/user/00/~00000008.db/db.18.gbf and b/Crusader.rep/user/00/~00000008.db/db.16.gbf differ
diff --git a/STATIC_REGRET/ANIM.DAT b/STATIC_REGRET/ANIM.DAT
deleted file mode 100644
index af193a7..0000000
Binary files a/STATIC_REGRET/ANIM.DAT and /dev/null differ
diff --git a/STATIC_REGRET/COMBAT.DAT b/STATIC_REGRET/COMBAT.DAT
deleted file mode 100644
index f926d9b..0000000
Binary files a/STATIC_REGRET/COMBAT.DAT and /dev/null differ
diff --git a/STATIC_REGRET/CRED.DAT b/STATIC_REGRET/CRED.DAT
deleted file mode 100644
index e6e1072..0000000
Binary files a/STATIC_REGRET/CRED.DAT and /dev/null differ
diff --git a/STATIC_REGRET/CRED.PAL b/STATIC_REGRET/CRED.PAL
deleted file mode 100644
index 9461e4f..0000000
Binary files a/STATIC_REGRET/CRED.PAL and /dev/null differ
diff --git a/STATIC_REGRET/CREDITS.DAT b/STATIC_REGRET/CREDITS.DAT
deleted file mode 100644
index e41af9d..0000000
Binary files a/STATIC_REGRET/CREDITS.DAT and /dev/null differ
diff --git a/STATIC_REGRET/DAMAGE.FLX b/STATIC_REGRET/DAMAGE.FLX
deleted file mode 100644
index f96694d..0000000
Binary files a/STATIC_REGRET/DAMAGE.FLX and /dev/null differ
diff --git a/STATIC_REGRET/DIFF.PAL b/STATIC_REGRET/DIFF.PAL
deleted file mode 100644
index 75896db..0000000
Binary files a/STATIC_REGRET/DIFF.PAL and /dev/null differ
diff --git a/STATIC_REGRET/DTABLE.FLX b/STATIC_REGRET/DTABLE.FLX
deleted file mode 100644
index 6807100..0000000
Binary files a/STATIC_REGRET/DTABLE.FLX and /dev/null differ
diff --git a/STATIC_REGRET/FIXED.DAT b/STATIC_REGRET/FIXED.DAT
deleted file mode 100644
index be4d860..0000000
Binary files a/STATIC_REGRET/FIXED.DAT and /dev/null differ
diff --git a/STATIC_REGRET/FONTS.DAT b/STATIC_REGRET/FONTS.DAT
deleted file mode 100644
index 3ca0592..0000000
Binary files a/STATIC_REGRET/FONTS.DAT and /dev/null differ
diff --git a/STATIC_REGRET/FONTS.FLX b/STATIC_REGRET/FONTS.FLX
deleted file mode 100644
index 890e96f..0000000
Binary files a/STATIC_REGRET/FONTS.FLX and /dev/null differ
diff --git a/STATIC_REGRET/GAMEPAL.PAL b/STATIC_REGRET/GAMEPAL.PAL
deleted file mode 100644
index a0557f2..0000000
Binary files a/STATIC_REGRET/GAMEPAL.PAL and /dev/null differ
diff --git a/STATIC_REGRET/GLOB.FLX b/STATIC_REGRET/GLOB.FLX
deleted file mode 100644
index d516093..0000000
Binary files a/STATIC_REGRET/GLOB.FLX and /dev/null differ
diff --git a/STATIC_REGRET/GUMPS.FLX b/STATIC_REGRET/GUMPS.FLX
deleted file mode 100644
index 715b1e5..0000000
Binary files a/STATIC_REGRET/GUMPS.FLX and /dev/null differ
diff --git a/STATIC_REGRET/HELP1.BMP b/STATIC_REGRET/HELP1.BMP
deleted file mode 100644
index 0f3ce2b..0000000
Binary files a/STATIC_REGRET/HELP1.BMP and /dev/null differ
diff --git a/STATIC_REGRET/HELP2.BMP b/STATIC_REGRET/HELP2.BMP
deleted file mode 100644
index c273077..0000000
Binary files a/STATIC_REGRET/HELP2.BMP and /dev/null differ
diff --git a/STATIC_REGRET/HELP3.BMP b/STATIC_REGRET/HELP3.BMP
deleted file mode 100644
index 1d90742..0000000
Binary files a/STATIC_REGRET/HELP3.BMP and /dev/null differ
diff --git a/STATIC_REGRET/HELP4.BMP b/STATIC_REGRET/HELP4.BMP
deleted file mode 100644
index 6ad672c..0000000
Binary files a/STATIC_REGRET/HELP4.BMP and /dev/null differ
diff --git a/STATIC_REGRET/HELP5.BMP b/STATIC_REGRET/HELP5.BMP
deleted file mode 100644
index 4e03af3..0000000
Binary files a/STATIC_REGRET/HELP5.BMP and /dev/null differ
diff --git a/STATIC_REGRET/ICONS.DAT b/STATIC_REGRET/ICONS.DAT
deleted file mode 100644
index 588d5d5..0000000
Binary files a/STATIC_REGRET/ICONS.DAT and /dev/null differ
diff --git a/STATIC_REGRET/LOAD.BMP b/STATIC_REGRET/LOAD.BMP
deleted file mode 100644
index 3424dc3..0000000
Binary files a/STATIC_REGRET/LOAD.BMP and /dev/null differ
diff --git a/STATIC_REGRET/MISC.PAL b/STATIC_REGRET/MISC.PAL
deleted file mode 100644
index 4cb1b18..0000000
Binary files a/STATIC_REGRET/MISC.PAL and /dev/null differ
diff --git a/STATIC_REGRET/MISC2.PAL b/STATIC_REGRET/MISC2.PAL
deleted file mode 100644
index 75896db..0000000
Binary files a/STATIC_REGRET/MISC2.PAL and /dev/null differ
diff --git a/STATIC_REGRET/MOUSE.SHP b/STATIC_REGRET/MOUSE.SHP
deleted file mode 100644
index 0d80111..0000000
Binary files a/STATIC_REGRET/MOUSE.SHP and /dev/null differ
diff --git a/STATIC_REGRET/MUSIC.AMF b/STATIC_REGRET/MUSIC.AMF
deleted file mode 100644
index 6be2a03..0000000
Binary files a/STATIC_REGRET/MUSIC.AMF and /dev/null differ
diff --git a/STATIC_REGRET/PALETTE.DAT b/STATIC_REGRET/PALETTE.DAT
deleted file mode 100644
index a0557f2..0000000
Binary files a/STATIC_REGRET/PALETTE.DAT and /dev/null differ
diff --git a/STATIC_REGRET/SAVE.BMP b/STATIC_REGRET/SAVE.BMP
deleted file mode 100644
index 0d52f1d..0000000
Binary files a/STATIC_REGRET/SAVE.BMP and /dev/null differ
diff --git a/STATIC_REGRET/SHAPES.FLX b/STATIC_REGRET/SHAPES.FLX
deleted file mode 100644
index 1f9313a..0000000
Binary files a/STATIC_REGRET/SHAPES.FLX and /dev/null differ
diff --git a/STATIC_REGRET/STAR.PAL b/STATIC_REGRET/STAR.PAL
deleted file mode 100644
index aac15ac..0000000
Binary files a/STATIC_REGRET/STAR.PAL and /dev/null differ
diff --git a/STATIC_REGRET/STUFF.DAT b/STATIC_REGRET/STUFF.DAT
deleted file mode 100644
index f65c1f1..0000000
Binary files a/STATIC_REGRET/STUFF.DAT and /dev/null differ
diff --git a/STATIC_REGRET/TRIG.DAT b/STATIC_REGRET/TRIG.DAT
deleted file mode 100644
index c5eda52..0000000
Binary files a/STATIC_REGRET/TRIG.DAT and /dev/null differ
diff --git a/STATIC_REGRET/TYPEFLAG.DAT b/STATIC_REGRET/TYPEFLAG.DAT
deleted file mode 100644
index 6787d89..0000000
Binary files a/STATIC_REGRET/TYPEFLAG.DAT and /dev/null differ
diff --git a/STATIC_REGRET/WPNOVLAY.DAT b/STATIC_REGRET/WPNOVLAY.DAT
deleted file mode 100644
index ff70c57..0000000
Binary files a/STATIC_REGRET/WPNOVLAY.DAT and /dev/null differ
diff --git a/STATIC_REGRET/XFORMPAL.DAT b/STATIC_REGRET/XFORMPAL.DAT
deleted file mode 100644
index fc88514..0000000
Binary files a/STATIC_REGRET/XFORMPAL.DAT and /dev/null differ
diff --git a/USECODE/EUSECODE_extracted/pseudocode/BART/slot_0F_enterFastArea.txt b/USECODE/EUSECODE_extracted/pseudocode/BART/slot_0F_enterFastArea.txt
index 1a2c1da..8762eff 100644
--- a/USECODE/EUSECODE_extracted/pseudocode/BART/slot_0F_enterFastArea.txt
+++ b/USECODE/EUSECODE_extracted/pseudocode/BART/slot_0F_enterFastArea.txt
@@ -11,59 +11,96 @@ function bart_enterFastArea() /* entry=117 class_id=0x01F5 slot=0x0F */
process_exclude();
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);
- }
- }
- }
+ 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;
block_0470:
return;
diff --git a/crusader_decompilation_notes.md b/crusader_decompilation_notes.md
index aee61ca..b6c3a7a 100644
--- a/crusader_decompilation_notes.md
+++ b/crusader_decompilation_notes.md
@@ -8,8 +8,6 @@ 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.
@@ -38,7 +36,6 @@ 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
deleted file mode 100644
index 107ad50..0000000
--- a/docs/map-rendering.md
+++ /dev/null
@@ -1,295 +0,0 @@
-# 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 f72b5a3..c3a6746 100644
--- a/docs/usecode-roundtrip-ir.md
+++ b/docs/usecode-roundtrip-ir.md
@@ -790,19 +790,4 @@ 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.
-
-## **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
+That gets to a reversible editor sooner than waiting for a full semantic VM recovery.
\ No newline at end of file
diff --git a/plan-mid.md b/plan-mid.md
index 788f0ba..dd8e639 100644
--- a/plan-mid.md
+++ b/plan-mid.md
@@ -49,7 +49,6 @@ 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.
@@ -163,7 +162,6 @@ 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
@@ -174,17 +172,16 @@ 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.
-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`.
+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`.
## Remaining Work To Reach A Reasonably Complete Decompilation State
diff --git a/render_maps.bat b/render_maps.bat
deleted file mode 100644
index d12ea80..0000000
--- a/render_maps.bat
+++ /dev/null
@@ -1,84 +0,0 @@
-@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
deleted file mode 100644
index ed32c05..0000000
--- a/tools/crusader_map/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .cli import main
-
-__all__ = ["main"]
diff --git a/tools/crusader_map/cli.py b/tools/crusader_map/cli.py
deleted file mode 100644
index 7672431..0000000
--- a/tools/crusader_map/cli.py
+++ /dev/null
@@ -1,261 +0,0 @@
-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
deleted file mode 100644
index 7e93277..0000000
--- a/tools/crusader_map/formats.py
+++ /dev/null
@@ -1,516 +0,0 @@
-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
deleted file mode 100644
index fdb367a..0000000
--- a/tools/crusader_map/png.py
+++ /dev/null
@@ -1,67 +0,0 @@
-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
deleted file mode 100644
index fe61f92..0000000
--- a/tools/crusader_map/sorting.py
+++ /dev/null
@@ -1,418 +0,0 @@
-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 a36fa3f..4095472 100644
--- a/tools/poc_crusader_usecode_parser.py
+++ b/tools/poc_crusader_usecode_parser.py
@@ -2544,110 +2544,6 @@ 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],
@@ -2739,20 +2635,34 @@ def render_structured_region(
index = selector_join_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
+ 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
true_tail_index = last_nonempty_block_index(blocks, index + 1, target_index)
if true_tail_index is not None:
@@ -2907,38 +2817,6 @@ 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}")
@@ -2977,47 +2855,6 @@ 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
deleted file mode 100644
index 4dd648b..0000000
--- a/tools/render_all_maps.py
+++ /dev/null
@@ -1,121 +0,0 @@
-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
deleted file mode 100644
index bfc6778..0000000
--- a/tools/render_crusader_map.py
+++ /dev/null
@@ -1,14 +0,0 @@
-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 e5a6d54..8b501d2 100644
--- a/tools/tests/test_usecode_structuring.py
+++ b/tools/tests/test_usecode_structuring.py
@@ -9,7 +9,6 @@ from tools.poc_crusader_usecode_parser import (
render_partially_structured_blocks,
render_structured_pseudocode,
try_decode_loop_selector,
- validate_pseudocode_text,
)
@@ -223,58 +222,6 @@ 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