FMV cutscenes (the .pss movies — 45 indexed in the TOC, 58 more carved
from layer 1 by carve.py) ship with no subtitle track at all, in any
language, including Japanese — confirmed by grepping the dumped Java
classes, the exported text tree, and the manifest for anything
caption-shaped (subtitle, telop, 字幕, any text object tied to a movie
path); nothing turned up. Dialogue during these movies is audio-only. This
is a different problem from the text pipeline in
FRENCH-TRANSLATION-GUIDE.md: there, you're
translating existing text objects. Here, you're authoring new subtitles
from nothing and burning them onto the picture, keeping the original
English/Japanese audio.
subs.py (CLI: subs-template, subs-burn, layer1-list,
layer1-patch; GUI: the four cards after "Patch disc objects") does the
mechanical half of this. Read the honesty section before using it on a
whole game's worth of cutscenes.
The container, measured on movie\mpeg2\1001_1.pss (20,004,868 bytes,
1,495 frames at 29.97, 49.8 s):
- A run of 16,384-byte packs: a 14-byte pack header, then PES
packets of 4,096 bytes each (the last one shortened so the pack
closes exactly), video (
E0) and audio (BD) interleaved about 5:1, three padding packets near the end, a 15-byte system header in the first pack,00 00 01 B9at the very end. 1,221 packs, 4,212 video packets, 671 audio packets. - The video is standard MPEG-2, Main Profile @ Main Level,
yuv420p, 512×448; the retail stream uses open GOPs (an I frame with two leading B frames, 15 pictures) after a 16-picture first GOP, with one sequence header for the whole stream. The re-encode uses closed GOPs with a sequence header per GOP, which the player accepted. - 1,455 of the 1,495 pictures carry a PTS in the PES header of the packet they start in (DTS too when it differs); the remaining ones share a packet with an earlier picture.
What splice() now guarantees, checked on every run: the output is the
same size as the source, every pack header, the system header and every
audio packet sit at their original offsets byte-for-byte, every video
slot is a PES packet of its original length (or a padding packet once
the new stream is spent), picture stamps are monotonic in decode order,
the stream ends with sequence_end, and ffmpeg -v warning decodes it
silently. fit_to_budget now targets the elementary stream at the
container's slot capacity, not the file size (which cannot change).
Verified on PCSX2 (2026-10-15, v2.9.127, LANGTEST disc): see the
results at the end of SCRIPTS.md for the boot of this
movie with ten Hindi cues. Not verified: audio/video sync over a
multi-minute cutscene (this one is 50 s), and the 58 layer-1 movies
beyond layer1-patch's read-back check.
The re-encode is lossy on top of the retail encode, so it should spend
every bit the container allows. fit_to_budget starts at 95 % of the
video slots' capacity (about 2.6 Mbit/s on 1001_1.pss, whose retail
stream is 2.75) and the encoder runs with a Main Level VBV (1,835 kbit),
rate-distortion macroblock decisions, trellis quantisation and 10-bit
DC. Measured above the subtitle band against the retail frames, that is
33.9 dB luma PSNR; the first shipped build (2.34 Mbit/s with a VBV of
0.3 × bitrate) was 31.1 dB and visibly blocky on the lab's desk shots —
the tight buffer starved scene changes. ffmpeg's two-pass mode refuses
constant-rate output, so this stays single-pass. The black bar and the
cue itself are the rest of the difference from the retail picture; the
bar is flat black, which the encoder codes in a handful of bits, so it
is also where a blurred band's bits went.
The first version of splice() carried each original video packet's
PTS/DTS bytes over verbatim. Frames are split across PES packets at
roughly 2.8 packets per frame, so after the proportional re-chunking a
stamp no longer sat on the picture it described: ffmpeg flagged
non-monotonic DTS. The second version omitted PTS/DTS from every
rebuilt video packet, which is spec-legal and round-tripped through
ffmpeg with no warnings — and hung the game. Booted on PCSX2
(2026-10-15, 1001_1.pss with Hindi cues), the screen went black at the
movie and the emulator log filled with isoFile error: Block index is past the end of file until a read abort: the console's player never got
a picture it could schedule, streamed to the end of the disc and kept
going. The retail muxer stamps nearly every picture (1,455 of 1,495 in
that movie), and the player depends on it.
What ships now: picture_units splits the new elementary stream at
every picture (taking the sequence/GOP header in front of it along),
picture_times gives each picture a DTS one frame after the previous
one in decode order and a PTS of its GOP's display base plus its
temporal reference, on the original's time base (first PTS/DTS and
3,003-tick frames read from the source), and splice() starts a PES
packet at every picture with that stamp. Pack headers, the system header
and every audio packet are still the original bytes in their original
order; the stream ends with the sequence_end code the retail stream has
(XS1KOR's "safe tail"). ffmpeg sees monotonic DTS and 1,495 stamped
pictures. Whether the console's player is satisfied is the boot test in
the next section's results.
- Extract and convert the movie normally first (
browse/ Convert for browsing) — you need the MP4 to actually watch/listen while timing cues. subs-template --src MOVIE.pss --out MOVIE.LANG.srt --detect— an SRT skeleton with one cue per interval the original burned-in subtitle is on screen. The detector samples the subtitle band four times a second and counts white pixels that have a dark outline next to them, so a bright floor does not count as text; on the first Woglinde movie (1001_1.pss) it found the ten lines with two stray cues in the noisy opening shot and one pair of short lines merged. Without--detectyou get uniform 5 s cues. Either way, check the times against the extracted MP4 and write the translation into each cue.- Time and translate in a real subtitle editor if you prefer (Aegisub, Subtitle Edit — anything that edits SRT). Multi-line cues are fine; the burner centres each line.
subs-burn --src MOVIE.pss --srt MOVIE.LANG.srt --out MOVIE.LANG.pss --font FONT.ttf— with--fontthe kit renders every cue itself: HarfBuzz shaping (so--script Deva --language hi, or--script Arab --language ar --rtl, come out joined and ordered), white letters with a black outline and shadow, one PNG per cue, composited by ffmpeg's plainoverlayfilter. While a cue is up, the band where the original English sits is covered with a solid black bar reaching the bottom of the frame (--mask x,y,w,h, default0,330,512,118;--band blurfor a box blur of the band instead;--mask noneto leave it). The bar hides the English completely, costs the encoder nothing and leaves the rest of the picture its bits; the blur left a soft smear and cost bits. The cover opens--band-padseconds (default 0.25) before each cue and closes that long after it, because the original line's fade-in and fade-out are rarely inside the SRT times and a late bar shows the English for a few frames. This is the scheme poketony's XS1KOR used for the Korean movies — fixed rows at 512×448, outline/shadow/dark spread over the original line, then a size-guarded MPEG-2 re-encode muxed over the untouched audio — minus libass, so any ffmpeg build works. The optionalfonttoolsanduharfbuzzpackages are needed for--font; without them, draw the cues in any image editor ascue0001.png,cue0002.png… (RGBA, frame width) and pass--cues DIR. Without either option the old libasssubtitlesfilter is used, which many ffmpeg builds (Homebrew's, on this project's own machine) do not have.--max-bytesdefaults to the source file's own size, which is always safe since nothing here can grow past its original allocation;--keep-cues DIRsaves the rendered PNGs for a look.- Write it back:
- TOC-indexed movies (the 45
movie\mpeg2\*.pss): the existing genericpatchcommand already handles this, unmodified —cli.py patch --iso GAME.iso --out MODDED.iso --set 'chain1:movie\mpeg2\XXXX.pss=MOVIE.LANG.pss'. - Layer-1 movies (the 58 raw-sector ones):
layer1-list --iso GAME.isoto find the index/name, thenlayer1-patch --iso GAME.iso --out MODDED.iso --index N --file MOVIE.LANG.pss.
- TOC-indexed movies (the 45
- Boot the patched ISO in PCSX2 and actually watch the scene. This is the only way to catch an IPU-compatibility problem or A/V drift — there is no way to check either from a terminal.
A patched movie must fit within its original object's byte allocation — growth is not possible without relocating every later object, which nothing here attempts:
- TOC movies: the gap to the next TOC entry's sector (same rule
repack.pyalready enforces for every other object type). - Layer-1 movies:
CarvedStream.size, defined bycarve.pyas the gap to the next carved stream's start (or end of image, for the last one) — real headroom, the same way TOC allocations are, just addressed by raw sector instead of a TOC entry. Same-size-or-smaller is safe; anything larger needs a full-disc sector relocation this module does not attempt.
Burning subtitles onto existing frames doesn't inherently need more bits
than the original encode, so hitting the ceiling is a bitrate choice
(fit_to_budget searches for one), not a hard blocker in practice.