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 outside 0-f overshoots much further rather than going negative: readchar returns a byte, so the int(ch-'a') + 10 branch is byte arithmetic and wraps, giving 234 for 'A' and 202 for '!', 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. A decoded snapshot is also the only source of a genuinely negative Which, Which being a plain int off the wire, so it is what the Which >= 0 arm of hasValidWhich defends against. 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. identifyType's bound is defensive rather than live: readHandlers registers readIdentify only for the identify scrolls, all of which sit inside the shorter idType table. 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 the wrapped values from input outside 0-f, 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 over both the wrapped values and a negative Which, and a check that whichLimit still agrees with the table sizes. Each guard was confirmed load-bearing by reverting it and watching the test fail. TODO.md records the step; Next Step is deliberately left alone, since this arrived out of band via an issue.
262 lines
7.8 KiB
Go
262 lines
7.8 KiB
Go
package game
|
|
|
|
// ObjectKind is the category of an item. In C this was the o_type byte,
|
|
// which doubled as the character drawn on the map; the port separates the
|
|
// category (ObjectKind) from its display character (Glyph).
|
|
type ObjectKind int
|
|
|
|
// Item categories.
|
|
const (
|
|
KindNone ObjectKind = iota
|
|
KindPotion
|
|
KindScroll
|
|
KindFood
|
|
KindWeapon
|
|
KindArmor
|
|
KindRing
|
|
KindWand // a "stick": wand or staff
|
|
KindAmulet
|
|
KindGold
|
|
)
|
|
|
|
// Prompt-filter pseudo-kinds for item selection (rogue.h CALLABLE and
|
|
// R_OR_S): they never appear on an Object, only as getItem filters.
|
|
const (
|
|
KindCallable ObjectKind = -1
|
|
KindRingOrStick ObjectKind = -2
|
|
)
|
|
|
|
// Category words shared by ObjectKind.String, the discovery list, and the
|
|
// ident table. (The bare identifiers Potion, Scroll, Ring, Gold are the
|
|
// glyph byte constants.)
|
|
const (
|
|
potionName = "potion"
|
|
scrollName = "scroll"
|
|
ringName = "ring"
|
|
goldName = "gold"
|
|
)
|
|
|
|
// Glyph returns the map/display character for this kind of object.
|
|
func (k ObjectKind) Glyph() byte {
|
|
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
|
switch k {
|
|
case KindPotion:
|
|
return Potion
|
|
case KindScroll:
|
|
return Scroll
|
|
case KindFood:
|
|
return Food
|
|
case KindWeapon:
|
|
return Weapon
|
|
case KindArmor:
|
|
return Armor
|
|
case KindRing:
|
|
return Ring
|
|
case KindWand:
|
|
return Stick
|
|
case KindAmulet:
|
|
return Amulet
|
|
case KindGold:
|
|
return Gold
|
|
}
|
|
|
|
return ' '
|
|
}
|
|
|
|
// String names the category the way the C type_name() did.
|
|
func (k ObjectKind) String() string {
|
|
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
|
switch k {
|
|
case KindPotion:
|
|
return potionName
|
|
case KindScroll:
|
|
return scrollName
|
|
case KindFood:
|
|
return "food"
|
|
case KindWeapon:
|
|
return "weapon"
|
|
case KindArmor:
|
|
return "suit of armor"
|
|
case KindRing:
|
|
return ringName
|
|
default:
|
|
return k.stringRest()
|
|
}
|
|
}
|
|
|
|
// objectKindForGlyph is the reverse of Glyph: what category of item does a
|
|
// map character denote. Returns KindNone for non-item characters.
|
|
func objectKindForGlyph(ch byte) ObjectKind {
|
|
switch ch {
|
|
case Potion:
|
|
return KindPotion
|
|
case Scroll:
|
|
return KindScroll
|
|
case Food:
|
|
return KindFood
|
|
case Weapon:
|
|
return KindWeapon
|
|
case Armor:
|
|
return KindArmor
|
|
case Ring:
|
|
return KindRing
|
|
case Stick:
|
|
return KindWand
|
|
case Amulet:
|
|
return KindAmulet
|
|
case Gold:
|
|
return KindGold
|
|
}
|
|
|
|
return KindNone
|
|
}
|
|
|
|
// MergesInPack reports whether picking up another of this kind merges into
|
|
// an existing pack entry's count (rogue.h ISMULT).
|
|
func (k ObjectKind) MergesInPack() bool {
|
|
return k == KindPotion || k == KindScroll || k == KindFood
|
|
}
|
|
|
|
// stringRest names the remaining kinds, including the ring-or-stick
|
|
// prompt pseudo-kind (the tail of the C type_name switch).
|
|
func (k ObjectKind) stringRest() string {
|
|
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
|
switch k {
|
|
case KindWand:
|
|
return "wand or staff"
|
|
case KindAmulet:
|
|
return "amulet"
|
|
case KindGold:
|
|
return goldName
|
|
case KindRingOrStick:
|
|
return "ring, wand or staff"
|
|
}
|
|
|
|
return "bizarre thing"
|
|
}
|
|
|
|
// Object is the _o arm of the C THING union: anything that can lie on the
|
|
// floor or ride in a pack.
|
|
type Object struct {
|
|
Kind ObjectKind // what kind of object it is (o_type)
|
|
Pos Coord // where it lives on the screen
|
|
Text string // what it says if you read it
|
|
Launch WeaponKind // what you need to launch it (noWeapon if none)
|
|
PackCh byte // what character it is in the pack
|
|
Damage DiceSpec // damage if used like sword
|
|
HurlDmg DiceSpec // damage if thrown
|
|
Count int // count for plural objects
|
|
Which int // which object of a type it is (index for the Kind's table)
|
|
HPlus int // plusses to hit
|
|
DPlus int // plusses to damage
|
|
ArmorClass int // armor protection (armor only)
|
|
Charges int // charges remaining (wands and staffs only)
|
|
GoldValue int // worth (gold piles only)
|
|
Bonus int // magic bonus (rings only: protection, add strength, ...)
|
|
Flags ObjFlags
|
|
Group int // group number for this object
|
|
Label string // label for object
|
|
}
|
|
|
|
// newObject is list.c new_item() for objects: a zeroed Object with the
|
|
// same non-zero defaults the C code relies on.
|
|
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) }
|
|
|
|
// ScrollKind returns Which as a scroll kind; valid only for KindScroll.
|
|
func (o *Object) ScrollKind() ScrollKind { return ScrollKind(o.Which) }
|
|
|
|
// RingKind returns Which as a ring kind; valid only for KindRing.
|
|
func (o *Object) RingKind() RingKind { return RingKind(o.Which) }
|
|
|
|
// WandKind returns Which as a wand kind; valid only for KindWand.
|
|
func (o *Object) WandKind() WandKind { return WandKind(o.Which) }
|
|
|
|
// WeaponKind returns Which as a weapon kind; valid only for KindWeapon.
|
|
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).
|
|
//
|
|
// The Which >= 0 arm is unreachable from the keyboard: createObj derives
|
|
// Which with byte arithmetic (int(ch-'a') + 10), which wraps to a large
|
|
// positive value rather than going negative. It is kept as
|
|
// defense-in-depth for the non-keyboard source — a decoded save file,
|
|
// where Which is an int off the wire and can hold anything — and is
|
|
// exercised there by TestRestoreRejectsOutOfRangeWhich.
|
|
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...)
|
|
}
|
|
|
|
// detachObj is list.c detach(): remove item (by identity) from a list.
|
|
func detachObj(list *[]*Object, item *Object) {
|
|
for i, o := range *list {
|
|
if o == item {
|
|
*list = append((*list)[:i], (*list)[i+1:]...)
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|