Skip to content

Latest commit

 

History

1,005 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

homm5-editor

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 to data-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.

What works

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.

Terrain

  • 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.

Objects

  • Placement that reaches a shipped map: an object palette catalogued from the 1466 _(AdvMapObjectLink) files, grouped by Editor/MapFilters.xml, with icons from Editor/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 $defs declared once in src/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. off is 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 AmbientLight preset 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 properties, model & scripting

  • map.xdb model (src/map/map.ts, src/format/xml.ts): loss-less XML DOM — serialize(parse(x)) === x on 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's MapScript row creates the .lua + its .xdb wrapper and binds it. A structural linter (src/script/lua-lint.ts) marks what the engine's parser rejects — unbalanced end/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, packing, localization

  • 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. A project.json manifest tracks file hashes at pack time for git 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_TO moves 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 is H5E/<name>.h5m; New map… writes one at once, and everything the editor installs — the mod, campaigns — goes beside it. npm run mod-paths says 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 .pak archives (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 .h5m is 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's units.json). A creature the game never had ships as a .h5u carrying 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 a bin/H5_Game_H5E.exe already patched to any number goes to any other in place (src/exe/creature-limit.ts, npm run creature-limit to look). Steam's own executable is DRM-wrapped, so its code cannot be read or patched at all; npm run unwrap-exe makes 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 BuildingType values 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 from types.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 plain name.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 a localization.json sidecar 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 .h5c the 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.

Running

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-data

which 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.

A standalone build

npm run dist

bundles 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):

  1. the archives unpacked into a tree we can read;
  2. 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;
  3. our extension compiled in and named by that copy's import table;
  4. 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.

GroundTerrain.bin

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. V comes 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, 0 blocked / 1 walkable), 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 + x is 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_Rock 92.4%, Grass/Rock_Floor_grass 75.5%, a drop steeper than 2 units 25.0%, Water/LavaFlow 26.4%, Water/Bog 24.6%, and sea (flag 0) 6.4% — below background, because flag 0 means 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, and 80 exists 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→24 and 24→32 each 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 0 marks swimmable water; those vertices sit at exactly 0.0 in 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.xdb does not appear in the top eight. Conversely a painted river keeps flag 16 and stays walkable however deep its bed. So a lake is dug with lower, not painted; Water.xdb, Bog.xdb and LavaFlow.xdb are decorative shallows you walk through.
  • Height 2.0 is the default ground level, not water. A bed dug by lower is always exactly 0.0.

Full write-up with the measurements: docs/TERRAIN_FORMAT.md.

Assets and 3D models

  • data.pak (ZIP, ~1.4 GB): 62k .xdb XML descriptors, 9k .dds textures, binary geometry in bin/Geometries/ (3567 GUID files), skeletons in bin/Skeletons/ (2242), animations in bin/animations/ (3393).
  • The reference chain is plain XML: map object → *.(AdvMapStaticShared).xdb (carries blockedTiles, the footprint) → *.(Model).xdb → geometry binary plus .dds.
  • Geometry binaries use the same container format as GroundTerrain.bin (same 08 <u32> and 03 <sizeB> tags), but the mesh layout inside (vertex/index/submesh/material/skin) is more involved.

Mesh geometry — working

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>, block tag <u32 sizeB> <body> with len = (sizeB−1)/2. parseTree() walks it.

  • A mesh node holds positions count₁×vec3f, a remap count₂×u16 (all values < count₁) and indices tris×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 by src/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.

Testing

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-nodata AND test-e2e-fast, so it launches the real app under Playwright and runs every spec in e2e/npm test is 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 whatever HOMM5_ROOT names — 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-campaign is 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-heightsc1m1/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-nodata

The 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-fast

Next

The 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 + Strength modifiers (so a layer's weight nudges and a tile can be erased) and the rnd/smth/zero/water terraforming 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).xdb is plain XML).
  • Fix the remaining undecoded meshes (see MESH_PLAN.md).

See ROADMAP.md for the full plan.

Contributing

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 commitssamples/ is gitignored; bring your own copy via HOMM5_DATA.

About

Map editor for Heroes of Might & Magic V: Tribes of the East, on Electron. Reverse-engineered .pak/map.xdb/GroundTerrain.bin, live 3D terrain rendering and editing. Contains no game content.

Topics

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages