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:
@@ -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)
|
||||
Reference in New Issue
Block a user