Jack Robinson | Blog

Parsing PSX TIM Images with Typescript

Recently, I’ve become enamoured with reverse engineering games from the original Playstation (PSX). Japan, compared to back home, has an absolute abundance of games available for older consoles, often for as little as 500 yen ($5.76 NZD). Heading into a second hand shop becomes a bit of an experience picking up games I can barely read the title of, and buying odd ones that never saw a western release - Like this, a simulator for running convenience stores:

Many of these games ran with formats specific to the Playstation, or even the particular developer. A lot of the time spent Reverse Engineering some of these games is just decoding what these files are. What this leads to is a wealth of homebrew tooling to parse files for formats, or specific games.

A common issue I’ve run into, running one of the newer Macbook models, is the lack of tools for not only MacOS, but arm64 in general. Many of the tools are written for Windows, and often are at least 10 years old and closed-source.

In order to get a little more familiar with working at this lower level, I decided to see if I could write a parser for the TIM 2D texture format to something you could run in your browser.

Disclaimer: Reverse Engineering is a subject fraught with a lot of grey legalities, so please use common sense when practicing. If you have to ask yourself "could this be illegal" the answer is probably yes.

TIM Format

The TIM format is pretty well defined on the internet. Youtube user Hilltop helpfully explains the format in depth, and even goes through a small example. Through a few google searches, it’s also possible to find the original PSX developer documentation - but I’m uncertain whether or not it’s still protected (so I won’t link it).

To summarise, the TIM format has four sections:

Project Setup

I’m going to be writing this in Typescript, and attempt to only use browser-supported API to make it as portable as possible.

This makes the code setup pretty easy:

mkdir tim-parser
cd tim-parser
yarn init -y
yarn add -D typescript
touch index.html
mkdir src
cd src
touch index.ts

Create a tsconfig.json at the base and we’ll add the following basic details:

