This post is written for the Kiroween 2026 hackathon (Resurrection category).

NecroHex is a patch workbench for Doom-format WAD map files. It runs in the browser at https://necrohex.koxia.cn, and the code is at https://github.com/kanzaki-chiya/NecroHex under the MIT license. You open a WAD and load a patch. The workbench shows what would change on the map and why anything was refused, and lets you export a small PWAD with a receipt. Everything is tested against Freedoom, the free content set, so every screenshot here is Freedoom.

The workbench with the Haunted House patch applied to Freedoom MAP01

The core of it was built in about three days, starting with the first commit on 2026-11-09.

The problem: editing a file format from 1993

A WAD map is a set of fixed-size binary records: sectors, things, linedefs and sidedefs. Old maps are still edited and remixed today, usually in a map editor or by rewriting bytes at known offsets. What I wanted was a change like "make every light in this map 30% darker, but don't touch the player starts" written down as a file that you can review before it's applied, replay on the next release of the map, check with a receipt, and revert.

Without that, editing is risky, and it gets worse when an AI agent does the editing. An agent that can write raw bytes can corrupt a map in ways nobody notices until the map is played.

NecroHex takes a different approach. A patch is a JSON list of operations. Each operation selects records (all of them, ones matching a where filter, or one record pinned by index plus a fingerprint) and changes fields with set, scale or clamp. Before anything is written:

  • Guardrails refuse risky writes unless the patch opts in. Examples are replacing player starts, keys or bosses, overwriting a nonzero sector special, or changing a thing into a different category. Some changes only produce warnings, such as a monster that gets bigger or a height change on a trigger-linked sector.
  • Value checks reject flat names that don't exist, unknown thing types and ceilings below floors.
  • A receipt lists every byte span that changes, with old and new values, so a patch can be verified and reverted.

AI agents get the same patch layer through an MCP server with five tools: inspect_wad, query_entities, plan_patch, apply_patch and verify_output. There is no tool that writes raw bytes, and apply_patch only accepts a planId from a matching plan_patch call.

How I used Kiro

The initial steering files and design documents were prepared with Claude Code before development started, from my original design; the repository says so in docs/kiro-usage.md. The workbench's look comes from an HTML mockup prepared the same way. The code in the repository was written in Kiro. I used the Kiro IDE throughout.

Specs, including one written after the code

I used three specs: wad-roundtrip, patch-engine and workbench.

wad-roundtrip was written in the wrong order. The code and tests already existed, and the requirements, design and tasks came afterwards. That turned out to be useful. Writing down what the code was supposed to guarantee, and checking it against the code, surfaced five real defects:

  • the parser didn't copy its input, so a caller could change the buffer underneath it;
  • a field write could land in the WAD header;
  • one field name returned a NaN byte span;
  • a bare SECTORS lump with no map marker could be written to;
  • Hexen-format THINGS were read with the Doom layout.

Each one was fixed with a regression test.

patch-engine went the normal way: requirements, design and tasks first, then Kiro worked through tasks.md one item at a time and ticked each one off. The design lists properties the engine must keep, and they are checked with property-based tests on generated data and on a real Freedoom fixture. Property 3 says applying a set twice gives the same result as applying it once. It produced counterexamples three times. None of them was an engine bug. Each one showed that the property was stated too broadly:

  • A later operation can select records that an earlier operation just changed, so a second run matches more records.
  • The guardrail against overwriting a nonzero sector special blocks the second run. The value written the first time is now the old value, and it's nonzero.
  • A patch can change fields that are part of its own pin's fingerprint. The second run then no longer recognises the record.

Each time the property got an explicit precondition, the shrunk case became a regression test, and the guardrail stayed as it was.

workbench was the browser app. Kiro rebuilt the prepared mockup in Vue on top of the real engine, so the browser and the CLI share one set of rules. While writing its design, Kiro checked the code and found that the WAD adapter imported node:child_process, which can't run in a browser. It moved that code to the CLI first and added a test so core and adapter-wad can't import Node modules again. A browser check during the tasks found a stale verdict: after switching maps, the title showed the new map's name with the previous map's count (MAP02 showed 300 changes, the real number was 327).

Steering

binary-safety.md is always included. It states the rules the product depends on:

  • serializing an unchanged file gives the same bytes;
  • patches only change the spans listed in the receipt;
  • the source file is never overwritten;
  • the tools never accept raw offsets and bytes;
  • relocation never guesses between similar candidates.

It also says not to weaken these rules to make a feature or a test pass. That came up for real. When the write guardrails went in, 15 existing property tests and regression cases started failing, because their test writes now hit the new rules: a marker value landing on a player start, or a flat name the fixture didn't contain. Kiro changed the tests and left every guardrail as it was. The same happened with the second Property 3 counterexample above. I can't prove the steering file is the reason, but it's the behaviour the file asks for.

Hooks

Two hooks run on save. One regenerates the typed record code when the record layout YAML changes. The other runs pnpm -r typecheck when a source file changes. Kiro tested both in a fresh session: the typecheck processes started after a save, and the generated file was rewritten. They haven't caught a problem yet, and their output didn't come back to the agent, so I can't say more than that they fire.

MCP: Kiro patching a map through its own tools

