PlayGuard

How the Switch parental controls work

此頁面尚未翻譯,以下為英文版。

What PlayGuard knows about the console's parental-control service (pctl), the play timer and the clocks around it: what the public references say, and what was found on hardware while building PlayGuard. It is meant to grow: every diagnostic report that teaches something new should end up here.

Each fact says how sure it is:

  • verified: seen on a console, with the firmware it was seen on;
  • observed: seen in diagnostic reports, consistent, but not tried every way;
  • inferred: deduced from the layout or from other facts, not tested directly;
  • unknown: still open. A diagnostic report that answers it is welcome.

The code is the reference for the details: source/core/pctl_ops.c (command table, session handling), source/core/pure.h (the play-timer block), source/core/time_ops.h (clocks), sysmodule/source/main.c (rescue). Public reference: switchbrew, Parental Control services.

The service and its session

  • PlayGuard runs under Atmosphère, where libnx's pctlInitialize lands on a privileged pctl:a / pctl:s session. The privileged commands (settings, PIN, play timer writes) only work there.
  • pctl:a takes a single session. Holding it while the system wants it (the HOME menu's PIN prompt, the pctlauth applet) made Atmosphère unstable (verified, 22.5.0). PlayGuard therefore opens a session, does one piece of work and closes it, every time, including on errors.
  • The PIN applet (pctlauth, used to register, change or ask for the PIN) opens its own privileged session: PlayGuard's must be closed first.
  • A malformed request makes the service drop the session (0xF601): the next command needs a fresh one (pctl_ops_reinit).

Restrictions

Command What Notes
1031 IsRestrictionEnabled parental controls on false while no PIN is set (observed, 22.0.0)
1032 / 1033 Get/SetSafetyLevel 0 none, 1 custom, 2 young child, 3 child, 4 teen
1034 GetSafetyLevelSettings(level) what a preset restricts 22.0.0: level 2 → age 7, sns 1, comm 1; level 3 → 12, 1, 1; level 4 → 16, 0, 0 (observed)
1035 / 1036 GetCurrentSettings / SetCustomSafetyLevelSettings 3 bytes: rating age, SNS posting restricted, free communication restricted 1036 only applies at the custom level
1037 / 1038 Get/SetDefaultRatingOrganization rating body, < 13 6 is PEGI on a European console (observed)
1039 / 1044 free-communication application list count, then 16-byte entries entry: application ID (u64, LE), then 01 and seven zeros (observed); the meaning of the 01 is unknown
1062 / 1063 stereo vision (3D) restriction bool follows the temporary unlock: false while unlocked, true once locked again (observed, 22.0.0)
1903 / 1904 exempt application list (debug) count, then entries always empty so far
1406 GetSettingsLastUpdated POSIX time always fails with 0x0001188E on the test console (observed, 22.0.0 and 23.0.1); the cause is unknown
1403 IsPairingActive companion app paired
1043 DeleteSettings wipes the PIN and every restriction cannot be undone
1941 DeletePairing unlinks the companion app
1601 / 1602 / 1603 return 0x00010A8E on a normal console: not usable (the 22.5.0 sysmodule returns that code when the session lacks a capability bit, as reversed by NX-Pctl-Manager)

PIN and temporary unlock

  • 1206 GetPinCodeLength: 0 means no PIN.
  • 1208 GetPinCode: the PIN comes back in an HIPC pointer buffer (SfBufferAttr_HipcPointer), NUL-terminated. PlayGuard never logs or reports its content.
  • 1201 UnlockRestrictionTemporarily (verified, 22.1.0), two traps:
  • the buffer must be an HIPC pointer buffer; a map-alias buffer, or no buffer, makes the service drop the session (0xF601);
  • the PIN must be passed NUL-terminated (length + 1 bytes); the bare digits are refused with 0xF80E.
  • From the 22.5.0 sysmodule, as reversed by NX-Pctl-Manager: its PIN check answers 0x0000F08E (2142-0120) for a wrong PIN or one that is not 4 to 8 digits, and 0x0001088E (2142-0132) when no PIN is registered. PlayGuard names both, not checked on a console yet.
  • 1006 IsRestrictionTemporaryUnlocked confirms the unlock. 1201 can succeed while 1006 still reads false: PlayGuard checks, and locks again with 1007 RevertRestrictionTemporaryUnlocked when it cannot tell.
  • While temporarily unlocked, 1453 IsPlayTimerEnabled reads false (observed, 22.0.0).
  • A restart of the console ends a temporary unlock: the console comes back locked, PlayGuard or not (observed, 23.0.1).
  • Registering or changing the PIN goes through the pctlauth applet; the direct SetPinCode command is refused.
  • The rescue sysmodule uses the same 1208 → 1201 sequence at boot, with no applet and no screen, so it still works while the play timer blocks every program.

The play timer

Commands

Command What Notes
1453 IsPlayTimerEnabled timer on true even with an all-zero block and no PIN; false while temporarily unlocked (observed, 22.0.0)
1455 IsRestrictedByPlayTimer the timer is blocking play now stays false past the limit while the console only warns (ALARM); true past it when suspending, even while temporarily unlocked (observed, 2026-10-14)
1454 GetPlayTimerRemainingTime TimeSpan, nanoseconds, signed today's limit minus the time spent; 0 when today has no limit; below 0 once more was played than the limit (the console only warning, or parental controls unlocked): minus the time over it, 1454 + 1952 still the limit (observed, 22.0.0, 2026-10-14: -224 s and 224 s on a 0-minute day)
1952 GetPlayTimerSpentTimeForTest TimeSpan, nanoseconds time spent today, see below
1459 GetPlayTimerRemainingTimeDisplayInfo [20.0.0+] 0x20 bytes layout partly known, see below
1458 / 1953 alarm disabled (get / set for debug) bool while set, the "time's up" alarm does not show
1451 / 1452 Start/StopPlayTimer
1954 IsBedtimeAlarmEnabled [18.0.0+] bool today's bedtime
1956 / 1957 bedtime alarm hour / minute [18.0.0+] u8 each
1958 / 1959 bedtime "allowed again" hour / minute [20.0.0+] u8 each
1960 GetExtraPlayingTimeForDebug [20.0.0+] always 0 so far
145601 GetPlayTimerSettings 0x44 bytes [21.0.0+ size] the block below
195101 SetPlayTimerSettingsForDebug 0x44 bytes writes the block
1460 GetWatcherStatusDisplayInfo [23.0.0+] in 1 byte, out 0x18 bytes with 0: the first 0x18 bytes of 1459 (observed, 23.0.1); the rest of its layout and its input are unknown

Never call 1456 or 1951: they are the 18.0.0–20.5.0 "old" variants and close the session on 21.0.0+.

Writing safely

Writing the settings while the timer counts down destabilises Atmosphère. Every PlayGuard play-timer write re-reads 1453 / 1455 / 1006 in its own session right before writing, and refuses while the timer is enabled or restricting and not temporarily unlocked.

With no PIN registered, 195101 writes nothing and still answers OK (the 22.5.0 sysmodule, as reversed by NX-Pctl-Manager): the limit, bedtime and "when the time is up" writes read 1206 first and refuse with "no PIN" (a 1206 that cannot be read lets the write go ahead; its read-back still catches a skipped write). The same sysmodule answers 0x00010E8E (2142-0135) while its settings manager is not set up yet.

The settings block (145601 / 195101)

0x44 bytes: four mode bytes, the daily rule and seven 8-byte days, Sunday to Saturday.

offset  size  field
0x00     4    modes
0x04     8    daily rule (timerMode DAILY)
0x0C     8    Sunday
0x14     8    Monday
 ...
0x3C     8    Saturday

The header, from the 22.5.0 pctl sysmodule as reversed by NX-Pctl-Manager (its own settings file's field table, enabled, mode, weekUsed, then the rules, and the server-sync serializer's names for bytes 1 and 2). It matches every block PlayGuard has seen (01 01 01 00); byte 01 at 0 (ALARM) was verified on a console (2026-10-14), DAILY not yet:

Byte Observed Meaning
00 01 enabled: pctl clears it on a 195101 write, and sets it again only when the network clock gives the time
01 01 restrictionMode: 0 ALARM (a warning only), 1 FORCED_TERMINATION (the software is suspended)
02 01 timerMode: 0 DAILY (the daily rule, every day), 1 EACH_DAY_OF_THE_WEEK (the seven days)
03 00 reserved
04..0B 00 00 00 06 00 00 00 00 the daily rule, in a day's format. Its 06 (byte 07) stays in the "off" block, but was seen turn to 00 with the limits kept (observed, 22.0.0, between 20:10 and 20:44 on 2026-10-09; what changed it is not known)

What PlayGuard does with them (source/core/pure.c): in DAILY mode the limits and bedtimes shown are the daily rule's, for every day (the seven days are ignored by the console). A write that changes such a block moves it to EACH_DAY_OF_THE_WEEK first, each day given the daily rule (all-zero days when it has nothing on), so the console does the same as before but for the change; writing back what was read leaves it as it is. restrictionMode is kept as read by those writes; Play timer › When the time is up shows it and changes it, byte 01 alone, once a limit or a bedtime is set (the "off" block is all zeros, and the next limit gives it the observed 01 01 01 00). The write is read back, and the block put back when the console does not keep it.

The byte-by-byte reading of a real block is under Reading a diagnostic report.

One day (8 bytes):

Byte Field Status
+0 bedtime on verified, 22.0.0
+1, +2 bedtime alarm hour, minute verified, 22.0.0
+3, +4 play allowed again, hour, minute (06:00 by default) verified, 22.0.0
+5 "this day has a limit" flag verified
+6, +7 that day's limit in minutes, u16 LE verified, 22.0.0
  • A day with no limit: flag 0, minutes 0. A day with its flag on and 0 minutes is a real 0-minute limit: no play that day. PlayGuard writes it for a day set to 0.
  • Everything zero but the header's byte 07 (06): no limit and no bedtime. PlayGuard writes all zeros for "no limit on any day", the one "off" block seen working. 1453 still reads true with that block (observed, 22.0.0).
  • The bedtime bytes match the companion app's daily settings (timeToPlayInOneDay, bedtime.enabled, endingTime, startingTime). Verified on 22.0.0 (2026-10-14): with 22:30, play allowed again at 09:00, set by PlayGuard, every day reads 01 16 1E 09 00 (on, 22, 30, 9, 0) and 1954 / 1956 / 1957 / 1958 / 1959 report on, 22:30 and 09:00. A bedtime write is still checked against 1954 / 1956 / 1957 and undone when the console disagrees.

What PlayGuard accepts as this layout (pt_plausible, source/core/pure.c): in each day and in the daily rule 04..0B, the bedtime and limit switches 0 or 1, hours below 24, minutes below 60, the limit 0 to 1440 minutes (or FFFF). The four mode bytes are not checked, so any value passes there (a non-zero mode is read as 1). Only what the layout cannot hold is refused, so an unseen companion-app setting does not block PlayGuard. A block that fails (a firmware that changed the layout, or garbage) is not shown, and every play-timer write refuses it without writing (the console's play-timer settings are in a format PlayGuard does not understand); the diagnostic report still has it, byte for byte.

How the minutes field was confirmed (22.0.0, 2026-10-09): the block was saved as a reference (Developer › Compare the play-timer block), Sunday and Saturday were changed from 120 to 180 minutes in PlayGuard, then the block was read again. Only two bytes changed: 0x12 and 0x42, both 78 → B4. The header, the flags and the bedtime bytes stayed as they were.

The header is the modes plus a "daily" rule

This was a hypothesis, now confirmed by the sysmodule (above). The companion app's API, as reproduced by open-source clients (pynintendoparental, used by Home Assistant, and switch-parental-controls), describes the play timer with:

  • restrictionMode: ALARM (an alarm only) or FORCED_TERMINATION (the software is suspended);
  • timerMode: DAILY (one rule for every day) or EACH_DAY_OF_THE_WEEK;
  • dailyRegulations: that one rule, and eachDayOfTheWeekRegulations: seven, each with a limit and a bedtime.

That is exactly eight rules and a few modes, and the block holds:

  • 12 header bytes = 4 mode bytes + one 8-byte rule;
  • bytes 04..0B follow the day format to the byte, down to the 06 of the default 06:00 "allowed again" time at their +3, which no other header field would explain;
  • that 06 stays when everything else is cleared, like a default. It was also seen turn to 00 while every limit stayed: a value that can change, as an "allowed again" hour of a rule nobody uses might.

So the guess was: bytes 00..03 hold the modes and bytes 04..0B the DAILY rule. The sysmodule places them: byte 01 is restrictionMode, byte 02 timerMode.

Still worth checking on a console (with the block saved first and put back after): put a limit in the 04..0B rule, set byte 02 to 0, then read 1454 / 1459: the remaining time should follow the daily rule. For restrictionMode, let the time run out once with each value of byte 01 and note whether the software is suspended.

Time spent and time left

Observed on 22.0.0, 2026-10-09 (a Friday, limit 120 minutes):

  • 1454 + 1952 = today's limit, every time: 1645 + 5555, 1560 + 5640 and 1428 + 5772 seconds, all 7200 s = 120 min.
  • Changing other days' limits leaves today's remaining time alone.
  • 1952 advances in real time while an application runs, PlayGuard included: +85 s between two reports 85 s apart, +132 s for 132 s.
  • It also counts with no limit set and no PIN (+370 s in 370 s, every day empty).
  • It only grew by 354 s across two hours, part of them temporarily unlocked: time outside applications, or while unlocked, does not seem to count (inferred; not measured separately).

Changing the clock resets the time spent (observed, 22.0.0, 2026-10-09, the evening reports):

When (user clock, local) What happened 1952 spent 1454 left
20:10 5772 s 1428 s
between automatic correction turned on: the user clock jumps 1411 s forward, to the network clock
21:08 54 s 7146 s
21:08 → 20:45 PlayGuard sets the network clock from NTP: the user clock follows, 1387 s back
20:45:14 0 s 7200 s
20:45:27 0 s 7200 s
20:46:22 PlayGuard open the whole time 0 s 7200 s
  • After the forward jump the day started over: 54 s counted since, out of more than 1900 s of reports apart. Whether the jump itself or something else in between reset it is inferred, not certain.
  • The backward jump reset it at once, and the time then stopped counting (55 s with PlayGuard open, still 0). Likely explanation, to be confirmed: the console counts from a reference time that is now in the future (21:08) and waits for the clock to pass it.
  • Both reset the remaining time to the full limit: changing the clock is a way around the play timer, and PlayGuard's own Console › Network clock does it.

When the time spent resets on its own (midnight local time, or the "allowed again" time) is not confirmed yet.

What PlayGuard does with this:

  • Timer health (source/action/timer_health_logic.cpp): since its own time counts while it runs as an application, the time left must go down while it is on screen. With the timer on, a limit above 0 today, not reached, not temporarily unlocked and PlayGuard not opened from the album, a time left that has not moved by 5 s over about 90 s on screen makes the Overview and the Play timer say the console is not counting. The likely causes it gives come from above (a clock set back) and from other tools' users (NX-Pctl-Manager #2, a network clock never set; #3 and #7, a limit just written). Not seen on a console yet: inferred from the table above.
  • Changes made outside PlayGuard (config.md): the time spent, taken as today's limit minus the time left (they add up to the limit), going down on the same day; the user clock moving against the steady clock; limits that differ from the ones PlayGuard last saw.

When the time runs out

A limit written by PlayGuard is enforced by the console (verified, 22.0.0, 2026-10-09): once today's time was used up, the system put up its full-screen "time limit reached, no more play today" message (in French: Vous avez atteint la limite de temps de jeu et ne pouvez plus jouer aujourd'hui), with two choices only: Sleep mode and Disable parental controls (the second asks for the PIN).

  • No "continue" choice: the suspend behaviour (FORCED_TERMINATION), which byte 01 of that block (01) says.
  • So PlayGuard says what the console does at the limit from that byte: "Time's up" always, then the game suspended, or a warning only once set to "alarm only" (in the phone app, or When the time is up; a limit below the time already played then warns of no suspension). ALARM verified on a console (2026-10-14): at the limit the console warned and the game went on; 1455 stayed false and 1454 went below 0, by the time over the limit, which the Overview and the Play timer tab show. Suspending can lose unsaved progress (Nintendo: time limit settings), so its confirmations say so, and No more play today can wait 5 minutes.
  • The PIN prompt behind Disable parental controls is the system's: a child who knows the PIN gets past the limit there.

1459 GetPlayTimerRemainingTimeDisplayInfo

0x20 bytes. Observed on 22.0.0:

Offset Size Value
0x00 1 02 while today has a limit, 00 without one
0x01..0x0F zeros
0x10 8 the remaining time in nanoseconds, the same value as 1454
0x18..0x1F zeros

Without any limit, all 0x20 bytes are zero. What the first byte means (a display state? which message the HOME menu shows?) is unknown: a report taken with the time used up, or a few minutes before, should tell.

Clocks

The play timer counts per day, so the console's clocks matter.

  • The time:s service gives the user clock (what the HOME menu shows), the network clock and the local clock, in UTC POSIX seconds.
  • 100 IsStandardUserSystemClockAutomaticCorrectionEnabled: when it is off, the user clock does not follow the network clock. PlayGuard refuses to write a measured time to the network clock then, so the system's own setting stays in charge. The exception is a console no time server answers: Set the network clock to the console clock (time_clock_set_to_user, decided by source/action/offline_clock_logic.cpp) writes the user clock, read in the same session, to the network clock, with correction off too. The user clock is the source and does not move; turning correction on afterwards should then leave it in place, both clocks being equal (inferred from the jump below; not tried on a console yet).
  • 200 IsStandardNetworkSystemClockAccuracySufficient.
  • On the test console the network clock was 1411 s (23.5 min) ahead of the user clock, with automatic correction off and accuracy insufficient (observed, 22.0.0, 2026-10-09). The network clock was the wrong one: once automatic correction was turned on, the user clock jumped to it, and setting the network clock from NTP (europe.pool.ntp.org, then fr.pool.ntp.org) put both back 1387 s, verified by reading it back. 200 then read true.
  • With automatic correction on, the user clock follows a network clock written by PlayGuard immediately (same value in the report taken right after).

Reading a diagnostic report

The report prints what each command returns, raw. Three habits make it readable:

  • Little-endian. Every number the console sends is stored lowest byte first. 78 00 is 0x0078 = 120; 00 C8 6E 7B 4C 01 00 00 is 0x0000014C7B6EC800.
  • TimeSpan is in nanoseconds. Divide by 1 000 000 000: 0x0000014C7B6EC800 = 1 428 000 000 000 ns = 1428 s = 23 min 48 s.
  • rc=0x00000000 is success. Anything else is a Horizon result code; the value after it is then meaningless (the report prints -).

A free-communication list entry (1044)

The report prints one line per entry: the application ID, then the eight bytes after it as returned (0100C9A00ECE6000 01 00 00 00 00 00 00 00). Byte by byte:

00 60 CE 0E A0 C9 00 01   01   00 00 00 00 00 00 00
└──────── u64 LE ───────┘  │   └─────── zeros ─────┘
 application 0100C9A00ECE6000  unknown, always 01

The play-timer block, byte by byte

The block of the 20:10 report (22.0.0, 2026-10-09), as the report prints it:

01 01 01 00 00 00 00 06 00 00 00 00 00 00 00 06
00 01 B4 00 00 00 00 06 00 01 00 00 00 00 00 06
00 01 00 00 00 00 00 06 00 01 00 00 00 00 00 06
00 01 00 00 00 00 00 06 00 01 78 00 00 00 00 06
00 01 B4 00

Cut into its fields instead of rows of 16:

offset  bytes                     field
0x00    01 01 01 00               modes: enabled, FORCED_TERMINATION, EACH_DAY_OF_THE_WEEK
0x04    00 00 00 06 00 00 00 00   the daily rule (unused in this mode)
0x0C    00 00 00 06 00 01 B4 00   Sunday     limit on, 0x00B4 = 180 min
0x14    00 00 00 06 00 01 00 00   Monday     limit on, 0 min: no play
0x1C    00 00 00 06 00 01 00 00   Tuesday    limit on, 0 min
0x24    00 00 00 06 00 01 00 00   Wednesday  limit on, 0 min
0x2C    00 00 00 06 00 01 00 00   Thursday   limit on, 0 min
0x34    00 00 00 06 00 01 78 00   Friday     limit on, 0x0078 = 120 min
0x3C    00 00 00 06 00 01 B4 00   Saturday   limit on, 0x00B4 = 180 min

One day, field by field (Sunday above):

00   00   00   06   00   01   B4 00
│    │    │    │    │    │    └───┴── limit, minutes, u16 LE: 0x00B4 = 180   verified
│    │    │    │    │    └─────────── limit flag: 1 = this day has a limit    verified
│    │    │    │    └──────────────── play allowed again, minute: 00          verified
│    │    │    └───────────────────── play allowed again, hour: 06            verified
│    │    └────────────────────────── bedtime alarm, minute                   verified
│    └─────────────────────────────── bedtime alarm, hour                     verified
└──────────────────────────────────── bedtime on                              verified

The code (source/core/pure.h) reads the same block as 34 u16 instead of bytes. The header=0101 0001 of the report is the first two: bytes 01 01, then 01 00. Because the header is 12 bytes, a day's u16 straddle its fields: a day's [+0] = 0x0600 is its bytes +2 and +3 (alarm minute, allowed-again hour), [+1] = 0x0100 its bytes +4 and +5 (allowed-again minute, limit flag), [+2] its minutes.

The morning reports (no PIN, no limit) show the "everything off" block:

0x00    00 00 00 00               header: all zero
0x04    00 00 00 06 00 00 00 00   only the 06 is left
0x0C    00 00 00 00 00 00 00 00   Sunday … Saturday: all zero
 …

1459, byte by byte

From the same 20:10 report:

0x00  02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
      └ 02: today has a limit (00 without one; other values unknown)
0x10  00 C8 6E 7B 4C 01 00 00 00 00 00 00 00 00 00 00
      └──────── u64 LE ───────┘
       0x0000014C7B6EC800 ns = 1428 s, the value 1454 returned

Still open

  • 1406 GetSettingsLastUpdated fails with 0x0001188E: why?
  • A console in DAILY mode, to confirm byte 02 on hardware (it comes from the sysmodule, not from a block PlayGuard has seen).
  • Bedtimes that differ from day to day, set from the companion app (the console's answer was only checked for today's).
  • 1459's first byte; 1460's input and what differs from 1459 (23.0.0+).
  • When the time spent resets on its own; how long it stays frozen after the clock goes back (until the clock passes the old time?).
  • What turned the header's byte 07 from 06 to 00.
  • The 01 byte of each free-communication list entry.
  • Whether time spent counts while temporarily unlocked (the app says it may not).
  • Whether a game left open on the HOME menu counts (parents report it does; it fits "while an application is open") and sleep mode does not: not measured by PlayGuard.
  • Whether a PIN entered from the HOME menu is logged anywhere PlayGuard can read (the companion app shows it since 2.5.0; see companion-app.md).

How to add a finding

  1. Turn on the developer tools, take a diagnostic report, change one thing, take another one. For the play-timer block, save the reference first (Developer › Compare the play-timer block): the next report lists every byte that changed. For what changes over time (the time spent, 1459, a reset at midnight), turn on Developer › Record the play timer instead: one CSV line every 30 s in logs/play_timer/log.csv while PlayGuard is open (its own time counts, so the time runs down with PlayGuard alone). The two hex columns are written only when they change. The last column, event, says why a line was written off the 30 s tick: recording started, each change PlayGuard makes, as its change history words it (limits per_day [120 …] -> [180 …], clock, unlock …), written right after it, and clock +1411 s vs elapsed when the user clock moved more than the time that went by (5 s of slack): a clock changed in System Settings, or the console asleep in between. What is done outside PlayGuard (System Settings, the PIN typed in the HOME menu) is not named: note it down. Its last 48 KB go with Send a report online. PlayGuard does not run in the background: the recording stops while a game is open, and while the console sleeps.
  2. Note the firmware, the date and exactly what was changed between the two.
  3. Add the fact here with its status, and update the comment next to the code that uses it.

在 GitHub 上檢視