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}