rakata_generics/git/blocks.rs
1//! Field blocks more than one GIT object type declares.
2//!
3//! Each of these is one declaration reached from several paths, and each was
4//! checked axis by axis against every copy before it became one. Where a copy
5//! diverged on any axis the block was not made: a saved trigger's trap fields
6//! and a saved door's overlap in concept and differ in label set, so they stay
7//! apart. The doc on each type records what was compared.
8
9use rakata_core::ResRef;
10use rakata_formats::gff::upsert_field;
11use rakata_formats::gff_label;
12use rakata_formats::schema::GffScalar;
13use rakata_formats::{GffModel, GffStruct, GffValue};
14
15use crate::shared::PORTRAIT_ID_USE_RESREF;
16
17/// Where a door or placeable sits in the area and which way it faces.
18///
19/// The consumer set is the four door and placeable types, blueprint-side
20/// placements and saved forms alike. Every one of them reads all four labels
21/// with the same zero fallback and writes all four unconditionally, so the
22/// block takes no per-consumer parameter.
23///
24/// Encounter and geometry points carry `X`, `Y` and `Z` identically and are
25/// still not consumers. A spawn point faces through `Orientation` rather than
26/// `Bearing`, so what counts as three of four shared labels is a position
27/// triple beside a differently-named field. Same shape that keeps a saved
28/// trigger's trap fields off a saved door's: shared concept, different label
29/// set. Sharing the triple alone across all eight is a real extraction and a
30/// wider one than this block.
31///
32/// What blocks it is that none of those paths has an audited absent value.
33/// All twenty declarations across the six schema tables are `Unexamined`, so
34/// merging would assert a sameness nobody has measured. Six of them, the
35/// coordinates under `Ute.Geometry` and `Ute.SpawnPointList`, are populated
36/// by no file in either corpus, which is why no further sweep can settle
37/// them and reading the pages is the only thing that can.
38///
39/// That condition covers the four labels this block already shares. Nothing
40/// moved when it was introduced, since all four consumers were already
41/// reading identical zeros, but an audit giving one path a different absent
42/// value now lands here rather than at four separate sites.
43#[derive(Debug, Clone, Copy, PartialEq, GffModel)]
44pub struct Placement {
45 /// X position (`X`).
46 #[gff(X, unexamined)]
47 pub x: f32,
48 /// Y position (`Y`).
49 #[gff(Y, unexamined)]
50 pub y: f32,
51 /// Z position (`Z`).
52 #[gff(Z, unexamined)]
53 pub z: f32,
54 /// Facing angle in radians (`Bearing`).
55 #[gff(Bearing, unexamined)]
56 pub bearing: f32,
57}
58
59/// Saving throws shared by doors and placeables, blueprint and saved alike.
60///
61/// All three are recorded divergences on the absent axis, and all three are
62/// the mechanism-only kind: a door constructs its saving throws and carries
63/// them over where a placeable stamps a literal, and both land on zero. What
64/// differs is how the default arises, not what it is, so the block needs no
65/// per-consumer parameter. See [`LockState`] for the same reading applied to
66/// a group where two of the pairs are weaker than that.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, GffModel)]
68pub struct SavingThrows {
69 /// Fortitude save (`Fort`).
70 #[gff(Fort, unexamined)]
71 pub fortitude: u8,
72 /// Reflex save (`Ref`).
73 #[gff(Ref, unexamined)]
74 pub reflex: u8,
75 /// Will save (`Will`).
76 #[gff(Will, unexamined)]
77 pub will: u8,
78}
79
80/// Lock state shared by doors and placeables, blueprint and saved forms alike.
81///
82/// `SecretDoorDC` is not here. Only the two door types read it, so it stays a
83/// loose door field rather than a placeable's eighth label that never applies.
84///
85/// The block takes no per-consumer defaults, and that is a measured claim
86/// rather than an assumption. Five of the seven are recorded divergences on
87/// the absent axis: a door constructs its lock group and carries it over
88/// where a placeable stamps an unconditional literal. Both sides land on the
89/// same value every time, so what differs is how the default arises, not what
90/// it is, and a shared read fallback is the value all four consumers were
91/// already reading. Contrast the trap fields, which resolve to genuinely
92/// different values per consumer and so were never shared. Same test,
93/// opposite answer.
94///
95/// Two labels are weaker than that. `Lockable` and `KeyName` are `Unexamined`
96/// on the door side against a populated placeable declaration, so those two
97/// pairs are one opinion and one blank rather than a measured agreement. The
98/// block does not assert them: it shares the fallback the door reader already
99/// used, and an audit giving the door path a different value would land here.
100#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
101pub struct LockState {
102 /// Whether the object is locked (`Locked`).
103 #[gff(Locked, unexamined)]
104 pub locked: bool,
105 /// Whether it can be locked at all (`Lockable`).
106 #[gff(Lockable, unexamined)]
107 pub lockable: bool,
108 /// Whether opening needs a key (`KeyRequired`).
109 #[gff(KeyRequired, unexamined)]
110 pub key_required: bool,
111 /// Tag of the key that opens it (`KeyName`).
112 #[gff(KeyName, unexamined)]
113 pub key_name: String,
114 /// Whether the key is consumed on use (`AutoRemoveKey`).
115 #[gff(AutoRemoveKey, unexamined)]
116 pub auto_remove_key: bool,
117 /// Difficulty of picking it open (`OpenLockDC`).
118 #[gff(OpenLockDC, unexamined)]
119 pub open_lock_dc: u8,
120 /// Difficulty of locking it (`CloseLockDC`).
121 #[gff(CloseLockDC, unexamined)]
122 pub close_lock_dc: u8,
123}
124
125/// The two labels a saved placeable or trigger carries its portrait under.
126///
127/// Declared apart from [`SavedPortrait`] because the value is one thing and
128/// the labels are two: an object carries `Portrait` or `PortraitId`, never
129/// both, so no member maps to a label and the codec is the owner's.
130///
131/// Placeables and triggers share this; a door does not. `Portrait` is stamped
132/// on those two and unexamined on the door, which is one measured axis and
133/// enough to part them.
134#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
135#[gff_entry(Portrait, wire = ResRef, stamped)]
136#[gff_entry(PortraitId, wire = u16, stamped = PORTRAIT_ID_USE_RESREF)]
137pub struct SavedPortraitLabels {}
138
139/// The same two labels on a door, whose `Portrait` the audit records as
140/// unexamined rather than stamped.
141#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
142#[gff_entry(Portrait, wire = ResRef, unexamined)]
143#[gff_entry(PortraitId, wire = u16, stamped = PORTRAIT_ID_USE_RESREF)]
144pub struct DoorPortraitLabels {}
145
146/// A saved object's portrait, as whichever of the two labels it carries.
147///
148/// Across every placeable and trigger in the fixture saves the two are
149/// exactly complementary: never both on one object, and never neither.
150/// Modelling them as one value rather than two [`Option`]s means the state
151/// where an object claims both, or claims neither, cannot be built.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub enum SavedPortrait {
154 /// `Portrait`: a resref naming the portrait image.
155 ResRef(ResRef),
156 /// `PortraitId`: a row index into `portraits.2da`.
157 Id(u16),
158}
159
160impl Default for SavedPortrait {
161 /// The `PortraitId` sentinel the engine stamps when the field is absent.
162 ///
163 /// `0xFFFF` fails the engine's `< 0xFFFE` check, so it goes on to the
164 /// `Portrait` resref instead of looking up a row. The sentinel is kept
165 /// rather than resolved to the empty resref it eventually reaches: what
166 /// the engine's member holds is `0xFFFF`, and which branch that selects
167 /// is resolution, which a typed view does not run.
168 fn default() -> Self {
169 Self::Id(PORTRAIT_ID_USE_RESREF)
170 }
171}
172
173impl SavedPortrait {
174 /// Reads whichever of the two labels the object carries.
175 ///
176 /// Prefers `Portrait` when both somehow appear, since a resref names an
177 /// image outright and a row index still has to be looked up.
178 pub(crate) fn read(structure: &GffStruct) -> Self {
179 match (
180 structure
181 .field("Portrait")
182 .and_then(<ResRef as GffScalar>::from_gff_value),
183 structure
184 .field("PortraitId")
185 .and_then(<u16 as GffScalar>::from_gff_value),
186 ) {
187 (Some(resref), _) => Self::ResRef(resref),
188 (None, Some(id)) => Self::Id(id),
189 (None, None) => Self::default(),
190 }
191 }
192
193 /// Writes back the one label this portrait uses, and only that one.
194 pub(crate) fn write(&self, structure: &mut GffStruct) {
195 match self {
196 Self::ResRef(resref) => {
197 upsert_field(structure, gff_label!("Portrait"), GffValue::ResRef(*resref))
198 }
199 Self::Id(id) => {
200 upsert_field(structure, gff_label!("PortraitId"), GffValue::UInt16(*id))
201 }
202 }
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 /// The absent-both state, which the reader-defaults guard cannot reach.
211 ///
212 /// That guard drops one label per probe, so dropping `PortraitId` leaves
213 /// `Portrait` in the file and the read takes the resref branch rather
214 /// than its own fallback. A fallback that only fires when two labels are
215 /// missing together needs a test that omits both.
216 #[test]
217 fn neither_portrait_field_present_gives_the_engine_sentinel() {
218 let portrait = SavedPortrait::read(&GffStruct::new(0));
219
220 assert_eq!(portrait, SavedPortrait::Id(PORTRAIT_ID_USE_RESREF));
221 }
222
223 #[test]
224 fn an_explicit_portrait_id_is_kept() {
225 let mut structure = GffStruct::new(0);
226 upsert_field(
227 &mut structure,
228 gff_label!("PortraitId"),
229 GffValue::UInt16(42),
230 );
231 let portrait = SavedPortrait::read(&structure);
232
233 assert_eq!(portrait, SavedPortrait::Id(42));
234 }
235}