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:
salh
2026-06-15 16:03:43 +03:00
parent 0d7a528395
commit c2e2fbfbbc
27 changed files with 620 additions and 291 deletions
+69 -3
View File
@@ -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
View File
@@ -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,
+4 -1
View File
@@ -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)
+46 -11
View File
@@ -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
View File
@@ -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("\\", "/")})