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.
384 lines
8.6 KiB
Go
384 lines
8.6 KiB
Go
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
|
package game
|
|
|
|
// scrolls.c — read a scroll and let it happen.
|
|
|
|
// readScroll reads a scroll from the pack and does the appropriate thing
|
|
// (scrolls.c read_scroll).
|
|
func (g *RogueGame) readScroll() {
|
|
p := &g.Player
|
|
|
|
obj, ok := g.promptPackItem("read", KindScroll)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if obj.Kind != KindScroll {
|
|
if !g.Options.Terse {
|
|
g.msg("there is nothing on it to read")
|
|
} else {
|
|
g.msg("nothing to read")
|
|
}
|
|
|
|
return
|
|
}
|
|
// Calculate the effect it has on the poor guy.
|
|
if obj == p.CurWeapon {
|
|
p.CurWeapon = nil
|
|
}
|
|
// Get rid of the thing
|
|
g.leavePack(obj, false, false)
|
|
|
|
if h := g.data.readHandler(obj); h != nil {
|
|
h(g, obj)
|
|
}
|
|
|
|
g.look(true) // put the result of the scroll on the screen
|
|
g.status()
|
|
|
|
// A malformed scroll has no lore entry to name (see quaff).
|
|
if obj.hasValidWhich() {
|
|
g.callIt(&g.Items.Scrolls[obj.Which])
|
|
}
|
|
}
|
|
|
|
// The per-scroll effect handlers, dispatched through
|
|
// gameData.readHandlers. Each is one case of the C read_scroll switch.
|
|
|
|
func (g *RogueGame) readMonsterConfusion(*Object) {
|
|
// Scroll of monster confusion. Give him that power.
|
|
g.Player.Flags.Set(CanConfuse)
|
|
g.msg("your hands begin to glow %s", g.pickColor("red"))
|
|
}
|
|
|
|
func (g *RogueGame) readEnchantArmor(*Object) {
|
|
p := &g.Player
|
|
if p.CurArmor != nil {
|
|
p.CurArmor.ArmorClass--
|
|
p.CurArmor.Flags.Clear(Cursed)
|
|
g.msg("your armor glows %s for a moment", g.pickColor("silver"))
|
|
}
|
|
}
|
|
|
|
func (g *RogueGame) readHoldMonster(*Object) {
|
|
// Hold monster scroll. Stop all monsters within two spaces from
|
|
// chasing after the hero.
|
|
held := g.holdMonstersNear()
|
|
|
|
if held > 0 {
|
|
g.addmsgf("the monster")
|
|
|
|
if held > 1 {
|
|
g.addmsgf("s around you")
|
|
}
|
|
|
|
g.addmsgf(" freeze")
|
|
|
|
if held == 1 {
|
|
g.addmsgf("s")
|
|
}
|
|
|
|
g.endmsg()
|
|
g.Items.Scrolls[ScrollHoldMonster].Know = true
|
|
} else {
|
|
g.msg("you feel a strange sense of loss")
|
|
}
|
|
}
|
|
|
|
// holdMonstersNear freezes every awake monster within two spaces of the
|
|
// hero and reports how many froze (the scan of the C S_HOLD case).
|
|
func (g *RogueGame) holdMonstersNear() int {
|
|
p := &g.Player
|
|
held := 0
|
|
|
|
for x := p.Pos.X - 2; x <= p.Pos.X+2; x++ {
|
|
if x < 0 || x >= NumCols {
|
|
continue
|
|
}
|
|
|
|
for y := p.Pos.Y - 2; y <= p.Pos.Y+2; y++ {
|
|
if y < 0 || y > NumLines-1 {
|
|
continue
|
|
}
|
|
|
|
if mp := g.Level.MonsterAt(y, x); mp != nil && mp.On(Awake) {
|
|
mp.Flags.Clear(Awake)
|
|
mp.Flags.Set(Held)
|
|
|
|
held++
|
|
}
|
|
}
|
|
}
|
|
|
|
return held
|
|
}
|
|
|
|
func (g *RogueGame) readSleep(*Object) {
|
|
// Scroll which makes you fall asleep
|
|
g.Items.Scrolls[ScrollSleep].Know = true
|
|
g.NoCommand += g.rnd(g.spread(5)) + 4 // SLEEPTIME
|
|
|
|
g.Player.Flags.Clear(Awake)
|
|
g.msg("you fall asleep")
|
|
}
|
|
|
|
func (g *RogueGame) readCreateMonster(*Object) {
|
|
// Create a monster: first look in a circle around him, next try
|
|
// his room, otherwise give up
|
|
mp, ok := g.createMonsterSpot()
|
|
if !ok {
|
|
g.msg("you hear a faint cry of anguish in the distance")
|
|
} else {
|
|
tp := &Monster{}
|
|
g.newMonster(tp, g.randMonster(false), mp)
|
|
}
|
|
}
|
|
|
|
// createMonsterSpot reservoir-samples a legal spot around the hero for a
|
|
// created monster; ok is false when every neighbor is blocked (the scan
|
|
// of the C S_CREATE case).
|
|
func (g *RogueGame) createMonsterSpot() (Coord, bool) {
|
|
p := &g.Player
|
|
i := 0
|
|
|
|
var mp Coord
|
|
|
|
for y := p.Pos.Y - 1; y <= p.Pos.Y+1; y++ {
|
|
for x := p.Pos.X - 1; x <= p.Pos.X+1; x++ {
|
|
// Don't put a monster on top of the player.
|
|
if y == p.Pos.Y && x == p.Pos.X {
|
|
continue
|
|
}
|
|
// Or anything else nasty
|
|
if ch := g.Level.VisibleChar(y, x); stepOk(ch) {
|
|
if ch == Scroll {
|
|
if fo := g.Level.ObjectAt(y, x); fo != nil &&
|
|
fo.ScrollKind() == ScrollScareMonster {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if i++; g.rnd(i) == 0 {
|
|
mp = Coord{Y: y, X: x}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return mp, i != 0
|
|
}
|
|
|
|
func (g *RogueGame) readIdentify(obj *Object) {
|
|
// Identify, let him figure something out. idType is shorter than the
|
|
// scroll table keying it (it stops after the last identify scroll),
|
|
// so the filter lookup carries its own bound. That bound is not
|
|
// reachable today: readHandlers registers readIdentify only for the
|
|
// identify scrolls, all of which sit inside idType. It is kept as
|
|
// defense-in-depth against a future table resize.
|
|
g.Items.Scrolls[obj.Which].Know = true
|
|
g.msg("this scroll is an %s scroll", g.Items.Scrolls[obj.Which].Name)
|
|
g.whatis(true, g.data.identifyType(obj.ScrollKind()))
|
|
}
|
|
|
|
func (g *RogueGame) readMagicMapping(*Object) {
|
|
// Scroll of magic mapping.
|
|
g.Items.Scrolls[ScrollMagicMapping].Know = true
|
|
g.msg("oh, now this scroll has a map on it")
|
|
// take all the things we want to keep hidden out of the window
|
|
for y := 1; y < NumLines-1; y++ {
|
|
for x := range NumCols {
|
|
g.revealSpot(y, x)
|
|
}
|
|
}
|
|
}
|
|
|
|
// revealSpot uncovers one map cell for magic mapping and draws it (the
|
|
// loop body of the C SCR_MAP case).
|
|
func (g *RogueGame) revealSpot(y, x int) {
|
|
pp := g.Level.At(y, x)
|
|
|
|
ch := revealChar(pp)
|
|
if ch != ' ' {
|
|
if tp := pp.Monst; tp != nil {
|
|
tp.OldCh = ch
|
|
if !g.Player.On(SenseMonsters) {
|
|
g.mvaddch(y, x, ch)
|
|
}
|
|
} else {
|
|
g.mvaddch(y, x, ch)
|
|
}
|
|
}
|
|
}
|
|
|
|
// revealChar decides what magic mapping shows at a cell, making secret
|
|
// doors, hidden passages, and hidden traps real as a side effect; ' '
|
|
// means show nothing (the switch of the C SCR_MAP loop).
|
|
func revealChar(pp *Place) byte {
|
|
ch := pp.Ch
|
|
pass := false
|
|
|
|
switch ch {
|
|
case Door, Stairs:
|
|
case '-', '|':
|
|
ch = revealWall(pp)
|
|
case ' ':
|
|
pass = revealSolid(pp)
|
|
case Passage:
|
|
pass = true
|
|
case Floor:
|
|
ch = revealFloor(pp)
|
|
default:
|
|
if pp.Flags.Has(FPassage) {
|
|
pass = true
|
|
} else {
|
|
ch = ' '
|
|
}
|
|
}
|
|
|
|
if pass {
|
|
if !pp.Flags.Has(FReal) {
|
|
pp.Ch = Passage
|
|
}
|
|
|
|
pp.Flags.Set(FSeen | FReal)
|
|
|
|
ch = Passage
|
|
}
|
|
|
|
return ch
|
|
}
|
|
|
|
// revealWall handles a wall cell for magic mapping: a secret door
|
|
// becomes a real door (the '-'/'|' arm).
|
|
func revealWall(pp *Place) byte {
|
|
if !pp.Flags.Has(FReal) {
|
|
pp.Ch = Door
|
|
pp.Flags.Set(FReal)
|
|
|
|
return Door
|
|
}
|
|
|
|
return pp.Ch
|
|
}
|
|
|
|
// revealSolid handles a blank cell for magic mapping, reporting whether
|
|
// it is passage: hidden passages become real; hidden things in walls
|
|
// stay hidden (the ' ' arm).
|
|
func revealSolid(pp *Place) bool {
|
|
if pp.Flags.Has(FReal) {
|
|
return pp.Flags.Has(FPassage)
|
|
}
|
|
|
|
pp.Flags.Set(FReal)
|
|
pp.Ch = Passage
|
|
|
|
return true
|
|
}
|
|
|
|
// revealFloor handles a floor cell for magic mapping: a hidden trap
|
|
// becomes a real, seen trap; real floor shows nothing (the FLOOR arm).
|
|
func revealFloor(pp *Place) byte {
|
|
if pp.Flags.Has(FReal) {
|
|
return ' '
|
|
}
|
|
|
|
pp.Ch = Trap
|
|
pp.Flags.Set(FSeen | FReal)
|
|
|
|
return Trap
|
|
}
|
|
|
|
func (g *RogueGame) readFoodDetection(*Object) {
|
|
// Food detection
|
|
found := false
|
|
|
|
g.scr.Hw.Clear()
|
|
|
|
for _, fo := range g.Level.Objects {
|
|
if fo.Kind == KindFood {
|
|
found = true
|
|
|
|
g.scr.Hw.MvAddCh(fo.Pos.Y, fo.Pos.X, Food)
|
|
}
|
|
}
|
|
|
|
if found {
|
|
g.Items.Scrolls[ScrollFoodDetection].Know = true
|
|
g.showWin("Your nose tingles and you smell food.--More--")
|
|
} else {
|
|
g.msg("your nose tingles")
|
|
}
|
|
}
|
|
|
|
func (g *RogueGame) readTeleportation(*Object) {
|
|
// Scroll of teleportation: make him disappear and reappear
|
|
p := &g.Player
|
|
curRoom := p.Room
|
|
|
|
g.teleport()
|
|
|
|
if curRoom != p.Room {
|
|
g.Items.Scrolls[ScrollTeleportation].Know = true
|
|
}
|
|
}
|
|
|
|
func (g *RogueGame) readEnchantWeapon(*Object) {
|
|
p := &g.Player
|
|
if p.CurWeapon == nil || p.CurWeapon.Kind != KindWeapon {
|
|
g.msg("you feel a strange sense of loss")
|
|
} else {
|
|
p.CurWeapon.Flags.Clear(Cursed)
|
|
|
|
if g.rnd(2) == 0 {
|
|
p.CurWeapon.HPlus++
|
|
} else {
|
|
p.CurWeapon.DPlus++
|
|
}
|
|
|
|
g.msg("your %s glows %s for a moment",
|
|
g.Items.Weapons[p.CurWeapon.Which].Name, g.pickColor("blue"))
|
|
}
|
|
}
|
|
|
|
func (g *RogueGame) readScareMonster(*Object) {
|
|
// Reading it is a mistake and produces laughter at her poor boo
|
|
// boo.
|
|
g.msg("you hear maniacal laughter in the distance")
|
|
}
|
|
|
|
func (g *RogueGame) readRemoveCurse(*Object) {
|
|
p := &g.Player
|
|
|
|
uncurse(p.CurArmor)
|
|
uncurse(p.CurWeapon)
|
|
uncurse(p.CurRing[Left])
|
|
uncurse(p.CurRing[Right])
|
|
g.msg("%s", g.chooseStr("you feel in touch with the Universal Onenes",
|
|
"you feel as if somebody is watching over you"))
|
|
}
|
|
|
|
func (g *RogueGame) readAggravateMonsters(*Object) {
|
|
// This scroll aggravates all the monsters on the current level
|
|
// and sets them running towards the hero
|
|
g.aggravate()
|
|
g.msg("you hear a high pitched humming noise")
|
|
}
|
|
|
|
func (g *RogueGame) readProtectArmor(*Object) {
|
|
p := &g.Player
|
|
if p.CurArmor != nil {
|
|
p.CurArmor.Flags.Set(Protected)
|
|
g.msg("your armor is covered by a shimmering %s shield",
|
|
g.pickColor("gold"))
|
|
} else {
|
|
g.msg("you feel a strange sense of loss")
|
|
}
|
|
}
|
|
|
|
// uncurse uncurses an item (scrolls.c uncurse).
|
|
func uncurse(obj *Object) {
|
|
if obj != nil {
|
|
obj.Flags.Clear(Cursed)
|
|
}
|
|
}
|