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
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
// Map loader. Runs once at startup. Every `sectors[i]` / `lines[i]` /
// `sides[i]` / `vertexes[i]` index is emitted after the corresponding
// `load_*` validator has bounds-checked the raw WAD index and returned
// `InvalidIndex` on overflow. See policy in src/render/mod.rs.
#![allow(clippy::indexing_slicing)]

//! Map geometry loaded from a WAD: vertexes, sectors, linedefs, sidedefs,
//! segs, subsectors, BSP nodes, blockmap, REJECT, and the raw THINGS list.
//!
//! Also hosts the WAD-format numeric constants ([`line_special`] and
//! [`doomednum`]) that the loader parses.

use alloc::vec::Vec;
use core::fmt;

use crate::math::{fixed_div, Angle, Fixed, FRACBITS};
use crate::wad::Wad;

// --- Index types (arena IDs) ---

// --- WAD-format numeric identifiers ---
//
// These constants describe raw values that appear in WAD lumps: linedef
// `special` fields, thing `type_num` fields. They live here (next to the
// MapThing / Line loaders that consume them) rather than in `types.rs`
// because they are format identifiers, not runtime game types.

/// Well-known line special types (from the `linedefs` lump `special` field).
pub mod line_special {
    pub const MANUAL_DOOR: i16 = 1;
    pub const WALK_OPEN_DOOR: i16 = 2;
    pub const WALK_CLOSE_DOOR: i16 = 3;
    pub const WALK_RAISE_DOOR: i16 = 4;
    pub const WALK_LIFT: i16 = 10;
    pub const EXIT_NORMAL: i16 = 11;
    pub const WALK_CLOSE30_OPEN: i16 = 16;
    pub const W1_FLOOR_LOWER_LOWEST: i16 = 36;
    pub const W1_FLOOR_LOWER_LOWEST_CHANGE: i16 = 37;
    pub const W1_FLOOR_LOWER_LOWEST_NX: i16 = 38;
    pub const WR_PLAT_DOWN_WAIT_UP: i16 = 88;
    pub const BLUE_LOCKED_DOOR: i16 = 26;
    pub const YELLOW_LOCKED_DOOR: i16 = 27;
    pub const RED_LOCKED_DOOR: i16 = 28;
    pub const MANUAL_OPEN_STAY: i16 = 31;
    pub const BLUE_OPEN_STAY: i16 = 32;
    pub const RED_OPEN_STAY: i16 = 33;
    pub const YELLOW_OPEN_STAY: i16 = 34;
    pub const WALK_EXIT: i16 = 52;
    pub const EXIT_SECRET: i16 = 51;
    pub const SWITCH_LIFT: i16 = 62;
    pub const BLAZE_RAISE: i16 = 117;
    pub const BLAZE_OPEN: i16 = 118;
    pub const EXIT_TELEPORT: i16 = 124;
}

/// Well-known Doom editor numbers (from the `things` lump `type_num` field).
pub mod doomednum {
    pub const PLAYER1_START: i16 = 1;
    pub const PLAYER2_START: i16 = 2;
    pub const PLAYER3_START: i16 = 3;
    pub const PLAYER4_START: i16 = 4;
    pub const DEATHMATCH_START: i16 = 11;
    // Items
    pub const HEALTH_BONUS: i32 = 2014;
    pub const ARMOR_BONUS: i32 = 2015;
    pub const STIMPACK: i32 = 2011;
    pub const MEDIKIT: i32 = 2012;
    pub const SOUL_SPHERE: i32 = 2013;
    pub const GREEN_ARMOR: i32 = 2018;
    pub const BLUE_ARMOR: i32 = 2019;
    pub const CLIP: i32 = 2007;
    pub const BOX_OF_AMMO: i32 = 2048;
    pub const SHELLS: i32 = 2008;
    pub const SHELL_BOX: i32 = 2049;
    pub const ROCKET: i32 = 2010;
    pub const ROCKET_BOX: i32 = 2046;
    pub const CELL: i32 = 17;
    pub const SHOTGUN: i32 = 2001;
    pub const CHAINGUN: i32 = 2002;
    pub const ROCKET_LAUNCHER: i32 = 2003;
    pub const PLASMA_RIFLE: i32 = 2004;
    pub const BFG: i32 = 2006;
    pub const BACKPACK: i32 = 8;
    // Keys
    pub const BLUE_CARD: i32 = 5;
    pub const YELLOW_CARD: i32 = 6;
    pub const RED_CARD: i32 = 13;
    pub const BLUE_SKULL: i32 = 40;
    pub const YELLOW_SKULL: i32 = 39;
    pub const RED_SKULL: i32 = 38;
}

