Skip to content

fix: lift the 64-upgrader wall, save format v3 - #16

Merged
vetr0s merged 1 commit into
mainfrom
fix/upgrader-cap
Jul 12, 2026
Merged

fix: lift the 64-upgrader wall, save format v3#16
vetr0s merged 1 commit into
mainfrom
fix/upgrader-cap

Conversation

@vetr0s

@vetr0s vetr0s commented Jul 12, 2026

Copy link
Copy Markdown
Owner

MAX_UPGRADERS was 64 because Item.upgraded_mask (the "which upgraders have already lifted this ore" set) was a single u64. The 65th upgrader in the whole world was a silent rejection, with MAX_ENTITIES at 10000 and the largest region at 128x128 = 16384 cells. Upgraders are the game's core verb, so the cap was a design wall sitting in the middle of the intended late game.

The change

piece detail
mask u64 upgraded_mask[UPGRADER_WORDS], cap 256. An ore's mask goes 8 -> 32 bytes, so items[1024] grows 48KB -> 72KB, against the 512KB of grids already in World
bit math mask_test/mask_set/mask_clear in world.c. Not open-coded anywhere: the old (u64)1 << id is undefined for an id >= 64, not visibly wrong, which is exactly the bug a naive widening leaves behind
save v3. v1/v2 files still load: both bitmaps were one u64 there and the only ids such a file can name are 0..63, so they widen into word 0. This is the first change to the byte layout, so it is the first version the reader actually branches on

Two latent load bugs on the same path, fixed here

bug fix
upgrader_id was never validated on load, so a corrupt file with an id past the cap shifted out of range the first time an ore touched that upgrader range-checked on read; the load fails cleanly
an upgrader dropped by the placement check (off-grid, or an occupied cell) leaked its id forever, because the id bitmap was restored wholesale from the file the bitmap is rebuilt from the upgraders that actually land, so it describes exactly the world that exists. A duplicate id fails the load rather than gating two upgraders on one bit

Verification

Headless probe over world.c + save.c:

  • 256 upgraders place, each with a unique in-range id; the 257th is rejected and spends nothing.
  • Cross-word: an upgrader with an id past word 0 (the test lands on id 100) lifts the ceiling exactly once, still climbs on a re-pass, and sets no bit in word 0. This is the case the old mask could not represent at all.
  • v3 round trip with mask bits in all four words: money, entities, ids and every mask word survive byte-exact, and the id bitmap rebuilds to the same set.
  • v2 compatibility against a real file, not a synthetic one: a fixture written by the pre-change binary (13 entities, 4 in-flight ore carrying mask bits) still loads, its masks land in word 0 with no high word invented, the upgrader's id is reclaimed, and the loaded world still ticks and banks.
  • A corrupt id (999) fails the load instead of shifting out of range.

./nob release and ./nob game clean under -Wall -Wextra; release binary runs without a crash.

Note: this changes the layout of Item and World, so a running ./nob hot needs a full restart, not a reload.

MAX_UPGRADERS was 64 because Item.upgraded_mask (the "which upgraders have
already lifted this ore" set) was a single u64. That made the 65th upgrader in
the WHOLE world a silent rejection, with MAX_ENTITIES at 10000 and the largest
region at 128x128. The cap sat in the middle of the intended late game.

The mask is now u64[UPGRADER_WORDS] and the cap is 256. An ore's mask goes 8 ->
32 bytes, so items[1024] grows 48KB -> 72KB, against the 512KB of grids already
in World. The word/bit split lives in mask_test/mask_set/mask_clear and is not
open-coded anywhere, because an id >= 64 in the old `(u64)1 << id` is undefined
rather than visibly wrong.

Save format v3. v1 and v2 files still load: the two bitmaps were one u64 there,
and the only ids such a file can name are 0..63, so they widen into word 0.
This is the first change to the byte layout, so it is the first version the
reader branches on.

Two latent load bugs on the same path, fixed here:

An upgrader_id was never validated on load. A corrupt file with an id past the
cap shifted out of range the first time an ore touched that upgrader. It now
fails the load.

An upgrader dropped by the placement check (off-grid or occupied cell) leaked
its id forever, because the id bitmap was restored wholesale from the file. The
bitmap is now rebuilt from the upgraders that actually land, so it describes
exactly the world that exists. A duplicate id fails the load rather than gating
two upgraders on one bit.

Covered by a headless probe: 256 place and the 257th is rejected, an upgrader
with an id past word 0 lifts the ceiling exactly once and climbs on re-pass, a
v3 file round-trips bits in all four words, a real v2 file written by the
pre-change binary still loads with its masks in word 0, and a corrupt id fails.
@vetr0s
vetr0s merged commit 6d49d66 into main Jul 12, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant