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
27use std::io::{Cursor, Read, Write};
28
29use crate::gff_helpers::{
30    get_bool, get_f32, get_i32, get_locstring, get_resref, get_string, get_u16, get_u32, get_u8,
31    upsert_field,
32};
33use rakata_core::{ResRef, StrRef};
34use rakata_formats::{
35    gff_schema::{FieldSchema, GffSchema, GffType},
36    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffStruct,
37    GffValue,
38};
39use thiserror::Error;
40
41/// A link between dialogue nodes (condition + target index).
42#[derive(Debug, Clone, PartialEq, Default)]
43pub struct DlgLink {
44    /// Condition script (`Active`). Empty resref means always active.
45    pub active: ResRef,
46    /// Target node index (`Index`) in the corresponding node array.
47    pub index: u32,
48    /// Display-inactive flag (`DisplayInactive`). Only meaningful on
49    /// entry->reply links; defaults to false for reply->entry links.
50    pub display_inactive: bool,
51}
52
53impl DlgLink {
54    fn from_gff_struct(s: &GffStruct) -> Self {
55        Self {
56            active: get_resref(s, "Active").unwrap_or_default(),
57            index: get_u32(s, "Index").unwrap_or(0),
58            display_inactive: get_bool(s, "DisplayInactive").unwrap_or(false),
59        }
60    }
61
62    fn to_gff_struct(&self, include_display_inactive: bool) -> GffStruct {
63        let mut s = GffStruct::new(0);
64        upsert_field(&mut s, "Active", GffValue::ResRef(self.active));
65        upsert_field(&mut s, "Index", GffValue::UInt32(self.index));
66        if include_display_inactive {
67            upsert_field(
68                &mut s,
69                "DisplayInactive",
70                GffValue::UInt8(u8::from(self.display_inactive)),
71            );
72        }
73        s
74    }
75}
76
77/// A per-node animation entry.
78#[derive(Debug, Clone, PartialEq)]
79pub struct DlgAnimation {
80    /// Participant tag (`Participant`).
81    pub participant: String,
82    /// Animation ID (`Animation`).
83    pub animation: u16,
84}
85
86impl DlgAnimation {
87    fn from_gff_struct(s: &GffStruct) -> Self {
88        Self {
89            participant: get_string(s, "Participant").unwrap_or_default(),
90            animation: get_u16(s, "Animation").unwrap_or(0),
91        }
92    }
93
94    fn to_gff_struct(&self) -> GffStruct {
95        let mut s = GffStruct::new(0);
96        upsert_field(
97            &mut s,
98            "Participant",
99            GffValue::String(self.participant.clone()),
100        );
101        upsert_field(&mut s, "Animation", GffValue::UInt16(self.animation));
102        s
103    }
104}
105
106/// A cutscene stunt actor entry.
107#[derive(Debug, Clone, PartialEq)]
108pub struct DlgStunt {
109    /// Participant tag (`Participant`).
110    pub participant: String,
111    /// Stunt model resref (`StuntModel`).
112    pub stunt_model: ResRef,
113}
114
115impl DlgStunt {
116    fn from_gff_struct(s: &GffStruct) -> Self {
117        Self {
118            participant: get_string(s, "Participant").unwrap_or_default(),
119            stunt_model: get_resref(s, "StuntModel").unwrap_or_default(),
120        }
121    }
122
123    fn to_gff_struct(&self) -> GffStruct {
124        let mut s = GffStruct::new(0);
125        upsert_field(
126            &mut s,
127            "Participant",
128            GffValue::String(self.participant.clone()),
129        );
130        upsert_field(&mut s, "StuntModel", GffValue::ResRef(self.stunt_model));
131        s
132    }
133}
134
135/// A dialogue node (entry = NPC line, reply = PC choice).
136///
137/// Entry and reply nodes share the same field layout. The difference is which
138/// link list they carry: entry nodes have `RepliesList`, reply nodes have
139/// `EntriesList`. The [`Dlg`] struct handles serializing the correct field
140/// name based on position.
141#[derive(Debug, Clone, PartialEq)]
142pub struct DlgNode {
143    // --- DialogBase fields ---
144    /// Localized display text (`Text`).
145    pub text: GffLocalizedString,
146    /// Script to run when this node fires (`Script`).
147    pub script: ResRef,
148    /// Speaker tag override (`Speaker`).
149    pub speaker: String,
150    /// Wait flags (`WaitFlags`).
151    pub wait_flags: u32,
152    /// Journal quest tag (`Quest`).
153    pub quest: String,
154    /// Journal quest entry ID (`QuestEntry`).
155    pub quest_entry: u32,
156    /// Plot index (`PlotIndex`).
157    pub plot_index: i32,
158    /// Plot XP percentage (`PlotXPPercentage`).
159    pub plot_xp_percentage: f32,
160    /// Delay in milliseconds (`Delay`).
161    pub delay: u32,
162    /// Fade type (`FadeType`).
163    pub fade_type: u8,
164    /// Fade color as RGB floats (`FadeColor`). GFF Vector3 type.
165    pub fade_color: [f32; 3],
166    /// Fade delay in seconds (`FadeDelay`).
167    pub fade_delay: f32,
168    /// Fade length in seconds (`FadeLength`).
169    pub fade_length: f32,
170    /// Sound resref (`Sound`).
171    pub sound: ResRef,
172    /// Voice-over resref (`VO_ResRef`).
173    pub vo_resref: ResRef,
174    /// Sound-exists flag (`SoundExists`).
175    pub sound_exists: bool,
176    /// Per-node animations (`AnimList`).
177    pub animations: Vec<DlgAnimation>,
178
179    // --- Camera fields ---
180    /// Listener tag override (`Listener`).
181    pub listener: String,
182    /// Camera angle preset (`CameraAngle`).
183    pub camera_angle: u32,
184    /// Static camera ID (`CameraID`).
185    pub camera_id: i32,
186    /// Camera height offset (`CamHeightOffset`).
187    pub cam_height_offset: f32,
188    /// Target height offset (`TarHeightOffset`).
189    pub tar_height_offset: f32,
190    /// Camera animation ID (`CameraAnimation`).
191    pub camera_animation: u16,
192    /// Camera video effect (`CamVidEffect`).
193    pub cam_vid_effect: i32,
194    /// Camera field of view (`CamFieldOfView`).
195    pub cam_field_of_view: f32,
196
197    // --- Links to other nodes ---
198    /// Links to the next nodes. For entry nodes these point into `ReplyList`;
199    /// for reply nodes they point into `EntryList`.
200    pub links: Vec<DlgLink>,
201}
202
203impl Default for DlgNode {
204    fn default() -> Self {
205        Self {
206            text: GffLocalizedString::new(StrRef::invalid()),
207            script: ResRef::blank(),
208            speaker: String::new(),
209            wait_flags: 0,
210            quest: String::new(),
211            quest_entry: 0,
212            plot_index: -1,
213            plot_xp_percentage: 1.0,
214            delay: u32::MAX,
215            fade_type: 0,
216            fade_color: [0.0, 0.0, 0.0],
217            fade_delay: 0.0,
218            fade_length: 0.0,
219            sound: ResRef::blank(),
220            vo_resref: ResRef::blank(),
221            sound_exists: false,
222            animations: Vec::new(),
223            listener: String::new(),
224            camera_angle: 0,
225            camera_id: -1,
226            cam_height_offset: 0.0,
227            tar_height_offset: 0.0,
228            camera_animation: 0,
229            cam_vid_effect: -1,
230            cam_field_of_view: -1.0,
231            links: Vec::new(),
232        }
233    }
234}
235
236impl DlgNode {
237    fn from_gff_struct(s: &GffStruct, link_field: &str) -> Self {
238        let animations = match s.field("AnimList") {
239            Some(GffValue::List(elements)) => {
240                elements.iter().map(DlgAnimation::from_gff_struct).collect()
241            }
242            _ => Vec::new(),
243        };
244
245        let links = match s.field(link_field) {
246            Some(GffValue::List(elements)) => {
247                elements.iter().map(DlgLink::from_gff_struct).collect()
248            }
249            _ => Vec::new(),
250        };
251
252        let fade_color = match s.field("FadeColor") {
253            Some(GffValue::Vector3(v)) => *v,
254            _ => [0.0, 0.0, 0.0],
255        };
256
257        Self {
258            text: get_locstring(s, "Text")
259                .cloned()
260                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
261            script: get_resref(s, "Script").unwrap_or_default(),
262            speaker: get_string(s, "Speaker").unwrap_or_default(),
263            wait_flags: get_u32(s, "WaitFlags").unwrap_or(0),
264            quest: get_string(s, "Quest").unwrap_or_default(),
265            quest_entry: get_u32(s, "QuestEntry").unwrap_or(0),
266            plot_index: get_i32(s, "PlotIndex").unwrap_or(-1),
267            plot_xp_percentage: get_f32(s, "PlotXPPercentage").unwrap_or(1.0),
268            delay: get_u32(s, "Delay").unwrap_or(u32::MAX),
269            fade_type: get_u8(s, "FadeType").unwrap_or(0),
270            fade_color,
271            fade_delay: get_f32(s, "FadeDelay").unwrap_or(0.0),
272            fade_length: get_f32(s, "FadeLength").unwrap_or(0.0),
273            sound: get_resref(s, "Sound").unwrap_or_default(),
274            vo_resref: get_resref(s, "VO_ResRef").unwrap_or_default(),
275            sound_exists: get_bool(s, "SoundExists").unwrap_or(false),
276            animations,
277            listener: get_string(s, "Listener").unwrap_or_default(),
278            camera_angle: get_u32(s, "CameraAngle").unwrap_or(0),
279            camera_id: get_i32(s, "CameraID").unwrap_or(-1),
280            cam_height_offset: get_f32(s, "CamHeightOffset").unwrap_or(0.0),
281            tar_height_offset: get_f32(s, "TarHeightOffset").unwrap_or(0.0),
282            camera_animation: get_u16(s, "CameraAnimation").unwrap_or(0),
283            cam_vid_effect: get_i32(s, "CamVidEffect").unwrap_or(-1),
284            cam_field_of_view: get_f32(s, "CamFieldOfView").unwrap_or(-1.0),
285            links,
286        }
287    }
288
289    fn to_gff_struct(
290        &self,
291        link_field: &str,
292        include_display_inactive: bool,
293        struct_id: i32,
294    ) -> GffStruct {
295        let mut s = GffStruct::new(struct_id);
296
297        upsert_field(&mut s, "Text", GffValue::LocalizedString(self.text.clone()));
298        upsert_field(&mut s, "Script", GffValue::ResRef(self.script));
299        upsert_field(&mut s, "Speaker", GffValue::String(self.speaker.clone()));
300        upsert_field(&mut s, "WaitFlags", GffValue::UInt32(self.wait_flags));
301        upsert_field(&mut s, "Quest", GffValue::String(self.quest.clone()));
302        upsert_field(&mut s, "QuestEntry", GffValue::UInt32(self.quest_entry));
303        upsert_field(&mut s, "PlotIndex", GffValue::Int32(self.plot_index));
304        upsert_field(
305            &mut s,
306            "PlotXPPercentage",
307            GffValue::Single(self.plot_xp_percentage),
308        );
309        upsert_field(&mut s, "Delay", GffValue::UInt32(self.delay));
310        upsert_field(&mut s, "FadeType", GffValue::UInt8(self.fade_type));
311        upsert_field(&mut s, "FadeColor", GffValue::Vector3(self.fade_color));
312        upsert_field(&mut s, "FadeDelay", GffValue::Single(self.fade_delay));
313        upsert_field(&mut s, "FadeLength", GffValue::Single(self.fade_length));
314        upsert_field(&mut s, "Sound", GffValue::ResRef(self.sound));
315        upsert_field(&mut s, "VO_ResRef", GffValue::ResRef(self.vo_resref));
316        upsert_field(
317            &mut s,
318            "SoundExists",
319            GffValue::UInt8(u8::from(self.sound_exists)),
320        );
321
322        let anim_structs: Vec<GffStruct> =
323            self.animations.iter().map(|a| a.to_gff_struct()).collect();
324        upsert_field(&mut s, "AnimList", GffValue::List(anim_structs));
325
326        upsert_field(&mut s, "Listener", GffValue::String(self.listener.clone()));
327        upsert_field(&mut s, "CameraAngle", GffValue::UInt32(self.camera_angle));
328        upsert_field(&mut s, "CameraID", GffValue::Int32(self.camera_id));
329        upsert_field(
330            &mut s,
331            "CamHeightOffset",
332            GffValue::Single(self.cam_height_offset),
333        );
334        upsert_field(
335            &mut s,
336            "TarHeightOffset",
337            GffValue::Single(self.tar_height_offset),
338        );
339        upsert_field(
340            &mut s,
341            "CameraAnimation",
342            GffValue::UInt16(self.camera_animation),
343        );
344        upsert_field(&mut s, "CamVidEffect", GffValue::Int32(self.cam_vid_effect));
345        upsert_field(
346            &mut s,
347            "CamFieldOfView",
348            GffValue::Single(self.cam_field_of_view),
349        );
350
351        let link_structs: Vec<GffStruct> = self
352            .links
353            .iter()
354            .map(|l| l.to_gff_struct(include_display_inactive))
355            .collect();
356        upsert_field(&mut s, link_field, GffValue::List(link_structs));
357
358        s
359    }
360}
361
362/// Typed DLG model built from/to [`Gff`] data.
363#[derive(Debug, Clone, PartialEq)]
364pub struct Dlg {
365    // --- Conversation config ---
366    /// Camera model resref (`CameraModel`).
367    pub camera_model: ResRef,
368    /// Delay before entry lines in ms (`DelayEntry`).
369    pub delay_entry: u32,
370    /// Delay before reply lines in ms (`DelayReply`).
371    pub delay_reply: u32,
372    /// End-conversation script (`EndConversation`).
373    pub end_conversation: ResRef,
374    /// End-conversation-abort script (`EndConverAbort`).
375    pub end_conver_abort: ResRef,
376    /// Skippable flag (`Skippable`).
377    pub skippable: bool,
378    /// Conversation type (`ConversationType`).
379    pub conversation_type: i32,
380    /// Computer type (`ComputerType`).
381    pub computer_type: u8,
382    /// Ambient music track (`AmbientTrack`).
383    pub ambient_track: ResRef,
384    /// Unequip-items flag (`UnequipItems`).
385    pub unequip_items: bool,
386    /// Unequip-head-item flag (`UnequipHItem`).
387    pub unequip_h_item: bool,
388    /// Animated-cutscene flag (`AnimatedCut`).
389    pub animated_cut: bool,
390    /// Old-hit-check flag (`OldHitCheck`).
391    pub old_hit_check: bool,
392
393    // --- Node arrays ---
394    /// Starting links (point into `entries` by index).
395    pub starting_list: Vec<DlgLink>,
396    /// Entry nodes (NPC dialogue lines).
397    pub entries: Vec<DlgNode>,
398    /// Reply nodes (PC dialogue choices).
399    pub replies: Vec<DlgNode>,
400    /// Cutscene stunt actors.
401    pub stunt_list: Vec<DlgStunt>,
402}
403
404impl Default for Dlg {
405    fn default() -> Self {
406        Self {
407            camera_model: ResRef::blank(),
408            delay_entry: 0,
409            delay_reply: 0,
410            end_conversation: ResRef::blank(),
411            end_conver_abort: ResRef::blank(),
412            skippable: true,
413            conversation_type: 0,
414            computer_type: 0,
415            ambient_track: ResRef::blank(),
416            unequip_items: false,
417            unequip_h_item: false,
418            animated_cut: false,
419            old_hit_check: false,
420            starting_list: Vec::new(),
421            entries: Vec::new(),
422            replies: Vec::new(),
423            stunt_list: Vec::new(),
424        }
425    }
426}
427
428impl Dlg {
429    /// Creates an empty DLG value.
430    pub fn new() -> Self {
431        Self::default()
432    }
433
434    /// Builds typed DLG data from a parsed GFF container.
435    pub fn from_gff(gff: &Gff) -> Result<Self, DlgError> {
436        if gff.file_type != *b"DLG " && gff.file_type != *b"GFF " {
437            return Err(DlgError::UnsupportedFileType(gff.file_type));
438        }
439
440        let root = &gff.root;
441
442        let starting_list = match root.field("StartingList") {
443            Some(GffValue::List(elements)) => {
444                elements.iter().map(DlgLink::from_gff_struct).collect()
445            }
446            _ => Vec::new(),
447        };
448
449        let entries = match root.field("EntryList") {
450            Some(GffValue::List(elements)) => elements
451                .iter()
452                .map(|s| DlgNode::from_gff_struct(s, "RepliesList"))
453                .collect(),
454            _ => Vec::new(),
455        };
456
457        let replies = match root.field("ReplyList") {
458            Some(GffValue::List(elements)) => elements
459                .iter()
460                .map(|s| DlgNode::from_gff_struct(s, "EntriesList"))
461                .collect(),
462            _ => Vec::new(),
463        };
464
465        let stunt_list = match root.field("StuntList") {
466            Some(GffValue::List(elements)) => {
467                elements.iter().map(DlgStunt::from_gff_struct).collect()
468            }
469            _ => Vec::new(),
470        };
471
472        Ok(Self {
473            camera_model: get_resref(root, "CameraModel").unwrap_or_default(),
474            delay_entry: get_u32(root, "DelayEntry").unwrap_or(0),
475            delay_reply: get_u32(root, "DelayReply").unwrap_or(0),
476            end_conversation: get_resref(root, "EndConversation").unwrap_or_default(),
477            end_conver_abort: get_resref(root, "EndConverAbort").unwrap_or_default(),
478            skippable: get_bool(root, "Skippable").unwrap_or(true),
479            conversation_type: get_i32(root, "ConversationType").unwrap_or(0),
480            computer_type: get_u8(root, "ComputerType").unwrap_or(0),
481            ambient_track: get_resref(root, "AmbientTrack").unwrap_or_default(),
482            unequip_items: get_bool(root, "UnequipItems").unwrap_or(false),
483            unequip_h_item: get_bool(root, "UnequipHItem").unwrap_or(false),
484            animated_cut: get_bool(root, "AnimatedCut").unwrap_or(false),
485            old_hit_check: get_bool(root, "OldHitCheck").unwrap_or(false),
486            starting_list,
487            entries,
488            replies,
489            stunt_list,
490        })
491    }
492
493    /// Converts this typed DLG value into a GFF container.
494    ///
495    /// All modeled fields are written explicitly from the typed representation.
496    pub fn to_gff(&self) -> Gff {
497        let mut root = GffStruct::new(-1);
498
499        upsert_field(
500            &mut root,
501            "CameraModel",
502            GffValue::ResRef(self.camera_model),
503        );
504        upsert_field(&mut root, "DelayEntry", GffValue::UInt32(self.delay_entry));
505        upsert_field(&mut root, "DelayReply", GffValue::UInt32(self.delay_reply));
506        upsert_field(
507            &mut root,
508            "EndConversation",
509            GffValue::ResRef(self.end_conversation),
510        );
511        upsert_field(
512            &mut root,
513            "EndConverAbort",
514            GffValue::ResRef(self.end_conver_abort),
515        );
516        upsert_field(
517            &mut root,
518            "Skippable",
519            GffValue::UInt8(u8::from(self.skippable)),
520        );
521        upsert_field(
522            &mut root,
523            "ConversationType",
524            GffValue::Int32(self.conversation_type),
525        );
526        upsert_field(
527            &mut root,
528            "ComputerType",
529            GffValue::UInt8(self.computer_type),
530        );
531        upsert_field(
532            &mut root,
533            "AmbientTrack",
534            GffValue::ResRef(self.ambient_track),
535        );
536        upsert_field(
537            &mut root,
538            "UnequipItems",
539            GffValue::UInt8(u8::from(self.unequip_items)),
540        );
541        upsert_field(
542            &mut root,
543            "UnequipHItem",
544            GffValue::UInt8(u8::from(self.unequip_h_item)),
545        );
546        upsert_field(
547            &mut root,
548            "AnimatedCut",
549            GffValue::UInt8(u8::from(self.animated_cut)),
550        );
551        upsert_field(
552            &mut root,
553            "OldHitCheck",
554            GffValue::UInt8(u8::from(self.old_hit_check)),
555        );
556
557        // Starting list (reply->entry link schema, no DisplayInactive).
558        let start_structs: Vec<GffStruct> = self
559            .starting_list
560            .iter()
561            .map(|l| l.to_gff_struct(false))
562            .collect();
563        upsert_field(&mut root, "StartingList", GffValue::List(start_structs));
564
565        // Entry nodes (struct_id=0, links go to RepliesList with DisplayInactive).
566        let entry_structs: Vec<GffStruct> = self
567            .entries
568            .iter()
569            .map(|n| n.to_gff_struct("RepliesList", true, 0))
570            .collect();
571        upsert_field(&mut root, "EntryList", GffValue::List(entry_structs));
572
573        // Reply nodes (struct_id=1, links go to EntriesList without DisplayInactive).
574        let reply_structs: Vec<GffStruct> = self
575            .replies
576            .iter()
577            .map(|n| n.to_gff_struct("EntriesList", false, 1))
578            .collect();
579        upsert_field(&mut root, "ReplyList", GffValue::List(reply_structs));
580
581        // Stunt list.
582        let stunt_structs: Vec<GffStruct> = self
583            .stunt_list
584            .iter()
585            .map(|st| st.to_gff_struct())
586            .collect();
587        upsert_field(&mut root, "StuntList", GffValue::List(stunt_structs));
588
589        Gff::new(*b"DLG ", root)
590    }
591}
592
593/// Errors produced while reading or writing typed DLG data.
594#[derive(Debug, Error)]
595pub enum DlgError {
596    /// Source file type is not supported by this parser.
597    #[error("unsupported DLG file type: {0:?}")]
598    UnsupportedFileType([u8; 4]),
599    /// Underlying GFF parser/writer error.
600    #[error(transparent)]
601    Gff(#[from] GffBinaryError),
602}
603
604/// Reads typed DLG data from a reader at the current stream position.
605#[cfg_attr(
606    feature = "tracing",
607    tracing::instrument(level = "debug", skip(reader))
608)]
609pub fn read_dlg<R: Read>(reader: &mut R) -> Result<Dlg, DlgError> {
610    let gff = read_gff(reader)?;
611    Dlg::from_gff(&gff)
612}
613
614/// Reads typed DLG data directly from bytes.
615#[cfg_attr(
616    feature = "tracing",
617    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
618)]
619pub fn read_dlg_from_bytes(bytes: &[u8]) -> Result<Dlg, DlgError> {
620    let gff = read_gff_from_bytes(bytes)?;
621    Dlg::from_gff(&gff)
622}
623
624/// Writes typed DLG data to an output writer.
625#[cfg_attr(
626    feature = "tracing",
627    tracing::instrument(level = "debug", skip(writer, dlg))
628)]
629pub fn write_dlg<W: Write>(writer: &mut W, dlg: &Dlg) -> Result<(), DlgError> {
630    let gff = dlg.to_gff();
631    write_gff(writer, &gff)?;
632    Ok(())
633}
634
635/// Serializes typed DLG data into a byte vector.
636#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(dlg)))]
637pub fn write_dlg_to_vec(dlg: &Dlg) -> Result<Vec<u8>, DlgError> {
638    let mut cursor = Cursor::new(Vec::new());
639    write_dlg(&mut cursor, dlg)?;
640    Ok(cursor.into_inner())
641}
642
643// =========================================================================
644// Leaf sub-schemas (no nested children)
645// =========================================================================
646
647/// Entry->Reply link children (`RepliesList` entries within each EntryList node).
648static ENTRY_REPLY_LINK_CHILDREN: &[FieldSchema] = &[
649    FieldSchema {
650        label: "Active",
651        expected_type: GffType::ResRef,
652        required: false,
653        children: None,
654        constraint: None,
655    },
656    FieldSchema {
657        label: "Index",
658        expected_type: GffType::UInt32,
659        required: false,
660        children: None,
661        constraint: None,
662    },
663    FieldSchema {
664        label: "DisplayInactive",
665        expected_type: GffType::UInt8,
666        required: false,
667        children: None,
668        constraint: None,
669    },
670];
671
672/// Reply->Entry link children (`EntriesList` entries within each ReplyList node).
673///
674/// Also used by `StartingList` entries (same schema: Active + Index).
675static REPLY_ENTRY_LINK_CHILDREN: &[FieldSchema] = &[
676    FieldSchema {
677        label: "Active",
678        expected_type: GffType::ResRef,
679        required: false,
680        children: None,
681        constraint: None,
682    },
683    FieldSchema {
684        label: "Index",
685        expected_type: GffType::UInt32,
686        required: false,
687        children: None,
688        constraint: None,
689    },
690];
691
692/// AnimList entry children (per-node animation list).
693static ANIM_LIST_CHILDREN: &[FieldSchema] = &[
694    FieldSchema {
695        label: "Participant",
696        expected_type: GffType::String,
697        required: false,
698        children: None,
699        constraint: None,
700    },
701    FieldSchema {
702        label: "Animation",
703        expected_type: GffType::UInt16,
704        required: false,
705        children: None,
706        constraint: None,
707    },
708];
709
710/// StuntList entry children (cutscene actor models).
711static STUNT_LIST_CHILDREN: &[FieldSchema] = &[
712    FieldSchema {
713        label: "Participant",
714        expected_type: GffType::String,
715        required: false,
716        children: None,
717        constraint: None,
718    },
719    FieldSchema {
720        label: "StuntModel",
721        expected_type: GffType::ResRef,
722        required: false,
723        children: None,
724        constraint: None,
725    },
726];
727
728// =========================================================================
729// Node sub-schemas (reference leaf schemas)
730// =========================================================================
731
732/// Entry node children: LoadDialogBase + LoadDialogCamera + RepliesList link.
733static ENTRY_NODE_CHILDREN: &[FieldSchema] = &[
734    // --- LoadDialogBase fields ---
735    FieldSchema {
736        label: "Text",
737        expected_type: GffType::LocalizedString,
738        required: false,
739        children: None,
740        constraint: None,
741    },
742    FieldSchema {
743        label: "Script",
744        expected_type: GffType::ResRef,
745        required: false,
746        children: None,
747        constraint: None,
748    },
749    FieldSchema {
750        label: "Speaker",
751        expected_type: GffType::String,
752        required: false,
753        children: None,
754        constraint: None,
755    },
756    FieldSchema {
757        label: "WaitFlags",
758        expected_type: GffType::UInt32,
759        required: false,
760        children: None,
761        constraint: None,
762    },
763    FieldSchema {
764        label: "Quest",
765        expected_type: GffType::String,
766        required: false,
767        children: None,
768        constraint: None,
769    },
770    FieldSchema {
771        label: "QuestEntry",
772        expected_type: GffType::UInt32,
773        required: false,
774        children: None,
775        constraint: None,
776    },
777    FieldSchema {
778        label: "PlotIndex",
779        expected_type: GffType::Int32,
780        required: false,
781        children: None,
782        constraint: None,
783    },
784    FieldSchema {
785        label: "PlotXPPercentage",
786        expected_type: GffType::Single,
787        required: false,
788        children: None,
789        constraint: None,
790    },
791    FieldSchema {
792        label: "Delay",
793        expected_type: GffType::UInt32,
794        required: false,
795        children: None,
796        constraint: None,
797    },
798    FieldSchema {
799        label: "FadeType",
800        expected_type: GffType::UInt8,
801        required: false,
802        children: None,
803        constraint: None,
804    },
805    FieldSchema {
806        label: "FadeColor",
807        expected_type: GffType::Vector3,
808        required: false,
809        children: None,
810        constraint: None,
811    },
812    FieldSchema {
813        label: "FadeDelay",
814        expected_type: GffType::Single,
815        required: false,
816        children: None,
817        constraint: None,
818    },
819    FieldSchema {
820        label: "FadeLength",
821        expected_type: GffType::Single,
822        required: false,
823        children: None,
824        constraint: None,
825    },
826    FieldSchema {
827        label: "Sound",
828        expected_type: GffType::ResRef,
829        required: false,
830        children: None,
831        constraint: None,
832    },
833    FieldSchema {
834        label: "VO_ResRef",
835        expected_type: GffType::ResRef,
836        required: false,
837        children: None,
838        constraint: None,
839    },
840    FieldSchema {
841        label: "SoundExists",
842        expected_type: GffType::UInt8,
843        required: false,
844        children: None,
845        constraint: None,
846    },
847    FieldSchema {
848        label: "AnimList",
849        expected_type: GffType::List,
850        required: false,
851        children: Some(ANIM_LIST_CHILDREN),
852        constraint: None,
853    },
854    // --- LoadDialogCamera fields ---
855    FieldSchema {
856        label: "Listener",
857        expected_type: GffType::String,
858        required: false,
859        children: None,
860        constraint: None,
861    },
862    FieldSchema {
863        label: "CameraAngle",
864        expected_type: GffType::UInt32,
865        required: false,
866        children: None,
867        constraint: None,
868    },
869    FieldSchema {
870        label: "CameraID",
871        expected_type: GffType::Int32,
872        required: false,
873        children: None,
874        constraint: None,
875    },
876    FieldSchema {
877        label: "CamHeightOffset",
878        expected_type: GffType::Single,
879        required: false,
880        children: None,
881        constraint: None,
882    },
883    FieldSchema {
884        label: "TarHeightOffset",
885        expected_type: GffType::Single,
886        required: false,
887        children: None,
888        constraint: None,
889    },
890    FieldSchema {
891        label: "CameraAnimation",
892        expected_type: GffType::UInt16,
893        required: false,
894        children: None,
895        constraint: None,
896    },
897    FieldSchema {
898        label: "CamVidEffect",
899        expected_type: GffType::Int32,
900        required: false,
901        children: None,
902        constraint: None,
903    },
904    FieldSchema {
905        label: "CamFieldOfView",
906        expected_type: GffType::Single,
907        required: false,
908        children: None,
909        constraint: None,
910    },
911    // --- Entry-specific link list ---
912    FieldSchema {
913        label: "RepliesList",
914        expected_type: GffType::List,
915        required: false,
916        children: Some(ENTRY_REPLY_LINK_CHILDREN),
917        constraint: None,
918    },
919];
920
921/// Reply node children: LoadDialogBase + LoadDialogCamera + EntriesList link.
922static REPLY_NODE_CHILDREN: &[FieldSchema] = &[
923    // --- LoadDialogBase fields (identical to entry) ---
924    FieldSchema {
925        label: "Text",
926        expected_type: GffType::LocalizedString,
927        required: false,
928        children: None,
929        constraint: None,
930    },
931    FieldSchema {
932        label: "Script",
933        expected_type: GffType::ResRef,
934        required: false,
935        children: None,
936        constraint: None,
937    },
938    FieldSchema {
939        label: "Speaker",
940        expected_type: GffType::String,
941        required: false,
942        children: None,
943        constraint: None,
944    },
945    FieldSchema {
946        label: "WaitFlags",
947        expected_type: GffType::UInt32,
948        required: false,
949        children: None,
950        constraint: None,
951    },
952    FieldSchema {
953        label: "Quest",
954        expected_type: GffType::String,
955        required: false,
956        children: None,
957        constraint: None,
958    },
959    FieldSchema {
960        label: "QuestEntry",
961        expected_type: GffType::UInt32,
962        required: false,
963        children: None,
964        constraint: None,
965    },
966    FieldSchema {
967        label: "PlotIndex",
968        expected_type: GffType::Int32,
969        required: false,
970        children: None,
971        constraint: None,
972    },
973    FieldSchema {
974        label: "PlotXPPercentage",
975        expected_type: GffType::Single,
976        required: false,
977        children: None,
978        constraint: None,
979    },
980    FieldSchema {
981        label: "Delay",
982        expected_type: GffType::UInt32,
983        required: false,
984        children: None,
985        constraint: None,
986    },
987    FieldSchema {
988        label: "FadeType",
989        expected_type: GffType::UInt8,
990        required: false,
991        children: None,
992        constraint: None,
993    },
994    FieldSchema {
995        label: "FadeColor",
996        expected_type: GffType::Vector3,
997        required: false,
998        children: None,
999        constraint: None,
1000    },
1001    FieldSchema {
1002        label: "FadeDelay",
1003        expected_type: GffType::Single,
1004        required: false,
1005        children: None,
1006        constraint: None,
1007    },
1008    FieldSchema {
1009        label: "FadeLength",
1010        expected_type: GffType::Single,
1011        required: false,
1012        children: None,
1013        constraint: None,
1014    },
1015    FieldSchema {
1016        label: "Sound",
1017        expected_type: GffType::ResRef,
1018        required: false,
1019        children: None,
1020        constraint: None,
1021    },
1022    FieldSchema {
1023        label: "VO_ResRef",
1024        expected_type: GffType::ResRef,
1025        required: false,
1026        children: None,
1027        constraint: None,
1028    },
1029    FieldSchema {
1030        label: "SoundExists",
1031        expected_type: GffType::UInt8,
1032        required: false,
1033        children: None,
1034        constraint: None,
1035    },
1036    FieldSchema {
1037        label: "AnimList",
1038        expected_type: GffType::List,
1039        required: false,
1040        children: Some(ANIM_LIST_CHILDREN),
1041        constraint: None,
1042    },
1043    // --- LoadDialogCamera fields (identical to entry) ---
1044    FieldSchema {
1045        label: "Listener",
1046        expected_type: GffType::String,
1047        required: false,
1048        children: None,
1049        constraint: None,
1050    },
1051    FieldSchema {
1052        label: "CameraAngle",
1053        expected_type: GffType::UInt32,
1054        required: false,
1055        children: None,
1056        constraint: None,
1057    },
1058    FieldSchema {
1059        label: "CameraID",
1060        expected_type: GffType::Int32,
1061        required: false,
1062        children: None,
1063        constraint: None,
1064    },
1065    FieldSchema {
1066        label: "CamHeightOffset",
1067        expected_type: GffType::Single,
1068        required: false,
1069        children: None,
1070        constraint: None,
1071    },
1072    FieldSchema {
1073        label: "TarHeightOffset",
1074        expected_type: GffType::Single,
1075        required: false,
1076        children: None,
1077        constraint: None,
1078    },
1079    FieldSchema {
1080        label: "CameraAnimation",
1081        expected_type: GffType::UInt16,
1082        required: false,
1083        children: None,
1084        constraint: None,
1085    },
1086    FieldSchema {
1087        label: "CamVidEffect",
1088        expected_type: GffType::Int32,
1089        required: false,
1090        children: None,
1091        constraint: None,
1092    },
1093    FieldSchema {
1094        label: "CamFieldOfView",
1095        expected_type: GffType::Single,
1096        required: false,
1097        children: None,
1098        constraint: None,
1099    },
1100    // --- Reply-specific link list ---
1101    FieldSchema {
1102        label: "EntriesList",
1103        expected_type: GffType::List,
1104        required: false,
1105        children: Some(REPLY_ENTRY_LINK_CHILDREN),
1106        constraint: None,
1107    },
1108];
1109
1110impl GffSchema for Dlg {
1111    fn schema() -> &'static [FieldSchema] {
1112        static SCHEMA: &[FieldSchema] = &[
1113            // --- Conversation config (13 scalars) ---
1114            FieldSchema {
1115                label: "CameraModel",
1116                expected_type: GffType::ResRef,
1117                required: false,
1118                children: None,
1119                constraint: None,
1120            },
1121            FieldSchema {
1122                label: "DelayEntry",
1123                expected_type: GffType::UInt32,
1124                required: false,
1125                children: None,
1126                constraint: None,
1127            },
1128            FieldSchema {
1129                label: "DelayReply",
1130                expected_type: GffType::UInt32,
1131                required: false,
1132                children: None,
1133                constraint: None,
1134            },
1135            FieldSchema {
1136                label: "EndConversation",
1137                expected_type: GffType::ResRef,
1138                required: false,
1139                children: None,
1140                constraint: None,
1141            },
1142            FieldSchema {
1143                label: "EndConverAbort",
1144                expected_type: GffType::ResRef,
1145                required: false,
1146                children: None,
1147                constraint: None,
1148            },
1149            FieldSchema {
1150                label: "Skippable",
1151                expected_type: GffType::UInt8,
1152                required: false,
1153                children: None,
1154                constraint: None,
1155            },
1156            FieldSchema {
1157                label: "ConversationType",
1158                expected_type: GffType::Int32,
1159                required: false,
1160                children: None,
1161                constraint: None,
1162            },
1163            FieldSchema {
1164                label: "ComputerType",
1165                expected_type: GffType::UInt8,
1166                required: false,
1167                children: None,
1168                constraint: None,
1169            },
1170            FieldSchema {
1171                label: "AmbientTrack",
1172                expected_type: GffType::ResRef,
1173                required: false,
1174                children: None,
1175                constraint: None,
1176            },
1177            FieldSchema {
1178                label: "UnequipItems",
1179                expected_type: GffType::UInt8,
1180                required: false,
1181                children: None,
1182                constraint: None,
1183            },
1184            FieldSchema {
1185                label: "UnequipHItem",
1186                expected_type: GffType::UInt8,
1187                required: false,
1188                children: None,
1189                constraint: None,
1190            },
1191            FieldSchema {
1192                label: "AnimatedCut",
1193                expected_type: GffType::UInt8,
1194                required: false,
1195                children: None,
1196                constraint: None,
1197            },
1198            FieldSchema {
1199                label: "OldHitCheck",
1200                expected_type: GffType::UInt8,
1201                required: false,
1202                children: None,
1203                constraint: None,
1204            },
1205            // --- Lists (4) ---
1206            FieldSchema {
1207                label: "EntryList",
1208                expected_type: GffType::List,
1209                required: false,
1210                children: Some(ENTRY_NODE_CHILDREN),
1211                constraint: None,
1212            },
1213            FieldSchema {
1214                label: "ReplyList",
1215                expected_type: GffType::List,
1216                required: false,
1217                children: Some(REPLY_NODE_CHILDREN),
1218                constraint: None,
1219            },
1220            FieldSchema {
1221                label: "StartingList",
1222                expected_type: GffType::List,
1223                required: false,
1224                children: Some(REPLY_ENTRY_LINK_CHILDREN),
1225                constraint: None,
1226            },
1227            FieldSchema {
1228                label: "StuntList",
1229                expected_type: GffType::List,
1230                required: false,
1231                children: Some(STUNT_LIST_CHILDREN),
1232                constraint: None,
1233            },
1234        ];
1235        SCHEMA
1236    }
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242
1243    /// Build a minimal DLG GFF for testing.
1244    fn make_test_dlg_gff() -> Gff {
1245        let mut root = GffStruct::new(-1);
1246        root.push_field("CameraModel", GffValue::resref_lit(""));
1247        root.push_field("DelayEntry", GffValue::UInt32(0));
1248        root.push_field("DelayReply", GffValue::UInt32(0));
1249        root.push_field("EndConversation", GffValue::resref_lit("k_end_conv"));
1250        root.push_field("EndConverAbort", GffValue::resref_lit("k_end_abort"));
1251        root.push_field("Skippable", GffValue::UInt8(1));
1252        root.push_field("ConversationType", GffValue::Int32(0));
1253        root.push_field("ComputerType", GffValue::UInt8(0));
1254        root.push_field("AmbientTrack", GffValue::resref_lit(""));
1255        root.push_field("UnequipItems", GffValue::UInt8(0));
1256        root.push_field("UnequipHItem", GffValue::UInt8(0));
1257        root.push_field("AnimatedCut", GffValue::UInt8(1));
1258        root.push_field("OldHitCheck", GffValue::UInt8(0));
1259
1260        // Starting list: one link pointing to entry 0.
1261        let mut start_link = GffStruct::new(0);
1262        start_link.push_field("Active", GffValue::resref_lit("k_cond_start"));
1263        start_link.push_field("Index", GffValue::UInt32(0));
1264        root.push_field("StartingList", GffValue::List(vec![start_link]));
1265
1266        // Entry 0: NPC says "Hello", links to reply 0.
1267        let mut entry0 = GffStruct::new(0);
1268        entry0.push_field(
1269            "Text",
1270            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(50000))),
1271        );
1272        entry0.push_field("Script", GffValue::resref_lit("k_entry_fire"));
1273        entry0.push_field("Speaker", GffValue::String("Bastila".into()));
1274        entry0.push_field("WaitFlags", GffValue::UInt32(0));
1275        entry0.push_field("Quest", GffValue::String("".into()));
1276        entry0.push_field("QuestEntry", GffValue::UInt32(0));
1277        entry0.push_field("PlotIndex", GffValue::Int32(-1));
1278        entry0.push_field("PlotXPPercentage", GffValue::Single(1.0));
1279        entry0.push_field("Delay", GffValue::UInt32(u32::MAX));
1280        entry0.push_field("FadeType", GffValue::UInt8(0));
1281        entry0.push_field("FadeColor", GffValue::Vector3([0.0, 0.0, 0.0]));
1282        entry0.push_field("FadeDelay", GffValue::Single(0.0));
1283        entry0.push_field("FadeLength", GffValue::Single(0.0));
1284        entry0.push_field("Sound", GffValue::resref_lit(""));
1285        entry0.push_field("VO_ResRef", GffValue::resref_lit("n_bastila_hi"));
1286        entry0.push_field("SoundExists", GffValue::UInt8(1));
1287        entry0.push_field("Listener", GffValue::String("".into()));
1288        entry0.push_field("CameraAngle", GffValue::UInt32(4));
1289        entry0.push_field("CameraID", GffValue::Int32(-1));
1290        entry0.push_field("CamHeightOffset", GffValue::Single(0.0));
1291        entry0.push_field("TarHeightOffset", GffValue::Single(0.0));
1292        entry0.push_field("CameraAnimation", GffValue::UInt16(0));
1293        entry0.push_field("CamVidEffect", GffValue::Int32(-1));
1294        entry0.push_field("CamFieldOfView", GffValue::Single(-1.0));
1295
1296        // Anim list on entry 0.
1297        let mut anim = GffStruct::new(0);
1298        anim.push_field("Participant", GffValue::String("Bastila".into()));
1299        anim.push_field("Animation", GffValue::UInt16(28));
1300        entry0.push_field("AnimList", GffValue::List(vec![anim]));
1301
1302        // Entry 0 links to reply 0 with DisplayInactive.
1303        let mut reply_link = GffStruct::new(0);
1304        reply_link.push_field("Active", GffValue::resref_lit(""));
1305        reply_link.push_field("Index", GffValue::UInt32(0));
1306        reply_link.push_field("DisplayInactive", GffValue::UInt8(1));
1307        entry0.push_field("RepliesList", GffValue::List(vec![reply_link]));
1308
1309        // Reply 0: PC says "Goodbye", links back to entry 0.
1310        let mut reply0 = GffStruct::new(1);
1311        reply0.push_field(
1312            "Text",
1313            GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(50001))),
1314        );
1315        reply0.push_field("Script", GffValue::resref_lit("k_reply_fire"));
1316        reply0.push_field("Speaker", GffValue::String("".into()));
1317        reply0.push_field("WaitFlags", GffValue::UInt32(0));
1318        reply0.push_field("Quest", GffValue::String("".into()));
1319        reply0.push_field("QuestEntry", GffValue::UInt32(0));
1320        reply0.push_field("PlotIndex", GffValue::Int32(-1));
1321        reply0.push_field("PlotXPPercentage", GffValue::Single(1.0));
1322        reply0.push_field("Delay", GffValue::UInt32(u32::MAX));
1323        reply0.push_field("FadeType", GffValue::UInt8(0));
1324        reply0.push_field("FadeColor", GffValue::Vector3([0.0, 0.0, 0.0]));
1325        reply0.push_field("FadeDelay", GffValue::Single(0.0));
1326        reply0.push_field("FadeLength", GffValue::Single(0.0));
1327        reply0.push_field("Sound", GffValue::resref_lit(""));
1328        reply0.push_field("VO_ResRef", GffValue::resref_lit(""));
1329        reply0.push_field("SoundExists", GffValue::UInt8(0));
1330        reply0.push_field("AnimList", GffValue::List(vec![]));
1331        reply0.push_field("Listener", GffValue::String("".into()));
1332        reply0.push_field("CameraAngle", GffValue::UInt32(0));
1333        reply0.push_field("CameraID", GffValue::Int32(-1));
1334        reply0.push_field("CamHeightOffset", GffValue::Single(0.0));
1335        reply0.push_field("TarHeightOffset", GffValue::Single(0.0));
1336        reply0.push_field("CameraAnimation", GffValue::UInt16(0));
1337        reply0.push_field("CamVidEffect", GffValue::Int32(-1));
1338        reply0.push_field("CamFieldOfView", GffValue::Single(-1.0));
1339
1340        let mut entry_link = GffStruct::new(0);
1341        entry_link.push_field("Active", GffValue::resref_lit(""));
1342        entry_link.push_field("Index", GffValue::UInt32(0));
1343        reply0.push_field("EntriesList", GffValue::List(vec![entry_link]));
1344
1345        root.push_field("EntryList", GffValue::List(vec![entry0]));
1346        root.push_field("ReplyList", GffValue::List(vec![reply0]));
1347
1348        // Stunt list with one actor.
1349        let mut stunt = GffStruct::new(0);
1350        stunt.push_field("Participant", GffValue::String("Bastila".into()));
1351        stunt.push_field("StuntModel", GffValue::resref_lit("p_bastilabb"));
1352        root.push_field("StuntList", GffValue::List(vec![stunt]));
1353
1354        Gff::new(*b"DLG ", root)
1355    }
1356
1357    #[test]
1358    fn reads_conversation_config() {
1359        let gff = make_test_dlg_gff();
1360        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1361
1362        assert_eq!(dlg.end_conversation, "k_end_conv");
1363        assert_eq!(dlg.end_conver_abort, "k_end_abort");
1364        assert!(dlg.skippable);
1365        assert_eq!(dlg.conversation_type, 0);
1366        assert!(dlg.animated_cut);
1367        assert!(!dlg.old_hit_check);
1368    }
1369
1370    #[test]
1371    fn reads_starting_list() {
1372        let gff = make_test_dlg_gff();
1373        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1374
1375        assert_eq!(dlg.starting_list.len(), 1);
1376        assert_eq!(dlg.starting_list[0].active, "k_cond_start");
1377        assert_eq!(dlg.starting_list[0].index, 0);
1378    }
1379
1380    #[test]
1381    fn reads_entry_node() {
1382        let gff = make_test_dlg_gff();
1383        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1384
1385        assert_eq!(dlg.entries.len(), 1);
1386        let entry = &dlg.entries[0];
1387        assert_eq!(entry.text.string_ref.raw(), 50000);
1388        assert_eq!(entry.script, "k_entry_fire");
1389        assert_eq!(entry.speaker, "Bastila");
1390        assert_eq!(entry.vo_resref, "n_bastila_hi");
1391        assert!(entry.sound_exists);
1392        assert_eq!(entry.camera_angle, 4);
1393        assert_eq!(entry.animations.len(), 1);
1394        assert_eq!(entry.animations[0].participant, "Bastila");
1395        assert_eq!(entry.animations[0].animation, 28);
1396        assert_eq!(entry.links.len(), 1);
1397        assert_eq!(entry.links[0].index, 0);
1398        assert!(entry.links[0].display_inactive);
1399    }
1400
1401    #[test]
1402    fn reads_reply_node() {
1403        let gff = make_test_dlg_gff();
1404        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1405
1406        assert_eq!(dlg.replies.len(), 1);
1407        let reply = &dlg.replies[0];
1408        assert_eq!(reply.text.string_ref.raw(), 50001);
1409        assert_eq!(reply.script, "k_reply_fire");
1410        assert!(!reply.sound_exists);
1411        assert_eq!(reply.links.len(), 1);
1412        assert_eq!(reply.links[0].index, 0);
1413    }
1414
1415    #[test]
1416    fn reads_stunt_list() {
1417        let gff = make_test_dlg_gff();
1418        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1419
1420        assert_eq!(dlg.stunt_list.len(), 1);
1421        assert_eq!(dlg.stunt_list[0].participant, "Bastila");
1422        assert_eq!(dlg.stunt_list[0].stunt_model, "p_bastilabb");
1423    }
1424
1425    #[test]
1426    fn all_fields_survive_typed_roundtrip() {
1427        let gff = make_test_dlg_gff();
1428        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1429        let bytes = write_dlg_to_vec(&dlg).expect("write succeeds");
1430        let reparsed = read_dlg_from_bytes(&bytes).expect("reparse succeeds");
1431
1432        assert_eq!(dlg, reparsed);
1433    }
1434
1435    #[test]
1436    fn fade_color_roundtrips_as_vector3() {
1437        let mut gff = make_test_dlg_gff();
1438        // Set a non-default FadeColor on entry 0.
1439        if let Some(GffValue::List(ref mut entries)) = gff
1440            .root
1441            .fields
1442            .iter_mut()
1443            .find(|f| f.label == "EntryList")
1444            .map(|f| &mut f.value)
1445        {
1446            // Replace the default [0,0,0] with a visible red.
1447            if let Some(field) = entries[0]
1448                .fields
1449                .iter_mut()
1450                .find(|f| f.label == "FadeColor")
1451            {
1452                field.value = GffValue::Vector3([1.0, 0.0, 0.0]);
1453            }
1454        }
1455
1456        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1457        assert_eq!(dlg.entries[0].fade_color, [1.0, 0.0, 0.0]);
1458
1459        let bytes = write_dlg_to_vec(&dlg).expect("write succeeds");
1460        let reparsed = read_dlg_from_bytes(&bytes).expect("reparse succeeds");
1461        assert_eq!(reparsed.entries[0].fade_color, [1.0, 0.0, 0.0]);
1462
1463        // Verify the GFF value is Vector3, not Struct.
1464        let rebuilt = dlg.to_gff();
1465        if let Some(GffValue::List(entries)) = rebuilt.root.field("EntryList") {
1466            let fade = entries[0]
1467                .field("FadeColor")
1468                .expect("FadeColor must be present");
1469            assert!(
1470                matches!(fade, GffValue::Vector3([1.0, 0.0, 0.0])),
1471                "FadeColor must be GffValue::Vector3, got {fade:?}"
1472            );
1473        } else {
1474            panic!("EntryList missing from rebuilt GFF");
1475        }
1476    }
1477
1478    #[test]
1479    fn typed_edits_roundtrip_through_gff_writer() {
1480        let gff = make_test_dlg_gff();
1481        let mut dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1482        dlg.end_conversation = ResRef::new("k_end_new").expect("valid test resref");
1483        dlg.entries[0].speaker = "Carth".into();
1484        dlg.entries[0].links[0].display_inactive = false;
1485
1486        let bytes = write_dlg_to_vec(&dlg).expect("write succeeds");
1487        let reparsed = read_dlg_from_bytes(&bytes).expect("reparse succeeds");
1488
1489        assert_eq!(reparsed.end_conversation, "k_end_new");
1490        assert_eq!(reparsed.entries[0].speaker, "Carth");
1491        assert!(!reparsed.entries[0].links[0].display_inactive);
1492    }
1493
1494    #[test]
1495    fn read_dlg_from_reader_matches_bytes_path() {
1496        let gff = make_test_dlg_gff();
1497        let bytes = {
1498            let mut c = Cursor::new(Vec::new());
1499            write_gff(&mut c, &gff).expect("test GFF serializes cleanly");
1500            c.into_inner()
1501        };
1502
1503        let mut cursor = Cursor::new(&bytes);
1504        let via_reader = read_dlg(&mut cursor).expect("reader parse succeeds");
1505        let via_bytes = read_dlg_from_bytes(&bytes).expect("bytes parse succeeds");
1506
1507        assert_eq!(via_reader, via_bytes);
1508    }
1509
1510    #[test]
1511    fn rejects_non_dlg_file_type() {
1512        let mut gff = make_test_dlg_gff();
1513        gff.file_type = *b"UTT ";
1514
1515        let err = Dlg::from_gff(&gff).expect_err("UTT must be rejected as DLG input");
1516        assert!(matches!(
1517            err,
1518            DlgError::UnsupportedFileType(file_type) if file_type == *b"UTT "
1519        ));
1520    }
1521
1522    #[test]
1523    fn write_dlg_matches_direct_gff_writer() {
1524        let gff = make_test_dlg_gff();
1525        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
1526
1527        let via_typed = write_dlg_to_vec(&dlg).expect("typed write succeeds");
1528
1529        let mut direct = Cursor::new(Vec::new());
1530        write_gff(&mut direct, &dlg.to_gff()).expect("direct write succeeds");
1531
1532        assert_eq!(via_typed, direct.into_inner());
1533    }
1534
1535    #[test]
1536    fn schema_field_count() {
1537        assert_eq!(Dlg::schema().len(), 17);
1538    }
1539
1540    #[test]
1541    fn schema_no_duplicate_labels() {
1542        let schema = Dlg::schema();
1543        let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
1544        labels.sort();
1545        let before = labels.len();
1546        labels.dedup();
1547        assert_eq!(before, labels.len(), "duplicate labels in DLG schema");
1548    }
1549
1550    #[test]
1551    fn schema_entry_node_count() {
1552        let entry_list = Dlg::schema()
1553            .iter()
1554            .find(|f| f.label == "EntryList")
1555            .expect("EntryList must exist");
1556        let children = entry_list.children.expect("EntryList must have children");
1557        assert_eq!(children.len(), 26);
1558    }
1559
1560    #[test]
1561    fn schema_reply_node_count() {
1562        let reply_list = Dlg::schema()
1563            .iter()
1564            .find(|f| f.label == "ReplyList")
1565            .expect("ReplyList must exist");
1566        let children = reply_list.children.expect("ReplyList must have children");
1567        assert_eq!(children.len(), 26);
1568    }
1569}