Skip to content

Neo Angband 0.22.0

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 21 Aug 11:12
· 2850 commits to master since this release

What 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:

  1. 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.
  2. Open System Settings -> Privacy & Security and scroll to
    Security, near the bottom.
  3. 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, and removeValue,
    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. append is treated as
    composing (like the flag ops) so two mods appending to one list is not a
    conflict; removeValue is 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 its base, and an object kind from a
    kind sharing its tval, 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: what base-object is, why an artifact's
    name is only half a name and carries none of the & and ~ decoration an
    ordinary item's does, and the trap that a wrong tval is reported while a
    wrong sval silently 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-object resolves 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 invalid flags, values or act token 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.txt writes every
    look-prefix and look-in-preposition without a trailing space, and upstream
    separates them from the name in finish_parse_feat (init.c L2256-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.ts asserted 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's shopnum
    from the order of the SHOP flags and counts them into z_info->store_max
    (init.c L2249-2257, L2275), and the port hard-coded that eight-feature list in
    town_gen_layout instead. So a mod that flags another terrain SHOP got a
    store with no door anywhere in town, unreachable for the whole game, and one
    that cleared a SHOP flag left a lot leading to a shop that no longer existed.
    FeatureRegistry now assigns shopnum in FEAT order and exposes
    storeMax / shopFeats(), and the town reads those. TOWN_STORE_FEATS stays
    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_constants runs check_critical_levels (init.c L986-1020) over
      the melee and ranged cutoff tables and refuses the data when the cutoffs do
      not strictly increase, because the power >= cutoff walk can never reach a
      row whose cutoff did not rise. That critical grade simply stops happening and
      the damage multiplier is quietly wrong. bindConstants did no such check, and
      melee-critical-level is 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 the o- tables unchecked exactly as upstream leaves them.
    • Shopkeeper tips came out in the wrong order. parse_hint prepends onto a
      list and finish_parse_hints publishes its head, so upstream's hints is in
      reverse hints.txt order; bindCore published file order. random_hint
      reservoir-samples over that list, so the draw count matched and the tip did
      not. Reversed at boot, the way names.txt already 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 its bindui binding are ported (player/abilities.ts,
    game/ui-entry.ts), names' list-to-array reversal (session/boot.ts),
    trap's list-to-array with tidx (world/trap.ts), history's entry
    reversal and successor resolution (player/bind.ts), p_race's ridx and
    class's cidx (array index, player/bind.ts); body's equip_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
    binds world.json; realm, shape and flavor publish a list and free the
    parser, and flavor's reverse order is reproduced at its reader
    (obj/flavor.ts).

  • A shop line naming a missing item took the whole game down. bindStore
    threw on a stock entry it could not resolve, from inside bindCore
    startGame, which the host runs at module top level, so the player got the
    crash screen and no game at all. The append field 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's normal table (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
    buy each lose one entry, and a store: 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 no plugin.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 of main.ts into
    mod-summary.ts so 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_curse refuses a curse that carries MULTIPLY_WEIGHT together
    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, append lets 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
    reached ego: unknown sval out of bindCore inside startGame, 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, because poss_items is 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, or null, 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's rec.owner.map(...) threw a TypeError from inside
    bindCore inside startGame, 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's field/type rule 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's fixed: lines above its flavor: 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 by flavor_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_randarts it did show: flavor_reset_fixed scrubs 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
    or mul aimed at a path holding a list or a string treated it as 0 and wrote
    a number over it; a merge aimed 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 an add against 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.