Reverse engineering · August to September 2026

FF Mod Studio

Tools for modding Freedom Fighters, a game from 2003. There's an editor for its textures and models, plus a Blender addon that sends edits back into the game.

FF Mod Studio with a Soviet soldier from a cutscene archive in its 3D viewport, textured, with the mesh list on the left and the bound texture named on the right
textures read from the game, none failing to decode
23,522
meshes rebuilt from the game's 90 archives
61,424
kinds of edit confirmed in the running game, not just in the file
3

Intro

Freedom Fighters is a squad shooter from 2003, set in a New York the Soviet Union has occupied. IO Interactive built it on Glacier, the same engine family as Hitman 2. I’ve been working on a remaster with new lighting and sharper textures. For that I needed tools that could read the game’s files and write them back.

FF Mod Studio is what came out of it. It’s a desktop editor in C++17 with a texture browser, a 3D mesh viewer and a console that logs every file operation. A command-line program does the batch work and checks every file in the game. The Blender addon drives that same program, so each file format has exactly one implementation.

It’s all ZIP files

The game keeps each level in an archive under its Scenes folder. They turned out to be ordinary PKZIP files, each holding about a dozen members named after the scene: .TEX for textures, .PRM for meshes, .GMS for the scene graph, plus others for animation, pathfinding and collision that I haven’t decoded yet.

Some members are wrapped again in a small container of the engine’s own: two sizes, a flag, then raw DEFLATE. All 180 of those containers inflate, compress again and inflate to identical bytes. That was the first thing I checked, because every edit depends on it.

One archive stores its members without compression, so the repacker copies each member’s original method instead of assuming one. Before any edit, the tool also copies the untouched archive to a backup. It won’t write without one. A menu item puts the original back.

I check all of it with a sweep over the 90 archives that ship with the game. This is its current output:

archives opened          : 90 / 90
members enumerated       : 1118
containers round-tripped : 180 / 180  (failures: 0)
TEX libraries parsed     : 90
texture records          : 23522
  decoded to pixels      : 23522
  decode failures        : 0
GMS scenes indexed       : 90  (44663 symbols)
PRM libraries parsed     : 90
  chunks                 : 61451  (skipped: 27)
  meshes built           : 61424
  triangles / vertices   : 2016294 / 2820961
  out-of-range indices   : 0
PASS

Textures

Each level’s .TEX file is a chain of records: a size, a format tag, the dimensions, then every mip level. The editor shows them as a searchable grid. You export one to PNG, edit it in anything, import it back and apply the change to the archive.

The editor on chapter 3's texture library: a grid of 585 textures with one selected, its format and size in the panel on the right, and the console along the bottom
Every texture in a level, searchable. The console along the bottom logs each file operation.

An import writes the new pixels into the bytes the old ones used, one mip level at a time. Nothing else in the record moves, including the fields I don’t understand yet. That’s why an imported image has to be the same size as the one it replaces.

Two details cost me real time. The size at the start of each record is a length to the next one, not an offset. And the record stores height before width. Swap them and square textures still look right, and so does the size check, because a 64 by 128 image has as many blocks as a 128 by 64 one. It only showed up as banding on a wheel texture that happened to be 128 by 64.

All 23,522 textures in the game, across four formats, decode.

An AK rifle texture at 256 by 128, magnified, beside the same texture rebuilt at 512 by 256, with close-ups of each underneath
Upscaling a texture. Resampling can't invent detail, but it does remove the blocks.
The rebel base in the running game: the player on a circular walkway inside a concrete hall, with a fire burning in a barrel and the game's HUD in the corners
The rebel base in the game itself, with all 336 of its textures upscaled and written back into the level's archive.

Meshes

The .PRM files hold the geometry: 61,424 meshes across the game, about two million triangles. Each mesh is a set of triangle strips. In a strip, the order of the corners decides which way each triangle faces.

I didn’t want to guess that order. Every vertex in the game also stores a normal, so I built the triangles each possible way and scored how well their faces agreed with the stored normals, over about 900 large meshes.

Reading of the strip Agreement with the stored normals
Strip, flipped winding +0.959
Triangle fan +0.032
Strip, plain winding -0.959

The plain reading is exactly inside out, and a fan is noise. Only the flipped strip fits.

An early safety limit of 256 vertices per strip was wrong as well. Detailed meshes are often stored as one long strip, up to 2,004 vertices. The limit had quietly dropped 2,027 meshes and about 700,000 triangles. For a while it looked as if the game had no detailed models at all.

A stone statue from chapter 7 in the editor's 3D viewport, 774 triangles, with the mesh list on the left and its numbers on the right
The same statue as a wireframe
A statue from the streets of occupied New York, shaded and as a wireframe.

Then I needed to know which texture goes on which mesh. A 16-bit field near the start of each mesh holds it, in its lower 11 bits. Across all 61,451 chunks, that field never once names a texture that doesn’t exist. The proof I trusted most was simpler, though. Drawn with the texture the field names, a Soviet soldier comes out with his insignia and his belt in the right places.

A Soviet soldier from a cutscene archive, opened in Blender with his texture already applied
Exported from the game and opened in Blender, textured. Before I found the texture field, every export came out grey.

Bit 11 of that field looked like a blend flag, since it’s set on every fire and smoke mesh. Flipping it on 536 meshes in one level changed nothing on screen. The game’s own code masks it off when it sorts draw calls. So the tool keeps the bit as it is and reads nothing into it.

Putting things in place

A level isn’t only meshes. The .GMS file, a database of the objects in the scene, uses absolute file offsets as pointers. A placement is easy to spot once you know the trick: it points back at its own rotation matrix, followed by pointers to a position and to the mesh it places.

Every rotation matrix in the game has a determinant of -1, the same flip of handedness everywhere. My first scan required +1 and found nothing.

