npm.io
0.1.0 • Published 2 weeks ago

@kokomi/w3g-parser-browser

Licence
MIT
Version
0.1.0
Deps
1
Size
106 kB
Vulns
0
Weekly
0

@kokomi/w3g-parser-browser

A native JavaScript WarCraft 3 replay parser. Feed it a .w3g file, get a structure back. No Node built-ins are used, so it runs in the browser as well as in Node.

It is a port of the Java reference parser in irinabot/api/stats-api/replay-parser, and is verified against it: both implementations are run over the same replay corpus and their output is compared field by field.

Install

From the registry, if the package is published:

npm install @kokomi/w3g-parser-browser

Straight from the repository — dist/ is not committed, so the prepare script builds it during install (npm installs the dev dependencies for that automatically):

npm install git+ssh://git@github.com/kirill-782/w3g-replay-parser-browser.git
npm install github:kirill-782/w3g-replay-parser-browser#master   # or a tag/commit

From a checkout on the same machine. This symlinks the directory, so a rebuild here is picked up by the consumer immediately — handy while developing both sides:

npm install "../w3g-replay-parser-browser"   # records file:../w3g-replay-parser-browser

Or as a tarball, when the consumer must not reach the network or the repo:

npm pack                                     # -> kokomi-w3g-parser-browser-<version>.tgz
npm install ../path/to/kokomi-w3g-parser-browser-0.1.0.tgz

All four give the same package: dist/lib (CommonJS, ES2020) plus dist/types, with pako as the only runtime dependency.

Parsing a replay

import ReplayParser, { ActionParser } from "@kokomi/w3g-parser-browser";

const replay = new ReplayParser().parseReplay(fileBytes);

replay.header;             // block layout of the file
replay.subHeader;          // product, version, build, game length
replay.isReforged;         // 12 byte block headers?
replay.records.gameInfo;   // host player, game name, decoded stat string
replay.records.startInfo;  // slots, random seed, game mode
replay.records.players;    // player records
replay.records.chatMessages;
replay.records.playerLeave;
replay.records.actions;    // time slots, each with rawData
replay.records.records;    // every record in stream order
replay.records.duration;   // in-game length in milliseconds

Time slots carry the timestamp at which the slot starts; duration is the sum of all increments.

Actions

Time-slot payloads are decoded separately, because most callers only need a few action types:

const actionParser = new ActionParser();

for (const slot of replay.records.actions) {
  for (const block of actionParser.processActionData(slot.rawData)) {
    block.playerId;
    block.actions;          // decoded actions, each with a numeric `type`
    block.remainingBuffer;  // bytes that could not be decoded, normally empty
    block.error;            // set when a handler failed
  }
}

type is the action id from the replay format. Every id the reference parser knows is supported, including the UJAPI ranges:

Range Meaning
0x010x75 standard game actions
0xA0 legacy UJAPI container, subType + subAction
0xA10xB4 standalone UJAPI actions (new id = 0xA0 + old sub id)

The UJApiActionType and UJApiSubActionType enums name them.

Pass your own handlers to override or extend the table:

new ActionParser({ 0x10: (bb, actionId) => ({ type: actionId, raw: bb.readBytes(14).toBuffer() }) });
new ActionParser(undefined, { strict: true }); // rethrow instead of collecting into remainingBuffer
64 bit values

Object, widget and player handles are 64 bit. They are exposed as a Handle64 ({ low, high }, both unsigned) so that the parsed output stays JSON serialisable:

import { handle64ToHex, handle64ToBigInt } from "@kokomi/w3g-parser-browser";

handle64ToHex(action.widgetHash);    // "000003500000034e"
handle64ToBigInt(action.widgetHash); // 3642132267854n

The older two-field style (objectId1 / objectId2) is kept on the actions that already used it.

Stat strings

statString.mapPath and statString.creator are decoded as UTF-8, which is lossy for the cp1251 and GBK map names that are common in practice. mapPathRaw and creatorRaw carry the original bytes, and assemblyStatString prefers them, so a stat string round-trips byte for byte.

Save games
import { SaveGameParser } from "@kokomi/w3g-parser-browser";

const save = new SaveGameParser().parseSaveGame(fileBytes);
save.data.mapPath;
save.data.gameName;
save.data.statString;  // decoded, same shape as replay.records.gameInfo.statString
save.data.slots;
Custom decompression

pako is used by default. To use DecompressionStream, a native binding or a pre-warmed instance, pass your own:

new ReplayParser({ decompressor: (data) => myInflateRaw(data) });

Development

npm run build   # tsc -> dist/
npm test        # builds, then runs test/run.js

