#!/usr/bin/env python3 """Display or edit a Hengband SAVE26 character's height and weight.""" from __future__ import annotations import argparse import shutil import struct import sys from pathlib import Path VARIANT = b"Hengband" QUICK_START_FIXED_PREFIX = 160 SUPPORTED_SAVE_VERSIONS = range(23, 27) def cstring(data: bytearray, pos: int, limit: int = 4096) -> tuple[bytes, int]: end = data.find(0, pos, min(len(data), pos + limit)) if end < 0: raise ValueError("unterminated string") return bytes(data[pos:end]), end + 1 def decode_save(raw: bytes) -> tuple[bytearray, int]: if len(raw) < 22 or raw[0] != len(VARIANT) or raw[1:1 + len(VARIANT)] != VARIANT: raise ValueError("SAVE26形式のHengbandセーブデータではありません") # length + unencoded variant + four chained version bytes + random XOR key start = 1 + len(VARIANT) + 4 + 1 plain = bytearray(raw) for i in range(start, len(raw)): plain[i] = raw[i] ^ raw[i - 1] version_at = start header_at = 1 + len(VARIANT) previous = 0 game_version = [] for encoded in raw[header_at:header_at + 4]: game_version.append(encoded ^ previous) previous = encoded # Saves made before 3.0.2.3 contain twelve obsolete bytes here. The # current loader skips them in rd_version_info() before reading SAVE. if tuple(game_version) < (3, 0, 2, 3): version_at += 12 version = struct.unpack_from(" None: if len(raw) < start + 8: raise ValueError("セーブデータが短すぎます") stored_v, stored_x = struct.unpack_from(" tuple[int, bytes]: candidates: list[tuple[int, int, bytes]] = [] # A player record starts with name, cause of death, and last message strings. # Scanning is used because all preceding lore/quest sections have variable size. for record in range(start, len(plain) - 400): try: name, p = cstring(plain, record, 64) if not name or len(name) > 31: continue _, p = cstring(plain, p, 1024) _, p = cstring(plain, p, 4096) p += QUICK_START_FIXED_PREFIX for _ in range(4): _, p = cstring(plain, p, 4096) p += 2 # unused random-quest count and quick_ok for _ in range(4): _, p = cstring(plain, p, 4096) # race,class,personality,sex,realm1,realm2,reserved,hit-die,expfact if plain[p + 6] != 0 or not 1 <= plain[p + 7] <= 100: continue expfact, age, height, weight = struct.unpack_from(" str: try: return value.decode(encoding) except UnicodeDecodeError: return "" def display_text(value: bytes) -> str: for encoding in ("utf-8", "cp932", "euc_jp"): text = _decode(value, encoding) if text and text.isprintable(): return text return value.hex() def find_social_class(plain: bytearray, body_at: int) -> int: """Locate sc using its reserved-field signature in wr_player().""" candidates = [] end = min(len(plain) - 12, body_at + 20000) for reserved_at in range(body_at + 100, end): if plain[reserved_at:reserved_at + 8] != b"\0" * 8: continue social = struct.unpack_from(" bytes: struct.pack_into(" int: parser = argparse.ArgumentParser(description="Hengband SAVE26の身長・体重を表示・変更します") parser.add_argument("savefile", type=Path) parser.add_argument("--name", help="候補が複数ある場合のキャラクター名") parser.add_argument("--age", type=int, metavar="YEARS", help="新しい年齢") parser.add_argument("--height", type=int, metavar="CM", help="新しい身長(cm)") parser.add_argument("--weight", type=int, metavar="KG", help="新しい体重(kg)") parser.add_argument("--social-class", type=int, metavar="NUMBER", help="新しい社会的地位(1~100)") parser.add_argument("--in-place", action="store_true", help="元ファイルを書換え(既定動作、.bakも作成)") parser.add_argument("--output", type=Path, help="書換えたセーブデータの出力先") args = parser.parse_args() try: raw = args.savefile.read_bytes() plain, start = decode_save(raw) verify_checksums(raw, plain, start) body_at, name = find_player(plain, start, args.name) social_at = find_social_class(plain, body_at) age = struct.unpack_from("