Skip to main content

rakata_generics/
shared.rs

1//! Types and engine sentinels more than one view needs.
2//!
3//! Shared *types*, not shared *schemas*. A field block that several views
4//! declare is a claim that every axis matches at every path, which is a
5//! measurement, and it belongs beside the views that made it: GIT's live in
6//! [`git::blocks`](crate::git::blocks). What is left here has no per-owner
7//! facts to diverge. [`ObjectId`] is a newtype over a handle the engine hands
8//! out, identical wherever it appears, and the constants are single values the
9//! engine itself uses as sentinels.
10//!
11//! The distinction is the finding rather than a tidy-up. Of the sharing
12//! candidates GIT raised, most were refused because some copy differed on an
13//! axis. `ObjectId` never wobbled.
14
15use rakata_core::ResRef;
16
17/// A runtime object handle, as the engine hands them out.
18///
19/// A newtype rather than a bare `u32` because the two are not
20/// interchangeable: zero is a perfectly ordinary object id, so a plain
21/// integer field cannot tell "object number zero" from "no object". Every
22/// type carrying one of these had that bug, defaulting to zero and thereby
23/// claiming an unplaced object referred to whichever object the engine
24/// numbered first.
25///
26/// [`Default`] is [`Self::INVALID`], which is what makes the fix structural.
27/// A type holding this can keep `#[derive(Default)]` and get the right value,
28/// where a bare `u32` needs every author to remember.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub struct ObjectId(u32);
31
32impl ObjectId {
33    /// The engine-wide `OBJECT_INVALID` placeholder, carried by anything that
34    /// has not been assigned a runtime id.
35    ///
36    /// **It never lands live.** `CSWSObject`'s base constructor checks for this
37    /// value and, on a match, discards it for a freshly allocated unique id.
38    /// Every object type inherits that constructor, so this holds for every
39    /// `ObjectId` on the axis: the ten GIT object lists, `Ifo`'s
40    /// `Mod_Area_list`, and the item entries on creatures, placeables and
41    /// stores alike.
42    ///
43    /// The audited absent-value is still this sentinel, because the
44    /// replacement is resolution and a typed view projects the file. What
45    /// changes is what the value means to whoever reads it back: an
46    /// `ObjectId` of `0x7F000000` says "allocate me one", not "this is my id".
47    /// A save editor displaying it as an identifier is displaying a request.
48    pub const INVALID: Self = Self(0x7F00_0000);
49
50    /// Wraps a raw handle.
51    pub const fn new(raw: u32) -> Self {
52        Self(raw)
53    }
54
55    /// The raw handle, for writing back into a GFF field.
56    pub const fn get(self) -> u32 {
57        self.0
58    }
59
60    /// Whether this names an actual object rather than the placeholder.
61    pub const fn is_valid(self) -> bool {
62        self.0 != Self::INVALID.0
63    }
64}
65
66impl Default for ObjectId {
67    fn default() -> Self {
68        Self::INVALID
69    }
70}
71
72/// The mapping lives beside the newtype rather than in a table inside the
73/// derive, which is what lets a view declare an `ObjectId` field with no edit
74/// anywhere else.
75impl rakata_formats::schema::GffScalar for ObjectId {
76    const GFF_TYPE: rakata_formats::schema::GffType = rakata_formats::schema::GffType::UInt32;
77
78    fn from_gff_value(value: &rakata_formats::GffValue) -> Option<Self> {
79        <u32 as rakata_formats::schema::GffScalar>::from_gff_value(value).map(Self)
80    }
81
82    fn to_gff_value(self) -> rakata_formats::GffValue {
83        rakata_formats::GffValue::UInt32(self.0)
84    }
85}
86
87impl From<u32> for ObjectId {
88    fn from(raw: u32) -> Self {
89        Self(raw)
90    }
91}
92
93impl From<ObjectId> for u32 {
94    fn from(id: ObjectId) -> Self {
95        id.0
96    }
97}
98
99/// The `PortraitId` value that hands control back to the `Portrait` resref.
100///
101/// A trigger's read stamps this when the file carries no `PortraitId`, which
102/// lands inside the documented `>= 0xFFFE` range where the engine consults
103/// the string field instead of the id. Defaulting to `0` would name portrait
104/// row zero and shadow the resref, which is the opposite behaviour.
105pub const PORTRAIT_ID_USE_RESREF: u16 = 0xFFFF;
106
107/// The inventory-grid position an item that has never been placed holds.
108///
109/// `Repos_PosX` and `Repos_Posy` are BYTEs read only by the nested container
110/// path, and both default to `0xFF` rather than to a plain zero. Grid slot 0
111/// is a real slot, so a zero fallback places every unplaced item in the
112/// top-left corner.
113pub const REPOS_UNPLACED: u8 = 0xFF;
114
115/// [`REPOS_UNPLACED`] at the width the file declares.
116///
117/// Vanilla files carry these fields as WORD while the engine's read call takes
118/// a BYTE, so the sentinel needs both widths. Written as a literal rather than
119/// widened from its sibling because `Into` is not const here, and the two are
120/// held together by the assertion below instead.
121pub const REPOS_UNPLACED_WORD: u16 = 0xFF;
122
123/// `SoundSetFile`'s "nothing assigned" sentinel.
124///
125/// The label reads like a resref and is not one: it is a `soundset.2da` row
126/// index read as a WORD, and the constructor sets `-1`. Row 0 is a real
127/// soundset, so a zero fallback assigns every creature the first row in the
128/// table rather than leaving it unassigned.
129///
130/// Fourth in a family that keeps producing the same defect, alongside
131/// [`PORTRAIT_ID_USE_RESREF`], [`TRAP_TYPE_ABSENT`] and the encounter's
132/// object references. A "nothing assigned" sentinel in this engine is almost
133/// never zero, because zero is a valid row in whatever table the field
134/// indexes.
135pub const SOUNDSET_UNASSIGNED: u16 = 0xFFFF;
136
137/// `TrapType`'s value when a file does not carry one.
138///
139/// Not a `traps.2da` row. Row `255` does not exist in vanilla data, so a file
140/// missing both this and `OnTrapTriggered` sends the engine to an
141/// out-of-range lookup rather than to a clean "no trap" state. Reading an
142/// absent `TrapType` as `0` instead would name a row that does exist, turning
143/// "no trap configured" into "trap type 0".
144pub const TRAP_TYPE_ABSENT: u8 = 0xFF;
145
146/// The resref a script slot holds when a file does not name one.
147///
148/// A door's constructor pre-arms all fifteen slots with the literal string
149/// `"default"`, and a trigger's does the same for its seven, so an absent hook
150/// on either resolves to that name rather than to nothing. This is the
151/// mechanism behind the documented `traps.2da` fallback: `OnTrapTriggered`
152/// becomes `"default"` on its own, which is one of the three spellings that
153/// routes to the table. A placeable's constructor arms nothing, so its hooks
154/// really are empty when absent.
155pub const SCRIPT_SLOT_SEED: ResRef = match ResRef::const_new("default") {
156    Ok(seed) => seed,
157    // Unreachable: a seven-character ASCII literal is inside every ResRef
158    // limit. `expect` is not const, so the match spells out the same thing.
159    Err(_) => panic!("\"default\" is a valid resref"),
160};
161
162#[cfg(test)]
163mod sentinel_tests {
164    use super::{REPOS_UNPLACED, REPOS_UNPLACED_WORD};
165
166    /// The two widths of the grid sentinel must name the same value.
167    ///
168    /// They are separate literals because `Into` is not const, so nothing but
169    /// this keeps them from drifting apart.
170    #[test]
171    fn the_grid_sentinel_agrees_across_its_two_widths() {
172        assert_eq!(u16::from(REPOS_UNPLACED), REPOS_UNPLACED_WORD);
173    }
174}