Skip to content

Instantly share code, notes, and snippets.

@curioustorvald
Created August 26, 2026 22:21
Show Gist options
  • Select an option

  • Save curioustorvald/ebdaadf022d562fd9760fecc4a8e0171 to your computer and use it in GitHub Desktop.

Select an option

Save curioustorvald/ebdaadf022d562fd9760fecc4a8e0171 to your computer and use it in GitHub Desktop.

Funk Repeat and Invert Loop — EF $x in ProTracker 1.x

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.

0. Provenance

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.

1. Executive summary

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.

2. Shared machinery

Everything in this section is identical in 1.0C and 1.1B. Verified by transcription of both files.

2.1 Channel state

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.

2.2 The EF $x handler

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:

  1. EF $x latches on tick 0 only. On later ticks of the same row the E-command dispatcher still reaches mt_FunkIt, and it returns immediately.
  2. The speed is sticky. It lives in n_glissfunk and persists per channel across rows, patterns and notes until another EF $x overwrites it. There is no "current row only" behaviour.
  3. EF $0 disables and returns without stepping — but does not clear n_funkoffset, so re-arming later resumes on the old phase.
  4. A non-zero EF $x falls through into mt_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.

2.3 The clock

mt_FunkTable: dc.b 0,5,6,7,8,10,11,13,16,19,22,26,32,43,64,128

Byte-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.

2.4 When mt_UpdateFunk runs

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 $x with x ≠ 0. mt_CheckMoreEfx calls mt_UpdateFunk (using the previous speed), then dispatches to mt_FunkIt, which installs the new speed and falls through into mt_UpdateFunk again. So the row on which funk is switched on, or its speed changed, gets a double accumulator advance.
  • 9 $xx (sample offset). mt_SetRegs does BSR mt_CheckMoreEfx and then branches to mt_SetPeriod, which itself ends in BRA mt_CheckMoreEfx (1.0C lines 215–218 and 268; 1.1B lines 219–222 and 272). Two entries, two mt_UpdateFunk calls. 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).

3. ProTracker 1.0C — Funk Repeat

3.1 Listing

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:

3.2 Semantics

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.

3.3 Reference pseudocode

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.

3.4 The mt_SetDMA interaction — do not skip this

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_replen

So 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.

3.5 Edge cases

  • 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 to n_loopstart. Funk Repeat is audibly inert. This is why the help file insists on a short loop.
  • n_replen == 0: candidate equals wavestart, the comparison passes, and the same pointer is rewritten forever. Inert, no crash. (ProTracker itself never writes replen = 0; the minimum is 1 word.)
  • Non-looping sample (repeat = 0, replen = 1): mt_NoLoop sets n_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_init zeroes the first longword of every sample (CLR.L (A2)), so the k = 0 position is silence.
  • Latent word-overflow. LSL.W #1,D2 shifts the word, so 2 × n_reallength is computed modulo 65536. For n_reallength ≥ $8000 the limit is wrong and the walk is pinned to n_loopstart or truncated. In practice this never fires: ProTracker's own maximum sample length is 65534 bytes = $7FFF words, 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.

3.6 Worked example

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.

4. ProTracker 1.1B — Invert Loop

4.1 Listing

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:

4.2 Semantics

n_wavestart is a read/write cursor into sample data, not a Paula pointer. Nothing is written to any hardware register.

Each step:

  1. advances the cursor by one byte;
  2. wraps it to n_loopstart if it reached n_loopstart + 2 × n_replen;
  3. 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 $0 freezes 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-clone clears 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 — a and A index byte-identical bitmaps. Every status string in the executable is stored lowercase (editskip = 0, command stored!, pos restored !) for the same reason.

4.3 Reference pseudocode

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.

4.4 Edge cases

  • n_replen == 1 (the 2-byte dummy loop of a non-looping sample): loopEnd = loopstart + 2, and the cursor alternates loopstart+1, loopstart, loopstart+1, …. For a non-looping sample those two bytes are inside the longword mt_init zeroed, so they toggle between $00 and $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 at loopstart, toggling one byte forever.
  • Null pointers. The 1.1B replayer has no guard. pt2-clone adds 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_SetDMA loop-pointer rewrite of §3.4 is still there in 1.1B (lines 285–297) and is now entirely harmless, because Invert Loop never writes AUDxLC.

4.5 Worked example

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.

5. What changed, exactly

5.1 Behaviour

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

5.2 Code

  • mt_FunkIt (the EF $x handler): 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_reallength was 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.

5.3 Documentation

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.

6. Which version has which

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:

  1. The 1.1A playroutine's own "Changes from V1.0C playroutine" header lists one item: the Noisetracker-compatible vibrato depth.
  2. 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.
  3. 1.1A ships the 1.0C PT.help byte-identical, still documenting Funk Repeat.
  4. 1.1A's own PTEffects.txt says EF- FunkRepeat … (add replen to repeat).
  5. 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 about EF $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.

7. Implementing Funk Repeat in a software mixer

7.1 Hardware-faithful model

If you are emulating Paula properly, the mapping is direct:

  • Keep the loop length fixed at 2 × n_replen for the whole note. Funk never changes it.
  • A funk step sets the channel's pending loop start. Paula reloads its internal pointer from AUDxLC at 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_loopstart for every channel, while leaving the funk cursor alone.

7.2 Non-destructive model

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 $Fx implementation 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.

7.3 Things that will bite you

  • Storing the funk speed as the table value rather than the nibble is fine and saves a lookup, but then EF $0 must 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 $x and EF $x share 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_wavestart only 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. Test vectors

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.

9. Source index

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment