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.
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:
- An
ID
block which tells you that you’re dealing with aTIM
file. - A
FLAG
section which explains what sort ofTIM
image we’re dealing with, - An optional
CLUT
or Colour Look Up Table, which defines the colours of our image. TheFLAG
section will describe whether or not this exists on our image. Pixel
blocks, which make up our image.
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:
ID
must equal0x10
Version
must equal0x00
Reserved
Space must be all0x00
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
or Pixel Mode, which defines what shape ourPixel
data is in.cf
or CLUT flag. This will be1
if we have a look up table, or0
otherwiseReserved Space
which again, should all be empty
pmode
is a number between 0 and 4
- 0 means
4 bit CLUT
- 1 means
8 bit CLUT
- 2 means
15 bit Direct
- 3 means
24 bit Direct
- 4 means
Mixed
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
bnum
which is the size in bytes of the CLUT Block (including itself)dx
anddy
which are the locations in VRAM where this image will be stored on the PSXw
andh
which are the width and height of our image- Some number of
CLUT
entries.
A CLUT entry is built of
R
,G
andB
components, each five bits each. This is different to the common, 8-bit colours we have today.STP
which is a flag that recommends certain transparency rules
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
.
- if
R
,G
,B
are0, 0, 0
andSTP
is0
, then it is transparent - if
R
,G
orB
have a value, andSTP
is0
then it is not transparent - if
R
,G
orB
have a value, andSTP
is1
then it is semi transparent - if
R
,G
andB
are0, 0, 0
andSTP
is1
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 };
}
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:
0x0C02
becomes0x20C
in little endian, which comes to524
bytesdx
anddy
are both0
w
is 8, but since we’re using a 4-bit CLUT, it’s actually 32 pixelsh
is 32 pixels
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!
- Create a
canvas
element - Go through the
PixelData
, and depending on thepmode
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;
}
- Also, don’t forget the rules for our
STP
flag. I interpreted “semi-transparent” to be an alpha value of128
. - Draw a 1 x 1 square using
fillRect
with said colour - ???
- 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!