The test suite runs on synthetic byte fixtures taken from the reference implementation and from docs/new-actions-0xA1-0xB4.md. It additionally parses every .w3g and .w3z file in public/ if that directory exists, asserting that no action byte is left undecoded. public/ is git-ignored, so drop your own replays there.

Compatibility notes for 0.1.0

Behaviour that changed relative to 0.0.x, in each case to match the reference implementation:

  • Reforged detection. isReforged now tests the build number (>= 6089, excluding 52240) instead of version > 31. The old test misclassified 1.31 replays (build 6072, version 10031) as Reforged and failed to parse them at all.
  • Time slot timestamps. A time slot is now stamped with the time it starts at. Previously the increment was added first, so every timestamp — including those on chat and leave records — was one increment too late.
  • Action ids 0x65 / 0x66 / 0x67. Previously 0x65 was unregistered, and 0x66 and 0x67 reported type 0x65 and 0x66. Each id now reports itself.
  • Action ids 0x29 and 0x2f are decoded instead of truncating the rest of the command block. 0x2f was unregistered and 0x29 was registered but always threw. (0x29 is a zero-body cheat action that the Java reference table also omits — this is the one place the port deliberately covers more than the reference.)
  • UJAPI 0xA0. The sub-action table now matches the reference (0x040x14). The previous build only recognised sub id 0x02, which does not exist in the protocol, so no real replay ever decoded. The result shape is now { type, subType, subAction }.
  • Target coordinates are floats. Actions 0x11, 0x12, 0x13, 0x14 and the minimap ping 0x68 read their coordinates with readFloat32 instead of readUint32. The bytes are IEEE-754 world coordinates, so the previous values were raw bit patterns: a click at -2144 was reported as 3305504768. Verified against a 13 replay corpus — 19 766 coordinates, all of them inside the map bounds implied by the stat string, versus none before. Note the map bounds are not a hard limit: the camera bounds are wider than the playable area, so a legitimate order can land outside.
  • Record 0x23 is decoded. GHost calls it REPLAY_DESYNC; it is an 11 byte record that appears just before a LeaveGame record. It used to hit the default branch of the record switch and throw Unknown recordId 35, which is not a BufferUnderflowError and therefore aborted the whole parse — one rare record cost you the entire replay. The four fields are exposed raw, since the format documentation never established what they mean.
  • Reforged player profiles are decoded. A 0x39 record with subType === 3 carries a protobuf message with the player id, the Battle.net tag and the portrait id. It is now exposed as record.profile (rawData is still there) so consumers get Stormrage55#2459 instead of a byte array. The message is walked directly rather than pulling in a protobuf runtime, and an unreadable payload leaves profile undefined instead of failing the parse. Reforged repeats the record whenever a profile changes, so the last one for a player wins.
  • UTF-8 BOM. A leading BOM in a string is preserved instead of being silently dropped.
  • Signedness. Hashtable keys, frameEventId and handle-typed sync values are read as signed int32, matching the reference. Previously the legacy 0xA0 sub-actions reported them unsigned while the equivalent standalone actions reported them signed, so the same hashtable cell did not compare equal across the two encodings.
  • ActionCommandBlock.remainingBuffer is the new spelling; remaingBuffer is kept as an alias.
  • ByteBuffer.ensureCapacity(n) now takes an absolute size rather than a count relative to offset. The two differ only when offset is non-zero.
  • Build target is ES2020.

Error handling

A replay that cannot be decoded raises, rather than returning a plausible-looking empty result:

  • A malformed game stat string is a hard error. Previously it was mistaken for "this record is not complete yet", and parseReplay returned zero records and no gameInfo with no error at all.
  • parseBlocks raises if it consumed every block and decoded no records.

Inside a time slot, decoding is best-effort, because one bad command block should not cost you the rest of the replay:

  • An unknown action id, a handler that fails, or a block that declares more bytes than are present leaves the undecoded bytes in remainingBuffer and sets error. A trailing fragment too short to be a block header comes back the same way instead of throwing.
  • Pass { strict: true } to rethrow instead. The thrown ActionFormatError carries playerId, actionId, cause and the blocks decoded so far.

ByteBuffer raises BufferUnderflowError (a RangeError subclass) when a read needs more bytes than are present; the record layer uses it to mean "wait for the next block".

Known gaps

Two action ids appear in real replays that neither this parser nor the Java reference decodes: 0x7B (mostly in Reforged replays) and 0x78. They surface as remainingBuffer content rather than being silently skipped. Across a 189 replay corpus they account for ~21 KB of undecoded bytes, all in two files.

AvailableActionData lists every action shape, but ActionCommandBlock.actions is typed ActionData[], whose index signature means switch (action.type) does not narrow. Cast to the specific interface when you need the fields typed.