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//! ## Unmodelled fields
28//!
29//! Five labels vanilla `.dlg` files carry are not modelled here and are
30//! dropped by a round trip: `NumWords`, `VO_ID`, `IsChild`, `Comment` and
31//! `LinkComment`. None exists as a string in the executable, so no reader
32//! can reach them and there is nothing to project. The evidence is in
33//! `docs/src/internals/unread_fields.md`.
34
35use std::io::{Cursor, Read, Write};
36
37use rakata_core::ResRef;
38use rakata_formats::gff::{upsert_field, GffLabel};
39use rakata_formats::gff_label;
40use rakata_formats::schema::FromGff;
41use rakata_formats::schema::GffScalar;
42use rakata_formats::GENERIC_FILE_TYPE;
43use rakata_formats::{
44    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffModel,
45    GffStruct, GffValue,
46};
47use thiserror::Error;
48
49/// What a `RepliesList` element carries beyond a link's own four labels.
50///
51/// Declared apart from [`DlgLink`] because the same element type serves three
52/// lists and only this one holds the flag. A link type declaring it outright
53/// would put the label in `EntriesList` and `StartingList` too, where no file
54/// carries it.
55#[derive(Debug, Clone, PartialEq, GffModel)]
56#[gff_entry(DisplayInactive, wire = u8, stamped)]
57pub struct DlgLinkDisplayInactive {}
58
59/// What an `EntryList` element carries beyond a node's own twenty-six.
60///
61/// An entry node's links live under `RepliesList` and a reply node's under
62/// `EntriesList`. One label apiece, and the twenty-six they share stay
63/// twenty-six declarations reached twice rather than fifty-two.
64#[derive(Debug, Clone, PartialEq, GffModel)]
65#[gff_entry(
66    RepliesList,
67    list = DlgLink,
68    element_extra = DlgLinkDisplayInactive,
69    element_id = positional,
70    unexamined
71)]
72pub struct DlgNodeRepliesTail {}
73
74/// What a `ReplyList` element carries beyond a node's own twenty-six.
75#[derive(Debug, Clone, PartialEq, GffModel)]
76#[gff_entry(EntriesList, list = DlgLink, element_id = positional, unexamined)]
77pub struct DlgNodeEntriesTail {}
78
79/// A link between dialogue nodes (condition + target index).
80#[derive(Debug, Clone, PartialEq, GffModel)]
81#[gff_entry(IsChild, wire = u8, read_only_dead = "the label string does not exist anywhere in the engine binary, so no read path can name it", unexamined)]
82#[gff_entry(LinkComment, wire = String, read_only_dead = "the label string does not exist anywhere in the engine binary, so no read path can name it", unexamined)]
83pub struct DlgLink {
84    /// Condition script (`Active`). Empty resref means always active.
85    #[gff(Active, stamped)]
86    pub active: ResRef,
87    /// Target node index (`Index`) in the corresponding node array. Exceeding bounds causes fatal engine load failure.
88    #[gff(Index, stamped)]
89    pub index: u32,
90    /// Display-inactive flag (`DisplayInactive`), declared on
91    /// [`DlgLinkDisplayInactive`] because only a `RepliesList` element carries
92    /// it. Only meaningful on
93    /// entry->reply links; defaults to false for reply->entry links.
94    ///
95    /// Set, a reply whose `Active` condition fails is still shown to the
96    /// player as a disabled option; clear, it is dropped from the list. No
97    /// vanilla dialogue sets it, so shipped content always takes the drop
98    /// branch, but it gates behaviour rather than being inert, so it is a
99    /// capability a mod can reach for.
100    pub display_inactive: bool,
101}
102
103impl DlgLink {
104    /// Reads one link element.
105    ///
106    /// Named apart from the generated pair because it is not the same
107    /// signature: which list a link sits in decides one of its labels, so the
108    /// caller has to say.
109    ///
110    /// The flag is declared on [`DlgLinkDisplayInactive`] rather than here,
111    /// so the derive supplies neither half and both live on this type.
112    fn read_link(structure: &GffStruct) -> Self {
113        Self {
114            display_inactive: structure
115                .field("DisplayInactive")
116                .and_then(<bool as GffScalar>::from_gff_value)
117                .unwrap_or(false),
118            ..Self::read_declared(structure)
119        }
120    }
121
122    /// Writes one link element, with the flag only where its list carries it.
123    ///
124    /// Omitted because no vanilla dialogue and no save carries the label, not
125    /// because its value equals a constant. Those coincide here and are
126    /// different rules: the second would also drop the label out of a file
127    /// that set it explicitly at its default.
128    fn write_link(&self, structure: &mut GffStruct, carries_display_inactive: bool) {
129        self.write_declared(structure);
130        if carries_display_inactive && self.display_inactive {
131            upsert_field(
132                structure,
133                gff_label!("DisplayInactive"),
134                GffValue::UInt8(u8::from(self.display_inactive)),
135            );
136        }
137    }
138}
139
140/// A per-node animation entry.
141#[derive(Debug, Clone, PartialEq, GffModel)]
142pub struct DlgAnimation {
143    /// Participant tag (`Participant`).
144    #[gff(Participant, unexamined)]
145    pub participant: String,
146    /// Animation ID (`Animation`).
147    #[gff(Animation, unexamined)]
148    pub animation: u16,
149}
150
151/// A cutscene stunt actor entry.
152#[derive(Debug, Clone, PartialEq, GffModel)]
153pub struct DlgStunt {
154    /// Participant tag (`Participant`).
155    #[gff(Participant, unexamined)]
156    pub participant: String,
157    /// Stunt model resref (`StuntModel`).
158    #[gff(StuntModel, unexamined)]
159    pub stunt_model: ResRef,
160}
161
162/// A dialogue node (entry = NPC line, reply = PC choice).
163///
164/// Entry and reply nodes share the same field layout. The difference is which
165/// link list they carry: entry nodes have `RepliesList`, reply nodes have
166/// `EntriesList`. The [`Dlg`] struct handles serializing the correct field
167/// name based on position.
168#[derive(Debug, Clone, PartialEq, GffModel)]
169#[gff_entry(Comment, wire = String, read_only_dead = "the label string does not exist anywhere in the engine binary, so no read path can name it", unexamined)]
170pub struct DlgNode {
171    /// Lip-sync animations (`AnimList`).
172    #[gff(AnimList, unexamined, list = DlgAnimation, element_id = 0)]
173    pub animations: Vec<DlgAnimation>,
174    /// Localized display text (`Text`).
175    #[gff(Text, unexamined)]
176    pub text: GffLocalizedString,
177    /// Script to run when this node fires (`Script`).
178    #[gff(Script, unexamined)]
179    pub script: ResRef,
180    /// Speaker tag override (`Speaker`).
181    /// Vanilla leaves the label out where it would be the empty string the
182    /// read substitutes.
183    #[gff(Speaker, stamped, omit = audited_constant(1167))]
184    pub speaker: String,
185    /// Wait flags (`WaitFlags`).
186    #[gff(WaitFlags, stamped)]
187    pub wait_flags: u32,
188    /// Journal quest tag (`Quest`).
189    #[gff(Quest, stamped)]
190    pub quest: String,
191    /// Journal quest entry ID (`QuestEntry`).
192    #[gff(QuestEntry, stamped)]
193    pub quest_entry: u32,
194    /// Plot index (`PlotIndex`).
195    #[gff(PlotIndex, stamped)]
196    pub plot_index: i32,
197    /// Plot XP percentage (`PlotXPPercentage`).
198    #[gff(PlotXPPercentage, stamped)]
199    pub plot_xp_percentage: f32,
200    /// How long the node holds, in seconds (`Delay`). On a node with a sound
201    /// name, the sentinel `0xFFFFFFFF` takes the root `delay_entry`, for
202    /// replies too, and sets a `wait_flags` flag. What a node holds for is
203    /// laid out in `docs/src/formats/gff/dlg.md`.
204    #[gff(Delay, stamped)]
205    pub delay: u32,
206    /// Fade type (`FadeType`).
207    #[gff(FadeType, stamped)]
208    pub fade_type: u8,
209    /// Fade color as RGB floats (`FadeColor`). GFF Vector3 type. Discarded if `FadeType` is 0.
210    #[gff(FadeColor, stamped = [0.0, 0.0, 0.0])]
211    pub fade_color: [f32; 3],
212    /// Fade delay in seconds (`FadeDelay`). Discarded if `FadeType` is 0.
213    #[gff(FadeDelay, stamped)]
214    pub fade_delay: f32,
215    /// Fade length in seconds (`FadeLength`). Discarded if `FadeType` is 0.
216    #[gff(FadeLength, stamped)]
217    pub fade_length: f32,
218    /// Sound resref (`Sound`). If playback fails, the engine falls back to `vo_resref`.
219    #[gff(Sound, unexamined)]
220    pub sound: ResRef,
221    /// Voice-over resref (`VO_ResRef`). Played as a fallback when `sound` fails to execute.
222    #[gff(VO_ResRef, stamped)]
223    pub vo_resref: ResRef,
224    /// Sound-presence bitmask (`SoundExists`).
225    ///
226    /// A bitmask rather than a flag, which is why it is a byte. An absent one
227    /// resolves to [`SOUND_EXISTS_ABSENT`], a value a boolean could not carry,
228    /// and the engine forces it to `0` at runtime when both `Sound` and
229    /// `VO_ResRef` fail to resolve.
230    #[gff(SoundExists, stamped = 128)]
231    pub sound_exists: u8,
232    /// Listener tag override (`Listener`).
233    #[gff(Listener, stamped)]
234    pub listener: String,
235    /// Camera angle preset (`CameraAngle`).
236    #[gff(CameraAngle, stamped)]
237    pub camera_angle: u32,
238    /// Static camera ID (`CameraID`). Ignored by engine unless `CameraAngle` is 6.
239    #[gff(CameraID, stamped)]
240    pub camera_id: i32,
241    /// Camera height offset (`CamHeightOffset`).
242    #[gff(CamHeightOffset, stamped)]
243    pub cam_height_offset: f32,
244    /// Target height offset (`TarHeightOffset`).
245    #[gff(TarHeightOffset, stamped)]
246    pub tar_height_offset: f32,
247    /// Camera animation ID (`CameraAnimation`).
248    #[gff(CameraAnimation, stamped)]
249    pub camera_animation: u16,
250    /// Camera video effect (`CamVidEffect`).
251    #[gff(CamVidEffect, stamped = -1)]
252    pub cam_vid_effect: i32,
253    /// Camera field of view (`CamFieldOfView`). Aggressively validated; the engine forces this to `-1.0` if the field is missing or explicitly negative.
254    #[gff(CamFieldOfView, stamped = -1.0)]
255    pub cam_field_of_view: f32,
256
257    // --- Links to other nodes ---
258    /// Links to the next nodes. For entry nodes these point into `ReplyList`;
259    /// for reply nodes they point into `EntryList`.
260    ///
261    /// No `#[gff]`, because the label is the list's rather than this type's:
262    /// it is declared on [`DlgNodeRepliesTail`] and [`DlgNodeEntriesTail`],
263    /// and both codec halves are this type's for the same reason.
264    pub links: Vec<DlgLink>,
265}
266
267impl DlgNode {
268    /// Reads one node element, from whichever list holds it.
269    ///
270    /// The role decides one label and nothing else: an entry node's links sit
271    /// under `RepliesList` and a reply node's under `EntriesList`. Everything
272    /// above that is the same twenty-six declarations, which is why the
273    /// schema reaches one array from both lists rather than holding two.
274    fn read_node(structure: &GffStruct, link_label: &str) -> Self {
275        let links = match structure.field(link_label) {
276            Some(GffValue::List(elements)) => elements.iter().map(DlgLink::read_link).collect(),
277            _ => Vec::new(),
278        };
279        Self {
280            links,
281            ..Self::read_declared(structure)
282        }
283    }
284
285    /// Writes one node element under the label its list gives it.
286    fn write_node(
287        &self,
288        structure: &mut GffStruct,
289        link_label: GffLabel,
290        links_carry_display_inactive: bool,
291    ) {
292        self.write_declared(structure);
293        let elements: Vec<GffStruct> = self
294            .links
295            .iter()
296            .enumerate()
297            .map(|(index, link)| {
298                let mut element = GffStruct::new(i32::try_from(index).unwrap_or(0));
299                link.write_link(&mut element, links_carry_display_inactive);
300                element
301            })
302            .collect();
303        upsert_field(structure, link_label, GffValue::List(elements));
304    }
305}
306
307/// What `SoundExists` resolves to when a node does not carry it.
308///
309/// A genuine oddity: not `0`, not `1`. Nothing overrides it except an explicit
310/// file value or the runtime downgrade that forces `0` when both `Sound` and
311/// `VO_ResRef` fail to resolve, so a reader substituting `0` reports the
312/// downgraded state for every node that merely omitted the field.
313pub const SOUND_EXISTS_ABSENT: u8 = 0x80;
314
315/// Typed DLG model built from/to [`Gff`] data.
316#[derive(Debug, Clone, PartialEq, GffModel)]
317#[gff_entry(NumWords, wire = u32, read_only_dead = "the label string does not exist anywhere in the engine binary, so no read path can name it", unexamined)]
318#[gff_entry(VO_ID, wire = String, read_only_dead = "the label string does not exist anywhere in the engine binary, so no read path can name it", unexamined)]
319pub struct Dlg {
320    /// Camera model resref (`CameraModel`).
321    #[gff(CameraModel, stamped)]
322    pub camera_model: ResRef,
323    /// What a voiced node using the `Delay` sentinel holds for, in seconds,
324    /// entries and replies alike (`DelayEntry`). Defaults to 0 if missing.
325    #[gff(DelayEntry, stamped)]
326    pub delay_entry: u32,
327    /// Read and never used, since the `Delay` sentinel takes `DelayEntry` on
328    /// replies too (`DelayReply`). Defaults to 0 if missing.
329    #[gff(DelayReply, stamped)]
330    pub delay_reply: u32,
331    /// End-conversation script (`EndConversation`). Fallback to empty string `""` if missing.
332    #[gff(EndConversation, stamped)]
333    pub end_conversation: ResRef,
334    /// End-conversation-abort script (`EndConverAbort`). Fallback to empty string `""` if missing.
335    #[gff(EndConverAbort, stamped)]
336    pub end_conver_abort: ResRef,
337    /// Skippable flag (`Skippable`). Explicitly defaults to `1` (True) if missing.
338    #[gff(Skippable, stamped = true)]
339    pub skippable: bool,
340    /// Conversation type (`ConversationType`). `0` = Cinematic, `1` = Computer, `2` = Special. The Cinematic variant explicitly unstealths the entire party at runtime.
341    #[gff(ConversationType, stamped)]
342    pub conversation_type: i32,
343    /// Computer type (`ComputerType`). Ignored by engine unless `ConversationType` is 1.
344    #[gff(ComputerType, stamped)]
345    pub computer_type: u8,
346    /// Ambient music track (`AmbientTrack`). Fallback to empty string `""` if missing.
347    #[gff(AmbientTrack, stamped)]
348    pub ambient_track: ResRef,
349    /// Unequip-items flag (`UnequipItems`).
350    #[gff(UnequipItems, stamped)]
351    pub unequip_items: bool,
352    /// Unequip-head-item flag (`UnequipHItem`).
353    #[gff(UnequipHItem, stamped)]
354    pub unequip_h_item: bool,
355    /// Animated-cutscene flag (`AnimatedCut`). When non-zero, the engine forces a global unpauseable state for the duration of the conversation.
356    #[gff(AnimatedCut, stamped)]
357    pub animated_cut: bool,
358    /// Old-hit-check flag (`OldHitCheck`).
359    #[gff(OldHitCheck, stamped)]
360    pub old_hit_check: bool,
361    /// Entry nodes (NPC dialogue lines).
362    #[gff(
363        EntryList,
364        unexamined,
365        list = DlgNode,
366        element_extra = DlgNodeRepliesTail,
367        element_id = positional,
368        manual_read,
369        manual_write
370    )]
371    pub entries: Vec<DlgNode>,
372    /// Reply nodes (PC dialogue choices).
373    #[gff(
374        ReplyList,
375        unexamined,
376        list = DlgNode,
377        element_extra = DlgNodeEntriesTail,
378        element_id = positional,
379        manual_read,
380        manual_write
381    )]
382    pub replies: Vec<DlgNode>,
383    /// Starting links (point into `entries` by index).
384    #[gff(
385        StartingList,
386        unexamined,
387        list = DlgLink,
388        element_id = positional,
389        manual_read,
390        manual_write
391    )]
392    pub starting_list: Vec<DlgLink>,
393    /// Cutscene stunt actors.
394    #[gff(StuntList, unexamined, list = DlgStunt, element_id = 0)]
395    pub stunt_list: Vec<DlgStunt>,
396}
397
398impl Dlg {
399    /// Creates an empty DLG value.
400    pub fn new() -> Self {
401        Self::default()
402    }
403
404    /// Builds typed DLG data from a parsed GFF container.
405    ///
406    /// # Errors
407    ///
408    /// Returns [`DlgError::UnsupportedFileType`] when the container is not a
409    /// `DLG ` or a bare `GFF `.
410    pub fn from_gff(gff: &Gff) -> Result<Self, DlgError> {
411        if gff.file_type != <Dlg as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
412            return Err(DlgError::UnsupportedFileType(gff.file_type));
413        }
414        let root = &gff.root;
415        let nodes = |label: &str, link_label: &'static str| match root.field(label) {
416            Some(GffValue::List(elements)) => elements
417                .iter()
418                .map(|element| DlgNode::read_node(element, link_label))
419                .collect(),
420            _ => Vec::new(),
421        };
422        Ok(Self {
423            starting_list: match root.field("StartingList") {
424                Some(GffValue::List(elements)) => elements.iter().map(DlgLink::read_link).collect(),
425                _ => Vec::new(),
426            },
427            entries: nodes("EntryList", "RepliesList"),
428            replies: nodes("ReplyList", "EntriesList"),
429            ..Self::read_declared(root)
430        })
431    }
432
433    /// Converts this typed DLG value into a GFF container.
434    pub fn to_gff(&self) -> Gff {
435        let mut root = GffStruct::new(-1);
436        self.write_declared(&mut root);
437
438        // A link's own list decides one of its labels and a node's decides
439        // one of its, which no declaration form states, so the three lists
440        // that carry either are written here.
441        let links: Vec<GffStruct> = self
442            .starting_list
443            .iter()
444            .enumerate()
445            .map(|(index, link)| {
446                let mut element = GffStruct::new(i32::try_from(index).unwrap_or(0));
447                link.write_link(&mut element, false);
448                element
449            })
450            .collect();
451        upsert_field(&mut root, gff_label!("StartingList"), GffValue::List(links));
452
453        for (label, link_label, nodes, display_inactive) in [
454            (
455                gff_label!("EntryList"),
456                gff_label!("RepliesList"),
457                &self.entries,
458                true,
459            ),
460            (
461                gff_label!("ReplyList"),
462                gff_label!("EntriesList"),
463                &self.replies,
464                false,
465            ),
466        ] {
467            let elements: Vec<GffStruct> = nodes
468                .iter()
469                .enumerate()
470                .map(|(index, node)| {
471                    let mut element = GffStruct::new(i32::try_from(index).unwrap_or(0));
472                    node.write_node(&mut element, link_label, display_inactive);
473                    element
474                })
475                .collect();
476            upsert_field(&mut root, label, GffValue::List(elements));
477        }
478
479        Gff::new(*b"DLG ", root)
480    }
481}
482
483/// Errors produced while reading or writing typed DLG data.
484#[derive(Debug, Error)]
485pub enum DlgError {
486    /// Source file type is not supported by this parser.
487    #[error("unsupported DLG file type: {0:?}")]
488    UnsupportedFileType([u8; 4]),
489    /// Underlying GFF parser/writer error.
490    #[error(transparent)]
491    Gff(#[from] GffBinaryError),
492}
493
494/// Reads typed DLG data from a reader at the current stream position.
495///
496/// # Errors
497///
498/// [`DlgError::Gff`] when the stream is not a readable GFF, and
499/// [`DlgError::UnsupportedFileType`] when it is a GFF of some other format,
500/// carrying the fourcc that was found.
501#[cfg_attr(
502    feature = "tracing",
503    tracing::instrument(level = "debug", skip(reader))
504)]
505pub fn read_dlg<R: Read>(reader: &mut R) -> Result<Dlg, DlgError> {
506    let gff = read_gff(reader)?;
507    Dlg::from_gff(&gff)
508}
509
510/// Reads typed DLG data directly from bytes.
511///
512/// # Errors
513///
514/// [`DlgError::Gff`] when `bytes` are not a readable GFF, and
515/// [`DlgError::UnsupportedFileType`] when they are a GFF of some other format,
516/// carrying the fourcc that was found.
517#[cfg_attr(
518    feature = "tracing",
519    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
520)]
521pub fn read_dlg_from_bytes(bytes: &[u8]) -> Result<Dlg, DlgError> {
522    let gff = read_gff_from_bytes(bytes)?;
523    Dlg::from_gff(&gff)
524}
525
526/// Authors the DLG file the typed view describes, into a writer.
527///
528/// # Errors
529///
530/// [`DlgError::Gff`] when the writer fails or a value will not encode. The
531/// typed view fixes the file type, so `UnsupportedFileType` cannot arise on
532/// this side.
533#[cfg_attr(
534    feature = "tracing",
535    tracing::instrument(level = "debug", skip(writer, dlg))
536)]
537pub fn author_dlg<W: Write>(writer: &mut W, dlg: &Dlg) -> Result<(), DlgError> {
538    let gff = dlg.to_gff();
539    write_gff(writer, &gff)?;
540    Ok(())
541}
542
543/// Authors the DLG file the typed view describes, as bytes.
544///
545/// # Errors
546///
547/// [`DlgError::Gff`] when a value will not encode. Writing into a `Vec` has no
548/// I/O to fail at.
549#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(dlg)))]
550pub fn author_dlg_to_vec(dlg: &Dlg) -> Result<Vec<u8>, DlgError> {
551    let mut cursor = Cursor::new(Vec::new());
552    author_dlg(&mut cursor, dlg)?;
553    Ok(cursor.into_inner())
554}
555
556// =========================================================================
557// Leaf sub-schemas (no nested children)
558// =========================================================================
559
560// =========================================================================
561// Node sub-schemas (reference leaf schemas)
562// =========================================================================
563
564#[cfg(test)]
565mod tests {
566    use rakata_formats::schema::{HasSchema, Shape};
567
568    /// One link written into a struct of its own, with the role its list
569    /// gives it.
570    macro_rules! written_link {
571        ($value:expr, $carries:expr) => {{
572            let mut structure = GffStruct::new(0);
573            $value.write_link(&mut structure, $carries);
574            structure
575        }};
576    }
577
578    use super::*;
579
580    #[test]
581    fn display_inactive_is_omitted_at_its_default_and_written_otherwise() {
582        // Both directions, and each failure is different. Vanilla never
583        // writes this field, so writing a zero would put one on every link
584        // the engine leaves out. Writing it when set is the other half: an
585        // omitted field reads back as the constant, which would drop the
586        // flag silently.
587        let cleared = DlgLink::default();
588        assert!(!cleared.display_inactive);
589        assert!(written_link!(cleared, true)
590            .field("DisplayInactive")
591            .is_none());
592
593        let set = DlgLink {
594            display_inactive: true,
595            ..DlgLink::default()
596        };
597        let written = written_link!(set, true);
598        assert_eq!(written.field("DisplayInactive"), Some(&GffValue::UInt8(1)));
599        assert!(DlgLink::read_link(&written).display_inactive);
600    }
601
602    /// Build a minimal DLG GFF for testing.
603    fn make_test_dlg_gff() -> Gff {
604        let mut root = GffStruct::new(-1);
605        root.push_field(gff_label!("CameraModel"), GffValue::resref_lit(""));
606        root.push_field(gff_label!("DelayEntry"), GffValue::UInt32(0));
607        root.push_field(gff_label!("DelayReply"), GffValue::UInt32(0));
608        root.push_field(
609            gff_label!("EndConversation"),
610            GffValue::resref_lit("k_end_conv"),
611        );
612        root.push_field(
613            gff_label!("EndConverAbort"),
614            GffValue::resref_lit("k_end_abort"),
615        );
616        root.push_field(gff_label!("Skippable"), GffValue::UInt8(1));
617        root.push_field(gff_label!("ConversationType"), GffValue::Int32(0));
618        root.push_field(gff_label!("ComputerType"), GffValue::UInt8(0));
619        root.push_field(gff_label!("AmbientTrack"), GffValue::resref_lit(""));
620        root.push_field(gff_label!("UnequipItems"), GffValue::UInt8(0));
621        root.push_field(gff_label!("UnequipHItem"), GffValue::UInt8(0));
622        root.push_field(gff_label!("AnimatedCut"), GffValue::UInt8(1));
623        root.push_field(gff_label!("OldHitCheck"), GffValue::UInt8(0));
624
625        // Starting list: one link pointing to entry 0.
626        let mut start_link = GffStruct::new(0);
627        start_link.push_field(gff_label!("Active"), GffValue::resref_lit("k_cond_start"));
628        start_link.push_field(gff_label!("Index"), GffValue::UInt32(0));
629        root.push_field(gff_label!("StartingList"), GffValue::List(vec![start_link]));
630
631        // Entry 0: NPC says "Hello", links to reply 0.
632        let mut entry0 = GffStruct::new(0);
633        entry0.push_field(
634            gff_label!("Text"),
635            GffValue::LocalizedString(GffLocalizedString::new(rakata_core::StrRef::from_raw(
636                50000,
637            ))),
638        );
639        entry0.push_field(gff_label!("Script"), GffValue::resref_lit("k_entry_fire"));
640        entry0.push_field(gff_label!("Speaker"), GffValue::String("Bastila".into()));
641        entry0.push_field(gff_label!("WaitFlags"), GffValue::UInt32(0));
642        entry0.push_field(gff_label!("Quest"), GffValue::String("".into()));
643        entry0.push_field(gff_label!("QuestEntry"), GffValue::UInt32(0));
644        entry0.push_field(gff_label!("PlotIndex"), GffValue::Int32(-1));
645        entry0.push_field(gff_label!("PlotXPPercentage"), GffValue::Single(1.0));
646        entry0.push_field(gff_label!("Delay"), GffValue::UInt32(u32::MAX));
647        entry0.push_field(gff_label!("FadeType"), GffValue::UInt8(0));
648        entry0.push_field(gff_label!("FadeColor"), GffValue::Vector3([0.0, 0.0, 0.0]));
649        entry0.push_field(gff_label!("FadeDelay"), GffValue::Single(0.0));
650        entry0.push_field(gff_label!("FadeLength"), GffValue::Single(0.0));
651        entry0.push_field(gff_label!("Sound"), GffValue::resref_lit(""));
652        entry0.push_field(
653            gff_label!("VO_ResRef"),
654            GffValue::resref_lit("n_bastila_hi"),
655        );
656        entry0.push_field(gff_label!("SoundExists"), GffValue::UInt8(1));
657        entry0.push_field(gff_label!("Listener"), GffValue::String("".into()));
658        entry0.push_field(gff_label!("CameraAngle"), GffValue::UInt32(4));
659        entry0.push_field(gff_label!("CameraID"), GffValue::Int32(-1));
660        entry0.push_field(gff_label!("CamHeightOffset"), GffValue::Single(0.0));
661        entry0.push_field(gff_label!("TarHeightOffset"), GffValue::Single(0.0));
662        entry0.push_field(gff_label!("CameraAnimation"), GffValue::UInt16(0));
663        entry0.push_field(gff_label!("CamVidEffect"), GffValue::Int32(-1));
664        entry0.push_field(gff_label!("CamFieldOfView"), GffValue::Single(-1.0));
665
666        // Anim list on entry 0.
667        let mut anim = GffStruct::new(0);
668        anim.push_field(
669            gff_label!("Participant"),
670            GffValue::String("Bastila".into()),
671        );
672        anim.push_field(gff_label!("Animation"), GffValue::UInt16(28));
673        entry0.push_field(gff_label!("AnimList"), GffValue::List(vec![anim]));
674
675        // Entry 0 links to reply 0 with DisplayInactive.
676        let mut reply_link = GffStruct::new(0);
677        reply_link.push_field(gff_label!("Active"), GffValue::resref_lit(""));
678        reply_link.push_field(gff_label!("Index"), GffValue::UInt32(0));
679        reply_link.push_field(gff_label!("DisplayInactive"), GffValue::UInt8(1));
680        entry0.push_field(gff_label!("RepliesList"), GffValue::List(vec![reply_link]));
681
682        // Reply 0: PC says "Goodbye", links back to entry 0.
683        let mut reply0 = GffStruct::new(1);
684        reply0.push_field(
685            gff_label!("Text"),
686            GffValue::LocalizedString(GffLocalizedString::new(rakata_core::StrRef::from_raw(
687                50001,
688            ))),
689        );
690        reply0.push_field(gff_label!("Script"), GffValue::resref_lit("k_reply_fire"));
691        reply0.push_field(gff_label!("Speaker"), GffValue::String("".into()));
692        reply0.push_field(gff_label!("WaitFlags"), GffValue::UInt32(0));
693        reply0.push_field(gff_label!("Quest"), GffValue::String("".into()));
694        reply0.push_field(gff_label!("QuestEntry"), GffValue::UInt32(0));
695        reply0.push_field(gff_label!("PlotIndex"), GffValue::Int32(-1));
696        reply0.push_field(gff_label!("PlotXPPercentage"), GffValue::Single(1.0));
697        reply0.push_field(gff_label!("Delay"), GffValue::UInt32(u32::MAX));
698        reply0.push_field(gff_label!("FadeType"), GffValue::UInt8(0));
699        reply0.push_field(gff_label!("FadeColor"), GffValue::Vector3([0.0, 0.0, 0.0]));
700        reply0.push_field(gff_label!("FadeDelay"), GffValue::Single(0.0));
701        reply0.push_field(gff_label!("FadeLength"), GffValue::Single(0.0));
702        reply0.push_field(gff_label!("Sound"), GffValue::resref_lit(""));
703        reply0.push_field(gff_label!("VO_ResRef"), GffValue::resref_lit(""));
704        reply0.push_field(gff_label!("SoundExists"), GffValue::UInt8(0));
705        reply0.push_field(gff_label!("AnimList"), GffValue::List(vec![]));
706        reply0.push_field(gff_label!("Listener"), GffValue::String("".into()));
707        reply0.push_field(gff_label!("CameraAngle"), GffValue::UInt32(0));
708        reply0.push_field(gff_label!("CameraID"), GffValue::Int32(-1));
709        reply0.push_field(gff_label!("CamHeightOffset"), GffValue::Single(0.0));
710        reply0.push_field(gff_label!("TarHeightOffset"), GffValue::Single(0.0));
711        reply0.push_field(gff_label!("CameraAnimation"), GffValue::UInt16(0));
712        reply0.push_field(gff_label!("CamVidEffect"), GffValue::Int32(-1));
713        reply0.push_field(gff_label!("CamFieldOfView"), GffValue::Single(-1.0));
714
715        let mut entry_link = GffStruct::new(0);
716        entry_link.push_field(gff_label!("Active"), GffValue::resref_lit(""));
717        entry_link.push_field(gff_label!("Index"), GffValue::UInt32(0));
718        reply0.push_field(gff_label!("EntriesList"), GffValue::List(vec![entry_link]));
719
720        root.push_field(gff_label!("EntryList"), GffValue::List(vec![entry0]));
721        root.push_field(gff_label!("ReplyList"), GffValue::List(vec![reply0]));
722
723        // Stunt list with one actor.
724        let mut stunt = GffStruct::new(0);
725        stunt.push_field(
726            gff_label!("Participant"),
727            GffValue::String("Bastila".into()),
728        );
729        stunt.push_field(
730            gff_label!("StuntModel"),
731            GffValue::resref_lit("p_bastilabb"),
732        );
733        root.push_field(gff_label!("StuntList"), GffValue::List(vec![stunt]));
734
735        Gff::new(*b"DLG ", root)
736    }
737
738    #[test]
739    fn reads_conversation_config() {
740        let gff = make_test_dlg_gff();
741        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
742
743        assert_eq!(dlg.end_conversation, "k_end_conv");
744        assert_eq!(dlg.end_conver_abort, "k_end_abort");
745        assert!(dlg.skippable);
746        assert_eq!(dlg.conversation_type, 0);
747        assert!(dlg.animated_cut);
748        assert!(!dlg.old_hit_check);
749    }
750
751    #[test]
752    fn reads_starting_list() {
753        let gff = make_test_dlg_gff();
754        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
755
756        assert_eq!(dlg.starting_list.len(), 1);
757        assert_eq!(dlg.starting_list[0].active, "k_cond_start");
758        assert_eq!(dlg.starting_list[0].index, 0);
759    }
760
761    #[test]
762    fn reads_entry_node() {
763        let gff = make_test_dlg_gff();
764        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
765
766        assert_eq!(dlg.entries.len(), 1);
767        let entry = &dlg.entries[0];
768        assert_eq!(entry.text.string_ref.raw(), 50000);
769        assert_eq!(entry.script, "k_entry_fire");
770        assert_eq!(entry.speaker, "Bastila");
771        assert_eq!(entry.vo_resref, "n_bastila_hi");
772        assert_eq!(entry.sound_exists, 1);
773        assert_eq!(entry.camera_angle, 4);
774        assert_eq!(entry.animations.len(), 1);
775        assert_eq!(entry.animations[0].participant, "Bastila");
776        assert_eq!(entry.animations[0].animation, 28);
777        assert_eq!(entry.links.len(), 1);
778        assert_eq!(entry.links[0].index, 0);
779        assert!(entry.links[0].display_inactive);
780    }
781
782    #[test]
783    fn reads_reply_node() {
784        let gff = make_test_dlg_gff();
785        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
786
787        assert_eq!(dlg.replies.len(), 1);
788        let reply = &dlg.replies[0];
789        assert_eq!(reply.text.string_ref.raw(), 50001);
790        assert_eq!(reply.script, "k_reply_fire");
791        assert_eq!(reply.sound_exists, 0);
792        assert_eq!(reply.links.len(), 1);
793        assert_eq!(reply.links[0].index, 0);
794    }
795
796    #[test]
797    fn reads_stunt_list() {
798        let gff = make_test_dlg_gff();
799        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
800
801        assert_eq!(dlg.stunt_list.len(), 1);
802        assert_eq!(dlg.stunt_list[0].participant, "Bastila");
803        assert_eq!(dlg.stunt_list[0].stunt_model, "p_bastilabb");
804    }
805
806    #[test]
807    fn all_fields_survive_typed_roundtrip() {
808        let gff = make_test_dlg_gff();
809        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
810        let bytes = author_dlg_to_vec(&dlg).expect("write succeeds");
811        let reparsed = read_dlg_from_bytes(&bytes).expect("reparse succeeds");
812
813        assert_eq!(dlg, reparsed);
814    }
815
816    #[test]
817    fn fade_color_roundtrips_as_vector3() {
818        let mut gff = make_test_dlg_gff();
819        // Set a non-default FadeColor on entry 0.
820        if let Some(GffValue::List(ref mut entries)) = gff
821            .root
822            .fields
823            .iter_mut()
824            .find(|f| f.label == "EntryList")
825            .map(|f| &mut f.value)
826        {
827            // Replace the default [0,0,0] with a visible red.
828            if let Some(field) = entries[0]
829                .fields
830                .iter_mut()
831                .find(|f| f.label == "FadeColor")
832            {
833                field.value = GffValue::Vector3([1.0, 0.0, 0.0]);
834            }
835        }
836
837        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
838        assert_eq!(dlg.entries[0].fade_color, [1.0, 0.0, 0.0]);
839
840        let bytes = author_dlg_to_vec(&dlg).expect("write succeeds");
841        let reparsed = read_dlg_from_bytes(&bytes).expect("reparse succeeds");
842        assert_eq!(reparsed.entries[0].fade_color, [1.0, 0.0, 0.0]);
843
844        // Verify the GFF value is Vector3, not Struct.
845        let rebuilt = dlg.to_gff();
846        if let Some(GffValue::List(entries)) = rebuilt.root.field("EntryList") {
847            let fade = entries[0]
848                .field("FadeColor")
849                .expect("FadeColor must be present");
850            assert!(
851                matches!(fade, GffValue::Vector3([1.0, 0.0, 0.0])),
852                "FadeColor must be GffValue::Vector3, got {fade:?}"
853            );
854        } else {
855            panic!("EntryList missing from rebuilt GFF");
856        }
857    }
858
859    #[test]
860    fn typed_edits_roundtrip_through_gff_writer() {
861        let gff = make_test_dlg_gff();
862        let mut dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
863        dlg.end_conversation = ResRef::new("k_end_new").expect("valid test resref");
864        dlg.entries[0].speaker = "Carth".into();
865        dlg.entries[0].links[0].display_inactive = false;
866
867        let bytes = author_dlg_to_vec(&dlg).expect("write succeeds");
868        let reparsed = read_dlg_from_bytes(&bytes).expect("reparse succeeds");
869
870        assert_eq!(reparsed.end_conversation, "k_end_new");
871        assert_eq!(reparsed.entries[0].speaker, "Carth");
872        assert!(!reparsed.entries[0].links[0].display_inactive);
873    }
874
875    #[test]
876    fn read_dlg_from_reader_matches_bytes_path() {
877        let gff = make_test_dlg_gff();
878        let bytes = {
879            let mut c = Cursor::new(Vec::new());
880            write_gff(&mut c, &gff).expect("test GFF serializes cleanly");
881            c.into_inner()
882        };
883
884        let mut cursor = Cursor::new(&bytes);
885        let via_reader = read_dlg(&mut cursor).expect("reader parse succeeds");
886        let via_bytes = read_dlg_from_bytes(&bytes).expect("bytes parse succeeds");
887
888        assert_eq!(via_reader, via_bytes);
889    }
890
891    #[test]
892    fn rejects_non_dlg_file_type() {
893        let mut gff = make_test_dlg_gff();
894        gff.file_type = *b"UTT ";
895
896        let err = Dlg::from_gff(&gff).expect_err("UTT must be rejected as DLG input");
897        assert!(matches!(
898            err,
899            DlgError::UnsupportedFileType(file_type) if file_type == *b"UTT "
900        ));
901    }
902
903    #[test]
904    fn write_dlg_matches_direct_gff_writer() {
905        let gff = make_test_dlg_gff();
906        let dlg = Dlg::from_gff(&gff).expect("test GFF is well-formed");
907
908        let via_typed = author_dlg_to_vec(&dlg).expect("typed write succeeds");
909
910        let mut direct = Cursor::new(Vec::new());
911        write_gff(&mut direct, &dlg.to_gff()).expect("direct write succeeds");
912
913        assert_eq!(via_typed, direct.into_inner());
914    }
915
916    #[test]
917    fn schema_field_count() {
918        assert_eq!(Dlg::schema().len(), 19);
919    }
920
921    #[test]
922    fn schema_no_duplicate_labels() {
923        let mut labels: Vec<&str> = Dlg::schema().iter().map(|f| f.label.as_str()).collect();
924        labels.sort_unstable();
925        let before = labels.len();
926        labels.dedup();
927        assert_eq!(before, labels.len(), "duplicate labels in DLG schema");
928    }
929
930    /// One node type serving two lists, which is the whole point of the
931    /// element extra: both reach the same twenty-six and differ by the label
932    /// their links sit under.
933    #[test]
934    fn the_two_node_lists_share_one_element_array() {
935        let element_of = |label: &str| {
936            let Shape::List { element, .. } = Dlg::schema()
937                .iter()
938                .find(|f| f.label.as_str() == label)
939                .expect("declared")
940                .shape
941            else {
942                panic!("{label} is a list");
943            };
944            element
945        };
946        let entries = element_of("EntryList");
947        let replies = element_of("ReplyList");
948        assert_eq!(entries.len(), 2, "the node's own part, then its list label");
949        assert!(
950            std::ptr::eq(entries[0], replies[0]),
951            "the twenty-six are one array reached twice, not two copies"
952        );
953        assert_eq!(entries[0].len(), 26);
954        assert_eq!(
955            entries[1]
956                .iter()
957                .map(|f| f.label.as_str())
958                .collect::<Vec<_>>(),
959            ["RepliesList"]
960        );
961        assert_eq!(
962            replies[1]
963                .iter()
964                .map(|f| f.label.as_str())
965                .collect::<Vec<_>>(),
966            ["EntriesList"]
967        );
968    }
969
970    /// Only a reply link carries the flag, and the link type is shared by
971    /// three lists, so declaring it on the element type would put it in files
972    /// that never hold it.
973    #[test]
974    fn only_the_replies_list_declares_display_inactive() {
975        let labels = |parts: &[&'static [rakata_formats::schema::Field]]| {
976            parts
977                .iter()
978                .flat_map(|part| part.iter())
979                .map(|f| f.label.as_str())
980                .collect::<Vec<_>>()
981        };
982        assert!(!labels(DlgLink::PARTS).contains(&"DisplayInactive"));
983
984        let Shape::List { element, .. } = DlgNodeRepliesTail::SCHEMA[0].shape else {
985            panic!("RepliesList is a list");
986        };
987        assert!(labels(element).contains(&"DisplayInactive"));
988
989        let Shape::List { element, .. } = DlgNodeEntriesTail::SCHEMA[0].shape else {
990            panic!("EntriesList is a list");
991        };
992        assert!(!labels(element).contains(&"DisplayInactive"));
993    }
994}