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
use neurodoom::engine::ClassicEngine;
use neurodoom::game_data::MobjFlag;
use neurodoom::map::MapData;
use neurodoom::math::*;
use neurodoom::physics;
use neurodoom::rules::PlayerAction;
use neurodoom::texture::TextureData;
use neurodoom::wad::Wad;
use neurodoom::world::PeerId;

fn load_wad() -> Vec<u8> {
    let path = std::env::var("DOOM_WAD")
        .unwrap_or_else(|_| "doom1.wad".to_string());
    std::fs::read(&path).unwrap_or_else(|e| panic!("failed to read WAD at {path}: {e}"))
}

fn setup_map() -> MapData {
    let data = load_wad();
    let wad = Wad::parse(&data).unwrap();
    let mut map = MapData::load(&wad, "E1M1").unwrap();
    let textures = TextureData::load(&wad).unwrap();
    map.resolve_textures(&textures);
    map
}

// --- check_sight unit tests ---

#[test]
fn sight_same_point_is_visible() {
    let map = setup_map();
    let p1 = map.things.iter().find(|t| t.type_num == 1).unwrap();
    let x = (p1.x as Fixed) << FRACBITS;
    let y = (p1.y as Fixed) << FRACBITS;
    let (fz, _) = physics::find_sector_heights(&map, x, y);
    let eye_z = fz + 41 * FRACUNIT; // ~eye height

    assert!(
        physics::check_sight(&map, x, y, eye_z, x, y, fz, 56 * FRACUNIT),
        "same position should always be visible"
    );
}

#[test]
fn sight_nearby_open_area() {
    let map = setup_map();
    // Player start area — nearby points in the same room should be visible
    let p1 = map.things.iter().find(|t| t.type_num == 1).unwrap();
    let x1 = (p1.x as Fixed) << FRACBITS;
    let y1 = (p1.y as Fixed) << FRACBITS;
    let (fz1, _) = physics::find_sector_heights(&map, x1, y1);
    let eye_z = fz1 + 41 * FRACUNIT;

    // Small offset — still in the same room
    let x2 = x1 + 64 * FRACUNIT;
    let y2 = y1;
    let (fz2, _) = physics::find_sector_heights(&map, x2, y2);

    assert!(
        physics::check_sight(&map, x1, y1, eye_z, x2, y2, fz2, 56 * FRACUNIT),
        "nearby point in same room should be visible"
    );
}

#[test]
fn sight_blocked_by_wall() {
    let map = setup_map();
    // E1M1 player start is at roughly (1056, -3616). Pick a point on the other
    // side of a wall — far enough that there must be solid geometry between them.
    let x1 = 1056 * FRACUNIT;
    let y1 = -3616 * FRACUNIT;
    let (fz1, _) = physics::find_sector_heights(&map, x1, y1);
    let eye_z = fz1 + 41 * FRACUNIT;

    // Point far away in a completely different area of the map
    let x2 = 3200 * FRACUNIT;
    let y2 = -2400 * FRACUNIT;
    let (fz2, _) = physics::find_sector_heights(&map, x2, y2);

    // This should be blocked by walls
    assert!(
        !physics::check_sight(&map, x1, y1, eye_z, x2, y2, fz2, 56 * FRACUNIT),
        "sight across map through walls should be blocked"
    );
}

// --- Monster AI integration tests ---

#[test]
fn monsters_dont_activate_through_walls() {
    let wad_data = load_wad();
    let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();

    // Run 35 ticks (1 second) — monsters in other rooms shouldn't have acquired targets
    for _ in 0..35 {
        engine.tick_single(PeerId(0), PlayerAction::default());
    }

    // Find player position
    let player_pos = engine.world().iter()
        .find(|e| engine.world().is_controlled(e.id))
        .map(|e| (e.x, e.y))
        .unwrap();

    // Check monsters: those far away (in other rooms) should NOT have a target
    let mut far_monsters_with_target = 0;
    let mut far_monsters_total = 0;
    for e in engine.world().iter() {
        if !e.flags.contains(MobjFlag::Shootable) || !e.flags.contains(MobjFlag::CountKill) {
            continue;
        }
        let dist = ((e.x - player_pos.0).abs() as i64 + (e.y - player_pos.1).abs() as i64)
            >> FRACBITS;
        // Monsters very far away (>1500 units) are definitely in other rooms
        if dist > 1500 {
            far_monsters_total += 1;
            if e.target.is_some() {
                far_monsters_with_target += 1;
            }
        }
    }

    assert!(
        far_monsters_total > 0,
        "E1M1 should have distant monsters"
    );
    assert_eq!(
        far_monsters_with_target, 0,
        "far-away monsters in other rooms should not have acquired a target \
         ({far_monsters_with_target}/{far_monsters_total} did)"
    );
}

#[test]
fn hitscan_does_not_damage_through_walls() {
    let wad_data = load_wad();
    let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();

    // Tick once to set up entities
    engine.tick_single(PeerId(0), PlayerAction::default());

    // Record health of all monsters
    let initial_health: Vec<_> = engine.world().iter()
        .filter(|e| e.flags.contains(MobjFlag::Shootable) && e.flags.contains(MobjFlag::CountKill))
        .map(|e| (e.id, e.health))
        .collect();

    // Find player position
    let player_pos = engine.world().iter()
        .find(|e| engine.world().is_controlled(e.id))
        .map(|e| (e.x, e.y))
        .unwrap();

    // Fire a bunch in different directions
    for angle_step in 0..8 {
        let angle_turn = (angle_step * 8192) as i16;
        engine.tick_single(PeerId(0), PlayerAction {
            angle_turn,
            ..PlayerAction::default()
        });
        engine.tick_single(PeerId(0), PlayerAction {
            buttons: neurodoom::types::Button::Attack.into(),
            ..PlayerAction::default()
        });
    }

    // Check: no monster behind a wall should have taken damage
    for e in engine.world().iter() {
        if !e.flags.contains(MobjFlag::Shootable) || !e.flags.contains(MobjFlag::CountKill) {
            continue;
        }
        let dist = ((e.x - player_pos.0).abs() as i64 + (e.y - player_pos.1).abs() as i64)
            >> FRACBITS;
        // Monsters very far away in other rooms
        if dist > 1500 {
            if let Some((_, orig_hp)) = initial_health.iter().find(|(id, _)| *id == e.id) {
                assert_eq!(
                    e.health, *orig_hp,
                    "monster at distance {} should not take hitscan damage through walls",
                    dist
                );
            }
        }
    }
}

#[test]
fn monster_movement_speed_is_nonzero() {
    // Regression test for the fixed_mul -> plain multiply bug
    let wad_data = load_wad();
    let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();

    // Record initial monster positions
    let initial_positions: Vec<_> = engine.world().iter()
        .filter(|e| e.flags.contains(MobjFlag::Shootable) && e.flags.contains(MobjFlag::CountKill))
        .map(|e| (e.id, e.x, e.y))
        .collect();

    // Walk toward monsters for 3 seconds to trigger A_Look on visible ones
    for _ in 0..105 {
        engine.tick_single(PeerId(0), PlayerAction {
            forward_move: 25,
            ..PlayerAction::default()
        });
    }

    // Check if any monster that has a target has actually moved
    let mut any_moved = false;
    for e in engine.world().iter() {
        if e.target.is_none() { continue; }
        if let Some((_, ox, oy)) = initial_positions.iter().find(|(id, _, _)| *id == e.id) {
            if e.x != *ox || e.y != *oy {
                any_moved = true;
                break;
            }
        }
    }

    // If any monster has a target, it should have moved (unless blocked)
    let targeted = engine.world().iter().filter(|e| e.target.is_some()
        && e.flags.contains(MobjFlag::CountKill)).count();
    if targeted > 0 {
        assert!(any_moved, "monsters with targets should move (speed bug regression)");
    }
}

#[test]
fn weapon_state_machine_fires_and_returns_to_ready() {
    use neurodoom::game_data::{S_PISTOL, S_PISTOL1};
    let wad_data = load_wad();
    let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();

    let pid = engine.world().controlled_entities().next().unwrap();

    // Initially in ready state
    let psp = engine.world().player_state(pid).unwrap().psp_state;
    assert_eq!(psp, S_PISTOL, "weapon should start in ready state");

    // Press attack — should transition to fire state
    let attack = PlayerAction {
        buttons: neurodoom::types::Button::Attack.into(),
        ..PlayerAction::default()
    };
    engine.tick_single(PeerId(0), attack.clone());
    let psp = engine.world().player_state(pid).unwrap().psp_state;
    assert_eq!(psp, S_PISTOL1, "weapon should enter fire state on attack");

    // After enough ticks without attack, should return to ready
    let idle = PlayerAction::default();
    for _ in 0..30 {
        engine.tick_single(PeerId(0), idle.clone());
    }
    let psp = engine.world().player_state(pid).unwrap().psp_state;
    assert_eq!(psp, S_PISTOL, "weapon should return to ready state after firing");
}

#[test]
fn pistol_damage_in_expected_range() {
    // Verify the PRNG-based damage formula produces values in 5-15 range
    let wad_data = load_wad();
    let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();

    // Sample p_random many times and verify damage formula
    let mut damages = Vec::new();
    for _ in 0..100 {
        let r = engine.world_mut().p_random();
        let damage = 5 * (r % 3 + 1);
        damages.push(damage);
    }

    assert!(damages.iter().all(|&d| d >= 5 && d <= 15),
        "pistol damage should be in range 5-15, got {:?}", damages);
    assert!(damages.iter().any(|&d| d == 5), "should sometimes roll minimum damage");
    assert!(damages.iter().any(|&d| d == 15), "should sometimes roll maximum damage");
}

#[test]
fn monster_hitscan_damage_in_expected_range() {
    // Verify monster damage formula: (1..=5) * 3 = 3-15
    let wad_data = load_wad();
    let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();

    let mut damages = Vec::new();
    for _ in 0..100 {
        let r = engine.world_mut().p_random();
        let damage = (r % 5 + 1) * 3;
        damages.push(damage);
    }

    assert!(damages.iter().all(|&d| d >= 3 && d <= 15),
        "monster damage should be in range 3-15, got {:?}", damages);
    assert!(damages.iter().any(|&d| d == 3), "should sometimes roll minimum damage");
    assert!(damages.iter().any(|&d| d == 15), "should sometimes roll maximum damage");
}

#[test]
fn player_health_clamps_at_one() {
    let wad_data = load_wad();
    let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();

    // Walk into monsters for several seconds
    let attack_forward = PlayerAction {
        forward_move: 25,
        ..PlayerAction::default()
    };
    for _ in 0..350 {
        engine.tick_single(PeerId(0), attack_forward.clone());
    }

    let pid = engine.world().controlled_entities().next().unwrap();
    let player = engine.world().get(pid).unwrap();
    // Player should never go below 1 HP
    assert!(player.health >= 1, "player health should clamp at 1, got {}", player.health);
    // Player should still be shootable (not lose flag on "death")
    assert!(player.flags.contains(MobjFlag::Shootable),
        "player should remain shootable after reaching low HP");
}

#[test]
fn weapon_bob_changes_when_moving() {
    let wad_data = load_wad();
    let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();

    let pid = engine.world().controlled_entities().next().unwrap();

    // Standing still — bob should be zero
    engine.tick_single(PeerId(0), PlayerAction::default());
    let bob_still = engine.world().player_state(pid).unwrap().bob;
    assert_eq!(bob_still, 0, "bob should be 0 when standing still");

    // Moving forward — bob should increase
    for _ in 0..10 {
        engine.tick_single(PeerId(0), PlayerAction {
            forward_move: 25,
            ..PlayerAction::default()
        });
    }
    let bob_moving = engine.world().player_state(pid).unwrap().bob;
    assert!(bob_moving > 0, "bob should be positive when moving, got {bob_moving}");

    // Check sx/sy are oscillating (not stuck at defaults)
    let ps = engine.world().player_state(pid).unwrap();
    let sx = ps.psp_sx;
    let _sy = ps.psp_sy;
    // sx should differ from default FRACUNIT when bob > 0
    assert!(sx != 0, "weapon sx should be non-zero");
}

#[test]
fn imp_fireball_has_correct_properties() {
    // EntityType(31) = MT_TROOPSHOT: imp fireball (BAL1 sprite)
    let info = neurodoom::game_data::EntityType(31).info().unwrap();
    assert!(info.speed > 0, "imp fireball should have non-zero speed, got {}", info.speed);
    assert!(info.flags().contains(MobjFlag::Missile), "imp fireball should have Missile flag");
    assert!(!info.spawnstate.is_null(), "imp fireball should have a spawn state");

    // Verify spawnstate uses BAL1 sprite (index 18 = "BAL1")
    let st = info.spawnstate.get().unwrap();
    assert_eq!(st.sprite.0, 18, "imp fireball should use BAL1 sprite (18), got {}", st.sprite.0);
    assert_eq!(info.damage, 3, "imp fireball should do 3 damage");
}

