detached bd83a2d

#26 fix(drm): allocate hardware cursor images as KMS dumb buffers

state
merged · 6d4359d · merge commit
branch
fix/nvidia-cursor-plane → main
opened
bnema ·
updated

Fixes neferwl#22.

Problem

On nvidia-drm, KMS refuses the hardware cursor plane with EINVAL (cursor plane refused; hardware cursor off). NeferWL then uses the software cursor, so every pointer motion forces a full frame. The cursor images were linear images that Vulkan allocated and exported, the same allocate-in-Vulkan, scan-out-in-KMS path that corrupted the primary plane on NVIDIA.

Fix

  • Allocation: cursor images are KMS dumb buffers (CREATE_DUMB, MAP_DUMB and mmap, AddFB2 by GEM handle), written by the CPU when the cursor image changes. Moves are unchanged: they only update the plane position.
  • Fallback: dumb buffers, then Vulkan cursor images, then the software cursor. Vulkan images are tried both when dumb allocation fails and when the plane refuses the dumb images in the TEST_ONLY probe; the current image is rewritten into them.
  • Log: hardware cursor logs alloc (dumb or vulkan, a warning for vulkan) and the size.
  • HDR cursor conversion: moved to internal/adapters/hdrcursor, so the drm adapter and the renderer write the same pixels. A recorded vector checks byte-identical output.

Verified

  • make check, make race, make mocks-check and staticcheck ./... pass.
  • Real-device dumb-buffer test (allocate, map, write, AddFB, read back; no modeset or commit) passes on amdgpu and on nvidia-drm 615.78.08.
  • Unit tests with generated mocks cover the allocator order, the TEST_ONLY refusal and swap (in-flight state, queued and hidden images), writes, crop and pitch, and cleanup.

Not verified

  • NVIDIA cursor plane acceptance with a display attached. This needs a test on an NVIDIA machine: the log should show "message":"hardware cursor" with "alloc":"dumb" and no cursor plane refused, and the cursor should show at startup.
CommitSubjectAuthorDateSignatureStatus
bd83a2dtest(drm): cover cursor fallback state, crop rows and file ownershipbriceGPG Verified
a5f1806fix(drm): allocate hardware cursor images as KMS dumb buffersbriceGPG Verified

Checks 1

