From af5b77ea13322bb4bd4d2b6aa66028f451854350 Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Thu, 26 Mar 2026 23:12:38 +0100 Subject: [PATCH] Map sorting and usecode --- crusader_decompilation_notes.md | 3 + docs/map-rendering.md | 221 +++++ docs/usecode-roundtrip-ir.md | 17 +- plan-mid.md | 21 +- tools/poc_crusader_usecode_parser.py | 219 ++++- tools/render_crusader_map.py | 1000 +++++++++++++++++++++++ tools/tests/test_usecode_structuring.py | 53 ++ 7 files changed, 1496 insertions(+), 38 deletions(-) create mode 100644 docs/map-rendering.md create mode 100644 tools/render_crusader_map.py diff --git a/crusader_decompilation_notes.md b/crusader_decompilation_notes.md index b6c3a7a..aee61ca 100644 --- a/crusader_decompilation_notes.md +++ b/crusader_decompilation_notes.md @@ -8,6 +8,8 @@ Recent verified localized-build batch: [docs/spanish-cheat-differences.md](docs/ Recent verified batch: [docs/retail-debug-arg.md](docs/retail-debug-arg.md) now records the live NE proof that retail `CRUSADER.EXE` still recognizes and executes a real `-debug` command-line branch. That branch prints `Debugging mode ON.`, sets `g_debugMsgLevel` at `1478:87e0`, and toggles two debug globals at `1478:0845/0859`. The later sink pass also closes the text-output target more tightly: `ProbablyPrintDebugMessage` formats through the static stdio-style table at `1478:6c32..6c81` and writes to the handle-`1` entry at `1478:6c46`, so the non-video side is ordinary DOS `stdout` gated by the debug threshold, plus the already-confirmed AVI timing overlay. Current best read remains `surviving debug-output / instrumentation switch`, not `the missing bootstrap for the hidden seg109/seg1408 usecode debugger`. The same batch also leaves the earlier `-laurie` and `0x659c/659e` debugger-state conclusions intact: `-debug` is a separate switch and is not currently evidenced as constructing the hidden usecode-debugger break-state object. +Recent tooling batch: [docs/map-rendering.md](docs/map-rendering.md) now starts a dedicated offline map-rendering lane. `tools/render_crusader_map.py` can load `FIXED.DAT`, expand `GLOB.FLX`, decode the required `SHAPES.FLX` entries with Crusader frame headers, apply `GAMEPAL.PAL`, and write a first-pass PNG, with a `--fixed-dat` override so the same pipeline can be pointed at either game's map file. The current renderer is intentionally limited to fixed-map content and a simple deterministic painter rather than the full Pentagram/ScummVM dependency sorter, and the current workspace caveat is that `STATIC_REGRET` still lacks a copied `FIXED.DAT`, so No Regret rendering needs that file supplied explicitly. + Latest doc-reconciliation batch: [docs/ne-segment1.md](docs/ne-segment1.md) now has a combined hidden-debugger component table that explicitly separates the seg109/raw-reference UI wrappers (`000b:9a86`, `000b:9c0d`, `000b:b3b1`, `000b:b62c`, `000b:2882`) from the live seg1408 breakpoint-state helpers (`1408:0000`, `1408:0053`, `1408:00dd`, `1408:029e`, `1408:03b0`, `1408:03f7`, `1408:0419`, `1408:0432`, `1408:0444`) and the interpreter hook at `1418:04aa..04b5`. Current best read remains `two connected layers of one hidden usecode debugger`, not `conflicting address claims for the same function family`. Follow-up cheat-key correction pass: [docs/ne-segment1.md](docs/ne-segment1.md) now also records a live NE cleanup of several folklore keyboard-cheat claims. `~` is a real runtime cheat-latch toggle at `13e8:203d`, `Ctrl+C` is wrong for this build and should be `Ctrl+L` for the coordinate popup at `13e8:255e`, and the third F7-family overlay really does exist as a separate `Ctrl+F7` path at `13e8:1a20` alongside the other two cheat-gated F7 overlay toggles. @@ -36,6 +38,7 @@ The same `docs/ne-segment1.md` note now also has the first consolidated cheat/de | [docs/retail-debug-arg.md](docs/retail-debug-arg.md) | Focused note on the retail `-debug` command-line switch: live parser evidence, exact startup message, surviving globals, segment `1468` instrumentation path, and why it is currently separate from the hidden usecode debugger bootstrap | | [docs/scummvm-crusader-reference.md](docs/scummvm-crusader-reference.md) | ScummVM Ultima8/Pentagram Crusader integration survey: USECODE/event tables, FLEX/resource formats, world/map loaders, HUD/media, and RE follow-up priorities | | [docs/pentagram-crusader-reference.md](docs/pentagram-crusader-reference.md) | Pentagram-source Crusader/U8 reference: direct Crusader USECODE parser and VM evidence, U8 usecode docs, runtime-confidence limits, and cross-checks against the ScummVM note | +| [docs/map-rendering.md](docs/map-rendering.md) | Offline map-rendering lane: `FIXED.DAT`/`GLOB.FLX`/`SHAPES.FLX`/`GAMEPAL.PAL` format notes, current Python renderer, supported inputs, and fidelity gaps | | [docs/usecode-roundtrip-ir.md](docs/usecode-roundtrip-ir.md) | ScummVM-to-binary USECODE cross-walk, owner-loaded class-layout and header/event-count reconciliation, conservative IR v0 plan, and the generated class-event/body-window outputs that now ground reversible `_BOOT`, `SURCAM*`, and environmental family decompile artifacts plus repeated-family regression checks | | [docs/usecode-pentagram-ghidra-path.md](docs/usecode-pentagram-ghidra-path.md) | Pentagram-derived Crusader USECODE parser plan, proof-of-concept workflow, canonical IR v1 goals, and the Ghidra-side annotation import path | | [docs/usecode-tooling-comparison.md](docs/usecode-tooling-comparison.md) | Comparison of Pentagram's converter/disassembler, the local `crusader-disasm` corpus/scripts, and the current workspace parser/pseudocode exporter, with emphasis on assumptions, strengths, and repo-specific differences | diff --git a/docs/map-rendering.md b/docs/map-rendering.md new file mode 100644 index 0000000..dd2ef26 --- /dev/null +++ b/docs/map-rendering.md @@ -0,0 +1,221 @@ +# 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` + +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 + +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 +``` + +## 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. + +## Immediate Follow-Ups + +1. Validate and tune the dependency sorter against representative Remorse and Regret rooms, especially tall wall seams and dense prop clusters. +2. Add optional atlas export for all shapes touched by a chosen map. +3. Add a second path for movable/dynamic content once the relevant Crusader save/runtime files are pinned down for both games. +4. Compare a few rendered regions against known in-game screenshots to tighten projection and ordering errors. +5. Add optional per-item manifest output with `(shape, frame, x, y, z, source)` rows for debugging bad composites. +6. Revisit raw `0007` rendering notes and the live executable only if the current Pentagram/ScummVM overlap model proves insufficient for specific remaining errors. \ No newline at end of file diff --git a/docs/usecode-roundtrip-ir.md b/docs/usecode-roundtrip-ir.md index c3a6746..f72b5a3 100644 --- a/docs/usecode-roundtrip-ir.md +++ b/docs/usecode-roundtrip-ir.md @@ -790,4 +790,19 @@ The strongest present path to a usable compiler/decompiler is: 5. Attach ScummVM event and intrinsic names as hints, not as truth. 6. Recompile by rebuilding the original class header and event table layout first, then re-emitting decoded and opaque ops together. -That gets to a reversible editor sooner than waiting for a full semantic VM recovery. \ No newline at end of file +That gets to a reversible editor sooner than waiting for a full semantic VM recovery. + +## **Recent Research (2026-03-26)** + +- **Root Cause:**: The structuring pass left forward/back-edge loops and counted-loop headers detached in fallback output, which produced unstructured pseudocode for some bodies (notably BART slot 0x0F). +- **Renderer Fixes:**: Added a conservative loop-lifting helper and a restricted infinite-loop lift in the partial fallback renderer to fold loops into structured blocks where safe. See the modified renderer at [tools/poc_crusader_usecode_parser.py](tools/poc_crusader_usecode_parser.py). +- **Validator Added:**: A lightweight pseudocode syntax/label validator was added to detect brace mismatches and missing goto/label targets before exporting pseudocode. +- **Tests:**: Added and adjusted unit tests in [tools/tests/test_usecode_structuring.py](tools/tests/test_usecode_structuring.py) to guard loop-lifting behavior and fallback conservatism. +- **Corpus Validation:**: Ran a corpus-wide render+validator pass over 977 decoded bodies; result: `TOTAL_BODIES=977, FAILURES=0` (no syntax/label failures). +- **Real-World Output:**: Regenerated the BART pseudocode file — [USECODE/EUSECODE_extracted/pseudocode/BART/slot_0F_enterFastArea.txt](USECODE/EUSECODE_extracted/pseudocode/BART/slot_0F_enterFastArea.txt) now shows an outer `while(true)` with nested structured branches and counted loops instead of detached labels. +- **Scope & Safety:**: Fully-structured renderer remains conservative; the loop-lifting helper is reused where safe. The outer infinite-loop lift was narrowed to partial fallback after tests revealed regressions when it was too broad. +- **Remaining Semantic Gap:**: Expression/comparison operand polarity still needs correction (some counted-loop conditions show inverted comparisons). Next work: fix operand ordering in the expression builder so loop headers reflect correct comparison direction. +- **Next Steps:**: (1) Implement compare-direction fix in the expression builder and add small semantic regression tests, (2) re-run unit tests and a corpus-wide render+validate sweep, (3) regenerate affected pseudocode files for inspection. +- **Files of Interest:**: [tools/poc_crusader_usecode_parser.py](tools/poc_crusader_usecode_parser.py), [tools/tests/test_usecode_structuring.py](tools/tests/test_usecode_structuring.py), [USECODE/EUSECODE_extracted/pseudocode/BART/slot_0F_enterFastArea.txt](USECODE/EUSECODE_extracted/pseudocode/BART/slot_0F_enterFastArea.txt). + +If you want, I can (a) implement the comparison/operand polarity fix next, (b) run the unit tests and a fresh corpus sweep, and (c) open a PR-ready commit with these doc and code updates. \ No newline at end of file diff --git a/plan-mid.md b/plan-mid.md index dd8e639..788f0ba 100644 --- a/plan-mid.md +++ b/plan-mid.md @@ -49,6 +49,7 @@ Detailed completed analysis belongs in the files under `docs/`, not in this plan - 000a/000d tracked-handle, cache, allocator, dispatch-entry, and startup/display support lanes now have a coherent partial map. - 000e parser and animation subsystems have a real partial map. - The auxiliary local disassembly corpus at `K:/ghidra/crusader-disasm` is now inventoried and integrated as a separate evidence source for shape metadata, static map/object dumps, opcode names, and older Remorse/Regret intrinsic-function vocabularies; its safe-reuse rules and porting implications are captured in `docs/crusader-disasm-reference.md`. +- The workspace now also has a first dedicated offline map-rendering/tooling lane: `tools/render_crusader_map.py` can load a chosen `FIXED.DAT`, expand `GLOB.FLX`, decode required `SHAPES.FLX` frames, apply `GAMEPAL.PAL`, and emit a first-pass PNG from either static set, while `docs/map-rendering.md` captures the current format contracts, the `--fixed-dat` override, and the intentionally limited compositor model. - The USECODE/VM owner/resource/runtime lane now has a workable partial model, a named sequencer entry, paired external file-family loader evidence, and supporting extraction/reporting tooling. - The USECODE/VM tooling lane now also has a concrete near-term implementation path: a Pentagram-derived proof-of-concept parser can reuse opcode decoding while swapping in the locally verified owner-loaded class and slot arithmetic, with a hybrid Ghidra comment/bookmark import path instead of a premature custom processor module. - The USECODE tooling lane now also has a first full readable corpus export: `tools/export_usecode_pseudocode.py` writes `977` current pseudocode bodies into `USECODE/EUSECODE_extracted/pseudocode`, and the first focused read of that corpus now shows `JELYHACK::use` / `JELYH2::use` as tiny shared `set_info(0x0207) -> process_exclude -> return` stubs rather than hidden active event cores. @@ -162,6 +163,7 @@ Detailed completed analysis belongs in the files under `docs/`, not in this plan 3. Refine the coverage ledger from already-verified notes before broadening into fresh segment sweeps. 4. Use boundary repair only on active blockers with clear payoff, with `000c:db68` now downgraded to optional hygiene unless it blocks adjacent work again. 5. Revisit the `0x4588` callback object only when caller-side evidence is strong enough to support behavioral naming. +6. Use the new offline map-rendering lane to cross-check shape ids, map placements, and visible world composition against `crusader-disasm` shape/map notes before promoting additional rendering- or static-object-related names in `CRUSADER.EXE`. ## Next Resume Point @@ -172,16 +174,17 @@ Detailed completed analysis belongs in the files under `docs/`, not in this plan 5. Refine the coverage ledger from already-verified notes before broadening into fresh segment sweeps. 6. Use boundary repair only on active blockers with clear payoff, with `000c:db68` now downgraded to optional hygiene unless it blocks adjacent work again. 7. Revisit the `0x4588` callback object only when caller-side evidence is strong enough to support behavioral naming. +8. Exercise `tools/render_crusader_map.py` on a few representative No Remorse and No Regret maps, then tighten the paint order using `TYPEFLAG.DAT` footpads and any mismatches visible against in-game screenshots or `crusader-disasm` map evidence. -8. Recover the real upstream caller/selector path into `entity_vm_opcode_sequence_run`, most likely by finding the first non-recursive `0x6714` context-method caller or vtable dispatch site rather than by repeating raw xref queries that still return no direct edges. -9. Recover real caller roles for `entity_vm_context_try_create_mask_0400_slot0a_with_offset` and `entity_vm_context_try_create_mask_0800_slot0b_with_offset` by treating them as the remaining dark members of the now-verified signed-additive masked-materializer subfamily and comparing them against the newly anchored slot-`0x12` caller pattern. -10. Tighten the newly surfaced higher-slot wrapper ladder around `0005:3115..31da`, especially the two slot-`0x12` caller sites at `0005:1776` / `0005:1945` and the slot-`0x10` guarded callsite, so any future promotion to `leaveFastArea` / `func11|cast` / `justMoved` / `AvatarStoleSomething` / `animGetHit` is driven by binary caller behavior rather than by external tables alone. -11. Tighten the outward caller chains around the renamed seg006 masked helpers `entity_vm_context_try_create_mask_0008_slot30_with_offset` (`0006:0ba4`) and `entity_vm_context_try_create_mask_0010_slot08_with_offset_if_ready` (`0006:108c`) so the local state-selector lane and the adjacent class-linked value family can be tied back to concrete gameplay subsystems rather than only to class-detail fields. -12. Tighten the paired-file-family reading of the seg070 twin loops at `0009:67b6` and `0009:6916` by recovering which temporary buffer and record schema each family populates behind `entity_vm_runtime_owner_resource_create`. -13. Promote additional ledger rows where the current docs already justify `Foothold`, `Partial`, or `Deep`. -14. If the VM lane stalls again, revisit `000e:ffb0` from the now-verified `00db/00dc` caller windows and try to recover an adjacent non-overlapped helper before attempting any boundary repair. -15. If the immortality lane is revisited, stay focused on `NPCTRIG` slot `0x0a` first, with slot `0x20` still treated as the typed/setup companion and `EVENT` only as the generic hub baseline; the three currently recovered direct `0005:295f` caller families are now all closed and comment-backed in the live NE program at `10f0:02d9`, `10f0:0379`, `10f0:03c3`, `10f0:03e5`, `1128:0ff0`, and `1138:1384`, so the next defensible step is an earlier producer that assigns subtype `0x20b/0x20c` into field `+0x3c` or otherwise chooses the owner-loaded class family before these generic damage consumers run. -16. Use the new Pentagram-derived parser proof of concept as the first tooling bridge for raw class/slot bodies: extend opcode coverage conservatively, emit IR v1 artifacts, and only then prototype a Ghidra-side annotation importer against compiled anchors like `000d:51fd`, `000d:5572`, `000d:46ec`, `000d:22bc`, and `000d:ebe3`. +9. Recover the real upstream caller/selector path into `entity_vm_opcode_sequence_run`, most likely by finding the first non-recursive `0x6714` context-method caller or vtable dispatch site rather than by repeating raw xref queries that still return no direct edges. +10. Recover real caller roles for `entity_vm_context_try_create_mask_0400_slot0a_with_offset` and `entity_vm_context_try_create_mask_0800_slot0b_with_offset` by treating them as the remaining dark members of the now-verified signed-additive masked-materializer subfamily and comparing them against the newly anchored slot-`0x12` caller pattern. +11. Tighten the newly surfaced higher-slot wrapper ladder around `0005:3115..31da`, especially the two slot-`0x12` caller sites at `0005:1776` / `0005:1945` and the slot-`0x10` guarded callsite, so any future promotion to `leaveFastArea` / `func11|cast` / `justMoved` / `AvatarStoleSomething` / `animGetHit` is driven by binary caller behavior rather than by external tables alone. +12. Tighten the outward caller chains around the renamed seg006 masked helpers `entity_vm_context_try_create_mask_0008_slot30_with_offset` (`0006:0ba4`) and `entity_vm_context_try_create_mask_0010_slot08_with_offset_if_ready` (`0006:108c`) so the local state-selector lane and the adjacent class-linked value family can be tied back to concrete gameplay subsystems rather than only to class-detail fields. +13. Tighten the paired-file-family reading of the seg070 twin loops at `0009:67b6` and `0009:6916` by recovering which temporary buffer and record schema each family populates behind `entity_vm_runtime_owner_resource_create`. +14. Promote additional ledger rows where the current docs already justify `Foothold`, `Partial`, or `Deep`. +15. If the VM lane stalls again, revisit `000e:ffb0` from the now-verified `00db/00dc` caller windows and try to recover an adjacent non-overlapped helper before attempting any boundary repair. +16. If the immortality lane is revisited, stay focused on `NPCTRIG` slot `0x0a` first, with slot `0x20` still treated as the typed/setup companion and `EVENT` only as the generic hub baseline; the three currently recovered direct `0005:295f` caller families are now all closed and comment-backed in the live NE program at `10f0:02d9`, `10f0:0379`, `10f0:03c3`, `10f0:03e5`, `1128:0ff0`, and `1138:1384`, so the next defensible step is an earlier producer that assigns subtype `0x20b/0x20c` into field `+0x3c` or otherwise chooses the owner-loaded class family before these generic damage consumers run. +17. Use the new Pentagram-derived parser proof of concept as the first tooling bridge for raw class/slot bodies: extend opcode coverage conservatively, emit IR v1 artifacts, and only then prototype a Ghidra-side annotation importer against compiled anchors like `000d:51fd`, `000d:5572`, `000d:46ec`, `000d:22bc`, and `000d:ebe3`. ## Remaining Work To Reach A Reasonably Complete Decompilation State diff --git a/tools/poc_crusader_usecode_parser.py b/tools/poc_crusader_usecode_parser.py index 4095472..a36fa3f 100644 --- a/tools/poc_crusader_usecode_parser.py +++ b/tools/poc_crusader_usecode_parser.py @@ -2544,6 +2544,110 @@ def render_selector_chain( return rendered, label_to_index[join_label] +def render_loop_construct( + blocks: list[tuple[str, list[str]]], + label_to_index: dict[str, int], + index: int, + end_index: int, + return_labels: set[str], + active_regions: set[tuple[int, int, tuple[str, ...]]] | None = None, + render_cache: dict[tuple[int, int, tuple[str, ...]], tuple[list[str], bool] | None] | None = None, +) -> tuple[list[str], int] | None: + _, statements = blocks[index] + if not statements: + return None + + terminal = parse_terminal_statement(statements[-1]) + if terminal is None or terminal.kind != "if": + return None + + target_label = terminal.target or "" + target_index = label_to_index.get(target_label) + if target_index is None or target_index <= index or target_index > end_index: + return None + + loop_tail_index = last_nonempty_block_index(blocks, index + 1, target_index) + if loop_tail_index is None: + return None + + loop_tail_terminal = parse_terminal_statement(blocks[loop_tail_index][1][-1]) + if loop_tail_terminal is None or loop_tail_terminal.kind != "goto" or loop_tail_terminal.target != blocks[index][0]: + return None + + loop_body = render_structured_region( + blocks, + label_to_index, + index + 1, + target_index, + return_labels, + {blocks[index][0]}, + active_regions, + render_cache, + ) + if loop_body is None: + return None + + loop_lines, _ = loop_body + loop_selector = None + if index > 0 and is_loop_selector_only_block(blocks[index - 1][1]): + loop_selector = parse_loop_selector_statement(blocks[index - 1][1][0]) + + rendered: list[str] = [] + if loop_selector is not None: + rendered.append(f"for {loop_selector} {{") + else: + rendered.append(f"while ({invert_condition_text(terminal.condition or 'condition')}) {{") + rendered.extend(indent_lines(loop_lines)) + rendered.append("}") + return rendered, target_index + + +def render_infinite_loop_construct( + blocks: list[tuple[str, list[str]]], + label_to_index: dict[str, int], + index: int, + end_index: int, + return_labels: set[str], + active_regions: set[tuple[int, int, tuple[str, ...]]] | None = None, + render_cache: dict[tuple[int, int, tuple[str, ...]], tuple[list[str], bool] | None] | None = None, +) -> tuple[list[str], int] | None: + if index + 1 >= end_index: + return None + + loop_label = blocks[index][0] + loop_tail_index: int | None = None + for candidate in range(end_index - 1, index, -1): + statements = blocks[candidate][1] + if not statements: + continue + terminal = parse_terminal_statement(statements[-1]) + if terminal is not None and terminal.kind == "goto" and terminal.target == loop_label: + loop_tail_index = candidate + break + + if loop_tail_index is None: + return None + + loop_body = render_structured_region( + blocks, + label_to_index, + index, + loop_tail_index + 1, + return_labels, + {loop_label}, + active_regions, + render_cache, + ) + if loop_body is None: + return None + + loop_lines, _ = loop_body + rendered = ["while (true) {"] + rendered.extend(indent_lines(loop_lines)) + rendered.append("}") + return rendered, loop_tail_index + 1 + + def render_structured_region( blocks: list[tuple[str, list[str]]], label_to_index: dict[str, int], @@ -2635,34 +2739,20 @@ def render_structured_region( index = selector_join_index continue - if target_index <= end_index: - loop_tail_index = last_nonempty_block_index(blocks, index + 1, target_index) - if loop_tail_index is not None: - loop_tail_terminal = parse_terminal_statement(blocks[loop_tail_index][1][-1]) - if loop_tail_terminal is not None and loop_tail_terminal.kind == "goto" and loop_tail_terminal.target == blocks[index][0]: - loop_body = render_structured_region( - blocks, - label_to_index, - index + 1, - target_index, - return_labels, - {blocks[index][0]}, - active_regions, - render_cache, - ) - if loop_body is not None: - loop_lines, _ = loop_body - loop_selector = None - if index > start_index: - loop_selector = parse_loop_selector_statement(blocks[index - 1][1][0]) if is_loop_selector_only_block(blocks[index - 1][1]) else None - if loop_selector is not None: - lines.append(f"for {loop_selector} {{") - else: - lines.append(f"while ({invert_condition_text(terminal.condition or 'condition')}) {{") - lines.extend(indent_lines(loop_lines)) - lines.append("}") - index = target_index - continue + loop_construct = render_loop_construct( + blocks, + label_to_index, + index, + end_index, + return_labels, + active_regions, + render_cache, + ) + if loop_construct is not None: + loop_lines, loop_join_index = loop_construct + lines.extend(loop_lines) + index = loop_join_index + continue true_tail_index = last_nonempty_block_index(blocks, index + 1, target_index) if true_tail_index is not None: @@ -2817,6 +2907,38 @@ def render_partially_structured_blocks(blocks: list[tuple[str, list[str]]]) -> l index = selector_join_index continue + loop_construct = render_loop_construct( + blocks, + label_to_index, + index, + len(blocks), + return_labels, + ) + if loop_construct is not None: + loop_lines, loop_join_index = loop_construct + lines.append(f" {label}:") + for statement in loop_lines: + lines.append(f" {statement}" if statement else "") + lines.append("") + index = loop_join_index + continue + + infinite_loop_construct = render_infinite_loop_construct( + blocks, + label_to_index, + index, + len(blocks), + return_labels, + ) + if infinite_loop_construct is not None: + loop_lines, loop_join_index = infinite_loop_construct + lines.append(f" {label}:") + for statement in loop_lines: + lines.append(f" {statement}" if statement else "") + lines.append("") + index = loop_join_index + continue + lines.append(f" {label}:") for statement in statements: lines.append(f" {statement}") @@ -2855,6 +2977,47 @@ def render_pseudocode(ir: dict[str, Any], shape_catalog: ShapeCatalog | None = N return apply_shape_catalog_to_pseudocode("\n".join(lines) + "\n", shape_catalog) +def validate_pseudocode_text(text: str) -> list[str]: + errors: list[str] = [] + label_lines: dict[str, int] = {} + goto_targets: list[tuple[str, int]] = [] + brace_depth = 0 + + for line_number, raw_line in enumerate(text.splitlines(), start=1): + stripped = raw_line.strip() + if not stripped: + continue + + if stripped.endswith("{"): + brace_depth += 1 + if stripped == "}": + brace_depth -= 1 + if brace_depth < 0: + errors.append(f"line {line_number}: unexpected closing brace") + brace_depth = 0 + + label_match = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*):", stripped) + if label_match is not None: + label = label_match.group(1) + previous_line = label_lines.get(label) + if previous_line is not None: + errors.append(f"line {line_number}: duplicate label {label} (first at line {previous_line})") + else: + label_lines[label] = line_number + + for match in re.finditer(r"\bgoto ([A-Za-z_][A-Za-z0-9_]*)\s*;", stripped): + goto_targets.append((match.group(1), line_number)) + + if brace_depth != 0: + errors.append(f"unbalanced braces: final depth {brace_depth}") + + for target, line_number in goto_targets: + if target not in label_lines: + errors.append(f"line {line_number}: goto target {target} has no label") + + return errors + + def render_text(ir: dict[str, Any]) -> str: labels = build_listing_labels(ir) diff --git a/tools/render_crusader_map.py b/tools/render_crusader_map.py new file mode 100644 index 0000000..496e3f8 --- /dev/null +++ b/tools/render_crusader_map.py @@ -0,0 +1,1000 @@ +from __future__ import annotations + +import argparse +import json +import struct +import sys +import zlib +from dataclasses import dataclass, field +from pathlib import Path + + +FLEX_TABLE_OFFSET = 0x80 +FLEX_COUNT_OFFSET = 0x54 +FIXED_MAP_COUNT_OFFSET = 0x54 +FIXED_MAP_TABLE_OFFSET = 0x80 +CRUSADER_COORD_SCALE = 2 +GLOB_COORD_MASK = ~0x3FF +GLOB_COORD_SHIFT = 2 +GLOB_COORD_OFFSET = 2 +FLAG_INVISIBLE = 0x0010 +FLAG_FLIPPED = 0x0020 +DEFAULT_BACKGROUND = (10, 12, 18, 255) +EGG_FAMILIES = {3, 4, 7, 8} + +SI_FIXED = 0x0001 +SI_SOLID = 0x0002 +SI_LAND = 0x0008 +SI_OCCL = 0x0010 +SI_NOISY = 0x0080 +SI_DRAW = 0x0100 +SI_ROOF = 0x0400 +SI_TRANSL = 0x0800 + + +def read_u16_le(data: bytes, offset: int) -> int: + return struct.unpack_from(" int: + return struct.unpack_from(" int: + return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) + + +@dataclass(frozen=True) +class FlexEntry: + offset: int + size: int + + +@dataclass(frozen=True) +class ShapeInfo: + family: int + flags: int + x: int + y: int + z: int + anim_type: int + + @property + def is_editor(self) -> bool: + return bool(self.flags & 0x1000) + + @property + def is_fixed(self) -> bool: + return bool(self.flags & SI_FIXED) + + @property + def is_solid(self) -> bool: + return bool(self.flags & SI_SOLID) + + @property + def is_land(self) -> bool: + return bool(self.flags & SI_LAND) + + @property + def is_occl(self) -> bool: + return bool(self.flags & SI_OCCL) + + @property + def is_noisy(self) -> bool: + return bool(self.flags & SI_NOISY) + + @property + def is_draw(self) -> bool: + return bool(self.flags & SI_DRAW) + + @property + def is_roof(self) -> bool: + return bool(self.flags & SI_ROOF) + + @property + def is_translucent(self) -> bool: + return bool(self.flags & SI_TRANSL) + + @property + def is_invitem(self) -> bool: + return self.family == 13 + + +@dataclass(frozen=True) +class GlobItem: + x: int + y: int + z: int + shape: int + frame: int + + +@dataclass(frozen=True) +class MapItem: + x: int + y: int + z: int + shape: int + frame: int + flags: int + quality: int + npc_num: int + map_num: int + next_item: int + source: str + + +@dataclass(frozen=True) +class ShapeFrame: + compressed: bool + width: int + height: int + xoff: int + yoff: int + line_offsets: tuple[int, ...] + rle_data: bytes + + +@dataclass +class SortNode: + item: MapItem + info: ShapeInfo + frame: ShapeFrame + pixels: list[int] + left: int + top: int + right: int + bottom: int + x: int + x_left: int + y: int + y_far: int + z: int + z_top: int + sx_left: int + sx_right: int + sx_top: int + sy_top: int + sx_bot: int + sy_bot: int + fbigsq: bool + flat: bool + occl: bool + solid: bool + draw: bool + roof: bool + noisy: bool + anim: bool + trans: bool + fixed: bool + land: bool + sprite: bool + invitem: bool + occluded: bool = False + order: int = -1 + depends: list["SortNode"] = field(default_factory=list) + + def list_less_than(self, other: "SortNode") -> bool: + if self.sprite != other.sprite: + return self.sprite < other.sprite + if self.z != other.z: + return self.z < other.z + return self.flat > other.flat + + def overlap(self, other: "SortNode") -> bool: + if not rect_intersects(self, other): + return False + + point_top_diff = (self.sx_top - other.sx_bot, self.sy_top - other.sy_bot) + point_bot_diff = (self.sx_bot - other.sx_top, self.sy_bot - other.sy_top) + + dot_top_left = point_top_diff[0] + point_top_diff[1] * 2 + dot_top_right = -point_top_diff[0] + point_top_diff[1] * 2 + dot_bot_left = point_bot_diff[0] - point_bot_diff[1] * 2 + dot_bot_right = -point_bot_diff[0] - point_bot_diff[1] * 2 + + right_clear = self.sx_right <= other.sx_left + left_clear = self.sx_left >= other.sx_right + top_left_clear = dot_top_left >= 0 + top_right_clear = dot_top_right >= 0 + bot_left_clear = dot_bot_left >= 0 + bot_right_clear = dot_bot_right >= 0 + + clear = right_clear or left_clear or (bot_right_clear or bot_left_clear) or (top_right_clear or top_left_clear) + return not clear + + def occludes(self, other: "SortNode") -> bool: + if not rect_contains(self, other): + return False + + point_top_diff = (self.sx_top - other.sx_top, self.sy_top - other.sy_top) + point_bot_diff = (self.sx_bot - other.sx_bot, self.sy_bot - other.sy_bot) + + dot_top_left = point_top_diff[0] + point_top_diff[1] * 2 + dot_top_right = -point_top_diff[0] + point_top_diff[1] * 2 + dot_bot_left = point_bot_diff[0] - point_bot_diff[1] * 2 + dot_bot_right = -point_bot_diff[0] - point_bot_diff[1] * 2 + + right_res = self.sx_right >= other.sx_right + left_res = self.sx_left <= other.sx_left + top_left_res = dot_top_left <= 0 + top_right_res = dot_top_right <= 0 + bot_left_res = dot_bot_left <= 0 + bot_right_res = dot_bot_right <= 0 + return right_res and left_res and bot_right_res and bot_left_res and top_right_res and top_left_res + + def below(self, other: "SortNode") -> bool: + if self.sprite != other.sprite: + return self.sprite < other.sprite + + if self.flat and other.flat: + if self.z != other.z: + return self.z < other.z + elif self.invitem == other.invitem: + if self.z_top <= other.z: + return True + if self.z >= other.z_top: + return False + + y_flat_self = self.y_far == self.y + y_flat_other = other.y_far == other.y + if y_flat_self and y_flat_other: + if self.y // 32 != other.y // 32: + return self.y < other.y + else: + if self.y <= other.y_far: + return True + if self.y_far >= other.y: + return False + + x_flat_self = self.x_left == self.x + x_flat_other = other.x_left == other.x + if x_flat_self and x_flat_other: + if self.x // 32 != other.x // 32: + return self.x < other.x + else: + if self.x <= other.x_left: + return True + if self.x_left >= other.x: + return False + + if self.z_top - 8 <= other.z and self.z < other.z_top - 8: + return True + if self.z >= other.z_top - 8 and self.z_top - 8 > other.z: + return False + + if y_flat_self != y_flat_other: + if self.y // 32 <= other.y_far // 32: + return True + if self.y_far // 32 >= other.y // 32: + return False + y_center_self = (self.y_far // 32 + self.y // 32) // 2 + y_center_other = (other.y_far // 32 + other.y // 32) // 2 + if y_center_self != y_center_other: + return y_center_self < y_center_other + + if x_flat_self != x_flat_other: + if self.x // 32 <= other.x_left // 32: + return True + if self.x_left // 32 >= other.x // 32: + return False + x_center_self = (self.x_left // 32 + self.x // 32) // 2 + x_center_other = (other.x_left // 32 + other.x // 32) // 2 + if x_center_self != x_center_other: + return x_center_self < x_center_other + + if self.flat or other.flat: + if self.z != other.z: + return self.z < other.z + if self.invitem != other.invitem: + return self.invitem < other.invitem + if self.flat != other.flat: + return self.flat > other.flat + if self.trans != other.trans: + return self.trans < other.trans + if self.anim != other.anim: + return self.anim < other.anim + if self.draw != other.draw: + return self.draw > other.draw + if self.solid != other.solid: + return self.solid > other.solid + if self.occl != other.occl: + return self.occl > other.occl + if self.fbigsq != other.fbigsq: + return self.fbigsq > other.fbigsq + + if self.x == other.x and self.y == other.y and self.trans != other.trans: + return self.trans < other.trans + + if self.land and other.land and self.roof != other.roof: + return self.roof < other.roof + if self.roof != other.roof: + return self.roof > other.roof + if self.z != other.z: + return self.z < other.z + + if x_flat_self or x_flat_other or y_flat_self or y_flat_other: + if self.sx_left != other.sx_left: + return self.sx_left > other.sx_left + if self.sy_bot != other.sy_bot: + return self.sy_bot < other.sy_bot + + if self.x + self.y != other.x + other.y: + return self.x + self.y < other.x + other.y + if self.x_left + self.y_far != other.x_left + other.y_far: + return self.x_left + self.y_far < other.x_left + other.y_far + if self.y != other.y: + return self.y < other.y + if self.x != other.x: + return self.x < other.x + if self.item.shape != other.item.shape: + return self.item.shape < other.item.shape + return self.item.frame < other.item.frame + + +class FlexArchive: + def __init__(self, path: Path) -> None: + self.path = path + self.data = path.read_bytes() + self.entries = self._read_entries(self.data) + + @staticmethod + def _read_entries(data: bytes) -> list[FlexEntry]: + count = read_u32_le(data, FLEX_COUNT_OFFSET) + entries: list[FlexEntry] = [] + for index in range(count): + base = FLEX_TABLE_OFFSET + index * 8 + entries.append(FlexEntry(read_u32_le(data, base), read_u32_le(data, base + 4))) + return entries + + def get(self, index: int) -> bytes: + entry = self.entries[index] + if entry.size == 0: + return b"" + return self.data[entry.offset : entry.offset + entry.size] + + def __len__(self) -> int: + return len(self.entries) + + +class ShapeArchive: + def __init__(self, path: Path) -> None: + self.archive = FlexArchive(path) + self._shape_cache: dict[int, tuple[ShapeFrame, ...]] = {} + self._decoded_frame_cache: dict[tuple[int, int], list[int]] = {} + + def get_frame(self, shape_index: int, frame_index: int) -> ShapeFrame: + frames = self._get_shape(shape_index) + if frame_index < 0 or frame_index >= len(frames): + raise IndexError(f"shape {shape_index} frame {frame_index} out of range") + return frames[frame_index] + + def decode_frame(self, shape_index: int, frame_index: int) -> tuple[ShapeFrame, list[int]]: + cache_key = (shape_index, frame_index) + decoded = self._decoded_frame_cache.get(cache_key) + frame = self.get_frame(shape_index, frame_index) + if decoded is None: + decoded = self._decode_pixels(frame) + self._decoded_frame_cache[cache_key] = decoded + return frame, decoded + + def _get_shape(self, shape_index: int) -> tuple[ShapeFrame, ...]: + cached = self._shape_cache.get(shape_index) + if cached is not None: + return cached + raw = self.archive.get(shape_index) + if not raw: + raise ValueError(f"shape {shape_index} has no data") + frames = self._parse_shape(raw) + self._shape_cache[shape_index] = frames + return frames + + @staticmethod + def _parse_shape(data: bytes) -> tuple[ShapeFrame, ...]: + frame_count = read_u16_le(data, 4) + frames: list[ShapeFrame] = [] + for index in range(frame_count): + header_offset = 6 + index * 8 + frame_offset = read_u24_le(data, header_offset) + frame_size = read_u32_le(data, header_offset + 4) + frame_data = data[frame_offset : frame_offset + frame_size] + if len(frame_data) < 28: + raise ValueError(f"frame {index} too small: {len(frame_data)}") + compressed = bool(read_u32_le(frame_data, 8)) + width = read_u32_le(frame_data, 12) + height = read_u32_le(frame_data, 16) + xoff = struct.unpack_from(" list[int]: + pixels = [-1] * (frame.width * frame.height) + rle = frame.rle_data + for row in range(frame.height): + pos = frame.line_offsets[row] + xpos = 0 + while xpos < frame.width: + if pos >= len(rle): + raise ValueError(f"row {row} overran RLE data") + xpos += rle[pos] + pos += 1 + if xpos == frame.width: + break + if pos >= len(rle): + raise ValueError(f"row {row} missing run header") + dlen = rle[pos] + pos += 1 + run_type = 0 + if frame.compressed: + run_type = dlen & 1 + dlen >>= 1 + if dlen <= 0 or xpos + dlen > frame.width: + raise ValueError(f"invalid run length {dlen} at row {row}") + row_base = row * frame.width + xpos + if run_type == 0: + end = pos + dlen + if end > len(rle): + raise ValueError(f"row {row} literal run overruns RLE data") + run = rle[pos:end] + for index, color in enumerate(run): + pixels[row_base + index] = color + pos = end + else: + if pos >= len(rle): + raise ValueError(f"row {row} repeated-color run missing color byte") + color = rle[pos] + pos += 1 + for index in range(dlen): + pixels[row_base + index] = color + xpos += dlen + return pixels + + +def load_palette(path: Path) -> list[tuple[int, int, int]]: + data = path.read_bytes() + if len(data) < 768: + raise ValueError(f"palette too small: {path}") + palette: list[tuple[int, int, int]] = [] + for index in range(256): + r = (data[index * 3] * 255) // 63 + g = (data[index * 3 + 1] * 255) // 63 + b = (data[index * 3 + 2] * 255) // 63 + palette.append((r, g, b)) + return palette + + +def load_typeflags(path: Path) -> list[ShapeInfo]: + data = path.read_bytes() + infos: list[ShapeInfo] = [] + for base in range(0, len(data), 9): + block = data[base : base + 9] + if len(block) < 9: + break + flags = 0 + if block[0] & 0x01: + flags |= 0x0001 + if block[0] & 0x02: + flags |= 0x0002 + if block[0] & 0x04: + flags |= 0x0004 + if block[0] & 0x08: + flags |= 0x0008 + if block[0] & 0x10: + flags |= 0x0010 + if block[0] & 0x20: + flags |= 0x0020 + if block[0] & 0x40: + flags |= 0x0040 + if block[0] & 0x80: + flags |= 0x0080 + if block[1] & 0x01: + flags |= 0x0100 + if block[1] & 0x02: + flags |= 0x0200 + if block[1] & 0x04: + flags |= 0x0400 + if block[1] & 0x08: + flags |= 0x0800 + if block[6] & 0x01: + flags |= 0x1000 + if block[6] & 0x02: + flags |= 0x2000 + if block[6] & 0x04: + flags |= 0x4000 + if block[6] & 0x08: + flags |= 0x8000 + if block[6] & 0x10: + flags |= 0x10000 + if block[6] & 0x20: + flags |= 0x20000 + if block[6] & 0x40: + flags |= 0x40000 + if block[6] & 0x80: + flags |= 0x80000 + family = (block[1] >> 4) + ((block[2] & 1) << 4) + x = ((block[3] << 3) | (block[2] >> 5)) & 0x1F + y = (block[3] >> 2) & 0x1F + z = ((block[4] << 1) | (block[3] >> 7)) & 0x1F + anim_type = block[4] >> 4 + infos.append(ShapeInfo(family=family, flags=flags, x=x, y=y, z=z, anim_type=anim_type)) + return infos + + +def load_globs(path: Path) -> list[list[GlobItem]]: + archive = FlexArchive(path) + globs: list[list[GlobItem]] = [] + for index in range(len(archive)): + raw = archive.get(index) + if not raw: + globs.append([]) + continue + count = read_u16_le(raw, 0) + items: list[GlobItem] = [] + for item_index in range(count): + base = 2 + item_index * 6 + items.append( + GlobItem( + x=raw[base], + y=raw[base + 1], + z=raw[base + 2], + shape=read_u16_le(raw, base + 3), + frame=raw[base + 5], + ) + ) + globs.append(items) + return globs + + +def load_map_items(path: Path, map_index: int) -> list[MapItem]: + if not path.exists(): + raise FileNotFoundError(path) + data = path.read_bytes() + map_count = read_u16_le(data, FIXED_MAP_COUNT_OFFSET) + if map_index < 0 or map_index >= map_count: + raise ValueError(f"map index {map_index} out of range 0..{map_count - 1}") + table_offset = FIXED_MAP_TABLE_OFFSET + map_index * 8 + map_offset = read_u32_le(data, table_offset) + map_size = read_u32_le(data, table_offset + 4) + payload = data[map_offset : map_offset + map_size] + if len(payload) != map_size: + raise ValueError(f"map {map_index} payload truncated") + items: list[MapItem] = [] + for base in range(0, len(payload), 16): + record = payload[base : base + 16] + if len(record) < 16: + break + x = read_u16_le(record, 0) * CRUSADER_COORD_SCALE + y = read_u16_le(record, 2) * CRUSADER_COORD_SCALE + items.append( + MapItem( + x=x, + y=y, + z=record[4], + shape=read_u16_le(record, 5), + frame=record[7], + flags=read_u16_le(record, 8), + quality=read_u16_le(record, 10), + npc_num=record[12], + map_num=record[13], + next_item=read_u16_le(record, 14), + source="fixed", + ) + ) + return items + + +def expand_glob_item(item: MapItem, globs: list[list[GlobItem]]) -> list[MapItem]: + if item.quality < 0 or item.quality >= len(globs): + return [] + expanded: list[MapItem] = [] + for glob_item in globs[item.quality]: + expanded.append( + MapItem( + x=(item.x & GLOB_COORD_MASK) + (glob_item.x << GLOB_COORD_SHIFT) + GLOB_COORD_OFFSET, + y=(item.y & GLOB_COORD_MASK) + (glob_item.y << GLOB_COORD_SHIFT) + GLOB_COORD_OFFSET, + z=item.z + glob_item.z, + shape=glob_item.shape, + frame=glob_item.frame, + flags=0, + quality=0, + npc_num=0, + map_num=item.map_num, + next_item=0, + source="glob", + ) + ) + return expanded + + +def collect_render_items( + base_items: list[MapItem], + shape_infos: list[ShapeInfo], + globs: list[list[GlobItem]], + include_editor: bool, + expand_globs: bool, + world_rect: tuple[int, int, int, int] | None, +) -> list[MapItem]: + render_items: list[MapItem] = [] + pending = list(base_items) + index = 0 + while index < len(pending): + item = pending[index] + index += 1 + if item.flags & FLAG_INVISIBLE: + continue + if world_rect is not None: + min_x, min_y, max_x, max_y = world_rect + if item.x < min_x or item.y < min_y or item.x > max_x or item.y > max_y: + continue + if item.shape >= len(shape_infos): + continue + info = shape_infos[item.shape] + if info.is_editor and not include_editor: + continue + if expand_globs and info.family == 3: + pending.extend(expand_glob_item(item, globs)) + continue + if info.family in EGG_FAMILIES: + continue + render_items.append(item) + return render_items + + +def rgba_buffer(width: int, height: int, color: tuple[int, int, int, int]) -> bytearray: + r, g, b, a = color + row = bytes((r, g, b, a)) * width + return bytearray(row * height) + + +def blit_frame( + buffer: bytearray, + canvas_width: int, + canvas_height: int, + left: int, + top: int, + frame: ShapeFrame, + pixels: list[int], + palette: list[tuple[int, int, int]], + flipped: bool, +) -> None: + for src_y in range(frame.height): + dst_y = top + src_y + if dst_y < 0 or dst_y >= canvas_height: + continue + row_base = src_y * frame.width + for src_x in range(frame.width): + color_index = pixels[row_base + (frame.width - 1 - src_x if flipped else src_x)] + if color_index < 0: + continue + dst_x = left + src_x + if dst_x < 0 or dst_x >= canvas_width: + continue + pixel_base = (dst_y * canvas_width + dst_x) * 4 + r, g, b = palette[color_index] + buffer[pixel_base : pixel_base + 4] = bytes((r, g, b, 255)) + + +def rect_intersects(left: SortNode, right: SortNode) -> bool: + return left.left < right.right and left.right > right.left and left.top < right.bottom and left.bottom > right.top + + +def rect_contains(outer: SortNode, inner: SortNode) -> bool: + return outer.left <= inner.left and outer.top <= inner.top and outer.right >= inner.right and outer.bottom >= inner.bottom + + +def build_sort_node(item: MapItem, info: ShapeInfo, frame: ShapeFrame, pixels: list[int]) -> SortNode: + flipped = bool(item.flags & FLAG_FLIPPED) + xdim = info.y * 32 if flipped else info.x * 32 + ydim = info.x * 32 if flipped else info.y * 32 + zdim = info.z * 8 + + x = item.x + y = item.y + z = item.z + x_left = x - xdim + y_far = y - ydim + z_top = z + zdim + + sx_left = x_left // 4 - y // 4 + sx_right = x // 4 - y_far // 4 + sx_top = x_left // 4 - y_far // 4 + sy_top = x_left // 8 + y_far // 8 - z_top + sx_bot = x // 4 - y // 4 + sy_bot = x // 8 + y // 8 - z + + left = sx_bot + frame.xoff - frame.width if flipped else sx_bot - frame.xoff + top = sy_bot - frame.yoff + right = left + frame.width + bottom = top + frame.height + + return SortNode( + item=item, + info=info, + frame=frame, + pixels=pixels, + left=left, + top=top, + right=right, + bottom=bottom, + x=x, + x_left=x_left, + y=y, + y_far=y_far, + z=z, + z_top=z_top, + sx_left=sx_left, + sx_right=sx_right, + sx_top=sx_top, + sy_top=sy_top, + sx_bot=sx_bot, + sy_bot=sy_bot, + fbigsq=xdim == ydim and xdim >= 128, + flat=zdim == 0, + occl=info.is_occl and not info.is_translucent, + solid=info.is_solid, + draw=info.is_draw, + roof=info.is_roof, + noisy=info.is_noisy, + anim=info.anim_type != 0, + trans=info.is_translucent, + fixed=info.is_fixed, + land=info.is_land, + sprite=False, + invitem=info.is_invitem, + ) + + +def insert_dependency_sorted(depends: list[SortNode], node: SortNode) -> None: + for index, current in enumerate(depends): + if current is node: + return + if node.list_less_than(current): + depends.insert(index, node) + return + depends.append(node) + + +def resolve_paint_order(ordered: list[SortNode]) -> list[SortNode]: + painted: list[SortNode] = [] + + def visit(node: SortNode) -> None: + if node.occluded or node.order >= 0: + return + node.order = -2 + for dependency in node.depends: + if dependency.order == -2: + break + if dependency.order == -1: + visit(dependency) + node.order = painted[-1].order + 1 if painted else 0 + painted.append(node) + + for node in ordered: + if node.order == -1: + visit(node) + return painted + + +def prepare_sorted_items( + items: list[MapItem], + archive: ShapeArchive, + shape_infos: list[ShapeInfo], +) -> tuple[int, int, int, int, list[SortNode], int]: + ordered: list[SortNode] = [] + min_left = sys.maxsize + min_top = sys.maxsize + max_right = -sys.maxsize + max_bottom = -sys.maxsize + occluded_count = 0 + + for item in items: + frame, pixels = archive.decode_frame(item.shape, item.frame) + node = build_sort_node(item, shape_infos[item.shape], frame, pixels) + + min_left = min(min_left, node.left) + min_top = min(min_top, node.top) + max_right = max(max_right, node.right) + max_bottom = max(max_bottom, node.bottom) + + insert_at = len(ordered) + for index, other in enumerate(ordered): + if insert_at == len(ordered) and node.list_less_than(other): + insert_at = index + if other.occluded: + continue + if not node.overlap(other): + continue + if node.below(other): + if other.occl and other.occludes(node): + node.occluded = True + occluded_count += 1 + break + insert_dependency_sorted(other.depends, node) + else: + if node.occl and node.occludes(other): + if not other.occluded: + other.occluded = True + occluded_count += 1 + else: + insert_dependency_sorted(node.depends, other) + ordered.insert(insert_at, node) + + return min_left, min_top, max_right, max_bottom, resolve_paint_order(ordered), occluded_count + + +def write_png_rgba(path: Path, width: int, height: int, pixels: bytearray) -> None: + def chunk(chunk_type: bytes, payload: bytes) -> bytes: + return ( + struct.pack(">I", len(payload)) + + chunk_type + + payload + + struct.pack(">I", zlib.crc32(chunk_type + payload) & 0xFFFFFFFF) + ) + + rows = bytearray() + stride = width * 4 + for row in range(height): + rows.append(0) + start = row * stride + rows.extend(pixels[start : start + stride]) + + payload = bytearray(b"\x89PNG\r\n\x1a\n") + payload.extend(chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))) + payload.extend(chunk(b"IDAT", zlib.compress(bytes(rows), level=9))) + payload.extend(chunk(b"IEND", b"")) + path.write_bytes(payload) + + +def parse_world_rect(values: list[str] | None) -> tuple[int, int, int, int] | None: + if values is None: + return None + if len(values) != 4: + raise ValueError("--world-rect expects four integers: min_x min_y max_x max_y") + min_x, min_y, max_x, max_y = (int(value, 0) for value in values) + if min_x > max_x or min_y > max_y: + raise ValueError("invalid --world-rect bounds") + return min_x, min_y, max_x, max_y + + +def resolve_static_dir(repo_root: Path, game: str, static_dir: str | None) -> Path: + if static_dir: + return Path(static_dir) + if game == "regret": + return repo_root / "STATIC_REGRET" + return repo_root / "STATIC" + + +def resolve_fixed_dat(static_dir: Path, fixed_dat: str | None) -> Path: + if fixed_dat: + return Path(fixed_dat) + return static_dir / "FIXED.DAT" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Render a Crusader fixed map to PNG.") + parser.add_argument("--game", choices=("remorse", "regret"), default="remorse") + parser.add_argument("--static-dir", help="Override the static asset directory.") + parser.add_argument("--fixed-dat", help="Override the FIXED.DAT path when it does not live under the selected static directory.") + parser.add_argument("--map", dest="map_index", type=int, required=True, help="Map index to render.") + parser.add_argument("--output", required=True, help="PNG output path.") + parser.add_argument("--metadata", help="Optional JSON metadata output path.") + parser.add_argument("--no-globs", action="store_true", help="Disable GLOB.FLX expansion.") + parser.add_argument("--include-editor", action="store_true", help="Render editor-only shapes.") + parser.add_argument( + "--world-rect", + nargs=4, + metavar=("MIN_X", "MIN_Y", "MAX_X", "MAX_Y"), + help="Restrict rendering to a world-space rectangle.", + ) + parser.add_argument( + "--max-pixels", + type=int, + default=100_000_000, + help="Fail if the output image would exceed this many pixels.", + ) + args = parser.parse_args() + + repo_root = Path(__file__).resolve().parents[1] + static_dir = resolve_static_dir(repo_root, args.game, args.static_dir) + fixed_dat_path = resolve_fixed_dat(static_dir, args.fixed_dat) + world_rect = parse_world_rect(args.world_rect) + + shape_infos = load_typeflags(static_dir / "TYPEFLAG.DAT") + palette = load_palette(static_dir / "GAMEPAL.PAL") + globs = load_globs(static_dir / "GLOB.FLX") + shape_archive = ShapeArchive(static_dir / "SHAPES.FLX") + if not fixed_dat_path.exists(): + raise FileNotFoundError( + f"missing FIXED.DAT at {fixed_dat_path}; copy the matching game map file into place or pass --fixed-dat" + ) + base_items = load_map_items(fixed_dat_path, args.map_index) + render_items = collect_render_items( + base_items, + shape_infos, + globs, + include_editor=args.include_editor, + expand_globs=not args.no_globs, + world_rect=world_rect, + ) + if not render_items: + raise ValueError("no renderable items were found for the selected map") + + min_left, min_top, max_right, max_bottom, prepared, occluded_count = prepare_sorted_items( + render_items, + shape_archive, + shape_infos, + ) + width = max_right - min_left + height = max_bottom - min_top + if width <= 0 or height <= 0: + raise ValueError("computed image bounds are invalid") + if width * height > args.max_pixels: + raise ValueError( + f"image would be {width}x{height} = {width * height} pixels; use --world-rect or raise --max-pixels" + ) + + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + buffer = rgba_buffer(width, height, DEFAULT_BACKGROUND) + for node in prepared: + blit_frame( + buffer, + width, + height, + node.left - min_left, + node.top - min_top, + node.frame, + node.pixels, + palette, + flipped=bool(node.item.flags & FLAG_FLIPPED), + ) + write_png_rgba(output_path, width, height, buffer) + + used_shapes = sorted({item.shape for item in render_items}) + metadata = { + "game": args.game, + "static_dir": str(static_dir), + "fixed_dat": str(fixed_dat_path), + "map": args.map_index, + "item_count": len(render_items), + "painted_item_count": len(prepared), + "occluded_item_count": occluded_count, + "used_shape_count": len(used_shapes), + "used_shapes": used_shapes, + "sorter": "scummvm_dependency_graph", + "bounds": { + "screen_left": min_left, + "screen_top": min_top, + "screen_right": max_right, + "screen_bottom": max_bottom, + "width": width, + "height": height, + }, + "world_rect": list(world_rect) if world_rect else None, + "glob_expansion": not args.no_globs, + "editor_shapes_included": args.include_editor, + } + if args.metadata: + metadata_path = Path(args.metadata) + metadata_path.parent.mkdir(parents=True, exist_ok=True) + metadata_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8") + print(json.dumps(metadata, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_usecode_structuring.py b/tools/tests/test_usecode_structuring.py index 8b501d2..e5a6d54 100644 --- a/tools/tests/test_usecode_structuring.py +++ b/tools/tests/test_usecode_structuring.py @@ -9,6 +9,7 @@ from tools.poc_crusader_usecode_parser import ( render_partially_structured_blocks, render_structured_pseudocode, try_decode_loop_selector, + validate_pseudocode_text, ) @@ -222,6 +223,58 @@ class UsecodeStructuringTests(unittest.TestCase): self.assertNotIn("block_0358:", text) self.assertNotIn("goto block_0469;", text) + def test_generic_loop_renders_in_partial_fallback(self) -> None: + blocks = [ + ("entry", ["goto block_01E2;"]), + ("block_01E2", ["counter = 0;"]), + ("block_025C", ["if (counter <= rndNum) goto block_0315;"]), + ("block_0267", ["counter2 = 1;"]), + ("block_026E", ["if (counter2 <= 7) goto block_02B6;"]), + ("block_0276", ["spawn FREE.waitNTimerTicks(pid, 10, 0x00000000);", "suspend;", "counter2 = (1 + counter2);", "goto block_026E;"]), + ("block_02B6", ["counter = (1 + counter);", "goto block_025C;"]), + ("block_0315", ["goto block_01E2;"]), + ] + + rendered = render_partially_structured_blocks(blocks) + + text = "\n".join(rendered) + self.assertIn("while (true) {", text) + self.assertIn("while (counter > rndNum) {", text) + self.assertIn("while (counter2 > 7) {", text) + self.assertNotIn("block_026E:", text) + self.assertNotIn("goto block_025C;", text) + + def test_infinite_loop_region_renders_as_while_true(self) -> None: + blocks = [ + ("entry", ["set_info(0x021B, *(arg_06));"]), + ("block_01E2", ["suspend;", "FREE.slot_20(100);", "if (retval > 50) goto block_0318;"]), + ("block_0205", ["FREE.slot_20(pid, 120);", "goto block_046D;"]), + ("block_0318", ["FREE.slot_20(pid, 60);"]), + ("block_046D", ["goto block_01E2;"]), + ("block_0470", ["return;"]), + ] + + rendered = render_partially_structured_blocks(blocks) + + text = "\n".join(rendered) + self.assertIn("while (true) {", text) + self.assertNotIn("goto block_01E2;", text) + self.assertNotIn("block_046D:", text) + + def test_pseudocode_validator_reports_missing_label(self) -> None: + errors = validate_pseudocode_text( + "function sample()\n{\n entry:\n goto missing;\n}\n" + ) + + self.assertEqual(errors, ["line 4: goto target missing has no label"]) + + def test_pseudocode_validator_accepts_balanced_text(self) -> None: + errors = validate_pseudocode_text( + "function sample()\n{\n entry:\n while (true) {\n goto entry;\n }\n}\n" + ) + + self.assertEqual(errors, []) + if __name__ == "__main__": unittest.main() \ No newline at end of file