130 lines
6.7 KiB
Python
130 lines
6.7 KiB
Python
"""Retain complete bounded ComicDessert/FiveDim repository captures offline.
|
|
|
|
This does not unlock activities, fabricate initial progress or modify game saves.
|
|
An incomplete capture fails rather than generating a misleading catalog.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
inputs = parser.add_mutually_exclusive_group(required=True)
|
|
inputs.add_argument('--log', type=Path)
|
|
inputs.add_argument('--evidence', type=Path, help='Complete exact flat-getter Frida evidence')
|
|
parser.add_argument('--assembly-sha256', required=True)
|
|
parser.add_argument('--output', type=Path, required=True)
|
|
args = parser.parse_args()
|
|
if not re.fullmatch(r'[0-9a-f]{64}', args.assembly_sha256):
|
|
raise SystemExit('An exact verified assembly SHA256 is required')
|
|
raw = (args.evidence or args.log).read_bytes()
|
|
if args.evidence:
|
|
evidence = json.loads(raw)
|
|
observed = evidence['normalized_result']
|
|
if observed.get('messages_truncated'):
|
|
raise SystemExit('Truncated evidence cannot produce a catalog')
|
|
tables, counts, complete, scripts = {}, {}, set(), set()
|
|
ended = False
|
|
for message in observed['messages']:
|
|
value = message.get('payload', {})
|
|
if value.get('kind') == 'ATTACHED' and value.get('scope', '').startswith('two exact lazy flat getters'):
|
|
scripts.add(message['script_id'])
|
|
if message.get('script_id') not in scripts:
|
|
continue
|
|
kind, name = value.get('kind'), value.get('table')
|
|
if kind == 'ERROR':
|
|
raise SystemExit('Flat capture failed: ' + value.get('error', 'unknown'))
|
|
if kind == 'BEGIN':
|
|
if name in tables or name not in ('cycle', 'level') or not 0 < value['count'] <= 256:
|
|
raise SystemExit('Unexpected or empty table')
|
|
tables[name], counts[name] = [], value['count']
|
|
elif kind == 'ROWS':
|
|
if name not in tables or name in complete:
|
|
raise SystemExit('Rows outside capture boundaries')
|
|
tables[name].extend(json.loads(row) for row in value['rows'])
|
|
elif kind == 'TABLE_END':
|
|
if name not in tables or len(tables[name]) != counts[name] or value['count'] != counts[name]:
|
|
raise SystemExit('Table count mismatch')
|
|
complete.add(name)
|
|
elif kind == 'END':
|
|
ended = True
|
|
if len(scripts) != 1 or not ended or complete != {'cycle', 'level'}:
|
|
raise SystemExit('Complete single flat capture required')
|
|
level_ids = {row['ID'] for row in tables['level']}
|
|
if len(level_ids) != len(tables['level']) or len({r['ID'] for r in tables['cycle']}) != len(tables['cycle']):
|
|
raise SystemExit('Duplicate configuration IDs')
|
|
if any(level not in level_ids for row in tables['cycle'] for level in row['LevelIDList']):
|
|
raise SystemExit('Cycle references missing levels')
|
|
result = {
|
|
'format_version': 1,
|
|
'client_assembly_sha256': args.assembly_sha256,
|
|
'evidence_id': evidence['evidence_id'],
|
|
'source': 'Current named ComicDessert CycleRow/LevelRow FromBinary -> dictionary Add -> lazy flat GetAll chains; no gameplay or scene factories',
|
|
'capture_sha256': hashlib.sha256(raw).hexdigest(),
|
|
'cycles': tables['cycle'],
|
|
'levels': tables['level'],
|
|
}
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
|
print(json.dumps({'cycles': len(tables['cycle']), 'levels': len(tables['level']), 'output': str(args.output)}))
|
|
return
|
|
marker = '[GAC_COMIC_STELLA_CONFIG] '
|
|
repositories, expected, completed, configs = {}, {}, set(), {}
|
|
ended = False
|
|
for line in raw.decode('utf-8', errors='replace').splitlines():
|
|
if marker not in line:
|
|
continue
|
|
value = line.split(marker, 1)[1]
|
|
if value.startswith('ERROR '):
|
|
raise SystemExit('Capture stopped: ' + value)
|
|
if value.startswith('BEGIN '):
|
|
match = re.fullmatch(r'BEGIN (\w+) count=(\d+)', value)
|
|
if not match or match[1] in repositories:
|
|
raise SystemExit('Repeated/incompatible capture: use a single run')
|
|
name, count = match[1], int(match[2])
|
|
if count > 256:
|
|
raise SystemExit('Repository outside capture bound')
|
|
repositories[name], expected[name] = [], count
|
|
elif value.startswith('ROW '):
|
|
_, name, body = value.split(' ', 2)
|
|
if name not in repositories or name in completed:
|
|
raise SystemExit('Repository row outside its bounded run')
|
|
repositories[name].append(json.loads(body))
|
|
elif value.startswith('END_REPOSITORY '):
|
|
match = re.fullmatch(r'END_REPOSITORY (\w+) rows=(\d+)', value)
|
|
if not match or match[1] not in repositories:
|
|
raise SystemExit('Unexpected repository completion')
|
|
name = match[1]
|
|
if len(repositories[name]) != int(match[2]) or len(repositories[name]) != expected[name]:
|
|
raise SystemExit('Repository row count mismatch: ' + name)
|
|
completed.add(name)
|
|
elif value.startswith('FIVEDIM_CONFIG '):
|
|
_, name, body = value.split(' ', 2)
|
|
if name in configs:
|
|
raise SystemExit('Duplicate FiveDim config capture: ' + name)
|
|
configs[name] = json.loads(body)
|
|
elif value == 'END':
|
|
ended = True
|
|
required = {'CycleConfigRepository', 'LevelConfigRepository'}
|
|
if not ended or completed != required or not all(repositories.get(name) for name in required):
|
|
raise SystemExit('Complete nonempty cycle and level repositories are required')
|
|
result = {
|
|
'format_version': 1,
|
|
'client_assembly_sha256': args.assembly_sha256,
|
|
'source': 'Read-only existing ComicDessert repositories and selected FiveDim config; no gameplay method invocation',
|
|
'capture_sha256': hashlib.sha256(raw).hexdigest(),
|
|
'repositories': repositories,
|
|
'five_dim_config': configs,
|
|
}
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
|
print(json.dumps({'output': str(args.output), 'rows': {k: len(v) for k, v in repositories.items()},
|
|
'five_dim_config': sorted(configs)}, ensure_ascii=False))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|