@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 |
|---|---|
0x01–0x75 |
standard game actions |
0xA0 |
legacy UJAPI container, subType + subAction |
0xA1–0xB4 |
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.
isReforgednow tests the build number (>= 6089, excluding 52240) instead ofversion > 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
0x65was unregistered, and0x66and0x67reportedtype0x65and0x66. Each id now reports itself. - Action ids 0x29 and 0x2f are decoded instead of truncating the rest of the command block.
0x2fwas unregistered and0x29was registered but always threw. (0x29is 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 (
0x04–0x14). The previous build only recognised sub id0x02, 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,0x14and the minimap ping0x68read their coordinates withreadFloat32instead ofreadUint32. The bytes are IEEE-754 world coordinates, so the previous values were raw bit patterns: a click at-2144was reported as3305504768. 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
0x23is decoded. GHost calls itREPLAY_DESYNC; it is an 11 byte record that appears just before aLeaveGamerecord. It used to hit thedefaultbranch of the record switch and throwUnknown recordId 35, which is not aBufferUnderflowErrorand 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
0x39record withsubType === 3carries a protobuf message with the player id, the Battle.net tag and the portrait id. It is now exposed asrecord.profile(rawDatais still there) so consumers getStormrage55#2459instead of a byte array. The message is walked directly rather than pulling in a protobuf runtime, and an unreadable payload leavesprofileundefined 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,
frameEventIdand handle-typed sync values are read as signed int32, matching the reference. Previously the legacy0xA0sub-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.remainingBufferis the new spelling;remaingBufferis kept as an alias.ByteBuffer.ensureCapacity(n)now takes an absolute size rather than a count relative tooffset. The two differ only whenoffsetis 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
parseReplayreturned zero records and nogameInfowith no error at all. parseBlocksraises 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
remainingBufferand setserror. 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 thrownActionFormatErrorcarriesplayerId,actionId,causeand 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.