Skip to content

native: run the imp's attack from the tic that reaches it - #477

Merged
MarcusKainth merged 2 commits into
mainfrom
native/troop-attack
Sep 5, 2026
Merged

native: run the imp's attack from the tic that reaches it#477
MarcusKainth merged 2 commits into
mainfrom
native/troop-attack

Conversation

@MarcusKainth

@MarcusKainth MarcusKainth commented Sep 4, 2026

Copy link
Copy Markdown
Owner

What this changes, and why

attacks::attack works out what A_TroopAttack and A_SargAttack leave.
Nothing called it. This is the call: the state cycle now enters a frame
carrying one of them instead of saying the tic could not be produced, and
what the routine answers with goes where it belongs.

  • the angle A_FaceTarget leaves, and the ambush flag it clears, over what
    the state cycle and the chase left;
  • the damage a claw that reaches does, through P_DamageMobj with the
    attacker as both the inflictor and the source, from attacks::claw_ask;
  • the draws, behind every look and every chase the tic already 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.

Both the routine and P_DamageMobj are folded over their ask lists rather
than mapped. attacks::attack_fold, attacks::no_attack and
inter::damage_fold are new for that. A map runs every function in its
body once even on an empty list and these two bodies are the largest the
statement carries, so a tic that reaches no attack would otherwise pay for
both in full.

mt_hurt_asks is named as the thinker stage's one P_DamageMobj ask
list, and its comment says so: the missile's impact, the barrel's blast
and a monster's hitscan join that list rather than each standing up a call
of its own. The hitscan inside the player fold stays where it is, because
a pellet's kill changes what the next pellet hits.

Three cases still 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 takes the fireball branch. P_AproxDistance from its imp to the
player is 407 map units when the routine runs at gametic 169, against a
melee bound of 60, so the claw cannot move the first divergence. That
stays at 169, on the two numbers the missile spawn draws, until the wiring
pull request above this one. What the tic does carry through at 169 is the
imp's frame, its angle and its flags, which sim_parity_live now reads
against the probe there.

The claw is seeded instead. sim_claw_live reads the routine itself
against a reader written from p_enemy.c; sim_troop_live reads what a
tic does with it, standing one MT_TROOP four units from another.

Limitations

The inter::Hurting world reads the target as the thinker stage has left
it so far: mk_m_x, mk_m_y, mk_m_z, the momentum the move and the
fall wrote, mk_m_state, mk_m_tics, mk_m_reactiontime,
mk_m_threshold, mk_m_target and the flags the chase left. m_health
and m_height have no writer ahead of this call and stand as the tic
started.

That is right for a target whose own thinker runs before the attacker's,
because the engine runs them in list order, and early for a target further
down the list. demo3's only claw target is the player, which is slot 1 and
always first, so nothing on this map measures the difference.

Evidence

The first divergence

Unchanged at gametic 169, which is what the fireball leaving the tic
unresolved means.

$ clickdoom native diff 400 --probe refemu/reference_traces/demo3/probe.9a6a47d01119.tsv
# native diff elapsed=42.2s tics=391 tics/s=9.3
clickdoom: error: tic 169 game slot 0 prndindex: 192 against the probe's 194

The probe's own prndindex moves 184 to 194 across that tic. This carries
eight of those ten; the two left are P_SpawnMissile's.

The imp through its attack

sim_parity_live's ATTACK fixture gains gametic 169 with the state,
flags and angle the probe carries there, and two checks on which tic
refuses.

+    (FIREBALL, 454, 4194438, 1381806976),
...
+    assert_eq!(at(FIREBALL).unresolved, 1, ...);
+    assert_eq!(at(FIREBALL - 1).unresolved, 0, ...);

$ cargo test -p clickdoom-native --features clickhouse-tests --test sim_parity_live
test the_tic_matches_the_engine_where_the_fixture_reaches ... ok
test result: ok. 1 passed; 0 failed; finished in 35.16s

The seeded claw

$ cargo test -p clickdoom-native --features clickhouse-tests --test sim_troop_live
test a_tic_carries_the_imps_attack_through ... ok
test result: ok. 1 passed; 0 failed; finished in 118.60s

The seeded arm fails when the wiring breaks

Four breaks, each applied on its own and reverted. Run against the mapped
shape; the assertions and the writebacks they reach are the same.

$ break: the angle the face leaves is not put back
thread 'a_tic_carries_the_imps_attack_through' panicked at native/tests/sim_troop_live.rs:208:5:
assertion `left == right` failed: the attacker turns onto its target
  left: 2147483648
 right: 2147483647

$ break: the flags the face clears are not put back
thread 'a_tic_carries_the_imps_attack_through' panicked at native/tests/sim_troop_live.rs:242:5:
assertion `left == right` failed: and the routine takes it off
  left: 32
 right: 0

