Initial commit
This commit is contained in:
143
ackit/originals.py
Normal file
143
ackit/originals.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Originals store: keeps the game's original bytes of every sound you modify, so any change can be
|
||||
reverted exactly, without keeping full copies of the 0.4-0.5 GB WADs.
|
||||
|
||||
Layout (one store per game folder, in the user data folder by default; see docs/decisions.md):
|
||||
<root>/store.json records: {key: {...}} + per-WAD info
|
||||
<root>/blobs/<sha256>.wem original WEMs and the replacements you applied
|
||||
<root>/gaps/<PACK>.json original non-zero padding of stream packs (byte-exact pack reverts)
|
||||
<root>/full/<rel path> optional full copies of WADs / pack indexes (fallback, see `full_backup`)
|
||||
Writes to store.json are atomic (temp file + rename).
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
|
||||
STORE_VERSION = 1
|
||||
|
||||
|
||||
def sha256(b):
|
||||
return hashlib.sha256(b).hexdigest()
|
||||
|
||||
|
||||
def sha1_16(b):
|
||||
return hashlib.sha1(b).hexdigest()[:16]
|
||||
|
||||
|
||||
class OriginalsStore:
|
||||
def __init__(self, root, game_root=None):
|
||||
self.root = root
|
||||
os.makedirs(os.path.join(root, "blobs"), exist_ok=True)
|
||||
self.path = os.path.join(root, "store.json")
|
||||
if os.path.exists(self.path):
|
||||
self.data = json.load(open(self.path, encoding="utf-8"))
|
||||
else:
|
||||
self.data = {"version": STORE_VERSION, "game_root": game_root, "created": time.time(),
|
||||
"entries": {}, "wads": {}}
|
||||
self.save()
|
||||
|
||||
# persistence -------------------------------------------------------------------------------
|
||||
def save(self):
|
||||
tmp = self.path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(self.data, f, indent=1)
|
||||
os.replace(tmp, self.path)
|
||||
|
||||
def put_blob(self, b):
|
||||
h = sha256(b)
|
||||
p = os.path.join(self.root, "blobs", h + ".wem")
|
||||
if not os.path.exists(p):
|
||||
tmp = p + ".tmp"
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(b)
|
||||
os.replace(tmp, p)
|
||||
return h
|
||||
|
||||
def get_blob(self, h):
|
||||
p = os.path.join(self.root, "blobs", h + ".wem")
|
||||
b = open(p, "rb").read()
|
||||
if sha256(b) != h:
|
||||
raise IOError("originals store is damaged: %s" % p)
|
||||
return b
|
||||
|
||||
# records ---------------------------------------------------------------------------------
|
||||
@property
|
||||
def entries(self):
|
||||
return self.data["entries"]
|
||||
|
||||
def get(self, key):
|
||||
return self.entries.get(key)
|
||||
|
||||
def original(self, key):
|
||||
e = self.get(key)
|
||||
return self.get_blob(e["orig"]) if e else None
|
||||
|
||||
def remember_original(self, key, archive, target, wad_rel, data):
|
||||
"""Store the original bytes the first time a sound is modified (no-op afterwards)."""
|
||||
if key not in self.entries:
|
||||
self.entries[key] = {"archive": archive, "target": target, "wad": wad_rel,
|
||||
"orig": self.put_blob(data), "orig_size": len(data),
|
||||
"orig_sha1": sha1_16(data), "first_modified": time.time(),
|
||||
"applied": None}
|
||||
return self.entries[key]
|
||||
|
||||
def record_applied(self, key, data, meta=None):
|
||||
e = self.entries[key]
|
||||
e["applied"] = self.put_blob(data)
|
||||
e["applied_at"] = time.time()
|
||||
e["meta"] = meta or {}
|
||||
|
||||
def forget(self, key):
|
||||
self.entries.pop(key, None)
|
||||
|
||||
def gc(self):
|
||||
"""Delete blobs no record refers to."""
|
||||
used = {e[k] for e in self.entries.values() for k in ("orig", "applied") if e.get(k)}
|
||||
d = os.path.join(self.root, "blobs")
|
||||
for f in os.listdir(d):
|
||||
if f.endswith(".wem") and f[:-4] not in used:
|
||||
os.remove(os.path.join(d, f))
|
||||
|
||||
# pack padding ------------------------------------------------------------------------------
|
||||
def pack_gaps(self, pack_id):
|
||||
p = os.path.join(self.root, "gaps", "%08X.json" % pack_id)
|
||||
if not os.path.exists(p):
|
||||
return None
|
||||
return {int(k): bytes.fromhex(v) for k, v in json.load(open(p)).items()}
|
||||
|
||||
def remember_pack_gaps(self, pack_id, gaps):
|
||||
p = os.path.join(self.root, "gaps", "%08X.json" % pack_id)
|
||||
if os.path.exists(p):
|
||||
return
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
json.dump({str(k): v.hex() for k, v in gaps.items()}, open(p, "w"))
|
||||
|
||||
# full-file fallback backups ----------------------------------------------------------------
|
||||
def full_backup(self, src, rel):
|
||||
"""Copy a game file once (before its first modification), verified by SHA-1."""
|
||||
dst = os.path.join(self.root, "full", rel)
|
||||
if os.path.exists(dst):
|
||||
return dst
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
tmp = dst + ".tmp"
|
||||
shutil.copy2(src, tmp)
|
||||
if _file_sha1(tmp) != _file_sha1(src):
|
||||
os.remove(tmp)
|
||||
raise IOError("backup copy of %s does not match the original" % rel)
|
||||
os.replace(tmp, dst)
|
||||
self.data.setdefault("full", {})[rel] = {"size": os.path.getsize(dst), "time": time.time()}
|
||||
self.save()
|
||||
return dst
|
||||
|
||||
def full_copy(self, rel):
|
||||
p = os.path.join(self.root, "full", rel)
|
||||
return p if os.path.exists(p) else None
|
||||
|
||||
|
||||
def _file_sha1(p):
|
||||
h = hashlib.sha1()
|
||||
with open(p, "rb") as f:
|
||||
for c in iter(lambda: f.read(1 << 22), b""):
|
||||
h.update(c)
|
||||
return h.hexdigest()
|
||||
Reference in New Issue
Block a user