Skip to main content

rakata_generics/
are.rs

1//! ARE (`.are`) typed generic wrapper.
2//!
3//! Areas are the static environment of a module: grass rendering, sunlight and
4//! fog, ambient audio scale, and interior or exterior state. The stage that a
5//! [`GIT`](crate::git)'s instances are then placed onto.
6//!
7//! ## Scope
8//! - Typed access for all engine-read area fields (identity, scripts, map, core flags,
9//!   weather, lighting, grass, stealth/transition save-state, and MiniGame struct).
10//! - Deterministic lossless conversion back to
11//!   [`Gff`](rakata_formats::gff::Gff).
12//!
13//! ## Field Layout (simplified)
14//! ```text
15//! ARE root struct
16//! +-- Tag              (CExoString)
17//! +-- Name             (CExoLocString)
18//! +-- Comments         (CExoString)
19//! +-- AlphaTest        (FLOAT)
20//! +-- CameraStyle      (INT)
21//! +-- DefaultEnvMap    (CResRef)
22//! +-- RestrictMode     (BYTE)
23//! +-- OnEnter/Exit/... (CResRef)
24//! +-- Flags            (DWORD)
25//! +-- Version          (DWORD)
26//! +-- LoadScreenID     (WORD)
27//! +-- ChanceRain/Snow/Lightning/Fog (INT)
28//! +-- StealthXPCurrent (DWORD)
29//! +-- TransPending/TransPendNextID/TransPendCurrID (BYTE)
30//! +-- Expansion_List   (List<Struct>)
31//! |   +-- Expansion_Name (CExoLocString)
32//! |   `-- Expansion_ID   (INT)
33//! +-- MiniGame         (Struct -> AreMiniGame, see [`minigame`])
34//! +-- Map              (Struct)
35//! |   +-- NorthAxis / MapZoom / MapResX
36//! |   +-- MapPt{1,2}{X,Y} / WorldPt{1,2}{X,Y}
37//! +-- Rooms            (List<Struct>)
38//!     +-- RoomName / AmbientScale / EnvAudio / ForceRating / DisableWeather
39//!     `-- PartSounds   (List<Struct>)
40//!         +-- Looping / ModelPart / OmenEvent / Sound
41//! ```
42
43pub mod minigame;
44
45use std::io::{Cursor, Read, Write};
46
47use rakata_core::ResRef;
48use rakata_formats::gff::upsert_field;
49use rakata_formats::gff_label;
50use rakata_formats::schema::FromGff;
51use rakata_formats::schema::GffScalar;
52use rakata_formats::GENERIC_FILE_TYPE;
53use rakata_formats::{
54    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffModel,
55    GffStruct, GffValue,
56};
57use thiserror::Error;
58
59pub use minigame::{
60    AreMiniGame, AreMiniGameBullet, AreMiniGameEnemy, AreMiniGameGunBank, AreMiniGameModel,
61    AreMiniGameMouse, AreMiniGameObjectScripts, AreMiniGameObstacle, AreMiniGamePlayer,
62    AreMiniGameSounds, AreMiniGameTargeting, AreMiniGameVehicle, AreMiniGameVehicleScripts,
63    BANK_ID_NONE,
64};
65
66/// Typed ARE model built from/to [`Gff`] data.
67#[derive(Debug, Clone, PartialEq, GffModel)]
68pub struct Are {
69    /// Legacy area id (`ID`).
70    #[gff(ID, stamped)]
71    pub unused_id: i32,
72    /// Legacy creator id (`Creator_ID`).
73    #[gff(Creator_ID, stamped)]
74    pub creator_id: i32,
75    /// Area format version (`Version`).
76    #[gff(Version, stamped)]
77    pub version: u32,
78    /// Toolset/comment field (`Comments`).
79    #[gff(Comments, stamped)]
80    pub comment: String,
81    /// Localized area name (`Name`).
82    #[gff(Name, stamped)]
83    pub name: GffLocalizedString,
84    /// Area tag (`Tag`). Engine always lowercases this on load.
85    #[gff(Tag, stamped)]
86    pub tag: String,
87    /// Heartbeat script resref (`OnHeartbeat`).
88    #[gff(OnHeartbeat, unexamined)]
89    pub on_heartbeat: ResRef,
90    /// User-defined event script resref (`OnUserDefined`).
91    #[gff(OnUserDefined, unexamined)]
92    pub on_user_defined: ResRef,
93    /// Enter script resref (`OnEnter`).
94    #[gff(OnEnter, unexamined)]
95    pub on_enter: ResRef,
96    /// Exit script resref (`OnExit`).
97    #[gff(OnExit, unexamined)]
98    pub on_exit: ResRef,
99    /// Area flags (`Flags`).
100    #[gff(Flags, stamped)]
101    pub flags: u32,
102    /// Camera style ID (`CameraStyle`).
103    #[gff(CameraStyle, stamped)]
104    pub camera_style: i32,
105    /// Default environment map resref (`DefaultEnvMap`).
106    #[gff(DefaultEnvMap, stamped)]
107    pub default_envmap: ResRef,
108    /// Unescapable area flag (`Unescapable`).
109    #[gff(Unescapable, constructed)]
110    pub unescapable: bool,
111    /// Restrict mode (`RestrictMode`) - triggers `UnstealthParty` on non-zero change.
112    #[gff(RestrictMode, constructed, omit = audited_constant(1057))]
113    pub restrict_mode: u8,
114    /// Chance of rain (`ChanceRain`, K2). Engine discards if interior. Truncated to byte (0-255) at runtime.
115    #[gff(ChanceRain, stamped, range_int = (0, 100))]
116    pub chance_rain: i32,
117    /// Chance of snow (`ChanceSnow`, K2). Engine discards if interior. Truncated to byte (0-255) at runtime.
118    #[gff(ChanceSnow, stamped, range_int = (0, 100))]
119    pub chance_snow: i32,
120    /// Chance of lightning (`ChanceLightning`, K2). Engine discards if interior. Truncated to byte (0-255) at runtime.
121    #[gff(ChanceLightning, stamped, range_int = (0, 100))]
122    pub chance_lightning: i32,
123    /// Wind power (`WindPower`). Engine discards if interior. Truncated to byte (0-255) at runtime.
124    #[gff(WindPower, stamped, range_int = (0, 2))]
125    pub wind_power: i32,
126    /// Chance of fog (`ChanceFog`).
127    #[gff(ChanceFog, stamped, omit = audited_constant(1057))]
128    pub chance_fog: i32,
129    /// Moon ambient color (`MoonAmbientColor`).
130    #[gff(MoonAmbientColor, stamped)]
131    pub moon_ambient_color: u32,
132    /// Moon diffuse color (`MoonDiffuseColor`).
133    #[gff(MoonDiffuseColor, stamped)]
134    pub moon_diffuse_color: u32,
135    /// Moon fog color (`MoonFogColor`).
136    #[gff(MoonFogColor, stamped)]
137    pub moon_fog_color: u32,
138    /// Sun ambient color (`SunAmbientColor`).
139    #[gff(SunAmbientColor, stamped)]
140    pub sun_ambient_color: u32,
141    /// Sun diffuse color (`SunDiffuseColor`).
142    #[gff(SunDiffuseColor, stamped)]
143    pub sun_diffuse_color: u32,
144    /// Sun fog color (`SunFogColor`).
145    #[gff(SunFogColor, stamped)]
146    pub sun_fog_color: u32,
147    /// Dynamic ambient color (`DynAmbientColor`).
148    #[gff(DynAmbientColor, stamped)]
149    pub dynamic_ambient_color: u32,
150    /// Moon fog near distance (`MoonFogNear`). Clamped to >= 0.0 by the engine.
151    ///
152    /// The absent value is far enough out that fog is effectively off, which
153    /// is a different outcome from the `0.0` a reader reaching for a plain
154    /// zero would produce. All four `*FogNear`/`*FogFar` reads resolve to it.
155    #[gff(MoonFogNear, stamped = 10000.0)]
156    pub moon_fog_near: f32,
157    /// Moon fog far distance (`MoonFogFar`). Clamped to >= 0.0 by the engine.
158    #[gff(MoonFogFar, stamped = 10000.0)]
159    pub moon_fog_far: f32,
160    /// Sun fog near distance (`SunFogNear`). Clamped to >= 0.0 by the engine.
161    #[gff(SunFogNear, stamped = 10000.0)]
162    pub fog_near: f32,
163    /// Sun fog far distance (`SunFogFar`). Clamped to >= 0.0 by the engine.
164    #[gff(SunFogFar, stamped = 10000.0)]
165    pub fog_far: f32,
166    /// Moon fog enabled (`MoonFogOn`).
167    #[gff(MoonFogOn, stamped)]
168    pub moon_fog_enabled: bool,
169    /// Sun fog enabled (`SunFogOn`).
170    #[gff(SunFogOn, stamped)]
171    pub fog_enabled: bool,
172    /// Moon shadows enabled (`MoonShadows`).
173    #[gff(MoonShadows, stamped)]
174    pub moon_shadows: bool,
175    /// Sun shadows enabled (`SunShadows`).
176    #[gff(SunShadows, stamped)]
177    pub shadows: bool,
178    /// Day/night cycle flag (`DayNightCycle`).
179    #[gff(DayNightCycle, stamped)]
180    pub day_night_cycle: u8,
181    /// Is-night flag (`IsNight`).
182    #[gff(IsNight, stamped)]
183    pub is_night: bool,
184    /// Shadow opacity (`ShadowOpacity`).
185    #[gff(ShadowOpacity, stamped)]
186    pub shadow_opacity: u8,
187    /// Lighting scheme (`LightingScheme`).
188    #[gff(LightingScheme, stamped)]
189    pub lighting_scheme: u8,
190    /// No-rest flag (`NoRest`).
191    #[gff(NoRest, constructed)]
192    pub no_rest: bool,
193    /// Mod spot-check modifier (`ModSpotCheck`).
194    #[gff(ModSpotCheck, stamped)]
195    pub mod_spot_check: i32,
196    /// Mod listen-check modifier (`ModListenCheck`).
197    #[gff(ModListenCheck, stamped)]
198    pub mod_listen_check: i32,
199    /// Grass diffuse color (`Grass_Diffuse`).
200    #[gff(Grass_Diffuse, stamped)]
201    pub grass_diffuse_color: u32,
202    /// Grass ambient color (`Grass_Ambient`).
203    #[gff(Grass_Ambient, stamped)]
204    pub grass_ambient_color: u32,
205    /// Grass density (`Grass_Density`).
206    #[gff(Grass_Density, stamped)]
207    pub grass_density: f32,
208    /// Grass quad size (`Grass_QuadSize`).
209    #[gff(Grass_QuadSize, stamped)]
210    pub grass_size: f32,
211    /// Grass texture resref (`Grass_TexName`).
212    #[gff(Grass_TexName, unexamined)]
213    pub grass_texture: ResRef,
214    /// Grass probability lower-left (`Grass_Prob_LL`).
215    #[gff(Grass_Prob_LL, stamped)]
216    pub grass_prob_ll: f32,
217    /// Grass probability lower-right (`Grass_Prob_LR`).
218    #[gff(Grass_Prob_LR, stamped)]
219    pub grass_prob_lr: f32,
220    /// Grass probability upper-left (`Grass_Prob_UL`).
221    #[gff(Grass_Prob_UL, stamped)]
222    pub grass_prob_ul: f32,
223    /// Grass probability upper-right (`Grass_Prob_UR`).
224    #[gff(Grass_Prob_UR, stamped)]
225    pub grass_prob_ur: f32,
226    /// Alpha test threshold (`AlphaTest`).
227    #[gff(AlphaTest, stamped = 0.2)]
228    pub alpha_test: f32,
229    /// Stealth XP max (`StealthXPMax`).
230    #[gff(StealthXPMax, unexamined)]
231    pub stealth_xp_max: u32,
232    /// Current stealth XP counter (`StealthXPCurrent`, save-state).
233    ///
234    /// A chained default, not an independent one: the engine's read falls
235    /// back to whatever `StealthXPMax` just resolved to. A plain zero here
236    /// starts an area with no stealth budget where the engine would start it
237    /// with the full one, which is why the read is this view's.
238    ///
239    /// Not clamped to the max afterwards, though the engine does clamp. That
240    /// happens after the read and is resolution rather than the file's own
241    /// content, and this view projects the file.
242    #[gff(
243        StealthXPCurrent,
244        not_a_constant,
245        from_siblings,
246        manual_read,
247        manual_write,
248        omit = matches("StealthXPMax", 1057)
249    )]
250    pub stealth_xp_current: u32,
251    /// Stealth XP loss (`StealthXPLoss`).
252    #[gff(StealthXPLoss, constructed)]
253    pub stealth_xp_loss: u32,
254    /// Stealth XP enabled (`StealthXPEnabled`).
255    #[gff(StealthXPEnabled, constructed)]
256    pub stealth_xp: bool,
257    /// Transition pending flag (`TransPending`, save-state).
258    #[gff(TransPending, constructed, omit = audited_constant(1057))]
259    pub trans_pending: u8,
260    /// Transition pending next ID (`TransPendNextID`, save-state).
261    #[gff(TransPendNextID, constructed, omit = audited_constant(1057))]
262    pub trans_pend_next_id: u8,
263    /// Transition pending current ID (`TransPendCurrID`, save-state).
264    #[gff(TransPendCurrID, constructed, omit = audited_constant(1057))]
265    pub trans_pend_curr_id: u8,
266    /// Load screen ID (`LoadScreenID`).
267    #[gff(LoadScreenID, stamped)]
268    pub loadscreen_id: u16,
269    /// Embedded room metadata (`Rooms` list).
270    #[gff(Rooms, unexamined, list = AreRoom, element_id = 0)]
271    pub rooms: Vec<AreRoom>,
272    /// Area expansion entries (`Expansion_List` list).
273    #[gff(Expansion_List, unexamined, list = AreExpansionEntry, element_id = 0)]
274    pub expansion_list: Vec<AreExpansionEntry>,
275    /// Embedded map metadata (`Map` struct).
276    #[gff(Map, unexamined, nested = AreMap)]
277    pub map: AreMap,
278    /// MiniGame struct (`MiniGame`).
279    #[gff(MiniGame, not_a_constant, nested = AreMiniGame, optional)]
280    pub mini_game: Option<AreMiniGame>,
281    /// Disable transit flag (`DisableTransit`). Toolset/K2-specific, ignored by K1 engine.
282    #[gff(
283        DisableTransit,
284        read_only_dead = "ARE-005 records DisableTransit, NoHangBack and PlayerVsPlayer as toolset fields the K1 engine never reads",
285        not_a_constant
286    )]
287    pub disable_transit: bool,
288    /// No-hang-back flag (`NoHangBack`). Toolset/K2-specific, ignored by K1 engine.
289    #[gff(
290        NoHangBack,
291        read_only_dead = "ARE-005 records DisableTransit, NoHangBack and PlayerVsPlayer as toolset fields the K1 engine never reads",
292        not_a_constant
293    )]
294    pub no_hang_back: bool,
295    /// Player-only flag (`PlayerOnly`). Toolset/K2-specific, ignored by K1 engine.
296    #[gff(PlayerOnly, unexamined)]
297    pub player_only: bool,
298    /// Player-vs-player mode (`PlayerVsPlayer`). Toolset/K2-specific, ignored by K1 engine.
299    #[gff(
300        PlayerVsPlayer,
301        read_only_dead = "ARE-005 records DisableTransit, NoHangBack and PlayerVsPlayer as toolset fields the K1 engine never reads",
302        not_a_constant
303    )]
304    pub player_vs_player: u8,
305    /// Grass emissive color (`Grass_Emissive`).
306    #[gff(
307        Grass_Emissive,
308        read_only_dead = "Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist",
309        not_a_constant
310    )]
311    pub grass_emissive_color: u32,
312    /// Dirty overlay color one (`DirtyARGBOne`).
313    #[gff(
314        DirtyARGBOne,
315        read_only_dead = "Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist",
316        not_a_constant
317    )]
318    pub dirty_argb_one: i32,
319    /// Dirty overlay size one (`DirtySizeOne`).
320    #[gff(
321        DirtySizeOne,
322        read_only_dead = "Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist",
323        not_a_constant
324    )]
325    pub dirty_size_one: i32,
326    /// Dirty overlay formula one (`DirtyFormulaOne`).
327    #[gff(
328        DirtyFormulaOne,
329        read_only_dead = "Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist",
330        not_a_constant
331    )]
332    pub dirty_formula_one: i32,
333    /// Dirty overlay func one (`DirtyFuncOne`).
334    #[gff(
335        DirtyFuncOne,
336        read_only_dead = "Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist",
337        not_a_constant
338    )]
339    pub dirty_func_one: i32,
340    /// Dirty overlay color two (`DirtyARGBTwo`).
341    #[gff(
342        DirtyARGBTwo,
343        read_only_dead = "Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist",
344        not_a_constant
345    )]
346    pub dirty_argb_two: i32,
347    /// Dirty overlay size two (`DirtySizeTwo`).
348    #[gff(
349        DirtySizeTwo,
350        read_only_dead = "Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist",
351        not_a_constant
352    )]
353    pub dirty_size_two: i32,
354    /// Dirty overlay formula two (`DirtyFormulaTwo`).
355    #[gff(
356        DirtyFormulaTwo,
357        read_only_dead = "Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist",
358        not_a_constant
359    )]
360    pub dirty_formula_two: i32,
361    /// Dirty overlay func two (`DirtyFuncTwo`).
362    #[gff(
363        DirtyFuncTwo,
364        read_only_dead = "Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist",
365        not_a_constant
366    )]
367    pub dirty_func_two: i32,
368    /// Dirty overlay color three (`DirtyARGBThree`).
369    #[gff(
370        DirtyARGBThree,
371        read_only_dead = "Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist",
372        not_a_constant
373    )]
374    pub dirty_argb_three: i32,
375    /// Dirty overlay size three (`DirtySizeThree`).
376    #[gff(
377        DirtySizeThree,
378        read_only_dead = "Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist",
379        not_a_constant
380    )]
381    pub dirty_size_three: i32,
382    /// Dirty overlay formula three (`DirtyFormulaThre`).
383    #[gff(
384        DirtyFormulaThre,
385        read_only_dead = "Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist",
386        not_a_constant
387    )]
388    pub dirty_formula_three: i32,
389    /// Dirty overlay func three (`DirtyFuncThree`).
390    #[gff(
391        DirtyFuncThree,
392        read_only_dead = "Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist",
393        not_a_constant
394    )]
395    pub dirty_func_three: i32,
396}
397
398impl Are {
399    /// Creates an empty ARE value.
400    pub fn new() -> Self {
401        Self::default()
402    }
403
404    /// Builds typed ARE data from a parsed GFF container.
405    ///
406    /// # Errors
407    ///
408    /// Returns [`AreError::UnsupportedFileType`] when the container is not an
409    /// `ARE ` or a bare `GFF `.
410    pub fn from_gff(gff: &Gff) -> Result<Self, AreError> {
411        if gff.file_type != <Are as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
412            return Err(AreError::UnsupportedFileType(gff.file_type));
413        }
414        let mut are = Self::read_declared(&gff.root);
415        are.stealth_xp_current = gff
416            .root
417            .field("StealthXPCurrent")
418            .and_then(<u32 as GffScalar>::from_gff_value)
419            .unwrap_or(are.stealth_xp_max);
420        Ok(are)
421    }
422
423    /// Serializes typed ARE data back into a GFF container.
424    pub fn to_gff(&self) -> Gff {
425        let mut root = GffStruct::new(-1);
426        self.write_declared(&mut root);
427
428        // The entry declares this omission and cannot carry it out: its test
429        // is a sibling's resolved value, which one entry cannot reach, so
430        // `may_omit` answers no and the writer that would drop the label is
431        // this one. Vanilla omits it whenever it equals the maximum, which is
432        // every file in the install.
433        if self.stealth_xp_current != self.stealth_xp_max {
434            upsert_field(
435                &mut root,
436                gff_label!("StealthXPCurrent"),
437                GffValue::UInt32(self.stealth_xp_current),
438            );
439        }
440        Gff::new(*b"ARE ", root)
441    }
442}
443
444/// Typed view over the ARE `Map` nested struct.
445#[derive(Debug, Clone, PartialEq, GffModel)]
446pub struct AreMap {
447    /// Horizontal map resolution (`MapResX`).
448    #[gff(MapResX, stamped)]
449    pub map_res_x: i32,
450    /// Map north-axis selection (`NorthAxis`).
451    #[gff(NorthAxis, stamped)]
452    pub north_axis: i32,
453    /// Map zoom level (`MapZoom`).
454    #[gff(MapZoom, stamped = 1)]
455    pub map_zoom: i32,
456    /// UI map point 1, X component (`MapPt1X`).
457    #[gff(MapPt1X, stamped = 0.0, manual_read)]
458    pub map_point_1_x: f32,
459    /// UI map point 1, Y component (`MapPt1Y`).
460    #[gff(MapPt1Y, stamped = 0.0, manual_read)]
461    pub map_point_1_y: f32,
462    /// UI map point 2, X component (`MapPt2X`).
463    #[gff(MapPt2X, stamped = 0.0, manual_read)]
464    pub map_point_2_x: f32,
465    /// UI map point 2, Y component (`MapPt2Y`).
466    #[gff(MapPt2Y, stamped = 0.0, manual_read)]
467    pub map_point_2_y: f32,
468    /// World point 1, X component (`WorldPt1X`).
469    #[gff(WorldPt1X, stamped = 0.0)]
470    pub world_point_1_x: f32,
471    /// World point 1, Y component (`WorldPt1Y`).
472    #[gff(WorldPt1Y, stamped = 0.0)]
473    pub world_point_1_y: f32,
474    /// World point 2, X component (`WorldPt2X`).
475    #[gff(WorldPt2X, stamped = 0.0)]
476    pub world_point_2_x: f32,
477    /// World point 2, Y component (`WorldPt2Y`).
478    #[gff(WorldPt2Y, stamped = 0.0)]
479    pub world_point_2_y: f32,
480}
481
482impl AreMap {
483    /// Reads one `Map` struct.
484    ///
485    /// `are.md`: the four `MapPt*` reads go through a dual path that checks
486    /// whether the field is formally FLOAT or INT, so an integer encoding is
487    /// a value the engine takes rather than a field it fails to find. The
488    /// four `WorldPt*` reads beside them are independent FLOAT reads, which
489    /// is why only half the block is hand-written: one helper over all eight
490    /// would grant the other four a tolerance the page does not give them.
491    fn read_element(structure: &GffStruct) -> Self {
492        let mut map = Self::read_declared(structure);
493        map.map_point_1_x = dual_path(structure, "MapPt1X");
494        map.map_point_1_y = dual_path(structure, "MapPt1Y");
495        map.map_point_2_x = dual_path(structure, "MapPt2X");
496        map.map_point_2_y = dual_path(structure, "MapPt2Y");
497        map
498    }
499}
500
501/// One `MapPt*` read, which the engine takes as FLOAT or as INT.
502///
503/// Absent either way resolves to `0.0`: the INT branch reads a literal zero
504/// and the FLOAT branch's own zero survives the conversion the engine applies
505/// after it.
506fn dual_path(structure: &GffStruct, label: &str) -> f32 {
507    #[allow(clippy::cast_precision_loss, clippy::as_conversions)]
508    match structure.field(label) {
509        Some(GffValue::Int32(value)) => *value as f32,
510        Some(GffValue::UInt32(value)) => *value as f32,
511        Some(GffValue::Int16(value)) => f32::from(*value),
512        Some(GffValue::UInt16(value)) => f32::from(*value),
513        Some(GffValue::Int8(value)) => f32::from(*value),
514        Some(GffValue::UInt8(value)) => f32::from(*value),
515        other => other
516            .and_then(<f32 as GffScalar>::from_gff_value)
517            .unwrap_or(0.0),
518    }
519}
520
521/// Typed view over one ARE room entry in the `Rooms` list.
522///
523/// `ForceRating` and `DisableWeather` are declared and not modelled. Neither
524/// field-name string exists anywhere in the executable, so no room load can
525/// look either up, and a field the engine never reads at a path is not that
526/// path's to model. They stay on the schema so a reader meeting one is told it
527/// is dead rather than told nothing, and so this writer stops putting them on
528/// rooms that carry neither.
529#[derive(Debug, Clone, PartialEq, GffModel)]
530#[gff_entry(
531    ForceRating,
532    wire = i32,
533    read_only_dead = "neither field-name string exists anywhere in swkotor.exe, confirmed by a binary-wide search; toolset-only in this build",
534    not_a_constant
535)]
536#[gff_entry(
537    DisableWeather,
538    wire = u8,
539    read_only_dead = "neither field-name string exists anywhere in swkotor.exe, confirmed by a binary-wide search; toolset-only in this build",
540    not_a_constant
541)]
542pub struct AreRoom {
543    /// Per-room part sound definitions (`PartSounds` list).
544    #[gff(PartSounds, not_a_constant, list = ArePartSound, element_id = 0)]
545    pub part_sounds: Vec<ArePartSound>,
546    /// Room name (`RoomName`).
547    #[gff(RoomName, stamped)]
548    pub room_name: String,
549    /// Environment audio ID (`EnvAudio`).
550    #[gff(EnvAudio, stamped)]
551    pub env_audio: i32,
552    /// Ambient scale (`AmbientScale`).
553    #[gff(AmbientScale, stamped)]
554    pub ambient_scale: f32,
555}
556
557/// Typed view over one ARE expansion-list entry.
558#[derive(Debug, Clone, PartialEq, GffModel)]
559pub struct AreExpansionEntry {
560    /// Localized expansion name (`Expansion_Name`).
561    #[gff(Expansion_Name, stamped)]
562    pub expansion_name: GffLocalizedString,
563    /// Expansion identifier (`Expansion_ID`).
564    #[gff(Expansion_ID, stamped)]
565    pub expansion_id: i32,
566}
567
568/// Typed view over one room part-sound entry (`Rooms[].PartSounds[]`).
569#[derive(Debug, Clone, PartialEq, GffModel)]
570pub struct ArePartSound {
571    /// Looping flag (`Looping`).
572    #[gff(Looping, unexamined)]
573    pub looping: bool,
574    /// Model part identifier (`ModelPart`).
575    #[gff(ModelPart, unexamined)]
576    pub model_part: String,
577    /// Optional omen event token (`OmenEvent`).
578    #[gff(OmenEvent, unexamined)]
579    pub omen_event: String,
580    /// Sound resref (`Sound`).
581    #[gff(Sound, unexamined)]
582    pub sound: ResRef,
583}
584
585/// Errors produced while reading or writing typed ARE data.
586#[derive(Debug, Error)]
587pub enum AreError {
588    /// Source file type is not supported by this parser.
589    #[error("unsupported ARE file type: {0:?}")]
590    UnsupportedFileType([u8; 4]),
591    /// Underlying GFF parser/writer error.
592    #[error(transparent)]
593    Gff(#[from] GffBinaryError),
594}
595
596/// Reads typed ARE data from a reader at the current stream position.
597///
598/// # Errors
599///
600/// [`AreError::Gff`] when the stream is not a readable GFF, and
601/// [`AreError::UnsupportedFileType`] when it is a GFF of some other format,
602/// carrying the fourcc that was found.
603#[cfg_attr(
604    feature = "tracing",
605    tracing::instrument(level = "debug", skip(reader))
606)]
607pub fn read_are<R: Read>(reader: &mut R) -> Result<Are, AreError> {
608    let gff = read_gff(reader)?;
609    Are::from_gff(&gff)
610}
611
612/// Reads typed ARE data directly from bytes.
613///
614/// # Errors
615///
616/// [`AreError::Gff`] when `bytes` are not a readable GFF, and
617/// [`AreError::UnsupportedFileType`] when they are a GFF of some other format,
618/// carrying the fourcc that was found.
619#[cfg_attr(
620    feature = "tracing",
621    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
622)]
623pub fn read_are_from_bytes(bytes: &[u8]) -> Result<Are, AreError> {
624    let gff = read_gff_from_bytes(bytes)?;
625    Are::from_gff(&gff)
626}
627
628/// Authors the ARE file the typed view describes, into a writer.
629///
630/// # Errors
631///
632/// [`AreError::Gff`] when the writer fails or a value will not encode. The
633/// typed view fixes the file type, so `UnsupportedFileType` cannot arise on
634/// this side.
635#[cfg_attr(
636    feature = "tracing",
637    tracing::instrument(level = "debug", skip(writer, are))
638)]
639pub fn author_are<W: Write>(writer: &mut W, are: &Are) -> Result<(), AreError> {
640    let gff = are.to_gff();
641    write_gff(writer, &gff)?;
642    Ok(())
643}
644
645/// Authors the ARE file the typed view describes, as bytes.
646///
647/// # Errors
648///
649/// [`AreError::Gff`] when a value will not encode. Writing into a `Vec` has no
650/// I/O to fail at.
651#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(are)))]
652pub fn author_are_to_vec(are: &Are) -> Result<Vec<u8>, AreError> {
653    let mut cursor = Cursor::new(Vec::new());
654    author_are(&mut cursor, are)?;
655    Ok(cursor.into_inner())
656}
657
658#[cfg(test)]
659mod tests {
660    use rakata_formats::gff::upsert_field;
661    use rakata_formats::gff_label;
662    use rakata_formats::schema::{HasSchema, Shape};
663
664    use super::*;
665
666    #[test]
667    fn stealth_xp_current_resolves_across_every_presence_combination() {
668        // A chained default needs every combination, not just the absent one.
669        // The absent case is the whole defect and the present case is what
670        // proves the chain does not overwrite a real value.
671        let build = |current: Option<u32>, max: Option<u32>| {
672            let mut gff = Gff::new(*b"ARE ", GffStruct::new(0));
673            if let Some(v) = current {
674                upsert_field(
675                    &mut gff.root,
676                    gff_label!("StealthXPCurrent"),
677                    GffValue::UInt32(v),
678                );
679            }
680            if let Some(v) = max {
681                upsert_field(
682                    &mut gff.root,
683                    gff_label!("StealthXPMax"),
684                    GffValue::UInt32(v),
685                );
686            }
687            Are::from_gff(&gff).expect("an ARE with no required fields parses")
688        };
689
690        // Present wins, and the max must not leak into it.
691        assert_eq!(build(Some(120), Some(500)).stealth_xp_current, 120);
692        // Absent chains off whatever the max resolved to. This is the case
693        // 16 of the 117 vanilla areas are in.
694        assert_eq!(build(None, Some(500)).stealth_xp_current, 500);
695        // Neither present lands on the constructed zero, which is where the
696        // old plain-zero fallback happened to be right.
697        assert_eq!(build(None, None).stealth_xp_current, 0);
698        // Above the max is projected as the file holds it. The engine clamps
699        // after the read; that is resolution, not the file's content.
700        assert_eq!(build(Some(900), Some(500)).stealth_xp_current, 900);
701        // The max itself is unaffected by any of it.
702        assert_eq!(build(None, Some(500)).stealth_xp_max, 500);
703    }
704
705    const TEST_ARE: &[u8] = include_bytes!(concat!(
706        env!("CARGO_MANIFEST_DIR"),
707        "/../../fixtures/test.are"
708    ));
709
710    #[test]
711    fn reads_core_are_fields_from_fixture() {
712        let are = read_are_from_bytes(TEST_ARE).expect("fixture must parse");
713
714        assert_eq!(are.unused_id, 0);
715        assert_eq!(are.creator_id, 0);
716        assert_eq!(are.tag, "Untitled");
717        assert_eq!(are.name.string_ref.raw(), 75_101);
718        assert_eq!(are.comment, "comments");
719        assert_eq!(are.version, 88);
720        assert_eq!(are.flags, 0);
721        assert_eq!(are.mod_spot_check, 0);
722        assert_eq!(are.mod_listen_check, 0);
723        assert_eq!(are.camera_style, 1);
724        assert_eq!(are.default_envmap, "defaultenvmap");
725        assert_eq!(are.grass_texture, "grasstexture");
726        assert!((are.grass_density - 1.0).abs() < f32::EPSILON);
727        assert!((are.grass_size - 1.0).abs() < f32::EPSILON);
728        assert!((are.grass_prob_ll - 0.25).abs() < f32::EPSILON);
729        assert!((are.grass_prob_lr - 0.25).abs() < f32::EPSILON);
730        assert!((are.grass_prob_ul - 0.25).abs() < f32::EPSILON);
731        assert!((are.grass_prob_ur - 0.25).abs() < f32::EPSILON);
732        assert_eq!(are.sun_ambient_color, 16_777_215);
733        assert_eq!(are.sun_diffuse_color, 16_777_215);
734        assert_eq!(are.dynamic_ambient_color, 16_777_215);
735        assert_eq!(are.sun_fog_color, 16_777_215);
736        assert_eq!(are.grass_ambient_color, 16_777_215);
737        assert_eq!(are.grass_diffuse_color, 16_777_215);
738        assert_eq!(are.grass_emissive_color, 16_777_215);
739        assert!(are.fog_enabled);
740        assert!((are.fog_near - 99.0).abs() < f32::EPSILON);
741        assert!((are.fog_far - 100.0).abs() < f32::EPSILON);
742        assert!(are.shadows);
743        assert_eq!(are.shadow_opacity, 205);
744        assert_eq!(are.wind_power, 1);
745        assert!(are.unescapable);
746        assert!(are.disable_transit);
747        assert!(are.stealth_xp);
748        assert_eq!(are.stealth_xp_loss, 25);
749        assert_eq!(are.stealth_xp_max, 25);
750        assert_eq!(are.on_enter, "k_on_enter");
751        assert_eq!(are.on_exit, "onexit");
752        assert_eq!(are.on_heartbeat, "onheartbeat");
753        assert_eq!(are.on_user_defined, "onuserdefined");
754        assert!((are.alpha_test - 0.2).abs() < f32::EPSILON);
755        assert_eq!(are.chance_rain, 99);
756        assert_eq!(are.chance_snow, 99);
757        assert_eq!(are.chance_lightning, 99);
758        assert_eq!(are.moon_ambient_color, 0);
759        assert_eq!(are.moon_diffuse_color, 0);
760        assert!(!are.moon_fog_enabled);
761        assert!((are.moon_fog_near - 99.0).abs() < f32::EPSILON);
762        assert!((are.moon_fog_far - 100.0).abs() < f32::EPSILON);
763        assert_eq!(are.moon_fog_color, 0);
764        assert!(!are.moon_shadows);
765        assert_eq!(are.dirty_argb_one, 123);
766        assert_eq!(are.dirty_size_one, 1);
767        assert_eq!(are.dirty_formula_one, 1);
768        assert_eq!(are.dirty_func_one, 1);
769        assert_eq!(are.dirty_argb_two, 1234);
770        assert_eq!(are.dirty_size_two, 1);
771        assert_eq!(are.dirty_formula_two, 1);
772        assert_eq!(are.dirty_func_two, 1);
773        assert_eq!(are.dirty_argb_three, 12_345);
774        assert_eq!(are.dirty_size_three, 1);
775        assert_eq!(are.dirty_formula_three, 1);
776        assert_eq!(are.dirty_func_three, 1);
777        assert!(!are.is_night);
778        assert_eq!(are.lighting_scheme, 0);
779        assert_eq!(are.day_night_cycle, 0);
780        assert!(!are.no_rest);
781        assert!(!are.no_hang_back);
782        assert!(!are.player_only);
783        assert_eq!(are.player_vs_player, 3);
784        assert_eq!(are.map.map_zoom, 1);
785        assert_eq!(are.map.map_res_x, 18);
786        assert_eq!(are.rooms.len(), 2);
787        assert!(are.expansion_list.is_empty());
788        assert_eq!(are.rooms[0].room_name, "002ebo");
789        assert!(are.rooms[0].part_sounds.is_empty());
790    }
791
792    /// The two dead room labels are in the file and out of the view, and both
793    /// halves are the point. Dropping a field the engine never reads is the
794    /// projection doing its job; the label still being on the tree is what a
795    /// lint rule reads to tell somebody it is inert.
796    #[test]
797    fn a_dead_room_label_is_on_the_tree_and_not_in_the_view() {
798        let gff = read_gff_from_bytes(TEST_ARE).expect("fixture parses");
799        let Some(GffValue::List(rooms)) = gff.root.field("Rooms") else {
800            panic!("the fixture carries rooms");
801        };
802        assert_eq!(
803            rooms[0].field("DisableWeather"),
804            Some(&GffValue::UInt8(1)),
805            "the fixture is the one that carries it; if it stopped, this test \
806             no longer covers a label the view drops"
807        );
808
809        let written = read_are_from_bytes(TEST_ARE)
810            .expect("fixture parses")
811            .to_gff();
812        let Some(GffValue::List(written_rooms)) = written.root.field("Rooms") else {
813            panic!("rooms are written back");
814        };
815        assert_eq!(
816            written_rooms[0].field("RoomName"),
817            Some(&GffValue::String("002ebo".to_owned())),
818            "a modelled label does survive, so the two below are dropped for \
819             being dead rather than for the write producing nothing"
820        );
821        for label in ["DisableWeather", "ForceRating"] {
822            assert!(
823                written_rooms[0].field(label).is_none(),
824                "`{label}` is read by nothing, so it is not this view's to write"
825            );
826        }
827    }
828
829    #[test]
830    fn all_fields_survive_typed_roundtrip() {
831        let are = read_are_from_bytes(TEST_ARE).expect("fixture must parse");
832        let encoded = author_are_to_vec(&are).expect("encode must succeed");
833        let reparsed = read_are_from_bytes(&encoded).expect("decode must succeed");
834        assert_eq!(are, reparsed);
835    }
836
837    #[test]
838    fn typed_edits_roundtrip_through_gff_writer() {
839        let mut are = read_are_from_bytes(TEST_ARE).expect("fixture must parse");
840        are.tag = "m01aa".into();
841        are.on_enter = ResRef::new("k_on_newenter").expect("valid test resref");
842        are.grass_texture = ResRef::new("new_grass").expect("valid test resref");
843        are.fog_enabled = false;
844        are.fog_near = 50.0;
845        are.shadow_opacity = 180;
846        are.stealth_xp_loss = 33;
847        are.dirty_formula_three = 9;
848        are.player_vs_player = 2;
849        are.restrict_mode = 1;
850        are.chance_fog = 42;
851        are.stealth_xp_current = 10;
852        are.trans_pending = 1;
853        are.trans_pend_next_id = 3;
854        are.trans_pend_curr_id = 2;
855        are.map.map_zoom = 7;
856        are.rooms[0].ambient_scale = 0.5;
857        are.expansion_list.push(AreExpansionEntry {
858            expansion_name: GffLocalizedString::new(rakata_core::StrRef::from_raw(321)),
859            expansion_id: 42,
860        });
861        are.rooms[0].part_sounds.push(ArePartSound {
862            looping: true,
863            model_part: "ROOM_A".into(),
864            omen_event: "ON_ENTER".into(),
865            sound: ResRef::new("amb_rooma").expect("valid test resref"),
866        });
867
868        let encoded = author_are_to_vec(&are).expect("encode");
869        let reparsed = read_are_from_bytes(&encoded).expect("decode");
870
871        assert_eq!(reparsed.tag, "m01aa");
872        assert_eq!(reparsed.on_enter, "k_on_newenter");
873        assert_eq!(reparsed.grass_texture, "new_grass");
874        assert!(!reparsed.fog_enabled);
875        assert!((reparsed.fog_near - 50.0).abs() < f32::EPSILON);
876        assert_eq!(reparsed.shadow_opacity, 180);
877        assert_eq!(reparsed.stealth_xp_loss, 33);
878        assert_eq!(reparsed.dirty_formula_three, 9);
879        assert_eq!(reparsed.player_vs_player, 2);
880        assert_eq!(reparsed.restrict_mode, 1);
881        assert_eq!(reparsed.chance_fog, 42);
882        assert_eq!(reparsed.stealth_xp_current, 10);
883        assert_eq!(reparsed.trans_pending, 1);
884        assert_eq!(reparsed.trans_pend_next_id, 3);
885        assert_eq!(reparsed.trans_pend_curr_id, 2);
886        assert_eq!(reparsed.map.map_zoom, 7);
887        assert_eq!(reparsed.rooms[0].ambient_scale, 0.5);
888        assert_eq!(reparsed.expansion_list.len(), 1);
889        assert_eq!(reparsed.expansion_list[0].expansion_id, 42);
890        assert_eq!(
891            reparsed.expansion_list[0].expansion_name.string_ref.raw(),
892            321
893        );
894        assert_eq!(reparsed.rooms[0].part_sounds.len(), 1);
895        assert!(reparsed.rooms[0].part_sounds[0].looping);
896        assert_eq!(reparsed.rooms[0].part_sounds[0].model_part, "ROOM_A");
897        assert_eq!(reparsed.rooms[0].part_sounds[0].omen_event, "ON_ENTER");
898        assert_eq!(reparsed.rooms[0].part_sounds[0].sound, "amb_rooma");
899        assert_eq!(reparsed.rooms.len(), 2);
900    }
901
902    #[test]
903    fn rejects_non_are_file_type() {
904        let gff = Gff::new(*b"DLG ", GffStruct::new(-1));
905        let err = Are::from_gff(&gff).expect_err("must fail");
906        assert!(matches!(err, AreError::UnsupportedFileType(file_type) if file_type == *b"DLG "));
907    }
908
909    #[test]
910    fn read_are_from_reader_matches_bytes_path() {
911        let mut cursor = Cursor::new(TEST_ARE);
912        let via_reader = read_are(&mut cursor).expect("reader parse");
913        let via_bytes = read_are_from_bytes(TEST_ARE).expect("bytes parse");
914        assert_eq!(via_reader.tag, via_bytes.tag);
915        assert_eq!(via_reader.rooms.len(), via_bytes.rooms.len());
916    }
917
918    #[test]
919    fn a_mistyped_rooms_list_reads_as_empty() {
920        let mut root = GffStruct::new(-1);
921        root.push_field(gff_label!("Rooms"), GffValue::UInt32(7));
922        let gff = Gff::new(*b"ARE ", root);
923
924        let are = Are::from_gff(&gff).expect("a mistyped list is not a read failure");
925
926        assert!(are.rooms.is_empty());
927    }
928
929    #[test]
930    fn map_points_accept_int_encoding_for_k1_parity() {
931        let mut root = GffStruct::new(-1);
932        let mut map = GffStruct::new(0);
933        map.push_field(gff_label!("MapPt1X"), GffValue::Int32(10));
934        map.push_field(gff_label!("MapPt1Y"), GffValue::Int32(20));
935        map.push_field(gff_label!("MapPt2X"), GffValue::UInt16(30));
936        map.push_field(gff_label!("MapPt2Y"), GffValue::UInt8(40));
937        root.push_field(gff_label!("Map"), GffValue::Struct(Box::new(map)));
938        let gff = Gff::new(*b"ARE ", root);
939
940        let are = Are::from_gff(&gff).expect("must parse");
941        assert_eq!((are.map.map_point_1_x, are.map.map_point_1_y), (10.0, 20.0));
942        assert_eq!((are.map.map_point_2_x, are.map.map_point_2_y), (30.0, 40.0));
943    }
944
945    #[test]
946    fn a_mistyped_expansion_list_reads_as_empty() {
947        let mut root = GffStruct::new(-1);
948        root.push_field(gff_label!("Expansion_List"), GffValue::UInt32(7));
949        let gff = Gff::new(*b"ARE ", root);
950
951        let are = Are::from_gff(&gff).expect("a mistyped list is not a read failure");
952
953        assert!(are.expansion_list.is_empty());
954    }
955
956    #[test]
957    fn a_mistyped_part_sounds_list_reads_as_empty() {
958        let mut root = GffStruct::new(-1);
959        let mut room = GffStruct::new(0);
960        room.push_field(gff_label!("PartSounds"), GffValue::UInt32(7));
961        root.push_field(gff_label!("Rooms"), GffValue::List(vec![room]));
962        let gff = Gff::new(*b"ARE ", root);
963
964        let are = Are::from_gff(&gff).expect("a mistyped list is not a read failure");
965
966        assert!(are.rooms[0].part_sounds.is_empty());
967    }
968
969    #[test]
970    fn a_mistyped_part_sound_field_reads_as_absent() {
971        // Every other view answers a wrong-typed label the same way, and the
972        // old reader answering with an error made ARE the one format where a
973        // single bad byte cost the whole area.
974        let mut root = GffStruct::new(-1);
975        let mut room = GffStruct::new(0);
976        let mut part_sound = GffStruct::new(0);
977        part_sound.push_field(gff_label!("Looping"), GffValue::String("yes".into()));
978        room.push_field(gff_label!("PartSounds"), GffValue::List(vec![part_sound]));
979        root.push_field(gff_label!("Rooms"), GffValue::List(vec![room]));
980        let gff = Gff::new(*b"ARE ", root);
981
982        let are = Are::from_gff(&gff).expect("a mistyped field is not a read failure");
983
984        assert!(!are.rooms[0].part_sounds[0].looping);
985    }
986
987    #[test]
988    fn write_are_matches_direct_gff_writer() {
989        let are = read_are_from_bytes(TEST_ARE).expect("fixture parse");
990        let from_are = author_are_to_vec(&are).expect("are encode");
991
992        let gff = are.to_gff();
993        let from_gff = rakata_formats::write_gff_to_vec(&gff).expect("gff encode");
994        assert_eq!(from_are, from_gff);
995    }
996
997    #[test]
998    fn schema_field_count() {
999        assert_eq!(Are::schema().len(), 81);
1000    }
1001
1002    #[test]
1003    fn schema_no_duplicate_labels() {
1004        let mut labels: Vec<&str> = Are::schema().iter().map(|f| f.label.as_str()).collect();
1005        labels.sort_unstable();
1006        let before = labels.len();
1007        labels.dedup();
1008        assert_eq!(before, labels.len(), "duplicate labels in ARE schema");
1009    }
1010
1011    /// Summed across parts, because a schema is a sequence: a type that
1012    /// flattens a child reaches its fields through a second part rather than
1013    /// copying them, and a count over the first part alone would miss them.
1014    fn child_count(label: &str) -> usize {
1015        let field = Are::schema()
1016            .iter()
1017            .find(|f| f.label.as_str() == label)
1018            .unwrap_or_else(|| panic!("{label} must exist in the schema"));
1019        match field.shape {
1020            Shape::List { element, .. } => element.iter().map(|p| p.len()).sum(),
1021            Shape::Struct { fields } => fields.iter().map(|p| p.len()).sum(),
1022            other => panic!("{label} is a container, got {other:?}"),
1023        }
1024    }
1025
1026    #[test]
1027    fn schema_containers_carry_their_children() {
1028        assert_eq!(child_count("Rooms"), 6);
1029        assert_eq!(child_count("Map"), 11);
1030        assert_eq!(child_count("Expansion_List"), 2);
1031        assert_eq!(child_count("MiniGame"), 15);
1032    }
1033
1034    /// The four labels a room's part sounds carry, which the schema this
1035    /// replaces declared the list without.
1036    #[test]
1037    fn a_part_sound_declares_what_the_view_reads() {
1038        let Shape::List { element, .. } = Are::schema()
1039            .iter()
1040            .find(|f| f.label.as_str() == "Rooms")
1041            .expect("declared")
1042            .shape
1043        else {
1044            panic!("Rooms is a list");
1045        };
1046        let part_sounds = element
1047            .iter()
1048            .flat_map(|part| part.iter())
1049            .find(|f| f.label.as_str() == "PartSounds")
1050            .expect("a room declares its part sounds");
1051        let Shape::List { element, .. } = part_sounds.shape else {
1052            panic!("PartSounds is a list");
1053        };
1054        let labels: Vec<&str> = element
1055            .iter()
1056            .flat_map(|part| part.iter())
1057            .map(|f| f.label.as_str())
1058            .collect();
1059        assert_eq!(labels, ["Looping", "ModelPart", "OmenEvent", "Sound"]);
1060    }
1061}