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
+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)