How to measure where a frame goes, and what the measurement currently says. The profiler itself (flags, summary-line format, Chrome trace export) is documented in README.md; this page is the findings — a recorded baseline to compare against, and the reasoning that baseline supports.
scripts/native/build-without-nix.bash # or: nix develop, cmake --build build
SDL_AUDIODRIVER=dummy \
./scripts/quiet_alsa.bash xvfb-run -a ./build/rpg_maker_clone \
--test_play --profile --profile_interval_ms=1000 \
--profile_trace=trace.json --timeout_ms=25000 \
--game_dir data/Nepheshel206beta/Nepheshel206Rbeta --rpg2k_new_game--rpg2k_new_game is what pushes the run past the title into the map, which
is the only scene worth profiling — the title screen draws almost nothing.
Read the summary lines off stderr, or aggregate the whole run from the trace.
The trace is the better source: the stderr line only prints the top eight
sections, so the cheap ones (the whole audio path, among others) fall off the
end and look absent when they are merely small. Each frame event in the
trace carries the frame's work_ms, which is the right denominator — dividing
by wall-clock instead folds in the fps-cap sleep and understates every section.
1405 frames over 24.8s, RelWithDebInfo (the project default), 320x240,
software rendering under Xvfb. Percentages are of CPU work, not wall clock.
| section | calls | avg ms | max ms | % work |
|---|---|---|---|---|
scene.update |
1406 | 4.69 | 536.65 | 54.0% |
gfx.lvgl |
1405 | 3.75 | 15.39 | 43.2% |
map.render |
1405 | 3.16 | 21.83 | 36.4% |
map.layers |
1406 | 2.25 | 20.84 | 26.0% |
map.pictures |
1406 | 0.76 | 0.96 | 8.7% |
map.refresh_pages |
1405 | 0.09 | 0.34 | 1.0% |
input.update |
1406 | 0.05 | 0.21 | 0.6% |
map.overlay |
1406 | 0.04 | 0.29 | 0.4% |
audio.music_load |
1 | 32.64 | 32.64 | 0.3% |
map.chars |
1406 | 0.02 | 0.12 | 0.2% |
gfx.invalidate |
1405 | 0.02 | 0.10 | 0.2% |
map.animate_events |
1405 | 0.01 | 0.09 | 0.1% |
audio.update |
1406 | 0.0002 | 0.0004 | 0.0% |
Headline: 8.7ms of work per frame against a 16.67ms budget, running at 57fps. Allocation churn is ~30,000 mruby allocations/second.
This is the state after the map-layer work described below. The same run
before it sat at 20fps and 34.1ms a frame — over budget on every single
frame — with map.layers alone at 22.7ms (66.5% of work) and ~350,000
allocations/second. Those are the numbers the rest of this page reasons about,
and they are what the tile cache and Bitmap#copy_blt removed.
Note that map.layers and map.chars are inside the if @battle gate in
#render, so they stop being recorded during a fight — the map is not drawn
there at all. A profile of a battle-heavy run will show a different shape.
Two thirds of the frame was one loop, Scene::Map#draw_layers. Every frame it
cleared both chip-layer bitmaps and re-blitted the whole visible grid — 21x16 =
336 tiles, each up to two layers, each tile going through
Game::ChipsetLayout.quads, which returned a freshly allocated array (four
8x8 quarters for an autotile) that was then blitted quad by quad.
Nothing about that was conditional. The map was redrawn from scratch whether or
not the camera moved, whether or not any tile animated, and whether or not
anything on screen changed at all. That was also where the allocation churn
came from: quads is a pure function of (id, abf, cf) and was called ~670
times per frame, allocating every time.
So two independent things were being paid for on every frame:
- Recomputing tile geometry that depends only on
(id, abf, cf). - Re-blitting static scenery that did not change since the last frame.
Both are now avoided.
(1) quads is memoised on (id, abf, cf). Alone this took map.layers
from 22.3ms to 12.9ms and frame work from 33.5ms to 22.8ms (20 → 25fps), with
allocation churn dropping from ~350k to ~225k/s. Two things it needs, both of
which bit during development: quads is called with nil ids (the renderer
draws the map edge every frame), and the key has to stay inside a signed
32-bit mrb_int or it becomes a bignum on the Emscripten / PSP / Wio builds.
Resolving the tile's block first answers both.
(2) The grid is cached. #rebuild_tile_cache builds the visible tiles into
a pair of buffers on whole-tile boundaries, and each frame #draw_layers copies
those into the frame buffers at the sub-tile scroll offset. The rebuild only
re-runs when #tile_cache_valid? says something it depends on changed: the
camera crossing a tile, an animation input the visible tiles actually follow,
a Tile Substitution (via Game::Map#revision), a tileset swap, or a new map.
The events cannot be cached — they move every frame and composite into these
same buffers — which is why the cache is separate from the frame buffers rather
than being drawn to directly.
That last qualifier matters more than it looks. Game::ChipsetLayout.anim_c
steps every 6 frames but only drives the block C animated chips; keying the
cache on it unconditionally rebuilt 18% of frames instead of 4%, for an input
most maps have nothing on screen that reads. .anim_input is what tells the
two apart.
The per-frame copy then became the cost. Through #blt those two
336x256 copies measured ~5ms a frame — a third of the whole budget spent
alpha-blending onto a surface that had just been cleared. Bitmap#copy_blt
does the same job as a row-wise memcpy, taking the copy path to ~1.8ms.
(3) The upper layer's blank chip is not drawn. RPG2000's first upper-layer
id (BLOCK_F) means "nothing here", and real data is very nearly all of it —
98.45% of the 584,049 upper cells across Nepheshel's 543 maps — so a rebuild
was blitting a fully transparent chipset cell ~330 times for no pixels. That
is 28% of a rebuild (16.5ms → 11.8ms), which matters more than its share of
the average suggests: the rebuild is the spike that drops frames. Note it is a
drawing sentinel only — the blank id still indexes entry 0 of the chipset's
upper passability table, a real lookup, so ChipsetLayout.upper_blank? must
never be used to skip one.
Net: map.layers 22.7ms → 2.3ms, frame work 34.1ms → 8.7ms, 20fps → 57fps,
and 96% of frames now skip the rebuild entirely.
gfx.lvgl (3.8ms, the LVGL render and flush) is now the largest single cost
after map.layers. It has not changed in absolute terms — the layer surfaces
are still fully invalidated every frame, because the events drawn over them
change every frame.
Caching trades memory for time, so the trade was measured rather than assumed. Two things are newly allocated:
- The two cached grids,
COLS*TILE x ROWS*TILEat ARGB8888 — 336x256x4 = 336 KiB each, 672 KiB together. Fixed, allocated once with the scene. - The
quadstable. Bounded, not unbounded: its key space is the game's distinct tile ids x 3abfx 4cf. For Nepheshel that ceiling is 639 distinct ids across all 543 maps, so 7,668 entries / ~28,000 small arrays / ~2.7 MiB (measured under CRuby, whose objects are larger than mruby's) — and only if a session renders every tile in the game in every animation state. It cannot grow with play time past that.
Against that, the change removes ~97% of the engine's allocation churn, which is worth more than the caches cost. Two 3-minute runs, same game, both read at their RSS plateau:
| before | after | |
|---|---|---|
| process RSS (plateau) | 70.02 MB | 69.33 MB |
| LVGL pool, floor (retained) | 16.60 MB | 16.58 MB |
| LVGL pool, peak | 26.72 MB | 25.70 MB |
| mruby live blocks, floor | 21,806 | 21,132 |
| mruby live blocks, peak | 190,539 | 162,916 |
| allocations/second | 348,172 | 10,571 |
So there is no net memory cost — RSS came out 0.7 MB lower, and every other figure is flat or down. The 672 KiB of surfaces and whatever the quads table has filled are more than paid for by the garbage no longer being produced: with 33x fewer allocations the heap holds far less transient rubbish between collections (peak live blocks down 27,000).
Read short windows carefully here. A 40-second sample of the same pair showed the optimized build with a higher heap peak, purely because the two builds were caught in different phases of the GC's sawtooth; only at the plateau do the numbers mean anything.
This is a renderer with pixel-parity commitments (ADR 0021), so the change was diffed rather than argued. Two mechanical facts carry it:
- The coordinate mapping is unchanged. Taking the source from
(ox, oy)maps cache pixelrx*TILE + ito destinationrx*TILE + i - ox, which is exactly where the old per-tile loop drew it, and both ends clip the same way. #copy_bltonto a cleared destination equals#bltonto one, becauseblend_overagainst a fully transparent pixel returns the source unchanged. The mruby-rgss tests assert this over every alpha. The one genuine difference is invisible and worth knowing about: for a fully transparent source pixel#bltbails out and leaves cleared black where#copy_bltcopies the colour channels across. Nothing can observe it — ada == 0pixel contributes nothing to any later composite and does not render — but a byte-for-byte comparison of the two buffers would show it.
End to end, frames were captured from a build before and after the change,
driven to the same input-gated point in Nepheshel's opening (--no_render_wait
so the frame-driven waits do not desynchronise two builds running at different
frame rates). The map region is pixel-identical — zero differing pixels over
640x435, tile layers, autotiles, furniture and character sprites included.
Skipping the blank upper chip was diffed the same way and came out identical
over the whole frame, which is the direct evidence that the chip it stopped
drawing really was fully transparent rather than merely assumed to be.
scripts/rpg2k/scene_check.rb covers the behaviour directly: a frame that
changed nothing must not re-blit a single tile; a tile crossing, a Tile
Substitution and an animation step the visible tiles follow must each rebuild;
and a map whose upper layer is entirely the blank id must cost exactly its
lower layer while still answering passability through that id.
Worth repeating for anyone extending this: the failure mode of a render cache is silence. Too eager and it only costs speed, but too lazy and the picture is simply wrong, with nothing raising. The invalidation checks are the part of this work most worth keeping honest.
Every docs/adr/018*/019* entry on the bc2cpp AOT compiler (registration
completeness, ivar embedding, devirtualization) repeated the same caveat:
"compiles clean and registers" is a prerequisite for a speed win, not proof of
one, and none of them had a real linked build to measure against. This section
closes that gap with the same repro this page already uses, built twice --
once as an ordinary interpreted build, once with RPGMAKER_BC2CPP=1 (which
swaps in mruby-rpg2k-compiled/mruby-lcf-compiled/mruby-rgss-compiled,
see docs/adr/0139) -- against the identical Nepheshel New Game repro:
scripts/native/build-without-nix.bash build # interpreted
RPGMAKER_BC2CPP=1 cp932_table=... jis0208_table=... \
cmake -S . -B build-bc2cpp -G Ninja -DCMAKE_BUILD_TYPE=Release && \
cmake --build build-bc2cpp -j"$(nproc)" # compiled
SDL_AUDIODRIVER=dummy xvfb-run -a ./build/rpg_maker_clone \
--test_play --profile --profile_interval_ms=1000 --timeout_ms=25000 \
--game_dir data/Nepheshel206beta/Nepheshel206Rbeta --rpg2k_new_game
# repeat against ./build-bc2cpp/rpg_maker_cloneTwo independent 25-second trials per build, steady-state 1-second profiler windows only (the first window, dominated by the one-time New Game transition, excluded and covered separately below):
| interpreted (2 trials) | RPGMAKER_BC2CPP=1 (2 trials) |
delta | |
|---|---|---|---|
| frame(work) avg | 2.412ms / 2.386ms | 1.674ms / 1.730ms | -29% |
scene.update avg |
1.498ms / 1.496ms | 0.777ms / 0.785ms | -48% |
gfx.lvgl avg |
0.676ms | 0.653ms | -3% (unaffected, as expected -- bc2cpp never touches rendering) |
| allocs/sec | ~4000 | ~3900 | roughly flat |
Both builds sit at the 60fps cap throughout the steady state (this sandbox's
host CPU is, per this page's own repeated caveat, far too fast to feel any of
this as a displayed fps number), so scene.update avg is the real number:
it is exactly the per-frame mruby interpreter/game-logic section every
registration-completeness and devirtualization ADR in this series targeted,
and it is now consistently, reproducibly essentially half its interpreted
cost across two independent trials each. This is the first real evidence
that the ~440 methods docs/adr/0190 moved from interpreted to compiled, and
the analysis-soundness fixes docs/adr/0188/0191/0192 made along the way,
add up to a genuine, measurable win on the exact code path they targeted --
not just a compiles-clean-and-registers proof.
One honest counter-datum, not swept under the rug: the one-time New
Game/Continue transition itself (map.transition.party/
map.transition.common_events, Game::Party.new building the starting
roster and Game::CommonEvent.load, both now largely compiled per
docs/adr/0190's Game::Party/Game::Actor wiring) was consistently
higher, not lower, under RPGMAKER_BC2CPP=1 across both trials
(map.transition.party: 152.71ms/161.53ms interpreted vs. 174.80ms/178.57ms
compiled; map.transition.common_events: 92.74ms/96.76ms vs.
102.30ms/103.47ms). This is a single n=1 section per run (it only happens
once per session), so it is far less statistically solid than the
steady-state numbers above, but it reproduced in the same direction twice and
is worth stating plainly rather than only reporting the favorable half of the
measurement. A plausible explanation this page does not confirm: this page's
own earlier finding is that the transition's cost is dominated by
LCF::Array2D's lazy byte-scanning decode of the item/common-event tables
(the StringIO#getbyte/read_row_bytes fix earlier on this page), a cost
bc2cpp's method-level devirtualization does not touch at all, while embedding
Game::Actor's ivars into a real C++ struct adds a fixed mrb_data_init cost
to every construction on this one-time, construction-heavy path with nothing
to amortize it against (unlike the steady-state frame, run tens of times a
second). Not investigated further here -- a real follow-up, isolating
construction cost from table-decode cost with the map.transition.* sections
already in place, before assuming which one actually explains it.
The flash-limited builds (psp, wio, maix) compile only the methods
tools/bc2cpp/hot_methods.txt lists to C++, and every other method runs as
bytecode (ADR 0214). Regenerate the list after a change moves hot code
around. A method the list does not name is interpreted: always correct, but
slower. scripts/bc2cpp/hot_only_check.rb fails when the list names a method
that no longer exists. It is not run in CI, so run it by hand after editing the
list.
# 1. A full-compile build (BC2CPP_HOT_ONLY=0 turns the closed-world build's
# hot-only mode off). The mruby gems build with -g, which the attribution
# below needs.
RPGMAKER_BC2CPP=1 cp932_table=... jis0208_table=... \
cmake -S . -B build-full -G Ninja -DCMAKE_BUILD_TYPE=Release && \
cmake --build build-full -j"$(nproc)"
# 2. Record every scenario under callgrind (Nepheshel first; each further
# RPG2000/2003 game gets a boot run and a walk/menu run). This takes about
# an hour on 4 cores.
ruby scripts/bc2cpp/hot_profile.rb record --binary build-full/rpg_maker_clone \
--out /tmp/hot --game data/Nepheshel206beta/Nepheshel206Rbeta \
--game <other game> ...
# 3. Select the list: 98% of compiled-code Ir (ADR 0214 has the tradeoff
# table), then the classes' #initialize and the keyword/super callees
# those methods need.
ruby scripts/bc2cpp/hot_profile.rb select --gen-dir build-full/mruby/host/mrbgems \
--threshold 0.98 --report /tmp/hot/callgrind.*.out > tools/bc2cpp/hot_methods.txtEach scenario is an RPG2k#main_loop count driven by --script, not a
wall-clock --timeout_ms, so callgrind's slowdown does not change the work
done. The scenarios are: map idle, battle play against troops 1 and 6,
walking plus every main-menu screen, .lsd save/load, a battle
animation, and a boot and a walk for each extra game. Self Ir is attributed by
source line of the generated *_gen.cpp, so an _impl inlined into its
caller still counts for its own method. Block-fallback, rescue-try and
nested helpers count for the method that owns them. A synthesized
struct-field accessor counts for its class's #initialize, since the
accessor exists only while that constructor compiles. That keeps record
classes such as RPG2k::Scene::Map::MapEventState embedded.
To check a new list, build the closed-world target (hot-only by
build_config.rb, the same as the flash builds) in a fresh build directory and
compare its Ir under callgrind with the BC2CPP_HOT_ONLY=0 build. The engine-binary
Ir is the stable figure: SDL's software blit under xvfb swings from run to
run. Also confirm that bc2cpp's == hot-only: listed but not compiled ==
section is empty.
BC2CPP_PROFILE_TIMINGS=1 makes bc2cpp print phase and analysis timings to
stderr. The whole-program report runs both the analysis and shipped-output
passes, so it reports each pass separately:
BC2CPP_PROFILE_TIMINGS=1 MRBC=build/mruby/host/mrbc/bin/mrbc \
ruby scripts/bc2cpp/coverage_report.rbBC2CPP_COVERAGE_KEEP_DIR=dir keeps the shipped pass's generated C++
(shipped.cxx) and its stderr (shipped.stderr), for a digit-masked line
multiset diff of two compiler revisions (difflib does not cope with a 20 MB
file).
The BC2CPP_TIME rows show broad pipeline phases; BC2CPP_DETAIL rows split
the expensive whole-program analyses. Timings are opt-in and do not affect
generated C++. Both ArgTypes and ClassArgTypes use the same call-site index,
so each MONO method visits only its own callers rather than rescanning every
instruction in the program. On the three-gem coverage workload, this reduced
one bc2cpp pass from about 91 seconds to about 16 seconds while keeping the
coverage totals unchanged.
To find hotspots inside a phase, sample bc2cpp itself with stackprof (gem install stackprof; StackProf.start(mode: :cpu, interval: 2000) from a
RUBYOPT=-r preload that dumps StackProf.results at exit, and
stackprof --sort-total dump). Compare CPU time (/usr/bin/time user), not
wall time, and interleave before/after runs: the machine is shared. The
whole-program run compiles each method about three times (a compiles_clean?
probe in the analysis CodeGen, another in the final CodeGen, then the real
compile), which is by design, so the cost is per-instruction compile work plus
the garbage it makes (about a quarter of the time is GC). Regexps that start
with (\w+) have no literal prefix for Onigmo, so guard scans over native
sources with include?, and index non-ASCII text through an ASCII view rather
than by character.
The same report counts cached send references separately from generic POLY
sites: cached references include fallback arms attached to direct-call guards,
so they are not a count of unresolved dispatch. Its method-name ranking uses
only generic POLY markers. It also cross-tabulates those sites by dispatch
path and receiver-class evidence, which helps separate unresolved tracing from
missing or unsafe targets. For unresolved receivers it records the nearest
defining opcode behind the receiver register (following MOVE aliases), so
large groups such as constant lookups, method results, indexed values and ivar
reads can be prioritized for a later proof without changing dispatch behavior.
For the largest origin groups it also lists the most frequent dispatched names,
which helps distinguish a repeated class-method opportunity from unrelated
receiver-tracing gaps.
For /, generated sends also test for an exact Float receiver and use Float's
native division body on that branch; non-Float receivers and Complex operands
retain ordinary Ruby dispatch. This lets the report distinguish a generic
receiver site from a guarded fast path without removing its fallback.
Closed-world constant-object calls can also trace across branches when every
forward edge either reaches the constant assignment or skips the call; a
branch-selected receiver keeps ordinary dispatch.
This report runs under the closed-world proof of the closed build with the same gem map used by
the compiled gems. It allows the NOMETHOD_REVIEWED audit to report findings
without aborting the measurement; that environment flag does not change
generated code. The dispatch totals therefore describe closed-world output,
rather than a non-closed-world all-owner analysis.
Short version: the audio processing is already on another thread, and it
was never the bottleneck. SDL_mixer decodes, synthesises and mixes on the
audio device thread that Mix_OpenAudio starts. Observed directly by counting
/proc/self/task around the call:
| point | threads |
|---|---|
before Mix_OpenAudio |
1 (main) |
after Mix_OpenAudio |
2 (+ SDLAudioP2) |
while a .mid plays |
5 (+ 3 TiMidity/decoder workers) |
What genuinely runs on the game-loop thread is only the control surface, and the profile prices it at essentially nothing:
audio.update— the per-frame backend poll (Mix_PlayingMusic, to resume a BGM after an ME): 0.0002ms/frame, 0.1ms total across a 25s run.audio.resolve— the Ruby asset search inRGSS::Audio.resolve: 0.23ms, and only on an actual Play command.Mix_PlayMusic/Mix_PlayChannel: 0.02ms.
So moving "audio processing" to a worker thread would move work that is already elsewhere, and would buy ~0.0002ms/frame. It was never where the frame went — not at the 34.1ms this page originally measured, and still not at 8.7ms.
One audio call does block the game loop, and it is the load, not the playback. Measured against Nepheshel's own files (SDL2_mixer 2.x, FreePats patch set):
| call | cost on the calling thread |
|---|---|
Mix_LoadMUS on a .mid |
20–30ms |
Mix_LoadMUS on a .wav |
0.05ms |
Mix_PlayMusic |
0.02ms |
Mix_LoadWAV (SE, first play) |
0.66ms |
A MIDI load is a 1–2 frame hitch, and Nepheshel is 143 .mid tracks, so every
BGM/ME change pays it — including every ME, which reloads the interrupted BGM
when it finishes (maybe_resume_bgm). SE are cached after first decode
(g_chunks), so they cost 0.66ms once per distinct sample and nothing after.
This is a real, if narrow, target: a load/decode worker thread that hands the
finished Mix_Music/Mix_Chunk back to the main thread to start. It removes a
visible stutter on BGM change. It does not improve steady-state frame rate,
because there is no steady-state audio cost to remove.
Note this is the same class of bug already fixed once here: Mix_GetMusicPosition
costs hundreds of ms per call on the MIDI decoder, and polling it every frame
for the "BGM played once" check once dragged this same game to under 2fps. It
is now answered from the clock instead (see g_music_start_ms in
src/sdl_audio.cxx). The lesson generalises — on this backend the expensive
audio calls are the ones that touch the decoder, not the ones that mix.
- The browser build is single-threaded. The Emscripten link options in
CMakeLists.txtcarry no-pthread/-sUSE_PTHREADS, and pthreads in wasm additionally needSharedArrayBuffer, i.e. COOP/COEP headers on the deployed page. Since GitHub Pages is the primary distribution, a worker thread has to be conditional, with the synchronous path kept for wasm. - PSP and Wio have no usable
std::thread.mruby-rgss/src/terminal.cxxalready documents and handles exactly this: its background writer thread is compiled out with#if !defined(PSP_BUILD) && !defined(WIO_TERMINAL). Any audio worker should follow that same shape. - SDL_mixer's own API is not thread-safe across arbitrary calls. The load
can move;
Mix_PlayMusic/Mix_PlayChanneland theg_chunkscache should stay owned by one thread.
Everything measured above used SDL_AUDIODRIVER=dummy on the native build,
where "the audio thread" is a real OS thread SDL_mixer owns outright. That
does not hold in the browser: with no -pthread/-sUSE_PTHREADS and no
-sAUDIO_WORKLET (CMakeLists.txt's if(EMSCRIPTEN) block never sets
either), Emscripten's SDL2 port falls back to a ScriptProcessorNode, whose
callback the Web Audio spec requires to run on the main thread — the same
one emscripten_set_main_loop drives the whole game loop on.
Graphics.update's frame-pacing block (mruby-rgss/src/lib.cxx, gfx_update)
used to enforce 60fps with a real blocking wait — lv_delay_ms, backed by a
plain OS sleep/spin — every single frame, sized to whatever was left of the
16-17ms budget. On desktop that costs nothing but wall clock; in the browser
it synchronously froze the one thread the audio callback also needed, which
is a textbook cause of audible delay/glitching that has nothing to do with
decode cost. It was also worse than it needed to be on a >60Hz display:
emscripten_set_main_loop(main_loop, 0, 0) used requestAnimationFrame,
which calls back at the display's own refresh rate, so a 120Hz/144Hz screen
ran the whole block — including this sleep — more often than the 60fps game
logic wanted.
The fix keeps the 60fps cap but stops enforcing it with a blocking call under
Emscripten: emscripten_set_main_loop(main_loop, 60, 0) (src/main.cxx) asks
Emscripten to pace the calls itself via its setTimeout-based scheduling
instead of raw vsync, which yields back to the browser's event loop between
frames instead of occupying it — and gfx_update's own lv_delay_ms call is
#ifndef __EMSCRIPTEN__, so the deadline/carry-forward bookkeeping that keeps
frame timing accurate still runs, but nothing blocks the JS thread on top of
Emscripten's own (already non-blocking) pacing.
The first pass here shrank Mix_OpenAudio's buffer for __EMSCRIPTEN__
(2048 → 1024 samples) on the theory that, with the blocking sleep gone, a
smaller buffer would just mean less baseline round-trip latency. That is
backwards for this backend, and the buffer is now larger instead (2048 →
4096): ScriptProcessorNode's callback has a well-documented failure mode
where, if the main thread does not hand it the next buffer in time, it does
not drop the missed buffer and resync to the clock -- it fires late and
stays that late, and the lateness compounds on every further stall until
the page reloads (this is one of the reasons the API is deprecated in favour
of AudioWorklet; see the Chromium/Firefox/spec discussion linked from
MDN's AudioWorklet guide
and WebAudio/web-audio-api#253).
A smaller buffer gives the main thread less slack before a given stall
crosses that line, not more.
Holding a movement key is close to the worst case for this, reported directly as "significant audio delay on key press hold" after the pacing fix above shipped: it is sustained main-thread cost rather than one spike -- every one of those frames pays the ordinary camera/animation/collision work, and periodically also the tile-crossing cache rebuild (the multi-millisecond spike documented earlier on this page) -- so it is repeated, compounding chances to miss the deadline, and several seconds of held input can build up a delay far more noticeable than a few extra milliseconds of fixed latency would be. A bigger buffer cannot make any one stall shorter, but it makes a given stall much less likely to actually cross the deadline in the first place, which is the only lever available without a real audio thread to mix on.
A real fix would still be AUDIO_WORKLET, which runs the audio callback on
its own thread outside the main JS thread entirely and would remove this
failure mode rather than just making it less likely. That was not attempted
here: it needs Wasm Workers, and SDL2's own Emscripten port does not
currently build with -sWASM_WORKERS/-pthread at all --
emscripten-core/emscripten#19667
tracks SDL_atomic.c.o failing to link because the vendored SDL2 build
lacks the atomics/bulk-memory target features either path requires. Worth
revisiting if the buffer bump above turns out not to be enough in practice,
but it is a real architectural change (a different SDL2 build, and this
engine's own single-threaded assumptions reaching across a worker boundary)
that deserves its own investigation and ADR rather than a speculative
attempt with no way to test it against a real browser from this repo's CI.
This whole section is reasoning from how the browser's audio and event-loop model works, not a browser-measured profile — the caveat below about this page's numbers being native/Xvfb-only applies doubly here, since none of it was ever measured against real Web Audio callback timing. If audio in the browser is still audibly delayed after this, that measurement -- ideally captured while holding a direction key, the case that surfaced this -- is the next thing to get, not another guess from the native numbers above.
Reported next, after the pacing and buffer fixes above: audio still glitches on a scene transition (walking onto a map-exit tile). This is not the same bug wearing a different hat -- it is the same underlying constraint (a stall on the single JS thread starves whatever ScriptProcessorNode callback is due, and per the buffer discussion above that lateness does not resync on its own) hitting a stall an order of magnitude larger than anything a buffer sized for per-frame jitter can absorb.
A map-to-map transition (Scene::Map#perform_teleport,
mruby-rpg2k/mrblib/scene/map.rb) runs as one synchronous block inside a
single scene.update frame -- nothing about it is spread across frames.
In order: the destination .lmu is parsed fresh (RPG2k#load_map), the map's
BGM is resolved and started if it changed (play_map_bgm -- a .mid change
alone costs the 20-30ms Mix_LoadMUS figure from earlier on this page), the
destination's chipset graphic is decoded (load_chipset_graphic, skipped
only when the tileset id happens to be unchanged), and every one of the
destination map's events and Common/map Parallel Processes is rebuilt
(build_events, build_parallels). None of this had its own profiler
section before now -- it was invisible inside the umbrella scene.update
bar. It does now: map.transition.load, map.transition.bgm,
map.transition.chipset, map.transition.build_events and
map.transition.build_parallels, at both call sites (Scene::Map#initialize
for a fresh map entry/Continue, and #perform_teleport for an in-session
Transfer Player/Teleport/Recall to Location).
The baseline table at the top of this page already has the relevant number,
uncommented on until now: scene.update's 536.65ms max, 114x its own
4.69ms average, on a 1405-frame run that is a single continuous
--rpg2k_new_game session -- i.e. one frame paid for something the rest did
not. Nepheshel's own opening is a long camera pan that ends in exactly one
Teleport into the first room (perform_teleport's comments describe this
same sequence twice), which is consistent with this outlier being that
transition. 536ms is ~5.75x the ~93ms of slack the 4096-sample wasm audio
buffer provides, on hardware faster than a browser's wasm execution -- nowhere
close to survivable by sizing a buffer, which is the only lever the fix above
had available.
Not fixed here. Unlike the frame-pacing and buffer work above, closing
this gap means either making the transition itself faster (the four new
sections above finally make it possible to find out which of load/chipset
decode/event-build actually dominates, rather than guessing) or spreading it
across several frames behind the fade so no single one blocks the thread for
that long -- both real engine changes, not a config tweak, and neither
should be attempted blind the way the frame-pacing fix's first pass already
had to be corrected twice. Reordering play_map_bgm to run after the heavy
work instead of before was considered and deliberately not done: the stall
itself is what starves the audio callback regardless of which track is
nominally playing, so moving the BGM call only changes which track's
in-flight audio gets cut and does not shorten the stall -- indeed it risks
being worse, briefly resuming the old track for a few audio callbacks right
before cutting to the new one, versus the current clean silence-then-new-track
result. The right next step is measuring with the new sections against a
save positioned right before a map exit, then deciding what to shorten or
defer from real numbers, the same way map.layers was fixed earlier on this
page.
Measuring per the plan above (Nepheshel, --rpg2k_new_game, the new sections)
found the New Game/Continue transition dominated by two sections that
#perform_teleport never even runs: map.transition.party (Game::Party.new
building the starting roster) at ~230ms, and map.transition.common_events
(Game::CommonEvent.load) at ~140ms -- together over 90% of a ~400ms
scene.update outlier. Bisecting with temporary profiler sections narrowed
map.transition.party's cost almost entirely to Game::Actor#recompute_stats
-> #equip_bonus -> the first db.item[id] lookup, which one-time-decodes
Nepheshel's whole 1200-row, 48KB item table -- and that first decode alone,
under plain CRuby (a mruby-lcf/mrblib/lcf.rb + schema.rb load, the same
"exact sources under CRuby" trick scripts/lcf/testbed_check.rb already uses)
took 37ms against mruby's ~220ms for the identical bytes. A 6x gap that size
on a byte-scanning loop is not "mruby is slower," it's a missing native
method.
LCF::Array2D#read_row_bytes (mruby-lcf/mrblib/lcf.rb) walks a table's
(id, len, payload) chunk stream forward-only, one BER-encoded integer at a
time (LCF.read_ber), for every row of every table on its first touch
-- capturing raw byte spans without decoding them, per the lazy-Array2D
design above, but still touching every byte to find the boundaries. Two
independent inefficiencies stacked on that walk:
StringIOhas no nativegetbyte.lcf.rbused to supply one itself,getc.getbyte(0), riding onStringIO#getc(3rd/mruby-stringio/src/stringio.c) -- which allocates and returns a fresh one-characterStringon every single byte read.IO/Filealready has a proper nativegetbyte(io_getbyte,3rd/mruby/mrbgems/mruby-io/src/io.c) returning a bare Integer with no allocation;StringIO-- what every nested chunk (Array1D.new/Array2D.newwraps its bytes in one,s = StringIO.new s if s.is_a? String) is actually read through -- never got the equivalent. Only the outermostLCF::Database < Fileread skipped this tax; everything else paid it, on every field, of every row it ever had to scan a boundary through.read_row_bytesrebuilt its captured span without = out + write_ber(idx)/out = out + write_ber(len) + s.read(len)-- each+allocates an entirely new String and copies everything accumulated so far, rather than extending the existing buffer in place.
Fixed both, narrowly:
- Added a real
StringIO#getbyte(stringio_getbyte,3rd/mruby-stringio/src/stringio.c), mirroringstringio_getcbut returning the byte as an Integer with no String allocation.lcf.rb's owngetbyteshim is gone;ungetbyteis untouched (it still needs to build a one-character String forungetc, called at most once, never in this hot loop). Declared as a properadd_dependency 'mruby-string-ext'inmruby-lcf/mrbgem.rakefor the<<below (AGENTS.md's own rule on this: a stdlib method a gem's own per-gem test build won't otherwise have), not left implicit on the strength of the full game build pulling it in transitively throughmruby-onig-regexp/mruby-marshal. - Switched
read_row_bytes's threeout = out + ...accumulations to<<(in-place, amortized-O(1)append).read_row_bytesis explicitly forward-only/no-#seekalready (see its own comment above -- a#seekdesyncs mruby-io's read-ahead buffer against a realFile's fd position), so this is the one safe lever left on that loop without reopening that finding.
Both changes are StringIO/LCF-level, not RPG2000-specific -- every table
this project ever lazily decodes (items, actors, common events, maps, ...)
walks through the same getbyte/read_row_bytes path, so the fix is not
scoped to New Game. Measured effect on the same Nepheshel
--rpg2k_new_game repro: map.transition.party 232ms -> ~83ms,
map.transition.common_events 138ms -> ~50ms, and the worst single
frame over the run ~410ms -> ~145-150ms (three separate runs, all in that
range) -- roughly a 64% cut, without touching any RPG2000 game logic.
Still not fully closed. ~145ms remains above the 4096-sample buffer's
~93ms of slack, so a New Game/Continue transition can still audibly glitch,
just far more briefly than before. The remaining cost (~83ms actor
construction, ~50ms common-event metadata) is now genuinely proportional,
one-time work -- decoding Nepheshel's real item/common-event tables at
mruby's real interpreted speed, not a rebuildable inefficiency -- so the
only lever left is the one already named above and not yet attempted: make
Scene::Map#initialize resumable and spread this across several frames
behind a fade, which is a materially larger, riskier engine change than
this fix and is left as its own follow-up rather than folded in here.
Separate from frame time: how many mruby objects the map scene allocates
each frame, tracked because a string of fixes (skip_to's terminator arrays
hoisted to frozen constants; Scene::Map#events_dirty?/
#record_map_event_positions reusing Arrays instead of rebuilding them every
frame; #step_parallels and four other @events.each loops rewritten as
block-free while loops; Game::Interpreter#range returning a Range
instead of a throwaway Array; Array#include? given a native-equivalent
override because mruby's own falls through to a block-allocating
Enumerable#include?) cut it by well over half across several rounds — see
changelog.d/ for each one's own measurement.
Measured the same way as the time baseline above (same command, same
Nepheshel run), reading RGSS::Profiler.stats[:object_types]'s cumulative
:allocs per type at two points in a steady window rather than the summary
line's live counts (those rise and fall with GC and cannot answer "how many
were allocated between two points"):
| type | allocs/sec |
|---|---|
Array |
~4000 |
Proc |
~1650 |
env |
~985 |
scripts/rpg2k/alloc_regression_check.rb automates exactly this measurement
(via the profiler trace's mruby_type_allocs counter series, see
README.md) and fails if any of the three exceeds a
ceiling set from this table — the standing regression guard for the fixes
above, run in CI alongside the other RPG2000 boot smokes. Re-run it with
--report to see fresh rates without asserting, e.g. after a deliberate new
per-frame feature that genuinely needs to raise a ceiling (with a comment in
the script saying why), or while investigating whether a change regressed one.
- Software rendering under Xvfb with
SDL_AUDIODRIVER=dummy. Absolute milliseconds will differ on real hardware; the ratios are what to compare. --profileand friends are test-play-only. Without--test_play(orGame.ini[Game] Test=1) the flags are parsed and then ignored, and the run prints no profiler output at all.- The stderr summary caps at eight sections. Prefer the trace when you care about anything cheap.
- One game, one scene. The RPG XP / VX / MV runtimes have their own scene code and are not covered by this baseline.
Build the closed world with BC2CPP_TRACE_RECV=1, run a workload with
BC2CPP_TRACE_RECV_OUT=<file>, then read the dump with
ruby scripts/bc2cpp/recv_trace_report.rb <file>. Every bc2cpp_send and
mrb_funcall_with_block in a generated method body whose receiver is a bare C
identifier records its receiver's class (and kind of Array/Hash/String/Range).
The report ranks sites by calls and counts single-class sites by kind: those are
the candidates for a direct native call. Receivers that are not a bare identifier
are counted as skipped. The flag cannot be combined with BC2CPP_TRACE_ELSE=1.
scripts/bc2cpp/recv_trace_check.rb checks the rewrite and the report without
an mruby build.