Skip to content

native: let the zombieman and the shotgun guy shoot - #478

Open
MarcusKainth wants to merge 1 commit into
mainfrom
native/hitscan-monsters
Open

native: let the zombieman and the shotgun guy shoot#478
MarcusKainth wants to merge 1 commit into
mainfrom
native/hitscan-monsters

Conversation

@MarcusKainth

@MarcusKainth MarcusKainth commented Sep 5, 2026

Copy link
Copy Markdown
Owner

What this changes, and why

Part of #376. A_PosAttack and A_SPosAttack are what the zombieman and the
shotgun guy do when their state cycle reaches an attack, and demo3 reaches
the first at gametic 300 and the second at 612.

Both share a shape: A_FaceTarget, one P_AimLineAttack at the angle the face
left, then P_LineAttack once for the zombieman and three times for the shotgun
guy. Each shot turns the angle by two numbers and rolls ((P_Random()%5)+1)*3
from a third.

The shots are a fold, not a map. The aim draws nothing of its own, but the
puff or blood spot a shot leaves draws four numbers before the next shot turns
its own angle, and a shot that reaches a thing draws for the damage on top. How
many numbers a shot draws depends on what the shot before it reached, so the
count has to be threaded. Step zero of that fold is the aim and every step after
it is a shot, which puts P_LineAttack's walk, its intercepts and their order in
the statement once rather than twice.

The asks are a fold too, so a tic with nothing shooting does not run the
body. That is the shape the cost model asks for when a stage holds a whole
P_LineAttack walk. The measurement below does not separate it from a map at
this size, and says so.

A_FaceTarget moves out of attack. Both routines begin with it, so the
angle and the ambush clear are written once and read by both.

What the answer carries is what the tic needs to apply: the angle and flags
the face left, the slope, what each shot reached as a shoot::reached answer,
what it rolled, and the two bases the caller hands to mobj::spawn_debris and
inter::damage_mobj.

Two paths leave the call stuck rather than guessed. A special line a shot
crossed is P_ShootSpecialLine, which is not written. A shot that kills
something a later shot of the same call would have reached changes what that
shot is told, and the shots are worked out against the world as the tic left it.
A_PosAttack fires once and reaches neither.

Where this delivers less than #376 asks for. Nothing calls the primitive
yet, so the resident statement does not hold the walk and native diff stops
where it did. A_CPosAttack is not here: E1M7 carries no chaingunner.

Evidence

ClickHouse 26.8.2.7 (the digest docker-compose.yml pins) in a throwaway
container of this lane's own on port 18138, docker/clickhouse/config.d and
users.d mounted as the compose file does. The machine was not quiet, so
scripts/machine-lock.sh was taken and given back around each timed run and the
arms were interleaved.

What the engine's own trace and ELF say about the draws

refemu's random-call log names the caller of every draw demo3 makes.
Gametic 300, a zombieman firing, and gametic 612, a shotgun guy:

300  0  A_PosAttack    92     the first of the spread
300  1  A_PosAttack   104     the second
300  2  A_PosAttack   132     the damage
300  3  P_SpawnPuff    36
300  4  P_SpawnPuff    48
300  5  P_SpawnMobj   188
300  6  P_SpawnPuff   104

612  3  A_SPosAttack  116     shot one
612  4  A_SPosAttack  128
612  5  A_SPosAttack  140
612  6..9   the puff
612 10  A_SPosAttack  116     shot two
612 11  A_SPosAttack  128
612 12  A_SPosAttack  140
612 13..16 the puff
612 17  A_SPosAttack  116     shot three

Three numbers a shot, the puff's four between one shot and the next, and nothing
at all from P_AimLineAttack. That is the order the fold puts them in.

The offsets name the instructions. A_PosAttack at 80016e98, in the toolchain
image the ROM is built with:

