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