Yesterday we showed our screenshot comparison pipeline, with captures from Zoo Tycoon 2 alongside OpenZT2 and a difference heatmap. This time we’re looking at the code underneath those pictures: how the original Direct3D 9 Effects files reach our Bevy renderer.

Zoo Tycoon 2 uses the D3D9 Effects Framework to package rendering instructions in .fx files. An effect can contain shader functions written in HLSL, Microsoft’s shader language, alongside parameters and techniques. Each technique contains ordered passes, and each pass selects shaders and assigns render state.

What’s in an effect?

Here’s a small example written for this article. The pixel shader returns a tint. The pass also enables alpha blending, chooses the blend factors and disables depth writes:

HLSL / D3D9 Effects
float4 Tint = float4(1.0, 0.5, 0.2, 0.5);

float4 tint_pixel() : COLOR0
{
    return Tint;
}

technique TransparentTint
{
    pass P0
    {
        PixelShader = compile ps_2_0 tint_pixel();
        AlphaBlendEnable = TRUE;
        SrcBlend = SRCALPHA;
        DestBlend = INVSRCALPHA;
        ZWriteEnable = FALSE;
    }
}

Translating the function preserves the colour calculation, but it leaves the pass assignments unresolved. If the renderer writes that colour as an opaque pixel, the half-transparent tint becomes a solid patch. If depth writes are wrong, a surface can hide something that should remain visible behind it. The shader’s output is only part of the draw.

Effects can also use parameters in state assignments. A loader that reads those assignments once and stores guessed constants loses the connection to the material parameters. We need the effect’s evaluation rules as well as its shader compiler.

Building on existing compilers

Our first integration used a Windows-only D3DX boundary. We removed that path and moved to native libraries so the game could load Effects without depending on the Windows runtime. The current implementation builds on Wine’s vkd3d-shader and MojoShader.

vkd3d-shader supplies the HLSL parser and compiler. We give it the whole effect with the fx_2_0 profile, and it produces compiled FX2 bytecode containing the effect structure and its shaders. Includes are opened through OpenZT2’s archive overlay, so a mod can override an included file independently of the main effect.

That lets us reuse an existing language implementation. Effects files have expressions, declarations and preprocessing rules; treating them as a collection of strings to replace would leave us implementing an increasingly accidental compiler.

MojoShader opens the compiled effect and evaluates its passes after we apply the material’s parameter values. It supplies the selected shaders and state changes for each pass. We also use its assembler for inline legacy shader assembly and its SPIR-V backend to translate D3D9 shader bytecode.

The gaps we extended

The libraries gave us most of the machinery, but the game exercised unfinished FX2 paths. Our vkd3d-shader patches add sampler and object-state emission, preserve state value types and vector components, and emit parameter-dependent state expressions. Those expressions become small preshader programs that can be evaluated with the effect’s parameters.

One smaller failure was repeated pass names. Pass labels aren’t ordinary HLSL variables: effects can contain repeated labels and address the passes by their order. Our patch keeps those passes in the technique’s ordered list instead of registering each label as a unique variable.

On the MojoShader side, we retain indexed state assignments and parameter references, then evaluate state preshaders when a pass begins. Other patches preserve complete state vectors and sampler mappings. Losing an index or a vector component here can give the renderer a plausible value for the wrong texture stage.

Shader interfaces needed attention too. Older pixel shaders can have implicit inputs, so their SPIR-V inputs still need linking to the vertex shader’s outputs. We extended that linking and the handling of vertex shaders used without a programmable pixel shader.

From compiled shaders to Bevy materials

The programmable path currently ends in SPIR-V, an intermediate shader format. Bevy accepts it through Shader::from_spirv. OpenZT2 also uses WGSL for its fixed-function material implementation, including a fragment shader adapted to a programmable vertex shader’s outputs. There isn’t a required HLSL-to-WGSL text conversion between MojoShader and Bevy.

After evaluation, each pass becomes an EffectPassMaterial asset. Its shader handles and texture bindings are registered with Bevy, and the pass’s state contributes to a material pipeline specialization key. The wgpu renderer then gets the blend, culling and depth settings it needs.

These assignments come directly from our Bevy material specialization code:

Rust
target.blend = key.bind_group_data.blend_state;
target.write_mask = key.bind_group_data.color_writes;

descriptor.primitive.cull_mode = key.bind_group_data.cull_face;
descriptor.primitive.polygon_mode = key.bind_group_data.polygon_mode;

depth.depth_write_enabled = Some(key.bind_group_data.depth_write_enabled);
depth.depth_compare = Some(key.bind_group_data.depth_compare);

Shader register and descriptor bindings also need to match Bevy’s material layout. We use rspirv when separating combined texture samplers into the bindings expected by the renderer. Bevy/wgpu owns the resulting pipelines and draw submission. The Effects libraries compile and evaluate the original instructions without running a second graphics renderer alongside the game.

Checking the picture

A successful compilation tells us the compiler accepted the effect. It takes a rendered comparison to find a wrong blend factor, a missing texture binding or a pass drawn in the wrong order. That’s how the compiler work connects to yesterday’s globe and terrain captures.

The remaining differences give us specific things to investigate. Cloud shading needs more work, and text layout has its own set of mismatches. We can follow each one back through the material state and shader inputs, then capture the same checkpoint again after a correction.