This is the abridged developer documentation for Megabonk Level Editor wiki # Music How Megabonk selects and plays music, and the points a mod can hook to replace it. Class and field names come from `.tools/il2cpp_dump/dump.cs`; call flow comes from the `GameAssembly.dll` decompile in Ghidra (RVA = VA - `0x180000000`, see “Using Ghidra through REA” in `development.md`). ## Engine [Section titled “Engine”](#engine) Music is plain Unity `AudioSource` and `AudioClip`. There is no FMOD or Wwise. A single `AudioSource` on `MusicController` plays one clip at a time; sound effects use the separate `AudioManager`. ## Classes [Section titled “Classes”](#classes) | Class | Role | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `MusicController : MonoBehaviour` | Singleton (`Instance`, static slot 0x0) that owns the `AudioSource` and plays tracks | | `MusicTrack : ScriptableObject` (`Assets.Scripts.Audio.Music`) | One track: optional `intro` clip and a looping `loop` clip | | `MusicUtility` (`Assets.Scripts.Audio.Music`) | Static helper that picks the track for a run | | `MusicSelectionUi` | Main-menu jukebox; sets `RunConfig.musicTrackIndex` | | `MusicPauseZone` | Ducks the music while the player is inside a radius | | `LowpassController` | Drives the `MusicVolume` and `MusicLowpass` mixer parameters | `MusicController` fields: `musicSource` 0x20, `lowpassFilter` 0x28, `volumeMultiplier` 0x30, `desiredVolumeMultiplier` 0x34, `desiredVolume` 0x38, `menuMusicTrack` 0x40, `currentTrack` 0x48, `isPlayingIntro` 0x50, `introLength` 0x54, `defaultPitch` 0x58. `MusicTrack` fields: `isEnabled` 0x18, `isInJukebox` 0x19, `isInRandomPool` 0x1A, `maxStageCompatibility` 0x1C, `category` 0x20 (`MusicCategory`: Forest 0, Desert 1, Other 2, Graveyard 3), `trackName` 0x28, `intro` 0x30, `loop` 0x38. Tracks are named assets; there are no numeric track ids. ## Methods and RVAs [Section titled “Methods and RVAs”](#methods-and-rvas) | Method | RVA | | ---------------------------------------------- | ------------------- | | `MusicController.OnStageStarted` | 0x3593D0 | | `MusicController.PlayStageMusic` | 0x359780 | | `MusicController.PlayMusicTrack(MusicTrack)` | 0x3595C0 | | `MusicController.PlayMenuTrack` | 0x359540 | | `MusicController.OnMainMenu` | 0x359130 | | `MusicController.StopMusic` | 0x3598E0 | | `MusicController.Update` | 0x35A110 | | `MusicUtility.GetMusicTrackToPlay(RunConfig)` | 0x4B40A0 | | `MusicUtility.GetTracks` / `GetTracksOther` | 0x4B4A40 / 0x4B4770 | | `MusicTrack.LoadToMemory` / `UnloadFromMemory` | 0x4B3F20 / 0x4B3FD0 | | `MusicSelectionUi.UpdateTrack` | 0x573F30 | Each RVA is listed once in the dump, so none is a folded shared stub. ## Selection flow [Section titled “Selection flow”](#selection-flow) A stage start reaches the music through two entry points that behave the same way: 1. `OnStageStarted` zeroes `volumeMultiplier`, then returns early when `MapController.isFinalBossStage` (static 0x20) is set. Otherwise it calls `GetMusicTrackToPlay(MapController.runConfig)` and passes the result to `PlayMusicTrack`. 2. `PlayStageMusic` does the same without the final-boss check. `GetMusicTrackToPlay(runConfig)` is the only function that picks a stage track, and its two callers are the methods above. Its logic: * On the final boss stage it returns `runConfig.mapData.bossTrack` (`MapData` 0xB8) when that is set. * When `runConfig.musicTrackIndex` (0x30) is not -1 it returns `GetTracks()[musicTrackIndex]`. This is the jukebox choice. * Otherwise it picks randomly: about 15% of the time the current character’s `themeSong` (`CharacterData` 0x78, subject to `themeTrackPlayedLastRound`) or a `GetTracksOther()` entry, and else the next entry of `mapData.musicTracks` (`MapData` 0xB0), tracked per `EMap` in `mapTrackRotation`. `PlayMusicTrack(track)`: 1. Calls `UnloadFromMemory` on the previous `currentTrack` when it differs from the new one. 2. Returns without changes when the new track equals `currentTrack` and `volumeMultiplier` is not 0. 3. Stores `currentTrack`, then calls `LoadToMemory`, which calls `AudioClip.LoadAudioData` on `intro` and `loop` when each is non-null. 4. With a non-null `intro`: sets the source clip to `intro`, `loop = false`, `isPlayingIntro = true` and `introLength = intro.length`; `Update` switches to `loop` once the intro ends. 5. With a null `intro`: sets `loop = true`, `isPlayingIntro = false` and the source clip to `loop`. 6. Calls `Play` on the source and sets `volumeMultiplier` to 1. A track with only a `loop` clip is therefore valid and starts playing immediately. Other callers of `PlayMusicTrack`: `OnMainMenu`, `PlayMenuTrack` (menu track), `MusicSelectionUi.UpdateTrack` (jukebox preview), and the method at 0x505F90, which plays a boss-room track (`GraveyardBossRoom.musicTrackBoss` 0x60) when its timer elapses. These bypass `GetMusicTrackToPlay`. ## Level music [Section titled “Level music”](#level-music) A level file’s optional `music` field (see [level-format.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/level-format.md)) selects either `bundle:/` or `track:`. For example, `"music": "track:14 Strobe"` selects `UserData/CustomMusic/14 Strobe.mp3` without rebuilding a pack. Runtime lookup is case-insensitive; the `track:` prefix is lowercase. The mod replaces the stock stage music with it: 1. `LevelLoaderPatch` calls `LevelMusic.Prepare` before the run starts. It parses the reference (`LevelMusicReference`). Bundle references use `PackPrefabs.ResolveClip`; runtime references resolve only the startup catalog and decode through `RuntimeMusicSession`, `Mp3Decoder` and `RuntimeMusicTrack`. Both produce a `MusicTrack` whose `loop` is the clip and whose `intro` stays null. 2. A Harmony prefix on `MusicUtility.GetMusicTrackToPlay` returns that track and skips the original while a custom run is active (`CustomLevelState.IsActive`). A vanilla run, or a level without `music`, passes through to the stock logic. 3. `OnStageStarted` and `PlayStageMusic` then hand the track to `PlayMusicTrack`, which loops the clip. `PackPrefabs` collects every `AudioClip` in a pack’s bundles next to the prefabs when it loads the pack. The clip is looked up by its asset name, which is the file name without extension. A manifest `assets` list filters prefabs only and does not list clips. Not covered: callers that pass a track straight to `PlayMusicTrack` (main menu, the jukebox preview, the boss-room trigger at 0x505F90) and a final boss stage, where `OnStageStarted` does nothing and the boss room picks its own track. An editor save writes the loaded `music` value back (`LiveEditPatch.SnapshotLevelFile`). Loading a level file from the editor’s load list does not restart the music, the same as it does not re-apply `noGrass`, `playerStart` or `maxSlopeAngle`. ## Radio [Section titled “Radio”](#radio) The THPS Warehouse places the game’s boombox (`interactable/boombox`, `EffectManager.boomboxQuest`) as a radio. While a custom run with a level `music` reference is active, a Harmony patch on `InteractableBoombox.Interact` plays the next clip of the same pack through `MusicController.PlayMusicTrack`, so it replaces the level music everywhere, not just near the boombox. `RadioPlaylist` orders the pack’s clips by name (the file names carry the soundtrack order, `01_Police_Truck` through `13_Le_Hot_99`), continues from the level’s own clip (`StartAfter`, so the first press after `04_Superman` plays `05_Jerry_Was_A_Race_Car_Driver`) and wraps. Both the level clip and a radio clip are looping clips, so `LevelMusic.Tick` (from `OnUpdate`) watches the music source’s playback time and, when it jumps back to the start, plays the next clip instead of letting the same one repeat (`RadioPlaylist.LoopWrapped`). It only follows clips the mod handed to `PlayMusicTrack`; the menu, jukebox and boss tracks are left alone. Each press calls `Next` once, so presses made in quick succession skip clips. The patch skips the stock interaction, which is single-use (it sets the private `done` flag) and throws a `NullReferenceException` when run a second time, so the boombox stays pressable and cycles through the soundtrack. A level without `music`, or a vanilla run, leaves the boombox alone. If the pack holds no clip to play, or playing it throws, the patch logs `Radio: ...; using the boombox's stock audio.` as a warning and the stock interaction runs unchanged. For a `track:` level, the playlist instead contains only startup-discovered runtime files in numeric order (below). It starts after the selected file, wraps, and uses the same boombox and natural-loop-end hooks. Each successful selection prefetches the successor’s managed PCM; Unity object creation and switching stay on the game thread. Rapid presses consume successive entries, not parallel playback requests. A failed runtime successor disables custom playback, cancels prefetch, releases owned objects and starts stock stage music rather than invoking the single-use stock boombox. Runtime and pack playlists never mix. ## Fallback [Section titled “Fallback”](#fallback) Invalid references, missing pack clips or runtime filenames, unreadable/deleted files, decode limits/errors, unsupported native bindings and clip-creation failures log `Music: ...; using the stock music.` and leave the level load intact. A failed initial selection returns to normal stock selection; a failed runtime successor explicitly starts stock stage music. The original reference remains available for editor saves after a failure. No `music` means stock selection without a warning. ## Audio formats and decoding [Section titled “Audio formats and decoding”](#audio-formats-and-decoding) Bundle playback uses imported clips. Audio is imported into the pack in the authoring project and ships as a normal `AudioClip` in the bundle (compressed in memory as Vorbis, quality 0.7). Any format the Unity editor imports works as the source, including mp3, ogg and wav. `ThpsPack.SaveSoundtrack` imports every mp3 in `tools/ost` (override the directory with `-musicSource`), naming each clip by its file name with runs of other characters made an underscore; the build skips them with a log line when the directory is absent, so the pack still builds without them. `scripts/transcode.py` can produce an mp3 from a lossless source. Runtime `track:` playback decodes mp3s using the native clip path below, despite failures in the ordinary managed wrappers. The runtime paths that fail, confirmed in the running game: * `UnityWebRequestMultimedia.GetAudioClip` throws `MissingMethodException` for `DownloadHandlerAudioClip..ctor(String, AudioType)`. * `AudioClip.Create` throws `MissingMethodException` for `Il2CppSystem.ReadOnlySpan.GetPinnableReference()`, which its name marshalling needs; `SetData` takes the same span path. * An initial direct `Construct_Internal_Injected` / `CreateUserSound_Injected` attempt with a hand-built span crashed with no managed log. This does not establish that native clip creation is unavailable: the corrected handle/pointer path below succeeds. ## Runtime music catalog [Section titled “Runtime music catalog”](#runtime-music-catalog) At mod startup, the runtime catalog scans only the top level of `/UserData/CustomMusic/` for `.mp3` files (extension matching is case-insensitive). Create that folder and copy files there before launching; restart after adding or renaming files. `tools/ost/` is a host-side source folder, not a discovery folder. The catalog does not decode files at startup. Track names are filenames without the extension, preserving spaces and dots. Lookup is case-insensitive and resolves only discovered entries, never arbitrary paths. Case-colliding names are all excluded with a warning; rename them to distinct names. Linked files and names containing path separators, colons or control characters are excluded. An absent folder yields an empty catalog; an unreadable folder warns without throwing. Discovery does not decode or verify audio contents: playback must handle files that disappear, become unreadable or fail decoding afterward. Order follows leading ASCII integer components matching `[0-9]+(\.[0-9]+)*`, not decimal numbers: `6 Base`, `06.01 Screamer`, `06.02 Nothing to Me`, `06.10 Ten`, `14 Strobe`. Components compare numerically regardless of leading zeros or size; a shorter equal prefix sorts first (`6` before `6.0`). Equal numeric prefixes use the complete extensionless name in ordinal (case-sensitive, culture-independent) order. Files without a numeric prefix sort last, also by ordinal name. Runtime playlists can preserve this supplied order; pack playlists retain their existing ordinal clip-name sorting. ## Runtime PCM probe [Section titled “Runtime PCM probe”](#runtime-pcm-probe) The development harness can decode mp3 files and audition either native `AudioClip` playback or buffer injection. Native probing and production `track:` playback share the clip factory, decoder and resource owner below; `bundle:` playback remains separate. See [development.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/development.md#runtime-music-probe) for commands. ### Decoder and output decision [Section titled “Decoder and output decision”](#decoder-and-output-decision) NLayer 1.16.0 decodes mp3 files as managed float PCM under .NET 6. The BepInEx mod build copies `NLayer.dll` beside the plugin in `BepInEx/plugins/MegabonkLevels/`; `CopyLocalLockFileAssemblies` is needed to get the dependency into the build output. A complete in-game decode of `01 Police Truck.mp3` produced 44,100 Hz stereo, 12,789,504 samples (6,394,752 frames), and peak 1.0414, matching the host decode and the earlier MelonLoader run. The 14 source files include both 44.1 and 48 kHz material and decoded successfully on the host; decoder peaks can exceed 1.0. **Prefer a real native `AudioClip`**, populated with NLayer PCM and passed to the existing `MusicTrack` path. The native probe created a two-second 44.1 kHz tone and then a 145.005-second stereo clip from the relative file `01 Police Truck.mp3`. Native `GetData_Injected` returned an exact match for the checked PCM prefix (up to 4,096 frames). the maintainer heard the tone and the song, and verified the modulation behavior below in THPS. No crash occurred in the corrected native attempts. The OS-audio fallback was not needed. ### Native binding details [Section titled “Native binding details”](#native-binding-details) The verified player is Win64 Unity 2023.2.22f1, running under Wine on macOS and under Proton on Linux. `UnityPlayer.dll` still registers the relevant internal calls even though their managed methods were stripped. Resolve them by name with `IL2CPP.il2cpp_resolve_icall`, not hardcoded addresses. The inspected RVAs were `Construct_Internal_Injected` 0x1217A0, `CreateUserSound_Injected` 0x1218A0, `SetData_Injected` 0x1216F0 and `GetData_Injected` 0x121660; these are evidence for this binary, not portable constants. The important boundary is **GC handle → Il2Cpp object → native Unity object**: 1. Use `AudioClip.Construct_Internal()`, whose generated wrapper calls `Unmarshal.UnmarshalUnityObject` on the handle returned by `Construct_Internal_Injected`. Do not wrap that handle with `new AudioClip(handle)` or treat it as a native pointer. 2. Read the resulting clip’s `m_CachedPtr`. This native pointer, not the Il2Cpp object’s `Pointer`, is the `self` argument for the injected creation/data calls. 3. Call `CreateUserSound_Injected` with a pinned UTF-16 name described by a blittable `{ IntPtr begin; int length; }` span, frame count, channels, source rate and a zero streaming byte. This avoids the stripped Il2Cpp span marshalling helper. 4. Pin each managed PCM block and pass a span of **sample count**, not frame count, to `SetData_Injected`; the offset is in **frames**. The call is synchronous; unpin after it returns. The native boolean return is one byte. Read back every block through `GetData_Injected` before playback; reject a non-finite result or maximum error above 0.0001. 5. Keep the clip and `MusicTrack` alive, set `track.loop = clip`, and use `MusicController.PlayMusicTrack`. No audio-thread injection or software resampling is needed for this path; Unity samples a clip at its declared source rate. Native Linux playback under Proton was verified on a Fedora Linux host (see below). The native probe’s non-Win64 guard was kept: inspect and validate bindings on any new ABI before enabling them. ### Observed signal behavior [Section titled “Observed signal behavior”](#observed-signal-behavior) | Control | Observed result with the native mp3 clip | | ------------------------------ | --------------------------------------------------------------------------------------------- | | Normal playback | Police Truck clearly audible in THPS with no injected filter attached | | Menus and item choices | the maintainer confirmed the game’s natural ducking works | | Source pitch held at 0.55 | Later status confirmed the value persisted; the maintainer heard slower tempo and lower pitch | | Source low-pass held at 400 Hz | Later status confirmed the cutoff persisted; the maintainer heard clear muffling | Release test overrides afterward: `music-probe pitch` and `music-probe lowpass` without `--value` return control to the game. The native clip enters the same playback path as a pack clip; these tests establish the key observed behaviors, not exhaustive parity across every game state or platform. The separate mixer `MusicLowpass` parameter was not independently forced. ### Buffer-injection alternative [Section titled “Buffer-injection alternative”](#buffer-injection-alternative) An injected `MonoBehaviour.OnAudioFilterRead(Il2CppStructArray, int)` also produces audible audio on the music source’s GameObject. `AsSpan()` views Unity’s buffer without copying it. The managed `float[]` signature failed: Il2CppInterop logged `has unsupported parameter Single[] data of type System.Single[]`, skipped the method, and its callback count stayed zero. the maintainer confirmed injected music follows the Options Music slider, but heard no duck when the source volume read 0.2 during resumed gameplay. Source `Pause()` stops callback/frame counters; frozen editor gameplay does not. Setting `clip = null` leaves `isPlaying` false but callbacks and audible injected music continue. Attaching to a playing source does not require a restart. Earlier one-shot pitch and low-pass tests reported no audible change, but later readings exposed the game resetting those properties. Those tests are **inconclusive**, not proof the processing is bypassed; held-control tests were subsequently performed on native clips only. Component/filter ordering would also need checking before using injection for parity. The injection probe copies one source frame per output frame without resampling. A 24.21-second measurement consumed about 48,047 frames/second, so 44.1 kHz material advances about 8.95% too fast on that output path, although the maintainer did not notice the speed difference by ear. Callback copy cost was in the tens of microseconds, versus about 21 ms for 1,024 frames at 48 kHz; this is diagnostic timing, not a production benchmark. ### Implications for runtime playback [Section titled “Implications for runtime playback”](#implications-for-runtime-playback) Runtime playback uses native clip creation and the existing `MusicTrack` pipeline. There is no need to reproduce the game’s pitch, ducking or low-pass in a custom callback on the verified player. The platform guard is deliberate: inspect and validate bindings on any new ABI before enabling them. The native probe decodes a whole track synchronously into bounded PCM blocks and loops it. It does not implement streaming, `track:` references or playlists. Switching native probe clips releases the previous probe track and clip after playback switches; relaunch returns to a clean stock baseline. The shared owner supports prefetch, but the probe does not schedule a successor. ### Production decoder and ownership [Section titled “Production decoder and ownership”](#production-decoder-and-ownership) `Mp3Decoder` uses NLayer 1.16.0, opening the input read-only and disposing the stream/decoder on success, failure or cancellation. Files must be nonempty and at most 64 MiB at open time. `PcmAudio.Read` packs interleaved floats into 8,192-sample blocks, including short decoder reads, with at most 256 MiB of PCM per track. It accepts mono/stereo at 8–48 kHz, rejects empty output, incomplete frames and non-finite samples, and preserves finite peaks above 1.0. Limits are checked during decoding, not trusted from duration metadata. The source’s own sample rate is retained. Cancellation is checked before/between reads; opening and NLayer’s header processing are synchronous and cannot be interrupted mid-call. `RuntimeMusicOwner` is confined to its creating (game) thread and owns one current Unity resource and at most one managed-only successor decode. `Prefetch` never creates Unity objects on its worker. A new request cancels and joins the previous worker before starting another, so repeated requests cannot accumulate workers or stale results. Selecting a prefetched track consumes its decoded PCM; otherwise selection cancels/discards prefetch and decodes synchronously. Selected decode errors reach the caller; errors from discarded prefetch are observed and ignored. Creation/play failures retain ownership of the previous resource and release any newly created candidate. The caller remains responsible for fallback if playback partially changes the source before throwing. The current track keeps no managed PCM once native creation completes. A switch temporarily overlaps the old clip, decoded successor PCM and new clip (up to roughly 768 MiB at the per-track limit, plus decoder/Unity overhead); normally it retains the current native clip plus at most 256 MiB of prefetched PCM. Unity destruction is deferred, and managed buffers become eligible for collection rather than being synchronously freed. This is bounded whole-track decoding, not streaming or a guarantee about process RSS. The nine-minute `13 Le Hot '99` alone needs about 206 MiB of stereo 48 kHz PCM, so a blanket 50 MiB-per-track estimate is not valid. `RuntimeMusicTrack` owns only its generated `MusicTrack` and `AudioClip`, never a pack asset. Disposal on the game thread detaches its clip if still active, clears the controller’s reference if it still points to that track, and destroys both objects; disposing an inactive resource does not stop a replacement or menu track. Owner disposal cancels/joins pending decoding and releases current ownership exactly once. `LevelMusic` releases its session on preparing a replacement level, entering the main menu, starting a vanilla run, loss of custom-run state, fallback or mod shutdown. Bundle cleanup destroys generated track wrappers only, never the pack’s clips. The native probe separately releases its resources once another game track takes over. When another game track takes over, `PlayMusicTrack` unloads the previous clip. Imported bundle clips can reload their asset data, but a generated runtime clip cannot: trying to reuse it leaves the source stopped. The next stage selection therefore re-decodes/rebuilds the current runtime track and restarts successor prefetch. A radio press still advances normally to a newly constructed successor. Failure to rebuild falls back to stock selection. The shared block upload path was checked on the default Wine setup with the two-second tone, `01 Police Truck` (44.1 kHz stereo, 145.005 seconds), `13 Le Hot '99` (48 kHz stereo, 562.344 seconds) and `14 Strobe` (44.1 kHz stereo, 635.872 seconds). Every block read back with zero error; switching long-track → tone → Police Truck and Le Hot → Strobe reported active sources without a crash. Strobe was read directly through Wine’s `Z:` host mapping, not copied into or discovered from the runtime folder. Invalid MPEG input warned and left Police Truck playing. These are transfer/source-state checks, not a new human listening confirmation; the earlier audible evidence above still applies to the native path. Native Linux remains guarded and unverified. Production integration was checked on default Wine (CrossOver/macOS) and verified end-to-end under Proton Experimental on Fedora Linux (Windows depot 3405342, `-force-d3d11`, Intel Arc MTL, Megabonk 1.0.69, MelonLoader 0.7.3). On fedora: startup discovery selected `track:14 strobe` (lowercase) from the runtime folder as the 635.872-second Strobe clip. Four-second fixtures naturally progressed through dotted numeric names and wrapped; repeated boombox calls advanced through the soundtrack. Missing, permission-denied and invalid-MPEG initial selections left a fully loaded level with playing stock music; an invalid successor released custom playback and resumed stock music. Menu playback released the session and prefetch. Bundle Superman → Jerry → Screamer progression, end detection, last-to-first wrap and replaying a previously unloaded bundle clip all reported playing sources. Runtime pause/unpause, held pitch/filter and duck controls were verified by source state, with overrides released afterward. the maintainer confirmed audible playback under Proton: “In the editor. Strobe is playing” and “It’s been working for the last dozen or so builds”. These integration checks do **not** constitute a complete itemized human-listening confirmation of all transitions, volume-slider response and natural ducking; they record what was observed. Earlier listening results for those behaviors apply to the shared native path. If both stock and injected music are silent while callbacks advance, first verify host audio with a native player and relaunch the game. In the test, an old Wine process was silent even with the probe detached; a fresh launch on the current Bluetooth output restored stock and injected audio. This is consistent with a stale output-device handle, not evidence that PCM injection failed. ## THPS soundtrack sources [Section titled “THPS soundtrack sources”](#thps-soundtrack-sources) The original PlayStation Tony Hawk’s Pro Skater soundtrack has 14 tracks. Screamer and Nothing to Me are one file in the game. Ain’t Got Time to Waste, Blue Thunder and Le Hot ’99 are exclusive to the European release (titled Tony Hawk’s Skateboarding); the first of those was dropped from the European Dreamcast and N64 ports. Albums and positions come from Discogs and MusicBrainz listings. | Track | Artist | Source release | | --------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | Police Truck | Dead Kennedys | *Give Me Convenience or Give Me Death* (1987 compilation); first issued in 1980 as the B-side of the “Holiday in Cambodia” single | | Here & Now | The Ernies | *Meson Ray* (1999) | | Vilified | Even Rude | *Superabsorbent* (1998), track 12 | | Superman | Goldfinger | *Hang-Ups* (1997), track 1 | | Jerry Was a Race Car Driver | Primus | *Sailing the Seas of Cheese* (1991), track 5 | | Screamer / Nothing to Me | Speedealer (credited REO Speedealer) | *REO Speedealer* (1998), tracks 2 and 11 of 15; reissued as *Speedealer* (2001) | | Cyco Vision | Suicidal Tendencies | *Freedumb* (1999), track 5 | | New Girl | The Suicide Machines | *Destruction by Definition* (1996), track 1 | | Committed | Unsane | *Occupational Hazard* (1998, Relapse Records), track 1 | | Euro-Barge | The Vandals | *Hitler Bad, Vandals Good* (1998), track 10 | | Ain’t Got Time to Waste | Aim feat. YZ | *Cold Water Music* (1999, ATIC Records), track 6; ripper metadata often dates it 2007, the year of a later reissue | | Blue Thunder | Aquasky | *Bodyshock* (1999), disc 1 track 3 of 8 (a two-disc set) | | Le Hot ’99 | Grand Unified | *Skinful Vol. 3* (1999, Second Skin compilation), track 12 of 12; a remix of the 1998 12” EP *Le Hot*, also on the *Skinful Vol. 3 Sampler* 12” | Two of these are widely misattributed. Cyco Vision is not on *Suicidal for Life* (1994), despite circulating under that album in soundtrack listings; the recording is from *Freedumb*. Committed is not on *Scattered, Smothered & Covered* (1995) either, which does not contain it. Discogs also lists unofficial vinyl pressings of the game soundtrack, including the three European tracks, as an alternative to buying each release. The mp3s live under `tools/ost/`, which is gitignored along with the rest of `tools/`. Filenames carry the in-game running order (`06.01` and `06.02` are the two halves of the one file the game ships for Screamer / Nothing to Me); the ID3 tags describe the source album instead, holding the artist, album, year and position on that album rather than a soundtrack numbering, so a rip can be checked against its release. Tags are ID3 v2.3 with latin1 text and no comments, lyrics or attached images. ## Open questions [Section titled “Open questions”](#open-questions) * What `Update` does with a null intro beyond `isPlayingIntro = false`; the track-switch path above does not set `introLength` for it, so it is expected to leave the loop alone. * Whether `MusicTrack.UnloadFromMemory` (`AudioClip.UnloadAudioData`) on a pack clip, when another track replaces it, leaves the clip playable on the next stage; confirm in game. * Which mix of Le Hot ’99 the game uses: the rip runs 9:23 against the 6:47 *Skinful Vol. 3* cut, so it looks like an extended version of the 12” rather than the compilation track. # Player guide How to install **Megabonk Level Editor** and play levels other people have made. For building or changing the mod itself, read [modding-setup.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/modding-setup.md). ## What this is [Section titled “What this is”](#what-this-is) Megabonk Level Editor is an unofficial, fan-made mod. It is not made, endorsed or supported by the developer of Megabonk, and it is not part of Steam Workshop: there is no Subscribe button in Steam. The mod loads through **BepInEx 6 IL2CPP** and adds custom levels to Map Selection beside the built-in maps. Levels are shared as plain files you copy into the game’s folder. The editor package contains no levels, third-party asset packs or music. ## Before you start [Section titled “Before you start”](#before-you-start) * Install Megabonk through Steam and start it once. * Quit the game, and ideally Steam, before installing. * You need an internet connection the first time you launch the modded game. It downloads some tools and takes about half a minute while the screen stays black. * The mod supports the **Windows build** of Megabonk. On Linux and on a Mac, run that build through Steam Play (Proton) or Wine. Install those separately; the mod ZIP does not install Steam, Wine or the game. * Do not activate MelonLoader and BepInEx together. Keep a backup of an older setup and follow the [loader rollback guide](/docs/thunderstore/#rollback) when switching between them. ## Installing [Section titled “Installing”](#installing) The public Thunderstore release has not been published yet. For local testing, the package is built from source with `task package:thunderstore`; get that ZIP from its builder and import it through your manager’s local/custom-mod option. Its package identifier is `Megabonk_Level_Editor`. 1. Select **Megabonk** in a compatible mod manager and create a clean profile. 2. Install **BepInExPack IL2CPP 6.0.788** and import the editor ZIP. Apply the profile to the game. 3. Start the modded game. Check `BepInEx/LogOutput.log` for `Megabonk Level Editor` and `MegabonkLevels mod initialized.`. 4. Add a level as described below, then restart the game to see it on Map Selection. On macOS, the tested setup is r2modmac 1.1.0 for managing files and plain Wine 11.18 for launching. Apply omits the BepInEx pack’s `dotnet/` runtime, and its Play button did not launch the Wine-hosted game. Follow the [package README’s Wine instructions](https://github.com/pythoninthegrass/megabonk_mods/blob/main/pkg/README.md#macos--wine-and-linux--proton) for the runtime and `winhttp` override. For manual installation, see [Installing by hand](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/modding-setup.md#installing-by-hand-for-players). Disable the editor in the manager and Apply to remove it. Keep your levels, packs and music under `UserData/`; disabling the mod does not remove them. ## Try Bramblecrown Hill [Section titled “Try Bramblecrown Hill”](#try-bramblecrown-hill) **Bramblecrown Hill** is an example level supplied separately from the editor. Get `levels/bramblecrown-hill.mblevel.json` from the source repository or the person who shared the mod, then copy it into `UserData/Levels/`. It needs no extra packs or music. See [Bramblecrown Hill](/docs/starter-level/) for a tour of its five districts and a short move-rock, save-copy, replay tutorial. The old standalone installer format is documented in [player-installer.md](/docs/player-installer/) as a legacy MelonLoader reference only; it is not compatible with the current BepInEx DLL. ## Finding your levels folder [Section titled “Finding your levels folder”](#finding-your-levels-folder) Levels live in the game’s own folder, in `UserData/Levels/`: 1. In Steam, right-click Megabonk, choose Manage, then Browse local files. That opens the game folder. 2. Open `UserData`, then `Levels`. If either folder is missing, create it with exactly that name. A level is a single file ending in `.mblevel.json`. ## Playing a level someone shared [Section titled “Playing a level someone shared”](#playing-a-level-someone-shared) 1. Save the `.mblevel.json` file they sent you. 2. Copy it into `UserData/Levels/`. 3. Start Megabonk. The level appears on the Map Selection screen beside the built-in maps, under the title the author gave it. If a level shows up but looks empty or as a placeholder, it needs an asset pack you do not have. The mod logs the missing pack’s name in `BepInEx/LogOutput.log` in the game folder as `pack '' is not installed`. Ask the author for the pack, which is a folder to place in `UserData/LevelPacks/`. A level can have a picture on the Map Selection screen: put an image with the same name as the level next to it, for example `thps.png` beside `thps.mblevel.json`. ## Sharing a level [Section titled “Sharing a level”](#sharing-a-level) Send the `.mblevel.json` file from your `UserData/Levels/` folder, as a Discord attachment, a forum upload or by any other way you would send a file. If it has a picture, send that too. If your level uses an asset pack, say which one, because the person receiving it needs it as well. Levels from other people are data files the game reads, not programs, but the mod itself is code from a third party. Install the mod only from someone you trust. # Player installer **Legacy reference:** this format belongs to the old MelonLoader-only runtime, which is not retained in the public history. It cannot install the current BepInEx DLL. Use the [player guide](/docs/player-guide/) and [Thunderstore package](/docs/thunderstore/) for Megabonk Level Editor; the old platform package tasks are internal in current source and reject direct CLI invocation. The player-facing download: a folder a Linux player unzips and runs `./megabonk-installer` in, which installs MelonLoader, the MegabonkLevels mod, the .NET 6 runtime it needs, and any levels or packs the package carries. This doc is the contract between the two halves — the **package** the build produces and the **installer** that consumes it — plus the Linux-specific behaviour that contract cannot express. The installer is one static Go binary per platform, built from `go/` by `task installer:build` and shipped inside the package, so a player needs nothing else: no Python, no uv, no repo checkout, no .NET SDK, no Wine installation. The package builder and verifier (`scripts/build_player_package.py`, `task package:check`) are Python and share a small contract module with the installer, `scripts/package_contract/`: the manifest schema in `manifest.py` and the pack checks in `packs.py`. The installer parses the same manifest in `go/internal/manifest`. ## Linux player setup [Section titled “Linux player setup”](#linux-player-setup) Use the **Windows game under Proton/Wine**, not the native Linux build. In Steam, open Megabonk’s Properties → Compatibility and select Proton, then wait for the Windows download to finish. Selecting Proton in the GUI triggered the depot switch on Fedora; editing Steam’s configuration alone did not. Launch vanilla once to create the prefix, choose **DX11 - Recommended**, then quit the game before installing. `-force-d3d11` in Steam’s Launch Options pins that renderer. Unzip the player package and run `./megabonk-installer`. Packages are currently built with `task package:linux`; a published player download URL is not yet available. No separate Wine install is needed with Steam Proton. For a game whose prefix is in a different Steam library, pass `--prefix` explicitly on both install and check: automatic prefix discovery currently assumes the game’s library. The following prefix-mode sequence loaded the mod on the CachyOS Proton host. Replace both paths with your actual game and active prefix paths; while vanilla is running, its `STEAM_COMPAT_DATA_PATH` environment variable names the compatdata directory (append `/pfx`). See [the gamepadui guide](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/gamepadui.md#install-launch-and-verify) for measurement, package transfer, and remote launch commands. ```sh cd /path/to/unzipped-player-package G=/media/sdcard/SteamLibrary/steamapps/common/Megabonk P="$HOME/.local/share/Steam/steamapps/compatdata/3405340/pfx" ./megabonk-installer --game-dir "$G" --prefix "$P" --dotnet-in prefix --wine-override registry ./megabonk-installer --game-dir "$G" --prefix "$P" --dotnet-in prefix --wine-override registry --check ``` `--check` must exit 0 and report that the install matches the package. Start Megabonk from Steam and check the **new launch’s** `MelonLoader/Latest.log` for the pinned version banner, `1 Mod loaded.` and `[MegabonkLevels] MegabonkLevels mod initialized.`. First-launch assembly generation took about 40 seconds on the tested host; a cached launch need not repeat `Assembly Generation Successful!`. Prefix-mode .NET needed no `DOTNET_ROOT` launch option. Inspect the install record for the actual DLL override method: the observed run reported `via: wine` despite requesting registry mode. Do not infer success from the Steam launch command or an old log. Re-run the same install command to repair drift. With the game stopped, `./megabonk-installer --game-dir "$G" --prefix "$P" --uninstall` removes recorded installer-owned files, not unrelated mods or saves. Packs must contain Windows bundles even though the host OS is Linux. ## Package layout [Section titled “Package layout”](#package-layout) ```text megabonk-levels--ml-linux/ ├── megabonk-installer the Go installer for this platform (install.exe on Windows) ├── manifest.json the contract, described below ├── vendor/ vendor archives, named exactly as the manifest names them ├── content/levels/ bundled .mblevel.json files and their sidecar thumbnails └── content/packs// bundle + manifest.json (+ attachments.json) per pack ``` `megabonk-installer` is the only entry point a player needs: a static binary that reads `manifest.json` from the folder it runs in (or `--package `), so there is no interpreter to choose and nothing to put on a path. The builder copies the platform’s binary from `.tools/installer/` and marks it executable; a package build without `--installer-binary` fails, and a missing file is a missing input that points at `task installer:build`. ## The manifest [Section titled “The manifest”](#the-manifest) `manifest.json` is a JSON object with `schema` `megabonk-player-package/1`. `load_manifest` in `scripts/package_contract/manifest.py` (the builder’s side) and `LoadManifest` in `go/internal/manifest` (the installer’s) refuse anything they cannot use, naming the missing or bad key rather than failing later during the install. | Key | Meaning | | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `schema` | `megabonk-player-package/1`. An unknown schema is an error, not a warning: an old installer must not half-install a package from a newer build. | | `platform` | The OS this package targets: `linux` or `windows`. Both installers accept either value; the manifest differs only in this key. | | `package_id` | Name in the install record and in `--check` output. | | `mod_version`, `melonloader_version`, `game_buildid` | Recorded at install time and echoed back by `--check` and `--uninstall`. `game_buildid` is the depot build id the shipped levels were authored against (`GAME_BUILD_ID` in `scripts/build_thps_level.py`), so a package and a level file agree about which game they expect. | | `generated` | UTC timestamp of the build. Set `SOURCE_DATE_EPOCH` for a byte-identical rebuild. | | `verified_on` | Distro/version pairs this package was actually tested on. Empty means untested, and it is not a claim of support. | | `vendors` | The archives under `vendor/`. | | `files` | Every file to place, and where. | ### vendors [Section titled “vendors”](#vendors) Each entry needs `name`, `url`, `sha256`, `kind`, `version`. `name` is the **filename under `vendor/`**, not a display name — the installer resolves an archive as `vendor/`. `kind` is the lookup key: the installer asks for `manifest.vendor("melonloader")` and `manifest.vendor("dotnet")`, so a package that omits either kind is rejected before anything is copied. Only those two kinds exist today: MelonLoader’s Windows release archive, and a base Windows x64 .NET 6 runtime. Both archives are the **Windows** ones on purpose, even though the target is Linux: MelonLoader’s native Linux build cannot load the native Linux game build, so the whole stack is the Windows stack running under Proton. `url` is provenance, not a download instruction: the installer never goes to the network. It verifies the copy under `vendor/` against `sha256` and stops if it does not match. ### files [Section titled “files”](#files) Each entry needs `path` (relative to the package root), `sha256`, and `dest` (relative to the game directory). Destinations follow what the game and the mod actually read: | Content | `dest` | Why there | | ----------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Mod DLL | `Mods/MegabonkLevels.Mod.dll` | Where MelonLoader loads mods from. | | Managed dependencies (NLayer) | `UserLibs/NLayer.dll` | MelonLoader’s probe path for third-party assemblies. | | Levels | `UserData/Levels/.mblevel.json` | `LiveEditPatch.LevelsDirectoryPath()` lists titled levels from here, and Map Selection shows them beside the native maps. A level anywhere else is invisible to the player. | | Packs | `UserData/LevelPacks//{,manifest.json,attachments.json}` | `PackPrefabs` loads packs from this layout; see [asset-bundles.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/asset-bundles.md). | Bundled content is ordinary `files[]` entries, so nothing in the schema is special about it: the same digest check covers it, and `--uninstall` removes it by the same record that removes the mod. `verify_package` checks every `vendor/` and `files[].path` against its digest and returns a message per problem — missing file or checksum mismatch — which is what `task package:check` runs. ## Install record [Section titled “Install record”](#install-record) After a successful install the installer writes `MegabonkLevels.installer.json` into the game directory, schema `megabonk-levels-install/1`: package id and versions, `installed_at`, the relative paths of every mod file, level and pack it placed, where the .NET runtime went and with what launch option, and how the `version.dll` override was applied. `game_buildid` is the build the package was verified against; `installed_game_buildid` is the build Steam’s appmanifest reported when the installer ran, or `null` when it reported none. The second key is optional, so records written before it existed still load under the same schema. That one file drives both `--check` and `--uninstall`. Uninstall is deliberately record-driven rather than a glob: it removes what it recorded placing and leaves anything it did not put there alone, so a player’s own `Mods/` entries, saves, and `UserData` survive. ## Exit codes [Section titled “Exit codes”](#exit-codes) | Code | Meaning | | ---- | ---------------------------------------------------------------------------------------------------------------------------- | | 0 | Done, nothing needed. | | 1 | Error (bad package, checksum mismatch, I/O). | | 2 | Usage — e.g. no `--package`. | | 3 | Nothing found to work on: no Steam install, no game directory, no package. | | 4 | The game directory is a build this installer will not touch, i.e. the native Linux build. | | 10 | `--check` found drift between the record and the package. A script can treat 10 as “reinstall” and 0 as “already installed”. | ## Installer contract [Section titled “Installer contract”](#installer-contract) Every platform’s installer — the same Go binary, built as `megabonk-installer` for Linux and macOS and `install.exe` for Windows — implements the steps below in this order, and a player’s experience differs between platforms only in how the installer is launched. The sections after this one describe the platform specifics; this one is the single list they must agree with, and the Go tests under `go/internal` hold the installer to it. 1. **Check the package.** Load `manifest.json` and verify every `vendor/` archive and `files[]` entry against its digest before touching the game. A bad or tampered package exits 1 with the list of mismatches and changes nothing. 2. **Find the game directory.** Use `--game-dir` when given. Otherwise read Steam’s recorded libraries and accept a directory only when it holds `Megabonk.exe`, `Megabonk.x86_64` or `Megabonk_Data/`. A game that cannot be found exits 3 and names every Steam client and library considered, each with why it was rejected, plus the flag to retry with. 3. **Classify the build.** Only the Windows build is supported, on every platform. The native Linux build is refused with exit 4 and the steps for switching to the Windows build. 4. **Compare the game build.** Read the installed build id from the library’s `appmanifest_3405340.acf` and print whether it matches the build the package was verified against. A difference prints a warning and the run continues, because a game update must not lock a player out; an absent appmanifest prints that the installed build is unknown. The installed id goes into the install record. 5. **Install MelonLoader at the pinned version** from `vendor/`, verified against the manifest checksum. The installer never goes to the network. 6. **Provide the .NET 6 runtime** from `vendor/` at user scope, inside the game directory or the Wine prefix, never machine-wide. Where it goes depends on the platform: [Windows package](#windows-package), [macOS package](#macos-package) and [.NET placement](#net-placement-and-the-launch-option) on Linux. 7. **Apply the `version.dll` override** where a Wine or Proton prefix needs it. Native Windows has none. 8. **Place the mod files** from `files[]`: the mod DLL and its managed dependencies. 9. **Create `UserData/Levels/`**, then **import levels** and **install packs** the package carries or that `--level` and `--pack` name. Inputs are validated first, so a bad level or pack is listed and nothing is half-installed. 10. **Write the install record**, which drives `--check` and `--uninstall`. 11. **Report** what was done and what was not, as numbered `N/M label` lines or a progress window. A failure exits nonzero with a message that says what to do. ### Re-running [Section titled “Re-running”](#re-running) A second run with nothing changed does nothing and says so. A run after something has gone missing or stale repairs only that: `--check` compares the install against the package by file digest and loader version marker, exits 10 when anything differs, and the next install run fixes just those entries. A wiped runtime is reinstalled together with its `version.dll` override, because losing one without the other leaves a game that starts and never loads a mod. After a game update the answer is the same run again. The game build comparison in step 4 tells the player whether the package still matches the game and the repair covers any loader file that went missing; if the build differs and the mod stops loading, the fix is a newer package, which the warning says. Whether Steam’s own update or file verification removes the loader files it did not install is not established here, and the game has not updated since February 2026, so no update has been observed; see [what is not verified](#what-is-verified-and-what-is-not). ### Uninstall [Section titled “Uninstall”](#uninstall) `--uninstall` is record-driven: it removes what the record says the installer placed, plus what the loader generates on first launch where the platform notes say so, and leaves a player’s own plugins, preferences, levels and saves alone. With no record it removes nothing. ## Building a package [Section titled “Building a package”](#building-a-package) ```sh task package:linux task package:linux MOD_VERSION=0.4.2 ``` `scripts/build_player_package.py` builds the mod, assembles everything into `.tools/player-package/`, and writes a distributable zip beside it. Two choices are intentional: * **The default is Bramblecrown Hill only.** The package tasks name `bramblecrown-hill.mblevel.json`, bringing its original selection illustration and biome icon, with no packs or music. See [Starter level](/docs/starter-level/) for the content inventory, first-edit tutorial and packaged smoke test. `levels/` also holds development fixtures, so there is no directory-wide bundling. Explicit `LEVELS` and `PACKS` overrides still support local packages; the lower-level builder bundles only files named with `--level` and `--pack`. A `.local.mblevel.json` is skipped even when named. Matching thumbnail and `.biome` sidecars are ordinary hashed manifest files, installed beside the level, repaired by a rerun and removed with it on uninstall. Images that match no bundled level stay out. * **Packs come from two places, the way `task pack:install` assembles them**: the built AssetBundle from `.tools/bundles//` and the manifest (plus optional attachments) from `unity/pack-authoring/packs//`. `--pack-platform` picks the bundle; `windows` is right on Linux because the game runs the Windows build. Staged packs are validated with `validate_pack_source` (`scripts/package_contract/packs.py`), which reproduces the game’s own rules — required `id`/`name`/`author`/`version`/`assets` fields, `id` matching the folder name, a bundle file present, and a `pack-attachments/1` sidecar when there is one — so a broken pack fails the build instead of landing in the Warehouse as a placeholder. Vendor archives are taken from `.tools/vendor-cache/` and only verified there, never downloaded unless you pass `--allow-download` (`ALLOW_DOWNLOAD=true` to the task, which forwards it). The build writes into a staging directory and swaps it in at the end, so a failure leaves the previous package untouched. When a download does happen it goes through `scripts/asset_download.py`, the only code in `scripts/` that touches the network (a test pins that). The rules: https only, TLS 1.2 floor with certificate verification; the status is checked before the body is read, so a 404, a login or captive-portal page, or an empty body reads as a missing asset rather than a checksum mismatch, and nothing poisoned ever lands in the cache; the body is capped, and an archive whose digest does not match the manifest pin is refused before it is cached. Redirects stay on the original host except for an explicit CDN allowance — GitHub release assets hand off to `release-assets.githubusercontent.com` and dotnetcli to azureedge/microsoft hosts — so an off-list redirect is refused as a missing asset. `install_melonloader.py` uses the same fetcher. The `url` in a manifest entry stays provenance for the player side: the installer itself never downloads. The zip is deterministic — sorted entries, fixed timestamps, modes 0755 for the installer binary and 0644 elsewhere — so two builds of the same inputs are byte-identical once `SOURCE_DATE_EPOCH` is pinned. ## Windows package [Section titled “Windows package”](#windows-package) ```sh task package:windows task package:windows MOD_VERSION=0.4.2 ``` `task package:windows` builds the mod and the `windows/amd64` Go installer, then runs `scripts/build_player_package.py --platform windows --installer-binary `. The package has the same `vendor/`, `content/` and `manifest.json` layout as the Linux one with `"platform": "windows"`, but the binary is `install.exe`. It lands in `.tools/player-package-windows/` (override with `OUTPUT_WINDOWS`), with `megabonk-levels--ml-windows.zip` beside it. Windows players unzip it and double-click `install.exe`: with no `--package`, the Go installer takes the folder it runs from. On native Windows the installer finds Steam through `HKCU\Software\Valve\Steam` (then `HKLM\...\WOW6432Node\Valve\Steam` and the Program Files default) and reads `libraryfolders.vdf` for libraries on other drives. It has no prefix, no `version.dll` override and no launch option; every write is inside the game directory. MelonLoader’s bootstrap otherwise installs .NET 6 machine-wide under Program Files on first launch, so the installer pre-seeds the vendored runtime into `MelonLoader/Dependencies/dotnet`, the path the loader itself uses; it then logs `Using HostFXR Path: \MelonLoader\Dependencies\dotnet\host\fxr\6.0.36\hostfxr.dll` and runs no runtime installer. `--uninstall` also removes the files the loader creates at first launch (`UserData/Loader.cfg`, and `Plugins/`, `UserData/Levels`, `UserData/LevelPacks`, `UserData` when empty), so the game returns to vanilla while a player’s own plugins, preferences and levels stay. The first launch downloads Cpp2IL from GitHub and takes about 35 seconds, so it needs network access. The Windows flow has been verified in a software-rendered Windows 11 guest, not on real hardware. ## macOS package [Section titled “macOS package”](#macos-package) ```sh task package:macos task package:macos MOD_VERSION=0.4.2 APPLE_SIGNING_IDENTITY="" NOTARY_PROFILE= task package:macos ``` `task package:macos` builds the mod in Release and the `darwin/arm64` Go installer, then runs `scripts/build_player_package.py --platform macos --installer-binary `. The package has the same `vendor/`, `content/` and `manifest.json` layout as the others with `"platform": "macos"`, and carries a bare `megabonk-installer` binary with no extension and no wrapper script, mode 0755 in the zip. It lands in `.tools/player-package-macos/` (override with `OUTPUT_MACOS`), with `megabonk-levels--ml-macos.zip` beside it. Only Apple silicon is built: no Intel Mac was available to test an `amd64` binary. Megabonk has no macOS build, so the player runs the **Windows build under Wine** and the installer works inside that prefix. It does not install Wine, create a prefix or log into Steam: Steam and Megabonk must already be installed in a Wine prefix, and the game started once. The player runs `./megabonk-installer` from the unzipped folder, which is the default `--package`. ### Finding the game and the prefix [Section titled “Finding the game and the prefix”](#finding-the-game-and-the-prefix) Prefixes are searched in this order, and the first Steam library that holds Megabonk wins: 1. `--prefix `, which limits the search to that prefix. 2. Plain Wine: `~/.local/share/megabonk/wine`, then `~/.wine`. 3. CrossOver bottles under `~/Library/Application Support/CrossOver/Bottles/`, in name order. CrossOver is commercial and is never required, so a bottle is only used when no plain-Wine prefix holds the game. In each prefix the installer reads Steam’s `libraryfolders.vdf` from `drive_c/Program Files (x86)/Steam` and maps the Windows paths in it back to the Mac filesystem: `C:` is the prefix’s `drive_c`, `Z:` is the filesystem root, and any other letter follows the prefix’s `dosdevices/:` link. `--game-dir` skips discovery, and the prefix is then the folder above the `drive_c` in that path; a game directory outside any prefix needs `--prefix` as well, and the installer says so rather than guessing. ### What it writes [Section titled “What it writes”](#what-it-writes) * MelonLoader and the mod files inside the game directory, as on every platform. * The vendored .NET 6 runtime in `MelonLoader/Dependencies/dotnet`, the path the loader itself uses, the same placement as native Windows. The launch then logs `Using HostFXR Path: C:\Program Files (x86)\Steam\steamapps\common\Megabonk\MelonLoader\Dependencies\dotnet\host\fxr\6.0.36\hostfxr.dll` and runs no runtime installer. No launch option and no `DOTNET_ROOT` are needed. * The `version.dll` = `native,builtin` override in the prefix. It goes in through a `wine` binary when one is found (`MEGABONK_WINE`, then `wine64` or `wine` on `PATH`, then a `Wine*.app` under `~/Applications` or `/Applications`), and otherwise by merging into the prefix’s `user.reg`, which is also how CrossOver bottles are handled. The method used is recorded as `via` in the install record, and `--wine-override off` skips it. Re-running is a no-op at the expected version, a wiped runtime is repaired together with its override, and `--uninstall` removes the loader, the mod, the runtime, the override and what the loader generated at first launch, leaving a player’s own plugins, preferences and levels. ### First launch [Section titled “First launch”](#first-launch) The first launch downloads Cpp2IL and the Unity dependencies and generates the Il2Cpp assemblies, so it needs network access and takes noticeably longer under Wine than elsewhere: about half a minute after the loader starts, during which the window stays black. The mod logs `MegabonkLevels mod initialized.` when it is done. The repo’s own `scripts/launch_game.py` passes `--melonloader.agfoffline`, which forbids those downloads, so a fresh install launched that way fails with `UnityDependencies_.zip does not Exist!` and a black screen for ever; start the first launch from Steam, or without the flag. ### Signing [Section titled “Signing”](#signing) `APPLE_SIGNING_IDENTITY` names a Developer ID Application identity (`security find-identity -v -p codesigning`); the task then signs the installer with the hardened runtime before it is packed, and `codesign --verify --strict` checks it. The finished zip is submitted through `scripts/notarize_macos.py`, which waits for the verdict, when `NOTARY_PROFILE` names a `notarytool` keychain profile or when `APPLE_API_KEY` (key id), `APPLE_API_ISSUER` and `APPLE_API_KEY_B64` (the base64 `.p8`) hold an App Store Connect API key; a profile wins over a key. The key is decoded to a mode 0600 temporary file that is deleted when the submission ends, so the same three variables serve a local run and a CI job. All of them come from the environment, never from the repo. A download through curl carries no quarantine attribute, so Gatekeeper does not assess it; a browser download does, and an unnotarized binary then needs a right-click Open. A notarization ticket cannot be stapled to a bare binary or a zip, so Gatekeeper checks Apple’s service when it assesses one. ### What is verified [Section titled “What is verified”](#what-is-verified) On an Apple silicon Mac with plain Wine 11 (Wine Devel), against a copy of a working prefix with MelonLoader, the mod, the override and every loader-generated folder removed: the signed package installed 7 steps (exit 0) with `--prefix`, a re-run and `--check` reported nothing to do, the game launched without `--melonloader.agfoffline` and logged `Using HostFXR Path:` under `MelonLoader/Dependencies/dotnet`, `Assembly Generation Successful!`, `1 Mod loaded.` and `MegabonkLevels mod initialized.`, and `--uninstall` returned the game directory to its vanilla contents and removed the override. Default discovery of the plain-Wine prefix was checked with `--dry-run`. CrossOver is covered by unit tests and a `--dry-run` against a real bottle only, since the licence on the test machines has expired. The same install started from Terminal in the desktop session, with no `--no-gui`, opened the progress window, waited for its Install confirmation, and completed with exit 0. ## Linux behaviour [Section titled “Linux behaviour”](#linux-behaviour) ### The Windows build only [Section titled “The Windows build only”](#the-windows-build-only) The installer classifies the game directory before touching it (`gamebuild.Classify` in `go/internal/gamebuild`). `Megabonk.exe` plus `Megabonk_Data/` is the Windows build and the only supported one. A directory with `Megabonk.x86_64` is the native Linux build and the installer stops with exit 4 and instructions for switching to the Windows build through Steam Play, because MelonLoader 0.7.3 segfaults the native binary during Il2Cpp assembly generation and the upstream fix is not in a release. That is a hard refusal, not a warning: installing onto it would leave a player with a crashed game and no log. ### Finding Steam and the game [Section titled “Finding Steam and the game”](#finding-steam-and-the-game) `go/internal/steam` looks for a Steam install under `$XDG_DATA_HOME/Steam`, `~/.local/share/Steam`, `~/.steam/root`, `~/.steam/steam`, the two Flatpak locations (`~/.var/app/com.valvesoftware.Steam/...`), and any Flatpak-granted path. A directory counts as a client when it has `steamapps/`. Library folders are read out of `config/libraryfolders.vdf` (`go/internal/vdf`, a read-only parser — the installer never writes VDF). Game identity comes from `GameCandidates`, which offers every directory three searches thought might be Megabonk, in the order it should be trusted: the `installdir` in the library’s own `appmanifest_3405340.acf`, then `steamapps/common/Megabonk` by name, then any other Il2Cpp install in `common`. The third tier exists because a copy moved out of its library keeps `GameAssembly.dll` and its data folder but loses the name. Acceptance never rests on that shape: a candidate is Megabonk only when it holds `Megabonk.exe`, `Megabonk.x86_64` or `Megabonk_Data/`, all three named by this game alone. `FindGameDir` takes the first accepted candidate; `--check` and the not-found report name what was rejected and why (`an Il2Cpp install (GameAssembly.dll) but no Megabonk.exe, Megabonk.x86_64 or Megabonk_Data/`), so a machine with another Unity/Il2Cpp title installed says so instead of installing into it. Two more things make this sturdier than it looks: * **A library on a detached drive** shows up as a configured root whose `steamapps` is gone. Those roots are skipped rather than reported as a missing game, and only when even the parent directory is missing. * **Flatpak Steam can only read paths granted to it**, so granted paths from `flatpak override --show` (or the persisted `local-overrides` file) are read on every run and added as extra search roots. They also count as the Flatpak client itself when one of them holds `steamapps/`: a maintainer machine exists where the entire Steam root — `config/`, `userdata/` and `steamapps/` — was moved to a data disk, so no client is left at any default location, and the grants are the only way back to it. Without this, a game on a second disk is simply invisible to a Flatpak install. ### Which Proton prefix [Section titled “Which Proton prefix”](#which-proton-prefix) Steam keeps the prefix at `/steamapps/compatdata//pfx` **in the library the game was installed to**, which is not always the one holding the game files after a move — the SD-card layout has the files on removable media and the live prefix in the home library. `FindPrefix` prefers the game’s own library, and when that prefix has no `drive_c/` it takes the only other usable prefix among the libraries searched and prints `Proton prefix: (Steam keeps it in that library, not beside the game)`. Two usable prefixes elsewhere is a guess, so nothing is chosen: both are printed with the `--prefix` hint, and the plan falls back to `gamedir` mode. A prefix passed with `--prefix` silences all of that reporting. `--check` names the prefixes it considered and why each was rejected (`no steamapps/compatdata prefix in this library`, `the prefix has no drive_c/: the game has never been launched under Proton in this library`). ### .NET placement and the launch option [Section titled “.NET placement and the launch option”](#net-placement-and-the-launch-option) MelonLoader’s Il2Cpp support needs a .NET 6 runtime, and the vendored one is the Windows build. It goes in one of two places: * `--dotnet-in prefix` (the default) extracts it to `/drive_c/Program Files/dotnet`, inside the Proton prefix, where Windows .NET finds it on its own and the player changes nothing. * `--dotnet-in gamedir` extracts it to a `dotnet6-win` directory beside the game executable, for when the prefix is not writable or not usable. The installer then prints the Steam launch option to paste: ```sh DOTNET_ROOT=Z:\path\to\game\dotnet6-win %command% ``` `Z:` is Proton’s drive letter for the filesystem root, so the path after it is the ordinary Unix path. The installer **never edits `localconfig.vdf`**: Steam rewrites that file while it runs and a concurrent write loses. It prints the exact line and leaves the paste to the player. `UsablePrefix` checks for `drive_c`, which Steam creates on the first Proton launch — so the fallback to `gamedir` mode fires when the player has not launched the game yet under Proton, and the warning tells them the launch option is coming. ### version.dll override [Section titled “version.dll override”](#versiondll-override) IL2Cpp hooking under Proton needs the `version.dll` = `native,builtin` DLL override in the prefix, and the game directory ships a `version.dll` stub for it to pick up. `SetDllOverride` in `go/internal/proton` writes it through the Proton tool’s own `wine` with `reg add` when that binary is findable (`via: wine`), and otherwise merges `Software\Wine\DllOverrides` into the prefix’s `user.reg` (`via: registry`). `--wine-override off` skips it; `--wine-override wine` fails loudly when no wine binary exists instead of falling back. The override is written when the entry is **missing or** when the runtime is being (re)installed. The second condition matters: a wiped prefix loses the override along with the runtime, and reinstalling only one of the two leaves a game that starts and never loads a mod. The method actually used is recorded and echoed back, so a player reporting a problem can say whether it went in through wine or the registry. ### Levels and packs from the command line [Section titled “Levels and packs from the command line”](#levels-and-packs-from-the-command-line) `--level ` and `--pack ` install extra content the package does not carry, through the same record so `--uninstall` removes it too. Both are validated before anything is copied — a bad pack is reported as a list of problems and nothing is half-installed. ### Progress reporting [Section titled “Progress reporting”](#progress-reporting) The installer shows a native progress window when it has a display and falls back to numbered `N/M label` lines otherwise; the window is described under “Progress window” below. `--non-interactive`, `--no-gui` or `MEGABONK_NON_INTERACTIVE=1` force plain text for install and uninstall, even on a desktop. The environment setting also accepts `true`, `yes` and `on` (case-insensitive); empty, `0`, `false`, `no` and `off` leave automatic selection enabled. `--json` suppresses progress output and dialogs. No installer prompt reads stdin. ## The installer [Section titled “The installer”](#the-installer) `go/` holds the installer: `go/cmd/megabonk-installer` builds one cgo-free binary per platform. The one external module is MyGo (`github.com/egoist/mygo`), used only for the progress window below, and `task installer:verify-static` fails if any other direct dependency appears or if a Linux binary needs anything at load time beyond glibc. `task package:linux`, `package:windows` and `package:macos` each copy the matching binary into their package; the binaries themselves land in gitignored `.tools/installer/`. ```sh task installer:build # cross-compile into .tools/installer task installer:test # gofmt check, go vet, go test (part of task check) task installer:verify-static # CGO off, MyGo the only module, glibc-only load deps ``` Targets are `darwin/arm64`, `linux/amd64` and `windows/amd64`. `linux/arm64` is left out on purpose: the game and its Proton prefixes are x86_64, so an arm64 Linux player would run the installer and never the mod. The Windows binary is exercised in a Windows 11 Docker guest (see below). Windows path handling has one rule: `resolve` answers in slash-separated form on every OS. `filepath.Abs` and `filepath.EvalSymlinks` return backslash paths there, and any comparison that mixes them with the slash-separated install paths misclassifies safe zip members as escaping the game directory. The `plan` and `steam` resolve seams take injectable abs/toSlash/eval so tests pin this without a Windows host; a guest run first caught the leak. ### Test seams [Section titled “Test seams”](#test-seams) Go tests are hermetic by construction. `MEGABONK_MOUNT_ROOTS` replaces the removable-media scan list (empty scans nothing) so a maintainer’s mounted library cannot turn a “not found” scenario into an install, and a `local-overrides` file with no grants is the source `flatpak_granted_paths` prefers, so the host’s `flatpak` CLI is never asked. Go unit tests cover what is awkward to reach through a subprocess — path arithmetic, the VDF and registry parsers, the flag seam above. ### Progress window [Section titled “Progress window”](#progress-window) The Go binary reports progress in a MyGo native-UI window — a checklist of the plan’s steps (pending circle, spinner on the running step, green check when done, red cross on the step that failed), a collapsible Details log and Cancel and Install buttons — drawn with GTK 3 on Linux, Metal on macOS and Direct3D 11 on Windows. It never uses MyGo’s webview mode, so WebKitGTK is never loaded. The selection order is the window, then plain numbered text; kdialog, zenity and yad are not used. On Linux the window needs a display (`DISPLAY` or `WAYLAND_DISPLAY`) and GTK 3, which the desktops players run already carry; GTK is `dlopen`ed at run time, so the binary itself loads only glibc and nothing depends on GTK being installed. `--no-gui`, `--non-interactive`, `MEGABONK_NON_INTERACTIVE=1`, `--json`, no display, or an app that cannot start (missing GTK) all give the text reporter, with `N/M label` lines, a closing message that starts with a capital letter, and the same exit codes. The window opens on a prompt — “Install Megabonk Levels” or “Uninstall Megabonk Levels” — listing every step the plan will take, with Cancel and Install buttons, and nothing is touched until Install is pressed. Cancel or closing the window at the prompt exits 1 with “nothing was changed”. There is no terminal check: a double-click launch gets the window, and scripted runs opt out with the variable or flags above. After a run the window stays open with the result and a Done button, so a double-click launch does not lose the outcome. A failure marks the failing step with a red cross, shows the error and opens Details. The Details log holds the step lines and everything the installer would print on stdout. After a successful install that needs a Steam launch option, the window shows it with a Copy button. Cancel closes the window once the executor has stopped. Cancel, or closing the window, once the run has started is only a request: the executor checks it between actions, never mid-copy. A cancelled install writes a partial install record for the actions already done and runs the normal record-driven uninstall, so the game directory returns to its prior state. A cancelled uninstall stops between steps and does not roll back. `MEGABONK_UI_STEP_DELAY` (a Go duration such as `2s`) pauses after each step so a person or a script can press Cancel mid-run; it is unset in normal use. On Windows MyGo logs `drawing without the GPU` lines to stderr when Direct3D 11 cannot create a swap chain (a VM or a service session), so stderr is not empty there even though stdout matches the text reporter. Packaging through `mygo build -platform` and its `.deb`/desktop-entry output was evaluated and not adopted; the binaries still come from plain `go build`. ## What is verified, and what is not [Section titled “What is verified, and what is not”](#what-is-verified-and-what-is-not) Automated, in `task check`: * Go: `task installer:test` runs gofmt, `go vet` and the unit tests under `go/internal`: plan, prefix selection, VDF and Steam discovery, manifest rules, record-driven uninstall, cancellation and the text reporter. * Python: `tests/test_build_player_package.py`, `tests/test_package_manifest.py` and `tests/test_package_packs.py` cover assembly for all three platforms, the manifest round-trip and `verify_package` on built packages, deterministic zips, vendor download handling and pack validation. Run against the real binaries when the installer was ported, using the Python installer it replaced as the oracle (it is no longer in the repository): * `linux/amd64` runs the full lifecycle — install, `--check` no-drift, drift repair with exit 10, uninstall — on `ubuntu:24.04`, `fedora:42` and the SteamOS-based `thevakhovske/holoiso` image, and in a `FROM scratch` image holding nothing but the binary and the fixture tree (no shell, no interpreter), so the no-interpreter claim is measured rather than inferred. * The four library layouts discovery has to survive ran as a non-root user in `fedora:42` and `ubuntu:24.04` with no network: a single library holding Megabonk beside an unrelated Il2Cpp title that sorts first, the same library with no Megabonk at all, the game on removable media with the live prefix in the home library, and two other libraries each with a usable prefix. The decoy keeps its own digest through every install, the report names the Il2Cpp install it refused with its reason, the SD-card install writes the .NET runtime into the home library’s prefix and says so, and the two-prefix case prints both candidates with the `--prefix` hint and falls back to `gamedir` mode until `--prefix` picks one. * `windows/amd64` runs the same lifecycle in the dockurr/windows Windows 11 guest. `windows_path` keeps an existing drive letter and only prefixes `Z:` for drive-less paths, so the `DOTNET_ROOT` launch option legitimately differs between a Linux host run and a native Windows run. * On the AlmaLinux 10.2 maintainer host’s real layout (Flatpak Steam with the root relocated to `/media/steam`, the stale unmounted library still in `libraryfolders.vdf`, and the native install present) the installer reports `Flatpak Steam may only read: /media/steam`, finds the relocated root through its Flatpak grant, skips the unmounted library, and refuses the native build with exit 4 and the Windows-build steps. * The download path runs against the real vendors: both pinned archives, the GitHub MelonLoader release and the dotnetcli .NET 6 runtime, download through their cross-host redirects with digests matching the pins, a real GitHub 404 reads as a missing asset, plain http and an off-list redirect host are refused, and a 404 leaves nothing in the cache; a cold-cache `--allow-download` rebuilds the whole package from scratch. Live-game evidence below was gathered with the Python installer that this one replaced, and has not been repeated with the Go binary: * On the CachyOS Proton host, the player-package install and `--check`, Windows .NET 6 resolving inside the active home-library prefix while the game lives on SD-card storage, the DLL override recorded as `via: wine`, and a fresh launch logging the mod-initialized marker. See [gamepadui.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/gamepadui.md) for the measured workflow and the three launch traps that cost time there. * On the Fedora player box, auto-discovery with no flags against a real single library holding an unrelated Il2Cpp title that sorts before Megabonk: discovery picked Megabonk through the appmanifest and the library’s own prefix, `--check` matched, and with Steam stopped the game launched under Proton through the offline Steamworks stub to the same three markers (`Assembly Generation Successful!`, `1 Mod loaded.`, `[MegabonkLevels] MegabonkLevels mod initialized.`), stable over three minutes of mod telemetry. * On an Apple silicon Mac with plain Wine, see “macOS package” above. Still pending: * A live Steam launch from a Go-installed Linux package, now that the Go binary is the only installer. * What a Steam update or “verify integrity of game files” does to the loader files in the game directory, which decides whether re-running after an update is ever needed beyond the build warning. * The printed game-directory `DOTNET_ROOT` launch option working when Steam actually applies it (both live proofs use prefix mode). * The persisted `local-overrides` file as the source of the Flatpak grants; the real host went through `flatpak override --show`, which is the other of the two paths. * Steam’s own behaviour around the download, including whether the Windows build needs the “Only download on my OS” untick on a given Steam version. # Bramblecrown Hill Bramblecrown Hill is an original example level, supplied separately from the Megabonk Level Editor package. Its five visual districts use Forest, Graveyard, Dungeon, Desert and Hell props and ground materials around a tall hill with a ruined watchpost. Gameplay uses Forest stage 0: foreign materials do not change stock enemy rosters or generation rules. The level’s explicit boss/miniboss pools override eligible boss picks, not ordinary enemies. No level packs or custom music are needed. ## First play [Section titled “First play”](#first-play) Copy `levels/bramblecrown-hill.mblevel.json` from the source repository into the game’s `UserData/Levels/`. For its selection illustration, copy `levels/bramblecrown-hill.png` alongside it. The Thunderstore package contains the editor runtime only; see the [player guide](/docs/player-guide/) for installation. Choose **Play**, choose a character, then select **Bramblecrown Hill** on Map Selection and confirm Tier 1. The entry has a green hill illustration; the illustration is not an in-game screenshot. You start in an open meadow with a free chest to the left, a distinctive rock ahead and to the right, and a tall wall of hillside straight ahead with the watchpost tower above it. A fence line of rocks and bushes leads from the meadow to the foot of the hill. The terrain graph connects every cell without jumping or jump-height upgrades; the spiral was reported walkable during manual testing, while the remaining routes still need physical checks. Drops of up to two height steps are configured to be safe (`fallDamageFreeHeight:40`); larger drops retain normal character fall damage. This expanded landing allowance still needs runtime verification and does not protect against lava, combat or the kill floor. The hill’s only walk-up route is a spiral path that climbs three quarters of a turn around it: a ramp west, a ramp north, a ramp east, a ramp south, then the flat summit where the boss spawner stands, ringed by ruined pillars. The summit is sixteen cell transitions from the start, so the climb is a deliberate trip rather than a shortcut. Descending one height step at a time is configured to be safe; skipping several steps can still cost health. Around the hill, a ring of districts each has its own biome, a chest and shrine locations: | District | Biome | What to expect | | ------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Spawn meadow | Forest | Open ground for the first fight, the free chest, and one more chest in the far corner | | West gully | Graveyard | A sunken trench behind a stone arch, with gravestones, dead trees, broken fences, pumpkins and pots, cobble patches, the coffin tile and a chest in the far corner | | West ridge | Forest | Wooded ledges above the gully, with a chest at the high corner | | North ruins | Dungeon | A raised plateau with crypt-stone floor, barrels, crates, bones, coins and braziers, with a chest and a view of the summit’s back | | East pen | Hell | A raised enclosure with fires, dark rocks and a corner chest; an optional lower stone court has side walls, two rectangular lava bands, a dry bypass, two north ramps and new west/south exits | | East highland | Desert | Sand underfoot, adobe houses, cacti, bare desert trees and sandstone landmarks, with a ramp up to a peak holding the best chest | | Spiral | Forest | The path itself, with a chest on its first landing as a reward for climbing | The routes form loops: the meadow, ridge and ruins connect around the back of the hill, and the pen connects to the highland and on to the ruins, so no district is a long dead end. The plan requests five Charge Shrines, two or three extras drawn from Charge, Magnet and Greed, three to five Moai shrines and two or three Shady Guy merchants, for 12 to 16 planned shrines from 27 authored candidate locations. These are planned counts, not guaranteed successful placements. Moai require 120 units of horizontal separation from other Moai placed by the pass (`shrineKindSpacing`), including after nudging; elevation does not make nearby heads count as spread out. No fallback relaxes that gap: an unplaceable shrine logs `placement unmet` rather than being forced into a cluster. Other kinds retain the ordinary 12.5-unit minimum gap. At least one Moai and one Shady Guy are requested outside the spawn meadow (`shrineGuarantees`): the first of each kind is drawn only from spots beyond the meadow and checked against its placed position, with a `guarantee unmet` warning when no such shrine could be placed. Static spot-selection checks cover 256 seeds without physics failures. Paused runtime checks on seeds 0, 1, 7 and 42 placed every planned shrine and satisfied the gap and outside-meadow rules; physical route clearance and budget playtesting remain pending. The level seed fixes shrine count/kind/order planning and the custom placement draws. Boss/miniboss replacements use a separate seeded stream: identical controlled spawn-call sequences repeat picks until their shared unused-name pool is exhausted, after which vanilla picks stand. All streams reset at generation start, and seed 0 is valid. Physics clearance can still change successful shrine placements; ordinary enemies, loot, combat and boss spawn offsets remain random. The authored grid, spiral and explicit prop transforms do not reroll with this seed. The map uses the custom-level Tier 1 rules: survival continues after the boss, with no next-stage portal or chain of custom stages. ## Your first edit [Section titled “Your first edit”](#your-first-edit) 1. Start Bramblecrown Hill and press **F8** to enter the editor. The run pauses. 2. Find the rock just ahead and to the right of the starting point. Use the **Select** tool (6), then click the rock. 3. Drag it a short distance across the flat meadow, keeping it away from the spawn, the fence line and chests. Leave the terrain and boss spawner alone for this first edit. 4. Change the save-name field to **my-bramblecrown** and click **Save**. This writes `my-bramblecrown.mblevel.json` without overwriting the original level. 5. Select that saved file in the editor’s Load list and click **Load**. Leave the editor with F8 to play the saved copy. The rock should remain where you moved it, while the original Bramblecrown Hill remains unchanged. The copy retains the level title. The editor’s filename distinguishes it from the original; do not expect the selection illustration to be copied automatically. You can share the saved `.mblevel.json` without an asset pack. ## Design [Section titled “Design”](#design) The layout follows the structure of a classic open hub level: one landmark visible from the start, a leading line toward it, a spiral ascent to a flat boss arena, and a ring of distinct districts that each reward a detour. The pacing runs rest in the meadow, tension in the sunken graveyard and the hell pen, rest again on the dungeon plateau, then the spiral climb into the boss. The grid is 13 by 12 cells (156 cells, 30.7% less ground area than the earlier 15 by 15 layout), with heights from -1 to 4 and sixteen one-step ramps. The generator uses a 13 by 13 array with an unoccupied final row; coordinates use its centre index 6, 40-unit cells and 20-unit height steps. Horizontal world bounds use the authored rectangle, not that square array: X `[-260,260]`, Z `[-260,220]`, with centre Z=-20. The spiral retains its 5 by 5 footprint and 3 by 3 summit. Cell and height numbers are authored in `scripts/build_starter_level.py` (`height`, `RAMPS`, `HILL`, `DISTRICTS`); object placement uses a fixed seed, so regeneration is reproducible. The three summit ruin origins are inset to `(±36,80,±36)` at their authored corners. Their conservative measured renderer footprints fit at least two units inside the plateau’s X/Z `[-60,60]` edges; origin-only support checks are not enough for these wide tower bases. The central boss area remains clear. Each biome is a district plus a `groundZones` entry (desert sand, crypt floor and hell rock under the matching district; see [level-format.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/level-format.md)) and a scatter table of that biome’s props. Every prop is registered with the euler X the game authored it with (most props, chests and trees are authored lying down at 270, rocks and ruin tiles at 0) and a scale chosen for this level’s cell size, so everything stands upright; a test fails when a placed prop has no registered rotation. Props from other maps are placed by catalog id, which keeps their own map prefix, so no pack is needed. The east pen’s optional lava court borrows the Graveyard intro’s architectural treatment: hard-edged pools flush with crypt-stone floor, walls at the sides, alternating dry ground and lava, and ramps back to the raised pen. Two `lavaZones` with matching `LavaCrypt` ground zones burn a standing player, away from chests and shrine spots. `LavaCrypt` is the actual native material observed on the loaded Graveyard intro pools. A material-only change still looked flat in the first runtime screenshot, so the renderer also reuses a readable native rectangular pool mesh and its original UV channels, fitted to the authored cell with upward-facing triangles. A close-up on the corrected build shows a textured orange surface. It does not clone the native damage scripts or colliders; the existing lava zones remain the damage source. The west exit rises toward `(181,2,-130)` and the south exit toward `(223,2,-219)`; adjoining wall segments are removed, and a lowered western entry keeps the dry route connected. The spawn can never be on lava. Wall collision, actual escape movement and damage on this revision still need runtime checks. The dungeon flat-prefab assemblies that appeared as buried pillars near `(-82,22,119)` and `(121,22,202)` are omitted; smaller dungeon props and the other plateau landmarks remain. Stage-boss spawns are drawn from Ent and Pharaoh (`bossPool`: `Ent1`-`Ent3`, `Pharaoh1`-`Pharaoh3`), with Bandit and Bush left out. The miniboss roster remains unchanged: `MinibossPig`, `MinibossGolem`, `MinibossGolemSand`, `MinibossScorpion`, `Ghostham`, `CalciumDad`. Arena suitability is not yet verified. Per-biome sky and fog are not part of the level format, so the level plays on Forest’s rules, and the Graveyard coffin tile is placed as scenery whose enemy behaviour is untested. ## Content and distribution [Section titled “Content and distribution”](#content-and-distribution) The starter content is exactly these three files under `levels/`: * `bramblecrown-hill.mblevel.json`: authored terrain and references to native game prefabs, not copies of the game assets. * `bramblecrown-hill.png`: original hill selection illustration. * `bramblecrown-hill.biome.png`: original cropped hill icon. The illustrations are drawn from geometric shapes by `scripts/build_starter_level.py`, using Pillow’s bundled default font. No game textures, screenshots, website images, THPS assets or soundtrack files are used to produce them. The source recipe is kept with the level for review and regeneration; this does not grant redistribution rights to the game’s assets. Regenerate all three starter files with: ```sh uv run python scripts/build_starter_level.py ``` The default `task package:linux`, `task package:windows` and `task package:macos` content selection names only this starter. The pack list is empty. Existing development fixtures remain in the repository but are not part of the default player package. Explicit `LEVELS` and `PACKS` overrides still support local development packages; this default selection is not the public-release content enforcement policy. ## Packaged runtime smoke test [Section titled “Packaged runtime smoke test”](#packaged-runtime-smoke-test) Use a fresh package extraction and an owned Windows game install. On Linux, run the Windows game under Proton; this is not a test of the native Linux build. 1. Build the default package for the target platform and verify it with `task package:check`. Inspect its manifest: the only `content/` entries must be the three starter files above, with no `content/packs/` or music entries. 2. Quit the game and install the extracted package with its bundled installer, pointing at the actual game directory and active Proton or Wine prefix. Run the installer’s `--check` and require exit 0. 3. Preserve any existing `UserData/Levels`, `UserData/LevelPacks` and `UserData/CustomMusic` in a separate backup location. Test with only the three packaged starter files in `UserData/Levels` and no optional packs or music. Restore the preserved directories after testing; never delete player content. 4. Launch a fresh game. Check that all three native map entries remain, only Bramblecrown Hill is added, both starter images display, and selecting it offers only Tier 1. Launch through the normal character/map menus. 5. Confirm the authored spawn, the fence line, visible trees/rocks/ruins, supported rewards, working ramps, enemy spawns and an accessible boss spawner on the summit. Run a normal combat interval and exercise the boss interaction. Inspect the fresh game log for missing prefabs/packs, placeholders, failed edits and exceptions. 6. Perform the first-edit tutorial above. Inspect the saved copy for the moved rock, reload it in a fresh run, and confirm its placement and collision. 7. Keep the package manifest, installer check, fresh logs, selection/play/editor screenshots and saved-copy diff as evidence. Distinguish harness-assisted checks from an unassisted survival playthrough. Automated checks in `tests/test_starter_level.py` and `tests/MegabonkLevels.Tests/starter_level_tests.cs` verify the file recipe, native-only references, placement parsing, terrain connectivity, the length of the climb to the summit, chest coverage of every district and selection art. Package tests check the default content selection and manifest hashes. ### Verified runtime [Section titled “Verified runtime”](#verified-runtime) Runtime evidence below is tied to the stated file/build hashes. The latest revision has a fresh visual/baseline check, but physical access to the additional exits, removal checks on foot and one-step landing health outcomes remain pending. Repeated manual drops from the same one-level location still caused damage on the body-height-sampling build. A read-only diagnostic measured a 22.073524-unit drop against the 20.25-unit cutoff; cumulative HP loss alone does not isolate individual landings. The replacement measures supporting-floor heights instead, retaining the 20-unit allowance rather than increasing it. Regression tests cover inflated body samples and missing/distant support. The revised diagnostics include both surface heights, the exemption decision and before/after landing HP; zero-damage one-level falls and normal larger-fall damage still require a fresh-build manual check. The failed runtime baseline is `.tools/evidence/bramblecrown-compact/fall-contact-fix/before.json`. After the floor-contact fix, manual feedback described overall fall damage as good, but identified the drop from `(-58,82,14)` to `(-84,42,39)` as the maximum fall that should be safe. Its diagnostic measured support heights approximately 80 and 40, a 40-unit drop, no exemption under the old 20-unit setting, and landing HP 87 to 73; the ordinary reduction stat was 0 afterward. This confirms surface measurement for that landing, not a failure of the contact fix. The starter allowance is now 40 units with the existing 0.25-unit contact tolerance; tests cover the boundary and rejection of larger falls. The expanded setting has not yet been loaded or physically checked. The native-lava revision loaded seed 0 as Calcium with zero Feathers, HP 100, no invulnerability and spawns off. Both surfaces used the native Graveyard template `pb_Mesh148722` with four vertices/four UVs; measured output normals were `(0,1,0)`. The final close-up shows textured orange lava, unlike the preceding material-only flat result and the first incorrectly facing mesh attempt. All 14 planned shrines were placed with no content/placement errors. `fall-damage` reported the 20-unit allowance active and Calcium’s ordinary reduction stat still 0 between landings; no manual landing outcome was measured yet. Evidence for this revision is `.tools/evidence/bramblecrown-compact/native-lava-runtime/capture.json`, `session.log` and `lava-native-close.png`. Level SHA-256: `76b1311d96f1946f47e93fc62b696be6698d0590dcfc4c08e5a89f86e3bc18e9`; DLL SHA-256: `c2d1034d9bedbe541953ea35cef99749d8f2154eafbcff04b22abfc01d643f9b`. The earlier `lava-native-mesh.png` in that folder is failed diagnostic evidence from the pre-winding build, not the final visual result. Player camera and manual control were restored at the authored spawn, with the game unpaused and enemies still off. #### Compact-layout placement check [Section titled “Compact-layout placement check”](#compact-layout-placement-check) The multi-seed placement checks below predate the rectangular world-bounds fix and tower inset. Manual walking subsequently exposed a 40-unit gap between the northern ground edge and the native barrier near `(99,22,217)`; the spiral itself was reported walkable. The earlier build’s placement results do not establish perimeter safety. Five fresh-process offline plain-Wine runs with the Steam stub covered seed 0 twice and seeds 1, 7 and 42 once each. All used Calcium, zero Feathers, no harness invulnerability, spawns off and the editor paused; both development stage/terrain overrides were off. The effective assets were `StageForest1` and `ParamForest1(Clone)`, with a 13 by 13 generator array, 40-unit cell spacing and 20-unit height steps. | Seed | Planned / placed | Moai | Shady Guy | Smallest horizontal Moai gap | | -------- | ---------------- | ---- | --------- | ---------------------------- | | 0, twice | 14 / 14 | 4 | 2 | 163.2 | | 1 | 14 / 14 | 5 | 2 | 205.5 | | 7 | 15 / 15 | 5 | 3 | 126.5 | | 42 | 13 / 13 | 3 | 2 | 231.4 | Every run satisfied the outside-meadow guarantees, with no placement/guarantee failure. The two seed-0 runs produced identical timestamp-stripped shrine logs and identical queried position/scale values for all 14 placements. Their two merchants had different rotations, so the result is not full-transform determinism; the cause of that rotation difference was not established. Live transform snapshots include positions, quaternion rotations, scales, cells and district membership. No boss/miniboss calls were exercised in this pass. Evidence is in gitignored `.tools/evidence/bramblecrown-compact/`, including per-run logs and `capture.json`, `comparison.json` and independent regeneration outputs. Level SHA-256: `f7976404fa388d05fc12f05f265fa3a5905dd8c123b92cb44360a4570a799781`; deployed DLL SHA-256: `6682599b3a627f6452b9fb7e09996b96595c2377c077f9faddf9b543f243b035`. One read-only item probe used the invalid singular `Feather` and was rejected; the corrected `Feathers` query returned zero without changing inventory. These checks do not certify physical walking, interaction access, combat, lava escape or packaged first play. A later fresh-process seed-0 run with both corrections measured matching horizontal bounds on the native edge collider and ground mesh: X `[-260,260]`, Z `[-260,220]`. The four summit tower mesh renderers were at least 2.425 units inside the plateau’s horizontal edges. All 14 planned shrines were placed, with a minimum Moai gap of 163.2 units and no content/placement errors. Calcium again had zero Feathers and no invulnerability; measurements were made paused with spawns off. This is live bounds/placement evidence, not physical collision-contact testing, and repeated-seed checks for this corrected content/build pair are still pending. The corrected run is recorded under `.tools/evidence/bramblecrown-compact/boundary-and-towers-fixed/`, with full bounds, placement transforms, logs and byte-identical independent regeneration. Its level SHA-256 is `c615f7087bc769485f9c103fc499cce86e05b65a39d1a77d345b3bb71f998940`; DLL SHA-256 is `0f584a0c5e85b1dc7f9058f9b1de105e7858e73de90f931e286ba153729c3a55`. Control was returned at the authored spawn with the editor off, the game unpaused and spawns still off for the next manual walk. #### Earlier layouts [Section titled “Earlier layouts”](#earlier-layouts) The following checks concern earlier 15 by 15 layouts, not the current compact layout. They are historical observations only. Do not use them to certify the compact layout’s placement spacing, physical routes, interactions or packaged first play. Loading `levels/bramblecrown-hill.mblevel.json` through the development harness on macOS under plain Wine built all 225 authored tiles and placed the boss spawner on the summit, with no missing-prefab, failed-edit or exception lines in the log. Live checks from that session: the shrines the mod spawns carry the game’s own rotations (Charge at 0, Greed, Magnet and Moai at 270) and the chests stand at 270 with the game’s small random tilt; a run rolled 19 shrines (5 guaranteed Charge, 6 Moai, 6 Shady Guy and 2 extras) from the 27 spots; a spawned Shady Guy opened the Shady Guy shop through `interact`; and views of the graveyard gully, hell pen, dungeon plateau and desert highland showed each biome’s ground and props. Later checks in the same session: standing on the lava pool cost 3 hit points per half second; two boss portal spawns were rewritten by the pool (`Ent1` to `Ent2`, then `Ent1` to `Pharaoh2`, whose boss bar and model appeared normally behind the summit portal); a timeline miniboss spawn (`GhostGrave1`, flag `Boss`) became `MinibossGolem`; and the grass filter removed the blades on the lava, hell rock and desert sand while the Forest meadow kept its grass. These were harness-assisted checks. The following were not repeated for this layout and still need a pass using the smoke test above: walking each ramp with native input, a combat interval with spawns on, the boss interaction, the Moai and coffin interactions, the first-edit tutorial and the packaged selection screen. ### Seeded-stream lifecycle smoke check [Section titled “Seeded-stream lifecycle smoke check”](#seeded-stream-lifecycle-smoke-check) Offline plain Wine with the Steam stub was used for two fresh-process loads with seed 0, the actual death-screen Confirm → summary Restart buttons, and a third fresh process loading an editor-saved copy. Both development stage/terrain overrides were off. Calcium had zero Feathers, no harness invulnerability grant, and spawns enabled; measurements were made paused in the editor. The effective assets were `StageForest1`/`ParamForest1`, with spacing 40 and height step 20. All four generations logged the same ordered 16-kind plan, all 16 successful placement positions (reported to three decimal places), golden outcomes and first boss replacement (`Ent1` → `Bush`, drawn from the earlier eight-boss roster, which has since dropped Bush). No placement failure or tight fallback was logged. Restart restored the authored start `(0, 0, -240)` and repeated the first boss replacement after it had already been used in the previous run. The saved copy retained seed 0, `baseline:none`, Forest stage 0 and Tier 1. A later offline plain-Wine pass covered the 12-16 budget, the outside-meadow guarantee and the six-boss roster: two fresh-process seed-0 loads logged identical 14-kind plans and placements, and seeds 1, 7 and 42 planned 14, 15 and 13 shrines with 3-5 Moai and 2-3 Shady Guy each. In every run the first Moai and first Shady Guy stood outside the meadow rectangle (all of them did in seeds 1, 7 and 42; seed 0 had a second Shady Guy inside it, which the guarantee allows), with no `guarantee unmet`, placement-failure or content-load line. A forced boss spawn (`Ent1` → `Pharaoh3`) came from the new roster. These runs used the editor harness with Calcium and zero Feathers; the guarantee was not seen failing, so only its passing path is measured, and positions are at logged three-decimal precision. Identity and local evidence: * Generated level SHA-256: `7a35e663f152722606f21101a2eb15879e73b1b4c40aae1c97de5245c9b1d6da`. * Measured DLL SHA-256: `de66290af0f550827b9c81f1e56f40a615ae13e4ebedb6986873fdfedf09ab22`. * Gitignored `.tools/evidence/bramblecrown-seeded/` holds fresh logs, `comparison.json`, `lifecycle-comparison.json`, stage/inventory/state reads and the saved diagnostic copy. Regenerating all three starter files into a separate directory produced byte-identical JSON and artwork. The death-button driver logged three expected Confirm refusals while its button was still inactive, then succeeded; these are recorded harness errors, not content-load failures. This establishes a bounded position-log comparison, not full-precision transform or district guarantees. The portal was activated through the harness from a distance with `CanInteract` true, not reached on foot. Only the first stage-boss pick was compared live; miniboss sequences, curse copies and pool exhaustion are covered by pure tests, not runtime evidence. The saved copy had no moved rock, so this is lifecycle replay evidence, not the first-edit tutorial. These checks do not establish roster suitability, ramp/enemy traversal, pack-free packaged selection, interaction accessibility or an unassisted survival clear. # THPS Warehouse reference What the Tony Hawk’s Pro Skater Warehouse level physically contains, read from a 100% playthrough video. The only 3D view of the level is that video, so this doc records what it shows and what it cannot show, so the level does not have to be re-watched to rebuild it. ## Source [Section titled “Source”](#source) ### Local-only assets [Section titled “Local-only assets”](#local-only-assets) The optional Warehouse workflow requires your own lawfully obtained PS1 THPS1 USA raw MODE2/2352 disc image. Megabonk itself requires a separate Steam copy. No ROM, extracted mesh, texture, soundtrack or generated pack bundle is distributed in this source tree. Extraction outputs remain under gitignored `.tools/`; soundtrack files must also be supplied locally with appropriate rights. Owning a game does not grant permission to redistribute its assets or derivative bundles. `tools/thps1.sha256` pins the locally verified input image, not a generated pack: ```text f1398cd15138a947fb3d49e1d31c7fe2aae9436ec09e32df917c658ccac30da9 ``` Run `task thps:verify-disc` (or set `DISC="path/to/disc.bin"`). Both `task thps:extract` and direct `thps1_extract.py` builds reject a different digest before extraction; the task verifies even when it reuses existing output. Other revisions or dump formats are not supported by this pin. It identifies the tested local input, not ownership, licensing status or independently verified archival provenance. * 219 frames at 1 fps, `tools/thps_warehouse_frames/frame_0001.jpg` to `frame_0219.jpg`, 1920x1080, from video time 00:39 to 04:18 of `tools/thps1_100_playthrough.mkv`. * Both the frames and the video are gitignored. Regenerate the frames with `ffmpeg -ss 00:39 -to 04:18 -i tools/thps1_100_playthrough.mkv -vf fps=1 -q:v 2 tools/thps_warehouse_frames/frame_%04d.jpg`. * The footage is the Tony Hawk’s Pro Skater 1 + 2 remake, not the 1999 original. The layout follows the original, but materials, lighting, graffiti and some props are remake art. Treat look notes as remake look. * A facecam overlay covers roughly x 1520-1900, y 30-310 in every frame, and a HUD sits top left. Ignore both. ### Frame ranges [Section titled “Frame ranges”](#frame-ranges) * 1-12: intro flyover over the whole room (12 is the best overview). * 13-28: close flyover shots of the letters, the big rail, the channel and the secret room. * 29-86, 92-113, 119-162: gameplay. * 87-91, 114-118, 174-187, 218-219: pause menus and results screens, no geometry. * 163-173: goal-camera flyover at the end of the first run. * 188-217: gameplay of the second run. ### Map and guide [Section titled “Map and guide”](#map-and-guide) * `tools/thps_warehouse_items_map.png` (with collectible markers) and `tools/thps_warehouse_itemless_map.png` (without), both 973x767, gitignored. Pixel positions below are in that image, x to the right and y down, read from the items map unless stated. * The [StrategyWiki Warehouse guide](https://strategywiki.org/wiki/Tony_Hawk%27s_Pro_Skater/Warehouse) is the reference for collectible descriptions and gap names; the scraped page is not included here. * The maps are an isometric (not top-down) render: floor and the two far walls are visible, the near sides are cut away, and everything raised appears shifted up the screen by its height. ## Scale [Section titled “Scale”](#scale) * The skater is the main ruler, taken as 1.8 m. Frame 188 and frame 71 show the skater beside doors, cones and barricades. * Cross-checks: orange cone about 0.7 m, A-frame barricade about 1.1 m tall and 2 m wide, wooden crate about 1 m, steel pipe of the big rail about 0.1-0.15 m across. * Perspective inflates anything nearer the camera than the skater, so every height and width below is a rough ratio, not a measurement. Numbers marked “low” were estimated from a single oblique view. ## Map orientation and scale [Section titled “Map orientation and scale”](#map-orientation-and-scale) ### Headings [Section titled “Headings”](#headings) The video cannot give compass headings, so the map defines its own. They are map headings, not real compass directions. * **N** is up-right on screen, along the long far wall. **S** is down-left. * **W** is up-left on screen, toward that long far wall. **E** is down-right. * The long far wall (screen edge from about (25,375) to (460,110)) is the **west wall**. It carries a black-and-yellow “SKATEBOARDING” sign near its south end, at about (110,388), and brown wooden pilasters at about x 190-215 and x 385-410. A short **north wall** continues from (460,110) to (550,185). * The south-west and south-east edges of the floor are cut away and show only the slab’s thickness, so no south or east wall is shown. ### Scale [Section titled “Scale”](#scale-1) * Anchoring the half pipe’s flat bottom at about 8 m against its width on the map (about 170 px between the two coping lines) gives roughly 20-25 px per metre along both floor axes. This is a single-anchor estimate, low confidence. * The big rail is a thin line on the itemless map through the centre box pile, from about (428,572) to (603,490), about 195 px, so about 8-10 m. This agrees with the frame estimate of at least 8-12 m. * The west wall is about 510 px, so about 20-25 m. The cut-away south-west edge from (25,375) to (250,660) is about 360 px, so about 14-17 m. The main room is therefore roughly 20-25 m by 14-17 m before the raised north-east block. * A 14-17 m side matches the frame-derived door wall: two door sets of 7-8 m each plus a pillar. So the door wall is probably a cut-away side or the north-east side, not the west wall. ### Discrepancies with the frame analysis [Section titled “Discrepancies with the frame analysis”](#discrepancies-with-the-frame-analysis) * The map’s sign reads “SKATEBOARDING”. The frames’ sign wall carries the “RENSSELAER HYDRANT CO.” sign. They are different signs, so the map’s west wall cannot be matched to the frames’ sign wall with confidence, and may be the same wall with a second sign out of the frames’ view. * The frame analysis calls the half pipe’s sides “left and back” only. The map shows two parallel coping lines about 170 px apart with a flat floor between them, so it has two opposing sides, which the frames did not show. * Heights cannot be read from the map. Vertical screen offset mixes height with floor position, so heights stay as in the frame analysis and the guide text. ## Landmarks [Section titled “Landmarks”](#landmarks) Walls are named by what is on them. * **Sign wall**: the long far wall with the painted “RENSSELAER HYDRANT CO. EST 1922” sign, a band of graffiti (KDR, JOSH, 518) at head height above the ramps, and a perforated X-braced gate beside the sign (frames 4, 119, 166, 167). * **Window wall**: the long wall of tall steel-sash windows, three tiers high, with a red truck visible outside (frames 12, 101, 123, 169). * **Door wall**: the end wall with two sets of white roll-up garage doors and a pillar between them (frames 1, 29, 163, 188). * **Graffiti brick corner**: the teal and white brick corner with the CHRISTIAN and WERK murals (frames 16-18, 61-64). ## Structures [Section titled “Structures”](#structures) ### Entrances (two roll-ins) [Section titled “Entrances (two roll-ins)”](#entrances-two-roll-ins) * Best frames: 1, 29, 30, 163, 188, 189 (door faces); 79, 92, 93, 98 (the way in from outside). * Two sets of four-leaf white slatted roll-up doors on the door wall, separated by a pillar with a yellow warning sign (frame 1). Each leaf has two rows of glazed panes above a slatted lower half. * Size (low): opening about 3 m tall; each set about 7-8 m wide, from the skater’s size in frame 188. * The skater smashes through the doors at the start, leaving splinters (frame 31). From outside, the way in is a ramp up between steel-lattice side walls, under a steel truss and lintel about 5 m above the floor and about 5-6 m wide (frames 79, 93). A diamond-plate steel threshold sits inside the door (frame 80). * A third, darker door with a graffiti-covered lower half sits at the right edge of the door wall (frame 1). * Beside the doors: a warning triangle and electrical box on the wall (188), a dark damaged opening with shelving to the left (163), and a puddle on the floor in front (188). ### Quarter pipes [Section titled “Quarter pipes”](#quarter-pipes) The room has several distinct quarter pipes along its walls. The frames do not give a reliable one-to-one match with the wiki’s wording, so they are grouped by look. **Wide plywood quarter pipe with curved end (sign wall side)** * Best frames: 156, 157, 158, 193, 196; 24 and 138 show it from the floor. * A long, low plywood transition runs along the graffiti band of the sign wall. Its left end curves around a corner, which is where the “winding” shape comes from (156, 193). * Size (low): about 2-3 m tall, wide enough to take the skater sideways across it for several seconds (frames 156-159). * Plywood sheets with visible seams, dark steel coping, concrete wall above the lip. Window-bar light shadows fall across its floor (24, 138, 156). **Wide slotted steel quarter pipe** * Best frames: 119, 86, 120, 121, 4 (from above). * Diamond-plate steel panels about 1.2-1.5 m wide, each with pairs of vertical slot cutouts, forming a long bank (119). A yellow painted stripe runs along the floor at its base. * Size (low): about 2.5-3 m tall. At its top it joins a deck, and the mezzanine glass corridor hangs overhead near its right end (119). * Dark metal, much cooler than the plywood ramps. This is the “slotted steel panel ramp” visible in several flyover frames (3, 4, 71, 72). **Winding quarter pipe** * Best frames: 7, 60, 122, 123, 124, 161, 162, 204. * A curved plywood quarter pipe with a U-shaped pocket on top; three crates sit in the pocket (frame 7). * Concave 90-degree corner with “Cheer The…” graffiti (161), steel coping, dark pipe rail at the lip (124). * Size (low): about 2.5-3 m tall, pocket deck about 3 m above the floor. **Tall channel quarter pipes** * Best frames: 25, 26, 27, 110, 129, 149, 151, 152, 212; 10 and 11 from the flyover. * Tall plywood panels with seams about every 1.2 m, graffiti (WERK, VERT, RAZ, TUKID), dark steel coping, mesh grille above (129). * They flank both entrances and are stepped in tiers along one corner wall: three steps rising roughly 1.5, 2.2 and 3 m (frame 110, low). * The tallest is about 3 m or more. Shoulder walls beside the ramps are about 1.5-2 m (frame 151). * A concrete pillar with WERK graffiti stands between the two large ramps that sandwich the entrances (10, 25, 171). **Small quarter pipe and second quarter pipe behind a deck** * Best frames: 16, 17, 18, 213, 214. * A small graffiti quarter pipe sits by the teal brick wall (16-18). A deck with another quarter pipe behind it carries a “RAZ” graffiti wall (213, 214). ### Half pipe [Section titled “Half pipe”](#half-pipe) * Best frames: 138, 139, 145, 146, 135, 136. * A wooden bay with plywood transitions on the left and back, concrete wall above the lip with “518”, “WEAK” and other graffiti, and window-bar shadows on a stained concrete floor (138). * Size (low): flat bottom about 8 m wide at skater scale, transitions about 2.5-3 m tall (138, 145). * It sits below the same graffiti wall as the wide plywood quarter pipe above (156 and 138 share the 518 and KDR graffiti). It is probably the same plywood bay; see Unresolved. ### Kickers [Section titled “Kickers”](#kickers) * Best frames: 9, 10, 11 (corner pair), 58, 103, 127. * At the corner near the sign wall, two kickers sit side by side with the S letter floating between them (9, 11). One is a plywood-panel ramp about 2.5-3 m wide and about 1 m tall at the lip (103, 127). The other is a lower open-frame wooden ramp about 0.6 m high. * Near the big rail, a flat plywood kicker sits at each end of the rail, with barricades and cones beside it (101, 126). * Plywood sheets with seams on a wooden frame; the plank panel pattern is visible on the 2x2 panel ramp in frame 9. ### Taxi funbox (probable) [Section titled “Taxi funbox (probable)”](#taxi-funbox-probable) * Best frames: 156, 193, 194, 217, 71; 4 and 12 from above. * A dark, low, car-sized object on the floor, near the sign wall end. Frame 156 shows a wrecked car shell, with an arched roof frame and planks laid over it. Frame 71 shows the same object as a faceted plywood-clad mound with debris on top. * Size (low): about 4-5 m long and 1-1.5 m high at the highest point, from frame 71 (barricade and skater scale). * The taxi’s paint and body shape are never visible, so the identification is probable, not confirmed. See Unresolved. ### Big rail [Section titled “Big rail”](#big-rail) * Best frames: 24, 23, 22, 126, 170, 210, 33, 34. * A long rust-red steel pipe about 0.1-0.15 m across on black posts, about 0.9-1 m above its base, with a bend and an elbow down at the near end (24). * It runs diagonally across the middle of the room. The base is a diamond-plate steel platform with a grated drain trench beside it and a graffitied steel beam under the pipe (23, 24). * Size (low): at least 8-12 m long. * Orange-and-white A-frame barricades and traffic cones sit around both ends, and one barricade is fixed to the rail. ### Decks, platforms and ledges [Section titled “Decks, platforms and ledges”](#decks-platforms-and-ledges) **Decks** * Best frames: 19-21, 26, 27, 133, 171, 213, 217. * A deck sits on top of the channel quarter pipes and carries a crate pile and mesh cage walls (19-21, 171). Lip about 1.5-3 m above the floor. * A concrete deck with a yellow painted border and graffiti face sits at floor level beside the half pipe (133). * A diamond-plate deck with a red rail edge and grates borders the rail (209). **Ledges** * Best frames: 40, 56, 57, 108, 127, 128, 211. * A long low concrete ledge runs along one wall, with a yellow edge stripe and a dark blue lower wall (108, 128). * Height about 0.8-1 m (frame 108), or about 1.5 m in frame 211 (“VERT” graffiti, low). Cardboard boxes sit on top. * A low curb about 0.2 m high appears in 205 and 216. ### Glass corridor and secret room [Section titled “Glass corridor and secret room”](#glass-corridor-and-secret-room) * Best frames: 4, 12, 166 (exterior); 28, 43, 172, 173 (interior). * A windowed box hangs on a black steel frame above the floor, near the right end of the sign wall (4, 119, 166). Pale wood-clad exterior with multi-pane steel windows. * The interior has a plank floor, wood wainscot about 1 m high, black-framed multi-pane windows on both long sides and an open truss ceiling beyond (172, 173). * Size (low): about 4 m wide and 3 m high, from window height and sill scale (28). The length cannot be measured. * The glass is breakable (43). The tape icon floats inside (4). * The skater reaches it by launching off the quarter pipe in front, not by a door (frames 39, 42, 43). * In the extracted `Warehouse` mesh the box is a closed room: walls on all four sides between an interior floor top at 15.64 and a roof underside at 25.42 (world units, scale 79.2; the slab’s underside is at 15.31 and the roof’s top at 25.75), over a footprint of x 149.4-178.9, z 20.9-31.4. The frames do not settle whether a skater can actually get in through a window, so the game’s boss portal standing on that floor is decoration for a launch-in entry, not a proven one. See [thps-warehouse-layout.md](/docs/thps-warehouse-layout/) for what the level places there. ### Box piles [Section titled “Box piles”](#box-piles) * Best frames: 82, 81, 98, 94 (by the door wall); 19-21 (on the deck); 25-27 and 171 (crates and cardboard boxes); 7 (on top of the winding quarter pipe); 40, 57 (on the ledge). * Two stacked wooden crates about 1.4 m tall together sit beside the door wall (82), and a pallet and plank pile sits nearby (94). * Crates are marked “FRAGILE” and “123-09”. Cardboard boxes (about 0.4-0.5 m) sit in small piles at the start (163, 164) and five on the deck at the sign wall corner (171). ### SKATE letters and other collectibles [Section titled “SKATE letters and other collectibles”](#skate-letters-and-other-collectibles) * Letters are floating neon icons, visible in the flyover only: S between the corner kickers (9, 11, 12), K above the wide ramp (4 and 33 area), A near the taxi (4, 12), T above the half pipe (12, 73), E over the second deck (25-27). * Hydrant markers (neon blue icons) float above several ramps (108-111). The tape icon floats inside the corridor (4). * Positions on the map are in the Collectibles section. ## Floor plan [Section titled “Floor plan”](#floor-plan) Feature codes (M1-M15) are used by the Collectibles and Gaps sections. Pixel boxes are x range, y range on the 973x767 map. Headings are the map headings defined above. ### Perimeter [Section titled “Perimeter”](#perimeter) * The west wall runs about 20-25 m from its south end at (25,375) to the north-west corner at (460,110). The wall is plain grey brick with pilasters, the sign, and a perforated gate at about (300,330) behind the half pipe. * A short north wall steps out to the north-east at (460,110)-(550,185), then the outline jogs again and rises into the raised north-east block at (680-940,20-250). * The east side is a stepped cut-away edge: from the south corner at about (250,660) the floor edge runs east, jogs out into a south tab (about (310-480,600-750)), then continues north-east along the south-east edge to (730,570), up the tall wall face at (685-730,440-600), and on to the east deck at about (890,400). * Cut-away edges mean the room continues past the map on the south and east sides in the frames’ view; the map shows no walls there. ### Interior features [Section titled “Interior features”](#interior-features) * **M1 West bank**: brown plywood quarter pipe against the south end of the west wall, about (60-170,400-490), with a curved end that wraps around the south-west corner at about (75,475). The SKATEBOARDING sign is directly above it. Painted text on its face reads “FIRM”. * **M2 West platform**: an L-shaped raised concrete platform north-east of M1, about (165-250,380-430), with a lipped front edge facing south-east. A box pile sits on it. A flat floor with cardboard sheets lies in front, at about (140-270,415-480). * **M3 Taxi**: a yellow cab shell on the floor, about (265,505), surrounded by flattened cardboard. It is the only yellow object on the itemless map, so the funbox in the frames is the taxi. * **M4 Half pipe**: a long channel running along the E axis from the west wall to the middle of the room. Two parallel yellow-and-black coping lines, one along (385,320)-(540,445) (north-east side) and one along (245,350)-(415,520) (south-west side), bound a flat floor with the T icon over it. The ramp face on the north-east side is a smooth brown surface; the south-west side is a grey sloped face with seams. * **M5 Hanging corridor**: a dark box on a black steel frame with two diagonal black beams rising toward the west wall, about (285-375,265-350), directly above the north end of M4. The tape icon sits at its south-east end at (355,322). * **M6 North bank**: a plywood quarter pipe under the north wall, about (440-540,225-320), with yellow-and-black coping along its top edge and a red graffiti mark on the wall above it at about (440,190). * **M7 Middle quarter pipe and pillar**: a small quarter pipe at about (570-640,340-400) with a tall grey pillar behind it at about (600-650,230-370) carrying yellow graffiti. * **M8 East channel quarter pipe**: a quarter pipe at about (665-760,415-500) running down to the east, with a dark vertical wall face at (685-730,440-540) on its south side. * **M9 East deck and back pipe**: a flat deck at about (740-880,400-460) holding a box pile, with a second, taller plywood quarter pipe behind it at about (780-890,320-420) against the east end. * **M10 South notch quarter pipe**: a small curved quarter pipe at about (350-390,600-650) at the end of the south tab, with a box pile on the ledge in front at (330,615). * **M11 South-east ledge**: a low ledge in the notch at the foot of M8’s wall, about (680-720,570-610), holding a box pile at (695,598). * **M12 Plywood kickers**: two flat plywood sheets on low frames, one at about (540-585,605-630) and one at about (480-520,640-675). The S icon floats between them at (525,625). * **M13 Dark pit**: a dark, textured, shadowed area at about (470-580,650-720) at the south-east end of the kickers. It reads as a hollow or dark ramp and is not identified. * **M14 Raised north-east block**: a large brown curved mass at about (680-940,20-250) with wall pilasters on its north-east face and a dark gap under it at about (660-800,140-260). The start arrow sits on its floor at (855,210). * **M15 Big rail and box pile**: a thin dark rail line through a heap of cardboard, from (428,572) to (603,490), runs roughly along the N axis at about the middle of the room. The same heap exists on the itemless map. * **Two sloped channels**: a wide grey slope rises from about (600,360) toward the dark gap under M14. It is divided into two lanes by the middle quarter pipe M7. M6, M7 and M8 flank the two lanes, which matches the guide’s channel: three quarter pipes sandwiching both entrances. ### ASCII sketch [Section titled “ASCII sketch”](#ascii-sketch) The sketch is schematic (map headings, not to scale). N is up, E is right. ```text N +----------------------------+ | W wall: sign, pilasters | M14 raised block (NE) | M1 bank M2 platform | start arrow | M3 taxi M5 corridor | M6 bank lanes | M4 half pipe | M7 pillar+QP | K M15 rail+pile | M8 QP, M9 deck+back QP (E) | S kickers M13 | M11 ledge | M10 notch | +----------------------------+ S ``` ## Collectibles [Section titled “Collectibles”](#collectibles) Positions are pixels on the 973x767 items map. Every icon floats above its true floor position by an unknown height, so the floor position is only known to within about the icon’s height in screen terms. Heights are from the guide text and the frame analysis. ### Box piles [Section titled “Box piles”](#box-piles-1) The red circles. The guide’s order cannot be matched to the circles with confidence; the match given is the best reading. | Circle | Pixel | On | Guide entry | Height | Confidence | | ------ | --------- | --------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------ | ----------------------------------- | | R1 | (200,400) | M2 west platform | 2 (platform with rail by the taxi), or 3 (U-shape on the winding quarter pipe) | on the platform, about 1-1.5 m, at the lip of M1 | the guide’s #2 and #3 both fit here | | R2 | (330,615) | M10 ledge in the south notch | 4 (ledge in a corner near the two kickers) | on a ledge about 1 m above the floor | probable | | R3 | (695,598) | M11 south-east ledge | 4 (corner ledge near the kickers) or 1 (turn right from start) | low ledge at the foot of the wall | the guide’s #4 fits R2 and R3 | | R4 | (790,457) | M9 east deck, in front of the back pipe | 5 (deck with a second quarter pipe behind) | deck level, about 1.5-2 m | probable, shares its spot with E | | R5 | (695,155) | the dark gap under M14, beside M6 | 1 or 3 | unknown, in shadow | uncertain | ### Letters [Section titled “Letters”](#letters) | Letter | Pixel | Over | Height relative to nearby structures | Guide wording | | ------ | --------- | ------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------- | | S | (525,625) | floor between the two kickers M12 | in the air, above the kicker lips, reached by an ollie | between two makeshift ramps | | K | (165,562) | floor in the south-west corner, south of M3 | high above the wide quarter pipe, reached by launching from it | end of the room, above the wide quarter pipe, behind the taxi | | A | (267,505) | directly above the taxi M3 | an ollie above the taxi roof | above the taxi funbox | | T | (405,382) | over the M4 half pipe floor | above the half pipe, reached from the ramp at speed | above the half pipe, next to the glass corridor | | E | (757,433) | M8 and M9, over the end of the quarter pipe | just past the quarter pipe’s lip | corner quarter pipe, with a deck and another quarter pipe behind | ### Tape and 100-point pickups [Section titled “Tape and 100-point pickups”](#tape-and-100-point-pickups) | Item | Pixel | Over | Height | Guide wording | | ----------- | --------- | ----------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------- | | Secret tape | (355,322) | south-east end of M5, above the north end of M4 | inside the corridor, about 3 m above the floor | floating inside the room above the half pipe | | 100 (north) | (570,303) | lane between M6 and M7 | in the air in the channel between two quarter pipes | in front of the left roll-in, between two quarter pipes | | 100 (east) | (668,382) | lane between M7 and M8 | same, in the other lane | the same, near the right roll-in | | 100 (south) | (398,686) | above M10 at the end of the south tab | above the wide quarter pipe, reached by launching from it | above the wide quarter pipe at the two-kicker corner | ## Gaps and rails [Section titled “Gaps and rails”](#gaps-and-rails) The guide’s gaps with the structures they connect. Map codes are the best reading and marked “probable” where the guide wording does not pin a single structure. | Gap | From | To | Map reading | | ---------------- | ----------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | Big Rail | one end of the big rail | the far end | M15, grind the whole rail | | Channel Gap | quarter pipe on one side of an entrance | quarter pipe on the other side | M6/M7 or M7/M8, either lane | | Deck 2 Rail | the deck holding another quarter pipe | the big rail | M9 deck to M15 | | Face Plant | the same quarter pipe | the wall beyond both pipes | M9 back pipe to the east wall, needs high speed | | High Rail | the winding quarter pipe | the high rail under the “skateboarding” sign | M1 to a high rail on the west wall above the sign | | Holy Shi… | a rail by the half pipe | the whole wide quarter pipe | M4 ramp to a rail on the wide pipe, then grind the pipe edge (the guide does not say which pipe in map terms; probable M6) | | Kicker 2 Ledge | the kicker facing away from the wall | the grey ledge at the corner | M12 to M11 | | Kicker Gap | one kicker | the other kicker | M12, short plank to plank | | Monster Grind | the high rail | the quarter pipe edge, then the ledge rail at its end | M1 high rail, down M1, then the M2 platform rail | | Over the Pipe | the large ramp on one side of the half pipe | the large ramp on the other side | the two sides of M4 | | Secret Room | one ramp | the other ramp, through the corridor | M4 sides, through M5 | | Taxi 2 Ledge | the taxi funbox | the ledge with the short rail on top | M3 to the M2 platform edge | | Taxi 2 Rail | the taxi funbox | the big rail | M3 to M15 | | Taxi Gap | the taxi funbox | the middle of the half pipe | M3 to the M4 floor | | Transfer | one side of the U-bend in the wide quarter pipe | the other side | the U-shape on the wide quarter pipe (probable M1 or M6) | | Transition Grind | the quarter pipe between a roll-in and a wall | the wall, then the half pipe edge | a lane-side pipe (probable M6) along the wall, wallride, then the M4 coping | Rails named in the guide: the big rail (M15); the short rail on the ledge by the taxi (M2); the high rail under the “skateboarding” sign (west wall, not visible as geometry on the map because it is above the wall line); the rail at the other side of the half pipe (not identified on the map). ## Look [Section titled “Look”](#look) * **Floor**: pale stained concrete, cracked, with tile seams, wet patches and puddles (188, 10). Diamond-plate steel at the entrance threshold and rail platform. * **Walls**: stained grey concrete, white brick and corrugated rust-coloured metal. A teal and dark-blue painted band runs along the lower wall (10, 16, 128). The sign wall carries the large painted hydrant company sign. * **Graffiti**: heavy on every ramp and wall. Names seen: KDR, JOSH, WERK, 518, RAZ, VERT, WEAK, CIRCUIT, TOKU, DOM, TUKID, CHRISTIAN. * **Windows**: steel-sash grid windows, three tiers high, along the window wall, with clerestory windows overhead. Window-bar shadows cast bright grids on the floor and ramps (24, 138, 156). * **Ceiling**: exposed steel trusses and beams with hanging fluorescent fixtures (163, 172). The ceiling is about 5 m or higher near the entrance. * **Ramp materials**: tall quarter pipes and the half pipe are plywood sheets with seams and steel coping. The wide bank along the sign wall is slotted diamond-plate steel. * **Props**: orange cones, orange-and-white A-frame barricades, wire or mesh cage panels, orange safety fence (55), pipes, a High Voltage sign above one ramp (122). * **Lighting**: bright shafts of sun from the window wall on a dim, dusty interior. Frame 188 and frame 24 show the contrast. ## Extracted game data [Section titled “Extracted game data”](#extracted-game-data) The [construction piece inventory](/docs/thps-warehouse-pieces/) assigns every extracted polygon and script-added element to an editable kit piece, including ramp profiles, windows and the cross-check against the landmarks above. It is additive; the shipped monolithic Warehouse remains unchanged. The mesh, textures and collectible positions come from the USA PS1 disc (SLUS-00860, raw MODE2/2352 `.bin`, sha256 `f1398cd1...`). Nothing ripped is committed: the disc image lives in `tools/thps1/` and every output in `.tools/thps1/`, both gitignored. ### Rerunning the extraction [Section titled “Rerunning the extraction”](#rerunning-the-extraction) 1. Download JayFoxRox/thps2-tools (MIT) into its own directory. The scripts used were `common.py` (sha256 `4932af48...`), `convert-psx.py` (`059ba8c7...`) and `disassemble-trg.py` (`566db4a8...`); the download was not a git checkout, so these hashes are the pin. 2. Run `uv run --with pillow scripts/thps1_extract.py --disc "tools/thps1/Tony Hawk's Pro Skater.bin" --converter-dir `. Pillow 13.0.0 and Python 3.13 were used. 3. The script reads `CD.HED` and `CD.WAD` from the ISO, cuts out `skware.psx` (mesh), `skware_l.psx` (textures) and `skware_t.trg` (nodes), converts them with a patched copy of `convert-psx.py`, and writes `.tools/thps1/warehouse/`: `warehouse.obj`, `warehouse.mtl`, 99 texture PNGs and `warehouse_nodes.json`. `scripts/thps1_extract.py` is covered by `tests/test_thps1_extract.py`; run it through `task scripts:test`. ### Format notes [Section titled “Format notes”](#format-notes) * `CD.HED` is a list of NUL-terminated names padded to 4 bytes, each followed by a u32 offset and a u32 size into `CD.WAD`, ended by a `0xFF` byte. 669 entries. * The level is a family of files: `skware.psx` is the mesh (252 objects, 288 models, no textures), `skware_l.psx` is the same container with no objects and only the texture section (99 textures, 72 4bpp and 25 8bpp palettes), `skware_t.trg` is the node and script data. `level.pre` is a bundle of interface bitmaps, not level textures. * Texture names are u32 hashes shared between `skware.psx` (face texture indices) and `skware_l.psx` (pixel data). Every one of the 80 materials the mesh uses has a texture. * THPS1 textures align only the width (4 pixels at 4bpp, 2 at 8bpp). `convert-psx.py` also rounds the height up to a multiple of 4 and loses sync at the first texture with an odd height (54x54 at 4bpp). The script patches `aligned_height` to the true height in a copy; with it every texture offset lands exactly. * Magenta in the PNGs is the PS1 transparency key. * Face UVs are u8 pixel coordinates. The script divides by the texture size and flips v, so the OBJ’s UVs run past 0..1 (u up to 6.4, v down to -12) wherever a small texture tiles. Import with repeat wrapping. * Positions in the nodes are s19.12 fixed point divided by 4096, the same unit as the OBJ. y is negative upward. Object positions in the mesh are s7.24, and vertices are s3.12. * The `thps1_file` flag in `disassemble-trg.py` only changes a TrickOb branch this level never reaches; output is identical with it on. ### Collectible nodes [Section titled “Collectible nodes”](#collectible-nodes) All positions are (x, y, z) in OBJ units. `warehouse_nodes.json` also holds the node index of each. | Item | Position | | --------------------- | ---------------------------------------------------------------------- | | S | (0.829, -0.104, 2.662) | | K | (1.620, -0.255, 3.481) | | A | (1.873, -0.104, 2.962) | | T | (1.893, -0.232, 2.297) | | E | (1.021, -0.152, 1.383) | | Tape | (2.238, -0.251, 2.297) | | 100 north | (1.893, -0.255, 1.632) | | 100 east | (1.395, -0.255, 1.632) | | 100 south | (0.921, -0.255, 3.234) | | 100, 200, 500 cluster | (1.444, -0.386, 0.916), (1.520, -0.386, 0.916), (1.594, -0.386, 0.916) | | Box pile (node 43) | (2.023, -0.381, 0.924) | | Box pile (node 107) | (0.896, -0.138, 1.346) | | Box pile (node 109) | (0.616, -0.078, 2.140) | | Box pile (node 111) | (1.318, -0.138, 3.188) | | Box pile (node 113) | (2.460, -0.078, 2.844) | * The box piles are not pickups. They are the five Baddy nodes whose script ends in a goal counter; debris nodes sit within about 0.05 units of three of them. The 45 `Crate` nodes are breakable props (glass, panels, signs), not the wooden crates. * The 100, 200 and 500 cluster is real and is not marked on the items map. ### Cross-check against the items map [Section titled “Cross-check against the items map”](#cross-check-against-the-items-map) A least-squares affine fit of the five letters, the tape and the three 100s to their pixel positions on `tools/thps_warehouse_items_map.png` leaves 5 to 32 px of error (the map labels sit beside the items). It gives about 255 px per unit along x and 261 along z. The five pile nodes project to (708,167), (810,453), (678,611), (311,612) and (172,380), each 16 to 34 px from a different one of the five red circles at (698,155), (790,457), (695,600), (330,614) and (199,401). The match is one to one with nothing left over. Mismatches: the cluster of three bonus pickups has no marker on the map, and the largest single error is K at 32 px. ### Unit calibration [Section titled “Unit calibration”](#unit-calibration) The skater (`hawk.psx`) is 0.844 units tall and the taxi (`c_taxi.psx`) 2.53 long, which both give about 2 m per unit, but at that scale the Warehouse (2.0 by 3.1 units) would be 4 by 7 m. The skater and vehicle models are therefore not in the level’s unit and cannot be used as the ruler. The calibration anchor is the half pipe, measured on the mesh at its centre line (x = 2.018): the flat floor runs from z 2.218 to z 2.376 (0.158 units), the coping lines are at z 2.075 and z 2.519 (0.444 units apart), and each transition is 0.135 units tall. One unit is taken as 18 m, from the 0.444 unit coping-to-coping width equal to 8 m. That makes the transitions 2.4 m tall (the frames suggest 2.5-3 m) and the flat floor 2.8 m wide, and the room about 36 m by 56 m, larger than the 20-25 m by 14-17 m read from the map. Reading the 8 m as the flat floor alone would give about 51 m per unit and 6.8 m transitions, which contradicts the frames. This is a single anchor and low confidence; the skater, taxi and cardboard pieces cannot cross-check it because the skater and vehicle models are in a different unit from the level. ## Unresolved [Section titled “Unresolved”](#unresolved) * **Walls and headings**: the map defines its own headings. The door wall, window wall and graffiti brick corner are not placed on it, since the south and east sides are cut away. The map’s “SKATEBOARDING” wall is not confirmed to be the frames’ sign wall. * **Room dimensions**: the 20-25 m by 14-17 m extent comes from one anchor (the half pipe flat at about 8 m) and is low confidence. The height of the raised north-east block and of every wall is not shown. * **Box pile numbering**: the extracted pile nodes match the five map circles one to one, but the guide’s pile numbers (1 to 5 in its text) are not matched to them. R1 fits guide piles 2 and 3, R2 and R3 both fit pile 4, and R5 sits in shadow under the north-east block. * **Floating icon heights**: all icons are drawn above their floor position by an unknown height, so ground positions and heights come from the guide text only. * **Start position and roll-ins**: the blue start arrow is on the raised north-east block, and the guide’s “left” and “right” roll-ins are not matched to the two lanes with confidence. The function of the block (roof, ramp or platform) is not shown. * **Dark pit M13**: unidentified; it may be a hollow, a dark ramp or a drain. * **Floor line near K**: a thin dark line with three ticks at about (120,490)-(180,540) in the south-west bay is either a floor marking or a low rail; unresolved. * **Quarter pipe naming**: the guide’s “wide”, “winding” and “U-shaped” quarter pipes are not mapped to M1, M6 and M8 with confidence. M1 is the best fit for the winding pipe (curved end). The U-shape on the guide’s wide pipe is not visible on the map. * **Half pipe sides**: the map shows two coping lines, but not whether both sides are full-height transitions of the same size. * **Relationship between the wide plywood and wide slotted quarter pipes**: the frames and the map do not settle which map structure is which. * **The “large ramp slope with lattice shadows”** at the right of frames 154, 155 and 191: possibly the sloped lanes under the north-east block, not confirmed. * **Glass corridor versus secret room**: the map shows one hanging box (M5), so they are probably one space. The tape room’s exact access point and the interior length are not shown. * **Exact heights and widths**: every size is a skater-scale ratio from an oblique view and is low confidence. The tallest ramps never show their coping in frame. * **Frames 215 and 216**: extreme top-down camera angles that do not map to a known structure. * **Remake versus original differences**: the map render is also a remake render. Which props and textures are remake-only is not known. Layout is assumed to match the original. * **Hidden surfaces**: backs of the ramps, the underside of the decks and the space behind the mezzanine and the north-east block are never on camera. # THPS Warehouse layout spec What the Megabonk editor can place, and how the THPS Warehouse (see [thps-warehouse.md](/docs/thps-warehouse/)) maps onto it. The structure inventory and map coordinates come from that doc; the grid, scale and catalog facts come from [level-format.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/level-format.md), [runtime-map-structure.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/runtime-map-structure.md) and [asset-bundles.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/asset-bundles.md), plus values read from the running game (build 1.0.69, CrossOver) with `query_objects` and the editor palette. ## Base map and stage [Section titled “Base map and stage”](#base-map-and-stage) Forest, stage 0, `ParamForest1`, debug seed `20261007` (the seed the repo’s regression levels use, so generation is known to be reproducible). * `EMapType.Tiles` maps are the only ones with a tile grid, so they are the only ones where terrain can be shaped with `SetTileHeight`, `SetTileSlope`, `AddTile` and `RemoveTile`. Forest and Graveyard are the Tiles maps. * Desert and Hell are `ProceduralMesh`: one heightmap mesh, no `proceduralTiles`, and the editor refuses tile tools there (objects only). They are excluded, as is Dungeon, which is a room-based generator and not a tile grid. * Forest over Graveyard: its smallest grid step. `ParamForest1` is 15x15 at 40 units per tile, `ParamForest2` 15x15 at 44, `ParamForest3` 16x16 at 48, `ParamGraveyard` 19x19 at 40. Forest also has no crypt dungeon, and its palette (75 entries) has been walked end to end. * Stage 0 rather than 1 or 2: it has the smallest scale (a step of 20 units against 22 and 24), and every existing regression level uses it. ## Scale and grid [Section titled “Scale and grid”](#scale-and-grid) | Quantity | Value | Source | | -------------- | ------------------------------------------------------------------------------ | -------------------------------------- | | Player capsule | 3.85 units tall, 0.6 radius | `CapsuleCollider` named `Player`, live | | Tile width | 40 units (`scaledTileWidth`) | `ParamForest1`, live | | Height step | 20 units per level (`scaledTileHeight`) | `ParamForest1`, live | | Grid | 15x15 tiles, cell (0,0) at world (-280,-280), world = (-280 + 40x, -280 + 40z) | runtime-map-structure.md | | Skater | 1.8 m | thps-warehouse.md | The skater is 1.8 m and the player is 3.85 units, so true scale is about 2.1 units per THPS metre. At that scale a tile is about 19 m wide and one height level is about 9 m, which is far coarser than the warehouse: its 2-3 m ramps are 4-6 units, a quarter of one level. Tiles are therefore too coarse to build anything in the level, and the level keeps just one of them out of sight; every floor, ramp, deck, ledge, wall and kicker comes from the placed Warehouse mesh. ### Chosen scale [Section titled “Chosen scale”](#chosen-scale) 4.4 units per THPS metre, about 2.1 times true player scale, so the playable footprint fills a whole number of tiles. * The extracted Warehouse mesh measures about 2.0 by 3.1 OBJ units (about 158 x 248 units at the placed scale of 79.2). * A block of 4 tiles east by 7 tiles north (160 x 280 units) holds it. * A 2-3 m ramp becomes 9-13 units, about 2.5-3.5 player heights, and one height level (20 units) is about 4.5 m. ### Grid orientation [Section titled “Grid orientation”](#grid-orientation) * Map north is grid +Z and map east is grid +X, the same as `SlopeDirection` (`North` is +Z, `East` is +X). Block-local coordinates below are units from the block’s south-west corner, `x` east and `z` north. * The Warehouse block covers cells x 8-11, z 4-10. The level is a from-scratch file whose grid holds a single tile in that block, flat at level -2 and hidden under the Warehouse floor, and the authored kill floor ends a fall. The mesh floats like a native island and its own walls enclose the arena, so no tile terrain is needed to hold the playfield in. ### Player-reachable heights [Section titled “Player-reachable heights”](#player-reachable-heights) Measured in the running game (build 1.0.69, CrossOver): * A plain jump from the floor peaks at about 7.8 units above it (the capsule centre reaches about 9.7 with a resting centre height of 1.925). The jump is stat-driven (`GetJumpForce`), so a run with different items can differ. * `PlayerMovement.maxSlopeAngle` is 45. A ramp face steeper than that cannot be run up: on a test pipe reaching 56 degrees the player stalled between a body height of 8 and 10. The extracted Warehouse half pipes go well past that: their faces run 31, 34, 54, 77 and 85 degrees up to the lip (measured from the mesh near (142, -3, 32)), so the level raises the limit instead, see “Slope limit on the half pipes”. * Anything taller than a jump is reached by running up a ramp or by launching off one, the same way the skater uses quarter pipes. * The game has a `JumpPad`, `Ladder`, wall climbing and rail grinding (`railSpeed`, `canJumpOffRailTime`), so higher spots can also be reached through those. ## Structure to catalog mapping [Section titled “Structure to catalog mapping”](#structure-to-catalog-mapping) For the additive editable kit, see the [construction piece inventory](/docs/thps-warehouse-pieces/). The mapping below describes the unchanged shipped level, not the separate kit assembly. The Warehouse is one `bundle:thps/Warehouse` placement built from the extracted mesh (see [asset-bundles.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/asset-bundles.md)): the floor, banks, half pipes, decks, ledges, walls, windows, doors and trusses are all in that mesh, so no structure needs its own catalog entry. The Megabonk catalog supplies what the mesh does not: | Feature | How it is built | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Arena floor and enclosure | the placed Warehouse mesh: its floor, banks and walls are all in it, and the game’s ground-layer spawn raycasts land enemies on its floor. The grid holds one tile, flat at level -2 under the mesh, which the game needs to derive its catch floor; the authored kill floor (`killFloorY` -35) ends a fall | | Grind rails on the mesh bars | hidden `interactable/rail/0` objects laid along each mesh bar (see “Mesh rail near (75, 3, 28)”) | | Box piles and the tape | `interactable/chest` and `interactable/chestFree` | | Letters | `forest/shrines/0` to `4` | | Charge Shrines | 2 or 3 each run at authored spots: the level’s `extraShrines`, `extraShrinesMax` and `shrineSpots` settings make the mod place them at authored spots each run (see “Charge Shrines” below) | | Boss portal | the game’s boss spawner, stood on the floor inside the hanging box above the main half pipe; the mod teleports its summoned bosses to random interior spots away from the box (see “Boss spawn redirect” in [development.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/development.md)) | Catalog IDs follow level-format.md. `rail/0` is a straight bar about 28 units long at scale 1; `rail/1` is about 50 units long with a bend; `rail/2` to `5` are curved variants not yet placed. ## Collectible mapping (confirmed) [Section titled “Collectible mapping (confirmed)”](#collectible-mapping-confirmed) Confirmed as written: box piles are chests, letters are shrines, the tape and the three 100 pickups are free chests. The 4.4 units per THPS metre scale is confirmed too. Collectibles stay as normal Megabonk items. The Forest palette has `interactable/chest`, `interactable/chestFree` and five shrines `forest/shrines/0` to `4`, which is exactly one shrine per letter. The positions come from `warehouse_nodes.json` (the extraction in thps-warehouse.md), not from a hand-written table. `collectibles()` in `scripts/build_thps_level.py` maps each node and anchors it with `scripts/warehouse_surface.py`, which reads `warehouse.obj` the way the pack importer does: * Box piles (nodes 43, 107, 109, 111, 113) are `interactable/chest`, in node order as R1 to R5. Letters are `forest/shrines/0` to `4` for S, K, A, T, E. The tape (node 16) and the three lane 100 pickups (nodes 15, 30, 31, `LANE_BONUS_NODES`) are `interactable/chestFree`. The 100, 200 and 500 cluster at nodes 148 to 150 has no item in the confirmed mapping and stays unplaced. * An item stands on the highest collider surface at or below its node, found by a downward ray. Its tilt is the surface normal, and its pivot sits `base_offset` above the surface along that normal (`ITEM_METRICS`, measured from the placed items’ mesh bounds with `query_objects`: 0.5 for both chests, 0.12, 0.05, 0.05, 0.02 and 0.13 for `forest/shrines/0` to `4`). * The spot has to be supported (the corners of half the item’s bounds land on the same plane within 0.3), on a surface no steeper than 45 degrees (`MAX_SURFACE_TILT`), and clear of every collider triangle except the one it stands on (a box of the item’s bounds starting 0.1 above its base). A node that fails slides in x and z by the least distance that passes, up to 10.5 units (`MAX_NODE_SHIFT`). * Tests check the base within 0.1 of the surface below, no overlap, the shift cap and the mapping. They skip when `.tools/thps1/warehouse` is absent. Deviations from the source node positions (y is the node height, and “drop” is how far the item stands below it): | Item | Node | Megabonk item | Node x, y, z | Base x, y, z | Slide | Drop | Tilt (deg) | | ---- | ---- | ------------------------ | ------------------ | ------------------ | ----- | ---- | ---------- | | R1 | 43 | `interactable/chest` | 139.9, 30.7, -82.5 | 137.4, 27.8, -82.5 | 2.5 | 2.9 | 0 | | R2 | 107 | `interactable/chest` | 50.7, 11.5, -49.1 | 50.7, 8.6, -49.1 | 0 | 2.9 | 0 | | R3 | 109 | `interactable/chest` | 28.5, 6.7, 13.8 | 28.5, 3.8, 13.8 | 0 | 2.9 | 0 | | R4 | 111 | `interactable/chest` | 84.0, 11.5, 96.8 | 82.5, 8.6, 96.8 | 1.5 | 2.9 | 0 | | R5 | 113 | `interactable/chest` | 174.5, 6.7, 69.6 | 174.5, 3.8, 69.6 | 0 | 2.9 | 0 | | S | 28 | `forest/shrines/0` | 45.3, 8.7, 55.1 | 45.3, 0.5, 55.1 | 0 | 8.2 | 0 | | K | 29 | `forest/shrines/1` | 108.0, 20.7, 120.0 | 108.0, 0.5, 110.0 | 10.0 | 20.2 | 0 | | A | 17 | `forest/shrines/2` | 128.1, 8.8, 78.9 | 125.1, 2.4, 82.9 | 5.0 | 6.4 | 19 | | T | 13 | `forest/shrines/3` | 129.6, 18.9, 26.2 | 129.6, -4.9, 26.2 | 0 | 23.8 | 0 | | E | 12 | `forest/shrines/4` | 60.6, 12.5, -46.2 | 60.6, 8.6, -46.2 | 0 | 3.9 | 0 | | Tape | 16 | `interactable/chestFree` | 156.9, 20.4, 26.2 | 156.9, 15.6, 26.2 | 0 | 4.7 | 0 | | 100 | 15 | `interactable/chestFree` | 129.6, 20.7, -26.5 | 129.6, 4.5, -26.5 | 0 | 16.2 | 34 | | 100 | 30 | `interactable/chestFree` | 90.2, 20.7, -26.5 | 90.2, 4.5, -26.5 | 0 | 16.2 | 34 | | 100 | 31 | `interactable/chestFree` | 52.7, 20.7, 100.4 | 52.7, 8.6, 101.9 | 1.5 | 12.2 | 0 | Hand-arranged items. Items moved in the editor are recorded in `scripts/thps_item_overrides.json` (item code to world `position` and euler `rotation`), and `collectibles()` applies them over the anchored transform, so regenerating the level reproduces them. The surface, overlap and node-distance tests skip these items, because the arrangement is deliberate. To record a new arrangement, save the level from the editor and run `uv run scripts/extract_thps_item_overrides.py /UserData/Levels/thps.mblevel.json`; it takes the last move of each collectible placement and writes only the items that differ from the anchored transform, replacing the file. K, A, T, E, the tape and the two lane 100 chests at nodes 15 and 30 are currently overridden, so the table above shows their anchored starting points, not where they end up. The overrides are not checked against the mesh: at the last extraction A overlapped three collider triangles and the node 30 chest two, and T had a pitch of 0 rather than the upright 270. T’s override stands on the main half pipe floor at (170.39, -4.91, 24.07), 0.02 above the mesh, and a test keeps it there; saved from the editor it had hovered 1.55 units up. E, the tape chest and the node 15 chest likewise had hovered 0.37 to 0.48 units and now rest on the surface; A and the node 30 chest lean into the floor by design and are left sunk by up to 0.8 units at their lowest corner. `lowest_clearance` measures the lowest footprint corner against the mesh, and a test keeps every hand-placed item from hovering. * R1 (node 43) sits on the very edge of the platform at x 140 with no floor beyond, so it slides 2.5 units onto the platform. R4 and the third 100 slide 1.5 units to clear a wall or an edge. * K hovers 20 units above the foot of the north quarter pipe, where the surface is steeper than the limit and the platform on top is too shallow for a shrine, so it stands on the flat floor 10 units south. A slides 5 units to a 19 degree slope. * T stands on the main half pipe floor, 23.8 units below its node, as the node’s letter hovers in the air above the pipe. * The two lane 100 pickups stand on a 34 degree ramp, so their chests tilt with it, and a player standing there slides off. * Checked in game: every item’s bounds sit on the predicted surface (bounds bottom within 0.05 of it), and the player dropped onto each base settles at the predicted height. R1 settles 0.04 above, and the two ramp chests rest about 0.1 above the model at the ramp foot after the player slides down. Close-ups of all 14 show none floating or clipping, and the tape chest’s room shows no chest through its underside. * The chest, shrine and merchant prefabs are authored lying down (the prefab asset’s euler X is 270), so a placement at rotation 0 renders them on their side. Items are placed at X 270 on flat ground, and the surface tilt is composed on top of that on a slope (`item_rotation` in `warehouse_surface.py`). * `forest/shrines/3` is the `ShadyGuy` prefab, the merchant NPC, not a shrine, and `forest/shrines/0` to `2` and `4` are the Challenge, Magnet, Maoi and Cursed shrines. The mapping stays one catalog entry per letter. ## Charge Shrines [Section titled “Charge Shrines”](#charge-shrines) The seed’s own Charge Shrines are removed with the rest of the generated clutter, and the Warehouse is far smaller than a native map, so it asks for 2 or 3 each run (`SHRINE_COUNT`, kinds restricted to `charge`) and names where they may stand (`SHRINE_SPOTS`, written to the level as `shrineSpots`; see “Extra shrines” in [level-format.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/level-format.md#extra-shrines)). An earlier version found spots with rules (flat floor, distance from lips and rails, wall contact) and kept putting shrines in front of ramps and half pipes and in the opening run, because none of those rules knew about kickers or the start slope, so the spots are authored instead. | Spot | Where | | --------------- | ----------------------------------------------------------------------------------------- | | (30, 2, 71) | the alcove on the west wall beside the kicker, off the grind line | | (22, 6, 4) | the top of Shorty’s ledge at the west wall by the anarchy poster, away from the start lip | | (41, 10, -41) | the tiered deck on the north wall | | (173, 28, 27) | the roof of the hanging box over the main half pipe | | (79, 10, 103) | the deck by the pocket on the south wall | | (178, 10, -32) | the corner at the east end of the north wall quarter pipe | | (178, 10, 123) | the south-east corner deck | | (139, 38, -123) | the ledge behind the start lip at the north wall | Every run places one Succ (Magnet), one Challenge, one Boss Curse and one totem (the Hawaiian statue, `InteractableShrineMoai`) plus 2 or 3 Charge Shrines, shuffled across the spots (`GUARANTEED_SHRINE_KINDS`), so a run has 6 or 7 shrines on 8 spots. The fixed SKATE letters already include one each of those kinds, so these are extras. The chests stay at their fixed positions, but each run shifts the luck their loot is rolled with by a value drawn from -0.5 to 2 (`CHEST_LUCK_RANGE`), so a run’s chests are poorer or richer overall. Each spot is an anchor, not an exact position: a shrine is about 3 units wide, so a spot at a wall or the foot of a slope has no room, and the game moves it outward in rings of 2 units to the nearest position where it fits. In the game all eight spots place, most within 6 units of their anchors ((22, 6, 4) settles at (23.4, 4.0, 5.4) and (173, 28, 27) at (177, 25.9, 27)). (178, 10, -32) and (139, 38, -123) are corners at the top of a quarter pipe and a ledge behind a lip, where the flat floor is a strip narrower than the shrine’s clearance box, so they take the tight fallback below and stand exactly on their spots. Tests check that there are more spots than the largest count, that the largest count fits with the spots clear of each other and that the spots are inside the Warehouse footprint; whether a shrine fits is only known in the game, where the log names the collider in the way of a spot that fails. The offline Warehouse mesh has no floor under (30, 2, 71) even though the game does, so a floor check against the mesh is not made. Each Charge Shrine is golden with a 10% chance every run and regular otherwise; the other kinds have no rarity (see “Extra shrines” in [level-format.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/level-format.md#extra-shrines)); a charged golden one offered Legendary rewards in the game, a regular one Common. Standing in a spawned shrine’s zone charges it and opens its reward window, which `dismiss_offers` closes. ## Windows opened to the player [Section titled “Windows opened to the player”](#windows-opened-to-the-player) The pack builds the Warehouse collision mesh without the faces of some windows (`ThpsPack.cs`, `SaveCollisionMesh`). The `FA88F30D` windows are dropped by material. The `F2184063` windows share their material with solid windows elsewhere, so only faces whose world position lies inside one of the `OpenedWindowBounds` boxes (x, y, z min and max) are dropped, and the rendered mesh is untouched: | Window | Bounds | | ----------------------------------------------- | ------------------------------------- | | Secret room, intact sections | x 149 to 179, y 15 to 26, z 20 to 32 | | Top of the starting slope, two panes at z -73.3 | x 80 to 140, y 27 to 38, z -75 to -72 | The half pipe behind the starting slope windows is reached through them. Other `F2184063` windows keep their collision. ## See-through textures [Section titled “See-through textures”](#see-through-textures) The PS1 transparency key (magenta) is baked to alpha 0 in every texture that carries it (22 of the 99), and those textures are listed in `SeeThroughTextures` in `ThpsPack.cs`. Their materials use `Sprites/Default` so the scene shows through, because the pack’s toon shader is opaque and its alpha-clip variant is not in the game build (with `_MK_ALPHA_CLIPPING` on, the albedo map is ignored). They are the ceiling chains, the crane hooks, the posters and graffiti, the chain-link fence, the lamp and the two window textures (`F2184063`, `FA88F30D`), whose keyed pixels are the broken panes. `Sprites/Default` is unlit, so these materials ignore the scene lighting. ## Open questions and risks [Section titled “Open questions and risks”](#open-questions-and-risks) * **Interaction is only partly verified.** The collectibles spawn through the same catalog path as generated chests, and a player standing at the R5 chest shows the chest’s 30 gold price label. Opening one needs the `Interact` key and gold, which the harness cannot send. * **Shrine effects on the letters.** The mapping is confirmed, but shrines change a run when used, so whether the effect suits a letter is worth a look once they are placed. * **No generated clutter.** A from-scratch level generates no trees, rocks, shrines or tile scenery, so nothing is removed. The Forest particle effect still exists in the level without a placement. * **Rail grinding needs the right approach.** The hidden `rail/0` objects do grind — the mesh rails and copings below were all checked in a run — but a rail does not catch on its own, and the harness injects no gameplay input, so speed, approach angle and jump timing still have to come from a hand on the keyboard. * **The structure map itself is low confidence.** Box pile numbering, the start block’s role and the exact positions are ambiguous in the source analysis; see its Unresolved list. ## Building the level [Section titled “Building the level”](#building-the-level) ### Editable construction assembly [Section titled “Editable construction assembly”](#editable-construction-assembly) `levels/thps-editable.mblevel.json` is a gitignored, locally generated reference layout assembled by `scripts/build_thps_editable_level.py`. Run `task thps:editable-level` to regenerate it, or `task thps:editable-install` to install it alongside the shipped level and the already-built pack. Build a missing pack first with `task pack:thps`, then relaunch with `relaunch(level="levels/thps-editable.mblevel.json")`. The generator uses `pieces.json` instance positions, full rotations/scales and canonical `catalogAsset` references, not a second implementation of mesh maths. Repeated signs, boards, kicker parts, posts and props reuse a compact asset library. Roof quads are merged into larger selectable sections, with shared panel assets wherever their shape permits. It snapshots `attachments.json` per-instance defaults into each owner placement: 17 supports and 41 grind paths. The nine archival `suppressed-post` instances are intentionally absent. Attached supports are not also placed at the root, and legacy rails are not emitted again. Placement IDs reserve each owner’s ordered children before the next root. The consolidated library has 200 construction assets plus the legacy Warehouse. The reference uses 32 roof sections instead of 1,541 individual quads, including 12 instances of the same stock flat panel. Its 24 loose planks use six artwork variants; different kicker top artwork remains separate. The source-face inventory remains complete without requiring an asset for each source instance. Floor tile, boss spawner, radio and the 14 collectibles reuse the shipped generator’s final transforms and overrides. There is no `bundle:thps/Warehouse` placement. The shipped level and generator remain the byte-preserved monolithic reference; the editable generator refuses to overwrite that file. Regeneration resets the reference layout, so save custom arrangements under another name rather than regenerating over them. In the editor, the **Level** panel cycles through settings with `<` and `>`. Enter marker coordinates as `[x,y,z]`, booleans as `true`/`false`, and `null` for an optional setting’s default; text fields take plain text and character lists take a JSON array. **Clear** empties the input; **Apply** or Enter commits it; **Cancel** or Escape discards the draft. Settings have their own **Undo/Redo** buttons, separate from object history. Save includes both histories’ current state, and settings-only changes count as dirty. The cyan player-start marker uses body height; the magenta boss destination uses floor height. **Locate marker** flies the camera to the selected marker, and **Marker to camera** moves it to the current camera position. Numeric input also edits either marker. They are editor-only lines without colliders. The boss destination remains independent of the portal and hanging room. The portal, radio, collectibles and construction geometry use normal object tools; Ctrl-click selects a hidden grind path instead of its visible owner. Gameplay controls apply immediately and persist on save: kill floor (still clamped above the catch floor), slope limit, slide-gated grinding and grass. Music, title/descriptions and character restrictions are editable as well. Map, stage, tier and seed changes are refused when existing edits target the current generation; start a fresh level or regenerate the layout instead. In-place Load similarly refuses a different generation. Pack dependencies are derived at save time; schema/build metadata are compatibility data, not props. The kit has its own revision (`packKitVersions`) in addition to the preserved legacy pack version. See [pack versions](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/level-format.md#pack-versions). Revision 2 replaces the original quad-per-prefab kit without compatibility aliases; regenerate this reference after rebuilding the pack. The preserved `thps.mblevel.json` and `forest_custom.mblevel.json` do not depend on those kit IDs. ### Authoring with the construction kit [Section titled “Authoring with the construction kit”](#authoring-with-the-construction-kit) Kit authoring happens in the editor on `levels/thps-editable.mblevel.json` or a scratch copy of it, and saves go to `UserData/Levels/.mblevel.json` through the toolbar. Nothing edited there touches the shipped file: see [Preserving the shipped Warehouse](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/development.md#preserving-the-shipped-warehouse), where `uv run scripts/check_thps_preservation.py` fails `task check` if `levels/thps.mblevel.json` drifts from its baseline. `task thps:editable-level` regenerates the editable reference from scratch, so save custom arrangements under another name instead of editing that file in place. **Palette.** The kit’s pieces sit in the palette’s `Packs` tab next to the legacy `Warehouse` monolith (see [Catalog palette](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/development.md#catalog-palette)). The search field filters as you type; clicking it focuses the field with the caret at the end, so a second search appends to the previous text — Escape blurs the field and a second Escape clears it. Clicking a slot only *selects* the entry; press 5 (or click **Place**) to arm placement, then click on the map. Middle-clicking a placed object selects its palette entry and switches to Place, which is the duplicate flow (`EditorObjectTools: pick block ...`). **Where a click lands.** The Warehouse levels author one floor tile, cell (10, 7), and no other, and the tile ray only sees the baked ground collision, so a place or tile click outside that cell is silently ignored — no log line, nothing happens. The yellow outline shows the targeted cell and, in Place, an orange circle marks the exact point; objects do not snap to the quarter-tile guide lines. **Placement height — the “invisible piece” trap.** A placed piece’s Y follows the floor tile’s surface, and that tile sits at level −2, whose top is at y −40 (20 per level, see development.md’s camera-pose formula), under the mesh that hides it. A fresh click therefore buries the piece 40 units below the visible Warehouse floor, where it looks invisible from any interior vantage while its collider and selection stay live (Select still gets it `via nearest`). Raise the cell to level 0 with tool 1 (twice) to place at floor height, or drag the piece up afterwards; `query_objects` on `Renderer` bounds tells you in one call whether a piece is at floor level or under it. The main-pipe modules in the recipe below mate at pivots y 0 to −4.93, so they want the raised tile. **Mating.** [Ramp module profiles and mating contract](/docs/thps-warehouse-pieces/#ramp-module-profiles-and-mating-contract) is the reference for profiles, widths and terminal edges. The numbers that matter for the main pipe: a transition strip’s pivot sits at its toe, like-side strips butt together at pitch 19.722656 along x, the lip sits 11.37 units from the toe, and a rectangular flat mates on the toe side with its centre 6.245470 south of the strip it joins (its north edge meets the strip pivot; the authored flat covers three strips). Palette placement uses the prefab’s own scale and rotation, so kit copies come in at the authored instances’ transform (scale 79.2, rot 0); keep scaling uniform, and compare ordered boundary vertices after placement, not bounding boxes. Grind copings and supports snapshot from `attachments.json` into each placement at the moment of placing (`LiveEdit: attached 'added-grind-coping-1/...' as placement: to placement:`), so a placed strip carries its own coping rails; the joint gaps they leave (about 1.4 units between adjacent copings at correct pitch) are inside `RailHandoff.SearchRadius`, so grinds chain across them (see [Coping rails on the half pipes and wall quarter pipes](#coping-rails-on-the-half-pipes-and-wall-quarter-pipes)). **Ramp rebuild, checked in the running editor.** The authored south half of the main pipe (`piece-main-south-01` at x 129.641983, `piece-main-south-02` at x 149.364639, `piece-main-flat` at (149.364639, −4.933436, 26.190527), all scale 79.2) was removed and rebuilt from three palette copies on the kept cell: | Piece copy | Position | Result | | --------------------- | ------------------ | ------------------------------------------------------------------------------------------------ | | `piece-main-south-02` | (114.40, 0, 6.53) | west strip | | `piece-main-south-01` | (134.06, 0, 6.53) | seam pitch 19.66 against the nominal 19.722656; coping joint gap 1.34 against the authored 1.403 | | `piece-main-flat` | (110.14, 0, −0.66) | 0.95 south of the nominal centre z 0.285 (strip z − 6.245470) | * **Ride.** A drop on the strip seam (x 124.2) slid continuously down both strips, over the toe/flat joint with its 0.95 gap, and came to rest at (124.2, 3.15, 1.61) — no catch at either joint. * **Grind.** With slide held, the catch took the west strip’s coping rail at (114.4, 10.6, 17.9) running east, and at the seam `RailHandoff: moved onto rail at distance 0.00, progress 0.000, direction 1.0` chained onto the east strip’s rail at (134.1, 10.6, 17.9): one continuous grind across the joint at the game’s grind speed. Which way a catch runs follows the approach side, so drop onto the rail from the end you want to travel toward. * **Save/reload.** The save reloaded with all three copies at exactly the saved transforms, verified through `query_objects`. **Performance with the full kit.** With the whole edit stack live (1,447 edits) entering editor mode rendered the palette’s 277 thumbnails in one frame in 360 ms, `LiveEdit: applied` the stack at load in about 40 ms, and a save wrote in about 207 ms; selection stays usable in dense stacks because the raycast falls back to the nearest object within an eighth of a tile of the cursor’s ground point. **Gotchas.** * **Undo is strictly LIFO.** To drop an edit buried under others, undo everything above it and redo the rest. The keyboard chord needs `Ctrl` held at least 0.2 seconds (`UndoRedoCtrlHoldSeconds`) before `Z`/`Y` registers; the toolbar **Undo**/**Redo** buttons have no hold requirement. * **Floor-flush pieces can be invisible from a low vantage** and hard to hit with the ray. The nearest-pick fallback usually still gets them, and the log distinguishes the two paths: `selected placement: '(Clone)' via raycast` versus `... via nearest`. Verify positions after edits with `query_objects` on `Renderer` bounds; destroyed clones stay listed, so filter on `activeSelf` or a non-zero extent. * **Identical stacking is refused.** Placing or moving a piece onto the exact position (within 0.01) of another of the same prefab is ignored with a log line, so nudge the second piece rather than re-clicking the same point (see [Object tools](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/development.md#object-tools)). ### Shipped monolithic reference [Section titled “Shipped monolithic reference”](#shipped-monolithic-reference) `levels/thps.mblevel.json` is generated, not hand-edited: `scripts/build_thps_level.py` writes it (`uv run scripts/build_thps_level.py`) and `tests/test_build_thps_level.py` covers the generator. `ThpsLevelTests` in `tests/MegabonkLevels.Tests` loads the saved file and checks that it is a from-scratch level with one floor tile, that the game keeps the authored kill floor, that the boss portal stands on the floor inside the hanging box, and that the boss redirect is on. Regenerate and reload with `uv run scripts/build_thps_level.py` then `relaunch(level="levels/thps.mblevel.json")`. What the file contains: * `baseline: "none"`, so the file authors its grid instead of editing a generated map, and carries `spawn`, `bossSpawner` and `bossRedirect` (see “From-scratch levels” in [level-format.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/level-format.md)). The grid is the stock 15x15 size with one tile at cell (10, 7) (`FLOOR_CELL`), flat at level -2 (`INNER_LEVEL`). The game derives its catch floor from the tiles and an empty grid arms the kill floor at `int.MaxValue`, which kills the player on spawn, so one tile has to exist; the mesh hides it. The Warehouse block the mesh spans is 4 tiles east by 7 tiles north at cells x 8-11, z 4-10 (`INNER_X`, `INNER_Z`). * `killFloorY` (`KILL_FLOOR_Y`, -35): authored rather than derived, because the tile-derived floor would sit 40 units below the lowest tile, deep enough for an actor to come to rest on the mesh’s sub-floor skirt above it and stay unkillable. The game raises a kill floor to at least 8 above its catch floor, which is 20 under the lowest tile, so the tile at level -2 puts the catch floor at -60 and keeps -35 as authored. It is above the mesh’s lowest geometry (-48.6), so anything that ends up under the Warehouse dies. All 43 mesh faces below -20 are vertical (x 21-80, z 3.5-104: the west wall’s skirt), so nothing is restable in that band. * One `bundle:thps/Warehouse` placement at uniform scale 79.2 (4.4 units per metre times 18 metres per OBJ unit), centred on the arena and lifted 0.5 above y=0 (`WAREHOUSE_LIFT`). The footprint fits the arena (a test checks it). The level records `packs: {"thps": ""}`, so the version check in asset-bundles.md applies; the pack has to be installed (`task pack:thps`) and the game relaunched before the level loads, or the placement shows a `Missing:` cube. * `noGrass`: the game’s grass manager raycasts onto the ground layer and otherwise grows through the Warehouse floor. * `grindRequiresSlide`: with a rail on every lip, the game’s own catch took the player into a grind the moment they rode over one, which is what made the lips impossible to skate (see “Grind gate” in [development.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/development.md)). A grind now starts only while the slide button is held, so an ollie off a lip keeps the player’s momentum; see “Coping rails on the half pipes and wall quarter pipes”. * `spawn` (`PLAYER_START`): the mod places the player at this position once, after the level’s edits are in the world (see “Custom-level startup cover” in [development.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/development.md)). `y` is the on-screen readout value, 1.925 above the feet. The spawn is (109, 38, -116), on the 26 degree slope of the start half pipe 4 units past the platform lip, so the player drops onto the pipe and slides forward; spawned nearer the lip, over its 72 degree face, the player caught the lip rail and stuck. * `bossSpawner`: the boss spawner is stood on the floor inside the hanging glass box above the main half pipe, world (170, 15.6, 27) (`BOSS_SPAWN_LOCAL`, `BOSS_SPAWN_Y`), hidden in plain sight the way the reference map has its secret room (see [thps-warehouse.md](/docs/thps-warehouse/), “Glass corridor and secret room”: the skater launches into it off the quarter pipe in front). It is scaled to 0.4 (`scale`) and turned 90 degrees so its arch faces across the floor, and its `BottomPiece` pillar mesh and the 43-unit-tall invisible `Collider` are hidden (`hiddenChildren` on the `bossSpawner` entry); that collider otherwise reaches down into the pipe and blocks it, and a visible collider in the box would block a grind. In the mesh the box is a closed room: an interior floor top at 15.64 (its slab’s underside at 15.31), a roof whose underside sits at 25.42, walls on all four sides between them, over a footprint of x 149.4-178.9, z 20.9-31.4 (`BOSS_PORTAL_FOOTPRINT`). * `bossRedirect`: keeps the boss spawn redirect on, which a from-scratch level otherwise skips (see “Boss spawn redirect” in [development.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/development.md)). The level no longer carries `bossSpawnPosition`, which the mod does not read. The redirect teleports each summoned boss to a random walkable spot at least 35 units from the spawner’s own position inside the box, keeping the portal, its arch and the Defeat the Boss wiring exactly where this level puts them. A spawner summons its boss at its own position and `EnemyMovementRb` chases with rigidbody physics on a Tiles map that has no navmesh, so a boss materialised inside or on top of the closed box can never get out. * The hidden mesh rails below, the only rails the level places. * The collectibles in the table above. Findings: * **Flat-tile prefabs are whole tiles.** `forest/stage0/stageTilePrefabs/flat/7` placed a flat slab with a ruined stone tower and blocks, not a crate. The palette thumbnail was misleading. Do not use the flat-tile entries as props. * **Rails.** `rail/0` places with a `PlaceObject` and honours rotation and uniform scale. The earlier hand-placed big, ledge and high rails stood in the old layout and were removed with it. * **Editor camera.** In editor mode the editor owns the camera, so the harness `camera` command has no effect there. For screenshots call `editor(on=false)`, `freeze()` and then `camera(...)`. * **Validate.** The editor’s Validate button produces no visible result in the toolbar or the log on this level, so reachability is checked by `ThpsLevelTests` instead. * **Save names.** The editor save writes to `UserData/Levels/.mblevel.json`; use a new name so no existing custom level is overwritten. ### Mesh rail near (75, 3, 28) [Section titled “Mesh rail near (75, 3, 28)”](#mesh-rail-near-75-3-28) The extracted Warehouse mesh carries its own rail: a thin flat bar at world x 75, height 3.17 to 3.44, running z 3 to 53 and then bending diagonally to about (81.5, 60.6). The extraction left it floating, with no posts and no `Rail` component, so it could not be ground. * **Bar kept.** The bar stays as extracted. A visible `rail/0` laid over it stacked on the bar, and `rail/1` is a sloped rail, so neither replaces it. * **Posts.** `ThpsPack.cs` (`AddRailPosts`, `RailPosts`) adds seven dark box posts under the bar, five along the straight run and two on the bend. The posts are part of the mesh and its collision. * **Grind.** `MESH_RAILS` in `scripts/build_thps_level.py` lays two hidden `interactable/rail/0` objects along the bar, one over the straight run (yaw 90, scale 1.786, centre z 28.13) and one over the bend (yaw 310.5, scale 0.352), at pivot heights -1.25 (run) and 2.41 (bend), the bar’s centre height of 3.3 minus 2.55 times the scale, so the game rail’s bar sits at the mesh bar’s height. They are placed with the `hidden` flag, described in `level-format.md`, so the game rail’s collider and `Rail` component stay live and nothing draws. * **Alignment.** Measured from top-down and side screenshots under `.tools/evidence/` (`rail_align_top2.png`, `rail0_top.png`, `rail0_side.png`, `rail_hidden_top.png`). * **Slide check.** Dropping the player onto the bar from above, at (75, 9, 30), carried it along the bar: samples at x 75.006 and 74.994 with z 47.8 then 57.9. The bend was not confirmed to carry the player round the corner. * **Handoff.** The game ends a grind by launching the player along the rail’s tangent, and its catch only sees a rail the player is already touching, so a rail meeting another at an angle drops the player at the joint. The bend and the run met at about 40 degrees: with the hidden rails touching, the run chained onto the bend but not back, and any gap between them broke one direction or the other. `RailHandoffPatch` (`rail_handoff_patch.cs`) postfixes `PlayerMovement.StopRail`: on a custom level, when a grind ran off either end of a rail, it starts the nearest other rail within `RailHandoff.SearchRadius` (3.5 units) that is not on cooldown. A jump off the middle of a rail does not hand off. The radius is that large because a game rail’s collider stops short of its spline by about 3.5% of its length at each end: the run’s collider spans z 4.90 to 51.37 against a spline from 3.13 to 53.13, so a player leaving the bend at the junction is about 2.2 units from it, outside both the game’s catch and a 2 unit search. That inset is the real cause of the failures above. * **Open.** The rail does not activate on its own; like the game’s other rails it needs the right approach and timing. Whether it needs a different approach angle, speed or jump timing to catch is not investigated. ### Mesh rails near (129, 27, 121) and (176, 8, 61) [Section titled “Mesh rails near (129, 27, 121) and (176, 8, 61)”](#mesh-rails-near-129-27-121-and-176-8-61) The Warehouse mesh has two more bars on the same material (`A53322CF`), handled the same way as the first: * **Wall rod, x 116.3 to 143.0 at z 120.8, height 27.3 to 27.55.** The rod the SKATEBOARDING banner hangs from, with a short bracket at each end running back to the wall (z 120.8 to 123.9). The brackets are real supports and are kept, so this rod gets a hidden game rail and no posts. * **Deck rail, z 47.9 to 74.7 at x 175.66, height 8.31 to 8.56.** Three posts (the two ends and the middle) in `RailPosts`, up from the floor to the bar. A hidden game rail lies along it. * **Hidden rails.** The third and fourth entries of `MESH_RAILS`: the wall rod at yaw 0, scale 0.954, centre (129.64, 24.997, 120.84), and the deck rail at yaw 90, scale 0.955, centre (175.66, 5.995, 61.295). Height is the bar’s centre height minus 2.55 times the scale (`RAIL0_BAR_HEIGHT`), since the game rail’s bar sits higher above its pivot the larger the scale. The first rail’s 4.55 offset only fits its 1.786 scale; applying it to these two put the grind about 2 units under the visible bar. Checked in the game: the grind sparks and the player sit on both visible bars. * **Floating leftovers.** Each post position on the first and deck rails carries a small plate (about 0.46 by 0.25) on the rail material, well below the bar (height 1.5 to 1.8 on the first rail, 5.7 to 6.1 on the deck rail). They are what is left of the original posts and float once the posts are missing. `RailLeftoverBounds` in `ThpsPack.cs` drops the rail-material faces inside two boxes around them; the bars are far above those boxes and are untouched. The wall rod has no such plates. * **Evidence.** `.tools/evidence/task-021.19-rail1-before.png` and `-after.png`, `-rail2-before.png`, `-rail3-after.png`. The thin diagonal line left beside the first rail’s middle post in the after shot is not on the rail material and was not removed. ### Coping rails on the half pipes and wall quarter pipes [Section titled “Coping rails on the half pipes and wall quarter pipes”](#coping-rails-on-the-half-pipes-and-wall-quarter-pipes) The yellow-and-black coping of the main half pipe near (142, -3, 32), and the yellow and grey-brown lips on the wall quarter pipes, each have a hidden game rail so they grind like the mesh rails above. `COPING_LIPS` in `scripts/build_thps_level.py` (`MAIN_PIPE_LIPS` plus `WALL_PIPE_LIPS`) lists the lips and `rail_along` turns each into a `rail/0` placement: the bar runs from start to end, pitched along a falling lip, with its centre on the lip. They are placed through `COPING_RAILS` with the `hidden` flag, the same way as the mesh rails. | Lip | From | To | Length | | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------ | | Main pipe, north | (119.78, 5.76, 8.59) | (178.95, 5.76, 8.59) | 59.2 | | Main pipe, south | (119.78, 5.76, 43.81) | (178.95, 5.76, 43.81) | 59.2 | | Main pipe, north west diagonal | (119.78, 5.76, 8.59) | (100.06, 0.5, 3.51) | 21.0 | | Main pipe, south west diagonal | (119.78, 5.76, 43.81) | (100.06, 0.5, 48.87) | 21.0 | | North wall tiers, height 8.56 | x 40.89 to 80.34, 100.06 to 119.78 and 139.5 to 178.95 | at z -27.1 | 39.5, 19.7, 39.4 | | North wall upper tier, height 16.63 | (40.89, 16.63, -70.03) | (80.34, 16.63, -70.03) | 39.5 | | South wall, height 8.56 | (21.17, 8.56, 101.06) | (64.48, 8.56, 101.06) | 43.3 | | South wall pocket and run | 64.48, 101.06 round the U-shaped pocket near (80, 8.56, 86), down to (96.19, 8.56, 120.63) | 10 segments, the tip as two chords | 4.7 to 19.6 each | | South wall to east wall | (96.19, 8.56, 120.63) | (162.92, 8.56, 120.63), then round to (175.66, 8.56, 108.04) and up to (175.66, 8.56, 79.77) | 66.7, then 3 curve segments and 28.3 | | Shorty’s ledge, west wall, height 3.79 | (40.89, 3.79, 3.51) to (40.89, 3.79, 8.11), then (25.77, 3.79, 26.19), (25.77, 3.79, 48.87) and (21.17, 3.79, 58.73) | 4 segments | 4.6, 23.6, 22.7, 10.9 | | Start half pipe platform, height 35.85 | (80.34, 35.85, -120.84) | (139.5, 35.85, -120.84) | 59.2 | * **Measuring.** `lip_runs` in `scripts/warehouse_surface.py` finds the edges where two faces fall away from each other (a ridge) and where a ramp meets a flat deck, with a grade of at most 0.3, merged into straight runs. It returns every lip above, and `tests/test_build_thps_level.py` checks each `COPING_LIPS` entry against it when the extraction is present. * **Start pipe.** The start half pipe’s mesh is double-sided, so each edge carries four faces; the finders drop duplicate faces (same opposite vertex and normal up to sign) before looking for two. The platform lip runs the width of the pipe, wall to wall, and its rail covers it edge to edge: dropped 1.4 units from the west wall the player caught the grind and rode it to x 138.86, 0.6 short of the east wall. A harness teleport onto it puts the player in the grind pose with sparks on the coping but gives no speed, so the player holds still; a grind along it was not exercised. Other lip runs in the start area, such as (80.34, 26.95, -58.02) to (103.35, 26.95, -58.02) and the 48.01 runs at z -65.75, are not coped. * **Ledge.** The Shorty’s ledge is a flat deck over a vertical face, so `lip_runs` skips it; `ledge_runs` in `scripts/warehouse_surface.py` finds the top edges of decks over a face at least 80 degrees steep, and `LEDGE_LIPS` is checked against it. The player leaves the grind at (43, 2, 4) at the kicker end and (22, 2, 60) at the wall end. Both ends touch a wall. The matching ledge edges on the east side, from (162.57, 3.79, 60.14) to (170.75, 3.79, 51.69) and (168.76, 3.79, 75.94), are not coped. * **Diagonals.** The west end of the main pipe is a hip: each lip bends down along a 21 unit diagonal to the floor corner, so `rail_along` pitches those rails and the grind chains onto them through `RailHandoffPatch`, the same long run and short bend as the mesh rail near (77, 2, 28). * **Pocket tip.** The lip turns about 174 degrees round the pocket’s rounded tip in four 2.5 unit mesh edges. Rails that short all sit within `RailHandoff.SearchRadius` of every joint, so the handoff bounced between them until all were on cooldown and dropped the player (the bail near (78, 3, 79)). The tip is two chords, (77.05, 8.56, 88.32) to (80.34, 8.56, 85.03) and on to (83.62, 8.56, 88.32), and `tests/test_build_thps_level.py` keeps every coping rail at least as long as the search radius. * **Handoff direction.** At a sharp joint the game started the next rail at its end with its direction multiplier at +1, so the grind stopped at once. `RailHandoffPatch` now sets `railDirectionMultiplier` after `StartRail` to run toward the far end (`RailHandoff.DirectionAwayFromNearestEnd`), and the log line for each handoff prints the progress and direction. Checked in game across the tip from both sides. * **Wall ends.** Each rail runs edge to edge along its lip, to the wall where a lip ends on one. A grind that reaches such an end keeps the rail’s speed, about 19.5, and reaches the wall anyway, so `RailWallEndPatch` (`rail_wall_end_patch.cs`) ends the grind short of it: a postfix on `PlayerMovement.RailMovement` that on a custom level raycasts along the horizontal velocity and calls `StopRail` once a wall comes within `RailWallEnd.DetachDistance` (5 units). The hit has to be a wall, meaning near vertical (`WallNormalMaxY` 0.3, which keeps floors and the 45 degree pipe faces rideable) and facing the player (`FacingDotMin` 0.7, so a rail bar or a wall seen edge on does not end the grind), and the player has to be moving at least `MinSpeed` (2). A rail other than the one being ground, within `RailHandoff.SearchRadius` and off cooldown, suppresses the detach and leaves the chain to `RailHandoffPatch`, which is what keeps the joints and the pocket tip intact. The detach caps the horizontal speed at `MaxDismountSpeed` (6) and adds `DismountHop` (5) upward; without the hop the player slides along the coping and the game catches the same rail again several times. The game does catch it once more about half a second later at low speed and its own `StopRail` ends that at the rail’s end, so the player comes to rest beside the wall rather than into it. The log line reads `RailWallEnd: ended the grind short of a wall at (, , ), progress

