mirror of
https://github.com/Druthulu/BFM-decomp
synced 2026-09-26 21:36:06 -04:00
b6ea3a2747
- extract.py: one-command full-disc extraction (27 files: 24 Track-1 verbatim
+ 3 .DA as raw CD-DA audio from tracks 2-4) over the frozen iso9660.py
- cd_archive.py + pac.py: .CD TOC walker + PAC splitter, {index}.{type} naming
(no collisions; 447 sub-files, 1189 entries), SQV detection (98)
- lzss.py: game-semantics decoder (pos==0 terminator, -1 bias) + 5 unit tests;
138 type-4 decoded clean (0 warnings)
- manifest.py: deterministic JSON-Lines manifest (1801 artifacts) + --verify
- crosscheck.py + tools/brave-CUE/posix_shim.h: build CUE's brave from source
(GPL untouched), byte-compare; 1484 raw identical, 138/138 type-4 F6
- docs/formats.md: F5 counts (SC02=43), F2 refuted, F6 resolved, F1 type-7=139
- docs/effort-map.md + CLAUDE.md: Effort-map check rule (R7), Ultracode fix
- .gitignore: /extracted/* + !manifest/EXE exceptions; brave _build/
- install gcc 13.3.0 for the oracle
- milestone: one command extracts the full disc; MAIN.CD=49 sub-files; decoder
agrees byte-for-byte with CUE up to the game pos==0 terminator; zero ROM
staged; project version 1.1.0 -> 1.2.0
100 lines
3.4 KiB
Python
100 lines
3.4 KiB
Python
"""Deterministic SHA1 manifest of the extracted disc (Validation Stage 1).
|
|
|
|
Walks the extraction output tree and records one JSON object per file --
|
|
``{"path", "size", "sha1"}`` -- sorted by path with pinned JSON serialization, so
|
|
the manifest is byte-identical across machines and re-runs given the same dump.
|
|
``manifest.sha1`` holds the SHA1 of ``manifest.jsonl`` itself: the single value a
|
|
contributor diffs to prove their own extraction matches.
|
|
|
|
See PROJECT_CONTEXT.md "Testing & Validation" stage 1. Pure stdlib.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
|
|
MANIFEST_NAME = "manifest.jsonl"
|
|
MANIFEST_SHA1_NAME = "manifest.sha1"
|
|
_HASH_CHUNK = 1 << 20 # 1 MiB streaming reads
|
|
# The manifest's own files are never recorded inside it.
|
|
_SELF = {MANIFEST_NAME, MANIFEST_SHA1_NAME}
|
|
|
|
|
|
def _sha1_file(path: Path) -> tuple[int, str]:
|
|
h = hashlib.sha1()
|
|
size = 0
|
|
with open(path, "rb") as fh:
|
|
while True:
|
|
chunk = fh.read(_HASH_CHUNK)
|
|
if not chunk:
|
|
break
|
|
h.update(chunk)
|
|
size += len(chunk)
|
|
return size, h.hexdigest()
|
|
|
|
|
|
def _list_files(out_root: Path) -> list[str]:
|
|
"""Sorted POSIX-relative paths of every file under ``out_root`` (excluding
|
|
the manifest itself)."""
|
|
return sorted(
|
|
p.relative_to(out_root).as_posix()
|
|
for p in out_root.rglob("*")
|
|
if p.is_file() and p.name not in _SELF
|
|
)
|
|
|
|
|
|
def build(out_root: Path) -> list[dict]:
|
|
"""Deterministic record list for every file under ``out_root`` (sorted)."""
|
|
records = []
|
|
for rel in _list_files(out_root):
|
|
size, sha1 = _sha1_file(out_root / rel)
|
|
records.append({"path": rel, "size": size, "sha1": sha1})
|
|
return records
|
|
|
|
|
|
def render(records: list[dict]) -> str:
|
|
"""Pinned, deterministic JSON-Lines serialization (locale-independent)."""
|
|
return "".join(
|
|
json.dumps(r, separators=(",", ":"), ensure_ascii=True, sort_keys=True) + "\n"
|
|
for r in records
|
|
)
|
|
|
|
|
|
def write(out_root: Path, records: list[dict]) -> str:
|
|
"""Write manifest.jsonl + manifest.sha1; return the manifest's own SHA1."""
|
|
text = render(records)
|
|
(out_root / MANIFEST_NAME).write_text(text, encoding="ascii", newline="\n")
|
|
digest = hashlib.sha1(text.encode("ascii")).hexdigest()
|
|
(out_root / MANIFEST_SHA1_NAME).write_text(digest + "\n", encoding="ascii", newline="\n")
|
|
return digest
|
|
|
|
|
|
def verify(out_root: Path) -> tuple[bool, list[str]]:
|
|
"""Re-hash every file named in manifest.jsonl and compare to the recorded
|
|
size/sha1; also flag any file on disk that the manifest does not list.
|
|
Returns ``(ok, problems)``.
|
|
"""
|
|
mpath = out_root / MANIFEST_NAME
|
|
if not mpath.is_file():
|
|
return False, [f"missing {mpath}"]
|
|
problems: list[str] = []
|
|
seen: set[str] = set()
|
|
for line in mpath.read_text(encoding="ascii").splitlines():
|
|
if not line.strip():
|
|
continue
|
|
rec = json.loads(line)
|
|
rel = rec["path"]
|
|
seen.add(rel)
|
|
fp = out_root / rel
|
|
if not fp.is_file():
|
|
problems.append(f"missing file: {rel}")
|
|
continue
|
|
size, sha1 = _sha1_file(fp)
|
|
if size != rec["size"] or sha1 != rec["sha1"]:
|
|
problems.append(f"mismatch: {rel}")
|
|
for rel in sorted(set(_list_files(out_root)) - seen):
|
|
problems.append(f"unlisted on disk: {rel}")
|
|
return (not problems), problems
|