Repository navigation
Expand file tree
/
Copy pathbriefing.zig
More file actions
1184 lines (1098 loc) · 55.8 KB
/
Copy pathbriefing.zig
File metadata and controls
1184 lines (1098 loc) · 55.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! The briefing, the front end's screen 7 (`interface_briefing`, `0x00437010`), which the rooms
//! run when the player goes through the briefing room's door (`rooms.Step.briefing`), before the
//! mission is flown:
//!
//! 1. The door, with AWAITING CLEARANCE along the bottom, shown for one frame, after which the
//! briefing loads (`Stage.door`).
//! 2. The walk into the briefing room, as movies over the screen, with the door's sound and the
//! room's chatter (`Way`).
//! 3. Enriquez at the room's screen, which plays the mission's movie (`movies`), while she loops
//! through her animation (`Segment`). Escape, the pointer's right button or the end of the movie
//! ends it.
//! 4. The loadout (`loadout.Loadout`), between the movies into its hologram and back
//! (`Room.hologram`), in the briefing's loop (`Stage.hologram`). Its Escape opens the in-game
//! options, which lead back to it or to the main menu (`Briefing.afterOptions`).
//! 5. Her last word, a line of `speech_hog` with an animation of its own, which ends at its last
//! frame, or on Escape or the right button (`Stage.tag`).
//!
//! The briefing room is the Reliant's up to mission 18, and the Yamato's after it (`Room`). Each
//! pass of the briefing (`Briefing.pass`) runs one pass of the current stage's loop, and the
//! drawing (`briefing_draw`, `0x0043E730`) moves Enriquez and the movie on (`Briefing.advance`).
//!
//! After the campaign's last mission, `WinMain` runs the screen for mission 29, the campaign's end
//! (`end_mission`): Enriquez speaks over the room, without a movie, until her speech ends, and no
//! loadout or last word follows.
//!
//! In the briefing and the loadout, the O key saves a screenshot (`screenshot_key`). Loading a
//! saved game with the in-game options' LOAD over the loadout ends the briefing, and the rooms open
//! for that game's mission (`afterOptions`).
//!
//! A game mode can brief its missions in the same room, with its own movies (`Own`,
//! [#819](https://github.com/OpenReliant/openreliant/issues/819)).
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const hog = @import("../../../formats/hog.zig");
const input = @import("../../input.zig");
const cbox = @import("../cbox.zig");
const hog_snd = @import("../hog_snd.zig");
const gameflow = @import("../gameflow.zig");
const matmanager = @import("../matmanager.zig");
const canvas = @import("canvas.zig");
const rooms = @import("rooms.zig");
const loadout = @import("../../interface/loadout/loadout.zig");
const in_game_options = @import("in_game_options.zig");
const log = std.log.scoped(.briefing);
/// The key that saves a screenshot in the briefing, as it does in the loadout: O.
pub const screenshot_key: input.Key = .o;
/// The mission after the campaign's last, 28, for which `WinMain` runs the briefing as the
/// campaign's end (`0x004373FA`): Enriquez's speech `end_debriefing` in place of the mission's
/// movie, and without the loadout or the last word.
pub const end_mission = 29;
/// Enriquez's speech at the campaign's end, of `speech_hog` (`0x004E89A8`).
pub const end_debriefing = "enddebriefing.ut";
/// The mission's movie, by its number from 1, which the room's screen plays, each `%s.bik`
/// (`0x004E89A0`) of the table the briefing builds (`0x00437016` on). Missions 12, 13, 17 and 22,
/// which the campaign has none of, take mission 1's.
const movies = [_][]const u8{ "new_m01", "new_m02", "new_m03", "new_m04", "new_m05", "new_m06", "new_m07", "new_m08", "new_m09", "new_m10", "new_m11", "new_m01", "new_m01", "new_m14", "new_m15", "new_m16", "new_m01", "new_m18", "new_m19", "new_m20", "new_m21", "new_m01", "new_m23", "new_m24", "new_m25", "new_m26", "new_m27", "new_m28" };
/// Enriquez's last word before a mission, by its number, a speech file of `speech_hog`
/// (`0x004E88C8`).
const tag_format = "ms_speech\\enrbr_tag{d:0>2}.ut";
/// What the briefing room plays before a mission: the movie on the room's screen, or Enriquez
/// speaking over the room, as at the campaign's end; and her last word after the loadout. Where a
/// name is null, nothing plays there.
pub const Plan = struct {
/// The movie, a Bink file from the game or a mod, such as `new_m01.bik`. Without a movie or
/// speech, the briefing ends at once and the loadout follows.
hologram: ?[]const u8 = null,
/// Enriquez's speech, a speech file from the game or a mod. The briefing ends when she
/// finishes. With a movie too, the movie plays on the room's screen without its sound while she
/// speaks, and starts over if it ends first.
///
/// **Improvement:** the original speaks words only at the campaign's end, with nothing on the
/// screen.
speech: ?[]const u8 = null,
/// Her last word, a speech file such as `ms_speech\enrbr_tag01.ut`; none leaves her animation
/// silent.
last_word: ?[]const u8 = null,
/// The original's plan for each campaign mission, from mission 1: the movie from `movies` and
/// the last word from `tag_format`.
///
/// **Improvement:** the original fixes them by the mission's number. In OpenReliant they are
/// part of the mission's settings, which mods can change (`openreliant.records.missions`,
/// [#976](https://github.com/OpenReliant/openreliant/issues/976)).
pub const campaign: [movies.len]Plan = plans: {
var plans: [movies.len]Plan = undefined;
for (&plans, movies, 1..) |*plan, movie, mission| plan.* = .{
.hologram = movie ++ ".bik",
.last_word = std.fmt.comptimePrint(tag_format, .{mission}),
};
break :plans plans;
};
comptime {
assert(campaign.len == gameflow.last_mission);
}
};
/// The movie of mission `mission` (`movies`); null for a mission the table has none for.
///
/// **Fix:** past the table the game reads what lies beside it on the stack for the movie's name.
/// OpenReliant plays none there, and the briefing ends at once.
pub fn movieName(mission: u16) ?[]const u8 {
if (mission == 0 or mission > Plan.campaign.len) return null;
return Plan.campaign[mission - 1].hologram;
}
/// Enriquez's last word before mission `mission` (`tag_format`), written into `buffer`, which holds
/// it for any mission's number.
fn tagLine(buffer: *[tag_line_size]u8, mission: u16) []const u8 {
return std.mem.print(buffer, tag_format, .{mission}) catch unreachable;
}
const tag_line_size = 32;
/// The bank of `resource.hog` whose sound plays as the briefing loads (`0x004E8A18`), at 110,
/// once (`0x00437187`).
pub const wait_bank = "waitloop.fat";
const wait_volume = 0x6E;
/// The bank of the disc's archive the way in's sounds come from (`0x004E89F4`): its first, a
/// sliding door, and its third, the room's chatter (`0x00437356` on). Its second, another door,
/// the briefing leaves unplayed.
pub const sounds_bank = "vrsfx.fat";
const door_sound = 0;
const chatter_sound = 2;
/// The step the music fades by as the briefing loads (`music_fade_out`, `0x004372C7`), and the
/// voices by as the briefing starts (`sound_fade_all`, `0x004373BB`).
const music_fade_step = 15;
const voices_fade_step = 4;
/// The strings of AWAITING CLEARANCE, written `%s %s` (`0x004E8750`) into 80 bytes, centred in
/// white across the door's foot in the large font (`briefing_door_draw`, `0x00437D30`).
const awaiting = 0x292;
const clearance = 0x293;
const awaiting_size = 80;
/// The blocks of the briefing's sprite sets: the room, the palette every shape is drawn with,
/// which the briefing makes VFX's global palette (`palette_to_vfx`, `0x00437282`, `0x0043E771`),
/// and Enriquez's first frame (`0x0043E7E3`).
const room_shape = 0;
const palette_block = 1;
const first_frame = 2;
/// The briefing's sprite set `name` of `resource.hog`, drawn with the palette of its
/// `palette_block`; null where it is left out.
fn readShapes(context: rooms.Context, name: []const u8) ?canvas.Shapes {
return .readWith(context.gpa, context.resources, name, palette_block);
}
/// The timer ticks until Enriquez's first frame (`0x004373E8`), and between her frames, a
/// fifteenth of a second (`0x004DC6D4`): the drawing adds them to the timer's count, and the next
/// frame is due once the count passes the sum, truncated (`__ftol`).
const first_wait = 6;
const frame_ticks: f32 = 6.666;
const frame_wait: u32 = @intFromFloat(frame_ticks);
/// The last word: its line is said as the animation reaches frame 17, and the animation ends at
/// frame 60 (`0x00437C46`, `0x00437C4B`), which is never drawn.
const tag_speaks_at = 0x11;
const tag_last = 0x3C;
/// A segment of Enriquez's animation in the briefing: its first shape past `first_frame`, and its
/// last frame, which it plays up to before the next segment's first.
pub const Segment = struct {
start: u8,
last: u8,
};
/// The segments her animation loops through, starting with the first.
pub const segment_count = 12;
/// The segments of `starts` and `lasts`, two of the executable's tables.
fn segments(starts: [segment_count]u8, lasts: [segment_count]u8) [segment_count]Segment {
var out: [segment_count]Segment = undefined;
for (&out, starts, lasts) |*segment, start, last| segment.* = .{ .start = start, .last = last };
return out;
}
/// The way into the briefing room (`0x00437324` on): movies from the disc's archive over the
/// screen, the door's sound and the room's chatter starting before the last.
pub const Way = struct {
/// The movie before the sounds, where there is one: the Yamato's, the screen by its door going
/// dark.
before: ?[]const u8,
/// The quarter tones the door's sound plays up (`0x00437388`).
door_pitch: i32,
movie: []const u8,
};
/// The briefing room: the Reliant's up to mission 18, the Yamato's after it (`0x00437120`, as
/// `rooms.Carrier.of` decides).
pub const Room = struct {
/// The door, which shows as the briefing loads: `%s.tga` (`0x0043EAF0`) of `0x004E8A28` and
/// `0x004E8A38`.
door: []const u8,
way: Way,
/// The sprite sets of `resource.hog`, the briefing's (`0x004E8A00`, `0x004E8A0C`) and the last
/// word's (`0x004E88E4`, `0x004E88F0`): the room (`room_shape`), and Enriquez's frames from
/// `first_frame`, the briefing's then the frame round the room's screen (`frame_shape`).
shapes: []const u8,
tag_shapes: []const u8,
/// Where Enriquez's frames are drawn (`0x0043E7B9`, `0x0043E7DC`).
enriquez_at: [2]i32,
/// The segments of her animation in the briefing.
segments: [segment_count]Segment,
/// Where the room's screen shows the movie (`0x0043E9BE`, `0x0043E9D8`), and the frame round
/// it, drawn over it (`0x0043EA60` on).
screen_at: [2]i32,
frame_shape: usize,
frame_at: [2]i32,
/// The movies from the room to the loadout's hologram and back, from the disc's archive
/// (`0x00437711`, `0x00437B57`).
hologram: struct { into: []const u8, back: []const u8 },
pub const reliant: Room = .{
.door = "inter\\rbriefdor.tga",
.way = .{ .before = null, .door_pitch = 1, .movie = "rel_c2bre.bik" },
.shapes = "rbrief.spr",
.tag_shapes = "rbrief2.spr",
.enriquez_at = .{ 469, 116 },
// `0x004E5C40`, `0x004E5C58`.
.segments = segments(.{ 0, 99, 0, 0, 0, 0, 137, 0, 0, 61, 0, 0 }, .{ 60, 37, 60, 60, 60, 60, 42, 60, 60, 37, 60, 60 }),
.screen_at = .{ 97, 29 },
.frame_shape = 0xB6,
.frame_at = .{ 89, 17 },
// `0x004E8918`, `0x004E88FC`.
.hologram = .{ .into = "rel_br2holo.bik", .back = "rel_holo2br.bik" },
};
pub const yamato: Room = .{
.door = "inter\\briefdoor.tga",
.way = .{ .before = "amonoff_.bik", .door_pitch = hog_snd.own_pitch, .movie = "briefing room 350.bik" },
.shapes = "brief.spr",
.tag_shapes = "brief2.spr",
.enriquez_at = .{ 1, 122 },
// `0x004E5C10`, `0x004E5C28`.
.segments = segments(.{ 0, 85, 0, 0, 0, 0, 123, 0, 0, 47, 0, 0 }, .{ 46, 37, 46, 46, 46, 46, 42, 46, 46, 37, 46, 46 }),
.screen_at = .{ 200, 42 },
.frame_shape = 0xA8,
.frame_at = .{ 190, 28 },
// `0x004E8928`, `0x004E890C`.
.hologram = .{ .into = "br2hol.bik", .back = "hol2br.bik" },
};
pub fn of(carrier: rooms.Carrier) *const Room {
return switch (carrier) {
.reliant => &reliant,
.yamato => &yamato,
};
}
comptime {
// Enriquez's frames lie between the room and the frame round the screen.
for ([_]Room{ reliant, yamato }) |room| {
for (room.segments) |segment| assert(first_frame + segment.start + segment.last < room.frame_shape);
}
}
};
/// A game mode's briefing in one of the game's briefing rooms, as the StarLancer trial briefs its
/// missions: the room, the movie on the room's screen and Enriquez's last word, in place of the
/// campaign's for the mission's number. The walk into the room is left out, as in the trial,
/// unless the mode asks for it (`walk_in`).
///
/// **Improvement:** the original briefs only the campaign's missions.
pub const Own = struct {
carrier: rooms.Carrier,
/// Whether the player walks into the room as in the campaign, with the door's sound and the
/// room's chatter, before the briefing.
walk_in: bool = false,
/// The movie on the room's screen, a Bink file of the game's or a mod's; none ends the
/// briefing at once, for the loadout.
movie: ?[]const u8 = null,
/// Her last word, a speech file such as `ms_speech\enrbr_tag01.ut`; none leaves her animation
/// silent.
last_word: ?[]const u8 = null,
};
/// Where the briefing stands.
pub const Stage = enum {
/// The door shows: the next pass loads the briefing and sets off the way in.
door,
/// The Yamato's way in has begun (`Way.before`): the next pass plays its sounds and its last
/// movie.
door_open,
/// The way in has played: the next pass starts the briefing.
walked_in,
/// Enriquez at the room's screen (`briefing_state` 1, `0x00520280`).
briefing,
/// The movie into the loadout's hologram has played: the next pass enters the loadout.
to_hologram,
/// The loadout, in the briefing's loop (`0x00437784`).
hologram,
/// The movie back from the hologram has played: the next pass starts the last word.
from_hologram,
/// Her last word (`briefing_state` 2).
tag,
};
/// What a pass reads.
pub const Input = struct {
keyboard: *input.Keyboard,
/// Whether the pointer's right button is down (`interface_pointer_right_down`).
right: bool,
/// The timer's count, a hundred a second (`game_ticks`), which Enriquez's frames step by.
ticks: u32,
/// What the loadout's frame reads (`read_mouse`, the clock), where the caller runs it.
hologram: ?loadout.Frame = null,
};
/// What a pass leads to, which the briefing's caller then runs.
pub const Step = union(enum) {
/// A movie of the way in, from the disc's archive over the screen
/// (`play_bink_movie_no_clear_resourced`, `movie.Kind.over_screen_from_disc`), after which the
/// briefing goes on.
movie: []const u8,
/// The briefing over, and the mission to be flown (`interface_briefing` returns -1, with
/// `0x0051D4B4` clear).
over,
/// The in-game options over the loadout (Escape, `0x004377A4`), whose choice `afterOptions`
/// takes.
options,
/// The briefing left from the loadout's in-game options for the main menu (`briefing_outcome`
/// 2, `0x004378E7` on).
main_menu,
/// The briefing left from the loadout's in-game options with a game loaded, for the rooms from
/// its mission (`briefing_outcome` 1).
loaded,
};
/// The briefing, as `interface_briefing` runs it.
pub const Briefing = struct {
context: rooms.Context,
/// The mission it comes before (`mission_number`), and its briefing room.
mission: u16,
carrier: rooms.Carrier,
room: *const Room,
/// A game mode's room and movies, in place of the campaign's.
own: ?Own = null,
/// What the campaign's records give the mission, in place of the original's by its number
/// (`Plan.campaign`); null for the original's.
plan: ?Plan = null,
/// Whether it starts from the loadout, the way in and the briefing left out (`0x0051DB48`), as
/// the developers' Enter with Control has it.
from_loadout: bool,
stage: Stage = .door,
/// The door (`background_set`).
door: matmanager.Background = .{},
/// `wait_bank`, and the way in's `sounds_bank`.
wait: ?hog_snd.BankFile = null,
sounds: ?hog_snd.BankFile = null,
/// The sprite set of the briefing, then of the last word (`0x00520254`).
shapes: ?canvas.Shapes = null,
/// Enriquez's frame (`0x0051DA94`), the segment of her animation (`0x0051D488`) and its first
/// shape after `first_frame` (`0x0051D4D4`), the timer count after which her next frame is due
/// (`0x0051D4D8`), and the shape the drawing shows (`advance`).
frame: u16 = 0,
segment: u8 = 0,
start: u16 = 0,
next: u32 = 0,
shown: usize = first_frame,
/// The mission's movie on the room's screen, and whether it plays on (`0x0052027C`).
film: rooms.Film = .{},
playing: bool = false,
/// The loudness of the movie's sound, Enriquez's narration, in LUFS, which her last word is
/// matched to (`cbox.Style.Levels`); null for none.
narration: ?f32 = null,
/// The speech file of Enriquez's words (`0x0051D9F0`), whether the last word has been said
/// (`0x0051DA14`), and the speech they play through.
line: []u8 = &.{},
said: bool = false,
speech: cbox.Player = .{},
/// The pointer's right button, which ends a stage only once it has come up since the stage
/// began.
///
/// **Fix:** the game ends a stage while the button is down, so that the press that skipped the
/// way in, still held, ends the briefing at once, and the last word after it.
right: input.FreshPress = .{},
/// Whether the last pass's O asked for a screenshot of the screen, which the caller then saves
/// (`screenshot_save`).
screenshot: bool = false,
/// What the loadout reads and draws with; none leaves the loadout out, the last word following
/// the briefing at once.
hologram_context: ?loadout.Context = null,
/// The loadout, from the briefing's loading to its leaving (`loadout_load`, `loadout_leave`).
hologram: ?*loadout.Loadout = null,
/// What the loadout leaves the mission.
result: ?loadout.Result = null,
/// The briefing before mission `mission`, its door to be shown for a frame (`0x00437010` to
/// `0x0043716F`); from the loadout where `from_loadout` has it; in a game mode's room and with
/// its movies where it gives `own`; and with what the campaign's records give the mission where
/// there is a `plan`.
///
/// **Improvement:** its sounds, the movie's and Enriquez's words among them, ring subtly in a
/// small room of the ship (`mss.Surroundings.inside`). The game plays them dry.
pub fn open(context: rooms.Context, mission: u16, from_loadout: bool, hologram: ?loadout.Context, own: ?Own, plan: ?Plan) Briefing {
context.sound.surround(.inside);
const carrier: rooms.Carrier = if (own) |given| given.carrier else .of(mission);
const room = Room.of(carrier);
var briefing: Briefing = .{ .context = context, .mission = mission, .carrier = carrier, .room = room, .own = own, .plan = plan, .from_loadout = from_loadout, .hologram_context = hologram };
briefing.door.show(context.gpa, context.resources.*, room.door);
return briefing;
}
pub fn close(briefing: *Briefing) void {
const gpa = briefing.context.gpa;
if (briefing.hologram) |hologram| hologram.destroy();
briefing.speech.stop(gpa, briefing.context.sound);
briefing.film.close();
briefing.context.sound.endAll();
briefing.freeLine();
briefing.freeShapes();
briefing.freeBanks();
briefing.door.deinit(gpa);
briefing.* = undefined;
}
fn freeLine(briefing: *Briefing) void {
briefing.context.gpa.free(briefing.line);
briefing.line = &.{};
}
fn freeShapes(briefing: *Briefing) void {
if (briefing.shapes) |*shapes| shapes.deinit(briefing.context.gpa);
briefing.shapes = null;
}
fn freeBanks(briefing: *Briefing) void {
inline for (.{ &briefing.wait, &briefing.sounds }) |bank| {
if (bank.*) |file| file.deinit(briefing.context.gpa);
bank.* = null;
}
}
/// A pass of the loop of the stage the briefing stands at, `in` read: what it leads to, if
/// anything.
pub fn pass(briefing: *Briefing, in: Input) ?Step {
briefing.screenshot = false;
return switch (briefing.stage) {
.door => briefing.load(in.ticks),
.door_open => briefing.walk(),
.walked_in => {
briefing.begin(in.ticks);
return briefing.briefingPass(in);
},
.briefing => briefing.briefingPass(in),
.to_hologram => {
briefing.enterHologram(in);
return briefing.hologramPass(in);
},
.hologram => briefing.hologramPass(in),
.from_hologram => {
briefing.lastWord(in.ticks);
return null;
},
.tag => briefing.tagPass(in),
};
}
/// What the briefing loads once its door has shown (`0x00437172` on): the wait's sound plays,
/// the loadout loads, but for mission 29 (`loadout_load`, `0x00441AA0`), the briefing's sprite
/// set is read, the music fades out and the voices pause, and the way in's sounds are read,
/// before the way in, or from the loadout on, the loadout. The disc that holds the briefing
/// opens, as the rooms have it open.
fn load(briefing: *Briefing, ticks: u32) ?Step {
const context = briefing.context;
const sound = context.sound;
briefing.wait = .read(context.gpa, context.resources, wait_bank, context.codec);
if (briefing.wait) |wait| _ = sound.playInScene(wait.bank, 0, wait_volume, hog_snd.once, hog_snd.centre, hog_snd.own_pitch);
// The loadout loads, but for the campaign's end (`0x0043719F`).
if (briefing.mission != end_mission) if (briefing.hologram_context) |hologram| {
briefing.hologram = loadout.Loadout.load(hologram) catch |err| blk: {
log.warn("the loadout is left out: {s}", .{@errorName(err)});
break :blk null;
};
};
briefing.shapes = readShapes(context, briefing.room.shapes);
briefing.speech.stop(context.gpa, sound);
sound.fadeMusic(music_fade_step, ticks);
sound.pauseAll();
context.disc.open(briefing.carrier.disc());
if (briefing.from_loadout) return briefing.afterBriefing(ticks);
// A game mode's briefing starts in the room, unless the mode asks for the walk in.
if (briefing.own) |own| if (!own.walk_in) {
briefing.stage = .walked_in;
return null;
};
briefing.sounds = context.readBank(sounds_bank);
if (briefing.room.way.before) |before| {
briefing.stage = .door_open;
return .{ .movie = before };
}
return briefing.walk();
}
/// The door's sound and the room's chatter, then the way in's movie (`0x00437356` on).
fn walk(briefing: *Briefing) Step {
const way = briefing.room.way;
if (briefing.sounds) |sounds| {
const sound = briefing.context.sound;
_ = sound.playInScene(sounds.bank, door_sound, hog_snd.loudest, hog_snd.once, hog_snd.centre, way.door_pitch);
_ = sound.playInScene(sounds.bank, chatter_sound, hog_snd.loudest, hog_snd.once, hog_snd.centre, hog_snd.own_pitch);
}
briefing.stage = .walked_in;
return .{ .movie = way.movie };
}
/// Enters the room at the timer count `ticks` (`0x004373BB` on): the voices fade out, Enriquez
/// starts her animation, and the room's screen starts the mission's movie. At the campaign's
/// end, or where the mission's plan has speech, she speaks instead, and a movie in the plan
/// plays without its sound while she does.
fn begin(briefing: *Briefing, ticks: u32) void {
briefing.context.sound.fadeAll(voices_fade_step);
briefing.stage = .briefing;
briefing.right = .{};
briefing.frame = 0;
briefing.segment = 0;
briefing.start = 0;
briefing.next = ticks + first_wait;
if (briefing.spoken()) |words| {
briefing.readWords(words);
briefing.sayWords(words);
// A movie in the plan plays while she speaks, without its sound.
const name = briefing.movie() orelse return;
briefing.film.open(briefing.context, name, .briefing);
const player = &(briefing.film.player orelse return);
briefing.playing = true;
player.bink.setVolume(0);
return;
}
const name = briefing.movie() orelse {
if (briefing.own == null and briefing.plan == null) log.warn("mission {d} has no briefing's movie", .{briefing.mission});
return;
};
briefing.film.open(briefing.context, name, .briefing);
const player = &(briefing.film.player orelse return);
briefing.playing = true;
player.bink.setRoom(.scene);
briefing.narration = player.bink.loudness() catch null;
}
/// A pass of the briefing's loop (`0x004374F0` on), `in` read: O asks for a screenshot
/// (`0x004375F1`); then Escape, the right button, or the movie's end ends it. Where Enriquez
/// speaks, the end of her speech ends it instead, whatever the movie does, and Escape and the
/// right button stop her.
fn briefingPass(briefing: *Briefing, in: Input) ?Step {
briefing.screenshot = in.keyboard.pressed(@backingInt(screenshot_key), .none, true);
const sound = briefing.context.sound;
const speaking = briefing.spoken() != null;
const over = in.keyboard.pressed(input.scan.escape, .none, true) or
(!briefing.playing and !speaking) or
briefing.right.pressed(in.right) or
(speaking and !briefing.speech.playing(sound));
if (!over) return null;
if (speaking) briefing.speech.stop(briefing.context.gpa, sound);
briefing.playing = false;
briefing.film.close();
briefing.freeLine();
sound.endAll();
return briefing.afterBriefing(in.ticks);
}
/// The briefing over (`0x004376D2` on): the voices end, and its sprite set and sounds are let
/// go; then, but at the campaign's end, the loadout and the last word.
fn afterBriefing(briefing: *Briefing, ticks: u32) ?Step {
briefing.context.sound.endAll();
briefing.freeShapes();
briefing.freeBanks();
if (briefing.mission == end_mission) return .over;
if (briefing.hologram == null) {
briefing.lastWord(ticks);
return null;
}
// The movie into the loadout's hologram (`0x00437711`).
briefing.stage = .to_hologram;
return .{ .movie = briefing.room.hologram.into };
}
/// The loadout, while the briefing shows its hologram (`0x00437784` on), which the driver
/// draws its scene of; null otherwise.
pub fn shownHologram(briefing: *const Briefing) ?*loadout.Loadout {
return if (briefing.stage == .hologram) briefing.hologram else null;
}
/// The loadout entered, once the movie into its hologram has played (`0x00437741`).
fn enterHologram(briefing: *Briefing, in: Input) void {
briefing.stage = .hologram;
const hologram = briefing.hologram.?;
const now = if (in.hologram) |frame| frame.now else 0;
hologram.enter(now) catch |err| log.warn("the loadout is left out: {s}", .{@errorName(err)});
}
/// A pass of the briefing's loop as it runs the loadout (`0x00437784` on), `in` read: Escape
/// asks for the in-game options (`0x004377A4`), the loadout's speech paused and every sound
/// ended; otherwise a frame of the loadout (`loadout_frame`), which ends the loop once it has
/// ended, and its O a screenshot.
fn hologramPass(briefing: *Briefing, in: Input) ?Step {
const hologram = briefing.hologram.?;
if (in.keyboard.pressed(input.scan.escape, .none, true)) {
// The scene's objects are let go of, the loadout's speech paused, and every sound ended
// (`0x004377A8` to `0x004377E8`).
hologram.pauseSpeech(true);
briefing.context.sound.endAll();
return .options;
}
const frame = in.hologram orelse return null;
const running = hologram.frame(frame, in.keyboard) catch |err| blk: {
log.warn("the loadout ends: {s}", .{@errorName(err)});
break :blk false;
};
briefing.screenshot = hologram.screenshot;
if (running) return null;
return briefing.leaveHologram();
}
/// The loadout left, its result kept for the mission (`loadout_leave`, `0x00437A5A`), and the
/// movie back from its hologram (`0x00437B57`).
fn leaveHologram(briefing: *Briefing) Step {
const hologram = briefing.hologram.?;
briefing.result = hologram.leave();
hologram.destroy();
briefing.hologram = null;
briefing.stage = .from_hologram;
return .{ .movie = briefing.room.hologram.back };
}
/// What the in-game options' `end` leads to over the loadout (`0x00437841`, `0x004378E7`
/// on): BACK, the loadout again (`loadout_resume`); MAIN MENU, the loadout left and the main
/// menu; a game loaded, the loadout left and the rooms.
pub fn afterOptions(briefing: *Briefing, end: in_game_options.End, now: u32) ?Step {
const hologram = briefing.hologram orelse return null;
switch (end) {
.back => {
hologram.pauseSpeech(false);
hologram.resumeAfterOptions(now) catch |err| log.warn("the loadout's page is left as it was: {s}", .{@errorName(err)});
return null;
},
.main_menu, .quit, .loaded => {
_ = hologram.leave();
hologram.destroy();
briefing.hologram = null;
return if (end == .loaded) .loaded else .main_menu;
},
}
}
/// Starts Enriquez's last word after the loadout, at the timer count `ticks` (`0x00437B80`
/// on): reads its sprite set and her line, and starts her animation from its first frame.
fn lastWord(briefing: *Briefing, ticks: u32) void {
const context = briefing.context;
briefing.shapes = readShapes(context, briefing.room.tag_shapes);
briefing.stage = .tag;
briefing.right = .{};
briefing.start = 0;
briefing.frame = 0;
briefing.said = false;
briefing.next = ticks + first_wait;
var buffer: [tag_line_size]u8 = undefined;
if (briefing.lastWordLine(&buffer)) |line| briefing.readWords(line);
}
/// The movie on the room's screen: the game mode's, the records', or the campaign's by the
/// mission's number (`movieName`).
fn movie(briefing: *const Briefing) ?[]const u8 {
if (briefing.own) |own| return own.movie;
if (briefing.plan) |plan| return plan.hologram;
return movieName(briefing.mission);
}
/// Enriquez's speech: at the campaign's end, `end_debriefing`; otherwise the speech in the
/// mission's plan, if any.
fn spoken(briefing: *const Briefing) ?[]const u8 {
if (briefing.mission == end_mission) return end_debriefing;
if (briefing.own != null) return null;
const plan = briefing.plan orelse return null;
return plan.speech;
}
/// Enriquez's last word: the game mode's, the records', or the campaign's by the mission's
/// number (`tagLine`), written into `buffer`.
fn lastWordLine(briefing: *const Briefing, buffer: *[tag_line_size]u8) ?[]const u8 {
if (briefing.own) |own| return own.last_word;
if (briefing.plan) |plan| return plan.last_word;
return tagLine(buffer, briefing.mission);
}
/// A pass of the last word's loop (`0x00437C50` on), `in` read: the line is said as the
/// animation reaches `tag_speaks_at`; Escape, the right button, or the animation's end ends it,
/// and the line with it.
fn tagPass(briefing: *Briefing, in: Input) ?Step {
if (briefing.frame == tag_speaks_at and !briefing.said) {
briefing.said = true;
var buffer: [tag_line_size]u8 = undefined;
if (briefing.lastWordLine(&buffer)) |line| briefing.sayWords(line);
}
const over = in.keyboard.pressed(input.scan.escape, .none, true) or briefing.frame == tag_last or briefing.right.pressed(in.right);
if (!over) return null;
briefing.speech.stop(briefing.context.gpa, briefing.context.sound);
briefing.freeLine();
briefing.freeShapes();
return .over;
}
/// Enriquez's words, the speech file `name` of `speech_hog` (`hog_read_file`), read; none where
/// it is left out.
fn readWords(briefing: *Briefing, name: []const u8) void {
briefing.freeLine();
briefing.line = briefing.context.readLine(name) orelse &.{};
}
/// The words read, `name`, spoken (`speech_play`): the last word at the loudness of the
/// narration before it, where the style's levels match them.
fn sayWords(briefing: *Briefing, name: []const u8) void {
if (briefing.line.len == 0) return;
rooms.say(briefing.context, &briefing.speech, briefing.line, name, .in_person, briefing.narration);
}
/// What `briefing_draw` moves on (`0x0043E730`), at `now` and the timer count `ticks`: the
/// shape Enriquez shows, and once her next frame is due, her animation, looping through its
/// segments in the briefing and playing to its end in the last word; and the movie's next due
/// frame, ending the movie at its last frame.
pub fn advance(briefing: *Briefing, now: u64, ticks: u32) void {
switch (briefing.stage) {
.door, .door_open, .walked_in, .to_hologram, .hologram, .from_hologram => return,
.briefing, .tag => {},
}
briefing.shown = first_frame + briefing.start + briefing.frame;
if (ticks > briefing.next) {
briefing.next = ticks + frame_wait;
briefing.step();
}
if (briefing.playing and briefing.film.advance(now)) {
// A movie under her words goes round again until she ends the briefing.
if (briefing.stage == .briefing and briefing.spoken() != null) {
briefing.film.loopBack(now);
} else {
briefing.playing = false;
}
}
}
/// Enriquez's next frame: in the briefing, looping through the segments of her animation; in
/// the last word, up to its end.
fn step(briefing: *Briefing) void {
switch (briefing.stage) {
.briefing => {
const ended = briefing.frame == briefing.room.segments[briefing.segment].last;
briefing.frame += 1;
if (!ended) return;
briefing.frame = 0;
briefing.segment = (briefing.segment + 1) % segment_count;
briefing.start = briefing.room.segments[briefing.segment].start;
},
.tag => if (briefing.frame < tag_last) {
briefing.frame += 1;
},
.door, .door_open, .walked_in, .to_hologram, .hologram, .from_hologram => {},
}
}
/// The movie paused while the window is away, as the message pump pauses it (`bink_playing`,
/// `BinkPause`), at `now`.
pub fn pause(briefing: *Briefing, paused: bool, now: u64) void {
if (briefing.film.player) |*player| player.bink.pause(paused, now);
}
/// `briefing_draw` (`0x0043E730`): the room, Enriquez, and in the briefing the movie on the
/// room's screen with the frame round it. Before the briefing, the door (`drawDoor`); in the
/// loadout, its render hook's tooltip (`0x0044B200`) over its hologram.
pub fn draw(briefing: *Briefing, target: canvas.Canvas) canvas.Error!void {
switch (briefing.stage) {
.door, .door_open, .walked_in => return briefing.drawDoor(target),
.hologram => if (briefing.hologram) |hologram| return hologram.draw(target) else return,
.to_hologram, .from_hologram => return,
.briefing, .tag => {},
}
const shapes = if (briefing.shapes) |*loaded| &loaded.art else null;
if (shapes) |art| {
try target.shape(art, room_shape, .{ 0, 0 });
try target.shape(art, briefing.shown, briefing.room.enriquez_at);
}
if (briefing.stage != .briefing or briefing.film.player == null) return;
briefing.film.drawAt(target, briefing.room.screen_at);
if (shapes) |art| try target.shape(art, briefing.room.frame_shape, briefing.room.frame_at);
}
/// `briefing_door_draw` (`0x00437D30`): the door, with AWAITING CLEARANCE across its foot.
fn drawDoor(briefing: *Briefing, target: canvas.Canvas) Allocator.Error!void {
if (briefing.door.image) |*shown| target.fill(shown);
var buffer: [awaiting_size]u8 = undefined;
const words = std.mem.print(&buffer, "{s} {s}", .{
target.strings.string(awaiting) orelse "",
target.strings.string(clearance) orelse "",
}) catch return;
try target.labelText(words);
}
};
test movieName {
try std.testing.expectEqualStrings("new_m01.bik", movieName(1).?);
try std.testing.expectEqualStrings("new_m01.bik", movieName(22).?);
try std.testing.expectEqualStrings("new_m28.bik", movieName(28).?);
try std.testing.expectEqual(null, movieName(0));
try std.testing.expectEqual(null, movieName(end_mission));
}
test "Plan.campaign" {
// Each mission's last word is the one `tagLine` names, and none speaks in place of its movie.
var buffer: [tag_line_size]u8 = undefined;
for (Plan.campaign, 1..) |plan, mission| {
try std.testing.expectEqualStrings(tagLine(&buffer, @intCast(mission)), plan.last_word.?);
try std.testing.expectEqual(null, plan.speech);
}
try std.testing.expectEqualStrings("ms_speech\\enrbr_tag12.ut", Plan.campaign[11].last_word.?);
try std.testing.expectEqualStrings("new_m14.bik", Plan.campaign[13].hologram.?);
}
test Room {
try std.testing.expectEqual(&Room.reliant, Room.of(.of(18)));
try std.testing.expectEqual(&Room.yamato, Room.of(.of(19)));
}
/// The briefing's files for the tests: `tested`'s disc with the movies `movie_names` and the way
/// in's sounds, and `resource.hog` with the wait's sound, and the doors and the sprite sets, which
/// don't parse, and so are left out.
fn testFiles(tested: *rooms.testing.Tested, movie_names: []const []const u8) !void {
const bank = comptime hog_snd.testing.bank(3);
const unread = "x";
try tested.init(movie_names, &.{.{ .name = sounds_bank, .data = &bank }}, &.{
.{ .name = wait_bank, .data = &bank },
.{ .name = "rbriefdor.tga", .data = unread },
.{ .name = "briefdoor.tga", .data = unread },
.{ .name = Room.reliant.shapes, .data = unread },
.{ .name = Room.reliant.tag_shapes, .data = unread },
.{ .name = Room.yamato.shapes, .data = unread },
.{ .name = Room.yamato.tag_shapes, .data = unread },
});
}
/// A speech archive of `lines` in `tested`'s folder, `speech_hog`.
fn testLines(tested: *rooms.testing.Tested, lines: []const hog.Member) !hog.Archive {
const gpa = std.testing.allocator;
try hog.testing.write(gpa, std.testing.io, tested.tmp.dir, "msspeech.hog", lines);
return .open(gpa, std.testing.io, tested.tmp.dir, "msspeech.hog");
}
/// A pass at frame `frame` of fifteen a second, and the drawing's moves.
fn passAt(briefing: *Briefing, keyboard: *input.Keyboard, right: bool, frame: u64) ?Step {
const ticks: u32 = @intCast(frame * 100 / 15);
const step = briefing.pass(.{ .keyboard = keyboard, .right = right, .ticks = ticks });
briefing.advance(frame * std.time.ns_per_s / 15, ticks);
return step;
}
test Briefing {
const gpa = std.testing.allocator;
const line = try cbox.testFile(gpa, 100, 64);
defer gpa.free(line);
var tested: rooms.testing.Tested = undefined;
try testFiles(&tested, &.{ "rel_c2bre.bik", "new_m01.bik" });
defer tested.deinit();
var lines = try testLines(&tested, &.{.{ .name = "enrbr_tag01", .data = line }});
defer lines.close(gpa);
var context = tested.context();
context.lines = &lines;
var keyboard: input.Keyboard = .{};
// The door shows, then the briefing loads, its wait's sound paused as it ends, and the way in
// plays, the door's sound and the room's chatter starting as its movie does.
var briefing: Briefing = .open(context, 1, false, null, null, null);
defer briefing.close();
try std.testing.expectEqual(Stage.door, briefing.stage);
try std.testing.expectEqual(.inside, tested.sound.surroundings);
try std.testing.expectEqualDeep(Step{ .movie = "rel_c2bre.bik" }, passAt(&briefing, &keyboard, false, 0).?);
try std.testing.expect(tested.sound.paused[1]);
try std.testing.expect(tested.sound.voicePlaying(2) and tested.sound.voicePlaying(3));
// In the room, the mission's movie plays on the room's screen while Enriquez loops through her
// animation, a frame every seventh tick.
try std.testing.expectEqual(null, passAt(&briefing, &keyboard, false, 1));
try std.testing.expectEqual(Stage.briefing, briefing.stage);
try std.testing.expect(briefing.playing);
try std.testing.expectEqual(first_frame, briefing.shown);
var frame: u64 = 2;
while (briefing.playing) : (frame += 1) try std.testing.expectEqual(null, passAt(&briefing, &keyboard, false, frame));
try std.testing.expect(briefing.frame > 0);
// After the movie, her last word: its line is said at its seventeenth frame, and it ends at
// its last frame.
try std.testing.expectEqual(null, passAt(&briefing, &keyboard, false, frame));
try std.testing.expectEqual(Stage.tag, briefing.stage);
try std.testing.expectEqual(0, briefing.frame);
try std.testing.expect(briefing.line.len > 0);
var step: ?Step = null;
while (step == null) : (frame += 1) {
step = passAt(&briefing, &keyboard, false, frame);
if (briefing.frame > tag_speaks_at) try std.testing.expect(briefing.said);
}
try std.testing.expectEqual(Step.over, step.?);
try std.testing.expectEqual(tag_last, briefing.frame);
}
test "Escape ends the briefing, and the right button the last word" {
var tested: rooms.testing.Tested = undefined;
try testFiles(&tested, &.{ "rel_c2bre.bik", "new_m02.bik" });
defer tested.deinit();
var keyboard: input.Keyboard = .{};
var briefing: Briefing = .open(tested.context(), 2, false, null, null, null);
defer briefing.close();
_ = passAt(&briefing, &keyboard, false, 0);
_ = passAt(&briefing, &keyboard, false, 1);
try std.testing.expect(briefing.playing);
keyboard.down[input.scan.escape] = true;
try std.testing.expectEqual(null, passAt(&briefing, &keyboard, false, 2));
try std.testing.expectEqual(Stage.tag, briefing.stage);
try std.testing.expectEqual(null, briefing.film.player);
// Without the speech archive, her line is skipped.
try std.testing.expectEqual(0, briefing.line.len);
// The right button, down as the last word begins, ends it once it has come up.
try std.testing.expectEqual(null, passAt(&briefing, &keyboard, true, 3));
try std.testing.expectEqual(null, passAt(&briefing, &keyboard, false, 4));
try std.testing.expectEqual(Step.over, passAt(&briefing, &keyboard, true, 5).?);
}
test "O asks for a screenshot in the briefing, before Escape ends it" {
var tested: rooms.testing.Tested = undefined;
try testFiles(&tested, &.{ "rel_c2bre.bik", "new_m02.bik" });
defer tested.deinit();
var keyboard: input.Keyboard = .{};
var briefing: Briefing = .open(tested.context(), 2, false, null, null, null);
defer briefing.close();
const o = @backingInt(screenshot_key);
// Not at the door.
keyboard.down[o] = true;
_ = passAt(&briefing, &keyboard, false, 0);
try std.testing.expect(!briefing.screenshot);
keyboard.down[o] = false;
keyboard.read();
_ = passAt(&briefing, &keyboard, false, 1);
try std.testing.expectEqual(Stage.briefing, briefing.stage);
// Once a press in the briefing, and in the pass Escape ends it.
keyboard.down[o] = true;
keyboard.down[input.scan.escape] = true;
try std.testing.expectEqual(null, passAt(&briefing, &keyboard, false, 2));
try std.testing.expect(briefing.screenshot);
try std.testing.expectEqual(Stage.tag, briefing.stage);
// Not in the last word.
keyboard.down[o] = false;
keyboard.read();
keyboard.down[o] = true;
_ = passAt(&briefing, &keyboard, false, 3);
try std.testing.expect(!briefing.screenshot);
}
test "the press that skipped the way in, still held, ends no stage" {
var tested: rooms.testing.Tested = undefined;
try testFiles(&tested, &.{ "rel_c2bre.bik", "new_m02.bik" });
defer tested.deinit();
var keyboard: input.Keyboard = .{};
var briefing: Briefing = .open(tested.context(), 2, false, null, null, null);
defer briefing.close();
_ = passAt(&briefing, &keyboard, true, 0);
for (0..3) |_| try std.testing.expectEqual(null, passAt(&briefing, &keyboard, true, 1));
try std.testing.expect(briefing.stage == .briefing and briefing.playing);
// Up, then down again, it ends the briefing; held on, not the last word.
try std.testing.expectEqual(null, passAt(&briefing, &keyboard, false, 1));
try std.testing.expectEqual(null, passAt(&briefing, &keyboard, true, 1));
try std.testing.expectEqual(Stage.tag, briefing.stage);
try std.testing.expectEqual(null, passAt(&briefing, &keyboard, true, 1));
try std.testing.expectEqual(Stage.tag, briefing.stage);
}
test "the Yamato's way in and the frames of Enriquez's animation" {
var tested: rooms.testing.Tested = undefined;
try testFiles(&tested, &.{});
defer tested.deinit();
var keyboard: input.Keyboard = .{};
var briefing: Briefing = .open(tested.context(), 19, false, null, null, null);
defer briefing.close();
try std.testing.expectEqual(&Room.yamato, briefing.room);
try std.testing.expectEqualDeep(Step{ .movie = "amonoff_.bik" }, passAt(&briefing, &keyboard, false, 0).?);
try std.testing.expectEqualDeep(Step{ .movie = "briefing room 350.bik" }, passAt(&briefing, &keyboard, false, 0).?);
// Without its movie, the briefing ends at once.
try std.testing.expectEqual(null, briefing.pass(.{ .keyboard = &keyboard, .right = false, .ticks = 100 }));
try std.testing.expectEqual(Stage.tag, briefing.stage);
// Her animation loops through its segments, playing each to its last frame, then the next
// from its first shape.