pub type VertexId = u16;
pub type SectorId = u16;
pub type SideId = u16;
pub type LineId = u16;
pub type SegId = u16;
pub type SubSectorId = u16;
pub type NodeId = u16;

// --- Constants ---

/// High bit flags a child as a subsector, not a node.
pub const NF_SUBSECTOR: u16 = 0x8000;

/// Blockmap shift: 128 map units per block.
pub const MAPBLOCKSHIFT: i32 = FRACBITS + 7; // 23
pub const MAPBLOCKSIZE: Fixed = 1 << MAPBLOCKSHIFT; // 128 << 16 in fixed, but blockmap uses integer coords
pub const MAPBTOFRAC: i32 = MAPBLOCKSHIFT - FRACBITS; // 7

// --- Runtime structures ---

#[derive(Debug, Clone, Copy)]
pub struct Vertex {
    pub x: Fixed,
    pub y: Fixed,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SlopeType {
    Horizontal,
    Vertical,
    Positive,
    Negative,
}

#[derive(Debug, Clone)]
pub struct Sector {
    pub floor_height: Fixed,
    pub ceiling_height: Fixed,
    pub floor_pic: i16,
    pub ceiling_pic: i16,
    pub light_level: i16,
    pub special: i16,
    pub tag: i16,
    // Mutable game state
    pub sound_traversed: i32,
    pub valid_count: i32,
    pub line_count: usize,
    pub first_line: usize,
    // Raw names for deferred resolution
    pub floor_pic_name: [u8; 8],
    pub ceiling_pic_name: [u8; 8],
}

#[derive(Debug, Clone)]
pub struct Side {
    pub textureoffset: Fixed,
    pub rowoffset: Fixed,
    pub top_texture: i16,
    pub bottom_texture: i16,
    pub mid_texture: i16,
    pub sector: SectorId,
    // Raw names for deferred resolution
    pub top_texture_name: [u8; 8],
    pub bottom_texture_name: [u8; 8],
    pub mid_texture_name: [u8; 8],
}

#[derive(Debug, Clone)]
pub struct Line {
    pub v1: VertexId,
    pub v2: VertexId,
    pub dx: Fixed,
    pub dy: Fixed,
    pub flags: i16,
    pub special: i16,
    pub tag: i16,
    pub sidenum: [Option<SideId>; 2],
    pub bbox: BBox,
    pub slope_type: SlopeType,
    pub front_sector: SectorId,
    pub back_sector: Option<SectorId>,
    pub valid_count: i32,
}

/// Axis-aligned bounding box [top, bottom, left, right].
pub type BBox = [Fixed; 4];
pub const BOXTOP: usize = 0;
pub const BOXBOTTOM: usize = 1;
pub const BOXLEFT: usize = 2;
pub const BOXRIGHT: usize = 3;

#[derive(Debug, Clone)]
pub struct Seg {
    pub v1: VertexId,
    pub v2: VertexId,
    pub offset: Fixed,
    pub angle: Angle,
    pub side: SideId,
    pub line: LineId,
    pub front_sector: SectorId,
    pub back_sector: Option<SectorId>,
}

#[derive(Debug, Clone)]
pub struct SubSector {
    pub sector: SectorId,
    pub num_lines: u16,
    pub first_line: SegId,
}

#[derive(Debug, Clone)]
pub struct Node {
    pub x: Fixed,
    pub y: Fixed,
    pub dx: Fixed,
    pub dy: Fixed,
    pub bbox: [BBox; 2],
    pub children: [u16; 2],
}

#[derive(Debug, Clone, Copy)]
pub struct MapThing {
    pub x: i16,
    pub y: i16,
    pub angle: i16,
    pub type_num: i16,
    pub options: i16,
}

#[derive(Debug, Clone)]
pub struct BlockMap {
    pub origin_x: Fixed,
    pub origin_y: Fixed,
    pub width: usize,
    pub height: usize,
    /// Offset table: one entry per block, indexes into `lists`.
    pub offsets: Vec<u32>,
    /// Flat array of line indices. Each block's list starts at offsets[block]
    /// and is terminated by 0xFFFF.
    pub lists: Vec<u16>,
}

// --- Map data container ---

#[derive(Debug, Clone)]
pub struct MapData {
    pub vertexes: Vec<Vertex>,
    pub sectors: Vec<Sector>,
    pub sides: Vec<Side>,
    pub lines: Vec<Line>,
    pub segs: Vec<Seg>,
    pub subsectors: Vec<SubSector>,
    pub nodes: Vec<Node>,
    pub blockmap: BlockMap,
    pub reject: Vec<u8>,
    pub things: Vec<MapThing>,
}

#[derive(Debug)]
pub enum MapError {
    MissingLump(&'static str),
    InvalidSize { lump: &'static str, size: usize, expected_multiple: usize },
    InvalidIndex { lump: &'static str, field: &'static str, value: i16, max: usize },
}

impl fmt::Display for MapError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MissingLump(name) => write!(f, "missing map lump: {name}"),
            Self::InvalidSize { lump, size, expected_multiple } => {
                write!(f, "{lump} lump size {size} not a multiple of {expected_multiple}")
            }
            Self::InvalidIndex { lump, field, value, max } => {
                write!(f, "{lump}.{field} index {value} out of range (max {max})")
            }
        }
    }
}

