TypeScript library for the data formats of Freelancer (Digital Anvil, 2003) — the binary UTF containers its assets ship in, the INI and BINI files that describe how they are used, the Lua 3.2 scene scripts, and the resource DLLs every name and infocard resolves into.
Isomorphic: it reads and writes bytes and never touches a filesystem or a network. Supplying the bytes is the consumer's job, which is what lets the same code run in Node, in a browser against the File System Access API, or in a worker.
Not affiliated with or endorsed by Digital Anvil or Microsoft. "Freelancer" is used to name the game whose formats these are.
Every format here is read in the same three steps, and each step is usable on its own.
bytes on disk ◀──▶ interim model ◀──▶ meaning
(BufferView) (classes: identity (plain data: what the
and mutation) game does with it)
- Binary — a cursor over bytes. No choices made.
- Interim — the document as the file spells it.
DirectoryandFilefor UTF,Section/Property/Valuefor INI,Value/Globalfor THN,Resource[]for a DLL. These are classes and records, they are public, and an editor builds one by hand and writes out bytes the game reads. Their methods are structural: they know how a tree is shaped, never what a payload means. - Meaning — typed structures that interpret the choices the game already made. A rigid model, a material, a texture, a scene. Plain data with no methods and no identity.
The library models what the game's data means. It does not decide what an application should do with
it: resolution order, defaults, caches and output conventions belong to the consumer. For INI that
third layer is the consumer's too — this library gives you the document exactly as the file spells
it, and what a [Ship] or a [Nebula] means is yours to define.
| Import | Contents | Documentation |
|---|---|---|
@treewyrm/freelancer |
getResourceId, getObjectId, and the lookups built on them |
UTF.md |
@treewyrm/freelancer/utf |
Directory, File — the UTF container |
UTF.md |
@treewyrm/freelancer/utility |
BufferView, windows-1252, tree, timestamp, number and name helpers |
UTF.md |
@treewyrm/freelancer/math |
Vector3, Vector4, Quat, Matrix3, Matrix4, Transform, scalar helpers |
API.md |
@treewyrm/freelancer/vmesh |
VMesh geometry parts and mesh library | VMESH.md |
@treewyrm/freelancer/compound |
The Cmpnd hierarchy shared by rigid and deformable models |
COMPOUND.md |
@treewyrm/freelancer/rigid |
.3db / .cmp / .sph models: parts, cameras, spheres, material animation |
RIGID.md |
@treewyrm/freelancer/animation |
Keyframe animation scripts, shared by .cmp and .anm |
ANIMATION.md |
@treewyrm/freelancer/deformable |
.dfm character models: bones, skinned meshes, detail levels |
DEFORMABLE.md |
@treewyrm/freelancer/surface |
.sur collision surfaces: parts, hulls, bounding volume hierarchy |
SURFACE.md |
@treewyrm/freelancer/texture |
Texture library entries: DDS surfaces, Targa mip chains, animations, cubemaps |
TEXTURE.md |
@treewyrm/freelancer/material |
Material library entries: shader type, colours, texture slots |
MATERIAL.md |
@treewyrm/freelancer/alchemy |
Alchemy particle effects: node library and effect library | ALCHEMY.md |
@treewyrm/freelancer/ini |
Document, Section, Property, Value, coercion, lookups, read / write |
INI.md |
@treewyrm/freelancer/ini/text |
Text INI parser and serializer | INI.md |
@treewyrm/freelancer/ini/binary |
BINI reader and writer | INI.md |
@treewyrm/freelancer/ini/save |
.fl saves: text under a positional XOR mask |
INI.md |
@treewyrm/freelancer/thn |
Globals, Global, Value, value helpers, read / write |
THN.md |
@treewyrm/freelancer/thn/text |
Lua source parser and serializer | THN.md |
@treewyrm/freelancer/thn/bytecode |
Compiled Lua 3.2 reader, and the opcode table | THN.md |
@treewyrm/freelancer/thn/scene |
Entities and events as typed records, and THORN's vocabulary | THORN.md |
@treewyrm/freelancer/resource |
Resource DLLs: read / write, string tables, infocards, the ids_* id space |
RESOURCE.md |
Freelancer's voice banks under DATA/AUDIO need no module of their own — they are flat UTF
directories of RIFF waveforms, handled with Directory and File directly. See
AUDIO.md for how a line is named and how to read and write one.
npm install @treewyrm/freelancerRequires Node.js >= 18.
Freelancer identifies almost everything by a hash of its name, and there are two hashes. Picking the wrong one yields a number rather than an error, which is why they sit together at the root and nowhere else.
import { getResourceId, getObjectId } from '@treewyrm/freelancer'
getResourceId('data.solar.starsphere.starsphere_rh05_stars.lod0-102.vms') // UTF resources
getObjectId('li_elite') // INI nicknamesBoth fold case by default, because the game compares names with stricmp. Alchemy is the one place
that does not.
import { Directory, File } from '@treewyrm/freelancer/utf'
import { readFileSync } from 'node:fs'
const root = Directory.read(readFileSync('ship.3db'))
root.getDirectory('MultiLevel', 'Level0', 'VMeshPart')
root.getFile('MultiLevel', 'Level0', 'VMeshPart', 'VMeshRef')Building one by hand and writing it out is the same object graph in reverse:
const root = new Directory()
root.setFile('Cmpnd', 'Root', 'Transform').writeFloats(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
const bytes = root.write()File implements ArrayBufferView and carries typed iterators — readIntegers / writeIntegers,
readFloats / writeFloats, readStrings / writeStrings — with the write side chaining.
import { Directory } from '@treewyrm/freelancer/utf'
import { readRigidModel } from '@treewyrm/freelancer/rigid'
import { getBoneModel, readDeformableModel } from '@treewyrm/freelancer/deformable'
const ship = readRigidModel(Directory.read(readFileSync('ships/li_fighter.cmp')))
const body = readDeformableModel(Directory.read(readFileSync('characters/bodies/br_darcy_body.dfm')))
const skeleton = getBoneModel(body) // the bone hierarchy, as a Model<Bone> tree.sur collision files are a standalone chunked binary rather than a UTF tree, so they take a
BufferView:
import { BufferView } from '@treewyrm/freelancer/utility'
import { readSurfaceLibrary } from '@treewyrm/freelancer/surface'
const parts = readSurfaceLibrary(BufferView.from(readFileSync('ships/li_fighter.sur')))The signature decides which parser runs, never the extension or the location. Retail DATA holds
1,251 BINI files and one text file, all named .ini, EXE/ holds three more text ones, and the two
.fl saves beside them are one masked and one plain.
import { Document, value } from '@treewyrm/freelancer/ini'
const document = Document.read(readFileSync('DATA/EQUIPMENT/goods.ini'))
const good = document.findByNickname('commodity_gold')!
const price = good.getValue('price')!
value.toFloat(price) // 425
value.toInteger(price) // 425
value.toText(price) // '425'Document, Section and Property are classes and both the lookups and the serialization are
methods on them, the same way Directory owns getFile and Directory.read / directory.write. A section reached by getSection or findByNickname carries
getProperty / filterProperties, getValue / getValues, hasProperty and getNickname; a
document iterates its sections directly, so for (const section of document) reads as it looks.
A property can also coerce positionally, which is how the engine reads a line of several values:
const [x, y, z] = document.getSection('Zone')!.getProperty('pos')!.format('float', 'float', 'float')A value carries the type the file recorded, and you ask for the type you want — this is how the
engine works, and the coercions here follow it, truncation and odd corners included. Three worth
knowing, all of them the engine's: a string coerced to a boolean accepts only true and false by
name (so yes is false); a float coerced to an integer truncates toward zero; an unparseable
string yields 0 rather than failing, because that is what the game gets.
Writing emits any of the three encodings from the same document:
import { Document, value } from '@treewyrm/freelancer/ini'
const document = new Document()
const good = document.addSection('Good')
good.addProperty('nickname', value.string('commodity_gold'))
good.addProperty('price', value.integer(100))
good.addProperty('separable') // a flag is a property with no values
writeFileSync('goods.ini', document.write('binary'))
writeFileSync('goods.txt', document.write('text'))
writeFileSync('goods.fl', document.write('save')) // FLS1, maskedaddSection and addProperty always append — never find-or-replace, because duplicate names
are legal and carry meaning. Use insertSection / insertProperty when a section belongs to the
one above it, the way a [LOD] trails its [Gun].
The mask on a .fl is obfuscation and nothing more — the pad depends only on a byte's position, so
it is its own inverse and save.mask both reads and writes it. A document does not remember what it
was read from, so 'save' has to be asked for; the default stays binary.
Repeats are lists, not mistakes. [Loadout] equip occurs 16,074 times across retail and 156
files repeat a section name, so every lookup comes in a singular and a plural form and the plural is
usually the honest one. Every lookup folds case and none of them string-compares, because six retail
section names and 32 property names are spelled more than one way — and nothing folds a name in
place, so what was read is what gets written back.
A .thn is a compiled Lua chunk but not a program: fifteen opcodes across all 1,506 retail scripts,
every one of them pushing a value or building a table, and no branch, call or function anywhere. So
it reads as data, and writes back as Lua source — which the engine loads just as happily, because it
loads scripts with dofile and Lua compiles text when the signature is absent.
import { read, value, write } from '@treewyrm/freelancer/thn'
const script = read(readFileSync('DATA/SCRIPTS/INTRO/intro_waterplanet.thn'))
const duration = value.getGlobal(script, 'duration')
if (duration && value.isNumber(duration)) value.toNumber(duration) // 361.872
writeFileSync('intro_waterplanet.thn', write(script)) // plain-text Lua, and the game runs itThe one thing to get right is that a bare word is not a string. type = SCENE is a read of a
global THORN defines, and writing type = "SCENE" hands the engine a string where it wants a
number. Identifiers are their own kind of value for exactly that reason.
./thn/scene is the layer above: entities and events as records, discriminated on type and
action, with THORN's vocabulary resolved. It reads both forms retail ships — 355 of the 1,506
scripts carry type = 9 where the rest carry type = SCENE — and both come out the same.
import * as thn from '@treewyrm/freelancer/thn'
import * as scene from '@treewyrm/freelancer/thn/scene'
const script = scene.read(thn.read(readFileSync('DATA/SCRIPTS/INTRO/intro_waterplanet.thn')))
for (const entity of script.entities)
if (entity.type === 'CAMERA') entity.cameraprops?.fovh // narrowed; a MARKER has no cameraprops
writeFileSync('out.thn', thn.write(scene.write(script)))It refuses what it has not measured — an entity type, event action, enum value or flag bit
outside the vocabulary is an error naming the value, because every alternative is a guess and all
41,250 retail entities resolve without one. And it keeps what it does not recognise, in
unknown, so a modded script survives a read-modify-write.
An ids_name or ids_info is a number into a set of DLLs, resources.dll first and then whatever
freelancer.ini's [Resources] block lists. Give readLibrary those files in that order:
import * as resource from '@treewyrm/freelancer/resource'
const library = resource.readLibrary(
resource.RETAIL_LIBRARIES.map((name) => resource.read(readFileSync(`EXE/${name}`))),
)
library.names.get(196609) // 'New York'
library.infocards.get(65539) // '<?xml version="1.0" encoding="UTF-16"?>…'Writing produces a real DLL — these images carry no code, so there is nothing to link:
const dll = resource.write([
...resource.writeStrings(new Map([[0, 'Nomad Battleship']])),
...resource.writeInfocards(new Map([[3, '<RDL><PUSH/><TEXT>…</TEXT><POP/></RDL>']])),
])Writing is a fixed point everywhere — what is written reads back identical and writes again to the same bytes. Byte-exactness holds where the format permits it:
| Direction | Guarantee |
|---|---|
| BINI → model → BINI | Byte-exact, verified over all 1,251 retail files |
| BINI → model → text → model → BINI | Byte-exact, same corpus |
| Save body → unmasked → save body | Byte-exact; the mask is its own inverse |
| THN bytecode → model → text → model | Exact, verified over all 1,506 retail scripts |
| DLL → resources → DLL | Exact, verified over all 37 DLLs in retail EXE |
resources → .rsrc |
Byte-identical to retail, 5 libraries of 7 |
| Material library | Byte-exact, all 7,525 materials |
| Deformable models | Byte-exact, all 204 |
| Alchemy effects | Byte-exact, 596 files bar two encoding residues |
| Texture library | 4,447 DDS surfaces, both cubemaps, 629 Targa chains |
The full table, including what cannot round-trip and why, is in RETAIL.md.
npm testThe corpus suites read a retail install from $FREELANCER_DATA, falling back to
~/Downloads/Freelancer/DATA, and skip themselves with a reason when neither exists. Every count
they assert was measured before the code existed, so a failure means a reader drifted rather than
that the number needs updating. The rest of the suite never depends on retail data being present.
Each module has a document carrying its binary layout, the retail measurements behind it, and the decisions not worth re-litigating.
| Document | Subject |
|---|---|
| API.md | Every export of every subpath, and what no barrel re-exports |
| UTF.md | The container, Directory / File, hashing, the utilities |
| VMESH.md | Mesh parts, the VMeshWire overlay, the mesh library, LOD levels |
| COMPOUND.md | The Cmpnd hierarchy, constraints, joints, hardpoints |
| RIGID.md | .3db / .cmp / .sph, cameras, spheres, material animation |
| ANIMATION.md | Keyframe scripts, channel types, the quantized quaternion decode |
| SURFACE.md | .sur collision files, and their Ipion Virtual Physics lineage |
| TEXTURE.md | The four forms a texture entry takes, and cubemaps |
| MATERIAL.md | Shader types, colours, texture slots, and why every property is optional |
| DEFORMABLE.md | .dfm characters: the bone table, skinned meshes, detail levels |
| ALCHEMY.md | .ale particle effects, the node library, the case-sensitive hash |
| INI.md | All three encodings, the shared document model, how the game reads a value |
| THN.md | The scene script format, its value domain, and why it is not INI |
| THORN.md | The scene vocabulary: entities, events, properties, and where each came from |
| RESOURCE.md | The resource DLLs: the PE container, string tables, infocards, the id space |
| RDL.md | The markup every ids_info resolves to, which has no module |
| AUDIO.md | DATA/AUDIO voice banks, which need no module of their own |
| ENGINE.md | The INI vocabularies the executables hardcode, and how they were recovered |
| SECTIONS.md | Every section and property the game reads, with its declared value shape |
| RETAIL.md | The corpus, the measurements, the quirks, the open questions |
| RENDERER.md | Mapping these structures onto a WebGL2 renderer |
Each module document carries the format's binary layout and data structures, then a closing Corpus chapter with the retail counts and round-trip results behind them. Function and type names are not repeated per module — they are all in API.md.
npm run build # compile TypeScript → dist/ via tsdown
npm test # Node test runner over src/**/*.test.ts (via tsx, no build step)MIT