Crusader_Decomp/tools/dump_dtable_names.py

458 lines
17 KiB
Python
Raw Normal View History

2026-03-30 00:19:01 +02:00
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'<RELOCATION ADDRESS="{re.escape(profile.data_segment)}:([0-9a-f]{{4}})" TYPE="0x3" VALUE="0x[0-9a-f]+,0x0,0x[0-9a-f]{{4}},0x[0-9a-f]+,0x([0-9a-f]{{4}})"',
re.IGNORECASE,
)
for match in pattern.finditer(xml_text):
slot_offset = int(match.group(1), 16)
target_offset = int(match.group(2), 16)
relocations[slot_offset] = target_offset
names: list[dict[str, object]] = []
for global_index in range(profile.global_slot_count):
slot_offset = profile.pointer_table_offset + (global_index * 4)
target_offset = relocations[slot_offset]
text = read_cstring(segment_data, target_offset)
names.append(
{
"global_index": global_index,
"slot_address": segment_abs(profile, slot_offset),
"address": segment_abs(profile, target_offset),
"name": text,
}
)
return names
def load_global_names_from_string_pool(profile: TargetProfile, segment_data: bytes) -> 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()