fix: bound wizard-created Which against its item table (closes #10)

createObj stored the raw 0-f nibble as Object.Which with no bounds
check, so wizard mode -> C -> / -> f made a wand numbered 15 against a
14-entry table and panicked in fixStick; input below 'a' or '0' went
negative and panicked the same way. C's create_obj() was equally
unchecked, but its consumers were either switches (defined for any
value) or static-array reads past the end (undefined, and survivable in
practice). Since one game is now one process, the Go panic kills the
game outright and leaves the terminal in raw mode.

Reject at the two boundaries a bad Which can enter through. createObj
now refuses an out-of-range choice with a message drawn from C's own
type_name() vocabulary and adds nothing to the pack, a deliberate
divergence recorded in a comment because C had no defined behavior here
to be faithful to. Restore refuses a snapshot describing such an object
(ErrSaveCorrupt) rather than loading a game that would explode later.

Behind those, whichLimit/hasValidWhich back defensive guards at every
dispatch the issue names: the quaffHandler/readHandler/zapHandler
accessors return no handler instead of indexing (for wands that is
exactly what non-MASTER C did, matching no case and still running
o_charges--), the callIt lore lookups, identifyType, armorClass for the
a_class[] reads, initWeapon against the missing init_dam[] row for
WeaponFlame, fixStick's ws_type[] read, and inventoryName and
objectWorth, hoisted so one check each covers the whole family of
per-kind name and appraisal tables.

No in-range input changes behavior and no guard consumes a random
number: the rejection precedes every rnd() call. TestSeedCompatItemTables
stays green untouched.

New game/wizard_test.go covers the exact reproducer, a rejection sweep
over every indexed kind including both negative-input forms, an
acceptance sweep proving valid choices still build the right item, one
no-panic test per guarded family, the fixStick crash site, the
corrupt-save rejection, and a check that whichLimit still agrees with
the table sizes. Each guard was confirmed load-bearing by reverting it
and watching the test panic.

TODO.md records the step; Next Step is deliberately left alone, since
this arrived out of band via an issue.
This commit is contained in:
2026-08-09 05:09:03 +00:00
parent eb31473ef0
commit 9dbd9d11cb
12 changed files with 660 additions and 13 deletions

View File

@@ -164,6 +164,38 @@ func newObject() *Object {
return &Object{Launch: noWeapon}
}
// whichLimit reports how many entries a kind's per-kind tables hold, so
// Which is a legal index exactly while 0 <= Which < whichLimit(Kind).
// Kinds whose Which is not a table index at all — food (0 ration, 1 the
// fruit), the amulet, gold, and the KindNone an unrecognized glyph maps
// to — report 0, meaning any value is acceptable, which is what C did
// with them too.
//
// Weapons count one past NumWeaponTypes on purpose: Items.Weapons is
// sized NumWeaponTypes+1 for the WeaponFlame dragon-breath entry, and
// fireBolt really does set Which = WeaponFlame on a live Object. That
// entry has no init_dam[] row, so initWeapon and createObj bound
// themselves by NumWeaponTypes instead.
func whichLimit(kind ObjectKind) int {
//nolint:exhaustive // the remaining kinds do not index a per-kind table
switch kind {
case KindPotion:
return int(NumPotionTypes)
case KindScroll:
return int(NumScrollTypes)
case KindRing:
return int(NumRingTypes)
case KindWand:
return int(NumWandTypes)
case KindArmor:
return int(NumArmorTypes)
case KindWeapon:
return int(NumWeaponTypes) + 1
}
return 0
}
// PotionKind returns Which as a potion kind; valid only for KindPotion.
func (o *Object) PotionKind() PotionKind { return PotionKind(o.Which) }
@@ -182,6 +214,29 @@ func (o *Object) WeaponKind() WeaponKind { return WeaponKind(o.Which) }
// ArmorKind returns Which as an armor kind; valid only for KindArmor.
func (o *Object) ArmorKind() ArmorKind { return ArmorKind(o.Which) }
// hasValidWhich reports whether Which is a legal index into this
// object's per-kind tables. Objects the game builds itself always
// satisfy it; the wizard-create command and a restored save file are the
// only two ways a malformed one can appear, and both reject it (see
// createObj and validateSnapshotObjects).
func (o *Object) hasValidWhich() bool {
limit := whichLimit(o.Kind)
return limit == 0 || (o.Which >= 0 && o.Which < limit)
}
// wizardCanCreate reports whether Which names something the wizard-create
// command can actually build. It is hasValidWhich narrowed for weapons:
// WeaponFlame owns a name-table slot but no init_dam[] row, so asking for
// it would leave initWeapon nothing to copy.
func (o *Object) wizardCanCreate() bool {
if o.Kind == KindWeapon {
return o.Which >= 0 && o.Which < int(NumWeaponTypes)
}
return o.hasValidWhich()
}
// attachObj is list.c attach(): push item onto the front of a list.
func attachObj(list *[]*Object, item *Object) {
*list = append([]*Object{item}, *list...)