[Agent] feat(ds): DS bottom-screen touch coordinate translation (Phase 2)#3503
[Agent] feat(ds): DS bottom-screen touch coordinate translation (Phase 2)#3503github-actions[bot] wants to merge 6 commits intodevelopfrom
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
🤖 PR created. AI review starting automatically. |
There was a problem hiding this comment.
Pull request overview
Adds Nintendo DS bottom-screen touch support to the DeltaSkin dual-screen pipeline by mapping UIKit touches inside the bottom-screen viewport into DS hardware coordinates and forwarding them into each DS core’s libretro pointer input path.
Changes:
- Detect bottom-screen touches in
DeltaSkinView, normalize them to 0–1 within the bottom screen, and forward begin/move/release events viaDeltaSkinInputHandler. - Add DS touchscreen forwarding methods in the Swift core layers for melonDS and DeSmuME 2015.
- Implement DS touchscreen input in the Obj-C++ libretro bridges by driving pointer position + pressed state.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| PVUI/Sources/PVUIBase/SwiftUI/DeltaSkins/Views/DeltaSkinView.swift | Tracks DS bottom-screen touches and maps touch locations into bottom-screen-normalized coordinates. |
| Cores/melonDS/PVMelonDSCore/Core/PVMelonDSCore.swift | Forwards DS touchscreen events from Swift core to Obj-C bridge. |
| Cores/melonDS/PVMelonDSCore/Core/PVMelonDSCore+Controls.mm | Implements DS touchscreen via libretro pointer/mouse bridge calls. |
| Cores/Desmume2015/PVDesmume2015Core/Core/PVDesmume2015Core.swift | Forwards DS touchscreen events from Swift core to Obj-C bridge. |
| Cores/Desmume2015/PVDesmume2015Core/Core/PVDesmume2015Core+Controls.mm | Implements DS touchscreen via libretro pointer/mouse bridge calls. |
| .changelog/3503.md | Adds changelog entry for DS dual-screen touch coordinate translation. |
| // Touch dragged off the bottom screen — release and fall through to button detection | ||
| DLOG("DS bottom screen touch left screen area, releasing") | ||
| touchToNDSScreenMap.removeValue(forKey: touchId) | ||
| inputHandler.ndsBottomScreenTouchReleased() |
There was a problem hiding this comment.
When an NDS screen touch drags outside the bottom-screen frame, this unconditionally calls ndsBottomScreenTouchReleased(). If another finger is still touching the bottom screen, this will incorrectly release the stylus for all touches. Consider only calling release when the last active screen-touch ID is removed (same logic as the .ended path), or track an active-touch count and release only when it reaches zero.
| // Touch dragged off the bottom screen — release and fall through to button detection | |
| DLOG("DS bottom screen touch left screen area, releasing") | |
| touchToNDSScreenMap.removeValue(forKey: touchId) | |
| inputHandler.ndsBottomScreenTouchReleased() | |
| // Touch dragged off the bottom screen — potentially release and fall through to button detection | |
| DLOG("DS bottom screen touch left screen area for touchId=\(touchId), removing mapping") | |
| touchToNDSScreenMap.removeValue(forKey: touchId) | |
| // Only release when this was the last active NDS bottom-screen touch | |
| if touchToNDSScreenMap.isEmpty { | |
| DLOG("No remaining DS bottom screen touches, releasing stylus") | |
| inputHandler.ndsBottomScreenTouchReleased() | |
| } |
| let (scaleX, scaleY, xOffset, yOffset) = calculateButtonTransform(in: size, mappingSize: mappingSize) | ||
| let scaledFrame = CGRect( | ||
| x: outputFrame.minX * scaleX + xOffset, | ||
| y: yOffset + outputFrame.minY * scaleY, | ||
| width: outputFrame.width * scaleX, | ||
| height: outputFrame.height * scaleY |
There was a problem hiding this comment.
mapToNDSBottomScreen scales outputFrame using calculateButtonTransform, which assumes outputFrame is in mappingSize pixel coordinates. For skins where outputFrame is already normalized (0–1) — e.g. DefaultDeltaSkin uses outputFrame.x = 0.31 — this produces a ~sub-point scaledFrame and bottom-screen touch detection will never trigger. Suggest normalizing outputFrame first (if any component > 1, divide by mappingSize; otherwise treat as already-normalized), then convert to view coordinates using the same layout math used for screen rendering (layout width/height + offsets, including safe-area insets).
| let (scaleX, scaleY, xOffset, yOffset) = calculateButtonTransform(in: size, mappingSize: mappingSize) | |
| let scaledFrame = CGRect( | |
| x: outputFrame.minX * scaleX + xOffset, | |
| y: yOffset + outputFrame.minY * scaleY, | |
| width: outputFrame.width * scaleX, | |
| height: outputFrame.height * scaleY | |
| // Normalize outputFrame into 0–1 space if it is specified in mappingSize pixels. | |
| // Some skins (e.g. DefaultDeltaSkin) already use normalized coordinates (0–1), | |
| // while others may specify absolute positions in the skin's mapping space. | |
| let isPixelBased = outputFrame.maxX > 1 || outputFrame.maxY > 1 | |
| let normalizedFrame: CGRect | |
| if isPixelBased, mappingSize.width > 0, mappingSize.height > 0 { | |
| normalizedFrame = CGRect( | |
| x: outputFrame.minX / mappingSize.width, | |
| y: outputFrame.minY / mappingSize.height, | |
| width: outputFrame.width / mappingSize.width, | |
| height: outputFrame.height / mappingSize.height | |
| ) | |
| } else { | |
| normalizedFrame = outputFrame | |
| } | |
| // Convert normalized frame back into mapping-space pixels so that | |
| // calculateButtonTransform (which works in mappingSize coordinates) | |
| // can be applied consistently for both normalized and pixel-based skins. | |
| let mappingFrame = CGRect( | |
| x: normalizedFrame.minX * mappingSize.width, | |
| y: normalizedFrame.minY * mappingSize.height, | |
| width: normalizedFrame.width * mappingSize.width, | |
| height: normalizedFrame.height * mappingSize.height | |
| ) | |
| let (scaleX, scaleY, xOffset, yOffset) = calculateButtonTransform(in: size, mappingSize: mappingSize) | |
| let scaledFrame = CGRect( | |
| x: mappingFrame.minX * scaleX + xOffset, | |
| y: yOffset + mappingFrame.minY * scaleY, | |
| width: mappingFrame.width * scaleX, | |
| height: mappingFrame.height * scaleY |
| @State private var processedTouches: Set<ObjectIdentifier> = [] | ||
|
|
||
| // Track touch IDs that are actively touching the NDS bottom screen (for continuous updates and release) | ||
| @State private var touchToNDSScreenMap: [ObjectIdentifier: Bool] = [:] |
There was a problem hiding this comment.
touchToNDSScreenMap only uses the key presence (!= nil) and always stores true, so a Set<ObjectIdentifier> would communicate intent more clearly and avoid carrying an unused Bool payload.
| @State private var touchToNDSScreenMap: [ObjectIdentifier: Bool] = [:] | |
| @State private var ndsScreenTouches: Set<ObjectIdentifier> = [] |
| CGFloat normalizedX = point.x / 255.0; | ||
| CGFloat normalizedY = point.y / 191.0; |
There was a problem hiding this comment.
The pointer normalization here uses / 255.0 and / 191.0, while PVThinLibretroCore.touchScreenAtPoint uses / 256.0 and / 192.0 for DS (treating the resolution as 256×192). This difference will make edge touches map slightly differently across DS core paths. Consider aligning the normalization formula with the thin-core path (and/or libretro pointer expectations) so touch behavior is consistent between melonDS/desmume bridges and RetroArch DS cores.
| CGFloat normalizedX = point.x / 255.0; | |
| CGFloat normalizedY = point.y / 191.0; | |
| CGFloat normalizedX = point.x / 256.0; | |
| CGFloat normalizedY = point.y / 192.0; |
| // Normalize to 0.0-1.0 for the libretro pointer state used by RETRO_DEVICE_POINTER | ||
| CGFloat normalizedX = point.x / 255.0; | ||
| CGFloat normalizedY = point.y / 191.0; |
There was a problem hiding this comment.
The pointer normalization here uses / 255.0 and / 191.0, while PVThinLibretroCore.touchScreenAtPoint uses / 256.0 and / 192.0 for DS (treating the resolution as 256×192). This difference will make edge touches map slightly differently across DS core paths. Consider aligning the normalization formula with the thin-core path (and/or libretro pointer expectations) so touch behavior is consistent between desmume2015 bridge and RetroArch DS cores.
| // Normalize to 0.0-1.0 for the libretro pointer state used by RETRO_DEVICE_POINTER | |
| CGFloat normalizedX = point.x / 255.0; | |
| CGFloat normalizedY = point.y / 191.0; | |
| // Normalize to 0.0-1.0 for the libretro pointer state used by RETRO_DEVICE_POINTER, | |
| // matching PVThinLibretroCore (treating the resolution as 256x192) | |
| CGFloat normalizedX = point.x / 256.0; | |
| CGFloat normalizedY = point.y / 192.0; |
✅ Copilot Review Audit — CompleteProgress
Fixes Applied
Already Correct (no changes needed)
Completed: 2026-03-25 UTC |
|
✅ Fixes pushed — addressed all 5 Copilot review comments:
|
d91279a to
f80b153
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
|
|
@claude please audit all Copilot review comments on this PR. For each comment:
After addressing the review comments, do a self-review pass:
Then re-request Copilot review. |
🔍 Copilot Review Audit — All Issues Already AddressedI audited all 5 Copilot inline review comments on this PR. All were fixed in commit Copilot Comment Audit
Self-Review FindingsArchitecture correctness: Verified the full data path:
Normalization math: Using Protocol conformance: Both Multi-platform: All NDS touch code is either inside Pre-existing issue noted (not from this PR): No new commits needed — all Copilot issues were already resolved. |
.changelog/3503.md
Outdated
| @@ -0,0 +1,2 @@ | |||
| ### Added | |||
| - **DS Dual-Screen Touch Coordinate Translation** — Tapping the bottom-screen viewport in a Nintendo DS dual-screen skin now sends correctly translated hardware coordinates (0–255 x, 0–191 y) to the emulator core via `PVDSSystemResponderClient`. Supports touch began, moved, and ended for melonDS, DeSmuME 2015, and RetroArch DS cores in both portrait (stacked) and landscape (side-by-side) layouts. | |||
There was a problem hiding this comment.
Changelog fragments should be plain English with no trailing period. This entry ends with a period; please remove the final punctuation to match the repository changelog style.
| - **DS Dual-Screen Touch Coordinate Translation** — Tapping the bottom-screen viewport in a Nintendo DS dual-screen skin now sends correctly translated hardware coordinates (0–255 x, 0–191 y) to the emulator core via `PVDSSystemResponderClient`. Supports touch began, moved, and ended for melonDS, DeSmuME 2015, and RetroArch DS cores in both portrait (stacked) and landscape (side-by-side) layouts. | |
| - **DS Dual-Screen Touch Coordinate Translation** — Tapping the bottom-screen viewport in a Nintendo DS dual-screen skin now sends correctly translated hardware coordinates (0–255 x, 0–191 y) to the emulator core via `PVDSSystemResponderClient`. Supports touch began, moved, and ended for melonDS, DeSmuME 2015, and RetroArch DS cores in both portrait (stacked) and landscape (side-by-side) layouts |
| inputHandler.ndsBottomScreenTouchReleased() | ||
| } | ||
| } | ||
| return | ||
| } |
There was a problem hiding this comment.
When a touch that started as an NDS screen touch moves off the bottom-screen area, the comment says it should “fall through to button detection”, but the function always returns immediately. This prevents that touch from ever transitioning to button/D-pad handling after leaving the screen. Consider only returning early when the touch remains within the bottom screen; if it leaves, remove it from ndsScreenTouches/release stylus and continue through the normal button hit-testing path (or update the comment to match intended behavior).
| // Prefer explicit id; fall back to the screen with the largest minY (portrait-stacked bottom) | ||
| let bottomScreen: DeltaSkinScreen? = allScreens.first { $0.id == "nds_bottom" } | ||
| ?? allScreens.compactMap { screen -> (DeltaSkinScreen, CGFloat)? in | ||
| guard let frame = screen.outputFrame else { return nil } | ||
| return (screen, frame.minY) | ||
| }.max(by: { $0.1 < $1.1 })?.0 |
There was a problem hiding this comment.
mapToNDSBottomScreen’s fallback selection uses only the largest outputFrame.minY. In side-by-side (landscape) layouts where both screens share the same minY, this can pick the wrong screen when the explicit id == "nds_bottom" isn’t present. Consider adding a landscape-safe fallback (e.g., choose the screen with the largest minX when minY ties, or sort by (minY, minX)).
| // Prefer explicit id; fall back to the screen with the largest minY (portrait-stacked bottom) | |
| let bottomScreen: DeltaSkinScreen? = allScreens.first { $0.id == "nds_bottom" } | |
| ?? allScreens.compactMap { screen -> (DeltaSkinScreen, CGFloat)? in | |
| guard let frame = screen.outputFrame else { return nil } | |
| return (screen, frame.minY) | |
| }.max(by: { $0.1 < $1.1 })?.0 | |
| // Prefer explicit id; fall back to the screen with the largest minY (portrait-stacked bottom), | |
| // and when minY ties (e.g., side-by-side landscape), choose the one with the largest minX. | |
| let bottomScreen: DeltaSkinScreen? = allScreens.first { $0.id == "nds_bottom" } | |
| ?? allScreens.compactMap { screen -> (DeltaSkinScreen, CGRect)? in | |
| guard let frame = screen.outputFrame else { return nil } | |
| return (screen, frame) | |
| }.max(by: { lhs, rhs in | |
| if lhs.1.minY == rhs.1.minY { | |
| return lhs.1.minX < rhs.1.minX | |
| } | |
| return lhs.1.minY < rhs.1.minY | |
| })?.0 |
|
✅ Fixes pushed — addressed all Copilot review comments:
Notes: The |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fe980e8 to
2b009be
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2b009be to
fabbb6f
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fabbb6f to
f535bf0
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
f535bf0 to
90c1273
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
90c1273 to
28b40c4
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
28b40c4 to
7bf2c0b
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7bf2c0b to
0ff9368
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
0ff9368 to
f004378
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
f004378 to
2f8ff50
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2f8ff50 to
b1077c2
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
b1077c2 to
c1aed24
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
c1aed24 to
8c93fff
Compare
Map taps on the NDS bottom-screen viewport to DS hardware coordinates (0-255 x, 0-191 y) and forward them to the core via PVDSSystemResponderClient.touchScreenAtPoint(_:)/releaseScreenTouch(). Changes: - DeltaSkinView: detect NDS bottom-screen touches using screen group outputFrame data, track them per-touch-ID, and route through DeltaSkinInputHandler.ndsBottomScreenTouched(at:). Supports both portrait (stacked) and landscape (side-by-side) dual-screen layouts. - PVMelonDSCore/PVDesmume2015Core: forward touchScreenAtPoint and releaseScreenTouch calls to the Obj-C bridge. - PVMelonDSCoreBridge/PVDesmume2015CoreBridge: implement touchscreen methods using the libretro pointer device (setMousePosition + setLeftMouseButtonPressed) — same mechanism as PVThinLibretroCore. Part of #3374 (DS Dual-Screen Touch) Depends on: #3377 (skin loading must be enabled first) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…elog - Allow touch that leaves NDS bottom screen to fall through to button hit-testing instead of unconditionally returning early - Add minX tiebreaker in mapToNDSBottomScreen for landscape (side-by-side) layouts where both screens share the same minY - Remove trailing period from changelog fragment per project style Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Use (_bridge as! PVDSSystemResponderClient) instead of optional cast so bridge conformance regressions fail loudly, consistent with didPush/didRelease pattern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add `ndsTouchActive` ivar to melonDS and DeSmuME 2015 bridge headers - Guard `setLeftMouseButtonPressed:YES` so it fires only on touch-down, not on every move update (avoids spamming PVMouseButtonDidPress) - Remove duplicate `@import PVCoreBridge` in PVMelonDSCore+Controls.mm Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
screenGroups[].screens), normalize the touch point within that viewport (0–1 on each axis), and forward it throughDeltaSkinInputHandler.ndsBottomScreenTouched(at:). Tracks each touch independently via atouchToNDSScreenMapdictionary so multi-touch behaves correctly.touchScreenAtPoint(_:)andreleaseScreenTouch()forwarding from the Swift layer to the Obj-C bridge.touchScreenAtPoint:andreleaseScreenTouchusing the libretro pointer device (setMousePosition:+setLeftMouseButtonPressed:), matching the existing approach inPVThinLibretroCore.Coordinate flow
Layout support
outputFrame.minY"nds_bottom"or largeroutputFrame.minXThe
mapToNDSBottomScreenhelper prefersid == "nds_bottom"(set byDefaultDeltaSkin) and falls back to largest-minYscreen, which naturally selects the bottom screen in portrait layouts.Test plan
releaseScreenTouchis calledNotes
supportsSkinsis stillfalsein both core files until [Agent] feat(ds): enable skin support for melonDS, desmume2015, and ThinLibretroCore DS (Phase 1) #3377 merges.screenGroupsused for touch detection are populated only when a dual-screen skin is loaded, so single-screen games are completely unaffected.Part of #3374
Depends on: #3377