Exploring Omise de Tensyu for PS1

*taps mic* this thing on?

30 min read / Posted under dev, psx, reverse-engineering

August 12, 2026

Intro

Over time, my collection of Playstation games has turned towards finding games that feel a little weird. A lot of games in the mid-to-late 90s had the energy of throwing anything at the wall, and seeing what stuck - this is how we ended up with Kurashi no Manners (a guide to Japanese manners), Suzuki Bakuhatsu (a game where you disarming bombs literally everywhere) and Egg (egg).

One area in particular I like to collect from is Simulation games - think tycoon games, life games, countless Densha de Go versions etc. With the recent rise of cozy life sims, one game caught my eye during a Mandarake visit last year - Omise de Tensyu (or The Shopkeeper):

Description

Embark on an adventure around the world and find job cards. Collecting cards expands the range of of available jobs to a whopping 40 different types! During your adventure, gain experience by selling items.

Build a shop, and become a shop owner. Once you become a full-fledged professional, open your own shop in your town! When a new shop opens, all sorts of people will gather around it.

City planning is interesting You can expand your city, change the pavement and streetlights and more. Build all sorts of shops and create your own town. With a memory card, you can even visit towns created by your friends!

Released in 1999 by Technosoft, and re-released on PS3 and PSP in 2010, Omise is billed as a “Shopkeeper RPG,” and can relate itself the early Mystery Dungeon games (specifically Torneko’s Great Adventure).

I admit I originally bought it partly for its content, but also, there isn’t a whole lot about this game outside of the Japanese-speaking side of the internet. Turns out the reason may be that it’s not a very good game? I found it in a book called “Shitty Games for Playstation” (プレイステーション クソゲー番付):

To be fair to Omise, this entry isn’t about gameplay, more poking fun at the name of the game - it says “Of course it is Award - The shop is obviously run by the owner,” But there are a couple reviews online saying “It’s okay.” I guess it’ll be loved by those who played it as a kid, but really it wasn’t a good game (Thinking of you, Stars Wars Episode 1 - The Phantom Menace for PS1)

But anyway, we’re not here today for a gameplay review of Omise de Tensyu, no, we’re here because I can’t help taking things apart to find new Discord emoji content.

This post will be a run-down of all the work I’ve done so far in reverse engineering the files and formats of the game. As of writing (May June July August 2026) I’ve managed to extract the files out of a VFS, and a further decompression of the .PAC files contained within.

The end goal of many reverse engineering projects like this on Japan-only games is to localise them into English. Pulling apart is easy, putting back together is arguably the more difficult approach, let alone the translation effort, so, for now this post will just be about documenting the formats within, and not about how to fan-translate a game.

I’d also like to lay a disclaimer that I don’t purport to be an expert at any of this, so there may be errors or missed assumptions along the way. At the end of the day I’m here to have fun and share what I’ve found - feel free to improve on my work if you so wish!

Disclaimer

Now for an actual disclaimer. Reverse engineering being a bit of a grey area leads me to state the following:

All work in this project stems from a legally acquired copy of Omise de Tensyu - in fact, I probably overpaid for it - but either way, I own a copy of this game.

I’d also like to express that this project is purely out of curiousity, and a desire to document that journey. Small excerpts of file data and imagery are shown to help explain what is ging on, but no game files, code, or assets are distrubuted as part of this post. Omise de Tensyu remainds the property of Sega (spoilers)

Lastly, the views and opinions expressed in this post are my own and do not necessarily reflect those of my employer (since I work in software and all). This work was done in my own time, on my own equipment.

Hokay, serious messaging out of the way, lets begin~

Overview

This post will be in two parts.

The first part will be going over the exploration of the VFS. These can tend to be straightforward, so I’ll take you through the steps and thoughts that played out as I went over them. Think it of it more like a journey, rather than an explanation.

The second part will be the more complex one, discussing the decompression of .PAC files. Compression in the reverse is a pretty complicated topic, which is probably why you don’t hear it talked about often in detail. Because of this, this part is more a report on what I found - more of an explanation than a journey.

On that note, where better to start than inserting the disc?

First Steps

Whats on the CD?

Plugging in my trusty usb CD drive, I was able to extract the following files from the disc:

PathSizeWhat is it?
SLPS_018.76662kbThe main PSX executable. This is common for every game
SYSTEM.CNF69 bytesBoot config. This is also common for every PSX game
TENSYU/OT.IX15kb???
TENSYU/OT.IM14.9mb??? Could be related to OT.IX?
TENSYU/ZTCDATA.OT637.5mb??? Based off of its size, I imagine it’s a lot of media.

I immediately booted up jPSXdec and added our disc image to it. After a bit of analysis, it found a fair few .TIM images stored in our OT.IM file:

This was a common sight in many games of the time - resources were slim, and you had to fight the CD drive to load your assets in a predictable and known manner. Indexing files within a single file gives you the benefit of being able to control where files ended up in relation to one another.

In our case, two things stand out to me:

Firstly, this isn’t all the media in the game. There are a lot more sprites than whats here - not to mention audio and the like. This means they’re probably hiding in one of our other files.

Secondly, since OT.IX and ZTCDATA.OT didn’t show up in the decoder, it means that there is more processing done on these files from within the executable. I was hoping to see .SEP/.SEQ/.VAB/.XA files (audio) or .STR (video) files in ZTCDATA.OT to confirm my hunch, but it’s probably likely there is some sort of processing or compression on this file done elsewhere.

