Skip to main content

rakata_generics/git/
door.rs

1//! Door placements in a GIT.
2
3use rakata_core::{ResRef, StrRef};
4use rakata_formats::{
5    gff_schema::{AbsentDefault, FieldLife, FieldSchema, GffType},
6    GffLocalizedString, GffStruct, GffValue,
7};
8
9use super::object_id_or_invalid;
10use crate::gff_helpers::{
11    get_bool, get_f32, get_i16, get_locstring, get_resref, get_string, get_u16, get_u32, get_u8,
12    upsert_field,
13};
14use crate::shared::{ObjectId, SavedPortrait, TrapDefaults, TrapSettings};
15
16/// A door instance placed in the area (struct type 8).
17///
18/// Doors use `Bearing` for orientation and `X`/`Y`/`Z` for position (not
19/// `XPosition`/`YPosition`/`ZPosition`).
20///
21/// Unlike [`GitWaypoint`](super::GitWaypoint), a static door placement genuinely does resolve its
22/// `TemplateResRef` against a `.utd` blueprint, and it carries the same
23/// transition group a trigger does. This type modelled neither for a while,
24/// which meant round-tripping any vanilla module returned doors with no
25/// blueprint to instantiate from and no tag to reference. See
26/// `docs/src/formats/gff/utd.md`'s "Save versus Template Load Paths".
27///
28/// ## `Tag` is dead here, and deliberately unmodelled
29///
30/// Every door placement in a real install carries a `Tag`, with a real value,
31/// and the engine does not read it. A templated door takes its tag from the
32/// `.utd` blueprint, and there is no overlay: the placement's copy has no way
33/// to win. Same label and same concept as the blueprint's live one, which is
34/// exactly why it needs saying. A field being read somewhere is not evidence
35/// about this copy.
36///
37/// That makes it a modder trap rather than a harmless leftover. Set it on a
38/// placement expecting that door to answer to the new tag and nothing
39/// happens, silently. It belongs in a Phase 1 rule on the raw tree, which is
40/// where a dead field can be reported instead of quietly dropped.
41///
42/// A consequence worth its own rule: two placements sharing a blueprint get
43/// the same tag, so a tag-based script reference to either breaks. Vanilla
44/// gives every door its own blueprint and never trips it. Filed as #46.
45#[derive(Debug, Clone, PartialEq, Default)]
46pub struct GitDoor {
47    /// Template resref (`TemplateResRef`), resolved against a `.utd`.
48    pub template_resref: ResRef,
49    /// Facing angle in radians (`Bearing`).
50    pub bearing: f32,
51    /// X position (`X`).
52    pub x: f32,
53    /// Y position (`Y`).
54    pub y: f32,
55    /// Z position (`Z`).
56    pub z: f32,
57    /// Destination module for a transition door (`LinkedToModule`).
58    pub linked_to_module: ResRef,
59    /// Destination object tag within that module (`LinkedTo`).
60    pub linked_to: String,
61    /// What [`Self::linked_to`] names (`LinkedToFlags`).
62    pub linked_to_flags: u8,
63    /// Player-facing name of the destination (`TransitionDestin`).
64    pub transition_destination: GffLocalizedString,
65    /// Visual model override (`Appearance`).
66    ///
67    /// Read on a sparse placement as well as a saved one: `LoadDoor` and
68    /// `LoadPlaceable` are the same field readers whichever path calls them,
69    /// so a template-referencing placement carrying its own `Appearance` has
70    /// that value read as an override on top of the blueprint's. Read as a
71    /// DWORD and truncated to a byte, which for a door keys `doortypes.2da`'s
72    /// model columns. Defaults to `0` when absent.
73    pub appearance: u32,
74    /// Runtime object ID (`ObjectId`). Save-game only.
75    pub object_id: ObjectId,
76}
77
78impl GitDoor {
79    pub(crate) fn from_gff_struct(s: &GffStruct) -> Self {
80        Self {
81            template_resref: get_resref(s, "TemplateResRef").unwrap_or_default(),
82            bearing: get_f32(s, "Bearing").unwrap_or(0.0),
83            x: get_f32(s, "X").unwrap_or(0.0),
84            y: get_f32(s, "Y").unwrap_or(0.0),
85            z: get_f32(s, "Z").unwrap_or(0.0),
86            linked_to_module: get_resref(s, "LinkedToModule").unwrap_or_default(),
87            linked_to: get_string(s, "LinkedTo").unwrap_or_default(),
88            linked_to_flags: get_u8(s, "LinkedToFlags").unwrap_or(0),
89            transition_destination: get_locstring(s, "TransitionDestin")
90                .cloned()
91                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
92            appearance: get_u32(s, "Appearance").unwrap_or(0),
93            object_id: object_id_or_invalid(s),
94        }
95    }
96
97    pub(crate) fn to_gff_struct(&self) -> GffStruct {
98        let mut s = GffStruct::new(8);
99        upsert_field(
100            &mut s,
101            "TemplateResRef",
102            GffValue::ResRef(self.template_resref),
103        );
104        upsert_field(&mut s, "Bearing", GffValue::Single(self.bearing));
105        upsert_field(&mut s, "X", GffValue::Single(self.x));
106        upsert_field(&mut s, "Y", GffValue::Single(self.y));
107        upsert_field(&mut s, "Z", GffValue::Single(self.z));
108        upsert_field(
109            &mut s,
110            "LinkedToModule",
111            GffValue::ResRef(self.linked_to_module),
112        );
113        upsert_field(&mut s, "LinkedTo", GffValue::String(self.linked_to.clone()));
114        upsert_field(
115            &mut s,
116            "LinkedToFlags",
117            GffValue::UInt8(self.linked_to_flags),
118        );
119        upsert_field(
120            &mut s,
121            "TransitionDestin",
122            GffValue::LocalizedString(self.transition_destination.clone()),
123        );
124        upsert_field(&mut s, "Appearance", GffValue::UInt32(self.appearance));
125        upsert_field(&mut s, "ObjectId", GffValue::UInt32(self.object_id.get()));
126        s
127    }
128}
129
130/// GIT `Door List` entry child schema (struct type 8).
131pub(crate) static DOOR_LIST_CHILDREN: &[FieldSchema] = &[
132    FieldSchema {
133        label: "Appearance",
134        expected_type: GffType::UInt32,
135        life: FieldLife::Live,
136        required: false,
137        absent: AbsentDefault::Unverified,
138        children: None,
139        constraint: None,
140    },
141    FieldSchema {
142        label: "ObjectId",
143        expected_type: GffType::UInt32,
144        life: FieldLife::Live,
145        required: false,
146        absent: AbsentDefault::Unverified,
147        children: None,
148        constraint: None,
149    },
150    FieldSchema {
151        label: "TemplateResRef",
152        expected_type: GffType::ResRef,
153        life: FieldLife::Live,
154        required: false,
155        absent: AbsentDefault::Unverified,
156        children: None,
157        constraint: None,
158    },
159    // Legitimate content in every vanilla door placement, and read by
160    // nothing: the tag comes from the `.utd` blueprint with no overlay.
161    FieldSchema {
162        label: "Tag",
163        expected_type: GffType::String,
164        life: FieldLife::ReadOnlyDead(
165            "a templated placement takes its tag from the blueprint, which the placement cannot override",
166        ),
167        required: false,
168        absent: AbsentDefault::Unverified,
169        children: None,
170        constraint: None,
171    },
172    FieldSchema {
173        label: "LinkedToModule",
174        expected_type: GffType::ResRef,
175        life: FieldLife::Live,
176        required: false,
177        absent: AbsentDefault::Unverified,
178        children: None,
179        constraint: None,
180    },
181    FieldSchema {
182        label: "LinkedTo",
183        expected_type: GffType::String,
184        life: FieldLife::Live,
185        required: false,
186        absent: AbsentDefault::Unverified,
187        children: None,
188        constraint: None,
189    },
190    FieldSchema {
191        label: "LinkedToFlags",
192        expected_type: GffType::UInt8,
193        life: FieldLife::Live,
194        required: false,
195        absent: AbsentDefault::Unverified,
196        children: None,
197        constraint: None,
198    },
199    FieldSchema {
200        label: "TransitionDestin",
201        expected_type: GffType::LocalizedString,
202        life: FieldLife::Live,
203        required: false,
204        absent: AbsentDefault::Unverified,
205        children: None,
206        constraint: None,
207    },
208    FieldSchema {
209        label: "Bearing",
210        expected_type: GffType::Single,
211        life: FieldLife::Live,
212        required: false,
213        absent: AbsentDefault::Unverified,
214        children: None,
215        constraint: None,
216    },
217    FieldSchema {
218        label: "X",
219        expected_type: GffType::Single,
220        life: FieldLife::Live,
221        required: false,
222        absent: AbsentDefault::Unverified,
223        children: None,
224        constraint: None,
225    },
226    FieldSchema {
227        label: "Y",
228        expected_type: GffType::Single,
229        life: FieldLife::Live,
230        required: false,
231        absent: AbsentDefault::Unverified,
232        children: None,
233        constraint: None,
234    },
235    FieldSchema {
236        label: "Z",
237        expected_type: GffType::Single,
238        life: FieldLife::Live,
239        required: false,
240        absent: AbsentDefault::Unverified,
241        children: None,
242        constraint: None,
243    },
244];
245
246// =========================================================================
247// The saved form
248// =========================================================================
249
250// Doors as stored inside a save game.
251//
252// A static `.git` door is a placement: a `TemplateResRef` plus a position,
253// and the engine loads the `.utd` behind it. A savegame door carries the
254// whole object inline, so the fields here are the ones a `.utd` would have
255// supplied plus the runtime state the blueprint has no place for.
256//
257// ## Position is spelled differently here than on a creature
258//
259// Doors and placeables use bare `X` / `Y` / `Z` with a single `Bearing`
260// scalar for facing. Creatures, triggers, stores and waypoints use
261// `XPosition` and a three-component orientation. The engine is not
262// consistent about this, so each saved type carries its own spellings rather
263// than sharing a position block that would have to be told which dialect to
264// write.
265//
266// ## What is not modelled
267//
268// `ActionList`, `EffectList`, `VarTable` and `SWVarTable` are live runtime
269// state whose layouts are only partly audited, and they are skipped for the
270// same reason [`SavedCreature`](crate::saved_creature::SavedCreature) skips
271// them: a typed view models what it enumerates and drops the rest, and
272// byte-exact preservation stays with the raw
273// [`Gff`](rakata_formats::Gff) tree.
274
275/// A door as stored inside a save game's module `GIT`.
276#[derive(Debug, Clone, PartialEq, Default)]
277pub struct SavedDoor {
278    /// Object tag (`Tag`).
279    pub tag: String,
280    /// Runtime object id (`ObjectId`).
281    pub object_id: ObjectId,
282
283    /// Appearance row (`Appearance`), into `genericdoors.2da`.
284    pub appearance: u32,
285    /// Generic door type (`GenericType`).
286    pub generic_type: u8,
287    /// Displayed name (`LocName`).
288    pub loc_name: GffLocalizedString,
289    /// Description (`Description`).
290    pub description: GffLocalizedString,
291    /// Portrait, in whichever of its two forms the object uses.
292    pub portrait: SavedPortrait,
293
294    /// World X position (`X`).
295    pub x: f32,
296    /// World Y position (`Y`).
297    pub y: f32,
298    /// World Z position (`Z`).
299    pub z: f32,
300    /// Facing, in radians (`Bearing`).
301    pub bearing: f32,
302
303    /// Current hit points (`CurrentHP`).
304    pub current_hp: i16,
305    /// Maximum hit points (`HP`).
306    pub hp: i16,
307    /// Damage reduction (`Hardness`).
308    pub hardness: u8,
309    /// Fortitude save (`Fort`).
310    pub fortitude: u8,
311    /// Reflex save (`Ref`).
312    pub reflex: u8,
313    /// Will save (`Will`).
314    pub will: u8,
315    /// Cannot be reduced below 1 HP (`Min1HP`).
316    pub min1_hp: bool,
317    /// Plot flag (`Plot`).
318    pub plot: bool,
319    /// Static, non-interactive geometry (`Static`).
320    pub is_static: bool,
321    /// Accepts scripted commands (`Commandable`).
322    pub commandable: bool,
323    /// Faction id (`Faction`).
324    pub faction: u32,
325
326    /// Open / closed / destroyed state (`OpenState`).
327    pub open_state: u8,
328    /// Can be locked at all (`Lockable`).
329    pub lockable: bool,
330    /// Currently locked (`Locked`).
331    pub locked: bool,
332    /// DC to pick the lock (`OpenLockDC`).
333    pub open_lock_dc: u8,
334    /// DC to re-lock (`CloseLockDC`).
335    pub close_lock_dc: u8,
336    /// DC to spot a secret door (`SecretDoorDC`).
337    pub secret_door_dc: u8,
338    /// Key tag that opens this door (`KeyName`).
339    pub key_name: String,
340    /// A key is required regardless of lock DC (`KeyRequired`).
341    pub key_required: bool,
342    /// Consume the key on use (`AutoRemoveKey`).
343    pub auto_remove_key: bool,
344
345    /// Trap configuration (`TrapDetectable`, `TrapDetectDC`, ...).
346    pub trap: TrapSettings,
347
348    /// Area transition target tag (`LinkedTo`).
349    pub linked_to: String,
350    /// Transition kind (`LinkedToFlags`).
351    pub linked_to_flags: u8,
352    /// Target module for a cross-module transition (`LinkedToModule`).
353    pub linked_to_module: ResRef,
354    /// Transition tooltip text (`TransitionDestin`).
355    pub transition_destin: GffLocalizedString,
356    /// Load screen shown across the transition (`LoadScreenID`).
357    pub load_screen_id: u16,
358
359    /// Attached conversation (`Conversation`).
360    pub conversation: ResRef,
361
362    /// `OnClick`.
363    pub on_click: ResRef,
364    /// `OnClosed`.
365    pub on_closed: ResRef,
366    /// `OnDamaged`.
367    pub on_damaged: ResRef,
368    /// `OnDeath`.
369    pub on_death: ResRef,
370    /// `OnDialog`.
371    pub on_dialog: ResRef,
372    /// `OnDisarm`.
373    pub on_disarm: ResRef,
374    /// `OnFailToOpen`.
375    pub on_fail_to_open: ResRef,
376    /// `OnHeartbeat`.
377    pub on_heartbeat: ResRef,
378    /// `OnLock`.
379    pub on_lock: ResRef,
380    /// `OnMeleeAttacked`.
381    pub on_melee_attacked: ResRef,
382    /// `OnOpen`.
383    pub on_open: ResRef,
384    /// `OnSpellCastAt`.
385    pub on_spell_cast_at: ResRef,
386    /// `OnTrapTriggered`.
387    pub on_trap_triggered: ResRef,
388    /// `OnUnlock`.
389    pub on_unlock: ResRef,
390    /// `OnUserDefined`.
391    pub on_user_defined: ResRef,
392}
393
394impl SavedDoor {
395    /// Creates an empty saved door.
396    pub fn new() -> Self {
397        Self::default()
398    }
399
400    /// Reads one saved `Door List` element.
401    pub fn from_struct(structure: &GffStruct) -> Self {
402        Self {
403            tag: get_string(structure, "Tag").unwrap_or_default(),
404            object_id: object_id_or_invalid(structure),
405
406            appearance: get_u32(structure, "Appearance").unwrap_or(0),
407            generic_type: get_u8(structure, "GenericType").unwrap_or(0),
408            loc_name: get_locstring(structure, "LocName")
409                .cloned()
410                .unwrap_or_default(),
411            description: get_locstring(structure, "Description")
412                .cloned()
413                .unwrap_or_default(),
414            portrait: SavedPortrait::read(
415                |label| get_resref(structure, label),
416                |label| get_u16(structure, label),
417            ),
418
419            x: get_f32(structure, "X").unwrap_or(0.0),
420            y: get_f32(structure, "Y").unwrap_or(0.0),
421            z: get_f32(structure, "Z").unwrap_or(0.0),
422            bearing: get_f32(structure, "Bearing").unwrap_or(0.0),
423
424            current_hp: get_i16(structure, "CurrentHP").unwrap_or(0),
425            hp: get_i16(structure, "HP").unwrap_or(0),
426            hardness: get_u8(structure, "Hardness").unwrap_or(0),
427            fortitude: get_u8(structure, "Fort").unwrap_or(0),
428            reflex: get_u8(structure, "Ref").unwrap_or(0),
429            will: get_u8(structure, "Will").unwrap_or(0),
430            min1_hp: get_bool(structure, "Min1HP").unwrap_or(false),
431            plot: get_bool(structure, "Plot").unwrap_or(false),
432            is_static: get_bool(structure, "Static").unwrap_or(false),
433            commandable: get_bool(structure, "Commandable").unwrap_or(false),
434            faction: get_u32(structure, "Faction").unwrap_or(0),
435
436            open_state: get_u8(structure, "OpenState").unwrap_or(0),
437            lockable: get_bool(structure, "Lockable").unwrap_or(false),
438            locked: get_bool(structure, "Locked").unwrap_or(false),
439            open_lock_dc: get_u8(structure, "OpenLockDC").unwrap_or(0),
440            close_lock_dc: get_u8(structure, "CloseLockDC").unwrap_or(0),
441            secret_door_dc: get_u8(structure, "SecretDoorDC").unwrap_or(0),
442            key_name: get_string(structure, "KeyName").unwrap_or_default(),
443            key_required: get_bool(structure, "KeyRequired").unwrap_or(false),
444            auto_remove_key: get_bool(structure, "AutoRemoveKey").unwrap_or(false),
445
446            trap: TrapSettings::read(
447                TrapDefaults::DOOR,
448                |label| get_bool(structure, label),
449                |label| get_u8(structure, label),
450            ),
451
452            linked_to: get_string(structure, "LinkedTo").unwrap_or_default(),
453            linked_to_flags: get_u8(structure, "LinkedToFlags").unwrap_or(0),
454            linked_to_module: get_resref(structure, "LinkedToModule").unwrap_or_default(),
455            transition_destin: get_locstring(structure, "TransitionDestin")
456                .cloned()
457                .unwrap_or_default(),
458            load_screen_id: get_u16(structure, "LoadScreenID").unwrap_or(0),
459
460            conversation: get_resref(structure, "Conversation").unwrap_or_default(),
461
462            on_click: get_resref(structure, "OnClick").unwrap_or_default(),
463            on_closed: get_resref(structure, "OnClosed").unwrap_or_default(),
464            on_damaged: get_resref(structure, "OnDamaged").unwrap_or_default(),
465            on_death: get_resref(structure, "OnDeath").unwrap_or_default(),
466            on_dialog: get_resref(structure, "OnDialog").unwrap_or_default(),
467            on_disarm: get_resref(structure, "OnDisarm").unwrap_or_default(),
468            on_fail_to_open: get_resref(structure, "OnFailToOpen").unwrap_or_default(),
469            on_heartbeat: get_resref(structure, "OnHeartbeat").unwrap_or_default(),
470            on_lock: get_resref(structure, "OnLock").unwrap_or_default(),
471            on_melee_attacked: get_resref(structure, "OnMeleeAttacked").unwrap_or_default(),
472            on_open: get_resref(structure, "OnOpen").unwrap_or_default(),
473            on_spell_cast_at: get_resref(structure, "OnSpellCastAt").unwrap_or_default(),
474            on_trap_triggered: get_resref(structure, "OnTrapTriggered").unwrap_or_default(),
475            on_unlock: get_resref(structure, "OnUnlock").unwrap_or_default(),
476            on_user_defined: get_resref(structure, "OnUserDefined").unwrap_or_default(),
477        }
478    }
479
480    /// Writes this door back into a `Door List` element.
481    pub fn to_struct(&self, index: usize) -> GffStruct {
482        let mut s = GffStruct::new(i32::try_from(index).expect("door index fits i32"));
483
484        upsert_field(&mut s, "Tag", GffValue::String(self.tag.clone()));
485        upsert_field(&mut s, "ObjectId", GffValue::UInt32(self.object_id.get()));
486
487        upsert_field(&mut s, "Appearance", GffValue::UInt32(self.appearance));
488        upsert_field(&mut s, "GenericType", GffValue::UInt8(self.generic_type));
489        upsert_field(
490            &mut s,
491            "LocName",
492            GffValue::LocalizedString(self.loc_name.clone()),
493        );
494        upsert_field(
495            &mut s,
496            "Description",
497            GffValue::LocalizedString(self.description.clone()),
498        );
499        self.portrait
500            .write(|label, value| upsert_field(&mut s, label, value));
501
502        upsert_field(&mut s, "X", GffValue::Single(self.x));
503        upsert_field(&mut s, "Y", GffValue::Single(self.y));
504        upsert_field(&mut s, "Z", GffValue::Single(self.z));
505        upsert_field(&mut s, "Bearing", GffValue::Single(self.bearing));
506
507        upsert_field(&mut s, "CurrentHP", GffValue::Int16(self.current_hp));
508        upsert_field(&mut s, "HP", GffValue::Int16(self.hp));
509        upsert_field(&mut s, "Hardness", GffValue::UInt8(self.hardness));
510        upsert_field(&mut s, "Fort", GffValue::UInt8(self.fortitude));
511        upsert_field(&mut s, "Ref", GffValue::UInt8(self.reflex));
512        upsert_field(&mut s, "Will", GffValue::UInt8(self.will));
513        upsert_field(&mut s, "Min1HP", GffValue::UInt8(self.min1_hp.into()));
514        upsert_field(&mut s, "Plot", GffValue::UInt8(self.plot.into()));
515        upsert_field(&mut s, "Static", GffValue::UInt8(self.is_static.into()));
516        upsert_field(
517            &mut s,
518            "Commandable",
519            GffValue::UInt8(self.commandable.into()),
520        );
521        upsert_field(&mut s, "Faction", GffValue::UInt32(self.faction));
522
523        upsert_field(&mut s, "OpenState", GffValue::UInt8(self.open_state));
524        upsert_field(&mut s, "Lockable", GffValue::UInt8(self.lockable.into()));
525        upsert_field(&mut s, "Locked", GffValue::UInt8(self.locked.into()));
526        upsert_field(&mut s, "OpenLockDC", GffValue::UInt8(self.open_lock_dc));
527        upsert_field(&mut s, "CloseLockDC", GffValue::UInt8(self.close_lock_dc));
528        upsert_field(&mut s, "SecretDoorDC", GffValue::UInt8(self.secret_door_dc));
529        upsert_field(&mut s, "KeyName", GffValue::String(self.key_name.clone()));
530        upsert_field(
531            &mut s,
532            "KeyRequired",
533            GffValue::UInt8(self.key_required.into()),
534        );
535        upsert_field(
536            &mut s,
537            "AutoRemoveKey",
538            GffValue::UInt8(self.auto_remove_key.into()),
539        );
540
541        self.trap
542            .write(|label, value| upsert_field(&mut s, label, value));
543
544        upsert_field(&mut s, "LinkedTo", GffValue::String(self.linked_to.clone()));
545        upsert_field(
546            &mut s,
547            "LinkedToFlags",
548            GffValue::UInt8(self.linked_to_flags),
549        );
550        upsert_field(
551            &mut s,
552            "LinkedToModule",
553            GffValue::ResRef(self.linked_to_module),
554        );
555        upsert_field(
556            &mut s,
557            "TransitionDestin",
558            GffValue::LocalizedString(self.transition_destin.clone()),
559        );
560        upsert_field(
561            &mut s,
562            "LoadScreenID",
563            GffValue::UInt16(self.load_screen_id),
564        );
565
566        upsert_field(&mut s, "Conversation", GffValue::ResRef(self.conversation));
567
568        upsert_field(&mut s, "OnClick", GffValue::ResRef(self.on_click));
569        upsert_field(&mut s, "OnClosed", GffValue::ResRef(self.on_closed));
570        upsert_field(&mut s, "OnDamaged", GffValue::ResRef(self.on_damaged));
571        upsert_field(&mut s, "OnDeath", GffValue::ResRef(self.on_death));
572        upsert_field(&mut s, "OnDialog", GffValue::ResRef(self.on_dialog));
573        upsert_field(&mut s, "OnDisarm", GffValue::ResRef(self.on_disarm));
574        upsert_field(
575            &mut s,
576            "OnFailToOpen",
577            GffValue::ResRef(self.on_fail_to_open),
578        );
579        upsert_field(&mut s, "OnHeartbeat", GffValue::ResRef(self.on_heartbeat));
580        upsert_field(&mut s, "OnLock", GffValue::ResRef(self.on_lock));
581        upsert_field(
582            &mut s,
583            "OnMeleeAttacked",
584            GffValue::ResRef(self.on_melee_attacked),
585        );
586        upsert_field(&mut s, "OnOpen", GffValue::ResRef(self.on_open));
587        upsert_field(
588            &mut s,
589            "OnSpellCastAt",
590            GffValue::ResRef(self.on_spell_cast_at),
591        );
592        upsert_field(
593            &mut s,
594            "OnTrapTriggered",
595            GffValue::ResRef(self.on_trap_triggered),
596        );
597        upsert_field(&mut s, "OnUnlock", GffValue::ResRef(self.on_unlock));
598        upsert_field(
599            &mut s,
600            "OnUserDefined",
601            GffValue::ResRef(self.on_user_defined),
602        );
603
604        s
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    fn sample() -> SavedDoor {
613        SavedDoor {
614            tag: "sec_door_01".to_string(),
615            object_id: ObjectId::new(0x8000_0031),
616            appearance: 12,
617            generic_type: 3,
618            portrait: SavedPortrait::Id(42),
619            x: 12.5,
620            y: -3.0,
621            bearing: 1.57,
622            current_hp: 40,
623            hp: 60,
624            locked: true,
625            open_lock_dc: 25,
626            key_name: "sec_key".to_string(),
627            trap: TrapSettings {
628                detectable: true,
629                detect_dc: 15,
630                trap_type: 4,
631                ..TrapSettings::default()
632            },
633            linked_to: "wp_exit".to_string(),
634            load_screen_id: 7,
635            on_open: ResRef::new("k_door_open").expect("valid resref"),
636            ..SavedDoor::default()
637        }
638    }
639
640    #[test]
641    fn round_trips_through_a_list_element() {
642        let door = sample();
643
644        let parsed = SavedDoor::from_struct(&door.to_struct(0));
645
646        assert_eq!(parsed, door);
647    }
648
649    #[test]
650    fn a_portrait_id_does_not_gain_a_portrait_resref() {
651        // The two fields are complementary in every save; writing both back
652        // would put a field on the object the engine never wrote there.
653        let written = sample().to_struct(0);
654
655        assert!(written.field("PortraitId").is_some());
656        assert!(written.field("Portrait").is_none());
657    }
658
659    #[test]
660    fn a_portrait_resref_survives_the_round_trip() {
661        let door = SavedDoor {
662            portrait: SavedPortrait::ResRef(ResRef::new("po_pdoor").expect("valid resref")),
663            ..sample()
664        };
665
666        let written = door.to_struct(0);
667        assert!(written.field("PortraitId").is_none());
668        assert_eq!(SavedDoor::from_struct(&written).portrait, door.portrait);
669    }
670
671    #[test]
672    fn an_absent_portrait_reads_as_the_engine_default() {
673        // A door with neither field is row 0, not a missing portrait.
674        let parsed = SavedDoor::from_struct(&GffStruct::new(0));
675
676        assert_eq!(parsed.portrait, SavedPortrait::Id(0));
677    }
678}