rakata_generics/git/door.rs
1//! Door placements in a GIT.
2
3use rakata_core::ResRef;
4use rakata_formats::{GffLocalizedString, GffModel, GffStruct};
5
6use crate::git::blocks::{DoorPortraitLabels, LockState, Placement, SavedPortrait, SavingThrows};
7use crate::shared::ObjectId;
8/// A door instance placed in the area (struct type 8).
9///
10/// Doors use `Bearing` for orientation and `X`/`Y`/`Z` for position (not
11/// `XPosition`/`YPosition`/`ZPosition`).
12///
13/// Unlike [`GitWaypoint`](super::GitWaypoint), a static door placement genuinely does resolve its
14/// `TemplateResRef` against a `.utd` blueprint, and it carries the same
15/// transition group a trigger does. This type modelled neither for a while,
16/// which meant round-tripping any vanilla module returned doors with no
17/// blueprint to instantiate from and no tag to reference. See
18/// `docs/src/formats/gff/utd.md`'s "Save versus Template Load Paths".
19///
20/// ## `Tag` is dead here, and deliberately unmodelled
21///
22/// A templated door takes its tag from the `.utd` blueprint and the
23/// placement's copy has no overlay to win through, so setting one is a modder
24/// trap rather than a harmless leftover: nothing happens, silently.
25///
26/// `docs/src/architecture.md` uses exactly this field as its worked example of
27/// the projection rule's "at that path is the whole test", and
28/// `docs/src/formats/gff/git.md` covers the consequence, that two placements
29/// sharing a blueprint end up with the same tag -- for every templated object
30/// The part of a `door` entry that is what every door entry carries, whichever form it takes.
31///
32/// Both arms flatten it and the list's element is it, so the labels here
33/// are one declaration reached from both forms rather than two copies.
34#[derive(Debug, Clone, PartialEq, GffModel)]
35pub struct GitDoorCommon {
36 /// Visual model override (`Appearance`).
37 ///
38 /// Read on a sparse placement as well as a saved one: `LoadDoor` and
39 /// `LoadPlaceable` are the same field readers whichever path calls them,
40 /// so a template-referencing placement carrying its own `Appearance` has
41 /// that value read as an override on top of the blueprint's. Read as a
42 /// DWORD and truncated to a byte, which for a door keys `doortypes.2da`'s
43 /// model columns. Defaults to `0` when absent.
44 /// Read by `LoadDoor` and truncated to a byte, then used to key
45 /// `doortypes.2da`. The absent value is the door struct's own
46 /// zero-initialized member rather than a literal, which is where this
47 /// parts from the placeable's otherwise identical field.
48 #[gff(Appearance, live, constructed, omit = audited_constant(117))]
49 pub appearance: u32,
50 /// Destination object tag within that module (`LinkedTo`).
51 #[gff(LinkedTo, not_a_constant)]
52 pub linked_to: String,
53 /// What [`Self::linked_to`] names (`LinkedToFlags`).
54 #[gff(LinkedToFlags, not_a_constant)]
55 pub linked_to_flags: u8,
56 /// Destination module for a transition door (`LinkedToModule`).
57 #[gff(LinkedToModule, not_a_constant)]
58 pub linked_to_module: ResRef,
59 /// Runtime object ID (`ObjectId`). Save-game only.
60 /// Runtime object id (`ObjectId`), which only a saved form carries.
61 ///
62 /// The area-level dispatcher reads it once per element ahead of the
63 /// static-versus-saved branch, so the engine reads it on both forms and
64 /// the liveness below is right. What it does not do is author it: every
65 /// element the engine saves carries one and no element the toolset shipped
66 /// does. Held as the absence so writing a static placement back does not
67 /// invent a runtime id for it.
68 #[gff(ObjectId, stamped = ObjectId::INVALID, optional = ObjectId)]
69 pub object_id: Option<ObjectId>,
70 /// Player-facing name of the destination (`TransitionDestin`).
71 #[gff(TransitionDestin, constructed)]
72 pub transition_destination: GffLocalizedString,
73}
74
75/// type, not only doors. Filed as #46.
76#[derive(Debug, Clone, PartialEq, GffModel)]
77#[gff_manual_element]
78pub struct GitDoor {
79 /// Placement, whose four labels sit at this object's own level.
80 #[gff(flatten = Placement)]
81 pub placement: Placement,
82 /// The block both forms carry, which the list declares as its element.
83 ///
84 /// No `#[gff]`: the arm contributes its parts to the split, and the
85 /// element is this block, so declaring it here too would make one label
86 /// two declarations. The arm's own codec reads and writes it.
87 pub common: GitDoorCommon,
88 /// Template resref (`TemplateResRef`), resolved against a `.utd`.
89 #[gff(TemplateResRef, unexamined)]
90 pub template_resref: ResRef,
91}
92
93// =========================================================================
94// The saved form
95// =========================================================================
96
97// Doors as stored inside a save game.
98//
99// A savegame door carries the whole object inline rather than pointing at a
100// `.utd`, so the fields here are the ones a blueprint would have supplied plus
101// the runtime state it has no place for. See
102// `docs/src/formats/save/index.md` for the template-versus-snapshot rule, and
103// its object position table for why doors spell position `X`/`Y`/`Z` with a
104// `Bearing` where creatures and triggers use `XPosition` and a three-component
105// orientation. That page lists all four naming styles; each saved type carries
106// its own rather than sharing a position block that would need telling which
107// dialect to write.
108//
109// ## What is not modelled
110//
111// `ActionList`, `EffectList`, `VarTable` and `SWVarTable` are live runtime
112// state whose layouts are only partly audited, and they are skipped for the
113// same reason [`SavedCreature`](crate::git::creature::SavedCreature) skips
114// them: a typed view models what it enumerates and drops the rest, and
115// byte-exact preservation stays with the raw
116// [`Gff`](rakata_formats::Gff) tree.
117
118/// A door as stored inside a save game's module `GIT`.
119#[derive(Debug, Clone, PartialEq, GffModel)]
120#[gff_manual_element]
121pub struct SavedDoor {
122 /// Placement, whose four labels sit at this object's own level.
123 #[gff(flatten = Placement)]
124 pub placement: Placement,
125 /// Saving throws, likewise flat on the object.
126 #[gff(flatten = SavingThrows)]
127 pub saves: SavingThrows,
128 /// Lock state, likewise flat on the object.
129 #[gff(flatten = LockState)]
130 pub lock: LockState,
131 /// Portrait, as whichever of the two labels the object carries.
132 ///
133 /// Declared per owner rather than shared: the audit parts `Portrait` and
134 /// `PortraitId` across doors, placeables and triggers on the absent axis.
135 /// Both halves are this type's, because one member stands for two labels
136 /// and which one is written depends on the variant.
137 #[gff(flatten = DoorPortraitLabels, manual_read, manual_write)]
138 pub portrait: SavedPortrait,
139
140 // Trap settings, per owner: every label parts from the other
141 // owners on the absent axis, so no block spans them.
142 /// Whether the trap can be spotted (`TrapDetectable`).
143 #[gff(TrapDetectable, constructed = true)]
144 pub trap_detectable: bool,
145 /// Whether the trap can be disarmed (`TrapDisarmable`).
146 #[gff(TrapDisarmable, constructed = true)]
147 pub trap_disarmable: bool,
148 /// Whether the trap fires once and clears (`TrapOneShot`).
149 #[gff(TrapOneShot, constructed = true)]
150 pub trap_one_shot: bool,
151 /// Trap row in `traps.2da` (`TrapType`).
152 #[gff(TrapType, constructed = 255)]
153 pub trap_type: u8,
154 /// Difficulty of spotting the trap (`TrapDetectDC`).
155 #[gff(TrapDetectDC, constructed)]
156 pub trap_detect_dc: u8,
157 /// Difficulty of disarming it (`DisarmDC`).
158 #[gff(DisarmDC, constructed)]
159 pub trap_disarm_dc: u8,
160 /// Whether a trap is present at all (`TrapFlag`).
161 #[gff(TrapFlag, constructed)]
162 pub trap_flag: u8,
163 // Trap and lock scripts, per owner for the same reason.
164 /// Fired when the object closes (`OnClosed`).
165 #[gff(OnClosed, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
166 pub on_closed: ResRef,
167 /// Fired when it takes damage (`OnDamaged`).
168 #[gff(OnDamaged, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
169 pub on_damaged: ResRef,
170 /// Fired when it is destroyed (`OnDeath`).
171 #[gff(OnDeath, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
172 pub on_death: ResRef,
173 /// Fired when its trap is disarmed (`OnDisarm`).
174 #[gff(OnDisarm, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
175 pub on_disarm: ResRef,
176 /// Fired on the object heartbeat (`OnHeartbeat`).
177 #[gff(OnHeartbeat, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
178 pub on_heartbeat: ResRef,
179 /// Fired when it is locked (`OnLock`).
180 #[gff(OnLock, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
181 pub on_lock: ResRef,
182 /// Fired when it is struck in melee (`OnMeleeAttacked`).
183 #[gff(OnMeleeAttacked, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
184 pub on_melee_attacked: ResRef,
185 /// Fired when it opens (`OnOpen`).
186 #[gff(OnOpen, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
187 pub on_open: ResRef,
188 /// Fired when a spell targets it (`OnSpellCastAt`).
189 #[gff(OnSpellCastAt, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
190 pub on_spell_cast_at: ResRef,
191 /// Fired when its trap goes off (`OnTrapTriggered`).
192 #[gff(OnTrapTriggered, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
193 pub on_trap_triggered: ResRef,
194 /// Fired when it is unlocked (`OnUnlock`).
195 #[gff(OnUnlock, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
196 pub on_unlock: ResRef,
197 /// Fired on a user-defined event (`OnUserDefined`).
198 #[gff(OnUserDefined, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
199 pub on_user_defined: ResRef,
200 /// The block both forms carry, which the list declares as its element.
201 ///
202 /// No `#[gff]`: the arm contributes its parts to the split, and the
203 /// element is this block, so declaring it here too would make one label
204 /// two declarations. The arm's own codec reads and writes it.
205 pub common: GitDoorCommon,
206 /// Object tag (`Tag`).
207 #[gff(
208 Tag,
209 read_only_dead = "a templated placement takes its tag from the blueprint, which the placement cannot override",
210 unexamined
211 )]
212 pub tag: String,
213 /// Accepts scripted commands (`Commandable`).
214 #[gff(Commandable, unexamined)]
215 pub commandable: bool,
216 /// Attached conversation (`Conversation`).
217 #[gff(Conversation, unexamined)]
218 pub conversation: ResRef,
219 /// Current hit points (`CurrentHP`).
220 #[gff(CurrentHP, unexamined)]
221 pub current_hp: i16,
222 /// Description (`Description`).
223 #[gff(Description, unexamined)]
224 pub description: GffLocalizedString,
225 /// Faction id (`Faction`).
226 #[gff(Faction, unexamined)]
227 pub faction: u32,
228 /// Generic door type (`GenericType`).
229 #[gff(GenericType, unexamined)]
230 pub generic_type: u8,
231 /// Maximum hit points (`HP`).
232 #[gff(HP, unexamined)]
233 pub hp: i16,
234 /// Damage reduction (`Hardness`).
235 #[gff(Hardness, unexamined)]
236 pub hardness: u8,
237 /// Load screen shown across the transition (`LoadScreenID`).
238 #[gff(LoadScreenID, unexamined)]
239 pub load_screen_id: u16,
240 /// Displayed name (`LocName`).
241 #[gff(LocName, unexamined)]
242 pub loc_name: GffLocalizedString,
243 /// Cannot be reduced below 1 HP (`Min1HP`).
244 #[gff(Min1HP, unexamined)]
245 pub min1_hp: bool,
246 /// `OnClick`.
247 #[gff(OnClick, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
248 pub on_click: ResRef,
249 /// `OnDialog`.
250 #[gff(OnDialog, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
251 pub on_dialog: ResRef,
252 /// `OnFailToOpen`.
253 #[gff(OnFailToOpen, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
254 pub on_fail_to_open: ResRef,
255 /// Open / closed / destroyed state (`OpenState`).
256 #[gff(OpenState, unexamined)]
257 pub open_state: u8,
258 /// Plot flag (`Plot`).
259 #[gff(Plot, unexamined)]
260 pub plot: bool,
261 /// DC to spot a secret door (`SecretDoorDC`). Doors only.
262 #[gff(SecretDoorDC, unexamined)]
263 pub secret_door_dc: u8,
264 /// Static, non-interactive geometry (`Static`).
265 #[gff(Static, unexamined)]
266 pub is_static: bool,
267}
268
269impl SavedDoor {
270 /// Reads one element, resolving the portrait's two labels into one value.
271 pub fn read_element(structure: &GffStruct) -> Self {
272 Self {
273 common: GitDoorCommon::read_declared(structure),
274 portrait: SavedPortrait::read(structure),
275 ..Self::read_declared(structure)
276 }
277 }
278
279 /// Writes one element, emitting whichever portrait label the value uses.
280 pub fn write_element(&self, structure: &mut GffStruct) {
281 self.write_declared(structure);
282 self.common.write_declared(structure);
283 self.portrait.write(structure);
284 }
285}
286
287impl GitDoor {
288 /// Reads one element, with the block the list declares as its element.
289 pub fn read_element(structure: &GffStruct) -> Self {
290 Self {
291 common: GitDoorCommon::read_declared(structure),
292 ..Self::read_declared(structure)
293 }
294 }
295
296 /// Writes one element, the common block included.
297 pub fn write_element(&self, structure: &mut GffStruct) {
298 self.write_declared(structure);
299 self.common.write_declared(structure);
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use crate::shared::PORTRAIT_ID_USE_RESREF;
307
308 /// One door written into an element struct of its own.
309 fn written(door: &SavedDoor) -> GffStruct {
310 let mut element = GffStruct::new(8);
311 door.write_element(&mut element);
312 element
313 }
314
315 fn sample() -> SavedDoor {
316 SavedDoor {
317 tag: "sec_door_01".to_string(),
318 common: GitDoorCommon {
319 object_id: Some(ObjectId::new(0x8000_0031)),
320 appearance: 12,
321 ..GitDoorCommon::default()
322 },
323 generic_type: 3,
324 portrait: SavedPortrait::Id(42),
325 placement: Placement {
326 x: 12.5,
327 y: -3.0,
328 bearing: 1.57,
329 ..Placement::default()
330 },
331 current_hp: 40,
332 hp: 60,
333 lock: LockState {
334 locked: true,
335 open_lock_dc: 25,
336 key_name: "sec_key".to_string(),
337 ..LockState::default()
338 },
339 trap_detectable: true,
340 trap_type: 4,
341 trap_detect_dc: 15,
342 load_screen_id: 7,
343 on_open: ResRef::new("k_door_open").expect("valid resref"),
344 ..SavedDoor::default()
345 }
346 }
347
348 #[test]
349 fn round_trips_through_a_list_element() {
350 let door = sample();
351
352 let parsed = SavedDoor::read_element(&written(&door));
353
354 assert_eq!(parsed, door);
355 }
356
357 #[test]
358 fn a_portrait_id_does_not_gain_a_portrait_resref() {
359 // The two fields are complementary in every save; writing both back
360 // would put a field on the object the engine never wrote there.
361 let written = written(&sample());
362
363 assert!(written.field("PortraitId").is_some());
364 assert!(written.field("Portrait").is_none());
365 }
366
367 #[test]
368 fn a_portrait_resref_survives_the_round_trip() {
369 let door = SavedDoor {
370 portrait: SavedPortrait::ResRef(ResRef::new("po_pdoor").expect("valid resref")),
371 ..sample()
372 };
373
374 let written = written(&door);
375 assert!(written.field("PortraitId").is_none());
376 assert_eq!(SavedDoor::read_element(&written).portrait, door.portrait);
377 }
378
379 #[test]
380 fn an_absent_portrait_reads_as_the_engine_default() {
381 // Both fields absent, which is the one state the absent-defaults
382 // guard cannot reach: it drops a single label per probe, so dropping
383 // `PortraitId` leaves `Portrait` behind and the reader takes the
384 // resref branch instead of its fallback.
385 let parsed = SavedDoor::read_element(&GffStruct::new(0));
386
387 assert_eq!(parsed.portrait, SavedPortrait::Id(PORTRAIT_ID_USE_RESREF));
388 }
389}