pygoattracker 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/PKG-INFO +1 -1
  2. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/pyproject.toml +1 -1
  3. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker/player.py +70 -1
  4. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker.egg-info/PKG-INFO +1 -1
  5. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/tests/test_player.py +69 -0
  6. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/LICENSE +0 -0
  7. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/README.md +0 -0
  8. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/setup.cfg +0 -0
  9. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker/__init__.py +0 -0
  10. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker/audio.py +0 -0
  11. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker/cli.py +0 -0
  12. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker/constants.py +0 -0
  13. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker/convert.py +0 -0
  14. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker/errors.py +0 -0
  15. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker/legacy.py +0 -0
  16. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker/model.py +0 -0
  17. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker/ninja.py +0 -0
  18. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker/reader.py +0 -0
  19. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker/reglog.py +0 -0
  20. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker/writer.py +0 -0
  21. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker.egg-info/SOURCES.txt +0 -0
  22. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker.egg-info/dependency_links.txt +0 -0
  23. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker.egg-info/entry_points.txt +0 -0
  24. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker.egg-info/requires.txt +0 -0
  25. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/src/pygoattracker.egg-info/top_level.txt +0 -0
  26. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/tests/test_audio.py +0 -0
  27. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/tests/test_cli.py +0 -0
  28. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/tests/test_convert.py +0 -0
  29. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/tests/test_coverage.py +0 -0
  30. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/tests/test_examples.py +0 -0
  31. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/tests/test_legacy.py +0 -0
  32. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/tests/test_lint.py +0 -0
  33. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/tests/test_model.py +0 -0
  34. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/tests/test_ninja.py +0 -0
  35. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/tests/test_reader.py +0 -0
  36. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/tests/test_reglog.py +0 -0
  37. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/tests/test_roundtrip.py +0 -0
  38. {pygoattracker-0.1.2 → pygoattracker-0.1.4}/tests/test_writer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygoattracker
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Pure-Python reader, writer, and player for GoatTracker 2 (.SNG) songs
5
5
  Author: Josh Bailey
6
6
  License: Apache License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pygoattracker"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Pure-Python reader, writer, and player for GoatTracker 2 (.SNG) songs"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -89,9 +89,31 @@ class Player:
89
89
  adparam: int = constants.DEFAULT_ADPARAM,
90
90
  optimize_pulse: bool = True,
91
91
  optimize_realtime: bool = True,
92
+ freq_table=None,
93
+ simplepulse: bool = False,
92
94
  ):
93
95
  if not 0 <= subtune < len(song.subtunes):
94
96
  raise GoatTrackerError(f"no such subtune: {subtune}")
97
+ # The editor playroutine (gplay.c) reads a fixed note-frequency table
98
+ # zero-padded to 128 entries. The PACKED player (gt2reloc/player.s) lays
99
+ # the table out as ``freqtbllo[firstnote..lastnote]`` followed
100
+ # immediately by ``freqtblhi[...]`` with NO padding, then the songtable;
101
+ # an out-of-range note (a wavetable relative step past the last note)
102
+ # therefore overruns into the adjacent relocated memory and yields an
103
+ # image-specific frequency. ``freq_table`` lets a caller (the packed-SID
104
+ # decompiler) supply that exact 128-entry table read from the image so
105
+ # the render reproduces the packed player's overrun frequencies. When
106
+ # None the editor's zero-padded ``constants.FREQ_TABLE`` is used.
107
+ self._freq_table = (
108
+ constants.FREQ_TABLE if freq_table is None else tuple(freq_table)
109
+ )
110
+ # ``simplepulse`` selects the packed player's SIMPLEPULSE pulse code
111
+ # path (greloc's one-byte pulse optimization), which computes pulse
112
+ # width differently from the editor player. The pulse table must then
113
+ # be the PACKED table (greloc's set-pulse byte + swapped speed); see
114
+ # ``_pulse_exec_simple``. Default False = the editor's full-mod path,
115
+ # so .SNG / editor playback is unchanged.
116
+ self.simplepulse = simplepulse
95
117
  self.adparam = adparam & 0xFFFF
96
118
  self.optimize_pulse = optimize_pulse
97
119
  self.optimize_realtime = optimize_realtime
@@ -201,7 +223,7 @@ class Player:
201
223
  return self._rtable[table][(ptr - 1) & 0xFF]
202
224
 
203
225
  def _freq(self, note: int) -> int:
204
- return constants.FREQ_TABLE[note & 0x7F]
226
+ return self._freq_table[note & 0x7F]
205
227
 
206
228
  def _speed_value(self, idx: int, chan: _Channel) -> int:
