This is the mechanics document. If you want to change the tools, or write your own, it tells you how the pieces fit and why they are built the way they are, so you never have to re-derive anything. The byte-level format reference is FORMATS.md. Everything here was worked out and verified on the USA disc in July 2026.
The worked example throughout is the KOS-MOS hair recolor (pinkhair.py),
because it hit every layer: palettes, true-colour pixels, compression,
multiple embedded copies, and a re-framed container.
Three properties of this disc make modding unusually clean:
- Plain ISO9660. Every file the ISO filesystem knows about is one
contiguous run of sectors. Byte N of
XENOSAGA.01is always atlba(XENOSAGA.01) * 2048 + Nin the image. No fragmentation, ever. - All game data lives in bigfile chains (
chains.py): chain 0 =XENOSAGA.00 + .01 + .02, chain 1 =XENOSAGA.10 + .11 + .12 + .13. A chain is addressed as one virtual byte space; a TOC at the head of each chain (XENOSAGA.00/.10) maps game paths to sector offsets relative to the chain start. - Objects are sector-aligned with slack. An object's allocation is the gap from its start sector to the next object's start sector. As long as a replacement fits its allocation, nothing else moves.
So a mod is: overwrite the object's bytes at the computed image offset,
update the TOC entry's size fields, done. repack.py implements exactly
this and nothing more:
read_entry(iso, chain, path)— read + transparently ARX-decompress one object straight from an ISO.patch_iso(iso, {(chain, path): payload})— write payloads back in place (always run it on a copy). Compressed entries are re-ARX'd automatically; the TOC's u32 csize / u24 usize (atentry.fields_off + 3 / + 7inside the TOC file) are patched; oversize replacements are refused; every write is verified by read-back.
The TOC grammar is in toc.py's docstring (validated to the byte over all
8,922 entries). TocEntry.fields_off is the repack hook: the byte offset
of the entry's sector/size/usize fields within the TOC file.
ARX (header ARX\0) is a word-oriented dictionary coder: a 30-entry LUT
of common u32 words in the header, then a stream where control bits pick
"literal word follows" (bit 0) or a prefix code selecting a LUT entry
(bit 1 + 2/4/6/8 code bits → slots 0-1 / 2-5 / 6-13 / 14-29, i.e. 3/5/7/9
control bits total per LUT hit vs 33 for a literal). Decoder and encoder
live in arx.py, ~80 lines each.
The encoder's LUT is the 30 most frequent u32 words of the payload, most
frequent first, ties broken by first occurrence in the payload. That
tie-break was found by comparing against retail: with it, 2,094 of the
2,095 compressed objects on the disc recompress byte-identically
(chain1/mtnpack/SCE02004D.arc differs only within the LUT order of
equal-frequency words, and round-trips exactly). Monolith's 2002 packer
evidently used the same greedy scheme.
Why byte-identity matters practically: recompression of an untouched
region is a no-op, so any diff between a patched ISO and retail is your
edit and nothing else — verification becomes cmp.
Header fields: u32 size_orig, u32 size_comp (whole blob incl. header), u32 0. The TOC's csize/usize mirror these.
An .xtx is a raw GS memory image (see FORMATS.md for the exact header).
Decoding composes sub-images onto a CT32 canvas; the canvas holds both:
- PSMT8 indexed regions — 8bpp pixels (swizzled) + 256-entry CLUT
tiles stored as 16x16 CT32 tiles at canvas coordinates, CSM1 entry
order. Which mesh uses which CLUT comes from the paired
.lexmesh headers (palette byte → canvas coords formula in FORMATS.md), but NOT all materials are visible there — some hide in VIF vertex streams. - Raw CT32 true-colour regions — 32-bit RGBA pixels used directly.
KOS-MOS's long hair-strand sheets are this (canvas x256-383, y0-127 in
kosmos.xtx). Signature: decodes to noise as PSMT8, and a 16x16 tile there has ~250 distinct colours (a CLUT tile is capped at 256 for the whole tile and materials never point at it).
Recoloring therefore has two mechanisms:
- CLUT edit (hairline, face shading): rewrite palette entries. A
palette entry is 4 bytes
R G B A(PS2 alpha is 7-bit: 0x80 = opaque) stored raster inside the tile's sub-image — per-entry edits need no swizzle or CSM1 awareness at all. - Pixel edit (strand sheets): hue-rotate any pixel matching the "hair blue" predicate. Because the edit is per-pixel and position-independent, GS swizzling is irrelevant — you can transform the bytes wherever they are stored.
The predicate + hue rotation used by pinkhair.py (_is_hair_blue,
_recolor_rgb): light strands b>140 and b>r+40 and g>r, CLUT/shadow
blues b>90 and b>r+30 and b>=g; rotate hue in HLS keeping luminance,
saturation nudged ×1.1. Changing the target colour is the hue parameter
(0..1: 0 red, 0.13 gold, 0.33 green, 0.75 purple, 0.92 pink). Recoloring a
different character = redo the curation: decode their textures, list
CLUT tiles via lex_materials, identify hair vs armor tiles by eye
(render previews), find any raw-CT32 regions with the noise+distinct-count
signature.
The disc embeds copies of char/pc/kosmos*.xtx in other containers:
| carrier | form |
|---|---|
char/pc/kosmos{,1,2,_h,_h1,_h3,_h5}.xtx |
the standalone files |
yamamoto/pc/kosmos{,1}.bin (battle bundles) |
byte-identical embed (section table u32 n, u32 total, u32 off[n]; lex @0x20, XTX by magic) |
scene/cf{0210,0740,1800,3140}.a (per-scene bundles) |
re-framed: same canvas bytes but with sporadic 4-byte zero words inserted and elided (~2020-byte effective row stride vs the canvas's 2048); non-zero words stay in order |
Patching only the standalone files looks complete and isn't — the opening
Encephalon-sim tutorial (ST0210) renders KOS-MOS from cf0210.a. The
sweep in pinkhair.py handles all forms with three granularities:
- 64-byte rows / 512-byte strand segments — plain
bytes.replace, catches standalone files and byte-identical embeds. - 16-byte quarter-rows — carrier detection anchors (a file with ≥4 anchors carries a copy) and span bounding.
- 4-byte values + a zero-tolerant walker — for re-framed carriers.
CLUT entries are replaced by exact value within the anchored span
(safe: verified the hair ramp shares no exact RGBA word with any other
tile in the canvas — re-check this if you change tiles!). Pixel rows
use
patch_reframed_row: anchor a 16-byte window, then two-pointer walk both directions — match → replace old with new, cf-side zero word → skip it, canvas-side zero word → skip that, anything else → stop. Recovers ~99% of strand pixels; the remainder are rows whose anchor windows the framing split.
Completeness rule: after any change, re-sweep the whole disc at a
finer granularity than you patched with, and confirm the carrier list is
exactly what you patched. manifest.csv + arx.decompress + bytes.find
is all it takes (see the sweep scripts pattern in pinkhair.py).
Two text-object families, 914 objects, all uncompressed:
*.txt(588) — whole-file Shift-JIS (cp932): scene scripts (with dev comments), U.M.N. event dialogue. May grow up to the object's allocation (the manifest records the budget;patch_isoupdates the TOC size when it changes).*.uml(326) — U.M.N. mails:0x60-byte header | Shift-JIS text region, space-padded, ending at the first NUL | binary tail(a small record + the mail's attached JPEG with Photoshop 8BIM blocks; the u32 at header +0x20 points into those resources). Only the text region is editable and its length is fixed — imports are space-padded back to exactly the original length, header and tail preserved verbatim.
Workflow: text-export → edit the .utf8.txt tree (any editor, any OS —
BOMs are tolerated, CRLF/LF preserved exactly) → text-import validates
every file against its byte budget (Shift-JIS bytes — kana/kanji cost 2)
and writes a patched ISO only when all files pass.
Encoding safety nets, in order:
- A few mails embed raw JIS symbol bytes (the ★●○ family) that strict
cp932 rejects — exported as
⟦XX⟧hex markers. U+27E6/27E7 are not encodable in cp932, so markers can never collide with game text; leave them in place and import restores the original bytes. - Every exported file is round-trip self-checked (decode → re-encode ==
disc bytes). Anything unstable (cp932 has duplicate NEC/IBM mappings)
falls back to a verbatim
.rawcopy instead of silently corrupting. - Import reconstructs
.umlobjects around the fixed text region even for.rawexports, so headers/attachments can never be truncated.
Where the rendered dialogue actually is — two engines, two homes:
- U.M.N. conversations (the Connection Gear chats,
umn/event*.txt,<speech>/<char>/<msg>markup with\15\2-style control codes) and the.umlmails are plain text objects — the pipeline above covers them. No copy of these lines exists in any Java class (verified by string-sweeping all ~2,200 carved classes), so the.txtreally is what the U.M.N. viewer renders. - Scene/cutscene dialogue is compiled into the
.evtJava classes as constant-pool strings. The parallelscene/cf*.txtplanner sources (loader scripts, dev comments — some with dialogue-looking copies) are never read for rendering: translating them changes nothing on screen. Any dialogue translation that "works" in the text tree but not in-game has fallen into exactly this trap.
A full dialogue translation goes through evt-export/evt-import
(evttext.py, see the end of this section). The same-length byte swap
below still works and is the quickest smoke test there is:
Target: the first line the game renders — "Virtual Tutorial" in the
Encephalon-sim tutorial, scene/ST0210.evt, chain 0 (the same scene as
the §4 texture story). "Tutoriel virtuel" happens to be the same 16
characters, so the swap is structurally free:
from repack import read_entry, patch_iso
PATH = r"scene\ST0210.evt"
data = read_entry(iso, 0, PATH) # uncompressed FL00 container
patched = data.replace(b'"Virtual Tutorial"', b'"Tutoriel virtuel"')
patch_iso(iso, {(0, PATH): patched}) # run on a copy, as alwaysVerified in-game (USA disc, PCSX2). The rules that make this safe:
- Byte length must not change. The string lives in a length-prefixed constant-pool entry; everything after the pool is index-addressed, not offset-addressed, so an in-place same-length swap disturbs nothing. Shorter translations: pad with spaces inside the quotes/line rather than shortening the entry.
- The string carries its layout verbatim. The ST0210 entry is
6 leading spaces (manual centering) + the quoted title +
\n+ NUL (the length prefix includes that trailing NUL — disc-wide quirk). Keep all of it; replace only the words. - Stick to ASCII for now. The encoding the renderer expects for non-ASCII constant-pool bytes (Shift-JIS vs Java's modified UTF-8) is not yet established — accents are unverified territory.
- No sweep needed. Unlike textures (§4), each scene
.evthas exactly one TOC entry disc-wide (grep manifest.csv) — one patch is complete.
Finding a line: unpack the containers (evt_unpack.py --dump ... --out ...), then grep -r the extracted classes for the on-screen text; or
grep the dumped .evt directly — scene containers are uncompressed, so
dialogue is plain bytes.
Smoke-testing the result costs nothing: with PCSX2 fast boot, the USA
disc lands on this exact ST0210 line in about a minute with zero
input — the patched line is literally the first thing the game shows
(boot-flow files base.evt / system.evt / ELF verified bit-identical
to retail while this happens; it is the game's own cold open). Boot the
patched ISO, read the first dialog box, done.
Three things that were on the list are now in the kit, each learned in part from poketony's XS1KOR, the Korean localisation of the Japanese disc (credits in FORMATS.md):
- Dialogue of any length.
evt-exportwrites every string literal of every scene/cutscene script (all three chains, 44,605 strings in 568 containers — flags and file names included, so translate what reads as dialogue and leave the rest) as@class.index<TAB>textlines;evt-importre-serialises the classes and rebuilds the FL00 container around them. Same validation style as text-import: all or nothing, with the overage listed per container. - Mails of any length.
.umltext may now grow; the images are shifted and their pointers fixed (FORMATS.md § UML). - Objects of any size.
--growontext-import/evt-import(orpatch_iso(grow=True)) relocates an object that no longer fits its sector allocation to the end of the image and repoints its TOC entry. Without the flag the old refusal stands. Budget: about 78 MB of growth keeps the image under the DVD-9 ceiling. - Other scripts.
font.pyreads and writes the two font files exactly (FORMATS.md § Fonts) andcli.py lang scan/encodebuilds the glyphs a translation needs from a TrueType font and turns the translated tree into font codes — shaped, wrapped, right-to-left where the script is. Walkthrough: SCRIPTS.md.
Verified: byte-exact identity rebuilds (567 containers, 326 mails, both fonts); relocation read back through the TOC on a scratch copy of the disc; and in-game on PCSX2 (2026-10-14) an Arabic cold-open dialogue line and an Amharic mail body drawn from the rebuilt fonts — screenshots and the recipe in SCRIPTS.md § Worked example. Still unverified: a relocated object past the original image end actually loading in the game (the scratch build exists, it has not been booted).
PCSX2's PINE socket (flag-hunt/pine.py, game-agnostic) is the ground
truth for "what did the game actually load":
read_blockdumps all 32 MB of EE RAM in under a second.- Search the dump for old vs new byte signatures. A partially patched palette leaves an exact fingerprint (the hair fix was found because the in-RAM CLUT showed precisely the 30 row-patched entries pink and 91 blue — the signature of the cf-bundle copy, not the standalone file).
write32/64pokes prototype an edit live before you burn an ISO. GS-resident textures refresh on the next upload (scene change/dialog advance), so a poke may take a moment to show.- Expect in-RAM CLUTs to be tinted copies (the engine bakes scene lighting into palettes at load: RGB shifted, alpha intact).
A new feature has exactly four touch points; miss one and it works on your machine but not in the release zips:
- Engine module (
repack.py/textpack.py/ yours) — keep it stdlib-only and importable. cli.py— acmd_<name>function + entry in the subcommand table inmain(). Lazy-import your module inside the function (keeps CLI startup instant).gui.py— abuild_<name>(form)builder returning[*CLI_ARGV, "<subcommand>", ...], an entry inBUILDERS, and amakeCard(...)block in the boot script (field types: text with optional file/dir picker, select, checkbox). The GUI is one self-contained HTML string served by stdlibhttp.server; it shells out to the CLI, so the two can never drift. Test headless withPORT=8931 python3 gui.py --no-browser+curl -X POST 127.0.0.1:8931/preview/<name>.packaging/xenosaga1-extractor.spec— add lazily-imported modules toHIDDEN(PyInstaller's static analysis finds top-level imports on its own, but the entries are load-bearing for function-level imports).
Packaging (build.py → PyInstaller one-folder bundle) puts gui and
xeno-cli side by side; frozen gui finds the CLI as a sibling binary.
.github/workflows/release.yml builds Windows and macOS zips on tag push
(PyInstaller does not cross-compile — Linux users run from source or
python build.py locally; the launchers launch.bat / launch.command /
launch.sh cover all three OSes for source checkouts).
- Patched objects: read back from the new ISO, confirm your edit and
len(new) <= allocation(repack enforces, but look). - Untouched neighbours byte-identical (
read_entryold vs new). - Compressed entries:
arx.decompress(new blob)round-trips. - Whole-disc re-sweep at finer granularity than you patched with (§4).
- Boot it. PINE-dump RAM in the target scene and search for your bytes if anything looks wrong (§6).