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
use core::default::Default;
use xmrs::fixed::units::PitchDelta;
#[derive(Clone, Default)]
pub struct Arpeggio {
/// Half1 / half2 offsets in semitones (integer). XM/MOD/IT/S3M
/// arpeggio nibbles are always integer semitone counts (`0..15`),
/// so `i16` is enough and lets the runtime stay Q-format down to
/// the [`PitchDelta`] return.
offset1: i16,
offset2: i16,
}
/// Per-channel arpeggio state + FT2 historical LUT.
///
/// When `use_ft2_lut` is `true`, the offset to apply at each tick
/// is picked from FT2's historical `arpeggioTab` (reverse-counted
/// to match FT2's `song.tick`). Otherwise the effect falls back to
/// a plain `tick % 3` rotation, which is what Protracker /
/// Scream Tracker 3 / Impulse Tracker do.
///
/// `tempo` is the current song speed (number of ticks per row). The
/// FT2 lookup needs it to convert xmrs' forward-counting tick into
/// FT2's reverse `song.tick`. Callers must keep it in sync via
/// [`Self::set_tempo`] whenever the player's tempo changes; a stale
/// tempo would shift the lookup by one slot and swap base/offset1 at
/// the wrong ticks.
#[derive(Clone, Default)]
pub struct EffectArpeggio {
data: Arpeggio,
use_ft2_lut: bool,
tempo: usize,
tick: u8,
in_progress: bool,
}
impl EffectArpeggio {
pub fn new(use_ft2_lut: bool, tempo: usize) -> Self {
Self {
use_ft2_lut,
tempo,
..Default::default()
}
}
pub fn set_tempo(&mut self, tempo: usize) {
self.tempo = tempo;
}
/// Latch row-time arpeggio offsets and reset the LFO.
pub fn tick0_semitones(&mut self, half1: usize, half2: usize) {
self.data.offset1 = half1.min(i16::MAX as usize) as i16;
self.data.offset2 = half2.min(i16::MAX as usize) as i16;
self.retrigger();
}
/// Current arpeggio offset as a [`PitchDelta`] (semitones,
/// Q8.8). The pitch chain consumes this directly.
pub fn value_pitch_delta(&self) -> PitchDelta {
let semitones = self.current_semitones();
PitchDelta::from_q8_8_i16(semitones.saturating_mul(256))
}
/// Whether an arpeggio has been triggered and not yet
/// consumed back to the base pitch.
pub fn in_progress(&self) -> bool {
self.in_progress
}
/// Advance the LFO by one tick.
pub fn tick(&mut self) {
self.in_progress = true;
self.tick += 1;
}
/// Reset to tick 0.
pub fn retrigger(&mut self) {
self.tick = 0;
self.in_progress = false;
}
fn current_semitones(&self) -> i16 {
let idx = if self.use_ft2_lut {
Self::ft2_arpeggio_index(self.tick, self.tempo)
} else {
// Protracker / ST3 / IT all use the simple three-step
// rotation with no LUT quirks.
self.tick % 3
};
match idx {
1 => self.data.offset1,
2 => self.data.offset2,
_ => 0,
}
}
/// FT2 historical arpeggio lookup.
///
/// In FT2, `song.tick` counts DOWN from `speed` (at row-load) to 1
/// (on the last effect tick of the row). The arpeggio routine
/// looks up `arpeggioTab[song.tick & 31]`. Crucially, arpeggio is
/// NOT run at the row-load tick in FT2 (it's not in the TickZero
/// jump table), so the effective lookup at the row-load tick is
/// implicit: `outPeriod` stays equal to `realPeriod`, which
/// corresponds to the base note (offset 0).
///
/// xmrsplayer uses a forward-counting tick (0 at row-load,
/// `tempo − 1` at the last effect tick). At the row-load tick this
/// helper returns 0 (base note) to match FT2's behaviour; for
/// effect ticks (`1..tempo-1`), the equivalent of FT2's
/// `song.tick` is `tempo − xmrs_tick`.
///
/// A previous formula (`tempo − tick − 1`) was shifted by one and
/// made tick 0 play offset2 for typical tempos — e.g. tempo=6
/// produced `[2, 1, 0, 2, 1, 0]` instead of FT2's
/// `[base, 2, 1, 0, 2, 1]`.
fn ft2_arpeggio_index(tick: u8, tempo: usize) -> u8 {
if tempo == 0 {
// Speed 0 halts the song; no arpeggio progression.
return 0;
}
let tick = tick as usize % tempo;
if tick == 0 {
// Row-load equivalent: FT2 does not execute the arpeggio
// effect at tickZero, so output is the base note.
return 0;
}
let reverse_tick = (tempo - tick) as u8;
match reverse_tick {
0..=15 => reverse_tick % 3,
51 | 54 | 60 | 63 | 72 | 78 | 81 | 93 | 99 | 105 | 108 | 111 | 114 | 117 | 120
| 123 | 126 | 129 | 132 | 135 | 138 | 141 | 144 | 147 | 150 | 153 | 156 | 159 | 165
| 168 | 171 | 174 | 177 | 180 | 183 | 186 | 189 | 192 | 195 | 198 | 201 | 204 | 207
| 210 | 216 | 219 | 222 | 225 | 228 | 231 | 234 | 237 | 240 | 243 => 0,
_ => 2,
}
}
}