Making Levels

Posted: 26 Apr 2024

Documenting what kind of work it tool to get custom levels working in Psychonauts.

I wanted to go in-depth on the blood, sweat and tears it took to get rudimentary custom levels working in Psychonauts. From the time I started working on this up until the birthday celebration Cohost post, it took days of practically non-stop work. I began on the 9th/10th and then kept going pretty much the whole time. To be clear, my initial intention was not to aim for custom levels so soon but here we are.

Astute folks who follow me will notice that starting on the 10th lines up with my Cohost post on the 11th featuring a GIF of what it looks like to remove a censor’s glasses. Indeed, this all began with importing the game’s models into Blender, via PLB->glTF conversion.

In theory, not the most complex task. The formats are mostly solved, thanks in no small part to @RayCarrot’s incredible work and contribution in building basically the entirety of the PsychoPortal library, a .NET library specifically designed to read/write the game’s formats.

So, at first things went quite fast. On April 10th, somewhere around 7am (I had not slept yet), I took this screenshot of one of the first Psychonauts models to be imported into Blender. One of the rats from the Asylum.

This is purely geometry, and not even all of it. The game’s models are split into pieces called “meshfrags” - these are a few different things. Meshes are primarily split into meshfrags by material but they’re also used by the LOD system (& have something to do with blendshapes)

And right now we’re just importing the first meshfrag, and just the geometry at that. Vertices and faces. No normal data, no material data, no skeleton or rigging. We have a long way to go before putting anything back in is even on the table.

We’re gonna be seeing this censor a few times. I have no idea why a censor was my go-to model for testing all this stuff. The 3 models that served as the main guinea pigs through all this were this censor, Oatmeal and the lungfish whistle.

Speaking of, that’s what I decided to name this tool. Oatmeal. It’s… a bit of a stretch. I like themed naming so I guess it was kind of based on the idea that Oatmeal takes things in + deposits them elsewhere, & this tool takes files in one format & outputs them in another.

Anyway, my next step was to get multi-meshfrag conversion working. Right now, by just dumping them all out seperately. Very haphazard. At this stage I barely knew how meshfrags were divided though, so part of figuring that out was inspecting them.

The current version of Oatmeal takes in Psychonauts PLB files (scenes) &outputs glTF files. glTF is a nice format choice for a few reasons that I’ll discuss later. So far though Oatmeal has been exclusively exporting to OBJ. A simple format, but perfect for this preliminary work.

By the way, have I mentioned yet that I’m basically clueless about most 3D model stuff? OBJ, glTF. RayCarrot was the one who did all of the work reversing the innards of the mesh data and I’ve only occasionally poked at it in the past so even that is a bit unfamiliar.

So this entire process has very much been improvisational, learning as I go along. Later on this meant learning some hard lessons about the glTF file format. Here though, I think I was unfamiliar with how OBJ groups handled vertex indexing.

Eventually I tackled UVs which weren’t particularly difficult.

Shortly after, I took a small detour dissecting Augustus to try & figure out the logic behind meshfrag splitting & eventually determined it to be several factors - materials are the main one, then LODs and blendshapes.

It was upon importing the kids’ cabins into Blender that I hit a snag - until now I’d been creating dummy materials with the full path of the texture as the material name. Turns out Blender has an awful 63 character limit on material names, meaning they’d get cut off.

This didn’t matter yet but it would mean that in the future I’d probably end up needing to write a Blender plugin to store the full paths. Interestingly Oatmeal actually started life as “Brain Tumbler” and was going to be 100% integrated into Blender as an add-on.

…but I gave up on that really fast when I realised how daunting it’d be to be working with 3D data, in an unfamiliar API, with a language I’m not super familiar with. Not great for productivity and I didn’t make much progress on it at all.

Brain Tumbler does live on - I still plan to create it as an add-on to work alongside Oatmeal, to have a user-friendly way of managing the various custom properties that are required to fully support Psychonauts’ data. But we’re getting ahead of ourselves with that.