207
229
  """16-bit speedtable value; high bit selects realtime calculation."""
@@ -537,6 +559,9 @@ class Player:
537
559
  self._toneporta(chan, chan.cmddata)
538
560
 
539
561
  def _pulse_exec(self, chan: _Channel) -> None:
562
+ if self.simplepulse:
563
+ self._pulse_exec_simple(chan)
564
+ return
540
565
  ptbl = constants.PTBL
541
566
  if self._lt(ptbl, chan.pulse_table_ptr) == constants.TABLEJUMP:
542
567
  chan.pulse_table_ptr = self._rt(ptbl, chan.pulse_table_ptr)
@@ -558,6 +583,50 @@ class Player:
558
583
  if not chan.pulsetime:
559
584
  chan.pulse_table_ptr = (chan.pulse_table_ptr + 1) & 0xFF
560
585
 
586
+ def _pulse_exec_simple(self, chan: _Channel) -> None:
587
+ """SIMPLEPULSE pulse executor (player.s mt_setpulse/mt_pulsemod,
588
+ ``.IF SIMPLEPULSE != 0``).
589
+
590
+ greloc's SIMPLEPULSE optimization (greloc.c ~888/1302) packs the
591
+ pulse table so a SET-PULSE step is ONE byte ``(pulsehi & 0x0f) |
592
+ (pulselo & 0xf0)`` and the modulation speed is pre-swapped
593
+ (``swapnybbles``). The packed player keeps a single ghost pulse byte:
594
+ mt_setpulse stores that one packed byte to BOTH ghostpulselo and
595
+ ghostpulsehi (no separate hi store, no ``& 0x0F``), and mt_pulsemod
596
+ does an 8-bit ``lo = lo + speed + carry`` accumulate writing the same
597
+ byte to both lo and hi. The SID then masks the high pulse nibble to 12
598
+ bits. The editor player (``_pulse_exec``) instead computes
599
+ ``pulse = (left & 0x0f) << 8 | right`` giving pulse-hi 0 where this
600
+ packed player gives pulse-hi = the packed byte's low nibble.
601
+
602
+ ``chan.pulse`` carries that single packed byte in its low 8 bits; the
603
+ 12-bit SID pulse is ``((byte & 0x0f) << 8) | byte``.
604
+ """
605
+ ptbl = constants.PTBL
606
+ if self._lt(ptbl, chan.pulse_table_ptr) == constants.TABLEJUMP:
607
+ chan.pulse_table_ptr = self._rt(ptbl, chan.pulse_table_ptr)
608
+ if not chan.pulse_table_ptr:
609
+ return
610
+ byte = chan.pulse & 0xFF
611
+ if not chan.pulsetime:
612
+ left = self._lt(ptbl, chan.pulse_table_ptr)
613
+ if left >= 0x80:
614
+ # Set pulse: one packed byte -> both lo and hi ghost registers.
615
+ byte = self._rt(ptbl, chan.pulse_table_ptr)
616
+ chan.pulse = ((byte & 0x0F) << 8) | byte
617
+ chan.pulse_table_ptr = (chan.pulse_table_ptr + 1) & 0xFF
618
+ return
619
+ chan.pulsetime = left
620
+ if chan.pulsetime:
621
+ # mt_pulsemod: lo = lo + speed + carry-out; hi = lo (same byte).
622
+ speed = self._rt(ptbl, chan.pulse_table_ptr)
623
+ total = byte + speed
624
+ byte = (total + (total >> 8)) & 0xFF
625
+ chan.pulse = ((byte & 0x0F) << 8) | byte
626
+ chan.pulsetime -= 1
627
+ if not chan.pulsetime:
628
+ chan.pulse_table_ptr = (chan.pulse_table_ptr + 1) & 0xFF
629
+
561
630
  def _get_new_notes(self, channel: int, chan: _Channel) -> None:
562
631
  pattern = self._patterns[chan.pattnum]
563
632
  newnote = pattern[chan.pattptr]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygoattracker
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Pure-Python reader, writer, and player for GoatTracker 2 (.SNG) songs
5
5
  Author: Josh Bailey
6
6
  License: Apache License
@@ -89,6 +89,24 @@ def test_single_note_timeline(song):
89
89
  assert (constants.CONTROL_REG, 0x09) in frames[55]
90
90
 
91
91
 
