Skip to content

fix(libScePad): validate handles, initialisation and motion sample times - #15047

Open
MarcZX10 wants to merge 8 commits into
boykopovar:mainfrom
MarcZX10:fix/p08-pad-handles-latch
Open

MarcZX10 wants to merge 8 commits into
boykopovar:mainfrom
MarcZX10:fix/p08-pad-handles-latch

Conversation

@MarcZX10

@MarcZX10 MarcZX10 commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

What

  • fix(libScePad): scePadGetControllerInformation reads the connection state of the handle's own port (Pad::IsConnected(PortOf(handle))). Before, every handle on port 1 or higher reported connected, and port 0 reported port 0's state regardless of the handle.
  • fix(libScePad): scePadRead and scePadReadState return PAD_ERROR_NOT_INITIALIZED for standard pad handles before scePadInit. scePadRead checks initialisation first, before the handle and argument checks, as firmware 13.42 does. scePadReadState checks it after its handle and argument checks; its order on the console is not traced yet. scePadOpen does not require initialisation, so such a handle can exist, and the read then threw from Pad::Read / Pad::ReadState.
  • fix(libScePad): queued motion samples are integrated at their own timestamps. Pad::Read used the read time as the fusion clock, so only the first queued sample of a read advanced the orientation.
  • test(libScePad): covers the queued motion clock, the port 1 connection state, and the early reads (including an unknown handle before scePadInit).

Not in this PR: closing a standard pad handle does not make later handle exports return PAD_ERROR_INVALID_HANDLE. KnownHandle still accepts any port that was ever opened. Changing this also changes the pinned behaviour in GuestPad.cpp (a closed handle still accepts scePadSetLightBar), and the console behaviour for closed handles is not measured here. This needs a maintainer decision and is left as a follow-up.

Not in this PR: Pad::Read clearing the button latch. The existing CheckLatchedPressLifetime test pins that a press drained by scePadRead is not reported again by scePadReadState.

Overlap: #6337 changes the timestamps of fresh samples in ReadState and the empty-queue path of Read. This PR changes only the fusion clock of queued entries and does not touch those lines' semantics. #14124 rejects negative user IDs before PortForUser, which covers the integer overflow item.

Tested

  • Linux x86-64, lane 3 build (run_tests.sh archive build with the host CPU gate patched), final HEAD d8cb5b574f:
    • guest_pad: passed (4.75 sec)
    • guest_pad_vibration_mode: passed
  • python3 tools/check_conventions.py --base upstream/main: clean. git diff --check upstream/main: clean.
  • Not run: negative control for the queued motion test and for the new early-read cases against the unfixed code. Not run on Windows, macOS, a console or a title.
  • During development, two earlier test expectations were wrong (a second close and a closed-handle light bar); both were removed, and the closed-handle behaviour is not part of this PR.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed under Silent stubs in their library's technical debt file
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • Fixes: none
  • Unblocks: none
  • GPUs tested: n/a
  • AI-assisted: yes

@BunnyFl BunnyFl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The connection-state fix and the per-sample fusion clock look right to me. Two of the new error returns don't match firmware 13.42, though, so I'm requesting changes.

Firmware 13.42's system/common/lib/libScePad.sprx (sha256 57eab7054e10d95ba0028dbd4808f94eb34c7663333a2b7ca9f68dc90cf62c60), read from disassembly, not measured on a console:

0x3090 scePadRead (q1cHNfGycLI): xor ecx, ecx ; jmp 0x1e50
0x1e50
  1e9a: cmp dword [0x24018], 0 ; je 2112        ; 2112: mov edx, 0x80920005 (NOT_INITIALIZED)
  1eaa: test rsi, rsi ; ... num-0x41 < 0xffffffc0 ; mov edx, 0x80920001 ; jne   ; null data or num outside 1..64
  ...                                            ; handle work only after these