To actually store any custom data on the converted models we have to move on from OBJ. It was working well given that at this stage I didn’t have any plans to figure out skeleton import, but we need more flexibility, so the next part of the post will be about glTF conversion.

By my estimate, it seems to have taken me roughly an hour of work to strip out all the OBJ conversion code and re-do everything to convert to glTF instead. Now using, the SharpGLTF library, I tested conversion on the lungfish whistle, which worked:

Of course I then tried it on the censor and that also worked just fine. In fact, now he has proper smoothing because I’m bringing over normal data as well.

It’s worth noting that Oatmeal didn’t - and still doesn’t - have LOD handling yet. Each meshfrag has a value that indicates which LOD it’s assigned to, with meshfrags on LOD 0 being used for all LODs. Right now, Oatmeal filters out everything past LOD1. Bit of a TODO.

With glTF now being the conversion target, I started storing texture paths in extra data per-material. I whipped up a quick script that used this data to assign actual images in Blender, so I was able to get the kids’ cabins partially working in Blender, with some scuffed UVs.

And here’s our rat friend from earlier, as well as Oatmeal, in glorious smooth glTF form imported into Blender.

Fun fact: I had planned to leverage glTF’s ability to store multiple scenes since level pack files can actually store multiple scenes. This has not yet happened due to a bug I ended up discovering in Blender’s glTF importer, which I reported but has not yet been fixed.

Shortly after figuring out what the LOD data in meshfrags actually meant, I made the censor glasses GIF for fun and moved on. Carried by pure momentum and faith in SharpGLTF’s ease of use, I set out to tackle skeletons and rigging.

Getting static Psychonauts models into Blender was one thing, but getting them in complete with rigging? That’d be pretty cool.

I quickly realised that this would be pain.

In fact, it has been pain. Continuously. Until yesterday at around 2am, skeletons have not quite worked correctly when converted to glTF and back. That one bugged me for a good while.

To take my mind off it I ended up creating the rotating Oatmeal GIF for fun. You may be shocked to hear that it took 2 hours to get the lighting just right for it.

Anyway, needless to say, I started losing my mind really fast trying to get skeletons working.

It took a bunch of fiddling to get it looking right when importing into Blender. This is because Psychonauts stores the local rotations of each skeleton joint as a vector, while glTF uses quaternions.

I’m going to restrain myself a bit here but essentially quaternions are technically a better way to store rotations than euler angle vectors. Unfortunately, they are also infinitely less intuitive and impossible to read unless you exist in the 4th dimension.

And because of the differences between quarternions and vectors, it is a HUGE pain to convert between the two. It’s easier to convert from a vector to a quaternion but doing it the other way around is a nightmare I’ll talk about later.

Documentation on the default .NET method for converting euler angles to quaternion turned out to be incredibly unclear and inconsistent, so I ended up finding a stack overflow answer that worked first try. Lo and behold, we had skeletons that looked about right:

Skinning, actually assigning weights to the bones for each vertex, would turn out to be another issue. Once again, some misunderstandings about how everything tied together in both the PLB and the glTF led to confusion.

Upon realising the problem though, my exact words were “Oh I think I see the issue. This code is about to get really ugly.”

This of course implies that the code was not already ugly. It was, but the addition of skinning definitely made it worse.

An interesting sidenote - both Psychonauts and the glTF format have a limit on how many bones can affect a single vertex. In the glTF format, you can have either 4 or 8 joints affecting a single vertex at the same time. In Psychonauts, you can have 2.

Interestingly, the second weight will always be 1 - first weight. I have no idea if that’s standard but it does seem odd. Also interesting is that Psychonauts did support at least one additional joint at some point, maybe even two.

There’s data in the format that’s only read if the file version is less than 308. In this case, 2 additional weights will be read. In addition, even the current format version has 2 additional ints being read (likely for 2 extra joints) that are never used.

After making my code uglier I tested conversion once more and finally, this time, we have fully rigged Psychonauts models being converted from PLB to GLTF.

