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}