///|
/// JS entry point for the headless capture path.
///
/// `run` checks `globalThis.__kaguraHeadless` before it touches a canvas,
/// a platform driver or `requestAnimationFrame`. When the object is present,
/// the game renders N ticks through the CPU rasterizer and the PNG lands on
/// `globalThis.__kaguraHeadlessFrame` instead of a swapchain. Nothing here
/// reaches for `node:` or a DOM global, so importing the built bundle in a
/// browser behaves exactly as before -- the request object is simply absent.
///
/// JS target only -- see `targets` in `moon.pkg`.
///|
/// Number of update ticks requested, or -1 when no headless render was asked
/// for. A request without an explicit `frames` renders one tick, which is
/// what "just show me the first frame" means.
extern "js" fn js_headless_frames() -> Int =
#| () => {
#| const request = globalThis.__kaguraHeadless;
#| if (request == null || typeof request !== "object") return -1;
#| const value = Number(request.frames);
#| if (!Number.isFinite(value) || value < 0) return 1;
#| return Math.floor(value);
#| }
///|
extern "js" fn js_headless_int(key : String, fallback : Int) -> Int =
#| (key, fallback) => {
#| const request = globalThis.__kaguraHeadless;
#| if (request == null) return fallback;
#| const value = Number(request[key]);
#| if (!Number.isFinite(value) || value <= 0) return fallback;
#| return Math.floor(value);
#| }
///|
extern "js" fn js_headless_double(key : String, fallback : Double) -> Double =
#| (key, fallback) => {
#| const request = globalThis.__kaguraHeadless;
#| if (request == null) return fallback;
#| const value = Number(request[key]);
#| return Number.isFinite(value) ? value : fallback;
#| }
///|
extern "js" fn js_headless_string(key : String) -> String =
#| (key) => {
#| const request = globalThis.__kaguraHeadless;
#| if (request == null) return "";
#| const value = request[key];
#| return value == null ? "" : String(value);
#| }
///|
/// Hand the encoded frame to the host. `png` arrives as the JS target's byte
/// representation; it is normalized to a `Uint8Array` here so the caller can
/// write it out without knowing anything about the MoonBit ABI.
extern "js" fn js_publish_headless_frame(
width : Int,
height : Int,
frames : Int,
png : Bytes,
skipped_commands : Int,
drawn_triangles : Int,
draw_commands : Int,
initial_state : String,
) -> Unit =
#| (width, height, frames, png, skipped_commands, drawn_triangles, draw_commands, initial_state) => {
#| const bytes = png instanceof Uint8Array
#| ? png
#| : new Uint8Array(png.buffer ?? png, png.byteOffset ?? 0, png.byteLength ?? png.length);
#| globalThis.__kaguraHeadlessFrame = {
#| width, height, frames, skipped_commands, drawn_triangles, draw_commands,
#| initial_state: initial_state || null,
#| png: bytes,
#| };
#| }
///|
/// Tell the host what viewport the render actually resolved to.
///
/// The size is decided here, not by the caller: a request without an explicit
/// width takes the example's own. Examples read their CSS size back through
/// `document.querySelector("canvas").getBoundingClientRect()` to scale cursor
/// coordinates, so a host stub reporting a different size silently misplaces
/// every pointer -- publishing the resolved size is what keeps the two agreed.
extern "js" fn js_publish_headless_viewport(width : Int, height : Int) -> Unit =
#| (width, height) => { globalThis.__kaguraHeadlessViewport = { width, height }; }
///|
extern "js" fn js_publish_headless_failure(reason : String) -> Unit =
#| (reason) => { globalThis.__kaguraHeadlessError = reason; }
///|
/// The source images the runtime hooks mirrored out while the game was loading
/// its assets. See `publish_source_image_for_headless` in
/// `platform_web/runtime_hooks`: the pixels cross on a global because the
/// dependency order forbids a direct call.
extern "js" fn js_headless_texture_count() -> Int =
#| () => (globalThis.__kaguraSourceImages?.length ?? 0)
///|
extern "js" fn js_headless_texture_field(index : Int, field : String) -> Int =
#| (index, field) => {
#| const value = globalThis.__kaguraSourceImages?.[index]?.[field];
#| return Number.isFinite(value) ? Math.floor(value) : 0;
#| }
///|
extern "js" fn js_headless_texture_pixels(index : Int) -> Bytes =
#| (index) => {
#| const pixels = globalThis.__kaguraSourceImages?.[index]?.pixels;
#| return pixels instanceof Uint8Array ? pixels : new Uint8Array(0);
#| }
///|
fn collect_headless_textures() -> Array[HeadlessTexture] {
let textures : Array[HeadlessTexture] = []
for index in 0.. Unit,
draw~ : (EngineContext) -> Array[@gfx.DrawTrianglesCommand],
on_frame? : () -> Unit,
width~ : Int,
height~ : Int,
) -> Bool {
let frames = js_headless_frames()
if frames < 0 {
return false
}
if requested_initial_state() != applied_initial_state.val {
js_publish_headless_failure(
"Requested initial state was not applied by the game",
)
return true
}
let input : HeadlessInput = {
cursor_x: js_headless_double("cursor_x", -1.0),
cursor_y: js_headless_double("cursor_y", -1.0),
pressed_keys: parse_headless_keys(js_headless_string("keys")),
}
let render_width = js_headless_int("width", width)
let render_height = js_headless_int("height", height)
js_publish_headless_viewport(render_width, render_height)
let frame = render_headless_frame(
update~,
draw~,
on_frame?,
width=render_width,
height=render_height,
frames~,
input~,
input_at=tick => headless_sequence_input(tick, input),
textures_at=collect_headless_textures,
)
match headless_frame_png(frame) {
Some(png) =>
js_publish_headless_frame(
frame.width,
frame.height,
frame.frames,
png,
frame.skipped_commands,
frame.drawn_triangles,
frame.draw_commands,
applied_initial_state.val.or(""),
)
None => js_publish_headless_failure("failed to encode the frame as PNG")
}
true
}
///|
extern "js" fn js_headless_input_step(tick : Int) -> String =
#| tick => {
#| const inputs = globalThis.__kaguraHeadless?.inputs;
#| if (inputs === undefined) return '';
#| return JSON.stringify(inputs[tick] ?? {cursor_x:-1,cursor_y:-1,keys:[],mouse_buttons:[],gamepads:[]});
#| }
///|
fn headless_sequence_input(
tick : Int,
fallback : HeadlessInput,
) -> @core.InputSnapshot {
let json = js_headless_input_step(tick)
if json == "" {
return headless_input_snapshot(fallback)
}
let step : HeadlessInputStep = @json.from_json(@json.parse(json)) catch {
error => abort("Invalid headless input step: " + error.to_string())
}
step.to_input()
}
///|
pub fn capture_viewport(width : Int, height : Int) -> (Int, Int) {
(js_headless_int("width", width), js_headless_int("height", height))
}
///|
let gpu_capture_for_test : Ref[Bool] = Ref(false)
///|
pub fn is_native_gpu_capture() -> Bool {
gpu_capture_for_test.val
}
///|
pub fn set_gpu_capture_for_test(enabled : Bool) -> Unit {
gpu_capture_for_test.val = enabled
}
///|
fn requested_initial_state() -> String? {
let name = js_headless_string("initial_state")
if name == "" {
None
} else {
Some(name)
}
}
///|
fn record_initial_state_failure(message : String) -> Unit {
js_publish_headless_failure(message)
}