28 files · +1824 -238
1/1
M .goreleaser.yaml +2 -2
@@ -60,7 +60,7 @@ nfpms:
6060 homepage: https://git.bnema.dev/neferwl
6161 maintainer: "bnema <b@bnema.dev>"
6262 description: A Wayland compositor that spends its frames on your apps, not on itself
63- license: AGPL-3.0-only
63+ license: GPL-3.0-only
6464 section: x11
6565 bindir: /usr/bin
6666 # Loaded at runtime (purego, no cgo): dpkg-shlibdeps cannot see them.
@@ -192,7 +192,7 @@ aurs:
192192 description: A Wayland compositor that spends its frames on your apps, not on itself
193193 maintainers:
194194 - "bnema <b at bnema dot dev>"
195- license: AGPL-3.0-only
195+ license: GPL-3.0-only
196196 # The release workflow verifies and publishes the generated PKGBUILD.
197197 skip_upload: true
198198 # publish.sh ships the file next to the PKGBUILD.
@@ -60,7 +60,7 @@ nfpms:
60 homepage: https://git.bnema.dev/neferwl60 homepage: https://git.bnema.dev/neferwl
61 maintainer: "bnema <b@bnema.dev>"61 maintainer: "bnema <b@bnema.dev>"
62 description: A Wayland compositor that spends its frames on your apps, not on itself62 description: A Wayland compositor that spends its frames on your apps, not on itself
63- license: AGPL-3.0-only63+ license: GPL-3.0-only
64 section: x1164 section: x11
65 bindir: /usr/bin65 bindir: /usr/bin
66 # Loaded at runtime (purego, no cgo): dpkg-shlibdeps cannot see them.66 # Loaded at runtime (purego, no cgo): dpkg-shlibdeps cannot see them.
@@ -192,7 +192,7 @@ aurs:
192 description: A Wayland compositor that spends its frames on your apps, not on itself192 description: A Wayland compositor that spends its frames on your apps, not on itself
193 maintainers:193 maintainers:
194 - "bnema <b at bnema dot dev>"194 - "bnema <b at bnema dot dev>"
195- license: AGPL-3.0-only195+ license: GPL-3.0-only
196 # The release workflow verifies and publishes the generated PKGBUILD.196 # The release workflow verifies and publishes the generated PKGBUILD.
197 skip_upload: true197 skip_upload: true
198 # publish.sh ships the file next to the PKGBUILD.198 # publish.sh ships the file next to the PKGBUILD.
M .mockery.yml +5 -0
@@ -44,6 +44,11 @@ packages:
4444 dir: internal/adapters/drm
4545 pkgname: drm
4646 filename: image_buffer_mock_test.go
47+ cursorImages:
48+ config:
49+ dir: internal/adapters/drm
50+ pkgname: drm
51+ filename: cursor_images_mock_test.go
4752 github.com/bnema/neferwl/internal/adapters/seat:
4853 interfaces:
4954 disabler:
@@ -44,6 +44,11 @@ packages:
44 dir: internal/adapters/drm44 dir: internal/adapters/drm
45 pkgname: drm45 pkgname: drm
46 filename: image_buffer_mock_test.go46 filename: image_buffer_mock_test.go
47+ cursorImages:
48+ config:
49+ dir: internal/adapters/drm
50+ pkgname: drm
51+ filename: cursor_images_mock_test.go
47 github.com/bnema/neferwl/internal/adapters/seat:52 github.com/bnema/neferwl/internal/adapters/seat:
48 interfaces:53 interfaces:
49 disabler:54 disabler:
M LICENSE +80 -67
@@ -1,5 +1,5 @@
1- GNU AFFERO GENERAL PUBLIC LICENSE
2- Version 3, 19 November 2007
1+ GNU GENERAL PUBLIC LICENSE
2+ Version 3, 29 June 2007
33
44 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
55 Everyone is permitted to copy and distribute verbatim copies
@@ -7,15 +7,17 @@
77
88 Preamble
99
10- The GNU Affero General Public License is a free, copyleft license for
11-software and other kinds of works, specifically designed to ensure
12-cooperation with the community in the case of network server software.
10+ The GNU General Public License is a free, copyleft license for
11+software and other kinds of works.
1312
1413 The licenses for most software and other practical works are designed
1514 to take away your freedom to share and change the works. By contrast,
16-our General Public Licenses are intended to guarantee your freedom to
15+the GNU General Public License is intended to guarantee your freedom to
1716 share and change all versions of a program--to make sure it remains free
18-software for all its users.
17+software for all its users. We, the Free Software Foundation, use the
18+GNU General Public License for most of our software; it applies also to
19+any other work released this way by its authors. You can apply it to
20+your programs, too.
1921
2022 When we speak of free software, we are referring to freedom, not
2123 price. Our General Public Licenses are designed to make sure that you
@@ -24,34 +26,44 @@ them if you wish), that you receive source code or can get it if you
2426 want it, that you can change the software or use pieces of it in new
2527 free programs, and that you know you can do these things.
2628
27- Developers that use our General Public Licenses protect your rights
28-with two steps: (1) assert copyright on the software, and (2) offer
29-you this License which gives you legal permission to copy, distribute
30-and/or modify the software.
31-
32- A secondary benefit of defending all users' freedom is that
33-improvements made in alternate versions of the program, if they
34-receive widespread use, become available for other developers to
35-incorporate. Many developers of free software are heartened and
36-encouraged by the resulting cooperation. However, in the case of
37-software used on network servers, this result may fail to come about.
38-The GNU General Public License permits making a modified version and
39-letting the public access it on a server without ever releasing its
40-source code to the public.
41-
42- The GNU Affero General Public License is designed specifically to
43-ensure that, in such cases, the modified source code becomes available
44-to the community. It requires the operator of a network server to
45-provide the source code of the modified version running there to the
46-users of that server. Therefore, public use of a modified version, on
47-a publicly accessible server, gives the public access to the source
48-code of the modified version.
49-
50- An older license, called the Affero General Public License and
51-published by Affero, was designed to accomplish similar goals. This is
52-a different license, not a version of the Affero GPL, but Affero has
53-released a new version of the Affero GPL which permits relicensing under
54-this license.
29+ To protect your rights, we need to prevent others from denying you
30+these rights or asking you to surrender the rights. Therefore, you have
31+certain responsibilities if you distribute copies of the software, or if
32+you modify it: responsibilities to respect the freedom of others.
33+
34+ For example, if you distribute copies of such a program, whether
35+gratis or for a fee, you must pass on to the recipients the same
36+freedoms that you received. You must make sure that they, too, receive
37+or can get the source code. And you must show them these terms so they
38+know their rights.
39+
40+ Developers that use the GNU GPL protect your rights with two steps:
41+(1) assert copyright on the software, and (2) offer you this License
42+giving you legal permission to copy, distribute and/or modify it.
43+
44+ For the developers' and authors' protection, the GPL clearly explains
45+that there is no warranty for this free software. For both users' and
46+authors' sake, the GPL requires that modified versions be marked as
47+changed, so that their problems will not be attributed erroneously to
48+authors of previous versions.
49+
50+ Some devices are designed to deny users access to install or run
51+modified versions of the software inside them, although the manufacturer
52+can do so. This is fundamentally incompatible with the aim of
53+protecting users' freedom to change the software. The systematic
54+pattern of such abuse occurs in the area of products for individuals to
55+use, which is precisely where it is most unacceptable. Therefore, we
56+have designed this version of the GPL to prohibit the practice for those
57+products. If such problems arise substantially in other domains, we
58+stand ready to extend this provision to those domains in future versions
59+of the GPL, as needed to protect the freedom of users.
60+
61+ Finally, every program is threatened constantly by software patents.
62+States should not allow patents to restrict development and use of
63+software on general-purpose computers, but in those that do, we wish to
64+avoid the special danger that patents applied to a free program could
65+make it effectively proprietary. To prevent this, the GPL assures that
66+patents cannot be used to render the program non-free.
5567
5668 The precise terms and conditions for copying, distribution and
5769 modification follow.
@@ -60,7 +72,7 @@ modification follow.
6072
6173 0. Definitions.
6274
63- "This License" refers to version 3 of the GNU Affero General Public License.
75+ "This License" refers to version 3 of the GNU General Public License.
6476
6577 "Copyright" also means copyright-like laws that apply to other kinds of
6678 works, such as semiconductor masks.
@@ -537,45 +549,35 @@ to collect a royalty for further conveying from those to whom you convey
537549 the Program, the only way you could satisfy both those terms and this
538550 License would be to refrain entirely from conveying the Program.
539551
540- 13. Remote Network Interaction; Use with the GNU General Public License.
541-
542- Notwithstanding any other provision of this License, if you modify the
543-Program, your modified version must prominently offer all users
544-interacting with it remotely through a computer network (if your version
545-supports such interaction) an opportunity to receive the Corresponding
546-Source of your version by providing access to the Corresponding Source
547-from a network server at no charge, through some standard or customary
548-means of facilitating copying of software. This Corresponding Source
549-shall include the Corresponding Source for any work covered by version 3
550-of the GNU General Public License that is incorporated pursuant to the
551-following paragraph.
552+ 13. Use with the GNU Affero General Public License.
552553
553554 Notwithstanding any other provision of this License, you have
554555 permission to link or combine any covered work with a work licensed
555-under version 3 of the GNU General Public License into a single
556+under version 3 of the GNU Affero General Public License into a single
556557 combined work, and to convey the resulting work. The terms of this
557558 License will continue to apply to the part which is the covered work,
558-but the work with which it is combined will remain governed by version
559-3 of the GNU General Public License.
559+but the special requirements of the GNU Affero General Public License,
560+section 13, concerning interaction through a network will apply to the
561+combination as such.
560562
561563 14. Revised Versions of this License.
562564
563565 The Free Software Foundation may publish revised and/or new versions of
564-the GNU Affero General Public License from time to time. Such new versions
565-will be similar in spirit to the present version, but may differ in detail to
566+the GNU General Public License from time to time. Such new versions will
567+be similar in spirit to the present version, but may differ in detail to
566568 address new problems or concerns.
567569
568570 Each version is given a distinguishing version number. If the
569-Program specifies that a certain numbered version of the GNU Affero General
571+Program specifies that a certain numbered version of the GNU General
570572 Public License "or any later version" applies to it, you have the
571573 option of following the terms and conditions either of that numbered
572574 version or of any later version published by the Free Software
573575 Foundation. If the Program does not specify a version number of the
574-GNU Affero General Public License, you may choose any version ever published
576+GNU General Public License, you may choose any version ever published
575577 by the Free Software Foundation.
576578
577579 If the Program specifies that a proxy can decide which future
578-versions of the GNU Affero General Public License can be used, that proxy's
580+versions of the GNU General Public License can be used, that proxy's
579581 public statement of acceptance of a version permanently authorizes you
580582 to choose that version for the Program.
581583
@@ -633,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found.
633635 Copyright (C) <year> <name of author>
634636
635637 This program is free software: you can redistribute it and/or modify
636- it under the terms of the GNU Affero General Public License as published by
638+ it under the terms of the GNU General Public License as published by
637639 the Free Software Foundation, either version 3 of the License, or
638640 (at your option) any later version.
639641
640642 This program is distributed in the hope that it will be useful,
641643 but WITHOUT ANY WARRANTY; without even the implied warranty of
642644 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643- GNU Affero General Public License for more details.
645+ GNU General Public License for more details.
644646
645- You should have received a copy of the GNU Affero General Public License
647+ You should have received a copy of the GNU General Public License
646648 along with this program. If not, see <https://www.gnu.org/licenses/>.
647649
648650 Also add information on how to contact you by electronic and paper mail.
649651
650- If your software can interact with users remotely through a computer
651-network, you should also make sure that it provides a way for users to
652-get its source. For example, if your program is a web application, its
653-interface could display a "Source" link that leads users to an archive
654-of the code. There are many ways you could offer source, and different
655-solutions will be better for different programs; see section 13 for the
656-specific requirements.
652+ If the program does terminal interaction, make it output a short
653+notice like this when it starts in an interactive mode:
654+
655+ <program> Copyright (C) <year> <name of author>
656+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657+ This is free software, and you are welcome to redistribute it
658+ under certain conditions; type `show c' for details.
659+
660+The hypothetical commands `show w' and `show c' should show the appropriate
661+parts of the General Public License. Of course, your program's commands
662+might be different; for a GUI interface, you would use an "about box".
657663
658664 You should also get your employer (if you work as a programmer) or school,
659665 if any, to sign a "copyright disclaimer" for the program, if necessary.
660-For more information on this, and how to apply and follow the GNU AGPL, see
666+For more information on this, and how to apply and follow the GNU GPL, see
661667 <https://www.gnu.org/licenses/>.
668+
669+ The GNU General Public License does not permit incorporating your program
670+into proprietary programs. If your program is a subroutine library, you
671+may consider it more useful to permit linking proprietary applications with
672+the library. If this is what you want to do, use the GNU Lesser General
673+Public License instead of this License. But first, please read
674+<https://www.gnu.org/licenses/why-not-lgpl.html>.
@@ -1,5 +1,5 @@
1- GNU AFFERO GENERAL PUBLIC LICENSE1+ GNU GENERAL PUBLIC LICENSE
2- Version 3, 19 November 20072+ Version 3, 29 June 2007
3 3
4 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>4 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5 Everyone is permitted to copy and distribute verbatim copies5 Everyone is permitted to copy and distribute verbatim copies
@@ -7,15 +7,17 @@
7 7
8 Preamble8 Preamble
9 9
10- The GNU Affero General Public License is a free, copyleft license for10+ The GNU General Public License is a free, copyleft license for
11-software and other kinds of works, specifically designed to ensure11+software and other kinds of works.
12-cooperation with the community in the case of network server software.
13 12
14 The licenses for most software and other practical works are designed13 The licenses for most software and other practical works are designed
15 to take away your freedom to share and change the works. By contrast,14 to take away your freedom to share and change the works. By contrast,
16-our General Public Licenses are intended to guarantee your freedom to15+the GNU General Public License is intended to guarantee your freedom to
17 share and change all versions of a program--to make sure it remains free16 share and change all versions of a program--to make sure it remains free
18-software for all its users.17+software for all its users. We, the Free Software Foundation, use the
18+GNU General Public License for most of our software; it applies also to
19+any other work released this way by its authors. You can apply it to
20+your programs, too.
19 21
20 When we speak of free software, we are referring to freedom, not22 When we speak of free software, we are referring to freedom, not
21 price. Our General Public Licenses are designed to make sure that you23 price. Our General Public Licenses are designed to make sure that you
@@ -24,34 +26,44 @@ them if you wish), that you receive source code or can get it if you
24 want it, that you can change the software or use pieces of it in new26 want it, that you can change the software or use pieces of it in new
25 free programs, and that you know you can do these things.27 free programs, and that you know you can do these things.
26 28
27- Developers that use our General Public Licenses protect your rights29+ To protect your rights, we need to prevent others from denying you
28-with two steps: (1) assert copyright on the software, and (2) offer30+these rights or asking you to surrender the rights. Therefore, you have
29-you this License which gives you legal permission to copy, distribute31+certain responsibilities if you distribute copies of the software, or if
30-and/or modify the software.32+you modify it: responsibilities to respect the freedom of others.
31-33+
32- A secondary benefit of defending all users' freedom is that34+ For example, if you distribute copies of such a program, whether
33-improvements made in alternate versions of the program, if they35+gratis or for a fee, you must pass on to the recipients the same
34-receive widespread use, become available for other developers to36+freedoms that you received. You must make sure that they, too, receive
35-incorporate. Many developers of free software are heartened and37+or can get the source code. And you must show them these terms so they
36-encouraged by the resulting cooperation. However, in the case of38+know their rights.
37-software used on network servers, this result may fail to come about.39+
38-The GNU General Public License permits making a modified version and40+ Developers that use the GNU GPL protect your rights with two steps:
39-letting the public access it on a server without ever releasing its41+(1) assert copyright on the software, and (2) offer you this License
40-source code to the public.42+giving you legal permission to copy, distribute and/or modify it.
41-43+
42- The GNU Affero General Public License is designed specifically to44+ For the developers' and authors' protection, the GPL clearly explains
43-ensure that, in such cases, the modified source code becomes available45+that there is no warranty for this free software. For both users' and
44-to the community. It requires the operator of a network server to46+authors' sake, the GPL requires that modified versions be marked as
45-provide the source code of the modified version running there to the47+changed, so that their problems will not be attributed erroneously to
46-users of that server. Therefore, public use of a modified version, on48+authors of previous versions.
47-a publicly accessible server, gives the public access to the source49+
48-code of the modified version.50+ Some devices are designed to deny users access to install or run
49-51+modified versions of the software inside them, although the manufacturer
50- An older license, called the Affero General Public License and52+can do so. This is fundamentally incompatible with the aim of
51-published by Affero, was designed to accomplish similar goals. This is53+protecting users' freedom to change the software. The systematic
52-a different license, not a version of the Affero GPL, but Affero has54+pattern of such abuse occurs in the area of products for individuals to
53-released a new version of the Affero GPL which permits relicensing under55+use, which is precisely where it is most unacceptable. Therefore, we
54-this license.56+have designed this version of the GPL to prohibit the practice for those
57+products. If such problems arise substantially in other domains, we
58+stand ready to extend this provision to those domains in future versions
59+of the GPL, as needed to protect the freedom of users.
60+
61+ Finally, every program is threatened constantly by software patents.
62+States should not allow patents to restrict development and use of
63+software on general-purpose computers, but in those that do, we wish to
64+avoid the special danger that patents applied to a free program could
65+make it effectively proprietary. To prevent this, the GPL assures that
66+patents cannot be used to render the program non-free.
55 67
56 The precise terms and conditions for copying, distribution and68 The precise terms and conditions for copying, distribution and
57 modification follow.69 modification follow.
@@ -60,7 +72,7 @@ modification follow.
60 72
61 0. Definitions.73 0. Definitions.
62 74
63- "This License" refers to version 3 of the GNU Affero General Public License.75+ "This License" refers to version 3 of the GNU General Public License.
64 76
65 "Copyright" also means copyright-like laws that apply to other kinds of77 "Copyright" also means copyright-like laws that apply to other kinds of
66 works, such as semiconductor masks.78 works, such as semiconductor masks.
@@ -537,45 +549,35 @@ to collect a royalty for further conveying from those to whom you convey
537 the Program, the only way you could satisfy both those terms and this549 the Program, the only way you could satisfy both those terms and this
538 License would be to refrain entirely from conveying the Program.550 License would be to refrain entirely from conveying the Program.
539 551
540- 13. Remote Network Interaction; Use with the GNU General Public License.552+ 13. Use with the GNU Affero General Public License.
541-
542- Notwithstanding any other provision of this License, if you modify the
543-Program, your modified version must prominently offer all users
544-interacting with it remotely through a computer network (if your version
545-supports such interaction) an opportunity to receive the Corresponding
546-Source of your version by providing access to the Corresponding Source
547-from a network server at no charge, through some standard or customary
548-means of facilitating copying of software. This Corresponding Source
549-shall include the Corresponding Source for any work covered by version 3
550-of the GNU General Public License that is incorporated pursuant to the
551-following paragraph.
552 553
553 Notwithstanding any other provision of this License, you have554 Notwithstanding any other provision of this License, you have
554 permission to link or combine any covered work with a work licensed555 permission to link or combine any covered work with a work licensed
555-under version 3 of the GNU General Public License into a single556+under version 3 of the GNU Affero General Public License into a single
556 combined work, and to convey the resulting work. The terms of this557 combined work, and to convey the resulting work. The terms of this
557 License will continue to apply to the part which is the covered work,558 License will continue to apply to the part which is the covered work,
558-but the work with which it is combined will remain governed by version559+but the special requirements of the GNU Affero General Public License,
559-3 of the GNU General Public License.560+section 13, concerning interaction through a network will apply to the
561+combination as such.
560 562
561 14. Revised Versions of this License.563 14. Revised Versions of this License.
562 564
563 The Free Software Foundation may publish revised and/or new versions of565 The Free Software Foundation may publish revised and/or new versions of
564-the GNU Affero General Public License from time to time. Such new versions566+the GNU General Public License from time to time. Such new versions will
565-will be similar in spirit to the present version, but may differ in detail to567+be similar in spirit to the present version, but may differ in detail to
566 address new problems or concerns.568 address new problems or concerns.
567 569
568 Each version is given a distinguishing version number. If the570 Each version is given a distinguishing version number. If the
569-Program specifies that a certain numbered version of the GNU Affero General571+Program specifies that a certain numbered version of the GNU General
570 Public License "or any later version" applies to it, you have the572 Public License "or any later version" applies to it, you have the
571 option of following the terms and conditions either of that numbered573 option of following the terms and conditions either of that numbered
572 version or of any later version published by the Free Software574 version or of any later version published by the Free Software
573 Foundation. If the Program does not specify a version number of the575 Foundation. If the Program does not specify a version number of the
574-GNU Affero General Public License, you may choose any version ever published576+GNU General Public License, you may choose any version ever published
575 by the Free Software Foundation.577 by the Free Software Foundation.
576 578
577 If the Program specifies that a proxy can decide which future579 If the Program specifies that a proxy can decide which future
578-versions of the GNU Affero General Public License can be used, that proxy's580+versions of the GNU General Public License can be used, that proxy's
579 public statement of acceptance of a version permanently authorizes you581 public statement of acceptance of a version permanently authorizes you
580 to choose that version for the Program.582 to choose that version for the Program.
581 583
@@ -633,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found.
633 Copyright (C) <year> <name of author>635 Copyright (C) <year> <name of author>
634 636
635 This program is free software: you can redistribute it and/or modify637 This program is free software: you can redistribute it and/or modify
636- it under the terms of the GNU Affero General Public License as published by638+ it under the terms of the GNU General Public License as published by
637 the Free Software Foundation, either version 3 of the License, or639 the Free Software Foundation, either version 3 of the License, or
638 (at your option) any later version.640 (at your option) any later version.
639 641
640 This program is distributed in the hope that it will be useful,642 This program is distributed in the hope that it will be useful,
641 but WITHOUT ANY WARRANTY; without even the implied warranty of643 but WITHOUT ANY WARRANTY; without even the implied warranty of
642 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the644 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643- GNU Affero General Public License for more details.645+ GNU General Public License for more details.
644 646
645- You should have received a copy of the GNU Affero General Public License647+ You should have received a copy of the GNU General Public License
646 along with this program. If not, see <https://www.gnu.org/licenses/>.648 along with this program. If not, see <https://www.gnu.org/licenses/>.
647 649
648 Also add information on how to contact you by electronic and paper mail.650 Also add information on how to contact you by electronic and paper mail.
649 651
650- If your software can interact with users remotely through a computer652+ If the program does terminal interaction, make it output a short
651-network, you should also make sure that it provides a way for users to653+notice like this when it starts in an interactive mode:
652-get its source. For example, if your program is a web application, its654+
653-interface could display a "Source" link that leads users to an archive655+ <program> Copyright (C) <year> <name of author>
654-of the code. There are many ways you could offer source, and different656+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
655-solutions will be better for different programs; see section 13 for the657+ This is free software, and you are welcome to redistribute it
656-specific requirements.658+ under certain conditions; type `show c' for details.
659+
660+The hypothetical commands `show w' and `show c' should show the appropriate
661+parts of the General Public License. Of course, your program's commands
662+might be different; for a GUI interface, you would use an "about box".
657 663
658 You should also get your employer (if you work as a programmer) or school,664 You should also get your employer (if you work as a programmer) or school,
659 if any, to sign a "copyright disclaimer" for the program, if necessary.665 if any, to sign a "copyright disclaimer" for the program, if necessary.
660-For more information on this, and how to apply and follow the GNU AGPL, see666+For more information on this, and how to apply and follow the GNU GPL, see
661 <https://www.gnu.org/licenses/>.667 <https://www.gnu.org/licenses/>.
668+
669+ The GNU General Public License does not permit incorporating your program
670+into proprietary programs. If your program is a subroutine library, you
671+may consider it more useful to permit linking proprietary applications with
672+the library. If this is what you want to do, use the GNU Lesser General
673+Public License instead of this License. But first, please read
674+<https://www.gnu.org/licenses/why-not-lgpl.html>.
M Makefile +1 -1
@@ -61,7 +61,7 @@ arch: adapter-imports
6161 # Adapters stay independent: production code may import only the shared
6262 # adapter libraries below. outputkit holds what both output backends (drm,
6363 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
64+ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|hdrcursor|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
6565 adapter-imports:
6666 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
6767 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
@@ -61,7 +61,7 @@ arch: adapter-imports
61 # Adapters stay independent: production code may import only the shared61 # Adapters stay independent: production code may import only the shared
62 # adapter libraries below. outputkit holds what both output backends (drm,62 # adapter libraries below. outputkit holds what both output backends (drm,
63 # headless) run; the others are leaf helpers.63 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb64+ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|hdrcursor|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
65 adapter-imports:65 adapter-imports:
66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
M README.md +2 -2
@@ -1,7 +1,7 @@
11 <h1 align="center">NeferWL</h1>
22
33 <p align="center">
4- <a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-blue?style=flat-square" alt="License: AGPLv3"></a>
4+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-GPLv3-blue?style=flat-square" alt="License: GPLv3"></a>
55 <a href="https://github.com/bnema/neferwl"><img src="https://img.shields.io/badge/platform-Linux-blue?style=flat-square" alt="Platform: Linux"></a>
66 <a href="https://github.com/bnema/neferwl/commits/main"><img src="https://img.shields.io/github/last-commit/bnema/neferwl/main?style=flat-square&logo=github" alt="Last commit"></a>
77 </p>
@@ -191,4 +191,4 @@ Before submitting, run `make check`, `make race`, `make mocks-check` and `static
191191
192192 ## License
193193
194-[GNU Affero General Public License v3.0](LICENSE).
194+[GNU General Public License v3.0](LICENSE).
@@ -1,7 +1,7 @@
1 <h1 align="center">NeferWL</h1>1 <h1 align="center">NeferWL</h1>
2 2
3 <p align="center">3 <p align="center">
4- <a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-blue?style=flat-square" alt="License: AGPLv3"></a>4+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-GPLv3-blue?style=flat-square" alt="License: GPLv3"></a>
5 <a href="https://github.com/bnema/neferwl"><img src="https://img.shields.io/badge/platform-Linux-blue?style=flat-square" alt="Platform: Linux"></a>5 <a href="https://github.com/bnema/neferwl"><img src="https://img.shields.io/badge/platform-Linux-blue?style=flat-square" alt="Platform: Linux"></a>
6 <a href="https://github.com/bnema/neferwl/commits/main"><img src="https://img.shields.io/github/last-commit/bnema/neferwl/main?style=flat-square&logo=github" alt="Last commit"></a>6 <a href="https://github.com/bnema/neferwl/commits/main"><img src="https://img.shields.io/github/last-commit/bnema/neferwl/main?style=flat-square&logo=github" alt="Last commit"></a>
7 </p>7 </p>
@@ -191,4 +191,4 @@ Before submitting, run `make check`, `make race`, `make mocks-check` and `static
191 191
192 ## License192 ## License
193 193
194-[GNU Affero General Public License v3.0](LICENSE).194+[GNU General Public License v3.0](LICENSE).
M docs/dev/architecture.md +7 -0
@@ -32,6 +32,13 @@ A DRM output flips between two images the renderer draws into, with no CPU copy.
3232 - **Fallback:** when libgbm is missing, GBM allocates nothing usable, or Vulkan or KMS refuses the images, the renderer allocates and exports them itself. Each kind (driver modifier, single-plane, linear) tries GBM, then the renderer. The log line `zero-copy output` names the allocator in its `alloc` field (`gbm` or `vulkan`), and is a warning for `vulkan`. This fallback is deprecated and goes away once GBM is validated on more setups (neferwl#23).
3333 - **Sync:** nvidia-drm before 560.35.3 does not wait for `IN_FENCE_FD`. On it, the output waits for each frame's fence on the CPU and commits without one. A client buffer whose fence is still pending is composed, not scanned out, while `IN_FENCE_FD` is unavailable.
3434
35+### Cursor images
36+
37+The cursor plane shows the hardware cursor from two ARGB8888 images that the CPU writes only when the cursor image changes, never on a move.
38+
39+- **Allocation:** the images are KMS dumb buffers, mapped for the CPU, with the pitch the driver picks. The output converts them to HDR10 when it is in HDR.
40+- **Fallback:** when KMS cannot make dumb buffers, or the plane refuses them in a TEST_ONLY commit, the renderer's linear cursor images are used. When the plane refuses those too, the cursor is drawn into every frame (software cursor). The log line `hardware cursor` names the allocator in its `alloc` field (`dumb` or `vulkan`), and is a warning for `vulkan`.
41+
3542 I also like bringing more tools to the Go ecosystem. NeferWL grew its own libraries along the way, and other Go projects can use them:
3643
3744 - [purego-libwayland](https://github.com/bnema/purego-libwayland): libwayland-server without cgo.
@@ -32,6 +32,13 @@ A DRM output flips between two images the renderer draws into, with no CPU copy.
32 - **Fallback:** when libgbm is missing, GBM allocates nothing usable, or Vulkan or KMS refuses the images, the renderer allocates and exports them itself. Each kind (driver modifier, single-plane, linear) tries GBM, then the renderer. The log line `zero-copy output` names the allocator in its `alloc` field (`gbm` or `vulkan`), and is a warning for `vulkan`. This fallback is deprecated and goes away once GBM is validated on more setups (neferwl#23).32 - **Fallback:** when libgbm is missing, GBM allocates nothing usable, or Vulkan or KMS refuses the images, the renderer allocates and exports them itself. Each kind (driver modifier, single-plane, linear) tries GBM, then the renderer. The log line `zero-copy output` names the allocator in its `alloc` field (`gbm` or `vulkan`), and is a warning for `vulkan`. This fallback is deprecated and goes away once GBM is validated on more setups (neferwl#23).
33 - **Sync:** nvidia-drm before 560.35.3 does not wait for `IN_FENCE_FD`. On it, the output waits for each frame's fence on the CPU and commits without one. A client buffer whose fence is still pending is composed, not scanned out, while `IN_FENCE_FD` is unavailable.33 - **Sync:** nvidia-drm before 560.35.3 does not wait for `IN_FENCE_FD`. On it, the output waits for each frame's fence on the CPU and commits without one. A client buffer whose fence is still pending is composed, not scanned out, while `IN_FENCE_FD` is unavailable.
34 34
35+### Cursor images
36+
37+The cursor plane shows the hardware cursor from two ARGB8888 images that the CPU writes only when the cursor image changes, never on a move.
38+
39+- **Allocation:** the images are KMS dumb buffers, mapped for the CPU, with the pitch the driver picks. The output converts them to HDR10 when it is in HDR.
40+- **Fallback:** when KMS cannot make dumb buffers, or the plane refuses them in a TEST_ONLY commit, the renderer's linear cursor images are used. When the plane refuses those too, the cursor is drawn into every frame (software cursor). The log line `hardware cursor` names the allocator in its `alloc` field (`dumb` or `vulkan`), and is a warning for `vulkan`.
41+
35 I also like bringing more tools to the Go ecosystem. NeferWL grew its own libraries along the way, and other Go projects can use them:42 I also like bringing more tools to the Go ecosystem. NeferWL grew its own libraries along the way, and other Go projects can use them:
36 43
37 - [purego-libwayland](https://github.com/bnema/purego-libwayland): libwayland-server without cgo.44 - [purego-libwayland](https://github.com/bnema/purego-libwayland): libwayland-server without cgo.
M internal/adapters/drm/atomic.go +9 -0
@@ -89,6 +89,15 @@ type kms interface {
8989 // addFB imports a single-plane dmabuf as a framebuffer of format.
9090 addFB(b *ports.DMABuf, format uint32) (uint32, error)
9191 rmFB(id uint32) error
92+ // createDumb allocates a width×height 32 bpp dumb buffer; mapDumb maps it
93+ // for the CPU (read/write) and unmapDumb releases the mapping;
94+ // addFBHandle makes a framebuffer of format from it; destroyDumb frees
95+ // it. The framebuffer is removed with rmFB first.
96+ createDumb(width, height int) (dumbBuffer, error)
97+ mapDumb(b dumbBuffer) ([]byte, error)
98+ unmapDumb(mem []byte) error
99+ addFBHandle(b dumbBuffer, width, height int, format uint32) (uint32, error)
100+ destroyDumb(handle uint32) error
92101 createLease(objects []uint32) (*os.File, uint32, error)
93102 revokeLease(id uint32) error
94103 resources() ([]uint32, []uint32, error)
@@ -89,6 +89,15 @@ type kms interface {
89 // addFB imports a single-plane dmabuf as a framebuffer of format.89 // addFB imports a single-plane dmabuf as a framebuffer of format.
90 addFB(b *ports.DMABuf, format uint32) (uint32, error)90 addFB(b *ports.DMABuf, format uint32) (uint32, error)
91 rmFB(id uint32) error91 rmFB(id uint32) error
92+ // createDumb allocates a width×height 32 bpp dumb buffer; mapDumb maps it
93+ // for the CPU (read/write) and unmapDumb releases the mapping;
94+ // addFBHandle makes a framebuffer of format from it; destroyDumb frees
95+ // it. The framebuffer is removed with rmFB first.
96+ createDumb(width, height int) (dumbBuffer, error)
97+ mapDumb(b dumbBuffer) ([]byte, error)
98+ unmapDumb(mem []byte) error
99+ addFBHandle(b dumbBuffer, width, height int, format uint32) (uint32, error)
100+ destroyDumb(handle uint32) error
92 createLease(objects []uint32) (*os.File, uint32, error)101 createLease(objects []uint32) (*os.File, uint32, error)
93 revokeLease(id uint32) error102 revokeLease(id uint32) error
94 resources() ([]uint32, []uint32, error)103 resources() ([]uint32, []uint32, error)
M internal/adapters/drm/commit_test.go +8 -6
@@ -55,9 +55,10 @@ func TestBusyFrameCommitsAfterEvent(t *testing.T) {
5555 func TestCursorImageNeverOverwritesScreen(t *testing.T) {
5656 o, _, _ := testOutput(t)
5757 c := o.cursor
58- r := portsmocks.NewMockRenderer(t)
58+ imgs := newMockcursorImages(t)
59+ c.imgs = imgs
5960 var written []int
60- r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).RunAndReturn(func(i int, _ []byte, _, _ int) error {
61+ imgs.EXPECT().write(mock.Anything, mock.Anything, 1, 1, float64(0)).RunAndReturn(func(i int, _ []byte, _, _ int, _ float64) error {
6162 written = append(written, i)
6263 return nil
6364 })
@@ -65,7 +66,7 @@ func TestCursorImageNeverOverwritesScreen(t *testing.T) {
6566 // Image 0 on screen: two changes before a commit both go to image 1.
6667 c.screen, c.cur, c.image = 90, 0, true
6768 for range 2 {
68- if err := c.setImage(r, px, 1, 1, 0, 0); err != nil {
69+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil {
6970 t.Fatal(err)
7071 }
7172 }
@@ -74,12 +75,12 @@ func TestCursorImageNeverOverwritesScreen(t *testing.T) {
7475 }
7576 // Image 1 in flight, image 0 on screen: nothing is free.
7677 c.committed(cursorState{on: true, fb: 91})
77- if err := c.setImage(r, px, 1, 1, 0, 0); err != nil || len(written) != 2 || c.later == nil {
78+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil || len(written) != 2 || c.later == nil {
7879 t.Fatalf("wrote a busy image: %v %v", err, written)
7980 }
8081 // The flip frees image 0.
8182 c.landed()
82- if changed, err := c.flushLater(r); !changed || err != nil || len(written) != 3 || written[2] != 0 || c.cur != 0 {
83+ if changed, err := c.flushLater(0); !changed || err != nil || len(written) != 3 || written[2] != 0 || c.cur != 0 {
8384 t.Fatalf("after flip: %v %v %v cur %d", changed, err, written, c.cur)
8485 }
8586 }
@@ -464,7 +465,7 @@ func TestCursorOffSkipsLoads(t *testing.T) {
464465 o, _, _ := testOutput(t)
465466 o.cursor.off = true
466467 loaded := false
467- o.setCursor(nil, func(ports.CursorChange, float64, ports.BufferTransform, int) (ports.CursorImage, error) {
468+ o.setCursor(func(ports.CursorChange, float64, ports.BufferTransform, int) (ports.CursorImage, error) {
468469 loaded = true
469470 return ports.CursorImage{}, nil
470471 }, ports.CursorChange{}, 1, 0)
@@ -497,6 +498,7 @@ func runReportsSeenAfterCursorCommit(t *testing.T) {
497498 r.EXPECT().UseTarget(mock.Anything).Return()
498499 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
499500 r.EXPECT().Close().Return().Once()
501+ k.EXPECT().createDumb(64, 64).Return(dumbBuffer{}, unix.ENOSYS).Once() // renderer images
500502 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(90, nil).Once()
501503 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(91, nil).Once()
502504 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
@@ -55,9 +55,10 @@ func TestBusyFrameCommitsAfterEvent(t *testing.T) {
55 func TestCursorImageNeverOverwritesScreen(t *testing.T) {55 func TestCursorImageNeverOverwritesScreen(t *testing.T) {
56 o, _, _ := testOutput(t)56 o, _, _ := testOutput(t)
57 c := o.cursor57 c := o.cursor
58- r := portsmocks.NewMockRenderer(t)58+ imgs := newMockcursorImages(t)
59+ c.imgs = imgs
59 var written []int60 var written []int
60- r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).RunAndReturn(func(i int, _ []byte, _, _ int) error {61+ imgs.EXPECT().write(mock.Anything, mock.Anything, 1, 1, float64(0)).RunAndReturn(func(i int, _ []byte, _, _ int, _ float64) error {
61 written = append(written, i)62 written = append(written, i)
62 return nil63 return nil
63 })64 })
@@ -65,7 +66,7 @@ func TestCursorImageNeverOverwritesScreen(t *testing.T) {
65 // Image 0 on screen: two changes before a commit both go to image 1.66 // Image 0 on screen: two changes before a commit both go to image 1.
66 c.screen, c.cur, c.image = 90, 0, true67 c.screen, c.cur, c.image = 90, 0, true
67 for range 2 {68 for range 2 {
68- if err := c.setImage(r, px, 1, 1, 0, 0); err != nil {69+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil {
69 t.Fatal(err)70 t.Fatal(err)
70 }71 }
71 }72 }
@@ -74,12 +75,12 @@ func TestCursorImageNeverOverwritesScreen(t *testing.T) {
74 }75 }
75 // Image 1 in flight, image 0 on screen: nothing is free.76 // Image 1 in flight, image 0 on screen: nothing is free.
76 c.committed(cursorState{on: true, fb: 91})77 c.committed(cursorState{on: true, fb: 91})
77- if err := c.setImage(r, px, 1, 1, 0, 0); err != nil || len(written) != 2 || c.later == nil {78+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil || len(written) != 2 || c.later == nil {
78 t.Fatalf("wrote a busy image: %v %v", err, written)79 t.Fatalf("wrote a busy image: %v %v", err, written)
79 }80 }
80 // The flip frees image 0.81 // The flip frees image 0.
81 c.landed()82 c.landed()
82- if changed, err := c.flushLater(r); !changed || err != nil || len(written) != 3 || written[2] != 0 || c.cur != 0 {83+ if changed, err := c.flushLater(0); !changed || err != nil || len(written) != 3 || written[2] != 0 || c.cur != 0 {
83 t.Fatalf("after flip: %v %v %v cur %d", changed, err, written, c.cur)84 t.Fatalf("after flip: %v %v %v cur %d", changed, err, written, c.cur)
84 }85 }
85 }86 }
@@ -464,7 +465,7 @@ func TestCursorOffSkipsLoads(t *testing.T) {
464 o, _, _ := testOutput(t)465 o, _, _ := testOutput(t)
465 o.cursor.off = true466 o.cursor.off = true
466 loaded := false467 loaded := false
467- o.setCursor(nil, func(ports.CursorChange, float64, ports.BufferTransform, int) (ports.CursorImage, error) {468+ o.setCursor(func(ports.CursorChange, float64, ports.BufferTransform, int) (ports.CursorImage, error) {
468 loaded = true469 loaded = true
469 return ports.CursorImage{}, nil470 return ports.CursorImage{}, nil
470 }, ports.CursorChange{}, 1, 0)471 }, ports.CursorChange{}, 1, 0)
@@ -497,6 +498,7 @@ func runReportsSeenAfterCursorCommit(t *testing.T) {
497 r.EXPECT().UseTarget(mock.Anything).Return()498 r.EXPECT().UseTarget(mock.Anything).Return()
498 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)499 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
499 r.EXPECT().Close().Return().Once()500 r.EXPECT().Close().Return().Once()
501+ k.EXPECT().createDumb(64, 64).Return(dumbBuffer{}, unix.ENOSYS).Once() // renderer images
500 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(90, nil).Once()502 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(90, nil).Once()
501 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(91, nil).Once()503 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(91, nil).Once()
502 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()504 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
M internal/adapters/drm/cursor.go +73 -29
@@ -31,14 +31,24 @@ type Cursor struct {
3131 wake chan struct{}
3232
3333 // Owned by the output goroutine.
34- plane *plane
35- size int
36- fbs [2]uint32 // the two images; cur is on screen or next
34+ plane *plane
35+ size int
36+ // imgs are the two images (nil: none), alloc says who allocated them
37+ // (allocDumb or allocVulkan) and fallback makes the renderer's images
38+ // when KMS refuses the dumb ones. fbs mirrors their framebuffers; cur
39+ // is on screen or next.
40+ imgs cursorImages
41+ alloc string
42+ fallback func() (cursorImages, error)
43+ fbs [2]uint32
3744 cur int
3845 image bool // an image is loaded
3946 hotX, hotY int
40- applied cursorState // what the last commit put on the plane
41- commits int
47+ // last is a copy of the loaded image, written again into new images
48+ // when the allocator changes.
49+ last laterImage
50+ applied cursorState // what the last commit put on the plane
51+ commits int
4252 // screen is the image the plane scans out (0: none); flight is what
4353 // the pending commit puts on it when flying. An image is written only
4454 // when it is neither, so the plane never shows a half-written image.
@@ -111,37 +121,70 @@ func (c *Cursor) poke() {
111121 }
112122 }
113123
114-// setup allocates the two cursor images and their framebuffers.
115-func (c *Cursor) setup(k kms, r ports.Renderer) error {
116- bufs, err := r.CursorBuffers(c.size)
124+// setup allocates the two cursor images and their framebuffers: KMS dumb
125+// buffers first, then the renderer's images. dumbErr is why the dumb buffers
126+// were not used (nil when they are); err is set when no images exist.
127+func (c *Cursor) setup(k kms, r ports.Renderer) (dumbErr, err error) {
128+ c.fallback = func() (cursorImages, error) { return newRendererCursor(k, r, c.size) }
129+ d, dumbErr := newDumbCursor(k, c.size)
130+ if dumbErr == nil {
131+ c.install(d, allocDumb)
132+ return nil, nil
133+ }
134+ imgs, err := c.fallback()
117135 if err != nil {
118- return err
136+ return dumbErr, err
119137 }
120- for i := range bufs {
121- if err == nil {
122- c.fbs[i], err = k.addFB(&bufs[i], fourccARGB)
123- }
124- for _, p := range bufs[i].Planes {
125- p.File.Close()
126- }
138+ c.install(imgs, allocVulkan)
139+ return dumbErr, nil
140+}
141+
142+// install makes imgs the cursor images; none is on screen or in flight.
143+func (c *Cursor) install(imgs cursorImages, alloc string) {
144+ c.imgs, c.alloc = imgs, alloc
145+ for i := range c.fbs {
146+ c.fbs[i] = imgs.fb(i)
147+ }
148+ c.cur = 0
149+ c.screen, c.flight, c.flying, c.applied = 0, cursorState{}, false, cursorState{}
150+}
151+
152+// useFallback replaces refused dumb images with the renderer's and writes
153+// the current image into them. It reports false when it cannot: no fallback,
154+// already used, or no images either.
155+func (c *Cursor) useFallback(hdrNits float64) (bool, error) {
156+ if c.alloc != allocDumb || c.fallback == nil {
157+ return false, nil
127158 }
159+ imgs, err := c.fallback()
128160 if err != nil {
129- c.free(k)
161+ return false, err
162+ }
163+ c.imgs.close()
164+ c.install(imgs, allocVulkan)
165+ l := c.later
166+ if l == nil && c.image {
167+ l = &c.last
130168 }
131- return err
169+ c.image, c.later = false, nil
170+ if l == nil {
171+ return true, nil
172+ }
173+ return true, c.setImage(l.pixels, l.w, l.h, l.hotX, l.hotY, hdrNits)
132174 }
133175
134176 // setImage loads premultiplied ARGB8888 pixels into an image neither on
135177 // screen nor in flight; when there is none it waits for the next commit
136178 // event (flushLater). An empty image hides the cursor (a client asked for
137179 // none).
138-func (c *Cursor) setImage(r ports.Renderer, pixels []byte, w, h, hotX, hotY int) error {
180+// hdrNits is the SDR white of an HDR output (0: SDR).
181+func (c *Cursor) setImage(pixels []byte, w, h, hotX, hotY int, hdrNits float64) error {
139182 c.later = nil
140183 if w <= 0 || h <= 0 || len(pixels) < w*h*4 {
141184 c.image = false
142185 return nil
143186 }
144- if c.fbs[0] == 0 {
187+ if c.imgs == nil {
145188 return errors.New("no cursor images")
146189 }
147190 next := c.freeSlot()
@@ -149,10 +192,11 @@ func (c *Cursor) setImage(r ports.Renderer, pixels []byte, w, h, hotX, hotY int)
149192 c.later = &laterImage{pixels: slices.Clone(pixels[:w*h*4]), w: w, h: h, hotX: hotX, hotY: hotY}
150193 return nil
151194 }
152- if err := r.WriteCursor(next, pixels, w, h); err != nil {
195+ if err := c.imgs.write(next, pixels, w, h, hdrNits); err != nil {
153196 return err
154197 }
155198 c.cur, c.image, c.hotX, c.hotY = next, true, hotX, hotY
199+ c.last = laterImage{pixels: append(c.last.pixels[:0], pixels[:w*h*4]...), w: w, h: h, hotX: hotX, hotY: hotY}
156200 return nil
157201 }
158202
@@ -169,12 +213,12 @@ func (c *Cursor) freeSlot() int {
169213
170214 // flushLater loads an image that waited for a free slot. It reports
171215 // whether the cursor changed.
172-func (c *Cursor) flushLater(r ports.Renderer) (bool, error) {
216+func (c *Cursor) flushLater(hdrNits float64) (bool, error) {
173217 l := c.later
174218 if l == nil || c.freeSlot() < 0 {
175219 return false, nil
176220 }
177- return true, c.setImage(r, l.pixels, l.w, l.h, l.hotX, l.hotY)
221+ return true, c.setImage(l.pixels, l.w, l.h, l.hotX, l.hotY, hdrNits)
178222 }
179223
180224 // committed records that a commit carrying state s is in flight.
@@ -229,13 +273,13 @@ func (c *Cursor) props(req *atomicReq, crtc uint32, s cursorState) {
229273 req.set(p.id, p.prop("CRTC_H"), size)
230274 }
231275
232-func (c *Cursor) free(k kms) {
233- for i, fb := range c.fbs {
234- if fb != 0 {
235- _ = k.rmFB(fb)
236- c.fbs[i] = 0
237- }
276+// free removes the framebuffers and releases the images.
277+func (c *Cursor) free() {
278+ if c.imgs != nil {
279+ c.imgs.close()
280+ c.imgs, c.alloc = nil, ""
238281 }
282+ c.fbs = [2]uint32{}
239283 c.image, c.later = false, nil
240284 c.screen, c.flying = 0, false
241285 }
@@ -31,14 +31,24 @@ type Cursor struct {
31 wake chan struct{}31 wake chan struct{}
32 32
33 // Owned by the output goroutine.33 // Owned by the output goroutine.
34- plane *plane34+ plane *plane
35- size int35+ size int
36- fbs [2]uint32 // the two images; cur is on screen or next36+ // imgs are the two images (nil: none), alloc says who allocated them
37+ // (allocDumb or allocVulkan) and fallback makes the renderer's images
38+ // when KMS refuses the dumb ones. fbs mirrors their framebuffers; cur
39+ // is on screen or next.
40+ imgs cursorImages
41+ alloc string
42+ fallback func() (cursorImages, error)
43+ fbs [2]uint32
37 cur int44 cur int
38 image bool // an image is loaded45 image bool // an image is loaded
39 hotX, hotY int46 hotX, hotY int
40- applied cursorState // what the last commit put on the plane47+ // last is a copy of the loaded image, written again into new images
41- commits int48+ // when the allocator changes.
49+ last laterImage
50+ applied cursorState // what the last commit put on the plane
51+ commits int
42 // screen is the image the plane scans out (0: none); flight is what52 // screen is the image the plane scans out (0: none); flight is what
43 // the pending commit puts on it when flying. An image is written only53 // the pending commit puts on it when flying. An image is written only
44 // when it is neither, so the plane never shows a half-written image.54 // when it is neither, so the plane never shows a half-written image.
@@ -111,37 +121,70 @@ func (c *Cursor) poke() {
111 }121 }
112 }122 }
113 123
114-// setup allocates the two cursor images and their framebuffers.124+// setup allocates the two cursor images and their framebuffers: KMS dumb
115-func (c *Cursor) setup(k kms, r ports.Renderer) error {125+// buffers first, then the renderer's images. dumbErr is why the dumb buffers
116- bufs, err := r.CursorBuffers(c.size)126+// were not used (nil when they are); err is set when no images exist.
127+func (c *Cursor) setup(k kms, r ports.Renderer) (dumbErr, err error) {
128+ c.fallback = func() (cursorImages, error) { return newRendererCursor(k, r, c.size) }
129+ d, dumbErr := newDumbCursor(k, c.size)
130+ if dumbErr == nil {
131+ c.install(d, allocDumb)
132+ return nil, nil
133+ }
134+ imgs, err := c.fallback()
117 if err != nil {135 if err != nil {
118- return err136+ return dumbErr, err
119 }137 }
120- for i := range bufs {138+ c.install(imgs, allocVulkan)
121- if err == nil {139+ return dumbErr, nil
122- c.fbs[i], err = k.addFB(&bufs[i], fourccARGB)140+}
123- }141+
124- for _, p := range bufs[i].Planes {142+// install makes imgs the cursor images; none is on screen or in flight.
125- p.File.Close()143+func (c *Cursor) install(imgs cursorImages, alloc string) {
126- }144+ c.imgs, c.alloc = imgs, alloc
145+ for i := range c.fbs {
146+ c.fbs[i] = imgs.fb(i)
147+ }
148+ c.cur = 0
149+ c.screen, c.flight, c.flying, c.applied = 0, cursorState{}, false, cursorState{}
150+}
151+
152+// useFallback replaces refused dumb images with the renderer's and writes
153+// the current image into them. It reports false when it cannot: no fallback,
154+// already used, or no images either.
155+func (c *Cursor) useFallback(hdrNits float64) (bool, error) {
156+ if c.alloc != allocDumb || c.fallback == nil {
157+ return false, nil
127 }158 }
159+ imgs, err := c.fallback()
128 if err != nil {160 if err != nil {
129- c.free(k)161+ return false, err
162+ }
163+ c.imgs.close()
164+ c.install(imgs, allocVulkan)
165+ l := c.later
166+ if l == nil && c.image {
167+ l = &c.last
130 }168 }
131- return err169+ c.image, c.later = false, nil
170+ if l == nil {
171+ return true, nil
172+ }
173+ return true, c.setImage(l.pixels, l.w, l.h, l.hotX, l.hotY, hdrNits)
132 }174 }
133 175
134 // setImage loads premultiplied ARGB8888 pixels into an image neither on176 // setImage loads premultiplied ARGB8888 pixels into an image neither on
135 // screen nor in flight; when there is none it waits for the next commit177 // screen nor in flight; when there is none it waits for the next commit
136 // event (flushLater). An empty image hides the cursor (a client asked for178 // event (flushLater). An empty image hides the cursor (a client asked for
137 // none).179 // none).
138-func (c *Cursor) setImage(r ports.Renderer, pixels []byte, w, h, hotX, hotY int) error {180+// hdrNits is the SDR white of an HDR output (0: SDR).
181+func (c *Cursor) setImage(pixels []byte, w, h, hotX, hotY int, hdrNits float64) error {
139 c.later = nil182 c.later = nil
140 if w <= 0 || h <= 0 || len(pixels) < w*h*4 {183 if w <= 0 || h <= 0 || len(pixels) < w*h*4 {
141 c.image = false184 c.image = false
142 return nil185 return nil
143 }186 }
144- if c.fbs[0] == 0 {187+ if c.imgs == nil {
145 return errors.New("no cursor images")188 return errors.New("no cursor images")
146 }189 }
147 next := c.freeSlot()190 next := c.freeSlot()
@@ -149,10 +192,11 @@ func (c *Cursor) setImage(r ports.Renderer, pixels []byte, w, h, hotX, hotY int)
149 c.later = &laterImage{pixels: slices.Clone(pixels[:w*h*4]), w: w, h: h, hotX: hotX, hotY: hotY}192 c.later = &laterImage{pixels: slices.Clone(pixels[:w*h*4]), w: w, h: h, hotX: hotX, hotY: hotY}
150 return nil193 return nil
151 }194 }
152- if err := r.WriteCursor(next, pixels, w, h); err != nil {195+ if err := c.imgs.write(next, pixels, w, h, hdrNits); err != nil {
153 return err196 return err
154 }197 }
155 c.cur, c.image, c.hotX, c.hotY = next, true, hotX, hotY198 c.cur, c.image, c.hotX, c.hotY = next, true, hotX, hotY
199+ c.last = laterImage{pixels: append(c.last.pixels[:0], pixels[:w*h*4]...), w: w, h: h, hotX: hotX, hotY: hotY}
156 return nil200 return nil
157 }201 }
158 202
@@ -169,12 +213,12 @@ func (c *Cursor) freeSlot() int {
169 213
170 // flushLater loads an image that waited for a free slot. It reports214 // flushLater loads an image that waited for a free slot. It reports
171 // whether the cursor changed.215 // whether the cursor changed.
172-func (c *Cursor) flushLater(r ports.Renderer) (bool, error) {216+func (c *Cursor) flushLater(hdrNits float64) (bool, error) {
173 l := c.later217 l := c.later
174 if l == nil || c.freeSlot() < 0 {218 if l == nil || c.freeSlot() < 0 {
175 return false, nil219 return false, nil
176 }220 }
177- return true, c.setImage(r, l.pixels, l.w, l.h, l.hotX, l.hotY)221+ return true, c.setImage(l.pixels, l.w, l.h, l.hotX, l.hotY, hdrNits)
178 }222 }
179 223
180 // committed records that a commit carrying state s is in flight.224 // committed records that a commit carrying state s is in flight.
@@ -229,13 +273,13 @@ func (c *Cursor) props(req *atomicReq, crtc uint32, s cursorState) {
229 req.set(p.id, p.prop("CRTC_H"), size)273 req.set(p.id, p.prop("CRTC_H"), size)
230 }274 }
231 275
232-func (c *Cursor) free(k kms) {276+// free removes the framebuffers and releases the images.
233- for i, fb := range c.fbs {277+func (c *Cursor) free() {
234- if fb != 0 {278+ if c.imgs != nil {
235- _ = k.rmFB(fb)279+ c.imgs.close()
236- c.fbs[i] = 0280+ c.imgs, c.alloc = nil, ""
237- }
238 }281 }
282+ c.fbs = [2]uint32{}
239 c.image, c.later = false, nil283 c.image, c.later = false, nil
240 c.screen, c.flying = 0, false284 c.screen, c.flying = 0, false
241 }285 }
A internal/adapters/drm/cursor_images.go +164 -0
@@ -0,0 +1,164 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/neferwl/internal/adapters/hdrcursor"
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// Who allocated the cursor images (the "alloc" log field).
12+//
13+// The renderer's cursor images (allocVulkan, shared with the output images)
14+// are the fallback.
15+const allocDumb = "dumb"
16+
17+// cursorImages are the two ARGB8888 images of the cursor plane and their
18+// framebuffers. The output goroutine owns them.
19+type cursorImages interface {
20+ // fb is the framebuffer of image i.
21+ fb(i int) uint32
22+ // write fills image i with premultiplied ARGB8888 pixels (w×h, w*4 per
23+ // row), cropped to the image and cleared around. hdrNits > 0 converts
24+ // them to the HDR10 output encoding for that SDR white.
25+ write(i int, pixels []byte, w, h int, hdrNits float64) error
26+ // close removes the framebuffers and releases the images.
27+ close()
28+}
29+
30+// dumbImage is one KMS dumb buffer mapped for the CPU.
31+type dumbImage struct {
32+ handle, fb uint32
33+ pitch int
34+ mem []byte
35+}
36+
37+// dumbCursor are two KMS dumb buffers: KMS allocates what it scans out, the
38+// CPU writes the pixels. Renderer-allocated images are refused by some
39+// drivers (nvidia-drm) on the cursor plane.
40+type dumbCursor struct {
41+ k kms
42+ size int
43+ img [2]dumbImage
44+ cache hdrcursor.Cache
45+}
46+
47+func newDumbCursor(k kms, size int) (*dumbCursor, error) {
48+ d := &dumbCursor{k: k, size: size}
49+ for i := range d.img {
50+ if err := d.alloc(&d.img[i]); err != nil {
51+ d.close()
52+ return nil, err
53+ }
54+ }
55+ return d, nil
56+}
57+
58+func (d *dumbCursor) alloc(img *dumbImage) error {
59+ b, err := d.k.createDumb(d.size, d.size)
60+ if err != nil {
61+ return fmt.Errorf("create dumb buffer: %w", err)
62+ }
63+ img.handle, img.pitch = b.handle, int(b.pitch)
64+ if img.pitch < d.size*4 {
65+ return fmt.Errorf("dumb buffer pitch %d, want at least %d", img.pitch, d.size*4)
66+ }
67+ if img.mem, err = d.k.mapDumb(b); err != nil {
68+ return fmt.Errorf("map dumb buffer: %w", err)
69+ }
70+ if len(img.mem) < img.pitch*d.size {
71+ return fmt.Errorf("dumb buffer mapping %d bytes, want %d", len(img.mem), img.pitch*d.size)
72+ }
73+ clear(img.mem)
74+ if img.fb, err = d.k.addFBHandle(b, d.size, d.size, fourccARGB); err != nil {
75+ return fmt.Errorf("dumb buffer framebuffer: %w", err)
76+ }
77+ return nil
78+}
79+
80+func (d *dumbCursor) fb(i int) uint32 { return d.img[i].fb }
81+
82+func (d *dumbCursor) write(i int, pixels []byte, w, h int, hdrNits float64) error {
83+ if i < 0 || i >= len(d.img) || d.img[i].mem == nil {
84+ return errors.New("no cursor image")
85+ }
86+ if w < 0 || h < 0 || len(pixels) < w*h*4 {
87+ return errors.New("short cursor image")
88+ }
89+ img := &d.img[i]
90+ mem := img.mem[:img.pitch*d.size]
91+ clear(mem)
92+ cw := min(w, d.size) * 4
93+ src, stride := pixels, w*4
94+ if hdrNits > 0 {
95+ // Called only on image changes, never on moves.
96+ src, stride = d.cache.Convert(pixels, w, h, d.size, hdrNits), cw
97+ }
98+ for y := range min(h, d.size) {
99+ copy(mem[y*img.pitch:y*img.pitch+cw], src[y*stride:y*stride+cw])
100+ }
101+ return nil
102+}
103+
104+func (d *dumbCursor) close() {
105+ for i := range d.img {
106+ img := &d.img[i]
107+ if img.fb != 0 {
108+ _ = d.k.rmFB(img.fb)
109+ }
110+ if img.mem != nil {
111+ _ = d.k.unmapDumb(img.mem)
112+ }
113+ if img.handle != 0 {
114+ _ = d.k.destroyDumb(img.handle)
115+ }
116+ *img = dumbImage{}
117+ }
118+ d.cache.Reset()
119+}
120+
121+// rendererCursor are the renderer's cursor images (linear dmabufs the
122+// renderer allocates and writes), imported as framebuffers.
123+type rendererCursor struct {
124+ k kms
125+ r ports.Renderer
126+ fbs [2]uint32
127+}
128+
129+func newRendererCursor(k kms, r ports.Renderer, size int) (*rendererCursor, error) {
130+ bufs, err := r.CursorBuffers(size)
131+ if err != nil {
132+ return nil, err
133+ }
134+ c := &rendererCursor{k: k, r: r}
135+ for i := range bufs {
136+ if err == nil {
137+ c.fbs[i], err = k.addFB(&bufs[i], fourccARGB)
138+ }
139+ for _, p := range bufs[i].Planes {
140+ p.File.Close()
141+ }
142+ }
143+ if err != nil {
144+ c.close()
145+ return nil, err
146+ }
147+ return c, nil
148+}
149+
150+func (c *rendererCursor) fb(i int) uint32 { return c.fbs[i] }
151+
152+// write relies on the renderer's own HDR state: hdrNits is not used.
153+func (c *rendererCursor) write(i int, pixels []byte, w, h int, _ float64) error {
154+ return c.r.WriteCursor(i, pixels, w, h)
155+}
156+
157+func (c *rendererCursor) close() {
158+ for i, fb := range c.fbs {
159+ if fb != 0 {
160+ _ = c.k.rmFB(fb)
161+ c.fbs[i] = 0
162+ }
163+ }
164+}
@@ -0,0 +1,164 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/neferwl/internal/adapters/hdrcursor"
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// Who allocated the cursor images (the "alloc" log field).
12+//
13+// The renderer's cursor images (allocVulkan, shared with the output images)
14+// are the fallback.
15+const allocDumb = "dumb"
16+
17+// cursorImages are the two ARGB8888 images of the cursor plane and their
18+// framebuffers. The output goroutine owns them.
19+type cursorImages interface {
20+ // fb is the framebuffer of image i.
21+ fb(i int) uint32
22+ // write fills image i with premultiplied ARGB8888 pixels (w×h, w*4 per
23+ // row), cropped to the image and cleared around. hdrNits > 0 converts
24+ // them to the HDR10 output encoding for that SDR white.
25+ write(i int, pixels []byte, w, h int, hdrNits float64) error
26+ // close removes the framebuffers and releases the images.
27+ close()
28+}
29+
30+// dumbImage is one KMS dumb buffer mapped for the CPU.
31+type dumbImage struct {
32+ handle, fb uint32
33+ pitch int
34+ mem []byte
35+}
36+
37+// dumbCursor are two KMS dumb buffers: KMS allocates what it scans out, the
38+// CPU writes the pixels. Renderer-allocated images are refused by some
39+// drivers (nvidia-drm) on the cursor plane.
40+type dumbCursor struct {
41+ k kms
42+ size int
43+ img [2]dumbImage
44+ cache hdrcursor.Cache
45+}
46+
47+func newDumbCursor(k kms, size int) (*dumbCursor, error) {
48+ d := &dumbCursor{k: k, size: size}
49+ for i := range d.img {
50+ if err := d.alloc(&d.img[i]); err != nil {
51+ d.close()
52+ return nil, err
53+ }
54+ }
55+ return d, nil
56+}
57+
58+func (d *dumbCursor) alloc(img *dumbImage) error {
59+ b, err := d.k.createDumb(d.size, d.size)
60+ if err != nil {
61+ return fmt.Errorf("create dumb buffer: %w", err)
62+ }
63+ img.handle, img.pitch = b.handle, int(b.pitch)
64+ if img.pitch < d.size*4 {
65+ return fmt.Errorf("dumb buffer pitch %d, want at least %d", img.pitch, d.size*4)
66+ }
67+ if img.mem, err = d.k.mapDumb(b); err != nil {
68+ return fmt.Errorf("map dumb buffer: %w", err)
69+ }
70+ if len(img.mem) < img.pitch*d.size {
71+ return fmt.Errorf("dumb buffer mapping %d bytes, want %d", len(img.mem), img.pitch*d.size)
72+ }
73+ clear(img.mem)
74+ if img.fb, err = d.k.addFBHandle(b, d.size, d.size, fourccARGB); err != nil {
75+ return fmt.Errorf("dumb buffer framebuffer: %w", err)
76+ }
77+ return nil
78+}
79+
80+func (d *dumbCursor) fb(i int) uint32 { return d.img[i].fb }
81+
82+func (d *dumbCursor) write(i int, pixels []byte, w, h int, hdrNits float64) error {
83+ if i < 0 || i >= len(d.img) || d.img[i].mem == nil {
84+ return errors.New("no cursor image")
85+ }
86+ if w < 0 || h < 0 || len(pixels) < w*h*4 {
87+ return errors.New("short cursor image")
88+ }
89+ img := &d.img[i]
90+ mem := img.mem[:img.pitch*d.size]
91+ clear(mem)
92+ cw := min(w, d.size) * 4
93+ src, stride := pixels, w*4
94+ if hdrNits > 0 {
95+ // Called only on image changes, never on moves.
96+ src, stride = d.cache.Convert(pixels, w, h, d.size, hdrNits), cw
97+ }
98+ for y := range min(h, d.size) {
99+ copy(mem[y*img.pitch:y*img.pitch+cw], src[y*stride:y*stride+cw])
100+ }
101+ return nil
102+}
103+
104+func (d *dumbCursor) close() {
105+ for i := range d.img {
106+ img := &d.img[i]
107+ if img.fb != 0 {
108+ _ = d.k.rmFB(img.fb)
109+ }
110+ if img.mem != nil {
111+ _ = d.k.unmapDumb(img.mem)
112+ }
113+ if img.handle != 0 {
114+ _ = d.k.destroyDumb(img.handle)
115+ }
116+ *img = dumbImage{}
117+ }
118+ d.cache.Reset()
119+}
120+
121+// rendererCursor are the renderer's cursor images (linear dmabufs the
122+// renderer allocates and writes), imported as framebuffers.
123+type rendererCursor struct {
124+ k kms
125+ r ports.Renderer
126+ fbs [2]uint32
127+}
128+
129+func newRendererCursor(k kms, r ports.Renderer, size int) (*rendererCursor, error) {
130+ bufs, err := r.CursorBuffers(size)
131+ if err != nil {
132+ return nil, err
133+ }
134+ c := &rendererCursor{k: k, r: r}
135+ for i := range bufs {
136+ if err == nil {
137+ c.fbs[i], err = k.addFB(&bufs[i], fourccARGB)
138+ }
139+ for _, p := range bufs[i].Planes {
140+ p.File.Close()
141+ }
142+ }
143+ if err != nil {
144+ c.close()
145+ return nil, err
146+ }
147+ return c, nil
148+}
149+
150+func (c *rendererCursor) fb(i int) uint32 { return c.fbs[i] }
151+
152+// write relies on the renderer's own HDR state: hdrNits is not used.
153+func (c *rendererCursor) write(i int, pixels []byte, w, h int, _ float64) error {
154+ return c.r.WriteCursor(i, pixels, w, h)
155+}
156+
157+func (c *rendererCursor) close() {
158+ for i, fb := range c.fbs {
159+ if fb != 0 {
160+ _ = c.k.rmFB(fb)
161+ c.fbs[i] = 0
162+ }
163+ }
164+}
A internal/adapters/drm/cursor_images_mock_test.go +204 -0
@@ -0,0 +1,204 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package drm
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockcursorImages creates a new instance of mockcursorImages. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockcursorImages(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockcursorImages {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockcursorImages{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockcursorImages is an autogenerated mock type for the cursorImages type
35+type mockcursorImages struct {
36+ mock.Mock
37+}
38+
39+type mockcursorImages_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockcursorImages) EXPECT() *mockcursorImages_Expecter {
44+ return &mockcursorImages_Expecter{mock: &_m.Mock}
45+}
46+
47+// close provides a mock function for the type mockcursorImages
48+func (_mock *mockcursorImages) close() {
49+ _mock.Called()
50+ return
51+}
52+
53+// mockcursorImages_close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'close'
54+type mockcursorImages_close_Call struct {
55+ *mock.Call
56+}
57+
58+// close is a helper method to define mock.On call
59+func (_e *mockcursorImages_Expecter) close() *mockcursorImages_close_Call {
60+ return &mockcursorImages_close_Call{Call: _e.mock.On("close")}
61+}
62+
63+func (_c *mockcursorImages_close_Call) Run(run func()) *mockcursorImages_close_Call {
64+ _c.Call.Run(func(args mock.Arguments) {
65+ run()
66+ })
67+ return _c
68+}
69+
70+func (_c *mockcursorImages_close_Call) Return() *mockcursorImages_close_Call {
71+ _c.Call.Return()
72+ return _c
73+}
74+
75+func (_c *mockcursorImages_close_Call) RunAndReturn(run func()) *mockcursorImages_close_Call {
76+ _c.Run(run)
77+ return _c
78+}
79+
80+// fb provides a mock function for the type mockcursorImages
81+func (_mock *mockcursorImages) fb(i int) uint32 {
82+ ret := _mock.Called(i)
83+
84+ if len(ret) == 0 {
85+ panic("no return value specified for fb")
86+ }
87+
88+ var r0 uint32
89+ if returnFunc, ok := ret.Get(0).(func(int) uint32); ok {
90+ r0 = returnFunc(i)
91+ } else {
92+ r0 = ret.Get(0).(uint32)
93+ }
94+ return r0
95+}
96+
97+// mockcursorImages_fb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'fb'
98+type mockcursorImages_fb_Call struct {
99+ *mock.Call
100+}
101+
102+// fb is a helper method to define mock.On call
103+// - i int
104+func (_e *mockcursorImages_Expecter) fb(i any) *mockcursorImages_fb_Call {
105+ return &mockcursorImages_fb_Call{Call: _e.mock.On("fb", i)}
106+}
107+
108+func (_c *mockcursorImages_fb_Call) Run(run func(i int)) *mockcursorImages_fb_Call {
109+ _c.Call.Run(func(args mock.Arguments) {
110+ var arg0 int
111+ if args[0] != nil {
112+ arg0 = args[0].(int)
113+ }
114+ run(
115+ arg0,
116+ )
117+ })
118+ return _c
119+}
120+
121+func (_c *mockcursorImages_fb_Call) Return(v uint32) *mockcursorImages_fb_Call {
122+ _c.Call.Return(v)
123+ return _c
124+}
125+
126+func (_c *mockcursorImages_fb_Call) RunAndReturn(run func(i int) uint32) *mockcursorImages_fb_Call {
127+ _c.Call.Return(run)
128+ return _c
129+}
130+
131+// write provides a mock function for the type mockcursorImages
132+func (_mock *mockcursorImages) write(i int, pixels []byte, w int, h int, hdrNits float64) error {
133+ ret := _mock.Called(i, pixels, w, h, hdrNits)
134+
135+ if len(ret) == 0 {
136+ panic("no return value specified for write")
137+ }
138+
139+ var r0 error
140+ if returnFunc, ok := ret.Get(0).(func(int, []byte, int, int, float64) error); ok {
141+ r0 = returnFunc(i, pixels, w, h, hdrNits)
142+ } else {
143+ r0 = ret.Error(0)
144+ }
145+ return r0
146+}
147+
148+// mockcursorImages_write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'write'
149+type mockcursorImages_write_Call struct {
150+ *mock.Call
151+}
152+
153+// write is a helper method to define mock.On call
154+// - i int
155+// - pixels []byte
156+// - w int
157+// - h int
158+// - hdrNits float64
159+func (_e *mockcursorImages_Expecter) write(i any, pixels any, w any, h any, hdrNits any) *mockcursorImages_write_Call {
160+ return &mockcursorImages_write_Call{Call: _e.mock.On("write", i, pixels, w, h, hdrNits)}
161+}
162+
163+func (_c *mockcursorImages_write_Call) Run(run func(i int, pixels []byte, w int, h int, hdrNits float64)) *mockcursorImages_write_Call {
164+ _c.Call.Run(func(args mock.Arguments) {
165+ var arg0 int
166+ if args[0] != nil {
167+ arg0 = args[0].(int)
168+ }
169+ var arg1 []byte
170+ if args[1] != nil {
171+ arg1 = args[1].([]byte)
172+ }
173+ var arg2 int
174+ if args[2] != nil {
175+ arg2 = args[2].(int)
176+ }
177+ var arg3 int
178+ if args[3] != nil {
179+ arg3 = args[3].(int)
180+ }
181+ var arg4 float64
182+ if args[4] != nil {
183+ arg4 = args[4].(float64)
184+ }
185+ run(
186+ arg0,
187+ arg1,
188+ arg2,
189+ arg3,
190+ arg4,
191+ )
192+ })
193+ return _c
194+}
195+
196+func (_c *mockcursorImages_write_Call) Return(err error) *mockcursorImages_write_Call {
197+ _c.Call.Return(err)
198+ return _c
199+}
200+
201+func (_c *mockcursorImages_write_Call) RunAndReturn(run func(i int, pixels []byte, w int, h int, hdrNits float64) error) *mockcursorImages_write_Call {
202+ _c.Call.Return(run)
203+ return _c
204+}
@@ -0,0 +1,204 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package drm
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockcursorImages creates a new instance of mockcursorImages. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockcursorImages(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockcursorImages {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockcursorImages{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockcursorImages is an autogenerated mock type for the cursorImages type
35+type mockcursorImages struct {
36+ mock.Mock
37+}
38+
39+type mockcursorImages_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockcursorImages) EXPECT() *mockcursorImages_Expecter {
44+ return &mockcursorImages_Expecter{mock: &_m.Mock}
45+}
46+
47+// close provides a mock function for the type mockcursorImages
48+func (_mock *mockcursorImages) close() {
49+ _mock.Called()
50+ return
51+}
52+
53+// mockcursorImages_close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'close'
54+type mockcursorImages_close_Call struct {
55+ *mock.Call
56+}
57+
58+// close is a helper method to define mock.On call
59+func (_e *mockcursorImages_Expecter) close() *mockcursorImages_close_Call {
60+ return &mockcursorImages_close_Call{Call: _e.mock.On("close")}
61+}
62+
63+func (_c *mockcursorImages_close_Call) Run(run func()) *mockcursorImages_close_Call {
64+ _c.Call.Run(func(args mock.Arguments) {
65+ run()
66+ })
67+ return _c
68+}
69+
70+func (_c *mockcursorImages_close_Call) Return() *mockcursorImages_close_Call {
71+ _c.Call.Return()
72+ return _c
73+}
74+
75+func (_c *mockcursorImages_close_Call) RunAndReturn(run func()) *mockcursorImages_close_Call {
76+ _c.Run(run)
77+ return _c
78+}
79+
80+// fb provides a mock function for the type mockcursorImages
81+func (_mock *mockcursorImages) fb(i int) uint32 {
82+ ret := _mock.Called(i)
83+
84+ if len(ret) == 0 {
85+ panic("no return value specified for fb")
86+ }
87+
88+ var r0 uint32
89+ if returnFunc, ok := ret.Get(0).(func(int) uint32); ok {
90+ r0 = returnFunc(i)
91+ } else {
92+ r0 = ret.Get(0).(uint32)
93+ }
94+ return r0
95+}
96+
97+// mockcursorImages_fb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'fb'
98+type mockcursorImages_fb_Call struct {
99+ *mock.Call
100+}
101+
102+// fb is a helper method to define mock.On call
103+// - i int
104+func (_e *mockcursorImages_Expecter) fb(i any) *mockcursorImages_fb_Call {
105+ return &mockcursorImages_fb_Call{Call: _e.mock.On("fb", i)}
106+}
107+
108+func (_c *mockcursorImages_fb_Call) Run(run func(i int)) *mockcursorImages_fb_Call {
109+ _c.Call.Run(func(args mock.Arguments) {
110+ var arg0 int
111+ if args[0] != nil {
112+ arg0 = args[0].(int)
113+ }
114+ run(
115+ arg0,
116+ )
117+ })
118+ return _c
119+}
120+
121+func (_c *mockcursorImages_fb_Call) Return(v uint32) *mockcursorImages_fb_Call {
122+ _c.Call.Return(v)
123+ return _c
124+}
125+
126+func (_c *mockcursorImages_fb_Call) RunAndReturn(run func(i int) uint32) *mockcursorImages_fb_Call {
127+ _c.Call.Return(run)
128+ return _c
129+}
130+
131+// write provides a mock function for the type mockcursorImages
132+func (_mock *mockcursorImages) write(i int, pixels []byte, w int, h int, hdrNits float64) error {
133+ ret := _mock.Called(i, pixels, w, h, hdrNits)
134+
135+ if len(ret) == 0 {
136+ panic("no return value specified for write")
137+ }
138+
139+ var r0 error
140+ if returnFunc, ok := ret.Get(0).(func(int, []byte, int, int, float64) error); ok {
141+ r0 = returnFunc(i, pixels, w, h, hdrNits)
142+ } else {
143+ r0 = ret.Error(0)
144+ }
145+ return r0
146+}
147+
148+// mockcursorImages_write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'write'
149+type mockcursorImages_write_Call struct {
150+ *mock.Call
151+}
152+
153+// write is a helper method to define mock.On call
154+// - i int
155+// - pixels []byte
156+// - w int
157+// - h int
158+// - hdrNits float64
159+func (_e *mockcursorImages_Expecter) write(i any, pixels any, w any, h any, hdrNits any) *mockcursorImages_write_Call {
160+ return &mockcursorImages_write_Call{Call: _e.mock.On("write", i, pixels, w, h, hdrNits)}
161+}
162+
163+func (_c *mockcursorImages_write_Call) Run(run func(i int, pixels []byte, w int, h int, hdrNits float64)) *mockcursorImages_write_Call {
164+ _c.Call.Run(func(args mock.Arguments) {
165+ var arg0 int
166+ if args[0] != nil {
167+ arg0 = args[0].(int)
168+ }
169+ var arg1 []byte
170+ if args[1] != nil {
171+ arg1 = args[1].([]byte)
172+ }
173+ var arg2 int
174+ if args[2] != nil {
175+ arg2 = args[2].(int)
176+ }
177+ var arg3 int
178+ if args[3] != nil {
179+ arg3 = args[3].(int)
180+ }
181+ var arg4 float64
182+ if args[4] != nil {
183+ arg4 = args[4].(float64)
184+ }
185+ run(
186+ arg0,
187+ arg1,
188+ arg2,
189+ arg3,
190+ arg4,
191+ )
192+ })
193+ return _c
194+}
195+
196+func (_c *mockcursorImages_write_Call) Return(err error) *mockcursorImages_write_Call {
197+ _c.Call.Return(err)
198+ return _c
199+}
200+
201+func (_c *mockcursorImages_write_Call) RunAndReturn(run func(i int, pixels []byte, w int, h int, hdrNits float64) error) *mockcursorImages_write_Call {
202+ _c.Call.Return(run)
203+ return _c
204+}
A internal/adapters/drm/cursor_images_test.go +491 -0
@@ -0,0 +1,491 @@
1+package drm
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "os"
7+ "testing"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+ "golang.org/x/sys/unix"
13+)
14+
15+const tSide = 16
16+
17+// dumbOK expects two dumb buffers with handles 10 and 11, mappings (with
18+// pitch bytes per row) returned in mem, and framebuffers 90 and 91.
19+func dumbOK(k *mockkms, pitch int) (mem [2][]byte) {
20+ for i := range mem {
21+ mem[i] = make([]byte, pitch*tSide)
22+ h := uint32(10 + i)
23+ b := dumbBuffer{handle: h, pitch: uint32(pitch), size: uint64(len(mem[i]))}
24+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
25+ k.EXPECT().mapDumb(b).Return(mem[i], nil).Once()
26+ k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(90+uint32(i), nil).Once()
27+ }
28+ return mem
29+}
30+
31+func dumbFree(k *mockkms) {
32+ for i := range 2 {
33+ k.EXPECT().rmFB(90 + uint32(i)).Return(nil).Once()
34+ k.EXPECT().destroyDumb(10 + uint32(i)).Return(nil).Once()
35+ }
36+ k.EXPECT().unmapDumb(mock.Anything).Return(nil).Twice()
37+}
38+
39+func rendererBufs(t *testing.T) [2]ports.DMABuf {
40+ var b [2]ports.DMABuf
41+ for i := range b {
42+ f, w, err := os.Pipe()
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ t.Cleanup(func() { _ = f.Close(); _ = w.Close() })
47+ if err := w.Close(); err != nil {
48+ t.Fatal(err)
49+ }
50+ b[i] = ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
51+ }
52+ return b
53+}
54+
55+func rendererOK(t *testing.T, k *mockkms, r *portsmocks.MockRenderer) {
56+ b := rendererBufs(t)
57+ r.EXPECT().CursorBuffers(tSide).Return(b, nil).Once()
58+ t.Cleanup(func() { assertPlanesClosed(t, b) })
59+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
60+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(81, nil).Once()
61+}
62+
63+// Dumb buffers come first: the renderer allocates nothing.
64+func TestCursorSetupUsesDumbBuffers(t *testing.T) {
65+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
66+ dumbOK(k, tSide*4)
67+ c := newCursor(&plane{}, tSide)
68+ dumbErr, err := c.setup(k, r)
69+ if dumbErr != nil || err != nil {
70+ t.Fatalf("setup: %v %v", dumbErr, err)
71+ }
72+ if c.alloc != allocDumb || c.fbs != [2]uint32{90, 91} {
73+ t.Fatalf("alloc %q fbs %v", c.alloc, c.fbs)
74+ }
75+ dumbFree(k)
76+ c.free()
77+ if c.imgs != nil || c.fbs != [2]uint32{} {
78+ t.Fatal("images kept after free")
79+ }
80+}
81+
82+// A dumb buffer that cannot be made (or mapped, or wrapped) falls back to
83+// the renderer's images, and what was made is released.
84+func TestCursorSetupFallsBackToRenderer(t *testing.T) {
85+ for name, fail := range map[string]func(k *mockkms){
86+ "create": func(k *mockkms) {
87+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
88+ },
89+ "map": func(k *mockkms) {
90+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
91+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
92+ k.EXPECT().mapDumb(b).Return(nil, unix.ENOMEM).Once()
93+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
94+ },
95+ "framebuffer": func(k *mockkms) {
96+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
97+ mem := make([]byte, b.size)
98+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
99+ k.EXPECT().mapDumb(b).Return(mem, nil).Once()
100+ k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()
101+ k.EXPECT().unmapDumb(mem).Return(nil).Once()
102+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
103+ },
104+ "second buffer": func(k *mockkms) {
105+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
106+ mem := make([]byte, b.size)
107+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
108+ k.EXPECT().mapDumb(b).Return(mem, nil).Once()
109+ k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(90, nil).Once()
110+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.EMFILE).Once()
111+ k.EXPECT().rmFB(uint32(90)).Return(nil).Once()
112+ k.EXPECT().unmapDumb(mem).Return(nil).Once()
113+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
114+ },
115+ } {
116+ t.Run(name, func(t *testing.T) {
117+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
118+ fail(k)
119+ rendererOK(t, k, r)
120+ c := newCursor(&plane{}, tSide)
121+ dumbErr, err := c.setup(k, r)
122+ if dumbErr == nil || err != nil {
123+ t.Fatalf("setup: %v %v", dumbErr, err)
124+ }
125+ if c.alloc != allocVulkan || c.fbs != [2]uint32{80, 81} {
126+ t.Fatalf("alloc %q fbs %v", c.alloc, c.fbs)
127+ }
128+ })
129+ }
130+}
131+
132+// Without either allocator there are no images.
133+func TestCursorSetupFailsWithoutAllocators(t *testing.T) {
134+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
135+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
136+ r.EXPECT().CursorBuffers(tSide).Return([2]ports.DMABuf{}, errors.New("no export")).Once()
137+ c := newCursor(&plane{}, tSide)
138+ dumbErr, err := c.setup(k, r)
139+ if dumbErr == nil || err == nil || c.imgs != nil || c.fbs != [2]uint32{} {
140+ t.Fatalf("setup: %v %v imgs %v fbs %v", dumbErr, err, c.imgs, c.fbs)
141+ }
142+ if _, err := c.useFallback(0); err != nil {
143+ t.Fatal(err)
144+ }
145+}
146+
147+// A renderer image whose framebuffer fails is released.
148+func TestRendererCursorReleasesOnFailure(t *testing.T) {
149+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
150+ bufs := rendererBufs(t)
151+ r.EXPECT().CursorBuffers(tSide).Return(bufs, nil).Once()
152+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
153+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()
154+ k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
155+ if _, err := newRendererCursor(k, r, tSide); err == nil {
156+ t.Fatal("framebuffer failure not reported")
157+ }
158+ assertPlanesClosed(t, bufs)
159+}
160+
161+func assertPlanesClosed(t *testing.T, bufs [2]ports.DMABuf) {
162+ t.Helper()
163+ for i, b := range bufs {
164+ for j, p := range b.Planes {
165+ if _, err := p.File.Stat(); !errors.Is(err, os.ErrClosed) {
166+ t.Errorf("buffer %d plane %d: Stat = %v, want os.ErrClosed", i, j, err)
167+ }
168+ }
169+ }
170+}
171+
172+// cursorOutput is a test output whose cursor plane has size tSide.
173+func cursorOutput(t *testing.T, errs ...error) (*Output, *mockkms, *portsmocks.MockRenderer, *[]commitRec) {
174+ o, k, commits := testOutput(t, errs...)
175+ o.cursor = newCursor(&plane{id: tCursor, typ: planeCursor, props: planeProps}, tSide)
176+ return o, k, portsmocks.NewMockRenderer(t), commits
177+}
178+
179+// The plane refuses the dumb images: the renderer's are tried (TEST_ONLY),
180+// the image on the cursor is rewritten into them, and writes go there.
181+func TestCursorDumbRefusedUsesRendererImages(t *testing.T) {
182+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
183+ dumbOK(k, tSide*4)
184+ if _, err := o.cursor.setup(k, r); err != nil {
185+ t.Fatal(err)
186+ }
187+ px := []byte{1, 2, 3, 4}
188+ if err := o.cursor.setImage(px, 1, 1, 2, 3, 0); err != nil {
189+ t.Fatal(err)
190+ }
191+ dumbFree(k)
192+ rendererOK(t, k, r)
193+ var written []int
194+ r.EXPECT().WriteCursor(mock.Anything, px, 1, 1).RunAndReturn(func(i int, _ []byte, _, _ int) error {
195+ written = append(written, i)
196+ return nil
197+ })
198+ o.testCursor()
199+ c := o.cursor
200+ if c.off || c.alloc != allocVulkan || c.fbs != [2]uint32{80, 81} || !c.image || c.hotX != 2 || c.hotY != 3 {
201+ t.Fatalf("off %v alloc %q fbs %v image %v hot %d,%d", c.off, c.alloc, c.fbs, c.image, c.hotX, c.hotY)
202+ }
203+ if len(written) != 1 || len(*commits) != 2 {
204+ t.Fatalf("written %v, %d TEST_ONLY commits", written, len(*commits))
205+ }
206+ if v, _ := (*commits)[1].req.value(tCursor, pFB); v != 80 || (*commits)[1].flags != atomicTestOnly {
207+ t.Fatalf("second test shows fb %d flags %#x", v, (*commits)[1].flags)
208+ }
209+ // Further changes are written by the renderer, not into the freed buffers.
210+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil || len(written) != 2 {
211+ t.Fatalf("write after fallback: %v %v", err, written)
212+ }
213+}
214+
215+// Both kinds refused: the software cursor takes over.
216+func TestCursorBothRefusedGoesOff(t *testing.T) {
217+ o, k, r, commits := cursorOutput(t, unix.EINVAL, unix.EINVAL)
218+ dumbOK(k, tSide*4)
219+ if _, err := o.cursor.setup(k, r); err != nil {
220+ t.Fatal(err)
221+ }
222+ dumbFree(k)
223+ rendererOK(t, k, r)
224+ if !o.testCursor() {
225+ t.Fatal("no change reported")
226+ }
227+ if !o.cursor.off || o.cursor.alloc != allocVulkan || len(*commits) != 2 {
228+ t.Fatalf("off %v alloc %q commits %d", o.cursor.off, o.cursor.alloc, len(*commits))
229+ }
230+ if o.cursor.desired().on {
231+ t.Fatal("cursor shown while off")
232+ }
233+ // The renderer's images are released with the output.
234+ k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
235+ k.EXPECT().rmFB(uint32(81)).Return(nil).Once()
236+ o.cursor.free()
237+}
238+
239+// Dumb images the plane takes stay, with no second test.
240+func TestCursorDumbAcceptedStays(t *testing.T) {
241+ for _, tc := range []struct {
242+ name string
243+ err error
244+ }{{"accepted", nil}, {"non-refusal", unix.EIO}} {
245+ t.Run(tc.name, func(t *testing.T) {
246+ o, k, r, commits := cursorOutput(t, tc.err)
247+ dumbOK(k, tSide*4)
248+ if _, err := o.cursor.setup(k, r); err != nil {
249+ t.Fatal(err)
250+ }
251+ if o.testCursor() || o.cursor.off || o.cursor.alloc != allocDumb || len(*commits) != 1 {
252+ t.Fatalf("off %v alloc %q commits %d", o.cursor.off, o.cursor.alloc, len(*commits))
253+ }
254+ dumbFree(k)
255+ o.cursor.free()
256+ })
257+ }
258+}
259+
260+// Renderer images refused while no dumb ones exist: off, no retry.
261+func TestCursorRendererRefusedGoesOff(t *testing.T) {
262+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
263+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
264+ rendererOK(t, k, r)
265+ if _, err := o.cursor.setup(k, r); err != nil {
266+ t.Fatal(err)
267+ }
268+ if !o.testCursor() || !o.cursor.off || len(*commits) != 1 {
269+ t.Fatalf("off %v commits %d", o.cursor.off, len(*commits))
270+ }
271+}
272+
273+// Writes go to the images that own the cursor, and the output's HDR white
274+// is passed along.
275+func TestCursorWritesGoToCurrentImages(t *testing.T) {
276+ o, _, _, _ := cursorOutput(t)
277+ imgs := newMockcursorImages(t)
278+ imgs.EXPECT().fb(0).Return(90).Once()
279+ imgs.EXPECT().fb(1).Return(91).Once()
280+ o.cursor.install(imgs, allocDumb)
281+ px := []byte{1, 2, 3, 4}
282+ imgs.EXPECT().write(0, px, 1, 1, float64(0)).Return(nil).Once()
283+ if err := o.cursor.setImage(px, 1, 1, 0, 0, o.cursorNits()); err != nil {
284+ t.Fatal(err)
285+ }
286+ o.hdr.on, o.hdr.settings.SDRBrightness = true, 203
287+ if got := o.cursorNits(); got != 203 {
288+ t.Fatalf("HDR white %v", got)
289+ }
290+ o.cursor.screen = 90
291+ imgs.EXPECT().write(1, px, 1, 1, float64(203)).Return(errors.New("full")).Once()
292+ if err := o.cursor.setImage(px, 1, 1, 0, 0, o.cursorNits()); err == nil {
293+ t.Fatal("write error swallowed")
294+ }
295+}
296+
297+// Dumb pitch may exceed the row size: rows land at the pitch, the image is
298+// cropped to the side and cleared around.
299+func TestDumbCursorWritePitchCropClear(t *testing.T) {
300+ k := newMockkms(t)
301+ const pitch = tSide*4 + 32
302+ mem := dumbOK(k, pitch)
303+ d, err := newDumbCursor(k, tSide)
304+ if err != nil {
305+ t.Fatal(err)
306+ }
307+ defer func() { dumbFree(k); d.close() }()
308+ big := make([]byte, 40*40*4)
309+ for y := range 40 {
310+ for x := range 40 {
311+ copy(big[(y*40+x)*4:], []byte{byte(x), byte(y), byte(x + y), 255})
312+ }
313+ }
314+ if err := d.write(1, big, 40, 40, 0); err != nil { // cropped to tSide
315+ t.Fatal(err)
316+ }
317+ last := (tSide - 1) * pitch
318+ for _, y := range []int{0, 1, 7, tSide - 1} {
319+ for _, x := range []int{0, 3, tSide - 1} {
320+ got := mem[1][y*pitch+x*4 : y*pitch+x*4+4]
321+ want := big[(y*40+x)*4 : (y*40+x)*4+4]
322+ if !bytes.Equal(got, want) {
323+ t.Fatalf("pixel %d,%d: %v want %v", x, y, got, want)
324+ }
325+ }
326+ }
327+ for y := range tSide {
328+ if !bytes.Equal(mem[1][y*pitch+tSide*4:(y+1)*pitch], make([]byte, pitch-tSide*4)) {
329+ t.Fatalf("row %d padding written", y)
330+ }
331+ }
332+ if err := d.write(1, []byte{1, 2, 3, 4}, 1, 1, 0); err != nil {
333+ t.Fatal(err)
334+ }
335+ m := mem[1]
336+ if !bytes.Equal(m[:4], []byte{1, 2, 3, 4}) || m[4] != 0 || m[pitch] != 0 || m[last+(tSide-1)*4] != 0 {
337+ t.Fatal("image not cleared around the new pixels")
338+ }
339+ // A wide, short image keeps its rows apart.
340+ row := []byte{9, 9, 9, 9, 8, 8, 8, 8}
341+ if err := d.write(0, append(row, row...), 2, 2, 0); err != nil {
342+ t.Fatal(err)
343+ }
344+ if m := mem[0]; !bytes.Equal(m[:8], row) || !bytes.Equal(m[pitch:pitch+8], row) || m[8] != 0 {
345+ t.Fatal("rows of a narrow image")
346+ }
347+ if err := d.write(0, []byte{1}, 1, 1, 0); err == nil {
348+ t.Fatal("short image accepted")
349+ }
350+ if err := d.write(2, nil, 0, 0, 0); err == nil {
351+ t.Fatal("bad index accepted")
352+ }
353+}
354+
355+// HDR cursors convert exactly as the renderer did (see hdrcursor's vector).
356+func TestDumbCursorWriteHDR(t *testing.T) {
357+ k := newMockkms(t)
358+ mem := dumbOK(k, tSide*4)
359+ d, err := newDumbCursor(k, tSide)
360+ if err != nil {
361+ t.Fatal(err)
362+ }
363+ defer func() { dumbFree(k); d.close() }()
364+ if err := d.write(0, []byte{255, 255, 255, 255}, 1, 1, 203); err != nil {
365+ t.Fatal(err)
366+ }
367+ if m := mem[0]; m[0] != 148 || m[1] != 148 || m[2] != 148 || m[3] != 255 {
368+ t.Fatalf("HDR white: %v", m[:4])
369+ }
370+ if err := d.write(1, []byte{255, 255, 255, 255}, 1, 1, 0); err != nil || mem[1][0] != 255 {
371+ t.Fatalf("SDR white: %v %v", err, mem[1][:4])
372+ }
373+ if d.cache.Len() != 4 {
374+ t.Fatalf("cache %d", d.cache.Len())
375+ }
376+}
377+
378+func TestCursorFallbackState(t *testing.T) {
379+ for _, mode := range []string{"loaded", "queued", "hidden"} {
380+ t.Run(mode, func(t *testing.T) {
381+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
382+ dumbOK(k, tSide*4)
383+ c := o.cursor
384+ if _, err := c.setup(k, r); err != nil {
385+ t.Fatal(err)
386+ }
387+ px := []byte{1, 2, 3, 4}
388+ if err := c.setImage(px, 1, 1, 2, 3, 0); err != nil {
389+ t.Fatal(err)
390+ }
391+ c.screen = 90
392+ c.committed(cursorState{on: true, fb: 91, x: 8, y: 9})
393+ want := []byte{1, 2, 3, 4}
394+ if mode == "queued" {
395+ px = []byte{5, 6, 7, 8}
396+ want = bytes.Clone(px)
397+ if err := c.setImage(px, 1, 1, 4, 5, 0); err != nil {
398+ t.Fatal(err)
399+ }
400+ if c.later == nil {
401+ t.Fatal("image not queued")
402+ }
403+ }
404+ if mode == "hidden" {
405+ if err := c.setImage(nil, 0, 0, 0, 0, 0); err != nil {
406+ t.Fatal(err)
407+ }
408+ }
409+ px[0] = 99 // Neither the loaded nor queued image may alias caller memory.
410+ dumbFree(k)
411+ rendererOK(t, k, r)
412+ if mode != "hidden" {
413+ r.EXPECT().WriteCursor(0, want, 1, 1).Return(nil).Once()
414+ }
415+ if !o.testCursor() {
416+ t.Fatal("swap not reported")
417+ }
418+ if c.screen != 0 || c.flying || c.flight != (cursorState{}) || c.applied != (cursorState{}) || c.later != nil {
419+ t.Fatalf("stale scanout state: screen %d flight %+v flying %v applied %+v", c.screen, c.flight, c.flying, c.applied)
420+ }
421+ c.landed()
422+ if c.screen != 0 {
423+ t.Fatal("old pending flight resurrected")
424+ }
425+ s := c.desired()
426+ if mode == "hidden" {
427+ if s.on || c.image {
428+ t.Fatal("hidden cursor shown")
429+ }
430+ } else {
431+ if !s.on || s.fb != 80 {
432+ t.Fatalf("desired %+v", s)
433+ }
434+ if mode == "queued" && (c.hotX != 4 || c.hotY != 5) {
435+ t.Fatal("queued hotspot lost")
436+ }
437+ }
438+ req := &atomicReq{}
439+ c.props(req, o.crtc, s)
440+ if err := k.commit(req, 0, 0); err != nil {
441+ t.Fatal(err)
442+ }
443+ wantFB := uint64(80)
444+ if mode == "hidden" {
445+ wantFB = 0
446+ }
447+ if fb, _ := (*commits)[2].req.value(tCursor, pFB); fb != wantFB {
448+ t.Fatalf("next commit fb %d want %d", fb, wantFB)
449+ }
450+ c.committed(s)
451+ c.landed()
452+ if c.screen != uint32(wantFB) {
453+ t.Fatalf("screen %d", c.screen)
454+ }
455+ })
456+ }
457+}
458+
459+func TestCursorFallbackFailureGoesOff(t *testing.T) {
460+ for _, mode := range []string{"allocation", "rewrite"} {
461+ t.Run(mode, func(t *testing.T) {
462+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
463+ dumbOK(k, tSide*4)
464+ c := o.cursor
465+ if _, err := c.setup(k, r); err != nil {
466+ t.Fatal(err)
467+ }
468+ px := []byte{1, 2, 3, 4}
469+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil {
470+ t.Fatal(err)
471+ }
472+ if mode == "allocation" {
473+ r.EXPECT().CursorBuffers(tSide).Return([2]ports.DMABuf{}, errors.New("allocation failed")).Once()
474+ } else {
475+ dumbFree(k)
476+ rendererOK(t, k, r)
477+ r.EXPECT().WriteCursor(0, px, 1, 1).Return(errors.New("rewrite failed")).Once()
478+ }
479+ if !o.testCursor() || !c.off || c.desired().on || len(*commits) != 1 {
480+ t.Fatalf("off %v desired %+v commits %d", c.off, c.desired(), len(*commits))
481+ }
482+ if mode == "allocation" {
483+ dumbFree(k)
484+ } else {
485+ k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
486+ k.EXPECT().rmFB(uint32(81)).Return(nil).Once()
487+ }
488+ c.free()
489+ })
490+ }
491+}
@@ -0,0 +1,491 @@
1+package drm
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "os"
7+ "testing"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+ "golang.org/x/sys/unix"
13+)
14+
15+const tSide = 16
16+
17+// dumbOK expects two dumb buffers with handles 10 and 11, mappings (with
18+// pitch bytes per row) returned in mem, and framebuffers 90 and 91.
19+func dumbOK(k *mockkms, pitch int) (mem [2][]byte) {
20+ for i := range mem {
21+ mem[i] = make([]byte, pitch*tSide)
22+ h := uint32(10 + i)
23+ b := dumbBuffer{handle: h, pitch: uint32(pitch), size: uint64(len(mem[i]))}
24+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
25+ k.EXPECT().mapDumb(b).Return(mem[i], nil).Once()
26+ k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(90+uint32(i), nil).Once()
27+ }
28+ return mem
29+}
30+
31+func dumbFree(k *mockkms) {
32+ for i := range 2 {
33+ k.EXPECT().rmFB(90 + uint32(i)).Return(nil).Once()
34+ k.EXPECT().destroyDumb(10 + uint32(i)).Return(nil).Once()
35+ }
36+ k.EXPECT().unmapDumb(mock.Anything).Return(nil).Twice()
37+}
38+
39+func rendererBufs(t *testing.T) [2]ports.DMABuf {
40+ var b [2]ports.DMABuf
41+ for i := range b {
42+ f, w, err := os.Pipe()
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ t.Cleanup(func() { _ = f.Close(); _ = w.Close() })
47+ if err := w.Close(); err != nil {
48+ t.Fatal(err)
49+ }
50+ b[i] = ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
51+ }
52+ return b
53+}
54+
55+func rendererOK(t *testing.T, k *mockkms, r *portsmocks.MockRenderer) {
56+ b := rendererBufs(t)
57+ r.EXPECT().CursorBuffers(tSide).Return(b, nil).Once()
58+ t.Cleanup(func() { assertPlanesClosed(t, b) })
59+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
60+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(81, nil).Once()
61+}
62+
63+// Dumb buffers come first: the renderer allocates nothing.
64+func TestCursorSetupUsesDumbBuffers(t *testing.T) {
65+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
66+ dumbOK(k, tSide*4)
67+ c := newCursor(&plane{}, tSide)
68+ dumbErr, err := c.setup(k, r)
69+ if dumbErr != nil || err != nil {
70+ t.Fatalf("setup: %v %v", dumbErr, err)
71+ }
72+ if c.alloc != allocDumb || c.fbs != [2]uint32{90, 91} {
73+ t.Fatalf("alloc %q fbs %v", c.alloc, c.fbs)
74+ }
75+ dumbFree(k)
76+ c.free()
77+ if c.imgs != nil || c.fbs != [2]uint32{} {
78+ t.Fatal("images kept after free")
79+ }
80+}
81+
82+// A dumb buffer that cannot be made (or mapped, or wrapped) falls back to
83+// the renderer's images, and what was made is released.
84+func TestCursorSetupFallsBackToRenderer(t *testing.T) {
85+ for name, fail := range map[string]func(k *mockkms){
86+ "create": func(k *mockkms) {
87+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
88+ },
89+ "map": func(k *mockkms) {
90+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
91+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
92+ k.EXPECT().mapDumb(b).Return(nil, unix.ENOMEM).Once()
93+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
94+ },
95+ "framebuffer": func(k *mockkms) {
96+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
97+ mem := make([]byte, b.size)
98+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
99+ k.EXPECT().mapDumb(b).Return(mem, nil).Once()
100+ k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()
101+ k.EXPECT().unmapDumb(mem).Return(nil).Once()
102+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
103+ },
104+ "second buffer": func(k *mockkms) {
105+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
106+ mem := make([]byte, b.size)
107+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
108+ k.EXPECT().mapDumb(b).Return(mem, nil).Once()
109+ k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(90, nil).Once()
110+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.EMFILE).Once()
111+ k.EXPECT().rmFB(uint32(90)).Return(nil).Once()
112+ k.EXPECT().unmapDumb(mem).Return(nil).Once()
113+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
114+ },
115+ } {
116+ t.Run(name, func(t *testing.T) {
117+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
118+ fail(k)
119+ rendererOK(t, k, r)
120+ c := newCursor(&plane{}, tSide)
121+ dumbErr, err := c.setup(k, r)
122+ if dumbErr == nil || err != nil {
123+ t.Fatalf("setup: %v %v", dumbErr, err)
124+ }
125+ if c.alloc != allocVulkan || c.fbs != [2]uint32{80, 81} {
126+ t.Fatalf("alloc %q fbs %v", c.alloc, c.fbs)
127+ }
128+ })
129+ }
130+}
131+
132+// Without either allocator there are no images.
133+func TestCursorSetupFailsWithoutAllocators(t *testing.T) {
134+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
135+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
136+ r.EXPECT().CursorBuffers(tSide).Return([2]ports.DMABuf{}, errors.New("no export")).Once()
137+ c := newCursor(&plane{}, tSide)
138+ dumbErr, err := c.setup(k, r)
139+ if dumbErr == nil || err == nil || c.imgs != nil || c.fbs != [2]uint32{} {
140+ t.Fatalf("setup: %v %v imgs %v fbs %v", dumbErr, err, c.imgs, c.fbs)
141+ }
142+ if _, err := c.useFallback(0); err != nil {
143+ t.Fatal(err)
144+ }
145+}
146+
147+// A renderer image whose framebuffer fails is released.
148+func TestRendererCursorReleasesOnFailure(t *testing.T) {
149+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
150+ bufs := rendererBufs(t)
151+ r.EXPECT().CursorBuffers(tSide).Return(bufs, nil).Once()
152+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
153+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()
154+ k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
155+ if _, err := newRendererCursor(k, r, tSide); err == nil {
156+ t.Fatal("framebuffer failure not reported")
157+ }
158+ assertPlanesClosed(t, bufs)
159+}
160+
161+func assertPlanesClosed(t *testing.T, bufs [2]ports.DMABuf) {
162+ t.Helper()
163+ for i, b := range bufs {
164+ for j, p := range b.Planes {
165+ if _, err := p.File.Stat(); !errors.Is(err, os.ErrClosed) {
166+ t.Errorf("buffer %d plane %d: Stat = %v, want os.ErrClosed", i, j, err)
167+ }
168+ }
169+ }
170+}
171+
172+// cursorOutput is a test output whose cursor plane has size tSide.
173+func cursorOutput(t *testing.T, errs ...error) (*Output, *mockkms, *portsmocks.MockRenderer, *[]commitRec) {
174+ o, k, commits := testOutput(t, errs...)
175+ o.cursor = newCursor(&plane{id: tCursor, typ: planeCursor, props: planeProps}, tSide)
176+ return o, k, portsmocks.NewMockRenderer(t), commits
177+}
178+
179+// The plane refuses the dumb images: the renderer's are tried (TEST_ONLY),
180+// the image on the cursor is rewritten into them, and writes go there.
181+func TestCursorDumbRefusedUsesRendererImages(t *testing.T) {
182+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
183+ dumbOK(k, tSide*4)
184+ if _, err := o.cursor.setup(k, r); err != nil {
185+ t.Fatal(err)
186+ }
187+ px := []byte{1, 2, 3, 4}
188+ if err := o.cursor.setImage(px, 1, 1, 2, 3, 0); err != nil {
189+ t.Fatal(err)
190+ }
191+ dumbFree(k)
192+ rendererOK(t, k, r)
193+ var written []int
194+ r.EXPECT().WriteCursor(mock.Anything, px, 1, 1).RunAndReturn(func(i int, _ []byte, _, _ int) error {
195+ written = append(written, i)
196+ return nil
197+ })
198+ o.testCursor()
199+ c := o.cursor
200+ if c.off || c.alloc != allocVulkan || c.fbs != [2]uint32{80, 81} || !c.image || c.hotX != 2 || c.hotY != 3 {
201+ t.Fatalf("off %v alloc %q fbs %v image %v hot %d,%d", c.off, c.alloc, c.fbs, c.image, c.hotX, c.hotY)
202+ }
203+ if len(written) != 1 || len(*commits) != 2 {
204+ t.Fatalf("written %v, %d TEST_ONLY commits", written, len(*commits))
205+ }
206+ if v, _ := (*commits)[1].req.value(tCursor, pFB); v != 80 || (*commits)[1].flags != atomicTestOnly {
207+ t.Fatalf("second test shows fb %d flags %#x", v, (*commits)[1].flags)
208+ }
209+ // Further changes are written by the renderer, not into the freed buffers.
210+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil || len(written) != 2 {
211+ t.Fatalf("write after fallback: %v %v", err, written)
212+ }
213+}
214+
215+// Both kinds refused: the software cursor takes over.
216+func TestCursorBothRefusedGoesOff(t *testing.T) {
217+ o, k, r, commits := cursorOutput(t, unix.EINVAL, unix.EINVAL)
218+ dumbOK(k, tSide*4)
219+ if _, err := o.cursor.setup(k, r); err != nil {
220+ t.Fatal(err)
221+ }
222+ dumbFree(k)
223+ rendererOK(t, k, r)
224+ if !o.testCursor() {
225+ t.Fatal("no change reported")
226+ }
227+ if !o.cursor.off || o.cursor.alloc != allocVulkan || len(*commits) != 2 {
228+ t.Fatalf("off %v alloc %q commits %d", o.cursor.off, o.cursor.alloc, len(*commits))
229+ }
230+ if o.cursor.desired().on {
231+ t.Fatal("cursor shown while off")
232+ }
233+ // The renderer's images are released with the output.
234+ k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
235+ k.EXPECT().rmFB(uint32(81)).Return(nil).Once()
236+ o.cursor.free()
237+}
238+
239+// Dumb images the plane takes stay, with no second test.
240+func TestCursorDumbAcceptedStays(t *testing.T) {
241+ for _, tc := range []struct {
242+ name string
243+ err error
244+ }{{"accepted", nil}, {"non-refusal", unix.EIO}} {
245+ t.Run(tc.name, func(t *testing.T) {
246+ o, k, r, commits := cursorOutput(t, tc.err)
247+ dumbOK(k, tSide*4)
248+ if _, err := o.cursor.setup(k, r); err != nil {
249+ t.Fatal(err)
250+ }
251+ if o.testCursor() || o.cursor.off || o.cursor.alloc != allocDumb || len(*commits) != 1 {
252+ t.Fatalf("off %v alloc %q commits %d", o.cursor.off, o.cursor.alloc, len(*commits))
253+ }
254+ dumbFree(k)
255+ o.cursor.free()
256+ })
257+ }
258+}
259+
260+// Renderer images refused while no dumb ones exist: off, no retry.
261+func TestCursorRendererRefusedGoesOff(t *testing.T) {
262+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
263+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
264+ rendererOK(t, k, r)
265+ if _, err := o.cursor.setup(k, r); err != nil {
266+ t.Fatal(err)
267+ }
268+ if !o.testCursor() || !o.cursor.off || len(*commits) != 1 {
269+ t.Fatalf("off %v commits %d", o.cursor.off, len(*commits))
270+ }
271+}
272+
273+// Writes go to the images that own the cursor, and the output's HDR white
274+// is passed along.
275+func TestCursorWritesGoToCurrentImages(t *testing.T) {
276+ o, _, _, _ := cursorOutput(t)
277+ imgs := newMockcursorImages(t)
278+ imgs.EXPECT().fb(0).Return(90).Once()
279+ imgs.EXPECT().fb(1).Return(91).Once()
280+ o.cursor.install(imgs, allocDumb)
281+ px := []byte{1, 2, 3, 4}
282+ imgs.EXPECT().write(0, px, 1, 1, float64(0)).Return(nil).Once()
283+ if err := o.cursor.setImage(px, 1, 1, 0, 0, o.cursorNits()); err != nil {
284+ t.Fatal(err)
285+ }
286+ o.hdr.on, o.hdr.settings.SDRBrightness = true, 203
287+ if got := o.cursorNits(); got != 203 {
288+ t.Fatalf("HDR white %v", got)
289+ }
290+ o.cursor.screen = 90
291+ imgs.EXPECT().write(1, px, 1, 1, float64(203)).Return(errors.New("full")).Once()
292+ if err := o.cursor.setImage(px, 1, 1, 0, 0, o.cursorNits()); err == nil {
293+ t.Fatal("write error swallowed")
294+ }
295+}
296+
297+// Dumb pitch may exceed the row size: rows land at the pitch, the image is
298+// cropped to the side and cleared around.
299+func TestDumbCursorWritePitchCropClear(t *testing.T) {
300+ k := newMockkms(t)
301+ const pitch = tSide*4 + 32
302+ mem := dumbOK(k, pitch)
303+ d, err := newDumbCursor(k, tSide)
304+ if err != nil {
305+ t.Fatal(err)
306+ }
307+ defer func() { dumbFree(k); d.close() }()
308+ big := make([]byte, 40*40*4)
309+ for y := range 40 {
310+ for x := range 40 {
311+ copy(big[(y*40+x)*4:], []byte{byte(x), byte(y), byte(x + y), 255})
312+ }
313+ }
314+ if err := d.write(1, big, 40, 40, 0); err != nil { // cropped to tSide
315+ t.Fatal(err)
316+ }
317+ last := (tSide - 1) * pitch
318+ for _, y := range []int{0, 1, 7, tSide - 1} {
319+ for _, x := range []int{0, 3, tSide - 1} {
320+ got := mem[1][y*pitch+x*4 : y*pitch+x*4+4]
321+ want := big[(y*40+x)*4 : (y*40+x)*4+4]
322+ if !bytes.Equal(got, want) {
323+ t.Fatalf("pixel %d,%d: %v want %v", x, y, got, want)
324+ }
325+ }
326+ }
327+ for y := range tSide {
328+ if !bytes.Equal(mem[1][y*pitch+tSide*4:(y+1)*pitch], make([]byte, pitch-tSide*4)) {
329+ t.Fatalf("row %d padding written", y)
330+ }
331+ }
332+ if err := d.write(1, []byte{1, 2, 3, 4}, 1, 1, 0); err != nil {
333+ t.Fatal(err)
334+ }
335+ m := mem[1]
336+ if !bytes.Equal(m[:4], []byte{1, 2, 3, 4}) || m[4] != 0 || m[pitch] != 0 || m[last+(tSide-1)*4] != 0 {
337+ t.Fatal("image not cleared around the new pixels")
338+ }
339+ // A wide, short image keeps its rows apart.
340+ row := []byte{9, 9, 9, 9, 8, 8, 8, 8}
341+ if err := d.write(0, append(row, row...), 2, 2, 0); err != nil {
342+ t.Fatal(err)
343+ }
344+ if m := mem[0]; !bytes.Equal(m[:8], row) || !bytes.Equal(m[pitch:pitch+8], row) || m[8] != 0 {
345+ t.Fatal("rows of a narrow image")
346+ }
347+ if err := d.write(0, []byte{1}, 1, 1, 0); err == nil {
348+ t.Fatal("short image accepted")
349+ }
350+ if err := d.write(2, nil, 0, 0, 0); err == nil {
351+ t.Fatal("bad index accepted")
352+ }
353+}
354+
355+// HDR cursors convert exactly as the renderer did (see hdrcursor's vector).
356+func TestDumbCursorWriteHDR(t *testing.T) {
357+ k := newMockkms(t)
358+ mem := dumbOK(k, tSide*4)
359+ d, err := newDumbCursor(k, tSide)
360+ if err != nil {
361+ t.Fatal(err)
362+ }
363+ defer func() { dumbFree(k); d.close() }()
364+ if err := d.write(0, []byte{255, 255, 255, 255}, 1, 1, 203); err != nil {
365+ t.Fatal(err)
366+ }
367+ if m := mem[0]; m[0] != 148 || m[1] != 148 || m[2] != 148 || m[3] != 255 {
368+ t.Fatalf("HDR white: %v", m[:4])
369+ }
370+ if err := d.write(1, []byte{255, 255, 255, 255}, 1, 1, 0); err != nil || mem[1][0] != 255 {
371+ t.Fatalf("SDR white: %v %v", err, mem[1][:4])
372+ }
373+ if d.cache.Len() != 4 {
374+ t.Fatalf("cache %d", d.cache.Len())
375+ }
376+}
377+
378+func TestCursorFallbackState(t *testing.T) {
379+ for _, mode := range []string{"loaded", "queued", "hidden"} {
380+ t.Run(mode, func(t *testing.T) {
381+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
382+ dumbOK(k, tSide*4)
383+ c := o.cursor
384+ if _, err := c.setup(k, r); err != nil {
385+ t.Fatal(err)
386+ }
387+ px := []byte{1, 2, 3, 4}
388+ if err := c.setImage(px, 1, 1, 2, 3, 0); err != nil {
389+ t.Fatal(err)
390+ }
391+ c.screen = 90
392+ c.committed(cursorState{on: true, fb: 91, x: 8, y: 9})
393+ want := []byte{1, 2, 3, 4}
394+ if mode == "queued" {
395+ px = []byte{5, 6, 7, 8}
396+ want = bytes.Clone(px)
397+ if err := c.setImage(px, 1, 1, 4, 5, 0); err != nil {
398+ t.Fatal(err)
399+ }
400+ if c.later == nil {
401+ t.Fatal("image not queued")
402+ }
403+ }
404+ if mode == "hidden" {
405+ if err := c.setImage(nil, 0, 0, 0, 0, 0); err != nil {
406+ t.Fatal(err)
407+ }
408+ }
409+ px[0] = 99 // Neither the loaded nor queued image may alias caller memory.
410+ dumbFree(k)
411+ rendererOK(t, k, r)
412+ if mode != "hidden" {
413+ r.EXPECT().WriteCursor(0, want, 1, 1).Return(nil).Once()
414+ }
415+ if !o.testCursor() {
416+ t.Fatal("swap not reported")
417+ }
418+ if c.screen != 0 || c.flying || c.flight != (cursorState{}) || c.applied != (cursorState{}) || c.later != nil {
419+ t.Fatalf("stale scanout state: screen %d flight %+v flying %v applied %+v", c.screen, c.flight, c.flying, c.applied)
420+ }
421+ c.landed()
422+ if c.screen != 0 {
423+ t.Fatal("old pending flight resurrected")
424+ }
425+ s := c.desired()
426+ if mode == "hidden" {
427+ if s.on || c.image {
428+ t.Fatal("hidden cursor shown")
429+ }
430+ } else {
431+ if !s.on || s.fb != 80 {
432+ t.Fatalf("desired %+v", s)
433+ }
434+ if mode == "queued" && (c.hotX != 4 || c.hotY != 5) {
435+ t.Fatal("queued hotspot lost")
436+ }
437+ }
438+ req := &atomicReq{}
439+ c.props(req, o.crtc, s)
440+ if err := k.commit(req, 0, 0); err != nil {
441+ t.Fatal(err)
442+ }
443+ wantFB := uint64(80)
444+ if mode == "hidden" {
445+ wantFB = 0
446+ }
447+ if fb, _ := (*commits)[2].req.value(tCursor, pFB); fb != wantFB {
448+ t.Fatalf("next commit fb %d want %d", fb, wantFB)
449+ }
450+ c.committed(s)
451+ c.landed()
452+ if c.screen != uint32(wantFB) {
453+ t.Fatalf("screen %d", c.screen)
454+ }
455+ })
456+ }
457+}
458+
459+func TestCursorFallbackFailureGoesOff(t *testing.T) {
460+ for _, mode := range []string{"allocation", "rewrite"} {
461+ t.Run(mode, func(t *testing.T) {
462+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
463+ dumbOK(k, tSide*4)
464+ c := o.cursor
465+ if _, err := c.setup(k, r); err != nil {
466+ t.Fatal(err)
467+ }
468+ px := []byte{1, 2, 3, 4}
469+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil {
470+ t.Fatal(err)
471+ }
472+ if mode == "allocation" {
473+ r.EXPECT().CursorBuffers(tSide).Return([2]ports.DMABuf{}, errors.New("allocation failed")).Once()
474+ } else {
475+ dumbFree(k)
476+ rendererOK(t, k, r)
477+ r.EXPECT().WriteCursor(0, px, 1, 1).Return(errors.New("rewrite failed")).Once()
478+ }
479+ if !o.testCursor() || !c.off || c.desired().on || len(*commits) != 1 {
480+ t.Fatalf("off %v desired %+v commits %d", c.off, c.desired(), len(*commits))
481+ }
482+ if mode == "allocation" {
483+ dumbFree(k)
484+ } else {
485+ k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
486+ k.EXPECT().rmFB(uint32(81)).Return(nil).Once()
487+ }
488+ c.free()
489+ })
490+ }
491+}
A internal/adapters/drm/dumb.go +77 -0
@@ -0,0 +1,77 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "unsafe"
7+
8+ "golang.org/x/sys/unix"
9+)
10+
11+// KMS dumb buffers: the driver allocates a linear buffer the CPU maps and
12+// the display scans out. Cursor images use them (see cursor_images.go).
13+
14+const (
15+ ioctlCreateDumb = 0xC02064B2 // DRM_IOWR('d', 0xB2, struct drm_mode_create_dumb)
16+ ioctlMapDumb = 0xC01064B3 // DRM_IOWR('d', 0xB3, struct drm_mode_map_dumb)
17+ ioctlDestroyDumb = 0xC00464B4 // DRM_IOWR('d', 0xB4, struct drm_mode_destroy_dumb)
18+)
19+
20+type createDumb struct {
21+ height, width, bpp, flags uint32
22+ handle, pitch uint32
23+ size uint64
24+}
25+
26+type mapDumb struct {
27+ handle, pad uint32
28+ offset uint64
29+}
30+
31+// dumbBuffer is a dumb buffer's GEM handle, row pitch and size in bytes.
32+type dumbBuffer struct {
33+ handle, pitch uint32
34+ size uint64
35+}
36+
37+func (k kmsDevice) createDumb(width, height int) (dumbBuffer, error) {
38+ if width <= 0 || height <= 0 {
39+ return dumbBuffer{}, fmt.Errorf("dumb buffer size %dx%d", width, height)
40+ }
41+ c := createDumb{height: uint32(height), width: uint32(width), bpp: 32}
42+ if err := ioctl(k.fd, ioctlCreateDumb, unsafe.Pointer(&c)); err != nil {
43+ return dumbBuffer{}, err
44+ }
45+ return dumbBuffer{handle: c.handle, pitch: c.pitch, size: c.size}, nil
46+}
47+
48+func (k kmsDevice) mapDumb(b dumbBuffer) ([]byte, error) {
49+ m := mapDumb{handle: b.handle}
50+ if err := ioctl(k.fd, ioctlMapDumb, unsafe.Pointer(&m)); err != nil {
51+ return nil, err
52+ }
53+ if b.size == 0 || b.size > 1<<30 {
54+ return nil, fmt.Errorf("dumb buffer size %d", b.size)
55+ }
56+ return unix.Mmap(k.fd, int64(m.offset), int(b.size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
57+}
58+
59+func (k kmsDevice) unmapDumb(mem []byte) error { return unix.Munmap(mem) }
60+
61+// addFBHandle makes a framebuffer of format over the dumb buffer, without
62+// modifier (the driver's linear layout).
63+func (k kmsDevice) addFBHandle(b dumbBuffer, width, height int, format uint32) (uint32, error) {
64+ if b.handle == 0 {
65+ return 0, errors.New("no dumb buffer")
66+ }
67+ cmd := fbCmd2{width: uint32(width), height: uint32(height), format: format}
68+ cmd.handles[0], cmd.pitches[0] = b.handle, b.pitch
69+ if err := ioctl(k.fd, ioctlAddFB2, unsafe.Pointer(&cmd)); err != nil {
70+ return 0, errors.Join(errors.New("add fb2"), err)
71+ }
72+ return cmd.fbID, nil
73+}
74+
75+func (k kmsDevice) destroyDumb(handle uint32) error {
76+ return ioctl(k.fd, ioctlDestroyDumb, unsafe.Pointer(&handle))
77+}
@@ -0,0 +1,77 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "unsafe"
7+
8+ "golang.org/x/sys/unix"
9+)
10+
11+// KMS dumb buffers: the driver allocates a linear buffer the CPU maps and
12+// the display scans out. Cursor images use them (see cursor_images.go).
13+
14+const (
15+ ioctlCreateDumb = 0xC02064B2 // DRM_IOWR('d', 0xB2, struct drm_mode_create_dumb)
16+ ioctlMapDumb = 0xC01064B3 // DRM_IOWR('d', 0xB3, struct drm_mode_map_dumb)
17+ ioctlDestroyDumb = 0xC00464B4 // DRM_IOWR('d', 0xB4, struct drm_mode_destroy_dumb)
18+)
19+
20+type createDumb struct {
21+ height, width, bpp, flags uint32
22+ handle, pitch uint32
23+ size uint64
24+}
25+
26+type mapDumb struct {
27+ handle, pad uint32
28+ offset uint64
29+}
30+
31+// dumbBuffer is a dumb buffer's GEM handle, row pitch and size in bytes.
32+type dumbBuffer struct {
33+ handle, pitch uint32
34+ size uint64
35+}
36+
37+func (k kmsDevice) createDumb(width, height int) (dumbBuffer, error) {
38+ if width <= 0 || height <= 0 {
39+ return dumbBuffer{}, fmt.Errorf("dumb buffer size %dx%d", width, height)
40+ }
41+ c := createDumb{height: uint32(height), width: uint32(width), bpp: 32}
42+ if err := ioctl(k.fd, ioctlCreateDumb, unsafe.Pointer(&c)); err != nil {
43+ return dumbBuffer{}, err
44+ }
45+ return dumbBuffer{handle: c.handle, pitch: c.pitch, size: c.size}, nil
46+}
47+
48+func (k kmsDevice) mapDumb(b dumbBuffer) ([]byte, error) {
49+ m := mapDumb{handle: b.handle}
50+ if err := ioctl(k.fd, ioctlMapDumb, unsafe.Pointer(&m)); err != nil {
51+ return nil, err
52+ }
53+ if b.size == 0 || b.size > 1<<30 {
54+ return nil, fmt.Errorf("dumb buffer size %d", b.size)
55+ }
56+ return unix.Mmap(k.fd, int64(m.offset), int(b.size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
57+}
58+
59+func (k kmsDevice) unmapDumb(mem []byte) error { return unix.Munmap(mem) }
60+
61+// addFBHandle makes a framebuffer of format over the dumb buffer, without
62+// modifier (the driver's linear layout).
63+func (k kmsDevice) addFBHandle(b dumbBuffer, width, height int, format uint32) (uint32, error) {
64+ if b.handle == 0 {
65+ return 0, errors.New("no dumb buffer")
66+ }
67+ cmd := fbCmd2{width: uint32(width), height: uint32(height), format: format}
68+ cmd.handles[0], cmd.pitches[0] = b.handle, b.pitch
69+ if err := ioctl(k.fd, ioctlAddFB2, unsafe.Pointer(&cmd)); err != nil {
70+ return 0, errors.Join(errors.New("add fb2"), err)
71+ }
72+ return cmd.fbID, nil
73+}
74+
75+func (k kmsDevice) destroyDumb(handle uint32) error {
76+ return ioctl(k.fd, ioctlDestroyDumb, unsafe.Pointer(&handle))
77+}
A internal/adapters/drm/dumb_device_test.go +111 -0
@@ -0,0 +1,111 @@
1+package drm
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "golang.org/x/sys/unix"
11+)
12+
13+// Real-device check of the dumb buffer layer: no modeset, no atomic commit.
14+// It skips when no card node can be opened or makes dumb buffers.
15+func TestDumbBuffersOnRealDevice(t *testing.T) {
16+ cards, _ := filepath.Glob("/dev/dri/card*")
17+ if len(cards) == 0 {
18+ t.Skip("no /dev/dri/card* node")
19+ }
20+ for _, path := range cards {
21+ f, err := os.OpenFile(path, os.O_RDWR|unix.O_CLOEXEC, 0)
22+ if err != nil {
23+ t.Logf("%s: %v", path, err)
24+ continue
25+ }
26+ done := func() bool {
27+ defer f.Close()
28+ return dumbDeviceRoundTrip(t, path, kmsDevice{fd: int(f.Fd())})
29+ }()
30+ if done {
31+ return
32+ }
33+ }
34+ t.Skip("no card node makes dumb buffers for this user")
35+}
36+
37+// dumbDeviceRoundTrip runs the buffer life cycle on k and reports false
38+// when the card cannot do it for a reason unrelated to the code under test
39+// (the permission or the driver).
40+func dumbDeviceRoundTrip(t *testing.T, path string, k kmsDevice) bool {
41+ size := cursorSize(k.fd)
42+ var imgs [2]dumbBuffer
43+ var mems [2][]byte
44+ var fbs [2]uint32
45+ cleanup := func() {
46+ for i := range imgs {
47+ if fbs[i] != 0 {
48+ if err := k.rmFB(fbs[i]); err != nil {
49+ t.Errorf("rmfb: %v", err)
50+ }
51+ }
52+ if mems[i] != nil {
53+ if err := k.unmapDumb(mems[i]); err != nil {
54+ t.Errorf("munmap: %v", err)
55+ }
56+ }
57+ if imgs[i].handle != 0 {
58+ if err := k.destroyDumb(imgs[i].handle); err != nil {
59+ t.Errorf("destroy dumb: %v", err)
60+ }
61+ }
62+ }
63+ }
64+ defer cleanup()
65+ for i := range imgs {
66+ b, err := k.createDumb(size, size)
67+ if err != nil {
68+ if errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM) || errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EINVAL) {
69+ t.Logf("%s: create dumb: %v", path, err)
70+ return false
71+ }
72+ t.Fatalf("%s: create dumb: %v", path, err)
73+ }
74+ imgs[i] = b
75+ if int(b.pitch) < size*4 || b.size < uint64(int(b.pitch)*size) {
76+ t.Fatalf("%s: dumb %dx%d pitch %d size %d", path, size, size, b.pitch, b.size)
77+ }
78+ if mems[i], err = k.mapDumb(b); err != nil {
79+ t.Fatalf("%s: map dumb: %v", path, err)
80+ }
81+ if fbs[i], err = k.addFBHandle(b, size, size, fourccARGB); err != nil {
82+ t.Fatalf("%s: add fb2 ARGB8888: %v", path, err)
83+ }
84+ t.Logf("%s: dumb buffer %d: %dx%d pitch %d size %d fb %d", path, i, size, size, b.pitch, b.size, fbs[i])
85+ }
86+ // Write a pattern through the shared mapping; each buffer keeps its own.
87+ for i, mem := range mems {
88+ d := &dumbCursor{k: k, size: size}
89+ d.img[i] = dumbImage{pitch: int(imgs[i].pitch), mem: mem}
90+ px := bytes.Repeat([]byte{byte(0x10 + i), 0x20, 0x30, 0xff}, 3*2)
91+ if err := d.write(i, px, 3, 2, 0); err != nil {
92+ t.Fatal(err)
93+ }
94+ }
95+ for i, mem := range mems {
96+ pitch := int(imgs[i].pitch)
97+ want := []byte{byte(0x10 + i), 0x20, 0x30, 0xff}
98+ for y := range 2 {
99+ if got := mem[y*pitch : y*pitch+4]; !bytes.Equal(got, want) {
100+ t.Fatalf("%s: buffer %d row %d read back %v, want %v", path, i, y, got, want)
101+ }
102+ if got := mem[y*pitch+3*4]; got != 0 {
103+ t.Fatalf("%s: buffer %d row %d not cleared after the image", path, i, y)
104+ }
105+ }
106+ if mem[2*pitch] != 0 || mem[(size-1)*pitch+(size-1)*4] != 0 {
107+ t.Fatalf("%s: buffer %d not cleared below the image", path, i)
108+ }
109+ }
110+ return true
111+}
@@ -0,0 +1,111 @@
1+package drm
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "golang.org/x/sys/unix"
11+)
12+
13+// Real-device check of the dumb buffer layer: no modeset, no atomic commit.
14+// It skips when no card node can be opened or makes dumb buffers.
15+func TestDumbBuffersOnRealDevice(t *testing.T) {
16+ cards, _ := filepath.Glob("/dev/dri/card*")
17+ if len(cards) == 0 {
18+ t.Skip("no /dev/dri/card* node")
19+ }
20+ for _, path := range cards {
21+ f, err := os.OpenFile(path, os.O_RDWR|unix.O_CLOEXEC, 0)
22+ if err != nil {
23+ t.Logf("%s: %v", path, err)
24+ continue
25+ }
26+ done := func() bool {
27+ defer f.Close()
28+ return dumbDeviceRoundTrip(t, path, kmsDevice{fd: int(f.Fd())})
29+ }()
30+ if done {
31+ return
32+ }
33+ }
34+ t.Skip("no card node makes dumb buffers for this user")
35+}
36+
37+// dumbDeviceRoundTrip runs the buffer life cycle on k and reports false
38+// when the card cannot do it for a reason unrelated to the code under test
39+// (the permission or the driver).
40+func dumbDeviceRoundTrip(t *testing.T, path string, k kmsDevice) bool {
41+ size := cursorSize(k.fd)
42+ var imgs [2]dumbBuffer
43+ var mems [2][]byte
44+ var fbs [2]uint32
45+ cleanup := func() {
46+ for i := range imgs {
47+ if fbs[i] != 0 {
48+ if err := k.rmFB(fbs[i]); err != nil {
49+ t.Errorf("rmfb: %v", err)
50+ }
51+ }
52+ if mems[i] != nil {
53+ if err := k.unmapDumb(mems[i]); err != nil {
54+ t.Errorf("munmap: %v", err)
55+ }
56+ }
57+ if imgs[i].handle != 0 {
58+ if err := k.destroyDumb(imgs[i].handle); err != nil {
59+ t.Errorf("destroy dumb: %v", err)
60+ }
61+ }
62+ }
63+ }
64+ defer cleanup()
65+ for i := range imgs {
66+ b, err := k.createDumb(size, size)
67+ if err != nil {
68+ if errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM) || errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EINVAL) {
69+ t.Logf("%s: create dumb: %v", path, err)
70+ return false
71+ }
72+ t.Fatalf("%s: create dumb: %v", path, err)
73+ }
74+ imgs[i] = b
75+ if int(b.pitch) < size*4 || b.size < uint64(int(b.pitch)*size) {
76+ t.Fatalf("%s: dumb %dx%d pitch %d size %d", path, size, size, b.pitch, b.size)
77+ }
78+ if mems[i], err = k.mapDumb(b); err != nil {
79+ t.Fatalf("%s: map dumb: %v", path, err)
80+ }
81+ if fbs[i], err = k.addFBHandle(b, size, size, fourccARGB); err != nil {
82+ t.Fatalf("%s: add fb2 ARGB8888: %v", path, err)
83+ }
84+ t.Logf("%s: dumb buffer %d: %dx%d pitch %d size %d fb %d", path, i, size, size, b.pitch, b.size, fbs[i])
85+ }
86+ // Write a pattern through the shared mapping; each buffer keeps its own.
87+ for i, mem := range mems {
88+ d := &dumbCursor{k: k, size: size}
89+ d.img[i] = dumbImage{pitch: int(imgs[i].pitch), mem: mem}
90+ px := bytes.Repeat([]byte{byte(0x10 + i), 0x20, 0x30, 0xff}, 3*2)
91+ if err := d.write(i, px, 3, 2, 0); err != nil {
92+ t.Fatal(err)
93+ }
94+ }
95+ for i, mem := range mems {
96+ pitch := int(imgs[i].pitch)
97+ want := []byte{byte(0x10 + i), 0x20, 0x30, 0xff}
98+ for y := range 2 {
99+ if got := mem[y*pitch : y*pitch+4]; !bytes.Equal(got, want) {
100+ t.Fatalf("%s: buffer %d row %d read back %v, want %v", path, i, y, got, want)
101+ }
102+ if got := mem[y*pitch+3*4]; got != 0 {
103+ t.Fatalf("%s: buffer %d row %d not cleared after the image", path, i, y)
104+ }
105+ }
106+ if mem[2*pitch] != 0 || mem[(size-1)*pitch+(size-1)*4] != 0 {
107+ t.Fatalf("%s: buffer %d not cleared below the image", path, i)
108+ }
109+ }
110+ return true
111+}
M internal/adapters/drm/kms_mock_test.go +308 -0
@@ -113,6 +113,84 @@ func (_c *mockkms_addFB_Call) RunAndReturn(run func(b *ports.DMABuf, format uint
113113 return _c
114114 }
115115
116+// addFBHandle provides a mock function for the type mockkms
117+func (_mock *mockkms) addFBHandle(b dumbBuffer, width int, height int, format uint32) (uint32, error) {
118+ ret := _mock.Called(b, width, height, format)
119+
120+ if len(ret) == 0 {
121+ panic("no return value specified for addFBHandle")
122+ }
123+
124+ var r0 uint32
125+ var r1 error
126+ if returnFunc, ok := ret.Get(0).(func(dumbBuffer, int, int, uint32) (uint32, error)); ok {
127+ return returnFunc(b, width, height, format)
128+ }
129+ if returnFunc, ok := ret.Get(0).(func(dumbBuffer, int, int, uint32) uint32); ok {
130+ r0 = returnFunc(b, width, height, format)
131+ } else {
132+ r0 = ret.Get(0).(uint32)
133+ }
134+ if returnFunc, ok := ret.Get(1).(func(dumbBuffer, int, int, uint32) error); ok {
135+ r1 = returnFunc(b, width, height, format)
136+ } else {
137+ r1 = ret.Error(1)
138+ }
139+ return r0, r1
140+}
141+
142+// mockkms_addFBHandle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'addFBHandle'
143+type mockkms_addFBHandle_Call struct {
144+ *mock.Call
145+}
146+
147+// addFBHandle is a helper method to define mock.On call
148+// - b dumbBuffer
149+// - width int
150+// - height int
151+// - format uint32
152+func (_e *mockkms_Expecter) addFBHandle(b any, width any, height any, format any) *mockkms_addFBHandle_Call {
153+ return &mockkms_addFBHandle_Call{Call: _e.mock.On("addFBHandle", b, width, height, format)}
154+}
155+
156+func (_c *mockkms_addFBHandle_Call) Run(run func(b dumbBuffer, width int, height int, format uint32)) *mockkms_addFBHandle_Call {
157+ _c.Call.Run(func(args mock.Arguments) {
158+ var arg0 dumbBuffer
159+ if args[0] != nil {
160+ arg0 = args[0].(dumbBuffer)
161+ }
162+ var arg1 int
163+ if args[1] != nil {
164+ arg1 = args[1].(int)
165+ }
166+ var arg2 int
167+ if args[2] != nil {
168+ arg2 = args[2].(int)
169+ }
170+ var arg3 uint32
171+ if args[3] != nil {
172+ arg3 = args[3].(uint32)
173+ }
174+ run(
175+ arg0,
176+ arg1,
177+ arg2,
178+ arg3,
179+ )
180+ })
181+ return _c
182+}
183+
184+func (_c *mockkms_addFBHandle_Call) Return(v uint32, err error) *mockkms_addFBHandle_Call {
185+ _c.Call.Return(v, err)
186+ return _c
187+}
188+
189+func (_c *mockkms_addFBHandle_Call) RunAndReturn(run func(b dumbBuffer, width int, height int, format uint32) (uint32, error)) *mockkms_addFBHandle_Call {
190+ _c.Call.Return(run)
191+ return _c
192+}
193+
116194 // commit provides a mock function for the type mockkms
117195 func (_mock *mockkms) commit(req *atomicReq, flags uint32, userData uint64) error {
118196 ret := _mock.Called(req, flags, userData)
@@ -296,6 +374,72 @@ func (_c *mockkms_createBlob_Call) RunAndReturn(run func(data []byte) (uint32, e
296374 return _c
297375 }
298376
377+// createDumb provides a mock function for the type mockkms
378+func (_mock *mockkms) createDumb(width int, height int) (dumbBuffer, error) {
379+ ret := _mock.Called(width, height)
380+
381+ if len(ret) == 0 {
382+ panic("no return value specified for createDumb")
383+ }
384+
385+ var r0 dumbBuffer
386+ var r1 error
387+ if returnFunc, ok := ret.Get(0).(func(int, int) (dumbBuffer, error)); ok {
388+ return returnFunc(width, height)
389+ }
390+ if returnFunc, ok := ret.Get(0).(func(int, int) dumbBuffer); ok {
391+ r0 = returnFunc(width, height)
392+ } else {
393+ r0 = ret.Get(0).(dumbBuffer)
394+ }
395+ if returnFunc, ok := ret.Get(1).(func(int, int) error); ok {
396+ r1 = returnFunc(width, height)
397+ } else {
398+ r1 = ret.Error(1)
399+ }
400+ return r0, r1
401+}
402+
403+// mockkms_createDumb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'createDumb'
404+type mockkms_createDumb_Call struct {
405+ *mock.Call
406+}
407+
408+// createDumb is a helper method to define mock.On call
409+// - width int
410+// - height int
411+func (_e *mockkms_Expecter) createDumb(width any, height any) *mockkms_createDumb_Call {
412+ return &mockkms_createDumb_Call{Call: _e.mock.On("createDumb", width, height)}
413+}
414+
415+func (_c *mockkms_createDumb_Call) Run(run func(width int, height int)) *mockkms_createDumb_Call {
416+ _c.Call.Run(func(args mock.Arguments) {
417+ var arg0 int
418+ if args[0] != nil {
419+ arg0 = args[0].(int)
420+ }
421+ var arg1 int
422+ if args[1] != nil {
423+ arg1 = args[1].(int)
424+ }
425+ run(
426+ arg0,
427+ arg1,
428+ )
429+ })
430+ return _c
431+}
432+
433+func (_c *mockkms_createDumb_Call) Return(dumbBufferMoqParam dumbBuffer, err error) *mockkms_createDumb_Call {
434+ _c.Call.Return(dumbBufferMoqParam, err)
435+ return _c
436+}
437+
438+func (_c *mockkms_createDumb_Call) RunAndReturn(run func(width int, height int) (dumbBuffer, error)) *mockkms_createDumb_Call {
439+ _c.Call.Return(run)
440+ return _c
441+}
442+
299443 // createLease provides a mock function for the type mockkms
300444 func (_mock *mockkms) createLease(objects []uint32) (*os.File, uint32, error) {
301445 ret := _mock.Called(objects)
@@ -415,6 +559,57 @@ func (_c *mockkms_destroyBlob_Call) RunAndReturn(run func(id uint32) error) *moc
415559 return _c
416560 }
417561
562+// destroyDumb provides a mock function for the type mockkms
563+func (_mock *mockkms) destroyDumb(handle uint32) error {
564+ ret := _mock.Called(handle)
565+
566+ if len(ret) == 0 {
567+ panic("no return value specified for destroyDumb")
568+ }
569+
570+ var r0 error
571+ if returnFunc, ok := ret.Get(0).(func(uint32) error); ok {
572+ r0 = returnFunc(handle)
573+ } else {
574+ r0 = ret.Error(0)
575+ }
576+ return r0
577+}
578+
579+// mockkms_destroyDumb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'destroyDumb'
580+type mockkms_destroyDumb_Call struct {
581+ *mock.Call
582+}
583+
584+// destroyDumb is a helper method to define mock.On call
585+// - handle uint32
586+func (_e *mockkms_Expecter) destroyDumb(handle any) *mockkms_destroyDumb_Call {
587+ return &mockkms_destroyDumb_Call{Call: _e.mock.On("destroyDumb", handle)}
588+}
589+
590+func (_c *mockkms_destroyDumb_Call) Run(run func(handle uint32)) *mockkms_destroyDumb_Call {
591+ _c.Call.Run(func(args mock.Arguments) {
592+ var arg0 uint32
593+ if args[0] != nil {
594+ arg0 = args[0].(uint32)
595+ }
596+ run(
597+ arg0,
598+ )
599+ })
600+ return _c
601+}
602+
603+func (_c *mockkms_destroyDumb_Call) Return(err error) *mockkms_destroyDumb_Call {
604+ _c.Call.Return(err)
605+ return _c
606+}
607+
608+func (_c *mockkms_destroyDumb_Call) RunAndReturn(run func(handle uint32) error) *mockkms_destroyDumb_Call {
609+ _c.Call.Return(run)
610+ return _c
611+}
612+
418613 // getBlob provides a mock function for the type mockkms
419614 func (_mock *mockkms) getBlob(id uint32) ([]byte, error) {
420615 ret := _mock.Called(id)
@@ -477,6 +672,68 @@ func (_c *mockkms_getBlob_Call) RunAndReturn(run func(id uint32) ([]byte, error)
477672 return _c
478673 }
479674
675+// mapDumb provides a mock function for the type mockkms
676+func (_mock *mockkms) mapDumb(b dumbBuffer) ([]byte, error) {
677+ ret := _mock.Called(b)
678+
679+ if len(ret) == 0 {
680+ panic("no return value specified for mapDumb")
681+ }
682+
683+ var r0 []byte
684+ var r1 error
685+ if returnFunc, ok := ret.Get(0).(func(dumbBuffer) ([]byte, error)); ok {
686+ return returnFunc(b)
687+ }
688+ if returnFunc, ok := ret.Get(0).(func(dumbBuffer) []byte); ok {
689+ r0 = returnFunc(b)
690+ } else {
691+ if ret.Get(0) != nil {
692+ r0 = ret.Get(0).([]byte)
693+ }
694+ }
695+ if returnFunc, ok := ret.Get(1).(func(dumbBuffer) error); ok {
696+ r1 = returnFunc(b)
697+ } else {
698+ r1 = ret.Error(1)
699+ }
700+ return r0, r1
701+}
702+
703+// mockkms_mapDumb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'mapDumb'
704+type mockkms_mapDumb_Call struct {
705+ *mock.Call
706+}
707+
708+// mapDumb is a helper method to define mock.On call
709+// - b dumbBuffer
710+func (_e *mockkms_Expecter) mapDumb(b any) *mockkms_mapDumb_Call {
711+ return &mockkms_mapDumb_Call{Call: _e.mock.On("mapDumb", b)}
712+}
713+
714+func (_c *mockkms_mapDumb_Call) Run(run func(b dumbBuffer)) *mockkms_mapDumb_Call {
715+ _c.Call.Run(func(args mock.Arguments) {
716+ var arg0 dumbBuffer
717+ if args[0] != nil {
718+ arg0 = args[0].(dumbBuffer)
719+ }
720+ run(
721+ arg0,
722+ )
723+ })
724+ return _c
725+}
726+
727+func (_c *mockkms_mapDumb_Call) Return(bytes []byte, err error) *mockkms_mapDumb_Call {
728+ _c.Call.Return(bytes, err)
729+ return _c
730+}
731+
732+func (_c *mockkms_mapDumb_Call) RunAndReturn(run func(b dumbBuffer) ([]byte, error)) *mockkms_mapDumb_Call {
733+ _c.Call.Return(run)
734+ return _c
735+}
736+
480737 // objProps provides a mock function for the type mockkms
481738 func (_mock *mockkms) objProps(obj uint32, typ uint32) (map[string][2]uint64, error) {
482739 ret := _mock.Called(obj, typ)
@@ -898,3 +1155,54 @@ func (_c *mockkms_rmFB_Call) RunAndReturn(run func(id uint32) error) *mockkms_rm
8981155 _c.Call.Return(run)
8991156 return _c
9001157 }
1158+
1159+// unmapDumb provides a mock function for the type mockkms
1160+func (_mock *mockkms) unmapDumb(mem []byte) error {
1161+ ret := _mock.Called(mem)
1162+
1163+ if len(ret) == 0 {
1164+ panic("no return value specified for unmapDumb")
1165+ }
1166+
1167+ var r0 error
1168+ if returnFunc, ok := ret.Get(0).(func([]byte) error); ok {
1169+ r0 = returnFunc(mem)
1170+ } else {
1171+ r0 = ret.Error(0)
1172+ }
1173+ return r0
1174+}
1175+
1176+// mockkms_unmapDumb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'unmapDumb'
1177+type mockkms_unmapDumb_Call struct {
1178+ *mock.Call
1179+}
1180+
1181+// unmapDumb is a helper method to define mock.On call
1182+// - mem []byte
1183+func (_e *mockkms_Expecter) unmapDumb(mem any) *mockkms_unmapDumb_Call {
1184+ return &mockkms_unmapDumb_Call{Call: _e.mock.On("unmapDumb", mem)}
1185+}
1186+
1187+func (_c *mockkms_unmapDumb_Call) Run(run func(mem []byte)) *mockkms_unmapDumb_Call {
1188+ _c.Call.Run(func(args mock.Arguments) {
1189+ var arg0 []byte
1190+ if args[0] != nil {
1191+ arg0 = args[0].([]byte)
1192+ }
1193+ run(
1194+ arg0,
1195+ )
1196+ })
1197+ return _c
1198+}
1199+
1200+func (_c *mockkms_unmapDumb_Call) Return(err error) *mockkms_unmapDumb_Call {
1201+ _c.Call.Return(err)
1202+ return _c
1203+}
1204+
1205+func (_c *mockkms_unmapDumb_Call) RunAndReturn(run func(mem []byte) error) *mockkms_unmapDumb_Call {
1206+ _c.Call.Return(run)
1207+ return _c
1208+}
@@ -113,6 +113,84 @@ func (_c *mockkms_addFB_Call) RunAndReturn(run func(b *ports.DMABuf, format uint
113 return _c113 return _c
114 }114 }
115 115
116+// addFBHandle provides a mock function for the type mockkms
117+func (_mock *mockkms) addFBHandle(b dumbBuffer, width int, height int, format uint32) (uint32, error) {
118+ ret := _mock.Called(b, width, height, format)
119+
120+ if len(ret) == 0 {
121+ panic("no return value specified for addFBHandle")
122+ }
123+
124+ var r0 uint32
125+ var r1 error
126+ if returnFunc, ok := ret.Get(0).(func(dumbBuffer, int, int, uint32) (uint32, error)); ok {
127+ return returnFunc(b, width, height, format)
128+ }
129+ if returnFunc, ok := ret.Get(0).(func(dumbBuffer, int, int, uint32) uint32); ok {
130+ r0 = returnFunc(b, width, height, format)
131+ } else {
132+ r0 = ret.Get(0).(uint32)
133+ }
134+ if returnFunc, ok := ret.Get(1).(func(dumbBuffer, int, int, uint32) error); ok {
135+ r1 = returnFunc(b, width, height, format)
136+ } else {
137+ r1 = ret.Error(1)
138+ }
139+ return r0, r1
140+}
141+
142+// mockkms_addFBHandle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'addFBHandle'
143+type mockkms_addFBHandle_Call struct {
144+ *mock.Call
145+}
146+
147+// addFBHandle is a helper method to define mock.On call
148+// - b dumbBuffer
149+// - width int
150+// - height int
151+// - format uint32
152+func (_e *mockkms_Expecter) addFBHandle(b any, width any, height any, format any) *mockkms_addFBHandle_Call {
153+ return &mockkms_addFBHandle_Call{Call: _e.mock.On("addFBHandle", b, width, height, format)}
154+}
155+
156+func (_c *mockkms_addFBHandle_Call) Run(run func(b dumbBuffer, width int, height int, format uint32)) *mockkms_addFBHandle_Call {
157+ _c.Call.Run(func(args mock.Arguments) {
158+ var arg0 dumbBuffer
159+ if args[0] != nil {
160+ arg0 = args[0].(dumbBuffer)
161+ }
162+ var arg1 int
163+ if args[1] != nil {
164+ arg1 = args[1].(int)
165+ }
166+ var arg2 int
167+ if args[2] != nil {
168+ arg2 = args[2].(int)
169+ }
170+ var arg3 uint32
171+ if args[3] != nil {
172+ arg3 = args[3].(uint32)
173+ }
174+ run(
175+ arg0,
176+ arg1,
177+ arg2,
178+ arg3,
179+ )
180+ })
181+ return _c
182+}
183+
184+func (_c *mockkms_addFBHandle_Call) Return(v uint32, err error) *mockkms_addFBHandle_Call {
185+ _c.Call.Return(v, err)
186+ return _c
187+}
188+
189+func (_c *mockkms_addFBHandle_Call) RunAndReturn(run func(b dumbBuffer, width int, height int, format uint32) (uint32, error)) *mockkms_addFBHandle_Call {
190+ _c.Call.Return(run)
191+ return _c
192+}
193+
116 // commit provides a mock function for the type mockkms194 // commit provides a mock function for the type mockkms
117 func (_mock *mockkms) commit(req *atomicReq, flags uint32, userData uint64) error {195 func (_mock *mockkms) commit(req *atomicReq, flags uint32, userData uint64) error {
118 ret := _mock.Called(req, flags, userData)196 ret := _mock.Called(req, flags, userData)
@@ -296,6 +374,72 @@ func (_c *mockkms_createBlob_Call) RunAndReturn(run func(data []byte) (uint32, e
296 return _c374 return _c
297 }375 }
298 376
377+// createDumb provides a mock function for the type mockkms
378+func (_mock *mockkms) createDumb(width int, height int) (dumbBuffer, error) {
379+ ret := _mock.Called(width, height)
380+
381+ if len(ret) == 0 {
382+ panic("no return value specified for createDumb")
383+ }
384+
385+ var r0 dumbBuffer
386+ var r1 error
387+ if returnFunc, ok := ret.Get(0).(func(int, int) (dumbBuffer, error)); ok {
388+ return returnFunc(width, height)
389+ }
390+ if returnFunc, ok := ret.Get(0).(func(int, int) dumbBuffer); ok {
391+ r0 = returnFunc(width, height)
392+ } else {
393+ r0 = ret.Get(0).(dumbBuffer)
394+ }
395+ if returnFunc, ok := ret.Get(1).(func(int, int) error); ok {
396+ r1 = returnFunc(width, height)
397+ } else {
398+ r1 = ret.Error(1)
399+ }
400+ return r0, r1
401+}
402+
403+// mockkms_createDumb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'createDumb'
404+type mockkms_createDumb_Call struct {
405+ *mock.Call
406+}
407+
408+// createDumb is a helper method to define mock.On call
409+// - width int
410+// - height int
411+func (_e *mockkms_Expecter) createDumb(width any, height any) *mockkms_createDumb_Call {
412+ return &mockkms_createDumb_Call{Call: _e.mock.On("createDumb", width, height)}
413+}
414+
415+func (_c *mockkms_createDumb_Call) Run(run func(width int, height int)) *mockkms_createDumb_Call {
416+ _c.Call.Run(func(args mock.Arguments) {
417+ var arg0 int
418+ if args[0] != nil {
419+ arg0 = args[0].(int)
420+ }
421+ var arg1 int
422+ if args[1] != nil {
423+ arg1 = args[1].(int)
424+ }
425+ run(
426+ arg0,
427+ arg1,
428+ )
429+ })
430+ return _c
431+}
432+
433+func (_c *mockkms_createDumb_Call) Return(dumbBufferMoqParam dumbBuffer, err error) *mockkms_createDumb_Call {
434+ _c.Call.Return(dumbBufferMoqParam, err)
435+ return _c
436+}
437+
438+func (_c *mockkms_createDumb_Call) RunAndReturn(run func(width int, height int) (dumbBuffer, error)) *mockkms_createDumb_Call {
439+ _c.Call.Return(run)
440+ return _c
441+}
442+
299 // createLease provides a mock function for the type mockkms443 // createLease provides a mock function for the type mockkms
300 func (_mock *mockkms) createLease(objects []uint32) (*os.File, uint32, error) {444 func (_mock *mockkms) createLease(objects []uint32) (*os.File, uint32, error) {
301 ret := _mock.Called(objects)445 ret := _mock.Called(objects)
@@ -415,6 +559,57 @@ func (_c *mockkms_destroyBlob_Call) RunAndReturn(run func(id uint32) error) *moc
415 return _c559 return _c
416 }560 }
417 561
562+// destroyDumb provides a mock function for the type mockkms
563+func (_mock *mockkms) destroyDumb(handle uint32) error {
564+ ret := _mock.Called(handle)
565+
566+ if len(ret) == 0 {
567+ panic("no return value specified for destroyDumb")
568+ }
569+
570+ var r0 error
571+ if returnFunc, ok := ret.Get(0).(func(uint32) error); ok {
572+ r0 = returnFunc(handle)
573+ } else {
574+ r0 = ret.Error(0)
575+ }
576+ return r0
577+}
578+
579+// mockkms_destroyDumb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'destroyDumb'
580+type mockkms_destroyDumb_Call struct {
581+ *mock.Call
582+}
583+
584+// destroyDumb is a helper method to define mock.On call
585+// - handle uint32
586+func (_e *mockkms_Expecter) destroyDumb(handle any) *mockkms_destroyDumb_Call {
587+ return &mockkms_destroyDumb_Call{Call: _e.mock.On("destroyDumb", handle)}
588+}
589+
590+func (_c *mockkms_destroyDumb_Call) Run(run func(handle uint32)) *mockkms_destroyDumb_Call {
591+ _c.Call.Run(func(args mock.Arguments) {
592+ var arg0 uint32
593+ if args[0] != nil {
594+ arg0 = args[0].(uint32)
595+ }
596+ run(
597+ arg0,
598+ )
599+ })
600+ return _c
601+}
602+
603+func (_c *mockkms_destroyDumb_Call) Return(err error) *mockkms_destroyDumb_Call {
604+ _c.Call.Return(err)
605+ return _c
606+}
607+
608+func (_c *mockkms_destroyDumb_Call) RunAndReturn(run func(handle uint32) error) *mockkms_destroyDumb_Call {
609+ _c.Call.Return(run)
610+ return _c
611+}
612+
418 // getBlob provides a mock function for the type mockkms613 // getBlob provides a mock function for the type mockkms
419 func (_mock *mockkms) getBlob(id uint32) ([]byte, error) {614 func (_mock *mockkms) getBlob(id uint32) ([]byte, error) {
420 ret := _mock.Called(id)615 ret := _mock.Called(id)
@@ -477,6 +672,68 @@ func (_c *mockkms_getBlob_Call) RunAndReturn(run func(id uint32) ([]byte, error)
477 return _c672 return _c
478 }673 }
479 674
675+// mapDumb provides a mock function for the type mockkms
676+func (_mock *mockkms) mapDumb(b dumbBuffer) ([]byte, error) {
677+ ret := _mock.Called(b)
678+
679+ if len(ret) == 0 {
680+ panic("no return value specified for mapDumb")
681+ }
682+
683+ var r0 []byte
684+ var r1 error
685+ if returnFunc, ok := ret.Get(0).(func(dumbBuffer) ([]byte, error)); ok {
686+ return returnFunc(b)
687+ }
688+ if returnFunc, ok := ret.Get(0).(func(dumbBuffer) []byte); ok {
689+ r0 = returnFunc(b)
690+ } else {
691+ if ret.Get(0) != nil {
692+ r0 = ret.Get(0).([]byte)
693+ }
694+ }
695+ if returnFunc, ok := ret.Get(1).(func(dumbBuffer) error); ok {
696+ r1 = returnFunc(b)
697+ } else {
698+ r1 = ret.Error(1)
699+ }
700+ return r0, r1
701+}
702+
703+// mockkms_mapDumb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'mapDumb'
704+type mockkms_mapDumb_Call struct {
705+ *mock.Call
706+}
707+
708+// mapDumb is a helper method to define mock.On call
709+// - b dumbBuffer
710+func (_e *mockkms_Expecter) mapDumb(b any) *mockkms_mapDumb_Call {
711+ return &mockkms_mapDumb_Call{Call: _e.mock.On("mapDumb", b)}
712+}
713+
714+func (_c *mockkms_mapDumb_Call) Run(run func(b dumbBuffer)) *mockkms_mapDumb_Call {
715+ _c.Call.Run(func(args mock.Arguments) {
716+ var arg0 dumbBuffer
717+ if args[0] != nil {
718+ arg0 = args[0].(dumbBuffer)
719+ }
720+ run(
721+ arg0,
722+ )
723+ })
724+ return _c
725+}
726+
727+func (_c *mockkms_mapDumb_Call) Return(bytes []byte, err error) *mockkms_mapDumb_Call {
728+ _c.Call.Return(bytes, err)
729+ return _c
730+}
731+
732+func (_c *mockkms_mapDumb_Call) RunAndReturn(run func(b dumbBuffer) ([]byte, error)) *mockkms_mapDumb_Call {
733+ _c.Call.Return(run)
734+ return _c
735+}
736+
480 // objProps provides a mock function for the type mockkms737 // objProps provides a mock function for the type mockkms
481 func (_mock *mockkms) objProps(obj uint32, typ uint32) (map[string][2]uint64, error) {738 func (_mock *mockkms) objProps(obj uint32, typ uint32) (map[string][2]uint64, error) {
482 ret := _mock.Called(obj, typ)739 ret := _mock.Called(obj, typ)
@@ -898,3 +1155,54 @@ func (_c *mockkms_rmFB_Call) RunAndReturn(run func(id uint32) error) *mockkms_rm
898 _c.Call.Return(run)1155 _c.Call.Return(run)
899 return _c1156 return _c
900 }1157 }
1158+
1159+// unmapDumb provides a mock function for the type mockkms
1160+func (_mock *mockkms) unmapDumb(mem []byte) error {
1161+ ret := _mock.Called(mem)
1162+
1163+ if len(ret) == 0 {
1164+ panic("no return value specified for unmapDumb")
1165+ }
1166+
1167+ var r0 error
1168+ if returnFunc, ok := ret.Get(0).(func([]byte) error); ok {
1169+ r0 = returnFunc(mem)
1170+ } else {
1171+ r0 = ret.Error(0)
1172+ }
1173+ return r0
1174+}
1175+
1176+// mockkms_unmapDumb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'unmapDumb'
1177+type mockkms_unmapDumb_Call struct {
1178+ *mock.Call
1179+}
1180+
1181+// unmapDumb is a helper method to define mock.On call
1182+// - mem []byte
1183+func (_e *mockkms_Expecter) unmapDumb(mem any) *mockkms_unmapDumb_Call {
1184+ return &mockkms_unmapDumb_Call{Call: _e.mock.On("unmapDumb", mem)}
1185+}
1186+
1187+func (_c *mockkms_unmapDumb_Call) Run(run func(mem []byte)) *mockkms_unmapDumb_Call {
1188+ _c.Call.Run(func(args mock.Arguments) {
1189+ var arg0 []byte
1190+ if args[0] != nil {
1191+ arg0 = args[0].([]byte)
1192+ }
1193+ run(
1194+ arg0,
1195+ )
1196+ })
1197+ return _c
1198+}
1199+
1200+func (_c *mockkms_unmapDumb_Call) Return(err error) *mockkms_unmapDumb_Call {
1201+ _c.Call.Return(err)
1202+ return _c
1203+}
1204+
1205+func (_c *mockkms_unmapDumb_Call) RunAndReturn(run func(mem []byte) error) *mockkms_unmapDumb_Call {
1206+ _c.Call.Return(run)
1207+ return _c
1208+}
M internal/adapters/drm/kms_test.go +3 -0
@@ -32,6 +32,9 @@ func TestStructSizesMatchIoctls(t *testing.T) {
3232 {"atomic", unsafe.Sizeof(modeAtomic{}), ioctlAtomic},
3333 {"create_blob", unsafe.Sizeof(createBlob{}), ioctlCreateBlob},
3434 {"get_cap", unsafe.Sizeof(getCap{}), ioctlGetCap},
35+ {"create_dumb", unsafe.Sizeof(createDumb{}), ioctlCreateDumb},
36+ {"map_dumb", unsafe.Sizeof(mapDumb{}), ioctlMapDumb},
37+ {"destroy_dumb", unsafe.Sizeof(uint32(0)), ioctlDestroyDumb},
3538 {"destroy_blob", unsafe.Sizeof(uint32(0)), ioctlDestroyBlob},
3639 } {
3740 if want := (tc.req >> 16) & 0x3fff; tc.got != want {
@@ -32,6 +32,9 @@ func TestStructSizesMatchIoctls(t *testing.T) {
32 {"atomic", unsafe.Sizeof(modeAtomic{}), ioctlAtomic},32 {"atomic", unsafe.Sizeof(modeAtomic{}), ioctlAtomic},
33 {"create_blob", unsafe.Sizeof(createBlob{}), ioctlCreateBlob},33 {"create_blob", unsafe.Sizeof(createBlob{}), ioctlCreateBlob},
34 {"get_cap", unsafe.Sizeof(getCap{}), ioctlGetCap},34 {"get_cap", unsafe.Sizeof(getCap{}), ioctlGetCap},
35+ {"create_dumb", unsafe.Sizeof(createDumb{}), ioctlCreateDumb},
36+ {"map_dumb", unsafe.Sizeof(mapDumb{}), ioctlMapDumb},
37+ {"destroy_dumb", unsafe.Sizeof(uint32(0)), ioctlDestroyDumb},
35 {"destroy_blob", unsafe.Sizeof(uint32(0)), ioctlDestroyBlob},38 {"destroy_blob", unsafe.Sizeof(uint32(0)), ioctlDestroyBlob},
36 } {39 } {
37 if want := (tc.req >> 16) & 0x3fff; tc.got != want {40 if want := (tc.req >> 16) & 0x3fff; tc.got != want {
M internal/adapters/drm/output.go +51 -20
@@ -579,18 +579,44 @@ func (o *Output) modeBlobFor(mode modeInfo) (uint32, error) {
579579 return blob, nil
580580 }
581581
582-// testCursor turns the hardware cursor off when KMS refuses its images on
583-// the cursor plane (e.g. a pitch the plane cannot scan out).
584-func (o *Output) testCursor() {
582+// testCursor asks KMS whether the cursor plane takes the cursor images.
583+// Refused dumb images are replaced by the renderer's; when those are refused
584+// too, or there are none, the hardware cursor goes off. It reports whether
585+// the cursor changed (new images or off).
586+func (o *Output) testCursor() (changed bool) {
585587 c := o.cursor
586588 if c == nil || c.off || c.fbs[0] == 0 {
587- return
589+ return false
588590 }
589- req := &atomicReq{}
590- c.props(req, o.crtc, cursorState{on: true, fb: c.fbs[0]})
591- if err := o.k.commit(req, atomicTestOnly, 0); refused(err) {
592- o.cursorOff(err)
591+ for {
592+ req := &atomicReq{}
593+ c.props(req, o.crtc, cursorState{on: true, fb: c.fbs[0]})
594+ err := o.k.commit(req, atomicTestOnly, 0)
595+ if !refused(err) {
596+ return changed
597+ }
598+ // Dumb images refused: the renderer's get one try.
599+ if c.alloc != allocDumb {
600+ o.cursorOff(err)
601+ return true
602+ }
603+ ok, ferr := c.useFallback(o.cursorNits())
604+ if !ok || ferr != nil {
605+ o.cursorOff(errors.Join(err, ferr))
606+ return true
607+ }
608+ changed = true
609+ o.log.Warn().Err(err).Str("connector", o.conn.name).Str("alloc", allocVulkan).Int("size", c.size).Msg("cursor plane refused dumb images; hardware cursor uses renderer images")
610+ }
611+}
612+
613+// cursorNits is the SDR white the cursor images are converted for on an HDR
614+// output (0: SDR).
615+func (o *Output) cursorNits() float64 {
616+ if !o.hdr.on {
617+ return 0
593618 }
619+ return float64(o.hdr.settings.SDRBrightness)
594620 }
595621
596622 // cursorOff stops using the cursor plane for the rest of the output's life.
@@ -797,11 +823,10 @@ func (o *Output) frameTest(fb uint32, fence *os.File, vrr bool, cur cursorState,
797823 }
798824
799825 // cursorRefused reports, after a commit carrying the cursor failed with
800-// EINVAL, whether the cursor plane was the cause; then the cursor is off
801-// and the commit can be retried without it.
826+// EINVAL, whether the cursor plane was the cause; then the cursor is off or
827+// on other images, and the commit can be retried.
802828 func (o *Output) cursorRefused() bool {
803- o.testCursor()
804- return o.cursor.off
829+ return o.testCursor()
805830 }
806831
807832 // commitState commits a cursor or VRR change without a new frame.
@@ -1042,7 +1067,7 @@ func (o *Output) Close() {
10421067 o.ctmBlob = 0
10431068 }
10441069 if o.cursor != nil {
1045- o.cursor.free(o.k)
1070+ o.cursor.free()
10461071 }
10471072 o.freeImages()
10481073 if o.alloc != nil {
@@ -1086,9 +1111,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10861111 }
10871112 defer r.Close()
10881113 if o.cursor != nil {
1089- if err := o.cursor.setup(o.k, r); err != nil {
1114+ dumbErr, err := o.cursor.setup(o.k, r)
1115+ switch {
1116+ case err != nil:
10901117 o.cursor.off = true
1091- o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("no hardware cursor images; hardware cursor off")
1118+ o.log.Warn().Err(errors.Join(dumbErr, err)).Str("connector", o.conn.name).Msg("no hardware cursor images; hardware cursor off")
1119+ case dumbErr != nil:
1120+ o.log.Warn().Err(dumbErr).Str("connector", o.conn.name).Str("alloc", o.cursor.alloc).Int("size", o.cursor.size).Msg("hardware cursor")
1121+ default:
1122+ o.log.Info().Str("connector", o.conn.name).Str("alloc", o.cursor.alloc).Int("size", o.cursor.size).Msg("hardware cursor")
10921123 }
10931124 }
10941125 // The seat sends its state first; while switched away (e.g. a monitor
@@ -1463,7 +1494,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
14631494 stateDirty = true
14641495 if o.cursor != nil && !o.protected {
14651496 // An image that waited for a free slot loads now.
1466- if _, err := o.cursor.flushLater(r); err != nil {
1497+ if _, err := o.cursor.flushLater(o.cursorNits()); err != nil {
14671498 o.log.Warn().Err(err).Msg("cursor")
14681499 }
14691500 }
@@ -1484,7 +1515,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
14841515 }
14851516 if !o.protected && o.cursor != nil && loadCursor != nil && (s.Scale != cursorScale || s.Transform != cursorTransform) {
14861517 cursorScale, cursorTransform = s.Scale, s.Transform
1487- o.setCursor(r, loadCursor, want, s.Scale, s.Transform)
1518+ o.setCursor(loadCursor, want, s.Scale, s.Transform)
14881519 stateDirty = true
14891520 }
14901521 scene, haveScene, dirty = s, true, true
@@ -1499,7 +1530,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
14991530 want = c
15001531 // Before the first scene the scale is unknown: loaded then.
15011532 if o.cursor != nil && loadCursor != nil && cursorScale > 0 {
1502- o.setCursor(r, loadCursor, want, cursorScale, cursorTransform)
1533+ o.setCursor(loadCursor, want, cursorScale, cursorTransform)
15031534 stateDirty = true
15041535 }
15051536 case b := <-contents:
@@ -1736,14 +1767,14 @@ func (o *Output) wakeCore() { corewake.Send(o.Frames, o.conn.name) }
17361767
17371768 // setCursor loads a cursor for scale and output transform t into the cursor
17381769 // images.
1739-func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) {
1770+func (o *Output) setCursor(load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) {
17401771 o.observeSecurity()
17411772 if o.protected || o.cursor.off {
17421773 return
17431774 }
17441775 img, err := load(c, scale, t, o.cursor.Limit())
17451776 if err == nil {
1746- err = o.cursor.setImage(r, img.Pixels, img.W, img.H, img.HotX, img.HotY)
1777+ err = o.cursor.setImage(img.Pixels, img.W, img.H, img.HotX, img.HotY, o.cursorNits())
17471778 }
17481779 if err != nil {
17491780 o.log.Warn().Err(err).Float64("scale", scale).Int("transform", int(t)).Msg("cursor")
@@ -579,18 +579,44 @@ func (o *Output) modeBlobFor(mode modeInfo) (uint32, error) {
579 return blob, nil579 return blob, nil
580 }580 }
581 581
582-// testCursor turns the hardware cursor off when KMS refuses its images on582+// testCursor asks KMS whether the cursor plane takes the cursor images.
583-// the cursor plane (e.g. a pitch the plane cannot scan out).583+// Refused dumb images are replaced by the renderer's; when those are refused
584-func (o *Output) testCursor() {584+// too, or there are none, the hardware cursor goes off. It reports whether
585+// the cursor changed (new images or off).
586+func (o *Output) testCursor() (changed bool) {
585 c := o.cursor587 c := o.cursor
586 if c == nil || c.off || c.fbs[0] == 0 {588 if c == nil || c.off || c.fbs[0] == 0 {
587- return589+ return false
588 }590 }
589- req := &atomicReq{}591+ for {
590- c.props(req, o.crtc, cursorState{on: true, fb: c.fbs[0]})592+ req := &atomicReq{}
591- if err := o.k.commit(req, atomicTestOnly, 0); refused(err) {593+ c.props(req, o.crtc, cursorState{on: true, fb: c.fbs[0]})
592- o.cursorOff(err)594+ err := o.k.commit(req, atomicTestOnly, 0)
595+ if !refused(err) {
596+ return changed
597+ }
598+ // Dumb images refused: the renderer's get one try.
599+ if c.alloc != allocDumb {
600+ o.cursorOff(err)
601+ return true
602+ }
603+ ok, ferr := c.useFallback(o.cursorNits())
604+ if !ok || ferr != nil {
605+ o.cursorOff(errors.Join(err, ferr))
606+ return true
607+ }
608+ changed = true
609+ o.log.Warn().Err(err).Str("connector", o.conn.name).Str("alloc", allocVulkan).Int("size", c.size).Msg("cursor plane refused dumb images; hardware cursor uses renderer images")
610+ }
611+}
612+
613+// cursorNits is the SDR white the cursor images are converted for on an HDR
614+// output (0: SDR).
615+func (o *Output) cursorNits() float64 {
616+ if !o.hdr.on {
617+ return 0
593 }618 }
619+ return float64(o.hdr.settings.SDRBrightness)
594 }620 }
595 621
596 // cursorOff stops using the cursor plane for the rest of the output's life.622 // cursorOff stops using the cursor plane for the rest of the output's life.
@@ -797,11 +823,10 @@ func (o *Output) frameTest(fb uint32, fence *os.File, vrr bool, cur cursorState,
797 }823 }
798 824
799 // cursorRefused reports, after a commit carrying the cursor failed with825 // cursorRefused reports, after a commit carrying the cursor failed with
800-// EINVAL, whether the cursor plane was the cause; then the cursor is off826+// EINVAL, whether the cursor plane was the cause; then the cursor is off or
801-// and the commit can be retried without it.827+// on other images, and the commit can be retried.
802 func (o *Output) cursorRefused() bool {828 func (o *Output) cursorRefused() bool {
803- o.testCursor()829+ return o.testCursor()
804- return o.cursor.off
805 }830 }
806 831
807 // commitState commits a cursor or VRR change without a new frame.832 // commitState commits a cursor or VRR change without a new frame.
@@ -1042,7 +1067,7 @@ func (o *Output) Close() {
1042 o.ctmBlob = 01067 o.ctmBlob = 0
1043 }1068 }
1044 if o.cursor != nil {1069 if o.cursor != nil {
1045- o.cursor.free(o.k)1070+ o.cursor.free()
1046 }1071 }
1047 o.freeImages()1072 o.freeImages()
1048 if o.alloc != nil {1073 if o.alloc != nil {
@@ -1086,9 +1111,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1086 }1111 }
1087 defer r.Close()1112 defer r.Close()
1088 if o.cursor != nil {1113 if o.cursor != nil {
1089- if err := o.cursor.setup(o.k, r); err != nil {1114+ dumbErr, err := o.cursor.setup(o.k, r)
1115+ switch {
1116+ case err != nil:
1090 o.cursor.off = true1117 o.cursor.off = true
1091- o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("no hardware cursor images; hardware cursor off")1118+ o.log.Warn().Err(errors.Join(dumbErr, err)).Str("connector", o.conn.name).Msg("no hardware cursor images; hardware cursor off")
1119+ case dumbErr != nil:
1120+ o.log.Warn().Err(dumbErr).Str("connector", o.conn.name).Str("alloc", o.cursor.alloc).Int("size", o.cursor.size).Msg("hardware cursor")
1121+ default:
1122+ o.log.Info().Str("connector", o.conn.name).Str("alloc", o.cursor.alloc).Int("size", o.cursor.size).Msg("hardware cursor")
1092 }1123 }
1093 }1124 }
1094 // The seat sends its state first; while switched away (e.g. a monitor1125 // The seat sends its state first; while switched away (e.g. a monitor
@@ -1463,7 +1494,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1463 stateDirty = true1494 stateDirty = true
1464 if o.cursor != nil && !o.protected {1495 if o.cursor != nil && !o.protected {
1465 // An image that waited for a free slot loads now.1496 // An image that waited for a free slot loads now.
1466- if _, err := o.cursor.flushLater(r); err != nil {1497+ if _, err := o.cursor.flushLater(o.cursorNits()); err != nil {
1467 o.log.Warn().Err(err).Msg("cursor")1498 o.log.Warn().Err(err).Msg("cursor")
1468 }1499 }
1469 }1500 }
@@ -1484,7 +1515,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1484 }1515 }
1485 if !o.protected && o.cursor != nil && loadCursor != nil && (s.Scale != cursorScale || s.Transform != cursorTransform) {1516 if !o.protected && o.cursor != nil && loadCursor != nil && (s.Scale != cursorScale || s.Transform != cursorTransform) {
1486 cursorScale, cursorTransform = s.Scale, s.Transform1517 cursorScale, cursorTransform = s.Scale, s.Transform
1487- o.setCursor(r, loadCursor, want, s.Scale, s.Transform)1518+ o.setCursor(loadCursor, want, s.Scale, s.Transform)
1488 stateDirty = true1519 stateDirty = true
1489 }1520 }
1490 scene, haveScene, dirty = s, true, true1521 scene, haveScene, dirty = s, true, true
@@ -1499,7 +1530,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1499 want = c1530 want = c
1500 // Before the first scene the scale is unknown: loaded then.1531 // Before the first scene the scale is unknown: loaded then.
1501 if o.cursor != nil && loadCursor != nil && cursorScale > 0 {1532 if o.cursor != nil && loadCursor != nil && cursorScale > 0 {
1502- o.setCursor(r, loadCursor, want, cursorScale, cursorTransform)1533+ o.setCursor(loadCursor, want, cursorScale, cursorTransform)
1503 stateDirty = true1534 stateDirty = true
1504 }1535 }
1505 case b := <-contents:1536 case b := <-contents:
@@ -1736,14 +1767,14 @@ func (o *Output) wakeCore() { corewake.Send(o.Frames, o.conn.name) }
1736 1767
1737 // setCursor loads a cursor for scale and output transform t into the cursor1768 // setCursor loads a cursor for scale and output transform t into the cursor
1738 // images.1769 // images.
1739-func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) {1770+func (o *Output) setCursor(load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) {
1740 o.observeSecurity()1771 o.observeSecurity()
1741 if o.protected || o.cursor.off {1772 if o.protected || o.cursor.off {
1742 return1773 return
1743 }1774 }
1744 img, err := load(c, scale, t, o.cursor.Limit())1775 img, err := load(c, scale, t, o.cursor.Limit())
1745 if err == nil {1776 if err == nil {
1746- err = o.cursor.setImage(r, img.Pixels, img.W, img.H, img.HotX, img.HotY)1777+ err = o.cursor.setImage(img.Pixels, img.W, img.H, img.HotX, img.HotY, o.cursorNits())
1747 }1778 }
1748 if err != nil {1779 if err != nil {
1749 o.log.Warn().Err(err).Float64("scale", scale).Int("transform", int(t)).Msg("cursor")1780 o.log.Warn().Err(err).Float64("scale", scale).Int("transform", int(t)).Msg("cursor")
A internal/adapters/hdrcursor/convert_test.go +29 -0
@@ -0,0 +1,29 @@
1+package hdrcursor
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+func TestCacheKeyGeometry(t *testing.T) {
9+ px := []byte{10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255, 100, 110, 120, 255}
10+ for _, tc := range []struct {
11+ name string
12+ w, h, side int
13+ }{{"dimensions", 1, 4, 4}, {"max-side", 2, 2, 1}} {
14+ t.Run(tc.name, func(t *testing.T) {
15+ var c Cache
16+ c.Convert(px, 2, 2, 4, 203)
17+ before := c.Key()
18+ got := bytes.Clone(c.Convert(px, tc.w, tc.h, tc.side, 203))
19+ if c.Key() == before {
20+ t.Fatal("geometry missing from cache key")
21+ }
22+ var fresh Cache
23+ want := fresh.Convert(px, tc.w, tc.h, tc.side, 203)
24+ if !bytes.Equal(got, want) {
25+ t.Fatalf("stale conversion: %v want %v", got, want)
26+ }
27+ })
28+ }
29+}
@@ -0,0 +1,29 @@
1+package hdrcursor
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+func TestCacheKeyGeometry(t *testing.T) {
9+ px := []byte{10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255, 100, 110, 120, 255}
10+ for _, tc := range []struct {
11+ name string
12+ w, h, side int
13+ }{{"dimensions", 1, 4, 4}, {"max-side", 2, 2, 1}} {
14+ t.Run(tc.name, func(t *testing.T) {
15+ var c Cache
16+ c.Convert(px, 2, 2, 4, 203)
17+ before := c.Key()
18+ got := bytes.Clone(c.Convert(px, tc.w, tc.h, tc.side, 203))
19+ if c.Key() == before {
20+ t.Fatal("geometry missing from cache key")
21+ }
22+ var fresh Cache
23+ want := fresh.Convert(px, tc.w, tc.h, tc.side, 203)
24+ if !bytes.Equal(got, want) {
25+ t.Fatalf("stale conversion: %v want %v", got, want)
26+ }
27+ })
28+ }
29+}
A internal/adapters/hdrcursor/hdrcursor.go +100 -0
@@ -0,0 +1,100 @@
1+// Package hdrcursor converts cursor images to the HDR10 output encoding
2+// (BT.2020 PQ) for cursor planes, whichever adapter owns the image memory.
3+package hdrcursor
4+
5+import (
6+ "crypto/sha256"
7+ "encoding/binary"
8+ "math"
9+)
10+
11+var srgbTable = func() (table [256]float64) {
12+ for i := range table {
13+ table[i] = SRGBToLinear(float64(i) / 255)
14+ }
15+ return
16+}()
17+
18+// SRGBToLinear decodes a unit-range sRGB component.
19+func SRGBToLinear(v float64) float64 {
20+ if v <= 0.04045 {
21+ return v / 12.92
22+ }
23+ return math.Pow((v+0.055)/1.055, 2.4)
24+}
25+
26+// PQEncode encodes absolute luminance in nits using SMPTE ST 2084.
27+func PQEncode(nits float64) float64 {
28+ if nits <= 0 {
29+ return 0
30+ }
31+ x := math.Pow(min(nits, 10000)/10000, 2610.0/16384)
32+ return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32)
33+}
34+
35+// Pixel converts premultiplied BGRA bytes in place, preserving alpha. The
36+// source is quantized to its 8-bit unassociated sRGB value for the LUT.
37+func Pixel(p []byte, nits float64) {
38+ a := float64(p[3]) / 255
39+ if a == 0 {
40+ clear(p[:3])
41+ return
42+ }
43+ index := func(v byte) int { return min(255, int(math.Round(float64(v)/a))) }
44+ r, g, b := srgbTable[index(p[2])], srgbTable[index(p[1])], srgbTable[index(p[0])]
45+ r, g, b = PQEncode((0.627404*r+0.329283*g+0.043313*b)*nits), PQEncode((0.069097*r+0.919540*g+0.011362*b)*nits), PQEncode((0.016391*r+0.088013*g+0.895595*b)*nits)
46+ p[0], p[1], p[2] = byte(math.Round(b*a*255)), byte(math.Round(g*a*255)), byte(math.Round(r*a*255))
47+}
48+
49+// Cache converts cursor images and keeps only the last result, bounded by
50+// the cursor plane's maximum side regardless of cursor theme size. The
51+// zero value is ready; it is not safe for concurrent use.
52+type Cache struct {
53+ key [32]byte
54+ pixels []byte
55+}
56+
57+// Len is the size of the cached conversion in bytes (0: empty).
58+func (c *Cache) Len() int { return len(c.pixels) }
59+
60+// Key identifies the cached conversion (the zero key: empty).
61+func (c *Cache) Key() [32]byte { return c.key }
62+
63+// Reset drops the cached conversion.
64+func (c *Cache) Reset() { *c = Cache{} }
65+
66+func key(pixels []byte, w, h, side int, nits float64) [32]byte {
67+ var header [32]byte
68+ binary.LittleEndian.PutUint64(header[:8], uint64(w))
69+ binary.LittleEndian.PutUint64(header[8:16], uint64(h))
70+ binary.LittleEndian.PutUint64(header[16:24], math.Float64bits(nits))
71+ binary.LittleEndian.PutUint64(header[24:], uint64(side))
72+ digest := sha256.New()
73+ _, _ = digest.Write(header[:])
74+ _, _ = digest.Write(pixels)
75+ var k [32]byte
76+ digest.Sum(k[:0])
77+ return k
78+}
79+
80+// Convert returns the w×h premultiplied BGRA image (w*4 per row) cropped
81+// to side×side and converted for an SDR white of nits, packed without row
82+// padding: min(w, side) pixels per row, min(h, side) rows. The result is
83+// owned by the cache and valid until the next call.
84+func (c *Cache) Convert(pixels []byte, w, h, side int, nits float64) []byte {
85+ pixels = pixels[:w*h*4]
86+ k := key(pixels, w, h, side, nits)
87+ if c.pixels != nil && c.key == k {
88+ return c.pixels
89+ }
90+ cw, ch := min(w, side)*4, min(h, side)
91+ out := make([]byte, cw*ch)
92+ for y := range ch {
93+ copy(out[y*cw:(y+1)*cw], pixels[y*w*4:y*w*4+cw])
94+ for x := 0; x < cw; x += 4 {
95+ Pixel(out[y*cw+x:y*cw+x+4], nits)
96+ }
97+ }
98+ c.key, c.pixels = k, out
99+ return out
100+}
@@ -0,0 +1,100 @@
1+// Package hdrcursor converts cursor images to the HDR10 output encoding
2+// (BT.2020 PQ) for cursor planes, whichever adapter owns the image memory.
3+package hdrcursor
4+
5+import (
6+ "crypto/sha256"
7+ "encoding/binary"
8+ "math"
9+)
10+
11+var srgbTable = func() (table [256]float64) {
12+ for i := range table {
13+ table[i] = SRGBToLinear(float64(i) / 255)
14+ }
15+ return
16+}()
17+
18+// SRGBToLinear decodes a unit-range sRGB component.
19+func SRGBToLinear(v float64) float64 {
20+ if v <= 0.04045 {
21+ return v / 12.92
22+ }
23+ return math.Pow((v+0.055)/1.055, 2.4)
24+}
25+
26+// PQEncode encodes absolute luminance in nits using SMPTE ST 2084.
27+func PQEncode(nits float64) float64 {
28+ if nits <= 0 {
29+ return 0
30+ }
31+ x := math.Pow(min(nits, 10000)/10000, 2610.0/16384)
32+ return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32)
33+}
34+
35+// Pixel converts premultiplied BGRA bytes in place, preserving alpha. The
36+// source is quantized to its 8-bit unassociated sRGB value for the LUT.
37+func Pixel(p []byte, nits float64) {
38+ a := float64(p[3]) / 255
39+ if a == 0 {
40+ clear(p[:3])
41+ return
42+ }
43+ index := func(v byte) int { return min(255, int(math.Round(float64(v)/a))) }
44+ r, g, b := srgbTable[index(p[2])], srgbTable[index(p[1])], srgbTable[index(p[0])]
45+ r, g, b = PQEncode((0.627404*r+0.329283*g+0.043313*b)*nits), PQEncode((0.069097*r+0.919540*g+0.011362*b)*nits), PQEncode((0.016391*r+0.088013*g+0.895595*b)*nits)
46+ p[0], p[1], p[2] = byte(math.Round(b*a*255)), byte(math.Round(g*a*255)), byte(math.Round(r*a*255))
47+}
48+
49+// Cache converts cursor images and keeps only the last result, bounded by
50+// the cursor plane's maximum side regardless of cursor theme size. The
51+// zero value is ready; it is not safe for concurrent use.
52+type Cache struct {
53+ key [32]byte
54+ pixels []byte
55+}
56+
57+// Len is the size of the cached conversion in bytes (0: empty).
58+func (c *Cache) Len() int { return len(c.pixels) }
59+
60+// Key identifies the cached conversion (the zero key: empty).
61+func (c *Cache) Key() [32]byte { return c.key }
62+
63+// Reset drops the cached conversion.
64+func (c *Cache) Reset() { *c = Cache{} }
65+
66+func key(pixels []byte, w, h, side int, nits float64) [32]byte {
67+ var header [32]byte
68+ binary.LittleEndian.PutUint64(header[:8], uint64(w))
69+ binary.LittleEndian.PutUint64(header[8:16], uint64(h))
70+ binary.LittleEndian.PutUint64(header[16:24], math.Float64bits(nits))
71+ binary.LittleEndian.PutUint64(header[24:], uint64(side))
72+ digest := sha256.New()
73+ _, _ = digest.Write(header[:])
74+ _, _ = digest.Write(pixels)
75+ var k [32]byte
76+ digest.Sum(k[:0])
77+ return k
78+}
79+
80+// Convert returns the w×h premultiplied BGRA image (w*4 per row) cropped
81+// to side×side and converted for an SDR white of nits, packed without row
82+// padding: min(w, side) pixels per row, min(h, side) rows. The result is
83+// owned by the cache and valid until the next call.
84+func (c *Cache) Convert(pixels []byte, w, h, side int, nits float64) []byte {
85+ pixels = pixels[:w*h*4]
86+ k := key(pixels, w, h, side, nits)
87+ if c.pixels != nil && c.key == k {
88+ return c.pixels
89+ }
90+ cw, ch := min(w, side)*4, min(h, side)
91+ out := make([]byte, cw*ch)
92+ for y := range ch {
93+ copy(out[y*cw:(y+1)*cw], pixels[y*w*4:y*w*4+cw])
94+ for x := 0; x < cw; x += 4 {
95+ Pixel(out[y*cw+x:y*cw+x+4], nits)
96+ }
97+ }
98+ c.key, c.pixels = k, out
99+ return out
100+}
A internal/adapters/hdrcursor/hdrcursor_test.go +76 -0
@@ -0,0 +1,76 @@
1+package hdrcursor
2+
3+import (
4+ "bytes"
5+ "math"
6+ "testing"
7+)
8+
9+// vector is a 5×3 premultiplied BGRA image: alpha ramps up with one fully
10+// transparent pixel (index 3).
11+func vector() (pixels []byte, w, h int) {
12+ w, h = 5, 3
13+ pixels = make([]byte, w*h*4)
14+ for i := range w * h {
15+ a := byte(i * 255 / (w*h - 1))
16+ if i == 3 {
17+ a = 0
18+ }
19+ c := func(v int) byte { return byte(v * int(a) / 255) }
20+ pixels[i*4], pixels[i*4+1], pixels[i*4+2], pixels[i*4+3] = c(i*37%256), c((i*91+20)%256), c((i*53+200)%256), a
21+ }
22+ return
23+}
24+
25+// The conversion is byte-identical to the one the Vulkan renderer did
26+// before it moved here (203 nits, cropped to 4×4).
27+func TestConvertMatchesRecordedVector(t *testing.T) {
28+ pixels, w, h := vector()
29+ want := []byte{0x0, 0x0, 0x0, 0x0, 0x5, 0x7, 0x9, 0x12, 0xd, 0x13, 0xf, 0x24, 0x0, 0x0, 0x0, 0x0, 0x2e, 0x31, 0x31, 0x5b, 0x3a, 0x1f, 0x20, 0x6d, 0x20, 0x39, 0x30, 0x7f, 0x33, 0x51, 0x46, 0x91, 0x4c, 0x58, 0x5f, 0xb6, 0x5f, 0x72, 0x5e, 0xc8, 0x6d, 0x4e, 0x4c, 0xda, 0x81, 0x75, 0x6c, 0xec}
30+ var c Cache
31+ if got := c.Convert(pixels, w, h, 4, 203); !bytes.Equal(got, want) {
32+ t.Fatalf("converted\n got %#v\nwant %#v", got, want)
33+ }
34+}
35+
36+func TestPixelWhite(t *testing.T) {
37+ p := []byte{255, 255, 255, 255}
38+ Pixel(p, 203)
39+ if p[3] != 255 || math.Abs(float64(p[0])-148) > 1 || p[0] != p[1] || p[1] != p[2] {
40+ t.Fatalf("cursor white: %v", p)
41+ }
42+ if PQEncode(0) != 0 || math.Abs(PQEncode(10000)-1) > 1e-9 {
43+ t.Fatal("PQ endpoints")
44+ }
45+}
46+
47+func TestCacheKeepsLastConversion(t *testing.T) {
48+ var c Cache
49+ px := []byte{255, 255, 255, 255}
50+ a := c.Convert(px, 1, 1, 64, 203)
51+ if c.Len() != 4 || a[0] != 148 {
52+ t.Fatalf("len %d pixel %v", c.Len(), a)
53+ }
54+ first := c.Key()
55+ if b := c.Convert(px, 1, 1, 64, 203); &a[0] != &b[0] {
56+ t.Fatal("same image converted again")
57+ }
58+ for i := range 12 {
59+ v := byte(i * 18)
60+ c.Convert([]byte{v, v, v, 255}, 1, 1, 64, 203)
61+ if c.Len() != 4 {
62+ t.Fatalf("cache grew after image %d: %d", i, c.Len())
63+ }
64+ }
65+ if c.Key() == first {
66+ t.Fatal("conversion not replaced")
67+ }
68+ c.Convert(px, 1, 1, 64, 100)
69+ if c.Key() == first {
70+ t.Fatal("nits not part of the key")
71+ }
72+ c.Reset()
73+ if c.Len() != 0 || c.Key() != [32]byte{} {
74+ t.Fatal("reset left a conversion")
75+ }
76+}
@@ -0,0 +1,76 @@
1+package hdrcursor
2+
3+import (
4+ "bytes"
5+ "math"
6+ "testing"
7+)
8+
9+// vector is a 5×3 premultiplied BGRA image: alpha ramps up with one fully
10+// transparent pixel (index 3).
11+func vector() (pixels []byte, w, h int) {
12+ w, h = 5, 3
13+ pixels = make([]byte, w*h*4)
14+ for i := range w * h {
15+ a := byte(i * 255 / (w*h - 1))
16+ if i == 3 {
17+ a = 0
18+ }
19+ c := func(v int) byte { return byte(v * int(a) / 255) }
20+ pixels[i*4], pixels[i*4+1], pixels[i*4+2], pixels[i*4+3] = c(i*37%256), c((i*91+20)%256), c((i*53+200)%256), a
21+ }
22+ return
23+}
24+
25+// The conversion is byte-identical to the one the Vulkan renderer did
26+// before it moved here (203 nits, cropped to 4×4).
27+func TestConvertMatchesRecordedVector(t *testing.T) {
28+ pixels, w, h := vector()
29+ want := []byte{0x0, 0x0, 0x0, 0x0, 0x5, 0x7, 0x9, 0x12, 0xd, 0x13, 0xf, 0x24, 0x0, 0x0, 0x0, 0x0, 0x2e, 0x31, 0x31, 0x5b, 0x3a, 0x1f, 0x20, 0x6d, 0x20, 0x39, 0x30, 0x7f, 0x33, 0x51, 0x46, 0x91, 0x4c, 0x58, 0x5f, 0xb6, 0x5f, 0x72, 0x5e, 0xc8, 0x6d, 0x4e, 0x4c, 0xda, 0x81, 0x75, 0x6c, 0xec}
30+ var c Cache
31+ if got := c.Convert(pixels, w, h, 4, 203); !bytes.Equal(got, want) {
32+ t.Fatalf("converted\n got %#v\nwant %#v", got, want)
33+ }
34+}
35+
36+func TestPixelWhite(t *testing.T) {
37+ p := []byte{255, 255, 255, 255}
38+ Pixel(p, 203)
39+ if p[3] != 255 || math.Abs(float64(p[0])-148) > 1 || p[0] != p[1] || p[1] != p[2] {
40+ t.Fatalf("cursor white: %v", p)
41+ }
42+ if PQEncode(0) != 0 || math.Abs(PQEncode(10000)-1) > 1e-9 {
43+ t.Fatal("PQ endpoints")
44+ }
45+}
46+
47+func TestCacheKeepsLastConversion(t *testing.T) {
48+ var c Cache
49+ px := []byte{255, 255, 255, 255}
50+ a := c.Convert(px, 1, 1, 64, 203)
51+ if c.Len() != 4 || a[0] != 148 {
52+ t.Fatalf("len %d pixel %v", c.Len(), a)
53+ }
54+ first := c.Key()
55+ if b := c.Convert(px, 1, 1, 64, 203); &a[0] != &b[0] {
56+ t.Fatal("same image converted again")
57+ }
58+ for i := range 12 {
59+ v := byte(i * 18)
60+ c.Convert([]byte{v, v, v, 255}, 1, 1, 64, 203)
61+ if c.Len() != 4 {
62+ t.Fatalf("cache grew after image %d: %d", i, c.Len())
63+ }
64+ }
65+ if c.Key() == first {
66+ t.Fatal("conversion not replaced")
67+ }
68+ c.Convert(px, 1, 1, 64, 100)
69+ if c.Key() == first {
70+ t.Fatal("nits not part of the key")
71+ }
72+ c.Reset()
73+ if c.Len() != 0 || c.Key() != [32]byte{} {
74+ t.Fatal("reset left a conversion")
75+ }
76+}
M internal/adapters/vulkan/cursor.go +5 -42
@@ -1,11 +1,8 @@
11 package vulkan
22
33 import (
4- "crypto/sha256"
5- "encoding/binary"
64 "errors"
75 "fmt"
8- "math"
96 "os"
107 "unsafe"
118
@@ -108,26 +105,6 @@ func (r *Renderer) cursorImage(size int) (*cursorImage, ports.DMABuf, error) {
108105 return c, ports.DMABuf{Width: size, Height: size, Format: fourccARGB, Modifier: 0, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}, nil
109106 }
110107
111-// cursorConversion keeps only the last converted image, bounded by the
112-// cursor plane's maximum dimensions, regardless of cursor theme size.
113-type cursorConversion struct {
114- key [32]byte
115- pixels []byte
116-}
117-
118-func cursorKey(pixels []byte, w, h int, nits float64) [32]byte {
119- var header [24]byte
120- binary.LittleEndian.PutUint64(header[:8], uint64(w))
121- binary.LittleEndian.PutUint64(header[8:16], uint64(h))
122- binary.LittleEndian.PutUint64(header[16:], math.Float64bits(nits))
123- digest := sha256.New()
124- _, _ = digest.Write(header[:])
125- _, _ = digest.Write(pixels)
126- var key [32]byte
127- digest.Sum(key[:0])
128- return key
129-}
130-
131108 // WriteCursor copies premultiplied ARGB8888 pixels (w×h, w*4 per row)
132109 // into cursor image i at its row pitch, cropped and cleared around.
133110 func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
@@ -141,27 +118,13 @@ func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
141118 mem := unsafe.Slice((*byte)(c.mapped), c.pitch*c.size)
142119 clear(mem)
143120 cw := min(w, c.size) * 4
121+ src, stride := pixels, w*4
144122 if r.hdrNits > 0 {
145123 // WriteCursor is called only on image changes, never on moves.
146- key := cursorKey(pixels[:w*h*4], w, h, r.hdrNits)
147- converted := r.cursorCache.pixels
148- if converted == nil || r.cursorCache.key != key {
149- converted = make([]byte, cw*min(h, c.size))
150- for y := range min(h, c.size) {
151- copy(converted[y*cw:(y+1)*cw], pixels[y*w*4:y*w*4+cw])
152- for x := 0; x < cw; x += 4 {
153- hdrCursorPixel(converted[y*cw+x:y*cw+x+4], r.hdrNits)
154- }
155- }
156- r.cursorCache = cursorConversion{key: key, pixels: converted}
157- }
158- for y := range min(h, c.size) {
159- copy(mem[y*c.pitch:y*c.pitch+cw], converted[y*cw:(y+1)*cw])
160- }
161- } else {
162- for y := range min(h, c.size) {
163- copy(mem[y*c.pitch:y*c.pitch+cw], pixels[y*w*4:y*w*4+cw])
164- }
124+ src, stride = r.cursorCache.Convert(pixels, w, h, c.size, r.hdrNits), cw
125+ }
126+ for y := range min(h, c.size) {
127+ copy(mem[y*c.pitch:y*c.pitch+cw], src[y*stride:y*stride+cw])
165128 }
166129 return nil
167130 }
@@ -1,11 +1,8 @@
1 package vulkan1 package vulkan
2 2
3 import (3 import (
4- "crypto/sha256"
5- "encoding/binary"
6 "errors"4 "errors"
7 "fmt"5 "fmt"
8- "math"
9 "os"6 "os"
10 "unsafe"7 "unsafe"
11 8
@@ -108,26 +105,6 @@ func (r *Renderer) cursorImage(size int) (*cursorImage, ports.DMABuf, error) {
108 return c, ports.DMABuf{Width: size, Height: size, Format: fourccARGB, Modifier: 0, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}, nil105 return c, ports.DMABuf{Width: size, Height: size, Format: fourccARGB, Modifier: 0, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}, nil
109 }106 }
110 107
111-// cursorConversion keeps only the last converted image, bounded by the
112-// cursor plane's maximum dimensions, regardless of cursor theme size.
113-type cursorConversion struct {
114- key [32]byte
115- pixels []byte
116-}
117-
118-func cursorKey(pixels []byte, w, h int, nits float64) [32]byte {
119- var header [24]byte
120- binary.LittleEndian.PutUint64(header[:8], uint64(w))
121- binary.LittleEndian.PutUint64(header[8:16], uint64(h))
122- binary.LittleEndian.PutUint64(header[16:], math.Float64bits(nits))
123- digest := sha256.New()
124- _, _ = digest.Write(header[:])
125- _, _ = digest.Write(pixels)
126- var key [32]byte
127- digest.Sum(key[:0])
128- return key
129-}
130-
131 // WriteCursor copies premultiplied ARGB8888 pixels (w×h, w*4 per row)108 // WriteCursor copies premultiplied ARGB8888 pixels (w×h, w*4 per row)
132 // into cursor image i at its row pitch, cropped and cleared around.109 // into cursor image i at its row pitch, cropped and cleared around.
133 func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {110 func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
@@ -141,27 +118,13 @@ func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
141 mem := unsafe.Slice((*byte)(c.mapped), c.pitch*c.size)118 mem := unsafe.Slice((*byte)(c.mapped), c.pitch*c.size)
142 clear(mem)119 clear(mem)
143 cw := min(w, c.size) * 4120 cw := min(w, c.size) * 4
121+ src, stride := pixels, w*4
144 if r.hdrNits > 0 {122 if r.hdrNits > 0 {
145 // WriteCursor is called only on image changes, never on moves.123 // WriteCursor is called only on image changes, never on moves.
146- key := cursorKey(pixels[:w*h*4], w, h, r.hdrNits)124+ src, stride = r.cursorCache.Convert(pixels, w, h, c.size, r.hdrNits), cw
147- converted := r.cursorCache.pixels125+ }
148- if converted == nil || r.cursorCache.key != key {126+ for y := range min(h, c.size) {
149- converted = make([]byte, cw*min(h, c.size))127+ copy(mem[y*c.pitch:y*c.pitch+cw], src[y*stride:y*stride+cw])
150- for y := range min(h, c.size) {
151- copy(converted[y*cw:(y+1)*cw], pixels[y*w*4:y*w*4+cw])
152- for x := 0; x < cw; x += 4 {
153- hdrCursorPixel(converted[y*cw+x:y*cw+x+4], r.hdrNits)
154- }
155- }
156- r.cursorCache = cursorConversion{key: key, pixels: converted}
157- }
158- for y := range min(h, c.size) {
159- copy(mem[y*c.pitch:y*c.pitch+cw], converted[y*cw:(y+1)*cw])
160- }
161- } else {
162- for y := range min(h, c.size) {
163- copy(mem[y*c.pitch:y*c.pitch+cw], pixels[y*w*4:y*w*4+cw])
164- }
165 }128 }
166 return nil129 return nil
167 }130 }
M internal/adapters/vulkan/cursor_test.go +6 -25
@@ -47,7 +47,7 @@ func TestCursorBuffersWriteCursor(t *testing.T) {
4747 }
4848 }
4949
50-func TestHDRCursorCachesConversion(t *testing.T) {
50+func TestHDRCursorWritesBothImagesAndInvalidates(t *testing.T) {
5151 r, err := New(32, 16)
5252 if err != nil {
5353 t.Skipf("Vulkan unavailable: %v", err)
@@ -65,28 +65,6 @@ func TestHDRCursorCachesConversion(t *testing.T) {
6565 if err := r.WriteCursor(0, px, 1, 1); err != nil {
6666 t.Fatal(err)
6767 }
68- if len(r.cursorCache.pixels) != 4 {
69- t.Fatalf("cache size %d", len(r.cursorCache.pixels))
70- }
71- if err := r.WriteCursor(1, px, 1, 1); err != nil {
72- t.Fatal(err)
73- }
74- if len(r.cursorCache.pixels) != 4 {
75- t.Fatalf("cache grew on reselect: %d", len(r.cursorCache.pixels))
76- }
77- first := r.cursorCache.key
78- for i := 0; i < 12; i++ {
79- color := byte(i * 18)
80- if err := r.WriteCursor(1, []byte{color, color, color, 255}, 1, 1); err != nil {
81- t.Fatal(err)
82- }
83- if len(r.cursorCache.pixels) != 4 {
84- t.Fatalf("cache grew after image %d: %d", i, len(r.cursorCache.pixels))
85- }
86- }
87- if first == r.cursorCache.key {
88- t.Fatal("cursor conversion not replaced")
89- }
9068 if err := r.WriteCursor(1, px, 1, 1); err != nil {
9169 t.Fatal(err)
9270 }
@@ -97,10 +75,13 @@ func TestHDRCursorCachesConversion(t *testing.T) {
9775 }
9876 }
9977 r.SetHDR(0)
78+ if r.cursorCache.Len() != 0 {
79+ t.Fatal("SetHDR kept conversion")
80+ }
10081 if err := r.WriteCursor(0, px, 1, 1); err != nil {
10182 t.Fatal(err)
10283 }
103- if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || len(r.cursorCache.pixels) != 0 {
104- t.Fatalf("SDR cursor/cache: %v %d", got, len(r.cursorCache.pixels))
84+ if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || r.cursorCache.Len() != 0 {
85+ t.Fatalf("SDR cursor/cache: %v %d", got, r.cursorCache.Len())
10586 }
10687 }
@@ -47,7 +47,7 @@ func TestCursorBuffersWriteCursor(t *testing.T) {
47 }47 }
48 }48 }
49 49
50-func TestHDRCursorCachesConversion(t *testing.T) {50+func TestHDRCursorWritesBothImagesAndInvalidates(t *testing.T) {
51 r, err := New(32, 16)51 r, err := New(32, 16)
52 if err != nil {52 if err != nil {
53 t.Skipf("Vulkan unavailable: %v", err)53 t.Skipf("Vulkan unavailable: %v", err)
@@ -65,28 +65,6 @@ func TestHDRCursorCachesConversion(t *testing.T) {
65 if err := r.WriteCursor(0, px, 1, 1); err != nil {65 if err := r.WriteCursor(0, px, 1, 1); err != nil {
66 t.Fatal(err)66 t.Fatal(err)
67 }67 }
68- if len(r.cursorCache.pixels) != 4 {
69- t.Fatalf("cache size %d", len(r.cursorCache.pixels))
70- }
71- if err := r.WriteCursor(1, px, 1, 1); err != nil {
72- t.Fatal(err)
73- }
74- if len(r.cursorCache.pixels) != 4 {
75- t.Fatalf("cache grew on reselect: %d", len(r.cursorCache.pixels))
76- }
77- first := r.cursorCache.key
78- for i := 0; i < 12; i++ {
79- color := byte(i * 18)
80- if err := r.WriteCursor(1, []byte{color, color, color, 255}, 1, 1); err != nil {
81- t.Fatal(err)
82- }
83- if len(r.cursorCache.pixels) != 4 {
84- t.Fatalf("cache grew after image %d: %d", i, len(r.cursorCache.pixels))
85- }
86- }
87- if first == r.cursorCache.key {
88- t.Fatal("cursor conversion not replaced")
89- }
90 if err := r.WriteCursor(1, px, 1, 1); err != nil {68 if err := r.WriteCursor(1, px, 1, 1); err != nil {
91 t.Fatal(err)69 t.Fatal(err)
92 }70 }
@@ -97,10 +75,13 @@ func TestHDRCursorCachesConversion(t *testing.T) {
97 }75 }
98 }76 }
99 r.SetHDR(0)77 r.SetHDR(0)
78+ if r.cursorCache.Len() != 0 {
79+ t.Fatal("SetHDR kept conversion")
80+ }
100 if err := r.WriteCursor(0, px, 1, 1); err != nil {81 if err := r.WriteCursor(0, px, 1, 1); err != nil {
101 t.Fatal(err)82 t.Fatal(err)
102 }83 }
103- if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || len(r.cursorCache.pixels) != 0 {84+ if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || r.cursorCache.Len() != 0 {
104- t.Fatalf("SDR cursor/cache: %v %d", got, len(r.cursorCache.pixels))85+ t.Fatalf("SDR cursor/cache: %v %d", got, r.cursorCache.Len())
105 }86 }
106 }87 }
M internal/adapters/vulkan/hdr.go +2 -2
@@ -28,7 +28,7 @@ type hdrPass struct {
2828 // remains owner-goroutine confined; all GPU objects are reused each frame.
2929 func (r *Renderer) SetHDR(nits float64) {
3030 if nits != r.hdrNits {
31- r.cursorCache = cursorConversion{}
31+ r.cursorCache.Reset()
3232 if r.hdrNits > 0 && nits > 0 {
3333 r.hdrOwn.valid = false
3434 }
@@ -47,7 +47,7 @@ func (r *Renderer) SetHDR(nits float64) {
4747 }
4848 r.hdrNits = nits
4949 if nits <= 0 {
50- r.cursorCache = cursorConversion{}
50+ r.cursorCache.Reset()
5151 r.hdrError = nil
5252 return
5353 }
@@ -28,7 +28,7 @@ type hdrPass struct {
28 // remains owner-goroutine confined; all GPU objects are reused each frame.28 // remains owner-goroutine confined; all GPU objects are reused each frame.
29 func (r *Renderer) SetHDR(nits float64) {29 func (r *Renderer) SetHDR(nits float64) {
30 if nits != r.hdrNits {30 if nits != r.hdrNits {
31- r.cursorCache = cursorConversion{}31+ r.cursorCache.Reset()
32 if r.hdrNits > 0 && nits > 0 {32 if r.hdrNits > 0 && nits > 0 {
33 r.hdrOwn.valid = false33 r.hdrOwn.valid = false
34 }34 }
@@ -47,7 +47,7 @@ func (r *Renderer) SetHDR(nits float64) {
47 }47 }
48 r.hdrNits = nits48 r.hdrNits = nits
49 if nits <= 0 {49 if nits <= 0 {
50- r.cursorCache = cursorConversion{}50+ r.cursorCache.Reset()
51 r.hdrError = nil51 r.hdrError = nil
52 return52 return
53 }53 }
M internal/adapters/vulkan/pq.go +6 -34
@@ -1,21 +1,13 @@
11 package vulkan
22
3-import "math"
3+import (
4+ "math"
45
5-var srgbTable = func() (table [256]float64) {
6- for i := range table {
7- table[i] = srgbToLinear(float64(i) / 255)
8- }
9- return
10-}()
6+ "github.com/bnema/neferwl/internal/adapters/hdrcursor"
7+)
118
129 // srgbToLinear decodes a unit-range sRGB component.
13-func srgbToLinear(v float64) float64 {
14- if v <= 0.04045 {
15- return v / 12.92
16- }
17- return math.Pow((v+0.055)/1.055, 2.4)
18-}
10+func srgbToLinear(v float64) float64 { return hdrcursor.SRGBToLinear(v) }
1911
2012 // linearToSRGB encodes a unit-range linear component; the inverse of
2113 // srgbToLinear.
@@ -27,13 +19,7 @@ func linearToSRGB(v float64) float64 {
2719 }
2820
2921 // pqEncode encodes absolute luminance in nits using SMPTE ST 2084.
30-func pqEncode(nits float64) float64 {
31- if nits <= 0 {
32- return 0
33- }
34- x := math.Pow(min(nits, 10000)/10000, 2610.0/16384)
35- return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32)
36-}
22+func pqEncode(nits float64) float64 { return hdrcursor.PQEncode(nits) }
3723
3824 // hdrPixel transforms an unassociated sRGB triplet into BT.2020 PQ.
3925 func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {
@@ -42,17 +28,3 @@ func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {
4228 pqEncode((0.069097*r + 0.919540*g + 0.011362*b) * nits),
4329 pqEncode((0.016391*r + 0.088013*g + 0.895595*b) * nits)
4430 }
45-
46-// hdrCursorPixel converts premultiplied BGRA bytes, preserving alpha.
47-// The source is quantized to its 8-bit unassociated sRGB value for the LUT.
48-func hdrCursorPixel(p []byte, nits float64) {
49- a := float64(p[3]) / 255
50- if a == 0 {
51- clear(p[:3])
52- return
53- }
54- index := func(v byte) int { return min(255, int(math.Round(float64(v)/a))) }
55- r, g, b := srgbTable[index(p[2])], srgbTable[index(p[1])], srgbTable[index(p[0])]
56- r, g, b = pqEncode((0.627404*r+0.329283*g+0.043313*b)*nits), pqEncode((0.069097*r+0.919540*g+0.011362*b)*nits), pqEncode((0.016391*r+0.088013*g+0.895595*b)*nits)
57- p[0], p[1], p[2] = byte(math.Round(b*a*255)), byte(math.Round(g*a*255)), byte(math.Round(r*a*255))
58-}
@@ -1,21 +1,13 @@
1 package vulkan1 package vulkan
2 2
3-import "math"3+import (
4+ "math"
4 5
5-var srgbTable = func() (table [256]float64) {6+ "github.com/bnema/neferwl/internal/adapters/hdrcursor"
6- for i := range table {7+)
7- table[i] = srgbToLinear(float64(i) / 255)
8- }
9- return
10-}()
11 8
12 // srgbToLinear decodes a unit-range sRGB component.9 // srgbToLinear decodes a unit-range sRGB component.
13-func srgbToLinear(v float64) float64 {10+func srgbToLinear(v float64) float64 { return hdrcursor.SRGBToLinear(v) }
14- if v <= 0.04045 {
15- return v / 12.92
16- }
17- return math.Pow((v+0.055)/1.055, 2.4)
18-}
19 11
20 // linearToSRGB encodes a unit-range linear component; the inverse of12 // linearToSRGB encodes a unit-range linear component; the inverse of
21 // srgbToLinear.13 // srgbToLinear.
@@ -27,13 +19,7 @@ func linearToSRGB(v float64) float64 {
27 }19 }
28 20
29 // pqEncode encodes absolute luminance in nits using SMPTE ST 2084.21 // pqEncode encodes absolute luminance in nits using SMPTE ST 2084.
30-func pqEncode(nits float64) float64 {22+func pqEncode(nits float64) float64 { return hdrcursor.PQEncode(nits) }
31- if nits <= 0 {
32- return 0
33- }
34- x := math.Pow(min(nits, 10000)/10000, 2610.0/16384)
35- return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32)
36-}
37 23
38 // hdrPixel transforms an unassociated sRGB triplet into BT.2020 PQ.24 // hdrPixel transforms an unassociated sRGB triplet into BT.2020 PQ.
39 func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {25 func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {
@@ -42,17 +28,3 @@ func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {
42 pqEncode((0.069097*r + 0.919540*g + 0.011362*b) * nits),28 pqEncode((0.069097*r + 0.919540*g + 0.011362*b) * nits),
43 pqEncode((0.016391*r + 0.088013*g + 0.895595*b) * nits)29 pqEncode((0.016391*r + 0.088013*g + 0.895595*b) * nits)
44 }30 }
45-
46-// hdrCursorPixel converts premultiplied BGRA bytes, preserving alpha.
47-// The source is quantized to its 8-bit unassociated sRGB value for the LUT.
48-func hdrCursorPixel(p []byte, nits float64) {
49- a := float64(p[3]) / 255
50- if a == 0 {
51- clear(p[:3])
52- return
53- }
54- index := func(v byte) int { return min(255, int(math.Round(float64(v)/a))) }
55- r, g, b := srgbTable[index(p[2])], srgbTable[index(p[1])], srgbTable[index(p[0])]
56- r, g, b = pqEncode((0.627404*r+0.329283*g+0.043313*b)*nits), pqEncode((0.069097*r+0.919540*g+0.011362*b)*nits), pqEncode((0.016391*r+0.088013*g+0.895595*b)*nits)
57- p[0], p[1], p[2] = byte(math.Round(b*a*255)), byte(math.Round(g*a*255)), byte(math.Round(r*a*255))
58-}
M internal/adapters/vulkan/pq_test.go +0 -5
@@ -13,9 +13,4 @@ func TestPQMath(t *testing.T) {
1313 if pqEncode(0) != 0 || math.Abs(pqEncode(10000)-1) > 1e-9 {
1414 t.Fatal("PQ endpoints")
1515 }
16- p := []byte{255, 255, 255, 255}
17- hdrCursorPixel(p, 203)
18- if p[3] != 255 || math.Abs(float64(p[0])-148) > 1 || p[0] != p[1] || p[1] != p[2] {
19- t.Fatalf("cursor white: %v", p)
20- }
2116 }
@@ -13,9 +13,4 @@ func TestPQMath(t *testing.T) {
13 if pqEncode(0) != 0 || math.Abs(pqEncode(10000)-1) > 1e-9 {13 if pqEncode(0) != 0 || math.Abs(pqEncode(10000)-1) > 1e-9 {
14 t.Fatal("PQ endpoints")14 t.Fatal("PQ endpoints")
15 }15 }
16- p := []byte{255, 255, 255, 255}
17- hdrCursorPixel(p, 203)
18- if p[3] != 255 || math.Abs(float64(p[0])-148) > 1 || p[0] != p[1] || p[1] != p[2] {
19- t.Fatalf("cursor white: %v", p)
20- }
21 }16 }
M internal/adapters/vulkan/renderer.go +2 -1
M packaging/arch/PKGBUILD +1 -1
M packaging/aur/neferwl-git/PKGBUILD +1 -1

Comments0 open / 0

no comments yet.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close