Reader and writer for Claris FileMaker (.fmp12) files
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Romein van Buren c4149728f6 Simplify and make the code more idiomatic
No behavior changes. Each item says what changed and why.

chunk.rs
- Add Decoded::data/path_push/key_value/one_byte. Every branch of
  Chunk::decode built the same struct by hand; the branches still mirror
  the spec one for one, with far less noise.
- Read lengths with len_at/wide_len_at instead of `as usize`. The casts
  could truncate on 32-bit targets; oversized lengths now return
  MalformedChunk.
- Rewrite parse_all as a `while let [first, second, ..]` loop. The second
  stop check after each chunk was already covered by the loop condition.
  Add a test for a single trailing zero byte.

dictionary.rs
- apply_chunks takes chunks by value (impl IntoIterator). Values are moved
  into the tree instead of cloned; the caller doesn't use them afterwards.
- KeyValue chunks push/pop the path instead of allocating a new Vec per
  chunk.
- children() returns an iterator that borrows only the dictionary, not the
  path argument, so callers can pass a temporary path.
- Add Entry::child_or_empty to replace three copies of
  `.get().map_or(&[][..], Entry::value_or_empty)`.

table.rs
- new_record uses entry().or_insert() instead of insert + get +
  expect("just inserted"). Library code shouldn't panic, and this also
  drops the `# Panics` doc gap.
- last_record_id comes from the records already read instead of walking
  the dictionary a second time.
- Replace RECORDS_SUFFIX[0] with RECORDS_KEY and build paths with
  .concat() instead of chain/collect.
- Use to_owned() for &str -> String, as the rest of the crate does.

calculation.rs
- Names::read builds the field map with flat_map instead of a nested loop
  that inserts into a mutable HashMap.
- Function::is_single_byte reuses the binary-searched name() instead of a
  linear scan over the same table.

script.rs
- Add Target::describe and use it in ScriptStep::details. At most one
  target applies, so five Option accessors chained together were
  needlessly roundabout.
- read_name builds its path with .concat().

scsu.rs
- Name the 0xFFFD marker REPLACEMENT instead of repeating the literal.
- Add next_pair for the repeated `(iter.next(), iter.next())` matches.
- Flatten push_code_point from a nested match to if / else if / else.

file.rs, sector.rs
- Header and payload buffers are stack arrays, not heap Vecs; their size
  is a constant.
- Check the magic with starts_with instead of slicing and comparing.
- read_dictionary accepts any Read + Seek, not only File.

fmp12-cli, tests
- show_script matches Format::Json explicitly instead of `_`, so a new
  format variant forces this match to be updated.
- Pass Column::name and ToString::to_string directly instead of
  redundant closures.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 11:51:49 +01:00
docs/fmp12 Read the text of comments and blank lines in script steps 2026-10-27 11:24:00 +01:00
fmp12-cli Simplify and make the code more idiomatic 2026-10-27 11:51:49 +01:00
src Simplify and make the code more idiomatic 2026-10-27 11:51:49 +01:00
tests Simplify and make the code more idiomatic 2026-10-27 11:51:49 +01:00
.gitignore Add private folder 2026-10-26 09:18:08 +01:00
AGENTS.md Update AGENTS.md 2026-10-27 11:16:07 +01:00
Cargo.lock Add fmp12-cli: a command-line tool for reading .fmp12 files 2026-10-27 09:29:01 +01:00
Cargo.toml Keep line breaks in csv/json output and simplify fmp12-cli 2026-10-27 09:39:54 +01:00
LICENSE.md Add readme and license 2026-10-25 17:06:36 +01:00
README.md Make script line numbers optional (-n) and move step formatting into the crate 2026-10-27 11:38:19 +01:00

fmp12

A Rust library for reading Claris FileMaker Pro .fmp12 database files, without FileMaker.

FileMaker's .fmp12 format is proprietary and (intended to be :p) undocumented. If you have a file but no FileMaker license, or you want to pull data into another tool, there is little to reach for. This crate decodes the format by building upon prior community efforts (fmptools, fmplib, and fp5dump).

Point it at a file and you get the tables, the field definitions, the record data, the scripts with their steps, and the formulas of calculation fields and script steps. It is read-only and still early: it covers the core of a database (tables, fields, records), the outline of scripts, and calculations. More features are on the way!

If your file fails to parse, please let me know.

use fmp12::FmpFile;

fn main() -> Result<(), fmp12::FmpError> {
    let file = FmpFile::open("database.fmp12")?;

    if let Some(table) = file.table("Contacts") {
        for record in table.records() {
            println!("{:?}", record.value(table, "Name"));
        }
    }
    Ok(())
}

Contents

Installing

