Parse Tacview ACMI 2.1 and 2.2 recordings in Node.js or the browser.
npm install acmi-parserFor most use cases, use the one-shot parseAcmi function. It accepts ACMI text,
binary data, or a browser Blob/File, and automatically handles single-file
ZIP archives.
import { parseAcmi } from "acmi-parser";
const recording = await parseAcmi(file, {
excludedTypes: ["Weapon", "Projectile"],
});
console.log(recording.entities, recording.frames, recording.timeSpan);In Node.js, Buffer can be passed directly:
import { readFile } from "node:fs/promises";
import { parseAcmi } from "acmi-parser";
const recording = await parseAcmi(await readFile("flight.zip.acmi"));parseAcmi() and AcmiParser.parse() accept the following input directly:
| Input | Typical source |
|---|---|
string |
Uncompressed ACMI text |
ArrayBuffer |
Response.arrayBuffer() |
ArrayBufferView |
Uint8Array, DataView, or a Node.js Buffer |
Blob |
A browser Blob or File |
Binary input may contain either plain ACMI text or a ZIP archive. A ZIP archive must contain exactly one non-directory entry.
interface AcmiParserOptions {
excludedTypes?: readonly string[];
signal?: AbortSignal;
}excludedTypes removes an entity when any of its ACMI Type components matches
the list. Use "Untyped" to remove entities without a Type property.
The legacy filter and controller options remain available for compatibility,
but new code should use excludedTypes and signal.
Both parsing entry points resolve to an AcmiData instance:
class AcmiData {
isValid: boolean;
header: Header;
globalProperties: GlobalProperties;
timeSpan: TimeSpan;
entities: Map<number, Entity>;
frames: Frame[];
getFrame(time: Dayjs): Frame | undefined;
createSampledTrajectories(options?: TrajectoryOptions): Trajectories;
}The maps use numeric entity IDs. ACMI hexadecimal IDs such as a are exposed as
their numeric value, for example data.entities.get(0x0a).
isValid reports basic structural validation, including the ACMI header,
property syntax, and the reference time required by parsed frames. It is not a
schema validator for every custom property. Unsupported ACMI types or versions
return an AcmiData object with isValid === false; they do not throw.
Header field |
Type | Description |
|---|---|---|
fileType |
string |
Header FileType; supported value is text/acmi/tacview |
fileVersion |
string |
Header FileVersion; supported values are 2.1 and 2.2 |
GlobalProperties field |
Type | Description |
|---|---|---|
referenceTime |
Dayjs |
Absolute origin used for frame and entity times |
recordingTime |
Dayjs | undefined |
Recording timestamp, when provided |
dataSource |
string | undefined |
Source application or system |
dataRecorder |
string | undefined |
Recorder name |
author |
string | undefined |
Recording author |
title |
string | undefined |
Recording title |
category |
string | undefined |
Recording category |
briefing |
string | undefined |
Briefing text |
debriefing |
string | undefined |
Debriefing text |
comments |
string | undefined |
Recording comments |
referenceLongitude |
number | undefined |
Longitude offset in degrees |
referenceLatitude |
number | undefined |
Latitude offset in degrees |
additionalProps |
Map<string, string> | undefined |
Unrecognized global ACMI properties |
Day.js instances provide methods such as toISOString(), add(), and diff().
An absent or invalid reference time is represented by an invalid Day.js value;
check it with referenceTime.isValid().
data.timeSpan describes the recording range, while every Entity has its own
active range.
class TimeSpan {
start: Dayjs;
end: Dayjs;
isValid(): boolean;
duration(): number;
}duration() returns seconds, including fractional seconds, or -1 when either
endpoint is invalid.
data.entities contains metadata keyed by numeric entity ID:
class Entity {
id: number;
timeSpan: TimeSpan;
name?: string;
types?: string[];
callsign?: string;
pilot?: string;
group?: string;
country?: string;
coalition?: string;
color?: string;
}types contains the components of the ACMI Type value split on +. Entity
metadata is stored once; position and orientation over time are stored in
frames.
Each frame is a scene snapshot:
type Scene = Map<number, Transform>;
class Frame {
timeStamp: number;
scene: Scene;
}timeStamp is the number of seconds relative to
globalProperties.referenceTime. scene maps every active, non-excluded entity
ID to its transform at that frame.
class Transform {
position: Vector3;
orientation?: Euler;
}Transform components use these units:
| Value | Meaning | Unit |
|---|---|---|
position.x |
Longitude | Degrees |
position.y |
Latitude | Degrees |
position.z |
Altitude | Metres |
orientation.roll |
Roll | Radians |
orientation.pitch |
Pitch | Radians |
orientation.yaw |
Yaw | Radians |
orientation is undefined until the source supplies orientation components.
When a PGM geoid is supplied to the parser constructor, its height is added to
the ACMI altitude.
Use getFrame() with an absolute Day.js time to retrieve the most recent frame
at or before that time. It returns undefined outside the recording time span.
import dayjs from "dayjs";
const frame = recording.getFrame(dayjs("2024-01-02T03:04:10Z"));
const transform = frame?.scene.get(entityId);
if (transform) {
console.log(transform.position.x, transform.position.y, transform.position.z);
}Create an AcmiParser when parsing more than one recording or when supplying a
PGM geoid model:
function parseAcmi(
data: AcmiInput,
options?: AcmiParserOptions,
): Promise<AcmiData>;
class AcmiParser {
constructor(geoidPgm?: AcmiBinaryInput);
parse(data: AcmiInput, options?: AcmiParserOptions): Promise<AcmiData>;
}import { AcmiParser } from "acmi-parser";
const parser = new AcmiParser(geoidPgm);
const first = await parser.parse(firstFile);
const second = await parser.parse(secondFile);The geoid argument accepts ArrayBuffer or any ArrayBufferView, including a
Node.js Buffer. Parser instances are reusable sequentially; each parse() call
replaces the instance's previous parsing state. The class remains available as
the package's default export for compatibility.
Pass an AbortSignal to cancel input processing or ZIP decompression:
const controller = new AbortController();
const result = parseAcmi(file, { signal: controller.signal });
controller.abort();
await result;Compressed-input failures reject with AcmiParseError. Its code is suitable
for programmatic handling:
import { AcmiParseError, parseAcmi } from "acmi-parser";
try {
await parseAcmi(file);
} catch (error) {
if (error instanceof AcmiParseError && error.code === "INVALID_ARCHIVE") {
// Show an actionable message to the user.
}
}| Error code | Meaning |
|---|---|
INVALID_ARCHIVE |
The ZIP is unreadable or does not contain exactly one file |
UNSUPPORTED_INPUT |
A JavaScript caller supplied an unsupported runtime value |
Cancellation rejects with the AbortSignal's reason, normally an AbortError.
AcmiParseError.cause retains the underlying ZIP error when one is available.
Parsed recordings can produce regularly sampled trajectories:
const trajectories = recording.createSampledTrajectories({
sampleRate: 1,
emulateOrientation: true,
});interface TrajectoryOptions {
sampleRate?: number;
emulateOrientation?: boolean;
}
type Trajectories = Map<number, Trajectory>;
class Trajectory {
samples: TrajectorySample[];
hasOrientations(): boolean;
emulateOrientations(withRoll?: boolean): void;
}
interface TrajectorySample {
time: Dayjs;
stateVector: StateVector;
}
class StateVector {
cartesian: Vector3;
quaternion?: Quaternion;
}sampleRate defaults to 1 second and must be finite and greater than zero.
Samples whose state is unchanged are omitted, while the recording's final state
is always retained.
stateVector.cartesian is a WGS84 Earth-centred, Earth-fixed Cartesian position
in metres. When present, stateVector.quaternion contains the fixed-frame
orientation as x, y, z, and w components.
If emulateOrientation is true, orientations are derived from the
trajectory's velocity and turning motion and assigned to every sample, replacing
source orientations. Trajectory.emulateOrientations() can also perform this
operation later and mutates the trajectory's samples. Its optional withRoll
argument enables turn-based bank-angle estimation.
for (const [entityId, trajectory] of trajectories) {
for (const sample of trajectory.samples) {
const { x, y, z } = sample.stateVector.cartesian;
const orientation = sample.stateVector.quaternion;
console.log(entityId, sample.time.toISOString(), x, y, z, orientation?.w);
}
}The package provides both ESM and CommonJS builds. AcmiParser is available as
both a named and default export.
| Export | Kind | Purpose |
|---|---|---|
parseAcmi |
Function | Parse one recording with a fresh parser |
AcmiParser |
Class | Reusable parser and optional geoid configuration |
AcmiParseError |
Class | Typed compressed-input error |
AcmiData |
Class | Parsed recording and query methods |
Header |
Class | ACMI header values |
GlobalProperties |
Class | Recording-level metadata |
TimeSpan |
Class | Absolute start/end and duration helper |
Entity |
Class | Entity metadata and active time span |
Frame |
Class | Timestamped scene snapshot |
Transform |
Class | Geodetic position and optional orientation |
Trajectory |
Class | Ordered trajectory samples and orientation helpers |
StateVector |
Class | ECEF Cartesian position and optional quaternion |
AcmiInput, AcmiBinaryInput |
Types | Accepted parser inputs |
AcmiParserOptions |
Type | Filtering and cancellation options |
AcmiParseErrorCode |
Type | Stable parser error codes |
Scene |
Type | Entity-to-transform map |
TrajectoryOptions |
Type | Trajectory sampling options |
Trajectories |
Type | Entity-to-trajectory map |
TrajectorySample |
Type | Timestamped state vector |
ITrajectoryOptions and ITrajectorySample are deprecated aliases retained for
source compatibility.
Pull requests and pushes to main run the Vitest suite and build the library and
both demos. User-facing changes should include a changeset:
pnpm changesetOn main, the release workflow maintains a Changesets version pull request.
Merging that pull request publishes acmi-parser, creates the corresponding
GitHub release, and attaches npm provenance.
Repository maintainers must allow GitHub Actions to create pull requests. npm
publishing uses the trusted publisher configured for
.github/workflows/release.yml; no long-lived npm token is required. The
workflow grants the id-token: write permission needed for npm's OIDC exchange.
The browser demo is deployed to GitHub Pages after every push to main. Its
Pages build uses /acmi-parser/ as the Vite base path while local development
continues to use /.