neurodoom 0.6.7

Deterministic no_std Doom engine with semantic and depth perception buffers for AI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
// The renderer is the hottest code in the engine — per-pixel loops fill
// `screen`, `semantic`, and `depth` buffers of fixed SCREENWIDTH * SCREENHEIGHT
// size. Direct `[idx]` access is intentional: indices are clamped by the
// surrounding BSP/column/span clip logic (yl/yh, x1/x2, view_height) before
// use, and converting each site to `.get()` would add a dead branch to the
// hot path that the compiler cannot always elide. Safer APIs are used at
// the boundaries (public methods take typed arguments, slice splits use
// `chunks_exact*`).
#![allow(clippy::indexing_slicing)]

//! Software BSP + column-based rasterizer that matches classic Doom's
//! visual output, with two extra buffers for AI perception:
//! [`Renderer::semantic`] (one [`SemanticClass`] per pixel) and
//! [`Renderer::depth`] (fixed-point distance per pixel).
//!
//! Entry points:
//! - [`Renderer::render_player_view`] — BSP walk, walls, planes.
//! - [`Renderer::draw_things`] — projected sprites.
//! - [`Renderer::draw_masked_walls`] — transparent midtextures.
//! - [`Renderer::draw_weapon_psp`] — first-person weapon overlay.
//! - [`Renderer::draw_hud`] (see [`hud`]) — HUD overlay.
//! - [`Renderer::indexed_to_rgba`] — final palette → RGBA conversion.
//!
//! Usually invoked indirectly via [`crate::DoomEngine::render_for`].

use alloc::vec;
use alloc::vec::Vec;

use crate::map::MapData;
use crate::math::*;
use crate::texture::TextureData;

// Per-concern rasterizer modules. Kept `#[doc(hidden)]` to signal they
// are implementation detail, not a stable contract — access via the
// `Renderer` methods defined in this module. Still accessible for
// integration tests that drive the renderer directly.
#[doc(hidden)]
pub mod bsp;
pub mod hud;
#[doc(hidden)]
pub mod planes;
#[doc(hidden)]
pub mod sprites;
#[doc(hidden)]
pub mod tables;
#[doc(hidden)]
pub mod walls;

pub use hud::HudFrame;

/// Parameters for rendering from a viewpoint. Alias for `world::Pose`
/// — a view is nothing more than a 3D position + facing, same shape as
/// an entity's pose.
pub type ViewParams = crate::world::Pose;

/// Semantic class of a rendered pixel.
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SemanticClass {
    Void = 0,
    Floor = 1,
    Ceiling = 2,
    Wall = 3,
    Door = 4,
    Projectile = 5,
    Enemy = 6,
    Player = 7,
    Item = 8,
}

impl SemanticClass {
    /// Reconstruct the class from its `u8` discriminant (as found in the
    /// `semantic_buffer`). Unknown values become `Void`.
    #[inline]
    pub fn from_u8(v: u8) -> Self {
        match v {
            1 => Self::Floor,
            2 => Self::Ceiling,
            3 => Self::Wall,
            4 => Self::Door,
            5 => Self::Projectile,
            6 => Self::Enemy,
            7 => Self::Player,
            8 => Self::Item,
            _ => Self::Void,
        }
    }

    /// A stable visualization color for this class (packed `0x00RRGGBB`).
    /// Used by the demos' "semantic view" toggle and by any perception
    /// debug tool that wants a conventional palette.
    #[inline]
    pub fn to_rgb(self) -> u32 {
        match self {
            Self::Void => 0x00_00_00_00,
            Self::Floor => 0x00_3C_3C_3C,
            Self::Ceiling => 0x00_28_28_50,
            Self::Wall => 0x00_78_64_50,
            Self::Door => 0x00_C8_C8_32,
            Self::Projectile => 0x00_FF_64_00,
            Self::Enemy => 0x00_FF_00_00,
            Self::Player => 0x00_00_FF_00,
            Self::Item => 0x00_00_C8_FF,
        }
    }
}

/// A drawseg — stores info about a rendered wall segment for sprite clipping.
#[derive(Clone)]
pub struct DrawSeg {
    pub x1: i32,
    pub x2: i32,
    pub scale1: Fixed,
    pub scale2: Fixed,
    pub scalestep: Fixed,
    pub silhouette: i32,
    pub bsilheight: Fixed,
    pub tsilheight: Fixed,
    /// Per-column top clip, indexed by absolute screen X.
    pub sprite_top_clip: [i16; SCREENWIDTH],
    /// Per-column bottom clip, indexed by absolute screen X.
    pub sprite_bottom_clip: [i16; SCREENWIDTH],
}

/// Deferred masked midtexture segment for second-pass rendering.
#[derive(Clone)]
pub struct MaskedSeg {
    pub seg_idx: usize,
    pub x1: i32,
    pub x2: i32,
    pub scale1: Fixed,
    pub scalestep: Fixed,
    pub rw_offset: Fixed,
    pub rw_distance: Fixed,
    pub rw_center_angle: Angle,
    pub light_num: usize,
    pub top_clip: [i16; SCREENWIDTH],
    pub bot_clip: [i16; SCREENWIDTH],
}

/// A vissprite — a projected sprite ready for rendering.
#[derive(Clone)]
pub struct VisSprite {
    pub x1: i32,
    pub x2: i32,
    pub scale: Fixed,
    pub x_iscale: Fixed,
    pub start_frac: Fixed,
    pub texture_mid: Fixed,
    pub patch_lump: usize,
    pub colormap_idx: usize,
    pub mobj_flags: u32,
    pub semantic: SemanticClass,
    pub gz: Fixed,
    pub gzt: Fixed,
    /// View-space Z depth (same units as rw_distance for wall comparison).
    pub tz: Fixed,
}

/// A visplane — a horizontal region to fill with a floor/ceiling flat.
#[derive(Clone)]
pub struct VisPlane {
    pub height: Fixed,
    pub pic_num: i16,
    pub light_level: i16,
    pub min_x: i32,
    pub max_x: i32,
    pub top: [u8; SCREENWIDTH],
    pub bottom: [u8; SCREENWIDTH],
}

impl VisPlane {
    pub fn new(height: Fixed, pic_num: i16, light_level: i16) -> Self {
        Self {
            height,
            pic_num,
            light_level,
            min_x: SCREENWIDTH as i32,
            max_x: -1,
            top: [0xFF; SCREENWIDTH],
            bottom: [0; SCREENWIDTH],
        }
    }
}

/// Solid seg clip range — tracks which screen columns have been filled by solid walls.
#[derive(Clone, Copy)]
pub struct ClipRange {
    pub first: i32,
    pub last: i32,
}

/// Maximum number of solid seg ranges.
const MAXSEGS: usize = 32;

/// Light levels and scale-based light selection.
pub const LIGHTLEVELS: usize = 16;
pub const LIGHTSEGSHIFT: i32 = 4;
pub const MAXLIGHTSCALE: usize = 48;
pub const LIGHTSCALESHIFT: i32 = 12;
pub const NUMCOLORMAPS: usize = 32;

/// Height units for wall Y calculation.
pub const HEIGHTBITS: i32 = 12;
pub const HEIGHTUNIT: i32 = 1 << HEIGHTBITS;

/// Line flags.
pub const ML_DONTPEGBOTTOM: i16 = 16;
pub const ML_DONTPEGTOP: i16 = 8;

/// Silhouette flags.
pub const SIL_NONE: i32 = 0;
pub const SIL_BOTTOM: i32 = 1;
pub const SIL_TOP: i32 = 2;
pub const SIL_BOTH: i32 = 3;

/// The main renderer state. All mutable render state lives here.
pub struct Renderer {
    // Output buffers
    pub screen: Vec<u8>,
    pub rgba: Vec<u8>,
    pub semantic: Vec<u8>,
    pub depth: Vec<Fixed>,

    // Clip arrays: track which rows are filled per column
    pub floor_clip: [i16; SCREENWIDTH],
    pub ceiling_clip: [i16; SCREENWIDTH],

