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