# Platforms and support

## 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](https://aka.ms/directx12agility) 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](/docs/guides/shaders.md#vulkan-binding-model).

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 as `data/product.json`; the game reads that. The editor, which has the project rather than a staged build, resolves the same values with `project_config.product.resolve`, so editor Play and a packaged build share one folder. Both resolvers run the test fixture `sdk/src/project_config/fixtures/product_identity_cases.json`. A host without a project (the project selector) uses `persistent/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`):

```text
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](/docs/systems/frontends-and-drivers.md)), 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:

1. The limiter returns “unlimited” whenever a blocking vsync Present already paces the frame — two pacers in series beat against each other.
2. 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.
