Population and peasant formulas#
Summary#
Every end-of-season turn tick updates the population of each province: births are computed from a population-tier lookup and prosperity, deaths from a climate/health tier and season modifier, and any tribute, immigration, or emigration is applied before clamping. A separate formula determines how many peasant units a province holds for allocation purposes. A third path—triggered by gold spent on province growth—advances a per-province population tier independently of the seasonal cycle.
Two distinct concepts:
- Population (
pop) — an integer head-count stored per province; drives taxation, labor, and army upkeep. - Peasants — a granular bucket count derived from
pop; the game UI and allocation sliders operate in peasant units, not raw population.
Integer math primitives#
All calculations use integer truncation. Two helpers are referenced throughout:
mul_percent(a, b) = (a * b) / 100 // truncating; never rounds up
ratio_percent(a, b) = (a * 100) / b // returns 0 when b == 0
Both are confirmed leaf functions — see Evidence table.
Rules#
1. Seasonal population update (lotr2_game_apply_tax_income)#
Called once per end-of-turn pass over all province slots. The province economy
table is indexed at stride 0x300.
Step 1 — Birth computation#
base_rate = lookup_threshold(pop, table_DAT_004d6308, 20_pairs, default=1)
lotr2_game_lookup_threshold walks a static table of (threshold, value) pairs;
it returns the value of the first pair where pop < threshold, or the default
when all thresholds are exceeded. The table at DAT_004d6308 (20 pairs) maps
population size to a base birth-rate percentage. These static values are in the
binary; they require a PE dump to confirm (see Open questions).
Prosperity multiplier on base_rate:
Prosperity byte (DAT_0053f9bc + id*0x300) |
Birth-rate factor |
|---|---|
| < 26 | 25% |
| 26 – 50 | 50% |
| 51 – 75 | 75% |
| 76 – 99 | 100% |
| ≥ 100 | 120% |
birth_pct = mul_percent(base_rate, prosperity_multiplier)
births = mul_percent(pop, birth_pct)
if births == 0 and birth_pct != 0: births = 1 // floor-to-1 rule
Step 2 — Death computation#
death_pct = DAT_004d63a8[health_tier * 4] + DAT_004d63c0[season * 4]
deaths = mul_percent(pop, death_pct)
if deaths == 0 and death_pct != 0: deaths = 1 // floor-to-1 rule
health_tier is (byte)(&DAT_0053f9b9)[id * 0x300] (province climate/health
index). season is the global season counter DAT_0057c934 (1–4). Table values
at DAT_004d63a8 and DAT_004d63c0 are static and require a PE dump to confirm.
Step 3 — Health-tier-0 death penalty and tie-break bias#
When health_tier == 0 (harshest climate), deaths receive an extra flat bonus:
if health_tier == 0: deaths += 2
One extra person is then added to whichever side has the larger rate:
if birth_pct < death_pct:
deaths += 1
else:
births += 1
Step 4 — Event modifier (DAT_0053fbab)#
Set by lotr2_game_apply_season_random_events before apply_tax_income runs.
The field is a signed byte per province.
mod = (signed byte)(&DAT_0053fbab)[id * 0x300]
cap = mul_percent(pop, 20) // 20% of current population
if mod < 0:
bonus = mul_percent(deaths, -mod) + 10
if bonus > cap: bonus = cap
deaths += bonus
elif mod > 0:
bonus = mul_percent(births, mod) + 10
if bonus > cap: bonus = cap
births += bonus
Population-shock event (0x8a) sets fbab to a negative value keyed on
season: season 4 → -40 (0xd8), seasons 1 and 3 → -30 (0xe2), season 2 →
-20 (0xec). Only fires when pop >= 100.
Step 5 — Apply net change#
pop = pop + births - deaths
if pop < 1:
births = 0
deaths = old_pop // retroactively record full loss
pop = 0
Step 6 — Tribute transfers#
pop -= emigration // DAT_0053f9ec, set by lotr2_game_apply_tribute_transfers
pop += immigration // DAT_0053f9f0
if pop < 1: pop = 0
Step 7 — Peasant granularity (per-province recalc)#
After population settles:
if pop < 1:
peasants = 1
else:
peasants = ((pop - 1) / 25) + 1 // integer ceil(pop / 25)
peasants is stored at (&DAT_0053fa68)[id * 0x300] and drives allocation
slot widths everywhere in the economy.
Step 8 — Tile stamp#
Map tile visual tier is stamped based on thresholds:
| Population range | Tile stamp |
|---|---|
< 801 (0x321) |
'/' (2) |
801 – 1200 (0x4b0) |
'3' (2) |
| > 1200 | '7' (2) |
Special tile levels (affecting village appearance):
| Population range | Level |
|---|---|
< 601 (0x259) |
0 |
601 – 1000 (0x3e8) |
1 |
1001 – 1400 (0x578) |
2 |
1401 – 1600 (0x640) |
3 |
| > 1600 | 4 |
2. Peasant allocation (lotr2_game_allocate_prosperity)#
Called from lotr2_game_economy_tick_player and from the UI whenever allocation
sliders change. Distributes pop across labor and tax buckets.
All cap values (targets) are read from the province economy record at offsets
DAT_0053fa7c, DAT_0053fa88, DAT_0053fa94, etc. A cap of 999999 is treated
as 0 (no allocation desired).
Tax split#
tax_pct = (signed byte)(&DAT_0053f9b8)[id * 0x300]
tax_pool = mul_percent(pop, tax_pct)
alloc_pool = pop - tax_pool
tax_pct is recalculated by lotr2_game_calc_tax_rate_percent and stored as a
byte; it reflects the proportion of population currently serving as soldiers,
diplomats, etc.
Stage A — labor allocation (3 buckets)#
Slider percentages (pctA0..2) are stored at DAT_0053fae0/fae4/fae8 per
province. Default values:
| Slot | Default % | Bucket field |
|---|---|---|
| 0 | 33 (0x21) |
DAT_0053fa74 |
| 1 | 50 (0x32) |
DAT_0053fa80 |
| 2 | 17 (0x11) |
DAT_0053fa8c |
All 9 bucket current-values are zeroed, then quota-pass:
q0 = mul_percent(alloc_pool, pctA0)
q1 = mul_percent(alloc_pool, pctA1)
q2 = mul_percent(alloc_pool, pctA2)
// quota fill — each bucket increments one unit at a time until quota or cap
while q0 > 0 and bucket0 < capA0: bucket0++; q0--; alloc_pool--
while q1 > 0 and bucket1 < capA1: bucket1++; q1--; alloc_pool--
while q2 > 0 and bucket2 < capA2: bucket2++; q2--; alloc_pool--
Remaining alloc_pool is then distributed round-robin, one unit per step, in
this fixed order per outer iteration (repeating until alloc_pool exhausted or
all caps full):
bucket0 (×2), bucket1 (×3), bucket2 (×1)
If alloc_pool remaining after round-robin is < peasants, it is folded into
tax_pool and alloc_pool set to 0.
Stage B — tax-pool allocation (5 buckets)#
Slider percentages at DAT_0053faf8/faf4/faf0/fafc/faec. Default values:
| Slot | Default % | Bucket field |
|---|---|---|
| B0 | 100 | DAT_0053fabc |
| B1 | 0 | DAT_0053fab0 |
| B2 | 0 | DAT_0053faa4 |
| B3 | 0 | DAT_0053fac8 |
| B4 | 0 | DAT_0053fa98 |
Same quota-fill pattern applied to tax_pool, then round-robin:
B0 (×1), B1 (×1), B2 (×1), B3 (×1), B4 (×1)
Idle remainder#
idle = alloc_pool_remainder + tax_pool_remainder
(&DAT_0053fad4)[id * 0x300] = idle
3. Allocation slider visualization (lotr2_game_recalc_allocation_sliders)#
Converts bucket counts into the 25-cell bar display (DAT_005540a0, stride 0x19
per slot). Called for the local player's province. Takes peasants as the
display divisor.
For each of 8 UI slots:
current = bucket[slot]
min_cap = bucket_min[slot] // DAT_0053fa78
max_cap = bucket_max[slot] // DAT_0053fa7c
cells_filled = ceil(current / peasants)
if current < min_cap: // below minimum → deficit indicator
delta = -ceil((min_cap - current) / peasants)
elif current > max_cap: // above maximum → surplus indicator
delta = ceil((current - max_cap) / peasants)
if max_cap == 0 and current % peasants != 0: delta += 1
else:
delta = 0
lotr2_game_apply_allocation_slider_delta(slot, cells_filled, delta)
lotr2_game_fill_allocation_slider_row and
lotr2_game_fill_allocation_slider_split paint the bar cells with up to two
colour values. Slot 6 always forces colour index 2.
4. Spend-driven province tier growth (lotr2_map_apply_population_growth)#
Called from the province-event dispatcher when event type 4 fires (gold-spend
path), passing spend and province_id. This is independent of the seasonal
birth/death cycle.
if pop <= 0: return // no effect on empty province
step = pop / 10 // integer truncation
// ladder — how many steps the spend covers
if spend < step: gain = 0
elif spend < step*2: gain = 1
elif spend < step*3: gain = 2
elif spend < step*4: gain = 3
elif spend < step*5: gain = 4
else: gain = 5
// cap: cannot exceed tier ceiling of 5
room = 5 - current_tier
if gain > room: gain = room
if gain < 0: gain = 0
// apply
current_tier += gain // DAT_0053fbc9[id*0x300]
province_pop_accumulator += gain // DAT_0053f9bc[id*0x300]
province_int_counter += gain // DAT_0053fb44[id*0x300], int32
// clamp accumulator at 100
if province_pop_accumulator > 100:
province_pop_accumulator = 100
current_tier is the province population tier byte; pop and province_id are
passed as parameters (GoG parameter order: param_1 = spend, param_2 = id).
Data model#
Province economy fields are in the lotr2_province_economy_table (stride 0x300,
up to 17 entries) at base address 0x0055C3B0 (retail) / 0x0053F9B0 (GoG).
Key field offsets within each 0x300 block:
| Offset | Size | Field |
|---|---|---|
+0x05 (0x0053f9b5) |
byte | Owner faction slot index |
+0x08 (0x0053f9b8) |
byte | Tax rate percent |
+0x09 (0x0053f9b9) |
byte | Health / climate tier index |
+0x0c (0x0053f9bc) |
byte | Prosperity / population-tier accumulator |
+0x0d (0x0053f9bd) |
byte | Prosperity baseline |
+0x24 (0x0053f9d4) |
int32 | Population (pop) |
+0x28 (0x0053f9d8) |
int32 | Population snapshot (previous turn) |
+0xb8 (0x0053fa68) |
byte | Peasant bucket count (ceil(pop/25)) |
+0xc4 (0x0053fa74) |
int32 | Stage-A bucket 0 current |
+0xc8 (0x0053fa78) |
int32 | Stage-A bucket 0 min-cap |
+0xcc (0x0053fa7c) |
int32 | Stage-A bucket 0 max-cap |
+0xd0 (0x0053fa80) |
int32 | Stage-A bucket 1 current |
+0xdc (0x0053fa8c) |
int32 | Stage-A bucket 2 current |
+0xfb (0x0053fbab) |
sbyte | Seasonal event birth/death modifier |
+0x119 (0x0053fbc9) |
byte | Province population tier |
GoG economy table base (province slot 0): 0x0053f9b0; stride 0x300.
Retail base: 0x0055C3B0.
Saved in the field-block table as block 4 (offset 0x015148 in save file,
0x3300 bytes): see ../save/save-file-format.md.
Evidence#
| Symbol | Address (retail) | Address (GoG) | Role |
|---|---|---|---|
lotr2_game_apply_tax_income |
0x00452602 |
0x00449ef3 |
Seasonal birth/death + tribute; main population update |
lotr2_game_allocate_prosperity |
0x00457c9f |
0x0044f6e7 |
Split pop into labor/tax buckets |
lotr2_game_recalc_allocation_sliders |
0x00459bd7 |
0x0045161e |
Recompute 25-cell slider bar for local player |
lotr2_game_apply_allocation_slider_delta |
0x00459e5e |
0x004518a5 |
Paint slider bar with fill/delta |
lotr2_game_fill_allocation_slider_row |
0x00459f83 |
0x004519ca |
Uniform-fill helper for slider bar |
lotr2_game_fill_allocation_slider_split |
0x0045a016 |
0x00451a5d |
Split-tone helper for slider bar |
lotr2_game_normalize_allocation_percents |
0x004585b8 |
0x00450000 |
Recompute pct fields from current bucket counts |
lotr2_game_allocation_slot_to_bucket |
0x00459d83 |
0x004517ca |
Map UI slot 0–7 → bucket index via DAT_004d6780 |
lotr2_game_allocation_slot_enabled |
0x00459df3 |
0x0045183a |
Whether slot is active |
lotr2_game_calc_tax_rate_percent |
0x00458502 |
0x0044ff4a |
Derive tax_pct from allocated vs total |
lotr2_map_apply_population_growth |
0x00433474 |
0x00428c42 |
Gold-spend province tier growth |
lotr2_game_apply_season_random_events |
0x00451785 |
0x00448819 |
Rotate event table; set fbab modifier |
lotr2_game_event_apply_population_shock |
0x00451c26 |
0x00448f6f |
Set negative fbab for plague/disaster |
lotr2_game_apply_tribute_transfers |
0x00452dca |
0x0044a6ba |
Compute emigration/immigration deltas |
lotr2_game_mul_percent |
0x00404d6b |
0x00404d6b |
(a * b) / 100 |
lotr2_game_ratio_percent |
0x00404dc1 |
0x00404dc1 |
(a * 100) / b |
lotr2_game_lookup_threshold |
0x00404e4b |
0x00404e4b |
Walk threshold table; return paired value |
lotr2_game_reset_allocation_sliders_default |
0x004514f8 |
0x004514f8 |
New-game defaults |
Evidence docs:
../../architecture/game/MODULE.md— end-turn tick,apply_tax_income,allocate_prosperity, allocation slider symbols../../architecture/map/MODULE.md—lotr2_map_apply_population_growth../../architecture/symbol-map.md— passes 29, 40, 44
Confidence#
confirmed — all formula arithmetic decompiled directly from
src/Lords2-gog.exe.c and cross-checked against src/LORDS2.EXE.c. Integer
truncation semantics (mul_percent, ratio_percent) verified as leaf functions.
Peasant formula (ceil(pop/25)) confirmed in both lotr2_game_apply_tax_income
and lotr2_game_init_new_campaign_players. Stage-A/B bucket fill order confirmed
by tracing the deterministic round-robin loops.
likely — exact names and semantics of Stage-A/B buckets (which bucket maps to "gold workers", "farm workers", etc.) are inferred from context; bucket identity requires UI cross-referencing.
likely — numeric contents of static tables DAT_004d6308 (birth-rate
thresholds), DAT_004d63a8 (health-tier death rates), and DAT_004d63c0
(seasonal death modifiers) require a PE dump for confirmation (no .exe binary
in-repo; script tools/ghidra/dump_combat_tables.py can be extended to cover
these VAs).
Interop notes#
- Save parity:
popand all bucket counts persist in block 4 of the save file. A reimplementation must reproduce per-turn population deltas exactly to avoid divergence in saved games. - The
prosperity_bytethreshold breakpoints (26/50/76/100) are embedded inlotr2_game_apply_tax_incomesource; they are not table-driven and are identical between retail and GoG builds. peasants = ceil(pop/25)is recomputed frompopeach turn; it is not stored independently as a stable save field — reconstructing it frompopis correct.
Open questions#
- Dump
DAT_004d6308(birth-rate table, 20 pairs),DAT_004d63a8(health-tier death rates), andDAT_004d63c0(seasonal death bonus) from bothLORDS2.EXEandLords2.exe; add a concrete table section here once confirmed. - Identify which Stage-A/B bucket corresponds to which in-game labor type (gold workers, farmers, build workers, etc.) by cross-referencing the allocation UI draw code.
- Confirm the
DAT_004d6780slot-to-bucket mapping table (8 entries × 4 bytes) by PE dump; this determines the exact bucket each UI slider row affects.