Current support
| Platform | Architecture | Graphics | Status |
|---|---|---|---|
| macOS | Apple Silicon (aarch64) | Metal 4.0 MSL; product floor macOS 26 | Active and validated |
| Windows | x86_64 | D3D12 + Agility SDK | Active |
| Linux | x86_64 | Vulkan plumbing | Incomplete — not a product peer of Metal/D3D12. Shaders already cook: build.akari.vulkan.json → SPIR-V via Akari |
macOS products ship with LSMinimumSystemVersion 26.0 (Kaji packaging default). Akari compiles MSL with -std=metal4.0 and -mmacosx-version-min=26.0. Device creation requires MTLGPUFamilyMetal4 and argument-buffer tier 2; a Mac that misses either logs the requirement, shows it in an alert when a window-server session exists, and exits with code 1. The live submit path is MTL4CommandQueue / reusable MTL4CommandBuffer with per-slot allocators (one begun buffer per allocator — reset only after that slot's queue marker completes), queue-attached residency sets (persistent/scene/frame), reused argument tables, a texture-view pool for mip views, MTL4Compiler PSOs, counter-heap pass timing, and shared-event ring wait (no classic command buffers). Metal 4 compute encoders unify blit and dispatch: when a dispatch consumes a preceding copy, keep both commands in one encoder and encode an explicit device-visible MTLStageBlit → MTLStageDispatch barrier. Do not encode intrapass barriers that wait on fragment/tile (illegal on Apple silicon).
macOS application exit
Closing the main window stops the AppKit event loop; Cmd+Q, the Quit menu, and
programmatic quit use NSApplication.terminate: and exit without returning from
[NSApp run]. AppDelegate.applicationWillTerminate: therefore performs native
shutdown and Metal cleanup, then calls engineDarwinAppOnTeardown to destroy the
application before AppKit exits. The event-loop tail calls the same idempotent
frontend teardown for window-close exits.
Both paths destroy the Worlds and AssetStore and run the subsystem and Host heap
checks. The application ABI keeps the file log and its writer alive until those
checks finish, emits Application teardown complete, then closes and drains the
writer. A successful process exit alone is not evidence that teardown ran.
Windows D3D12 Agility SDK
Windows products ship the DirectX 12 Agility SDK redistributable so the app is not limited to the OS-inbox D3D12 feature set.
| Piece | Location |
|---|---|
| Vendored pin | 3rd-party/d3d12-agility/ (VERSION, D3D12SDKVersion, headers, bin/x64/*.dll) |
| EXE exports | src/hikari/src/native_frontends/Windows/main.cpp — D3D12SDKVersion + D3D12SDKPath=.\D3D12\ |
| Staged runtime | bin/game/D3D12/D3D12Core.dll (+ d3d12SDKLayers.dll) via Kaji packaging |
macOS / Metal never references this tree. Update steps: 3rd-party/d3d12-agility/README.md.
For a draw-specific GPU fault, a debug-layer build can pass --d3d12-gbv-pass=gbuffer_geometry to select an exact GPU pass label. This enables guarded GPU validation on that pass's command lists and leaves other shaders unpatched, avoiding full-scene validation compilation. The filter uses GPU event labels, so retain GPU markers for this diagnostic. It also forwards validation messages immediately to the engine log, before a potentially blocked Present. Unpatched passes do not track implicit state promotion; confirm state-only findings with full --d3d12-gbv. Omit the option for ordinary execution.
DRED reports partially completed command lists first, then unstarted lists, with nearby event contexts. Fully completed lists are counted rather than expanded. The completed counter is a count: operation completed - 1 is the last completed operation, and completed is the first uncompleted one. These breadcrumbs locate stalled work, not necessarily the exact faulty instruction.
All D3D12 scene passes, including the first G-buffer phase and shadows, acquire command lists from the frame pool. Each surface gets a distinct list and allocator within an epoch; only a completed frame fence permits reuse. A list indexed solely by the primary swapchain or frame slot can be reset by a preview while its earlier commands are still executing. The shared RTV heap reserves the per-scene inventory for every supported surface and the in-flight generations awaiting retirement.
D3D12 bindless descriptors have a CPU-only canonical heap and three shader-visible frame replicas. Writes update the canonical descriptor and the current, fence-waited replica, marking the others dirty. beginBindlessFrame copies pending descriptors into the next waited replica, including nulls from retirement. Cached material and geometry rows may reuse their indices without resolving resources again; a logical index alone does not guarantee that every physical heap has received its descriptor. Metal already performs the equivalent propagation in its argument-buffer backend. Scene barriers clear the canonical heap and all replicas while the GPU is idle.
Synchronization across dynamic modules
Zig implements Windows futex waits with an internal waiter table in each linked module. A mutex shared between the application and renderer DLLs must use the same std.Io vtable for lock and unlock. Calling each DLL's Threaded.global_single_threaded.io() can park the consumer in one table and wake an empty table in the producer, leaving the consumer asleep even though the mutex is unlocked. macOS uses a different futex path and does not reproduce this failure.
RenderCommandQueue and GeometryStagingPool retain their creator's IO vtable and use it for every mutex operation. The creator module must outlive these objects. This is the same ownership requirement as their allocator callbacks. A blocked queue consumer prevents the renderer from reaching its frame boundary, so asset reload's pause request can then freeze the main thread too.
Windows DPI
The process must be PerMonitorV2. The PE manifest src/hikari/src/native_frontends/Windows/app.manifest (embedded by Kaji's Windows frontend link) plus an early SetProcessDpiAwarenessContext fallback in main.cpp are both required. Without them Windows OS-upscales a 96-DPI surface and the whole UI looks soft.
Client rect, swapchain, and session.resize sizes are physical pixels. WM_SIZE often arrives before device init — runtime_session/graphics_ops.resize must still push size into the renderer so D3D12 does not create a logical-sized swapchain that DXGI stretches. Device create uses HWND GetClientRect and syncs render_target_options from the real buffer size.
Linux
Linux has early device/callback scaffolding under src/hikari/src/platform/Linux/. It does not run the shared render-graph / renderer_core path used on macOS and Windows, and the static native backend does not create a Linux renderer. Treat module names such as vulkan as scaffolding until that path is brought up.
The shader side is not scaffolding: Akari emits Vulkan-dialect HLSL and compiles it to SPIR-V with the vendored DXC (build.akari.vulkan.json, linux-vulkan in the asset daemon, Kaji and Shinra), one .spv per stage. A Linux renderer must mirror the descriptor-set contract in akari_emit_common::vulkan (sets 0–3: buffers, textures, samplers, renderer-global bindless + geometry sampler catalog); see Shader authoring.
The target distinction matters: a platform directory or RHI vocabulary does not mean a product feature is ready on every host.
Layering
Shared cross-platform logic stays in src/hikari/src/graphics/, scene/, physics/, input/, scripting/, and other common engine folders. API-specific Zig implementation lives under src/hikari/src/platform/<platform>/; native Objective-C++/C++ implementation lives under src/hikari/src/native/<platform>/.
Keep proprietary console SDK headers, constants, handles, and conditionals entirely in their platform driver. Shared engine and graph code must compile without a console SDK installed.
Well-known directories
src/hikari/src/platform/paths.zig resolves OS directories via paths.get(.kind, buffer) / paths.getAlloc:
| Kind | Windows | macOS | Linux |
|---|---|---|---|
temp | %TEMP% / %TMP% | $TMPDIR → /tmp | $TMPDIR → /tmp |
persistent | %LOCALAPPDATA% | ~/Library/Application Support | $XDG_DATA_HOME → ~/.local/share |
Add a Kind and one table entry per OS to extend. Callers append product/subpaths; the API does not create directories. Project-scoped state stays under <project>/.engine/, not these roots.
Per-user writable root
Player settings (configs/*.json written by hi.config), writable:// saves and machine caches (the D3D12 pipeline library) share one root, resolved once by runtime_session/config.zig resolveWritableRoot. Boot composes the user configs/ layer over the shipped one from this root before the window or renderer exists, and the session writes back to the same root.
-
Platform-owned storage. A frontend whose platform owns writable storage (console save-data mounts, store sandboxes) passes it in
HikariApplicationCreateInfo.writable_path. It is used as-is; the platform already scopes it per title. Console SDK calls that mount it stay in that platform's frontend. -
Desktop. Frontends pass nothing and get the product folder under
persistent, named the way each OS expects (paths.productAlloc):OS Folder macOS ~/Library/Application Support/<identifier>(Apple's guidance)Windows %LOCALAPPDATA%\<publisher>\<title>Linux $XDG_DATA_HOME/<identifier>→~/.local/share/<identifier>Components are made file-system safe (
<>:"/\|?*and control characters become_, leading/trailing dots and spaces go, Windows device names get a_suffix). Changing the identifier — or, on Windows, the publisher or title — moves the folder. -
Identity. Kaji resolves identifier, title and publisher from
hikari.project.json+configs/packaging.json(the values in Info.plist and the MSIX identity) and stages them asdata/product.json; the game reads that. The editor, which has the project rather than a staged build, resolves the same values withproject_config.product.resolve, so editor Play and a packaged build share one folder. Both resolvers run the test fixturesdk/src/project_config/fixtures/product_identity_cases.json. A host without a project (the project selector) usespersistent/Hikari/editor.
Secure storage
src/hikari/src/platform/secure_storage.zig stores opaque byte secrets via the OS vault:
| Op | Zig | Backend |
|---|---|---|
| Upsert | secure_storage.set(key, value) | macOS Keychain generic password (com.hikari.secure_storage / account = key); Windows Credential Manager (Hikari/<key>, CRED_TYPE_GENERIC) |
| Read | secure_storage.get(key, buffer) | Same; returned slice aliases buffer |
| Remove | secure_storage.delete(key) | Same |
Limits: max_key_bytes (256), max_value_bytes (2560 — Windows credential blob floor). Keys must be non-empty and NUL-free. Errors: NotFound, Overflow, InvalidKey, Unavailable. Native bodies: native/macOS/src/SecureStorage.m, native/Windows/src/SecureStorage.cpp. Linux is unsupported (Unavailable).
First consumer: editor MCP Bearer token (editor/agent/project_state.zig) — key hikari.mcp.<sha256(project_id)[0..16] hex>; never written to .engine/user/mcp.json. The durable project UUID keeps the vault identity stable across project moves and renames.
Frontend responsibilities
The macOS and Windows native frontends are intentionally small. They own process entry and event loop integration and communicate over hikari_frontend.h. They do not own world simulation policy, scene loading, graphics orchestration, or game logic.
Editor argv
hikari-editor (macOS main.mm, Windows main.cpp):
hikari-editor [--project=<directory|hikari.project.json>] [--log=<path>]Flag form only — bare positional project paths are rejected. Omit --project= to boot the project selector. --log= passes an explicit path at application create (redirects the engine NDJSON log file; editor default is <project>/.engine/logs/editor-latest.json). Kaji --run already forwards --project=.
Game argv
hikari accepts optional --log=<path> the same way. Packaged game data paths remain next to the executable (not via argv).
Frame pacing
Output backing belongs to each surface. Direct-output graphs acquire it before encoding; offscreen graphs submit their work before acquiring it for final composition. This keeps a blocking drawable acquisition from delaying independent scene work and does not add an intermediate render target. Metal waits for each drawable on its consuming queue before the first writing command buffer, then signals it after the final writes and presents. Empty presents use the same ownership path.
Standalone games take present policy from configs/game.json → window.vsync (default on) and window.frame_rate (default unlimited = display-driven with VSync, uncapped without). They never inherit editor host pacing. Editor host policy is configs/editor.json → frame_pacing (defaults: 30 Hz idle Edit, 60 Hz interaction/Play; halved on battery, lower under OS battery saver, AC rates under the toolbar battery pill's session-only boost; every row also offers unlimited), always via Window.setFrameRate (do not issue per-frame native pacing policy calls). Play-in-editor uses the editor table, never the game window. Finite rates request a fixed CAFrameRateRange, but Core Animation chooses a cadence supported by the current display; a 60 Hz request need not deliver 60 Hz on a 144 Hz panel. Requested policy, display-link intervals, render delivery, and actual presentation are separate measurements; unlimited is setFrameRate(0,0,0) / CAFrameRateRangeDefault. Unclassifiable power-source reads as AC — never throttle on a guess.
macOS applies the preferred range to every window's CADisplayLink, including tool windows opened after the last change, and only one link drives engine frames (see frontends and drivers), so the cap holds however many windows are open. The OS paces the tick; there is no CPU limiter on that host.
Standalone App also requires a render admission ticket before input sampling, simulation, HUD construction, or publication. The fixed capacity is two outstanding frames, including the CPU reservation: one may execute on the render thread while another is prepared or pending. A pending packet cannot be replaced. Dequeue opens the packet mailbox but retains its ticket; completion releases the ticket only after renderFrame returns, including backend frame-slot waits. This bounds CPU production under GPU pressure while preserving overlap. It does not mean GPU execution or display scanout has finished; the backend GPU ring remains independent.
When admission is deferred, native event processing continues and main-thread residency/retirement maintenance is serviced at most once per 16 ms. No gameplay tick, input consumption, UI construction, publication, or simulation-clock advance occurs on that path. Scene transitions, readiness callbacks, and operations that need a render fence stay on admitted frames; asynchronous workers continue independently. Existing residency budgets still apply. Keyboard and mouse press/release edges are latched until the next accepted input frame, including taps that end between frames; repeated edges coalesce, while gamepads retain their existing sampled behavior. The next admitted tick uses elapsed time through the normal session clock and existing fixed-step limits.
Mailbox availability and frame completion wake a waiting producer through the native main-thread service mechanism; neither invokes game code on the render thread. macOS retries an already deferred display request. Windows waits for messages or a bounded maintenance timeout. Suspend cancels an unsubmitted reservation; render-thread stop invalidates outstanding ticket generations so stale completions cannot release resumed work. Editor frames retain their independent pacing and packet coalescing policy.
There is no new author setting for admission depth. Existing game VSync and frame-rate settings remain exposed in the editor's Project Settings window. RenderThread.pacingStats() provides admitted/deferred/completed/cancelled counts, outstanding tickets, and replaced packets for diagnostics; standalone replacements should remain zero. hi.render().fps() continues to measure primary render-thread delivery, independently of host tick attempts.
Windows stores min/max/preferred on the native app and checks its accumulated deadline before admitting a tick. Longer waits remain message-aware; hikari_precise_wait_until_ns handles the final short interval. Two rules the loop depends on:
- The limiter returns “unlimited” whenever a blocking vsync Present already paces the frame — two pacers in series beat against each other.
- The deadline advances by one period per admitted frame, rather than restarting after work. A delay exceeding two periods rebases it to avoid an unbounded catch-up burst. Admission deferral consumes no deadline.
Measure with hikari_monotonic_now_ns (QPC). Never GetTickCount64: its ~15.6 ms step is not sharpened by timeBeginPeriod(1), and a gate comparing it against a 16 ms period pins the editor near 40 FPS. timeBeginPeriod(1) itself is focus-gated.
Graphics parity
Shared rendering changes generally require Metal and D3D12 updates. Keep pipeline behavior and UI's one-draw-call contract aligned. Where a capability is genuinely not available, report it from the RHI capability set and retain a graceful shared policy instead of exposing native resources to game code.