razodactyl/mod-omikron-tools

0

stars

84

commits

TypeScript

primary language

Aug 24, 2026

updated

README

Omikron: The Nomad Soul — Modding Tools

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.

The level viewer showing Anekbah, assembled from its geometry and scene script

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.

Quickstart

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.

What is in it

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       │                  │
└──────────────┴──────────────────────────────────────────────┴──────────────────┘
  • The selection is tiered — game › district › level › character (and mesh, take, document, store record, file) — and it is the breadcrumb in the header. Every lens shows that: pick KAY in Anekbah, switch to Animation, and you get KAY's takes; switch to Mesh and you get KAY's mesh.
  • One viewport for the session. Lenses draw into layers of a single renderer; switching lens hides the others' layers and remembers each camera. The world stays loaded while you look at a character.
  • One Scene hierarchy and one Assets browser, the same in every lens. Tick a level to draw it; double-click anything to open it in the lens that fits.
  • One clock. The Timeline in the bottom dock is the only transport; Space and the arrow keys reach it from anywhere, and leaving a lens takes its take off the clock (no voice playing into a lens that cannot show it).
  • One camera, two habits. Left drag either looks around (fly) or swings around the framed point (orbit) — the button at the top left of the viewport switches, and the pivot is whatever you last framed. The wheel zooms geometrically, by a percentage of the distance to that pivot, so it stays fine when you are on a face and coarse when you are over a district (shift for bigger steps, alt for smaller).
  • Ctrl+K searches levels, characters, documents and files by name, and runs commands. 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.

The morph player running a facial capture on the character it was recorded forMorph — 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.
The library reading a document in the game's own bitmap fontsLibrary — 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.
The texture browser showing a decoded 3DT setTextures — 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).

Finding the game

Worked out automatically on first use, in this order:

  1. the OMIKRON_ROOT environment variable
  2. .omikron-game-path, written the first time you choose a folder
  3. a Steam scan — registry install path, the usual defaults, and every library in libraryfolders.vdf
  4. asking, with a folder picker when one is available

To 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/.

Layout

PathPurpose
run.pyUnified CLI — the entry point for most tasks.
omikron_common.pyShared 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.pyExports 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.pySanity-checks every .3DO in the game: parse errors and out-of-range face indices.
hexat.pyHex-dump a file at a given offset — for reverse-engineering unknown fields.
serve.pyLocal 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.htmlRedirects to the Studio's Catalog lens. ?legacy=1 keeps the old overview.
textures.htmlLegacy .3DT browser. Studio: Mesh lens, Textures section.
anim.htmlLegacy morph player. Studio: Animation.
level.htmlLegacy level viewer. Studio: World.
library.htmlLegacy library. Studio: Library.
rig.htmlLegacy skeletal player. Studio: Animation.
fnt-render.jsBarrel for src/lib/font/.
data.htmlLegacy IAM explorer. Studio: Data.
inspect.htmlLegacy file inspector. Studio: Hex.
tool-shell.jsLegacy chrome (nav + picker). I/O now lives in src/lib/io.
test-formats.mjsChecks the format library against real game files. node test-formats.mjs.
test-camera.mjsChecks the viewport camera headlessly — geometric zoom, the zoom floor, orbit. node test-camera.mjs.
test-render.mjsChecks the render layer against the install — rig group coverage, pivots, hierarchy, and that a bound take moves its joints. node test-render.mjs.
viewer.htmlLegacy mesh viewer with the interactive seam editor. Studio: Mesh. Geometry now goes through buildGeometry.
omikron-format.jsBarrel for src/lib/format/.
mesh-render.jsBarrel for src/lib/render/mesh-render.js.
viewer-common.jsBarrel for src/lib/render/viewer-common.js.
omikron_formats.mdThe 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.pyFirst-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.

Format coverage

1,666 MB across 2,787 files, by how well it is understood:

StatusShareSize
parsed80.3%1,337 MB
partial14.7%245 MB
external (MPG, BMP, WAV, TTF)5.0%83 MB
unknown0.1%1.5 MB
FormatState
.3DO meshes, .3DT textures, .3DM morph takessolved
.3DA skeletal animation, .WRE radar, .ADP audio, .FNT fontssolved
IAM/ object, scene, area and dialogue stores, *.TAG, HASHCODE.TABsolved
.SCX scene scriptspartial — 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 trajectoriespartial
.MPT, .PIX/.SIZ fontsunread

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.

Resolved: sub-object hierarchy, position offsets, and parented vertices

See Mesh Descriptor in omikron_formats.md for full details. Three things confirmed and implemented in omikron_3do.py / omikron-format.js:

  1. 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.

  2. 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.

  3. 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.

Notes for contributors

  • Keep 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.
  • Shared viewer plumbing belongs in viewer-common.js. It is deliberately free of anything format- or layout-specific so a second tool can import it.
  • Never write into the game install. It is read-only input. Everything generated belongs in _exports/, which is gitignored and disposable; if a command ever defaults an output path next to its input, that is a bug.
  • Commit as you go.

Contributors

razodactyl

84 commits

razodactyl/mod-omikron-tools

0

stars

84

commits

TypeScript

primary language

