Skip to content

Direct3D9 Bridge

M T edited this page Oct 4, 2026 · 1 revision

Direct3D 9 Bridge

The Direct3D 9 bridge is the host-side replacement for d3d9.dll (and a tiny ddraw.dll). The statically translated Halo PC executable still calls Direct3DCreate9 and then drives a COM device through vtables exactly as it did on Windows; the bridge allocates those COM objects in guest memory, points every vtable slot at a native shim, tracks all device state on the host, and turns each draw into a call on the Metal Renderer. It sits between the translated engine (see EngineReuse Runtime and Win32 Compatibility Layer) and Metal; the Panorama System hooks into the same draw path to redirect world and HUD draws into per-bearing targets.

Source files

File Role
d3d9.c COM object table, vtable/shim generation, IDirect3D9, IDirect3DDevice9 and resource interfaces, lighting, Reset, Present, shim registration.
d3d9_render.inc Device draw state, state-block recording, render-target surfaces, texture upload, the three draw backends (programmable, fixed-function 3D, pretransformed RHW), index normalisation, vertex-buffer residency, diagnostics. Included by d3d9.c.
d3d9_capture.inc Opt-in render trace and draw-payload capture (diagnostics only).
d3d9_process_vertices.inc IDirect3DDevice9::ProcessVertices front end (see Geometry Fast Paths).
fp_vertex_capture.inc One-frame first-person (viewmodel) draw capture, diagnostics only.
model_vertex_capture.inc, model_capture_boundary.h, model_capture_scope.h, model_capture_hooks.inc One-frame actor (biped) draw capture keyed on the original model-draw functions, diagnostics only.
stateblock.h Generic masked state-block payload (HostStateBlock).
rhw_declaration.h Recognises Halo's two POSITIONT vertex declarations as RHW FVF layouts.
ddraw.c Minimal IDirectDraw7 for the game's display-mode probing.
metalwin.h / metalwin.m macOS window presenter (CPU framebuffer upload to a CAMetalLayer). On visionOS the same C API is provided by the app bridge.
resources.c PE RT_STRING/dialog resource reader for halo.exe/strings.dll. It has no texture-related code; it is listed here only because texture resources are not read from PE files at all.

The header comment at the top of d3d9.c (lines 1-3) still says "programmable shaders remain pending"; that comment predates the programmable path and is stale. Every method is accounted for, so stdcall stacks stay balanced; methods without an implementation log once and return D3D_OK.

COM emulation

Guest object layout and dispatch

Each D3D object is a 16-byte guest allocation created by obj_new:

Guest offset Contents
+0 Pointer to the class vtable (guest memory)
+4 Host slot index into objs[65536]
+8 Generation number (non-zero, incremented per allocation)
+12 Unused

vtable_for builds one guest vtable per class lazily; each slot holds host_proc_address("d3d9.dll", "IFace::Method"), i.e. the magic address the translated code calls to reach a host shim. The shim table is generated by the METHOD_SHIM(class, index, handler) macro (line 969): one native function per (class, vtable slot). A shim recovers this from ARG(0), validates it with obj_from_guest (slot in range, objs[slot].guest == this, guest generation word matches), and calls the class handler with the method index. A bad this is fatal (engine_fail("d3d9 bad this")). Every handler returns through RET_STDCALL(hr, argc) where argc (including this) comes from the per-class method tables at lines 73-87, so the callee pops exactly the stdcall argument bytes.

host_shims_d3d9_build registers Direct3DCreate9 plus every IFace::Method name with the shim layer (up to 512 entries). Classes that share a method layout share shim arrays: textures/cube/volume textures share tex_fns, VB/IB share buf_fns, and vertex declarations, shaders, state blocks, swap chains and queries share simple_fns.

Binding validation uses obj_find, a 16384-bucket hash from guest address to slot, so a caller-supplied pointer is never dereferenced to discover its class.

The D3DObj record

D3DObj is one record per object kind (K_D3D, K_DEVICE, K_TEXTURE, K_CUBETEX, K_VOLTEX, K_VB, K_IB, K_SURFACE, K_VOLUME, K_VDECL, K_VSHADER, K_PSHADER, K_STATEBLOCK, K_SWAPCHAIN, K_QUERY). The fields that matter beyond the obvious description fields:

Field Meaning
data, data_capacity Guest buffer for VB/IB/surface bytes; implicit back/depth buffers keep a reusable capacity.
level_data[16], level_surface[16], face_data[6][16], face_surface[6][16] Per-mip (and per-cube-face) guest pixel storage and the cached IDirect3DSurface9/IDirect3DVolume9 alias for each level.
owner For a level alias, the parent texture's guest address.
renderer, gpu_dirty For render-target surfaces, the mr_context and whether the GPU copy is newer than the guest bytes.
content_generation, hashed_generation, content_key Texture/buffer change tracking and the cached upload key (see Textures and Texture Packs). For shaders content_key is the token hash.
mod_generation, mod_entry, mod_checked Cached texture-pack match for the current generation.
stateblock, stateblock_device Custom state-block payload and the device it retains.
resident, resident_generation, resident_copies, resident_refused Vertex-buffer residency (see Geometry Fast Paths).
radial_fog_terms, radial_fog_view_plane Vertex shaders: fog terms the Radial Fog rewrite would change, computed once at creation.

The table holds at most 65536 objects; exhausting it logs d3d object table full and exits with code 3. Freed slots go on a free list; the generation word makes a stale guest pointer to a reused slot fail validation.

Reference counting and lifetime