The crate is named fmp12, MIT licensed, and uses Rust edition 2024 (so it needs a recent Rust toolchain). It is not yet published to crates.io, but it can be installed directly from the git repo:

[dependencies]
fmp12 = { git = "https://forge.smartyellow.nl/romein/fmp" }

Command line

The fmp12-cli crate in this repo builds an fmp12 command that looks inside a file from the terminal. It is a separate crate, so the library itself has no command-line dependencies.

cargo install --git https://forge.smartyellow.nl/romein/fmp fmp12-cli   # or: cargo run -p fmp12-cli -- <command>
fmp12 info database.fmp12                        # creator, version date, size, counts
fmp12 tables database.fmp12                      # id, name, number of fields and records
fmp12 fields database.fmp12 Contacts             # type, data type, storage, auto-enter, calculation
fmp12 records database.fmp12 Contacts -l 10      # the first 10 records
fmp12 scripts database.fmp12                     # id, name and number of steps
fmp12 scripts database.fmp12 "Script 1"          # the steps of one script
fmp12 scripts database.fmp12 "Script 1" -n       # ... with line numbers

Output is text for reading in a terminal: a table, labelled lines for info, and for one script a line per step as in FileMaker's script editor, numbered with -n/--number (Set Variable [ $i ; 0 ; 1 ], with a // in front of a disabled step, # text for a comment and an empty line for a blank line). Add -j/--json to any of these commands to get minified JSON instead: an array with one object per row, or for info a single object. JSON values are strings, as in the library, except the booleans Enabled (of a script step) and Indexed (of a field); a record with nothing stored for a column has null. A value with line breaks (a formula, say) is shown on one line in the text, with ↵ for each break, and kept as it is in JSON.

Records come out in record order and tables in id order, so the output is the same on every run. A script step of a type the crate doesn't recognise is listed as Unknown (opcode) and warned about on stderr. Errors go to stderr with a non-zero exit code; an unknown table or script name lists the names that do exist.

Usage

Everything starts with FmpFile::open, which reads the whole file into memory and parses it.

File information

let file = fmp12::FmpFile::open("database.fmp12")?;

file.file_size();     // size on disk, in bytes
file.creator_name();  // the app that created it, e.g. "Pro 12.0"
file.version_date();  // a chrono::NaiveDate: the FileMaker version's save date

Tables

for table in file.tables() {
    println!("{} (id {})", table.name(), table.id());
}

let contacts = file.table("Contacts");   // Option<&Table>, looked up by name

Columns (fields)

let table = file.table("Contacts").unwrap();

for column in table.columns() {
    println!("{}: {:?}", column.name(), column.data_type());
}

let column = table.column("Email").unwrap();
column.field_type();    // FieldType: Simple, Calculation, Summary
column.data_type();     // DataType: Text, Number, Date, Time, Timestamp, Container
column.storage_type();  // FieldStorageType: Regular, Global, Calculation, UnstoredCalculation
column.auto_enter();    // AutoEnterOption: serial number, creation/modification stamps, calculations, ...
column.repetitions();   // how many repetitions the field has (1 for a normal field)
column.indexed();       // whether the field is always indexed
column.calculation();   // Option<&Calculation>: the formula of a calculation or auto-enter calculation field

Anything the crate doesn't recognise comes back as an Unknown(u8) variant carrying the raw byte, so a file from a newer FileMaker version still opens.

Records

// All records. The order is not defined.
for record in table.records() {
    let name: Option<&str> = record.value(table, "Name");
}

// One record by its index. Indexes start at 1.
let first = table.record(1);

record.value(table, "Column") returns None when the column doesn't exist or the record has no value stored for it. Values are always returned as strings, including numbers, dates and timestamps; the crate does not convert them to typed values.

Scripts

for script in file.scripts() {          // ordered by id
    println!("{} (id {})", script.name(), script.id());

    for step in script.steps() {        // in execution order
        println!("  {:?} (opcode {})", step.name(), step.opcode());
    }
}

let script = file.script("Script 1");   // Option<&Script>, looked up by name

Each ScriptStep has:

step.index();         // u16: the step's index, which keys its arguments in the file
step.opcode();        // u8: the step type's numeric id (e.g. 141 = Set Variable)
step.name();          // Option<&'static str>: "Set Variable", ...; None for an unrecognised opcode
step.is_enabled();    // false for a step that is commented out (disabled) in the script
step.calculations();  // &[Calculation]: the formulas among the step's arguments
step.variable();      // Option<&str>: the variable of Set Variable / Insert from URL, e.g. "$i"
step.comment();       // Option<&str>: the text of a Blank Line/Comment step ("" for a blank line)
step.field();         // Option<&FieldRef>: the field a Set Field step sets
step.layout();        // Option<&Reference>: the layout a Go to Layout step goes to (id and name)
step.script();        // Option<&Reference>: the script a Perform Script step performs (id and name)
step.record();        // &[u8]: the step's raw 28-byte record
step.display_name();  // String: the name, or "Unknown (opcode)" for an unrecognised step
step.details();       // String: the arguments above, separated by " ; "
step.line();          // String: the step as in FileMaker's script editor, e.g. `Set Variable [ $i ; 0 ; 1 ]`

