diff --git a/CLAUDE.md b/CLAUDE.md index a7ae6fb..1867947 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -258,6 +258,34 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal `highlight_palette` overrides a slot, and `theme.highlight_color` reads through `theme.highlight_palette`, so nothing else needs to know. +- **`lintunes/kaleidostrobe/`** + **`gui/visualizer_kaleido.py`** — the + fourth visualizer style (Round 84): Kaleidostrobe, the 2002 iTunes plugin + by Shehryar Lasi, **reverse-engineered from its PowerPC Mach-O** (symbols + intact) to be identical, not "inspired by". Method: capstone listings, and + the original running under a **Unicorn PPC emulator** through its own + VisualPluginHandler (imports answered in Python, GL calls recorded) as the + oracle. `engine.py` is the Qt-free port, routine by routine, with `f32` / + `math.fma` wherever the PPC rounded to single or fused a multiply-add + (a palette index on `x.5` depends on it), `ParkMiller` = Darwin 6's + `rand()` (RAND_MAX 2³¹−1). `tables.npz` holds what the original computes + deterministically, extracted by running *its* code: 22 colour maps and 23 + base wave images (before their random `transform()`, which is ported, as + are the 16 live `calcDelta` warps — each with its **own** static counters + per layer). Quirks kept on purpose: `flipDiag`'s off-by-one. Facts that + shape the port: the plugin renders only after 39,999 µs, so it ran at + **25 fps** and counts frames for rotation and switching — the renderer + steps the engine on its own 25 Hz clock, never the widget's 60 Hz timer; + beat timing is in TickCount (1/60 s). The original's Color/Flash settings + were Apple SDK leftovers its code never read, so the style has none. It + reads only iTunes' **spectrum** bytes (never the waveform); iTunes' byte + scale was never published, so `itunes_spectrum` (√magnitude, 43 Hz bins) + is the one calibrated stand-in. Painting: each GL triangle is planar, so + it is drawn in texture space through a projective **world** transform + (Qt mis-draws a strongly projective *brush* transform); the top cone is + always nearer, so painter's order is the depth test. + `tests/kaleidostrobe_golden.npz` was recorded from the emulated original + and `test_round84` holds the port to it byte for byte. + - **`lintunes/importers/itunes_importer.py`** — parses the iTunes XML plist. Remaps Mac `file:///Volumes/...` paths to the local `--music-root` with case/Unicode-normalization fuzzy matching (macOS is case-insensitive + NFD vs diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 24f7600..f011870 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.52.0" +__version__ = "0.53.0" diff --git a/lintunes/gui/preferences_dialog.py b/lintunes/gui/preferences_dialog.py index 2f45c0b..0d5621b 100644 --- a/lintunes/gui/preferences_dialog.py +++ b/lintunes/gui/preferences_dialog.py @@ -79,6 +79,7 @@ _VISUALIZER_STYLES = { "bars": "Bars", "radial": "Radial dots", "laser": "Laser", + "kaleidostrobe": "Kaleidostrobe", } @@ -599,6 +600,17 @@ class PreferencesDialog(QDialog): form.addRow("", square) self._viz_pages.addWidget(laser) + kaleido = QWidget() + form = self._viz_form(kaleido) + about = QLabel( + "The 2002 iTunes visualizer by Shehryar Lasi, rebuilt from its " + "original code. It has no settings: the Color and Flash boxes " + "it once showed were never connected to anything.") + about.setWordWrap(True) + about.setEnabled(False) + form.addRow(about) + self._viz_pages.addWidget(kaleido) + self._viz_pages.setCurrentIndex(list(_VISUALIZER_STYLES).index(current)) column.addWidget(self._viz_pages) diff --git a/lintunes/gui/visualizer.py b/lintunes/gui/visualizer.py index 7776046..6c01eb0 100644 --- a/lintunes/gui/visualizer.py +++ b/lintunes/gui/visualizer.py @@ -348,6 +348,9 @@ class VisualizerWidget(QWidget): regardless (in color), since opening it is asking to see it.""" if self._no_pcm or (self._mode == MODE_OFF and not big): return + # A style with colours of its own (Kaleidostrobe) can't take the + # gray as its colour; it is told instead. + self._renderer.dimmed = self._mode == MODE_DIM self._renderer.paint(painter, rect, self._bar_color(), self._grid_gray()) def paintEvent(self, event): diff --git a/lintunes/gui/visualizer_kaleido.py b/lintunes/gui/visualizer_kaleido.py new file mode 100644 index 0000000..c7cb753 --- /dev/null +++ b/lintunes/gui/visualizer_kaleido.py @@ -0,0 +1,198 @@ +"""Kaleidostrobe as a visualizer style: the 2002 iTunes plugin's simulation +(``lintunes.kaleidostrobe.engine``), fed what iTunes fed it and painted the +way its OpenGL did. + +Two things are this file's business, the rest is the engine's: + +- **Time.** The plugin rendered only when more than 40 ms had passed since + its last frame, so it ran at 25 fps however often iTunes asked, and its + rotation and pattern switching count *frames*. The renderer keeps its own + 25 Hz clock and steps the engine on it, never on the widget's 60 Hz timer + — otherwise everything would spin 2.4× too fast. TickCount (1/60 s) is + derived from the same clock. +- **Input.** iTunes handed plugins 512 spectrum bins per channel as bytes. + How iTunes scaled them was never documented; ``itunes_spectrum`` is a + calibrated stand-in (``SPECTRUM_GAIN``). Kaleidostrobe divides everything by + running peaks, so it is the *shape* over time that matters, not the units. + +Painting: every GL triangle is planar, so its texture → screen mapping is a +homography; each one is filled with the texture as a tiled brush under that +projective transform (GL_REPEAT, GL_LINEAR via smooth transforms). The top +layer's depth is always in front of the bottom one's, so painter's order is +the depth test. +""" +import math +import time + +import numpy as np + +from PyQt6.QtCore import Qt, QPointF +from PyQt6.QtGui import QBrush, QColor, QImage, QPainter, QPolygonF, QTransform + +from lintunes.kaleidostrobe import engine as ks + + +STYLE = "kaleidostrobe" +STEP_S = ks.FRAME_US / 1e6 # one plugin frame +MAX_CATCH_UP = 3 # frames run at once after a stall, at most +FFT_AT_44K = 1024 # iTunes' 512 bins are a 1024-point FFT's half +SPECTRUM_GAIN = 320.0 # byte = √magnitude × this (full-scale sine = 1) + +_PROJ_F = 1.0 / math.tan(math.radians(ks.FOVY) / 2) +_WINDOWS = {} + + +def itunes_spectrum(samples: np.ndarray, rate: float) -> np.ndarray: + """2×512 bytes shaped like iTunes' ``spectrumData``: linear bins 43 Hz + apart (a 1024-point FFT at 44.1 kHz, whatever the real rate). Mono audio + fills both channels alike. + + The byte scale is ours. Measured against synthetic songs, a log (dB) + scale pins all three of the plugin's band levels near 1, so the cone + height and colour scaling stop moving; linear magnitude starves the + mids. √magnitude keeps all three alive. Beat detection barely cares — + it hears a 90 BPM backbeat on every scale, and on none can it pick a + kick out of a constant sub-bass, which is the original's own limit.""" + n = max(64, int(round(FFT_AT_44K * rate / 44100.0))) + window = _WINDOWS.get(n) + if window is None: + window = _WINDOWS[n] = np.hanning(n).astype(np.float32) + seg = samples[-n:] + if len(seg) < n: + seg = np.concatenate([np.zeros(n - len(seg), np.float32), seg]) + mag = np.abs(np.fft.rfft(seg * window))[:512] / (n / 4) + if len(mag) < 512: + mag = np.concatenate([mag, np.zeros(512 - len(mag))]) + row = np.clip(np.sqrt(mag) * SPECTRUM_GAIN, 0, 255).astype(np.uint8) + return np.stack([row, row]) + + +def _rotz(deg): + r = math.radians(deg) + c, s = math.cos(r), math.sin(r) + return np.array([[c, -s, 0, 0], [s, c, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]) + + +def _lift(z): + m = np.eye(4) + m[2, 3] = z + return m + + +class KaleidostrobeRenderer: + name = STYLE + dimmed = False # set by the widget: paint in dim mode's gray + + def __init__(self, config=None): + self._engine = None + self._clock = 0.0 # simulated seconds since the engine began + self._behind = 0.0 # time owed to the 25 Hz clock + self._frame = None # the last FrameOut, or None = blank + self._images = [None, None] + + # ---- the renderer contract ---- + + def configure(self, config): + pass # the original's two settings were never read by its code + + def clear(self): + """Paused or stopped: blank, but the plugin keeps its state, as it + did across iTunes' pause.""" + self._frame = None + + def advance(self, frame) -> bool: + if self._engine is None: + # srand(TickCount()) at 'init': any clock reading will do + seed = int(time.monotonic() * ks.TICKS_PER_S) & 0x7fffffff + self._engine = ks.Kaleidostrobe(seed, self._ticks()) + self._behind = min(self._behind + max(0.0, frame.dt), + STEP_S * MAX_CATCH_UP) + stepped = False + while self._behind >= STEP_S: + self._behind -= STEP_S + self._clock += STEP_S + spectrum = itunes_spectrum(frame.samples, frame.rate) + self._frame = self._engine.step(spectrum, self._ticks()) + self._images = [None, None] + stepped = True + return stepped + + def _ticks(self): + return 1000 + int(self._clock * ks.TICKS_PER_S) + + # ---- painting ---- + + def _texture(self, i, dim): + img = self._images[i] + if img is None: + rgba = np.ascontiguousarray(self._frame.textures[i]) + img = QImage(rgba.data, ks.SIZE, ks.SIZE, ks.SIZE * 4, + QImage.Format.Format_RGBA8888).copy() + self._images[i] = img + if dim is not None: + img = _dimmed(img, dim) + return img + + def paint(self, painter, rect, color: QColor, gray: QColor = None): + if self._frame is None or rect.width() < 2 or rect.height() < 2: + return + # In colour mode the highlight goes unused — Kaleidostrobe brought + # its own palettes. Dim mode tints the picture with its gray. + dim = color if self.dimmed else None + painter.save() + painter.setClipRect(rect, Qt.ClipOperation.IntersectClip) + painter.fillRect(rect, QColor(0, 0, 0)) # glClearColor(0, 0, 0, 0) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True) + painter.setPen(Qt.PenStyle.NoPen) + w, h = rect.width(), rect.height() + aspect = w / h + proj = np.array([[_PROJ_F / aspect, 0, 0, 0], + [0, _PROJ_F, 0, 0], + [0, 0, -1.0, 0]]) # rows: x_c, y_c, w_c + screen = np.array([[w / 2, 0, rect.left() + w / 2], + [0, -h / 2, rect.top() + h / 2], + [0, 0, 1.0]]) + # The rim reaches this rect's corners (reshape's gFarHalfWidth). + engine_fhw = self._engine.far_half_width + half = ks.f32(ks.FAR_HALF_HEIGHT * aspect) + rim = math.sqrt(2 * half * half) / engine_fhw if engine_fhw else 1.0 + view = _lift(-ks.EYE_Z) + world = painter.worldTransform() + for layer in self._frame.layers: + painter.setBrush(QBrush(self._texture(layer.texture, dim))) + painter.setOpacity(layer.alpha) + base = view @ _lift(layer.lift) @ _rotz(layer.turn) + for tri in layer.triangles: + m = screen @ proj @ (base @ _rotz(tri.turn)) + pts = np.array([(x * rim, y * rim, z, 1.0) if z == 0.0 + else (x, y, z, 1.0) for x, y, z in tri.xyz]) + st = np.array([(s, t, 1.0) for s, t in tri.st]) + to_xyz = pts.T @ np.linalg.inv(st.T) # (s, t, 1) → object + hom = m @ to_xyz @ np.diag([1 / ks.SIZE, 1 / ks.SIZE, 1.0]) + # Scale so w is 1 at the first vertex: every vertex is in + # front of the eye, so w stays positive across the triangle. + hom = hom / (m @ pts[0])[2] + # Draw the triangle in texture space through the homography: + # Qt's raster engine is exact for a projective *world* + # transform, not for a projective brush transform. + painter.setWorldTransform(QTransform( + hom[0, 0], hom[1, 0], hom[2, 0], + hom[0, 1], hom[1, 1], hom[2, 1], + hom[0, 2], hom[1, 2], hom[2, 2]) * world) + painter.drawPolygon(QPolygonF( + [QPointF(s * ks.SIZE, t * ks.SIZE) for s, t in tri.st])) + painter.restore() + + +def _dimmed(img: QImage, tint: QColor) -> QImage: + """Dim mode: the picture's luminance in the panel's gray.""" + a = np.frombuffer(img.constBits().asstring(img.sizeInBytes()), np.uint8) + a = a.reshape(ks.SIZE, ks.SIZE, 4).astype(np.float32) + luma = (a[..., 0] * 0.299 + a[..., 1] * 0.587 + a[..., 2] * 0.114) / 255.0 + out = np.empty((ks.SIZE, ks.SIZE, 4), np.uint8) + for i, c in enumerate((tint.red(), tint.green(), tint.blue())): + out[..., i] = (luma * c).astype(np.uint8) + out[..., 3] = 255 + return QImage(out.data, ks.SIZE, ks.SIZE, ks.SIZE * 4, + QImage.Format.Format_RGBA8888).copy() diff --git a/lintunes/gui/visualizer_styles.py b/lintunes/gui/visualizer_styles.py index 2024af4..4e58f09 100644 --- a/lintunes/gui/visualizer_styles.py +++ b/lintunes/gui/visualizer_styles.py @@ -10,6 +10,8 @@ audio intake, the timer, on/dim/off, the color. Pure numpy + QPainter. frequency, radius is volume, and a dot that moves leaves fading ghosts. - ``LaserRenderer`` — a laser bounced off a mirror glued to a membrane stretched over a speaker, projected onto a screen (Tame Impala's rig). +- ``KaleidostrobeRenderer`` (``visualizer_kaleido``) — the 2002 iTunes + plugin, ported from its PowerPC binary. Every tunable lives in preferences.json under a ``visualizer_*`` key; ``settings(prefs)`` reads them clamped to ``LIMITS``, and a renderer takes @@ -29,7 +31,8 @@ from lintunes.preferences import DEFAULTS STYLE_BARS = "bars" STYLE_RADIAL = "radial" STYLE_LASER = "laser" -STYLES = (STYLE_BARS, STYLE_RADIAL, STYLE_LASER) +STYLE_KALEIDOSTROBE = "kaleidostrobe" +STYLES = (STYLE_BARS, STYLE_RADIAL, STYLE_LASER, STYLE_KALEIDOSTROBE) BANDS = 20 STEPS = 20 # vertical granularity per bar @@ -995,4 +998,7 @@ def make_renderer(style: str, config=None): return RadialRenderer(config) if style == STYLE_LASER: return LaserRenderer(config) + if style == STYLE_KALEIDOSTROBE: + from lintunes.gui.visualizer_kaleido import KaleidostrobeRenderer + return KaleidostrobeRenderer(config) return BarsRenderer(config) diff --git a/lintunes/kaleidostrobe/__init__.py b/lintunes/kaleidostrobe/__init__.py new file mode 100644 index 0000000..77ddd51 --- /dev/null +++ b/lintunes/kaleidostrobe/__init__.py @@ -0,0 +1,2 @@ +"""Kaleidostrobe, the 2002 iTunes visualizer by Shehryar Lasi, ported from +its PowerPC binary. See ``engine`` for the simulation.""" diff --git a/lintunes/kaleidostrobe/engine.py b/lintunes/kaleidostrobe/engine.py new file mode 100644 index 0000000..87c3215 --- /dev/null +++ b/lintunes/kaleidostrobe/engine.py @@ -0,0 +1,706 @@ +"""Kaleidostrobe's per-frame simulation, ported from the PowerPC original. + +Every function here is a translation of the routine named in its docstring +(the bundle kept its symbols). Where the original rounded to single +precision, ``f32`` does too; where it used a fused multiply-add, so does +``math.fma``: an index that lands on ``x.5`` decides a palette entry, so the +rounding has to be the original's, not merely close to it. + +The engine is Qt-free. ``step()`` advances one frame and returns what the +original handed OpenGL: two 128×128 RGBA textures and, per layer, the +triangles to draw (object-space vertices, texture coordinates, and the +modelview that places them). ``visualizer_kaleido`` paints that. +""" +import math +import struct +from dataclasses import dataclass, field +from importlib import resources + +import numpy as np + + +SIZE = 128 # the index images and textures are 128×128 +TICKS_PER_S = 60 # Mac OS TickCount() +FRAME_US = 40_000 # 'vrnd' renders only after more than 39,999 µs +FOVY = 36.8698976458 # reshape(): gluPerspective(fovy, w/h, 5, 25) +EYE_Z = 15.0 # display(): gluLookAt(0,0,15, 0,0,0, 0,1,0) +NEAR, FAR = 5.0, 25.0 +FAR_HALF_HEIGHT = 5.0 # gFarHalfHeight +ROTATION_STEPS = 1080 # rotation = (rotation + 1) % 1080, drawn /3 + +# Function tables, as the plugin's arrays name them (InitGL picks with +# rand() % len, DrawGL with newValue(…, len)). +MAP_TABLE = (0, 1, 2, 3, 21, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, + 18, 19, 20, 0, 1, 3, 4, 20, 6, 7, 9, 10, 11, 12, 15, 17, 18, 19, + 21) +WAVE_TABLE = (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, + 18, 19, 20, 21, 22, 31, 32, 33, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18, 20, 22, 31, 32, 33) +DELTA_TABLE = (0, 1, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18) +POLAR_WAVES = {31: 3, 32: 12, 33: 16} # initWaveShape03x: base, polar, transform + +# ProcessRenderData's starting state (VisualPluginHandler 'show'/'vply'). +BAND_PEAK_START = ((1225.0, 484.0, 900.0, 32761.0), + (1089.0, 441.0, 576.0, 24964.0)) +MIN_MAX_BAND_POWER = (1157.0, 462.5, 738.0, 28862.5) +BASS_MAX_FLOOR = 12.788898 +MID_MAX_FLOOR = 7.219702 +TREBLE_MAX_FLOOR = 1.702842 +PEAK_DECAY = 0.99383029715 +# lowpass biquad shared by the three bands: b0, b1, b2, a1, a2 +LP_B0, LP_B1, LP_A1, LP_A2 = 0.0023, 0.0045, 1.8614, 0.8704 +BEAT_PERIOD_START = 45.0 # ticks, i.e. 80 BPM + + +def f32(x): + """Round to single precision, as frsp / a float store did.""" + return struct.unpack('f', struct.pack('f', x))[0] + + +def cmod(a, m): + """C's %, which keeps the sign of the dividend.""" + return np.fmod(a, m) + + +class ParkMiller: + """Darwin 6 libc rand() (FreeBSD's Park–Miller): RAND_MAX 0x7fffffff.""" + + def __init__(self, seed=1): + self.x = seed & 0xffffffff + + def rand(self) -> int: + x = self.x + if x == 0: + x = 123459876 + hi, lo = divmod(x, 127773) + x = 16807 * lo - 2836 * hi + if x < 0: + x += 0x7fffffff + self.x = x + return x % 0x80000000 + + +_RAND_SCALE = f32(4.656612873077393e-10) # 1 / 2^31, as the float it was + + +def _unit(rng) -> float: + return f32(f32(float(rng.rand())) * _RAND_SCALE) + + +# -------------------------------------------------------------------------- +# Tables: extracted by running the original makeMap / initWaveShape code +# -------------------------------------------------------------------------- + +_TABLES = None + + +def tables(): + global _TABLES + if _TABLES is None: + with resources.files(__package__).joinpath("tables.npz").open("rb") as f: + data = np.load(f) + _TABLES = {k: data[k] for k in data.files} + return _TABLES + + +def _norm_coord() -> np.ndarray: + """initGlobals: normCoord[i] = i/127·2 − 1, in single precision.""" + out = np.empty(SIZE, np.float32) + for i in range(SIZE): + q = f32(f32(float(i)) / f32(127.0)) + out[i] = f32((q + q) + -1.0) + return out + + +def _polar_tables(nc): + """initGlobals: radius = √(x² + y²) and theta = atan2(y, x) + π.""" + radius = np.empty((SIZE, SIZE), np.float32) + theta = np.empty((SIZE, SIZE), np.float32) + for y in range(SIZE): + ny = float(nc[y]) + for x in range(SIZE): + nx = float(nc[x]) + radius[y, x] = f32(math.sqrt(f32(ny * ny + f32(nx * nx)))) + if ny != 0.0 and nx != 0.0: + theta[y, x] = f32(math.atan2(ny, nx) + 3.1415926) + elif ny == 0.0: + theta[y, x] = 0.0 if nx >= 0.0 else f32(3.141592502593994) + else: + theta[y, x] = f32(1.570796251296997) if ny > 0.0 \ + else f32(4.71238899230957) + return radius, theta + + +def _cos_index(nc): + """The fishbowl remap of calcDelta014/016/017: int(cos(nc·π/2)·127 + ½).""" + return np.array([int(math.fma(math.cos(float(v) * 3.141592 * 0.5), 127.0, 0.5)) + for v in nc], np.int64) + + +class _Geometry: + """Per-process constants derived from initGlobals.""" + + def __init__(self): + nc = self.nc = _norm_coord() + self.radius, self.theta = _polar_tables(nc) + self.cos127 = _cos_index(nc) + c18 = np.empty(SIZE, np.int64) + for i in range(SIZE): + v = float(nc[i]) + c = math.cos(v * 3.141592 * 0.5) + c18[i] = int(math.fma(c, 64.0, 0.5)) if v <= 0.0 \ + else int((127.0 - c * 64.0) + 0.5) + self.cos18 = c18 + # RectToPolar's sample points: column from the angle, row from the + # distance (both ≥ 0, so fctiwz's truncation is int()). + col = [int(math.fma(float(t) / 6.2831852, 127.0, 0.5)) + for t in self.theta.ravel()] + row = [int(math.fma(float(r) / 1.41421356237, 127.0, 0.5)) + for r in self.radius.ravel()] + self.polar_col = np.fmod(np.array(col, np.int64), SIZE).reshape(SIZE, SIZE) + self.polar_row = np.fmod(np.array(row, np.int64), SIZE).reshape(SIZE, SIZE) + + +_GEOMETRY = None + + +def geometry() -> _Geometry: + global _GEOMETRY + if _GEOMETRY is None: + _GEOMETRY = _Geometry() + return _GEOMETRY + + +# -------------------------------------------------------------------------- +# Index-image operations (in place on one 128×128 uint8 buffer) +# -------------------------------------------------------------------------- + +def flip_diag(img): + """flipDiag: a transpose that skips the cells beside the diagonal. + + The loop runs x < y − 1 where a transpose wants x < y, so (y, y−1) and + (y−1, y) stay put. It shows as a faint seam along the diagonal; kept.""" + for y in range(SIZE): + if y >= 2: + row = img[y, :y - 1].copy() + img[y, :y - 1] = img[:y - 1, y] + img[:y - 1, y] = row + + +def inside_out(img): + """insideOut: each half of the image flipped top to bottom.""" + img[0:64] = img[0:64][::-1].copy() + img[64:128] = img[64:128][::-1].copy() + + +def transform(img, rng): + """transform: none, flipDiag, insideOut or both, at random.""" + k = int(math.fma(_unit(rng), 4.999999, 1.0)) + if k == 2: + flip_diag(img) + elif k == 3: + inside_out(img) + elif k == 4: + flip_diag(img) + inside_out(img) + elif k == 5: + inside_out(img) + flip_diag(img) + + +def rect_to_polar(img): + """RectToPolar: column = angle, row = distance from the centre.""" + g = geometry() + img[:] = img[g.polar_row, g.polar_col] + + +# -------------------------------------------------------------------------- +# calcDelta*: buffer 1 = a warp of buffer 0, by each layer's own counter +# -------------------------------------------------------------------------- + +_Y, _X = np.mgrid[0:SIZE, 0:SIZE] + + +def _block_average(src, rows, s): + """calcDelta005/011: 32-wide horizontal averages of a shifted image.""" + out = np.empty((len(rows), SIZE), np.uint8) + for i, y in enumerate(rows): + line = src[(y + s) % SIZE] + for blk in range(0, SIZE, 32): + cols = (blk + s + np.arange(32)) % SIZE + out[i, blk:blk + 32] = int(int(line[cols].astype(np.int64).sum()) * 0.03125) + return out + + +def delta(n, src, state): + """calcDelta: returns the warped image; advances ``state`` (a dict of + that function's static counters for this layer).""" + S = SIZE + Y, X = _Y, _X + if n == 0: + s = state.get('h', 0) + out = src[Y, (X + s) % S] + state['h'] = s + 1 + elif n == 1: + h, v = state.get('h', 0), state.get('v', 0) + out = src[(Y + v) % S, (X + h) % S] + state['h'], state['v'] = h + 1, v + 1 + elif n == 3: + s = state.get('s', 0) + out = np.where(X < 64, src[Y, (X + s) % 64], src[Y, ((X - s) % 64) + 64]) + state['s'] = (s + 1) % 64 + elif n == 4: + s = state.get('s', 0) + q = src[(Y[:64, :64] + s) % 64, (X[:64, :64] + s) % 64] + out = np.empty((S, S), np.uint8) + out[:64, :64] = q + out[64:, :64] = q[::-1] + out[64:, 64:] = q[::-1, ::-1] + out[:64, 64:] = q[:, ::-1] + state['s'] = (s + 1) % 64 + elif n == 5: + s = state.get('s', 0) + out = _block_average(src, range(S), s) + state['s'] = (s + 1) % S + elif n == 6: + s = state.get('s', 0) + out = np.empty((S, S), np.uint8) + full = src[(Y + s) % S, (X + s) % S] + y, x = np.nonzero(X[:64, :64] <= Y[:64, :64]) + v = full[y, x] + for yy, xx in ((y, x), (y, 127 - x), (127 - y, x), (127 - y, 127 - x), + (x, y), (x, 127 - y), (127 - x, y), (127 - x, 127 - y)): + out[yy, xx] = v + state['s'] = (s + 1) % S + elif n == 8: + s = state.get('s', 0) + out = src[(Y + s) % S, X] + state['s'] = s + 1 + elif n == 9: + s = state.get('s', 0) + row = (Y + s) % S + out = np.where(X < 32, src[row, (63 - X) % S], + np.where(X >= 96, src[row, (191 - X) % S], src)) + state['s'] = s + 1 + elif n == 10: + s = state.get('s', 0) + out = np.where(Y < 64, src[Y, (X + s) % S], src) + state['s'] = s + 1 + elif n == 11: + s = state.get('s', 0) + out = np.empty((S, S), np.uint8) + out[:64] = _block_average(src, range(64), s) + out[64:] = src[(Y[64:] + s) % S, (X[64:] + s) % S] + state['s'] = (s + 1) % S + elif n == 12: + s = state.get('s', 0) + row, col = (Y + s) % S, (X + s) % S + out = src[row, col].copy() + top = src[row[:64, :64], 127 - col[:64, :64]] + out[:64, :64] = top + out[:64, 64:] = src[row[:64, :64], col[:64, :64]][:, ::-1] + state['s'] = s + 1 + elif n == 13: + s = state.get('s', 0) + direct = src[(Y + s) % S, (X + s) % S] + swapped = src[(X + s) % S, (Y + s) % S] + out = np.where(Y + X >= 127, direct, swapped) + state['s'] = s + 1 + elif n == 14: + s = state.get('s', 0) + c = geometry().cos127 + out = src[(c[Y] + s) % S, (c[X] + s) % S] + state['s'] = s + 1 + elif n == 16: + s = state.get('s', 0) + c = geometry().cos127 + out = src[(c[Y] + s) % S, c[X]] + state['s'] = s + 1 + elif n == 17: + s = state.get('s', 0) + c = geometry().cos127 + out = src[(Y + s) % S, (c[X] + s) % S] + state['s'] = s + 1 + elif n == 18: + s = state.get('s', 0) + c = geometry().cos18 + out = src[(Y + s) % S, (c[X] + s) % S] + state['s'] = s + 1 + else: + raise ValueError(f"calcDelta{n:03d} is not in the plugin's table") + return np.ascontiguousarray(out, np.uint8) + + +# -------------------------------------------------------------------------- +# The plugin +# -------------------------------------------------------------------------- + +@dataclass +class Triangle: + st: tuple # ((s, t), …) × 3, texture coordinates + xyz: tuple # ((x, y, z), …) × 3, object space + turn: float # degrees about z applied after the layer's own turn + + +@dataclass +class Layer: + texture: int # 0 = bottom, 1 = top + alpha: float # glColor4f alpha (MODULATE with an opaque texture) + lift: float # glTranslatef(0, 0, lift) + turn: float # glRotatef(turn, 0, 0, 1) + triangles: list = field(default_factory=list) + + +@dataclass +class FrameOut: + textures: np.ndarray # (2, 128, 128, 4) uint8 RGBA + layers: list # [bottom Layer, top Layer] + beat: bool + + +class Kaleidostrobe: + """One instance of the plugin: 'init' + 'show' + 'vply', then a + ``step()`` per 'vrnd' the plugin accepted.""" + + def __init__(self, seed: int, ticks: int): + self.rng = ParkMiller(seed) # srand(TickCount()) + self.width, self.height = 640, 480 + t = tables() + self.maps = t['maps'] + self.waves = t['waves'] + self.wave_inc = t['wave_inc'] + # ProcessRenderData state + self.band_peak = [list(map(f32, row)) for row in BAND_PEAK_START] + self.bass_max = f32(BASS_MAX_FLOOR) + self.mid_max = f32(MID_MAX_FLOOR) + self.treble_max = f32(TREBLE_MAX_FLOOR) + self.hold = 0.0 + self.xn1 = self.xn2 = self.yn1 = self.yn2 = 0.0 + self.lp = {b: [0.0, 0.0, 0.0, 0.0] for b in ('bass', 'mid', 'treble')} + self.signal = self.avg_signal = 0.0 + self.bass = self.mid = self.treble = 0.0 + # checkBeat + self.prev_signal = 0.0 + self.prev_run_avg = 0.0 + self.max_run_avg = f32(0.35) + self.beat_periods = [BEAT_PERIOD_START] * 16 + self.next_cell = 0 + self.beat = False + self.beat_count = 0 + self.period_avg = BEAT_PERIOD_START + self.now = ticks + self.prev_tick = ticks - 1 + self.last_beat = ticks - 46 + self.start_tick = ticks + self.frame_count = 0 + self.ticks_per_frame = 1.0 + # DrawGL / display / updateTexture state + self.rotation = 0 + self.top_object = self.bottom_object = -1 + self.flip = [0, 0, 0] + self.index_offset = [0.0, 0.0] + self.cur_map = [0, 0] + self.cur_wave = [0, 0] + self.cur_delta = [0, 0] + self.buffers = np.zeros((2, 2, SIZE, SIZE), np.uint8) + self.palettes = np.zeros((2, 256, 4), np.uint8) + self.wave_scale = [0.0, 0.0] + # each calcDelta function's own static counters, per layer: a warp + # chosen again resumes where it left off, a new one starts at 0 + self.delta_state = [dict(), dict()] + self.textures = np.zeros((2, SIZE, SIZE, 4), np.uint8) + self._init_gl() + + # ---- InitGL ---- + def _init_gl(self): + for tex in (0, 1): + self.cur_map[tex] = self.rng.rand() % len(MAP_TABLE) + self.cur_wave[tex] = self.rng.rand() % len(WAVE_TABLE) + self.cur_delta[tex] = self.rng.rand() % len(DELTA_TABLE) + self._make_map(tex) + self._init_wave(tex) + + def _make_map(self, tex): + self.palettes[tex] = self.maps[MAP_TABLE[self.cur_map[tex]]] + + def _init_wave(self, tex): + """initWaveShape into buffer 0 (currFrameIndex is set to 0 first).""" + n = WAVE_TABLE[self.cur_wave[tex]] + img = self.buffers[tex, 0] + base = POLAR_WAVES.get(n, n) + img[:] = self.waves[base] + transform(img, self.rng) + self.wave_scale[tex] = float(self.wave_inc[base]) + if n in POLAR_WAVES: + rect_to_polar(img) + # RectToPolar also leaves its result in the other buffer, which + # calcDelta overwrites before anything reads it. + self.buffers[tex, 1] = img + transform(img, self.rng) + + def set_size(self, width: int, height: int): + self.width, self.height = max(1, int(width)), max(1, int(height)) + + @property + def far_half_width(self) -> float: + """reshape: the rim reaches the screen's corners.""" + t = f32(FAR_HALF_HEIGHT * self.width / self.height) + return f32(math.sqrt(f32(f32(t * t) + f32(t * t)))) + + # ---- ProcessRenderData ---- + def _process(self, spectrum, now): + self.ticks_per_frame = f32(f32(float((now - self.start_tick) & 0xffffffff)) + / f32(float(self.frame_count))) + self.now = now + if spectrum is None: + return + nch = len(spectrum) + mins = [0xff, 0xff] + for ch in range(nch): + mins[ch] = int(spectrum[ch][:256].min()) + floor = f32(float(mins[0] if mins[0] < mins[1] else mins[1])) + norm = [[0.0] * 4 for _ in range(nch)] + for ch in range(nch): + for b in range(4): + v = f32(float(int(spectrum[ch][b]) ** 2 - mins[ch] ** 2)) + if v > self.band_peak[ch][b]: + self.band_peak[ch][b] = v + p = self.band_peak[ch][b] + norm[ch][b] = f32(v / p) if p > 0.0 else 0.0 + signal = 0.0 + bass = mid = treble = 0.0 + for ch in range(nch): + for b in range(4): + signal = f32(signal + norm[ch][b]) + p = f32(self.band_peak[ch][b] * PEAK_DECAY) + self.band_peak[ch][b] = MIN_MAX_BAND_POWER[b] \ + if p < MIN_MAX_BAND_POWER[b] else p + row = spectrum[ch] + for i in range(0, 32): + bass = f32(bass + float(row[i])) + for i in range(32, 176): + mid = f32(mid + float(row[i])) + for i in range(176, 256): + treble = f32(treble + float(row[i])) + signal = f32(signal / f32(float(nch * 4))) + if signal > self.hold: + self.hold = signal + else: + signal = self.hold + self.signal = signal + dt = float((now - self.prev_tick) & 0xffffffff) + self.hold = f32(self.hold - dt * 0.04444444) + if self.hold < 0.0: + self.hold = 0.0 + avg = math.fma(signal, 0.5, self.xn1 * 0.5) + avg = avg + f32(self.xn2 * 0.0) + f32(self.yn1 * 0.0) - f32(self.yn2 * 0.0) + self.avg_signal = f32(avg) + self.yn2, self.xn2 = self.yn1, self.xn1 + self.yn1, self.xn1 = self.avg_signal, signal + n = f32(float(nch)) + self.bass, self.bass_max = self._band( + 'bass', f32(f32(bass / (n * 32.0)) - floor), self.bass_max, + BASS_MAX_FLOOR, f32(12.788898468017578)) + self.mid, self.mid_max = self._band( + 'mid', f32(f32(mid / (n * 144.0)) - floor), self.mid_max, + MID_MAX_FLOOR, f32(7.219701766967773)) + self.treble, self.treble_max = self._band( + 'treble', f32(f32(treble / (n * 80.0)) - floor), self.treble_max, + TREBLE_MAX_FLOOR, f32(1.7028419971466064), clamp=True) + self._check_beat(now) + self.prev_tick = now + + def _band(self, name, x, peak, floor_d, floor_f, clamp=False): + st = self.lp[name] # x1, x2, y1, y2 + y = math.fma(x, LP_B0, st[0] * LP_B1) + y = math.fma(st[1], LP_B0, y) + y = math.fma(st[2], LP_A1, y) + y = f32(y - st[3] * LP_A2) + if y > peak: + peak = y + st[3], st[1] = st[2], st[0] + st[2], st[0] = y, x + level = f32(y / peak) + if clamp and level < 0.0: + level = 0.0 + peak = f32(peak * PEAK_DECAY) + if peak < floor_d: + peak = floor_f + return level, peak + + def _check_beat(self, now): + """checkBeat: a rise in the bass-bin signal well above its recent + best, at least 16 ticks after the last beat.""" + rav = f32(math.fma(self.prev_run_avg, 0.25, + f32(self.signal - self.prev_signal) * 0.75)) + if rav > self.max_run_avg: + self.max_run_avg = rav + beat = rav > self.max_run_avg * 0.6 + self.prev_signal = self.signal + self.prev_run_avg = rav + self.max_run_avg = f32(self.max_run_avg * PEAK_DECAY) + if self.max_run_avg < 0.35: + self.max_run_avg = f32(0.35) + since = (now - self.last_beat) & 0xffffffff + if beat and since > 15: + self.beat_count += 1 + self.beat_periods[self.next_cell] = f32(float(since)) + self.next_cell = (self.next_cell + 1) % 16 + total = 0.0 + for p in self.beat_periods: + total = f32(total + p) + self.period_avg = f32(total * 0.0625) + self.last_beat = now + self.beat = True + else: + self.beat = False + + # ---- updateTexture ---- + def _update_textures(self): + level = f32((f32(f32(self.bass + self.mid) + self.treble)) / 2.625 + 0.075) + if level < 0.0: + level = 0.0 + lv = np.float32(level) + for tex in (0, 1): + if self.beat and (self.beat_count & 7) == 0: + self.index_offset[tex] = 0.0 + n = DELTA_TABLE[self.cur_delta[tex]] + self.buffers[tex, 1] = delta(n, self.buffers[tex, 0], + self.delta_state[tex].setdefault(n, {})) + idx = self.buffers[tex, 1].astype(np.float32) + a = np.trunc((idx + np.float32(self.index_offset[tex])).astype(np.float64) + + 0.5).astype(np.int64) + a = cmod(a, 256) + b = np.trunc((a.astype(np.float32) * lv).astype(np.float64) + + 0.5).astype(np.int64) + b = cmod(b, 256) + self.textures[tex] = self.palettes[tex][b] + k = f32(48.0 / self.period_avg) + k = f32(k * k) + step = k * (level * 2.625 / 2.25) * self.wave_scale[tex] + tpf = self.ticks_per_frame / 1.825405 + self.index_offset[tex] = f32(self.index_offset[tex] + + math.fma(step, tpf, tpf)) + + # ---- display ---- + def _pick_object(self) -> int: + return int(math.fma(_unit(self.rng), 17.999999, 1.0)) - 1 + + def _display(self) -> list: + height = f32(math.fma(self.mid, 0.5, 2.0)) + if height < 2.0: + height = f32(2.0) + depth = f32(math.pow(self.avg_signal, 1.25)) if self.avg_signal >= 0 \ + else float('nan') + if (self.beat and ((self.beat_count + 16) & 31) == 0) or self.bottom_object < 0: + self.bottom_object = self._pick_object() + if (self.beat and ((self.beat_count + 48) & 31) == 0) or self.top_object < 0: + self.top_object = self._pick_object() + spin = f32(self.rotation / 3.0) + bottom = Layer(0, 1.0, 0.0, spin, self._object( + self.bottom_object, f32(height / 1.5), f32(math.fma(depth, 10.0, -10.0)))) + top = Layer(1, 0.5, 5.0, f32(-self.rotation / 3.0), self._object( + self.top_object, height, f32(depth * 5.0))) + self.rotation = (self.rotation + 1) % ROTATION_STEPS + return [bottom, top] + + def _object(self, k, h, z): + if k == 0: + return self._draw8(h, z) + if k in (1, 2): + return self._draw12(h, z) + if k in (3, 4): + n = 6 + elif k == 5: + n = 10 + elif k <= 16: + n = 2 * k - 6 + else: + n = 30 + return self._draw_n(h, z, n) + + def _draw_n(self, h, z, n): + """drawNTriangles: n/2 mirrored pairs, each pair turned 720/n°.""" + fhw = self.far_half_width + t = f32(math.tan((math.pi + math.pi) / n)) + hx = f32(h * t) + half = f32(fhw * t) + tv = f32(h * 0.866) + ts = f32((hx + hx) * 0.866) + step = f32(720.0 / n) + tris = [] + for i in range(n // 2): + turn = step * i + tris.append(Triangle(((0.0, tv), (-ts, -tv), (0.0, -tv)), + ((0.0, 0.0, z), (-half, -fhw, 0.0), (0.0, -fhw, 0.0)), turn)) + tris.append(Triangle(((0.0, tv), (0.0, -tv), (-ts, -tv)), + ((0.0, 0.0, z), (0.0, -fhw, 0.0), (half, -fhw, 0.0)), turn)) + return tris + + def _draw12(self, h, z): + """draw12Triangles: six mirrored pairs, 60° apart.""" + fhw = self.far_half_width + wide = f32(fhw / 0.866) + tv = f32(h * 0.866) + tn = f32(-h * 0.866) + left = f32(-wide * 0.5) + right = f32(wide * 0.5) + tris = [] + for i in range(6): + turn = 60.0 * i + tris.append(Triangle(((0.0, tv), (-h, tn), (0.0, tn)), + ((0.0, 0.0, z), (left, -fhw, 0.0), (0.0, -fhw, 0.0)), turn)) + tris.append(Triangle(((0.0, tv), (0.0, tn), (-h, tn)), + ((0.0, 0.0, z), (0.0, -fhw, 0.0), (right, -fhw, 0.0)), turn)) + return tris + + def _draw8(self, h, z): + """draw8Triangles: a square pyramid as one triangle fan.""" + f = self.far_half_width + rim = ((-f, f), (-f, 0.0), (-f, -f), (0.0, -f), (f, -f), (f, 0.0), + (f, f), (0.0, f), (-f, f)) + st = ((-h, h), (-h, -h)) + tris = [] + for i in range(8): + a, b = rim[i], rim[i + 1] + tris.append(Triangle(((h, -h), st[i % 2], st[(i + 1) % 2]), + ((0.0, 0.0, z), (a[0], a[1], 0.0), (b[0], b[1], 0.0)), 0.0)) + return tris + + # ---- DrawGL ---- + def _new_value(self, cur, n) -> int: + while True: + v = int(math.fma(_unit(self.rng), n - 1e-06, 1.0)) - 1 + if v != cur: + return v + + def _switch(self): + """After a beat: every 8th swaps a layer's warp, 16th its colours, + 32nd its pattern — alternating layers each time.""" + if not self.beat: + return + c = self.beat_count + if (c & 0x1f) == 0: + tex = self.flip[0] + self.cur_wave[tex] = self._new_value(self.cur_wave[tex], 50.0) + self._init_wave(tex) + self.flip[0] = int(tex == 0) + elif (c & 0xf) == 0: + tex = self.flip[1] + self.cur_map[tex] = self._new_value(self.cur_map[tex], 37.0) + self._make_map(tex) + self.flip[1] = int(tex == 0) + elif (c & 7) == 0: + tex = self.flip[2] + self.cur_delta[tex] = self._new_value(self.cur_delta[tex], 16.0) + self.flip[2] = int(tex == 0) + + def step(self, spectrum, ticks: int) -> FrameOut: + """One accepted 'vrnd': ``spectrum`` is iTunes' 2×512 uint8 array + (or None for no data); ``ticks`` is TickCount() now.""" + self.frame_count += 1 + self._process(spectrum, ticks) + self._update_textures() + layers = self._display() + self._switch() + return FrameOut(self.textures.copy(), layers, self.beat) diff --git a/lintunes/kaleidostrobe/tables.npz b/lintunes/kaleidostrobe/tables.npz new file mode 100644 index 0000000..4a3261c Binary files /dev/null and b/lintunes/kaleidostrobe/tables.npz differ diff --git a/lintunes/preferences.py b/lintunes/preferences.py index 2808eae..80da4a1 100644 --- a/lintunes/preferences.py +++ b/lintunes/preferences.py @@ -14,7 +14,7 @@ DEFAULTS = { "volume": 0.8, # logical 0..1 master output volume "normalize_volume": False, # play every song at one loudness (loudness.py) "visualizer_mode": "on", # on (color) | dim (gray) | off — click cycle - "visualizer_style": "bars", # bars | radial | laser (visualizer_styles) + "visualizer_style": "bars", # bars | radial | laser | kaleidostrobe # The styles' tunables — ranges in visualizer_styles.LIMITS. # Fade times in seconds, 0 = off: how long what a bar or dot left # behind, or the laser's past sweeps, take to fade into the background. diff --git a/scripts/kaleidostrobe/README.md b/scripts/kaleidostrobe/README.md new file mode 100644 index 0000000..9650c84 --- /dev/null +++ b/scripts/kaleidostrobe/README.md @@ -0,0 +1,21 @@ +# Kaleidostrobe reverse-engineering tools + +How `lintunes/kaleidostrobe/` was made, and how to check it again. Dev-only: +needs `pip install capstone unicorn numpy` (never a LinTunes dependency) and +the original bundle (`KALEIDOSTROBE_BIN`, default +`~/documents/misc/lil hacky bits/Kaleidostrobe.bundle/Contents/MacOS/Kaleidostrobe`). + +- `macho.py` — Mach-O PPC parser + annotated disassembly + (`python macho.py _display _checkBeat`): resolves the PIC base, stubs, + non-lazy pointers and float constants. +- `emu.py` — the original under Unicorn: imports answered in Python + (libm, Darwin `rand`, TickCount/Microseconds, Carbon's `pi`), GL calls + recorded. After adding a hook, `ctl_remove_cache` — Unicorn never applies + a hook to code it already translated. +- `plugin.py` — drives `VisualPluginHandler` like iTunes: init, show, vply, + vrnd. +- `extract.py OUT.npz` — the colour maps and base wave images + → `lintunes/kaleidostrobe/tables.npz`. +- `golden.py OUT.npz` — `tests/kaleidostrobe_golden.npz`. +- `compare.py [frames]`, `coverage.py` — the port against the original, + frame by frame / function by function. diff --git a/scripts/kaleidostrobe/compare.py b/scripts/kaleidostrobe/compare.py new file mode 100644 index 0000000..a78a4c6 --- /dev/null +++ b/scripts/kaleidostrobe/compare.py @@ -0,0 +1,107 @@ +"""Run the emulated original and the Python port side by side.""" +import math, sys, random +sys.path.insert(0, __file__.rsplit('/', 1)[0]) +sys.path.insert(0, str(__import__('pathlib').Path(__file__).resolve().parents[2])) +import numpy as np +from plugin import Plugin +from lintunes.kaleidostrobe.engine import Kaleidostrobe + +def rotz(a): + r = math.radians(a); c, s = math.cos(r), math.sin(r) + m = np.eye(4); m[0, 0] = c; m[0, 1] = -s; m[1, 0] = s; m[1, 1] = c + return m + +def trans(x, y, z): + m = np.eye(4); m[:3, 3] = (x, y, z); return m + +def gl_triangles(calls): + """(texture, alpha, [(st×3, eye-xyz×3)]) per glBegin batch, plus textures.""" + stack = [np.eye(4)]; tex = None; alpha = 1.0; st = (0, 0) + out = []; cur = None; mode = None + for name, a in calls: + M = stack[-1] + if name == '_glLoadIdentity': stack[-1] = np.eye(4) + elif name == '_gluLookAt': stack[-1] = stack[-1] @ trans(0, 0, -15) + elif name == '_glPushMatrix': stack.append(stack[-1].copy()) + elif name == '_glPopMatrix': stack.pop() + elif name == '_glRotatef': stack[-1] = stack[-1] @ rotz(a[0]) + elif name == '_glTranslatef': stack[-1] = stack[-1] @ trans(*a[:3]) + elif name == '_glBindTexture': tex = a[1] + elif name == '_glColor4f': alpha = a[3] + elif name == '_glBegin': cur = []; mode = a[0] + elif name == '_glTexCoord2f': st = a[:2] + elif name in ('_glVertex2f', '_glVertex3f'): + v = (a[0], a[1], a[2] if name == '_glVertex3f' else 0.0, 1.0) + cur.append((st, (M @ np.array(v))[:3])) + elif name == '_glEnd': + tris = [] + if mode == 4: + tris = [cur[i:i + 3] for i in range(0, len(cur), 3)] + elif mode == 6: + tris = [[cur[0], cur[i], cur[i + 1]] for i in range(1, len(cur) - 1)] + for t in tris: + out.append((tex, alpha, t)) + return out + +def engine_triangles(frame): + out = [] + for layer in frame.layers: + base = trans(0, 0, -15) @ trans(0, 0, layer.lift) @ rotz(layer.turn) + for t in layer.triangles: + M = base @ rotz(t.turn) + out.append((layer.texture + 1, layer.alpha, + [(t.st[i], (M @ np.array((*t.xyz[i], 1.0)))[:3]) for i in range(3)])) + return out + +def spectra(frame, rnd): + """Fake iTunes spectra: a kick every ~13 frames, noise elsewhere.""" + kick = (frame % 13) < 2 + row = [rnd.randrange(0, 40) for _ in range(512)] + for b in range(4): + row[b] = rnd.randrange(180, 256) if kick else rnd.randrange(20, 120) + for b in range(4, 32): + row[b] = rnd.randrange(60, 200) + return [row, row[:]] + +def main(frames=200, seed_ticks=1000, w=640, h=480): + p = Plugin(rect=(0, 0, h, w), ticks=seed_ticks) + e = Kaleidostrobe(seed=seed_ticks, ticks=seed_ticks) + e.set_size(w, h) + rnd = random.Random(7) + tex_bad = geo_bad = 0 + t0 = p.k.micro + for f in range(frames): + spec = spectra(f, rnd) + start = len(p.k.gl) + p.k.micro += 40001 + ticks = seed_ticks + (p.k.micro - t0) * 60 // 1_000_000 + calls = p.frame(None, spec, dt_us=0, dticks=ticks - p.k.ticks) + ours = e.step(np.array(spec, np.uint8), ticks) + # textures: the two glTexImage2D uploads of this frame + idx = [i for i in range(start, len(p.k.gl)) if p.k.gl[i][0] == '_glTexImage2D'] + theirs = [np.frombuffer(p.k.textures[i], np.uint8).reshape(128, 128, 4) for i in idx] + for t in range(2): + d = int((theirs[t] != ours.textures[t]).any(-1).sum()) + if d: + tex_bad += 1 + if tex_bad <= 5: + print(f'frame {f} tex {t}: {d} texels differ') + a, b = gl_triangles(calls), engine_triangles(ours) + ok = len(a) == len(b) and all( + x[0] == y[0] and abs(x[1] - y[1]) < 1e-6 and + all(np.allclose(x[2][i][0], y[2][i][0], atol=1e-4) and + np.allclose(x[2][i][1], y[2][i][1], atol=1e-3) for i in range(3)) + for x, y in zip(a, b)) + if not ok: + geo_bad += 1 + if geo_bad <= 3: + print(f'frame {f}: geometry differs ({len(a)} vs {len(b)} triangles)') + for x, y in list(zip(a, b))[:2]: + print(' orig', x); print(' port', y) + if f % 50 == 0: + print(f'frame {f}: beat={ours.beat} beats={e.beat_count} objects={e.bottom_object},{e.top_object} ' + f'map={e.cur_map} wave={e.cur_wave} delta={e.cur_delta}') + print(f'{frames} frames: texture mismatches {tex_bad}, geometry mismatches {geo_bad}') + +if __name__ == '__main__': + main(int(sys.argv[1]) if len(sys.argv) > 1 else 200) diff --git a/scripts/kaleidostrobe/coverage.py b/scripts/kaleidostrobe/coverage.py new file mode 100644 index 0000000..d7ce269 --- /dev/null +++ b/scripts/kaleidostrobe/coverage.py @@ -0,0 +1,45 @@ +import sys; sys.path.insert(0, __file__.rsplit('/',1)[0]); sys.path.insert(0, str(__import__('pathlib').Path(__file__).resolve().parents[2])) +import numpy as np +from plugin import Plugin +from lintunes.kaleidostrobe import engine as E + +TEXIDX, CURTEX, CURFRAME, WAVEINC = 0x457f8, 0x13dbc, 0x13dc0, 0x257f0 +p = Plugin(seed=1); k = p.k +rng = np.random.default_rng(3) +bad = 0 +for n in E.DELTA_TABLE: + for tex in (0, 1): + state = {} + # the emulator's static counters may already have moved during + # Plugin(); sync by reading them is fiddly, so zero them first + k.u.mem_write(0x13b2c, b'\0' * (0x13be4 - 0x13b2c)) + for it in range(4): + src = rng.integers(0, 256, (128, 128), dtype=np.uint8) + base = TEXIDX + tex * 0x8000 + k.u.mem_write(base, src.tobytes()) + k.w32(CURTEX, tex); k.w32(CURFRAME + 4 * tex, 1) + k.call(f'_calcDelta{n:03d}') + theirs = np.frombuffer(k.mem(base + 0x4000, 0x4000), np.uint8).reshape(128, 128) + ours = E.delta(n, src, state) + d = int((theirs != ours).sum()) + if d: + bad += 1; print(f'delta {n:03d} tex {tex} iter {it}: {d} differ') +print('deltas checked') +for seed in (5, 99, 31337): + for idx in range(len(E.WAVE_TABLE)): + k.rng.srand(seed); r = E.ParkMiller(seed) + tex = idx % 2 + k.w32(CURTEX, tex); k.w32(CURFRAME + 4 * tex, 0) + n = E.WAVE_TABLE[idx] + k.call(f'_initWaveShape{n:03d}') + theirs = np.frombuffer(k.mem(TEXIDX + tex * 0x8000, 0x4000), np.uint8).reshape(128, 128) + eng = E.Kaleidostrobe.__new__(E.Kaleidostrobe) + t = E.tables(); eng.waves, eng.wave_inc = t['waves'], t['wave_inc'] + eng.rng = r; eng.buffers = np.zeros((2, 2, 128, 128), np.uint8) + eng.wave_scale = [0.0, 0.0]; eng.cur_wave = [idx, idx] + eng._init_wave(tex) + d = int((theirs != eng.buffers[tex, 0]).sum()) + inc = k.rf(WAVEINC + 4 * tex) + if d or abs(inc - eng.wave_scale[tex]) > 1e-7 or k.rng.x != r.x: + bad += 1; print(f'wave {n:03d} seed {seed}: {d} differ, inc {inc} vs {eng.wave_scale[tex]}, rng {k.rng.x == r.x}') +print('waves checked; failures:', bad) diff --git a/scripts/kaleidostrobe/emu.py b/scripts/kaleidostrobe/emu.py new file mode 100644 index 0000000..bda20db --- /dev/null +++ b/scripts/kaleidostrobe/emu.py @@ -0,0 +1,253 @@ +"""Run the original Kaleidostrobe PPC code under Unicorn, as iTunes would. + +Imports are trapped at their __picsymbol_stub entries and answered in Python; +GL calls are recorded (name, args) so a frame's draw list can be compared. +""" +import math, struct, sys +sys.path.insert(0, __file__.rsplit('/', 1)[0]) +import macho +from unicorn import Uc, UC_ARCH_PPC, UC_MODE_PPC32, UC_MODE_BIG_ENDIAN, UC_HOOK_CODE +from unicorn import UC_HOOK_MEM_UNMAPPED, UcError +import unicorn.ppc_const as P + +R = [getattr(P, f'UC_PPC_REG_{i}') for i in range(32)] +F = [getattr(P, f'UC_PPC_REG_FPR{i}') for i in range(32)] +STUB0, STUBEND, STUBSZ = 0x11f5c, 0x12d00, 36 +MAGIC = 0x7fff0 # return address: emu_stop when reached +STACK_TOP = 0x0ff0000 +HEAP = 0x1000000 + +NOOPS = {'_GetPortForeColor', '_GetPortBackColor', '_RGBForeColor', '_RGBBackColor', + '_PaintRect', '_EraseRect', '_InsetRect', '_TextFont', '_TextSize', '_TextMode', + '_TETextBox', '_FMGetFontFamilyFromName', '_ShowWindow', '_HideWindow', + '_fprintf', '_fclose', '_strcat', '_MPBlockCopy'} +STUBS = {a: n for a, (t, n) in macho.stubname.items() if t == 'stub'} + + +class ParkMiller: + """FreeBSD 4 / Darwin 6 libc rand(): RAND_MAX 0x7fffffff.""" + def __init__(self, seed=1): + self.x = seed + + def srand(self, s): + self.x = s & 0xffffffff + + def rand(self): + x = self.x + if x == 0: + x = 123459876 + hi, lo = divmod(x, 127773) + x = 16807 * lo - 2836 * hi + if x < 0: + x += 0x7fffffff + self.x = x + return x % 0x80000000 + + +class KS: + def __init__(self, seed=1): + u = self.u = Uc(UC_ARCH_PPC, UC_MODE_PPC32 | UC_MODE_BIG_ENDIAN) + u.mem_map(0, 0x80000) + u.mem_write(0, macho.D[:0x24000]) + u.mem_map(STACK_TOP - 0x100000, 0x110000) + u.mem_map(HEAP, 0x400000) + self.heap = HEAP + u.reg_write(P.UC_PPC_REG_MSR, u.reg_read(P.UC_PPC_REG_MSR) | 0x2000) + # the weak import aglChoosePixelFormat must look present + u.mem_write(0x23fb8, struct.pack('>I', 1)) + # Carbon's MathLib `extern const double pi` (fp.h) + self.heap += 16 + u.mem_write(HEAP, struct.pack('>d', math.pi)) + u.mem_write(0x23fa4, struct.pack('>I', HEAP)) + u.hook_add(UC_HOOK_CODE, self._stub, begin=STUB0, end=STUBEND - 1) + u.hook_add(UC_HOOK_CODE, self._magic, begin=MAGIC, end=MAGIC + 3) + itcall = macho.func_size['_ITCallApplication'][0] + u.hook_add(UC_HOOK_CODE, lambda u, a, s, d: self._ret(r3=0), begin=itcall, end=itcall + 3) + self.rng = ParkMiller(seed) + self.ticks = 1000 + self.micro = 10_000_000 + self.gl = [] + self.textures = {} + self.trace_gl = True + + # ---- memory helpers ---- + def r32(self, a): return struct.unpack('>I', self.u.mem_read(a, 4))[0] + def w32(self, a, v): self.u.mem_write(a, struct.pack('>I', v & 0xffffffff)) + def rf(self, a): return struct.unpack('>f', self.u.mem_read(a, 4))[0] + def wf(self, a, v): self.u.mem_write(a, struct.pack('>f', v)) + def rd(self, a): return struct.unpack('>d', self.u.mem_read(a, 8))[0] + def wd(self, a, v): self.u.mem_write(a, struct.pack('>d', v)) + def mem(self, a, n): return bytes(self.u.mem_read(a, n)) + + def alloc(self, n): + a = (self.heap + 15) & ~15 + self.heap = a + n + self.u.mem_write(a, b'\0' * n) + return a + + def reg(self, i): return self.u.reg_read(R[i]) + def setreg(self, i, v): self.u.reg_write(R[i], v & 0xffffffff) + + def freg(self, i): + v = self.u.reg_read(F[i]) + return struct.unpack('>d', struct.pack('>Q', v & 0xffffffffffffffff))[0] + + def setfreg(self, i, x): + self.u.reg_write(F[i], struct.unpack('>Q', struct.pack('>d', x))[0]) + + # ---- calling ---- + def call(self, addr, *args, fargs=()): + if isinstance(addr, str): + addr = macho.func_size[addr][0] + u = self.u + sp = STACK_TOP - 0x400 + u.reg_write(R[1], sp) + self.w32(sp, 0) + for i, a in enumerate(args[:8]): + self.setreg(3 + i, a) + for i, a in enumerate(args[8:]): + self.w32(sp + 24 + 4 * (8 + i), a) + for i, x in enumerate(fargs): + self.setfreg(1 + i, x) + u.reg_write(P.UC_PPC_REG_LR, MAGIC) + try: + u.emu_start(addr, MAGIC + 4) + except UcError as e: + pc = u.reg_read(P.UC_PPC_REG_PC) + raise RuntimeError(f'{e} at pc={pc:#x} ({macho.symfor(pc)})') from None + return self.reg(3) + + def _magic(self, u, addr, size, ud): + u.emu_stop() + + def _ret(self, r3=None, f1=None): + if r3 is not None: + self.setreg(3, r3) + if f1 is not None: + self.setfreg(1, f1) + self.u.reg_write(P.UC_PPC_REG_PC, self.u.reg_read(P.UC_PPC_REG_LR)) + + def stackarg(self, i): + sp = self.reg(1) + return self.r32(sp + 24 + 4 * i) + + # ---- imports ---- + def _stub(self, u, addr, size, ud): + if (addr - STUB0) % STUBSZ: + return + name = STUBS.get(addr, '?') + fn = getattr(self, 'imp' + name, None) + if fn is not None: + fn() + return + if name.startswith('_gl') or name.startswith('_agl'): + self._glcall(name) + return + if name in NOOPS: + self._ret(r3=0) + return + raise RuntimeError(f'unhandled import {name}') + + def f(self, n): return [self.freg(1 + i) for i in range(n)] + def r(self, n): return [self.reg(3 + i) for i in range(n)] + + def imp_sin(self): self._ret(f1=math.sin(self.freg(1))) + def imp_cos(self): self._ret(f1=math.cos(self.freg(1))) + def imp_tan(self): self._ret(f1=math.tan(self.freg(1))) + def imp_sqrt(self): self._ret(f1=math.sqrt(self.freg(1)) if self.freg(1) >= 0 else float('nan')) + def imp_floor(self): self._ret(f1=math.floor(self.freg(1))) + def imp_atan2(self): y, x = self.f(2); self._ret(f1=math.atan2(y, x)) + + def imp_pow(self): + x, y = self.f(2) + try: + v = math.pow(x, y) + except (ValueError, OverflowError): + v = float('nan') + self._ret(f1=v) + + def imp_modf(self): + x = self.freg(1) + ip = float(math.trunc(x)) + self.wd(self.reg(5), ip) + self._ret(f1=x - ip) + + def imp_rand(self): self._ret(r3=self.rng.rand()) + def imp_srand(self): self.rng.srand(self.reg(3)); self._ret() + def imp_TickCount(self): self._ret(r3=self.ticks) + + def imp_Microseconds(self): + p = self.reg(3) + self.w32(p, self.micro >> 32); self.w32(p + 4, self.micro) + self._ret() + + def imp_memcpy(self): + d, s, n = self.r(3) + self.u.mem_write(d, self.mem(s, n)); self._ret(r3=d) + + def imp_BlockMoveData(self): + s, d, n = self.r(3) + self.u.mem_write(d, self.mem(s, n)); self._ret() + + def imp_memset(self): + d, c, n = self.r(3) + self.u.mem_write(d, bytes([c & 0xff]) * n); self._ret(r3=d) + + def imp_NewPtrClear(self): self._ret(r3=self.alloc(self.reg(3))) + def imp_NewRgn(self): self._ret(r3=self.alloc(16)) + def imp_DisposePtr(self): self._ret() + def imp_DisposeRgn(self): self._ret() + def imp_GetPort(self): self.w32(self.reg(3), 0x4444); self._ret() + def imp_SetPort(self): self._ret() + def imp_EqualRect(self): self._ret(r3=0) + def imp_strlen(self): + a = self.reg(3); n = 0 + while self.mem(a + n, 1) != b'\0': n += 1 + self._ret(r3=n) + + port_rect = (0, 0, 480, 640) # top, left, bottom, right + + def imp_GetPortBounds(self): + rect = self.reg(4) + self.u.mem_write(rect, struct.pack('>4h', *self.port_rect)) + self._ret(r3=rect) + + def imp_PlayerGetPluginFileSpec(self): self._ret(r3=0) + + # ---- GL / AGL ---- + GL_FLOATS = { + '_glClearColor': 4, '_glColor4f': 4, '_glRotatef': 4, '_glTranslatef': 3, + '_glVertex2f': 2, '_glVertex3f': 3, '_glTexCoord2f': 2, '_gluPerspective': 4, + '_gluLookAt': 9, + } + + def _glcall(self, name): + ret = 0 + if name in self.GL_FLOATS: + args = tuple(self.f(self.GL_FLOATS[name])) + elif name == '_glTexEnvf': + args = (self.reg(3), self.reg(4), self.freg(1)) + elif name == '_glTexImage2D': + args = tuple(self.r(8)) + (self.stackarg(8),) + w, h = args[3], args[4] + self.textures[len(self.gl)] = self.mem(args[8], w * h * 4) + elif name == '_glGenTextures': + n, p = self.r(2) + for i in range(n): + self.w32(p + 4 * i, 1 + i) + args = (n,) + elif name.startswith('_agl'): + args = tuple(self.r(4)) + ret = 1 if name not in ('_aglGetError',) else 0 + if name == '_aglCreateContext': + ret = 0xc0c0 + else: + args = tuple(self.r(4)) + if self.trace_gl: + self.gl.append((name, args)) + self._ret(r3=ret) + + +if __name__ == '__main__': + k = KS() + print(k.call('_newValue', 3, fargs=(10.0,))) diff --git a/scripts/kaleidostrobe/extract.py b/scripts/kaleidostrobe/extract.py new file mode 100644 index 0000000..3c8bfca --- /dev/null +++ b/scripts/kaleidostrobe/extract.py @@ -0,0 +1,85 @@ +"""Extract Kaleidostrobe's deterministic tables by running the original code. + +- 22 colour maps (makeMap000-021): mapRGB[0], 256 RGBA entries +- base wave images (initWaveShape000-022), *before* their random transform() + (transform is stubbed out here; the port applies it with its own rand()), + plus the gWaveIncScale each one sets +Every call is checked to touch nothing but its output. +""" +import struct, sys +import numpy as np +sys.path.insert(0, __file__.rsplit('/', 1)[0]) +import macho +from plugin import Plugin +from unicorn import UC_HOOK_CODE + +MAPRGB = 0x23fd4 +TEXIDX = 0x457f8 # textureImageIndexArrays +CURTEX = 0x13dbc +CURFRAME = 0x13dc0 +WAVEINC = 0x257f0 # gWaveIncScale (2 floats) + +p = Plugin(seed=1) +k = p.k +tr = macho.func_size['_transform'][0] +k.u.hook_add(UC_HOOK_CODE, lambda u, a, s, d: k._ret(), begin=tr, end=tr + 3) +k.u.ctl_remove_cache(0, 0x80000) + +def snapshot(): + return bytearray(k.mem(0x13000, 0x759f8 - 0x13000)) + +def changed(before, after): + b = np.frombuffer(bytes(before), np.uint8); a = np.frombuffer(bytes(after), np.uint8) + idx = np.nonzero(a != b)[0] + 0x13000 + return idx + +def regions(idx): + out = set() + for a in idx[::max(1, len(idx)//200)]: + out.add(macho.symfor(int(a)).split('+')[0]) + return sorted(out) + +k.w32(CURTEX, 0) +k.w32(CURFRAME, 0); k.w32(CURFRAME + 4, 0) + +maps = np.zeros((22, 256, 4), np.uint8) +for m in range(22): + k.u.mem_write(MAPRGB, b'\x55' * 1024) + before = snapshot() + k.call(f'_makeMap{m:03d}') + after = snapshot() + idx = changed(before, after) + stray = idx[(idx < MAPRGB) | (idx >= MAPRGB + 1024)] + data = np.frombuffer(k.mem(MAPRGB, 1024), np.uint8).reshape(256, 4) + untouched = int((np.frombuffer(k.mem(MAPRGB, 1024), np.uint8) == 0x55).sum()) + maps[m] = data + print(f'map {m:03d}: stray={regions(stray) if len(stray) else "-"} 0x55-left={untouched}') + +WAVES = list(range(23)) + [31, 32, 33] +waves = {} +incs = {} +for w in WAVES: + if w >= 31: + continue + for fill in (0x00, 0xff): + k.u.mem_write(TEXIDX, bytes([fill]) * 0x8000) + k.wf(WAVEINC, -999.0) + before = snapshot() + k.call(f'_initWaveShape{w:03d}') + after = snapshot() + img = np.frombuffer(k.mem(TEXIDX, 0x4000), np.uint8).reshape(128, 128).copy() + if fill == 0: + img0 = img + else: + img1 = img + idx = changed(before, after) + stray = idx[((idx < TEXIDX) | (idx >= TEXIDX + 0x4000)) & ((idx < WAVEINC) | (idx >= WAVEINC + 4))] + partial = int((img0 != img1).sum()) + waves[w] = img0 + incs[w] = k.rf(WAVEINC) + print(f'wave {w:03d}: inc={incs[w]!r} partial-pixels={partial} stray={regions(stray) if len(stray) else "-"}') + +np.savez_compressed(sys.argv[1] if len(sys.argv) > 1 else 'tables.npz', maps=maps, + waves=np.stack([waves[w] for w in range(23)]), + wave_inc=np.array([incs[w] for w in range(23)], np.float32)) +print('saved') diff --git a/scripts/kaleidostrobe/golden.py b/scripts/kaleidostrobe/golden.py new file mode 100644 index 0000000..9b1d54c --- /dev/null +++ b/scripts/kaleidostrobe/golden.py @@ -0,0 +1,63 @@ +"""Golden data for tests/test_round84.py, produced by the ORIGINAL plugin +running under emulation (see compare.py / coverage.py).""" +import sys, hashlib, random +sys.path.insert(0, __file__.rsplit('/', 1)[0]); sys.path.insert(0, str(__import__('pathlib').Path(__file__).resolve().parents[2])) +import numpy as np +from plugin import Plugin +import compare +from lintunes.kaleidostrobe import engine as E + +def digest(a): return hashlib.sha256(np.ascontiguousarray(a).tobytes()).hexdigest()[:16] + +def pattern(seed): + y, x = np.mgrid[0:128, 0:128] + return ((x * (7 + seed) + y * (13 + 2 * seed)) ^ (x * y + seed)).astype(np.uint8) + +out = {} +# 1. end to end: 120 frames at 640x480, seed ticks 1000 +p = Plugin(rect=(0, 0, 480, 640), ticks=1000) +rnd = random.Random(11) +F = 120 +spectra = np.zeros((F, 2, 512), np.uint8); ticks = np.zeros(F, np.int64) +tex_d = []; geo = []; counts = [] +t0 = p.k.micro +for f in range(F): + spec = compare.spectra(f, rnd); spectra[f] = spec + start = len(p.k.gl) + p.k.micro += 40001 + tk = 1000 + (p.k.micro - t0) * 60 // 1_000_000; ticks[f] = tk + calls = p.frame(None, spec, dt_us=0, dticks=tk - p.k.ticks) + idx = [i for i in range(start, len(p.k.gl)) if p.k.gl[i][0] == '_glTexImage2D'] + tex_d.append([digest(np.frombuffer(p.k.textures[i], np.uint8)) for i in idx]) + tris = compare.gl_triangles(calls) + counts.append(len(tris)) + geo.append(np.array([[(*v[0], *v[1]) for v in t[2]] for t in tris], np.float32)) +out['spectra'] = spectra; out['ticks'] = ticks +out['tex_digest'] = np.array(tex_d) +out['tri_counts'] = np.array(counts) +out['tris'] = np.concatenate(geo) # (sum, 3, 5): s, t, x, y, z (eye space) +# 2. every warp, 3 iterations from zeroed counters, layer 1 +TEXIDX, CURTEX, CURFRAME = 0x457f8, 0x13dbc, 0x13dc0 +k = p.k +dd = [] +for n in E.DELTA_TABLE: + k.u.mem_write(0x13b2c, b'\0' * (0x13be4 - 0x13b2c)) + row = [] + for it in range(3): + src = pattern(n * 3 + it) + k.u.mem_write(TEXIDX + 0x8000, src.tobytes()) + k.w32(CURTEX, 1); k.w32(CURFRAME + 4, 1) + k.call(f'_calcDelta{n:03d}') + row.append(digest(np.frombuffer(k.mem(TEXIDX + 0x8000 + 0x4000, 0x4000), np.uint8))) + dd.append(row) +out['delta_digest'] = np.array(dd) +# 3. every wave-table entry, rand seeded 4321 +wd = [] +for idx in range(len(E.WAVE_TABLE)): + k.rng.srand(4321) + k.w32(CURTEX, 0); k.w32(CURFRAME, 0) + k.call(f'_initWaveShape{E.WAVE_TABLE[idx]:03d}') + wd.append(digest(np.frombuffer(k.mem(TEXIDX, 0x4000), np.uint8))) +out['wave_digest'] = np.array(wd) +np.savez_compressed(sys.argv[1], **out) +print('frames', F, 'triangles', sum(counts), 'beats seen', None) diff --git a/scripts/kaleidostrobe/macho.py b/scripts/kaleidostrobe/macho.py new file mode 100644 index 0000000..21ef125 --- /dev/null +++ b/scripts/kaleidostrobe/macho.py @@ -0,0 +1,188 @@ +"""Kaleidostrobe Mach-O PPC helper: sections, symbols, stubs, annotated disassembly.""" +import struct, sys, re +import capstone +import os +BIN = os.environ.get('KALEIDOSTROBE_BIN', os.path.expanduser( + '~/documents/misc/lil hacky bits/Kaleidostrobe.bundle/Contents/MacOS/Kaleidostrobe')) +D = open(BIN, 'rb').read() + +def u32(a): return struct.unpack('>I', D[a:a+4])[0] +def s16(v): return v - 0x10000 if v & 0x8000 else v + +sections = {} # name -> (addr,size,off,reserved1,segname) +syms = [] # (addr, sect, name) +allsyms = [] +off = 28; ncmds = struct.unpack('>I', D[16:20])[0] +dysym = None; symtab = None +seclist = [] +for _ in range(ncmds): + cmd, sz = struct.unpack('>II', D[off:off+8]) + if cmd == 1: + ns = struct.unpack('>I', D[off+48:off+52])[0] + so = off + 56 + for _ in range(ns): + sn = D[so:so+16].rstrip(b'\0').decode(); sg = D[so+16:so+32].rstrip(b'\0').decode() + a, s, o, al, ro, nr, fl, r1, r2 = struct.unpack('>9I', D[so+32:so+68]) + seclist.append((sg, sn, a, s, o, fl, r1, r2)) + sections[(sg, sn)] = (a, s, o, r1, r2) + so += 68 + elif cmd == 2: + symtab = struct.unpack('>4I', D[off+8:off+24]) + elif cmd == 11: + dysym = struct.unpack('>18I', D[off+8:off+80]) + off += sz +so, ns, stro, strs = symtab +for i in range(ns): + sx, ty, sect, desc, val = struct.unpack('>IBBhI', D[so+i*12:so+i*12+12]) + name = D[stro+sx:D.index(b'\0', stro+sx)].decode() + allsyms.append((name, ty, sect, val)) + if not (ty & 0xe0) and (ty & 0x0e) == 0x0e: + syms.append((val, sect, name)) +syms.sort() +addr2name = {} +for v, s, n in syms: + addr2name.setdefault(v, n) +funcs = [(v, n) for v, s, n in syms if s == 1] +func_size = {} +for i, (v, n) in enumerate(funcs): + func_size[n] = (v, (funcs[i+1][0] if i+1 < len(funcs) else 0x112e4) - v) + +# indirect symbols +indirectsymoff, nindirect = dysym[12], dysym[13] +ind = [u32(indirectsymoff + 4*i) for i in range(nindirect)] +stubname = {} +for (sg, sn, a, s, o, fl, r1, r2) in seclist: + typ = fl & 0xff + if typ in (6, 7): # non-lazy / lazy pointers, 4 bytes each + for k in range(s // 4): + idx = ind[r1 + k] + nm = allsyms[idx][0] if idx < len(allsyms) and idx not in (0x80000000, 0x40000000) else '?local' + stubname[a + 4*k] = ('ptr', nm) + elif typ == 8: # symbol stubs + for k in range(s // r2): + idx = ind[r1 + k] + nm = allsyms[idx][0] if idx < len(allsyms) else '?' + stubname[a + r2*k] = ('stub', nm) + +def symfor(a): + if a in addr2name: return addr2name[a] + if 0x112e4 <= a < 0x11f5c and not (0x113c0 <= a < 0x1140c): return f'K_{a:x}' + best = None + for v, s, n in syms: + if v <= a: best = (v, n) + else: break + if best: return f'{best[1]}+{a-best[0]:#x}' + return hex(a) + +def section_of(a): + for (sg, sn, sa, s, o, fl, r1, r2) in seclist: + if sa <= a < sa + s: return sn + return None + +def fval(a, dbl): + if a + (8 if dbl else 4) > len(D): return None + return struct.unpack('>d' if dbl else '>f', D[a:a+(8 if dbl else 4)])[0] + +md = capstone.Cs(capstone.CS_ARCH_PPC, capstone.CS_MODE_32 | capstone.CS_MODE_BIG_ENDIAN) + +def disasm(name, seed=None): + start, size = func_size[name] + out = [] + base = dict(seed or {}) + stack = {} + last_call_ret = None + for ins in md.disasm(D[start:start+size], start): + m, ops = ins.mnemonic, ins.op_str + note = '' + regs = [r.strip() for r in ops.split(',')] + word = u32(ins.address) + opc = word >> 26 + dest = regs[0] if regs and re.fullmatch(r'r\d+', regs[0]) else None + handled = False + if opc in (16, 18): + mm = re.search(r'0x[0-9a-f]+$', ops) + if mm: + tgt = int(mm.group(0), 16) + if word & 1: + last_call_ret = ins.address + 4 + note = stubname[tgt][1] if tgt in stubname else symfor(tgt) + if tgt != ins.address + 4: + for r in ('r0','r3','r4','r5','r6','r7','r8','r9','r10','r11','r12','ctr'): + base.pop(r, None) + base['lr'] = last_call_ret + elif not (start <= tgt < start + size): + note = symfor(tgt) + handled = True + elif m == 'mflr': + v = base.get('lr', last_call_ret) + if v is not None: base[dest] = v; note = f'= {v:#x}' + handled = True + elif m == 'mtlr': + if regs[0] in base: base['lr'] = base[regs[0]] + else: base.pop('lr', None) + handled = True + elif m == 'mtctr': + if regs[0] in base: base['ctr'] = base[regs[0]] + else: base.pop('ctr', None) + handled = True + elif m == 'mfctr': + if 'ctr' in base: base[dest] = base['ctr'] + else: base.pop(dest, None) + handled = True + elif m == 'mr': + if regs[1] in base: base[dest] = base[regs[1]] + else: base.pop(dest, None) + handled = True + elif m == 'addis': + if regs[1] in base: + base[dest] = (base[regs[1]] + (s16(word & 0xffff) << 16)) & 0xffffffff + else: base.pop(dest, None) + handled = True + elif m == 'addi': + if regs[1] in base: + a = (base[regs[1]] + s16(word & 0xffff)) & 0xffffffff + base[dest] = a; note = f'&{symfor(a)}' + else: base.pop(dest, None) + handled = True + else: + mm = re.match(r'(\S+), (-?(?:0x)?[0-9a-f]*)\((r\d+)\)$', ops) + if mm and (m.startswith('l') or m.startswith('st')): + breg = mm.group(3); rt = mm.group(1) + d16 = s16(word & 0xffff) + if breg == 'r1': + if m == 'stw': + if rt in base: stack[d16] = base[rt] + else: stack.pop(d16, None) + elif m == 'lwz': + if d16 in stack: base[rt] = stack[d16]; note = f'(spill {stack[d16]:#x})' + else: base.pop(rt, None) + elif m.startswith('l') and rt.startswith('r'): + base.pop(rt, None) + handled = True + elif breg in base: + a = (base[breg] + d16) & 0xffffffff + note = symfor(a) + loaded = None + if a in stubname and stubname[a][0] == 'ptr': + note = f'[ptr->{stubname[a][1]}]' + if section_of(a) == '__nl_symbol_ptr': + loaded = u32(a); note += f' = {symfor(loaded)}' + if m in ('lfs','lfd','stfs','stfd') and section_of(a) in ('__const','__data'): + v = fval(a, m in ('lfd','stfd')) + note += f' = {v!r}' + if m.startswith('l') and rt.startswith('r'): + if loaded is not None: base[rt] = loaded + else: base.pop(rt, None) + handled = True + else: + if m.startswith('l') and rt.startswith('r'): base.pop(rt, None) + handled = True + if not handled and dest and m not in ('cmpwi','cmplwi','cmpw','cmplw','stmw','fcmpu','stwx','stbx','sthx','stfsx','stfdx','stw','stb','sth','stfs','stfd'): + base.pop(dest, None) + out.append(f'{ins.address:5x} {m:8s} {ops:30s} {("; " + note) if note else ""}') + return '\n'.join(out) + +if __name__ == '__main__': + for n in sys.argv[1:]: + print(f'===== {n} ({func_size[n][0]:#x}, {func_size[n][1]} bytes)') + print(disasm(n)) diff --git a/scripts/kaleidostrobe/plugin.py b/scripts/kaleidostrobe/plugin.py new file mode 100644 index 0000000..198d8cc --- /dev/null +++ b/scripts/kaleidostrobe/plugin.py @@ -0,0 +1,68 @@ +"""Drive the emulated plugin through VisualPluginHandler like iTunes did.""" +import struct, sys +sys.path.insert(0, __file__.rsplit('/', 1)[0]) +from emu import KS + +def fourcc(s): return struct.unpack('>I', s.encode())[0] + +HANDLER = '_VisualPluginHandler' + + +class Plugin: + def __init__(self, seed=1, rect=(0, 0, 480, 640), ticks=1000): + k = self.k = KS(seed) + k.ticks = ticks + k.port_rect = rect + self.msg = k.alloc(0x100) + self.port = k.alloc(0x100) + self.render = k.alloc(0x802) + # init + k.call(HANDLER, fourcc('init'), self.msg, 0) + self.pd = k.r32(self.msg + 0x18) + # show: port, rect, options + k.w32(self.msg, self.port) + k.u.mem_write(self.msg + 4, struct.pack('>4h', *rect)) + k.w32(self.msg + 0xc, 0) + k.call(HANDLER, fourcc('show'), self.msg, self.pd) + # play + k.w32(self.msg, 0); k.w32(self.msg + 4, 0) + k.call(HANDLER, fourcc('vply'), self.msg, self.pd) + + def frame(self, wave=None, spec=None, dt_us=40001, dticks=None): + """One 'vrnd'. wave/spec: 2x512 uint8 lists (or None for silence).""" + k = self.k + k.micro += dt_us + k.ticks += dticks if dticks is not None else round(dt_us * 60 / 1e6) + data = bytearray(0x802) + data[0] = 2 + data[0x401] = 2 + if wave is not None: + data[1:0x401] = bytes(wave[0]) + bytes(wave[1]) + else: + data[1:0x401] = bytes([128]) * 1024 + if spec is not None: + data[0x402:0x802] = bytes(spec[0]) + bytes(spec[1]) + k.u.mem_write(self.render, bytes(data)) + k.w32(self.msg, self.render) + k.w32(self.msg + 4, 0) + start = len(k.gl) + k.call(HANDLER, fourcc('vrnd'), self.msg, self.pd) + return k.gl[start:] + + +if __name__ == '__main__': + import random + p = Plugin() + print('pd', hex(p.pd), 'gl calls during show:', len(p.k.gl)) + for name, args in p.k.gl[:40]: + print(' ', name, args) + rnd = random.Random(0) + for i in range(3): + wave = [[rnd.randrange(64, 192) for _ in range(512)] for _ in range(2)] + spec = [[rnd.randrange(0, 200) for _ in range(512)] for _ in range(2)] + calls = p.frame(wave, spec) + print('frame', i, len(calls)) + from collections import Counter + print(Counter(n for n, a in calls)) + for c in calls[:60]: + print(' ', c) diff --git a/setup.py b/setup.py index 57a4c6f..dcc5c84 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,7 @@ setup( "lintunes.export": ["templates/*"], "lintunes.android": ["andTunes.apk", "andTunes.json"], "lintunes.andtunes": ["buttons/*.png"], + "lintunes.kaleidostrobe": ["tables.npz"], }, install_requires=[ "PyQt6>=6.8.0", # QAudioBufferOutput (visualizer) needs Qt 6.8+ diff --git a/tests/kaleidostrobe_golden.npz b/tests/kaleidostrobe_golden.npz new file mode 100644 index 0000000..190af92 Binary files /dev/null and b/tests/kaleidostrobe_golden.npz differ diff --git a/tests/test_round84.py b/tests/test_round84.py new file mode 100644 index 0000000..ff70ed5 --- /dev/null +++ b/tests/test_round84.py @@ -0,0 +1,320 @@ +"""Round 84: Kaleidostrobe, the 2002 iTunes visualizer, as the fourth style. + +``kaleidostrobe_golden.npz`` was recorded from the *original* PowerPC plugin, +run under emulation through its own VisualPluginHandler: every frame's two +uploaded textures (as digests) and every triangle it handed OpenGL, for a +fixed run of spectra; plus the output of each warp (calcDelta) and each +wave-table entry. The port has to reproduce them exactly. + +1. The engine against the original: textures byte for byte, geometry. +2. The pieces: warps, wave shapes, transform's quirks, Darwin's rand(). +3. The renderer: 25 fps, the GL picture, dim mode, pause. +4. The style in the app. +""" +import hashlib +import math +from pathlib import Path + +import numpy as np +import pytest + +from PyQt6.QtCore import QObject, QRectF, pyqtSignal +from PyQt6.QtGui import QColor, QImage, QPainter + +from lintunes.kaleidostrobe import engine as ks +from lintunes.gui import visualizer_styles as vs +from lintunes.gui.visualizer_kaleido import KaleidostrobeRenderer, itunes_spectrum +from lintunes.preferences import Preferences + + +GOLDEN = np.load(Path(__file__).with_name("kaleidostrobe_golden.npz")) +RATE = 44100 + + +def _digest(a): + return hashlib.sha256(np.ascontiguousarray(a).tobytes()).hexdigest()[:16] + + +def _pattern(seed): + y, x = np.mgrid[0:128, 0:128] + return ((x * (7 + seed) + y * (13 + 2 * seed)) ^ (x * y + seed)).astype(np.uint8) + + +def _rotz(deg): + r = math.radians(deg) + c, s = math.cos(r), math.sin(r) + return np.array([[c, -s, 0, 0], [s, c, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]) + + +def _lift(z): + m = np.eye(4) + m[2, 3] = z + return m + + +def _eye_space(frame): + """Each triangle as (s, t, x, y, z) × 3 in eye space, the way the + golden file recorded the original's GL calls.""" + out = [] + for layer in frame.layers: + base = _lift(-ks.EYE_Z) @ _lift(layer.lift) @ _rotz(layer.turn) + for tri in layer.triangles: + m = base @ _rotz(tri.turn) + out.append([(*tri.st[i], *(m @ np.array((*tri.xyz[i], 1.0)))[:3]) + for i in range(3)]) + return np.array(out) + + +# -------------------------------------------------------------------------- +# 1. The engine against the original +# -------------------------------------------------------------------------- + +class TestAgainstTheOriginal: + def test_every_frame_matches(self): + engine = ks.Kaleidostrobe(seed=1000, ticks=1000) # srand(TickCount()) + engine.set_size(640, 480) + counts = GOLDEN["tri_counts"] + offsets = np.concatenate([[0], np.cumsum(counts)]) + for f, (spectrum, ticks) in enumerate(zip(GOLDEN["spectra"], GOLDEN["ticks"])): + frame = engine.step(spectrum, int(ticks)) + for tex in (0, 1): + assert _digest(frame.textures[tex]) == GOLDEN["tex_digest"][f][tex], \ + f"frame {f}, texture {tex}" + ours = _eye_space(frame) + theirs = GOLDEN["tris"][offsets[f]:offsets[f + 1]] + assert ours.shape == theirs.shape, f"frame {f}" + np.testing.assert_allclose(ours[..., :2], theirs[..., :2], atol=1e-4) + np.testing.assert_allclose(ours[..., 2:], theirs[..., 2:], atol=1e-3) + + def test_the_run_saw_beats_and_switches(self): + """The recorded run must exercise more than the opening state.""" + engine = ks.Kaleidostrobe(seed=1000, ticks=1000) + deltas = set() + for spectrum, ticks in zip(GOLDEN["spectra"], GOLDEN["ticks"]): + engine.step(spectrum, int(ticks)) + deltas.add(tuple(engine.cur_delta)) + assert engine.beat_count >= 8 + assert len(deltas) > 1 + + +# -------------------------------------------------------------------------- +# 2. The pieces +# -------------------------------------------------------------------------- + +class TestPieces: + @pytest.mark.parametrize("i, n", list(enumerate(ks.DELTA_TABLE))) + def test_every_warp_matches(self, i, n): + state = {} + for it in range(3): + out = ks.delta(n, _pattern(n * 3 + it), state) + assert _digest(out) == GOLDEN["delta_digest"][i][it], f"iteration {it}" + + @pytest.mark.parametrize("idx", range(len(ks.WAVE_TABLE))) + def test_every_wave_matches(self, idx): + engine = ks.Kaleidostrobe(seed=1, ticks=1000) + engine.rng = ks.ParkMiller(4321) + engine.cur_wave[0] = idx + engine._init_wave(0) + assert _digest(engine.buffers[0, 0]) == GOLDEN["wave_digest"][idx] + + def test_rand_is_darwins(self): + rng = ks.ParkMiller(1) + assert [rng.rand() for _ in range(3)] == [16807, 282475249, 1622650073] + + def test_flip_diag_keeps_its_off_by_one(self): + img = np.arange(128 * 128, dtype=np.int64).reshape(128, 128) % 251 + img = img.astype(np.uint8) + before = img.copy() + ks.flip_diag(img) + assert img[10, 3] == before[3, 10] # transposed… + assert img[10, 9] == before[10, 9] # …except beside the diagonal + assert img[9, 10] == before[9, 10] + + def test_inside_out_flips_each_half(self): + img = np.repeat(np.arange(128, dtype=np.uint8)[:, None], 128, 1) + ks.inside_out(img) + assert img[0, 0] == 63 and img[63, 0] == 0 + assert img[64, 0] == 127 and img[127, 0] == 64 + + def test_a_reselected_warp_resumes_its_own_counter(self): + engine = ks.Kaleidostrobe(seed=3, ticks=1000) + engine.cur_delta = [0, 0] # calcDelta000 on both layers + for _ in range(5): + engine._update_textures() + engine.cur_delta[0] = 1 # another warp: its own counter, 0 + engine._update_textures() + assert engine.delta_state[0][1] == {'h': 1, 'v': 1} + assert engine.delta_state[0][0] == {'h': 5} + + +# -------------------------------------------------------------------------- +# 3. The renderer +# -------------------------------------------------------------------------- + +def _frame(dt=0.016, seconds=4096 / RATE, hz=60.0): + t = np.arange(int(seconds * RATE)) / RATE + samples = (0.4 * np.sin(2 * np.pi * hz * t)).astype(np.float32) + return vs.Frame(samples, RATE, np.zeros(0, np.float32), dt) + + +def _paint(renderer, w, h, background="white", color="blue"): + img = QImage(w, h, QImage.Format.Format_RGB32) + img.fill(QColor(background)) + painter = QPainter(img) + renderer.paint(painter, QRectF(0, 0, w, h), QColor(color), QColor("gray")) + painter.end() + a = np.frombuffer(img.constBits().asstring(img.sizeInBytes()), np.uint8) + return a.reshape(h, w, 4)[..., [2, 1, 0]].astype(float) + + +def _bilinear(tex, s, t): + u, v = s * 128 - 0.5, t * 128 - 0.5 + u0, v0 = np.floor(u).astype(int), np.floor(v).astype(int) + fu, fv = (u - u0)[..., None], (v - v0)[..., None] + T = tex.astype(float) + + def at(y, x): + return T[y % 128, x % 128] + return (at(v0, u0) * (1 - fu) * (1 - fv) + at(v0, u0 + 1) * fu * (1 - fv) + + at(v0 + 1, u0) * (1 - fu) * fv + at(v0 + 1, u0 + 1) * fu * fv) + + +def _gl_reference(frame, w, h): + """What OpenGL drew: cast a ray through each pixel centre, take the + nearest triangle, interpolate (s, t), GL_LINEAR + GL_REPEAT, blend.""" + f = 1 / math.tan(math.radians(ks.FOVY) / 2) + px, py = np.meshgrid(np.arange(w) + 0.5, np.arange(h) + 0.5) + d = np.stack([(2 * px / w - 1) * (w / h) / f, (1 - 2 * py / h) / f, + -np.ones_like(px)], -1) + out = np.zeros((h, w, 3)) + for layer in frame.layers: + depth = np.full((h, w), -np.inf) + col = np.zeros((h, w, 3)) + hit = np.zeros((h, w), bool) + base = _lift(-ks.EYE_Z) @ _lift(layer.lift) @ _rotz(layer.turn) + for tri in layer.triangles: + m = base @ _rotz(tri.turn) + p = np.array([(m @ np.array((*v, 1.0)))[:3] for v in tri.xyz]) + e1, e2 = p[1] - p[0], p[2] - p[0] + n = np.cross(e1, e2) + with np.errstate(divide='ignore', invalid='ignore'): + x = d * ((p[0] @ n) / (d @ n))[..., None] + d00, d01, d11 = e1 @ e1, e1 @ e2, e2 @ e2 + v2 = x - p[0] + den = d00 * d11 - d01 * d01 + b1 = (d11 * (v2 @ e1) - d01 * (v2 @ e2)) / den + b2 = (d00 * (v2 @ e2) - d01 * (v2 @ e1)) / den + b0 = 1 - b1 - b2 + take = (b0 >= 0) & (b1 >= 0) & (b2 >= 0) & (x[..., 2] > depth) + st = np.array(tri.st) + s = b0 * st[0, 0] + b1 * st[1, 0] + b2 * st[2, 0] + t = b0 * st[0, 1] + b1 * st[1, 1] + b2 * st[2, 1] + c = _bilinear(frame.textures[layer.texture][..., :3], s, t) + col[take] = c[take] + depth[take] = x[..., 2][take] + hit |= take + out[hit] = out[hit] * (1 - layer.alpha) + col[hit] * layer.alpha + return out + + +class TestRenderer: + def test_runs_at_the_plugins_25_fps(self, qapp): + r = KaleidostrobeRenderer() + for _ in range(125): # 2 s of 16 ms widget ticks + r.advance(_frame()) + assert r._engine.frame_count == 50 + + def test_a_stall_is_not_replayed(self, qapp): + r = KaleidostrobeRenderer() + r.advance(_frame()) + r.advance(_frame(dt=5.0)) # the GUI thread froze for 5 s + assert r._engine.frame_count <= 1 + 3 + + @pytest.mark.parametrize("frames", [3, 30, 90]) + def test_paints_what_opengl_drew(self, qapp, frames): + r = KaleidostrobeRenderer() + r.advance(_frame()) + r._engine.set_size(96, 60) + for _ in range(frames): + r.advance(_frame(dt=0.04)) + ours = _paint(r, 96, 60) + gl = _gl_reference(r._frame, 96, 60) + assert np.abs(ours - gl).max() <= 3 + + def test_fills_any_shape(self, qapp): + r = KaleidostrobeRenderer() + for _ in range(5): + r.advance(_frame(dt=0.04)) + for w, h in ((140, 36), (300, 900), (4, 4)): + img = _paint(r, w, h, background="magenta") + magenta = (img[..., 0] == 255) & (img[..., 1] == 0) & (img[..., 2] == 255) + assert not magenta.any() # the cone reaches every corner + + def test_dim_mode_is_gray(self, qapp): + r = KaleidostrobeRenderer() + for _ in range(5): + r.advance(_frame(dt=0.04)) + r.dimmed = True # the widget then passes its gray + img = _paint(r, 120, 80, color="gray") + assert np.abs(img[..., 0] - img[..., 2]).max() <= 2 # gray tint + + def test_pause_blanks_but_keeps_the_plugin(self, qapp): + r = KaleidostrobeRenderer() + for _ in range(5): + r.advance(_frame(dt=0.04)) + engine, count = r._engine, r._engine.frame_count + r.clear() + img = _paint(r, 40, 30, background="white") + assert (img == 255).all() # nothing drawn + r.advance(_frame(dt=0.04)) + assert r._engine is engine and engine.frame_count == count + 1 + + @pytest.mark.parametrize("rate", [44100, 48000, 96000]) + def test_spectrum_bins_are_itunes_43_hz(self, rate): + t = np.arange(4096) / rate + tone = (0.5 * np.sin(2 * np.pi * 1000 * t)).astype(np.float32) + spec = itunes_spectrum(tone, rate) + assert spec.shape == (2, 512) and spec.dtype == np.uint8 + assert abs(int(spec[0].argmax()) - round(1000 / (44100 / 1024))) <= 1 + assert (spec[0] == spec[1]).all() + + def test_silence_is_zero(self): + assert not itunes_spectrum(np.zeros(4096, np.float32), RATE).any() + + +# -------------------------------------------------------------------------- +# 4. In the app +# -------------------------------------------------------------------------- + +class _FakePlayer(QObject): + audio_buffer = pyqtSignal(object) + playing_changed = pyqtSignal(bool) + track_changed = pyqtSignal(object) + + def is_playing(self): + return False + + +class _LastFm(QObject): + login_finished = pyqtSignal(bool, str) + status_message = pyqtSignal(str) + + def is_logged_in(self): + return False + + +class TestInTheApp: + def test_is_a_style(self, qapp, tmp_path): + from lintunes.gui.visualizer import VisualizerWidget + prefs = Preferences(tmp_path) + prefs.set("visualizer_style", "kaleidostrobe") + vis = VisualizerWidget(_FakePlayer(), prefs) + assert isinstance(vis._renderer, KaleidostrobeRenderer) + + def test_preferences_offers_it(self, qapp, tmp_path): + from lintunes.gui.preferences_dialog import PreferencesDialog + prefs = Preferences(tmp_path) + dialog = PreferencesDialog(prefs, _LastFm()) + dialog._viz_radios["kaleidostrobe"].setChecked(True) + assert prefs.get("visualizer_style") == "kaleidostrobe" + assert dialog._viz_pages.currentIndex() == 3