obj_release and obj_retain implement the rules:

  • Only buffers, declarations, shaders, state blocks, textures, surfaces and volumes are reclaimable (obj_reclaimable). Device, IDirect3D9, swap chain and query objects never reach zero; Release floors at 1.
  • A texture level alias is created with zero references and is owned by its parent. While the game holds a reference to a level, the level holds one reference on the parent; when that last external reference is dropped the parent reference is released instead of destroying the level, so GPU-authoritative contents survive until the parent dies.
  • obj_destroy destroys child surfaces (and their Metal render targets) before freeing shared pages, drops any resident vertex copy, frees guest storage, zeroes the guest object, and releases the creating device (resource_device) and a state block's device.
  • Every resource created through the device calls obj_attach_device, so GetDevice returns a retained device and the device outlives its resources.
  • All binding slots (SetTexture, SetStreamSource, SetIndices, SetVertexDeclaration, shaders, render target, depth surface) hold strong references through binding_replace, which retains the new owner before releasing the old one.

IDirect3D9

Handled by method_d3d.

Method Behaviour
GetAdapterCount 1
GetAdapterIdentifier Driver halo-vision-metal, description Halo Vision Metal Renderer, device \\.\DISPLAY1, driver version 0x0009000F00000000, vendor 0x10DE, device id 1.
GetAdapterModeCount / EnumAdapterModes Ten modes from 640x480 to 2560x1920 at 60 Hz, X8R8G8B8.
GetAdapterDisplayMode 1920x1080.
CheckDeviceType, CheckDeviceFormat, CheckDeviceMultiSampleType, CheckDepthStencilMatch, CheckDeviceFormatConversion Always D3D_OK (CheckDeviceFormat logs its arguments).
GetDeviceCaps fill_caps: a fixed 304-byte D3DCAPS9 (8 texture stages, 8 simultaneous textures, 4096 max texture size, VS/PS version fields 0xFFFE0200/0xFFFF0200, 256 VS constants, etc.).
CreateDevice Creates the device, the implicit back buffer (X8R8G8B8, usage 1) stored in device->data and the implicit depth surface (format from the present parameters or 75 = D3DFMT_D24S8) in device->level_surface[0]; clamps the back buffer to at least 64x64 (default 640x480); resets render state and lighting; calls metalwin_init once.

IDirect3DDevice9

method_device first offers the call to the state-block control handler, then to render_method, which itself tries state-block recording, resource bindings and target bindings before its own switch. Whatever is left falls through to the method_device switch.

Group Methods (vtable index) Behaviour
Lifetime AddRef (1), Release (2), QueryInterface (0) QueryInterface returns this without checking the IID.
Lost device TestCooperativeLevel (3) Always D3D_OK; the device is never lost.
Info GetAvailableTextureMem (4) = 256 MiB; EvictManagedResources (5) no-op; GetDeviceCaps (7); GetDisplayMode (8) = back-buffer size; GetCreationParameters (9) = adapter 0, HAL, main HWND, flags 0x40; ShowCursor (12) returns 0; GetNumberOfSwapChains (15) = 1; GetRasterStatus (19) = in vblank; GetGammaRamp (22) identity ramp.
Swap chain GetSwapChain (14) Allocates a new swap-chain object each call.
Reset / Present 16, 17 See Reset and Present.
Resource creation CreateTexture (23), CreateVolumeTexture (24), CreateCubeTexture (25), CreateVertexBuffer (26), CreateIndexBuffer (27), CreateRenderTarget (28), CreateDepthStencilSurface (29), CreateOffscreenPlainSurface (36), CreateVertexDeclaration (86), CreateVertexShader (91), CreatePixelShader (106), CreateQuery (118), CreateStateBlock (59) See Resources.
Surface transfer GetRenderTargetData (32), StretchRect (34), ColorFill (35) surface_copy: CPU nearest-neighbour copy between A8R8G8B8/X8R8G8B8 surfaces after reading back GPU contents; X8 forces alpha to 0xFF; filters other than NONE/POINT return D3DERR_NOTAVAILABLE. ColorFill writes guest bytes directly.
Targets GetBackBuffer (18), SetRenderTarget (37), GetRenderTarget (38), SetDepthStencilSurface (39), GetDepthStencilSurface (40) target_binding_method. Only render-target index 0 is accepted; SetRenderTarget also resets the viewport to the surface size, as D3D9 does.
Scene BeginScene (41), EndScene (42) No-ops.
Clear 43 Rectangle lists are rejected (D3DERR_NOTAVAILABLE). A colour clear marks the target GPU-authoritative and calls mr_clear; depth and stencil clears call mr_clear_depth/mr_clear_stencil.
Transforms SetTransform (44), GetTransform (45) VIEW (2), PROJECTION (3), WORLD (256) and TEXTURE0..7 (16..23); others ignored. MultiplyTransform (46) is a no-op.
Viewport SetViewport (47), GetViewport (48) Stored verbatim.
Material / lights 49-54 Validated and stored for CPU lighting (see Fixed-function lighting).
Render, stage and sampler state 57/58, 66/67, 68/69 Stored in draw_state.rs[256], ts[8][33], sampler[16][14]; out-of-range indices return D3DERR_INVALIDCALL.
State blocks 59, 60, 61 See State blocks.
Bindings GetTexture/SetTexture (64/65), SetVertexDeclaration/GetVertexDeclaration (87/88), SetFVF/GetFVF (89/90), shaders (92/93, 107/108), SetStreamSource/GetStreamSource (100/101), SetIndices/GetIndices (104/105) resource_binding_method. SetFVF clears the declaration binding and vice versa (d3d9_set_vertex_input).
Shader constants 94-99, 109-114 256 VS float4, 224 PS float4, 16 int4 and 16 bool per stage, range checked.
Draws 81-84 See Draw path.
ProcessVertices 85 See Geometry Fast Paths.
Misc getters ValidateDevice (70), GetSoftwareVertexProcessing (78), GetNPatchMode (80) ValidateDevice reports one pass; GetSoftwareVertexProcessing returns 0; GetNPatchMode pushes 0.0 on the emulated x87 stack (its float return).
Explicit no-ops (D3D_OK) MultiplyTransform (46), SetClipPlane (55), SetPaletteEntries (71), SetCurrentTexturePalette (73), SetScissorRect (75), SetSoftwareVertexProcessing (77), SetNPatchMode (79), SetStreamSourceFreq (102) Accepted and ignored. The scissor rectangle is ignored; SCISSORTESTENABLE itself causes draws to be rejected.
Logged as unimplemented (return D3D_OK) Everything else, e.g. GetDirect3D (6), cursor methods (10, 11), CreateAdditionalSwapChain (13), SetDialogBoxMode (20), SetGammaRamp (21), UpdateSurface (30), UpdateTexture (31), GetFrontBufferData (33), GetClipPlane (56), clip status (62, 63), palette getters, GetScissorRect (76), patches (115-117).