92
+ def test_freq_table_override(song):
93
+ """A per-instance ``freq_table`` overrides the editor's fixed note table so
94
+ the packed-SID decompiler can reproduce the packed player's overrun
95
+ frequencies (it lays the table out unpadded, so out-of-range notes read
96
+ adjacent image bytes). Default (None) keeps the editor table."""
97
+ # The default render emits C-4 at the standard table frequency.
98
+ base = play(Player(song), 60)
99
+ assert (constants.FREQ_LO_REG, C4_FREQ & 0xFF) in base[8]
100
+ assert (constants.FREQ_HI_REG, C4_FREQ >> 8) in base[8]
101
+ # Override note 48 (C-4) to an arbitrary image-specific value; the render
102
+ # now emits that frequency for the same note, nothing else changes.
103
+ table = list(constants.FREQ_TABLE)
104
+ table[48] = 0xBEEF
105
+ frames = play(Player(song, freq_table=table), 60)
106
+ assert (constants.FREQ_LO_REG, 0xEF) in frames[8]
107
+ assert (constants.FREQ_HI_REG, 0xBE) in frames[8]
108
+
109
+
92
110
  def test_keyoff_keyon(song):
93
111
  song.patterns[0].rows[4] = Row(note=constants.KEYOFF)
94
112
  song.patterns[0].rows[5] = Row(note=constants.KEYON)
@@ -323,6 +341,57 @@ def test_pulse_low_byte_unmasked(song):
323
341
  assert player.regs[constants.PULSE_LO_REG] == 0x01
324
342
 
325
343
 
344
+ def test_simplepulse_set_writes_packed_byte_to_both_nibbles(song):
345
+ # greloc's SIMPLEPULSE optimization packs a set-pulse step into one byte and
346
+ # the packed player stores it to BOTH ghostpulselo and ghostpulsehi. For a
347
+ # packed set byte $88 the SID pulse is ((0x88 & 0x0f) << 8) | 0x88 == $888
348
+ # (pulse-hi nibble = the byte's low nibble), whereas the editor player
349
+ # computes (left & 0x0f) << 8 | right == $088 (pulse-hi 0).
350
+ song.pulsetable.left = [0x80, 0xFF]
351
+ song.pulsetable.right = [0x88, 0x00]
352
+ song.instruments[0].pulse_ptr = 1
353
+ editor = Player(song)
354
+ play(editor, 10)
355
+ assert (editor.regs[3] << 8) | editor.regs[2] == 0x088
356
+
357
+ packed = Player(song, simplepulse=True)
358
+ play(packed, 10)
359
+ assert (packed.regs[3] << 8) | packed.regs[2] == 0x888
360
+
361
+
362
+ def test_simplepulse_modulation_accumulates_one_byte(song):
363
+ # SIMPLEPULSE modulation does lo = lo + speed + carry and mirrors the byte
364
+ # into pulse-hi. Start at packed byte $08, then a 16-step modulation of $10:
365
+ # the byte walks $08,$18,$28,... and pulse-hi tracks its low nibble ($8).
366
+ song.pulsetable.left = [0x80, 0x10, 0xFF]
367
+ song.pulsetable.right = [0x08, 0x10, 0x00]
368
+ song.instruments[0].pulse_ptr = 1
369
+ player = Player(song, simplepulse=True)
370
+ play(player, 10)
371
+ history = []
372
+ for _ in range(20):
373
+ player.play_frame()
374
+ history.append((player.regs[3], player.regs[2])) # (hi, lo)
375
+ # The defining SIMPLEPULSE invariant: the one ghost byte feeds both pulse
376
+ # registers, so pulse-hi is ALWAYS the low nibble of pulse-lo (the editor's
377
+ # 12-bit pulse keeps an independent hi byte and would not hold this).
378
+ assert all(hi == (lo & 0x0F) for hi, lo in history)
379
+ # While in the $10-per-step ramp the low byte climbs by $10 (low nibble
380
+ # fixed at $8) until a low-nibble carry advances the table.
381
+ assert history[0] == (0x08, 0x28)
382
+ assert history[2] == (0x08, 0x38)
383
+
384
+
385
+ def test_simplepulse_default_off_matches_editor(song):
386
+ # Default simplepulse=False leaves the editor pulse path byte-identical.
387
+ song.pulsetable.left = [0x88, 0x10, 0xFF]
388
+ song.pulsetable.right = [0x00, 0x40, 0x00]
389
+ song.instruments[0].pulse_ptr = 1
390
+ a = reg_history(song, 40, constants.PULSE_HI_REG)
391
+ b = reg_history(song, 40, constants.PULSE_HI_REG, simplepulse=False)
392
+ assert a == b
393
+
394
+
326
395
  def test_filter_program():
327
396
  # A long pattern so the looping song does not retrigger the note
328
397
  # (and with it the filter program) during the measurement.
File without changes
File without changes
File without changes