Reverse-engineered format parsers, exporters and a browser workbench for Omikron: The Nomad Soul (Quantic Dream, 1999). It reads the shipped game directly — meshes, textures, motion capture, skeletal animation, levels, audio, fonts and the game's own text — and puts it all behind one local page.

CHECKLIST.md tracks what is done and what is next — start there if you are picking the project back up.
Nothing here writes to the game install. The project lives wherever you
cloned it, reads the game read-only, and puts everything it generates in
_exports/ — so that directory can be deleted and rebuilt at will.
python run.py serve
That starts a local server and opens the Studio — one authoring app. It
finds the game on its own (see Finding the game if it
cannot). After npm install && npm run build the server serves the build
from app/dist; set OMIKRON_VITE=1 to attach a live Vite process instead.
The old single-purpose pages remain at *.html?legacy=1 (also under the
Studio's ⋯ menu).
Everything else is optional:
pip install pillow # PNG texture export; falls back to BMP without it
python run.py info "$OMIKRON_ROOT/MESHES/OBJETS/BARRE.3DO" # one mesh, described
python run.py export "$OMIKRON_ROOT/MESHES/OBJETS/BARRE.3DO" # one mesh -> OBJ/MTL
python run.py export-all # all 635, a few minutes
python run.py scx-info "$OMIKRON_ROOT/SCPTDATA/anekbah.SCX" # a scene script
node test-formats.mjs # check the parsers
node test-camera.mjs # check the camera
node test-render.mjs # check the rig and geometry
npm install && npm run build # Studio production build
python run.py --help lists the rest. Every script also runs standalone
(python omikron_3do.py <file> --info) if you only need one piece. With GNU
Make available, make help gives shorter invocations of the same commands —
it is a thin wrapper; run.py is the source of truth.
One DCC-style shell, one selection, seven lenses on it.
Omikron › Anekbah › anekbah › KAY World · Mesh · Animation · Library · Data · Hex · Catalog
┌──────────────┬──────────────────────────────────────────────┬──────────────────┐
│ Scene │ │ Inspector │
│ ▾ Anekbah │ the one viewport │ KAY │
│ ☑ anekbah │ (or a document, for Data/Hex) │ Character │
│ KAY │ │ Dialogue │
│ Assets ├──────────────────────────────────────────────┤ │
│ Characters… │ Timeline ▶ 1 / 412 · Console │ │
└──────────────┴──────────────────────────────────────────────┴──────────────────┘
Alt+1…7 switch lens; Ctrl+B / Ctrl+J / Ctrl+Alt+B
toggle the docks; F frames; [ ] step authored cameras in World.The shared library under src/lib/ is the single implementation: format
parsers, mesh→three.js, I/O, session contract, audio, fonts, catalog joins.
![]() | Morph — 777 motion-capture takes, each matched to the face it was recorded for and played on the textured bust, beside the line of dialogue being spoken. |
![]() | Library — 763 books, newspapers, dossiers and notes recovered from the unmapped tail of the OBJECT records, drawn in the game's own .FNT faces, beside the 3D object that carries each one. |
![]() | Textures — every .3DT set decompressed against its mesh, browsable and exportable as PNG. |
Plus Meshes (sub-object tree, normals, seam editor), Animation
(.3DA skeletal takes played on the character their joint names bind them to),
Levels (above), Data (the IAM stores and the designers' symbol tables),
Inspect (any file at all) and Overview (a live inventory of the install).
Worked out automatically on first use, in this order:
OMIKRON_ROOT environment variable.omikron-game-path, written the first time you choose a folderlibraryfolders.vdfTo set it explicitly:
set OMIKRON_ROOT=D:\Games\Omikron # Windows
export OMIKRON_ROOT=/games/Omikron # macOS/Linux
The folder wanted is the one containing MESHES/.
| Path | Purpose |
|---|---|
run.py | Unified CLI — the entry point for most tasks. |
omikron_common.py | Shared parsing primitives, plus locating the game install and the _exports/ layout. |
omikron_3do.py | .3DO mesh parser + Wavefront OBJ/MTL exporter. |
omikron_3dt.py | .3DT texture decompressor + PNG/BMP exporter. |
omikron_scx.py | .SCX scene/script parser (object keyframes, script triggers, asset references) and extractor — a scene file also contains its own sounds and props inline. run.py scx-assets writes them to _exports/scenes/. |
batch_export.py | Exports every mesh under the game's MESHES/ into _exports/ in one pass. The single implementation — run.py export-all and make export-all both call its export_all(). |
validate_3do.py | Sanity-checks every .3DO in the game: parse errors and out-of-range face indices. |
hexat.py | Hex-dump a file at a given offset — for reverse-engineering unknown fields. |
serve.py | Local HTTP server (127.0.0.1 only) — serves the project directory and the whole game install read-only, and exposes /api/list, /api/info and /api/mesh-index so the tools can browse and load files instead of requiring drag-and-drop. run.py serve / make serve. |
app/ | Studio. Vite + React shell. src/session/ holds what outlives a lens (the stage, the clock, the assembled world, the asset index, navigation); src/ui/ the docks and primitives; src/lenses/ the seven lenses, each { Main, Inspector } reacting to the selection. |
src/lib/ | Shared library — format/, render/, io/, session/, audio/, font/, catalog/, world/, morph/. Root omikron-format.js etc. re-export these so legacy HTML and test-formats.mjs keep working. |
index.html | Redirects to the Studio's Catalog lens. ?legacy=1 keeps the old overview. |
textures.html | Legacy .3DT browser. Studio: Mesh lens, Textures section. |
anim.html | Legacy morph player. Studio: Animation. |
level.html | Legacy level viewer. Studio: World. |
library.html | Legacy library. Studio: Library. |
rig.html | Legacy skeletal player. Studio: Animation. |
fnt-render.js | Barrel for src/lib/font/. |
data.html | Legacy IAM explorer. Studio: Data. |
inspect.html | Legacy file inspector. Studio: Hex. |
tool-shell.js | Legacy chrome (nav + picker). I/O now lives in src/lib/io. |
test-formats.mjs | Checks the format library against real game files. node test-formats.mjs. |
test-camera.mjs | Checks the viewport camera headlessly — geometric zoom, the zoom floor, orbit. node test-camera.mjs. |
test-render.mjs | Checks the render layer against the install — rig group coverage, pivots, hierarchy, and that a bound take moves its joints. node test-render.mjs. |
viewer.html | Legacy mesh viewer with the interactive seam editor. Studio: Mesh. Geometry now goes through buildGeometry. |
omikron-format.js | Barrel for src/lib/format/. |
mesh-render.js | Barrel for src/lib/render/mesh-render.js. |
viewer-common.js | Barrel for src/lib/render/viewer-common.js. |
omikron_formats.md | The binary format reference for the formats we read — read this before touching parser internals. |
research/ | Work in progress on the formats with no parser yet, plus the deep dives on IAM/, .3DM and the arm seam. Read research/README.md before adding to it — it records how this project has been fooled before. |
survey_formats.py | First-pass triage of unparsed formats — size, magic, text-vs-binary. python survey_formats.py [DIR]. |
legacy/ | Superseded scripts, kept for history. Not maintained — see its README. |
1,666 MB across 2,787 files, by how well it is understood:
| Status | Share | Size |
|---|---|---|
| parsed | 80.3% | 1,337 MB |
| partial | 14.7% | 245 MB |
| external (MPG, BMP, WAV, TTF) | 5.0% | 83 MB |
| unknown | 0.1% | 1.5 MB |
| Format | State |
|---|---|
.3DO meshes, .3DT textures, .3DM morph takes | solved |
.3DA skeletal animation, .WRE radar, .ADP audio, .FNT fonts | solved |
IAM/ object, scene, area and dialogue stores, *.TAG, HASHCODE.TAB | solved |
.SCX scene scripts | partial — and the big one. Header, scripts, doors, file refs and embedded media parse, but that is 94.4% of their 235 MB, and most of the rest is the per-scene header |
.CTL controllers, .ANI, .SFX effects, .MAP map screens, .OPT trajectories | partial |
.MPT, .PIX/.SIZ fonts | unread |
The live version of the first table is on the workbench home page, computed from the install rather than from this file.
CHECKLIST.md is the short version — one line per item.
research/OPEN.md is the standing manifest of what is
still unknown, ordered by payoff, and it records what has already been tried
and rejected for each — read it before starting on any of them.
See Mesh Descriptor in omikron_formats.md for full details. Three
things confirmed and implemented in omikron_3do.py / omikron-format.js:
A sub-object's pos field is a real per-part offset that must be added
to that part's vertices (they're stored local to it) -- for every
mesh, characters included. Skipping it for skeletal meshes was tried and
collapses every limb onto the origin.
A triangle/quad vertex index with its "parented" bit set belongs to an ancestor's vertex block. Which ancestor is decided once per sub-object -- the nearest one large enough for the biggest flagged index that sub-object uses -- so all of its faces borrow from the same block. Deciding per index splits a single triangle across two parts.
parent_id/child_id/sibling_id reference the descriptor's
node_id field (+0x08), not its 0-based position in the
descriptor array — the earlier assumption that they were positional
was the reason some hierarchy references appeared to point outside
the file entirely. Cross-checked across dozens of files: node_id
resolves every parent_id with a 100% match rate.
validate_3do.py reports 0 parse errors and 0 out-of-range faces across
all 635 meshes. The residual 8 files with a 1-6 vertex overshoot were fixed
by resolving flagged indices against the ancestor chain rather than falling
back to the sub-object's own block (point 2 above).
Characters still do not render as a fully closed surface -- there is a
remaining gap at the joints whose cause is not established. See
omikron_formats.md for what has been ruled out, and for a warning about two
plausible-looking metrics that each produced a confident wrong answer.
omikron_common.py as the single source for anything shared
across two or more parsers (string reading, default paths, etc). Two
format modules independently reimplementing the same struct layout is
how offset bugs get fixed in one place and silently linger in the
other — it already happened once here (.3DT's material parsing used
to duplicate .3DO's).run.py should expose every parser's read-only commands. If you
add a new format module with its own CLI, wire its info/scan
equivalents into run.py too, the same way scx-* commands do — the
goal is one entry point, not one script per format that people have to
discover individually.omikron-format.js is the JavaScript twin of omikron_3do.py. The
browser cannot run the Python, so the format algorithm necessarily exists
twice. If you change the binary format understanding, change both — nothing
catches the drift for you except validate_3do.py, and that only measures
the Python side. Keep the algorithm in that module rather than in the viewer
page so it stays portable.viewer-common.js. It is deliberately
free of anything format- or layout-specific so a second tool can import it._exports/, which is gitignored and disposable; if a
command ever defaults an output path next to its input, that is a bug.84 commits
Hacker News (1)
TypeScript
30.2%
HTML
28.5%
JavaScript
25.6%
Python
8.9%
CSS
5.3%
PowerShell
1.4%
Reverse-engineered format parsers, exporters and a browser workbench for Omikron: The Nomad Soul (Quantic Dream, 1999). It reads the shipped game directly — meshes, textures, motion capture, skeletal animation, levels, audio, fonts and the game's own text — and puts it all behind one local page.

CHECKLIST.md tracks what is done and what is next — start there if you are picking the project back up.
Nothing here writes to the game install. The project lives wherever you
cloned it, reads the game read-only, and puts everything it generates in
_exports/ — so that directory can be deleted and rebuilt at will.
python run.py serve
That starts a local server and opens the Studio — one authoring app. It
finds the game on its own (see Finding the game if it
cannot). After npm install && npm run build the server serves the build
from app/dist; set OMIKRON_VITE=1 to attach a live Vite process instead.
The old single-purpose pages remain at *.html?legacy=1 (also under the
Studio's ⋯ menu).
Everything else is optional:
pip install pillow # PNG texture export; falls back to BMP without it
python run.py info "$OMIKRON_ROOT/MESHES/OBJETS/BARRE.3DO" # one mesh, described
python run.py export "$OMIKRON_ROOT/MESHES/OBJETS/BARRE.3DO" # one mesh -> OBJ/MTL
python run.py export-all # all 635, a few minutes
python run.py scx-info "$OMIKRON_ROOT/SCPTDATA/anekbah.SCX" # a scene script
node test-formats.mjs # check the parsers
node test-camera.mjs # check the camera
node test-render.mjs # check the rig and geometry
npm install && npm run build # Studio production build
python run.py --help lists the rest. Every script also runs standalone
(python omikron_3do.py <file> --info) if you only need one piece. With GNU
Make available, make help gives shorter invocations of the same commands —
it is a thin wrapper; run.py is the source of truth.
One DCC-style shell, one selection, seven lenses on it.
Omikron › Anekbah › anekbah › KAY World · Mesh · Animation · Library · Data · Hex · Catalog
┌──────────────┬──────────────────────────────────────────────┬──────────────────┐
│ Scene │ │ Inspector │
│ ▾ Anekbah │ the one viewport │ KAY │
│ ☑ anekbah │ (or a document, for Data/Hex) │ Character │
│ KAY │ │ Dialogue │
│ Assets ├──────────────────────────────────────────────┤ │
│ Characters… │ Timeline ▶ 1 / 412 · Console │ │
└──────────────┴──────────────────────────────────────────────┴──────────────────┘
Alt+1…7 switch lens; Ctrl+B / Ctrl+J / Ctrl+Alt+B
toggle the docks; F frames; [ ] step authored cameras in World.The shared library under src/lib/ is the single implementation: format
parsers, mesh→three.js, I/O, session contract, audio, fonts, catalog joins.
![]() | Morph — 777 motion-capture takes, each matched to the face it was recorded for and played on the textured bust, beside the line of dialogue being spoken. |
![]() | Library — 763 books, newspapers, dossiers and notes recovered from the unmapped tail of the OBJECT records, drawn in the game's own .FNT faces, beside the 3D object that carries each one. |
![]() | Textures — every .3DT set decompressed against its mesh, browsable and exportable as PNG. |
Plus Meshes (sub-object tree, normals, seam editor), Animation
(.3DA skeletal takes played on the character their joint names bind them to),
Levels (above), Data (the IAM stores and the designers' symbol tables),
Inspect (any file at all) and Overview (a live inventory of the install).
Worked out automatically on first use, in this order:
OMIKRON_ROOT environment variable.omikron-game-path, written the first time you choose a folderlibraryfolders.vdfTo set it explicitly:
set OMIKRON_ROOT=D:\Games\Omikron # Windows
export OMIKRON_ROOT=/games/Omikron # macOS/Linux
The folder wanted is the one containing MESHES/.
| Path | Purpose |
|---|---|
run.py | Unified CLI — the entry point for most tasks. |
omikron_common.py | Shared parsing primitives, plus locating the game install and the _exports/ layout. |
omikron_3do.py | .3DO mesh parser + Wavefront OBJ/MTL exporter. |
omikron_3dt.py | .3DT texture decompressor + PNG/BMP exporter. |
omikron_scx.py | .SCX scene/script parser (object keyframes, script triggers, asset references) and extractor — a scene file also contains its own sounds and props inline. run.py scx-assets writes them to _exports/scenes/. |
batch_export.py | Exports every mesh under the game's MESHES/ into _exports/ in one pass. The single implementation — run.py export-all and make export-all both call its export_all(). |
validate_3do.py | Sanity-checks every .3DO in the game: parse errors and out-of-range face indices. |
hexat.py | Hex-dump a file at a given offset — for reverse-engineering unknown fields. |
serve.py | Local HTTP server (127.0.0.1 only) — serves the project directory and the whole game install read-only, and exposes /api/list, /api/info and /api/mesh-index so the tools can browse and load files instead of requiring drag-and-drop. run.py serve / make serve. |
app/ | Studio. Vite + React shell. src/session/ holds what outlives a lens (the stage, the clock, the assembled world, the asset index, navigation); src/ui/ the docks and primitives; src/lenses/ the seven lenses, each { Main, Inspector } reacting to the selection. |
src/lib/ | Shared library — format/, render/, io/, session/, audio/, font/, catalog/, world/, morph/. Root omikron-format.js etc. re-export these so legacy HTML and test-formats.mjs keep working. |
index.html | Redirects to the Studio's Catalog lens. ?legacy=1 keeps the old overview. |
textures.html | Legacy .3DT browser. Studio: Mesh lens, Textures section. |
anim.html | Legacy morph player. Studio: Animation. |
level.html | Legacy level viewer. Studio: World. |
library.html | Legacy library. Studio: Library. |
rig.html | Legacy skeletal player. Studio: Animation. |
fnt-render.js | Barrel for src/lib/font/. |
data.html | Legacy IAM explorer. Studio: Data. |
inspect.html | Legacy file inspector. Studio: Hex. |
tool-shell.js | Legacy chrome (nav + picker). I/O now lives in src/lib/io. |
test-formats.mjs | Checks the format library against real game files. node test-formats.mjs. |
test-camera.mjs | Checks the viewport camera headlessly — geometric zoom, the zoom floor, orbit. node test-camera.mjs. |
test-render.mjs | Checks the render layer against the install — rig group coverage, pivots, hierarchy, and that a bound take moves its joints. node test-render.mjs. |
viewer.html | Legacy mesh viewer with the interactive seam editor. Studio: Mesh. Geometry now goes through buildGeometry. |
omikron-format.js | Barrel for src/lib/format/. |
mesh-render.js | Barrel for src/lib/render/mesh-render.js. |
viewer-common.js | Barrel for src/lib/render/viewer-common.js. |
omikron_formats.md | The binary format reference for the formats we read — read this before touching parser internals. |
research/ | Work in progress on the formats with no parser yet, plus the deep dives on IAM/, .3DM and the arm seam. Read research/README.md before adding to it — it records how this project has been fooled before. |
survey_formats.py | First-pass triage of unparsed formats — size, magic, text-vs-binary. python survey_formats.py [DIR]. |
legacy/ | Superseded scripts, kept for history. Not maintained — see its README. |
1,666 MB across 2,787 files, by how well it is understood:
| Status | Share | Size |
|---|---|---|
| parsed | 80.3% | 1,337 MB |
| partial | 14.7% | 245 MB |
| external (MPG, BMP, WAV, TTF) | 5.0% | 83 MB |
| unknown | 0.1% | 1.5 MB |
| Format | State |
|---|---|
.3DO meshes, .3DT textures, .3DM morph takes | solved |
.3DA skeletal animation, .WRE radar, .ADP audio, .FNT fonts | solved |
IAM/ object, scene, area and dialogue stores, *.TAG, HASHCODE.TAB | solved |
.SCX scene scripts | partial — and the big one. Header, scripts, doors, file refs and embedded media parse, but that is 94.4% of their 235 MB, and most of the rest is the per-scene header |
.CTL controllers, .ANI, .SFX effects, .MAP map screens, .OPT trajectories | partial |
.MPT, .PIX/.SIZ fonts | unread |
The live version of the first table is on the workbench home page, computed from the install rather than from this file.
CHECKLIST.md is the short version — one line per item.
research/OPEN.md is the standing manifest of what is
still unknown, ordered by payoff, and it records what has already been tried
and rejected for each — read it before starting on any of them.
See Mesh Descriptor in omikron_formats.md for full details. Three
things confirmed and implemented in omikron_3do.py / omikron-format.js:
A sub-object's pos field is a real per-part offset that must be added
to that part's vertices (they're stored local to it) -- for every
mesh, characters included. Skipping it for skeletal meshes was tried and
collapses every limb onto the origin.
A triangle/quad vertex index with its "parented" bit set belongs to an ancestor's vertex block. Which ancestor is decided once per sub-object -- the nearest one large enough for the biggest flagged index that sub-object uses -- so all of its faces borrow from the same block. Deciding per index splits a single triangle across two parts.
parent_id/child_id/sibling_id reference the descriptor's
node_id field (+0x08), not its 0-based position in the
descriptor array — the earlier assumption that they were positional
was the reason some hierarchy references appeared to point outside
the file entirely. Cross-checked across dozens of files: node_id
resolves every parent_id with a 100% match rate.
validate_3do.py reports 0 parse errors and 0 out-of-range faces across
all 635 meshes. The residual 8 files with a 1-6 vertex overshoot were fixed
by resolving flagged indices against the ancestor chain rather than falling
back to the sub-object's own block (point 2 above).
Characters still do not render as a fully closed surface -- there is a
remaining gap at the joints whose cause is not established. See
omikron_formats.md for what has been ruled out, and for a warning about two
plausible-looking metrics that each produced a confident wrong answer.
omikron_common.py as the single source for anything shared
across two or more parsers (string reading, default paths, etc). Two
format modules independently reimplementing the same struct layout is
how offset bugs get fixed in one place and silently linger in the
other — it already happened once here (.3DT's material parsing used
to duplicate .3DO's).run.py should expose every parser's read-only commands. If you
add a new format module with its own CLI, wire its info/scan
equivalents into run.py too, the same way scx-* commands do — the
goal is one entry point, not one script per format that people have to
discover individually.omikron-format.js is the JavaScript twin of omikron_3do.py. The
browser cannot run the Python, so the format algorithm necessarily exists
twice. If you change the binary format understanding, change both — nothing
catches the drift for you except validate_3do.py, and that only measures
the Python side. Keep the algorithm in that module rather than in the viewer
page so it stays portable.viewer-common.js. It is deliberately
free of anything format- or layout-specific so a second tool can import it._exports/, which is gitignored and disposable; if a
command ever defaults an output path next to its input, that is a bug.Hacker News (1)
84 commits
TypeScript
30.2%
HTML
28.5%
JavaScript
25.6%
Python
8.9%
CSS
5.3%
PowerShell
1.4%