, speed kept `. Checked in game against the east wall on the main pipe south lip: a 57 unit run up from x 122 let go 4.83 short at the full grind speed of 19.5, with the player alive at full health. * **Not coped.** The two wooden kickers near (45, 3.3, 47) and (45, 3.3, 62), the taxi funbox plywood near (120 to 137, 2 to 5, 75 to 83), the three earlier mesh rails, and the vertical-faced tops at heights 45 to 51, which have no ramp face. * **Checked in game.** Dropped onto the main pipe lips, the north tier, the south wall and the pocket, the player grinds with sparks on the coping, and from the north lip the grind carries onto the west diagonal. The harness injects no gameplay input, so a drop only ever grinds west, toward a coping rail’s progress 0 end. An eastbound grind runs from a scratch copy of the level with the two main-pipe coping rails rotated 180 degrees (`.tools/evidence/thps-east-wall-scratch.mblevel.json`), which turns the same catch eastbound at the game’s own grind speed. Ollies off the lips, and grinding from a run up a pipe, still need real keyboard input. * **Slide-gated.** Because these rails sit on every lip, the level sets `grindRequiresSlide`, so a grind starts only while the slide button is held and riding over a coping keeps the player’s momentum (`GrindGate` and `GrindGatePatch`, see “Grind gate” in [development.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/development.md)). Checked in game on the main pipe’s north coping and the north wall tier with the harness holding the button in place of the keyboard. Released, the catch is refused and the player stands on the lip normally: `GrindGate: slide released, grind start blocked at (140.0, 7.9, 8.6) by the StartRail hook.` on the coping, `... blocked at (160.0, 11.1, -27.1) ...` on the tier (`.tools/evidence/grind-gate-live-blocked.png`). Held, the same drop starts a real moving grind: `GrindGate: slide held, grind started at (140.0, 7.9, 8.6) on the rail at (149.4, 5.4, 8.6).` on the coping, where the player travels to x 121, and `GrindGate: slide held, grind started at (160.0, 10.6, -27.1) on the rail at (159.2, 8.3, -27.1).` on the tier, where the run covers x 160 to 118.9 before the rail’s end (`grind-gate-tier-held.png`). Chaining asks the same question, held: `RailHandoff: moved onto rail at distance 0.00, progress 0.000, direction 1.0.` onto the west diagonal, which then runs to `progress 1.000`. The button is a hold rather than a toggle, so releasing it at any point comes the player off the rail: `GrindGate: slide released, the grind ends at (112.2, 5.7, 6.6), progress 0.397.` mid-diagonal, with momentum carrying them the rest of the way along the lip while the catch keeps refusing them (`... blocked at (102.5, 3.1, 4.1) by the StartRail hook.`) until the button goes down again, when that same rail is caught at once (`GrindGate: slide held, grind started at (102.5, 3.1, 4.1) on the rail at (110.0, 3.0, 6.1).`). Checked on the keyboard as well: holding slide catches the coping and letting go drops the player off the lip. While held, `GrindAssist` also catches a rail within 2 units of the player’s feet, so an approach that misses the bar still goes into a grind: dropped 1.6 units off that bar, `GrindAssist: caught the rail at (149.4, 5.4, 8.6) from 0.00 units at (150.0, 9.4, 10.2), progress 0.510.` That every blocked line names the `StartRail` hook answers what the dump could not: the game’s `Rail.FixedUpdate` catch calls `StartRail` without consulting `CanStartGrind`. As a control, the same level with the key stripped (`.tools/evidence/thps-no-gate-scratch.mblevel.json`) still grinds on contact: dropped at x 176 with the button on the real device and nobody near the keyboard, the player rode the whole north coping and on to the floor at (88.6, 2.4, 0.5) with no `GrindGate` line at all (`.tools/evidence/grind-gate-no-key-control.png`). ### Slope limit on the half pipes [Section titled “Slope limit on the half pipes”](#slope-limit-on-the-half-pipes) The player could not ollie off the main half pipe edges near (142, -3, 32). The cause is the movement limit, not the collision mesh: the pipe face passes 45 degrees about 3 units up its 8 unit rise, so above that the player is not grounded and cannot ride to the lip. The level file therefore sets `maxSlopeAngle` (`MAX_SLOPE_ANGLE` in `scripts/build_thps_level.py`, 88), which the mod applies to `PlayerMovement.maxSlopeAngle` for the run only (see `level-format.md`); Forest and every level without the key keep the game’s 45. Vertical faces (90 degrees) stay unclimbable, so walls still block. The mod logs `LiveEdit: max slope angle 45 -> 88` when it applies. Open: how the raised limit feels on the other ramps and whether a lower value rides the pipes as well. ### Check against the reference frames [Section titled “Check against the reference frames”](#check-against-the-reference-frames) The frames in `tools/thps_warehouse_frames/` come from the 2020 remake, so the textures and lighting differ from the PS1 mesh by design. Compared with frames 4, 12 and 138 and the items map: * Present: the walled room, the main half pipe and its hanging box, the banks, decks and ledges, the rail bars, the windows and the boss portal. * Roof: the pack adds a flat dark roof (see “Warehouse roof” in asset-bundles.md), so a view from above the room shows the roof and not the interior; the items map and frame 12 are cut-away or interior views. * Lighting: the level uses Forest’s fog and lighting, which tints the room teal and has none of the remake’s window light shafts. Surfaces read grey-green and dark. * Not compared: a pitch-45 south-east overview matching the items map, because of the roof. # Warehouse construction piece inventory This is the construction kit’s source partition, not its palette asset list. The builder maps source instances to reusable prefabs through `catalogAsset` in `pieces.json`; source IDs below are not necessarily loadable catalog IDs. The shipped `bundle:thps/Warehouse`, its assets and `levels/thps.mblevel.json` remain unchanged. See [asset-bundles.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/asset-bundles.md) for the piece prefabs, their pivots and the `pieces.json` sidecar, and the [reference](/docs/thps-warehouse/), [shipped layout](/docs/thps-warehouse-layout/) and [preservation check](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/development.md#preserving-the-shipped-warehouse). ## Source and addressing [Section titled “Source and addressing”](#source-and-addressing) Source: `.tools/thps1/warehouse/warehouse.obj`, from the PS1 extraction. Source SHA-256: `917aa268b447df4e7131cac0b55cbf0894e848789c86289daf845d25a562b25a` There are **252 object declarations, 200 nonempty objects and 1,345 polygon faces**, assigned to **214 named source pieces** below. A face number is the one-based ordinal of an `f` line across the whole file, before triangulation, coplanar separation or importer filtering. Ranges are inclusive; comma-separated ranges form one piece. Groups are the literal `o` hashes, not material hashes. Every source face has exactly one owner, including double-sided faces and suppressed post remnants. There is no catch-all remainder. A source row may yield multiple independently editable instances when it contains disconnected sign copies; `sourceId` records their common inventory owner. Module types describe reuse, not a second face assignment. Empty objects: `947969A2`, `0D703818`, `7A77088E`, `E4139D2D`, `9314ADBB`, `0A1DFC01`, `7D1ACC97`, `0108955F`, `9801C4E5`, `EF06F473`, `B31D819B`, `D0B9495C`, `2A14D021`, `49B018E6`, `BE5B0A8C`, `27525B36`, `50556BA0`, `CE31FE03`, `DFB35C91`, `46BA0D2B`, `6F12583B`, `954EED36`, `F61B0981`, `811C3917`, `1F78ACB4`, `687F9C22`, `1E575FE1`, `875E0E5B`, `370A0C19`, `AE035DA3`, `271B3BAD`, `D9046D35`, `BA664D03`, `CABEDA1B`, `4760F896`, `9F9D74DB`, `86B0FA50`, `0C47BC8C`, `EF5B1379`, `817474C9`, `27CBD007`, `187D2573`, `F673445F`, `6FD0D543`, `0DE57681`, `F28254C7`, `A114B35C`, `94EC273B`, `7B408C1A`, `E52419B9`, `F0593ECD`, `6E3DAB6E` Those 52 declarations have no geometry and need no palette placeholder. They are accounted for separately, not assigned invented faces. Run `uv run scripts/check_thps_piece_inventory.py` to verify the fingerprint, empty declarations, selectors, unique names/IDs and exact face coverage. The same check runs in pytest and fails rather than skips without the extraction. The Markdown tables are the machine-readable inventory; edit them rather than maintaining a competing manifest. ### Coordinates and preservation rules [Section titled “Coordinates and preservation rules”](#coordinates-and-preservation-rules) Measurements here are **Megabonk world units**, not the reference video’s estimated metres or its map headings. Transform raw OBJ `(x,y,z)` to `((x-1.519043)*79.2+100, -y*79.2+0.5, (z-1.9661865)*79.2)`. World north is +Z. Keep unrounded source coordinates/UVs in meshes; numbers below are rounded for reading, never snapping targets. Use one canonical pivot at the midpoint of a module’s lower riding edge, with local +Y up and +Z pointing from toe toward lip; deck/flat pivots are their walking-surface centres. Corner pivots are at the intersection of the extended lip lines at deck height. Wall/window pivots use bottom-centre of the panel. Reusable signs/props/kicker parts use canonical geometry frames; their full sidecar transforms, rather than a source location or yaw alone, define the placement. Roof sections retain the Warehouse axes and use their geometry centre. Store the inverse pivot transform so reassembly restores the original world vertices; standardized loose planks retain their fitted bounds/artwork rather than incidental source skew. These are kit conventions, not changes to the legacy mesh’s centre pivot. The start area contains paired state/double-sided geometry that the importer currently draws together. Rows naming two groups retain both, including nonidentical material variants; do not deduplicate them on the assumption that only one should be visible. Preserve global `CoplanarLayering` offsets before partitioning so posters and planks do not gain different offsets when their neighbours move. Triangulation, winding, UVs and transparency stay as in `ThpsPack.cs`. ## Source face inventory [Section titled “Source face inventory”](#source-face-inventory) ### Hanging glass box [Section titled “Hanging glass box”](#hanging-glass-box) The room occupies x 149.36–178.95, z 20.93–31.47. Its window objects are individual **window sections**, not every small pane painted in a texture. Each two-face window is the two sides of one section and moves as one piece. The east wall is part of the arena wall panel below, not an eighth glass window. | ID | Element | Groups | Faces | Module | | ---------------------------- | ------------------------------------ | -------- | ------- | ------- | | piece-room-wall-south | Glass box south wall frame | B22D8FBF | 71-82 | wall | | piece-room-wall-north | Glass box north wall frame | 5C23EE93 | 83-94 | wall | | piece-room-roof | Glass box roof slab | 4E3C5402 | 177-180 | roof | | piece-room-floor | Glass box floor slab and edge fascia | 4E3C5402 | 181-201 | deck | | piece-room-window-se | Glass box south east intact window | BB7DC2A2 | 202-203 | window | | piece-room-window-sw | Glass box south west broken window | BC1006BB | 204-205 | window | | piece-room-window-ne | Glass box north east broken window | 521E6797 | 206-207 | window | | piece-room-window-nw | Glass box north west intact window | CC7AF234 | 208-209 | window | | piece-room-underside-box | Glass box underside support box | 105A150D | 210-228 | support | | piece-room-wall-west | Glass box west wall frame | 2B24DE05 | 312-323 | wall | | piece-room-window-west | Glass box west broken window | 25195701 | 553-554 | window | | piece-room-chain-north | Glass box north suspension chain | 10500CBD | 121-124 | support | | piece-room-chain-south | Glass box south suspension chain | 89595D07 | 125-128 | support | | piece-room-brace-north-outer | Glass box north outer diagonal brace | 6BA45667 | 115-117 | support | | piece-room-brace-south-outer | Glass box south outer diagonal brace | 1CA366F1 | 118-120 | support | Floor top is 15.640, bottom 15.311; roof underside is 25.424, top 25.753. Keep each slab’s top, bottom and edge faces together. Intact room windows (`F2184063`) are collision-open only in the authored room bounds; broken windows (`FA88F30D`) are always collision-open. Their frames remain solid. Window pieces must carry that result locally, not keep testing the old world bounds after movement. No new breakable-window gameplay is implied. ### Main half pipe and hip ends [Section titled “Main half pipe and hip ends”](#main-half-pipe-and-hip-ends) | ID | Element | Groups | Faces | Module | | ------------------------- | ---------------------------------------------- | -------- | --------- | --------------- | | piece-main-hip-north | Main half pipe north west hip end | ED3C7CD2 | 229-231 | hip | | piece-main-hip-south | Main half pipe south west hip end | 9A3B4C44 | 232-234 | hip | | piece-main-hip-apron | Main half pipe tapered west sloping apron | 1D44E8A6 | 264-270 | hip-apron | | piece-main-bank-north-01 | Main half pipe north outer bank west strip | 1F4E66D8 | 330 | outer-bank | | piece-main-bank-north-02 | Main half pipe north outer bank middle strip | 1F4E66D8 | 331 | outer-bank | | piece-main-bank-north-03 | Main half pipe north outer bank east strip | 1F4E66D8 | 332 | outer-bank | | piece-main-bank-north-end | Main half pipe north outer bank hip end | 1F4E66D8 | 333 | bank-end | | piece-main-bank-south-01 | Main half pipe south outer bank west strip | 86473762 | 334 | outer-bank | | piece-main-bank-south-02 | Main half pipe south outer bank middle strip | 86473762 | 335 | outer-bank | | piece-main-bank-south-03 | Main half pipe south outer bank ledge join | 86473762 | 336-338 | outer-bank | | piece-main-bank-south-end | Main half pipe south outer bank hip end | 86473762 | 339 | bank-end | | piece-main-south-03 | Main half pipe south transition east section | 03321DFE | 1127-1131 | main-transition | | piece-main-north-01 | Main half pipe north transition west section | 0A8451D5 | 1132-1136 | main-transition | | piece-main-south-01 | Main half pipe south transition west section | 7358E971 | 1137-1141 | main-transition | | piece-main-south-02 | Main half pipe south transition middle section | EA51B8CB | 1142-1146 | main-transition | | piece-main-north-02 | Main half pipe north transition middle section | 9D56885D | 1147-1151 | main-transition | | piece-main-flat | Main half pipe rectangular flat bottom | 7D836143 | 1152-1154 | flat | | piece-main-north-03 | Main half pipe north transition east section | 74352D68 | 1155-1159 | main-transition | ### Wall quarter pipes, corners and decks [Section titled “Wall quarter pipes, corners and decks”](#wall-quarter-pipes-corners-and-decks) Deck faces are split from riding transitions even when the original object mixed them. Lip/coping strips stay with their transition; grind paths follow their owning transition, not the adjacent deck. Names use measured world location rather than uncertain remake wall names. | ID | Element | Groups | Faces | Module | | ------------------------------ | --------------------------------------------- | -------- | --------------- | --------------- | | piece-south-transition-end | South wall quarter pipe west end section | C02D64C7 | 272-276 | wall-transition | | piece-south-deck-end | South wall quarter pipe west end deck | C02D64C7 | 277 | deck | | piece-pocket-return | South pocket return corner transition | B72A5451 | 278-280,285-296 | corner | | piece-pocket-return-deck | South pocket return corner deck | B72A5451 | 281-284,297 | deck | | piece-pocket-tip-west | South pocket rounded tip west transition | 3055F0B3 | 1031-1040 | convex-tip | | piece-pocket-tip-west-deck | South pocket rounded tip west deck | 3055F0B3 | 1041-1042 | deck | | piece-pocket-side-west | South pocket west side transition | 4752C025 | 1043-1057 | wall-transition | | piece-pocket-side-west-deck | South pocket west side deck | 4752C025 | 1058-1062 | deck | | piece-pocket-tip-east | South pocket rounded tip east transition | A95CA109 | 1063-1072 | convex-tip | | piece-pocket-tip-east-deck | South pocket rounded tip east deck | A95CA109 | 1073-1074 | deck | | piece-pocket-side-east | South pocket east side transition | 373834AA | 1075-1079 | wall-transition | | piece-pocket-side-east-deck | South pocket east side deck | 373834AA | 1080-1081 | deck | | piece-south-transition-middle | South wall quarter pipe middle run | D9365586 | 1082-1086 | wall-transition | | piece-south-deck-middle | South wall quarter pipe middle deck | D9365586 | 1087 | deck | | piece-southeast-corner-deck | South east quarter pipe corner deck | AE316510 | 1088-1092 | deck | | piece-southeast-corner | South east quarter pipe corner transition | AE316510 | 1093-1107 | corner | | piece-north-transition-east | North wall quarter pipe east run | 5E49F164 | 1160-1169 | wall-transition | | piece-north-deck-east | North wall quarter pipe east deck | 5E49F164 | 1170-1171 | deck | | piece-north-transition-middle | North wall quarter pipe middle run | C740A0DE | 1172-1176 | wall-transition | | piece-north-deck-middle | North wall quarter pipe middle deck | C740A0DE | 1177 | deck | | piece-north-upper-transition | North wall upper tier transition | 2E2305EB | 1178-1187 | wall-transition | | piece-north-upper-deck | North wall upper tier deck | 2E2305EB | 1188-1189 | deck | | piece-north-transition-west-01 | North wall lower tier west transition | B0479048 | 1190-1194 | wall-transition | | piece-north-transition-west-02 | North wall lower tier east transition | B0479048 | 1195-1199 | wall-transition | | piece-north-tier-platform | North wall tier connecting platform | BAD88457 | 1200-1204 | deck | | piece-south-transition-east | South wall quarter pipe east straight run | 279549C0 | 1205-1209 | wall-transition | | piece-south-deck-east | South wall quarter pipe east straight deck | 279549C0 | 1210 | deck | | piece-east-transition | East wall quarter pipe straight run | 50927956 | 1212-1218 | wall-transition | | piece-east-deck | East wall quarter pipe straight deck | 50927956 | 1211 | deck | | piece-south-transition-west | South wall quarter pipe west straight run | DE5B919F | 1246-1250 | wall-transition | | piece-south-deck-west | South wall quarter pipe west straight deck | DE5B919F | 1251 | deck | | piece-south-transition-return | South wall quarter pipe pocket to middle run | 403F043C | 1252-1256 | wall-transition | | piece-south-deck-return | South wall quarter pipe pocket to middle deck | 403F043C | 1257 | deck | ### Start platform, roll-ins and upper enclosure [Section titled “Start platform, roll-ins and upper enclosure”](#start-platform-roll-ins-and-upper-enclosure) The two roll-ins are separate left/right modules. Start flat-bottom geometry belongs to the platform, not the long roll-in. The high rear quarter pipe has the same 8.06 rise as a wall pipe but starts at y 27.78. | ID | Element | Groups | Faces | Module | | ----------------------------- | -------------------------------------------- | ------------------ | --------------- | --------------- | | piece-start-fence-west | Start roll-in west lattice side panel | 9D14F7B5, 041DA60F | 1-23,48-70 | wall | | piece-start-gable | Start rear wall and overhead lintel assembly | EA147B7B, ADCB734A | 389-426,723-760 | wall | | piece-start-window-frame-west | Start window west frame | 602D8163, FE56F714 | 427-432,707-712 | wall | | piece-start-window-frame-east | Start window east frame | F924D0D9, 78C285BA | 433-442,713-722 | wall | | piece-start-transition-west | Start rear half pipe west transition | 5924357D, B2C3B10F | 443-447,681-685 | wall-transition | | piece-start-transition-east | Start rear half pipe east transition | 5924357D, B2C3B10F | 448-452,686-690 | wall-transition | | piece-start-rear-deck | Start rear half pipe high deck | 5924357D, B2C3B10F | 453-454,691-692 | deck | | piece-rollin-east | East entrance curved roll-in | 4AA01023, 25DF6D67 | 455-462,650-657 | roll-in | | piece-start-flat-east | Start half pipe east flat platform | 4AA01023, 25DF6D67 | 463-464,658-659 | flat | | piece-start-enclosure-east | Start east side wall panels | 9A7E8FF4, FD06E2F9 | 465-489,624-648 | wall | | piece-start-enclosure-west | Start west side wall panels | ED79BF62, 365A315C | 607-618,660-671 | wall | | piece-start-lamp | Start rear overhead lamp | 895344B7, 12633D46 | 619-623,676-680 | support | | piece-start-flat-west | Start half pipe west flat platform | EE83BEC2, 3DA720B5 | 694-697,773-776 | flat | | piece-rollin-west | West entrance curved roll-in | EE83BEC2, 3DA720B5 | 698-705,765-772 | roll-in | | piece-start-window-west | Start west individual window | 4F87E3FF | 761-762 | window | | piece-start-window-east | Start east individual window | D68EB245 | 763-764 | window | | piece-start-roof-east | Start east upper roof panel | 1ACB7E91 | 777-784 | roof | | piece-start-roof-west | Start west upper roof panel | 83C22F2B | 840-847 | roof | | piece-start-ceiling-middle | Start central flat ceiling panel | F4C51FBD | 848-854 | roof | | piece-start-ceiling-east | Start east flat ceiling panel | 6AA18A1E | 855-860 | roof | | piece-start-side-wall | Start roll-in lower west solid wall | AF25296F | 989-997 | wall | The two start windows are intact `F2184063` sections but collision-open at their starting location. The two frames still block. The source has neither remake door splinters nor a separate animated roll-up-door asset to expose. ### Arena floor, ledges, walls and structural supports [Section titled “Arena floor, ledges, walls and structural supports”](#arena-floor-ledges-walls-and-structural-supports) These are named spatial panels, not one uneditable shell. Wall panels include their textured brick/window bands; those painted bands are not separate glass objects. Deep skirts and subterranean details remain individually accounted for even though the kill floor makes them unreachable. | ID | Element | Groups | Faces | Module | | --------------------------- | ----------------------------------------------- | -------- | ------------------- | --------------- | | piece-west-ledge-skirt | West ledge lower skirt | A8556103 | 235-236 | ledge | | piece-west-foundation-north | West wall north foundation skirt | DA61E06B | 237-238 | wall | | piece-south-under-ramp | South quarter pipe below-floor transition strip | 1C7398A9 | 239-250 | wall-transition | | piece-west-foundation-end | West wall foundation end panel | 41C4AC04 | 251 | wall | | piece-west-pillar-lower | West pillar below-floor section | 57B36E8A | 252-263 | support | | piece-wall-northwest-bay | North west arena wall bay | 7470EED8 | 298-311 | wall | | piece-wall-southeast-bay | South east arena wall bay | 731D2AC1 | 324-329 | wall | | piece-wall-west-north | West arena wall north panel | 0377DE4E | 340-349 | wall | | piece-floor-west-middle | West floor panel with surface decals | AD183D24 | 350-360 | floor | | piece-floor-taxi-south | Taxi south floor panel | D4C48580 | 522-529 | floor | | piece-floor-kickers | Kicker bay floor panel | 4DCDD43A | 530-533 | floor | | piece-floor-northwest | North west floor panel | 3ACAE4AC | 534-535 | floor | | piece-floor-rollin-toe | Entrance roll-in toe floor panel | D3A94199 | 536-538 | floor | | piece-wall-pocket-back | South pocket rear wall panel | 041A1A57 | 539-547 | wall | | piece-wall-east-entrance | East wall entrance end panel | 93C8C3DF | 861-869 | wall | | piece-wall-east-north | East wall north of main half pipe | E4CFF349 | 870-875 | wall | | piece-wall-east-pipe | East wall behind main half pipe and box | 84087AAC | 876-900 | wall | | piece-wall-east-deck | East wall behind ledge and rail | F30F4A3A | 901-913 | wall | | piece-floor-pipe-south | Floor south of main half pipe | CDDFB4C1 | 914-918 | floor | | piece-wall-south-east | South wall east panel | 6A061B80 | 919-924 | wall | | piece-wall-south-middle | South wall middle panel | 1D012B16 | 925-930 | wall | | piece-wall-south-west | South wall west straight panel | 8365BEB5 | 931-936 | wall | | piece-wall-south-end | South west corner wall panel | F4628E23 | 937-949 | wall | | piece-wall-west-south | West wall south panel | 6D6BDF99 | 950-963 | wall | | piece-wall-north-tier-side | North tier west side wall | 1A6CEF0F | 964-974 | wall | | piece-wall-north-tier-end | North tier west rear wall | 8AD3F29E | 975-982 | wall | | piece-wall-north-tier-back | North tier upper back wall | FDD4C208 | 983-988 | wall | | piece-floor-rail-drain | Big rail platform and drain floor | 23D1D5ED | 998-1005 | floor | | piece-floor-rail-south | Big rail south floor panel | BDB5404E | 1006-1018 | floor | | piece-floor-rail-north | Big rail north floor panel | CAB270D8 | 1019-1023 | floor | | piece-floor-west-north | West ledge north floor panel | 53BB2162 | 1024-1028 | floor | | piece-floor-east-north | East entrance floor panel | 24BC11F4 | 1029-1030 | floor | | piece-south-foundation | South wall deep foundation skirt | A89BBCBC | 1108-1110 | wall | | piece-west-foundation-south | West wall south foundation skirt | 3192ED06 | 1111-1122 | wall | | piece-floor-taxi-north | Taxi north floor panel | DA1F0DB2 | 1219-1224 | floor | | piece-floor-east-drain | East ledge drain floor panel | A4AE710F | 1225-1232 | floor | | piece-floor-east-south | East south floor panel | 54D6E57B | 1233-1235,1240-1245 | floor | | piece-east-ramp-end | East quarter pipe ledge-height end cap | 54D6E57B | 1236-1239 | end-cap | | piece-ledge-west | Shorty’s west ledge top and front | 3999C81C | 1262-1269 | ledge | | piece-ledge-east | East deck low ledge top and front | A09099A6 | 1270-1284 | ledge | | piece-crane-hook | Suspended crane hook and chain | E2F52869 | 101-108 | support | | piece-truss-east-north-01 | East north ceiling diagonal brace | E56DBEAE | 109-111 | support | | piece-truss-east-north-02 | East north inner ceiling brace | 85AA374B | 112-114 | support | | piece-west-pillar-upper | West pillar above-floor section | 4B768210 | 143-158 | support | | piece-truss-south-01 | South ceiling brace west | EBB6369C | 159-161 | support | | piece-truss-south-02 | South ceiling brace east | 72BF6726 | 162-164 | support | | piece-truss-west-01 | West ceiling brace north | 82C7F352 | 165-167 | support | | piece-truss-west-02 | West ceiling brace middle north | F5C0C3C4 | 168-170 | support | | piece-truss-west-03 | West ceiling brace south | 6CC9927E | 171-173 | support | | piece-truss-west-04 | West ceiling brace middle south | 1BCEA2E8 | 174-176 | support | ### Rails, taxi, kickers and loose boards [Section titled “Rails, taxi, kickers and loose boards”](#rails-taxi-kickers-and-loose-boards) The big rail’s straight and bent bars are separate pieces, including both visible sides. Suppressed post plates get an explicit owner/disposition: they are archival post-remnant pieces, **excluded from the default assembly’s render and collision**, matching `RemoveRailLeftovers`. Do not accidentally restore them, or omit their accounting because they are hidden in the legacy mesh. They can be exposed as optional repair/debris pieces, not structural posts. | ID | Element | Groups | Faces | Module | | ------------------------ | ------------------------------------- | -------- | --------------- | --------------- | | piece-rail-sign | High sign rail rod and wall brackets | 63097BD6 | 25-47 | rail | | piece-kicker-south-frame | South kicker wooden frame | 51F9A12C | 129-132 | kicker | | piece-kicker-north-frame | North kicker wooden frame | C8F0F096 | 133-136 | kicker | | piece-rail-big-bend | Big rail bent bar and end cap | 8D071AFA | 363-365,373-375 | rail | | piece-rail-big-straight | Big rail straight bar and end cap | 8D071AFA | 366-372,376-382 | rail | | piece-post-remnant-01 | Big rail suppressed post plate 1 | E67C9BCD | 383 | suppressed-post | | piece-post-remnant-02 | Big rail suppressed post plate 2 | 0F1F3EF8 | 384 | suppressed-post | | piece-post-remnant-03 | Big rail suppressed post plate 3 | 96166F42 | 385 | suppressed-post | | piece-post-remnant-04 | Big rail suppressed post plate 4 | E1115FD4 | 386 | suppressed-post | | piece-post-remnant-05 | Big rail suppressed post plate 5 | 7F75CA77 | 387 | suppressed-post | | piece-post-remnant-06 | Big rail suppressed bend post plate | 0872FAE1 | 388 | suppressed-post | | piece-post-remnant-07 | Big rail suppressed post plate 7 | 917BAB5B | 490 | suppressed-post | | piece-rail-deck | East deck rail bar | 140E4B40 | 496-512 | rail | | piece-post-remnant-08 | Deck rail suppressed north post plate | 76C3865C | 513 | suppressed-post | | piece-post-remnant-09 | Deck rail suppressed south post plate | 01C4B6CA | 514 | suppressed-post | | piece-kicker-south-board | South kicker riding board | 81140CCD | 517-521 | kicker | | piece-board-taxi-01 | Taxi north loose board 1 | B79E1F26 | 548-552 | prop | | piece-board-taxi-02 | Taxi south loose board 2 | 59907E0A | 555-559 | prop | | piece-board-taxi-03 | Taxi west loose board 3 | B0F3DB3F | 560-564 | prop | | piece-board-taxi-04 | Taxi north west loose board 4 | 5EFDBA13 | 565-569 | prop | | piece-board-taxi-05 | Taxi south east loose board 5 | B9459714 | 570-574 | prop | | piece-board-floor-01 | East floor loose board 1 | D08B6AF6 | 575-578 | prop | | piece-board-floor-02 | East floor loose board 2 | 39E8CFC3 | 579-582 | prop | | piece-board-floor-03 | Big rail north loose board 3 | A0E19E79 | 583-586 | prop | | piece-board-floor-04 | Big rail east loose board 4 | 4759B37E | 587-590 | prop | | piece-board-floor-05 | Big rail west loose board 5 | 279E3A9B | 591-594 | prop | | piece-board-floor-06 | Big rail middle loose board 6 | C9905BB7 | 595-598 | prop | | piece-board-floor-07 | Big rail diagonal loose board 7 | BE976B21 | 599-602 | prop | | piece-board-floor-08 | Big rail west middle loose board 8 | CEFD9FAE | 603-606 | prop | | piece-taxi | Taxi funbox | B5300BF6 | 785-839 | prop | | piece-kicker-north-board | North kicker riding board | 181D5D77 | 1285-1289 | kicker | | piece-board-taxi-06 | Taxi east loose board 6 | C7F4EBA9 | 1290-1294 | prop | | piece-board-taxi-07 | Taxi north east loose board 7 | 29FA8A85 | 1295-1299 | prop | | piece-board-taxi-08 | Taxi south west loose board 8 | CE42A782 | 1300-1304 | prop | | piece-board-floor-09 | East south loose board 9 | 49823B4C | 1305-1308 | prop | | piece-board-floor-10 | East middle loose board 10 | A78C5A60 | 1309-1312 | prop | | piece-board-floor-11 | East north middle loose board 11 | 4EEFFF55 | 1313-1316 | prop | | piece-board-floor-12 | East diagonal loose board 12 | D7E6AEEF | 1317-1320 | prop | | piece-board-floor-13 | Big rail south loose board 13 | 305E83E8 | 1321-1324 | prop | | piece-board-floor-14 | Big rail centre loose board 14 | 50990A0D | 1325-1328 | prop | | piece-board-floor-15 | Big rail north east loose board 15 | 20F3FE82 | 1329-1332 | prop | | piece-board-floor-16 | Big rail north west loose board 16 | 57F4CE14 | 1333-1336 | prop | Taxi bounds are x 121.64–137.36, y 0.83–5.24, z 76.17–81.57. Its eight surrounding sloping boards are separate pieces, not welded into the car. Kicker frames and boards may be grouped for placement but remain independently editable; north board/frame meet near z 44.44–47.60, south near z 61.13–64.28, board tops y 3.30. ### Signs, posters and graffiti overlays [Section titled “Signs, posters and graffiti overlays”](#signs-posters-and-graffiti-overlays) These IDs identify source instances for face accounting. The palette uses deduplicated artwork/geometry assets; `pieces.json` maps each instance to its `catalogAsset` and placement transform. Identical artwork need not have an entry for each wall where it appeared. These are textured overlay faces, not new mesh lettering or remake props. Each row can move independently of its supporting wall. Paired start-area copies stay with the same sign. Materials identify appearance, while the source selector determines ownership. | ID | Element | Groups | Faces | Module | | ----------------------------- | ------------------------------ | ------------------ | --------------- | ------ | | piece-sign-entrance-middle | Middle entrance overhead sign | 912CB677 | 24 | sign | | piece-poster-northwest-01 | North west upper poster | 7C1384F6 | 95 | sign | | piece-poster-northwest-02 | North west tall poster | ECAC9967 | 96 | sign | | piece-graffiti-northwest | North west wall graffiti strip | 9BABA9F1 | 97 | sign | | piece-poster-northwest-03 | North west lower poster | FB6C2014 | 98 | sign | | piece-poster-rollin-east | East roll-in wall poster | 82B098B0 | 99 | sign | | piece-graffiti-east-entrance | East entrance graffiti strip | D04173D7 | 100 | sign | | piece-sign-south-high | South wall high sign | 7B7E40EF | 137 | sign | | piece-sign-west | West wall banner | 921DE5DA | 138 | sign | | piece-sign-northwest | North west wall banner | 0B14B460 | 139 | sign | | piece-graffiti-east-south | East south wall graffiti | 8C6B1082 | 140 | sign | | piece-poster-east-deck | East deck wall poster | 626571AE | 141 | sign | | piece-sign-east | East wall banner | 120F8521 | 142 | sign | | piece-sign-start-side | Start east side sign | 4948226D, 14E269BE | 271,649 | sign | | piece-sign-south-underfloor | South foundation banner | 6C8D5CED | 361 | sign | | piece-sign-start-middle | Start middle window sign | FCDA3430, C6365CE4 | 362,706 | sign | | piece-sign-start-rear | Start rear wall banner | A7464341, 592AC8B5 | 491-493,672-674 | sign | | piece-poster-start-rear | Start rear high poster | 3E4F12FB, DFBEBA1B | 494,675 | sign | | piece-graffiti-start | Start window graffiti | D72CB7CE, 09E75906 | 495,693 | sign | | piece-sign-skateboarding | SKATEBOARDING hanging sign | A02B8758 | 515-516 | sign | | piece-poster-foundation-south | West foundation south poster | 4695DD90 | 1123 | sign | | piece-poster-foundation-north | West foundation north poster | D8F14833 | 1124 | sign | | piece-poster-box-east | Main pipe east wall poster | 15624138 | 1125 | sign | | piece-poster-middle-pillar | Middle entrance pillar poster | F5B7A826 | 1126 | sign | | piece-poster-west-north | West ledge north poster | 3922D6E2 | 1258 | sign | | piece-poster-west-middle | West ledge middle poster | 4E25E674 | 1259 | sign | | piece-sign-east-entrance | East entrance wide sign | 92DC24F5 | 1260-1261 | sign | | piece-graffiti-south-west | South west wall graffiti | E51AD54C | 1337 | sign | | piece-graffiti-east-north | East north wall graffiti | 6508B5B7 | 1338-1339 | sign | | piece-graffiti-north-side | North side lower graffiti | 957021C3 | 1340 | sign | | piece-graffiti-pocket-back | South pocket back graffiti | 8B06D49B | 1341 | sign | | piece-banner-south-east | South east wall banner | FC01E40D | 1342 | sign | | piece-banner-south-west | South west wall banner | 0C797079 | 1343 | sign | | piece-banner-west | West ledge wall banner | DE9AFBE5 | 1344 | sign | | piece-banner-entrance-middle | Middle entrance wall banner | A99DCB73 | 1345 | sign | ## Ramp module profiles and mating contract [Section titled “Ramp module profiles and mating contract”](#ramp-module-profiles-and-mating-contract) Profiles are the original **piecewise-linear** riding surfaces, not circular quarter pipes fitted to a radius. Keep every profile vertex and texture seam. For the table below, `(run,rise)` starts at the toe; run points toward the lip. Width can vary independently only when both mating edges retain the same profile; uniform scaling is the safe initial palette transform. A reflected module needs reversed winding/normals, not merely a negative collider scale. | Module | Measured profile and height | Width and mating edges | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | main-transition | South profile `(0,0), (4.931,1.334), (8.334,3.364), (10.538,6.671), (11.312,10.054), (11.370,10.692)`; toe y -4.933, lip 5.759 | Six 19.722-wide strips across x 119.781–178.949. Like-side profiles butt together; north is a near mirror with small source differences, not an exact reflection. Toe joins flat at z 19.945 or 32.436; lip joins the outer bank at z 8.595 or 43.806 (no horizontal main-pipe deck). | | flat | Main bottom y -4.933, depth 12.491 (z 19.945–32.436) | Rectangular bottom joins both main toes. Start flats are separate at y 27.783, not interchangeable at the same elevation. | | hip-apron | Seven triangular sloping faces from x 100.058 at y 0.5 to the main west profile x 119.781 at y -4.933–0.5 | Joins rectangular flat and the lower parts of both hip transitions. It is not a flat-bottom module despite occupying the bottom of the tapered end. | | outer-bank / bank-end | Straight bank rises 5.259 over run 7.889, from floor 0.5 to lip 5.759 | Three strips on each side share the main transition lip; opposite toe meets arena floor. Two triangular west end pieces mate the hip end. South east strip is not rectangular: it meets the 3.787-high ledge. | | hip | West end tapers from lip y 5.759 at x 119.781 to floor y 0.5 at x 100.058; three original faces per side | East edge mates to full main profile; west edge closes onto floor. Diagonal lip ends at z 3.51/48.87, not the straight lip’s z. Keep both asymmetric end types. | | wall-transition | Typical `(0,0), (1.547,0.522), (4.466,1.972), (6.574,4.447), (7.425,7.077), (8.082,8.063)`; floor 0.5 to lip 8.563 | Straight widths usually 19.722, with longer/shorter source runs retained. Side mates use the full profile, toes meet floor, lips meet deck. Upper north tier has same rise from 8.563 to 16.626; start rear from 27.783 to 35.846. Slight source variations (including steel faces and a 0.019 vertex offset) must not be rounded away. | | corner | Same wall rise 8.063 swept through the three faceted sectors of a 90-degree concave turn | `piece-pocket-return` and `piece-southeast-corner` mate at both straight profile ends; their split decks fill the inside lip polygon. Rotate to reuse; do not replace with a smooth torus. | | convex-tip | Same wall rise, each tip half turns 90 degrees in two faceted sectors; full tip turns back 180 degrees | West/east tip halves meet at x 80.335, lip z 85.030; side lips continue at x 77.048/83.622, z 88.317. Mate side transitions at z 88.317. Coping uses two chords across four mesh edges, not four short grind rails. | | deck | Flat walking surface at the parent lip elevation; room slab thickness 0.329; wall decks generally 3.287 deep | Mate lip boundary without overlap; straight outer edge can mate another deck. Corner decks are polygonal, not bounding-box rectangles. Keep source fascia with its deck. | | roll-in | Toe `(z,y)` is `(-19.017,0.5)`, then `(-20.564,1.022), (-23.483,2.472), (-32.262,8.447), (-40.499,16.007), (-48.756,22.562), (-58.017,26.952), (-65.752,27.783), (-73.312,27.783)` | Two tapered entrance lanes, separated by the centre pillar/channel. Upper edge meets start flat y 27.783; lower edge meets floor y 0.5. Width changes toward the top: preserve the boundary polyline, not a rectangular substitute. | | kicker | Board rises from about 0.38 to 3.30 over about 10.06; frame from 0.5 to 3.19 | North/south boards face opposite directions and meet their frame at the original lip. Toe meets floor; side and rear edges are terminal, not quarter-pipe mates. | | ledge | West/east top y 3.787, floor 0.5; vertical front with shaped plan outline | Top may mate a deck at y 3.787. This is an end/curb module, not a rideable transition. Separate skirt retains its own below-floor faces. | | end-cap | East quarter pipe terminal face at z 79.770, y 3.710–8.563 | Vertical terminal face joins the 3.787-high east ledge and upper transition/deck boundary. It is not a riding face or a substitute for a full-height wall. | The below-floor strip at faces 239–250 is an archival transition/end strip (y -7.54–0), not an alternative floor-height wall pipe. Preserve it in the assembly; never use its bounds as the regular wall pipe’s profile. For joins, compare the **ordered boundary vertices** after placement, not just bounding boxes or nominal heights. Endpoints must coincide at source precision and adjacent riding normals follow the original faceting. The wall ends and hip ends above are the terminal modules; do not invent caps across open riding edges. Splitting additional width strips is permitted later only along existing face boundaries (or an explicitly recorded new cut with interpolated UVs). ## Importer and generator additions [Section titled “Importer and generator additions”](#importer-and-generator-additions) No source-face selector is possible for these: their source is the named code/config value below. Every enumerated instance needs an editable transform or setting, not a hidden consequence of the monolithic Warehouse placement. The separate [editable assembly](/docs/thps-warehouse-layout/#editable-construction-assembly) uses these geometry assets, snapshots local grind/support attachments and keeps the game placements and level settings below editable. Ownership does not prevent selecting an individual support or path; see [piece attachments](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/asset-bundles.md#piece-attachments). | Inventory ID / instances | Authoritative source | Editable form and ownership | | -------------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | added-roof-section-`ordinal` | `ThpsPack.AddRoof`, `WarehouseRoof.Quads`, spill-level ceiling and edge wall profiles | Consolidate emitted faces into larger 40-by-40-world-unit footprint sections, preserving every ceiling step, skirt, underside and inset rather than substituting a flat lid. Shared canonical panel assets are referenced through `pieces.json`; each placed section has its own collision and selection. The room/source roof rows above are separate. | | added-floor-patch-0 | `FloorPatches[0]`, `AddFloorPatches` | One floor patch, OBJ x 0.52–0.76, z 2.58–3.04, y -0.003 after Y flip; world x 20.876–39.884, z 48.614–85.046, y 0.2624. Keep fitted floor UVs and depth under decals. | | added-post-0 through added-post-6 | `RailPosts[0:7]`, `AddRailPosts` | Seven independent 0.5-wide box posts below big rail: (x,z) (75,4), (75,16), (75,28), (75,40), (75,52), (78.3,56.8), (81.4,60.5). Bottom y 0.5, top 3.17, five faces each. Associate with straight/bend bars without baking into them. | | added-post-7 through added-post-9 | `RailPosts[7:10]` | Three deck rail posts at (175.66,49.1), (175.66,61.3), (175.66,73.45), bottom 0.5, top 8.31. Same independent selection and collision as big rail posts. | | added-grind-mesh-0 through added-grind-mesh-3 | `MESH_RAILS`, `hidden_rail_edit` | Four hidden game rail paths: big straight, big bend, sign rod, deck rail. Attach local path/collider metadata to the matching visible rail; expose path editing/selection even when render-hidden. Transform and delete with the bar; no second visible game rail. | | added-grind-coping-0 through added-grind-coping-27 | Every ordered `COPING_LIPS` entry via `COPING_RAILS`/`rail_along` | 28 paths: indices 0–3 are main north/south and their west hips, 4–22 are the 19 wall/pocket lips, 23–26 the four west ledge lips, 27 the start lip. Keep exact source endpoints and two-chord pocket tip. A path spanning adjacent strips needs explicit ownership/subpaths without duplicate catch regions; splitting must preserve handoffs and avoid paths shorter than 3.5. Decks must not own a second copy. | | added-boss-portal | `boss_spawner()`, level `bossSpawner` | Editable boss portal at (170,15.6,27), scale 0.4, yaw 90; retain hidden `BottomPiece` and `Collider`. Select/move the portal independently of the room. | | added-boss-spawn | level `bossRedirect` | Boolean level setting that keeps the boss redirect on. The redirect picks its own walkable spot, so there is no destination marker; moving room/portal must not silently put the boss inside a closed room. | | added-radio | `radio_edits`, `RADIO_POSITION` | Ordinary `interactable/boombox` at (90,35.9,-123.5), separately movable/deletable. Playback track belongs to level music setting, not its geometry. | | added-pile-R1 through added-pile-R5 | Baddy nodes 43,107,109,111,113; `collectibles()` | Five separate `interactable/chest` placements. These are replacements for goal piles, not five extra source crate meshes. Keep final anchored/overridden transforms. | | added-letter-S, K, A, T, E | Letter nodes 28,29,17,13,12; `LETTER_SHRINES` | Five independently editable `forest/shrines/0..4` objects. T is the merchant prefab. Preserve upright rotation and deliberate overrides, not just node coordinates. | | added-tape | Node 16 | Separate `interactable/chestFree` inside the room. | | added-bonus-15, added-bonus-30, added-bonus-31 | `LANE_BONUS_NODES` | Three separately editable free chests. Nodes 148–150 (100/200/500 cluster) are intentionally not placed: no invented kit instances. | | added-player-start | `PLAYER_START`, level `spawn` | Editable start marker at (109,38,-116), where y is player centre, not feet. Marker saves the level setting independently of the portal. | | retained-spawn-portal | `PlayerSpawnPortal` | The game still creates its own portal; it is not in the file, so it is not separately selectable. Do not conflate its transform with `spawn`. | | retained-forest-particles | `ForestParticles` | Created by the game without a placement; not OBJ geometry and not in the file. | | added-terrain-bootstrap | `authored_grid`, `FLOOR_CELL` | One authored tile (10,7), height -2, slope none, on the 15x15 stock grid. Expose normal tile edits; the game needs it to derive its catch floor. | | added-clutter-removals | none | A from-scratch level generates no clutter, so there is nothing to remove. | | settings-gameplay | `killFloorY=-35`, `maxSlopeAngle=88`, `grindRequiresSlide=true`, `noGrass=true` | Authored level controls, editable and saved once per level. Do not bake them into individual ramp assets. | | settings-music | `music=bundle:thps/04_Superman` | Editable track reference, independent of boombox transform; soundtrack bytes stay baselined. | | settings-presentation | `title`, `description`, `mapDescription`, `allowedCharacters` | Editable level metadata; initial values come from `build_level()` (Calcium/Tony McZoom restriction). | | settings-generation | `map=1`, `stageIndex=0`, `tier=0`, `packs` | Editable kit and pack dependencies. `baseline: none`, so nothing is generated from a seed. | | settings-format | `schemaVersion`, `gameBuildId` | Serialization/compatibility metadata, preserved by save/load rather than represented as scene objects. | Roof generation must run against the same pre-partition geometry and post repairs as the legacy importer. Re-running a spill calculation separately on each movable piece would change its output and is not equivalent. Store the baked roof panel transforms; do not rebuild it implicitly when a ramp moves. ## Landmark cross-check [Section titled “Landmark cross-check”](#landmark-cross-check) This checks the inventory against the structures and M1–M15 map labels in [the reference](/docs/thps-warehouse/#structures). The reference mixes remake imagery with a PS1 extraction: an unmatched remake detail is not evidence for adding geometry to the preserved PS1 assembly. | Reference landmark / structure | Inventory coverage and limits | | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | M1 winding bank, M10 south notch | South wall transitions, pocket side/tip/return modules and split decks. Their world-space positions, not uncertain map headings, define placement. | | M2 west platform / ledges | West Shorty’s ledge, its skirt, adjacent floor, deck rail/sign rail where measured; five chest replacements remain separate. The remake/map’s rail names do not prove a different source ownership. | | M3 taxi | `piece-taxi` plus eight surrounding loose boards; distinct from the wall ramps. | | M4 half pipe | Six transition strips, rectangular flat bottom, sloping tapered apron, six outer bank strips and their two end pieces, and two hip ends; four authored main/diagonal coping paths. | | M5 glass corridor / secret room | Three wall frames, arena east wall, five individual window sections, floor and roof slabs, underside support and suspension pieces; tape, boss portal and destination separate. | | M6 north bank, M7 middle quarter pipe/pillar, M8 east channel | Three lower north runs, upper tier, two independent roll-ins, centre/rear lintel assembly, side panels and window frames. The reference’s exact map-to-world headings remain uncertain. | | M9 east deck / back pipe | Tier platform, south/east deck/transition rows and the east ledge-height end cap cover the extracted decks and back pipes; do not substitute remake measurements for the source placement. | | M11 south-east ledge | East low ledge with top/front faces, drain floor and adjacent arena wall. | | M12 kickers | Two frames and two opposite riding boards, not one combined kicker pair. | | M13 dark pit | Reference identification is unresolved. Extracted underfloor skirts, below-floor transition and drain panels are all assigned; no fabricated pit prop is needed to close coverage. | | M14 raised start block / two entrances | High rear transition and deck, start flats, left/right roll-ins, windows, lattice/solid walls and upper roof/ceiling panels. | | M15 big rail | Straight bar, bent bar, seven added posts, suppressed old plates, floor/drain, loose boards and two hidden grind paths. | | High rail / SKATEBOARDING sign | Sign rod with original brackets, hanging sign faces and one hidden grind path; no added posts under the wall rod. | | Deck rail | East bar, three added posts, two suppressed old plates and one hidden grind path. | | Box piles / letters / tape / bonus icons | Fourteen actual game placements from final generator transforms; extracted goal-pile nodes are not wooden crate geometry. | | Window wall / brick and graffiti walls | Spatial wall panels, textured window bands and independently assigned sign/poster/graffiti overlays. Source transparency is retained. | | Ceiling / trusses / lamp / crane | Source upper roof/ceiling panels, ten ceiling/room diagonal braces, chains, crane hook, lamp, pillar sections and added spill roof panels. | | Remake cones, barricades, truck, door splinters, sunlight shafts | No separate source objects or generator placements identified. Do not invent instances from video-only details; the source partition still covers every face. | This is a source/measurement audit. The kit’s palette listing, search, thumbnails, placement at world scale, selection, rotation, drag-move, removal and undo/redo were checked in the running editor through the dev harness for a prop, a window and an added piece. Attachment checks cover owner transforms, independent support edits, deletion/undo, isolated copies and saved replay. The three main north transition modules hand off grinds across their split coping paths; the relocated window remains collision-open while its frame stays solid. Visual equivalence and joint collision throughout a complete assembled level remain open. The preservation check guards the shipped Warehouse throughout. # Thunderstore packaging and releases The public product is **Megabonk Level Editor**; its package identifier is `Megabonk_Level_Editor`. The repository remains `megabonk_mods`, the assembly is `MegabonkLevels.Mod.dll` and the stable BepInEx GUID is `megabonklevels.runtime`. The team namespace is configured in the root `thunderstore.toml`. ## Package source and build [Section titled “Package source and build”](#package-source-and-build) `pkg/` contains the package’s player-facing README, reference manifest, icon and NLayer license. The root README describes the repository, not the package page. The 256×256 icon is a rasterization of `site/public/favicon.svg`; regenerate it with `rsvg-convert -w 256 -h 256 -o pkg/icon.png site/public/favicon.svg`. From the repository root: ```sh task package:thunderstore task package:thunderstore:check ``` The build installs tcli 0.2.4 locally in `.tools/tcli/` if missing, builds the Release DLLs (with Task’s `.env` loading), runs package tests, invokes `tcli build` and validates the resulting `.tools/thunderstore/-Megabonk_Level_Editor-.zip`. It never publishes. After CLI installation and a Release build, `.tools/tcli/tcli build` also builds the ZIP directly. tcli generates the ZIP’s `manifest.json` from `thunderstore.toml`; `pkg/manifest.json` is a reviewable reference, checked against that generated manifest. tcli adds `namespace` and `FullName` fields; the ZIP checker permits those only with the configured identity. Update both metadata sources and the BepInPlugin display version together for each release. The ZIP has exactly these files, with no enclosing directory: ```text manifest.json README.md icon.png BepInEx/plugins/MegabonkLevels/MegabonkLevels.Mod.dll BepInEx/plugins/MegabonkLevels/NLayer.dll BepInEx/plugins/MegabonkLevels/NLayer.LICENSE.txt ``` The dependency is `BepInEx-BepInExPack_IL2CPP-6.0.788`. The package does not redistribute BepInEx, its .NET runtime, Harmony, generated game/Unity interop, game assets, PDBs, installers or developer test data. The ZIP checker, `scripts/check_thunderstore_package.py`, rejects unexpected files and DLLs that differ from the current Release output. The older `package:windows`, `package:linux` and `package:macos` tasks describe the legacy MelonLoader installer format and are internal: Task hides them from listings and rejects direct CLI invocation. Do not expose a wrapper that combines them with this BepInEx DLL. Rebuilding legacy packages requires a separately retained legacy checkout, not the public BepInEx source history. ## Shared release version [Section titled “Shared release version”](#shared-release-version) `release-please-config.json` defines one root release, with Git tags such as `v0.1.0`. The Python release strategy updates `pyproject.toml`; targeted `extra-files` keep the other product-version fields synchronized: * the root project entry in `uv.lock`, selected by name rather than array index * the mod project’s `` and the BepInPlugin display version * Thunderstore’s `versionNumber` and the reference manifest’s `version_number` * the site’s `package.json` and both root version fields in `package-lock.json` The Go installer has no embedded product-version field: its source follows the same Git tag. Do not change the `go` toolchain directive to a product version. Dependencies, game builds, level/pack schemas and the standalone boot spike have their own versions and are not part of this synchronization. Keep the plugin’s inline `x-release-please-version` annotation; the generic updater uses it to change only that line. The `uv.lock` selector accepts both plain names and the tagged-value objects used by Release Please’s TOML parser, without updating third-party package versions. Two JSON updaters target the npm lockfile’s top-level version and `packages[""].version`; no dependency entries are selected. Version examples in player documentation use placeholders so they do not become stale on the next release. ### First release and subsequent bumps [Section titled “First release and subsequent bumps”](#first-release-and-subsequent-bumps) `.release-please-manifest.json` is Release Please’s current/last-released version ledger, not the requested next version. It is bootstrapped at `0.1.0`; the temporary `"release-as": "0.1.0"` override ensures the first release PR targets exactly that version instead of deriving a higher one from existing commits. Remove the override after the first release PR is merged and the GitHub release exists. Otherwise later runs remain pinned at `0.1.0`. The option is supported but deprecated upstream; use it only for this bootstrap, not normal releases. After that, let conventional commits and Release Please advance the ledger and all version files together. Do not independently release the website or add separate language packages to the manifest unless their release lifecycles are intentionally being split. `.github/workflows/release-please.yml` runs on pushes to `main`. It reads the repository Actions secret `RELEASE_PLEASE_TOKEN` with contents and pull-request write permissions. The optional placeholder in `.env.example` is for local tooling only: Actions does not read `.env`. Keep the real token out of Git, command arguments and logs. This workflow creates release PRs and GitHub releases; it does **not** publish to Thunderstore, npm, NuGet or PyPI, build a player package, or deploy the website. A push that adds the workflow can start release automation, so review the configuration before pushing. Check current version consistency with: ```sh uv run pytest tests/test_release_versions.py -n 0 ``` For the optional offline future-bump integration test, install the upstream updaters locally first; this does not contact GitHub or create a release: ```sh npm install --prefix .tools/release-please --no-save --ignore-scripts release-please@17.11.2 uv run pytest tests/test_release_versions.py -n 0 ``` The integration test applies Release Please’s actual updaters and composite update handling to temporary copies at a different version, then checks that all product fields moved and dependency/toolchain versions did not. ### Dependency updates [Section titled “Dependency updates”](#dependency-updates) `.github/renovate.json5` groups npm, NuGet, Go modules and Python dependencies. Python uses `pep621` for `pyproject.toml`/`uv.lock` and an explicit `pep723` file pattern for inline script metadata under `scripts/`. There is no Cargo project, so no Rust rule is configured. Other default Renovate managers are not disabled. The schedule permits new update branches during **Saturday 10–11 AM America/Chicago**, including lockfile maintenance. It is an allowed window, not a guarantee that the bot will run at exactly 10:00. Validate without running dependency updates: ```sh npx --yes --package renovate@44.222.5 -- renovate-config-validator --strict .github/renovate.json5 ``` ## Validate a manifest offline [Section titled “Validate a manifest offline”](#validate-a-manifest-offline) `scripts/validate_thunderstore_manifest.py` reimplements [the site’s Manifest Validator](https://thunderstore.io/tools/manifest-v1-validator/), which needs a logged-in account and a team you belong to. It ports `ManifestV1Serializer` and the Django 4.2 / DRF validators behind it, including their message text, so a line printed here matches the line the site prints. ```sh uv run scripts/validate_thunderstore_manifest.py # pkg/manifest.json uv run scripts/validate_thunderstore_manifest.py --online # plus thunderstore.io uv run scripts/validate_thunderstore_manifest.py .tools/thunderstore/-.zip task package:thunderstore:manifest # same, via Task task package:thunderstore:manifest ONLINE=true ``` It takes a `manifest.json` or a built package ZIP, and the team defaults to `thunderstore.toml`’s `[package].namespace`; `--team` picks another, `--json` emits machine-readable output. Exit 1 means the manifest would be rejected, exit 2 means the input could not be read. `task package:thunderstore` runs it on `pkg/manifest.json` before building, and `task package:thunderstore:check` runs it on the built ZIP. Offline it checks everything the serializer can decide from the document alone: required fields, the name and version patterns and length caps, a version that is not written in its canonical form, the URL rules, dependency strings, self-dependency, duplicate dependencies, the dependency and installer list caps, installer identifier shape and reuse, and the team name’s shape. The four checks that read the site’s database run only with `--online`, and only once the offline rules pass – as on the site, where they are serializer-level checks that valid fields must reach: * each dependency resolves to a published package version * this `namespace-name-version` is not already published * no package exists with the same name in a different capitalization * installer identifiers exist, which stays a note because that table is not public `--online` uses the public read-only JSON API through `scripts/asset_download.py`, so no token is involved: `/api/experimental/package////` and `/api/cyberstorm/listing///` for the team’s package list. The `api/v1/package/...` detail paths return 404 for the same package, so those two are deliberate. An unreachable host is a note, not a failure. Behaviours worth knowing before debugging a disagreement with the site: * Extra keys are ignored, which is why tcli’s `namespace` and `FullName` are harmless. * `PackageReference.parse` takes the version and then the name from the right, so `Dash-Ed-Team-Mythic-TestMod-1.1.0` is one reference and a version with two dots is mandatory. * A bare `mailto:` address is rejected while `mailto://anything` passes, and `ipfs://` needs something that looks like a host: `ipfs://CID` fails. ## Content and licensing [Section titled “Content and licensing”](#content-and-licensing) The Thunderstore package is runtime-only: **no levels, third-party packs or music** are included. In particular, extracted THPS assets and music must not be published with it. Screenshots in the README are publicly hosted links, not bundled game assets. NLayer 1.16.0 is redistributed under MIT with its upstream license notice preserved in `pkg/NLayer.LICENSE.txt`. The repository currently has no project-wide license file. Packaging does not choose a code/content license on the maintainer’s behalf. Confirm the intended release terms and rights to any newly added content before first publication; adding a level or pack is a separate licensing and manager-layout review. ## Test the final ZIP, not the build folder [Section titled “Test the final ZIP, not the build folder”](#test-the-final-zip-not-the-build-folder) 1. Quit the game. Back up the manager’s settings/profile metadata and inventory the affected game files. Preserve the existing profile and manual development DLLs outside the manager’s sync tree. 2. Create a **new Megabonk profile** with only the declared BepInEx dependency. Do not reuse a development profile or another plugin’s configuration files. 3. Import the built ZIP with the manager’s local/custom mod import, then Apply. Do not copy the plugin DLL manually as a substitute for this test. 4. Check that the installed plugin and NLayer hashes match the ZIP, and that only one copy of `megabonklevels.runtime` can load. The manager may wrap or flatten the package’s plugin subfolder; inspect the actual installed paths. 5. Launch and check `BepInEx/LogOutput.log` for `Loading [Megabonk Level Editor ]`, `MegabonkLevels mod initialized.` and `Chainloader startup complete`. Check errors associated with this plugin, not just the chainloader’s own `ErrorLog.log`. 6. Confirm the console opens and a separately supplied, pack-free level appears on Map Selection and plays. Enter the editor, make and undo an edit, and save a copy. No mod-specific BepInEx config is expected; developer tools require `MEGABONK_DEV=true` in the launch environment. 7. Review `pkg/README.md` using Thunderstore’s [Markdown Preview](https://thunderstore.io/tools/markdown-preview/), including both externally hosted screenshots. Validate the generated manifest with `task package:thunderstore:manifest ONLINE=true`, and confirm it with the [Manifest Validator](https://thunderstore.io/tools/manifest-v1-validator/), which is the final word. The screenshots use the public showcase site, not private-repository raw URLs. Recheck the editor image URL after a website rebuild changes its asset hash. 8. Record the ZIP SHA-256, profile, installed hashes, log evidence and screenshots. Rebuild and repeat the import test if any packaged file changes afterward. On the tested macOS setup, r2modmac 1.1.0 manages files but does not successfully launch the Windows game through plain Wine. Use the existing Wine launch path. Its Apply omits the loader pack’s `dotnet/`, `.doorstop_version` and `changelog.txt`; supply these from the dependency ZIP, not from this mod package. Set the prefix’s `winhttp=native,builtin` override (the repo launcher passes it in the environment). Park the MelonLoader `version.dll`; both proxies must not be active together. See [Running BepInEx under Wine](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/development.md#running-bepinex-under-wine). For an isolated test game outside the Wine prefix, inspect the manager-written `doorstop_config.ini` before launching. r2modmac may write a host path such as `\Users\...` without a Windows drive letter. Back up that file, then use paths relative to the game folder instead: ```ini [General] target_assembly=BepInEx\core\BepInEx.Unity.IL2CPP.dll [UnityMono] dll_search_path_override=BepInEx\core ``` Keep the existing sections and other settings, including the relative `dotnet\coreclr.dll` runtime path. Re-Apply may overwrite these changes. Launch with the game folder as the working directory and the **test copy’s executable** explicitly; the repo’s current plain-Wine launcher still names the default Steam executable even when `MEGABONK_GAME_DIR` changes its working/log directory. Use an explicit `Z:\...\Megabonk.exe` path for a host-side test copy, and point `MEGABONK_GAME_DIR` at that same folder for harness commands. Do not mistake a successful launch of the development install for a manager-install test. The manager’s sync is bidirectional: deleting installed mod files can also remove them from its profile at the next sync. Restore backups with the manager stopped; do not delete an installed tree to simulate disabling a mod. With r2modmac 1.1.0, local ZIP imports appear as `Local-Megabonk_Level_Editor-`. Apply preserves the plugin’s nested folder: `BepInEx/plugins/Local-Megabonk_Level_Editor-/MegabonkLevels/` contains the DLL, NLayer and its license. That layout loads correctly; do not flatten it by hand. A clean-profile install was verified under Wine 11.18 with the omitted runtime supplied from the dependency ZIP and relative Doorstop paths. Console, pack-free level loading/gameplay, terrain editing, undo and save-copy all work. ## Release gate and publishing [Section titled “Release gate and publishing”](#release-gate-and-publishing) Before the first publish: * Use the BepInEx runtime on main. Legacy MelonLoader rollback source lives only in the maintainer’s private history backup; it is not a public download or tag. Preserve local loader backups when testing a cutover or rollback. * Confirm the team’s service account has package-upload permission. Read [Global Rules](https://wiki.thunderstore.io/moderation/global-rules) and any Megabonk-specific rules. Do not distribute game assemblies or unlicensed assets. * Confirm all linked documentation and feedback destinations are publicly accessible. A private GitHub repository’s source/docs/issues links do not work for players without access; make them public or replace them before release. * Complete the clean-profile ZIP test, manifest validation and README preview. Record rights/licensing decisions and the exact tested archive hash. * Confirm community `megabonk`, categories `mods` and `tools`, and NSFW **false** for every upload. The category API currently exposes no AI Generated category; check again before uploading and apply it if it becomes available and applies. * Obtain explicit publication approval. Building a package is not approval to upload it or to merge the cutover. Create a **team service account**, not a personal token, at Thunderstore’s team settings. Supply its token only through `TCLI_AUTH_TOKEN` in the shell environment or a gitignored local environment file. Never put it in TOML, command arguments, task notes or logs. After approval, validate and publish the already tested ZIP: ```sh task package:thunderstore:check .tools/tcli/tcli publish --file .tools/thunderstore/-Megabonk_Level_Editor-.zip ``` Do not use bare `tcli publish`: it rebuilds by default and could upload a ZIP that was not tested. Recheck the stored SHA-256 immediately before the upload. After publishing, verify the page’s title, icon, screenshots, dependency, category and NSFW settings, then install the published version in another clean profile. That last download check cannot be performed before publication. Package versions are immutable. Updates require a higher version with the same team/name identity. Retain superseded releases; after a soak period and approval, **deprecate, never delete** an obsolete package. Do not automatically deprecate anything during the initial upload. ## Rollback [Section titled “Rollback”](#rollback) Quit the game and disable the package in the manager; Apply and verify the plugin is no longer in the active plugin tree. Keep user-authored `UserData/` files. For a loader rollback, park `winhttp.dll` and `doorstop_config.ini`, restore the preserved MelonLoader `version.dll`, and deploy the DLL from the final legacy tag, not this BepInEx package. Leave inactive loader trees intact. Reverse the proxy renames to return to BepInEx. Restore the original manager profile/settings and manual development artifacts from the pre-test inventory if required. # Megabonk Level Editor wiki > Install the mod, play custom levels and explore level-building references. Guides for the unofficial Megabonk Level Editor. Start with installation, then try a custom level or explore how the Warehouse is built. The public Thunderstore release has not been published yet. The current mod uses **BepInEx 6 IL2CPP** and the **Windows build** of Megabonk, including through Proton or Wine. ## Start playing [Section titled “Start playing”](#start-playing) * [Player guide](/docs/player-guide/) — install the mod, add levels and share them. * [Bramblecrown Hill](/docs/starter-level/) — an original starter level and a first-edit tutorial. * [Music](/docs/music/) — custom tracks and level music. ## Explore the Warehouse [Section titled “Explore the Warehouse”](#explore-the-warehouse) * [Warehouse reference](/docs/thps-warehouse/) — the source map and its structures. * [Warehouse layout](/docs/thps-warehouse-layout/) — how the level is assembled in Megabonk. * [Construction pieces](/docs/thps-warehouse-pieces/) — the editable kit and its mating rules. ## Packages and compatibility [Section titled “Packages and compatibility”](#packages-and-compatibility) * [Thunderstore packaging](/docs/thunderstore/) — package contents, releases and loader rollback. * [Legacy player installer](/docs/player-installer/) — the older MelonLoader format, **not compatible with the current BepInEx DLL**. ## Take the docs with you [Section titled “Take the docs with you”](#take-the-docs-with-you) Every page has **Copy page** and **View as Markdown** controls. Use [llms.txt](/llms.txt) for the index or [llms-full.txt](/llms-full.txt) for all published guides. Technical references outside this wiki link to the [source repository](https://github.com/pythoninthegrass/megabonk_mods/tree/main/docs). Return to the [showcase](https://megabonkmod.party/) to see the editor, console and MCP tools.