The outline of a script is read (which steps it has, what type each one is, and whether it is enabled), the calculations in the steps' arguments, and what some steps point at: the variable of Set Variable and Insert from URL, the text of comments, the field of Set Field, the layout of Go to Layout and the script of Perform Script. These accessors return None for any other step type. The other arguments (dialog text, options, sort orders) are not decoded yet; record() is the raw data the step's type is derived from. calculations() lists a step's formulas in the order of its arguments, without saying which argument each is; for Set Variable that is the value, then the repetition. line() writes a step as FileMaker's script editor does, without a line number: the name and the arguments in brackets, // in front of a disabled step, # text for a comment and an empty line for a blank line. Script folders and separators haven't been verified against a real file, so they may show up as scripts with an empty name.

Calculations

let table = file.table("Rooms").unwrap();
let formula = table.column("RemainingDays").unwrap().calculation().unwrap();

println!("{formula}");           // RemainingHours / 24

for token in formula.tokens() {  // in order, white space included
    match token {
        fmp12::Token::Field(field) => println!("{:?}::{:?}", field.table(), field.field()),
        fmp12::Token::Function(function) => println!("{:?}", function.name()),
        _ => {}
    }
}

Display writes the formula back as the text FileMaker shows, with every space and line break, FileMaker's English function names, and field references with the names they have in the file. tokens() gives it as a list of Tokens: Whitespace, Text (unescaped), Number (as decimal text), Name (such as a Let variable), Variable ($x), Field, Function, Operator and Unknown. Fields are Option<&str> names because a reference whose table or field can't be found is written as Table#3d00001::Field#17.

Only the functions and operators that occur in the test files are named; any other function is a Function whose name() is None (and whose code() you can still use), and an unrecognised byte is a Token::Unknown. Comments in formulas are not decoded yet. Only the formulas of fields and script steps are read, not those of layouts.

Adding records (in memory only)

let mut file = fmp12::FmpFile::open("database.fmp12")?;
let table = file.table_mut("Contacts").unwrap();

let new_index = table
    .new_record([("Name", "Ada Lovelace"), ("Email", "ada@example.com")])?
    .index();

This adds the record to the in-memory Table only. Nothing is written back to the file. An unknown column name returns FmpError::UnknownColumn.

Text

FileMaker stores strings obfuscated with a simple XOR (0x5A) and compressed with the Standard Compression Scheme for Unicode (SCSU). This crate undoes the XOR and decompresses the SCSU, so text in any script comes out as regular UTF-8 strings. Malformed sequences are replaced with U+FFFD rather than failing the read.

Errors

Every fallible call returns FmpError:

Variant Meaning
BadMagic The file does not start with the .fmp12 signature, so it is probably not an .fmp12 file.
BadHeader The header couldn't be parsed (e.g. an unreadable version date).
BadSectorHeader The first sector claims a previous sector, which should never happen.
SectorOutOfRange(id) The sector chain points past the end of the file.
UnknownChunkCode(code) A data chunk uses a code this crate can't decode.
MalformedChunk A chunk's declared length doesn't match the data after it.
UnexpectedEof The file ends before the format says it should.
UnknownColumn { table, column } new_record was given a column the table doesn't have.
Io(..) The file couldn't be read.

Roadmap

  • File header: creator app, version date, size
  • Tables and their names
  • Field definitions: type, data type, storage, auto-enter, repetitions, indexing
  • Record values, as text
  • Scripts: names, and each step's type, index and whether it is enabled
  • Calculations: the formulas of calculation fields, auto-enter calculations and script step arguments, as tokens
  • What script steps point at: variables, comment text, the Set Field field, the layout of Go to Layout, the script of Perform Script
  • Adding records to an in-memory table
  • A command-line tool (fmp12-cli) to list tables, fields, records and scripts
  • Forward compatibility: unrecognised values come back as Unknown(..) variants

Next up

  • The remaining script step arguments (dialog text and buttons, options such as Set Error Capture on/off, sort orders, ...)
  • The rest of the functions and operators of calculations (only those in the test files are named), comments in formulas, and a tree of the formula instead of a list of tokens
  • Calculations in layouts (conditional formatting, hide conditions) and in field validation

Later

  • Relationships and table occurrences
  • Layouts
  • Value lists
  • Field validation rules
  • Container data (images, files): the field type is reported today, but not its contents
  • Script folders and separators, and empty scripts (not yet checked against a real file)
  • Writing or saving changes back to a file

