Skip to content

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:

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: pop and 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_byte threshold breakpoints (26/50/76/100) are embedded in lotr2_game_apply_tax_income source; they are not table-driven and are identical between retail and GoG builds.
  • peasants = ceil(pop/25) is recomputed from pop each turn; it is not stored independently as a stable save field — reconstructing it from pop is correct.

Open questions#

  • Dump DAT_004d6308 (birth-rate table, 20 pairs), DAT_004d63a8 (health-tier death rates), and DAT_004d63c0 (seasonal death bonus) from both LORDS2.EXE and Lords2.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_004d6780 slot-to-bucket mapping table (8 entries × 4 bytes) by PE dump; this determines the exact bucket each UI slider row affects.