Skip to main content

rpfm_extensions/startpos/
settlements.rs

1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Settlement layouts of a startpos (`REGION/SETTLEMENT/SETTLEMENT_EXPANSION_MANAGER/SETTLEMENT_EXPANSION_MAP_DATA`),
12//! built from the campaign map's `map_data.esf` (`REGION_DATA/SETTLEMENT_INFO`).
13//!
14//! Verified against Attila's main campaign (186 settlements, 983 slots): slot ids, kinds, positions, footprints,
15//! zones, walls and touching slots match the game-built startpos, and so do the sprawl cells and their
16//! footprint/zone flags. The edge pieces of the sprawl cells need the cells' regions, so the world writer computes
17//! them.
18
19use getset::{CopyGetters, Getters};
20use serde_derive::{Deserialize, Serialize};
21
22use std::cmp::Reverse;
23use std::collections::HashSet;
24
25use rpfm_lib::error::{RLibError, Result};
26use rpfm_lib::files::esf::{ESF, NodeType, RecordNode};
27
28use super::coords::{HexCell, HEX_EDGES};
29use super::esf_nav::{as_record, as_str, as_u32, cell_groups, child_record, child_records, leaves};
30
31#[cfg(test)] #[path = "settlements_test.rs"] mod settlements_test;
32
33/// Directions from a wall walk step to the first direction it scans next: the one after the step's reverse.
34const WALL_SCAN_OFFSET: u8 = 4;
35
36//---------------------------------------------------------------------------//
37//                              Types
38//---------------------------------------------------------------------------//
39
40/// Layout of a settlement: its building slots and its sprawl cells.
41#[derive(Debug, Clone, PartialEq, Default, Getters, Serialize, Deserialize)]
42#[getset(get = "pub")]
43pub struct SettlementLayout {
44
45    /// Region key.
46    region: String,
47
48    /// Slots, in startpos order: main settlement, port (or placeholder), then the secondary slots.
49    slots: Vec<SlotLayout>,
50
51    /// Cells the settlement can sprawl over.
52    sprawl: Vec<SprawlCell>,
53}
54
55/// Whether a slot is one of the two primary slots or a secondary one.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
57pub enum SlotKind {
58
59    /// Main settlement or port slot (`SLOT_MAP_DATA` directly under the expansion data).
60    #[default]
61    Primary,
62
63    /// Extra slot (`SECONDARY_SLOT_ARRAY`).
64    Secondary,
65}
66
67/// A building slot of a settlement.
68#[derive(Debug, Clone, PartialEq, Eq, Default, CopyGetters, Getters, Serialize, Deserialize)]
69pub struct SlotLayout {
70
71    /// Slot id. The port placeholder of settlements without port uses 0.
72    #[getset(get_copy = "pub")]
73    id: u32,
74
75    /// Primary or secondary slot.
76    #[getset(get_copy = "pub")]
77    kind: SlotKind,
78
79    /// Cell the slot is anchored to. `None` for the port placeholder of settlements without port.
80    #[getset(get_copy = "pub")]
81    position: Option<HexCell>,
82
83    /// Anchor cell and its neighbours, in the game's order.
84    #[getset(get = "pub")]
85    footprint: Vec<HexCell>,
86
87    /// Zone of the slot (`SLOT_ZOE_ARRAY`), in map file order.
88    #[getset(get = "pub")]
89    zone: Vec<HexCell>,
90
91    /// Walk around the zone's outer boundary, for walled settlements (see [`wall_walk`]). Empty otherwise.
92    #[getset(get = "pub")]
93    walls: Vec<HexCell>,
94
95    /// Bit set of the other slots (by position in the settlement's slot list) whose zone shares or touches a cell
96    /// of this slot's zone.
97    #[getset(get_copy = "pub")]
98    touching_slots: u32,
99}
100
101/// A cell of the sprawl area of a settlement.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, CopyGetters, Serialize, Deserialize)]
103#[getset(get_copy = "pub")]
104pub struct SprawlCell {
105
106    /// The cell.
107    cell: HexCell,
108
109    /// The cell is part of a slot footprint.
110    in_footprint: bool,
111
112    /// The cell is part of a slot zone.
113    in_zone: bool,
114
115    /// Bitmask of the slots whose zone contains the cell (`1 << slot index`).
116    zone_slots: u32,
117}
118
119//---------------------------------------------------------------------------//
120//                              Implementations
121//---------------------------------------------------------------------------//
122
123impl SettlementLayout {
124
125    /// Builds the layout of every settlement of a campaign map.
126    ///
127    /// # Arguments
128    ///
129    /// * `map_data` - Decoded `map_data.esf` of the campaign map.
130    /// * `is_walled` - Returns `true` for regions whose settlement has walls (major settlements in Attila).
131    ///
132    /// # Returns
133    ///
134    /// One layout per region with a settlement, in map file order.
135    ///
136    /// # Errors
137    ///
138    /// Fails if the map data doesn't have the expected records.
139    pub fn all_from_map_data(map_data: &ESF, is_walled: impl Fn(&str) -> bool) -> Result<Vec<Self>> {
140        let mut layouts = vec![];
141        for region in region_data_records(map_data)? {
142            let key = region_key(region)?;
143            if let Some(info) = child_record(region, "SETTLEMENT_INFO") {
144                layouts.push(Self::from_settlement_info(key, info, is_walled(key))?);
145            }
146        }
147        Ok(layouts)
148    }
149
150    /// Builds the layout of a settlement from its `SETTLEMENT_INFO` record.
151    ///
152    /// # Arguments
153    ///
154    /// * `region` - Region key.
155    /// * `info` - The region's `SETTLEMENT_INFO` record from `map_data.esf`.
156    /// * `walled` - If the settlement has walls.
157    ///
158    /// # Errors
159    ///
160    /// Fails if the record doesn't have the expected children.
161    pub fn from_settlement_info(region: &str, info: &RecordNode, walled: bool) -> Result<Self> {
162        let map_slots = child_records(info, "SLOT_ARRAY_BLOCK")
163            .flat_map(|block| block.children().iter())
164            .map(|group| map_slot(region, group))
165            .collect::<Result<Vec<_>>>()?;
166
167        let (main_position, main_zone) = map_slots.first().cloned()
168            .ok_or_else(|| map_error(region, "the settlement has no slots"))?;
169
170        let port_position = child_record(info, "PORT_POSITIONS_BLOCK").and_then(|block| cell_groups(block).first().copied());
171        let port_zone = child_record(info, "PORT_AREA_BLOCK").map(cell_groups).unwrap_or_default();
172
173        let mut slots = vec![SlotLayout::new(0, SlotKind::Primary, Some(main_position), main_zone, walled)];
174        slots.push(match port_position {
175            Some(position) => SlotLayout::new(1, SlotKind::Primary, Some(position), port_zone, walled),
176            None => SlotLayout::default(),
177        });
178
179        // Secondary slot ids start at 2 whether the settlement has a port or not.
180        for (index, (position, zone)) in map_slots.into_iter().enumerate().skip(1) {
181            slots.push(SlotLayout::new(index as u32 + 1, SlotKind::Secondary, Some(position), zone, walled));
182        }
183
184        link_touching_slots(&mut slots);
185
186        let sprawl_cells = child_record(info, "LOGICAL_POSITION_BIT_ARRAY")
187            .ok_or_else(|| map_error(region, "missing LOGICAL_POSITION_BIT_ARRAY"))
188            .and_then(|bits| bit_array_cells(region, bits))?;
189
190        Ok(Self {
191            region: region.to_owned(),
192            sprawl: sprawl(&sprawl_cells, &slots),
193            slots,
194        })
195    }
196}
197
198impl SlotLayout {
199
200    /// Creates a slot anchored to a cell, computing its footprint and walls.
201    fn new(id: u32, kind: SlotKind, position: Option<HexCell>, zone: Vec<HexCell>, walled: bool) -> Self {
202        let walls = if walled { wall_walk(&zone) } else { vec![] };
203        Self {
204            id,
205            kind,
206            footprint: position.map(|position| position.footprint()).unwrap_or_default(),
207            position,
208            zone,
209            walls,
210            touching_slots: 0,
211        }
212    }
213}
214
215/// Returns the `REGION_DATA` records of the map, in file order.
216pub(crate) fn region_data_records(map_data: &ESF) -> Result<Vec<&RecordNode>> {
217    let root = as_record(map_data.root_node()).ok_or_else(|| RLibError::CampaignMapDataError("the root node is not a record".to_owned()))?;
218    let block = child_record(root, "REGIONS_DATA")
219        .and_then(|regions_data| child_record(regions_data, "REGIONS_BLOCK"))
220        .ok_or_else(|| RLibError::CampaignMapDataError("missing MAP_DATA/REGIONS_DATA/REGIONS_BLOCK".to_owned()))?;
221    Ok(child_records(block, "REGION_DATA").collect())
222}
223
224/// Returns the key of a `REGION_DATA` record (its first string leaf).
225pub(crate) fn region_key(region: &RecordNode) -> Result<&str> {
226    region.children().first()
227        .and_then(|group| leaves(group).find_map(as_str))
228        .ok_or_else(|| RLibError::CampaignMapDataError("a REGION_DATA record has no key".to_owned()))
229}
230
231/// Reads a map slot (a `SLOT_ARRAY_BLOCK` group): its first candidate position and its zone.
232fn map_slot(region: &str, group: &[NodeType]) -> Result<(HexCell, Vec<HexCell>)> {
233    let record = |name: &str| group.iter().filter_map(as_record).find(|record| record.name() == name);
234    let position = record("SLOT_POSITIONS_BLOCK")
235        .and_then(|positions| cell_groups(positions).first().copied())
236        .ok_or_else(|| map_error(region, "a slot has no position"))?;
237    let zone = record("SLOT_AREA_BLOCK").map(cell_groups).unwrap_or_default();
238    Ok((position, zone))
239}
240
241/// Decodes a `LOGICAL_POSITION_BIT_ARRAY`: bounding box, row width and an LSB-first, row-major bitmap.
242fn bit_array_cells(region: &str, bits: &RecordNode) -> Result<Vec<HexCell>> {
243    let group = bits.children().first().ok_or_else(|| map_error(region, "empty LOGICAL_POSITION_BIT_ARRAY"))?;
244    let values = leaves(group).collect::<Vec<_>>();
245    let number = |index: usize| values.get(index).and_then(|node| as_u32(node));
246    let (x0, y0, width) = match (number(0), number(1), number(4)) {
247        (Some(x0), Some(y0), Some(width)) if width > 0 => (x0, y0, width),
248        _ => return Err(map_error(region, "malformed LOGICAL_POSITION_BIT_ARRAY header")),
249    };
250    let bytes = match values.get(5) {
251        Some(NodeType::U8Array(bytes)) => bytes,
252        _ => return Err(map_error(region, "LOGICAL_POSITION_BIT_ARRAY has no bitmap")),
253    };
254
255    let cells = bytes.iter().enumerate()
256        .flat_map(|(byte_index, byte)| (0..8).filter(move |bit| byte & (1 << bit) != 0).map(move |bit| byte_index as u32 * 8 + bit))
257        .map(|index| HexCell::new((x0 + index % width) as u16, (y0 + index / width) as u16))
258        .collect();
259    Ok(cells)
260}
261
262/// Computes the footprint and zone flags of every sprawl cell.
263fn sprawl(cells: &[HexCell], slots: &[SlotLayout]) -> Vec<SprawlCell> {
264    let footprint = slots.iter().flat_map(|slot| slot.footprint.iter().copied()).collect::<HashSet<_>>();
265    let zones = slots.iter().map(|slot| slot.zone.iter().copied().collect::<HashSet<_>>()).collect::<Vec<_>>();
266    cells.iter().map(|cell| {
267        let zone_slots = zones.iter().enumerate()
268            .filter(|(_, zone)| zone.contains(cell))
269            .fold(0, |mask, (index, _)| mask | (1 << index));
270        SprawlCell {
271            cell: *cell,
272            in_footprint: footprint.contains(cell),
273            in_zone: zone_slots != 0,
274            zone_slots,
275        }
276    }).collect()
277}
278
279/// Fills the touching slots of every slot of a settlement.
280fn link_touching_slots(slots: &mut [SlotLayout]) {
281    let reaches = slots.iter()
282        .map(|slot| slot.zone.iter().flat_map(HexCell::footprint).collect::<HashSet<_>>())
283        .collect::<Vec<_>>();
284    let masks = reaches.iter().enumerate().map(|(index, reach)| {
285        slots.iter().enumerate()
286            .filter(|(other, slot)| *other != index && slot.zone.iter().any(|cell| reach.contains(cell)))
287            .fold(0, |mask, (other, _)| mask | 1u32.checked_shl(other as u32).unwrap_or_default())
288    }).collect::<Vec<_>>();
289
290    for (slot, mask) in slots.iter_mut().zip(masks) {
291        slot.touching_slots = mask;
292    }
293}
294
295/// Returns the walk around the outer boundary of a zone the game stores as a slot's walls.
296///
297/// The walk starts on the zone cell with the highest column (then the lowest row), as if it came from direction 0.
298/// Each step looks for the next zone cell scanning the directions from the one after the reverse of the last step,
299/// and records it. The walk ends when it is about to repeat its first step, so cells on one-cell-wide parts of the
300/// zone appear twice and cells only next to holes don't appear.
301fn wall_walk(zone: &[HexCell]) -> Vec<HexCell> {
302    let cells = zone.iter().copied().collect::<HashSet<_>>();
303    let Some(start) = zone.iter().copied().min_by_key(|cell| (Reverse(cell.x()), cell.y())) else { return vec![] };
304
305    // Every step uses an edge of a zone cell, and a closed walk uses each edge in each direction at most once.
306    let max_steps = zone.len() * usize::from(HEX_EDGES) + 1;
307    let mut walk = Vec::new();
308    let (mut cell, mut incoming, mut first_step) = (start, 0, None);
309    while walk.len() < max_steps {
310        let next = (0..HEX_EDGES)
311            .map(|offset| (incoming + WALL_SCAN_OFFSET + offset) % HEX_EDGES)
312            .find_map(|direction| cell.neighbour(direction).filter(|neighbour| cells.contains(neighbour)).map(|neighbour| (direction, neighbour)));
313        let Some((direction, neighbour)) = next else { return vec![start] };
314        if cell == start && first_step == Some(direction) {
315            break;
316        }
317        first_step.get_or_insert(direction);
318        walk.push(neighbour);
319        (cell, incoming) = (neighbour, direction);
320    }
321    walk
322}
323
324fn map_error(region: &str, message: &str) -> RLibError {
325    RLibError::CampaignMapDataError(format!("region {region}: {message}"))
326}