Introducing Strata
After a long period of inactivity, one of the older parts of Catch22 HexEdit has recently come back to life. The original HexEdit (does anyone remember this still!?) had a small feature called TypeLib for describing structured binary data. The language itself actually predates HexEdit’s 2012-era implementation by several years, and much of the compiler behind it has been sitting largely untouched for the best part of two decades now.
TypeLib has now found a new home, in a newly updated q22 editor. It’s also gained a new name - and so I’d like to introduce Strata:

Strata is a C-like language for defining structured binary data. At its simplest, it lets you describe the physical layout of a file using familiar structures, unions, arrays, enums and typedefs. On top of those basic definitions it adds an IDL-style annotation system for describing what the data means, how it should be presented, and how structures elsewhere in the file relate to one another.
The important part, though, is that Strata remains declarative. A Strata definition describes the data and the relationships within it; it isn’t a program which happens to parse a binary file.
That distinction becomes increasingly important once we get beyond the easy parts of a file format. PE files make a useful example: the DOS header is easy, as are the PE signature, the COFF header and most of the optional header. But when we encounter section tables, RVAs, imported symbols, data directories, etc, things start getting complicated.
At this point a binary file stops looking like one enormous C struct. Strata’s answer is not to abandon declarations and force the user into writing a custom parser. Instead, the language gives us progressively richer building blocks for describing what is there, and how the pieces fit together.
Strata and Causeway
As part of resurrecting HexEdit’s structure system for q22, the language has started growing again. The underlying concepts and design intention are the same, and the original parser is identical, but Strata now does considerably more than the old TypeLib did, and the parsing engine has been separated into a standalone project.
There is also, inevitably, a naming theme here. HexEdit (and now q22) immediately conjure up images of hexagons. Strata’s language features provide building blocks, and the word Strata implies layers; layers suggest rock; and columns of rock naturally lead to images the Giant’s Causeway.
Therefore the Strata compiler is now called Causeway.
This is also a convenient point to make an unusual disclaimer for a software project being developed in 2026: Causeway is not AI generated. About 99% of the compiler is my original code from roughly twenty years ago, and this in turn was originally based on the recursive-descent parser presented in Fraser and Hanson’s 1995 A Retargetable C Compiler: Design and Implementation.
Delightfully Old-school
The compiler may be old , but q22 isn’t simply old HexEdit with a new coat of paint. Integrating Causeway into q22, and particularly porting and reworking the old type-rendering machinery, was done with heavy assistance from Codex. There is therefore a slightly odd dividing line in the implementation: a twenty-year-old hand-written recursive-descent compiler, a thirty-year old HexView design, both feeding a substantially modernised Qt application whose integration code has had a great deal of help from an AI coding agent.
It works rather well. More importantly, revisiting the system after this much time has given me an opportunity to think rather more clearly about what the language is actually for. When I started porting this project to Qt6, Codex described TypeLib as ‘Delightfully old-school’. I’m not sure whether to be proud, or offended by that!?
So let’s get underway. We’ll start with the most conventional part of Strata: C structures.
Starting with C
The lowest layer of Strata is deliberately conventional. If you know C, you already know most of it, and that is useful for two reasons. Firstly, a huge number of binary formats are already documented using C structures, or something that looks very much like them. Secondly, C’s type system gives us a perfectly good set of building blocks for describing the straightforward parts of a binary file.
Take the PE file header:
typedef struct _IMAGE_FILE_HEADER {
word Machine;
word NumberOfSections;
dword TimeDateStamp;
dword PointerToSymbolTable;
dword NumberOfSymbols;
word SizeOfOptionalHeader;
word Characteristics;
} IMAGE_FILE_HEADER;
There is nothing particularly clever going on here. word is 16 bits, dword is 32 bits, and the fields are laid out consecutively in the file. Unlike a C compiler, Strata isn’t interested in the alignment rules of a particular CPU or ABI, so there is no implicit padding between fields. Unless the definition explicitly says otherwise, the structure maps directly onto the data in the file.
From there we can assemble larger structures from smaller ones. The DOS header, for example, contains the file offset of the PE header, which can be expressed directly:
typedef struct _PE {
IMAGE_DOS_HEADER dosHeader;
[offset(dosHeader.e_lfanew)]
IMAGE_NT_HEADERS ntHeaders;
} PE;
The offset tag is already doing slightly more than C can do, but the underlying model is still straightforward. Primitive types make fields; fields make structures; structures can contain other structures, unions and arrays; and typedefs let us give those pieces meaningful names and reuse them elsewhere. For the simple parts of a file format, that is often all we need.

Figure 1: A PE file described using ordinary C-like Strata structures. Selecting a field in the Structure View identifies the corresponding bytes in the file.
The simplicity of C declarations is a purposeful feature of Strata. One temptation when designing a binary description language is to immediately start adding convenient operations for reading arbitrary data, seeking around the file and executing bits of parsing logic. This would solve the immediate problems, but before long the format definition has quietly turned into a program.
Strata takes a different route. Rather than replacing those basic declarations with a parser, it adds more building blocks around them.
Adding information with tags
A C structure can tell us that Machine is a 16-bit integer at a particular offset. That is technically correct, but not terribly helpful. If the value is 0x8664, what we actually want q22 to display is something closer to AMD64 than a bare decimal integer.
Strata expresses this additional information using tags. Tags use an IDL-style syntax and sit immediately before the declaration they describe:
[enum(IMAGE_FILE_MACHINE)]
word Machine;
With a suitable enum definition, q22 can display the symbolic machine type while the underlying field remains exactly the same 16-bit value in exactly the same place in the file. The declaration describes the physical representation; the tag adds information that q22 can use when interpreting and presenting it.
The same idea applies to flags and formatting:
[bitflag(IMAGE_FILE_CHARACTERISTICS)]
word Characteristics;
[format("timestamp", "unix")]
dword TimeDateStamp;
[string]
char Name[8];
Tags can also describe relationships which affect how a declaration is interpreted. A flexible array, for example, can derive its element count from a field elsewhere in the structure:
[
count(ntHeaders.FileHeader.NumberOfSections),
element(name(Name))
]
IMAGE_SECTION_HEADER sectionHeader[];
Similarly, a union can select the appropriate interpretation according to a discriminator:
[select(OptionalHeader32.Magic)]
union {
[case(0x10b)] IMAGE_OPTIONAL_HEADER32 OptionalHeader32;
[case(0x20b)] IMAGE_OPTIONAL_HEADER64 OptionalHeader64;
};
These tags do different jobs, but they follow the same model: the declaration provides the basic shape of the data, while the tags add the information needed to interpret that shape.
Crucially, we still haven’t needed to write a parser in our Strata definition. There isn’t a loop which reads NumberOfSections and repeatedly calls read_section_header(). We haven’t written an if statement to determine whether the optional header is PE32 or PE32+, and we haven’t written code to split the Characteristics value into individual flags. We have declared those relationships instead.
This gives Strata a vocabulary that can gradually become richer without throwing away the simplicity of the original C structures. And we’re going to need it, because this is the point where a PE file starts getting complicated.
Files aren’t really C structures
So far the first few PE headers are simple, linear definitions. The DOS header tells us where to find the PE header; the PE header gives us a file header and an optional header; and after that comes a table of section headers. So far, so good.
But a section header does not contain its section. Instead it contains information about the section: its name, virtual address, size and the offset at which its data can be found in the file. Likewise, a PE data-directory entry doesn’t contain an import table or an export directory. It contains an RVA which tells us where that structure exists in the loaded image.
This is where the simple model of “a file is one enormous nested C structure” starts to fall apart. The data is still structured, but the structure is no longer purely physical containment. Some structures describe other structures elsewhere in the file; some addresses are file offsets, while others are RVAs which have to be translated through the section table. Some arrays have a count, others terminate with a sentinel, and some have both a maximum size and a terminating condition.
Increasingly, the useful relationships between the pieces have very little to do with the order in which those pieces happen to appear on disk.
An imperative parser would deal with this quite naturally: read an RVA, locate the section containing it, translate it to a file offset, seek there and parse whatever structure is expected. Strata still needs to express this same relationship - but without the imperative logic.
For this, we need another set of building blocks: address mappings, dynamic structures and dynamic arrays.
Following the pointers
A PE section header contains two different descriptions of essentially the same region of data: where that region will live in memory, and where its bytes actually live in the file. Stripped down to the interesting fields, it looks something like this:
typedef struct _IMAGE_SECTION_HEADER {
char Name[8];
dword VirtualSize;
dword VirtualAddress;
dword SizeOfRawData;
dword PointerToRawData;
// ...
} IMAGE_SECTION_HEADER;
VirtualAddress is an RVA. PointerToRawData is a file offset. SizeOfRawData tells us how much data is present on disk. Once the image has been loaded, other structures throughout the PE file refer to locations inside that section using RVAs rather than file offsets.
A parser would usually solve this by maintaining a function along the lines of rva_to_offset(): given an RVA, walk the section table, find the section whose address range contains it, then translate the address back into a file offset. That works perfectly well, but it is an algorithm. What we really want to describe is the mapping itself.
Strata does this with offset_map:
[
offset_map(VirtualAddress, SizeOfRawData, PointerToRawData),
offset_map("rva", VirtualAddress, SizeOfRawData, PointerToRawData),
dynamic_container(type(SECTION))
]
typedef struct _IMAGE_SECTION_HEADER {
// ...
} IMAGE_SECTION_HEADER;
The arguments describe the relationship directly: this section maps a range beginning at VirtualAddress, extending for SizeOfRawData bytes, onto the file at PointerToRawData. The named "rva" form gives that address space a name which can be used elsewhere in the definition.
The dynamic_container tag provides somewhere for referenced data associated with a section to be attached in the raw Structure View. It doesn’t consume any more bytes or somehow insert the section data after the section header. The header remains where it really is in the file, and the section contents remain wherever PointerToRawData says they are; we are describing the relationship between them rather than pretending that one physically contains the other.
With the section table in place, we now have a set of mappings which can resolve RVAs throughout the rest of the image. And PE gives us plenty of those.
Dynamic structures and arrays
Consider the PE data-directory table. An entry is little more than an address and a size:
typedef struct _IMAGE_DATA_DIRECTORY {
dword VirtualAddress;
dword Size;
} IMAGE_DATA_DIRECTORY;
What that address points to depends on which entry in the array we are looking at. One might describe the export directory, another the imports, another resources, exception information, relocations, and so on.
The export directory is a single structure at an RVA. Inside the tags applied to the data-directory entries, Strata can describe it with a dynamic_struct:
dynamic_struct(
case(IMAGE_DIRECTORY_ENTRY_EXPORT),
type(IMAGE_EXPORT_DIRECTORY),
offset(VirtualAddress),
mapper(offset_map),
optional(Size != 0)
)
There is quite a lot packed into that declaration, but none of it is procedural. For the export-directory entry, there is an IMAGE_EXPORT_DIRECTORY at VirtualAddress; the address should be resolved using the section mappings we established earlier; and the structure only exists if the directory size is non-zero.
We haven’t said how to find the section. We haven’t walked the section table, performed the RVA calculation, moved a file pointer or invoked a parser for IMAGE_EXPORT_DIRECTORY. Those are implementation details for Causeway and q22. The Strata definition just describes the relationship.
dynamic_struct is used where that relationship leads to one structure. Import tables give us the corresponding array case. An import-directory entry points to an array of IMAGE_IMPORT_DESCRIPTOR structures and, conveniently, PE doesn’t give us a simple element count; the descriptors continue until a terminating entry is reached.
Again, this is the sort of thing that would naturally turn into a loop in an ordinary parser. In Strata it still remains declarative:
dynamic_array(
case(IMAGE_DIRECTORY_ENTRY_IMPORT),
type(IMAGE_IMPORT_DESCRIPTOR),
offset(VirtualAddress),
max_count(Size / sizeof(IMAGE_IMPORT_DESCRIPTOR)),
mapper(offset_map),
terminated_by(
OriginalFirstThunk == 0 &&
FirstThunk == 0
),
terminator("hidden")
)
This says that the import-directory entry refers to an array of IMAGE_IMPORT_DESCRIPTOR structures beginning at VirtualAddress. The RVA is translated through the section mappings, the directory size provides a sensible upper bound, and an import descriptor whose thunk fields are both zero terminates the array.
What it does not contain is a loop.
That may sound like a philosophical distinction more than a practical one, but it has a fairly profound effect on how Strata definitions grow. An array is still an array. A referenced array is still described in terms of its type, location, bounds and termination condition. We keep adding information to the declaration rather than dropping into a different language whenever the format becomes awkward.
The import descriptor itself continues the same pattern. Its Name field is another RVA, this time referring to a zero-terminated string containing the DLL name:
dynamic_array(
name(DllName),
type(CHAR),
offset(Name),
max_count(4096),
mapper(offset_map),
terminated_by(0)
)
The same building block that described an array of import descriptors can therefore describe a referenced string. It is still just a sequence of elements located somewhere else in the file.
This is one of the things I particularly like about the Strata model. There isn’t a special PE mechanism for any of this. Sections establish mappings; dynamic structures and arrays use those mappings; and ordinary tags describe counts, terminators, names and conditions. More complicated definitions emerge by combining a fairly small set of declarations.

Figure 2: Referenced PE structures resolved dynamically. The import descriptor array, thunk data and DLL names live at separate locations in the file, but can still be presented together as related structures.
At this point Strata can describe considerably more than a nested C structure. It can describe non-linear relationships across a file, including address translation, referenced structures and variable-length data, without requiring the definition author to implement the traversal themselves.
There is, however, another problem. What we have built so far is still fundamentally organised around the way the PE file is constructed. It is a good description of the structures which are actually present and the relationships between them, but that doesn’t necessarily make it the best way for a human to explore the file.
Sometimes the most useful view of a binary has very little to do with its physical organisation.
Physical structure isn’t always useful structure
Consider imports again. Physically, the information needed to describe an imported function is spread across several different parts of the image. The optional header contains a data-directory entry which identifies the import table. The import table contains descriptors for individual DLLs. Those descriptors refer to thunk arrays elsewhere in the image, which in turn refer to names somewhere else again. On disk, that arrangement makes perfect sense.
From a user’s point of view, however, what we really mean is something much simpler:
PE Image
SECTION .text
SECTION .rdata
Debug
TLS
LoadConfig
Imports
Qt6DBus.dll
dwmapi.dll
DwmSetWindowAttribute
DwmExtendFrameIntoClientArea
DwmGetColorizationColor
USER32.dll
SECTION .data
The first view describes how the file is constructed. The second describes what is in it. Those are both useful views of exactly the same data, and Strata deliberately keeps them separate.
Semantic views
A semantic view begins with another structure definition, but this time the structure doesn’t describe bytes in the file. It describes the shape of the view we want to construct.
A simplified version of the PE schema looks like this:
[semantic]
typedef struct _PE_SECTION_VIEW {
IMAGE_IMPORT_DESCRIPTOR Imports[];
BYTE Bytes[];
} PE_SECTION_VIEW;
[semantic("PE Image")]
typedef struct _PE_VIEW {
[element(tree("flatten"))]
PE_SECTION_VIEW Sections[];
} PE_VIEW;
There is no PE_VIEW structure hiding somewhere inside an executable, nor does each PE section physically contain an Imports array followed by a Bytes array. This is a schema for a view.
That gives us another useful building block. Instead of forcing the user-facing representation to follow the layout of the file, we can declare the shape we would like it to have and then populate it from the physical structures we’ve already defined. The raw PE root attaches that schema with [semantic(PE_VIEW)]; adding it doesn’t alter any of the offsets, sizes or physical relationships we’ve already described.
The mechanism which connects the two is emit.
The section table gives us a useful place to start. Every IMAGE_SECTION_HEADER represents something we would like to appear as a section in the semantic tree, so the section declaration can create a corresponding row:
emit_row(
dest(Sections, key(Name), name(Name)),
offset(PointerToRawData),
map("rva", VirtualAddress, SizeOfRawData, PointerToRawData)
)
emit_row creates or reuses a section in the semantic view named from the PE section header. .text, .rdata, .data and the rest are therefore not being reconstructed later by some separate PE renderer; the existing section declaration contributes them directly to the semantic tree. The row also carries the RVA mapping we already know about, which means that other declarations referring to addresses within .rdata, for example, can be associated with that .rdata node in the semantic view.
The raw section bytes can be emitted beneath the section:
emit(
dest(Sections.Bytes),
type(BYTE),
offset("rva", VirtualAddress),
count(SizeOfRawData)
)
And the import-directory declaration can contribute the imports to whichever section owns its RVA:
emit(
case(IMAGE_DIRECTORY_ENTRY_IMPORT),
dest(Sections.Imports),
label("Imports"),
type(IMAGE_IMPORT_DESCRIPTOR),
offset("rva", VirtualAddress),
max_count(Size / sizeof(IMAGE_IMPORT_DESCRIPTOR)),
terminated_by(
OriginalFirstThunk == 0 &&
FirstThunk == 0
),
terminator("hidden")
)
Notice how similar this is to the dynamic_array declaration we used earlier. The type, address, bounds and terminating condition are the same kinds of building blocks. What has changed is the destination.
A dynamic array says, roughly, this structure refers to these bytes elsewhere in the physical Structure View. An emit says these bytes also belong here in the semantic view. The distinction is small in syntax but quite important architecturally: emit doesn’t manufacture a copy of the import table or turn it into some disconnected model object. Emitted rows can still represent the real bytes at their real offsets; the semantic tree simply organises them according to the schema rather than according to where the corresponding declarations happened to occur in the file.

