458 lines
17 KiB
Python
458 lines
17 KiB
Python
|
|
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()
|