That tangent over, after making my code uglier I tested conversion once more and finally, this time, we have fully rigged Psychonauts models being converted from PLB to GLTF.

I spent some time making silly poses with this. Somehow, I think the real weight of this brushed past me due to my momentum at the time. I’d set out just to do something basic and see how far it took me and yet I had already blown past all self-expectation.

Momentum really is the key word here. This is only a couple days in and I just kept going wherever my instincts took me next. In this case, they took me to animation support.

This only took a couple hours or so. SharpGLTF’s simplicity carried me a lot. There was one minor issue that came from bone scaling though:

I’m still not 100% on what this means, it happens in Ray1Map in Unity as well. The fix is to take the parent bone’s scale at the current time, calculate the difference between our scale and the parent’s, then subtract that difference from our scale.

I still don’t know if this is “correct” but it’s worked in all cases I’ve seen so far. With that solved though… where will the momentum go next? Well, the next logical step with so much import stuff figured out seems like export. glTF to PLB conversion.

So that’ll be the next section of the post - talking about how I ended up reversing this process to get glTFs back to PLBs. This’ll lay the foundation of getting custom levels into the game.

Interestingly, one of the first initial hurdles was my own tools. PsychoPortal itself. It has a bit of an… architecture problem, that I’ve been meaning to fix, but never gotten around to.

Essentially it’s real good at reading data and presenting it in .NET structures, but it has a few quirks and problems that make it a little annoying to build those structures from scratch. Everything’s quite optimised for reading.

For example, when reading an array it’s common for the size of that array to come before the array. PsychoPortal tends to read and store those array sizes, and relies on them to write the correct array size when writing a file back out.

Which is a little odd, since when writing data the size of the array can be when the data is serialised and shouldn’t have to be manually assigned. What I’m saying is - PsychoPortal has been growing alongside Oatmeal in a few ways, which has been fun.

That aside I really didn’t document much of the initial process - it was kinda boring. A lot of trial and error figuring out the absolute minimum data that needs to be in a PLB file without the game or PsychoPortal crashing.

From my estimate it took me somewhere in the range of 2-3 hours to get the simplest possible glTF file in the world working. This is the precursor to what would become my post featuring a floating Suzanne in the game.

As a meta-note, if you’re wondering about the purpose of those posts, they pretty much were just a fun way to tease progress without outright stating what I was up to. This is a big deal! I wanted to be fun about it. If people knew, they knew.

Anyway yes, there wasn’t anything before this model, not counting a version with weird messed up shading thanks to a Blender issue. Suzanne was well and truly the first fully custom from-scratch mesh to be imported into Psychonauts. I think that’s neat.

As a completely custom entity too - an entity called “modeltest” I made exclusively to test custom models without replacing anything existing.

It was pretty much the same process in reverse at this point. The next step, as it had been with PLB conversion, had been UVs. This resulted in a weird red brain Suzanne because of the texture I was using at the time as a placeholder.

I think I may have been hardcoding the texture at this point too? I can’t actually recall. Anyway after this was working it was @Bekoha’s idea to give it a UV grid texture and simple animation (driven through code only - no rigging or even skeleton support yet!) and post it.

This model is a single meshfrag too, so it really is the bare minimum. As such my final task for that night was to set up multi-meshfrag support as well as remove the hardcoding of the texture paths. In this model, the middles of the eyes are using another material/meshfrag.

This was actually pretty easy - glTF splits meshes in the same way pretty much. There, they’re called “primitives” but it’s the same principle. Every face of the mesh with a given material gets grouped into the same primitive. All I needed to do was iterate the primitives.

As I went to bed I had a fun idea - grab a model from Psychonauts 2, convert it as-is to PLB, and see what happens. Could the Psychonauts 1 engine handle Psychonauts 2 models? The answer as it turns out was yes, as demonstrated in my later posts featuring Sam in P1.

Completely unrigged again but it was really neat. The lighting is off in those images though - and there’s a good reason.

