Skip to content

Commit 5c457fc

Browse files
LinuxJessiclaude
andcommitted
Depack/repack kit: patch any file on either disc, in place
repack.py — the general write-back layer complementing the extraction pipeline (depack) and chrtex.py (texture-aware front end). Resolves any in-game path through all three Lba tables and the ISO9660 root to an absolute ISO offset; works on both discs, auto-detecting which tables apply (Lba0 shared, Lba1 Disc 1, Lba2 Disc 2). - repack-info / repack-extract / repack-patch: single-file resolution, depack, and verified in-place patch. Same-size by default; --pad permits resizes up to the slot's sector allocation (zero-filled), since the engine catalogs store implicit sector-packed offsets — files cannot move without a container rebuild (documented limit; X3.00-catalog notes: front-coded name trie, literal LE32 sizes). - repack-tree: patch an entire mod directory mirroring the game tree, with --dry-run preview. GUI card 14. - docs/REPACK.md: the full depack -> edit -> repack loop, disc model, and the size rules. Tested against both retail discs: multi-region resolution (Lba0/1/2), extract==dump parity, tree patch round-trip, slack padding, and over-allocation rejection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VD7n8nxxQ2uznHX4FJJtHb
1 parent e78a0fe commit 5c457fc

6 files changed

Lines changed: 442 additions & 0 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,18 @@ Disc 2 is the same flow with a fresh `--work` directory, using `Lba0.txt` +
324324
| `code-extract` | Run 7-Zip over the ISO to pull the non-X3 files (SLUS, OVL, IRX, SYSTEM.CNF) into a directory. |
325325
| `disasm` | MIPS R5900 disassembly + `lui`/`addiu` string xrefs for SLUS / OVL ELFs. Needs `capstone`. |
326326

327+
### Depack / repack (any file, both discs)
328+
329+
| Command | What it does |
330+
|------------------|--------------|
331+
| `repack-info` | Resolve an in-game path: table, container, absolute ISO offset, sector-allocation slack. |
332+
| `repack-extract` | Pull any one file out of an ISO via the Lba tables (all three tables, both discs). |
333+
| `repack-patch` | Write a file back into an ISO in place, read-back verified. Same-size by default; `--pad` allows resizes up to the slot's sector allocation. |
334+
| `repack-tree` | Patch an entire mod directory that mirrors the game tree (`mymod/mdl/chr/...``\mdl\chr\...`). `--dry-run` previews. GUI card 14. |
335+
336+
Full loop and the disc model's rules: [docs/REPACK.md](docs/REPACK.md).
337+
Underlying module works standalone too: [`repack.py`](repack.py).
338+
327339
### Character-texture modding
328340

329341
| Command | What it does |

cli.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
)
3535
from lba import discover_lba_files
3636
from regions import auto_assign_regions, parse_manual_assignments, validate_regions, RegionAssignmentError
37+
import repack
3738
import resolver
3839
import scooper
3940
import toc as toc_mod
@@ -329,6 +330,26 @@ def cmd_chr_iso_sweep(args: argparse.Namespace) -> None:
329330
chrtex.cmd_iso_sweep(args.iso, args.match, args.mode, args.hue, args.lba)
330331

331332

333+
# ---------------------------------------------------------------------------
334+
# whole-file depack/repack (repack.py; see docs/REPACK.md)
335+
# ---------------------------------------------------------------------------
336+
337+
def cmd_repack_info(args: argparse.Namespace) -> None:
338+
repack.cmd_info(args.iso, args.path, args.lba)
339+
340+
341+
def cmd_repack_extract(args: argparse.Namespace) -> None:
342+
repack.cmd_extract(args.iso, args.path, args.out, args.lba)
343+
344+
345+
def cmd_repack_patch(args: argparse.Namespace) -> None:
346+
repack.cmd_patch(args.iso, args.path, args.file, args.pad, args.lba)
347+
348+
349+
def cmd_repack_tree(args: argparse.Namespace) -> None:
350+
repack.cmd_tree(args.iso, args.mod, args.pad, args.dry_run, args.lba)
351+
352+
332353
# ---------------------------------------------------------------------------
333354
# Argument parser
334355
# ---------------------------------------------------------------------------
@@ -482,6 +503,38 @@ def _build_parser() -> argparse.ArgumentParser:
482503
xs.add_argument("--lba", help="dir with Lba0.txt (default: the kit's lba/)")
483504
xs.set_defaults(func=cmd_chr_iso_sweep)
484505