At this point, I’m going to continue with my hunch that OT.IX and OT.IM are somehow linked. Since OT.IX is the smaller of the two, we’ll start there.

Oho, would you look at that!

What have we got here?

Immediately upon opening OT.IX we’re met with what looks like a list of directories and files. Let’s see if we can glean anything from this:

  • The first 8 bytes look suspiciously like a header (no text, on the byte boundary etc.)
  • Our list of filenames seem to be grouped by directories at the front, files second (it switches at 0x00000200 with EVDATA.PAC)

If we continue down the file, there is some other suspiciously shaped data:

A couple more things we can gauge from this is that there are some columns that are incrementing, and others that are repeating.

Based off this knowledge, and a bit of intuition, I think this is a file index. I’m hoping that there is data in the header that tells us how many items there are in this file, and then I’m expecting the column that is increasing is some byte offset into one of our other files. The repeating column, I’m guessing, is probably pointing back into our directory / filename structure.

At this point, there may be a little bit of glossing over how I came to some conclusions. A lot of it is head scratching for a while, but the techniques are not too disimilar to some of the techniques Hilltop describes in his videos.

Parsing an .IX/.IM File

Our .IX file starts with a 16 byte header: 0x1902 0x8419 0x0800 0x0C02. I’ve broken them up into four 4-byte numbers, partly because it’s easier to read, but also because they actually describe offsets within our file.

The smoking gun to this fact is our latter two pairs - 0x0800 (8) and 0x0C02 (524). Assuming they’re byte offsets, they point to the first entry of our block of directories, and the second conveniently points to the first entry of our filename block.

Taking this knowledge, we can see that our byte data seems to also start at the point described by 0x8419 (6532). Therefore, we can guess our header is:

Hex (LE)DecimalUse
0x1902537???
0x84196532Records Offset
0x08008Directory Offset
0x0C02524Filename Offset

Using this, building a script to unpack the file structure isn’t too hard. I’ve reused my ByteReader class from my .TIM post again:

import { ByteReader } from "./reader.ts";
import fs from "node:fs";

const index = fs.readFileSync("ot.ix");
const reader = new ByteReader(index.buffer);

const unknown = reader.readHalf();
const recordOffset = reader.readHalf();
const directoryOffset = reader.readHalf();
const filenameOffset = reader.readHalf();

Filepaths

We can parse through the directory and filename listing using the offsets and build a lookup of byte offset -> string value. We don’t need to differentiate them when reading the actual data, so I’ll add them all into the same lookup:

const filepathsBuffer = index.buffer.slice(directoryOffset, recordOffset);
const filepathsView = new DataView(filepathsBuffer);

const filepathsLookup = new Map<number, string>();
const decoder = new TextDecoder("latin1");

let currentStr = [];
let start = 0;

for (let i = 0; i < filepathsBuffer.byteLength; i++) {
  const byte = filepathsView.getUint8(i);

  // If we've run into a null byte, then it's a null terminator for the string
  if (byte === 0) {
    filepathsLookup.set(
      start + directoryOffset,
      decoder.decode(new Uint8Array(currentStr)),
    );
    currentStr = [];
    start = i + 1;
  } else {
    currentStr.push(byte);
  }
}

After this runs, we get this:

"8": "TOWN/",
"14": "TOWN/CHARA/",
"26": "TOWN/LEVEL/LV1MODEL/",
"47": "TOWN/LEVEL/LV1PIC/",
"66": "TOWN/LEVEL/LV2MODEL/",
"87": "TOWN/LEVEL/LV2PIC/",
"106": "TOWN/LEVEL/LV3MODEL/",
"127": "TOWN/LEVEL/LV3PIC/",
"146": "TOWN/LEVEL/LV4MODEL/",
<snip>
"4683": "JIMEN29.PAC",
"4695": "JIMEN30.PAC",
"4707": "LOG",
"4711": "JIMEN31.PAC",
"4723": "SEJ04.TXT",
"4733": "BATTLE_F.PAC",
"4746": "J00M.TIM",
<snip>

Records

Since our file and directory names don’t have any forward references, we can assume that the records themselves must reference back to our lookup.

Digging through the listing, I’ve picked out the SOUND directory, as it only has two files in it, so we should see reference to thre SOUND/ directory twice. The byte positions of the directory and files are:

  • Directory: "500": "SOUND/" (0xF401)
  • Files:
    • "6356": "SOUND2.PAC" (0xD418)
    • "6367": "SOUND.PAC" (0xDF18).

A quick search in our hex editor, however, doesn’t surface anything for these values. One approach is to see if our values are offset by something - say, the offsets in our header?

  • Directory: 0xF401 (500) - 0x0800 (8) = 0xEC01 (492)
  • Filenames:
    • 0xD418 (6356) - 0x0C02 (524) = 0xC816 (5832)
    • 0xDF18 (6367) - 0x0C02 (524) = 0xD316 (5843)

Plugging these into our hex editor, we can see that there are indeed two entries next to another referencing 0xC816 and 0xD316:

Next, it’d be handy to have two things: Where our data is, and how big it is. Looking at our SOUND entries, they seem to be followed by what looks like two values:

  • SOUND2.PAC - 0x381A0000 (6712) and 0xC4000000 (196)
  • SOUND.PAC - 0xFC1A0000 (6908) and 0x74000000 (116)

It’s not a coincidence that 6908 (SOUND.PAC first number) - 196 (SOUND2.PAC second number) = 6712 (SOUND.PAC first number) - our VFS needs to know how big the file is it’s pointing at, plus where that file is in our data. I’m going to hazard a guess that our first 4 bytes here are the offset in the file, and the next 4 are the size of the file. This leaves 4 bytes of padding between entries, leaving us with the following format:

HexSizeDescription
0xEC012 bytesPointer to Directory Lookup (offset by value in header)
0xD3162 bytesPointer to Filename Lookup (offset by value in header)
0xFC1A00004 bytesPointer to the start of the file
0x740000004 bytesSize of the data
0x000000004 bytesReserved / Padding?

In code, it’d look something like this:

const recordsData = index.buffer.slice(recordOffset);
const recordsReader = new ByteReader(recordsData);

const records = [];

while (recordsReader.position() < recordsData.byteLength) {
  const dirOffset = recordsReader.readHalf();
  const nameOffset = recordsReader.readHalf();
  // Fixing slashes 'cause I'm not on Windows
  const directory = filepathsLookup
    .get(dirOffset + directoryOffset)
    ?.replaceAll("\\", "/");
  const filename = filepathsLookup.get(nameOffset + filenameOffset);
  const dataStart = recordsReader.readWord();
  const dataSize = recordsReader.readWord();
  // Our reserved space
  const _ = recordsReader.readWord();

  records.push({
    directory,
    filename,
    dataStart,
    dataSize,
  });
}

What we’re left with is a collection of file records:

// ...
{
    "directory": "SOUND/",
    "filename": "SOUND2.PAC",
    "dataStart": 6712,
    "dataSize": 196
  },
  {
    "directory": "SOUND/",
    "filename": "SOUND.PAC",
    "dataStart": 6908,
    "dataSize": 116
  },
//...

But to what?

Extraction

Before we we begin, the records array has a length of 537 entries. If you remember, our header had a unknown value which also happened to be 537, that probably means our missing entry is file count - which makes sense.

When it comes to which file this is pointing at, I’m pretty confident it’s OT.IM:

  • We already know it contains un-compressed files (those .TIM images from before)
  • The sum of dataSize is 7274. If it’s bytes, its too small for anything, but if it’s sector count then it’s 7274 * 2048 = 14.8mb which feels awfully similar to the filesize of OT.IM.
  • It’s called OT.IM, our index is OT.IX.

Now we have all the pieces in play, extracting the data from OT.IM is pretty straightforward:

const data = fs.readFileSync("OT.IM");
const dataBuffer = new Uint8Array(data);

const SECTOR_SIZE = 2048;

for (const record of records) {
  fs.mkdirSync(`./output/${record.directory}`, { recursive: true });
  const start = SECTOR_SIZE * record.dataStart;
  const fileData = dataBuffer.slice(
    start,
    start + SECTOR_SIZE * record.dataSize,
  );
  fs.writeFileSync(`./output/${record.directory}${record.filename}`, fileData);
}

Wahay!

What have we got?

After extracting everything, we’re left with something that resembles more of a filesystem we’re used to. A couple fun notes as we go along:

  • We seem to have wrapped up SLPS_018.76 and SYSTEM.CNF in our VFS, however the SYSTEM.CNF file refers to a OT.exe rather than the PS1 executable. Our two SLPS_018.76 files appear to be exact matches with one another
  • Those .TIM files seem to be under /JOBTIM - I guess they’re frequently used enough that there’s no need to process them further
  • BATTLE/ seems to contain a couple files that aren’t probably meant to be there - SEJ04.TXT and LOG. The .TXT file doesn’t have anything of real use but LOG is interesting - more on that later
  • ZZZZZZZZ.LOC is a junk file - probably a method to align files on the disc, or maybe some old data leftover from dev that got swept up into the executable.

OTX.FLI when shifted to ShiftJIS encoding makes a reference to TGS 98 - “TGS98出展バージョンだよ” which translates to “This is the TGS 98 Exhibition Version” or something. Doing some digging, they were indeed at the Autumn TGS (they used to have two a year?)

This was about the time that the company faced financial troubles, explaining the smaller booth size. Fellow companies in the vicinity that met similar fates are Compile (コンパイル, to the right) of Puyo Puyo fame, Riverhill Soft (リバーヒルソフト, to the lower right) and Data East or DECO (データエースト).

But other than that, we seem to be left with a number of .PAC files, and no clear indication of what they are.

Parsing a .PAC file

Opening them in our hex editor doesn’t show anything too obvious, other than they all seem to have the same columns pattern of incrementing numbers we saw before, but this time at the beginning of the file. Checking for filenames, or changing encoding doesn’t seem to identify any obvious text, which leads me to feel that they are compressed in some form.

Rather than jumping headfirst into picking our file apart - I wouldn’t say I’m very proficient at unpicking compression routines - I wonder if there’s anything we can glean from that LOG file from before:

Data Pack for DOS  v0.10  By OOTUKA  (C)1996 Technosoft Co.,ltd

