Since mid-August Iβve been spending most of my free time on a reimplementation of SCP: Containment Breach in Rust with Bevy. The original game dates from 2012, itβs written in Blitz3D, and its source code is public. 816 commits as I write this, from August 17 to September 2, 2026.
Let me say it up front. This is a fan project, derived from someone elseβs work. Joonas βRegalisβ Rikkonenβs game is under CC BY-SA 3.0, and so is the SCP Foundation, so my whole repository is too, code and assets included. Iβm not redrawing anything. The models, textures and sounds come from the original game, all I do is convert them from one container to another.
Not a line-by-line port
I set myself a rule on day one. The Blitz3D source is a behavioural reference, not a template to copy. I read the .bb to find out what the game does, then I write my own version with my own architecture.
What runs today is a first-person controller, seeded procedural generation of the facility, doors with their buttons, keycards and elevators, room streaming, and SCP-173. The workspace fits in five crates: scpcb (the binary), scpcb_assets, scpcb_gltf, scpcb_xfile, plus a helper. Alongside them, a dozen or so offline CLI tools. Bevy 0.19, avian3d 0.7, Rust 2024 edition.
But most of the time went somewhere else entirely, into getting the 2012 asset formats to talk.
The RMesh format
Rooms are stored in a homegrown format, .rmesh, and no specification for it exists. The only authority is the gameβs own reader function. I pulled the format out of the source by hand, then checked it byte by byte against the 113 shipped files.
Itβs a forward-only stream, with no version, no length table and no chunk framing:
βββββββββββββββββββββββββββββββββββββββββββββββ
β Header magic String β
βββββββββββββββββββββββββββββββββββββββββββββββ€
β Visible surfaces i32 + N β
βββββββββββββββββββββββββββββββββββββββββββββββ€
β Invisible collision surfaces i32 + N β
βββββββββββββββββββββββββββββββββββββββββββββββ€
β Trigger boxes (only if the magic is the β
β HasTriggerBox variant) β
βββββββββββββββββββββββββββββββββββββββββββββββ€
β Point entities i32 + N β
βββββββββββββββββββββββββββββββββββββββββββββββ€
β Trailer: String "EOF" (never read) β
βββββββββββββββββββββββββββββββββββββββββββββββ
You donβt guess a format like this, you measure it. A few findings that would have silently broken a naive parser:
- 112 of the 113 files parse. The last one,
room2.rmesh, is zero bytes, an orphan the original game doesnβt load either. My loader rejects it with a typed error instead of panicking. - One surface goes up to 13,864 vertices. Thatβs past
u16, so the indices have to beu32. - Every surface has exactly two texture slots, each preceded by a flag. Except that 217 slots have a non-zero flag and a zero-length name. Thatβs the real encoding of βthis slot is unusedβ. Trust the flag alone and you go hunting for a texture that doesnβt exist.
And then the big one. The file is in left-handed space (Blitz3Dβs) while Bevy is right-handed. The conversion (negate Z, flip the winding, negate the pitch) is the projectβs single coordinate convention, and everything that reads Blitz3D depends on it.
The .x files: three formats, one reader
The props and pickups are in DirectX .x. I wrote a dedicated reader, scpcb_xfile. &[u8] in, a model or an error out. No engine, no filesystem, no glTF.
There are 56 files in the corpus, and they arenβt in one format but three:
xof 0302txt 0064 17 files 3D World Studio, 64-bit text floats
xof 0303txt 0032 38 files Blender/3ds style, template blocks
xof 0303bzip0032 1 file Battery.x, MSZIP-compressed binary stream
Hence three front-ends, a single token stream, a single semantic pass. The parser never knows which lexer fed it, and thatβs what makes three formats into one reader instead of three parsers.
The measured traps, here too:
- 16 of the 56 files carry an orphan closing brace at depth 0, always wedged between the frame tree and the animation block. A strictly balanced recursive-descent parser therefore rejects 29% of the corpus, including both leaves of the containment door.
- Normal blocks have their own count and their own face index array. One file has 128 positions for 33 normals. Assuming normals and positions run in parallel is wrong for half the corpus, and wrong silently. The model shows up fine, itβs just lit completely wrong.
- Only 2 frame matrices out of 182 arenβt the identity. One of those two belongs to
lamp2.x, the most instanced prop in the game, with 202 references across the rooms. Ignore frame transforms and every desk lamp shifts by 96 units.
B3D to glTF: the converter I didnβt write
The animated NPCs are in .b3d. I weighed three routes before picking one, and ended up writing nothing myself. An existing Rust converter reads the skinning, the animations and, above all, the non-standard chunk that carries the clip names. I forked a copy, applied five patches, and documented the pin and each patch. Itβs GPL, so a test checks against the lockfile that no game crate depends on it. The conversion is offline, the game only ever loads glTF.
The least obvious rule in the pipeline is about textures. The .b3d entries are often absolute Windows paths along the lines of D:\SCP - CB - Vanilla\GFX\npcs\173texture.jpg. The resolver tries the full path, then the basename alone, and only that second strategy can rescue an absolute path. So you have to point the context at the modelβs own folder. Point it at the game root and it finds nothing and puts out untextured models, without a single error.
Baking the lightmaps
The work in progress is the lighting. The 2012 game ships its precomputed lightmap atlases, and they act as a fidelity anchor. I bake my own alongside them, in HDR, into an Rgba16Float DDS, written with exactly the same version of the same crate that Bevy reads it back with, so the writer and the reader canβt drift apart.
A single constant drives the resolution, texel density per world unit. It was calibrated on a small room. Bad idea: on a storage room, my atlas has 3.7 times more texels than the 2012 one.
The most instructive part came out of playtesting. I had a light leak around a door and I blamed the bake twice in a row, wrongly. The real culprit was elsewhere. The real-time lights had no shadow maps, so each one was lighting through the wall behind it, and the spill landed on the doors and the props. No lightmap change could fix that, which explains why comparing my atlas with the 2012 one gave two pixel-identical images.
Two tools came out of it, and theyβre the most useful ones in the project. One key re-bakes the atlas in memory from the config files, with no recompile. The second is an egui debug panel with sliders on the lighting parameters.
Where it stands
Itβs young, and thereβs a lot left. You can walk the facility, the doors open, 173 does what itβs supposed to do when you stop looking at it. It isnβt the game, though. NPCs are missing, so are items, saving and full audio, and the lighting isnβt done. Multiplayer is planned for in the architecture but doesnβt exist.
The most transferable thing in here is neither Rust nor Bevy. Itβs the method. For an undocumented format, measuring the whole corpus before writing a line of parser costs you a day and saves you weeks of silent bugs.