506+
ri = sp.add_parser("repack-info", help="Where a disc file lives in the ISO + its slack")
507+
ri.add_argument("--iso", required=True)
508+
ri.add_argument("--path", required=True, help=r"in-game path, e.g. \snd\adx\bgm\...")
509+
ri.add_argument("--lba", help="dir with Lba*.txt (default: the kit's lba/)")
510+
ri.set_defaults(func=cmd_repack_info)
511+
512+
re_ = sp.add_parser("repack-extract", help="Pull any one file out of an ISO")
513+
re_.add_argument("--iso", required=True)
514+
re_.add_argument("--path", required=True)
515+
re_.add_argument("--out", required=True)
516+
re_.add_argument("--lba", help="dir with Lba*.txt (default: the kit's lba/)")
517+
re_.set_defaults(func=cmd_repack_extract)
518+
519+
rp = sp.add_parser("repack-patch", help="Write any one file back into an ISO (verified)")
520+
rp.add_argument("--iso", required=True, help="work on a COPY of your ISO")
521+
rp.add_argument("--path", required=True)
522+
rp.add_argument("--file", required=True)
523+
rp.add_argument("--pad", action="store_true",
524+
help="allow a different size up to the sector allocation (zero-padded)")
525+
rp.add_argument("--lba", help="dir with Lba*.txt (default: the kit's lba/)")
526+
rp.set_defaults(func=cmd_repack_patch)
527+
528+
rt = sp.add_parser("repack-tree",
529+
help="Patch every file of a mirror tree into an ISO (verified)")
530+
rt.add_argument("--iso", required=True, help="work on a COPY of your ISO")
531+
rt.add_argument("--mod", required=True,
532+
help=r"mod dir mirroring the game tree (mod/mdl/chr/... = \mdl\chr\...)")
533+
rt.add_argument("--pad", action="store_true")
534+
rt.add_argument("--dry-run", action="store_true")
535+
rt.add_argument("--lba", help="dir with Lba*.txt (default: the kit's lba/)")
536+
rt.set_defaults(func=cmd_repack_tree)
537+
485538
da = sp.add_parser(
486539
"disasm",
487540
help="Disassemble PS2 ELFs (SLUS / OVL) and emit string xrefs. Needs `pip install capstone`.",

docs/MODDING-CHARACTERS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,11 @@ plain `00`, so for a quick test that one file is enough; cutscenes use the
155155
Both discs: run the same commands against a Disc 2 copy — identical
156156
offsets, identical bytes.
157157

158+
For batches (or non-texture files), the general layer is
159+
[`repack.py`](../repack.py): keep your edited files in a directory that
160+
mirrors the game tree and `repack-tree` patches them all at once — see
161+
[REPACK.md](REPACK.md).
162+
158163
The patched ISO also works on real hardware (burn / USB-load) — it's a
159164
plain same-size byte edit, no filesystem changes.
160165

docs/REPACK.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Depack / repack: the full mod loop for Xenosaga III
2+
3+
The kit's extraction pipeline (prep → scan → extract) is the **depack**
4+
half: it mirrors every file on the disc into a `dump/` tree.
5+
[`repack.py`](../repack.py) is the **repack** half: it writes files back
6+
into an ISO, in place, for *any* file type on either disc — models, audio,
7+
movies, event packages, textures, tables.
8+
9+
The character-texture tools ([MODDING-CHARACTERS.md](MODDING-CHARACTERS.md))
10+
are a format-aware front end; this layer works on whole files and is
11+
format-agnostic.
12+
13+
## The loop
14+
15+
```sh
16+
# 0. depack once (or reuse your existing dump/) — see the README pipeline
17+
# 1. start a mod tree containing ONLY the files you change,
18+
# mirroring the in-game paths:
19+
mkdir -p mymod/mdl/chr/pc
20+
cp dump/mdl/chr/pc/C3shion00.chr mymod/mdl/chr/pc/
21+
# ... edit mymod/mdl/chr/pc/C3shion00.chr with whatever tool ...
22+
23+
# 2. clone the ISO (instant on APFS) and repack the tree into it
24+
cp -c "Xenosaga ... (Disc 1).iso" MOD.iso
25+
python3 cli.py repack-tree --iso MOD.iso --mod mymod --dry-run # preview
26+
python3 cli.py repack-tree --iso MOD.iso --mod mymod # do it
27+
```
28+
29+
Every write is read back and verified. The GUI exposes this as card 14;
30+
single files go through `repack-extract` / `repack-patch`, and
31+
`repack-info` shows where any path lives:
32+
33+
```
34+
$ python3 cli.py repack-info --iso MOD.iso --path '\mdl\chr\pc\C3kosmos00.chr'
35+
\mdl\chr\pc\C3kosmos00.chr
36+
table Lba0.txt offset 0x0CCF6000 size 432384 (0x69900)
37+
lives in X3.01 -> ISO byte 0xD326800
38+
sector allocation 434176 bytes (1792 slack)
39+
```
40+
41+
## Disc model (why this works, and its one hard limit)
42+
43+
Files hide inside the `X3.*` containers, indexed by three byte-addressed
44+
tables: `Lba0` (shared system/model/audio data — **byte-identical on both
45+
discs**, so one mod tree patches Disc 1 and Disc 2 copies with identical
46+
commands), `Lba1` (Disc 1 story content, X3.11–13), `Lba2` (Disc 2 story
47+
content, X3.21–23). `repack.py` reads the ISO's own root directory for the
48+
container extents, so it works on any dump of either disc; it auto-detects
49+
which tables apply and refuses paths from the wrong disc.
50+
51+
The engine's on-disc catalogs (`X3.00` / `X3.10` / `X3.20`) store the file
52+
tree with **literal sizes but implicit offsets** — files pack back-to-back
53+
at 2048-byte sector granularity, each starting on the sector after its
54+
predecessor's last. Consequences:
55+
56+
* **Same-size replacement** — always safe. This is the default; anything
57+
else is rejected.
58+
* **Different size within the sector allocation** (`--pad`): the file is
59+
zero-padded to its original allocation so nothing moves. The engine
60+
still *reads* the original byte count, so this is only correct for
61+
formats that carry their own internal sizes and ignore trailing bytes
62+
(Xc/`.chr`/`.sme` packages, `txy`, ADX). Opt-in for that reason.
63+
`repack-info`'s "slack" line tells you the headroom (0–2047 bytes,
64+
whatever the original left in its final sector).
65+
* **Anything bigger** means every later file in that container shifts and
66+
the binary catalog's size chain must be rewritten — a full container
67+
rebuild. Nothing supports that yet; it is the known limit. (The catalog
68+
format is a front-coded name trie with LE32 sizes — decoded enough to
69+
know the layout, not enough to regenerate. Future work.)
70+
71+
## Practical notes
72+
73+
* Always patch a **copy** (`cp -c` on macOS is a free clone). Keep your
74+
originals pristine.
75+
* Patched ISOs boot in PCSX2 and on real hardware — there are no
76+
checksums or anti-tamper anywhere in the read path.
77+
* Testing in PCSX2: load from a memory-card save, not a savestate —
78+
savestates restore the *old* data from saved RAM/VRAM until the game
79+
re-streams it (any map change).
80+
* Duplicate data: unlike Xenosaga I (which buries byte-copies of textures
81+
inside battle/scene bundles), XS3 keeps one file per asset. What *does*
82+
repeat is per-variant data — each costume/cutscene model is its own
83+
`.chr` with its own palettes — so "change X everywhere" means patching
84+
every variant file, which is what mirror trees and `chr-iso-sweep` are
85+
for.
86+
* `--lba` points the resolver at a different directory of `Lba*.txt`
87+
tables if you're not using the kit's bundled ones.

