Rename dev/ to demo/, split moq-boy into rs/ and js/#1204
Conversation
Rename the dev/ folder to demo/ to better reflect its purpose. Split the moq-boy hybrid project into rs/moq-boy (Rust emulator) and js/moq-boy (web viewer), matching the repo's convention. Keep ROM hosting infrastructure in demo/boy/. Flatten worker.ts files out of src/ in demo/boy/ and demo/pub/. Add `just dev` as alias for `just demo web`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
WalkthroughThis pull request reorganizes the repository structure by renaming the 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
rs/moq-boy/src/emulator.rs (1)
68-71: Prefer.context(...)over re-wrapping these Boytacean errors.These
map_err(anyhow!(...))calls flatten the source chain, which makes emulator startup/reset failures harder to diagnose. If theboytaceanerror types implementstd::error::Error, switching these to.context("failed to initialize emulator")?,.context("failed to load ROM")?, and.context("failed to reload ROM")?will preserve the underlying cause. As per coding guidelines, Useanyhow::Context(.context("msg")) instead of.map_err(|_| anyhow::anyhow!("msg"))for Rust error conversion.Also applies to: 85-90
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@rs/moq-boy/src/emulator.rs` around lines 68 - 71, Replace the .map_err(|e| anyhow::anyhow!("...: {e}"))? patterns with anyhow::Context so the original Boytacean errors are preserved; specifically change the gb.load(false).map_err(...) call to gb.load(false).context("failed to initialize emulator")? and the gb.load_rom(&rom, None).map_err(...) call to gb.load_rom(&rom, None).context("failed to load ROM")? (and similarly update the reload ROM call around the gb.load_rom usage at the 85-90 region to .context("failed to reload ROM")?); also ensure the anyhow::Context trait is in scope (use anyhow::Context) so .context(...) compiles.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@doc/setup/demo/boy.md`:
- Around line 34-35: Update the example ROM invocation in the "demo boy start"
docs to use the named input required by the recipe/binary: replace the
positional path usage with the named parameter rom=... (or the equivalent flag
--rom ...) so the examples like "demo boy start path/to/game.gb" become "demo
boy start rom=path/to/game.gb" (apply the same change to the other example
occurrences in this file).
In `@rs/moq-boy/src/audio.rs`:
- Around line 51-75: The code currently reuses the Opus encoder frame_size
(derived from ffmpeg_encoder.frame_size(), OPUS_FRAME_SAMPLES) for buffering
input at self.input_sample_rate, causing timing drift for 44.1 kHz input; define
a separate input_frame_size (e.g. compute samples for a 20 ms chunk:
(input_sample_rate * 20) / 1000 or special-case 44100→882) and use that for
sample_buffer accumulation and when creating input frames, then after resampling
use the actual resampled output length (or compute the output duration from
input_frame_size and sample rates) to advance frame_count/timestamps instead of
assuming 960/OPUS_SAMPLE_RATE; update places referencing frame_size,
sample_buffer, frame_count, input_sample_rate, resampler and ffmpeg_encoder to
use the new input_frame_size vs the existing Opus frame_size so timestamps
reflect resampled output duration.
In `@rs/moq-boy/src/main.rs`:
- Around line 268-285: emu.pressed_buttons() returns a HashSet so the collected
Vec held has non-deterministic ordering causing new_status_str to flip even when
buttons unchanged; to fix, after creating held (the Vec from
emu.pressed_buttons()), sort it before building new_status (either by deriving
Ord on Button and calling held.sort() or by calling held.sort_by_key(|b|
format!("{:?}", b))) so the serialized new_status/new_status_str and comparison
with last_status are stable.
- Around line 224-250: The blocking emulator loop currently drains commands with
while let Ok(cmd) = cmd_rx.try_recv() but never exits the surrounding infinite
loop when the channel is closed; update the task that processes cmd_rx (the code
using cmd_rx.try_recv() inside the loop that matches on
input::Command::{Buttons, ViewerLeft, Reset}) to detect
TryRecvError::Disconnected and break out or return Ok(()) so the blocking task
can shut down cleanly (i.e., check the Err variant of try_recv() and on
Disconnected perform an early return or explicit shutdown instead of looping
forever).
- Around line 105-106: Replace the separate AtomicBool and (Mutex, Condvar) pair
with a single Arc<(Mutex<bool>, Condvar)> (reuse the name resume_notify) and
change all uses accordingly: initialize with Mutex::new(true) to start paused;
in the emulator thread, lock the mutex and use a loop that checks the bool guard
and calls cvar.wait(&mut guard) while the flag indicates paused; in every resume
path that previously did paused.store(...) and resume_notify.1.notify_all(),
instead lock the same mutex, set *guard = false (or true per your semantics)
while holding the lock, then call notify_all() on the Condvar; update all
locations that previously accessed paused (AtomicBool) to use the Mutex<bool>
guard so the pause predicate is always guarded by the same mutex.
---
Nitpick comments:
In `@rs/moq-boy/src/emulator.rs`:
- Around line 68-71: Replace the .map_err(|e| anyhow::anyhow!("...: {e}"))?
patterns with anyhow::Context so the original Boytacean errors are preserved;
specifically change the gb.load(false).map_err(...) call to
gb.load(false).context("failed to initialize emulator")? and the
gb.load_rom(&rom, None).map_err(...) call to gb.load_rom(&rom,
None).context("failed to load ROM")? (and similarly update the reload ROM call
around the gb.load_rom usage at the 85-90 region to .context("failed to reload
ROM")?); also ensure the anyhow::Context trait is in scope (use anyhow::Context)
so .context(...) compiles.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: b7cd5f3d-e3da-4f85-8482-85cae8299fe6
⛔ Files ignored due to path filters (3)
bun.lockis excluded by!**/*.lockdemo/pub/bun.lockis excluded by!**/*.lockdemo/web/src/favicon.svgis excluded by!**/*.svg
📒 Files selected for processing (64)
CLAUDE.mdCargo.tomlREADME.mddemo/boy/.gitignoredemo/boy/CHANGELOG.mddemo/boy/justfiledemo/boy/worker.tsdemo/boy/wrangler.jsoncdemo/justfiledemo/pub/justfiledemo/pub/media/.gitignoredemo/pub/package.jsondemo/pub/tsconfig.jsondemo/pub/worker.tsdemo/pub/wrangler.jsoncdemo/relay/.gitignoredemo/relay/justfiledemo/relay/leaf0.tomldemo/relay/leaf1.tomldemo/relay/localhost.tomldemo/relay/prod.tomldemo/relay/root.tomldemo/throttle/enabledemo/web/.envdemo/web/README.mddemo/web/justfiledemo/web/package.jsondemo/web/src/discover.tsdemo/web/src/highlight.tsdemo/web/src/index.cssdemo/web/src/index.htmldemo/web/src/index.tsdemo/web/src/mse.htmldemo/web/src/publish.htmldemo/web/src/publish.tsdemo/web/src/vite-env.d.tsdemo/web/tailwind.config.jsdemo/web/tsconfig.jsondemo/web/vite.config.tsdev/boy/src/audio.rsdev/boy/src/emulator.rsdev/boy/src/input.rsdev/boy/src/main.rsdev/boy/src/video.rsdoc/app/web.mddoc/concept/standard/interop.mddoc/js/@moq/lite.mddoc/js/index.mddoc/setup/demo/boy.mddoc/setup/demo/web.mdjs/moq-boy/package.jsonjs/moq-boy/src/index.htmljs/moq-boy/src/index.tsjs/moq-boy/tsconfig.jsonjs/moq-boy/vite.config.tsjustfilepackage.jsonrs/moq-boy/Cargo.tomlrs/moq-boy/src/audio.rsrs/moq-boy/src/emulator.rsrs/moq-boy/src/input.rsrs/moq-boy/src/main.rsrs/moq-boy/src/video.rsrs/moq-relay/README.md
💤 Files with no reviewable changes (5)
- dev/boy/src/input.rs
- dev/boy/src/emulator.rs
- dev/boy/src/main.rs
- dev/boy/src/video.rs
- dev/boy/src/audio.rs
| just demo boy start path/to/game.gb | ||
| ``` |
There was a problem hiding this comment.
ROM invocation syntax is incorrect in the docs.
The examples use a positional ROM path, but the recipe and binary currently require named rom=... input, which maps to --rom. As written, these commands won’t run successfully.
Proposed doc fix
-just demo boy start path/to/game.gb
+just demo boy start rom=path/to/game.gb-just demo boy start path/to/other.gb
+just demo boy start rom=path/to/other.gbAlso applies to: 42-43
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@doc/setup/demo/boy.md` around lines 34 - 35, Update the example ROM
invocation in the "demo boy start" docs to use the named input required by the
recipe/binary: replace the positional path usage with the named parameter
rom=... (or the equivalent flag --rom ...) so the examples like "demo boy start
path/to/game.gb" become "demo boy start rom=path/to/game.gb" (apply the same
change to the other example occurrences in this file).
| let frame_size = ffmpeg_encoder.frame_size() as usize; | ||
|
|
||
| // Set up resampler if input rate differs from Opus rate. | ||
| let resampler = if input_sample_rate != OPUS_SAMPLE_RATE { | ||
| Some(ffmpeg_next::software::resampling::Context::get( | ||
| ffmpeg_next::format::Sample::I16(ffmpeg_next::format::sample::Type::Packed), | ||
| ffmpeg_next::ChannelLayout::STEREO, | ||
| input_sample_rate, | ||
| ffmpeg_next::format::Sample::I16(ffmpeg_next::format::sample::Type::Packed), | ||
| ffmpeg_next::ChannelLayout::STEREO, | ||
| OPUS_SAMPLE_RATE, | ||
| )?) | ||
| } else { | ||
| None | ||
| }; | ||
|
|
||
| Ok(Self { | ||
| opus, | ||
| ffmpeg_encoder, | ||
| resampler, | ||
| sample_buffer: Vec::new(), | ||
| frame_size: if frame_size > 0 { frame_size } else { OPUS_FRAME_SAMPLES }, | ||
| frame_count: 0, | ||
| input_sample_rate, | ||
| }) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
wc -l rs/moq-boy/src/audio.rsRepository: moq-dev/moq
Length of output: 82
🏁 Script executed:
cat -n rs/moq-boy/src/audio.rsRepository: moq-dev/moq
Length of output: 5700
🏁 Script executed:
grep -n "frame_size\|push_samples\|encode_frame\|frame_count" rs/moq-boy/src/audio.rs | head -40Repository: moq-dev/moq
Length of output: 723
🏁 Script executed:
sed -n '80,140p' rs/moq-boy/src/audio.rsRepository: moq-dev/moq
Length of output: 2176
🏁 Script executed:
sed -n '80,100p' rs/moq-boy/src/main.rsRepository: moq-dev/moq
Length of output: 852
🏁 Script executed:
python3 <<'EOF'
# Verify the timestamp math
frame_size = 960
opus_rate = 48000
input_rate = 44100
# Time per frame at 44.1 kHz (actual input consumption)
input_frame_ms = (frame_size / input_rate) * 1000
# Time per frame according to timestamp calculation
timestamp_advance_us = (frame_size * 1_000_000) // opus_rate
timestamp_advance_ms = timestamp_advance_us / 1000
# Drift per frame
drift_ms = input_frame_ms - timestamp_advance_ms
drift_per_second = drift_ms * (1000 / frame_size / (1000 / input_rate))
print(f"Input frame duration at 44.1 kHz: {input_frame_ms:.2f} ms")
print(f"Timestamp advance (line 131): {timestamp_advance_ms:.2f} ms")
print(f"Drift per frame: {drift_ms:.2f} ms")
print(f"Frames per second: {input_rate / frame_size:.1f}")
print(f"Cumulative drift per second: {drift_ms * (input_rate / frame_size):.2f} ms")
EOFRepository: moq-dev/moq
Length of output: 223
Separate the 44.1 kHz input frame size from the 48 kHz Opus frame size to fix audio timing drift.
self.frame_size is derived from the 48 kHz Opus encoder (line 51, storing 960 samples), but it is reused for input buffering at 44.1 kHz (line 91). This causes each input batch to contain 960 samples at 44.1 kHz = ~21.77 ms of audio, while the timestamp at line 131 advances by only 20 ms (960 * 1_000_000 / 48000). The result is 81+ ms of cumulative audio desynchronization per second.
Additionally, line 108 marks the input frame with self.input_sample_rate (44.1 kHz), which is then resampled to 48 kHz before encoding. The resampler will produce approximately 1043 samples at the output, but the timestamp calculation still assumes 960 samples at 48 kHz were generated.
Define a separate input frame size (882 samples/channel for 20 ms at 44.1 kHz) and buffer input accordingly, or derive output timestamps from the actual resampled frame duration rather than the input frame size.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@rs/moq-boy/src/audio.rs` around lines 51 - 75, The code currently reuses the
Opus encoder frame_size (derived from ffmpeg_encoder.frame_size(),
OPUS_FRAME_SAMPLES) for buffering input at self.input_sample_rate, causing
timing drift for 44.1 kHz input; define a separate input_frame_size (e.g.
compute samples for a 20 ms chunk: (input_sample_rate * 20) / 1000 or
special-case 44100→882) and use that for sample_buffer accumulation and when
creating input frames, then after resampling use the actual resampled output
length (or compute the output duration from input_frame_size and sample rates)
to advance frame_count/timestamps instead of assuming 960/OPUS_SAMPLE_RATE;
update places referencing frame_size, sample_buffer, frame_count,
input_sample_rate, resampler and ffmpeg_encoder to use the new input_frame_size
vs the existing Opus frame_size so timestamps reflect resampled output duration.
| let paused = Arc::new(AtomicBool::new(true)); // Start paused until first viewer. | ||
| let resume_notify = Arc::new((Mutex::new(()), Condvar::new())); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's find the file and examine the relevant code sections
find . -type f -name "main.rs" -path "*/moq-boy/*"Repository: moq-dev/moq
Length of output: 79
🏁 Script executed:
# Extract the code at the specified line ranges to understand the pause/resume mechanism
# Lines 105-106, 118-121, 141-145, 165-176, 199-206
sed -n '100,210p' rs/moq-boy/src/main.rs | cat -nRepository: moq-dev/moq
Length of output: 4379
🏁 Script executed:
# Let me get more context around the wait and notify patterns
# to understand the full scope of the race condition
sed -n '100,112p' rs/moq-boy/src/main.rs | cat -nRepository: moq-dev/moq
Length of output: 673
🏁 Script executed:
# Now let's see all the notify paths to verify the pattern
rg "notify_all" rs/moq-boy/src/main.rs -B 2 -A 1Repository: moq-dev/moq
Length of output: 559
🏁 Script executed:
# Let me get the complete context of the wait implementation including the outer if
sed -n '99,115p' rs/moq-boy/src/main.rs | cat -nRepository: moq-dev/moq
Length of output: 774
🏁 Script executed:
# Get all the notify_all() call locations with full context
rg "notify_all" rs/moq-boy/src/main.rs -B 3 -A 0Repository: moq-dev/moq
Length of output: 669
🏁 Script executed:
# Let me extract the full implementation including the outer if statement
sed -n '99,110p' rs/moq-boy/src/main.rsRepository: moq-dev/moq
Length of output: 518
🏁 Script executed:
# Get the absolute line numbers by using grep with line numbers
rg "let paused = Arc::new\(AtomicBool" rs/moq-boy/src/main.rs -nRepository: moq-dev/moq
Length of output: 141
🏁 Script executed:
# Now get the actual lines with proper context
cat -n rs/moq-boy/src/main.rs | sed -n '100,120p'Repository: moq-dev/moq
Length of output: 957
🏁 Script executed:
# Get the wait loop implementation that starts around line 101-110
cat -n rs/moq-boy/src/main.rs | sed -n '130,165p'Repository: moq-dev/moq
Length of output: 1274
🏁 Script executed:
# Get all the resume/notify patterns to understand all code paths
cat -n rs/moq-boy/src/main.rs | sed -n '118,125p'Repository: moq-dev/moq
Length of output: 331
🏁 Script executed:
# Get the pause/resume task at the "Monitor overall pause state" section
cat -n rs/moq-boy/src/main.rs | sed -n '165,185p'Repository: moq-dev/moq
Length of output: 827
🏁 Script executed:
# Get the blocking emulator loop where it waits
cat -n rs/moq-boy/src/main.rs | sed -n '185,220p'Repository: moq-dev/moq
Length of output: 1632
Guard the pause predicate with the same mutex as the Condvar.
The emulator thread's wait loop at lines 204–205 holds the mutex while checking paused and calling cvar.wait(). However, all resume paths (lines 121, 144, 176, 180) modify paused via store() and immediately call notify_all() without acquiring the same mutex. This creates a race: if a resume happens after the check at line 204 but before wait() is called at line 205, the notification is lost and the emulator thread blocks indefinitely. Restructure as a single Arc<(Mutex<bool>, Condvar)> and acquire the mutex before modifying paused on all resume paths.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@rs/moq-boy/src/main.rs` around lines 105 - 106, Replace the separate
AtomicBool and (Mutex, Condvar) pair with a single Arc<(Mutex<bool>, Condvar)>
(reuse the name resume_notify) and change all uses accordingly: initialize with
Mutex::new(true) to start paused; in the emulator thread, lock the mutex and use
a loop that checks the bool guard and calls cvar.wait(&mut guard) while the flag
indicates paused; in every resume path that previously did paused.store(...) and
resume_notify.1.notify_all(), instead lock the same mutex, set *guard = false
(or true per your semantics) while holding the lock, then call notify_all() on
the Condvar; update all locations that previously accessed paused (AtomicBool)
to use the Mutex<bool> guard so the pause predicate is always guarded by the
same mutex.
| // Drain pending commands. | ||
| while let Ok(cmd) = cmd_rx.try_recv() { | ||
| match cmd { | ||
| input::Command::Buttons { | ||
| buttons, | ||
| viewer_id, | ||
| ts_ms, | ||
| } => { | ||
| emu.set_buttons(&viewer_id, buttons.into_iter().collect()); | ||
| last_input = std::time::Instant::now(); | ||
|
|
||
| let latency = current_ts_ms - ts_ms; | ||
| if latency >= 0.0 { | ||
| viewer_latency.insert(viewer_id, (latency, std::time::Instant::now())); | ||
| } | ||
| } | ||
| input::Command::ViewerLeft { viewer_id } => { | ||
| emu.viewer_left(&viewer_id); | ||
| viewer_latency.remove(&viewer_id); | ||
| } | ||
| input::Command::Reset => { | ||
| tracing::info!("resetting emulator (viewer request)"); | ||
| emu.reset()?; | ||
| last_input = std::time::Instant::now(); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find the main.rs file
find . -path "*moq-boy*" -name "main.rs" -type fRepository: moq-dev/moq
Length of output: 79
🏁 Script executed:
# Get the file size to understand how much we need to read
wc -l rs/moq-boy/src/main.rsRepository: moq-dev/moq
Length of output: 81
🏁 Script executed:
# Read the context around lines 224-250 and 315-319
sed -n '200,280p' rs/moq-boy/src/main.rs | cat -nRepository: moq-dev/moq
Length of output: 3118
🏁 Script executed:
# Need to see more context - looking at a wider range to understand the outer loop
sed -n '170,260p' rs/moq-boy/src/main.rs | cat -nRepository: moq-dev/moq
Length of output: 3595
🏁 Script executed:
# Also need to check lines around 315-319 mentioned in the comment
sed -n '305,330p' rs/moq-boy/src/main.rs | cat -nRepository: moq-dev/moq
Length of output: 836
🏁 Script executed:
# Search for the spawn_blocking and tokio::select! context to understand the full picture
rg -A 5 -B 5 "spawn_blocking" rs/moq-boy/src/main.rs | head -60Repository: moq-dev/moq
Length of output: 441
🏁 Script executed:
# Let me examine the full outer loop to see all possible exit conditions
sed -n '170,331p' rs/moq-boy/src/main.rs | cat -nRepository: moq-dev/moq
Length of output: 6268
🏁 Script executed:
# Let's also check the exact line numbers for context by looking at the raw lines
sed -n '224,250p' rs/moq-boy/src/main.rs | nl -v 224Repository: moq-dev/moq
Length of output: 97
🏁 Script executed:
# And lines 315-319 as mentioned
sed -n '315,320p' rs/moq-boy/src/main.rs | nl -v 315Repository: moq-dev/moq
Length of output: 97
🏁 Script executed:
# Get exact content of lines 224-250 and 315-319
cat rs/moq-boy/src/main.rs | sed -n '224,250p'Repository: moq-dev/moq
Length of output: 818
🏁 Script executed:
# Check lines 315-319
cat rs/moq-boy/src/main.rs | sed -n '315,320p'Repository: moq-dev/moq
Length of output: 258
Exit the blocking emulator loop when the command channel disconnects.
When session.closed() or input::handle_viewers() completes in the tokio::select!, cmd_tx is dropped. However, the outer loop in the blocking task lacks an exit condition for channel disconnection. After cmd_rx.try_recv() begins returning Err(TryRecvError::Disconnected), the while loop exits but the surrounding infinite loop continues, preventing clean shutdown. Return Ok(()) when receiving TryRecvError::Disconnected, or add an explicit shutdown signal.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@rs/moq-boy/src/main.rs` around lines 224 - 250, The blocking emulator loop
currently drains commands with while let Ok(cmd) = cmd_rx.try_recv() but never
exits the surrounding infinite loop when the channel is closed; update the task
that processes cmd_rx (the code using cmd_rx.try_recv() inside the loop that
matches on input::Command::{Buttons, ViewerLeft, Reset}) to detect
TryRecvError::Disconnected and break out or return Ok(()) so the blocking task
can shut down cleanly (i.e., check the Err variant of try_recv() and on
Disconnected perform an early return or explicit shutdown instead of looping
forever).
| let held: Vec<_> = emu.pressed_buttons().iter().copied().collect(); | ||
| let idle_secs = idle_time.as_secs(); | ||
| let remaining = timeout_secs.saturating_sub(idle_secs); | ||
|
|
||
| let latency_map: serde_json::Map<String, serde_json::Value> = viewer_latency | ||
| .iter() | ||
| .map(|(k, (ms, _))| (k.clone(), serde_json::json!((*ms as u32)))) | ||
| .collect(); | ||
|
|
||
| let new_status = serde_json::json!({ | ||
| "buttons": held, | ||
| "reset_in": remaining, | ||
| "latency": latency_map, | ||
| }); | ||
| let new_status_str = new_status.to_string(); | ||
|
|
||
| if new_status_str != last_status { | ||
| last_status = new_status_str.clone(); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n rs/moq-boy/src/main.rs | sed -n '260,290p'Repository: moq-dev/moq
Length of output: 1286
🏁 Script executed:
# Find the definition of pressed_buttons
rg "fn pressed_buttons" rs/moq-boyRepository: moq-dev/moq
Length of output: 133
🏁 Script executed:
# Search for the emulator type definition
rg "struct.*[Ee]mu|impl.*pressed_buttons" rs/moq-boy -A 3Repository: moq-dev/moq
Length of output: 243
🏁 Script executed:
rg "enum Button|struct Button" rs/moq-boy/src/emulator.rs -A 10Repository: moq-dev/moq
Length of output: 127
🏁 Script executed:
# Check if Button implements Ord or has any sorting traits
rg "impl.*Ord|impl.*PartialOrd|#\[derive.*Ord" rs/moq-boy/src/emulator.rsRepository: moq-dev/moq
Length of output: 37
🏁 Script executed:
# Look for any sorting of buttons before line 268
sed -n '240,285p' rs/moq-boy/src/main.rsRepository: moq-dev/moq
Length of output: 1470
Sort the button collection before serializing to ensure stable status payloads.
emu.pressed_buttons() returns a HashSet<Button>, and collecting it directly to a Vec produces a non-deterministic order. This causes new_status_str to change even when the pressed buttons haven't changed, triggering redundant status publishes. Sort the buttons before serializing:
let mut held: Vec<_> = emu.pressed_buttons().iter().copied().collect();
held.sort_by_key(|btn| format!("{:?}", btn)); // or derive Ord and use held.sort()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@rs/moq-boy/src/main.rs` around lines 268 - 285, emu.pressed_buttons() returns
a HashSet so the collected Vec held has non-deterministic ordering causing
new_status_str to flip even when buttons unchanged; to fix, after creating held
(the Vec from emu.pressed_buttons()), sort it before building new_status (either
by deriving Ord on Button and calling held.sort() or by calling
held.sort_by_key(|b| format!("{:?}", b))) so the serialized
new_status/new_status_str and comparison with last_status are stable.
Summary
dev/→demo/to better reflect the folder's purposemoq-boyintors/moq-boy(Rust emulator publisher) andjs/moq-boy(web viewer), matching repo conventionsjustfile,wrangler.jsonc,worker.ts) indemo/boy/src/worker.ts→worker.tsindemo/boy/anddemo/pub/just devas alias forjust demo webCLAUDE.md,README.md,doc/,Cargo.toml,package.json)Test plan
just checkpassesjust fixpassesjust demostarts relay + pub + webjust devstarts web demojust demo boystarts boy democargo build --bin moq-boybuilds fromrs/moq-boy🤖 Generated with Claude Code