$ riscv-none-elf-objdump -d --disassemble=A_PosAttack build/doom-rv32im.elf
80016eb8:  jalr  -192(ra) # 80016df4 <A_FaceTarget>
80016ecc:  sw    a5,8(sp)              # actor->angle, kept
80016ed4:  jalr  1252(ra) # 8001d3b4 <P_AimLineAttack>
80016ef0:  jalr  1524(ra) # 800144e0 <P_Random>
80016ef4:  mv    s1,a0                 # +0x5c = 92, the first answer kept
80016efc:  jalr  1512(ra) # 800144e0 <P_Random>
80016f00:  lw    a5,8(sp)              # +0x68 = 104
80016f04:  sub   a1,s1,a0              # first - second
80016f08:  slli  a1,a1,0x14            # << 20
80016f0c:  add   a1,a1,a5              # angle + that
80016f18:  jalr  1484(ra) # 800144e0 <P_Random>
80016f1c:  lui   a5,0x66666            # +0x84 = 132, the damage roll

The earlier draw is the left operand, the shift is twenty, and the aim runs
before any of the three and draws nothing. A_SPosAttack at 80016f7c emits
the same three instructions inside its loop, with the aim's slope held in s5
across all three shots:

80016fd4:  jalr  996(ra) # 8001d3b4 <P_AimLineAttack>
80016fdc:  mv    s5,a0                 # the slope, held across the loop
80016fec:  jalr  1272(ra) # 800144e0 <P_Random>
80016ff0:  mv    s0,a0                 # +0x74 = 116
80016ff8:  jalr  1260(ra) # 800144e0 <P_Random>
80016ffc:  sub   s0,s0,a0              # +0x80 = 128, first - second
80017004:  jalr  1248(ra) # 800144e0 <P_Random>
80017008:  mulh  a5,a0,s2              # +0x8c = 140, the damage roll
80017014:  slli  s0,s0,0x14
80017018:  add   s0,s0,s3              # bangle + that
80017058:  bnez  s4,80016fe8           # three times round

The ELF is the pinned one:

$ shasum -a 256 rom/build/doom-rv32im.bin
9a6a47d01119f67580e48e9875207186c25efd56ff93019df331eb307cfaa5d9
$ cat rom/PINNED_HASH
9a6a47d01119f67580e48e9875207186c25efd56ff93019df331eb307cfaa5d9

The gunshots against the engine

sim_gunshot_live puts three of E1M7's own zombiemen and three of its shotgun
guys onto the player, leaves one of each with no target, and fires at three
random indices and two draw offsets, with and without MF_SHADOW on the player.
Every number is compared against native/tests/support/attacks.rs, a reader
written from p_enemy.c.

The answer carries no shot angle, so where the shots went is checked by asking
the same walk for the angles the reader worked out and comparing what it
reached. What each shot reaches, and what its damage call draws, are what
sim_hitscan_live and sim_damage_live check; the reader takes those as given
and follows the face, the spread, the damage roll and the order the three run in.

$ CLICKHOUSE_HTTP_PORT=18138 cargo test -q -p clickdoom-native \
    --features clickhouse-tests --test sim_gunshot_live
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.27s

The fan's own coverage check fails the run unless it reaches every arm. Its
counts, read by raising one of its thresholds:

the fan reaches every arm: nothing 0, wall 82, thing 62, one shot 36, three shots 36, no target 24, fuzzy 54

A shot that reaches nothing at all is not among them, and the check asserts that:
the level is closed and MISSILERANGE is longer than any sight line on it, so
every shot ends on a wall or a thing.

The test fails when the behaviour breaks

Five breaks, each one line of the generator.

The spread's two draws swapped:

thread 'a_gunshot_leaves_what_the_engine_leaves' panicked at native/tests/sim_gunshot_live.rs:173:17:
assertion `left == right` failed: shadow false, index 0, base 0: where the shots went
  left: [(2, 40), (1, 782), (2, 51), (1, 491), (1, 491), (1, 491), (2, 9), (2, 9), (2, 9), (1, 63), (1, 63), (2, 25)]
 right: [(2, 40), (1, 782), (1, 863), (1, 491), (1, 486), (1, 486), (2, 9), (2, 9), (2, 9), (1, 62), (2, 25), (1, 63)]