Once the server was registered in .kiro/settings/mcp.json, Kiro showed it as connected with five tools. In a new session I asked Kiro to change one room on MAP01, using only those tools:

  1. inspect_wad: Freedoom's freedoom2.wad has 32 maps, and MAP01 has 211 sectors and 200 things.
  2. query_entities: Kiro found the player starts, then looked for indoor sectors with no special and no tag. It picked sector #12, an indoor room away from the spawn.
  3. Kiro wrote a patch that pins #12 by index and fingerprint, changes the floor to FLOOR5_2, and sets special 8 (a glowing light).
  4. plan_patch: ready. The pin matched exactly with score 1, two fields change, and all 15 checks passed.
  5. apply_patch with that planId: a PWAD and a receipt.
  6. verify_output: replaying the receipt, checking that only the listed spans changed, and reverting all passed. DSDA-Doom ran MAP01 for 350 tics and exited cleanly.

After each Kiro report I checked the result independently. Loading the PWAD in the engine showed the new floor in the room:

Sector #12 before (top) and after (bottom): the floor changes from the grey grid to FLOOR5_2

The glow didn't show at all. In the original engine, a glowing sector moves between its own light level and its darkest neighbour's. Sector #12 is at 160, and both its neighbours are at 255, so the glow went from 160 to 160. The patch was valid and the receipt was correct, but the effect was invisible.

That became a new warning. Kiro added sector-light-effect-invisible: if after the patch a sector has light special 1, 8 or 17 and no darker neighbour, the plan says so. It uses the light levels after the patch on both sides. On the Haunted House demo patch it fires once: MAP01 #39 glows at 89 against a darkest neighbour of 134.

Relocation: finding the same room in a newer release

Map authors renumber sectors when they edit. Between Freedoom 0.12.1 and 0.13.0, MAP01 went from 198 to 211 sectors. A patch that says "sector #74" is wrong on the new version.

A pin carries a fingerprint of its record: centroid, bounding box, area, heights, line count, textures and tag. If the record at the expected index doesn't match, the engine scores the other sectors. It relocates only when exactly one candidate scores at least 0.85 and leads the next one by at least 0.1. Otherwise the whole patch is refused.

The Relocation Demo patch pins MAP01 #74 from 0.12.1. On 0.13.0 it resolves to #196 with score 0.8728:

RELOCATED: #74 → #196, the old position drawn dashed, the new one in orange

I want to be honest about how strong that is. 0.8728 is only 0.023 above the threshold. The sector's boundary lines changed between versions, so this match is backed by area overlap (94% of #74's sample points fall inside #196), not by identical geometry. The weights were calibrated on 0.12.1 and 0.13.0 only, and 0.11.3 was held out. On the held-out version there is one known wrong pick: MAP25 #621 → #622, two concentric octagonal rings where the outer one was merged into the room. It's documented in the repository, and I didn't tune the weights to hide it. The workbench marks every relocated record in orange with "review".

Things that went wrong

  • Flaky tests. A coverage threshold in one property test was too tight and failed about one run in ten. Kiro worked out the distribution and loosened it, and tripled the runs of a second test that missed now and then.
  • Windows file names. The CLI and MCP server refuse to overwrite existing files. An independent probe showed that Windows device names (NUL, COM1.wad) and NTFS stream syntax (out.wad:x) got past that check. Kiro now rejects them on every platform.
  • An always-false flag. The export report said no map was modified, because the export re-parsed the output and lost track of what had changed. The bytes were right and the report was wrong. Now the changes are replayed on the input and must match the engine's output byte for byte, or nothing is written.
  • CI's first run. It failed before any test ran: a step used a git-ignored cache folder as its working directory, and that folder doesn't exist on a fresh checkout. One mkdir fixed it. The second run downloaded and checked both Freedoom releases and ran all 295 adapter tests without skipping any.

Where it ended up

  • 511 tests across five packages: core, the WAD adapter, the CLI, the MCP server and the web workbench. CI runs them on every push with real Freedoom data; only the four tests that need the DSDA-Doom engine are skipped there.
  • A browser workbench that calls the same engine as the CLI. A PWAD exported in the browser is byte-identical to one exported by the CLI.
  • The PWADs it writes load and run in DSDA-Doom. Passing checks only mean the checks passed: they don't prove a map can be finished.

All of this used about 1,400 Kiro credits.

What I liked most about Kiro was how well the spec workflow and steering held the line: writing designs with explicit properties before coding—and even retrofitting a spec onto wad-roundtrip—caught real bugs, and when new guardrails broke existing tests, Kiro fixed the tests instead of weakening the rules. The rough edges were mostly in the IDE plumbing: the inline run buttons in tasks.md failed with "Could not locate tasks.md" so I had to run task lists from chat, and PostFileSave hook output never came back to the agent, which meant polling processes just to prove the hooks fired. Next time I would write the spec before the code on the first package too, and wire up the MCP server and in-engine checks on day one so visual blind spots like the invisible light effect show up sooner.

Try it at https://necrohex.koxia.cn. Pick "Careless Edit" to watch the guardrails refuse a patch, or "Relocation Demo" to see a pin follow its room to a new version. The code is at https://github.com/kanzaki-chiya/NecroHex.

Written for Kiroween 2026.