Skip to main content

rakata_generics/are/
minigame.rs

1//! Typed views over the ARE `MiniGame` subsystem.
2//!
3//! One area in four thousand-odd ships a minigame, and the four that do carry
4//! a `MiniGame` struct nested in the ARE root. `Type` selects the mode: 1 is
5//! swoop racing, 2 is the turret sequence. Naming anything here for swoop
6//! alone names half the subsystem, hence the neutral module name.
7//!
8//! ## Field layout
9//! ```text
10//! MiniGame              (Struct -> AreMiniGame)
11//! +-- Type / MovementPerSec / LateralAccel / Bump_Plane
12//! +-- DoBumping / UseInertia / DOF / Music
13//! +-- Far_Clip / Near_Clip / CameraViewAngle
14//! +-- Player            (Struct -> AreMiniGamePlayer)
15//! |   +-- Models        (List<AreMiniGameModel>)
16//! |   `-- Track / Camera / CameraRotate
17//! +-- Mouse             (Struct -> AreMiniGameMouse)
18//! +-- Enemies           (List<AreMiniGameEnemy>)
19//! `-- Obstacles         (List<AreMiniGameObstacle>)
20//! ```
21//!
22//! `Mouse`, `Enemies` and `Obstacles` are siblings of `Player`, not children.
23//! `Enemies` and `Obstacles` are engine-attested; `Mouse` sits alongside them
24//! in the one vanilla file carrying it, but its loader path is untraced, so
25//! that level rests on the data alone.
26//!
27//! The nesting was wrong here for a while, which read every enemy and obstacle
28//! in the game as absent. `docs/src/internals/minigame_deep_dive.md` has the
29//! structure and what the engine does with each, and
30//! `docs/src/testing.md` uses the mistake as its worked example of a test that
31//! repeats the code's own misreading.
32
33use rakata_core::ResRef;
34use rakata_formats::{GffModel, GffStruct};
35
36/// Typed view over the ARE `MiniGame` nested struct.
37#[derive(Debug, Clone, PartialEq, GffModel)]
38pub struct AreMiniGame {
39    /// Mini-game type (`Type`): 1 = swoop, 2 = turret.
40    #[gff(Type, unexamined)]
41    pub mini_game_type: u32,
42    /// Movement speed (`MovementPerSec`).
43    #[gff(MovementPerSec, unexamined)]
44    pub movement_per_sec: f32,
45    /// Lateral acceleration (`LateralAccel`).
46    #[gff(LateralAccel, unexamined = 60.0)]
47    pub lateral_accel: f32,
48    /// Bump plane index (`Bump_Plane`).
49    #[gff(Bump_Plane, unexamined)]
50    pub bump_plane: u32,
51    /// Bumping enabled (`DoBumping`).
52    #[gff(DoBumping, unexamined)]
53    pub do_bumping: bool,
54    /// Inertia enabled (`UseInertia`).
55    #[gff(UseInertia, unexamined)]
56    pub use_inertia: bool,
57    /// Degrees of freedom (`DOF`).
58    #[gff(DOF, unexamined)]
59    pub dof: u32,
60    /// Music resref (`Music`).
61    #[gff(Music, unexamined)]
62    pub music: ResRef,
63    /// Far clip distance (`Far_Clip`).
64    #[gff(Far_Clip, unexamined = 100.0)]
65    pub far_clip: f32,
66    /// Near clip distance (`Near_Clip`).
67    #[gff(Near_Clip, unexamined = 0.1)]
68    pub near_clip: f32,
69    /// Camera view angle (`CameraViewAngle`).
70    #[gff(CameraViewAngle, unexamined = 65.0)]
71    pub camera_view_angle: f32,
72    /// Player sub-struct (`Player`).
73    #[gff(Player, unexamined, nested = AreMiniGamePlayer, optional)]
74    pub player: Option<AreMiniGamePlayer>,
75    /// Mouse axis settings (`Mouse`), when the area carries the struct.
76    ///
77    /// A sibling of `Player`, not a child of it. One vanilla minigame area of
78    /// four has it.
79    ///
80    /// [`Option`] rather than a defaulted value, because presence and default
81    /// are different questions for a struct. A default answers what the
82    /// engine substitutes for an absent scalar; it says nothing about whether
83    /// a nested struct exists. Materializing an all-zero `Mouse` for the
84    /// three files without one and then writing it back would invent
85    /// structure those files never had. `MiniGame` itself is an `Option` on
86    /// [`Are`](crate::Are) for the same reason, so this matches the convention already in
87    /// place rather than adding a second one.
88    ///
89    /// What the engine does with an absent `Mouse` is untraced, which is the
90    /// argument for `Option` rather than against it: preserving the file's
91    /// actual state invents nothing under that ignorance.
92    #[gff(Mouse, not_a_constant, nested = AreMiniGameMouse, optional)]
93    pub mouse: Option<AreMiniGameMouse>,
94    /// Enemy list (`Enemies`), a sibling of `Player`.
95    #[gff(Enemies, unexamined, list = AreMiniGameEnemy, element_id = 0)]
96    pub enemies: Vec<AreMiniGameEnemy>,
97    /// Obstacle list (`Obstacles`), a sibling of `Player`.
98    #[gff(Obstacles, unexamined, list = AreMiniGameObstacle, element_id = 0)]
99    pub obstacles: Vec<AreMiniGameObstacle>,
100}
101
102/// The `Scripts` struct every minigame object carries.
103///
104/// Read by `CSWMiniGameObject::LoadScripts`, which obstacles use directly and
105/// vehicles extend (see [`AreMiniGameVehicleScripts`]). Every slot defaults to
106/// an empty resref and the engine writes that empty value into the script slot
107/// whether or not the file supplied one, so an absent field clears a prior
108/// script rather than leaving it in place.
109///
110/// `OnHeartbeat` is the fifth and last slot the base reads, after
111/// `OnAnimEvent`.
112///
113/// It was missing from the write-up of this subsystem, and from the corpus
114/// scan the write-up was checked against, because the ARE root carries a
115/// script field under the same label: a scan keyed on bare labels sees the
116/// root's and counts the nested one as already modelled.
117#[derive(Debug, Clone, PartialEq, GffModel)]
118pub struct AreMiniGameObjectScripts {
119    /// Fired when the object is created (`OnCreate`).
120    #[gff(OnCreate, stamped)]
121    pub on_create: ResRef,
122    /// Fired on the object's heartbeat (`OnHeartbeat`).
123    #[gff(OnHeartbeat, stamped)]
124    pub on_heartbeat: ResRef,
125    /// Fired on a model animation event (`OnAnimEvent`).
126    #[gff(OnAnimEvent, stamped)]
127    pub on_anim_event: ResRef,
128    /// Fired when a bullet hits the object (`OnHitBullet`).
129    #[gff(OnHitBullet, stamped)]
130    pub on_hit_bullet: ResRef,
131    /// Fired when a track follower hits the object (`OnHitFollower`).
132    #[gff(OnHitFollower, stamped)]
133    pub on_hit_follower: ResRef,
134}
135
136/// The `Scripts` struct on a vehicle, which is the object set plus five more.
137///
138/// `CSWTrackFollower::LoadScripts` overrides the base and reads the extra five
139/// on top of it. Obstacles never carry them, and the corpus agrees: an
140/// obstacle's `Scripts` holds exactly the base set.
141#[derive(Debug, Clone, PartialEq, GffModel)]
142pub struct AreMiniGameVehicleScripts {
143    /// The slots shared with obstacles.
144    #[gff(flatten = AreMiniGameObjectScripts)]
145    pub object: AreMiniGameObjectScripts,
146    /// Fired when the vehicle takes damage (`OnDamage`).
147    #[gff(OnDamage, stamped)]
148    pub on_damage: ResRef,
149    /// Fired when the vehicle is destroyed (`OnDeath`).
150    #[gff(OnDeath, stamped)]
151    pub on_death: ResRef,
152    /// Fired when the vehicle fires a gun bank (`OnFire`).
153    #[gff(OnFire, stamped)]
154    pub on_fire: ResRef,
155    /// Fired when the vehicle hits an obstacle (`OnHitObstacle`).
156    #[gff(OnHitObstacle, stamped)]
157    pub on_hit_obstacle: ResRef,
158    /// Fired each time the vehicle completes a track loop (`OnTrackLoop`).
159    #[gff(OnTrackLoop, stamped)]
160    pub on_track_loop: ResRef,
161}
162
163/// The `Sounds` struct on a vehicle.
164///
165/// Read by `CSWTrackFollower::LoadSounds`. Both default to empty and are
166/// written into their slots whether or not the file supplied them, the same
167/// overwrite-on-absence pattern the scripts follow. A non-empty `Engine` sound
168/// is additionally forced into looping playback.
169#[derive(Debug, Clone, PartialEq, GffModel)]
170pub struct AreMiniGameSounds {
171    /// Looping engine sound (`Engine`).
172    #[gff(Engine, stamped)]
173    pub engine: ResRef,
174    /// One-shot destruction sound (`Death`).
175    #[gff(Death, stamped)]
176    pub death: ResRef,
177}
178
179/// The `BankID` value that means "no bank".
180///
181/// The engine reads `BankID` with this as its default and then gates bank
182/// creation by comparing the *resolved* value against the same literal, with
183/// no separate check for whether the file supplied one. So an absent `BankID`
184/// and one written explicitly as `0xffffffff` are the same case, which is why
185/// the view models one `u32` rather than an `Option` that would claim to tell
186/// them apart.
187pub const BANK_ID_NONE: u32 = 0xFFFF_FFFF;
188
189/// The ballistics of one gun bank (`Bullet`).
190///
191/// The first five fields are a presence-gated chain: each read reports
192/// whether the file supplied the field, and that gates whether the next one
193/// is attempted at all. If any is genuinely absent the chain truncates and
194/// the bank is never created, so there is no such thing as a partial bank
195/// built from defaults. `Bullet_Model` and `Collision_Sound` are read
196/// unconditionally once the chain completes.
197///
198/// The view models all seven as plain fields rather than making the chain
199/// visible in the type. A file missing one of the five is malformed, and
200/// saying so is a diagnostic's job: the schema marks them required and lint
201/// reports the dropped bank. Seven `Option`s would push that judgement onto
202/// every caller that only wanted to read a rate of fire.
203#[derive(Debug, Clone, PartialEq, GffModel)]
204pub struct AreMiniGameBullet {
205    /// Damage per hit (`Damage`).
206    #[gff(Damage, required, not_a_constant)]
207    pub damage: u32,
208    /// Seconds before the bullet expires (`Lifespan`).
209    #[gff(Lifespan, required, not_a_constant)]
210    pub lifespan: f32,
211    /// Seconds between shots (`Rate_Of_Fire`).
212    #[gff(Rate_Of_Fire, required, not_a_constant)]
213    pub rate_of_fire: f32,
214    /// Travel speed (`Speed`).
215    #[gff(Speed, required, not_a_constant)]
216    pub speed: f32,
217    /// What the bank is allowed to shoot at (`Target_Type`).
218    #[gff(Target_Type, required, not_a_constant)]
219    pub target_type: u32,
220    /// Bullet model resref (`Bullet_Model`).
221    #[gff(Bullet_Model, stamped)]
222    pub bullet_model: ResRef,
223    /// Sound played on impact (`Collision_Sound`).
224    #[gff(Collision_Sound, stamped)]
225    pub collision_sound: ResRef,
226}
227
228/// The AI aiming parameters an enemy gun bank carries.
229///
230/// Flat on the bank entry, siblings of `Bullet` rather than fields inside it,
231/// and read only for enemies: the player's own guns never carry them. Grouped
232/// here because they are one concept and because the grouping is what lets
233/// the bank say "this is an enemy's" without a separate flag.
234#[derive(Debug, Clone, PartialEq, GffModel)]
235pub struct AreMiniGameTargeting {
236    /// How far the bank looks for a target (`Sensing_Radius`).
237    #[gff(Sensing_Radius, not_a_constant)]
238    pub sensing_radius: f32,
239    /// Horizontal firing arc (`Horiz_Spread`).
240    #[gff(Horiz_Spread, not_a_constant)]
241    pub horiz_spread: f32,
242    /// Vertical firing arc (`Vert_Spread`).
243    #[gff(Vert_Spread, not_a_constant)]
244    pub vert_spread: f32,
245    /// How far shots stray from the target (`Inaccuracy`).
246    #[gff(Inaccuracy, not_a_constant)]
247    pub inaccuracy: f32,
248}
249
250impl AreMiniGameTargeting {
251    /// The four labels, read and written as a set.
252    const LABELS: [&'static str; 4] = [
253        "Sensing_Radius",
254        "Horiz_Spread",
255        "Vert_Spread",
256        "Inaccuracy",
257    ];
258
259    /// Reads the group, or `None` when the bank is not an enemy's.
260    ///
261    /// Presence is keyed on all four rather than any, because the engine
262    /// reads them as a presence-gated chain and aborts the whole bank if one
263    /// is missing. A bank with three of the four is a bank the engine drops,
264    /// so treating that as `Some` would report a working set where the file
265    /// describes a broken one. No vanilla bank is partial: enemy banks carry
266    /// all four and player banks carry none.
267    fn read_group(structure: &GffStruct) -> Option<Self> {
268        Self::LABELS
269            .iter()
270            .all(|label| structure.field(label).is_some())
271            .then(|| Self::read_declared(structure))
272    }
273}
274
275/// One entry in a vehicle's `Gun_Banks` list.
276#[derive(Debug, Clone, PartialEq, GffModel)]
277pub struct AreMiniGameGunBank {
278    /// Which hardpoint this bank occupies (`BankID`).
279    ///
280    /// [`BANK_ID_NONE`] means the engine skips the bank entirely.
281    #[gff(BankID, required, stamped = 4294967295)]
282    pub bank_id: u32,
283    /// Gun model resref (`Gun_Model`).
284    ///
285    /// An invalid or absent resref aborts the bank, so an empty value here
286    /// describes a bank that will not be created.
287    #[gff(Gun_Model, required, not_a_constant)]
288    pub gun_model: ResRef,
289    /// Ballistics (`Bullet`), absent when the bank carries no such struct.
290    ///
291    /// [`Option`] because absence is not the same as a zeroed struct. The
292    /// engine skips a bank with no `Bullet`; a view that materialised an
293    /// all-zero one and wrote it back would turn a bank the engine drops into
294    /// one it creates and fires at zero speed. That is a silent change of
295    /// meaning on write, which is the failure this type exists to avoid.
296    #[gff(Bullet, unexamined, nested = AreMiniGameBullet, optional)]
297    pub bullet: Option<AreMiniGameBullet>,
298    /// Sound played when the bank fires (`Fire_Sound`).
299    ///
300    /// A sibling of `Bullet`, not a field inside it, despite reading like one.
301    #[gff(Fire_Sound, stamped)]
302    pub fire_sound: ResRef,
303    /// AI aiming parameters, absent on a player's own banks.
304    ///
305    /// Flat on the bank entry rather than under a label of its own, so the
306    /// four sit beside `Bullet` in the file. Whether the bank carries them at
307    /// all is a rule no attribute states, which is why both codec halves are
308    /// this type's: the engine reads the four as a presence-gated chain and
309    /// drops the whole bank if one is missing, so a partial set is not a
310    /// group to fill in with defaults.
311    #[gff(flatten = AreMiniGameTargeting, manual_read, manual_write)]
312    pub targeting: Option<AreMiniGameTargeting>,
313}
314
315impl AreMiniGameGunBank {
316    /// Reads one `Gun_Banks` element.
317    ///
318    /// The derive supplies neither element half for a type with a
319    /// hand-written codec, so the targeting group's presence rule lives here.
320    fn read_element(structure: &GffStruct) -> Self {
321        Self {
322            targeting: AreMiniGameTargeting::read_group(structure),
323            ..Self::read_declared(structure)
324        }
325    }
326
327    /// Writes one `Gun_Banks` element.
328    fn write_element(&self, structure: &mut GffStruct) {
329        self.write_declared(structure);
330        if let Some(targeting) = &self.targeting {
331            targeting.write_element(structure);
332        }
333    }
334}
335
336/// The fields the player vehicle and every enemy vehicle both carry.
337///
338/// The engine backs both with one class, `CSWTrackFollower`, and both
339/// `CSWMiniPlayer::Load` and `CSWMiniEnemy::Load` delegate to
340/// `CSWTrackFollower::Load` on an embedded sub-object before reading anything
341/// of their own. So the sharing here is a fact about the engine's object
342/// graph, not a resemblance spotted between two field lists.
343///
344/// The distinction decides whether a shared base is worth having at all. One
345/// derived from the intersection of two field lists is a coincidence given a
346/// name, and it breaks the moment either side gains a field the other lacks.
347/// This one names a class the engine has, so a new field belongs either to
348/// that class or to one of the two subclasses, and the model has somewhere
349/// honest to put it either way.
350///
351/// Obstacles sit outside this entirely. `CSWMGObstacle::Load` reads a name and
352/// a scripts struct, with no lifecycle, weapon or track data at all.
353#[derive(Debug, Clone, PartialEq, GffModel)]
354pub struct AreMiniGameVehicle {
355    /// Model list (`Models`).
356    #[gff(Models, unexamined, list = AreMiniGameModel, element_id = 0)]
357    pub models: Vec<AreMiniGameModel>,
358    /// Track resref (`Track`) the vehicle follows.
359    #[gff(Track, unexamined)]
360    pub track: ResRef,
361    /// Current hit points (`Hit_Points`).
362    ///
363    /// The engine applies the file's value only when it is greater than zero.
364    /// A zero or absent value leaves whatever the object was constructed with,
365    /// so zero here means "engine decides", not "starts dead".
366    #[gff(Hit_Points, unanswered)]
367    pub hit_points: u32,
368    /// Maximum hit points (`Max_HPs`), gated the same way as [`Self::hit_points`].
369    #[gff(Max_HPs, unanswered)]
370    pub max_hps: u32,
371    /// Collision sphere radius (`Sphere_Radius`).
372    ///
373    /// Default `-1.0`, which the engine reads as a sentinel: it applies the
374    /// value only when it is at or above zero, so a negative radius means the
375    /// constructed default stands.
376    #[gff(Sphere_Radius, unanswered = -1.0)]
377    pub sphere_radius: f32,
378    /// Invulnerability window in seconds after a hit (`Invince_Period`).
379    #[gff(Invince_Period, stamped)]
380    pub invince_period: f32,
381    /// Damage dealt to whatever this vehicle bumps into (`Bump_Damage`).
382    #[gff(Bump_Damage, stamped)]
383    pub bump_damage: i32,
384    /// Number of times the vehicle loops its track (`Num_Loops`).
385    ///
386    /// Default `-10`, a sentinel the engine passes through to a virtual setter
387    /// whose own handling of it was not traced. Vanilla writes `-1` for every
388    /// enemy, which is a different sentinel again.
389    #[gff(Num_Loops, unanswered = -10)]
390    pub num_loops: i32,
391    /// Script slots (`Scripts`).
392    ///
393    /// Not an [`Option`] despite being a nested struct, unlike
394    /// [`AreMiniGame::mouse`]. The engine's read here is traced and provably
395    /// indifferent: an absent `Scripts` and one holding nothing but empty
396    /// resrefs leave the object in the same state, because every slot is
397    /// written whether or not the file supplied it. Materializing one invents
398    /// a label but no behaviour. Once absent-value auditing can express "omit
399    /// when equal to the audited default", an all-empty `Scripts` is a
400    /// candidate to stop being written at all.
401    #[gff(Scripts, unexamined, nested = AreMiniGameVehicleScripts)]
402    pub scripts: AreMiniGameVehicleScripts,
403    /// Engine and destruction sounds (`Sounds`), on the same footing as
404    /// [`Self::scripts`].
405    #[gff(Sounds, unexamined, nested = AreMiniGameSounds)]
406    pub sounds: AreMiniGameSounds,
407    /// Weapon hardpoints (`Gun_Banks`).
408    #[gff(Gun_Banks, unexamined, list = AreMiniGameGunBank, element_id = 0)]
409    pub gun_banks: Vec<AreMiniGameGunBank>,
410}
411
412/// Typed view over the `MiniGame.Player` sub-struct.
413#[derive(Debug, Clone, PartialEq, GffModel)]
414pub struct AreMiniGamePlayer {
415    /// The vehicle fields shared with every enemy.
416    #[gff(flatten = AreMiniGameVehicle)]
417    pub vehicle: AreMiniGameVehicle,
418    /// Camera resref (`Camera`) - only used for turret type (type 2).
419    #[gff(Camera, unexamined)]
420    pub camera: ResRef,
421    /// Camera rotation flag (`CameraRotate`).
422    #[gff(CameraRotate, unexamined)]
423    pub camera_rotate: bool,
424    /// Lower speed bound (`Minimum_Speed`).
425    ///
426    /// Default `-1.0`, a sentinel: the engine applies the value only when it
427    /// is at or above zero, so a negative bound leaves the constructed one.
428    #[gff(Minimum_Speed, unanswered = -1.0)]
429    pub minimum_speed: f32,
430    /// Upper speed bound (`Maximum_Speed`), default `100.0`.
431    ///
432    /// Gated the same way as [`Self::minimum_speed`], which for a default of
433    /// `100.0` means the gate never rejects it.
434    #[gff(Maximum_Speed, stamped = 100.0)]
435    pub maximum_speed: f32,
436    /// Seconds to accelerate across the speed range (`Accel_Secs`).
437    ///
438    /// The engine keeps only the acceleration it derives from this and the
439    /// two speed bounds, never the field itself, and the derivation has three
440    /// branches: exactly `0.0` gives the full speed range as the rate, a
441    /// negative value skips the derivation, and anything else divides the
442    /// range by it. The view models the file's value, since that is what the
443    /// file holds and what a caller edits.
444    #[gff(Accel_Secs, not_a_constant = -1.0)]
445    pub accel_secs: f32,
446    /// Positive-X track boundary (`TunnelXPos`).
447    #[gff(TunnelXPos, stamped)]
448    pub tunnel_x_pos: f32,
449    /// Negative-X track boundary (`TunnelXNeg`).
450    #[gff(TunnelXNeg, stamped)]
451    pub tunnel_x_neg: f32,
452    /// Positive-Y track boundary (`TunnelYPos`).
453    #[gff(TunnelYPos, unexamined)]
454    pub tunnel_y_pos: f32,
455    /// Negative-Y track boundary (`TunnelYNeg`).
456    #[gff(TunnelYNeg, unexamined)]
457    pub tunnel_y_neg: f32,
458    /// Positive-Z track boundary (`TunnelZPos`).
459    #[gff(TunnelZPos, stamped)]
460    pub tunnel_z_pos: f32,
461    /// Negative-Z track boundary (`TunnelZNeg`).
462    #[gff(TunnelZNeg, stamped)]
463    pub tunnel_z_neg: f32,
464    /// Per-axis flags for an unbounded tunnel (`TunnelInfinite`).
465    ///
466    /// A GFF vector rather than three floats, unlike the bounds it modifies.
467    #[gff(TunnelInfinite, unexamined)]
468    pub tunnel_infinite: [f32; 3],
469    /// Start position X offset (`Start_Offset_X`).
470    ///
471    /// The engine assembles the three into one vector and feeds it to the
472    /// vehicle's origin. They stay three fields here because that is how the
473    /// file stores them.
474    #[gff(Start_Offset_X, stamped)]
475    pub start_offset_x: f32,
476    /// Start position Y offset (`Start_Offset_Y`).
477    #[gff(Start_Offset_Y, stamped)]
478    pub start_offset_y: f32,
479    /// Start position Z offset (`Start_Offset_Z`).
480    #[gff(Start_Offset_Z, stamped)]
481    pub start_offset_z: f32,
482    /// Camera target X offset (`Target_Offset_X`).
483    ///
484    /// Three independent floats, and not assembled into a vector the way the
485    /// start offsets are.
486    #[gff(Target_Offset_X, stamped)]
487    pub target_offset_x: f32,
488    /// Camera target Y offset (`Target_Offset_Y`).
489    #[gff(Target_Offset_Y, stamped)]
490    pub target_offset_y: f32,
491    /// Camera target Z offset (`Target_Offset_Z`).
492    #[gff(Target_Offset_Z, stamped)]
493    pub target_offset_z: f32,
494}
495
496/// Typed view over one model entry in a mini-game model list.
497#[derive(Debug, Clone, PartialEq, GffModel)]
498pub struct AreMiniGameModel {
499    /// Model resref (`Model`).
500    #[gff(Model, unexamined)]
501    pub model: ResRef,
502    /// Whether the model rotates (`RotatingModel`).
503    #[gff(RotatingModel, unexamined = true)]
504    pub rotating_model: bool,
505}
506
507/// Typed view over the `MiniGame.Player.Mouse` sub-struct.
508#[derive(Debug, Clone, PartialEq, GffModel)]
509pub struct AreMiniGameMouse {
510    /// X axis index (`AxisX`).
511    #[gff(AxisX, unexamined)]
512    pub axis_x: u32,
513    /// Y axis index (`AxisY`).
514    #[gff(AxisY, unexamined)]
515    pub axis_y: u32,
516    /// Flip X axis (`FlipAxisX`).
517    #[gff(FlipAxisX, unexamined)]
518    pub flip_axis_x: bool,
519    /// Flip Y axis (`FlipAxisY`).
520    #[gff(FlipAxisY, unexamined)]
521    pub flip_axis_y: bool,
522}
523
524/// Typed view over one enemy entry in `MiniGame.Enemies`.
525#[derive(Debug, Clone, PartialEq, GffModel)]
526pub struct AreMiniGameEnemy {
527    /// The vehicle fields shared with the player.
528    #[gff(flatten = AreMiniGameVehicle)]
529    pub vehicle: AreMiniGameVehicle,
530    /// Enemy-only flag (`Trigger`), default `0`.
531    ///
532    /// The one field an enemy reads beyond the shared base. Unlike the
533    /// carry-over fields there, the default is applied unconditionally: the
534    /// read's presence flag is never inspected, so an absent `Trigger` lands
535    /// as `0` exactly as if the file had written one.
536    ///
537    /// Kept as the byte the file holds rather than narrowed to a `bool`, even
538    /// though vanilla only ever writes `0` or `1` and the module models its
539    /// other byte flags as `bool`. The spec records the type and the default
540    /// and says nothing about the value being interpreted as a flag, so
541    /// narrowing would turn a `2` into a `1` on write on the strength of a
542    /// guess about the name. The range vanilla happens to use is evidence
543    /// about vanilla's content, not about the field's domain.
544    ///
545    /// The inconsistency with this module's six `bool` fields runs the other
546    /// way from how it looks, and it is deliberate. `DoBumping`,
547    /// `UseInertia`, `CameraRotate`, `RotatingModel`, `FlipAxisX` and
548    /// `FlipAxisY` appear nowhere in the minigame or ARE specs: they predate
549    /// the audit that covered this subsystem, so each is a `bool` on the
550    /// strength of its name alone. `Trigger` is the one with a traced read.
551    /// Do not "fix" it to match them.
552    ///
553    /// Left as is rather than widened, because a corpus round-trip reports no
554    /// changed values across ARE, which means no vanilla file carries a value
555    /// outside `0`/`1` in any of the six. The narrowing is lossless against
556    /// real content and latent rather than live, so this is a note and a
557    /// cheap future audit question, not a defect.
558    #[gff(Trigger, stamped)]
559    pub trigger: u8,
560}
561
562/// Typed view over one obstacle entry in `MiniGame.Obstacles`.
563///
564/// The lightest of the three object shapes. `CSWMGObstacle::Load` matches the
565/// entry to an already-placed object by `Name` and reads its `Scripts` struct,
566/// and nothing else: no lifecycle, no weapons, no track geometry.
567#[derive(Debug, Clone, PartialEq, GffModel)]
568pub struct AreMiniGameObstacle {
569    /// Obstacle name resref (`Name`), matched against a placed object.
570    #[gff(Name, unexamined)]
571    pub name: ResRef,
572    /// Script slots (`Scripts`), the base set with none of the vehicle
573    /// additions.
574    #[gff(Scripts, unexamined, nested = AreMiniGameObjectScripts)]
575    pub scripts: AreMiniGameObjectScripts,
576}
577
578#[cfg(test)]
579mod tests {
580    use rakata_formats::schema::{Field, Shape};
581    use rakata_formats::{gff_label, GffValue};
582
583    /// One value written into a struct of its own, which is what the old
584    /// `to_struct` pair returned.
585    macro_rules! written_struct {
586        ($value:expr) => {{
587            let mut structure = GffStruct::new(0);
588            $value.write_element(&mut structure);
589            structure
590        }};
591    }
592
593    use super::*;
594
595    use crate::are::{author_are_to_vec, read_are_from_bytes, Are};
596    use rakata_formats::Gff;
597
598    /// Asserts every label the writer emits is declared at the level it lands
599    /// on.
600    ///
601    /// A container references its child's parts, so a flattened grandchild's
602    /// fields are in the walk. Recursion stops where a container declares no
603    /// child schema, which is the view modelling nothing behind it rather
604    /// than a level going unchecked.
605    fn assert_declared(structure: &GffStruct, parts: &[&[Field]], path: &str) {
606        for field in &structure.fields {
607            let entry = parts
608                .iter()
609                .flat_map(|part| part.iter())
610                .find(|f| f.label.as_str() == field.label.as_str())
611                .unwrap_or_else(|| {
612                    panic!("{path}.{} is written but not in the schema", field.label)
613                });
614            let child_path = format!("{path}.{}", field.label);
615            match (&field.value, entry.shape) {
616                (GffValue::Struct(inner), Shape::Struct { fields }) if !fields.is_empty() => {
617                    assert_declared(inner, fields, &child_path);
618                }
619                (GffValue::List(items), Shape::List { element, .. }) if !element.is_empty() => {
620                    for item in items {
621                        assert_declared(item, element, &child_path);
622                    }
623                }
624                _ => {}
625            }
626        }
627    }
628
629    #[test]
630    fn every_written_minigame_label_is_in_the_schema() {
631        // Every optional struct present and every list non-empty, because a
632        // default value leaves the lists empty and the walk then stops above
633        // the levels this is meant to cover. The `Bullet` and targeting
634        // levels sit four deep and are only reachable through a populated
635        // gun bank.
636        let vehicle = || AreMiniGameVehicle {
637            models: vec![AreMiniGameModel::default()],
638            gun_banks: vec![AreMiniGameGunBank {
639                bullet: Some(AreMiniGameBullet::default()),
640                targeting: Some(AreMiniGameTargeting::default()),
641                ..AreMiniGameGunBank::default()
642            }],
643            ..AreMiniGameVehicle::default()
644        };
645        let mini_game = AreMiniGame {
646            player: Some(AreMiniGamePlayer {
647                vehicle: vehicle(),
648                ..AreMiniGamePlayer::default()
649            }),
650            mouse: Some(AreMiniGameMouse::default()),
651            enemies: vec![AreMiniGameEnemy {
652                vehicle: vehicle(),
653                trigger: 1,
654            }],
655            obstacles: vec![AreMiniGameObstacle::default()],
656            ..AreMiniGame::default()
657        };
658
659        let mut written = GffStruct::new(0);
660        mini_game.write_element(&mut written);
661        // Assert the walk reaches the deepest level before trusting that it
662        // found nothing there. A guard that passes by stopping early is the
663        // failure this whole batch keeps running into.
664        let deepest = written
665            .field("Player")
666            .and_then(|v| match v {
667                GffValue::Struct(p) => p.field("Gun_Banks"),
668                _ => None,
669            })
670            .and_then(|v| match v {
671                GffValue::List(banks) => banks.first().and_then(|b| b.field("Bullet")),
672                _ => None,
673            });
674        assert!(
675            matches!(deepest, Some(GffValue::Struct(_))),
676            "the fixture must reach MiniGame.Player.Gun_Banks[].Bullet"
677        );
678
679        assert_declared(&written, AreMiniGame::PARTS, "MiniGame");
680    }
681
682    #[test]
683    fn a_mistyped_mini_game_field_reads_as_absent() {
684        let mut root = GffStruct::new(-1);
685        root.push_field(gff_label!("MiniGame"), GffValue::UInt32(7));
686        let gff = Gff::new(*b"ARE ", root);
687
688        let are = Are::from_gff(&gff).expect("a mistyped field is not a read failure");
689
690        assert!(are.mini_game.is_none());
691    }
692
693    #[test]
694    fn mini_game_struct_roundtrips() {
695        let mg = AreMiniGame {
696            mini_game_type: 1,
697            movement_per_sec: 25.0,
698            lateral_accel: 45.0,
699            bump_plane: 2,
700            do_bumping: true,
701            use_inertia: true,
702            dof: 3,
703            music: ResRef::new("mus_swoop").expect("valid test resref"),
704            far_clip: 200.0,
705            near_clip: 0.5,
706            camera_view_angle: 70.0,
707            player: Some(AreMiniGamePlayer {
708                vehicle: AreMiniGameVehicle {
709                    models: vec![AreMiniGameModel {
710                        model: ResRef::new("swoopbike").expect("valid test resref"),
711                        rotating_model: false,
712                    }],
713                    track: ResRef::new("trk_race01").expect("valid test resref"),
714                    hit_points: 120,
715                    max_hps: 150,
716                    sphere_radius: 4.5,
717                    invince_period: 0.75,
718                    bump_damage: 9,
719                    num_loops: 3,
720                    scripts: AreMiniGameVehicleScripts {
721                        object: AreMiniGameObjectScripts {
722                            on_heartbeat: ResRef::new("mg_beat").expect("valid test resref"),
723                            ..AreMiniGameObjectScripts::default()
724                        },
725                        on_fire: ResRef::new("mg_fire").expect("valid test resref"),
726                        ..AreMiniGameVehicleScripts::default()
727                    },
728                    sounds: AreMiniGameSounds {
729                        engine: ResRef::new("snd_engine").expect("valid test resref"),
730                        death: ResRef::new("snd_boom").expect("valid test resref"),
731                    },
732                    gun_banks: vec![AreMiniGameGunBank {
733                        bank_id: 0,
734                        gun_model: ResRef::new("mgg_turret").expect("valid test resref"),
735                        bullet: Some(AreMiniGameBullet {
736                            damage: 30,
737                            speed: 300.0,
738                            ..AreMiniGameBullet::default()
739                        }),
740                        ..AreMiniGameGunBank::default()
741                    }],
742                },
743                camera: ResRef::new("cam_swoop").expect("valid test resref"),
744                camera_rotate: true,
745                ..AreMiniGamePlayer::default()
746            }),
747            // Siblings of `Player`, matching where the files put them.
748            mouse: Some(AreMiniGameMouse {
749                axis_x: 1,
750                axis_y: 2,
751                flip_axis_x: true,
752                flip_axis_y: false,
753            }),
754            enemies: vec![AreMiniGameEnemy {
755                vehicle: AreMiniGameVehicle {
756                    track: ResRef::new("trk_enemy01").expect("valid test resref"),
757                    hit_points: 40,
758                    max_hps: 40,
759                    sphere_radius: 2.0,
760                    num_loops: -1,
761                    ..AreMiniGameVehicle::default()
762                },
763                trigger: 1,
764            }],
765            obstacles: vec![AreMiniGameObstacle {
766                name: ResRef::new("obs_rock").expect("valid test resref"),
767                scripts: AreMiniGameObjectScripts {
768                    on_hit_follower: ResRef::new("mg_bump").expect("valid test resref"),
769                    ..AreMiniGameObjectScripts::default()
770                },
771            }],
772        };
773
774        let mut are = Are::new();
775        are.mini_game = Some(mg);
776
777        let encoded = author_are_to_vec(&are).expect("encode");
778        let reparsed = read_are_from_bytes(&encoded).expect("decode");
779
780        let mg_out = reparsed.mini_game.expect("MiniGame must survive roundtrip");
781        assert_eq!(mg_out.mini_game_type, 1);
782        assert!((mg_out.movement_per_sec - 25.0).abs() < f32::EPSILON);
783        assert!((mg_out.lateral_accel - 45.0).abs() < f32::EPSILON);
784        assert_eq!(mg_out.bump_plane, 2);
785        assert!(mg_out.do_bumping);
786        assert!(mg_out.use_inertia);
787        assert_eq!(mg_out.dof, 3);
788        assert_eq!(mg_out.music, "mus_swoop");
789        assert!((mg_out.far_clip - 200.0).abs() < f32::EPSILON);
790        assert!((mg_out.near_clip - 0.5).abs() < f32::EPSILON);
791        assert!((mg_out.camera_view_angle - 70.0).abs() < f32::EPSILON);
792
793        let player = mg_out.player.expect("Player must survive roundtrip");
794        assert_eq!(player.vehicle.models.len(), 1);
795        assert_eq!(player.vehicle.models[0].model, "swoopbike");
796        assert!(!player.vehicle.models[0].rotating_model);
797        assert_eq!(player.vehicle.track, "trk_race01");
798        assert_eq!(player.vehicle.hit_points, 120);
799        assert_eq!(player.vehicle.max_hps, 150);
800        assert!((player.vehicle.sphere_radius - 4.5).abs() < f32::EPSILON);
801        assert!((player.vehicle.invince_period - 0.75).abs() < f32::EPSILON);
802        assert_eq!(player.vehicle.bump_damage, 9);
803        assert_eq!(player.vehicle.num_loops, 3);
804        assert_eq!(player.vehicle.scripts.object.on_heartbeat, "mg_beat");
805        assert_eq!(player.vehicle.scripts.on_fire, "mg_fire");
806        assert_eq!(player.vehicle.sounds.engine, "snd_engine");
807        assert_eq!(player.vehicle.sounds.death, "snd_boom");
808        assert_eq!(player.camera, "cam_swoop");
809        assert!(player.camera_rotate);
810        let mouse = mg_out.mouse.as_ref().expect("Mouse must survive roundtrip");
811        assert_eq!(mouse.axis_x, 1);
812        assert_eq!(mouse.axis_y, 2);
813        assert!(mouse.flip_axis_x);
814        assert!(!mouse.flip_axis_y);
815        assert_eq!(mg_out.enemies.len(), 1);
816        assert!(mg_out.enemies[0].vehicle.models.is_empty());
817        assert_eq!(mg_out.enemies[0].vehicle.track, "trk_enemy01");
818        assert_eq!(mg_out.enemies[0].vehicle.hit_points, 40);
819        assert_eq!(mg_out.enemies[0].vehicle.num_loops, -1);
820        assert_eq!(mg_out.obstacles.len(), 1);
821        assert_eq!(mg_out.obstacles[0].name, "obs_rock");
822        assert_eq!(mg_out.obstacles[0].scripts.on_hit_follower, "mg_bump");
823
824        // Pin the level, not just the round-trip. The reader looked these up
825        // on `Player` for as long as the type existed, which read every
826        // vanilla minigame as having no enemies and no obstacles at all. A
827        // round-trip could not catch it: nothing was read, so nothing was
828        // written, and the two agreed.
829        let encoded_gff = are.to_gff();
830        let Some(GffValue::Struct(mini_game)) = encoded_gff.root.field("MiniGame") else {
831            panic!("MiniGame must be a struct");
832        };
833        for label in ["Mouse", "Enemies", "Obstacles"] {
834            assert!(
835                mini_game.field(label).is_some(),
836                "{label} belongs on MiniGame"
837            );
838        }
839        let Some(GffValue::Struct(player_struct)) = mini_game.field("Player") else {
840            panic!("Player must be a struct");
841        };
842        for label in ["Mouse", "Enemies", "Obstacles"] {
843            assert!(
844                player_struct.field(label).is_none(),
845                "{label} must not be written under Player"
846            );
847        }
848    }
849
850    #[test]
851    fn a_gun_bank_without_a_bullet_stays_without_one() {
852        // The engine skips a bank with no `Bullet`. Materialising an all-zero
853        // one on read and writing it back would turn a bank the engine drops
854        // into a bank it creates, firing zero-speed bullets. The round-trip
855        // would look stable the whole time.
856        let bank = AreMiniGameGunBank::read_element(&GffStruct::new(0));
857        assert!(bank.bullet.is_none());
858        assert_eq!(bank.bank_id, BANK_ID_NONE);
859
860        let written = written_struct!(bank);
861        assert!(
862            written.field("Bullet").is_none(),
863            "an absent Bullet must not be invented on write"
864        );
865    }
866
867    #[test]
868    fn targeting_needs_all_four_labels_to_count_as_present() {
869        // The engine reads the four as a presence-gated chain and aborts the
870        // bank if one is missing, so three of four describes a bank that gets
871        // dropped. Reporting that as a working targeting group would claim
872        // the file says something it does not.
873        let mut partial = GffStruct::new(0);
874        partial.push_field(gff_label!("Sensing_Radius"), GffValue::Single(200.0));
875        partial.push_field(gff_label!("Horiz_Spread"), GffValue::Single(70.0));
876        partial.push_field(gff_label!("Vert_Spread"), GffValue::Single(70.0));
877        let bank = AreMiniGameGunBank::read_element(&partial);
878        assert!(bank.targeting.is_none(), "three of four is not a set");
879
880        let mut full = partial.clone();
881        full.push_field(gff_label!("Inaccuracy"), GffValue::Single(0.01));
882        let bank = AreMiniGameGunBank::read_element(&full);
883        let targeting = bank.targeting.expect("all four present");
884        assert!((targeting.sensing_radius - 200.0).abs() < f32::EPSILON);
885        assert!((targeting.inaccuracy - 0.01).abs() < f32::EPSILON);
886
887        // A player bank carries none of them and must not gain four zeroes.
888        let player_bank = AreMiniGameGunBank::read_element(&GffStruct::new(0));
889        let written = written_struct!(player_bank);
890        for label in AreMiniGameTargeting::LABELS {
891            assert!(
892                written.field(label).is_none(),
893                "{label} belongs to enemy banks only"
894            );
895        }
896    }
897
898    #[test]
899    fn the_player_movement_block_round_trips_and_defaults_to_its_sentinels() {
900        // Three of these default to something other than zero, and two of the
901        // three are sentinels meaning "leave the constructed value alone". A
902        // player struct with none of them set must come back saying that,
903        // not saying the vehicle has a zero speed range.
904        let bare = AreMiniGamePlayer::read_element(&GffStruct::new(0));
905        assert!((bare.minimum_speed - -1.0).abs() < f32::EPSILON);
906        assert!((bare.maximum_speed - 100.0).abs() < f32::EPSILON);
907        assert!((bare.accel_secs - -1.0).abs() < f32::EPSILON);
908        assert_eq!(bare.tunnel_infinite, [0.0; 3]);
909
910        let player = AreMiniGamePlayer {
911            minimum_speed: 5.0,
912            maximum_speed: 42.5,
913            accel_secs: 2.5,
914            tunnel_x_pos: 45.0,
915            tunnel_x_neg: 2.0,
916            tunnel_y_pos: 3.0,
917            tunnel_y_neg: 4.0,
918            tunnel_z_pos: 9999.0,
919            tunnel_z_neg: -9999.0,
920            tunnel_infinite: [0.0, 0.0, 1.0],
921            start_offset_x: 7.0,
922            start_offset_y: 8.0,
923            start_offset_z: 9.0,
924            target_offset_x: 10.0,
925            target_offset_y: 11.0,
926            target_offset_z: -5.0,
927            ..AreMiniGamePlayer::default()
928        };
929
930        let back = AreMiniGamePlayer::read_element(&written_struct!(player));
931        assert_eq!(back, player);
932
933        // `TunnelInfinite` is a GFF vector while the bounds it modifies are
934        // separate floats, so the writer has to keep them different shapes.
935        let written = written_struct!(player);
936        assert!(matches!(
937            written.field("TunnelInfinite"),
938            Some(GffValue::Vector3([0.0, 0.0, 1.0]))
939        ));
940        assert!(matches!(
941            written.field("TunnelZPos"),
942            Some(GffValue::Single(_))
943        ));
944    }
945
946    #[test]
947    fn an_obstacle_writes_the_base_script_set_and_no_vehicle_slots() {
948        // `CSWMGObstacle::Load` goes through the base LoadScripts, so the five
949        // slots the track-follower override adds have no reader on an
950        // obstacle. Writing them would put labels in the file that the engine
951        // never looks for, and the corpus agrees: no vanilla obstacle carries
952        // one. A round-trip cannot catch this on its own, since an invented
953        // label reads back as whatever was invented.
954        let mut are = Are::new();
955        are.mini_game = Some(AreMiniGame {
956            obstacles: vec![AreMiniGameObstacle::default()],
957            ..AreMiniGame::default()
958        });
959
960        let encoded = are.to_gff();
961        let Some(GffValue::Struct(mini_game)) = encoded.root.field("MiniGame") else {
962            panic!("MiniGame must be a struct");
963        };
964        let Some(GffValue::List(obstacles)) = mini_game.field("Obstacles") else {
965            panic!("Obstacles must be a list");
966        };
967        let Some(GffValue::Struct(scripts)) = obstacles[0].field("Scripts") else {
968            panic!("an obstacle must carry a Scripts struct");
969        };
970
971        for label in [
972            "OnCreate",
973            "OnHeartbeat",
974            "OnAnimEvent",
975            "OnHitBullet",
976            "OnHitFollower",
977        ] {
978            assert!(
979                scripts.field(label).is_some(),
980                "{label} is in the base script set"
981            );
982        }
983        for label in [
984            "OnDamage",
985            "OnDeath",
986            "OnFire",
987            "OnHitObstacle",
988            "OnTrackLoop",
989        ] {
990            assert!(
991                scripts.field(label).is_none(),
992                "{label} belongs to the vehicle override, not an obstacle"
993            );
994        }
995    }
996
997    #[test]
998    fn an_area_without_a_mouse_struct_does_not_gain_one() {
999        // Three of the four vanilla minigame areas carry no `Mouse`. Writing
1000        // an all-zero one back would invent structure they never had, which a
1001        // defaulted field rather than an `Option` would do silently.
1002        let mut are = Are::new();
1003        are.mini_game = Some(AreMiniGame {
1004            mouse: None,
1005            ..AreMiniGame::default()
1006        });
1007
1008        let encoded = are.to_gff();
1009        let Some(GffValue::Struct(mini_game)) = encoded.root.field("MiniGame") else {
1010            panic!("MiniGame must be a struct");
1011        };
1012        assert!(mini_game.field("Mouse").is_none());
1013
1014        let reparsed = Are::from_gff(&encoded).expect("parses");
1015        assert!(reparsed.mini_game.expect("minigame").mouse.is_none());
1016    }
1017}