08-02-2026, 07:19 AM
Hi SodThor,
thanks for the help, for now.
I can't validate what Claude is saying myself.
I don't want to rule out the possibility that the AI is on the wrong track again.
But see for yourself Claude's reply is below:
Hi Thor,
thanks a lot — I built your version against 67db562 and measured it side by side, and I also went and measured the bit-set idea. Both were worth the time. Long post, sorry.
Your files
1. There's a typo with real consequences. In enemies_update:
u8 is_anim = (u8)((spr >> 1414) & 1u); /* should be >> 14 */
cc900 doesn't fold this to 0 — it masks the shift count to the low nibble. I checked the generated asm:
>> 14 -> srl 0xe,HL
>> 1414 -> srl 0x6,HL (0x586 & 0xF = 6)
So is_anim becomes bit 6 of the sprite number, i.e. arbitrary per enemy. Animated enemies get drawn from spr_num (never set for them), and static ones index lvl_sspr_anim_frames[anim_idx] with an anim_idx that was never set.
2. Most of the measured gain is that typo. Cycles per game frame, emulator with hardware-calibrated wait states (cart_wait 3, vram_wait 3, ldir 14), both builds forced to the same fps mode:
scene your version with >> 14
level start −7.3 % −1.5 %
tower band (row 139) −6.3 % −2.8 %
The difference is skipped animation work, not speed.
The rest holds up well. The put_cell / put_cell_word macros taking the plane pointer plus a pre-shifted flip bit, UnsetSprite as a macro (72 far calls gone here), moving the pointer arithmetic in SetSpritePosition behind the early-out, hoisting the mirrored words out of the anim_update cell loop, the multiply out of lvl1_put_row, y + ship_hit_y[j] out of the inner collision loops, and is_static → is_anim. I checked the base++ mutations in draw_sprites — each one ends in a continue or isn't read afterwards, so they're correct, just fragile. Your patch also applies cleanly to my current tree, which has moved on quite a bit since the GitHub commit.
One small thing: put_cell_word doesn't parenthesise its parameters (flip | word, scr_ty, col). Harmless with today's call sites, but it's a trap waiting for the first expression argument.
Also just noting: your file sets FPS_MODE_DEFAULT back to 30 — I'm running 20 here, so that's not a difference on your side, I just matched them before measuring.
So: definitely useful, I'm taking it, with the shift fixed and expecting about 2 % rather than 7 %.
The bit-set idea
Measured rather than guessed, and the number is bigger than I expected.
Your premise holds: every object array here is ≤ 16 entries (bullets 4, enemies 10, enemy bullets 8, metasprite enemies 8, pickups 12, map-object bullets 16, weapon bullets 4, mines 8, worms 1, wall worms 2, balls 6), so a u16 bit set fits all of them.
Upper bound, measured: I added one extra, behaviour-neutral pass over all 79 slots, once per game frame (result into a volatile sink, so it generates no follow-on work). Cost: +4.0 %, 9394 cycles/frame. So a single full pass over all slots is 4 % — and the real code makes several passes per frame (update, draw, collision). Definitely not noise.
Two things that shrink the win: a bit set only removes the inactive visits — active objects still have to be touched. And without BS1F from C, finding the first zero bit is a loop as well, so the gain is the memory read plus the address computation, not the loop itself.
That points at the best candidate: my structs are padded to powers of two, so indexing is a shift — except TMapobjBullet (18 bytes). I tried padding that one to 32 and it measured slower, because the field offsets fall out of the 8-bit displacement and at cart_wait 3 every extra instruction byte costs three cycles. So that array does a muls per slot, and it's also the biggest one (16). That's where I'd start.
The risk is maintenance: 61 places set active = 0, 19 set it to 1, 218 read it. A bit set kept alongside the flag will desync eventually, and the symptom (leaked or double-allocated slots) is nasty to find. If I do this, the bit set has to replace the field so that every missed site is a compile error — the same trick that made your is_static → is_anim rename safe.
So: worth trying, targeted at the map-object bullets and the enemies first, and measured one at a time.
One aside: your question incidentally caught a bug in my own tooling — my cycle probe had silently stopped entering the level after I added a "GET READY PLAYER 1" screen, and was reporting "no scroll" for every ROM including the unchanged reference. Fixed.
Thanks for digging into this.
thanks for the help, for now.
I can't validate what Claude is saying myself.
I don't want to rule out the possibility that the AI is on the wrong track again.
But see for yourself Claude's reply is below:
Hi Thor,
thanks a lot — I built your version against 67db562 and measured it side by side, and I also went and measured the bit-set idea. Both were worth the time. Long post, sorry.
Your files
1. There's a typo with real consequences. In enemies_update:
u8 is_anim = (u8)((spr >> 1414) & 1u); /* should be >> 14 */
cc900 doesn't fold this to 0 — it masks the shift count to the low nibble. I checked the generated asm:
>> 14 -> srl 0xe,HL
>> 1414 -> srl 0x6,HL (0x586 & 0xF = 6)
So is_anim becomes bit 6 of the sprite number, i.e. arbitrary per enemy. Animated enemies get drawn from spr_num (never set for them), and static ones index lvl_sspr_anim_frames[anim_idx] with an anim_idx that was never set.
2. Most of the measured gain is that typo. Cycles per game frame, emulator with hardware-calibrated wait states (cart_wait 3, vram_wait 3, ldir 14), both builds forced to the same fps mode:
scene your version with >> 14
level start −7.3 % −1.5 %
tower band (row 139) −6.3 % −2.8 %
The difference is skipped animation work, not speed.
The rest holds up well. The put_cell / put_cell_word macros taking the plane pointer plus a pre-shifted flip bit, UnsetSprite as a macro (72 far calls gone here), moving the pointer arithmetic in SetSpritePosition behind the early-out, hoisting the mirrored words out of the anim_update cell loop, the multiply out of lvl1_put_row, y + ship_hit_y[j] out of the inner collision loops, and is_static → is_anim. I checked the base++ mutations in draw_sprites — each one ends in a continue or isn't read afterwards, so they're correct, just fragile. Your patch also applies cleanly to my current tree, which has moved on quite a bit since the GitHub commit.
One small thing: put_cell_word doesn't parenthesise its parameters (flip | word, scr_ty, col). Harmless with today's call sites, but it's a trap waiting for the first expression argument.
Also just noting: your file sets FPS_MODE_DEFAULT back to 30 — I'm running 20 here, so that's not a difference on your side, I just matched them before measuring.
So: definitely useful, I'm taking it, with the shift fixed and expecting about 2 % rather than 7 %.
The bit-set idea
Measured rather than guessed, and the number is bigger than I expected.
Your premise holds: every object array here is ≤ 16 entries (bullets 4, enemies 10, enemy bullets 8, metasprite enemies 8, pickups 12, map-object bullets 16, weapon bullets 4, mines 8, worms 1, wall worms 2, balls 6), so a u16 bit set fits all of them.
Upper bound, measured: I added one extra, behaviour-neutral pass over all 79 slots, once per game frame (result into a volatile sink, so it generates no follow-on work). Cost: +4.0 %, 9394 cycles/frame. So a single full pass over all slots is 4 % — and the real code makes several passes per frame (update, draw, collision). Definitely not noise.
Two things that shrink the win: a bit set only removes the inactive visits — active objects still have to be touched. And without BS1F from C, finding the first zero bit is a loop as well, so the gain is the memory read plus the address computation, not the loop itself.
That points at the best candidate: my structs are padded to powers of two, so indexing is a shift — except TMapobjBullet (18 bytes). I tried padding that one to 32 and it measured slower, because the field offsets fall out of the 8-bit displacement and at cart_wait 3 every extra instruction byte costs three cycles. So that array does a muls per slot, and it's also the biggest one (16). That's where I'd start.
The risk is maintenance: 61 places set active = 0, 19 set it to 1, 218 read it. A bit set kept alongside the flag will desync eventually, and the symptom (leaked or double-allocated slots) is nasty to find. If I do this, the bit set has to replace the field so that every missed site is a compile error — the same trick that made your is_static → is_anim rename safe.
So: worth trying, targeted at the map-object bullets and the enemies first, and measured one at a time.
One aside: your question incidentally caught a bug in my own tooling — my cycle probe had silently stopped entering the level after I added a "GET READY PLAYER 1" screen, and was reporting "no scroll" for every ROM including the unchanged reference. Fixed.
Thanks for digging into this.

