Skip to content

Gameplay variable audit: the tables were right, nothing was reading them - #1

Merged
themuffinator merged 2 commits into
mainfrom
claude/gameplay-variables-review-045a1e
Sep 3, 2026
Merged

Gameplay variable audit: the tables were right, nothing was reading them#1
themuffinator merged 2 commits into
mainfrom
claude/gameplay-variables-review-045a1e

Conversation

@themuffinator

@themuffinator themuffinator commented Sep 3, 2026

Copy link
Copy Markdown
Owner

An audit of every gameplay number in the port — player and creature health,
weapon damage, armour, ammo — checked against the disc it was read from.

The data half is clean

No changes were needed there, and that is the headline. Re-run against
SLES_015.34:

q2psx-inspect weapons  -> PASS  11 weapons, 3 armour classes, 22 sounds, 0 mismatches
q2psx-inspect classes  -> PASS  every one of 651 spawns names a class in the table
q2psx-inspect items    -> PASS  1013 place records resolve; 0 with no handler
q2psx-inspect ai       -> PASS  130 checks, 0 mismatches
q2psx-inspect death    -> PASS  every check passed

The per-weapon damage, quad, pellet, spread, kick and muzzle figures in
weapon.c agree with FORMATS.md §13.1 row for row; the ammo capacity tiers and
all six armour constants land on PC Quake II's own numbers.

Every defect was in the wiring

Six of them, and they share one shape: a field that only ever held its default.

where was now
q2_actor_from_player armour_class a literal 0 from the inventory
q2_actor_from_player powerups never copied inv->flags
q2_sim_fire quad hardcoded false level_time < inv.quad_until
combat rules skill frozen at 1 q2_cre_skill()
client frame sim.multiplayer written only by the save loader follows mp_enabled
player thresholds gib at −100 and −40 one copy, Q2_PDEATH_GIB_HEALTH

Armour was always the weakest kind. armour_class was written as 0, and 0
is jacket — so combat and body armour absorbed 0.30 of an ordinary hit instead
of 0.60 and 0.80, and neither absorbed anything from an energy weapon, where
jacket's column is genuinely zero. The projection rebuilds on every damage
attempt, so no pickup or save could get a real class past it. The power shield
was worse off: the two bits 0x80057AC4 tests live in the inventory's flag
word, which was never copied, so absorption returned at its first guard.

Quad multiplied nothing. Every fire function picks one of two immediates by
comparing the level clock against the player's expiry word, and the sim called
them with that comparison hardcoded false. The powerup was picked up, drawn on
the HUD, counted down and played its firing sound for thirty seconds while the
shotgun kept doing 6 a pellet.

Skill and deathmatch never arrived. Only the clock was refreshed in the
combat rules, so skill stayed 1 (easy as dangerous as medium) and
deathmatch stayed false. q2_sim.multiplayer had one writer — the save
loader — so a match started from the menu ran every DM rule in its
single-player form: railgun 100 instead of 150, the armour bias that favours
the wearer, single-player item amounts with no respawn, and the rocket-jump
ceiling applied where the console does not apply it.

Measured

Through the real damage path, 200 armour held, one 100-point hit — the share
absorbed:

before after
jacket, normal 31% 31%
combat, normal 31% 61%
body, normal 31% 81%
body, energy 0% 61%
power shield (30-pt hit) 0% 67%, at one cell per two points

A player in body armour was taking 69 points of a 100-point hit. They should
have been taking 19.

Also corrected

Three comments the audit found drifting from their code: weapon.h's refire
note described a 30-tick gate against a table that is deliberately all zeros
(both are right — the write exists, nothing reads it back, the fire clip is the
rate); weapontables.c called the stored muzzle triple (right, up, forward)
where the header and loader either side of it both say down; and the corpse
health floor was spelled out twice under two names.

Testing

35/35 tests pass, including 10 new checks — six in test_combat for the damage
projection, four in test_sim for the session reaching the shot. Client and
tools rebuild clean with no new warnings. Rendering is byte-identical between
the two builds: the same frame comes out of both, which is precisely why these
survived — none of it is visible until something hits you.

For the reviewer

The one figure that had no instruction behind it is now settled. `(damage + 1)

1for the skill-0 halving rounds half up where a MIPS signed/2`
truncates, so it looked like it might be a transcription slip. Read off the
disc with the repository's own MIPS decoder, it is exactly right:

80057D5C  addu  s5, a0            ; attacker
80057D64  addu  s2, a1            ; target
80057D6C  addu  s1, a2            ; damage
80057D94  lw    s0, 12(s2)        ; the target's client block
...
800582C8  beq   s0, zero, ...     ; the target has a client
800582D0  lh    v0, 0x800B334A    ; the skill halfword
800582D8  bne   v0, zero, ...     ; only at skill 0
800582E0  lw    v0, 748(s5)       ; attacker+0x2EC, its entity back-pointer
800582E8  beq   v0, zero, ...
800582F0  lw    v0, 12(s5)        ; the attacker's client block
800582F8  bne   v0, zero, ...     ; only when it has none: a monster
800582FC  addiu v0, s1, 1         ; delay slot: damage + 1
80058300  sra   s1, v0, 1         ; damage = (damage + 1) >> 1

The +1 is unconditional. A compiler's signed divide by two would have emitted
the sign-bit idiom instead (srl v0, x, 31; addu; sra) and there is no such
term, so the source really was (damage + 1) >> 1. 25 points of monster damage
arrive as 13, not 12 - which also gives id's "never rounds to nothing" for
free. Transcribed into combat.c with the four guards either side of it; no
behaviour change, the citation is the point.

That transcription records one thing the port does not reproduce:
0x800582E0 additionally requires attacker+0x2EC - the actor's entity
back-pointer, zeroed at 0x8007F0DC when a body stops being a creature - to be
non-zero, so the console refuses the halving for an attacker that has become a
corpse. attacker != NULL stands in for it, and nothing in this port damages
anyone from a detached actor.

🤖 Generated with Claude Code

An audit of every gameplay number against the disc. The DATA half came back
clean: `q2psx-inspect weapons` reports 0 mismatches on all eleven weapons, the
three armour classes and the ammo tiers; `classes` resolves all 651 spawns to
their health and gib thresholds; `items` resolves all 1013 place records. The
per-weapon damage, spread, kick and muzzle figures in weapon.c agree with
FORMATS.md §13.1 row for row.

Every bug was in the wiring between those tables and the damage function, and
they share one shape: a field that only ever held its default.

- `q2_actor_from_player` wrote a literal 0 into `armour_class`, and 0 is
  jacket. Combat and body armour absorbed 0.30 instead of 0.60 and 0.80, and
  nothing at all from an energy weapon. It rebuilds on every damage attempt, so
  no pickup or save could get a real class past it.
- The same projection never copied `powerups`, which is where the two bits
  0x80057AC4 tests live, so the power shield spent no cells and saved nothing.
- `q2_sim_fire` passed `quad` as a hardcoded `false`. The powerup was picked
  up, drawn, counted down and played its sound while multiplying nothing.
- The combat rules refreshed only their clock, so `skill` stayed 1 and easy was
  as dangerous as medium, and `deathmatch` stayed false.
- `q2_sim.multiplayer` had one writer, the save loader. A match started from
  the menu left it false, so the railgun did 100, armour used the
  single-player bias, items gave single-player amounts and did not respawn.
- The player carried two gib thresholds, -100 and -40, and the corpse floor was
  spelled out twice under two names.

Measured through the real damage path, a 100-point hit on 200 body armour:
before, 31 absorbed either way; after, 81 normal and 61 energy. A 30-point hit
with a power shield: 0 absorbed before, 20 after, at one cell per two points.

Ten new checks pin it, split between the projection (test_combat) and the
session reaching the shot (test_sim). Also corrects three stale comments the
audit turned up: weapon.h's refire note against a table that is deliberately
zero, weapontables.c's muzzle triple against the header's own reading, and the
duplicated health floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Left flagged on the last commit as the one figure in combat.c with no
instruction behind it: `(amount + 1) >> 1` rounds half up where a MIPS signed
divide by two truncates, so it could have been a transcription slip. Read off
the disc, it is not.

The prologue fixes the registers — 0x80057D5C `addu s5, a0` attacker,
0x80057D64 `addu s2, a1` target, 0x80057D6C `addu s1, a2` damage, 0x80057D94
`lw s0, 12(s2)` the target's client — and the halving is

    800582FC  addiu v0, s1, 1
    80058300  sra   s1, v0, 1

with the +1 UNCONDITIONAL. A compiler's signed `/2` emits the sign-bit idiom
instead (`srl v0, x, 31; addu; sra`) and there is no such term, so the source
really was `(damage + 1) >> 1`. 25 points of monster damage arrive as 13.

The four guards either side of it are transcribed too, and record the one thing
the port does not reproduce: 0x800582E0 also requires `attacker+0x2EC`, the
actor's entity back-pointer, to be non-zero — so the console refuses the
halving for an attacker that has become a corpse. `attacker != NULL` stands in
for it and nothing here damages anyone from a detached actor.

No behaviour change; the citation is the point. Read with the repository's own
MIPS decoder against SLES_015.34, since the IDA bridge was unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@themuffinator
themuffinator merged commit 2b37292 into main Sep 3, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant