Skip to content

native: thread a hit's own target through the same-tic fold - #513

Merged
MarcusKainth merged 6 commits into
mainfrom
native/same-target-threading
Sep 7, 2026
Merged

native: thread a hit's own target through the same-tic fold#513
MarcusKainth merged 6 commits into
mainfrom
native/same-target-threading

Conversation

@MarcusKainth

@MarcusKainth MarcusKainth commented Sep 7, 2026

Copy link
Copy Markdown
Owner

What this changes, and why

damage_fold's own mobj fields - health, flags, state, tics, momentum,
reactiontime, target and threshold - read the tic-start arrays directly
rather than an accumulator. A second hit on a target this tic has
already hit once, whether two monsters' own shots on the player or a
shot and a claw, overwrote the first's own answer instead of building
on it, and DM_SAME_TARGET refused the tic for this rather than
committing the wrong shared answer. Two hitscan monsters firing in the
same tic, which is ordinary once the attacker cap lifts, would hit this
on every such tic.

inter::hurt gains HIT_RESULTS, an array parallel to the existing
HIT_TARGETS: each target's own latest mobj fields, in HEALTH
through THRESHOLD's own order. reach and damaged read a repeat
target's own carried answer from it instead of the tic-start arrays,
the same threading the player's own fields already ride.
hurt::SAME_TARGET and unresolved::DM_SAME_TARGET are gone, along
with the refusal that only ever fed them.

Threading exposed a second bug the refusal had been masking. The
scatter that applies an in-flight missile's own hit back onto the
target's mobj columns (removed's own missile scatter, and
thrown_thinks's own tk_hurt_at) picked the first missile in the
list whose own impact reached a slot, indexOf's own answer, not the
last. Two hits on the same target in one list now read the list in
reverse to find the one carrying the combined answer.

Evidence

Driver unit tests, clippy and formatting, on the final change:

$ cargo build --workspace
    Finished `dev` profile [unoptimized + debuginfo] target(s)

$ cargo clippy --workspace --all-targets --features clickhouse-tests
    Finished `dev` profile [unoptimized + debuginfo] target(s)

$ cargo fmt --check
(clean)

$ cargo test -p clickdoom-native --lib
test result: ok. 260 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Live, against a throwaway container:

$ CLICKHOUSE_HOST=localhost CLICKHOUSE_HTTP_PORT=18123 CLICKHOUSE_PASSWORD=clickdoom \
  cargo test -p clickdoom-native --features clickhouse-tests \
  --test sim_player_damage_live -- --test-threads 1
test a_claw_reaches_the_player_through_its_own_armour ... ok
test a_fireball_reaches_the_player_through_its_own_armour ... ok
test an_invulnerable_player_is_still_pushed_but_not_hurt ... ok
test two_fireballs_in_one_tic_thread_the_player_through_both ... ok

$ CLICKHOUSE_HOST=localhost CLICKHOUSE_HTTP_PORT=18123 CLICKHOUSE_PASSWORD=clickdoom \
  cargo test -p clickdoom-native --features clickhouse-tests \
  --test sim_kills_and_drops_live --test sim_gunshot_kill_drop_live \
  --test sim_missile_kill_drop_live -- --test-threads 1
test a_claw_that_kills_a_zombieman_counts_it_and_drops_a_clip ... ok
test a_pistol_shot_that_kills_a_zombieman_counts_it_and_drops_a_clip ... ok
test an_in_flight_fireball_that_kills_a_zombieman_counts_it_and_drops_a_clip ... ok

$ ./target/release/clickdoom native load --fresh --host localhost --port 18123 \
  --password clickdoom --database clickdoom_native
$ ./target/release/clickdoom native load \
  --probe refemu/probe/fixtures/demo3-frames.9a6a47d01119.tsv \
  --host localhost --port 18123 --password clickdoom --database clickdoom_native
$ ./target/release/clickdoom native render --frame 40 --expect-probe-fbhash \
  --host localhost --port 18123 --password clickdoom --database clickdoom_native
frame 40 fb_hash=2eb87849ee6d9714 frames=3 render=16.9ms total=2.93s

two_fireballs_in_one_tic_thread_the_player_through_both is the arm
this PR's own fix reaches: two fireballs landing on the player in the
same mt_missile_thoughts list, both seeded from the real gametic 205
probe row (native/tests/fixtures/demo3-player-damage.tsv) plus a
repurposed corpse on the same heading. Before this change it asserted
the gap directly - the shared m_health field disagreeing with the
player's own p_health, and DM_SAME_TARGET set. It now asserts the
two agree and the tic resolves, which is the one place the scatter fix
also had to land: without it, the shared field carried the first
hit's own answer rather than both, agreeing with neither the old
broken value nor the new correct one, and the arm still failed. The
other three live suites are unaffected call sites, rerun to confirm the
threading change carries nothing else's own behaviour with it.

