//! wgpu rendering state for the UI window.
//!
//! One [`RenderState`] is built on `ApplicationHandler::resumed` and reused
//! for the lifetime of the window. It owns the wgpu device/queue/surface,
//! the frontbuffer texture (resized on demand when the guest reports a new
//! `SwapInfo`), and the two pipelines (`blit`, `hud`).

use std::sync::Arc;

use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt;
use winit::window::Window;

use crate::font;
use crate::xenos_pipeline::{DrawRequest, XenosPipeline};

/// HUD vertex layout — six of these per character quad.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct HudVertex {
    pub pos_px: [f32; 2],
    pub uv: [f32; 2],
    pub rgba: [f32; 4],
}

#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct BlitUniforms {
    aspect_correction: [f32; 2],
    _pad0: [f32; 2],
    fallback_rgb: [f32; 3],
    _pad1: f32,
}

#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct HudUniforms {
    inv_surface: [f32; 2],
    _pad: [f32; 2],
}

pub struct RenderState {
    pub window: Arc<Window>,
    surface: wgpu::Surface<'static>,
    device: wgpu::Device,
    queue: wgpu::Queue,
    config: wgpu::SurfaceConfiguration,
    sampler: wgpu::Sampler,

    // Blit pipeline (frontbuffer → screen).
    blit_pipeline: wgpu::RenderPipeline,
    blit_bind_group_layout: wgpu::BindGroupLayout,
    blit_uniforms: wgpu::Buffer,
    blit_bind_group: wgpu::BindGroup,
    frontbuffer_tex: wgpu::Texture,
    frontbuffer_view: wgpu::TextureView,
    frontbuffer_size: (u32, u32),
    frontbuffer_aspect: f32,

    // HUD pipeline (bitmap font → screen).
    hud_pipeline: wgpu::RenderPipeline,
    hud_uniforms: wgpu::Buffer,
    hud_bind_group: wgpu::BindGroup,
    hud_vertex_buffer: wgpu::Buffer,
    hud_vertex_capacity: u64,
    hud_vertex_count: u32,

    /// Background color used when no frontbuffer has been uploaded yet.
    pub fallback_rgb: [f32; 3],

    /// Xenos host pipeline. Renders captured `DRAW_INDX*` events into the
    /// frontbuffer texture with the placeholder `xenos_interp.wgsl`.
    xenos_pipeline: XenosPipeline,
    /// Draws rendered so far via `xenos_pipeline`. Tracked to know how
    /// many fresh procedural draws to dispatch between `draws_total`
    /// reports in `SwapInfo`.
    pub xenos_draws_rendered: u64,
    /// First-Pixels M4 — per-path dispatch counters. `draws_total` in
    /// `SwapInfo` reflects the *guest-side* captured-draw count; these
    /// split it into "served by a compiled translator pipeline" vs "fell
    /// back to the uber-shader interpreter". HUD surfaces both so when
    /// the game actually reaches DRAW_INDX we can tell at a glance
    /// whether (a) we dispatched at all, (b) the translator accepted
    /// the shader, or (c) we're running the slow interpreter path.
    pub xenos_dispatches_translator: u64,
    pub xenos_dispatches_interpreter: u64,
    /// iterate-3O: running total of replayed draws that carried a real guest
    /// vertex window (vs. the procedural fallback). Surfaced on the HUD.
    real_geometry_draws: u64,
    /// One-shot latch so we emit a tracing::info! on the **first** real
    /// draw dispatch rather than spamming every frame. Pairs with the
    /// "first translator compile" latch below.
    first_dispatch_logged: bool,
    first_translator_compile_logged: bool,
    /// P5 host-side wgpu texture cache — keyed on `TextureKey`, uploads
    /// kernel-decoded linear bytes into `wgpu::Texture` + `TextureView`
    /// and hands the view to the xenos pipeline's `@group(1) @binding(0)`
    /// slot.
    host_texture_cache: crate::texture_cache_host::TextureCacheHost,
}

