A from-scratch map & campaign editor for Heroes of Might & Magic V: Tribes of the East, built on Electron and Node with no native dependencies. It reverse- engineers the game's own formats and rebuilds the editor on top of them — and it already builds maps the game loads and plays: a mission created here from scratch, packed by us, runs. The proving ground is rebuilding a shipped campaign mission click by click (see Testing below).
TypeScript throughout. Node 24 — and the Node 24.18 inside Electron 43 — strip
types natively, so src/, electron/ and tools/ run their .ts straight off
disk with no build step; only renderer/ is built — esbuild bundles its code,
because a browser cannot strip types, and the same step assembles its page from
renderer/page.html and renderer/parts/, because HTML has no include of its
own. tsconfig.json is therefore type-check only.
One exception worth knowing: electron/preload.cjs must stay JavaScript.
Electron reads a preload verbatim and never applies Node's type-stripping hook,
so a .cts preload dies on the first type annotation — silently, leaving
window.editor undefined while the window loads as normal.
No game content lives here. This repository holds code and format notes only. Running anything requires your own legal copy of the game: the tools read assets from its folder (
HOMM5_DATA, defaulting todata-unpacked, which is gitignored). Nival/Ubisoft models, textures and maps are neither distributed nor to be committed here.Unofficial project, not affiliated with Nival or Ubisoft.
Launch with npm start. Open a map (or make one with New map…, which puts a
.h5m in the game's H5E folder) and you
get a live 3D scene you sculpt, paint, populate, script and pack.
- Rendering, end to end: tile textures through a splat shader compositing by
<Priority>, sea derived from the ground-flag plane, painted river brushes, vertical cut faces where ground kinds meet, rock-textured cliffs, and both floors. Write-up: docs/TERRAIN_FORMAT.md. GroundTerrain.bin(src/terrain/terrain.ts): reads heights, texture layer masks, ground flags, passability and the river plane, and writes every one of them back. Planes are fixed-size, so a write is a byte-for-byte overwrite in place and the output differs only where asked. Round-trip tested on real maps (npm run test-terrain,test-terrain-write).- Height brushes, named as the original editor names them. Bulk and Dig sculpt smoothly with a radial falloff, on a chosen force and tension so a stroke lands on an exact value (C1M1's field is 87.7% off any fixed lattice — constants couldn't reach it). Raise stands a plateau 2.0 above the ground with sheer cut edges, carrying the relief it was raised from. Lower digs a pit to exactly 0.0 and flags it water, so it floods. Ramp cuts a walkable half-step (flag bit 3), and only at the foot of a cut — all 3,718 ramp vertices in the shipped maps border a different tier. Plateau levels everything it touches to the tier the stroke started on. A Ground-kind brush sets the tier (and ramp bit) without moving the ground — for a surface already at its final height.
- Tile painting: all shipped tiles previewed from their own
.dds, grouped by category. Arm a brush and left-drag at 1/3/5/7 tiles, or Rect, or Vertex. A stroke writes a chosen weight into one layer and leaves the layers under it alone (real ground blends — C1M1's weights sum to 510 at a vertex as often as not), goes onto the GPU masks for immediate feedback and into the main process, which owns the bytes that get saved. Picking a tile the map lacks splices in a new layer (src/terrain/terrain-layer.ts) — the one terrain edit that moves bytes rather than overwriting them. - River brushes (Water, Bog, LavaFlow): these are not ordinary tiles. A stroke writes the half-tile river plane — which is what makes a river a river to the game rather than paint — and optionally carves the bed below its banks. Carving is a toggle because ground already at its final height must not be dug.
- Passability grid and the movement mask (the original's Masks tab): the tile grid coloured in three states — red blocked, blue navigable, clear walkable — plus Mask/Erase brushes. Three states because blocked and you cannot walk here are different questions: a lake stops a footman and carries a boat.
- Brush cursor: the system arrow is hidden while a brush is armed and replaced by its footprint drawn on the ground, following the terrain, so size and placement are visible before committing.
- Placement that reaches a shipped map: an object palette catalogued from the
1466
_(AdvMapObjectLink)files, grouped byEditor/MapFilters.xml, with icons fromEditor/IconCache. It also places the 559 shared definitions no link points at — the 434 statics an_(AdvMapSharedGroup)picks from at random and the 83 named heroes the original reaches only through "Random hero". A position can be a fraction of a tile and a facing any angle (C1M1 uses 80 distinct ones); Alt-drag/Alt-click and the panel's x/y/degrees boxes set exact values. - New objects arrive correctly: the donor gives the field set (right across
types, versions and mods), the schema gives the values — measured against a map
made in the original for the purpose (
docs/OBJECT_DEFAULTS.md), all 21 types. The game's own type spec (data/types.xml, 739 types) then supplies a field a donor's version predates and confirms 29 defaults independently. - Select, move (grid-snapped), rotate and delete — a free-angle slider, ±15°
and quarter-turn buttons,
[/]keys, Delete. - Property panel: every simple field of the selected object, read from the object itself rather than a per-type table, with editors inferred from the value (bool/number/enum/text). A field an object doesn't carry is offered under its own heading when both the game's spec and our schema agree it exists. Enum fields are dropdowns over the full legal set from the spec — every value the shipped maps use is offered, and an unlisted (modded) value is kept rather than dropped.
- Object tree ("Tree…"): the structures a text box can't honestly hold — a
hero's army, a capture trigger, a monster's reward — rendered from
$defsdeclared once insrc/schema/objects.schema.json, wherever they appear. - Every object edit runs through the same path-addressed, recorded API, so it shares undo / dirty / save.
- Idle animation, off by default. The Idle stance button cycles
off / visible / all: creatures and buildings play the clip the game plays when nothing is happening.offis not a paused loop — the scene is built without bones at all, so a map being edited pays nothing for it. Reading it meant decoding Granny GR2 and porting its Oodle1 compression — three format notes, linked under Assets and 3D models below. - The map's own lighting. Each floor lights like its
AmbientLightpreset says — sun colour and direction, ambient and shade, a dark underground — instead of one built-in daylight; and the ~10,800 point lights the designers hung on placed objects (crystal glow, torch pools) are baked into each floor's ground (docs/LIGHTING.md). - Particle effects play. Campfires burn, mana crystals spark, portals shimmer — the game's own frames, not an approximation (docs/EFFECTS_FORMAT.md). What they cost was never counted until it was: on a shipped map they outweigh the entire rest of the scene in draw calls, per-frame CPU and texture memory. Measured and planned in SLICE_fx_performance.md. What the engine does with an effect that the files cannot say — whether a UV scrolls, which shader is bound, what makes a quad stand upright — is to be read out of the running game rather than inferred: SLICE_effects_probe.md.
map.xdbmodel (src/map/map.ts,src/format/xml.ts): loss-less XML DOM —serialize(parse(x)) === xon every sample map — with a typed object model over it. Editing an object rewrites exactly one line; deletion is surgical.- Undo/redo (
Ctrl+Z/Ctrl+Shift+Z/Ctrl+Y): not a command model — an edit is recorded as the byte difference between the documents before and after it, so anything is undoable without writing its inverse, and a feature added later is undoable for free. It covers terrain brushes, object edits and adding a ground layer alike, and survives a restart (the stack is stored keyed by a hash of the documents and re-adopted only if they still match). - Map Properties dialog: two synchronised views over
<AdvMapDesc>— a curated 8-tab dialog (General / Players / Teams / Heroes / Spells / Artifacts / Script / Rumours) and a full tree, both schema-driven (x-tab,x-file, refs), both editing by path through one API so they share undo/dirty/save. Structured refs (Birds/Wind/AmbientLight, a player's main town/hero) get create/select/edit controls. See docs/MAP_PROPERTIES.md. - Regions: named rectangles dragged out on the map and coloured in a panel — because four coordinates are not how a person describes a box they can see.
- Script editor (CodeMirror 6, in the document window): Lua highlighting
(legacy stream mode, so the game's 4.0-shaped Lua isn't painted red), the app's
dark theme, and completion from the engine API (204 functions from the shipped
manuals,
npm run script-api), the game's own scripts, and this map's names — objects, regions, objectives — offered inside string literals where the API takes them. New on the map'sMapScriptrow creates the.lua+ its.xdbwrapper and binds it. A structural linter (src/script/lua-lint.ts) marks what the engine's parser rejects — unbalancedend/brackets, unterminated strings — live in the gutter; it deliberately does not flag unknown names (our API list is partial; completion prevents mistyped names instead). - Default, unique
<Name>handles for placed objects (MONSTER_001, …), numbered per type — an empty handle can't be addressed from Lua at all. See docs/NAMES_AND_SCRIPTING.md.
- Project model: the editor edits unpacked files (a project is a tree of
files, the way the game sees
data/…), not a ZIP in place. Opening an archive unpacks it into a reused workspace; Save repacks over the source; Pack is a separate explicit build to.h5m/.h5c/.h5u/.pak. Aproject.jsonmanifest tracks file hashes at pack time forgit status-style dirty detection and editor-version drift. Archive members are named by their in-game path (Maps/…/map.xdb) — pack to the root and the game can't see the map.HOMM5_UNPACK_TOmoves the working copies somewhere of your choosing, at their in-game path, which is what the e2e suite runs with; it changes nothing about what is saved or where a build lands. - One folder, ours (src/game/mod-paths.ts): our copy of the
executable reads
<game>/H5E/and none of the five folders the shipped game scans, so nothing anyone else installed is mounted. A map of ours isH5E/<name>.h5m; New map… writes one at once, and everything the editor installs — the mod, campaigns — goes beside it.npm run mod-pathssays which set an executable is reading and switches it. - The map list is the install's, never the unpacked data: ours out of
H5E/, the game's read straight out of its.pakarchives (their names only — the 1.4 GB one is never read through). Opening one of the game's maps unpacks a copy to start from, gathering it from every archive that holds a piece of it, newest member winning, exactly as the engine would. - Archives (docs/ARCHIVES.md): a map, a campaign and a mod
are one thing to the engine, and a
.h5mis mounted for the whole session rather than for its own mission — so a campaign can ship its mod inside itself, and a stray file in a map overrides the game for every other map. Which copy of a path wins is decided by member date, which is why packing stamps a real one. - New creatures, artifacts and dwellings (
src/mods/creature-mod.ts,src/mods/artifacts.ts,src/mods/dwellings.ts, docs/UNITS_AND_ARTIFACTS.md) — Units… and Artifacts… in the toolbar build and install one from the window: pick a shipped creature or artifact as a preset and its every field loads (stats, texts, abilities, the four art documents / slot, rank, prices, icon), then edit the difference. Recolor repaints a mod creature's textures by palette — the textures' dominant colours as swatches, each remappable on its own, so the cloak goes grey and the skin stays skin. Dwellings and whole creature sets stay on the command line (npm run units-mod, a project'sunits.json). A creature the game never had ships as a.h5ucarrying its own copy of the three files a mod must edit, its five own files and its art; a dwelling is an object and costs the game nothing global. What a mod cannot carry is the creature ceiling: 180 is compiled into the executable, and an id above it is read and silently ignored. So installing sets it — one action writes the archive and the ceiling, they have to agree exactly, and abin/H5_Game_H5E.exealready patched to any number goes to any other in place (src/exe/creature-limit.ts,npm run creature-limitto look). Steam's own executable is DRM-wrapped, so its code cannot be read or patched at all;npm run unwrap-exemakes the copy that can — copying it when it is already clean (GOG, retail) and unwrapping it with Steamless when it is not, never overwriting a copy that already carries a ceiling (src/exe/exe-unwrap.ts). - Adventure-map buildings (docs/mapPlaceables/buildings/BUILDINGS.md): what a
building is, and what the Buildings window makes of it. A behaviour is
one of the 128
BuildingTypevalues compiled into the executable, bound either by a<Type>field or by the document's own class — and the two do not substitute for each other, which was measured in the game rather than guessed. A parameter lives in the definition, in the placement or in a global table, and everything else — a bank's guard, a windmill's payout — is in the executable. Includes the full registry of the 128 values, which of the sixteen classes declares each, and §7 on the editor: a tab per class, forms built fromtypes.xml, and a building that carries its own art so it can be repainted or re-modelled without touching a shipped file. - Every window that makes something refuses early (docs/CONTENT_FORMS.md) — creatures, artifacts, artifact sets, heroes, buildings, campaigns and New Map mark what their build will not go without (a star on the label), name what is still missing under the form, and keep Save down until it is in. The rules are the ones the channel and the core already enforce, moved to where they can be read before a rebuild rather than after one — a creature with no preset used to reach the build and come back "cannot resolve the donor (none)". The same page carries the rule the near-misses came from: a form must carry back everything it shows, or it saves the last thing's values over this one's.
- New artifacts carry no properties of their own: a record holds six hero
stats, and every special behaviour the shipped artifacts have is compiled
against a specific id. What data, script and the executable each control is in
docs/ARTIFACT_EFFECTS.md; what the engine does with
those ids — the necromancy sum, artifact sets addressed by enum value, and the
command layer every change goes through — is in
docs/ENGINE_INTERNALS.md, read out of the binary
with the tools in
tools/reverse/. Our own artifacts carry real properties as of 2026-07-29 — how that was found and built is docs/_slices_done/SLICE_artifact_effects.md. - New spells (docs/engineInternals/SPELLS.md) — Spells… in the toolbar adds a page to the spellbook the game will let a hero cast: school, rank, mana, the four damage entries the four masteries use, and what resistances answer it. What it REACHES is one choice, not two flags — the whole battlefield, an area, or one stack — because the engine has one damage branch per shape and picks by exactly that pair. Two fields have nowhere in the game's data to live and are drawn here instead: the TILES an area covers, as a grid of checkboxes centred on the tile aimed at (the combat grid is square, so a cross or a ring is as legal as a block), and the creature KINDS the damage passes over, which is how Holy Word spares the undead. Both reach the game through the file the native extension reads, because the engine picks a shape and a kind filter by switching on the spell's NUMBER — and ours is a number it was never compiled against.
- External-change watcher (
src/map/watch.ts): the original Nival editor can be open on the same folder. When it saves, a banner offers to take its version. Content hashes, not timestamps, so our own saves never self-trigger. - Localization (
docs/LOCALIZATION.md): the game reads one language (the ref names a plainname.txt), so localization is the editor's job. A per-map Localize toggle authors every language side by side in tagged files (name.en.txt) behind alocalization.jsonsidecar the game never sees; Export as<language>packs an ordinary single-language.h5m. - Campaigns (docs/CAMPAIGNS.md): bind maps into a story and
pack a
.h5cthe game's Modifications menu loads. Three dialogs, following the original editor — campaign, mission list, mission — plus the thing the original cannot do: reopen a campaign and edit it. A campaign carries no maps; each mission names one by path and the game's VFS finds it in whatever archive ships it. Heroes travel between missions under their character's name, which is the one detail that decides whether a handover works at all; the doc lists that and the other traps that fail silently.
On Windows, start-editor.bat does the same by double-click: it checks Node,
installs dependencies on first run, and keeps its window open if anything fails.
npm start # build the renderer, then launch the editor
npm run typecheck # tsc --noEmit across the whole project
npm test # every unit test-* in one run (tools/test-all.ts)
npm run test-e2e # Playwright: New Map, placement, scripts, the C1M1 rebuild
npm run harness # the renderer in a plain browser, on a stub bridge
npm run dist # bundle and package into dist/homm5-editor-win32-x64/
npm run unpack-data # unpack the install's .pak into data-unpacked
npm run inspect # low-level dump of a .bin's structure
npm run pak # ZIP (.pak/.h5m/.h5c) read/write CLI
The individual unit tests (test-terrain, test-terrain-write,
test-terrain-layer, test-map, test-watch, test-pak, test-objects,
test-schema, test-history, test-lua-lint, test-typespec, …) run one at a
time from package.json; npm test runs them all.
npm run harness serves renderer/harness.html on :8123 — the real index.html
with a stubbed window.editor injected ahead of the app module. The renderer
talks to Electron at module scope, so without this the UI can only be exercised by
launching the whole app; the harness makes the brushes and the toolbar clickable
in any browser, and records every IPC call on window.__calls.
Point HOMM5_DATA at an unpacked game data folder, or build one with
npm run unpack-datawhich unpacks every .pak in the install's data/ into data-unpacked
(gitignored) in the game's own overlay order — the addon's files (a2p1-*) last,
so they win, exactly as the game loads them. It skips files already current, so
re-running after a patch only writes what changed. A partial unpack is the usual
cause of untextured ground: the random-map generator's tiles, for one, ship only
in the addon pak. Individual archives are ordinary ZIPs and tools/pak-cli.js
handles them one at a time.
npm run distbundles the app into build/ (main process, preloads, renderer — no
node_modules, no type stripping at runtime) and packages that into
dist/homm5-editor-win32-x64/, about 350 MB, most of it Chromium. Copy the
folder anywhere and run homm5-editor.exe; there is no installer, on purpose.
A packaged editor has no checkout to take its bearings from, so on first run it
asks: where the game is installed, and where to put the unpacked data. Both
answers go in settings.json under the user's app-data folder, and
homm5-editor.exe --setup reopens the screen when they go stale.
Then that screen prepares the install — four steps, and the editor is only worth opening once they are done (src/game/first-run.ts):
- the archives unpacked into a tree we can read;
- a readable copy of the executable (
bin/H5_Game_H5E.exe) — the shipped one is never written to, which is what keeps it as the off switch; - our extension compiled in and named by that copy's import table;
- that copy pointed at
H5E/, our own mod folder, instead of the five the game ships with.
Three of those four used to be npm commands typed by hand, which meant they were
done once on the machine of whoever wrote them. They are all idempotent and the
screen shows which are already true, so a second run does nothing;
e2e/first-run.spec.ts does all four to an install that did not exist a second
ago and then checks that a second pass writes not one byte.
The paths come from that picker and nowhere else. HOMM5_ROOT / HOMM5_DATA
— from the shell, or from a .env beside the checkout (see .env.example) —
fill the two fields in and decide nothing. The e2e suite deliberately does not
read that file: to it HOMM5_ROOT means "the install to play in", and its
default is a throwaway under _tmp so a run cannot leave e2e … maps in a real
game folder.
The build is unsigned, so on another machine SmartScreen will warn about an unknown publisher — that needs a code-signing certificate, not a packaging flag.
Reverse-engineered empirically and cross-checked against WindBell's 2009 analysis (heroescommunity.com TID=32009). The container is a stream of self-describing arrays, each introduced by a framing group:
<blockTag u8> <u32 sizeA>
01 08 <u32 V> # side in VERTICES (V = tiles + 1)
02 08 <u32 V> # the same value again
03 <u32 sizeB> # sizeB = 2 * arrayByteLength + 1
<array data>
The low bit of a size is a width flag, which is why every size the array scan ever met looked "always odd":
odd -> the size is a little-endian u32 there, len = (size - 1) / 2
even -> the size IS that single byte, len = size / 2
Arrays are big and always take the u32 form; path strings are short and always
take the one-byte form. A tile path of length L is stored as
03 <2L+4> 03 <2L> <L bytes>, an outer record wrapping the string record —
identical across all 20 layer paths in the sample maps. Knowing this is what
makes it possible to write a new layer rather than only read one.
Key points:
- Data is stored per vertex, not per tile: a T×T map yields
(T+1)²values per plane. - Every array declares its own length via
sizeB(len = (sizeB − 1) / 2), so the parser hardcodes no sizes and works for any map.Vcomes from the file too. - Plane order: texture layers (u8 mask + a path to
(AdvMapTile).xdb), then height (float32), then ground flags (u8), a near-uniform reserved plane, passability (u8,0blocked /1walkable), and the river plane on a half-tile(2V−1)²grid. - Passability is per TILE, though stored in a vertex-sized array: entry
y*V + xis tile(x, y)and the last row and column are filler — the map interior is 8.98% blocked while both are 0.00%, exactly, over 2.3M vertices. Every other u8 plane in the file is per vertex, so this one is the exception. - Passability is authored, not derived. Against a 9.0% background rate of
blocked vertices across all 232 maps:
Sand/Sand_Rock92.4%,Grass/Rock_Floor_grass75.5%, a drop steeper than 2 units 25.0%,Water/LavaFlow26.4%,Water/Bog24.6%, and sea (flag0) 6.4% — below background, because flag0means navigable: a boat crosses it, so there is nothing to block. Water is not implicitly impassable, and an overlay that paints it red is claiming the opposite of the truth. Depth explains nothing: a bed level with its bank is 23.8% blocked and one more than 1.5 below it is 22.1%, with every bucket between within a point of those. Whether a river can be waded is a decision recorded here. - The ground flag is the tier number times 16, plus 8 for a ramp:
flag = 16 * tier + 8 * isRamp. Median height per value across all 232 maps —0→ 0.00,16→ 2.50,32→ 5.24,48→ 6.65,64→ 8.00, and80exists too. The step across each adjacent pair is exactly 2.00 at the median and ≥0.8 in 95–100% of cases. The ramp bit falls out of the same arithmetic:16→24and24→32each step 1.00, exactly half, so a ramp sits midway between two tiers — which is what makes it walkable rather than a wall. A cut therefore forms wherever the tier changes, so a plateau stacked on a plateau cuts against it; treating everything above ground as one "plateau" kind smooths that wall away. ⚠️ WindBell's spec is inaccurate here. There are no separate Plateau / Ramp / WaterDepth planes — those are bits of one flag plane.⚠️ Height is not the only plane with a visual effect. The flags decide where terrain breaks into a vertical cut and where it stays smooth, and the river plane carries painted water. Without them a map looks fundamentally different.- Navigability is the flag, and the water texture has nothing to do with it.
Flag
0marks swimmable water; those vertices sit at exactly0.0in 100.0% of the 62,788 flagged vertices measured across 60 shipped maps. The engine draws the surface itself — what is painted on a navigable bed is ordinary ground: DarkGround 29%, Conquest/Dirt 27%, Grass 14%, and 56% of such vertices carry no strong texture at all.Water.xdbdoes not appear in the top eight. Conversely a painted river keeps flag16and stays walkable however deep its bed. So a lake is dug withlower, not painted;Water.xdb,Bog.xdbandLavaFlow.xdbare decorative shallows you walk through. - Height
2.0is the default ground level, not water. A bed dug byloweris always exactly0.0.
Full write-up with the measurements: docs/TERRAIN_FORMAT.md.
data.pak(ZIP, ~1.4 GB): 62k.xdbXML descriptors, 9k.ddstextures, binary geometry inbin/Geometries/(3567 GUID files), skeletons inbin/Skeletons/(2242), animations inbin/animations/(3393).- The reference chain is plain XML: map object →
*.(AdvMapStaticShared).xdb(carriesblockedTiles, the footprint) →*.(Model).xdb→ geometry binary plus.dds. - Geometry binaries use the same container format as
GroundTerrain.bin(same08 <u32>and03 <sizeB>tags), but the mesh layout inside (vertex/index/submesh/material/skin) is more involved.
There is no public HoMM5 mesh parser; this one was reverse-engineered from scratch. Meshes decode to render-ready vertices and triangles with zero broken edges on reconstruction.
-
The container is a tree of records: scalar
tag 08 <u32>, blocktag <u32 sizeB> <body>withlen = (sizeB−1)/2.parseTree()walks it. -
A mesh node holds positions
count₁×vec3f, a remapcount₂×u16(all values < count₁) and indicestris×3×u16. The engine does a vertex split:render[i].pos = positions[remap[i]], with triangles indexing render vertices. The remap is identified unambiguously as the u16 array whose values are all< count₁. -
Positions are validated against the bbox in
.(Geometry).xdb. The file stores the data twice (LOD); the duplicate is dropped. -
UVs are the first 4 bytes of the attribute stream (
tag3): 2×int16 ÷ 2048 (V ∈ [0,1], U tiles). Confirmed by UV continuity across shared edges. -
Normals are computed from geometry — the packed ones are imprecise.
-
Textures are
.dds(DXT1/3/5 and uncompressed), decoded bysrc/format/dds.ts. -
Skeletons and animations are a different format altogether — RAD's Granny GR2 (docs/GR2_FORMAT.md), a self-describing container packed with RAD's Oodle1 codec, ported here rather than shelled out to the game's 32-bit DLL — and byte-exact against that DLL on every packed file (docs/OODLE1_FORMAT.md). What the decoded structures mean, and how the map plays them: docs/ANIMATION_FORMAT.md.
-
Particle effects (
bin/effects) are a baked Maya simulation — not emitter parameters but a recording: per particle a birth/death frame and 30 fps keys for position, rotation, size, colour and texture frame, so playback is interpolation and exact by construction. Verified byte-complete over the whole shipped library: docs/EFFECTS_FORMAT.md. -
Lighting is plain XML (
Lights/_(AmbientLight)), but drawing it right needed two discoveries — the game multiplies colours in gamma space, and the sun's Pitch counts from the zenith: docs/LIGHTING.md.
Still open: per-submesh material assignment. The 17 of 1572 shareds that
refuse to mesh turned out to be the game's own dead stubs — empty <Model/>
documents the original editor's palette never links to (the working
Sunflowers, snow Alchemist Lab and subterranean rails are DIFFERENT shareds;
verified against the original editor 2026-07-27) — refused with a message
when placed. Details in docs/GEOMETRY_FORMAT.md
and MESH_PLAN.md.
npm test runs every test-* script in package.json, each in its own
process (tools/test-all.ts). Two things about that are worth knowing before
you run it:
- it includes
test-e2e-nodataANDtest-e2e-fast, so it launches the real app under Playwright and runs every spec ine2e/—npm testis not a pure unit run, and it takes tens of minutes rather than seconds; - none of it writes into the game this checkout sits in. Specs work in the run's
own install (
_tmp/e2e-install, or whateverHOMM5_ROOTnames — which is how the live runner points them at a real one) and in map folders under the data root that they make and remove.test-campaignis the one exception: a unit tool, and it writes the campaign fixtures into<game>/H5E/.
Only test-e2e — which adds the C1M1 chain to the above — is left out; run it
deliberately.
The unit test-* scripts guard the format layer (byte-faithful round trips) and
the model. The north-star e2e, though, is rebuilding the shipped campaign
missions from scratch — one at a time in order, driving the real app through
Playwright and diffing each reconstruction against the original to surface, and
then close, whatever the editor can't yet express. See
docs/E2E_RECONSTRUCTION.md.
The first mission, C1M1, is rebuilt end to end: its whole GroundTerrain.bin
(heights, ground kinds, rivers, twelve texture layers, passability), all 2645
objects placed by clicking with their fractional positions and 80 facings, their
fields, the map settings, the 17 regions, the tile list, the objectives, the
localized texts, and the mission Lua — each a staged spec under e2e/
(c1m1/001-heights … c1m1/013-texts), closed by a capstone (c1m1/014-pack) that
verifies the whole map and packs it into a playable map file. npm run diff-terrain, diff-objects and diff-map are down to a handful of accepted
deviations the engine doesn't read. Every gap it hit became a feature above.
A second, one-minute reconstruction (e2e/mod-007-sharpshooter/) rebuilds a
map of our own — the showcase for the Heroes III port's Sharpshooter — against
the same three gap reports. It is the cheap regression the C1M1 chain cannot be,
and it covers what a shipped mission does not: a map whose creature, dwellings
and artifacts exist only because a mod is installed. Authoring that mod
through the window is e2e/units-create, artifacts-create and units-recolor
— one spec per thing, each on its own isolated game install so any of them can
run alone and the real one is never touched.
Most of that needs the game's data, which cannot be published, so it runs on a
machine that has the game. What does not need it is tagged @nodata and runs on
every push (.github/workflows/build.yml): thirteen tests that create a blank
map, pack and reopen it, write and localise its texts, and build a campaign —
our own formats, end to end through the real app, against a data root that is
two empty folders.
npm run test-e2e-nodataThe C1M1 chain is half an hour and rebuilds a map it leaves on disk, so it is not the check to run before every commit — and running it over the map a previous run built proves little, since idempotent stages find nothing to do. Delete that map when the reconstruction is what you mean to test; otherwise:
npm run test-e2e-fastThe terrain and object tooling is enough to build a map by hand; attention is on typed entity editing and reaching the next reconstructed mission.
- Typed per-type editors (Phase 4): towns (faction, buildings, garrison), heroes (class, army, artifacts, skills), creature stacks, players/teams — beyond the generic property panel.
- Multi-select, copy/paste.
- Terrain-tab parity, deferred: the texture
Up/Down/Eraser+Strengthmodifiers (so a layer's weight nudges and a tile can be erased) and thernd/smth/zero/waterterraforming tools. - Map validation on save, as the original does (overlaps, unset settings, towns with no specialisation), reported as a click-to-fly list.
- Dialog-scene editor + player (Phase 5b). The skeletal playback it needed is done; what is missing is driving clips other than idle.
- Campaign editor (
*.(Campaign).xdbis plain XML). - Fix the remaining undecoded meshes (see MESH_PLAN.md).
See ROADMAP.md for the full plan.
Contributions welcome — see CONTRIBUTING.md for the
conventions (byte-faithful formats, schema-driven editing, TS strip-mode
gotchas) and ROADMAP.md for open work. One hard rule: no game
content in commits — samples/ is gitignored; bring your own copy via
HOMM5_DATA.