gui.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,19 @@ def build_chr_iso_sweep(form):
324324
return args
325325

326326

327+
def build_repack_tree(form):
328+
iso = _str(form, "iso")
329+
mod = _str(form, "mod")
330+
if not iso or not mod:
331+
raise ValueError("ISO copy and mod dir are both required")
332+
args = [*CLI_ARGV, "repack-tree", "--iso", iso, "--mod", mod]
333+
if _bool(form, "pad"):
334+
args.append("--pad")
335+
if _bool(form, "dry_run"):
336+
args.append("--dry-run")
337+
return args
338+
339+
327340
BUILDERS = {
328341
"doctor": build_doctor,
329342
"prep": build_prep,
@@ -339,6 +352,7 @@ def build_chr_iso_sweep(form):
339352
"chr-palettes": build_chr_palettes,
340353
"chr-import-palettes": build_chr_import_palettes,
341354
"chr-iso-sweep": build_chr_iso_sweep,
355+
"repack-tree": build_repack_tree,
342356
}
343357

344358

@@ -1461,6 +1475,18 @@ def list_dir(path_str: str, ext_filter: str | None = None) -> dict:
14611475
{name: 'hue', label: 'Hue', value: '0.92'},
14621476
], 'Recolor ISO'));
14631477
1478+
cards.appendChild(makeCard(14, 'repack-tree', 'Repack — patch a mod tree into an ISO',
1479+
'<b>Work on a copy of your ISO.</b> Takes a folder that mirrors the game tree (<code>mymod/mdl/chr/pc/C3shion00.chr</code> patches <code>\\mdl\\chr\\pc\\C3shion00.chr</code>) and writes every file in it back into the ISO, read-back verified. Same-size replacements always work; enable padding to allow smaller/slightly larger files up to each slot’s sector allocation. Any file type — models, audio, movies, events. See <code>docs/REPACK.md</code>.',
1480+
[
1481+
{name: 'iso', label: 'ISO copy to patch', placeholder: 'MOD.iso — never your original',
1482+
pick: {mode: 'file', filter: 'iso'}},
1483+
{name: 'mod', label: 'Mod dir (mirror tree)', value: defaultWork + '/mods', pick: {mode: 'dir'}},
1484+
{name: 'pad', type: 'checkbox', label: 'Allow padded resize',
1485+
hint: 'permit different sizes up to the sector allocation (zero-padded)'},
1486+
{name: 'dry_run', type: 'checkbox', label: 'Dry run', value: true,
1487+
hint: 'list what would be patched without writing'},
1488+
], 'Repack'));
1489+
14641490
document.querySelector('.card').classList.add('open');
14651491
})();
14661492

0 commit comments

Comments
 (0)