<BTL_T13.PAC>のリストを表示します。
--------------------------------------------------------------------------
itemcom.txt   99/01/18 21:39.22  [   11392] [    2931]  25.73%  -lh5- (16)
syoubai.tim   98/06/07 17:20.40  [    1088] [     517]  47.52%  -lh5- (16)
miss.tim      98/10/08 10:31.58  [     256] [     138]  53.91%  -lh5- (16)
num.tim       98/11/12 20:57.40  [     448] [     191]  42.63%  -lh5- (16)
kaifuku.tim   98/06/07 17:20.40  [    2112] [     336]  15.91%  -lh5- (16)
healing.tim   98/06/07 17:20.40  [    2112] [     251]  11.88%  -lh5- (16)
fire.tim      98/12/10 11:36.06  [     576] [     412]  71.53%  -lh5- (16)
btl_item.tim  98/10/06 10:07.04  [    2112] [     903]  42.76%  -lh5- (16)
panic.tim     98/09/29 10:26.58  [     320] [     184]  57.50%  -lh5- (16)
nige.tim      98/12/16 17:20.22  [     320] [     260]  81.25%  -lh5- (16)
dougu.tim     98/12/16 17:20.22  [     320] [     276]  86.25%  -lh5- (16)
kemuri.tim    98/06/07 17:20.40  [    2112] [     520]  24.62%  -lh5- (16)
...

Using this, I’d like to see if someone else has given a go at decompressing .PAC files.

Plugging in Data Pack for DOS v0.10 into Google brings up a rather bemusing thread from foone losing their mind at unpacking a version 1.3 of the tool used in Azumanga Donjyara Daioh. I find it rather funny that the developers there also left a LOG file in the output of another game. Anywho, they suggest pulling the decompression routine out of the executable directly via Ghidra, which is an idea.

Searching for terms like “Technosoft OOTUKA” or “Technosoft PAC” files surfaced an old romhack utility called Thunder Force V PAC Tool. Thunder Force V (TFV) was another game developed by Technosoft around the same time, so hopefully I could run this and decompress our .PAC files:

Would you look at that - seems that Thunder Force 5 shared the same DataPack PAC processing as our game. Arguably we could call this project there, and just utilise our .exe for decompression (and compression, even) but I was curious if there was any documentation out there for how the compression actually works.

Unfortunately a dozen vigourous internet searches yielded very little in terms of documentation. The upside is that the author is still active in the Playstation and Sega MegaDrive communities, even maintaining the PSX loader for Ghidra I’ve used and abused a ton - neat!

So, for the sake of satisfying my curiousity I decided to continue down the path of figuring out how these .PAC files are decompressed, the TF5PAC.exe becomes another handy tool to verify things as I work through them.

File Layout

Our .PAC file starts with a 4 byte number describing the number of entries, followed by that number of 4 byte pointers to other places within the file.

ExampleSizeDescription
0x100000004 bytesHow many entries in this file
0x600000004 bytes * ENTRY_COUNTPointer to an entry in file

The entries are a little tricky - the pointer doesn’t actually point to the start of the entry, rather, it’s pointing to a pointer to the next entry. I’m not sure why there are two levels of pointer, but I imagine it’s to do with how these were intended to be read by the game executable (as it is unlikely the files were extracted into memory in their entirety)

ExampleSizeDescription
0xA5AEA49F 0x72706EB4 0xA9AD000012 bytesObfuscated Filename
0x202000004 bytesRaw Size
0x340100004 bytesCompressed Size
0x189EA2364 bytesModified Time?
0x030002004 bytesFlag?
0x440100004 bytesPointer to next Entry
0x030000004 bytesExtra A (always 3)
0x010000004 bytesExtra B
0x000000004 bytesExtra C (maybe an index for sequences of files, sej01.tx = 1, sej02.txt = 2 etc.)
COMPRESSED_SIZE bytesCompressed Data
X bytesPadding

A few things to observe in this block:

  • Funnily enough, it seems filenames are shifted slightly to obfuscate them. The TF5 decompressor shows that if we subtract 0x40 from each ASCII character, then they seem to become legible. This isn’t done in the PSX executable.
  • Names are 00 padded if they’re shorter than the 12 bytes allocated.
  • Flags are always 0x03000200
  • The PSX executable, nor the TF5 decompressor identify the Modified time block in any manner, but based off of the value for syoubai.tim being 897207640, and converting it to unix time returns 7th of June, 1998 at 17:20:40 which is the time for this file in the LOG.
  • Deceptively, the pointers in this block aren’t offset like the ones in our header are. Those are offset by 0x1C, wheras the ones in the entry are actually the start of the next header entry (not their pointer block). I feel too dumb to intuit why that may be.
  • Entries are padded to fit on the 4 byte boundary. Seems to be 0x30, 0x31 etc. depending on the number of bytes to pad
  • I’m not fully sure what the extra fields are doing - one seems to say 0x03 every entry though, and the other is an index that matches the filenames index.
  • Amusingly, it seems these files contain a bunch of fragments of old memory from the machine they were built on. I’m pretty sure this is because these files sizes are padded up to fit on CD sector boundaries, so every now and then you can actually see symbols from the old PS1 SDK.

If we happen to decode another Technosoft game down the line, here’s an ImHex pattern we can plug in:

fn decode_name(ref auto obfName) {
    str result;
    for (u8 i = 0, i < 12, i+= 1) {
        if (obfName[i] >= 0x41) {
            result += char(obfName[i] - 0x40);
        }
    }
    return result;
};