    // Solid seg tracking
    pub solidsegs: [ClipRange; MAXSEGS],
    pub solidsegs_end: usize,

    // View parameters
    pub view_x: Fixed,
    pub view_y: Fixed,
    pub view_z: Fixed,
    pub view_angle: Angle,
    pub view_cos: Fixed,
    pub view_sin: Fixed,
    pub extra_light: i32,

    // Screen geometry
    pub view_width: i32,
    pub view_height: i32,
    pub center_x: Fixed,
    pub center_y: Fixed,
    pub center_x_frac: Fixed,
    pub center_y_frac: Fixed,
    pub projection: Fixed,

    // Angle-to-screen mapping
    pub viewangletox: Vec<i32>,
    pub xtoviewangle: Vec<Angle>,
    pub clip_angle: Angle,

    // Collected render data
    pub drawsegs: Vec<DrawSeg>,
    pub visplanes: Vec<VisPlane>,
    pub masked_segs: Vec<MaskedSeg>,

    // Validity counter (increments per frame)
    pub valid_count: i32,

    // Scale light tables: [LIGHTLEVELS][MAXLIGHTSCALE] → colormap index
    pub scalelight: [[usize; MAXLIGHTSCALE]; LIGHTLEVELS],

    // Plane rendering tables
    pub yslope: [Fixed; SCREENHEIGHT],
    pub distscale: [Fixed; SCREENWIDTH],
    pub basexscale: Fixed,
    pub baseyscale: Fixed,
    pub zlight: [[usize; planes::MAXLIGHTZ]; LIGHTLEVELS],

    // Sky flat number (picnum that triggers sky rendering)
    pub sky_flat_num: i16,
    // Sky wall texture index (for column rendering)
    pub sky_texture_num: i16,

    // Vissprites (collected during sprite projection)
    pub vissprites: Vec<VisSprite>,

    // Current floor/ceiling plane indices during BSP traversal
    pub cur_floor_plane: Option<usize>,
    pub cur_ceiling_plane: Option<usize>,
}

impl Default for Renderer {
    fn default() -> Self {
        Self::new()
    }
}

impl Renderer {
    pub fn new() -> Self {
        let npix = SCREENWIDTH * SCREENHEIGHT;
        let mut r = Self {
            screen: vec![0u8; npix],
            rgba: vec![0u8; npix * 4],
            semantic: vec![0u8; npix],
            depth: vec![0i32; npix],
            floor_clip: [0i16; SCREENWIDTH],
            ceiling_clip: [0i16; SCREENWIDTH],
            solidsegs: [ClipRange { first: 0, last: 0 }; MAXSEGS],
            solidsegs_end: 0,
            view_x: 0,
            view_y: 0,
            view_z: 0,
            view_angle: 0,
            view_cos: 0,
            view_sin: 0,
            extra_light: 0,
            // Match Doom's default setblocks=10: viewwidth=320, viewheight=168
            // (setblocks=11 for fullscreen would be viewheight=200)
            view_width: SCREENWIDTH as i32,
            view_height: SCREENHEIGHT as i32,
            center_x: (SCREENWIDTH / 2) as Fixed,
            center_y: (SCREENHEIGHT / 2) as Fixed,
            center_x_frac: ((SCREENWIDTH / 2) as Fixed) << FRACBITS,
            center_y_frac: ((SCREENHEIGHT / 2) as Fixed) << FRACBITS,
            projection: ((SCREENWIDTH / 2) as Fixed) << FRACBITS,
            viewangletox: vec![0i32; FINEANGLES / 2],
            xtoviewangle: vec![0u32; SCREENWIDTH + 1],
            clip_angle: 0,
            drawsegs: Vec::with_capacity(128),
            masked_segs: Vec::with_capacity(32),
            visplanes: Vec::with_capacity(64),
            valid_count: 0,
            scalelight: [[0usize; MAXLIGHTSCALE]; LIGHTLEVELS],
            yslope: [0; SCREENHEIGHT],
            distscale: [0; SCREENWIDTH],
            basexscale: 0,
            baseyscale: 0,
            zlight: [[0usize; planes::MAXLIGHTZ]; LIGHTLEVELS],
            vissprites: Vec::with_capacity(128),
            sky_flat_num: -1,
            sky_texture_num: -1,
            cur_floor_plane: None,
            cur_ceiling_plane: None,
        };
        r.init_texture_mapping();
        r.init_light_tables();
        r.init_planes();
        r.init_zlight();
        r
    }

