Files
GaCy-GO/tools/export_comic_dessert_catalog.py
T
2026-10-28 18:54:43 +07:00

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()