Files
Sylpheed/tools/re-capture/slb_segment_phase.py
sim e909c7c133 chore: retire the last dead paths and names from the consolidation
Nothing here changes what a tool computes; it changes where tools look.

- tools/re-capture: 33 censuses globbed /work/sylph_extract, a path that has
  existed nowhere since /work became a clone, so they matched nothing and
  printed empty results. They now resolve the disc through a new disc.py
  from $SYLPHEED_DISC and exit loudly without it (the #44 fix, generalised).
  Nine scripts that imported siblings from the retired Reborn checkout or an
  old session scratchpad now import from their own directory. unitgroup.py
  only needs the variable when --pak is not given.
- sylpheed-xex: the loader only ever uses the XEX2 retail key. The dead
  devkit key and a doc comment claiming a devkit fallback that does not
  exist are gone; Project Sylpheed is a retail XEX2, so no XEX1 key either.
- sylpheed-viewer: real_font_rasterizes looked for /tmp/sylph_extract and so
  always skipped. It reads $SYLPHEED_DISC now, and passes against the disc.
- Comments and docs that named xenia-rs, the Reborn repository or /work/*.pe
  as places to look now name sylpheed.db, Canary's ppc_context.h and the
  flat .pe; docs/re/README.md no longer says the native Canary build does not
  run.

Historical records keep their original paths: findings that were measured
against /work/xenia-rs/sylpheed.db still say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:30:28 +02:00

298 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""Why a `.slb` bank's XMA data starts at 1392 / 1468 / 1600 / 1728.
Static, disc-only. No emulator, no decoder, no run-generated input: everything
here is read straight out of `dat/sound.pak` + `dat/sound.p00..p04`.
The answer (see docs/re/structures/slb-data-offset.md): the XMA packet grid is
2048-byte aligned *inside each individual `.pNN` segment file*, but the `.pak`
TOC addresses entries in the flat CONCATENATION of those files, at offsets that
are themselves multiples of 2048. The segment files are not multiples of 2048
long, so the grid phase seen inside an entry is
X = (cumulative start of the .pNN segment holding the wave) mod 2048
and the four disc-wide values are just the running sums of the segment sizes.
Subcommands
phases the segment table and where 1392/1468/1600/1728 come from
verify predicted vs measured X over every bank on the disc
bank <name> full structural dump of one bank entry
chain <name> [n] the tiling arithmetic across n consecutive entries
Usage
SYLPHEED_DISC=/path/to/extracted/disc python3 slb_segment_phase.py verify
"""
import bisect
import os
import re
import struct
import sys
import zlib
BLOCK = 2048 # XMA1 packet size and the archive's data-offset alignment unit
WAVE_HDR = 4096 # RIFF + 32-byte `fmt ` + `Dmmy` pad, always exactly two blocks
# --- IPFB name hash (crate `sylpheed_formats::hash`, from `sub_82455C78`) -----
MODULUS = 0x00FF_F9D7
RECIP = 0x8003_1493
def _rotl32(x, n):
x &= 0xFFFFFFFF
return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF
def name_hash(name):
a = b = 0
for byte in name.lower().encode("latin-1"):
c = (byte - 256) & 0xFFFFFFFF if byte >= 0x80 else byte
a = ((_rotl32(a, 8) & 0xFFFFFF00) + c) & 0xFFFFFFFF
b = (b + c) & 0xFFFFFFFF
q = _rotl32(((a * RECIP) >> 32) & 0xFFFFFFFF, 9) & 0x1FF
a = (a - q * MODULUS) & 0xFFFFFFFF
return ((b & 0xFF) << 24) | (a & 0xFFFFFF)
class Pak:
"""Minimal IPFB reader: TOC plus raw access to the concatenated segments."""
def __init__(self, path):
data = open(path, "rb").read()
assert data[:4] == b"IPFB", data[:4]
count, self.block, self.flags = struct.unpack_from(">III", data, 4)
self.toc = [struct.unpack_from(">III", data, 0x10 + 12 * i) for i in range(count)]
base, pos, self.segs = path[:-4], 0, []
i = 0
while os.path.exists("%s.p%02d" % (base, i)):
seg = "%s.p%02d" % (base, i)
n = os.path.getsize(seg)
self.segs.append((pos, pos + n, seg))
pos, i = pos + n, i + 1
self.seg_starts = [s for s, _, _ in self.segs]
def raw(self, off, size):
"""Bytes [off, off+size) of the flat concatenation of the .pNN files."""
out = b""
for s, e, path in self.segs:
if off < e and off + size > s:
lo, hi = max(off, s), min(off + size, e)
with open(path, "rb") as f:
f.seek(lo - s)
out += f.read(hi - lo)
return out
def read(self, idx):
_, off, csz = self.toc[idx]
raw = self.raw(off, csz)
return zlib.decompress(raw[10:]) if raw[:2] == b"Z1" else raw
def find(self, name):
h = name_hash(name)
for i, (hh, _, _) in enumerate(self.toc):
if hh == h:
return i
return None
def phase_at(self, concat_off):
"""The 2048-grid phase in force at a flat-concatenation offset."""
k = bisect.bisect_right(self.seg_starts, concat_off) - 1
return self.seg_starts[k] % BLOCK
def entry_names(disc):
"""Every `.slb` path named in any `<lang>\\sounds.tbl` inside tables.pak."""
tbl = Pak(os.path.join(disc, "dat", "tables.pak"))
names = set()
for lang in ("eng", "jpn", "deu", "fra", "esp", "ita"):
i = tbl.find(lang + "\\sounds.tbl")
if i is None:
continue
blob = tbl.read(i)
for run in re.finditer(rb"[\x20-\x7e]{6,}", blob):
for m in re.finditer(r"[A-Za-z0-9_\\.]+\.slb", run.group().decode("latin-1")):
names.add(m.group())
return names
# --- structure walkers --------------------------------------------------------
def bank_headers(data, phase):
"""Bank headers inside an entry. They sit on the grid, so only `phase` is scanned."""
out = []
for off in range(phase, max(0, len(data) - 56), BLOCK):
if data[off + 0x18 : off + 0x1C] != b"\x00\x00\x08\x00":
continue
if data[off : off + 4] != data[off + 0x20 : off + 0x24]:
continue
f = struct.unpack(">14I", data[off : off + 56])
out.append(
dict(at=off, id=f[0], data_size=f[7], hdr_blocks=f[9],
bits=f[10] >> 16, channels=f[10] & 0xFFFF)
)
return out
def waves(data):
out = []
for m in re.finditer(b"RIFF", data):
r = m.start()
if data[r + 8 : r + 12] != b"WAVE":
continue
dp = data.find(b"data", r)
if dp < 0:
continue
out.append(dict(at=r, data_at=dp + 8,
data_size=struct.unpack_from("<I", data, dp + 4)[0]))
return out
def seeks(data):
"""`seek` (XMA dpds) chunks: one u32 LE per emitted packet, so the entry count
is the wave's packet count and the chunk sits immediately after its data."""
out = []
for m in re.finditer(b"seek", data):
s = m.start()
if s + 16 > len(data):
continue
size, streams, n = struct.unpack_from("<III", data, s + 4)
if streams == 1 and size == 8 + 4 * n and n:
out.append(dict(at=s, packets=n, data_start=s - n * BLOCK))
return out
# --- subcommands --------------------------------------------------------------
def cmd_phases(pak):
print("segment size size%2048 cum_start PHASE (= cum_start%2048)")
running = 0
for s, e, path in pak.segs:
n = e - s
print(" %-10s %11d %8d %12d %d" % (os.path.basename(path), n, n % BLOCK, s, s % BLOCK))
running = s % BLOCK
print()
print("The four disc-wide values are the running sums of `size % 2048`:")
acc = 0
for s, e, path in pak.segs:
print(" %-10s phase %4d" % (os.path.basename(path), s % BLOCK))
acc = (acc + (e - s)) % BLOCK
_ = running
def cmd_verify(pak, disc):
names = entry_names(disc)
ok = bad = 0
seek_ok = seek_bad = 0
misses = []
for name in sorted(names):
i = pak.find(name)
if i is None:
continue
_, off, _ = pak.toc[i]
data = pak.read(i)
w = waves(data)
if w:
pred = pak.phase_at(off + w[0]["at"])
meas = w[0]["at"] % BLOCK
if pred == meas:
ok += 1
else:
bad += 1
if len(misses) < 10:
misses.append((name, pred, meas))
else:
# RIFF-less entry: the prediction is still exact, cross-check on `seek`.
sk = seeks(data)
if sk:
pred = pak.phase_at(off + sk[0]["at"])
if sk[0]["at"] % BLOCK == pred:
seek_ok += 1
else:
seek_bad += 1
print("banks with a RIFF : predicted == measured %d, mismatches %d" % (ok, bad))
print("RIFF-less, via seek : agree %d, disagree %d" % (seek_ok, seek_bad))
for m in misses:
print(" MISMATCH", m)
return 1 if (bad or seek_bad) else 0
def cmd_bank(pak, name):
i = pak.find(name)
if i is None:
sys.exit("no such entry: " + name)
_, off, csz = pak.toc[i]
data = pak.read(i)
phase = pak.phase_at(off)
print("%s\n pak offset %d (%%2048=%d) stored %d bytes phase %d"
% (name, off, off % BLOCK, csz, phase))
events = []
for b in bank_headers(data, phase):
events.append((b["at"], "BANK id=%d data_size=%d hdr=%d blocks (%d B) %dbit/%dch -> wave at %d"
% (b["id"], b["data_size"], b["hdr_blocks"], b["hdr_blocks"] * BLOCK,
b["bits"], b["channels"], b["at"] + b["hdr_blocks"] * BLOCK)))
for w in waves(data):
over = w["data_at"] + w["data_size"] - len(data)
events.append((w["at"], "RIFF data@%d size=%d (%d packets)%s"
% (w["data_at"], w["data_size"], w["data_size"] // BLOCK,
" OVERRUNS window by %d" % over if over > 0 else "")))
for s in seeks(data):
events.append((s["at"], "seek %d packets -> its data began at %d%s"
% (s["packets"], s["data_start"],
" (BEFORE this window)" if s["data_start"] < 0 else "")))
for at, text in sorted(events):
print(" %8d %%2048=%-5d %s" % (at, at % BLOCK, text))
def cmd_chain(pak, name, count):
"""Show that entry K's overrunning wave lands exactly on entry K+1's leading `seek`."""
i = pak.find(name)
if i is None:
sys.exit("no such entry: " + name)
order = sorted(range(len(pak.toc)), key=lambda k: pak.toc[k][1])
start = order.index(i)
prev = None
for k in order[start : start + count]:
_, off, csz = pak.toc[k]
data = pak.read(k)
padded = (len(data) + BLOCK - 1) // BLOCK * BLOCK
sk = seeks(data)
lead = sk[0] if sk and sk[0]["data_start"] < 0 else None
w = waves(data)
tail = w[-1] if w and w[-1]["data_at"] + w[-1]["data_size"] > len(data) else None
line = "off=%-11d len=%-7d padded=%-7d" % (off, len(data), padded)
if lead:
line += " leading seek@%-6d (%d packets)" % (lead["at"], lead["packets"])
if tail:
line += " tail wave ends at %d" % (tail["data_at"] + tail["data_size"])
print(line)
if prev and lead:
predicted = prev[0] - prev[1] # tail end minus previous padded length
mark = "OK " if predicted == lead["at"] else "!! "
print(" %s predecessor's wave ends %d past its padded window; leading seek is at %d"
% (mark, predicted, lead["at"]))
prev = ((tail["data_at"] + tail["data_size"]) if tail else None, padded)
if prev[0] is None:
prev = None
def main():
disc = os.environ.get("SYLPHEED_DISC")
if not disc:
sys.exit("set SYLPHEED_DISC to the extracted disc root")
pak = Pak(os.path.join(disc, "dat", "sound.pak"))
argv = sys.argv[1:] or ["phases"]
cmd = argv[0]
if cmd == "phases":
cmd_phases(pak)
elif cmd == "verify":
sys.exit(cmd_verify(pak, disc))
elif cmd == "bank":
cmd_bank(pak, argv[1])
elif cmd == "chain":
cmd_chain(pak, argv[1], int(argv[2]) if len(argv) > 2 else 4)
else:
sys.exit(__doc__)
if __name__ == "__main__":
main()