The damage rolled from the first number rather than the third:

assertion `left == right` failed: shadow false, index 0, base 0: a zombieman firing at the player
  left: Gunned { ..., damage: [12], spawn_base: [3], hurt_base: [7], draws: 8 }
 right: Gunned { ..., damage: [3], spawn_base: [3], hurt_base: [7], draws: 8 }

The shotgun guy firing once:

assertion `left == right` failed: a shotgun guy firing at the player fires what the routine fires
  left: 1
 right: 3

The puff's four numbers not counted:

assertion `left == right` failed: shadow false, index 0, base 0: a zombieman firing at the player
  left: Gunned { ..., spawn_base: [3], hurt_base: [3], draws: 4 }
 right: Gunned { ..., spawn_base: [3], hurt_base: [7], draws: 8 }

The face's own two numbers not counted:

assertion `left == right` failed: shadow true, index 0, base 0: a zombieman firing at the player
  left: Gunned { ..., damage: [3], spawn_base: [3], hurt_base: [7], draws: 8 }
 right: Gunned { ..., damage: [6], spawn_base: [5], hurt_base: [9], draws: 10 }

What it costs, and what the second walk will cost

A throwaway container of this lane's own on port 18138 at the digest
docker-compose.yml pins, the machine lock taken and given back around each
run, three rounds, interleaved arm by arm, every value the minimum of its
rounds. analysis is QueryAnalysisMicroseconds from system.query_log, and
the tic is the query's own CPU time with parsing, analysis, planning and
pipeline building taken off, over the tics the window holds.

The last two rows are copies of the statement that wire the primitive into the
tic, one over an empty ask list and one over a single ask every tic. The copies
are for the measurement only; no generator code changed for them.

resident analysis tic over 60 tic over 240
main at d824fc9 1,073,633 23.53 s 39.30 ms 32.75 ms
the gunshot 1,074,137 23.90 s 39.04 ms 33.01 ms
wired, nothing shooting 1,150,586 26.18 s 40.91 ms 33.60 ms
wired, one shooting a tic 1,150,696 26.11 s 41.03 ms 33.35 ms

The four rows were measured against main at d824fc9, before the rebase onto
f2818a7. Nothing in the generator moved in the rebase, so the sizes and the
tics stand; what the rebase added is three fields filled in a struct literal,
described below.

The pull request itself adds 504 bytes, which is the two routine ids the ask
switches on, and the tic sits below main on the shorter window and 0.26 ms
above it on the longer, inside the 0.5 ms a change is allowed.

Wiring it adds about 77,000 bytes and 2.6 s of analysis, and between 0.6 and
1.7 ms a tic. The two wired rows do not separate on this instrument: the rounds
are 1 to 3 ms apart, which is the size of what is being measured, so what the
fold over the asks saves on a tic with nothing shooting is below what these
rounds resolve. The same wiring with the body in a map, measured in an earlier
sweep against that sweep's own main, read 1.45 ms above it against this one's
0.60, which points the same way without settling it.

Every round, as analysis / cpu tic:

  base   240 26.42/34.61 23.71/32.95 24.19/32.75
  base    60 26.21/40.39 24.83/39.30 23.53/40.78
  gun    240 25.67/34.76 24.39/33.01 25.91/33.63
  gun     60 25.10/41.05 23.90/39.04 26.98/40.67
  fold0  240 29.08/40.84 26.18/33.60 28.63/34.76
  fold0   60 28.14/43.31 27.09/40.91 29.03/41.93
  fold1  240 28.48/33.70 26.11/33.37 27.16/33.35
  fold1   60 27.30/42.44 26.25/41.03 28.05/41.87

Checks

$ make lint
EXIT=0

The lane's own suites and the ones that read the same walk or the same face:

sim_gunshot_live  ok. 1 passed  in 5.69s
sim_claw_live     ok. 1 passed  in 2.21s
sim_troop_live    ok. 1 passed  in 125.49s
sim_parity_live   ok. 1 passed  in 37.53s
$ CLICKHOUSE_HTTP_PORT=18138 cargo test -p clickdoom-native --features clickhouse-tests
EXIT=0

