Repository navigation
Commit e02998b
v2.7.4 "Pocket": the mobile apps survive what a phone does to them (#552)
* fix(mobile): input never waits for a frame; cap ROM buffers at 16 MiB
The first two Rust-bridge items of v2.7.4, from the frontend audit
(docs/audits/frontend-audit-report.md §2.2, §2.4). `rustynes-mobile` is shared
by the Android and iOS hosts, so both platforms get both fixes.
MOB-01 -- input waited on the frame lock
`NesController` kept the per-port masks inside `Mutex<Inner>`, the lock
`run_frame` holds for a whole frame, a netplay rollback (up to 7 frames
re-simulated), a Lua `on_frame` callback and a RetroAchievements evaluation.
`set_buttons` / `set_button` / `buttons` took the same lock, so a touch or
gamepad event on the UI thread waited out whatever the emulation thread was
doing. The masks are now `[AtomicU8; 4]` on the controller, outside the lock:
`set_buttons` is a store, `set_button` a single `fetch_or` / `fetch_and` (so a
press and a release racing on two threads cannot lose a bit), `buttons` a load.
`latch_input` copies them into the core under the lock immediately before each
frame runs, and before `pre_tick_movie`, so movie playback still overrides live
input and recording captures what the frame consumes. That latch point is where
a lock-waiting call used to land, so emulated input timing is unchanged.
The Android triage for this release found the same residual from the Kotlin
side (AND-07): event handlers only call in on a mask change, but each change
still blocked on this mutex. This closes it without touching Kotlin.
Red first: `input_does_not_wait_for_the_frame_lock` holds the lock itself and
calls `set_buttons` / `set_button` / `buttons` from another thread; on the old
code the call timed out after 2 s. `held_input_reaches_the_core_at_the_next_frame`
pins the latch. Mutations: no latch in `step_frame` (caught); `set_button` as a
load-then-store instead of an atomic RMW (caught).
One step was removed rather than kept: `load_state` used to re-apply the masks
to the core after a restore. With every frame latching first, that re-apply was
unobservable -- removing it left every test green, because nothing emulates in
between -- so it is gone, with a comment at the site, and
`load_state_preserves_held_input` now also checks the core has the held button
after the next frame.
MOB-02 -- ROM buffers were not size-checked
`decompress_rom` bounded zip entries at 16 MiB but returned any non-zip buffer
unchecked, and the core then copies the whole buffer (`rom_bytes`) and splits it
into PRG and CHR. The cartridge loader ignores trailing bytes, so a VALID image
padded to any size loaded. `check_rom_size` now refuses a buffer over
`MAX_ROM_BYTES` (16 MiB, hoisted to module level and shared with the zip bound)
in both `NesController::new` and `load_rom`, before `decompress_rom`. The
caller's own buffer already exists at that point; this stops the copies after
it. Red first: `a_rom_over_the_size_cap_is_refused_at_the_bridge` pads
`tiny_nrom()` to 16 MiB + 1 and loaded on the old code. Mutations: no check in
`new` (caught), `>` as `>=` (caught by the exactly-at-cap case).
Verification: `cargo test -p rustynes-mobile --lib` 30 passed, 0 failed;
clippy `-D warnings` and fmt clean. docs/android.md records both.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
* feat(mobile): export and import battery save RAM through the bridge
MOB-05 / AND-09 of the frontend audit, and the mobile half of FE-01: the
bridge exposed no battery RAM at all, so neither the Android nor the iOS app
could keep an in-game save across a relaunch. The desktop gained `.sav`
persistence in v2.7.3; this is the shared Rust half for mobile. The Kotlin and
Swift halves that write the file follow in this release.
API (`NesController`, UniFFI-exported, so both hosts get it):
- `has_battery()` -- the header's battery bit. Same rule as the desktop: the
save RAM slice is not the question, because NROM, MMC1 and MMC3 expose work
RAM whatever the header says.
- `battery_ram()` -- a copy of the save RAM, or empty without a battery. No
dirty flag: the host compares with what it last wrote, as the desktop does.
- `load_battery_ram(bytes)` -- load a `.sav` before the first frame. Refused,
with nothing copied, when the cartridge has no battery or the length is not
its save size; a partial copy would mix two saves.
- `MobileError::Battery { reason }`, appended last. No Kotlin or Swift code
matches `MobileError` exhaustively (checked), so the generated enums grow
without breaking a host build.
Keying: each host already stores save states per ROM, so the `.sav` goes beside
them under the same key and the bridge needs no key API. That settles the
v2.7.3 carry-over question for mobile without changing either host's layout.
Tests (red as a compile error, the API not existing):
`a_battery_save_round_trips_through_the_bridge` (a synthetic NROM with the
battery bit writes $6000 every frame; a fresh controller takes the bytes back),
`a_cart_without_a_battery_has_nothing_to_persist`,
`a_battery_save_of_the_wrong_size_is_refused` (0, 8191 and 8193 bytes; RAM
unchanged). Four mutations, each caught: the export ignoring the battery gate,
the import ignoring it, the import copying nothing, and the size check removed.
Verification: `cargo test -p rustynes-mobile --lib` 33 passed; clippy clean.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
* fix(netplay,cheevos): stop joining network workers on the dropping thread
MOB-04 of the frontend audit (report §2.5): `SignalingClient::drop`
(rustynes-netplay) and `HttpTransport::drop` (rustynes-cheevos) closed their
channel and then joined the worker unconditionally. A worker blocked on the
network held the dropping thread until the network gave up: the signaling
worker's WebSocket handshake runs under the 10 s `CONNECT_TIMEOUT`, and the RA
HTTP worker's requests under a 30 s global timeout. On mobile that thread is
the UI thread leaving a netplay screen or logging out; on the desktop it is the
winit thread. The audit named both sites correctly.
Both now call `join_or_detach(worker, 100 ms)`: join if the worker finishes
within the grace period, otherwise drop the handle, which detaches the thread.
The grace covers the normal case -- an idle signaling worker notices the closed
channel within one 20 ms read timeout, an idle HTTP worker is parked in `recv`
and exits at once -- so only a worker stuck on the network is left to finish on
its own.
Why detaching is safe, per site, since that is what a reviewer should check:
- signaling: the worker owns its socket and its halves of both channels, both
already closed from our side; it exits when the handshake or read returns
and touches nothing the caller frees.
- RA HTTP: the worker owns its `ureq` agent and the request. It never calls the
rcheevos callback; only `poll_completions` does, on the caller's thread, from
a receiver dropped with `self`. So a late response is simply discarded.
The audit suggested an explicit acknowledgement protocol with a deadline; the
bounded `is_finished` poll is that, without a new channel.
Red first: `dropping_the_client_does_not_wait_for_a_silent_relay` points a
real `SignalingClient` at a local listener that accepts and never answers;
`drop` took 10.06 s on the old code and 0.30 s of test time now (the 200 ms
before it is the test letting the worker reach the handshake). For the RA
transport, whose worker only blocks with a live rcheevos request in flight, the
helper itself is pinned: `a_busy_worker_is_detached_not_joined` (a 5 s worker is
detached within the grace; a finished one is joined). Mutation there -- always
join -- is caught (the test waits the full 5 s and fails).
Verification: `cargo test -p rustynes-netplay` 9 suites, 103 passed;
`cargo test -p rustynes-cheevos` 9 passed; clippy -D warnings clean.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
* fix(core): a failed save-state load leaves the machine as it was
MOB-08 of the frontend audit (report §8 row; `rustynes-core/src/nes.rs`).
The finding is filed under mobile, but the defect is in the core, so every host
had it: desktop, libretro, and both mobile apps.
`Nes::restore_inner` applies a blob in stages that mutate as they go: the bus
sections (BUS / PPU / APU / MAP) first, then the CPU section, then the
cross-section clock check added in v2.7.0. A blob rejected at any stage after
the first -- a CPU section version mismatch, a CPU body that fails to decode, a
missing CPU section, a CPU/PPU clock skew -- returned `Err` with the bus already
overwritten. The machine was then the blob's bus and the running game's CPU,
neither state, and the next frame emulated it. The user guide's troubleshooting
page already promised "the running emulator state is unchanged" on a failed
load; that was false until now.
Fix: the stages move into `apply_snapshot`, and a LOUD restore (`restore`,
user-driven loads) snapshots the running machine first and re-applies that
backup if any stage fails, then returns the original error. Our own fresh
snapshot always restores -- the round-trip invariant every save-state test
pins -- so the re-apply result is ignored rather than allowed to mask the
real error.
Deliberately NOT done for `restore_quiet`: it is run-ahead's and netplay's
per-frame rollback, the hot path, and it only ever restores snapshots this core
just wrote, which cannot fail these checks. A backup there would add a full
snapshot per frame for no case that occurs. Rewind clearing and the
debug-hooks attribution clears already ran only after success, so they need no
change. The timeline-generation bump stays unconditional (its comment is
updated: a failed loud restore no longer leaves a discontinuity, so the bump
costs a consumer one telemetry reset).
Red first: `a_failed_restore_leaves_the_machine_untouched` takes a snapshot at
frame 1, runs to frame 4, then restores the frame-1 blob with its CPU section
version decremented (the existing stale-slot fixture). On the old code the
machine's snapshot afterwards differed from before the attempt. Mutations, all
caught: no backup taken; backup taken but not re-applied; backup taken only on
the quiet path. The success path is unchanged; AccuracyCoin, nestest and the
full test-roms suite are re-run at the release gate.
Verification: `cargo test -p rustynes-core --lib` 201 passed; clippy clean;
`no_std` thumbv7em build clean. docs/frontend.md and the troubleshooting guide
record it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
* fix(mobile): contain panics at the FFI boundary and say when it happened
MOB-03, MOB-07 and MOB-09 of the frontend audit, plus the Rust half of FE-03
and AND-06. The headline is that MOB-03 was worse than the audit said.
MOB-03 -- every panic killed the mobile apps
The audit asked for `catch_unwind` on the hand-written JNI (`rustynes-android`)
and C-ABI (`rustynes-ios`) entry points, assuming UniFFI's generated methods
were already protected. Neither held in shipped builds: the Android `.so` and
the iOS static library are built `--release`, and the workspace `release`
profile sets `panic = "abort"`. Under abort nothing reaches a catch -- not the
guard the audit proposed, and not the `catch_unwind` UniFFI wraps each exported
method in -- so any panic anywhere in the bridge terminated the app with no
auto-save and no error report.
- New `[profile.release-mobile]`: `release` plus `panic = "unwind"`. Used by
Gradle's `cargoNdkBuild`, `android.yml`, and `scripts/build-ios-xcframework.sh`
(including every `target/<triple>/release/` path, now `release-mobile/`).
Desktop, libretro and wasm keep `release` and its abort.
- `rustynes_mobile::catch_ffi_panic` runs a body under `catch_unwind` and
returns the panic message; each shim's `guarded(entry, fallback, body)` logs it
and returns a harmless value (a null handle, a dropped frame, 0). All 6 JNI
entries and all 14 C-ABI entries are wrapped (counted, not assumed).
Cost, measured on arm64 (`cargo ndk`, both builds of this tree):
librustynes_android.so 11.64 MB -> 13.74 MB (+18%)
librustynes_mobile.so 6.21 MB -> 7.32 MB (+18%)
Frame time, criterion `nes_run_frame_nestest_fast`, interleaved A/B on one host:
abort 3.947 / 3.936 ms, unwind 3.949 / 3.940 ms -- +0.04% to +0.08%, noise.
So the price is size, not speed. Reverting is one line in Cargo.toml.
Verification: `catch_ffi_panic` has a host unit test. The Android shim compiled
under `release-mobile` via Gradle's cargo-ndk step (both ABIs). The iOS shim
cannot be compiled here (the vendored C needs an Apple SDK, and PR CI never
builds iOS -- only the tag-triggered `ios.yml` does), so `ffi.rs` was
type-checked on Linux in a scratch crate that includes the real file via
`#[path]` against stubs of `gfx_metal` / `audio` (the real `audio_dsp`); 9 tests
passed there, one calling a wrapped entry. The real Metal/CoreAudio modules
remain uncompiled until the maintainer's iOS build.
MOB-07 -- a recovered poison was silent
Poisoning only became reachable with unwind: a panic in a method holding
`inner` is now caught by UniFFI and leaves `Inner` possibly half-updated.
`NesController::lock` used to take the state and carry on. It now ends any
netplay session (the peers are simulating a game this one may have left
mid-frame), queues `HostWarning::RecoveredFromInternalError`, and clears the
poison so the warning fires once per panic. It does not power-cycle: that would
throw away unsaved progress without asking. The new variant needed the host
sides in the same change, because both match `HostWarning` exhaustively:
Kotlin `hostWarningText` + `host_warning_recovered_from_internal_error` (EN, ES),
Swift `warningText` + a `Localizable.xcstrings` entry (EN, ES).
`recovering_from_a_panic_ends_netplay_and_warns_once` poisons the lock with a
real panic; three mutations caught (silent recovery, poison not cleared, netplay
surviving).
MOB-09 -- TURN DNS under the frame lock
`np_host_room` / `np_join_room` ran `to_nat_config`, which resolves the TURN
host, while holding `inner`, stalling the emulation thread's next frame for as
long as DNS took. It now runs before the lock. `NatConnect` construction stays
under it, so a concurrent `load_rom` cannot swap the ROM under the session's
hash. Not under test: a test cannot make DNS slow deterministically; the order
is verified by reading. (The Android callers already use Dispatchers.IO; the iOS
callers are on the main actor and are fixed in the Swift tier.)
FE-03 and AND-06 (docs)
FE-03 (report §2.1, raw handles dereferenced as `&mut` across threads) was
REFUTED on both platforms by tracing every caller: all Android native calls on
a handle come from its one `nes-gl` render thread; all iOS calls are on the main
thread. Both shims now document that invariant and that it rests on the host
convention, and the Android `nativeDestroy` SAFETY comment no longer claims the
Kotlin side nulls a field (the handle is a local). AND-06: the manifest, module
docs, `docs/android.md` diagram and (by dated amendment) ADR 0024 claimed an
AAudio sink and focus listener that were never built; audio is Kotlin's
`AudioTrack`. Corrected.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
* fix(android): keep battery saves, write atomically, stop when stopped
The Android tier of the v2.7.4 frontend audit line (AND-01..05, 08, 09),
triaged against the code before any change; the per-item verdicts go in the
frontend ledger. AND-07 needed no Kotlin change (its residual was the bridge
mutex, fixed by MOB-01); AND-06 was documentation, fixed with the Rust tier;
AND-10 is recorded as won't-fix (trim callbacks do not change the low-memory
killer's score, which the audit claimed).
AND-09 -- battery saves (the mobile half of FE-01)
`Battery.kt`: `BatteryStore` puts the save at `filesDir/battery/<rom-sha256>.sav`
(the save-state key), and `BatterySaver` applies the desktop's rules: only a
battery cartridge, size checked from metadata BEFORE reading, a wrong-size or
unreadable file never loaded and never overwritten that session, "clean" means
equal to the last write. The `.sav` is loaded before the first frame and before
the auto-resume state (which is newer and restores cartridge RAM too). It is
compared once a second from the loop with the write on Dispatchers.IO (at most
one in flight), and written synchronously in the background save, on close, and
on dispose. On a ROM switch the outgoing save is written AFTER the new
controller is swapped in: the loop no longer runs the old one, so its RAM is
final, and the write never blocks the UI thread.
AND-05 -- non-atomic writes
`writeAtomic` over `androidx.core.util.AtomicFile` (side file, fsync, rename;
`failWrite` on any error) now backs save-state slots, RA progress, the recents
list and the battery save -- and `GameLibrary` / `GameConfig` too, which the
audit did not name but hold user data (favourites, box art, per-game filters)
behind the same truncating `writeText`. The androidx class is plain Java, so the
tests run the real thing.
AND-04 -- main-thread I/O
`loadRom` split into `prepareRom` (the stream read, the hash, the `.sav` and
auto-state loads, the persistable grant, the recents and library writes) on
Dispatchers.IO, and `publishRom` (swap in, per-game filter) on Main, via one
`openRom` helper for all four entry points; the SAF display-name query moved to
the I/O side too. The save-slot sheet reads its timestamps and thumbnails in one
`produceState` on IO, and its Save / Load / Delete run there. The background
save in `onPause` stays synchronous on purpose: the process may be frozen or
killed as soon as it returns.
AND-03 -- running and playing in the background
The Activity pauses emulation in `onStop` and resumes in `onStart` only what it
paused, so a game the player paused stays paused. Keyed on STOP, not PAUSE:
picture-in-picture calls `onPause` without `onStop` and must keep playing.
`AudioPlayer` now requests AUDIOFOCUS_GAIN; on a loss (transient or not) it
drops samples (the loop paces on its wall-clock floor), ducks to 0.2 on
CAN_DUCK, restores on GAIN, pauses the track and abandons focus while emulation
is paused, and re-requests focus on resume, since a permanent loss is not handed
back. Emulation is not paused for focus: a notification sound should not stop a
game.
AND-01 / AND-08 -- the render thread
Each render thread is a `Worker` with its own stop token. Before, one shared
`running` flag served all of them and `surfaceCreated` refused to start a thread
while a stalled one lived, leaving the view black until the next
background/foreground. The audit's fix ("set running = true regardless") would
have revived the old thread, bound to the dead surface. Now a new surface always
gets a new worker, which joins its predecessor before any native call (keeping
the one-native-user invariant FE-03 rests on), and only the CURRENT worker may
consume pending surface state, checked under the same lock that retires the old
one, so a retiring thread cannot steal the new surface's init. The 2 ms
sleep-poll (up to 500 wakeups a second idle) is a park with a one-frame safety
timeout; every producer unparks.
AND-02 -- recomposition
The per-frame `frame` read sat inside inline Row -> Column -> Box, so each new
frame re-ran all of `EmulatorScreen`. The picture now reads it inside
`FrameScope`, its own restart scope, and the body's idle-screen test uses a
`derivedStateOf { frame != null }` that changes only when a game opens or
closes. Deferred with the reason: skipping the per-frame bitmap pack on the GPU
path (the triage's second suggestion) would break screenshots, clips and
casting, which read that bitmap; it needs an on-demand capture path first.
Tests: the app had no unit tests. `app/src/test` + JUnit 4 +
`unitTests.isReturnDefaultValues` (so `android.util.Log` stubs return instead of
throwing). `PersistenceTest`, 7 tests: an interrupted write keeps the previous
contents and leaves no side file; the battery rules (first write, no rewrite
when unchanged, wrong size untouched, disabled never writes, and a `.sav`
symlinked to /dev/zero refused from metadata). Mutations caught: `writeAtomic`
as a plain write; always rewriting; the disable ignored; the metadata size check
removed. That last one first came back NOT CAUGHT -- `read` catches the
OutOfMemoryError a read-first provokes, so both paths returned `Unusable` -- and
the test now asserts which check refused the file.
Not under automated test, and on the maintainer's device checklist: the render
thread restart (AND-01), recomposition counts (AND-02), background pause and
audio focus (AND-03), I/O off the main thread (AND-04; StrictMode logs it in
debug builds), and the park/unpark wake (AND-08).
Verification: `./gradlew :app:testFossDebugUnitTest :app:compilePlayDebugKotlin`
BUILD SUCCESSFUL (both flavors compile; 7 tests, 0 failed).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
* fix(ios): rate-control the audio sink and report a dead stream
The Rust half of the iOS tier of the v2.7.4 frontend audit line: IOS-02,
IOS-03, IOS-09 and the sink side of IOS-08, plus a CI check that compiles the
iOS-only Rust on Linux for the first time.
Where the code went. The ring, the rate control and the channel fan-out moved
into a new host-compiled module, `audio_ring.rs` (the way `audio_dsp` already
is), so they are unit-tested on Linux; `audio.rs` (still `cfg(target_os =
"ios")`) keeps only the cpal wiring.
- IOS-02, no dynamic rate control -- CONFIRMED, though not as the audit
described it. The triage found the audit's mechanism wrong (the pacing is
wall-clock at 60.0988 Hz, so the ring does not "quickly fill"): what was real
is that nothing steered the queue, so it sat wherever the host clock and the
audio clock drifted, up to a 250 ms cap, and the first callbacks underran
because playback started empty. `Producer` now runs a 4-tap Hermite resampler
steered by the ring's fill (the desktop's ±1% law), holding the queue at
50 ms; `Ring` starts draining only once the target is queued and re-arms
after an underrun. The resampler is the same algorithm as
`rustynes-frontend/src/resampler.rs` -- this project's own code, no
provenance header -- copied because the iOS crate cannot depend on the
desktop frontend; the copy says so and names its twin.
`rate_control_steers_the_ring_to_its_target` starts the ring empty and at
9000 samples, feeds it at exactly the nominal rate, and requires it to settle
within 25% of the target; pinning the ratio at 1.0 fails it.
- IOS-03, per-sample atomics in the real-time callback -- CONFIRMED, severity
overstated (nanoseconds per sample). `Ring::pop_into` does one index load and
one store per chunk, into a buffer the callback allocates once at stream
build, never per call. A cost property with no observable output, so a
mutation back to per-sample indexing is expected to pass the tests; the site
says so.
- IOS-09, surround channels fed the left image -- REFUTED as unreachable
(cpal 0.18 always negotiates stereo on iOS, and only the DSP-enabled path was
affected), fixed as hygiene: `fan_out` gives extra channels the centre.
- IOS-08 (sink side): the error callback only logged. A fatal kind -- the same
three the desktop reopens on, which include how cpal reports a media-services
reset -- now sets a flag exposed as `rustynes_ios_audio_is_invalid` (declared
in `rustynes_ios.h`, panic-guarded like every other entry); the Swift tier
polls it and rebuilds the sink. Every stream build also passes a 2 s timeout
instead of `None`.
Mutations on `audio_ring`, all caught: rate control pinned at 1.0; the start
threshold removed; no re-arm after an underrun; surround channels back to the
left image.
`scripts/ios-host-typecheck.sh`, run in the CI lint job. Nothing on a pull
request compiled `audio.rs` or `ffi.rs` -- only the tag-triggered `ios.yml`
did, and that needs an Apple SDK -- so this release's FFI guards and these sink
changes would first have compiled at release time. The script generates a crate
under `target/` that includes the REAL `audio_dsp.rs`, `audio_ring.rs`,
`audio.rs` and `ffi.rs` via `#[path]`, compiles `audio.rs` against the real cpal
(its API is the same on every backend), stubs only `gfx_metal` (wgpu/Metal,
which genuinely needs iOS), and runs 16 tests including one through a wrapped
FFI entry. Shown to fail: a planted `u8`/`bool` mismatch in `ffi.rs` exits 101.
It cannot check `gfx_metal.rs`, or runtime behaviour on the iOS backend.
Verification: `cargo test -p rustynes-ios --lib` 15 passed (8 DSP + 7 ring);
`scripts/ios-host-typecheck.sh` 16 passed; clippy -D warnings clean.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
* fix(ios): battery saves, 60 Hz display sync, background-safe cloud sync
The Swift tier of the v2.7.4 frontend audit line, and the Swift halves of
MOB-05, MOB-09 and IOS-08. Every item was triaged against the code first
(verdicts go in the frontend ledger); IOS-06, IOS-07 and IOS-09 were refuted.
NONE OF THIS IS COMPILED HERE. There is no Swift toolchain on this Linux
machine, and PR CI never builds iOS (only the tag-triggered ios.yml does). The
changes are kept small and conventional, every UniFFI call follows the label
spelling of existing calls (`loadBatteryRam(bytes:)` like `loadPalette(bytes:)`),
and the project is Swift 5 mode with an iOS 17 floor (checked in project.yml).
The maintainer's device checklist has to build and exercise each item before
the PR merges.
- MOB-05, battery saves (`BatterySave.swift`): the desktop's and Android's rules
-- battery bit only, size checked from attributes before reading, a wrong-size
file never loaded and never overwritten that session, atomic writes on a
private serial queue. Loaded before the first frame; compared once a second
(write off the main thread); written synchronously on close and on a game
switch, and inside a background task when the app backgrounds.
- IOS-04 (`BackgroundTask.swift`): CloudKit uploads now run in a UIKit
background task, which the helper always ends -- including from the
expiration handler, because a task left running past its time gets the app
terminated. The triage also found the reconcile marking a slot `.synced` when
the LOCAL copy was newer and had never uploaded (a suspended or failed
upload); it now re-uploads it.
- IOS-10: uploads used `.allKeys` (client wins), so a device holding an older
save that uploaded later replaced a newer one. `performUpload` fetches the
server record, skips if it is newer (reconcile pulls it), otherwise updates
that record and saves with `.ifServerRecordUnchanged`, so a concurrent write
from another device fails the save instead of being lost. The per-record save
result is now checked; it was discarded, so a per-record failure read as
success. Wall-clock timestamps still order saves, but the overwrite window is
closed by the change tag.
- IOS-01: the display link asked for 60-120 Hz (preferring 120) and paced the
core with a 60.0988 Hz wall-clock accumulator, which hitches about every 5 s
at 120 Hz and every 10 s at 60 Hz (the triage's arithmetic). It now asks for
exactly 60 Hz and, when the link really runs at ~60 Hz, runs exactly one
console frame per vsync (display sync). The core then runs 0.16% slow, inside
the +/-1% the new sink rate control absorbs. Any other refresh keeps the
accumulator.
- IOS-08: a media-services reset now re-configures the session and rebuilds the
sink; the core also rebuilds any sink reporting its stream dead
(`rustynes_ios_audio_is_invalid`), throttled to one attempt per 2 s, re-applying
the audio-depth settings. A new hardware rate cannot be matched without
rebuilding the core, and is logged. The adjacent defect the triage found -- an
unplugged headset paused the game until a background/foreground cycle, with
no in-app way out -- is now its own pause reason, cleared by the next menu close.
- IOS-05: `.defersSystemGestures(on: .bottom)` and `.persistentSystemOverlays(.hidden)`
on the game view (the pad already sat above the safe area; the risk was a thumb
sliding off the bottom).
- IOS-11: at `.serious` / `.critical` thermal state the video filter is suspended
until the device cools; the chosen filter is untouched.
- MOB-09 (Swift): `NetplayModel` is @mainactor and called `npJoin`,
`npHostRoom` and `npJoinRoom` synchronously, running their DNS lookups on the
main thread; they now run in detached tasks and publish back on the main actor.
- Found by the triage, not in the audit: opening a ROM from another app while a
game ran swapped the core under a `MetalGameView` whose coordinator kept the
old one (renderer already destroyed) and never started the new one -- a frozen
screen. The view is now keyed to its core with `.id`.
- IOS-06 (refuted; GCController's handler queue defaults to main): the queue is
now set to main explicitly, since the file's own comment assumed otherwise.
- The `EmulatorCore` header claimed a separate display-link thread; the handles
are touched only on the main thread, which is what FE-03's safety rests on.
Corrected, with that warning.
docs/ios.md records all of it, including the unverified status.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
* docs(audits): record a verdict for all 30 mobile rows, and FE-03
The frontend ledger's AND-01..10, IOS-01..11 and MOB-01..09 rows were still
"untriaged" from the v2.7.0 seeding. Each now carries a verdict, the evidence
behind it, and the commit that acted on it:
- Fixed and pinned by a red-first test where a test can observe it (MOB-01,
02, 04, 05, 07, 08 and the Android persistence path).
- Refuted: IOS-06 (GCController.handlerQueue defaults to main and was never
changed, so there was no race; set to main explicitly anyway, since the
file's own comment assumed off-main delivery), IOS-07 (the display link
runs on the main run loop, which drains its autorelease pool every
iteration; the per-frame buffers are ARC Data and Rust Vecs), IOS-09
(unreachable: cpal 0.18 always negotiates stereo on iOS; the fan-out was
fixed as hygiene with a caught mutation), and FE-03 (every caller traced:
each shim's handles are driven from one thread only; the invariant is now
documented in both shims).
- MOB-03 recorded as worse than reported: the mobile libraries were built
panic = "abort", so neither the proposed guards nor UniFFI's own could
contain anything.
- AND-10 won't-fix: the callbacks are indeed absent, but adding them does not
change the low-memory killer's score, which depends on process state.
- MOB-06 (the per-frame framebuffer copy through UniFFI) deferred to v2.7.5
with the other performance work, where it has to clear the >3% A/B bar.
FE-03 is new: it is in the report's section 2.1 prose but not in its section
8 table, so the seeding missed it. A stray escaped quote in the MOB-03 row is
also fixed.
Also fixes a rustdoc failure in crates/rustynes-ios/src/audio_ring.rs, whose
module preamble's intra-doc links to Ring, Ring::pop_into, Producer and
fan_out did not resolve under `RUSTDOCFLAGS="-D warnings" cargo doc
--workspace --no-deps`; they are now crate-qualified paths. Doc text only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
* chore(release): cut v2.7.4 "Pocket"
The fifth release of the ADR 0041 audit line: the mobile apps survive what a
phone does to them. The fixes are the six commits before this one; this commit
is the version bump and the release documents.
- Workspace and rustynes-cosim to 2.7.4 via
scripts/release-automation/bump_release.py (every current-release anchor,
the libretro .info display version, the cosim lockfile). The nested "Built
on" chains are trimmed back to one level in AGENTS, README, SUPPORT,
VERSION-PLAN and to-dos/ROADMAP, as at every cut; OVERVIEW, SECURITY and
STATUS keep the full chain by convention.
- CHANGELOG [2.7.4], a VERSION-PLAN row (v2.7.3 loses "(current)"), the
to-dos/ROADMAP release-line tail, the ROADMAP status line, and the
maintainer-authored release-notes override that release-auto.yml publishes.
- to-dos/plans/v2.7.4-pocket-plan.md with an As-built table, and the v2.7.4
row of the line plan marked as built.
- docs/mobile-v2.7.4-device-checklist.md: 2 build steps, 12 Android and 13
iOS rows, each naming the finding it checks. It exists because of the
2026-09-22 decision that mobile work is done in all tiers and a device run
gates the merge.
What this release does NOT establish, stated here so the tag does not imply
it: no Swift in v2.7.4 has been compiled -- this machine has no Xcode and no
pull-request job builds iOS; the Android render-thread restart, audio focus,
background pause, recomposition scoping and park/unpark have no automated
test; and the iOS audio-sink rebuild and 60 Hz display sync have not run on a
device. The Rust halves are host-tested (scripts/ios-host-typecheck.sh, now a
CI step). The checklist covers each; the release notes say the same.
Verification on this tree:
cargo test --workspace --features test-roms
154 suites, 2,716 passed, 0 failed
AccuracyCoin (RAM decoder): 144 total, 0 fail
nestest: pass
release_anchor_audit 15/15, release_state_prose_audit 2/2,
release_notes_render_audit 8/8
fmt, clippy (every feature combo AGENTS.md lists, both wasm32),
cargo doc -D warnings, thumbv7em no_std build: clean
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
* fix(mobile): contain a mid-frame panic in the bridge and freeze the game
Correction to MOB-03 as shipped earlier on this branch: unwinding did not
keep the app open. v2.7.4 switched the mobile libraries to panic = "unwind"
so panics could be caught, and the CHANGELOG, release notes and warning text
all said that after one "the game keeps running". For the calls that matter
most, that was false.
Mechanism. UniFFI does catch a panic in every exported method, but what it
does next depends on whether the method is declared fallible. run_frame,
step_frame and np_advance_frame are not, so:
- Swift: the generated wrapper is `try! rustCall { ... }` (verified by
generating the bindings from the host library with the project's own
uniffi-bindgen 0.32.1: rustynes_mobile.swift, `open func runFrame()`).
The caught panic becomes UniffiInternalError.rustPanic, and `try!` turns
that into a crash.
- Kotlin: the wrapper throws InternalException("Rust panic"). The frame loop
in MainActivity runs in a Compose LaunchedEffect with a try/finally and no
catch, and an exception escaping a LaunchedEffect is rethrown by the
recomposer, which crashes the app (read from the code, not reproduced).
So a core panic at 60 Hz still closed both apps; the new JNI / C-ABI guards
only covered the renderer and audio shims.
Fix. NesController::contained_frame runs a frame body under
catch_ffi_panic inside the bridge. The panic unwinds out of the body, which
drops the guard and poisons the mutex; taking the lock again runs the
MOB-07 recovery (ends netplay, queues RecoveredFromInternalError once), and
that recovery now also sets a new `frozen` flag. A frozen machine runs no
further emulated cycle: run_frame returns the current framebuffer,
step_frame does nothing, np_advance_frame reports a stall. A fresh start
thaws it: load_rom, power_cycle, or a successful load_state (a whole
snapshot replaces whatever the panic left behind). Reset deliberately does
not, since it keeps most of the machine's state.
Why freeze rather than carry on: this answers the one part of agy's round-1
"silent failure" finding that held. After a panic the core may be half-way
through an update; running on from there could, for instance, let a
confused game overwrite its own battery save, which the phones now persist
periodically. Freezing keeps the RAM as the game last wrote it. The audit's
own MOB-07 remedy was a power-cycle; freezing keeps the picture on screen and
leaves the choice to the player. The warning text says so in all five
places (Rust default, Android EN/ES, Swift, xcstrings EN/ES): "RustyNES
stopped the game after an internal error. Any netplay session was ended.
Reopen the game or load a save state to continue."
Scope, deliberately: only the three frame paths are wrapped. They are the
calls that execute emulated cycles and the ones made sixty times a second,
so where a core defect surfaces. Every other exported method keeps UniFFI's
guard, so a panic there still reaches the host as an exception; the ledger
row says so.
Test: a_panic_mid_frame_freezes_the_game_instead_of_reaching_the_host, via
a cfg(test) thread-local fault injector compiled out of real builds. It
failed first (the panic escaped run_frame). Six mutations, each reverting
one piece: freeze not set, no catch, frozen check removed, and each of the
three thaws removed -- all CAUGHT. The load_rom thaw was first NOT CAUGHT
(stimulus blind): a fresh Nes reports frame 1 and its first frame does not
advance the counter, so `frame() == 1` held frozen or not. The test now
runs two frames and asserts the counter moved, as for the power cycle,
which has the same first-frame property.
Clippy: an allow for struct_excessive_bools on Inner (four independent
flags; an enum would encode combinations that are all legal).
Verified: cargo test -p rustynes-mobile 36 passed; clippy -D warnings on
rustynes-mobile/-android/-ios all targets; rustdoc -D warnings;
scripts/ios-host-typecheck.sh; fmt. The string resources parse. NOT
verified: the Swift and Kotlin sides of the new text on a device -- the
checklist's A12 row is updated to the new expectation.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
* fix(android,ios): close three races and two stale docs from review
Copilot round 1 on #552 made five findings; all five held when checked
against the code. Three are races this branch introduced or left behind,
two are documentation that now contradicts the code.
1. Android battery: a late periodic flush could overwrite the final save.
The periodic job ran `saver.flushIfChanged(ctrl.batteryRam())` on
Dispatchers.IO, so the RAM was read BEFORE the saver's lock. A job that
read, then waited on the lock while a final flush (close, ROM switch,
onStop) wrote newer RAM, then wrote its older copy over it -- losing up to
a second of in-game progress in the case the final flush exists for.
There is no cross-ROM mix-up (each ROM gets its own NesController and
BatterySaver, and the job holds both), which I checked because it would
have been the worse failure. Fix: flushIfChanged takes a reader, `() ->
ByteArray`, and calls it under the lock; all three call sites pass
`{ ctrl.batteryRam() }`. A late flush then reads RAM at least as new as
the final one and finds it unchanged. The Rust mutex the reader takes is
never held while calling back into Kotlin, so there is no lock-order
cycle. New JVM test a_late_periodic_flush_does_not_overwrite_the_final_one
pins the saver's contract; that each call site passes a reader and not
bytes read earlier is a property of MainActivity, checked by reading it
and not by the test, which says so.
2. Android render worker: `worker = next` was published before
`next.thread` was assigned, and `wake()` -- called from the emulation
thread by submitFrame / setFilter / surfaceChanged -- reads
`worker?.thread`. Reading an unassigned `lateinit` throws
UninitializedPropertyAccessException, so a frame submitted in that window
crashed the loop. Fix: the Thread is constructed inside the same
synchronized block, before the worker is published (the @volatile write
of `worker` then publishes the assignment); it is started after the lock
as before. Not under automated test (a timing window on a device thread);
device checklist A7 exercises surface churn.
3. iOS netplay: the join / host-room / join-room calls moved to
Task.detached in this release (MOB-09), and their completions were not
tied to the game. Closing or swapping the game while a DNS lookup or
relay handshake was in flight let the completion publish a session on
the OLD core and start polling it, leaving an orphaned session nothing
ever left. Fix: an attach/detach generation counter; each operation
captures it, and a completion from a stale generation calls npLeave on
the core it used (if a session came up) and publishes nothing, errors
included. Swift, uncompiled here, like the rest of the iOS changes.
4. README: the paragraph after the current-release anchor still described
v2.7.3 (2,698 tests; "the mobile apps still keep [battery saves] only
inside a save state"). Rewritten for v2.7.4: 2,716 tests, all three
hosts keep battery saves, and the device checklist named.
5. CloudSaveStateSync.swift: the file header still said uploads were
force-overwrite / client-wins and that local-newer slots were already
uploaded -- the exact behaviour IOS-10 and IOS-04 removed. A later edit
trusting that summary could reintroduce the data loss. Rewritten to the
conditional policy, with the pre-v2.7.4 behaviour named as history.
Also corrected while searching for more of (4): docs/mappers.md still said
"the desktop and mobile frontends do not persist cartridge battery RAM at
all", which has been false for the desktop since v2.7.3; it now lists each
host's `.sav` and the battery-bit gate they share.
Verified: Android `:app:testFossDebugUnitTest` 8 tests, 0 failures (7 before
+ the new one) and `:app:compilePlayDebugKotlin`, both with the Rust build
tasks excluded (no bridge API changed). Swift not compiled.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
* fix(mobile): act on CodeRabbit round 1, and make CI run the iOS check
CodeRabbit's round 1 on #552 made ten findings. Nine held against the code
and are fixed here; one was already fixed in the previous commit (the
render-worker publication order, also found by Copilot). Plus a CI failure
of my own.
CI: the new "iOS shim + audio sink host type-check" step failed with exit
126. scripts/ios-host-typecheck.sh was committed 100644: this repository
runs core.fileMode=false (the NAS convention), so the executable bit set
locally was never recorded. `git update-index --chmod=+x` fixes the index.
Because that step failed, the three lint-job steps after it -- clippy with
`full`, clippy on the mappers with mapper-audio compiled out, and the
commercial-roms harness compile -- did not run in CI on this PR (rustdoc and
the other clippy combos run before it and passed). This push runs them.
The warning nobody saw (MOB-07). Both apps drained host warnings only after a
movie load, so RecoveredFromInternalError -- queued when the bridge contains
a panic -- never reached the screen, and "the app says so" in the notes was
not true. Android now drains every 15 frames in the loop (the RA / netplay
cadence); iOS polls once a second while a game is open.
The Android frame loop now catches InternalException around each iteration.
The previous commit contains panics inside the bridge for the three frame
paths; any other bridge call the loop makes (npIsActive, drainAudioBytes,
indexFramebufferBytes, ...) still reaches Kotlin as InternalException, which
would end the LaunchedEffect and crash the app. Caught, it is logged, the loop
waits one frame, and the next bridge call recovers the lock, freezes the game
and queues the warning. (The diff for MainActivity is large because the loop
body is re-indented into the try; the logic change is the catch and the
poll.)
Android battery, same file (AND-09). Reopening the running game gives the
outgoing and incoming sessions separate BatterySavers for one `.sav`; their
instance locks did not exclude each other, so their AtomicFile writes could
meet in the one staging file per path. Savers now share a per-path lock. I
tried to pin this first: a JVM stress test of two savers writing 8 KiB images
concurrently passed 3 of 3 runs on the UNFIXED code (each image is a single
write call on the JVM), so it could not fail and was not kept; the lock is
structural and says so at the site. Separately, openRom now writes the
running game's save before preparing the new ROM, so a reopen reads it back
missing at most the frames run during preparation rather than up to a
second.
iOS audio ring soundness (IOS-02/03). Moving Ring out of audio.rs made it
public, with `&self` push / pop_into on a `Sync` type: safe code could call
push from two threads, both load the same tail and write one UnsafeCell --
undefined behaviour reachable without `unsafe`. Ring is private again;
`ring(cap, threshold)` returns one RingTx and one RingRx, neither Clone, both
`&mut self`, so the single-producer / single-consumer rule the unsafe relies
on is enforced by the types. Producer owns the RingTx; the cpal callback owns
the RingRx. The tests are rewritten against the handles; behaviour is
unchanged (15 rustynes-ios tests, 16 in the host type-check).
iOS audio retry (IOS-08). A rebuild that destroyed the sink and failed to open
a new one left `audio` nil, and recoverAudioIfNeeded returned early on nil --
so the log said "retrying" and nothing did. An audioLost flag keeps the
retry going on the existing 2 s throttle; a sink that never opened at init
stays silent as before.
iOS thermal (IOS-11). A thermal transition called applyDisplaySettings, which
also re-reads the palette and the HD pack from disk on the main actor. It now
applies the filter only.
iOS netplay (MOB-09). CodeRabbit refined Copilot's finding fixed in the
previous commit: the stale-completion npLeave has no session identity, so it
is safe only if nothing newer could have started on the same core. Now only
one join / host runs at a time (the others report "Still connecting"), and
Leave also bumps the generation, so a completion that lands after Leave ends
its own session instead of reviving one the player left. A native
per-operation generation, as suggested, would also work but changes the
bridge API for a case the one-at-a-time rule already closes.
Docs: the uniffi-bindgen example path is release-mobile/ (it still said
release/); docs/android.md now carries the size and frame-time figures its
"measured below" pointed at nothing for; to-dos/ROADMAP.md no longer says
AccuracyCoin "reads 143/144 from v2.6.17" -- it has read 144/144 since
v2.6.18. The release notes, checklist (I14, I15) and ledger (seven rows
annotated "Review (#552)") follow.
Also from agy round 1: the battery background task in AppModel now ends in a
`defer`. agy named BatterySave.swift, which begins no task, and flushNow
cannot throw, so there was no leak; the defer is for the next early return.
Verified: cargo test -p rustynes-mobile 36 and -p rustynes-ios 15; clippy -D
warnings on the three mobile crates; rustdoc; ios-host-typecheck (16);
Android :app:testFossDebugUnitTest 8/8 and :app:compilePlayDebugKotlin;
release audits 15/2/8; markdownlint. NOT verified: every Swift change
(uncompiled here) and the Android loop's catch, which needs a device panic.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
* fix(core,ios): assert the restore rollback, log iCloud upload failures
From agy round 2 on #552. Two changes; the rest of the round is answered in
the PR.
nes.rs: the MOB-08 rollback ignored the result of re-applying the backup
(`let _ = self.apply_snapshot(&backup)`). That was deliberate and commented:
the backup is this core's own fresh snapshot, the round-trip every
save-state test pins, and the ORIGINAL error is the one worth returning.
agy's point that a broken invariant would pass silently is still fair, and
the core is no_std with no logger, so the result is now debug_assert!-ed.
Debug and test builds fail loudly if the invariant ever breaks; the release
build is unchanged (the assert compiles out), so no golden or byte-identity
is affected.
CloudSaveStateSync.upload: both failure branches returned nil with nothing
logged -- the per-record failure (typically serverRecordChanged, another
device wrote first) and a thrown modifyRecords error. Each is now logged
with the slot and the error, which is what a device-checklist run of I7/I8
needs to tell a conflict from an outage. Behaviour is unchanged. Swift,
uncompiled here.
Verified: cargo test -p rustynes-core a_failed_restore (1 passed), clippy -D
warnings on rustynes-core all targets, thumbv7em no_std build.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>1 parent 79a5b41 commit e02998b
65 files changed
Lines changed: 4000 additions & 924 deletions
File tree
- .github
- release-notes
- workflows
- android/app
- src
- main
- java/com/doublegate/rustynes
- res
- values-es
- values
- test/java/com/doublegate/rustynes
- crates
- rustynes-android
- src
- rustynes-cheevos/src
- rustynes-core/src
- rustynes-cosim
- rustynes-ios
- include
- src
- rustynes-libretro
- rustynes-mobile/src
- bin
- rustynes-netplay/src
- docs
- adr
- audits
- user-guide
- ios/RustyNES
- scripts
- to-dos
- plans
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
129 | 129 | | |
130 | 130 | | |
131 | 131 | | |
132 | | - | |
| 132 | + | |
133 | 133 | | |
134 | 134 | | |
135 | 135 | | |
136 | 136 | | |
137 | 137 | | |
138 | | - | |
| 138 | + | |
139 | 139 | | |
140 | 140 | | |
141 | 141 | | |
| |||
146 | 146 | | |
147 | 147 | | |
148 | 148 | | |
149 | | - | |
| 149 | + | |
150 | 150 | | |
151 | 151 | | |
152 | 152 | | |
| |||
159 | 159 | | |
160 | 160 | | |
161 | 161 | | |
162 | | - | |
163 | | - | |
| 162 | + | |
| 163 | + | |
164 | 164 | | |
165 | 165 | | |
166 | 166 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
446 | 446 | | |
447 | 447 | | |
448 | 448 | | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
449 | 456 | | |
450 | 457 | | |
451 | 458 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | | - | |
| 46 | + | |
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
| |||
225 | 225 | | |
226 | 226 | | |
227 | 227 | | |
228 | | - | |
| 228 | + | |
229 | 229 | | |
230 | 230 | | |
231 | 231 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
5 | | - | |
| 5 | + | |
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
| |||
0 commit comments