from __future__ import annotations import argparse import csv import json import re from dataclasses import dataclass from pathlib import Path @dataclass(frozen=True) class CategorySpec: name: str table_offset: int entry_size: int entry_count: int display_frame_offset: int display_frame_size: int name_base_global_index: int helper_name: str @dataclass(frozen=True) class TargetProfile: target: str exe_name: str xml_name: str | None data_segment: str helper_function_address: str data_segment_file_offset: int pointer_table_offset: int global_slot_count: int string_pool_offset: int sequential_string_count: int | None invalid_alias_indices: frozenset[int] output_prefix: str categories: tuple[CategorySpec, ...] notes: tuple[str, ...] REMORSE_CATEGORIES: tuple[CategorySpec, ...] = ( CategorySpec( name="weapon", table_offset=0x20C6, entry_size=0x12, entry_count=14, display_frame_offset=0x0D, display_frame_size=1, name_base_global_index=0, helper_name="Weapon_GetNameForShapeNo", ), CategorySpec( name="misc_item", table_offset=0x2244, entry_size=0x04, entry_count=12, display_frame_offset=0x02, display_frame_size=2, name_base_global_index=14, helper_name="MiscTable_GetNameForItemShapeNo", ), CategorySpec( name="bomb", table_offset=0x2274, entry_size=0x08, entry_count=9, display_frame_offset=0x06, display_frame_size=2, name_base_global_index=32, helper_name="MiscTable_GetNameForBombShapeNo", ), CategorySpec( name="ammo", table_offset=0x2042, entry_size=0x08, entry_count=6, display_frame_offset=0x06, display_frame_size=2, name_base_global_index=26, helper_name="Ammo_GetNameForShapeNo", ), ) REGRET_CATEGORIES: tuple[CategorySpec, ...] = ( CategorySpec( name="weapon", table_offset=0x2614, entry_size=0x12, entry_count=17, display_frame_offset=0x0D, display_frame_size=1, name_base_global_index=0, helper_name="Weapon_GetNameForShapeNo", ), CategorySpec( name="misc_item", table_offset=0x2792, entry_size=0x04, entry_count=19, display_frame_offset=0x02, display_frame_size=2, name_base_global_index=17, helper_name="MiscTable_GetNameForItemShapeNo", ), CategorySpec( name="bomb", table_offset=0x27DE, entry_size=0x08, entry_count=8, display_frame_offset=0x06, display_frame_size=2, name_base_global_index=44, helper_name="MiscTable_GetNameForBombShapeNo", ), CategorySpec( name="ammo", table_offset=0x2590, entry_size=0x08, entry_count=8, display_frame_offset=0x06, display_frame_size=2, name_base_global_index=36, helper_name="Ammo_GetNameForShapeNo", ), ) PROFILES: dict[str, TargetProfile] = { "remorse": TargetProfile( target="remorse", exe_name="CRUSADER.EXE", xml_name="CRUSADER.EXE.xml", data_segment="1478", helper_function_address="1118:056a", data_segment_file_offset=0xE3C00, pointer_table_offset=0x22BC, global_slot_count=41, string_pool_offset=0x238C, sequential_string_count=None, invalid_alias_indices=frozenset({0, 14, 26, 32}), output_prefix="", categories=REMORSE_CATEGORIES, notes=( "Each category uses local index 0 as a reserved INVALID slot.", "DTable_GetNameForShapeNo tests categories in order: weapon, misc_item, bomb, ammo, then falls back to INVALID.", "The dump is derived from live retail CRUSADER.EXE bytes plus the named helper structure recovered in segment 1118.", ), ), "regret": TargetProfile( target="regret", exe_name="REGRET.EXE", xml_name=None, data_segment="1480", helper_function_address="1130:056a", data_segment_file_offset=0xE2400, pointer_table_offset=0x2856, global_slot_count=52, string_pool_offset=0x2926, sequential_string_count=49, invalid_alias_indices=frozenset({0, 17, 36, 44}), output_prefix="regret_", categories=REGRET_CATEGORIES, notes=( "Each category uses local index 0 as a reserved INVALID slot, but Regret stores only one physical INVALID string and aliases it into later category starts.", "DTable_GetNameForShapeNo is recovered in the live REGRET.EXE helper cluster at 1130:056a, alongside the same 1130:0145..0474 name-helper family seen in Remorse.", "This dump is derived from live retail REGRET.EXE bytes. No local REGRET.EXE XML export was required once the 1480 data-segment base, pointer table, and string pool were recovered from the binary.", ), ), } def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Dump Crusader DTable_GetNameForShapeNo mappings.") parser.add_argument("target", nargs="?", default="remorse", choices=sorted(PROFILES.keys())) return parser.parse_args() def read_u16(data: bytes, start: int) -> int: return int.from_bytes(data[start : start + 2], "little") def read_u8(data: bytes, start: int) -> int: return data[start] def read_cstring(data: bytes, start: int) -> str: end = data.index(0, start) return data[start:end].decode("ascii") def segment_abs(profile: TargetProfile, offset: int) -> str: return f"{profile.data_segment}:{offset:04x}" def load_global_names_from_xml(profile: TargetProfile, segment_data: bytes, xml_text: str) -> list[dict[str, object]]: relocations: dict[int, int] = {} pattern = re.compile( rf' list[dict[str, object]]: if profile.sequential_string_count is None: raise ValueError(f"Profile {profile.target} does not define a sequential string count") unique_strings: list[tuple[int, str]] = [] cursor = profile.string_pool_offset for _ in range(profile.sequential_string_count): text = read_cstring(segment_data, cursor) unique_strings.append((cursor, text)) cursor += len(text) + 1 invalid_offset, invalid_name = unique_strings[0] next_unique_index = 1 names: list[dict[str, object]] = [] for global_index in range(profile.global_slot_count): slot_offset = profile.pointer_table_offset + (global_index * 4) if global_index in profile.invalid_alias_indices: target_offset = invalid_offset text = invalid_name else: target_offset, text = unique_strings[next_unique_index] next_unique_index += 1 names.append( { "global_index": global_index, "slot_address": segment_abs(profile, slot_offset), "address": segment_abs(profile, target_offset), "name": text, } ) if next_unique_index != len(unique_strings): raise ValueError( f"Profile {profile.target} consumed {next_unique_index} strings but extracted {len(unique_strings)} unique strings" ) return names def load_global_names(profile: TargetProfile, repo_root: Path, segment_data: bytes) -> list[dict[str, object]]: if profile.xml_name: xml_path = repo_root / "exports" / profile.xml_name xml_text = xml_path.read_text(encoding="utf-8") return load_global_names_from_xml(profile, segment_data, xml_text) return load_global_names_from_string_pool(profile, segment_data) def load_category_entries( profile: TargetProfile, segment_data: bytes, global_names: list[dict[str, object]], ) -> dict[str, list[dict[str, object]]]: category_entries: dict[str, list[dict[str, object]]] = {} for spec in profile.categories: entries: list[dict[str, object]] = [] for local_index in range(spec.entry_count): entry_offset = spec.table_offset + (local_index * spec.entry_size) shape = read_u16(segment_data, entry_offset) if spec.display_frame_size == 1: display_frame = read_u8(segment_data, entry_offset + spec.display_frame_offset) else: display_frame = read_u16(segment_data, entry_offset + spec.display_frame_offset) global_name_index = spec.name_base_global_index + local_index name_entry = global_names[global_name_index] entries.append( { "category": spec.name, "helper_name": spec.helper_name, "local_index": local_index, "reserved_zero_slot": local_index == 0, "table_address": segment_abs(profile, entry_offset), "shape": shape, "shape_hex": f"0x{shape:04X}", "display_frame": display_frame, "display_frame_hex": f"0x{display_frame:04X}", "global_name_index": global_name_index, "global_name_slot_address": name_entry["slot_address"], "global_name_address": name_entry["address"], "name": name_entry["name"], } ) category_entries[spec.name] = entries return category_entries def resolve_shapes( profile: TargetProfile, category_entries: dict[str, list[dict[str, object]]], fallback_name: str, ) -> list[dict[str, object]]: shape_membership: dict[int, list[dict[str, object]]] = {} for spec in profile.categories: for entry in category_entries[spec.name]: shape_membership.setdefault(int(entry["shape"]), []).append(entry) resolved: list[dict[str, object]] = [] for shape in sorted(shape_membership): if shape == 0: continue memberships = shape_membership[shape] chosen: dict[str, object] | None = None for spec in profile.categories: for entry in memberships: if entry["category"] == spec.name and not entry["reserved_zero_slot"] and int(entry["shape"]) != 0: chosen = entry break if chosen is not None: break resolved.append( { "shape": shape, "shape_hex": f"0x{shape:04X}", "resolved_name": chosen["name"] if chosen is not None else fallback_name, "resolved_category": chosen["category"] if chosen is not None else "fallback_invalid", "resolved_helper_name": chosen["helper_name"] if chosen is not None else "DTable_GetNameForShapeNo fallback", "resolved_local_index": chosen["local_index"] if chosen is not None else None, "resolved_global_name_index": chosen["global_name_index"] if chosen is not None else 0, "resolved_global_name_slot_address": chosen["global_name_slot_address"] if chosen is not None else segment_abs(profile, profile.pointer_table_offset), "resolved_global_name_address": chosen["global_name_address"] if chosen is not None else segment_abs(profile, profile.string_pool_offset), "display_frame": chosen["display_frame"] if chosen is not None else None, "display_frame_hex": chosen["display_frame_hex"] if chosen is not None else None, "memberships": memberships, } ) return resolved def write_slot_csv(path: Path, global_names: list[dict[str, object]]) -> None: with path.open("w", newline="", encoding="utf-8") as handle: writer = csv.DictWriter(handle, fieldnames=["global_index", "slot_address", "address", "name"]) writer.writeheader() for row in global_names: writer.writerow(row) def write_table_csv(path: Path, categories: tuple[CategorySpec, ...], category_entries: dict[str, list[dict[str, object]]]) -> None: fieldnames = [ "category", "helper_name", "local_index", "reserved_zero_slot", "table_address", "shape", "shape_hex", "display_frame", "display_frame_hex", "global_name_index", "global_name_slot_address", "global_name_address", "name", ] with path.open("w", newline="", encoding="utf-8") as handle: writer = csv.DictWriter(handle, fieldnames=fieldnames) writer.writeheader() for category in (spec.name for spec in categories): for row in category_entries[category]: writer.writerow({key: row[key] for key in fieldnames}) def write_resolved_csv(path: Path, resolved_rows: list[dict[str, object]]) -> None: fieldnames = [ "shape", "shape_hex", "resolved_name", "resolved_category", "resolved_helper_name", "resolved_local_index", "resolved_global_name_index", "resolved_global_name_slot_address", "resolved_global_name_address", "display_frame", "display_frame_hex", ] with path.open("w", newline="", encoding="utf-8") as handle: writer = csv.DictWriter(handle, fieldnames=fieldnames) writer.writeheader() for row in resolved_rows: writer.writerow({key: row[key] for key in fieldnames}) def output_basename(profile: TargetProfile) -> str: return f"{profile.output_prefix}dtable" def main() -> None: args = parse_args() profile = PROFILES[args.target] repo_root = Path(__file__).resolve().parent.parent exe_path = repo_root / profile.exe_name out_dir = repo_root / "out" out_dir.mkdir(parents=True, exist_ok=True) exe_bytes = exe_path.read_bytes() segment_data = exe_bytes[profile.data_segment_file_offset :] global_names = load_global_names(profile, repo_root, segment_data) category_entries = load_category_entries(profile, segment_data, global_names) resolved_rows = resolve_shapes(profile, category_entries, fallback_name=str(global_names[0]["name"])) json_path = out_dir / f"{output_basename(profile)}_get_name_dump.json" payload = { "target": profile.target, "function": "DTable_GetNameForShapeNo", "function_address": profile.helper_function_address, "segment": profile.data_segment, "segment_file_offset_hex": f"0x{profile.data_segment_file_offset:05X}", "pointer_table_address": segment_abs(profile, profile.pointer_table_offset), "string_pool_address": segment_abs(profile, profile.string_pool_offset), "lookup_order": [spec.name for spec in profile.categories], "global_name_slots": global_names, "category_entries": category_entries, "resolved_shapes": resolved_rows, "fallback_name": global_names[0]["name"], "fallback_global_index": global_names[0]["global_index"], "notes": list(profile.notes), } json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") slots_csv_path = out_dir / f"{output_basename(profile)}_global_name_slots.csv" tables_csv_path = out_dir / f"{output_basename(profile)}_category_entries.csv" resolved_csv_path = out_dir / f"{output_basename(profile)}_resolved_shapes.csv" write_slot_csv(slots_csv_path, global_names) write_table_csv(tables_csv_path, profile.categories, category_entries) write_resolved_csv(resolved_csv_path, resolved_rows) print(f"Wrote {json_path}") print(f"Wrote {slots_csv_path}") print(f"Wrote {tables_csv_path}") print(f"Wrote {resolved_csv_path}") if __name__ == "__main__": main()