impl core::error::Error for MapError {}

// --- Binary helpers ---

fn read_i16_le(data: &[u8], off: usize) -> i16 {
    let Some(slice) = data.get(off..off + 2) else { return 0 };
    let mut bytes = [0u8; 2];
    bytes.copy_from_slice(slice);
    i16::from_le_bytes(bytes)
}

fn read_u16_le(data: &[u8], off: usize) -> u16 {
    let Some(slice) = data.get(off..off + 2) else { return 0 };
    let mut bytes = [0u8; 2];
    bytes.copy_from_slice(slice);
    u16::from_le_bytes(bytes)
}

fn lump_count(data: &[u8], entry_size: usize, lump_name: &'static str) -> Result<usize, MapError> {
    if !data.len().is_multiple_of(entry_size) {
        return Err(MapError::InvalidSize {
            lump: lump_name,
            size: data.len(),
            expected_multiple: entry_size,
        });
    }
    Ok(data.len() / entry_size)
}

/// Read an 8-byte name field, returning the index of a matching texture name
/// (placeholder: returns -1 for "-" / empty, 0 otherwise — real texture
/// resolution happens in the texture loading step).
fn read_texture_name(data: &[u8], off: usize) -> i16 {
    if data[off] == b'-' || data[off] == 0 {
        -1
    } else {
        0 // placeholder; texture resolution deferred to Step 4
    }
}

fn read_flat_name(data: &[u8], off: usize) -> i16 {
    if data[off] == b'-' || data[off] == 0 {
        -1
    } else {
        0 // placeholder
    }
}

// --- Slope type classification ---

fn slope_type(dx: Fixed, dy: Fixed) -> SlopeType {
    if dx == 0 {
        SlopeType::Vertical
    } else if dy == 0 {
        SlopeType::Horizontal
    } else if fixed_div(dy, dx) > 0 {
        SlopeType::Positive
    } else {
        SlopeType::Negative
    }
}

// --- Loader ---

