-
Notifications
You must be signed in to change notification settings - Fork 5
Shader Translation
Halo PC ships Direct3D 9 shader bytecode (vertex shaders vs_1_1, pixel shaders ps_1_x/ps_2_0). The port never recompiles HLSL: the Direct3D 9 Bridge receives the token streams at CreateVertexShader/CreatePixelShader, optionally substitutes an exact-byte pixel-shader replacement from a shader pack, and hands them to the Metal Renderer, which translates them to Metal Shading Language with the vendored MojoShader Metal profile, compiles each distinct function once, and builds render pipelines ahead of the draws that need them. Pipelines used in one session are recorded on disk and rebuilt in the background at the start of the next.
| File | Role |
|---|---|
d3d9.c |
Shader object creation: token scan, shader-pack substitution, content hash, registration, radial-fog term count. |
shader_mod_pack.h / shader_mod_runtime.inc
|
.hvs shader pack format, validation and lookup. |
metalshader.c / metalshader.h
|
Wrapper around MOJOSHADER_parse with the Metal profile, compatibility fixes for Halo's bytecode, and extraction of the metadata the renderer needs (ms_shader). |
metalrenderer.m |
Pipeline compiler service: translation cache, function cache, pipeline caches, background workers, manifest and binary archive, pixel-shader extras injection. |
third_party/mojoshader |
Vendored MojoShader (zlib licence). |
radial_fog.h |
Token-level vertex-shader rewrite applied before translation for the opt-in Radial Fog variants. |
flowchart TD
A["CreatePixelShader / CreateVertexShader (engine thread)"] --> B["Copy tokens to 0x0000FFFF end token"]
B --> C{"Pixel shader matches a .hvs entry byte-for-byte?"}
C -->|"yes"| D["Use replacement tokens"]
C -->|"no"| E["Keep original tokens"]
D --> F["content_key = FNV-1a 64 of tokens"]
E --> F
F --> G["mr_shader_created(stage, tokens, key)"]
G --> H["Register tokens, queue translate task (with known alpha/fog variants)"]
H --> I["Worker: xlat_get -> ms_translate_shader_mapped -> MojoShader Metal profile"]
I --> J["Worker: fn_enqueue MSL text, compile alone into its own MTLLibrary"]
H --> K["Worker: scan manifest recipes completed by this shader"]
K --> L["worker_prepare -> worker_build: MTLRenderPipelineState (QUEUED -> BUILDING -> READY)"]
M["First draw needing the pipeline"] --> N{"Pipeline entry"}
N -->|"READY"| O["Draw"]
N -->|"BUILDING"| P["Wait for that one pipeline"] --> O
N -->|"absent"| Q["Build on engine thread, reusing compiled functions"] --> R["Append recipe to manifest"] --> O
CreateVertexShader (method 91) and CreatePixelShader (106), d3d9.c:785-804:
- Count tokens up to and including
0x0000FFFF(at most 65536 words). - Pixel shaders only:
shader_mod_lookupreturns an exact-byte replacement if the shader pack has one (below). - Copy the (possibly replaced) tokens into a guest allocation owned by the shader object;
GetFunctionreturns these bytes. - Compute
content_key, a 64-bit FNV-1a hash of the token bytes (never 0). Pipeline lookups use this key instead of rehashing the shader on every draw. - Call
mr_shader_createdso translation and compilation start immediately on a worker, not at the first draw. - Vertex shaders: count the fog terms the radial-fog rewrite would change for both modes, logging up to 64 eligible shaders.
- Optionally save the bytes for an active render capture.
Shaders are never destroyed in the renderer: translations, functions and registered token copies live for the process.
The host Makefile compiles only mojoshader.c, mojoshader_common.c, profiles/mojoshader_profile_common.c and profiles/mojoshader_profile_metal.c, with MOJOSHADER_NO_VERSION_INCLUDE=1 and every other profile disabled (SUPPORT_PROFILE_D3D/BYTECODE/HLSL/GLSL120/GLSLES/GLSLES3/GLSL/ARB1/ARB1_NV/SPIRV/GLSPIRV=0). The test runner builds the same set. No source file in third_party/mojoshader carries a project-specific modification comment; metalrenderer.h describes it as "pinned upstream MojoShader", and every Halo-specific adjustment lives in metalshader.c and metalrenderer.m (listed below).
| MojoShader API | Called from | Purpose |
|---|---|---|
MOJOSHADER_parse(MOJOSHADER_PROFILE_METAL, entry, tokens, bytes, NULL, 0, samplerMap, count, NULL, NULL, NULL) |
ms_translate_shader_mapped |
Parse and emit MSL. No swizzles, no custom allocators. |
MOJOSHADER_freeParseData |
same | Release parse results. |
The parse result supplies: output/output_len (MSL source), shader_type, major_ver/minor_ver, uniforms (type, register index, array count), attributes (usage, index, and a name vN from which the vertex attribute location is parsed), outputs (usage, index), samplers (type, index), and errors. These are copied into ms_shader, bounded to 512 uniforms, 16 attributes, 16 outputs and 16 samplers (more fails with shader metadata exceeds renderer bounds). packed_float4_count (and int/bool counts) record how many registers MojoShader packs into its uniforms_float4 array; the renderer uses the uniform list to pack exactly those registers per draw.
The entry name passed to MojoShader is the placeholder MRENTRYPLACEHOLDER (MR_PH); the renderer later substitutes mr_vs_<hash>/mr_fs_<hash> derived from the source text (see Function cache).
| Problem | Fix | Code |
|---|---|---|
Early Halo PC ps_2_0 blobs carry a D3DX constant table whose header is 20 bytes instead of the 28-byte layout MojoShader accepts; MojoShader reports only Shader has corrupt CTAB data. |
Strip exactly that recognised comment block (magic CTAB, header size 20) and parse again; version and instruction tokens are unchanged. |
without_legacy_ctab20 |
Halo's shaders are stripped of constant tables but use relative constant addressing (c[a0.x + n], e.g. bone palettes), which MojoShader rejects with relative addressing unsupported without a CTAB. |
Insert a synthetic CTAB comment after the version token declaring one float4 array c covering the whole register file (256 registers for vertex shaders, 224 for pixel shaders); no instruction token changes. used_synthetic_ctab is set. |
with_register_ctab |
The Metal profile's CTAB-array path emits #define cN uniforms.uniforms_float4[..]; with a trailing semicolon that becomes part of expressions. |
Blank that semicolon on #define c lines that reference uniforms.uniforms_float4[
|
copy_and_sanitize_source |
ps_1_x shaders declare no sampler types; D3D samples according to the bound texture. |
For pixel shaders below 2.0, pass a MOJOSHADER_samplerMap built from the bound texture kinds (2D/cube/volume) of the 16 stages. |
metalshader.c:136-143 |
| The stage parsed differs from the expected stage. | Fail with shader stage mismatch. |
:169-173 |
Further adjustments in metalrenderer.m:
-
Sampler-map retry. When a
ps_1_xstage's bound texture kind disagrees with the kind the instruction samples (a 2D texture on atexm3x3stage, say), the forced map produces MSL with no matchingsampleoverload and the fragment fails to compile.program_buildretries the pixel translation without a map so MojoShader infers each sampler kind from the instructions. At draw time any mismatched or unbound stage gets an opaque-black texture of the declared kind. -
Fog output index. MojoShader labels
vs_1_xoFogwith RASTOUT register number 1 although the emitted semantic is the unindexed[[user(fog)]]; for radial-fog variants only, the output's index is corrected to 0 (xlat_get) so the fixed fragment can find it. The non-radial path is left as it was. -
Pixel extras. D3D applies alpha test and vertex fog after the pixel shader, but Metal has neither.
inject_pixel_extrasedits the MojoShader output text: it relies on the Metal profile closing the fragment parameter list with\n) {and ending withreturn output;, adds aconstant MRPixelExtras &mr_extra [[buffer(1)]]parameter (alpha reference, fog enable, fog colour), adds a[[user(fog)]]input (either into MojoShader's_Inputstruct or as a separateMRExtraInputstage-in), and inserts before the final return:output.oC0.rgb = mix(fog_color, output.oC0.rgb, saturate(fog))and/orif (!(a CMP r)) discard_fragment();for the D3D comparison function. Edits are applied from the end backwards so earlier insertions do not shift later offsets. Each (alpha function, fog) pair is a distinct variant:variant = alpha_func * 2 + fog.
mr_compiler is created with the shared Metal state (compiler_for). The long comment at metalrenderer.m:1229-1277 records the design rationale:
- An earlier build compiled every pipeline at its first draw on the engine thread, with one MSL library per pipeline whose entry points were named after the pipeline key. A headset session built 479 of them in 12.4 s, each a visible hitch; a translated pixel shader cost 60-80 ms as its own library (mostly parsing
<metal_texture>), and every blend/mask/declaration variant of the same shader pair paid for the whole library again. - Therefore a function is compiled once per distinct source text, alone, named after that text, so every variant of a shader pair reuses it and Metal's own per-app cache recognises it in later launches. Batching several functions into one library is 15-20x cheaper cold, but Metal keys compiled code on the library a function came from, so the same function compiled in a different batch next session would miss both Metal's cache and the archive; functions are therefore never batched.
- The game's shaders are registered at creation and compiled by background workers before any draw.
- Every pipeline built is recorded in a manifest and rebuilt by the workers as soon as the same shaders are created in a later session.
- A draw whose pipeline is not ready builds it or waits for the worker already building that one pipeline. Draws are never skipped: a skipped draw is a visible pop, and with several panorama bearings rendered from one engine frame a pipeline finishing between bearings would show an object in one bearing but not its neighbour.
- A 2026-09-14 audit compiled all 469 captured Halo pixel shaders individually.
xlat_get memoises MojoShader output in 1024 hash buckets keyed by FNV-1a over (stage, token key, whether a sampler map applies, the 16 sampler kinds when it does, and the radial-fog mode when non-zero). Translation runs outside the lock; a racing duplicate is discarded. Failed translations are cached as well (ok = 0 with MojoShader's error text), so a bad shader fails the same way every time without being translated again.
fn_require and fn_enqueue key functions by FNV-1a of (stage, MSL text with the placeholder entry name), in 4096 buckets. The entry name is mr_vs_<16 hex> or mr_fs_<16 hex> of that hash (fn_name); text_named substitutes it for every placeholder occurrence. fn_compile calls newLibraryWithSource: for that one function and newFunctionWithName:.
Function states: QUEUED (waiting for a worker; text held), COMPILING, READY, FAILED. A caller that needs a queued function takes it over and compiles it itself rather than waiting; a caller that finds one COMPILING waits on the changed condition. A failed function is retried after 2 s (MR_RETRY_NS) for up to 3 attempts in total; otherwise its stored error is returned.
| Pipeline kind | Vertex text | Fragment text |
|---|---|---|
Programmable with PS (program_texts) |
Translated VS (radial variant if requested) | Translated PS, or the same with pixel extras injected |
| Programmable without PS | Translated VS |
make_fixed_fragment over the VS outputs |
Fixed RHW / clip (fixed_rhw_texts) |
Generated fixed_rhw_vertex_source(clip, fog)
|
Translated PS (+extras) for mr_draw_program_rhw, else generated fixed fragment |
program_build then builds the vertex descriptor from the declaration, sets both depth and stencil attachments to Depth32Float_Stencil8, configures blending, and creates the pipeline (pipeline_create), first trying the binary archive with MTLPipelineOptionFailOnBinaryArchiveMiss when one is loaded.
Pipeline cache entries (mr_program_cache, mr_fixed_rhw_cache) move through QUEUED (a worker will build it from a manifest recipe), BUILDING, READY and FAILED. Entries never move or disappear, so a READY entry is read without the lock.
program_for (and fixed_rhw_program_for):
-
READY: return it; if a worker built it, count a prewarm hit. -
QUEUED, orFAILEDafter a background attempt, orFAILEDwith fewer than 3 attempts and 2 s elapsed: claim it and build on the engine thread. -
BUILDING: wait for that pipeline only (pthread_cond_waitonchanged), counting wait time. -
FAILEDotherwise: fail the draw with the stored error. - Absent: insert and build. If the cache is full, the draw fails.
After a successful engine-thread build the recipe is appended to the manifest (unless the entry came from a recipe) and the pixel-extras variant is noted.
Workers (compiler_worker) are detached pthreads named halo.pipelines at QOS_CLASS_UTILITY, started on demand up to HALO_PIPELINE_WORKERS (default 3, clamped to 0-6). They drain four queues in priority order:
-
translate (
worker_translate): for a newly registered shader, queue any manifest recipes it completes, translate it, and enqueue its function text. For vertex shaders with radial fog enabled, also enqueue the radial variants of every eligible mode. For pixel shaders, enqueue one text per variant bit already seen (variants_seen), so a shader created after the game started using alpha-test/fog combinations is prewarmed for them too. -
prepare (
worker_prepare): rebuild the draw state from a recipe, recompute its key and drop the recipe if it differs (it was recorded by a build that keyed pipelines differently), insert aQUEUEDentry if there is room below 3/4 of capacity, enqueue its function texts, then queue a build. -
functions (
fn_run_queued): compile the oldest queued function. -
build (
worker_build): claim aQUEUEDentry, build it, publishREADYorFAILED(a failed background build is retried by the engine at first draw).
note_variant: the first time an engine-built pipeline uses a new (alpha function, fog) pixel variant, every registered pixel shader is queued for translation of that variant.
Worker errors go to a thread-local buffer (mr_err_sink) so they never overwrite the engine thread's mr_last_error().
With HALO_PIPELINE_WORKERS=0 nothing runs in the background: mr_shader_created returns immediately and every pipeline is built at its first draw, still sharing compiled functions. HALO_SHADER_PREWARM=0 keeps the workers (for manifest prewarming) but stops compiling at shader creation.
A recipe (mr_recipe, 1088 bytes) is everything a pipeline build reads, without per-draw values: kind (program or fixed), clip-space, overlay, has-PS, pipeline key, VS and PS content keys, stride, declaration (up to 520 bytes: 64 elements plus end marker), 16 stream strides, blend factors/op, write mask, alpha-test enable/function, fog enable, sampler kinds and bound flags, the eight fixed stages, and (version 2) the radial-fog mode. Shaders are referenced by content key only; a recipe can be rebuilt only once the same shaders have been created in the new session (recipes_scan_locked). A recipe is recordable only if its shaders have content keys and its declaration fits.
On-disk layout, manifest-v2.bin in the pipeline directory (manifest_load):
| Offset | Field |
|---|---|
| 0 | magic 0x4D505648 ("HVPM" read little-endian) |
| 4 | version (2) |
| 8 | record size (1088) |
| 12 | reserved (0) |
| 16 | records, appended one at a time |
At startup (on a serial utility-QoS dispatch queue) the loader:
- falls back to
manifest-v1.bin(1080-byte records, identical prefix; the v2 field defaults to 0) if no v2 file exists, leaving the v1 file intact so an older app can still use it; - drops records with an invalid kind, declaration size or radial mode, and a torn tail;
- keeps only the newest record per key, at most 8192 (
MR_RECIPE_MAX), and rewrites the file atomically (.tmp+rename) if anything was dropped or migrated; - seeds
variants_seenfrom the recorded pixel variants; - queues every recipe whose shaders are already registered, then opens the file for appending.
Recipes with a radial-fog mode are kept on disk but not prewarmed while radial fog is disabled. New recipes are appended asynchronously (store_append) until the 8192 limit.
With HALO_PIPELINE_ARCHIVE=1 and a device that supports it, archive_open loads pipelines-<hash>.metalarchive, where the hash covers halo-pipelines-1|<device name>|<OS version string>; archives for other identities are deleted, files above 768 MiB discarded, unreadable archives removed. Because Metal cannot serialise an archive it loaded from a file once anything is added (reproduced as a crash on macOS 26), the loaded archive is read-only and a fresh collector archive receives every pipeline this session builds or loads; the collector replaces the file after a quiet period (HALO_PIPELINE_ARCHIVE_SAVE_MS, default 15000 ms) once prepare/build queues are empty, via .tmp + rename. The source comment notes that with Metal's own cache warm the archive saved nothing measurable, which is why it is off by default.
mr_pipeline_cache_flush writes the archive and fsyncs the manifest immediately (for tests or an app about to be suspended).
mr_pipeline_stats reports engine-thread builds and time, functions compiled on the engine thread, waits and wait time, background pipelines/functions/time, prewarm hits, archive hits and stores, shaders registered and recipes loaded. With HALO_PIPELINE_TRACE=1 every build is logged with its location and duration. These feed the device report described in Diagnostics and Telemetry.
A shader pack replaces whole pixel shaders by exact byte match. It is loaded once, lazily, on the first CreatePixelShader (shader_mod_initialize), from HALO_SHADER_PACK, unless HALO_SHADER_MODS=0. The visionOS app sets HALO_SHADER_PACK to the bundled ShaderMods.hvs if present. Lookup happens only at shader creation, never per draw or bearing.
Format (hsm_open), all integers little-endian:
| Offset | Size | Field |
|---|---|---|
| 0 | 8 | magic HVSHD001
|
| 8 | 4 | entry count, 1-128 (HSM_MAX_ENTRIES) |
| 12 | 4 | reserved, must be 0 |
| 16 | ... | entries back to back: u32 original_size, u32 replacement_size, original token bytes, replacement token bytes |
Validation: total file at most 4 MiB (HSM_MAX_BYTES) and at least 16 bytes; every length within the file; the entries must end exactly at the end of the file; both shaders must pass hsm_shader (4-aligned, 8 bytes to 256 KiB, version token 0xFFFF0200 i.e. ps_2_0, every instruction's length field non-zero and in bounds, opcodes at most 96, comment blocks skipped, end token last); duplicate originals are rejected as ambiguous. Any failure discards the whole pack and logs [shader-mod] invalid pack; using originals. Lookup is a linear byte comparison (hsm_find); the first application of each entry is logged.
The comment in shader_mod_pack.h notes that the release gate also translates and compiles every replacement through the production MojoShader/Metal path; the packing tools are described in Visual Mods Pipeline.
| Variable | Default | Effect | Read at |
|---|---|---|---|
HALO_PIPELINE_WORKERS |
3 (0-6) | Background compiler threads; 0 disables background work. | metalrenderer.m:2014 |
HALO_SHADER_PREWARM |
1 | 0 stops translating/compiling at shader creation. | metalrenderer.m:2015 |
HALO_PIPELINE_CACHE |
1 | 0 disables the manifest (and archive). | metalrenderer.m:2213 |
HALO_PIPELINE_CACHE_DIR |
<Caches>/HaloVision/pipelines |
Pipeline store directory. | metalrenderer.m:2214 |
HALO_PIPELINE_ARCHIVE |
0 | 1 enables the MTLBinaryArchive. |
metalrenderer.m:2183 |
HALO_PIPELINE_ARCHIVE_SAVE_MS |
15000 | Quiet period before the archive is written. | metalrenderer.m:2094 |
HALO_PIPELINE_TRACE |
0 | Logs every function compile and pipeline build. | metalrenderer.m:1390 |
HALO_SHADER_PACK |
unset (visionOS: bundled ShaderMods.hvs) |
Path of the .hvs pack. |
shader_mod_runtime.inc:7 |
HALO_SHADER_MODS |
enabled |
0 ignores the pack. |
shader_mod_runtime.inc:7 |
HALO_RADIAL_FOG |
off | Adds radial-fog vertex-shader variants (see Radial Fog). | halo_settings.c |
| Test | Run by run_source_checks.py
|
What it asserts |
|---|---|---|
test_metalrenderer_pipeline_cache.m |
yes (macOS) | Real Metal and MojoShader with synthetic vs_1_1/ps_1_1 shaders, run as two processes sharing a temporary cache directory. Session 1: pipelines built at the draw; vertex/fragment functions compiled once and shared by blend/mask variants; a pipeline found again is not rebuilt; shaders registered at creation are compiled by workers so a later first draw compiles no function; a failed shader fails the same way without being re-translated; pixels match a pipeline built with both functions in one library. Session 2: recorded pipelines rebuilt by workers once the shaders are created, a draw waits only for the pipeline a worker is building, no draw builds anything, and pixels match session 1. |
test_shader_mod_pack.c |
yes | Valid pack opens and matches exactly (wrong size does not); every truncation fails; trailing bytes, bad lengths, invalid tokens, too many entries, duplicate originals and non-zero reserved field are rejected. |
test_radial_fog.c, test_radial_fog_renderer.m
|
yes | MojoShader translation of rewritten shaders, recipe/manifest v1-to-v2 migration (see Radial Fog). |
Documents master-chef at commit 9f915af (v1.0.3). Unofficial project, not affiliated with Microsoft, Bungie, Gearbox or Apple. Original code is MIT licensed; game content is not included.
Overview
- Architecture Overview
- Repository Layout
- Glossary
- Environment Variables
- Contributing Guide
- Open Questions
Translation
- Static Translation Pipeline
- XWA Decoder and Lifter
- Function Address Lists
- EngineReuse Runtime
- x87 Floating Point
Host runtime
- EngineHost Overview
- Win32 Compatibility Layer
- Threading and Synchronization
- Guest Memory and Heap
- Engine Overrides and Hooks
- Runtime Settings
Graphics
- Direct3D9 Bridge
- Metal Renderer
- Shader Translation
- Textures and Texture Packs
- Geometry Fast Paths
- Radial Fog
Panorama and presentation
- Panorama System
- Panorama Budget and LOD
- Frame Pacing
- visionOS App
- Immersive Presenter
- Layer Alignment
Audio and input
Tooling and process