0xf5e0 scePadSetVibrationForce (8BOObG94-tc)
  f5fb: cmp dword [0x24018], 0 ; je -> 0x80920005
  f604: test rsi, rsi ; je -> 0x80920001
  f615: lock ; ... ioctl(fd, 0x80184822, {handle, large, small, ...}) ; unlock
  return the ioctl result if negative
  1. scePadRead checks initialization first. In the module, NOT_INITIALIZED comes before the data and count check and before the handle is looked at. This PR puts it after KnownHandle, the argument checks and the special-handle branch. So before scePadInit, an unknown handle gets INVALID_HANDLE here but NOT_INITIALIZED on the console, and so does a special handle. Moving if (!Pad::IsInitialized()) return PAD_ERROR_NOT_INITIALIZED; to the top of scePadRead, for every handle kind, matches the module.
  2. scePadSetVibrationForce with a handle that has no pad port. The module never validates the handle itself. After the init and null checks, it passes the handle to the kernel ioctl 0x80184822 and returns whatever that gives. Returning PAD_ERROR_INVALID_HANDLE there is a code the module doesn't produce; the kernel's answer isn't known. main's NotImplemented for that case was the honest choice, so I'd keep it. The module's order also puts the init check before the null-param check, if you want to align that while you're there.

I didn't trace the scePadReadState body past its first checks (at 0x1a70 it rejects a null data with 0x80920001 first), so I haven't settled where its init check sits. I didn't build this branch; the points above come from the module.

@oneandonlydean

Copy link
Copy Markdown
Collaborator

Hi MarcZX10,

Thanks for the per-port connection fix and the per-sample fusion clock; both read correctly to me, and the two new tests (CheckSecondPortConnection, CheckQueuedMotionSampleTimes) exercise them.

I agree with BunnyFl's review on the two error returns, and have one addition on the test side:

  1. scePadSetVibrationForce for a handle with no pad port now returns PAD_ERROR_INVALID_HANDLE, and CheckVibrationForce pins that for 0x999 and -1. The PR does not say where that code comes from, and BunnyFl's disassembly shows the native function passing the handle straight to a kernel call rather than validating it. Could you either keep the previous NotImplemented for that case (and drop the two new Require lines), or cite the source for INVALID_HANDLE and say in the debt entry that the code is not measured?
  2. scePadRead / scePadReadState return PAD_ERROR_NOT_INITIALIZED only after the handle and argument checks. BunnyFl reports that the native scePadRead checks initialisation first. If you follow that, the early-pad test (earlyPad) still holds, and an unknown handle before scePadInit would then also return NOT_INITIALIZED.

@github-actions github-actions Bot added the prx label Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Other open pull requests touch the same code. Merging one will break the other, or both implement the same thing. If this one builds on them, list them in Depends on.

Pull request Conflicting files (lines on main) Same exports Same new files
PR 15905 core/libs/tests/GuestPad.cpp L11, L163-165
PR 16289 core/libs/tests/GuestPad.cpp L11, L982, L1297-1298

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 5038 core/libs/prx/libScePad/Export.cpp
docs/dev/debt/libScePad.md
PR 8260 core/libs/prx/libScePad/Export.cpp
core/libs/tests/GuestPad.cpp
docs/dev/debt/libScePad.md
PR 12310 core/libs/prx/libScePad/Export.cpp
docs/dev/debt/libScePad.md
PR 12311 core/libs/prx/libScePad/Export.cpp
docs/dev/debt/libScePad.md
PR 12312 core/libs/prx/libScePad/Export.cpp
docs/dev/debt/libScePad.md
PR 14124 core/libs/prx/libScePad/Export.cpp
core/libs/tests/GuestPad.cpp
docs/dev/debt/libScePad.md
PR 14212 docs/dev/debt/libScePad.md
PR 16463 docs/dev/debt/libScePad.md

@MarcZX10

