155 lines
6.6 KiB
Python
155 lines
6.6 KiB
Python
"""Extract bounded, typed CW dictionaries from a reference MemoryPack cache.
|
|||
|
|
|
||
|
|
Other cache schemas need not match. Each candidate must decode every row with
|
||
|
|
matching dictionary/row IDs, unique keys and bounded arrays. Require catalog
|
||
|
|
coverage for nodes and reject ambiguous largest candidates. This exports
|
||
|
|
reference data, not a claim of current Client value compatibility.
|
||
|
|
"""
|
||
|
|
import argparse
|
||
|
|
import gzip
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
from pathlib import Path
|
||
|
|
import re
|
||
|
|
import struct
|
||
|
|
|
||
|
|
|
||
|
|
class Reader:
|
||
|
|
def __init__(self, data, offset):
|
||
|
|
self.data = data
|
||
|
|
self.offset = offset
|
||
|
|
|
||
|
|
def uint(self):
|
||
|
|
value = struct.unpack_from('<I', self.data, self.offset)[0]
|
||
|
|
self.offset += 4
|
||
|
|
return value
|
||
|
|
|
||
|
|
def byte(self):
|
||
|
|
value = self.data[self.offset]
|
||
|
|
self.offset += 1
|
||
|
|
return value
|
||
|
|
|
||
|
|
def array(self, fixed=False):
|
||
|
|
count = self.uint()
|
||
|
|
if count > 4096:
|
||
|
|
raise ValueError('array exceeds extraction bound')
|
||
|
|
values = []
|
||
|
|
for _ in range(count):
|
||
|
|
if fixed and self.byte() != 1:
|
||
|
|
raise ValueError('fixed-value schema differs')
|
||
|
|
values.append(self.uint())
|
||
|
|
return values
|
||
|
|
|
||
|
|
|
||
|
|
def row(reader, kind):
|
||
|
|
headers = {'portals': 5, 'node_templates': 7, 'penalty_rules': 5,
|
||
|
|
'basic_bonuses': 5, 'combination_bonuses': 3}
|
||
|
|
if reader.byte() != headers[kind]:
|
||
|
|
raise ValueError('row schema differs')
|
||
|
|
identity = reader.uint()
|
||
|
|
result = {'ID': identity}
|
||
|
|
if kind == 'portals':
|
||
|
|
flag = reader.byte()
|
||
|
|
if flag > 1:
|
||
|
|
raise ValueError('invalid boolean')
|
||
|
|
result.update(IfInBook=bool(flag), EffectParamList=reader.array(True),
|
||
|
|
ShowBonusIDList=reader.array(), PortalGameRefTrait=reader.array())
|
||
|
|
elif kind == 'node_templates':
|
||
|
|
result.update(StageID=reader.uint(), NodeType=reader.uint(),
|
||
|
|
ParamList=reader.array(), PenaltyBonusRuleID=reader.uint(),
|
||
|
|
IsAugment=reader.uint(), BasicGoldRewardNum=reader.uint())
|
||
|
|
if result['NodeType'] not in range(6):
|
||
|
|
raise ValueError('unknown node enum')
|
||
|
|
elif kind == 'penalty_rules':
|
||
|
|
result.update(ThresholdPosition=reader.uint(),
|
||
|
|
ThresholdFailPlayerHPPenalty=reader.uint(),
|
||
|
|
ThresholdPassBasicPlayerHPPenalty=reader.uint(),
|
||
|
|
ProgressPenaltyCoefficient=reader.uint())
|
||
|
|
if result['ThresholdPosition'] > 100 or any(
|
||
|
|
value > 1000000 for key, value in result.items() if key != 'ID'):
|
||
|
|
raise ValueError('penalty values exceed known schema range')
|
||
|
|
elif kind == 'basic_bonuses':
|
||
|
|
result.update(Value=reader.uint(), BonusTypeParam=reader.uint(),
|
||
|
|
BonusTypeParamList=reader.array(), BonusType=reader.uint())
|
||
|
|
# Preserve numeric bonus kinds added after the public source snapshot.
|
||
|
|
# Consumers must reject kinds they do not implement, not drop rows.
|
||
|
|
if result['BonusType'] > 255:
|
||
|
|
raise ValueError('bonus kind exceeds observed schema bound')
|
||
|
|
else:
|
||
|
|
result.update(CombinationBonusList=reader.array(), BonusNumberList=reader.array())
|
||
|
|
if len(result['CombinationBonusList']) != len(result['BonusNumberList']):
|
||
|
|
raise ValueError('combination quantities differ')
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def extract(data, kind, required):
|
||
|
|
header = {'portals': 5, 'node_templates': 7, 'penalty_rules': 5,
|
||
|
|
'basic_bonuses': 5, 'combination_bonuses': 3}[kind]
|
||
|
|
pattern = re.compile(rb'(?P<key>.{4})' + bytes([header]) + rb'(?P=key)', re.S)
|
||
|
|
candidates = []
|
||
|
|
attempts = 0
|
||
|
|
for match in pattern.finditer(data):
|
||
|
|
start = match.start() - 4
|
||
|
|
if start < 0:
|
||
|
|
continue
|
||
|
|
count = struct.unpack_from('<i', data, start)[0]
|
||
|
|
if not 2 <= count <= 10000:
|
||
|
|
continue
|
||
|
|
attempts += 1
|
||
|
|
if attempts > 50000:
|
||
|
|
raise ValueError(f'{kind}: candidate bound exceeded')
|
||
|
|
reader = Reader(data, start + 4)
|
||
|
|
rows = {}
|
||
|
|
try:
|
||
|
|
for _ in range(count):
|
||
|
|
key = reader.uint()
|
||
|
|
value = row(reader, kind)
|
||
|
|
if key != value['ID'] or key in rows:
|
||
|
|
raise ValueError('dictionary identity differs')
|
||
|
|
rows[key] = value
|
||
|
|
if reader.offset - start > 10000000:
|
||
|
|
raise ValueError('dictionary exceeds extraction bound')
|
||
|
|
if required.issubset(rows):
|
||
|
|
candidates.append((count, start, reader.offset, rows))
|
||
|
|
except (ValueError, IndexError, struct.error):
|
||
|
|
continue
|
||
|
|
if not candidates:
|
||
|
|
raise ValueError(f'{kind}: no complete matching dictionary')
|
||
|
|
largest = max(candidate[0] for candidate in candidates)
|
||
|
|
winners = [candidate for candidate in candidates if candidate[0] == largest]
|
||
|
|
if len(winners) != 1:
|
||
|
|
raise ValueError(f'{kind}: ambiguous full dictionary')
|
||
|
|
count, start, end, rows = winners[0]
|
||
|
|
print(f'{kind}: {count} rows, decompressed bytes {start}..{end}')
|
||
|
|
return {'offset': start, 'length': end-start, 'rows': rows}
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
parser.add_argument('--resource', required=True, type=Path)
|
||
|
|
parser.add_argument('--catalog', required=True, type=Path)
|
||
|
|
parser.add_argument('--output', required=True, type=Path)
|
||
|
|
args = parser.parse_args()
|
||
|
|
packed = args.resource.read_bytes()
|
||
|
|
data = gzip.decompress(packed)
|
||
|
|
if len(data) > 256 * 1024 * 1024:
|
||
|
|
raise ValueError('decompressed resource exceeds extraction bound')
|
||
|
|
catalog = json.loads(args.catalog.read_text(encoding='utf-8-sig'))
|
||
|
|
node_keys = {node['NodeTemplateID'] for node in catalog['stage_routes']}
|
||
|
|
tables = {}
|
||
|
|
for kind, required in [('node_templates', node_keys), ('portals', set()),
|
||
|
|
('basic_bonuses', set()), ('combination_bonuses', set())]:
|
||
|
|
tables[kind] = extract(data, kind, required)
|
||
|
|
penalties = {value['PenaltyBonusRuleID']
|
||
|
|
for value in tables['node_templates']['rows'].values()
|
||
|
|
if value['PenaltyBonusRuleID']}
|
||
|
|
tables['penalty_rules'] = extract(data, 'penalty_rules', penalties)
|
||
|
|
output = {'format_version': 1, 'source_sha256': hashlib.sha256(packed).hexdigest(),
|
||
|
|
'target_assembly_sha256': catalog['provenance']['game_assembly_sha256'],
|
||
|
|
'compatibility': 'Reference dictionaries; target node ID coverage checked, values require Client comparison.',
|
||
|
|
'tables': tables}
|
||
|
|
args.output.write_text(json.dumps(output, indent=2)+'\n', encoding='utf-8')
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
main()
|