struct FileEntry {
    u8 name[12][[format("decode_name")]];
    u32 raw_size;
    u32 comp_size;
    u32 mtime;
    u32 flag;
    u32 ptr;
    u32 extra_a;
    u32 extra_b;
    u32 extra_c;
    u8 payload[comp_size];
    padding[ptr - 0x10 - comp_size];
};

struct PACFile {
    u32 entry_count;
    u32 entry_pointers[entry_count];
    FileEntry entries[entry_count];
};

PACFile pacFile @ 0x0;

Parsing

Lets write some code for what we have got so far. We’ll load the file up and read the entries and pointers. Since we have a LOG we’ll use what it’s telling - BTL_T13.PAC.

import { ByteReader } from "./reader.ts";
import fs from "node:fs";

const pac = fs.readFileSync("BTL_T13.PAC");
const buffer = pac.buffer;
const reader = new ByteReader(buffer);

const entryCount = reader.readWord();

const offsetTable = [];
for(let i = 0; i < entryCount; i++) {
  offsetTable.push(reader.readWord());
}

We then can use this offsetTable an iterate over each of our entries and at least parse their metadata as per the entry header described above:

const files = [];
for(let i = 0; i < offsetTable.length; i++) {
  const offset = offsetTable[i];
  const entryStart = offset - 0x1c; // as per above, pointers are not the first byte of the table.
  const entryEnd = i + 1 < offsetTable.length ? offsetTable[i + 1] : buffer.byteLength;
  files.push({ start: entryStart, end: entryEnd });
}

Then, for each of our blocks, we can slice our original buffer, instantiate a new byte reader, and parse their headers. I’ve added a new function to our ByteReader called take which will ‘take’ n bytes and return them as a Uint8Array. This’ll come in handy when parsing the filename:

function decodeFilename(bytes: Uint8Array) {
  const out = [];
  for(const byte of bytes) {
    out.push(byte < 0x41 ? 0 : byte - 0x40);
  }
  // remove any 0s from the end
  while(out.length && out[out.length - 1] === 0) {
    out.pop();
  }

  return String.fromCharCode(...out);
}

for (let i = 0; i < files.length; i++) {
  const file = files[i];
  const subBuffer = buffer.slice(file.start, file.end);
  const entryReader = new ByteReader(subBuffer);

  // Filename, 12 bytes.
  const filenameBytes = entryReader.take(12);
  const name = decodeFilename(filenameBytes);

  // Standard metadata
  const rawSize = entryReader.readWord();
  const compressedSize = entryReader.readWord();
  const modifiedTime = entryReader.readWord();
  const flag = entryReader.readWord();
  const pointer = entryReader.readWord();
  const extraA = entryReader.readWord();
  const extraB = entryReader.readWord();
  const extraC = entryReader.readWord();

  const compressedBytes = entryReader.take(compressedSize);

  const fileData = {
    filename: name,
    rawSize: rawSize,
    compressedSize: compressedSize,
    modifiedTime: new Date(modifiedTime * 1000),
    flag,
    pointer,
    extraA,
    extraB,
    extraC,
    compressedBytes,
  };
}

Outputting this to our log, we get the following:

[
  {
  filename: 'itemcom.txt',
  rawSize: 11498,
  compressedSize: 2990,
  modifiedTime: 1999-02-07T02:46:10.000Z,
  flag: 131075,
  pointer: 3008,
  extraA: 3,
  extraB: 917508,
  extraC: 0,
  compressedBytes: <Uint8Array>
},
{
  filename: 'syoubai.tim',
  rawSize: 1088,
  compressedSize: 517,
  modifiedTime: 1998-06-07T08:20:40.000Z,
  flag: 131075,
  pointer: 536,
  extraA: 3,
  extraB: 1,
  extraC: 0,
  compressedBytes: <Uint8Array>
},
{
  filename: 'miss.tim',
  rawSize: 256,
  compressedSize: 138,
  modifiedTime: 1998-10-08T01:31:58.000Z,
  flag: 131075,
  pointer: 156,
  extraA: 3,
  extraB: 1,
  extraC: 0,
  compressedBytes: <Uint8Array>
}
...
]

Now we get to the fun part.

Decompression

At this point, we have the individual file components, but their data is compressed. We can extract the original code from the executable, and plug their decompression routines into some code like foone did (threatend?) above, and slowly modify it into something more readable.

For a bit of background, the TF5PAC tool describes that TF5 used a modified LZ77 compression format. LZ77 is the grandpappy of most of the modern compression formats we have today - DEFLATE (behind .zip files) and PNG both can link themselves back to the original algoirthm.

The core concept is that data tends to be repeating, so what if instead of storing the repated text, we just point backwards in the file to where we saw it last:

the cat sat on the mat, the cat sat on the chair

Could become

the cat sat on the mat, (24, 19) chair

Where the first number is how far back we go, and the second how many characters we copy accross. This leads to a slightly smaller string in this case, but over thousands of bytes you can get some nice reduction in size - this lookback is in essence how LZ77 works.

In our case, we’re dealing with something more akin to LZSS - LZ77 can in some implementations just replace everything with lookbacks, LZSS is a small modification where it’ll not convert something into a lookback if it’ll end up being larger than the data it’s compressing.