$ break: the claw's answer never reaches the columns
thread 'a_tic_carries_the_imps_attack_through' panicked at native/tests/sim_troop_live.rs:197:5:
the damage is three times one to eight: 0

$ break: the fireball stops refusing the tic
thread 'a_tic_carries_the_imps_attack_through' panicked at native/tests/sim_troop_live.rs:229:5:
assertion `left == right` failed: and the fireball says the tic could not be produced
  left: 0
 right: 1

Cost

Measured on a throwaway ClickHouse 26.8.2.7 of this lane's own, started
from docker-compose.yml's digest with the same config.d and users.d,
against d824fc9 in the same session, three rounds interleaved, the
machine lock taken and given back around each measurement. tic is the
statement's wall clock less parsing, analysis, planning and pipeline
building, divided by the rows it wrote. The 60 tic window is idle; the 240
tic window runs the chase, the shot and the attack.

arm    window   tic (median)   analysis (median)   FunctionExecute/tic
main    60 tics     38.65 ms         21.62 s              13237
troop   60 tics     43.20 ms         27.87 s              13393
main   240 tics     34.46 ms         23.48 s              20971
troop  240 tics     35.41 ms         27.32 s              21074

The resident statement goes from 1,073,633 to 1,255,576 bytes.

The idle tic rises 4.6 ms, against a gate of 0.5 ms. The active tic
rises 1.0 ms.

Folding the two bodies is what the active figure bought. The same
measurement against the mapped shape read +4.2 ms idle and +2.9 ms active,
and the mapped shape put 252 extra function executions on an idle tic
where the folded one puts 156.

What is left is not the bodies. With both fold bodies replaced by the
answer an empty list gives, and everything else in place:

arm         tic       analysis    FunctionExecute/tic
main      38.42 ms     21.85 s          13237
troop     42.16 ms     27.07 s          13393
nobodies  41.18 ms     24.86 s          13353

So the two routines cost about 1 ms of the 3.7 that run measured, and the
other 2.8 ms is the work that has to be per slot: the mt_attackers
filter, the fourth sight ask list, and the writebacks for the angle, the
flags and the eleven columns a claw moves.

Folding the writebacks the same way does not give that back. A shape that
put all thirteen of those arrayMaps inside two arrayFolds, one over
the stage's hurt asks with the eleven held columns as its accumulator and
one over the slot the routine turned, measured the same as this branch on
three interleaved rounds against d824fc9: main 41.59 ms against troop
46.76 ms by arm medians, +4.4 ms paired, where this branch reads +4.6 ms.
A fold body does not run on an empty list, so those thirteen maps are not
what the idle tic was paying for. That shape is not in this branch, and
where the 2.8 ms does sit is still open.

mt_attackers and the fourth sight ask list are built from mt_cycles
and mt_next, which the tic already has, and from the attacker list
itself, so neither adds a pass over every slot.

The demo gate

Relative to main, in the same session, two rounds each.

r1-main    tics/s=34.9  late=7
r1-troop   tics/s=34.9  late=32
r2-main    tics/s=34.9  late=38
r2-troop   tics/s=34.9  late=34

Suites

$ make lint
make lint exit=0

$ cargo nextest run -p clickdoom-native --features clickhouse-tests
Summary [ 806.013s] 281 tests run: 281 passed, 0 skipped

$ make native-smoke
native-smoke exit=0

A second commit

A_Chase's face-target comment claimed nothing on this map carries
MF_SHADOW, and E1M7 has a spectre. The commit beside this one says what
is true instead: no target of a face-target carries it, because monsters
face the player and the player has no blur sphere.

Invariants

None.

Spec impact

  • None. No contract in SPEC.md is touched

Checks

  • make gates. Not run whole. make lint, the whole of
    clickdoom-native's test set against a live server, and
    make native-smoke ran instead, all by exit code
  • make native-smoke, if native/ or driver/src/native/ changed
  • No AI attribution trailers in the commits

Anything else

The idle tic is the part worth attention. Two shapes have been tried
against it: folding the two routine bodies, which bought the active tic,
and folding the writebacks, which bought nothing and is not here.

Written mostly by Claude Opus 5.

@github-actions github-actions Bot added the area: native Native mode: the tic simulation and renderer as SQL, and the WAD loader label Sep 4, 2026
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.
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.
@MarcusKainth
MarcusKainth marked this pull request as ready for review September 5, 2026 00:28
@MarcusKainth
MarcusKainth merged commit f2818a7 into main Sep 5, 2026
18 checks passed
@MarcusKainth
MarcusKainth deleted the native/troop-attack branch September 5, 2026 01:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: native Native mode: the tic simulation and renderer as SQL, and the WAD loader

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant