From 087bba8b4937f3a8faca4fcf65c4ad60c302f7ba Mon Sep 17 00:00:00 2001 From: Kelly Kinkade Date: Thu, 12 Nov 2026 12:38:11 -0600 Subject: [PATCH 1/2] dead-noble: recover from a deceased baron/count/duke This script provides tools for recovering from a deceased baron/count/duke. When the landholder dies, the game selects a successor, but this successor is almost always at another site. The game is supposed to push the successor into an army that will path to the site and join it as the new landholder, but the vanilla code to do this includes a test that prevents it from working for landholders (it works for any _other_ civ-level noble position where this happens, just not for landholders. go figure.) so clearly this is what is _supposed_ to happen, it just doesn't because Toady's code is broken. This script finds the already-appointed successor, creates an army, shoves the successor into it, and emulates a unitless "makeown" so that when the successor arrives they'll (re)join the site as the new landholder automatically. Tested on a fort with a dead baron obtained from BlindIRL. --- dead-noble.lua | 1336 +++++++++++++++++++++++++++++++++++++++++++ docs/dead-noble.rst | 143 +++++ 2 files changed, 1479 insertions(+) create mode 100644 dead-noble.lua create mode 100644 docs/dead-noble.rst diff --git a/dead-noble.lua b/dead-noble.lua new file mode 100644 index 000000000..d6584418b --- /dev/null +++ b/dead-noble.lua @@ -0,0 +1,1336 @@ +-- Repair or replace a fortress's dead/missing land holder (baron/count/duke). +--[====[ + +dead-noble +========== +Diagnose and fix a fortress that has lost its baron, count, or duke and +can no longer receive a replacement. + +A land-holder assignment lives on the parent civilization +(``plotinfo.civ_id``), and the civilization's site link to the fort's site +points at it via ``position_profile_id``. When the holder dies, the game +is supposed to clear ``assignment.histfig`` and re-classify the vacancy so +that the normal succession/appointment/claimant pipeline can fill it. +When that fails, the slot stays occupied by a dead historical figure and +the fortress is never eligible for another land holder. + +Usage: + +``dead-noble status`` + Dump the state of every land-holder assignment linked to this site: + assignment/def ids, current and last holder (alive/dead, whereabouts), + site-link pointers, and the vacancy caches the assignment sits in. + +``dead-noble army`` + For the site's land-holder(s): resolve their nemesis record, check the + civilized-actor sweep list, and report any army/army-controller moving + them (the native succession-traveler mechanism) plus the destination + the native logic would pick. + +``dead-noble vacate [--living]`` + For each land-holder assignment tied to this site whose holder is dead + (or absent; ``--living`` also strips a living off-site holder), perform + the same cleanup the native death path performs: remove the holder's + POSITION entity link, record a FORMER_POSITION link and a + ``remove_hf_entity_link`` history event, clear ``histfig`` while + keeping ``histfig2`` (last holder, used for heir searches), pull the + assignment out of the responsibility caches, and re-queue it for + evaluation. On the next entity-advance tick the game itself will run + succession (rightful heir), then appointment, then a spawned claimant + -- i.e. vanilla behaviour resumes. + +``dead-noble claim`` + Like ``vacate``, but additionally force the claimant path: the + assignment is pushed onto ``possible_claimable`` so the native + position-claim machinery spawns a claimant who travels to the fort + (same-landmass restriction applies, as in vanilla). + +``dead-noble install`` + Directly install the selected (or ``--unit``) citizen as the holder of + the fort's current land-holder position, replicating the diplomat + ESTABLISH_LAND_HOLDER path: the assignment is created or reused, + ``histfig``/``histfig2`` are set, a POSITION link plus + ``add_hf_entity_link`` history event are recorded, the responsibility + caches and the civilization's site link are updated, and the native + position-activity evaluation is re-triggered. + +``dead-noble install --position BARON`` + Install into a specific land-holder position code instead of the one + currently associated with the site. + +``dead-noble settle`` + Finish the arrival half of a recall: a holder brought home by a + MOVE_TO_SITE army materializes at the fort still flagged as a + visitor/guest (the native sweep delivers the body but never performs + the take-office step). Runs the native ``unitst::make_own`` routine + on the materialized holder -- the same code path as a petition for + citizenship -- which grants the MEMBER link to the fort's site + government, updates the entity's member lists, switches the + whereabouts record to settler, and clears guest/visitor flags. Also + resets ``st_id`` to -1, matching settled land-holders. + +``dead-noble fetch`` + Patch-free alternative to ``recall`` for land-holder positions: the + native sweep's ``land_holder > 0`` gate skips barons/counts/dukes no + matter what ``st_id`` says, so instead of patching the binary this + command emulates the sweep's army-creation tail directly -- allocates + an ``army_controller`` (``MOVE_TO_SITE``, destination = fort) and an + ``army`` at the holder's position, adds their nemesis, and switches + their whereabouts to in-army. It also pre-instates the holder: + grants the MEMBER link to the fort's site government and updates its + member lists immediately, so that when the army arrives the native + ingress machinery delivers them as a citizen and officeholder (the + 'has returned' announcement) rather than a guest. The native + army-advance code does the march; no ``settle`` step is needed. + +]====] + +local argparse = require('argparse') + +local world = df.global.world +local plotinfo = df.global.plotinfo +local utils = require('utils') + +local function qerr(fmt, ...) + qerror(fmt:format(...)) +end + +local function civ() + return df.historical_entity.find(plotinfo.civ_id) + or qerr('cannot resolve parent civ %d', plotinfo.civ_id) +end + +local function position_def(entity, position_id) + for _, def in ipairs(entity.positions.own) do + if def.id == position_id then return def end + end +end + +local function def_for(entity, code) + for _, def in ipairs(entity.positions.own) do + if def.code == code then return def end + end +end + +local function find_assignment(entity, id) + for _, a in ipairs(entity.positions.assignments) do + if a.id == id then return a end + end +end + +local function site_link_to(entity, site_id) + for _, link in ipairs(entity.site_links) do + if link.target == site_id then return link end + end +end + +local function hf_of(hfid) + return hfid ~= -1 and df.historical_figure.find(hfid) or nil +end + +local function site_landmass(site) + if not site then return nil end + local col = world.world_data.region_map[site.pos.x] + return col and col:_displace(site.pos.y).landmass_id or nil +end + +local function hf_landmass(wb) + if wb.abs_smm_x == -1 or wb.abs_smm_y == -1 then return nil end + local col = world.world_data.region_map[wb.abs_smm_x // 0x30] + return col and col:_displace(wb.abs_smm_y // 0x30).landmass_id or nil +end + +local function hf_desc(hf) + if not hf then return '' end + local name = dfhack.df2utf(dfhack.translation.translateName(hf.name, true)) + local state = hf.died_year ~= -1 and ('DEAD ' .. hf.died_year) or 'alive' + local site = 'site?' + local wb = hf.info and hf.info.whereabouts or nil + if wb then + site = 'site ' .. tostring(wb.site_id) + local lm = hf_landmass(wb) + if lm then + site = site .. ', landmass ' .. tostring(lm) + end + end + return ('%s (hf %d, %s, %s)'):format(name, hf.id, state, site) +end + +-- land-holder assignments the civ associates with our site. +-- strict=false lists every land-holder assignment (diagnostics); +-- strict=true keeps only those actually tied to this site: the +-- assignment our site link points at, plus any with st_id == our site. +local function site_land_holder_assignments(entity, strict) + local out = {} + local link = site_link_to(entity, plotinfo.site_id) + local linked_id = link and link.position_profile_id or -1 + -- site links pointing at each assignment id + local link_sites = {} + for _, l in ipairs(entity.site_links) do + if l.position_profile_id ~= -1 then + link_sites[l.position_profile_id] = + link_sites[l.position_profile_id] or {} + table.insert(link_sites[l.position_profile_id], l.target) + end + end + for _, a in ipairs(entity.positions.assignments) do + local def = position_def(entity, a.position_id) + if def and def.land_holder > 0 then + local ours = a.st_id == plotinfo.site_id or a.id == linked_id + if not ours and link_sites[a.id] then + for _, sid in ipairs(link_sites[a.id]) do + if sid == plotinfo.site_id then ours = true break end + end + end + if not strict or ours then + table.insert(out, { + assignment = a, def = def, + ours = ours and true or false, + sites = link_sites[a.id], + }) + end + end + end + table.sort(out, function(x, y) + return x.def.land_holder < y.def.land_holder + end) + return out, linked_id +end + +local function in_vector(vec, item) + for _, v in ipairs(vec) do + if v == item then return true end + end + return false +end + +local function vec_desc(vec, item) + return in_vector(vec, item) and 'yes' or 'no' +end + +local function holder_is_dead(hf) + return hf == nil or hf.died_year ~= -1 +end + +local function holder_off_site(hf) + return hf and hf.info and hf.info.whereabouts + and hf.info.whereabouts.site_id ~= plotinfo.site_id +end + +local function rd_i32(obj, off) + local _, a = df.sizeof(obj) + return df.reinterpret_cast('int32_t', a + off).value +end + +local function nemesis_of_hfid(hfid) + for _, n in ipairs(world.nemesis.all) do + if n.figure and n.figure.id == hfid then return n end + end +end + +-- emulate FUN_14147d730's gate chain for one nemesis, printing each verdict +local function trace_sweep(nem, hf) + local function gate(ok, name) + print((' [%s] %s'):format(ok and 'PASS' or 'FAIL', name)) + return ok + end + if not gate(rd_i32(hf, 0x30) == -1, + ('figure+0x30 (died_year?) == -1 [=%d]'):format(rd_i32(hf, 0x30))) + then return end + if not gate(nem.group_leader_id == -1, 'group_leader_id == -1') then return end + local uok = nem.unit == nil or ((nem.unit.flags1.whole >> 31) & 1) == 0 + if not gate(uok, 'unit == NULL or unit.flags1 bit31 clear') then return end + -- entity-link scan: POSITION link -> entity -> assignments[assignment_id] + -- native quirk: RSP+0x70 (dest's st_id source) is the LAST resolved link's + -- assignment, while RSP+0x38/0x48 (fallback + army bookkeeping) is the + -- MIN-precedence winner. + local assignment, ent, def -- min-precedence winner + local last_asg, last_ent, last_def -- last resolved link + local best_prec = 30001 + for _, l in ipairs(hf.entity_links) do + if l._type == df.histfig_entity_link_positionst and l.entity_id ~= -1 then + local e = df.historical_entity.find(l.entity_id) + local asg + if e then + for _, a2 in ipairs(e.positions.assignments) do + if a2.id == l.assignment_id then asg = a2 break end + end + end + local d + if asg then + for _, p in ipairs(e.positions.own) do + if p.id == asg.position_id then d = p break end + end + end + print((' link: asg_id=%d ent=%s -> asg %s (%s) st_id=%s prec=%s%s'): + format(l.assignment_id, tostring(l.entity_id), + asg and asg.id or 'UNRESOLVED', + d and d.code or '?', + asg and asg.st_id or '?', + d and d.precedence or '?', + asg and ' [last-wins candidate]' or '')) + if asg then + last_asg, last_ent, last_def = asg, e, d + if d and d.precedence < best_prec then + best_prec = d.precedence + assignment, ent, def = asg, e, d + end + end + end + end + if last_asg and last_asg ~= assignment then + print((' !! dest st_id read from LAST link (asg %s st_id=%s), ' .. + 'winner is asg %s'): + format(last_asg.id, tostring(last_asg.st_id), assignment.id)) + end + if not gate(assignment ~= nil, + ('POSITION link resolved (asg=%s ent=%s prec=%d)'):format( + assignment and assignment.id or 'nil', + ent and ent.id or 'nil', best_prec)) then return end + local wb = hf.info and hf.info.whereabouts + if not gate(wb ~= nil, 'whereabouts present') then return end + if not gate(wb.state ~= 3 and wb.state ~= 4, + ('whereabouts.state %d not in {3,4}'):format(wb.state)) + then return end + if not gate(wb.abs_smm_x ~= -1, 'abs_smm_x set') then return end + -- debugger-verified: land_holder>0 on the LAST resolved def skips + -- unconditionally BEFORE destination resolution (def+0x2c8, jg -> tail) + if not gate(last_def and last_def.land_holder <= 0, + ('last def.land_holder %s <= 0 (unconditional skip)'): + format(last_def and last_def.land_holder)) + then return end + -- destination: st_id is a site ID resolved by the sweep's get_site + -- (verified live: st_id=1629 produced an army to site id 1629). + local dest + if last_asg.st_id ~= -1 then + dest = df.world_site.find(last_asg.st_id) + print((' [dest] st_id path (asg %d st_id=%d) -> site %s'): + format(last_asg.id, last_asg.st_id, dest and dest.id or 'nil')) + elseif tostring(ent.type):find('CIVILIZATION') then + for _, l in ipairs(ent.site_links) do + if l.flags.capital then dest = df.world_site.find(l.target) end + end + print((' [dest] capital path -> site %s'):format(dest and dest.id or 'nil')) + elseif ent.type == df.historical_entity_type.SiteGovernment + or ent.type == df.historical_entity_type.Religion + or ent.type == df.historical_entity_type.MilitaryUnit then + for _, l in ipairs(ent.site_links) do + if l.flags.residence then dest = df.world_site.find(l.target) end + end + print((' [dest] residence path -> site %s'):format(dest and dest.id or 'nil')) + else + gate(false, ('winner entity type %s not in {0,1,5,7} -> skip'): + format(tostring(ent.type))) + return + end + if not gate(dest ~= nil, 'destination site resolved') then return end + local lm_hf = hf_landmass(wb) + local lm_dest = site_landmass(dest) + gate(lm_hf ~= nil and lm_hf == lm_dest, + ('landmass match %s == %s'):format(tostring(lm_hf), tostring(lm_dest))) + local _, saddr = df.sizeof(dest) + local g = df.reinterpret_cast('int32_t', saddr + 0x348).value + gate(g == 0, ('site+0x348 == 0 [=%d]'):format(g)) + local home = nil + for _, l in ipairs(hf.site_links) do + local t = l:getType() + if t >= 3 and t <= 7 and l.site ~= -1 then home = l.site break end + end + if not home then home = wb.site_id end + if not gate(home ~= dest.id, + ('not already home: home %d != dest %d'):format(home, dest.id)) + then return end + for _, l in ipairs(hf.entity_links) do + local t = l:getType() + if (t == 6 or t == 4) and l.link_strength ~= 0 then + gate(false, ('PRISONER/SLAVE link strength %d'):format(l.link_strength)) + return + end + end + for _, l in ipairs(hf.histfig_links) do + if tostring(l._type):find('IMPRISONER') and l.link_strength ~= 0 then + gate(false, 'IMPRISONER link') + return + end + end + print(' [OK] all gates pass -> native sweep SHOULD create MOVE_TO_SITE') +end + +-- which world.nemesis.* vector is at world+0x13cb8 (the sweep's iterate)? +local function cmd_nemvecs() + local _, waddr = df.sizeof(world) + for _, name in ipairs{'all', 'independent_actor', 'civilized_actor', + 'plotter_actor', 'new_plotter_actor', + 'artist_actor', 'poet_actor', 'bard_actor', + 'dancer_actor', 'scholar_actor', 'hero_actor', + 'underbelly_actor', 'religious_actor', + 'merchant_actor', 'order_load'} do + local ok, a = pcall(function() + local _, x = df.sizeof(world.nemesis:_field(name)) + return x + end) + if ok and a then + print((' %-22s +0x%x %s'):format(name, a - waddr, + (a - waddr) == 0x13cb8 and '<== sweep iterates THIS' or '')) + end + end +end + +local function cmd_trace() + local entity = civ() + -- the native call needs cur_season_tick%120==50 AND the region_logic + -- frame counter (every 144 calls) to coincide -> fires ~every 120-720 ticks + print(('cur_season_tick %d (%%120 = %d, needs 50)'): + format(df.global.cur_season_tick, df.global.cur_season_tick % 120)) + local rows = site_land_holder_assignments(entity, true) + for _, row in ipairs(rows) do + local hf = hf_of(row.assignment.histfig) + if not hf then + print(('assignment %d: no living holder'):format(row.assignment.id)) + else + local nem = nemesis_of_hfid(hf.id) + print(('assignment %d holder %s nemesis %s'): + format(row.assignment.id, hf.id, nem and nem.id or 'nil')) + if nem then trace_sweep(nem, hf) end + end + end +end + +local function cmd_status() + local entity = civ() + local rows, linked_id = site_land_holder_assignments(entity, false) + print(('civ %d (%s), site %d, site-link position_profile_id %d'): + format(entity.id, + dfhack.df2utf(dfhack.translation.translateName(entity.name, true)), + plotinfo.site_id, linked_id)) + if #rows == 0 then + print('no land-holder assignments found for this site') + -- still show defs in case none were ever created + for _, def in ipairs(entity.positions.own) do + if def.land_holder > 0 then + print((' def %d %s land_holder=%d (no assignment)'): + format(def.id, def.code, def.land_holder)) + end + end + return + end + local fort_site = df.world_site.find(plotinfo.site_id) + local fort_lm = site_landmass(fort_site) + for _, row in ipairs(rows) do + local a, def = row.assignment, row.def + local cur = hf_of(a.histfig) + local last = hf_of(a.histfig2) + print(('assignment %d%s: %s (def %d, land_holder %d, st_id %d, flags {%s})'): + format(a.id, row.ours and ' [THIS SITE]' or '', def.code, + def.id, def.land_holder, a.st_id, + a.flags.active and 'active' or 'INACTIVE')) + if row.sites then + print((' holds land at sites: %s'): + format(table.concat(row.sites, ' '))) + end + print((' holder %d: %s'):format(a.histfig, hf_desc(cur))) + if cur and cur.died_year == -1 and holder_off_site(cur) then + local lm = hf_landmass(cur.info.whereabouts) + if lm and fort_lm and lm ~= fort_lm then + print(' !! holder is on a different landmass; the native ' .. + 'succession-traveler army cannot bring them home') + end + end + print((' last_holder %d: %s'):format(a.histfig2, hf_desc(last))) + if a.id == linked_id and cur then + -- does the holder actually carry the position link? + local has_link = false + for _, l in ipairs(cur.entity_links) do + if df.histfig_entity_link_positionst:is_instance(l) + and l.assignment_id == a.id + then + has_link = true + end + end + print((' holder has POSITION link to this assignment: %s'): + format(tostring(has_link))) + for _, l in ipairs(cur.site_links) do + if l.site ~= -1 then + print((' holder site link: %s -> site %d'): + format(tostring(l._type), l.site)) + end + end + end + print((' caches: evaluate=%s succession=%s appointable=%s elected=%s claimable=%s'): + format(vec_desc(entity.positions.possible_evaluate, a), + vec_desc(entity.positions.possible_succession, a), + vec_desc(entity.positions.possible_appointable, a), + vec_desc(entity.positions.possible_elected, a), + vec_desc(entity.positions.possible_claimable, a))) + end + local f = entity.flags + print(('entity flags: eval_activity=%s possible_evaluate=%s succession=%s appointable=%s elected=%s nems_check=%s'): + format(tostring(f.evaluate_position_profile_activity), + tostring(f.possible_evaluate_position_profile), + tostring(f.possible_succession_position_profile), + tostring(f.possible_appointable_position_profile), + tostring(f.possible_elected_position_profile), + tostring(f.make_nems_check_positions))) + + -- which sites' civ links point at which assignments; flags on ours + print('site links with land-holder pointers:') + local linkflag_names = { + 'land_for_holding', 'central_holding_land', 'land_holder_residence', + 'residence', 'local_market', + } + for _, link in ipairs(entity.site_links) do + if link.position_profile_id ~= -1 or link.flags.land_for_holding then + local flagstr = {} + for _, fn in ipairs(linkflag_names) do + if link.flags[fn] then table.insert(flagstr, fn) end + end + local site = df.world_site.find(link.target) + local sname = site and dfhack.df2utf(dfhack.translation.translateName(site.name, true)) or '?' + print((' site %d (%s) -> assignment %d [%s] type %s%s'): + format(link.target, sname, link.position_profile_id, + table.concat(flagstr, ' '), tostring(link.type), + link.target == plotinfo.site_id and ' <== THIS SITE' or '')) + end + end + -- the fortress entity's own residence link + local gov = df.historical_entity.find(plotinfo.group_id) + if gov then + for _, link in ipairs(gov.site_links) do + if link.flags.residence then + print(('fortress gov RESIDENCE link: site %d, position_profile_id %d'): + format(link.target, link.position_profile_id)) + end + end + end +end + +-- erase `item` from a pointer vector, returning success +local function vec_erase(vec, item) + for i, v in ipairs(vec) do + if v == item then vec:erase(i) return true end + end + return false +end + +-- remove `assignment` from each responsibility slot its def claims +local function remove_from_responsibility_caches(entity, def, assignment) + for resp, held in pairs(def.responsibilities) do + if held then + vec_erase(entity.assignments_by_type[resp], assignment) + end + end +end + +local function add_to_responsibility_caches(entity, def, assignment) + for resp, held in pairs(def.responsibilities) do + if held then + local vec = entity.assignments_by_type[resp] + if not in_vector(vec, assignment) then vec:insert('#', assignment) end + end + end +end + +-- queue a vacant assignment for re-evaluation (mirrors +-- history_figurest::remove_entity_position_link_by_index) +local function queue_vacancy(entity, assignment) + if assignment.flags.active + and not in_vector(entity.positions.possible_evaluate, assignment) + then + entity.positions.possible_evaluate:insert('#', assignment) + entity.flags.possible_evaluate_position_profile = true + world.flags.an_entity_has_possible_evaluate_position_profile = true + end + entity.flags.evaluate_position_profile_activity = true + world.flags.an_entity_has_evaluate_position_profile_activity = true +end + +local function new_remove_link_event(entity, hf, position_id) + df.global.hist_event_next_id = df.global.hist_event_next_id + 1 + world.history.events:insert('#', { + new = df.history_event_remove_hf_entity_linkst, + id = df.global.hist_event_next_id - 1, + year = df.global.cur_year, + seconds = df.global.cur_year_tick, + civ = entity.id, + histfig = hf.id, + link_type = df.histfig_entity_link_type.POSITION, + position_id = position_id, + }) +end + +local function new_add_link_event(entity, hf, assignment) + df.global.hist_event_next_id = df.global.hist_event_next_id + 1 + world.history.events:insert('#', { + new = df.history_event_add_hf_entity_linkst, + id = df.global.hist_event_next_id - 1, + year = df.global.cur_year, + seconds = df.global.cur_year_tick, + civ = entity.id, + histfig = hf.id, + link_type = df.histfig_entity_link_type.POSITION, + position_id = assignment.id, -- native stores the assignment id here + }) +end + +local function new_add_member_event(entity, hf) + df.global.hist_event_next_id = df.global.hist_event_next_id + 1 + world.history.events:insert('#', { + new = df.history_event_add_hf_entity_linkst, + id = df.global.hist_event_next_id - 1, + year = df.global.cur_year, + seconds = df.global.cur_year_tick, + civ = entity.id, + histfig = hf.id, + link_type = df.histfig_entity_link_type.MEMBER, + position_id = -1, + }) +end + +local function new_change_state_event(hf, state, site_id, reason) + local site = site_id ~= -1 and df.world_site.find(site_id) or nil + df.global.hist_event_next_id = df.global.hist_event_next_id + 1 + local ev = df.history_event_change_hf_statest:new() + ev.id = df.global.hist_event_next_id - 1 + ev.year = df.global.cur_year + ev.seconds = df.global.cur_year_tick + ev.hfid = hf.id + ev.state = state + ev.reason = reason or df.history_event_reason.none + ev.site = site_id + ev.region = -1 + ev.layer = -1 + if site then ev.region_pos:assign(site.pos) end + world.history.events:insert('#', ev) +end + +-- strip `hf`'s POSITION link for `assignment`, mirroring +-- history_figurest::remove_entity_position_link_by_index +local function strip_position_link(entity, hf, def, assignment) + local start_year, link_strength + for i = #hf.entity_links - 1, 0, -1 do + local link = hf.entity_links[i] + if df.histfig_entity_link_positionst:is_instance(link) + and link.entity_id == entity.id + and link.assignment_id == assignment.id + then + start_year = link.start_year + link_strength = link.link_strength + hf.entity_links:erase(i) + link:delete() + end + end + if start_year then + hf.entity_links:insert('#', { + new = df.histfig_entity_link_former_positionst, + entity_id = entity.id, + assignment_id = assignment.id, + link_strength = link_strength or 100, + start_year = start_year, + end_year = df.global.cur_year, + }) + new_remove_link_event(entity, hf, def.id) + end + remove_from_responsibility_caches(entity, def, assignment) + assignment.histfig = -1 -- histfig2 (last holder) is kept for heirs + queue_vacancy(entity, assignment) +end + +-- vacancy cleanup when the dead hf record itself is gone +local function assignment_cleanup(entity, def, assignment) + remove_from_responsibility_caches(entity, def, assignment) + assignment.histfig = -1 + queue_vacancy(entity, assignment) +end + +-- find the nemesis record for an hf, and report army/controller state +local function army_desc(a) + local c = a.controller + local goal = c and tostring(c.goal) or '?' + return ('army %d pos(%d,%d) flags{%s} controller %s goal=%s site_id=%s') + :format(a.id, a.pos.x, a.pos.y, + a.flags.player and 'player' or '-', + c and tostring(c.id) or 'none', goal, + c and tostring(c.site_id) or '?') +end + +-- native gate at world_site+0x348 == mined_hours (FUN_14147d730 @0x14147e08c): +-- must be 0 for the sweep to send a noble there +local function probe_site_gate(site_id) + local site = df.world_site.find(site_id) + if not site then return end + local _, saddr = df.sizeof(site) + local gate = df.reinterpret_cast('int32_t', saddr + 0x348).value + print((' site %d (%s, type %s): mined_hours+0x348 = %d, raw mined_hours=%d%s'): + format(site_id, + dfhack.df2utf(dfhack.translation.translateName(site.name)), + tostring(site.type), gate, site.mined_hours, + gate ~= 0 and ' -- nonzero BLOCKS native travel' or '')) +end + +local function cmd_army() + local entity = civ() + local rows = site_land_holder_assignments(entity, true) + if #rows == 0 then + print('no land-holder assignment tied to this site') + return + end + for _, row in ipairs(rows) do + local a = row.assignment + local hf = hf_of(a.histfig) + if not hf then + print(('assignment %d (%s): no living holder to track'): + format(a.id, row.def.code)) + goto continue + end + print(('assignment %d (%s): st_id=%d ab_id=%d squad_id=%d army_controller=%d'): + format(a.id, row.def.code, a.st_id, a.ab_id, a.squad_id, + a.assigned_army_controller_id)) + print((' holder %s'):format(hf_desc(hf))) + local nem = nemesis_of_hfid(hf.id) + if not nem then + print(' no nemesis record for this hf') + goto continue + end + print((' nemesis %d (unit_id %d, group_leader %d, travel_link %d)'): + format(nem.id, nem.unit_id, nem.group_leader_id, + nem.travel_link_nemid)) + -- unit gate: unit==NULL passes; a unit with flags1 bit31 skips + if nem.unit then + print((' ! nemesis.unit -> unit %d flags1=0x%x bit31=%d%s'): + format(nem.unit.id, nem.unit.flags1.whole, + nem.unit.flags1.whole >> 31 & 1, + (nem.unit.flags1.whole >> 31 & 1) == 1 + and ' -- native sweep SKIPS this nemesis' or '')) + end + -- duplicate nemesis records would confuse the sweep + local nems = 0 + for _, n in ipairs(world.nemesis.all) do + if n.figure and n.figure.id == hf.id then nems = nems + 1 end + end + if nems > 1 then + print((' ! %d nemesis records point at this hf'):format(nems)) + end + -- is the nemesis in the civilized_actor sweep list? + local in_sweep = false + for _, n in ipairs(world.nemesis.civilized_actor) do + if n == nem then in_sweep = true break end + end + print((' in civilized_actor sweep: %s'):format(tostring(in_sweep))) + -- native sweep gates (FUN_14147d730) + local wb = hf.info and hf.info.whereabouts or nil + if wb then + print((' whereabouts: state=%s site_id=%d abs_smm=(%d,%d)'): + format(tostring(wb.state), wb.site_id, + wb.abs_smm_x, wb.abs_smm_y)) + else + print(' whereabouts: -- native sweep skips this hf') + end + for _, l in ipairs(hf.entity_links) do + local t = l._type + if t == df.histfig_entity_link_prisonerst + or t == df.histfig_entity_link_slavest + then + print((' ! %s link, link_strength=%d (blocks travel)'): + format(tostring(t), l.link_strength)) + end + end + for _, l in ipairs(hf.histfig_links) do + if tostring(l._type):find('IMPRISONER') then + print((' ! IMPRISONER link, link_strength=%d (blocks travel)'): + format(l.link_strength)) + end + end + -- POSITION links: sweep resolves entity -> assignments[assignment_id] + for _, l in ipairs(hf.entity_links) do + if l._type == df.histfig_entity_link_positionst then + local e = df.historical_entity.find(l.entity_id) + print((' POSITION link: entity=%d (%s) assignment_id=%d strength=%d'): + format(l.entity_id, e and tostring(e.type) or '?', + l.assignment_id, l.link_strength)) + end + end + -- HOME_SITE* site links (types 3-7): native treats the first as "home" + for _, l in ipairs(hf.site_links) do + local t = l:getType() + if t >= df.histfig_site_link_type.HOME_SITE_ABSTRACT_BUILDING + and t <= df.histfig_site_link_type.HOME_SITE_SAVED_CIVZONE + then + print((' home-site link: %s -> site %d'): + format(tostring(l._type), l.site)) + end + end + -- armies containing the nemesis + local found = false + for _, army in ipairs(world.armies.all) do + for _, m in ipairs(army.members) do + if m.nemesis_id == nem.id then + print(' member of ' .. army_desc(army)) + found = true + end + end + end + if not found then print(' not in any army') end + ::continue:: + end + -- destination the native sweep would pick for a civ nemesis + for _, l in ipairs(entity.site_links) do + if l.flags.capital then + print(('civ CAPITAL site link -> %d (army destination for civ nobles)') + :format(l.target)) + probe_site_gate(l.target) + end + end + probe_site_gate(plotinfo.site_id) + -- any controller already headed for our site? + for _, c in ipairs(world.army_controllers.all) do + if c.site_id == plotinfo.site_id then + print(('controller %d targets our site: goal=%s'): + format(c.id, tostring(c.goal))) + end + end + -- is the succession-traveler mechanism alive anywhere in the world? + local goal_counts = {} + for _, c in ipairs(world.army_controllers.all) do + local g = tostring(c.goal) + goal_counts[g] = (goal_counts[g] or 0) + 1 + end + print('army_controller goals worldwide:') + for g, n in pairs(goal_counts) do + print((' %s x%d'):format(g, n)) + end + print(('civilized_actor nemeses: %d'):format(#world.nemesis.civilized_actor)) +end + +local function cmd_vacate(opts) + local entity = civ() + local rows = site_land_holder_assignments(entity, true) + local touched = 0 + for _, row in ipairs(rows) do + local a, def = row.assignment, row.def + local hf = hf_of(a.histfig) + local clear = false + if a.histfig == -1 then + -- already vacant; make sure it's queued + queue_vacancy(entity, a) + print(('assignment %d (%s): already vacant, re-queued'): + format(a.id, def.code)) + touched = touched + 1 + elseif holder_is_dead(hf) then + clear = true + elseif opts.living and holder_off_site(hf) then + clear = true + end + if clear then + if hf then + strip_position_link(entity, hf, def, a) + else + assignment_cleanup(entity, def, a) + end + print(('assignment %d (%s): cleared holder %d, queued for succession'): + format(a.id, def.code, a.histfig2)) + touched = touched + 1 + end + end + if touched == 0 then + print('nothing to vacate: no dead or absent land-holder assignments') + else + print(('%d assignment(s) queued; native succession will run on the ' + .. 'next entity tick'):format(touched)) + end +end + +local function cmd_claim(opts) + local entity = civ() + local rows = site_land_holder_assignments(entity, true) + local pushed = 0 + for _, row in ipairs(rows) do + local a, def = row.assignment, row.def + local hf = hf_of(a.histfig) + if a.histfig ~= -1 and (holder_is_dead(hf) + or (opts.living and holder_off_site(hf))) then + strip_position_link(entity, hf, def, a) + end + if a.histfig == -1 then + if not in_vector(entity.positions.possible_claimable, a) then + entity.positions.possible_claimable:insert('#', a) + end + entity.flags.make_nems_check_positions = true + world.flags.an_entity_wants_to_mark_news_for_position_claim = true + print(('assignment %d (%s): queued for claimant'): + format(a.id, def.code)) + pushed = pushed + 1 + end + end + if pushed == 0 then + print('no vacant land-holder assignments to claim') + else + print(('queued %d claim(s); a claimant should be generated and ' + .. 'travel to the fort'):format(pushed)) + end +end + +-- Point the assignment's bound-site field (st_id, +0x2c) at our fort. +-- st_id is a SITE ID: the sweep resolves it through get_site and builds a +-- MOVE_TO_SITE army for the holder. NOTE: the sweep's land_holder>0 gate +-- (def+0x2c8, jg -> tail) fires BEFORE this read -- the native path only +-- works if that check is neutralized (debugger NOP test) or the position +-- is not a land-holder. If a created army heads for the wrong site, the +-- live controller can be retargeted: set site_id/pos_x/pos_y directly +-- (verified live: a retargeted army delivered the holder correctly). +local function cmd_recall(opts) + local entity = civ() + local rows = site_land_holder_assignments(entity, true) + if #rows == 0 then + print('no land-holder assignment tied to this site') + return + end + for _, row in ipairs(rows) do + local a, def = row.assignment, row.def + local hf = hf_of(a.histfig) + if not hf or holder_is_dead(hf) then + print(('assignment %d (%s): no living holder to recall'): + format(a.id, def.code)) + elseif holder_off_site(hf) == false then + print(('assignment %d (%s): holder already at this site'): + format(a.id, def.code)) + elseif plotinfo.site_id == a.st_id then + print(('assignment %d (%s): st_id already points here; ' + .. 'sweep should act next tick'):format(a.id, def.code)) + else + local old = a.st_id + a.st_id = plotinfo.site_id + print(('assignment %d (%s): st_id %d -> %d; ' + .. 'native sweep should create a MOVE_TO_SITE army'): + format(a.id, def.code, old, a.st_id)) + end + end +end + +-- a recalled holder's MOVE_TO_SITE army delivers them as a visitor: +-- materialized at the fort but still flagged guest and uninstalled. +-- unitst::make_own (via dfhack.units.makeown) is the native routine the +-- petition-for-citizenship flow uses -- it grants the MEMBER link to the +-- fort's site government, updates the entity's member lists, switches +-- whereabouts to settler, and clears guest/visitor flags. Verified live: +-- after makeown the holder appears on the nobles screen and citizens +-- list. st_id is then reset to -1 (the settled-state convention). +local function cmd_settle(opts) + local entity = civ() + local rows = site_land_holder_assignments(entity, true) + if #rows == 0 then + print('no land-holder assignment tied to this site') + return + end + for _, row in ipairs(rows) do + local a, def = row.assignment, row.def + local hf = hf_of(a.histfig) + local wb = hf and hf.info and hf.info.whereabouts + local nem = hf and nemesis_of_hfid(hf.id) + local unit = nem and nem.unit + if not hf then + print(('assignment %d (%s): no holder to settle'): + format(a.id, def.code)) + elseif not wb or wb.site_id ~= plotinfo.site_id then + print(('assignment %d (%s): holder not at this site (site %d)'): + format(a.id, def.code, wb and wb.site_id or -1)) + elseif not unit then + print(('assignment %d (%s): holder unit not materialized yet'): + format(a.id, def.code)) + elseif not (unit.flags3.guest or unit.flags2.visitor + or unit.flags2.visitor_uninvited + or wb.state ~= df.whereabouts_type.settler) + then + print(('assignment %d (%s): holder already settled'): + format(a.id, def.code)) + else + -- unitst::make_own is history-silent: emit the link/state + -- events a natural settle produces so legends stay complete + local gov = df.historical_entity.find(plotinfo.group_id) + local had_member = false + for _, l in ipairs(hf.entity_links) do + if l:getType() == df.histfig_entity_link_type.MEMBER + and l.entity_id == plotinfo.group_id then + had_member = true break + end + end + dfhack.units.makeown(unit) + local has_member = false + for _, l in ipairs(hf.entity_links) do + if l:getType() == df.histfig_entity_link_type.MEMBER + and l.entity_id == plotinfo.group_id then + has_member = true break + end + end + if gov and not had_member and has_member then + new_add_member_event(gov, hf) + end + new_change_state_event(hf, df.whereabouts_type.settler, + plotinfo.site_id, df.history_event_reason.become_citizen) + if a.st_id == plotinfo.site_id then a.st_id = -1 end + local who = dfhack.df2utf( + dfhack.translation.translateName(hf.name, true)) + local what = def.name[0] ~= '' and def.name[0] + or def.code:lower() + dfhack.gui.showAnnouncement( + ('%s has arrived to take the position of %s.'): + format(who, what), COLOR_CYAN, true) + print(('assignment %d (%s): settled holder hf %d at site %d ' + .. '(st_id now %d)'): + format(a.id, def.code, hf.id, plotinfo.site_id, a.st_id)) + end + end +end + +-- Emulated MOVE_TO_SITE army creation, replicating the tail of the +-- civilized-actor sweep (FUN_14147d730) for a single nemesis -- the +-- patch-free alternative to bypassing the land_holder gate. Per the +-- decompile the sweep: allocates an army_controller, fills the +-- goal_move_to_site data, allocates an army, add_nemesis, places it at +-- the nemesis's smm position. Uncertain fields (tracking/sneak/odor +-- ratings, path vectors, travel_rate) are left at benign defaults; the +-- native army-advance machinery drives the march itself. +local function cmd_fetch(opts) + local entity = civ() + local rows = site_land_holder_assignments(entity, true) + if #rows == 0 then + print('no land-holder assignment tied to this site') + return + end + local site = df.world_site.find(plotinfo.site_id) + if not site then qerr('cannot find our site %d', plotinfo.site_id) end + -- smm coords = site rect (embark tiles) * 3, verified live + local dst_x = math.floor((site.global_min_x + site.global_max_x) / 2) * 3 + local dst_y = math.floor((site.global_min_y + site.global_max_y) / 2) * 3 + for _, row in ipairs(rows) do + local a, def = row.assignment, row.def + local hf = hf_of(a.histfig) + local wb = hf and hf.info and hf.info.whereabouts + local nem = hf and nemesis_of_hfid(hf.id) + if not hf or holder_is_dead(hf) then + print(('assignment %d (%s): no living holder to fetch'): + format(a.id, def.code)) + elseif not wb then + print(('assignment %d (%s): holder has no whereabouts'): + format(a.id, def.code)) + elseif wb.site_id == plotinfo.site_id then + print(('assignment %d (%s): holder already at this site'): + format(a.id, def.code)) + elseif wb.state == df.whereabouts_type.army_died + or wb.state == df.whereabouts_type.army_survived then + print(('assignment %d (%s): holder already traveling ' + .. '(army %d)'):format(a.id, def.code, wb.army_id)) + elseif not nem then + print(('assignment %d (%s): no nemesis record'): + format(a.id, def.code)) + else + -- mirror the native landmass gate: armies cannot pathfind + -- across water, so a cross-landmass fetch would leave the + -- holder stranded in-army forever (native blocks this too) + local lm_hf, lm_dst = hf_landmass(wb), site_landmass(site) + if lm_hf ~= nil and lm_dst ~= nil and lm_hf ~= lm_dst + and not opts.force + then + print(('assignment %d (%s): holder on landmass %s, ' + .. 'fort on %s -- armies cannot cross water; ' + .. 'use --force to try anyway or install locally'): + format(a.id, def.code, + tostring(lm_hf), tostring(lm_dst))) + goto continue + end + -- start at his smm pos; if abstract (-1), start at his site + local src_x, src_y = wb.abs_smm_x, wb.abs_smm_y + if (src_x == -1 or src_y == -1) and wb.site_id ~= -1 then + local s = df.world_site.find(wb.site_id) + if s then + src_x = math.floor( + (s.global_min_x + s.global_max_x) / 2) * 3 + src_y = math.floor( + (s.global_min_y + s.global_max_y) / 2) * 3 + end + end + if src_x == -1 or src_y == -1 then + print(('assignment %d (%s): cannot place army ' + .. '(no smm position)'):format(a.id, def.code)) + goto continue + end + + local c = df.army_controller:new() + c.id = df.global.army_controller_next_id + df.global.army_controller_next_id = c.id + 1 + c.entity_id = -1 + c.site_id = plotinfo.site_id + c.subregion_id = -1 + c.pos_x = dst_x + c.pos_y = dst_y + c.year = df.global.cur_year + c.year_tick = df.global.cur_season_tick + c.parent_id = -1 + c.master_id = c.id + c.master_hf = -1 + c.commander_hf = -1 + c.origin_task_holder_nemesis_id = -1 + c.origin_task_id = -1 + c.origin_plot_holder_nemesis_id = -1 + c.origin_plot_id = -1 + local g = df.army_controller_goal_move_to_sitest:new() + g.goal_hfid = hf.id + g.goal_srbid = -1 + g.flag.RETURNING_TO_CURRENT_HOME = true + g.goal_ent_id = entity.id + g.goal_epp_id = a.id + g.goal_ab_id = -1 + g.reason = df.history_event_reason.none + c.data.goal_move_to_site = g + c.goal = df.army_controller_goal_type.MOVE_TO_SITE + world.army_controllers.all:insert('#', c) + + local army = df.army:new() + army.id = df.global.army_next_id + df.global.army_next_id = army.id + 1 + army.pos:assign{x = src_x, y = src_y, z = 0} + army.last_pos:assign{x = src_x, y = src_y, z = 0} + army.controller_id = c.id + army.controller = c + army.travel_rate = 16 + army.item_type = df.item_type.NONE + army.item_subtype = -1 + army.mat_type = -1 + army.mat_index = -1 + army.current_army_tracking_info_id = -1 + local m = df.army_nemesisst:new() + m.nemesis_id = nem.id + m.section_master_acid = -1 + m.section_index = -1 + army.members:insert('#', m) + world.armies.all:insert('#', army) + + wb.state = df.whereabouts_type.army_died -- IN_ARMY + wb.site_id = -1 + wb.army_id = army.id + wb.abs_smm_x = -1 + wb.abs_smm_y = -1 + wb.subregion_id = -1 + wb.feature_layer_id = -1 + new_change_state_event(hf, df.whereabouts_type.army_died, + -1, df.history_event_reason.none) + + -- pre-instate: grant the MEMBER link to the fort's site gov + -- and insert the hf into its member lists NOW. Verified + -- live: the arrival-ingress machinery checks the hf's links + -- at materialization time, so a holder with the link already + -- written arrives as a citizen+officeholder with the native + -- 'has returned' announcement, instead of a guest. + local gov = df.historical_entity.find(plotinfo.group_id) + if gov then + local has_member = false + for _, l in ipairs(hf.entity_links) do + if l:getType() == df.histfig_entity_link_type.MEMBER + and l.entity_id == gov.id then + has_member = true break + end + end + if not has_member then + hf.entity_links:insert('#', { + new = df.histfig_entity_link_memberst, + entity_id = gov.id, + link_strength = 100, + }) + utils.insert_sorted(gov.histfig_ids, hf.id) + utils.insert_sorted(gov.hist_figures, hf, 'id') + if hf.nemesis_id >= 0 then + utils.insert_sorted(gov.nemesis_ids, hf.nemesis_id) + end + new_add_member_event(gov, hf) + end + end + + print(('assignment %d (%s): created army %d controller %d ' + .. 'goal=MOVE_TO_SITE site %d, marching from (%d,%d) ' + .. 'to (%d,%d); holder pre-instated as member'): + format(a.id, def.code, army.id, c.id, plotinfo.site_id, + src_x, src_y, dst_x, dst_y)) + end + ::continue:: + end +end + +-- pick the land-holder assignment for this site's current rank: +-- prefer one that already exists (even dead-holder), then the def the +-- site link points at, then the lowest unfilled land-holder rank +local function choose_install_target(entity, opts) + local rows, linked_id = site_land_holder_assignments(entity, true) + if opts.position then + local def = def_for(entity, opts.position) + if not def then qerr('no position %s on this civ', opts.position) end + if def.land_holder <= 0 then + qerr('%s is not a land-holder position', opts.position) + end + for _, row in ipairs(rows) do + if row.def == def then return row.def, row.assignment end + end + return def, nil + end + -- the assignment our site link names is THE one for this fort; + -- otherwise the highest-tier site-tied assignment wins + if linked_id ~= -1 then + local a = find_assignment(entity, linked_id) + if a then return position_def(entity, a.position_id), a end + end + if #rows > 0 then + return rows[#rows].def, rows[#rows].assignment + end + -- never held: first (lowest) land-holder def + local best + for _, def in ipairs(entity.positions.own) do + if def.land_holder > 0 + and (not best or def.land_holder < best.land_holder) + then + best = def + end + end + if not best then qerr('this civ has no land-holder positions') end + return best, nil +end + +local function get_target_unit(opts) + if opts.unit then + return df.unit.find(opts.unit) + or qerr('cannot find unit %d', opts.unit) + end + return dfhack.gui.getSelectedUnit() + or qerr('no unit selected (use --unit ID)') +end + +local function ensure_assignment(entity, def) + -- reuse a vacant assignment for this def tied to our site if present + for _, a in ipairs(entity.positions.assignments) do + if a.position_id == def.id and a.histfig == -1 + and (a.st_id == plotinfo.site_id or a.st_id == -1) + then + return a, false + end + end + local a = df.entity_position_assignment:new() + a.id = entity.positions.next_assignment_id + entity.positions.next_assignment_id = entity.positions.next_assignment_id + 1 + a.position_id = def.id + a.st_id = -1 -- st_id is the recall-destination field; settled = -1 + a.flags.active = true + entity.positions.assignments:insert('#', a) + return a, true +end + +local function cmd_install(opts) + local entity = civ() + local unit = get_target_unit(opts) + local nemesis = dfhack.units.getNemesis(unit) + if not nemesis or not nemesis.figure then + qerr('selected unit has no historical figure/nemesis') + end + local hf = nemesis.figure + + local def, assignment = choose_install_target(entity, opts) + + -- clear any existing holder of the chosen assignment + if assignment and assignment.histfig ~= -1 then + local old = hf_of(assignment.histfig) + if old then + strip_position_link(entity, old, def, assignment) + else + assignment_cleanup(entity, def, assignment) + end + end + assignment = assignment or ensure_assignment(entity, def) + if assignment.histfig ~= -1 then + assignment_cleanup(entity, def, assignment) + end + assignment.st_id = -1 -- holder is already local; no recall needed + assignment.flags.active = true + + -- strip the candidate's existing position links on this civ, as + -- assign_post_wg_entity_position_profile does before granting + for i = #hf.entity_links - 1, 0, -1 do + local link = hf.entity_links[i] + if df.histfig_entity_link_positionst:is_instance(link) + and link.entity_id == entity.id + then + local old_a = find_assignment(entity, link.assignment_id) + local old_def = old_a and position_def(entity, old_a.position_id) + if old_a and old_def then + strip_position_link(entity, hf, old_def, old_a) + else + hf.entity_links:erase(i) + link:delete() + end + end + end + + -- grant: mirrors history_figurest::add_entity_position_link + hf.entity_links:insert('#', { + new = df.histfig_entity_link_positionst, + entity_id = entity.id, + assignment_id = assignment.id, + link_strength = 100, + start_year = df.global.cur_year, + }) + assignment.histfig = hf.id + assignment.histfig2 = hf.id + add_to_responsibility_caches(entity, def, assignment) + new_add_link_event(entity, hf, assignment) + + -- update the civ's site link to our fort, as ESTABLISH_LAND_HOLDER does + local link = site_link_to(entity, plotinfo.site_id) + if link then + link.position_profile_id = assignment.id + link.flags.land_for_holding = true + link.flags.land_holder_residence = true + end + + -- let the next entity tick refresh position activity natively + entity.flags.evaluate_position_profile_activity = true + world.flags.an_entity_has_evaluate_position_profile_activity = true + + local who = dfhack.df2utf(dfhack.translation.translateName(hf.name, true)) + local what = def.name[0] ~= '' and def.name[0] or def.code:lower() + dfhack.gui.showAnnouncement( + ('%s has been appointed to the position of %s.'):format(who, what), + COLOR_CYAN, true) + print(('installed %s as %s (assignment %d on civ %d)'): + format(who, what, assignment.id, entity.id)) +end + +local function main(args) + local opts = {} + local positionals = argparse.processArgsGetopt(args, { + {'h', 'help', handler = function() opts.help = true end}, + {nil, 'living', handler = function() opts.living = true end}, + {nil, 'force', handler = function() opts.force = true end}, + {nil, 'unit', hasArg = true, + handler = function(v) opts.unit = tonumber(v) end}, + {nil, 'position', hasArg = true, + handler = function(v) opts.position = v end}, + }) + if opts.help or #positionals ~= 1 then + print(dfhack.script_help()) + return + end + if df.global.gamemode ~= df.game_mode.DWARF then + qerr('only available in fortress mode') + end + local cmd = positionals[1] + if cmd == 'status' then cmd_status() + elseif cmd == 'army' then cmd_army() + elseif cmd == 'vacate' then cmd_vacate(opts) + elseif cmd == 'claim' then cmd_claim(opts) + elseif cmd == 'recall' then cmd_recall(opts) + elseif cmd == 'settle' then cmd_settle(opts) + elseif cmd == 'fetch' then cmd_fetch(opts) + elseif cmd == 'trace' then cmd_trace() + elseif cmd == 'nemvecs' then cmd_nemvecs() + elseif cmd == 'install' then cmd_install(opts) + else qerr('unknown command %s', cmd) end +end + +if not dfhack_flags.module then + main({...}) +end diff --git a/docs/dead-noble.rst b/docs/dead-noble.rst new file mode 100644 index 000000000..052a8644b --- /dev/null +++ b/docs/dead-noble.rst @@ -0,0 +1,143 @@ +dead-noble +========== + +.. dfhack-tool:: + :summary: Repair or replace a fortress's dead/missing land holder. + :tags: fort armok + +When a fortress that has been granted a barony, county, or duchy loses its +baron, count, or duke, the game can fail to ever assign a replacement. +This tool diagnoses and repairs the land-holder assignment the parent +civilization keeps for the fort's site, letting normal game mechanics +resume -- or directly installing a chosen citizen. + +Usage +----- + +``dead-noble status`` + Show every land-holder assignment linked to this site: the current and + previous holders (alive/dead, whereabouts), the civilization's + site-link pointer, and which vacancy caches the assignment occupies. + Run this first to see exactly where the pipeline stalled. + +``dead-noble vacate [--living]`` + Perform the same cleanup the native death path performs for each dead + (or, with ``--living``, living but off-site) holder of a land-holder + assignment tied to this site: the holder's position link is retired to + a former-position link, a ``remove_hf_entity_link`` history event is + recorded, ``histfig`` is cleared while ``histfig2`` is kept (the game + uses it to find the rightful heir), and the assignment is re-queued + for evaluation. On the next entity-advance tick the game itself will + run succession, appointment, or claim -- normal vanilla behavior. + +``dead-noble claim [--living]`` + Like ``vacate``, but pushes the vacant assignment directly onto the + civilization's ``possible_claimable`` cache so a claimant is spawned + and travels to the fort (the same-landmass limitation from vanilla + still applies). + +``dead-noble army`` + Diagnose the native successor-travel mechanism for each site-tied + assignment: the holder's nemesis record, ``whereabouts`` state, + blocking links, ``st_id``/``ab_id``/``squad_id`` fields, any + army membership, the destination the native sweep would resolve + (the assignment's ``st_id`` site, else the civ capital/residence), + and a worldwide histogram of army-controller goals. + +``dead-noble recall`` + Point the site-tied assignment's ``st_id`` recall-destination field + at this fort (site-id semantics). The vanilla civilized-actor + sweep resolves it with ``get_site`` and creates a real + ``MOVE_TO_SITE`` army that walks the holder here. Note: the + sweep's ``land_holder > 0`` gate skips baron/count/duke positions + before the destination is ever read -- recall only works for them + if that native check is bypassed (see Notes). A created army can + be redirected in flight by rewriting its controller's + ``site_id``/``pos_x``/``pos_y``. + +``dead-noble install [--unit ID] [--position CODE]`` + Install a citizen (the selected unit, or ``--unit ID``) as the holder + of the fort's land-holder position, replicating the diplomat's + ESTABLISH_LAND_HOLDER path: a vacant assignment is reused or created, + holder links and a ``add_hf_entity_link`` history event are written, + responsibility caches and the civilization's site link are updated, + and native position-activity evaluation is re-triggered. + ``--position`` selects a specific position code (e.g. ``BARON``) when + the site has more than one land-holder assignment. + +``dead-noble fetch`` + Patch-free alternative to ``recall`` for land-holder positions: the + native sweep's ``land_holder > 0`` gate skips barons/counts/dukes no + matter what ``st_id`` says, so instead of patching the binary this + command emulates the sweep's army-creation tail directly -- it + allocates an ``army_controller`` (``MOVE_TO_SITE`` to the fort) and + an ``army`` at the holder's position, adds their nemesis record, and + switches the whereabouts state to in-army. The game's normal + army-advance machinery then marches them to the fort exactly like a + native recall. + +``dead-noble settle`` + Finish the arrival half of a recall/fetch: a holder delivered by a + ``MOVE_TO_SITE`` army materializes still flagged as a visitor/guest + -- vanilla performs the travel but never the take-office step. + Runs the native citizenship routine (``unitst::make_own``) on the + materialized holder, granting the MEMBER link to the fort's site + government, updating its member lists, switching the whereabouts + record to settler, and clearing guest/visitor flags, then emits the + matching ``add_hf_entity_link``/``change_hf_state`` history events + and resets ``st_id`` to -1. After settling, the holder appears on + the nobles screen and in the citizens list. + +Examples +-------- + +``dead-noble status`` + Find out why no replacement baron ever arrived. + +``dead-noble vacate`` + Let the game find the rightful heir or a new appointee itself. + +``dead-noble install`` + Make the selected citizen the new baron (or count/duke, matching the + fort's current rank) immediately. + +``dead-noble recall`` + Have the native traveler sweep fetch the living off-site holder + itself -- run ``dead-noble army`` afterward to see the created army, + then ``dead-noble settle`` once the holder arrives. + +``dead-noble fetch`` + Same result as ``recall`` without touching native code: the script + builds the ``MOVE_TO_SITE`` army itself. Works for land-holder + positions where the native sweep's gate prevents ``recall`` from + ever firing. + +``dead-noble settle`` + Turn an arrived but still-visiting holder into a proper citizen and + officeholder (nobles screen + citizens list). + +Notes +----- + +- All history is preserved: former holders get ``FORMER_POSITION`` links + and removal events; new holders get position links and + ``add_hf_entity_link`` events, exactly as the native paths do. +- ``vacate`` is the safest option -- every subsequent step (heir search, + appointment, claimant spawn, travel) is performed by the game itself. +- An off-site successor who has already been appointed but never traveled + can be fetched with ``recall`` (native travel), or cleared with + ``vacate --living``, after which ``claim`` or ``install`` will fill the + slot. +- ``recall`` for land-holder positions is limited by a native gate: the + successor-travel sweep skips any position whose definition has + ``land_holder > 0`` *before* reading the destination, so a baron, + count, or duke never gets a native travel army no matter what + ``st_id`` says. The ``st_id`` write is still correct and consumed + by the same native path -- it just needs the one native check + bypassed for the target only. A production fix should scope the + bypass (call the creation path for the single intended nemesis, or + patch for a single sweep window); a global patch makes every + stranded land-holder in the world travel at once. +- ``st_id`` is the recall-destination field, not the site binding: + every settled land-holder has ``st_id = -1``, and the site binding + lives on the civilization's site link (``position_profile_id``). From fedac22653e1d40c05b53e7150b6caaa9789aa7f Mon Sep 17 00:00:00 2001 From: Kelly Kinkade Date: Thu, 12 Nov 2026 15:41:58 -0600 Subject: [PATCH 2/2] address review comments --- dead-noble.lua | 129 ++++++++++++------------------------------------- 1 file changed, 31 insertions(+), 98 deletions(-) diff --git a/dead-noble.lua b/dead-noble.lua index d6584418b..0f5ed61b7 100644 --- a/dead-noble.lua +++ b/dead-noble.lua @@ -6,82 +6,7 @@ dead-noble Diagnose and fix a fortress that has lost its baron, count, or duke and can no longer receive a replacement. -A land-holder assignment lives on the parent civilization -(``plotinfo.civ_id``), and the civilization's site link to the fort's site -points at it via ``position_profile_id``. When the holder dies, the game -is supposed to clear ``assignment.histfig`` and re-classify the vacancy so -that the normal succession/appointment/claimant pipeline can fill it. -When that fails, the slot stays occupied by a dead historical figure and -the fortress is never eligible for another land holder. - -Usage: - -``dead-noble status`` - Dump the state of every land-holder assignment linked to this site: - assignment/def ids, current and last holder (alive/dead, whereabouts), - site-link pointers, and the vacancy caches the assignment sits in. - -``dead-noble army`` - For the site's land-holder(s): resolve their nemesis record, check the - civilized-actor sweep list, and report any army/army-controller moving - them (the native succession-traveler mechanism) plus the destination - the native logic would pick. - -``dead-noble vacate [--living]`` - For each land-holder assignment tied to this site whose holder is dead - (or absent; ``--living`` also strips a living off-site holder), perform - the same cleanup the native death path performs: remove the holder's - POSITION entity link, record a FORMER_POSITION link and a - ``remove_hf_entity_link`` history event, clear ``histfig`` while - keeping ``histfig2`` (last holder, used for heir searches), pull the - assignment out of the responsibility caches, and re-queue it for - evaluation. On the next entity-advance tick the game itself will run - succession (rightful heir), then appointment, then a spawned claimant - -- i.e. vanilla behaviour resumes. - -``dead-noble claim`` - Like ``vacate``, but additionally force the claimant path: the - assignment is pushed onto ``possible_claimable`` so the native - position-claim machinery spawns a claimant who travels to the fort - (same-landmass restriction applies, as in vanilla). - -``dead-noble install`` - Directly install the selected (or ``--unit``) citizen as the holder of - the fort's current land-holder position, replicating the diplomat - ESTABLISH_LAND_HOLDER path: the assignment is created or reused, - ``histfig``/``histfig2`` are set, a POSITION link plus - ``add_hf_entity_link`` history event are recorded, the responsibility - caches and the civilization's site link are updated, and the native - position-activity evaluation is re-triggered. - -``dead-noble install --position BARON`` - Install into a specific land-holder position code instead of the one - currently associated with the site. - -``dead-noble settle`` - Finish the arrival half of a recall: a holder brought home by a - MOVE_TO_SITE army materializes at the fort still flagged as a - visitor/guest (the native sweep delivers the body but never performs - the take-office step). Runs the native ``unitst::make_own`` routine - on the materialized holder -- the same code path as a petition for - citizenship -- which grants the MEMBER link to the fort's site - government, updates the entity's member lists, switches the - whereabouts record to settler, and clears guest/visitor flags. Also - resets ``st_id`` to -1, matching settled land-holders. - -``dead-noble fetch`` - Patch-free alternative to ``recall`` for land-holder positions: the - native sweep's ``land_holder > 0`` gate skips barons/counts/dukes no - matter what ``st_id`` says, so instead of patching the binary this - command emulates the sweep's army-creation tail directly -- allocates - an ``army_controller`` (``MOVE_TO_SITE``, destination = fort) and an - ``army`` at the holder's position, adds their nemesis, and switches - their whereabouts to in-army. It also pre-instates the holder: - grants the MEMBER link to the fort's site government and updates its - member lists immediately, so that when the army arrives the native - ingress machinery delivers them as a citizen and officeholder (the - 'has returned' announcement) rather than a guest. The native - army-advance code does the march; no ``settle`` step is needed. +See dead-noble.rst for full documentation. ]====] @@ -234,7 +159,7 @@ local function trace_sweep(nem, hf) print((' [%s] %s'):format(ok and 'PASS' or 'FAIL', name)) return ok end - if not gate(rd_i32(hf, 0x30) == -1, + if not gate(hf.died_year == -1, ('figure+0x30 (died_year?) == -1 [=%d]'):format(rd_i32(hf, 0x30))) then return end if not gate(nem.group_leader_id == -1, 'group_leader_id == -1') then return end @@ -289,8 +214,8 @@ local function trace_sweep(nem, hf) ent and ent.id or 'nil', best_prec)) then return end local wb = hf.info and hf.info.whereabouts if not gate(wb ~= nil, 'whereabouts present') then return end - if not gate(wb.state ~= 3 and wb.state ~= 4, - ('whereabouts.state %d not in {3,4}'):format(wb.state)) + if not gate(wb.state ~= whereabouts_type.army_died and wb.state ~= whereabouts_type.army_survived, + ('whereabouts.state %d not in {army_died,army_survived}'):format(wb.state)) then return end if not gate(wb.abs_smm_x ~= -1, 'abs_smm_x set') then return end -- debugger-verified: land_holder>0 on the LAST resolved def skips @@ -306,7 +231,7 @@ local function trace_sweep(nem, hf) dest = df.world_site.find(last_asg.st_id) print((' [dest] st_id path (asg %d st_id=%d) -> site %s'): format(last_asg.id, last_asg.st_id, dest and dest.id or 'nil')) - elseif tostring(ent.type):find('CIVILIZATION') then + elseif ent.type == df.historical_entity_type.Civilization then for _, l in ipairs(ent.site_links) do if l.flags.capital then dest = df.world_site.find(l.target) end end @@ -329,12 +254,17 @@ local function trace_sweep(nem, hf) gate(lm_hf ~= nil and lm_hf == lm_dest, ('landmass match %s == %s'):format(tostring(lm_hf), tostring(lm_dest))) local _, saddr = df.sizeof(dest) - local g = df.reinterpret_cast('int32_t', saddr + 0x348).value - gate(g == 0, ('site+0x348 == 0 [=%d]'):format(g)) + local g = dest.min_depth + gate(g == 0, ('min_depth == 0 [=%d]'):format(g)) local home = nil for _, l in ipairs(hf.site_links) do local t = l:getType() - if t >= 3 and t <= 7 and l.site ~= -1 then home = l.site break end + if (t == df.histfig_site_link_type.HOME_SITE_ABSTRACT_BUILDING or + t == df.histfig_site_link_type.HOME_SITE_REALIZATION_BUILDING or + t == df.histfig_site_link_type.LAIR or + t == df.histfig_site_link_type.HOME_SITE_REALIZATION_SUL or + t == df.histfig_site_link_type.HOME_SITE_SAVED_CIVZONE) + and l.site ~= -1 then home = l.site break end end if not home then home = wb.site_id end if not gate(home ~= dest.id, @@ -342,13 +272,13 @@ local function trace_sweep(nem, hf) then return end for _, l in ipairs(hf.entity_links) do local t = l:getType() - if (t == 6 or t == 4) and l.link_strength ~= 0 then + if (t == df.histfig_entity_link_type.SLAVE or t == df.histfig_entity_link_type.PRISONER) and l.link_strength ~= 0 then gate(false, ('PRISONER/SLAVE link strength %d'):format(l.link_strength)) return end end for _, l in ipairs(hf.histfig_links) do - if tostring(l._type):find('IMPRISONER') and l.link_strength ~= 0 then + if l._type == df.histfig_hf_link_type.IMPRISONER and l.link_strength ~= 0 then gate(false, 'IMPRISONER link') return end @@ -545,10 +475,9 @@ local function queue_vacancy(entity, assignment) end local function new_remove_link_event(entity, hf, position_id) - df.global.hist_event_next_id = df.global.hist_event_next_id + 1 world.history.events:insert('#', { new = df.history_event_remove_hf_entity_linkst, - id = df.global.hist_event_next_id - 1, + id = df.global.hist_event_next_id, year = df.global.cur_year, seconds = df.global.cur_year_tick, civ = entity.id, @@ -556,13 +485,14 @@ local function new_remove_link_event(entity, hf, position_id) link_type = df.histfig_entity_link_type.POSITION, position_id = position_id, }) + df.global.hist_event_next_id = df.global.hist_event_next_id + 1 + end local function new_add_link_event(entity, hf, assignment) - df.global.hist_event_next_id = df.global.hist_event_next_id + 1 world.history.events:insert('#', { new = df.history_event_add_hf_entity_linkst, - id = df.global.hist_event_next_id - 1, + id = df.global.hist_event_next_id, year = df.global.cur_year, seconds = df.global.cur_year_tick, civ = entity.id, @@ -570,13 +500,13 @@ local function new_add_link_event(entity, hf, assignment) link_type = df.histfig_entity_link_type.POSITION, position_id = assignment.id, -- native stores the assignment id here }) + df.global.hist_event_next_id = df.global.hist_event_next_id + 1 end local function new_add_member_event(entity, hf) - df.global.hist_event_next_id = df.global.hist_event_next_id + 1 world.history.events:insert('#', { new = df.history_event_add_hf_entity_linkst, - id = df.global.hist_event_next_id - 1, + id = df.global.hist_event_next_id, year = df.global.cur_year, seconds = df.global.cur_year_tick, civ = entity.id, @@ -584,11 +514,11 @@ local function new_add_member_event(entity, hf) link_type = df.histfig_entity_link_type.MEMBER, position_id = -1, }) + df.global.hist_event_next_id = df.global.hist_event_next_id + 1 end local function new_change_state_event(hf, state, site_id, reason) local site = site_id ~= -1 and df.world_site.find(site_id) or nil - df.global.hist_event_next_id = df.global.hist_event_next_id + 1 local ev = df.history_event_change_hf_statest:new() ev.id = df.global.hist_event_next_id - 1 ev.year = df.global.cur_year @@ -601,6 +531,7 @@ local function new_change_state_event(hf, state, site_id, reason) ev.layer = -1 if site then ev.region_pos:assign(site.pos) end world.history.events:insert('#', ev) + df.global.hist_event_next_id = df.global.hist_event_next_id + 1 end -- strip `hf`'s POSITION link for `assignment`, mirroring @@ -653,14 +584,13 @@ local function army_desc(a) c and tostring(c.site_id) or '?') end --- native gate at world_site+0x348 == mined_hours (FUN_14147d730 @0x14147e08c): +-- native gate at world_site+0x348 == min_depth (FUN_14147d730 @0x14147e08c): -- must be 0 for the sweep to send a noble there local function probe_site_gate(site_id) local site = df.world_site.find(site_id) if not site then return end - local _, saddr = df.sizeof(site) - local gate = df.reinterpret_cast('int32_t', saddr + 0x348).value - print((' site %d (%s, type %s): mined_hours+0x348 = %d, raw mined_hours=%d%s'): + local gate = site.min_depth + print((' site %d (%s, type %s): min_depth = %d, raw min_depth=%d%s'): format(site_id, dfhack.df2utf(dfhack.translation.translateName(site.name)), tostring(site.type), gate, site.mined_hours, @@ -752,8 +682,11 @@ local function cmd_army() -- HOME_SITE* site links (types 3-7): native treats the first as "home" for _, l in ipairs(hf.site_links) do local t = l:getType() - if t >= df.histfig_site_link_type.HOME_SITE_ABSTRACT_BUILDING - and t <= df.histfig_site_link_type.HOME_SITE_SAVED_CIVZONE + if (t == df.histfig_site_link_type.HOME_SITE_ABSTRACT_BUILDING or + t == df.histfig_site_link_type.HOME_SITE_REALIZATION_BUILDING or + t == df.histfig_site_link_type.LAIR or + t == df.histfig_site_link_type.HOME_SITE_REALIZATION_SUL or + t == df.histfig_site_link_type.HOME_SITE_SAVED_CIVZONE) then print((' home-site link: %s -> site %d'): format(tostring(l._type), l.site))