When doing this sort of stuff, I tend to lean into seeing if there’s any prior work that we can work off elsewhere (if you hadn’t gathered yet) so I was looking through various forums and Discord channels for LZ77 / LZSS discussions on the PS1 specifically, and one name kept appearing over and over again: Haruhiko Okumura.

Okumura, in the late 80’s, released a bunch of ‘reference’ implementations of LZx algorithms, one of which being LZSS.c. Going deep on compression algorithms is a recipe for turning this post into a textbook, so I’ll defer to this writeup on entropymine.

But, the pieces I want to highlight are the idea of this interwoven bit buffer and how pulling pieces from it branch in our decompression routine. We basically take 8 bits, and then pop one off whenever we need to know what to do next. When it’s empty, we then take another 8 and continue until we finish the file. This implementation also keeps track of a 4096 bit window for referencing back.

Turns out our .PAC file is doing something very similar, but with different window sizes. Instead of 8 bits, we’re taking 16, and instead of 4096 bits lookback buffer we have 8192 bits. Conveniently this is what that mysterious -lh5- flag on our LOG uses too - -lh5- is probably referring to a variant of lzh which is LZSS accompanied by another technique called Huffman Coding to improve compression. Maybe OOTUKA pulled out the LZSS parts for DataPack, and felt the overhead of Huffmans was too much, so just took the idea of extended windows and bit buffers.

Extracting PAC files

With all these pieces in place, lets implement our .PAC decompression with our other code. There is a bit of bit trickery going on in here that can take a bit to get your head around, but I’ll do my best to explain as we go.

We’ll lay the ground work for our function first:

function decompress(compressedBytes: Uint8Array) {
  const result: number[] = [];

  // Setup our flag bits.
  // This code is essentially equivalent fo `reader.getHalf()` but since we're
  // operating on the Uint8Array, we'll do it the old fashioned way.
  let bitBuffer = compressedBytes[0] | (compressedBytes[1] << 8);
  let position = 2; // ^ since we just consumed two up there
  let bitsLeft = 16;

  // ...
}

Next we’ll write out a helper that will pop a bit off of our flag bits, and handle refilling it when we need it:

const takeBit = () => {
  const bit = bitBuffer & 1; // take the last bit
  bitBuffer >>>= 1; // shift all our bits to the right
  bitsLeft -= 1; // reduce the count

  if (bitsLeft === 0) {
    // Same as above
    bitBuffer =
      compressedBytes[position] | (compressedBytes[position + 1] << 8);
    position += 2;
    bitsLeft = 16;
  }
  return bit;
}

Next, we’ll write another helper - this one will copy bytes we’ve seen before and place them at the current position in our output. We’re looking back at the uncompressed data to copy (the data as it were before we compressed it), so this may look a little confusing at first glance:

const lookback = (distance: number, length: number) {
  for (let i = 0; i < length; i++) {
    result.push(result[result.length - distance]);
  }
}

Now we’re ready to begin the actual decompression step. In our case, there are three different actions we’re going to do based on the flag bits we have in front of us. The flow will go like this:

while true:
  if the next bit is 1:
    copy directly from compressedData into result

  if the next bit is 0: (0 0)
    this is a 'short match' between 2-5 bits in length, and up to 255 bits back

  if the next bit is 1: (0 1)
    this is a 'long match', up to 3 to 9~256 bytes in length depending on the next byte
    however, if this byte is 0, then we're at the end, so bail out.

Direct match is easiest, we’ll start with that:

while (true) {

  if(takeBit() === 1) {
    result.push(compressedBytes[position]);
    position +=1;
    continue;
  }

  // ...
}

Then it’ll be the short match. This is if we’ve seen 0 and now a second 0 in order. This tells us then to take another two bits off of the flag bits buffer and use it to calculate the length we’re going to copy. We’ll then take one byte from the compressedBytes array, and that will determine how far back we go.

Lastly, we’ll add 2 to the length because that’s what the source did, and it makes sense - no point on compressing a single byte.

  if(takeBit() === 0) {
    // We're taking the next flag (which is at the back) and treating it as the 2s column
    // the next as a 1s column, leaving us with 3. Add 2, 'cause theres no point compressing
    // a single byte, right?
    const length = (takeBit() << 1) + nextBit() + 2;
    // Then, we read a byte off of our compressed bytes stream to tell us how
    // far back to go.
    const distance = compressedBytes[position];
    // And copy it to our output
    lookback(distance, length);
    continue;
  }

The astute among you may have just realised that we’ve assembled this binary number into the opposite way to everything else. It seems foone also ran into something similar with their Data Pack version, these flags are evaluated in a big-endian manner. Feels a little odd.

Either way, lastly need to deal with the extended lookback. When parsing this, we go straight to the compressedData to fetch the information we need by consuming two bytes. We take the first 13 bits to determine how far back we look, and the remaining three bits will tell us the length to copy - however - 3 bits is tiny (max 9 bytes of data).

Turns out that if our 3 bits evaluate to 0, then we grab a third byte, and use that to branch out to whats next:

  • If our extended byte is 0, then we’ve reached the end of the file.
  • If our extended byte is 1, then we do nothing
  • If our extended byte is >2, then we compy that amount + 1

We skip 1 in this case, because it would result in using 2 bits + 3 bytes to compress two bytes (1 byte + 1), which is pointless, and so we skip.

With that in mind, we reach the final branch of our decompression function. We’ve consumed two bits already by this point, so we’re just operating on the byte array from this point.

const firstByte = compressedBytes[position];
const secondByte = compressedBytes[position + 1];
position += 2;

// Take the 13 bits to determine distance
// Remember, a byte is 8 bits, and we need 13 here. 0xf8 is 11111000 in binary,
// and so we keep the top 5 values, and then shift them down 5 bits to the left,
// leaving us 13 bits.
const distance = firstByte | ((secondByte & 0xf8) << 5)
// Then, we want those 3 bits at the end to tell us what to do next - 0x07 is 11100000 in binary
const inlineFlag = secondByte & 0x07;

if (inlineFlag > 0) {
  lookback(distance, inlineFlag + 2); // taken from the source, results in 3..9
  continue;
}

// otherwise, we pull another byte and work out what to do:
const extended = compressedBytes[position];
position += 1;

// If another 0 appears, we're done.
if(extendedLength === 0) {
  return { data: Uint8Array.from(result), size: position };
}

// If it's 1, then skip it. In the source this is handled by != 1, but for the
// sake of clarity
if (extendedLength === 1) {
  continue;
}

// Otherwise it's a long copy
lookback(distance, extendedLength + 1);

With that, we can now extract compressed .PAC files, however the format does allow for non-compressed files to be stored within a PAC too, so we can swap between direct bytes copying and this decompress function by checking to see if compressedSize < rawSize

let data;
if(compressedSize < rawSize) {
  data = decompress(compressedBytes);
} else {
  data = compressedBytes.slice(0, rawSize);
}

Duplicates

Loading up the export of TF5PAC to test our script against theirs, EVENT1.PAC shows something a little suspicious when viewed in a file diff tool:

It appears that there’s an extra file in the output that we don’t have, and the one we do have seems to have a difference in bytes. If we just extract the filenames out of the .PAC, we can check to see how many times haiiro.tim appears:

The 25th entry, and the 117th entry confirm that PAC files can contain duplicate filenames, but what is different about them?

It’s a little hard to see in this view, but if we load it up into a viewer, the difference is a little clearer:

So, the difference between the files is that they seem to reference different coordinates within the frame buffer - while .TIM files are simple 2D textures, it’s also useful to consider them as something akin to a VRAM patch file. Loading one up is loading their CLUT and Pixel data into pre-defined positions within the buffer, so having multiple in a PAC makes sense in the regard that you may need to shift things around depending on what else is being loaded in.

In any case, this means our script should consider that the same file will appear twice within a PAC, and so we shouldn’t clobber the existing file with the same name - this can be solved by just appending a number to the end of name.

Note that when it comes to recompressing .PAC files, we’ll have to remember to revert this change, which is probably something contained within the .BIN file that TF5PAC also outputs as part of its work. But I’ve abosolved myself of that issue for now by just focusing on extraction.

Exploring the output

With that all done, we’re ready to go through and decompress all our .PAC files:

Files

There’s actually a ton of files here.

> find ./ -type f | wc -l
16235

…over 16 thousand to be exact?!

> fdupes -r -m ./
12252 duplicate files (in 1693 sets), occupying 12.9 megabytes

It appears that 1693 of our files are duplicated ~12,000 times, and there are another ~2000 unique files (giving us a number close to our 16,000).

This makes sense, especially when you consider how CDs work - aligning our items one after another for cuts down time we have to seek for it by moving the laser elsewhere. When it’s all no more than a couple hundred kBs, no harm in getting duplicating to get that performance boost.

Images

Many of our files are the aforementioned .TIM images. Here’s the Technosoft splash taken from the output, viewed in a TIM viewer:

You could spend hours looking through them, to be honest - from textures used on the 3d models, through to this:

This roughly translates to “Scheduled for release in February 1999, please enjoy the rest of the game in the full version.” - seems more than just the TGS flags ended up in the full production version of the game.

.txt files

We also have hundreds of .txt files with ShiftJIS encoding. It seems like these contain the in-game text, and probably where we can begin to translate (if one so desired).

The format seems to be new-line seperated text, and maybe switches between even and odd lines for female and male playable characters, using @ as the substitution marker:

お姉さん美人でござるな@これほどの美人は他にいないでござる // female
お兄さんカッコイイでござるよ@アイドルに間違われるであろう? // male

Some of them also come with the header:

*****************************************************************************
*     This file is created by awk
*       Created from [CUS12.CSV]
*****************************************************************************
*                                        Programed by S.Nagata
*****************************************************************************

It’s pretty cool to see people leave their name in their code like this - OOTUKA in the PAC output, Nagata here - the executable makes mention of a YUMIMINT and their audio library. None of these names appear in the credits under programmer, so feels a bit like archaeology digging through the pieces to get more of an idea of who worked at Technosoft in the 90s.

(Translating names is pretty difficult Japanese for me, but you can find a list here if you’d like to google yourself)

Actually, on names, it appears Technosoft used handles every now and then for games credits - YUMIMINT is probably the same CYBER YUMIMINT in the Thunder Force 5 credits. It’s an interesting note that they didn’t in this case.

In terms of odd files, there is one - STAFSCRL.txt:

*「おみせde店主」
*スタッフ(仮)
*テスト0:NAME
*テスト1:
*NAME
NAME
*テスト2:
*NAME
NAME
NAME
*テスト3:
*NAME
NAME
*1999 TechnoSoft
*つづく・・・
*終了!
E

This was probably a test program for the credits scene, but inevitably never used - the credits image above was pieced together by extracting the .TIM images from the epilogue scenes.

ZTCDATA.OT

When we first opened the CD, I mentioned that ZTCDATA.OT may have been media or something, and then promptly never mentioned it again.

Now that I know more about how the game is put together, I’m inclined to believe that this isn’t media, but another junk file to position the game on the disk predictably. I came to this conclusion because the file is never refereneced directly in the executable (OT.IX and OT.IM are), and from what I can gather, neither is its sector. Not to mention, most of the cutscenes are done in-game, rather than pre-rendered video, and we’ve discovered most of the audio already.

I’m a little bemused that this game shipped with 600mb of junk data, and I know that is just how it worked back then.

Other Loose Ends

The full output contains more than just .TIM and .txt files:

> find ./ -type f | sed -e '/.*\/[^\/]*\.[^\/]*$/!s/.*/(none)/' -e 's/.*\.//' | LC_COLLATE=C sort | uniq -c
 119 000
  72 001
  59 002
  50 003
  38 004
  28 005
  21 006
  17 007
  13 008
   6 009
   5 010
   4 011
   3 012
   1 013
   1 014
   1 015
 105 evp
  69 hit
   2 moc
  27 nrs
   8 ovr
   1 pac
  27 plb
2838 pmc
   3 pmd
   1 sdf
  47 sep
12028 tim
 684 txt
   2 vb
   2 vh

A few of these are pretty standard things to see on an PS1 game - .pmd are 3D models, .tim we’ve mentioned are 2D sprites. .sep, .vh and .vb are all audio files. These are pretty well understood in the community, and there tend to be tools and writeups up the wazoo about all of them. .ovr are pretty stanard too - they’re Overlays, pieces of game code not within the main executable that are loaded at runtime to save on memory usage.

Looking into the 000 -> 015 files that appear in the AREVE folders. They seem to be tied to the .evp files, maybe they’re parameters for the event system?

The most glaring outlier here are those .pmc files. Guessing by their name, and the fact that Omise is actually a 3D game, I’m thinking these are the 3D models, just in an internal format. They don’t look like a .pmd nor .tmd the normal PSX formats, but I don’t really have a lot of experience with them, so I leave that as an exercise to the reader. The .hit files look like they contain vector positions and sizes, so maybe they’re the .pmc hitboxes?

Either way, there are plenty more secrets for the discerning reader to look into, but for now I think we can leave it there.

Wrapping up

Omise de Tensyu ended up being one of the last games Technosoft released, as rumours spread in late 1999 that the company was exiting game development. By early 2000 they were seen selling their computer equipment, supposedly to recoup some costs, and in 2001 it was official as Technosoft declared bankruptcy.

The Technosoft brand was sold to a pachinko company, Twenty One, who would continue to re-release various games - Omise for example was re-released on PS3 and PSP in 2010. These days, Technosoft’s game IP is owned by Sega, who were still selling Omise on the Playstation Network as of 2016. Earlier this year, Technosoft Co. Ltd. was deregistered.

A number of staff members went off to form Ganbarion. During my research, it appears our good frend OOTUKA (who is probably Haruhiko Otsuka), went on to work for Ganbarion after Technosoft, and Ganbarion eventually went on to make Azumanga Donjara Daioh. Woo, full circle.

Code

I’ve wrapped up the implementation we’ve made in this project, and put it up on Github with a few changes here and there. Note that you will require your own copy of the game files to get use out of it.

Omise de Tensyu and Thunder Force V are likely not the only games that use the .PAC format in some way. I have a copy of My Garden which was also released by Technosoft in 1999, and I can confirm that there are PAC files that can be extracted in the same way we describe here (but haven’t dug further than that).

With that knowledge, there were a frew more games between 1997 (when TFV was released) and 1999 that could use similar formats. I’ll chuck them out just in case a drive-by Google search pops this post up:

Technosoft also did a couple games in conjunction with Bandai, so these may also include some too:

Thanks

If you’ve made it this far, thanks! It was a fairly long one. I wasn’t joking when I said I like to pull these games apart for emoji content, but it actually turned into a rather fun puzzle. I personally think projects like these are like the programmers sudoku - you’re trying to fill in gaps so that the numbers line up with one another. I’d say in this case, Omise was a relatively straightforward project, considering I didn’t have to pour over lines and lines of assembly output, but it still took ~3 months on-and-off again research to put together.

I’d like to shout out to lab313ru who (from what I gather) wrote TF5PAC. Without their tool, many things would have been too hard for my monkey brain.

If you’re interested, I’ll never stop plugging Hilltop - I actually met them once for lunch after TGS one year, and he’s a very cool dude - but his videos give a very good starter for getting into this stuff.

I’d also recommend jumping on the PSX.dev Discord. More than once I utilised the search bar on this to hunt for information, but also if you’re interested in PSX homebrew, there is a decent community here for it.

Lastly, as a complete tangent, I’d like to highlight meowandmeow.info. I stumbled across this site during a deep dive on the games listed in the Shitty Games book, and it’s exactly the sort of niche site I love. They’ve not been active as of late, but there’s still a lot of content to dig through in the meantime.

Here’s hoping I don’t take three years to do another writeup - Until next time!