MarcZX10 commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Head d8cb5b574f.

  1. scePadRead / scePadReadState initialisation order: done for scePadRead. It now checks Pad::IsInitialized() first, before the handle and argument checks, for every handle kind. A new case covers unknown handle 0x999 before scePadInit.
    scePadReadState is unchanged. It still returns NOT_INITIALIZED after its handle and argument checks. Could you trace where its init check sits in the module, or give a measurement, so we can match it?
  2. scePadSetVibrationForce without a pad port: done. It is back to NotImplemented, and the INVALID_HANDLE lines are removed from CheckVibrationForce.

Queued motion clock and port 1 connection fixes: unchanged, as accepted.

Verified on d8cb5b574f (Linux x86-64, archive build, exit 0): guest_pad passed (4.75 sec), guest_pad_vibration_mode passed. check_conventions and git diff --check clean.
Not run: a negative control of the new early-read cases against the unfixed code.

@BunnyFl BunnyFl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MarcZX10 thanks: scePadRead now checks initialisation first (Export.cpp:307), and the vibration-force case is back to NotImplemented. Here is the scePadReadState trace you asked for. Its init check sits elsewhere than at Export.cpp:324 on this head (details below); I'm requesting that one change.

Firmware 13.42 system/common/lib/libScePad.sprx (sha256 57eab7054e10d95ba0028dbd4808f94eb34c7663333a2b7ca9f68dc90cf62c60), read from disassembly, not measured on a console:

3080: 31 d2              xor    %edx,%edx              ; scePadReadState (YndgXqQVV7c)
3082: e9 e9 e9 ff ff     jmp    0x1a70
1a70: 48 85 f6           test   %rsi,%rsi              ; data
1a73: 74 33              je     0x1aa8                 ; 1aa8: mov $0x80920001,%eax ; ret
      ...                                              ; 1adc-1b12: fill *data with defaults
1afa: 44 89 f1           mov    %r14d,%ecx
1b19: e8 32 03 00 00     call   0x1e50                 ; (handle, data, 1, 0): scePadRead's reader
1b1e: 85 c0              test   %eax,%eax
1b20: 74 0d              je     0x1b2f                 ; 0: continues in scePadReadState
1b22: 0f 88 1b 03 00 00  js     0x1e43                 ; negative: returned as is
1e9a: 83 3d 77 21 02 00 00  cmpl $0x0,0x22177(%rip)    ; 0x24018: initialised?
1ea1: 0f 84 6b 02 00 00  je     0x2112                 ; 2112: mov $0x80920005,%edx (NOT_INITIALIZED)
1eaa: 48 85 f6           test   %rsi,%rsi
1ead: 41 8d 57 bf        lea    -0x41(%r15),%edx
      ...                                              ; 1eb1-1eba: sete/setb/or combine the two tests
1eb4: 83 fa c0           cmp    $0xffffffc0,%edx       ; num outside 1..64, or null data
1ebc: ba 01 00 92 80     mov    $0x80920001,%edx
1ec1: 0f 85 95 10 00 00  jne    0x2f5c                 ; 2f5c: return %edx
1eda: e8 11 8d 01 00     call   0x1abf0                ; with 0x24028 (not read); the handle is used from 0x1ee7

So scePadReadState checks, in this order:

  1. a null data → INVALID_ARG (0x1a70);
  2. it fills *data with defaults;
  3. not initialised → NOT_INITIALIZED (0x1e9a), for every handle. 0x1e50 first uses the handle at 0x1ee7, after the init and argument checks and a call to 0x1abf0 that I haven't read.

This head checks the handle first (Export.cpp:322), then data (:323), then initialisation only for handles that are neither remote nor special (:324). Before scePadInit, three cases differ from the module:

  • an unknown handle with a valid data → the console gives NOT_INITIALIZED, this head INVALID_HANDLE;
  • an unknown handle with null data → the console gives INVALID_ARG, this head INVALID_HANDLE;
  • a remote or special handle → the console gives NOT_INITIALIZED, this head fills *data and returns 0.