All these models so far have had their materials labelled as double sided, so they don’t cull their backfaces. Why? Because for reasons unknown to me at the time, the normals on PLB->glTF converted models were flipped, so models were inside out.

To hide this and dust it under the rug to deal with later I just made the material double sided. This worked but it did result in the models being significantly darker than they should have been. It wasn’t until much later that I figured out what the issue was.

This was a good lesson for me I think - I did avoid the problem but I’d have probably gotten stuck on it for ages and not made as much progress if I didn’t. I prioritised just applying a quick workaround and dealing with it later over halting progress to deal with it.

This sounds bad & it kinda is but it also tends to be necessary in programming. Sometimes other problems are more important. I’ve always known this of course but I do tend to fall into the trap of getting stuck, so I’m glad that didn’t happen here. Here’s 2 Oatmeals.

Speaking of avoiding problems - the next task would involve the return of the pain. It was now time to try and get rigged glTF models back into the game, with their rigs intact. Hoo boy. I didn’t know it at the time but this would end up being a problem for the whole week.

I definitely WOULD have gotten stuck here, and probably crashed and burned and completely failed to make further progress, had it not initially appeared to work fine. This did lead to a nasty surprise later but we’ll get to that. For now we’ll stay in the past where things seemed to work just fine and I moved on and kept finishing other tasks.

For some reason when I started working on this part I became kinda weirdly obsessed with the fact that model joints all have a bounds assigned. To this day I’m actually not entirely sure how these bounds were originally calculated.

I eventually ended up hardcoding the bounds for each joint in the converted file to just be a fixed size and that seemed to work. Seemed to work that is, of course, after I figured out how to translate the glTF skeleton in the first place.

This… was when I got a bit annoyed at SharpGLTF. No real fault of its own, but rather a limitation of the glTF format itself.

See I was wondering why everything was falling apart - when reading a glTF file, SharpGLTF provides a function called GetArmatureRoot. I had assumed that this would just return the root bone of my armature. It did not.

If I recall, it was instead returning the root of the whole model. Needless to say, starting the skeleton with a “Cube” object rather than, you know, the root of the skeleton.

There’s basically no way to easily find the start of the real skeleton without A LOT of pain with how glTF works, as far as I understand. All the skeleton joints end up just as generic nodes in the glTF file.

I sought help from the creator of SharpGLTF and it seemed like I wouldn’t be able to find the skeleton properly without going through a lot of preamble which isn’t really what I wanted at that moment in time.

I ended up working around it instead, requiring that the root of the skeleton be marked with additional custom data to help Oatmeal find it and actually build the skeleton correctly.

Around this time I was debating a bit on when the right time would be to announce all of this. Bekoha suggested getting a fully custom model with custom animations in-game and announcing it then but that of course was reliant on me figuring out this skeleton nonsense.

Figuring out animations after that would be a whole other task (still haven’t done it!) so I was wondering about a compromise - something rigged to an existing skeleton, replacing an existing model.

I was also thinking about custom levels at this time. As far as I could tell, the only real requirement the game has to load a PLB as a level is that it has collision. I got quite sidetracked thinking about this and started seeing what would be required.

See collision meshes themselves are quite simple, they’re just a list of vertices and primitives basically. The hard part was octrees. Not only was I unfamiliar with the concept, but this was a spot where PsychoPortal was a bit rough.

The octree data did get serialised in such a way that it could be read/written but it was opaque. Octree nodes were read just as a big sequence of 8*3 bytes for example. Mysterious and not very helpful.

It was at this point that some work went into figuring out what that data was but I’m gonna break chronology and go out of order a bit, for consistency. I did end up getting the skeleton working in the PLB file.

I was able to convert Oatmeal from PLB, to glTF, export him from Blender and then re-convert him to PLB. Sure enough, flipped normals aside, he did work with fully functional rigging and animation. Nothing seemed out of the ordinary.

I even got rid of the original mesh, quickly slapped 2 cubes onto a couple of the joints and then tested that. Sure enough…

I even did a test with Ford, creating THIS horrible thing that I decided not to post. Sorry that you have to see it now.

I actually didn’t do a teaser progress post for rigged models at all, I don’t think. I just couldn’t think of anything good enough for it. It’s unfortunate, in a sense. If I had, perhaps I’d have spotted the evil lurking within my code at this point. But I didn’t.

So, back to the octrees. It was around when we were figuring that out that I had thought of “Man, how nice would it be to be able to announce custom levels on the game’s birthday?” But at that point I think it still seemed very unlikely.

In the next part of the post, we’ll talk about what those very clever (sincerely, it’s great) programmers at Double Fine did to store octree data. And why that made it such a pain to reverse engineer.

As stated before, the octree data’s representation in PsychoPortal was very opaque. There was a lot of mystery to be found in it. A mysterious box known only as SSECube, octree nodes being naught but a series of bytes, the octree leaf data consisting entirely of nonsensical ints

This is the other issue with PsychoPortal at the moment - basically all of the information there has been gleamed from reverse engineering the game code. But sometimes, even then, it’s not always clear what specific data is or what it’s for or even what type it is.

In this case, the game read octree nodes in a fairly unclear manner which made even the type of data it was unclear. As such, the only choice was to represent the data within as an unnamed series of bytes.

Now an octree, if you didn’t know, is a type of data structure where every node can connect to 8 other nodes. Each of those 8 nodes is then either a leaf (indicating the end of that branch) or another node which can then link to 8 more nodes or leaves.

Imagine a big cube encompassing the whole level. Now divide that cube into 8 smaller cubes. The big cube is the top node, the 8 smaller cubes are the sub-nodes/leaves. If they’re a leaf then that’s that, if not then they divide further.

Now I’m not great at maths stuff so I may make a mistake but to my understanding it’s quite efficient to take a given position on the level and then determine exactly which branch path down the octree that position is in.

For example, if you wanted to test an entity’s collision against your collision mesh without testing the entire mesh. You could, say, remember which primitives of the collision mesh are in each leaf, find the leaf the entity is in, and only test against those primitives.

Which is, to my understanding, exactly what Psychonauts does. Only problem is we don’t yet understand how that information is stored. The game can’t handle it when there’s no octree so if we want collision we need to at least learn enough to create a dummy octree.

Leaves and nodes are stored seperately so let’s start with figuring out the nodes. Right now PsychoPortal is just reading each node by reading 83 bytes. That’s interesting - 83. This was unusual until it clicked that the game has a “Uint24” type.

A uint24 of course being a 24-bit uint - a uint with a byte chopped off. A 3-byte uint. Octree node data consists of 8 24-bit uints. That’s a good start, now we can read the data properly

The octree has 3 arrays - an array of primitive indices, an array of nodes and an array of leaves. We can make some educated guesses about what kind of data is being stored here based on that.

From this we can assume that an octree node must:

And we somehow do all that with 3 bytes. Hm. That’s tough. I’m not going to lie I probably would have been stumped on this if it wasn’t for Bekoha

At this point I had already clambered into bed. It was 9am, my brain was completely fried from spending the whole day trying to figure out skinning. We’d determined that it seemed like they were using a (0x400000) to indicate that a node was a leaf.

And the constant, 0x7FFFFF, to indicate an empty leaf. Finally, the maximum number of nodes/leaves was 0x3FFFFF. And it was when he was reviewing the information that it clicked for him. How do you fit all that information into 3 bytes with no overlap?

Simple. The leaf flag is 0x400000. One more than the maximum number of nodes. So they can never overlap. That’s really clever! Fitting so much information into such a small space! DF’s programmers can really cook, huh?

After we figured all this out, I’d proceed to go to bed and wake up later that day to figure out skinning. So now we’re caught up proper. We’ve got skinning working and we’ve got one half of the octree information figured out. But we’re still missing a crucial half.

That being the leaves themselves. For a while though I fixated on this “SSEBox” variable that was stored in the collision tree. This is a combination of a vector3 and a float which were labelled as “position” and “length” where length is always a multiple of eight.