impl MapData {
    /// Load a map by name (e.g. "E1M1") from a parsed WAD.
    pub fn load(wad: &Wad, map_name: &str) -> Result<Self, MapError> {
        let marker = wad
            .find_lump(map_name)
            .ok_or(MapError::MissingLump("map marker"))?;

        // Helper: find a sub-lump after the map marker
        let get_lump = |name: &'static str| -> Result<&[u8], MapError> {
            let idx = wad
                .find_lump_after(name, marker + 1)
                .ok_or(MapError::MissingLump(name))?;
            Ok(wad.lump_data(idx))
        };

        let vert_data = get_lump("VERTEXES")?;
        let sector_data = get_lump("SECTORS")?;
        let side_data = get_lump("SIDEDEFS")?;
        let line_data = get_lump("LINEDEFS")?;
        let seg_data = get_lump("SEGS")?;
        let ssector_data = get_lump("SSECTORS")?;
        let node_data = get_lump("NODES")?;
        let thing_data = get_lump("THINGS")?;
        let blockmap_data = get_lump("BLOCKMAP")?;
        let reject_data = get_lump("REJECT")?;

        let vertexes = Self::load_vertexes(vert_data)?;
        let sectors = Self::load_sectors(sector_data)?;
        let sides = Self::load_sidedefs(side_data, sectors.len())?;
        let lines = Self::load_linedefs(line_data, &vertexes, &sides)?;
        let segs = Self::load_segs(seg_data, &vertexes, &lines, &sides)?;
        let subsectors = Self::load_subsectors(ssector_data, &segs, &sectors)?;
        let nodes = Self::load_nodes(node_data)?;
        let things = Self::load_things(thing_data)?;
        let blockmap = Self::load_blockmap(blockmap_data)?;

        Ok(Self {
            vertexes,
            sectors,
            sides,
            lines,
            segs,
            subsectors,
            nodes,
            blockmap,
            reject: reject_data.to_vec(),
            things,
        })
    }

    fn load_vertexes(data: &[u8]) -> Result<Vec<Vertex>, MapError> {
        let count = lump_count(data, 4, "VERTEXES")?;
        let mut verts = Vec::with_capacity(count);
        for i in 0..count {
            let off = i * 4;
            verts.push(Vertex {
                x: (read_i16_le(data, off) as Fixed) << FRACBITS,
                y: (read_i16_le(data, off + 2) as Fixed) << FRACBITS,
            });
        }
        Ok(verts)
    }

    fn load_sectors(data: &[u8]) -> Result<Vec<Sector>, MapError> {
        let count = lump_count(data, 26, "SECTORS")?;
        let mut sectors = Vec::with_capacity(count);
        for i in 0..count {
            let off = i * 26;
            let mut floor_name = [0u8; 8];
            floor_name.copy_from_slice(&data[off + 4..off + 12]);
            let mut ceil_name = [0u8; 8];
            ceil_name.copy_from_slice(&data[off + 12..off + 20]);

            sectors.push(Sector {
                floor_height: (read_i16_le(data, off) as Fixed) << FRACBITS,
                ceiling_height: (read_i16_le(data, off + 2) as Fixed) << FRACBITS,
                floor_pic: read_flat_name(data, off + 4),
                ceiling_pic: read_flat_name(data, off + 12),
                light_level: read_i16_le(data, off + 20),
                special: read_i16_le(data, off + 22),
                tag: read_i16_le(data, off + 24),
                sound_traversed: 0,
                valid_count: 0,
                line_count: 0,
                first_line: 0,
                floor_pic_name: floor_name,
                ceiling_pic_name: ceil_name,
            });
        }
        Ok(sectors)
    }

    fn load_sidedefs(data: &[u8], num_sectors: usize) -> Result<Vec<Side>, MapError> {
        let count = lump_count(data, 30, "SIDEDEFS")?;
        let mut sides = Vec::with_capacity(count);
        for i in 0..count {
            let off = i * 30;
            let sector_idx = read_i16_le(data, off + 28);
            if sector_idx < 0 || sector_idx as usize >= num_sectors {
                return Err(MapError::InvalidIndex {
                    lump: "SIDEDEFS",
                    field: "sector",
                    value: sector_idx,
                    max: num_sectors,
                });
            }
            let mut top_name = [0u8; 8];
            top_name.copy_from_slice(&data[off + 4..off + 12]);
            let mut bottom_name = [0u8; 8];
            bottom_name.copy_from_slice(&data[off + 12..off + 20]);
            let mut mid_name = [0u8; 8];
            mid_name.copy_from_slice(&data[off + 20..off + 28]);

            sides.push(Side {
                textureoffset: (read_i16_le(data, off) as Fixed) << FRACBITS,
                rowoffset: (read_i16_le(data, off + 2) as Fixed) << FRACBITS,
                top_texture: read_texture_name(data, off + 4),
                bottom_texture: read_texture_name(data, off + 12),
                mid_texture: read_texture_name(data, off + 20),
                sector: sector_idx as SectorId,
                top_texture_name: top_name,
                bottom_texture_name: bottom_name,
                mid_texture_name: mid_name,
            });
        }
        Ok(sides)
    }

    fn load_linedefs(
        data: &[u8],
        vertexes: &[Vertex],
        sides: &[Side],
    ) -> Result<Vec<Line>, MapError> {
        let count = lump_count(data, 14, "LINEDEFS")?;
        let mut lines = Vec::with_capacity(count);
        for i in 0..count {
            let off = i * 14;
            let v1_idx = read_u16_le(data, off);
            let v2_idx = read_u16_le(data, off + 2);
            let flags = read_i16_le(data, off + 4);
            let special = read_i16_le(data, off + 6);
            let tag = read_i16_le(data, off + 8);
            let s1 = read_i16_le(data, off + 10);
            let s2 = read_i16_le(data, off + 12);

            let v1 = &vertexes[v1_idx as usize];
            let v2 = &vertexes[v2_idx as usize];
            let dx = v2.x - v1.x;
            let dy = v2.y - v1.y;

            let side0 = if s1 >= 0 && (s1 as usize) < sides.len() {
                Some(s1 as SideId)
            } else {
                None
            };
            let side1 = if s2 >= 0 && (s2 as usize) < sides.len() {
                Some(s2 as SideId)
            } else {
                None
            };

            let front_sector = side0
                .map(|s| sides[s as usize].sector)
                .unwrap_or(0);
            let back_sector = side1.map(|s| sides[s as usize].sector);

            let bbox = [
                v1.y.max(v2.y), // top
                v1.y.min(v2.y), // bottom
                v1.x.min(v2.x), // left
                v1.x.max(v2.x), // right
            ];

            lines.push(Line {
                v1: v1_idx,
                v2: v2_idx,
                dx,
                dy,
                flags,
                special,
                tag,
                sidenum: [side0, side1],
                bbox,
                slope_type: slope_type(dx, dy),
                front_sector,
                back_sector,
                valid_count: 0,
            });
        }
        Ok(lines)
    }

    fn load_segs(
        data: &[u8],
        vertexes: &[Vertex],
        lines: &[Line],
        sides: &[Side],
    ) -> Result<Vec<Seg>, MapError> {
        let count = lump_count(data, 12, "SEGS")?;
        let mut segs = Vec::with_capacity(count);
        for i in 0..count {
            let off = i * 12;
            let v1 = read_u16_le(data, off);
            let v2 = read_u16_le(data, off + 2);
            let angle_raw = read_i16_le(data, off + 4);
            let linedef_idx = read_u16_le(data, off + 6);
            let side = read_i16_le(data, off + 8);
            let offset = read_i16_le(data, off + 10);

            let line = &lines[linedef_idx as usize];
            let side_idx = if side == 0 {
                line.sidenum[0].unwrap_or(0)
            } else {
                line.sidenum[1].unwrap_or(0)
            };

            let front_sector = sides[side_idx as usize].sector;
            let back_sector = if side == 0 {
                line.sidenum[1].map(|s| sides[s as usize].sector)
            } else {
                line.sidenum[0].map(|s| sides[s as usize].sector)
            };

            // Recompute angle from vertex positions for precision
            let vx1 = &vertexes[v1 as usize];
            let vx2 = &vertexes[v2 as usize];
            let _ = angle_raw; // original WAD angle, we recompute
            let angle = point_to_angle(vx2.x - vx1.x, vx2.y - vx1.y);

            segs.push(Seg {
                v1,
                v2,
                offset: (offset as Fixed) << FRACBITS,
                angle,
                side: side_idx,
                line: linedef_idx,
                front_sector,
                back_sector,
            });
        }
        Ok(segs)
    }

    fn load_subsectors(
        data: &[u8],
        segs: &[Seg],
        sectors: &[Sector],
    ) -> Result<Vec<SubSector>, MapError> {
        let count = lump_count(data, 4, "SSECTORS")?;
        let _ = sectors; // used for validation
        let mut subsectors = Vec::with_capacity(count);
        for i in 0..count {
            let off = i * 4;
            let num_segs = read_u16_le(data, off);
            let first_seg = read_u16_le(data, off + 2);

            // Determine sector from first seg's front sector
            let sector = segs[first_seg as usize].front_sector;

            subsectors.push(SubSector {
                sector,
                num_lines: num_segs,
                first_line: first_seg,
            });
        }
        Ok(subsectors)
    }

    fn load_nodes(data: &[u8]) -> Result<Vec<Node>, MapError> {
        let count = lump_count(data, 28, "NODES")?;
        let mut nodes = Vec::with_capacity(count);
        for i in 0..count {
            let off = i * 28;
            let x = (read_i16_le(data, off) as Fixed) << FRACBITS;
            let y = (read_i16_le(data, off + 2) as Fixed) << FRACBITS;
            let dx = (read_i16_le(data, off + 4) as Fixed) << FRACBITS;
            let dy = (read_i16_le(data, off + 6) as Fixed) << FRACBITS;

            let mut bbox = [[0i32; 4]; 2];
            for (child, child_bbox) in bbox.iter_mut().enumerate() {
                for (coord, val) in child_bbox.iter_mut().enumerate() {
                    let idx = off + 8 + child * 8 + coord * 2;
                    *val = (read_i16_le(data, idx) as Fixed) << FRACBITS;
                }
            }

            let children = [
                read_u16_le(data, off + 24),
                read_u16_le(data, off + 26),
            ];

            nodes.push(Node { x, y, dx, dy, bbox, children });
        }
        Ok(nodes)
    }

    fn load_things(data: &[u8]) -> Result<Vec<MapThing>, MapError> {
        let count = lump_count(data, 10, "THINGS")?;
        let mut things = Vec::with_capacity(count);
        for i in 0..count {
            let off = i * 10;
            things.push(MapThing {
                x: read_i16_le(data, off),
                y: read_i16_le(data, off + 2),
                angle: read_i16_le(data, off + 4),
                type_num: read_i16_le(data, off + 6),
                options: read_i16_le(data, off + 8),
            });
        }
        Ok(things)
    }

    fn load_blockmap(data: &[u8]) -> Result<BlockMap, MapError> {
        if data.len() < 8 {
            return Err(MapError::InvalidSize {
                lump: "BLOCKMAP",
                size: data.len(),
                expected_multiple: 8,
            });
        }

        let origin_x = (read_i16_le(data, 0) as Fixed) << FRACBITS;
        let origin_y = (read_i16_le(data, 2) as Fixed) << FRACBITS;
        let width = read_u16_le(data, 4) as usize;
        let height = read_u16_le(data, 6) as usize;

        // The rest of the lump is u16 values: offset table then line lists.
        // Offsets are in units of u16 from the start of the lump.
        let num_blocks = width * height;
        let mut offsets = Vec::with_capacity(num_blocks);
        for i in 0..num_blocks {
            let off = 8 + i * 2;
            if off + 2 > data.len() {
                break;
            }
            offsets.push(read_u16_le(data, off) as u32);
        }

        // Read the entire u16 array for the lists portion
        let total_u16 = data.len() / 2;
        let mut lists = Vec::with_capacity(total_u16);
        for i in 0..total_u16 {
            lists.push(read_u16_le(data, i * 2));
        }

        Ok(BlockMap {
            origin_x,
            origin_y,
            width,
            height,
            offsets,
            lists,
        })
    }

    /// BSP root node index. Panics if nodes are empty (invalid map).
    pub fn root_node(&self) -> u16 {
        debug_assert!(!self.nodes.is_empty(), "BSP tree has no nodes");
        (self.nodes.len() - 1) as u16
    }

    /// Resolve texture/flat name references against loaded TextureData.
    /// Must be called after TextureData::load().
    pub fn resolve_textures(&mut self, textures: &crate::texture::TextureData) {
        for side in &mut self.sides {
            side.top_texture = textures.texture_num_for_name(&side.top_texture_name);
            side.bottom_texture = textures.texture_num_for_name(&side.bottom_texture_name);
            side.mid_texture = textures.texture_num_for_name(&side.mid_texture_name);
        }
        for sector in &mut self.sectors {
            sector.floor_pic = textures.flat_num_for_name(&sector.floor_pic_name);
            sector.ceiling_pic = textures.flat_num_for_name(&sector.ceiling_pic_name);
        }
    }
}

