Why Open Quest Format froze its file format at v0.1 while the chrome keeps changing
Open Quest Format runs two version numbers, choom. The file format was frozen at OQF1 in the first release and can only grow by bolting columns onto the end, while the TypeScript packages stay 0.x and are still allowed to break their API. This post jacks into why the two are kept apart and how the append-only rule holds the line.
Open Quest Format runs two version numbers, choom, and they measure different things. The packages sit at 0.1.0, which is what you would expect from chrome this fresh off the ripperdoc’s table. The files, on the other hand, are versioned as OQF1, the four bytes on the first line of every compact quest, and I froze that version in the first release, before any game had shipped with the format jacked into its data.
I know that looks backwards from the street. The usual corpo advice is to settle the API first, run it in production for a while until it stops throwing sparks, and only then start making promises about the data. For a file format I think that order is gonk, so OQF is built on the opposite bet, the kind a fixer makes when he knows which side of the deal carries the risk: the files get a stability promise from the first release, and the code keeps the freedom to change while it matures.
Two version numbers because there are two kinds of merc on the gig
The packages are used by netrunners who write code. If @oqf/runtime renames a method in 0.2, the netrunner running it fixes an import, reruns the type checker and gets on with the gig, because the tooling works like a ripperdoc’s scan and tells them exactly what broke. The README is explicit about this: the TypeScript API may change between minor versions while it settles, so if that matters to you, you should pin a minor range and keep your deck steady.
The format is used by chooms who write quests, and that is a very different run. A quest file can represent hours of some choom’s writing, real eddies of time poured into every line. It sits in a repo, later in a game’s data folder, and maybe gets copied into an engine project by someone who has never run npm install in their life. If the format changes under that file, there is no type checker standing guard like ICE to warn anyone. The file either flatlines on load or, which is worse, it loads but reads the wrong values and keeps smiling, and nobody notices until something in the game starts to glitch.
So I gave the two numbers different rules, like two crews working different sides of the same gig. OQF1 only changes when a file written today would stop parsing. The packages (core, runtime, dialogue, i18n, cli, examples and the editor) all share one version, and pnpm version:set bumps the whole crew together in one run. I did that because I didn’t want anybody to have to play fixer and work out which @oqf/core pairs with which @oqf/runtime: if the numbers match, you know that set of chrome was tested together on the same rig.
If I had tied the format version and the package version together, I would have ended up with one of two bad deals. Either every API cleanup during 0.x would look like a format break, which would make chooms think their files were about to get zeroed when they weren’t, or every promise about the format would also lock in an API I’m still not happy with, like signing a corpo contract before reading the fine print. Keeping the two numbers separate means I don’t have to pay eddies for either of those deals.
What “frozen” means for TAB-separated chrome
The compact format is line oriented, from the same bloodline as NWF. Each line is a list of TAB-separated cells, the first cell names the record type, and the column order for every record type is written down in the spec like the terms of a fixer’s contract: docs/12-compact-spec.md for quests and docs/13-dialogue-spec.md for dialogue. On top of that sits a single rule, the ICE around the whole format, and it is what freezing actually means here: new columns can be bolted onto the end of a record, but existing columns are never reordered and never removed, no matter how preem a new layout looks.
For that promise to hold out on the street and not just in a corpo pitch deck, the parser has to follow two street rules of its own. A record with fewer cells than the spec lists is valid, because it was produced by an older writer, a veteran merc that didn’t know about the newer columns. A record with more cells than the spec lists is also valid, because it was produced by a newer writer, fresh chrome from a later release, and the parser keeps those extra cells in the extension bag under x-oqf.extra so that they survive a round trip instead of getting dumped in an alley and flatlined.
I wanted to see the second rule work on a real fixture, since a unit test’s toy input can hide problems that real files out on the street would expose. So I scavved templates/linear.oqf from the examples package and appended three cells to every S line: two empty ones, then 2h, which stands in for a column that doesn’t exist yet. Then I jacked the file into the built 0.1.0 parseCompact and toCompact. The output was byte-identical to the input, and the first step’s ext came back as {"x-oqf.extra":"\t\t2h"}. The 0.1.0 tool has no idea what that extra cell means, and it doesn’t need one, because like a courier on a fixer’s gig all it has to do is hold the package, keep the seal intact and hand it back out unchanged.
The magic line is the one place where the parser is allowed to refuse a file, the only ICE standing at the door. If you change it to OQF2, the same parser stops the run on line 1 with expected the magic line "OQF1", got "OQF2". That is the kind of flatline I want when a file really is incompatible, because the error is explicit, it reads like a straight answer from a fixer rather than a glitch, and it hits before anything has been half-loaded and started feeding the game bad data.
The column surgery I ran on the ripperdoc’s table before the freeze
docs/06-formats.md still holds the sketch I wrote while I was planning the format, back when the chrome was still on the bench. If you lay it next to the frozen spec, you can see that three records got cut open in the middle, ripperdoc style, with new chrome slotted in between existing parts. The Q header gained unlock between tagRefs and title. The R reward line gained outcome between amount and payload, because the sketch had left quest-level rewards as “exact encoding to be settled”. And the S step line gained actorRefs just before title.
Every one of those changes is an insertion, which is exactly the move the rule now bans, and that is the reason I froze at v0.1 instead of waiting for v1.0. While the format was still a sketch, I could shuffle columns freely, because nothing outside my own rig depended on their order and no other choom had a file on the line. Once 0.1.0 hit the street, an insertion would flatline files written by other chooms, so the cheapest moment to lock the order was right then, before anyone else had spent eddies writing files against it.
How the planned v2 chrome slots into the append-only rule
The roadmap lists four v2 gigs under the line “planned for now so v1 files stay valid when they land”. A claim like that is easy to write and hard to check, the kind of line every megacorp prints on the box and every merc learns not to trust on sight, so here is how each of the four fits without a single cut to the order of existing columns.
Timers are the first gig on the list, and at the moment the clock for them lives in the engine, not in the file, which means the game’s own rig does the counting. The failure template, for example, closes a sale before nightfall with a fail cell of event world.nightfall, which means the game emits that event and the step flatlines. v2 adds a deadline on steps and var.time.* in conditions. A deadline is a new step column, so it gets bolted onto the end of the record like any other new column, no ripperdoc required. The condition half already runs clean through the parser today: var.time.hour >= 18 comes out of the 0.1.0 parser as {">=":[{"var":"var.time.hour"},18]}, because var.* is the namespace the game supplies and the validator already treats its contents as unknown turf it has no business policing.
Factions are mostly there already, since reputation exists as a reward kind with an opaque ref, and the reference quest grants reputation harbor 10 on the returned outcome and -10 on sold, which is street cred in everything but name. What v2 adds is a faction registry and standing thresholds exposed as variables, so the game knows which gangs a choom is square with. A threshold is just a condition that reads a variable, and conditions can already read variables today, which means thresholds fit into what the format can express now, no new chrome needed.
Radiant quests get a template header with parameters like $target and $location, followed by a substitution pass before load. The runtime never sees a template, because what it loads is the output of that pass, which is an ordinary OQF1 quest, the way a fixer hands a merc the gig with the target and the address already filled in. From where the runtime is jacked in, nothing about the file format changes.
Tabletop export (printable quest sheets, a Foundry export) is an exporter, a side run that never lays a finger on the source. It reads the model and writes some other format, like a braindance editor cutting a new edit from the same recording, so the quest file itself never changes.
None of the four needs a new cell jammed into the middle of a record. I ran that check before freezing the format, the way a samurai checks every round before a run, and it is the reason a v2 list can exist at all while the project is still at 0.1.0.
What’s incoming on the fixer’s board
Append-only covers the order of cells in the compact format. There are two pieces that sit outside that rule’s ICE, and both are already on the fixer’s board and on the way.
The first gig is forward-compatible JSON reading. The compact parser already rolls with files from a newer writer, like a fixer who takes any merc with the right creds, but today the JSON parser bounces keys it doesn’t know like a bouncer at the Afterlife, and the published schema says additionalProperties: false. To run the check, I slipped a deadline key into a step in the JSON form of the same template, and 0.1.0 turned it away at the door with unknown key "deadline" at quests[0].steps[0].deadline. So the current state of the street is that old files load in new tools in both forms, while new files load in old tools only in the compact form. JSON reading that behaves the same way as the compact parser, with the same street manners toward keys it has never seen, is incoming.
The second gig is multiple objectives per step, and this one lives inside a cell. Append-only guards the order of cells, but it says nothing about the grammar inside a single cell, so this change needs its own plan and its own run. One objective per step is also the only open gig in the decisions log. The reference quest wants the trade step to require five silver koi and a talk with the otter, and since v0.1 only holds a single objective, the talk half runs today through a dialogue binding and a flag, a back-alley workaround that still gets the gig done. If a choom types the obvious spelling, collect fish.rare 5 and talk otter otter_trade, 0.1.0 answers with unknown variable namespace "talk", a hard flatline rather than a quiet guess. A list of objectives is incoming and lined up as a candidate for v0.2, next on the fixer’s list. When it lands it will come with its own column, or behind a new magic line that declares the change, and it will keep its hands off the current grammar of the complete cell so that existing files keep meaning the same thing and no choom’s quest gets rewired behind their back.
Serializers must reproduce the fixtures byte for byte, no street discounts
There is one rule in CONTRIBUTING that I won’t bend for any amount of eddies: every serializer has to reproduce the fixtures byte for byte after normalization. packages/examples/quests/ is the shared source of truth, the one braindance every implementation has to replay frame for frame, and the round-trip test takes eleven of those fixtures through three checks each, a gauntlet every run has to clear. First, the compact form reserializes to the same bytes. Second, the compact and JSON forms parse to the same model. Third, the JSON model serializes back to the same compact bytes.
The consequence of that rule is where chooms tend to push back. CONTRIBUTING treats any parser change that alters a single byte of output as a breaking change to the format, and it requires a decision written into docs/00-decisions.md before it can land. That includes changes that look harmless, like writing dictionaries in a different order or picking another canonical spelling for a condition. Both would slip past every type check in the repo, the way a netrunner slips past ICE that only scans for the wrong thing, and both would still change the format under everybody’s feet.
The reason this matters is that v1.5 is about engine loaders, starting with Godot, and a GDScript loader can’t jack into my TypeScript. What it can do is jack into the same fixtures and compare bytes. If the reference implementation drifted by one byte, the two implementations would disagree about what a quest file is, like two fixers quoting different prices for the same gig, and the whole purpose of a shared format is that every implementation agrees on exactly that.
Incoming, choom. The Cozy Coast integration is on its way as the first game to ship with OQF chrome, and engine exporters for Godot, Unity and Unreal will follow in v1.5. Until those hit the street, the only
OQF1files out there are the fixtures in the repo and whatever some choom writes after reading the spec. That is exactly why I wanted the rule in place now: at this stage keeping it costs zero eddies, whereas later every change to the column order would hit real files out on the street.