Neo Angband 0.22.0
Pre-releaseWhat you can download
| Platform | File |
|---|---|
| Windows (installer) | Neo Angband Setup 0.22.0.exe |
| Windows (portable, one file) | Neo Angband-0.22.0-portable.exe |
| macOS | .dmg, or the .zip if you prefer to unpack it yourself |
| Linux | .AppImage (no install), .deb, or .tar.gz |
| Self-hosting | neo-angband-web-0.22.0.zip - static files, any web server |
The portable Windows build and the AppImage need no installer:
download, run, and the game keeps its saves in a folder beside itself.
These builds are not code-signed
There is no Apple Developer identity or Windows certificate behind this
project yet, so your OS blocks the first launch.
Windows is one click: SmartScreen says "Windows protected your PC",
choose More info then Run anyway.
macOS is not one click, and the dialog it shows you does not contain the
way through - it offers only Done and Move to Trash. Do this:
- Drag the app out of the
.dmg, then double-click it and press Done
on the refusal. This step is required: it is what makes the permission
below appear, and it expires about an hour later. - Open System Settings -> Privacy & Security and scroll to
Security, near the bottom. - Press Open Anyway on the line naming Neo Angband, authenticate, and
launch it again.
Or, the same decision in one command:
xattr -d com.apple.quarantine "/Applications/Neo Angband.app"
The old right-click -> Open trick does NOT work: Apple removed that
bypass in macOS 15 Sequoia.
On Apple Silicon take the arm64 build. The x64 one is for Intel Macs,
not a fallback - Apple is withdrawing Rosetta 2, so on a current Mac it is
likelier to refuse to launch than to run slowly.
If that trade is not one you want to make, build it yourself -
docs/INSTALL.md - or play in the browser, which needs no trust decision.
Your save
Saves survive an update. Every save-format change ships the conversion that
reads the version before it, and a save this build cannot open is left
untouched rather than replaced.
Current state of the project at version 0.22.0 - the mod-resilience release.
Nothing about the game's rules changed. What changed is that a mod can now add
one line to a list instead of restating it, that a mod's own content is drawn
rather than lettered, and that thirteen defects are fixed - four of them ways one
line of one mod could take the whole game down at boot, which now cost that mod
its line and nothing else.
Added
- A mod can add one entry to a list. Two new field-patch ops:
append, which
adds entries to the array at a dot-path without restating it, andremoveValue,
which drops entries deep-equal to a value. This closes the limitation recorded
under 0.21.0: putting a modded item in a shop's stock is now a three-line patch,
core's own stock list survives untouched, and two mods can both add to the same
store because neither has to replace the other's list.appendis treated as
composing (like the flag ops) so two mods appending to one list is not a
conflict;removeValueis order-dependent, because it can erase another mod's
entry, so it is reported and the mod that loads last wins. - An added monster or item is drawn from its family in tile mode. A tile set
maps named monsters to pictures and has never heard of a mod's, so modded
content used to stand out as a coloured letter among pictures, and the only
fix available to an author was a pref file naming atlas coordinates, which are
correct for one tile set and wrong for every other. A monster with no tile of
its own now takes one from a race sharing itsbase, and an object kind from a
kind sharing itstval, so a modded ant is an ant in every tile set at once.
Restricted to records a mod ADDED, by provenance, so core's own drawing is
untouched, including the object kinds that are deliberately blank because they
are drawn by flavour. Runs in both tile engines. A pref file naming a specific
tile still wins. - Tutorial 2 now stocks its item in the Armoury, and its finished mod is checked
against the real store binder, which is where an item name that does not
resolve is caught, rather than becoming a shop that quietly lacks it. - A seventh tutorial: add an artifact. The one shape the first six did not
cover, and the last of the three examples a player asked for by name. An
artifact is a layer over an item the game already ships rather than an item of
its own, so it gets its own page: whatbase-objectis, why an artifact's
nameis only half a name and carries none of the&and~decoration an
ordinary item's does, and the trap that a wrongtvalis reported while a
wrongsvalsilently invents a placeholder base object, because that is the
behaviour the Phial, the Star and the Arkenstone depend on. Its finished mod
is a real folder like the other six, bound by the real object registry rather
than read back as JSON.
Fixed
-
A mod's artifact naming a base object that is not there costs the artifact,
not the game. Third instance of the store binder's defect, and the first
where the right size of the drop is the whole record: a shop with one fewer
stock line is a shop and an ego with one fewer candidate base is an ego, but an
artifact with no base kind is not an artifact, because every number on it is an
adjustment to a kind that has to exist. So a mod-contributed artifact whose
base-objectresolves to nothing is dropped whole and reported against that
mod through the same mod-manager path the store and ego drops already use, and
core's own still throws the message it always threw. Index-safe in the
direction that matters: core's pack composes first and mods append, so no core
artifact can sit behind a mod's and a savefile's core artifacts keep their
numbers. An invalidflags,valuesoracttoken on a mod's artifact still
throws and is recorded in docs/PLANNED.md. -
The look/target UI ran terrain prefixes into the name: "the entrance to
theArmoury", "You are inan open door", "somelava".terrain.txtwrites every
look-prefixandlook-in-prepositionwithout a trailing space, and upstream
separates them from the name infinish_parse_feat(init.cL2256-2272),
which appends a space to each non-empty one after the parse. The port copied
the data faithfully and never ported that hook, so the targeting code's
<preposition><prefix><name>concatenation had nothing between its parts.
Ten features, three visible strings: the eight store entrances ("the entrance
to the"), the open and broken doors ("in"), and lava ("some"), plus the
stores' "at" preposition, which the same normalisation covers. Two comments in
known.tsasserted this was upstream's own data rather than a missing hook
and are corrected; the assertions that had encoded the unseparated values are
now the test that would have caught it. A port defect rather than an upstream
wart: upstream renders these lines correctly, so it belongs in core, not in
bug-fixes. -
The town laid out eight store lots no matter what the terrain data said, and
three other post-parse hooks were absent.finish_parse_feat's trailing-space
half landed above; the same hook also derives each shop entrance'sshopnum
from the order of theSHOPflags and counts them intoz_info->store_max
(init.cL2249-2257, L2275), and the port hard-coded that eight-feature list in
town_gen_layoutinstead. So a mod that flags another terrainSHOPgot a
store with no door anywhere in town, unreachable for the whole game, and one
that cleared aSHOPflag left a lot leading to a shop that no longer existed.
FeatureRegistrynow assignsshopnuminFEATorder and exposes
storeMax/shopFeats(), and the town reads those.TOWN_STORE_FEATSstays
as the shipped-data expectation with a test that fails if the two ever part.Audited the other twelve
finish_parse_*hooks against the port at the same
time. Two more were missing:- A bad critical-level table was accepted in silence.
finish_parse_constantsrunscheck_critical_levels(init.cL986-1020) over
the melee and ranged cutoff tables and refuses the data when the cutoffs do
not strictly increase, because thepower >= cutoffwalk can never reach a
row whose cutoff did not rise. That critical grade simply stops happening and
the damage multiplier is quietly wrong.bindConstantsdid no such check, and
melee-critical-levelis a top-level key of the constants record that a mod
can replace wholesale. It now rejects, with the last row's cutoff exempt
exactly as upstream leaves it (which is why the shipped tables can end in
-1), and theo-tables unchecked exactly as upstream leaves them. - Shopkeeper tips came out in the wrong order.
parse_hintprepends onto a
list andfinish_parse_hintspublishes its head, so upstream'shintsis in
reversehints.txtorder;bindCorepublished file order.random_hint
reservoir-samples over that list, so the draw count matched and the tip did
not. Reversed at boot, the waynames.txtalready is for the same reason.
The remaining ten are reproduced or have nothing to reproduce, recorded here so
the next audit does not repeat the reading:player_prop's per-element
expansion and itsbinduibinding are ported (player/abilities.ts,
game/ui-entry.ts),names' list-to-array reversal (session/boot.ts),
trap's list-to-array withtidx(world/trap.ts),history's entry
reversal and successor resolution (player/bind.ts),p_race'sridxand
class'scidx(array index,player/bind.ts);body'sequip_slots_max
padding is a no-op for a single 12-slot body and the port carries the real slot
count;world's level-reference validation has no port subject, since nothing
bindsworld.json;realm,shapeandflavorpublish a list and free the
parser, andflavor's reverse order is reproduced at its reader
(obj/flavor.ts). - A bad critical-level table was accepted in silence.
-
A shop line naming a missing item took the whole game down.
bindStore
threw on a stock entry it could not resolve, from insidebindCore→
startGame, which the host runs at module top level, so the player got the
crash screen and no game at all. Theappendfield op made that reachable from
an ordinary pair of mods and an ordinary click: mod A appends an item mod B
defines to a store'snormaltable (tutorial 2 is exactly this patch), the
player disables mod B, and the appended line now names nothing. A
mod-contributed entry that resolves to nothing is now dropped and reported
against the mod on its own row in the mod manager; the rest of the store and
every other shop in town are untouched. Core's own data still fails loudly:
the tolerance is decided per entry from the record's provenance, so an
unresolvable line in a store no pack has touched throws exactly the message it
always threw, which is every store in a modless game. Covers every field a
patch can reach:normal,always(including its svalless book lines) and
buyeach lose one entry, and astore:entrance repointed at a feature that
does not exist leaves the shop unenterable rather than taking the game down.
The record keeps its place in the store list, because that list is read
positionally and renumbering it would move a saved game's stock between shops.
The owner list resolves no names and so has nothing to refuse. -
The character dump called every installed content mod "(not installed)". The
[Mods enabled]block resolved each enabled id's version out of the two bundled
PLUGIN registries only, so a mod carrying noplugin.js(most of them, and all
of the tutorial mods) matched neither and printed the "(not installed)"
fallback. A pack in the player's own mods folder was equally invisible whether it
shipped code or not, since those registries glob the bundle rather than the
folder. Measured in the running desktop build: two tutorial content packs
enabled and demonstrably composed, both reported as not installed. The version
now also resolves through the content-pack registry: every bundled pack plus
everything from the mods directory, a picked folder, or a repository install,
and "(not installed)" is kept for an id that genuinely resolves to nothing,
which is a real state worth a line. This mattered because naming the mods is the
block's entire purpose: a dump claiming a loaded mod is absent points the reader
at core for behaviour a mod caused. The list also moved out ofmain.tsinto
mod-summary.tsso it is testable at all: the entry module cannot be imported,
which is why a list that was wrong for every content-only mod stayed green. -
Monster recall did not know what a monster's KIND implies. Upstream unions
each race's base flags into its lore at startup (finish_parse_lore), so a
player who has never met a giant black ant still knows ants are animals with
weird minds, and that ainu resist fire and cannot be confused. The port had
the race half of that inheritance and not the lore half, which is exactly why
nothing noticed, since the flags were on the race all along and simply never
known. Measured against the shipped pack: 54 of the 56 monster bases carry
flags, so recall was quieter than upstream's for every monster the player has
not met. The wizard "wipe monster lore" command still loses them for good,
because upstream's union runs once at startup and never again; the existing
wipe test is what caught the first attempt putting it in the wrong place. -
A curse could multiply an object's weight by a negative number.
finish_parse_curserefuses a curse that carriesMULTIPLY_WEIGHTtogether
with a negative weight adjustment, and the port had the parser-side weight
check but not this one: it is a FINISH hook, and the port's own comment said
as much while never implementing it, which is exactly the blindness a parity
test written against upstream's parser tests cannot see. Core's own data now
fails the same way upstream's does. A mod's curse instead loses the FLAG and
is told: of the two halves, the flag is the one whose removal leaves a
coherent curse (a plain additive weight reduction), and failing the parse for
a mod would mean the crash screen and no game. Core ships no curse using the
flag at all, so nothing shipped changes. -
An ego's
item:line naming a missing base item took the game down. The
same defect the store's stock table had, in a second file:item:names a
specific base kind,appendlets one mod add an entry to another pack's list,
and "mod A gives an ego a base item mod B defines, player disables mod B" then
reachedego: unknown svalout ofbindCoreinsidestartGame, the crash
screen over one line of one ego. A mod-contributed line that resolves to
nothing is now dropped from that ego's candidate list and reported against the
mod; core's own still throws the message it always threw. Dropping one entry is
the whole cost here, becauseposs_itemsis a set of candidates and an ego
with one fewer candidate still works: it simply cannot land on the kind that
went away, which is what the player asked for by disabling the pack that
defined it. The core-versus-mod decision itself now lives in one place
(mod/refusal.ts) rather than in each binder, so two binders cannot come to
different answers about the same provenance. -
A patch could make a field unreadable and take the game down at boot. A
field patch that wrote a scalar, ornull, over a field core writes as a list
or an object produced a record that composed perfectly and that no binder could
read: the store binder'srec.owner.map(...)threw aTypeErrorfrom inside
bindCoreinsidestartGame, which the host runs at module top level, so the
player got the crash screen and no game. The composer already checked this:
the record check'sfield/typerule fired on it and named the mod, but that
check reports and never refuses by design, because the blueprint it reads is a
measurement of core's own records and an unlisted value is legal. Container-ness
is the exception, since nothing can iterate a string, so the composer now
refuses that one class: the field is put back to what the record had before, the
pack is told on its own row, and the rest of the patch still lands. Two things
it deliberately still allows: a scalar written as the wrong scalar (readable,
and the measurement cannot prove otherwise), and a patch that REMOVES a field,
because dropping fields is how a total conversion works and putting them back
would undo it. -
Randart games handed out the wrong gems. flavor.txt writes a ring or
amulet record'sfixed:lines above itsflavor:lines, and the binder bound
them the other way round, so the flavour list was not in the file's order.
That list is walked backwards byflavor_assign_random(it reproduces C's
prepend-into-a-linked-list), which makes a flavour's position in it the thing
that decides which ring it lands on. In an ordinary game nothing showed: a
fixed flavour keeps its own sval and the random assignment skips it, so the
random ones kept their relative order and every ring looked right. Under
birth_randartsit did show:flavor_reset_fixedscrubs every fixed sval but
the One Ring's, which drops seven more entries into the random pool at the
wrong end of the list. The draw COUNT is identical either way, so the RNG
stream never moved and no seed probe could have caught it; only the assignment
itself differs, and a test now runs both orders against one seed to show that
it does under randarts and does not without. -
Three field-patch ops silently destroyed data instead of failing. An
add
ormulaimed at a path holding a list or a string treated it as0and wrote
a number over it; amergeaimed at a list replaced the list with an object.
All three now raise a patch error naming the path and what was actually there.
This was not theoretical: a documentation example shipped anaddagainst a
store's stock list, which turned the list into a number while composition
reported no problems at all.
Changed
- Tutorial 3 no longer says an added monster is stuck as a coloured letter in tile
mode, and no longer says a mod can carry its own art for its own monsters. A
mod contributes a whole graphics mode, not one picture added to somebody else's
set. Both claims had outgrown the code. - Cleaned up some artifacts in the documentation and in source comments: the
prose now uses plain ASCII punctuation throughout.
Found something that does not match Angband 4.2.6? Open an issue or come and say so in the Discord.