Non-goals

  • The older .fp3/.fp5 formats, which are structured differently

How it works

You don't need this section to use the library, but it helps when something fails to parse.

An .fmp12 file is a chain of 4096-byte sectors:

  1. The header (sector 0) starts with a magic number plus the text HBAM7, and holds the creator app name and the version date. A leading sector 1 is unused.
  2. Body sectors form a linked list: each says which sector comes next, and they may be stored out of order. FmpFile::open follows the chain from the start.
  3. Each sector's payload is a stream of small encoded chunks: push or pop a path segment, store a value at the current path, store a key/value pair, or store one segment of a large value.
  4. Replaying every chunk builds a dictionary: a tree, like a tiny file system, where a path such as [3].[16].[5] holds the table definitions. Values are plain bytes until something interprets them.
  5. The table reader looks at well-known paths in that tree, decodes the text, reads each field's flag bytes into the FieldType/DataType/etc. enums, and gathers the records. The script reader does the same for [17].[5]: it splits each script's step code into 28-byte records and reads the step type (negative for a disabled step) from bytes 20–21, and the layout, script or field a step points at from bytes 6 onward. The calculation reader turns the formula stored at a field's or a step argument's calculation node into tokens: text, numbers, field references (resolved to names through the table occurrences), variables, functions and operators.

Only the final step's results are public API. The earlier layers are internal to the crate.

Project layout

fmp12-cli/        the `fmp12` command-line tool (its own crate; depends on this one)
  src/main.rs     entry point and error reporting
  src/cli.rs      the arguments and subcommands (clap)
  src/commands.rs one function per subcommand
  src/render.rs   text tables and minified JSON output
  tests/cli.rs    runs the binary against the fixtures
src/
  lib.rs          crate docs and public exports
  file.rs         FmpFile: opening a file, header parsing, walking the sector chain
  sector.rs       reading one 4096-byte sector and its chunks
  chunk.rs        decoding the variable-length chunk encoding
  dictionary.rs   the path-addressed tree that chunks are replayed into
  table.rs        Table, Column, Record and the field-flag enums
  script.rs       Script, ScriptStep and the step type names
  calculation.rs  Calculation, Token and the decoding of formulas
  bytes.rs        big-endian integers and the XOR text decoding
  scsu.rs         SCSU (Unicode compression) decoder
  error.rs        FmpError
tests/
  open_file.rs    integration tests against a real file
  calculations.rs integration tests for calculations and script steps, checked against the XML export
  fixtures/       Untitled.fmp12 (a small FileMaker 12 sample database)
                  Scripts.fmp12 (one script: a Show Custom Dialog step among comments and blank lines),
                  Roommatch.fmp12 (two tables, 304 records, four scripts),
                  and Roommatch_fmp12.xml (FileMaker's own XML export of it, UTF-16)
docs/fmp12/
  README.md       index of our notes on the file format, and how far to trust them
  file.md         header and sectors
  chunks.md       chunk encoding, integers and text
  dictionary.md   the path tree and a map of known paths
  tables.md       tables, fields and records
  scripts.md      scripts and script steps
  calculations.md calculation formulas and how they are stored
  _fmplib.md      community notes on the format (Rasmus20B), unmodified
  _fmptools.txt   community notes on the format (Evan Miller), unmodified

Development

cargo build
cargo test      # unit tests, integration tests against the fixture, and the doc example
cargo clippy

The repo is a Cargo workspace of the library and fmp12-cli; these commands cover both. Add -p fmp12 or -p fmp12-cli to pick one. fmp12-cli/tests/cli.rs runs the fmp12 binary against the fixtures below.

The integration tests open tests/fixtures/Untitled.fmp12, a one-table database with three records, and check the header, the table and column metadata, record reading, and in-memory record creation. Scripts are tested against tests/fixtures/Scripts.fmp12, and tests/fixtures/Roommatch.fmp12, a larger two-table database, checks that a file with long key-value chunks opens and reads its tables and scripts. tests/calculations.rs compares every script's steps, what they point at, and every formula the crate reads from Roommatch with tests/fixtures/Roommatch_fmp12.xml, the XML that FileMaker (Save a Copy as XML) wrote for the same file.

Because the format is reverse-engineered, chunk decoding in chunk.rs mirrors the community notes byte for byte. When a real-world file fails to parse, the docs/fmp12/ notes are the first place to look for what the format does there.

About

Thanks for your interest in fmplib! In case you have any questions, ideas, or remarks, please feel free to drop a line to romein@smartyellow.nl. Also do let me know what you're using fmplib for.

Released under the MIT License. Copyright © 2025-present Romein van Buren.