Development diary#

Note

This is a journal, not documentation. It records what I was thinking at the time, including the parts I got wrong and had to undo. Where it disagrees with the manual, the manual is right — it gets corrected and this does not.

Newest entries first. The release log says what shipped and when; this says why, and what it cost.

The goal#

I played Zangband as a student. Development stopped in 2005, at 2.7.5-pre1, and Angband did not stop — it is at 4.2.6 now, with twenty years of better level generation, a real data-driven architecture and an object property system Zangband never had. So: rebuild Zangband’s character on top of that, rather than resurrect a 2005 codebase.

The second goal took longer to say out loud. Zangband began as a game built on Roger Zelazny’s Chronicles of Amber and did not stay one. It picked up Lovecraft, then Tolkien, then a scattering of other role-playing material, until Amber was one theme among several rather than the thing the game was about. I want it pointed back at the books. That is now written down as a project goal rather than a preference, and it applies to content already imported, not just to what comes next.

19 September 2026 — twelve boxes where the gear should be#

The Items window is the paper doll: twelve equipment slots ranged around a pen-and-ink figure, leader lines tying each one to the shoulder or the hand it belongs to, and the pack down the right. Steven sent the design and I built it, and the first thing it did was draw twelve small empty rectangles where the symbols were meant to be.

Tofu. The glyph box wanted the item’s own ASCII symbol — | for the dagger, ~ for the torch, ( for the leather armour — and the obvious way to get one is object_char(), which is the function the game’s own item lists call. It returns x_char, and x_char is the display mapping. With a tile set chosen it is not a letter at all; it is a tile index, and Courier Prime has no glyph at 0x8F. The character I actually wanted is d_char, the one in the data file, which is the same whatever the map is being drawn in. That is the whole distinction between “what the game prints” and “what the game is”, and I got it backwards because object_char is the friendlier name.

Then the second pane went off the side of the window. The panes are a two-column grid that collapses to one below about 1080 pixels, and the collapse worked; the un-collapse did not. Tk’s grid remembers every option a slave has ever been given, so a pane that once spanned three columns goes on spanning them however carefully the next call sets its row and column. The wide branch has to say -columnspan 1 out loud. It took a set of measurements to see it, because from the outside “the left pane is 1180 wide in a 1180 window” looks like a weight problem and not a memory problem.

And the slot labels ate the layout. A Tk label with no wraplength asks for the width of its text on one line, and twelve equipment names asking that is a pane that wants two thousand pixels — and grid gives a column its requested width before it shares out anything that is left. The fix is a fixed wrap at the design’s own 118px. The interesting part is the fix I tried first: follow the row’s real width, so the text uses whatever space there is. That is a ratchet. Wider block, wider wraplength, wider request, wider block. It settled at nothing at all.

And then Steven crashed it from the menu bar. Not the new window — the plain Window menu, picking “Display inventory listing”. The report is an abort() inside mem_free, called from textui_get_item, and I had already written a guard against exactly this and a paragraph of comment explaining why the guard worked. Both were wrong.

inkey_flag is 4.2’s “the main loop is waiting for a command”, and main-win.c guards its menus with it, so I did too. What it does not mean is “no command is running”. ui-input.c sets it before the main loop’s inkey() and clears it only when that inkey() finally has a key to hand back — so it stays true for the whole wait, and the whole wait includes the nested wait inside a prompt that a command has opened. A menu command that prompts leaves the menu bar live, and a second command starts inside the first. Which is a heap corruption and not a display glitch: textui_get_item keeps its floor and throwing lists in file statics, the inner call allocates over them and frees them on the way out, and the outer call then frees the same two pointers.

I did not want to argue about that from the stack trace, so I took the guard back out and drove it: open the inventory listing from the menu, open the equipment listing from inside its prompt, escape out of both and let them unwind. Signal 6, the same abort. Put the guard back — the front end now counts its own depth rather than trusting a flag that means something adjacent — and the second command is refused and the session lives. The crash dialog Steven saw at 17:54 was that experiment, which I should have said before running it.

The lesson is not about inkey_flag. It is that I wrote a confident comment explaining why a borrowed flag was the right one, and the comment made the bug harder to find, because I read it again while looking and believed it.

The part I am actually pleased with is the message line, and it is the part nobody will notice. A drop does not move an item. It pushes CMD_WIELD and waits, because the game owns the turn, the curse checks and the refusals — a cursed ring stays on the finger, and the window has no business knowing that in advance. So the window takes a snapshot, issues the command, and says You are using a Dagger only once a re-read shows the dagger is actually on the arm. If half a second goes by and nothing has changed it says so instead. It would have been one line to print the cheerful version immediately and be wrong occasionally, and I would never have found out.

The last thing was the drop targets, which is a Tk lesson I will need again. winfo containing looks like the hit test and is not: it answers for the whole application, so with the main window stacked above this one every drop landed on the map canvas of the game behind. The thing genuinely nearest the pointer during a drag is the drag ghost. Twelve slots and a pack pane are thirteen rectangles; asking them directly is both shorter and correct.

Dragging the dagger onto the weapon slot now takes the morning star off and puts the dagger on, and the burden line at the bottom goes from 25.2 pounds worn to 11.4 without being told to.

15 September 2026 — the pet that killed its owner#

Steven was four turns into a new character when his own soldier beat him to death in a corridor on level one. The message log is the whole bug report: You push past a soldier. The soldier hits you. The soldier misses you. The soldier hits you. And then, a few turns later, You die.

The push-past is mine and it worked exactly as designed. Walking into your own pet swaps places with it instead of hitting it, which is the thing that stops the “you just attacked your pet, it hates you now” machinery firing every time an animal gets into a doorway ahead of you. What I never thought about is the half-turn after the swap. The pet is now standing behind you in a corridor. A pet inside its leash has nothing it needs to do, so it moves at random. A corridor has two ways out of it. One of them is you.

And monster_turn() resolves “this monster wants the grid the player is standing in” by calling make_attack_normal(), which has never once asked whose side the monster is on. It did not need to, before there were sides.

Three symptoms, one hole. Once I had the melee one I went looking for the rest of the class, and there were two more:

It can cast at you too. 4.2 aims a monster’s spells with target.midx, and a target of zero means the player. A pet that finds nothing to fight this turn has a target of zero. A pet apprentice would blind and magic-missile its owner without ever turning hostile.

And it stops you resting. disturb_near — “disturb whenever viewable monster moves”, on by default — fires for any visible monster that does anything, and cancels rest when it does. A pet follows you, so it is always visible and always doing something. Steven found this one himself while trying to heal the character the soldier was killing: he could not rest a single turn. That is not a pet bug in the usual sense; the option simply means “warn me when something moves in view”, and your own animal is not a warning.

Zangband guards all three, and I ported the feature without them. They are one line each, and they are not in the interesting parts of the code — they are at the top of make_attack_normal (melee1.c:167), at the top of make_attack_spell (mspells1.c:765), and wrapped round the disturb in process_monster (melee2.c:2551). When I read those files I was reading them for the pet AI: the target search, the leash, the alignment rules. The guards look like noise until you know what happens without them. They are not noise. They are three-quarters of what makes a pet safe to stand next to.

So the fix is theirs, in the same three places, plus one of my own: a pet that wants your square now steps around you rather than standing there bumping into you, because “refuse the blow and spend the turn” would cost a pet in a corridor half its moves.

The test that passed for the wrong reason. I wrote three regression tests and two of them were green immediately, which should have made me suspicious and did not until I reverted the fix and watched them stay green. Every monster is placed with MFLAG_NICE — the engine’s free first move, which suppresses spellcasting — so “the pet never cast at the player over four hundred attempts” was true of a pet that could not cast at all. Clearing the flag made the test fail against the unfixed code, which is the only evidence that a regression test is a regression test. Reverting the fix and watching the new tests go red is a step I will not skip again; it cost two minutes and it caught two of three.

The other one failed the other way, on its own setup rather than on its subject. player_resting_cancel latches a static flag that swallows the next attempt to rest, so placing the hostile control in view — which disturbs the player — meant the control half could never start resting at all. Nothing wrong with the game; the test simply had to ask twice.

Four turns to die, and the character was carrying nothing but a torch. Cheap as bug reports go.

14 September 2026 — the oldest document, and the mechanics nobody wrote down#

The magic system overview, dated May 2000 – five years older than the source it describes and by some distance the oldest thing in the archive. Steven flagged the age before I started, which turned out to matter less than usual: almost all of it is right.

The structure is exact. Seven realms, four books of eight, two bought in town and two found below, Arcane alone buyable entire. The class entitlements match tables.c row for row, including the two that look like mistakes and are not – a Priest’s second realm cannot be Life or Death, and a Ranger’s first realm is Nature with no choice offered. The one place the document is wrong is the Ranger’s second realm, where it lists six including Nature; the table gives five and no Nature, and ours gives the same five.

What I did not expect was how much of the casting section is implemented and undocumented. Three mechanics, all live, none of them in our manual:

Armour costs mana. The document says the threshold runs from thirty pounds for a Mage to forty for a Paladin. class.txt says magic:1:300:28 for the Mage and magic:1:400:8 for the Paladin – three hundred and four hundred tenths of a pound, exactly. Past it you lose a point of maximum mana per ten tenths over.

Casting on an empty pool. Every part of this is here: the warning, the “Attempt it anyway?” prompt, a failure chance that rises five points for every point of mana you are short, fainting that free action does not prevent, and a constitution hit about half the time that can be permanent. It is one of the better-built things in the game and it was documented in one clause of attack.rst about hunger.

Spell durations are not cumulative. 4.2’s own behaviour, unchanged.

So the magic chapter has a Casting section now, because a player who loses four points of mana to a suit of chain has no way to find out why.

One rule is genuinely gone, and it is not ours to have dropped. Zangband takes mana from an intelligence-based caster wearing anything on their hands, unless the gloves grant free action or dexterity. 4.2 retired CUMBER_GLOVE upstream and we never reinstated it, so a Mage here may wear gauntlets for nothing. Worth a note in the manual rather than a line in a plan: it is a difference a player meets, not work outstanding.

And one piece of flavour we never took. do_cmd_destroy rewards a Warrior with max_exp / 20 for burning any rare spellbook, a Paladin of Life for burning anyone else’s, and a Paladin of Death for burning a Life book. Three classes’ attitude to magic, expressed as a mechanic. The obstacle is that 4.2 has no destroy command at all – ignoring an item hides it rather than burning it – so there is no moment for the reward to hang on, and inventing one is design rather than porting. Open question 7 in the content plan.

Ten documents in, and the first where the thing worth doing was not correcting the game or correcting the document, but writing down what the game already does. The casting rules have been right since the realms went in. Nobody had told the player.

14 September 2026 — ninety-five options, eleven of them real#

Steven asked which of Zangband’s options we have not implemented. The honest first answer was embarrassing: a name diff says eighty-six of ninety-five are missing. The useful answer took another hour and is eleven.

The gap between those two numbers is the whole job. Fifty-seven of Zangband’s options describe behaviour 4.2 simply does, so the switch went with the choice: depth_in_feet is gone because the game shows feet and level together; easy_open is gone because opening is always the easy behaviour; the seven view_* lighting options collapsed into one when 4.2 rewrote lighting, the six disturb_* into disturb_near, the five stack_* into birth_stacking. Twenty-four are present, about half of them renamed – always_pickup is pickup_always, hilite_player is highlight_player, ironman_downward is birth_force_descend. Three stopped being options at all: point_based and autoroller are choices on the birth screen, and vanilla_town is meaningless in a game whose design is the wilderness.

Two I expected to be missing and were not, which is the kind of thing that pays for doing this properly. monster_light did not disappear – it became a per-monster field in the data, which is better than a global switch and is why grepping the option name finds nothing. And speak_unique is unconditional here: monster speech is CNT-04 and every monster that can talk does.

The eleven that are genuinely absent cluster, which is why they are one question and not eleven. Seven are the ironman family – no shops, every room unusual, Moria-style generation, harder quests, always-small levels, always- arena levels, always-autoscum – and three more are the same knobs without the compulsion. They belong beside nightmare mode rather than apart from it, and three of them are the ironman form of the other three, so deciding either group decides half of the other. The eleventh is silly_monsters, which turned on Zangband’s joke monsters and which DEC-30 has effectively already refused.

Open question 4 in the balance plan, with the table.

And the mapping caught a wrong sentence in the decision log. The Golem note – the one arguing that nightmare mode should not strip a Golem’s stun immunity because in Zangband three settings share that penalty – says “ironman_shops and ironman_downward strip it too, and this game has neither of those options”. We have ironman_downward. The argument does not depend on it, but the sentence was wrong, and decisions.md says in its own header to amend rather than duplicate, so it is amended in place with a dated note. That is the opposite of the diary rule and deliberately so: a journal records what I thought at the time, and a decision log records what is true.

14 September 2026 — the constant added so nothing could drift, and the two things that drifted#

The Linux job went red and stayed red for three commits. One test out of fourteen hundred and thirty: object/imported, thirty of thirty-one, and the harness prints a seed but not a name, so the first job was getting the name out of it. VERBOSE=1 and the seed from the log reproduced it on the first try – a-borrowed-lord-is-kinder, line 652, sworn_low * 2 > borrowed_low * 3.

The cause was mine, and it was in the commit that was trying to prevent exactly this. Aligning the patron code with the Chaos Patrons manual moved the floor of a generous reward roll down one rung: Zangband rolls rand_range(5, 20) and then decrements, so the bottom four rungs are unreachable, not the bottom five its own spoiler claims. That went in as PATRON_NASTY_FLOOR, with a comment saying it exists so the roll and the test that measures it cannot drift apart. Then I changed the roll and one of the three tests.

What the drift did to the measurement. The test counts how often a roll lands below the floor, sworn to a Lord versus borrowing one. With the two sides agreed, only a nasty roll can get down there, so the count is a clean read on the one-in-N chance and the ratio comes out at 2.0 – the doubling, exactly. With the test’s floor one rung above the roll’s, a generous roll can land on the rung in between, and generous rolls are five in six. Both sides picked up the same large constant term and the ratio collapsed towards one: 1.2 measured, against a bound of 1.5. Not flaky. Wrong on every seed, which is the good version of this bug – a diluted statistic that still cleared the bound would have sat there for months.

The fix is the line the new constant was added for. Measured across fourteen seeds afterwards, 1.838 to 2.183, and the derivation in the comment needed redoing too: the expected counts drop from 850 and 1700 to 670 and 1330, because a fifth of the ladder is rarer than a quarter.

And the third test. player/virtue had the same line and was passing, which is why nothing pointed at it. It survived because its two samples are much further apart – virtues at full stretch push the nasty chance from one in twelve to a certainty – so the diluted ratio was still about 3.2 and cleared 1.5 comfortably. It was measuring the wrong boundary and getting the right answer. That is worse than failing, so it is fixed as well.

Three call sites, one changed, one caught by CI, one silently wrong: about the distribution I would expect from a search-and-replace I did by hand and thought I had finished.

Then the full run found a different one. With the patron tests fixed I ran the whole suite locally rather than trusting CI, and player/ancient-curse failed – not every time, about one run in twelve. It had nothing to do with the patrons. The test drains experience six hundred times and requires at least ninety, and the comment justifying ninety says it is four standard deviations above the rate a curse with no cascade would produce. That much is true. What it never says is where ninety sits relative to the rate the curse actually produces, which is 17.2% – a mean of 103 with a standard deviation of 9.4. The bound was 1.4 standard deviations below the middle of the distribution it was measuring.

That is the same mistake as the patron test I fixed two entries of reasoning ago, and I had the corrected version of that argument open at the time. A bound on a sampled statistic has two distributions to clear, the one it wants to reject and the one it expects to see, and checking only the first is how you write a test that is four standard deviations rigorous and fails every twelfth run. Ten times the trials separates them: at six thousand the no-cascade figure is 667 and the real one 1030, and 850 is seven and a half standard deviations above the first and six below the second. Eighty consecutive runs, no failures, and it costs half a second.

The part I cannot fix so cheaply: the seed did not pin it. Suites print ZTK_TEST_SEED so a CI flake can be replayed, but this one seeds the RNG after generating the cave, so a fixed seed still gets a different dungeon and a different answer. Six failures in sixty runs at one fixed seed. The reporting line was added precisely so flakes would stop costing twenty whole-suite runs to find, and here it would have handed somebody a seed that reproduces nothing. Worth fixing, but not today, and not quietly in a commit about patrons.

14 September 2026 — a documented file that is never opened#

Steven asked whether the user pref files are documented. They are – customize.rst has had a User Pref Files section all along, and unlike the debug page it is written for 4.2’s behaviour rather than Zangband’s: the right directory, the right platform paths, the right entry points. Which made the one thing wrong with it worth the reading.

It documents two files the game never loads. The section says the load order is window.prf, then race.prf, then class.prf, then name.prf, and then advises: “You can save some settings – for example, keymaps – to the Mage.prf file if you only want them to be loaded for mages.” That was Zangband’s behaviour. process_character_pref_files() loads window.prf, user.prf, and then the character’s name – falling back to the savefile’s name if there is no file for the character. There is no race file and no class file, and a Mage.prf sitting in a user directory is read by nothing.

So the advice was not merely stale, it was a recipe that silently does nothing. Somebody following it would write a file, see no keymaps, and have no way to tell whether they had got the syntax wrong, the directory wrong, or the idea wrong.

And the replacement was undocumented. 4.2 dropped the per-race and per-class files because it has something better: a pref file can carry conditional lines. ?: takes a bracketed expression and switches the rest of the file on or off, the variables are $CLASS, $RACE and $SYS, and the operators are EQU, IOR, AND and NOT. The game’s own font.prf is the worked example – ?:[IOR [EQU $SYS xaw] [EQU $SYS x11]] is how it picks an X11 font – and it has been sitting in lib/customize/ being an example nobody was pointed at. The section now gives the class- conditional keymap that the Mage.prf advice was reaching for, written the way that actually works, with the example keyed to [F1] so it matches the Keymaps section twenty lines further down.

user.prf was missing from the list too, which is the file most people actually want: loaded for every character, and the natural home for exactly the kind of thing the conditional syntax fences off.

Two documentation defects in two days, both the same shape – a page that described an interface nobody had opened in a while. The debug page listed every command and never said the menu existed; this one listed a loading order with two entries that do not load. Neither would ever fail a test, and both cost somebody an afternoon the first time they trusted it.

14 September 2026 — the page that listed the commands and not the door#

Steven looked at Zangband’s “Wizard and Debugging Modes” page and said he did not think we had all of it, and that it was certainly not in the manual. He was right twice, and wrong about which way round the gap ran, which is the interesting part.

Nothing documented is missing. I diffed the page against ui-game.c by machine: forty-eight debug commands implemented, forty-eight described. Every one of Zangband’s thirty-odd has a counterpart or a better replacement, and the four I first thought absent – query a square flag, query a feature, the noise and scent peek, the keystroke log – are all in a Query submenu I had not found yet.

Seven implemented commands were not documented, and three of them are the ones this game added: Mutations, Gain a pet and Set allegiance. Those three exist precisely because mutations, pets and three-sided allegiance are the hardest things here to reach by playing – chaos hands out mutations and you do not choose them; a monster’s side is not something you can set from the keyboard – and the page that tells a developer how to test this game did not mention any of them. The other four were Gain gold, Gain hit points, Know every place and Learn all monsters.

And the page never said how to open the menu. No ^A anywhere on it. It opened on “Item Creation” and listed keys, and a reader coming from Zangband’s page – which describes a flat ^A-then-a-letter interface – would have tried ^A c and got a category list instead. 4.2 nests the commands in nine categories and every key on our page is a key within one, which the page did not say, so every entry on it was subtly wrong about how to use it.

Wizard mode was not documented at all. It appears twice in playing.rst as ^w (special - wizard mode) in a key table, and once in the options page as a thing that puts Cheat on the status line. What it does was nowhere. It is worth three sentences: it marks the savefile permanently on first use, it shows every artifact in the knowledge list whether created or not, and it makes death optional – cheat_death() restores you and hands you back the game. That last one is the whole reason the mode exists and it was written down in no document at all.

Also worth recording because it is the fourth time this week: Zangband’s page describes wizard mode as giving verbose damage reporting and an explanation of why an object vanished. Ours does neither. Not a gap – those are 2002 Zangband features that 4.2 never had and we never took – but it is another line in a document that would have sent somebody looking for a feature that was never here. The page now says so.

Nothing in the game changed. This was a documentation defect from end to end, and the machine diff is the part worth keeping: forty-eight against forty-eight is a number that can be re-checked in a minute, where “looks complete” is not.

14 September 2026 — a curse with a third of itself missing#

The Ancient and Foul Curse, and the spoiler that has been wrong all along in a way nobody could have caught without opening spells2.c.

The cascade is right and the weights are exact. Nine steps, cumulative weights 4, 7, 11, 14, 19, 22, 23, 24, 26 out of 27, which is the spoiler’s 4/27, 3/27, 4/27, 3/27, 5/27, 3/27, 1/27, 1/27, 2/27 to the number – and the unclaimed twenty-seventh, where the curse passes without incident, is in there too and commented. One in six to drag in the next step, one in three to start over, stopping on paralysis or Cyberdemons. All Zangband’s.

But the spoiler describes a 27-case curse and the source rolls ``randint1(34)``. The seven cases it never mentions are all at the dungeon-wrecking end: an earthquake of radius 5 to 15; destroy_area() to radius 20 followed by a 10d5 mana ball; a 10d10 teleport that also summons something greater twelve times in thirteen; and a wall breaker with a one-in- seven radius-7 KILL_WALL behind it. Three of the four only fire on the first round, which is presumably why the spoiler’s author never saw them often enough to write them down. A fifth of Zangband’s curse rolls reshape the level and none of ours do.

That is not a bug – CNT-15 cites the spoiler and DEC-16 licenses it – but it is a third of a signature mechanic, and 4.2 has EF_DESTRUCTION, EF_EARTHQUAKE and a KILL_WALL sphere, so all four are expressible. Open question 6 in the content plan, with the re-weighting noted as arithmetic and the real question flagged as whether a curse that destroys the area under the player is wanted at all.

One real defect, and it is the patron rung again. cascading was 7, so the chain could not run past the Cyberdemon step into the stat-ruin behind it. Zangband writes the cascade as C fall-through and every case ends if (!one_in_(6)) break; – including the Cyberdemon case, which drops into the default. So it is steps - 1, and it says so now rather than carrying a magic 7. Worth noting the spoiler disagrees with both: it says the first six fall through and amnesia does not, which is neither what we had nor what the code does. Three different answers and only spells2.c counts.

And the mechanic had no test. That is how a chain came to stop one step short with nothing noticing, so it has one now: the weights by census, the two stop conditions, and the cascade itself proved by the one signature a single step cannot leave – experience loss runs far above the 3-in-27 a non-cascading curse would give. Checked against a deliberate regression, as the patron tests were: cutting the cascade to zero fails it.

Also missing, and now written down: Zangband’s curse trap (fields.c:1570) – summon 2+1d3 monsters, then invoke the curse if the depth beats 1d100, one time in six again. Of the source’s nine invocation sites we have four, and every absence is now accounted for somewhere: Shuffle deferred, the midnight bell listed as unbuilt nightmare content, the patron in yesterday’s open question, and the trap in today’s.

Nine spoilers in, the pattern has stopped surprising me. The documents are a record of what somebody believed about the game in about 2002, and they are wrong about something roughly two times in three. What has changed is that I no longer read them looking for what we missed; I read them looking for which source file to open.

14 September 2026 — the spoiler that had already been read properly#

Mutations, and the first of these checks where the answer is mostly “yes, somebody already did this carefully”. The effects were audited against mutation_effect() rather than the spoiler when they went in, and the release note from that day says why in one sentence I could not improve on: the spoiler gives the headline of each mutation and the headline is generally the good half. Superhuman strength is “+4 STR” there and +4 STR, -1 INT, -1 WIS in the code; being puny is “-4 STR” and also +2 DEX. Built from the documentation, every good mutation would have been better than Zangband’s and every bad one kinder. The manual already carries the regeneration-penalty finding too – the spoiler warns that mutations slow your healing, which was true of 2.2.2d and had been taken out again by 2.7.5.

So I checked the half that audit did not cover: the nine ways in and the six ways out.

All fifteen are accounted for, which took some finding because they are spread across seven files. In: a Beastman at birth and per level (player-birth.c, player.c), Polymorph Self as spell and as mutation (one EF_POLY_SELF), a failed Chaos spell (chaos_backfires cases 27 and 28), an unresisted chaos hit at one in three (project-player.c), a patron’s level-up reward (player-util.c), and the Chaos deities give you gifts mutation – which works by granting OF_PATRON, so patron_owes_reward() starts returning true for a character who never swore to anybody. Out: the potion of New Life, a cancelling mutation, the Chaos Tower, Polymorph Self’s shedding loop, and strangely normal. Two are deliberately absent with the decision recorded: the Eldritch Horror went with DEC-32, and Trump’s Shuffle is deferred.

And one path is listed that neither game has. PLR-34 includes “failing a Death spell from the Necronomicon”, taken from the spoiler, which says it has a “chance of same effects as being blasted by an Eldritch Horror”. The Necronomicon miscast inlines confusion, hallucination and a loss of intelligence and wisdom and calls nothing that mutates. And the comparison does not save it: sanity_blast() does not mutate either, nor does have_nightmare(). Zangband has eight gain_mutation() call sites and not one of them is a Death spell.

Our death_miscast() is therefore right, and right for the best reason – somebody transcribed the code rather than the prose. But the requirement still reads like outstanding work, and a comment of ours in player-util.c said the nightmare we dropped could “sometimes grant a mutation”, which it could not. Both now say what the source says.

That second one is the whole lesson of this fortnight in miniature. The Hellfire bug was a note that said Zangband’s hell fire hurt good monsters; the Mind Blast bug was a note that transcribed a divisor as 4 where the source said 5; both times the data followed the note and the note had not been checked against the code. A note that overstates what was dropped is the same failure pointing the other way – it is how something gets restored that was never there. Correcting a comment nobody is reading today is cheap insurance against that, which is why it was worth the ten minutes.

Nothing in the game changed. Eight spoilers, seven realms, a power list, a reward ladder and ninety-six mutations, and this is the first one where the implementation needed nothing at all.

14 September 2026 — one rung, and nine Lords who cannot curse you#

The patron spoiler is the one where most of the content is deliberately not ours. DEC-38 threw out Zangband’s sixteen – Moorcock’s Elric gods plus four Warhammer ones – for nine Lords of the Courts, so the rosters, the names and the favourite stats are a settled difference and not a finding. What had to match is the machinery: the ladder of twenty, the roll that indexes it, and the thirty-one things a Lord can do.

The machinery is right, to a rung. The cruelty ladder is exact – one in six normally, one in two at level thirteen, one in three at every other thirteenth, one in twelve at every fourteenth – and so is the one-in-six mutation that replaces a reward rather than arriving beside it, in that order, which the comment beside it explains better than I could now. The borrowed-Lord rule is there too, with a note pointing out that Zangband’s nasty_chance *= 2 reads backwards from the fiction: it is a denominator, so doubling it halves the cruelty.

To a rung, and one rung out. A generous roll is meant to avoid the bottom four of the twenty. Zangband rolls rand_range(5, 20) and then decrements, landing on 4 through 19. Ours read rand_range(PATRON_LADDER / 4, ...) – five through nineteen – and avoided five. The quarter is tidy arithmetic and it is the spoiler’s, which says in as many words that “the first five in the list are considered ‘nasty’” and that a kind roll picks “from the ‘nice’ 15”. The source says four and sixteen. BAL-18 again, and the seventh time this week that a number came from the spoiler where the code says something else.

The fix is one constant, PATRON_NASTY_FLOOR, now used by the roll and by the test that measures it – they had the boundary written out separately, which is how they could have drifted apart without either being wrong on its own. With it moved, the test’s own diagnostic line reads the theory back exactly: 393, 141, 257 and 66 bottom-rung rolls per four thousand at levels 13, 20, 26 and 28, against 400, 133, 267 and 67 predicted from the cruelty odds times four rungs in twenty. It was 15 rungs in 20 before and the rates were all a fifth light.

And nine Lords who cannot lay the Curse. Eight of Zangband’s thirty-one rewards have no counterpart here, and the interesting thing is how many of them are already built. TY_CURSE invokes the Ancient and Foul Curse – which is CNT-15, which is finished, which the Chaos backfire table already reaches – and in Zangband nine of the sixteen patrons could lay it on you. Not one of our nine can. Genocide and mass genocide are the same story: the effects exist and are Death’s Genocide and Mass Genocide, and no Lord can grant either. The rest – multiple objects, havoc, polymorph wounds – are partly reachable through things that exist elsewhere.

That is not a defect exactly; DEC-38 settles the roster and is silent on the reward set, and the servant rewards went in the same way, after shipping with none of the three. It is a gap that nobody has walked up to yet, and it is now open question 6 with the table and a note that adding a reward costs a rung on somebody’s ladder. TY_CURSE is the one I would take first: a Lord of the Courts laying the Ancient and Foul Curse on a servant who displeased it is the single most Zelazny thing in the whole mechanic, and the machinery for it has been sitting finished for milestones.

14 September 2026 — a mapping table with a hole, and a pool with five things in it#

The random-object-powers spoiler is a different shape from the others: it is not a list of content but a description of a mechanism, three pools of abilities and the egos that draw from them. So the check was whether the pools exist, whether they hold what Zangband’s hold, and whether the egos that should draw from them do.

The mechanism is there and is 4.2’s own. KF_RAND_SUSTAIN, KF_RAND_POWER, KF_RAND_HI_RES, KF_RAND_BASE_RES and KF_RAND_RES_POWER are exactly Zangband’s EGO_XTRA_* kinds under other names, and obj-make.c:398 reads them off the ego. All twenty-two egos the spoiler names exist, though Zangband’s Boots of Levitation are 4.2’s Boots of Slow Descent.

The pools line up better than I expected. Extra Sustains is five where Zangband has six, because 4.2 has no Charisma. Extra Resistances is eleven abilities of which nine are 4.2 elements and reachable, and the other two – blindness and confusion – exist as PROT_BLIND and PROT_CONF, which 4.2 files under protections rather than resistances, so they come out of the power pool instead. Extra Powers is seven of eight; the missing one is permanent light, which 4.2 made a modifier rather than a flag, so there is no flag to roll.

One defect, and it was in the converter rather than the data. objflagmap.toml has a [rand_ability] table that maps Zangband’s Lua add_ego_power() calls onto 4.2’s kind flags, by hand, because the converter cannot read Lua. It had two entries. Three Zangband-only egos make those calls, and (Pattern Weapon) was not one of the two – so it shipped with no random ability at all, where Zangband gives it an unconditional extra high resist. One line in the table, a re-run, and a one-line diff in the generated file. The table now says out loud that an ego missing from it silently loses its ability, which is the property that let this through.

And one thing that is not a defect so much as an accident nobody chose. 4.2 picks an ego’s extra power from every flag whose subtype is protection or misc ability, and the enum comment for that subtype reads “a good property, suitable for ego items”. Five flags we imported from Zangband were classified misc ability: PASS_WALL, PATRON, LUCK_10, STRANGE_LUCK and EASY_ENCHANT. So they are in the pool. A Blessed Blade has about a one-in-twenty-one chance of letting the wielder walk through walls, and a similar chance of attaching a Lord of the Courts of Chaos to somebody who is not a Chaos-Warrior. PASS_WALL is granted deliberately by one race and nothing else; PATRON by the (Chaotic) ego and nothing else. Zangband’s own list of extra abilities is eight mild utility flags and has nothing like either.

I did not fix it, because the obvious fix is wrong in an interesting way. Flag subtype is read in exactly two places: the ego pools, and the rune list, which skips OFT_NONE. Moving a flag to OFT_NONE takes it out of the pool and out of rune identification – fine for PASS_WALL, which no object grants, and wrong for PATRON, which an ego does grant and which a player ought to be able to learn. That is a decision with a shape, not a patch, so it is open question 5 in the content plan with the table and the OFT_MELEE suggestion written out.

A note on method. Half the work here was deciding what was in scope at all. The spoiler describes twenty-two egos and eighteen of them are Angband’s own, living in 4.2’s ego_item.txt; only sixteen egos are Zangband-only and only three of those carry a random ability. Comparing the other eighteen against Zangband’s e_info.txt would have produced a long list of differences and not one of them a defect, because the project keeps 4.2’s egos. BAL-18’s lesson from the nightmare audit – the source is the port, the spoiler is a candidate list – has a companion here: the spoiler describes Zangband’s game, and the parts of it we deliberately did not take are not findings.

14 September 2026 — the Intel Mac, and a decision that was two decisions#

Steven asked for an Intel Mac build and for the manual to say we support one. The build was an afternoon. The interesting part was reading DEC-22 again.

It says macOS means Apple Silicon only, and the reasoning under it is that Intel reaches legacy status in September 2026 — this month, as it happens — so there is no future in it. That is a forecast, and forecasts are the part of a decision log that ages. But underneath it, doing most of the actual work, was a second claim that has not aged at all: no universal binaries. Those are two different decisions that happened to be written as one, and only the first of them was wrong.

So the amendment keeps the shape of the thing and reverses the conclusion. Intel is supported. Universal is still refused, and now for its own reason rather than as a corollary — a fat image makes every download carry a second copy of the game that the machine downloading it can never run, and the Releases page can list two files as easily as one. That reason was always the better one. It was just never load-bearing while there was only one architecture to argue about.

The code was almost nothing. ARCHS was already a variable in both makefiles, inherited from vanilla, and both already threaded it into CFLAGS; make -f Makefile.osx ARCHS=x86_64 built and signed a working Intel application on the first attempt, from this arm64 machine, with no toolchain to install. I had half expected to spend the afternoon on that and did not spend ten minutes.

What did need thought was the two places the architecture leaks out of the compiler.

The file name. Both builds write their image into the repository root, and both were called ZangbandTK-<version>-osx.dmg. Build one, then the other, and the second silently replaces the first — and since the disk images are byte-for-byte plausible either way, nothing tells you which one you have until a player cannot open it. The makefiles now derive a -intel suffix from ARCHS rather than taking it as another thing the caller has to remember to pass. Deriving it is the point: a name that is composed in the workflow as well as in the makefile is a name that will eventually be composed two different ways.

The deployment target. Makefile.osx-tty asked for macOS 11.0, and I had written the comment explaining why — 11.0 is the first release that ran on arm64, so asking for less is not honoured. Reading it back with an Intel build in hand, that comment is an argument about arm64 and nothing else. An Intel Mac has been running macOS since 2006. It now takes 10.9, the same floor Makefile.osx has always used. A justified number is still only justified for the case it was written about, and this one had quietly become a default.

I built both front ends for x86_64 before writing a line of workflow, because a CI job is an expensive place to find out that a build does not compile. Both were clean under -Werror, and the terminal one — signed, tarred, unpacked, and started under Rosetta — walked a new character down to a generated level. That is the same check scripts/smoke-tty makes in CI, which is why it was worth running here first.

CI itself builds on macos-15-intel, a real Intel runner, rather than cross-compiling on Apple Silicon. Not for the compiler’s sake — it clearly does not care — but because three of the four checks around the build need an executable that can actually start: the signature, the smoke test, and the otool check that catches a curses library resolved out of Homebrew. That last one needed a second path, too. Homebrew is /opt/homebrew on Apple Silicon and /usr/local on Intel, and the existing check already looked for both, which was luck rather than foresight.

The documentation took longer than the build, which is usual now. download opened its macOS section by saying Intel was not supported; it now opens by asking which Mac you have and answering it in a table, because the first thing a person arriving at that page needs is to pick the right file. Both READMEs inside the downloads name all four files, so somebody who took the wrong one can work that out from the thing they already have rather than from the website they did not read.

14 September 2026 — the third punishment, and a mercy at thirty#

Steven asked for the Mindcrafter backfire and for the mana-recovery question to be written down rather than answered. Both done.

The backfire is a faithful port and was the easy half. mind.c:515 rolls again at half the failure chance and reads one of five bands off a d100 – 4% forget the map, 10% hallucinate, 30% confused, 45% stunned, 11% a mana storm centred on the caster at twice their level that also empties part of the pool. All five had somewhere to land already: player_forget_the_world() from Chaos’s table, the three timed effects, and EF_SPHERE for the storm, which is the same shape Chaos’s own chaos-ball band uses.

Two things needed thought rather than transcription.

The gate. Racial powers and class powers arrive at player_use_power() through the same door, and only one of them backfires – Zangband keeps racial activations in racial.c, which charges hit points for a shortfall and has no backfire table at all. So a Draconian’s breath must still fail quietly. There is no flag in the data to ask, and there does not need to be: walking p->class->powers for the pointer answers it exactly, and a race’s powers are never in that list. A Mindcrafter carries both kinds at once, which is what makes the test worth writing.

The mercy at thirty. Writing the band census I set the test character to level 40 and the confusion band never landed once in two thousand rolls. That was not the port being wrong: a Mindcrafter gains PROT_CONF at 30 (DEC-78), so from there the most common of the five backfires cannot touch it. Thirty per cent of the table quietly stops applying to exactly the characters most likely to be casting into it. Zangband has the same progression and the same consequence, so it is faithful – and it is now a case of its own, because from either side it looks like a bug: a band that fires and does nothing, or a table that appears to have lost a third of itself. The census runs at 25 instead, where all five outcomes can be seen.

The test cost me two more wrong assumptions, both the same kind. I set the character a five-hundred-point mana pool and asserted the storm left exactly fifty behind; it left twenty-six, because the backfire runs update_stuff() on its way through, which recomputes the pool from the class and clamps anything above it – so I was measuring the clamp and calling it the drain. Filling from player->msp and asserting the difference is the honest version. Then the band census made the same mistake in reverse, detecting the storm as “mana below five hundred”, which was true on every roll once the pool was real. Both are the same lesson: a test that invents the state it measures is measuring its own invention.

And the mana question is now open question 5 in the player-systems plan, with the three ways out written down rather than a preference. The short of it: RESTORE_MANA would carry Zangband’s gain but 4.2 has no effect that spends energy, so shipping the gain alone hands the class free mana with no time cost, and a chain cannot roll dice against damage another effect just dealt so the amount has to be approximated however it is done. Fixing one power to close a class-wide gap may also be the wrong shape entirely. It is a balance decision and it wanted a decision-maker, not a patch.

That is seven realms, one power list, and three punishments – Chaos, Death and now psionics – all of them Zangband’s own numbers.

14 September 2026 — the Mindcrafter, and a whole system that is not there#

Eighth spoiler, and the only one that is not a realm. The Mindcrafter’s twelve powers are a list rather than a book, which meant checking them against mind.c rather than tables.c, and against a hand-written block of class.txt rather than anything the converter produces.

The figures are exact and so is most of the behaviour. All twelve levels, costs and failure rates match mindcraft_powers[]. Precognition’s whole ladder is right – monsters at 1, traps and doors at 5, invisibility at 15, mapping at 20, telepathy 25 to 39, everything at 30, the level lit at 45 – which is worth saying because the spoiler gets two of those wrong. Character Armour’s five resistance thresholds are right too, at 17, 21, 25, 29 and 33, where the spoiler rounds them to 15, 20, 25, 30 and 35.

The spoiler in fact swaps the first two powers outright: it lists Precognition at 1/1/15 and Neural Blast at 2/1/20, and mindcraft_powers[] has {1, 1, 15, "Neural Blast"} then {2, 1, 20, "Precognition"}. It also puts Psychometry’s identify band at level 40 where the code says 25, and gives Psychic Drain a radius the code sets to zero with a comment saying it always is. Four faults in one document, and the game agreed with the code every time.

Minor Displacement was missing half of itself. plev < 40 ? teleport_player(10) : dimension_door() – a random ten-step hop for most of a career and a chosen destination after 40. Only the first half was here. The fix is the band I could not have written a week ago: TELEPORT_TO, which I only know carries a dimension door because Trump’s deferral turned out to rest on the claim that it did not.

Three things I did not fix and wrote down instead. Mind Wave above 25 should do plev * ((plev - 5) / 10 + 1), a multiplier that steps 3, 4, 5 as you pass 25, 35 and 45; 4.2’s expression language walks operations across one value and cannot multiply the level by a term drawn from the level, so it is fixed at 3 – exact to 34 and two fifths short at 50. Psychometry pseudo-identifies below 25 in Zangband, and 4.2 has no pseudo-identification to reach for. And Psychic Drain does not drain: Zangband gives back (5d(damage))/4 mana and charges up to 150 energy for it, and ours is a psi ball and nothing else, which leaves the Mindcrafter with no mana recovery anywhere in its list. RESTORE_MANA exists; the energy cost has no effect to express it, so shipping the gain without the price is a balance decision and not mine.

And the big one, which is not a defect in anything so much as an absence. Zangband’s Mindcrafter backfires and ours does not. mind.c line 515: on a failed power, at half the failure chance again, one of five things – 4% forget the map, 10% hallucinate, 30% confused, 45% stunned, and 11% a mana storm centred on the caster at twice their level which also empties a chunk of their pool. We have chaos_backfires() and death_miscast() already, and the hook they hang on; a failed psionic power here just says so and takes the mana. It is the third realm-or-class punishment Zangband has and the only one missing, and it is a feature rather than a fix, so it is Steven’s call.

A working note worth more than any of the above. Every test in the suite failed at once in the middle of this, forty-six of them, on parse errors in p_race.txt – a file I had not touched. A merge had landed mid-session bringing Nightmare mode and a Draconian change, and the unit-test binaries had not relinked against the new library, so they were reading today’s data with last week’s parser. cmake --build build --target unittests will not relink them and neither will rebuilding the library; deleting the executables does not help either, because the target believes they are current. The only thing that worked was building all 118 unittest-* targets by name. That is the third time this month the same stale-link trap has cost me an hour, and the first time it arrived looking like forty-six real failures.

11 September 2026 — the mode that was mostly a spoiler#

Steven had archive/manual/Nightmare-Mode.txt open and asked whether our plan for nightmare mode matches it. Two answers, and the second one is the one that matters.

The small answer: no. Our §2.8 summarised three of the spoiler’s eight sections and nobody noticed, so Combat, Magic, Town, Wilderness and Dungeon, and Other — twenty-two claims — had never been written down anywhere in the plan. M11 is the next milestone now that pets have closed, and it would have been scoped off a document we had read a quarter of.

The large answer is that reading the other three quarters would not have helped much either, because the spoiler describes a game Zangband never shipped. ironman_nightmare is p_ptr->birth[18] and the macro is the only way to reach it — a grep for birth[18] returns the #define and nothing else — and it appears at twenty-four sites. Against roughly forty-five claims, sixteen changes to play exist, plus the option, the score and a line in the character dump. No doubled monster damage. No monster criticals, no slays disregarded, no spell-failure floor, no ball spells turning on the caster. The whole Town section is one if at the inn. The whole Objects section — ten items, from device failure to pseudo-ID — is nothing at all.

And four of the sixteen appear in no spoiler. One of them is, I think, the cruellest thing in the mode: a sustain fails one time in thirteen, and a stat drain is permanent twelve times in thirteen. A player reading the spoiler would know about the doubled hit points and never learn that their sustains had quietly stopped working. Stair creation is disabled, which is the escape hatch gone. Monsters arrive with double energy and without the free first move the engine normally grants, which is a different and worse thing than “start awake”.

The fourth I liked enough to want it kept: before the midnight curse, a bell tolls at eleven, quarter past, half past and quarter to. Four warnings, the only mercy in the mode and the only piece of stagecraft in it, and no documentation ever mentioned it.

So M11 is not the port it was written as. It splits: the sixteen Zangband actually built, each with a source line to check against, and then a separate stage of things we choose to add, designed as ours rather than described as restorations. Some of that second stage is nearly free, because earlier milestones left the hooks lying about — repro-max is already a constant in constants.txt, reflection already has its one-in-ten in CNT-09’s code, 4.2 already shuffles shopkeepers and gives them purses, M8 already has a mutation table to weight. One item is refused outright: the spoiler restores the cyberdemon summon, and DEC-30 is why our own cascade already calls greater demons instead.

The part I want to remember is about method. DEC-16 says read the manuals, because they carry mechanics the data files do not, and that rule found the Ancient and Foul Curse and the random ability tables. It is still right. But a release note from 3.50.x says all four spoiler-based requirements “match”, and what was actually checked was that the document existed and said what the requirement’s first line said. Nobody opened the source. A spoiler is evidence of what its author intended; only the code is evidence of what the player got. That is BAL-18 now, and it applies to every requirement still resting on a document rather than a source line.

11 September 2026 — Death, and a note that inverted its own source#

Last of the seven. Two defects, and the first one is the most embarrassing kind because the reasoning that caused it is written down in full beside the thing it broke.

Hellfire and Malediction lost their bonus against evil. The note on both said: “Zangband’s hell fire does double damage to good monsters; 4.2 has a projection that favours evil (HOLY_ORB) and none that favours good. MANA is taken instead.” Both halves are wrong, and they are wrong in a way that cancels out into a confident conclusion.

spells1.c line 1100 reads /* Hellfire -- hurts Evil */ followed by if (FLAG(r_ptr, RF_EVIL)) dam *= 2;. And 4.2’s HOLY_ORB handler is project_monster_resist_other(context, RF_EVIL, 2, false, ...) – double damage to evil, no immunity for anyone, no resistance for anyone. That is GF_HELL_FIRE exactly, projection for projection. The note reached for HOLY_ORB, looked at it, decided it favoured the wrong side, and put it back.

So the realm’s opening attack and its capstone both threw unresisted damage with no bonus at all. Six hundred and sixty-six points against an evil unique should be thirteen hundred and thirty-two and has been six hundred and sixty-six since the realm shipped. Both now use HOLY_ORB, MANA’s nothing-resists-it property coming along with it rather than instead of it.

A miscast Death spell hurt by one die too many, in every book. Zangband is take_hit(damroll(o_ptr->sval + 1, 6), ...) with sval the 0-based book – 1d6 through 4d6, which its spoiler states as “1, 2, 3, or 4d6”. Ours read damroll(book + 2, 6): 2d6 through 5d6. The wrong formula was in three places that all agreed with each other – the code, the test that checks the code, and the sentence in the manual describing the test – and in none of them did anything disagree with cmd5.c, because nothing in any of them had read cmd5.c. The test is a good one, too: it checks the mean over four hundred rolls with the statistics worked out in the comment, and it would have caught a wrong die count instantly. It was just checking the wrong number.

The figures themselves are exact. 242 level-and-mana rows across the eight classes Zangband gives Death – the most of any realm – with nothing to answer for. The spoiler, again, has three faults of its own: the Monk’s first two books are shifted a row, Vampiric Branding is given three or four levels early for four different classes, and the book listing calls a spell “Poison Brand” where tables.c says “Poison Branding”.

And the manual claimed Death was “the only realm that punishes you for casting it badly”, eleven paragraphs after the Chaos section explains that Chaos backfires. What is true is narrower and better: Death is the realm where the spells that work cost you blood – Hellfire’s fifty to a hundred, Genocide’s 1d4 a monster, Mass Genocide’s 1d3 – which is Zangband’s own “need their own blood as the focus”, and which the chapter had never said.

That is all seven realms read against their spoilers. The tally: thirteen errors in the documents against nine in the game, and the game’s nine were all found by the documents being read carefully rather than by anything failing. Nothing in the suite was red at any point this week.

11 September 2026 — Chaos, where the spoiler lost badly#

Sixth spoiler, and the first realm where I changed no game data at all. Chaos is clean. Every one of the thirty-two chains matches cmd5.c, every deviation already has a note explaining itself, and 188 level-and-mana rows across six classes came out with nothing to answer for.

The document did not. Its class tables are the worst of the six:

  • The Chaos-Warrior’s entire mana column is wrong. Thirty-one of its thirty-two rows disagree with ours, and tables.c agrees with ours every time — { 40, 67 } for Invoke Logrus where the spoiler says 45, { 47, 150 } for Summon Demon where it says 100. The spoiler’s numbers look like an earlier table’s, uniformly about two thirds of the real cost.

  • The Priest’s second book is shifted a row, so six spells carry the previous spell’s figures.

  • The High-Mage’s Trap/Door Destruction is given as level 2; the table says 1.

And three of its spell descriptions are wrong outright. Meteor Swarm is described as “damage 65+level, radius 3 if level less than 40, 4 otherwise”; the source is project(0, 2, x, y, (plev * 3) / 2, GF_METEOR, ...) — radius 2, damage plev\*3/2, which is what we have. And Summon Demon is described as “1 in 3 the demon(s) will try to kill you”, where the code says bool pet = (one_in_(3)) — one in three that it serves you, two in three hostile. The spoiler has it exactly backwards.

Which is the interesting part, because our manual had it backwards the other way. The Chaos chapter still said Summon Demon “is always hostile, where Zangband gave it a one-in-three chance of serving you”. That was true in 3.57.0 and stopped being true in 3.72.0 when pets let the pet branch come back; pets.rst was updated then and this chapter was not. Three documents, three different answers, and only the game itself right. It is the fourth stale pets-era claim these sweeps have turned up and the pattern is always the same shape: a thing was blocked on monster allegiance, allegiance arrived, the fix landed, and one sentence somewhere else kept describing the old world.

One thing I checked expecting to find an error and did not: the chapter says a Chaos backfire can drop “eight monsters” on you. chaos_backfires() case 34 and 35 loop for (i = 0; i < 8; i++) around EF_SUMMON. Eight it is.

The rest of the work was filling the chapter out. It described a realm that is “almost nothing else” but attack spells and then named four of them, so Chain Lightning, Breathe Logrus, Wonder, Alter Reality, Polymorph Other, Teleport Other, Arcane Binding, Magic Rocket and Invoke Logrus are in it now, along with the realm’s own sentence about itself — Chaos has no protective spells, which is the thing to weigh before taking it alone.

11 September 2026 — Trump, and a spell that detected one square#

Fifth spoiler and the worst haul yet: three defects in the data, one false claim in a decision note, and the manual missing about a third of the realm.

Trump Divination detected nothing. Every DETECT_ handler in 4.2 takes the half-height and half-width of the rectangle it searches from the effect’s own y and x fields, supplied by effect-yx. Those default to zero. Trump Divination had seven detections and no effect-yx on any of them, so it searched a rectangle of zero by zero: the single square the caster was standing on. Thirty mana, level twenty-five to thirty-five depending on class, and it told you about the floor under your feet.

What makes it worth writing down is how invisible it was. The spell has the right seven effects in the right order; the chain reads correctly; nothing warns, nothing fails, and the spell reports success. I only found it by noticing the chain was shorter than Arcane’s Detection, which does the same seven things — and the difference was seven repetitions of a line that is easy to read as noise. A sweep over class.txt says it was the only spell in the game missing it, which is the good news and the reason nothing else showed a symptom.

Mind Blast was wrong twice, and both errors came from the note above it. The note transcribed damroll(3 + (plev-1)/5, 3) as /4 and the chain followed the note — a quarter more dice than Zangband from level five up. And fire_bolt_or_beam(beam - 10, ...) lost its - 10 entirely, so it beamed ten per cent more often than it should. Arcane’s Zap is the same shape, BOLT_OR_BEAM:ELEC:0:-10 with /5, and had both right; the two spells sitting side by side in the same file with different answers is what made it visible. The lesson I keep relearning this week: the note is not the source, and when the note and the data agree with each other and not with cmd5.c, they agree because one was copied from the other.

Trump Spying finally fixed, having reported it twice and fixed it neither time. rand_range(25, 55) is 24+1d31; the spoiler renders that roll as “1d30+25” and this entry followed the spoiler. Three other spells make the same call and all three were right.

Then the one I am not fixing. The [trump] header comment said the hostile summon “is not lost: it is the realm’s miscast, which Death and Chaos already have and Trump shares through the same machinery.” Trump shares no such thing. spell_backfire_kind() returns BACKFIRE_CHAOS for chaos, BACKFIRE_DEATH for death and BACKFIRE_NONE for everything else, and player/miscast asserts exactly that for every spell of every class. So a failed Trump summon costs the mana and nothing else, and the hostile half of the realm — which is the risk the spoiler opens on — is simply gone. The comment now says so. Giving Trump its own backfire is a design decision and Steven’s to make; correcting a note that claimed the work was already done is not.

The manual was missing both attack spells, the fetch, the detection, the banish, the brand and Mass Trump — a chapter that said “what it is for is reach” and then listed only the teleports. All in now, along with the note about failed summons, because a player choosing this realm on the strength of “cast it badly and an angry group appears” should know that half of the sentence is not true here.

11 September 2026 — Nature, and a resistance nobody was getting#

Fourth spoiler, and the first one where the defect I found is worth real hit points.

Resistance True was missing poison. Zangband’s case 19 calls inc_oppose_ five times — acid, electricity, fire, cold and poison — and our chain had four of the five. The note beside it explained the SET_VALUE bracket carefully and correctly and simply never mentioned the fifth call, so there was nothing to notice: a spell called Resistance True, in the book you buy at level eighteen, granting exactly what the cheaper Resist Environment grants plus acid. The spoiler says “resistance to all elements and poison” and it was right. OPP_POIS has existed in 4.2 all along and class.txt uses it in five other places.

That is the half of the spell worth the mana, which is what makes it a real loss rather than a tidy one: every one of the four elements has a cheap single-element spell somewhere in the game, and poison does not.

The figures are otherwise exact — 192 level-and-mana rows across the six classes Zangband gives Nature, and one apparent difference which resolved the other way. The spoiler puts the Mage’s Nature Awareness at 5/5; tables.c says { 7, 6 }, and cmd5.c labels the case /* Nature Awareness -- downgraded */. The spoiler was written before somebody downgraded it and then transcribed the column one row out, which is why it shows one 7 6 where the table has two. Our data follows the table. Four spoilers in and the count is five errors in the documents against two in the game.

The manual had a plain factual error in this one, and it is the kind that comes from writing about a book without opening it. “The last four spells are a blizzard, a lightning storm, a whirlpool and Nature’s Wrath.” The last four are Whirlpool, Call Sunlight, Elemental Branding and Nature’s Wrath; Blizzard and Lightning Storm are the two before. Two whole spells — flooding the level with sunlight, and putting fire or frost on your weapon — appeared nowhere in the chapter at all.

The other thing missing was the spoiler’s own first paragraph: Nature “contains the only powerful healing spell outside the Life Realm”. I checked it rather than copying it — Herbal Healing at a thousand points is the only heal of that size outside Life in the whole game; Death’s Vampirism True drains a hundred at a time and is a different thing. That is the answer to “why would a Ranger or a Druid take this”, and the chapter never gave it.

11 September 2026 — the fifth deferral was never a deferral#

Steven said to do it, so Trump’s Dimension Door works now. One line of realmmap: defer out, effects = [ "TELEPORT_TO" ] in.

The interesting part is what caught it and what caught me. The reason recorded against the spell was that 4.2 has “no interface for picking an empty square to appear on”. effect_handler_TELEPORT_TO has a player-choice branch that calls get_aim_dir, falls through to target_get, sets a local dim_door flag, and randomises the landing only when you aimed into a vault — which is Zangband’s own rule for the spell, written out in 4.2’s own code. Sorcery’s Dimension Door is the same Zangband function and has been shipping on that branch since Sorcery went in. So the realm that could not express the spell and the realm that already did were the same game, three milestones apart, and nobody compared them because nobody had reason to until I read Sorcery against its spoiler yesterday.

What I got right by accident: the test. trumps_deferrals_are_the_five names its five rather than counting them, and the comment above it says a count “would let a sixth in silently”. It does the opposite job just as well — the moment Dimension Door grew an effect the test failed on require(!expected), because a spell on the list is not allowed to work. So the list and the comment both had to be rewritten to say four, and the reasoning for the one that left had to be written down to make the test build. A test that made me explain myself before it would go green.

A trap worth recording twice. cmake --build build --target unittests does not relink a changed test source, so the first run gave me 26 of 27 with the old test name in the output, which reads exactly like a real failure in new code. --target run-unittest-player-realm rebuilds the one test and runs it: 27 of 27. I have written this down before and still lost ten minutes to it.

Seventeen inert spells now, not eighteen, and Trump has four. Worth noticing that the number went down for the first time since these sweeps began.

11 September 2026 — Sorcery, and the charm that missed the boat#

Same treatment for Sorcery. The figures are exact: 190 level-and-mana rows across the six classes that can study it, no mismatches, all thirty-two names in the spoiler’s order, four book titles right, the first two in town. The spoiler marks one row XXXXXXXX for a Rogue and a Ranger and it is Globe of Invulnerability — which is precisely the seven-of-eight Grimoire of Power that turned up in yesterday’s sweep of short books. Two independent readings of the same table agreeing is the first time this week I have not had to go and check something twice.

Two real defects, both in the realm I was checking.

Charm Monster never got the pets treatment. Zangband’s charm_monster(dir, plev) makes a permanent pet. It was mapped to COMMAND — take a monster over for a while and hand it back — with the note saying, quite correctly at the time, that 4.2 has no pets. PLR-22 brought pets, and the other four charms in the game were rebuilt on MON_CHARM: Life’s Day of the Dove, Nature’s Animal Taming and Animal Friendship, Death’s Enslave Undead. This one was missed. It is the only spell left in the game whose note still says 4.2 has no pets, and it has been the wrong spell ever since — not weaker, different, in exactly the way the Trump work spent a milestone arguing about. Now BOLT_AWARE:MON_CHARM at the caster’s level, the single-target twin of Day of the Dove.

Globe of Invulnerability was a turn short. rand_range(8, 16) is eight to sixteen inclusive, which in 4.2’s dice is 7+1d9. We had 7+1d8. Life’s Holy Invulnerability is rand_range(7, 14) and was already right, which is what made the other one visible: two spells of the same shape rendered two different ways, and only one of them can be correct.

And two things the manual had wrong, both of which I only caught by reading the source rather than the chapter.

Telekinesis was described as “lifting fifteen pounds a level”. The call is fetch(dir, plev * 15, FALSE) and 4.2 stores weight in tenths of a pound — ui-object.c prints weight / 10 and weight % 10 as “N.N lb”. So it is a pound and a half per level, not fifteen. At level forty that is the difference between sixty pounds and six hundred, and the spoiler had it right all along: “1.5*level lbs”.

Recharging was described as “four times the strength of the Arcane spell of the same name”. Both are recharge(plev * 4). They are the same spell at the same strength; Arcane simply charges you twenty-one levels more for it, which is a better sentence anyway and is the one there now. The “four times” is against Angband’s own Recharging, which is what the realmmap note said and the chapter garbled.

One finding I am leaving alone because it is not Sorcery’s. Trump’s Dimension Door is deferred on a premise that is false. Its note says 4.2 has “no interface for picking an empty square to appear on”. It has exactly that: effect_handler_TELEPORT_TO has a player-choice branch that calls get_aim_dir and target_get, sets a local dim_door flag, and randomises the landing only if you aimed into a vault. Sorcery’s Dimension Door — the same Zangband function — already ships on it and works. So one realm’s copy of the spell does the thing while the other does nothing and explains that it cannot, which is the Holy Vision situation inside out. It is a one-line fix and it is Steven’s call, not mine to slip in while checking a different realm.

11 September 2026 — checking Arcane, and the spoiler losing#

Third pass over an old spoiler today, and the first one where the implementation came out ahead of the document I was checking it against.

Arcane is faithful. All thirty-two names in the spoiler’s order, the four book titles, all four sold in town at 100, 250, 1000 and 2500 gold — and every level and mana figure for all six classes that can study it, which I diffed by machine rather than by eye: 191 rows, no mismatches. Phlogiston and Detect Enchantment are the two deferrals the manual already names.

Twice the spoiler is simply wrong, and we follow the source. It says Cure Medium Wounds is “same as Life spell”, which is 4d10; cmd5.c says hp_player(damroll(4, 8)). It says Recharging is power level times two; the source says recharge(plev * 4). Our data has 4d8 and plev*4, and the note beside Recharging already said so in as many words. Somebody read the code rather than the spoiler when this realm went in, and it shows.

One more where I expected a defect and found care. Zangband’s telepathy durations are all rand_range(25, 55), and the spoiler renders that as “25+1d30”, which is 26 to 55 and wrong by one at the bottom. Our Arcane and Sorcery entries say 24+1d31, which is exactly 25 to 55. Trump Spying is the one that took the spoiler’s figure instead — 25+1d30 — so that one is a turn short at the minimum. One turn, in one spell, in a realm nobody asked me about; noted here and not fixed today.

What was missing was the manual, again, and the biggest gap is the spoiler’s own headline. Arcane “attempts to encompass all ‘useful’ spells from all realms”, and the specialised realms “usually offer the same spell at a lower level and cost” — so it is “perhaps not recommendable as one’s only realm”. The chapter said Arcane is buyable and called that its bargain, and never said the other half. I checked the claim before writing it down: for a Mage, nineteen Arcane spells also appear in a realm it might be studying, and seventeen of the nineteen are cheaper there. Identify is level 38 for 30 mana in Arcane and 10 for 7 in Sorcery. Stone to Mud is 20 against Nature’s 5. Only Cure Poison is better in Arcane. The spoiler’s advice survives the port exactly.

Then the Ranger’s Clairvoyance row in the spoiler’s table says XXXXXXXX, and our Ranger’s Manual of Mastery holds seven spells. Correct — and it pulled a thread. Sixteen books in this game hold fewer than eight spells, and all sixteen belong to a Rogue or a Ranger, the two classes entitled to realms they were never built for. A Rogue’s Necronomicon has four rituals of eight, no Hellfire among them, and two of the four are inert: two working rituals in a fifty thousand gold book. That is a thing a player should be told before buying it, and the manual has never mentioned it anywhere. It does now, with the whole table.

11 September 2026 — three spells that asked the caster’s class#

Steven read this morning’s entry and said to fix both. Neither fix was the one I expected.

Holy Orb. The comment in realmmap.toml claimed the priest/non-priest halving was carried on the other field of the ball, “which is what its own Orb of Draining already does”. It is not. other on BALL is a radius divisor — 4.2 adds one to the radius for every other levels the caster has — and that is how Zangband’s rule of radius 2 below level 30 and 3 above it arrives. The field was doing a real job, just not the job the comment named, and because it looked like the class bonus had been handled nobody went looking for the class bonus.

So the halving had nowhere to live, and the spell shipped at three halves of level for every class — the priestly figure, handed to Mages, Paladins, Monks and Warrior-Mages as well.

Then it turned out not to be one spell. Zangband tests pclass inside three spells: Holy Orb for a Priest or High-Mage, and Mana Burst in Chaos and Orb of Entropy in Death for a Mage or High-Mage, all three the same plev/2-or-plev/4 divisor. The other two were handled honestly and differently — their notes say the distinction is one “the data language cannot ask” and give every class the non-mage figure. That was true of the effect language and false of the file: class.txt holds a separate copy of every spell per class. The distinction cannot go in the chain and never needed to; it goes in the file.

So class-effects now sits beside effects in realmmap.toml, naming the classes that differ, and all three spells carry Zangband’s split. Holy Orb is the only one where anyone loses: four classes stop getting a bonus they were never entitled to. The two mage classes gain on the other two, which is what Zangband gave them.

The generator validates the key, because the failure mode is silence — a misspelt class name would miss the lookup and quietly hand that class the default chain, which is precisely the bug the key exists to fix. Preist now fails the build.

Holy Vision. I had assumed its description overclaimed and wanted rewording. Reading cmd5.c instead of the spoiler settled it: case 29: return identify_fully(). It is the same call as Sorcery’s Identify True, Trump’s Trump Lore and Nature’s Stone Tell, and all three of those are deferred for the same stated reason. Holy Vision alone had been mapped to IDENTIFY, which in 4.2 learns a single rune, and then described as telling you everything about an object — the one thing it could not do.

Rewording it would have left Life selling one rune for fifty mana at level 40 when Sorcery sells the same rune for two mana at level 5. So it is deferred, which makes four spells behind that one wall and eighteen inert spells in the game rather than seventeen. Whole-object identification is now comfortably the commonest reason a spell here does nothing, and the feature list says so, which it did not before.

None of this touches a savefile. The spell-list fingerprint hashes names and indices and not effects, so the corpus still loads 38 of 38 — I checked rather than reasoned about it, having reasoned wrongly once already today.

A thing I did not change, and want written down because it will come up. Our Necromancer and Blackguard take a Zangband class’s figures under DEC-55, and a Necromancer therefore casts Orb of Entropy at Mage levels and Mage mana. It does not get the Mage’s damage bonus, because Zangband’s test names CLASS_MAGE and a Necromancer is not one. Death’s own class not getting Death’s orb bonus is arguable the other way — the Priest gets Life’s — and if it should change it is one line in realms.py and a re-run.

11 September 2026 — the realm the chapter forgot#

Steven had archive/manual/Life-Magic.txt open and asked whether our manual covers it. Mostly it does, through the in-game spell descriptions, which is where the spoiler’s per-spell effects belong. But the realm chapter had a section for Sorcery, Trump, Chaos, Arcane, Nature and Death, and none for Life — the first realm we converted, and the only one with nothing written about it.

The chapter caught itself. Its own opening note says seventeen spells across the seven realms are inert and “are named in the realm sections below”, and the sections name sixteen: four in Sorcery, five in Trump, two in Arcane, two in Nature, three in Death. The seventeenth is Bless Weapon, and with no Life section there was nowhere for it to be named. The four book titles had the same problem — Book of Common Prayer, High Mass, Book of the Unicorn, Blessings of the Grail appeared once in the release log and nowhere a player would look, so the manual never said which two are sold in town.

Checking the numbers before writing the section turned up two things I did not go looking for, and neither is a documentation problem.

Holy Orb’s class bonus is not in the game. Zangband gives 3d6 + 3/2 of your level to priests and high mages and 3d6 + 5/4 to everybody else, and realmmap.toml has a comment saying exactly that, and saying it is expressed as the other field on the ball the way 4.2’s own Orb of Draining does it. The emitted chains do not have it. A Priest’s Holy Orb and a Paladin’s are identical character for character — $B+3d6 with B at three halves of level — so every class casts the priestly version. The mapping file describes a translation that did not happen.

Holy Vision claims something the engine does not do. Its description says it tells you everything about one object; it emits IDENTIFY, and 4.2’s IDENTIFY identifies a single unknown rune on one item. Whole-object identification is precisely the wall that deferred Identify True, Trump Lore and Stone Tell — the realm chapter names it as impossible three times in three different realms, and then Life quietly offers it for fifty mana. I left the spell out of the new section rather than write the claim down a fourth time.

So the section says what the realm is for, which books are where, and the three prayers that translate to something measurably different — the two dispels that hit demons by being evil, Warding True reduced to the one glyph, and Divine Intervention doing nine of its ten things for want of an angel in the summon table. It also records something I had not noticed until I put the levels side by side: Life is the one realm a High-Mage is not first into. A Priest reaches Holy Orb at 10 against its 19, and Holy Invulnerability at 45 against 49. The chapter claimed the High-Mage gets there earliest full stop, and that is true in six realms out of seven. Zangband’s table knows whose realm this is.

Two smaller corrections fell out of the same read. The feature list said six of the seven realms were playable, with a hundred and ninety-two workings in twenty-four books, eleven lines above a sentence saying all seven are — it was written when Trump was still out and never updated when Trump went in.

10 September 2026 — three cues for one fact#

Steven sent me a design for the race list on the character creation screen, which he said “does not work very well”. The list is longer than the screen and scrolls, and until today the only thing that said so was a thumb in a one-column gutter, which I added on the sixth. His design keeps it and puts two more cues behind it for the same fact: a count in the column header, and a row of plain words under the menu. His words: “row of words is unambiguous in a way no ASCII glyph in a 1-column gutter can be, and it costs one line of screen”.

He is right, and the reason is worth writing down. The bar answers where am I as a picture, and a picture one column wide has to be recognised as a scrollbar before it can say anything at all — and nothing on the screen said it was one. So now there is RACE  1-21/28 over the list and 13 more below - Up/Down or SPACE to scroll, a-y to pick directly under it. All three appear only while the list scrolls, which means the presence of the status line is itself a signal, and a menu that fits looks exactly as it did.

The header pays for itself twice over: until today no column on that screen said what it was. The line under the menu costs a row of the list, which is what the second row given up by MENU_ROWS is for.

Then two things showed up that I only saw because I drew the screen at actual size instead of reading the code.

The bar was drawn hard up against the column to its right — ^Str:  +0 — and capping the ends made that worse rather than better, because the caps are what make a column of punctuation read as a bar, and a bar glued to a stat block reads as a typo. The columns were packed edge to edge and sized so that Chaos-Warrior filled its field exactly, so the fix was a column of air on each side of the bar and one more column of width for the lists. The roller, the rightmost thing on the screen, still ends at column 73 of 80.

The second was older than anything I did today. Each question writes its help text in the column to its right, and when the next question is asked, its list is drawn over the top of that help. The two do not cover exactly the same columns — so as soon as there was a gap between them, a one-character stripe of the previous answer’s stat block showed through it: a stray 0, +, m standing in an otherwise blank column beside the class list, looking for all the world like part of it. The help text of a question already answered is simply not redrawn now. It was never meant to be seen; it was only ever being covered up.

Then, looking at the finished screen, he found something that had nothing to do with any of it: ESC at the stats column did not go back to the class. It never had, for any class. The realm question is skipped when the class has nothing to choose — a Warrior studies no magic — and the skip walked forwards whichever direction it was being walked through, so stepping back off the stats question landed on a stage with nothing to ask, which sent you straight to the stats question again. The slot counter is also left past the end when the question finishes, so a Mage, who does have a realm to choose, hit exactly the same wall. Two lines: start the question again from its first slot when it is arrived at from below, and when it has nothing to ask, walk on the way we were going. A question never asked is not a place to stop on the way back either.

That one now has an end-to-end test — walk to the stats column, escape, change the class, and check the character comes out a Mage. It fails on the old code with a Warrior, which is the whole point of writing it.

5 September 2026 — three wrong answers before the right one#

Steven reported a graphical fault: black squares appearing in the wilderness and going away again as he moved. It was not graphical. It took me three wrong answers to find that out, and each one was wrong in an instructive way.

First I explained it away. I read the code, found that the surface deliberately does not memorise itself under daylight, found that trees do not allow line of sight, and concluded the black squares were unexplored ground behind a tree — working as designed, and would he like me to change the design? He said: no, the ground was explored, it was drawn, and it became unexplored as I moved. Which is not the design and never could be.

I should not have needed telling. He had said “glitch” and I had heard “thing I can explain”. The explanation was coherent, drew on real code, and answered a question he had not asked.

Then I guessed at mechanisms. Monsters with blank tiles. Objects with no mapping. A knowledge chunk losing grids across a window scroll. Each time I reasoned from a screenshot to a theory and went looking for evidence to fit it. I wrote a headless harness to reproduce it, walked a character sixty steps, found nothing, and concluded the harness was missing something rather than that my idea was wrong.

Then he gave me a savefile. Ardormor, and “just move left and right”.

That changed everything, and the thing it changed was that I stopped theorising. I put a probe in the real game loop, drove the real save through the test front end, and printed the state of every isolated unknown grid. A hundred and twenty-five of them, and every single one said the same word: tree.

From there it was mechanical, and the mechanics were not where I had been looking. The trees were in view. They were lit. They had line of sight to the player. become_viewable() was setting them seen, correctly, every pass — I traced it and watched it do so. They were then memorised, correctly, twice. And then something forgot them. A backtrace named it in one line: path_analyse(), reached from update_monsters(), every turn.

path_analyse() walks the projection path from the player to each visible monster and forgets any grid on it that the player remembers as blocking sight — on the reasoning that a monster visible through it proves the memory wrong. In Angband that reasoning cannot fail, because nothing there passes a projectile while blocking sight. A tree does both, and it is the only thing in this game that does. So los() and project_path() disagree about trees: the monster is visible around one while the path to it goes through one. The memory was right and was thrown away anyway.

And it compounds, which is what made it look like a flickering fault rather than a steady one. A forgotten grid remembers as FEAT_NONE, FEAT_NONE has no LOS flag, so the test that forgot it stays true forever after. One tree could hold a line of country open until you walked down it.

Thirty-eight lost squares on his savefile before, none after.

What I want to keep from this. The bug was found the moment I had a reproduction and stopped reasoning from pictures. Everything before that — three theories, a harness that found nothing, an explanation of why it was not a bug — was me preferring an argument I could make to evidence I did not have. The savefile was worth more than all of it, and I could have asked for one at the start.

The other half is smaller and also mine. When somebody who has played the game for months says a thing is happening, the useful response is to find out what they saw, not to explain why what they saw was correct.

And the test needed four goes, which is one more than the fix. It passed against a broken build (it never refreshed the view, and the function under test only runs for monsters in view). It wiped the town’s residents. It left the character standing somewhere else and crashed one run in twelve putting a knowledge array back. Then, given a suite of its own, it still failed under the sanitizer pass on a seed of its own: it placed monsters on a fixed ring three grids out, and a tree is passable but is not floor, so square_isempty() refuses one — stood in woodland, which is the entire point of the test, that ring can be trees the whole way round.

Each of those was me writing a test that asserted the right thing and arranged the wrong world for it. The lesson I want is not “be careful”: it is that a test which has never been watched to fail is not yet a test, and I only caught three of these four by deliberately breaking the fix and looking.

A footnote on the harness, because it cost an hour and would cost it again. My first before/after comparisons said the fix changed nothing, twice. The game was saving back to the savefile between runs, so each run started from where the last one left off and I was never comparing the same thing. Copy the save in fresh every run. The numbers only meant something after that.

4 September 2026 — writing a script that can see colour#

Gervais had a wilderness hiding in it. The other three tilesets had nothing, so they had to be drawn, and I cannot draw. Steven had already said do all three, including the two whose licences say nothing at all, having gone and checked for himself that there is nothing to find.

The way out is that nine of the ten features are not pictures. Grass is mottled greens. Deep water is a blue that barely varies. Sand is mottled tans. A texture is a rule rather than a drawing, and a rule can be asked for at 16x16 and again at 8x8, where a shrunk 32x32 tile is mush. Only the tree is a shape, and a blob with a trunk under it reads as a tree at sixteen pixels.

That also decided the palette question. If the script picks the greens, they are this script’s greens and the tile sits beside the sheet rather than in it. So it samples: every opaque pixel in the target sheet, sorted into hue families, most frequent kept. Adam Bolt’s greens for Adam Bolt. Nothing in there knows what colour grass is; it knows to go and ask.

Then I spent an hour learning that I cannot classify colour by inequality.

The first run put scarlet earth in two of the three tilesets, because r >= g >= b is true of pure red. I tightened it to require green clear of blue, and (110, 45, 35) sailed through, which is brick. Earth is on the orange axis: green has to be a real fraction of the way up from blue towards red, and a quarter turned out to be the number.

The second run gave Adam Bolt a blue tree. Its brightest “green” was (88, 136, 136) — teal, where green and blue are equal, and g >= b does not mind. Nomad’s brightest green was (248, 248, 8), which is yellow, where red and green are equal and g >= r does not mind either. Green has to be greener than both its neighbours, which is obvious once you have shipped two tilesets that disagree.

And the canopy was washing out even after that, because I was brightening by multiplying every channel. The first channel to hit 255 stops while the others keep climbing, so the hue slides towards white. Cap the factor at what the brightest channel can take and the ratios — and the colour — survive.

What I want to remember is not the three bugs. It is that I found the first two by rendering the tiles and squinting, got the diagnosis wrong twice, and then found all three in about a minute by printing the sampled palette. The palette is three numbers per colour. The tile is a thousand pixels. I had been reading the big thing.

The other lesson was cheaper and worse. The first version appended a row unconditionally, so running it twice put two rows on and moved every coordinate. A generated file that changes every time you regenerate it is not a generated file, it is a liability, and I caught it only because I happened to run the script twice while fixing something else. It reclaims its own row now, and I tested that by running it three times and diffing the bytes.

Steven asked, while this was going on, that we write down what we have done to these tilesets. He is right and it is the single most useful thing to come out of today. There is now lib/tiles/README, and the same in LICENSE.md and the manual, and the distinction it draws is the one that actually matters: a mapping adds a lookup table pointing at art already in the sheet and alters no image, and a modification writes pixels. Gervais is mapped and its PNG is byte-identical to Angband’s. The other three are modified, one row each, nothing existing touched.

That distinction is not pedantry. It is the whole of whether a licence that forbids modification has been respected — which is precisely the thing I got wrong this morning by reading Shockbolt’s terms for the question I was already asking instead of the one that mattered. Writing down which kind of change we made to what means the next person does not have to reconstruct it from the pixels, and neither do I.

4 September 2026 — the tiles were in the box#

The wilderness had no art. Seventeen terrain features — grass, earth, sand, mud, water at three depths, roads, trees, mountainside, six town services — and not a tile for any of them in any of the five tilesets. In graphics mode the game’s flagship feature was a field of ASCII letters surrounded by pictures.

I costed the obvious answer and it was awful: seventeen tiles in five styles is eighty-five pieces of art, and I cannot draw. So before proposing anything I went and looked in the sheets, and the answer was sitting in one of them.

Angband maps 1,151 of Gervais’ 3,840 cells. Another 1,239 unmapped cells hold art, and a contiguous block — sheet rows 23 to 25 — is a wilderness terrain set. Grass, three browns of earth, sand, cobbled road, water pale and deep and open, boulders, bushes, bare trees, blossom trees. Gervais drew for a variant audience in the days when everybody was building a wilderness. Angband never had one, so nobody ever wired them up. They have been shipping in this repository, unused, the whole time.

The mechanics of it turned out to be measurable, which I did not expect. Gervais gives each feature three consecutive columns for its lighting states, and I could not tell from the file which column was which — graf-dvg.prf maps FLOOR as N, N+1, N+2 but LAVA as N, N-1, N+1, so there is no positional rule. There is a luminance rule: across the fifteen features already mapped three ways, mean brightness runs torch > lit > dark without exception. Torch is a light right beside you and is the brightest; lit is ambient room light; dark is remembered. Once I had that, finding candidates was mechanical — a run of three unmapped, fully-covered cells whose luminance descends — and 3,840 cells became 75 to choose between.

Then I nearly ruined it by trusting the arithmetic. I picked bare earth by mean colour, rgb(130,83,53), a perfectly sensible brown. It is lava. Molten rock photographs as brown if you average it, because the black crust and the orange cracks meet in the middle. My open sea had a large blue snowflake painted on it. Both went straight into a render and both were obvious in a second.

That is the whole lesson of the afternoon, really. The classifier narrowed 3,840 cells to 75 and could not tell lava from soil; my eye could not have found the 75 and settled the last step instantly. Neither half was optional.

I stopped at thirteen of eighteen. The four town services left over — inn, magesmith, chaos tower, recharger — could have had a borrowed arch each from the row the shops use, and I decided against it. Two buildings drawn identically is worse than a building drawn as a letter: the letter is honestly unfinished, and the duplicate is quietly wrong. That is the same rule the monster tiles already follow, where uniques are deliberately left as text rather than given a stand-in portrait that says something false about them.

And a real defect fell out of it. Adding a new prf meant adding it to the tileset’s DATA list, which is where I noticed graf-ztk.prf was not in one — not in any of the four. That file is what gives 234 imported monsters a tile instead of a letter, every tileset’s main prf asks for it with a %: line, and make install has never copied it. Any build that installs rather than running from the tree has been shipping four tilesets that reference a missing file, since the day the feature landed.

It was invisible for the same reason as everything else I have found today: the only thing that would notice is an install, and this machine does not do one. check-build-lists checks lib/gamedata/Makefile against disk and stopped there. It checks the tilesets now. I tested that by deleting an entry and watching it go red, because a check I have not seen fail is a check I have not tested.

Three times today a generated or enumerated file had quietly drifted from what it describes, and every time the gate that would have caught it either did not exist or was never run. That is starting to look less like three accidents and more like a category.

4 September 2026 — a tileset we had no right to ship#

I spent the afternoon working out how to get wilderness terrain tiles into five tilesets without drawing eighty-five pieces of art, and found something better than a plan: David Gervais’ sheet already contains a wilderness set. Angband maps 1,151 of its 3,840 cells; another 1,239 unmapped cells hold art, and a contiguous block of it is grass, water, sand, dirt, cobbled road, trees and boulders, in runs of nine that are Gervais’ three lighting variants. He drew for a variant audience. Angband has no wilderness, so nobody wired them up. Ours is a mapping job, not an art job.

Then I checked the licences, because two of the options involved deriving new art from existing sheets, and derivation is where licences start to matter. Adam Bolt’s are free for any purpose. Gervais is CC-BY 3.0. And Shockbolt’s said, in our own LICENSE.md, that modification needs the author’s permission.

I reported that as a constraint on adding tiles. Steven went and read the original, which is blunter than the paraphrase we inherited:

Permission is not granted to: modify the tileset without the author’s permission; use or distribute the tileset with other games or projects.

Not “modification needs permission”. Use or distribute with other games or projects. We are another project. We have been shipping 17MB of somebody else’s art on a permission that was never granted to us — in the repository, in the disk image, in the Windows package, in the Debian source tarball.

His answer was one line: we just drop it. That is the right answer and I did not reach it myself, which is worth writing down. I had the licence text in front of me and read it for the question I was already asking — can we add tiles? — and not for the question that mattered, which is may we have these at all? A constraint on modifying something is only interesting if you are entitled to it in the first place, and I never checked the antecedent.

The removal itself was undramatic, which is its own small relief: there is no Shockbolt-specific code anywhere. Three source files mention it and all three are comments. The double-height tile support is generic and stays. grafmode.c already refuses a mode whose image is missing — written for the web build, which left the big set out to keep the page load down — so a saved preference naming mode 5 or 6 now finds nothing and falls back to ASCII without anybody writing new code for it. I left ids 5 and 6 unused rather than renumbering, because a stale preference resolving to a different tileset is worse than one resolving to none.

Two nice consequences. The web build no longer omits anything, because Shockbolt was the only reason it omitted anything. And the terrain problem got easier: the one tileset whose licence forbade us adding the missing tiles is the one that has gone, and Gervais — which already has the art — permits both mapping and modification.

One more thing fell out of reading Angband’s copying page properly. The original 8×8 tiles and Nomad’s carry no licence statement at all, there or here. That is not the same kind of problem — it is an inherited silence rather than an explicit refusal — but it is a silence I would rather have written down than assumed, so it is in LICENSE.md now.

I want to be careful not to turn this into a complaint about the terms. Raymond Gaustadnes drew that tileset for Angband and is entitled to say where it goes. The licence even offers permission on request to non-commercial projects, which this is. Somebody could write and ask. Until somebody does, the honest position is the one we now hold.

A postscript, from a typo. I meant to run borrow-tiles.py --check and typed check, which is not a flag the script knows, so it fell through to the generate path and rewrote all four graf-ztk.prf files. Two lines disappeared: hobo and raving lunatic.

I nearly reverted it. A licence removal is not the place for an unexplained data change, and the right instinct on seeing one is to put it back. But the entries had gone because the monsters had gone – dropped from the import weeks ago in 77b340f50, for being Angband’s own wearing Zangband names – and nothing had regenerated the file since. The committed prf had been pointing at two monsters that do not exist.

The script has had a --check mode for precisely this since it was written, and it names both faults the moment you run it. I checked that by putting the stale file back and running it: two problems, both correct. Nothing has ever run it. So it is a pass in check-build now, next to the realm check and the build-input lists, which are both there because CI caught something the local gate did not. Same lesson, third time: a check nobody runs is a check that does not exist.

A mistyped argument found a fortnight-old defect. I would rather have found it on purpose.

4 September 2026 — the dragon went to the other side#

Steven found a forum summary of what players actually liked about the original and asked me to check it against what we have built. That is a better question than it looks, because it is not “is it faithful” — it is “did the part anyone cared about survive the port”. Five points. Four came back mostly clean.

The third one did not. It reads: “deep mechanics like recruiting an army of summoned pets that can polymorph into incredibly powerful dragons”. We have the army — the whole of Trump is summons, and the upkeep is built. We have the dragons: Trump Dragon and Trump Ancient Dragon call one directly. What we did not have was the sentence’s verb.

Polymorph a pet here and it came back hostile.

The reason is the shape of the code rather than anything anyone decided. A polymorph is one creature to the player and two to the machine: 4.2 deletes the monster and places a new one at the same square. Everything that lives on the monster struct and is not explicitly copied across that seam is gone, and since PLR-22 one of those things is whose side it is on. Nobody wrote “a polymorphed pet turns on you”; the field simply was not carried, and a new monster is hostile because hostile is zero.

Zangband knew. polymorph_monster() reads friendly and pet into two locals before the delete and hands them to place_monster_aux() after — five lines, obviously deliberate, and the reason the strategy the forum post remembers was possible at all. Ours is the same two points, one line each.

The part that bothered me more than the bug was that it was silent. MON_POLY sets the damage to zero, which is correct — a polymorph is not a wound. But PLR-33’s anger has exactly one site, mon_take_hit(), and that returns early on zero damage. So nothing angered the pet and nothing said “gets angry!”. You cast a spell on your own animal, it turned into something else, and then it attacked you, with no line of text anywhere accounting for the change. A player would reasonably conclude the game was broken. It was, but not in the way they would have guessed.

Writing the tests made me find the interesting half. There are two rules here and they look like they contradict: hurting a pet turns it hostile, and a polymorphed pet stays yours. Chaos does both at once — it damages and it polymorphs. Which wins?

The order settles it, and the order was already right by accident. project_m() applies damage first and side effects second, so a chaos ball angers the pet through the ordinary path, and by the time the shape changes the side being carried across is the one anger just wrote. Hostile. Both rules hold and neither needed a special case — as long as the allegiance is read at the top of the side-effect handler and not any earlier. Read it earlier and preserving it becomes a way to launder an attack: bomb your own animal for free provided it polymorphs. There is a test for that now, and it is the one I would have missed if I had only tested the thing I set out to fix.

Checked the fix the way the last two went in: reverted the restore and ran the suite. The pet and the friendly monster fail, the chaos case still passes. That split is the whole rule, stated as a pair of failures.

One thing I looked at and left alone. If place_new_monster() fails after the delete, the monster is simply gone — and for a pet that means it vanishes with no message. Zangband put the old race back in that case. Ours does not, and it is 4.2’s own behaviour rather than anything we introduced, so it is written down here rather than changed on the way past.

Later the same day, Steven read that last paragraph and said resolve it, which was the right call and I should have made it myself. “4.2’s own behaviour” was doing a lot of work in that sentence — it is 4.2’s behaviour in a game that has no water in it.

I had assumed the failure was theoretical. It is not, and the number is embarrassing: 3.5 per cent of polymorphs from depth thirty down, about one in twenty-eight, deleted the monster. 1.2 per cent at fifteen, 0.2 in the town.

The cause is entirely ours. Two dozen races in the bestiary are fish, and place_new_monster_one() refuses to put a fish on dry land — a rule we added for the sea, and correct. poly_race() draws from the whole allocation table and never asked where the monster was standing. So every fish drawn was a deletion, and the rate climbs with depth because the deep fish — krakens, whales, Charybdis — sit right in the band a deep polymorph draws from. We built the sea and never told the polymorph about it.

I fixed it at the draw rather than at the failure, which I think is the more interesting half. Zangband’s fallback puts the old monster back, and that turns a deletion into a fizzle — the spell visibly does nothing, one cast in fifty-eight. Better, not good. Asking poly_race() for a shape that can stand where the monster is standing turns it into a spell that works. So the placement preconditions that do not depend on who is standing there are one function now, monster_race_fits_grid(), obeyed by the placer and consulted by the drawer, which is the only arrangement where the two cannot drift apart. Zangband’s fallback is kept behind it, because “unreachable” is a claim I have now been wrong about once today.

The tests took three goes and each go taught me something.

The first pair passed against a deliberately broken build, because I had them counting the wrong thing — with the fallback in place nothing vanishes, so “never empty” goes quiet while the spell is silently fizzling. The counter that separates the fix from the near miss is unchanged: how often the monster comes back as itself. Nought with the filter, eight in six hundred with only the fallback.

The second go was tuned at the wrong depth and with the wrong monster. A young blue dragon is level 21 and the polymorph saving throw bottoms out at 11 whatever the power, so it saves sometimes, and every save counts as unchanged and drowns the signal. A soldier is level 2 and never saves.

The third thing was not about polymorph at all. The suite flaked at 12 per cent, and it was the generator putting the character in a doorway with seven walls round it: the helper that places a monster beside the player wants an empty floor grid, and an open door is passable and is not floor. One level in twenty has nowhere to stand. Generate until there is room.

Three attempts at a test for a fix I was confident in after ten minutes. The fix was the easy part.

4 September 2026 — the manual was describing two different games#

Steven asked me to check the manual for consistency, because it had been a muddle of the original Angband documentation, Zangband’s own docs imported from twenty years ago, and what we have written since. He was right, and the muddle was worse than “some pages are old”. The manual was in places describing a game that is not this one.

The worst of it is the ending. Exploring the Dungeon’s Winning The Game still told you to kill Sauron on level 99 and Morgoth on level 100, complete with Grond, and the players’ A Players’ Guide to ZangbandTK opened its “vital points” list with the same two names. quest.txt has said Oberon and the Serpent of Chaos for months, and DEC-30 is the whole reason this project exists. Two of the four documents a new player is most likely to read were pointing them at Tolkien.

The rest is the ordinary rot of a manual that was inherited rather than written. Three of the four I would not have guessed:

The Chaos Tower is a service and nobody told the towns page. wild.h has six entries in WILD_SERVICE_*; Towns and Services listed five and Features said “Five services” in bold. The tower has been in a great city since DEC-24 and is documented in Mutations — just not in the chapter about buildings.

Trump shipped and three pages did not notice. The Realms of Magic still opened with “Six of the seven realms are playable” and had a section headed Trump, and why you cannot choose it. class.txt has four Trump books and thirty-two spells, and the same milestone quietly un-deferred Animal Taming, Summon Animal, Animal Friendship and Enslave Undead — five spells the Nature and Death sections were still calling inert.

Nobody had recounted the imports. The object page said 85 kinds in one sentence and 82 in another; the file has 86. Ego types were 17 in four places and are 16. Armour was nineteen and is eighteen; rings and amulets were twenty-eight and are twenty-five. Numbers written once and never checked again.

The map symbol tables were Angband’s. N and x were marked (unused); they are twenty-four fish and two lurkers. There was no grass, no tree, no water, no mountainside and no sea in a game whose defining feature is a wilderness, and no 9 for the magetower.

Then the file paths. ~/.angband/Angband and Documents/Angband in four places across the docs, lib/readme.txt, old_class.txt and the man page — VERSION_NAME is “ZangbandTK”, so every one of them named a directory that does not exist. And org.rephial.angband for the macOS preferences, where the bundle identifier has been org.zangbandtk.zangbandtk since the app was first built.

The SDL 1.2 front end I retired yesterday still had its own section in Customising the game, telling you about a status bar in an application that is not built.

A thing I found while reading the keysets#

A was bound twice. cmd_item has Activate an object on it and cmd_info had Command pets on it, both at keymap 0, and cmd_init() walks cmds_all in order and overwrites — so pets won, and CMD_ACTIVATE had no key at all in either keyset.

I flagged it rather than fixing it, because which command keeps a key is a gameplay decision. Steven said fix it, so I went looking for which one should move — and found that the visible half of this was the smaller half.

cmd_lookup_key(CMD_ACTIVATE) was returning 0, and object inscriptions are matched against that value. ui-object.c compares the character after @ to it when looking for a quick tag, and get_item_allow() compares it when looking for a ! confirmation. So @A1 had stopped tagging an item for activation and !A had stopped asking before activating one, on items that had been inscribed that way for years, with no message and nothing in the UI to suggest it. That is a worse bug than a key that opens the wrong menu, and it settles the question: Activate keeps A.

Pets moved to P — ! in the roguelike keyset. There is no letter free in both: the roguelike keyset spends eight on running and eight on walking, and by 4.2 everything else is spoken for. That is why the quest log is J/% and a racial power is N/&, and pets now follow the same shape. P for pets is at least a mnemonic, which A for allies was reaching for.

The reason this shipped at all is the interesting part. The nested command lists assert on a duplicate key — there is a comment in cmd_debug_player explaining that a clash there is “a crash before the title screen rather than a menu that quietly does the wrong thing”, which is exactly right. The top-level lists had no such check and simply overwrote. So the tables that matter most were the ones with no guard on them. They have one now, and command/lookup has a regression test that checks A resolves to CMD_ACTIVATE in both directions — the reverse lookup especially, since that is the half that broke silently.

I put the bug back to check the assert fires. It does, at startup, before the title screen.

world.txt, which was still describing Angband’s world#

Steven asked me to update it in the same pass. It was a hundred and twenty-eight lines naming the depths “Angband 1” through “Angband 127” — one dungeon, a hundred and twenty-eight levels, each linked to the next. That is Angband’s world and not ours: ours is a wilderness with thirteen dungeons opening off it, and depth 100 is in both the Courts of Chaos and the Abyss. Calling depth 40 “Angband 40” said the opposite of what the game does.

They are “Level <n>” now. Depth 0 stays “Town”, because two places in the level builder look the chunk up by that literal string.

The part worth recording is what the file turned out to be for, because it is not what its name suggests. up and down are parsed, cross-checked at startup, and then never read by anything — every caller uses level_by_depth(). What the file actually does is say which depths exist (the level builder dereferences level_by_depth() without checking, so a missing line is a crash on arrival) and give each depth a stable key for persistent levels. The header now says so.

And that key is savefile-visible, which made the rename a real one. With birth_levels_persist a stored level is filed under its depth’s name and that name goes to disk as text, so renaming would have quietly cost such a character every level it had banked. rename.txt’s own header says to add an entry whenever you rename something a savefile can hold; there is no level: record type and adding one for a hundred and twenty-seven mechanical renames would be silly, so the fallback lives in level_by_name() and a small migration runs on the one load path that never calls it. Both read only when the current name has already failed, so a current savefile pays nothing.

One thing I did not fix, and it should be written down: the persistent-level key is the depth, not the level. With thirteen dungeons, the fortieth of Garnath and the fortieth of Rebma are filed under the same name and the second overwrites the first. birth_levels_persist is an experimental option and this is a design question about what a persistent level even means in a world with a map, not a typo to correct. It is in the file header so the next reader does not have to rediscover it.

What I did not do#

The seven inherited manual chapters are corrected, not rewritten. Their prose, their examples and their emphasis are still Angband’s, and they do not attempt to explain the wilderness, pets, mutations, patrons or the realms — those have pages of their own, and The Manual now says so plainly rather than promising a revision that has not happened.

The players’ guide is the one I am least comfortable with. It was written by an Angband player for 3.5.0, it is calibrated to monster numbers we deliberately changed, and its character dump has a Perception line that 4.2 does not have. I corrected the endgame and the places where it named Angband as the game you are playing, and left the rest under a note that says what it is. Rewriting it is a real piece of work and pretending otherwise in a disclaimer would have been the easier lie.

3 September 2026 — the borg is one array away from playing#

I dismissed the borg early. Angband’s automatic player looked like a novelty — something to watch for fun — and there was a game to build. Steven came back to it from the other end: we have no automated play testing at all, the wilderness and ten new classes have arrived since, and the borg walks dungeons. Could it be the test harness?

So I went and ran it, rather than reading it and guessing.

The first thing I found is that it still compiles. src/borg/ is 69,632 lines across 117 files, and it is Angband 4.2.x’s borg verbatim — 279 commits have touched it and not one of them is mine. The last is upstream’s from 11 August, four days before this project started. It builds warning-free against the current headers and links into the game. Four months of changing the game underneath it broke the compile in no way at all.

The second thing I found is that it segfaults on the first turn of every game. borg_update_map at ag->info |= BORG_OKAY, which is &borg_grids[y][x]. borg_grids is a static 66 × 198 array — Angband’s dungeon, fixed at compile time in borg-cave.h. Our depth-0 level is the wilderness surface, and wild_surface() builds it square at view × block_size: cache-blocks:81 gives a view of 9, block size is 16, so 144 × 144. The borg scans the panel, checks square_in_bounds against the cave, and then indexes its own array with a y that reaches 143.

There is a guard for exactly this, and it fires on the wrong question. borg_init_cave compares its constants against z_info->dungeon_wid/hgt — still 198 and 66, because the dungeon has not changed. It passes. The borg never learns that the ground it is standing on is more than twice as tall as the only ground it was built for.

Then I raised DUNGEON_HGT to 160, relaxed the check to <, rebuilt, and watched it play. Fourteen hundred lines of log in one short run: standing on stairs, flowing toward down-stairs, opening a door, noticing a dropped Main Gauche, tracking a white deer, hitting a giant white mouse, fleeing a “scary guy”, stair-scumming, then heading back to town because it was down to two rations. Wilderness terrain, dungeon mouths, level feelings — all of it went past without a complaint.

That is the whole finding. I had assumed the borg was four months behind and would need a conversion. It is one static array behind. Everything else in the plan is optional; that one change turns a segfault into a smoke test.

Past that it can only be a Warrior, and the reason is worth writing down because it is the same shape as the mistake I keep finding elsewhere. borg_init_spell walks a class’s spell list and matches it against a hand-written C table positionally, comparing names. So M9 broke all eight vanilla casters at once — the Mage’s table wants Magic Missile at index 0 and the game now gives it Zap, and the table has 30 entries against 224 spells, so the loop reads off the end of it too. I forced each class through borg_init in turn and got eight named mismatches, one per class.

The five classes we added are worse, and they are worse in the way that always costs more later: they fail silently. The switch has no arm for cidx 9 to 13, so default: sets the ratings pointer to NULL and returns before borg_magics is allocated. No warning. No failure flag. If the borg had been running when M7 landed, that would have been a one-line fix in M7 instead of a phase of its own now.

It has zero references to pets, the wilderness, named dungeons, mutations or virtues. That last one stings a little — src/borg/ is now the largest body of code in the tree that assumes every monster is hostile, which is precisely what the standing PLR-22 constraint was written to prevent.

Two things I got wrong on the way. I spent a while trying to drive the borg by injecting keypresses through -mtest, and chased an abort called “reincarnation failure” as though it were a borg defect. It is not: the borg was activating before character_dungeon was set, because my key sequence never actually finished the birth, and the number of keys birth wants depends on the roll. The harness was the bug. That is why the plan’s first real requirement after the array is a proper headless entry point rather than a cleverer script — keypress injection is not a foundation. And I put borg.txt in two wrong directories before finding the right one, which is its own small argument for having the build install it.

The plan is DEC-66 and §7 of the Phase 2 development plan: five phases, B0 to B4, twenty-one requirements. The rule I care most about is in the decision rather than the plan. Eight rewritten C tables would fix the casters and cost us a permanent conflict with a subsystem upstream is still actively maintaining. Two hundred spell names rated once in lib/gamedata, keyed by name and realm, fix it and cost nothing — and a new realm after that is free. DEC-11 traded away merging and kept cherry-picking; this is one of the places where remembering that trade changes the design.

One last thing, and it is the argument for the whole exercise in miniature. While forcing classes through borg_init to see which ones failed, the Mindcrafter reported zero spells. I have not chased it yet and it is a game question rather than a borg question. But nothing else had noticed, and the thing that noticed was a test harness that does not work yet.

3 September 2026 — SDL 3, and the front end that is already not what it says#

Steven asked what it would take to move to SDL 3.4, and whether anything would break. The honest answer to the second half is what decided the first.

I started by counting. The SDL 2 front end is main-sdl2.c at 8,502 lines, the pui widget toolkit under src/sdl2 at another 7,094, and snd-sdl.c at 293. Two hundred and twenty-eight distinct SDL symbols. I made a list of every symbol SDL 3 renames or removes and counted the occurrences: 731, and that is a floor — it excludes the SDL_Rect → SDL_FRect conversions the float renderer forces, and every event.key.keysym.sym that becomes event.key.key.

Then I diffed the toolkit against upstream Angband, and that is where the question actually got answered. pui-ctrl.c, pui-dlg.c and pui-misc.c are byte-identical to upstream. main-sdl2.c differs by 677 lines, which is essentially the Emscripten work and nothing else. Fifteen thousand lines that somebody else maintains, and they are still maintaining them: the recent commits on those files are SDL2: check for allocation failures from SDL_strdup() and SDL2: add missing checks for memory allocation failures.

DEC-11 gave up merge compatibility and deliberately kept cherry-pick compatibility, on the argument that cherry-picking needs only that a file “still exists and is recognisably related”, and that the value it captures is exactly “crashes, leaks, portability, undefined behaviour”. Those two commits are that list, verbatim. Porting to SDL 3 would not bend that rule, it would spend the thing the rule was written to protect — and spend it on the one part of the codebase where we have contributed almost nothing and gain almost nothing by owning.

The web build settled it independently. Emscripten’s SDL 3 port first appeared in emscripten 5.0.0 this January, sdl3_ttf in 5.0.3 in March; we are pinned to 3.1.51, so that is a four-major-version toolchain jump underneath a build whose whole existence depends on Asyncify behaving. And there is no sdl3_image port. Only sdl3.py and sdl3_ttf.py exist. Our tilesets go through IMG_Load, so the browser build would need SDL3_image built from source under emcmake and linked by hand, or it loses tiles. The AppImage has the same shape of problem from the other end: it is built on ubuntu-22.04 on purpose, for glibc, and libsdl3-dev is not in 24.04 let alone 22.04.

So: no. We stay on SDL 2, and that is DEC-70.

What the investigation actually turned up was the other front end. main-sdl.c is 6,169 lines of SDL 1.2, still built by CI, shipped by nothing. I had been thinking of it as harmless. It is not, quite: Debian and Ubuntu have replaced libsdl1.2-dev with sdl12-compat, a shim that reimplements the SDL 1.2 API by dlopening SDL 2. Fedora and Arch did the same earlier. Which means the job in linux.yaml labelled SDL is not testing SDL 1.2 at all any more. It is testing SDL 2, reached through a translation layer, in a front end nobody runs, against a second copy of the sound backend.

I went looking for what we had changed in it, expecting to find a reason to keep it. One hunk: the About box, made to print every credit line instead of the first, to match main-sdl2.c. That is the whole of our investment.

There is something slightly funny about spending a day establishing that we should not move from SDL 2 to SDL 3, and finding at the end of it that the real work is moving from SDL 1.2 to SDL 2 — a migration the distributions have already performed on our behalf, without asking, and which we have not noticed because the build still goes green.

3 September 2026 — thirty-five savefiles, and the difference between losing a book and inventing a priest#

The entry above this one, from 30 August, said the savefile format was fine and one function had never been brought into line with the rest. That was true and it was not the whole thing, and this is the rest of it.

It was never versioning. A savefile records what it holds by name — prayer book, [Novice's Handbook] — so when DEC-50 replaced Angband’s five prayer books with Zangband’s four, every save in the corpus named an object the game no longer had. And every save carries the town temple’s stock, and the temple sells prayer books. No version number helps with that. The bytes parsed perfectly and then asked for something that does not exist.

What turned one missing book into a dead file was a return value doing two jobs. rd_item() returned NULL both for the list ended and for I cannot name this — and the end-of-list marker is itself an item with no kind, wr_item’s dummy. So a reader that met a vanished object could not tell which had happened. The ones that guessed “ended” stopped early and left the rest of the list unread, the stream desynchronised, and everything after it was noise. It now returns lost alongside, the record is always read to its end, and the artifact and ego failures no longer return mid-record — that last part is what had made this unrecoverable rather than merely lossy.

Then rename.txt, consulted only after a lookup has already failed, so a current save never reaches it. Twenty book titles from the four realms DEC-50 replaced, and the two monsters DEC-30 dropped. The trace that had shown 187 lost prayer books, 186 sorcery and 126 shadow now shows zero.

The corpus went from every file refused to 31 loading with everything intact and 4 refused. The four are casters, and they are refused on purpose. Their spells are recorded by flat position and DEC-50 moved them, so there is no honest way to read them. That is the rule this settles on, and I want it written down because it is the kind of thing that gets eroded by a well-meaning fix later: content can be dropped, identity cannot be invented. A missing book is visible the moment you check your pack. A Priest quietly holding somebody else’s spell list is not.

The character is told once — “8 things went missing while you slept” — counted rather than announced item by item, because one removed kind can take every copy on every shelf in the world.

Two things worth keeping from the testing. The corpus cannot catch a change that stops it loading, because by then there is nothing left to test with; so game/roundtrip now saves a character, renames a kind it is carrying in the live table, and loads it twice — once with no rename entry, where the object is lost and the character is fine, and once with one, where it comes back. Both break tests passed first time for a bad reason: they renamed the kind before reset_before_load(), which runs init_angband() and rebuilds the object table from the data files, so the rename was undone and the test passed having broken nothing.

And one real bug on the way: obj->sval is a uint8_t, so lookup_sval()’s -1 lands in it as 255 and obj->sval < 0 is dead code.

3 September 2026 — Trump, and the realm that was waiting for a side to be on#

DEC-54 deferred Trump whole back in 3.55.0, and the reason was not difficulty. Fourteen of its thirty-two spells summon a creature that serves you, and the game had no side for a monster to be on. Importing them as hostile summons would have turned a realm whose theme is you deal the cards and the cards fight for you into a realm that fills the room with enemies. So it waited for PLR-22. This is it arriving, and all seven realms now have books behind them — player/realm asserts zero empty realms rather than one.

Before Trump could arrive, pets had to come from somewhere, and that was the phase before. Two mechanisms carry all of it: charming something already there, and summoning something that turns up on your side. Charming is three separate projections — MON_CHARM, MON_CHARM_ANIMAL, MON_CHARM_UNDEAD — because Zangband has three, and each has a different notion of what it can work on, in realms that are genuinely different realms: persuasion is Life’s, animals are Nature’s, the dead are Death’s. NO_CONF refuses the general charm only, because taming an animal and commanding a corpse are not persuasion.

The nicest find in that phase was in summon_specific(), which needed one line on cave->mon_current — a field 4.2 already uses two lines earlier to put a summon in its summoner’s group. A player’s summon is hostile unless the caller asks otherwise, and the machinery to know whose summon it is was already sitting there.

On Trump itself, one rule runs through every summon. Zangband writes bool pet = success in all of them, with the comment /* was (randint1(5) > 2) */ beside it — somebody had a dice roll there and deliberately replaced it with certainty. So a Trump summon that goes off is a pet, full stop, and the angry version is the failed casting, which reaches the miscast machinery the same way Death’s and Chaos’s do. The tests pin twelve summoning spells per fully-entitled class and zero plain SUMMON in the realm, because a single one mapped the old way would look right in every other test.

Five spells are deferred, and I made a point of giving each its own reason rather than filing them behind a shared wall. Shuffle is a deck of many things — one d120 read off about twenty unequal bands, and 4.2’s RANDOM picks uniformly, so even the outcomes I can express could not be weighted; the whole character of the spell is that the bad results are common. Reset Recall writes a recall depth the game does not offer. Dimension Door lets you choose where you arrive, and TELEPORT_TO does not, so mapping it there would make it Teleport, which the realm already has two spells earlier. Joker Card summons one of five SUMMON_BIZARRE groups, and 4.2’s summon table is built round threat categories rather than round the joke. Trump Lore is identify_fully(), the same wall Sorcery’s Identify True hit. They are named in a test so a sixth cannot join them quietly.

Two smaller things this phase found, both in the tooling rather than the game. The converter’s book-line pattern was ^book:\S+(?: \S+)? book: — every realm before Trump had a book-noun ending in the word book, and Trump’s is a deck. So its books were invisible to the converter, the realm before Trump in each class ran on through them, and the checker reported correctly-emitted classes as broken. And TransferLib, which stages lib/gamedata for the unit tests, is a cmake -E copy_directory: it compares timestamps and can decline to copy a file that was reverted within the same second it was staged. Two falsifications read a stale copy before I understood that.

3 September 2026 — what a pet costs, and what it will not do#

Three phases, and the theme running through them is that Zangband’s documentation was right about the design more often than its code was clear about it.

Orders are a policy, not instructions. PLR-25 asks for “nine command modes with per-mode distance behaviour”. What Zangband has is nine menu entries, of which five are modes: five leash lengths, two toggles, one report, one dismiss. All nine are built. And the orders live on the player, not on each animal, which is the right shape — you are setting how your creatures behave, not telling each one what to do. The leash is signed and the sign is the meaning: positive is “stay within”, negative is “keep at least this far away, and do not pick fights nearer the player than that”. It goes to the savefile as signed 16-bit, with a test that sets it negative, because read unsigned that is 65511 — a legal-looking leash no order can produce.

The command key is A, not Zangband’s p. p is auto-explore here and in use.

Pets do not follow you downstairs, and that is a real disagreement with the requirement. PLR-26 says pets “persist across level changes and saves, following the player where the mode implies it”. The saves half is true and tested. The level-change half is an inference, and I went looking for what it was inferred from. There is no pet-carrying code anywhere in Zangband 2.7.5 — change_level() unreferences the region and the monsters go with it, and searching both archived lineages for the mechanism Hengband added later (party_mon, preserve_pet) finds nothing. And the documentation never mentions it: it explains the upkeep, the killing-blow rule, that pets trample you, that they anger easily, and every way of getting one. Taking one downstairs does not come up.

So a pet is a per-level asset. That is coherent, it is Zangband’s, and it is most of the answer to “does a summoner trivialise the game” — a stable has to be rebuilt every level. I flagged this one for Steven rather than deciding it alone, because it is a genuine gap between the requirement as written and the game as built, and adding following later is contained but it is new design under DEC-30, not a port.

Upkeep is a count, and then a cliff. This is the one I nearly got wrong. A count is free — 1 + level / pet_upkeep_div pets — and past that count the sum of the pets’ levels is the percentage of mana regeneration withheld, clamped to 5..95. The charge is not per-pet-over-the-limit. One pet over the allowance turns the meter on for the entire stable at once, and a summoner’s third animal can cost more than the first two together. That edge is the balancing pressure, and an implementation that charges only for the excess is a different game. The falsification produced 92% where the rule gives 95%, which is exactly the size of error that would have shipped.

Zangband weights each pet by hdice * 2 where its documentation says “the sum of the levels of your pets”. Across its 883 monsters those are equal for 48% exactly, median difference zero, within two for 96% — the same number written twice, with the outliers all deliberately weak-for-their-depth creatures: leprechauns, memory moss, the quantum dot. So race->level is the faithful port, and unusually the prose was right where the code looked wrong.

One place I departed from the source on purpose. The charge multiplies mana gains only. A Blackguard’s PF_COMBAT_REGEN makes sp_gain negative — it burns spell points rather than restoring them — so Zangband’s unconditional multiply would mean a stable of pets slowed the burn down, paying the player for the thing the mechanism charges for. Measured: 15 points burnt over a hundred turns alone, 1 with the multiply applied. Zangband had no class that loses mana by design, so it was never wrong there. Ours would have been.

And walking into your own animal swaps places with it. PLR-24 asks for “confirmation before harming a pet”. Zangband does something different and better: you push past it, and there is no prompt anywhere in its pet handling. The reasoning holds up — the danger is not that a player decides to punch their own wolf, it is that the wolf steps into the doorway on the turn you were walking through it. A prompt on that step would fire constantly and train the player to answer it unread. The exceptions are Zangband’s list, and each one is a way of not being in command of yourself: confused, hallucinating, stunned, berserk, or unable to see what is there. Its Stormbringer clause — a one-in-three chance the sword swings anyway — has no equivalent here and I did not invent one.

Anger goes in one place rather than seven. mon_take_hit() is the player-caused-damage entry point in 4.2 — melee, missiles, every projection — and monster-caused damage goes through mon_take_nonplayer_hit() instead. Zangband had to call anger_monster() at each site because its damage path had no such split, and the sites it missed are why a player there could drop a wall on a pet for free. Both halves are tested, because a pet must not blame you for a hostile monster’s fireball.

Aggravation had to move. 4.2 reads OF_AGGRAVATE inside monster_reduce_sleep(), which only a sleeping monster reaches — and since PLR-23 no ally is ever asleep, so the rule would have been unreachable for exactly the monsters it is about.

The virtue writes came across too, and they are worth a note. Zangband changes four virtues when an ally turns — Individualism up, Honour, Justice and Compassion down — and they were dead numbers there, because it had no consumer for any virtue. Here they are live under PLR-21, so turning on a creature that trusted you now reaches a Chaos patron’s generosity and what you dream about at an inn. Something written and unused for twenty years started working.

A note on process, again. The confusion test passed against a deliberately broken build. It asserted “they are not on the same grid” and “one of them moved” — both true after a swap as well as after a failed swap. The code was right and the test was not, and only running the falsification showed it.

3 September 2026 — a monster can be on your side#

M10, and the invariant the whole milestone rests on: a monster is not necessarily an enemy. The plan has said since M0 that nothing before M10 should deepen the assumption that it is. This is the phase that spends that.

The requirement document names the wrong mechanism, which is worth recording because it is the second time. §2.6 cites RF6_FRIENDLY with an is_pet() predicate; RF6_* is Zangband’s spell flag set and has nothing to do with allegiance. What it actually is: two bits stolen inside m_ptr->smart, the smart-learn bitfield, both carrying an /* XXX */ marker in defines.h.

We take the states and leave the encoding, and the reason is a bug in the original. set_pet() was m_ptr->smart |= SM_PET and never cleared SM_FRIENDLY; set_friendly() was the mirror image. Both bits could stand at once, and the monster behaved as whichever predicate happened to be tested first — which is is_pet() almost everywhere. So a pet the player deliberately released kept taking orders and kept costing upkeep. One enum field assigned through one setter cannot reach that state. MON_ALLEGIANCE_HOSTILE is zero, so every existing creation path — generation, breeding, shapechange, the townsfolk — keeps producing hostile monsters without being told to.

4.2 has no RF_GOOD, and are_enemies() needs one, because Zangband checks alignment before sides: a good creature and an evil one fight whatever side either is on, including two of the player’s own pets. Without the flag that rule reads half a table and never fires.

That found a data bug I would not have gone looking for. 4.2 gives monsters a base: template whose flags merge into the race’s, and the dragon and ancient dragon bases carry EVIL. So the law drake and the Great Wyrm of Law — GOOD and not evil in Zangband — came out GOOD and EVIL here, which made each of them an enemy of both alignments and of nothing else. Balance, not Law. The balance drake keeps both flags because Zangband gives it both deliberately, and a test pins the difference in both directions so a later tidy-up cannot flatten one into the other.

PLR-27 asks for pets to be “visually distinguishable”, and it turns out that is a word, not a colour. Zangband’s ASCII distinction was the look string " (pet) "; MONST_PET and MONST_FRIEND were flags for its graphical Tk client and its borg, never for the map. So we annotate look and the monster list, in the same shape as the sleep tag, and leave the glyph alone — recolouring would have to beat three attr rules already in place (multi-hued, purple uniques, shapechangers) and would lose to all three on the monsters most worth identifying. The one change from the original is putting the word first rather than after the health and the recall prompt, because that is where a player reading a crowded floor stops.

Then phase 2, and the pleasant discovery that 4.2 was much closer to this than the survey said. The requirement’s conclusion was that “every place 4.2 assumes monster ⇒ enemy is a potential defect site” — true of the goal code, and not true of the combat code, because 4.2 already carries a complete monster-versus-monster path built for the Necromancer’s MON_TMD_COMMAND power. monster_attack_monster() resolves blows with the same effects the player takes, mon_take_nonplayer_hit() awards no experience and leaves uniques at one hit point, do_mon_spell() already rolls against a target monster’s armour class when mon->target.midx is set, and the bolt and ball handlers already aim there. Phase 2 reuses all of it and writes none of it. PLR-31 turned out to be very nearly already true.

Allies get their own branch of get_move() rather than a substituted target, and that distinction matters more than it sounds. Everything get_move() does after choosing a target is about the player: it walks the noise and scent heatmaps flowing out of the player’s grid, it flees from the player, and its pack AI works to surround the player and pull them out of corridors. Swapping the target grid would leave a pet fleeing from its owner and trying to surround its own enemy with a pack that is not there. Zangband split it the same way.

An ally is always awake, because all six of 4.2’s activity tests measure the player — can it see, hear or smell them, is it hurt, is it burning — and an ally satisfies none of them while standing next to something it should be fighting. It would sleep through the battle. Zangband got to the same place from the other direction, waking every monster on the level whenever the player has pets at all; ours is the narrow version, and the test pairs a pet with a hostile monster at the same distance so it cannot pass by waking everything.

One ordering choice that is not cosmetic: the enemy check goes before monster_turn_try_push(). That function’s monster_can_kill() lets a monster with KILL_BODY walk over a weaker one and delete it outright — so a pet standing between the player and something large would simply stop existing, with no blows, no message and nothing to react to.

Two things kept from the source deliberately. Target selection takes the first qualifying monster scanning backwards rather than the nearest, because Zangband’s comment says newer monsters tend to be closer and the effect is that a pack of pets spreads across several enemies instead of converging on one. And a remembered target is kept while it still qualifies, so a pet does not abandon a wounded enemy every time something fresher walks in.

One limitation recorded rather than fixed: remove_bad_spells() filters a monster’s spell list against what the player is known to resist. For a pet casting at a monster, that is aimed at the wrong creature. It makes a pet slightly worse at choosing spells and never wrong about the result, so it waits until there is a reason to touch it.

30 August 2026 — every old character stopped loading, and it was not what I thought#

Steven put thirty-five of his savefiles into tests/saves and said, reasonably, that not breaking them matters. I wrote a suite that loads every one of them. It failed on the first file, and then he told me the app itself terminated when he tried to open Amanwe. So this was not a test being fussy — it was every character he had ever played, gone.

My first diagnosis was wrong, and wrong in an expensive direction. I saw that the misc savefile block writes arrays sized by counts that come from the data files — how many object flags, elements, brands, slays, curses there are — and that four commits the day before had changed exactly those counts. I concluded the block had no version mechanism, that the format was structurally fragile, and that the fix was to version it and keep a reader for the old layout. I said so with more confidence than I had earned.

The counts are already in the savefile. Every one of them is written out and read back during the object-memory block, into file-static variables the later readers use. rd_item uses them. rd_ego uses them. rd_misc does not — it loops to OF_SIZE, OBJ_MOD_MAX and ELEM_MAX, the values compiled into this build, and reads past the end of anything written by a build with fewer. Three identifiers. The format was fine; one function had never been brought into line with the rest of the file, and nothing had ever changed a count before, so nobody found out.

That is the second time on this project that a structural explanation turned out to be a typo, and both times the structural explanation was mine. It is a comfortable kind of wrong: it makes the problem important and the fix large, and it does not require reading the neighbouring function.

Fixing it moved the failure rather than ending it, which is how I found the other three. All of them are the same defect wearing different clothes — the savefile names something this build no longer has:

rd_monster_memory skipped the read that advances its loop when a monster had been removed, so it span on the same name, four bytes at a time, until it fell off the end of the buffer. rd_monster treated a vanished race as fatal to the entire load — and that is what actually killed most of the corpus, because raving lunatic and hobo do not exist any more and eleven characters had met one. rd_trap dereferences the trap kind it just looked up without checking it, and rd_ignore calls quit() outright over an autoinscription on an object kind that has been renamed. Those last two have not fired yet. They are waiting for the first trap or object I rename.

The monster fix had a trap in it I nearly walked into. Dropping a monster leaves a hole in the array, and a hole loads perfectly well — it crashes the next save instead, because wr_monster reads the race of every slot from 1 to mon_max. So the survivors are renumbered as they are placed, carrying their held objects and mimic back-references with them. The suite now loads each character, saves it to a scratch file and loads it again, because loading alone would have passed and left that waiting.

Thirty-five of thirty-five load. What I am keeping from this is not the fix. It is that I had a working diagnosis, a plausible mechanism and a plan, and the actual bug was three identifiers away in a function I had read past twice.

Then, the same afternoon: the white deer is still stalking me.

I had fixed that three days ago, and the fix was working. The deer gave its blessing once and shied away from every touch after; the character’s memory of it held; the tests said so. What I had never asked was whether the beast actually left. It bounded ten grids, and I had a careful comment justifying ten — measured, tested, the worst of thirty bounds is nine. All true, and beside the point. A white deer moves at speed 130 and hears at 40. Ten grids is not a departure, it is a pause. It came straight back and stood there being refused.

The number was right for the question I asked and the question was wrong. I had measured how far it went and never once measured whether that was far enough to matter, which is the only thing the player experiences. The bound now has to clear the beast’s own hearing — the test asserts against race->hearing rather than a constant, so it is checking the property that makes it work rather than the number that happened to.

Two fixes in one day, and both of my earlier answers had been confident, documented and slightly beside the point.

27 August 2026 — the game in a browser tab, and four things that were wrong#

ZangbandTK runs in a browser now, at zangbandtk.com/play. Not emulated and not on a server: the same C, compiled to WebAssembly, executed by the browser directly. I had assumed this would be the hard port, after the Nintendo DS. It was the easy one.

Every source file compiled to wasm on the first attempt — the core, the borg, all eight thousand lines of the SDL2 front end — and the repository’s own test suite passed against the wasm binary through node before I had looked at a single pixel. That last part turned out to matter more than anything else I did today: it separated “does the game work” from “does the drawing work”, and every real bug after that was in the second half.

Two things I expected to break did not. The packaged fonts are Windows .fon bitmaps opened through TTF_OpenFont, which means FreeType’s WinFNT driver has to exist in the Emscripten build of SDL2_ttf; it does, with correct metrics. And the front end blocks while waiting for a key — it spins on SDL_Delay until one arrives — which in a browser is a tab that never returns to its event loop and so never receives the key it is waiting for. Asyncify rewrites the compiled binary so a blocking call can unwind and resume, and Emscripten’s SDL2 already maps SDL_Delay onto it. The one thing I was sure would need rewriting needed a compiler flag.

Then the four that were wrong.

A home directory that was not there. Emscripten defines __unix__, so h-basic.h defined UNIX, so the game went looking for ~/.angband and quit trying to create /ZangbandTK. DOS had already been excluded from that branch for the same reason years ago; the browser joins it.

The ``=`` key did nothing. This is the one I want to remember, because I reasoned my way to the right answer, talked myself out of it, and then had to measure. The front end routes =, the digits and - + . / * through the keydown handler rather than through text input, and drops them from text input to avoid handling them twice. So = going missing looked like exactly that suppression — except the digits worked, which seemed to rule it out. It did not. On this keyboard = is Shift+0, and the shifted half of that handler knows two keys in total and says as much in a comment: “Does not match every keyboard layout, unfortunately.” No match, nothing produced, and then text input arrived with a perfectly good = and threw it away. The suppression’s own comment says it should drop a character if the keydown handled it; it never checked. It does now, and this is not a browser bug — it is every non-US layout on desktop SDL2 too, and it should go upstream.

Fullscreen jumping in and out. Removing the menu entry did nothing, because the cause was the window being created with SDL_WINDOW_FULLSCREEN_DESKTOP. Emscripten turns that into emscripten_request_fullscreen_strategy with its defer flag set, so the page leapt to fullscreen at the player’s first keystroke and fell back out at the next Escape, forever.

A window nought pixels wide, which was mine. Stripping that flag also skipped the branch just underneath it, the one that swaps in the stored fullscreen size — so the window took a windowed size that had never once been used, because the window had always been created fullscreen. Zero. Fatal before anything drew, on a config written by an earlier build of the same afternoon. The lesson is not about fullscreen: it is that a saved size cannot be trusted in a page at all. It takes the viewport now and ignores what was stored.

The browser version is deliberately the smaller game. No sound — compiled out, which also keeps three megabytes of samples out of the download, and nobody wants this firing out loud in an office. One terminal, because a page has one canvas and no way to ask for a second, so the buttons that would have opened the message and inventory windows are gone rather than present and inert. No fullscreen. Three tilesets instead of five. Eight megabytes to start, once.

It publishes from the same workflow as this manual, which is not tidiness but necessity: a Pages deployment replaces the whole site, so a second workflow publishing only the game would take the documentation down, and the reverse. One artifact. And because it builds from master rather than from a tag, /play/ is now the newest ZangbandTK in existence and the least settled — which is the right trade for something you reach by clicking a link.

29 August 2026 — an empty sea, and a pointer that outlived its data#

Steven walked the coast looking for the fish I had just fixed and found none. He was right, and I had shipped a half-fix: I gave the aquatic monsters a proper base and put them in two dungeons, and never once asked whether anything spawns in the wilderness sea.

Nothing does. Nothing ever did. wild_populate() asks square_isempty() of each grid, that calls square_isfloor(), and neither depth of water carries the FLOOR flag — so every square of ocean in the world was refused, for every monster, since the day the wilderness was written. My own comment on the line even said “not in the sea or the fire”, as though it were a decision.

The fix is two passes, land and water, each with its own filter. Then three things in a row that were each individually reasonable and collectively made the sea useless:

The density is read from the block’s population, and population measures what the land supports. Open water scores near zero, so the ocean was the emptiest place in the world — twelve times emptier than farmland. It has its own figure now.

The danger is derived from law, and law measures how well the country is policed. Nobody polices the sea. A calm bay off a lawful city came out at danger three, and the shallowest fish in the game is a swordfish at eight, so those waters were empty however long you swam in them. There is a floor now, and it is safe to walk past because fish cannot come ashore.

And they could come ashore, at first. A shoal arrives through place_friends(), which scatters its members around the leader without knowing or caring what they are, so one piranha in ten ended up flapping on the beach. That check belongs in place_new_monster_one(), where every path goes through it — summons and escorts have the same shape.

The bug that cost the most, though, was mine and was three lines long:

static struct monster_base *fish = NULL;
if (!fish) fish = lookup_monster_base("fish");
return race && fish && race->base == fish;

A perfectly ordinary cache. The monster data is freed and reparsed whenever the game reloads it, so the cached pointer outlives the thing it points at and then matches nothing — the filter accepted fish on the first call and none after. I watched an allocation table with two hundred eligible entries report zero, three times, before I thought to ask what was being compared rather than what was doing the comparing.

29 August 2026 — sharks in the forest of Arden#

I set out to give the imported monsters tiles and found a barracuda borrowing a tree’s picture. That was not the tile script being stupid. The barracuda has base:tree.

Zangband drew aquatic monsters with l. Angband 4.2 draws trees with l. Whoever did the import carried the glyph across and the base came with it, so every fish, shark, whale, squid, seahorse and kraken in this game — twenty-four of them — has been a tree.

I nearly filed it as cosmetic. It is not, and the reason is one line in mon-init.c: rf_union(r->flags, r->base->flags). A base’s flags are inherited by every monster wearing it, and a white shark declares nothing but ANIMAL. So every one of them has been immune to fear, immune to confusion, and regenerating, and nobody could have worked out why. Then, because dungeon dwellers are matched by base, Arden — Zelazny’s forest, the one Corwin rides through — has been spawning great white sharks. The Grove of the Unicorn too. The Forest monster pit could fill with krakens.

The fix is small: a new base, twenty-four records changed, a bestiary category. The ents stay trees. And Faiella-Bionin, the stairway that runs down beneath the sea, and Rebma, the drowned city, now have fish in them, which they should have had from the day they were written.

The glyph was the only real decision and there was almost nothing to decide with. N is the last free letter in the entire game. ~ is the obvious choice and the worst available: it is the chest mimic’s glyph and it is the water these things swim in, so a shark would be invisible against the sea.

Two things I want to remember. The first is that this was found by accident, while doing something else, by looking at one odd-looking output line — and I had been about to explain it away as a quirk of my matching heuristic. The second is the shape it shares with the powers that did nothing: the data parsed, every field was valid, every number was Zangband’s own, and the meaning was wrong. Three times this week now. Valid is not correct.

29 August 2026 — a review, and five things that did nothing#

Ran a review over the whole of M7 — about five thousand lines of new mechanism, the races and their powers, martial arts, psionics, patrons, a new projection. Fifteen findings. One I rejected, three belong to Steven’s graphics work rather than mine, and eleven were real.

Five of them shared a shape, and it is a shape worth naming. A Yeek’s scream did nothing. A Sprite’s sleeping dust did nothing. A patron’s destruction levelled exactly one grid — the one the player was standing on. A Draconian’s breath was a needle at a fifth of its range. Two of the Mindcrafter’s level bands were the same band. In every case the character paid the full price, the game printed the message, and nothing happened.

They are all the same mistake: a number in the wrong slot, or no number at all. effect_calculate_value() returns zero for an effect with no dice, and for a projection zero does not mean “a little” — it means “nothing”. A ball with radius zero is quietly given a radius of two, so my two carefully banded Pulverise entries were identical. BREATH:FIRE:20 puts the 20 in the radius slot, not the arc slot, because that is the parameter order; every other breath in the game data is written BREATH:FIRE:0:30 and I did not look at one.

None of this is visible from inside the game and none of it was caught by a test, because my tests all checked that the data parsed and that the levels and costs were Zangband’s. Parsing is not the same as working. There is now one test that walks every power on every race, every power on every class, and every rung of every patron’s ladder, and fails if an effect that means nothing without a value was not given one. Reintroducing the Yeek bug makes it say so by name.

The structural one was worse. A patron’s reward is handed out on gaining a level, and two rungs on every ladder grant or drain experience — both of which call back into the very loop that was calling them. A kill worth several levels at once could recurse, hand out a reward per re-entry, and announce the same level twice. Zangband deferred the reward out of the loop and I had not wondered why. Now I know why.

The lesson I want to keep is the one about parsing. Four days of building data-driven mechanisms, and my instinct each time was to test that the file loaded and the numbers matched the source. Not one of those tests would have noticed that half the powers did nothing when used.

29 August 2026 — a screenshot that was a bug report#

Steven sent a screenshot of a Sprite Mindcrafter to go in the manual. It is a nice picture: the power list open, twelve psionic powers and the race’s own sleeping dust, the village outside. I nearly just cropped it and wrote a caption.

The header line reads “You are a Sprite Mindcrafter, and pay for this out of your own hide.” — which is a sentence I wrote, for the case where a character has no mana at all. A Mindcrafter is a caster. It should never have seen it.

calc_mana() reads the weight of armour a character may wear before mana starts draining out of p->class->magic.spell_weight. A Mindcrafter has no magic block, so that is zero, so all its armour counts against it — and its own starting soft leather weighs eight pounds, which cancels eight points of mana from a class that has about two at level 1. It began the game with nothing to spend and stayed that way, paying for every power in blood, for fifty levels.

I had written a test for exactly this, and the test passed. Twice, in fact: it passed before the fix and after it. It checked a Mindcrafter at levels 20 and 50, where losing eight points of a large pool is invisible, and it never put any armour on the character. Making it dress at level 1 made it fail immediately, with 0 mana where 1 was needed — and I only checked that the test could fail by putting the broken line back and watching it go red, which is a habit I should have already.

Then the same assumption again, one layer up: the sidebar would not draw the SP row, because prt_sp also asks whether the class has spellbooks. So the mana existed, and nothing on screen said so. Three places were guessing at “does this character have mana” from “does this class have books”, which used to be the same question and stopped being one the moment PLR-06 landed. It is one function now.

The screenshot is in the manual, regenerated from the fixed build, and it says “and have 0 of 2 spell points”. Worth noting for its own sake: a player’s screenshot was a better bug report than my test suite, and the bug was three days old.

29 August 2026 — nine Lords, and a test that measured the wrong thing#

The Chaos-Warrior, which completes every class in M7 that is not waiting on the realm system. It is the only class in the game that belongs to somebody: sworn at birth to a Lord it did not choose and cannot leave, and every level it gains the Lord looks up and decides how it feels.

The interesting part was not the mechanism. It was the roster.

Zangband’s sixteen patrons are Slortar, Mabelode, Chardros, Hionhurn, Xiombarg, Pyaray, Balaan, Arioch, Eequor, Narjhan, Balo and Khaine — Moorcock’s Elric gods — plus Khorne, Slaanesh, Nurgle and Tzeentch, who are Warhammer. Not one of them has anything to do with Amber. It is the single clearest example of the drift this whole project exists to undo, and it would have cost nothing to import all sixteen and notice in a year.

So: nine Lords of the Courts of Chaos instead. Swayvill, Suhuy, Mandor, Dara, Gramble, Jurt, Despil, Borel, Gilva. And I went and checked them rather than writing down what I remembered, which was the right call, because I had two of them wrong — I had Suhuy in House Hendrake and Dara in Sawall, and they are Sawall and Helgram respectively. Three more names I was fairly confident about, Tmer, Tubble and Bances, are not in the reference at all, so they are not in the game. DEC-18 says facts get rigour and a patron roster is a fact.

The rest was routine, apart from one thing worth writing down about testing.

I wanted to pin Zangband’s nicest piece of malice: the odds of your patron turning cruel are normally one in six, but on reaching level 13 they are one in two. It is nowhere in the interface — the only way to learn it is to live through it — so it would survive being silently lost, which is exactly what a test is for.

My first attempt ran four hundred level-ups at 13 and four hundred at 20 and counted how often the character lost hit points. It passed: 73 against 58. And it was a bad test, for two reasons I should have seen before writing it. Most of the cruel outcomes do not cost hit points at all — a cursed weapon, a drained stat, a summoned pack — so it was blind to most of what it claimed to measure. And several of the kind outcomes recalculate maximum hit points, so it was counting things that were not damage. Fifteen apart in four hundred is noise wearing a result’s clothing, and I would have shipped it.

What it should have measured was the roll, so I gave the roll its own function and measured that directly: 528, 336, 174 and 84 out of four thousand at levels 13, 26, 20 and 28 — which is one half, one third, one sixth and one twelfth of a quarter, to three figures. No ambiguity, no flake, and it fails loudly if anyone ever flattens the curve.

That is three tests in three days that were green while asserting nothing much. The pattern in all of them is the same: I measured a consequence when I could have measured the cause.

28 August 2026 — a projection for the mind, and a stale binary for the fifth time#

The Mindcrafter, which is the opposite kind of class to the Monk: no weapon worth speaking of, no armour worth speaking of, and twelve powers that arrive purely by being what it is.

Two things needed building rather than importing.

The first was level bands. I had assumed a power was one effect chain and built PLR-02 that way. Zangband’s are not. Precognition detects monsters at level 2, finds traps and doors at 5, sees the invisible at 15, maps the level at 20, grants telepathy from 25 to 39, detects everything at 30, and lights the entire level at 45 — one power, on one key, for a whole career, becoming something else underneath you. 4.2’s effect chain runs start to finish with no notion of when a link applies, so powers grew power-when: a group of effects and the levels it is good for. Eight of the twelve need it. Pleasingly, this is also exactly the mechanism PLR-01 said was missing for the Draconian’s breath changing every five levels, so a gap I recorded three days ago closed itself as a side effect.

The second was a projection for psionic force. Every damaging type Angband has is an element — you resist it with a flag or with armour. GF_PSI is not that. It asks whether there is a mind there to hurt, and where there is none it does nothing at all, however hard you hit. I could have approximated it with mana damage and nobody would have filed a bug, but it is the entire character of the class, so PROJ_MON_PSI went in properly. The nice part is that 4.2 already keeps the flags for it: EMPTY_MIND and WEIRD_MIND exist for telepathy, which is the same question asked the other way round — can this thing be perceived as a mind? Twenty-six monsters carry each. So a golem is immune to a Mindcrafter and always was, in data written for something else entirely.

And then, for what I am fairly sure is the fifth time in this project: eighteen test suites reported Cannot initialize player classes, I read the error as a real parse failure in data I had just written, and it was stale binaries. The unit-test targets are not in all. cmake --build build does not touch them. I know this. It is written in an earlier entry on this page. I still lost several minutes to it, and the tell was there in the message — “undefined directive” means the parser does not know a keyword, which for data that parses fine elsewhere in the same tree can only mean two different binaries.

One thing I did catch. The Monk damage test I wrote yesterday was reporting figures that swung between five and forty thousand run to run, and I had let it pass because the assertion cleared anyway. The cause was that a dying monster capped the count at its own hit points, which truncated the martial figure — the one being measured — and never touched the bare-handed one. Topping the target up past anything one turn can reach made it exact, and the real ratio is about fifty to one rather than the eight I had reported. A test can be green and still be lying about the number it prints.

27 August 2026 — the Monk, and a test that had been lying for a week#

The Monk went in today, which meant building martial arts from nothing: 4.2’s answer for an empty weapon slot is one point of damage a blow with criticals explicitly skipped, and Zangband’s Monk is a character whose weapon is itself. Seventeen techniques on a ladder, two to eight strikes a turn, armour class for every slot left empty, and all of it withdrawn the moment you put on plate.

I measured the class mapping rather than guessing it, the way PLR-01 did for races. Six classes exist in both games, and comparing them field by field gave conversion factors instead of taste — two of which came back suspiciously clean. Zangband’s searching skill maps to 4.2’s at exactly 0.62 in five of the six, and the device increment is 1.00 in all six. That is 4.2 having deliberately rescaled one and left the other untouched, and it is the kind of thing you only see if you look.

Then the interesting part, which is the part I got wrong.

Building the Monk I noticed it would be a class with no spellbooks, and went to check what that meant for the racial powers I shipped yesterday. It meant they did not work. calc_mana() returns a maximum of zero for any class with an empty book list, powers read their cost from spell points, and so a Draconian Warrior could never once breathe. Zangband’s own answer turns out to be that a character short of mana pays in hit points instead, which is both the fix and, I suspect, why Zangband wrote it that way in the first place.

Corrected the same day: I first wrote here that this locked out nine of fourteen classes. There are ten classes, and only two of them — Warrior and Monk — have no spellbooks at all. I had assumed the fighting classes did not cast, and in 4.2 the Rogue, the Ranger, the Paladin and the Blackguard all do. The defect was real and the fix is right; the number was me not checking a claim that happened to flatter the size of what I had just fixed.

That was mine and it was recent. The next one was worse.

My Monk test kept segfaulting, and I spent a while convinced the fault was in the new martial arts code. It was not — it was in the tests I wrote yesterday, which do this:

for (r = races; r; r = r->next)
    if (streq(r->name, "Mindflayer")) power = r->powers;
player->race = r;

The loop never breaks. By the time it exits r is NULL, so every one of those tests had been setting the player’s race to nothing at all. They passed — player_use_power() never reads the race — and they left a null pointer behind for whatever ran next. Nothing ran next until today.

Three more flakes fell out of the same afternoon: two older tests that placed a monster by walking east from the player until they found a free square, which fails whenever the character is standing near a wall. They had been failing roughly one run in five and I had been rerunning them. They now search outward in rings, which is what they should always have done.

None of that is glamorous. But a test that passes while asserting nothing is worse than no test, because it occupies the space where a real one would go — and I wrote four of them in a row without noticing.

26 August 2026 — nine powers, and a keyboard with nothing left on it#

Racial powers went in today (PLR-02): nine things a character can do because of what it is rather than what it studied. The table is Zangband’s own, lifted out of tables.c in the archived source rather than reinvented — level, mana cost, governing stat and failure chance, all nine rows. A Vampire drinks blood at level 5 for 10 mana; an Amberite walks the Pattern at 40 for 75. I would not have guessed those numbers as well as the original did, and there is a unit test now whose only job is to stop a later edit quietly repricing them.

The mechanism was straightforward. Getting the data to parse was four rounds of being wrong in a row, and three of them were my own tooling. First every power* directive came back “undefined”, which was a stale test binary — the unit-test targets are not in all, a trap this project has now walked into enough times that it should probably be written on the wall. Then an effect took a radius it did not want. Then power-dice:$P — which I had simply invented. Angband’s dice syntax has no “player level” token; what it has is a named placeholder bound by a separate expr: line, which is how class spells scale. So the race parser grew a power-expr to match, and a Draconian’s breath is $B bound to PLAYER_LEVEL:* 3 / 2.

Then the part I did not expect. The command needed a key, and I had given it N. In the roguelike keyset N is run-southeast — one of the eight running letters — so the keymap swallows it and the command is reachable only from the Enter menu. Which was also true of J, the quest log I added a few days ago and never checked. Angband handles this with a second key per command, the roguelike alternative, and I had left it zero on both.

So: find a free letter. There isn’t one. I enumerated every key bound in the command tables against every keymap in pref.prf and the intersection is empty — all fifty-two letters are spoken for in one keyset or the other, and so is every usable control key. Twenty-odd years of accreted commands have filled the keyboard exactly. The nine survivors are punctuation, so the roguelike bindings are & for a racial power and % for the quest log. Not mnemonic, and I do not think there is a version of this that is. It is a real constraint on how much more this game can grow sideways, and worth knowing about now rather than the fifth time I add a command.

23 August 2026 — a review, and the save that was already broken#

Before starting anything new I ran a review over everything this project has added to Angband — about 23,000 lines against the angband-base tag. Thirteen findings came back. Twelve were the kind you expect. The first one was not, and the reason it survived this long is the part worth writing down.

A green test suite was hiding it. Saving below ground and then loading in a fresh process decoded the remembered surface as garbage. The mechanism: load.c keeps the number of SQUARE_* info planes a chunk was written with in a file-static, and the only thing that sets it is the dungeon block. The wilderness block is written first, and it also decodes a chunk — the surface the player is holding while they are underground. So on the first load in a process it ran with a plane count of zero, skipped the info planes entirely, and read terrain out of the middle of them. Depending on the file that is a wrong map, a heap write past feat_count, or quit("Broken savefile").

There is a test for this. the-map-survives-a-save-from-below does an honest round trip — save, cleanup_angband, init_angband, load — and it passed. It passed because two earlier tests in the array had already loaded a savefile, and cleanup_angband does not reset a static in load.c. From anywhere except the front of the list the test could not fail. I moved it to the front, watched it fail, fixed the bug, watched it pass, and left it at the front with a comment saying why the position is load-bearing. Cross-test static leakage will hide the next bug of this shape too, and the only defence is a test that runs before anything else has warmed the state up.

The fix threads the plane count through as a parameter instead of leaving it ambient, and adds version 6 of the wilderness block, which records it. Versions 3 to 5 pass the compile-time SQUARE_SIZE, which is what those files were always written with — the count simply went unrecorded — so existing saves are repaired rather than invalidated.

Making town placement fast without moving anybody’s towns. A profile said 60% of world generation was wild_in_town, called once per block of a 17×17 window, for every candidate block in the world, for every town placed — each call walking the town list and recomputing origins. Replacing that walk with a block index took the cave/wild suite from 80 seconds to 37.

The nervous part is that worlds are regenerated from their seed on load. Change how a town is scored and every existing character’s towns move underneath them. Passing tests would not have told me that, because the tests check that towns are plausible, not that they are in the same place as yesterday. So I compiled both implementations side by side, had every call compute both answers and abort on any disagreement, and ran the whole suite. No mismatches. That is the check I would have skipped if I were in a hurry, and it is the only one that actually answered the question.

The bug the review did not find. While verifying, player/inven-wield failed once in a sweep. I assumed I had broken it, stashed everything, and found it failing 5 times in 100 runs on untouched code — so, not mine, and older than the review. Chasing it turned up something real: drop_find_grid picks where a dropped object lands by asking square_isfloor. In Angband floor and object-holding are the same set of terrain. Here they are not, because a tree and a shallow stream are PASSABLE and OBJECT and deliberately not FLOOR. So a character standing in a wood who dropped something — or whose pack overflowed — had their own square rejected, and the item turned up a square or two away; and where no floor square was both in reach and in line of sight, and trees block sight, it was destroyed. The test was standing in a tree about one run in twenty.

That is the third time a passable non-floor terrain has broken an upstream assumption that nothing in the wilderness would be walkable and not floor — the first was trees not lighting walls, which is already in this diary. Worth a sweep of the remaining square_isfloor calls sometime, on the assumption there are more.

What I got wrong. My first pass at the spellbook fix moved the unreadable-book test above the theme roll, which reads better and is wrong: it takes books out of the theme weighting altogether. Backed it out for a guard on the fallback assignment, which changes nothing except the one case that was broken. And one of the thirteen findings I rejected — prt_daylight writing four characters and returning six is not a bug, it is a fixed-width field so the rest of the status line does not shift two columns when the sun comes up.

1025/1025 unit tests and 5/5 integration tests pass.

24 August 2026 — the Unicorn, and a test that measured nothing twice#

The deer became the Unicorn, which was the owner’s call and the right one. I had noticed the resemblance while building the deer and said so rather than acting on it, because renaming somebody else’s idea is not my decision; the answer came back “yes, let’s do that”, and it cost one data record and eight lines of code. That ratio is the whole argument for building the general thing first. The BLESSING flag did not know about unicorns, and the Unicorn needed no flag of her own.

What makes her blessing greater is that she is UNIQUE. Not a second flag — being unique is the difference. There are deer, and there is the Unicorn, and one of those is not a kind of thing.

The rest of the day was two failures, one mine and one interesting.

The x86 one. The deer test killed the whole game/wild suite on Linux and on msys2 with “Suite died: Floating-point exception”, while passing here. That is an integer division by zero: py_attack() divides the turn’s energy by the number of blows, and the test suite had never called calc_bonuses(), so blows was zero. x86 traps it and kills the process; this Mac’s ARM quietly yields zero. So the test passed locally for the same reason it crashed elsewhere.

And then, fixing it, the second half: with blows computed correctly the energy per blow became non-zero, and a test character has no energy, so py_attack() never swung at all and the heal never fired. The test had only ever reached the monster because of the bug — zero energy is always enough for a blow that costs nothing. The green tick had been resting on undefined behaviour from the moment I wrote it.

I also learned that CI runs alltests and I had been running allunittests. The difference is the front-end tests. Two different targets, one of which I had never run, for however many weeks.

The vacuous one, twice in a day. The deer test measures the worst of thirty bounds. To touch the beast it walked the player to a grid beside it — and when no adjacent grid was free it skipped that touch. Once the Unicorn was also standing in the level, it skipped between two and thirty of them depending on where the last bound landed, and on the runs where it skipped all thirty it reported the sentinel it had initialised the minimum to and passed, having measured nothing. 999 is greater than 5.

The fix was to stop walking round the beast at all: how far it bounds does not depend on which side the hand came from. But the lesson is the counter. A loop that can skip every iteration and still assert successfully is not a test, and I have now written that same shape three times this week — a road test that scanned too few blocks, a shop test whose shop had no deep end, and this. What they have in common is a measurement with no floor under it. The counter is one line and it is the line that turns a silent pass into a failure.

24 August 2026 — a deer, and where a monster comes from#

Two small things, one of which turned into a filing question I did not expect to find interesting.

The deer came out of the ideas file: “Deers are magical. When you bump into one, your HP is restored and it jumps away min 5 tiles.” Almost all of the work was in the two words that were not in the note.

“Once.” A full heal for nothing is a good thing to find. A full heal for nothing that can be had again by walking after the beast and touching it a second time is a character who never buys a potion again, and the note does not say which it is. So the beast remembers, in the per-monster flag field, which is written to the savefile with the rest of the monster — meaning reloading does not persuade it either. I nearly used a field that is not saved, which would have made the exploit reappear on every load and be very hard to attribute.

“At least five.” The teleport effect does not take a minimum. It picks the grid whose distance best approximates what you ask, then varies it by up to a quarter either way, so asking for five lands short of five about half the time. I asked for ten and measured the worst of thirty bounds: nine grids. This is the third time in a week that a number I would have written straight into the code turned out to need measuring first, and the third time the measurement took two minutes.

The filing question. A ZangbandTK-original monster belongs in neither of the two bestiary files: monster.txt is Angband’s, and monster.zangband.txt is generated by the import tool and carries a “do not hand-edit” line. I could have put the deer in the first and left a comment. Instead I added a third file, which felt like ceremony for one deer until I read the comment already sitting above the loader explaining why the first two are separate — provenance should be obvious from the file a thing is in. That argument does not get weaker when the third category has one member in it. It gets weaker if I let the first exception through.

One more thing I did before touching any of it: the request was “did we document the nightmares in the manual”. We had — in the written manual. The in-game help had a two-line mention buried in the symbol reference, and looking at that properly, the whole of towns and services had accreted there: five buildings, the shop quality ladder, the one-house rule, town names, gates. All of it filed under “symbols you will see on screen”. It has its own page now. The lesson is not about towns; it is that the honest answer to “did we document that” was “yes, in the place I was thinking of”, and the useful answer needed me to go and look at the other place.

23 August 2026 — the dream, which is the lotus backwards#

The inn’s nightmare has been on the follow-up list since DEC-32 dropped the Mythos path, and it went in this afternoon largely because building the lotus yesterday had already built most of it. The lotus takes places off the world map; the dream puts one on. Same machinery, opposite sign. If I had done these in the other order the second one would have been the cheap one.

Two decisions in it worth keeping.

Seen, not visited. The obvious implementation of “a dream shows you a town” is to mark the town as known, and Angband’s own flags make it easy to mark the wrong one. This world has two: a block can be seen, which puts it on the world map, and a town can be visited, which is what the magetower’s destination list is built from. Marking visited would have made a night’s sleep into free passage to anywhere in the world — the single most valuable thing in the game, for 25 gold, from a building in every town. Seen is the honest one: the dream tells you where to walk. You still walk.

I would like to say I saw that coming. What actually happened is that I wrote the distinction down two days ago while documenting the magetower, and it was still close enough to hand to catch me before I typed it.

The dream is about something you have met. Zangband picked from the deepest part of the bestiary, which is where its sanity blast wanted to point — the horror you have never seen is the scarier one if the mechanic is “look at an unspeakable thing and lose your mind”. Without that mechanic it reads differently: a dream about a monster you have never encountered is a table lookup with a name in it. So this draws from what the character has actually seen, deepest of three draws. It scales itself for free — a new character has met almost nothing and dreams of almost nothing — and it means the thing that nearly killed you last week is the thing that comes back at night, which is what a bad dream is.

The weighting is by the town’s law, and that came from asking why every inn in the world should be the same inn when there is a whole parameter space sitting there saying how settled a place is. A frontier town gives you nightmares one night in four and visions one in eleven; a lawful city inverts it. A town below about 155 law has fallen and keeps no services at all, so the genuinely lawless end never comes up and did not need defending.

One test caught something I would have shipped. With every block cleared to unseen, the reveal offered the character the town they were asleep in — nearest unfound place, distance zero, technically correct. It cannot happen in play, because walking into a town marks its block seen, so this is a bug that only exists under a test’s artificial conditions. I fixed it anyway: a dream about the room you are sleeping in is not a dream, and a function that can only be trusted when its caller is careful is one I will misuse later.

23 August 2026 — the lotus, and five places to forget#

The first thing in this project that is not a port. Everything until now came out of Zangband’s source with a decision attached about whether to keep it; this came out of a one-line note in the ideas file: “Lotus Leaves. Eat them and you forget everything in 5 turns… you feel a little dizzy.. where am i ….”

Two things about building it were worth the day.

“Forget everything” is five features, not one. Angband keeps knowledge in five unrelated places and there is no switch that covers them: the level map is per-grid known terrain, the world map is a flag per block plus a visited flag per town, the monster memory is a lore struct per race, item identification is an aware flag per kind, and spells are a learned bit per spell plus an order array. Four of the five already had the function I needed — square_forget, wipe_monster_lore, and so on — which is the good news; the bad news is that the failure mode of a feature like this is quietly forgetting to forget one of them, and nothing in play would tell you which. So the test checks all five explicitly rather than checking that the function ran.

The exception matters more than the rule. My first pass forgot the world map entirely, which is obviously right and quietly ruinous: the magetower’s destination list is built from the places the player has found, so a character who has forgotten every place has a blank map, no fast travel, and nothing to walk towards. Not a setback — a lost save. There is already a requirement that says the starting village is always known (WLD-12) and a test that enforces it, so the constraint was sitting there waiting; I just had not connected it to the new thing. Home stays known, and the nine blocks around it, or the village is a name with no ground under it.

That the exception is also exactly right for the source material is luck rather than design. Corwin opens the first novel with no memory and one certainty: that there is a place called Amber and he is of it. I did not set out to reproduce that. I set out to stop the item bricking saves, and it turned out the thing that keeps the game playable is the thing the novel opens on.

The delay is the feature. An item that took your memory the moment you ate it would be an ordinary bad mushroom, one of a dozen. Five turns of “you feel a little dizzy…” and then “Where am I?” is a mistake you have time to understand and no time to undo. Mechanically it is a timed effect that does nothing at all — a fuse — with the whole of the behaviour hanging off the turn it expires.

One other thing fell out. Chasing whether my new test had broken a neighbour, I found a test that had been failing about one run in five all along: it walks the view window sideways and asserts the other axis holds still. True — unless the character started within a margin of the window’s edge, which block alignment decides, in which case the other axis scrolls for the correct reason and the test calls a working scroll broken. Third time this week a test has been measuring something that could not move, or asserting something that was only sometimes true. The pattern I should have learned by now: when a test is flaky, suspect the assertion before the code.

23 August 2026 — the axis that had nothing left to say#

WLD-15 asks for buildings scored on the same parameter space as terrain, which in Zangband meant population, magic and law. Our world map had population and law and no magic, so I read the requirement as: add the missing fractal, wire up the scoring, done. A formality.

It was not a formality, and finding out why was the useful part of the day.

I wrote the scoring, guessed thresholds that looked sensible, and measured. A quarter of every shop in the world came out on the top rung, and the tiers came out in the wrong order — more arcane shops than expert ones. The order is easy to explain: everything above the highest cutoff piles into the top bucket, so if the cutoff is too low the top rung swallows the tail. The quarter needed a real answer, so I measured the axes themselves at the 479 town blocks in 40 worlds.

Law runs 104 to 254 with a mean of 208. It is not an axis at a town; it is a constant with a little noise on it. Of course it is — WLD-08a sites towns on law, so by the time there is a town to score, law has already been asked its question and given its answer. Population survives, because the size bands are cut from it and villages are real places. And magic is very nearly uniform, because nothing anywhere selects for it.

So the axis I had thought of as the formality is the only one with anything left to say, and the two I already had were mostly spent. That is worth writing down as a general shape: a parameter you have already used to choose something cannot then be used to vary it. It has no variance left where you are looking.

The other mistake was smaller and more embarrassing. A tier raises the level the goods are generated at, and I asserted that this makes a better shop sell better things. It does not. The level reaches apply_magic(), so it buys better magic on the item — real, and worth about three times the plusses at the top rung — but which kind of item a shop sells comes out of store.txt, and nothing in that list depends on level. So an arcane shop’s shelves were exactly as deep as a plain one’s. A long word on the sign with nothing behind it, which is the precise failure WLD-16a exists to avoid.

The test that caught it nearly failed to. It picked “the first shop with any turnover”, which is the general store, whose entire stock is food and torches: there is no deep end of that table to bias towards, so the measurement was flat whether the code worked or not. Picking the widest-ranging shop instead — the alchemist, levels 1 to 40 — made the difference visible immediately. Two days running now, a test has been weak in the same way: measuring something that could not move.

Then object value turned out to be a bad proxy too. A deeper potion is not a dearer potion, so the alchemist’s shelves are worth the same at every rung. The magic shows up only on things that can carry a plus, which the alchemist does not sell, so that half of the claim is measured across every shop instead of one. Three metrics before one of them meant what I wanted it to mean.

Ended at 70 / 18 / 8 / 2 per cent, thresholds taken from centiles of the measured distribution rather than chosen. 113 hand-authored building types replaced by three records and a score.

23 August 2026 — a road you can see, and a building that was demolished#

Two faults from the same afternoon, and the second one taught me more.

The road first. “The road appears to end at the beach. That was really a long walk.” It did not end; it turned. A road was one grid wide, and a one-grid road that turns a right angle in the block you happen to be standing in is a single square of floor at right angles to the way you are going. There is nothing to see. Roads are three grids wide now with their corners squared off, which is not a cosmetic change but the difference between a road that reads as a road and one that reads as a dead end. It cost about three per cent of the world in paving.

The second was services silently missing from towns — the magetower that was promised and was not there. I chased that number for a long time and every step was wrong in the same way.

I assumed placement was running out of room, because a service needs a clear lot off a street and the shops and the ruins take most of them. So I made more lots available. Then I moved the services earlier, ahead of the ruins. Then I replaced sixty random guesses with a systematic sweep of every lot. Then I moved them ahead of the shops too. Each change moved the failure rate — 65 per cent, then 43, then 48, then 8, then 5 with the largest cities at 11 — and I read the movement as progress. It was not progress. It was noise on twenty-four samples, and I had been reading a random walk as a trend for four rounds.

What ended it was giving up on the theory and instrumenting the thing I believed: print a line whenever placement fails to find a lot. It printed nothing. Not once, in any band. Every service was being built. The ruin pass that runs afterwards skips a lot that already has a building on it — by asking feat_is_shop(), and a magetower is not a shop. So the generator built the magetower and then built a ruin on top of it.

Two lines to fix. Zero failures in 2,100 towns afterwards.

The lesson is not “instrument earlier”, which I already knew. It is that I never checked the premise. “Placement is failing” was never measured; it was inferred from services being absent, and absent has two causes — never built, or built and destroyed. I spent four rounds optimising the half of the search space the bug was not in, and the measurements I took along the way were all consistent with that, because a random walk is consistent with anything.

One more thing worth keeping: the test I wrote to protect the fix passed on the first seed range I tried while the bug was still live. Twenty-four villages, all green, on a lucky seed. The version that actually catches it walks every band, because the village — small, and left to the ruins — is the worst case, not the great city I had assumed.

21 August 2026 — the DS, and what a 4 MB machine is actually short of#

Parked, and worth writing down properly because it got further than I expected and then stopped for a reason I did not expect.

It builds, and it runs. Under an emulator the ROM boots, reads its data off the card, loads all 1013 monsters, makes a character and generates a world. On a machine with four megabytes. I had assumed the wilderness would be what killed it, and the wilderness turned out to be the cheapest thing in the game: the whole world map is 129 by 129 blocks of six bytes each, about 98 KB, because terrain comes from a seed as you walk rather than being stored.

What the DS is short of is not the world. It is the game. About 1.5 MB of text in lib/gamedata, parsed into structs and strings — the bestiary being most of it — leaves only a few hundred kilobytes free. The live surface chunk is what tips it over, and it is allocated twice, because after building the level the game allocates the player’s known map at the same size. Worse, cave_new takes a separate allocation per grid for that grid’s flags, so a 144x144 surface is 20,736 allocations whose headers cost more than the flags they hold.

So the DS gets a smaller world: 260x260 grids, one town, all thirteen dungeons still out there. That is three lines in a constants file, applied to this build only, and the file is on the card — so the numbers came out of bisecting on real data rather than out of my arithmetic, which was wrong twice on the way.

Then it failed on the actual hardware, with “Unable to access filesystem”. That is DLDI: homebrew needs a driver for the specific card it runs from patched into the ROM. Which would be a small thing, except that the first ROM I sent to the DS had already been patched — by the emulator, which rewrites the file in place and had stamped its own driver into it. A pristine ROM says “Default (No interface)” and is 902,656 bytes; after melonDS had opened it, the same file said “melonDS DLDI driver” and was padded to a megabyte. I had staged the test ROM next to the thing I was treating as the deliverable, so the emulator quietly edited the deliverable.

The lesson I will actually keep from the day: never flash a ROM an emulator has opened. The one I will probably have to learn again: I was confident three times about a machine I cannot run, and each time it was a measurement on the card that put me right, not more reading of the source.

21 August 2026 — Windows, twice#

The Windows build works, which I did not entirely expect. The mingw cross build, MSBuild, MSYS2 and Cygwin all pass and have for a while, so shipping a Windows zip turned out to be packaging rather than porting.

The 64-bit build was more awkward than the 32-bit one for a reason I would not have guessed: the libpng and zlib that ship in src/win/dll are 32-bit binaries. The CMake option says so outright — “32-bit x86 only” — so you cannot simply point the same recipe at an x86_64 toolchain, and Ubuntu has no cross-built libpng to substitute. It builds on a Windows runner under MSYS2 instead, statically linked, which also means the zip is one executable with nothing beside it to lose.

Tested under CrossOver, and I spent a while convinced it was broken because I kept checking for a window before it had finished loading five tilesets. It was fine. It created a character, generated a world, saved 58 KB and exited cleanly — which matters more than the window did, because savefile I/O is where 32-bit and 64-bit actually diverge and the savefile code had changed recently.

No ARM build. Windows on ARM emulates x64, and for a game that spends its time in level generation rather than a render loop, that is enough.

18 to 20 August 2026 — the world fills in#

Towns, roads and dungeons, in that order, and each one broke something the previous one had established.

A dozen towns in four sizes went in, and then three faults made every town that was not home unusable: building lots were all clamped to the starting village’s size, so larger towns came out as empty fields inside their walls; no town was drawn at all once the window stopped covering home; and about one town in fifty lost its down staircase to the gate-cutting. All three were the same mistake in different clothes — code written when there was one town, at one place, at one size.

Then a town wall I was standing next to was invisible, but only when I stood in trees. Angband lights a wall if the grid between it and you carries light onto its face, which quietly assumes anything blocking sight is a wall nobody can stand in. ZangbandTK’s trees are passable and block sight, so the grid I was standing in was judged to be blocking the light. Only the stretches of wall with grass in front of them lit up, which is exactly what made it baffling to look at.

Thirteen dungeons, each covering a range of depths and ending at the bottom of it, so going deeper means crossing the world to find one that reaches deeper. That is the first thing in the game that is Amber’s geography rather than Angband’s.

17 to 18 August 2026 — the website, and four failures in a row#

Getting the manual published took longer than writing the manual would have.

The docs were Angband’s Sphinx project, essentially untouched: the config still said project = "Angband", and the theme was sphinx-better-theme, unmaintained since 2013, propped up with a hand-written template and a hundred lines of CSS doing by hand what a current theme does by configuration. Swapping it was the right move and it broke three separate things, none of which I found until each one failed in turn:

  • The pull-request docs workflow, which installed the old theme by name.

  • The CMake documentation target, which copies docs/_templates — a directory that stopped existing when the last file in it was deleted, because git does not track empty directories.

  • scripts/pkg_win, which created a fixed list of documentation subdirectories, and the new theme keeps its assets in different ones.

The lesson is dull and worth writing down anyway: several separate things consume the documentation build, and I only checked the one I was working in.

The publishing had its own comedy. GitHub Pages defaults to serving a branch, and guessed master /docs because that is where docs live — which is right for a repository whose docs folder holds a finished site and wrong for one whose docs folder holds Sphinx sources. It sat harmlessly until a branch build ran, then published docs/README.md rendered by Jekyll as the entire website.

And the release pipeline had never produced a release. Not once. Four reasons stacked deep enough that fixing any one would not have revealed the next: the docs job broken as above; a release that required Windows, 3DS and Nintendo DS builds before it would publish a macOS disk image; a trigger on every push to master tagging with a git describe string; and no permissions block, so the token could not write the release it had just built.

The macOS signing was its own thing. The build leaves a signature on the executable alone, which is worse than none — it declares sealed resources that do not exist, so macOS rejects it outright rather than treating it as unsigned. Fixing that meant discovering that codesign refuses any bundle carrying Finder information, and that the SetFile call the Makefile has always made sets exactly that, immediately after signing. They cannot both be had. The Finder bundle bit has been unnecessary since Finder started reading Info.plist, so it went.

I also wrote the wrong first-launch instructions and shipped them: right-click → Open, which is what everyone remembers, and which Apple removed. On current macOS you have to be refused once and then use Open Anyway under Privacy & Security.

16 August 2026 — the wilderness, and one over-correction#

The biggest piece of work so far, and the one that made me change a rule.

The principle I had written down was: Zangband was built on Angband 2.8.1, so a great deal of what Zangband looks like is simply what 2.8.1 looked like. Its town is a rectangular grid of shops because that is what 2.8.1’s town was. The walls, the moat and the gates are dressing on a 2.8.1 town. Reproducing that would not be rebuilding Zangband — it would be undoing twenty-five years of Angband and calling the result a variant.

Reading my own principle, I then stripped the rock that 4.2’s town clearing is blasted out of, on the grounds that a ring of granite round a town in a field reads as a wall, and a walled town was the thing I was avoiding. Playing it showed me why that was wrong inside a couple of minutes. The rock is not decoration: it is what keeps the wilderness out of the market square, and what stops line of sight at the edge of town. Without it the town was open to anything that fancied walking in, and a new character could see half a county from the staircase.

So the rock stays and the roads go through it. The principle was right; the inference I drew from it was not. The test is whether a thing is a Zangband idea or 2.8.1 showing through — not whether it happens to look like a wall.

Two requirements were withdrawn during this work, one of them along with the code it had asked for. Writing requirements from a 2005 game means some of them describe problems the new architecture does not have.

15 August 2026 — measuring instead of guessing, and giving up on clean-room#

I started with a clean-room approach: derive requirements from Zangband’s documentation and behaviour, then build against those without reading its source. I dropped it within days, and I think that was right.

The wilderness settled it. Fractal terrain generation, the height/population/law decision tree, block caching, and road and river routing between towns are around nine thousand lines in wild1.c, wild2.c and wild3.c that took years to get right. A requirements document cannot carry that, and rediscovering it from a description would have been expensive and worse. The rule became: port where the algorithm is the value, reimplement where 4.2’s architecture differs. Reading the source to understand intent is always correct; copying it into a structure it was not written for is not.

The lethality numbers came from measurement rather than taste, which I am pleased about. Across the 450 monsters that Zangband 2.7.5 and Angband 2.8.1 have in common, Zangband’s carried a median 0.73× the hit points and 0.50× the armour class of the release it forked from. So every monster in ZangbandTK carries 73% of Angband’s hit points and 50% of its armour class, and both numbers live in a data file where anyone can put them back to 100 and play at vanilla lethality. That one change is most of what makes the game feel different.

The first weeks were content: 389 monsters, 51 artifacts, all 18 ego types, three weapon mechanics Angband has no equivalent of, and the Ancient and Foul Curse with its cascade intact. All of it now has to be read again against the Amber goal, because it was imported before that goal was written down as a filter rather than a preference. Some of it will not survive. Reskin before deleting, where the mechanic is sound.