games/dino: binary size optimization (fix #537) #632

Merged
Burer merged 37 commits from dino-rewrite into main 2026-08-14 13:51:16 +00:00
Owner
  • Size (fit into 8192 bytes or 16 IMG sectors):

    • Drop ulist in favor of static arrays
    • Dropping malloc/free/exit; sprintf, pow, strlen and 64-bit division no longer pull in libc and libtcc1
    • Dead code removed
  • Rendering:

    • libimg dropped. png2h.py turns the original PNG into a palette-indexed atlas; the blit writes a 32bpp buffer, sysfn 65 puts it on screen - the same call img_draw made. No runtime PNG decode, output pixel-identical. The buffer ends at the ground line: 18% fewer pixels per frame.
  • Bugs fixed:

    • Every fourth pterodactyl was invisible and unhittable: getRandomNumber is inclusive, so yPosArr[3] read the next field (999) and the bird spawned off-screen
    • Releasing the jump key never shortened the first jump: gated on a flag set only after the intro; Chrome gates on the loop being scheduled
    • Flap rate 1000/6 truncated to 166, minSpeed 8.5 to 8 - birds came earlier than in Chrome
    • Hardcoded caption height clipped the window on every non-default skin
    • A maximum jump left the buffer and cut off the dino's head; the playfield is centered now
    • Obstacles and clouds leaked: nodes were freed, their data never
  • Localization:

    • Caption localized to EN/RU/ES via CONFIG_LANG, fits every skin in the tree
image.png
- Size (fit into 8192 bytes or 16 IMG sectors): - Drop `ulist` in favor of static arrays - Dropping `malloc`/`free`/`exit`; `sprintf`, `pow`, `strlen` and 64-bit division no longer pull in libc and libtcc1 - Dead code removed - Rendering: - libimg dropped. `png2h.py` turns the original PNG into a palette-indexed atlas; the blit writes a 32bpp buffer, sysfn 65 puts it on screen - the same call `img_draw` made. No runtime PNG decode, output pixel-identical. The buffer ends at the ground line: 18% fewer pixels per frame. - Bugs fixed: - Every fourth pterodactyl was invisible and unhittable: `getRandomNumber` is inclusive, so `yPosArr[3]` read the next field (999) and the bird spawned off-screen - Releasing the jump key never shortened the first jump: gated on a flag set only after the intro; Chrome gates on the loop being scheduled - Flap rate `1000/6` truncated to 166, `minSpeed` 8.5 to 8 - birds came earlier than in Chrome - Hardcoded caption height clipped the window on every non-default skin - A maximum jump left the buffer and cut off the dino's head; the playfield is centered now - Obstacles and clouds leaked: nodes were freed, their data never - Localization: - Caption localized to EN/RU/ES via `CONFIG_LANG`, fits every skin in the tree <img alt="image.png" src="attachments/1f0f62bc-dadb-449d-955c-7f73b1bd6979">
8.4 KiB
Burer added the
Kind
Enhancement
AI
Category
Applications
labels 2026-08-05 07:51:09 +00:00
Burer changed title from WIP: games/dino: binary size optimization to WIP: games/dino: binary size optimization (fix #537) 2026-08-08 15:50:52 +00:00
Leency approved these changes 2026-08-09 09:02:52 +00:00
Burer marked the pull request as ready for review 2026-08-09 09:04:47 +00:00
dunkaist approved these changes 2026-08-12 09:50:52 +00:00
Burer added 37 commits 2026-08-14 13:31:26 +00:00
Store the sprite atlas as a palette-indexed array (6 colors +
transparency) generated from the original PNG by png2h.py, blit it
with a plain palette lookup and output the 32bpp back buffer through
ksys_draw_bitmap_palette, as in flpybird.

Removes the libimg dependency and the runtime PNG decode; rendered
frames stay pixel-identical. The flat 84 KiB index array kpacks to
the same size as any pre-compressed encoding, so no RLE needed.
Delete two 40000-byte debug guard arrays around the runner state,
unused ulist functions, empty graphicsInit/Destroy, the ignored blit
"center" and horizonUpdate "showNightMode" parameters, unused struct
fields and defines, and commented-out chromium JS leftovers.

No mechanics changes: every live branch is verbatim the same.
Obstacles, clouds and the type history now live in fixed arrays inside
Horizon (worst case ~5 on-screen obstacles); ulist.c and every
malloc/free/exit call are gone, which also fixes the Obstacle/Cloud
leaks (list nodes were freed, their data never).

intToStr writes digits directly instead of dragging in sprintf;
maxScore is computed by a loop instead of pow(); minSpeed becomes
double so the pterodactyl's 8.5 threshold no longer truncates to 8
(matches Chrome).

Verified: old and new horizon produce bit-identical state traces over
20000 simulated frames with a shared RNG seed.
getRandomNumber is inclusive on both ends, so yPosArr[3] read the
neighbouring multipleSpeed field (999): every 4th pterodactyl spawned
at y=999, off-screen and impossible to collide with. Chrome indexes
with length - 1.

Pterodactyl frameRate 1000/6 truncated to 166 ms by integer division;
Chrome uses 166.(6).

graphicsDelay slept a fixed 20 ms whenever less than 10 ms of frame
budget remained; round up to the next hundredth instead.
tcc has no optimizer; try gcc to squeeze the binary. The floppy image
carries no libc.dll, so link newlib statically (app.lds, -lc -lm -lgcc,
same approach as updf). The linker map lands next to the binary for
size analysis. Sources untouched; Makefile still builds with ktcc.
Rebuild LDFLAGS by plain concatenation instead of LDFLAGS:gsub(),
which dies with "attempt to call a nil value" under tup's lua.
The static newlib archives (libapp.a and friends) live in
tools/win32/lib, one level above the mingw32/lib TOOLCHAIN_LIBPATH
guess. Also define max() in misc.h: ktcc ksys.h has it as a macro,
newlib ksys.h does not, and the implicit declaration would fail at
link time.
The autobuild toolchain provides libc.dll plus import lib only; a
statically linked gcc build would need freestanding replacements for
libc, which is not worth it here. Restore the tcc Tupfile; keep the
max() guard in misc.h.
tcc emits code verbatim, so simplify at the source level: drop the
gameOverPanel struct and runner width/height fields (compile-time
constants now), hardcode the background color, merge the duplicated
trexDraw blit, and turn obstacleDraw''s x87 sourceX math into the
identical integer w*size*(size-1)/2.

Blank the moon and star sprites in the atlas (night mode does not
exist, nothing draws them): the compressed index data shrinks by
about 190 bytes and every sprite the game does draw is untouched.
tcc compiles double math into verbose x87 sequences, so precompute
what never changes: game-over panel coordinates are compile-time
constants (205/41/282/75 on the 600x150 canvas), the score coefficient
round(d * 0.025) is the integer (d + 20) / 40, floor() of always-
positive values is a plain cast, and the idle blink delay draws
directly from getRandomNumber instead of ceil(rand()/RAND_MAX * n).

Per-module .text drops from 14324 to 13289 bytes (host tcc measure);
gameplay math is bit-identical, verified by exhaustive comparison for
the score coefficient and a 20000-frame simulation for the rest.
Every 0.5-probability draw ((double)rand()/RAND_MAX vs 0.5) becomes an
integer comparison against RAND_MAX/2 - the accepted sets are provably
identical for any odd RAND_MAX. Struct fields that never change fold
into defines (distance meter x/y, horizon line geometry and bump
threshold, horizon dimensions and gap coefficient, runner msPerFrame,
per-obstacle gapCoefficient), constant parameters disappear from
horizonInit/distanceMeterInit/cloudInit/obstacleInit/trexDraw, and the
trex animation entry is referenced by pointer instead of being copied.

.text drops 13289 -> 12732 bytes (host tcc measure). Verified
bit-identical: 20000-frame simulation of the full horizon pipeline
produces byte-equal state traces before and after.
Replace the struct-by-value createAdjustedCollisionBox/boxCompare pair
with one boxesIntersect() over plain ints; collision box fields shrink
to signed char (largest value is 58). Drop the trex.msPerFrame field
duplicating currentAnimFrames->msPerFrame, fold the repeated jump-key
test into isJumpKey(), seed rand from _ksys_get_ns_count instead of
time(NULL), and store minSpeed/frameRate as float (8.5 is exact in
float; the integer timer threshold is unchanged).

.text 12732 -> 12317, .data -280. Verified: 20M randomized collision
states match the old code exactly; the 20000-frame horizon simulation
stays byte-identical.
The runner/trex/distanceMeter/horizon/horizonLine globals live in BSS,
and their init functions run exactly once, so writing zeros there is
dead weight; the restart path sets its own state and is untouched.
Config tables shrink to short (largest value is 999), the system color
table is gone - the window background is the game background color,
visible only in the few border pixels around the back buffer. Prune
dead includes while at it.

.text 12317 -> 11947, .data -136. The 20000-frame horizon simulation
stays byte-identical.
ns/1000000 was the only 64-bit division in the game, dragging
__udivdi3 out of libtcc1 into the binary. The low 32 bits of the
quotient are exactly one x86 divl of (hi % 1e6):lo - the quotient
always fits in 32 bits - so do that inline. Verified equal to the
64-bit division on 50M random values.
Autopsy of the unpacked binary showed ~3.5K of libtcc1 code linked in:
libtcc1.c builds as a single object, so one referenced helper drags in
the whole archive member. The only remaining reference was the 64-bit
shift in getTimeStamp (tcc calls __lshrdi3 even for a constant count).
Read sysfn 26.10 straight into two registers instead - no long long
arithmetic is left anywhere in the game.
The high-score string length is known when it is written (3 prefix
glyphs plus maxScoreUnits digits, zero before the first game over), so
store it instead of calling strlen every frame - that was the last
reference keeping the strlen import in the binary.
Own xorshift32 rand/srand (masked to ktcc''s RAND_MAX of 65535, the
threshold proofs hold for any odd RAND_MAX), an exact iround() that
compares the fraction instead of adding 0.5, and trunc-plus-test
expressions for the always-positive ceil sites. The import table now
holds only crt0''s exit.

Verified: iround matches C round() on 100M random values and every
half boundary within 1 ulp; the 20000-frame horizon simulation is
byte-identical. rand sequences differ from ktcc''s generator, but the
seed comes from the clock anyway.
kpack prices this the other way around: the four import entries and
thunks compressed to almost nothing, while the unique xorshift/iround
code costs real compressed bytes - the binary grew from 8317 to 8419.
Sections land in SRCS order (all .text, then all .data), so module
adjacency affects LZMA match locality. Best of 300 sampled
permutations compresses ~27 bytes better. No code change.
The python LZMA estimator predicted -27; the real kpack produced +25
(8317 -> 8342). Its match pricing differs enough that order tuning
against the estimator is noise. Back to the 8317 layout.
Incremented on every restart and game start, read nowhere: the sound
and analytics that consume it in Chrome were never ported.
The game is 7-colored, so keep palette indices in the buffer and let
sysfn 65 (bpp=8) apply DINO_PALETTE - the same dwords the blit used to
look up, so output pixels are identical. The palette grows to 256
entries (rest zero) in case the kernel reads the full 8bpp table.

Blit and fill lose the lookup, the buffer shrinks 480K -> 120K of BSS.
sysfn 65 reads palette entries per pixel index only, and the image
never holds an index above 6.
Chrome gates the post-crash restart on getTimeStamp() - runner.time
(time freezes at game over because updates stop); the port grew its
own timeAfterCrashedMs accumulator in the main loop instead, which
also lost time whenever a frame overran. Go back to the original way
and drop the accumulator.

runnerPlay and runner.paused came from Chrome''s visibility-change
pause, which was never ported: paused was only ever set together with
crashed, and the crashed branch shadows the paused one, so the whole
path is unreachable. Remove it.
Chrome''s isRunning() just asks whether the update loop is scheduled,
which is true from the first key press; the port''s hand-rolled flag
only became true after the intro, so releasing the jump key during the
very first jump never shortened it - a hidden deviation. runner.playing
matches Chrome''s meaning exactly in our loop. The restart guard the
flag also served has no Chrome counterpart and could never fail:
restart is only reachable from crashed states.
Chrome''s keydown handler calls update() and lets the pending flag
schedule the next tick; the port added a skip-one-iteration dance on
top, whose only effect was suppressing a single zero-delta update at
game start - mechanically nothing. Remove it.

Compare raw scancodes (0x100 flags the E0 prefix) instead of loading
a keyboard layout and translating through it: the four keys the game
knows are layout-independent, numpad keys stay ignored as before.
With raw-scancode comparison every byte of the Pause sequence
(0xE1 -> 0x61, 0x1D, 0x45) already lands on codes no game key uses,
so the E1 state machine protected nothing. The close button is the
window''s only button, so exit right in the handler instead of
carrying a quit flag through the loop.
The window height and the blit position hardcoded a 24-pixel caption,
so on skins with a taller or shorter one the image was clipped or
misplaced. Ask sysfn 48.4 for the caption height and let window style
0x74 make drawing coordinates client-relative, so the blit lands at
(0, 0) whatever the skin.

The playfield is 150 pixels tall as in chrome while the window is 200,
and the spare 50 sat unused below it - a maximum-height jump could
therefore leave the buffer and lose the top of the dino, the more so
because our frames are longer than chrome''s 16.7 ms. Center the
playfield instead: 25 pixels above absorb every overshoot down to
about 16 fps, and the layout is symmetric.
Rewrite the caption as DINO [JUMP - UP/SPACE | DUCK - DOWN | RESTART -
JUMP/ENTER] and add russian and spanish variants, picked by -DLANG_xxx
from CONFIG_LANG the way the other C games do it. Upper case keeps the
byte range narrow, which kpack likes.

The russian string starts with \3, the kernel encoding marker for
UTF-8 (gui/window.inc), so this file stays UTF-8 instead of CP866.
The keys are labelled in english on the keyboard, and ENTER already
stood untranslated, so translate the actions only. Also shaves 19
bytes off the caption, which the ru_RU build needs: it landed one
byte over the 8192 mark.
Give the english and spanish captions the same \3 encoding marker as
the russian one: they are pure ASCII so it changes nothing today, but
it states the encoding where the string is written and keeps an
accented spanish word from turning into mojibake later.

ПРИСЕД instead of ПРИСЕСТЬ - shorter, and a noun like ПРЫЖОК next to it.
ВВЕРХ/ПРОБЕЛ, ВНИЗ and ВВОД instead of the latin key names: ВВОД is
the traditional russian name of the Enter key, and mixing scripts was
also expensive - splitting cyrillic runs with ASCII words cost the
ru_RU build 17 bytes even though the caption got 23 bytes shorter.
The caption is clipped at (window width - skin margins) / 8 characters,
which the skins in the tree put between 63 (ConLenov/Win10) and 75; the
default Shkvorka allows 67, and the spanish caption needed 77. Drop the
spaces around the dash and use single spaces between the hints: EN 49,
RU 58, ES 63 characters, so nothing is cut on any skin, and no wording
had to be sacrificed. Spanish switches to nouns (SALTO, AGACHADA,
REINICIO) matching the russian ones.
Handing sysfn 65 palette indices moved the lookup into the kernel,
which then does it for all 120000 pixels of the buffer - six times
more than the blit touches. Keep the buffer 32bpp so the kernel copies
it as is and let the blit resolve the palette, as it did before.

The buffer now ends at the ground line: nothing is drawn below it and
the window paints that strip in its own background color anyway, so a
frame moves 18% fewer pixels.

FRAME_TIME moves to config.h and becomes 30 ms. sysfn 5 sleeps in
whole hundredths of a second, so a 20 ms target gave 22-28 ms frames
where the old rounding gave 12-38; 30 ms is a period the timer can
hold, and the rate stops swinging with load.

The atlas palette is built after the unused sprites are blanked, so
the two colors only the moon used are gone: 5 entries instead of 7.
games/dino: let the kernel fill the work area
Test PR / Build (ru_RU) (pull_request) Successful in 2m10s
Test PR / Build (en_US) (pull_request) Successful in 2m14s
Test PR / Build (es_ES) (pull_request) Successful in 2m17s
d33bb8caf6
Style bit C was set, which means "do not fill the working area on
window draw". That went unnoticed while the buffer covered the whole
window, but since it ends at the ground line the strip below it was
never painted. Clear the bit, as flpybird does.
Burer force-pushed dino-rewrite from eb1abfa1f6 to d33bb8caf6 2026-08-14 13:31:26 +00:00 Compare
Burer merged commit aa8913ea33 into main 2026-08-14 13:51:16 +00:00
Burer deleted branch dino-rewrite 2026-08-14 13:51:16 +00:00
Sign in to join this conversation.
No Reviewers
3 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: KolibriOS/kolibrios#632