Figure 3: A semantic view of the same PE file. Instead of mirroring the file’s physical layout, Strata reorganises the data into a more useful structure: sections contain logical content such as imports, which in turn contain DLLs and imported functions.
This also means that the semantic view is assembled by the declarations which actually know about the data. The section header declares what constitutes a section; the import-directory entry declares where the imports are; other parts of the PE definition can contribute exports, relocations, resources or whatever else they understand. There isn’t a second piece of code at the end which walks a completed PE model and manually constructs a prettier tree.
By now we have built two quite different representations of the same executable. The first follows the physical structure of the file and is useful when you want to know where an RVA came from, which field pointed to something, how large a structure actually is, or what bytes immediately surround it. The second can organise those same structures according to their meaning, allowing imports to appear beneath the section which owns them and unrelated physical tables to contribute information to the same semantic object.
Neither required us to write a PE parser in the conventional sense.
Take a look at the full PE strata definition: https://github.com/strobejb/q22/blob/main/src/causeway/strata/pe.strata
Why keep it declarative?
At this point it is probably obvious that Strata could have been designed very differently.
A lot of binary-format tooling eventually gravitates towards an imperative style. You start with a structure syntax because it is convenient for the easy parts, but sooner or later some awkward format feature appears and the simplest way to support it is to add a bit of code: an if, a loop, a seek, a helper function, perhaps a small scripting language embedded inside the type definition. Repeat often enough and the “format description” quietly turns into a parser with a user interface attached.
There is nothing inherently wrong with that approach. In fact it is often more powerful and certainly more flexible. If your goal is “make it possible to describe anything”, an imperative escape hatch solves a lot of problems quickly. But it also changes the centre of gravity.
Once the definition author is expected to write code, the burden of traversal, control flow and assembly moves out of the editor and into each individual format definition. Understanding a file format turns into a reverse-engineering problem, rather than just reading a plain definition. The implementation stops being “the thing that understands arrays, mappings, references, alternative views and presentation”, and starts becoming “the thing that runs your parsing script”. That is not the route I wanted to take with Strata.
The basic idea behind Strata is that the format definition should describe the facts about the binary data: what fields exist, how large they are, how they are grouped, how one structure refers to another, how addresses are translated, how arrays are bounded or terminated and, if necessary, how the same data should be reorganised into a more useful semantic view. Those are all declarative statements. They are properties of the format, not steps in an algorithm.
Of course there is still an algorithm somewhere. Causeway and q22 have to evaluate expressions, resolve mappings, follow references, instantiate rows and build views. None of that happens by magic. The point is that the algorithm belongs to the language implementation, not to every Strata definition.
That may sound like an aesthetic preference, but I think it has practical consequences. It keeps definitions closer to the format documentation: a well-written Strata definition can often be read almost as a structural description of the file, rather than as a custom parser for it. It also encourages reuse; once the language learns a concept like offset_map, dynamic_array or emit, that building block becomes available everywhere rather than living as one-off code inside a single format definition.
Most importantly, it keeps the mental model relatively sane. The solution to a new problem is still “how do I describe this?”, not “how do I break out and script around it?”
That doesn’t mean Strata is finished, or that it has every building block I’ll ever need. Quite the opposite: the language has evolved precisely by encountering awkward binary-format problems and then trying to solve them without abandoning the declarative model.
This is how TypeLib slowly turned into Strata.
Twenty years later
Revisiting this system for q22 has been a slightly odd experience. Some of it has felt like opening a time capsule, some of it has held up better than I expected, and some of it has benefited enormously from finally being looked at again with a clearer idea of what the language is actually trying to do.
What matters more, though, is that the language now feels as though it has earned its own identity. It is no longer just “that old TypeLib thing from HexEdit”. Over time it has accumulated a more coherent set of ideas about structures, metadata, references, mappings and semantic views, and those ideas fit together well enough that giving the language a proper name finally felt justified.
There is still plenty more to write about. I haven’t touched on every part of the language here, and the PE examples only cover a small portion of what the current definitions can do. But I hope this gives a reasonable sense of the design: Strata provides building blocks for describing binary data, starting with ordinary C structures and gradually layering on enough declarative machinery to describe relationships that are a good deal more complicated than a nested struct.
That, at least, is the idea. Take a look at the Strata language reference for a more detailed description of the language.
I hope people still find some interest in this project. If you have any feedback, I’d love to hear it!