#[test]
fn imp_fires_projectile_when_in_range() {
    let wad_data = load_wad();
    let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();

    // Walk forward toward imps for 20 seconds (imps are ~2400 units away)
    for _ in 0..700 {
        engine.tick_single(PeerId(0), PlayerAction {
            forward_move: 25,
            ..PlayerAction::default()
        });
    }

    // Wait for imps to attack
    for _ in 0..200 {
        engine.tick_single(PeerId(0), PlayerAction::default());
    }

    // Check if any projectile entities exist (EntityType 31 = imp fireball)
    let fireballs: Vec<_> = engine.world().iter()
        .filter(|e| e.entity_type == neurodoom::game_data::EntityType(31))
        .collect();

    // Check for any Missile-flagged entities
    let missiles: Vec<_> = engine.world().iter()
        .filter(|e| e.flags.contains(MobjFlag::Missile))
        .collect();

    // Check imp state (EntityType 13 = imp, doomednum 3001)
    let imps: Vec<_> = engine.world().iter()
        .filter(|e| e.entity_type == neurodoom::game_data::EntityType(13))
        .collect();
    let imps_with_target = imps.iter().filter(|e| e.target.is_some()).count();
    let imp_states: Vec<_> = imps.iter().map(|e| e.state.0).collect();

    eprintln!("Fireballs (ET33): {}, Total missiles: {}", fireballs.len(), missiles.len());
    eprintln!("Imps: {}, with target: {}, states: {:?}", imps.len(), imps_with_target, imp_states);
}

#[test]
fn puff_spawns_on_hitscan_hit() {
    let wad_data = load_wad();
    let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();

    let _initial_count = engine.world().entity_count();

    // Walk toward enemies and fire
    for _ in 0..70 {
        engine.tick_single(PeerId(0), PlayerAction {
            forward_move: 25,
            ..PlayerAction::default()
        });
    }

    // Fire at enemies
    let attack = PlayerAction {
        buttons: neurodoom::types::Button::Attack.into(),
        ..PlayerAction::default()
    };
    for _ in 0..20 {
        engine.tick_single(PeerId(0), attack.clone());
    }

    // Count entities — should have spawned puffs (more entities than initial)
    // Puffs are EntityType(39)
    let puff_count = engine.world().iter()
        .filter(|e| e.entity_type == neurodoom::game_data::EntityType(37))
        .count();

    // We can't guarantee a hit, but at least verify puffs can exist
    // The test mainly ensures no panics during puff spawning
    let _ = puff_count;
}

#[test]
fn rocket_launcher_spawns_projectile() {
    let wad_data = load_wad();
    let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();

    let pid = engine.world().controlled_entities().next().unwrap();
    // Give rocket launcher and ammo
    if let Some(ps) = engine.world_mut().player_state_mut(pid) {
        ps.weapon_owned[neurodoom::types::WeaponType::RocketLauncher as usize] = true;
        ps.ammo = [999, 99, 999, 99];
        ps.ready_weapon = neurodoom::types::WeaponType::RocketLauncher;
        // Set weapon to rocket ready state
        ps.psp_state = neurodoom::game_data::StateNum(57); // S_MISSILE
        ps.psp_tics = 1;
    }

    // Fire
    let attack = PlayerAction {
        buttons: neurodoom::types::Button::Attack.into(),
        ..PlayerAction::default()
    };
    // Tick enough for the fire state to trigger A_FIRE_MISSILE
    for _ in 0..20 {
        engine.tick_single(PeerId(0), attack.clone());
    }

    // Check for missile entities
    let missiles: Vec<_> = engine.world().iter()
        .filter(|e| e.flags.contains(MobjFlag::Missile))
        .collect();
    let rockets: Vec<_> = engine.world().iter()
        .filter(|e| e.entity_type == neurodoom::game_data::EntityType(33))
        .collect();

    eprintln!("Missiles: {}, Rockets (ET33): {}", missiles.len(), rockets.len());
    // Check total entities for any projectile-like things
    let total = engine.world().entity_count();
    eprintln!("Total entities: {}", total);

    assert!(missiles.len() > 0 || rockets.len() > 0,
        "rocket launcher should spawn at least one projectile");
}

