Skip to content

Add .rmgr replay file recording - #195

Open
hopskipnfall wants to merge 9 commits into
Jay-Day:masterfrom
hopskipnfall:master
Open

hopskipnfall wants to merge 9 commits into
Jay-Day:masterfrom
hopskipnfall:master

Conversation

@hopskipnfall

@hopskipnfall hopskipnfall commented Sep 5, 2026 •

Copy link
Copy Markdown

Note: This is a giant PR and I would normally split this up to make it easier
to review, but then GitHub wouldn't build the binaries for us.

The important file to read is docs/RMGR_SPEC.md.
All of the code is based on the decisions in that document. This PR
description also gives background on aspects of the implementation.
Consider this PR just a review of that SPEC document.

Summary

Adds an optional, per-match replay recording feature: while a Smash Remix
match is in progress, RMG-K can record every seated port's controller
inputs and (for Smash Remix specifically) a snapshot of game state -
position, damage, stocks, action state, items, stage hazards, match
settings (including RNG seed and Remix's own gameplay/stage mutators) - to
a self-contained binary file (.rmgr), independent of and unrelated to
the existing Kaillera .krec recording. The intent is to support
downstream analysis tooling (e.g. the companion web viewer,
rmgr-viewer) without
needing to re-run the emulator to reconstruct what happened in a match.

The feature is fully optional at both build time and runtime:

  • Build time: gated behind a new GAME_STATS CMake option (on by
    default; ./Build.sh --no-game-stats or -DGAME_STATS=OFF disables it).
    It requires building mupen64plus-core with DEBUGGER=1, which pulls in
    a binutils (libopcodes/libbfd) link dependency - the only new
    external dependency this feature introduces.
  • Runtime: off by default. A "Record replay file (.rmgr) by default"
    checkbox in Settings controls the default; per-session overrides exist
    for netplay/playback dialogs and headless export tooling.

The format is currently at version 5 (docs/RMGR_SPEC.md §title) -
several incompatible revisions happened during development before this
spec document existed to pin the layout down; there is no migration path
from anything recorded before this document, and none is planned. Since
this document was written, the container format itself hasn't changed
again, but MatchSettings/StateFrame/ItemUpdate's own
recorderSchemaVersion (scoped to goodName == "SmashRemix2.0.1") has:

  • Schema 2 added render scale (fighters and items), a
    character-specific state field, shield health, and a special-hit-status
    byte to StateFrame/ItemUpdate - see §5.2/§5.3 of the spec.
  • Schema 3 added an RNG seed (sSYUtilsRandomSeed, captured once at
    match start - sufficient for deterministic replay-from-inputs, since
    loading it and replaying this match's recorded controller inputs
    reproduces the same RNG-dependent outcomes) and all 31 Gameplay Settings
    plus 8 (core) Stage Settings from Remix's own Toggles.asm mutators
    (hitstun, hitlag, DI, Z-cancel, rage, tripping, game speed, hazard mode,
    camera mode, and so on) to MatchSettings - see §5.1/§5.1.1/§5.1.2.

Design

The on-disk format is fully specified in
docs/RMGR_SPEC.md (added by this PR) - that document
is the source of truth for the byte layout; this section is a summary of
the design decisions behind it.

Self-describing binary event stream, modeled on
Project Slippi's .slp format for
Melee (not byte-compatible, several deliberate departures - see the spec's
§1). A small fixed header is followed by a sequence of typed events, each
a 1-byte command code plus a payload whose size was declared up front by
the very first event in the stream. A reader that doesn't recognize a
command code can still skip it correctly using that declared size, so the
format can grow new event types or new trailing fields on existing events
without breaking old parsers.

Buffered, then compressed once. The recorder holds the whole match's
events in memory as it plays and writes the file in a single pass -
header, then a deflate-compressed event-stream block - only once the
match ends. A crash or force-quit mid-match produces no file at all (an
accepted trade-off - the format has no partial/truncated-file case to
reason about). This keeps files small: per-frame data is highly
redundant frame-to-frame, and compressing the whole stream captures that
well without any bespoke encoding. In practice, across 157 real matches
recorded during development on the current format version: ~2.2 KB per
second of match
(median and mean agree closely; ~2.0-4.0 KB/s range
depending on how much is happening on screen) - a typical ~2-minute match
is roughly 260 KB.

Two-layer event catalog, so the format isn't Smash-specific at the
container level:

  • A core layer (player names, port slot types, controller inputs,
    match end/frame count) that's always recorded for any N64 title RMG-K
    can drive - conceptually close to a .krec-equivalent recording on its
    own.
  • A smash64 game-family extension layer (match settings, per-frame
    position/damage/stocks/action-state, items, stage hazards, final
    placements) that's only recorded when the loaded ROM is a recognized
    build (goodName == "SmashRemix2.0.1" today). The header's new
    gameFamily field tells a reader which extension events, if any, apply
    • a reader checks that one field instead of maintaining its own list of
      every goodName string that's ever meant "this is Smash."

This means a future second game (or a different Smash Remix/vanilla SSB64
build) can define its own extension layer without touching this one, and
(once a game-agnostic recording trigger is wired up - see Known
limitations below) an unrecognized game still produces a valid,
input-only recording rather than nothing at all.

goodName/gameFamily/recorderSchemaVersion as three independent
axes
(spec §3.2): gameFamily says which extension events apply,
goodName pins the exact ROM build, and recorderSchemaVersion is a
per-goodName counter bumped whenever this recorder's understanding of
that build's memory layout changes - independent of the container
format's own version, and independent of gameFamily. This is the
mechanism schema 2 and schema 3 (above) both used, rather than bumping the
container version.

Architecture

  • Source/RMG-Core/Replay.cpp/.hpp - the writer and its small public
    API (OnEmulationStart/OnFrame/OnEmulationStop, plus per-session
    overrides for output path/player names/timestamp used by headless
    export). Internally: a per-match state machine (Idle /
    WaitingForMatch / Recording), an in-memory event buffer, and a
    detached worker thread for the match-end compress-and-write step so
    stopping emulation (frequently on the UI thread, via
    CoreStopEmulation()) never blocks on it.
  • Source/RMG-Core/ReplayMemory.cpp/.hpp - the Smash Remix
    RDRAM pointer-chase that feeds the writer; not part of the container
    format itself, the source of truth for every extension field's
    semantics. Fully separate from the writer so a future second game family
    doesn't need to touch this file.
  • Source/RMG/Utilities/KailleraExport/ - headless .krec → .rmgr
    export tooling (ReplayFileExport.*, ReplayHook.*), for backfilling
    recordings from existing Kaillera netplay recordings without replaying
    them interactively.
  • Settings/UI - a new GameStats_ReplayEnabled setting and its
    checkbox in SettingsDialog; per-dialog override plumbing in the
    Kaillera playback/lobby dialogs.

Testing

  • Built and ran locally (macOS, GAME_STATS=ON) against real Smash Remix
    matches, including a match recorded on the schema-3 (RNG seed + Remix
    settings) build; confirmed the writer produces well-formed files
    (header, compression, event stream all parse back out correctly against
    the spec).
  • Validated end-to-end against the companion reader/viewer tooling.
    rmgr-ts (the TypeScript
    reader/writer) has been updated through schema 3 and correctly parses
    files this writer produces, including the schema-1/2 backward-compat
    path (older files still parse with the new fields simply absent).
    rmgr-viewer (which
    consumes rmgr-ts) surfaces the RNG seed and Remix Gameplay/Stage
    Settings in its Replay Info panel.
  • CI: build-linux/build-windows matrices exercise both GAME_STATS=ON
    and GAME_STATS=OFF across Debug/Release.

Known limitations

Called out in docs/RMGR_SPEC.md §9 in full; the notable ones:

  • Core-layer recording is currently only reachable for the recognized
    game family.
    The wire format supports a game-agnostic, input-only
    recording for any N64 title, but the actual recording trigger
    (OnFrame()'s state machine) still depends on Smash-specific memory
    reads (ReplayMemory::IsInVsMatchScreen()/ReadMatchInfo()) with no
    generic replacement wired up yet - flagged explicitly in code comments
    as a follow-up (a game-agnostic PIF-level controller/frame source, of
    the kind already used for Kaillera netplay sync, would be the natural
    fit).
  • Most of Smash Remix's settings surface beyond Gameplay/Stage Settings
    still isn't captured.
    MatchSettings now records the RNG seed plus
    all 31 Gameplay Settings and 8 (core) Stage Settings, but Remix also has
    Music Settings, Player Tags, and ~18 named plus ~170 auto-generated
    per-stage/per-track random-pool toggles that are deliberately out of
    scope for now.
  • Item/weapon tracking has no velocity, damage, owner/attacker port, or
    expiration data yet
    - ItemUpdate records a named kind, position, and
    (as of schema 2) render scale, but nothing further has been mapped in
    memory.
  • Stage hazard tracking covers exactly one hazard - Whispy Woods' wind
    on Dream Land.
  • No aggregate damage-dealt/taken breakdown, or incoming-damage-this-hit
    field
    , even though the emulator exposes them.
  • MatchEnd.endReason cannot currently distinguish time-out from
    stock-out
    - both collapse to a generic "normal end".
  • No ROM-identity check for the extension layer. Nothing currently
    verifies the loaded ROM matches gameFamily's expectations beyond the
    goodName string comparison; a mismatch would produce an extension layer
    full of garbage bytes rather than falling back to core-only recording.
    This is a correctness gap to close, not an accepted design limitation.

hopskipnfall and others added 5 commits September 18, 2026 19:23
* Add new game entries to mupen64plus.ini (Jay-Day#185)

Extra MK64 Roms and Duke Nukem

* Replace rollback lobby custom UDP punching with ICE and add per-player frame delay (Jay-Day#189)

* integrate libjuice into rollback p2p

* more accurate ping timings

* Better ping messages in UI

* Measure complete room-wide peer latency

* Add opt-in ICE diagnostics with redacted libjuice logging

* Show ICE connection failures in lobby seat status

* Avoid naming conflicts with socket read and write functions

* Filter additional noisy STUN connection trace logs

* test individual frame delay choice

* Fixed bug so that players who choose lower frame delay bare the rollback burden, rather than everyone else in the lobby

* Make frame delay player-local and fix prediction window at 9

* 2-20 char name limit

* ICE fixes / QoL updates (Jay-Day#190)

* Ice fixes (#1)

Improve p2p connection reliability
Prediction frames are back! Per-player
Kicked people stay kicked, within the lifetime of that room

* Adjust rollback lobby defaults and ping display

* Separate local frame delay from room prediction settings

* Hide 0-frame delay again and fix Auto label after delay sentinel change (#2)

Upstream re-added 0 to the delay dropdown, but GekkoNet's zero-delay path
still has an open host crash (baseline rollback with divergent frame-0
inputs), so it stays hidden until that's fixed.

Also point setAutoComboLabel at the delay combo's new -1 Auto sentinel:
since the sentinel moved off 0 it was relabeling the "0 f" item instead,
so the resolved "Auto (N f)" value never showed on the Auto entry.

---------

Co-authored-by: Jeremy Daley <93jd@pm.me>

* Lobby UI: remember window size, unified resizable column clamp (Jay-Day#192)

* Remember lobby window size and clamp browse tree columns to the viewport

* Enable DEBUGGER=1 and expose DebugMem* API in CoreApi

Pure build-infrastructure groundwork, extracted from the shared-memory
GameStats work (PR #1) without its feature code: enables the core's
DEBUGGER=1 build flag (required for DebugMemRead*/DebugMemGetPointer to
actually work, they're no-ops without it) and hooks those functions into
RMG-Core's CoreApi, following the existing HOOK_FUNC pattern.

* Fix SDL.h not found when building core with DEBUGGER=1

DEBUGGER=1 pulls src/debugger/*.c into the mupen64plus-core build, which
needs SDL.h. The core Makefile's own pkg-config auto-detection for this
doesn't reliably resolve when invoked from inside the ExternalProject
build step, so pass the already-known-good SDL3 flags (from the
pkg_check_modules(SDL3 ...) call already relied on for SDL3_net) through
explicitly as SDL_CFLAGS/SDL_LDLIBS instead.

SDL3's pkg-config Cflags only points at the parent include dir (expecting
the modern #include <SDL3/SDL.h> style); the core's debugger sources
predate SDL3 and use the legacy bare #include <SDL.h>, so an extra -I
directly into the SDL3/ subdirectory is added for that to resolve too.

* Fix write/read symbol conflict in vendored SDL_net.c

SDL_net.c defines local static write()/read() shims for Windows sockets
(SOCKET-typed handles aren't POSIX file descriptors), but their names
collide with mingw's own io.h declarations of write()/read(), which a
newer GCC now flags as a hard error. Rename the shims and use the same
poll()/WindowsPoll(), instead of touching the (POSIX-only, unaffected)
call sites.

Pre-existing bug, unrelated to the DEBUGGER=1 changes in this branch --
surfaced because it blocks every Windows build regardless of that flag.

* Port dbg_debugger.c's semaphore usage to the SDL3 API

SDL3 renamed SDL_sem -> SDL_Semaphore, SDL_SemWait -> SDL_WaitSemaphore,
and SDL_SemPost -> SDL_SignalSemaphore (SDL_CreateSemaphore/
SDL_DestroySemaphore keep their names, just the pointer type changed).
SDL3's own SDL_oldnames.h compatibility shim deliberately does not alias
these -- it replaces the old names with macros that fail to compile with
a self-documenting error, to force an explicit migration -- so this
updates the (previously never-compiled-on-SDL3) debugger code directly
rather than relying on the shim.

* Fix TRUE/FALSE undeclared in dbg_memory.c's disassembler path

TRUE/FALSE aren't defined by current binutils/libbfd headers (this file
apparently relied on them coming in transitively before). get_has_recompiled
already returns plain int, so use 0/1 directly instead of adding another
macro dependency.

Verified this file (and its siblings dbg_debugger.c, dbg_decoder.c,
dbg_breakpoints.c, and api/debugger.c) against the real MSYS2 UCRT64 SDL3
and binutils packages locally with the mingw-w64 cross compiler before
pushing, to avoid another full CI round-trip.

* Fix missing binutils transitive link deps on Windows

Newer libbfd.a/libopcodes.a pull in libsframe (stack trace metadata),
zstd + zlib (compressed debug sections), libiberty (xstrerror), and
libintl (translated messages) internally, but static linking needs
these listed explicitly -- the existing -lopcodes -lbfd alone left
several symbols (xstrerror, ZSTD_isError, libintl_sprintf, sframe_*,
compress/uncompress2) unresolved at link time on MinGW.

Verified with a minimal link test locally against the real MSYS2
UCRT64 binutils/zstd/gettext-runtime/zlib packages before pushing.

* Gate DEBUGGER=1 behind a new GAME_STATS CMake option

DEBUGGER=1 was unconditional, permanently pulling in binutils
(libopcodes/libbfd, plus libsframe/zstd/libintl/zlib transitively on
Windows) as a link dependency for every RMG-K build even though
nothing used the debugger API yet. Make it opt-in (default OFF) so
only builds that actually want replay-file recording pay that cost.

Also adds the GameStats_ReplayEnabled setting the recording feature
will read to decide whether to record a match.

* Add ReplayMemory: Smash Remix RAM-layout reader for replay recording

Isolates the hand-derived pointer-chase (match info block, per-port
match struct, player object -> player struct, controller/position
sub-structs) from the event-stream writer that will consume it, so
the "hairy N64 addresses" stay in one small, independently reasoned
about file. All addresses come from prior RAM-map research reproduced
in rmgk-replay-file-agent-prompt.md section 6, not re-derived here.

* Add Replay: Slippi-style event-stream writer for .rmgr replay files

Slippi-modeled design agreed in the handoff doc: 1-byte command code +
fixed payload per event, an Event Payloads event declaring every other
event's size up front for forward compatibility, and a length field
kept at 0 and patched at match end for crash-safety during live
recording. Explicitly not FlatBuffers (see handoff doc section 3.3).

The per-match state machine lives entirely in OnFrame(), driven by
ReplayMemory's game_status/current_screen reads - open a new file
when a VS match starts, write Pre/Post-Frame events every real frame,
finalize on a 1->5 game_status transition or on unexpected screen
change / emulation stop.

* Add independent "Record replay file" checkbox to Settings > Core

A single persistent setting rather than a per-lobby-dialog checkbox
like krec's - this feature also covers offline play, which has no
lobby/room UI to attach a per-session checkbox to. Disabled with an
explanatory tooltip in builds compiled without GAME_STATS=ON.

* Hook Replay:: into the emulation lifecycle

Replay::OnEmulationStart() arms the per-match state machine right
after the frame callback is registered (same success-path branch krec
piggybacks its own frame-counter reset on). Replay::OnFrame() runs
from FrameCallback(), which mupen64plus-core already skips during
GekkoNet rollback resimulation - so this is exactly once per real
frame with no extra filtering needed on this side.
Replay::OnEmulationStop() finalizes any still-open file when the user
quits mid-match.

* Fix cross-thread data race and lifecycle gaps found in final review

- Replay::OnEmulationStop() runs on the UI thread while OnFrame() runs
  on the emulation thread; both touched s_File/s_State with no
  synchronization. Added a mutex covering all three public entry points.
- OnEmulationStart() didn't handle a leftover open file from an
  abnormal prior session end, which silently disabled recording for
  the rest of the process (open() on an already-open ofstream just
  sets failbit). Also wired OnEmulationStop() into
  MainWindow::on_Emulation_Finished, mirroring the existing
  n02::recordingClose() call there for the same reason.
- The Settings "Restore Defaults" button didn't reset
  replayEnabledCheckBox (wrong tab's C++ functions), and could
  silently reset it to false from an unrelated tab. Moved the wiring
  to the Core-tab-scoped functions.
- OpenNewFile() now reports success/failure so a failed file open
  doesn't silently transition into a fake "Recording" state.
- Added warning/info logging on open failure/success.
- localtime() -> thread-safe platform variant.

* Default GAME_STATS to ON

Restores the behavior the branch's first 6 commits already had (DEBUGGER=1
unconditional, CI-verified) before this session gated it behind an
opt-in flag. No CI changes needed: build.yml never references
GAME_STATS and already installs binutils-dev / MSYS2's binutils
transitively, so it just inherits the new default.

Build.sh gains --no-game-stats as the opt-out (matching the existing
--no-cheats/--no-angrylion pattern) and folds it into --fast-dev.

* Add .rmgr format spec and implementation plan

RMGR_SPEC.md is the authoritative byte-layout reference for the .rmgr
replay format, written for external tooling (e.g. the TypeScript
reader/writer, which will live in its own repo) that has no reason to
read Replay.cpp/ReplayMemory.cpp to know the wire format.

The implementation plan documents how the feature was built task by
task, for anyone tracing a design decision back to its reasoning.

* Export Replay:: functions across the RMG-Core/RMG DLL boundary

CI caught it: RMG.exe failed to link on Windows with "undefined
reference to Replay::OnEmulationStop()". MainWindow.cpp (the RMG
target) calls it directly from on_Emulation_Finished(), but the
function wasn't marked CORE_EXPORT, so it never made it into
RMG-Core.dll's export table for a non-NETPLAY build (NETPLAY=ON builds
happened to be masked by the --export-all-symbols link flag n02
pulls in). Same fix applied to OnEmulationStart()/OnFrame() for
consistency with the rest of this file's public API, matching the
existing convention elsewhere in RMG-Core (decorate the .cpp
definition only, per Emulation.cpp/Emulation.hpp).

Also corrected a stale comment: OnEmulationStart() runs on the
emulation thread, not the UI thread as previously claimed - caught in
the final review but left as a non-blocking doc nit at the time.

* Move .rmgr output to a top-level "replays" folder, add arming log

Was CoreGetUserDataDirectory()/"Replays" (nested under the per-platform
user-data dir). Changed to a bare "replays" relative path, matching
krec's own "records" directory convention exactly (Source/n02/n02_client.cpp)
- both now land as siblings next to the executable in a portable
install instead of .rmgr being buried a level deeper than .krec.

Also logs when the feature actually arms at emulation start ("Replay:
enabled, watching for a match to start"), since there was previously
no observable signal between "checkbox is on" and "a match was
detected" - needed for diagnosing the report that no replays/ folder
was appearing.

* Track team/handicap match settings in GameStart

Adds the gameplay-affecting settings already present in the verified
RAM map research but not yet captured: global teamsEnabled (MI+0x02)
and handicapMode (MI+0x08), plus per-port team (playerStruct+0x0C),
handicap (+0x12), and CPU level (+0x13). The per-port fields need the
same player-object/player-struct chase as Post-Frame data, so they're
read via ReadPortPlayerState and left at their zero default if a
port's characters haven't spawned yet when GameStart is captured
(e.g. file opened during the pre-match countdown) - same tolerant
"not currently available" handling as everywhere else in this file.

Pure field-append per the format's own versioning rule (docs/RMGR_SPEC.md
section 5): GameStart grows from 150 to 164 bytes, with the new fields
strictly after the original v1 layout so an old parser reading a new
file is unaffected. Did not attempt a wholesale "copy every Remix
setting" - only fields with already-verified memory offsets from the
prior RAM-map research went in; anything beyond that would need new
reverse-engineering this session has no tooling to safely do.

* Move replay recording to per-room toggles, add playback opt-in

Record replay file was a single always-on-by-default Settings switch that
applied to every match, unlike krec's per-room "Record game" checkbox +
"enable by default" pattern. Give .rmgr the same shape: a checkbox in each
netplay room/lobby dialog seeded from a new Kaillera-tab default, an
independent checkbox in the krec Playback dialog for generating a .rmgr
while replaying a recording, and a consume-once override
(Replay::SetEnabledOverride) so each dialog's checkbox controls only its
own launch without leaking into unrelated offline launches.

Also restrict all of it to Smash Remix 2.0.1: the feature only has meaning
for that game, and GoodName mismatches would otherwise record garbage
metadata for anything else.

* Record native combo hit count and combo damage in .rmgr

Adds comboHitCount/comboDamage to PostFrameUpdate, read from the same
per-port match struct Smash Remix's own combo meter reads (not something
this feature derives itself) - it already tracks "hits taken in the
current unbroken chain" per victim, including surviving grabs/wall-bounces/
tech-chases the way vanilla wouldn't, which is exactly what's needed to
tell a fresh neutral hit from a combo continuation without guessing at it
from action-state/hitstun heuristics.

Pure field-append per the format's own versioning rules (§5): grows
PostFrameEvent from 42 to 50 bytes, no header version bump.

* Record goodName and recorderSchemaVersion in the .rmgr header

The file previously carried zero information about which ROM/game
produced it - IsSupportedGame()'s GoodName check only gated whether
recording happened, then discarded the value. That's fine for a single
hardcoded game, but doesn't scale to "a new Remix version" or "a
different ROM/game entirely", both of which are on the roadmap.

Adds two fields, deliberately on separate axes: goodName identifies which
ROM build produced the file, recorderSchemaVersion (its own counter per
goodName) identifies which revision of this recorder's interpretation of
that ROM's memory layout wrote it - independent because a bugfix to an
existing field's offset changes recorded values without changing any
event's declared byte size, which the existing per-event EventPayloads
mechanism has no way to signal on its own.

This grows FileHeader itself (12 -> 80 bytes), which the existing
field-append/new-event versioning mechanisms don't cover, so
FileHeader.version bumps 1 -> 2. Breaking on purpose: no version-1 file
has any external consumer yet.

* Record wall-clock start time in the .rmgr header, tidy filename format

Adds recordedAtEpochSeconds (seconds since Unix epoch, UTC) to the header
- the filename's timestamp already existed but can't be trusted the same
way (files get renamed/copied/re-shared), and there was no in-file record
of when a recording actually started.

Also switches the filename's own timestamp from krec's compact
YYMMDDHHMMSS to YYYYMMDD-HHMMSS (4-digit year, dashed) for something that
reads clearly at a glance, computed from the same instant written to the
header field so the two can never disagree. BuildFileName() now takes that
instant as a parameter rather than reading the clock itself, specifically
so the caller can reuse one `now` for both.

Another breaking FileHeader change (2 -> 3, 80 -> 88 bytes) on top of the
last one - same reasoning, still pre-adoption.

* Temp diagnostic: print SDL_CFLAGS_FOR_CORE inputs on Linux CI

dbg_debugger.c fails to find SDL.h on Linux CI despite the SDL_CFLAGS
passthrough added for exactly this. No Linux machine to reproduce locally
against - printing what's actually computed there instead of guessing.
Will be removed once the real fix lands.

* Fix Linux CI: SDL3's pkg-config prefix didn't match where it was installed

Root cause of the Linux "SDL.h: No such file or directory" failures in
dbg_debugger.c (DEBUGGER=1's SDL_CFLAGS passthrough in
Source/3rdParty/CMakeLists.txt) wasn't a flaw in that passthrough logic -
diagnostic output confirmed it was correctly transforming whatever
pkg-config reported into a legacy -I.../SDL3 flag. The actual bug was one
step upstream: the "Build SDL3" CI step never set CMAKE_INSTALL_PREFIX at
configure time, so SDL3's generated sdl3.pc baked in CMake's default
(/usr/local) even though `cmake --install --prefix /usr/` was placing the
real files under /usr. pkg-config kept reporting -I/usr/local/include/SDL3,
which didn't exist, while the real headers sat one prefix over. Windows
never hit this because it installs SDL3 via a prebuilt MSYS2 package
instead of building from source.

Removes the temporary diagnostic message() calls added to track this down.

* Fix Linux ARM64 build broken by the DEBUGGER=1 SDL_CFLAGS fix

The previous fix force-included SDL3/SDL_oldnames.h and stdio.h into
every compilation unit via SDL_CFLAGS -> CFLAGS, to patch a couple of
debugger/*.c files without touching vendored source. But CFLAGS is shared
by every rule in this Makefile, including new-dynarec's arm64/*.S files -
routed through the C compiler for preprocessing (see the core Makefile's
%.o: %.S rule), so those forced #includes landed there too. glibc's
stdio.h injected real declarations into what's supposed to be a near-empty
assembly preprocessing pass, and the assembler choked on the result
(confirmed on real Linux CI, both x86_64 and aarch64).

Turns out neither forced-include was actually needed for what's in this
vendored source snapshot: dbg_debugger.c already calls SDL3's native
semaphore names (SDL_CreateSemaphore/SDL_WaitSemaphore/...), not the old
SDL2 short names SDL_oldnames.h exists to shim - so that one was dropped
outright. dbg_breakpoints.c's sprintf() calls without <stdio.h> were real,
so that one's now a direct #include in the file itself instead of a
global force-include, avoiding the collateral damage. -I/-D flags are
still added globally (safe for a .S file, since they don't inject
content) - only -include was the problem.

* Add session handoff doc for match-stats brainstorm

Summarizes what's landed vs. still uncommitted, and the current state
of the edge-guard/recovery stat brainstorm so a fresh session can pick
up without re-deriving context.

* Add headless .rmgr export from the Playback dialog

Mirrors the existing "Export MP4" feature but skips video/audio,
running EmulatorProxy + PifReplay at high speed to just produce a
.rmgr file. ReplayHook isolates RMG-Core's Replay:: API from
EmulatorProxy's self-contained mupen64plus type definitions, which
collide if both headers land in the same translation unit.

Output paths are collision-checked (FindCollisionFreePath) so two
same-second exports don't overwrite each other.

* Fix replay export speed, player names, and UI polish

- Fix speed: mupen64plus-core's main_speedset() silently rejects any
  speed factor above 1000% and leaves it unchanged, so the export's
  2000% request was rejected every frame and it ran at 1x the whole
  time. Now requests 1000%, the core's actual ceiling.
- Fix player names: the headless export process never runs through
  n02's live netplay UI code, so recording_player_names was always
  empty for it. Added Replay::SetPlayerNamesOverride() (mirrors the
  existing output-path override) so the export can pass through the
  names already stored in the .krec being replayed.
- Remove the "Also record replay" checkbox from the Playback dialog,
  superseded by the dedicated Export Replays button.
- Rename "Export Replay" to "Export Replays" throughout, and match
  Export MP4's blue primary-button styling.

* Fix Export Replays output location and naming

The export was writing its .rmgr next to the source .krec (records/),
using the krec's own basename verbatim - so re-exporting the same
recording silently overwrote the same file in records/ every time,
and .rmgr output never ended up alongside other .rmgr files at all.

Now builds the output path the same way a live-recorded .rmgr is
named: replays/<YYYYMMDD-HHMMSS>-Player1-Player2.rmgr, reading the
timestamp and player names straight from the .krec's own header so
re-exporting the same recording reproduces the same base name. The
existing collision-avoidance (FindCollisionFreePath in
ReplayFileExport.cpp) still applies unchanged.

* Fix Linux build: gate KailleraExport:: usage behind _WIN32

onPlaybackExportReplay() referenced KailleraExport::KrecData/
ParseKrecFile unconditionally, but KrecParser.hpp is only included
under #ifdef _WIN32 - the function still has to type-check on any
GAME_STATS build regardless of platform, even though it's only ever
reachable on Windows (its button/connect are both

* Fix multi-match .krec export naming; raise export speed to 2000%

Replay.cpp's output-path and player-names overrides were consumed on
the first OpenNewFile() call, but a headless export of a .krec spanning
multiple matches opens one .rmgr per match - so only the first match's
file got the override-derived name; every later match silently fell
back to the plain default naming (no names) and, before this, a fixed
records/ path that got overwritten on every subsequent match.

Both overrides now persist for the whole emulation session (cleared at
OnEmulationStop() instead of per-file), and every OpenNewFile() call -
override path or default - runs through a new FindCollisionFreePath()
so each match still gets a distinct, correctly-named file instead of
overwriting the last one.

Also raises mupen64plus-core's main_speedset() ceiling from 1000% to
2000% (a hard-coded upstream limit, not anything export-specific) and
requests 2000% for replay export accordingly.

* Name exported replays after their source .krec + game number

Previously exported .rmgr files were named from the krec's own
recorded timestamp + player names, re-derived independently of the
krec's actual on-disk filename - correct in the common case, but not
guaranteed to match if the krec had been renamed, making it harder to
trace an exported file back to its source.

Now names them "<krec name>-<game number>.rmgr" (game 1, 2, 3, ...)
directly from the krec's own filename, so the export always plainly
corresponds to its source .krec. Replay.cpp's OpenNewFile() does the
numbering (SetOutputPathOverride is now the base path, not the full
per-file path), which also simplifies KailleraPlaybackDialog.cpp - it
no longer needs to parse the krec's header at all for filename
purposes.

* Extrapolate per-match recording timestamps from krec, add ItemUpdate event

Headless .krec export now stamps each exported match's
recordedAtEpochSeconds from the krec's own recording-start time (embedded
header timestamp, or a filename-derived fallback) plus elapsed krec input
frames / 60, instead of wall-clock time at export (which drifts arbitrarily
since headless replay runs at up to 2000% speed).

Adds a new ItemUpdate event (code 0x06, recorder schema v2) capturing the
shared item/hazard/projectile object list's position and raw type ID per
frame, per docs/smashremix ram-map.md section 14. Backward compatible: new
event type, not a header version bump, per RMGR_SPEC.md section 5.

* Fix ItemUpdate's type field (schema v3), add Whispy wind tracking

Schema v2's ItemUpdate.typeId was wrong, not just incomplete: +0x0C on the
raw GObj is four packed single bytes (link_id/dl_link_id/frame_draw_last/
obj_kind), not a 32-bit type ID - reading it as a u32 produced a large,
constantly-changing value (confirmed empirically: 39 recorded games, only
Link's bomb ever showing up, with garbage-looking values like 0x40B6B01).

Corrected against a real SSB64 decompilation (VetriTheRetri/ssb-decomp-re):
read link_id as a single byte (4=Item, 5=Weapon), then chase GObj+0x84 ->
ITStruct*/WPStruct* -> +0x0C for the real, named kind (ITKind or WPKind).
Both enums are now fully documented in RMGR_SPEC.md section 7.6. Any data
captured under schema v2 should be discarded, not migrated.

Also adds StageHazardUpdate (schema v3), currently tracking just Whispy
Woods' wind on Dream Land (docs/smashremix ram-map.md section 10.3, now
fully confirmed via the same decomp) - a sparse per-frame event, extensible
to more hazards later via the field-append mechanism.

* Add hitbox and hurtbox recording (schema v5); make it opt-in

**Hitbox/Hurtbox Recording (schema v5)**
- `ReadHitboxes()`/`ReadHurtboxes()` collect FTAttackColl/ITAttackColl/WPAttackColl (4/2/2 slots) and FTDamageColl (11 slots) per frame
- New `HitboxUpdate`/`HurtboxUpdate` events (codes 0x08/0x09) record all active slots every frame
- Deliberately verbose/exhaustive for now, as future goal is to compute geometry from (characterId, actionStateId, actionFrameCounter) alone
- See docs/RMGR_SPEC.md §4.8/§4.9 for full layout and confidence caveats on Item/Weapon offsets

**Opt-In Setting (off by default)**
- New `GameStats_RecordHitboxData` setting (Settings.hpp/cpp)
- "Include hitbox and hurtbox data (large files)" checkbox in SettingsDialog, disabled unless replay recording is on
- When off, `ReadHitboxes()`/`ReadHurtboxes()` are not called at all (not just skipped)

**Bug fixes** (incidental to hitbox/hurtbox work)
- Fixed Weapons never being recorded: were walking only gGCCommonLinks[4] (Item list), Weapons live on separate gGCCommonLinks[5]
- Fixed held Items recorded with garbage positions: now check ITStruct::owner_gobj != NULL and skip held items

**Schema history (§5 in RMGR_SPEC.md)**
- v4→v5: added HitboxUpdate/HurtboxUpdate events; bumped recorderSchemaVersion to 5

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* Fix held-item detection (schema v6); owner_gobj never actually cleared

ITStruct::owner_gobj was being used as a "currently held" proxy since
schema v4, but decomp confirms (itMainSetFighterRelease()) it's
deliberately retained across a throw/drop for later damage/KO
attribution - it reads non-NULL for essentially an item's entire
lifetime, held or not. This meant the filter silently swallowed nearly
every thrown/dropped Item's flight, not just its brief held phase.

Replaced with a position-based proxy: while held, the engine
re-parents the item's DObj onto the holding fighter's hand-bone joint,
so its position reads near (0,0,0) instead of a real world coordinate
- itMainSetFighterRelease() writes the real coordinates back at the
exact release moment (lbCommonEjectTreeDObj()). IsHeldItemPosition()
uses "position still reads near (0,0,0)" as the signal instead, no new
offset work required.

Bumped recorderSchemaVersion to 6. Item ItemUpdate data recorded under
schema 4/5 is missing most or all of every thrown/dropped Item's real
flight and should be re-recorded (Weapons unaffected - this check
never applied to them).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* Fix jumpsUsed read width (u32 -> u8); export jumpsRemaining (schema v7)

PostFrameUpdate.jumpsUsed was read at the wrong width -
playerStruct+0x148 is a single byte (u8), confirmed by the decomp's own
naming convention (the very next field is literally named
unk_ft_0x149, encoding its own byte offset); +0x14A-0x14B is padding.
An earlier u32 read there, on a word-swapped emulator where a *byte*
read needs its address XORed with 3, landed on that padding instead of
the real field - reading a constant 0 for an entire match, every port,
no matter how much jumping happened.

Fixed the read width AND switched what gets exported at this same wire
position: jumpsRemaining (jumpsMax, chased from the per-character
FTAttributes+0x64, minus the corrected jumps_used) instead of
jumpsUsed directly - more directly useful, and what this project
wanted to export in the first place.

Bumped recorderSchemaVersion to 7. Schema v6 and earlier files' byte
here is meaningless (the old bug's output, not real data) - not that
byte-layout changed, since it's still a u8 at the same wire position.

* Replace recordedAtEpochSeconds with recordedAtEpochMillis + nanosOffset (schema v4)

Second-resolution timestamps aren't precise enough to align multiple .rmgr
recordings from the same session against each other. Header version 3 -> 4
(88 -> 92 bytes): recordedAtEpochSeconds (u64 seconds) is replaced with
recordedAtEpochMillis (u64 millis) plus a new recordedAtNanosOffset (u32,
nanoseconds within that millisecond, best-effort). Live recording now reads
std::chrono::system_clock::now() instead of time(nullptr); the .krec-derived
override path converts its frame-based elapsed offset to milliseconds
(rounded) instead of truncating to whole seconds, and always writes a 0
nanosOffset since it has no sub-millisecond information to offer.

* Document optional version-3 read-compat in the versioning history

rmgr-ts's parser now keeps reading version-3 files by synthesizing
recordedAtEpochMillis/recordedAtNanosOffset from their recordedAtEpochSeconds
(*1000, nanosOffset 0), instead of the usual hard cutover - a reader-side
choice, since this project's own writer never writes below the current
version regardless.

* Fix hurtbox-slot gate (schema v8); decouple hitbox/hurtbox checkbox from live-record default

ReadHurtboxes() gated on IsValidRdramPointer(joint), but decomp confirms
(ftmanager.c) an unused FTDamageColl slot leaves joint untouched garbage,
not NULL - hitStatus (explicitly 0/nGMHitStatusNone for unused slots) is
the real signal. The wrong gate filtered out every slot, used or not,
every frame; a real match with confirmed combat recorded zero
HurtboxUpdate events the entire time. Fixed by checking hitStatus first.
PS_ATTACK_COLL_BASE/PS_DAMAGE_COLL_BASE/strides were independently
re-verified against the decomp and are correct - not the bug.

Also notes that HitboxUpdate is still producing zero events post-fix for
reasons not yet isolated (sampling timing vs. a runtime settings issue -
see docs/RMGR_SPEC.md §8), and documents that FTDamageColl.size is stored
pre-halved (half-extents) for whenever hurtbox rendering gets built.

Separately: the "Include hitbox and hurtbox data" checkbox was only
enabled when "Enable replay recording (.rmgr) by default" was checked,
even though it has real effect during headless replay export (which
bypasses that default entirely via SetEnabledOverride) - decoupled them
and renamed the checkbox to end in "(debug)".

* Add hitbox/hurtbox diagnostic logging

The schema v8 gate fix didn't resolve the empty-hitbox/hurtbox symptom on
re-test (still zero events across a real match with confirmed combat) -
static review of the settings/gating pipeline turns up nothing wrong, so
this adds two log lines to make the runtime state directly observable
instead of guessing further:

- Once per session (OnEmulationStart): whether s_RecordHitboxData actually
  ended up true.
- Every 5 seconds during recording (RecordFrame): how many hitbox/hurtbox
  objects ReadHitboxes()/ReadHurtboxes() actually found that frame.

Diagnostic only, no behavior change. Remove once the root cause is found.

* Wire up RMG-Core's debug callback during headless .rmgr export

Found while chasing the hitbox/hurtbox diagnostic: RMG-Core's own
CoreAddCallbackMessage() (Replay.cpp's "Replay: ..." info/warning messages,
including the new hitbox/hurtbox diagnostics) was completely invisible
during "Export Replays" - not just the new diagnostics, ANY message from
that mechanism, past or future. The headless export subprocess (relaunches
the app with --export-rmgr-output, see main.cpp) never reaches
MainWindow::Init(), which is the only place CoreSetupCallbacks() gets
called normally, so every message silently queued into RMG-Core's pending
buffer and was discarded when the process exited - the export progress
dialog only ever showed this file's own explicit fprintf(stderr, ...)
progress lines, nothing from RMG-Core itself.

Registers a no-op callback (just to satisfy RMG-Core's "has anyone
registered yet" gate) and turns on CoreSetPrintDebugCallback so messages
print to stdout, which the GUI's QProcess (MergedChannels) already
captures and shows in the same progress dialog as the existing frame-count
progress lines. Real, permanent visibility fix, not scoped to this one
debugging session.

* Add "View Log" button to the export-finished dialog

Found while chasing the hitbox/hurtbox diagnostic: even with the previous
commit's fix wiring RMG-Core's debug callback into the export subprocess's
stdout, the captured output (m_exportLog) was only ever surfaced on a
FAILED export (summarizeExportLog() in the failure message box) - a
successful export just showed "Export finished. Saved to: <path>" with no
way to see what was logged along the way. Adds a "View Log" button next to
Open Folder/Open File that shows the full captured log in a read-only text
view, so RMG-Core's "Replay: ..." diagnostics (and anything else printed
during export) are actually visible regardless of whether the export
succeeded or failed.

* Record Whispy Woods' wind direction (schema v9)

Adds StageHazardUpdate.hazardFlags bit 0x02: which way the wind is
blowing (0 = left, 1 = right), alongside the existing bit 0x01
(currently blowing). Read from the fixed s8 global smashremix
docs/ram-map.md section 10.3.1 documents (gGRCommonStruct + 0x2A,
lr_players) - the engine's own already-decided direction for the
current wind cycle, no recomputation needed. Only set (and only
meaningful) when the blowing bit is also set. Bumps
kRecorderSchemaVersion 8 -> 9; documented in docs/RMGR_SPEC.md's
section 4.7 bit table and section 5 versioning history.

* Add .rmgr v2 design doc: compression, core/game-family event layering

Design for the breaking rewrite ahead of real-user rollout - buffered
single-shot deflate compression, a game-agnostic core input layer plus
a per-game-family extension layer, and removal of hitbox/hurtbox
recording. Not yet implemented.

* Rewrite RMGR_SPEC.md for format v5 (design only, no code changes)

Ground-up rewrite reflecting the approved v2 design: buffered
single-shot deflate compression, a game-agnostic core input layer
plus a per-game-family (smash64) extension layer, hitbox/hurtbox
recording removed, and dead schema-history narration cut. version
continues incrementing (4 -> 5) rather than resetting, per feedback,
so an old reader unambiguously rejects the new format instead of the
number colliding with an unrelated earlier meaning.

Source/RMG-Core/Replay.cpp and ReplayMemory.cpp still implement v4 -
this commit is spec-only, matching the explicit request not to touch
code yet.

* Address review notes on the v5 spec: drop nanos field, fix framing

- version no longer resets to 1; keeps incrementing (5), so an old
  reader unambiguously rejects the file instead of colliding in value
  with an unrelated earlier meaning. (Already the case, just tightened
  the wording.)
- Stop describing this as superseding a real "version 4" spec - this
  is the first time .rmgr has been formally specified at all; the
  fork's prior in-development code was never published or seen by the
  upstream maintainers.
- Soften the "deterministic replay" claim: no RNG seed is captured, so
  full re-simulation isn't actually guaranteed - cross-referenced to
  the existing limitation in what's now SS9.
- Drop recordedAtNanosOffset entirely (108-byte header, down from 112);
  millisecond resolution is kept.
- Reformat SS8.1/8.2/8.3/8.4 (character IDs, stage IDs, action state
  IDs, button bits) from prose/code-block layouts into proper markdown
  tables.

* Call out RNG and unmapped Remix-settings gaps in known limitations

Sharpens the existing "no RNG seed" bullet with a cross-reference to
the SS1 determinism caveat, and adds a new bullet noting that most of
Smash Remix's own match-settings additions beyond the original SSB64
set aren't mapped into MatchSettings yet.

* Implement .rmgr v5: buffered+compressed writer, core/smash64 event split

Rewrites Source/RMG-Core/Replay.cpp / ReplayMemory.cpp / .hpp to match
the format-v5 spec: the whole match now buffers in memory and is
written as a single deflate-compressed block at match end (no more
seek-and-patch streamLength trick); the event catalog splits into a
game-agnostic core layer (MatchStart/InputFrame/MatchEnd) and a
smash64-family extension layer (MatchSettings/StateFrame/ItemUpdate/
StageHazardUpdate/MatchResult), gated on a new header gameFamily
field; recordedAtNanosOffset is dropped; hitbox/hurtbox recording is
removed entirely, including its Settings dialog checkbox.

Verified by compiling RMG-Core and RMG (Settings.cpp/.hpp,
SettingsDialog.cpp/.ui, Replay.cpp, ReplayMemory.cpp) from scratch -
all touched files build with zero errors; the only remaining build
failure in this environment (VidExt.cpp, missing Vulkan headers) is
pre-existing and unrelated.

The reader side (rmgr-ts, rmgr-viewer) is not updated yet - tracked
as follow-up work per the design doc's SS7.

* Drop internal planning docs from this PR, keep only RMGR_SPEC.md

Removes the superpowers handoff/plan/design-doc markdown files this
branch accumulated - not worth carrying into the PR. RMGR_SPEC.md is
the only markdown deliverable; its few references to the now-removed
design doc are folded inline so it stays self-contained.

* Address review findings: async match-write, filename override, reuse

- FinalizeFile() now hands compression + the file write off to a
  detached worker thread instead of doing both synchronously on
  whatever thread calls OnEmulationStop() (frequently the UI thread
  via CoreStopEmulation()) - stopping emulation or ending a match no
  longer blocks the UI for however long a multi-MB match takes to
  compress and write.
- OpenNewFile() now test-opens the actual destination file (not just
  its parent directory) before any events are buffered, so a write
  failure is caught before a whole match is recorded into memory
  instead of only being discovered - and discarded - at match end.
- BuildFileName() now honors SetPlayerNamesOverride() like MatchStart
  already did, via a new shared ResolvePlayerNames() helper - headless
  .krec export no longer produces files with correct in-header player
  names but a blank filename suffix.
- FindCollisionFreePath, duplicated verbatim in Replay.cpp and
  ReplayFileExport.cpp, is now one shared CoreFindCollisionFreePath in
  RMG-Core's File.cpp/.hpp.
- Removed a redundant per-port-per-frame characterId memory read
  (ReadPortMatchInfo and ReadPortPlayerState were both reading the
  same byte); consolidated three copy-pasted buffer-append blocks in
  Replay.cpp into one AppendBytes()/AppendValue() helper; reserve()
  the event buffer upfront to cut reallocation churn during a match.

Verified by rebuilding RMG-Core from scratch - all six touched files
compile with zero errors (only pre-existing macOS-only linker path
quirks remain, unrelated to this change).

* Fix bad rebase conflict resolution in vendored SDL_net.c

The rebase onto upstream/master hit a conflict in this vendored file
between an old commit on this branch (renaming the Windows write/read
helpers to SDLNet_PlatformWrite/SDLNet_PlatformRead) and upstream's
own, different fix for the same symbol collision (just naming them
SocketWrite/SocketRead directly, matching the POSIX branch's #define
target). I resolved the conflict by keeping this branch's old commit
instead of upstream's - backwards, and it left SocketWrite/SocketRead
completely undefined on Windows (the '#define SocketWrite write' line
only exists in the POSIX #else branch), breaking every Windows build.
CI caught it. Reverting the file to match upstream/master exactly.

---------

Co-authored-by: denvar-labs <denvarkart@gmail.com>
Co-authored-by: Ownasaurus <Ownasaurus@gmail.com>
Co-authored-by: Jeremy Daley <93jd@pm.me>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Reads existing recordings with a self-contained legacy reader (not
part of @rmg-k/rmgr, which only reads v5 by design) and rewrites them
with the new package's serializeReplay. Reports per-file and total
size and parse-time comparisons.

Run against this session's 40 sample recordings: 43.29 MB -> 12.31 MB
(28.4% of original, 3.52x smaller) from compression alone, at the
cost of slightly higher average parse time (9.93ms -> 11.30ms per
file) from the added decompression step - both comfortably
sub-frame-budget in absolute terms.
This tool converts .rmgr recordings for use with rmgr-viewer and
depends on the sibling rmgr-ts package the same way rmgr-viewer's
other scripts do, so it belongs alongside them rather than in RMG-K.
… armor

ItemUpdate gains trailing scaleX/scaleY (DObj render scale; Samus's
Charge Shot grows with charge). StateFrame gains trailing:
- scaleX/scaleY: fighter root-joint scale (Giant/Tiny; Kirby's aerial
  up-B growth, which also affects ECB and ledge-grab reach)
- characterSpecific: passive_vars word - Samus/DK stored charge, Kirby's
  copied ability (8 = none)
- shieldHealth
- specialHitStatus / starHitStatus: the timed (respawn etc.) and Star hit
  statuses, separate from hurtboxState's motion-script one
- knockbackResist: temporary knockback armor (Yoshi's double jump)

Backwards compatible via EventPayloads-declared sizes: schema-1 files
keep 25-byte ItemUpdate / 50-byte StateFrame and older readers skip the
trailing bytes. Also corrects the hurtboxState docs (2 = invincible,
3 = intangible). Offsets are from the decomp and not yet live-verified.
Super Star item invincibility isn't needed; knockbackResist moves to
offset 0x43. Respawn invincibility stays covered by specialHitStatus.
#5)

* Fix duplicate Internal_InputPluginSwitchRequested enumerator in SettingsID

Settings.hpp declared this enumerator twice in the same enum class,
which fails to compile unconditionally. Left over from a rebase that
introduced GameStats_ReplayEnabled between the two occurrences instead
of removing the duplicate.

* Record RNG seed and Smash Remix Toggles.asm settings in MatchSettings

Bumps .rmgr format to version 6. MatchSettingsEvent grows from 32 to
75 bytes:

- rngSeed: sSYUtilsRandomSeed at match start, captured once. Paired
  with this match's recorded inputs, it's sufficient to reproduce
  RNG-dependent outcomes (item spawns, move variance, etc.) on replay.
- 31 Gameplay Settings + 8 Stage Settings from Remix's Toggles.asm
  menu-configured mutators (hitstun, hitlag, DI, Z-cancel, rage,
  tripping, game speed, hazard mode, camera mode, and so on) - match
  properties captured once, not per-frame data.

Addresses and enum meanings verified directly against the Smash
Remix 2.0.1 source build. See docs/RMGR_SPEC.md $5.1/$5.1.1/$5.1.2 for
the full field list and value tables.

Deliberately out of scope: the ~18 named + ~170 auto-generated
per-stage/per-track random-pool toggles, Music Settings, and Player
Tags - can be added later as ordinary field-appends if needed.
* Add design doc: queued Export Replays

Design for turning the blocking, one-at-a-time 'Export Replays'
(.krec -> .rmgr) flow into a queue - select a recording, export, select
another, export again without waiting for the first to finish. See the
doc for the full design (scope, data model, UI, error handling).

* Queue Export Replays instead of blocking on one at a time

Clicking Export Replays no longer refuses while an export is running -
it now starts immediately if idle, or is appended to a FIFO queue
(m_replayExportQueue) that drains automatically as each export
finishes. This lets you keep selecting recordings and clicking Export
Replays without waiting for the current one to finish.

Export MP4 is untouched - same modal QProgressDialog, same one-at-a-
time behavior, still sharing the underlying m_exportProcess slot
(mutually exclusive with Export Replays, as before).

Replaces Export Replays' modal QProgressDialog with a non-modal panel
(m_replayExportPanel: status label + progress bar + queue-count label
+ a capped history log) that appears on the first export and stays
visible, updating in place, rather than blocking the dialog. Finished
exports no longer show a popup (success or failure) - they're logged
to the panel's history instead, so several queued exports finishing
close together don't stack dialogs.

See docs/superpowers/specs/2026-09-20-queued-replay-export-design.md
for the full design.
)

Adds §8.3.1: exhaustive, decomp-verified per-character action state ID
tables for Mario, Luigi, Fox, Donkey Kong, Samus, Link, Yoshi, Captain
Falcon, Pikachu, Jigglypuff, Ness, and Kirby (largest at 83 states -
one full copied move-set slot per copyable neutral-B).

Every character independently restarts its own status enum at 0x0DC;
there is no cross-character ID sharing except Luigi/Mario, whose
tables are byte-identical and point at the same functions. A few
states in this range per character (Attack13, Attack100Start/Loop/End)
are jab-combo continuations rather than actual special moves, despite
sharing the block - flagged inline rather than blanket-labeling the
whole range "special moves" as the doc previously did.

Scoped to the original 12 characters only, including their same-
moveset Polygon/JP/EU variant IDs - not extended to any Remix-added
unique character (Dr. Mario, Dark Samus, Giant DK, etc.), since those
aren't confirmed to share an original character's code. Updates the
corresponding Known Limitations bullet (§9) accordingly.
Smash Remix's Salty Runback (hold Start on the no-contest/results
screen to instantly restart, bypassing the normal win/loss flow)
wasn't being detected by the recorder's state machine: it doesn't
transition through the screen change or necessarily the observable
game_status==5 tick our two existing signals depend on, since it can
skip both on its way straight back into a fresh battle.

Adds ReplayMemory::IsSaltyRunbackActive(), reading GameEnd.asm's
is_salty_runback flag (0x8048B174) - confirmed via a bass -sym build
against real Remix source, not inferred. OnFrame() now finalizes the
current match and starts a new one on a 0->1 edge of this flag, in
addition to the existing game_status==5 check. Edge-detected rather
than a level check, since the flag likely stays set for the entire
replacement match too - re-baselined to the current value whenever a
new recording starts, so it can't misfire on that match's own first
frame.

Known gap, documented in docs/RMGR_SPEC.md's Known Limitations: two
runbacks triggered back-to-back with no non-runback match end between
them may not be independently caught, since it's unconfirmed whether
the flag reliably drops back to 0 in between.
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