panel — open-source game server manager (public release)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Remap 7DTD decoration.7dt / multiblocks.7dt block ids from a world's NATIVE
|
||||
table to the cluster CANONICAL table, BY NAME. Preserves all non-id bits.
|
||||
|
||||
VENDORED, SELF-CONTAINED copy of nim-freeze/remap_7dt.py with nimtool.read_nim
|
||||
inlined and the host sys.path dependency removed, so it runs inside the
|
||||
panel-7dtd container (python3-minimal, no extra packages). Keep behaviour
|
||||
byte-identical to remap_7dt.py — the entrypoint's auto-provision relies on
|
||||
apply()'s exit code (0 = remapped + self-verified safe, 1 = refuse to start).
|
||||
|
||||
7dt format (little-endian, no trailing bytes):
|
||||
[0] u8 version (=6)
|
||||
[1:5] u32 count
|
||||
then count * 17-byte records; block id = u16 @ record offset +12,
|
||||
low 15 bits (& 0x7FFF) = id, high bit + bytes +14/+16 = rotation/placement.
|
||||
Rewrite rule: new = (orig & ~0x7FFF) | (canon_id & 0x7FFF).
|
||||
"""
|
||||
import sys, struct
|
||||
|
||||
REC = 17; HDR = 5; IDOFF = 12
|
||||
|
||||
|
||||
# --- inlined from nimtool.py (read_nim / parse_nim_bytes), byte-faithful ---
|
||||
def read_nim(path):
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
return parse_nim_bytes(data)
|
||||
|
||||
|
||||
def parse_nim_bytes(data):
|
||||
if len(data) < 8:
|
||||
raise ValueError("file too short for header (%d bytes)" % len(data))
|
||||
version, count = struct.unpack_from("<II", data, 0)
|
||||
off = 8
|
||||
records = []
|
||||
for i in range(count):
|
||||
if off + 5 > len(data):
|
||||
raise ValueError("record %d: truncated header at offset %d (len=%d)" % (i, off, len(data)))
|
||||
rid, flag = struct.unpack_from("<HH", data, off)
|
||||
namelen = data[off + 4]
|
||||
off += 5
|
||||
if off + namelen > len(data):
|
||||
raise ValueError("record %d (id=%d): name truncated at offset %d (len=%d)" % (i, rid, off, len(data)))
|
||||
name = data[off:off + namelen].decode("utf-8")
|
||||
off += namelen
|
||||
records.append((rid, flag, name))
|
||||
if off != len(data):
|
||||
raise ValueError("trailing bytes: consumed %d of %d" % (off, len(data)))
|
||||
return records, version
|
||||
# --- end inlined ---
|
||||
|
||||
|
||||
def load_id2name(path):
|
||||
if path.endswith('.nim'):
|
||||
recs, _ = read_nim(path)
|
||||
return {i: n for i, _, n in recs}
|
||||
d = {}
|
||||
for line in open(path, encoding='utf-8', errors='replace'):
|
||||
line = line.rstrip('\n')
|
||||
if not line: continue
|
||||
p = line.split('\t')
|
||||
if len(p) >= 2 and p[0].isdigit():
|
||||
d[int(p[0])] = p[1]
|
||||
return d
|
||||
|
||||
def load_name2id(path):
|
||||
recs, _ = read_nim(path)
|
||||
return {n: i for i, _, n in recs}
|
||||
|
||||
def canon_idset(path):
|
||||
recs, _ = read_nim(path)
|
||||
return {i for i, _, n in recs}
|
||||
|
||||
def decode_ids(b):
|
||||
ver = b[0]; count = struct.unpack_from('<I', b, 1)[0]
|
||||
assert len(b) == HDR + REC*count, f"BAD LEN {len(b)} != {HDR+REC*count} (ver={ver} count={count})"
|
||||
out = []
|
||||
for r in range(count):
|
||||
pos = HDR + r*REC + IDOFF
|
||||
orig = struct.unpack_from('<H', b, pos)[0]
|
||||
out.append((pos, orig, orig & 0x7FFF, orig & 0x8000))
|
||||
return ver, count, out
|
||||
|
||||
def analyze(native, canon, infile):
|
||||
b = open(infile, 'rb').read()
|
||||
n2 = load_id2name(native); c2 = load_name2id(canon)
|
||||
ver, count, recs = decode_ids(b)
|
||||
from collections import Counter
|
||||
dist = Counter(r[2] for r in recs)
|
||||
unmap_id = sorted(i for i in dist if i not in n2)
|
||||
nm_for = {i: n2.get(i) for i in dist}
|
||||
unmap_name = sorted(set(nm_for[i] for i in dist if nm_for[i] is not None and nm_for[i] not in c2))
|
||||
inst_unmap_id = sum(dist[i] for i in unmap_id)
|
||||
print(f" file={infile.split('/')[-1]} ver={ver} count={count} distinct_ids={len(dist)}")
|
||||
print(f" distinct ids NOT in native table: {len(unmap_id)} (instances {inst_unmap_id}/{count} = {100*inst_unmap_id/count:.1f}%)")
|
||||
if unmap_id[:8]: print(f" e.g. {unmap_id[:8]}")
|
||||
print(f" distinct names NOT in canon: {len(unmap_name)}")
|
||||
if unmap_name[:8]: print(f" e.g. {unmap_name[:8]}")
|
||||
nchg = sum(dist[i] for i in dist if i in n2 and n2[i] in c2 and (c2[n2[i]] & 0x7FFF) != i)
|
||||
print(f" instances whose canon id DIFFERS from native (would be rewritten): {nchg} ({100*nchg/count:.1f}%)")
|
||||
ok = (len(unmap_id) == 0 and len(unmap_name) == 0)
|
||||
print(f" >>> FULLY REMAPPABLE: {ok}")
|
||||
return ok
|
||||
|
||||
def decodecheck(table, infile):
|
||||
b = open(infile, 'rb').read(); ids = canon_idset(table) if table.endswith('.nim') else set(load_id2name(table))
|
||||
ver, count, recs = decode_ids(b)
|
||||
from collections import Counter
|
||||
dist = Counter(r[2] for r in recs)
|
||||
miss = sum(dist[i] for i in dist if i not in ids)
|
||||
print(f" {infile.split('/')[-1]} vs {table.split('/')[-1]}: {count} instances, {len(dist)} distinct ids, MISSING-in-table {miss} ({100*miss/count:.1f}%) [clean world should be ~0%]")
|
||||
|
||||
def apply(native, canon, infile, outfile):
|
||||
src = open(infile, 'rb').read(); b = bytearray(src)
|
||||
n2 = load_id2name(native); c2 = load_name2id(canon); cset = canon_idset(canon)
|
||||
ver, count, recs = decode_ids(b)
|
||||
changed = 0; bad = []
|
||||
for (pos, orig, bid, high) in recs:
|
||||
name = n2.get(bid)
|
||||
if name is None: bad.append(('noid', bid)); continue
|
||||
cid = c2.get(name)
|
||||
if cid is None: bad.append(('noname', name)); continue
|
||||
new = high | (cid & 0x7FFF)
|
||||
if new != orig:
|
||||
struct.pack_into('<H', b, pos, new); changed += 1
|
||||
if bad:
|
||||
print(f" !! {len(bad)} unmappable records, ABORT (e.g. {bad[:5]})"); return False
|
||||
open(outfile, 'wb').write(b)
|
||||
assert len(b) == len(src), "LENGTH CHANGED"
|
||||
diffbytes = [k for k in range(len(src)) if src[k] != b[k]]
|
||||
badpos = [k for k in diffbytes if (k - HDR) % REC not in (IDOFF, IDOFF+1)]
|
||||
ver2, count2, recs2 = decode_ids(bytes(b))
|
||||
unresolved = sum(1 for (_,o,bid,_) in recs2 if bid not in cset)
|
||||
print(f" wrote {outfile.split('/')[-1]}: count={count}(unchanged) len={len(b)}(unchanged) records_rewritten={changed}")
|
||||
print(f" byte-diff outside id field: {len(badpos)} (MUST be 0) ids unresolved in canon after remap: {unresolved} (MUST be 0)")
|
||||
return len(badpos) == 0 and unresolved == 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
cmd = sys.argv[1]
|
||||
if cmd == 'analyze': analyze(sys.argv[2], sys.argv[3], sys.argv[4])
|
||||
elif cmd == 'decodecheck': decodecheck(sys.argv[2], sys.argv[3])
|
||||
elif cmd == 'apply': sys.exit(0 if apply(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]) else 1)
|
||||
Reference in New Issue
Block a user