/// Compute angle from dx, dy (replaces R_PointToAngle2 for seg angles).
/// Compute angle from dx, dy — exact Doom R_PointToAngle algorithm.
/// Uses SlopeDiv + tantoangle table for each octant.
pub fn point_to_angle(dx: Fixed, dy: Fixed) -> Angle {
    use crate::math::{ANG90, ANG180, ANG270};
    use crate::tables::TANTOANGLE;

    if dx == 0 && dy == 0 {
        return 0;
    }

    if dx >= 0 {
        if dy >= 0 {
            if dx > dy {
                // octant 0
                TANTOANGLE[slope_div(dy as u32, dx as u32)]
            } else {
                // octant 1
                ANG90 - 1 - TANTOANGLE[slope_div(dx as u32, dy as u32)]
            }
        } else {
            let ay = (-dy) as u32;
            if dx as u32 > ay {
                // octant 8
                0u32.wrapping_sub(TANTOANGLE[slope_div(ay, dx as u32)])
            } else {
                // octant 7
                ANG270 + TANTOANGLE[slope_div(dx as u32, ay)]
            }
        }
    } else {
        let ax = (-dx) as u32;
        if dy >= 0 {
            if ax > dy as u32 {
                // octant 3
                ANG180 - 1 - TANTOANGLE[slope_div(dy as u32, ax)]
            } else {
                // octant 2
                ANG90 + TANTOANGLE[slope_div(ax, dy as u32)]
            }
        } else {
            let ay = (-dy) as u32;
            if ax > ay {
                // octant 4
                ANG180 + TANTOANGLE[slope_div(ay, ax)]
            } else {
                // octant 5
                ANG270 - 1 - TANTOANGLE[slope_div(ax, ay)]
            }
        }
    }
}

/// Doom's SlopeDiv: (num << 3) / (den >> 8), clamped to SLOPERANGE.
fn slope_div(num: u32, den: u32) -> usize {
    const SLOPERANGE: u32 = 2048;
    if den < 512 {
        return SLOPERANGE as usize;
    }
    let ans = (num << 3) / (den >> 8);
    if ans <= SLOPERANGE {
        ans as usize
    } else {
        SLOPERANGE as usize
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn slope_type_classification() {
        assert_eq!(slope_type(0, 100), SlopeType::Vertical);
        assert_eq!(slope_type(100, 0), SlopeType::Horizontal);
        assert_eq!(slope_type(100, 100), SlopeType::Positive);
        assert_eq!(slope_type(100, -100), SlopeType::Negative);
        assert_eq!(slope_type(-100, 100), SlopeType::Negative);
        assert_eq!(slope_type(-100, -100), SlopeType::Positive);
    }
}