impl RenderState {
    /// Create a new render state bound to `window`.
    pub fn new(window: Arc<Window>) -> anyhow::Result<Self> {
        let size = window.inner_size();
        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::default());
        let surface = instance
            .create_surface(window.clone())
            .map_err(|e| anyhow::anyhow!("create_surface failed: {e}"))?;
        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
            power_preference: wgpu::PowerPreference::LowPower,
            force_fallback_adapter: false,
            compatible_surface: Some(&surface),
        }))
        .ok_or_else(|| anyhow::anyhow!("no wgpu adapter available"))?;

        // Request the adapter's *actual* limits (not the extra-conservative
        // `downlevel_defaults` ceiling of 2048²). Otherwise resizing the
        // window past 2048×anything panics in `Surface::configure` with
        //   "Surface width and height must be within the maximum supported
        //    texture size".
        let adapter_limits = adapter.limits();
        let (device, queue) = pollster::block_on(adapter.request_device(
            &wgpu::DeviceDescriptor {
                label: Some("xenia-ui device"),
                required_features: wgpu::Features::empty(),
                required_limits: adapter_limits.clone(),
                memory_hints: wgpu::MemoryHints::Performance,
            },
            None,
        ))?;

        let surface_caps = surface.get_capabilities(&adapter);
        let format = surface_caps
            .formats
            .iter()
            .copied()
            .find(|f| f.is_srgb())
            .unwrap_or(surface_caps.formats[0]);
        let present_mode = if surface_caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
            wgpu::PresentMode::Mailbox
        } else {
            wgpu::PresentMode::Fifo
        };
        let max_dim = adapter_limits.max_texture_dimension_2d.max(1);
        let config = wgpu::SurfaceConfiguration {
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
            format,
            width: size.width.clamp(1, max_dim),
            height: size.height.clamp(1, max_dim),
            present_mode,
            alpha_mode: surface_caps.alpha_modes[0],
            view_formats: vec![],
            desired_maximum_frame_latency: 2,
        };
        surface.configure(&device, &config);

        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
            label: Some("xenia-ui sampler"),
            address_mode_u: wgpu::AddressMode::ClampToEdge,
            address_mode_v: wgpu::AddressMode::ClampToEdge,
            address_mode_w: wgpu::AddressMode::ClampToEdge,
            mag_filter: wgpu::FilterMode::Linear,
            min_filter: wgpu::FilterMode::Linear,
            mipmap_filter: wgpu::FilterMode::Nearest,
            ..Default::default()
        });

        // ── Blit pipeline ──
        let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("blit.wgsl"),
            source: wgpu::ShaderSource::Wgsl(include_str!("shaders/blit.wgsl").into()),
        });
        let blit_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("blit bind group layout"),
            entries: &[
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 1,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 2,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
                    count: None,
                },
            ],
        });
        let blit_pipeline_layout =
            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
                label: Some("blit pipeline layout"),
                bind_group_layouts: &[&blit_bgl],
                push_constant_ranges: &[],
            });
        let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("blit pipeline"),
            layout: Some(&blit_pipeline_layout),
            vertex: wgpu::VertexState {
                module: &blit_shader,
                entry_point: "vs_main",
                compilation_options: Default::default(),
                buffers: &[],
            },
            fragment: Some(wgpu::FragmentState {
                module: &blit_shader,
                entry_point: "fs_main",
                compilation_options: Default::default(),
                targets: &[Some(wgpu::ColorTargetState {
                    format: config.format,
                    blend: None,
                    write_mask: wgpu::ColorWrites::ALL,
                })],
            }),
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::TriangleList,
                strip_index_format: None,
                front_face: wgpu::FrontFace::Ccw,
                cull_mode: None,
                polygon_mode: wgpu::PolygonMode::Fill,
                unclipped_depth: false,
                conservative: false,
            },
            depth_stencil: None,
            multisample: wgpu::MultisampleState::default(),
            multiview: None,
            cache: None,
        });

        // Placeholder 1×1 frontbuffer so a bind group can exist before the
        // guest posts its first swap.
        let (frontbuffer_tex, frontbuffer_view) = make_frontbuffer(&device, 1, 1);
        let initial_uniforms = BlitUniforms {
            aspect_correction: [1.0, 1.0],
            _pad0: [0.0; 2],
            fallback_rgb: [0.06, 0.06, 0.09],
            _pad1: 0.0,
        };
        let blit_uniforms = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("blit uniforms"),
            contents: bytemuck::bytes_of(&initial_uniforms),
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
        });
        let blit_bind_group = make_blit_bg(
            &device,
            &blit_bgl,
            &blit_uniforms,
            &frontbuffer_view,
            &sampler,
        );

        // ── HUD pipeline ──
        let hud_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("hud.wgsl"),
            source: wgpu::ShaderSource::Wgsl(include_str!("shaders/hud.wgsl").into()),
        });
        let atlas_bytes = font::build_atlas();
        let atlas_size = wgpu::Extent3d {
            width: font::CELL_W,
            height: font::CELL_H * font::GLYPH_COUNT,
            depth_or_array_layers: 1,
        };
        let atlas = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("hud font atlas"),
            size: atlas_size,
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::R8Unorm,
            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
            view_formats: &[],
        });
        queue.write_texture(
            wgpu::ImageCopyTexture {
                texture: &atlas,
                mip_level: 0,
                origin: wgpu::Origin3d::ZERO,
                aspect: wgpu::TextureAspect::All,
            },
            &atlas_bytes,
            wgpu::ImageDataLayout {
                offset: 0,
                bytes_per_row: Some(font::CELL_W),
                rows_per_image: Some(font::CELL_H * font::GLYPH_COUNT),
            },
            atlas_size,
        );
        let atlas_view = atlas.create_view(&wgpu::TextureViewDescriptor::default());

        let hud_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("hud bind group layout"),
            entries: &[
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::VERTEX,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 1,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 2,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
                    count: None,
                },
            ],
        });
        let hud_pipeline_layout =
            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
                label: Some("hud pipeline layout"),
                bind_group_layouts: &[&hud_bgl],
                push_constant_ranges: &[],
            });
        let hud_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("hud pipeline"),
            layout: Some(&hud_pipeline_layout),
            vertex: wgpu::VertexState {
                module: &hud_shader,
                entry_point: "vs_main",
                compilation_options: Default::default(),
                buffers: &[wgpu::VertexBufferLayout {
                    array_stride: std::mem::size_of::<HudVertex>() as u64,
                    step_mode: wgpu::VertexStepMode::Vertex,
                    attributes: &wgpu::vertex_attr_array![
                        0 => Float32x2,
                        1 => Float32x2,
                        2 => Float32x4,
                    ],
                }],
            },
            fragment: Some(wgpu::FragmentState {
                module: &hud_shader,
                entry_point: "fs_main",
                compilation_options: Default::default(),
                targets: &[Some(wgpu::ColorTargetState {
                    format: config.format,
                    blend: Some(wgpu::BlendState {
                        // Premultiplied alpha blend.
                        color: wgpu::BlendComponent {
                            src_factor: wgpu::BlendFactor::One,
                            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
                            operation: wgpu::BlendOperation::Add,
                        },
                        alpha: wgpu::BlendComponent::OVER,
                    }),
                    write_mask: wgpu::ColorWrites::ALL,
                })],
            }),
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::TriangleList,
                ..Default::default()
            },
            depth_stencil: None,
            multisample: wgpu::MultisampleState::default(),
            multiview: None,
            cache: None,
        });

        let initial_hud_uniforms = HudUniforms {
            inv_surface: [1.0 / config.width as f32, 1.0 / config.height as f32],
            _pad: [0.0; 2],
        };
        let hud_uniforms = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("hud uniforms"),
            contents: bytemuck::bytes_of(&initial_hud_uniforms),
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
        });
        let hud_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("hud bind group"),
            layout: &hud_bgl,
            entries: &[
                wgpu::BindGroupEntry {
                    binding: 0,
                    resource: hud_uniforms.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 1,
                    resource: wgpu::BindingResource::TextureView(&atlas_view),
                },
                wgpu::BindGroupEntry {
                    binding: 2,
                    resource: wgpu::BindingResource::Sampler(&sampler),
                },
            ],
        });
        // Initial 4KB vertex buffer — grown on demand.
        let hud_vertex_capacity = 4096;
        let hud_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("hud vertex buffer"),
            size: hud_vertex_capacity,
            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });

        // The Xenos host pipeline renders against the frontbuffer's sRGB
        // format. When the frontbuffer is recreated (resize), the pipeline
        // itself doesn't need rebuilding because the format stays constant
        // (`XENOS_FRONTBUFFER_FORMAT`).
        let xenos_pipeline = XenosPipeline::new(&device, &queue, XENOS_FRONTBUFFER_FORMAT);

        Ok(Self {
            window,
            surface,
            device,
            queue,
            config,
            sampler,
            blit_pipeline,
            blit_bind_group_layout: blit_bgl,
            blit_uniforms,
            blit_bind_group,
            frontbuffer_tex,
            frontbuffer_view,
            frontbuffer_size: (1, 1),
            frontbuffer_aspect: 16.0 / 9.0,
            hud_pipeline,
            hud_uniforms,
            hud_bind_group,
            hud_vertex_buffer,
            hud_vertex_capacity,
            hud_vertex_count: 0,
            fallback_rgb: [0.06, 0.06, 0.09],
            xenos_pipeline,
            xenos_draws_rendered: 0,
            real_geometry_draws: 0,
            xenos_dispatches_translator: 0,
            xenos_dispatches_interpreter: 0,
            first_dispatch_logged: false,
            first_translator_compile_logged: false,
            host_texture_cache: crate::texture_cache_host::TextureCacheHost::new(),
        })
    }

    /// First-Pixels M4 — (width, height) of the frontbuffer texture the
    /// Xenos pipeline currently paints into. Surfaced on the HUD so a
    /// "1×1" value immediately reveals the pipeline never `ensured`
    /// itself to the swap-reported 1280×720 (a common misconfiguration
    /// signature).
    pub fn frontbuffer_size(&self) -> (u32, u32) {
        self.frontbuffer_size
    }

    /// Live count of wgpu-uploaded guest textures in the host cache.
    /// Mirrors `xenia_gpu::texture_cache::TextureCache::len` but only
    /// counts entries that actually have GPU-side storage (i.e. had a
    /// supported format on decode). Expected to stay at 0 until the
    /// game first binds a texture; see the `xdispatch` split on the
    /// HUD render row for when that happens.
    pub fn host_texture_count(&self) -> usize {
        self.host_texture_cache.len()
    }

    /// Upload a kernel-decoded texture into the host wgpu cache and bind
    /// it into the xenos pipeline. `None` reverts to the built-in dummy.
    /// Called from `app.rs` right before `dispatch_xenos_draws`.
    pub fn bind_primary_texture(
        &mut self,
        payload: Option<(xenia_gpu::texture_cache::TextureKey, Vec<u8>)>,
    ) {
        use xenia_gpu::texture_cache::CachedTexture;
        // Destructure to split borrows — `host_texture_cache`, `device`,
        // `queue`, and `xenos_pipeline` are disjoint fields on `self`.
        let Self {
            device,
            queue,
            xenos_pipeline,
            host_texture_cache,
            ..
        } = self;
        match payload {
            None => xenos_pipeline.set_texture_view(device, None),
            Some((key, bytes)) => {
                let cached = CachedTexture {
                    key,
                    version_when_uploaded: 0,
                    bytes,
                };
                host_texture_cache.upload(device, queue, &cached);
                metrics::counter!(
                    "gpu.texture.upload",
                    "fmt" => format!("{:?}", key.format),
                )
                .increment(1);
                let view_ref = host_texture_cache
                    .view_for(&key)
                    .expect("upload just ran; view must exist");
                xenos_pipeline.set_texture_view(device, Some(view_ref));
            }
        }
    }

    pub fn surface_size(&self) -> (u32, u32) {
        (self.config.width, self.config.height)
    }

    pub fn resize(&mut self, width: u32, height: u32) {
        if width == 0 || height == 0 {
            return;
        }
        // Clamp to the adapter's `max_texture_dimension_2d`. If the user
        // drags the window larger than the GPU can back (e.g., a GLES
        // fallback adapter with 2048² max), `surface.configure()` panics
        // unless we cap first.
        let max_dim = self.device.limits().max_texture_dimension_2d.max(1);
        let clamped_w = width.min(max_dim);
        let clamped_h = height.min(max_dim);
        if (clamped_w, clamped_h) != (width, height) {
            tracing::debug!(
                from = format_args!("{width}x{height}"),
                to = format_args!("{clamped_w}x{clamped_h}"),
                max_dim,
                "ui.resize: clamped to adapter texture limit"
            );
        }
        self.config.width = clamped_w;
        self.config.height = clamped_h;
        self.surface.configure(&self.device, &self.config);
        let u = HudUniforms {
            inv_surface: [1.0 / clamped_w as f32, 1.0 / clamped_h as f32],
            _pad: [0.0; 2],
        };
        self.queue
            .write_buffer(&self.hud_uniforms, 0, bytemuck::bytes_of(&u));
    }

    /// Ensure the frontbuffer host render target is at least `w×h`. Used
    /// by the Xenos pipeline so procedural draws go to a full-size target
    /// even when the guest hasn't published a VdSwap-derived size yet.
    pub fn ensure_frontbuffer_at_least(&mut self, w: u32, h: u32) {
        let (cw, ch) = self.frontbuffer_size;
        let max_dim = self.device.limits().max_texture_dimension_2d.max(1);
        let nw = w.max(cw).min(max_dim);
        let nh = h.max(ch).min(max_dim);
        if (nw, nh) != (cw, ch) {
            let (tex, view) = make_frontbuffer(&self.device, nw, nh);
            self.frontbuffer_tex = tex;
            self.frontbuffer_view = view;
            self.frontbuffer_size = (nw, nh);
            self.frontbuffer_aspect = nw as f32 / nh as f32;
            self.blit_bind_group = make_blit_bg(
                &self.device,
                &self.blit_bind_group_layout,
                &self.blit_uniforms,
                &self.frontbuffer_view,
                &self.sampler,
            );
        }
    }

    /// Render `count` captured draws into the frontbuffer texture via the
    /// Xenos host pipeline. `base_draw_index` is the monotonic index of the
    /// first draw in the batch; the shader uses `base_draw_index + i` as
    /// its per-draw identifier for procedural colouring.
    ///
    /// Caller should first `ensure_frontbuffer_at_least` to a non-1×1 size
    /// and (typically) call `clear_frontbuffer_black` if this is the first
    /// batch since the last swap.
    pub fn dispatch_xenos_draws(
        &mut self,
        base_draw_index: u64,
        count: u32,
        vertex_count_hint: u32,
        prim_kind: u32,
        vs_key: u32,
        ps_key: u32,
        parsed_vs: &xenia_gpu::ucode::ParsedShader,
        parsed_ps: &xenia_gpu::ucode::ParsedShader,
        vs_ucode: &[u32],
        ps_ucode: &[u32],
        constants: &xenia_gpu::xenos_constants::XenosConstantsBlock,
    ) {
        if count == 0 {
            return;
        }
        let _prof_g =
            xenia_gpu::prof::ScopeTimer::new(&xenia_gpu::prof::DRAW_NS, &xenia_gpu::prof::DRAW_CALLS);
        let _span = tracing::debug_span!(
            "ui.xenos.dispatch",
            count,
            base_draw_index,
            vs_len = vs_ucode.len(),
            ps_len = ps_ucode.len()
        )
        .entered();
        metrics::counter!("gpu.xenos.draw_dispatch").increment(count as u64);
        metrics::histogram!("ui.xenos.upload_ucode_kb")
            .record(((vs_ucode.len() + ps_ucode.len()) as f64) * 4.0 / 1024.0);
        // Upload shader + constants once per batch; all draws in this batch
        // share the same program + per-frame constants.
        self.xenos_pipeline
            .upload_shader_and_constants(&self.queue, vs_ucode, ps_ucode, constants);

        // P7 — try to translate this (vs, ps) pair to a dedicated WGSL
        // module + compiled pipeline. If either stage refuses (see
        // `xenia_gpu::translator::reject` reasons), fall back to the
        // runtime interpreter so we still render *something*. The
        // translate+compile is memoised per `(vs_key, ps_key)` so the
        // overhead only hits first-use of a shader pair.
        let pre_translator_count = self.xenos_pipeline.translated_pipeline_count();
        let use_translated = vs_key != 0
            && ps_key != 0
            && ensure_translated_pipeline(
                &mut self.xenos_pipeline,
                &self.device,
                vs_key,
                ps_key,
                parsed_vs,
                parsed_ps,
            );
        // First-Pixels M4 — one-shot log when the translator compiles its
        // very first pipeline. Good terminal signal that P7 is actually
        // doing work on this workload; also identifies which (vs, ps) key
        // pair succeeded so follow-up inspection can target it.
        if !self.first_translator_compile_logged
            && self.xenos_pipeline.translated_pipeline_count() > pre_translator_count
        {
            self.first_translator_compile_logged = true;
            tracing::info!(
                vs_key = format_args!("{:#010x}", vs_key),
                ps_key = format_args!("{:#010x}", ps_key),
                "first translator pipeline compiled"
            );
        }

        let mut encoder = self
            .device
            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
                label: Some("xenos dispatch"),
            });
        let mut served_translator: u64 = 0;
        let mut served_interpreter: u64 = 0;
        for i in 0..count {
            let idx = base_draw_index.wrapping_add(i as u64) as u32;
            let req = DrawRequest {
                draw_index: idx,
                vertex_count: vertex_count_hint.max(3),
                prim_kind,
                // Synthetic fallback path: no real vertex window.
                vertex_base_dwords: 0,
                // No real geometry → no NDC transform (procedural positions are
                // already in clip space).
                ndc_scale: [0.0, 0.0],
                ndc_offset: [0.0, 0.0],
            };
            // Synthetic visualizer path (legacy): no captured render state, so
            // use the opaque default.
            let rstate = crate::xenos_pipeline::RenderState::OPAQUE;
            if use_translated
                && self.xenos_pipeline.render_one_translated(
                    &self.device,
                    &self.queue,
                    &mut encoder,
                    &self.frontbuffer_view,
                    req,
                    vs_key,
                    ps_key,
                    rstate,
                )
            {
                metrics::counter!("gpu.shader.use", "path" => "translator").increment(1);
                served_translator += 1;
                continue;
            }
            self.xenos_pipeline.render_one(
                &self.device,
                &self.queue,
                &mut encoder,
                &self.frontbuffer_view,
                req,
                rstate,
            );
            metrics::counter!("gpu.shader.use", "path" => "interpreter").increment(1);
            served_interpreter += 1;
        }
        self.queue.submit(std::iter::once(encoder.finish()));
        self.xenos_draws_rendered = self.xenos_draws_rendered.saturating_add(count as u64);
        self.xenos_dispatches_translator =
            self.xenos_dispatches_translator.saturating_add(served_translator);
        self.xenos_dispatches_interpreter =
            self.xenos_dispatches_interpreter.saturating_add(served_interpreter);
        // First-Pixels M4 — one-shot "we actually rendered something" log.
        // Fires the moment the pipeline dispatches its first real guest
        // draw; subsequent draws stay silent so the log doesn't flood.
        if !self.first_dispatch_logged && count > 0 {
            self.first_dispatch_logged = true;
            let (fbw, fbh) = self.frontbuffer_size;
            tracing::info!(
                base_draw_index,
                count,
                vs_key = format_args!("{:#010x}", vs_key),
                ps_key = format_args!("{:#010x}", ps_key),
                served_translator,
                served_interpreter,
                fb_w = fbw,
                fb_h = fbh,
                "first Xenos draw dispatched"
            );
        }
    }

    /// iterate-3O real-render slice: replay a batch of *real* captured guest
    /// draws. Unlike [`dispatch_xenos_draws`] (synthetic placeholder geometry),
    /// each [`DrawCapture`] carries the actual guest vertex window, primitive
    /// type, host vertex count, and the real (vs, ps) keys. Per capture we:
    ///   1. upload the captured guest vertex bytes into `vertex_buffer` (b4),
    ///   2. upload the matching VS/PS microcode + per-frame constants,
    ///   3. render through the translated (P7) pipeline if it compiled, else
    ///      the interpreter — with `vertex_base_dwords` set so the shader
    ///      rebases its absolute fetch address into the uploaded window.
    ///
    /// Returns the number of captures that had a real vertex window (vs. the
    /// procedural fallback), for HUD reporting. `shader_blobs` / `constants`
    /// come from the bridge; `seen` records which blobs have had static
    /// metrics emitted (one-shot per blob, matching the legacy path).
    pub fn dispatch_xenos_captures(
        &mut self,
        captures: &[xenia_gpu::draw_capture::DrawCapture],
        shader_blobs: &std::collections::HashMap<u32, Vec<u32>>,
        constants: &xenia_gpu::xenos_constants::XenosConstantsBlock,
        seen: &mut std::collections::HashSet<(u8, u32)>,
    ) -> u32 {
        if captures.is_empty() {
            return 0;
        }
        let mut real_count = 0u32;
        // iterate-3X (GPUBUG-111): each captured draw uploads its OWN vertex
        // window + per-draw constants + shader via `queue.write_buffer`. In
        // wgpu all `write_buffer` calls staged before a single `queue.submit`
        // are applied *before any* command in that submit executes — so a single
        // encoder for the whole batch made every draw read only the LAST draw's
        // vertex buffer / uniforms (the splash logo quad sampled the fullscreen
        // background quad's vertices → nothing rendered where the logo was).
        // Submit ONE encoder PER draw so each draw's writes land before its own
        // pass. The frontbuffer uses `LoadOp::Load`, so per-draw submits still
        // composite over each other exactly like before.
        for cap in captures {
            // iterate-3T: bind this draw's REAL decoded texture (keyed off the
            // active PS's tfetch slot, attached in `gpu_system`) so the textured
            // logo samples the artwork. `None` reverts to the magenta stub for
            // flat draws. Each `set_texture_view` rebuilds the tex bind group;
            // the subsequent `render_one*` reads it, so per-draw binding works
            // even though all draws share one encoder.
            {
                let Self {
                    device,
                    queue,
                    xenos_pipeline,
                    host_texture_cache,
                    ..
                } = self;
                if cap.textures.is_empty() {
                    xenos_pipeline.set_texture_view(device, None);
                } else {
                    // Root-#3: upload EVERY plane this draw samples, then bind
                    // each to its own fetch-constant slot so a multi-texture
                    // shader (the intro video's YUV Y/U/V planes) reads the
                    // right texture per `tfetch`. iterate-3AD: use the decoder's
                    // real content `version` (from `span_max_version`) so the
                    // host cache re-uploads when the guest fills MORE of an
                    // evolving atlas (e.g. the 2nd splash logo sharing a K8888
                    // surface with the publisher logo).
                    for (_slot, key, version, bytes) in &cap.textures {
                        let cached = xenia_gpu::texture_cache::CachedTexture {
                            key: *key,
                            version_when_uploaded: *version,
                            bytes: bytes.clone(),
                        };
                        host_texture_cache.upload(device, queue, &cached);
                    }
                    // Collect a view per slot (immutable borrow — uploads are
                    // done) and bind them all at once.
                    let mut slot_views: [Option<&wgpu::TextureView>;
                        crate::xenos_pipeline::TEX_SLOTS] =
                        [None; crate::xenos_pipeline::TEX_SLOTS];
                    for (slot, key, _version, _bytes) in &cap.textures {
                        let s = *slot as usize;
                        if s < crate::xenos_pipeline::TEX_SLOTS {
                            slot_views[s] = host_texture_cache.view_for(key);
                        }
                    }
                    xenos_pipeline.set_texture_slots(device, &slot_views);
                }
            }
            let raw_vs = shader_blobs.get(&cap.vs_key).cloned().unwrap_or_default();
            let raw_ps = shader_blobs.get(&cap.ps_key).cloned().unwrap_or_default();
            let parsed_vs = xenia_gpu::ucode::parse_shader(&raw_vs);
            let parsed_ps = xenia_gpu::ucode::parse_shader(&raw_ps);
            if seen.insert((0u8, cap.vs_key)) {
                xenia_gpu::shader_metrics::emit_for(&parsed_vs, "vs");
            }
            if seen.insert((1u8, cap.ps_key)) {
                xenia_gpu::shader_metrics::emit_for(&parsed_ps, "ps");
            }
            let vs_packed = xenia_gpu::ucode::pack_for_wgsl(&parsed_vs);
            let ps_packed = xenia_gpu::ucode::pack_for_wgsl(&parsed_ps);
            // Upload this draw's shader + constants + real vertex window.
            self.xenos_pipeline.upload_shader_and_constants(
                &self.queue,
                &vs_packed,
                &ps_packed,
                constants,
            );
            if cap.has_real_vertices && !cap.vertex_dwords.is_empty() {
                self.xenos_pipeline
                    .upload_vertex_data(&self.queue, &cap.vertex_dwords);
                real_count += 1;
            }
            let use_translated = cap.vs_key != 0
                && cap.ps_key != 0
                && ensure_translated_pipeline(
                    &mut self.xenos_pipeline,
                    &self.device,
                    cap.vs_key,
                    cap.ps_key,
                    &parsed_vs,
                    &parsed_ps,
                );
            let base = if cap.has_real_vertices {
                cap.window_base_dwords
            } else {
                0
            };
            let req = DrawRequest {
                draw_index: cap.draw_index,
                vertex_count: cap.host_vertex_count.max(3),
                prim_kind: cap.prim_code,
                vertex_base_dwords: base,
                // iterate-3S: apply the per-draw guest viewport → host NDC
                // transform only when we have real geometry (otherwise the
                // procedural fallback already emits clip-space positions).
                ndc_scale: if cap.has_real_vertices { cap.ndc_scale } else { [0.0, 0.0] },
                ndc_offset: if cap.has_real_vertices { cap.ndc_offset } else { [0.0, 0.0] },
            };
            // iterate-3Y: replay this draw's real color/blend/write-mask state
            // (captured from `RB_BLENDCONTROL0` / `RB_COLOR_MASK`) so overlays
            // composite the way the guest intends instead of opaquely
            // overwriting the logo.
            let rstate = crate::xenos_pipeline::RenderState {
                blend_control: cap.blend_control,
                color_mask: cap.color_mask,
            };
            let mut encoder = self
                .device
                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
                    label: Some("xenos capture replay (per-draw)"),
                });
            let served_translated = use_translated
                && self.xenos_pipeline.render_one_translated(
                    &self.device,
                    &self.queue,
                    &mut encoder,
                    &self.frontbuffer_view,
                    req,
                    cap.vs_key,
                    cap.ps_key,
                    rstate,
                );
            // Log only the "interesting" draws — multi-texture or any non-K8888
            // (e.g. the movie's k_8 YUV planes) — so the boot's single-K8888
            // quads don't flood the cap before the movie composites.
            let interesting = cap.textures.len() > 1
                || cap.textures.iter().any(|(_, k, ..)| {
                    !matches!(k.format, xenia_gpu::texture_cache::TextureFormat::K8888)
                });
            if std::env::var("XENIA_BIND_LOG").is_ok() && interesting {
                use std::sync::atomic::{AtomicUsize, Ordering};
                static N: AtomicUsize = AtomicUsize::new(0);
                let n = N.fetch_add(1, Ordering::Relaxed);
                if n < 200 {
                    let slots: Vec<u8> = cap.textures.iter().map(|(s, ..)| *s).collect();
                    let fmts: Vec<String> = cap
                        .textures
                        .iter()
                        .map(|(_, k, ..)| format!("{:?}", k.format))
                        .collect();
                    eprintln!(
                        "BIND-LOG ps={:#x} vs={:#x} ntex={} slots={:?} fmts={:?} translated={} verts={}",
                        cap.ps_key,
                        cap.vs_key,
                        cap.textures.len(),
                        slots,
                        fmts,
                        served_translated,
                        cap.host_vertex_count,
                    );
                }
            }
            if served_translated {
                self.xenos_dispatches_translator =
                    self.xenos_dispatches_translator.saturating_add(1);
            } else {
                self.xenos_pipeline.render_one(
                    &self.device,
                    &self.queue,
                    &mut encoder,
                    &self.frontbuffer_view,
                    req,
                    rstate,
                );
                self.xenos_dispatches_interpreter =
                    self.xenos_dispatches_interpreter.saturating_add(1);
            }
            self.queue.submit(std::iter::once(encoder.finish()));
        }
        self.xenos_draws_rendered = self
            .xenos_draws_rendered
            .saturating_add(captures.len() as u64);
        // Frontbuffer readback for offline color verification (XENIA_DUMP_FRAME).
        // Counts frames that contained a movie (k_8 YUV) draw and dumps the
        // composited frontbuffer at a few fade-in stages so the YUV→RGB output
        // can be inspected as a raw RGBA image without needing a live viewer.
        if std::env::var("XENIA_DUMP_FRAME").is_ok() {
            use std::sync::atomic::{AtomicUsize, Ordering};
            static MOVIE_FRAMES: AtomicUsize = AtomicUsize::new(0);
            let has_movie = captures.iter().any(|c| {
                c.textures.iter().any(|(_, k, _, _)| {
                    matches!(k.format, xenia_gpu::texture_cache::TextureFormat::K8)
                })
            });
            if has_movie {
                let n = MOVIE_FRAMES.fetch_add(1, Ordering::Relaxed);
                if n % 20 == 0 && n <= 2000 {
                    let dir = "/tmp/claude-1000/-home-fabi-RE---Project-Sylpheed/c5711e5b-8a9c-410c-860d-662365e450a4/scratchpad";
                    self.dump_frontbuffer(&format!("{dir}/fb_movie_{n:04}.raw"));
                }
            }
        }
        self.real_geometry_draws = self
            .real_geometry_draws
            .saturating_add(real_count as u64);
        if !self.first_dispatch_logged {
            self.first_dispatch_logged = true;
            tracing::info!(
                captures = captures.len(),
                real_vertex_draws = real_count,
                "first Xenos capture batch replayed (real geometry)"
            );
        }
        real_count
    }

    /// Diagnostic: copy the current frontbuffer back to the CPU and write it
    /// as tight RGBA8 bytes (`width*height*4`) to `path`. Blocks on the GPU.
    /// Used to verify rendered colors offline (e.g. the intro-video YUV→RGB).
    fn dump_frontbuffer(&self, path: &str) {
        let (w, h) = self.frontbuffer_size;
        if w == 0 || h == 0 {
            return;
        }
        let bpp = 4u32;
        let unpadded = w * bpp;
        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
        let padded = unpadded.div_ceil(align) * align;
        let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("frontbuffer readback"),
            size: (padded * h) as u64,
            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
            mapped_at_creation: false,
        });
        let mut encoder = self
            .device
            .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("fb-dump") });
        encoder.copy_texture_to_buffer(
            wgpu::ImageCopyTexture {
                texture: &self.frontbuffer_tex,
                mip_level: 0,
                origin: wgpu::Origin3d::ZERO,
                aspect: wgpu::TextureAspect::All,
            },
            wgpu::ImageCopyBuffer {
                buffer: &buffer,
                layout: wgpu::ImageDataLayout {
                    offset: 0,
                    bytes_per_row: Some(padded),
                    rows_per_image: Some(h),
                },
            },
            wgpu::Extent3d {
                width: w,
                height: h,
                depth_or_array_layers: 1,
            },
        );
        self.queue.submit(std::iter::once(encoder.finish()));
        let slice = buffer.slice(..);
        slice.map_async(wgpu::MapMode::Read, |_| {});
        self.device.poll(wgpu::Maintain::Wait);
        let data = slice.get_mapped_range();
        let mut out = Vec::with_capacity((unpadded * h) as usize);
        for row in 0..h as usize {
            let s = row * padded as usize;
            out.extend_from_slice(&data[s..s + unpadded as usize]);
        }
        drop(data);
        buffer.unmap();
        let _ = std::fs::write(path, &out);
        eprintln!("FRONTBUFFER-DUMP {w}x{h} -> {path} ({} bytes)", out.len());
    }

    /// Count of distinct translator pipelines compiled so far. Surfaced
    /// on the HUD as `xlated=N` to make "is P7 working?" observable.
    pub fn translated_pipeline_count(&self) -> usize {
        self.xenos_pipeline.translated_pipeline_count()
    }

    /// Running count of captured draws that carried a real vertex window
    /// (surfaced on the HUD). Updated by [`dispatch_xenos_captures`].
    pub fn real_geometry_draws(&self) -> u64 {
        self.real_geometry_draws
    }

    /// Clear the frontbuffer to `[r,g,b,a]` in linear space. Matches the
    /// fallback clear the outer swapchain render does so the two stages
    /// agree on "no draws yet = dark navy".
    pub fn clear_frontbuffer(&mut self, color: [f64; 4]) {
        let mut encoder = self
            .device
            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
                label: Some("xenos frontbuffer clear"),
            });
        self.xenos_pipeline
            .clear(&mut encoder, &self.frontbuffer_view, color);
        self.queue.submit(std::iter::once(encoder.finish()));
    }

    /// Upload a new frontbuffer. `rgba` is assumed to be tightly packed
    /// `width*height*4` bytes in RGBA8. Resizes the texture if dimensions
    /// change.
    pub fn upload_frontbuffer(&mut self, width: u32, height: u32, rgba: &[u8]) {
        if width == 0 || height == 0 {
            return;
        }
        if (width, height) != self.frontbuffer_size {
            let (tex, view) = make_frontbuffer(&self.device, width, height);
            self.frontbuffer_tex = tex;
            self.frontbuffer_view = view;
            self.frontbuffer_size = (width, height);
            self.frontbuffer_aspect = width as f32 / height as f32;
            self.blit_bind_group = make_blit_bg(
                &self.device,
                &self.blit_bind_group_layout,
                &self.blit_uniforms,
                &self.frontbuffer_view,
                &self.sampler,
            );
        }
        let expected = (width as usize) * (height as usize) * 4;
        if rgba.len() < expected {
            tracing::warn!(
                "upload_frontbuffer: short buffer ({} < {}); skipping",
                rgba.len(),
                expected
            );
            return;
        }
        self.queue.write_texture(
            wgpu::ImageCopyTexture {
                texture: &self.frontbuffer_tex,
                mip_level: 0,
                origin: wgpu::Origin3d::ZERO,
                aspect: wgpu::TextureAspect::All,
            },
            &rgba[..expected],
            wgpu::ImageDataLayout {
                offset: 0,
                bytes_per_row: Some(width * 4),
                rows_per_image: Some(height),
            },
            wgpu::Extent3d {
                width,
                height,
                depth_or_array_layers: 1,
            },
        );
    }

    /// Upload fresh HUD quads. Replaces the previous HUD for this frame.
    pub fn set_hud_vertices(&mut self, verts: &[HudVertex]) {
        self.hud_vertex_count = verts.len() as u32;
        if verts.is_empty() {
            return;
        }
        let needed = std::mem::size_of_val(verts) as u64;
        if needed > self.hud_vertex_capacity {
            self.hud_vertex_capacity = needed.next_power_of_two();
            self.hud_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
                label: Some("hud vertex buffer (grown)"),
                size: self.hud_vertex_capacity,
                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            });
        }
        self.queue
            .write_buffer(&self.hud_vertex_buffer, 0, bytemuck::cast_slice(verts));
    }

    /// Present one frame.
    #[tracing::instrument(skip_all, name = "ui.render")]
    pub fn render(&mut self) -> Result<(), wgpu::SurfaceError> {
        // Update blit uniforms (letterbox aspect).
        let aspect_surface = self.config.width as f32 / self.config.height as f32;
        let (sx, sy) = if aspect_surface > self.frontbuffer_aspect {
            // Surface is wider than the frontbuffer → bars on sides.
            (self.frontbuffer_aspect / aspect_surface, 1.0)
        } else {
            (1.0, aspect_surface / self.frontbuffer_aspect)
        };
        let blit_u = BlitUniforms {
            aspect_correction: [sx, sy],
            _pad0: [0.0; 2],
            fallback_rgb: self.fallback_rgb,
            _pad1: 0.0,
        };
        self.queue
            .write_buffer(&self.blit_uniforms, 0, bytemuck::bytes_of(&blit_u));

        let frame = self.surface.get_current_texture()?;
        let view = frame
            .texture
            .create_view(&wgpu::TextureViewDescriptor::default());
        let mut encoder = self
            .device
            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
                label: Some("xenia-ui frame"),
            });
        {
            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                label: Some("blit + hud"),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view: &view,
                    resolve_target: None,
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Clear(wgpu::Color {
                            r: self.fallback_rgb[0] as f64,
                            g: self.fallback_rgb[1] as f64,
                            b: self.fallback_rgb[2] as f64,
                            a: 1.0,
                        }),
                        store: wgpu::StoreOp::Store,
                    },
                })],
                depth_stencil_attachment: None,
                timestamp_writes: None,
                occlusion_query_set: None,
            });
            pass.set_pipeline(&self.blit_pipeline);
            pass.set_bind_group(0, &self.blit_bind_group, &[]);
            pass.draw(0..3, 0..1);

            if self.hud_vertex_count > 0 {
                pass.set_pipeline(&self.hud_pipeline);
                pass.set_bind_group(0, &self.hud_bind_group, &[]);
                pass.set_vertex_buffer(0, self.hud_vertex_buffer.slice(..));
                pass.draw(0..self.hud_vertex_count, 0..1);
            }
        }
        let _prof_t0 = std::time::Instant::now();
        self.queue.submit(std::iter::once(encoder.finish()));
        frame.present();
        {
            use xenia_gpu::prof;
            prof::add(&prof::PRESENT_NS, _prof_t0.elapsed().as_nanos() as u64);
            let n = prof::PRESENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
            // Periodic snapshot so a SIGTERM'd (timed-out) movie run still
            // yields a profile — the clean-exit report may never be reached.
            if prof::enabled() && n % 500 == 0 {
                prof::report(0);
            }
        }
        Ok(())
    }
}

