feat(lang): float and double types with ordinary operators

`float` (32-bit) and `double` (64-bit) with + - * / %, comparisons and unary minus.
Decimal literals take their type from context and stay `fixed` elsewhere; int and long
promote implicitly (LUDIC_WARN_FLOAT_PROMOTE=1 lists every promotion). float(), double(),
int(), long() and fixed() convert; floats(n)/doubles(n) buffers; float fields, globals,
constants and parameters; Math.* computes in float for float arguments; string/print
write the shortest round-tripping decimal; float_bits/float_from_bits expose the bits.
@deterministic code may not use floats. The f_* runtime helpers stay as they are.

Editors know the new type words; the JetBrains plugin is 1.5.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-16 16:02:03 +03:00
parent f92d7f89c6
commit e010c2cecc
40 changed files with 67726 additions and 57272 deletions

1
.gitignore vendored
View file

@ -26,6 +26,7 @@ tools/editors/vscode/node_modules/
tools/editors/vscode/*.vsix
tools/editors/jetbrains/.gradle/
tools/editors/jetbrains/build/
tools/editors/jetbrains/.kotlin/
# IntelliJ plugin SDK sandbox (tools/editors/jetbrains)
.intellijPlatform/

View file

@ -191,7 +191,9 @@ for a complete title screen.
| `countdown` | an `int` component field the engine steps toward 0 once per Update | `i32` |
| a bare `enum` | its variants, as an `int` | `i32` |
| `IVec2` | an integer (x, y) pair by value — `v.x`, `v.y`, `IVec2.make/add/sub/…` | `i64` |
| `fixed` | Q16.16 fixed-point | `i32` |
| `fixed` | Q16.16 fixed-point — deterministic | `i32` |
| `float` | IEEE single-precision floating point | `float` |
| `double` | IEEE double-precision floating point | `double` |
| `bool` | boolean | `i32` |
| `entity` | entity handle | `i32` |
| `string` | text (a string literal, an interpolation, a concatenation) | `ptr` |
@ -201,6 +203,7 @@ for a complete title screen.
| `words` | buffer of 32-bit words — `w[i]` reads/writes an `int` | `ptr` |
| `fixeds` | buffer of `fixed` values — `f[i]` reads/writes a `fixed` | `ptr` |
| `pointers` | buffer of pointers — `p[i]` reads/writes a `pointer` | `ptr` |
| `floats` / `doubles` | buffer of floats / doubles — `floats(n)`, `v[i]` | `ptr` |
Allocate raw buffers with `bytes(n)` (n bytes) or `words(n)` (n 32-bit words);
both return a pointer you index with `buf[i]` — retype the binding (`words` /
@ -214,6 +217,44 @@ point (`1.5`) is `fixed`. Arithmetic on two `fixed` values lowers to
`fxmul`/`fxdiv`; mixing `int` and `fixed` promotes the `int`. Convert with
`fixed(i)` (int→fixed) and `floor(f)` (fixed→int).
### Floating point
`float` and `double` are ordinary IEEE numbers with ordinary operators, for
rendering, GPU data and any math that needs more range than `fixed`:
```ludic
program Shade {
function falloff(dist: float, radius: float) -> float {
let k = Math.clamp(1.0 - dist / radius, 0, 1)
return k * k
}
entry {
let light: float = falloff(2, 8) # ints promote to float
print(light * 0.5) # 0.28125
}
}
```
- **Literals take their type from context.** `1.5` is a `float` where a float is
expected — a typed binding, a parameter, a field, the other operand — and
exactly that decimal, not its Q16.16 approximation. With no float in sight it
stays `fixed`, so existing code keeps its meaning. A whole literal expression
(`1.0 / 3.0`) is evaluated in the context's type.
- **Promotion.** `int` and `long` promote to the float type of the other operand;
`float` with `double` promotes to `double`.
- **Explicit conversions.** `float(x)`, `double(x)`, `int(x)` (truncates toward
zero), `long(x)`, `fixed(x)` (truncated to Q16.16). `fixed` and the float types never
mix silently, and a `double` narrows to `float` only through `float(x)`.
- **`Math.*`** computes in float when given one (`Math.sqrt(2.0 * x)`) and answers
in that type — `Math.floor(x)` of a float is a float; `sign` returns `int`.
- **Text.** `string(x)`, `print(x)` and interpolation write the shortest decimal
that reads back as the same value: `0.3`, `2.0`, `0.30000000000000004`.
- **Bits.** `float_bits(x)` / `float_from_bits(i)` (and the `double_` pair) move
the IEEE pattern to and from an integer, for files and packets.
- **Determinism.** A `@deterministic` function or handler cannot compute with
floats — the compiler says so — because IEEE results can differ between
machines. Lockstep simulation stays in `fixed`.
## Properties, entities, queries
```ludic

11
changes/float-types.md Normal file
View file

@ -0,0 +1,11 @@
bump: minor
type: feat
`float` and `double`: IEEE floating point with ordinary operators, so renderer math reads
`a * b + c` instead of `f_add(f_mul(a, b), c)`.
- Decimal literals take their type from context (`let s: float = 0.1` is exactly 0.1), and
stay `fixed` elsewhere, so existing programs keep their meaning.
- `int`/`long` promote; `float(x)`, `double(x)`, `int(x)`, `long(x)` and `fixed(x)` convert.
- `floats(n)` / `doubles(n)` buffers; float fields, globals, constants and parameters.
- `Math.*` computes in float when given one; `string`/`print`/interpolation write the
shortest round-tripping decimal; `float_bits` / `float_from_bits` expose the IEEE pattern.
- `@deterministic` code may not use floats (it is now checked).

View file

@ -19,5 +19,5 @@ The JetBrains plugin covers much more of the IDE:
package banner has script shortcuts, and Tools | Ludic | Run Script… lists them all.
- The toolchain's and the project's packages are listed under External Libraries.
- The Structure view, parameter info and code-block navigation now come from `ludic-lsp`.
- The plugin is now 1.4.0 and requires IntelliJ Platform 2024.2 or newer and LSP4IJ 0.21.
- The plugin is now 1.5.0 and requires IntelliJ Platform 2024.2 or newer and LSP4IJ 0.21.
- Fixed: Enter no longer inserts an extra `}`, and Comment Line no longer writes two spaces.

View file

@ -4,15 +4,15 @@ name: fixed
category: builtins
kind: builtin
tokens: fixed
sig: fixed(n) -> fixed
tip: Lift an integer into a Q16.16 fixed-point value.
sig: fixed(x) -> fixed
tip: Convert an int (or a float) into a Q16.16 fixed-point value.
order: 50
---
Converts an integer into a <code>fixed</code> value (Ludic's Q16.16 fixed-point type), so it can take part in fractional arithmetic. Ludic has no floating point; <code>fixed</code> is how you carry sub-pixel precision for smooth movement and physics-like accumulation. Use <code>fixed</code> when you need to combine an <code>int</code> with fixed-point values or divide to get a fraction — for example <code>fixed(1) / fixed(4)</code> is <code>0.25</code>. Convert back to a whole number for drawing with <code>flr</code>.
Converts an integer into a <code>fixed</code> value (Ludic's Q16.16 fixed-point type), so it can take part in fractional arithmetic. <code>fixed</code> is how you carry deterministic sub-pixel precision for smooth movement and physics-like accumulation; a <code>float</code> or <code>double</code> converts too, truncated toward zero to Q16.16. Use <code>fixed</code> when you need to combine an <code>int</code> with fixed-point values or divide to get a fraction — for example <code>fixed(1) / fixed(4)</code> is <code>0.25</code>. Convert back to a whole number for drawing with <code>flr</code>.
Parameters:
- `n` — the integer to lift into fixed-point
- `x` — the integer (or float) to convert
```ludic
program SmoothAccumulate {

View file

@ -0,0 +1,25 @@
---
id: fn-float
name: float
category: builtins
kind: builtin
tokens: float double
sig: float(x) -> float / double(x) -> double
tip: Convert any number to a float (or double).
order: 51
---
Converts an <code>int</code>, <code>long</code>, <code>fixed</code>, <code>float</code> or <code>double</code> to a <code>float</code> (or, with <code>double(x)</code>, to a <code>double</code>). It is the one conversion that crosses from fixed-point to floating point: `float(fixed(1)) / 4.0` is <code>0.25</code>. A decimal literal converts exactly, so `float(0.1)` is the nearest float to 0.1, not the nearest Q16.16 value.
Parameters:
- `x` — the number to convert
```ludic
program Convert {
entry {
let half = fixed(1) / 2
print(float(half) * 3.0) # 1.5
print(double(7) / 2) # 3.5
}
}
```

View file

@ -0,0 +1,24 @@
---
id: fn-float_bits
name: float_bits
category: builtins
kind: builtin
tokens: float_bits float_from_bits double_bits double_from_bits
sig: float_bits(x) -> int / float_from_bits(bits) -> float
tip: A float's IEEE bit pattern as an int, and back.
order: 53
---
<code>float_bits(x)</code> returns the 32 bits of a <code>float</code> as an <code>int</code>, and <code>float_from_bits(bits)</code> reads them back. <code>double_bits</code> / <code>double_from_bits</code> do the same for a <code>double</code> with a <code>long</code>. Use them where a float has to travel as raw data: a file format, a network packet, or a <code>words</code> buffer shared with code that still stores bit patterns.
Parameters:
- `x` — the float to encode (`float_bits`), or the bits to decode (`float_from_bits`)
```ludic
program Bits {
entry {
print(float_bits(1.0)) # 1065353216 (0x3F800000)
print(float_from_bits(0x40000000)) # 2.0
}
}
```

View file

@ -0,0 +1,25 @@
---
id: fn-int
name: int
category: builtins
kind: builtin
tokens: int long
sig: int(x) -> int / long(x) -> long
tip: Convert a number to a whole number, truncating toward zero.
order: 52
---
Converts a <code>float</code>, <code>double</code>, <code>fixed</code> or <code>long</code> to an <code>int</code> (or, with <code>long(x)</code>, to a <code>long</code>). Floating-point values truncate toward zero, as in C: `int(2.9)` is <code>2</code> and `int(-2.9)` is <code>-2</code>. A <code>fixed</code> value rounds down, like <code>floor</code>. For other rounding use <code>Math.floor</code>, <code>Math.ceil</code> or <code>Math.round</code>.
Parameters:
- `x` — the number to convert
```ludic
program Whole {
entry {
let pixels: float = 12.75
print(int(pixels)) # 12
print(long(pixels * 1000.0)) # 12750
}
}
```

View file

@ -5,3 +5,5 @@ order: 6
---
Deterministic fixed-point math. Every function is computed in Q16.16 with plain integer arithmetic, so results are bit-identical on every platform and every run — the same guarantee the rest of the runtime gives. Arguments are positional.
Given a <code>float</code> or <code>double</code> argument, the same functions compute in that type instead (using the platform's math library) and return it — <code>floor</code>, <code>ceil</code> and <code>round</code> included, which return an <code>int</code> only for <code>fixed</code>. <code>sign</code> returns an <code>int</code> either way. <code>trunc</code> and <code>atan</code> exist only for floats.

View file

@ -0,0 +1,25 @@
---
id: type-double
name: double
category: types
kind: type
tokens: double
sig: double
tip: A 64-bit IEEE floating-point number for precise math.
order: 4
---
A <code>double</code> is an IEEE-754 double-precision number: about sixteen significant digits. Use it where a <code>float</code> loses precision — large world coordinates, accumulated time, geodesy — and convert to <code>float</code> at the boundary to the GPU.
It behaves like <code>float</code>: decimal literals take its type from context, <code>int</code> and <code>long</code> promote to it, and a <code>float</code> mixed with a <code>double</code> widens to <code>double</code>. Narrowing back is explicit: <code>float(d)</code>.
```ludic
program Precise {
entry {
let total: double = 0.1
print(total + 0.2) # 0.30000000000000004
let small: float = float(total)
print(small) # 0.1
}
}
```

View file

@ -0,0 +1,30 @@
---
id: type-float
name: float
category: types
kind: type
tokens: float
sig: float
tip: A 32-bit IEEE floating-point number — what the GPU uses.
order: 3
---
A <code>float</code> is an IEEE-754 single-precision number: about seven significant digits, a huge range, and ordinary operators (<code>+ - * / %</code>, comparisons). It is the type for rendering and physics that runs on the GPU or talks to it — positions, normals, colours in shaders, matrices — and for any math where <code>fixed</code>'s ±32768 range is too small.
A decimal literal takes its type from where it is used: `let s: float = 0.5`, `x * 0.5` with a float `x`, and a float parameter all read `0.5` exactly as a float, while an untyped `let k = 0.5` stays <code>fixed</code>. An <code>int</code> promotes to float automatically; everything else converts explicitly with <code>float(x)</code>, <code>int(x)</code>, <code>fixed(x)</code>. <code>Math.*</code> accepts floats and answers in float.
Floats are not deterministic across machines the way <code>fixed</code> is, so simulation that must replay bit-for-bit stays in <code>fixed</code>.
```ludic
program Orbit {
function radius(x: float, y: float) -> float { return Math.sqrt(x * x + y * y) }
entry {
let speed: float = 1.5
var angle: float = 0.0
angle = angle + speed * 2 # the int 2 promotes
print(radius(3, 4)) # 5.0
print(Math.sin(angle) < 1.0)
}
}
```

View file

@ -0,0 +1,24 @@
---
id: type-floats
name: floats
category: types
kind: type
tokens: floats doubles
sig: floats / doubles
tip: A buffer of floats (or doubles) — v[i] reads and writes one.
order: 9
---
<code>floats(n)</code> allocates room for <code>n</code> <code>float</code> values and returns a <code>floats</code> buffer; <code>doubles(n)</code> does the same for <code>double</code>. Index it with <code>v[i]</code> to read or write an element, with no bounds checking. The memory is exactly what a GPU vertex or uniform buffer expects, so a <code>floats</code> buffer uploads as it is.
```ludic
program Vertices {
entry {
let v = floats(6)
v[0] = 0.0; v[1] = 0.5
v[2] = -0.5; v[3] = -0.5
v[4] = 0.5; v[5] = -0.5
print(v[1] - v[3]) # 1.0
}
}
```

View file

@ -0,0 +1,38 @@
# floats.ludic — float (32-bit) and double (64-bit): ordinary operators, literals
# typed by their context, int promotion, conversions and Math.* in float.
# Running it prints: 1 2 3 4 5 6 7 8 9 10 11 12
program Floats {
const HALF: float = 0.5
var g_gravity: float = -9.81
property Body { x: float = 1.25, vy: float, mass: double = 70.5 }
function lerp(a: float, b: float, t: float) -> float { return a + (b - a) * t }
entry {
let a: float = 0.1
if a * 3 > 0.29 and a * 3 < 0.31 { print(1) } # the int 3 promotes
if lerp(0, 10, 0.25) == 2.5 { print(2) } # ints and literals as floats
let d: double = 0.1
if string(d + 0.2) == "0.30000000000000004" { print(3) } # doubles keep their digits
if string(a + 0.2) == "0.3" { print(4) } # shortest text that reads back
let b = new Body
b.vy = b.vy + g_gravity * HALF
if b.vy < -4.9 and b.vy > -4.91 and b.mass == 70.5 { print(5) }
let v = floats(3)
v[0] = 1.5
v[1] = v[0] * v[0]
if v[1] == 2.25 { print(6) }
let up: float = 2.9
let down: float = -2.9
if int(up) == 2 and int(down) == -2 { print(7) } # a float truncates toward zero
if fixed(HALF) == fixed(0.5) and float(fixed(1)) / 4.0 == 0.25 { print(8) }
if Math.sqrt(16.0) == 4.0 and Math.abs(-a) == a { print(9) }
if Math.floor(a * 25) == 2.0 and Math.clamp(a * 100, 0, 5) == 5.0 { print(10) }
if float_bits(1.0) == 0x3F800000 and float_from_bits(0x40000000) == 2.0 { print(11) }
if `{HALF} and {double(1) / 4}` == "0.5 and 0.25" { print(12) }
}
}

View file

@ -33,6 +33,13 @@ function emit_index_addr(e: Node) -> pointer {
g_addr_ty = "fixed"
return rf
}
if (base.ty == "floats") or (base.ty == "doubles") { # IEEE float / double elements
var et = "float"
if (base.ty == "doubles") { et = "double" }
let rd = emit_bind(`getelementptr inbounds {et}, ptr {base.code}, i32 {bi.code}`)
g_addr_ty = et
return rd
}
if (base.ty == "pointers") { # a `pointers` buffer: pointer elements
let rp = emit_bind(`getelementptr inbounds ptr, ptr {base.code}, i32 {bi.code}`)
g_addr_ty = "ptr"

View file

@ -1067,12 +1067,14 @@ function emit_call(e: Node) -> Val {
if (name == "string") { # string(x): int/bool/fixed/long -> text, a string passes through
let a = emit_expr(e.kids[0])
if (llty(a.ty) == "ptr") { return a }
if is_fp(a.ty) { return emit_fp_str(a) }
if (llty(a.ty) == "i64") { g_uses_longstr = true; return val(emit_bind(`call ptr @lp_long_str(i64 {a.code})`), "string") }
g_uses_intstr = true
return val(emit_bind(`call ptr @lp_int_str(i32 {a.code})`), "string")
}
if (name == "print") { # print(x): a value + newline (string, long, or int)
let a = emit_expr(e.kids[0])
var a = emit_expr(e.kids[0])
if is_fp(a.ty) { a = emit_fp_str(a) }
if (llty(a.ty) == "ptr") { emit(" call i32 (ptr, ...) @printf(ptr @.fmt_line, ptr " + `{a.code})\n`) }
else { if (llty(a.ty) == "i64") { emit(" call i32 (ptr, ...) @printf(ptr @.fmt_long, i64 " + `{a.code})\n`) }
else { emit(" call i32 (ptr, ...) @printf(ptr @.fmt_int, i32 " + `{a.code})\n`) } }
@ -1089,7 +1091,37 @@ function emit_call(e: Node) -> Val {
let w = emit_bind(`zext i32 {by} to i64`)
return val(emit_bind(`call ptr @malloc(i64 {w})`), "words")
}
if (name == "fixed") { let a = emit_expr(e.kids[0]); return val(emit_bind(`shl i32 {a.code}, 16`), "fixed") }
if (name == "fixed") {
let a = emit_expr(e.kids[0])
if is_fp(a.ty) { return emit_fp_to_fixed(a) }
if (a.lit != null) { return val(a.code, "fixed") } # fixed(1.5) is the literal itself
return val(emit_bind(`shl i32 {a.code}, 16`), "fixed")
}
# float(x) / double(x) / int(x) / long(x): explicit numeric conversions
if ((name == "float") or (name == "double")) and (find_fn(name) == null) and len(e.kids) == 1 {
return emit_fp_convert(name, emit_expr(e.kids[0]))
}
if (name == "int") and (find_fn(name) == null) and len(e.kids) == 1 {
let a = emit_expr(e.kids[0])
if is_fp(a.ty) { return emit_fp_to_int(a) }
if (a.ty == "fixed") { return val(emit_bind(`ashr i32 {a.code}, 16`), "int") }
if (llty(a.ty) == "i64") { return val(emit_bind(`trunc i64 {a.code} to i32`), "int") }
return val(a.code, "int")
}
if (name == "long") and (find_fn(name) == null) and len(e.kids) == 1 {
let a = emit_expr(e.kids[0])
if is_fp(a.ty) { return emit_fp_to_long(a) }
return val(to_long(a), "long")
}
if (name == "float_bits") and (find_fn(name) == null) { return emit_float_bits(emit_expr(e.kids[0])) }
if (name == "float_from_bits") and (find_fn(name) == null) { return emit_float_from_bits(emit_expr(e.kids[0])) }
if (name == "double_bits") and (find_fn(name) == null) { return emit_double_bits(emit_expr(e.kids[0])) }
if (name == "double_from_bits") and (find_fn(name) == null) { return emit_double_from_bits(emit_expr(e.kids[0])) }
if ((name == "floats") or (name == "doubles")) and (find_fn(name) == null) {
var ft = "float"
if (name == "doubles") { ft = "double" }
return emit_fp_buffer(ft, emit_expr(e.kids[0]))
}
if (name == "floor") { let a = emit_expr(e.kids[0]); return val(emit_bind(`ashr i32 {a.code}, 16`), "int") }
# --- the testing framework's assertions (see emit_test_runner) --------------
# expect(cond) / expect_eq(a, b) / expect_near(a, b, tol): on failure they set
@ -1349,7 +1381,8 @@ function emit_call(e: Node) -> Val {
function emit_expr(e: Node) -> Val {
if (e == null) { return val("0", "int") }
if e.kind == E_INT { return val(itoa(e.ival), "int") }
if e.kind == E_FLOAT { return val(itoa(e.ival), "fixed") }
if e.kind == E_FLOAT { let fv = val(itoa(e.ival), "fixed"); fv.lit = e; return fv }
if e.kind == E_PREVAL { return g_prevals[e.ival] }
if e.kind == E_BOOL { return val(itoa(e.ival), "bool") }
if e.kind == E_NULL { return val("null", "pointer") }
if e.kind == S_SPAWN { return val(emit_spawn(e), "entity") } # `let e = spawn Model { … }`
@ -1385,7 +1418,11 @@ function emit_expr(e: Node) -> Val {
# expression's real type — so `const X: fixed = 10.0` yields a `fixed`, not
# the raw Q16.16 bits mislabelled `int`. Every existing const is an int
# literal, for which this is byte-identical to the old immediate.
if g.kind == N_CONST { return emit_expr(g.a) }
if g.kind == N_CONST {
let cv = emit_expr(g.a)
if is_fp(g.ty) { return val(to_fp(cv, g.ty, `const {e.s}`), g.ty) }
return cv
}
let r = emit_bind(`load {llty(g.ty)}, ptr @g_{e.s}`)
return val(r, g.ty)
}
@ -1438,6 +1475,15 @@ function emit_expr(e: Node) -> Val {
if e.kind == E_BIN { return emit_bin(e) }
if e.kind == E_UN {
let a = emit_expr(e.a)
if is_fp(a.ty) {
if (e.s == ("-")) { return val(emit_bind(`fneg {a.ty} {a.code}`), a.ty) }
perr(`operator {e.s} does not apply to {a.ty}`)
}
if (e.s == ("-")) and (a.lit != null) { # -1.5 stays a literal: exact in a float context
let nv = val(emit_bind(`sub i32 0, {a.code}`), "fixed")
nv.lit = e
return nv
}
if (llty(a.ty) == "i64") { # negate / bit-flip a long, staying 64-bit
if (e.s == ("-")) { return val(emit_bind(`sub i64 0, {a.code}`), "long") }
if (e.s == "~") { return val(emit_bind(`xor i64 {a.code}, -1`), "long") }

View file

@ -2,8 +2,11 @@
# module header. Mirrors the pieces of compiler/back/ that this subset needs.
# structs and slices are references, so every non-scalar type lowers to `ptr`.
property Val { code: pointer = null, ty: pointer = null }
function val(code: pointer, ty: pointer) -> Val { let v = new Val; v.code = code; v.ty = ty; return v }
# lit: the expression of a value made only of numeric literals with at least one
# decimal among them (`1.5`, `-0.25`, `1.0 / 3.0`). It is `fixed` on its own and
# is re-evaluated exactly in a float context — see emit_float.ludic.
property Val { code: pointer = null, ty: pointer = null, lit: Node = null }
function val(code: pointer, ty: pointer) -> Val { let v = new Val; v.code = code; v.ty = ty; v.lit = null; return v }
var head: Buf # module-level: types, globals, string constants
var code: Buf # function bodies
@ -125,11 +128,13 @@ function llty(t: pointer) -> pointer {
if is_bare_enum(t) { return "i32" } # a payload-free enum is an int by another name
if (t == "countdown") { return "i32" } # an int the engine counts down to 0 each Update
if (t == "long") { return "i64" } # a 64-bit signed integer
if (t == "float") { return "float" } # IEEE single precision
if (t == "double") { return "double" } # IEEE double precision
if (t == "Vector") { return "i64" } # a 2D vector: (x, y) fixeds packed into one i64
if (t == "IVec2") { return "i64" } # an integer 2D vector: (x, y) ints packed into one i64
if (t == "Rect") { return "i128" } # a rectangle: (x, y, w, h) fixeds packed into one i128
if (t == "byte") { return "i8" } # a single byte (p[i] on a raw ptr)
if (t == "words") or (t == "fixeds") or (t == "pointers") { return "ptr" } # typed buffers
if (t == "words") or (t == "fixeds") or (t == "pointers") or (t == "floats") or (t == "doubles") { return "ptr" } # typed buffers
if (t == "void") { return "void" }
return "ptr"
}

View file

@ -13,6 +13,7 @@ function emit_params_sig(d: Node) -> void {
function emit_fn(d: Node) -> void {
ll_t = 0; ll_lbl = 0; g_term = false; loc_reset(); nloop = 0
det_enter(d.s)
g_cur_scene = null # a function belongs to no scene: `become` leaves the live one
ret_ty = d.ty
let fbody = buf_new()
@ -40,6 +41,7 @@ function emit_fn(d: Node) -> void {
emit(buf_str(falloc))
emit(buf_str(fbody))
emit("}\n\n")
g_det_ctx = ""
}
function emit_main(d: Node) -> void {
@ -208,6 +210,9 @@ function emit_program() -> void {
code = buf_new()
g_uses_str = false
g_uses_intstr = false
g_uses_fpstr = false
g_fp_decls = new []pointer
g_prevals = new []Val
g_uses_strslice = false
g_uses_loopback = false
g_uses_expect = false
@ -256,7 +261,8 @@ function emit_program() -> void {
if g_uses_world_despawn { emit_world_despawn_fn() } # #84: @fn_world_despawn, after all World.despawn / esys_bounds-kill uses are seen
if g_uses_loopback { emit_loopback() } # built-in transport, after all net_send/net_poll uses are seen
if g_uses_str { emit_str_prelude() } # @lp_str_eq / @lp_str_concat, after all uses are seen
if g_uses_intstr { emit_int_str() } # @lp_int_str, for string(int) in interpolation
if g_uses_intstr { emit_int_str() }
if g_uses_fpstr { emit_fp_str_fn() } # @lp_int_str, for string(int) in interpolation
if g_uses_longstr { emit_long_str() } # @lp_long_str, for string(long) / long interpolation
if g_uses_strslice { emit_str_slice() } # @lp_str_slice, for s[a..b]
if g_uses_mathrt { emit_math_prelude() } # @lp_fx_sqrt / @lp_fx_sin + the sine table

View file

@ -108,6 +108,10 @@ function to_fixed(v: Val) -> pointer {
# pair the language has: int (i32) <-> long (i64). int widens with sext, long
# narrows with trunc; everything else (same width, or ptr) passes through.
function coerce_code(v: Val, target: pointer) -> pointer {
if is_fp(target) { return to_fp(v, target, `a {target} slot`) }
if is_fp(v.ty) and not is_fp(target) and (llty(target) != "ptr") {
perr(`a {v.ty} does not convert to {target} implicitly — write int(x) or fixed(x)`)
}
let lt = llty(target)
let vt = llty(v.ty)
if (lt == vt) { return v.code }
@ -140,7 +144,7 @@ function emit_str_op(op: pointer, a: Val, b: Val) -> Val {
# the result type of a unary arithmetic operator on a value of type `ty`: fixed
# and long survive, every other 32-bit scalar (int/bool/enum) collapses to int
function arith_ty(ty: pointer) -> pointer {
if (ty == "fixed") or (ty == "long") { return ty }
if (ty == "fixed") or (ty == "long") or is_fp(ty) { return ty }
return "int"
}
@ -149,7 +153,12 @@ function emit_bin(e: Node) -> Val {
let a = emit_expr(e.a)
let b = emit_expr(e.b)
let isnull = e.a.kind == E_NULL or e.b.kind == E_NULL
return emit_bin_vals(e.s, a, b, isnull)
let r = emit_bin_vals(e.s, a, b, isnull)
# literal arithmetic stays a literal (for a float context) when a decimal is in it
if not is_cmp(e.s) and not is_fp(r.ty) and (a.lit != null or b.lit != null) {
if (a.lit != null or e.a.kind == E_INT) and (b.lit != null or e.b.kind == E_INT) { r.lit = e }
}
return r
}
# lower `a <op> b` on two already-evaluated operands. Shared by binary expressions
@ -170,6 +179,7 @@ function emit_bin_vals(op: pointer, a: Val, b: Val, isnull: bool) -> Val {
# a 64-bit operand (and no fixed/ptr involved) promotes the whole expression to
# i64: the other side widens with sext, and the result stays `long`.
let lng = ((llty(a.ty) == "i64") or (llty(b.ty) == "i64")) and not fx and not ptrish
if is_fp(a.ty) or is_fp(b.ty) { return emit_fp_bin(op, a, b) }
if is_cmp(op) {
var ac = a.code; var bc = b.code
var ct = "i32"

View file

@ -0,0 +1,457 @@
# emit_float.ludic — IEEE floating point: `float` (32-bit) and `double` (64-bit).
#
# Rules (LANGUAGE.md, "Floating point"):
# - an int (or long) operand promotes to the float type of the other side;
# float with double promotes to double
# - a decimal literal (`1.5`) takes a float type from its context — an
# operand, a typed binding, a parameter — and is `fixed` otherwise
# - fixed never mixes with float silently: float(x) / fixed(x) convert
# - float -> int, double -> float are explicit: int(x), float(x)
#
# The compiler has no floating point of its own, so a literal travels as its
# decimal text and LLVM parses it (a `double` constant, fptrunc'd to `float`).
function is_fp(t: pointer) -> bool { return (t == "float") or (t == "double") }
# @deterministic functions and handlers compute the same bits on every machine,
# which IEEE float (fused multiply-adds, libm differences) does not promise
var g_det_names: []pointer = new []pointer
var g_det_ctx: pointer = "" # the @deterministic declaration being emitted, or ""
function det_enter(name: pointer) -> void {
g_det_ctx = ""
var i = 0
while i < len(g_det_names) { if (g_det_names[i] == name) { g_det_ctx = name }; i += 1 }
}
function fp_guard(t: pointer) -> void {
if not (g_det_ctx == "") {
perr(`@deterministic {g_det_ctx} cannot compute with {t}: floating point differs between machines — use fixed`)
}
}
# the wider of two operand types, when at least one is float/double
function fp_result(a: pointer, b: pointer) -> pointer {
if (a == "double") or (b == "double") { return "double" }
return "float"
}
# a literal-only expression (Val.lit) evaluated exactly in float type t
function fp_const(e: Node, t: pointer) -> pointer {
if e.kind == E_FLOAT { return fp_lit_code(e.s, t) }
if e.kind == E_INT { return fp_lit_code(itoa(e.ival), t) }
if e.kind == E_UN {
let x = fp_const(e.a, t)
return emit_bind(`fneg {t} {x}`)
}
if e.kind == E_BIN {
let a = fp_const(e.a, t)
let b = fp_const(e.b, t)
var inst = "fadd"
if (e.s == ("-")) { inst = "fsub" }
if (e.s == ("*")) { inst = "fmul" }
if (e.s == ("/")) { inst = "fdiv" }
if (e.s == ("%")) { inst = "frem" }
return emit_bind(`{inst} {t} {a}, {b}`)
}
perr("internal: not a literal expression")
return "0.0"
}
# the zero a slot of LLVM type lt starts at
function zero_of(lt: pointer) -> pointer {
if (lt == "ptr") { return "null" }
if (lt == "float") or (lt == "double") { return "0.0" }
return "0"
}
# a literal's decimal text as a value of type t
function fp_lit_code(text: pointer, t: pointer) -> pointer {
var d = text
if not str_has(d, '.') { d = d + ".0" }
if (t == "double") { return d }
return emit_bind(`fptrunc double {d} to float`)
}
function str_has(s: pointer, ch: int) -> bool {
var i = 0
while i < len(s) { if s[i] == ch { return true }; i += 1 }
return false
}
# is this register text an integer constant (`42`, `-7`)?
function is_int_const(code: pointer) -> bool {
let n = len(code)
if n == 0 { return false }
var i = 0
if code[0] == '-' { i = 1 }
if i >= n { return false }
while i < n { if code[i] < '0' or code[i] > '9' { return false }; i += 1 }
return true
}
# v as a value of float type t (implicit conversions only). `what` names the
# context for the error message.
function to_fp(v: Val, t: pointer, what: pointer) -> pointer {
fp_guard(t)
if (v.ty == t) { return v.code }
if (v.ty == "float") and (t == "double") { return emit_bind(`fpext float {v.code} to double`) }
if (v.ty == "double") and (t == "float") {
perr(`{what}: a double does not narrow to float implicitly — write float(x)`)
}
if (v.lit != null) { return fp_const(v.lit, t) }
if (v.ty == "fixed") {
perr(`{what}: fixed and {t} do not mix implicitly — convert with {t}(x) or fixed(x)`)
}
let lt = llty(v.ty)
if (lt == "i64") { fp_promote_note(v.ty, t); return emit_bind(`sitofp i64 {v.code} to {t}`) }
if (lt == "i32") {
if is_int_const(v.code) { return fp_lit_code(v.code, t) }
fp_promote_note(v.ty, t)
return emit_bind(`sitofp i32 {v.code} to {t}`)
}
perr(`{what}: a {v.ty} is not a number`)
return v.code
}
# LUDIC_WARN_FLOAT_PROMOTE=1: report every implicit promotion of a computed
# (non-constant) integer to float — the audit a migration from float bit
# patterns in ints relies on, since such a value would silently change meaning
var g_warn_promote: int = -1
function fp_promote_note(from: pointer, t: pointer) -> void {
if g_warn_promote < 0 {
g_warn_promote = 0
if (getenv("LUDIC_WARN_FLOAT_PROMOTE") != null) { g_warn_promote = 1 }
}
if g_warn_promote == 0 { return }
var file = g_err_file
if (file == null) { file = "" }
let m = `{file}:{itoa(g_err_line)}: warning: a computed {from} is promoted to {t}\n`
file_write(file_stderr(), m, len(m))
}
function fcmp_code(op: pointer) -> pointer {
if (op == ("<")) { return "olt" }
if (op == ("<=")) { return "ole" }
if (op == (">")) { return "ogt" }
if (op == (">=")) { return "oge" }
if (op == ("==")) { return "oeq" }
return "une" # != is true for NaN, as everywhere
}
# `a <op> b` where at least one side is float/double
function emit_fp_bin(op: pointer, a: Val, b: Val) -> Val {
var t = "float"
if is_fp(a.ty) and is_fp(b.ty) { t = fp_result(a.ty, b.ty) }
else { if is_fp(a.ty) { t = a.ty } else { t = b.ty } }
let ac = to_fp(a, t, `the left side of {op}`)
let bc = to_fp(b, t, `the right side of {op}`)
if is_cmp(op) {
let c = emit_bind(`fcmp {fcmp_code(op)} {t} {ac}, {bc}`)
return val(emit_bind(`zext i1 {c} to i32`), "bool")
}
var inst = ""
if (op == ("+")) { inst = "fadd" }
if (op == ("-")) { inst = "fsub" }
if (op == ("*")) { inst = "fmul" }
if (op == ("/")) { inst = "fdiv" }
if (op == ("%")) { inst = "frem" }
if (inst == "") { perr(`operator {op} does not apply to {t}`) }
return val(emit_bind(`{inst} {t} {ac}, {bc}`), t)
}
# float(x) / double(x): any number to that float type, explicitly
function emit_fp_convert(t: pointer, v: Val) -> Val {
fp_guard(t)
if (v.lit != null) { return val(fp_const(v.lit, t), t) }
if is_fp(v.ty) {
if (v.ty == t) { return v }
if (t == "double") { return val(emit_bind(`fpext float {v.code} to double`), t) }
return val(emit_bind(`fptrunc double {v.code} to float`), t)
}
if (v.ty == "fixed") {
let f = emit_bind(`sitofp i32 {v.code} to {t}`)
return val(emit_bind(`fdiv {t} {f}, 65536.0`), t)
}
return val(to_fp(v, t, `{t}(x)`), t)
}
# int(x) on a float: truncates toward zero, as C does
function emit_fp_to_int(v: Val) -> Val {
return val(emit_bind(`fptosi {v.ty} {v.code} to i32`), "int")
}
# long(x) on a float
function emit_fp_to_long(v: Val) -> Val {
return val(emit_bind(`fptosi {v.ty} {v.code} to i64`), "long")
}
# fixed(x) on a float: truncated toward zero to Q16.16, as int(x) truncates
function emit_fp_to_fixed(v: Val) -> Val {
let m = emit_bind(`fmul {v.ty} {v.code}, 65536.0`)
return val(emit_bind(`fptosi {v.ty} {m} to i32`), "fixed")
}
# float_bits(x) / float_from_bits(i): the IEEE bit pattern of a float, both
# ways — what a GPU buffer or a file holds
function emit_float_bits(v: Val) -> Val {
let f = to_fp(v, "float", "float_bits(x)")
return val(emit_bind(`bitcast float {f} to i32`), "int")
}
function emit_float_from_bits(v: Val) -> Val {
return val(emit_bind(`bitcast i32 {v.code} to float`), "float")
}
function emit_double_bits(v: Val) -> Val {
let f = to_fp(v, "double", "double_bits(x)")
return val(emit_bind(`bitcast double {f} to i64`), "long")
}
function emit_double_from_bits(v: Val) -> Val {
return val(emit_bind(`bitcast i64 {to_long(v)} to double`), "double")
}
# ---- text -------------------------------------------------------------------
var g_uses_fpstr: bool = false
# string(x) for a float/double: the shortest text that reads back as x
function emit_fp_str(v: Val) -> Val {
g_uses_fpstr = true
var d = v.code
var single = "0"
if (v.ty == "float") { d = emit_bind(`fpext float {v.code} to double`); single = "1" }
return val(emit_bind(`call ptr @lp_fp_str(double {d}, i32 {single})`), "string")
}
# @lp_fp_str(v, single): "%.*g" with the fewest digits that round-trip (through
# float when single), plus ".0" when the text would read as an int
function emit_fp_str_fn() -> void {
fp_declare("declare i32 @snprintf(ptr, i64, ptr, ...)\n")
fp_declare("declare double @strtod(ptr, ptr)\n")
fp_declare("declare ptr @strpbrk(ptr, ptr)\n")
emith("@.fmt_fpg = private unnamed_addr constant [5 x i8] c\"%.*g\\00\"\n")
emith("@.fp_marks = private unnamed_addr constant [8 x i8] c\".eEnNiI\\00\"\n")
emith("define ptr @lp_fp_str(double %v, i32 %single) {\n")
emith("entry:\n %buf = call ptr @malloc(i64 40)\n br label %try\n")
emith("try:\n %p = phi i32 [ 6, %entry ], [ %p1, %again ]\n")
emith(" %w = call i32 (ptr, i64, ptr, ...) @snprintf(ptr %buf, i64 36, ptr @.fmt_fpg, i32 %p, double %v)\n")
emith(" %r = call double @strtod(ptr %buf, ptr null)\n")
emith(" %rf = fptrunc double %r to float\n %vf = fptrunc double %v to float\n")
emith(" %eqf = fcmp oeq float %rf, %vf\n %eqd = fcmp oeq double %r, %v\n")
emith(" %is1 = icmp ne i32 %single, 0\n %eq = select i1 %is1, i1 %eqf, i1 %eqd\n")
emith(" %p1 = add i32 %p, 1\n %last = icmp sge i32 %p, 17\n %stop = or i1 %eq, %last\n")
emith(" br i1 %stop, label %done, label %again\n")
emith("again:\n br label %try\n")
emith("done:\n %mark = call ptr @strpbrk(ptr %buf, ptr @.fp_marks)\n %whole = icmp eq ptr %mark, null\n")
emith(" br i1 %whole, label %dot, label %out\n")
emith("dot:\n %n64 = call i64 @strlen(ptr %buf)\n %e0 = getelementptr inbounds i8, ptr %buf, i64 %n64\n")
emith(" store i8 46, ptr %e0\n %n1 = add i64 %n64, 1\n %e1 = getelementptr inbounds i8, ptr %buf, i64 %n1\n")
emith(" store i8 48, ptr %e1\n %n2 = add i64 %n64, 2\n %e2 = getelementptr inbounds i8, ptr %buf, i64 %n2\n")
emith(" store i8 0, ptr %e2\n br label %out\n")
emith("out:\n ret ptr %buf\n}\n")
}
# ---- buffers ----------------------------------------------------------------
# floats(n) / doubles(n): n uninitialised elements, indexed like words
function emit_fp_buffer(t: pointer, n: Val) -> Val {
var sz = "4"; var ty = "floats"
if (t == "double") { sz = "8"; ty = "doubles" }
let by = emit_bind(`mul i32 {n.code}, {sz}`)
let w = emit_bind(`zext i32 {by} to i64`)
return val(emit_bind(`call ptr @malloc(i64 {w})`), ty)
}
# ---- Math.* on floats ---------------------------------------------------------
var g_fp_decls: []pointer
# declare a libm function once per program
function fp_declare(line: pointer) -> void {
var i = 0
while i < len(g_fp_decls) { if (g_fp_decls[i] == line) { return }; i += 1 }
push(g_fp_decls, line)
emith(line)
}
# call a one-argument libm/intrinsic function of type t
function fp_call1(base: pointer, t: pointer, x: pointer) -> pointer {
var fname = base
if (t == "float") { fname = base + "f" }
fp_declare(`declare {t} @{fname}({t})\n`)
return emit_bind(`call {t} @{fname}({t} {x})`)
}
function fp_call2(base: pointer, t: pointer, x: pointer, y: pointer) -> pointer {
var fname = base
if (t == "float") { fname = base + "f" }
fp_declare(`declare {t} @{fname}({t}, {t})\n`)
return emit_bind(`call {t} @{fname}({t} {x}, {t} {y})`)
}
function fp_intrinsic1(name: pointer, t: pointer, x: pointer) -> pointer {
var sfx = "f64"
if (t == "float") { sfx = "f32" }
fp_declare(`declare {t} @llvm.{name}.{sfx}({t})\n`)
return emit_bind(`call {t} @llvm.{name}.{sfx}({t} {x})`)
}
function fp_intrinsic2(name: pointer, t: pointer, x: pointer, y: pointer) -> pointer {
var sfx = "f64"
if (t == "float") { sfx = "f32" }
fp_declare(`declare {t} @llvm.{name}.{sfx}({t}, {t})\n`)
return emit_bind(`call {t} @llvm.{name}.{sfx}({t} {x}, {t} {y})`)
}
# min / max as a comparison and a select: `x < y ? x : y`
function fp_pick(meth: pointer, t: pointer, x: pointer, y: pointer) -> pointer {
var cc = "olt"
if (meth == "max") { cc = "ogt" }
let c = emit_bind(`fcmp {cc} {t} {x}, {y}`)
return emit_bind(`select i1 {c}, {t} {x}, {t} {y}`)
}
# the float type a Math call works in, given its already-evaluated first
# argument and the static types of the rest; "" when it is not a float call
function fp_math_type(first: Val, e: Node) -> pointer {
var t = ""
if is_fp(first.ty) { t = first.ty }
var i = 1
while i < len(e.kids) {
let st = static_type(e.kids[i])
if (st != null) and is_fp(st) {
if (t == "") { t = st } else { t = fp_result(t, st) }
}
i += 1
}
return t
}
function fp_arg(e: Node, i: int, t: pointer, meth: pointer) -> pointer {
let v = emit_expr(e.kids[i])
return to_fp(v, t, `Math.{meth}`)
}
# Math.<meth>(…) in float type t; the first argument is already evaluated
function emit_fp_math(meth: pointer, t: pointer, first: Val, e: Node) -> Val {
let x = to_fp(first, t, `Math.{meth}`)
if (meth == "abs") { return val(fp_intrinsic1("fabs", t, x), t) }
if (meth == "sqrt") { return val(fp_intrinsic1("sqrt", t, x), t) }
if (meth == "sin") { return val(fp_call1("sin", t, x), t) }
if (meth == "cos") { return val(fp_call1("cos", t, x), t) }
if (meth == "exp") { return val(fp_call1("exp", t, x), t) }
if (meth == "log") { return val(fp_call1("log", t, x), t) }
if (meth == "tan") { return val(fp_call1("tan", t, x), t) }
if (meth == "asin") { return val(fp_call1("asin", t, x), t) }
if (meth == "acos") { return val(fp_call1("acos", t, x), t) }
if (meth == "atan") { return val(fp_call1("atan", t, x), t) }
if (meth == "floor") { return val(fp_call1("floor", t, x), t) }
if (meth == "ceil") { return val(fp_call1("ceil", t, x), t) }
if (meth == "round") { return val(fp_call1("round", t, x), t) }
if (meth == "trunc") { return val(fp_intrinsic1("trunc", t, x), t) }
if (meth == "sign") {
let pos = emit_bind(`fcmp ogt {t} {x}, 0.0`)
let neg = emit_bind(`fcmp olt {t} {x}, 0.0`)
let lo = emit_bind(`select i1 {neg}, i32 -1, i32 0`)
return val(emit_bind(`select i1 {pos}, i32 1, i32 {lo}`), "int")
}
if (meth == "deg_to_rad") { # x * (pi / 180), computed in t
let k = emit_bind(`fdiv {t} {fp_lit_code("3.141592653589793", t)}, 180.0`)
return val(emit_bind(`fmul {t} {x}, {k}`), t)
}
if (meth == "rad_to_deg") { # x * (180 / pi), computed in t
let k = emit_bind(`fdiv {t} 180.0, {fp_lit_code("3.141592653589793", t)}`)
return val(emit_bind(`fmul {t} {x}, {k}`), t)
}
if (meth == "min") or (meth == "max") { # the first unless the second is strictly smaller (larger)
let y = fp_arg(e, 1, t, meth)
return val(fp_pick(meth, t, x, y), t)
}
if (meth == "pow") { return val(fp_intrinsic2("pow", t, x, fp_arg(e, 1, t, meth)), t) }
if (meth == "atan2") { return val(fp_call2("atan2", t, x, fp_arg(e, 1, t, meth)), t) }
if (meth == "hypot") { return val(fp_call2("hypot", t, x, fp_arg(e, 1, t, meth)), t) }
if (meth == "posmod") or (meth == "wrap") {
# a result with the sign of the divisor: ((x % m) + m) % m
let m = fp_arg(e, 1, t, meth)
let r = emit_bind(`frem {t} {x}, {m}`)
let s = emit_bind(`fadd {t} {r}, {m}`)
return val(emit_bind(`frem {t} {s}, {m}`), t)
}
if (meth == "clamp") {
let lo = fp_arg(e, 1, t, meth)
let hi = fp_arg(e, 2, t, meth)
let a = fp_pick("max", t, x, lo) # min(max(x, lo), hi)
return val(fp_pick("min", t, a, hi), t)
}
if (meth == "lerp") { # lerp(a, b, t)
let b = fp_arg(e, 1, t, meth)
let k = fp_arg(e, 2, t, meth)
let d = emit_bind(`fsub {t} {b}, {x}`)
let dk = emit_bind(`fmul {t} {d}, {k}`)
return val(emit_bind(`fadd {t} {x}, {dk}`), t)
}
if (meth == "inverse_lerp") { # inverse_lerp(a, b, v)
let b = fp_arg(e, 1, t, meth)
let v = fp_arg(e, 2, t, meth)
let num = emit_bind(`fsub {t} {v}, {x}`)
let den = emit_bind(`fsub {t} {b}, {x}`)
return val(emit_bind(`fdiv {t} {num}, {den}`), t)
}
if (meth == "remap") { # remap(v, a0, a1, b0, b1)
let a0 = fp_arg(e, 1, t, meth)
let a1 = fp_arg(e, 2, t, meth)
let b0 = fp_arg(e, 3, t, meth)
let b1 = fp_arg(e, 4, t, meth)
let num = emit_bind(`fsub {t} {x}, {a0}`)
let den = emit_bind(`fsub {t} {a1}, {a0}`)
let k = emit_bind(`fdiv {t} {num}, {den}`)
let span = emit_bind(`fsub {t} {b1}, {b0}`)
let off = emit_bind(`fmul {t} {span}, {k}`)
return val(emit_bind(`fadd {t} {b0}, {off}`), t)
}
if (meth == "smoothstep") { # smoothstep(e0, e1, v)
let e1 = fp_arg(e, 1, t, meth)
let v = fp_arg(e, 2, t, meth)
let num = emit_bind(`fsub {t} {v}, {x}`)
let den = emit_bind(`fsub {t} {e1}, {x}`)
let k0 = emit_bind(`fdiv {t} {num}, {den}`)
let k1 = fp_pick("max", t, k0, "0.0")
let k = fp_pick("min", t, k1, "1.0")
let kk = emit_bind(`fmul {t} {k}, {k}`)
let tk = emit_bind(`fmul {t} {k}, 2.0`)
let three = emit_bind(`fsub {t} 3.0, {tk}`)
return val(emit_bind(`fmul {t} {kk}, {three}`), t)
}
if (meth == "move_toward") { # move_toward(from, to, step)
let to = fp_arg(e, 1, t, meth)
let st = fp_arg(e, 2, t, meth)
let d = emit_bind(`fsub {t} {to}, {x}`)
let ad = fp_intrinsic1("fabs", t, d)
let reach = emit_bind(`fcmp ole {t} {ad}, {st}`)
let neg = emit_bind(`fcmp olt {t} {d}, 0.0`)
let nst = emit_bind(`fneg {t} {st}`)
let dir = emit_bind(`select i1 {neg}, {t} {nst}, {t} {st}`)
let moved = emit_bind(`fadd {t} {x}, {dir}`)
return val(emit_bind(`select i1 {reach}, {t} {to}, {t} {moved}`), t)
}
if (meth == "dist") or (meth == "dist2") { # dist(x1, y1, x2, y2)
let y1 = fp_arg(e, 1, t, meth)
let x2 = fp_arg(e, 2, t, meth)
let y2 = fp_arg(e, 3, t, meth)
let dx = emit_bind(`fsub {t} {x2}, {x}`)
let dy = emit_bind(`fsub {t} {y2}, {y1}`)
let dx2 = emit_bind(`fmul {t} {dx}, {dx}`)
let dy2 = emit_bind(`fmul {t} {dy}, {dy}`)
let s = emit_bind(`fadd {t} {dx2}, {dy2}`)
if (meth == "dist2") { return val(s, t) }
return val(fp_intrinsic1("sqrt", t, s), t)
}
perr(`Math.{meth} has no {t} form`)
return val(x, t)
}
# ---- pre-evaluated arguments ------------------------------------------------
# A call that has to look at its first argument's type before choosing a
# lowering evaluates it once and swaps in an E_PREVAL node, so the chosen path
# does not evaluate it (and its side effects) a second time.
var g_prevals: []Val
function preval_node(v: Val) -> Node {
push(g_prevals, v)
let n = node(E_PREVAL)
n.ival = len(g_prevals) - 1
return n
}

View file

@ -26,7 +26,7 @@ function emit_str_const(s: pointer) -> pointer {
# the constant initializer for a global var: a literal, or 0/null
function global_init(d: Node) -> pointer {
if (d.a == null) { if (llty(d.ty) == "ptr") { return "null" }; return "0" }
if (d.a == null) or is_fp(d.ty) { return zero_of(llty(d.ty)) } # a float's value is set by L_init_globals
let e = d.a
if e.kind == E_INT or e.kind == E_FLOAT or e.kind == E_BOOL { return itoa(e.ival) }
if e.kind == E_UN and (e.s == ("-")) and e.a.kind == E_INT { return (("-") + itoa(e.a.ival)) }
@ -40,6 +40,7 @@ function global_init(d: Node) -> pointer {
# does a global's initializer need code at startup (anything global_init cannot fold)?
function global_needs_init_code(d: Node) -> bool {
if (d.a == null) { return false }
if is_fp(d.ty) { return true }
let e = d.a
if e.kind == E_INT or e.kind == E_FLOAT or e.kind == E_BOOL or e.kind == E_NULL { return false }
if e.kind == E_UN and (e.s == ("-")) and e.a.kind == E_INT { return false }
@ -62,7 +63,8 @@ function emit_global_init_fn() -> void {
let d = prog[i]
if d.kind == N_VAR and global_needs_init_code(d) {
let v = emit_expr(d.a)
emit(" store "); emit(llty(d.ty)); emit(" "); emit(coerce_code(v, d.ty)); emit(", ptr @g_"); emit(d.s); emit("\n")
let cv = coerce_code(v, d.ty) # any conversion is its own line, before the store
emit(" store "); emit(llty(d.ty)); emit(" "); emit(cv); emit(", ptr @g_"); emit(d.s); emit("\n")
}
i += 1
}

View file

@ -36,11 +36,10 @@ function emit_new_struct(name: pointer, rec: Node) -> Val {
emit(" "); emit(addr); emit(" = getelementptr inbounds "); emit(lty)
emit(", ptr "); emit(obj); emit(", i32 0, i32 "); emit(itoa(f)); emit("\n")
let lt = llty(fd.ty)
var v = "0"
if (lt == "ptr") { v = "null" }
if (fd.a != null) { let dv = emit_expr(fd.a); v = dv.code }
var v = zero_of(lt)
if (fd.a != null) { let dv = emit_expr(fd.a); v = coerce_code(dv, fd.ty) }
let ov = rec_field(rec, fd.s) # explicit override wins over the default
if (ov != null) { let dv = emit_expr(ov); v = dv.code }
if (ov != null) { let dv = emit_expr(ov); v = coerce_code(dv, fd.ty) }
emit(" store "); emit(lt); emit(" "); emit(v); emit(", ptr "); emit(addr); emit("\n")
f += 1
}

View file

@ -316,9 +316,7 @@ function emit_stmt(st: Node) -> void {
let slot = emit_alloca(llty(ty)); store_at(llty(ty), v0.code, slot); loc_push(st.s, slot, ty); loc_set_mut(st.ival); return }
let slot = emit_alloca(llty(ty))
if (st.a == null) {
var z = "0"
if (llty(ty) == "ptr") { z = "null" }
store_at(llty(ty), z, slot)
store_at(llty(ty), zero_of(llty(ty)), slot)
} else { let v = emit_expr(st.a); store_at(llty(ty), coerce_code(v, ty), slot) }
loc_push(st.s, slot, ty)
loc_set_mut(st.ival)

View file

@ -4,6 +4,7 @@
# called only when the runtime defines them.
function emit_system_fn(sys: Node) -> void {
det_enter(sys.s)
g_cur_scene = sys.c # scene owning this handler (null if global) — for `become`
ll_t = 0; ll_lbl = 0; g_term = false; loc_reset(); nloop = 0; nself = 0
ret_ty = "void"
@ -19,6 +20,7 @@ function emit_system_fn(sys: Node) -> void {
emit(buf_str(falloc))
emit(buf_str(fbody))
emit("}\n\n")
g_det_ctx = ""
}
# one enable-gated call to @sys_<d.s> (skipped while the handler is disabled).

View file

@ -26,9 +26,8 @@ function emit_init_component(e: pointer, comp: pointer, rec: Node) -> void {
let addr = nreg()
emit(" "); emit(addr); emit(" = getelementptr inbounds %Cmp_"); emit(comp); emit(", ptr "); emit(slot); emit(", i32 0, i32 "); emit(itoa(f)); emit("\n")
let lt = llty(fd.ty)
var v = "0"
if (lt == "ptr") { v = "null" }
if (fd.a != null) { let dv = emit_expr(fd.a); v = dv.code }
var v = zero_of(lt)
if (fd.a != null) { let dv = emit_expr(fd.a); v = coerce_code(dv, fd.ty) }
emit(" store "); emit(lt); emit(" "); emit(v); emit(", ptr "); emit(addr); emit("\n")
f += 1
}
@ -42,7 +41,9 @@ function emit_init_component(e: pointer, comp: pointer, rec: Node) -> void {
let addr = nreg()
emit(" "); emit(addr); emit(" = getelementptr inbounds %Cmp_"); emit(comp); emit(", ptr "); emit(slot); emit(", i32 0, i32 "); emit(itoa(fidx)); emit("\n")
let dv = emit_expr(fi.a)
emit(" store "); emit(llty(field_type(c, fi.s))); emit(" "); emit(dv.code); emit(", ptr "); emit(addr); emit("\n")
let fty = field_type(c, fi.s)
let dcode = coerce_code(dv, fty)
emit(" store "); emit(llty(fty)); emit(" "); emit(dcode); emit(", ptr "); emit(addr); emit("\n")
}
j += 1
}

View file

@ -10,6 +10,16 @@ function is_math_builtin(name: pointer) -> bool {
}
function emit_math_builtin(name: pointer, e: Node) -> Val {
if len(e.kids) == 0 { return emit_math_builtin_fixed(name, e) }
let orig = e.kids[0]
let fpv = math_fp_route(name, e)
if (fpv != null) { return fpv }
let r = emit_math_builtin_fixed(name, e)
e.kids[0] = orig # the node may be emitted again elsewhere
return r
}
function emit_math_builtin_fixed(name: pointer, e: Node) -> Val {
if (name == "abs") {
let a = emit_expr(e.kids[0])
let c = emit_bind(`icmp slt i32 {a.code}, 0`)
@ -79,9 +89,31 @@ function is_math_ns(meth: pointer) -> bool {
return false
}
# a float/double call goes to emit_fp_math; otherwise the evaluated first
# argument is left in place (E_PREVAL) for the fixed-point path. null = fixed.
function math_fp_route(meth: pointer, e: Node) -> Val {
if len(e.kids) == 0 { return null }
if (e.kids[0].kind == E_PREVAL) { return null }
let first = emit_expr(e.kids[0])
let t = fp_math_type(first, e)
if not (t == "") { return emit_fp_math(meth, t, first, e) }
e.kids[0] = preval_node(first)
return null
}
function emit_math_ns(meth: pointer, e: Node) -> Val {
if len(e.kids) == 0 { return emit_math_ns_fixed(meth, e) }
let orig = e.kids[0]
let fpv = math_fp_route(meth, e)
if (fpv != null) { return fpv }
let r = emit_math_ns_fixed(meth, e)
e.kids[0] = orig # the node may be emitted again elsewhere
return r
}
function emit_math_ns_fixed(meth: pointer, e: Node) -> Val {
if (meth == "min") or (meth == "max") or (meth == "abs") or (meth == "clamp") {
return emit_math_builtin(meth, e)
return emit_math_builtin_fixed(meth, e)
}
if (meth == "sign") { # sign(x) -> -1 / 0 / 1 (int)
let a = emit_expr(e.kids[0])

View file

@ -70,6 +70,7 @@ const E_NEW: int = 39
const E_FLOAT: int = 40
const E_REC: int = 41
const E_FINIT: int = 42
const E_PREVAL: int = 190 # an already-evaluated value (emit_float.ludic preval_node)
const E_NULL: int = 43 # the `null` pointer literal
const E_SLICE: int = 44 # s[a..b] — substring (a=base, b=start, c=end)

View file

@ -136,8 +136,10 @@ function lex_at(src: pointer, first_line: int) -> void {
continue
}
var v = 0
let nstart = i
while i < n and char_is_digit(src[i]) { v = v * 10 + (src[i] - 48); i += 1 }
# a fractional part makes it a Q16.16 fixed literal
# a fractional part makes it a Q16.16 fixed literal (its text is kept: in a
# float context the literal is exactly that decimal instead)
if i < n and src[i] == '.' and char_is_digit(src[i + 1]) {
i += 1
# Accumulate only the first 4 fractional digits: `fnum << 16` must stay
@ -149,7 +151,7 @@ function lex_at(src: pointer, first_line: int) -> void {
i += 1
}
let bits = (v << 16) + ((fnum << 16) + (fden >> 1)) / fden
tok_push(TK_FLOAT, null, bits, line)
tok_push(TK_FLOAT, src[nstart..i], bits, line)
continue
}
tok_push(TK_INT, null, v, line)

View file

@ -184,7 +184,7 @@ function p_primary() -> Node {
if is_op("[") { return parse_list() }
if t.kind == TK_ID and (t.text == "emit") and (toks[pi + 1].kind == TK_ID) { return parse_emit() }
if t.kind == TK_INT { let n = node(E_INT); n.ival = t.ival; pi += 1; return n }
if t.kind == TK_FLOAT { let n = node(E_FLOAT); n.ival = t.ival; pi += 1; return n }
if t.kind == TK_FLOAT { let n = node(E_FLOAT); n.ival = t.ival; n.s = t.text; pi += 1; return n }
if t.kind == TK_STR { let n = node(E_STR); n.s = t.text; pi += 1; return n }
if t.kind == TK_ID {
if (t.text == "true") { let n = node(E_BOOL); n.ival = 1; pi += 1; return n }
@ -769,6 +769,7 @@ function parse_one_decl() -> void {
var esys_comp: pointer = null # @EngineSystem(Comp, Phase) — a package compile-time engine system (#62)
var esys_phase: pointer = null
var ns_name: pointer = null # @Namespace(Name) — a package-provided Foo.* namespace (#62)
var is_det = false # @deterministic — no floating point inside (emit_float.ludic)
while is_op("@") {
pi += 1; let a = eat_id() # collect a leading @annotation
if a == "export" { is_export = true }
@ -796,6 +797,7 @@ function parse_one_decl() -> void {
else if a == "System" { sys_phase = "Update"; if is_op("(") { pi += 1; sys_phase = eat_id(); eat_op(")") } } # @System(Phase) binary-module system (#64)
else if a == "EngineSystem" { eat_op("("); esys_comp = eat_id(); eat_op(","); esys_phase = eat_id(); eat_op(")") } # @EngineSystem(Comp, Phase) package engine system (#62)
else if a == "Namespace" { eat_op("("); ns_name = eat_id(); eat_op(")") } # @Namespace(Name) package Foo.* namespace (#62)
else if a == "deterministic" { is_det = true }
else if a == "ClearColor" { # @ClearColor(colour) — Render auto-clear + auto-present (#86)
eat_op("(")
g_clear_color = expr() # a literal, a `const`, or a Color.Name — resolved when emitted
@ -835,6 +837,7 @@ function parse_one_decl() -> void {
if is_id("prefab") { push(prog, parse_prefab()); return } # a model with preset component fields
if is_id("handler") {
let h = parse_system()
if is_det { push(g_det_names, h.s) }
if (role != null) { if (role == "server") { h.ival = 1 } else { h.ival = 2 } } # N5: @Server=1 / @Predicted=2
if (on_event != null) { register_onlisten(on_event, h.a); return } # @On(Event) listener
if (onspawn_model != null) {
@ -870,6 +873,7 @@ function parse_one_decl() -> void {
if is_id("const") { push(prog, parse_const()); return }
if is_id("function") {
let f = parse_fn()
if is_det { push(g_det_names, f.s) }
if is_export { f.ival = 1 }
if (sys_phase != null) { push(g_mod_sys_fn, f.s); push(g_mod_sys_phase, sys_phase) } # #64: register at load
if (esys_comp != null) { push(g_esys_comp, esys_comp); push(g_esys_fn, f.s); push(g_esys_phase, esys_phase) } # #62: package engine system

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -17,7 +17,7 @@ packages cleanly can still fail to load:
scheduled-for-removal API usages.
- `./gradlew runIde` boots a real IDEA 2025.3 with the plugin installed. The
sandbox log line to look for is
`Loaded custom plugins: LSP4IJ (0.20.1), Ludic (1.4.0)` in
`Loaded custom plugins: LSP4IJ (0.20.1), Ludic (1.5.0)` in
`.intellijPlatform/sandbox/Ludic/IC-2025.3/log/idea.log`.
**If the plugin does not appear in Settings -> Plugins after installing**, the
@ -35,7 +35,7 @@ as the error message, which is not obvious the first time you see it:
```bash
export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS
cd tools/editors/jetbrains
./gradlew buildPlugin # -> build/distributions/Ludic-1.4.0.zip
./gradlew buildPlugin # -> build/distributions/Ludic-1.5.0.zip
./gradlew runIde # try it in a sandbox IDE
```

View file

@ -1,6 +1,6 @@
pluginGroup = io.ludic.ide
pluginName = Ludic
pluginVersion = 1.4.0
pluginVersion = 1.5.0
# 2024.2 (242) is the oldest platform LSP4IJ 0.20+ installs on, so it is the
# floor here too: claiming older would let an IDE accept this plugin and then

View file

@ -71,7 +71,7 @@ object LudicVocabulary {
"enable", "disable", "match", "machine", "prefab", "state", "become", "where",
"and", "or", "not", "break", "continue", "new", "emit", "cancel", "try"
)
val PRIMITIVES = setOf("int", "long", "fixed", "countdown", "bool", "entity", "string", "pointer", "byte", "words", "fixeds", "pointers", "Vector", "IVec2", "Rect", "void")
val PRIMITIVES = setOf("int", "long", "fixed", "float", "double", "countdown", "bool", "entity", "string", "pointer", "byte", "words", "fixeds", "floats", "doubles", "pointers", "Vector", "IVec2", "Rect", "void")
val PHASES = setOf("Start", "Input", "FixedUpdate", "Update", "LateUpdate", "Render", "Overlay")
val WIDGETS = setOf("panel", "col", "row", "label", "button", "image", "spacer")

View file

@ -185,7 +185,7 @@
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on|cancellable|public|layer|start|shows|lasts|loads|then|export|internal)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|extern)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(program|property|model|prefab|namespace|enum|ui|const|var|let|function|fn|handler|entry|event|scene|state|test)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|long|fixed|countdown|bool|entity|string|pointer|byte|words|fixeds|pointers|Vector|IVec2|Rect|void)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|long|fixed|float|double|countdown|bool|entity|string|pointer|byte|words|fixeds|floats|doubles|pointers|Vector|IVec2|Rect|void)\\b" },
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false|null)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render|Overlay)\\b" }
]

View file

@ -185,7 +185,7 @@
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on|cancellable|public|layer|start|shows|lasts|loads|then|export|internal)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|extern)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(program|property|model|prefab|namespace|enum|ui|const|var|let|function|fn|handler|entry|event|scene|state|test)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|long|fixed|countdown|bool|entity|string|pointer|byte|words|fixeds|pointers|Vector|IVec2|Rect|void)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|long|fixed|float|double|countdown|bool|entity|string|pointer|byte|words|fixeds|floats|doubles|pointers|Vector|IVec2|Rect|void)\\b" },
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false|null)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render|Overlay)\\b" }
]

View file

@ -23,6 +23,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/backend/emit_addr.ludic")
push(f, "selfhost/backend/emit_intrin.ludic")
push(f, "selfhost/backend/emit_intrin2.ludic")
push(f, "selfhost/backend/emit_float.ludic")
push(f, "selfhost/backend/stdlib/emit_math.ludic")
push(f, "selfhost/backend/stdlib/emit_vector.ludic")
push(f, "selfhost/backend/stdlib/emit_ivec.ludic")

View file

@ -591,6 +591,7 @@ function cmd_dev_test() -> int {
feat_case("lang/lifecycle", "", "1 700 50 950 2", "lifecycle.ludic (@OnStart/@OnAttach/@OnSpawn/@OnDespawn/@OnQuit in order)")
feat_case("lang/toggle", "", "6 0 7 1 0", "toggle.ludic (enable/disable + @OnDisable/@OnEnable across property/model)")
feat_case("lang/strings", "", "1 2 3 4 5 6 7 8 9", "strings.ludic (str ops, interpolation, slicing)")
feat_case("lang/floats", "", "1 2 3 4 5 6 7 8 9 10 11 12", "floats.ludic (float/double: operators, context-typed literals, conversions, Math)")
feat_case("lang/operators", "", "1 2 3 4 5 6 7 8 9 10", "operators.ludic (compound assignment on fixed/string/long, -fixed, char escapes, list literals)")
feat_case("lang/rng_demo", "", "69 89 6 -1 0", "rng_demo.ludic (Random.value/int/sign)")
feat_case("lang/time_demo", "aaaaaa", "0 1 2 3 4 16", "time_demo.ludic (Time.frame/elapsed/delta)")

View file

@ -166,7 +166,7 @@ function cmd_test_tools() -> int {
# IntelliJ SDK). One env var away: LUDIC_TEST_JETBRAINS=1 ludic-dev test-tools
if (getenv_or("LUDIC_TEST_JETBRAINS", "0") == "1") {
if shq("test -x tools/editors/jetbrains/gradlew") {
if shq("cd tools/editors/jetbrains && ./gradlew buildPlugin verifyPluginStructure --console=plain -q >/dev/null 2>&1 && test -f build/distributions/Ludic-1.4.0.zip") { ok("jetbrains plugin builds and verifies") } else { bad("jetbrains plugin build") }
if shq("cd tools/editors/jetbrains && ./gradlew buildPlugin verifyPluginStructure --console=plain -q >/dev/null 2>&1 && test -f build/distributions/Ludic-1.5.0.zip") { ok("jetbrains plugin builds and verifies") } else { bad("jetbrains plugin build") }
if shq("cd tools/editors/jetbrains && ./gradlew test --console=plain -q >/dev/null 2>&1") { ok("jetbrains platform tests (lexer, file type, PSI, folding)") } else { bad("jetbrains platform tests") }
} else { print(" skip jetbrains plugin (no gradle wrapper)") }
} else {

View file

@ -227,7 +227,7 @@ program LudicLsp {
return c == '&' or c == '|' or c == '^' or c == '~' # & | ^ ~
}
function is_type_word(w: pointer) -> bool {
return (w == "int") or (w == "long") or (w == "fixed") or (w == "countdown") or (w == "bool") or (w == "entity") or (w == "string") or (w == "pointer") or (w == "byte") or (w == "words") or (w == "fixeds") or (w == "pointers") or (w == "Vector") or (w == "IVec2") or (w == "Rect") or (w == "void")
return (w == "int") or (w == "long") or (w == "fixed") or (w == "float") or (w == "double") or (w == "floats") or (w == "doubles") or (w == "countdown") or (w == "bool") or (w == "entity") or (w == "string") or (w == "pointer") or (w == "byte") or (w == "words") or (w == "fixeds") or (w == "pointers") or (w == "Vector") or (w == "IVec2") or (w == "Rect") or (w == "void")
}
function is_phase_word(w: pointer) -> bool {
return (w == "Start") or (w == "Input") or (w == "FixedUpdate") or (w == "Update") or (w == "LateUpdate") or (w == "Render")

View file

@ -71,7 +71,7 @@ static const char* LUDIC_KW_STMT[] = {
"and","or","not","break","continue","new","emit","cancel","try", 0
};
static const char* LUDIC_TYPES[] = {
"int","long","fixed","countdown","bool","entity","string","pointer","byte","words","fixeds","pointers","Vector","IVec2","Rect","void", 0
"int","long","fixed","float","double","countdown","bool","entity","string","pointer","byte","words","fixeds","floats","doubles","pointers","Vector","IVec2","Rect","void", 0
};
static const char* LUDIC_PHASES[] = {
"Start","Input","FixedUpdate","Update","LateUpdate","Render","Overlay", 0