Skip to main content

rakata_generics/git/
creature.rs

1//! Creature placements in a GIT.
2
3use rakata_core::ResRef;
4use rakata_formats::{
5    gff_schema::{AbsentDefault, FieldLife, FieldSchema, GffType},
6    GffLocalizedString, GffStruct, GffValue,
7};
8
9use super::item::SavedItem;
10use super::object_id_or_invalid;
11use crate::gff_helpers::{
12    get_f32, get_i16, get_i32, get_i8, get_locstring, get_resref, get_string, get_u16, get_u32,
13    get_u8, upsert_field,
14};
15use crate::shared::ObjectId;
16use crate::utc::UtcSkills;
17
18/// A creature instance placed in the area (struct type 4).
19#[derive(Debug, Clone, PartialEq, Default)]
20pub struct GitCreature {
21    /// Template resref (`TemplateResRef`).
22    pub template_resref: ResRef,
23    /// X position (`XPosition`).
24    pub x_position: f32,
25    /// Y position (`YPosition`).
26    pub y_position: f32,
27    /// Z position (`ZPosition`).
28    pub z_position: f32,
29    /// X orientation (`XOrientation`).
30    pub x_orientation: f32,
31    /// Y orientation (`YOrientation`).
32    pub y_orientation: f32,
33    /// Z orientation (`ZOrientation`).
34    pub z_orientation: f32,
35}
36
37// `ObjectId` is modelled on `SavedCreature` below and deliberately not here,
38// which reads as an oversight now that the two forms share a file.
39//
40// The static creature path never reads the label. The constructor takes the
41// placeholder as a bare literal with no lookup against `ObjectId` anywhere in
42// that branch, which is the same never-looked-up status as `.utc`'s `Tail`
43// and `Wings` rather than a read with a default behind it.
44//
45// This is creature-specific. Every other object type reads the label once
46// before the static-versus-saved branch, so their static forms are genuinely
47// read-with-default and keep the field.
48//
49// The label stays declared in the schema, so a static creature carrying one
50// is legitimate content rather than an unrecognized field. Saying it is dead
51// *here* and live on the saved form is not something `FieldLife` can express,
52// since it is one path with two lives, and that gap is recorded with the
53// wider schema question rather than worked around.
54impl GitCreature {
55    pub(crate) fn from_gff_struct(s: &GffStruct) -> Self {
56        Self {
57            template_resref: get_resref(s, "TemplateResRef").unwrap_or_default(),
58            x_position: get_f32(s, "XPosition").unwrap_or(0.0),
59            y_position: get_f32(s, "YPosition").unwrap_or(0.0),
60            z_position: get_f32(s, "ZPosition").unwrap_or(0.0),
61            x_orientation: get_f32(s, "XOrientation").unwrap_or(0.0),
62            y_orientation: get_f32(s, "YOrientation").unwrap_or(0.0),
63            z_orientation: get_f32(s, "ZOrientation").unwrap_or(0.0),
64        }
65    }
66
67    pub(crate) fn to_gff_struct(&self) -> GffStruct {
68        let mut s = GffStruct::new(4);
69        upsert_field(
70            &mut s,
71            "TemplateResRef",
72            GffValue::ResRef(self.template_resref),
73        );
74        upsert_field(&mut s, "XPosition", GffValue::Single(self.x_position));
75        upsert_field(&mut s, "YPosition", GffValue::Single(self.y_position));
76        upsert_field(&mut s, "ZPosition", GffValue::Single(self.z_position));
77        upsert_field(&mut s, "XOrientation", GffValue::Single(self.x_orientation));
78        upsert_field(&mut s, "YOrientation", GffValue::Single(self.y_orientation));
79        upsert_field(&mut s, "ZOrientation", GffValue::Single(self.z_orientation));
80        s
81    }
82}
83
84/// GIT `Creature List` entry child schema (struct type 4).
85pub(crate) static CREATURE_LIST_CHILDREN: &[FieldSchema] = &[
86    FieldSchema {
87        label: "ObjectId",
88        expected_type: GffType::UInt32,
89        life: FieldLife::Live,
90        required: false,
91        absent: AbsentDefault::Unverified,
92        children: None,
93        constraint: None,
94    },
95    FieldSchema {
96        label: "TemplateResRef",
97        expected_type: GffType::ResRef,
98        life: FieldLife::Live,
99        required: false,
100        absent: AbsentDefault::Unverified,
101        children: None,
102        constraint: None,
103    },
104    FieldSchema {
105        label: "XPosition",
106        expected_type: GffType::Single,
107        life: FieldLife::Live,
108        required: false,
109        absent: AbsentDefault::Unverified,
110        children: None,
111        constraint: None,
112    },
113    FieldSchema {
114        label: "YPosition",
115        expected_type: GffType::Single,
116        life: FieldLife::Live,
117        required: false,
118        absent: AbsentDefault::Unverified,
119        children: None,
120        constraint: None,
121    },
122    FieldSchema {
123        label: "ZPosition",
124        expected_type: GffType::Single,
125        life: FieldLife::Live,
126        required: false,
127        absent: AbsentDefault::Unverified,
128        children: None,
129        constraint: None,
130    },
131    FieldSchema {
132        label: "XOrientation",
133        expected_type: GffType::Single,
134        life: FieldLife::Live,
135        required: false,
136        absent: AbsentDefault::Unverified,
137        children: None,
138        constraint: None,
139    },
140    FieldSchema {
141        label: "YOrientation",
142        expected_type: GffType::Single,
143        life: FieldLife::Live,
144        required: false,
145        absent: AbsentDefault::Unverified,
146        children: None,
147        constraint: None,
148    },
149    FieldSchema {
150        label: "ZOrientation",
151        expected_type: GffType::Single,
152        life: FieldLife::Live,
153        required: false,
154        absent: AbsentDefault::Unverified,
155        children: None,
156        constraint: None,
157    },
158];
159
160// =========================================================================
161// The saved form
162// =========================================================================
163
164// Saved creature snapshots from a save game's module `GIT`.
165//
166// A module `GIT` inside a save sets `UseTemplates = 0`, which makes each
167// creature element a full self-contained snapshot rather than a placement
168// that points at a `.utc` blueprint. There is no `TemplateResRef` to follow
169// and no blueprint load: every field the engine needs is on the element, and
170// a field that is missing falls back to the engine's hardcoded default rather
171// than to a template value.
172//
173// ## Why this is not built on [`Utc`](crate::utc::Utc)
174//
175// The two serializations overlap heavily but neither contains the other, and
176// the fields they do not share are not a tidy category. A handful are genuine
177// authoring metadata that a runtime snapshot has no use for
178// (`TemplateResRef`, `PaletteID`, `Comment`). The rest are ordinary character
179// data that the save writer either drops or stores differently: a blueprint
180// carries both `Portrait` and `PortraitId` while a snapshot carries only
181// `PortraitId`; a blueprint's `SaveWill` / `SaveFortitude` are the documented
182// dead fields while a snapshot carries computed `WillSaveThrow` /
183// `FortSaveThrow` totals plus round-tripping `willbonus` / `fortbonus`
184// inputs; `WalkRate` becomes `MovementRate`.
185//
186// Sharing a "creature core" between the two would therefore draw a line
187// through the middle of character data based on which representation the save
188// writer happened to pick, which describes no real concept. So the overlapping
189// fields are spelled out here as well as in [`Utc`](crate::utc::Utc). The cost
190// is duplication; the benefit is that each type writes exactly the field set
191// it enumerates, with no hand-maintained list of exceptions.
192//
193// If you go looking to quantify that overlap, note that "a root-level field of
194// [`Utc`](crate::utc::Utc)" is not a well-defined set. Its list helpers handle
195// both the list's own label and the labels of the elements inside it, so
196// whether `Spell`, `Feat` or `Rank` counts as root or nested depends entirely
197// on how you slice the source. Counts derived that way do not reconcile and
198// are not worth deriving. The field *names* on either side are exact; the
199// arithmetic over them is not, and nothing here depends on it.
200//
201// ## What is not modelled yet
202//
203// Progression and inventory are covered: `ClassList`, `FeatList`,
204// `SkillList`, `ItemList` and `Equip_ItemList` all read and write.
205//
206// `ActionList`, `EffectList`, `ExpressionList`, `PerceptionList`, `VarTable`
207// and the `CombatInfo` / `CombatRoundData` / `SWVarTable` structs are live
208// runtime state whose layouts are only partly audited.
209//
210// Neither group is read or written here. That follows the projection rule: a
211// typed view models what it enumerates and drops the rest, and byte-exact
212// preservation stays with the raw [`Gff`](rakata_formats::Gff) tree.
213
214/// A creature as stored inside a save game's module `GIT`.
215#[derive(Debug, Clone, PartialEq, Default)]
216pub struct SavedCreature {
217    // --- Identity ---
218    /// Object tag (`Tag`).
219    pub tag: String,
220    /// Localized first name (`FirstName`).
221    pub first_name: GffLocalizedString,
222    /// Localized last name (`LastName`).
223    pub last_name: GffLocalizedString,
224    /// Localized description (`Description`).
225    pub description: GffLocalizedString,
226    /// Runtime object id (`ObjectId`).
227    pub object_id: ObjectId,
228    /// Runtime area id the creature belongs to (`AreaId`).
229    pub area_id: u32,
230    /// Dialogue this creature opens (`Conversation`). Not a script hook.
231    pub conversation: ResRef,
232
233    // --- Placement ---
234    /// World X position (`XPosition`).
235    pub x_position: f32,
236    /// World Y position (`YPosition`).
237    pub y_position: f32,
238    /// World Z position (`ZPosition`).
239    pub z_position: f32,
240    /// Facing X component (`XOrientation`).
241    pub x_orientation: f32,
242    /// Facing Y component (`YOrientation`).
243    pub y_orientation: f32,
244    /// Facing Z component (`ZOrientation`).
245    pub z_orientation: f32,
246
247    // --- Appearance ---
248    /// Appearance row (`Appearance_Type`).
249    pub appearance_type: u16,
250    /// Head appearance (`Appearance_Head`).
251    pub appearance_head: u8,
252    /// Portrait row (`PortraitId`). A snapshot carries only the id; a
253    /// blueprint additionally carries a `Portrait` resref.
254    pub portrait_id: u16,
255    /// Body-model phenotype (`Phenotype`).
256    pub phenotype: i32,
257    /// Skin colour index (`Color_Skin`).
258    pub color_skin: u8,
259    /// Hair colour index (`Color_Hair`).
260    pub color_hair: u8,
261    /// First tattoo colour index (`Color_Tattoo1`).
262    pub color_tattoo1: u8,
263    /// Second tattoo colour index (`Color_Tattoo2`).
264    pub color_tattoo2: u8,
265    /// Head-duplication flag (`DuplicatingHead`).
266    pub duplicating_head: u8,
267    /// Backup-head flag (`UseBackupHead`).
268    pub use_backup_head: u8,
269    /// Party-member disguise flag (`PM_IsDisguised`).
270    pub is_disguised: u8,
271    /// Corpse body-bag appearance (`BodyBag`).
272    pub body_bag: u8,
273
274    // --- Abilities ---
275    /// Strength (`Str`).
276    pub strength: u8,
277    /// Dexterity (`Dex`).
278    pub dexterity: u8,
279    /// Constitution (`Con`).
280    pub constitution: u8,
281    /// Intelligence (`Int`).
282    pub intelligence: u8,
283    /// Wisdom (`Wis`).
284    pub wisdom: u8,
285    /// Charisma (`Cha`).
286    pub charisma: u8,
287
288    // --- Vitals ---
289    /// Live hit points (`CurrentHitPoints`).
290    pub current_hit_points: i16,
291    /// Base hit points carried from the template (`HitPoints`).
292    pub hit_points: i16,
293    /// Computed maximum hit points (`MaxHitPoints`). Rebuilt on load from
294    /// class levels and the Constitution modifier, so an edit here does
295    /// nothing.
296    pub max_hit_points: i16,
297    /// Current-HP mirror (`PregameCurrent`). Written on every save and read
298    /// by nothing.
299    pub pregame_current: i16,
300    /// Live Force points (`ForcePoints`).
301    pub force_points: i16,
302    /// Current Force pool (`CurrentForce`).
303    pub current_force: i16,
304    /// Computed maximum Force points (`MaxForcePoints`).
305    pub max_force_points: i16,
306
307    // --- Computed combat totals ---
308    /// Computed armour class (`ArmorClass`). Rebuilt on load from armour
309    /// tables, natural AC, the Dexterity modifier, feats and active effects.
310    pub armor_class: i16,
311    /// Computed Reflex save total (`RefSaveThrow`). Rebuilt on load.
312    pub ref_save_throw: i8,
313    /// Computed Will save total (`WillSaveThrow`). Rebuilt on load.
314    pub will_save_throw: i8,
315    /// Computed Fortitude save total (`FortSaveThrow`). Rebuilt on load.
316    pub fort_save_throw: i8,
317    /// Permanent Reflex bonus (`refbonus`). Unlike the total above, this is
318    /// an input the engine reads back.
319    pub ref_bonus: i16,
320    /// Permanent Will bonus (`willbonus`). Read back on load.
321    pub will_bonus: i16,
322    /// Permanent Fortitude bonus (`fortbonus`). Read back on load.
323    pub fort_bonus: i16,
324    /// Natural armour class (`NaturalAC`).
325    pub natural_ac: u8,
326
327    // --- Progression ---
328    /// Accumulated experience (`Experience`).
329    pub experience: u32,
330    /// Carried gold (`Gold`). Skipped on load for creatures currently in the
331    /// party, whose wealth lives in the shared party pool instead.
332    pub gold: u32,
333    /// Unspent skill points (`SkillPoints`).
334    pub skill_points: u16,
335    /// Multiclass level-up bookkeeping (`MClassLevUpIn`). Written on every
336    /// save and read by nothing.
337    pub multiclass_level_up_in: u8,
338    /// Joining experience (`JoiningXP`). Read only by the fresh-spawn path,
339    /// so it resets to zero when a save is continued.
340    pub joining_xp: i32,
341    /// Starting package row (`StartingPackage`).
342    pub starting_package: u8,
343    /// Challenge rating (`ChallengeRating`).
344    pub challenge_rating: f32,
345    /// Age (`Age`).
346    pub age: i32,
347
348    // --- Identity tables ---
349    /// Race row (`Race`).
350    pub race: u8,
351    /// Subrace name (`Subrace`).
352    pub subrace: String,
353    /// Subrace row (`SubraceIndex`).
354    pub subrace_index: u8,
355    /// Deity name (`Deity`).
356    pub deity: String,
357    /// Gender row (`Gender`).
358    pub gender: u8,
359    /// Alignment on the light/dark axis (`GoodEvil`). The engine clamps this
360    /// to a maximum of 100.
361    pub good_evil: u8,
362    /// Faction id indexing the session faction table (`FactionID`).
363    pub faction_id: u16,
364    /// Sound-set row (`SoundSetFile`).
365    pub sound_set_file: u16,
366
367    // --- Runtime behaviour ---
368    /// AI state (`AIState`).
369    pub ai_state: i32,
370    /// Orientation-lock flag (`NotReorienting`).
371    pub not_reorienting: u8,
372    /// Detect mode (`DetectMode`). The loader reads the byte only to step
373    /// past it and resets every restored creature to mode 1.
374    pub detect_mode: u8,
375    /// Stealth mode (`StealthMode`).
376    pub stealth_mode: u8,
377    /// Player-commandable flag (`Commandable`).
378    pub commandable: u8,
379    /// Listening flag (`Listening`).
380    pub listening: u8,
381    /// Party-interaction flag (`PartyInteract`).
382    pub party_interact: u8,
383    /// Plot-protection flag (`Plot`).
384    pub plot: u8,
385    /// Minimum-one-hit-point flag (`Min1HP`).
386    pub min1_hp: u8,
387    /// Interruptable flag (`Interruptable`).
388    pub interruptable: u8,
389    /// Disarmable flag (`Disarmable`).
390    pub disarmable: u8,
391    /// Destroyable flag (`IsDestroyable`).
392    pub is_destroyable: u8,
393    /// Raiseable flag (`IsRaiseable`).
394    pub is_raiseable: u8,
395    /// Corpse-selectable flag (`DeadSelectable`).
396    pub dead_selectable: u8,
397    /// Player-character flag (`IsPC`).
398    pub is_pc: u8,
399    /// Spawn-script-fired flag (`CreatnScrptFird`).
400    pub creation_script_fired: u8,
401    /// Ambient animation state (`AmbientAnimState`).
402    pub ambient_anim_state: u8,
403    /// Current animation (`Animation`).
404    pub animation: i32,
405    /// Creature size row (`CreatureSize`).
406    pub creature_size: i32,
407    /// Movement rate row (`MovementRate`). A blueprint spells this `WalkRate`.
408    pub movement_rate: u8,
409
410    // --- Progression lists ---
411    /// Class levels and known powers (`ClassList`).
412    pub classes: Vec<SavedClass>,
413    /// Feat ids (`FeatList`).
414    pub feats: Vec<u16>,
415    /// Skill ranks (`SkillList`), positional as the engine stores them.
416    pub skills: UtcSkills,
417    /// Carried inventory (`ItemList`).
418    pub inventory: Vec<SavedItem>,
419    /// Equipped items (`Equip_ItemList`).
420    pub equipment: Vec<SavedItem>,
421
422    // --- Legacy ---
423    /// Tail appearance (`Tail`). Hardcoded to zero on read; written anyway.
424    pub tail: u8,
425    /// Wing appearance (`Wings`). Hardcoded to zero on read; written anyway.
426    pub wings: u8,
427
428    // --- Scripts ---
429    /// On-attacked script (`ScriptAttacked`).
430    pub on_attacked: ResRef,
431    /// On-damaged script (`ScriptDamaged`).
432    pub on_damaged: ResRef,
433    /// On-death script (`ScriptDeath`).
434    pub on_death: ResRef,
435    /// On-dialogue script (`ScriptDialogue`).
436    pub on_dialog: ResRef,
437    /// On-disturbed script (`ScriptDisturbed`).
438    pub on_disturbed: ResRef,
439    /// On-end-dialog script (`ScriptEndDialogu`).
440    pub on_end_dialog: ResRef,
441    /// On-end-round script (`ScriptEndRound`).
442    pub on_end_round: ResRef,
443    /// On-heartbeat script (`ScriptHeartbeat`).
444    pub on_heartbeat: ResRef,
445    /// On-blocked script (`ScriptOnBlocked`).
446    pub on_blocked: ResRef,
447    /// On-notice script (`ScriptOnNotice`).
448    pub on_notice: ResRef,
449    /// On-rested script (`ScriptRested`).
450    pub on_rested: ResRef,
451    /// On-spawn script (`ScriptSpawn`).
452    pub on_spawn: ResRef,
453    /// On-spell-at script (`ScriptSpellAt`).
454    pub on_spell: ResRef,
455    /// On-user-defined script (`ScriptUserDefine`).
456    pub on_user_defined: ResRef,
457}
458
459impl SavedCreature {
460    /// Creates an empty snapshot.
461    pub fn new() -> Self {
462        Self::default()
463    }
464
465    /// Reads a snapshot from one `Creature List` element.
466    ///
467    /// Every field is optional: a `UseTemplates = 0` element that omits one
468    /// resolves to the engine's hardcoded default, which is what the
469    /// per-field fallbacks here reproduce.
470    pub fn from_struct(structure: &GffStruct) -> Self {
471        Self {
472            tag: get_string(structure, "Tag").unwrap_or_default(),
473            first_name: get_locstring(structure, "FirstName")
474                .cloned()
475                .unwrap_or_default(),
476            last_name: get_locstring(structure, "LastName")
477                .cloned()
478                .unwrap_or_default(),
479            description: get_locstring(structure, "Description")
480                .cloned()
481                .unwrap_or_default(),
482            object_id: object_id_or_invalid(structure),
483            area_id: get_u32(structure, "AreaId").unwrap_or(0),
484            conversation: get_resref(structure, "Conversation").unwrap_or_default(),
485
486            x_position: get_f32(structure, "XPosition").unwrap_or(0.0),
487            y_position: get_f32(structure, "YPosition").unwrap_or(0.0),
488            z_position: get_f32(structure, "ZPosition").unwrap_or(0.0),
489            x_orientation: get_f32(structure, "XOrientation").unwrap_or(0.0),
490            y_orientation: get_f32(structure, "YOrientation").unwrap_or(0.0),
491            z_orientation: get_f32(structure, "ZOrientation").unwrap_or(0.0),
492
493            appearance_type: get_u16(structure, "Appearance_Type").unwrap_or(0),
494            appearance_head: get_u8(structure, "Appearance_Head").unwrap_or(0),
495            portrait_id: get_u16(structure, "PortraitId").unwrap_or(0),
496            phenotype: get_i32(structure, "Phenotype").unwrap_or(0),
497            color_skin: get_u8(structure, "Color_Skin").unwrap_or(0),
498            color_hair: get_u8(structure, "Color_Hair").unwrap_or(0),
499            color_tattoo1: get_u8(structure, "Color_Tattoo1").unwrap_or(0),
500            color_tattoo2: get_u8(structure, "Color_Tattoo2").unwrap_or(0),
501            duplicating_head: get_u8(structure, "DuplicatingHead").unwrap_or(0),
502            use_backup_head: get_u8(structure, "UseBackupHead").unwrap_or(0),
503            is_disguised: get_u8(structure, "PM_IsDisguised").unwrap_or(0),
504            body_bag: get_u8(structure, "BodyBag").unwrap_or(0),
505
506            strength: get_u8(structure, "Str").unwrap_or(0),
507            dexterity: get_u8(structure, "Dex").unwrap_or(0),
508            constitution: get_u8(structure, "Con").unwrap_or(0),
509            intelligence: get_u8(structure, "Int").unwrap_or(0),
510            wisdom: get_u8(structure, "Wis").unwrap_or(0),
511            charisma: get_u8(structure, "Cha").unwrap_or(0),
512
513            current_hit_points: get_i16(structure, "CurrentHitPoints").unwrap_or(0),
514            hit_points: get_i16(structure, "HitPoints").unwrap_or(0),
515            max_hit_points: get_i16(structure, "MaxHitPoints").unwrap_or(0),
516            pregame_current: get_i16(structure, "PregameCurrent").unwrap_or(0),
517            force_points: get_i16(structure, "ForcePoints").unwrap_or(0),
518            current_force: get_i16(structure, "CurrentForce").unwrap_or(0),
519            max_force_points: get_i16(structure, "MaxForcePoints").unwrap_or(0),
520
521            armor_class: get_i16(structure, "ArmorClass").unwrap_or(0),
522            ref_save_throw: get_i8(structure, "RefSaveThrow").unwrap_or(0),
523            will_save_throw: get_i8(structure, "WillSaveThrow").unwrap_or(0),
524            fort_save_throw: get_i8(structure, "FortSaveThrow").unwrap_or(0),
525            ref_bonus: get_i16(structure, "refbonus").unwrap_or(0),
526            will_bonus: get_i16(structure, "willbonus").unwrap_or(0),
527            fort_bonus: get_i16(structure, "fortbonus").unwrap_or(0),
528            natural_ac: get_u8(structure, "NaturalAC").unwrap_or(0),
529
530            experience: get_u32(structure, "Experience").unwrap_or(0),
531            gold: get_u32(structure, "Gold").unwrap_or(0),
532            skill_points: get_u16(structure, "SkillPoints").unwrap_or(0),
533            multiclass_level_up_in: get_u8(structure, "MClassLevUpIn").unwrap_or(0),
534            joining_xp: get_i32(structure, "JoiningXP").unwrap_or(0),
535            starting_package: get_u8(structure, "StartingPackage").unwrap_or(0),
536            challenge_rating: get_f32(structure, "ChallengeRating").unwrap_or(0.0),
537            age: get_i32(structure, "Age").unwrap_or(0),
538
539            race: get_u8(structure, "Race").unwrap_or(0),
540            subrace: get_string(structure, "Subrace").unwrap_or_default(),
541            subrace_index: get_u8(structure, "SubraceIndex").unwrap_or(0),
542            deity: get_string(structure, "Deity").unwrap_or_default(),
543            gender: get_u8(structure, "Gender").unwrap_or(0),
544            good_evil: get_u8(structure, "GoodEvil").unwrap_or(0),
545            faction_id: get_u16(structure, "FactionID").unwrap_or(0),
546            sound_set_file: get_u16(structure, "SoundSetFile").unwrap_or(0),
547
548            ai_state: get_i32(structure, "AIState").unwrap_or(0),
549            not_reorienting: get_u8(structure, "NotReorienting").unwrap_or(0),
550            detect_mode: get_u8(structure, "DetectMode").unwrap_or(0),
551            stealth_mode: get_u8(structure, "StealthMode").unwrap_or(0),
552            commandable: get_u8(structure, "Commandable").unwrap_or(0),
553            listening: get_u8(structure, "Listening").unwrap_or(0),
554            party_interact: get_u8(structure, "PartyInteract").unwrap_or(0),
555            plot: get_u8(structure, "Plot").unwrap_or(0),
556            min1_hp: get_u8(structure, "Min1HP").unwrap_or(0),
557            interruptable: get_u8(structure, "Interruptable").unwrap_or(0),
558            disarmable: get_u8(structure, "Disarmable").unwrap_or(0),
559            is_destroyable: get_u8(structure, "IsDestroyable").unwrap_or(0),
560            is_raiseable: get_u8(structure, "IsRaiseable").unwrap_or(0),
561            dead_selectable: get_u8(structure, "DeadSelectable").unwrap_or(0),
562            is_pc: get_u8(structure, "IsPC").unwrap_or(0),
563            creation_script_fired: get_u8(structure, "CreatnScrptFird").unwrap_or(0),
564            ambient_anim_state: get_u8(structure, "AmbientAnimState").unwrap_or(0),
565            animation: get_i32(structure, "Animation").unwrap_or(0),
566            creature_size: get_i32(structure, "CreatureSize").unwrap_or(0),
567            movement_rate: get_u8(structure, "MovementRate").unwrap_or(0),
568
569            classes: read_list(structure, "ClassList", SavedClass::from_struct),
570            feats: read_list(structure, "FeatList", |e| get_u16(e, "Feat").unwrap_or(0)),
571            skills: read_skills(structure),
572            inventory: read_list(structure, "ItemList", SavedItem::from_struct),
573            equipment: read_list(structure, "Equip_ItemList", SavedItem::from_struct),
574
575            tail: get_u8(structure, "Tail").unwrap_or(0),
576            wings: get_u8(structure, "Wings").unwrap_or(0),
577
578            on_attacked: get_resref(structure, "ScriptAttacked").unwrap_or_default(),
579            on_damaged: get_resref(structure, "ScriptDamaged").unwrap_or_default(),
580            on_death: get_resref(structure, "ScriptDeath").unwrap_or_default(),
581            on_dialog: get_resref(structure, "ScriptDialogue").unwrap_or_default(),
582            on_disturbed: get_resref(structure, "ScriptDisturbed").unwrap_or_default(),
583            on_end_dialog: get_resref(structure, "ScriptEndDialogu").unwrap_or_default(),
584            on_end_round: get_resref(structure, "ScriptEndRound").unwrap_or_default(),
585            on_heartbeat: get_resref(structure, "ScriptHeartbeat").unwrap_or_default(),
586            on_blocked: get_resref(structure, "ScriptOnBlocked").unwrap_or_default(),
587            on_notice: get_resref(structure, "ScriptOnNotice").unwrap_or_default(),
588            on_rested: get_resref(structure, "ScriptRested").unwrap_or_default(),
589            on_spawn: get_resref(structure, "ScriptSpawn").unwrap_or_default(),
590            on_spell: get_resref(structure, "ScriptSpellAt").unwrap_or_default(),
591            on_user_defined: get_resref(structure, "ScriptUserDefine").unwrap_or_default(),
592        }
593    }
594
595    /// Writes this snapshot into a `Creature List` element.
596    ///
597    /// Emits exactly the fields this type models; the runtime lists and nested
598    /// structs named in the module docs are not written because they are not
599    /// read.
600    pub fn to_struct(&self, index: usize) -> GffStruct {
601        let mut s = GffStruct::new(i32::try_from(index).expect("creature index fits i32"));
602
603        upsert_field(&mut s, "Tag", GffValue::String(self.tag.clone()));
604        upsert_field(
605            &mut s,
606            "FirstName",
607            GffValue::LocalizedString(self.first_name.clone()),
608        );
609        upsert_field(
610            &mut s,
611            "LastName",
612            GffValue::LocalizedString(self.last_name.clone()),
613        );
614        upsert_field(
615            &mut s,
616            "Description",
617            GffValue::LocalizedString(self.description.clone()),
618        );
619        upsert_field(&mut s, "ObjectId", GffValue::UInt32(self.object_id.get()));
620        upsert_field(&mut s, "AreaId", GffValue::UInt32(self.area_id));
621        upsert_field(&mut s, "Conversation", GffValue::ResRef(self.conversation));
622
623        upsert_field(&mut s, "XPosition", GffValue::Single(self.x_position));
624        upsert_field(&mut s, "YPosition", GffValue::Single(self.y_position));
625        upsert_field(&mut s, "ZPosition", GffValue::Single(self.z_position));
626        upsert_field(&mut s, "XOrientation", GffValue::Single(self.x_orientation));
627        upsert_field(&mut s, "YOrientation", GffValue::Single(self.y_orientation));
628        upsert_field(&mut s, "ZOrientation", GffValue::Single(self.z_orientation));
629
630        upsert_field(
631            &mut s,
632            "Appearance_Type",
633            GffValue::UInt16(self.appearance_type),
634        );
635        upsert_field(
636            &mut s,
637            "Appearance_Head",
638            GffValue::UInt8(self.appearance_head),
639        );
640        upsert_field(&mut s, "PortraitId", GffValue::UInt16(self.portrait_id));
641        upsert_field(&mut s, "Phenotype", GffValue::Int32(self.phenotype));
642        upsert_field(&mut s, "Color_Skin", GffValue::UInt8(self.color_skin));
643        upsert_field(&mut s, "Color_Hair", GffValue::UInt8(self.color_hair));
644        upsert_field(&mut s, "Color_Tattoo1", GffValue::UInt8(self.color_tattoo1));
645        upsert_field(&mut s, "Color_Tattoo2", GffValue::UInt8(self.color_tattoo2));
646        upsert_field(
647            &mut s,
648            "DuplicatingHead",
649            GffValue::UInt8(self.duplicating_head),
650        );
651        upsert_field(
652            &mut s,
653            "UseBackupHead",
654            GffValue::UInt8(self.use_backup_head),
655        );
656        upsert_field(&mut s, "PM_IsDisguised", GffValue::UInt8(self.is_disguised));
657        upsert_field(&mut s, "BodyBag", GffValue::UInt8(self.body_bag));
658
659        upsert_field(&mut s, "Str", GffValue::UInt8(self.strength));
660        upsert_field(&mut s, "Dex", GffValue::UInt8(self.dexterity));
661        upsert_field(&mut s, "Con", GffValue::UInt8(self.constitution));
662        upsert_field(&mut s, "Int", GffValue::UInt8(self.intelligence));
663        upsert_field(&mut s, "Wis", GffValue::UInt8(self.wisdom));
664        upsert_field(&mut s, "Cha", GffValue::UInt8(self.charisma));
665
666        upsert_field(
667            &mut s,
668            "CurrentHitPoints",
669            GffValue::Int16(self.current_hit_points),
670        );
671        upsert_field(&mut s, "HitPoints", GffValue::Int16(self.hit_points));
672        upsert_field(&mut s, "MaxHitPoints", GffValue::Int16(self.max_hit_points));
673        upsert_field(
674            &mut s,
675            "PregameCurrent",
676            GffValue::Int16(self.pregame_current),
677        );
678        upsert_field(&mut s, "ForcePoints", GffValue::Int16(self.force_points));
679        upsert_field(&mut s, "CurrentForce", GffValue::Int16(self.current_force));
680        upsert_field(
681            &mut s,
682            "MaxForcePoints",
683            GffValue::Int16(self.max_force_points),
684        );
685
686        upsert_field(&mut s, "ArmorClass", GffValue::Int16(self.armor_class));
687        upsert_field(&mut s, "RefSaveThrow", GffValue::Int8(self.ref_save_throw));
688        upsert_field(
689            &mut s,
690            "WillSaveThrow",
691            GffValue::Int8(self.will_save_throw),
692        );
693        upsert_field(
694            &mut s,
695            "FortSaveThrow",
696            GffValue::Int8(self.fort_save_throw),
697        );
698        upsert_field(&mut s, "refbonus", GffValue::Int16(self.ref_bonus));
699        upsert_field(&mut s, "willbonus", GffValue::Int16(self.will_bonus));
700        upsert_field(&mut s, "fortbonus", GffValue::Int16(self.fort_bonus));
701        upsert_field(&mut s, "NaturalAC", GffValue::UInt8(self.natural_ac));
702
703        upsert_field(&mut s, "Experience", GffValue::UInt32(self.experience));
704        upsert_field(&mut s, "Gold", GffValue::UInt32(self.gold));
705        upsert_field(&mut s, "SkillPoints", GffValue::UInt16(self.skill_points));
706        upsert_field(
707            &mut s,
708            "MClassLevUpIn",
709            GffValue::UInt8(self.multiclass_level_up_in),
710        );
711        upsert_field(&mut s, "JoiningXP", GffValue::Int32(self.joining_xp));
712        upsert_field(
713            &mut s,
714            "StartingPackage",
715            GffValue::UInt8(self.starting_package),
716        );
717        upsert_field(
718            &mut s,
719            "ChallengeRating",
720            GffValue::Single(self.challenge_rating),
721        );
722        upsert_field(&mut s, "Age", GffValue::Int32(self.age));
723
724        upsert_field(&mut s, "Race", GffValue::UInt8(self.race));
725        upsert_field(&mut s, "Subrace", GffValue::String(self.subrace.clone()));
726        upsert_field(&mut s, "SubraceIndex", GffValue::UInt8(self.subrace_index));
727        upsert_field(&mut s, "Deity", GffValue::String(self.deity.clone()));
728        upsert_field(&mut s, "Gender", GffValue::UInt8(self.gender));
729        upsert_field(&mut s, "GoodEvil", GffValue::UInt8(self.good_evil));
730        upsert_field(&mut s, "FactionID", GffValue::UInt16(self.faction_id));
731        upsert_field(
732            &mut s,
733            "SoundSetFile",
734            GffValue::UInt16(self.sound_set_file),
735        );
736
737        upsert_field(&mut s, "AIState", GffValue::Int32(self.ai_state));
738        upsert_field(
739            &mut s,
740            "NotReorienting",
741            GffValue::UInt8(self.not_reorienting),
742        );
743        upsert_field(&mut s, "DetectMode", GffValue::UInt8(self.detect_mode));
744        upsert_field(&mut s, "StealthMode", GffValue::UInt8(self.stealth_mode));
745        upsert_field(&mut s, "Commandable", GffValue::UInt8(self.commandable));
746        upsert_field(&mut s, "Listening", GffValue::UInt8(self.listening));
747        upsert_field(
748            &mut s,
749            "PartyInteract",
750            GffValue::UInt8(self.party_interact),
751        );
752        upsert_field(&mut s, "Plot", GffValue::UInt8(self.plot));
753        upsert_field(&mut s, "Min1HP", GffValue::UInt8(self.min1_hp));
754        upsert_field(&mut s, "Interruptable", GffValue::UInt8(self.interruptable));
755        upsert_field(&mut s, "Disarmable", GffValue::UInt8(self.disarmable));
756        upsert_field(
757            &mut s,
758            "IsDestroyable",
759            GffValue::UInt8(self.is_destroyable),
760        );
761        upsert_field(&mut s, "IsRaiseable", GffValue::UInt8(self.is_raiseable));
762        upsert_field(
763            &mut s,
764            "DeadSelectable",
765            GffValue::UInt8(self.dead_selectable),
766        );
767        upsert_field(&mut s, "IsPC", GffValue::UInt8(self.is_pc));
768        upsert_field(
769            &mut s,
770            "CreatnScrptFird",
771            GffValue::UInt8(self.creation_script_fired),
772        );
773        upsert_field(
774            &mut s,
775            "AmbientAnimState",
776            GffValue::UInt8(self.ambient_anim_state),
777        );
778        upsert_field(&mut s, "Animation", GffValue::Int32(self.animation));
779        upsert_field(&mut s, "CreatureSize", GffValue::Int32(self.creature_size));
780        upsert_field(&mut s, "MovementRate", GffValue::UInt8(self.movement_rate));
781
782        upsert_field(
783            &mut s,
784            "ClassList",
785            GffValue::List(
786                self.classes
787                    .iter()
788                    .enumerate()
789                    .map(|(i, c)| c.to_struct(i))
790                    .collect(),
791            ),
792        );
793        upsert_field(
794            &mut s,
795            "FeatList",
796            GffValue::List(
797                self.feats
798                    .iter()
799                    .enumerate()
800                    .map(|(i, feat)| {
801                        let mut e = GffStruct::new(i32::try_from(i).expect("feat index fits i32"));
802                        upsert_field(&mut e, "Feat", GffValue::UInt16(*feat));
803                        e
804                    })
805                    .collect(),
806            ),
807        );
808        upsert_field(
809            &mut s,
810            "SkillList",
811            GffValue::List(write_skills(&self.skills)),
812        );
813        upsert_field(
814            &mut s,
815            "ItemList",
816            GffValue::List(
817                self.inventory
818                    .iter()
819                    .enumerate()
820                    .map(|(index, item)| item.to_struct(index))
821                    .collect(),
822            ),
823        );
824        upsert_field(
825            &mut s,
826            "Equip_ItemList",
827            GffValue::List(
828                self.equipment
829                    .iter()
830                    .enumerate()
831                    .map(|(index, item)| item.to_struct(index))
832                    .collect(),
833            ),
834        );
835
836        upsert_field(&mut s, "Tail", GffValue::UInt8(self.tail));
837        upsert_field(&mut s, "Wings", GffValue::UInt8(self.wings));
838
839        upsert_field(&mut s, "ScriptAttacked", GffValue::ResRef(self.on_attacked));
840        upsert_field(&mut s, "ScriptDamaged", GffValue::ResRef(self.on_damaged));
841        upsert_field(&mut s, "ScriptDeath", GffValue::ResRef(self.on_death));
842        upsert_field(&mut s, "ScriptDialogue", GffValue::ResRef(self.on_dialog));
843        upsert_field(
844            &mut s,
845            "ScriptDisturbed",
846            GffValue::ResRef(self.on_disturbed),
847        );
848        upsert_field(
849            &mut s,
850            "ScriptEndDialogu",
851            GffValue::ResRef(self.on_end_dialog),
852        );
853        upsert_field(
854            &mut s,
855            "ScriptEndRound",
856            GffValue::ResRef(self.on_end_round),
857        );
858        upsert_field(
859            &mut s,
860            "ScriptHeartbeat",
861            GffValue::ResRef(self.on_heartbeat),
862        );
863        upsert_field(&mut s, "ScriptOnBlocked", GffValue::ResRef(self.on_blocked));
864        upsert_field(&mut s, "ScriptOnNotice", GffValue::ResRef(self.on_notice));
865        upsert_field(&mut s, "ScriptRested", GffValue::ResRef(self.on_rested));
866        upsert_field(&mut s, "ScriptSpawn", GffValue::ResRef(self.on_spawn));
867        upsert_field(&mut s, "ScriptSpellAt", GffValue::ResRef(self.on_spell));
868        upsert_field(
869            &mut s,
870            "ScriptUserDefine",
871            GffValue::ResRef(self.on_user_defined),
872        );
873
874        s
875    }
876}
877
878/// One entry from a saved creature's `ClassList`.
879///
880/// Distinct from [`UtcClass`](crate::utc::UtcClass): a saved class also
881/// carries `SpellsPerDayList`, which a blueprint does not.
882#[derive(Debug, Clone, PartialEq, Eq, Default)]
883pub struct SavedClass {
884    /// Class row (`Class`).
885    pub class_id: i32,
886    /// Level in this class (`ClassLevel`).
887    pub class_level: i16,
888    /// Known power ids from `KnownList0` (`Spell`).
889    pub powers: Vec<u16>,
890    /// Remaining casts per level from `SpellsPerDayList` (`NumSpellsLeft`).
891    pub spells_per_day: Vec<u8>,
892}
893
894impl SavedClass {
895    fn from_struct(structure: &GffStruct) -> Self {
896        Self {
897            class_id: get_i32(structure, "Class").unwrap_or(0),
898            class_level: get_i16(structure, "ClassLevel").unwrap_or(0),
899            powers: read_list(structure, "KnownList0", |e| {
900                get_u16(e, "Spell").unwrap_or(0)
901            }),
902            spells_per_day: read_list(structure, "SpellsPerDayList", |e| {
903                get_u8(e, "NumSpellsLeft").unwrap_or(0)
904            }),
905        }
906    }
907
908    fn to_struct(&self, index: usize) -> GffStruct {
909        let mut s = GffStruct::new(i32::try_from(index).expect("class index fits i32"));
910        upsert_field(&mut s, "Class", GffValue::Int32(self.class_id));
911        upsert_field(&mut s, "ClassLevel", GffValue::Int16(self.class_level));
912        upsert_field(
913            &mut s,
914            "KnownList0",
915            GffValue::List(
916                self.powers
917                    .iter()
918                    .enumerate()
919                    .map(|(i, spell)| {
920                        let mut e = GffStruct::new(i32::try_from(i).expect("power index fits i32"));
921                        upsert_field(&mut e, "Spell", GffValue::UInt16(*spell));
922                        e
923                    })
924                    .collect(),
925            ),
926        );
927        upsert_field(
928            &mut s,
929            "SpellsPerDayList",
930            GffValue::List(
931                self.spells_per_day
932                    .iter()
933                    .enumerate()
934                    .map(|(i, left)| {
935                        let mut e = GffStruct::new(i32::try_from(i).expect("spell level fits i32"));
936                        upsert_field(&mut e, "NumSpellsLeft", GffValue::UInt8(*left));
937                        e
938                    })
939                    .collect(),
940            ),
941        );
942        s
943    }
944}
945
946/// Maps each element of a list field through `build`, or yields nothing when
947/// the field is absent or not a list.
948fn read_list<T>(structure: &GffStruct, label: &str, build: impl Fn(&GffStruct) -> T) -> Vec<T> {
949    match structure.field(label) {
950        Some(GffValue::List(entries)) => entries.iter().map(build).collect(),
951        _ => Vec::new(),
952    }
953}
954
955/// Reads `SkillList`, which the engine stores positionally: one `Rank` per
956/// skill row, in `skills.2da` order.
957fn read_skills(structure: &GffStruct) -> UtcSkills {
958    let ranks = read_list(structure, "SkillList", |e| get_u8(e, "Rank").unwrap_or(0));
959    let at = |index: usize| ranks.get(index).copied().unwrap_or(0);
960    UtcSkills {
961        computer_use: at(0),
962        demolitions: at(1),
963        stealth: at(2),
964        awareness: at(3),
965        persuade: at(4),
966        repair: at(5),
967        security: at(6),
968        treat_injury: at(7),
969    }
970}
971
972/// Writes the eight skill ranks back in engine order.
973fn write_skills(skills: &UtcSkills) -> Vec<GffStruct> {
974    [
975        skills.computer_use,
976        skills.demolitions,
977        skills.stealth,
978        skills.awareness,
979        skills.persuade,
980        skills.repair,
981        skills.security,
982        skills.treat_injury,
983    ]
984    .iter()
985    .enumerate()
986    .map(|(index, rank)| {
987        let mut e = GffStruct::new(i32::try_from(index).expect("skill index fits i32"));
988        upsert_field(&mut e, "Rank", GffValue::UInt8(*rank));
989        e
990    })
991    .collect()
992}
993
994#[cfg(test)]
995mod tests {
996    use super::*;
997
998    #[test]
999    fn round_trips_through_a_gff_struct() {
1000        let mut creature = SavedCreature::new();
1001        creature.tag = "bastila".to_string();
1002        creature.current_hit_points = 42;
1003        creature.gold = 1250;
1004        creature.good_evil = 75;
1005        creature.ref_bonus = -3;
1006        creature.on_death = ResRef::new("k_hen_death").expect("valid resref");
1007        creature.conversation = ResRef::new("k_hen_dlg").expect("valid resref");
1008
1009        let parsed = SavedCreature::from_struct(&creature.to_struct(0));
1010
1011        assert_eq!(parsed, creature);
1012    }
1013
1014    #[test]
1015    fn an_absent_field_falls_back_to_the_engine_default() {
1016        // A UseTemplates = 0 element that omits a field gets the engine's
1017        // hardcoded default, not a blueprint value.
1018        let empty = SavedCreature::from_struct(&GffStruct::new(0));
1019
1020        assert_eq!(empty.current_hit_points, 0);
1021        assert_eq!(empty.gold, 0);
1022        assert!(empty.tag.is_empty());
1023    }
1024
1025    #[test]
1026    fn progression_lists_round_trip() {
1027        let mut creature = SavedCreature::new();
1028        creature.classes = vec![SavedClass {
1029            class_id: 3,
1030            class_level: 7,
1031            powers: vec![10, 22, 41],
1032            spells_per_day: vec![0, 2, 1],
1033        }];
1034        creature.feats = vec![1, 5, 9, 44];
1035        creature.skills.stealth = 6;
1036        creature.skills.treat_injury = 12;
1037
1038        let parsed = SavedCreature::from_struct(&creature.to_struct(0));
1039
1040        assert_eq!(parsed.classes, creature.classes);
1041        assert_eq!(parsed.feats, creature.feats);
1042        assert_eq!(parsed.skills, creature.skills);
1043    }
1044
1045    #[test]
1046    fn skill_ranks_keep_their_engine_order() {
1047        // SkillList is positional: rank N belongs to skills.2da row N.
1048        let mut creature = SavedCreature::new();
1049        creature.skills.computer_use = 1;
1050        creature.skills.persuade = 4;
1051
1052        let written = creature.to_struct(0);
1053        let GffValue::List(entries) = written.field("SkillList").expect("written") else {
1054            panic!("SkillList should be a list");
1055        };
1056
1057        assert_eq!(entries.len(), 8);
1058        assert_eq!(get_u8(&entries[0], "Rank"), Some(1));
1059        assert_eq!(get_u8(&entries[4], "Rank"), Some(4));
1060    }
1061
1062    #[test]
1063    fn a_class_without_powers_still_round_trips() {
1064        let mut creature = SavedCreature::new();
1065        creature.classes = vec![SavedClass {
1066            class_id: 1,
1067            class_level: 2,
1068            ..SavedClass::default()
1069        }];
1070
1071        let parsed = SavedCreature::from_struct(&creature.to_struct(0));
1072
1073        assert_eq!(parsed.classes[0].class_id, 1);
1074        assert!(parsed.classes[0].powers.is_empty());
1075        assert!(parsed.classes[0].spells_per_day.is_empty());
1076    }
1077
1078    #[test]
1079    fn a_default_snapshot_writes_every_script_hook() {
1080        // The positional Vec this replaced wrote nothing by default and
1081        // truncated silently if it were short.
1082        let written = SavedCreature::default().to_struct(0);
1083
1084        for label in [
1085            "ScriptAttacked",
1086            "ScriptDamaged",
1087            "ScriptDeath",
1088            "ScriptDialogue",
1089            "ScriptDisturbed",
1090            "ScriptEndDialogu",
1091            "ScriptEndRound",
1092            "ScriptHeartbeat",
1093            "ScriptOnBlocked",
1094            "ScriptOnNotice",
1095            "ScriptRested",
1096            "ScriptSpawn",
1097            "ScriptSpellAt",
1098            "ScriptUserDefine",
1099            "Conversation",
1100        ] {
1101            assert!(written.field(label).is_some(), "{label} was not written");
1102        }
1103    }
1104
1105    #[test]
1106    fn negative_save_bonuses_survive() {
1107        // refbonus / willbonus / fortbonus are signed inputs the engine reads
1108        // back, unlike the computed totals beside them.
1109        let mut creature = SavedCreature::new();
1110        creature.ref_bonus = -5;
1111        creature.will_bonus = -1;
1112        creature.fort_save_throw = -2;
1113
1114        let parsed = SavedCreature::from_struct(&creature.to_struct(0));
1115
1116        assert_eq!(parsed.ref_bonus, -5);
1117        assert_eq!(parsed.will_bonus, -1);
1118        assert_eq!(parsed.fort_save_throw, -2);
1119    }
1120}