Native frontend and Zig application split
The engine uses native desktop process frontends with a small opaque C ABI:
| Platform | Native entry point | Graphics path |
|---|---|---|
| macOS | src/hikari/src/native_frontends/macOS/main.mm | Metal |
| Windows | src/hikari/src/native_frontends/Windows/main.cpp | D3D12 |
The public C header is src/hikari/include/hikari_frontend.h (ABI version 9). Native code owns OS event loops. Zig App and EditorApp implementations own application/session state and translate callbacks through src/hikari/src/application/ (abi.zig lockstep with the header).
The platform bridge under src/hikari/src/native/{macOS,Windows}/src/ is split by ownership rather than by OS callback name:
| Domain | Owns |
|---|---|
application/ | Boot/teardown sequencing, the thin AppKit/Win32 lifecycle facade, and application menu commands |
startup/ | Splash state, drawing, compositor commit, and final-window handoff |
windowing/ | Primary/tool window lifetime, native id registry, event routing, DPI/scale, and custom chrome |
frame/ | Frame wake/coalescing, display cadence, vsync, and CPU pacing |
system/ | Cached appearance, power-source, and power-saver state plus OS notifications |
services/ | Dialogs, clipboard, and Finder/Explorer integration |
root src/ | Renderer/input/audio/accessibility/secure-storage implementations that are already cohesive native subsystems |
application/ is the only lifecycle orchestrator; it does not absorb implementations from the other domains. Frontends use platform include/Application.h and include/Windowing.h, not private Objective-C classes or Win32 registry state. Kaji compiles every domain into its own object-cache directory and links the recursive object set; compile-many removes orphaned objects and static archives are recreated so a source move or deletion cannot leave stale code in a product.
ABI v9 (product identity, window identity, durable services, frame clock, presentation readiness, frame admission) — required order: create → set_frontend_ops → window_config / product_identity / startup_presentation → start → launch → device-init → tick/render → shutdown → destroy. Product identity comes from src/hikari/src/version.zig; each release carries its number, subtitle, and slogan together, shared by logs, editor compatibility checks, About, and native startup chrome. A native editor shows a compact, theme-aware splash after start and commits it to the compositor (macOS: applicationWillFinishLaunching + CA flush / occlusion wait; Windows: RedrawWindow + DwmFlush) before launch blocks the UI thread on AssetStore / shader work. Without that commit, orderFront / ShowWindow only update the backing store and the plate appears after launch — then vanishes on the first present. Heavyweight editor/session creation stays deferred to launch, while the final editor window prepares invisibly. Launch must precede renderer device-init (shaders load only through AssetStore bound at launch). The splash uses the embedded About artwork and remains visible until is_presentation_ready reports that the render thread completed a primary present. macOS then grows/fades the splash into the ready editor; reduced-motion preference requests a direct handoff. Standalone on_tick may return HIKARI_RESULT_FRAME_DEFERRED: the frontend must skip on_renderer_render for that attempt and continue event/maintenance processing. Standalone durable services run while the application is running, including admission stalls; editor durable services also run while suspended. While suspended, tick/render remain invalid; editor frontends may call on_durable_service to pump bounded main-thread services such as MCP. Window notifications (on_resize, on_window_focus_changed) stay valid for as long as a window exists — started, running, or suspended — because the OS posts them from display reconfigures and monitor moves that land while the app is inactive.
Standalone games show their actual primary window with an opaque native cover before session launch/device initialization. The cover defaults to black; Kaji reads startup_background_color (#RRGGBB) from configs/packaging.json and embeds it in the game frontend. No artwork, extra scene, or separate splash window is required. macOS keeps the cover beside the layer-hosting Metal view; Windows uses a child cover. Resize keeps it filling the client area. Editor artwork and reveal behavior are unchanged.
The game App defers the initial scene and queues it through the ordinary budgeted scene loader. The host keeps processing events while scene planning, worker decoding, resource creation and instantiation advance. Native device setup remains synchronous and is timed separately. The cover waits for initial world GPU readiness, then for completion of a new primary submission after that readiness edge; an earlier empty frame cannot reveal the window. An initial scene failure is logged and closes the window. Games may choose a lightweight first scene and load further content with the existing scene APIs.
Standalone startup emits info-level milestones from application creation: native window committed, scene queued, render device ready, CPU assets ready, GPU ready, and the first ready scene frame. Work counters separate scene document IO/decode, planning, store adoption, spawn, asset worker activity, actual asset decodes, CPU GPU-upload staging/submission, and native graphics/compute pipeline creation. Counts and accumulated elapsed times cover completed calls at reveal, including cache-independent decode work; concurrent and nested stages overlap and must not be summed as total startup latency. Upload counters are not GPU timestamp measurements. Scene-job logs also report wall time by phase and pump advances (blocking advances are not frames). Blocking drains wait for both document and asset workgroups; interactive pumps never wait on those groups. Startup counters are disabled after reveal and in the editor, and dynamic drivers adopt the host sample through HostProcessState (driver contract 33).
Frame clock. on_frame_clock is optional and additive: a frontend that owns a
vsync-aligned clock publishes the target present time of the frame it is about to
request, and the sim uses that span instead of measuring the wall clock. It matters
because a tick starts whenever the event loop reaches the coalesced frame, while the
frame it produces is displayed on a vblank — feeding the sim the CPU span makes
travel-per-frame disagree with duration-on-screen, which is visible as motion judder.
macOS publishes CADisplayLink.targetTimestamp from the primary window's link only
(a tool window on a second display has its own cadence and must not drive the sim).
Windows does not publish yet and falls back to the monotonic clock. Linux has no product frontend. Either way
a measured span is clamped to Time.max_measured_delta so a stalled frame cannot
teleport the camera.
| Symbol | Role |
|---|---|
HikariWindowId (PRIMARY=0, INVALID=0xFFFFFFFF) | Native window system assigns ids |
on_resize(app, window_id, w, h, layer_scale) | Per-window physical drawable size + live display scale (PRIMARY on boot path); advisory — frontends drop zero-size frames and log rejections instead of aborting |
on_renderer_device_init | GPU device + primary surface only |
on_renderer_surface_init(app, window_id, surface, scale) | Secondary surfaces only; before device_init → INVALID_STATE; primary id → INVALID_ARGUMENT |
on_window_close_requested → HikariCloseResponse | Cancelable close (CONTINUE / CANCEL) |
on_window_focus_changed | Per-window focus |
HikariFrontendOps + set_frontend_ops | App→frontend vtable (create_window / destroy_window / set_window_title / window_command); registered after create, before start |
on_durable_service | Editor-only main-thread durable bus pump; valid while running or suspended; game returns UNSUPPORTED |
on_frame_clock(app, target_timestamp) | Optional vsync-aligned present stamp (seconds), main thread, before on_tick; unpublished → sim measures the wall clock |
Hard cut — no legacy ABI support. Game product stays single-window (create_window / secondary surface → UNSUPPORTED). Editor Class 1 is shipped: native create_window + WindowId→SurfaceId map for chrome/tool OS windows (Settings, Project Settings, debug tools). Class 2 Asset Preview Viewport (PreviewHost) is shipped; a detached free-floating editor scene window remains deferred. Design: docs/design/multi-window-editor.md, preview-viewport.md.
This split lets the application compile as a dynamic library or as an LLVM object linked into the native executable without changing the process contract.
Driver composition
DriverRecipe selects implementations for:
windowinginputrendererphysicsscriptinggame_uiaudiogpu_preference(auto,high_performance) — D3D12 only; seerendering.md
null selects the host default. A recipe may load default modules and list extra dynamic modules. Modules load before driver creation and remain loaded until all driver instances have been destroyed.
The registry is the one implementation-selection seam. Desktop/tool builds can load compatible engine modules; console configurations use compile-time registered SDK implementations only. Do not add platform switches to shared input, graphics, physics, audio, scene, or UI code.
Dynamic and monolithic products
| Mode | Runtime composition |
|---|---|
dynamic | Native executable plus shared Zig application, platform bridge, and driver dylibs (windowing, input, renderer, physics, scripting, game_ui, audio). |
monolithic | Native linker combines frontend, Zig application, engine, drivers, and platform bridge into one executable. Runtime driver loading is compiled out. |
Editor game modules are always dynamic: opening a project compiles libgame against the thin hikari_game SDK and the editor dlopens it. Game modules talk to the host through HostApi — they do not share Entity/World layout with the editor. --type= only selects driver/application/platform linkage for the editor host. The standalone game product may still bake game code into a monolithic hikari executable.
Vendor middleware and packaged data may remain external in both modes. Monolithic builds reject non-empty dynamic driver module lists.
Adding or changing a driver
- Start from the shared type-erased contract in
src/hikari/src/backend/. - Keep native handles and API-specific types below
src/hikari/src/platform/<platform>/orsrc/hikari/src/native/<platform>/. - Register selection only through
backend.Registry. - Put
@exportofhikari_driver_module_entry_v1on the thin root (src/hikari/src/driver_module_*.zig, which must live undersrc/). PE only surfaces root-module exports; a nested@exportproduces empty stubs on Windows and the module fails symbol lookup. - Preserve lifecycle ownership: destroy every driver before unloading its module.
- Bump
backend/module.zig's contract version if a factory, vtable, or cross-module type changes.on_device_initreturns PODDeviceInitStatus(ok/failed) — notanyerror; Zig error ints are not shared across dylib copies. - Update Metal and D3D12 paths for shared graphics behavior, or document a real capability difference.
Audio follows the same driver pattern (Core Audio AudioQueue / WASAPI device factory via drivers.audio). The engine owns the software mixer, SPSC command queue, and audio worker thread; the device callback only pulls mixed f32. See audio.md.
Logging
Engine logging is centralized in src/hikari/src/log.zig. Executable and driver roots install std_options.logFn; native dependencies use their opt-in callback APIs. In dynamic builds, module-static logging state is not automatically shared with the host, so each dynamic root installs the sink explicitly.
Teeing: every enabled log record goes to:
- The optional process sink (editor bottom console via
diagnostics.Store) - stderr always (so
kaji … --runand IDE terminals show live output) - An optional NDJSON file via
io_stream(log.configureFile/setFilePath):- Editor (project bound):
<project>/.engine/logs/editor-latest.jsonby default (truncate/recreate each launch). Standalone selector: no file until a project is open. - Game: no file unless
--log=<path>is set. --log=<path>/HIKARI_LOG_FILE: redirects (or enables, for game) the file tee. Native frontends export the env before application create.
- Editor (project bound):
Application roots set std_options.log_level = .debug in Debug and .info in release. Both log.emit (including native callbacks and forwarded tool/game output) and the host/SDK logFn callbacks honor the root's log_level and log_scope_levels before sink delivery. Scope overrides can explicitly enable debug in release; there is no unconditional release-mode drop. Filtered records reach neither the UI sink, stderr, nor the NDJSON file.
Use std.log.scoped(...).debug for compile-time levels and scopes. Expensive debug-only arguments or timing capture need a comptime std.log.logEnabled guard as well; asset-load traces and mesh-decode timings follow this rule. log.emit is for already formatted records with runtime levels/scopes. Explicit --metal-trace and --d3d12-trace output remains opt-in and uses info-level records, so release's normal info threshold admits it without enabling ordinary debug logs. zig build test-logging checks filtering and scope overrides across the host, SDK, native bridge, console, and file sinks; run it in Debug and ReleaseFast.
kaji --run: the conductor launches the product with inherited stdout/stderr (no redirect/buffer-until-exit). Build-step tools still use redirected capture.
Frontend ABI v9 and driver engine contract v34 require rebuilding native frontends and dynamic drivers together. The input wire layout now carries latched keyboard/mouse press and release masks, and the game UI context carries explicit pointer edges.