v0.53.0: Kaleidostrobe, the 2002 iTunes visualizer, as the fourth style

Reverse-engineered from its PowerPC binary and checked against the original
running under emulation: textures byte for byte and every triangle, over
3,600 frames. 25 fps like the plugin; its colour maps and wave images are
extracted from its own code; tests/kaleidostrobe_golden.npz was recorded
from the original.
This commit is contained in:
2026-10-14 12:02:22 -07:00
parent 6178f5d0f7
commit e38c751e39
21 changed files with 2109 additions and 3 deletions
+28
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.52.0"
__version__ = "0.53.0"
+12
View File
@@ -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)
+3
View File
@@ -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):
+198
View File
@@ -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()
+7 -1
View File
@@ -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)
+2
View File
@@ -0,0 +1,2 @@
"""Kaleidostrobe, the 2002 iTunes visualizer by Shehryar Lasi, ported from
its PowerPC binary. See ``engine`` for the simulation."""
+706
View File
@@ -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<n>: 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<n> 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)
Binary file not shown.
+1 -1
View File
@@ -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.
+21
View File
@@ -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.
+107
View File
@@ -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)
+45
View File
@@ -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)
+253
View File
@@ -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,)))
+85
View File
@@ -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')
+63
View File
@@ -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)
+188
View File
@@ -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))
+68
View File
@@ -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)
+1
View File
@@ -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+
Binary file not shown.
+320
View File
@@ -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