From fd65791ee6e0f9cd40dda306130b275aa09604ef Mon Sep 17 00:00:00 2001 From: Marcus Kainth Date: Fri, 4 Sep 2026 23:43:17 +0100 Subject: [PATCH 1/2] native: run the imp's attack from the tic that reaches it The state cycle now enters a frame carrying A_TroopAttack or A_SargAttack rather than saying the tic could not be produced. The attack primitive answers with the angle A_FaceTarget leaves, the flags it clears, the damage a claw that reaches does and how many numbers the call drew, and the tic puts each of those where it belongs: the angle and the flags over what the state cycle and the chase left, the damage through P_DamageMobj with the attacker as both the inflictor and the source, and the draws behind every look and chase the tic ran. P_CheckMeleeRange ends in P_CheckSight, so the attackers ask alongside the lookers, the chasers and the hearers in the one sight call a tic makes. Three cases say the tic could not be produced. More than one thing reaching a routine would have the second draw from an index the first moves. The fireball wants a missile spawned. A claw that kills owes the kill count and whatever the corpse drops. demo3 reaches the routine at gametic 169 and the imp throws a fireball from 407 map units away, so the first divergence stays there, at the two numbers the spawn draws. What the tic does carry through is the imp's frame, its angle and its flags, which sim_parity_live now reads against the probe at that gametic. The claw is seeded instead: sim_troop_live stands one imp four units from another and reads the damage, the pointer P_DamageMobj turns onto the attacker, the threshold it sets and the ambush flag the face clears. --- native/src/sql/sim/attacks.rs | 24 +++ native/src/sql/sim/inter.rs | 17 +++ native/src/sql/sim/mobj.rs | 214 +++++++++++++++++++++++++-- native/tests/sim_parity_live.rs | 22 ++- native/tests/sim_troop_live.rs | 249 ++++++++++++++++++++++++++++++++ 5 files changed, 514 insertions(+), 12 deletions(-) create mode 100644 native/tests/sim_troop_live.rs diff --git a/native/src/sql/sim/attacks.rs b/native/src/sql/sim/attacks.rs index 1dc222f..0f23718 100644 --- a/native/src/sql/sim/attacks.rs +++ b/native/src/sql/sim/attacks.rs @@ -176,6 +176,30 @@ pub fn attack(asks: &str, world: &Attacking<'_>) -> String { ) } +/// [`attack`] over an ask list that carries at most one, folded rather +/// than mapped. +/// +/// A map runs every function in its body once even on an empty list, and +/// this body is the whole routine. A fold runs its body only where the +/// list has an element, so a tic reaching no attack pays for the fold and +/// nothing under it. The answer is the last ask in the list, and +/// [`no_attack`] is what an empty one gives. +pub fn attack_fold(asks: &str, world: &Attacking<'_>) -> String { + let (values, body) = attacks(world); + format!( + "arrayFold((ak_held, ak_ask) -> {}, {asks}, {})", + bind::chain_in("aka", &values, &body), + no_attack(), + ) +} + +/// The [`attacked`] tuple for a tic that reached no attack: no turn, no +/// claw, no missile and no draw. +pub fn no_attack() -> String { + "(toUInt32(0), toInt32(0), toUInt8(0), toInt32(0), toUInt8(0), toUInt32(0), toUInt8(0))" + .to_owned() +} + /// What one attack works out, as the values a body reads and the /// [`attacked`] tuple it answers with. fn attacks(world: &Attacking<'_>) -> (Vec<(String, String)>, String) { diff --git a/native/src/sql/sim/inter.rs b/native/src/sql/sim/inter.rs index 2dee9b0..5864dcb 100644 --- a/native/src/sql/sim/inter.rs +++ b/native/src/sql/sim/inter.rs @@ -1009,6 +1009,23 @@ fn reach(world: &Hurting<'_>) -> Vec<(String, String)> { ] } +/// [`damage_mobj`] over an ask list that carries at most one, folded +/// rather than mapped. +/// +/// A map runs every function in its body once even on an empty list, and +/// this body is the whole routine. A fold runs its body only where the +/// list has an element, so a caller with nothing to hurt pays for the fold +/// and nothing under it. The answer is the last ask in the list, and +/// [`no_hurt`] is what an empty one gives. +pub fn damage_fold(asks: &str, world: &Hurting<'_>) -> String { + let (values, body) = damaged(world); + format!( + "arrayFold((dm_held, dm_ask) -> {}, {asks}, {})", + bind::chain_in("dma", &values, &body), + no_hurt(), + ) +} + /// What one call works out, as the values a body reads and the [`hurt`] /// tuple it answers with. fn damaged(world: &Hurting<'_>) -> (Vec<(String, String)>, String) { diff --git a/native/src/sql/sim/mobj.rs b/native/src/sql/sim/mobj.rs index 7a0d237..9d14228 100644 --- a/native/src/sql/sim/mobj.rs +++ b/native/src/sql/sim/mobj.rs @@ -1,7 +1,7 @@ //! What a thing does with its momentum and its states, from `p_mobj.c`. use super::map::{self, World, answer}; -use super::{State, enemy, inter, maputl, sight}; +use super::{State, attacks, enemy, inter, maputl, sight}; use crate::sql::Statement; use crate::sql::bind; use crate::sql::fixed; @@ -256,6 +256,16 @@ pub fn thinkers(state: &State) -> Vec<(String, String)> { s("m_target") ), ); + bind( + "mt_attackers", + format!( + "arrayFilter((k, c, n, t) -> c = 1 AND n != 0 AND t != 0 \ + AND (state_action[1 + n] = a_troopattack \ + OR state_action[1 + n] = a_sargattack), \ + mt_slots, mt_cycles, mt_next, {})", + s("m_target") + ), + ); let pairs = |slot: &str, other: &dyn Fn(&str) -> String| { sight::asking( &format!("{}[{slot}]", s("m_subsector")), @@ -280,10 +290,11 @@ pub fn thinkers(state: &State) -> Vec<(String, String)> { "mt_pairs", format!( "arrayConcat(arrayMap(k -> {}, mt_lookers), arrayMap(k -> {}, mt_chasers), \ - arrayMap(k -> {}, mt_hearers))", + arrayMap(k -> {}, mt_hearers), arrayMap(k -> {}, mt_attackers))", pairs("k", &player), pairs("k", &target), pairs("k", &heard), + pairs("k", &target), ), ); bind("mt_seen", sight::check_sight("mt_pairs")); @@ -299,6 +310,12 @@ pub fn thinkers(state: &State) -> Vec<(String, String)> { "arraySlice(mt_seen, 1 + length(mt_lookers) + length(mt_chasers), length(mt_hearers))" .to_owned(), ); + bind( + "mt_attack_seen", + "arraySlice(mt_seen, 1 + length(mt_lookers) + length(mt_chasers) \ + + length(mt_hearers), length(mt_attackers))" + .to_owned(), + ); bind( "mt_heard_seen", "arrayMap(k -> toUInt8(mt_hearer_seen[indexOf(mt_hearers, k)]), mt_slots)".to_owned(), @@ -569,7 +586,7 @@ pub fn thinkers(state: &State) -> Vec<(String, String)> { enemy::chased::STATE ), ); - bind("mk_m_flags", { + bind("cq_m_flags", { let held = s("m_flags"); format!( "arrayMap((k, c) -> toInt32(if(indexOf(mt_movers, k) = 0, {held}[k], c.{})), \ @@ -597,16 +614,39 @@ pub fn thinkers(state: &State) -> Vec<(String, String)> { ); } for (column, member, cast, _) in &held { - bind( - &format!("mk_{column}"), - format!("arrayMap(c -> {cast}(c.{member}), cw_slot)"), - ); + let name = if *column == "m_angle" { + "cq_m_angle".to_owned() + } else { + format!("mk_{column}") + }; + bind(&name, format!("arrayMap(c -> {cast}(c.{member}), cw_slot)")); } + // The attack reads what the state cycle and the chase left, and what + // it leaves stands over them. + for (name, expr) in strikes(state) { + bind(&name, expr); + } + bind( + "mk_m_angle", + format!( + "arrayMap((k, v) -> toUInt32(if(k = at_one AND at_one != 0, at_struck.{}, v)), \ + mt_slots, cq_m_angle)", + attacks::attacked::ANGLE + ), + ); + bind( + "mk_m_flags", + format!( + "arrayMap((k, v) -> toInt32(if(k = at_one AND at_one != 0, at_struck.{}, v)), \ + mt_slots, cq_m_flags)", + attacks::attacked::FLAGS + ), + ); bind( "now_prndindex", format!( "toUInt8(bitAnd(toUInt32({}) + arraySum(mt_shouts) \ - + arraySum(arrayMap(c -> c.{}, cw_chased)), 255))", + + arraySum(arrayMap(c -> c.{}, cw_chased)) + at_draws, 255))", s("prndindex"), enemy::chased::DRAWS ), @@ -620,7 +660,7 @@ pub fn thinkers(state: &State) -> Vec<(String, String)> { "toUInt8({} = 1 OR arrayExists(a -> a.{} = 1, mt_two) \ OR arrayExists(c -> c.{} = 1, cw_chased) OR cw_crowded = 1 \ OR tx_crowded = 1 OR tx_unrun = 1 OR tx_crossed = 1 \ - OR tz_unrun = 1)", + OR tz_unrun = 1 OR at_unrun = 1)", s("unresolved"), cycled::STUCK, enemy::chased::STUCK @@ -672,6 +712,15 @@ fn removed(state: &State, player: &str) -> Vec<(String, String)> { } else { s(column) }; + // What the claw left, before the renumbering, because the target + // the damage sets is a slot like any other. + let held = match clawed(column) { + Some(member) => format!( + "arrayMap((k, v) -> toInt32(if(at_clawed = 1 AND k = at_target, \ + mt_hurt.{member}, v)), mt_slots, {held})" + ), + None => held, + }; let held = if POINTERS.contains(&column) { format!("arrayMap(t -> {}, {held})", moved_slot("t")) } else { @@ -699,6 +748,24 @@ fn removed(state: &State, player: &str) -> Vec<(String, String)> { bindings } +/// Where a column the claw moves sits in the answer `P_DamageMobj` gives. +fn clawed(column: &str) -> Option { + Some(match column { + "m_health" => inter::hurt::HEALTH, + "m_flags" => inter::hurt::FLAGS, + "m_state" => inter::hurt::STATE, + "m_tics" => inter::hurt::TICS, + "m_momx" => inter::hurt::MOMX, + "m_momy" => inter::hurt::MOMY, + "m_momz" => inter::hurt::MOMZ, + "m_height" => inter::hurt::HEIGHT, + "m_reactiontime" => inter::hurt::REACTIONTIME, + "m_target" => inter::hurt::TARGET, + "m_threshold" => inter::hurt::THRESHOLD, + _ => return None, + }) +} + /// The mobj array columns the thinker writes, which the compaction reads /// from it rather than from the tic's own start. const THINKER_COLUMNS: [&str; 21] = [ @@ -730,6 +797,131 @@ const THINKER_COLUMNS: [&str; 21] = [ /// written beside them. const POINTERS: [&str; 2] = ["m_target", "m_tracer"]; +/// `A_TroopAttack` and `A_SargAttack` for the things whose state cycle +/// reached one, and the damage a claw that lands does. +/// +/// The routine turns the thing towards its target, takes it off ambush and +/// either claws the target or, for an imp out of reach, throws a fireball. +/// Both the routine and `P_DamageMobj` are folded over their ask lists +/// rather than mapped, so a tic that reaches neither runs neither body. +/// +/// Three cases say the tic could not be produced: more than one thing +/// reaching a routine, because the second would draw from an index the +/// first moves; the fireball, which wants a missile spawned; and a claw +/// that kills, which owes the kill count and whatever the corpse drops. +fn strikes(state: &State) -> Vec<(String, String)> { + let s = |column: &str| state.get(column); + let mut bindings: Vec<(String, String)> = Vec::new(); + let mut bind = |name: &str, expr: String| bindings.push((name.to_owned(), expr)); + + // One ask per attacker: the slot, the routine its frame carries, the + // sight `P_CheckMeleeRange` needs, and how many numbers the tic drew + // before it. + bind( + "at_asks", + format!( + "arrayMap(k -> (toUInt32(k), toInt32(state_action[1 + mt_next[k]]), \ + toUInt8(mt_attack_seen[indexOf(mt_attackers, k)]), \ + toUInt32(arraySum(arraySlice(mt_shouts, 1, k)) \ + + arraySum(arrayMap(c -> toUInt32(c.{}), arraySlice(cw_slot, 1, k))))), \ + mt_attackers)", + enemy::chased::DRAWS + ), + ); + let world = attacks::Attacking { + m_x: &s("m_x"), + m_y: &s("m_y"), + m_angle: "cq_m_angle", + m_flags: "cq_m_flags", + m_type: &s("m_type"), + m_target: "mk_m_target", + prndindex: &s("prndindex"), + }; + bind("at_struck", attacks::attack_fold("at_asks", &world)); + // The one attacker a tic carries and what it drew before its own call. + // A tic reaching more than one is refused below and reads neither. + bind( + "at_one", + "toUInt32(if(length(mt_attackers) = 1, mt_attackers[1], 0))".to_owned(), + ); + bind( + "at_base", + format!( + "toUInt32(arraySum(arrayMap(a -> toUInt32(a.{}), at_asks)))", + attacks::striking::BASE + ), + ); + + // The thinker stage's `P_DamageMobj` ask list, and the one call over + // it. A missile's impact, a barrel's blast and a monster's hitscan all + // hurt things in this stage; each joins this list rather than standing + // up a call of its own, because the routine is among the largest + // things the statement carries and every copy costs a tic that hurts + // nothing. + bind( + "mt_hurt_asks", + format!( + "arraySlice([{}], 1, at_struck.{})", + attacks::claw_ask("at_struck", "greatest(at_one, 1)", "mk_m_target", "at_base"), + attacks::attacked::CLAWED, + ), + ); + // The target as the stage has left it so far. `m_health` and + // `m_height` have no writer ahead of this one, so they stand as the + // tic started. + let hurting = inter::Hurting { + m_x: "mk_m_x", + m_y: "mk_m_y", + m_z: "mk_m_z", + m_momx: "mk_m_momx", + m_momy: "mk_m_momy", + m_momz: "mk_m_momz", + m_reactiontime: "mk_m_reactiontime", + m_type: &s("m_type"), + m_state: "mk_m_state", + m_tics: "mk_m_tics", + m_flags: "cq_m_flags", + m_health: &s("m_health"), + m_height: &s("m_height"), + m_target: "mk_m_target", + m_threshold: "mk_m_threshold", + m_player: &s("m_player"), + prndindex: &s("prndindex"), + readyweapon: &s("p_readyweapon"), + }; + bind("mt_hurt", inter::damage_fold("mt_hurt_asks", &hurting)); + + bind( + "at_clawed", + format!("toUInt8(at_struck.{})", attacks::attacked::CLAWED), + ); + bind( + "at_target", + "toUInt32(if(at_clawed = 1, mk_m_target[greatest(at_one, 1)], 0))".to_owned(), + ); + bind( + "at_draws", + format!( + "toUInt32(toUInt32(at_struck.{}) + toUInt32(mt_hurt.{}))", + attacks::attacked::DRAWS, + inter::hurt::DRAWS, + ), + ); + bind( + "at_unrun", + format!( + "toUInt8(length(mt_attackers) > 1 \ + OR at_struck.{throws} = 1 OR at_struck.{stuck} = 1 \ + OR mt_hurt.{counted} = 1 OR mt_hurt.{drop} != -1)", + throws = attacks::attacked::THROWS, + stuck = attacks::attacked::STUCK, + counted = inter::hurt::COUNTED, + drop = inter::hurt::DROP, + ), + ); + bindings +} + /// The first state a cycle enters, and `A_Look` where the state carries /// it. fn entry_one(slot: &str, state: &State) -> String { @@ -754,7 +946,9 @@ fn entry_one(slot: &str, state: &State) -> String { "toUInt8(multiIf(k = {slot}, 0, c = 0, 0, n = 0, 0, \ state_action[1 + n] != 0 AND state_action[1 + n] != a_look \ AND state_action[1 + n] != a_chase \ - AND state_action[1 + n] != a_facetarget, 1, 0))" + AND state_action[1 + n] != a_facetarget \ + AND state_action[1 + n] != a_troopattack \ + AND state_action[1 + n] != a_sargattack, 1, 0))" ), format!("toUInt8({enters})"), format!( diff --git a/native/tests/sim_parity_live.rs b/native/tests/sim_parity_live.rs index 02ec742..623e618 100644 --- a/native/tests/sim_parity_live.rs +++ b/native/tests/sim_parity_live.rs @@ -221,14 +221,20 @@ const FLASH: [(u32, i32, i32, i32); 6] = [ /// missile frames, marks it as having just attacked and turns it towards /// the player, and at 161 the frame after that turns it again. Both frames /// carry `A_FaceTarget`; the first is entered by the attack and the second -/// by the tic count running out. -const ATTACK: [(u32, i32, i32, u32); 4] = [ +/// by the tic count running out. At 169 the imp reaches the frame carrying +/// `A_TroopAttack`, which turns it once more. +const ATTACK: [(u32, i32, i32, u32); 5] = [ (152, 444, 4194310, 1610612736), (153, 452, 4194438, 1413509120), (161, 453, 4194438, 1393606112), (168, 453, 4194438, 1393606112), + (FIREBALL, 454, 4194438, 1381806976), ]; +/// The gametic the imp's `A_TroopAttack` runs on. Its target stands far +/// past `MELEERANGE`, so the routine throws a fireball. +const FIREBALL: u32 = 169; + const THRUST: [(u32, usize, i32, i32, i32, i32); 4] = [ (142, 118, 8272000, -4310912, 0, 0), (142, 258, 13992912, 4297488, 0, 0), @@ -573,6 +579,18 @@ async fn the_tic_matches_the_engine_where_the_fixture_reaches() { "the imp winding up its attack at gametic {tic}" ); } + // The routine turns the imp and then wants a missile, which is the + // branch this tic does not spawn. + assert_eq!( + at(FIREBALL).unresolved, + 1, + "the tic the imp throws its fireball says it could not be produced" + ); + assert_eq!( + at(FIREBALL - 1).unresolved, + 0, + "and the tic before it is one the run carried through" + ); for (tic, slot, x, y, momx, momy) in THRUST { let row = at(tic); let place = if slot == 118 { 0 } else { 1 }; diff --git a/native/tests/sim_troop_live.rs b/native/tests/sim_troop_live.rs new file mode 100644 index 0000000..baba052 --- /dev/null +++ b/native/tests/sim_troop_live.rs @@ -0,0 +1,249 @@ +//! `A_TroopAttack` reached through a tic, against a real ClickHouse +//! server. +//! +//! `native/tests/sim_claw_live.rs` reads the routine itself against a +//! reader written from `p_enemy.c`. This reads what a tic does with it: +//! the frame the state cycle enters, the angle and the flags it leaves on +//! the attacker, and the damage that reaches the target. +//! +//! `demo3` reaches the routine once and the imp throws a fireball, so the +//! claw is seeded: an imp put beside another imp, one tic from the frame +//! the routine sits on. +//! +//! Every arm is a row seeded into one session, because a session pays the +//! tic statement's analysis once. +//! +//! Needs a reachable ClickHouse (`CLICKHOUSE_HOST` / `CLICKHOUSE_HTTP_PORT` +//! / `CLICKHOUSE_PASSWORD`, defaulting to `localhost:8123` with no +//! password). Behind the `clickhouse-tests` feature, so a run without a +//! server visibly excludes them. +#![cfg(feature = "clickhouse-tests")] + +use clickdoom_native::sql::sim; +use clickdoom_native::sql::sim::tick::Input; +use clickdoom_native::{load, sql, wad::Wad}; +use clickhouse::Row; +use serde::Deserialize; + +mod support; + +use support::db::Fixture; +use support::seed; + +/// The tic every arm copies its row from. Gametic 40 is early enough that +/// no monster has woken and the list still holds the level's own things. +const BEFORE: u32 = 40; + +/// The imp that runs the routine and the imp it swings at. Both are +/// `MT_TROOP` on the level's own list, and at gametic 40 both stand +/// still. +const ATTACKER: usize = 116; +const TARGET: usize = 117; + +/// `states.tsv`: the frame carrying `A_FaceTarget`, and the frame after it +/// carrying `A_TroopAttack`. Seeding the first with one tic of wait left +/// puts the routine on the tic the arm runs. +const FACE: i32 = 453; +const ATTACK: i32 = 454; + +/// `p_local.h` +const BASETHRESHOLD: i32 = 100; + +/// `p_mobj.h` +const MF_AMBUSH: i64 = 32; + +/// The angle `R_PointToAngle2` gives for a target due west. The octant it +/// lands in counts down from half a turn, so the answer is a unit short of +/// it. +const DUE_WEST: u32 = 0x7fff_ffff; + +/// How far from its target each arm stands the attacker. `MELEERANGE` is +/// sixty four units and `P_CheckMeleeRange` measures against that less +/// twenty, plus the target's radius. Four units is inside the claw's +/// reach and close enough that the two share a sector whatever the map +/// looks like there; four hundred is outside it. +const NEAR: i64 = 4 * 65536; +const FAR: i64 = 400 * 65536; + +/// One arm per seeded row: its name, where the copy of `BEFORE` lands and +/// how far the attacker stands from its target. The tics are far apart so +/// the arms cannot read each other's rows. +/// +/// `ambush` stands where `claw` stands and differs from it by one flag, so +/// what it reads is the flag and nothing else. +const ARMS: [(&str, u32, i64); 3] = [ + ("claw", 200, NEAR), + ("fireball", 300, FAR), + ("ambush", 400, NEAR), +]; + +#[derive(Row, Deserialize)] +struct Clawed { + tic: u32, + state: i32, + angle: u32, + attacker_flags: i32, + health: i32, + hunts: u32, + threshold: i32, + prndindex: u8, + unresolved: u8, +} + +/// A column of one slot replaced, leaving every other slot alone. +fn put(column: &'static str, slot: usize, value: String, cast: &str) -> (&'static str, String) { + ( + column, + format!( + "arrayMap((v, k) -> {cast}(if(k = {slot}, {value}, v)), \ + p.{column}, arrayEnumerate(p.{column}))" + ), + ) +} + +#[tokio::test] +async fn a_tic_carries_the_imps_attack_through() { + let bytes = support::doom1(); + let wad = Wad::parse(&bytes).unwrap(); + let fixture = Fixture::create("sim_troop").await; + let db = fixture.database.clone(); + + let mut plan = load::plan(&db, &wad); + plan.extend(sql::level_statements(&db, support::MAP, support::DEMO)); + plan.extend(sim::load_statements(&db)); + plan.push(sim::tick::demo_statement(&db, 1, BEFORE)); + if let Err(error) = fixture.execute(&plan).await { + fixture.finish().await; + panic!("{error}"); + } + + let mut statements: Vec = Vec::new(); + for (name, at, away) in ARMS { + let flags = if name == "ambush" { MF_AMBUSH } else { 0 }; + let overrides = [ + // The attacker beside its target, in the frame whose next + // carries the routine, with one tic of wait left on it. + put( + "m_x", + ATTACKER, + format!("p.m_x[{TARGET}] + {away}"), + "toInt32", + ), + put("m_y", ATTACKER, format!("p.m_y[{TARGET}]"), "toInt32"), + put("m_z", ATTACKER, format!("p.m_z[{TARGET}]"), "toInt32"), + put("m_state", ATTACKER, FACE.to_string(), "toInt32"), + put("m_tics", ATTACKER, "1".to_owned(), "toInt32"), + put("m_target", ATTACKER, TARGET.to_string(), "toUInt32"), + put( + "m_flags", + ATTACKER, + format!("bitOr(toInt64(p.m_flags[{ATTACKER}]), {flags})"), + "toInt32", + ), + // Nothing else has this one's attention, so what the claw + // leaves is the whole of what moves its pointer. + put("m_threshold", TARGET, "0".to_owned(), "toInt32"), + ]; + statements.extend( + seed::row(&db, at, BEFORE, &overrides) + .into_iter() + .map(sql::Statement::sql), + ); + statements.push(sim::tick::run_statement( + &db, + &[Input::keys(at + 1, 0, (0, 0))], + )); + } + if let Err(error) = fixture.execute(&statements).await { + fixture.finish().await; + panic!("{error}"); + } + + let wanted: Vec = ARMS + .iter() + .flat_map(|(_, at, _)| [at.to_string(), (at + 1).to_string()]) + .collect(); + let rows: Vec = fixture + .rows(&format!( + "SELECT tic, m_state[{ATTACKER}] AS state, m_angle[{ATTACKER}] AS angle, \ + m_flags[{ATTACKER}] AS attacker_flags, m_health[{TARGET}] AS health, \ + m_target[{TARGET}] AS hunts, m_threshold[{TARGET}] AS threshold, \ + prndindex, unresolved \ + FROM {db}.native_state WHERE tic IN ({}) ORDER BY tic", + wanted.join(", ") + )) + .await; + fixture.finish().await; + assert_eq!( + rows.len(), + ARMS.len() * 2, + "a seeded row and a tic from it for every arm" + ); + let at = |tic: u32| { + rows.iter() + .find(|row| row.tic == tic) + .unwrap_or_else(|| panic!("no row for tic {tic}")) + }; + + // The imp within reach claws its target. + let (before, after) = (at(200), at(201)); + assert_eq!( + before.state, FACE, + "the seeded row is a tic from the routine" + ); + assert_eq!(after.state, ATTACK, "and the cycle reaches it"); + assert_eq!(after.unresolved, 0, "the claw is a branch this runs"); + let taken = before.health - after.health; + assert!( + (3..=24).contains(&taken) && taken % 3 == 0, + "the damage is three times one to eight: {taken}" + ); + assert_ne!( + after.prndindex, before.prndindex, + "and the damage draws for itself" + ); + // `A_FaceTarget` runs first, and the target stands four units back + // along the x axis, so the attacker ends up pointing straight down + // it. + assert_eq!(after.angle, DUE_WEST, "the attacker turns onto its target"); + // `P_DamageMobj` turns a thing with nothing else on its mind onto + // whatever hit it, which is how the claw is told apart from a hit + // that only moved a number. + assert_eq!( + after.hunts, ATTACKER as u32, + "the target turns on the imp that clawed it" + ); + assert_eq!( + after.threshold, BASETHRESHOLD, + "and holds that for the threshold's worth of tics" + ); + + // The imp out of reach throws a fireball, which this tic does not + // spawn. + let (before, after) = (at(300), at(301)); + assert_eq!(after.state, ATTACK, "the cycle reaches the routine"); + assert_eq!( + after.health, before.health, + "nothing reaches a target four hundred units away" + ); + assert_eq!( + after.unresolved, 1, + "and the fireball says the tic could not be produced" + ); + + // `A_FaceTarget` takes the thing off ambush, and the tic carries that + // through with the rest of what the routine leaves. + let (before, after) = (at(400), at(401)); + assert_eq!( + before.attacker_flags & MF_AMBUSH as i32, + MF_AMBUSH as i32, + "the seeded row puts the attacker on ambush" + ); + assert_eq!( + after.attacker_flags & MF_AMBUSH as i32, + 0, + "and the routine takes it off" + ); + assert_eq!(after.unresolved, 0, "on a tic that runs"); + assert!(before.health - after.health > 0, "and the claw still lands"); +} From 71097119f85716d20772966a45a3201ea5012176 Mon Sep 17 00:00:00 2001 From: Marcus Kainth Date: Sat, 5 Sep 2026 00:53:03 +0100 Subject: [PATCH 2/2] native: say which things carry MF_SHADOW on this map A_Chase's face-target comment claimed nothing on the map carries the flag. E1M7 has a spectre. What is true is that no target of a face-target carries it, because monsters face the player and the player has no blur sphere, and that is what makes the refusal beside it unreachable rather than the flag being absent. --- native/src/sql/sim/enemy.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/native/src/sql/sim/enemy.rs b/native/src/sql/sim/enemy.rs index 022ecad..5278c0e 100644 --- a/native/src/sql/sim/enemy.rs +++ b/native/src/sql/sim/enemy.rs @@ -872,9 +872,11 @@ fn chased(state: &Chasing<'_>) -> String { ), ); // `A_FaceTarget` points the thing at what it is about to attack and - // takes it off ambush. A target it cannot see clearly turns the angle - // by a random amount, which is two draws; nothing on this map carries - // that flag and `cs_stuck` refuses one that does. + // takes it off ambush. A target carrying `MF_SHADOW` turns the angle + // by a random amount instead, which is two draws, and `cs_stuck` + // refuses that tic. No target of a face-target on this map carries the + // flag, because monsters face the player and the player has no blur + // sphere. let target = |array: &str| format!("{array}[{}[k]]", state.m_target); value( "cc_faced",