Elite Beat Ouendan: Music LIVES!

Elite Beat Ouendan: Music LIVES! is a Windows port of Elite Beat Agents and Osu! Tatakae! Ouendan 1 and 2 with controller support and display options.

README

Elite Beat Ouendan: Music LIVES (eba-recomp)

Elite Beat Agents, Osu! Tatakae! Ouendan and Moero! Nekketsu Rhythm Damashii Osu! Tatakae! Ouendan 2 on Windows, recompiled from their own code to native PC programs, with a launcher, Tap mode for controllers (rumble, DualSense triggers), display and look options, assists and No-Fail, all three games alike.

You need your own cartridge dumps. Nothing of the games is in this repository or its releases: the launcher asks for each game's .nds dump (EBA USA, Ouendan 1 and 2 Japan) and checks it. The release zip is on the Releases page: unpack it anywhere, run eba-recomp.exe, point each game at its dump, play. Windows 10 or 11, 64-bit, a graphics driver with OpenGL 4.5. The README in the zip covers controls and options.

The project's own code is MIT (LICENSE). Built on ndskit (a static recompiler and runtime for the Nintendo DS, MIT), SDL3, Dear ImGui and rcheevos. Not affiliated with Nintendo or iNiS.

Everything below is the developer's notes.


eba-recomp

Static recompilation of Elite Beat Agents (USA, AOSE), Osu! Tatakae! Ouendan (Japan, AOSJ) and Moero! Nekketsu Rhythm Damashii (Japan, AO2J) to native PC code with ndskit (submodule at ndskit/). The ndskit submodule needs the patches in patches/ applied before building (see "Local ndskit patches").

Status (2026-10-22)

All three games boot headless with no interpreted functions, through the title, menus, stage map, a stage's intro comic, gameplay and the fail screen, with the real saves loaded and the music in the WAV.

