mirror of
https://github.com/sal063/AC6_recomp
synced 2026-06-25 02:02:10 -04:00
Add 60fps cutscene clamp for in-engine cinematics
Suspend the FPS unlock while a demo-manager Exec (DD sub_82184460 / EM sub_821856F8) ticks, so the frame-locked IngameCinematics Sequencer plays at native ~30fps instead of double speed. Adds ac6_cutscene_clamp CVar (default on).
This commit is contained in:
@@ -736,7 +736,59 @@ def extract_r8_visible(payload: bytes, storage_width: int, visible_width: int, v
|
||||
return b"".join(rows)
|
||||
|
||||
|
||||
def export_ntxr(input_path: Path, output_root: Path, source_root: Path) -> dict:
|
||||
def existing_export_entry(input_path: Path, output_root: Path, source_root: Path) -> dict | None:
|
||||
output_base = output_root / input_path.relative_to(source_root)
|
||||
json_path = output_base.with_suffix(".json")
|
||||
dds_path = output_base.with_suffix(".dds")
|
||||
preview_path = output_base.with_suffix(".tga")
|
||||
png_path = output_base.with_suffix(".png")
|
||||
if not (json_path.exists() and dds_path.exists() and preview_path.exists() and png_path.exists()):
|
||||
return None
|
||||
|
||||
try:
|
||||
metadata = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
entry = {
|
||||
"source": str(input_path.relative_to(source_root)).replace("\\", "/"),
|
||||
"size": input_path.stat().st_size,
|
||||
"status": "exported",
|
||||
"resumed": True,
|
||||
"layout": metadata.get("layout"),
|
||||
"format": metadata.get("format"),
|
||||
"dds": str(dds_path.relative_to(output_root)).replace("\\", "/"),
|
||||
"preview": str(preview_path.relative_to(output_root)).replace("\\", "/"),
|
||||
"preview_png": str(png_path.relative_to(output_root)).replace("\\", "/"),
|
||||
"metadata": str(json_path.relative_to(output_root)).replace("\\", "/"),
|
||||
"visible_width": metadata.get("visible_width"),
|
||||
"visible_height": metadata.get("visible_height"),
|
||||
"storage_width": metadata.get("storage_width"),
|
||||
"storage_height": metadata.get("storage_height"),
|
||||
"mip_count": metadata.get("mip_count"),
|
||||
"notes": metadata.get("notes"),
|
||||
}
|
||||
raw_png = metadata.get("raw_png")
|
||||
if raw_png:
|
||||
raw_path = json_path.with_name(raw_png)
|
||||
if raw_path.exists():
|
||||
entry["raw_preview_png"] = str(raw_path.relative_to(output_root)).replace("\\", "/")
|
||||
alpha_png = metadata.get("alpha_png")
|
||||
if alpha_png:
|
||||
alpha_path = json_path.with_name(alpha_png)
|
||||
if alpha_path.exists():
|
||||
entry["alpha_preview_png"] = str(alpha_path.relative_to(output_root)).replace("\\", "/")
|
||||
if metadata.get("face_pngs"):
|
||||
entry["face_pngs"] = metadata["face_pngs"]
|
||||
return entry
|
||||
|
||||
|
||||
def export_ntxr(input_path: Path, output_root: Path, source_root: Path, *, skip_existing: bool = False) -> dict:
|
||||
if skip_existing:
|
||||
existing = existing_export_entry(input_path, output_root, source_root)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
blob = input_path.read_bytes()
|
||||
plan, reason = classify_ntxr(blob)
|
||||
output_base = output_root / input_path.relative_to(source_root)
|
||||
@@ -964,6 +1016,17 @@ def main() -> int:
|
||||
default=Path("out") / "ac6_runtime_ntxr_exported",
|
||||
help="Output directory for exported textures",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Re-export textures even when a complete prior export already exists",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Export at most this many NTXR files (0 = no limit)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
source_root = args.input.resolve()
|
||||
@@ -972,8 +1035,11 @@ def main() -> int:
|
||||
|
||||
exported = []
|
||||
skipped = []
|
||||
for input_path in sorted(source_root.rglob("*.ntxr")):
|
||||
result = export_ntxr(input_path, output_root, source_root)
|
||||
ntxr_paths = sorted(source_root.rglob("*.ntxr"))
|
||||
if args.limit > 0:
|
||||
ntxr_paths = ntxr_paths[:args.limit]
|
||||
for input_path in ntxr_paths:
|
||||
result = export_ntxr(input_path, output_root, source_root, skip_existing=not args.force)
|
||||
if result["status"] == "exported":
|
||||
exported.append(result)
|
||||
else:
|
||||
|
||||
+130
-104
@@ -4,10 +4,13 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from ac6_fhm import ext_for_magic, parse_fhm as parse_fhm_container, safe_tag
|
||||
|
||||
|
||||
DUMP_RE = re.compile(
|
||||
r"^entry_(?P<record_id>\d+)_mode(?P<mode>\d+)_c(?P<compressed_size>\d+)_u(?P<decompressed_size>\d+)"
|
||||
@@ -15,8 +18,14 @@ DUMP_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> dict:
|
||||
if not path.exists():
|
||||
return {"entries": []}
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def load_manifest_entries(path: Path) -> dict[tuple[int, int], list[dict]]:
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
manifest = load_manifest(path)
|
||||
by_pair: dict[tuple[int, int], list[dict]] = defaultdict(list)
|
||||
for entry in manifest["entries"]:
|
||||
if entry["storage_kind"] != "compressed":
|
||||
@@ -25,93 +34,38 @@ def load_manifest_entries(path: Path) -> dict[tuple[int, int], list[dict]]:
|
||||
return by_pair
|
||||
|
||||
|
||||
def parse_fhm(blob: bytes) -> list[dict]:
|
||||
if len(blob) < 0x1C or blob[:4] != b"FHM ":
|
||||
return []
|
||||
|
||||
count = struct.unpack_from(">I", blob, 0x10)[0]
|
||||
if count == 0:
|
||||
return []
|
||||
|
||||
table_base = 0x14
|
||||
offsets_base = table_base
|
||||
sizes_base = offsets_base + (count * 4)
|
||||
if sizes_base + (count * 4) > len(blob):
|
||||
return []
|
||||
|
||||
offsets = [struct.unpack_from(">I", blob, offsets_base + (i * 4))[0] for i in range(count)]
|
||||
sizes = [struct.unpack_from(">I", blob, sizes_base + (i * 4))[0] for i in range(count)]
|
||||
|
||||
entries = []
|
||||
for index, (offset, size) in enumerate(zip(offsets, sizes)):
|
||||
if offset >= len(blob):
|
||||
continue
|
||||
|
||||
end = offset + size
|
||||
if end > len(blob):
|
||||
next_offset = offsets[index + 1] if index + 1 < len(offsets) else len(blob)
|
||||
end = min(next_offset, len(blob))
|
||||
if end <= offset:
|
||||
continue
|
||||
|
||||
child = blob[offset:end]
|
||||
entries.append(
|
||||
{
|
||||
"index": index,
|
||||
"offset": offset,
|
||||
"size": len(child),
|
||||
"magic": child[:4].decode("ascii", errors="replace"),
|
||||
"data": child,
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def safe_name(name: str) -> str:
|
||||
return "".join(ch if ch.isalnum() or ch in ("-", "_", ".") else "_" for ch in name)
|
||||
|
||||
|
||||
def magic_extension(magic: str) -> str:
|
||||
normalized = magic.strip().upper()
|
||||
mapping = {
|
||||
"FHM": ".fhm",
|
||||
"NTXR": ".ntxr",
|
||||
"NSXR": ".nsxr",
|
||||
"MDLP": ".mdlp",
|
||||
"PLAD": ".plad",
|
||||
"BFX": ".bfx",
|
||||
"BSN": ".bsn",
|
||||
"ACE6": ".ace6",
|
||||
"NFH": ".nfh",
|
||||
}
|
||||
return mapping.get(normalized, ".bin")
|
||||
return safe_tag(name)
|
||||
|
||||
|
||||
def extract_container(blob: bytes, container_dir: Path, output_root: Path, depth: int,
|
||||
max_depth: int) -> list[dict]:
|
||||
children = parse_fhm(blob)
|
||||
children = parse_fhm_container(blob) or []
|
||||
if not children:
|
||||
return []
|
||||
|
||||
child_entries = []
|
||||
for child in children:
|
||||
safe_magic = safe_name(child["magic"])
|
||||
child_name = f"{child['index']:03d}_{safe_magic}{magic_extension(child['magic'])}"
|
||||
safe_magic = safe_name(child.magic)
|
||||
child_name = f"{child.index:03d}_{safe_magic}{ext_for_magic(child.magic)}"
|
||||
child_path = container_dir / child_name
|
||||
child_path.write_bytes(child["data"])
|
||||
child_path.write_bytes(child.data)
|
||||
|
||||
child_entry = {
|
||||
"index": child["index"],
|
||||
"offset": child["offset"],
|
||||
"size": child["size"],
|
||||
"magic": child["magic"],
|
||||
"index": child.index,
|
||||
"offset": child.offset,
|
||||
"declared_size": child.declared_size,
|
||||
"size": child.size,
|
||||
"magic": child.magic,
|
||||
"path": str(child_path.relative_to(output_root)).replace("\\", "/"),
|
||||
}
|
||||
if child.notes:
|
||||
child_entry["parser_notes"] = child.notes
|
||||
|
||||
if depth < max_depth and child["data"][:4] == b"FHM ":
|
||||
nested_dir = container_dir / f"{child['index']:03d}_{safe_magic}"
|
||||
if depth < max_depth and child.data[:4] == b"FHM ":
|
||||
nested_dir = container_dir / f"{child.index:03d}_{safe_magic}"
|
||||
nested_dir.mkdir(parents=True, exist_ok=True)
|
||||
nested_children = extract_container(child["data"], nested_dir, output_root, depth + 1,
|
||||
nested_children = extract_container(child.data, nested_dir, output_root, depth + 1,
|
||||
max_depth)
|
||||
if nested_children:
|
||||
child_entry["nested"] = nested_children
|
||||
@@ -121,8 +75,57 @@ def extract_container(blob: bytes, container_dir: Path, output_root: Path, depth
|
||||
return child_entries
|
||||
|
||||
|
||||
def extract_blob(blob: bytes, label: str, output_root: Path, max_depth: int,
|
||||
source_record: dict) -> dict:
|
||||
container_dir = output_root / safe_name(label)
|
||||
container_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
children = parse_fhm_container(blob) or []
|
||||
if not children:
|
||||
raw_path = container_dir / f"{safe_name(label)}.bin"
|
||||
raw_path.write_bytes(blob)
|
||||
return {
|
||||
**source_record,
|
||||
"kind": "raw",
|
||||
"magic": blob[:4].decode("latin-1", errors="replace") if len(blob) >= 4 else "",
|
||||
"size": len(blob),
|
||||
"path": str(raw_path.relative_to(output_root)).replace("\\", "/"),
|
||||
}
|
||||
|
||||
child_entries = extract_container(blob, container_dir, output_root, 0, max_depth)
|
||||
return {
|
||||
**source_record,
|
||||
"kind": "fhm",
|
||||
"child_count": len(child_entries),
|
||||
"children": child_entries,
|
||||
}
|
||||
|
||||
|
||||
def iter_offline_manifest_sources(manifest_path: Path, files_dir: Path) -> list[dict]:
|
||||
manifest = load_manifest(manifest_path)
|
||||
sources = []
|
||||
manifest_root = manifest_path.parent
|
||||
for entry in manifest.get("entries", []):
|
||||
if not entry.get("extracted"):
|
||||
continue
|
||||
rel_path = entry.get("path")
|
||||
if not rel_path:
|
||||
continue
|
||||
path = manifest_root / rel_path
|
||||
if files_dir and not path.is_relative_to(files_dir):
|
||||
# Keep support for custom --pac-files while still trusting the
|
||||
# manifest's relative paths when they point elsewhere.
|
||||
alt = files_dir / Path(rel_path).name
|
||||
if alt.exists():
|
||||
path = alt
|
||||
if not path.exists():
|
||||
continue
|
||||
sources.append({"entry": entry, "path": path})
|
||||
return sources
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Extract child payloads from runtime-dumped AC6 FHM containers.")
|
||||
parser = argparse.ArgumentParser(description="Extract child payloads from offline-decoded or runtime-dumped AC6 FHM containers.")
|
||||
parser.add_argument(
|
||||
"--dump-dir",
|
||||
type=Path,
|
||||
@@ -135,12 +138,23 @@ def main() -> int:
|
||||
default=Path("out") / "ac6_pac_extracted_raw" / "manifest.json",
|
||||
help="Manifest produced by extract_ac6_pac.py",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pac-files",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Decoded PAC files directory produced by extract_ac6_pac.py (default: <manifest-dir>/files)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=Path("out") / "ac6_runtime_fhm_extracted",
|
||||
default=Path("out") / "ac6_runtime_fhm_typed",
|
||||
help="Output directory for parsed FHM containers and child payloads",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-runtime-dumps",
|
||||
action="store_true",
|
||||
help="Also merge entry_* runtime dumps from --dump-dir when present",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-depth",
|
||||
type=int,
|
||||
@@ -151,14 +165,42 @@ def main() -> int:
|
||||
|
||||
dump_dir = args.dump_dir.resolve()
|
||||
manifest_path = args.manifest.resolve()
|
||||
pac_files = args.pac_files.resolve() if args.pac_files else manifest_path.parent / "files"
|
||||
output_root = args.output.resolve()
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
by_pair = load_manifest_entries(manifest_path)
|
||||
extracted = []
|
||||
|
||||
for source in iter_offline_manifest_sources(manifest_path, pac_files):
|
||||
entry = source["entry"]
|
||||
path = source["path"]
|
||||
blob = path.read_bytes()
|
||||
label = f"idx_{entry['index']:04d}"
|
||||
extracted.append(
|
||||
extract_blob(
|
||||
blob,
|
||||
label,
|
||||
output_root,
|
||||
args.max_depth,
|
||||
{
|
||||
"source": "offline_pac",
|
||||
"entry_index": entry["index"],
|
||||
"pac_name": entry["pac_name"],
|
||||
"storage_kind": entry["storage_kind"],
|
||||
"compressed_size": entry["compressed_size"],
|
||||
"decompressed_size": entry["decompressed_size"],
|
||||
"source_offset": entry["offset"],
|
||||
"input_path": str(path.relative_to(manifest_path.parent)).replace("\\", "/"),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
selected_dumps: dict[tuple[int, int, int, int], Path] = {}
|
||||
|
||||
for dump_path in sorted(dump_dir.glob("*.bin")):
|
||||
runtime_dump_count = 0
|
||||
runtime_glob = sorted(dump_dir.glob("*.bin")) if args.include_runtime_dumps and dump_dir.exists() else []
|
||||
for dump_path in runtime_glob:
|
||||
match = DUMP_RE.match(dump_path.name)
|
||||
if not match:
|
||||
continue
|
||||
@@ -183,6 +225,7 @@ def main() -> int:
|
||||
selected_dumps[key] = dump_path
|
||||
|
||||
for dump_path in sorted(selected_dumps.values()):
|
||||
runtime_dump_count += 1
|
||||
match = DUMP_RE.match(dump_path.name)
|
||||
assert match is not None
|
||||
|
||||
@@ -199,16 +242,15 @@ def main() -> int:
|
||||
if len(candidates) == 1
|
||||
else f"pair_c{compressed_size}_u{decompressed_size}"
|
||||
)
|
||||
container_dir = output_root / safe_name(base_label)
|
||||
container_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
blob = dump_path.read_bytes()
|
||||
children = parse_fhm(blob)
|
||||
if not children:
|
||||
raw_path = container_dir / dump_path.name
|
||||
raw_path.write_bytes(blob)
|
||||
extracted.append(
|
||||
extracted.append(
|
||||
extract_blob(
|
||||
blob,
|
||||
f"runtime_{base_label}",
|
||||
output_root,
|
||||
args.max_depth,
|
||||
{
|
||||
"source": "runtime_dump",
|
||||
"dump": dump_path.name,
|
||||
"record_id": record_id,
|
||||
"codec_mode": codec_mode,
|
||||
@@ -216,31 +258,13 @@ def main() -> int:
|
||||
"decompressed_size": decompressed_size,
|
||||
"source_offset": source_offset,
|
||||
"candidate_indexes": [entry["index"] for entry in candidates],
|
||||
"kind": "raw",
|
||||
"path": str(raw_path.relative_to(output_root)).replace("\\", "/"),
|
||||
}
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
child_entries = extract_container(blob, container_dir, output_root, 0, args.max_depth)
|
||||
|
||||
extracted.append(
|
||||
{
|
||||
"dump": dump_path.name,
|
||||
"record_id": record_id,
|
||||
"codec_mode": codec_mode,
|
||||
"compressed_size": compressed_size,
|
||||
"decompressed_size": decompressed_size,
|
||||
"source_offset": source_offset,
|
||||
"candidate_indexes": [entry["index"] for entry in candidates],
|
||||
"kind": "fhm",
|
||||
"child_count": len(child_entries),
|
||||
"children": child_entries,
|
||||
}
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"dump_dir": str(dump_dir),
|
||||
"pac_files": str(pac_files),
|
||||
"dump_dir": str(dump_dir) if args.include_runtime_dumps else None,
|
||||
"manifest": str(manifest_path),
|
||||
"output": str(output_root),
|
||||
"containers": extracted,
|
||||
@@ -250,6 +274,8 @@ def main() -> int:
|
||||
json.dumps(
|
||||
{
|
||||
"containers": len(extracted),
|
||||
"offline_sources": sum(1 for item in extracted if item.get("source") == "offline_pac"),
|
||||
"runtime_dumps": runtime_dump_count,
|
||||
"output": str(output_root),
|
||||
},
|
||||
indent=2,
|
||||
|
||||
@@ -106,7 +106,10 @@ def main() -> int:
|
||||
output_root = args.output.resolve()
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
swg_files = [input_path] if input_path.is_file() else sorted(input_path.rglob("*_SWG_.bin"))
|
||||
if input_path.is_file():
|
||||
swg_files = [input_path]
|
||||
else:
|
||||
swg_files = sorted({*input_path.rglob("*_SWG_.bin"), *input_path.rglob("*.swg")})
|
||||
parsed = []
|
||||
for swg_path in swg_files:
|
||||
result = parse_one(swg_path)
|
||||
|
||||
@@ -13,6 +13,7 @@ DEFAULT_ASSET_ROOT = Path("out") / "build" / "win-amd64-relwithdebinfo" / "asset
|
||||
DEFAULT_RAW_OUT = Path("out") / "ac6_pac_extracted_raw"
|
||||
DEFAULT_DUMP_DIR = Path("out") / "ac6_pac_runtime_dump"
|
||||
DEFAULT_TYPED_OUT = Path("out") / "ac6_runtime_fhm_typed"
|
||||
DEFAULT_MDLP_OUT = Path("out") / "ac6_mdlp_parts"
|
||||
DEFAULT_SWG_OUT = Path("out") / "ac6_runtime_swg_parsed"
|
||||
DEFAULT_NTXR_OUT = Path("out") / "ac6_runtime_ntxr_exported"
|
||||
|
||||
@@ -33,13 +34,13 @@ def count_fhm_containers(manifest: dict | None) -> int | None:
|
||||
return None
|
||||
containers = manifest.get("containers")
|
||||
if isinstance(containers, list):
|
||||
return len(containers)
|
||||
return sum(1 for item in containers if item.get("kind") == "fhm")
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run the AC6 asset extraction pipeline over PAC archives and collected runtime decode dumps."
|
||||
description="Run the AC6 asset extraction pipeline over offline-decoded PAC archives."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--asset-root",
|
||||
@@ -57,7 +58,7 @@ def main() -> int:
|
||||
"--dump-dir",
|
||||
type=Path,
|
||||
default=DEFAULT_DUMP_DIR,
|
||||
help="Directory containing runtime PAC decode dumps",
|
||||
help="Directory containing optional runtime PAC decode dumps",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--typed-out",
|
||||
@@ -71,6 +72,12 @@ def main() -> int:
|
||||
default=DEFAULT_SWG_OUT,
|
||||
help="Output directory for parse_ac6_swg.py",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mdlp-out",
|
||||
type=Path,
|
||||
default=DEFAULT_MDLP_OUT,
|
||||
help="Output directory for extract_ac6_mdlp_parts.py",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ntxr-out",
|
||||
type=Path,
|
||||
@@ -82,6 +89,16 @@ def main() -> int:
|
||||
action="store_true",
|
||||
help="Pass --raw-only to extract_ac6_pac.py",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-decompress",
|
||||
action="store_true",
|
||||
help="Do not pass --decompress to extract_ac6_pac.py",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-runtime-dumps",
|
||||
action="store_true",
|
||||
help="Also process entry_* runtime dumps from --dump-dir when present",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-pac-extract",
|
||||
action="store_true",
|
||||
@@ -94,6 +111,7 @@ def main() -> int:
|
||||
raw_out = args.raw_out.resolve()
|
||||
dump_dir = args.dump_dir.resolve()
|
||||
typed_out = args.typed_out.resolve()
|
||||
mdlp_out = args.mdlp_out.resolve()
|
||||
swg_out = args.swg_out.resolve()
|
||||
ntxr_out = args.ntxr_out.resolve()
|
||||
|
||||
@@ -104,24 +122,36 @@ def main() -> int:
|
||||
pac_args = [str(THIS_DIR / "extract_ac6_pac.py"), str(asset_root), "--output", str(raw_out)]
|
||||
if args.raw_only:
|
||||
pac_args.append("--raw-only")
|
||||
elif not args.no_decompress:
|
||||
pac_args.append("--decompress")
|
||||
run_step(pac_args, repo_root)
|
||||
elif not raw_manifest.exists():
|
||||
raise SystemExit(f"asset root does not exist and no prior raw manifest is available: {asset_root}")
|
||||
|
||||
if not dump_dir.exists():
|
||||
raise SystemExit(f"runtime dump directory does not exist: {dump_dir}")
|
||||
|
||||
fhm_args = [
|
||||
str(THIS_DIR / "extract_ac6_runtime_fhm.py"),
|
||||
"--dump-dir",
|
||||
str(dump_dir),
|
||||
"--manifest",
|
||||
str(raw_manifest),
|
||||
"--pac-files",
|
||||
str(raw_out / "files"),
|
||||
"--output",
|
||||
str(typed_out),
|
||||
]
|
||||
if raw_manifest.exists():
|
||||
fhm_args.extend(["--manifest", str(raw_manifest)])
|
||||
if args.include_runtime_dumps:
|
||||
fhm_args.extend(["--include-runtime-dumps", "--dump-dir", str(dump_dir)])
|
||||
run_step(fhm_args, repo_root)
|
||||
|
||||
run_step(
|
||||
[
|
||||
str(THIS_DIR / "extract_ac6_mdlp_parts.py"),
|
||||
"--input",
|
||||
str(typed_out),
|
||||
"--output",
|
||||
str(mdlp_out),
|
||||
],
|
||||
repo_root,
|
||||
)
|
||||
|
||||
run_step(
|
||||
[
|
||||
str(THIS_DIR / "parse_ac6_swg.py"),
|
||||
@@ -147,14 +177,16 @@ def main() -> int:
|
||||
summary = {
|
||||
"asset_root": str(asset_root),
|
||||
"raw_manifest": str(raw_manifest) if raw_manifest.exists() else None,
|
||||
"dump_dir": str(dump_dir),
|
||||
"dump_dir": str(dump_dir) if args.include_runtime_dumps else None,
|
||||
"typed_manifest": str(typed_out / "manifest.json"),
|
||||
"mdlp_manifest": str(mdlp_out / "manifest.json"),
|
||||
"swg_manifest": str(swg_out / "manifest.json"),
|
||||
"ntxr_manifest": str(ntxr_out / "manifest.json"),
|
||||
}
|
||||
|
||||
raw_data = read_json(raw_manifest)
|
||||
typed_data = read_json(typed_out / "manifest.json")
|
||||
mdlp_data = read_json(mdlp_out / "manifest.json")
|
||||
swg_data = read_json(swg_out / "manifest.json")
|
||||
ntxr_data = read_json(ntxr_out / "manifest.json")
|
||||
|
||||
@@ -162,9 +194,12 @@ def main() -> int:
|
||||
summary["pac_entries"] = raw_data.get("entry_count")
|
||||
summary["pac_extracted"] = raw_data.get("extracted_count")
|
||||
summary["pac_skipped"] = raw_data.get("skipped_count")
|
||||
summary["pac_decompressed"] = raw_data.get("decompressed_count")
|
||||
fhm_containers = count_fhm_containers(typed_data)
|
||||
if fhm_containers is not None:
|
||||
summary["fhm_containers"] = fhm_containers
|
||||
if mdlp_data:
|
||||
summary["mdlp_packages"] = mdlp_data.get("package_count")
|
||||
if swg_data:
|
||||
summary["swg_files"] = swg_data.get("parsed_count")
|
||||
if ntxr_data:
|
||||
|
||||
+15
-55
@@ -3,54 +3,11 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MAGIC_EXT = {
|
||||
"FHM ": ".fhm", "NTXR": ".ntxr", "NDXR": ".ndxr", "NSXR": ".nsxr",
|
||||
"MDLP": ".mdlp", "PLAD": ".plad", "MATE": ".mate", "NFIC": ".nfic",
|
||||
"NFH\x00": ".nfh", "CAPT": ".capt", "Scen": ".scen", "ACE6": ".ace6",
|
||||
"RIFF": ".wav", "SWG\x00": ".swg",
|
||||
}
|
||||
|
||||
|
||||
def parse_fhm(blob: bytes):
|
||||
"""Return list of (index, offset, child_bytes) or None if not an FHM."""
|
||||
if len(blob) < 0x1C or blob[:4] != b"FHM ":
|
||||
return None
|
||||
count = struct.unpack_from(">I", blob, 0x10)[0]
|
||||
if count == 0 or count > 100000:
|
||||
return []
|
||||
offs_base = 0x14
|
||||
size_base = offs_base + count * 4
|
||||
if size_base + count * 4 > len(blob):
|
||||
return []
|
||||
offsets = [struct.unpack_from(">I", blob, offs_base + i * 4)[0] for i in range(count)]
|
||||
sizes = [struct.unpack_from(">I", blob, size_base + i * 4)[0] for i in range(count)]
|
||||
out = []
|
||||
for i, (off, sz) in enumerate(zip(offsets, sizes)):
|
||||
if off == 0 or off >= len(blob):
|
||||
continue
|
||||
end = off + sz
|
||||
if end > len(blob) or end <= off:
|
||||
nxt = offsets[i + 1] if i + 1 < count else len(blob)
|
||||
end = min(nxt, len(blob))
|
||||
if end > off:
|
||||
out.append((i, off, blob[off:end]))
|
||||
return out
|
||||
|
||||
|
||||
def magic_of(blob: bytes) -> str:
|
||||
return blob[:4].decode("latin-1") if len(blob) >= 4 else ""
|
||||
|
||||
|
||||
def ext_for(blob: bytes) -> str:
|
||||
return MAGIC_EXT.get(magic_of(blob), ".bin")
|
||||
|
||||
|
||||
def safe_tag(magic: str) -> str:
|
||||
return "".join(c if c.isalnum() else "_" for c in magic) or "raw"
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from ac6_fhm import ext_for_blob, magic_of, parse_fhm, safe_tag
|
||||
|
||||
|
||||
def unpack(blob: bytes, out_dir: Path, root: Path, depth: int, max_depth: int) -> list[dict]:
|
||||
@@ -59,15 +16,18 @@ def unpack(blob: bytes, out_dir: Path, root: Path, depth: int, max_depth: int) -
|
||||
return []
|
||||
recs = []
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
for idx, off, child in children:
|
||||
magic = magic_of(child)
|
||||
name = f"{idx:04d}_{safe_tag(magic)}{ext_for(child)}"
|
||||
for child in children:
|
||||
magic = child.magic
|
||||
name = f"{child.index:04d}_{safe_tag(magic)}{ext_for_blob(child.data)}"
|
||||
path = out_dir / name
|
||||
path.write_bytes(child)
|
||||
rec = {"index": idx, "offset": off, "size": len(child), "magic": magic,
|
||||
"path": str(path.relative_to(root)).replace("\\", "/")}
|
||||
if depth < max_depth and child[:4] == b"FHM ":
|
||||
nested = unpack(child, out_dir / f"{idx:04d}_FHM", root, depth + 1, max_depth)
|
||||
path.write_bytes(child.data)
|
||||
rec = {"index": child.index, "offset": child.offset,
|
||||
"declared_size": child.declared_size, "size": child.size,
|
||||
"magic": magic, "path": str(path.relative_to(root)).replace("\\", "/")}
|
||||
if child.notes:
|
||||
rec["parser_notes"] = child.notes
|
||||
if depth < max_depth and child.data[:4] == b"FHM ":
|
||||
nested = unpack(child.data, out_dir / f"{child.index:04d}_FHM", root, depth + 1, max_depth)
|
||||
if nested:
|
||||
rec["children"] = nested
|
||||
recs.append(rec)
|
||||
@@ -97,7 +57,7 @@ def main() -> int:
|
||||
stem = src.stem.split(".")[0]
|
||||
if blob[:4] != b"FHM ":
|
||||
# Top-level non-FHM (raw entry): copy through as a leaf.
|
||||
dst = out_root / f"{stem}{ext_for(blob)}"
|
||||
dst = out_root / f"{stem}{ext_for_blob(blob)}"
|
||||
dst.write_bytes(blob)
|
||||
manifest.append({"source": src.name, "kind": "leaf", "magic": magic_of(blob),
|
||||
"path": str(dst.relative_to(out_root)).replace("\\", "/")})
|
||||
|
||||
Reference in New Issue
Block a user