I won’t spend too much time on this but it seems to essentially describe the top node of the octree? The one that gets divided. It would make a lot of sense. I spent some time opening collision meshes and manually positioning the SSEBox in them but didn’t learn a ton.

Ignoring that for now we set out to reverse engineer exactly how and what the octree leaves were storing. When we first saw Ghidra’s decompiled code for this I think the answer to this that was presented at the time was quite accurate - “something unholy”.

This is… a mess. There’s some serious bitwise maths going on here. With each octree leaf stored as a single int and with the fact that they have to hold 2 pieces of information the intuition is that it could be split into 2 shorts. Needless to say, that’s not the case.

It’s still relatively simple in theory but in terms of implementations it’s a lot more complicated - and even cleverer than how the nodes are stored.

I made many attempts to unravel this mess into something I could understand. I tried isolating the code and feeding examples of leaves into it but Ghidra’s decompile was just too messy and I’m not good at bitwise maths. Whenever I got it compiling, the results seemed wrong.

Bekoha and I did some wandering around the various octree related functions though I was quite useless here. I was quite slow to understand how octrees worked though he was able to eventually explain it in such a way I could understand.

None of this really helped though. While it certainly presented a few theories those theories didn’t really hold up. The core of the issue remained - we had no idea what this tangled mess of bitwise maths was actually doing.

It was very clearly doing some operations on the leaf value to extract the index into the octree’s primitives table, as well as how many primitives from that index were in this leaf. But how?

I briefly went back to the SSECubes, writing a mode into Oatmeal that would read every non-level mesh in the game and dump the length of the SSECube. I made some very interesting discoveries this way but that’d be a hell of a tangent for another post.

It did help us find the smallest possible collision mesh though, which existed by accident (again, another post). It had 63 primitives, a single octree node and 8 leaves. That was pretty good for having a good, simple example target for reverse engineering.

After all the failed attempts to understand Ghidra’s decompile, the only path forward seemed clear. Ignore the decompile, roll up my sleeves, and try to understand the actual assembly behind it.

It’d been quite some time since I had to directly interpret assembly like this, and last time I did it would’ve been PowerPC assembly, so it was going to take me a bit of mental effort to do this. I opened up a fresh Godbolt file and got to work manually decompiling this.

I made an incorrect assumption right away and after being corrected I realised that the whole time I’d actually misinterpreted part of the decompile - at one point I thought it was getting the lower 2 bytes of the number but it was actually getting only the third byte.

Which is… somehow even more cursed. At least it kinda was at the time. Less so understanding why but the why is just is crazy.

Going through the assembly line by line, I’d feed in an example leaf input and have my program, a recreation of the logic, output information about what’s going on at every single step.

It took a couple iterations but i eventually ended up arriving at this output:

Something about this felt… right. For reference, local_1c is what holds the index in the primitives array and local_18 is the number of primitives. Checking said array, there was clearly a boundary of sorts after index 12. Was this it?

But how can we tell if this is right? Well, I fed in the value of the next leaf. The result? Well, the index starts at 12 and goes +4 from there. Starting right where the previous leaf would leave (ha) off.

In goes the next leaf. We start at 16 and go +9 from there. Again, right where the previous leaf would have stopped. This was it. We had it.

Once again it was 9am. Yet another sprint of late night to early morning work to solve how they’d packed the leaves but we’d done it. Double Fine had put in some effort to pack as much information into as small a space as they could and succeeded.

It’s hard for me to put into words exactly how this works so let me use a couple of diagrams, first one I made to visualise which bits are where in the final result. Here’s a colour coded bit-level representation of an octree leaf.

This gives us a maximum of 0x7ffff primitives in one octree, and a maximum of 0x1fff primitives in any given leaf. It makes sense that they would want to prioritise maximising the number of primitives per-octree.

And here’s a diagram by Bekoha showing how the code in the game extracts these two values from a uint. The whole number, in bits, is at the top, with each piece colour coded. On the left is how the game extracts the primitive index & on the right is how it pulls out the count.

