Compare commits
1 Commits
af3050b187
...
9dbd9d11cb
| Author | SHA1 | Date | |
|---|---|---|---|
| 9dbd9d11cb |
73
TODO.md
73
TODO.md
@@ -37,45 +37,40 @@ wizard commands).
|
|||||||
- 2026-08-09 Wizard-create bounds fix (`fix/wizard-which-bounds`, closes #10):
|
- 2026-08-09 Wizard-create bounds fix (`fix/wizard-which-bounds`, closes #10):
|
||||||
`createObj` stored the raw `0-f` nibble as `Object.Which` with no bounds
|
`createObj` stored the raw `0-f` nibble as `Object.Which` with no bounds
|
||||||
check, so wizard mode -> `C` -> `/` -> `f` produced a wand numbered 15 against
|
check, so wizard mode -> `C` -> `/` -> `f` produced a wand numbered 15 against
|
||||||
a 14-entry table and panicked in `fixStick`. Input outside `0-f` overshoots
|
a 14-entry table and panicked in `fixStick`; input below `'a'` or `'0'` went
|
||||||
much further rather than going negative: `readchar` returns a `byte`, so the
|
negative (`'A'` gives -22, `'!'` gives -54) and panicked the same way. C's
|
||||||
`int(ch-'a') + 10` branch is byte arithmetic and wraps — `'A'` gives 234 and
|
`create_obj()` was equally unchecked, but every C consumer was either a
|
||||||
`'!'` gives 202 — and panicked the same way. C's `create_obj()` was equally
|
`switch` (defined for any value) or a static-array read past the end
|
||||||
unchecked, but every C consumer was either a `switch` (defined for any value)
|
(undefined, and survivable in practice), whereas since refactor step 8 one
|
||||||
or a static-array read past the end (undefined, and survivable in practice),
|
game is one process, so the Go panic kills the game with the terminal still in
|
||||||
whereas since refactor step 8 one game is one process, so the Go panic kills
|
raw mode. Fixed at the two boundaries a bad `Which` can enter through:
|
||||||
the game with the terminal still in raw mode. Fixed at the two boundaries a
|
`createObj` now rejects an out-of-range choice with a message built from C's
|
||||||
bad `Which` can enter through: `createObj` now rejects an out-of-range choice
|
own `type_name()` vocabulary and adds nothing to the pack (a deliberate,
|
||||||
with a message built from C's own `type_name()` vocabulary and adds nothing to
|
commented divergence, since C had no defined behavior here to be faithful to),
|
||||||
the pack (a deliberate, commented divergence, since C had no defined behavior
|
and `Restore` refuses a snapshot describing such an object (`ErrSaveCorrupt`)
|
||||||
here to be faithful to), and `Restore` refuses a snapshot describing such an
|
instead of loading a game that would explode later. Behind those,
|
||||||
object (`ErrSaveCorrupt`) instead of loading a game that would explode later.
|
`whichLimit`/`hasValidWhich` back defensive guards at every dispatch named in
|
||||||
Behind those, `whichLimit`/`hasValidWhich` back defensive guards at every
|
the issue: the three effect tables (the new `quaffHandler`, `readHandler`, and
|
||||||
dispatch named in the issue: the three effect tables (the new `quaffHandler`,
|
`zapHandler` accessors return no handler rather than indexing — for wands that
|
||||||
`readHandler`, and `zapHandler` accessors return no handler rather than
|
is exactly non-`MASTER` C, which matched no case and still ran `o_charges--`),
|
||||||
indexing — for wands that is exactly non-`MASTER` C, which matched no case and
|
the `callIt` lore lookups, `identifyType` (whose table is shorter than the
|
||||||
still ran `o_charges--`), the `callIt` lore lookups, `identifyType` (whose
|
scroll table keying it), `armorClass` for all four `a_class[]` reads,
|
||||||
table is shorter than the scroll table keying it, though no scroll that can
|
`initWeapon` against the missing `init_dam[]` row for `WeaponFlame`,
|
||||||
reach `readIdentify` overshoots it, so that one is defensive rather than a
|
`fixStick`'s `ws_type[]` read, and `inventoryName`, hoisted so one check
|
||||||
live bound), `armorClass` for the four `a_class[]` reads, `initWeapon` against
|
covers the scroll-title read the issue listed plus its potion-color,
|
||||||
the missing `init_dam[]` row for `WeaponFlame`, `fixStick`'s `ws_type[]` read,
|
ring-stone, wand-material, weapon and armor siblings. `objectWorth` got the
|
||||||
and `inventoryName`, hoisted so one check covers the scroll-title read the
|
same hoisted guard, since the death-screen appraisal reads the identical
|
||||||
issue listed plus its potion-color, ring-stone, wand-material, weapon and
|
per-kind tables. No in-range input changes behavior and no guard consumes a
|
||||||
armor siblings. `objectWorth` got the same hoisted guard, since the
|
random number — the rejection precedes every `rnd()` call, verified both by an
|
||||||
death-screen appraisal reads the identical per-kind tables. No in-range input
|
explicit seed-unchanged test and by `TestSeedCompatItemTables` staying green
|
||||||
changes behavior and no guard consumes a random number — the rejection
|
untouched. New `game/wizard_test.go`: the exact reproducer, a rejection sweep
|
||||||
precedes every `rnd()` call, verified both by an explicit seed-unchanged test
|
over every indexed kind including both negative-input forms, an acceptance
|
||||||
and by `TestSeedCompatItemTables` staying green untouched. New
|
sweep proving valid choices still build the right item, one no-panic test per
|
||||||
`game/wizard_test.go`: the exact reproducer, a rejection sweep over every
|
guarded family (wand/potion/scroll/armor/weapon), the `fixStick` crash site,
|
||||||
indexed kind including both wrapping-input forms, an acceptance sweep proving
|
the corrupt-save rejection, and a check that `whichLimit` still agrees with
|
||||||
valid choices still build the right item, one no-panic test per guarded family
|
the table sizes. Each guard was confirmed load-bearing by reverting it and
|
||||||
(wand/potion/scroll/armor/weapon), the `fixStick` crash site, the corrupt-save
|
watching the test panic. `Next Step` deliberately not rotated: this was
|
||||||
rejection over the wrapped values and a negative `Which` (a decoded snapshot
|
out-of-band issue work.
|
||||||
is the only source of one, so it is what exercises the `Which >= 0` arm of
|
|
||||||
`hasValidWhich`), 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. `Next Step` deliberately not rotated: this was out-of-band issue
|
|
||||||
work.
|
|
||||||
|
|
||||||
- 2026-08-09 Stale-docs correction (`docs-staleness`, closes #3): four claims in
|
- 2026-08-09 Stale-docs correction (`docs-staleness`, closes #3): four claims in
|
||||||
`MEMORY.md`/`TODO.md`/`README.md` had gone false and were misdirecting agents
|
`MEMORY.md`/`TODO.md`/`README.md` had gone false and were misdirecting agents
|
||||||
|
|||||||
@@ -219,13 +219,6 @@ func (o *Object) ArmorKind() ArmorKind { return ArmorKind(o.Which) }
|
|||||||
// satisfy it; the wizard-create command and a restored save file are the
|
// 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
|
// only two ways a malformed one can appear, and both reject it (see
|
||||||
// createObj and validateSnapshotObjects).
|
// 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 {
|
func (o *Object) hasValidWhich() bool {
|
||||||
limit := whichLimit(o.Kind)
|
limit := whichLimit(o.Kind)
|
||||||
|
|
||||||
|
|||||||
@@ -169,12 +169,10 @@ func (g *RogueGame) createMonsterSpot() (Coord, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (g *RogueGame) readIdentify(obj *Object) {
|
func (g *RogueGame) readIdentify(obj *Object) {
|
||||||
// Identify, let him figure something out. idType is shorter than the
|
// Identify, let him figure something out. readHandler has already
|
||||||
// scroll table keying it (it stops after the last identify scroll),
|
// bounded Which against the scroll table to get here, but idType is
|
||||||
// so the filter lookup carries its own bound. That bound is not
|
// shorter than that table (it stops after the last identify scroll),
|
||||||
// reachable today: readHandlers registers readIdentify only for the
|
// so the filter lookup carries its own bound.
|
||||||
// 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.Items.Scrolls[obj.Which].Know = true
|
||||||
g.msg("this scroll is an %s scroll", g.Items.Scrolls[obj.Which].Name)
|
g.msg("this scroll is an %s scroll", g.Items.Scrolls[obj.Which].Name)
|
||||||
g.whatis(true, g.data.identifyType(obj.ScrollKind()))
|
g.whatis(true, g.data.identifyType(obj.ScrollKind()))
|
||||||
|
|||||||
@@ -897,10 +897,7 @@ func (d *gameData) zapHandler(obj *Object) func(g *RogueGame, obj *Object) bool
|
|||||||
|
|
||||||
// identifyType reads extern.c's identify-scroll to item-kind map,
|
// identifyType reads extern.c's identify-scroll to item-kind map,
|
||||||
// returning KindNone for any scroll past the last identify scroll (the
|
// returning KindNone for any scroll past the last identify scroll (the
|
||||||
// table is shorter than the scroll table it is keyed by). No scroll that
|
// table is shorter than the scroll table it is keyed by).
|
||||||
// can reach readIdentify is past that end, so the guard is defensive
|
|
||||||
// rather than a live bound; it exists so a future table resize cannot
|
|
||||||
// turn the lookup into a panic.
|
|
||||||
func (d *gameData) identifyType(kind ScrollKind) ObjectKind {
|
func (d *gameData) identifyType(kind ScrollKind) ObjectKind {
|
||||||
if kind < 0 || int(kind) >= len(d.idType) {
|
if kind < 0 || int(kind) >= len(d.idType) {
|
||||||
return KindNone
|
return KindNone
|
||||||
|
|||||||
@@ -27,16 +27,14 @@ func (g *RogueGame) createObj() {
|
|||||||
g.Msgs.Mpos = 0
|
g.Msgs.Mpos = 0
|
||||||
|
|
||||||
// Deliberate divergence from 5.4.4: C stored this nibble unchecked, so
|
// Deliberate divergence from 5.4.4: C stored this nibble unchecked, so
|
||||||
// 'a'-'f' indexed straight past the ends of the per-kind static
|
// 'a'-'f' (and anything below '0' or 'a', which goes negative) indexed
|
||||||
// tables. Input outside '0'-'9' and 'a'-'f' overshoots much further:
|
// straight past the ends of the per-kind static tables. That was
|
||||||
// readchar returns a byte, so ch-'a' above is byte arithmetic and
|
// undefined behavior C happened to survive by reading adjacent memory;
|
||||||
// wraps instead of going negative ('A' gives 234, '!' gives 202).
|
// in Go it is a panic that kills the process with the terminal still in
|
||||||
// Reading past a static array was undefined behavior C happened to
|
// raw mode. C had no defined behavior here to be faithful to, so the
|
||||||
// survive by picking up adjacent memory; in Go it is a panic that
|
// choice is rejected outright rather than emulating a garbage read. The
|
||||||
// kills the process with the terminal still in raw mode. C had no
|
// check precedes every rnd() call below, so the RNG sequence is
|
||||||
// defined behavior here to be faithful to, so the choice is rejected
|
// untouched either way.
|
||||||
// outright rather than emulating a garbage read. The check precedes
|
|
||||||
// every rnd() call below, so the RNG sequence is untouched either way.
|
|
||||||
if !obj.wizardCanCreate() {
|
if !obj.wizardCanCreate() {
|
||||||
g.msg("there is no such %s", obj.Kind)
|
g.msg("there is no such %s", obj.Kind)
|
||||||
|
|
||||||
|
|||||||
@@ -47,12 +47,9 @@ func TestCreateObjWandFReproducer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateObjRejectsOutOfRangeWhich sweeps the rejection across every
|
// TestCreateObjRejectsOutOfRangeWhich sweeps the rejection across every
|
||||||
// kind whose Which is a table index, including input outside '0'-'f'.
|
// kind whose Which is a table index, including the negative values that
|
||||||
// isDigit is false for such input, so it takes the letter branch, where
|
// input below 'a' and below '0' produce ('A'-'a'+10 == -22, '!'-'a'+10 ==
|
||||||
// ch-'a' is byte arithmetic and wraps rather than going negative: 'A'
|
// -54; isDigit is false for both, so both take the letter branch).
|
||||||
// (65) gives int(224)+10 == 234 and '!' (33) gives int(192)+10 == 202.
|
|
||||||
// Those far-past-the-end values, not negative ones, are what the guard
|
|
||||||
// has to catch on the keyboard path.
|
|
||||||
func TestCreateObjRejectsOutOfRangeWhich(t *testing.T) {
|
func TestCreateObjRejectsOutOfRangeWhich(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -65,14 +62,14 @@ func TestCreateObjRejectsOutOfRangeWhich(t *testing.T) {
|
|||||||
{"potion f is past NumPotionTypes", Potion, 'f'},
|
{"potion f is past NumPotionTypes", Potion, 'f'},
|
||||||
{"ring f is past NumRingTypes", Ring, 'f'},
|
{"ring f is past NumRingTypes", Ring, 'f'},
|
||||||
// NumScrollTypes is 18, past the 'f' the prompt tops out at, so a
|
// NumScrollTypes is 18, past the 'f' the prompt tops out at, so a
|
||||||
// scroll can only be driven out of range by input that wraps.
|
// scroll can only be driven out of range by going negative.
|
||||||
{"scroll 'A' wraps to 234", Scroll, 'A'},
|
{"negative below 'a' for scroll", Scroll, 'A'},
|
||||||
{"armor 9 is past NumArmorTypes", Armor, '9'},
|
{"armor 9 is past NumArmorTypes", Armor, '9'},
|
||||||
{"weapon 9 is the flame pseudo-weapon", Weapon, '9'},
|
{"weapon 9 is the flame pseudo-weapon", Weapon, '9'},
|
||||||
{"wand 'A' wraps to 234", Stick, 'A'},
|
{"negative: letter below 'a'", Stick, 'A'},
|
||||||
{"wand '!' wraps to 202", Stick, '!'},
|
{"negative: character below '0'", Stick, '!'},
|
||||||
{"armor 'A' wraps to 234", Armor, 'A'},
|
{"negative below 'a' for armor", Armor, 'A'},
|
||||||
{"weapon '!' wraps to 202", Weapon, '!'},
|
{"negative below '0' for weapon", Weapon, '!'},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -318,30 +315,9 @@ func TestFixStickMalformedWhichDoesNotPanic(t *testing.T) {
|
|||||||
// TestRestoreRejectsOutOfRangeWhich is the save-file half of the fix: a
|
// TestRestoreRejectsOutOfRangeWhich is the save-file half of the fix: a
|
||||||
// malformed object must not be able to sneak past the keyboard guard by
|
// malformed object must not be able to sneak past the keyboard guard by
|
||||||
// arriving in a snapshot.
|
// arriving in a snapshot.
|
||||||
//
|
|
||||||
// A decoded save is also the only place a *negative* Which can come
|
|
||||||
// from. On the keyboard path createObj's ch-'a' is byte arithmetic and
|
|
||||||
// wraps, so 'A' and '!' land at 234 and 202; Which is a plain int in the
|
|
||||||
// gob stream, so a tampered file can carry any value at all. Both shapes
|
|
||||||
// are covered here, and the negative case is what exercises the
|
|
||||||
// Which >= 0 arm of hasValidWhich.
|
|
||||||
func TestRestoreRejectsOutOfRangeWhich(t *testing.T) {
|
func TestRestoreRejectsOutOfRangeWhich(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
which int
|
|
||||||
}{
|
|
||||||
{"one past the wand table", int(NumWandTypes)},
|
|
||||||
{"the value 'A' wraps to on the keyboard path", 234},
|
|
||||||
{"the value '!' wraps to on the keyboard path", 202},
|
|
||||||
{"negative, reachable only from a tampered file", -1},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
g := mkGame(t, 11)
|
g := mkGame(t, 11)
|
||||||
st := g.snapshot()
|
st := g.snapshot()
|
||||||
|
|
||||||
@@ -350,7 +326,7 @@ func TestRestoreRejectsOutOfRangeWhich(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
st.Player.Body.Pack[0].Kind = KindWand
|
st.Player.Body.Pack[0].Kind = KindWand
|
||||||
st.Player.Body.Pack[0].Which = tc.which
|
st.Player.Body.Pack[0].Which = int(NumWandTypes)
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "rogue.save")
|
path := filepath.Join(t.TempDir(), "rogue.save")
|
||||||
writeSnapshot(t, path, st)
|
writeSnapshot(t, path, st)
|
||||||
@@ -364,8 +340,6 @@ func TestRestoreRejectsOutOfRangeWhich(t *testing.T) {
|
|||||||
if statErr != nil {
|
if statErr != nil {
|
||||||
t.Error("a rejected save file was deleted; it should be left alone")
|
t.Error("a rejected save file was deleted; it should be left alone")
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeSnapshot gob-encodes a snapshot to path the way saveFile does.
|
// writeSnapshot gob-encodes a snapshot to path the way saveFile does.
|
||||||
|
|||||||
Reference in New Issue
Block a user