Requested change: in scePadReadState, check data == nullptr first, then !Pad::IsInitialized() for every handle kind, then the handle. A test case per bullet above would pin it. Step 2 means the console also writes its defaults into *data before returning NOT_INITIALIZED. Matching that is optional; if you leave it out, the debt entry is the place to say so.

Not blocking, already on main: in scePadRead's shared reader the argument check also comes before the handle (0x1eaa-0x1ec1, then the lookup at 0x1ee7), and it rejects num above 64 as well as below 1 (lea -0x41; cmp $0xffffffc0; I checked the range by modelling those two instructions over -1000..1000: exactly 1..64 pass). This head checks the handle first (Export.cpp:308) and only num <= 0 (:309). Fine to leave for a follow-up.

What I ran. This head merged onto main 23d549a9c7 (merge 5bce0a95a9, clean), Windows 11 Pro, WinLibs GCC, Release:

  • guest_pad and guest_pad_vibration_mode: 2 of 2 pass.
  • Negative control: with main's Export.cpp, guest_pad aborts (1 of 2 failed), so the new early-read cases fail on main. That's the run your Tested section listed as not done.
  • tools/pr_overlap.py --branch at this head: no same exports. #16289 conflicts in core/libs/tests/GuestPad.cpp only. Nine PRs couldn't be compared (#5038, #8260, #12308-#12312, #13486, #14124); none of their GitHub patches changes scePadRead, scePadReadState, scePadSetVibrationForce, the connection check or the fusion clock.

AI-assisted: yes (Claude Code)

@BunnyFl

BunnyFl commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

@MarcZX10 one addition to my review, so you don't need to ask for it: the defaults scePadReadState writes into *data (step 2), at 0x1adc-0x1b12, before the call to the reader at 0x1b19. Same module and sha256 as in the review, read from disassembly, not measured on a console.

With edx = 0, which is what scePadReadState passes (0x3080), 0x1a70 takes the branch at 0x1adc:

1adc: c5 fe 7f 03           vmovdqu %ymm0,(%rbx)          ; ymm0 = 0
1ae0: c5 fe 7f 43 20        vmovdqu %ymm0,0x20(%rbx)
1ae5: c5 fe 7f 43 58        vmovdqu %ymm0,0x58(%rbx)
1aea: c5 fe 7f 43 40        vmovdqu %ymm0,0x40(%rbx)      ; bytes 0x00-0x77 are now 0
1afd: c7 43 04 80 80 80 80  movl   $0x80808080,0x4(%rbx)
1b04: c7 43 0c 00 00 00 00  movl   $0x0,0xc(%rbx)
1b0b: c7 43 18 00 00 80 3f  movl   $0x3f800000,0x18(%rbx)
1b12: c7 43 20 00 00 80 3f  movl   $0x3f800000,0x20(%rbx)

On AnyPS5's PadData (core/libs/SceTypes.hpp:1178), that is:

  • every field from buttons through offset 0x77 set to 0;
  • then left_stick_x, left_stick_y, right_stick_x and right_stick_y set to 0x80;
  • then orientation_w set to 1.0f;
  • then acceleration_y set to 1.0f.

These stores (0x1adc-0x1b12) come before the reader's NOT_INITIALIZED check at 0x1e9a. Before scePadInit, the console returns NOT_INITIALIZED with *data holding these values.

What I haven't read, so I can't answer it yet:

  • the path when the reader returns 0 (0x1b2f onward), where scePadReadState calls 0x1abf0 with 0x24028 again (0x1b2f-0x1b36) and then walks a table at 0x24018;
  • how the reader treats remote and special handles once the pad library is initialised.

AI-assisted: yes (Claude Code)

@BunnyFl

BunnyFl commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

@MarcZX10 here are the two paths my last comment (issuecomment-6690429806) listed as unread. I've now read them; what is still unread is at the end. Same module and sha256, read from disassembly, not measured on a console. 0x1abf0 and 0x1ac10 are called with 0x24028 around every table access; I take them for a lock and unlock but haven't read them.

