The base recipe for adding an artifact (record, shared, icon, ceiling patch,
map's <artifactIDs>) is ARTIFACTS.md beside this file.
This document is the other half: what a new artifact can do — which knobs are
data, which behaviours a script can build, and which are compiled into the
executable and closed. Researched 2026-07-27 from the ToE data
(a2p1-data.pak, the addon layer that overrides base files), the shipped
script manuals (Editor Documentation/HOMM5_*.pdf), strings pulled out of
bin/H5_Game.exe, and how NAF and MMH5.5 do it.
A fourth layer — patching or hooking the engine itself — is where the port is headed, so that a new artifact is indistinguishable from a shipped one rather than emulated from outside. What that costs, and where the engine's own artifact code can be cut into, is in ENGINE_INTERNALS.md.
| layer | controls | ceiling |
|---|---|---|
data (.xdb in the mod archive) |
identity, price, slot, the six hero stats, set membership, every set effect's numbers, a handful of named constants (the Necromancer's Pendant among them) | cannot invent new behaviour |
script (advmap-common.lua hook) |
anything the adventure API can express: stats, skills, spells, creatures, resources, movement, custom abilities | no equip trigger (poll instead); a fixed list of mutable things; combat numbers out of reach |
| exe | every special property of the 97 shipped artifacts, keyed by id; the ten set behaviours and their thresholds | closed. A new id gets nothing and cannot borrow |
Every record in GameMechanics/RefTables/Artifacts.xdb has exactly the same
fields — verified by histogram over all 97, no record carries anything extra,
no spell references, no ability hooks anywhere. Beyond the known
name/description/model/Type/Slot/icon/CostOfGold/AIValue/HeroStatsModif:
CanBeGeneratedToSell— false excludes the artifact from merchants and random generation. False on the 8 quest/scripted items (GRAAL, ANGEL_WINGS, BOOTS_OF_LEVITATION, GOLDEN_SEXTANT, ARTIFACT_FREIDA, ARTIFACT_PRINCESS, ARTIFACT_RING_OF_THE_SHADOWBRAND, ARTIFACT_NONE). Set it false on plot artifacts so shops never sell them.ArtifactShared— href to theAdvMapArtifactSharedmap object. The link is mutual: the shared names the artifact back through<ArtifactID>.AvailableForPresets/PresetPrice— the duel-preset shop. False only on the four skill tomes; prices are −1 / 0 / 300–2000 gold.
Type spread: 28 MINOR, 37 MAJOR, 31 RELIC, 1 GRAIL. Slots: PRIMARY 14,
SECONDARY 11, HEAD 10, CHEST 9, FEET 9, SHOULDERS 11, NECK 8, FINGER 14,
MISCSLOT 8, INVENTORY 3 (the Grail and the two "carried person" tokens).
HeroStatsModif — Attack/Defence/Knowledge/SpellPower/Morale/Luck, plain
integers, negative allowed (the shipped cursed items use it). This is the
whole vocabulary of a record's own effect.
The exe hardcodes which artifact triggers a behaviour, but some magnitudes
live in GameMechanics/RPGStats/DefaultStats.xdb and can be retuned:
combat/HeroSkills/Necromancer/Necromancy:NecroPendantBonus= 10 (the Pendant's +necromancy %) andNecroPendant_CreatureCostDisountPercents= 10 (its dark-energy discount; the typo is canonical). Beside them:RaisePercentBase,RaisePercentPerSkillLevel,NecromancyAmplifierBonus,GrailBonus,EnergyBase,EnergyPerNecromancyAmplifier,CreaturePowerPointsForOneEnergy, and the full dead→undeadTransformTable— the whole necromancy economy is data.adventure:ChestMinorArtifactChance= 4,SacrificialAltar_ArtifactCostOfGoldToExpCoef= 0.5,Marketplace_ArtifactSellCostOfGoldCoef= 0.5.Banks: which artifacts creature banks pay out, per difficulty.
So: the strength of the Pendant's specials can be changed; the hook cannot be moved to another id.
Two lists in the map's <AdvMapDesc> gate artifacts at the map level:
<artifactIDs>— which artifacts exist on this map at all (see the base recipe; an artifact outside the list is refused everywhere on that map).disabledArtifactSets— sits among the map-desc fields (next toMapRumours,dialogs,teamsin the exe's field table): a per-map list disabling artifact sets. The stock editor exposes it; our reader should carry it through.
DefaultStats.xdb, block <ArtifactSets><Sets> — ten <Item> entries. Full
shape (the Lion set, smallest complete example):
<Item>
<Effect>ARTFSET_EFFECT_LIONS</Effect>
<Artifacts>
<Item>
<Artifact>CROWN_OF_COURAGE</Artifact>
<CombinesAtPuton>true</CombinesAtPuton>
<CombinesAtBackpack>false</CombinesAtBackpack>
</Item>
<!-- … one Item per member … -->
</Artifacts>
<NameFileRef href="ArtifactSets/Lions_Name.txt"/>
<DescriptionFileRef href="ArtifactSets/Lions_Desc.txt"/>
<CombinedDescriptionsFileRefs>
<Item href=""/>
<Item href=""/>
<Item href="ArtifactSets/Lions_Desc3.txt"/>
</CombinedDescriptionsFileRefs>
<CombinedHeroClassBonusesDescs>
<Item>
<HeroClass>HERO_CLASS_KNIGHT</HeroClass>
<BonusDescFileRef href="ArtifactSets/Lions_Desc2_Knight.txt"/>
</Item>
</CombinedHeroClassBonusesDescs>
<CombinedIcons>
<Item/>
<Item href="/Textures/HeroScreen/Artifacts/Lion_Hide_Cape.xdb#xpointer(/Texture)"/>
<Item href="/Textures/HeroScreen/Artifacts/Lion_Hide_Cape.xdb#xpointer(/Texture)"/>
</CombinedIcons>
</Item>The three Combined* arrays are per-piece-count: index N (0-based) is
"N+1 pieces worn"; an empty href means no tier at that count. That is display
only — the mechanical thresholds are compiled into the exe per Effect enum.
CombinesAtPuton/CombinesAtBackpack exist per member but are
uniformly true/false across all shipped data (a backpack-counted set piece is
untried territory). Set texts live in GameMechanics/RPGStats/ArtifactSets/*.txt
(relative hrefs resolve beside DefaultStats.xdb); tooltip framing in
Text/Tooltips/ArtifactSets/ (a2p1-texts.pak).
| Effect enum | set (pieces) | fires at | behaviour (constants in §below) |
|---|---|---|---|
| DRAGONISH | Power of the Dragons (8) | 2/4/6/8 | +all stats; tier-7 buffs; more stats; free tier-7 weekly |
| DWARVEN | Dwarven (4) | 2/4 | army 40% magic-proof (+Runemage SP%); buffs last 10 turns |
| LIONS | Lion (3) | 3 only | Knight: hero attacks demoralize target |
| MAGIS | Magi (4) | 2/4 | army casters ×2 SP; Wizard: cheaper ATB after hero cast |
| NECROMANCERS | Necromancer (4) | 2/4 | enemy −1 speed (+Banshee riders); bad-morale enemies −20% att/def, +20% necromancy, −25% raise cost |
| EDUCATIONAL | Enlightenment (2) | 2 | +15% experience |
| HUNTERS | Hunter (2) | 2 | shooters −30% ATB cost |
| OGRES | Ogre (2) | 2 | army +3 att / +2 HP; Barbarian ATB rider |
| RUNIC | Runic (2) | 2 | +1 all stats; Warlock (sic) Elemental Vision ×2 |
| DEMONIC | Demonic (2) | 2 | +5 attack; Demon Lord +25% gating |
Class riders (the 2Necromancer-style constants) apply only to that class.
The set debuffs are implemented as hidden spells — SPELL_ARTFSET_LIONS_DEMORALIZED
and SPELL_ARTFSET_NECROMANCERS_DEBUFF exist in the exe's spell table.
<ArtifactsSetsEffectsConsts> at the bottom of DefaultStats.xdb — flat
name→number pairs; the naming is <Set>_<threshold>[<Class>]_<what>. The exe
knows which constant belongs to which enum+threshold; you can retune every
number, you cannot add a constant or a new effect kind.
| constant | value |
|---|---|
| Dragonish_2_AllStats | 1 |
| Dragonish_4_Creature_Tier | 7 |
| Dragonish_4_Creature_Attack | 5 |
| Dragonish_4_Creature_Defence | 5 |
| Dragonish_4_Creature_HitPoints | 20 |
| Dragonish_6_AllStats | 3 |
| Dragonish_8_Creature_Tier | 7 |
| Dragonish_8_Creature_Count | 1 |
| Dwarven_2_Creature_MagicProofPercents | 40 |
| Dwarven_2Dwarf_SpellpowerPercents | 10 |
| Dwarven_4_Creature_SpellsBuffsTurns | 10 |
| Lions_2_HeroAttack_DeMorale | 2 |
| Magis_2_Casters_SpellpowerMultiplier | 2 |
| Magis_4_2Wizard_AfterCastATBLessPercents | 10 |
| Necromancers_2_EnemyCreature_DeSpeed | 1 |
| Necromancers_2Necromancer_BansheeWhail_DeMorale | 1 |
| Necromancers_2Necromancer_BansheeWhail_DeLuck | 1 |
| Necromancers_2Necromancer_BansheeWhail_DeInitiativeMultiplier | 2 |
| Necromancers_2Necromancer_BansheeWhail_ATBLessPercents | 2 |
| Necromancers_4_EnemyCreature_BadMoralePenaltyToAttackDefencePercents | 20 |
| Necromancers_4Necromancer_NecromancyBonusPercents | 20 |
| Necromancers_4Necromancer_NecromancyCreatureCostDisountPercents | 25 |
| Educational_2_GainExperienceBonusPercents | 15 |
| Hunters_2_ShootersATBLessPercents | 30 |
| Ogres_2_Creature_Attack | 3 |
| Ogres_2_Creature_HitPoints | 2 |
| Ogres_2Barbarian_HeroAttack_ATBLessPercents | 30 |
| Runic_2_AllStats | 1 |
| Runic_2Warlock_ElementalVisionMultiplier | 2 |
| Demonic_2_Attack | 5 |
| Demonic_2DemonLord_GatingBonusPercents | 25 |
(BansheeWhail, Disount, Hummer — the typos are canonical, reproduce them.)
A new <Item> in <Sets> is data, so a new set exists — membership,
tooltips, per-count texts and icons all work from the entry alone. Its
mechanics follow <Effect>, and there are three ways to fill that in:
- declare our own effect id.
ArtifactSetEffectis an ordinary enum intypes.xml, so a mod appendsARTFSET_EFFECT_<OURS> = 11exactly the way it appends an artifact id. Nothing shipped is displaced and nothing borrowed; the engine counts and draws the set and implements no behaviour, which is what we want since the behaviour will be ours. Confirmed in game on 2026-07-28: the twelfth value parses, the eleventh set is reached — there is no compiled ceiling on sets — it is named on the hero screen, and the game counts the worn pieces itself. This is the route the port takes — see engineInternals/EXTENSION.md. ARTFSET_EFFECT_CUSTOM(value 0) — the developers' own slot for "no predefined effect, add it with scripts", per the field's description intypes.xml, and confirmed unused by the 25 call sites in code. Fine for a quick experiment; still someone else's name for our thing.- borrow a shipped enum — the set then behaves as that set, thresholds and shared constants included (retuning one changes the donor too). Useful as a control to prove a bonus reaches the engine's arithmetic, not as a design.
The editor writes the first of those. addArtifactSet puts the enum entry
in types.xml and the row in DefaultStats.xdb, in the same single pass that
already patches types.xml for creatures and artifacts — a mod that shipped two
copies of that file would have the second win whole. It refuses a shipped
effect id outright, since taking one over is silent in the build and shows up
in play as the game's own set having quietly stopped.
One thing to get right, because the file format invites the opposite reading:
CombinedDescriptionsFileRefs holds one entry per member, indexed from one
piece worn. Every shipped set leaves the first blank, which looks like a
"nothing worn" slot and is not — read that way, every description arrives a
piece early and the set appears to combine sooner than it does. A bonus that
persists is repeated rather than left blank: the Dragonish set names its
two-piece text at both two and three pieces.
A set cannot be probed by an archive of its own, and the first attempt at
one was wrong for a reason worth writing down: a mod REPLACES a game file
rather than merging it, so two archives both carrying types.xml end with one
winning whole. A separate probe would have taken the port's creatures and
artifacts down with it — and the editor would have refused to add anything
after, since ourMod() throws when UserMODs holds more than one mod with a
manifest. Everything that edits a game file rides in the one archive.
The probe therefore lived with the mod it joined, as a script beside the port's other probes that added the set to the port's own mod. It has served its purpose and is gone: the Cloak of the Undead King is authored through the Artifacts dialog now, and is a fixture of e2e/mods.ts.
Global hook: scripts/advmap-startup.lua runs on every adventure map and ends
with doFile("/scripts/advmap-common.lua") — a mod overrides the latter. A
startThread + sleep polling loop is steadier than SetTrigger (one
handler slot per trigger kind; a map's own script would take it).
| call | gives |
|---|---|
HasArtefact(hero, id, onlyEquipped=0) |
possession. The third argument is real but undocumented — with 1 the engine checks only the equipped slots and never opens the backpack (read out of the code, engineInternals/ARTIFACTS_AND_EQUIPMENT.md). Worn-state detection therefore needs no set at all |
GetArtifactSetItemsCount(hero, setID, onlyCombined=1) |
worn count of a set's members; onlyCombined=0 counts the backpack too. Useful for tiered set bonuses |
GiveArtefact(hero, id, bindToHero=0) |
grant; bindToHero=1 makes it untransferable |
RemoveArtefact(hero, id) |
take away (errors if absent — guard with HasArtefact) |
setID is the ARTFSET_EFFECT_* enum value, not a position in the
<Sets> list — the engine's own necromancy code calls it with 5 for the
Necromancer set, and 5 is where NECROMANCERS sits in the enum. A custom set
is therefore index 0.
ChangeHeroStat(hero, statID, delta)— deltas on STAT_ATTACK / STAT_DEFENCE / STAT_SPELL_POWER / STAT_KNOWLEDGE / STAT_LUCK / STAT_MORALE / STAT_MOVE_POINTS / STAT_MANA_POINTS / STAT_EXPERIENCE (experience only upward). Values clamp at 0 and at move/mana maxima. Read back withGetHeroStat.- Skills and perks:
GiveHeroSkill,HasHeroSkill(true also when granted by an artifact),GetHeroSkillMastery. No RemoveHeroSkill — a granted skill stays;TakeAwayHeroExpstrips skills but randomly, it is not an undo. - Spells:
TeachHeroSpell,KnowHeroSpell. No forget-spell call — NAF ran into the same wall and made spell-granting artifacts permanent-by-design ("Ancient Relics"). - Army:
AddHeroCreatures/RemoveHeroCreatures/GetHeroCreatures. MakeHeroNecromancer(hero, level)— any hero raises undead after combat at the given necromancy level (no skill needed, none granted). The honest script equivalent of "+necromancy" for a non-necromancer; for a necromancer, add stacks directly after battles instead.ControlHeroCustomAbility(hero, CUSTOM_ABILITY_1..4, mode)— up to four activatable buttons in the hero's spellbook; activation firesCUSTOM_ABILITY_TRIGGERwith(heroName, abilityID). Names, descriptions and icons come fromGameMechanics/RPGStats/Skills.xdb. The way to give an artifact an activated power.- Economy and misc:
SetPlayerResource/GetPlayerResource(daily-income artifacts),GiveExp,OpenCircleFog(vision artifacts),GiveHeroWarMachine/RemoveHeroWarMachine.
Adventure triggers: NEW_DAY_TRIGGER(0), PLAYER_ADD_HERO_TRIGGER(1),
PLAYER_REMOVE_HERO_TRIGGER(2), OBJECTIVE_STATE_CHANGE_TRIGGER(3),
OBJECT_TOUCH_TRIGGER(4), OBJECT_CAPTURE_TRIGGER(5),
REGION_ENTER_AND_STOP_TRIGGER(6), REGION_ENTER_WITHOUT_STOP_TRIGGER(7),
HERO_LEVELUP_TRIGGER(8), WAR_FOG_ENTER_TRIGGER(9),
TOWN_HERO_DEPLOY_TRIGGER(10), plus CUSTOM_ABILITY_TRIGGER.
There is no equip/unequip trigger and no hero-screen event. A worn-state
bonus is a polling thread: each tick read the state
(GetArtifactSetItemsCount for set members, HasArtefact otherwise),
diff against the last tick, apply/remove deltas with ChangeHeroStat, and
keep the bookkeeping in SetGameVar/GetGameVar so it survives save/load.
Anything granted while worn must be undone by the script when the artifact
leaves — which is why reversible bonuses (stat deltas) age better than
irreversible ones (skills, spells).
A combat script (SetHeroCombatScript(hero, ref), or per-arena) sees the
battle through the COMBAT API — and the exe registers far more of it than the
manual admits (EXE_LUA_REGISTRY.md): beside the
documented GetAttackerHero/Get*Creatures/AddCreature/Finish there are
undocumented setATB, UnitCastAimedSpell/UnitCastAreaSpell/
UnitCastGlobalSpell, commandDoSpell, SummonCreature,
SetUnitManaPoints, displace, addUnit/removeUnit. So a combat script
can move ATB, cast any spell through a unit, summon and reposition stacks —
an artifact's combat effect can be "on combat start, cast X / shift ATB by Y"
(signatures need in-game probing; none of this is in the manuals). What
remains out of reach is the damage formula itself — there is no damage hook,
so a literal "+50% fire damage" stays exe-only. On the adventure side,
undocumented GetLastSavedCombatIndex makes post-combat detection clean
(poll it, then read the combat through the GetSavedCombat* family). NAF
adds from experience: combat-script tricks break in multiplayer (tactical
scripting is prohibited there) — single-player only.
One row per term, written by the editor from the mod and read by
native/homm5-editor.c at load:
necromancy artifact 97 5 # artifact 97 worn -> +5% raised
energy set 2 150 97 98 99 # any 2 of those three worn -> +150 ceiling
Both forms are the same question inside the extension — count how many of these ids are worn; if at least N, add this — which is why a single artifact is a one-member row with a threshold of 1. A set of ours is nothing the executable has to recognise, and an id may appear in as many rows as it likes; each is counted on its own.
| want | verdict |
|---|---|
| ±primary stats, luck, morale while worn | script (poll + ChangeHeroStat), fully reversible |
| +movement, +mana, daily gold/resources | script, natural fits (STAT_MOVE_POINTS, SetPlayerResource on new day) |
| +% necromancy on a new artifact | natively, via our own set and a hook. The raise percentage is one sum (engineInternals/NECROMANCY.md) whose last term is already "worn pieces of a set ≥ threshold → add a number from data"; our own term is the same twenty bytes with our own set id and number. Script fallback for a hero without the skill: MakeHeroNecromancer (the engine consults it only when the skill is 0) |
| grant a spell / skill while worn | one-way only — no removal calls; treat as permanent (NAF's compromise) |
| activated artifact power | ControlHeroCustomAbility + CUSTOM_ABILITY_TRIGGER |
| +% fire (or any element) damage | impossible for a new id. Exe-only (the Trident/Icicle/Cape family); no damage hook in any script API |
| ATB/initiative effects, combat-start spell casts | combat script, via undocumented setATB / UnitCast* (single-player; signatures to be probed) |
| new set with own thresholds/behaviour | done. Membership and texts are data; the BONUS is a row the extension reads and the THRESHOLD is ours, because the extension counts the worn members itself rather than asking the engine's set accessor (which answers 0 for an effect of ours). "Two of three" is expressible, which no shipped effect is. What the set does on an EVENT is a script it carries — artifact-scripts.ts |
| dark energy grants | done, natively. There is no setter because the engine does not set the pool: it keeps a CEILING of four numbers and fills to it. A term of ours is a fifth (engineInternals/NECROMANCY.md), and RestoreDarkEnergy(player) — a Lua function the extension registers — asks a player to refill out of turn. Seen in game 2026-07-29 |
| backpack-passive artifact | trivially scriptable — poll HasArtefact and skip the worn check (NAF ships this as a feature) |
| auto-combining artifacts | script: detect all parts via HasArtefact, RemoveArtefact them, GiveArtefact the combined one (NAF does exactly this, with a one-day delay) |
Both confirm the layering rather than escape it:
- NAF (New Artifacts Framework, heroesworld.ru): states outright that standard artifact properties cannot be bound to new items — only strategic-mode scripts. Ships backpack-passives, uncombinable-slot items and auto-combining "Ancient Relics" (collect all parts → merged next day, bound to hero); spell-granting artifacts are permanent because spells cannot be unlearned; notes tactical-mode scripting is unavailable in multiplayer.
- MMH5.5: 103 new artifacts, all effects through their Lua framework
(near-complete Lua 4.0 library on both adventure and combat maps); artifact
ids declared in their
advmap-startup.lua; mapmaker knobs likeH55_RemoveTheseArtifactsFromBanksare script variables.
Sources: NAF thread, MMH5.5 scripting tutorial, MMH5.5 artifact release, MMH5.5 on ModDB.
A new set (e.g. the King of the Dead cloak set):
- Add the artifacts (base recipe: ARTIFACTS.md).
- Append
ARTFSET_EFFECT_<OURS>to theArtifactSetEffectenum intypes.xml— append only, the value is what saves and maps store. - Append an
<Item>to<ArtifactSets><Sets>in our override ofDefaultStats.xdbusing that effect: members, texts, per-count icons. - Supply the behaviour. Natively via a hook is the target
(engineInternals/EXTENSION.md);
a Lua thread polling
GetArtifactSetItemsCount(hero, ourId, 1)and applyingChangeHeroStatdeltas is the stopgap that needs no native code, with the seams listed above (no equip event, script must undo its own deltas). - Remember: one archive for everything — a second archive touching
types.xmlorDefaultStats.xdbsilently loses (mods replace files, never merge).
A custom property on a single artifact: same loop with
HasArtefact (possession-only granularity) — or make it a one-piece set
to get worn-detection.
-
ARTFSET_EFFECT_CUSTOMparses, draws the set tooltip, fires nothing. -
GetArtifactSetItemsCountaddresses an added set (by index?) and counts our artifacts. - An 11th set's tooltip/UI renders at all (the manager may cap at ten).
-
CombinesAtBackpack=trueactually counts backpack pieces for the exe effects (never used in shipped data). -
disabledArtifactSetsround-trips through our map reader/writer.
Micro-artifacts (Academy) mirror the big pattern: shells and prefixes are data
(MicroArtifactShells/Prefixes.xdb), the 11 effect magnitudes
(MicroArtifactEffects.xdb ids) are exe-hardcoded per id — same wall, smaller
bricks.