pmf: obfuscated Ogg Theora movies
- Source
docs/formats/pmf.md- SHA-256
219db7ea…849feddd
Layout · What the Ogg holds · The files · How the game reads it · Making a replacement movie · Leads
The game's full-motion movies, in movie/: the opening, the chapter movies, the trailer and one title movie per language. A .pmf is an ordinary Ogg file (one Theora video stream and one Ogg Skeleton stream, no audio) whose bytes are XORed with a repeating 60-byte key, RotXor's key 1, by file position. There is no header, no ZIP and no other wrapping. The game reads the file straight from disk, removes the XOR as it reads, and hands the bytes to libogg and libtheora. A movie's sound isn't in the .pmf: it is a separate sabf sound bank, played alongside and used as the movie's clock (Sound).
| Based on | All 17 files in movie/ (6,857,562,768 bytes in all; sizes in The files), the same paths and sizes as their FileTable.bin records; system/system_data.dat#6 and textseq/textseq_*.dat; the unpacked executable, SHA-256 7b0230a11498f40396c9048345e7cfe0ef442cb7ca8faffc6f197920d2669599. Outside specs: RFC 3533 (Ogg page layout and CRC), libtheora's codec.h (th_info, colour space and pixel format values), Ogg Skeleton 4, all checked 2026-10-11. |
| Checked | 2026-10-11 |
| Method | Static analysis with Ghidra 12.1.4 through the decompiled code in local/exe/decomp/ (functions named by address below). Single-file C# exploration programs (session scratchpad, movie/): pmfscan.cs removes the XOR from every .pmf exactly as the game's read callback does (4096-byte reads from offset 0) and walks the result as Ogg pages, checking each page's capture pattern, version, CRC and sequence number, and reading the Theora and Skeleton headers; ftmovie.cs lists the file-table entries and dumps the tables that pick a movie's sound; textseq.cs reads the movie IDs out of the caption files. Not yet decoded or rebuilt by the toolchain. No in-game tests yet. |
Layout
A .pmf is n bytes, with no header of its own.
| Offset | Size | Type | Name | Meaning | Confidence | Source |
|---|---|---|---|---|---|---|
| 0x0 | n − (n mod 4) |
raw[n − (n mod 4)] |
ogg_words |
An Ogg file, as whole 4-byte words, each XORed with a key word: the word at offset 4w is XORed with word w mod 15 of the key. |
confirmed | pmfscan.cs, all 17 files: every page's capture pattern, version and CRC are right after removing the XOR; 0x1402b0970 → 0x1401a2bf0. |
n − (n mod 4) |
n mod 4 |
raw[n mod 4] |
ogg_tail |
The Ogg file's last 0 to 3 bytes, not XORed. | confirmed | pmfscan.cs: 14 of the 17 files have a tail of 1 to 3 bytes, and the CRC of every last page is right with the tail left as it is. |
- confirmed: after removing the XOR, every file is a run of Ogg pages from offset 0 to the end of the file, with nothing before, between or after them: 19,547 pages in
Prologue.PMF, 1,587 to 7,323 in the others, none with a bad CRC or a gap in its sequence numbers. Source:pmfscan.cs, all 17 files. - confirmed: the XOR is its own inverse, so applying the same step to an Ogg file makes a
.pmf, and applying it to an unchanged.pmf's Ogg gives back the original byte for byte. Source: XOR;pmfscan.csshows the game's step yields valid Ogg for all 17 files.
Key 1
The 15 key words are RotXor's key 1, the same 60 bytes at 0x140ba5300 that the .dat deobfuscation uses from start word 8. Here the start word is the word index in the file, so word w uses key word w mod 15, and the key repeats every 60 bytes of the file. Key word k is bytes 4k to 4k + 3 of the key, in order.
- likely: the read callback passes
position >> 2as the start word, and0x1401a2bf0XORs each whole 4-byte word of the buffer with key word(start + i) mod 15, leaving any bytes after the last whole word alone. Source:0x14026c260(ftell,fread, then0x1402b0970(buffer, bytes read, position)),0x1402b0970,0x1401a2bf0. - confirmed: every file starts
FF DB 0A 4A, which isOggSXOR key word 0 (B0 BC 6D 19). Source:pmfscan.cs, all 17 files.
What the Ogg holds
Each file has two logical streams, both opened on the first pages and both ended with an end-of-stream page. These are data facts, confirmed by pmfscan.cs over all 17 files, unless marked.
| Stream | Pages | What it is |
|---|---|---|
| Ogg Skeleton 4.0 | 4 | The first page of the file. Packets fishead (version 4.0), one fisbone for the Theora stream, and an index of keyframes. Players may ignore it (Ogg Skeleton 4), and the game does. |
| Theora | the rest | The video. |
- confirmed: no file has a Vorbis (or any other) audio stream. Source:
pmfscan.cs, all 17 files.
The Theora identification header, the same in every file except where noted:
| Field | Value |
|---|---|
| Bitstream version | 3.2.1 |
Frame size (frame_width × frame_height) |
1920 × 1088 |
Picture size (pic_width × pic_height) |
1920 × 1080, at pic_x 0, pic_y 4 |
Frame rate (fps_numerator / fps_denominator) |
2997 / 100 (29.97 frames per second) |
| Pixel aspect ratio | 1 : 1; 0 : 0 (unspecified) in the eight title_* files |
| Colour space | 0 (unspecified) |
| Pixel format | 0 (4:2:0) |
keyframe_granule_shift |
6 |
| Encoder (comment header vendor string) | Xiph.Org libtheora 1.2.0alpha 20100924 (Ptalarbvorm) |
The files
Movies are file-table group 2000; the file ID picks the movie. Frames and length are from the Theora stream's last granule position. Sound is the sound bank row for the file ID. All values confirmed (ftmovie.cs, pmfscan.cs, system/system_data.dat#6), except the Plays column, which is likely (static analysis, Who plays a movie).
| File ID | Path | Size | n mod 4 |
Frames | Length (s) | Sound | Plays |
|---|---|---|---|---|---|---|---|
| 1 | movie/Prologue.PMF |
1,274,853,163 | 3 | 5,412 | 180.58 | 1012: sound/bin/ja/Movie/prologue.sab or en/ |
Caption sequence 1–2 |
| 2 | movie/chapter1.pmf |
93,062,497 | 1 | 3,530 | 117.78 | 100: sound/bin/OPN_movie/chapter1.sab |
Caption sequence 3–4 |
| 3 | movie/chapter2a.pmf |
132,179,871 | 3 | 2,232 | 74.47 | 1000: sound/bin/ja/Movie/chapter2A.sab or en/ |
Caption sequence 5–6 |
| 4 | movie/chapter2b.pmf |
209,409,231 | 3 | 2,130 | 71.07 | 1002: …/Movie/chapter2B.sab |
Caption sequence 7–8 |
| 5 | movie/chapter3a.pmf |
296,572,350 | 2 | 2,253 | 75.18 | 1004: …/Movie/chapter3A.sab |
Caption sequence 9–10 |
| 6 | movie/chapter3b.pmf |
263,473,704 | 0 | 2,282 | 76.14 | 1006: …/Movie/chapter3B.sab |
Caption sequence 11–12 |
| 7 | movie/chapter3c.pmf |
298,417,197 | 1 | 2,412 | 80.48 | 1008: …/Movie/chapter3C.sab |
Caption sequence 13–14 |
| 8 | movie/chapter4.pmf |
376,471,216 | 0 | 3,084 | 102.90 | 1010: …/Movie/chapter4.sab |
Caption sequence 15–16 |
| 9 | movie/trailer.pmf |
474,503,489 | 1 | 4,375 | 145.98 | 71: sound/bin/bgm/taco_BGM71.sab |
Caption sequence 17 |
| 10 | movie/title_ja-jp.pmf |
428,427,129 | 1 | 1,845 | 61.56 | none | Title, language 0 |
| 11 | movie/title_en-us.pmf |
428,696,523 | 3 | 1,845 | 61.56 | none | Title, language 1 |
| 12 | movie/title_fr-fr.pmf |
430,711,354 | 2 | 1,845 | 61.56 | none | Title, language 2 |
| 13 | movie/title_de-de.pmf |
430,493,277 | 1 | 1,845 | 61.56 | none | Title, language 3 |
| 14 | movie/title_es-es.pmf |
429,716,016 | 0 | 1,845 | 61.56 | none | Title, language 4 |
| 15 | movie/title_zh-cn.pmf |
430,259,687 | 3 | 1,845 | 61.56 | none | Title, language 5 |
| 16 | movie/title_zh-tw.pmf |
430,654,369 | 1 | 1,845 | 61.56 | none | Title, language 6 |
| 17 | movie/title_ko-kr.pmf |
429,661,695 | 3 | 1,845 | 61.56 | none | Title, language 7 |
- confirmed: each file's
FileTable.binsize equals its size on disk. Source:ftmovie.cs.
How the game reads it
Static analysis of the unpacked executable, so every fact here is at most likely; the functions are in the names list.
Opening
- likely: every movie is started through
Movie_Open(0x1402f3cd0, a wrapper over0x14026b0f0) with group 2000 and a file ID; nothing opens a.pmfby name. Source: the three callers of0x1402f3cd0(Who plays a movie) all pass2000. - likely:
0x14026b0f0creates aVideoTheoraDecoderand calls0x140284f80, which opens group 2000 and the file ID through the file table with flags0x2000000(FileTable_OpenById,0x1402af860). That opens a file-system handle on the table's path under the data root and keeps the full path in it; nothing is read through the file system's queue, so the table's size isn't used. Source:0x14026b0f0,0x140284f80,0x1402af860→FileDesc_OpenPath(0x140295760, size flag clear; the full path is the string at the start of the handle). - likely: the decoder then opens that path itself with
_fsopen(path, "rb", _SH_DENYNO)and starts a decoding thread over it. If the open or the start fails, the movie decoder is replaced by aVideoNullDecoderand the movie is skipped, with no error. Source:VideoTheoraDecoder::vfunction12(0x14026bdd0),0x14026b0f0(falls back toVideoNullDecoder).
Reading
- likely: the decoding thread reads the file in 4096-byte
freads from offset 0 to the end of the file, each into libogg's sync buffer (ogg_sync_buffer,ogg_sync_wrote), through the read callback0x14026c260, which removes the XOR from the bytes just read (Key 1). It stops when a read returns 0 bytes. Source:0x1401e09c0(buffer, read of0x1000, wrote),0x14026c260. - likely: the only seek is back to offset 0, to play a movie again from the start (a loop flag in the decoder state); every read therefore starts at a multiple of 4096, and the key stays aligned. Source:
0x1401e0ae0(calls the seek callback0x14026c2e0with offset 0,SEEK_SET, at the end of the file when the flag at+0x80is set). - likely: the file's size in
FileTable.binis never used: the decoder reads untilfreadreturns nothing. Source:0x14026c260,0x1401e09c0; the handle's size (+0x48) isn't read by the movie code. - likely: the read position goes through
ftell, which returns a 32-bitlongon Windows; a file of 2 GiB (2,147,483,648 bytes) or more would get a wrong key from that point. Source:0x14026c260(_ftell_nolock, then(uint)position >> 2).
What it checks
The decoding thread (0x1401e0ae0, started by 0x1401e29f0 with at most 75 decoded frames queued) reads the beginning-of-stream pages, then checks the Theora header. Nothing in the .pmf is checked by the game's own code before libogg sees it.
- likely: of the streams that start on the first pages, the first Theora stream is the video and the first Vorbis stream the audio; any other stream (such as the Skeleton) is dropped. Source:
0x1401e0ae0(th_decode_headerin0x1401f63b0tried first,vorbis_synthesis_headerin0x140631000only while no Vorbis stream is found, otherwise the stream is cleared). - likely: decoding stops, and nothing is shown, if the file has neither a Theora nor a Vorbis stream, if
frame_widthorframe_heightis over 99,999, if the colour space is over 2 (only unspecified, Rec. 470M and Rec. 470BG), if the pixel format isn't 0 (4:2:0) or 3 (4:4:4), or if the Theora setup header is missing. Source:0x1401e0ae0(theth_infofields at+0x04,+0x08,+0x2C,+0x30; field order from libtheora'scodec.h). - likely: the frame rate is taken from
fps_numerator/fps_denominatorand timestamps each frame. Source:0x1401e0ae0. - likely: decoded Vorbis audio is never played: the decoder takes every queued audio packet and frees it at once. Source:
VideoTheoraDecoder::vfunction8(0x14026c300:0x1401e3130/0x1401e31b0in a loop). - likely: the video is drawn from two textures created at the decoded frame's width and height; no size limit was found beyond the 99,999 check. Frame buffers come from two 'movie pool' allocators, falling back to the general allocator for the movie heap when they can't serve a request. Source:
0x14026c300(texture creation),0x14026b960(pools),0x14026c1e0(fallback to0x140198c90with heap0x1A).
Sound
- likely: when a movie is opened,
0x14026b0f0reads rowfile IDofsystem/system_data.dat#6(anxlcetable of 18 rows × 8 bytes: two u32 sound IDs, the second used when the voice setting at+0x2Bof0x140ce4e30is set) and, if the ID isn't 0, opens that sound bank by ID, reads it whole and plays it. The group is the voice language's (401 for Japanese, 402 for English, from0x1402860d0), except for sound IDs 71 and 100, which come from the group at0x140ce1aa8 + 0x20: the sound manager's group, 400 (sound/), stored there when game setup (0x1402c2110) creates the sound manager with it (0x140287200). Source:0x14026b0f0(table at0x140ce4da0, set fromsystem_dataentry 6 by0x1402bd7d0),0x1402860d0,0x140286e90,0x1402887c0. - confirmed: the table's rows 1–9 hold the same ID in both columns, and rows 0 and 10–17 hold 0; IDs 71 and 100 exist only in group 400 among the sound groups, which fits. Source:
ftmovie.cs. - likely: a movie with a sound bank is timed by the sound: each frame is chosen by the sound's playback position, and the movie ends when the sound stops playing or the video ends, whichever comes first. A movie with no sound (the title movies) is timed by the clock. Source:
0x14026b5d0(0x1402860f0position and0x140286110state of sound slot 0; finished flag at+0x73).
Who plays a movie
- likely: the caption player (
textseq/textseqcaption.cpp) plays the chapter movies and the trailer:TextSeqCaption_Start(0x140430f30→0x140431490) loadstextseq/textseq_<lang>.dat(group 2700, file ID = language index + 1) and picks a caption sequence (entry) from a list at0x140c9df00by movie number 1–9, plus 1 when the voice setting is set; the sequence's first row of type 1 holds the movie's file ID at+0x06, which0x1404316d0passes toMovie_Open. It is started from the title (0x1404242e0, movie numbers 1 and 2), the Warren Report's movie player (0x1404c39f0) and0x140317850. Source: those functions. - confirmed: caption sequences 1–17 in every
textseq_<lang>.datchecked (ja-jp,en-us) name movie IDs 1, 1, 2, 2, …, 8, 8, 9. Source:textseq.cs. - likely: the title screen plays file ID
10 + language index(0 Japanese, 1 English, 2 French, 3 German, 4 Spanish, 5 Simplified Chinese, 6 Traditional Chinese, 7 Korean, the order of thetextseq_<lang>.datfiles). Source:0x1404adc70(language from0x14026ca60). - likely: the gallery's movie entry plays the file ID in the u16 at
+0x00of a row ofmenu/menu_data.dat#87. Source:MenuGalleryMovie::vfunction5(0x1405142c0).
Making a replacement movie
What a mod has to produce, from the facts above. Not tested in game.
- Encode an Ogg file with one Theora stream: 4:2:0 (or 4:4:4), colour space unspecified, Rec. 470M or Rec. 470BG. An audio stream is ignored, and so is a Skeleton stream. Any resolution and frame rate the header can carry should work; the originals are 1920 × 1080 at 29.97 frames per second.
- XOR it as in Layout: every whole 4-byte word
wwith key wordw mod 15, leaving the lastn mod 4bytes as they are. Keep it under 2 GiB. - Put it at the original's path. The game doesn't need its
FileTable.binsize changed, but set it to the new size anyway, like every other rebuilt file (filetable.md). - For movies 1–9, the picture follows the sound bank's playback and stops when it stops: a replacement of a different length needs its sound bank replaced to match (see the
sabfdoc). Captions come fromtextseq/.
Leads
- The gallery's movie list:
menu/menu_data.dat#87holds12, 0, 1, …, 11in its first field, which don't all read as movie file IDs; howMenuGalleryMovieis given its row is unchecked. - What sets the movie number at
0x140d5c698that0x140317850passes to the caption player (probably an event-script native), and the list of sequences at0x140c9df00. - What sets the decoder's loop flag (
+0x80of the decoding state; the fourth argument of0x1401e29f0, not shown by the decompiler at its one call). - The decoding wrapper's structure (4096-byte feeds, the 99,999 check, the colour-space check, audio and video queues) looks like icculus's TheoraPlay; checking it against that source would name its functions.
- Confirm in game: a re-encoded movie of another resolution, a movie with an audio stream, and a file over 2 GiB.