Tag files describe themselves
Every shipped tag carries a blay layout section holding its own field names, type names, and enum option names. The definitions for all 101 tag groups are recoverable from the shipped data alone, without touching the engine binary.
Why this matters
The expected route to a tag editor was recovering field definitions from HaloSimulation_tag_release.dll, since Blam tag builds historically embed their definition tables. That work is no longer on the critical path. The strings below came out of a shipped weapon tag, and they are the same human-readable names Guerilla displayed:
Because each tag carries its own layout, a reader can be entirely generic. No hand-written per-group parsers and no hardcoded offsets are required, which is the failure mode that makes most Halo tag tooling brittle across builds.
One shape, all the way down
The entire tag body is built from a single repeating shape: a 12-byte header of a four-CC magic, a version, and a content size, followed by that many bytes of content. The size excludes the header. Sections chain as siblings and nest as children at every level, so one generic walker reads the whole file.
| Section | Kind | Contents | Evidence |
|---|---|---|---|
| blay | layout | The tag's own field definitions | Verified |
| tgly | container | Holds every definition table | Verified |
| str* | blob | NUL-separated UTF-8 strings, referenced by byte offset | Verified |
| options | table | String offsets, one per enum or bitfield option | Verified |
| tgft | table | Types: name, on-disk size, composite flag | Verified |
| gras | table | Fields: name, type index | Verified |
| blv2 | table | Blocks: name, maximum element count | Verified |
| stv4 | table | Structs: 16-byte GUID and name | Verified |
| bdat | data | The tag's actual field values | Verified |
| tgbl | container | Wraps the data payload | Verified |
Strings are referenced by byte offset into the blob rather than by index, and an offset pointing at a NUL resolves to the empty string, which the data uses for unnamed fields such as padding. The blob is byte-packed, so nothing is reliably dword aligned.
A group decoded end to end
camera_track is the smallest group and decodes completely. This is the real Halo definition, including the engine's actual 16 control-point limit. Terminator fields delimit struct boundaries, so the field list is a flattened tree rather than a nested one.
types: [0] block 12b composite
[1] real vector 3d 12b
[2] real quaternion 16b
[3] terminator X 0b
fields: +0 12b real vector 3d position
+12 16b real quaternion orientation
+28 0b terminator X <unnamed>
+28 12b block control points
+40 0b terminator X <unnamed>
blocks: camera_track_control_point_block max 16
camera_track_block max 1Definition size by group
All 101 groups parse. Groups with no enums or bitfields carry an empty option table.
| Group | Four-CC | Strings | Fields |
|---|---|---|---|
| character | char | 1,038 | 1,248 |
| biped | bipd | 1,031 | 582 |
| weapon | weap | 783 | 503 |
| chud_definition | chdt | 899 | 612 |
| collision_model | coll | 270 | 388 |
| camera_track | trak | 10 | 5 |
What is not solved yet
Zero-size types carry payload elsewhere
Five types report a size of zero: array, custom, pad, struct, and terminator. Pad in particular must consume bytes, so the field record's auxiliary word is presumed to carry the length, the struct target, and the option run an enum or bitfield owns. That mapping is not yet established.
The data walk is not yet proven
The decisive test is walking the bdat payload with the field list and asserting it is consumed exactly, for all 12,290 shipped tags. Until that passes, field offsets into the data are inferred rather than confirmed.
A previous revision of this page reported that the option table overran its own section in 16 of 101 groups. That was a misreading: the word in question is a byte size, not an entry count. Read correctly, no group overruns.
Tooling
A Rust workspace under crates/ reads containers and layouts directly. ue-iostore parses UE 5.5 IoStore containers, blam-tag parses the container header and section tree, blam-defs holds the shared definition model, and blam-cli exposes it as the mjolnir command. It parses 101 of 101 groups across all 12,290 shipped payloads and recovers a complete field list for every one.
54 distinct field type names appear across the corpus, and every one has the same size in every group it appears in. That consistency is the strongest available check that the type table decode is correct.
Reproduce
Read-only against your own installed copy. Any oo2core_9_win64.dll from a local Unreal Engine install works, since UE 5.5 statically links Oodle and the game ships no separate DLL. Nothing is written to disk.
$env:HCE_PAKS = "<install>\Meteorite\Content\Paks"
$env:OODLE = "<UE>\Engine\Binaries\DotNET\AutomationTool\oo2core_9_win64.dll"
# Every group, with its definition table sizes
cargo run --release -p blam-cli -- groups
# One group's section tree and type, block, and struct tables
cargo run --release -p blam-cli -- layout --group camera_track --tables
# A group's resolved field list with running offsets
cargo run --release -p blam-cli -- fields --group weapon
# The field type vocabulary and its sizes
cargo run --release -p blam-cli -- typesFull working notes: tag_body_format.md. Container and packaging detail: Blam tag data.