Handles in the reader (0x1e50), after the init and argument checks. It calls 0x1abf0(0x24028) and returns its result if negative (0x1edf-0x1ee1, 0x211c). Then it compares the handle against 16 table slots (0x1f10-0x1f8d, stride 0x5c8 from 0x24018). If no slot matches:

2123: 48 8d 3d fe 1e 02 00  lea    0x21efe(%rip),%rdi    ; 0x24028
212a: e8 e1 8a 01 00        call   0x1ac10
212f: ba 03 00 92 80        mov    $0x80920003,%edx      ; INVALID_HANDLE
2134: e9 23 0e 00 00        jmp    0x2f5c                ; return %edx

There is no separate branch for remote or special handles in this function: every handle goes through the same slot lookup. Whether the handles this PR calls remote or special get a slot depends on how they were opened, which I haven't traced.

scePadReadState after the reader returns (0x1b1e onward):

  • negative → returned as is (0x1b22);
  • positive (a sample was read into *data) → returns 0 (0x1b28);
  • 0 (no sample) → it calls 0x1abf0(0x24028) again, returning its result if negative (0x1b3d), and repeats the slot lookup itself (0x1b60-0x1bdb):
    • no slot matches → 0x1ac10, then returns 0x80920003 (0x1d2a-0x1d36);
    • the slot's first word is 0 → 0x1ac10, then returns 0 with the defaults from step 2 still in *data (0x1cec-0x1cf0 → 0x1e2f);
    • otherwise it copies the slot's cached state (slot + 0x4a0 to +0x517) over *data (0x1d90-0x1dc3). If connected (+0x4c) is then 0, it sets connected = 1, connected_count (+0x68) from slot byte 0x1c, and timestamp (+0x50) to the result of 0x1acb0() minus 1000. It then writes *data back into the slot cache (0x1dc7-0x1e2c), calls 0x1ac10, and returns 0 (0x1e3d → 0x1b28).

Not read: 0x1acb0, which supplies the timestamp, and the reader's sample-copy path once a slot matches (0x2147 onward).

AI-assisted: yes (Claude Code)

@adriadam10 adriadam10 self-assigned this Nov 7, 2026
MarcZX10 and others added 8 commits November 7, 2026 03:45
Pad::Read builds each queued entry with the read time as the fusion clock,
so only the first queued sample of a read advances the orientation; every
later sample sees dt = 0 and its gyro data is dropped from the integration.

Use the sample timestamp as the fusion clock for queued entries and keep
the last fusion time monotonic. Fresh samples from ReadState and the
empty-queue path still use the read time.
The connected flag of scePadGetControllerInformation was true for every handle on a port other than 0, and for port 0 it reflected port 0 regardless of the handle. It now reads the connection state of the port the handle belongs to.
…ePadSetVibrationForce

A handle that passed the null check but has no pad port reached a NotImplemented throw. It now returns PAD_ERROR_INVALID_HANDLE, as the other handle-taking exports do.
…State before initialisation

scePadOpen does not require scePadInit, so a standard handle can exist before initialisation. scePadRead and scePadReadState then reached Pad::Read and Pad::ReadState, which throw. They now return PAD_ERROR_NOT_INITIALIZED, as the other pad exports do.
… reads

Cover the queued motion sample clock, the connection state of a port 1 handle, the vibration force handle error, and scePadRead and scePadReadState before initialisation. The queued motion check was not run against the unfixed code.
…ion force unimplemented without a pad port

Firmware 13.42 checks the init state before the handle in scePadRead, and its scePadSetVibrationForce does not validate the handle itself, so the INVALID_HANDLE return is not what the module does. Restore the NotImplemented path.
…scePadReadState

Co-Authored-By: Claude <noreply@anthropic.com>
@adriadam10
adriadam10 force-pushed the fix/p08-pad-handles-latch branch from d8cb5b5 to a0e86f9 Compare November 7, 2026 02:52
@github-actions github-actions Bot added the docs label Nov 7, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants