Files
metin2-client/tools/wiki/build_catalogue.py
T

113 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Build a bounded, versioned wiki catalogue from the current server's public tables.
No account/character/configuration data is read. Output is UTF-8 TSV, never executable Python.
"""
import argparse
import hashlib
import json
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
FILES = ('item_proto.txt', 'item_names.txt', 'mob_proto.txt', 'mob_names.txt', 'refine.tsv')
def rows(path):
for line in path.read_bytes().splitlines():
cols = line.split(b'\t')
if cols and cols[0].split(b'~')[0].isdigit():
yield cols
def names(path, overlay):
result = {}
for cols in rows(path):
if len(cols) > 1:
result[int(cols[0].split(b'~')[0])] = cols[1].decode('gbk', 'replace')
for path in overlay:
for line in path.read_text(encoding='utf-8').splitlines():
c = line.split('\t', 1)
if len(c) == 2 and c[0].isdigit():
result[int(c[0])] = c[1]
return result
def clean(value):
value = str(value)
if any(c in value for c in '\t\r\n\x00') or len(value) > 256:
raise ValueError('invalid catalogue cell')
# Native text renderer recognizes pipe escape sequences; source names are plain text.
return value.replace('|', '/')
def build(source, output, names_dir):
hashes = {name: hashlib.sha256((source / name).read_bytes()).hexdigest() for name in FILES}
item_names = names(source / 'item_names.txt', [names_dir / 'item_names_zh.txt', names_dir / 'item_names_zh_overlay.txt'])
mob_names = names(source / 'mob_names.txt', [names_dir / 'mob_names_zh.txt', names_dir / 'mob_names_zh_overlay.txt'])
records, items, mobs, refines = [], {}, set(), {}
for c in rows(source / 'item_proto.txt'):
if len(c) < 33:
raise ValueError('truncated item row')
extent = list(map(int, c[0].split(b'~')))
start, end = extent[0], extent[-1]
if not 0 < start <= end < 2**32 or end - start > 10000 or start in items:
raise ValueError('invalid/duplicate item range')
kind, subtype = c[2].decode('ascii'), c[3].decode('ascii')
if kind == 'ITEM_ELK':
continue
level = max([int(c[i+1]) for i in (14, 16) if c[i] == b'LEVEL'] + [0])
name = item_names.get(start, '物品 #%d' % start)
# I id range_end name type subtype minimum_level size refined_vnum refine_set
row = ['I', start, end, name, kind, subtype, level, int(c[4]), int(c[11]), int(c[12])]
items[start] = row
records.append(row)
extents = sorted((v[1], v[2]) for v in items.values())
if any(a[1] >= b[0] for a, b in zip(extents, extents[1:])):
raise ValueError('overlapping item ranges')
for c in rows(source / 'mob_proto.txt'):
if len(c) < 27:
raise ValueError('truncated mob row')
if c[3] not in (b'MONSTER', b'STONE'):
continue
vnum = int(c[0])
if vnum in mobs:
raise ValueError('duplicate monster')
mobs.add(vnum)
# M id name type rank level hp damage_min damage_max experience defense
records.append(['M', vnum, mob_names.get(vnum, '怪物 #%d' % vnum), c[3].decode('ascii'),
c[2].decode('ascii')] + [int(c[i]) for i in (5, 20, 18, 19, 25, 26)])
for c in rows(source / 'refine.tsv'):
if len(c) != 13:
raise ValueError('refine table must contain 13 columns')
values = list(map(int, c))
rid, cost, prob = values[:3]
if rid in refines or rid <= 0 or cost < 0 or not 0 <= prob <= 100:
raise ValueError('invalid refine record')
mats = [(values[i], values[i+1]) for i in range(3, 13, 2) if values[i] or values[i+1]]
if any(v <= 0 or n <= 0 for v, n in mats):
raise ValueError('invalid refine ingredient')
refines[rid] = True
records.append(['R', rid, cost, prob, ','.join('%d:%d' % pair for pair in mats)])
if not items or not mobs or not refines or len(records) > 30000:
raise ValueError('empty or oversized catalogue')
payload = ''.join('\t'.join(clean(c) for c in row) + '\n' for row in records).encode('utf-8')
digest = hashlib.sha256(payload).hexdigest()
output.mkdir(parents=True, exist_ok=True)
(output / 'catalogue.txt').write_bytes(('MTWIKI\t1\t' + digest + '\n').encode('ascii') + payload)
report = dict(schema=1, catalogue_sha256=digest, source_sha256=hashes,
items=len(items), monsters=len(mobs), refine_recipes=len(refines),
source='203 public server tables; no player data',
generator_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
localization_sha256={name:hashlib.sha256((names_dir/name).read_bytes()).hexdigest() for name in ('item_names_zh.txt','item_names_zh_overlay.txt','mob_names_zh.txt','mob_names_zh_overlay.txt')},
scope='template catalogue; acquisition and runtime rates not yet queried')
(output / 'manifest.json').write_text(json.dumps(report, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
return report
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', type=Path, required=True)
parser.add_argument('--output', type=Path, default=REPO / 'data/wiki')
parser.add_argument('--names-dir', type=Path, default=REPO / 'data/zh_40250')
args = parser.parse_args()
print(json.dumps(build(args.source, args.output, args.names_dir), ensure_ascii=False, indent=2))