Skip to content

AArch64 stage 5: PSCI resets and powers the machine off, behind one reset and power-off seam - #647

Merged
Japabu merged 11 commits into
mainfrom
wt/toyos-armnext
Oct 2, 2026
Merged

Japabu merged 11 commits into
mainfrom
wt/toyos-armnext

Conversation

@Japabu

@Japabu Japabu commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

The first owed item of stage 5 in issues/kernel/toyos-runs-on-arm64.md: SYSTEM_RESET, SYSTEM_OFF and CPU_OFF behind one reset and power-off seam, which takes x86-64's reset register and PM1a out of drivers/acpi.rs, and the stop shown on eight CPUs ending in that power-off. The stage's other items stay owed, and the track says which.

What changed, per decision

The seam (kernel/src/power.rs, new) holds can_reboot, reboot, reset_now and shutdown, which were drivers::acpi's. It keeps what every end of the machine owes on either architecture: the console's last drain, the xHCI stop before the machine ends (the module's invariant moves with it), and a reboot refused on a machine without a reset. arch::power does the write: can_reset, reset and off.

x86-64 (arch/x86_64/power.rs, new):

  • The FADT reset register and S5 through PM1a read toyos_acpi::Table directly, as psci.rs does.
  • init_reset stays before percpu::init_bsp loads the IDT, so every reportable panic has a reset to write. init_off runs right after init_bsp, so a fault in the DSDT checksum or the \_S5_ scan is reported. It used to run after gpt::init.
  • A PM1a_CNT_BLK past the 16-bit port space is refused by name, where it used to be narrowed to a different port. A zero one is "no PM1a control block".
  • This code was every caller of the kernel's arch::pio, whose two port-space refusals were dead on x86-64. Both pio modules go, and drivers::acpi::Table::open with them.

toyos-acpi (src/dsdt.rs, new): s5_slp_typ answers S5. It is the kernel's old find_s5_slp_typ, moved beside dsdt_address, and it now refuses a first element wider than SLP_TYP's three bits (S5::Wide), which shifted into place would set SLP_EN and reserved bits.

AArch64 (arch/aarch64/power.rs, new; psci.rs):

  • reset is SYSTEM_RESET, which asks nothing of the other CPUs (DEN0022 §5.12.2), so the wedge and panic paths may call it. The call takes no lock.
  • off is SYSTEM_OFF by the specification's recipe (§5.10.1, §5.10.3): a new SGI, Off, is raised on every other roster CPU by name, as the kick is; each CPU that takes it stops its timer (§5.5.2) and calls CPU_OFF; the caller asks AFFINITY_INFO of each until it answers OFF, which is PSCI's word and not the kernel's; then it calls SYSTEM_OFF.
  • The wait ends at time::DEAF_CPU's span, five seconds from the SGI: the bound the tree already has for a CPU that does not take an interrupt, and the one The boot CPU waits for an AP's echo as long as a dead CPU is: 5 s, not 100 ms #670 moved time::AP_START to from 100 ms on a recorded red. off declares no bound of its own; it had a private 100 ms Budget.
  • At that end a CPU still on is named on the UART and the machine powers off regardless; it does not panic, as the TLB shootdown's wait on the same tripwire does. What keeps a CPU on may be firmware's refusal of CPU_OFF, firmware's word never panics this kernel, and the machine was asked to end. The reason is at the site.
  • AFFINITY_INFO is asked again once a millisecond (ASK_AGAIN, a Cadence), where it was a bare spin of firmware calls. PSCI has no notification for a CPU going off, so the answer is polled, paced as xhci::PORT_POLL paces its port reads.
  • Every PSCI refusal on the way out is named with its code: a SYSTEM_RESET or SYSTEM_OFF that returns, a refused CPU_OFF, and an AFFINITY_INFO refusal, which is named at once and not waited out. The code is the i32 the call returned; psci::Error keeps one table, of, for CPU_ON and PSCI_VERSION.
  • Each of those lines is written whole under one serial::panic_registers() hold, as every fatal path writes since Fatal paths write the console UART only under its registers, whose lock knows which CPU's fatal path holds it #675, and the hold is dropped before a halt: a CPU halted holding the registers costs every later line the whole bound.
  • SGI_OFF is never ended, as SGI_HALT is not. A refused CPU_OFF leaves its CPU halted at the SGI's running priority, which a pending kick cannot get past.
  • psci::init keeps the conduit in one AtomicU8, which smp::start, reset and off all read. Platform loses its copy. It runs first in boot::interrupts, ahead of the per-CPU block and the GIC as x86-64's init_reset is ahead of the APIC and the IDT, so a panic in either's bring-up has a reset; it ran after both.
  • New calls, all PSCI 0.2, which init already requires: CPU_OFF (0x8400_0002), SMC64 AFFINITY_INFO (0xC400_0004), SYSTEM_OFF (0x8400_0008), SYSTEM_RESET (0x8400_0009).
  • Two actuators: power-off-spares-the-last-two (cpu6 and cpu7 halt on SGI_OFF without CPU_OFF) and psci-withheld (psci::init keeps no conduit). Both carry #[allow(dead_code)] with the reason at the site: x86-64 builds the accessors and reads neither, and a target_arch cfg may not go into generic actuator.rs.

The panic path (panic_reboot.rs):

  • On AArch64, a panic after psci::init now arms the reboot bound where it used to hold, because can_reboot is true there.
  • "unless a key is pressed" and "no key inside the bound, so nobody is here" are said only where arch::keyboard_controller::PANIC_KEYS says the panic path reads a key. On AArch64 it reads none.
  • The held line and Bound::Held say "no reset", which is true on a machine without PSCI.

toyos-gicv3: unpacked_affinity, the inverse of packed_affinity, because PSCI names a CPU by MPIDR's fields where they stand.

Tests

Guest, and why each is one. No type holds firmware's answer or what a booted kernel says, no host process executes SMC or HVC or links the kernel's architecture module, and AArch64 has no metal: the track records the owner's ruling that no ARM hardware is a target. Each of the first four rows below judges what a PSCI provider did with a call the kernel made through SMC or HVC from EL1; the rows after them say their own reason.

  • virt_smp (VirtEl2, SMC) and virt_el1_smp (VirtTcg, HVC), changed: tests/virtsmpcase now ends with the job shutdown, and after what they judged before, both judge the power-off. The stop's record names 8 CPUs, Shutting down. is the console's last line, QEMU's SHUTDOWN reason is guest-shutdown and it exits 0, and QEMU's own trace of every PSCI call shows each of the seven other CPUs call CPU_OFF once, all before the remaining CPU's one SYSTEM_OFF, and no SYSTEM_RESET.
  • virt_off_names_the_cpus_left_on (new): under power-off-spares-the-last-two, each of cpu6 and cpu7 that is not the powering CPU is named after the last word, in roster order, and nothing else is; the trace has every other CPU call CPU_OFF before the one SYSTEM_OFF. It waits the whole of DEAF_CPU's span by construction, and its line says how many calls QEMU traced. The match on AFFINITY_INFO's answer alone could be lifted into a pure function and host-tested; that test would hold it against a model of PSCI its own author wrote, and would not hold the call's shape (function id, target_affinity, conduit) or the line reaching the UART after the console's last drain. Here QEMU's PSCI answers.
  • virt_reboot (new, tests/virtrebootcase): the job reboot. Rebooting. is the last line, QEMU stops for guest-reset, and the trace holds one SYSTEM_RESET and neither CPU_OFF nor SYSTEM_OFF. The runner's own deadline line is refused, since it reboots too.
  • virt_reboot_refused_without_psci (new): under psci-withheld the same job is refused by name, "reboot: this machine has no reset this kernel performs — refused", before anything is torn down, it ends with exit 1, and the boot never says Rebooting.. Its kernel makes no PSCI call after PSCI_VERSION: what it judges is the syscall's refusal on a kernel that kept no conduit, which is a booted kernel's answer to a process. No metal row can reach it: the actuator and the arm it stages are AArch64's.
  • virt_fatal_halts_the_others_first, changed: the line it waits for past the stop is panic: rebooting in 60 s, timed by, the arm line as a machine with a reset and no panic key says it, the 60 read from toyos_tco::PANIC_BOUND_MS. It took the held line too, so it stayed green on an AArch64 panic that holds or promises a key. The line is a booted AArch64 kernel's, on the one machine that architecture has.
  • virt_failed_ap_leaves_no_hole boots the same case, so its guest now powers off after the job it judges. Its verdicts are unchanged.
  • machine_shutdown (new, x86-64): run shutdown on tests/testcases. The boot logged ACPI: PM1a=0x604 SLP_TYPa=0, Shutting down. is said, QEMU stops for guest-shutdown and exits 0, and nothing on the console is a kernel death. The decode is held by host tests (below). The write cannot be a metal row: the metal loop reaches the T14 over ssh once it is back from a reset (src/metal.rs, return_secs), a T14 that powered itself off never answers again, and nothing in the loop turns it on.

machine_reboot's QEMU half stays cut, as #660 left it: its metal row holds it. The four tests that wait for a machine to end share one wait, power::ended: the last word, QEMU's SHUTDOWN reason, then QEMU's exit.

No row judges a duration. virt_smp and virt_el1_smp are red on a CPU named after the last word, which now takes one PSCI still answers on five seconds after its SGI: the span past which the TLB shootdown calls a CPU deaf and panics. virt_off_names_the_cpus_left_on is red on a line that is not one of the two the actuator arranges.

Harness. BootOptions::psci_trace (-trace arm_psci_call) is refused on anything but TCG's AArch64. QmpShutdown, QemuInstance::await_exit and QemuInstance::budget come back: #660 deleted them with the tests that read them, and these tests read them.

Metal. machine_soft_off_decoded (new) rides the boot machine_reboot already takes: the kernel log off the T14's stick carries ACPI: PM1a=, which none of init_off's refusals lets through.

Host.

  • toyos-acpi/tests/corpus.rs, crafted AML: ZeroOp, OneOp and a BytePrefix constant; \_S4_'s package ahead of \_S5_'s; a two-byte PkgLength; a wide value and a WordPrefix; no package, a method named _S5_, and a package cut by the table's end; a package past the declared length. The test that stated the crate reads nothing inside a DSDT loses that half and keeps its XSDT half under its own name.
  • toyos-acpi/tests/fixtures.rs, the_dsdt_names_the_sleep_type_that_powers_qemu_off: s5_slp_typ over QEMU 11.1.1's own DSDT answers SlpTyp(0). fixtures/qemu-11.1.1/dsdt.bin is 8500 bytes read off guest memory by the monitor's pmemsave; SOURCE says which boot, and src/licence.rs has its row.
  • toyos-checks' psci_power_off_judge reads the trace line as QEMU 11.1.1 formats it, with and without the log backend's pid@time: head, and refuses every short trace: a CPU missing, a CPU twice, a CPU_OFF after the SYSTEM_OFF, the power-off missing or doubled, a reset beside it, the powering CPU among those turned off, a CPU left on that called CPU_OFF.
  • toyos-gicv3's unpacking_puts_each_field_back_and_no_flag.

Gates

At 190edc726, which is de4e37b96 less three let _ = fs::remove_file(&trace); lines and one comment sentence in tests/toyos.rs, on the development host, an Apple-silicon Mac with QEMU 11.1.1 (the two Profile::Virt rows run under HVF, every other virt_ row under TCG by its profile, every x86-64 guest under TCG), git status --porcelain --ignore-submodules=none empty before and after:

command exit
cargo run -- --ci host 0 Host: 64 step(s), all green; clippy, warnings denied: clean
cargo run -- --build-only 0
cargo run -- --build-only --arch aarch64 0
cargo run -- --build-only --arch aarch64 --kernel-feature boot-actuators --kernel-feature test-actuators 0
cargo test --test toyos-build -- --list 0 19 virt_ names
cargo test --test toyos-build -- --metal --list 0 50 registration(s) and 218 shared member(s) over 28 boot(s)
cargo test --test toyos-build -- virt_ 0 19 passed, 19 total (17.1s); one-minute load average 9.8 after it, no INVL
cargo test --test toyos-build 0 25 passed, 25 total (22.1s); one-minute load average 14.8 after it, no INVL
cargo test --test toyos-build -- --metal --metal-readback <dir> machine_ 0 judged, below: 2 passed, 0 failed, 1 boot(s)

The four rows that no longer remove their trace before the boot that writes it each read it, in the virt_ run's words:

  [virt] stop: 5 of 5 userland thread(s) stopped across 8 cpu(s) in 0 ms of a 2010 ms budget over 1 sweep(s), 0 of 0 userland block operation(s) still open; every CPU but 0x0 called CPU_OFF, then 0x0 SYSTEM_OFF
  PASS  virt_el1_smp  (4s)
  [virt] stop: 5 of 5 userland thread(s) stopped across 8 cpu(s) in 0 ms of a 2010 ms budget over 1 sweep(s), 0 of 0 userland block operation(s) still open; every CPU but 0x0 called CPU_OFF, then 0x0 SYSTEM_OFF
  PASS  virt_smp  (5s)
  [virt] Rebooting., then one SYSTEM_RESET, and QEMU stopped for guest-reset
  PASS  virt_reboot  (4s)
  [virt] [6, 7] left on and named; the rest CPU_OFF, then 0x0 SYSTEM_OFF; 4995 PSCI call(s) traced
  PASS  virt_off_names_the_cpus_left_on  (9s)

No CI job ran at this head: the pull request is a draft, and toolchain, host and guest each skip a draft. guest / suite under KVM, a required check beside host, has not run this branch.

The T14

One boot, jobcase, at 17e636323, run by the orchestrator: #647 (comment). toyos-metal exited 0 (t14-647-jobcase EXIT=0 (73s) in the orchestrator's summary); machine LENOVO 20W0003AMZ, BIOS N34ET71W (1.71).

cargo test --test toyos-build -- --metal --metal-readback <dir> machine_ over that boot's readback touches no machine and exits 0, at 17e636323, at f4f6a453d, at de4e37b96 and at 190edc726, with the same words:

[metal] 2 registration(s) and 0 shared member(s) over 1 boot(s)

[metal] the boots
  jobcase: Boot: complete 1152 ms, back in 37 s, the stick enumerated 0 s after that
    the panel painted 10 time(s) and put 8483968 px on the glass

[metal] the tests
  [power] [2026-10-02 17:02:34 0.000 cpu0 boot] ACPI: reset register SystemIO 0xcf9 <- 0x06
  PASS machine_reboot
  [power] [2026-10-02 17:02:34 0.000 cpu0] ACPI: PM1a=0x1804 SLP_TYPa=7
  PASS machine_soft_off_decoded

[metal] 2 passed, 0 failed, 1 boot(s)
  • machine_reboot holds the reset register's decode in the kernel log and the loader pass after the reset reading DONE: the T14 reset through the moved register and came back.
  • machine_soft_off_decoded holds ACPI: PM1a=0x1804 SLP_TYPa=7. Seven is the top value S5::Wide lets through, and 0x1804 is inside the port space, so the T14's \_S5_ and PM1a block have now been through both refusals. The T14 was not powered off: nothing turns it back on.
  • The judging wrote nothing under tests/metal/: git status --porcelain was empty after each run, and none printed a now records line (3 number(s) on LENOVO 20W0003AMZ, BIOS N34ET71W (1.71 ); 0 past its record). Nothing of it belongs in a commit.

The x86-64 kernel that booted is still this head's. git diff --name-only 17e636323 190edc726 -- kernel/ bootloader/ toyos-abi/ toyos/ toyos-acpi/ src/ system.toml:

kernel/src/arch/aarch64/boot.rs
kernel/src/arch/aarch64/power.rs
kernel/src/arch/aarch64/psci.rs

kernel/src/arch/mod.rs compiles arch/aarch64 only under target_arch = "aarch64", so the x86-64 kernel is built from files unchanged since the boot. f4f6a453d changed those three files, tests/toyos.rs and one issue file, and 190edc726 tests/toyos.rs alone. No boot of this head was staged.

What did change in the jobcase image came in with the merge and is #636's: userland/toybox, whose reboot applet is the image's one job, lost its five probe applets to userland/kernelprobe. Neither judged row reads anything toybox says.

Negative controls

At de4e37b96, each applied as a checked patch, the named test run, the patch reversed in the same script, the tree clean after: #647 (comment). None was run again at 190edc726. The same patches gave the same exits at f4f6a453d, before the merge; they, that run's words and the trace's measured sizes are #647 (comment), and n0 is #647 (comment).

patch what it does test exit
d1 every CPU reaches CPU_OFF 200 ms late (the review's) virt_ 0: 19 of 19; virt_smp, virt_el1_smp and virt_off_names_the_cpus_left_on green
d1 on 17e636323 (run from f4f6a453d) the same, on the 100 ms budget virt_ 1: those three rows red, cpu1 to cpu7 named not off
m1 CPU_OFF never called on the SGI virt_smp 1: cpu1 to cpu7 named not off
m2 the SGI never raised virt_smp 1: the same seven lines
m3 an AFFINITY_INFO answer of ON taken for OFF virt_off_names_the_cpus_left_on 1: no CPU named
n0 the whole change reverted onto origin/main (kernel/, toyos-gicv3/, toyos-acpi/src; the two actuators stay declared; the same bytes against a31eec595 and b331934c9) virt_off_names_the_cpus_left_on 1: QEMU never exits
m4 off calls SYSTEM_RESET virt_smp 1: guest-reset
m5 reset calls SYSTEM_OFF virt_reboot 1: guest-shutdown
m7 can_reset answers true virt_reboot_refused_without_psci 1: the reboot proceeds and the job never ends
p1 AArch64's can_reset answers false, so its panic holds virt_fatal_halts_the_others_first 1: the armed line never comes
p2 AArch64's PANIC_KEYS is true, so its panic promises a key virt_fatal_halts_the_others_first 1: the same
c1 AFFINITY_INFO asked of an affinity no CPU has virt_smp 1: power: cpu1's AFFINITY_INFO answered -2; SYSTEM_OFF regardless, to cpu7's
t0 the timer stop deleted from cpu_off virt_ 0: 19 of 19, as at 94dea677c, which is the tracked issue's evidence
s2 a scout: a panic between percpu::init_bsp and irqchip::init its own row 0: panic: rebooting in 60 s, timed by the counter frequency the CPU states, then one SYSTEM_RESET and guest-reset 60.0 s later
s3 the same scout with psci::init back after enable_interrupts its own row 1: panic: holding this panel

At earlier heads, over files unchanged since (git diff --name-only 94dea677c 190edc726 names nothing under kernel/src/arch/x86_64 or toyos-acpi/src, and neither toyos-acpi/tests/corpus.rs nor tests/common/power.rs; git diff --name-only 17e636323 190edc726 names nothing under toyos-acpi/); their patches and words are #647 (comment) and #647 (comment):

patch what it does test exit
m6 (at 94dea677c) x86-64's off writes no PM1a machine_shutdown 1: QEMU never exits
h1 (at 94dea677c) PkgLength read as one byte toyos-acpi --test corpus 101: a_two_byte_package_length_is_stepped_over
h2 (at 94dea677c) the width refusal removed toyos-acpi --test corpus 101: an_s5_value_wider_than_slp_typ_is_refused_with_it
h3 (at 17e636323) the scan matches _S4_ toyos-acpi --test fixtures 101: SlpTyp(2), not SlpTyp(0)

What the longer wait costs, measured by a probe that prints the trace's size; the patch is in the second comment above:

head virt_off_left_on.psci calls the row
17e636323: 100 ms, a bare spin 6,199,064 bytes 50,812 4 s
de4e37b96: DEAF_CPU's span, asked each millisecond 609,512 bytes 4,996 9 s (8 to 10 s across the third round's runs)

virt_smp's trace is 3,172 bytes, 26 calls, on either head; virt_el1_smp's went from 248,880 bytes, 2,040 calls, to 3,050 bytes, 25 calls.

Independent oracles

  • Arm DEN0022 is cited at each site for the function ids (§5.1), AFFINITY_INFO's answers (§5.7.1), the power-off recipe (§5.10.1, §5.10.3), SYSTEM_RESET asking nothing of other cores (§5.12.2) and CPU_OFF's caller duties (§5.5.2).
  • QEMU 11.1.1's PSCI is a second implementation of that specification, and the verdicts above are its: its trace of each call with the calling CPU's affinity, its SHUTDOWN reason, and its exit. It acted on 0x8400_0008 by powering off, on 0x8400_0009 by resetting and on 0x8400_0002 by turning the caller off, and it answered 0xC400_0004 OFF for each CPU that had called CPU_OFF, ON for the two that had not, and -2 for an affinity no CPU has.
  • QEMU's own DSDT, read by the host out of guest memory, for s5_slp_typ.
  • Real hardware, for the x86-64 half: the T14 boot above.

The merge of origin/main

de4e37b96 merges b331934c9 (#636), which moved the five virt_ probes out of the shipped toybox into userland/kernelprobe. One conflict, tests/virtsmpcase/system.toml: unmap_touch points at kernelprobe as main has it, shutdown stays a toybox applet, and the image names both programs. The merge commit's message accounts for every hunk of both sides.

f4c04c52c merged a31eec595. #660 had cut the guest suite and deleted what only the cut tests used, where this branch had modified it; #675 had replaced serial::panic_raw with panic_registers(). The merge commit's message accounts for every hunk of both sides in the seven conflicted files, and for arch/aarch64/power.rs, which did not conflict and did not build.

Tracked, not fixed

  • issues/kernel/nothing-fails-without-the-timer-stop-before-cpu-off.md: no arm can fail on the timer stop before CPU_OFF, and none is in reach. t0 is its evidence.
  • issues/panic-path/nothing-judges-that-an-aarch64-panic-resets-at-its-bound.md: the reset at the end of an AArch64 panic's bound is measured by scouts only (s0 in the earlier comment, s2 in the table above), and no standing test waits the bound. virt_fatal_halts_the_others_first holds the arm line.

Net lines

git diff --shortstat origin/main...190edc726: 42 files, +1188 −354.

  • Production: kernel/ +478 −301, the two pure crates' src/ +56. The growth is AArch64's reset, power-off and CPU_OFF, which it did not have, and the seam's own 59 lines; x86-64's half moved.
  • Tests, fixtures and harness: +558 −47. src/: +7. issues/: +89 −6.

What I'm unsure of

  • DEAF_CPU is a Tripwire, and off takes its span without its panic. time::AP_START does the same and says so by being a Budget; off says it in a comment and declares nothing. Using AP_START itself would have typed it and misnamed it.
  • Nothing but reading holds the pacing. No row can fail on ASK_AGAIN's wait being deleted, and that deletion was not run: the bare spin wrote 6 MB at 100 ms, so at this span it would write about fifty times that, which is an extrapolation. virt_off_names_the_cpus_left_on prints its call count and judges none: the bound on it is the span over the period, two kernel constants the harness cannot read.
  • One millisecond is a choice. It bounds what the power-off waits past the last CPU going off, and is xhci::PORT_POLL's period.
  • virt_off_names_the_cpus_left_on costs five seconds of every suite run, the span it exists to wait out.
  • No image of this head has booted the T14. The file list above is why the jobcase readback stands for its kernel; its toybox is Nothing ships for tests alone: the five virt probes leave the shipped toybox, and two owner rulings enter reviewer.md #636's.
  • A panic in psci::init itself, or before it, still holds its panel: psci::init reads the FADT and calls firmware, and nothing ahead of it has a reset to offer.
  • dsdt.bin is not from the boot the other eight fixtures came off. Its boot's firmware put the tables 0x400000 lower. Four of that boot's tables are the committed files byte for byte and four differ in address bytes and checksums alone; the old boot's DSDT was never kept, so that the two DSDTs are equal is unmeasured beyond their length, 8500.
  • machine_shutdown drives the stdin path's run shutdown on tests/testcases, which nothing else in the guest suite does.
  • Every x86-64 guest here ran under TCG. CI's KVM lane has not run this head.
  • A CPU is turned off wherever the SGI finds it, a kernel Lock held with interrupts open included. After the SGI the caller takes one lock, the UART's registers, which no CPU holds with interrupts open.
  • A refusal on the reset path now waits for the UART's registers, bounded as every fatal path's wait is. Before Fatal paths write the console UART only under its registers, whose lock knows which CPU's fatal path holds it #675 that line took nothing.
  • x86-64's PANIC_KEYS is true whether or not an i8042 answers. The poll reads port 0x60 either way.

🤖 Generated with Claude Code

https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm

Japabu and others added 2 commits September 30, 2026 23:38
Stage 5 of issues/kernel/toyos-runs-on-arm64.md owed SYSTEM_RESET,
SYSTEM_OFF and CPU_OFF behind a reset and power-off seam that takes
x86-64's reset register and PM1a out of drivers/acpi.rs, and the stop
shown on eight CPUs ending in that power-off. Stage 4's remaining items
wait on stage 6 (the HVF run), on metal, or on an ABI brief, so this is
the first unblocked step whose exit is not met.

The seam: kernel/src/power.rs keeps what every reset owes whatever the
machine (the console's last drain, the xHCI stop before the register,
the reboot refused without a reset); arch::power is the register write.
x86-64's is the FADT's reset register and S5 through PM1a, moved as they
were, both decoded in one place before the IDT loads; the PM1a decode
used to run after gpt::init. Its I/O-port-space checks were dead on x86
and every arch::pio user was that code, so both pio modules go.

AArch64's is PSCI (Arm DEN0022): SYSTEM_RESET, which asks no
coordination of the other cores (5.12.2), so a wedge may call it; and
SYSTEM_OFF, whose caller places every core in a known state first
(5.10.1), by the specification's own recipe (5.10.3): an SGI asks every
other CPU in the roster to stop its timer and call CPU_OFF, and the
caller waits, bounded, for AFFINITY_INFO to answer each off before it
calls SYSTEM_OFF. A CPU still on past the budget is named on the UART
raw, since the console's last drain is behind the call. The conduit
psci::init found is kept in one word, which smp::start and the reset
both read. toyos-gicv3 gains the inverse of packed_affinity, which is
how PSCI names a CPU.

Tests: virt_smp and virt_el1_smp's case now ends with the job
`shutdown`, and both judge the power-off: the stop's record names eight
CPUs, `Shutting down.` is the last word, QEMU stops for guest-shutdown,
and QEMU's own `arm_psci_call` trace (BootOptions::psci_trace) has every
CPU but one call CPU_OFF once before the remaining one calls SYSTEM_OFF
once, through SMC at EL2 and through HVC at EL1. virt_reboot
(tests/virtrebootcase) judges one SYSTEM_RESET and guest-reset.
machine_shutdown is x86-64's power-off, which no test judged by QEMU's
stop reason before this moved it. toyos-checks' psci_power_off_judge
refuses each way a trace falls short.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L
@Japabu

Japabu commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator runs at d1c216d (cargo test --test toyos-build -- <args>; logs orch/logs/647-armnext-*.log):

job args patch exit
virt 0
machine_shutdown 0
n0-whole-virt 1
m1-no-cpu-off 1
m2-no-off-sgi 1
m3-on-is-off 0
m4-off-is-reset 1
m5-reset-is-off 1
m6-x86-no-pm1a 1
whole 0

m3-on-is-off stayed green (exit 0): no arm tells an AFFINITY_INFO that answers ON as OFF.

@Japabu
Japabu marked this pull request as ready for review September 30, 2026 22:34
@Japabu

Japabu commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

Round 1 at d1c216d9c. CI host passed at this head (run 36786318898). The orchestrator's runs at this head (issuecomment-5920889728, logs orch/logs/647-armnext-*.log):

  • virt_: 17 of 17, exit 0.
  • machine_shutdown: exit 0.
  • Whole suite: 507 of 507, exit 0.
  • n0: red on virt_smp, virt_el1_smp and virt_reboot.
  • m1, m2, m4, m5, m6: red. m3: green.

Net lines: +372 (686 added, 314 deleted).

  • Production (kernel/, toyos-gicv3/src): +130 (417/-287).
  • Tests: +246 (268/-22).
  • issues/: -4.

BLOCKER

  • kernel/src/arch/aarch64/power.rs:40 — m3 (Ok(_) in place of Ok(psci::Affinity::Off)) exits 0. In every arm, the other CPUs' CPU_OFF reaches QEMU before the caller's SYSTEM_OFF whether or not the caller waits; m3's own green trace has all seven ahead of it. So no arm has a CPU that PSCI still answers ON when the machine powers off. Add refusal arm P1 below: two CPUs never call CPU_OFF, and each one that is not the powering CPU must be named after the budget. Required runs: P1 green at its head, m3 applied onto P1 red (no line names a CPU), n0 onto P1 red. P1 spares the roster's last two CPUs. That way the powering CPU is never the only spared one, and a wait that stops before the end of the roster also goes red.
  • kernel/src/arch/aarch64/power.rs:61 — no arm can fail on the timer stop, and none is within reach.
    • DEN0022 is the source of the requirement, not an observation of the kernel meeting it.
    • No AArch64 metal target exists.
    • A second PSCI provider (TF-A under QEMU) arrives only with a C cross-toolchain, which is an undeclared host tool.
    • Deleting the stop does nothing on metal in this scenario either. SGI_OFF lands on idle CPUs, and an idle CPU with no parked deadline already has its one-shot stopped (toyos-sched/src/timer.rs:43).
    • The stop serves a claim that no interrupt reaches a core CPU_OFF turned off. That claim also covers kicks from CPUs that are not off yet, and a timer stop does not cover those.
    • "What I'm unsure of" is not the tracker. Record the gap in issues/:
      • an owner;
      • as evidence, the deletion mutation's run at the head, with command, exit code and log (the PR asserts it stays green without a run);
      • an exit condition: an AArch64 metal target where SGI_OFF lands on a CPU whose timer is armed.

NOTE

  • kernel/src/arch/x86_64/boot.rs:90 — T14: the decoded values cannot change, so no T14 run is needed.
    • init_off reads only the direct map. mm::init sets the map's extent once (kernel/src/mm/mod.rs:162), before both the old and the new call site, and nothing between the two sites writes a firmware table.
    • What moves is the failure mode. The DSDT checksum and the \_S5_ scan now run before percpu::init_bsp loads the IDT (:92), where a fault ends unreported in firmware's handler.
    • power::init's doc (power.rs:25-32) gives the pre-IDT reason for the reset register only.
    • Fix: call init_off after percpu::init_bsp.
  • kernel/src/arch/aarch64/power.rs:60 — irqchip::end(irqchip::SGI_OFF) does nothing useful when CPU_OFF succeeds. When CPU_OFF is refused, the end has already dropped the running priority, so a pending kick wakes the masked wfi in cpu::halt at once, for good. That is the failure irqchip.rs:309-311 names, and SGI_HALT avoids it by never being ended (trap.rs:166-168). Delete the end.
  • kernel/src/arch/aarch64/power.rs:24,51,63 — every PSCI answer on the way out is dropped. A refused CPU_OFF names its CPU without the reason. A SYSTEM_OFF or SYSTEM_RESET that returns leaves the machine on and silent, with the console already drained. Add one serial::panic_raw of the code at each site.
  • kernel/src/arch/aarch64/power.rs:16 — fn can_reset() -> bool { true } passes every test: no profile boots AArch64 without PSCI, and x86-64 has no arm for a missing reset register either. An actuator that keeps psci::init from storing the conduit, run with the job reboot, would judge the refusal line.
  • kernel/src/panic_reboot.rs:116-137 — on AArch64 every panic now resets after the bound, and nothing judges it.
    • The arm line promises "unless a key is pressed", but the AArch64 panic poll reads no key at all (arch/aarch64/keyboard_controller.rs:1-7).
    • The held line and Bound::Held (:40) still say "no reset register" on a machine without PSCI.
  • kernel/src/arch/x86_64/power.rs:136 — find_s5_slp_typ is now a pure decode, generic over toyos_acpi::Phys, living in the kernel. It belongs in toyos-acpi beside dsdt_address, host-tested on the fixture DSDT that toyos-acpi/tests/fixtures.rs:102-108 opens but never decodes.
  • kernel/src/arch/x86_64/power.rs:73,127 — two firmware values are narrowed instead of refused. pm1a as u16 turns a 32-bit PM1a_CNT_BLK into a different port. A \_S5_ byte above 7, shifted left by 10, sets SLP_EN and reserved bits. Refuse both by name. This predates the branch, but the branch rewrote both lines.
  • tests/common/power.rs:75-108 — machine_shutdown is machine_reboot (:44-72) with four values changed. Make them one function that takes the command, the decode line, the last word and the stop reason.
  • Architecture split: holds, nothing to change.
    • asm! and core::arch appear only under arch/aarch64 and arch/x86_64.
    • kernel/src/power.rs reaches both through crate::arch::power.
    • No target_arch, arch::x86_64 or arch::aarch64 was added to generic code.
    • unpacked_affinity in pure toyos-gicv3 is arithmetic.
    • Deleting both pio modules removes the unreachable! port shim.

REMOVE

  • kernel/src/arch/aarch64/boot.rs:319-320 — "Then PSCI, which starts the other CPUs and resets and powers off the machine." — a list of callers; it rots.
  • tests/common/power.rs:900-904 — the story of acpi::init_power, which this branch deletes — chronology in source that now names nothing.
  • issues/kernel/toyos-runs-on-arm64.md:98 — "drivers/acpi.rs:325,345 (reset and PM1a soft-off)" — neither is in that file any more.
  • PR body, "Why this stage and this step", second bullet — stage 4's blocked items are not this change's record.
  • PR body, "(the value in the orchestrator's earlier q35 logs, 31 occurrences)" — a provenance count.
  • PR body, the "Gates" block at 1e41371ca — a superseded head.
  • PR body, the /Users/jan/.claude/jobs/2280e09e/... paths (jobs.txt, mutations/) — local scratch that main's record will not have.
  • PR body, "What I'm unsure of":
    • the trace-line-format bullet and the "first boot on AArch64 that runs quiesce" bullet — false since virt_smp and virt_el1_smp ran green at d1c216d9c;
    • the timer bullet, once the issue records it.
  • PR body, M3's "a race, so a green here is a finding about the judge" — m3 was measured green, and P1's result replaces the line.

P1 applies at d1c216d9c. The implementer runs it: P1 green, m3 on P1 red, n0 on P1 red.

--- a/kernel/src/actuator.rs
+++ b/kernel/src/actuator.rs
@@ -170 +170,6 @@
     power_refused_once = "power-refused-once";
+
+    /// The roster's last two CPUs take the power-off's SGI and halt without
+    /// `CPU_OFF`, so PSCI answers them on for the whole budget. Judged by
+    /// `virt_off_names_the_cpus_left_on`.
+    power_off_spares_the_last_two = "power-off-spares-the-last-two";
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -61,2 +61,5 @@ pub(super) fn cpu_off() -> ! {
     irqchip::stop_timer();
+    if crate::actuator::power_off_spares_the_last_two() && percpu::cpu_id() + 2 >= crate::smp::cpu_count() {
+        cpu::halt()
+    }
     if let Some(psci) = psci::conduit() {
--- a/tests/toyos.rs
+++ b/tests/toyos.rs
@@ const SCREEN_TESTS @@
     ("virt_reboot", Sched::Parallel, qemu::Profile::VirtEl2),
+    ("virt_off_names_the_cpus_left_on", Sched::Parallel, qemu::Profile::VirtEl2),
@@ fn ended_through_psci( @@
     reason: &str,
     trace: &Path,
+    said_after: impl FnOnce(&[(u64, u64)]) -> Vec<String>,
 ) -> Result<(String, Vec<(u64, u64)>), String> {
@@
     let after: Vec<&str> = lines[at + 1..].iter().copied().filter(|l| !l.trim().is_empty()).collect();
-    if !after.is_empty() {
-        return Err(format!("{} line(s) after the boot's last word:\n  {}", after.len(), after.join("\n  ")));
-    }
     let traced = fs::read_to_string(trace).map_err(|e| format!("read the PSCI trace: {e}"))?;
-    Ok((console, psci_calls(&traced)?))
+    let calls = psci_calls(&traced)?;
+    let want = said_after(&calls);
+    if !after.iter().map(|l| l.trim_end()).eq(want.iter().map(String::as_str)) {
+        return Err(format!("{} line(s) after the boot's last word, not {want:?}:\n  {}", after.len(), after.join("\n  ")));
+    }
+    Ok((console, calls))
 }
@@ virt_smp and virt_reboot pass `|_| Vec::new()` as `said_after` @@
@@ fn psci_powered_off @@
-fn psci_powered_off(calls: &[(u64, u64)], cpus: u32) -> Result<u64, String> {
+fn psci_powered_off(calls: &[(u64, u64)], cpus: u32, left_on: &[u64]) -> Result<u64, String> {
@@
-    let others: Vec<u64> = (0..u64::from(cpus)).filter(|&cpu| cpu != last).collect();
+    let others: Vec<u64> = (0..u64::from(cpus)).filter(|&cpu| cpu != last && !left_on.contains(&cpu)).collect();
@@ virt_smp and tests/checks.rs pass `&[]` as `left_on` @@
+/// `power-off-spares-the-last-two`: cpu6 and cpu7 halt on the power-off's SGI
+/// without `CPU_OFF`, so `AFFINITY_INFO` answers each on to the end of the
+/// budget. Each of them but the one powering off is named after the last
+/// word, in roster order, and nothing else is; every other CPU is off before
+/// the one `SYSTEM_OFF`.
+fn virt_off_names_the_cpus_left_on(profile: qemu::Profile) -> Result<(), String> {
+    let trace = common::lane::dir().join("virt_off_left_on.psci");
+    let _ = fs::remove_file(&trace);
+    let mut qemu = boot_virt_smp(BootOptions {
+        profile,
+        smp: VIRT_CPUS,
+        qmp: true,
+        kernel_features: ACTUATOR_KERNEL,
+        kernel_params: &["power-off-spares-the-last-two"],
+        psci_trace: Some(trace.clone()),
+        ..Default::default()
+    });
+    let mut stop = qemu::QmpShutdown::open(qemu.qmp_socket(), qemu.budget(qemu::GUEST_QUIET));
+    let serial = judge_virt_job(&mut qemu, "unmap_touch", UNMAP_TOUCH_SAID)?;
+    let spared = |calls: &[(u64, u64)]| -> Vec<u64> {
+        let last = calls.iter().find(|&&(function, _)| function == PSCI_SYSTEM_OFF).map(|&(_, cpu)| cpu);
+        (u64::from(VIRT_CPUS) - 2..u64::from(VIRT_CPUS)).filter(|&cpu| Some(cpu) != last).collect()
+    };
+    let named = |calls: &[(u64, u64)]| -> Vec<String> {
+        spared(calls)
+            .iter()
+            .map(|cpu| format!("power: cpu{cpu} is not off by PSCI's answer inside the budget; SYSTEM_OFF regardless"))
+            .collect()
+    };
+    let (_, calls) = ended_through_psci(&mut qemu, &mut stop, serial, SHUTTING_DOWN, "guest-shutdown", &trace, named)?;
+    let left_on = spared(&calls);
+    let last = psci_powered_off(&calls, VIRT_CPUS, &left_on)?;
+    eprintln!("  [virt] {left_on:?} left on and named; the rest CPU_OFF, then {last:#x} SYSTEM_OFF");
+    Ok(())
+}
@@ fn run_screen_test @@
         "virt_reboot" => virt_reboot(profile),
+        "virt_off_names_the_cpus_left_on" => virt_off_names_the_cpus_left_on(profile),

SEND BACK

Japabu and others added 2 commits October 1, 2026 01:04
…d every refusal says its code

The review of 647 at d1c216d upheld two BLOCKERs. This commit answers
them and fixes the NOTEs.

B1: m3, which takes any AFFINITY_INFO answer for OFF, stayed green. In
every arm the other CPUs' CPU_OFF reached QEMU before the caller's
SYSTEM_OFF, so no arm had a CPU that PSCI still answered ON at the
power-off. virt_off_names_the_cpus_left_on is the review's P1 arm. Its
actuator, power-off-spares-the-last-two, makes cpu6 and cpu7 halt on
SGI_OFF without calling CPU_OFF. The console must then name each of them
that is not the powering CPU, after the last word and in roster order,
and nothing else. QEMU's trace must show every other CPU calling CPU_OFF
before the one SYSTEM_OFF.

The review's patch set kernel_features: ACTUATOR_KERNEL beside
kernel_params. The harness's kernel_of refuses that pair, since a
parameter already selects the test kernel, so the arm sets
kernel_params only. The patch also left the new actuator dead on x86-64,
whose kernel builds -Dwarnings. The x86 boot-actuators check failed on
it with "function `power_off_spares_the_last_two` is never used". Both
PSCI actuators now carry #[allow(dead_code)], with the reason at the
site. actuator.rs is generic code, so a target_arch cfg may not go
there.

m3 is reshaped to fit the new match: an ON answer is taken for OFF.

B2: the timer stop before CPU_OFF has no arm that can fail, and none is
in reach. The gap is issues/kernel/nothing-fails-without-the-timer-stop-before-cpu-off.md.
It records the owner and this exit: an AArch64 metal target where
SGI_OFF lands on a CPU whose timer is armed. The owner's ruling that no
ARM hardware is a target stands against that exit, and the issue says so.
Its evidence is the deletion mutation's guest run, which is the
orchestrator's.

NOTEs:
- x86-64: init_reset stays before percpu::init_bsp. init_off moves after
  it, so a fault in the DSDT checksum or the \_S5_ scan is reported.
- AArch64: cpu_off no longer ends SGI_OFF. SGI_HALT is never ended
  either, so a refused CPU_OFF leaves the CPU halted at the SGI's
  running priority, with no pending kick to wake it.
- Every PSCI refusal on the way out prints its code raw: SYSTEM_RESET or
  SYSTEM_OFF returning, a refused CPU_OFF, and AFFINITY_INFO refusing,
  which is now named at once instead of waited out. psci::Error::code
  undoes Error::of.
- can_reset's no-PSCI arm is virt_reboot_refused_without_psci. With the
  actuator psci-withheld, psci::init keeps no conduit. The job reboot
  is then refused by name before anything is torn down and ends with
  exit 1, and the boot never says Rebooting.
- The panic arm line promises "unless a key is pressed" only where the
  architecture's keyboard_controller::PANIC_KEYS says the panic path
  reads one. The same holds for reboot_now's "no key inside the bound".
  The held line and Bound::Held say "no reset", which is true on a
  machine without PSCI. panic_before_peripherals_reboots now refuses
  the new held text by a named constant.
- find_s5_slp_typ moves into toyos-acpi as s5_slp_typ, answering S5. It
  refuses an \_S5_ value SLP_TYP's three bits cannot hold (S5::Wide).
  Its host tests are crafted AML in corpus.rs:
  - ZeroOp, OneOp and a BytePrefix constant;
  - \_S4_'s package ahead of \_S5_'s;
  - a two-byte PkgLength;
  - a wide value and a WordPrefix;
  - no package, a method named \_S5_, and a package cut by the table's
    end;
  - a package past the declared length.
  No fixture DSDT exists to test against: fixtures/qemu-11.1.1/SOURCE
  says that boot's DSDT "is not here". Two host mutations are red:
  PkgLength read as one byte, and the width refusal removed.
- x86-64 refuses a PM1a_CNT_BLK past the 16-bit port space by name, and
  a zero one as no PM1a control block, where the old code narrowed the
  address to u16.
- machine_reboot and machine_shutdown are one function, machine_stops.
  It takes the command, the decode line, the last word and the stop
  reason.

REMOVEs: the list of PSCI's callers in aarch64 boot.rs, the init_power
story in tests/common/power.rs and the clause citing it, and the track's
drivers/acpi.rs:325,345 port-I/O citation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L
Japabu and others added 2 commits October 2, 2026 18:32
Twenty-six landings since 08381fb. Seven files conflicted. Three of
them are modify/delete in substance: #660 cut the guest suite to the
tests only a booted machine answers and deleted what only the cut tests
used, and this branch had modified what it deleted.

kernel/src/actuator.rs: main's list, which #660 took 68 actuators out
of, `power-refused-once` among them, plus this branch's two,
`power-off-spares-the-last-two` and `psci-withheld`.

kernel/src/arch/aarch64/irqchip.rs: `Intid::Off` follows `Halt`, and
`LogNest`, which #660 deleted, is gone. `Storm` follows it unnumbered, and
nothing reads its number.

kernel/src/panic_reboot.rs: this branch's lines, through #675's
`serial::panic_registers()` in place of `serial::panic_raw`. The arm
line's three writes share one hold of the registers.

kernel/src/syscall/machine.rs: main's deletion of `refused_once`, and
this branch's `power::can_reboot()` and its refusal line.

kernel/src/arch/aarch64/power.rs did not conflict and did not build:
`serial::panic_raw` and `panic_raw_dec` are gone since #675. Each line
is now written under one `panic_registers()` hold, as every fatal path
on main writes, and the hold is dropped before a halt, because a CPU
halted holding the registers costs every later line the whole bound.
`reset` takes the registers only to say a refusal; the call itself still
takes no lock.

tests/common/power.rs: main's file. Every hunk this branch had there
landed in code #660 deleted:
- `machine_reboot` generalised into `machine_stops`: main cut the QEMU
  half of `machine_reboot` for its metal row, so the generalisation has
  one caller left, `machine_shutdown`, and is written as that.
- the `init_power` story removed from `died_and_reset`, the
  `NO_RESET_HELD` refusal in `panic_before_peripherals_reboots`, and
  `acpi::reboot` renamed in `usb_reset_hands_devices_back`'s prose: all
  three tests are cut on main, and the track names them.
What is added back is `machine_shutdown`, and `ended`, the wait both it
and the virt tests make: the last word, QEMU's `SHUTDOWN` reason, then
QEMU's exit. `SHUTTING_DOWN` is declared there once.

tests/common/qemu.rs: main's file, plus `BootOptions::psci_trace`. Its
refusal of a second `-D` log goes: main deleted `nvme_trace`, the only
other one. Three things #660 deleted because only cut tests read them
come back, because this branch's tests read them: `QmpShutdown` with
`shutdown_reason`, `QemuInstance::await_exit` and
`QemuInstance::budget`.

tests/toyos.rs: main's file, plus this branch's hunks:
- the three `virt_` registrations, without `Sched`, which is gone;
- `machine_shutdown` in `MACHINE_TESTS`, with why only QEMU can be
  asked; `machine_reboot`'s QEMU registration and its `machine_stops`
  arm stay cut;
- `judge_virt_job` by reference, `boot_virt_smp` by `BootOptions`,
  `virt_smp`'s power-off, `ended_through_psci` over `power::ended`,
  `psci_calls`, `psci_powered_off`, and the three new virt tests;
- the `acpi::shutdown()` comment this branch renamed sat in a test main
  cut.

Everything else merged without conflict.

Built at this tree: `cargo run -- --build-only` EXIT=0, the same with
`--arch aarch64` EXIT=0 and as the test kernel EXIT=0, and `cargo test
--test toyos-build --no-run` EXIT=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
…e crate reads no DSDT

`machine_soft_off_decoded` is a metal row on the boot `machine_reboot`
already rides: the kernel log off the T14's stick carries the PM1a
control block's port and the `SLP_TYPa` its `\_S5_` names, or none of
`init_off`'s refusals would have let it. This branch moved that decode
after the IDT and into `toyos_acpi::s5_slp_typ`, and added two refusals
of firmware values, a PM1a block past the port space and an `\_S5_`
value wider than three bits. The T14's values have been through
neither. QEMU's `machine_shutdown` judges q35's, and the write: a T14
that powered itself off never answers the metal loop again, so the
power-off itself has no row.

`toyos-acpi/tests/corpus.rs` still said that nothing in the crate reads
what is inside a DSDT and that `find_s5_slp_typ` stays in the kernel,
under a test named for two things the crate does not do. `s5_slp_typ`
has been the crate's since bb355a4 and five corpus cases cover it. The
statement and its half of the test go; both assertions it held are made
elsewhere in the file (`hpet_base` answering `Absent` at two sites, a
DSDT opening in `s5_of`). The test keeps the half that is true, under
its name.

The timer-stop issue is `kind: tooling`: it is an instrument that does
not exist, and nothing in the kernel is broken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
@Japabu

Japabu commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Guest runs and negative controls at 94dea677c (the merge of origin/main a31eec595 plus one commit), on the development host, an Apple-silicon Mac, QEMU 11.1.1: the two Profile::Virt rows run under HVF, every other virt_ row under TCG by its profile, and every x86-64 guest under TCG. One script ran them in this order with git status --porcelain --ignore-submodules=none empty before and after each: the patch checked with git apply --check, applied, the command run, the patch reversed.

arm command exit what it said
green cargo test --test toyos-build -- virt_ 0 19 passed, 19 total
green cargo test --test toyos-build -- machine_shutdown 0 1 passed, 1 total
green cargo test --test toyos-build 0 25 passed, 25 total (45.4s)
m3-on-is-off … -- virt_off_names 1 0 line(s) after the boot's last word, not ["power: cpu6 is not off …", "power: cpu7 is not off …"]
n0-whole … -- virt_off_names 1 QEMU had not exited 15 s after it was asked to
t0-no-timer-stop … -- virt_ 0 19 passed, 19 total
m1-no-cpu-off … -- virt_smp 1 7 line(s) after the boot's last word, not []: cpu1 to cpu7 each named not off
m2-no-off-sgi … -- virt_smp 1 the same seven lines
m4-off-is-reset … -- virt_smp 1 QEMU stopped this guest for Some("guest-reset"), not "guest-shutdown"
m5-reset-is-off … -- virt_reboot 1 virt_reboot: QEMU stopped this guest for Some("guest-shutdown"), not "guest-reset"; virt_reboot_refused_without_psci passed
m7-can-reset-true … -- virt_reboot_refused 1 STALLED: waiting for the job reboot to end — it went quiet
m6-x86-no-pm1a … -- machine_shutdown 1 QEMU had not exited 29 s after it was asked to
s0-scout-panic-resets … -- virt_scout 0 see below
green (cd toyos-acpi && cargo test --test corpus) 0 32 passed
h1-pkglength-one-byte the same 101 a_two_byte_package_length_is_stepped_over ... FAILED
h2-no-width-refusal the same 101 an_s5_value_wider_than_slp_typ_is_refused_with_it ... FAILED

The host's one-minute load average ran from 12 to 43 across the script; no run was marked INVL.

n0-whole is the whole change reverted onto its base: git diff --binary HEAD a31eec595 -- kernel/ toyos-gicv3/ toyos-acpi/src ':(exclude)kernel/src/actuator.rs' at 94dea677c, 53,739 bytes, sha256 e00729644328ccd9d3aadd3f79606b20f5de0de08ebd79d4abc0bd7560a34dc6. It is the next comment.

s0-scout-panic-resets is a one-boot measurement and not a test of this branch: test-late-panic on virt at EL2, waited to QEMU's stop.

  [scout] the boot log's last lines, newest first:
    [kernel 1.557 cpu0] panic: rebooting in 60 s, timed by the calibrated clock
  [scout] QEMU's stop reason Some("guest-reset"), 60.0s after the ready marker was read
  [scout] said after the ready marker: Ok("\npanic: the bound is over: returning this machine to firmware\n")
  [scout] CPU_OFF, SYSTEM_OFF and SYSTEM_RESET calls traced: [(84000009, 0)]
  PASS  virt_scout_panic_resets  (63s)

The patches, each as git diff wrote it at 94dea677c:

m3-on-is-off.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 9bfeb6cf3..3d105e01d 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -48,7 +48,7 @@ pub fn off() -> ! {
         let mpidr = toyos_gicv3::unpacked_affinity(crate::smp::hardware_id(cpu));
         loop {
             match psci.affinity_info(mpidr) {
-                Ok(psci::Affinity::Off) => break,
+                Ok(psci::Affinity::Off | psci::Affinity::On) => break,
                 Ok(_) if !deadline.reached(crate::clock::now()) => core::hint::spin_loop(),
                 Ok(_) => {
                     let mut uart = serial::panic_registers();
t0-no-timer-stop.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 9bfeb6cf3..bcdf440b2 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -84,7 +84,6 @@ pub fn off() -> ! {
 /// a refusal leaves this CPU halted with nothing signalled to it, and [`off`]
 /// names it still on.
 pub(super) fn cpu_off() -> ! {
-    irqchip::stop_timer();
     if crate::actuator::power_off_spares_the_last_two() && percpu::cpu_id() + 2 >= crate::smp::cpu_count() {
         cpu::halt()
     }
m1-no-cpu-off.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 9bfeb6cf3..9323c0bc0 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -91,14 +91,7 @@ pub(super) fn cpu_off() -> ! {
     let Some(psci) = psci::conduit() else {
         unreachable!("power: SGI_OFF is raised only by `off`, which holds a conduit")
     };
-    let refusal = psci.cpu_off();
-    let mut uart = serial::panic_registers();
-    uart.write(b"power: cpu");
-    uart.dec(u64::from(percpu::cpu_id()));
-    uart.write(b"'s CPU_OFF answered ");
-    say_code(&mut uart, refusal);
-    uart.write(b"\n");
-    drop(uart);
+    let _ = psci;
     cpu::halt()
 }
 
m2-no-off-sgi.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 9bfeb6cf3..2f643a56f 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -41,7 +41,6 @@ pub fn reset() -> ! {
 pub fn off() -> ! {
     cpu::disable_interrupts();
     let Some(psci) = psci::conduit() else { cpu::halt() };
-    irqchip::off_all_but_self();
     let deadline = Deadline::at(crate::clock::now() + OTHERS_OFF.duration());
     let me = percpu::cpu_id();
     for cpu in (0..crate::smp::cpu_count()).filter(|&cpu| cpu != me) {
m4-off-is-reset.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 9bfeb6cf3..b1e40d799 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -69,7 +69,7 @@ pub fn off() -> ! {
             }
         }
     }
-    let refusal = psci.system_off();
+    let refusal = psci.system_reset();
     let mut uart = serial::panic_registers();
     uart.write(b"power: SYSTEM_OFF answered ");
     say_code(&mut uart, refusal);
m5-reset-is-off.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 9bfeb6cf3..31674ff26 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -26,7 +26,7 @@ pub fn can_reset() -> bool {
 /// wedge calls this: the call takes no lock, and only its refusal is said.
 pub fn reset() -> ! {
     if let Some(psci) = psci::conduit() {
-        let refusal = psci.system_reset();
+        let refusal = psci.system_off();
         let mut uart = serial::panic_registers();
         uart.write(b"power: SYSTEM_RESET answered ");
         say_code(&mut uart, refusal);
m7-can-reset-true.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 9bfeb6cf3..038b204d4 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -19,7 +19,7 @@ const OTHERS_OFF: Budget = Budget::of(
 );
 
 pub fn can_reset() -> bool {
-    psci::conduit().is_some()
+    true
 }
 
 /// `SYSTEM_RESET`, which asks nothing of the other CPUs (DEN0022 §5.12.2). A
m6-x86-no-pm1a.patch
diff --git a/kernel/src/arch/x86_64/power.rs b/kernel/src/arch/x86_64/power.rs
index 2df8b54dd..acd72e938 100644
--- a/kernel/src/arch/x86_64/power.rs
+++ b/kernel/src/arch/x86_64/power.rs
@@ -131,7 +131,7 @@ pub fn off() -> ! {
     if pm1a != 0 {
         let val = (u16::from(SLP_TYPA.load(Ordering::Relaxed)) << 10) | SLP_EN;
         // SAFETY: pm1a and slp_typ come only from the validated FADT parse via PM1A_CNT_PORT/SLP_TYPA, and the zero check above confirms that parse happened.
-        unsafe { cpu::outw(pm1a, val) };
+        let _ = val;
     }
     cpu::halt()
 }
h1-pkglength-one-byte.patch
diff --git a/toyos-acpi/src/dsdt.rs b/toyos-acpi/src/dsdt.rs
index 5ac350631..e0376f754 100644
--- a/toyos-acpi/src/dsdt.rs
+++ b/toyos-acpi/src/dsdt.rs
@@ -33,7 +33,7 @@ pub fn s5_slp_typ<P: Phys>(dsdt: &Table<P>) -> S5 {
         // `PkgLength`'s lead byte counts the bytes after it in bits 7:6
         // ("Package Length Encoding"); `NumElements` follows it.
         let Some(lead) = dsdt.byte(at + 5) else { return S5::Absent };
-        let element = at + 5 + 1 + usize::from(lead >> 6) + 1;
+        let element = at + 5 + 1 + usize::from(lead & 0) + 1;
         let value = match dsdt.byte(element) {
             Some(BYTE_PREFIX) => dsdt.byte(element + 1),
             byte => byte,
h2-no-width-refusal.patch
diff --git a/toyos-acpi/src/dsdt.rs b/toyos-acpi/src/dsdt.rs
index 5ac350631..62f1097c9 100644
--- a/toyos-acpi/src/dsdt.rs
+++ b/toyos-acpi/src/dsdt.rs
@@ -39,7 +39,7 @@ pub fn s5_slp_typ<P: Phys>(dsdt: &Table<P>) -> S5 {
             byte => byte,
         };
         return match value {
-            Some(value) if value <= SLP_TYP_MAX => S5::SlpTyp(value),
+            Some(value) if value <= u8::MAX => S5::SlpTyp(value),
             Some(value) => S5::Wide(value),
             None => S5::Absent,
         };
s0-scout-panic-resets.patch
diff --git a/tests/toyos.rs b/tests/toyos.rs
index c9d3f6ccf..a6c72144c 100644
--- a/tests/toyos.rs
+++ b/tests/toyos.rs
@@ -158,6 +158,7 @@ const SCREEN_TESTS: &[(&str, qemu::Profile)] = &[
     ("virt_reboot", qemu::Profile::VirtEl2),
     ("virt_off_names_the_cpus_left_on", qemu::Profile::VirtEl2),
     ("virt_reboot_refused_without_psci", qemu::Profile::VirtEl2),
+    ("virt_scout_panic_resets", qemu::Profile::VirtEl2),
 ];
 
 /// The tests whose machine shape *is* the test, each on a boot of its own.
@@ -1984,6 +1985,44 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re
         "virt_reboot" => virt_reboot(profile),
         "virt_off_names_the_cpus_left_on" => virt_off_names_the_cpus_left_on(profile),
         "virt_reboot_refused_without_psci" => virt_reboot_refused_without_psci(profile),
+        "virt_scout_panic_resets" => {
+            let trace = common::lane::dir().join("virt_scout_panic.psci");
+            let _ = fs::remove_file(&trace);
+            let mut qemu = QemuInstance::boot_with_options(
+                test_config,
+                &[],
+                &[],
+                BootOptions {
+                    profile,
+                    qmp: true,
+                    kernel_params: &["test-late-panic"],
+                    psci_trace: Some(trace.clone()),
+                    ready_marker: "panic: rebooting in",
+                    ..Default::default()
+                },
+            );
+            let bound = Duration::from_millis(toyos_tco::PANIC_BOUND_MS);
+            let mut stop = qemu::QmpShutdown::open(qemu.qmp_socket(), qemu.budget(bound + qemu::GUEST_QUIET));
+            let armed = qemu.boot_log().lines().rev().take(4).collect::<Vec<_>>().join("\n    ");
+            let from = Instant::now();
+            let stopped = stop.reason();
+            let waited = from.elapsed();
+            let by = qemu.budget(qemu::GUEST_QUIET);
+            let said = qemu.await_exit(by);
+            let traced = fs::read_to_string(&trace).map_err(|e| format!("read the PSCI trace: {e}"))?;
+            let calls: Vec<(u64, u64)> = psci_calls(&traced)?
+                .into_iter()
+                .filter(|&(function, _)| [PSCI_CPU_OFF, PSCI_SYSTEM_OFF, PSCI_SYSTEM_RESET].contains(&function))
+                .collect();
+            eprintln!("  [scout] the boot log's last lines, newest first:\n    {armed}");
+            eprintln!("  [scout] QEMU's stop reason {stopped:?}, {waited:.1?} after the ready marker was read");
+            eprintln!("  [scout] said after the ready marker: {said:?}");
+            eprintln!("  [scout] CPU_OFF, SYSTEM_OFF and SYSTEM_RESET calls traced: {calls:x?}");
+            if stopped.as_deref() != Some("guest-reset") || calls != [(PSCI_SYSTEM_RESET, calls.first().map_or(0, |c| c.1))] {
+                return Err("a late panic on virt did not end in one SYSTEM_RESET and a guest-reset".to_string());
+            }
+            Ok(())
+        }
         "screen_fatal_behind_a_painter" => {
             // The fatal halt with a painter holding the panel's latch and
             // never giving it back — which is what a painter is when the halt

@Japabu

Japabu commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

n0-whole.patch, the whole change reverted onto a31eec595 at 94dea677c (sha256 e00729644328ccd9d3aadd3f79606b20f5de0de08ebd79d4abc0bd7560a34dc6):

n0-whole.patch
diff --git a/kernel/src/arch/aarch64/boot.rs b/kernel/src/arch/aarch64/boot.rs
index b4eccf580..97f358f9f 100644
--- a/kernel/src/arch/aarch64/boot.rs
+++ b/kernel/src/arch/aarch64/boot.rs
@@ -328,9 +328,10 @@ pub fn reserved() -> Region {
 }
 
 /// What the boot learns bringing interrupts up and hands later steps: the
-/// other CPUs the MADT names.
+/// other CPUs the MADT names, and how to start them.
 pub struct Platform {
     gic: super::irqchip::Gic,
+    psci: Option<super::psci::Conduit>,
 }
 
 /// Interrupt delivery: this CPU's per-CPU block, the GIC and the timer's
@@ -339,8 +340,7 @@ pub fn interrupts(rsdp_addr: u64) -> Platform {
     super::percpu::init_bsp();
     let gic = super::irqchip::init(rsdp_addr);
     super::cpu::enable_interrupts();
-    super::psci::init(rsdp_addr);
-    Platform { gic }
+    Platform { gic, psci: super::psci::init(rsdp_addr) }
 }
 
 /// The clock: the generic timer's count, at the rate firmware states in
@@ -368,7 +368,7 @@ pub fn platform_devices(_rsdp_addr: u64) {}
 
 /// Every other CPU, running.
 pub fn start_other_cpus(platform: &Platform, _args: &KernelArgs) {
-    super::smp::start(&platform.gic);
+    super::smp::start(&platform.gic, platform.psci);
 }
 
 /// The interrupt-controller selftests an actuator asks for.
diff --git a/kernel/src/arch/aarch64/irqchip.rs b/kernel/src/arch/aarch64/irqchip.rs
index 09e282734..7a2345f23 100644
--- a/kernel/src/arch/aarch64/irqchip.rs
+++ b/kernel/src/arch/aarch64/irqchip.rs
@@ -40,8 +40,6 @@ pub(super) enum Intid {
     Kick = 0,
     /// Stops a CPU for good: [`stop_other_cpus`]'s.
     Halt,
-    /// Turns a CPU off for the machine's power-off: [`off_all_but_self`]'s.
-    Off,
     /// What `irq-storm` floods this CPU with.
     Storm,
     Hda,
@@ -50,7 +48,6 @@ pub(super) enum Intid {
 
 pub(super) const SGI_KICK: u32 = Intid::Kick as u32;
 pub(super) const SGI_HALT: u32 = Intid::Halt as u32;
-pub(super) const SGI_OFF: u32 = Intid::Off as u32;
 #[cfg(feature = "boot-actuators")]
 pub(super) const SGI_STORM: u32 = Intid::Storm as u32;
 
@@ -216,7 +213,7 @@ pub fn init_cpu(frame: u64) {
     redistributor.write_u32(GICR_ICFGR1, edge);
 
     stop_timer_hardware();
-    let enabled = 1 << SGI_KICK | 1 << SGI_HALT | 1 << SGI_OFF | 1 << timer;
+    let enabled = 1 << SGI_KICK | 1 << SGI_HALT | 1 << timer;
     #[cfg(feature = "boot-actuators")]
     let enabled = enabled | 1 << SGI_STORM;
     redistributor.write_u32(GICR_ISENABLER0, enabled);
@@ -295,22 +292,13 @@ pub fn kick_cpu(cpu: u32) {
     sgi(SGI_KICK, crate::smp::hardware_id(cpu));
 }
 
-pub fn kick_all_but_self() {
-    all_but_self(SGI_KICK);
-}
-
-/// Raise the power-off's SGI on every other CPU the roster holds.
-pub(super) fn off_all_but_self() {
-    all_but_self(SGI_OFF);
-}
-
 // Each by name, not with `IRM`'s broadcast: that reaches an AP which echoed too
 // late and halted with its SGIs enabled, where a kick left pending wakes the
 // masked `wfi` at once, for good.
-fn all_but_self(intid: u32) {
+pub fn kick_all_but_self() {
     let me = percpu::cpu_id();
     for cpu in (0..crate::smp::cpu_count()).filter(|&cpu| cpu != me) {
-        sgi(intid, crate::smp::hardware_id(cpu));
+        kick_cpu(cpu);
     }
 }
 
diff --git a/kernel/src/arch/aarch64/keyboard_controller.rs b/kernel/src/arch/aarch64/keyboard_controller.rs
index e04e19a2e..ef6462509 100644
--- a/kernel/src/arch/aarch64/keyboard_controller.rs
+++ b/kernel/src/arch/aarch64/keyboard_controller.rs
@@ -1,9 +1,6 @@
 //! The platform's own keyboard controller. An Arm machine has none: its
 //! keyboards are USB, and the panic panel's key poll reads nothing here.
 
-/// Whether the panic path reads a key here.
-pub const PANIC_KEYS: bool = false;
-
 /// No byte ever waits.
 pub fn poll_byte() -> Option<(u8, bool)> {
     None
diff --git a/kernel/src/arch/aarch64/mod.rs b/kernel/src/arch/aarch64/mod.rs
index 9440620de..2257f3a94 100644
--- a/kernel/src/arch/aarch64/mod.rs
+++ b/kernel/src/arch/aarch64/mod.rs
@@ -26,8 +26,8 @@ pub mod irqchip;
 pub mod keyboard_controller;
 pub mod paging;
 pub mod percpu;
+pub mod pio;
 pub mod pmu;
-pub mod power;
 pub mod psci;
 pub mod rtc;
 pub mod smp;
diff --git a/kernel/src/arch/aarch64/pio.rs b/kernel/src/arch/aarch64/pio.rs
new file mode 100644
index 000000000..b2886833d
--- /dev/null
+++ b/kernel/src/arch/aarch64/pio.rs
@@ -0,0 +1,15 @@
+//! The I/O port space: AArch64 has none.
+
+/// Whether this architecture has an I/O port space at all. Firmware tables
+/// that name a port are only honoured where it does.
+pub const EXISTS: bool = false;
+
+/// Never called: every caller checks [`EXISTS`] first.
+pub unsafe fn outb(_port: u16, _value: u8) {
+    unreachable!("AArch64 has no I/O port space")
+}
+
+/// Never called: every caller checks [`EXISTS`] first.
+pub unsafe fn outw(_port: u16, _value: u16) {
+    unreachable!("AArch64 has no I/O port space")
+}
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
deleted file mode 100644
index 9bfeb6cf3..000000000
--- a/kernel/src/arch/aarch64/power.rs
+++ /dev/null
@@ -1,112 +0,0 @@
-//! Reset and power-off: PSCI's `SYSTEM_RESET` and `SYSTEM_OFF` (Arm DEN0022
-//! §5.12, §5.10), through the conduit [`super::psci`] kept. A machine without
-//! PSCI has neither, and holds where either is asked for.
-//!
-//! Every line here goes straight to the UART, each whole under one fatal
-//! path's hold of its registers ([`serial::panic_registers`]): nothing drains
-//! the log ring after any of these calls. The hold is let go before a halt,
-//! since a CPU halted holding it costs every later line the whole bound.
-
-use super::{cpu, irqchip, percpu, psci};
-use crate::drivers::serial;
-use crate::time::{Budget, Deadline, Duration};
-
-/// How long the other CPUs get to be off by PSCI's answer before this one
-/// powers the machine off anyway.
-const OTHERS_OFF: Budget = Budget::of(
-    Duration::from_millis(100),
-    "the machine powers off with the CPUs PSCI still answers on, each named on the console",
-);
-
-pub fn can_reset() -> bool {
-    psci::conduit().is_some()
-}
-
-/// `SYSTEM_RESET`, which asks nothing of the other CPUs (DEN0022 §5.12.2). A
-/// wedge calls this: the call takes no lock, and only its refusal is said.
-pub fn reset() -> ! {
-    if let Some(psci) = psci::conduit() {
-        let refusal = psci.system_reset();
-        let mut uart = serial::panic_registers();
-        uart.write(b"power: SYSTEM_RESET answered ");
-        say_code(&mut uart, refusal);
-        uart.write(b"; holding\n");
-    }
-    cpu::halt()
-}
-
-/// `SYSTEM_OFF`, once every other CPU the roster holds has turned itself off
-/// with `CPU_OFF` and PSCI answers it off: the caller puts every core in a
-/// known state first, and this is DEN0022 §5.10.3's own way to.
-pub fn off() -> ! {
-    cpu::disable_interrupts();
-    let Some(psci) = psci::conduit() else { cpu::halt() };
-    irqchip::off_all_but_self();
-    let deadline = Deadline::at(crate::clock::now() + OTHERS_OFF.duration());
-    let me = percpu::cpu_id();
-    for cpu in (0..crate::smp::cpu_count()).filter(|&cpu| cpu != me) {
-        let mpidr = toyos_gicv3::unpacked_affinity(crate::smp::hardware_id(cpu));
-        loop {
-            match psci.affinity_info(mpidr) {
-                Ok(psci::Affinity::Off) => break,
-                Ok(_) if !deadline.reached(crate::clock::now()) => core::hint::spin_loop(),
-                Ok(_) => {
-                    let mut uart = serial::panic_registers();
-                    uart.write(b"power: cpu");
-                    uart.dec(u64::from(cpu));
-                    uart.write(b" is not off by PSCI's answer inside the budget; SYSTEM_OFF regardless\n");
-                    break;
-                }
-                Err(refusal) => {
-                    let mut uart = serial::panic_registers();
-                    uart.write(b"power: cpu");
-                    uart.dec(u64::from(cpu));
-                    uart.write(b"'s AFFINITY_INFO answered ");
-                    say_code(&mut uart, refusal);
-                    uart.write(b"; SYSTEM_OFF regardless\n");
-                    break;
-                }
-            }
-        }
-    }
-    let refusal = psci.system_off();
-    let mut uart = serial::panic_registers();
-    uart.write(b"power: SYSTEM_OFF answered ");
-    say_code(&mut uart, refusal);
-    uart.write(b"; holding\n");
-    drop(uart);
-    cpu::halt()
-}
-
-/// This CPU's part in [`off`], on the SGI that asked for it: its timer
-/// stopped, since a core `CPU_OFF` turned off may take no interrupt (DEN0022
-/// §5.5.2), then `CPU_OFF`. The SGI is never ended, as `SGI_HALT` is not, so
-/// a refusal leaves this CPU halted with nothing signalled to it, and [`off`]
-/// names it still on.
-pub(super) fn cpu_off() -> ! {
-    irqchip::stop_timer();
-    if crate::actuator::power_off_spares_the_last_two() && percpu::cpu_id() + 2 >= crate::smp::cpu_count() {
-        cpu::halt()
-    }
-    let Some(psci) = psci::conduit() else {
-        unreachable!("power: SGI_OFF is raised only by `off`, which holds a conduit")
-    };
-    let refusal = psci.cpu_off();
-    let mut uart = serial::panic_registers();
-    uart.write(b"power: cpu");
-    uart.dec(u64::from(percpu::cpu_id()));
-    uart.write(b"'s CPU_OFF answered ");
-    say_code(&mut uart, refusal);
-    uart.write(b"\n");
-    drop(uart);
-    cpu::halt()
-}
-
-/// The code PSCI refused a call with, signed.
-fn say_code(uart: &mut serial::PanicUart, refusal: psci::Error) {
-    let code = refusal.code();
-    if code < 0 {
-        uart.write(b"-");
-    }
-    uart.dec(u64::from(code.unsigned_abs()));
-}
diff --git a/kernel/src/arch/aarch64/psci.rs b/kernel/src/arch/aarch64/psci.rs
index d70537d78..e4df148df 100644
--- a/kernel/src/arch/aarch64/psci.rs
+++ b/kernel/src/arch/aarch64/psci.rs
@@ -3,48 +3,27 @@
 //! where a hypervisor stands in for it. Every call is SMCCC's (Arm DEN0028D):
 //! the function in `x0`, its arguments in `x1`–`x3`, the answer in `x0`.
 
-use core::sync::atomic::{AtomicU8, Ordering::Relaxed};
-
 use toyos_acpi::Psci;
 
 use crate::drivers::acpi::direct_phys;
 use crate::log;
 
-/// The function IDs this kernel calls, as PSCI numbers them from 0.2 on
-/// (DEN0022 §5.1), each the SMC64 one where there is one.
+/// The function IDs of `PSCI_VERSION` and the SMC64 `CPU_ON`, as PSCI
+/// numbers them from 0.2 on.
 const PSCI_VERSION: u32 = 0x8400_0000;
-const CPU_OFF: u32 = 0x8400_0002;
 const CPU_ON: u32 = 0xC400_0003;
-const AFFINITY_INFO: u32 = 0xC400_0004;
-const SYSTEM_OFF: u32 = 0x8400_0008;
-const SYSTEM_RESET: u32 = 0x8400_0009;
 
-/// `CPU_ON`'s `target_cpu` and `AFFINITY_INFO`'s `target_affinity`:
-/// `MPIDR_EL1`'s affinity fields where they stand, Aff3 in bits 39:32, every
-/// other bit zero.
+/// `CPU_ON`'s `target_cpu`: `MPIDR_EL1`'s affinity fields where they stand,
+/// Aff3 in bits 39:32, every other bit zero.
 const TARGET_CPU: u64 = 0xFF_00FF_FFFF;
 
 /// How this machine reaches PSCI.
 #[derive(Clone, Copy)]
-#[repr(u8)]
 pub enum Conduit {
-    Smc = 1,
+    Smc,
     Hvc,
 }
 
-/// The conduit [`init`] found, zero for none: one word, because a reset on a
-/// wedged machine reads it and may take no lock. Written on the boot CPU
-/// before any other starts.
-static CONDUIT: AtomicU8 = AtomicU8::new(0);
-
-/// What `AFFINITY_INFO` answers of one CPU (DEN0022 §5.7.1).
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
-pub enum Affinity {
-    On,
-    Off,
-    OnPending,
-}
-
 /// A PSCI call's refusal, as the specification numbers its return codes.
 #[derive(Clone, Copy, Debug)]
 pub enum Error {
@@ -75,22 +54,6 @@ impl Error {
             other => Self::Unnamed(other),
         }
     }
-
-    /// The code PSCI answered with: [`Error::of`] undone.
-    pub fn code(self) -> i32 {
-        match self {
-            Self::NotSupported => -1,
-            Self::InvalidParameters => -2,
-            Self::Denied => -3,
-            Self::AlreadyOn => -4,
-            Self::OnPending => -5,
-            Self::InternalFailure => -6,
-            Self::NotPresent => -7,
-            Self::Disabled => -8,
-            Self::InvalidAddress => -9,
-            Self::Unnamed(code) => code,
-        }
-    }
 }
 
 impl Conduit {
@@ -134,35 +97,6 @@ impl Conduit {
         }
     }
 
-    /// Turn this CPU off: an answer is a refusal, since `CPU_OFF` does not
-    /// return when it succeeds.
-    pub fn cpu_off(self) -> Error {
-        Error::of(self.call(CPU_OFF, 0, 0, 0))
-    }
-
-    /// Whether the CPU whose `MPIDR_EL1` is `mpidr` is on, off, or on its way
-    /// on: affinity level 0, the CPU itself.
-    pub fn affinity_info(self, mpidr: u64) -> Result<Affinity, Error> {
-        match self.call(AFFINITY_INFO, mpidr & TARGET_CPU, 0, 0) {
-            0 => Ok(Affinity::On),
-            1 => Ok(Affinity::Off),
-            2 => Ok(Affinity::OnPending),
-            code => Err(Error::of(code)),
-        }
-    }
-
-    /// Power the machine off. Neither this nor [`Conduit::system_reset`]
-    /// returns (DEN0022 §5.1.9, §5.1.11), so an answer is firmware breaking
-    /// that.
-    pub fn system_off(self) -> Error {
-        Error::of(self.call(SYSTEM_OFF, 0, 0, 0))
-    }
-
-    /// Reset the machine cold.
-    pub fn system_reset(self) -> Error {
-        Error::of(self.call(SYSTEM_RESET, 0, 0, 0))
-    }
-
     fn name(self) -> &'static str {
         match self {
             Self::Smc => "SMC",
@@ -171,15 +105,14 @@ impl Conduit {
     }
 }
 
-/// PSCI as the FADT names it, asked for its version and kept for
-/// [`conduit`]; none where the machine offers none this kernel can call,
-/// which the log says.
-pub fn init(rsdp_addr: u64) {
+/// PSCI as the FADT names it, asked for its version: `None` where the machine
+/// offers none this kernel can call, which the log says.
+pub fn init(rsdp_addr: u64) -> Option<Conduit> {
     let fadt = match toyos_acpi::find_table(direct_phys(), rsdp_addr, b"FACP", toyos_acpi::SDT_HEADER_LEN) {
         Ok(fadt) => fadt,
         Err(e) => {
             log!("PSCI: none, because the FADT is unusable: {e:?}");
-            return;
+            return None;
         }
     };
     let conduit = match toyos_acpi::psci(&fadt) {
@@ -187,41 +120,27 @@ pub fn init(rsdp_addr: u64) {
         Psci::Hvc => Conduit::Hvc,
         Psci::Absent => {
             log!("PSCI: none: the FADT's ARM_BOOT_ARCH does not set PSCI_COMPLIANT");
-            return;
+            return None;
         }
         Psci::Undefined { revision, minor } => {
             log!("PSCI: none: the FADT is version {revision}.{minor}, and ARM_BOOT_ARCH begins at 5.1");
-            return;
+            return None;
         }
         Psci::Short => {
             log!("PSCI: none: the FADT ends before ARM_BOOT_ARCH and the minor version after it");
-            return;
+            return None;
         }
     };
     let version = conduit.call(PSCI_VERSION, 0, 0, 0);
     if version < 0 {
         log!("PSCI: PSCI_VERSION through {} answered {:?}; none used", conduit.name(), Error::of(version));
-        return;
+        return None;
     }
     let (major, minor) = (version >> 16, version & 0xFFFF);
     if major == 0 && minor < 2 {
         log!("PSCI: {major}.{minor} through {}, which numbers no SMC64 CPU_ON; none used", conduit.name());
-        return;
-    }
-    if crate::actuator::psci_withheld() {
-        log!("PSCI: {major}.{minor} through {}, withheld: this kernel keeps no conduit", conduit.name());
-        return;
+        return None;
     }
     log!("PSCI: {major}.{minor} through {}", conduit.name());
-    CONDUIT.store(conduit as u8, Relaxed);
-}
-
-/// The conduit [`init`] kept, or `None` on a machine without PSCI.
-pub fn conduit() -> Option<Conduit> {
-    match CONDUIT.load(Relaxed) {
-        0 => None,
-        1 => Some(Conduit::Smc),
-        2 => Some(Conduit::Hvc),
-        other => unreachable!("PSCI: the kept conduit reads {other}, which `init` never stores"),
-    }
+    Some(conduit)
 }
diff --git a/kernel/src/arch/aarch64/smp.rs b/kernel/src/arch/aarch64/smp.rs
index a8441abf4..0f294097f 100644
--- a/kernel/src/arch/aarch64/smp.rs
+++ b/kernel/src/arch/aarch64/smp.rs
@@ -30,11 +30,11 @@ pub struct ApStart {
 
 /// Start every other CPU `gic` names, in the MADT's order, until one does not
 /// echo within [`AP_START`] or the roster is full.
-pub fn start(gic: &irqchip::Gic) {
+pub fn start(gic: &irqchip::Gic, psci: Option<psci::Conduit>) {
     let me = cpu::hardware_id();
     ROSTER.set_bsp(me);
     let others = gic.cpus.iter().filter(|gicc| packed_affinity(gicc.mpidr) != me);
-    let Some(psci) = psci::conduit() else {
+    let Some(psci) = psci else {
         log!("SMP: no PSCI to start the other {} CPUs with; the boot CPU runs alone", others.count());
         return;
     };
diff --git a/kernel/src/arch/aarch64/trap.rs b/kernel/src/arch/aarch64/trap.rs
index 9b541ac07..397a2137f 100644
--- a/kernel/src/arch/aarch64/trap.rs
+++ b/kernel/src/arch/aarch64/trap.rs
@@ -166,7 +166,6 @@ fn irq(from_el0: bool) {
         // Never ended: the running priority it keeps is every interrupt's
         // own, so the interface signals this CPU nothing and the halt stays.
         irqchip::SGI_HALT => cpu::halt(),
-        irqchip::SGI_OFF => super::power::cpu_off(),
         irqchip::SGI_KICK => {
             percpu::irq_took(Source::Timer);
             irqchip::end(intid);
diff --git a/kernel/src/arch/x86_64/boot.rs b/kernel/src/arch/x86_64/boot.rs
index a606ea3f4..341a7edd2 100644
--- a/kernel/src/arch/x86_64/boot.rs
+++ b/kernel/src/arch/x86_64/boot.rs
@@ -87,10 +87,9 @@ pub fn interrupts(rsdp_addr: u64) -> Platform {
     // Off the same tables as the MADT, and before the IDT below makes a panic
     // reportable: a panic that can be reported but not ended leaves the machine
     // holding its panel for a hand that may not be in the room.
-    super::power::init_reset(rsdp_addr);
+    acpi::init_reset(rsdp_addr);
     apic::init();
     percpu::init_bsp(apic::id());
-    super::power::init_off(rsdp_addr);
     ioapic::init(&madt);
     idt::enable_interrupts();
     super::syscall::init();
diff --git a/kernel/src/arch/x86_64/i8042/mod.rs b/kernel/src/arch/x86_64/i8042/mod.rs
index 5ff7fc7ba..16b3dcbfe 100644
--- a/kernel/src/arch/x86_64/i8042/mod.rs
+++ b/kernel/src/arch/x86_64/i8042/mod.rs
@@ -1245,9 +1245,6 @@ pub fn init(rsdp_addr: u64) {
 
 }
 
-/// Whether the panic path reads a key here: [`poll_byte`] is its poll.
-pub const PANIC_KEYS: bool = true;
-
 /// One byte from the controller if it has one; never waits. Only legal once
 /// every CPU is halted — port 0x60's sole reader is otherwise the ISR.
 pub fn poll_byte() -> Option<(u8, bool)> {
diff --git a/kernel/src/arch/x86_64/mod.rs b/kernel/src/arch/x86_64/mod.rs
index 2521b6e7c..c988e59d7 100644
--- a/kernel/src/arch/x86_64/mod.rs
+++ b/kernel/src/arch/x86_64/mod.rs
@@ -28,8 +28,8 @@ pub mod mtrr;
 pub mod paging;
 pub mod pat;
 pub mod percpu;
+pub mod pio;
 pub mod pmu;
-pub mod power;
 pub mod rtc;
 pub mod smp;
 pub mod switch;
diff --git a/kernel/src/arch/x86_64/pio.rs b/kernel/src/arch/x86_64/pio.rs
new file mode 100644
index 000000000..5a29539bb
--- /dev/null
+++ b/kernel/src/arch/x86_64/pio.rs
@@ -0,0 +1,7 @@
+//! The I/O port space: x86-64 has one, reached by `in` and `out`.
+
+/// Whether this architecture has an I/O port space at all. Firmware tables
+/// that name a port are only honoured where it does.
+pub const EXISTS: bool = true;
+
+pub use super::cpu::{outb, outw};
diff --git a/kernel/src/arch/x86_64/power.rs b/kernel/src/arch/x86_64/power.rs
deleted file mode 100644
index 2df8b54dd..000000000
--- a/kernel/src/arch/x86_64/power.rs
+++ /dev/null
@@ -1,137 +0,0 @@
-//! Reset and power-off through the FADT: its reset register, and S5 soft-off
-//! through the PM1a control block with the `SLP_TYPa` the DSDT's `\_S5_`
-//! package names.
-//!
-//! All input is firmware-supplied and untrusted: a table that does not decode
-//! is a machine with no reboot or no soft-off, said by name, never a panic.
-
-use core::mem::size_of;
-use core::sync::atomic::{AtomicU16, AtomicU8, Ordering};
-
-use toyos_acpi::{Reset, Table, TableError, S5, SDT_HEADER_LEN, SDT_REVISION};
-
-use super::cpu;
-use crate::drivers::acpi::direct_phys;
-use crate::log;
-
-const SLP_EN: u16 = 1 << 13;
-
-static PM1A_CNT_PORT: AtomicU16 = AtomicU16::new(0);
-static SLP_TYPA: AtomicU8 = AtomicU8::new(0);
-
-static RESET_PORT: AtomicU16 = AtomicU16::new(0);
-static RESET_VALUE: AtomicU8 = AtomicU8::new(0);
-
-/// Record the FADT's reset register, or say by name why this machine has none.
-///
-/// Before `percpu::init_bsp` loads the IDT: from then on every panic can be
-/// reported, and a panic that can be reported but not ended is a machine that
-/// still needs a hand. Walking these tables inside the panic handler instead is
-/// refused — a table walk on a machine that has already failed once is how a
-/// panic becomes a triple fault.
-pub fn init_reset(rsdp_addr: u64) {
-    let fadt = match toyos_acpi::find_table(direct_phys(), rsdp_addr, b"FACP", toyos_acpi::FADT_FOR_RESET) {
-        Ok(table) => table,
-        Err(e) => {
-            log!("ACPI: FADT unusable: {e:?} — no reboot, a panic will hold the panel");
-            return;
-        }
-    };
-    match toyos_acpi::reset_register(&fadt) {
-        Reset::Port { port, value } => {
-            RESET_PORT.store(port, Ordering::Relaxed);
-            RESET_VALUE.store(value, Ordering::Relaxed);
-            log!("ACPI: reset register SystemIO {port:#x} <- {value:#04x}");
-        }
-        other => log!("ACPI: no reset register this kernel writes ({other:?}) — no reboot"),
-    }
-}
-
-/// Record S5 soft-off, or say by name why this machine has none; it keeps
-/// booting either way. After the IDT, so a fault in the DSDT walk is reported.
-pub fn init_off(rsdp_addr: u64) {
-    const FADT_FOR_POWER: usize = toyos_acpi::FADT_PM1A_CNT_BLK + size_of::<u32>();
-    const FADT_FOR_X_DSDT: usize = toyos_acpi::FADT_X_DSDT + size_of::<u64>();
-
-    let fadt = match toyos_acpi::find_table(direct_phys(), rsdp_addr, b"FACP", FADT_FOR_POWER) {
-        Ok(table) => table,
-        Err(e) => {
-            log!("ACPI: FADT unusable: {e:?} — no soft-off, shutdown will halt instead");
-            return;
-        }
-    };
-
-    let Some(block) = fadt.u32_at(toyos_acpi::FADT_PM1A_CNT_BLK).filter(|&block| block != 0) else {
-        log!("ACPI: FADT has no PM1a control block — no soft-off");
-        return;
-    };
-    let Ok(pm1a) = u16::try_from(block) else {
-        log!("ACPI: FADT puts the PM1a control block at {block:#x}, past the 16-bit port space — no soft-off");
-        return;
-    };
-
-    // Prefer X_DSDT over DSDT; a revision claiming 2.0 doesn't prove the field is present, so the length is checked rather than trusting the revision alone.
-    let dsdt_addr = toyos_acpi::dsdt_address(&fadt);
-    if dsdt_addr == 0 {
-        log!(
-            "ACPI: FADT names no DSDT (rev {:?}, needs {FADT_FOR_X_DSDT} bytes for X_DSDT) — no soft-off",
-            fadt.byte(SDT_REVISION)
-        );
-        return;
-    }
-
-    let dsdt = match Table::open(direct_phys(), dsdt_addr, b"DSDT", SDT_HEADER_LEN) {
-        Ok(table) => table,
-        Err(TableError::Unmapped { .. }) => {
-            log!("ACPI: FADT points the DSDT at {dsdt_addr:#x}, which is not an address — no soft-off");
-            return;
-        }
-        Err(e) => {
-            log!("ACPI: DSDT at {dsdt_addr:#x} unusable: {e:?} — no soft-off");
-            return;
-        }
-    };
-
-    let slp_typ = match toyos_acpi::s5_slp_typ(&dsdt) {
-        S5::SlpTyp(slp_typ) => slp_typ,
-        S5::Absent => {
-            log!("ACPI: no \\_S5_ package in the DSDT — no soft-off");
-            return;
-        }
-        S5::Wide(byte) => {
-            log!("ACPI: the DSDT's \\_S5_ names SLP_TYPa {byte:#x}, wider than its three bits — no soft-off");
-            return;
-        }
-    };
-
-    PM1A_CNT_PORT.store(pm1a, Ordering::Relaxed);
-    SLP_TYPA.store(slp_typ, Ordering::Relaxed);
-    log!("ACPI: PM1a={pm1a:#x} SLP_TYPa={slp_typ}");
-}
-
-pub fn can_reset() -> bool {
-    RESET_PORT.load(Ordering::Relaxed) != 0
-}
-
-/// Write the reset register and nothing else: no lock, nothing but the port
-/// the FADT named. A machine with no reset register halts.
-// No fallback: 0xCF9, the keyboard controller and anything else are written only where a table named them.
-pub fn reset() -> ! {
-    let port = RESET_PORT.load(Ordering::Relaxed);
-    if port != 0 {
-        // SAFETY: the port is non-zero only where `init_reset` decoded an 8-bit System I/O register, and the value is that register's.
-        unsafe { cpu::outb(port, RESET_VALUE.load(Ordering::Relaxed)) };
-    }
-    cpu::halt()
-}
-
-/// Enter S5, or halt on a machine whose tables named no soft-off.
-pub fn off() -> ! {
-    let pm1a = PM1A_CNT_PORT.load(Ordering::Relaxed);
-    if pm1a != 0 {
-        let val = (u16::from(SLP_TYPA.load(Ordering::Relaxed)) << 10) | SLP_EN;
-        // SAFETY: pm1a and slp_typ come only from the validated FADT parse via PM1A_CNT_PORT/SLP_TYPA, and the zero check above confirms that parse happened.
-        unsafe { cpu::outw(pm1a, val) };
-    }
-    cpu::halt()
-}
diff --git a/kernel/src/deadline.rs b/kernel/src/deadline.rs
index 8fdc1d071..4c4bd89de 100644
--- a/kernel/src/deadline.rs
+++ b/kernel/src/deadline.rs
@@ -221,7 +221,7 @@ fn expire() -> ! {
     // The seal first, because the USB stop `reset_now` makes before it writes
     // the register is bounded but not instant, and this record is the
     // diagnostic the whole mechanism exists for.
-    crate::power::reset_now()
+    crate::drivers::acpi::reset_now()
 }
 
 /// What a sealed record opens with, and so what the next boot's loader prints
diff --git a/kernel/src/drivers/acpi.rs b/kernel/src/drivers/acpi.rs
index 87db44ac6..79e52c048 100644
--- a/kernel/src/drivers/acpi.rs
+++ b/kernel/src/drivers/acpi.rs
@@ -3,17 +3,31 @@
 //! The decode is `toyos-acpi`, pure and host-tested against QEMU's own tables
 //! and a crafted corpus. What stays here is everything that touches the
 //! machine: the direct-map reader the crate decodes through, the log lines a
-//! machine owner reads a refusal off, and the `Vec`s the crate cannot allocate.
+//! machine owner reads a refusal off, the `Vec`s the crate cannot allocate, and
+//! the PM1 port writes that turn a validated FADT into a soft-off.
 //!
 //! All input is firmware-supplied and untrusted: no panic on any input path,
 //! every failure is a [`TableError`] and the caller decides what it means.
+//!
+//! **No reset this kernel performs leaves a USB device mid-command.**
+//! [`reset_now`] and [`shutdown`] are the only two places this kernel writes a
+//! register that ends the machine, and each stops every xHCI controller
+//! ([`stop::before_reset`]) before it does — which is what makes that a property
+//! of the reset rather than of whoever asked for one, and what a third caller
+//! gets without knowing it is owed. Resets this kernel does not perform — a TCO
+//! or firmware watchdog, a triple fault, power loss — are outside it and always
+//! will be.
 
 use alloc::vec::Vec;
 use core::mem::size_of;
 use core::ptr::{read_unaligned, read_volatile};
+use core::sync::atomic::{AtomicU16, AtomicU8, Ordering};
+use crate::drivers::xhci::stop;
 use crate::log;
 use crate::DirectMap;
-use toyos_acpi::{Century, MadtEntry, Mapped, Memory, CMOS_RAM, MADT_ENTRIES, SDT_HEADER_LEN, SDT_REVISION};
+use toyos_acpi::{
+    Century, MadtEntry, Mapped, Memory, Reset, CMOS_RAM, MADT_ENTRIES, SDT_HEADER_LEN, SDT_REVISION,
+};
 
 pub use toyos_acpi::{IoApicEntry, SourceOverride, TableError};
 
@@ -47,7 +61,11 @@ pub fn direct_phys() -> DirectPhys {
 pub struct Table(toyos_acpi::Table<DirectPhys>);
 
 impl Table {
-    /// The declared length, already bounded by [`find_table`].
+    pub fn open(base: u64, signature: &[u8; 4], needed: usize) -> Result<Table, TableError> {
+        toyos_acpi::Table::open(direct_phys(), base, signature, needed).map(Table)
+    }
+
+    /// The declared length, already bounded by [`Table::open`].
     pub fn len(&self) -> usize {
         self.0.len()
     }
@@ -114,6 +132,14 @@ pub fn inventory(rsdp_addr: u64) {
     );
 }
 
+const SLP_EN: u16 = 1 << 13;
+
+static PM1A_CNT_PORT: AtomicU16 = AtomicU16::new(0);
+static SLP_TYPA: AtomicU16 = AtomicU16::new(0);
+
+static RESET_PORT: AtomicU16 = AtomicU16::new(0);
+static RESET_VALUE: AtomicU8 = AtomicU8::new(0);
+
 /// Log a refusal with the reason, and hand the caller `None`.
 // Never a panic: a machine owner needs to see the reason, not have the kernel die on a firmware defect.
 fn refuse<T>(what: &str, error: TableError) -> Option<T> {
@@ -139,6 +165,64 @@ pub fn find_ecam_base(rsdp_addr: u64) -> Option<(u64, u16)> {
     Some((base, segment))
 }
 
+/// Parse FADT and DSDT to prepare for ACPI shutdown.
+// A machine without them keeps booting without soft-off rather than panicking.
+// The reset register is not decoded here: it is `init_reset`, which runs before
+// the boot has anything a panic could report on.
+pub fn init_power(rsdp_addr: u64) {
+    const FADT_FOR_POWER: usize = toyos_acpi::FADT_PM1A_CNT_BLK + size_of::<u32>();
+    const FADT_FOR_X_DSDT: usize = toyos_acpi::FADT_X_DSDT + size_of::<u64>();
+
+    let fadt = match find_table(rsdp_addr, b"FACP", FADT_FOR_POWER) {
+        Ok(table) => table,
+        Err(e) => {
+            log!("ACPI: FADT unusable: {e:?} — no soft-off, shutdown will halt instead");
+            return;
+        }
+    };
+
+    let Some(pm1a) = fadt.field::<u32>(toyos_acpi::FADT_PM1A_CNT_BLK) else {
+        log!("ACPI: FADT has no PM1a control block — no soft-off");
+        return;
+    };
+    let pm1a = pm1a as u16;
+    if pm1a != 0 && !crate::arch::pio::EXISTS {
+        log!("ACPI: FADT puts PM1a control at port {pm1a:#x}, and this machine has no I/O port space — no soft-off");
+        return;
+    }
+
+    // Prefer X_DSDT over DSDT; a revision claiming 2.0 doesn't prove the field is present, so the length is checked rather than trusting the revision alone.
+    let dsdt_addr = toyos_acpi::dsdt_address(&fadt.0);
+    if dsdt_addr == 0 {
+        log!(
+            "ACPI: FADT names no DSDT (rev {:?}, needs {FADT_FOR_X_DSDT} bytes for X_DSDT) — no soft-off",
+            fadt.field::<u8>(SDT_REVISION)
+        );
+        return;
+    }
+
+    let dsdt = match Table::open(dsdt_addr, b"DSDT", SDT_HEADER_LEN) {
+        Ok(table) => table,
+        Err(TableError::Unmapped { .. }) => {
+            log!("ACPI: FADT points the DSDT at {dsdt_addr:#x}, which is not an address — no soft-off");
+            return;
+        }
+        Err(e) => {
+            log!("ACPI: DSDT at {dsdt_addr:#x} unusable: {e:?} — no soft-off");
+            return;
+        }
+    };
+
+    let Some(slp_typ) = find_s5_slp_typ(&dsdt) else {
+        log!("ACPI: no \\_S5_ package in the DSDT — no soft-off");
+        return;
+    };
+
+    PM1A_CNT_PORT.store(pm1a, Ordering::Relaxed);
+    SLP_TYPA.store(slp_typ, Ordering::Relaxed);
+    log!("ACPI: PM1a={pm1a:#x} SLP_TYPa={slp_typ}");
+}
+
 /// FADT revision and the IA-PC boot architecture flags.
 // `Err` is not "absent" and must not be treated as one by the caller.
 pub fn iapc_boot_arch(rsdp_addr: u64) -> Result<(u8, u16), TableError> {
@@ -169,6 +253,103 @@ pub fn rtc_century_register(rsdp_addr: u64) -> Result<Option<u8>, TableError> {
     }
 }
 
+/// Record the FADT's reset register, or say by name why this machine has none.
+///
+/// The one decode of it, and it runs before `percpu::init_bsp` rather than with
+/// the rest of the power tables: from the moment that function loads the IDT,
+/// every panic can be reported, and a panic that can be reported but not ended
+/// is a machine that still needs a hand. Walking these tables inside the panic
+/// handler instead is refused — a table walk on a machine that has already
+/// failed once is how a panic becomes a triple fault.
+pub fn init_reset(rsdp_addr: u64) {
+    let fadt = match find_table(rsdp_addr, b"FACP", toyos_acpi::FADT_FOR_RESET) {
+        Ok(table) => table,
+        Err(e) => {
+            log!("ACPI: FADT unusable: {e:?} — no reboot, a panic will hold the panel");
+            return;
+        }
+    };
+    match toyos_acpi::reset_register(&fadt.0) {
+        Reset::Port { port, .. } if !crate::arch::pio::EXISTS => {
+            log!("ACPI: the reset register is SystemIO {port:#x}, and this machine has no I/O port space — no reboot");
+        }
+        Reset::Port { port, value } => {
+            RESET_PORT.store(port, Ordering::Relaxed);
+            RESET_VALUE.store(value, Ordering::Relaxed);
+            log!("ACPI: reset register SystemIO {port:#x} <- {value:#04x}");
+        }
+        other => log!("ACPI: no reset register this kernel writes ({other:?}) — no reboot"),
+    }
+}
+
+pub fn can_reboot() -> bool {
+    RESET_PORT.load(Ordering::Relaxed) != 0
+}
+
+/// Return the machine to firmware through the FADT's reset register.
+// No fallback: 0xCF9, the keyboard controller and anything else are written only where a table named them.
+pub fn reboot() -> ! {
+    crate::drivers::serial::flush_final();
+    // Kernel-internal, so a bug rather than a machine quiesced and then left halted quietly.
+    assert!(
+        RESET_PORT.load(Ordering::Relaxed) != 0,
+        "reboot: no reset register, and the caller did not ask can_reboot() first"
+    );
+    reset_now()
+}
+
+/// Write the reset register and nothing else.
+///
+/// **[`reboot`] is not reachable from a wedge**, which is why this exists
+/// beside it: that path opens with `serial::flush_final`, and a `BackendGuard`
+/// masks interrupts for its whole life — so a CPU stuck inside one holds what
+/// the call would wait for, on exactly the boots `crate::deadline` exists for.
+/// This takes no lock and touches nothing but the port the FADT named.
+///
+/// A machine with no reset register halts here rather than returning: the
+/// caller has already sealed why, and holding is what such a machine has always
+/// done.
+pub fn reset_now() -> ! {
+    // **Here and not at either caller.** `stop::before_reset` is registers and
+    // nothing else — written for the panic path, so it takes no lock and
+    // allocates nothing, which is the only kind of call a wedge may make — and
+    // it *appends* its account to whatever this boot already sealed, so a
+    // `WEDGED` page carries what the reset did to USB the way a `PANIC` one
+    // does. A caller seals first and calls this second: the record is the
+    // diagnostic the seal exists for and may not be lost to a stop that does
+    // not return.
+    stop::before_reset();
+    let port = RESET_PORT.load(Ordering::Relaxed);
+    if port == 0 {
+        crate::arch::cpu::halt();
+    }
+    // SAFETY: the port is non-zero only where `init_reset` decoded an 8-bit System I/O register, and the value is that register's.
+    unsafe { crate::arch::pio::outb(port, RESET_VALUE.load(Ordering::Relaxed)) };
+
+    crate::arch::cpu::halt();
+}
+
+/// Trigger ACPI S5 (soft-off) shutdown.
+pub fn shutdown() -> ! {
+    // Last chance: nothing drains the log ring after this point.
+    crate::drivers::serial::flush_final();
+    // S5 takes VBUS with it on a machine whose ports are not always-on and
+    // takes nothing on one whose are, so the devices are handed back here for
+    // the same reason as at a reboot.
+    stop::before_reset();
+
+    let pm1a = PM1A_CNT_PORT.load(Ordering::Relaxed);
+    let slp_typ = SLP_TYPA.load(Ordering::Relaxed);
+
+    if pm1a != 0 {
+        let val = (slp_typ << 10) | SLP_EN;
+        // SAFETY: pm1a and slp_typ come only from the validated FADT parse via PM1A_CNT_PORT/SLP_TYPA, and the zero check above confirms that parse happened.
+        unsafe { crate::arch::pio::outw(pm1a, val) };
+    }
+
+    crate::arch::cpu::halt();
+}
+
 /// Given the RSDP address, parse XSDT -> HPET table -> return HPET MMIO base address.
 pub fn find_hpet_base(rsdp_addr: u64) -> Option<u64> {
     let base = match toyos_acpi::hpet_base(direct_phys(), rsdp_addr) {
@@ -221,3 +402,36 @@ pub fn parse_madt(rsdp_addr: u64) -> Option<MadtInfo> {
     log!("ACPI: MADT cpus={:?}", apic_ids);
     Some(MadtInfo { apic_ids, io_apics, source_overrides })
 }
+
+/// Scan DSDT AML bytecode for the \_S5_ package and extract SLP_TYPa.
+// Bounded by the table's declared length via [`Table::field`].
+fn find_s5_slp_typ(dsdt: &Table) -> Option<u16> {
+    let s5 = b"_S5_";
+    let len = dsdt.len();
+
+    for i in SDT_HEADER_LEN..len.saturating_sub(7) {
+        if (0..4).any(|j| dsdt.field::<u8>(i + j) != Some(s5[j])) {
+            continue;
+        }
+        if dsdt.field::<u8>(i + 4) != Some(0x12) {
+            continue;
+        }
+
+        let pkg_lead = dsdt.field::<u8>(i + 5)?;
+        let pkg_len_bytes = match (pkg_lead >> 6) & 0x03 {
+            0 => 1usize,
+            n => (n + 1) as usize,
+        };
+
+        // Skip: "_S5_"(4) + PackageOp(1) + PkgLength + NumElements(1)
+        let val_off = i + 4 + 1 + pkg_len_bytes + 1;
+        let byte = dsdt.field::<u8>(val_off)?;
+        return Some(if byte == 0x0A {
+            dsdt.field::<u8>(val_off + 1)? as u16
+        } else {
+            byte as u16
+        });
+    }
+
+    None
+}
diff --git a/kernel/src/drivers/serial.rs b/kernel/src/drivers/serial.rs
index 67368b2d5..c1f9c14c6 100644
--- a/kernel/src/drivers/serial.rs
+++ b/kernel/src/drivers/serial.rs
@@ -254,7 +254,7 @@ pub unsafe fn panic_flush() {
 }
 
 /// Drains the ring before the machine powers off, so the tail of a shutdown
-/// is not lost to `power::shutdown()` cutting power with logs still queued.
+/// is not lost to `acpi::shutdown()` cutting power with logs still queued.
 ///
 /// Bounded on the wire like `panic_flush`, but never bypasses: every CPU is
 /// still live here, and reading the ring unsynchronized is only safe once
diff --git a/kernel/src/hardlockup/mod.rs b/kernel/src/hardlockup/mod.rs
index 3b2e518d8..24a379e30 100644
--- a/kernel/src/hardlockup/mod.rs
+++ b/kernel/src/hardlockup/mod.rs
@@ -28,7 +28,7 @@
 //! stuck: it seals a `WEDGED` record naming itself, its `pc` and `sp` from the
 //! NMI frame, the lock it is spinning on if `Lock::lock` recorded one, a line
 //! for every other CPU, and the tail of the log ring — then writes the reset
-//! register through `power::reset_now`.
+//! register through `acpi::reset_now`.
 //!
 //! # The discipline this file is written under
 //!
@@ -284,7 +284,7 @@ fn locked_up(me: usize, pc: u64, sp: u64, now: u64) -> ! {
     // The seal first, because the USB stop `reset_now` makes before it writes
     // the register is bounded but not instant, and this record is the
     // diagnostic the whole mechanism exists for.
-    crate::power::reset_now()
+    crate::drivers::acpi::reset_now()
 }
 
 /// What this CPU was waiting for before a nested acquisition took the slot, so
diff --git a/kernel/src/main.rs b/kernel/src/main.rs
index cab6b78d1..9369fc7b1 100644
--- a/kernel/src/main.rs
+++ b/kernel/src/main.rs
@@ -36,7 +36,6 @@ mod hardlockup;
 mod mm;
 mod panic;
 mod panic_reboot;
-mod power;
 
 mod keyboard;
 mod mouse;
@@ -430,6 +429,7 @@ pub(crate) unsafe extern "C" fn kernel_main(kernel_args: &KernelArgs) -> ! {
     arch::watchdog::init(&pci_devices);
     file_cache::init();
     gpt::init(kernel_args);
+    acpi::init_power(kernel_args.rsdp_addr);
 
     boot_phase!("peripherals ready", t_periph);
 
diff --git a/kernel/src/panic_reboot.rs b/kernel/src/panic_reboot.rs
index 1aa2cace6..65bda7dbe 100644
--- a/kernel/src/panic_reboot.rs
+++ b/kernel/src/panic_reboot.rs
@@ -11,9 +11,14 @@
 //! both phases then compare the same counter against the same unit. A machine
 //! that states no frequency and has no calibrated clock cannot time anything,
 //! and the arm line says so instead of resetting on a guess.
+//!
+//! The reset is the FADT's; a machine whose reset register this kernel could
+//! not decode is refused by name and holds, with no fallback. That register is
+//! decoded before `percpu::init_bsp` loads the IDT, so every panic that can
+//! reach this path at all has one to write.
 
-use crate::arch::{cpu, keyboard_controller};
-use crate::drivers::serial;
+use crate::arch::cpu;
+use crate::drivers::{acpi, serial};
 use crate::time::{Budget, Duration};
 
 /// The shipped bound.
@@ -28,7 +33,7 @@ pub enum Bound {
     /// Reset the machine at this `cpu::counter` reading.
     At(u64),
     /// Hold the panel: somebody is reading it, or nothing here could time a
-    /// wait, or this machine has no reset this kernel performs.
+    /// wait, or this machine has no reset register to write.
     Held,
 }
 
@@ -98,32 +103,30 @@ pub fn arm(on_the_record: bool) -> Bound {
     // ASCII only, here and in every line below: the panel's font renders
     // anything outside 0x20..=0x7E as a dot.
     let secs = budget.duration().millis() / 1_000;
-    // A key retires the bound only where the panic path reads one.
-    let unless = if keyboard_controller::PANIC_KEYS { " unless a key is pressed" } else { "" };
-    match (deadline(budget), crate::power::can_reboot()) {
+    // The clock and the reset register are two separate ways to have no
+    // reboot, and a line naming one when the other is what failed answers
+    // whoever reads the panel with nothing.
+    match (deadline(budget), acpi::can_reboot()) {
         (Some((cycles, source)), true) => {
             if on_the_record {
                 alert!(
-                    "{ARMED} in {secs} s{unless}, timed by {}",
+                    "{ARMED} in {secs} s unless a key is pressed, timed by {}",
                     source.named()
                 );
             } else {
-                let mut uart = serial::panic_registers();
-                uart.write(b"panic: rebooting");
-                uart.write(unless.as_bytes());
-                uart.write(b"\n");
+                serial::panic_registers().write(b"panic: rebooting unless a key is pressed\n");
             }
             Bound::At(cycles)
         }
         (Some((_, source)), false) => {
             if on_the_record {
                 alert!(
-                    "{HELD}, timed by {}: this kernel has no reset to hand the machine back to \
-                     firmware with",
+                    "{HELD}, timed by {}: this kernel has decoded no reset register to hand \
+                     the machine back to firmware with",
                     source.named()
                 );
             } else {
-                serial::panic_registers().write(b"panic: holding this panel: no reset\n");
+                serial::panic_registers().write(b"panic: holding this panel: no reset register\n");
             }
             Bound::Held
         }
@@ -144,12 +147,11 @@ pub fn arm(on_the_record: bool) -> Bound {
 /// Return the machine to firmware. The second of this path's two lines, and it
 /// goes out raw: the log has already been flushed and drained by here.
 pub fn reboot_now() -> ! {
-    serial::panic_registers().write(if keyboard_controller::PANIC_KEYS {
-        b"\npanic: no key inside the bound, so nobody is here: returning this machine to firmware\n"
-    } else {
-        b"\npanic: the bound is over: returning this machine to firmware\n"
-    });
-    // Not `power::reboot`: its flush waits on the console wire, and a CPU this
+    serial::panic_registers().write(
+        b"\npanic: no key inside the bound, so nobody is here: returning this machine to \
+          firmware\n",
+    );
+    // Not `acpi::reboot`: its flush waits on the console wire, and a CPU this
     // panic stopped may be holding it.
-    crate::power::reset_now()
+    acpi::reset_now()
 }
diff --git a/kernel/src/power.rs b/kernel/src/power.rs
deleted file mode 100644
index 0b39a9d64..000000000
--- a/kernel/src/power.rs
+++ /dev/null
@@ -1,59 +0,0 @@
-//! The machine's two ends this kernel performs, a reset and a power-off, each
-//! the architecture's own (`arch::power`).
-//!
-//! **No reset this kernel performs leaves a USB device mid-command.**
-//! [`reset_now`] and [`shutdown`] are the only two places this kernel ends the
-//! machine, and each stops every xHCI controller ([`stop::before_reset`])
-//! before it does — which is what makes that a property of the reset rather
-//! than of whoever asked for one, and what a third caller gets without knowing
-//! it is owed. Resets this kernel does not perform — a TCO or firmware
-//! watchdog, a triple fault, power loss — are outside it and always will be.
-
-use crate::drivers::{serial, xhci::stop};
-
-/// Whether this machine has a reset this kernel can perform.
-pub fn can_reboot() -> bool {
-    crate::arch::power::can_reset()
-}
-
-/// Return the machine to firmware.
-pub fn reboot() -> ! {
-    serial::flush_final();
-    // Kernel-internal, so a bug rather than a machine quiesced and then left halted quietly.
-    assert!(can_reboot(), "reboot: no reset, and the caller did not ask can_reboot() first");
-    reset_now()
-}
-
-/// Reset the machine and do nothing else.
-///
-/// **[`reboot`] is not reachable from a wedge**, which is why this exists
-/// beside it: that path opens with `serial::flush_final`, and a `BackendGuard`
-/// masks interrupts for its whole life — so a CPU stuck inside one holds what
-/// the call would wait for, on exactly the boots `crate::deadline` exists for.
-/// This takes no lock.
-///
-/// A machine with no reset halts here rather than returning: the caller has
-/// already sealed why, and holding is what such a machine has always done.
-pub fn reset_now() -> ! {
-    // **Here and not at either caller.** `stop::before_reset` is registers and
-    // nothing else — written for the panic path, so it takes no lock and
-    // allocates nothing, which is the only kind of call a wedge may make — and
-    // it *appends* its account to whatever this boot already sealed, so a
-    // `WEDGED` page carries what the reset did to USB the way a `PANIC` one
-    // does. A caller seals first and calls this second: the record is the
-    // diagnostic the seal exists for and may not be lost to a stop that does
-    // not return.
-    stop::before_reset();
-    crate::arch::power::reset()
-}
-
-/// Power the machine off, or halt on one that offers no power-off.
-pub fn shutdown() -> ! {
-    // Last chance: nothing drains the log ring after this point.
-    serial::flush_final();
-    // A power-off takes VBUS with it on a machine whose ports are not
-    // always-on and takes nothing on one whose are, so the devices are handed
-    // back here for the same reason as at a reboot.
-    stop::before_reset();
-    crate::arch::power::off()
-}
diff --git a/kernel/src/syscall/machine.rs b/kernel/src/syscall/machine.rs
index e79004b43..eb3094280 100644
--- a/kernel/src/syscall/machine.rs
+++ b/kernel/src/syscall/machine.rs
@@ -7,7 +7,7 @@
 
 use alloc::vec::Vec;
 
-use crate::power;
+use crate::drivers::acpi;
 use crate::user_ptr::{SyscallContext, UserBytesMut};
 use crate::UserAddr;
 use crate::{log, process};
@@ -105,7 +105,7 @@ fn quiesce(last: &str) -> Result<(), SyscallError> {
     // volumes still have bytes to take and this takes the controller away from
     // them; and after everything else here,
     // because nothing may run between it and the register stop
-    // `power::reboot`/`power::shutdown` do — which every reset this kernel
+    // `acpi::reboot`/`acpi::shutdown` do — which every reset this kernel
     // performs goes through. It is bounded, and the reset follows either way.
     crate::drivers::xhci::seal_shut();
     Ok(())
@@ -119,23 +119,23 @@ pub(super) fn sys_shutdown(syscap: RawHandle) -> u64 {
     if let Err(e) = quiesce("Shutting down.") {
         return e.to_u64();
     }
-    power::shutdown();
+    acpi::shutdown();
 }
 
 /// Returns the machine to firmware; requires a `SysCap` carrying [`Rights::POWER`]. Returns only when refused.
-// The reset is demanded before anything is torn down: a machine without one is left running, not synced, stopped and still on.
+// The register is demanded before anything is torn down: a machine whose FADT names none is left running, not synced, stopped and still on.
 pub(super) fn sys_reboot(syscap: RawHandle) -> u64 {
     if let Err(e) = demand_syscap(syscap, Rights::POWER) {
         return e.refuse();
     }
-    if !power::can_reboot() {
-        log!("reboot: this machine has no reset this kernel performs — refused");
+    if !acpi::can_reboot() {
+        log!("reboot: this machine's FADT names no reset register — refused");
         return SyscallError::NotSupported.to_u64();
     }
     if let Err(e) = quiesce("Rebooting.") {
         return e.to_u64();
     }
-    power::reboot();
+    acpi::reboot();
 }
 
 /// The most live threads `SYS_SYSINFO` will describe; kept under `mm::MAX_HEAP_ALLOC` so an unbounded thread count cannot trip the allocator's fail-fast assert.
diff --git a/toyos-acpi/src/dsdt.rs b/toyos-acpi/src/dsdt.rs
deleted file mode 100644
index 5ac350631..000000000
--- a/toyos-acpi/src/dsdt.rs
+++ /dev/null
@@ -1,48 +0,0 @@
-//! The DSDT (signature `DSDT`), read for one value by a byte scan, not an AML
-//! interpreter: the first element of its `\_S5_` package (ACPI 6.5, "\_Sx").
-
-use crate::{Phys, Table, SDT_HEADER_LEN};
-
-/// AML's `PackageOp` and `BytePrefix` (ACPI 6.5, "AML Grammar Definition").
-const PACKAGE_OP: u8 = 0x12;
-const BYTE_PREFIX: u8 = 0x0A;
-
-/// The widest `SLP_TYPx` the PM1 control register holds: three bits, 12:10.
-const SLP_TYP_MAX: u8 = 7;
-
-/// What a DSDT says to write to `SLP_TYPa` for S5 soft-off.
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
-pub enum S5 {
-    SlpTyp(u8),
-    /// No `_S5_` followed by `PackageOp` whose first element lies inside the
-    /// table.
-    Absent,
-    /// A first element wider than `SLP_TYPx`, which shifted into place would
-    /// set `SLP_EN` and reserved bits.
-    Wide(u8),
-}
-
-/// The first element of the first `\_S5_` package in `dsdt`, read inside the
-/// table's declared length: a `BytePrefix` constant, or the element's own
-/// byte, which is `ZeroOp` and `OneOp`'s value.
-pub fn s5_slp_typ<P: Phys>(dsdt: &Table<P>) -> S5 {
-    for at in SDT_HEADER_LEN..dsdt.len() {
-        if (0..4).any(|i| dsdt.byte(at + i) != Some(b"_S5_"[i])) || dsdt.byte(at + 4) != Some(PACKAGE_OP) {
-            continue;
-        }
-        // `PkgLength`'s lead byte counts the bytes after it in bits 7:6
-        // ("Package Length Encoding"); `NumElements` follows it.
-        let Some(lead) = dsdt.byte(at + 5) else { return S5::Absent };
-        let element = at + 5 + 1 + usize::from(lead >> 6) + 1;
-        let value = match dsdt.byte(element) {
-            Some(BYTE_PREFIX) => dsdt.byte(element + 1),
-            byte => byte,
-        };
-        return match value {
-            Some(value) if value <= SLP_TYP_MAX => S5::SlpTyp(value),
-            Some(value) => S5::Wide(value),
-            None => S5::Absent,
-        };
-    }
-    S5::Absent
-}
diff --git a/toyos-acpi/src/lib.rs b/toyos-acpi/src/lib.rs
index 99a40edff..9e2378500 100644
--- a/toyos-acpi/src/lib.rs
+++ b/toyos-acpi/src/lib.rs
@@ -14,7 +14,6 @@
 #![no_std]
 #![forbid(unsafe_code)]
 
-mod dsdt;
 mod fadt;
 mod gtdt;
 mod madt;
@@ -23,7 +22,6 @@ mod spcr;
 
 use toyos_bootmap::DirectMapEnd;
 
-pub use dsdt::{s5_slp_typ, S5};
 pub use fadt::{
     century_of, dsdt_address, iapc_boot_arch, psci, reset_register, rtc_century, Century, Psci,
     Reset, CMOS_RAM, FADT_FOR_RESET, FADT_PM1A_CNT_BLK, FADT_X_DSDT,
diff --git a/toyos-gicv3/src/lib.rs b/toyos-gicv3/src/lib.rs
index a1dcb92d0..43bec42c2 100644
--- a/toyos-gicv3/src/lib.rs
+++ b/toyos-gicv3/src/lib.rs
@@ -22,12 +22,6 @@ pub const fn packed_affinity(mpidr: u64) -> u32 {
     ((mpidr & 0xFF_FFFF) | ((mpidr >> 8) & 0xFF00_0000)) as u32
 }
 
-/// [`packed_affinity`] undone: the four fields where `MPIDR_EL1` holds them,
-/// every other bit zero, which is how PSCI names a CPU.
-pub const fn unpacked_affinity(packed: u32) -> u64 {
-    (packed & 0xFF_FFFF) as u64 | ((packed >> 24) as u64) << 32
-}
-
 /// `ICC_SGI1R_EL1` raising SGI `intid` on the one CPU whose packed affinity is
 /// `target`: `Aff3`, `Aff2` and `Aff1` name its cluster, `RS` the range of
 /// sixteen `Aff0` values it falls in, and the target list its one bit there.
diff --git a/toyos-gicv3/tests/gicv3.rs b/toyos-gicv3/tests/gicv3.rs
index 373cc9c12..1348e21b6 100644
--- a/toyos-gicv3/tests/gicv3.rs
+++ b/toyos-gicv3/tests/gicv3.rs
@@ -1,4 +1,4 @@
-use toyos_gicv3::{find_redistributor, packed_affinity, sgi1r, sgi1r_others, unpacked_affinity, FRAME};
+use toyos_gicv3::{find_redistributor, packed_affinity, sgi1r, sgi1r_others, FRAME};
 
 #[test]
 fn affinity_drops_mpidr_flags_between_aff2_and_aff3() {
@@ -7,14 +7,6 @@ fn affinity_drops_mpidr_flags_between_aff2_and_aff3() {
     assert_eq!(packed_affinity(mpidr), 0x1234_5678);
 }
 
-#[test]
-fn unpacking_puts_each_field_back_and_no_flag() {
-    let mpidr = 0x12 << 32 | 1 << 31 | 1 << 30 | 1 << 24 | 0x34_5678;
-    assert_eq!(unpacked_affinity(packed_affinity(mpidr)), 0x12_0034_5678);
-    // QEMU `virt`'s seventeenth CPU, the first of its second cluster.
-    assert_eq!(unpacked_affinity(0x100), 0x100);
-}
-
 #[test]
 fn sgi_names_aff0_by_range_and_bit() {
     // Aff0 0x13 is range 1, bit 3.

Japabu and others added 2 commits October 2, 2026 18:43
…et nothing judges

The timer-stop issue owed its evidence a run. With the stop deleted from
`cpu_off` at 94dea67, `cargo test --test toyos-build -- virt_` exits
0, 19 of 19, so the issue now says what was measured and where the
patch and the run are.

The review of d1c216d noted that an AArch64 panic now resets after its
bound and nothing judges it. bb355a4 made the two lines it named true.
The reset itself was never measured: one boot now has, `test-late-panic`
on `virt` waited to QEMU's stop, and it ends 60.0 s after the arm line
in one SYSTEM_RESET and a `guest-reset`. A standing row costs 60 s of a
45 s suite, the actuator that shortened the bound is gone since #660,
and AArch64 has no metal, so the gap is an issue with that measurement
as its evidence and the row as its exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
The review of d1c216d asked for the S5 decode to be host-tested on
QEMU's DSDT, and bb355a4 answered that no fixture held one: capturing
it takes a boot, which was not an implementer's to run then. It is now.

`fixtures/qemu-11.1.1/dsdt.bin` is the 8500 bytes a `Profile::Headless`
guest of QEMU 11.1.1 held at 0x7f77a000, read by the monitor's
`pmemsave` in a test added by a patch and removed after its run, so no
byte came through the decoder. It is not the boot the other eight files
came off: that one's firmware put the tables 0x400000 higher. Read
beside them, this boot's MADT, HPET, DMAR and WAET are those files byte
for byte, and its RSDP, XSDT, FADT and MCFG differ in address bytes and
checksums alone, which `SOURCE` now says. `SOURCE` also loses its claim
that nothing in the crate decodes a DSDT.

`the_dsdt_names_the_sleep_type_that_powers_qemu_off` opens it at its own
address and reads `S5::SlpTyp(0)`, the value that boot's kernel logged
as `ACPI: PM1a=0x604 SLP_TYPa=0` and `machine_shutdown` powers QEMU off
with. `\_S4_`'s package sits 14 bytes ahead of `\_S5_`'s there and names
2, so a scan that took the wrong package answers 2.

`src/licence.rs` gains the file's row, as the other eight have one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
@Japabu

Japabu commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Two more patches, and the runs at the final head 17e636323, which differs from 94dea677c in two issue files, src/licence.rs's one row and toyos-acpi's fixture, its SOURCE and its test (git diff --stat 94dea677c 17e636323: 6 files, none under kernel/ or tests/).

arm command exit what it said
green, 17e636323 cargo test --test toyos-build 0 25 passed, 25 total (34.0s), load average 41 to 46
green, 17e636323 (cd toyos-acpi && cargo test --test fixtures) 0 12 passed
h3-s4-for-s5, 17e636323 the same 101 the_dsdt_names_the_sleep_type_that_powers_qemu_off ... FAILED, left: SlpTyp(2), right: SlpTyp(0)
s1-scout-tables, 2a548d834 cargo test --test toyos-build -- machine_scout 0 below

s1-scout-tables is the capture of toyos-acpi/fixtures/qemu-11.1.1/dsdt.bin and not a test of this branch: it walks from the kernel's ACPI: RSDP at line to every table by pmemsave, host-side.

  [scout] rsdp at 0x7f77e014, 36 bytes, summing to 0
  [scout] xsdt at 0x7f77d0e8, 84 bytes, summing to 0
  [scout] facp at 0x7f779000, 244 bytes, summing to 0
  [scout] apic at 0x7f778000, 128 bytes, summing to 0
  [scout] hpet at 0x7f777000, 56 bytes, summing to 0
  [scout] mcfg at 0x7f776000, 60 bytes, summing to 0
  [scout] dmar at 0x7f775000, 144 bytes, summing to 0
  [scout] waet at 0x7f774000, 40 bytes, summing to 0
  [scout] dsdt at 0x7f77a000, 8500 bytes, summing to 0
  [scout] [kernel 0.000 cpu0] ACPI: PM1a=0x604 SLP_TYPa=0
  [scout] [kernel 0.000 cpu0 boot] ACPI: reset register SystemIO 0xcf9 <- 0x0f

cmp of each captured table against the committed fixture: apic, hpet, dmar and waet identical; mcfg differs at offsets 9 and 47; facp at 9, 38, 42 and 142; xsdt at 9, 38, 46, 54, 62, 70 and 78; rsdp at 8, 18, 26 and 32. dsdt.bin is sha256 d183acae4048280f9c9cd8353045544d7d84319b304b7f65d4d0895e8b8ac06b.

h3-s4-for-s5.patch
diff --git a/toyos-acpi/src/dsdt.rs b/toyos-acpi/src/dsdt.rs
index 5ac350631..70a531ee4 100644
--- a/toyos-acpi/src/dsdt.rs
+++ b/toyos-acpi/src/dsdt.rs
@@ -27,7 +27,7 @@ pub enum S5 {
 /// byte, which is `ZeroOp` and `OneOp`'s value.
 pub fn s5_slp_typ<P: Phys>(dsdt: &Table<P>) -> S5 {
     for at in SDT_HEADER_LEN..dsdt.len() {
-        if (0..4).any(|i| dsdt.byte(at + i) != Some(b"_S5_"[i])) || dsdt.byte(at + 4) != Some(PACKAGE_OP) {
+        if (0..4).any(|i| dsdt.byte(at + i) != Some(b"_S4_"[i])) || dsdt.byte(at + 4) != Some(PACKAGE_OP) {
             continue;
         }
         // `PkgLength`'s lead byte counts the bytes after it in bits 7:6
s1-scout-tables.patch
diff --git a/tests/toyos.rs b/tests/toyos.rs
index c9d3f6ccf..020e6d4bc 100644
--- a/tests/toyos.rs
+++ b/tests/toyos.rs
@@ -174,6 +174,7 @@ const MACHINE_TESTS: &[&str] = &[
     // no way to turn it back on, so only a machine QEMU reports stopping can
     // be asked. `machine_soft_off_decoded` reads the T14's own decode.
     "machine_shutdown",
+    "machine_scout_tables",
 ];
 
 /// **The metal profile**: which registrations run on the ThinkPad T14, what
@@ -2252,6 +2253,47 @@ fn run_machine_test(name: &str, test_config: &Path) -> Result<(), String> {
         "iommu_virtio_platform" => common::iommu::iommu_virtio_platform(test_config),
         "nested_nmi_is_loud" => faults::nested_nmi_is_loud(test_config),
         "machine_shutdown" => power::machine_shutdown(test_config),
+        "machine_scout_tables" => {
+            let options = BootOptions { qmp: true, ..Default::default() };
+            let mut qemu = QemuInstance::boot_with_options(test_config, &[], &[], options);
+            let log = qemu.boot_log().to_string();
+            let said = |needle: &str| log.lines().find(|l| l.contains(needle)).unwrap_or_default().to_string();
+            let rsdp_line = said("ACPI: RSDP at 0x");
+            let rsdp_at = rsdp_line.rsplit("0x").next().unwrap_or_default().trim();
+            let rsdp_at = u64::from_str_radix(rsdp_at, 16).map_err(|e| format!("{rsdp_line:?}: {e}"))?;
+            let le = |b: &[u8]| b.iter().rev().fold(0u64, |v, &x| v << 8 | u64::from(x));
+            let out = compile::repo_root().join("target/scout-tables");
+            fs::create_dir_all(&out).map_err(|e| e.to_string())?;
+            let mut keep = |name: &str, at: u64, bytes: &[u8]| {
+                let sum = bytes.iter().fold(0u8, |s, &b| s.wrapping_add(b));
+                eprintln!("  [scout] {name} at {at:#x}, {} bytes, summing to {sum}", bytes.len());
+                fs::write(out.join(format!("{name}.bin")), bytes).expect("write a table");
+            };
+            let rsdp = qemu.guest_memory(rsdp_at, 36)?;
+            keep("rsdp", rsdp_at, &rsdp);
+            let xsdt_at = le(&rsdp[24..32]);
+            let len = le(&qemu.guest_memory(xsdt_at, 36)?[4..8]) as usize;
+            let xsdt = qemu.guest_memory(xsdt_at, len)?;
+            keep("xsdt", xsdt_at, &xsdt);
+            let mut dsdt_at = 0;
+            for entry in xsdt[36..].chunks_exact(8) {
+                let at = le(entry);
+                let len = le(&qemu.guest_memory(at, 36)?[4..8]) as usize;
+                let table = qemu.guest_memory(at, len)?;
+                let name = String::from_utf8_lossy(&table[..4]).to_lowercase();
+                keep(&name, at, &table);
+                if name == "facp" {
+                    let x = if len >= 148 { le(&table[140..148]) } else { 0 };
+                    dsdt_at = if x != 0 { x } else { le(&table[40..44]) };
+                }
+            }
+            let len = le(&qemu.guest_memory(dsdt_at, 36)?[4..8]) as usize;
+            let dsdt = qemu.guest_memory(dsdt_at, len)?;
+            keep("dsdt", dsdt_at, &dsdt);
+            eprintln!("  [scout] {}", said("ACPI: PM1a="));
+            eprintln!("  [scout] {}", said("ACPI: reset register"));
+            Ok(())
+        }
         other => Err(format!("unknown machine test {other}")),
     }
 }

@Japabu

Japabu commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

T14 evidence at 17e636323, run by the orchestrator: the one boot the round requested, jobcase, flashed and booted once (cargo run --bin toyos-metal -- --image … --readback … --fat32-check from the worktree, clean at the head before and after). Machine LENOVO 20W0003AMZ, BIOS N34ET71W (1.71).

  • image sha256 9a028ad1b4337e1a45a7b36cb7bc4387a8f3c2c7d0e3040ce0c7df750e9d12e5, armed with boot-deadline=120000
  • toyos-metal EXIT=0, verdict passed, "the machine booted ToyOS in 1152 ms", back on ssh after 37 s; toyos-fat32-check: the log partition's 35651584 bytes check out
  • the lines the x86-64 half of the seam is read from, in the readback's kernel.log:
    • :22 ACPI: reset register SystemIO 0xcf9 <- 0x06
    • :28 ACPI: PM1a=0x1804 SLP_TYPa=7
  • This is the head's green arm only; no mutation was booted on the machine, and the harness's judging of machine_soft_off_decoded over this readback has not been run.

@Japabu

Japabu commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Round 2 at 17e636323 (origin/main a31eec595 merged in as f4c04c52c). No CI job ran: the pull request is a draft and toolchain, host and guest skipped, so the gates are the development host's exits in the body: cargo run -- --ci host 0 (Host: 64 step(s), all green), cargo test --test toyos-build 0 (25 passed, 25 total). The x86-64 half rests on one T14 boot, jobcase at this head (issuecomment-5957341973), read below.

Earlier BLOCKERs:

  • kernel/src/arch/aarch64/power.rs:40 (m3 exited 0) — CLOSED. virt_off_names_the_cpus_left_on is green in the whole suite at 17e636323 (exit 0, 25 of 25). At 94dea677c m3 on it exits 1 (0 line(s) after the boot's last word, not ["power: cpu6 …", "power: cpu7 …"]) and n0 on it exits 1 (QEMU had not exited 15 s after it was asked to). git diff --stat 94dea677c 17e636323 names six files, none under kernel/ or tests/.
  • kernel/src/arch/aarch64/power.rs:61 (the timer stop) — CLOSED. issues/kernel/nothing-fails-without-the-timer-stop-before-cpu-off.md carries an owner, an exit and t0: cargo test --test toyos-build -- virt_ exit 0, 19 of 19, with the stop deleted, on the kernel this head has.

Net lines, git diff --shortstat origin/main...17e636323: 42 files, +1188 −346.

  • Production: kernel/ +485 −301, toyos-acpi/src and toyos-gicv3/src +56.
  • Tests, fixtures and harness: +551 −39. src/: +7. issues/: +89 −6.

BLOCKER

  • kernel/src/arch/aarch64/power.rs:16, tests/toyos.rs:1444,1484 — OTHERS_OFF gives every other CPU 100 ms to take SGI_OFF and be off, and three guest rows go red when it expires: virt_smp and virt_el1_smp on any line after the last word, virt_off_names_the_cpus_left_on on any CPU named but the spared two. That is a QEMU row judging a duration (root CLAUDE.md, "Timing and audio verdicts come only from metal"), a test asserting a kernel Budget never expires (tests/CLAUDE.md:16), and a private sibling of time::AP_START, which The boot CPU waits for an AP's echo as long as a dead CPU is: 5 s, not 100 ms #670 (in this branch since f4c04c52c) moved from this same 100 ms to DEAF_CPU's span on a recorded red: virt_smp, SMP: cpu7 mpidr=0x7 did not echo within 100ms, 7 of 8, on a loaded host. The body calls the number "a choice, not a measurement". Closes on: D1 below red on virt_smp at 17e636323; at the answering head D1 green on all three rows, m1, m2 and m3 still red, and the size of virt_off_names_the_cpus_left_on's trace file and the row's duration stated (the body leaves the size unmeasured at 100 ms, and a longer wait polls AFFINITY_INFO longer).
  • PR body, "Real hardware, for the x86-64 half: not read yet" and the --metal --metal-readback row at exit 2 — the hardware reading is not in the body, and machine_reboot and machine_soft_off_decoded have never been judged: their only run staged an image. The boot has since run (toyos-metal exit 0; the readback's kernel.log:22 ACPI: reset register SystemIO 0xcf9 <- 0x06, :28 ACPI: PM1a=0x1804 SLP_TYPa=7, the top value S5::Wide lets through; loader.log:51 the last boot read DONE). Closes on cargo test --test toyos-build -- --metal --metal-readback <dir> machine_ over that readback, with its exit and both rows' lines, in the body.

NOTE

  • kernel/src/arch/aarch64/psci.rs:79-93 — Error::code writes Error::of's nine rows a second time, backwards, for four callers that only print the number: one table declared twice. cpu_off, system_off, system_reset and affinity_info's refusal return the i32 that call answered, and code goes.
  • tests/toyos.rs:3532 — PAST_THE_STOP takes either word, so the one standing row that sees an AArch64 panic after psci::init (virt_fatal_halts_the_others_first) stays green if that panic holds again or says "unless a key is pressed". Hold it to panic: rebooting in 60 s, timed by: the arm line costs no 60 s.
  • issues/panic-path/nothing-judges-that-an-aarch64-panic-resets-at-its-bound.md — against Guest tests: no standing test was cut (s0 was on no head), SYSTEM_RESET through reset_now is virt_reboot's with m5 red, and the reset at the bound is recorded with an owner, s0's command, exit and four lines, and an exit a test can fail. With the note above taken, what stays unheld is the bound's expiry alone. Nothing else to change.
  • kernel/src/arch/aarch64/boot.rs:342 — psci::init runs after percpu::init_bsp and irqchip::init, so every panic in GIC bring-up holds its panel for good, where arch/x86_64/power.rs:25-31 puts init_reset first so that no reportable panic lacks a reset. Call it first in interrupts, or say at the site what it needs from the two steps before it.
  • PR body, "Every AArch64 row below judges what a PSCI provider did with a call the kernel made" — false of virt_reboot_refused_without_psci, whose kernel makes no call after PSCI_VERSION; that row's reason is its own sentence.
  • f4c04c52c's resolution — git diff origin/main...17e636323 is the branch's delta alone and reverts no hunk of main's. QmpShutdown, await_exit and QemuInstance::budget each have a caller at this head and no sibling in tests/common/qemu.rs; arch/aarch64/power.rs writes each line under one panic_registers() hold and drops it before each halt. Nothing to change.

REMOVE

  • PR body, Metal — "The T14's \_S5_ and PM1a block have never been through S5::Wide or the port-space refusal." — false since the jobcase boot.
  • PR body, "What I'm unsure of" — "The T14 has not read this branch." — false since the jobcase boot.

D1 passes git apply --check at 17e636323: every CPU that takes the power-off's SGI reaches CPU_OFF 200 ms late, which is a vCPU its host served late. The implementer runs it.

diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 9bfeb6c..dc0ec82 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -91,6 +91,10 @@ pub(super) fn cpu_off() -> ! {
     let Some(psci) = psci::conduit() else {
         unreachable!("power: SGI_OFF is raised only by `off`, which holds a conduit")
     };
+    let late = Deadline::at(crate::clock::now() + Duration::from_millis(200));
+    while !late.reached(crate::clock::now()) {
+        core::hint::spin_loop();
+    }
     let refusal = psci.cpu_off();
     let mut uart = serial::panic_registers();
     uart.write(b"power: cpu");

SEND BACK

…illisecond

`off` gave every other CPU 100 ms to take `SGI_OFF` and be off by
`AFFINITY_INFO`, on a private `Budget` whose number was a choice. A vCPU its
host serves late is slow and not dead: #670 moved `time::AP_START` off the
same 100 ms on a recorded red. The wait now ends at `time::DEAF_CPU`'s span,
the bound the tree already has for a CPU that does not take an interrupt, and
declares nothing of its own. Its end stays a degraded answer and no panic:
what keeps a CPU on may be firmware's refusal of `CPU_OFF`, and the machine
was asked to end.

The poll was a bare spin of firmware calls. At 100 ms
`virt_off_names_the_cpus_left_on`'s trace was 6199064 bytes, 50812 calls; at
five seconds that loop is fifty times it. `AFFINITY_INFO` is now asked again
once per `ASK_AGAIN`, a 1 ms `Cadence`, as the xHCI port poll is paced: the
same row's trace is 610488 bytes, 5004 calls, and the row says its count.

Review of 17e6363, the rest:
- `psci::Error::code` wrote `Error::of`'s table a second time for callers
  that print the number. `cpu_off`, `system_off`, `system_reset` and
  `affinity_info`'s refusal answer the `i32` the call returned.
- `psci::init` runs first in `interrupts`, as x86-64's `init_reset` does, so
  a panic in the per-CPU block or the GIC's bring-up has a reset.
- `virt_fatal_halts_the_others_first` is held to the armed line as a machine
  with a reset and no panic key says it, `panic: rebooting in 60 s, timed
  by`, where it took the held line too. The issue that records what stays
  unheld says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
@Japabu

Japabu commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Guest runs and negative controls at f4f6a453d, on the development host, an Apple-silicon Mac, QEMU 11.1.1: the two Profile::Virt rows under HVF, every other virt_ row under TCG by its profile. Three scripts ran them, with git status --porcelain --ignore-submodules=none empty before and after each arm: the patch checked with git apply --check, applied, the command run, the patch reversed.

arm command exit what it said
green cargo test --test toyos-build -- virt_ 0 19 passed, 19 total (21.5s); virt_off_names_the_cpus_left_on 10 s, 4333 PSCI call(s) traced
d1-late-cpu-off … -- virt_ 0 19 passed, 19 total (26.4s): virt_smp 5 s, virt_el1_smp 5 s, virt_off_names_the_cpus_left_on 10 s, 4997 PSCI call(s) traced
d1-late-cpu-off on 17e636323 … -- virt_ 1 16 passed, 3 failed: virt_smp and virt_el1_smp each 7 line(s) after the boot's last word, not [], virt_off_names_the_cpus_left_on 7 lines where 2 are owed; cpu1 to cpu7 each named not off
m1-no-cpu-off … -- virt_smp 1 7 line(s) after the boot's last word, not []: cpu1 to cpu7 each named not off; the row took 8 s
m2-no-off-sgi … -- virt_smp 1 the same seven lines; 8 s
m3-on-is-off … -- virt_off_names 1 0 line(s) after the boot's last word, not ["power: cpu6 is not off …", "power: cpu7 is not off …"]
m4-off-is-reset … -- virt_smp 1 QEMU stopped this guest for Some("guest-reset"), not "guest-shutdown"
m5-reset-is-off … -- virt_reboot 1 virt_reboot: QEMU stopped this guest for Some("guest-shutdown"), not "guest-reset"; virt_reboot_refused_without_psci passed
m7-can-reset-true … -- virt_reboot_refused 1 STALLED: waiting for the job reboot to end — it went quiet
p1-can-reset-false … -- virt_fatal 1 STALLED: waiting for the fatal path's line past the stop — it went quiet
p2-panic-keys-true … -- virt_fatal 1 the same
c1-affinity-refused … -- virt_smp 1 7 line(s) after the boot's last word, not []: power: cpu1's AFFINITY_INFO answered -2; SYSTEM_OFF regardless to cpu7's
n0-whole … -- virt_off_names 1 QEMU had not exited 15 s after it was asked to
t0-no-timer-stop … -- virt_ 0 19 passed, 19 total (18.7s), as at 94dea677c: the tracked issue's evidence still stands
s2-scout-gic-panic-resets … -- virt_scout 0 see below
s3-scout-gic-panic-psci-last … -- virt_scout 1 see below

The host's one-minute load average ran from 5 to 29 across them; no run was marked INVL.

d1-late-cpu-off is the review's patch, unchanged. Its arm on 17e636323 took this worktree's tree back to that commit with git diff --binary HEAD 17e636323 applied as a patch (git diff --stat 17e636323 printed nothing after it), D1 on top, both reversed after the run.

p1 and p2 are the two ways the review named for virt_fatal_halts_the_others_first to stay green on a wrong panic: AArch64 with no reset, whose panic holds, and a panic path that says it reads a key. c1 asks AFFINITY_INFO of an affinity no CPU has, which QEMU's PSCI refuses with -2, the code psci::Error::of names InvalidParameters: a refusal's code on the console, which no standing row produces.

t0-no-timer-stop is the earlier round's patch, unchanged: irqchip::stop_timer(); deleted from cpu_off.

n0-whole is the whole change reverted onto its base: git diff --binary HEAD a31eec595 -- kernel/ toyos-gicv3/ toyos-acpi/src ':(exclude)kernel/src/actuator.rs' at f4f6a453d, 53,785 bytes, sha256 6b45fd5de382e10a8de31f14ff3e7094847a5a9e3a560abacecf4e3251cd1490. It is the next comment.

s2 and s3 are one-boot measurements and not tests of this branch: a panic between percpu::init_bsp and irqchip::init, on virt at EL2, waited to QEMU's stop. s2 is the head with that panic; s3 is the same with psci::init back after enable_interrupts, where 17e636323 had it. Their first form, an unconditional panic!, built as s2 and not as s3 (unused variable: rsdp_addr under -D warnings), so both were rewritten with the panic behind black_box(true) and run again; these are the second runs.

s2, exit 0:
  [scout] [kernel 0.000 cpu0 boot] PSCI: 1.1 through SMC
  [scout] [kernel 0.000 cpu0] percpu: BSP cpu_id=0 mpidr=0x0
  [scout] scout: the GIC's bring-up fails here
  [scout] [kernel 0.000 cpu0] panic: rebooting in 60 s, timed by the counter frequency the CPU states
  [scout] QEMU's stop reason Some("guest-reset"), 60.0s after the ready marker was read
  [scout] said after the ready marker: Ok("\npanic: the bound is over: returning this machine to firmware\n")
  [scout] CPU_OFF, SYSTEM_OFF and SYSTEM_RESET calls traced: [(84000009, 0)]
  PASS  virt_scout_gic_panic  (61s)

s3, exit 1:
  [scout] (not said)
  [scout] [kernel 0.000 cpu0] percpu: BSP cpu_id=0 mpidr=0x0
  [scout] scout: the GIC's bring-up fails here
  [scout] [kernel 0.000 cpu0] panic: holding this panel, timed by the counter frequency the CPU states: this kernel has no reset to hand the machine back to firmware with
FAIL virt_scout_gic_panic: a panic in the GIC's bring-up did not arm a reset

The trace's size, by a probe that prints it, applied to tests/toyos.rs and reversed after each run (cargo test --test toyos-build -- virt_, exit 0 on both heads, 19 passed, 19 total):

head virt_off_left_on.psci calls the row virt_smp-SMC.psci virt_smp-HVC.psci
17e636323, 100 ms, a bare spin 6,199,064 bytes 50,812 4 s 3,172 bytes, 26 calls 248,880 bytes, 2,040 calls
f4f6a453d, DEAF_CPU's span, asked each millisecond 610,122 bytes 5,001 9 s 3,172 bytes, 26 calls 3,050 bytes, 25 calls

A second probe run at f4f6a453d, of that row alone (… -- virt_off_names, exit 0), read 610,366 bytes, 5,003 calls, 8 s. Across this round's runs at the head the row's own line read 4,333 to 5,003 calls and the row took 8 to 10 s.

The patches, each as git diff wrote it at f4f6a453d:

d1-late-cpu-off.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 9bfeb6c..dc0ec82 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -91,6 +91,10 @@ pub(super) fn cpu_off() -> ! {
     let Some(psci) = psci::conduit() else {
         unreachable!("power: SGI_OFF is raised only by `off`, which holds a conduit")
     };
+    let late = Deadline::at(crate::clock::now() + Duration::from_millis(200));
+    while !late.reached(crate::clock::now()) {
+        core::hint::spin_loop();
+    }
     let refusal = psci.cpu_off();
     let mut uart = serial::panic_registers();
     uart.write(b"power: cpu");
m1-no-cpu-off.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 91144dcae..a6107bcec 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -100,14 +100,7 @@ pub(super) fn cpu_off() -> ! {
     let Some(psci) = psci::conduit() else {
         unreachable!("power: SGI_OFF is raised only by `off`, which holds a conduit")
     };
-    let refusal = psci.cpu_off();
-    let mut uart = serial::panic_registers();
-    uart.write(b"power: cpu");
-    uart.dec(u64::from(percpu::cpu_id()));
-    uart.write(b"'s CPU_OFF answered ");
-    say_code(&mut uart, refusal);
-    uart.write(b"\n");
-    drop(uart);
+    let _ = psci;
     cpu::halt()
 }
 
m2-no-off-sgi.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 91144dcae..76d187c8f 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -43,7 +43,6 @@ pub fn reset() -> ! {
 pub fn off() -> ! {
     cpu::disable_interrupts();
     let Some(psci) = psci::conduit() else { cpu::halt() };
-    irqchip::off_all_but_self();
     // The tripwire's span without its panic: what keeps a CPU on may be
     // firmware's refusal, and firmware's word never panics this kernel.
     let deadline = Deadline::at(crate::clock::now() + Duration::from_nanos(DEAF_CPU.nanos()));
m3-on-is-off.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 91144dcae..e178bc046 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -52,7 +52,7 @@ pub fn off() -> ! {
         let mpidr = toyos_gicv3::unpacked_affinity(crate::smp::hardware_id(cpu));
         loop {
             match psci.affinity_info(mpidr) {
-                Ok(psci::Affinity::Off) => break,
+                Ok(psci::Affinity::Off | psci::Affinity::On) => break,
                 Ok(_) if !deadline.reached(crate::clock::now()) => {
                     let again = Deadline::at(crate::clock::now() + ASK_AGAIN.duration());
                     while !again.reached(crate::clock::now()) {
m4-off-is-reset.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 91144dcae..9c5740360 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -78,7 +78,7 @@ pub fn off() -> ! {
             }
         }
     }
-    let refusal = psci.system_off();
+    let refusal = psci.system_reset();
     let mut uart = serial::panic_registers();
     uart.write(b"power: SYSTEM_OFF answered ");
     say_code(&mut uart, refusal);
m5-reset-is-off.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 91144dcae..ed58135a8 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -26,7 +26,7 @@ pub fn can_reset() -> bool {
 /// wedge calls this: the call takes no lock, and only its refusal is said.
 pub fn reset() -> ! {
     if let Some(psci) = psci::conduit() {
-        let refusal = psci.system_reset();
+        let refusal = psci.system_off();
         let mut uart = serial::panic_registers();
         uart.write(b"power: SYSTEM_RESET answered ");
         say_code(&mut uart, refusal);
m7-can-reset-true.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 91144dcae..bc3e0f667 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -19,7 +19,7 @@ const ASK_AGAIN: Cadence = Cadence::every(
 );
 
 pub fn can_reset() -> bool {
-    psci::conduit().is_some()
+    true
 }
 
 /// `SYSTEM_RESET`, which asks nothing of the other CPUs (DEN0022 §5.12.2). A
p1-can-reset-false.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 91144dcae..a9acccf72 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -19,7 +19,7 @@ const ASK_AGAIN: Cadence = Cadence::every(
 );
 
 pub fn can_reset() -> bool {
-    psci::conduit().is_some()
+    false
 }
 
 /// `SYSTEM_RESET`, which asks nothing of the other CPUs (DEN0022 §5.12.2). A
p2-panic-keys-true.patch
diff --git a/kernel/src/arch/aarch64/keyboard_controller.rs b/kernel/src/arch/aarch64/keyboard_controller.rs
index e04e19a2e..a3e0c1577 100644
--- a/kernel/src/arch/aarch64/keyboard_controller.rs
+++ b/kernel/src/arch/aarch64/keyboard_controller.rs
@@ -2,7 +2,7 @@
 //! keyboards are USB, and the panic panel's key poll reads nothing here.
 
 /// Whether the panic path reads a key here.
-pub const PANIC_KEYS: bool = false;
+pub const PANIC_KEYS: bool = true;
 
 /// No byte ever waits.
 pub fn poll_byte() -> Option<(u8, bool)> {
c1-affinity-refused.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 91144dcae..e48e3279e 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -51,7 +51,7 @@ pub fn off() -> ! {
     for cpu in (0..crate::smp::cpu_count()).filter(|&cpu| cpu != me) {
         let mpidr = toyos_gicv3::unpacked_affinity(crate::smp::hardware_id(cpu));
         loop {
-            match psci.affinity_info(mpidr) {
+            match psci.affinity_info(mpidr | 0xFF00) {
                 Ok(psci::Affinity::Off) => break,
                 Ok(_) if !deadline.reached(crate::clock::now()) => {
                     let again = Deadline::at(crate::clock::now() + ASK_AGAIN.duration());
s2-scout-gic-panic-resets.patch
diff --git a/kernel/src/arch/aarch64/boot.rs b/kernel/src/arch/aarch64/boot.rs
index afdceef98..4bd13b9f6 100644
--- a/kernel/src/arch/aarch64/boot.rs
+++ b/kernel/src/arch/aarch64/boot.rs
@@ -339,6 +339,9 @@ pub fn interrupts(rsdp_addr: u64) -> Platform {
     // First, so a panic in anything below has a reset to end its bound with.
     super::psci::init(rsdp_addr);
     super::percpu::init_bsp();
+    if core::hint::black_box(true) {
+        panic!("scout: the GIC's bring-up fails here");
+    }
     let gic = super::irqchip::init(rsdp_addr);
     super::cpu::enable_interrupts();
     Platform { gic }
diff --git a/tests/toyos.rs b/tests/toyos.rs
index 1a54f5f95..5949bcf00 100644
--- a/tests/toyos.rs
+++ b/tests/toyos.rs
@@ -158,6 +158,7 @@ const SCREEN_TESTS: &[(&str, qemu::Profile)] = &[
     ("virt_reboot", qemu::Profile::VirtEl2),
     ("virt_off_names_the_cpus_left_on", qemu::Profile::VirtEl2),
     ("virt_reboot_refused_without_psci", qemu::Profile::VirtEl2),
+    ("virt_scout_gic_panic", qemu::Profile::VirtEl2),
 ];
 
 /// The tests whose machine shape *is* the test, each on a boot of its own.
@@ -1987,6 +1988,48 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re
         "virt_reboot" => virt_reboot(profile),
         "virt_off_names_the_cpus_left_on" => virt_off_names_the_cpus_left_on(profile),
         "virt_reboot_refused_without_psci" => virt_reboot_refused_without_psci(profile),
+        "virt_scout_gic_panic" => {
+            let trace = common::lane::dir().join("virt_scout_gic_panic.psci");
+            let _ = fs::remove_file(&trace);
+            let mut qemu = QemuInstance::boot_with_options(
+                test_config,
+                &[],
+                &[],
+                BootOptions {
+                    profile,
+                    qmp: true,
+                    psci_trace: Some(trace.clone()),
+                    ready_marker: "panic: ",
+                    ..Default::default()
+                },
+            );
+            let bound = Duration::from_millis(toyos_tco::PANIC_BOUND_MS);
+            let mut stop = qemu::QmpShutdown::open(qemu.qmp_socket(), qemu.budget(bound + qemu::GUEST_QUIET));
+            let log = qemu.boot_log().to_string();
+            for said in ["PSCI: ", "percpu: BSP", "scout: ", "panic: "] {
+                eprintln!("  [scout] {}", log.lines().find(|l| l.contains(said)).unwrap_or("(not said)"));
+            }
+            if !log.contains("panic: rebooting in") {
+                return Err("a panic in the GIC's bring-up did not arm a reset".to_string());
+            }
+            let from = Instant::now();
+            let stopped = stop.reason();
+            let waited = from.elapsed();
+            let by = qemu.budget(qemu::GUEST_QUIET);
+            let said = qemu.await_exit(by);
+            let traced = fs::read_to_string(&trace).map_err(|e| format!("read the PSCI trace: {e}"))?;
+            let calls: Vec<(u64, u64)> = psci_calls(&traced)?
+                .into_iter()
+                .filter(|&(function, _)| [PSCI_CPU_OFF, PSCI_SYSTEM_OFF, PSCI_SYSTEM_RESET].contains(&function))
+                .collect();
+            eprintln!("  [scout] QEMU's stop reason {stopped:?}, {waited:.1?} after the ready marker was read");
+            eprintln!("  [scout] said after the ready marker: {said:?}");
+            eprintln!("  [scout] CPU_OFF, SYSTEM_OFF and SYSTEM_RESET calls traced: {calls:x?}");
+            if stopped.as_deref() != Some("guest-reset") || calls != [(PSCI_SYSTEM_RESET, 0)] {
+                return Err("a panic in the GIC's bring-up did not end in one SYSTEM_RESET and a guest-reset".to_string());
+            }
+            Ok(())
+        }
         "screen_fatal_behind_a_painter" => {
             // The fatal halt with a painter holding the panel's latch and
             // never giving it back — which is what a painter is when the halt
s3-scout-gic-panic-psci-last.patch
diff --git a/kernel/src/arch/aarch64/boot.rs b/kernel/src/arch/aarch64/boot.rs
index afdceef98..497e91b36 100644
--- a/kernel/src/arch/aarch64/boot.rs
+++ b/kernel/src/arch/aarch64/boot.rs
@@ -336,11 +336,13 @@ pub struct Platform {
 /// Interrupt delivery: this CPU's per-CPU block, the GIC and the timer's
 /// interrupt, and interrupts unmasked. The syscall gate is the vectors' own.
 pub fn interrupts(rsdp_addr: u64) -> Platform {
-    // First, so a panic in anything below has a reset to end its bound with.
-    super::psci::init(rsdp_addr);
     super::percpu::init_bsp();
+    if core::hint::black_box(true) {
+        panic!("scout: the GIC's bring-up fails here");
+    }
     let gic = super::irqchip::init(rsdp_addr);
     super::cpu::enable_interrupts();
+    super::psci::init(rsdp_addr);
     Platform { gic }
 }
 
diff --git a/tests/toyos.rs b/tests/toyos.rs
index 1a54f5f95..5949bcf00 100644
--- a/tests/toyos.rs
+++ b/tests/toyos.rs
@@ -158,6 +158,7 @@ const SCREEN_TESTS: &[(&str, qemu::Profile)] = &[
     ("virt_reboot", qemu::Profile::VirtEl2),
     ("virt_off_names_the_cpus_left_on", qemu::Profile::VirtEl2),
     ("virt_reboot_refused_without_psci", qemu::Profile::VirtEl2),
+    ("virt_scout_gic_panic", qemu::Profile::VirtEl2),
 ];
 
 /// The tests whose machine shape *is* the test, each on a boot of its own.
@@ -1987,6 +1988,48 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re
         "virt_reboot" => virt_reboot(profile),
         "virt_off_names_the_cpus_left_on" => virt_off_names_the_cpus_left_on(profile),
         "virt_reboot_refused_without_psci" => virt_reboot_refused_without_psci(profile),
+        "virt_scout_gic_panic" => {
+            let trace = common::lane::dir().join("virt_scout_gic_panic.psci");
+            let _ = fs::remove_file(&trace);
+            let mut qemu = QemuInstance::boot_with_options(
+                test_config,
+                &[],
+                &[],
+                BootOptions {
+                    profile,
+                    qmp: true,
+                    psci_trace: Some(trace.clone()),
+                    ready_marker: "panic: ",
+                    ..Default::default()
+                },
+            );
+            let bound = Duration::from_millis(toyos_tco::PANIC_BOUND_MS);
+            let mut stop = qemu::QmpShutdown::open(qemu.qmp_socket(), qemu.budget(bound + qemu::GUEST_QUIET));
+            let log = qemu.boot_log().to_string();
+            for said in ["PSCI: ", "percpu: BSP", "scout: ", "panic: "] {
+                eprintln!("  [scout] {}", log.lines().find(|l| l.contains(said)).unwrap_or("(not said)"));
+            }
+            if !log.contains("panic: rebooting in") {
+                return Err("a panic in the GIC's bring-up did not arm a reset".to_string());
+            }
+            let from = Instant::now();
+            let stopped = stop.reason();
+            let waited = from.elapsed();
+            let by = qemu.budget(qemu::GUEST_QUIET);
+            let said = qemu.await_exit(by);
+            let traced = fs::read_to_string(&trace).map_err(|e| format!("read the PSCI trace: {e}"))?;
+            let calls: Vec<(u64, u64)> = psci_calls(&traced)?
+                .into_iter()
+                .filter(|&(function, _)| [PSCI_CPU_OFF, PSCI_SYSTEM_OFF, PSCI_SYSTEM_RESET].contains(&function))
+                .collect();
+            eprintln!("  [scout] QEMU's stop reason {stopped:?}, {waited:.1?} after the ready marker was read");
+            eprintln!("  [scout] said after the ready marker: {said:?}");
+            eprintln!("  [scout] CPU_OFF, SYSTEM_OFF and SYSTEM_RESET calls traced: {calls:x?}");
+            if stopped.as_deref() != Some("guest-reset") || calls != [(PSCI_SYSTEM_RESET, 0)] {
+                return Err("a panic in the GIC's bring-up did not end in one SYSTEM_RESET and a guest-reset".to_string());
+            }
+            Ok(())
+        }
         "screen_fatal_behind_a_painter" => {
             // The fatal halt with a painter holding the panel's latch and
             // never giving it back — which is what a painter is when the halt
probe-trace-size.patch
diff --git a/tests/toyos.rs b/tests/toyos.rs
--- a/tests/toyos.rs
+++ b/tests/toyos.rs
@@ -1478,6 +1478,7 @@ fn ended_through_psci(
     let after: Vec<&str> = lines[at + 1..].iter().copied().filter(|l| !l.trim().is_empty()).collect();
     let traced = fs::read_to_string(trace).map_err(|e| format!("read the PSCI trace: {e}"))?;
     let calls = psci_calls(&traced)?;
+    eprintln!("  [probe] {}: {} bytes, {} calls; first line {:?}", trace.display(), traced.len(), calls.len(), traced.lines().next());
     let want = said_after(&calls);
     if !after.iter().map(|l| l.trim_end()).eq(want.iter().map(String::as_str)) {
         return Err(format!(
t0-no-timer-stop.patch
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
index 9bfeb6cf3..bcdf440b2 100644
--- a/kernel/src/arch/aarch64/power.rs
+++ b/kernel/src/arch/aarch64/power.rs
@@ -84,7 +84,6 @@ pub fn off() -> ! {
 /// a refusal leaves this CPU halted with nothing signalled to it, and [`off`]
 /// names it still on.
 pub(super) fn cpu_off() -> ! {
-    irqchip::stop_timer();
     if crate::actuator::power_off_spares_the_last_two() && percpu::cpu_id() + 2 >= crate::smp::cpu_count() {
         cpu::halt()
     }

@Japabu

Japabu commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

n0-whole.patch at f4f6a453d: git diff --binary HEAD a31eec595 -- kernel/ toyos-gicv3/ toyos-acpi/src ':(exclude)kernel/src/actuator.rs', 53,785 bytes, sha256 6b45fd5de382e10a8de31f14ff3e7094847a5a9e3a560abacecf4e3251cd1490.

n0-whole.patch
diff --git a/kernel/src/arch/aarch64/boot.rs b/kernel/src/arch/aarch64/boot.rs
index afdceef98..97f358f9f 100644
--- a/kernel/src/arch/aarch64/boot.rs
+++ b/kernel/src/arch/aarch64/boot.rs
@@ -328,20 +328,19 @@ pub fn reserved() -> Region {
 }
 
 /// What the boot learns bringing interrupts up and hands later steps: the
-/// other CPUs the MADT names.
+/// other CPUs the MADT names, and how to start them.
 pub struct Platform {
     gic: super::irqchip::Gic,
+    psci: Option<super::psci::Conduit>,
 }
 
 /// Interrupt delivery: this CPU's per-CPU block, the GIC and the timer's
 /// interrupt, and interrupts unmasked. The syscall gate is the vectors' own.
 pub fn interrupts(rsdp_addr: u64) -> Platform {
-    // First, so a panic in anything below has a reset to end its bound with.
-    super::psci::init(rsdp_addr);
     super::percpu::init_bsp();
     let gic = super::irqchip::init(rsdp_addr);
     super::cpu::enable_interrupts();
-    Platform { gic }
+    Platform { gic, psci: super::psci::init(rsdp_addr) }
 }
 
 /// The clock: the generic timer's count, at the rate firmware states in
@@ -369,7 +368,7 @@ pub fn platform_devices(_rsdp_addr: u64) {}
 
 /// Every other CPU, running.
 pub fn start_other_cpus(platform: &Platform, _args: &KernelArgs) {
-    super::smp::start(&platform.gic);
+    super::smp::start(&platform.gic, platform.psci);
 }
 
 /// The interrupt-controller selftests an actuator asks for.
diff --git a/kernel/src/arch/aarch64/irqchip.rs b/kernel/src/arch/aarch64/irqchip.rs
index 09e282734..7a2345f23 100644
--- a/kernel/src/arch/aarch64/irqchip.rs
+++ b/kernel/src/arch/aarch64/irqchip.rs
@@ -40,8 +40,6 @@ pub(super) enum Intid {
     Kick = 0,
     /// Stops a CPU for good: [`stop_other_cpus`]'s.
     Halt,
-    /// Turns a CPU off for the machine's power-off: [`off_all_but_self`]'s.
-    Off,
     /// What `irq-storm` floods this CPU with.
     Storm,
     Hda,
@@ -50,7 +48,6 @@ pub(super) enum Intid {
 
 pub(super) const SGI_KICK: u32 = Intid::Kick as u32;
 pub(super) const SGI_HALT: u32 = Intid::Halt as u32;
-pub(super) const SGI_OFF: u32 = Intid::Off as u32;
 #[cfg(feature = "boot-actuators")]
 pub(super) const SGI_STORM: u32 = Intid::Storm as u32;
 
@@ -216,7 +213,7 @@ pub fn init_cpu(frame: u64) {
     redistributor.write_u32(GICR_ICFGR1, edge);
 
     stop_timer_hardware();
-    let enabled = 1 << SGI_KICK | 1 << SGI_HALT | 1 << SGI_OFF | 1 << timer;
+    let enabled = 1 << SGI_KICK | 1 << SGI_HALT | 1 << timer;
     #[cfg(feature = "boot-actuators")]
     let enabled = enabled | 1 << SGI_STORM;
     redistributor.write_u32(GICR_ISENABLER0, enabled);
@@ -295,22 +292,13 @@ pub fn kick_cpu(cpu: u32) {
     sgi(SGI_KICK, crate::smp::hardware_id(cpu));
 }
 
-pub fn kick_all_but_self() {
-    all_but_self(SGI_KICK);
-}
-
-/// Raise the power-off's SGI on every other CPU the roster holds.
-pub(super) fn off_all_but_self() {
-    all_but_self(SGI_OFF);
-}
-
 // Each by name, not with `IRM`'s broadcast: that reaches an AP which echoed too
 // late and halted with its SGIs enabled, where a kick left pending wakes the
 // masked `wfi` at once, for good.
-fn all_but_self(intid: u32) {
+pub fn kick_all_but_self() {
     let me = percpu::cpu_id();
     for cpu in (0..crate::smp::cpu_count()).filter(|&cpu| cpu != me) {
-        sgi(intid, crate::smp::hardware_id(cpu));
+        kick_cpu(cpu);
     }
 }
 
diff --git a/kernel/src/arch/aarch64/keyboard_controller.rs b/kernel/src/arch/aarch64/keyboard_controller.rs
index e04e19a2e..ef6462509 100644
--- a/kernel/src/arch/aarch64/keyboard_controller.rs
+++ b/kernel/src/arch/aarch64/keyboard_controller.rs
@@ -1,9 +1,6 @@
 //! The platform's own keyboard controller. An Arm machine has none: its
 //! keyboards are USB, and the panic panel's key poll reads nothing here.
 
-/// Whether the panic path reads a key here.
-pub const PANIC_KEYS: bool = false;
-
 /// No byte ever waits.
 pub fn poll_byte() -> Option<(u8, bool)> {
     None
diff --git a/kernel/src/arch/aarch64/mod.rs b/kernel/src/arch/aarch64/mod.rs
index 9440620de..2257f3a94 100644
--- a/kernel/src/arch/aarch64/mod.rs
+++ b/kernel/src/arch/aarch64/mod.rs
@@ -26,8 +26,8 @@ pub mod irqchip;
 pub mod keyboard_controller;
 pub mod paging;
 pub mod percpu;
+pub mod pio;
 pub mod pmu;
-pub mod power;
 pub mod psci;
 pub mod rtc;
 pub mod smp;
diff --git a/kernel/src/arch/aarch64/pio.rs b/kernel/src/arch/aarch64/pio.rs
new file mode 100644
index 000000000..b2886833d
--- /dev/null
+++ b/kernel/src/arch/aarch64/pio.rs
@@ -0,0 +1,15 @@
+//! The I/O port space: AArch64 has none.
+
+/// Whether this architecture has an I/O port space at all. Firmware tables
+/// that name a port are only honoured where it does.
+pub const EXISTS: bool = false;
+
+/// Never called: every caller checks [`EXISTS`] first.
+pub unsafe fn outb(_port: u16, _value: u8) {
+    unreachable!("AArch64 has no I/O port space")
+}
+
+/// Never called: every caller checks [`EXISTS`] first.
+pub unsafe fn outw(_port: u16, _value: u16) {
+    unreachable!("AArch64 has no I/O port space")
+}
diff --git a/kernel/src/arch/aarch64/power.rs b/kernel/src/arch/aarch64/power.rs
deleted file mode 100644
index 91144dcae..000000000
--- a/kernel/src/arch/aarch64/power.rs
+++ /dev/null
@@ -1,120 +0,0 @@
-//! Reset and power-off: PSCI's `SYSTEM_RESET` and `SYSTEM_OFF` (Arm DEN0022
-//! §5.12, §5.10), through the conduit [`super::psci`] kept. A machine without
-//! PSCI has neither, and holds where either is asked for.
-//!
-//! Every line here goes straight to the UART, each whole under one fatal
-//! path's hold of its registers ([`serial::panic_registers`]): nothing drains
-//! the log ring after any of these calls. The hold is let go before a halt,
-//! since a CPU halted holding it costs every later line the whole bound.
-
-use super::{cpu, irqchip, percpu, psci};
-use crate::drivers::serial;
-use crate::time::{Cadence, Deadline, Duration, DEAF_CPU};
-
-/// How often [`off`] asks again of a CPU PSCI still answers on: PSCI says a
-/// CPU is off only to one that asks.
-const ASK_AGAIN: Cadence = Cadence::every(
-    Duration::from_millis(1),
-    "one call into firmware per period, and a power-off at most one period after the last CPU is off",
-);
-
-pub fn can_reset() -> bool {
-    psci::conduit().is_some()
-}
-
-/// `SYSTEM_RESET`, which asks nothing of the other CPUs (DEN0022 §5.12.2). A
-/// wedge calls this: the call takes no lock, and only its refusal is said.
-pub fn reset() -> ! {
-    if let Some(psci) = psci::conduit() {
-        let refusal = psci.system_reset();
-        let mut uart = serial::panic_registers();
-        uart.write(b"power: SYSTEM_RESET answered ");
-        say_code(&mut uart, refusal);
-        uart.write(b"; holding\n");
-    }
-    cpu::halt()
-}
-
-/// `SYSTEM_OFF`, once every other CPU the roster holds has turned itself off
-/// with `CPU_OFF` and PSCI answers it off: the caller puts every core in a
-/// known state first, and this is DEN0022 §5.10.3's own way to. The budget
-/// for all of them is [`DEAF_CPU`]'s span from the SGI: a CPU PSCI still
-/// answers on at its end is named, and the machine powers off regardless.
-pub fn off() -> ! {
-    cpu::disable_interrupts();
-    let Some(psci) = psci::conduit() else { cpu::halt() };
-    irqchip::off_all_but_self();
-    // The tripwire's span without its panic: what keeps a CPU on may be
-    // firmware's refusal, and firmware's word never panics this kernel.
-    let deadline = Deadline::at(crate::clock::now() + Duration::from_nanos(DEAF_CPU.nanos()));
-    let me = percpu::cpu_id();
-    for cpu in (0..crate::smp::cpu_count()).filter(|&cpu| cpu != me) {
-        let mpidr = toyos_gicv3::unpacked_affinity(crate::smp::hardware_id(cpu));
-        loop {
-            match psci.affinity_info(mpidr) {
-                Ok(psci::Affinity::Off) => break,
-                Ok(_) if !deadline.reached(crate::clock::now()) => {
-                    let again = Deadline::at(crate::clock::now() + ASK_AGAIN.duration());
-                    while !again.reached(crate::clock::now()) {
-                        core::hint::spin_loop();
-                    }
-                }
-                Ok(_) => {
-                    let mut uart = serial::panic_registers();
-                    uart.write(b"power: cpu");
-                    uart.dec(u64::from(cpu));
-                    uart.write(b" is not off by PSCI's answer inside the budget; SYSTEM_OFF regardless\n");
-                    break;
-                }
-                Err(refusal) => {
-                    let mut uart = serial::panic_registers();
-                    uart.write(b"power: cpu");
-                    uart.dec(u64::from(cpu));
-                    uart.write(b"'s AFFINITY_INFO answered ");
-                    say_code(&mut uart, refusal);
-                    uart.write(b"; SYSTEM_OFF regardless\n");
-                    break;
-                }
-            }
-        }
-    }
-    let refusal = psci.system_off();
-    let mut uart = serial::panic_registers();
-    uart.write(b"power: SYSTEM_OFF answered ");
-    say_code(&mut uart, refusal);
-    uart.write(b"; holding\n");
-    drop(uart);
-    cpu::halt()
-}
-
-/// This CPU's part in [`off`], on the SGI that asked for it: its timer
-/// stopped, since a core `CPU_OFF` turned off may take no interrupt (DEN0022
-/// §5.5.2), then `CPU_OFF`. The SGI is never ended, as `SGI_HALT` is not, so
-/// a refusal leaves this CPU halted with nothing signalled to it, and [`off`]
-/// names it still on.
-pub(super) fn cpu_off() -> ! {
-    irqchip::stop_timer();
-    if crate::actuator::power_off_spares_the_last_two() && percpu::cpu_id() + 2 >= crate::smp::cpu_count() {
-        cpu::halt()
-    }
-    let Some(psci) = psci::conduit() else {
-        unreachable!("power: SGI_OFF is raised only by `off`, which holds a conduit")
-    };
-    let refusal = psci.cpu_off();
-    let mut uart = serial::panic_registers();
-    uart.write(b"power: cpu");
-    uart.dec(u64::from(percpu::cpu_id()));
-    uart.write(b"'s CPU_OFF answered ");
-    say_code(&mut uart, refusal);
-    uart.write(b"\n");
-    drop(uart);
-    cpu::halt()
-}
-
-/// The code PSCI refused a call with, signed.
-fn say_code(uart: &mut serial::PanicUart, code: i32) {
-    if code < 0 {
-        uart.write(b"-");
-    }
-    uart.dec(u64::from(code.unsigned_abs()));
-}
diff --git a/kernel/src/arch/aarch64/psci.rs b/kernel/src/arch/aarch64/psci.rs
index 44f17d0b5..e4df148df 100644
--- a/kernel/src/arch/aarch64/psci.rs
+++ b/kernel/src/arch/aarch64/psci.rs
@@ -3,48 +3,27 @@
 //! where a hypervisor stands in for it. Every call is SMCCC's (Arm DEN0028D):
 //! the function in `x0`, its arguments in `x1`–`x3`, the answer in `x0`.
 
-use core::sync::atomic::{AtomicU8, Ordering::Relaxed};
-
 use toyos_acpi::Psci;
 
 use crate::drivers::acpi::direct_phys;
 use crate::log;
 
-/// The function IDs this kernel calls, as PSCI numbers them from 0.2 on
-/// (DEN0022 §5.1), each the SMC64 one where there is one.
+/// The function IDs of `PSCI_VERSION` and the SMC64 `CPU_ON`, as PSCI
+/// numbers them from 0.2 on.
 const PSCI_VERSION: u32 = 0x8400_0000;
-const CPU_OFF: u32 = 0x8400_0002;
 const CPU_ON: u32 = 0xC400_0003;
-const AFFINITY_INFO: u32 = 0xC400_0004;
-const SYSTEM_OFF: u32 = 0x8400_0008;
-const SYSTEM_RESET: u32 = 0x8400_0009;
 
-/// `CPU_ON`'s `target_cpu` and `AFFINITY_INFO`'s `target_affinity`:
-/// `MPIDR_EL1`'s affinity fields where they stand, Aff3 in bits 39:32, every
-/// other bit zero.
+/// `CPU_ON`'s `target_cpu`: `MPIDR_EL1`'s affinity fields where they stand,
+/// Aff3 in bits 39:32, every other bit zero.
 const TARGET_CPU: u64 = 0xFF_00FF_FFFF;
 
 /// How this machine reaches PSCI.
 #[derive(Clone, Copy)]
-#[repr(u8)]
 pub enum Conduit {
-    Smc = 1,
+    Smc,
     Hvc,
 }
 
-/// The conduit [`init`] found, zero for none: one word, because a reset on a
-/// wedged machine reads it and may take no lock. Written on the boot CPU
-/// before any other starts.
-static CONDUIT: AtomicU8 = AtomicU8::new(0);
-
-/// What `AFFINITY_INFO` answers of one CPU (DEN0022 §5.7.1).
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
-pub enum Affinity {
-    On,
-    Off,
-    OnPending,
-}
-
 /// A PSCI call's refusal, as the specification numbers its return codes.
 #[derive(Clone, Copy, Debug)]
 pub enum Error {
@@ -118,35 +97,6 @@ impl Conduit {
         }
     }
 
-    /// Turn this CPU off: an answer is the code of a refusal, since `CPU_OFF`
-    /// does not return when it succeeds.
-    pub fn cpu_off(self) -> i32 {
-        self.call(CPU_OFF, 0, 0, 0)
-    }
-
-    /// Whether the CPU whose `MPIDR_EL1` is `mpidr` is on, off, or on its way
-    /// on: affinity level 0, the CPU itself. `Err` is the code of a refusal.
-    pub fn affinity_info(self, mpidr: u64) -> Result<Affinity, i32> {
-        match self.call(AFFINITY_INFO, mpidr & TARGET_CPU, 0, 0) {
-            0 => Ok(Affinity::On),
-            1 => Ok(Affinity::Off),
-            2 => Ok(Affinity::OnPending),
-            code => Err(code),
-        }
-    }
-
-    /// Power the machine off. Neither this nor [`Conduit::system_reset`]
-    /// returns (DEN0022 §5.1.9, §5.1.11), so an answer is the code firmware
-    /// broke that with.
-    pub fn system_off(self) -> i32 {
-        self.call(SYSTEM_OFF, 0, 0, 0)
-    }
-
-    /// Reset the machine cold.
-    pub fn system_reset(self) -> i32 {
-        self.call(SYSTEM_RESET, 0, 0, 0)
-    }
-
     fn name(self) -> &'static str {
         match self {
             Self::Smc => "SMC",
@@ -155,15 +105,14 @@ impl Conduit {
     }
 }
 
-/// PSCI as the FADT names it, asked for its version and kept for
-/// [`conduit`]; none where the machine offers none this kernel can call,
-/// which the log says.
-pub fn init(rsdp_addr: u64) {
+/// PSCI as the FADT names it, asked for its version: `None` where the machine
+/// offers none this kernel can call, which the log says.
+pub fn init(rsdp_addr: u64) -> Option<Conduit> {
     let fadt = match toyos_acpi::find_table(direct_phys(), rsdp_addr, b"FACP", toyos_acpi::SDT_HEADER_LEN) {
         Ok(fadt) => fadt,
         Err(e) => {
             log!("PSCI: none, because the FADT is unusable: {e:?}");
-            return;
+            return None;
         }
     };
     let conduit = match toyos_acpi::psci(&fadt) {
@@ -171,41 +120,27 @@ pub fn init(rsdp_addr: u64) {
         Psci::Hvc => Conduit::Hvc,
         Psci::Absent => {
             log!("PSCI: none: the FADT's ARM_BOOT_ARCH does not set PSCI_COMPLIANT");
-            return;
+            return None;
         }
         Psci::Undefined { revision, minor } => {
             log!("PSCI: none: the FADT is version {revision}.{minor}, and ARM_BOOT_ARCH begins at 5.1");
-            return;
+            return None;
         }
         Psci::Short => {
             log!("PSCI: none: the FADT ends before ARM_BOOT_ARCH and the minor version after it");
-            return;
+            return None;
         }
     };
     let version = conduit.call(PSCI_VERSION, 0, 0, 0);
     if version < 0 {
         log!("PSCI: PSCI_VERSION through {} answered {:?}; none used", conduit.name(), Error::of(version));
-        return;
+        return None;
     }
     let (major, minor) = (version >> 16, version & 0xFFFF);
     if major == 0 && minor < 2 {
         log!("PSCI: {major}.{minor} through {}, which numbers no SMC64 CPU_ON; none used", conduit.name());
-        return;
-    }
-    if crate::actuator::psci_withheld() {
-        log!("PSCI: {major}.{minor} through {}, withheld: this kernel keeps no conduit", conduit.name());
-        return;
+        return None;
     }
     log!("PSCI: {major}.{minor} through {}", conduit.name());
-    CONDUIT.store(conduit as u8, Relaxed);
-}
-
-/// The conduit [`init`] kept, or `None` on a machine without PSCI.
-pub fn conduit() -> Option<Conduit> {
-    match CONDUIT.load(Relaxed) {
-        0 => None,
-        1 => Some(Conduit::Smc),
-        2 => Some(Conduit::Hvc),
-        other => unreachable!("PSCI: the kept conduit reads {other}, which `init` never stores"),
-    }
+    Some(conduit)
 }
diff --git a/kernel/src/arch/aarch64/smp.rs b/kernel/src/arch/aarch64/smp.rs
index a8441abf4..0f294097f 100644
--- a/kernel/src/arch/aarch64/smp.rs
+++ b/kernel/src/arch/aarch64/smp.rs
@@ -30,11 +30,11 @@ pub struct ApStart {
 
 /// Start every other CPU `gic` names, in the MADT's order, until one does not
 /// echo within [`AP_START`] or the roster is full.
-pub fn start(gic: &irqchip::Gic) {
+pub fn start(gic: &irqchip::Gic, psci: Option<psci::Conduit>) {
     let me = cpu::hardware_id();
     ROSTER.set_bsp(me);
     let others = gic.cpus.iter().filter(|gicc| packed_affinity(gicc.mpidr) != me);
-    let Some(psci) = psci::conduit() else {
+    let Some(psci) = psci else {
         log!("SMP: no PSCI to start the other {} CPUs with; the boot CPU runs alone", others.count());
         return;
     };
diff --git a/kernel/src/arch/aarch64/trap.rs b/kernel/src/arch/aarch64/trap.rs
index 9b541ac07..397a2137f 100644
--- a/kernel/src/arch/aarch64/trap.rs
+++ b/kernel/src/arch/aarch64/trap.rs
@@ -166,7 +166,6 @@ fn irq(from_el0: bool) {
         // Never ended: the running priority it keeps is every interrupt's
         // own, so the interface signals this CPU nothing and the halt stays.
         irqchip::SGI_HALT => cpu::halt(),
-        irqchip::SGI_OFF => super::power::cpu_off(),
         irqchip::SGI_KICK => {
             percpu::irq_took(Source::Timer);
             irqchip::end(intid);
diff --git a/kernel/src/arch/x86_64/boot.rs b/kernel/src/arch/x86_64/boot.rs
index a606ea3f4..341a7edd2 100644
--- a/kernel/src/arch/x86_64/boot.rs
+++ b/kernel/src/arch/x86_64/boot.rs
@@ -87,10 +87,9 @@ pub fn interrupts(rsdp_addr: u64) -> Platform {
     // Off the same tables as the MADT, and before the IDT below makes a panic
     // reportable: a panic that can be reported but not ended leaves the machine
     // holding its panel for a hand that may not be in the room.
-    super::power::init_reset(rsdp_addr);
+    acpi::init_reset(rsdp_addr);
     apic::init();
     percpu::init_bsp(apic::id());
-    super::power::init_off(rsdp_addr);
     ioapic::init(&madt);
     idt::enable_interrupts();
     super::syscall::init();
diff --git a/kernel/src/arch/x86_64/i8042/mod.rs b/kernel/src/arch/x86_64/i8042/mod.rs
index 5ff7fc7ba..16b3dcbfe 100644
--- a/kernel/src/arch/x86_64/i8042/mod.rs
+++ b/kernel/src/arch/x86_64/i8042/mod.rs
@@ -1245,9 +1245,6 @@ pub fn init(rsdp_addr: u64) {
 
 }
 
-/// Whether the panic path reads a key here: [`poll_byte`] is its poll.
-pub const PANIC_KEYS: bool = true;
-
 /// One byte from the controller if it has one; never waits. Only legal once
 /// every CPU is halted — port 0x60's sole reader is otherwise the ISR.
 pub fn poll_byte() -> Option<(u8, bool)> {
diff --git a/kernel/src/arch/x86_64/mod.rs b/kernel/src/arch/x86_64/mod.rs
index 2521b6e7c..c988e59d7 100644
--- a/kernel/src/arch/x86_64/mod.rs
+++ b/kernel/src/arch/x86_64/mod.rs
@@ -28,8 +28,8 @@ pub mod mtrr;
 pub mod paging;
 pub mod pat;
 pub mod percpu;
+pub mod pio;
 pub mod pmu;
-pub mod power;
 pub mod rtc;
 pub mod smp;
 pub mod switch;
diff --git a/kernel/src/arch/x86_64/pio.rs b/kernel/src/arch/x86_64/pio.rs
new file mode 100644
index 000000000..5a29539bb
--- /dev/null
+++ b/kernel/src/arch/x86_64/pio.rs
@@ -0,0 +1,7 @@
+//! The I/O port space: x86-64 has one, reached by `in` and `out`.
+
+/// Whether this architecture has an I/O port space at all. Firmware tables
+/// that name a port are only honoured where it does.
+pub const EXISTS: bool = true;
+
+pub use super::cpu::{outb, outw};
diff --git a/kernel/src/arch/x86_64/power.rs b/kernel/src/arch/x86_64/power.rs
deleted file mode 100644
index 2df8b54dd..000000000
--- a/kernel/src/arch/x86_64/power.rs
+++ /dev/null
@@ -1,137 +0,0 @@
-//! Reset and power-off through the FADT: its reset register, and S5 soft-off
-//! through the PM1a control block with the `SLP_TYPa` the DSDT's `\_S5_`
-//! package names.
-//!
-//! All input is firmware-supplied and untrusted: a table that does not decode
-//! is a machine with no reboot or no soft-off, said by name, never a panic.
-
-use core::mem::size_of;
-use core::sync::atomic::{AtomicU16, AtomicU8, Ordering};
-
-use toyos_acpi::{Reset, Table, TableError, S5, SDT_HEADER_LEN, SDT_REVISION};
-
-use super::cpu;
-use crate::drivers::acpi::direct_phys;
-use crate::log;
-
-const SLP_EN: u16 = 1 << 13;
-
-static PM1A_CNT_PORT: AtomicU16 = AtomicU16::new(0);
-static SLP_TYPA: AtomicU8 = AtomicU8::new(0);
-
-static RESET_PORT: AtomicU16 = AtomicU16::new(0);
-static RESET_VALUE: AtomicU8 = AtomicU8::new(0);
-
-/// Record the FADT's reset register, or say by name why this machine has none.
-///
-/// Before `percpu::init_bsp` loads the IDT: from then on every panic can be
-/// reported, and a panic that can be reported but not ended is a machine that
-/// still needs a hand. Walking these tables inside the panic handler instead is
-/// refused — a table walk on a machine that has already failed once is how a
-/// panic becomes a triple fault.
-pub fn init_reset(rsdp_addr: u64) {
-    let fadt = match toyos_acpi::find_table(direct_phys(), rsdp_addr, b"FACP", toyos_acpi::FADT_FOR_RESET) {
-        Ok(table) => table,
-        Err(e) => {
-            log!("ACPI: FADT unusable: {e:?} — no reboot, a panic will hold the panel");
-            return;
-        }
-    };
-    match toyos_acpi::reset_register(&fadt) {
-        Reset::Port { port, value } => {
-            RESET_PORT.store(port, Ordering::Relaxed);
-            RESET_VALUE.store(value, Ordering::Relaxed);
-            log!("ACPI: reset register SystemIO {port:#x} <- {value:#04x}");
-        }
-        other => log!("ACPI: no reset register this kernel writes ({other:?}) — no reboot"),
-    }
-}
-
-/// Record S5 soft-off, or say by name why this machine has none; it keeps
-/// booting either way. After the IDT, so a fault in the DSDT walk is reported.
-pub fn init_off(rsdp_addr: u64) {
-    const FADT_FOR_POWER: usize = toyos_acpi::FADT_PM1A_CNT_BLK + size_of::<u32>();
-    const FADT_FOR_X_DSDT: usize = toyos_acpi::FADT_X_DSDT + size_of::<u64>();
-
-    let fadt = match toyos_acpi::find_table(direct_phys(), rsdp_addr, b"FACP", FADT_FOR_POWER) {
-        Ok(table) => table,
-        Err(e) => {
-            log!("ACPI: FADT unusable: {e:?} — no soft-off, shutdown will halt instead");
-            return;
-        }
-    };
-
-    let Some(block) = fadt.u32_at(toyos_acpi::FADT_PM1A_CNT_BLK).filter(|&block| block != 0) else {
-        log!("ACPI: FADT has no PM1a control block — no soft-off");
-        return;
-    };
-    let Ok(pm1a) = u16::try_from(block) else {
-        log!("ACPI: FADT puts the PM1a control block at {block:#x}, past the 16-bit port space — no soft-off");
-        return;
-    };
-
-    // Prefer X_DSDT over DSDT; a revision claiming 2.0 doesn't prove the field is present, so the length is checked rather than trusting the revision alone.
-    let dsdt_addr = toyos_acpi::dsdt_address(&fadt);
-    if dsdt_addr == 0 {
-        log!(
-            "ACPI: FADT names no DSDT (rev {:?}, needs {FADT_FOR_X_DSDT} bytes for X_DSDT) — no soft-off",
-            fadt.byte(SDT_REVISION)
-        );
-        return;
-    }
-
-    let dsdt = match Table::open(direct_phys(), dsdt_addr, b"DSDT", SDT_HEADER_LEN) {
-        Ok(table) => table,
-        Err(TableError::Unmapped { .. }) => {
-            log!("ACPI: FADT points the DSDT at {dsdt_addr:#x}, which is not an address — no soft-off");
-            return;
-        }
-        Err(e) => {
-            log!("ACPI: DSDT at {dsdt_addr:#x} unusable: {e:?} — no soft-off");
-            return;
-        }
-    };
-
-    let slp_typ = match toyos_acpi::s5_slp_typ(&dsdt) {
-        S5::SlpTyp(slp_typ) => slp_typ,
-        S5::Absent => {
-            log!("ACPI: no \\_S5_ package in the DSDT — no soft-off");
-            return;
-        }
-        S5::Wide(byte) => {
-            log!("ACPI: the DSDT's \\_S5_ names SLP_TYPa {byte:#x}, wider than its three bits — no soft-off");
-            return;
-        }
-    };
-
-    PM1A_CNT_PORT.store(pm1a, Ordering::Relaxed);
-    SLP_TYPA.store(slp_typ, Ordering::Relaxed);
-    log!("ACPI: PM1a={pm1a:#x} SLP_TYPa={slp_typ}");
-}
-
-pub fn can_reset() -> bool {
-    RESET_PORT.load(Ordering::Relaxed) != 0
-}
-
-/// Write the reset register and nothing else: no lock, nothing but the port
-/// the FADT named. A machine with no reset register halts.
-// No fallback: 0xCF9, the keyboard controller and anything else are written only where a table named them.
-pub fn reset() -> ! {
-    let port = RESET_PORT.load(Ordering::Relaxed);
-    if port != 0 {
-        // SAFETY: the port is non-zero only where `init_reset` decoded an 8-bit System I/O register, and the value is that register's.
-        unsafe { cpu::outb(port, RESET_VALUE.load(Ordering::Relaxed)) };
-    }
-    cpu::halt()
-}
-
-/// Enter S5, or halt on a machine whose tables named no soft-off.
-pub fn off() -> ! {
-    let pm1a = PM1A_CNT_PORT.load(Ordering::Relaxed);
-    if pm1a != 0 {
-        let val = (u16::from(SLP_TYPA.load(Ordering::Relaxed)) << 10) | SLP_EN;
-        // SAFETY: pm1a and slp_typ come only from the validated FADT parse via PM1A_CNT_PORT/SLP_TYPA, and the zero check above confirms that parse happened.
-        unsafe { cpu::outw(pm1a, val) };
-    }
-    cpu::halt()
-}
diff --git a/kernel/src/deadline.rs b/kernel/src/deadline.rs
index 8fdc1d071..4c4bd89de 100644
--- a/kernel/src/deadline.rs
+++ b/kernel/src/deadline.rs
@@ -221,7 +221,7 @@ fn expire() -> ! {
     // The seal first, because the USB stop `reset_now` makes before it writes
     // the register is bounded but not instant, and this record is the
     // diagnostic the whole mechanism exists for.
-    crate::power::reset_now()
+    crate::drivers::acpi::reset_now()
 }
 
 /// What a sealed record opens with, and so what the next boot's loader prints
diff --git a/kernel/src/drivers/acpi.rs b/kernel/src/drivers/acpi.rs
index 87db44ac6..79e52c048 100644
--- a/kernel/src/drivers/acpi.rs
+++ b/kernel/src/drivers/acpi.rs
@@ -3,17 +3,31 @@
 //! The decode is `toyos-acpi`, pure and host-tested against QEMU's own tables
 //! and a crafted corpus. What stays here is everything that touches the
 //! machine: the direct-map reader the crate decodes through, the log lines a
-//! machine owner reads a refusal off, and the `Vec`s the crate cannot allocate.
+//! machine owner reads a refusal off, the `Vec`s the crate cannot allocate, and
+//! the PM1 port writes that turn a validated FADT into a soft-off.
 //!
 //! All input is firmware-supplied and untrusted: no panic on any input path,
 //! every failure is a [`TableError`] and the caller decides what it means.
+//!
+//! **No reset this kernel performs leaves a USB device mid-command.**
+//! [`reset_now`] and [`shutdown`] are the only two places this kernel writes a
+//! register that ends the machine, and each stops every xHCI controller
+//! ([`stop::before_reset`]) before it does — which is what makes that a property
+//! of the reset rather than of whoever asked for one, and what a third caller
+//! gets without knowing it is owed. Resets this kernel does not perform — a TCO
+//! or firmware watchdog, a triple fault, power loss — are outside it and always
+//! will be.
 
 use alloc::vec::Vec;
 use core::mem::size_of;
 use core::ptr::{read_unaligned, read_volatile};
+use core::sync::atomic::{AtomicU16, AtomicU8, Ordering};
+use crate::drivers::xhci::stop;
 use crate::log;
 use crate::DirectMap;
-use toyos_acpi::{Century, MadtEntry, Mapped, Memory, CMOS_RAM, MADT_ENTRIES, SDT_HEADER_LEN, SDT_REVISION};
+use toyos_acpi::{
+    Century, MadtEntry, Mapped, Memory, Reset, CMOS_RAM, MADT_ENTRIES, SDT_HEADER_LEN, SDT_REVISION,
+};
 
 pub use toyos_acpi::{IoApicEntry, SourceOverride, TableError};
 
@@ -47,7 +61,11 @@ pub fn direct_phys() -> DirectPhys {
 pub struct Table(toyos_acpi::Table<DirectPhys>);
 
 impl Table {
-    /// The declared length, already bounded by [`find_table`].
+    pub fn open(base: u64, signature: &[u8; 4], needed: usize) -> Result<Table, TableError> {
+        toyos_acpi::Table::open(direct_phys(), base, signature, needed).map(Table)
+    }
+
+    /// The declared length, already bounded by [`Table::open`].
     pub fn len(&self) -> usize {
         self.0.len()
     }
@@ -114,6 +132,14 @@ pub fn inventory(rsdp_addr: u64) {
     );
 }
 
+const SLP_EN: u16 = 1 << 13;
+
+static PM1A_CNT_PORT: AtomicU16 = AtomicU16::new(0);
+static SLP_TYPA: AtomicU16 = AtomicU16::new(0);
+
+static RESET_PORT: AtomicU16 = AtomicU16::new(0);
+static RESET_VALUE: AtomicU8 = AtomicU8::new(0);
+
 /// Log a refusal with the reason, and hand the caller `None`.
 // Never a panic: a machine owner needs to see the reason, not have the kernel die on a firmware defect.
 fn refuse<T>(what: &str, error: TableError) -> Option<T> {
@@ -139,6 +165,64 @@ pub fn find_ecam_base(rsdp_addr: u64) -> Option<(u64, u16)> {
     Some((base, segment))
 }
 
+/// Parse FADT and DSDT to prepare for ACPI shutdown.
+// A machine without them keeps booting without soft-off rather than panicking.
+// The reset register is not decoded here: it is `init_reset`, which runs before
+// the boot has anything a panic could report on.
+pub fn init_power(rsdp_addr: u64) {
+    const FADT_FOR_POWER: usize = toyos_acpi::FADT_PM1A_CNT_BLK + size_of::<u32>();
+    const FADT_FOR_X_DSDT: usize = toyos_acpi::FADT_X_DSDT + size_of::<u64>();
+
+    let fadt = match find_table(rsdp_addr, b"FACP", FADT_FOR_POWER) {
+        Ok(table) => table,
+        Err(e) => {
+            log!("ACPI: FADT unusable: {e:?} — no soft-off, shutdown will halt instead");
+            return;
+        }
+    };
+
+    let Some(pm1a) = fadt.field::<u32>(toyos_acpi::FADT_PM1A_CNT_BLK) else {
+        log!("ACPI: FADT has no PM1a control block — no soft-off");
+        return;
+    };
+    let pm1a = pm1a as u16;
+    if pm1a != 0 && !crate::arch::pio::EXISTS {
+        log!("ACPI: FADT puts PM1a control at port {pm1a:#x}, and this machine has no I/O port space — no soft-off");
+        return;
+    }
+
+    // Prefer X_DSDT over DSDT; a revision claiming 2.0 doesn't prove the field is present, so the length is checked rather than trusting the revision alone.
+    let dsdt_addr = toyos_acpi::dsdt_address(&fadt.0);
+    if dsdt_addr == 0 {
+        log!(
+            "ACPI: FADT names no DSDT (rev {:?}, needs {FADT_FOR_X_DSDT} bytes for X_DSDT) — no soft-off",
+            fadt.field::<u8>(SDT_REVISION)
+        );
+        return;
+    }
+
+    let dsdt = match Table::open(dsdt_addr, b"DSDT", SDT_HEADER_LEN) {
+        Ok(table) => table,
+        Err(TableError::Unmapped { .. }) => {
+            log!("ACPI: FADT points the DSDT at {dsdt_addr:#x}, which is not an address — no soft-off");
+            return;
+        }
+        Err(e) => {
+            log!("ACPI: DSDT at {dsdt_addr:#x} unusable: {e:?} — no soft-off");
+            return;
+        }
+    };
+
+    let Some(slp_typ) = find_s5_slp_typ(&dsdt) else {
+        log!("ACPI: no \\_S5_ package in the DSDT — no soft-off");
+        return;
+    };
+
+    PM1A_CNT_PORT.store(pm1a, Ordering::Relaxed);
+    SLP_TYPA.store(slp_typ, Ordering::Relaxed);
+    log!("ACPI: PM1a={pm1a:#x} SLP_TYPa={slp_typ}");
+}
+
 /// FADT revision and the IA-PC boot architecture flags.
 // `Err` is not "absent" and must not be treated as one by the caller.
 pub fn iapc_boot_arch(rsdp_addr: u64) -> Result<(u8, u16), TableError> {
@@ -169,6 +253,103 @@ pub fn rtc_century_register(rsdp_addr: u64) -> Result<Option<u8>, TableError> {
     }
 }
 
+/// Record the FADT's reset register, or say by name why this machine has none.
+///
+/// The one decode of it, and it runs before `percpu::init_bsp` rather than with
+/// the rest of the power tables: from the moment that function loads the IDT,
+/// every panic can be reported, and a panic that can be reported but not ended
+/// is a machine that still needs a hand. Walking these tables inside the panic
+/// handler instead is refused — a table walk on a machine that has already
+/// failed once is how a panic becomes a triple fault.
+pub fn init_reset(rsdp_addr: u64) {
+    let fadt = match find_table(rsdp_addr, b"FACP", toyos_acpi::FADT_FOR_RESET) {
+        Ok(table) => table,
+        Err(e) => {
+            log!("ACPI: FADT unusable: {e:?} — no reboot, a panic will hold the panel");
+            return;
+        }
+    };
+    match toyos_acpi::reset_register(&fadt.0) {
+        Reset::Port { port, .. } if !crate::arch::pio::EXISTS => {
+            log!("ACPI: the reset register is SystemIO {port:#x}, and this machine has no I/O port space — no reboot");
+        }
+        Reset::Port { port, value } => {
+            RESET_PORT.store(port, Ordering::Relaxed);
+            RESET_VALUE.store(value, Ordering::Relaxed);
+            log!("ACPI: reset register SystemIO {port:#x} <- {value:#04x}");
+        }
+        other => log!("ACPI: no reset register this kernel writes ({other:?}) — no reboot"),
+    }
+}
+
+pub fn can_reboot() -> bool {
+    RESET_PORT.load(Ordering::Relaxed) != 0
+}
+
+/// Return the machine to firmware through the FADT's reset register.
+// No fallback: 0xCF9, the keyboard controller and anything else are written only where a table named them.
+pub fn reboot() -> ! {
+    crate::drivers::serial::flush_final();
+    // Kernel-internal, so a bug rather than a machine quiesced and then left halted quietly.
+    assert!(
+        RESET_PORT.load(Ordering::Relaxed) != 0,
+        "reboot: no reset register, and the caller did not ask can_reboot() first"
+    );
+    reset_now()
+}
+
+/// Write the reset register and nothing else.
+///
+/// **[`reboot`] is not reachable from a wedge**, which is why this exists
+/// beside it: that path opens with `serial::flush_final`, and a `BackendGuard`
+/// masks interrupts for its whole life — so a CPU stuck inside one holds what
+/// the call would wait for, on exactly the boots `crate::deadline` exists for.
+/// This takes no lock and touches nothing but the port the FADT named.
+///
+/// A machine with no reset register halts here rather than returning: the
+/// caller has already sealed why, and holding is what such a machine has always
+/// done.
+pub fn reset_now() -> ! {
+    // **Here and not at either caller.** `stop::before_reset` is registers and
+    // nothing else — written for the panic path, so it takes no lock and
+    // allocates nothing, which is the only kind of call a wedge may make — and
+    // it *appends* its account to whatever this boot already sealed, so a
+    // `WEDGED` page carries what the reset did to USB the way a `PANIC` one
+    // does. A caller seals first and calls this second: the record is the
+    // diagnostic the seal exists for and may not be lost to a stop that does
+    // not return.
+    stop::before_reset();
+    let port = RESET_PORT.load(Ordering::Relaxed);
+    if port == 0 {
+        crate::arch::cpu::halt();
+    }
+    // SAFETY: the port is non-zero only where `init_reset` decoded an 8-bit System I/O register, and the value is that register's.
+    unsafe { crate::arch::pio::outb(port, RESET_VALUE.load(Ordering::Relaxed)) };
+
+    crate::arch::cpu::halt();
+}
+
+/// Trigger ACPI S5 (soft-off) shutdown.
+pub fn shutdown() -> ! {
+    // Last chance: nothing drains the log ring after this point.
+    crate::drivers::serial::flush_final();
+    // S5 takes VBUS with it on a machine whose ports are not always-on and
+    // takes nothing on one whose are, so the devices are handed back here for
+    // the same reason as at a reboot.
+    stop::before_reset();
+
+    let pm1a = PM1A_CNT_PORT.load(Ordering::Relaxed);
+    let slp_typ = SLP_TYPA.load(Ordering::Relaxed);
+
+    if pm1a != 0 {
+        let val = (slp_typ << 10) | SLP_EN;
+        // SAFETY: pm1a and slp_typ come only from the validated FADT parse via PM1A_CNT_PORT/SLP_TYPA, and the zero check above confirms that parse happened.
+        unsafe { crate::arch::pio::outw(pm1a, val) };
+    }
+
+    crate::arch::cpu::halt();
+}
+
 /// Given the RSDP address, parse XSDT -> HPET table -> return HPET MMIO base address.
 pub fn find_hpet_base(rsdp_addr: u64) -> Option<u64> {
     let base = match toyos_acpi::hpet_base(direct_phys(), rsdp_addr) {
@@ -221,3 +402,36 @@ pub fn parse_madt(rsdp_addr: u64) -> Option<MadtInfo> {
     log!("ACPI: MADT cpus={:?}", apic_ids);
     Some(MadtInfo { apic_ids, io_apics, source_overrides })
 }
+
+/// Scan DSDT AML bytecode for the \_S5_ package and extract SLP_TYPa.
+// Bounded by the table's declared length via [`Table::field`].
+fn find_s5_slp_typ(dsdt: &Table) -> Option<u16> {
+    let s5 = b"_S5_";
+    let len = dsdt.len();
+
+    for i in SDT_HEADER_LEN..len.saturating_sub(7) {
+        if (0..4).any(|j| dsdt.field::<u8>(i + j) != Some(s5[j])) {
+            continue;
+        }
+        if dsdt.field::<u8>(i + 4) != Some(0x12) {
+            continue;
+        }
+
+        let pkg_lead = dsdt.field::<u8>(i + 5)?;
+        let pkg_len_bytes = match (pkg_lead >> 6) & 0x03 {
+            0 => 1usize,
+            n => (n + 1) as usize,
+        };
+
+        // Skip: "_S5_"(4) + PackageOp(1) + PkgLength + NumElements(1)
+        let val_off = i + 4 + 1 + pkg_len_bytes + 1;
+        let byte = dsdt.field::<u8>(val_off)?;
+        return Some(if byte == 0x0A {
+            dsdt.field::<u8>(val_off + 1)? as u16
+        } else {
+            byte as u16
+        });
+    }
+
+    None
+}
diff --git a/kernel/src/drivers/serial.rs b/kernel/src/drivers/serial.rs
index 67368b2d5..c1f9c14c6 100644
--- a/kernel/src/drivers/serial.rs
+++ b/kernel/src/drivers/serial.rs
@@ -254,7 +254,7 @@ pub unsafe fn panic_flush() {
 }
 
 /// Drains the ring before the machine powers off, so the tail of a shutdown
-/// is not lost to `power::shutdown()` cutting power with logs still queued.
+/// is not lost to `acpi::shutdown()` cutting power with logs still queued.
 ///
 /// Bounded on the wire like `panic_flush`, but never bypasses: every CPU is
 /// still live here, and reading the ring unsynchronized is only safe once
diff --git a/kernel/src/hardlockup/mod.rs b/kernel/src/hardlockup/mod.rs
index 3b2e518d8..24a379e30 100644
--- a/kernel/src/hardlockup/mod.rs
+++ b/kernel/src/hardlockup/mod.rs
@@ -28,7 +28,7 @@
 //! stuck: it seals a `WEDGED` record naming itself, its `pc` and `sp` from the
 //! NMI frame, the lock it is spinning on if `Lock::lock` recorded one, a line
 //! for every other CPU, and the tail of the log ring — then writes the reset
-//! register through `power::reset_now`.
+//! register through `acpi::reset_now`.
 //!
 //! # The discipline this file is written under
 //!
@@ -284,7 +284,7 @@ fn locked_up(me: usize, pc: u64, sp: u64, now: u64) -> ! {
     // The seal first, because the USB stop `reset_now` makes before it writes
     // the register is bounded but not instant, and this record is the
     // diagnostic the whole mechanism exists for.
-    crate::power::reset_now()
+    crate::drivers::acpi::reset_now()
 }
 
 /// What this CPU was waiting for before a nested acquisition took the slot, so
diff --git a/kernel/src/main.rs b/kernel/src/main.rs
index cab6b78d1..9369fc7b1 100644
--- a/kernel/src/main.rs
+++ b/kernel/src/main.rs
@@ -36,7 +36,6 @@ mod hardlockup;
 mod mm;
 mod panic;
 mod panic_reboot;
-mod power;
 
 mod keyboard;
 mod mouse;
@@ -430,6 +429,7 @@ pub(crate) unsafe extern "C" fn kernel_main(kernel_args: &KernelArgs) -> ! {
     arch::watchdog::init(&pci_devices);
     file_cache::init();
     gpt::init(kernel_args);
+    acpi::init_power(kernel_args.rsdp_addr);
 
     boot_phase!("peripherals ready", t_periph);
 
diff --git a/kernel/src/panic_reboot.rs b/kernel/src/panic_reboot.rs
index 1aa2cace6..65bda7dbe 100644
--- a/kernel/src/panic_reboot.rs
+++ b/kernel/src/panic_reboot.rs
@@ -11,9 +11,14 @@
 //! both phases then compare the same counter against the same unit. A machine
 //! that states no frequency and has no calibrated clock cannot time anything,
 //! and the arm line says so instead of resetting on a guess.
+//!
+//! The reset is the FADT's; a machine whose reset register this kernel could
+//! not decode is refused by name and holds, with no fallback. That register is
+//! decoded before `percpu::init_bsp` loads the IDT, so every panic that can
+//! reach this path at all has one to write.
 
-use crate::arch::{cpu, keyboard_controller};
-use crate::drivers::serial;
+use crate::arch::cpu;
+use crate::drivers::{acpi, serial};
 use crate::time::{Budget, Duration};
 
 /// The shipped bound.
@@ -28,7 +33,7 @@ pub enum Bound {
     /// Reset the machine at this `cpu::counter` reading.
     At(u64),
     /// Hold the panel: somebody is reading it, or nothing here could time a
-    /// wait, or this machine has no reset this kernel performs.
+    /// wait, or this machine has no reset register to write.
     Held,
 }
 
@@ -98,32 +103,30 @@ pub fn arm(on_the_record: bool) -> Bound {
     // ASCII only, here and in every line below: the panel's font renders
     // anything outside 0x20..=0x7E as a dot.
     let secs = budget.duration().millis() / 1_000;
-    // A key retires the bound only where the panic path reads one.
-    let unless = if keyboard_controller::PANIC_KEYS { " unless a key is pressed" } else { "" };
-    match (deadline(budget), crate::power::can_reboot()) {
+    // The clock and the reset register are two separate ways to have no
+    // reboot, and a line naming one when the other is what failed answers
+    // whoever reads the panel with nothing.
+    match (deadline(budget), acpi::can_reboot()) {
         (Some((cycles, source)), true) => {
             if on_the_record {
                 alert!(
-                    "{ARMED} in {secs} s{unless}, timed by {}",
+                    "{ARMED} in {secs} s unless a key is pressed, timed by {}",
                     source.named()
                 );
             } else {
-                let mut uart = serial::panic_registers();
-                uart.write(b"panic: rebooting");
-                uart.write(unless.as_bytes());
-                uart.write(b"\n");
+                serial::panic_registers().write(b"panic: rebooting unless a key is pressed\n");
             }
             Bound::At(cycles)
         }
         (Some((_, source)), false) => {
             if on_the_record {
                 alert!(
-                    "{HELD}, timed by {}: this kernel has no reset to hand the machine back to \
-                     firmware with",
+                    "{HELD}, timed by {}: this kernel has decoded no reset register to hand \
+                     the machine back to firmware with",
                     source.named()
                 );
             } else {
-                serial::panic_registers().write(b"panic: holding this panel: no reset\n");
+                serial::panic_registers().write(b"panic: holding this panel: no reset register\n");
             }
             Bound::Held
         }
@@ -144,12 +147,11 @@ pub fn arm(on_the_record: bool) -> Bound {
 /// Return the machine to firmware. The second of this path's two lines, and it
 /// goes out raw: the log has already been flushed and drained by here.
 pub fn reboot_now() -> ! {
-    serial::panic_registers().write(if keyboard_controller::PANIC_KEYS {
-        b"\npanic: no key inside the bound, so nobody is here: returning this machine to firmware\n"
-    } else {
-        b"\npanic: the bound is over: returning this machine to firmware\n"
-    });
-    // Not `power::reboot`: its flush waits on the console wire, and a CPU this
+    serial::panic_registers().write(
+        b"\npanic: no key inside the bound, so nobody is here: returning this machine to \
+          firmware\n",
+    );
+    // Not `acpi::reboot`: its flush waits on the console wire, and a CPU this
     // panic stopped may be holding it.
-    crate::power::reset_now()
+    acpi::reset_now()
 }
diff --git a/kernel/src/power.rs b/kernel/src/power.rs
deleted file mode 100644
index 0b39a9d64..000000000
--- a/kernel/src/power.rs
+++ /dev/null
@@ -1,59 +0,0 @@
-//! The machine's two ends this kernel performs, a reset and a power-off, each
-//! the architecture's own (`arch::power`).
-//!
-//! **No reset this kernel performs leaves a USB device mid-command.**
-//! [`reset_now`] and [`shutdown`] are the only two places this kernel ends the
-//! machine, and each stops every xHCI controller ([`stop::before_reset`])
-//! before it does — which is what makes that a property of the reset rather
-//! than of whoever asked for one, and what a third caller gets without knowing
-//! it is owed. Resets this kernel does not perform — a TCO or firmware
-//! watchdog, a triple fault, power loss — are outside it and always will be.
-
-use crate::drivers::{serial, xhci::stop};
-
-/// Whether this machine has a reset this kernel can perform.
-pub fn can_reboot() -> bool {
-    crate::arch::power::can_reset()
-}
-
-/// Return the machine to firmware.
-pub fn reboot() -> ! {
-    serial::flush_final();
-    // Kernel-internal, so a bug rather than a machine quiesced and then left halted quietly.
-    assert!(can_reboot(), "reboot: no reset, and the caller did not ask can_reboot() first");
-    reset_now()
-}
-
-/// Reset the machine and do nothing else.
-///
-/// **[`reboot`] is not reachable from a wedge**, which is why this exists
-/// beside it: that path opens with `serial::flush_final`, and a `BackendGuard`
-/// masks interrupts for its whole life — so a CPU stuck inside one holds what
-/// the call would wait for, on exactly the boots `crate::deadline` exists for.
-/// This takes no lock.
-///
-/// A machine with no reset halts here rather than returning: the caller has
-/// already sealed why, and holding is what such a machine has always done.
-pub fn reset_now() -> ! {
-    // **Here and not at either caller.** `stop::before_reset` is registers and
-    // nothing else — written for the panic path, so it takes no lock and
-    // allocates nothing, which is the only kind of call a wedge may make — and
-    // it *appends* its account to whatever this boot already sealed, so a
-    // `WEDGED` page carries what the reset did to USB the way a `PANIC` one
-    // does. A caller seals first and calls this second: the record is the
-    // diagnostic the seal exists for and may not be lost to a stop that does
-    // not return.
-    stop::before_reset();
-    crate::arch::power::reset()
-}
-
-/// Power the machine off, or halt on one that offers no power-off.
-pub fn shutdown() -> ! {
-    // Last chance: nothing drains the log ring after this point.
-    serial::flush_final();
-    // A power-off takes VBUS with it on a machine whose ports are not
-    // always-on and takes nothing on one whose are, so the devices are handed
-    // back here for the same reason as at a reboot.
-    stop::before_reset();
-    crate::arch::power::off()
-}
diff --git a/kernel/src/syscall/machine.rs b/kernel/src/syscall/machine.rs
index e79004b43..eb3094280 100644
--- a/kernel/src/syscall/machine.rs
+++ b/kernel/src/syscall/machine.rs
@@ -7,7 +7,7 @@
 
 use alloc::vec::Vec;
 
-use crate::power;
+use crate::drivers::acpi;
 use crate::user_ptr::{SyscallContext, UserBytesMut};
 use crate::UserAddr;
 use crate::{log, process};
@@ -105,7 +105,7 @@ fn quiesce(last: &str) -> Result<(), SyscallError> {
     // volumes still have bytes to take and this takes the controller away from
     // them; and after everything else here,
     // because nothing may run between it and the register stop
-    // `power::reboot`/`power::shutdown` do — which every reset this kernel
+    // `acpi::reboot`/`acpi::shutdown` do — which every reset this kernel
     // performs goes through. It is bounded, and the reset follows either way.
     crate::drivers::xhci::seal_shut();
     Ok(())
@@ -119,23 +119,23 @@ pub(super) fn sys_shutdown(syscap: RawHandle) -> u64 {
     if let Err(e) = quiesce("Shutting down.") {
         return e.to_u64();
     }
-    power::shutdown();
+    acpi::shutdown();
 }
 
 /// Returns the machine to firmware; requires a `SysCap` carrying [`Rights::POWER`]. Returns only when refused.
-// The reset is demanded before anything is torn down: a machine without one is left running, not synced, stopped and still on.
+// The register is demanded before anything is torn down: a machine whose FADT names none is left running, not synced, stopped and still on.
 pub(super) fn sys_reboot(syscap: RawHandle) -> u64 {
     if let Err(e) = demand_syscap(syscap, Rights::POWER) {
         return e.refuse();
     }
-    if !power::can_reboot() {
-        log!("reboot: this machine has no reset this kernel performs — refused");
+    if !acpi::can_reboot() {
+        log!("reboot: this machine's FADT names no reset register — refused");
         return SyscallError::NotSupported.to_u64();
     }
     if let Err(e) = quiesce("Rebooting.") {
         return e.to_u64();
     }
-    power::reboot();
+    acpi::reboot();
 }
 
 /// The most live threads `SYS_SYSINFO` will describe; kept under `mm::MAX_HEAP_ALLOC` so an unbounded thread count cannot trip the allocator's fail-fast assert.
diff --git a/toyos-acpi/src/dsdt.rs b/toyos-acpi/src/dsdt.rs
deleted file mode 100644
index 5ac350631..000000000
--- a/toyos-acpi/src/dsdt.rs
+++ /dev/null
@@ -1,48 +0,0 @@
-//! The DSDT (signature `DSDT`), read for one value by a byte scan, not an AML
-//! interpreter: the first element of its `\_S5_` package (ACPI 6.5, "\_Sx").
-
-use crate::{Phys, Table, SDT_HEADER_LEN};
-
-/// AML's `PackageOp` and `BytePrefix` (ACPI 6.5, "AML Grammar Definition").
-const PACKAGE_OP: u8 = 0x12;
-const BYTE_PREFIX: u8 = 0x0A;
-
-/// The widest `SLP_TYPx` the PM1 control register holds: three bits, 12:10.
-const SLP_TYP_MAX: u8 = 7;
-
-/// What a DSDT says to write to `SLP_TYPa` for S5 soft-off.
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
-pub enum S5 {
-    SlpTyp(u8),
-    /// No `_S5_` followed by `PackageOp` whose first element lies inside the
-    /// table.
-    Absent,
-    /// A first element wider than `SLP_TYPx`, which shifted into place would
-    /// set `SLP_EN` and reserved bits.
-    Wide(u8),
-}
-
-/// The first element of the first `\_S5_` package in `dsdt`, read inside the
-/// table's declared length: a `BytePrefix` constant, or the element's own
-/// byte, which is `ZeroOp` and `OneOp`'s value.
-pub fn s5_slp_typ<P: Phys>(dsdt: &Table<P>) -> S5 {
-    for at in SDT_HEADER_LEN..dsdt.len() {
-        if (0..4).any(|i| dsdt.byte(at + i) != Some(b"_S5_"[i])) || dsdt.byte(at + 4) != Some(PACKAGE_OP) {
-            continue;
-        }
-        // `PkgLength`'s lead byte counts the bytes after it in bits 7:6
-        // ("Package Length Encoding"); `NumElements` follows it.
-        let Some(lead) = dsdt.byte(at + 5) else { return S5::Absent };
-        let element = at + 5 + 1 + usize::from(lead >> 6) + 1;
-        let value = match dsdt.byte(element) {
-            Some(BYTE_PREFIX) => dsdt.byte(element + 1),
-            byte => byte,
-        };
-        return match value {
-            Some(value) if value <= SLP_TYP_MAX => S5::SlpTyp(value),
-            Some(value) => S5::Wide(value),
-            None => S5::Absent,
-        };
-    }
-    S5::Absent
-}
diff --git a/toyos-acpi/src/lib.rs b/toyos-acpi/src/lib.rs
index 99a40edff..9e2378500 100644
--- a/toyos-acpi/src/lib.rs
+++ b/toyos-acpi/src/lib.rs
@@ -14,7 +14,6 @@
 #![no_std]
 #![forbid(unsafe_code)]
 
-mod dsdt;
 mod fadt;
 mod gtdt;
 mod madt;
@@ -23,7 +22,6 @@ mod spcr;
 
 use toyos_bootmap::DirectMapEnd;
 
-pub use dsdt::{s5_slp_typ, S5};
 pub use fadt::{
     century_of, dsdt_address, iapc_boot_arch, psci, reset_register, rtc_century, Century, Psci,
     Reset, CMOS_RAM, FADT_FOR_RESET, FADT_PM1A_CNT_BLK, FADT_X_DSDT,
diff --git a/toyos-gicv3/src/lib.rs b/toyos-gicv3/src/lib.rs
index a1dcb92d0..43bec42c2 100644
--- a/toyos-gicv3/src/lib.rs
+++ b/toyos-gicv3/src/lib.rs
@@ -22,12 +22,6 @@ pub const fn packed_affinity(mpidr: u64) -> u32 {
     ((mpidr & 0xFF_FFFF) | ((mpidr >> 8) & 0xFF00_0000)) as u32
 }
 
-/// [`packed_affinity`] undone: the four fields where `MPIDR_EL1` holds them,
-/// every other bit zero, which is how PSCI names a CPU.
-pub const fn unpacked_affinity(packed: u32) -> u64 {
-    (packed & 0xFF_FFFF) as u64 | ((packed >> 24) as u64) << 32
-}
-
 /// `ICC_SGI1R_EL1` raising SGI `intid` on the one CPU whose packed affinity is
 /// `target`: `Aff3`, `Aff2` and `Aff1` name its cluster, `RS` the range of
 /// sixteen `Aff0` values it falls in, and the target list its one bit there.
diff --git a/toyos-gicv3/tests/gicv3.rs b/toyos-gicv3/tests/gicv3.rs
index 373cc9c12..1348e21b6 100644
--- a/toyos-gicv3/tests/gicv3.rs
+++ b/toyos-gicv3/tests/gicv3.rs
@@ -1,4 +1,4 @@
-use toyos_gicv3::{find_redistributor, packed_affinity, sgi1r, sgi1r_others, unpacked_affinity, FRAME};
+use toyos_gicv3::{find_redistributor, packed_affinity, sgi1r, sgi1r_others, FRAME};
 
 #[test]
 fn affinity_drops_mpidr_flags_between_aff2_and_aff3() {
@@ -7,14 +7,6 @@ fn affinity_drops_mpidr_flags_between_aff2_and_aff3() {
     assert_eq!(packed_affinity(mpidr), 0x1234_5678);
 }
 
-#[test]
-fn unpacking_puts_each_field_back_and_no_flag() {
-    let mpidr = 0x12 << 32 | 1 << 31 | 1 << 30 | 1 << 24 | 0x34_5678;
-    assert_eq!(unpacked_affinity(packed_affinity(mpidr)), 0x12_0034_5678);
-    // QEMU `virt`'s seventeenth CPU, the first of its second cluster.
-    assert_eq!(unpacked_affinity(0x100), 0x100);
-}
-
 #[test]
 fn sgi_names_aff0_by_range_and_bit() {
     // Aff0 0x13 is range 1, bit 3.

#636 moved the five `virt_` probes out of the shipped toybox into
`userland/kernelprobe`, and put two rulings in `reviewer.md`.

One conflict, `tests/virtsmpcase/system.toml`, every hunk of both sides kept:
- main's: `[programs.toybox]` became `[programs.kernelprobe]`, and
  `bin/unmap_touch` points at `/system/bin/kernelprobe`. Both taken.
- this branch's: the header's second sentence, `shutdown` after `unmap_touch`
  in the job list, and `bin/shutdown` pointing at `/system/bin/toybox`. All
  three kept, and `[programs.toybox]` stays beside `[programs.kernelprobe]`,
  because `shutdown` is a shipped toybox applet and no probe.

`tests/toyos.rs` merged without conflict: main's two hunks are a doc line
and `virt_timer_preempts`'s arm, neither in a function this branch touches.
`tests/virtrebootcase/system.toml` names toybox alone, for `reboot`, and is
untouched.

`cargo test --test toyos-build -- virt_` on the merged tree before this
commit: exit 0, 19 of 19.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
@Japabu

Japabu commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

The same controls at de4e37b96, the merge of origin/main b331934c9 (#636): every patch in #647 (comment) passes git apply --check there unchanged, and n0-whole taken against b331934c9 is the same bytes (sha256 6b45fd5de382e10a8de31f14ff3e7094847a5a9e3a560abacecf4e3251cd1490). One script, after the gates: each patch checked, applied, the command run, the patch reversed, git status --porcelain --ignore-submodules=none empty before and after each.

arm command exit what it said
green cargo test --test toyos-build 0 25 passed, 25 total (21.3s); virt_off_names_the_cpus_left_on: 4992 PSCI call(s) traced
probe-trace-size … -- virt_ 0 19 passed, 19 total (18.0s); virt_off_left_on.psci 609,512 bytes, 4,996 calls, the row 9 s; virt_smp-SMC.psci 3,172 bytes, 26 calls; virt_smp-HVC.psci 3,050 bytes, 25 calls
d1-late-cpu-off … -- virt_ 0 19 passed, 19 total (18.9s): virt_smp 4 s, virt_el1_smp 4 s, virt_off_names_the_cpus_left_on 9 s, 4991 PSCI call(s) traced
m1-no-cpu-off … -- virt_smp 1 7 line(s) after the boot's last word, not []: cpu1 to cpu7 each named not off; 8 s
m2-no-off-sgi … -- virt_smp 1 the same seven lines; 8 s
m3-on-is-off … -- virt_off_names 1 0 line(s) after the boot's last word, not ["power: cpu6 is not off …", "power: cpu7 is not off …"]
m4-off-is-reset … -- virt_smp 1 QEMU stopped this guest for Some("guest-reset"), not "guest-shutdown"
m5-reset-is-off … -- virt_reboot 1 virt_reboot: QEMU stopped this guest for Some("guest-shutdown"), not "guest-reset"
m7-can-reset-true … -- virt_reboot_refused 1 STALLED: waiting for the job reboot to end — it went quiet
p1-can-reset-false … -- virt_fatal 1 STALLED: waiting for the fatal path's line past the stop — it went quiet
p2-panic-keys-true … -- virt_fatal 1 the same
c1-affinity-refused … -- virt_smp 1 power: cpu1's AFFINITY_INFO answered -2; SYSTEM_OFF regardless to cpu7's
n0-whole … -- virt_off_names 1 QEMU had not exited 15 s after it was asked to
t0-no-timer-stop … -- virt_ 0 19 passed, 19 total (22.2s)
s2-scout-gic-panic-resets … -- virt_scout 0 panic: rebooting in 60 s, timed by the counter frequency the CPU states; guest-reset 60.0 s after; [(84000009, 0)]
s3-scout-gic-panic-psci-last … -- virt_scout 1 panic: holding this panel, timed by the counter frequency the CPU states: this kernel has no reset to hand the machine back to firmware with

The host's one-minute load average ran from 6 to 15 across the script; no run was marked INVL. d1 on 17e636323 was not run again: that tree has not changed.

@Japabu

Japabu commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Round 3 at de4e37b96 (origin/main b331934c9 merged in, and main is still there). No CI job ran: the pull request is a draft, and host, toolchain and guest each read SKIPPED. The gates are the development host's exits in the body, read against their logs: cargo run -- --ci host 0 ([ci] Host: 64 step(s), all green), cargo test --test toyos-build 0 (25 passed, 25 total (21.3s), one-minute load 5.94). guest / suite under KVM has not run this head; it is a required check and runs when the pull request is made ready.

Earlier BLOCKERs:

  • kernel/src/arch/aarch64/power.rs (OTHERS_OFF, the flat 100 ms whose expiry reds QEMU rows) — CLOSED. D1 on 17e636323: cargo test --test toyos-build -- virt_ exit 1, 16 passed, 3 failed, virt_smp, virt_el1_smp and virt_off_names_the_cpus_left_on each naming cpu1 to cpu7. D1 at de4e37b96: the same command exit 0, 19 passed, 19 total (18.9s), the three rows green. At de4e37b96 m1 and m2 on virt_smp exit 1 (seven CPUs named, the row 8 s) and m3 on virt_off_names exit 1 (0 line(s) after the boot's last word). The trace is 609,512 bytes, 4,996 calls, and the row 9 s. The wait ends at time::DEAF_CPU's span, read at the site with its refusal-reason, and off declares no bound.
    • ASK_AGAIN is not a flat wait, and it stands on its measurement with no test. Its elapsing is never taken for the event: only AFFINITY_INFO's OFF, a refusal or the span's end leaves the loop, and PSCI offers nothing to wait on. It is the tree's Cadence used as one, as xhci::PORT_POLL and syscall/io.rs's CONSOLE_REPOLL pace an answer nothing posts. It does not ship for a test alone: on the shipping kernel with no actuator, virt_el1_smp's power-off made 2,040 firmware calls as a bare spin and makes 25 paced (virt_smp-HVC.psci, 248,880 to 3,050 bytes). A row red on the call count would be a QEMU row judging a rate, and the loop is a reader's to check. The fifty-times figure is arithmetic on a measured rate and the body says it is an estimate; there is nothing to send back to measure.
  • PR body (the T14 reading) — CLOSED. cargo test --test toyos-build -- --metal --metal-readback <dir> machine_ at de4e37b96: exit 0, PASS machine_reboot on ACPI: reset register SystemIO 0xcf9 <- 0x06, PASS machine_soft_off_decoded on ACPI: PM1a=0x1804 SLP_TYPa=7, [metal] 2 passed, 0 failed, 1 boot(s). The body carries the command, the exit and the words.

Net lines, git diff --shortstat origin/main...de4e37b96: 42 files, +1190 −352.

  • Production: kernel/ +478 −301, toyos-acpi/src and toyos-gicv3/src +56. Accepted: AArch64 gains a reset, a power-off and CPU_OFF it did not have, and x86-64's half moved.
  • Tests, fixtures and harness: +560 −45. src/: +7. issues/: +89 −6.

BLOCKER

None.

NOTE

  • tests/toyos.rs:1410,1572,1612 — let _ = fs::remove_file(&trace); removes a file that cannot be there and reads no status — the run's directory is new for every process (tests/common/lane.rs, Run::begin) and each of the four trace names has one row; delete the three lines.

REMOVE

  • tests/toyos.rs:3480-3481 — "The fatal one holds its panel, so the machine is still there to ask." — false at this head: the row's only machine arms the reset, and p1, the panic that holds, is the row's red.
  • PR body, Gates — "The same eight exited 0 at f4f6a453d, this round's commit before the merge (25 passed, 25 total (21.4s))." — the head's own eight are the table above it.

LAND AFTER NAMED CHANGES

`virt_smp`, `virt_reboot` and `virt_off_names_the_cpus_left_on` each removed
their PSCI trace before the boot that writes it, and read no status. The file
cannot be there: `lane::dir()` is under the directory `Run::begin` makes new
for every process, and each trace name has one row.

`the_others_halt_first`'s doc said the fatal CPU holds its panel. The only
machine that row boots arms the reset, and a panic that holds is its red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
@Japabu
Japabu marked this pull request as ready for review October 2, 2026 18:41
@Japabu
Japabu enabled auto-merge October 2, 2026 18:41
@Japabu
Japabu added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit dd87383 Oct 2, 2026
6 checks passed
@Japabu
Japabu deleted the wt/toyos-armnext branch October 2, 2026 19:23
Japabu added a commit that referenced this pull request Oct 2, 2026
No conflict. `src/build.rs` is the one file changed on both sides, in separate
hunks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
Japabu added a commit that referenced this pull request Oct 2, 2026
Two conflicts.

bootloader/src/main.rs: main's start_kernel takes no layout and a RootImage
that is always there; the branch's side is the rename alone, entry_counter and
root_read_ticks, on main's signature, handoff and call.

tests/toyos.rs: main's cut of the guest suite (520c0d1) deleted the x86-64
guest test boot_from_power_on, its MACHINE_TESTS entry and comment, its
run_machine_test arm and its judge; the guest-suite track owes it a metal row.
The branch had changed all four. Hunk by hunk of the branch's side:
- the SCREEN_TESTS row virt_boot_from_power_on: kept, in main's two-field shape;
- the MACHINE_TESTS comment and the run_machine_test arm passing Arch::X86_64:
  gone with the test main deleted;
- the run_screen_test arm virt_boot_from_power_on: kept;
- LOADER_COUNTER, POWER_ON, COUNTER_BACKWARDS, GENERIC_TIMER_HZ and the judge:
  kept for the virt row, AArch64's alone. TSC_PERIOD, the Arch parameter, the
  x86-64 period arm and the IA32_TSC_ADJUST tail had the deleted test as their
  only caller and go with it. A refusal carries the serial it read, as the
  other virt rows' do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
Japabu added a commit that referenced this pull request Oct 2, 2026
#647, #642 and #636's follow-up landed since the last merge. One conflict,
in tests/toyos.rs: #647 made `boot_virt_smp` take a `BootOptions` and
`judge_virt_job` borrow its guest, and this branch had added
`virt_mask_windows` on the old shape. Main's shape is kept whole;
`virt_mask_windows` names its kernel build in the options and lends its
guest like the others. `tests/virtsmpcase` now ends in `shutdown`, which
this test does not wait for: it judges once `unmap_touch` has ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
Japabu added a commit that referenced this pull request Oct 2, 2026
No conflict: #647 touches tests/toyos.rs away from this branch's two hunks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
Japabu added a commit that referenced this pull request Oct 2, 2026
…'s line is main's again

The named changes of #649's third review (comment 5960868119).

tests/toyos.rs: the merge of dd87383 took a space out of a line of #647's
in `virt_smp`. It is main's line again, so the file's diff against main is
this branch's alone.

The herd's defect: its own report at N = 256 read 1980239, 2 x 5075290 and
4725822 in this head's three T14 boots (comment 5960575031, readbacks
649-r6/), against 1032292, 2 x 519358 and 1175943 at 8b73eba. One reading
a size is decided by which boot carried one of the machine's own events, so
the exit takes the track's statistic: the median of at least five boots a
size, the tail beside it. The roster's syscall counts are said of the
fourteen boots they are the range of.

The 9 ms defect: its first reading recurred in 2-report-halved inside the
herd's own report, all eight CPUs at 2 x 5014552 to 2 x 5075290 with the
shootdown line's max=10038us beside it and nmi=1; and cpu7's line of the
first report read 11492028, 2 x 1308074 and 8456658, where the stop's kept
lines read at most 1396887, 2 x 758960 and 1470910. The exit covers the
one-CPU reading before the first job's exit as well.

The spawning CPU's defect: cpu7's line of the herd's report read 1980239 in
1-head, 468787 past the 1511452 that boot's `pwd` report reads for an
applet's spawn.

No byte of an image moves: the three boots at 0aa8d4c stand for this head
if its staged kernel is the one 1-head booted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
Japabu added a commit that referenced this pull request Oct 3, 2026
922 commits of main since 8ee3c51. What main deleted takes the branch's
hunks on it with it, and what main changed under the branch is taken as
main wrote it.

Conflicts, per file:

- tests/toyos.rs, tests/common/power.rs, tests/common/faults.rs,
  tests/common/qemu.rs: main's as written (#660 cut the guest suite to
  what only a booted machine answers, #625 deleted the tiers). The
  branch's hunks there had no home: its five `isa_` guest tests and
  `crash_report_reads_no_kernel_memory` with their helpers (`isa_ports`,
  `isa_verdict`, `isa_status_byte`, `wait_for_obf`), which stood on the
  tier tables, `Profile::MetalNoUsb`, `BootOptions::i8042`,
  `qmp_send_keys`, `logstream::stage` and the `i8042-fault` and
  `i8042-budget-expired` actuators, all gone; `panic_ignores_keys` and
  its edits to `panicked()`, `PANIC_REBOOTING` and `await_reset`, whose
  `panic_reboots` family main cut; and its deletions of
  `screen_pager_keys`, `screen_paged_scrollback` and
  `check_no_stale_cells`, which main had already deleted. The commit
  after this one puts the dropped tests' behaviours on main's tiers.
  `tests/toyos-rust-tests/src/bin/isa_grant.rs` goes with the harness
  that gave it its roles, and comes back there.
- tests/test-durations: deleted by #639; the branch's two removed rows
  have no file.
- src/sourcegate.rs: `ARCH_RULES` went with #624; the branch's row has
  no table.
- issues/build/assembly-outside-an-arch-module-in-userland-and-guest-probes.md:
  main rewrote the one line the branch reworded.
- kernel/src/sched/driver.rs: `irq_ring`'s `UserDev` arm went with #634,
  and `isa::drain_pending` with it. The row's handler posts its claim's
  watch itself (`IrqWatch::post_in_place`), and the first interrupt is
  announced at the holder's read, both as `pcidev` does on main.
- kernel/src/arch/{x86_64,aarch64}/pio.rs: #647 deleted both, their
  `EXISTS` and `out*` being dead. They come back holding only what the
  `isa` claim needs of an architecture.
- kernel/src/panic_reboot.rs: main's `PANIC_KEYS` goes with the key the
  panic path no longer reads, and the line it kept for a machine that
  reads none is the one line now ("the bound is over"); the reset is
  `power::reset_now` (#647) and the raw writes are under the console's
  registers (#675).
- kernel/src/arch/x86_64/i8042/mod.rs, aarch64/keyboard_controller.rs:
  main's `poll_byte` and `PANIC_KEYS` go with the pager.
- kernel/src/arch/x86_64/idt/mod.rs: `log_nest` went on main.
- kernel/src/actuator.rs: main's cut of the i8042's actuators stands.
- issues/kernel/the-kernel-is-small-interrupts-post-and-threads-wait.md:
  main's stage 6 with the branch's stage 7 after it, and main's three
  "#592's i8042 stage" read "stage 7.2". Stage 7.2 loses its clause
  about `i8042-trace` and `shell_type_once`, which main deleted.

What the merge had to change beside them, to build and to stay true on
main:

- `kernel/src/device.rs`: `Isa` joins the classes whose `Claimed::Class`
  drop cancels nothing, a match main added.
- The straddle actuator is the i8042 driver's own module
  (`i8042::straddle`), reached from `isa::claim` through one arch
  function, and it raises the driver's flood itself at each answered
  claim: main deleted `i8042-fault`, the flood the branch's test was
  staged with, and the guest's keys with it. `i8042-withheld` leaves the
  controller unprobed, where the branch used `i8042-budget-expired`.
- `panic-reboot-fast` comes back, which main deleted with the tests that
  armed it: `screen_fatal_behind_a_painter` proved its CPU watches the
  reset bound by the pager's second page, and with no pager the reset is
  the proof. It runs on the profile whose keys reach the i8042 and
  presses one inside the bound, which is what `panic_ignores_keys`
  asserted. `Profile::Gop`, which nothing else boots, goes.
- `screen_panic_muted` reads the arm line as it is now written.
- main's `report_line` and `heartbeat` are gone, so nothing reads the
  i8042's status port off `IRQ_CPU` and the quarantine's doc says so.
- The port grant's pure half is `toyos_userbound::port`: the bitmap as
  the processor reads it, which rows a switch opens and closes, and the
  decode of a refused `in` or `out`. A grantable port is a `u8`, so one
  past the bitmap is no row. `percpu` embeds the bitmap in the TSS and
  `pio` is what is left of the architecture.
- A mint no longer clears the row's record: its release cleared it, and
  an interrupt taken with no claim on the row is the next holder's to
  read.
- `toyos-tco`'s doc of the panic bound and the guest-suite track's two
  pager lines named what this branch deletes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant