From Fire Emblem Heroes Wiki
Jump to: navigation, search
Template-info.svg Documentation

This module provides functions to display unit data, store them into the wiki database, and automatically adjust unit data for derived maps. It is heavy optimized and designed to replace its template equivalent.

Usage[edit source]

The entry point of this module is simply main. Two different sets of parameters are accepted by the module, depending on whether unit data is defined or retrieved from the database.

Unit definitions[edit source]

To define units for a map, add the following to the map page itself:

|mapImage=<!-- map image, sometimes optional -->
{unit=;pos=;rarity=;slot=;level=;stats=[hp;atk;spd;def;res];weapon=;assist=;special=;a=;b=;c=;seal=}; <!-- less verbose -->
<!-- other units -->
|Hard=[<!-- more units -->]
|Lunatic=[<!-- more units -->]
|<!-- other tabs -->
  • mapImage: (Optional) Map image using Template:MapLayout or {{#invoke:MapLayout|map}}. Initial units should not be added, they are automatically populated by this module. The same map image is used for all unit tabs. This parameter is required if any unit specified in the module is a reinforcement unit.
  • globalai: (Optional) The default AI setting for units that do not have their own ai specifications. This is mostly useful for manually inputted unit data. Apart from the values accepted for unit definitions, the following string values expand to commonly used settings:
    • activeall: {turn=1}. This is also the default AI setting if this parameter is not given.
    • passivelinked: {group=1}
    • passivesingle: {}
  • no cargo: (Optional) If true, do not store any units into the Cargo database. Random units (see below) are never stored regardless of whether this parameter is set.
  • no ai: (Optional) If true, do not show the AI settings tables.
  • Other parameters: Any parameter name that isn't one of the parameters listed above or a derived map parameter is considered a tab name. The parameter value is an ObjectArg list of hashes defining the units for the Normal tab. The difficulty of the tab is automatically determined from the tab name.

Unlike the old templates, units no longer need to be defined in separate groups depending on their armies and reinforcement settings; the module automatically groups units and generates multiple unit tables as necessary.

Derived maps[edit source]

To display units for a derived map, use the following parameters instead:

  • battle: Battle number for the derived map, e.g. 1 for the first battle.
  • derived: One of the tags for derived maps. See below for a complete list of supported values.
  • derivedMap: (Optional) Page name of the base map where the derived units were originally defined. Defaults to the current page (it is possible to display derived unit stats on the same page they are defined, but this is no longer done).
  • derivedTabs: An ObjectArg hash which maps tab names from the original map to tab names of the derived map. In the example above, the Normal tab contains derived units from the original map at Normal difficulty, while the Lunatic tab contains derived units from Hard difficulty. The new tab names must be supported by the derived setting for unit promotion to occur.
  • mapImage: Map image using Template:MapLayout or {{#invoke:MapLayout|map}}. Initial units should not be added, they are automatically populated by this module. The same map image is used for all unit tabs.
  • allyPos: Comma-separated list of ally starting positions, same as in Template:MapLayout. On derived maps with reinforcements, these starting positions must be declared outside the mapImage parameter, so that they will be placed only in the infobox but not in reinforcement layouts.

List of derived map settings[edit source]

A list of values that can be used for the derived parameter, followed by the tab names supported by that setting.

Derived map settings
  • blessed_gardens: Normal, Hard, Lunatic, Infernal
  • blessed_grounds_even: Infernal
  • blessed_grounds_odd: Lunatic, Infernal
  • cc_paralogue_even1_double: Normal, Hard, Lunatic
  • cc_paralogue_even1_single: Normal, Hard, Lunatic
  • cc_paralogue_even1_single_cx019: Normal, Hard, Lunatic
  • cc_paralogue_even2_double: Normal, Hard, Lunatic
  • cc_paralogue_even2_single: Normal, Hard, Lunatic
  • cc_paralogue_even3_double: Normal, Hard, Lunatic
  • cc_paralogue_even3_single: Normal, Hard, Lunatic
  • cc_paralogue_even3_single_cx019: Normal, Hard, Lunatic
  • cc_paralogue_odd1_double: Normal, Hard, Lunatic
  • cc_paralogue_odd1_double_cx019: Normal, Hard, Lunatic
  • cc_paralogue_odd1_single: Normal, Hard, Lunatic
  • cc_paralogue_odd1_single_cx019: Normal, Hard, Lunatic
  • cc_paralogue_odd2_double: Normal, Hard, Lunatic
  • cc_paralogue_odd2_single: Normal, Hard, Lunatic
  • cc_paralogue_odd3_double: Normal, Hard, Lunatic
  • cc_paralogue_odd3_single: Normal, Hard, Lunatic
  • cc_paralogue_odd3_single_cx019: Normal, Hard, Lunatic
  • cc_story_even1_double: Normal, Hard, Lunatic
  • cc_story_even1_single: Normal, Hard, Lunatic
  • cc_story_even2_double: Normal, Hard, Lunatic
  • cc_story_even2_single: Normal, Hard, Lunatic
  • cc_story_even3_double: Normal, Hard, Lunatic
  • cc_story_even3_single: Normal, Hard, Lunatic
  • cc_story_even4_double: Normal, Hard, Lunatic
  • cc_story_even4_single: Normal, Hard, Lunatic
  • cc_story_even5_double: Normal, Hard, Lunatic
  • cc_story_even5_single: Normal, Hard, Lunatic
  • cc_story_odd1_double: Normal, Hard, Lunatic
  • cc_story_odd1_single: Normal, Hard, Lunatic
  • cc_story_odd2_double: Normal, Hard, Lunatic
  • cc_story_odd2_single: Normal, Hard, Lunatic
  • cc_story_odd3_double: Normal, Hard, Lunatic
  • cc_story_odd3_single: Normal, Hard, Lunatic
  • cc_story_odd4_double: Normal, Hard, Lunatic
  • cc_story_odd4_single: Normal, Hard, Lunatic
  • cc_story_odd5_double: Normal, Hard, Lunatic
  • cc_story_odd5_single: Normal, Hard, Lunatic
  • squad_assault: Lunatic

The tags have the following usage:

  • blessed_gardens is applied to all Blessed Gardens.
  • blessed_grounds_odd is applied to all odd-numbered Grounds (e.g. Grounds of Earth: Earth 1, Grounds of Fire: Fire 3), similarly for blessed_grounds_even.
  • cc_paralogue_odd* is applied to odd-numbered Paralogue chapters with corresponding part numbers. single is for single-chapter Chain Challenges, double is for double-chapter Chain Challenges. cc_paralogue_even* is for even-numbered chapters.
  • cc_story_odd* and cc_story_even* are used similarly but for story maps. Book number does not matter.
  • squad_assault is applied to all Squad Assault maps.

Unit definition[edit source]

Each hash inside a unit data list defines a single ally or enemy unit, corresponding to a single use of the old Template:UnitData. More precisely, each hash represents a spawn entry which may be used to produce multiple identical reinforcement units.

  • unit: Page name of the unit.
  • pos: Coordinate of the unit. Like Template:MapLayout, a1 corresponds to the bottom left corner.
  • rarity: Rarity of the unit.
  • level: Level of the unit. Levels above 40 can be obtained from internal map definitions, but all known values for 40+ levels can be deduced from the map mode alone.
  • slot: Internal slot order of the unit. The slot order 1 is assigned to the first unit defined in the map file, and increments by 1 for every other unit defined. On maps without reinforcements, this is equal to the slot index assigned to the spawned unit, and can be obtained by experimenting with the game AI.
  • stats: A 5-element array containing the base stats of the unit, excluding any stats coming from equipped skills and other skill adjustments such as Arena bonuses or blessings. This is not always equal to final stat subtracted by stat modifiers from skills because the game clamps final stats between 0 and 99, but such cases are extremely rare; consult the internal map definitions when in doubt.
  • weapon: Page name of the unit's equipped weapon. Use - to indicate absence of an equipped weapon.
  • refine: (Optional) Refine path of the unit's weapon. Must be one of atk, spd, def, res, skill1, or skill2.
  • assist, special, a, b, c, seal: Skill name of the unit's equipped Assist / Special / Passive A / Passive B / Passive C / Sacred Seal. Use - to indicate absence of an equipped skill.
  • accessory: (Optional) Page name of the unit's equipped accessory.
  • cooldown: (Optional) Initial Special cooldown count of the unit. Only needed for units with a non-default cooldown count (but the module does not check whether the given count is indeed less than the default count).
  • max_cooldown: (Optional) Maximum Special cooldown count of the unit. Only needed for units with a non-default maximum cooldown count.
  • properties: (Optional) Comma-separated list of properties applied to the unit. Currently the following properties are accepted:
    • is_ally: Indicates that the unit is an ally unit rather than an enemy unit. Ally units have their own sections separate from enemy units.
    • use_ally_stats: When used on an enemy unit that has both playable and enemy variants (e.g. Surtr Ruler of Flame Face FC.webp Surtr: Ruler of Flame), indicates that the playable variant should be used for stat calculation and skill promotion on derived maps.
    • use_enemy_stats: When used on an ally unit that has both playable and enemy variants, indicates that the enemy variant should be used for stat calculation and skill promotion on derived maps.
  • ai: (Optional) AI settings for the individual unit. If omitted, uses the globalai value of the module invocation, otherwise completely overrides that default setting (note that this parameter does not accept simple string values). It should be a hash with the following subfields, all optional:
    • turn: The first turn from which the unit starts moving on its own.
    • group: The AI group number of the unit; when any unit from a group is engaged, all units from that group start moving.
    • delay: The number of turns the unit may wait after its AI group is engaged but before the unit starts moving.
    • break_walls: Indicates the unit will destroy breakable terrain.
    • tether: Indicates the unit will return to its starting position if it has no actions to take. (Needs further investigation.)
  • spawn: (Optional) Spawn settings for the unit. If omitted, the unit appears in the initial setup of the map, otherwise this entry represents one or multiple reinforcements. It should be a hash with the following subfields, all optional:
    • turn: The first turn starting from which reinforcements appear.
    • count: The maximum number of reinforcement units to appear.
    • target: The page name of the target unit used to check for spawning.
    • remain: The maximum number of target units remaining on the map before reinforcements appear.
    • kills: The minimum number of target units to kill before reinforcements appear.
    • cond: Only for old special map pages where reinforcement settings can no longer be tested. If present, overrides the other setting fields and uses it as the reinforcement description in the map visual.

The module tracks pages containing unit definitions where any of the weapon, assist, special, a, b, c, or seal parameters are not given.

Random units[edit source]

On Tempest Trials only, random units can also be specified through this template. The unit definition takes a shorter format:


The pos, rarity, slot, level, ai, and spawn fields share their meanings with non-random units. The random argument is a hash with the following subfields:

  • moves: (Optional) Move type of the unit. Omit to allow all move types.
  • weapons: (Optional) Weapon type of the unit, or any of the weapon class names such as "Ranged" or "Physical". Omit to allow all weapon types.
  • staff: (Optional) If explicitly set to false, excludes staff weapon types.

Known values for 40+ levels[edit source]

Map mode Difficulty Level
Story maps Lunatic 40 / 41 / 42 / 43 / 45
Paralogue maps Luantic 40 / 42 / 45
Grand / Bound / Legendary / Mythic Hero Battles Infernal 45
Abyssal 50
Tempest Trials Lunatic (7×) 40 / 41 / 42 / 43 / 44 / 45 / 45

Examples[edit source]

local Util = require 'Module:Util'
local List = require 'Module:ListUtil'
local Hash = require 'Module:HashUtil'
local FEHStatUtil = require 'Module:FEHStatUtil'
local Tab = require 'Module:Tab'
local MapLayout = require 'Module:MapLayout'
local parseArgs = require 'Module:ObjectArg'.parse
local memoizer = require 'Module:Memoizer'.memoizer
local superimposeDiv = require 'Module:Superimpose'.div
local stripWikitext = require('Module:StripWikitext').main1
local escq = require 'Module:EscQ'.main1
local toboolean = require 'Module:Bool'.toboolean
local mf =
local cargo = mw.ext.cargo

local STATS = {'HP', 'Atk', 'Spd', 'Def', 'Res'}
local SKILL_CATEGORIES = {'weapon', 'assist', 'special', 'a', 'b', 'c', 'seal'}
local DIFFICULTY_ORDER = Util.getDifficulties()
local EMPTY_STAT_MODIFIERS = {0, 0, 0, 0, 0}
local EMPTY_STRING = "-"
local EMPTY_TEXT_LONG = "—"
local UNKNOWN_TEXT = "?"
local ORDERING_MIXIN = List.ordering_mixin()

local memoizeTable = function (f)
	local mt = {__metatable = 'memoizeTable'}
	local done = false
	mt.__index = function (_, k)
		done = true
		mt.__index = f()
		return mt.__index[k]
	mt.__pairs = function (_)
		if not done then
			mt.__index = f()
		return pairs(mt.__index)
	return setmetatable({}, mt)
local MOVE_TYPES = memoizeTable(function ()
	return Hash.from_ipairs(cargo.query("MoveTypes", "IconFile,Name,_pageName,WikiName,Sort", {groupBy = 'WikiName', limit = 100}), function (row)
		row.Sort = tonumber(row.Sort)
		return row.WikiName, row
local WEAPON_TYPES = memoizeTable(function ()
	return Hash.from_ipairs(cargo.query("WeaponTypes", "IconFile,Name,_pageName,WikiName,Classes__full=Classes,Type,Sort", {groupBy = 'WikiName', limit = 100}), function (row)
		row.Sort = tonumber(row.Sort)
		row.Classes = mw.text.split(row.Classes, '%s*,%s*')
		return row.WikiName, row

local memoizeUnits = memoizer(function (name)
	local result = cargo.query(
		  IF(Properties__full LIKE '%enemy%','1','')=isEnemy,IF(Properties__full LIKE '%generic%','1','')=isGeneric,
		  Lv1HP5,Lv1Atk5,Lv1Spd5,Lv1Def5,Lv1Res5,HPGR3,AtkGR3,SpdGR3,DefGR3,ResGR3]], {
			join = 'Units.WikiName=UnitStats.WikiName',
			where = ("'%s' IN (Units._pageName,IFNULL(CONCAT(Name,': ',Title),Name))"):format(escq(name)),
			limit = 2, -- ally + enemy
	for _, v in ipairs(result) do
		v.isEnemy = v.isEnemy ~= ''
		v.isGeneric = v.isGeneric ~= ''
	return result

local memoizeUnitsWithWikiName = function ()
	memoizeUnits = memoizer(function (name)
		local result = cargo.query(
			  IF(Properties__full LIKE '%enemy%','1','')=isEnemy,IF(Properties__full LIKE '%generic%','1','')=isGeneric,
			  Lv1HP5,Lv1Atk5,Lv1Spd5,Lv1Def5,Lv1Res5,HPGR3,AtkGR3,SpdGR3,DefGR3,ResGR3]], {
				join = 'Units.WikiName=UnitStats.WikiName',
				where = ("Units.WikiName='%s'"):format(escq(name)),
				limit = 2, -- ally + enemy
		for _, v in ipairs(result) do
			v.isEnemy = v.isEnemy ~= ''
			v.isGeneric = v.isGeneric ~= ''
		return result

--[[ {default skill at 1*, ..., default skill at 5*} ]]
local memoizeUnitWeapons = memoizer(function (wikiname)
	local result = cargo.query('Units,UnitSkills,Skills', "skill,defaultRarity,skillPos", {
		join = 'Units.WikiName=UnitSkills.WikiName,UnitSkills.skill=Skills.WikiName',
		where = ("Units.WikiName='%s' AND Scategory='weapon'"):format(escq(wikiname)),
		limit = 10,
	for _, v in ipairs(result) do
		v.defaultRarity = tonumber(v.defaultRarity)
		v.skillPos = tonumber(v.skillPos)

	result =, function (v) return v.defaultRarity ~= nil end)
	table.sort(result, function (x, y) return x.defaultRarity < y.defaultRarity end)
	return List.generate(5, function (rarity)
		local s = select(2, List.rfind_if(result, function (v) return v.defaultRarity <= rarity end))
		return s and s.skill or false

local disableSkillDesc = false

local memoizeSkill = (function ()
	local DEFAULT_ICONS = {
		weapon = "Icon_Skill_Weapon.png",
		assist = "Icon_Skill_Assist.png",
		special = "Icon_Skill_Special.png",
		a = "Empty_Passive_Icon.png",
		b = "Empty_Passive_Icon.png",
		c = "Empty_Passive_Icon.png",
		seal = "Empty_Passive_Icon.png",
	local MISSING_ICONS = {
		weapon = "Icon_Skill_Weapon.png",
		assist = "Icon_Skill_Assist.png",
		special = "Icon_Skill_Special.png",
		a = "Passive_No_Image.png",
		b = "Passive_No_Image.png",
		c = "Passive_No_Image.png",
		seal = "Passive_No_Image.png",
		weapon = "Scategory='weapon'",
		assist = "Scategory='assist'",
		special = "Scategory='special'",
		a = "Scategory='passivea'",
		b = "Scategory='passiveb'",
		c = "Scategory='passivec'",
		seal = "Scategory LIKE 'passive_' OR Scategory='sacredseal'",

	return memoizer(function (skillName, category, refine)
		if Util.isNilOrEmpty(skillName) then
			return {ic = MISSING_ICONS[category], name = UNKNOWN_TEXT, StatModifiers = EMPTY_STAT_MODIFIERS}
		elseif skillName == EMPTY_STRING then
			return {ic = DEFAULT_ICONS[category], name = EMPTY_TEXT_LONG, StatModifiers = EMPTY_STAT_MODIFIERS}

		local query = cargo.query("Skills", "CONCAT(Icon)=ic,_pageName=page,Name=name,Description=desc,WikiName=wikiname,StatModifiers,Cooldown=cd,Next=nextSkill,PromotionRarity=prarity,PromotionTier=ptier", {
			where = ("'%s' IN (Name,_pageName,WikiName) AND (%s) AND IFNULL(RefinePath,'-')='%s'"):format(escq(skillName), CATEGORY_QUERIES[category], refine or "-"),
			limit = 1,
		if not query then
			return {invalid = true, ic = MISSING_ICONS[category], name = skillName, StatModifiers = EMPTY_STAT_MODIFIERS}

		if disableSkillDesc then
			query.desc = nil
		if query.ic == '' then
			query.ic = DEFAULT_ICONS[category]
		end = tonumber(
		query.prarity = tonumber(query.prarity)
		query.ptier = tonumber(query.ptier)
		query.StatModifiers = List.map_self(mw.text.split(query.StatModifiers, ','), function (x) return tonumber(x) end)
		for i = 1, 5 do
			query.StatModifiers[i] = query.StatModifiers[i] or 0
		return query
	end, function (skillName, category, refine)
		return (skillName or '') .. ';' .. (category or '') .. ';' .. (refine or '')

local objectArgStruct = function (t)
	if t then
		local assocs = {}
		for k, v in Hash.sorted_pairs(t) do
			assocs[#assocs + 1] = ('%s=%s'):format(k, v)
		return ('{%s}'):format(table.concat(assocs, ';'))

local Template_Hover_nocargo = function (text, title)
	if Util.isNilOrEmpty(title) then
		return text
	return tostring(mw.html.create('span'):attr('title', mw.text.decode(title)) -- mw.html automatically HTML-escapes attributes
		:css('border-bottom', '0'):css('text-decoration', 'underline dotted'):css('cursor', 'help')

local Template_SkillPage_nocargo = function (page, name, desc)
	if Util.isNilOrEmpty(name) then
		return Util.isNilOrEmpty(page) and '' or ('[[%s]]'):format(page)
	if Util.isNilOrEmpty(page) then
		return name or ''
	return ('[[%s|%s]]'):format(page, Util.isNilOrEmpty(desc) and name or Template_Hover_nocargo(name, stripWikitext(desc)))

local getMoveIcon = function (wikiname)
	local mov = MOVE_TYPES[wikiname]
	if mov then
		return ('[[File:%s|x26px|alt=%s|link=%s]]'):format(mov.IconFile, mov.Name, mov._pageName)
	elseif not Util.isNilOrEmpty(wikiname) then
		return "[[File:Icon_Move_" .. wikiname .. ".png|x26px|link=]]"
		return ''

local getWeaponIcon = function (wikiname)
	local wep = WEAPON_TYPES[wikiname]
	if wep then
		return ('[[File:%s|x26px|alt=%s|link=%s]]'):format(wep.IconFile, wep.Name, wep._pageName)
	elseif not Util.isNilOrEmpty(wikiname) then
		return "[[File:Icon_Class_" .. wikiname .. ".png|x26px|link=]]"
		return ''

local makeStatRow = function (title, text)
	local tr = mw.html.create("tr")
	tr:tag("th"):attr("scope", "row"):css("text-align", "left"):wikitext(title)
	return tr

local makeSkillRow = function (icon, text, pkind)
	local tr = mw.html.create('tr'):css('height', '25%')
	if pkind then
		tr:tag("td"):css("width", "15%"):node(superimposeDiv(("[[File:%s|x23px|alt=|link=]]"):format(icon),
			{('[[File:Passive Icon %s.png|14x14px|link=]]'):format(pkind), 12, 10}))
		tr:tag("td"):css("width", "15%"):wikitext(("[[File:%s|x23px|alt=|link=]]"):format(icon))
	tr:tag("td"):css("width", "85%"):wikitext(text)
	return tr

local statTextFunc = function (stat, modifier)
	if not stat or not tonumber(stat) then
		stat = math.min(99, math.max(0, tonumber(stat)))
		local finalStat = math.min(99, math.max(0, stat + modifier))
		if finalStat ~= stat then
			return Template_Hover_nocargo(finalStat, "Without skills: " .. stat)
			return stat

local rarityFunc = function (text)
	if not text then
		return "[[File:RarityLineU.png|x25px|Rarity]]"
		return "[[File:RarityLine" .. text .. ".png|x25px|alt=" .. text .. "★|Rarity]]"

local lvFunc = function (text, displayed)
	if not text then
	elseif (tonumber(text) or 0) > tonumber(displayed) then
		return Template_Hover_nocargo(text, "Displayed as " .. displayed .. "+ in‐game.")
		return text

local slotFunc = function (text)
	if not text then
		return Template_Hover_nocargo(UNKNOWN_TEXT, "It is unknown what this unit's slot order is.")
	elseif text == EMPTY_STRING then
		return ""
		return Template_Hover_nocargo("#" .. text, "This unit is in slot " .. text .. ".")

local cooldownFunc = function (cdcount, initcd)
	if cdcount then
		local text = Template_Hover_nocargo(cdcount, 'This unit has a Special cooldown count of ' .. cdcount .. '.')
		if initcd then
			text = Template_Hover_nocargo(initcd, 'This unit has an initial Special cooldown of ' .. initcd .. '.') .. ' / ' .. text
		return text
	return ""

local blessingFunc = function (text)
	if text and text ~= EMPTY_STRING then
		local escQBlessing = escq(text)
		local blessingQuery = cargo.query( "Items", "Name,ImageFile", { where="_pageName LIKE '".. escQBlessing .. "%' OR Name LIKE '".. escQBlessing .. "%'", limit=1 } )[1]
		if blessingQuery then
			return Template_Hover_nocargo("[[File:Map_Unit_Info_"..(blessingQuery["ImageFile"] or "Blank.png").."|border|x20px|alt=" .. (blessingQuery["Name"] or text) .. "|link=|]]",
				"This unit has the " .. mw.ustring.lower(blessingQuery["Name"] or text) .. " conferred.")
	return ""

local accessoryFunc = function (text)
	if text and text ~= EMPTY_STRING then
		local accessoryQuery = cargo.query( "Accessories", "_pageName,Name,Type", { where="'"..escq(text).."' IN (_pageName,Name)", limit=1 } )[1]
		if accessoryQuery then
			return "[[File:Accessory_Type_"..(accessoryQuery["Type"] or "unknown")..".png|border|x20px|alt=" .. accessoryQuery["Name"] .. "|link="..accessoryQuery["_pageName"].."]]"
	return ""

local skillFunc = function (query)
	local txt = Template_SkillPage_nocargo(,, query.desc)
	if query.invalid then
		txt = ("[[%s]]"):format(txt)
	return query.ic, txt

local getUnitQuery = function (unitInfo)
	local unitQueries = memoizeUnits(unitInfo.unit)
	return unitQueries[#unitQueries <= 1 and 1 or List.find_if(unitQueries, function (query)
		if query.isEnemy then
			return (not or and not
			return ( or and not

local skillPromotion = function (unitInfo, cat, mptier)
	if Util.isNilOrEmpty(unitInfo[cat]) or unitInfo[cat] == EMPTY_STRING then
		return unitInfo[cat]
	local skill = memoizeSkill(unitInfo[cat], cat)
	if skill.invalid then
		return unitInfo[cat]

	if cat == 'weapon' then
		local unitQuery = getUnitQuery(unitInfo)
		local weapons = memoizeUnitWeapons(unitQuery.wikiname)
		if unitQuery.isEnemy then
			if weapons[unitInfo.rarity] then
				return weapons[unitInfo.rarity]
		elseif mptier and skill.ptier and skill.ptier <= mptier then
			local oldRarity = List.find(weapons, skill.wikiname) -- prevent skill demotion?
			if oldRarity and oldRarity <= unitInfo.rarity and weapons[unitInfo.rarity] then
				return weapons[unitInfo.rarity]

	if mptier then
		while skill.nextSkill ~= '' and skill.prarity and skill.prarity <= unitInfo.rarity and skill.ptier and skill.ptier <= mptier do
			local skill2 = memoizeSkill(skill.nextSkill, cat)
			if skill2.invalid then
			skill = skill2
	return skill.wikiname

local translateAIInfo = function (info)
	if info == 'activeall' then
		return {turn = 1}
	elseif info == 'passivelinked' then
		return {group = 1}
	elseif info == 'passivesingle' then
		return {}
		return info

local parseGlobalAIInfo = function (str)
	if Util.isNilOrEmpty(str) then
		return {turn = 1}
	local info, err = parseArgs(str)
	if err then
		return nil, require 'Module:Error'.error(err)
		return translateAIInfo(info)

local makeAITable = function (infos, globalai)
	local tbl = mw.html.create('table'):addClass('wikitable'):css('text-align', 'center')

	local row = tbl:tag('tr')
	row:tag('th'):wikitext('Start turn')

	local getTurnTxt = function (unitInfo, inGroup)
		local hasTurn = type( == 'number'
		if inGroup then
			local hasDelay = type( == 'number'
			if hasTurn and hasDelay then
				return 'Turn ' .. .. ' or ' .. .. ' turn(s) after group is engaged'
			elseif hasTurn and not hasDelay then
				return 'Turn ' .. .. ' or after group is engaged'
			elseif hasDelay then
				return .. ' turn(s) after group is engaged'
				return 'After group is engaged'
		elseif hasTurn then
			return 'Turn ' ..
			return 'None'

	local fillUnit = function (unitInfo, inGroup)
		local unitQuery = getUnitQuery(unitInfo)
		row:tag('td'):wikitext(type(unitInfo.slot) == 'number' and ('#' .. unitInfo.slot) or UNKNOWN_TEXT)
		row:tag('td'):wikitext(unitInfo.random and 'Random' or
			unitQuery and ('[[%s|%s]]'):format(, or unitInfo.unit)
		row:tag('td'):wikitext(getTurnTxt(unitInfo, inGroup))

		local notes = table.concat(List.compact { and 'Attacks breakable terrain' or nil, and 'Returns to starting position if unit has no actions to make' or nil,
		}, '<br />')
		row:tag('td'):wikitext(notes == '' and EMPTY_TEXT_LONG or notes)

	local unitsByAIGroup = List.group_by(, function (v)
		return not
	end), function (v)
		return and tonumber( or math.huge
	for group, units in Hash.sorted_pairs(unitsByAIGroup) do
		table.sort(units, function (x, y) return x._index < y._index end)
		row = tbl:tag('tr')
		if group < math.huge then
			row:tag('td'):attr('rowspan', #units):wikitext(group)
			for i, unitInfo in ipairs(units) do
				if i > 1 then
					row = tbl:tag('tr')
				fillUnit(unitInfo, true)
			for _, unitInfo in ipairs(units) do
				row = tbl:tag('tr')
				fillUnit(unitInfo, false)

	return ('<p><b>AI settings</b></p>%s<p><i>See [[AI]] for a detailed description of the enemy movement settings.</i></p>'):format(tostring(tbl))

local parseUnitData = function (unitInfo, opts)
	local unitQuery = getUnitQuery(unitInfo)
	local unitImage = "[[File:Hero_No_Image_Face_FC.png|x79px|link=]]"
	local unitPageLink = ""
	local moveTypeIcon = ""
	local weaponTypeIcon = ""

	if unitInfo.random then
		unitPageLink = "<br/>Random"
		if not unitInfo.random.moves then
			for wikiname in Hash.sorted_pairs(MOVE_TYPES, function (v1, v2) return v1.Sort < v2.Sort end) do
				moveTypeIcon = moveTypeIcon .. getMoveIcon(wikiname)
			moveTypeIcon = getMoveIcon(unitInfo.random.moves)
		local weaponList = unitInfo.random.weapons
		local excludeStaff = toboolean(unitInfo.random.staff) == false
		for wikiname, v in Hash.sorted_pairs(WEAPON_TYPES, function (v1, v2) return v1.Sort < v2.Sort end) do
			if (not weaponList or List.find(v.Classes, weaponList) or v.Type == weaponList or wikiname == weaponList) and not (v.Type == 'Staff' and excludeStaff) then
				weaponTypeIcon = weaponTypeIcon .. getWeaponIcon(wikiname)
	elseif unitQuery then
		local cc = opts.ccArgs
		if cc then
			cc.level = tonumber(cc.level)
			cc.rarity = tonumber(cc.rarity)
			cc.hpfactor = tonumber(cc.hpfactor)
			cc.ptier = tonumber(cc.ptier)

			if unitInfo.level and unitInfo.rarity then
				local newLv = math.max(unitInfo.level, cc.level or 1)
				local newRarity = math.max(unitInfo.rarity, cc.rarity or 1)
				local baseStats =, function (stat) return tonumber(unitQuery['Lv1' .. stat .. '5']) or 0 end)
				local growthRates =, function (stat) return tonumber(unitQuery[stat .. 'GR3']) or 0 end)

				if unitInfo.rarity < newRarity then
					local rarityBonuses = FEHStatUtil.getRarityBonuses(baseStats)
					List.map_self(unitInfo.stats, function (statVal, i)
						return statVal and statVal + rarityBonuses[newRarity][i] - rarityBonuses[unitInfo.rarity][i]

				List.map_self(unitInfo.stats, function (statVal, i)
					return statVal and statVal + math.floor((
						FEHStatUtil.getMasterGrowthRate(newRarity, growthRates[i]) * (newLv - 1) -
						FEHStatUtil.getMasterGrowthRate(unitInfo.rarity, growthRates[i]) * (unitInfo.level - 1)) / 100)

				unitInfo.rarity = newRarity
				unitInfo.level = newLv
				unitInfo._lv = newLv

			for _, cat in ipairs(SKILL_CATEGORIES) do
				if not (cat == 'weapon' and unitInfo.refine) then
					unitInfo[cat] = skillPromotion(unitInfo, cat, cc.ptier)

			if unitInfo.stats[1] and cc.hpfactor then
				unitInfo.stats[1] = math.max(1, math.floor(unitInfo.stats[1] * cc.hpfactor))

		local unitProperties = mw.text.split(unitQuery.Properties, '%s*,%s*')
		unitPageLink = "[[".. .."|" .. .. "]]"
		unitImage = ('<div %s">[[File:%s_%s.png|x79px|link=%s]]</div>'):format(
			unitInfo.isEnemy and 'style="transform:scaleX(-1);' or '',
			mf(, unitQuery.isGeneric and 'Mini_Unit_Idle' or 'Face_FC',
		moveTypeIcon = getMoveIcon(unitQuery.MoveType)
		weaponTypeIcon = getWeaponIcon(unitQuery.WeaponType)
	elseif not Util.isNilOrEmpty(unitInfo.unit) then
		unitPageLink = "<br/>[[".. unitInfo.unit .. "]]"

	local skillQueries = Hash.generate(SKILL_CATEGORIES, function (cat)
		return memoizeSkill(unitInfo[cat], cat, cat == 'weapon' and unitInfo.refine or nil)
	local hasInvalid = Hash.any(skillQueries, function (v) return v.invalid end)
	local statModifiers =, function (t)
		return t.StatModifiers
	end)), function (stats) return List.sum(stats) end)

	local cooldownCount = nil
	if skillQueries.special.wikiname then
		if unitInfo.max_cooldown then
			cooldownCount = unitInfo.max_cooldown
			cooldownCount = 0
			for _, v in pairs(skillQueries) do
				if v.wikiname and then
					cooldownCount = cooldownCount +
			cooldownCount = math.max(1, cooldownCount)

	-- obtain icons and texts
	local hpText = statTextFunc(unitInfo.stats[1], statModifiers[1])
	local atkText = statTextFunc(unitInfo.stats[2], statModifiers[2])
	local spdText = statTextFunc(unitInfo.stats[3], statModifiers[3])
	local defText = statTextFunc(unitInfo.stats[4], statModifiers[4])
	local resText = statTextFunc(unitInfo.stats[5], statModifiers[5])

	local rarityText = rarityFunc(unitInfo.rarity)
	local lvText = lvFunc(unitInfo._lv, unitInfo.displaylevel or 40)
	local slotText = slotFunc(unitInfo.slot)
	local cooldownText = cooldownFunc(cooldownCount, unitInfo.cooldown)
	local blessingText = blessingFunc(unitInfo.blessing)
	local accessoryText = accessoryFunc(unitInfo.accessory)

	local weaponIcon, weaponText = skillFunc(skillQueries["weapon"])
	local assistIcon, assistText = skillFunc(skillQueries["assist"])
	local specialIcon, specialText = skillFunc(skillQueries["special"])
	local passiveAIcon, passiveAText = skillFunc(skillQueries["a"])
	local passiveBIcon, passiveBText = skillFunc(skillQueries["b"])
	local passiveCIcon, passiveCText = skillFunc(skillQueries["c"])
	local passiveSIcon, passiveSText = skillFunc(skillQueries["seal"])

	if cooldownText ~= '' then
		specialText = specialText .. ' (' .. cooldownText .. ')'

	-- generate sub-tables
	local statTable = mw.html.create("table"):css("width", "100%"):css("height", "100%"):css("text-align", "right")
	statTable:node(makeStatRow("LV.", lvText))
	statTable:node(makeStatRow("HP", hpText))
	statTable:node(makeStatRow("Atk", atkText))
	statTable:node(makeStatRow("Spd", spdText))
	statTable:node(makeStatRow("Def", defText))
	statTable:node(makeStatRow("Res", resText))

| rarity | stats | random |
+--------+       |        |
|  slot  |       |        |
+--------+       |        |
|  unit  |       |        |
	if unitInfo.random then
		local randomTable = mw.html.create("table"):css("width", "100%"):css("height", "100%")
		randomTable:tag('tr'):tag('td'):attr('colspan', 2):wikitext('This unit is randomly generated.')
		randomTable:tag('tr'):tag('td'):css('width', '30%'):wikitext("'''Move types'''"):done()
			:tag('td'):css('width', '70%'):wikitext("'''Weapon types'''")

		local uTbl = mw.html.create("table"):css("text-align","center"):css("width","100%"):css("height", "100%"):css("table-layout", "fixed")

		local row = uTbl:tag("tr")
		row:tag("td"):css("width", "28%"):wikitext(rarityText)
		row:tag("td"):attr("rowspan", 3):css("width", "16%"):css("height", "100%"):css("padding-right", "2em"):node(statTable)
		row:tag("td"):attr("rowspan", 3):css("width", "56%"):node(randomTable)
		uTbl:tag("tr"):tag("td"):wikitext(unitImage .. unitPageLink) -- hero icon and name

		return uTbl, hasInvalid

	local skills1Table = mw.html.create("table"):css("width", "100%"):css("height", "100%"):css("table-layout", "fixed")
	skills1Table:node(makeSkillRow(weaponIcon, weaponText))
	skills1Table:node(makeSkillRow(assistIcon, assistText))
	skills1Table:node(makeSkillRow(specialIcon, specialText))
	skills1Table:tag("tr"):css("height", "25%"):tag("td"):attr("colspan", 2)
		:wikitext(mw.text.trim(blessingText .. " " .. accessoryText))

	local passivesTable = mw.html.create("table"):css("width", "100%"):css("height", "100%")
	passivesTable:node(makeSkillRow(passiveAIcon, passiveAText, 'A'))
	passivesTable:node(makeSkillRow(passiveBIcon, passiveBText, 'B'))
	passivesTable:node(makeSkillRow(passiveCIcon, passiveCText, 'C'))
	passivesTable:node(makeSkillRow(passiveSIcon, passiveSText, 'S'))

|  rarity   | stats | skills | passives |
+-----------+       |        |          |
|   slot    |       |        |          |
+-----------+       |        |          |
|   unit    |       |        |          |
+-----+-----+       |        |          |
| mov | wep |       |        |          |
	-- generate html
	local uTbl = mw.html.create("table"):css("text-align","center"):css("width","100%"):css("height", "100%"):css("table-layout", "fixed")

	local row = uTbl:tag("tr")
	row:tag("td"):attr("colspan", 2):css("width", "28%"):wikitext(rarityText)
	row:tag("td"):attr("rowspan", 4):css("width", "16%"):css("height", "100%"):css("padding-right", "2em"):node(statTable)
	row:tag("td"):attr("rowspan", 4):css("width", "28%"):node(skills1Table)
	row:tag("td"):attr("rowspan", 4):css("width", "28%"):node(passivesTable)

	row = uTbl:tag("tr")
	row:tag("td"):attr("colspan", 2):wikitext(slotText)

	row = uTbl:tag("tr")
	row:tag("td"):attr("colspan", 2):wikitext(unitImage .. unitPageLink) -- hero icon and name

	row = uTbl:tag("tr")
	row:tag("td"):wikitext(weaponTypeIcon) -- weapon type icon
	row:tag("td"):wikitext(moveTypeIcon) -- move icon

	if toboolean(opts['no cargo']) or opts.ccArgs then
		return uTbl, hasInvalid

	local propertiesClone = Hash.clone(
	propertiesClone.use_ally_stats = nil -- unnecessary as wikiname is used to identify unit
	propertiesClone.use_enemy_stats = nil -- unnecessary as wikiname is used to identify unit

	return uTbl, hasInvalid, {
		tabname = opts.tabname,
		difficulty = opts.difficulty,
		name = unitQuery and,
		unit = unitQuery and unitQuery.wikiname,
		pos = unitInfo.pos,
		rarity = unitInfo.rarity,
		slot = unitInfo.slot,
		cooldown = unitInfo.cooldown,
		blessing = unitInfo.blessing,
		level = unitInfo._lv,
		hp = unitInfo.stats[1],
		atk = unitInfo.stats[2],
		spd = unitInfo.stats[3],
		def = unitInfo.stats[4],
		res = unitInfo.stats[5],
		weapon = skillQueries.weapon.wikiname,
		assist = skillQueries.assist.wikiname,
		special = skillQueries.special.wikiname,
		a = skillQueries.a.wikiname,
		b = skillQueries.b.wikiname,
		c = skillQueries.c.wikiname,
		seal = skillQueries.seal.wikiname,
		accessory = unitInfo.accessory,
		ai = objectArgStruct(, -- unless there is a good reason to query their subfields individually, these will be passed as objectargs
		spawn = objectArgStruct(unitInfo.spawn),
		properties = table.concat(Hash.keys(propertiesClone), ','),

local parseTabData = function (unitObj, opts)
	local infos, err = unitObj
	if type(infos) == 'string' then
		infos, err = parseArgs(unitObj == '' and '[]' or unitObj)
		if err then
			return require 'Module:Error'.error(err)

	-- sanitize input
	if opts.ccArgs and not next(opts.ccArgs) then
		opts.ccArgs = nil
	for i, unitInfo in ipairs(infos) do
		unitInfo._index = i
		unitInfo._lv = unitInfo.level
		unitInfo.level = tonumber(unitInfo.level)
		unitInfo.rarity = tonumber(unitInfo.rarity)
		if not unitInfo.stats then
			unitInfo.stats =, function (stat) return unitInfo[mw.ustring.lower(stat)] or false end)
		List.map_self(unitInfo.stats, function (v) return tonumber(v) or false end)
		if not then = {}
		elseif type( == 'string' then = List.to_set(mw.text.split(, ','))
		end = or opts.globalai or {turn = 1}
		if unitInfo.spawn and Util.isNilOrEmpty(opts.mapImage) then
			return require 'Module:Error'.error('A reinforcement unit has been specified but the mapImage parameter is missing.')
	local hasInvalid = false

	-- workaround for lua not having true hash tables
	local unitsBySpawn = Hash.from_ipairs(infos, function (u)
		local spawn = u.spawn or {count = -1}
		return setmetatable({ and 0 or 1,
			spawn.cond or '',
			tonumber(spawn.turn) or -1, or '',
			tonumber(spawn.kills) or -1,
			tonumber(spawn.remain) or -1,
			tonumber(spawn.count) or 1,
	local unitsBySpawnFlatten = {}
	for spawn, unitInfo in Hash.sorted_pairs(unitsBySpawn) do
		if #unitsBySpawnFlatten == 0 or spawn ~= unitsBySpawnFlatten[#unitsBySpawnFlatten][1] then
			table.insert(unitsBySpawnFlatten, {spawn, {}})
		table.insert(unitsBySpawnFlatten[#unitsBySpawnFlatten][2], unitInfo)
	local allyStartIdx = List.find_if(unitsBySpawnFlatten, function (v) return v[1][1] == 0 end)
	local enemyStartIdx = List.find_if(unitsBySpawnFlatten, function (v) return v[1][1] == 1 end)
	local enemyReinfStartIdx = List.find_if(unitsBySpawnFlatten, function (v) return v[1][1] == 1 and v[1][7] ~= -1 end)

	local ns = mw.title.getCurrentTitle().namespace

	local tables =, function (allSpawns, i)
		local spawn, infosPart = unpack(allSpawns)
		table.sort(infosPart, function (x, y) return x._index < y._index end)

		local entry = mw.html.create('div')

		if i == allyStartIdx then
			entry:tag('p'):tag('b'):wikitext('Ally units')
		elseif i == enemyStartIdx then
			entry:tag('p'):tag('b'):wikitext('Enemy units')
		elseif i == enemyReinfStartIdx then
			entry:tag('p'):tag('b'):wikitext('Enemy reinforcements')

		if spawn[7] ~= -1 then -- *.spawn.count
			local reinfmap = entry:tag('table'):addClass('wikitable'):addClass('default'):addClass('mw-collapsed'):addClass('mw-collapsible')
			local txtnode = reinfmap:tag('tr'):tag('th')
			if spawn[2] ~= '' then -- *.spawn.cond
				if spawn[7] ~= 1 then -- *.spawn.count
					txtnode:wikitext((' (×%d)'):format(spawn[7])) -- *.spawn.count
				txtnode:wikitext(': ')
				if spawn[3] ~= -1 then -- *.spawn.turn
					txtnode:wikitext('Turn ', spawn[3]) -- *.spawn.turn
					if spawn[4] ~= '' then -- *
						txtnode:wikitext(' or later, ')
				if spawn[4] ~= '' then -- *
					local target_count = spawn[6] ~= -1 and spawn[6] or spawn[5] -- *.spawn.remain or *.spawn.kills
					txtnode:wikitext(('%d %s unit%s %s'):format(target_count, spawn[4], target_count > 1 and 's' or '',
						spawn[6] ~= -1 and 'remaining' or 'killed'))

			local mapArgs = {}
			for _, unitInfo in ipairs(infosPart) do
				if unitInfo.pos then
					mapArgs[unitInfo.pos] = tostring(MapLayout.makeUnitIcon_noCargo(getUnitQuery(unitInfo), spawn[1] == 0 and 'Ally' or 'Enemy'))
			reinfmap:tag('tr'):tag('td'):node(superimposeDiv(opts.mapImage or '', {MapLayout._map(mapArgs), 0, 0}))

		local tbl = entry:tag("table"):addClass("wikitable"):addClass("unitdata-unit-table")
			:css("text-align", "center"):css('border-spacing', '1px')
		tbl:tag("tr"):tag("th"):attr("scope", "colgroup"):wikitext(spawn[1] == 0 and 'Ally data' or 'Enemy data')

		for _, unitInfo in ipairs(infosPart) do
			local unitRow, invalid, cargoArgs = parseUnitData(unitInfo, opts)
			tbl:tag('tr'):css("height", "18em"):tag('td'):node(unitRow)
			hasInvalid = hasInvalid or invalid

			-- Store in Cargo
			if cargoArgs and ns == 0 then
				mw.getCurrentFrame():expandTemplate {title = "MapUnitDefinition", args = cargoArgs}

		return tostring(entry)

	if not toboolean(opts['no ai']) then
		table.insert(tables, 1, opts.globalaierror or makeAITable(infos, opts.globalai))
	if opts.ccArgs then
		if Util.isNilOrEmpty(opts.mapImage) then
			return require 'Module:Error'.error('An infobox for the derived map is to be generated but the mapImage parameter is missing.')
		local initMapArgs = {allyPos = opts.allyPos}
		for _, unitInfo in ipairs(infos) do
			if unitInfo.pos and (unitInfo.spawn and (unitInfo.spawn.count or 1) or -1) == -1 then
				initMapArgs[unitInfo.pos] = tostring(MapLayout.makeUnitIcon_noCargo(getUnitQuery(unitInfo), and 'Ally' or 'Enemy'))
		table.insert(tables, 1, mw.getCurrentFrame():expandTemplate {title = 'Derived Map Infobox', args = {
			battle = opts.battle,
			baseMap = opts.mapPage,
			baseTab = opts.tabnameOrig,
			level = opts.ccArgs.level,
			rarity = opts.ccArgs.rarity,
			hpfactor = opts.ccArgs.hpfactor,
			mapImage = tostring(superimposeDiv(opts.mapImage, {MapLayout._map(initMapArgs), 0, 0})),
	if ns == 0 then
		if List.any(infos, function (v) return not v.random and List.any(SKILL_CATEGORIES, function (cat) return not v[cat] end) end) then
			tables[#tables + 1] = '[[Category:Pages with incomplete unit data]]'
		if hasInvalid then
			tables[#tables + 1] = '[[Category:Pages with script errors]]'
	return table.concat(tables, '\n')

local p = {}

p.makeTable = function (frame)
	local globalai, globalaierror = parseGlobalAIInfo(frame.args.globalai)
	local opts = {
		globalai = globalai,
		globalaierror = globalaierror,
		mapImage = frame.args.mapImage,
		tabname = frame.args.tabName,
		difficulty = frame.args.difficulty,
		battle = frame.args.battle,
		['no cargo'] = frame.args['no cargo'],
		['no ai'] = frame.args['no ai'],
		ccArgs = {
			hpfactor = tonumber(frame.args.hpfactor),
			level = tonumber(frame.args.level),
			rarity = tonumber(frame.args.rarity),
			ptier = tonumber(frame.args.ptier),
	return parseTabData(frame.args[1], opts)

local findDifficulty = function (tabName)
	return select(2, List.find_if(DIFFICULTY_ORDER, function (difficulty)
		return mw.ustring.find(tabName, difficulty, 1, true)

local NOEMPTY_UNIT_FIELDS = {'pos', 'rarity', 'slot', 'cooldown', 'blessing', 'level', 'hp', 'atk', 'spd', 'def', 'res', 'accessory', 'ai', 'spawn'}

p.main = function (frame)
	local allUnits = Hash.clone(frame.args)
	local derived = Hash.remove(allUnits, 'derived')
	if derived then
		derived = mw.loadData 'Module:UnitData/data'.derived_settings[derived]
	local globalai, globalaierror = parseGlobalAIInfo(Hash.remove(allUnits, 'globalai'))
	local opts = {
		globalai = globalai,
		globalaierror = globalaierror,
		battle = Hash.remove(allUnits, 'battle'),
		mapImage = Hash.remove(allUnits, 'mapImage'),
		['no cargo'] = Hash.remove(allUnits, 'no cargo'),
		['no ai'] = Hash.remove(allUnits, 'no ai'),

	local tabsStr = Hash.remove(allUnits, 'derivedTabs')
	if tabsStr then
		local tabMap, err = parseArgs(tabsStr)
		if err then
			return require 'Module:Error'.error(err)
		for from, to in pairs(tabMap) do
			allUnits[to] = {from = from}

		disableSkillDesc = true
		opts.mapPage = Hash.remove(allUnits, 'derivedMap') or mw.title.getCurrentTitle().fullText
		opts.allyPos = Hash.remove(allUnits, 'allyPos')
		local baseUnits = cargo.query(
			  Accessory=accessory,AI=ai,Spawn=spawn,Properties__full=properties]], {
			where = ("_pageName='%s' AND Unit IS NOT NULL"):format(escq(opts.mapPage)),
			orderBy = 'Slot,Unit',
			limit = 100,
		for _, unitInfo in ipairs(baseUnits) do
			if tabMap[unitInfo.TabName] then
				for _, k in ipairs(NOEMPTY_UNIT_FIELDS) do
					if unitInfo[k] == '' then
						unitInfo[k] = nil
				for _, cat in ipairs(SKILL_CATEGORIES) do
					unitInfo[cat] = unitInfo[cat] == '' and '-' or unitInfo[cat]
				if then = parseArgs(
				if unitInfo.spawn then
					unitInfo.spawn = parseArgs(unitInfo.spawn)
				end = List.to_set(mw.text.split(, '%s*,%s*'))
				for k, v in pairs(unitInfo) do
					if type(v) ~= 'table' then
						unitInfo[k] = tonumber(v) or v
				table.insert(allUnits[tabMap[unitInfo.TabName]], unitInfo)

	local tabberArgs = {}
	for tabName, unitList in Hash.sorted_pairs(allUnits, function (_, _, k1, k2) return Util.difficultySort(k1, k2) end) do
		opts.tabnameOrig = unitList.from
		opts.tabname = tabName
		opts.difficulty = findDifficulty(tabName)
		opts.ccArgs = derived and derived[opts.difficulty]
		if opts.ccArgs then -- remove metatable
			opts.ccArgs = Hash.clone(opts.ccArgs)
		tabberArgs[#tabberArgs + 1] = {tabName, '\n' .. tostring(parseTabData(unitList, opts))}

	return Tab.tabber(tabberArgs):css( "overflow-x", "auto" )

-- for doc page
p.derivedSettingsList = function ()
	local DERIVED_SETTINGS = mw.loadData 'Module:UnitData/data'.derived_settings
	local deriveds = {}
	for k, v in Hash.sorted_pairs(DERIVED_SETTINGS) do
		deriveds[#deriveds + 1] = ("* <code>%s</code>: %s"):format(k,
			table.concat(, function (dif) return v[dif] end), ', '))
	return table.concat(deriveds, '\n')

return p