{
  "include": ["src/**/*.ts"],
  "compilerOptions": {
    "lib": ["ESNext", "dom"],
    "outDir": "dist/",

    "alwaysStrict": true,
    "allowUnreachableCode": false,
    "noImplicitAny": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

In index.ts we’ll create a parse function that takes in a File object, and returns the filename for now (to keep tsc happy).

export function parse(file: File) {
  return file.name;
}

In our index.html add the skeleton of a page:

<!DOCTYPE html>
<html>
  <head> </head>
  <body>
    <input type="file" id="file-upload" />
    <script type="module">
      import { parse } from "./index.js";

      const fileElement = document.getElementById("file-upload");
      fileElement.addEventListener("change", (event) => {
        const files = event.target.files;
        if (files.length) {
          console.log(parse(files[0]));
        }
      });
    </script>
  </body>
</html>

In the package.json add the following:

"scripts": {
  "build": "tsc",
  "postbuild": "cp index.html dist/index.html"
}

Then, whenever we run yarn build, our code should be spat out in a dist/ folder that should have an index.js and a index.html file included.

Since Chrome (and other browsers) don’t like running local JS files, you’ll have to run it in a little http server. The easiest way is using npx and http-server.

npx http-server dist/

Experts are free to modify this as they wish to just run it from the NodeJS commandline.

Example File

The file we’ll be parsing is this one here. Note, when you go to save it you should use a Hex Editor (I use Hex Fiend) to save it, otherwise your example will be chock full of whitespace.

Reader

The original Playstation is a 32-bit, little endian MIPS system. This means that a block of hex 0x10 0x00 0x00 0x00 is read in bytes right to left - 16 not 268435456.

The common building blocks of the Playstation memory is this notion of “Words” - 4 bytes of information (hence why the example file is formatted in such a way). In order to make our job easier, we are going to wrap reading our binary centered around words (4 bytes), half words (2 bytes) and single bytes, while also ensuring that we’re always reading these values as little endian numbers.

TypedArrays are the low-level JS APIs for using buffers on the web (not to be confused with the NodeJS Buffer type). Pulling values from them, the output will be read as the native byte order - which in the majority of systems is little-endian - but the engineer in me tingles at that statement.

Thankfully, there’s an ever lower-level pair of interfaces we can use - ArrayBuffer and DataView. The ArrayBuffer is our raw data, while the DataView allows us to control how we read it. This means, if for whatever reason we’re running on a big-endian system, we’ll still be able to tell our code to read it as little-endian values. Overkill? Probably.

Code

The intent here is to create a stateful reader, which will track where we are in our file. It will take in an ArrayBuffer and create a DataView internally. We’ll also implement our readWord, readHalf and readByte methods, which will return the data we’re interested in, and move the position forward. This means we don’t have to worry about keeping track where we are in the file. If we do need to know where we are in the file (spoiler alert: we will), we’ll add the position method to return our current byte position.

All DataView.get<X> functions take a second boolean argument that allows us to retrieve the value as a little-endian number. We’ll also refer to the values by their bit count - 32 bit is four bytes, 16 is two and 8 is one.

class ByteReader {
  private currentIndex: number;
  private dataView: DataView;

  constructor(data: ArrayBuffer) {
    this.currentIndex = 0;
    this.dataView = new DataView(data);
  }

  /**
   * Reads four bytes of information, and returns it as a uint32 number
   */
  readWord() {
    const result = this.dataView.getUint32(this.currentIndex, true);
    this.currentIndex += 4;
    return result;
  }

  /**
   * Reads two bytes of information, and returns it as a uint16 number
   */
  readHalf() {
    const result = this.dataView.getUint16(this.currentIndex, true);
    this.currentIndex += 2;
    return result;
  }

  /**
   * Reads a byte of information, and returns it as a uint8 number
   */
  readByte() {
    const result = this.dataView.getUint8(this.currentIndex, true);
    this.currentIndex += 1;
    return result;
  }

  /**
   * Returns the current byte position of the reader
   */
  position() {
    return this.currentIndex;
  }
}

We’ll also have to make a change to our parse function. File has a method arrayBuffer which will return its underlying data. This function returns a Promise<ArrayBuffer> so we’ll convert parse to be an async function.

export async function parse(file: File) {
  const data = await file.arrayBuffer();
  const reader = new ByteReader(data);
}

ID Block

Now we have the pieces in place to begin parsing our file. The first block, according to the above order is the ID block:

ID Block

Reserved

(bits 16-31)

Version

(bits 8-15)

ID

(bits 0-7)

(Remember little endian, the least significant bit is the rightmost)

In the spec, there are some constant values that are expected in this block:

With that knowledge, we can define our type, TimId and our function parseId.

interface TimId {
  id: number;
  version: number;
  reservedSpace: number;
}

function parseId(reader: ByteReader): TimId {
  // bits 0-7
  const id = reader.readByte();
  if (id !== 0x10) {
    throw new Error(
      `parsed ID does not match TIM spec. expected 0x10, got ${id}`
    );
  }

  // bits 8-15
  const version = reader.readByte();
  // bits 16-31
  const reservedSpace = reader.readHalf();

  return {
    id,
    version,
    reservedSpace,
  };
}

Doing a little pen-and-paper with our example image, we can take the first four bytes, and confirm that it matches the spec:

10000000 08000000 2C000000 0000E001
10000100 0080177F 92760E72 6A45E71C
F3397946 1F570080 00800080 00800080
FF7F1F00 0C020000 00000000 08002000
FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF
...

Neat!

Flags

The next block are the file Flags. The spec defines it as:

pmode is a number between 0 and 4

Flag Block

Reserved

(bits 5-31)

CF

pmode

(0-3)

The important thing here is that pmode is not a nice round number - it’s too big to be read with getByte and too small to be got with getHalf - it’s 1.5 bytes.

To fetch the bit information out of our number, we’re going to have to implement a new function, retrieveBits

function retrieveBits(input: number, index: number, bits: number) {
  const mask = ((1 << bits) - 1) << index;
  return (input & mask) >> index;
}

The gist of this function is, given an input number, give me bits bits from index, right to left (zero-indexed).

It’s at this point my head was a little fuzzy, so I think it might be good to look at our example image, work through it pen-and-paper, and then write the code.

The Flag block is 0x08 0x00 0x00 0x00 which when read as a little endian number, is 8. 8 in binary is 1000. What we’re doing is taking the rightmost 3 bits (retrieveBits(input, 0, 3)) as our pmode - in this case this is helpfully 0, so it’s a 4-bit CLUT image. Next, we take that last bit (retrieveBits(input, 3, 1)) 1 to be our CLUT Flag.

Given that, here’s TimFlags parseFlags

enum PixelMode {
  CLUT4 = 0,
  CLUT8 = 1,
  Direct15 = 2,
  Direct24 = 3,
  Mixed = 4,
}

interface TimFlags {
  pmode: PixelMode;
  cf: number;
  reservedSpace: number;
}

function parseFlags(reader: ByteReader): TimFlags {
  const data = readWord();

  // bits 0-3
  const pmode = retrieveBits(data, 0, 3);
  // bit 4
  const cf = retrieveBits(data, 3, 1);

  // bits 5-31
  const reservedSpace = retrieveBits(data, 4, 28);

  return {
    pmode,
    cf,
    reservedSpace,
  };
}

Updating our example file:

10000000 08000000 2C000000 0000E001
10000100 0080177F 92760E72 6A45E71C
F3397946 1F570080 00800080 00800080
FF7F1F00 0C020000 00000000 08002000
FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF

CLUT

The third block is the Colour Look-Up Table (CLUT). This will exist on the image if flags.cf === 1. These take the form of 12 bytes describing what the CLUT will look like, followed by the individual CLUT entries.

CLUT Block

bnum
dy dx
h w
CLUT1 CLUT0
... ...
CLUTn CLUTn-1

CLUT Entry

STP

B

(bits 10-14)

G

(bits 5-9)

R

(bits 0-4)

The CLUT Block is built of

A CLUT entry is built of

The STP bit is pretty inconsequential when parsing, but when it comes to rendering it’s a bit funny, and depends on PSX settings, and the values of R, G and B.

  1. if R, G, B are 0, 0, 0 and STP is 0, then it is transparent
  2. if R, G or B have a value, and STP is 0 then it is not transparent
  3. if R, G or B have a value, and STP is 1 then it is semi transparent
  4. if R, G and B are 0, 0, 0 and STP is 1 then its non-transparent

3 changes depending if Translucent Processing is turned on, on the PSX. If it is off, then it is not transparent.

Again, not really important when parsing, but useful when it comes to rendering.

Code

With this knowledge, we can write our type and function. We’ll parse the header of the CLUT block, then loop for the remaining bytes to fetch out the colours - take note of our first use of reader.position().

interface Colour {
  r: number;
  g: number;
  b: number;
  stp: number;
}

interface TimClut {
  bnum: number;
  dx: number;
  dy: number;
  w: number;
  h: number;
  clut: Colour[];
}

function parseClut(reader: ByteReader): TimClut {
  const bnum = reader.readWord();

  const dx = reader.readHalf();
  const dy = reader.readHalf();

  const w = reader.readHalf();
  const h = reader.readHalf();

  // This is where reader.position() comes handy.
  // We've just parsed 12 bytes, so we can just subtract it from `bnum` to find
  // the end position of our loop.
  const endPosition = reader.position() + (bnum - 12);
  const clut: Colour[] = [];

  while (reader.position() < endPosition) {
    // Read the 2 bytes of colour information
    const block = reader.readHalf();

    const r = retrieveBits(data, 0, 5);
    const g = retrieveBits(data, 5, 5);
    const b = retrieveBits(data, 10, 5);
    const stp = retrieveBits(data, 15, 1);
    clut.push({ r, g, b, stp });
  }

  return { bnum, dx, dy, w, h, clut };
}
Hey, why do you parse it dx/w, then dy/h, compared to the spec which does it dy/h, then dx/w?

Nice catch!

The spec describes it in this reverse order, yes, but it's because of the way the PSX works with word blocks. If we were to parse this as a full four-byte word, then yes, I'd have to use retrieveBits on the right-most bits to retrieve dx and dy.

However, since I'm reading two bytes at a time, we're only considering two bytes of data - therefore the first two bytes are the rightmost bits if we read all four - confusing, I know.

Back to our example, we can apply what we know of CLUTs and work through our image.

10000000 08000000 2C000000 0000E001
10000100 0080177F 92760E72 6A45E71C
F3397946 1F570080 00800080 00800080
FF7F1F00 0C020000 00000000 08002000
FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF
...

The first 4 bytes are the bnum, so 0x2C000000 in little endian is 44, so our CLUT should be the next 11 bytes.

Then, the next byte should be our x and y coordinate. x is the first four bits, so thats 0 and y is the next four, which is 480. The w and h are next, with 16 and 1 respectively. This checks out, because a LUT doesn’t have to be any large image - just as long as we can get the colour value, it just needs to be tiny. This also tells us that this has 16 different colours indexed, so we should have 8 * 4 byte blocks left - which matches our original math of 44 bytes, minus the 12 byte header, leaves 32 bytes remaining.

10000000 08000000 2C000000 0000E001
10000100 0080177F 92760E72 6A45E71C
F3397946 1F570080 00800080 00800080
FF7F1F00 0C020000 00000000 08002000
FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF
...

Pixels

Pixels are the meat and potatoes of the TIM image, and define what colour pixel goes where. Like the CLUT they’re in two parts - a header or info block, and n pixel entries.

Where it differs though, is that the entries change depending on the value of the pmode flag parsed before.

Note, the Pixel entries are 2 byte blocks.

Pixel Block

bnum
dy dx
h w
DATA1 DATA0
... ...
DATAn DATAn-1

Data Entry (4-bit CLUT mode)

Pixel 3

(bits 12-15)

Pixel 2

(bits 8-11)

Pixel 1

(bits 4-7)

Pixel 0

(bits 0-3)

Data Entry (8-bit CLUT mode)

Pixel 1

(bits 8-15)

Pixel 0

(bits 0-7)

Data Entry (15-bit Direct)

STP

B

(bits 10-14)

G

(bits 5-9)

R

(bits 0-4)

Data Entry (24-bit Direct)

G0

(bits 8-15)

R0

(bits 0-7)

R1

(bits 8-15)

B0

(bits 0-7)

B1

(bits 8-15)

G1

(bits 0-7)

You’ll notice the Mixed pmode isn’t present here. That’s because the spec doesn’t explain what it looks like! It seems like some black magic rule that says “whatever goes,” so we’ll ignore it for now.

The other important difference is the value of the w field. If we’re operating in 4-bit CLUT or 8-bit CLUT the w is 1/4 or 1/2 of the real width. To fix it, we can just multiply this number by four or two when we need it.

Code

The code for this is rather similar to parsing CLUT - we find the bnum field, calculate the rest of the header, then loop until we’ve read that many bytes.

The main difference is to switch based on what sort of pmode we’re looking at.

type PixelEntry = number | Colour;

interface TimPixelData {
  bnum: number;
  dx: number;
  dy: number;
  w: number;
  h: number;
  data: PixelEntry[];
}

function parsePixelData(reader: ByteReader, pmode: PixelMode): TimPixelData {
  // As before, we parse out the header values
  const bnum = reader.readWord();

  const dx = reader.readHalf();
  const dy = reader.readHalf();
  const w = reader.readHalf();
  const h = reader.readHalf();

  // we find the end position
  const endPosition = reader.position() + (bnum - 12);

  const data: PixelData[] = [];

  while (reader.position() < endPosition) {
    switch (pixelMode) {
      case PixelMode.CLUT4:
        const four = reader.readByte();
        data.push(retrieveBits(four, 0, 4));
        data.push(retrieveBits(four, 4, 4));
        break;
      case PixelMode.CLUT8:
        const eight = reader.readHalf();
        data.push(retrieveBits(eight, 0, 8));
        data.push(retrieveBits(eight, 8, 8));
        break;
      case PixelMode.Direct15:
        const fifteen = reader.readWord();
        const r = retrieveBits(fifteen, 0, 5);
        const g = retrieveBits(fifteen, 15, 5);
        const b = retrieveBits(fifteen, 10, 5);
        const stp = retrieveBits(fifteen, 15, 1);

        data.push({
          r,
          g,
          b,
          stp,
        });

        break;
      case PixelMode.Direct24:
        const x = reader.readHalf();
        const y = reader.readHalf();
        const z = reader.readHalf();

        data.push({
          r: retrieveBits(x, 0, 8),
          g: retrieveBits(x, 8, 8),
          b: retrieveBits(y, 0, 8),
        });

        data.push({
          r: retrieveBits(y, 8, 8),
          g: retrieveBits(z, 0, 8),
          b: retrieveBits(z, 8, 8),
        });
        break;
      case PixelMode.Mixed:
        throw new Error("Mixed pixel mode not supported.");
    }
  }

  return {
    bnum,
    dx,
    dy,
    w,
    h,
    data,
  };
}

That one was a bit meaty. Working through our example:

10000000 08000000 2C000000 0000E001
10000100 0080177F 92760E72 6A45E71C
F3397946 1F570080 00800080 00800080
FF7F1F00 0C020000 00000000 08002000
FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF
...

Parsing the header will give us the following information:

Leaving us with 512 bytes of pixel information. I leave it up to the reader if they wish to go through each one.

10000000 08000000 2C000000 0000E001
10000100 0080177F 92760E72 6A45E71C
F3397946 1F570080 00800080 00800080
FF7F1F00 0C020000 00000000 08002000
FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF
...

Putting it all Together

Now we have all our pieces together, we can finish the parse function

interface TimImage {
  id: TimId;
  flags: TimFlags;
  clut?: TimClut;
  pixel: TimPixelData;
}

export async function parse(file: File) {
  const data = await file.arrayBuffer();
  const reader = new ByteReader(data);

  const id = parseId(reader);
  const flags = parseFlags(reader);

  if (flags.cf) {
    return {
      id,
      flags,
      clut: parseClut(reader),
      pixels: parsePixelData(reader, flags.pmode),
    };
  }

  return {
    id,
    flags,
    pixels: parsePixelData(reader, flags.pmode),
  };
}

Then, in our index.html, we’ll just console.log the output:

...
<script type="module">
  import { parse } from "./index.js";

  const fileElement = document.getElementById("file-upload");
  fileElement.addEventListener("change", (event) => {
    const files = event.target.files;
    if (files.length) {
      parse(files[0]).then((result) => console.log(result));
    }
  });
</script>
...

In the browser, open up our page, and open your developer tools console. Uploading a file should trigger the parse function, and console log out our TIM image! Wahay!

Viewing the TIM Image

I’m not going to go into depth on how you might render one of these images, but it is relatively straightforward - the hard part is over!

  1. Create a canvas element
  2. Go through the PixelData, and depending on the pmode find the colour of the current pixel. Remember that the CLUT is using RGB 555, so we need to convert it to RGBA. I use the following:
function convertColour(n: number) {
  return (n * 255) / 31;
}
  1. Also, don’t forget the rules for our STP flag. I interpreted “semi-transparent” to be an alpha value of 128.
  2. Draw a 1 x 1 square using fillRect with said colour
  3. ???
  4. Profit.

If it all works, our example image should spit this out:

Tiny, I know.

Conclusion

In this post, I described how you can parse a TIM file using the low-level ArrayBuffer and DataView Javascript interfaces. We then built a way to parse them using Typescript, only relying on web-based API, which means we should be able to use them in web projects and make them cross-platform out of the box.

If you’re interested in the full code, here’s the repository

Or, if you want to use it in your own projects, it’s up on NPM

yarn add @zombrodo/tim-parser

Thanks for reading!