Restore three lost C behaviors: schtick message, forced redraw, greeting (closes #13) #32
Reference in New Issue
Block a user
Delete Branch "fix/lost-c-behaviors"
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?
Closes #13.
Three behaviours from 5.4.4 that the port dropped silently, one commit
(the WIP checkpoint
3f0a14cis amended away, not preserved).sticks.c237 — theotherwisearm closingdo_zap's switchprints
"what a bizarre schtick!". It is under#ifdef MASTER, notunder a runtime
wizardtest, so it is unconditional in this port,and
WS_NOPis a case of the switch in its own right, so it stayssilent and still spends a charge.
command.c288-291 —CTRL('R')forces a full repaint through anew
Terminal.Repaint(tcellScreen.Sync), not the diffing refresh.main.c107-113 — the startup greeting, both wordings, printed onstdout before the screen is taken.
Full detail is in the commit message and the TODO.md Completed Steps
entry.
Next Stepis deliberately not rotated (out-of-band issue work);nothing here is deliberately dropped, so ARCHITECTURE.md section 9 is
unchanged, while 5.3 gains
Repaint.Two defects in the inherited WIP checkpoint were found and fixed —
see the PR comment below.
Three behaviors from 5.4.4 that the port dropped silently. Each is a few lines; grouped because they are all "restore something C did". 1. sticks.c 237: the "otherwise" arm closing do_zap's switch printed "what a bizarre schtick!", and doZap had turned it into doing nothing. The arm is under #ifdef MASTER, not under a runtime wizard test, so in the MASTER build this port is it printed for every player and must not be gated on g.Wizard. WS_NOP is a case of that switch in its own right ("when WS_NOP: break;"), so "no handler ran" cannot be the trigger: the wand of nothing does nothing quietly. C's switch covers all 14 WS_ values, so its otherwise is reachable only for an o_which outside the table, which is what Object.hasValidWhich already screens for. All three arms fall through to obj.Charges--, as C's do. 2. command.c 288-291: CTRL('R') is "after = FALSE; clearok(curscr, TRUE); wrefresh(curscr);" — a forced full repaint. The port called g.refresh(), the ordinary diffing blit, which cannot fix the only situation the command exists for: a screen corrupted by another program's output leaves the game's record of it still correct, so the diff sends nothing. New Terminal.Repaint (tcell Screen.Sync, which discards tcell's record of the terminal rather than diffing against it), Screen.Repaint and g.repaint(), implemented in term.Tcell and in both headless test terminals. Named for the curses operation: the interface is the game's abstraction, not tcell's. It repaints what was last rendered — C repainted curscr, not stdscr — so it takes no window. 3. main.c 107-113: the startup greeting existed nowhere in the tree. New game.Greeting, printed on stdout by cmd/rogue/main.go before term.New(), the port's initscr(). Only the wizard wording is #ifdef MASTER; the other is unconditional. The %d is dnum, which main.c has just assigned to seed, so it is Params.Seed. Neither wording ends in a newline. Two placement details the tests pin: the printf sits after parse_opts, so a ROGUEOPTS name= is what the player is greeted by; and it sits after the -s/-d handling and after restore(), which never returns, so a resumed game does not announce that a dungeon is being dug (digsNewDungeon). Greeting parses ROGUEOPTS into a throwaway game built the way New builds the real one, tables and home directory included: ParseOpts handles every option, not just the one the greeting reads, and inven= is matched against inv_t_name[], which lives on the game. All three message strings verified byte-for-byte against origin/c-master sticks.c and main.c. No RNG call is added on any path and nothing under game/testdata/ changed; TestSeedCompatItemTables is green against the untouched golden. Mutation-proved, each behavior removed in turn with only its own test failing: dropping the message arm fails TestZapUnhandledWandSaysBizarreSchtick; extending the message to WS_NOP fails TestZapWandOfNothingIsSilent; putting g.refresh() back fails TestRedrawCommandForcesFullRepaint; swapping the two wordings, and ignoring the ROGUEOPTS name, both fail TestGreeting; greeting on the restore path fails TestDigsNewDungeon. ARCHITECTURE.md 5.3 gains Repaint and why a blit cannot substitute for it; nothing here is deliberately dropped, so section 9 is unchanged. TODO.md gets a Completed Steps entry; Next Step deliberately not rotated, this being out-of-band issue work.Two defects in the inherited WIP checkpoint
The
3f0a14ccheckpoint was treated as a starting point, not a foundation.Both of these are fixed here.
1.
Greetingcrashed the game at startup for any player withinven=inROGUEOPTS. It parsed the options into a bare&RogueGame{Whoami: ...},but
ParseOptshandles every option, not just thename=the greetingreads. The
inven=arm matches the value againstinv_t_name[](
options.c parse_opts), which lives ong.data— nil on a bare game.ROGUEOPTS="inven=slow"therefore nil-dereferenced before the player saw asingle character. Reproduced first as a failing test case, which is now a
regression test ("ROGUEOPTS inventory style parses without a fault"); the
throwaway game is now built the way
Newbuilds the real one, tables andhome directory included.
2. The checkpoint did not pass lint either, not only
fmt-check. Twoissues:
forbidigoonfmt.Printincmd/rogue/main.go(the repo'sconvention for stdout is
_, _ = fmt.Fprint(os.Stdout, ...), as ingame/score.goandterm/tcell.go), andgoconston the repeated accountname in
greeting_test.go. Both fixed; the greeting keeps its lack of atrailing newline. C's
fflush(stdout)has no counterpart becauseos.Stdoutis unbuffered.Everything else in the checkpoint was read line by line and stands.
C verification
Sources read via
git show origin/c-master:...(never checked out), withrogue.h's#define when break;case/#define otherwise break;defaultinmind.
Message text, byte-for-byte. Each Go literal was matched with a
fixed-string grep against the exact C statement:
sticks.c237msg("what a bizarre schtick!");game/sticks.gomain.c109 (MASTER)printf("Hello %s, welcome to dungeon #%d", whoami, dnum);game/game.gomain.c112printf("Hello %s, just a moment while I dig the dungeon...", whoami);game/game.goPlacement and gating.
#ifdef MASTER, not inside a runtimewizardtest, so this MASTER-compiled port prints it for every player.It is not gated on
g.Wizard— that would be #11's trap in reverse.WS_NOPiswhen WS_NOP: break;, a case of the switch in its own right,and all arms fall out into
obj->o_charges--. C's switch covers all 14WS_values (0..13, verified againstrogue.h294-307, which the GoWandKindenum matches one-for-one), sootherwiseis reachable only foran
o_whichoutside the table — exactly whatObject.hasValidWhichscreens for.
WandNothingis the one kind with no handler and a validWhich, so the three-way switch needs no new state.command.c288-291 isafter = FALSE; clearok(curscr,TRUE); wrefresh(curscr);— norefresh()of stdscr, and the command looprefreshes before the next key read anyway.
main.c: only the wizard arm is#ifdef MASTER; the normal wording isunconditional. Neither has a trailing newline. The
%disdnum, andseed = dnumis assigned a few lines above, so it isParams.Seed. Theprintf sits after
parse_opts(env)(so aROGUEOPTSname=is what theplayer is greeted by), and after the
-s/-dhandling (bothexit())and after
restore()(which never returns) — so only a new dungeongreets.
Mutation results
Each behaviour was removed in turn,
make testrun, and the mutationreverted. In every case exactly the intended test failed and nothing else
did.
g.msg("what a bizarre schtick!")from the default armTestZapUnhandledWandSaysBizarreSchtickcase obj.hasValidWhich()arm, soWandNothingspeaksTestZapWandOfNothingIsSilentCTRL('R')back tog.repaint()→g.refresh()TestRedrawCommandForcesFullRepaintTestGreeting(all 5 sub-cases)ROGUEOPTS, always uses the account nameTestGreeting(the 2 sub-cases that setname=)digsNewDungeondrops the restore testTestDigsNewDungeon/restore_a_saveMutation 2 is the one the previous session died on and never confirmed: it
is now proved that the
WandNothingsilence is pinned by a test of its own,independently of the message's presence for unhandled kinds.
Separately, the nil-dereference in defect 1 above was proved by a failing
test before the fix, which is the same discipline in reverse.
Verification
make checkfully green:fmt-checkclean (make fmtwas run and foldedin),
golangci-lint0 issues, tests pass with-timeout 30s -race -cover.GOLANGCI_LINT_CACHEin afresh empty directory outside the worktree, and was retried until it
reported neither the parallel-lint error nor any path outside this
worktree. One run did hit
parallel golangci-lint is runningand wasdiscarded and retried, so the isolation-plus-retry pair earned its keep.
The
gomodguarddeprecation warning (#29) is present and untouched.TestSeedCompatItemTablespasses against its untouched golden;git diffagainstmainshows zero changes undergame/testdata/. NoRNG call is added on any path:
Greetingruns beforeNew, andParseOptsnever reachesrnd..golangci.ymlunmodified.maketargets only; no rawgo/linterinvocations.
TODO.mdhas a Completed Steps entry in this same commit andNext Stepis not rotated (out-of-band issue work).
Repaintand the reason a blit cannotsubstitute for it. Section 9 is unchanged — item 2 was not split out,
so nothing from this issue remains deliberately dropped.
1142f43; the WIP checkpoint is amended away, not preserved.Verdict: PASS
Independent adversarial review of
1142f43againstmain@727dfb2. Reviewed in a throwaway worktree; nothing changed, nothing committed. Every claim in the PR body and the PR comment was re-derived from the C sources and from a local run, not taken on trust.Definition of done (issue #13)
WandNothingdoes notgame/sticks.go48-58)CTRL('R')forces a full repaint via a newTerminalmethodgame/screen.go,term/tcell.go,game/tables.go)game/game.goGreeting,cmd/rogue/main.go47-49)make checkgreenTODO.mdupdated in the same commit(closes #13)Primary focus 1 — the inherited nil dereference
(a) The crash was real. Reproduced by reverting the fix to a bare
&RogueGame{Whoami: params.Name}:TestGreeting/ROGUEOPTS_inventory_style_parses_without_a_faultfails withpanic: runtime error: invalid memory address or nil pointer dereference. The path isParseOpts→parseOptName→parseOptValue(op.kind == optInvT) →parseInvType, which ranges overg.data.invTName(game/options.go434) on a nilg.data.(b) The fix is complete. I enumerated everything
ParseOptscan reach, viaoptList(game/options.go30-45), and checked each against the throwaway game ingame/game.go185-189:&o.Terse,&o.FightFlush,&o.Jump,&o.SeeFloor,&o.PassGo,&o.Tombstone,&o.InvType—Optionsis an embedded value struct, so field pointers into a zero-valuedRogueGameare valid; no nil possible.&g.Whoami,&g.Fruit,&g.FileName— plain string fields; assignment only.g.Home— read byparseOptValue's~expansion (game/options.go402); a string, so at worst a wrong prefix, never a fault. Populated anyway.g.data.invTName— the only pointer dereference, populated bynewGameData().That is the complete reachable set. No other option can fault a hand-built game, so this is not a partial fix.
(c) The regression test genuinely covers it. The
inven=slow,name=Rodneycase exercises the exact arm that faulted, and it is the only sub-case that fails under the reverted fix — so it is pinned by that case specifically, not incidentally by another.Primary focus 2 — the
WandNothingdistinctionrogue.h294-307WS_LIGHT..WS_CANCEL(0..13) plusMAXSTICKS 14againstgame/types.go324-339WandLight..WandCancellationplusNumWandTypes. Index-for-index identical, includingWS_MISSILE=WandMagicMissile(6) andWS_NOP=WandNothing(10).WandNothingis genuinely the only validWhichwith no handler.zapHandlers(game/tables.go670-684) has an entry for every index exceptWandNothing; the array is[NumWandTypes], andzapHandler(game/tables.go894-900) returns nil for anything failinghasValidWhich.whichLimit(KindWand)isNumWandTypes(game/object.go188-189), so the three arms of the switch partition the space exactly.game/sticks.go50-58: handler / valid-Which/ neither, all three falling out toobj.Charges--on line 60, matching C's fall-out toobj->o_charges--. Not wrapped inif g.Wizard— correct, and see below.WandNothing— asserted inTestZapWandOfNothingIsSilent, and structurally guaranteed by thedefault-less fallthrough to line 60.case obj.hasValidWhich():soWandNothingspeaks:make testfails with exactly--- FAIL: TestZapWandOfNothingIsSilentand nothing else (2 FAIL lines in the log, both that same test across the run and the verbose rerun). Collateral: none. Mutation reverted; worktree clean.Message strings byte-for-byte
Read via
git show origin/c-master:...;c-masternever checked out.sticks.c— the arm is at line 237 and ismsg("what a bizarre schtick!");, wrapped in#ifdef MASTER/#endif, not in a runtimewizardtest. Confirmed by reading the raw bytes: the preceding lines arewhen WS_NOP:/break;and the#ifdef MASTERopens immediately after, with#endifbefore the closing brace. The distinction is exactly as the finisher stated, and the Go code is correctly unconditional.main.c107-113 —printf("Hello %s, welcome to dungeon #%d", whoami, dnum);andprintf("Hello %s, just a moment while I dig the dungeon...", whoami);. Both match the Go literals character for character, including the three-dot ellipsis, the#before%d, the comma placement, and the absence of any\n.TestGreetingasserts the no-newline property explicitly.Greeting placement and the
%dEvery one of the four claims verified against
main.c:#ifdef MASTER; theelseand the normalprintfsit outside it, so the normal wording is unconditional. Go mirrors this with a plainif params.Wizard.%disdnum, andseed = dnum;is assigned atmain.cline 65, above the printf.Params.Seedis the right value;Newalso setsDnum: int(params.Seed), so the greeting and the game agree.parse_opts(env)(line 55), so a ROGUEOPTSname=is what greets — matched byGreetingre-runningParseOpts, and pinned by twoTestGreetingcases.-sand-dhandling (bothexit(0)) and afterrestore()(never returns).digsNewDungeonreproduces this, and-sis additionally excluded by returning before the greeting inrun.TestDigsNewDungeoncovers all four combinations.command.c288-291 also confirmed verbatim:after = FALSE; clearok(curscr,TRUE); wrefresh(curscr);, with norefresh()of stdscr.RNG order
git diff origin/main..HEAD --name-onlylists nothing undergame/testdata/.TestSeedCompatItemTablesPASSes in the verbose log.Greetingruns beforeNew, and theParseOptschain reaches no RNG call — verified by readingparseOptName,parseOptValue,parseInvType,strucpy.Forced repaint
term.Tcell.Repaintist.screen.Sync(), which discards tcell's model of the physical screen and redraws every cell — a genuine forced repaint, notShow()'s diff. All threeTerminalimplementations are updated:term/tcell.go87,game/term_test.go21,game/autosave_test.go462. Grep forfunc (…) Render(returns exactly those three types, so no implementation was missed.Screen.Repaintnil-guards the device the same wayRefresh/Finido.TestRedrawCommandForcesFullRepaintwatches the terminal, not the window, which is the only place the difference is observable, and also assertsAfterstays false.Lint fixes
forbidigo: the new stdout write is_, _ = fmt.Fprint(os.Stdout, …) // CLI output. The convention claim is true —game/score.go232 is_, _ = fmt.Fprintln(os.Stdout, line) // CLI outputandterm/tcell.go218 is the same shape. Not suppressed.goconst: fixed by theconst account = "conan"ingreeting_test.go, andconst bizarreSchtickineffects_test.goshares the literal between the present/absent pair. Not suppressed.//nolintbeyond the two//nolint:testpackagefile headers on the new white-box test files, which is the approved 2026-07-07 convention.Commit and branch hygiene
3f0a14cis not an ancestor of1142f43— the WIP checkpoint is gone, not preserved.Restore three lost C behaviors: schtick message, forced redraw, greeting (closes #13).sneak <sneak@sneak.berlin>. No trailers.merge-base(origin/main, HEAD) == origin/main, so fast-forwardable; Gitea reports mergeable.git diff --checkclean. No scripted-edit artifacts..golangci.ymlsha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb, not in the diff.script/in this repo (Makefile records the exemption); no such files touched. The head commit carries no CI statuses because the repo has no workflow, soneeds-checksdoes not apply.t.Parallel(), including sub-tests.Docs
ARCHITECTURE.md §5.3 gains
Repaintin both theTerminallisting and theScreenlisting, plus the paragraph on why a blit cannot substitute. §9 is untouched, which is right: item 2 was delivered rather than split out, so nothing new is deliberately dropped. TODO.md gains a Completed Steps entry andNext Stepis not rotated.Local gate
Run in a private worktree with
GOLANGCI_LINT_CACHEpointed at a fresh empty directory outside the worktree.make fmt-check: clean (gofmt and prettier).make lint:0 issues. Noparallel golangci-lint is running, and no reported path outside the worktree, so the run is valid. The expectedgomodguarddeprecation warning (#29) is present and is not a finding.make testwithGOFLAGS=-count=1, three separate runs: all green, no(cached)markers, race detector clean each time.cmd/rogue29.7%,game49.4%.Non-blocking observations (not defects, no rework required)
game/effects_test.goTestZapWandOfNothingIsSilentassertsMsgs.Huh != bizarreSchtickrather thanMsgs.Huh == "". It catches the mutation that matters and the one C actually forbids, but a hypothetical mutation emitting some other message would slip past. Tightening to an empty-string assertion would close that, since the test already clearsHuhfirst.GreetinginheritsNew's handling of a degenerateROGUEOPTS="name=": C falls back to the account name whenwhoami[0] == '\0'afterparse_opts(main.c55-57), whereas the port pre-seedsWhoamiand lets an explicit emptyname=blank it. This is pre-existing inNewand the greeting is consistent with the game it announces, so changing it here would be scope creep and would desynchronise the two. Worth an issue only if seed-faithfulness ofwhoamiever matters.Manager notes (the review is in its own comment above).
Verdict accepted: PASS. Labeling
merge-readyand merging directly —mainis unprotected here, so this does not go tosneakdespite thereviewer's suggestion.
This PR nearly shipped a startup crash, and the recovery process is what
caught it. The original session died mid-verification, leaving ~300
uncommitted lines in a temp worktree. Had that been treated as "almost done"
and finished mechanically,
Greetingwould have gone in building a bare&RogueGame{}and handing it toParseOpts— which handles every option,not just the
name=the greeting reads. Any player withinven=inROGUEOPTSwould have hit a nil-pointer panic before the game started.The finisher reproduced it as a failing test before fixing it, which is
the right order and is what
MEMORY.mdasks for.The check I most wanted was completeness, not correctness, and it was done
properly. A fix that stops
inven=crashing while leaving some other optioncrashing is the same bug wearing a different hat. The reviewer enumerated the
entire reachable set through
optList— seven&o.*Options pointers (avalue struct, valid on a zero
RogueGame),&g.Whoami,&g.Fruit,&g.FileName,g.Home, andg.data.invTNameas the only pointer deref — andconfirmed every one is now populated. That is the difference between "the
reported crash is fixed" and "this class of crash is closed".
The
WandNothingcase is finally proven. It was the exact mutation thedead session never reached, and it is the whole point of item 1 — the message
must fire for an unhandled kind but not for one that deliberately does
nothing. The reviewer reproduced the mutation independently (deleting the
hasValidWhicharm fails onlyTestZapWandOfNothingIsSilent) and verifiedthe partition is exact: the
WandKindenum matchesrogue.h294-307index-for-index,
zapHandlersomits onlyWandNothing, andwhichLimit(KindWand) == NumWandTypes. Charges decrement on all three arms,matching C.
One C detail worth recording because it changes when the message fires:
sticks.c:237is inside#ifdef MASTER, not gated on a runtimewizardtest. So it is unconditional in this port and correctly not gated on
g.Wizard. Getting that wrong would have hidden the message from ordinaryplay.
A correction to my own status comment on #13: I wrote that the WIP
checkpoint failed only
fmt-check. It also failedlint—forbidigoon abare
fmt.Print. I had runmake check, seen it stop at the first failingtarget, and reported that as the whole story.
make checkshort-circuits;"it fails fmt-check" is not the same as "it fails only fmt-check".
The two non-blocking observations (a slightly loose assertion in
TestZapWandOfNothingIsSilent, and a pre-existingROGUEOPTS="name="fallback divergence inherited from
New) are noted and not folded in. Thesecond is pre-existing and belongs to the options handling, not this change.