For education purposes only · no game files included · bring your own THUG
Guide 13 · levels branch

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.

GoalCopy THUG2's level files into 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.
Play Level, past THUG1's levels: Training, Boston, Barcelona, Berlin, Australia, New Orleans, Skatopia, Pro Skater, The Triangle (greyed out), School, Philadelphia. 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.
Play Level, scrolled to the end. Hover the pins.
  1. 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).
  2. 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, or levels/<level>/<level>.scn.xbx loose.
  3. Choosing one runs its own load script (load_thug2_au and friends), which has C++ fill in a level structure, then loads it like any THUG1 level.
  4. 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).
Australia, in THUG1. Plaza, HAWK sign, a ledge, a bail. No sound in the clip; in game it's THUG1's soundtrack.

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:

excerptpython3 scn.py AU_sky.scn.xbx

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:

excerptWn32/Code/Plat/Gfx/p_nx.cpp

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:

excerptWn32/Code/Plat/Gfx/p_nxsector.cpp

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:

FileLarge facesTHUG1 layout+ bytes, after the pad+ bytes, no pad
THUG2 Australia6,815noOKOK
THUG2 Downhill Jam681nonoOK
THUG2 School2,013nonoOK
THUG2 Airport16,317nonoOK
THUG1 New Jersey8,335OKnono

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:

diffCode/Gel/Collision/CollTriData.cpp

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 saidWhat THUG2 was doing
1MarkLevelVisited called with bad level num 40Nothing: I picked 40. The career has room for 21 levels.
2Anim anims\Ped_Female\Ped_F_WalkFromIdle2.ska doesn't existIts peds walk in ways THUG1's never learned
3Asset ...shrimp.tex is of unsupported type 3Loading a texture dictionary as its own asset. For a shrimp.
4Failsed to load ...shrimp.col.XbxThe shrimp has no collision. (The typo is Neversoft's.)
5Couldn't open scene file models\none\none.skin.XbxA ped wearing a part called none
6Sound type 'AU_TableUmbrella' not found in RigidBodySoundsUmbrellas you can knock over, with their own sound
7Could not find structure component named 'struct'A sunbather, ped_sun_bather_01, from THUG2's ped profiles
8Expected 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.
9Could not find nodearray veh_cop_nodearrayStill the cop car: its node array was in a pre file that had already been unloaded
10EXC_BAD_ACCESS (address=0x10)No assert, just a level object with no collision sector
11Tried to change the value of the non-existent symbol 'InsideAdjacentReverbBox01'Reverb zones under a bridge
12Negative curly_bracket_count ??The gap scanner reading THUG2's multi-line { } as statements. The two question marks are Neversoft's.
13Asking to play sound that hasn't been loaded CarLoop.A car humming along with a sound THUG2 loads its own way
14Trying to get duration on an animation that doesn't exist ... Ped_M_JumpLeftA 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:

excerptCode/Gel/Scripting/script.cpp (CScript::execute_command)

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:

excerptCode/Sk/Modules/Skate/CustomLevels.cpp

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:

excerptan ASan build, first try

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:

excerptASan, third try

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:

diffWn32/Code/Plat/Gfx/p_nxtexture.cpp

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.

Barcelona. Around second three, a THUG2 scooter drives by and the THUG1 skater skitches it.

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:

excerptlog, New Orleans

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:

diffCode/Gel/Scripting/script.cpp

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

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:

layoutData/CustomLevels

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.

excerptCode/Sk/Modules/Skate/CustomLevels.cpp

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.

diffCode/Sk/GameNet/GameNet.cpp

How it got here

CommitWhat
working treelevels 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.