From 04bb83a8d91fdb659f21f58088473a9bcbfbef2b Mon Sep 17 00:00:00 2001 From: Marcus Kainth Date: Sat, 5 Sep 2026 02:21:51 +0100 Subject: [PATCH] native: throw the imp's fireball from the tic that reaches it A_TroopAttack's missile branch reached P_SpawnMissile and the tic refused it. The strike now builds a throwing ask from the attack's own answer and folds missile::spawn over it, and the thing it makes goes on the end of the mobj arrays the way a spawn does: born_column for every column but the two the caller assigns, the next link order for m_linkseq, the slot the compaction leaves for m_id, and one of each counter. missile::spawn_fold is new, for the same reason inter::damage_fold is: a map runs every function in its body once even on an empty list, and the body here is P_SpawnMissile and the move test under it. The fireball goes at the end because P_AddThinker appends, and the pointer it carries to the imp that threw it is renumbered with every other pointer the compaction moves. --- native/src/sql/sim/missile.rs | 21 ++++++++ native/src/sql/sim/mobj.rs | 79 +++++++++++++++++++++++++---- native/src/sql/sim/tick.rs | 2 +- native/tests/sim_parity_live.rs | 90 +++++++++++++++++++++++++++++---- native/tests/sim_troop_live.rs | 33 +++++++++--- 5 files changed, 199 insertions(+), 26 deletions(-) diff --git a/native/src/sql/sim/missile.rs b/native/src/sql/sim/missile.rs index e772cba..1896d03 100644 --- a/native/src/sql/sim/missile.rs +++ b/native/src/sql/sim/missile.rs @@ -142,6 +142,27 @@ pub fn spawn( ) } +/// [`spawn`] over an ask list, folded rather than mapped, as an array of +/// [`thrown`] tuples. +/// +/// A map runs every function in its body once even on an empty list, and +/// this body is `P_SpawnMissile` and the move test under it. A fold runs +/// its body only where the list has an element, so a tic that throws +/// nothing pays for the fold and nothing under it. +pub fn spawn_fold( + asks: &str, + world: &Throwing<'_>, + spawning: &mobj::Spawning<'_>, + map: &World<'_>, +) -> String { + let (values, body) = thrown(world, spawning, map); + format!( + "arrayFold((ms_held, ms_ask) -> arrayPushBack(ms_held, {}), {asks}, \ + CAST([] AS Array({THROWN_TYPE})))", + bind::chain_in("msa", &values, &body) + ) +} + /// What one missile works out, as the values a body reads and the /// [`thrown`] tuple it answers with. fn thrown( diff --git a/native/src/sql/sim/mobj.rs b/native/src/sql/sim/mobj.rs index 1c98846..06a53fd 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, attacks, enemy, inter, maputl, sight}; +use super::{State, attacks, enemy, inter, maputl, missile, sight}; use crate::sql::Statement; use crate::sql::bind; use crate::sql::fixed; @@ -102,6 +102,7 @@ pub fn constants(db: &str) -> Vec<(String, String)> { super::table_column(db, "mobjinfo", "flags"), ), ("mt_puff".to_owned(), thing_type(db, "MT_PUFF")), + ("mt_troopshot".to_owned(), thing_type(db, "MT_TROOPSHOT")), ("mt_blood".to_owned(), thing_type(db, "MT_BLOOD")), // The engine names the frames a puff and a blood spot are put into // by hand. No table holds them, so they are read off the spawn @@ -623,7 +624,7 @@ pub fn thinkers(state: &State) -> Vec<(String, String)> { } // The attack reads what the state cycle and the chase left, and what // it leaves stands over them. - for (name, expr) in strikes(state) { + for (name, expr) in strikes(state, &world) { bind(&name, expr); } bind( @@ -678,7 +679,8 @@ pub fn thinkers(state: &State) -> Vec<(String, String)> { /// pointer at the thing that was taken becomes 0, which is what the /// contract says none means. /// -/// `next_seq` counts what the level has ever spawned and does not move. +/// A fireball the tic threw goes on the end of the list, behind what the +/// compaction left, and takes one of each counter. fn removed(state: &State, player: &str) -> Vec<(String, String)> { let s = |column: &str| state.get(column); let mut bindings: Vec<(String, String)> = Vec::new(); @@ -726,9 +728,25 @@ fn removed(state: &State, player: &str) -> Vec<(String, String)> { } else { held }; + // `P_AddThinker` puts a new thing on the end of the list, so a + // fireball the tic threw goes behind what survived the compaction. + let born = match missile::born_column(column, "t") { + Some(value) => { + let value = if POINTERS.contains(&column) { + moved_slot(&value) + } else { + value + }; + format!("arrayMap(t -> {value}, mt_thrown)") + } + None => format!( + "arrayMap((t, i) -> toUInt32({} + i - 1), mt_thrown, arrayEnumerate(mt_thrown))", + s("next_linkseq") + ), + }; bind( &format!("now_{column}"), - format!("arrayFilter((v, a) -> a = 1, {held}, mt_kept)"), + format!("arrayConcat(arrayFilter((v, a) -> a = 1, {held}, mt_kept), {born})"), ); } bind( @@ -745,6 +763,13 @@ fn removed(state: &State, player: &str) -> Vec<(String, String)> { ); bind("now_p_attacker", moved_slot(&s("p_attacker"))); bind("now_p_mo", format!("toUInt32(mt_slot[{player}])")); + // Every thing the tic threw took one of each counter. + for column in ["next_seq", "next_linkseq"] { + bind( + &format!("now_{column}"), + format!("toUInt32({} + length(mt_thrown))", s(column)), + ); + } bindings } @@ -809,7 +834,7 @@ const POINTERS: [&str; 2] = ["m_target", "m_tracer"]; /// 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)> { +fn strikes(state: &State, map: &World<'_>) -> 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)); @@ -894,6 +919,38 @@ fn strikes(state: &State) -> Vec<(String, String)> { }; bind("mt_hurt", inter::damage_fold("mt_hurt_asks", &hurting)); + // `P_SpawnMissile` for an imp whose claw did not reach. The fireball + // is the only missile a routine throws here, so its type is the one + // constant. + bind( + "mt_throw_asks", + format!( + "arraySlice([(at_one, toUInt32(mk_m_target[greatest(at_one, 1)]), \ + mt_troopshot, at_base + toUInt32(at_struck.{}))], 1, at_struck.{})", + attacks::attacked::DRAWS, + attacks::attacked::THROWS, + ), + ); + let throwing = missile::Throwing { + m_x: "mk_m_x", + m_y: "mk_m_y", + m_z: "mk_m_z", + m_radius: &s("m_radius"), + m_height: &s("m_height"), + m_flags: "cq_m_flags", + prndindex: &s("prndindex"), + }; + let spawning = Spawning { + floorheight: &s("sec_floorheight"), + ceilingheight: &s("sec_ceilingheight"), + prndindex: &s("prndindex"), + skill: "skill", + }; + bind( + "mt_thrown", + missile::spawn_fold("mt_throw_asks", &throwing, &spawning, map), + ); + bind( "at_clawed", format!("toUInt8(at_struck.{})", attacks::attacked::CLAWED), @@ -905,21 +962,23 @@ fn strikes(state: &State) -> Vec<(String, String)> { bind( "at_draws", format!( - "toUInt32(toUInt32(at_struck.{}) + toUInt32(mt_hurt.{}))", + "toUInt32(toUInt32(at_struck.{}) + toUInt32(mt_hurt.{}) \ + + arraySum(arrayMap(t -> toUInt32(t.{}), mt_thrown)))", attacks::attacked::DRAWS, inter::hurt::DRAWS, + missile::thrown::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, + "toUInt8(length(mt_attackers) > 1 OR at_struck.{stuck} = 1 \ + OR mt_hurt.{counted} = 1 OR mt_hurt.{drop} != -1 \ + OR arrayExists(t -> t.{thrown} = 1, mt_thrown))", stuck = attacks::attacked::STUCK, counted = inter::hurt::COUNTED, drop = inter::hurt::DROP, + thrown = missile::thrown::STUCK, ), ); bindings diff --git a/native/src/sql/sim/tick.rs b/native/src/sql/sim/tick.rs index 2ef5447..6c00015 100644 --- a/native/src/sql/sim/tick.rs +++ b/native/src/sql/sim/tick.rs @@ -275,7 +275,7 @@ mod tests { #[test] fn each_caller_of_the_move_test_holds_one() { let sql = resident_statement("nat"); - assert_eq!(sql.matches("arrayMap(mv ->").count(), 4); + assert_eq!(sql.matches("arrayMap(mv ->").count(), 5); assert_eq!(sql.matches("arrayMap(clip ->").count(), 1); assert_eq!(sql.matches("arrayFold((move_at, move_step)").count(), 1); assert_eq!(sql.matches("arrayFold((cw_at, cw_step)").count(), 1); diff --git a/native/tests/sim_parity_live.rs b/native/tests/sim_parity_live.rs index 623e618..8bdf2ac 100644 --- a/native/tests/sim_parity_live.rs +++ b/native/tests/sim_parity_live.rs @@ -235,6 +235,36 @@ const ATTACK: [(u32, i32, i32, u32); 5] = [ /// past `MELEERANGE`, so the routine throws a fireball. const FIREBALL: u32 = 169; +/// The last thing on the list at [`FIREBALL`], read out of the reference +/// emulator's demo3 trace: `m_type`, `m_x`, `m_y`, `m_z`, `m_momx`, +/// `m_momy`, `m_momz`, `m_angle`, `m_target`, `m_state`, `m_tics` and +/// `m_flags`. `MT_TROOPSHOT` is thing type 31 and the imp that threw it is +/// slot 119. +/// +/// `P_AddThinker` puts a new thing on the end of the list and +/// `P_RunThinkers` walks that list to its end, so the engine runs the +/// fireball's own thinker on the tic it was thrown. The `x`, `y` and +/// `tics` here are what that thinker left. What `P_SpawnMissile` and +/// `P_CheckMissileSpawn` leave is one momentum step behind it, with one +/// tic more on the clock. +const THROWN: [i64; 12] = [ + 31, + 21_353_709, + -8_314_002, + 2_097_152, + -285_410, + 589_940, + 0, + 1_381_806_976, + 119, + 97, + 1, + 67_088, +]; + +/// How many things stand on the list before the imp throws. +const ON_THE_LIST: u64 = 264; + const THRUST: [(u32, usize, i32, i32, i32, i32); 4] = [ (142, 118, 8272000, -4310912, 0, 0), (142, 258, 13992912, 4297488, 0, 0), @@ -345,6 +375,8 @@ struct Walked { state119: i32, flags119: i32, angle119: u32, + things: u64, + shot: Vec, } async fn walked(fixture: &Fixture, db: &str) -> Vec { @@ -375,7 +407,12 @@ async fn walked(fixture: &Fixture, db: &str) -> Vec { arrayMap(k -> m_momx[k], [118, 258]) AS thrust_momx, \ arrayMap(k -> m_momy[k], [118, 258]) AS thrust_momy, \ p_extralight AS extralight, m_state[119] AS state119, \ - m_flags[119] AS flags119, m_angle[119] AS angle119 \ + m_flags[119] AS flags119, m_angle[119] AS angle119, \ + toUInt64(length(m_x)) AS things, \ + arrayMap(a -> toInt64(a[length(a)]), \ + [m_type, m_x, m_y, m_z, m_momx, m_momy, m_momz, \ + arrayMap(v -> toInt32(v), m_angle), arrayMap(v -> toInt32(v), m_target), \ + m_state, m_tics, m_flags]) AS shot \ FROM {db}.native_state ORDER BY tic" )) .await @@ -579,17 +616,52 @@ 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. + // The fireball the routine throws, on the end of the list. assert_eq!( - at(FIREBALL).unresolved, - 1, - "the tic the imp throws its fireball says it could not be produced" + at(FIREBALL - 1).things, + ON_THE_LIST, + "the list before the throw is the level's own" ); assert_eq!( - at(FIREBALL - 1).unresolved, - 0, - "and the tic before it is one the run carried through" + at(FIREBALL).things, + ON_THE_LIST + 1, + "and the throw puts one more thing on it" + ); + let shot = &at(FIREBALL).shot; + let (x, y, momx, momy) = (shot[1], shot[2], shot[4], shot[5]); + // Everything the spawn itself decides. + for (at, name) in [ + (0, "type"), + (3, "z"), + (4, "momx"), + (5, "momy"), + (6, "momz"), + (7, "angle"), + (8, "target"), + (9, "state"), + (11, "flags"), + ] { + assert_eq!(shot[at], THROWN[at], "the fireball's {name}"); + } + // The engine runs the fireball's own thinker on the tic it was thrown + // and this does not, so the point is one momentum step short of the + // probe's and the wait is one tic longer. + assert_eq!( + (x + momx, y + momy), + (THROWN[1], THROWN[2]), + "the fireball stands one step behind where the engine left it" + ); + assert_eq!( + shot[10], + THROWN[10] + 1, + "and one tic behind on its own clock" + ); + // Every number the throw draws is counted, which is what moved the + // first divergence off `prndindex`. + assert_eq!( + at(FIREBALL).prndindex, + 194, + "the tic draws what the engine draws" ); for (tic, slot, x, y, momx, momy) in THRUST { let row = at(tic); diff --git a/native/tests/sim_troop_live.rs b/native/tests/sim_troop_live.rs index baba052..6f626f8 100644 --- a/native/tests/sim_troop_live.rs +++ b/native/tests/sim_troop_live.rs @@ -49,6 +49,9 @@ const ATTACK: i32 = 454; /// `p_local.h` const BASETHRESHOLD: i32 = 100; +/// `mobjtype.tsv`: the fireball an imp throws. +const TROOPSHOT: i32 = 31; + /// `p_mobj.h` const MF_AMBUSH: i64 = 32; @@ -88,6 +91,9 @@ struct Clawed { threshold: i32, prndindex: u8, unresolved: u8, + things: u64, + last_type: i32, + last_target: u32, } /// A column of one slot replaced, leaving every other slot alone. @@ -168,7 +174,9 @@ async fn a_tic_carries_the_imps_attack_through() { "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 \ + prndindex, unresolved, toUInt64(length(m_x)) AS things, \ + m_type[length(m_type)] AS last_type, \ + m_target[length(m_target)] AS last_target \ FROM {db}.native_state WHERE tic IN ({}) ORDER BY tic", wanted.join(", ") )) @@ -218,17 +226,30 @@ async fn a_tic_carries_the_imps_attack_through() { "and holds that for the threshold's worth of tics" ); - // The imp out of reach throws a fireball, which this tic does not - // spawn. + // The imp out of reach throws a fireball, which goes on the end of the + // list. 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" + "no claw reaches a target four hundred units away" + ); + assert_eq!( + after.things, + before.things + 1, + "the throw puts one more thing on the list" + ); + assert_eq!( + after.last_type, TROOPSHOT, + "and the thing it puts there is a fireball" ); assert_eq!( - after.unresolved, 1, - "and the fireball says the tic could not be produced" + after.last_target, ATTACKER as u32, + "carrying a pointer back at the imp that threw it" + ); + assert_ne!( + after.prndindex, before.prndindex, + "and the spawn draws for itself" ); // `A_FaceTarget` takes the thing off ambush, and the tic carries that