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:
@@ -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,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.52.0"
|
||||
__version__ = "0.53.0"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Kaleidostrobe, the 2002 iTunes visualizer by Shehryar Lasi, ported from
|
||||
its PowerPC binary. See ``engine`` for the simulation."""
|
||||
@@ -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.
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,)))
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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.
@@ -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
|
||||
Reference in New Issue
Block a user