#[test]
fn e1m1_step_lines_have_lower_textures() {
    let wad_data = load_wad();
    let wad = neurodoom::wad::Wad::parse(&wad_data).unwrap();
    let mut map = neurodoom::map::MapData::load(&wad, "E1M1").unwrap();
    let textures = neurodoom::texture::TextureData::load(&wad).unwrap();
    map.resolve_textures(&textures);

    // Lines 73-82 should have lower textures on their front side (side 0)
    for li in 73..83 {
        let line = &map.lines[li];
        let s0 = line.sidenum[0].unwrap() as usize;
        let side = &map.sides[s0];
        eprintln!("Line {li}: top={} bot={} mid={} bot_name={:?}",
            side.top_texture, side.bottom_texture, side.mid_texture,
            core::str::from_utf8(&side.bottom_texture_name).unwrap_or("?"));
    }

    // Verify at least one has a valid bottom texture
    let has_bot = (73..83).any(|li| {
        let s0 = map.lines[li].sidenum[0].unwrap() as usize;
        map.sides[s0].bottom_texture > 0
    });
    assert!(has_bot, "E1M1 step lines should have lower textures (SLADWALL)");
}

#[test]
fn e1m1_step_walls_visible() {
    let wad_data = load_wad();
    let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();
    engine.tick_single(PeerId(0), PlayerAction::default());
    // Verify wall pixels render (basic sanity check)
    let wall_count = engine.semantic_buffer().iter()
        .filter(|&&c| c == neurodoom::render::SemanticClass::Wall as u8).count();
    assert!(wall_count > 1000, "should have many wall pixels");
}

#[test]
fn e1m1_diagonal_stripe_midtextures_resolved() {
    let wad_data = load_wad();
    let wad = neurodoom::wad::Wad::parse(&wad_data).unwrap();
    let mut map = neurodoom::map::MapData::load(&wad, "E1M1").unwrap();
    let textures = neurodoom::texture::TextureData::load(&wad).unwrap();
    map.resolve_textures(&textures);

    // Lines 298-303 have BRNBIGL/BRNBIGR/BRNBIGC midtextures
    for li in 298..=303 {
        let line = &map.lines[li];
        for si in 0..2 {
            if let Some(sid) = line.sidenum[si] {
                let side = &map.sides[sid as usize];
                eprintln!("Line {li} side[{si}]={sid}: mid_tex={} mid_name={:?}",
                    side.mid_texture,
                    core::str::from_utf8(&side.mid_texture_name).unwrap_or("?"));
            }
        }
    }

    // Verify they resolved
    let line298 = &map.lines[298];
    let s0 = line298.sidenum[0].unwrap() as usize;
    assert!(map.sides[s0].mid_texture > 0,
        "BRNBIGL should resolve to valid texture index, got {}", map.sides[s0].mid_texture);
}

#[test]
fn brnbigl_texture_has_content() {
    let wad_data = load_wad();
    let wad = neurodoom::wad::Wad::parse(&wad_data).unwrap();
    let textures = neurodoom::texture::TextureData::load(&wad).unwrap();

    // BRNBIGL = texture index 5
    let tex = &textures.textures[5];
    eprintln!("BRNBIGL: {}x{}, data_len={}", tex.width, tex.height, tex.data.len());

    let nonzero = tex.data.iter().filter(|&&b| b != 0).count();
    let total = tex.data.len();
    eprintln!("Non-zero pixels: {}/{} ({:.1}%)", nonzero, total, 100.0 * nonzero as f64 / total as f64);

    // Check first column
    let col0 = tex.column(0);
    let col0_nz = col0.iter().filter(|&&b| b != 0).count();
    eprintln!("Column 0: {} non-zero out of {}", col0_nz, col0.len());

    assert!(nonzero > 0, "BRNBIGL should have non-zero pixels (diagonal stripes)");
}

#[test]
fn prng_is_deterministic() {
    let wad_data = load_wad();
    let mut e1 = ClassicEngine::new(&wad_data, "E1M1").unwrap();
    let mut e2 = ClassicEngine::new(&wad_data, "E1M1").unwrap();

    let seq1: Vec<_> = (0..50).map(|_| e1.world_mut().p_random()).collect();
    let seq2: Vec<_> = (0..50).map(|_| e2.world_mut().p_random()).collect();
    assert_eq!(seq1, seq2, "PRNG should be deterministic across instances");
}