Newest first: what changed, why, and what it means for you.
Two things the first captures showed. The re-encoded movie was blocky:
the encoder ran with a rate buffer 0.3 × the bitrate, which starved
scene changes, and at 85 % of the slot. It now runs at 95 % of the
video slots' capacity with a Main Level buffer, rate-distortion
macroblock decisions and trellis quantisation (33.9 dB luma above the
band against retail, from 31.1). And the blurred band under the cue
left a soft smear where the English had been; subs-burn now covers
the band with a solid black bar to the bottom of the frame (--mask 0,330,512,118, --band black), which hides the English outright and
costs the encoder nothing, and opens a quarter second early and closes
a quarter second late (--band-pad) so the English fade never peeks
out. --band blur keeps the old look. The Hindi
tree on the LANGTEST disc now holds all fourteen lines of the tutorial
scene, so "Battle program has been set." and its neighbours are Hindi
too. Captures of both are in SCRIPTS.md.
Two gaps the script pipeline still had, both closed and both seen running on PCSX2 from the LANGTEST disc; the mock-up pictures in SCRIPTS.md are replaced by captures.
The executables' strings. Tech names, End, Menu, item and skill
descriptions and the field menu's labels are C literals in the five
code overlays (OV01.OVL…) and the boot ELF. ovl-export writes them
as a third text tree (9,335 referenced strings on the USA disc, found
through pointer words and lui/addiu pairs with the overlays' own
symbol tables), ovl-import writes them back: in place when they fit,
otherwise appended to the overlay's load segment with every reference
repointed, the overlay going to the end of the image if its slot is
full (--grow). The memory past the largest overlay is unused on this
disc, which is what makes the append safe; the boot ELF has a bss after
its data and stays in-place only. Georgian (Mkhedruli) tech names,
seven of them relocated past the overlay's old end, are drawn by the
battle bars. The approach follows poketony's XS1KOR, which moved
overlay strings for the Korean disc.
Movies in any script without libass. subs-burn --font renders each
SRT cue itself — HarfBuzz shaping through the same code as the font
step, white with a black outline — and composites it with ffmpeg's
plain overlay filter, covering the band that holds the burned-in
English with a black bar while a cue is up (--band blur for the
earlier blur); --cues DIR takes hand-drawn PNGs instead.
subs-template --detect times a skeleton from the original subtitle
(white pixels with a dark outline next to them, sampled four times a
second). The first Woglinde movie with ten Hindi cues plays in the game.
This too is XS1KOR's plan (fixed rows, outline/shadow/dark spread,
size-guarded re-encode over the untouched audio), re-done with filters
every ffmpeg build has.
The battle screen's own font. The command bars and HUD labels are
not drawn with font0/font1 at all: OV01 keeps a 25-column grid of 10×18
cells in the status sheet yamamoto\stat\stat.xtx and a 230-byte
lookup table (digits, A–Z, a–z, four punctuation marks). Two-byte codes
draw nothing there, which the first Georgian attempt showed as empty
bars. statfont scan paints a translation's letters into the grid's
110 free cells and gives each a byte the table lacks; statfont encode
writes the battle strings with those bytes and the rewritten table (an
overlay string like the others) into the tree ovl-import takes. KOS-
MOS's tech bars read in Mkhedruli on PCSX2.
Movie timing, the hard way. Two more things the first boot taught:
the console's movie player needs a PTS/DTS on nearly every picture (the
retail muxer stamps 1,455 of 1,495), and it reads the container by the
sector, so packs must stay 16,384 bytes with 4,096-byte packets, with
video delivered no more than about 0.3 s ahead of its decode time.
subs.splice is now a small muxer that reproduces that shape and
pacing from the original's own stamps; the earlier "omit the stamps"
version hung the game on a black screen. The encoder also runs at the
container's full rate with a proper VBV and trellis (33.9 dB above the
band against retail, up from 31.1 on the first build, which was
visibly blocky).
Hindi on screen. The cold open's title, KOS-MOS's program menu,
Allen's question and the Yes/No box, drawn by the game from Kohinoor
Devanagari cells with conjuncts intact. One lesson: the dialogue box
wraps a little under 400 px, so --width 370 for the kit's own breaks.
New: ovl-export, ovl-import [--grow] [--retail], statfont extract|scan|encode, subs-template --detect,
subs-burn --font/--face/--px/--script/--language/--rtl/--cues/--mask/--bottom/--keep-cues;
GUI cards for the executable strings and the battle font;
repack.patch_fs_file / relocate_fs_file; FORMATS.md § Code overlays
and § The battle screen's font.
Everything in the October notes below ships as v1.2.0: models and armatures in Blender, the texture fixes, dialogue of any length, the third chain, the fonts and the new-script pipeline. This entry records what changed in the script pipeline after the first in-game run.
Seen running, not just previewed. A LANGTEST disc built from the
pink-hair example carries an Arabic dialogue line in the Encephalon
cold open and an Amharic U.M.N. mail. PCSX2 draws both from the rebuilt
font0.tex: the Arabic joined and right-to-left in the dialogue box,
the Amharic body wrapped at the mail viewer's 400 px, proportional,
with its Latin run and Ethiopic punctuation in place. The pictures are
docs/images/ingame-*.png; the full command sequence is
SCRIPTS.md § Worked example. The earlier preview-*.png
mock-ups stay in the guide labelled as such.
Two scripts in one font. lang scan --extend glyphset.json adds a
second script (another TrueType font, another HarfBuzz tag) to the
slots an earlier scan allocated, so Arabic and Ethiopic share one pair
of fonts on the LANGTEST disc: 81 proportional slots used, 80 free.
Encoder fixes from the first real texts.
- A token that mixes scripts (an Amharic word glued to a Latin model name, a bracket on an Arabic word) is split into runs by class and each run encoded by its own rule; before, the whole token went to the target script and lost its Latin half.
- Raw C0 controls and
⟦XX⟧escapes pass through untouched, and the leading control sequence of a line is hoisted ahead of any right-to-left reordering so mail bodies keep their mode byte. - The disc's own Japanese-range symbols (■ bullets, box drawing, fullwidth forms) are left as they are instead of being re-shaped.
- Leading and trailing spaces survive; lines wrap only at spaces, never inside a word; a paragraph with no right-to-left run stays left-to-right.
Still to verify. Relocation past the image end (--grow onto new
sectors) has been read back through the TOC on a scratch copy but not
booted; both LANGTEST objects fit their slots. The mail list's subject
and sender in a new script have not been seen either: a loaded save
keeps the inbox list in RAM and only re-reads the body.
Driving PCSX2 from a script, for anyone repeating this. Pad presses posted to the emulator by process id need a hold of about 300 ms; shorter taps are dropped at the title screen even though hotkeys land. The field menu will not open while the character stands on a ladder.
The three walls a translation project hit are gone, and credit for the first look over each of them goes to poketony's XS1KOR, the Korean localisation of the Japanese disc, whose tools showed the shape of the answer; the implementations here are the kit's own, against the USA disc.
Dialogue of any length. evt-export / evt-import (evttext.py)
write every string literal in every scene and cutscene script as a text
line and rebuild the Java classes and their FL00 container around the
translations. An identity rebuild of all 567 containers is byte-exact.
Mails (.uml) may grow too: the attached images move and their pointers
follow. And when an object no longer fits its sector allocation,
--grow relocates it to the end of the image and repoints its TOC entry
— the engine reads by raw sector, so the place does not matter. Tested
on a scratch copy of the disc: relocated, read back through the TOC,
neighbours untouched, relocated again, shrunk back in place.
The third chain. The disc's second layer carries its own ISO9660
volume with XENOSAGA.20–.24: 266 objects the kit had been reading
only as raw movie sectors, including 89 cutscene scripts and 44 voice
streams. list, extract, classes, text and dialogue export all see
chain 2 now; the raw layer-1 carve is skipped when the chain is there.
Fonts, exactly. font.py decodes font0.tex/font1.tex to four
sheets of 20x24 cells and back byte-for-byte, and knows how the
executable addresses them: JIS order with two folds, a 256-cell
proportional block for Latin text with a permuted metrics table, and
right - left + 3 pixels per glyph. The metrics match the inked columns
of every ASCII glyph, which is how the block was confirmed. The game's
text turns out to be EUC-JP, not Shift-JIS; textpack now decodes it as
such (byte-identical for ASCII, correct symbols for the mails' bullets).
New scripts. lang scan shapes a translated tree with HarfBuzz,
gives each glyph the translation uses a proportional slot (161 free, or
213 if the translation needs no Latin letters) or a kanji cell, and
draws it from a TrueType font into both faces. lang encode shapes,
measures, wraps, orders right-to-left lines with embedded Latin runs
kept forward and brackets mirrored, and writes the codes. Previews of
Arabic, Hebrew and Devanagari lines render correctly from the rebuilt
cells, and the screen agrees: on PCSX2 the Encephalon cold open reads
"درس افتراضي" joined and right-to-left, and a U.M.N. mail body in
Amharic wraps and mixes with Latin, both drawn by the game from the
rebuilt fonts (SCRIPTS.md § Worked example has the pictures and the
disc recipe).
New CLI: evt-export, evt-import [--grow], text-import --grow,
font-extract, font-rebuild, lang scan|encode|preview; GUI cards for
the dialogue and script steps. Optional packages for the TrueType path:
pip install fonttools uharfbuzz.
Models now import into Blender with a real armature instead of a flat
list of joints. The .jnt file is a tree of 64-byte nodes: a type
(offset, rotation joint, translate, bone end, name), a parent index, a
local position and three rotation angles. The skin's weight groups are
the rotation joints in order, which checks out on every model tried,
and the face file's own small skeleton comes along for eyes and mouth.
Every bone lands on the mesh, hands and hair included, from the files
alone. The one trap: the three angles are rotations about Z, Y and X in
that order, not X, Y, Z. Read the usual way they give a convincing
T-pose with the hair raised while legs and spine still line up, and
for a while that looked like a missing bind pose. It was settled by
running the game: PCSX2 with PINE enabled, a save state with KOS-MOS on
screen, and a RAM dump that contains the engine's own per-bone matrix
table. Brute-forcing the rotation reading against that table gave an
exact match for Z, Y, X and nothing close for any other reading. The
capture tool (docs/research/pine_pose.py) stays in the kit as the way
that was verified; nobody needs to run it. Byte layout and the evidence:
FORMATS.md § JNT skeletons.
Eyelashes: solved. Every character's lash mesh (UV type 0x7F,
flagged true-colour) draws a small true-colour strip of the atlas that
the decoder had been rendering through a palette: a dark navy gradient
fading to transparent, which the lash polygons stretch into strokes.
The decoder now draws those rects as raw true colour unconditionally
(the coherence guard that protects hair sheets would reject a mostly
transparent strip), and KOS-MOS has her lashes back
(docs/images/kosmos-face.png). The long detour through "4-bit sheets"
was wrong: the regions on her atlas that split into two clean 4-bit
images are 8-bit-swizzled data with two images packed per byte by the
artists; read with PCSX2's verified GS 4-bit layout they are noise, so
the game never treats them as 4-bit textures.
Models. browse has a new kind, Models: every .lex on the disc
becomes a .glb under browse/models/ with its texture inside, ready
for Blender (File ▸ Import ▸ glTF 2.0). You get the mesh, normals, UVs,
vertex colours and skin-weight groups; face files are merged into their
bodies and also written alone. The parser (lex.py) follows Lakuwu's
xenotool and was checked against it: KOS-MOS comes out at 3,290
triangles and 46 weight groups in both. On the retail disc the run
writes 1,315 .glb files (1,145 of them textured) holding 5.15 million
triangles, 663 MB in all, with 104 face files merged into their bodies,
nothing unparsed and six files carrying a warning; 760 of them carry a
posed skeleton. The how-to is BLENDER.md.
Texture fixes. Looking at models in Blender showed up four bugs in the texture decoder, some there since the first release. All fixed:
- Two-part atlases overwrote themselves. 42 character textures (Cherenkov, Jr., MOMO, Shion and Virgil variants, Van, Joachim and more) came out as beige noise because the second half of the image was placed on top of the first. The placement rule is now fitted on every multi-part texture on the disc. Seventeen menu sheets improve with it too: the card-game tutorial pages, the slot frame, the battle font.
- Materials hidden in the vertex stream were ignored. A mesh can switch palette mid-stream (Cherenkov's sleeves). The decoder now sees those materials, which was an open item in the format notes since v1.0.0.
- Hair sheets were a guess. Albedo's white hair came out half beige. The model says exactly which regions are true colour, so the decoder now uses that instead of a noise test.
- Narrower atlases were scaled wrong in the model export. UVs now use the texture size the model declares, not the width of the decoded image. KOS-MOS happened to be the full width, which is why she looked right from the start and Cherenkov did not.
Checked by re-rendering all 1,019 model textures on the disc with the old and new code: 756 change. Textures decoded with their own model palette rise from 790 to 940, and the fallback guesswork drops from 155 to 14. Every file whose transparency grew was looked at by eye, and all were improvements; the only wash is seven tiny card-game banners that were unreadable before and after.
Casino. The card faces, the slot machine and the concept-art
gallery in the casino were the last textures that came out wrong. These
menu sheets use several palettes at once, and the game's menu code
(OV11.OVL) says which palette colours which part of the sheet. The
kit now reads those descriptors, with a check that keeps a sheet from
borrowing another sheet's palette. This closes GitHub issue #1.
Still open. The skeleton pose and hierarchy (.jnt), the eyelash
textures (4 bits per pixel, undecoded, flagged in the .glb as
…_lash-4bpp-undecoded), two map meshes in vertex formats the parser
skips, and the slot machine's reel strips, which no descriptor covers.
For the byte-level details of each change see FORMATS.md;
the regression harness lives in docs/research/lexless_render.py.
The ~500 embedded XTX hits the sweep reported as undecodable (all in
scene/cf*.a) were never a texture variant. Scene archives, .fpk
packs and .arc bundles keep their members ARX-compressed in place,
and the ARX coder passes literal words through verbatim — so a packed
texture's header still reads as XTX\0 plus sane sizes inside the
compressed stream, while its pixels do not exist until decompression.
browse --kinds texturesnow decompresses every in-file ARX container first (browse.py: iter_arx_containers; 1,864 inside the 112 retail scene archives: 603 XTX, 778lexmodels, 483 FPK packs), decodes the XTX members, and palettes them with thelexmodels packed beside them (nearest member first). Raw hits inside a container's span are skipped, so the bogus rows are gone — and so are two "textures" the old sweep had decoded out of the middle of a compressed stream (cf3021.a_208e4c,cf3070.a_61690c: garbage).- Retail result: 1,363 embedded PNGs, 1,696 duplicates skipped, 0
undecodable (was 1,365 / 1,095 / 528). Nothing new to look at:
all 603 packed textures are byte-identical copies of standalone
.xtxyou already have — the NPC skins (char/, 266), enemies (enemy/, 197), objects (obj/, 66), map atlases (map/, 65) and mechs (robo/, 9) each field scene packs for itself. What is new is the provenance map:browse/embedded_textures.csvnow lists, per scene archive, every texture it carries and which standalone PNG it equals (packed=arx). That is the query the recolor tooling needed by hand ("whichcf*.acarry KOS-MOS's palette?"), answered for every texture on the disc. docs/FORMATS.mdgets the.aarchive layout (TOC + 0x100-byte record header + ARX member) and corrects the repack note: the "re-framed canvas with 4-byte inserts" thatpinkhair.pyworks around is the ARX bit stream (the entry-level sweep works because the hair words are stream literals).
Proving issue #1 against the July build (v1.0.0 renders decoded from
the tagged browse.py) turned up three things the interim decoders
had got wrong on the lex-less UI sheets. All folded into v1.1.0.
The candidate ranking penalises transparency (right for character
atlases, where a wrong palette hides garbage behind alpha 0). UI frames
legitimately have transparent cut-outs — slot-reel windows, the window
frame — so on those sheets the ranking preferred washed-out grey
palettes parked for other sub-images over the sheet's own CLUT at a
conventional corner spot, which v1.0.0's first-hit rule had picked
correctly. For textures without a .lex, the first conventional-spot
palette is now kept when it renders 50–95 % opaque and is, penalty
aside, about as coherent as the ranked winner (or the winner renders
the sheet nearly flat). Changes exactly six sheets, all for the
better: tanaka/slot_2 (slot frame), poker_2 (pay table), w1
(neon reel labels), window, carddata/card_Rare (rarity labels),
texture/fm8009 (the Elsa deck map).
The interim build still repainted one tile of the casino slot machine
(tanaka/slot_1, an orange square) that v1.0.0 rendered black. Cause:
the 64-px block-rescue pass (a wrong palette "explains" a near-black
noise block better than the base palette). Checked across every
lex-less texture where that pass fired (32 files, 54 blocks): every one
of those rescues was wrong — grey blobs over the card-tutorial text, a
noise strip on the casino menu, the orange tile. The pass now runs only
for model-bound textures (a .lex exists), the same gate as the
raw-CT32 pass below. Verified through the real pipeline: all 114
char/pc + char/npc atlases byte-identical; the casino sheets now
differ from v1.0.0 only by the blanked palette squares (issue #1) and by
the sheets v1.0.0 got wrong (data viewer, slot frame).
Still open on the KOS-MOS atlas: the two plane-packed 4bpp visor-HUD
sheets. New lead recorded in FORMATS.md: they are drawn by effect
libraries (simajiri/esd/eve017.esd, eve107.esd, boss0185.esd,
scene/cf0680.a) as PSMT4 from the character VRAM slot with CLUT
pointers 14400/14401 — palettes those effects bring themselves.
Verifying issue #1 against the July build turned up a regression from
the 2026-08-21 decoder: the raw-CT32 pass (which draws true-colour
hair sheets straight from the canvas) also fired on lex-less UI sheets,
where dithered 8bpp art — two indices alternating — reads as a
"coherent" checker when taken as CT32. The casino slot reels
(tanaka/slot_1) came out blue/yellow checkered and the poker felt
(tanaka/poker_1) half garbled. The pass now runs only for model-bound
textures (a .lex exists); verified byte-identical output on all 114
char/pc + char/npc atlases, clean reels and felt. Still imperfect in
the casino: base.png (slot symbol sheet) is tinted by a wrong overlay
palette, as it was in every build.
To pick this up: re-run browse --kinds textures on an existing dump
(no re-extract). Pure-Python ARX adds ~2 minutes to the sweep.
Full retest of the extractor against the retail USA ISO (two complete
extract→classes→verify→browse passes; verify clean on all 8,980 objects
both times). Everything below is on the extract/browse (viewing) side
— nothing changes what patch/pinkhair/text-import write to an ISO.
- Embedded texture sweep — ~3,000 validated XTX blobs live inside
other files (
.esd/.espeffect libraries,.ascene archives, battle.bin, and the NLNK/NBGL/NBXX UI containers.npr/.rbg/.bxx: title screens, help pages, room backdrops).browse --kinds texturesnow sweeps every dump file and writes 1,365 new PNGs tobrowse/textures_png/_embedded/(1,095 byte-identical duplicates are recorded, not re-written) with a per-find manifest inbrowse/embedded_textures.csv. The ~500 undecodable hits are all one in-.aXTX variant whose sub-image pointers are GS addresses (pixel data streamed separately) — open thread. - PS2ICON3D resources —
hdd.res(the HDD-install bundle) is now unpacked by theimageskind into its boot CNF,icon.sys, and the memory-card.ico3D icon model. - Hidden string tables — the
textkind sniffs text out of binary-extension files:evtitem.dat(event-item descriptions),CASINO.res(casino dialogue), dev.info/.umlconfigs — exported as readable*.strings.txt. The.infofiles turned out to be EUC-JP, not Shift-JIS; the decoder now auto-picks the reading without mojibake.
- Raw CT32 regions render as art, not noise. Character atlases mix
8bpp paletted art with true-colour CT32 regions (KOS-MOS's and NPCs'
hair-strand sheets). Per 64px block, when the palette render measures
as dither noise but the canvas read directly as CT32 is coherent, the
block is drawn straight from the canvas. Confirmed by the file format
itself: lex materials with
pal=0xFFare true-colour bindings whose rects (in canvas coords) mark exactly these regions. - Companion-model materials merged. A
.lexwith no.xtxof its own (kosmos_face.lex,kosmos_h_face.lex, …105 textures have such companions) binds its materials onto the sibling atlas whose stem is the longest prefix of the lex stem. This is ground truth the heuristics can't reach — it's what finally rendered KOS-MOS's red eye correctly (palette tile (448,112), bound by kosmos_face.lex). - Map atlas rescue. Overworld atlases (
map/MC_*.xtx) bind their parked CLUTs through VIF-stream materials the static parser never sees; the per-block rescue pool now includes every CLUT-looking tile in the canvas (recovered e.g. MC_DYU01's second DURANDAL sign and starfield). - Fine-grained trusted-palette rescue. 32px blocks outside every parsed material rect repaint when one of the same file's material palettes renders them near-perfectly (thresholds fitted so all true fixes pass and all false repaints are blocked; zero opacity regressions across samples).
- All-16×16 sprite files decode.
carddata/game/1p2p.xtx(the 1P/2P indicators) was the one undecodable texture on the disc — every sub-image was being classified as a palette tile. Now 1,269/1,269 standalone textures decode. - Known residual: two regions of the KOS-MOS atlas are plane-packed 4bpp visor-HUD sheets (symbol sheet, keypad, HUD text — each index byte's two nibbles are two separate images) whose 16-colour palettes are not stored on disc. Rendering them faithfully needs a live-RAM palette capture (PCSX2 + PINE). Details in FORMATS.md.
- The driver's timebase is fixed at 96 PPQN. The SMD header byte @
0x20 (retail 100/120/123/127) is not ticks-per-quarter — reversed
from SSD.IRX: a 2 ms timer ISR advances one sequence tick per
underflow of a 16.16 accumulator, rate =
((tempo*53687)>>8 * master)>>8, so ticks/sec = 1.6×tempo = tempo×96/60 exactly. First-pass tooling read 0x20 as PPQN and played the battle themes ~25% fast (U.M.N. Mode at 100 was only 4% off, which masked the bug).music-exportoutput is now at correct speed: BATTLE1/2 162.7 s, LastBattle 305.5 s, jingles 34.9 s.
Re-run browse (and music-export) on an existing dump — no
re-extract needed; the disc data was never wrong, only its conversion.
The packaged Windows build must be rebuilt (GitHub Actions release
workflow) or run from source; older exes predate all of the above.
- Sequenced-BGM engine:
ssd.py(SMD→MIDI, SWD→SoundFont2) +ssd_render.py(numpy SPU2 sampler → WAV/FLAC),cli.py music-export, reversed from the unstripped SSD.IRX driver. - Battle voice/SE banks (
yamamoto/snd/sed/*.bin) →battle_audiobrowse kind (1,502 sample WAVs, stereo pairs stitched). - XTX palette pipeline v2: overlay CBP descriptors, coherence-ratio candidate ranking, CLUT-tile blanking (GitHub issue #1).
- Arabic-translation groundwork spec (
docs/ARABIC-TRANSLATION-SPEC.md).