Summary
On --target android (aarch64-linux-android), reads of numeric fields from a plain object return incorrect values. The bug affects any obj.field read of a numeric field — not just reads feeding FFI arguments. Comparisons between two fields return the wrong boolean, arithmetic using fields produces NaN, and FFI calls that take obj.field as an f64 parameter receive NaN.
All other targets tested (native macOS, iOS, tvOS) emit correct code for the same source.
Perry version: 0.5.146
NDK: 30.0.14904198, arch aarch64-linux-android24
Device: Pixel 4a, Adreno 618, Android 14, Vulkan backend
Minimal reproducer
repro.ts:
interface Rect { x: number; y: number; width: number; height: number; }
function overlapsPrim(
ax: number, ay: number, aw: number, ah: number,
bx: number, by: number, bw: number, bh: number,
): boolean {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
function overlapsObj(a: Rect, b: Rect): boolean {
return a.x < b.x + b.width
&& a.x + a.width > b.x
&& a.y < b.y + b.height
&& a.y + a.height > b.y;
}
const A: Rect = { x: 10.0, y: 20.0, width: 100.0, height: 50.0 };
const B: Rect = { x: 50.0, y: 30.0, width: 20.0, height: 20.0 };
const prim = overlapsPrim(A.x, A.y, A.width, A.height, B.x, B.y, B.width, B.height);
const obj = overlapsObj(A, B);
console.log("prim =", prim);
console.log("obj =", obj);
console.log("A.x =", A.x, "A.y =", A.y, "A.width =", A.width, "A.height =", A.height);
if (prim !== obj) console.log("BUG: overlapsPrim and overlapsObj disagree");
Host build (works)
$ perry compile repro.ts -o repro && ./repro
prim = true
obj = true
A.x = 10 A.y = 20 A.width = 100 A.height = 50
Android build (broken)
$ ANDROID_NDK_HOME=... perry compile --target android repro.ts -o repro_android
The android output is a shared-object-style ELF intended to be loaded via System.loadLibrary from an Android activity (running the ELF via adb shell SIGILLs due to Pixel seccomp — unrelated). Loaded inside a proper JNI context (via Bloom Engine's Android activity harness), the expected logs never print with correct values:
A.x, A.y, A.width, A.height print as NaN
overlapsObj returns false for clearly overlapping rects
overlapsPrim (which only uses primitive args) returns true as expected
BUG: …disagree fires
Observed impact in a production app
Bloom Jump (2D platformer, src/main.ts built with perry compile --target android) hit this bug in every form, over time:
| Pattern |
Symptom on Android |
Workaround |
drawText(text, x, y, size, color: Color) → bloom_draw_text(…, color.r, color.g, color.b, color.a) |
Text invisible (glyph quads dropped when any channel is NaN) |
drawTextRgba(text, x, y, size, r, g, b, a) taking primitives |
loadMusic(path) returning { handle: number }, then bloom_play_music(music.handle) |
Music silently never plays (NaN as usize == 0) |
loadMusicRaw/playMusicRaw returning/taking a raw number |
beginMode2D(camera: Camera2D) → bloom_begin_mode_2d(camera.offset.x, camera.target.x, camera.zoom, …) |
Entire scene invisible inside the camera mode; background + HUD drawn outside it work |
beginMode2DRaw(ox, oy, tx, ty, rot, zoom) taking primitives |
const ATLAS_ID = loadTexture("atlas").id; at module scope |
All textured quads invisible (ID is NaN → native rejects draw) |
Call bloom_load_texture(path) FFI directly, store the returned number |
Pure-TS checkCollisionRecs(a: Rect, b: Rect) (no FFI involved) |
Always returns false — player can't collect coins, enemies never hurt/get stomped |
Flat [x,y,w,h] arrays + primitive-arg aabbOverlap(ax,ay,…) |
The last row is the most telling: there is no FFI in the call path at all. The miscompilation is at the obj.field read itself.
Why I think it's codegen, not source
- Same TS compiles and runs correctly on every non-android target (macOS, iOS, tvOS produced from the same
main.ts).
- Replacing any
obj.field read with an equivalent flat-array arr[idx] read or primitive local variable immediately fixes the symptom at that call site.
- The source patterns above are idiomatic TypeScript and match how Perry itself writes engine wrappers (e.g.
engine/src/shapes/index.ts:checkCollisionRecs).
What would help
- A fix in the aarch64-linux-android codegen for
obj.field (numeric-field) reads.
- In the meantime, even a diagnostic that flags when
obj.field reads flow through the affected code path would save users from silent failure — the production symptoms (invisible text, silent audio, invisible rendering, broken collision) all fail silently with no runtime error.
Happy to run further experiments on request. Bloom Jump's workarounds live at:
engine/src/text/index.ts → drawTextRgba
engine/src/audio/index.ts → loadMusicRaw family
engine/src/core/index.ts → beginMode2DRaw
engine/src/textures/index.ts → drawTextureProRaw
jump/src/main.ts — uses all of the above + flat-array collision rects
Summary
On
--target android(aarch64-linux-android), reads of numeric fields from a plain object return incorrect values. The bug affects anyobj.fieldread of a numeric field — not just reads feeding FFI arguments. Comparisons between two fields return the wrong boolean, arithmetic using fields produces NaN, and FFI calls that takeobj.fieldas anf64parameter receive NaN.All other targets tested (native macOS, iOS, tvOS) emit correct code for the same source.
Perry version: 0.5.146
NDK: 30.0.14904198, arch aarch64-linux-android24
Device: Pixel 4a, Adreno 618, Android 14, Vulkan backend
Minimal reproducer
repro.ts:Host build (works)
Android build (broken)
The android output is a shared-object-style ELF intended to be loaded via
System.loadLibraryfrom an Android activity (running the ELF viaadb shellSIGILLs due to Pixel seccomp — unrelated). Loaded inside a proper JNI context (via Bloom Engine's Android activity harness), the expected logs never print with correct values:A.x,A.y,A.width,A.heightprint asNaNoverlapsObjreturnsfalsefor clearly overlapping rectsoverlapsPrim(which only uses primitive args) returnstrueas expectedBUG: …disagreefiresObserved impact in a production app
Bloom Jump (2D platformer,
src/main.tsbuilt withperry compile --target android) hit this bug in every form, over time:drawText(text, x, y, size, color: Color)→bloom_draw_text(…, color.r, color.g, color.b, color.a)drawTextRgba(text, x, y, size, r, g, b, a)taking primitivesloadMusic(path)returning{ handle: number }, thenbloom_play_music(music.handle)NaN as usize == 0)loadMusicRaw/playMusicRawreturning/taking a raw numberbeginMode2D(camera: Camera2D)→bloom_begin_mode_2d(camera.offset.x, camera.target.x, camera.zoom, …)beginMode2DRaw(ox, oy, tx, ty, rot, zoom)taking primitivesconst ATLAS_ID = loadTexture("atlas").id;at module scopebloom_load_texture(path)FFI directly, store the returned numbercheckCollisionRecs(a: Rect, b: Rect)(no FFI involved)false— player can't collect coins, enemies never hurt/get stomped[x,y,w,h]arrays + primitive-argaabbOverlap(ax,ay,…)The last row is the most telling: there is no FFI in the call path at all. The miscompilation is at the
obj.fieldread itself.Why I think it's codegen, not source
main.ts).obj.fieldread with an equivalent flat-arrayarr[idx]read or primitive local variable immediately fixes the symptom at that call site.engine/src/shapes/index.ts:checkCollisionRecs).What would help
obj.field(numeric-field) reads.obj.fieldreads flow through the affected code path would save users from silent failure — the production symptoms (invisible text, silent audio, invisible rendering, broken collision) all fail silently with no runtime error.Happy to run further experiments on request. Bloom Jump's workarounds live at:
engine/src/text/index.ts→drawTextRgbaengine/src/audio/index.ts→loadMusicRawfamilyengine/src/core/index.ts→beginMode2DRawengine/src/textures/index.ts→drawTextureProRawjump/src/main.ts— uses all of the above + flat-array collision rects