About 60% of the static geometry is already stored at its real position, so a level can be drawn top-down from the meshes alone. The rest gets its position from those placements.

The rebel base drawn top-down from the meshes' own coordinates, with no placement data applied
The rebel base, drawn straight from the geometry.

To check the placements I needed something independent to score them against. The game’s .SGP files hold its doorways, in world space. Counting doorways with geometry within 2 metres of their frame, eight levels went from 27 of 103 to 128 of 168 once I read a second kind of placement, one that shares a single rotation between many objects.

On chapter 3, 96.3% of placed vertices land inside the level’s footprint. The rest are strays I haven’t explained yet.

A level seen from above: static geometry in grey, and objects placed from the scene graph in red, a few of them stranded outside
Objects placed from the scene graph. Most land where they should. The strays outside are the part still to solve.

The sphere nobody could see

Reading a file back proves it’s well formed. It doesn’t prove the game will draw it. I learned that from a test I was sure of: replacing a 158-vertex concrete platform with a sphere of 3,136 vertices, 20 metres across.

The sphere was in the file, and my tools read it back two metres from where the player stood. In the game there was nothing there, for two rounds of testing.

Every mesh names a bounds record. My importer never updated it, because an early note of mine said that record was often just a unit box. Measuring showed the note was wrong. Across 16,934 chunks the record matched the mesh’s real extent 84% of the time. The game was culling my sphere against the box of the small platform it had replaced.

What gave it away was a screenshot. The object was exactly where the tool said and still invisible, which is what culling looks like. A placement bug looks different. With the bounds updated, the sphere renders.

Three kinds of edit are now confirmed in the running game:

Edit How I know
Textures A fully retextured level rendered correctly, with UV wrapping and transparency intact
Placement Raising all 99 placements in a level by 4 metres lifted the walkways while collision stayed put, leaving the player under the floor
New geometry The sphere, with a different vertex count and a shape the artists never made

Loose files

The game’s executable kept a developer switch. With LoadFilter=.TEX in its ini file, the engine reads textures from loose files on disk before it looks in the archives. I tested it by giving the rebel base a loose texture file in magenta while its archive stayed byte for byte the original. The level loaded magenta.

So texture mods don’t need repacking at all. Once the tool has put every texture where the engine expects to find it, an edit becomes a file copy. Meshes and placements don’t work that way: loose .PRM and .GMS files crash the game when the level loads, so those still go through the archive.

The Blender addon

Most of the actual editing happens in Blender. You pick a level and the addon imports it textured, at its real coordinates. When you’ve edited a mesh, the addon sends it back to the game. It doesn’t parse anything itself. Every read and write goes through the command-line program, so there’s no second copy of a format to drift out of step.

The FF Mod Kit panel in Blender, with buttons to import a level or one mesh and to send a mesh back, and a level loaded
The addon. Pick a level, import it, edit a model, send it back.

Characters come out too, as rigged glTF files with their skeletons and weights: 1,405 pieces across 42 archives, with every bone influence resolving. They stand 1.8 metres tall in Blender because the exporter turns centimetres into metres and flips one axis on the way out.

Chapter 3 imported into Blender through the addon: textured skyscrapers and streets, seen in perspective
One button. This is what the addon gives you, not a scene staged for the picture.

What isn’t done

Plenty. Characters can be exported but not written back yet. Objects in the scene graph can be moved, but their other properties can’t be edited. Missions and AI are compiled for the engine’s own virtual machine. Editing them would need a disassembler for it, which is a project of its own.

The scene database view: a table of symbols recovered from the scene file, and a note saying the view is read-only
The scene graph view. It's only half understood, and the tool says so.

I also don’t have the picture that matters most yet: the game running with my textures in it. The level carrying them is the chapter 2 rebel base, so I need to play up to it and take a screenshot. Everything above shows the tools working. That one would show the game working.

More work

2026R packages

FounderForge

A Shiny app that turns real marker data into an AlphaSimR founder population without writing R. Load a VCF, HapMap, numeric or PLINK file, check it, filter it and export what newMapPop() needs.

Tools: R · Shiny · golem · AlphaSimR · vcfR · data.table · ggplot2

2026Apps

Plotpin

An Android app for finding a plant again. I built it to walk back to bagged sorghum plants in a crossing block. Now it works for anyone who marks points in a field.

Tools: Kotlin · Jetpack Compose · Room · Paparazzi

2026Machine learning

Colony Counter

An Android app that counts bacterial colonies from plate photos, offline. Two small YOLO models do the counting, in about three quarters of a second a plate on my phone.

Tools: Python · Ultralytics YOLO26 · ONNX Runtime · Kotlin · Jetpack Compose · CVAT

2026R packages

ggWizard

A Shiny wizard for making ggplot2 charts from a CSV or Excel file without writing code. You check and clean the data, pick one of eight chart types and style it, then export it with the R script behind it.

Tools: R · Shiny · golem · ggplot2 · plotly · readxl · DT

2026R packages

Linkmapper

A Shiny app for building genetic linkage maps and mapping QTL without writing code. It began as the final-year project of two colleagues and me at KNUST. Now it's an R package, with a hosted copy that runs in the browser.

Tools: R · Shiny · golem · onemap · R/qtl · ggiraph

2026R packages

Clusnalyst

A Shiny app for cluster analysis of marker, trait and sequence data. My colleagues and I built the first version at KNUST in 2022. In 2026 I rewrote it as an R package with a live demo.

Tools: R · Shiny · golem · ape · cluster · msa · ggtree · ggiraph

2026Codebreaking

The Voynich manuscript

A book about 600 years old, written in a script nobody can read. Here's what's known about it and why no codebreaker has cracked it, with a note on my own work.

Tools: Python · NumPy · SciPy