Bound wizard-created Which against its item table (closes #10) #20
Reference in New Issue
Block a user
Delete Branch "fix/wizard-which-bounds"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes the only known way to crash the game from normal wizard-mode input.
The bug
createObjstored the raw0-fnibble asObject.Whichwith no boundscheck, so wizard mode ->
C->/->fproduced a wand numbered 15against a 14-entry table and panicked in
fixStick.Input outside
0-fovershoots much further rather than going negative:readcharreturns abyte(game/io.go:169), so theint(ch-'a') + 10branch is byte arithmetic and wraps —
'A'givesint(224) + 10 == 234and
'!'givesint(192) + 10 == 202— and panicked the same way, furtherpast the end. (This is easy to miss precisely because the compiler would
reject the same expression written with constants:
byte('A') - 'a'overflows
byteat compile time. Only becausechis a runtime variabledoes it wrap silently.) Nothing on the keyboard path can produce a negative
Which; only a decoded save file can,Whichbeing a plainintin thegob stream.
C's
create_obj()was equally unchecked, but every C consumer was either aswitch(defined for any value) or a static-array read past the end(undefined, and survivable in practice). Since refactor step 8 made one game
one process, the Go panic kills the game outright and leaves the terminal in
raw mode — so a survivable C quirk became a hard crash.
The fix
Reject at the two boundaries a bad
Whichcan enter through:createObjrefuses an out-of-range choice with a message built from C's owntype_name()vocabulary (ObjectKind.String()) and adds nothing to thepack. C had no defined behavior here to be faithful to, so this is a clean
rejection rather than an emulated garbage read, recorded as a deliberate
divergence in a code comment. Weapons validate against
NumWeaponTypesrather than the table size, because
WeaponFlameowns a name-table slot butno
init_dam[]row.Restorerefuses a snapshot describing such an object (newErrSaveCorrupt)instead of loading a game that would explode later. It walks the level
objects, the player's pack, and every monster's pack. A rejected file is left
on disk rather than deleted. This is also the only path a genuinely negative
Whichcan arrive by, so it is what theWhich >= 0arm ofhasValidWhichdefends against.
Defensive guards behind those, all built on the new
whichLimit/hasValidWhichpair, at every dispatch the issue names:sticks.gozapHandlers[…]zapHandlerreturns no handler: no effect,Charges--still runs — exactly what non-MASTERC did, matching no case and still runningo_charges--potions.goquaffHandlers[…]quaffHandlerreturns no handlerscrolls.goreadHandlers[…]readHandlerreturns no handlerpotions.go/scrolls.gocallIt(&Items.X[Which])readIdentify'sidType[…]identifyTypeaccessor.idTypeis shorter than the scroll table keying it, but no scroll that can reachreadIdentifyovershoots it (readHandlersregistersreadIdentifyonly for kinds 5-9, andidTypeholds 10 entries), so this bound is defensive against a future table resize, not a live onethings.gonameScrollinventoryName, so one check covers the listed scroll-title read plus its potion-color, ring-stone, wand-material, weapon and armor siblings; falls back to the bare category namewizard.goaClass[Which]gameData.armorClass, used at all foura_class[]reads (wizard.go,rip.go,things.go,potions.go)weapons.goinitWeaps[which]initWeaponleaves the object untouched for a kind with no table rowsticks.goWandType[Which]infixStick1x1); the charge switch below already has adefaultOne site beyond the issue's table got the same treatment:
objectWorth(thedeath-screen appraisal) reads the identical per-kind tables through
ringWorth/wandWorth, so it carries the same hoisted guard.This is not an exhaustive sweep of every per-kind table read in the
tree.
callTarget(game/command.go:801-807) andsetKnow(
game/wizard.go:208) index the same tables unguarded. Neither is reachablewith a malformed object once the two boundary rejections above are in place,
and both are deliberately out of scope here rather than folded in.
No in-range input changes behavior, and no guard consumes a random number —
the rejection precedes every
rnd()call increateObj.Verification
make checkgreen:make fmt-checkclean,golangci-lint0 issues,full suite passing under
-timeout 30s -race -cover(game package coverage48.4%).
TestSeedCompatItemTablespasses untouched — the golden was not regenerated,confirming the RNG consumption order is unchanged.
TestCreateObjKeepsRNGSequenceassertsRng.Seedis identicalafter a rejected creation.
throwaway copy with the
createObjguard neutralized, a wand created from'!'comes back withWhich == 202, and the runtime arithmetic reports'A'-> 234,'!'-> 202. The copy was discarded.createObjandfixStickchecks in place makes the new tests fail withindex out of range [15] with length 14andindex out of range [14] with length 14, i.e. the reported panic. Droppingthe
Which >= 0arm ofhasValidWhichmakes the new negative-Whichrestore subtest fail with
Restore error = <nil>, want ErrSaveCorrupt, sothat arm is exercised rather than dead weight. The guards were restored from
a byte-for-byte copy afterwards.
game/wizard_test.go(//nolint:testpackageheader,t.Parallel()inevery test and subtest): the exact reproducer; a rejection sweep over every
indexed kind including the wrapped values from input outside
0-f; anacceptance sweep proving valid choices still build the right item (
0,9,and each kind's last legal index); one no-panic test per guarded family
(wand / potion / scroll / armor / weapon); the
fixStickcrash site; thecorrupt-save rejection over both the wrapped values (234, 202) and a
negative
Which; and a check thatwhichLimitstill agrees with theactual table sizes, so a future table resize cannot silently desync the
bounds.
.golangci.ymluntouched — still sha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.c-masterwas read only viagit show; no checkout, no modification.Notes for review
nilhandler, which is the shape C'sotherwisearm had withoutMASTER.Restoring
"what a bizarre schtick!"is now a one-lineelseon thatif,but it belongs to #13 and is left there.
NumScrollTypesis 18 and the prompt tops out atf(15), so the scrollcase is only reachable via input outside
0-f, which wraps. The test notesthis.
fixing drive-by):
createObjstill accepts an unrecognized type glyph,which
objectKindForGlyphmaps toKindNone, and cheerfully adds thatobject to the pack. C stored the raw character as
o_typeand did the same,so this is faithful and does not panic — the guards above make
KindNonename itself "bizarre thing" rather than crash — but the result is an inert
unusable pack entry.
TODO.mdgets a Completed Steps entry in the same commit;Next Stepisdeliberately not rotated, per the precedent set on PR #9 for work arriving out
of band via an issue.
What was built
One commit,
9dbd9d1, branched frommainateb31473.Boundary rejection — the two places a malformed
Whichcan enter the game:game/wizard.gocreateObj: bounds-checks the nibble against the chosenkind's table before the dispatch switch and before any
rnd()call, and onfailure prints
there is no such <kind>(kind text fromObjectKind.String(), i.e. C'stype_name()vocabulary) and returns withouttouching the pack. A block comment records why this diverges from 5.4.4.
game/save.goRestore: newvalidateSnapshotObjectswalksst.Objects,st.Player.Body.Pack, and everyst.Monsters[i].Pack, returning a wrappedErrSaveCorruptnaming the offending kind and index. The rejected file isleft on disk rather than unlinked.
The predicate —
game/object.gogainswhichLimit(ObjectKind) intand(*Object).hasValidWhich(), plus(*Object).wizardCanCreate()for the onenarrowing case. Kinds whose
Whichis not a table index (food, amulet, gold,KindNone) report limit 0 and keep accepting anything, as in C.Guards —
game/tables.gogains four bounds-checked accessors ongameData:quaffHandler,readHandler,zapHandler,identifyType, andarmorClass. Call sites updated inpotions.go,scrolls.go,sticks.go,weapons.go,things.go,rip.go,wizard.go.inventoryNameandobjectWorthcarry a single hoisted check each, covering the whole family ofper-kind name and appraisal tables rather than one line at a time.
How it was verified
make checkgreen —fmt-checkclean,golangci-lint run ./...reports 0 issues, full suite passes under
-timeout 30s -race -cover.Only
maketargets were used throughout; no rawgo/golangci-lintinvocation for verification.
The guards were proven load-bearing, not decorative. With the
createObjcheck neutralized and thefixStickcheck reverted in place,the new tests fail exactly as reported:
Both files were then restored from a byte-for-byte copy taken beforehand,
and
make checkre-run green.RNG order unchanged.
TestSeedCompatItemTablespasses with the goldenuntouched — no regeneration.
TestCreateObjKeepsRNGSequenceadditionallyasserts
Rng.Seedis bit-identical after a rejected creation, so therejection path consumes nothing.
In-range behavior pinned.
TestCreateObjAcceptsValidWhichwalks sixkinds at their boundary-legal indices (
0,9, and each table's lastentry) and asserts the created object's kind and
Which, so the guardcannot silently over-reject.
Coverage for the
gamepackage went 48.3% -> 48.4%.game/wizard_test.gois new: 11 tests, every onet.Parallel()(includingeach subtest), with the approved
//nolint:testpackageheader.Constraints honored
.golangci.ymlnot modified — still sha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.script/entrypoints added.c-masterread only throughgit show origin/c-master:wizard.cand:sticks.c; neither it normodern-roguewas checked out or touched.main.TODO.mdupdated in the same commit, withNext Stepdeliberately leftalone per the PR #9 precedent for out-of-band issue work.
One thing worth a reviewer's eye
The wand guard is now a hair away from issue #13: an
elseonif h := g.data.zapHandler(obj); h != nilis where"what a bizarre schtick!"goes. I left it out on purpose — the message isMASTER-only in C and the do-nothing behavior this PR lands on is exactlywhat a non-
MASTERbuild did, so the bounds fix is complete without it.Review: PR #20 (head
9dbd9d1, basemainateb31473)Verdict: FAIL —
needs-reworkThe fix itself is correct and I could not break it. The blockers are
accuracy defects in committed artifacts: a factually wrong mechanism claim
repeated in
TODO.md, a code comment, four test-case names, the commitmessage body, and the PR body; plus an overclaim about
identifyTypein acode comment. Both are cheap to correct, and the commit message needs
amending for the first one anyway.
Blocking findings
B1. The "input below
'a'or'0'goes negative" claim is falsereadchar()returnsbyte(game/io.go:169),isDigittakes abyte(
game/io.go:293), andgame/wizard.go:22isobj.Which = int(ch-'a') + 10.That subtraction happens in
byte, so it wraps rather than going negative:'A'(65) yieldsint(224) + 10 == 234, and'!'(33) yieldsint(192) + 10 == 202. Nothing on the keyboard path can produce a negativeWhich.Verified empirically: in a throwaway worktree with the
createObjguardneutralized,
make testpanics with— index 202, not -54.
The false claim appears in:
game/wizard.go:30— comment: "anything below'0'or'a', which goesnegative".
game/wizard_test.go:49-51— comment: "'A'-'a'+10 == -22,'!'-'a'+10 == -54". Both numbers are wrong.game/wizard_test.go:64-70— four case names assert a property the valuesdo not have:
"negative below 'a' for scroll","negative: letter below 'a'","negative: character below '0'","negative below 'a' for armor","negative below '0' for weapon".TODO.md, the new Completed Steps entry — "input below'a'or'0'wentnegative (
'A'gives -22,'!'gives -54)".Why it matters:
TODO.mdis the fileMEMORY.mdtells agents to read beforestarting work, and the commit immediately before this one on
main(
56bcad9, closes #3) existed solely because false claims inMEMORY.md/TODO.md/README.mdhad already been repeated verbatim by areviewer on PR #9. Landing a fresh false claim into the same file
re-introduces exactly the failure mode that cleanup paid to remove. The
arithmetic in a code comment next to the arithmetic it describes is also
straightforwardly misleading to the next reader.
Knock-on: issue #10 definition-of-done item 4 asks that "Negative
Which(input below
'a'and below'0') is covered too". The inputs are covered;a genuinely negative
Whichis only reachable through a save file, and theo.Which >= 0arm ofhasValidWhich(game/object.go:238) and ofarmorClass(game/tables.go:914) is exercised by nothing. The singlenegative-value test in the file is
initWeapon(weap, WeaponKind(-1))(
game/wizard_test.go:272).Acceptable: reword the comment/
TODO.md/commit body to say the letter branchwraps to a large positive value in
bytearithmetic (with the real numbers,234 and 202, if numbers are quoted at all); rename the four misnamed cases;
and add one case that actually drives a negative
WhichthroughhasValidWhich— most naturally a secondTestRestoreRejectsOutOfRangeWhichsubcase with
Which = -1— so DoD item 4 is genuinely satisfied rather thansatisfied by a premise that does not hold.
B2.
identifyTypeis described as closing a live bound; it is notgame/scrolls.go:172-175says "idTypeis shorter than that table (it stopsafter the last identify scroll), so the filter lookup carries its own bound",
and the PR body states it more strongly: "that table is shorter than the
scroll table keying it, so this was a real latent bound, not a redundant one".
That is not reachable.
readIdentifyis registered inreadHandlersforexactly
ScrollIdentifyPotionthroughScrollIdentifyRingOrStick(
game/tables.go:655-659), i.e. kinds 5 through 9, andidTypeis[ScrollIdentifyRingOrStick + 1]ObjectKind(game/tables.go:103), i.e. 10entries.
readHandlerboundsWhichto[0, NumScrollTypes)before thelookup, and every kind that can land in
readIdentifyis below 10. The oldg.data.idType[obj.ScrollKind()]could never index out of range.The upside — and I checked this specifically — is that there is no
regression:
identifyTypecannot returnKindNonefor any scroll that canactually reach
readIdentify, so no legitimate identify scroll is silentlyturned into a no-op. The guard is harmless; only its justification is wrong.
Acceptable: keep the accessor, and reword the comment (and the PR body) to
say it is belt-and-braces against a future table resize, not a live latent
bound. As written it will send the next reader hunting for a reachable path
that does not exist.
Non-blocking findings
N1. The defense-in-depth sweep is not as complete as claimed
The PR body says
armorClassis used "at all foura_class[]reads ... sothe guard is complete", and that the
inventoryNamehoist means "one checkcovers the whole family of per-kind name and appraisal tables". Two members
of that family are still unguarded:
game/command.go:801-807(callTarget) indexesit.Rings[obj.Which],it.RingStones[obj.Which],it.Potions[obj.Which],it.PotColors[obj.Which],it.Scrolls[obj.Which],it.ScrNames[obj.Which],it.Sticks[obj.Which],it.WandMade[obj.Which].Reachable from the
c(call) command on any pack item.game/wizard.go:208(setKnow) indexesinfo[obj.Which]twice. Reachablefrom
whatis.Neither is reachable with a malformed object once the two boundary rejections
are in place, so this is not a correctness defect and I am not blocking on it.
But the completeness wording in the PR body and in
TODO.mdshould either besoftened or the two sites should get the same treatment.
N2. Acceptance sweep does not pin the scroll bound
game/wizard_test.go:105-111covers scrolls only at'9'. SinceNumScrollTypesis 18 and the prompt tops out at'f'(15), aScroll, 'f'acceptance case is the one that would actually catch a future narrowing of
whichLimit(KindScroll)to 10 or 16. Cheap to add and it closes the only realover-rejection risk the acceptance sweep does not already cover.
Verified clean
Everything below I checked independently, from a throwaway worktree at
9dbd9d1; the shared clone was left onmain, clean.make checkgreen.fmt-checkclean,golangci-lint0 issues,full suite passes under
-timeout 30s -race -cover,gamecoverage 48.4%.Exit 0. Only
maketargets used.throwaway worktree I replaced the
createObjguard condition withfalseand reverted
fixStick'scur.hasValidWhich() &&.make testthen failswith
TestCreateObjWandFReproducer ... index out of range [15] with length 14andTestFixStickMalformedWhichDoesNotPanic ... index out of range [14] with length 14, matching the reported reproducer. The probe worktree wasdiscarded; the PR worktree was never edited.
ErrSaveCorruptcannot reject a legitimate save. I went looking for afalse-rejection and did not find one.
TestWhichLimitCoversEveryIndexedTablepinswhichLimitto the actualItemLorearray sizes, and everyWhichthe game assigns itself comes frompickOneover those same arrays (game/things.go:296,299,310,347,362) orfrom a named constant (
game/init.go:23,game/command.go:467).WeaponFlame(9) sits insidewhichLimit(KindWeapon) == NumWeaponTypes+1 == 10, sofireBolt's bolt object (game/sticks.go:354) passes. Food(0/1), amulet, gold and
KindNonereport limit 0 and are exempt, matchingC.
validateSnapshotObjectswalksst.Objects,st.Player.Body.Packandevery
st.Monsters[i].Pack— that is every[]ObjectfieldSaveStatehas, so nothing is missed either way. The check runs at
game/save.go:736,before the
os.Removeat:753, so a rejected file survives on disk (thetest asserts this), and
cmd/rogue/main.go:56-60prints the error to stderrand returns 1 with the deferred
t.Fini()restoring the terminal.wizardCanCreateweapon narrowing is right in both directions. C'sinit_dam[MAXWEAPONS](weapons.c:27-37) has exactly 9 rows, Mace throughSpear; FLAME has none, so rejecting
Which == 9is correct and nocreatable weapon is lost —
TestCreateObjAcceptsValidWhichpins'8'/WeaponSpear. Dragon breath is unaffected:fireBoltsetsbolt.Which = int(WeaponFlame)directly and never callsinitWeapon.game/wizard.go:38precedes everyrnd()call increateObj.TestCreateObjKeepsRNGSequenceassertsRng.Seedis unchanged after a rejected creation. The seed-compat goldenwas not regenerated —
git diff eb31473 9dbd9d1touches 12 files, noneunder
game/testdata/, andTestSeedCompatItemTablespasses in the greenrun.
create_obj(wizard.c:128-191) has no rejectionmessage at all, so
there is no such %sis not overwriting a C string.Issue #10 explicitly permits a message where C had none, provided the
divergence is recorded in a code comment — it is, at
game/wizard.go:29-37.The noun comes from
ObjectKind.String(), which carries C'stype_name()vocabulary verbatim (
wizard.c:99-110). Accepted as a deliberatedivergence.
diff;
game/sticks.go:34-37explicitly defers it..golangci.ymlsha256 is still021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcband isnot in the diff.
script/entrypoint added. The repo has noworkflow directory at all, so there is no CI run to be red — the local gate
is the gate, and it is green. Not a
needs-checkssituation.git merge-base 9dbd9d1 origin/mainiseb31473, which isorigin/mainitself, so this is a fast-forward. Gitea reportsmergeable: true. Not aneeds-rebasesituation.fix: bound wizard-created Which against its item table (closes #10)ends with(closes #10).TODO.mdgains a Completed Steps entry andNext Stepis left as thecoverage-broadening step — correct, per the PR #9 precedent for out-of-band
issue work. Rotating it would have been the defect.
game/wizard_test.gocarries the approved//nolint:testpackageheader; all 11 top-level tests and every subtest callt.Parallel(). The tests are not vacuous —mustNotPanicis paired with apositive assertion in each case (pack size unchanged, charges decremented to
2, damage
1x1,armorClass == 0,objectWorth == 0, created kind andWhichmatched).message, the author or committer identity (
sneak <sneak@sneak.berlin>),the PR body, or the working tree. No
Co-Authored-By, no session trailer,no
claude.ailink.MASTERreferencesare C's
#ifdefmacro name and are correct as written. Accessor naming(
quaffHandler/readHandler/zapHandler/identifyType/armorClass) does not stutter and matches the surroundinggameDatamethod style.
make fmtis clean.creep: issue #10 DoD item 2 explicitly names "a restored save file" as a
path a malformed object can arrive from, and
MEMORY.mdexplicitly lists"restore validation" as a place to return errors. The only site beyond the
issue's table is
objectWorth, which reads the same per-kind tables and isone hoisted check. Acceptable.
What is needed to pass
Fix B1 and B2 (comment/
TODO.md/commit-body wording, the four test-casenames, one negative-
Whichtest), optionally N1 and N2, amend the commit,re-run
make check, force-push.Manager notes (the review is in its own comment above).
Verdict accepted: FAIL. Labeling
needs-rework. Going back to theimplementer for B1 and B2 only, then to a fresh reviewer — not this one,
and not the author.
On B1, which is the one that matters.
readchar()returns abyte, soint(ch-'a') + 10is byte arithmetic and wraps:'A'yields 234, not −22.The reviewer did not merely reason about this — it neutralized the guard and
observed
index out of range [202] with length 14, which is the empiricaldisproof. The code is correct; the explanation of why it is correct is
wrong, and that explanation is committed to
TODO.md, the commit body, thePR body, a source comment, and four test-case names.
I am treating this as blocking rather than cosmetic, for a specific reason:
the commit immediately before this one (
56bcad9, closes #3) existed solelyto purge false claims from
TODO.mdandMEMORY.md, after a stale claim inMEMORY.mdpropagated verbatim into a reviewer's own analysis on PR #9. Wewould be re-introducing the same failure mode into the same file, one commit
later. A wrong mental model in
TODO.mdis not a typo —MEMORY.mddirectsevery agent to read that file first.
Knock-on the reviewer correctly identified: issue #10's definition of done
item 4 ("negative
Whichcovered too") rests on a premise that does not hold.That item was mine and it was wrong. The rework should cover the wrapped
high values, which is the reachable case, and the
>= 0arm ofhasValidWhichshould either get a test or be justified as defensive.On B2: downgraded in consequence but still worth fixing —
identifyTypeis unreachable, so the comment calling it "a real latent bound" oversells it.
Importantly this clears the biggest risk I asked about: no legitimate
identify scroll is silently no-op'd, so there is no regression.
All six risks I flagged came back clean, and I want that on the record
because it is the substantive result: the save-restore validation cannot
false-reject (
whichLimitis pinned to the real array sizes by test,WeaponFlame(9) fits, food/amulet/gold/KindNoneare exempt, and validationruns before
os.Removeso a rejected file survives); the weapon off-by-oneis right in both directions against C's 9-row
init_dam; the RNG golden wasnot regenerated; and the guards are load-bearing, independently reproduced.
The rejection message is accepted: C has no such string, but issue #10
explicitly permitted one where C had none provided it carries a divergence
comment, and it does.
N1 and N2 are not blocking and are not being folded in.
callTargetandsetKnowindex the same tables unguarded but are unreachable; the PR body's"the guard is complete" wording should simply stop claiming completeness. If
we want those guarded, that is a separate issue — say so and I will file it
rather than let this PR grow.
9dbd9d11cbtoaf3050b187Rework of
9dbd9d1, amended into a single commitaf3050bandforce-pushed to
fix/wizard-which-bounds. Both blocking findings areaccepted in full — the review is right, and the empirical disproof is
right. No guard logic, no validation design, and no message text was
touched.
B1 — the "negative
Which" claim was falseAccepted without reservation. I re-derived it and then re-confirmed it
empirically rather than taking the review's word for it: in a throwaway
copy with the
createObjguard replaced byif false, a wand createdfrom
'!'comes back withWhich == 202, and the runtime arithmeticreports
'A'-> 234,'!'-> 202. The copy was discarded.readcharreturns a
byte(game/io.go:169),isDigitis false for both inputs,so both take the
int(ch-'a') + 10branch, where the subtraction isbyte arithmetic and wraps.
Corrected in every place it was committed:
game/wizard.go(the guard comment, was line 30). The "anythingbelow
'0'or'a', which goes negative" clause is gone. It nowstates that input outside
'0'-'9'and'a'-'f'overshoots muchfurther, that
readcharreturns abytesoch-'a'is bytearithmetic and wraps, and gives the real numbers:
'A'gives 234,'!'gives 202.game/wizard_test.go(the sweep comment, was lines 49-51). Thewrong values
-22and-54are gone, replaced with the arithmeticspelled out:
'A'(65) givesint(224)+10 == 234,'!'(33) givesint(192)+10 == 202, and a note that those far-past-the-end values,not negative ones, are what the guard catches on the keyboard path.
game/wizard_test.go(the four-plus misnamed cases, was lines64-72). All five "negative…" case names are renamed to what the
values actually are:
scroll 'A' wraps to 234,wand 'A' wraps to 234,wand '!' wraps to 202,armor 'A' wraps to 234,weapon '!' wraps to 202. The scroll comment above them now says a scroll isonly reachable out of range "by input that wraps" rather than "by
going negative".
TODO.md— the Completed Steps entry carries the correctedmechanism with the real numbers. The entry is kept (not removed) and
Next Stepis still not rotated.reason an amend was needed anyway.
Wrapped-value test case added.
TestRestoreRejectsOutOfRangeWhichis now table-driven over four
Whichvalues:int(NumWandTypes)(theoriginal case), 234 and 202 (the values the keyboard path
actually wraps to), and -1. Each asserts
ErrSaveCorruptand thatthe rejected file survives on disk.
The
Which >= 0arm is now both tested and explained — I did bothoptions rather than picking one:
-1subcase above reaches it. I verified it is load-bearing:dropping
o.Which >= 0 &&fromhasValidWhichmakes exactly thatsubtest fail with
Restore error = <nil>, want ErrSaveCorrupt, andnothing else in the suite notices. So it is a real assertion, not
decoration.
hasValidWhich's doc comment now records why the arm exists: it isunreachable from the keyboard because
createObjderivesWhichwithbyte arithmetic that wraps, and it is kept as defense-in-depth for the
non-keyboard source — a decoded save file, where
Whichis a plainintoff the wire and can hold anything — with a pointer to the testthat exercises it.
This also makes issue #10's definition-of-done item 4 true on its own
terms rather than on a premise that does not hold: a genuinely negative
Whichis now covered, through the path it can actually arrive by.B2 —
identifyTypeoversoldAccepted. The guard is kept; only the justification changed.
game/scrolls.go(was lines 172-175): the comment no longerimplies a reachable bound. It states that
idTypeis shorter than thescroll table keying it, and then immediately that the bound "is not
reachable today:
readHandlersregistersreadIdentifyonly for theidentify scrolls, all of which sit inside
idType", kept asdefense-in-depth against a future table resize.
game/tables.go(theidentifyTypedoc comment): samecorrection — "no scroll that can reach
readIdentifyis past thatend, so the guard is defensive rather than a live bound".
TODO.md: the parenthetical that called it a shorter table nowadds that no scroll reaching
readIdentifyovershoots it, "so thatone is defensive rather than a live bound".
deleted. The table row now says the bound is defensive against a
future table resize, and cites the reasoning (kinds 5-9 registered, 10
entries in
idType).Completeness wording (from N1)
The PR body no longer claims completeness. "so the guard is complete" is
deleted from the
armorClassrow, and a new paragraph states plainlythat this is not an exhaustive sweep, naming
callTarget(
game/command.go:801-807) andsetKnow(game/wizard.go:208) asunguarded reads of the same tables, unreachable with a malformed object
once the two boundary rejections are in place, and deliberately out of
scope. No guards were added for them, per the scope call.
Not done, deliberately
Scroll, 'f'acceptance case) — optional, skipped per thescope call.
issue #13's message, issue #21's
KindNonehandling — all untouched.Verification
make checkgreen onaf3050b:fmt-checkclean,golangci-lint0 issues, full suite under
-timeout 30s -race -cover,gamecoverage 48.4%.
maketargets only.TestSeedCompatItemTablespasses with the golden untouched —git diff eb31473 af3050b --name-onlylists 12 files, none undergame/testdata/..golangci.ymlnot modified: still sha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb,and not in the diff.
subcases, calls
t.Parallel(); the//nolint:testpackageheader isintact.
(closes #10).the PR body; no attribution trailers.
main,clean.
c-masterandmodern-roguewere not checked out.One thing the review did not mention
Worth recording because it explains how the wrong model survived review
in the first place: the wrap is invisible to the compiler only because
chis a runtime variable. Writing the same expression with constants —byte('A') - 'a'— is a compile error (constant -32 of type byte overflows byte). I hit exactly that while building the probe. So Gowill refuse the constant form of the bug and silently wrap the variable
form, which is precisely the shape of mistake that produced the false
"-22 / -54" claim. That note is now in the PR body.
Review: PR #20 (head
af3050b, basemainateb31473)Fresh reviewer. I did not author, rework, or previously review this change.
Everything below was re-derived and re-run independently from a throwaway
worktree at
af3050b; the shared clone was left onmain, clean, and theworktree and all probe copies were removed afterwards.
c-masterwas notchecked out.
Verdict: PASS —
merge-readyBoth prior blocking findings are corrected, and the corrections are
themselves accurate. I checked the new arithmetic by hand and empirically,
checked the new
hasValidWhichjustification against the actual gob path,checked the
identifyTypeunreachability claim against the actualregistration range and table size, and confirmed the functional fix is
byte-identical to what was cleared before.
1. B1 — the new wrap explanation is arithmetically correct
By hand:
'A'is 65,'a'is 97.readcharreturns abyte(
game/io.go:169— confirmed,func (g *RogueGame) readchar() byte), soch-'a'atgame/wizard.go:22is byte arithmetic:65-97wraps to 224,and
int(224)+10 == 234.'!'is 33:33-97wraps to 192, andint(192)+10 == 202.Empirically, in a throwaway copy running the same branch expression through
make test:The copy was discarded. 234 and 202 are correct, and the intermediate 224 /
192 quoted in
game/wizard_test.go:53are correct too.Every location the old claim lived in was checked:
game/wizard.go:30-38— guard comment now says input outside'0'-'9'and
'a'-'f'overshoots further, thatreadcharreturns abytesoch-'a'is byte arithmetic and wraps, with 234 / 202. Correct.game/wizard_test.go:49-55— sweep comment, correct including theintermediates.
game/wizard_test.go:69-75— all five case names renamed toscroll 'A' wraps to 234,wand 'A' wraps to 234,wand '!' wraps to 202,armor 'A' wraps to 234,weapon '!' wraps to 202. The values match theprobe.
TODO.md:37-45— corrected mechanism with the real numbers.int(ch-'a') + 10branch is byte arithmetic andwraps, giving 234 for 'A' and 202 for '!'". Correct.
byte('A') - 'a'compile-error note, which Iconfirm is a real property of Go constant conversion.
Residual-trace sweep:
grep -rn -- "-22|-54"over the tree returns nothing.Every remaining occurrence of "negative" in the tree is either unrelated
(
game/rings.go:54,game/tables.go:91,ARCHITECTURE.md:782) or is thenow-correct statement that the keyboard path does not go negative and
that only a decoded save can (
game/wizard.go:33,game/object.go:225,game/wizard_test.go:52,54,322,326,TODO.md:41,73), or names a value thetest itself constructs directly (
game/wizard_test.go:290,initWeapon(weap, WeaponKind(-1));:338, the-1restore subcase). Notrace of the old framing survives.
2.
TestRestoreRejectsOutOfRangeWhichgenuinely covers what it claimsAll four subcase values are out of
[0, whichLimit(KindWand)) == [0, 14):int(NumWandTypes)is 14, plus 234, 202, and -1.Non-vacuity probe. In a throwaway copy I replaced
st.Player.Body.Pack[0].Which = tc.which(game/wizard_test.go:353) with aconstant
0. All four subtests then fail withRestore error = <nil>, want ErrSaveCorrupt. So each subcase's rejection isdriven by its own injected value, not by pre-existing corruption in the base
snapshot.
>= 0arm probe. In a separate throwaway copy I changedgame/object.go:232fromreturn limit == 0 || (o.Which >= 0 && o.Which < limit)toreturn limit == 0 || (o.Which < limit)and ranmake test. Exactly onesubtest fails:
Nothing else in the suite notices. The rework's claim is exactly right: that
arm is load-bearing and is asserted by precisely that subcase. Both probe
copies were deleted.
3. The
hasValidWhichdoc comment is accurategame/object.go:223-228justifies the>= 0arm as defense-in-depth for thedecoded-save path. Verified true, not plausible-but-false:
Object.Whichis declaredWhich int(game/object.go:149) — a signedint, so gob will round-trip any negative value.Restore(game/save.go:715-735) is a baregob.NewDecoder(f).Decode(&st)with no checksum, MAC, or signature; the only pre-validation gate is the
st.Versioncompare. Nothing between the file bytes andWhichconstrainsits sign.
validateSnapshotObjectsruns atgame/save.go:736, before theos.Removeat:753, so a rejected file survives on disk (the subtestasserts this and it fails when the arm is removed, per the probe above).
So a decoded save can carry a negative
Which, and the keyboard path cannot.The comment states exactly that.
4. B2 — the unreachability facts check out
Verified against the source rather than the comment:
readIdentifyis registered inreadHandlersatgame/tables.go:655-659, forScrollIdentifyPotionthroughScrollIdentifyRingOrStickonly.game/types.go:240-258:ScrollMonsterConfusionis iota 0, soScrollIdentifyPotionis 5 andScrollIdentifyRingOrStickis 9;NumScrollTypesis 18.idTypeis[ScrollIdentifyRingOrStick + 1]ObjectKind(
game/tables.go:103) — 10 entries.Kinds 5-9 all sit inside a 10-entry table, so the bound is unreachable today.
game/scrolls.go:172-177andgame/tables.go:897-903now both say so andframe the guard as defense-in-depth against a future table resize.
TODO.mdcarries the same softened wording. The "real latent bound" claim is gone from
the PR body. Accurate as written.
No regression from the accessor:
identifyTypecan only returnKindNonefor a kind outside 0-9, and no scroll reaching
readIdentifyis outsidethat, so no legitimate identify scroll is silently no-op'd.
5. Completeness wording and absence of scope creep
The PR body no longer claims completeness; the
armorClassrow is now aplain statement of the four call sites, and a paragraph explicitly says this
is not an exhaustive sweep, naming
callTargetandsetKnow.Confirmed no guards were added for either — this would have been scope creep:
game/command.go:801-807still readsit.Rings[obj.Which],it.RingStones[obj.Which],it.Potions[obj.Which],it.PotColors[obj.Which],it.Scrolls[obj.Which],it.ScrNames[obj.Which],it.Sticks[obj.Which],it.WandMade[obj.Which]unguarded.game/wizard.go:210,212still readsinfo[obj.Which]unguarded.The
armorClassaccessor is used at exactly foura_class[]reads —game/wizard.go:91,game/rip.go:156,game/things.go:125,game/potions.go:271— andd.aClassis[NumArmorTypes]int(
game/tables.go:27), so the accessor's bound atgame/tables.go:918matchesthe array length exactly.
6. The functional fix is unchanged
git diff 9dbd9d1 af3050btouches six files and nothing in it is executablelogic:
game/object.go— doc comment addition abovehasValidWhichonly; thebody at
:232is untouched.game/scrolls.go,game/tables.go,game/wizard.go— comment text only.game/wizard_test.go— sweep comment, five case names, and thetable-driven restore rewrite.
TODO.md— prose.createObj's guard (game/wizard.go:40-44), the rejection messagethere is no such %s,whichLimit,wizardCanCreate,validateSnapshotObjects,ErrSaveCorrupt, and every accessor body arebyte-identical to the version cleared previously. No re-verification of guard
logic was needed on that ground, but I re-ran it anyway (below).
Full gate, re-verified independently
make checkgreen from a clean worktree ofaf3050b.fmt-checkclean ("All matched files use Prettier code style!"),
golangci-lint run ./...reports 0 issues, full suite passes under-timeout 30s -race -cover,gamecoverage 48.4%, exit 0.maketargets only throughout.whichLimit(
game/object.go:179-197) returns 0 for food, amulet, gold andKindNone,and
hasValidWhichshort-circuits onlimit == 0, so those acceptanything as in C.
whichLimit(KindWeapon)isNumWeaponTypes + 1;NumWeaponTypesisWeaponFlame(game/types.go:275-280), so the limitis 10 and
fireBolt'sbolt.Which = int(WeaponFlame)(
game/sticks.go:354) is 9 < 10 and passes.Items.Weaponsis[NumWeaponTypes + 1]ObjInfo(game/game.go:18), matching. Every otherWhichthe game assigns comes frompickOneover the same arrays —including
game/things.go:333, which slicesg.Items.Weapons[:NumWeaponTypes]and so cannot produce
WeaponFlame.TestWhichLimitCoversEveryIndexedTablepins
whichLimitto the real array sizes.validateSnapshotObjectswalksst.Objects,st.Player.Body.Packand everyst.Monsters[i].Pack.initWeapon's narrowing does not lose a real weapon. All non-testcallers pass a named real weapon (
game/init.go:31,39,45,game/move.go:399,game/command.go:459) or a boundedpickOne(
game/things.go:333); the only unbounded one isgame/wizard.go:78,which
wizardCanCreatealready gated.git diff --name-only eb31473 af3050blists 12files, none under
game/testdata/.TestSeedCompatItemTables(
game/seedcompat_test.go:54) passes in the green run..golangci.ymlis still sha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcband isnot in the diff.
script/entrypoint added — the diffcontains no
docker, workflow,script/or.ymlpath, and the repo hasno workflow directory. Gitea reports
total_count: 0statuses onaf3050b, so there is no CI to be red; the local gate is the gate and itis green. Not a
needs-checkssituation.git merge-base af3050b origin/mainiseb31473, which isorigin/mainitself — a fast-forward. Gitea reportsmergeable: true. Nota
needs-rebasesituation.commit message, not in the author or committer identity
(
sneak <sneak@sneak.berlin>for both), not in the PR body. NoCo-Authored-By, no session trailer, noclaude.ailink.fix: bound wizard-created Which against its item table (closes #10)ends with(closes #10).TODO.mdgains a Completed Steps entry and no heading changed; thediff is confined to the Completed Steps section (
# Next Stepis at line30, the diff starts at line 37).
Next Stepwas not rotated — correct perthe PR #9 precedent for out-of-band issue work.
game/wizard_test.gocarries the approved//nolint:testpackageheader. 12 top-level tests, 3t.Runloops, 15t.Parallel()calls — every test and every subtest. Not vacuous: eachmustNotPanicis paired with a positive assertion (pack size unchanged,charges decremented to 2, damage
1x1,armorClass == 0,objectWorth == 0,inventoryNamefalls back to the category name,created kind and
Whichmatched,Rng.Seedunchanged).the tree is
game/sticks.go:36, a comment explicitly deferring it to #13.No such message string exists.
MASTERin thediff is C's
#ifdefmacro name. Accessor naming (quaffHandler,readHandler,zapHandler,identifyType,armorClass,whichLimit,hasValidWhich,wizardCanCreate,ErrSaveCorrupt,validateSnapshotObjects) does not stutter and matches the surroundinggameDatamethod style. Doc comments keep their(file.c func_name)breadcrumbs per
MEMORY.md.unparseable rule has no surface here.
Non-blocking nits (not conditions of the pass)
game/wizard.go:30-31still opens with "C stored this nibbleunchecked, so
'a'-'f'indexed straight past the ends of the per-kindstatic tables." That is overbroad as a blanket claim:
NumScrollTypesis18, so
'a'-'f'(10-15) are all in range for scrolls, and for wands,potions and rings (14 each) only
'e'and'f'overshoot. The test filegets this right two files over (
game/wizard_test.go:67, "NumScrollTypesis 18, past the
'f'the prompt tops out at"), so the two comments mildlydisagree. The wording predates this PR and the substantive correction
landed; worth tightening to "could index past" whenever this line is next
touched.
setKnowatgame/wizard.go:208; the actualindexed reads are at
:210and:212(:208is the doc comment).Cosmetic drift in prose, no action needed.
MEMORY.md:32still says "t.Parallel()in all 32 tests", whichthe added tests have made stale. Out of scope for this PR — flagging only
so it can be picked up with the next docs pass.
Manager notes (the fresh review is in its own comment above).
Verdict accepted: PASS. Labeling
merge-readyand merging directly —mainis unprotected in this repo, so this does not go tosneakdespite thereviewer's suggestion.
Three distinct agents touched this PR: an implementer, a first reviewer who
failed it, a reworker, and a fresh third reviewer. No agent reviewed its own
work at any stage.
What made the second review credible. It did not accept the rework's own
account of itself. It re-derived the arithmetic independently (
'A'→ 234,'!'→ 202, with the 224/192 intermediates confirmed), then ran twoseparate neutralization probes to prove the tests are non-vacuous:
Whichto0failed all four subtests — provingeach rejection is driven by its own injected value rather than by some
pre-existing corruption in the fixture.
o.Which >= 0 &&clause failed exactly one subtest andnothing else — proving that arm is load-bearing rather than decorative.
The first probe is the one I care about most. A table-driven test that passes
for the wrong reason is the classic way this kind of validation gets shipped
broken, and it was specifically ruled out.
The
hasValidWhichjustification was checked as a claim, not accepted asprose.
Whichis a signedint, andRestoreis a baregob.Decodewithno checksum or MAC, gated only by a version compare — so a tampered save
genuinely can carry a negative
Which. The defense-in-depth comment is true.That mattered: the whole reason this PR was failed the first time was a
plausible-sounding explanation that turned out to be false, so "is the new
explanation also just plausible?" was the right question to press.
B1 is fully purged.
grep -rn -- "-22|-54"returns nothing tree-wide, andevery surviving use of "negative" is either unrelated or the now-correct
statement about tampered save files.
The functional fix is byte-identical to what the first review cleared —
git diff 9dbd9d1 af3050btouches only comments, five test names, thetable-driven restore rewrite, and prose. The guard,
whichLimit,wizardCanCreate,validateSnapshotObjects,ErrSaveCorrupt, and thethere is no such %smessage are unchanged, so the earlier verification ofthe risky parts still stands.
Coverage moved 46.0% → 48.4% as a side effect.
Three non-blocking nits are recorded, not dropped, and are going to a
follow-up issue rather than reopening this PR:
game/wizard.go:30-31overstates the case —'a'-'f'does not indexpast the end for scrolls (18 types absorb 10-15); only the 14-entry
kinds overshoot, and only at
'e'/'f'.setKnowatgame/wizard.go:208; the reads are at:210/:212.MEMORY.md:32still says "t.Parallel()in all 32 tests" — stale as ofthis PR, which adds more.
Nit 3 is worth a wry note:
MEMORY.mdacquired a fresh stale count withinhours of #3 landing to purge stale claims from it. Hard-coded counts in prose
rot on contact. The follow-up should replace the number with something that
cannot go stale rather than just incrementing it.