Skip to main content

rakata_generics/
dlg.rs

1//! DLG (`.dlg`) typed generic wrapper.
2//!
3//! DLG resources are GFF-backed dialogue trees. The structure is a directed
4//! graph of entry nodes (NPC lines) and reply nodes (PC choices), connected
5//! by indexed links.
6//!
7//! ## Scope
8//! - Typed access for conversation config, stunt list, and all node fields.
9//! - Typed entry/reply node arrays with per-node text, scripts, camera, and
10//!   animation data.
11//! - Typed links (condition script + target index + display-inactive flag).
12//! - Full serialization from scratch - all modeled fields are written
13//!   explicitly without relying on source struct passthrough.
14//!
15//! ## Field Layout (simplified)
16//! ```text
17//! DLG root struct
18//! +-- CameraModel / DelayEntry / DelayReply / Skippable / ConversationType
19//! +-- EndConversation / EndConverAbort / ComputerType / AmbientTrack
20//! +-- UnequipItems / UnequipHItem / AnimatedCut / OldHitCheck
21//! +-- StartingList[]  (links -> EntryList by index)
22//! +-- EntryList[]     (NPC lines, each with RepliesList[])
23//! +-- ReplyList[]     (PC choices, each with EntriesList[])
24//! `-- StuntList[]     (cutscene actor models)
25//! ```
26//!
27//! ## Fields vanilla writes that nothing reads
28//!
29//! A corpus run flags five labels present in real `.dlg` files that no view
30//! writes, and they are all authoring metadata the engine never looks at:
31//!
32//! | field | where |
33//! |---|---|
34//! | `NumWords` | most entries and replies |
35//! | `VO_ID` | most nodes |
36//! | `IsChild` | links |
37//! | `Comment` | nodes |
38//! | `LinkComment` | links |
39//!
40//! None of the five exists as a string literal anywhere in the executable,
41//! so no field read can reach them on any code path. That is a stronger
42//! claim than "the loader we traced ignores them": there is no loader that
43//! could. The engine also contains no word-counting logic, which rules out
44//! `NumWords` being recomputed on save rather than read.
45//!
46//! They stay unmodelled deliberately, per the projection rule. This note
47//! exists so the next corpus run does not buy the same answer twice.
48//!
49//! One loose end: `IsChild` and `LinkComment` co-occur on links, and why
50//! they travel together is not answerable from the engine, since neither is
51//! reachable from it. It is a question about the authoring toolchain rather
52//! than the runtime.
53
54use std::io::{Cursor, Read, Write};
55
56use crate::gff_helpers::{
57    get_bool, get_f32, get_i32, get_locstring, get_resref, get_string, get_u16, get_u32, get_u8,
58    upsert_field,
59};
60use rakata_core::{ResRef, StrRef};
61use rakata_formats::{
62    gff_schema::{AbsentDefault, FieldLife, FieldSchema, GffSchema, GffType},
63    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffStruct,
64    GffValue,
65};
66use thiserror::Error;
67
68/// A link between dialogue nodes (condition + target index).
69#[derive(Debug, Clone, PartialEq, Default)]
70pub struct DlgLink {
71    /// Condition script (`Active`). Empty resref means always active.
72    pub active: ResRef,
73    /// Target node index (`Index`) in the corresponding node array. Exceeding bounds causes fatal engine load failure.
74    pub index: u32,
75    /// Display-inactive flag (`DisplayInactive`). Only meaningful on
76    /// entry->reply links; defaults to false for reply->entry links.
77    ///
78    /// Set, a reply whose `Active` condition fails is still shown to the
79    /// player as a disabled option; clear, it is dropped from the list. No
80    /// vanilla dialogue sets it, so shipped content always takes the drop
81    /// branch, but it gates behaviour rather than being inert, so it is a
82    /// capability a mod can reach for.
83    pub display_inactive: bool,
84}
85
86impl DlgLink {
87    fn from_gff_struct(s: &GffStruct) -> Self {
88        Self {
89            active: get_resref(s, "Active").unwrap_or_default(),
90            index: get_u32(s, "Index").unwrap_or(0),
91            display_inactive: get_bool(s, "DisplayInactive").unwrap_or(false),
92        }
93    }
94
95    fn to_gff_struct(&self, include_display_inactive: bool) -> GffStruct {
96        let mut s = GffStruct::new(0);
97        upsert_field(&mut s, "Active", GffValue::ResRef(self.active));
98        upsert_field(&mut s, "Index", GffValue::UInt32(self.index));
99        // Omitted at its default rather than always written. The engine's
100        // absent-value here is an audited constant of 0 and our default
101        // matches it, so leaving the field out reproduces what vanilla
102        // writes, which is nothing. Anything else has to be written, since
103        // an absent field would read back as the constant and lose it.
104        //
105        // This is the general write policy, not a rule for this field: omit
106        // only where the absent-value is an audited constant *and* the value
107        // equals it. A default that is unverified, or one the engine carries
108        // over from prior state, must always be written.
109        if include_display_inactive && self.display_inactive {
110            upsert_field(
111                &mut s,
112                "DisplayInactive",
113                GffValue::UInt8(u8::from(self.display_inactive)),
114            );
115        }
116        s
117    }
118}
119
120/// A per-node animation entry.
121#[derive(Debug, Clone, PartialEq)]
122pub struct DlgAnimation {
123    /// Participant tag (`Participant`).
124    pub participant: String,
125    /// Animation ID (`Animation`).
126    pub animation: u16,
127}
128
129impl DlgAnimation {
130    fn from_gff_struct(s: &GffStruct) -> Self {
131        Self {
132            participant: get_string(s, "Participant").unwrap_or_default(),
133            animation: get_u16(s, "Animation").unwrap_or(0),
134        }
135    }
136
137    fn to_gff_struct(&self) -> GffStruct {
138        let mut s = GffStruct::new(0);
139        upsert_field(
140            &mut s,
141            "Participant",
142            GffValue::String(self.participant.clone()),
143        );
144        upsert_field(&mut s, "Animation", GffValue::UInt16(self.animation));
145        s
146    }
147}
148
149/// A cutscene stunt actor entry.
150#[derive(Debug, Clone, PartialEq)]
151pub struct DlgStunt {
152    /// Participant tag (`Participant`).
153    pub participant: String,
154    /// Stunt model resref (`StuntModel`).
155    pub stunt_model: ResRef,
156}
157
158impl DlgStunt {
159    fn from_gff_struct(s: &GffStruct) -> Self {
160        Self {
161            participant: get_string(s, "Participant").unwrap_or_default(),
162            stunt_model: get_resref(s, "StuntModel").unwrap_or_default(),
163        }
164    }
165
166    fn to_gff_struct(&self) -> GffStruct {
167        let mut s = GffStruct::new(0);
168        upsert_field(
169            &mut s,
170            "Participant",
171            GffValue::String(self.participant.clone()),
172        );
173        upsert_field(&mut s, "StuntModel", GffValue::ResRef(self.stunt_model));
174        s
175    }
176}
177
178/// A dialogue node (entry = NPC line, reply = PC choice).
179///
180/// Entry and reply nodes share the same field layout. The difference is which
181/// link list they carry: entry nodes have `RepliesList`, reply nodes have
182/// `EntriesList`. The [`Dlg`] struct handles serializing the correct field
183/// name based on position.
184#[derive(Debug, Clone, PartialEq)]
185pub struct DlgNode {
186    // --- DialogBase fields ---
187    /// Localized display text (`Text`).
188    pub text: GffLocalizedString,
189    /// Script to run when this node fires (`Script`).
190    pub script: ResRef,
191    /// Speaker tag override (`Speaker`).
192    pub speaker: String,
193    /// Wait flags (`WaitFlags`).
194    pub wait_flags: u32,
195    /// Journal quest tag (`Quest`).
196    pub quest: String,
197    /// Journal quest entry ID (`QuestEntry`).
198    pub quest_entry: u32,
199    /// Plot index (`PlotIndex`).
200    pub plot_index: i32,
201    /// Plot XP percentage (`PlotXPPercentage`).
202    pub plot_xp_percentage: f32,
203    /// Delay in milliseconds (`Delay`). The sentinel value `0xFFFFFFFF` instructs the engine to read from the root `delay_entry` / `delay_reply` instead and modulate `wait_flags`. With no sound and parent delay 0, the node terminates instantly.
204    pub delay: u32,
205    /// Fade type (`FadeType`).
206    pub fade_type: u8,
207    /// Fade color as RGB floats (`FadeColor`). GFF Vector3 type. Discarded if `FadeType` is 0.
208    pub fade_color: [f32; 3],
209    /// Fade delay in seconds (`FadeDelay`). Discarded if `FadeType` is 0.
210    pub fade_delay: f32,
211    /// Fade length in seconds (`FadeLength`). Discarded if `FadeType` is 0.
212    pub fade_length: f32,
213    /// Sound resref (`Sound`). If playback fails, the engine falls back to `vo_resref`.
214    pub sound: ResRef,
215    /// Voice-over resref (`VO_ResRef`). Played as a fallback when `sound` fails to execute.
216    pub vo_resref: ResRef,
217    /// Sound-exists flag (`SoundExists`). The engine forcibly downgrades this to `false` if both `sound` and `vo_resref` fail to play.
218    pub sound_exists: bool,
219    /// Per-node animations (`AnimList`).
220    pub animations: Vec<DlgAnimation>,
221
222    // --- Camera fields ---
223    /// Listener tag override (`Listener`).
224    pub listener: String,
225    /// Camera angle preset (`CameraAngle`).
226    pub camera_angle: u32,
227    /// Static camera ID (`CameraID`). Ignored by engine unless `CameraAngle` is 6.
228    pub camera_id: i32,
229    /// Camera height offset (`CamHeightOffset`).
230    pub cam_height_offset: f32,
231    /// Target height offset (`TarHeightOffset`).
232    pub tar_height_offset: f32,
233    /// Camera animation ID (`CameraAnimation`).
234    pub camera_animation: u16,
235    /// Camera video effect (`CamVidEffect`).
236    pub cam_vid_effect: i32,
237    /// Camera field of view (`CamFieldOfView`). Aggressively validated; the engine forces this to `-1.0` if the field is missing or explicitly negative.
238    pub cam_field_of_view: f32,
239
240    // --- Links to other nodes ---
241    /// Links to the next nodes. For entry nodes these point into `ReplyList`;
242    /// for reply nodes they point into `EntryList`.
243    pub links: Vec<DlgLink>,
244}
245
246impl Default for DlgNode {
247    fn default() -> Self {
248        Self {
249            text: GffLocalizedString::new(StrRef::invalid()),
250            script: ResRef::blank(),
251            speaker: String::new(),
252            wait_flags: 0,
253            quest: String::new(),
254            quest_entry: 0,
255            plot_index: -1,
256            plot_xp_percentage: 1.0,
257            delay: u32::MAX,
258            fade_type: 0,
259            fade_color: [0.0, 0.0, 0.0],
260            fade_delay: 0.0,
261            fade_length: 0.0,
262            sound: ResRef::blank(),
263            vo_resref: ResRef::blank(),
264            sound_exists: false,
265            animations: Vec::new(),
266            listener: String::new(),
267            camera_angle: 0,
268            camera_id: -1,
269            cam_height_offset: 0.0,
270            tar_height_offset: 0.0,
271            camera_animation: 0,
272            cam_vid_effect: -1,
273            cam_field_of_view: -1.0,
274            links: Vec::new(),
275        }
276    }
277}
278
279impl DlgNode {
280    fn from_gff_struct(s: &GffStruct, link_field: &str) -> Self {
281        let animations = match s.field("AnimList") {
282            Some(GffValue::List(elements)) => {
283                elements.iter().map(DlgAnimation::from_gff_struct).collect()
284            }
285            _ => Vec::new(),
286        };
287
288        let links = match s.field(link_field) {
289            Some(GffValue::List(elements)) => {
290                elements.iter().map(DlgLink::from_gff_struct).collect()
291            }
292            _ => Vec::new(),
293        };
294
295        let fade_color = match s.field("FadeColor") {
296            Some(GffValue::Vector3(v)) => *v,
297            _ => [0.0, 0.0, 0.0],
298        };
299
300        Self {
301            text: get_locstring(s, "Text")
302                .cloned()
303                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
304            script: get_resref(s, "Script").unwrap_or_default(),
305            speaker: get_string(s, "Speaker").unwrap_or_default(),
306            wait_flags: get_u32(s, "WaitFlags").unwrap_or(0),
307            quest: get_string(s, "Quest").unwrap_or_default(),
308            quest_entry: get_u32(s, "QuestEntry").unwrap_or(0),
309            plot_index: get_i32(s, "PlotIndex").unwrap_or(-1),
310            plot_xp_percentage: get_f32(s, "PlotXPPercentage").unwrap_or(1.0),
311            delay: get_u32(s, "Delay").unwrap_or(u32::MAX),
312            fade_type: get_u8(s, "FadeType").unwrap_or(0),
313            fade_color,
314            fade_delay: get_f32(s, "FadeDelay").unwrap_or(0.0),
315            fade_length: get_f32(s, "FadeLength").unwrap_or(0.0),
316            sound: get_resref(s, "Sound").unwrap_or_default(),
317            vo_resref: get_resref(s, "VO_ResRef").unwrap_or_default(),
318            sound_exists: get_bool(s, "SoundExists").unwrap_or(false),
319            animations,
320            listener: get_string(s, "Listener").unwrap_or_default(),
321            camera_angle: get_u32(s, "CameraAngle").unwrap_or(0),
322            camera_id: get_i32(s, "CameraID").unwrap_or(-1),
323            cam_height_offset: get_f32(s, "CamHeightOffset").unwrap_or(0.0),
324            tar_height_offset: get_f32(s, "TarHeightOffset").unwrap_or(0.0),
325            camera_animation: get_u16(s, "CameraAnimation").unwrap_or(0),
326            cam_vid_effect: get_i32(s, "CamVidEffect").unwrap_or(-1),
327            cam_field_of_view: get_f32(s, "CamFieldOfView").unwrap_or(-1.0),
328            links,
329        }
330    }
331
332    fn to_gff_struct(
333        &self,
334        link_field: &str,
335        include_display_inactive: bool,
336        struct_id: i32,
337    ) -> GffStruct {
338        let mut s = GffStruct::new(struct_id);
339
340        upsert_field(&mut s, "Text", GffValue::LocalizedString(self.text.clone()));
341        upsert_field(&mut s, "Script", GffValue::ResRef(self.script));
342        upsert_field(&mut s, "Speaker", GffValue::String(self.speaker.clone()));
343        upsert_field(&mut s, "WaitFlags", GffValue::UInt32(self.wait_flags));
344        upsert_field(&mut s, "Quest", GffValue::String(self.quest.clone()));
345        upsert_field(&mut s, "QuestEntry", GffValue::UInt32(self.quest_entry));
346        upsert_field(&mut s, "PlotIndex", GffValue::Int32(self.plot_index));
347        upsert_field(
348            &mut s,
349            "PlotXPPercentage",
350            GffValue::Single(self.plot_xp_percentage),
351        );
352        upsert_field(&mut s, "Delay", GffValue::UInt32(self.delay));
353        upsert_field(&mut s, "FadeType", GffValue::UInt8(self.fade_type));
354        upsert_field(&mut s, "FadeColor", GffValue::Vector3(self.fade_color));
355        upsert_field(&mut s, "FadeDelay", GffValue::Single(self.fade_delay));
356        upsert_field(&mut s, "FadeLength", GffValue::Single(self.fade_length));
357        upsert_field(&mut s, "Sound", GffValue::ResRef(self.sound));
358        upsert_field(&mut s, "VO_ResRef", GffValue::ResRef(self.vo_resref));
359        upsert_field(
360            &mut s,
361            "SoundExists",
362            GffValue::UInt8(u8::from(self.sound_exists)),
363        );
364
365        let anim_structs: Vec<GffStruct> =
366            self.animations.iter().map(|a| a.to_gff_struct()).collect();
367        upsert_field(&mut s, "AnimList", GffValue::List(anim_structs));
368
369        upsert_field(&mut s, "Listener", GffValue::String(self.listener.clone()));
370        upsert_field(&mut s, "CameraAngle", GffValue::UInt32(self.camera_angle));
371        upsert_field(&mut s, "CameraID", GffValue::Int32(self.camera_id));
372        upsert_field(
373            &mut s,
374            "CamHeightOffset",
375            GffValue::Single(self.cam_height_offset),
376        );
377        upsert_field(
378            &mut s,
379            "TarHeightOffset",
380            GffValue::Single(self.tar_height_offset),
381        );
382        upsert_field(
383            &mut s,
384            "CameraAnimation",
385            GffValue::UInt16(self.camera_animation),
386        );
387        upsert_field(&mut s, "CamVidEffect", GffValue::Int32(self.cam_vid_effect));
388        upsert_field(
389            &mut s,
390            "CamFieldOfView",
391            GffValue::Single(self.cam_field_of_view),
392        );
393
394        let link_structs: Vec<GffStruct> = self
395            .links
396            .iter()
397            .map(|l| l.to_gff_struct(include_display_inactive))
398            .collect();
399        upsert_field(&mut s, link_field, GffValue::List(link_structs));
400
401        s
402    }
403}
404
405/// Typed DLG model built from/to [`Gff`] data.
406#[derive(Debug, Clone, PartialEq)]
407pub struct Dlg {
408    // --- Conversation config ---
409    /// Camera model resref (`CameraModel`).
410    pub camera_model: ResRef,
411    /// Delay before entry lines in ms (`DelayEntry`). If missing, safely defaults to 0.
412    pub delay_entry: u32,
413    /// Delay before reply lines in ms (`DelayReply`). If missing, safely defaults to 0.
414    pub delay_reply: u32,
415    /// End-conversation script (`EndConversation`). Fallback to empty string `""` if missing.
416    pub end_conversation: ResRef,
417    /// End-conversation-abort script (`EndConverAbort`). Fallback to empty string `""` if missing.
418    pub end_conver_abort: ResRef,
419    /// Skippable flag (`Skippable`). Explicitly defaults to `1` (True) if missing.
420    pub skippable: bool,
421    /// Conversation type (`ConversationType`). `0` = Cinematic, `1` = Computer, `2` = Special. The Cinematic variant explicitly unstealths the entire party at runtime.
422    pub conversation_type: i32,
423    /// Computer type (`ComputerType`). Ignored by engine unless `ConversationType` is 1.
424    pub computer_type: u8,
425    /// Ambient music track (`AmbientTrack`). Fallback to empty string `""` if missing.
426    pub ambient_track: ResRef,
427    /// Unequip-items flag (`UnequipItems`).
428    pub unequip_items: bool,
429    /// Unequip-head-item flag (`UnequipHItem`).
430    pub unequip_h_item: bool,
431    /// Animated-cutscene flag (`AnimatedCut`). When non-zero, the engine forces a global unpauseable state for the duration of the conversation.
432    pub animated_cut: bool,
433    /// Old-hit-check flag (`OldHitCheck`).
434    pub old_hit_check: bool,
435
436    // --- Node arrays ---
437    /// Starting links (point into `entries` by index).
438    pub starting_list: Vec<DlgLink>,
439    /// Entry nodes (NPC dialogue lines).
440    pub entries: Vec<DlgNode>,
441    /// Reply nodes (PC dialogue choices).
442    pub replies: Vec<DlgNode>,
443    /// Cutscene stunt actors.
444    pub stunt_list: Vec<DlgStunt>,
445}
446
447impl Default for Dlg {
448    fn default() -> Self {
449        Self {
450            camera_model: ResRef::blank(),
451            delay_entry: 0,
452            delay_reply: 0,
453            end_conversation: ResRef::blank(),
454            end_conver_abort: ResRef::blank(),
455            skippable: true,
456            conversation_type: 0,
457            computer_type: 0,
458            ambient_track: ResRef::blank(),
459            unequip_items: false,
460            unequip_h_item: false,
461            animated_cut: false,
462            old_hit_check: false,
463            starting_list: Vec::new(),
464            entries: Vec::new(),
465            replies: Vec::new(),
466            stunt_list: Vec::new(),
467        }
468    }
469}
470
471impl Dlg {
472    /// Creates an empty DLG value.
473    pub fn new() -> Self {
474        Self::default()
475    }
476
477    /// Builds typed DLG data from a parsed GFF container.
478    pub fn from_gff(gff: &Gff) -> Result<Self, DlgError> {
479        if gff.file_type != *b"DLG " && gff.file_type != *b"GFF " {
480            return Err(DlgError::UnsupportedFileType(gff.file_type));
481        }
482
483        let root = &gff.root;
484
485        let starting_list = match root.field("StartingList") {
486            Some(GffValue::List(elements)) => {
487                elements.iter().map(DlgLink::from_gff_struct).collect()
488            }
489            _ => Vec::new(),
490        };
491
492        let entries = match root.field("EntryList") {
493            Some(GffValue::List(elements)) => elements
494                .iter()
495                .map(|s| DlgNode::from_gff_struct(s, "RepliesList"))
496                .collect(),
497            _ => Vec::new(),
498        };
499
500        let replies = match root.field("ReplyList") {
501            Some(GffValue::List(elements)) => elements
502                .iter()
503                .map(|s| DlgNode::from_gff_struct(s, "EntriesList"))
504                .collect(),
505            _ => Vec::new(),
506        };
507
508        let stunt_list = match root.field("StuntList") {
509            Some(GffValue::List(elements)) => {
510                elements.iter().map(DlgStunt::from_gff_struct).collect()
511            }
512            _ => Vec::new(),
513        };
514
515        Ok(Self {
516            camera_model: get_resref(root, "CameraModel").unwrap_or_default(),
517            delay_entry: get_u32(root, "DelayEntry").unwrap_or(0),
518            delay_reply: get_u32(root, "DelayReply").unwrap_or(0),
519            end_conversation: get_resref(root, "EndConversation").unwrap_or_default(),
520            end_conver_abort: get_resref(root, "EndConverAbort").unwrap_or_default(),
521            skippable: get_bool(root, "Skippable").unwrap_or(true),
522            conversation_type: get_i32(root, "ConversationType").unwrap_or(0),
523            computer_type: get_u8(root, "ComputerType").unwrap_or(0),
524            ambient_track: get_resref(root, "AmbientTrack").unwrap_or_default(),
525            unequip_items: get_bool(root, "UnequipItems").unwrap_or(false),
526            unequip_h_item: get_bool(root, "UnequipHItem").unwrap_or(false),
527            animated_cut: get_bool(root, "AnimatedCut").unwrap_or(false),
528            old_hit_check: get_bool(root, "OldHitCheck").unwrap_or(false),
529            starting_list,
530            entries,
531            replies,
532            stunt_list,
533        })
534    }
535
536    /// Converts this typed DLG value into a GFF container.
537    ///
538    /// All modeled fields are written explicitly from the typed representation.
539    pub fn to_gff(&self) -> Gff {
540        let mut root = GffStruct::new(-1);
541
542        upsert_field(
543            &mut root,
544            "CameraModel",
545            GffValue::ResRef(self.camera_model),
546        );
547        upsert_field(&mut root, "DelayEntry", GffValue::UInt32(self.delay_entry));
548        upsert_field(&mut root, "DelayReply", GffValue::UInt32(self.delay_reply));
549        upsert_field(
550            &mut root,
551            "EndConversation",
552            GffValue::ResRef(self.end_conversation),
553        );
554        upsert_field(
555            &mut root,
556            "EndConverAbort",
557            GffValue::ResRef(self.end_conver_abort),
558        );
559        upsert_field(
560            &mut root,
561            "Skippable",
562            GffValue::UInt8(u8::from(self.skippable)),
563        );
564        upsert_field(
565            &mut root,
566            "ConversationType",
567            GffValue::Int32(self.conversation_type),
568        );
569        upsert_field(
570            &mut root,
571            "ComputerType",
572            GffValue::UInt8(self.computer_type),
573        );
574        upsert_field(
575            &mut root,
576            "AmbientTrack",
577            GffValue::ResRef(self.ambient_track),
578        );
579        upsert_field(
580            &mut root,
581            "UnequipItems",
582            GffValue::UInt8(u8::from(self.unequip_items)),
583        );
584        upsert_field(
585            &mut root,
586            "UnequipHItem",
587            GffValue::UInt8(u8::from(self.unequip_h_item)),
588        );
589        upsert_field(
590            &mut root,
591            "AnimatedCut",
592            GffValue::UInt8(u8::from(self.animated_cut)),
593        );
594        upsert_field(
595            &mut root,
596            "OldHitCheck",
597            GffValue::UInt8(u8::from(self.old_hit_check)),
598        );
599
600        // Starting list (reply->entry link schema, no DisplayInactive).
601        let start_structs: Vec<GffStruct> = self
602            .starting_list
603            .iter()
604            .map(|l| l.to_gff_struct(false))
605            .collect();
606        upsert_field(&mut root, "StartingList", GffValue::List(start_structs));
607
608        // Entry nodes (struct_id=0, links go to RepliesList with DisplayInactive).
609        let entry_structs: Vec<GffStruct> = self
610            .entries
611            .iter()
612            .map(|n| n.to_gff_struct("RepliesList", true, 0))
613            .collect();
614        upsert_field(&mut root, "EntryList", GffValue::List(entry_structs));
615
616        // Reply nodes (struct_id=1, links go to EntriesList without DisplayInactive).
617        let reply_structs: Vec<GffStruct> = self
618            .replies
619            .iter()
620            .map(|n| n.to_gff_struct("EntriesList", false, 1))
621            .collect();
622        upsert_field(&mut root, "ReplyList", GffValue::List(reply_structs));
623
624        // Stunt list.
625        let stunt_structs: Vec<GffStruct> = self
626            .stunt_list
627            .iter()
628            .map(|st| st.to_gff_struct())
629            .collect();
630        upsert_field(&mut root, "StuntList", GffValue::List(stunt_structs));
631
632        Gff::new(*b"DLG ", root)
633    }
634}
635
636/// Errors produced while reading or writing typed DLG data.
637#[derive(Debug, Error)]
638pub enum DlgError {
639    /// Source file type is not supported by this parser.
640    #[error("unsupported DLG file type: {0:?}")]
641    UnsupportedFileType([u8; 4]),
642    /// Underlying GFF parser/writer error.
643    #[error(transparent)]
644    Gff(#[from] GffBinaryError),
645}
646
647/// Reads typed DLG data from a reader at the current stream position.
648#[cfg_attr(
649    feature = "tracing",
650    tracing::instrument(level = "debug", skip(reader))
651)]
652pub fn read_dlg<R: Read>(reader: &mut R) -> Result<Dlg, DlgError> {
653    let gff = read_gff(reader)?;
654    Dlg::from_gff(&gff)
655}
656
657/// Reads typed DLG data directly from bytes.
658#[cfg_attr(
659    feature = "tracing",
660    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
661)]
662pub fn read_dlg_from_bytes(bytes: &[u8]) -> Result<Dlg, DlgError> {
663    let gff = read_gff_from_bytes(bytes)?;
664    Dlg::from_gff(&gff)
665}
666
667/// Writes typed DLG data to an output writer.
668#[cfg_attr(
669    feature = "tracing",
670    tracing::instrument(level = "debug", skip(writer, dlg))
671)]
672pub fn write_dlg<W: Write>(writer: &mut W, dlg: &Dlg) -> Result<(), DlgError> {
673    let gff = dlg.to_gff();
674    write_gff(writer, &gff)?;
675    Ok(())
676}
677
678/// Serializes typed DLG data into a byte vector.
679#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(dlg)))]
680pub fn write_dlg_to_vec(dlg: &Dlg) -> Result<Vec<u8>, DlgError> {
681    let mut cursor = Cursor::new(Vec::new());
682    write_dlg(&mut cursor, dlg)?;
683    Ok(cursor.into_inner())
684}
685
686// =========================================================================
687// Leaf sub-schemas (no nested children)
688// =========================================================================
689
690/// Entry->Reply link children (`RepliesList` entries within each EntryList node).
691static ENTRY_REPLY_LINK_CHILDREN: &[FieldSchema] = &[
692    // Dead: the label strings do not exist in the compiled engine, so no
693    // read path can name them. See dlg.md's "Fields the Loader Never Reads".
694    FieldSchema {
695        label: "IsChild",
696        expected_type: GffType::UInt8,
697        life: FieldLife::ReadOnlyDead(
698            "the label string does not exist anywhere in the engine binary, so no read path can name it",
699        ),
700        required: false,
701        absent: AbsentDefault::Unverified,
702        children: None,
703        constraint: None,
704    },
705    FieldSchema {
706        label: "LinkComment",
707        expected_type: GffType::String,
708        life: FieldLife::ReadOnlyDead(
709            "the label string does not exist anywhere in the engine binary, so no read path can name it",
710        ),
711        required: false,
712        absent: AbsentDefault::Unverified,
713        children: None,
714        constraint: None,
715    },
716    FieldSchema {
717        label: "Active",
718        expected_type: GffType::ResRef,
719        life: FieldLife::Live,
720        required: false,
721        absent: AbsentDefault::Unverified,
722        children: None,
723        constraint: None,
724    },
725    FieldSchema {
726        label: "Index",
727        expected_type: GffType::UInt32,
728        life: FieldLife::Live,
729        required: false,
730        absent: AbsentDefault::Unverified,
731        children: None,
732        constraint: None,
733    },
734    FieldSchema {
735        label: "DisplayInactive",
736        expected_type: GffType::UInt8,
737        life: FieldLife::Live,
738        required: false,
739        absent: AbsentDefault::Unverified,
740        children: None,
741        constraint: None,
742    },
743];
744
745/// Reply->Entry link children (`EntriesList` entries within each ReplyList node).
746///
747/// Also used by `StartingList` entries (same schema: Active + Index).
748static REPLY_ENTRY_LINK_CHILDREN: &[FieldSchema] = &[
749    // Dead: the label strings do not exist in the compiled engine, so no
750    // read path can name them. See dlg.md's "Fields the Loader Never Reads".
751    FieldSchema {
752        label: "IsChild",
753        expected_type: GffType::UInt8,
754        life: FieldLife::ReadOnlyDead(
755            "the label string does not exist anywhere in the engine binary, so no read path can name it",
756        ),
757        required: false,
758        absent: AbsentDefault::Unverified,
759        children: None,
760        constraint: None,
761    },
762    FieldSchema {
763        label: "LinkComment",
764        expected_type: GffType::String,
765        life: FieldLife::ReadOnlyDead(
766            "the label string does not exist anywhere in the engine binary, so no read path can name it",
767        ),
768        required: false,
769        absent: AbsentDefault::Unverified,
770        children: None,
771        constraint: None,
772    },
773    FieldSchema {
774        label: "Active",
775        expected_type: GffType::ResRef,
776        life: FieldLife::Live,
777        required: false,
778        absent: AbsentDefault::Unverified,
779        children: None,
780        constraint: None,
781    },
782    FieldSchema {
783        label: "Index",
784        expected_type: GffType::UInt32,
785        life: FieldLife::Live,
786        required: false,
787        absent: AbsentDefault::Unverified,
788        children: None,
789        constraint: None,
790    },
791];
792
793/// AnimList entry children (per-node animation list).
794static ANIM_LIST_CHILDREN: &[FieldSchema] = &[
795    FieldSchema {
796        label: "Participant",
797        expected_type: GffType::String,
798        life: FieldLife::Live,
799        required: false,
800        absent: AbsentDefault::Unverified,
801        children: None,
802        constraint: None,
803    },
804    FieldSchema {
805        label: "Animation",
806        expected_type: GffType::UInt16,
807        life: FieldLife::Live,
808        required: false,
809        absent: AbsentDefault::Unverified,
810        children: None,
811        constraint: None,
812    },
813];
814
815/// StuntList entry children (cutscene actor models).
816static STUNT_LIST_CHILDREN: &[FieldSchema] = &[
817    FieldSchema {
818        label: "Participant",
819        expected_type: GffType::String,
820        life: FieldLife::Live,
821        required: false,
822        absent: AbsentDefault::Unverified,
823        children: None,
824        constraint: None,
825    },
826    FieldSchema {
827        label: "StuntModel",
828        expected_type: GffType::ResRef,
829        life: FieldLife::Live,
830        required: false,
831        absent: AbsentDefault::Unverified,
832        children: None,
833        constraint: None,
834    },
835];
836
837// =========================================================================
838// Node sub-schemas (reference leaf schemas)
839// =========================================================================
840
841/// Entry node children: LoadDialogBase + LoadDialogCamera + RepliesList link.
842static ENTRY_NODE_CHILDREN: &[FieldSchema] = &[
843    // Dead: the label strings do not exist in the compiled engine, so no
844    // read path can name them. See dlg.md's "Fields the Loader Never Reads".
845    FieldSchema {
846        label: "Comment",
847        expected_type: GffType::String,
848        life: FieldLife::ReadOnlyDead(
849            "the label string does not exist anywhere in the engine binary, so no read path can name it",
850        ),
851        required: false,
852        absent: AbsentDefault::Unverified,
853        children: None,
854        constraint: None,
855    },
856    // --- LoadDialogBase fields ---
857    FieldSchema {
858        label: "Text",
859        expected_type: GffType::LocalizedString,
860        life: FieldLife::Live,
861        required: false,
862        absent: AbsentDefault::Unverified,
863        children: None,
864        constraint: None,
865    },
866    FieldSchema {
867        label: "Script",
868        expected_type: GffType::ResRef,
869        life: FieldLife::Live,
870        required: false,
871        absent: AbsentDefault::Unverified,
872        children: None,
873        constraint: None,
874    },
875    FieldSchema {
876        label: "Speaker",
877        expected_type: GffType::String,
878        life: FieldLife::Live,
879        required: false,
880        absent: AbsentDefault::Unverified,
881        children: None,
882        constraint: None,
883    },
884    FieldSchema {
885        label: "WaitFlags",
886        expected_type: GffType::UInt32,
887        life: FieldLife::Live,
888        required: false,
889        absent: AbsentDefault::Unverified,
890        children: None,
891        constraint: None,
892    },
893    FieldSchema {
894        label: "Quest",
895        expected_type: GffType::String,
896        life: FieldLife::Live,
897        required: false,
898        absent: AbsentDefault::Unverified,
899        children: None,
900        constraint: None,
901    },
902    FieldSchema {
903        label: "QuestEntry",
904        expected_type: GffType::UInt32,
905        life: FieldLife::Live,
906        required: false,
907        absent: AbsentDefault::Unverified,
908        children: None,
909        constraint: None,
910    },
911    FieldSchema {
912        label: "PlotIndex",
913        expected_type: GffType::Int32,
914        life: FieldLife::Live,
915        required: false,
916        absent: AbsentDefault::Unverified,
917        children: None,
918        constraint: None,
919    },
920    FieldSchema {
921        label: "PlotXPPercentage",
922        expected_type: GffType::Single,
923        life: FieldLife::Live,
924        required: false,
925        absent: AbsentDefault::Unverified,
926        children: None,
927        constraint: None,
928    },
929    FieldSchema {
930        label: "Delay",
931        expected_type: GffType::UInt32,
932        life: FieldLife::Live,
933        required: false,
934        absent: AbsentDefault::Unverified,
935        children: None,
936        constraint: None,
937    },
938    FieldSchema {
939        label: "FadeType",
940        expected_type: GffType::UInt8,
941        life: FieldLife::Live,
942        required: false,
943        absent: AbsentDefault::Unverified,
944        children: None,
945        constraint: None,
946    },
947    FieldSchema {
948        label: "FadeColor",
949        expected_type: GffType::Vector3,
950        life: FieldLife::Live,
951        required: false,
952        absent: AbsentDefault::Unverified,
953        children: None,
954        constraint: None,
955    },
956    FieldSchema {
957        label: "FadeDelay",
958        expected_type: GffType::Single,
959        life: FieldLife::Live,
960        required: false,
961        absent: AbsentDefault::Unverified,
962        children: None,
963        constraint: None,
964    },
965    FieldSchema {
966        label: "FadeLength",
967        expected_type: GffType::Single,
968        life: FieldLife::Live,
969        required: false,
970        absent: AbsentDefault::Unverified,
971        children: None,
972        constraint: None,
973    },
974    FieldSchema {
975        label: "Sound",
976        expected_type: GffType::ResRef,
977        life: FieldLife::Live,
978        required: false,
979        absent: AbsentDefault::Unverified,
980        children: None,
981        constraint: None,
982    },
983    FieldSchema {
984        label: "VO_ResRef",
985        expected_type: GffType::ResRef,
986        life: FieldLife::Live,
987        required: false,
988        absent: AbsentDefault::Unverified,
989        children: None,
990        constraint: None,
991    },
992    FieldSchema {
993        label: "SoundExists",
994        expected_type: GffType::UInt8,
995        life: FieldLife::Live,
996        required: false,
997        absent: AbsentDefault::Unverified,
998        children: None,
999        constraint: None,
1000    },
1001    FieldSchema {
1002        label: "AnimList",
1003        expected_type: GffType::List,
1004        life: FieldLife::Live,
1005        required: false,
1006        absent: AbsentDefault::Unverified,
1007        children: Some(ANIM_LIST_CHILDREN),
1008        constraint: None,
1009    },
1010    // --- LoadDialogCamera fields ---
1011    FieldSchema {
1012        label: "Listener",
1013        expected_type: GffType::String,
1014        life: FieldLife::Live,
1015        required: false,
1016        absent: AbsentDefault::Unverified,
1017        children: None,
1018        constraint: None,
1019    },
1020    FieldSchema {
1021        label: "CameraAngle",
1022        expected_type: GffType::UInt32,
1023        life: FieldLife::Live,
1024        required: false,
1025        absent: AbsentDefault::Unverified,
1026        children: None,
1027        constraint: None,
1028    },
1029    FieldSchema {
1030        label: "CameraID",
1031        expected_type: GffType::Int32,
1032        life: FieldLife::Live,
1033        required: false,
1034        absent: AbsentDefault::Unverified,
1035        children: None,
1036        constraint: None,
1037    },
1038    FieldSchema {
1039        label: "CamHeightOffset",
1040        expected_type: GffType::Single,
1041        life: FieldLife::Live,
1042        required: false,
1043        absent: AbsentDefault::Unverified,
1044        children: None,
1045        constraint: None,
1046    },
1047    FieldSchema {
1048        label: "TarHeightOffset",
1049        expected_type: GffType::Single,
1050        life: FieldLife::Live,
1051        required: false,
1052        absent: AbsentDefault::Unverified,
1053        children: None,
1054        constraint: None,
1055    },
1056    FieldSchema {
1057        label: "CameraAnimation",
1058        expected_type: GffType::UInt16,
1059        life: FieldLife::Live,
1060        required: false,
1061        absent: AbsentDefault::Unverified,
1062        children: None,
1063        constraint: None,
1064    },
1065    FieldSchema {
1066        label: "CamVidEffect",
1067        expected_type: GffType::Int32,
1068        life: FieldLife::Live,
1069        required: false,
1070        absent: AbsentDefault::Unverified,
1071        children: None,
1072        constraint: None,
1073    },
1074    FieldSchema {
1075        label: "CamFieldOfView",
1076        expected_type: GffType::Single,
1077        life: FieldLife::Live,
1078        required: false,
1079        absent: AbsentDefault::Unverified,
1080        children: None,
1081        constraint: None,
1082    },
1083    // --- Entry-specific link list ---
1084    FieldSchema {
1085        label: "RepliesList",
1086        expected_type: GffType::List,
1087        life: FieldLife::Live,
1088        required: false,
1089        absent: AbsentDefault::Unverified,
1090        children: Some(ENTRY_REPLY_LINK_CHILDREN),
1091        constraint: None,
1092    },
1093];
1094
1095/// Reply node children: LoadDialogBase + LoadDialogCamera + EntriesList link.
1096static REPLY_NODE_CHILDREN: &[FieldSchema] = &[
1097    // Dead: the label strings do not exist in the compiled engine, so no
1098    // read path can name them. See dlg.md's "Fields the Loader Never Reads".
1099    FieldSchema {
1100        label: "Comment",
1101        expected_type: GffType::String,
1102        life: FieldLife::ReadOnlyDead(
1103            "the label string does not exist anywhere in the engine binary, so no read path can name it",
1104        ),
1105        required: false,
1106        absent: AbsentDefault::Unverified,
1107        children: None,
1108        constraint: None,
1109    },
1110    // --- LoadDialogBase fields (identical to entry) ---
1111    FieldSchema {
1112        label: "Text",
1113        expected_type: GffType::LocalizedString,
1114        life: FieldLife::Live,
1115        required: false,
1116        absent: AbsentDefault::Unverified,
1117        children: None,
1118        constraint: None,
1119    },
1120    FieldSchema {
1121        label: "Script",
1122        expected_type: GffType::ResRef,
1123        life: FieldLife::Live,
1124        required: false,
1125        absent: AbsentDefault::Unverified,
1126        children: None,
1127        constraint: None,
1128    },
1129    FieldSchema {
1130        label: "Speaker",
1131        expected_type: GffType::String,
1132        life: FieldLife::Live,
1133        required: false,
1134        absent: AbsentDefault::Unverified,
1135        children: None,
1136        constraint: None,
1137    },
1138    FieldSchema {
1139        label: "WaitFlags",
1140        expected_type: GffType::UInt32,
1141        life: FieldLife::Live,
1142        required: false,
1143        absent: AbsentDefault::Unverified,
1144        children: None,
1145        constraint: None,
1146    },
1147    FieldSchema {
1148        label: "Quest",
1149        expected_type: GffType::String,
1150        life: FieldLife::Live,
1151        required: false,
1152        absent: AbsentDefault::Unverified,
1153        children: None,
1154        constraint: None,
1155    },
1156    FieldSchema {
1157        label: "QuestEntry",
1158        expected_type: GffType::UInt32,
1159        life: FieldLife::Live,
1160        required: false,
1161        absent: AbsentDefault::Unverified,
1162        children: None,
1163        constraint: None,
1164    },
1165    FieldSchema {
1166        label: "PlotIndex",
1167        expected_type: GffType::Int32,
1168        life: FieldLife::Live,
1169        required: false,
1170        absent: AbsentDefault::Unverified,
1171        children: None,
1172        constraint: None,
1173    },
1174    FieldSchema {
1175        label: "PlotXPPercentage",
1176        expected_type: GffType::Single,
1177        life: FieldLife::Live,
1178        required: false,
1179        absent: AbsentDefault::Unverified,
1180        children: None,
1181        constraint: None,
1182    },
1183    FieldSchema {
1184        label: "Delay",
1185        expected_type: GffType::UInt32,
1186        life: FieldLife::Live,
1187        required: false,
1188        absent: AbsentDefault::Unverified,
1189        children: None,
1190        constraint: None,
1191    },
1192    FieldSchema {
1193        label: "FadeType",
1194        expected_type: GffType::UInt8,
1195        life: FieldLife::Live,
1196        required: false,
1197        absent: AbsentDefault::Unverified,
1198        children: None,
1199        constraint: None,
1200    },
1201    FieldSchema {
1202        label: "FadeColor",
1203        expected_type: GffType::Vector3,
1204        life: FieldLife::Live,
1205        required: false,
1206        absent: AbsentDefault::Unverified,
1207        children: None,
1208        constraint: None,
1209    },
1210    FieldSchema {
1211        label: "FadeDelay",
1212        expected_type: GffType::Single,
1213        life: FieldLife::Live,
1214        required: false,
1215        absent: AbsentDefault::Unverified,
1216        children: None,
1217        constraint: None,
1218    },
1219    FieldSchema {
1220        label: "FadeLength",
1221        expected_type: GffType::Single,
1222        life: FieldLife::Live,
1223        required: false,
1224        absent: AbsentDefault::Unverified,
1225        children: None,
1226        constraint: None,
1227    },
1228    FieldSchema {
1229        label: "Sound",
1230        expected_type: GffType::ResRef,
1231        life: FieldLife::Live,
1232        required: false,
1233        absent: AbsentDefault::Unverified,
1234        children: None,
1235        constraint: None,
1236    },
1237    FieldSchema {
1238        label: "VO_ResRef",
1239        expected_type: GffType::ResRef,
1240        life: FieldLife::Live,
1241        required: false,
1242        absent: AbsentDefault::Unverified,
1243        children: None,
1244        constraint: None,
1245    },
1246    FieldSchema {
1247        label: "SoundExists",
1248        expected_type: GffType::UInt8,
1249        life: FieldLife::Live,
1250        required: false,
1251        absent: AbsentDefault::Unverified,
1252        children: None,
1253        constraint: None,
1254    },
1255    FieldSchema {
1256        label: "AnimList",
1257        expected_type: GffType::List,
1258        life: FieldLife::Live,
1259        required: false,
1260        absent: AbsentDefault::Unverified,
1261        children: Some(ANIM_LIST_CHILDREN),
1262        constraint: None,
1263    },
1264    // --- LoadDialogCamera fields (identical to entry) ---
1265    FieldSchema {
1266        label: "Listener",
1267        expected_type: GffType::String,
1268        life: FieldLife::Live,
1269        required: false,
1270        absent: AbsentDefault::Unverified,
1271        children: None,
1272        constraint: None,
1273    },
1274    FieldSchema {
1275        label: "CameraAngle",
1276        expected_type: GffType::UInt32,
1277        life: FieldLife::Live,
1278        required: false,
1279        absent: AbsentDefault::Unverified,
1280        children: None,
1281        constraint: None,
1282    },
1283    FieldSchema {
1284        label: "CameraID",
1285        expected_type: GffType::Int32,
1286        life: FieldLife::Live,
1287        required: false,
1288        absent: AbsentDefault::Unverified,
1289        children: None,
1290        constraint: None,
1291    },
1292    FieldSchema {
1293        label: "CamHeightOffset",
1294        expected_type: GffType::Single,
1295        life: FieldLife::Live,
1296        required: false,
1297        absent: AbsentDefault::Unverified,
1298        children: None,
1299        constraint: None,
1300    },
1301    FieldSchema {
1302        label: "TarHeightOffset",
1303        expected_type: GffType::Single,
1304        life: FieldLife::Live,
1305        required: false,
1306        absent: AbsentDefault::Unverified,
1307        children: None,
1308        constraint: None,
1309    },
1310    FieldSchema {
1311        label: "CameraAnimation",
1312        expected_type: GffType::UInt16,
1313        life: FieldLife::Live,
1314        required: false,
1315        absent: AbsentDefault::Unverified,
1316        children: None,
1317        constraint: None,
1318    },
1319    FieldSchema {
1320        label: "CamVidEffect",
1321        expected_type: GffType::Int32,
1322        life: FieldLife::Live,
1323        required: false,
1324        absent: AbsentDefault::Unverified,
1325        children: None,
1326        constraint: None,
1327    },
1328    FieldSchema {
1329        label: "CamFieldOfView",
1330        expected_type: GffType::Single,
1331        life: FieldLife::Live,
1332        required: false,
1333        absent: AbsentDefault::Unverified,
1334        children: None,
1335        constraint: None,
1336    },
1337    // --- Reply-specific link list ---
1338    FieldSchema {
1339        label: "EntriesList",
1340        expected_type: GffType::List,
1341        life: FieldLife::Live,
1342        required: false,
1343        absent: AbsentDefault::Unverified,
1344        children: Some(REPLY_ENTRY_LINK_CHILDREN),
1345        constraint: None,
1346    },
1347];
1348
1349impl GffSchema for Dlg {
1350    fn schema() -> &'static [FieldSchema] {
1351        static SCHEMA: &[FieldSchema] = &[
1352            // Dead: the label strings do not exist in the compiled engine, so no
1353            // read path can name them. See dlg.md's "Fields the Loader Never Reads".
1354            FieldSchema {
1355                label: "NumWords",
1356                expected_type: GffType::UInt32,
1357                life: FieldLife::ReadOnlyDead(
1358                    "the label string does not exist anywhere in the engine binary, so no read path can name it",
1359                ),
1360                required: false,
1361                absent: AbsentDefault::Unverified,
1362                children: None,
1363                constraint: None,
1364            },
1365            FieldSchema {
1366                label: "VO_ID",
1367                expected_type: GffType::String,
1368                life: FieldLife::ReadOnlyDead(
1369                    "the label string does not exist anywhere in the engine binary, so no read path can name it",
1370                ),
1371                required: false,
1372                absent: AbsentDefault::Unverified,
1373                children: None,
1374                constraint: None,
1375            },
1376            // --- Conversation config (13 scalars) ---
1377            FieldSchema {
1378                label: "CameraModel",
1379                expected_type: GffType::ResRef,
1380                life: FieldLife::Live,
1381                required: false,
1382                absent: AbsentDefault::Unverified,
1383                children: None,
1384                constraint: None,
1385            },
1386            FieldSchema {
1387                label: "DelayEntry",
1388                expected_type: GffType::UInt32,
1389                life: FieldLife::Live,
1390                required: false,
1391                absent: AbsentDefault::Unverified,
1392                children: None,
1393                constraint: None,
1394            },
1395            FieldSchema {
1396                label: "DelayReply",
1397                expected_type: GffType::UInt32,
1398                life: FieldLife::Live,
1399                required: false,
1400                absent: AbsentDefault::Unverified,
1401                children: None,
1402                constraint: None,
1403            },
1404            FieldSchema {
1405                label: "EndConversation",
1406                expected_type: GffType::ResRef,
1407                life: FieldLife::Live,
1408                required: false,
1409                absent: AbsentDefault::Unverified,
1410                children: None,
1411                constraint: None,
1412            },
1413            FieldSchema {
1414                label: "EndConverAbort",
1415                expected_type: GffType::ResRef,
1416                life: FieldLife::Live,
1417                required: false,
1418                absent: AbsentDefault::Unverified,
1419                children: None,
1420                constraint: None,
1421            },
1422            FieldSchema {
1423                label: "Skippable",
1424                expected_type: GffType::UInt8,
1425                life: FieldLife::Live,
1426                required: false,
1427                absent: AbsentDefault::Unverified,
1428                children: None,
1429                constraint: None,
1430            },
1431            FieldSchema {
1432                label: "ConversationType",
1433                expected_type: GffType::Int32,
1434                life: FieldLife::Live,
1435                required: false,
1436                absent: AbsentDefault::Unverified,
1437                children: None,
1438                constraint: None,
1439            },
1440            FieldSchema {
1441                label: "ComputerType",
1442                expected_type: GffType::UInt8,
1443                life: FieldLife::Live,
1444                required: false,
1445                absent: AbsentDefault::Unverified,
1446                children: None,
1447                constraint: None,
1448            },
1449            FieldSchema {
1450                label: "AmbientTrack",
1451                expected_type: GffType::ResRef,
1452                life: FieldLife::Live,
1453                required: false,
1454                absent: AbsentDefault::Unverified,
1455                children: None,
1456                constraint: None,
1457            },
1458            FieldSchema {
1459                label: "UnequipItems",
1460                expected_type: GffType::UInt8,
1461                life: FieldLife::Live,
1462                required: false,
1463                absent: AbsentDefault::Unverified,
1464                children: None,
1465                constraint: None,
1466            },
1467            FieldSchema {
1468                label: "UnequipHItem",
1469                expected_type: GffType::UInt8,
1470                life: FieldLife::Live,
1471                required: false,
1472                absent: AbsentDefault::Unverified,
1473                children: None,
1474                constraint: None,
1475            },
1476            FieldSchema {
1477                label: "AnimatedCut",
1478                expected_type: GffType::UInt8,
1479                life: FieldLife::Live,
1480                required: false,
1481                absent: AbsentDefault::Unverified,
1482                children: None,
1483                constraint: None,
1484            },
1485            FieldSchema {
1486                label: "OldHitCheck",
1487                expected_type: GffType::UInt8,
1488                life: FieldLife::Live,
1489                required: false,
1490                absent: AbsentDefault::Unverified,
1491                children: None,
1492                constraint: None,
1493            },
1494            // --- Lists (4) ---
1495            FieldSchema {
1496                label: "EntryList",
1497                expected_type: GffType::List,
1498                life: FieldLife::Live,
1499                required: false,
1500                absent: AbsentDefault::Unverified,
1501                children: Some(ENTRY_NODE_CHILDREN),
1502                constraint: None,
1503            },
1504            FieldSchema {
1505                label: "ReplyList",
1506                expected_type: GffType::List,
1507                life: FieldLife::Live,
1508                required: false,
1509                absent: AbsentDefault::Unverified,
1510                children: Some(REPLY_NODE_CHILDREN),
1511                constraint: None,
1512            },
1513            FieldSchema {
1514                label: "StartingList",
1515                expected_type: GffType::List,
1516                life: FieldLife::Live,
1517                required: false,
1518                absent: AbsentDefault::Unverified,
1519                children: Some(REPLY_ENTRY_LINK_CHILDREN),
1520                constraint: None,
1521            },
1522            FieldSchema {
1523                label: "StuntList",
1524                expected_type: GffType::List,
1525                life: FieldLife::Live,
1526                required: false,
1527                absent: AbsentDefault::Unverified,
1528                children: Some(STUNT_LIST_CHILDREN),
1529                constraint: None,
1530            },
1531        ];
1532        SCHEMA
1533    }
1534}
1535
1536#[cfg(test)]
1537mod tests {
1538    use super::*;
1539
1540    #[test]
1541    fn display_inactive_is_omitted_at_its_default_and_written_otherwise() {
1542        // The write policy in one test. Vanilla never writes this field, and
1543        // its absent-value is an audited constant matching our default, so
1544        // writing a zero would put a field on every link the engine leaves
1545        // out. Writing it when set is the other half: an omitted field reads
1546        // back as the constant, which would silently drop the flag.
1547        let cleared = DlgLink::default();
1548        assert!(!cleared.display_inactive);
1549        assert!(cleared
1550            .to_gff_struct(true)
1551            .field("DisplayInactive")
1552            .is_none());
1553
1554        let set = DlgLink {
1555            display_inactive: true,
1556            ..DlgLink::default()
1557        };
1558        let written = set.to_gff_struct(true);
1559        assert_eq!(written.field("DisplayInactive"), Some(&GffValue::UInt8(1)));
1560        assert!(DlgLink::from_gff_struct(&written).display_inactive);
1561    }
1562
1563    /// Build a minimal DLG GFF for testing.
1564    fn make_test_dlg_gff() -> Gff {
1565        let mut root = GffStruct::new(-1);
1566        root.push_field("CameraModel", GffValue::resref_lit(""));
1567        root.push_field("DelayEntry", GffValue::UInt32(0));
1568        root.push_field("DelayReply", GffValue::UInt32(0));
1569        root.push_field("EndConversation", GffValue::resref_lit("k_end_conv"));
1570        root.push_field("EndConverAbort", GffValue::resref_lit("k_end_abort"));
1571        root.push_field("Skippable", GffValue::UInt8(1));
1572        root.push_field("ConversationType", GffValue::Int32(0));
1573        root.push_field("ComputerType", GffValue::UInt8(0));
1574        root.push_field("AmbientTrack", GffValue::resref_lit(""));
1575        root.push_field("UnequipItems", GffValue::UInt8(0));
1576        root.push_field("UnequipHItem", GffValue::UInt8(0));
1577        root.push_field("AnimatedCut", GffValue::UInt8(1));
1578        root.push_field("OldHitCheck", GffValue::UInt8(0));
1579
1580        // Starting list: one link pointing to entry 0.
1581        let mut start_link = GffStruct::new(0);
1582        start_link.push_field("Active", GffValue::resref_lit("k_cond_start"));
1583        start_link.push_field("Index", GffValue::UInt32(0));
1584        root.push_field("StartingList", GffValue::List(vec![start_link]));
1585
1586        // Entry 0: NPC says "Hello", links to reply 0.
1587        let mut entry0 = GffStruct::new(0);
1588        entry0.push_field(
1589            "Text",
1590            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(50000))),
1591        );
1592        entry0.push_field("Script", GffValue::resref_lit("k_entry_fire"));
1593        entry0.push_field("Speaker", GffValue::String("Bastila".into()));
1594        entry0.push_field("WaitFlags", GffValue::UInt32(0));
1595        entry0.push_field("Quest", GffValue::String("".into()));
1596        entry0.push_field("QuestEntry", GffValue::UInt32(0));
1597        entry0.push_field("PlotIndex", GffValue::Int32(-1));
1598        entry0.push_field("PlotXPPercentage", GffValue::Single(1.0));
1599        entry0.push_field("Delay", GffValue::UInt32(u32::MAX));
1600        entry0.push_field("FadeType", GffValue::UInt8(0));
1601        entry0.push_field("FadeColor", GffValue::Vector3([0.0, 0.0, 0.0]));
1602        entry0.push_field("FadeDelay", GffValue::Single(0.0));
1603        entry0.push_field("FadeLength", GffValue::Single(0.0));
1604        entry0.push_field("Sound", GffValue::resref_lit(""));
1605        entry0.push_field("VO_ResRef", GffValue::resref_lit("n_bastila_hi"));
1606        entry0.push_field("SoundExists", GffValue::UInt8(1));
1607        entry0.push_field("Listener", GffValue::String("".into()));
1608        entry0.push_field("CameraAngle", GffValue::UInt32(4));
1609        entry0.push_field("CameraID", GffValue::Int32(-1));
1610        entry0.push_field("CamHeightOffset", GffValue::Single(0.0));
1611        entry0.push_field("TarHeightOffset", GffValue::Single(0.0));
1612        entry0.push_field("CameraAnimation", GffValue::UInt16(0));
1613        entry0.push_field("CamVidEffect", GffValue::Int32(-1));
1614        entry0.push_field("CamFieldOfView", GffValue::Single(-1.0));
1615
1616        // Anim list on entry 0.
1617        let mut anim = GffStruct::new(0);
1618        anim.push_field("Participant", GffValue::String("Bastila".into()));
1619        anim.push_field("Animation", GffValue::UInt16(28));
1620        entry0.push_field("AnimList", GffValue::List(vec![anim]));
1621
1622        // Entry 0 links to reply 0 with DisplayInactive.
1623        let mut reply_link = GffStruct::new(0);
1624        reply_link.push_field("Active", GffValue::resref_lit(""));
1625        reply_link.push_field("Index", GffValue::UInt32(0));
1626        reply_link.push_field("DisplayInactive", GffValue::UInt8(1));
1627        entry0.push_field("RepliesList", GffValue::List(vec![reply_link]));
1628
1629        // Reply 0: PC says "Goodbye", links back to entry 0.
1630        let mut reply0 = GffStruct::new(1);
1631        reply0.push_field(
1632            "Text",
1633            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(50001))),
1634        );
1635        reply0.push_field("Script", GffValue::resref_lit("k_reply_fire"));
1636        reply0.push_field("Speaker", GffValue::String("".into()));
1637        reply0.push_field("WaitFlags", GffValue::UInt32(0));
1638        reply0.push_field("Quest", GffValue::String("".into()));
1639        reply0.push_field("QuestEntry", GffValue::UInt32(0));
1640        reply0.push_field("PlotIndex", GffValue::Int32(-1));
1641        reply0.push_field("PlotXPPercentage", GffValue::Single(1.0));
1642        reply0.push_field("Delay", GffValue::UInt32(u32::MAX));
1643        reply0.push_field("FadeType", GffValue::UInt8(0));
1644        reply0.push_field("FadeColor", GffValue::Vector3([0.0, 0.0, 0.0]));
1645        reply0.push_field("FadeDelay", GffValue::Single(0.0));
1646        reply0.push_field("FadeLength", GffValue::Single(0.0));
1647        reply0.push_field("Sound", GffValue::resref_lit(""));
1648        reply0.push_field("VO_ResRef", GffValue::resref_lit(""));
1649        reply0.push_field("SoundExists", GffValue::UInt8(0));
1650        reply0.push_field("AnimList", GffValue::List(vec![]));
1651        reply0.push_field("Listener", GffValue::String("".into()));
1652        reply0.push_field("CameraAngle", GffValue::UInt32(0));
1653        reply0.push_field("CameraID", GffValue::Int32(-1));
1654        reply0.push_field("CamHeightOffset", GffValue::Single(0.0));
1655        reply0.push_field("TarHeightOffset", GffValue::Single(0.0));
1656        reply0.push_field("CameraAnimation", GffValue::UInt16(0));
1657        reply0.push_field("CamVidEffect", GffValue::Int32(-1));
1658        reply0.push_field("CamFieldOfView", GffValue::Single(-1.0));
1659
1660        let mut entry_link = GffStruct::new(0);
1661        entry_link.push_field("Active", GffValue::resref_lit(""));
1662        entry_link.push_field("Index", GffValue::UInt32(0));
1663        reply0.push_field("EntriesList", GffValue::List(vec![entry_link]));
1664
1665        root.push_field("EntryList", GffValue::List(vec![entry0]));
1666        root.push_field("ReplyList", GffValue::List(vec![reply0]));
1667
1668        // Stunt list with one actor.
1669        let mut stunt = GffStruct::new(0);
1670        stunt.push_field("Participant", GffValue::String("Bastila".into()));
1671        stunt.push_field("StuntModel", GffValue::resref_lit("p_bastilabb"));
1672        root.push_field("StuntList", GffValue::List(vec![stunt]));
1673
1674        Gff::new(*b"DLG ", root)
1675    }
1676
1677    #[test]
1678    fn reads_conversation_config() {
1679        let gff = make_test_dlg_gff();
1680        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1681
1682        assert_eq!(dlg.end_conversation, "k_end_conv");
1683        assert_eq!(dlg.end_conver_abort, "k_end_abort");
1684        assert!(dlg.skippable);
1685        assert_eq!(dlg.conversation_type, 0);
1686        assert!(dlg.animated_cut);
1687        assert!(!dlg.old_hit_check);
1688    }
1689
1690    #[test]
1691    fn reads_starting_list() {
1692        let gff = make_test_dlg_gff();
1693        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1694
1695        assert_eq!(dlg.starting_list.len(), 1);
1696        assert_eq!(dlg.starting_list[0].active, "k_cond_start");
1697        assert_eq!(dlg.starting_list[0].index, 0);
1698    }
1699
1700    #[test]
1701    fn reads_entry_node() {
1702        let gff = make_test_dlg_gff();
1703        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1704
1705        assert_eq!(dlg.entries.len(), 1);
1706        let entry = &dlg.entries[0];
1707        assert_eq!(entry.text.string_ref.raw(), 50000);
1708        assert_eq!(entry.script, "k_entry_fire");
1709        assert_eq!(entry.speaker, "Bastila");
1710        assert_eq!(entry.vo_resref, "n_bastila_hi");
1711        assert!(entry.sound_exists);
1712        assert_eq!(entry.camera_angle, 4);
1713        assert_eq!(entry.animations.len(), 1);
1714        assert_eq!(entry.animations[0].participant, "Bastila");
1715        assert_eq!(entry.animations[0].animation, 28);
1716        assert_eq!(entry.links.len(), 1);
1717        assert_eq!(entry.links[0].index, 0);
1718        assert!(entry.links[0].display_inactive);
1719    }
1720
1721    #[test]
1722    fn reads_reply_node() {
1723        let gff = make_test_dlg_gff();
1724        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1725
1726        assert_eq!(dlg.replies.len(), 1);
1727        let reply = &dlg.replies[0];
1728        assert_eq!(reply.text.string_ref.raw(), 50001);
1729        assert_eq!(reply.script, "k_reply_fire");
1730        assert!(!reply.sound_exists);
1731        assert_eq!(reply.links.len(), 1);
1732        assert_eq!(reply.links[0].index, 0);
1733    }
1734
1735    #[test]
1736    fn reads_stunt_list() {
1737        let gff = make_test_dlg_gff();
1738        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1739
1740        assert_eq!(dlg.stunt_list.len(), 1);
1741        assert_eq!(dlg.stunt_list[0].participant, "Bastila");
1742        assert_eq!(dlg.stunt_list[0].stunt_model, "p_bastilabb");
1743    }
1744
1745    #[test]
1746    fn all_fields_survive_typed_roundtrip() {
1747        let gff = make_test_dlg_gff();
1748        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1749        let bytes = write_dlg_to_vec(&dlg).expect("write succeeds");
1750        let reparsed = read_dlg_from_bytes(&bytes).expect("reparse succeeds");
1751
1752        assert_eq!(dlg, reparsed);
1753    }
1754
1755    #[test]
1756    fn fade_color_roundtrips_as_vector3() {
1757        let mut gff = make_test_dlg_gff();
1758        // Set a non-default FadeColor on entry 0.
1759        if let Some(GffValue::List(ref mut entries)) = gff
1760            .root
1761            .fields
1762            .iter_mut()
1763            .find(|f| f.label == "EntryList")
1764            .map(|f| &mut f.value)
1765        {
1766            // Replace the default [0,0,0] with a visible red.
1767            if let Some(field) = entries[0]
1768                .fields
1769                .iter_mut()
1770                .find(|f| f.label == "FadeColor")
1771            {
1772                field.value = GffValue::Vector3([1.0, 0.0, 0.0]);
1773            }
1774        }
1775
1776        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1777        assert_eq!(dlg.entries[0].fade_color, [1.0, 0.0, 0.0]);
1778
1779        let bytes = write_dlg_to_vec(&dlg).expect("write succeeds");
1780        let reparsed = read_dlg_from_bytes(&bytes).expect("reparse succeeds");
1781        assert_eq!(reparsed.entries[0].fade_color, [1.0, 0.0, 0.0]);
1782
1783        // Verify the GFF value is Vector3, not Struct.
1784        let rebuilt = dlg.to_gff();
1785        if let Some(GffValue::List(entries)) = rebuilt.root.field("EntryList") {
1786            let fade = entries[0]
1787                .field("FadeColor")
1788                .expect("FadeColor must be present");
1789            assert!(
1790                matches!(fade, GffValue::Vector3([1.0, 0.0, 0.0])),
1791                "FadeColor must be GffValue::Vector3, got {fade:?}"
1792            );
1793        } else {
1794            panic!("EntryList missing from rebuilt GFF");
1795        }
1796    }
1797
1798    #[test]
1799    fn typed_edits_roundtrip_through_gff_writer() {
1800        let gff = make_test_dlg_gff();
1801        let mut dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1802        dlg.end_conversation = ResRef::new("k_end_new").expect("valid test resref");
1803        dlg.entries[0].speaker = "Carth".into();
1804        dlg.entries[0].links[0].display_inactive = false;
1805
1806        let bytes = write_dlg_to_vec(&dlg).expect("write succeeds");
1807        let reparsed = read_dlg_from_bytes(&bytes).expect("reparse succeeds");
1808
1809        assert_eq!(reparsed.end_conversation, "k_end_new");
1810        assert_eq!(reparsed.entries[0].speaker, "Carth");
1811        assert!(!reparsed.entries[0].links[0].display_inactive);
1812    }
1813
1814    #[test]
1815    fn read_dlg_from_reader_matches_bytes_path() {
1816        let gff = make_test_dlg_gff();
1817        let bytes = {
1818            let mut c = Cursor::new(Vec::new());
1819            write_gff(&mut c, &gff).expect("test GFF serializes cleanly");
1820            c.into_inner()
1821        };
1822
1823        let mut cursor = Cursor::new(&bytes);
1824        let via_reader = read_dlg(&mut cursor).expect("reader parse succeeds");
1825        let via_bytes = read_dlg_from_bytes(&bytes).expect("bytes parse succeeds");
1826
1827        assert_eq!(via_reader, via_bytes);
1828    }
1829
1830    #[test]
1831    fn rejects_non_dlg_file_type() {
1832        let mut gff = make_test_dlg_gff();
1833        gff.file_type = *b"UTT ";
1834
1835        let err = Dlg::from_gff(&gff).expect_err("UTT must be rejected as DLG input");
1836        assert!(matches!(
1837            err,
1838            DlgError::UnsupportedFileType(file_type) if file_type == *b"UTT "
1839        ));
1840    }
1841
1842    #[test]
1843    fn write_dlg_matches_direct_gff_writer() {
1844        let gff = make_test_dlg_gff();
1845        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1846
1847        let via_typed = write_dlg_to_vec(&dlg).expect("typed write succeeds");
1848
1849        let mut direct = Cursor::new(Vec::new());
1850        write_gff(&mut direct, &dlg.to_gff()).expect("direct write succeeds");
1851
1852        assert_eq!(via_typed, direct.into_inner());
1853    }
1854
1855    #[test]
1856    fn schema_field_count() {
1857        assert_eq!(Dlg::schema().len(), 19);
1858    }
1859
1860    #[test]
1861    fn schema_no_duplicate_labels() {
1862        let schema = Dlg::schema();
1863        let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
1864        labels.sort();
1865        let before = labels.len();
1866        labels.dedup();
1867        assert_eq!(before, labels.len(), "duplicate labels in DLG schema");
1868    }
1869
1870    #[test]
1871    fn schema_entry_node_count() {
1872        let entry_list = Dlg::schema()
1873            .iter()
1874            .find(|f| f.label == "EntryList")
1875            .expect("EntryList must exist");
1876        let children = entry_list.children.expect("EntryList must have children");
1877        assert_eq!(children.len(), 27);
1878    }
1879
1880    #[test]
1881    fn schema_reply_node_count() {
1882        let reply_list = Dlg::schema()
1883            .iter()
1884            .find(|f| f.label == "ReplyList")
1885            .expect("ReplyList must exist");
1886        let children = reply_list.children.expect("ReplyList must have children");
1887        assert_eq!(children.len(), 27);
1888    }
1889}