Levels
Tony Hawk's Underground 2 came out a year after THUG, on the same engine, with the same file extensions and the same version numbers in its files. So its levels should just load, right? They didn't. Thirty-odd ASSERTION FAILED screens later, Australia, Barcelona, Berlin, Boston and the rest of THUG2's world are at the end of THUG1's Play Level, and you skate them with THUG1's skater, THUG1's physics and THUG1's soundtrack.
Data/CustomLevels, as they are in THUG2's own Data folder, and play them in THUG1. Whatever THUG2 has that THUG1 doesn't (new triggers, new ped AI, its goals) gets skipped, not crashed on.
1THUG2's own order: Training first, Airport last
2pre/SE2scn.prx isn't in Data/CustomLevels: greyed out, skipped by the cursor
3Enter: load_thug2_ph
4Still true for THUG1's levels. THUG2's don't ask.
- The list is fixed: THUG2's fifteen levels, in the order THUG2's own level select has them, after a divider (the same black line retail draws around Created Park).
- Not installed? Then it's greyed out, like a locked level, and the cursor skips it. A level is there when its scene is:
pre/<level>scn.prx, orlevels/<level>/<level>.scn.xbxloose. - Choosing one runs its own load script (
load_thug2_auand friends), which has C++ fill in a level structure, then loads it like any THUG1 level. - THUG2's levels are never locked, and never in story mode. In network games, only the ones whose files check out can be picked (step 10).
1Same version number, different file
THUG1's scene loader reads three version numbers at the top of a .scn.xbx and then ignores them. THUG2's
files have the same three: 1, 1, 1. Same materials, too, byte for byte the same layout. So before touching
the engine, a 60 line Python mirror of THUG1's loader got pointed at THUG2's Australia sky, the smallest scene there is:
A sky with a billion vertices. Where THUG1 has the vertex count, THUG2 already has the first mesh's bounding sphere. THUG2 moved the vertices into the meshes: no sector-wide vertex stream any more, each mesh carries its own interleaved buffer, plus its indices twice (once numbered across the sector, once local to the mesh). The io_thps_scene Blender addon, which imports THUG2 scenes, had the layout. The Python mirror then walked every THUG2 scene, model and skin to the exact last byte: 44 scenes, 321 models and skins.
THUG1 can't tell the two formats apart from the header, so it doesn't try. It walks the whole file in THUG2's layout, with bounds checks, and only calls it THUG2 if that ends exactly where the file does:
Then a new sector loader splits each mesh's interleaved vertices back into the separate streams THUG1's
sMesh::Initialize wants, unpacking the skinned meshes' 11:11:10 normals on the way:
Textures needed nothing. THUG2's .tex.xbx is THUG1's, DXT and all. The one file format that didn't change.
26,818 bytes nobody asked for
Collision files do change version: 9 in THUG1, 10 in THUG2. Every header field still added up: the vertex counts, the face counts, every object's offsets. Then the BSP tree's size came out as 3,686,842,515 bytes, in an 866 KB file. Something sat between the faces and the tree.
In Australia that something was 6,818 bytes long, and Australia has 6,815 large faces. One byte per face, plus alignment. The small faces had been hiding the same byte in their padding all along (THUG1's are always 0, THUG2's are 255, 254, 252…). A per-face light value, by the look of it. Three guesses, tested on all of THUG2's levels and one of THUG1's:
| File | Large faces | THUG1 layout | + bytes, after the pad | + bytes, no pad |
|---|---|---|---|---|
| THUG2 Australia | 6,815 | no | OK | OK |
| THUG2 Downhill Jam | 681 | no | no | OK |
| THUG2 School | 2,013 | no | no | OK |
| THUG2 Airport | 16,317 | no | no | OK |
| THUG1 New Jersey | 8,335 | OK | no | no |
Last column, every THUG2 level. The collision translator now skips the bytes and writes the result out in THUG1's layout, stamped version 9, so nothing downstream ever sees a 10:
The same pass clamps THUG2's terrain types to ones THUG1 has. A terrain past the end of THUG1's sound table is an array index into the void.
3The ASSERTION FAILED parade
Scene: loads. Collision: loads. Australia's first full boot then died fourteen times in a row, each time on
something THUG2 has and THUG1 doesn't. This port keeps the retail asserts on, and on a Mac an assert is a
SIGTRAP: one bail, back to the dock, rebuild, retry.
| # | The game said | What THUG2 was doing |
|---|---|---|
| 1 | MarkLevelVisited called with bad level num 40 | Nothing: I picked 40. The career has room for 21 levels. |
| 2 | Anim anims\Ped_Female\Ped_F_WalkFromIdle2.ska doesn't exist | Its peds walk in ways THUG1's never learned |
| 3 | Asset ...shrimp.tex is of unsupported type 3 | Loading a texture dictionary as its own asset. For a shrimp. |
| 4 | Failsed to load ...shrimp.col.Xbx | The shrimp has no collision. (The typo is Neversoft's.) |
| 5 | Couldn't open scene file models\none\none.skin.Xbx | A ped wearing a part called none |
| 6 | Sound type 'AU_TableUmbrella' not found in RigidBodySounds | Umbrellas you can knock over, with their own sound |
| 7 | Could not find structure component named 'struct' | A sunbather, ped_sun_bather_01, from THUG2's ped profiles |
| 8 | Expected to find 6 bones in the car skeleton (found 7 in TRG_PreEvent_B_CopCar) | The cop car loaded fine. It just has one more bone. |
| 9 | Could not find nodearray veh_cop_nodearray | Still the cop car: its node array was in a pre file that had already been unloaded |
| 10 | EXC_BAD_ACCESS (address=0x10) | No assert, just a level object with no collision sector |
| 11 | Tried to change the value of the non-existent symbol 'InsideAdjacentReverbBox01' | Reverb zones under a bridge |
| 12 | Negative curly_bracket_count ?? | The gap scanner reading THUG2's multi-line { } as statements. The two question marks are Neversoft's. |
| 13 | Asking to play sound that hasn't been loaded CarLoop. | A car humming along with a sound THUG2 loads its own way |
| 14 | Trying to get duration on an animation that doesn't exist ... Ped_M_JumpLeft | A ped THUG1 could build, jumping out of the way with a move it doesn't have |
Fixing these one by one would have been fine for Australia and useless for the next level. So instead the port got a lenient mode, switched on while a custom level is loaded and off again for THUG1's own levels. In it, a call to a script, function, object, animation, sound or asset that isn't there is skipped with a warning in the log instead of an assert:
The same idea, a few lines each, went into the asset manager, the pip loader, the model and skin loaders, the
animation component, the struct lookups, Change, the sound manager and the expression evaluator. Peds
whose appearance, skeleton or animations THUG1 doesn't have aren't created at all, and goal manager calls about
goals that were never added just answer no, from one filter in front of every GoalManager_
function.
Lenient mode never touches THUG1's own levels: every check is && LenientScripts(). The retail game still bails on its own bugs, as is tradition.
4Don't let THUG2 rename your stuff
Two quieter problems in the scripts. THUG1 looks up its node array by one name, NodeArray; THUG2 calls
it AU_NodeArray, BA_NodeArray… And a level's scripts get loaded with
assert if a symbol already exists, so a THUG2 level defining anything THUG1 already has would bail; and if it
replaced it instead, unloading the level would delete THUG1's version for good. Both are handled while the level's
qb's are parsed:
A symbol THUG1 already has still gets parsed (that's the only way to know how many bytes to skip), just into a throwaway entry that's deleted right after.
5Barcelona ate the heap
Most levels loaded after that. Barcelona died on Loading Scene BA ... with no assert at all: macOS's
malloc found its own free list trampled, inside a perfectly innocent new. The crash is never where the
damage is, so the port got an AddressSanitizer build, and promptly crashed somewhere else:
The build compiles the game's scripts with its own QScript compiler, and the sanitizer built that too. It caught the
compiler reading a float through an 8 byte unsigned long. Fine, harmless, not today: the ASan build got the
normal compiler instead. Second try, the game starts, and THUG1 itself trips ASan reading one byte past a script.
Also harmless, also a 2003 classic. Third try, with -fsanitize-recover to report and keep going:
DXT stores 4×4 pixel blocks, and the decoder writes whole blocks. Barcelona has textures whose smallest mipmaps are 2×2 and 1×1: one block, sixteen pixels written into room for four, or one. If THUG1's own textures ever went that small, nobody noticed. Now a level smaller than a block decodes into a block-sized buffer and gets cropped:
While in there: mip sizes were halved with >>= 1, so a 64×2 texture's mip chain went 32×1, 16×0. A zero-height mip makes the whole texture incomplete in OpenGL. They stop at 1 now, for THUG1's textures too.
6New Orleans and the off-by-two
New Orleans bailed on Bad token 'Unknown' (73). Token 73 is SHORTJUMP, which only
THUG2's compiler emits: inside SWITCH, each CASE is followed by a 2 byte jump to the next
case, and each case's code ends with a jump past ENDSWITCH. THUG1 finds its case by reading through
them in order, so it now just steps over the jump after CASE. Then the next bail:
Token 49 is @, which isn't even in that script. The interpreter had landed in the middle of
something. The port already ran SHORTJUMP (an earlier THUG2 experiment), reading the 2 byte offset and
jumping from the end of it. QScript's own decompiler takes the offset from the start of the offset:
Every THUG2 SWITCH had been landing two bytes past the end of its case, in the middle of whatever came
next. One deleted line.
7Smaller bails
- Too many prefixes. THUG1 indexes node names by prefix, with room for 13,000 of them. THUG2's levels are bigger. Now 40,000.
- A CRC32 collision across two games. Skatopia has a rail called
TRG_rail_endgoal_rocket_05. THUG1 has a park editor piece calledSk3Ed_RAs_10x10x8_4. Both hash to0xa73da98c, and the name table asserts on it. It's debug only, so it's a warning now. - The 7th bone. THUG2's cars have one more hierarchy object than THUG1's car skeleton. The renderer only draws the bones the skeleton has, so the 7th part just isn't drawn.
- Boston's question.
GetSingleTag Stateis a THUG2 function, so<State>never gets set, andIF ( <State> = Skater_OnWall )becomes an expression with an operator and nothing to compare. The evaluator's error is a warning in lenient mode, and theIFis false. - Level 18. Custom levels need a career slot, and there are 21. Slot 18 belonged to West Side, a level that only devkits had. It finally gets used.
8Australia and Barcelona








The rest of the world
All fifteen of THUG2's levels load and survive a scripted run of skating, ollies, grinds and turns: Training, Boston, Barcelona, Berlin, Australia, New Orleans, Skatopia, Pro Skater, The Triangle, School, Philadelphia, Downhill Jam, Los Angeles, Canada and Airport. THUG2's test level loaded too, and then got cut from the list. It's a test level.
What's left out: THUG2's goals (they're written for THUG2's goal scripts), its cutscenes, and the peds built from THUG2's appearances and animations. The levels themselves, the rails, the cars and the objects are all there.
9Bring your own levels
From THUG2's Game/Data, copy the level's four pre files (and, if you like, its loading screen) into
the port's Data/CustomLevels, keeping THUG2's folders:
Or copy THUG2's whole pre folder, and its levels/<code> folders, for all fifteen
at once. Any folder inside CustomLevels can hold levels the same way, and levels that aren't THUG2's
are listed after THUG2's, for playing alone. The folder is read
the first time Play Level opens, so restart the game after adding levels. While a custom level runs, files it wants
that THUG1's data doesn't have are looked up in its folder, so THUG2's models or sounds can go there as well. The
macOS app also reads ~/Library/Application Support/THUG/CustomLevels, since its own Data
is inside THUG.app.
10Same files, or no game
A network game doesn't send anyone the level. Every player loads it from their own disk, and the host only says
which one, by the checksum of its load script. That's why every THUG2 level got its own load script: with one shared
load_custom_level, the host's "Australia" could have been anything at all on the other end. And if your
Australia isn't my Australia, your rails are where my walls are. So for network games a THUG2 level only counts when
its files are THUG2's own, byte for byte: seven files, MD5'd against THUG2's PC release the first time they're
needed.
The last three are THUG2's network versions of each level, which it loads loose in network games: the same
city with some objects taken out. THUG1 asks for AUscn_net.pre in a network game, so a small pre file
mapper (File::SetPreFileMapper) points it at THUG2's AUscn.prx, and the scene loader picks
up the loose net files after it. The nodes flagged AbsentInNetGames stay out, as they do in THUG1's
own levels. Copy only the pre files and the level still plays, alone.
Then the net version turned out to be the better one for playing alone too. THUG2's single player levels are
built around its story: walls that only break when a goal says so, doors that open after a cutscene, objects that
only exist for goals THUG1 skips. The net version has none of that. So when the net files are there, it's what every
game loads, high score mode included. One more thing made it look right: THUG2's level scripts ask
InNetGame and InMultiplayerGame themselves, and Training's TR_KillCreate_NetGeo
only knocks the wall through when the answer is yes. Now the answer is yes, but only for the level's own scripts. The
parser remembers which qb files were THUG2's, and THUG1's scripts still get the truth.


Joining a game whose level you don't have stops at the door, with the refusal dialog retail uses for full games and wrong passwords:
The harsh one: you're in, on New Jersey, and the host picks Change Level > Australia. Your copy refuses to follow and shows the same message. Seconds later your game notices the host stopped talking to it, times it out, and retail's "You have lost connection to the host" dialog lands right on top of ours. Technically correct. Not helpful. Now the timeout still cleans up, but leaves the dialog alone while ours is up.
How it got here
| Commit | What |
|---|---|
| working tree | levels branch: THUG2 scenes, models and collision; lenient mode; Data/CustomLevels in Play Level; THUG2's fixed level list, MD5 checks and network games |
Neversoft shipped THUG2 a year after THUG. Twenty-two years later, THUG got its levels anyway. Still waiting on the shopping cart.







