Gameplay variable audit: the tables were right, nothing was reading them - #1
Merged
Merged
Conversation
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>
|
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:The per-weapon damage, quad, pellet, spread, kick and muzzle figures in
weapon.cagree with FORMATS.md §13.1 row for row; the ammo capacity tiers andall 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.
q2_actor_from_playerarmour_classa literal0q2_actor_from_playerpowerupsnever copiedinv->flagsq2_sim_firequadhardcodedfalselevel_time < inv.quad_untilskillfrozen at 1q2_cre_skill()sim.multiplayerwritten only by the save loadermp_enabledQ2_PDEATH_GIB_HEALTHArmour was always the weakest kind.
armour_classwas written as0, and 0is 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
0x80057AC4tests live in the inventory's flagword, 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
skillstayed 1 (easy as dangerous as medium) anddeathmatchstayed false.q2_sim.multiplayerhad one writer — the saveloader — 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:
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 refirenote 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.ccalled 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_combatfor the damageprojection, four in
test_simfor the session reaching the shot. Client andtools 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)
The
+1is unconditional. A compiler's signed divide by two would have emittedthe sign-bit idiom instead (
srl v0, x, 31; addu; sra) and there is no suchterm, so the source really was
(damage + 1) >> 1. 25 points of monster damagearrive as 13, not 12 - which also gives id's "never rounds to nothing" for
free. Transcribed into
combat.cwith the four guards either side of it; nobehaviour change, the citation is the point.
That transcription records one thing the port does not reproduce:
0x800582E0additionally requiresattacker+0x2EC- the actor's entityback-pointer, zeroed at
0x8007F0DCwhen a body stops being a creature - to benon-zero, so the console refuses the halving for an attacker that has become a
corpse.
attacker != NULLstands in for it, and nothing in this port damagesanyone from a detached actor.
🤖 Generated with Claude Code