    /// Initialize light scale tables.
    fn init_light_tables(&mut self) {
        const DISTMAP: usize = 2;
        for i in 0..LIGHTLEVELS {
            let startmap =
                ((LIGHTLEVELS - 1 - i) * 2 * NUMCOLORMAPS / LIGHTLEVELS) as i32;
            for j in 0..MAXLIGHTSCALE {
                // j * SCREENWIDTH / viewwidth / DISTMAP  (viewwidth=SCREENWIDTH, detailshift=0)
                let level = (startmap - (j as i32) / DISTMAP as i32)
                    .clamp(0, NUMCOLORMAPS as i32 - 1);
                self.scalelight[i][j] = level as usize;
            }
        }
    }

    /// Set up the renderer for a frame.
    pub fn setup_frame(&mut self, x: Fixed, y: Fixed, z: Fixed, angle: Angle) {
        self.view_x = x;
        self.view_y = y;
        self.view_z = z;
        self.view_angle = angle;
        self.view_sin = crate::tables::FINESINE[(angle >> ANGLETOFINESHIFT) as usize];
        self.view_cos = finecosine((angle >> ANGLETOFINESHIFT) as usize);
        self.valid_count += 1;
    }

    /// Clear all per-frame state.
    pub fn clear_frame(&mut self) {
        self.masked_segs.clear();
        // Clear output buffers
        self.screen.fill(0);
        self.semantic.fill(SemanticClass::Void as u8);
        self.depth.fill(0);

        // Reset clip arrays — use view_height, not SCREENHEIGHT
        self.floor_clip.fill(self.view_height as i16);
        self.ceiling_clip.fill(-1);

        // Reset solid segs: left sentinel + right sentinel
        self.solidsegs[0] = ClipRange { first: -0x7FFF_FFFF, last: -1 };
        self.solidsegs[1] = ClipRange {
            first: self.view_width,
            last: 0x7FFF_FFFF,
        };
        self.solidsegs_end = 2;

        // Clear collected data
        self.drawsegs.clear();
        self.visplanes.clear();
        self.cur_floor_plane = None;
        self.cur_ceiling_plane = None;
    }

    /// Render the player's view. Main entry point.
    pub fn render_player_view(
        &mut self,
        map: &MapData,
        textures: &TextureData,
        view: &ViewParams,
    ) {
        self.setup_frame(view.x, view.y, view.z, view.angle);
        self.clear_frame();
        self.clear_planes();

        // Traverse BSP and render walls (also collects visplanes)
        let root = map.root_node();
        self.render_bsp_node(map, textures, root);

        // Render collected floor/ceiling planes
        self.draw_planes(textures);

        // Note: masked midtextures are drawn later via draw_masked_segs(),
        // after sprites, so they correctly occlude sprites behind them.
    }

    /// Draw deferred masked midtextures. Call after draw_things().
    pub fn draw_masked_walls(&mut self, map: &MapData, textures: &TextureData) {
        self.draw_masked_segs(map, textures);
    }

