-
Notifications
You must be signed in to change notification settings - Fork 0
Add NTSC handling and rotation #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
nchapman
commented
Nov 25, 2025
- Fix NTSC handling although in the end I'm not sure it was necessary but at least it's working!
- Add rotation support in libretro primarily for arcade games
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds rotation support and improved timing/geometry handling for arcade games in the MinArch libretro frontend. The implementation includes software-based rotation (90°, 180°, 270°) for cores that request display rotation via the libretro API, along with enhanced handling of dynamic geometry changes and frame timing callbacks.
Key Changes:
- Software rotation implementation with RGB565 pixel-by-pixel transformation for all rotation angles
- Dynamic geometry and AV info handling via
SET_GEOMETRY,SET_SYSTEM_AV_INFO, andSET_ROTATIONenvironment callbacks - Frame timing callback support and throttle state reporting for better core synchronization
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| workspace/all/minarch/minarch.c | Core changes: added rotation state management, rotation buffer allocation, software rotation application in video pipeline, environment callback handlers for rotation/geometry/timing, and frame time callback invocations |
| workspace/all/common/scaler.h | Added rotation function declaration, rotation angle constants (ROTATION_0 through ROTATION_270), and improved memcpy_neon signature with const correctness |
| workspace/all/common/scaler.c | Implemented rotate_c16() function with pixel-by-pixel rotation logic for all four rotation angles (0°, 90°, 180°, 270°) |
| skeleton/TEMPLATES/minarch-paks/configs/base/A2600/default.cfg | Removed Stella-specific core options (filter and crop settings) as part of NTSC handling fixes |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| dst_pixels[dst_y * dst_stride + dst_x] = src_pixels[src_y * src_stride + src_x]; | ||
| } | ||
| } | ||
| break; |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The rotation implementation lacks a default case in the switch statement. While rotation values are validated in the environment callback, adding a default case would make the code more defensive and easier to debug if an invalid value somehow gets through.
Suggestion: Add a default case that logs an error or at least does nothing explicitly:
default:
// Invalid rotation value, skip rotation
break;| break; | |
| break; | |
| default: | |
| // Invalid rotation value, skip rotation and log error | |
| fprintf(stderr, "rotate_c16: Invalid rotation value %u\n", rotation); | |
| break; |
workspace/all/minarch/minarch.c
Outdated
| // Update pitch in renderer if dimensions were swapped by rotation | ||
| if (rotated_data != frame_data && | ||
| (video_state.rotation == ROTATION_90 || video_state.rotation == ROTATION_270)) { |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When rotation is applied (including 180°), the rotation buffer uses a tightly-packed pitch (dst_w * sizeof(uint16_t)), which may differ from the original pitch if it had padding. The renderer.src_p should be updated for all rotation cases where rotated_data != frame_data, not just for 90°/270° rotations.
Suggestion: Change the condition to:
// Update pitch in renderer if rotation was applied
if (rotated_data != frame_data) {
renderer.src_p = rotation_buffer.pitch;
}| // Update pitch in renderer if dimensions were swapped by rotation | |
| if (rotated_data != frame_data && | |
| (video_state.rotation == ROTATION_90 || video_state.rotation == ROTATION_270)) { | |
| // Update pitch in renderer if rotation was applied | |
| if (rotated_data != frame_data) { |
| case ROTATION_90: | ||
| // 90° CCW: dst[y,x] = src[src_w-1-x, y] | ||
| // Output dimensions: src_h × src_w | ||
| for (uint32_t src_y = 0; src_y < src_h; src_y++) { | ||
| for (uint32_t src_x = 0; src_x < src_w; src_x++) { | ||
| uint32_t dst_x = src_y; | ||
| uint32_t dst_y = src_w - 1 - src_x; | ||
| dst_pixels[dst_y * dst_stride + dst_x] = src_pixels[src_y * src_stride + src_x]; | ||
| } | ||
| } | ||
| break; | ||
|
|
||
| case ROTATION_180: | ||
| // 180°: dst[y,x] = src[src_h-1-y, src_w-1-x] | ||
| for (uint32_t src_y = 0; src_y < src_h; src_y++) { | ||
| for (uint32_t src_x = 0; src_x < src_w; src_x++) { | ||
| uint32_t dst_x = src_w - 1 - src_x; | ||
| uint32_t dst_y = src_h - 1 - src_y; | ||
| dst_pixels[dst_y * dst_stride + dst_x] = src_pixels[src_y * src_stride + src_x]; | ||
| } | ||
| } | ||
| break; | ||
|
|
||
| case ROTATION_270: | ||
| // 270° CCW (90° CW): dst[y,x] = src[x, src_h-1-y] | ||
| // Output dimensions: src_h × src_w | ||
| for (uint32_t src_y = 0; src_y < src_h; src_y++) { | ||
| for (uint32_t src_x = 0; src_x < src_w; src_x++) { | ||
| uint32_t dst_x = src_h - 1 - src_y; | ||
| uint32_t dst_y = src_x; | ||
| dst_pixels[dst_y * dst_stride + dst_x] = src_pixels[src_y * src_stride + src_x]; | ||
| } | ||
| } | ||
| break; |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The rotation implementation uses a naive pixel-by-pixel approach which could be a performance bottleneck for larger resolutions. For 90° and 270° rotations, the memory access pattern is cache-unfriendly (writing to non-sequential memory locations).
Consider optimizing in the future by:
- Processing in tiles/blocks to improve cache locality
- Using SIMD instructions (NEON on ARM) for bulk pixel operations
- Transposing in chunks rather than individual pixels
This is acceptable for arcade games at typical resolutions (e.g., 224x320), but may struggle with higher resolution cores.
workspace/all/minarch/minarch.c
Outdated
| case RETRO_ENVIRONMENT_GET_FASTFORWARDING: { /* 49 */ | ||
| bool* out = (bool*)data; | ||
| if (out) { | ||
| *out = fast_forward ? true : false; |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The expression fast_forward ? true : false is redundant. Since fast_forward is already a boolean-compatible value, it can be directly assigned.
Suggestion: Simplify to:
*out = fast_forward;| *out = fast_forward ? true : false; | |
| *out = fast_forward; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Validate pitch before attempting conversion | ||
| size_t min_pitch = width * sizeof(uint32_t); | ||
| if (pitch < min_pitch) { | ||
| LOG_error("Skipping threaded frame due to invalid pitch: %zu < %zu", pitch, | ||
| min_pitch); | ||
| pthread_mutex_unlock(&core_mx); | ||
| return; // Abort frame to prevent buffer corruption | ||
| } | ||
|
|
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicate pitch validation: This validation is also performed in video_refresh_callback_main (lines 3999-4004) before calling buffer_downsample. Since comprehensive validation is already done here, the duplicate check above could be removed.
| // Validate pitch before attempting conversion | |
| size_t min_pitch = width * sizeof(uint32_t); | |
| if (pitch < min_pitch) { | |
| LOG_error("Skipping threaded frame due to invalid pitch: %zu < %zu", pitch, | |
| min_pitch); | |
| pthread_mutex_unlock(&core_mx); | |
| return; // Abort frame to prevent buffer corruption | |
| } |
| int temp = src_w; | ||
| src_w = src_h; | ||
| src_h = temp; | ||
| // Keep aspect ratio as-is (it's already for rotated dimensions) |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical bug: Pitch not updated after dimension swap. When rotation is 90° or 270°, the dimensions are swapped (lines 3685-3691), but the pitch src_p is not updated to match the swapped dimensions. For example, if the original frame is 240×320 with pitch=480 bytes, after swapping it becomes 320×240, but the pitch remains 480 bytes when it should be 640 bytes (320 * 2).
This will cause the scaler to use an incorrect pitch value, potentially leading to rendering corruption.
Fix: After swapping dimensions, recalculate the pitch:
if (video_state.rotation == ROTATION_90 || video_state.rotation == ROTATION_270) {
int temp = src_w;
src_w = src_h;
src_h = temp;
src_p = src_w * sizeof(uint16_t); // Recalculate pitch for swapped dimensions
}| // Keep aspect ratio as-is (it's already for rotated dimensions) | |
| // Keep aspect ratio as-is (it's already for rotated dimensions) | |
| src_p = src_w * sizeof(uint16_t); // Recalculate pitch for swapped dimensions |
workspace/all/minarch/minarch.c
Outdated
| // Determine current throttle mode | ||
| if (fast_forward) { | ||
| state->mode = RETRO_THROTTLE_FAST_FORWARD; | ||
| state->rate = (float)(max_ff_speed + 1); // max_ff_speed: 0=2x, 1=3x, 2=4x, 3=5x |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Incorrect comment: The comment states max_ff_speed: 0=2x, 1=3x, 2=4x, 3=5x, but based on the calculation formula max_ff_speed + 1 and the implementation at line 6202 (ff_frame_time = 1000000 / (core.fps * (max_ff_speed + 1))), the actual mapping is: 0→1x, 1→2x, 2→3x, 3→4x.
Update the comment to reflect the correct mapping or adjust the calculation to match the comment.
| state->rate = (float)(max_ff_speed + 1); // max_ff_speed: 0=2x, 1=3x, 2=4x, 3=5x | |
| state->rate = (float)(max_ff_speed + 1); // max_ff_speed: 0=1x, 1=2x, 2=3x, 3=4x |
| if (required_size > rotation_buffer.size) { | ||
| void* new_buffer = realloc(rotation_buffer.buffer, required_size); | ||
| if (!new_buffer) { | ||
| LOG_error("Failed to allocate rotation buffer: %zu bytes", required_size); | ||
| return; | ||
| } | ||
| rotation_buffer.buffer = new_buffer; | ||
| rotation_buffer.size = required_size; | ||
| LOG_info("Allocated rotation buffer: %ux%u pitch=%u (%zu bytes)", width, height, pitch, | ||
| required_size); | ||
| } | ||
|
|
||
| rotation_buffer.width = width; | ||
| rotation_buffer.height = height; | ||
| rotation_buffer.pitch = pitch; |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Buffer state inconsistency after allocation failure: If realloc fails (returns NULL), the function returns early (line 3582) without updating rotation_buffer.size. On subsequent calls with the same or smaller size requirement, the condition at line 3578 will be false, causing the function to skip reallocation and proceed to lines 3590-3592, updating width/height/pitch fields even though rotation_buffer.buffer is NULL. This creates an inconsistent state where the fields indicate a valid buffer, but the buffer pointer is NULL.
Additionally, the standard realloc(NULL, size) is equivalent to malloc(size), so this should work on the first call. However, the inconsistent state after a failed reallocation is problematic.
Fix: Either check if the buffer is NULL before updating fields, or reset rotation_buffer.size = 0 when allocation fails to ensure retry on next call:
if (!new_buffer) {
LOG_error("Failed to allocate rotation buffer: %zu bytes", required_size);
rotation_buffer.size = 0; // Force retry on next call
return;
}
workspace/all/minarch/minarch.c
Outdated
| if (run) { | ||
| // Call frame time callback if registered | ||
| if (video_state.frame_time_cb) { | ||
| retro_usec_t current_time = SDL_GetTicks() * 1000; // Convert ms to µs |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Low precision timestamp for frame time callback: The frame time callback is called with SDL_GetTicks() * 1000, which provides millisecond-precision timestamps scaled to microseconds. This means the microsecond-level precision is lost (values will always be multiples of 1000).
A more accurate approach would be to use getMicroseconds() (already used at line 6205 for timing), which likely provides true microsecond precision:
retro_usec_t current_time = getMicroseconds();
video_state.frame_time_cb(current_time);This would provide more accurate timing information to cores that rely on precise frame timing.
| retro_usec_t current_time = SDL_GetTicks() * 1000; // Convert ms to µs | |
| retro_usec_t current_time = getMicroseconds(); // True microsecond precision |
workspace/all/minarch/minarch.c
Outdated
|
|
||
| // Call frame time callback if registered | ||
| if (video_state.frame_time_cb) { | ||
| retro_usec_t current_time = SDL_GetTicks() * 1000; // Convert ms to µs |
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Low precision timestamp for frame time callback: The frame time callback is called with SDL_GetTicks() * 1000, which provides millisecond-precision timestamps scaled to microseconds. This means the microsecond-level precision is lost (values will always be multiples of 1000).
A more accurate approach would be to use getMicroseconds() (already used at line 6205 for timing), which likely provides true microsecond precision:
retro_usec_t current_time = getMicroseconds();
video_state.frame_time_cb(current_time);This would provide more accurate timing information to cores that rely on precise frame timing.
| retro_usec_t current_time = SDL_GetTicks() * 1000; // Convert ms to µs | |
| retro_usec_t current_time = getMicroseconds(); |
| // Validate pitch before attempting conversion | ||
| size_t min_pitch = width * sizeof(uint32_t); | ||
| if (pitch < min_pitch) { | ||
| LOG_error("Skipping frame due to invalid pitch: %zu < %zu", pitch, min_pitch); | ||
| return; // Abort entire frame to prevent rendering corrupted data | ||
| } | ||
|
|
Copilot
AI
Nov 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicate pitch validation: The pitch validation for XRGB8888 format is performed both here (lines 3999-4004) and inside the buffer_downsample function (lines 3507-3514). This is redundant since buffer_downsample already performs comprehensive validation. Consider removing this duplicate check to avoid code duplication and rely on the validation inside buffer_downsample.
| // Validate pitch before attempting conversion | |
| size_t min_pitch = width * sizeof(uint32_t); | |
| if (pitch < min_pitch) { | |
| LOG_error("Skipping frame due to invalid pitch: %zu < %zu", pitch, min_pitch); | |
| return; // Abort entire frame to prevent rendering corrupted data | |
| } |
4b6ec2d to
b87a813
Compare