Formal specification, derived by transcription from the ProTracker 1.0C and 1.1B playroutine sources. Nothing here is derived from other articles or inferred from a third-party player; every claim is traceable to a line of 68000 assembly cited inline. Where the sources are silent or damaged, this document says so explicitly.
Audience: someone implementing EF $x — either half of it — in a software mixer.
| File | What it is |
|---|---|
1.0c/ProTracker_v1.0c.s |
Protracker V1.0C Playroutine, Lars "Zap" Hamre / Amiga Freelancers 1990, "Optimized & Recoded For Seka By The Master Of Aero". VBlank replayer. Contains Funk Repeat. |
1.1a/pt-play.S |
Protracker V1.1A Playroutine, 1990. Damaged — see §6. The funk routine itself did not survive. |
1.1b/ProTracker_v1.1b.s |
Protracker V1.1B Playroutine, 1991, VBlank version 2. Contains Invert Loop. |
1.1b/ProTracker_v1.1b_CIA.s |
Same, CIA-timed. Funk block identical to the VBlank version, instruction for instruction. |
1.1b/ProTracker_v1.1b_FIX.s |
The "100% FIXED" third-party respin. Funk block identical apart from cosmetics: it branches to mt_Return2 where the others say mt_Return, and writes the MOVEM register list D1/A0 rather than A0/D1 (same assembled mask). |
1.0c/PT.help, 1.1a/PT.help |
The in-program help file. Byte-identical in both releases (md5 b24d6b77…), and both still describe Funk Repeat. |
1.1a/PTEffects.txt.pp |
PowerPacker-compressed (PP20) effect list shipped with 1.1A. |
1.1a/PT.ReadMe!.pp |
PowerPacker-compressed 1.1A release notes, dated XX/12-1990. |
2.3d/ProTracker_v2.3a.s, …_CIA.s |
Protracker V2.3A Playroutine. Funk block identical to 1.1B instruction for instruction (the two variants differ only in the mt_Return / mt_Return2 label name), and the "Changes from V1.0C playroutine" header is the same three-item list. |
2.3d/PT.HELP |
The real ProTracker 2.3D in-program help file. Uncompressed; readable with strings. |
2.3d/PT2.3D |
The real ProTracker 2.3D executable — an uncrunched AmigaDOS hunk file, so its code and strings can be read directly. Per 2.3d/PT2.3D.DOC it is Detron's 1994 AGA fix of Crayon's 2.3A, not a Hamre release. |
Cross-check used to confirm the reading, not to derive it:
pt2-clone/src/pt2_replayer.c (updateFunk, lines 278–297). Its
release/effects.txt is a reflowed secondary copy of 2.3d/PT.HELP and is
missing a line — cite the real help file instead (§5.3).
The two .pp files decompress with any PowerPacker PP20 decruncher; the
quoted text below was obtained that way.
EF $x is one effect slot that held two entirely different algorithms
across ProTracker 1.x. They share a name in the source (mt_FunkIt,
mt_UpdateFunk, mt_FunkTable), a channel state layout, a speed nibble, and a
tick-accumulator clock. They share nothing about what actually happens when
the clock ticks.
- 1.0C, "Funk Repeat". Steps the channel's loop start pointer forward by
one whole loop length, writing it straight into Paula's
AUDxLC. The loop window slides through the body of the sample, one block at a time, and wraps back to the real loop start when the next block would fall off the end. The sample data is never touched. - 1.1B, "Invert Loop". Steps a byte cursor forward by one byte inside the loop region and one's-complements the sample byte it lands on, in place. The loop is progressively chewed into a squarewave. The sample data is permanently modified in Chip RAM.
Both are driven by the same clock, so the $x nibble means the same thing in
both: a step rate. Only the step differs.
Everything in this section is identical in 1.0C and 1.1B. Verified by transcription of both files.
The per-channel structure (mt_chanXtemp) is unchanged between the two
releases; the field offsets below hold in 1.0C, 1.1A and 1.1B alike.
| Offset | Size | Name | Role in EF $x |
|---|---|---|---|
| 4 | L | n_start |
Sample data base address. Read by 1.0C only. |
| 10 | L | n_loopstart |
n_start + 2 × repeatOffsetWords. The real loop start. |
| 14 | W | n_replen |
Loop length in words. Byte length is 2 × n_replen. |
| 31 | B | n_glissfunk |
High nibble = funk speed $x. Low nibble is E3 $x glissando — a different effect sharing the same byte. |
| 35 | B | n_funkoffset |
The step accumulator, 8-bit. |
| 36 | L | n_wavestart |
The moving pointer. Meaning differs per version (see §3, §4). |
| 40 | W | n_reallength |
Full sample length in words. Read by 1.0C only; in 1.1B it is still written on every instrument change (ProTracker_v1.1b.s:176) but never read again — vestigial. |
Initialisation. n_loopstart and n_wavestart are both set, to the same
value, in mt_PlayVoice's instrument-setup block (1.0C lines 182 and 196; 1.1B
lines 186 and 200) — once for the looped case and once for the non-looped case.
That block runs only when the pattern cell carries a non-zero sample number,
not on every note. A bare note with no sample number leaves n_wavestart
exactly where funk left it.
n_funkoffset is never initialised, never cleared on a note, never cleared
by EF $0. In the whole of either playroutine it is written in exactly three
places, all inside mt_UpdateFunk: the accumulate, the bit test, and the
clear-on-overflow. Its phase therefore survives note triggers, instrument
changes, effect changes and pattern jumps.
Identical in both versions apart from a branch-label name:
mt_FunkIt:
TST.B mt_counter ; tick 0 only
BNE.L mt_Return2
MOVE.B n_cmdlo(A6),D0
AND.B #$0F,D0
LSL.B #4,D0 ; x -> high nibble
AND.B #$0F,n_glissfunk(A6)
OR.B D0,n_glissfunk(A6) ; merge, preserving E3x in the low nibble
TST.B D0
BEQ.L mt_Return2 ; EF0 = off, and stop here
mt_UpdateFunk: ; <-- FALL THROUGH, not a call
...Four things matter here:
EF $xlatches on tick 0 only. On later ticks of the same row the E-command dispatcher still reachesmt_FunkIt, and it returns immediately.- The speed is sticky. It lives in
n_glissfunkand persists per channel across rows, patterns and notes until anotherEF $xoverwrites it. There is no "current row only" behaviour. EF $0disables and returns without stepping — but does not clearn_funkoffset, so re-arming later resumes on the old phase.- A non-zero
EF $xfalls through intomt_UpdateFunk, which the caller (mt_CheckMoreEfx) has already run this tick. See §2.4.
E3 $x (mt_SetGlissControl) writes the low nibble of the same byte with
AND.B #$F0 / OR.B. The two effects are independent and must not clobber each
other. An implementation that stores funk speed in its own field must make sure
E3 $x does not reset it, and vice versa.
mt_FunkTable: dc.b 0,5,6,7,8,10,11,13,16,19,22,26,32,43,64,128Byte-identical in 1.0C (line 1024) and 1.1B (line 1027).
MOVEQ #0,D0
MOVE.B n_glissfunk(A6),D0
LSR.B #4,D0 ; D0 = speed nibble x
BEQ.s mt_funkend ; x = 0 -> nothing
LEA mt_FunkTable(PC),A0
MOVE.B (A0,D0.W),D0 ; D0 = FunkTable[x]
ADD.B D0,n_funkoffset(A6) ; 8-bit add, wraps
BTST #7,n_funkoffset(A6)
BEQ.s mt_funkend ; bit 7 clear -> no step this tick
CLR.B n_funkoffset(A6) ; RESET TO ZERO, not "subtract 128"
... step ...The accumulator is reset to zero, not decremented by 128. The remainder is
discarded. Because of that the accumulator always starts a cycle at 0 and the
step period is exactly ceil(128 / FunkTable[x]) ticks, with no jitter —
the table was evidently chosen to make that come out to a clean ladder:
$x |
FunkTable[x] |
Step period (ticks) |
|---|---|---|
0 |
0 | (off) |
1 |
5 | 26 |
2 |
6 | 22 |
3 |
7 | 19 |
4 |
8 | 16 |
5 |
10 | 13 |
6 |
11 | 12 |
7 |
13 | 10 |
8 |
16 | 8 |
9 |
19 | 7 |
A |
22 | 6 |
B |
26 | 5 |
C |
32 | 4 |
D |
43 | 3 |
E |
64 | 2 |
F |
128 | 1 |
An implementation may use the period column directly only if it also
guarantees the same phase behaviour: the counter is not reset on a note, not
reset when the speed changes, and not reset by EF $0. Changing $x
mid-flight leaves the accumulator wherever it was, so the first step after a
speed change lands at an irregular interval. Emulating that faithfully is easier
with the accumulator than with a period counter.
Note that a "tick" here is a replayer tick, i.e. a call of mt_music — at
VBlank timing 50 Hz, at CIA timing BPM × 2 / 5 Hz. The step rate does not
scale with the song's speed (F $xx) setting; a slower song simply spends
more ticks per row, so funk steps more times per row.
mt_UpdateFunk is called unconditionally from the top of both tick paths,
before any effect dispatch:
mt_CheckEfx— ticks 1…speed−1 (1.0C line 338, 1.1B line 342)mt_CheckMoreEfx— tick 0 (1.0C line 777, 1.1B line 785)
so it runs for every channel on every tick, whether or not the channel has a note, an instrument, or any effect at all. A channel with a non-zero funk speed keeps funking through empty rows.
There are exactly two places where it runs twice in one tick for one channel, both on tick 0, and both are faithful behaviour rather than accidents to be cleaned up:
EF $xwithx ≠ 0.mt_CheckMoreEfxcallsmt_UpdateFunk(using the previous speed), then dispatches tomt_FunkIt, which installs the new speed and falls through intomt_UpdateFunkagain. So the row on which funk is switched on, or its speed changed, gets a double accumulator advance.9 $xx(sample offset).mt_SetRegsdoesBSR mt_CheckMoreEfxand then branches tomt_SetPeriod, which itself ends inBRA mt_CheckMoreEfx(1.0C lines 215–218 and 268; 1.1B lines 219–222 and 272). Two entries, twomt_UpdateFunkcalls. This one has nothing to do with funk and applies whatever the funk speed is.
During a pattern delay (EE $x), repeated rows go through
mt_NoNewAllChannels → mt_CheckEfx, so funk keeps stepping once per tick and
mt_SetDMA is skipped entirely — which matters for 1.0C (§3.4).
1.0c/ProTracker_v1.0c.s:986–1022, after the shared preamble of §2.3:
MOVE.L n_start(A6),D1
MOVEQ #0,D2
MOVE.W n_reallength(A6),D2
LSL.W #1,D2 ; NOTE: word shift
ADD.L D2,D1 ; D1 = start + 2*reallength (end of sample)
MOVE.W n_replen(A6),D2
LSL.L #1,D2
SUB.L D2,D1 ; D1 = end - 2*replen (highest legal block start)
MOVE.L n_wavestart(A6),D2
MOVEQ #0,D0
MOVE.W n_replen(A6),D0
LSL.L #1,D0
ADD.L D0,D2 ; D2 = wavestart + 2*replen (candidate)
CMP.L D1,D2
BLS.s mt_funkok ; keep candidate if candidate <= limit
MOVE.L n_loopstart(A6),D2 ; else snap back to the real loop start
mt_funkok:
MOVE.L D2,n_wavestart(A6)
MOVE.L D2,(A5) ; A5 = $DFF0A0 + 16*ch -> AUDxLC
mt_funkend:n_wavestart is the loop start pointer currently installed in Paula. Each
step advances it by one full loop length (2 × n_replen bytes) and writes it to
the channel's AUDxLC register. AUDxLEN is not touched — it still holds
n_replen from mt_SetDMA — so the loop keeps its length and only moves.
The walk visits
n_loopstart + k × (2 × n_replen) for k = 0, 1, 2, … K
where K = floor((n_reallength − repeatOffset − n_replen) / n_replen), then
resets to k = 0. The comparison is BLS (unsigned ≤) against
n_start + 2×n_reallength − 2×n_replen, so the last block visited is the last
one that fits entirely inside the sample.
Nothing is written to sample data. Stop the effect and the sound is unchanged.
funkRepeatStep(ch):
limit = ch.start + 2*ch.reallength - 2*ch.replen # byte addresses
candidate = ch.wavestart + 2*ch.replen
ch.wavestart = (candidate <= limit) ? candidate : ch.loopstart
paula[ch].AUDxLC = ch.wavestart # takes effect at the next loop wrap
Comparisons are unsigned on 32-bit values.
On every row tick that is not a pattern-delay repeat, after all four channels
have run mt_PlayVoice, the replayer executes mt_SetDMA, whose tail does this
unconditionally for all four channels, whether or not they took a new note
(1.0C lines 281–293):
MOVE.L n_loopstart(A6),$A0(A5) ; AUDxLC := n_loopstart
MOVE.W n_replen(A6),$A4(A5) ; AUDxLEN := n_replenSo Paula's loop pointer is forced back to the real loop start at the top of
every row, wiping out whatever funk installed during the previous row —
including the funk step that fired on tick 0, because mt_CheckMoreEfx runs
inside mt_PlayVoice and therefore before mt_SetDMA.
The software cursor n_wavestart is not reset by this and keeps marching. The
net audible behaviour of 1.0C Funk Repeat is therefore:
Every row, the loop snaps back to the sample's real loop point. Then, on the first funk step that fires on tick ≥ 1, it jumps to wherever the free-running cursor has got to, and continues stepping for the rest of the row.
At EF $F (a step every tick) with speed 6, that is: the real loop start on
tick 0, then five successive cursor positions on ticks 1–5.
At EF $1 (a step every 26 ticks) most rows contain no step at all and the
effect is a rare single jump. Pattern-delayed rows skip mt_SetDMA and so are
the only rows where the walk runs uninterrupted.
Whether the editor's internal playroutine in the PT1.0 binary behaved
identically cannot be checked from this material — that executable is crunched.
The replayer above is the reference the format was documented against, and it is
what every module distributed as "plays in PT1.0C" was actually verified with.
- Loop sits at the tail of the sample (
repeatOffset + replen == reallength, the common "loop the last N bytes" case):limit == n_loopstart, the candidate always exceeds it, and every step resets ton_loopstart. Funk Repeat is audibly inert. This is why the help file insists on a short loop. n_replen == 0: candidate equalswavestart, the comparison passes, and the same pointer is rewritten forever. Inert, no crash. (ProTracker itself never writesreplen = 0; the minimum is 1 word.)- Non-looping sample (
repeat = 0,replen = 1):mt_NoLoopsetsn_loopstart = n_start, and the walk marches a 2-byte window through the whole sample in 2-byte steps. Paula is looping two bytes, so what you hear is a squarewave at half the channel's sample rate whose level and offset jump on every step — a high buzz that changes character as it scans, not a musical scan of the material. Both replayers'mt_initzeroes the first longword of every sample (CLR.L (A2)), so thek = 0position is silence. - Latent word-overflow.
LSL.W #1,D2shifts the word, so2 × n_reallengthis computed modulo 65536. Forn_reallength ≥ $8000the limit is wrong and the walk is pinned ton_loopstartor truncated. In practice this never fires: ProTracker's own maximum sample length is 65534 bytes =$7FFFwords, exactly the largest value for which the shift is exact. It can only bite on a module built by some other tool with an over-long sample. Reproduce it only if you are chasing bit-exactness on malformed input.
n_start = $1000, n_reallength = $40 words (128 bytes),
repeat offset $08 words → n_loopstart = $1010, n_replen = $08 words
(16 bytes). limit = $1000 + 128 − 16 = $1070.
n_wavestart after each successive step:
$1020 $1030 $1040 $1050 $1060 $1070 $1010 $1020 $1030 $1040 …
Seven distinct positions, cycle length 7 steps.
Loop at the tail — n_start = $3000, n_reallength = $10w, repeat $08w,
n_replen = $08w: n_loopstart = $3010, limit = $3010, and every step yields
$3010. Inert, as described above.
1.1b/ProTracker_v1.1b.s:994–1023, after the shared preamble of §2.3:
MOVE.L n_loopstart(A6),D0
MOVEQ #0,D1
MOVE.W n_replen(A6),D1
ADD.L D1,D0
ADD.L D1,D0 ; D0 = loopstart + 2*replen (one past loop end)
MOVE.L n_wavestart(A6),A0
ADDQ.L #1,A0 ; pre-increment by ONE BYTE
CMP.L D0,A0
BLO.s mt_funkok ; keep if A0 < loop end
MOVE.L n_loopstart(A6),A0 ; else wrap to loop start
mt_funkok:
MOVE.L A0,n_wavestart(A6)
MOVEQ #-1,D0
SUB.B (A0),D0 ; D0.b = $FF - sample[A0] == ~sample[A0]
MOVE.B D0,(A0) ; write it back
mt_funkend:n_wavestart is a read/write cursor into sample data, not a Paula pointer.
Nothing is written to any hardware register.
Each step:
- advances the cursor by one byte;
- wraps it to
n_loopstartif it reachedn_loopstart + 2 × n_replen; - replaces the sample byte at the cursor with its one's complement.
$FF − s and ~s are the same operation on a byte. In signed terms
(ProTracker samples are signed 8-bit) it is s → −s − 1: a reflection about
−0.5, not a negation. That asymmetry is why it adds a DC-ish buzz rather than
just flipping polarity.
Because inversion is an involution, a byte that has been hit twice is back to
its original value. A full traversal of the loop inverts every byte; a second
full traversal restores every byte. The cycle length is therefore
2 × (2 × n_replen) steps, and the loop's timbre sweeps from clean, through
progressively more inverted, to fully inverted (which is also clean, just
reflected), and back.
The modification is destructive and shared. It writes into the module's sample data in Chip RAM. Consequences an implementation must reproduce or consciously decide not to:
- The change persists after the effect stops.
EF $0freezes the damage, it does not undo it. - Every channel playing that instrument hears it, not just the channel running the effect.
- Two channels funking the same instrument fight over the same bytes.
- In the tracker, the damage is visible in the sample editor and is saved with the module.
Ctrl-Z— "Restore Effects" in ProTracker's key list — stops the funking but does not restore the sample. In the real 2.3D executable the handler walks the four 44-byte channel structures and clears four fields in each (CLR.B $12(A0),$1E(A0),$1F(A0),$22(A0)—n_finetune,n_wavecontrol,n_glissfunk,n_loopcount), then prints its status message.pt2-cloneclears exactly the same four fields by name, so the two agree independently.- Trivia, since it gets misquoted: the message stored in the 2.3D binary is lowercase,
efx restored !. It appears on screen in capitals because ProTracker's 8×5 font has no lowercase glyphs at all —aandAindex byte-identical bitmaps. Every status string in the executable is stored lowercase (editskip = 0,command stored!,pos restored !) for the same reason.
- Trivia, since it gets misquoted: the message stored in the 2.3D binary is lowercase,
invertLoopStep(ch):
loopEnd = ch.loopstart + 2*ch.replen
ch.wavestart = (ch.wavestart + 1 < loopEnd) ? ch.wavestart + 1 : ch.loopstart
sampleData[ch.wavestart] = ~sampleData[ch.wavestart] # one's complement
The pre-increment matters: after a fresh instrument trigger (wavestart = loopstart) the first byte inverted is loopstart + 1, and loopstart
itself is not touched until the first wrap.
n_replen == 1(the 2-byte dummy loop of a non-looping sample):loopEnd = loopstart + 2, and the cursor alternatesloopstart+1, loopstart, loopstart+1, …. For a non-looping sample those two bytes are inside the longwordmt_initzeroed, so they toggle between$00and$FF— audibly a buzz, not the silence the zeroing was there to provide.n_replen == 0:loopEnd == loopstart, the test always fails, and the cursor sticks atloopstart, toggling one byte forever.- Null pointers. The 1.1B replayer has no guard.
pt2-cloneadds one (if (ch->n_loopstart != NULL && ch->n_wavestart != NULL), commented "ProTracker bugfix") because in the clone an unset channel can hold a null sample pointer. A from-scratch implementation with indices rather than pointers has no equivalent hazard; a pointer-based one should add the guard. - The
mt_SetDMAloop-pointer rewrite of §3.4 is still there in 1.1B (lines 285–297) and is now entirely harmless, because Invert Loop never writesAUDxLC.
n_loopstart = 0, n_replen = 2 words (4 bytes), sample data
10 20 30 40 50 60:
| Step | Cursor | Data after |
|---|---|---|
| 1 | 1 | 10 DF 30 40 50 60 |
| 2 | 2 | 10 DF CF 40 50 60 |
| 3 | 3 | 10 DF CF BF 50 60 |
| 4 | 0 | EF DF CF BF 50 60 |
| 5 | 1 | EF 20 CF BF 50 60 |
| 6 | 2 | EF 20 30 BF 50 60 |
| 7 | 3 | EF 20 30 40 50 60 |
| 8 | 0 | 10 20 30 40 50 60 |
Back to the original after 8 steps = 2 × 4 bytes, as predicted. Bytes 4 and 5
are outside the loop and are never touched.
| 1.0C Funk Repeat | 1.1B Invert Loop | |
|---|---|---|
Meaning of n_wavestart |
Paula loop-start pointer | byte cursor into sample data |
| Step size | 2 × n_replen bytes (one whole loop) |
1 byte |
| Range walked | n_loopstart … sampleEnd − 2×n_replen |
n_loopstart … n_loopstart + 2×n_replen − 1 |
| Wrap test | candidate ≤ limit keeps (BLS) |
cursor < loopEnd keeps (BLO) |
| Touches sample data | no | yes — one's complement, in place |
| Touches hardware | yes — writes AUDxLC |
no |
Reads n_start |
yes | no |
Reads n_reallength |
yes | no (field written but dead) |
| Reversible | yes, automatically | only by inverting the same byte again |
| Affects other channels | no | yes, via shared sample data |
| Survives the effect ending | no | yes |
Fought by mt_SetDMA |
yes, once per row (§3.4) | no |
| Cycle length | K + 1 steps, K = floor((reallength − repeat − replen) / replen) |
4 × n_replen steps |
| Sound | the loop window scans through the sample — a coarse granular/loop-scanner sweep | the loop degrades into a buzzing squarewave and back |
mt_FunkIt(theEF $xhandler): unchanged, instruction for instruction.mt_FunkTable: unchanged, byte for byte.- The accumulator preamble of
mt_UpdateFunk(speed fetch, table lookup, 8-bit accumulate,BTST #7, clear): unchanged, instruction for instruction. - The step body: completely rewritten.
- Register save list:
MOVEM.L A0/D1-D2,-(SP)→MOVEM.L A0/D1,-(SP). The new body needs one scratch register fewer. - The channel struct is unchanged —
n_reallengthwas left in place and is still populated on every instrument change, it is simply never read again.
That last point is the strongest internal evidence that this was a targeted replacement of one routine rather than a redesign: the surrounding code was not touched at all, not even to drop a field that had become dead.
And it stayed that way. The 2.3A playroutine (2.3d/ProTracker_v2.3a.s) carries
the funk block unchanged from 1.1B, instruction for instruction, and still writes
n_reallength on every instrument change (line 174) without ever reading it.
Invert Loop was therefore stable across the whole of ProTracker 1.1B–2.3A; the
dead field outlived it by years and is gone only in pt2-clone's reconstructed
channel struct.
The 1.0C / 1.1A help file (PT.help, byte-identical between the two releases)
says:
Cmd EF. Funk Repeat [Speed:$0-$F] --------------------------------- Usage: $EF + funkspeed This command will need a short loop ($10,20,40,80 etc. bytes) to work. It will move the loop through the whole length of the sample. Sounds like shit, really, but who cares? Example: C-300EF8 Set Funkspeed to 8. To turn off the funking, set the speed to 0, or press ctrl + Z.
"It will move the loop through the whole length of the sample" is an exact
description of §3.2. The 1.1A PTEffects.txt one-liner is more exact still:
EF- FunkRepeat EFx : speed (add replen to repeat)
"add replen to repeat" is the algorithm — the repeat point plus one loop length, per step.
ProTracker 2.3D's own PT.HELP keeps the same paragraph, swaps the middle
sentence, and adds one of its own:
Cmd EF. Invert Loop [Speed:$0-$F] --------------------------------- Usage: $EF + Invertspeed This command will need a short loop ($10,20,40,80 etc. bytes) to work. It will invert the loop byte by byte. Sounds better than funkrepeat... Example: C-300EF8 Set invspeed to 8. To turn off the inverting, set invspeed to 0, or press ctrl + Z.
"Sounds better than funkrepeat..." is the authors' own acknowledgement, inside
the shipping product, that EF $x used to be a different effect. Note that
pt2-clone's release/effects.txt is a reflowed variant of this text that
drops that line; quote 2.3d/PT.HELP instead. 2.3D's help is also in sync
with its own dispatcher (E5- Set Finetune, E6- Patternloop), unlike 1.1A's
PTEffects.txt — see §6.
The "short loop" advice was inherited verbatim even though the reason for it changed completely: in 1.0C a short loop is needed so there is room to walk; in 1.1B it is needed so the inversion cycle is short enough to hear.
Established beyond doubt: 1.0C has Funk Repeat; 1.1B has Invert Loop.
1.1A cannot be settled from the obtainable materials. 1.1a/pt-play.S
is damaged: it is intact from the header through mt_Vibrato (line ~581), at
which point it jumps mid-word into the middle of the period table, runs to
;/* End of File */, and is then followed by ~100 kB of repeating binary
garbage. The block containing mt_UpdateFunk did not survive. grep -i funk
over the whole file returns four hits: the two EQUs (n_glissfunk,
n_funkoffset) and the two BSR mt_UpdateFunk call sites. The routine itself
is gone. The pt1.1a executable is a crunched Amiga hunk file and was not
unpacked for this document.
The documentary evidence all points the same way — that 1.1A still had Funk Repeat, and Invert Loop arrived in 1.1B:
- The 1.1A playroutine's own "Changes from V1.0C playroutine" header lists one item: the Noisetracker-compatible vibrato depth.
- The 1.1B playroutine's "Changes from V1.0C playroutine" header lists three: that same vibrato item, plus "Funk Repeat changed to Invert Loop", plus "Period set back earlier when stopping an effect". The list is cumulative from the same baseline, so the two extra items landed between the two releases.
- 1.1A ships the 1.0C
PT.helpbyte-identical, still documenting Funk Repeat. - 1.1A's own
PTEffects.txtsaysEF- FunkRepeat … (add replen to repeat). - The 1.1A release notes (
PT.ReadMe!, dated XX/12-1990) contain a long changelog that mentions the vibrato-depth change explicitly and says nothing aboutEF $x.
Independently of all five, ProTracker 2.3D's PT.HELP says of Invert Loop
"Sounds better than funkrepeat..." — the product itself, years later, still
naming the thing it replaced (§5.3).
The one caveat on (3) and (4): 1.1A's PTEffects.txt also lists
E5- Set Loop / E6- Jump to Loop, whereas the 1.1A playroutine's E-dispatcher
actually has E5 = mt_SetFineTune and E6 = mt_JumpLoop. So that file was
already carrying at least one stale line, and is weak evidence on its own. Taken
with (1) and (2), which come from the source itself, the attribution is safe
enough to build on — but if a clean 1.1A source or an unpacked pt1.1a ever
turns up, this is the paragraph to check.
Practical consequence for a player: a .mod cannot tell you which one its
author meant. There is no version field, no flag, nothing. The scene settled on
Invert Loop — every player from ProTracker 2.x onward implements only that, and
EF $x in a MOD today means Invert Loop by default. Funk Repeat is a
1990-only dialect. If both are to be offered, it must be a user-selectable
compatibility mode or a separate effect, never an automatic guess.
If you are emulating Paula properly, the mapping is direct:
- Keep the loop length fixed at
2 × n_replenfor the whole note. Funk never changes it. - A funk step sets the channel's pending loop start. Paula reloads its internal pointer from
AUDxLCat the loop wrap, not immediately — so a step taken while the voice is mid-block does not audibly do anything until that block finishes. Implement it as "next wrap uses the new start", not "seek now". - If the voice is still in its one-shot pre-loop region, the step has no audible effect at all until playback first reaches the loop.
- Reproduce §3.4: at the start of every row (except pattern-delay repeats) reset the installed loop start to
n_loopstartfor every channel, while leaving the funk cursor alone.
A modern mixer usually will not want to write into the sample pool, and in this
codebase must not — S $Fx (Taud's Invert Loop) is already implemented as a
per-instrument XOR mask laid over the loop region rather than as a write, and
Funk Repeat should be its sibling, not an exception.
Funk Repeat is easier to do non-destructively than Invert Loop, because it never modifies data in the first place. It is a read-address remap:
loopLen = 2 * replen # constant for the whole note
step(voice): # all offsets are bytes from sample[0]
cand = voice.funkWindowBase + loopLen
voice.funkWindowBase = (cand <= sampleLengthBytes - loopLen) ? cand : loopStart
readAddress(voice, pos): # pos = loop-relative, 0 .. loopLen-1
return voice.funkWindowBase + pos
funkWindowBase is §3.3's cursor expressed as an offset rather than an
address — the n_start term drops out on both sides of the comparison — and
loopLen never changes. Everything else — interpolation taps, loop wrap arithmetic, the
sounding pitch — is unchanged.
Three things to get right:
- Resolve against the voice's active loop, not the instrument record's base loop, if your format lets a patch replace loop points under a sounding voice. The existing
S $Fximplementation already had to be fixed for exactly this. - The window may only move at a loop wrap. Moving it between two output samples mid-block is a step discontinuity — a click, once per step, up to once per tick at
EF $F. Either defer to the wrap (faithful) or crossfade the two mappings over a short window the way the sample-modification effects already do. Do not just teleport. - State scope. The cursor is per-voice and follows the note; the speed and the accumulator are per-channel and outlive the note. Nothing here is per-instrument, unlike Invert Loop's mask — Funk Repeat has no shared state at all, so two channels funking the same instrument simply do not interact.
- Storing the funk speed as the table value rather than the nibble is fine and saves a lookup, but then
EF $0must be recognised before the lookup, and any UI or file format that round-trips the value must agree on which one it is storing. E3 $xandEF $xshare a byte in the original. If you split them into two fields, check every reset path resets the right one.- Resetting the accumulator on note-on feels correct and is wrong. ProTracker never does it. Same for resetting it on
EF $0. - Resetting the cursor on every note trigger is also a divergence: the original resets
n_wavestartonly when the row carried a sample number. Whether to follow that or the friendlier "reset on trigger" is a judgement call, but make it a conscious one. - Funk Repeat on a sample whose loop is at the very end is silent by design (§3.5). If your test module does that, you are not looking at a bug.
8.1 Accumulator. Starting from n_funkoffset = 0, the tick numbers on which
the first three steps fire, per $x (1-based, counting mt_UpdateFunk calls):
$x |
fires on ticks |
|---|---|
1 |
26, 52, 78 |
2 |
22, 44, 66 |
3 |
19, 38, 57 |
4 |
16, 32, 48 |
5 |
13, 26, 39 |
6 |
12, 24, 36 |
7 |
10, 20, 30 |
8 |
8, 16, 24 |
9 |
7, 14, 21 |
A |
6, 12, 18 |
B |
5, 10, 15 |
C |
4, 8, 12 |
D |
3, 6, 9 |
E |
2, 4, 6 |
F |
1, 2, 3 |
8.2 Funk Repeat walk. start=$1000, reallength=$40w, repeat=$08w,
replen=$08w ⇒ loopstart=$1010, limit=$1070. Successive n_wavestart:
$1020 $1030 $1040 $1050 $1060 $1070 $1010 $1020 …
start=$2000, reallength=$20w, repeat=0, replen=$04w ⇒ loopstart=$2000,
limit=$2038. Successive n_wavestart:
$2008 $2010 $2018 $2020 $2028 $2030 $2038 $2000 …
start=$3000, reallength=$10w, repeat=$08w, replen=$08w ⇒
loopstart=$3010, limit=$3010. Successive n_wavestart:
$3010 $3010 $3010 … (inert).
8.3 Invert Loop. See the table in §4.5. An implementation is correct if the
data returns to its original state after exactly 4 × n_replen steps and byte
loopstart is first touched on step 2 × n_replen.
| Symbol | 1.0C | 1.1B |
|---|---|---|
n_glissfunk EQU |
34 | 44 |
n_funkoffset EQU |
38 | 48 |
n_wavestart EQU |
39 | 49 |
n_reallength EQU |
40 | 50 |
n_wavestart init (looped / unlooped) |
182 / 196 | 186 / 200 |
mt_UpdateFunk call from mt_CheckEfx |
338 | 342 |
mt_UpdateFunk call from mt_CheckMoreEfx |
777 | 785 |
EF dispatch in mt_E_Commands |
826 | 834 |
mt_FunkIt |
976 | 984 |
mt_UpdateFunk |
986 | 994 |
mt_FunkTable |
1024 | 1027 |
mt_SetDMA loop-pointer rewrite |
281–293 | 285–297 |
Line numbers are for 1.0c/ProTracker_v1.0c.s and 1.1b/ProTracker_v1.1b.s.
2.3d/ProTracker_v2.3a.s runs two lines earlier than 1.1B throughout the funk
region (mt_FunkIt 982, mt_UpdateFunk 992, mt_FunkTable 1025) with
identical contents.
This is a development blog post of Microtone, a browser-based tracker with microtonality, surround/spatial authoring, and of course, accurate Funk Repeat and non-destructive Invert Loop. Please check it out if you're interested!