Skip to content

Commit 8562897

Browse files
Ralph Kuepperclaude
andcommitted
feat(linux): first-class Linux backend + Perry 0.5.x string FFI fixes
Linux backend port: - Port 25 missing render/post-FX/profiler/screenshot/impulse FFI from the macOS backend so libbloom_linux.a covers the surface bloom/core's TS layer calls into. Without these, perry compile fails to link any game on Linux with undefined-reference errors for bloom_take_screenshot, bloom_set_render_scale/upscale_mode/cas_strength/auto_resolution, bloom_set_dof/ssgi/fog/vignette/film_grain/sun_shafts/chromatic_aberration, bloom_set_taa_enabled/auto_exposure/manual_exposure/env_intensity, bloom_set_env_clear_from_hdr, bloom_get_physical_width/height, bloom_get_render_scale, bloom_splat_impulse, bloom_profiler_overlay_text, bloom_profiler_frame_history. EngineState already exposed the underlying renderer methods; the wrappers are platform-agnostic. - Add native/linux/bundle-jolt.sh: merges cmake-built libbloom_jolt.a + libJolt.a into libbloom_linux_bundled.a so perry's single-staticlib link picks up the JPH:: symbols. Output to a separate filename so cargo's hardlink-based caching doesn't revert the merge on the next cargo build. - package.json linux target: lib -> libbloom_linux_bundled.a, libs:["stdc++"]. Add `image` dep for HDR env loading. Perry 0.5.x string-FFI fixes (affects all platforms): - Add bloom_shared::string_header::alloc_perry_string with the new 20-byte StringHeader layout (utf16_len, byte_len, capacity, refcount, flags). Old engine code wrote a 12-byte 0.4.x layout; perry's 0.5.x runtime reads 8 bytes into the payload, returning strings with a garbage prefix and trailing read-past-end. - Replace all hand-rolled 12-byte allocations in linux + macos (bloom_read_file, bloom_get_clipboard_text, bloom_open_file_dialog, bloom_save_file_dialog, bloom_profiler_overlay_text, bloom_profiler_frame_history) with the helper. - Return alloc_perry_string("") instead of std::ptr::null() on miss in bloom_read_file / clipboard / dialog paths. Perry NaN-boxes a null string-typed return as a string at address 0; subsequent .length / .charCodeAt segfault. Empty-string return makes `data.length === 0` the correct miss check (matches what the jump game's discoverLevels already assumes). - package.json: declare bloom_read_file, bloom_get_clipboard_text, bloom_open_file_dialog, bloom_save_file_dialog as returns:"string" so perry codegen NaN-boxes the pointer as a string instead of sitofp'ing it to a number (which made `data.length` return 0 and `data.charCodeAt(i)` print decimal digits of the pointer value). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c668e56 commit 8562897

7 files changed

Lines changed: 315 additions & 125 deletions

File tree

native/linux/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

native/linux/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ jolt = ["bloom-shared/jolt"]
1313

1414
[dependencies]
1515
bloom-shared = { path = "../shared", default-features = false, features = ["mp3"] }
16+
image = { version = "0.25", default-features = false, features = ["hdr"] }
1617
raw-window-handle = "0.6"
1718
wgpu = "29"
1819
libc = "0.2"

native/linux/bundle-jolt.sh

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env bash
2+
# Merge libbloom_jolt.a + libJolt.a (built by bloom-shared/build.rs via cmake)
3+
# into libbloom_linux.a. Perry's link step only sees the single staticlib
4+
# named in package.json's perry.nativeLibrary.targets.linux.lib, so the
5+
# C++ Jolt symbols would otherwise be unresolved.
6+
set -euo pipefail
7+
8+
cd "$(dirname "$0")"
9+
TARGET_DIR="target/release"
10+
SRC_LIB="$TARGET_DIR/libbloom_linux.a"
11+
# Output to a separate filename so cargo's hardlink caching doesn't revert
12+
# our merge on the next `cargo build` (cargo restores the cached copy of
13+
# libbloom_linux.a via hardlink). Perry's package.json points at this name.
14+
OUT_LIB="$TARGET_DIR/libbloom_linux_bundled.a"
15+
16+
if [[ ! -f "$SRC_LIB" ]]; then
17+
echo "bundle-jolt: $SRC_LIB not found — run 'cargo build --release' first" >&2
18+
exit 1
19+
fi
20+
MAIN_LIB="$SRC_LIB"
21+
22+
JOLT_SHIM=$(find "$TARGET_DIR/build" -name libbloom_jolt.a -path "*/out/lib/*" | head -1)
23+
JOLT_LIB=$(find "$TARGET_DIR/build" -name libJolt.a -path "*/out/lib/*" | head -1)
24+
25+
if [[ -z "$JOLT_SHIM" || -z "$JOLT_LIB" ]]; then
26+
echo "bundle-jolt: could not locate libbloom_jolt.a / libJolt.a under $TARGET_DIR/build" >&2
27+
exit 1
28+
fi
29+
30+
WORK=$(mktemp -d)
31+
trap 'rm -rf "$WORK"' EXIT
32+
33+
# Extract each archive into its own subdir so identical object names
34+
# (e.g. bloom_jolt.cpp.o) from different archives don't overwrite each
35+
# other, then rename with a per-archive prefix before merging.
36+
extract() {
37+
local archive="$1" prefix="$2"
38+
local subdir="$WORK/$prefix"
39+
mkdir -p "$subdir"
40+
( cd "$subdir" && ar x "$archive" )
41+
for f in "$subdir"/*.o; do
42+
mv "$f" "$WORK/${prefix}_$(basename "$f")"
43+
done
44+
rmdir "$subdir"
45+
}
46+
47+
extract "$(realpath "$MAIN_LIB")" bloom
48+
extract "$(realpath "$JOLT_SHIM")" jshim
49+
extract "$(realpath "$JOLT_LIB")" jolt
50+
51+
# Build the merged staticlib. Output to OUT_LIB so cargo doesn't clobber it
52+
# via its hardlink cache.
53+
rm -f "$OUT_LIB"
54+
ar crs "$OUT_LIB" "$WORK"/*.o
55+
56+
echo "bundle-jolt: merged $(ls "$WORK"/*.o | wc -l) object files into $OUT_LIB"

native/linux/src/lib.rs

Lines changed: 202 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use bloom_shared::engine::EngineState;
22
use bloom_shared::renderer::Renderer;
3-
use bloom_shared::string_header::str_from_header;
3+
use bloom_shared::string_header::{str_from_header, alloc_perry_string};
44
use bloom_shared::audio::{parse_wav, parse_ogg, parse_mp3};
55

66
use std::sync::OnceLock;
@@ -1401,13 +1401,13 @@ pub extern "C" fn bloom_set_cursor_shape(shape: f64) {
14011401
#[no_mangle]
14021402
pub extern "C" fn bloom_set_clipboard_text(_text_ptr: *const u8) {}
14031403
#[no_mangle]
1404-
pub extern "C" fn bloom_get_clipboard_text() -> *const u8 { std::ptr::null() }
1404+
pub extern "C" fn bloom_get_clipboard_text() -> *const u8 { alloc_perry_string("") }
14051405

14061406
// E5b: File dialogs (stub on this platform)
14071407
#[no_mangle]
1408-
pub extern "C" fn bloom_open_file_dialog(_filter_ptr: *const u8, _title_ptr: *const u8) -> *const u8 { std::ptr::null() }
1408+
pub extern "C" fn bloom_open_file_dialog(_filter_ptr: *const u8, _title_ptr: *const u8) -> *const u8 { alloc_perry_string("") }
14091409
#[no_mangle]
1410-
pub extern "C" fn bloom_save_file_dialog(_default_name_ptr: *const u8, _title_ptr: *const u8) -> *const u8 { std::ptr::null() }
1410+
pub extern "C" fn bloom_save_file_dialog(_default_name_ptr: *const u8, _title_ptr: *const u8) -> *const u8 { alloc_perry_string("") }
14111411

14121412
// Model bounds accessors. Return the axis-aligned bounding box of a loaded
14131413
// model in model-local coordinates. Editors use these to size gizmos, auto-
@@ -1456,24 +1456,14 @@ pub extern "C" fn bloom_file_exists(path_ptr: *const u8) -> f64 {
14561456
#[no_mangle]
14571457
pub extern "C" fn bloom_read_file(path_ptr: *const u8) -> *const u8 {
14581458
let path = str_from_header(path_ptr);
1459+
// Always return a valid Perry string. A null pointer would NaN-box into a
1460+
// string-typed JS value pointing at address 0; subsequent `.length` /
1461+
// `.charCodeAt` reads dereference the bogus StringHeader and segfault.
1462+
// Callers detect "missing file" via `data.length === 0` (e.g. the
1463+
// jump game's discoverLevels probe across level1..level10 / custom_*).
14591464
match std::fs::read_to_string(path) {
1460-
Ok(contents) => {
1461-
// Return Perry-format string: StringHeader (length u32 + capacity u32 + refcount u32) followed by UTF-8 data
1462-
let bytes = contents.as_bytes();
1463-
let len = bytes.len();
1464-
let total = 12 + len; // 12 bytes header (3 × u32) + data
1465-
let layout = std::alloc::Layout::from_size_align(total, 4).unwrap();
1466-
unsafe {
1467-
let ptr = std::alloc::alloc(layout);
1468-
if ptr.is_null() { return std::ptr::null(); }
1469-
*(ptr as *mut u32) = len as u32; // length
1470-
*(ptr.add(4) as *mut u32) = len as u32; // capacity
1471-
*(ptr.add(8) as *mut u32) = 1; // refcount (unique)
1472-
std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr.add(12), len);
1473-
ptr
1474-
}
1475-
}
1476-
Err(_) => std::ptr::null(),
1465+
Ok(contents) => alloc_perry_string(&contents),
1466+
Err(_) => alloc_perry_string(""),
14771467
}
14781468
}
14791469

@@ -2200,6 +2190,197 @@ pub extern "C" fn bloom_set_sss_enabled(on: f64) {
22002190
engine().renderer.set_sss_enabled(on != 0.0);
22012191
}
22022192

2193+
// ============================================================
2194+
// Render scale / upscale / DRS / post-FX / screenshots / impulse
2195+
// Ports of the macOS FFI surface so the bloom/core TS layer links
2196+
// cleanly on Linux. EngineState in bloom-shared already exposes the
2197+
// underlying renderer methods, so these wrappers are platform-agnostic.
2198+
// ============================================================
2199+
2200+
#[no_mangle]
2201+
pub extern "C" fn bloom_take_screenshot(path_ptr: *const u8) {
2202+
let path = str_from_header(path_ptr).to_string();
2203+
let eng = engine();
2204+
eng.renderer.screenshot_requested = true;
2205+
eng.renderer.pending_screenshot_path = Some(path);
2206+
}
2207+
2208+
#[no_mangle]
2209+
pub extern "C" fn bloom_set_env_clear_from_hdr(path_ptr: *const u8) {
2210+
use image::ImageDecoder;
2211+
let path = str_from_header(path_ptr).to_string();
2212+
let file = match std::fs::File::open(&path) {
2213+
Ok(f) => f,
2214+
Err(_) => return,
2215+
};
2216+
let decoder = match image::codecs::hdr::HdrDecoder::new(std::io::BufReader::new(file)) {
2217+
Ok(d) => d,
2218+
Err(_) => return,
2219+
};
2220+
let (w, h) = decoder.dimensions();
2221+
let byte_len = (w as usize) * (h as usize) * 3 * 4;
2222+
let mut buf = vec![0u8; byte_len];
2223+
if decoder.read_image(&mut buf).is_err() {
2224+
return;
2225+
}
2226+
let rgb_f32: Vec<f32> = buf
2227+
.chunks_exact(4)
2228+
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
2229+
.collect();
2230+
engine().renderer.load_env_from_hdr(w, h, &rgb_f32);
2231+
}
2232+
2233+
#[no_mangle]
2234+
pub extern "C" fn bloom_set_fog(r: f64, g: f64, b: f64, density: f64, height_ref: f64, height_falloff: f64) {
2235+
let r_ = engine();
2236+
r_.renderer.set_fog_color(r as f32, g as f32, b as f32);
2237+
r_.renderer.set_fog_density(density as f32);
2238+
r_.renderer.set_fog_height_falloff(height_ref as f32, height_falloff as f32);
2239+
}
2240+
2241+
#[no_mangle]
2242+
pub extern "C" fn bloom_set_chromatic_aberration(strength: f64) {
2243+
engine().renderer.set_chromatic_aberration(strength as f32);
2244+
}
2245+
2246+
#[no_mangle]
2247+
pub extern "C" fn bloom_set_vignette(strength: f64, softness: f64) {
2248+
engine().renderer.set_vignette(strength as f32, softness as f32);
2249+
}
2250+
2251+
#[no_mangle]
2252+
pub extern "C" fn bloom_set_film_grain(strength: f64) {
2253+
engine().renderer.set_film_grain(strength as f32);
2254+
}
2255+
2256+
#[no_mangle]
2257+
pub extern "C" fn bloom_set_sun_shafts(strength: f64, decay: f64, r: f64, g: f64, b: f64) {
2258+
let eng = engine();
2259+
eng.renderer.set_sun_shaft_strength(strength as f32);
2260+
eng.renderer.set_sun_shaft_decay(decay as f32);
2261+
eng.renderer.set_sun_shaft_color(r as f32, g as f32, b as f32);
2262+
}
2263+
2264+
#[no_mangle]
2265+
pub extern "C" fn bloom_set_auto_exposure(on: f64) {
2266+
engine().renderer.set_auto_exposure(on != 0.0);
2267+
}
2268+
2269+
#[no_mangle]
2270+
pub extern "C" fn bloom_set_taa_enabled(on: f64) {
2271+
engine().renderer.set_taa_enabled(on != 0.0);
2272+
}
2273+
2274+
#[no_mangle]
2275+
pub extern "C" fn bloom_set_render_scale(scale: f64) {
2276+
engine().renderer.set_render_scale(scale as f32);
2277+
}
2278+
2279+
#[no_mangle]
2280+
pub extern "C" fn bloom_get_render_scale() -> f64 {
2281+
engine().renderer.render_scale() as f64
2282+
}
2283+
2284+
#[no_mangle]
2285+
pub extern "C" fn bloom_set_upscale_mode(mode: f64) {
2286+
engine().renderer.set_upscale_mode(mode as u32);
2287+
}
2288+
2289+
#[no_mangle]
2290+
pub extern "C" fn bloom_set_cas_strength(strength: f64) {
2291+
engine().renderer.set_cas_strength(strength as f32);
2292+
}
2293+
2294+
#[no_mangle]
2295+
pub extern "C" fn bloom_get_physical_width() -> f64 {
2296+
engine().renderer.physical_width() as f64
2297+
}
2298+
2299+
#[no_mangle]
2300+
pub extern "C" fn bloom_get_physical_height() -> f64 {
2301+
engine().renderer.physical_height() as f64
2302+
}
2303+
2304+
#[no_mangle]
2305+
pub extern "C" fn bloom_set_auto_resolution(target_hz: f64, enabled: f64) {
2306+
let eng = engine();
2307+
if enabled != 0.0 {
2308+
let current = eng.renderer.render_scale();
2309+
eng.drs.enable(target_hz as f32, current);
2310+
} else {
2311+
eng.drs.disable();
2312+
}
2313+
}
2314+
2315+
#[no_mangle]
2316+
pub extern "C" fn bloom_set_manual_exposure(value: f64) {
2317+
engine().renderer.set_manual_exposure(value as f32);
2318+
}
2319+
2320+
#[no_mangle]
2321+
pub extern "C" fn bloom_set_env_intensity(intensity: f64) {
2322+
engine().renderer.set_env_intensity(intensity as f32);
2323+
}
2324+
2325+
#[no_mangle]
2326+
pub extern "C" fn bloom_set_ssgi_enabled(enabled: f64) {
2327+
engine().renderer.set_ssgi_enabled(enabled != 0.0);
2328+
}
2329+
2330+
#[no_mangle]
2331+
pub extern "C" fn bloom_set_ssgi_intensity(intensity: f64) {
2332+
engine().renderer.set_ssgi_intensity(intensity as f32);
2333+
}
2334+
2335+
#[no_mangle]
2336+
pub extern "C" fn bloom_set_ssgi_radius(radius: f64) {
2337+
engine().renderer.set_ssgi_radius(radius as f32);
2338+
}
2339+
2340+
#[no_mangle]
2341+
pub extern "C" fn bloom_set_dof(enabled: f64, focus_distance: f64, aperture: f64) {
2342+
let r = &mut engine().renderer;
2343+
r.set_dof_enabled(enabled != 0.0);
2344+
r.set_dof_focus_distance(focus_distance as f32);
2345+
r.set_dof_aperture(aperture as f32);
2346+
}
2347+
2348+
#[no_mangle]
2349+
pub extern "C" fn bloom_splat_impulse(x: f64, z: f64, radius: f64, strength: f64) {
2350+
engine().renderer.impulse_field.submit_splat(
2351+
x as f32, z as f32, radius as f32, strength as f32,
2352+
);
2353+
}
2354+
2355+
#[no_mangle]
2356+
pub extern "C" fn bloom_profiler_frame_history() -> *const u8 {
2357+
let hist = engine().profiler.frame_history();
2358+
let mut s = String::with_capacity(hist.len() * 24);
2359+
for (cpu, gpu) in &hist {
2360+
s.push_str(&format!("{:.2}|{:.2}\n", cpu, gpu));
2361+
}
2362+
alloc_perry_string(&s)
2363+
}
2364+
2365+
#[no_mangle]
2366+
pub extern "C" fn bloom_profiler_overlay_text() -> *const u8 {
2367+
let snap = engine().profiler.snapshot();
2368+
let mut s = String::with_capacity(snap.len() * 48);
2369+
for (label, cpu, gpu) in &snap {
2370+
s.push_str(label);
2371+
s.push('|');
2372+
s.push_str(&format!("{:.2}", cpu));
2373+
s.push('|');
2374+
match gpu {
2375+
Some(g) => s.push_str(&format!("{:.2}", g)),
2376+
None => s.push_str("-1"),
2377+
}
2378+
s.push('\n');
2379+
}
2380+
alloc_perry_string(&s)
2381+
}
2382+
2383+
22032384
// ============================================================
22042385
// Profiler — CPU phase timings (always available) + GPU timestamps
22052386
// (when the adapter supports TIMESTAMP_QUERY). Disabled by default.

0 commit comments

Comments
 (0)