Cherry-picked onto origin/main after retargeting the PR's base:
native/kills-and-drops merged as #509, so this branch's own six
commits (c422c50 through 1852d9b) moved onto origin/main at
fcb39a9, before #511 (c853d2f) landed - the merge script rebases
past it. One conflict, native/src/sql/sim/mod.rs's UNRESOLVED_BITS:
origin/main already carries DM_SAME_TARGET and
PLAT_NEXT_HIGHEST_OVERFLOW (from #509 and #522), and this branch's
own first commit removes DM_SAME_TARGET in favour of the threading
this PR adds. Kept PLAT_NEXT_HIGHEST_OVERFLOW, dropped
DM_SAME_TARGET's const and array entry, array size 36 to 35.

native load --fresh and the full suite, against the throwaway
container:

$ cargo test -p clickdoom-native --lib
test result: ok. 267 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

$ CLICKHOUSE_HOST=localhost CLICKHOUSE_HTTP_PORT=18123 CLICKHOUSE_PASSWORD=clickdoom \
  scripts/test-group.sh native-sim-c
9 tests run: 9 passed (2 slow), 0 skipped

$ CLICKHOUSE_HOST=localhost CLICKHOUSE_HTTP_PORT=18123 CLICKHOUSE_PASSWORD=clickdoom \
  scripts/test-group.sh native-sim-g
30 tests run: 30 passed (5 slow), 0 skipped

$ CLICKHOUSE_HOST=localhost CLICKHOUSE_HTTP_PORT=18123 CLICKHOUSE_PASSWORD=clickdoom \
  cargo test -p clickdoom-native --features clickhouse-tests \
  --test sim_missile_same_target_live --test sim_missile_wall_live \
  --test sim_player_damage_live --test sim_justattacked_live -- --nocapture
test two_fireballs_in_one_list_thread_a_zombieman_through_both ... ok
test a_missile_that_hits_a_wall_reserves_its_own_explosion_draw ... ok
test a_thing_that_just_attacked_does_not_attack_again ... ok
test two_fireballs_in_one_tic_thread_the_player_through_both ... ok
test an_invulnerable_player_is_still_pushed_but_not_hurt ... ok
test a_fireball_reaches_the_player_through_its_own_armour ... ok
test a_claw_reaches_the_player_through_its_own_armour ... ok

native-sim-c and native-sim-g are the two groups CI runs this
branch's own named suites under; sim_missile_same_target_live and
sim_missile_wall_live are new files neither group's binary filter
names yet, so run directly alongside sim_player_damage_live (the
two-fireball arm this PR's own threading change affects) and
sim_justattacked_live (the seed guard this PR added catches a real
gap in).

Invariants

None. No PUR-N rule is touched: every routine here is plain SQL over
the tic's own state row and the tables the load already carries.

Spec impact

  • None. No contract in SPEC.md is touched

Checks

  • make gates. Ran instead: cargo build --workspace, cargo clippy --workspace --all-targets --features clickhouse-tests, cargo fmt --check, cargo test -p clickdoom-native --lib, and the live tests above, all against a throwaway container - all passing, shown above.
  • make native-smoke - passing, against the same throwaway container.
  • No AI attribution trailers in the commits

Anything else

Stacked on native/kills-and-drops (#509, approved, not yet merged),
branched from its own head as asked. native/player-damage (#505) is
also not yet merged; this branch carries #509's own commits ahead of
it, so it needs a rebase once #505 merges, then again once #509 does.

A defect this surfaced and left alone: seeding two independent
in-flight missiles from scratch (not from a probe row, not sharing a
target) on a freshly loaded level, aimed at a third thing on parallel
lines that never touch, only the first missile's own impact ran - the
second's own state never left its spawn frame. Filed as #512 rather
than chased here: it reproduced identically across two different
missile geometries, which points at the two-missiles-in-one-list case
itself, and both the single-missile case (sim_missile_kill_drop_live.rs)
and the two-missiles-on-the-player case this PR fixes (seeded from a
real probe row) work, so it did not block this PR's own evidence.

Written mostly by Claude Sonnet 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 7, 2026
@MarcusKainth
MarcusKainth force-pushed the native/kills-and-drops branch 2 times, most recently from eee99a6 to 6898aca Compare September 7, 2026 16:55
Base automatically changed from native/kills-and-drops to main September 7, 2026 17:50
@MarcusKainth
MarcusKainth force-pushed the native/same-target-threading branch from 1852d9b to 8bc1005 Compare September 7, 2026 17:52
damage_fold's own mobj fields (health, flags, state, tics, momentum,
reactiontime, target, threshold) read the tic-start arrays directly
rather than an accumulator, so a second hit on a target already hit
this tic overwrote the first's own answer instead of building on it.
DM_SAME_TARGET refused the tic for this rather than committing the
wrong shared answer.

The hurt tuple now carries HIT_RESULTS alongside HIT_TARGETS: each
target's own latest mobj fields, in the same order HIT_TARGETS names
the targets. A call reads a repeat target's own carried answer instead
of the tic-start arrays, the same way the player's own fields already
thread. DM_SAME_TARGET is gone, along with the field that only ever
fed it.

Threading exposed a second bug the refusal had been masking: the
scatter that applies an in-flight missile's own hit back onto the
target's mobj columns picked the first missile in the list whose own
impact reached a slot, not the last. Two missiles in one list landing
on the same target now read the list in reverse to find the one
carrying the combined answer, the same fix in both removed's own
missile scatter and thrown_thinks's own tk_hurt_at.
seed::row kept only the first override it found for a column name, so
a caller wanting two different slots' own values in one column, and
calling put() twice for it the way one arm here did, lost the second
one silently and seeded the wrong row. The next arm that does this now
fails loudly at the point it happens instead of testing data nobody
asked for.

sim_justattacked_live.rs's own two-slot m_x/m_y arm hit exactly this:
the target's own position never actually moved to where the comment
says it stands, north-east of the imp past CHASE_SLOP, since the real
thing at slot 1 kept its own gametic-40 position instead. Combined into
one put2 call each, matching the pattern sim_missile_kill_drop_live.rs
already uses for the same shape.
The two-fireballs-on-the-player arm already proves the threaded fold
for the player's own case, seeded from a real probe row. This proves
the general one: two hand-seeded fireballs, on parallel lines that
never touch in flight, landing on a third thing turned into a
zombieman in the same tic. Both explode, the target's own health
carries both hits, and the tic resolves.
missile::draws answered the impact's own damage roll plus
P_DamageMobj's draws, and never reserved P_ExplodeMissile's own
tics -= P_Random()&3, which the real thinker draws wherever the walk
sets the missile off, whether or not what it stopped on was something
this damages. now_prndindex and every base after an exploding missile
were one short from the point the first one in a tic connects.

The worst case now reserves it whenever the touch blocked the move,
over the sky hack this does not model, which only ever draws less: the
same shape as the existing fall check's own worst case, an
over-reservation is safe where an under-reservation is the bug.
move_ceilingline forces the ceiling line to none whenever a thing
blocked the move, since P_CheckPosition reaches things before lines
and stops at the first that answers no. The sky hack reads that same
line, so it can never take a missile a touch stopped - only one a wall
or a special line blocked instead, which missile_draws does not reach
at all. Reserving the explosion's own draw wherever a touch blocks is
therefore the real count, not an over-reservation that happens to be
safe: unlike a refuse bit, a wrong draw count would shift every later
base silently rather than being caught.
missile_draws only reserved P_ExplodeMissile's own draw where a thing
blocked the move, but a wall or a special line stops a missile exactly
as a thing does, and misses the target far more often than a hit
lands, so a wall-blocked missile is the ordinary case, not an edge
one. mkd_xy_blocked now reads the same three thought_of's own
mn_xy_blocked does - a thing, a step or ceiling too tight, and a
special line - and the sky hack, which only a wall or a line can ever
reach, is read for directly rather than assumed away.

Seeded from demo3's own recorded run: the fireball at slot 265
explodes on a wall at gametic 224 with nothing on the level's own list
taking damage, so PIT_CheckThing is not what stopped it. The whole
tic's own prndindex agrees with the reference exactly, which a wall
reserving nothing would have shifted along with every base after it.
@MarcusKainth
MarcusKainth force-pushed the native/same-target-threading branch from 8bc1005 to 92665b6 Compare September 7, 2026 17:58
@MarcusKainth
MarcusKainth merged commit afd85a5 into main Sep 7, 2026
20 checks passed
@MarcusKainth
MarcusKainth deleted the native/same-target-threading branch September 7, 2026 18:55
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