pub(crate) const XENOS_FRONTBUFFER_FORMAT: wgpu::TextureFormat =
    wgpu::TextureFormat::Rgba8UnormSrgb;

/// P7 — translate a (vs, ps) parsed shader pair to WGSL and insert it into
/// the pipeline cache keyed on `(vs_key, ps_key)`. Returns `true` on
/// success or if the pair was already cached. On translator rejection
/// bumps `gpu.shader.translate_reject{reason,stage}` and returns `false`,
/// signalling the caller to fall back to the interpreter.
fn ensure_translated_pipeline(
    xenos_pipeline: &mut XenosPipeline,
    device: &wgpu::Device,
    vs_key: u32,
    ps_key: u32,
    parsed_vs: &xenia_gpu::ucode::ParsedShader,
    parsed_ps: &xenia_gpu::ucode::ParsedShader,
) -> bool {
    use xenia_gpu::translator::{combine_stages, translate, Stage, Translation};
    if xenos_pipeline.has_translated(vs_key, ps_key) {
        return true;
    }
    let vs_body = match translate(parsed_vs, Stage::Vertex) {
        Translation::Ok(body) => body,
        Translation::Reject(reason) => {
            metrics::counter!(
                "gpu.shader.translate_reject",
                "stage" => "vs",
                "reason" => reason,
            )
            .increment(1);
            if std::env::var("XENIA_BIND_LOG").is_ok() {
                eprintln!("TRANSLATE-REJECT vs={vs_key:#x} ps={ps_key:#x} stage=vs reason={reason}");
            }
            return false;
        }
    };
    let ps_body = match translate(parsed_ps, Stage::Pixel) {
        Translation::Ok(body) => body,
        Translation::Reject(reason) => {
            metrics::counter!(
                "gpu.shader.translate_reject",
                "stage" => "ps",
                "reason" => reason,
            )
            .increment(1);
            if std::env::var("XENIA_BIND_LOG").is_ok() {
                eprintln!("TRANSLATE-REJECT vs={vs_key:#x} ps={ps_key:#x} stage=ps reason={reason}");
            }
            return false;
        }
    };
    let wgsl = combine_stages(&vs_body, &ps_body);
    if let Ok(dir) = std::env::var("XENIA_DUMP_WGSL") {
        let path = format!("{dir}/wgsl_vs{vs_key:#x}_ps{ps_key:#x}.wgsl");
        let _ = std::fs::write(&path, &wgsl);
        eprintln!("DUMP-WGSL wrote {path}");
    }
    xenos_pipeline.insert_translated(device, vs_key, ps_key, &wgsl)
}