This is so clever. I’m sure that in the original code they probably used bitfields, but it’s still really clever packing. I admire this a lot even if I can only barely comprehend it. Something unholy, indeed.

So, why pack it this way? Why go through the trouble? My guess is memory concerns. You’ve only got 64MB on an Xbox and they were having memory optimisation issues all the time. Optimising octree data would’ve given them some savings.

If you think about how an octree works you realise that storing everything in a more naive way would add up in size very fast. It’d be kind of a problem when you’re storing one of these octrees for every mesh that needs collision.

In fact, there’s compatibility code for loading an older, much simpler version of this data. But it would’ve taken up MUCH more space. So clearly they revamped it to optimise it. Really, really clever. It’s so easy to forget how much thought has to go into this kind of thing.

And so, once again at around 9am, I quickly wrote up a quick scratch implementation of the logic in PsychoPortal so I didn’t lose it and went to bed. The pieces were rapidly falling into place. I didn’t know if I’d be able to build a level, but collision seemed doable.

I don’t really know how to build an octree, I’ll figure it out at some point, but I at least had a plan for how I can fake it, knowing now how the data is stored and how the leaves are packed.

I could make a horrible dummy octree. This octree would have 1 node, and 8 leaves. The node would just point to each leaf. Each leaf would simply contain all the primitives in the collision mesh.

This is horribly inefficient of course, and invalidates the point of an octree. It would mean the game is always testing all colliders against every collision primitive in the mesh. But it would, in theory, still work. And that’s been the key this whole time. Making it work.

But there was still a final stretch of work to do to get collision working.

One of these was kind of trivial - the SSECube. It seems that collision needs to more or less fall within the SSECube bounds to work properly. That’s easy enough. I don’t calculate this right now but the current build of Oatmeal locks it to being really big.

The other issues though… well, first off, to generate the collision polygons in the first place they need a float called Float_0C in PsychoPortal at the time. This value’s purpose was… unclear.

The comment above it simply stated that it was used in the polygons’ “triangle plane calculations” but that didn’t explain anything at all. Unfortunately, RayCarrot couldn’t provide any insight because of how long ago this code was written.

So, no choice but to seek it out myself. I did some research on what “triangle plane calculations” could mean and found some information but its relevance wasn’t immediately clear to me. So I dove back into the Ghidra mines instead.

It wasn’t long before I found this - it certainly did resemble what I’d seen about plane calculations.

It subtracts Vert1 from Vert2, then Vert1 from Vert3. We can call these BA and CA. It then gets the cross product of CA and BA (done using the overload of the / operator in the Psychonauts codebase).

The resulting cross product is multiplied by Float_0C before being used to construct an EPlane. The EPlane constructor is quite simple - it takes a Vec3 representing the plane’s extents and then another Vec3 which seems to be used as part of calculating the normal.

It all makes sense, except for Float_0C’s involvement. But I had a bit of a spontaneous idea - is it somehow using Float_0C to normalise the extents?

I grabbed the values from an existing collision mesh and did some of the maths. Sure enough, once you multiply the cross product by Float_0C, it brings the cross product closer to one. Ultimately, we can calculate Float_0C by doing 1 / (∥CA×BA∥). I think.

I’m actually still not 100% on this - it did bring it closer to 1 and this did end up working but I’m still unsure exactly. The one I tried didn’t bring it exactly to one but it could’ve been a precision issue. The point is - this works.

But this doesn’t help given what our final issue. Remember how I said all my models were converting with inverted normals and I swept it under the rug? Yeah time for that to be a pain.

Right now, I was testing this by using an existing model - one of the platforms from Ford’s lab. It was the first example I could think of of a collision mesh used on a prop rather than a full level. Which was my goal, at this time.

And it turns, on checking, that the normals of the collision mesh were inverted. But why? I had no idea, mainly because I’m stupid. It didn’t seem to me that collision meshes stored normals.