290 tests over the unit suite and every live suite, on a throwaway container at
the pinned digest.

$ make native-smoke
EXIT=0
frame 40 fb_hash=2eb87849ee6d9714 frames=3 render=19.9ms total=4.39s

The state rows over 300 tics, against main at f2818a7 from a fresh load
each, as hex(cityHash64(...)) over the rows ordered by tic, with each side's
binary built at the commit it names:

main    rows: 300  hash: C229DB96CF572921
branch  rows: 300  hash: C229DB96CF572921

Both stop at tic 169 on the same prndindex, which is the divergence main
already has.

Invariants

None of the PUR-N rules move. The gunshot reads the state row, the level
tables and the pinned constants; nothing reads a clock or a host value, and the
random numbers it draws are the engine's own, at the offsets p_enemy.c makes
them. scripts/check_purity.sh runs inside make lint above.

Spec impact

  • None. No contract in SPEC.md is touched

Checks

  • make gates. The suites run instead are named above
  • make native-smoke, if native/ or driver/src/native/ changed
  • No AI attribution trailers in the commits

Anything else

face_target moves the face out of attack, and the same refactor widens
attacks::Attacking with m_z, m_height and m_health, which the gunshot
reads for the shot's own origin. The rebase onto f2818a7 fills those three
fields at the one Attacking literal mobj.rs has, from mk_m_z, m_height
and m_health, the same arrays the inter::Hurting literal below it reads.
attack_fold reads none of the three, so the claw is unchanged and
sim_troop_live and sim_parity_live pass above.

The wiring lane W needs. attacks::hitscan(asks, world, targets, hurting) takes
(slot, routine, base) asks, where routine is state_action[1 + state] for
the frame the cycle entered. The answer's SHOTS, DAMAGE, SPAWN_BASE and
HURT_BASE run together, one entry per shot: a shot goes to
mobj::spawn_debris with its own base and, where it reached a thing, to
inter::damage_mobj with the other. DRAWS counts all of it, so whatever the
tic does after the call reads that rather than a constant.

shoot::traverse is asked once for the aim and every shot together, so wiring
this adds one walk to the statement and not one per shot.

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 5, 2026
@MarcusKainth
MarcusKainth marked this pull request as ready for review September 5, 2026 02:05
@MarcusKainth
MarcusKainth force-pushed the native/hitscan-monsters branch from 4694e9d to b36ddd8 Compare September 5, 2026 02:05
`A_PosAttack` and `A_SPosAttack` share a shape: `A_FaceTarget`, one
`P_AimLineAttack` at the angle the face left, then `P_LineAttack` once for
the zombieman and three times for the shotgun guy. Both are one primitive
over a list of asks, keyed by the routine the frame carries.

Each shot turns the angle by two numbers, the earlier one on the left, and
rolls `((P_Random()%5)+1)*3` from a third. The aim draws none of its own,
and the puff or blood spot a shot leaves draws four more before the next
shot turns its own angle, so the shots are a fold: what a shot draws depends
on what the shot before it reached. Step zero of the fold is the aim and
every step after it is a shot, so `P_LineAttack`'s walk, its intercepts and
their order are in the statement once.

The answer carries what the face left, the slope, what each shot reached as
a `shoot::reached` answer, what it rolled, and the two bases the caller
hands to `mobj::spawn_debris` and `inter::damage_mobj`.

`A_FaceTarget` moves out of `attack` into a builder both read, so the angle
and the ambush clear are written once.

Two paths leave the call stuck rather than guessed: a special line a shot
crossed, which is `P_ShootSpecialLine`, and a shot that kills something a
later shot of the same call would have reached, which changes what that
shot is told.

`shoot::REACHED_TYPE` names the answer's type, which a caller carrying a
list of them through a fold needs.
@MarcusKainth
MarcusKainth force-pushed the native/hitscan-monsters branch from b36ddd8 to 548d9c7 Compare September 5, 2026 02:14
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