ludic/tools/migrate/floatbits.py
Orkuncakilkaya cc89fc37a4 feat(lang): strict numbers in float files; render3d on float
A numbers float file adapts decimal literals to a fixed operand or slot, and refuses to
promote a computed int to a float implicitly: there it is almost always float bits. Explicit
float(x) is always allowed.

render3d's numbers are float, converted by tools/migrate/floatbits.py - a whole-program
inference of which ints carried IEEE bits (union-find over flows, calls, returns, buffers,
nested buffers and lexical scopes) and a rewriter to operators, Math.* and float literals,
with float_bits / float_from_bits left only where bits really cross (runtime scratch
buffers, mixed buffers). Seed regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 17:13:25 +03:00

332 lines
16 KiB
Python

"""Rewrite IEEE-float-bits-in-an-int code to Ludic's `float`.
python3 floatbits.py <game dir> <ludic home> [--dry]
Converts <game>/src, <game>/lab and <home>/packages/ludic.render3d; <home>/runtime stays as it is
and is where raw bits remain (float_bits / float_from_bits at the calls into it)."""
import struct
import sys
from floatinfer import Program, FLOAT_OPS
from ludic_ast import PREC, walk
BIN = {'f_add': ('+', 9), 'f_sub': ('-', 9), 'f_mul': ('*', 10), 'f_div': ('/', 10), 'f_mod': ('%', 10)}
CMP = {'f_ls': ('<', 3), 'f_gt': ('>', 3)}
MATH = {'f_abs': 'abs', 'f_min': 'min', 'f_max': 'max', 'f_clamp': 'clamp', 'f_sqrt': 'sqrt', 'f_sin': 'sin',
'f_cos': 'cos', 'f_tan': 'tan', 'f_atan2': 'atan2', 'f_floor': 'floor', 'f_pow': 'pow', 'f_exp': 'exp',
'f_log': 'log', 'f_lerp': 'lerp', 'f_rad': 'deg_to_rad'}
CONST = {'F_ZERO': '0.0', 'F_ONE': '1.0', 'F_TWO': '2.0', 'F_HALF': '0.5', 'F_PI': 'PI'}
CONV = {'f_to_int': 'int', 'f_fx': 'fixed', 'f32_to_fx': 'fixed', 'fx_to_f32': 'float', 'f_from_int': 'float'}
def plain(v):
"""a float as decimal text Ludic reads: no exponent, the shortest that round-trips as f32"""
import math
if not math.isfinite(v): return '1000000000.0' # a NaN or infinite sentinel: never equal to a real value
for digits in range(1, 12):
t = f'{v:.{digits}g}'
if struct.unpack('>f', struct.pack('>f', float(t)))[0] == struct.unpack('>f', struct.pack('>f', v))[0]: break
from decimal import Decimal
t = format(Decimal(t), 'f')
return t if '.' in t else t + '.0'
def flit(text, bits=False):
"""a literal's text as a float literal; with bits, a big int or hex is a float's bit pattern"""
t = text.replace('_', '')
if t.startswith('0x') and bits:
return plain(struct.unpack('>f', struct.pack('>I', int(t, 16) & 0xffffffff))[0])
if t.isdigit() and int(t) > (1 << 20) and bits:
return plain(struct.unpack('>f', struct.pack('>I', int(t) & 0xffffffff))[0])
if t.startswith('0x'): return plain(float(int(t, 16)))
return t if '.' in t else t + '.0'
INT_OPS = ('+', '-', '*', '/', '%', '|', '&', '^', '<<', '>>')
class Rewriter:
def __init__(self, prog):
self.p = prog
uf = prog.uf
self.fl = lambda k: k is not None and uf.is_float(k) and not uf.is_int(k)
self.to_float_lit = set() # num nodes that stand where a float is
self.wrap_bits = set() # nodes whose float value leaves as bits
self.wrap_from = set() # nodes whose bits arrive as a float
self.plan()
# ---- where literals and bits cross --------------------------------------
def value_into(self, node, target_float):
"""a value node flows into a slot: note a literal to float, or a crossing"""
if node is None: return
inner = node.kids[0] if node.kind == 'paren' else node
lit = inner if inner.kind == 'num' else (inner.kids[0] if inner.kind == 'unary' and inner.val == '-' and inner.kids and inner.kids[0].kind == 'num' else None)
k = self.p.node_key.get(id(node))
if self.mixed_read(inner):
if target_float: self.wrap_from.add(id(node))
return
if k is not None and k[0] == 'RC' and inner.kind == 'call' and inner.kids[0].kind == 'id':
f = self.p.funcs.get(inner.kids[0].val)
if f and f.ret and self.fl(f.ret.key) != self.fl(k): k = f.ret.key
if target_float:
if lit is not None: self.to_float_lit.add(id(lit))
elif k is not None and not self.fl(k) and (self.p.uf.is_int(k) or k[0] in ('B', 'RC', 'RD')): self.wrap_from.add(id(node))
else:
if k is not None and self.fl(k): self.wrap_bits.add(id(node))
def plan(self):
p = self.p
for fp in p.files:
if fp.runtime: continue
for s in fp.stmts:
fn = s.fn
if s.kind == 'let' and s.b is not None:
self.value_into(s.b, self.fl(s.decl.key))
elif s.kind == 'assign':
tk = p.node_key.get(id(s.a))
if s.a.kind == 'index':
ck = p.node_key.get(id(s.a.kids[0]))
tf = ck is not None and ck not in p.mixed and self.fl(('E', ck))
self.value_into(s.b, tf)
else:
self.value_into(s.b, self.fl(tk))
elif s.kind == 'return':
f = p.funcs.get(fn)
if f and f.ret: self.value_into(s.a, self.fl(f.ret.key))
for d in fp.globals.values():
if d.init is not None: self.value_into(d.init, self.fl(d.key))
for fields in fp.props.values():
for d in fields.values():
if d.init is not None: self.value_into(d.init, self.fl(d.key))
for f, i, k, a in p.calls:
self.value_into(a, self.fl(f.params[i].key))
for call, a, k in p.bound_args:
if self.fl(k) and a.kind != 'named': self.wrap_bits.add(id(a))
for n_id, k in p.bound_rets.items():
if self.fl(k): self.wrap_from.add(n_id)
# comparisons between a float and a literal
for fp in p.files:
if fp.runtime: continue
for s in fp.stmts:
for e in (s.a, s.b):
if e is None: continue
for n in walk(e):
if n.kind == 'bin' and n.val in ('==', '!=', '<', '>', '<=', '>='):
a, b = n.kids
ka, kb = p.node_key.get(id(a)), p.node_key.get(id(b))
if self.fl(ka) and b.kind == 'num': self.to_float_lit.add(id(b))
if self.fl(kb) and a.kind == 'num': self.to_float_lit.add(id(a))
if n.kind == 'call' and n.kids[0].kind == 'id' and (n.kids[0].val in BIN or n.kids[0].val in CMP or n.kids[0].val in MATH or n.kids[0].val == 'f_neg'):
for arg in n.kids[1:]:
if self.mixed_read(arg): self.wrap_from.add(id(arg))
if n.kind == 'bin' and n.val in ('==', '!=', '<', '>', '<=', '>='):
a, b = n.kids
ka, kb = self.kind(a), self.kind(b)
if ka == 'f' and kb == 'i' and b.kind != 'num': self.wrap_bits.add(id(a))
if kb == 'f' and ka == 'i' and a.kind != 'num': self.wrap_bits.add(id(b))
if n.kind == 'bin' and n.val in INT_OPS:
a, b = n.kids
ka, kb = self.kind(a), self.kind(b)
bitwise = n.val in ('|', '&', '^', '<<', '>>')
if ka == 'f' and (bitwise or kb == 'i'): self.wrap_bits.add(id(a))
if kb == 'f' and (bitwise or ka == 'i'): self.wrap_bits.add(id(b))
if n.kind == 'call' and n.kids[0].kind == 'id' and n.kids[0].val in ('push',):
args = n.kids[1:]
if len(args) == 2:
ck = p.node_key.get(id(args[0]))
if ck is not None and ck not in p.mixed and self.fl(('E', ck)): self.value_into(args[1], True)
# ---- rendering ----------------------------------------------------------
def render(self, n, src):
"""(text, precedence) of a node after the rewrite"""
t, pr = self.render_inner(n, src)
if id(n) in self.wrap_bits: return f'float_bits({t})', 12
if id(n) in self.wrap_from: return f'float_from_bits({t})', 12
return t, pr
def need(self, kid, src, minp):
t, pr = self.render(kid, src)
return f'({t})' if pr < minp else t
def render_inner(self, n, src):
k = n.kind
if k == 'num':
if id(n) in self.to_float_lit: return flit(n.val, bits=True), 12
return n.val, 12
if k == 'id':
if n.val in CONST: return CONST[n.val], 12
return n.val, 12
if k == 'call' and n.kids[0].kind == 'id':
name = n.kids[0].val
args = n.kids[1:]
if name in BIN and len(args) == 2:
op, pr = BIN[name]
return f'{self.need(args[0], src, pr)} {op} {self.need(args[1], src, pr + 1)}', pr
if name in CMP and len(args) == 2:
op, pr = CMP[name]
return f'{self.need(args[0], src, pr + 1)} {op} {self.need(args[1], src, pr + 1)}', pr
if name == 'f_neg' and len(args) == 1:
return f'-{self.need(args[0], src, 11)}', 11
if name in MATH:
return f'Math.{MATH[name]}(' + ', '.join(self.render(a, src)[0] for a in args) + ')', 12
if name == 'fx_to_f32' and len(args) == 1 and not self.is_fixed(args[0]):
# Q16.16 bits in an int: float(x) of it would be the bits as a number
return f'float({self.render(args[0], src)[0]}) / 65536.0', 10
if name in CONV and len(args) == 1:
return f'{CONV[name]}({self.render(args[0], src)[0]})', 12
if name == 'fl' and len(args) == 1:
a = args[0]
if a.kind == 'num': return flit(a.val), 12
if a.kind == 'unary' and a.val == '-' and a.kids[0].kind == 'num': return '-' + flit(a.kids[0].val), 11
return f'float({self.render(a, src)[0]})', 12
if name == 'fi' and len(args) == 1:
a = args[0]
if a.kind == 'num': return flit(a.val), 12
if a.kind == 'unary' and a.val == '-' and a.kids[0].kind == 'num': return '-' + flit(a.kids[0].val), 11
return f'float({self.render(a, src)[0]})', 12
if name == 'fr' and len(args) == 2:
parts = []
for a in args:
parts.append(flit(a.val) if a.kind == 'num' else f'float({self.render(a, src)[0]})')
return f'{parts[0]} / {parts[1]}', 10
if name == 'f_neg1' and not args: return '-1.0', 11
if name in ('words', 'ints_n') and self.float_container(n):
return f'{"floats" if name == "words" else "floats_n"}(' + ', '.join(self.render(a, src)[0] for a in args) + ')', 12
if k == 'new' and n.val == '[]int' and self.float_container(n): return 'new []float', 12
if k == 'new' and n.val in ('[]words', '[][]int') and self.nested_float(('A', id(n))):
return ('new []floats' if n.val == '[]words' else 'new [][]float'), 12
if not n.kids: return src[n.s:n.e], self.prec_of(n)
# splice the kids into the node's own text
out, pos = [], n.s
for idx, kid in enumerate(n.kids):
if kid.s < pos or kid.s < 0: continue
out.append(src[pos:kid.s])
if n.kind == 'bin':
pr = PREC[n.val]
t = self.need(kid, src, pr if idx == 0 else pr + 1)
elif n.kind == 'unary':
t = self.need(kid, src, 11)
elif n.kind in ('member', 'index', 'slice') and idx == 0 or n.kind == 'call' and idx == 0:
t = self.need(kid, src, 12)
else:
t = self.render(kid, src)[0]
out.append(t)
pos = kid.e
out.append(src[pos:n.e])
return ''.join(out), self.prec_of(n)
def nested_float(self, k):
ik = ('E', k)
return ik not in self.p.mixed and self.fl(('E', ik))
def mixed_read(self, n):
"""an element of a buffer that holds both kinds: its bits, whatever the inference joined it to"""
if n.kind != 'index': return False
ck = self.p.node_key.get(id(n.kids[0]))
return ck is not None and ck in self.p.mixed
def is_fixed(self, n):
k = self.p.node_key.get(id(n))
if k is not None and self.p.types.get(k, '') == 'fixed': return True
return n.kind == 'call' and n.kids[0].kind == 'id' and n.kids[0].val in ('f_fx', 'f32_to_fx')
def kind(self, n):
"""'f', 'i', 'lit' or None: what a node carries, as far as the inference knows"""
if n.kind == 'num': return 'i' if '.' not in n.val else 'lit'
if n.kind == 'paren': return self.kind(n.kids[0])
if self.mixed_read(n): return 'i'
if n.kind == 'bin' and n.val in INT_OPS:
ks = {self.kind(k) for k in n.kids} - {'lit', None}
return ks.pop() if len(ks) == 1 else None
k = self.p.node_key.get(id(n))
if k is None: return None
if self.fl(k): return 'f'
if self.p.uf.is_int(k): return 'i'
return None
def float_container(self, n):
k = self.p.node_key.get(id(n))
return k is not None and k not in self.p.mixed and self.fl(('E', k))
def prec_of(self, n):
if n.kind == 'bin': return PREC[n.val]
if n.kind == 'unary': return 11
return 12
# ---- files --------------------------------------------------------------
def roots(self, fp):
"""the outermost expression nodes of a file, each once"""
seen = []
for s in fp.stmts:
for e in (s.a, s.b):
if e is not None: seen.append(e)
if s.decl is not None and s.decl.init is not None and s.decl.init is not s.b: seen.append(s.decl.init)
for d in fp.globals.values():
if d.init is not None: seen.append(d.init)
for fields in fp.props.values():
for d in fields.values():
if d.init is not None: seen.append(d.init)
seen.sort(key=lambda n: (n.s, -n.e))
out, end = [], -1
for n in seen:
if n.s >= end:
out.append(n)
end = n.e
return out
def decl_edits(self, fp):
p = self.p
edits = []
decls = list(fp.globals.values())
for fields in fp.props.values(): decls += list(fields.values())
for f in fp.funcs.values():
if f.file != fp.path: continue
decls += list(f.params)
if f.ret: decls.append(f.ret)
decls += list(f.locals.values())
for d in decls:
if d.ts is None or d.ts < 0 or not d.ty: continue
if d.ty == 'int' and self.fl(d.key): edits.append((d.ts, d.te, 'float'))
elif d.ty in ('words', '[]int') and d.key not in p.mixed and self.fl(('E', d.key)):
edits.append((d.ts, d.te, 'floats' if d.ty == 'words' else '[]float'))
elif d.ty in ('[]words', '[][]int') and self.nested_float(d.key):
edits.append((d.ts, d.te, '[]floats' if d.ty == '[]words' else '[][]float'))
return edits
def rewrite(self, fp):
src = fp.src
edits = self.decl_edits(fp)
for n in self.roots(fp):
t, _ = self.render(n, src)
if t != src[n.s:n.e]: edits.append((n.s, n.e, t))
edits.sort()
out, pos = [], 0
for s, e, t in edits:
if s < pos: continue
out.append(src[pos:s])
out.append(t)
pos = e
out.append(src[pos:])
return ''.join(out)
def main():
game, home = sys.argv[1], sys.argv[2]
dry = '--dry' in sys.argv
prog = Program([game + '/src', game + '/lab', home + '/packages/ludic.render3d'], [home + '/runtime/native']).run()
rw = Rewriter(prog)
changed = 0
for fp in prog.files:
if fp.runtime: continue
new = rw.rewrite(fp)
if new != fp.src:
changed += 1
if not dry: open(fp.path, 'w').write(new)
print(f'{changed} files rewritten')
if dry:
import difflib
for fp in prog.files:
if fp.runtime or not any(x in fp.path for x in sys.argv[3:] if not x.startswith('--')): continue
new = rw.rewrite(fp)
for l in difflib.unified_diff(fp.src.split('\n'), new.split('\n'), lineterm='', n=0): print(l)
if __name__ == '__main__':
main()