Skip to main content

rakata_generics/git/
creature.rs

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