May 25, 2026
This is the third of a series of posts detailing how I put interactive, 3D versions of Crazy Taxi’s levels onto the web. If you haven’t read the preceeding posts, I’d highly recommend it, since this builds off many of the ideas developed there.
By the end of Part 2, we’d covered quite a bit of ground: learning about how 3D models are stored as vertex attributes, how those attributes are represented in the GameCube’s Graphics Library (a.k.a. GX), and how Crazy Taxi’s .shp file format contains the GX Display Lists that describe how to draw a model. After all of this, we were only able to render one measly cube.
And we didn’t even cover everything need to do that! I skipped over how Jasper, noclip.website’s creator, reverse engineered the texture format, and didn’t really touch on how noclip turns GX DisplayLists into WebGL/WebGPU calls that can be rendered in the browser.
In this post, we’ll right some of those wrongs by exploring a much more complicated .shp model, and by the end of this we should be able to render any of Crazy Taxi’s 2,700+ models. And as for which model to target, for my money, there’s really only one choice:
Finding the hut
To be honest, rendering Crazy Taxi’s Pizza Hut was kinda priority number 1 for me when I got started with this project. I can’t exactly say why, but the idea of painstakingly recreating this egregious example of product placement was just really funny to me. Also, because Pizza Hut, KFC, FILA, and Tower Records models all got de-branded in later versions of the game due to licensing issues, it made this process a bizarre case of consumerist preservation.
As it turns out, finding out which .all archive contains the Pizza Hut was a pretty easy task: simply grep for “pizza” in our expanded archive directories:
$ grep -R -i pizza ct-extract/
Binary file ct-extract/poldc2_stream.all/course_dc3b_052k.shp matches
Binary file ct-extract/poldc1_stream.all/course_4b_048_a_ph.shp matches Looking for “pizza” in the strings output of these models gives us some more helpful output:
$ strings ct-extract/poldc2_stream.all/course_dc3b_052k.shp | grep -i pizza
TT_PizzaHut_log_01_tr.tex
$ strings ct-extract/poldc1_stream.all/course_4b_048_a_ph.shp | grep -i pizza
TT_PizzaHut_log_01_tr.tex So it looks like we have two .shp models that contain a Pizza Hutty logo (I’m assuming that’s what “log” is trying to indicate here). At first it might seem odd that there’s two Pizza Hut models, but if you’ve played the console version of Crazy Taxi, you might already know why that is.
When Crazy Taxi was originally ported from arcades to consoles, it touted both the original “Arcade Mode” map from arcade cabinets, as well as a new and much larger map which was confusingly named “Original Mode”. And both maps contain a Pizza Hut model with slightly different surroundings:
Either model would work just fine for our purposes, but let’s see if we can determine which model is which.
Original Recipe
So we know our Pizza Huts are in poldc1_stream.all and poldc2_stream.all, but after grepping around some more, I couldn’t find anything clearly indicating which one of these files corresponds to Arcade Mode vs. Original Mode. That said, we can definitely make some educated guesses. Here are all the archive files that start with “pol” (which I’m guessing is short for “polygon”):
polDC0.all
poldc1_stream.all
poldc1.all
poldc2_stream.all
poldc2.all
poldc3_stream.all
poldc3.all Being that there are 3 main maps in the GameCube version (Arcade, Original, and the Crazy Box collection), my guess was that the “dc1”, “dc2”, and “dc3” might refer to those, with “DC0” seemingly being the odd one out as it has no _stream companion. Maybe it’s used for shared models, or models used in the main menu?
And, since our Pizza Hut .shp files were in archive files referencing dc1 and dc2, I assumed that those might be our Arcade and Original maps.
But is dc1 the Arcade map, or is dc2? Well, I know the Original mode map features a fairly sprawling subway system, and only poldc2.all contains models with “train” in their names, so for now let’s assume that dc1 refers to Arcade, and dc2 refers to Original.
About those textures
Now that we’ve got a handle on which .shp represents the Arcade Mode’s Pizza Hut, I’d like to finally demystify how we parsed the textures in the previous post’s cube model. But first, how do we find a model’s textures?
Recall that our .shp file contains not only a model’s vertex attributes and GX Display Lists, but also a list of texture files at the end. Let’s take a look at the list for the Arcade version of Pizza Hut:
| Bits | uint | int | float |
|---|---|---|---|
| 8 | - | - | - |
| 16 | - | - | - |
| 32 | - | - | - |
Unsurprisingly, there’s quite a few more textures here than were in cube0.shp, and keen eyes might recognize white.tex, which was also present in cube0.shp. Actually, every single .shp file’s texture list starts with white.tex, and I never really figured why. Take a second to scan through this listing and see if you can see a pattern here.
As I mentioned in the first post, when you’re looking at lists of objects in binary, a good first step is to try and determine whether the items have a fixed size or a variable size. If each item starts with an integer-y value, or corresponds to a list of integers elsewhere in the file, it’s worth checking whether there’s that many bytes of data following it, and whether the pattern repeats from there. If not, there’s a decent chance the items are fixed sized.
In this case, the data starts with ASCII string data with no clear integer lengths in sight, so the texture names are probably fixed length. Counting ahead from the start of the white.tex to the start of the next string yields 0x2c bytes, and then looking another 0x2c bytes ahead, we see the start of another string. So that looks to be our block size!
Finally, whenever ASCII strings are stored in a file, unless they’re accompanied by a byte length you can usually assume that they’re null-terminated, i.e. the string’s end is indicated by a 00 byte. And sure enough, we do see a null byte at the end of each occurrence of .tex. But we’ve also got some random ASCII data strewn after these termination bytes, such as at offsets 0x7ca6 and 0x7cd4.
While this might be meaningful data, I think this is much more likely to be junk data similar to what we saw at the end of Part 2, Section 7. And since it always follows the terminating null byte, it’s very easy to simply discard this data.
After all of this, we’ve got an easy way to read off the full list of textures used by this model. So let’s try reading one of them.
Photo Shop
The first texture listed in our Pizza Hut model is called TT_shop_04.tex, which we can find in all three of the level texture archives: texDC1.all, texDC2.all, and texdc3.all. It’s possible these are all the same, but since we’re targeting the Arcade mode version, let’s pick the one from texDC1.all. Here’s the first chunk of data from the texture file:
| Bits | uint | int | float |
|---|---|---|---|
| 8 | - | - | - |
| 16 | - | - | - |
| 32 | - | - | - |
Now, since Jasper’s the one who figured out the .tex format, I’ll mostly be echoing his wisdom here, but if you’ve worked with textures a bit before, you might be able to figure out a bit of what’s going on here without too much difficulty.
That’s because texture formats are often just different rearrangements of the same basic types of data: the dimensions of the texture, maybe a flag or two describing the format of the pixel data, and some information about a somewhat complicated thing called a mipmap. Setting aside the last item for now, take a look at the binary data and see if you can make a guess as to the dimensions our texture. Hint: it’s usually a nice even power of 2.
Once we’ve got our dimensions, the next step is figuring out what the pixel data’s format is, and here we’ve got good news and bad news. The bad news is there’s a ton of pixel data formats out there, and some of the more common compressed ones (like Unity’s Crunch) are complicated and not well documented. The good news is GameCube’s GX API only uses a handful of formats, all of which are fairly well understood at this point. And notably, one of the more common GX formats called CMPR (for “compressed”) uses a flag value 0x0E, which just so happens to be what we see in TT_shop_04.tex at offset 0x0C!
But before we dig into how these pixels are encoded, let’s revisit mipmaps, since they’ll soon become quite relevant.
A little image goes a long way
Mipmaps are a fairly old and commonplace technique for storing smaller versions of the same texture alongside the original. Usually, these smaller copies are ones whose dimensions are reduced by increasing powers of 2, so in addition to our 64x64 texture above, its mipmap would also include copies at 32x32, 16x16, 8x8, and so on. Why? Well, it all comes down to nasty property of digital signals called aliasing.
Suppose you’re looking at a 3D scene of a black and white checkerboard floor. The tiles closest to your camera will look pretty normal, since the tiles are large enough that your screen’s pixels generally only need to be entirely white or entirely black. But because of perspective, as your eye looks farther into the distance, the tiles will become smaller. And since the pixels on your screen aren’t getting any smaller, eventually you’ll run into instances where a single pixel will need to somehow depict both a black and a white tile, meaning some of the checkerboard image’s data will necessarily have to be discarded.
As you can see in this image, this can lead to all sorts of artistically undesirable effects, such as jaggies or moiré patterns. These are due to a mathematically fascinating, but extremely obnoxious, phenomenon known as aliasing. Aliasing occurs whenever you sample (i.e. pick a pixel color) a signal (i.e. a texture) at a much lower frequency (i.e. resolution) than its original.
You’ve probably heard of anti-aliasing, which refers to a family of techniques for mitigating the aforementioned jaggies etc. Unfortunately, anti-aliasing techniques are generally more computationally expensive than simply telling the GPU to simply plop down the color of the nearest texel. So, instead of letting the shader do all the work all the time, developers will usually create pre-downsampled versions of their textures to be used when the image would be rendered small enough. And this, dear readers, is what a mipmap is.
A GPU can easily use its depth buffer to sample smaller mips for further pixels, letting us do a depth-aware form of anti-aliasing very cheaply. Here’s the same checkerboard example from above, but this time using mipmapping to prevent aliasing:
With all of this in mind, let’s see if we can make sense of our texture’s pixel data.
CMPR? I hardly KNWR!
So what does our texture data look like? Well, since it’s compressed using Nintendo’s CMPR format, it’s not super easy to visually inspect the values and look for RGB pixel data. But based on noclip’s implementation of CMPR, we can do some basic sanity checks here.
CMPR images are composed of 8-byte blocks, each of which represents a 4x4 grids of pixels in the image. A block starts with two colors encoded as RGB565 values, a two-byte format where 5 bits are used to encode a red value, 6 bits are used for green, and 5 are used for blue. Two more colors are produced by blending between the initial two, forming a table of 4 colors. The remaining 4 bytes is used to represent the 16 pixels, each being composed of two bits that represent which of the four colors is used for that pixel.
To illustrate this, I made a little widget that decodes each block of pixels individually. Select blocks in the list at the top-left, and it’ll show how the 8 bytes of that block turn into 16 pixels:
| Color | Source | Result |
|---|---|---|
| 0 | 11110 111101 11110 | #f7f7f7 |
| 1 | 11110 111101 11011 | #f7ebef |
| 2 | blend(color0, color1) | #f7f2f4 |
| 3 | blend(color0, color1) | #f7eff2 |
Since we know our image’s dimensions (64x64), we would expect our texture to have of these blocks. But recall that our texture is mipmapped, so there’s also a 32x32 copy of the texture, which works out to more blocks. Then a 16x16 copy, which is 16 blocks, then an 8x8 (4 blocks), a 4x4 (1 block), and finally the 2x2 and 1x1 mips which are allotted 1 block apiece.
All told, that’s 343 blocks, which at 8 bytes apiece yields 2,744 bytes total for this texture. But if you subtract the offset where TT_shop_04.tex’s pixel data starts (0x60) from where it ends (0xAFF), you get 2,720 bytes. We’re missing 24 bytes, or 3 CMPR blocks!
As it turns out, that’s equal to the number of blocks for the 4x4, 2x2, and 1x1 mips. So one might guess that 3 of our 6 mips are excluded from the texture data, and as it turns out, the u32 at offset 0x18 of TT_shop_04.tex happens to equal 3. Doing a similar block-count analysis on other CMPR textures reveals the same pattern, so let’s go ahead and name the u32 at 0x18 num_mips.
Now that we’ve squared away our textures, let’s figure out how to get the rest of this Pizza Hut drawn.
VAT hunting
Recall from Part 2 that the thing which actually renders stuff on GameCube is called a Display List (or DL), and it starts with an index into Vertex Attribute Table (or VAT). The VAT contains all the useful format information for the shape’s vertex positions, texture coordinates (aka uv coordinates), color data, etc. But crucially, the DLs only contain an index into the VAT, and not the VAT entry itself.
Since we didn’t have the VAT entry for cube0.shp in Part 2, we basically eyeballed the binary data for colors, positions, etc. until we figured out their various formats, allowing us to actually render the dang thing. That approach just isn’t going to scale to 2,700+ other models, so it’s high time we actually figured how those VAT entries are created.
And I won’t beat around the bush, this process sucked. I ended up spending hours in Ghidra, an open source reverse engineering toolkit, poring over the game’s executable trying to find something that resembled the VAT list. And although I won’t be detailing this exploration since it was mostly fruitless, I wanted to mention it because sometimes the process of reverse engineering has dead ends and leads that go nowhere, and that’s OK! During this process I still learned a lot, and ended up manually annotating a bunch of code that ended up being helpful later in the project.
All that said, I did find one promising lead in Ghidra: the GXFIFO register. While flailing about and basically decompiling functions at random, I found a bunch that referenced a named register called GXFIFO. I recognized GX as the codename for GameCube’s graphics system, and knew FIFO to mean “first-in, first-out”, which generally refers to a queue-like interface. Asking Jasper about it, he mentioned that reading/writing to the GXFIFO register is pretty much the only way to interface with the GameCube’s GPU pipeline. Display Lists, for example, are submitted to the GPU by writing them to GXFIFO. And indeed, when a game creates VAT entries, it does so by writing them there.
He also mentioned that if spelunking in Ghidra isn’t seeming fruitful, the GameCube emulator Dolphin has a great feature called the FIFO Player which records all data written to the GXFIFO register and lets you inspect them. This, as it turns out, was our ticket.
FIFO 2026 (is this anything)
As I alluded to earlier, virtually everything on the screen in a GameCube game is drawn by sending commands to the GXFIFO register. So, if you keep track of the data written to GXFIFO, along with some bookkeeping about the state of some other registers, you can effectively analyze and play back any number of frames without even having the game’s binary. This is exactly what Dolphin’s FIFO Player does, and despite having a few warts, it’s one of the most useful tools I used while reversing Crazy Taxi.
Here’s an example of the FIFO player analyzing a frame outside of our Pizza Hut:
In the screenshots above, we’re looking at a single GX_DRAW_TRIANGLES Display List that happens to correspond to the Pizza Hut’s roof. How’d I know it’s the roof? In the FIFO Player window, if you click back over to the “Play/Record” tab, you can set a limit to how many objects are rendered per frame. As it turns out, if you set that limit to 293 objects, the roof disappears, but set it to 294 and it comes back, meaning “Object 294” is our guy.
Seeing the Display Lists is cool, but as a reminder, we’re here to see some VAT entries. And although the DL in the screenshot above does mention VAT 3, the VAT itself is nowhere to be found in any of the FIFO calls. Tragically, that’s because by the time this frame was recorded, the VAT entries had all already been sent to the GXFIFO register. So what do we do?
We fork Dolphin
We fork Dolphin. You see, as I mentioned before, the FIFO Player records not only GXFIFO data, but also some data about the other GameCube registers. And as it happens, VATs are stored in a section of registers called the Command Processor, or CP. If we dump the CP registers, we can view the state of the VAT at the time of this frame! This can be done a couple of ways, but I went about it by iterating through every single CP register related to VATs on each draw call. Now, for every single draw call, Dolphin will dump every VAT entry, yielding 8 blocks of rather verbose info like this:
VTXFMT 3
Position elements: 3 (x, y, z) (1)
Position format: Short (3)
Position shift: 14 (6.1035156e-05)
Normal elements: 1 (normal) (0)
Normal format: Unsigned Byte (0)
Color 0 elements: 3 (r, g, b) (0)
Color 0 format: RGB 24 bits 888 (1)
Color 1 elements: 3 (r, g, b) (0)
Color 1 format: RGB 16 bits 565 (0)
Texture coord 0 elements: 2 (s, t) (1)
Texture coord 0 format: Short (3)
Texture coord 0 shift: 7 (0.0078125)
Byte dequant: shift applies to u8/s8 components
Normal index 3: single index shared by normal, tangent, and binormal
Texture coord 1 elements: 1 (s) (0)
Texture coord 1 format: Unsigned Byte (0)
Texture coord 1 shift: 0 (1)
Texture coord 2 elements: 1 (s) (0)
Texture coord 2 format: Unsigned Byte (0)
Texture coord 2 shift: 0 (1)
Texture coord 3 elements: 1 (s) (0)
Texture coord 3 format: Unsigned Byte (0)
Texture coord 3 shift: 0 (1)
Texture coord 4 elements: 1 (s) (0)
Texture coord 4 format: Unsigned Byte (0)
Enhance VCache (must always be on): Yes
Texture coord 4 shift: 0 (1)
Texture coord 5 elements: 1 (s) (0)
Texture coord 5 format: Unsigned Byte (0)
Texture coord 5 shift: 0 (1)
Texture coord 6 elements: 1 (s) (0)
Texture coord 6 format: Unsigned Byte (0)
Texture coord 6 shift: 0 (1)
Texture coord 7 elements: 1 (s) (0)
Texture coord 7 format: Unsigned Byte (0)
Texture coord 7 shift: 0 (1) As it happens, this readout of every VAT ended up being the same regardless of the draw call. And while that ends up being incredibly convenient for us, it’s not guaranteed to be the case! If a game needs more than 8 VAT formats, it can create new ones at runtime, meaning the CP state might change between frames. But, because Crazy Taxi has a fairly simple set of model formats, it seems to set its 8 VATs at startup and then never changes them. As such, we can just record the above output for a single draw call, and then we’ve got all the VAT entries we’ll need to render every model in the game!
noclip expects VATs to be defined in terms of TypeScript enums, so the Dolphin output above would become something like this in our renderer:
let vat: GX_VtxAttrFmt[] = [];
vat[GX.Attr.POS] = { compCnt: GX.CompCnt.POS_XYZ, compType: GX.CompType.S16, compShift: 14 };
vat[GX.Attr.NRM] = { compCnt: GX.CompCnt.NRM_XYZ, compType: GX.CompType.U8, compShift: 0 };
vat[GX.Attr.CLR0] = { compCnt: GX.CompCnt.CLR_RGB, compType: GX.CompType.RGB8, compShift: 0 };
vat[GX.Attr.CLR1] = { compCnt: GX.CompCnt.CLR_RGB, compType: GX.CompType.RGB565, compShift: 0 };
vat[GX.Attr.TEX0] = { compCnt: GX.CompCnt.TEX_ST, compType: GX.CompType.S16, compShift: 7 };
vats[GX.VtxFmt.VTXFMT3] = vat; Repeat for each VAT, and now noclip knows how to read every draw call for every model.
Pizza Time
In this post, we’ve decoded textures, we’ve dumped VATs from a snapshot of the GameCube’s graphics pipeline, and at last we are ready to go full Hut. Behold:
Magnificent, but that’s not all! Through the use of judicious greping, we can determine the .shp for any of our beloved brands, letting us finally display any storefront we could possibly want:
Indeed, any individual shape file we choose can now be rendered! Which naturally begs the question, what would happen if we render them all at once?
Hm. That, um, doesn’t seem right. As it happens, there’s a bit more involved in assembling each of these pieces to form the game world. In the next post, we’ll dive into that and hopefully end up with a nearly complete Crazy Taxi level!