But what I didn’t realise at the time was, as Bekoha succinctly put it, “every polygon has a normal it came free with your triangle”. Vertex order. Right. I see. glTF’s vertex ordering is the flipped compared to Psychonauts, it turns out. The inverted normals were finally fixed.

After all that, it still didn’t work. My only guess was that it was something to do with my octree. I tried to make it work but alas, it was not meant to be at that moment.

I went to bed later feeling really feverish. I don’t know why but my mental state was real bad that night. I couldn’t sleep for a couple more hours because for whatever reason I couldn’t stop dividing octrees in my head.

I know it sounds incredibly silly, but that is what happened. I’d think about a cube dividing into 8 in my head over and over again, it was a lot worse than it sounds. Needless to say my sleep quality was awful.

When I woke up I naturally felt terrible. I could barely bring myself to keep going but for me, a small break can turn into a long hiatus, so I just kept pushing on despite how awful I felt at that time.

Injecting the dummy octree into the original mesh worked, so I didn’t know why my mesh wouldn’t work. It was incredibly demotivating. It was then pretty much by pure chance that I happened to fiddle with just the right things at that moment.

I adjusted the collision tree’s “bounds” property, and adjusted the “length” of the SSECube to match. I spawned the converted model in-game. Raz stood on it. Oh. Oh.

Then, I pointed Oatmeal at a custom mesh. A little test platform I called “colplat”. I converted it to PLB, stuck it in the game, and spawned it as an entity. Sure enough…

For the first time, Raz was standing on top of a custom model, with custom collision. He could run around on the colplat and even slip a bit on the little cone I’d stuck on there.

Turns out, collisions don’t really work unless the collision mesh is within the bounds/SSECube. Right now I just lock this cube to be massive. Not very efficient I’m sure but you know the drill by now - it works.

Excited, I scaled up the colplat significantly and extended it a little bit. I spawned it way up in the air so Raz could run around on it unobstructed. It worked. (Excuse the audio delay, it’s a recurring problem with my OBS)

This was so cool. I was so excited. So excited in fact that I got very curious about something. I made a couple tweaks on the script side of things hit F3 - the key used to run arbitrary custom script code in Astralathe. Then, I waited. Everything was black, except Raz himself.

And then…

And so, on the 15th of April 2024, at around 15:24PM BST, my little test platform, “colplat”, could technically be considered as the first ever created, from-scratch custom Psychonauts level.

It has no skybox. No audio or entities. No lighting. It’s just Raz standing on a little platform in the void.

Back in June 2022, I was discussing the recent tease/breakthrough in NieR Automata level modding with Bekoha. At that time I had said “I really wanna get custom level stuff working for Psychonauts tho. At the least I wanna see Raz running around in a flat checkerboard test map devoid of music or ambience”

And here, almost 2 years later, I achieved almost exactly that. More than that, technically. colplat isn’t even flat - it’s got a couple slopes and that nifty cone. I made it.

Of course I couldn’t help myself. It was such a relatively easy process I made some further tweaks. The next iteration of colplat had a second floor, a skybox, music, better textures.

Words cannot describe how surreal this is to me. How surreal it continues to be, that I can whip up something in Blender, do a couple of exports, run Oatmeal and then watch Raz run around in a custom level.

And all of this, accomplished in 5-6 days. Even now I’m shocked at how fast I actually worked here. Obviously, so many solutions employed are intentionally quick and dirty, chosen to quickly get a result. But for now? That’s fine.

It’d take a lot of time to explain all the development that then happened between this point and the creation of the birthday level, including the nightmare I had with quaternions that I escaped only very recently.

And this is a post about how the true first custom level - colplat - came to be. It’s… not as heartwarming as the first level being the birthday level but the birthday one was the first public one, so I think it still counts.

I may do a second post as a followup to explain the creation of the birthday level, this post has already become incredibly long.

It’s only been a few days, but it still feels like it’s been such a long road to this point. And a long road still stretches on ahead, too. There’s still a lot more to do.