The Ledger

September 13, 2026

Neo Angband: Borg v0.7.0

Needs Neo Angband 0.27.0 or newer, up from 0.25.0. Several of the fixes below are in the game rather than here - the trap predicate a locked door used to fail, the host answering a blocking prompt for an autoplayer (both 0.26.0), and the real multi-turn rest command plus the shop-buy / shop-sell / shop-exit command handlers (both 0.27.0). A Borg that needs them and loads on a game without them is a Borg that wedges against the first locked door or heals at half rate, so manifest.json refuses instead. src/play.test.ts measures whether the engine on disk has the trap fix, and skips itself rather than failing when it does not, so a red suite here always means a fault here.

The entries divide into two halves. The first five stopped the Borg PLAYING: it wedged, shuttled or swapped forever. The rest stopped it playing WELL, and they share one shape - a fact the ported decision code reads that nothing in the mod ever wrote. Between them they are why a level-one character stood still in town and let a squint-eyed rogue kill it.

Fixed

  • It walked onto an up staircase and then walked off it again, for as long as you left it running. borg_caution’s “take the stairs you are standing on” block (borg-caution.c:1169-1204) had never been ported. stairLess and stairMore were written in twelve places and read in none, so every ladder rung that flows toward a staircase got the Borg there and nothing spent the turn: the flow ran out, the explore rung stepped one square off, and the flee rung pulled it back. Ported at its upstream position, which is why borgCaution now takes the flow state - the up-stair list decides whether going down is allowed.

  • A readiness flag was initialised to “never asked” and nothing ever asked. readyMorgoth starts at -1, and upstream turns that into 0 on the first look, at the head of borg_prepared_aux and borg_restock. Neither normalisation was ported, and three readers test for exactly 0 - including the stair choice above, so it would have stayed switched off even once the block existed. The missing call site was borg_caution’s own restock arm (caution.c:1064), which is also what makes the Borg go home when it runs out of food, fuel or cures; that is ported too.

  • Arriving on a level did not clear “use the next staircase”. Upstream’s borg_update sets stair_less and stair_more false on every level change (borg-update.c:2135). Without it, arriving in town with stairMore still set walked straight back down and arriving on level one with stairLess still set climbed straight back up: 215 descend/ascend pairs and nothing else. Two intents are journeys across several levels and upstream keeps them, so the level wipe no longer resets everything: rising survives every arrival but the town’s, and fleeingToTown survives depth one.

  • It swapped one wooden torch for an identical one, 3964 times in 4000 decisions. borg_wear_stuff was missing three guards: the two “been sitting on this level forever” returns (wear.c:779) and, the one that matters, if (p <= b_p + 50) continue (wear.c:913). The margin is the anti-loop - without it two near-identical items are each the better choice in turn, forever.

    The margin was necessary and not sufficient, because the difference being measured was 14000 points rather than noise, and the cause was the COMPARISON. A candidate loadout’s score came out of the engine’s hypothetical derive and was compared against the live self-model’s score, which comes out of the live derive

    • and those two do not answer identically. Angband’s own calc_light skips a daytime town’s light only on the live pass (player-calcs.c:1607), so in town the hypothetical side counted a torch’s radius the character does not have. Faithfully, because the port keeps that wart.

    Upstream never meets this, because it compares two numbers from one code path: it wields the candidate for real, scores it, and puts it back. That property is restored here. A candidate’s score is now applied as a DELTA against the character as it stands, derived through the same hypothetical path, so any systematic difference between the two derives cancels instead of reading as a gain on every candidate. One extra derive per decision, memoised per think.

  • borg_prep_leave_level_spells is ported (borg-flow-stairs.c:252), because the stair block above is its only caller: a caster with mana to spare hastes, resists and buffs before it takes a staircase, in upstream’s order, each one setting the no-rest timer that stops it resting the buff away.

  • It rested while something it could not see beat it to death. Upstream’s answer to an attacker it cannot find is REGIONAL FEAR, and borg_fear_regional’s own comment (borg-update.c:697) is the specification: it exists “to keep him from resting while unseen guys attack him”. This port had the two fear caches, both updaters that fill them and every reader that consults them, and nothing in between. borgFearGrid and borgFearRegional had no caller at all, so both caches held zero for the life of every character.

    The whole path is connected now, at upstream’s own four points: an attack message the Borg cannot attribute raises fear at its own grid, a monster appearing right afterwards is assumed to be the cause and clears it again, the fear decays a point every ten turns, and a new level forgets it outright. The monster half is re-stamped once per think from each tracked monster’s own danger, which is what makes a crowd read as dangerous when no single member of it does.

    Recognising an attack at all needs the game’s blow-method messages, which upstream builds its suffix_hit_by table from at start-up. The plugin now reads the same records from ctx.registries.monsters.blowMethods, so a mod’s own blow method is recognised on the same terms as core’s.

  • The one gate that decides whether it is safe to sit still was wired to true. borg_check_rest is ported, and borg_recover puts every one of its rests behind it - but the seam carrying its answer was the literal constant, so the Borg would rest with a monster one square away. It asks the real function now.

    The function was also missing six of its own arms, including both fear tests, the “I just cast a preparatory spell” timer, a mold two squares off, a breeder within ten, and anything that walks through walls. Its danger test asked the grid’s TOTAL danger where upstream asks about one monster, which let a busy level veto a rest next to something harmless.

  • Nothing the Borg timed ever ran down. Every duration it tracks - the resist-all guess, the no-resting-after-a-prep-spell timer, the recall countdown, the see-invisible clock - is counted in game turns and decremented each think by borg_game_ratio. Neither the decrements nor the ratio had been ported, so each of those latched permanently on first use: one Sense Invisible and the Borg would never look again. The ratio was also present as a placeholder set to 10 where upstream computes 1000 at normal speed, a hundredfold error in the one check that read it.

  • It never knew it was waiting for a Word of Recall. goal.recalling is read in eight places and was written in none, so the Borg re-read the scroll and never sat still for it. The ignition, lift-off and cancellation messages (borg-messages.c:709-757) are consumed now, and the countdown floors at 1 the way upstream’s does, so reaching zero cannot be mistaken for “no recall running”.

  • The self-model believed a full pack was empty. borg_notice matches carried items by (tval, sval) against the role table upstream builds at start-up (borg-item-val.c) to count what the Borg has: healing potions, phase doors, cures, fuel, food. The table was a seam that defaulted to {} and no caller ever passed one, so every comparison was against undefined and every count stayed zero. The Borg dived, restocked and judged itself prepared as though it carried nothing at all. It defaults to the real table now, which is what upstream does unconditionally, and the food count is why borg_check_rest refused every grid on the level once it was finally being asked.

  • Every monster read as unable to attack from a distance. ranged_attack is the count of a race’s spell flags (borg-flow-kill.c:216); five subsystems read it and nothing wrote it. Among them is the rest check, which refuses a grid in line of sight of something that can shoot.

  • The escape counter could only ever go down. Seven places lower it for a phase door, which upstream says is not really an escape, and the one place that raises it (caution.c:1655) was not ported. So “flee the level after three escapes” and “after fifty-five regardless” were both unreachable.

  • It chased monsters that were not there. A tracked record is the Borg’s belief about a monster, and borg_follow_kill (borg-flow-kill.c:552) is the only thing upstream has that ever revises one downward: when the Borg can see the grid it left a monster on and the monster is not on it, the record is followed one step into somewhere it cannot see, or forgotten. None of it was ported, so a record for a monster that had died out of sight, been mistaken or simply walked away survived the full 2000-turn expiry - and borg_flow_kill kept routing the Borg to it.

    That is the second half of “moving frantically back and forth across three cells”: flow to kill the phantom, arrive, find nothing, let the explore rung step back, repeat. Measured over four seeds at 2000 decisions each, the tightest stretch of sixty consecutive monster-free decisions that were not spent resting went from one square to twenty-one, and the number of such stretches confined to three squares at full hit points went from 403 to zero.

    Upstream’s 1-in-100 anti-loop roll is deliberately not ported and says so in the source. Its test is randint1(100) < 1, and randint1 returns 1 to 100, so the arm is unreachable in 4.2.6; porting it would add a deletion path the original does not have.

  • Nothing ever marked a grid as lit by the Borg’s own light. BORG_LIGHT is “my torch reaches here” and BORG_GLOW is “this room lights itself”; three ported subsystems ask for the first and borg_update_light (borg-cave-light.c:71) was not ported, so all three read every dark corridor as unlit. Among them is the check above, which is why a phantom in a corridor could not be disproved even standing next to it.

  • The Borg’s own random stream restarted at the top of every decision. The controller reseeded the private generator to a fixed constant before each think, so the first draw of decision one and the first draw of decision fifty were the same number, and so was every draw after it. Anything that consults the Borg’s generator at a stable point in the think was therefore frozen: an equal-cost pathfinding tie-break resolved the same way every time, and a low-probability branch either never fired or fired on every pass. Upstream’s swap-in/swap-out around borg_think ends by writing the ADVANCED value back (borg.c:504) and seeds the stream once at start-up (borg-init.c:487-488), so its stream carries from one think to the next. It now does here too. The Borg remains fully deterministic for a given starting seed, because determinism comes from the stream being private and seeded, not from restarting it.

    src/play.test.ts also gave every one of its four “different” runs the same Borg seed, so the suite was structurally blind to this entire class of bug. The seed now varies with the run.

  • It read the wrong square’s danger before deciding whether to teleport out. borg_escape’s parameter is named b_q, but it has exactly one caller and that caller passes the danger of the grid the Borg is STANDING ON (borg-caution.c:1653). This port trusted the name and passed the smallest danger among the surrounding squares instead. Every threshold inside is “is this danger above X”, so a smaller number could only ever suppress an escape, never cause a spurious one: a first-level character on a dangerous square with one safe square beside it read the safe square’s number, decided it was fine, and stayed to be killed. The exact scenario the field report described.

  • There was no way to simply step back from a monster. borg_caution’s *** Back away *** block (borg-caution.c:1664-1846) had never been ported, and nothing else in the ladder does its job: caution found no escape item, returned nothing, and the next rung attacked. That left a fresh character exactly one response to anything that walked up to it, whatever the odds, which is how a level-one character dies to a town rogue rather than retreating up the alley it came from. Ported at its upstream position, thresholds and all: the 40 percent danger reduction a character past level 35 demands before giving up a square, the 80 percent one below it, the freedom-of-movement tiebreak when two squares are equally dangerous, the refusal to move while confused or while the anti-summon corridor timer is running, and the bounce detection that stops the retreat itself becoming a shuffle. Upstream’s two warts in this block are kept: a trap in any one direction abandons the whole search, and the “next to a monster” flag latches on the first bad direction and then applies to all of them, which in practice makes backing away a corridor manoeuvre.

  • It never picked anything up, and it was two separate faults. Every rung that walks to a floor object gates on that object’s estimated value, and borg_new_take’s valuation (borg-flow-take.c:251-271) had never been ported: the field it writes was initialised to “worthless” and no code path ever set it otherwise, so every object on every floor scored zero and the Borg walked past all of it. Gold was the exception and hid the rest, because the engine collects gold on the step whatever the character has decided.

    Fixing the valuation alone would have made it worse. Upstream never presses a pickup key: it turns on pickup_always when it takes over (borg-init.c:415), so stepping onto an object collects it. A mod cannot flip that option without changing the human’s saved settings and leaving them changed, so the Borg now asks for what is under its feet with the ordinary pickup command, on arrival only. On arrival only is not a detail: a rung that collects while standing still picks up whatever the junk-dropping rung has just put down, and puts it down again. Measured at fifty-five drops against five pickups on one square before that was gated.

    Pricing needs the shop value of an object kind and whether the character knows the flavour, both of which the frozen view leaves out; they now arrive through the same host seam that already carries danger vision, so a mod’s own object is priced by the same lookup as one of the game’s.

    Upstream also inscribes an item “borg ignore” one keypress before it drops it as junk, which prices it at -10 and is the only thing that stops the Borg collecting its own discards on the way past. There is no inscribe command on the frozen action surface, so the Borg remembers what it has thrown away on this level instead.

  • It turned round almost as soon as it arrived. borg_must_return_to_town (borg-prepared.c:828) has two guards and the port had neither: upstream never asks the question in town, and never asks it in the first hundred turns on a level, so a character short of supplies walks the level it is on before deciding to go home. Without them, the “go to town without delay” rung - which outranks attacking, collecting and exploring - could fire on the first think after arriving anywhere.

  • The Borg’s clock never restarted, and after thirty thousand decisions it stopped playing for good. borg_t is a PER-LEVEL counter upstream, reset to 1000 on every arrival (borg-update.c:2017), and every absolute test written against it assumes that. Here it only ever climbed, so the message-flush hacks at twelve and twenty-five thousand fired once and never again, the monster purge at twenty thousand became permanent, and borg_think_dungeon’s own overflow panic at thirty thousand - which hands the game back to a human - became a fixed end to every long session rather than something no character ever reaches. The same arrival now also zeroes the anti-summon timer and accumulates the time-since-town counter, which nothing had ever written, so the two arms that read it were measuring time on the current level instead.

  • Arriving somewhere new left the old level’s staircases on the map. The location tracks - stairs, doors, glyphs, rubble veins - are wiped on arrival upstream (borg-update.c:2165-2183) and were not wiped here, because they live beside the pathfinder rather than in the world model. The rung that keeps a weak character near an escape route then measured the distance from the character to a staircase on a level it was no longer on. At character level one that leash is seventeen grids, so it decided it had wandered too far on its first think of every new level, and turned back. A companion fault kept it turned back: the flag only clears when the Borg is within three grids of a known up staircase, and the port had added a guard that skipped the clear when no staircase was known at all, so on a level with none the flag never came off.

  • A readiness flag was being written by a routine that upstream leaves alone. borg_prepared’s shallow branch (borg-prepared.c:627) returns without touching ready_morgoth; this port set it to “unknown” on the way out regardless. borg_caution will only set the flag that actually spends a turn on a staircase while that reads zero, so whether the Borg would climb depended on which subsystem had asked a question last.

  • It rested at half the rate upstream does. The agent action surface’s rest() took no argument and the engine mapped it to a single-turn hold, so every recovery-ladder call spent one turn and stopped; a real rest needs five consecutive turns to earn the doubled hit-point and mana regeneration (player-util.c:459, REST_REQUIRED_FOR_REGEN), which a hold never starts. rest() now takes an optional turn count and the seven recovery call sites pass REST_COMPLETE (“rest as needed”) explicitly; the one call that wanted a single tactical turn while waiting for an approaching monster moved to hold() instead, because a count of exactly 1 means “repeat the last rest”, not “one turn”. This needs the corresponding Neo Angband fix, which has not reached a release yet - see PLANNED.md item 9. Until then the call degrades to today’s single-turn hold, silently and correctly; no player-facing change lands until the engine catches up.

Added

  • A test that plays the game. src/play.test.ts boots a real game against the engine, installs the Borg as its command provider, and drives 1500 decisions on each of four seeds. It does not check that the Borg plays well - a level-one character dying on depth two is upstream’s own outcome - it checks that the Borg is still playing: that no single command has run away with the session, that the character covered ground, and that game time passed. Every loop above is individually a correct decision and collectively a hang, which is precisely what seventeen files of unit tests could not see. It skips, loudly, when the game’s checkout is not beside this one.