unimplemented remembers up to 64 distinct (class, method) pairs and logs d3d9 X::Y not implemented (returning D3D_OK) once each.

Resources

Textures, cube and volume textures

create_texture validates size (up to 8192x8192, depth up to 512), computes the full mip count when levels == 0, caps levels at 16, and records the description; it does not allocate pixel storage. Storage for a level is allocated on first LockRect/GetSurfaceLevel from guest pages (method_texture). level_size handles DXT1 (8-byte blocks) and DXT2-5 (16-byte blocks); other formats use bytes_per_pixel_x8 (unknown formats are assumed 32 bpp).

  • LockRect flushes any GPU-newer alias of the level (readback), allocates storage, increments content_generation, and returns pitch and pointer. UnlockRect and AddDirtyRect also increment the generation. Lock flags are not inspected for textures.
  • GetSurfaceLevel/GetCubeMapSurface/GetVolumeLevel return the cached alias described above; each cube face/mip pair has a distinct alias.
  • GetLevelDesc, GetLevelCount and GetType work; GetPriority and GetLOD return 0 and GetAutoGenFilterType returns 2 (LINEAR); SetPrivateData, FreePrivateData, SetPriority, PreLoad, SetLOD, SetAutoGenFilterType and GenerateMipSubLevels are no-ops; GetPrivateData is logged as unimplemented.

Only level 0 is ever uploaded to Metal; lower levels the game writes are ignored and the renderer regenerates mips on the CPU (see Textures and Texture Packs).

Vertex and index buffers

create_buffer allocates the guest storage up front (index buffers must be D3DFMT_INDEX16 (101) or INDEX32 (102)). method_buffer:

  • Lock(offset, size, ppData, flags) range-checks, increments locked and content_generation, and returns a pointer into the guest buffer. If the flags contain D3DLOCK_NOOVERWRITE (0x1000) or D3DLOCK_DISCARD (0x2000) the buffer is permanently retired from residency, because those flags identify streaming use even when D3DUSAGE_DYNAMIC was omitted.
  • Unlock decrements locked and increments the generation.
  • GetDesc reports format, type, usage, pool, size and FVF.

Draws read buffer bytes straight from guest memory; there is no separate "managed" copy.

Surfaces and volumes

make_surface allocates guest pages for every standalone surface. LockRect reads back GPU contents first (surface_flush) and marks the content changed; GetDC returns a fake HDC 0x20003. GetContainer checks the requested IID against the standard COM IIDs (IUnknown, IDirect3DResource9, IDirect3DBaseTexture9, the specific texture interface, or IDirect3DDevice9 for standalone surfaces) and returns E_NOINTERFACE otherwise (resource_get_container).

Render-target surfaces

A surface becomes a Metal render target the first time a draw or clear targets it (surface_prepare); only A8R8G8B8 (21) and X8R8G8B8 (22) surfaces qualify. Ownership of the pixels alternates between the guest bytes and the GPU:

stateDiagram-v2
    [*] --> CPU_authoritative
    CPU_authoritative --> GPU_authoritative: draw or colour Clear, after surface_prepare uploads the guest bytes
    GPU_authoritative --> GPU_authoritative: further draws
    GPU_authoritative --> CPU_authoritative: surface_flush readback for LockRect, StretchRect, Present fallback or texture alias
    CPU_authoritative --> CPU_authoritative: ColorFill or surface_copy writes guest bytes
Loading

gpu_dirty records which side is newer. A render-target texture whose level 0 is GPU-dirty is sampled directly through mr_target_texture without any readback. All surfaces that own renderers are listed in render_surfaces[] so flush_data can read back any surface whose guest storage a texture upload is about to hash.

Vertex declarations and shaders

CreateVertexDeclaration copies up to 64 D3DVERTEXELEMENT9 entries plus the end marker. CreateVertexShader/CreatePixelShader (line 785) copy the token stream up to the 0x0000FFFF end token (at most 65536 words), substitute an exact-byte pixel-shader replacement from the shader pack if one matches, compute a 64-bit FNV-1a content_key, hand the tokens to mr_shader_created so they are translated off the engine thread, and (vertex shaders) count radial-fog terms. Details are in Shader Translation. GetFunction/GetDeclaration return the stored bytes.

Queries and swap chains

Queries record their type; Issue is a no-op and GetData always writes 1 (if the buffer has at least 4 bytes) and returns S_OK, so occlusion and event queries never stall. Swap-chain Present only increments the frame counter; GetBackBuffer returns the device back buffer; GetPresentParameters reports the back-buffer size and X8R8G8B8.

Device state

HostD3DDrawState is the single live copy of device state: render states, texture-stage states, sampler states, 16 texture bindings, FVF, shaders, declaration, 16 streams (pointer/offset/stride), index buffer, viewport, the full VS/PS constant files, and the world/view/projection/texture matrices. render_defaults applies the D3D9 defaults the bridge relies on (Z enable, Z write, SRCBLEND=ONE, DESTBLEND=ZERO, CULLMODE=CCW, ZFUNC=LESSEQUAL, ALPHAFUNC=ALWAYS, stencil defaults, COLORWRITEENABLE=0xF, BLENDOP=ADD, stage 0 MODULATE/SELECTARG1 defaults, wrap addressing, point filtering, identity matrices, full-target viewport).

Getters and draws always read the live state, even while a state block is being recorded.

State blocks

BeginStateBlock/EndStateBlock produce a custom block backed by HostStateBlock: a byte image of HostD3DStateSnapshot (draw state, material, 256 light slots) with a parallel byte mask. While recording, stateblock_record_method diverts setters into host_sb_write (marking the bytes they cover) instead of changing live state; invalid arguments return D3DERR_INVALIDCALL without touching the mask.

  • Capture copies live bytes into the masked positions only; Apply copies masked bytes back into a snapshot of live state and restores it through draw_state_restore, which retains every new binding before releasing old ones.
  • The 36 resource offsets (16 textures, 16 streams, VS, PS, declaration, index buffer) are reference-counted inside the block (host_sb_replace_ref); a partial write over a binding field is rejected before any mutation. A custom block also retains its device.
  • MultiplyTransform, SetClipPlane, palette, scissor rectangle, software vertex processing, N-patch mode and SetStreamSourceFreq are not stored; the first time each is seen during recording a [stateblock] legacy setter ... is not stored in custom masks line is logged.
  • CreateStateBlock (predefined D3DSBT_* blocks) creates an object whose Capture/Apply are no-ops. The observational trace (HALO_STATEBLOCK_TRACE=1) exists to show whether the engine depends on them.

BeginStateBlock while already recording, and Reset while recording, return D3DERR_INVALIDCALL.

Fixed-function lighting

SetMaterial, SetLight and LightEnable are validated (finite values, light type 1-3, non-negative range/attenuation, spot angles) and stored in current_material and light_slots[256]; at most 8 lights can be enabled at once (lighting_enable). Enabling an undefined slot installs the D3D default white directional light.

Lighting runs on the CPU per vertex in the fixed-function 3D path only. d3d9_prepare_lighting is computed once per draw: the inverse-transpose cofactors of the world matrix (normals are passed through untransformed if the determinant is below 1e-12), and per enabled light the negated normalised direction (directional) or normalised spot axis plus cos(phi/2)/cos(theta/2). d3d9_light_vertex_prepared then evaluates emissive + ambient·D3DRS_AMBIENT + Σ attenuation·(ambient + diffuse·N·L·spot), with material sources selected by D3DRS_COLORVERTEX (141) and the *MATERIALSOURCE states (145, 147, 148), optional NORMALIZENORMALS (143), range and 1/(a0+a1·d+a2·d²) attenuation, and the D3D spot falloff. Specular lighting is not computed; the vertex specular colour is passed through.

Draw path

All four draw methods (DrawPrimitive, DrawIndexedPrimitive, DrawPrimitiveUP, DrawIndexedPrimitiveUP) end in gpu_draw, which wraps the backend call in mr_texture_bindings_begin/end so textures resolved for this draw cannot be evicted by a later stage's upload.

flowchart TD
    A["Translated engine calls IDirect3DDevice9::Draw*"] --> B["render_method (d3d9_render.inc)"]
    B --> C{"Which draw?"}
    C -->|"DrawPrimitive / DrawIndexedPrimitive"| D["Validate VB/IB ranges, compute first vertex and base"]
    C -->|"DrawPrimitiveUP / DrawIndexedPrimitiveUP"| E["Use caller memory, unbind stream 0 and indices afterwards"]
    D --> F["gpu_draw: texture binding scope"]
    E --> F
    F --> G["gpu_draw_impl: panorama UI gate, capture hooks"]
    G --> H{"Vertex shader bound?"}
    H -->|"yes"| P["gpu_program_draw"]
    H -->|"no"| I{"HALO_FF3D and declaration, no PS, no FVF?"}
    I -->|"yes"| J["ff3d_draw: CPU transform + lighting"]
    J -->|"D3DERR_NOTAVAILABLE without reason"| K
    I -->|"no"| K["RHW path: FVF 0x144 / 0x1C4 / 0x244 or POSITIONT declaration"]
    K --> L{"Pixel shader bound?"}
    L -->|"yes"| M["mr_draw_program_rhw"]
    L -->|"no"| N["mr_draw_rhw"]
    P --> Q["Upload textures, normalise indices, resident VB lookup"]
    Q --> R["mr_draw_program"]
    J --> S["mr_draw_fixed_clip"]
    R --> T["Metal command buffer (shared)"]
    S --> T
    M --> T
    N --> T
Loading

Entry-point validation

render_method computes the vertex count from the primitive count with primitive_vertices (triangle list 3n, strip/fan n+2, other types 0, primitive counts above 65534 rejected) and checks that stream 0 and the index buffer cover the requested range. For DrawIndexedPrimitive the vertex pointer passed on is stream0 + offset + (BaseVertexIndex + MinIndex)·stride and the index base becomes -MinIndex, so every index the renderer sees is relative to the first vertex actually copied. For the UP variants the stream-0 binding (and the index binding) is cleared afterwards, as D3D9 specifies.

Note: primitive_vertices returns 0 for point and line primitives, so those topologies are rejected by the device entry points even though the renderer itself supports line and point lists (see Metal Renderer).

draw_indices then converts 16- or 32-bit indices (rebased by base) to a 16-bit scratch array, expands triangle fans into lists, and rejects any index outside [0, nv) or above 65535, more than 196608 indices, or an index range past the 4 GiB guest space. It has a vectorised fast path for 16-bit lists/strips (see Geometry Fast Paths). Vertex and index scratch buffers grow to the largest draw and are reused (draw_scratch_grow); only the engine thread submits draws.

Programmable backend

gpu_program_draw requires a bound vertex shader and declaration and rejects (with a logged reason) anything outside what the renderer implements:

Rejection reason Condition
programmable object binding No VS or no declaration
programmable topology Primitive not list/strip/fan
sRGB/separatealpha/scissor SRGBWRITEENABLE (194), SEPARATEALPHABLENDENABLE (206) or SCISSORTESTENABLE (174)
two-sided stencil TWOSIDEDSTENCILMODE (185)
blend factors Illegal blend factors/operation
secondary stream binding / secondary stream range A declaration stream other than 0 not bound, stride 0 or above 256, or range outside the buffer
texture kind / texture upload Bound texture could not be uploaded

It fills an mr_program_state with the token streams and their precomputed keys, the declaration, the full constant files, TEXTUREFACTOR, colour write mask (forced to include alpha when drawing into the panorama HUD target), fog enable/colour and radial-fog mode, depth/stencil/cull/alpha-test state and the blend mapping. Secondary streams are addressed at the same first vertex as stream 0. Samplers are resolved for every stage when a pixel shader is bound; without a pixel shader only stages up to the first D3DTOP_DISABLE whose operands actually read D3DTA_TEXTURE are resolved. When the fast paths are on, stream 0 and secondary streams may be bound from a resident GPU copy.

Fixed-function 3D backend (HALO_FF3D)

When HALO_FF3D is set (the visionOS app sets it to 1; desktop leaves it unset), draws with no VS, no PS, no FVF, a declaration, and no scissor or two-sided stencil go to ff3d_draw:

  1. The declaration is scanned for stream-0 POSITION (float3/float4), TEXCOORD0..7 on any stream, stream-0 COLOR0/COLOR1 (D3DCOLOR) and a float3 NORMAL. Without an untransformed position it returns D3DERR_NOTAVAILABLE so the RHW path can try.
  2. During a panorama pass it records the projection scale and viewport of the first depth-tested draw into the back buffer (panorama_px/py, panorama_draw_viewport).
  3. Each vertex is transformed by world·view·projection to homogeneous clip space; w is kept rather than divided, because dividing by w on the CPU produced inf/NaN at w = 0 and could turn a clipped primitive into a screen-sized triangle. Metal does the clipping.
  4. Vertex colour comes from COLOR0 (white if absent), then CPU lighting if D3DRS_LIGHTING is on. With radial fog enabled on a panorama pass and FOGENABLE set with FOGTABLEMODE = NONE, a per-vertex range-fog factor is computed (see Radial Fog).
  5. For each texture stage up to the first DISABLE that samples a texture, the source coordinate set is read from the right stream (secondary streams are indexed by the same vertex), optionally transformed by D3DTTFF_COUNT2 (only flags 0 and 2 are accepted; generated coordinates are rejected), and stored in the stage's own slot. The stage's texture must be a 2D texture.
  6. The vertices go to mr_draw_fixed_clip, whose generated fragment shader evaluates the texture-stage combiner (see Metal Renderer).

Diagnostic switches HALO_FF3D_FLAT, HALO_FF3D_SHADE and HALO_FF3D_WHITE replace the vertex colour (flat green, a normal-based two-tone shade, or white) and use a single-stage SELECTARG1(DIFFUSE) material.

Pretransformed (RHW) backend

The rest of gpu_draw_impl handles screen-space vertices. It accepts FVF 0x144 (XYZRHW|DIFFUSE|TEX1, 28-byte minimum stride), 0x1C4 (+SPECULAR, 32 bytes) and 0x244 (TEX2, 36 bytes). When no FVF is set, halo_rhw_declaration_fvf recognises the two POSITIONT declarations the original executable builds (tables 0065E380/0065E3A0, created by 005301B0) byte-for-byte as 0x144/0x1C4. This is a draw-local interpretation; GetFVF still reports 0.

The path rejects FOGENABLE, two-sided stencil, separate alpha, sRGB write and scissor. Two sub-cases:

  • Pixel shader bound (HUD/screen effects): vertices are repacked into mr_vertex_fixed_rhw (colour, optional specular, one or two UV sets), every stage's texture is resolved, and the draw goes to mr_draw_program_rhw with the translated pixel shader. Cull mode 0 is promoted to 1 (none).
  • No pixel shader: alpha test must be GREATEREQUAL if enabled. If colour or alpha is written, stage 1 must be disabled or unbound (multiple texture stages). Stage 0 must be one of the combinations the simple shader implements: colour SELECTARG1(TEXTURE) or MODULATE(TEXTURE, DIFFUSE|CURRENT), alpha SELECTARG1(DIFFUSE|CURRENT), SELECTARG1(TEXTURE) or MODULATE(TEXTURE, DIFFUSE|CURRENT). Selecting diffuse alpha sets texture_color_only (Halo's additive particles modulate texture RGB but keep diffuse alpha; requiring texture alpha used to discard those draws). Sampler LOD bias and sRGB sampling are rejected. SELECTARG1(TEXTURE) is emulated by forcing the vertex colour (or alpha) to white so the shader's multiply leaves the texel unchanged.

Every RHW vertex gets +0.5 added to x and y: D3D9 integer pixel centres correspond to Metal half-integer centres.

A depth-only draw (colour write mask 0 and no alpha test) skips texture resolution entirely.

Blend-state mapping

map_blend_state maps ALPHABLENDENABLE/SRCBLEND/DESTBLEND/BLENDOP to the renderer:

D3D SRCBLEND / DESTBLEND (op ADD) Renderer mode
blending disabled MR_BLEND_NONE
ONE / ZERO MR_BLEND_NONE
SRCALPHA / INVSRCALPHA MR_BLEND_SRC_ALPHA
ONE / ONE MR_BLEND_ADD
DESTCOLOR / ZERO, ZERO / SRCCOLOR MR_BLEND_MODULATE
DESTCOLOR / SRCCOLOR MR_BLEND_MODULATE2
SRCALPHA / ZERO MR_BLEND_SRC_ALPHA_ZERO
SRCALPHA / ONE MR_BLEND_SRC_ALPHA_ADD
ONE / INVSRCALPHA MR_BLEND_PREMULTIPLIED
DESTALPHA / ONE MR_BLEND_DEST_ALPHA_ADD
anything else legal, or a non-ADD BLENDOP MR_BLEND_CUSTOM with raw factors/op

MR_BLEND_CUSTOM is rejected (draw returns blend factors) for ops outside 1-5, factors outside 1-15, and the two BOTH* factors (12, 13) that Metal cannot express. The Metal factor equations are on the Metal Renderer page; DEST_ALPHA_ADD applies src·dstA + dst to RGB and alpha.

Return codes and failure

A draw the bridge cannot represent returns D3DERR_NOTAVAILABLE through unsupported_draw, which logs the first 24 reasons with the FVF/VS/PS/declaration; it is never counted as a successful draw. Range errors return D3DERR_INVALIDCALL, allocation failures E_OUTOFMEMORY. Renderer errors are reported through mr_last_error() (first 30 logged).

Reset and lost device

reset_device:

  1. Rejects a null device/parameters or a state block being recorded.
  2. Uses the requested size (0 keeps the current size); the size must be 64-8192.
  3. Calls mr_resize on the back buffer's renderer first; on failure returns D3DERR_NOTAVAILABLE and leaves every surface, size and binding unchanged. The renderer keeps its pipeline and texture caches; only the size-dependent colour/depth targets are replaced.
  4. Re-describes the implicit back buffer and depth surface (reset_implicit_surface), reallocating guest storage only when it must grow, and clearing it.
  5. Rebinds the implicit targets, reapplies render-state and lighting defaults (all bindings released), and invalidates panorama history (host_panorama_invalidate).

The device is never lost (TestCooperativeLevel always succeeds), so there is no D3DPOOL_DEFAULT recreation cycle.

Present

Present (case 17) is also the host's per-frame heartbeat: it records the presenting thread, checks the FP environment, runs the input injection and controller bridges, the gaze pointer servo and the audio watchdog, and then publishes the frame through one of three paths:

Condition Action
A panorama GPU pool slot is ready and valid metalwin_present_gpu(slot, w, h): zero-copy hand-off; views and HUD were already blitted into the platform pool.
A GPU sink is active but the world frame is incomplete metalwin_present_dropped: nothing is copied; the presenter keeps its last complete panorama.
Otherwise surface_flush (readback) then metalwin_present(guest bytes); a failed readback returns D3DERR_NOTAVAILABLE.

Afterwards it finishes the first-person and model captures, writes optional frame captures, logs every 60 frames, prints the draw-traffic report every 600 frames, and exits when host_frame_limit is reached. The panorama-specific parts are described in Panorama System and Immersive Presenter.

macOS presenter

metalwin.m creates one fixed-size NSWindow with a layer-hosting CAMetalLayer (BGRA8, opaque). Window creation is marshalled to the main thread with a 3-second timeout so a host whose main thread is blocked fails cleanly instead of deadlocking; metalwin_present uploads the BGRA framebuffer with replaceRegion, draws a full-screen triangle with a linear clamp sampler, presents, and pumps Cocoa events (asynchronously on the main queue when called from another thread). metalwin_present_gpu and metalwin_present_dropped are declared in the header but implemented by the visionOS bridge (EngineVisionBridge.h).

DirectDraw 7 stub

ddraw.c exists only so the game's display probing succeeds: DirectDrawEnumerateExA reports one "Primary Display Driver", DirectDrawCreateEx returns a single shared object, EnumDisplayModes calls back with seven modes at 16 and 32 bpp, GetCaps/GetAvailableVidMem report 256 MiB, GetDeviceIdentifier reports halo-vision-null, and CreateSurface fails with DDERR_UNSUPPORTED. Every other method returns DD_OK.

Diagnostics

All diagnostics are opt-in and observational: none of them changes draw acceptance or guest state.

Facility Switch What it does
Render trace HALO_RENDER_TRACE_FROM, _TO, _EVERY Per selected frame, groups draws by a key (primitive, stride, FVF, shaders, declaration, colour mask, target, viewport, depth/fog/stencil/scissor, blend, HRESULT, reason, pass) into at most 32 rows and logs [render-trace]/[render-draw] at Present (d3d9_capture.inc).
Draw reject trace HALO_DRAW_REJECT_TRACE=1, HALO_DRAW_REJECT_FROM, _TO Logs up to 32 distinct rejected-draw keys with blend/alpha/depth state and up to 8 declarations element by element.
Drop tally HALO_DROPTALLY Counts unsupported-draw reasons and logs them every 60 frames.
Render capture HALO_RENDER_CAPTURE=<dir>, _FROM, _TO, _PASS (0-2), _LIMIT (1-256, default 40), _VS, _FVF Writes JSON/binary payloads per draw (state, transforms, material, lights, constants, shaders, level-0 textures, declaration, vertices, secondary streams, indices, HRESULT sidecars) within a 128 MiB budget, plus capture-summary.json.
First-person capture HALO_FP_CAPTURE=<dir>, HALO_FP_CAPTURE_FROM One frame of the centre pass inside the viewmodel scope (004924B0): up to 16 draws, 8 ProcessVertices calls and 8 bone uploads (SetVertexShaderConstantF starting at c29), with clip-space vertices and normalised indices.
Model capture HALO_MODEL_CAPTURE=<dir>, _FROM, _OBJECT Hooks the original model-draw functions 004D6FC0 (normal) and 00533850/00533730 (deferred) via host_model_capture_dispatch; selects one world biped (caller 0050F049, object type 0) and captures up to 64 draws, 16 PV calls, 32 bone uploads in a 64 MiB budget, plus a 64-entry candidate inventory.
Draw profile HALO_DRAW_PROFILE Times CPU vertex work and submission per draw; host_draw_profile_report logs [draw-profile]. Per-pass timing is always on.
Sampler trace HALO_SAMPLER_TRACE Logs up to 32 distinct mag/min/mip/anisotropy tuples.
State-block trace HALO_STATEBLOCK_TRACE=1 Logs create/begin/end/capture/apply (32 details, then counts) with hashes of bindings, matrices and constants, and a summary each second.
Frame capture HALO_FRAME_CAPTURE=<dir>, HALO_FRAME_CAPTURE_EVERY (default 250), HALO_CAPTURE_DENSE Writes frame-NNNN.bgra + JSON of the back buffer at frame 1, the frame limit, every N frames, or every 20 frames.

Environment variables

Defaults are "unset" unless noted. The visionOS app sets several of these before the engine starts (EngineVisionRuntime.m) without overriding explicit values; see Environment Variables and Runtime Settings.

Variable Default Effect Read at
HALO_FF3D unset (visionOS: 1) Enables the CPU fixed-function 3D backend. d3d9_render.inc:831
HALO_FF3D_FLAT, HALO_FF3D_SHADE, HALO_FF3D_WHITE unset Diagnostic vertex colouring for the FF3D path. d3d9_render.inc:714-716
HALO_FF3D_NOALPHA unset Ignores alpha test in the FF3D path. d3d9_render.inc:709
HALO_FF3D_LOG, HALO_FF3D_PASSES, HALO_FF3D_RAW unset FF3D logging: draw/texture/vertex samples and material/light setters; one log per distinct pass signature; raw vertex bytes for large textures. d3d9_render.inc:672, d3d9.c:771, d3d9_render.inc:739, d3d9_render.inc:761
HALO_FF3D_DUMPTEX unset Directory to dump the first 40 decoded textures as .bgra. d3d9_render.inc:343
HALO_DROPTALLY unset Per-reason drop counts every 60 frames. d3d9_render.inc:264
HALO_DRAW_REJECT_TRACE, _FROM, _TO off Rejected-draw trace window. d3d9_render.inc:212
HALO_DRAW_PROFILE unset Per-draw CPU timing and [draw-profile] log. d3d9_render.inc:404
HALO_SAMPLER_TRACE unset Distinct sampler tuples. d3d9_render.inc:473
HALO_RENDER_TRACE_FROM, _TO, _EVERY off Render-trace frame window and interval. d3d9_capture.inc:28-29
HALO_RENDER_CAPTURE and _FROM, _TO, _PASS, _LIMIT, _VS, _FVF off Draw payload capture. d3d9_capture.inc:170-172, :210-212
HALO_PROCESS_VERTICES_TRACE, HALO_PROCESS_VERTICES_CAPTURE unset Log ProcessVertices calls; capture their source vertices (and restrict render capture to them). d3d9_render.inc:1060-1065
HALO_FP_CAPTURE, HALO_FP_CAPTURE_FROM off First-person capture. fp_vertex_capture.inc:21-22
HALO_MODEL_CAPTURE, _FROM, _OBJECT off Actor capture. model_capture_hooks.inc:9, model_vertex_capture.inc:28-29
HALO_STATEBLOCK_TRACE off (1 enables) State-block trace. d3d9.c:321
HALO_FRAME_CAPTURE, HALO_FRAME_CAPTURE_EVERY (250), HALO_CAPTURE_DENSE off Back-buffer dumps at Present. d3d9.c:733-737
HALO_RADIAL_FOG off See Radial Fog. halo_settings.c
HALO_PV_SKIN_FAST, HALO_PV_DIFFERENTIAL, HALO_PV_DIFFERENTIAL_FROM_FRAME, HALO_DRAW_FASTPATH see Geometry Fast Paths
HALO_TEXTURE_PACK, HALO_TEXTURE_MODS, HALO_SHADER_PACK, HALO_SHADER_MODS see Textures and Texture Packs and Shader Translation

Present-time host hooks

Present also evaluates a set of debugging and automation switches. They are not graphics features, but they live in d3d9.c because Present is the per-frame boundary:

Variable Default Effect Line
HALO_KEYSEQ unset start:end:hexScan,... holds DirectInput scan codes during frame ranges. 528
HALO_LOOKSEQ unset start:end:dx:dy,... injects relative mouse motion. 536
HALO_PAD2KEY on (0 disables) Maps the controller sticks to WASD and mouse look when no menu is active; right trigger notifies haptics. 551
HALO_LOOKSCALE 25 Stick-to-mouse scale for HALO_PAD2KEY. 564
HALO_CTLLOG unset (visionOS 1) Logs the pad-to-player input chain every 60 frames. 570
HALO_SETMODE1_AT unset Sets game mode global 00719720 to 1 at a frame. 583
HALO_MKSESSION_AT, HALO_MKSESSION_FN unset, 0049d210 Calls a guest function at a frame. 584-586
HALO_SAVEWATCH, HALO_SAVEFORCE unset Log / clear the save-busy flags. 590-592
HALO_POKE8, HALO_POKE32, HALO_PEEK unset Write/read guest globals in 0x400000-0xC00000 each frame (peek every 60). 595-601
HALO_FORCEDIFF unset Forces campaign difficulty 0-3. 610
HALO_SETGLOBAL unset Sets HSC boolean globals by index. 616
HALO_A10_AUTOPLAY unset (visionOS 0) 1 forces Heroic, clears save-busy and input gates for diagnostics; must not establish playability evidence. 625
HALO_WAKE_SCRIPT, HALO_SCAN_THREADS unset Wake / list HSC script threads. 638-643
HALO_SCAN_DEVICES, HALO_OPEN_DEVICES unset Log nearby device machines; force them open. 653-672
HALO_TRACE unset Logs game-mode/shell state changes. 679
HALO_CONSOLE_FRAME, HALO_CONSOLE_CMD unset Executes a console command at a frame via 004C6A80. 686
HALO_LOADMAP, HALO_LOADMAP_FRAME, HALO_CAMPAIGN, HALO_DIFF, HALO_GAMEMODE, HALO_GAMEMODE_FRAME unset Requests a map load at a frame, optionally via the campaign-start function 0049CFD0 with a difficulty, and sets a game-mode flag. 691-709

Most of these use getenv directly each frame; the hot draw-path switches use the HOST_ENV macro (host.h:121), which caches the value per call site.

Threading

The bridge is engine-thread only. Draw scratch buffers, the object table, draw_state, capture state and the render-surface list have no locks; the comment on draw_scratch_grow states the invariant ("only the engine thread submits draws, and a draw is finished before the next begins"). Shader translation is pushed to the renderer's worker threads (see Shader Translation), and GPU completion happens on Metal threads, but neither touches bridge state. See Threading and Synchronization.

Tests

Test Built by tools/run_source_checks.py What it asserts
test_d3d9_resource_lifetime.c yes (macOS) Buffer COM/Lock/binding/state-block/Reset/shader ownership against the real guest heap and page allocators; slot recycling over 140000 buffers; 9000-descriptor hash collision/removal stress.
test_d3d9_texture_lifetime.c yes (macOS) Texture/cube/volume parent aliases (36 distinct cube face-mips), GetContainer/GetDevice references, GPU-target teardown, sampler/custom-block/RT/depth/Reset ownership over 96000 descriptor turnovers.
test_d3d9_reset.c yes (macOS) Reset keeps the renderer, frees replaced pages, reuses smaller storage, resets state, invalidates same-size panorama history, and changes nothing when mr_resize fails.
test_d3d9_stateblock.c no (standalone) Custom Begin/End control, invalid/nested recording, live state untouched while recording, sparse F/I/B constant masks, masked Capture/Apply over 1000 cycles without reallocation, binding retains, final Release, predefined blocks unchanged.
test_stateblock.c no stateblock.h alone: mask recording, replacement/null/duplicate resource ownership, allocation counts.
test_d3d9_stateblock_trace.c no The trace preserves CPU/draw state, caps details, counts omissions and rate-limits summaries.
test_d3d9_lighting.c no D3DMATERIAL9/D3DLIGHT9 layouts (68/104 bytes), 8-light limit, directional/point/spot results, and that prepared lighting equals the per-vertex reference across light kinds, material sources and singular transforms.
test_d3d9_destalpha_blend.c no FF3D accepts DESTALPHA/ONE with the observed POSITION/NORMAL/TEXCOORD declaration, forwards the RGB-only mask and depth state, and leaves live state unchanged. It also asserts that DESTALPHA/ZERO is rejected; with the current MR_BLEND_CUSTOM mapping that pair is legal, so this standalone fixture appears to predate the custom-blend change.
test_rhw_declaration.c no Both POSITIONT tables map to 0x144/0x1C4; offsets, stream and type must match exactly; ordinary BSP POSITION declarations are excluded.
test_draw_reject_trace.c no Window gating, deduplication, lifetime caps, invalid-handle safety, unchanged draw state, HRESULT and fallback reason.
test_render_capture_selectors.c no Frame/pass/limit selectors, default limit 40, 128 MiB budget, metadata/result files, read-only state.
test_fp_vertex_capture.c no FP capture gates, payloads, caps, manifest, one-shot closure; live and guest bytes unchanged.
test_model_vertex_capture.c no Actor ABI/biped filter, reentry, nested deferred identity, guest escape cleanup, PV before/after, stream slices, omissions.
test_draw_indices.c, test_vb_resident.c, test_d3d9_radial_fog.c yes (macOS) See Geometry Fast Paths and Radial Fog.

Related pages

Clone this wiki locally