    /// Render deferred masked midtexture segs (transparent walls like diagonal stripes).
    fn draw_masked_segs(&mut self, map: &MapData, textures: &TextureData) {
        let mut segs: Vec<MaskedSeg> = core::mem::take(&mut self.masked_segs);

        // Sort by distance: farthest first so closer walls draw on top
        segs.sort_by(|a, b| b.rw_distance.cmp(&a.rw_distance));

        for ms in &segs {
            let seg = &map.segs[ms.seg_idx];
            let line = &map.lines[seg.line as usize];
            let side = &map.sides[seg.side as usize];
            let front_sector = &map.sectors[seg.front_sector as usize];
            let back_sector = match seg.back_sector {
                Some(bs) => &map.sectors[bs as usize],
                None => continue,
            };

            let masked_tex = if side.mid_texture > 0 {
                side.mid_texture
            } else {
                let other = if line.sidenum[0] == Some(seg.side) { line.sidenum[1] } else { line.sidenum[0] };
                other.map(|s| map.sides[s as usize].mid_texture).unwrap_or(0)
            };
            if masked_tex <= 0 || masked_tex as usize >= textures.textures.len() { continue; }
            let tex = &textures.textures[masked_tex as usize];
            let tex_h = (tex.height as Fixed) << FRACBITS;

            let texmid = if line.flags & ML_DONTPEGBOTTOM != 0 {
                let base = front_sector.floor_height.max(back_sector.floor_height);
                base + tex_h - self.view_z
            } else {
                let base = front_sector.ceiling_height.min(back_sector.ceiling_height);
                base - self.view_z
            } + side.rowoffset;

            let mut cur_scale = ms.scale1;

            for x in ms.x1..=ms.x2 {
                let angle_idx = ms.rw_center_angle
                    .wrapping_add(self.xtoviewangle[x as usize])
                    >> ANGLETOFINESHIFT;
                let tex_col = (ms.rw_offset
                    - fixed_mul(
                        crate::tables::FINETANGENT[(angle_idx as usize) % (FINEANGLES / 2)],
                        ms.rw_distance,
                    )) >> FRACBITS;

                let light_idx = ((cur_scale >> LIGHTSCALESHIFT) as usize).min(MAXLIGHTSCALE - 1);
                let colormap_idx = self.scalelight[ms.light_num][light_idx];

                let iscale = if cur_scale > 0 {
                    (0xFFFFFFFFu32 / cur_scale as u32) as Fixed
                } else {
                    i32::MAX
                };

                // Per-column depth comparable to sprite tz (projection / scale)
                let col_depth = if cur_scale > 0 {
                    fixed_div(self.projection, cur_scale)
                } else {
                    i32::MAX
                };

                let mt_top = ms.top_clip[x as usize] as i32 + 1;
                let mt_bot = ms.bot_clip[x as usize] as i32 - 1;
                if mt_top <= mt_bot {
                    self.draw_masked_column(
                        x, mt_top, mt_bot,
                        tex, tex_col, colormap_idx, iscale, texmid,
                        SemanticClass::Wall, col_depth, &textures.colormaps,
                    );
                }

                cur_scale += ms.scalestep;
            }
        }

        self.masked_segs = segs;
    }

    /// Convert the indexed-color framebuffer to RGBA using palette 0.
    pub fn indexed_to_rgba(&mut self, textures: &TextureData) {
        let Some(pal) = textures.palettes.first() else {
            return;
        };
        // The palette is a flat RGB byte array (256 * 3 = 768 bytes). Split
        // into 3-byte RGB chunks once so the per-pixel lookup is one bounds
        // check against a `&[[u8; 3]]` of length 256 instead of three
        // against a flat `&[u8]`.
        // Trailing non-triple bytes in the palette (rare) are ignored.
        let (rgb_triples, _tail) = pal.as_chunks::<3>();

        for (&pixel, rgba) in self.screen.iter().zip(self.rgba.chunks_exact_mut(4)) {
            let rgb = match rgb_triples.get(pixel as usize) {
                Some(t) => *t,
                None => [0, 0, 0],
            };
            rgba[0] = rgb[0];
            rgba[1] = rgb[1];
            rgba[2] = rgb[2];
            rgba[3] = 255;
        }
    }

    /// Draw a single textured column with semantic + depth.
    #[allow(clippy::too_many_arguments)]
    pub fn draw_column(
        &mut self,
        x: i32,
        yl: i32,
        yh: i32,
        texture: &crate::texture::WallTexture,
        tex_col: i32,
        colormap_idx: usize,
        iscale: Fixed,
        texturemid: Fixed,
        class: SemanticClass,
        distance: Fixed,
        colormaps: &[[u8; 256]],
    ) {
        if yl > yh || x < 0 || x >= SCREENWIDTH as i32 {
            return;
        }

        let yl = yl.max(0);
        let yh = yh.min(self.view_height - 1);

        // Get texture column data (wrap around width)
        let col_idx = (tex_col as usize) % (texture.width as usize);
        let col_data = texture.column(col_idx);
        let tex_height = texture.height as i32;
        if tex_height == 0 {
            return;
        }

        let cmap = if colormap_idx < colormaps.len() {
            &colormaps[colormap_idx]
        } else if !colormaps.is_empty() {
            &colormaps[0]
        } else {
            return;
        };

        // Texture stepping: matches Doom's dc_texturemid + (dc_yl - centery) * fracstep
        // Must use i32 wrapping arithmetic to match Doom's overflow behavior
        let frac_start = texturemid
            .wrapping_add((yl - self.center_y).wrapping_mul(iscale));
        let mut frac = frac_start as i64;

        for y in yl..=yh {
            let idx = (y * SCREENWIDTH as i32 + x) as usize;

            // Get texture pixel
            let mut tex_y = ((frac >> FRACBITS) as i32) % tex_height;
            if tex_y < 0 {
                tex_y += tex_height;
            }
            let tex_y = tex_y as usize;
            let pixel = if tex_y < col_data.len() {
                col_data[tex_y]
            } else {
                0
            };

            // Apply colormap and write
            self.screen[idx] = cmap[pixel as usize];
            self.semantic[idx] = class as u8;
            self.depth[idx] = distance;

            frac += iscale as i64;
        }
    }

    /// Draw a non-repeating (masked) texture column for midtextures.
    /// Only draws pixels within the texture's actual height range.
    /// Areas outside the texture are transparent (not drawn).
    #[allow(clippy::too_many_arguments)] // mirrors R_DrawMaskedColumn signature
    pub fn draw_masked_column(
        &mut self,
        x: i32,
        yl: i32,
        yh: i32,
        texture: &crate::texture::WallTexture,
        tex_col: i32,
        colormap_idx: usize,
        iscale: Fixed,
        texturemid: Fixed,
        class: SemanticClass,
        distance: Fixed,
        colormaps: &[[u8; 256]],
    ) {
        if yl > yh || x < 0 || x >= SCREENWIDTH as i32 { return; }
        let yl = yl.max(0);
        let yh = yh.min(self.view_height - 1);

        let col_idx = (tex_col as usize) % (texture.width as usize);
        let col_data = texture.column(col_idx);
        let tex_height = texture.height as i32;
        if tex_height == 0 { return; }

        let cmap = if colormap_idx < colormaps.len() {
            &colormaps[colormap_idx]
        } else if !colormaps.is_empty() {
            &colormaps[0]
        } else {
            return;
        };

        let frac_start = texturemid
            .wrapping_add((yl - self.center_y).wrapping_mul(iscale));
        let mut frac = frac_start as i64;

        for y in yl..=yh {
            let tex_y = (frac >> FRACBITS) as i32;
            // Non-repeating: only draw within 0..tex_height
            // Skip pixel 0 = transparent (unpatched area in composited texture)
            if tex_y >= 0 && tex_y < tex_height {
                let pixel = col_data[tex_y as usize];
                if pixel != 0 {
                    let idx = (y * SCREENWIDTH as i32 + x) as usize;
                    // Depth test only against sprites — don't occlude sprites
                    // in front of the masked wall. Non-sprite pixels (walls,
                    // floors, ceilings) are already handled by clip arrays.
                    let sem = self.semantic[idx];
                    let is_sprite = sem == SemanticClass::Enemy as u8
                        || sem == SemanticClass::Player as u8
                        || sem == SemanticClass::Item as u8
                        || sem == SemanticClass::Projectile as u8;
                    if is_sprite && distance > self.depth[idx] {
                        // Sprite is closer — don't overwrite
                    } else {
                        self.screen[idx] = cmap[pixel as usize];
                        self.semantic[idx] = class as u8;
                        self.depth[idx] = distance;
                    }
                }
            }
            frac += iscale as i64;
        }
    }
}