Aug 24, 2026

updated

README

Omikron: The Nomad Soul — Modding Tools

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.

The level viewer showing Anekbah, assembled from its geometry and scene script

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.

Quickstart

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.

What is in it

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       │                  │
└──────────────┴──────────────────────────────────────────────┴──────────────────┘
  • The selection is tiered — game › district › level › character (and mesh, take, document, store record, file) — and it is the breadcrumb in the header. Every lens shows that: pick KAY in Anekbah, switch to Animation, and you get KAY's takes; switch to Mesh and you get KAY's mesh.
  • One viewport for the session. Lenses draw into layers of a single renderer; switching lens hides the others' layers and remembers each camera. The world stays loaded while you look at a character.
  • One Scene hierarchy and one Assets browser, the same in every lens. Tick a level to draw it; double-click anything to open it in the lens that fits.
  • One clock. The Timeline in the bottom dock is the only transport; Space and the arrow keys reach it from anywhere, and leaving a lens takes its take off the clock (no voice playing into a lens that cannot show it).
  • One camera, two habits. Left drag either looks around (fly) or swings around the framed point (orbit) — the button at the top left of the viewport switches, and the pivot is whatever you last framed. The wheel zooms geometrically, by a percentage of the distance to that pivot, so it stays fine when you are on a face and coarse when you are over a district (shift for bigger steps, alt for smaller).
  • Ctrl+K searches levels, characters, documents and files by name, and runs commands. 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.

The morph player running a facial capture on the character it was recorded forMorph — 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.
The library reading a document in the game's own bitmap fontsLibrary — 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.
The texture browser showing a decoded 3DT setTextures — 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).

Finding the game

Worked out automatically on first use, in this order:

  1. the OMIKRON_ROOT environment variable
  2. .omikron-game-path, written the first time you choose a folder
  3. a Steam scan — registry install path, the usual defaults, and every library in libraryfolders.vdf
  4. asking, with a folder picker when one is available

To 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/.

Layout

PathPurpose
run.pyUnified CLI — the entry point for most tasks.
omikron_common.pyShared 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.pyExports 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.pySanity-checks every .3DO in the game: parse errors and out-of-range face indices.
hexat.pyHex-dump a file at a given offset — for reverse-engineering unknown fields.
serve.pyLocal 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.htmlRedirects to the Studio's Catalog lens. ?legacy=1 keeps the old overview.
textures.htmlLegacy .3DT browser. Studio: Mesh lens, Textures section.
anim.htmlLegacy morph player. Studio: Animation.
level.htmlLegacy level viewer. Studio: World.
library.htmlLegacy library. Studio: Library.
rig.htmlLegacy skeletal player. Studio: Animation.
fnt-render.jsBarrel for src/lib/font/.
data.htmlLegacy IAM explorer. Studio: Data.
inspect.htmlLegacy file inspector. Studio: Hex.
tool-shell.jsLegacy chrome (nav + picker). I/O now lives in src/lib/io.
test-formats.mjsChecks the format library against real game files. node test-formats.mjs.
test-camera.mjsChecks the viewport camera headlessly — geometric zoom, the zoom floor, orbit. node test-camera.mjs.
test-render.mjsChecks the render layer against the install — rig group coverage, pivots, hierarchy, and that a bound take moves its joints. node test-render.mjs.
viewer.htmlLegacy mesh viewer with the interactive seam editor. Studio: Mesh. Geometry now goes through buildGeometry.
omikron-format.jsBarrel for src/lib/format/.
mesh-render.jsBarrel for src/lib/render/mesh-render.js.
viewer-common.jsBarrel for src/lib/render/viewer-common.js.
omikron_formats.mdThe 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.pyFirst-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.

Format coverage

1,666 MB across 2,787 files, by how well it is understood:

StatusShareSize
parsed80.3%1,337 MB
partial14.7%245 MB
external (MPG, BMP, WAV, TTF)5.0%83 MB
unknown0.1%1.5 MB
FormatState
.3DO meshes, .3DT textures, .3DM morph takessolved
.3DA skeletal animation, .WRE radar, .ADP audio, .FNT fontssolved
IAM/ object, scene, area and dialogue stores, *.TAG, HASHCODE.TABsolved
.SCX scene scriptspartial — 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 trajectoriespartial
.MPT, .PIX/.SIZ fontsunread

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.

Resolved: sub-object hierarchy, position offsets, and parented vertices

See Mesh Descriptor in omikron_formats.md for full details. Three things confirmed and implemented in omikron_3do.py / omikron-format.js:

  1. 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.

  2. 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.

  3. 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.

Notes for contributors

  • Keep 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.
  • Shared viewer plumbing belongs in viewer-common.js. It is deliberately free of anything format- or layout-specific so a second tool can import it.
  • Never write into the game install. It is read-only input. Everything generated belongs in _exports/, which is gitignored and disposable; if a command ever defaults an output path next to its input, that is a bug.
  • Commit as you go.

See what people are saying

Contributors

razodactyl

84 commits

Languages

TypeScript

30.2%

HTML

28.5%

JavaScript

25.6%

Python

8.9%

CSS

5.3%

PowerShell

1.4%