fn make_frontbuffer(device: &wgpu::Device, w: u32, h: u32) -> (wgpu::Texture, wgpu::TextureView) {
    let tex = device.create_texture(&wgpu::TextureDescriptor {
        label: Some("frontbuffer"),
        size: wgpu::Extent3d {
            width: w,
            height: h,
            depth_or_array_layers: 1,
        },
        mip_level_count: 1,
        sample_count: 1,
        dimension: wgpu::TextureDimension::D2,
        format: XENOS_FRONTBUFFER_FORMAT,
        // `RENDER_ATTACHMENT` added so the Xenos host pipeline (P3, see
        // `xenos_pipeline.rs`) can render procedural draws directly into
        // this texture instead of only consuming CPU-side raw scrapes.
        usage: wgpu::TextureUsages::TEXTURE_BINDING
            | wgpu::TextureUsages::COPY_DST
            | wgpu::TextureUsages::COPY_SRC
            | wgpu::TextureUsages::RENDER_ATTACHMENT,
        view_formats: &[],
    });
    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
    (tex, view)
}

fn make_blit_bg(
    device: &wgpu::Device,
    layout: &wgpu::BindGroupLayout,
    uniforms: &wgpu::Buffer,
    fb_view: &wgpu::TextureView,
    sampler: &wgpu::Sampler,
) -> wgpu::BindGroup {
    device.create_bind_group(&wgpu::BindGroupDescriptor {
        label: Some("blit bind group"),
        layout,
        entries: &[
            wgpu::BindGroupEntry {
                binding: 0,
                resource: uniforms.as_entire_binding(),
            },
            wgpu::BindGroupEntry {
                binding: 1,
                resource: wgpu::BindingResource::TextureView(fb_view),
            },
            wgpu::BindGroupEntry {
                binding: 2,
                resource: wgpu::BindingResource::Sampler(sampler),
            },
        ],
    })
}

/// Layout helpers for the HUD text compositor.
pub mod hud {
    use super::HudVertex;
    use crate::font;

    /// Emit six vertices (two triangles) for a single character at
    /// `(px, py)` in pixel space, color `rgba`, scale factor `scale`.
    pub fn push_char(out: &mut Vec<HudVertex>, ch: char, px: f32, py: f32, scale: f32, rgba: [f32; 4]) {
        let b = ch as u32;
        if b < font::FIRST as u32 || b > font::LAST as u32 {
            return;
        }
        let idx = b - font::FIRST as u32;
        let w = font::CELL_W as f32 * scale;
        let h = font::CELL_H as f32 * scale;
        // UV: each glyph occupies y = idx * CELL_H .. (idx+1) * CELL_H in the
        // atlas; atlas width = CELL_W.
        let atlas_h = (font::CELL_H * font::GLYPH_COUNT) as f32;
        let v0 = (idx * font::CELL_H) as f32 / atlas_h;
        let v1 = ((idx + 1) * font::CELL_H) as f32 / atlas_h;

        let tl = HudVertex { pos_px: [px, py],         uv: [0.0, v0], rgba };
        let tr = HudVertex { pos_px: [px + w, py],     uv: [1.0, v0], rgba };
        let bl = HudVertex { pos_px: [px, py + h],     uv: [0.0, v1], rgba };
        let br = HudVertex { pos_px: [px + w, py + h], uv: [1.0, v1], rgba };
        out.extend_from_slice(&[tl, tr, bl, bl, tr, br]);
    }

    pub fn push_string(out: &mut Vec<HudVertex>, s: &str, px: f32, py: f32, scale: f32, rgba: [f32; 4]) {
        let mut cx = px;
        let step = font::CELL_W as f32 * scale;
        for ch in s.chars() {
            push_char(out, ch, cx, py, scale, rgba);
            cx += step;
        }
    }
}

pub use hud::push_string;