EBAOE1OE2
ARM9 functions4,517 (6 refuse passes)3,2264,086 program + 1,191 in 7 overlays
ARM7 functions1,0261,0041,026 (EBA's ARM7)
unresolved calls / jumps (ARM9 program)841 / 23437 / 3691 / 15
self-test vectors, game functions24,346 (1,614 functions)18,280 (1,183)28,690 (1,866; 4,690 through the overlay dispatch)
failures000
save256 KiB flash8 KiB EEPROM256 KiB flash
stage played"The Anthem""ループ&ループ""サムライブルー"
windowed playtiming and audio right (user)timing and audio right (user)works great (user), through the overlay swaps into a song

Plus ndskit's instruction test: 57,781 vectors, 0 failures. OE2's overlays change hands as the game goes (6 then 1 at boot, 0 for the stage, 5 for gameplay, 6 again at the fail screen), each found by the runtime. Known runtime gap seen: 3D edge marking is not drawn (the EBA and OE2 stage maps).

The options bar (all three games)

EBA's window has a menu bar (src/eba_ui.cpp, Dear ImGui in third_party/imgui, v1.92.9b):

  • Game: screenshot (F12), quit (Esc).
  • Display: resolution (Auto, or 1x to 6x), layout (vertical, side by side, top screen only, bottom screen only; F9 cycles), frame rate (DS, 60, 120, 240, uncapped), Vsync (Auto: on when the display's refresh rate suits the frame rate; On: always, no tearing; Off: never, the least latency; --vsync auto|on|off), fullscreen (F11, Alt+Enter), low latency.
  • Look: the filter. Skins and HD approach circles are switched off for 1.0 (kSkins in src/eba.h) until a full HD skin is made (see Future updates).

No menu has submenus (in a small window ImGui puts one over its menu): each choice is a list under a grey heading.

  • Input: button mode on or off, rumble and its strength, up to four hit keys (click one, then press a key; right-click clears), the connected gamepads, the key map.
  • Timing: the offset (-8 to +8 frames), the sound's latency (1 to 6 frames), and Calibrate..., a window that counts every tap the game judges by how many frames after its note it came (before the offset): a histogram, the average and spread, the grades, and Apply to set the offset to the average (from 20 taps).

Every change is made at once and saved in %APPDATA%\eba-recomp\EBA\settings.ini (SDL's folder for the user's settings); ndsrun's own options win for a run that gives them, and headless runs ignore the file. In a window the bar sits above the screens and the window makes room for it; in fullscreen it shows while the mouse is near the top; F1 hides or shows it. While the mouse is on the bar or a menu, it is not the stylus, and Esc closes the menu, not the game.

Timing offset (all three games)

The judge (0x020681D8, every tap and slider head; spinners are not timed) is recompiled with a hook (--hook 020681D8); src/eba_timing.cpp gives it the song's clock minus the offset, so a tap made N frames late is judged as on time. The picture and the music are untouched, so it suits a player who follows the sound or the picture alike. Checked headless with the button mode's test player pressing every note 4 frames late: offset 0, every note a 100 and the stage failed; offset 4, rank S, 258 of 258 at 300. Options: --port offset=N, --port timing-log (every judged tap). The playtest that asked for it: 241 presses averaged 1.8 frames late (about 30 ms), 99 of them 100s.

Display options (EBA's window; any ndskit game)

build/recomp/b/ndsrun roms/EBA.nds --save build/play/EBA.sav --window --scale 3 --fps 60 --layout side

All of these also change while the window runs (the options bar, or F9 and F11).

optionwhat it does
--layout stacked|side|top|bottomthe screens one above the other (default), side by side (for wide displays), or one alone. The stylus follows the lower screen wherever the layout puts it (none with the upper alone).
--low-latencywaits for each picture to be drawn before the next (glFinish), so the driver queues none ahead of the display.
--audio-latency Fthe sound queued ahead of the device, in frames (3 by default; 1.5 held without a dropout at 60).
--scale Ncomposes both screens at N times the DS's resolution (1-6): 1x 256x384, 2x 512x768, 3x 768x1152, 4x 1024x1536, 5x 1280x1920, 6x 1536x2304 (the first to cover a 4K display's height). The 3D draws at that resolution; the 2D layers stay the DS's pixels, sharp. The window opens at that size, fitted to the display; a larger window gets a sharp-bilinear upscale, a smaller one the mean of the pixels it covers (supersampling). Without --scale the picture follows the window's size, as before.
--filter sharp|bilinear|bicubic|softthe picture's filter, on the DS's pixels (Display > Filter): sharp as before; bilinear soft; bicubic (Catmull-Rom) smooth but fairly crisp; soft (a cubic B-spline) the most blended. The 3D is resampled with the 2D.
--fullscreenthe whole display, letterboxed at the DS's shape. F11 or Alt+Enter toggles it.
--fps dsthe default: the DS's 59.83 frames a second, presented with the vertical sync (on a 60 Hz display one frame shows twice every 5.8 s).
--fps 60 / 120 / 240the game at exactly 60 frames a second (0.29% faster than the DS, the sound resampled with it: about 5 cents, inaudible), each frame shown 1, 2 or 4 times. On a display at that refresh rate the vertical sync is the clock, so every frame lasts the same: no judder. On another display, paced by the clock.
--fps uncappedthe DS's pace, pictures as fast as the GPU draws them, without the vertical sync.

The title bar shows the last second's frames and pictures. The log ends with the run's averages (after its first 60 frames), how many frames came late, the longest, and the longest the game itself took to run one; any stall over 50 ms is noted as it happens. F12 also saves the composed picture at full scale (shotN-3x.png).

Measured on this PC (RTX 4060, display 2715x1527 at 60.000 Hz, NVIDIA's "Vertical sync" left to the application), 20 s from the boot each:

--fpsgame frames a secondpictures a second
ds59.80 to 59.8360, with the sync
6060.00, no frame late60.0, the sync the clock
12059.95120.0
24059.95235.8
uncapped (fullscreen, 4x)59.732,166

The game itself never took more than 6 ms for a frame. On this 60 Hz display, 120 and 240 run without the sync, so they can tear; on a display at 120 or 240 Hz they lock to it the way 60 does here. Above 60, each extra picture repeats the game's last frame: the DS draws nothing between frames. The planned HD note layer will draw the hit circles, approach circles, slider balls and spinners between frames, which is what makes 120/240/uncapped smoother. If a driver keeps the vertical sync on against the runtime's request (NVIDIA Control Panel's "Vertical sync: On" did), the log says so.

Button mode (all three games)

For players without a touchscreen: any face button of an Xbox, PlayStation or Switch controller, or Space on the keyboard, in place of the stylus during a song. It is on in EBA's build (src/eba_buttons.cpp, linked into ndsrun by cmake/eba.cmake).

  • Hit circle: press on the beat. The stylus touches the note the game is judging, for two frames; the game judges it as its own tap, with its own windows (300 at +-2 frames, 100 at +-5, 50 at +-8, and a press more than 8 frames early is a miss, as with a stylus).
  • Slider: press on its head and hold; the stylus follows the ball, a path point a frame, through every pass and the tail.
  • Spinner: mash. Each press spins the stylus faster around the spinner (2 turns a second per press, halving in 0.4 s without presses, at most 10 a second); about 6 presses a second keeps up with the hardest spinners. Or turn the left stick round (past 60% of its throw): the stylus turns with it, 1.5 times as far, either way, on top of any presses (at most 18 turns a second). Checked headless (stick-auto=N, a stick turning N times a second): The Anthem with the stick at 5 a second scores 3,349,920, the same as mashing; held still, 3,322,660 (the spinner's bonus lost). Ouendan 2's first stage: 2,947,250 by stick, 2,949,450 by mashing. Or mash the triggers (L2, R2: a press past 60% of the pull, released under 35%), counted as presses on a spinner only. A DualSense's adaptive triggers push back while a spinner turns (Input > DualSense trigger resistance, on by default): continuous resistance (SDL's DualSense effect report, mode 1) of 120 (of 255) at the start, easing slowly with the spinner's speed (smoothed over about 2 seconds) to none at full speed; off after it. Headless (buttons-log): at 7.5 presses a second it eases 120 to 40 over 115 frames and is still easing when the finale spinner ends (it was 200 to 0 over 80 frames).
  • The mouse still works and wins while it is down. Outside a song the face buttons are the DS's (south B, east A, west Y, north X), for the menus.

Options, each as --port ...: buttons=off; button-keys=Space,F,J (SDL key names); buttons-log (every press, the note it hit and how many frames off); buttons-auto (a test player: presses on every note's frame, holds the sliders, mashes the spinners; with stick-auto[=N] it spins them with a left stick turning N times a second instead).

build/recomp/b/ndsrun roms/EBA.nds --save build/play/EBA.sav --window --scale 3 --fps 60 --port buttons-log

Checked headless: buttons-auto from the boot through The Anthem on Hard Rock, everything through button mode: rank S, 258 of 258 at 300, max combo 408, the stage cleared. With no button pressed, runs are byte-identical to before. What it reads of the game (the song clock, the notes' manager and each note's fields) is at the top of src/eba_buttons.cpp; tools/eba_notes.py inspects a RAM dump.

Rumble (Input menu, on by default, with a strength; --port rumble=off, rumble-strength=N, rumble-log; src/eba_rumble.cpp), in button mode, through SDL's rumble (a DualSense's haptics, a Switch Pro Controller's or Joy-Con's HD rumble, Switch 2 controllers where SDL 3.4 drives them, Xbox motors):

  • high (both motors, 150 ms fading) for the note that ends a combo (the next note is number 1: a tap, a slider's tail) and every spinner's end;
  • medium (60%, 100 ms) for every other note hit, a slider's head or tail, and a miss (its X);
  • low (30%, 65 ms) for slider ticks, panned across the pad by the ball's x;
  • nothing while a spinner turns (a DualSense's triggers push back instead).

Taps, slider heads and misses come from the judge's hook; ticks are the combo going up without a judged tap; a tail is felt as the ball reaches its end while held (Ouendan 1 leaves some tails out of its combo, before a break). Checked headless with rumble-log on all three games' first stages, every note accounted for. The Anthem: high 40 combo ends (13 of them slider tails) and the finale spinner; medium 202 hits, 28 heads, 15 tails; low 122 ticks. Ouendan 2: high 35 and its spinner; medium 252, 16, 3; low 19. Ouendan 1: high 59; medium 168, 6; low 14 (its first stage has no spinner). Pressing 10 frames late (buttons-auto=10): a medium pulse for each of the 8 misses.

A face button (or a hit key) also taps SKIP at the lower right in a stage's opening and ending comics (all three games), and in a song's intro after GO! (the Agents' arrival, the episode's card, ...) up to its first note, where the game has a SKIP there (EBA, Ouendan 2). Where it has none, the same press fast-forwards instead (fast_forward, button mode or not): the recomp's turbo runs the game at full speed, frames neither drawn nor paced, the sound dropped, until 1.5 s before the first note appears (Ouendan 1's intro) or until the song is over (every game's outro, after the last note: EBA and Ouendan 1 never let the player skip it, Ouendan 2 does). Checked headless: Ouendan 1 from song frame 479 to 767, the first note (948) then played; its outro from 10006 to the results; EBA's outro from 9718 to the ending comic and results. In a window the screen holds its last picture for the second or two the fast-forward takes.

The d-pad hits like the face buttons (in songs and comics), so a stream can alternate between the two sets; keyboard hit keys have bits of their own.

The game's own play (src/eba_play.cpp, from the decomp)

The matching decompilation (eba-decomp, docs/DECOMP_FINDINGS.md) showed what the judge (Note::Judge) reads of the session besides the touch:

  • Autoplay: session +0x234 (EBA) / +0x27C (Ouendan 2, the same load in its judge): 1, 2, 3 judge every note 300, 100, 50 on its own frame; 4 at random. --port autoplay=N. Checked: EBA's The Anthem, 257 at 300 and cleared (spinners too); Ouendan 2's Samurai Blue, 362 at 300 and cleared.

  • Replays (saved replays are a future update; off in release builds): every judged tap goes into the session's replay Array (+0xA0: data, capacity, count; 8-byte events: frame judged, the note's time, kind, result, a new group). A window's run saves each song's to <out>/replays/GAME_TAG_stage_time.rep as it ends (--port replay-save=FILE headless); --port replay=FILE loads one into the buffer the game makes for its own (as the song starts) and sets the replaying field (+0x138 / +0x15C). Checked: a run pressing 4 frames late, recorded and played back with no input, gives the same 241 judgements. The game's replay mode does not drain the life gauge: a recorded fail plays back to a clear.

  • Assists (all three, the Assist menu): the frames a note shows and the hit test's extra radius are .data the game sets for each stage (EBA 75 frames for The Anthem, 99 for Makes No Difference; Ouendan 2's first stage 72, Ouendan 1's 90; the radius +3 in all three): the approach is scaled from the game's value (+30%, +65%, -20%), the radius widened by 5 or 11 px. Off, they are left alone. --port approach=PCT, --port hit-extra=PX.

  • No-Fail (all three, the Assist menu): in a song, the life gauge's switch (an s32 the song's setup sets to 1; EBA's Training turns it off) is held at 0, so no judgement fills or drains it; off mid-song, it is set back to 1. Hide the life bar (on by default) leaves out its four textures meanwhile: the 気/Yes markers, the bar, and the red and yellow fills (the fills are the same textures in all three games). Customs keep their score with it. Checked headless with no input: The Anthem fails (Regroup?) by frame 8,000 without it, plays on at 13,000 with it; Ouendan 2 fails (応援失敗) by 8,000, plays to the stage's end with it; Ouendan 1 fails by 7,000, plays on at 11,500. --port no-fail, --port hide-life=off.

    approachhit radiusgauge switchits function (EBA's)
    EBA0x020B47040x020B22E00x020B40C80x02067AD8
    Ouendan 20x020926440x0209003C0x020922000x020BAAD0
    Ouendan 10x020891B00x020861840x02088A7C0x0202F340

    Ouendan 2's were found by matching its code to EBA's: the function before each judge sets a note's appear frame from the approach (EBA 0x020681B8, Ouendan 2 0x020BB294); the judges load the radius at the same place; the gauge function loads the gauge table by judgement, then tests the switch.

Effects (all three games, optional, src/eba_fx.cpp)

Off by default (Effects menu; --port fx, --port fx-intensity=N). Nothing shows before a 30x combo (the first combo burst); a broken combo fades it out until the next 30x. The combo is the game's own counter (session +0xE0 EBA, +0xF4 Ouendan 2, +0xBC Ouendan 1: each Combo's count, 0x2C before its score; slider ticks counted, as shown). EBA's combo bursts (the Agent portrait) come at 30, 60, 100 and 150, not 90.

  • Fire (all three): fire rising behind the squad or the Agents, taller as the combo grows (full by 150x), flaring with each hit, a burst on an all-300 group (a toggle): the Ouendan games' own fire, heightened. EBA had spotlights for a while; they were removed (2026-10-25): new graphics made from scratch do not suit the games.
  • Where: in the 3D frame, a pass after the polygons at a depth just in front of the farthest opaque one (the stage's background), so the Agents, the notes (both 3D) and the HUD are not lit; and under the 3D where a 2D background shows through it (the compose pass). --port fx-debug draws it in front of everything; --port combo-log logs the combo.

Local ndskit patches

In patches/, applied in this order to upstream ndskit (5ee24af); together they reproduce the submodule's working tree exactly:

  • ndskit-discover.patch: two discovery fixes (below).
  • ndskit-display-options.patch: the display options above (runtime display.cpp, audio.cpp, main.cpp, nds.h, display.h). Headless runs are byte-identical with and without it.
  • ndskit-input-hook.patch: one input path for the window and scripts (nds_apply_input) with a hook for a port's own code; SDL3 gamepads by the place of each button (south B, east A, west Y, north X, the d-pad, Start, Back, shoulders); PAD_* names in input scripts; --port KEY[=VALUE] options for the port.
  • ndskit-display-live.patch: screen layouts (--layout, F9); every display setting changeable while the window runs (display_change); hooks for a port's UI (its first look at each event, drawing over the picture, a margin kept above the screens); --low-latency and --audio-latency. The screens are now always composed into a picture at the scale and laid out from it.
  • ndskit-function-hooks.patch: python -m ndskit.recomp --hook ADDR recompiles a function as f_ADDR_orig with f_ADDR in its place, calling the hook a port sets with arm_set_hook (ArmModule::hooks). One of ndskit's own known gaps ("no hooks yet").
  • ndskit-filters.patch: --filter and the out pass's smoothing filters; display_settings() reports a change not yet applied (the options bar saved the old value).
  • `ndskit-effects