Skip to main content

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}