Edit 18-March-2014: Another substantial revision. New screenshots, description, and code are below.- Added columns to show how much overloaded each dorf is (including or excluding anything they're currently hauling.)
- Added support for sorting by any column.
Default output (sorts by descending speed):

Notice the column headers above, and the key below. Most zeros are left out to reduce clutter.
Red numbers in the 'E' column indicate encumbrance; green numbers indicate spare capacity.
The 'E-H' colum is the same thing, except calculations are made without the current weight of hauling jobs.
Pen/Pasture Large Animal is another common cause of sloth. Others are still unexplained. (E.g., sixth from bottom.)
Here is the help output:

And here is the output sorted by the E-H column (how much encumbered or free if not hauling anything):

I think I'm done with it now, except for bug fixes.
Put it in hack/scripts/
Tested with dfhack r3 on Windows and Linux.
Should work with r4 as well, but I have not tested it.
- Let us know what happens if you try it.
Post a reply if you spot a bug or numbers that look suspicious.
Some of the characters in the names may not display correctly on Linux.
show-speeds.lua -
-- Shows current speed and encumbrance for each citizen.
--
local Version = 2.1
-- Fixed to work with either dfhack r3 or r4.
-- Added columns for excess weight and excess weight without hauling.
-- Made sortable by any column.
-- Trimmed width of text columns.
-- Changed display color scheme.
--
-- Version 2.0
-- Completely reworked, to provide information that (hopefully) explains low speeds.
--
-- Thanks to Putnum, Warmist, and Meph for help and encouragement, and to the writers
-- of all the scripts, .cpp, and .xml that I looked at or copied from.
-- Change these if necessary to improve contrast:
-- (My Windows and Linux machines don't use the same background!)
local overloadColor = COLOR_RED
local underloadColor = COLOR_GREEN
local informationColor = COLOR_DARKGREY
-- Make these match any changes above:
local overloadColorName = 'Red'
local underloadColorName = 'Green'
local args = {...}
local dorfs = {}
local showHelp = false
local showVersion = false
local sortReversed = false
local itemizeOther = false
local sortBySpeed = true -- default
local sortByHauling = false
local sortByWeapon = false
local sortByArmor = false
local sortByOther = false
local sortByTotal = false
local sortByMaximum = false
local sortByExcess = false
local sortByDifference = false
local sortByName = false
local sortByProfession = false
local sortByJob = false
-- sanity checks:
if not dfhack.isMapLoaded() then
qerror('You must load a map to use this.')
end
-- process arguments:
if #args > 0 then
for a = 1, #args, 1 do
args[a] = args[a]:lower()
if args[a] == 'help' then
showHelp = true
elseif args[a] == '-v' then
showVersion = true
elseif args[a] == '-r' then
sortReversed = true
elseif args[a] == '-i' then
itemizeOther = true
elseif args[a] == 's' then -- not needed; provided for completeness
sortBySpeed = true
elseif args[a] == 'h' then
sortByHauling = true
elseif args[a] == 'w' then
sortByWeapon = true
elseif args[a] == 'a' then
sortByArmor = true
elseif args[a] == 'o' then
sortByOther = true
elseif args[a] == 't' then
sortByTotal = true
elseif args[a] == 'm' then
sortByMaximum = true
elseif args[a] == 'e' then
sortByExcess = true
elseif args[a] == '-' then
sortByDifference = true
elseif args[a] == 'j' then
sortByJob = true
elseif args[a] == 'n' then
sortByName = true
elseif args[a] == 'p' then
sortByProfession = true
else
print('Invalid argument: ', args[a])
return
end
end
end
-- show help and exit:
if showHelp then
print()
print('Shows current speed, encumbrance, name, profession, and job for all citizens.')
print()
print('Usage:')
print()
print(' show-speeds lists citizens sorted by speed (high to low)')
print()
print(' show-speeds X if X is a column label, sorts by the indicated column')
print(' o use minus sign (show-speeds -) for the E-H column')
print(' o use the first letter (only) for the text columns')
print()
print(' show-speeds -r reverses sort order')
print(' o compatible with any numeric column argument')
print()
print(' show-speeds -i itemize gross weights of any worn containers')
print(' o compatible with any column argument')
print()
print(' show-speeds help show this help text and exit')
print(' show-speeds -v show script version number and exit')
print()
print('Arguments are not case sensitive.')
print()
print('Notes:')
print(' o Pen/Pasture Large Animal can have either much or little effect, apparently')
print(' depending on the animal. Animals being led do not show up in the weights.')
print(' o Store Item will not show a weight hauled if en route to pick up the item.')
print(' o If you load a game and run the script before unpausing, most of the')
print(' information needed by the script will not be available. Unpause the')
print(' game for at least a few seconds before using the script.')
print(' o Some low speeds appear to be transient. If no explanation for a low speed')
print(' can be determined from the output, unpause the game for a few seconds and')
print(' then run the script again.')
print()
return
end
-- show version and exit
if showVersion then
print()
print(' show-speeds version',Version)
print()
return
end
-- *** supporting functions ***
-- comparator for sorting units by speed:
function compSpeeds(a,b)
if sortReversed then -- naughty me!
return (a.speed < b.speed)
else
return (a.speed > b.speed)
end
end
-- comparator for sorting by hauling weight:
function compHauling(a,b)
if sortReversed then
return (a.haulingWeight < b.haulingWeight)
else
return (a.haulingWeight > b.haulingWeight)
end
end
-- comparator for sorting by weapon weight:
function compWeapons(a,b)
if sortReversed then
return (a.weaponWeight < b.weaponWeight)
else
return (a.weaponWeight > b.weaponWeight)
end
end
-- comparator for sorting by armorWeight:
function compArmor(a,b)
if sortReversed then
return (a.armorWeight < b.armorWeight)
else
return (a.armorWeight > b.armorWeight)
end
end
-- comparator for sorting by other item weight:
function compOther(a,b)
if sortReversed then
return (a.otherWeight < b.otherWeight)
else
return (a.otherWeight > b.otherWeight)
end
end
-- comparator for sorting by total load:
function compTotals(a,b)
if sortReversed then
return (a.totalWeight < b.totalWeight)
else
return (a.totalWeight > b.totalWeight)
end
end
-- comparator for sorting by maximum unencumbered load:
function compMaximums(a,b)
if sortReversed then
return (a.loadCapacity < b.loadCapacity)
else
return (a.loadCapacity > b.loadCapacity)
end
end
-- comparator for sorting by excess load:
function compExcessWeight(a,b)
if sortReversed then
return (a.excessWeight < b.excessWeight)
else
return (a.excessWeight > b.excessWeight)
end
end
-- comparator for sorting by excess load, ignoring hauling load:
function compModifiedExcessWeight(a,b)
if sortReversed then
return (a.excessWeightWithoutHauling < b.excessWeightWithoutHauling)
else
return (a.excessWeightWithoutHauling > b.excessWeightWithoutHauling)
end
end
-- comparator for sorting units by name:
function compNames(a,b)
return a.name < b.name
end
-- comparator for sorting units by profession:
function compProfessions(a,b)
return a.profession < b.profession
end
-- comparator for sorting units by job:
function compJobs(a,b)
if a.job == nil then
return false
elseif b.job == nil then
return true
end
return a.job_name < b.job_name
end
-- round a positive number to an integer:
function round(n)
return math.floor(n+0.5)
end
-- dfhack.print a string truncated or padded to a specified field width;
-- if the string is too long, end with a slash to indicate the truncation
function printStringInField(s,w)
local sLength = s:len()
if sLength > w then
dfhack.print(s:sub(1,w-1)..'/')
else
dfhack.print(s)
for i = w - sLength-1, 0, -1 do
dfhack.print(' ')
end
end
end
-- dfhack.print a positive number rounded to an integer and right-justified in a 3-wide field;
-- leave field blank if number is zero, or print a dot if it is greater than zero but rounds to zero
function printNumber(n)
local rounded
if n == 0 then
dfhack.print(' ')
else
rounded = round(n)
if rounded == 0 then
dfhack.print(' .') -- indicates fractional value rounded to zero
else
if rounded < 100 then
dfhack.print(' ')
end
if rounded < 10 then
dfhack.print(' ')
end
dfhack.print(rounded)
end
end
end
-- is the unit hiding a curse?
-- from isHidingCurse in Units.cpp, which is not exported for lua
function isHidingCurse(v)
if not v.job.hunt_target then -- presumably not "hiding curse" while hunting
local identity = dfhack.units.getIdentity(v) -- finds assumed identity
if identity then
if identity.unk_4c == 0 then
return true
end
end
end
return false
end
-- get a unit's strength:
-- from getPhysicalAttrValue in Units.cpp, which is not exported for lua
function getStrength(v)
local attributeObject = v.body.physical_attrs[df.physical_attribute_type.STRENGTH]
local strength = attributeObject.value - attributeObject.soft_demotion
local modifier = v.curse.attr_change
local modifiedValue
if modifier then
modifiedValue = strength * modifier.phys_att_perc[df.physical_attribute_type.STRENGTH] / 100
modifiedValue = modifiedValue + modifier.phys_att_add[df.physical_attribute_type.STRENGTH]
if isHidingCurse(v) then
strength = math.min(strength,modifiedValue)
else
strength = modifiedValue
end
end
return math.max(0,strength)
end
-- get a unit's size:
function getSize(v)
local bodySize
if dfhack.VERSION == "0.34.11-r3" then
bodySize = v.body.physical_attr_tissues.STRENGTH -- *** I think this is actually .size_info.size_cur ***
else -- *** for now, assume r4 ***
bodySize = v.body.size_info.size_cur
end
return bodySize
end
-- *** end supporting functions ***
-- build a list of citizens and their relevant properties:
for _,v in ipairs(df.global.world.units.active) do
if dfhack.units.isCitizen(v) then
local d = {}
-- look up some stuff:
d.name = dfhack.TranslateName(dfhack.units.getVisibleName(v))
d.profession = dfhack.units.getProfessionName(v)
d.job = v.job.current_job
if d.job == nil then
-- do nothing (the negation or the test doesn't work on Windows)
else
d.jobtype = v.job.current_job.job_type
d.job_name = df.job_type.attrs[d.jobtype].caption -- *** substitute acronyms for long job names ***
end
d.speed = round(100000/dfhack.units.computeMovementSpeed(v))
-- calculate unencumbered load capacity: (logic from computeMovementSpeed in Units.cpp)
d.size = getSize(v)
d.strength = getStrength(v)
d.loadCapacity = math.max(1, d.size/10 + d.strength*3)
d.loadCapacity = d.loadCapacity/100 -- since my weights are 1/100 of the Units.cpp logic
-- calculate armor-user skill multiplier: (logic from calcInventoryWeight in Units.cpp)
d.armorSkill = math.min(15,dfhack.units.getEffectiveSkill(v,df.job_skill.ARMOR))
d.armorMultiplier = 15 - d.armorSkill
-- process the inventory:
d.haulingWeight = 0
d.otherWeight = 0
d.weaponWeight = 0
d.armorWeight = 0
d.otherItemType = -1
d.flask = false
d.backpack = false
d.quiver = false
d.mysteryItem = false
d.mysteryItemWeight = 0
for i = 0, #v.inventory-1, 1 do
-- find the item weight in dorf-pounds
if not v.inventory[i].item.flags['weight_computed'] then
v.inventory[i].item:calculateWeight()
end
itemWeight = v.inventory[i].item.weight + v.inventory[i].item.weight_fraction / 1000000
-- break the inventory weights into categories:
if v.inventory[i].mode == 0 then -- hauled item (see df.units.xml) -- *** figure out how to replace the hard-coded 0 with the enumerated mode name ***
d.haulingWeight = d.haulingWeight + itemWeight
elseif v.inventory[i].item:isWeapon() then
d.weaponWeight = d.weaponWeight + itemWeight
elseif v.inventory[i].item:isArmor() then
if d.armorSkill > 1 then
itemWeight = itemWeight * d.armorMultiplier / 16 -- per calcInventoryWeight in Units.cpp
end
d.armorWeight = d.armorWeight + itemWeight
else
d.otherWeight = d.otherWeight + itemWeight
-- break out the items for later, if the itemize option is was used
if itemizeOther then
otherItemType = v.inventory[i].item:getType()
if otherItemType == 11 then -- *** fix the hard-coding ***
d.flask = true
d.flaskWeight = itemWeight
elseif otherItemType == 60 then
d.pack = true
d.packWeight = itemWeight
elseif otherItemType == 61 then
d.quiver = true
d.quiverWeight = itemWeight
else -- no other types currently itemized
d.mysteryItem = true
d.mysteryItemWeight = d.mysteryItemWeight + itemWeight -- possibly more than one
end
end
end
end
-- analyze the load:
d.totalWeight = d.weaponWeight+d.armorWeight+d.haulingWeight+d.otherWeight
d.excessWeight = d.totalWeight - d.loadCapacity
d.overloaded = (d.excessWeight > 0)
d.excessWeightWithoutHauling = d.excessWeight - d.haulingWeight
-- store for sorting:
table.insert(dorfs,d)
end
end
-- sort the list according to any argument:
if sortByHauling then
table.sort(dorfs,compHauling)
elseif sortByWeapon then
table.sort(dorfs,compWeapons)
elseif sortByArmor then
table.sort(dorfs,compArmor)
elseif sortByOther then
table.sort(dorfs,compOther)
elseif sortByTotal then
table.sort(dorfs,compTotals)
elseif sortByMaximum then
table.sort(dorfs,compMaximums)
elseif sortByExcess then
table.sort(dorfs,compExcessWeight)
elseif sortByDifference then
table.sort(dorfs,compModifiedExcessWeight)
-- text fields:
elseif sortByName then
table.sort(dorfs,compNames)
elseif sortByProfession then
table.sort(dorfs,compProfessions)
elseif sortByJob then
table.sort(dorfs,compJobs)
-- default to speed:
else
table.sort(dorfs,compSpeeds)
end
-- show the list:
print()
dfhack.color(informationColor)
print(" S H W A O T M E E-H Name Profession Job")
dfhack.color(nil)
-- output loop:
for _,d in ipairs(dorfs) do
-- speed:
printNumber(d.speed)
-- weight hauled:
dfhack.print(' ')
printNumber(d.haulingWeight)
-- weight of weapons
dfhack.print(' ')
printNumber(d.weaponWeight)
-- weight of armor
dfhack.print(' ')
printNumber(d.armorWeight)
-- weight of everything else
dfhack.print(' ')
printNumber(d.otherWeight)
-- total weight equipped/carried:
dfhack.print(' ')
printNumber(d.totalWeight)
-- unencumbered capacity:
dfhack.print(' ')
printNumber(d.loadCapacity)
-- excessWeight load:
dfhack.print(' ')
if d.overloaded then
dfhack.color(overloadColor)
printNumber(d.excessWeight)
else
dfhack.color(underloadColor)
printNumber(-d.excessWeight)
end
dfhack.color(nil)
-- excess load without any hauling:
dfhack.print(' ')
if d.excessWeightWithoutHauling > 0 then
dfhack.color(overloadColor)
printNumber(d.excessWeightWithoutHauling)
else
dfhack.color(underloadColor)
printNumber(-d.excessWeightWithoutHauling)
end
dfhack.color(nil)
-- name:
dfhack.print(' ')
printStringInField(d.name,13)
-- profession
dfhack.print(' ')
printStringInField(d.profession,13)
-- current job:
dfhack.print(' ')
if d.job == nil then
print(' -')
else
printStringInField(d.job_name,13)
print()
end
-- itemized containers:
if itemizeOther then
dfhack.color(informationColor)
if d.flask then
dfhack.print(' ')
printNumber(d.flaskWeight)
print(' flask')
end
if d.pack then
dfhack.print(' ')
printNumber(d.packWeight)
print(' backpack')
end
if d.quiver then
dfhack.print(' ')
printNumber(d.quiverWeight)
print(' quiver')
end
if d.mysteryItem then
dfhack.print(' ')
printNumber(d.mysteryItemWeight)
print(' other items')
end
dfhack.color(nil)
end
end -- main loop
print()
dfhack.color(informationColor)
print('S : Current relative speed (nominal average is 100)')
print('H : Weight of any hauled items')
print('W : Weight of any weapons')
print('A : Weight of any armor and clothes (possibly reduced by armor-wearer skill)')
print('O : Weight of other items carried or worn (quivers, backpacks, flasks, etc.)')
print('T : Total weight carried or worn')
print('M : Maximum allowable weight without encumbrance')
--
dfhack.print('E : ')
dfhack.color(overloadColor)
dfhack.print(overloadColorName..':')
dfhack.color(informationColor)
dfhack.print(' Encumbering Weight (T-M); ')
dfhack.color(underloadColor)
dfhack.print(underloadColorName..':')
dfhack.color(informationColor)
print(' Spare Capacity (M-T)')
--
print('E-H : As E, but without current hauling')
print()
print('Notes:')
print(' o Rounding may prevent numbers from appearing to add up correctly.')
print(' o Period in numeric column indicates non-zero weight rounded to zero.')
print()
dfhack.print('Use ')
dfhack.color(nil)
dfhack.print('show-speeds help')
dfhack.color(informationColor)
print(' for more information.')
dfhack.color(nil)
print() -- otherwise color does not reset before the next dfhack prompt