diff --git a/evidence/azahar-stylus-parity/capture.json b/evidence/azahar-stylus-parity/capture.json new file mode 100644 index 0000000..1913bdf --- /dev/null +++ b/evidence/azahar-stylus-parity/capture.json @@ -0,0 +1,72 @@ +{ + "step": "azahar-stylus-parity", + "recorded": "2026-10-08T00:25:07.482933+00:00", + "revision": "working tree on 63e11a3", + "command": "tools/verify.sh format; PS5_FRONTENDS=\"es-de picker\" PS5_VULKAN_TEMPLATE=\"$PWD/build/picker-template\" tools/verify.sh unit build integration evidence; python3 tools/deploy-title.py", + "lines": [ + "format: PASS", + "182 unit and 182 integration tests: PASS", + "Azahar software raster matches DeSmuME at 1x, 5x and 18x: PASS", + "Vulkan shader compile/link, pen predicate parity and clipped nib coordinates: PASS", + "Old Azahar raster fails the DeSmuME pixel comparison as expected", + "Native Azahar build, ABI and cursor-patch provenance: PASS", + "Stored eboot.bin and both cores match staged SHA-256: PASS", + "Core-switch crash remains unresolved; no lifecycle changes made", + "Fresh PS5 Azahar launch: cursor captured, 399 frames by 7 seconds, clean exit" + ], + "native_azahar": { + "sha256": "54cdcf809cf521eb544a60c26726a3074045c812c53942ce3cb9b4b383b2b0f6", + "bytes": 62186560, + "abi_passed": true, + "source_revision": "4aef900a1a903d0aa6ea86fdb1e18236fb0d5907", + "port_inputs_sha256": { + "tools/core-fork.sh": "392c8a7577eaa7a81e99f58a6e006cecab92610d959dc460aabc3ef1064ac1b0", + "tooling/native/ps5-core.ld": "5ffe0f0e99e23de5245afb46fa6dfda6dd860b53d942a5fe2f06eaf9e520df42", + "tooling/native/core_cxx_runtime.cpp": "6d7c11d8595f47601b3a281e616033ea5d28c6329872aa286d4d81c597509433", + "tools/build-azahar.sh": "2418b991f7f4a71c4c4c984ab7831dad085861c6a40e82c0e8ca791acee2f40c", + "tooling/azahar/ps5-toolchain.cmake": "930762cf6f56d1e8b1a927bd70ac5e782f77cdb9b9622617973ef92ce881c398", + "tooling/azahar/touchpad-stylus.patch": "fa8187c2919fda1152b611318aaeced29f9c4e8ccb25997f25f230ce624ccd6f" + } + }, + "deployed_readback": { + "eboot.bin": { + "bytes": 38772204, + "sha256": "096ba6d77e9a6667f0d41c1473268200316cd086ee6b4d83523ada646e255287", + "matches_staged": true + }, + "cores/desmume_libretro.so": { + "bytes": 5626312, + "sha256": "b1b4d20bf62a4115007646b76ae7f5e04ddb661b8f9cb5b79a00c940c3b801a4", + "matches_staged": true + }, + "cores/azahar_libretro.so": { + "bytes": 62186560, + "sha256": "54cdcf809cf521eb544a60c26726a3074045c812c53942ce3cb9b4b383b2b0f6", + "matches_staged": true + } + }, + "preview_sha256": "d7d3b09c0ad154398920b1f614a9d5c3cb4dec9dd71156197ea995226ca15a84", + "raw_directory": "klog/azahar-stylus-parity", + "console_run": { + "command": "python3 klog/azahar-stylus-parity/run-fresh.py", + "build_id": "cbe6d6f13b15aaaf5f75604e555562a32f5dce39ef0a434e84a661dee6a2ae83", + "raw_capture": "klog/run-PPSA99169-202311.log", + "frontend_log": "klog/retroarch-202311.log", + "events": [ + "input: pad script MARK at 7.011 s: frames=399 worst_ms=80.4 over_40ms=7", + "input: pad script STOP at 9.01 s", + "rarch_main returned = 0" + ], + "frames": [ + { + "path": "klog/pad-202311/pad-shot-1.png", + "sha256": "54eaff3ddb6428207c2760e30c39f02b53c1244242c08080ae1eb43f3a6a8efb" + }, + { + "path": "klog/pad-202311/pad-shot-2.png", + "sha256": "cc1920a1ad0e2a7337099e11b4ed18144faf232c292ee6443bdc6d29ef327361" + } + ], + "visual_check": "Both screenshots show the white pen with dark outline/nib pointing down-left on the lower screen." + } +} diff --git a/evidence/azahar-stylus-parity/expectation.json b/evidence/azahar-stylus-parity/expectation.json new file mode 100644 index 0000000..3b71888 --- /dev/null +++ b/evidence/azahar-stylus-parity/expectation.json @@ -0,0 +1,17 @@ +{ + "step": "azahar-stylus-parity", + "note": "Matching DeSmuME pen in Azahar; crash investigation is separate.", + "must_contain": [ + "format: PASS", + "182 unit and 182 integration tests: PASS", + "Azahar software raster matches DeSmuME at 1x, 5x and 18x: PASS", + "Vulkan shader compile/link, pen predicate parity and clipped nib coordinates: PASS", + "Old Azahar raster fails the DeSmuME pixel comparison as expected", + "Native Azahar build, ABI and cursor-patch provenance: PASS", + "Stored eboot.bin and both cores match staged SHA-256: PASS", + "Core-switch crash remains unresolved; no lifecycle changes made", + "Fresh PS5 Azahar launch: cursor captured, 399 frames by 7 seconds, clean exit" + ], + "must_not_contain": [], + "human_check": "Check the stylus with physical touchpad movement, press and drag on the TV." +} diff --git a/tests/test_touchpad_stylus.py b/tests/test_touchpad_stylus.py index 307058e..6a971f5 100644 --- a/tests/test_touchpad_stylus.py +++ b/tests/test_touchpad_stylus.py @@ -127,32 +127,83 @@ int main() { } ''') - def test_azahar_software_stylus(self): - source = self.patched('azahar', 'src/citra_libretro/input/mouse_tracker.cpp') + def test_azahar_stylus_matches_desmume(self): + software = self.patched('azahar', 'src/citra_libretro/input/mouse_tracker.cpp') + desmume = self.patched('desmume', 'desmume/src/frontend/libretro/libretro.cpp') + shader = self.patched('azahar', 'src/video_core/host_shaders/vulkan_cursor.frag') + # Compile the real shaders together, then exercise the fragment's actual + # shape predicate against DeSmuME's rasterizer at native and scaled sizes. + with tempfile.TemporaryDirectory() as td: + for ext in ('vert', 'frag'): + Path(td, f'cursor.{ext}').write_text(self.patched( + 'azahar', f'src/video_core/host_shaders/vulkan_cursor.{ext}')) + subprocess.run(['glslangValidator', '-V', '-l', 'cursor.vert', 'cursor.frag'], + cwd=td, check=True, capture_output=True) + shape = shader[shader.index(' float along'):shader.index(' color +=')] + shape = shape.replace('p.x', 'dx').replace('p.y', 'dy') + shape = shape.replace('vec3 ink', 'uint32_t ink').replace('vec3(1.0)', '0xffffff') + shape = shape.replace('vec3(32.0, 40.0, 48.0) / 255.0', '0x202830') + # GLSL literals are floats; preserve that in the extracted C++ predicate. + import re + shape = re.sub(r'(\d+\.\d+)', r'\1f', shape) self.compile_run(""" #include #include #include #include #include +using std::min; using std::max; using std::abs; +constexpr int RETRO_PIXEL_FORMAT_XRGB8888=1; +int colorMode=1; +uint32_t pointer_color_32=0xffffff; namespace Layout { struct FramebufferLayout { - bool IsWithinTouchscreen(int x,int y)const{return x>=10&&x<30&&y>=20&&y<40;} + int left,top,right,bottom; + bool IsWithinTouchscreen(int x,int y)const{return x>=left&&x=top&&y pixels(40*50,0xff123456); - cursor.Render(40,50,x,y,8,layout,pixels.data()); - assert(pixels[static_cast(y)*40+static_cast(x)]==0xffedcba9); - for(int py=0;py<50;++py) for(int px=0;px<40;++px) - if(!layout.IsWithinTouchscreen(px,py)) assert(pixels[py*40+px]==0xff123456); + SoftwareCursorRenderer cursor; + for(int scale : {1,5,18}) { + const int w=40*scale,h=32*scale; + Layout::FramebufferLayout layout{0,0,w,h}; + for(int x : {0,w/2,w-1}) for(int y : {0,h/2,h-1}) { + std::vector ds(w*h,0x123456),az=ds; + DrawStylus(reinterpret_cast(ds.data()),w,w,h,x,y,scale); + cursor.Render(w,h,x,y,8*scale,layout,az.data()); + assert(ds==az); + for(int py=0;py>16)&255;g+=(c>>8)&255;b+=c&255; + } + assert(ds[py*w+px]==((r/4)<<16|(g/4)<<8|(b/4))); + } + // Preserve alpha and never paint outside the lower screen. + layout={3*scale,4*scale,w-3*scale,h-4*scale}; + az.assign(w*h,0xff123456); + cursor.Render(w,h,x,y,8*scale,layout,az.data()); + for(int py=0;py>24)==255); + if(!layout.IsWithinTouchscreen(px,py)) assert(az[py*w+px]==0xff123456); + else assert((az[py*w+px]&0xffffff)==ds[py*w+px]); + } + layout={0,0,w,h}; + } } - cursor.Render(40,50,10,20,8,layout,nullptr); + cursor.Render(40,50,10,20,8,{0,0,40,50},nullptr); } """) @@ -167,17 +218,20 @@ int main() { #include struct Rect {float left,top,right,bottom; float GetHeight()const{return bottom-top;}}; int main() { - for(float scale : {1.f,18.f}) for(float x : {0.f,160.f,319.f}) + for(float resolution : {1.f,5.f,18.f}) for(float x : {0.f,160.f,319.f}) for(float y : {0.f,120.f,239.f}) { struct {float width,height; Rect bottom_screen;} layout{ - 400*scale,480*scale,{40*scale,240*scale,360*scale,480*scale}}; - struct {float projected_x,projected_y;} cursor{x*scale,y*scale}; + 400*resolution,480*resolution,{40*resolution,240*resolution,360*resolution,480*resolution}}; + struct {float projected_x,projected_y;} cursor{x*resolution,y*resolution}; """ + source[start:end] + """ - assert(sizeof(vertices)/sizeof(float)==18); - assert(vertices[0]==cx && vertices[1]==cy); // nib is the exact touch point - for(unsigned i=0;i<18;i+=2) { - assert(vertices[i]>=bl && vertices[i]<=br); - assert(vertices[i+1]>=bt && vertices[i+1]<=bb); + assert(sizeof(vertices)/sizeof(float)==24); + for(unsigned i=0;i<24;i+=4) { + float px=(vertices[i]+1)*buf_w/2, py=(vertices[i+1]+1)*buf_h/2; + assert(px>=layout.bottom_screen.left-.001 && px<=layout.bottom_screen.right+.001); + assert(py>=layout.bottom_screen.top-.001 && py<=layout.bottom_screen.bottom+.001); + // Every clipped vertex still measures its local position from the nib. + assert(std::abs(px-(abs_x+vertices[i+2]*scale))<.001); + assert(std::abs(py-(abs_y+vertices[i+3]*scale))<.001); } } } diff --git a/tooling/azahar/touchpad-stylus.patch b/tooling/azahar/touchpad-stylus.patch index 5acc6e1..539fd9e 100644 --- a/tooling/azahar/touchpad-stylus.patch +++ b/tooling/azahar/touchpad-stylus.patch @@ -61,7 +61,7 @@ last_moved = std::chrono::steady_clock::now(); } -@@ -340,49 +353,24 @@ +@@ -340,47 +353,40 @@ return; // No framebuffer data available } @@ -94,24 +94,39 @@ - int pixelIndex = y * bufferWidth + x; - // XOR blend for visibility on any background - pixels[pixelIndex] ^= cursorColor; -+ // A pen whose nib is the touch point, clipped to the bottom screen. -+ static const char glyph[12][13] = { -+ "## ", "#.# ", " #.# ", " #.## ", -+ " #..# ", " #...# ", " #...# ", " #...# ", -+ " #...# ", " #...#", " #.# ", " # " -+ }; -+ const int size = std::max(1, static_cast(renderRatio / 8)); ++ // Same supersampled white pen and dark nib/outline as DeSmuME. ++ const float size = std::max(1.0f, renderRatio / 8.0f); ++ const int x = static_cast(projectedX), y = static_cast(projectedY); + auto* pixels = static_cast(framebuffer_data); -+ for (int row = 0; row < 12 * size; ++row) { -+ for (int col = 0; col < 12 * size; ++col) { -+ const int x = static_cast(projectedX) + col; -+ const int y = static_cast(projectedY) + row; -+ if (glyph[row / size][col / size] == '#' && x >= 0 && y >= 0 && -+ x < bufferWidth && y < bufferHeight && layout.IsWithinTouchscreen(x, y)) { -+ pixels[y * bufferWidth + x] ^= 0x00ffffff; ++ for (int py = std::max(0, static_cast(y - 21 * size)); ++ py < std::min(bufferHeight, static_cast(y + 2 * size)); ++py) { ++ for (int px = std::max(0, static_cast(x - 2 * size)); ++ px < std::min(bufferWidth, static_cast(x + 17 * size)); ++px) { ++ if (!layout.IsWithinTouchscreen(px, py)) ++ continue; ++ const uint32_t original = pixels[py * bufferWidth + px]; ++ unsigned red = 0, green = 0, blue = 0, covered = 0; ++ for (int sy = 0; sy < 2; ++sy) { ++ for (int sx = 0; sx < 2; ++sx) { ++ const float dx = (px - x + (sx - 0.5f) * 0.5f) / size; ++ const float dy = (py - y + (sy - 0.5f) * 0.5f) / size; ++ const float along = dx * 0.6f - dy * 0.8f; ++ const float side = dx * 0.8f + dy * 0.6f; ++ const float radius = std::min(1.6f, along * 0.32f); ++ const float cap = std::max(0.0f, along - 22.0f); ++ uint32_t colour = original; ++ if (along >= 0 && side * side + cap * cap <= radius * radius) { ++ ++covered; ++ colour = along > 5.0f && along < 20.5f && ++ std::abs(side) < radius - 0.45f ? 0xffffff : 0x202830; ++ } ++ red += (colour >> 16) & 255; ++ green += (colour >> 8) & 255; ++ blue += colour & 255; ++ } } - } - } +- } +- } - - // Draw horizontal line of cursor - for (int y = horizontalTop; y < horizontalBottom; ++y) { @@ -121,11 +136,12 @@ - // XOR blend for visibility on any background - pixels[pixelIndex] ^= cursorColor; - } -- } -- } ++ if (covered) ++ pixels[py * bufferWidth + px] = (original & 0xff000000) | ++ ((red / 4) << 16) | ((green / 4) << 8) | (blue / 4); + } + } } - - } // namespace Input --- a/src/citra_libretro/input/mouse_tracker.h +++ b/src/citra_libretro/input/mouse_tracker.h @@ -61,6 +61,7 @@ @@ -138,10 +154,71 @@ float lastMouseY; --- a/src/video_core/renderer_vulkan/renderer_vulkan.cpp +++ b/src/video_core/renderer_vulkan/renderer_vulkan.cpp -@@ -1087,28 +1087,15 @@ - const float br = (layout.bottom_screen.right / buf_w) * 2.0f - 1.0f; - const float bb = (layout.bottom_screen.bottom / buf_h) * 2.0f - 1.0f; +@@ -485,26 +485,24 @@ + present_pipelines[i] = pipeline; + } +- // Build cursor pipeline (simple position-only, inverted color blending) ++ // Build the DeSmuME-style pen pipeline (position and local pen coordinates). + { + const vk::VertexInputBindingDescription cursor_binding = { + .binding = 0, +- .stride = sizeof(float) * 2, ++ .stride = sizeof(float) * 4, + .inputRate = vk::VertexInputRate::eVertex, + }; + +- const vk::VertexInputAttributeDescription cursor_attribute = { +- .location = 0, +- .binding = 0, +- .format = vk::Format::eR32G32Sfloat, +- .offset = 0, ++ const std::array cursor_attributes = { ++ vk::VertexInputAttributeDescription{0, 0, vk::Format::eR32G32Sfloat, 0}, ++ vk::VertexInputAttributeDescription{1, 0, vk::Format::eR32G32Sfloat, sizeof(float) * 2}, + }; + + const vk::PipelineVertexInputStateCreateInfo cursor_vertex_input = { + .vertexBindingDescriptionCount = 1, + .pVertexBindingDescriptions = &cursor_binding, +- .vertexAttributeDescriptionCount = 1, +- .pVertexAttributeDescriptions = &cursor_attribute, ++ .vertexAttributeDescriptionCount = 2, ++ .pVertexAttributeDescriptions = cursor_attributes.data(), + }; + + const vk::PipelineInputAssemblyStateCreateInfo cursor_input_assembly = { +@@ -528,11 +526,11 @@ + + const vk::PipelineColorBlendAttachmentState cursor_blend_attachment = { + .blendEnable = true, +- .srcColorBlendFactor = vk::BlendFactor::eOneMinusDstColor, +- .dstColorBlendFactor = vk::BlendFactor::eOneMinusSrcColor, ++ .srcColorBlendFactor = vk::BlendFactor::eOne, ++ .dstColorBlendFactor = vk::BlendFactor::eOneMinusSrcAlpha, + .colorBlendOp = vk::BlendOp::eAdd, + .srcAlphaBlendFactor = vk::BlendFactor::eOne, +- .dstAlphaBlendFactor = vk::BlendFactor::eZero, ++ .dstAlphaBlendFactor = vk::BlendFactor::eOneMinusSrcAlpha, + .alphaBlendOp = vk::BlendOp::eAdd, + .colorWriteMask = vk::ColorComponentFlagBits::eR | vk::ColorComponentFlagBits::eG | + vk::ColorComponentFlagBits::eB | vk::ColorComponentFlagBits::eA, +@@ -1075,40 +1073,20 @@ + // Convert from bottom-screen-local to layout-absolute, then to NDC + const float abs_x = layout.bottom_screen.left + cursor.projected_x; + const float abs_y = layout.bottom_screen.top + cursor.projected_y; +- const float cx = (abs_x / buf_w) * 2.0f - 1.0f; +- const float cy = (abs_y / buf_h) * 2.0f - 1.0f; +- const float ratio = static_cast(layout.bottom_screen.GetHeight()) / 30.0f; +- const float rw = ratio / buf_w; +- const float rh = ratio / buf_h; +- +- // Bottom screen bounds in NDC +- const float bl = (layout.bottom_screen.left / buf_w) * 2.0f - 1.0f; +- const float bt = (layout.bottom_screen.top / buf_h) * 2.0f - 1.0f; +- const float br = (layout.bottom_screen.right / buf_w) * 2.0f - 1.0f; +- const float bb = (layout.bottom_screen.bottom / buf_h) * 2.0f - 1.0f; +- - // Crosshair geometry clamped to bottom screen bounds - const float vl = std::fmax(cx - rw / 5.0f, bl); - const float vr = std::fmin(cx + rw / 5.0f, br); @@ -155,10 +232,16 @@ - - // 12 vertices = 4 triangles (2 for vertical bar, 2 for horizontal bar) - // clang-format off -+ // Stylus silhouette: the nib stays exactly at the touch coordinate. -+ // Use the existing inversion pipeline so it remains visible over the game. -+ const auto px = [&](float v) { return std::clamp(cx + v * rw / 4.0f, bl, br); }; -+ const auto py = [&](float v) { return std::clamp(cy + v * rh / 4.0f, bt, bb); }; ++ const float scale = std::max(1.0f, layout.bottom_screen.GetHeight() / 240.0f); ++ // Clip positions and local coordinates together, so clipping never distorts the pen. ++ const float left = std::max(abs_x - 2 * scale, float(layout.bottom_screen.left)); ++ const float top = std::max(abs_y - 21 * scale, float(layout.bottom_screen.top)); ++ const float right = std::min(abs_x + 17 * scale, float(layout.bottom_screen.right)); ++ const float bottom = std::min(abs_y + 2 * scale, float(layout.bottom_screen.bottom)); ++ const float l = left / buf_w * 2 - 1, r = right / buf_w * 2 - 1; ++ const float t = top / buf_h * 2 - 1, b = bottom / buf_h * 2 - 1; ++ const float u0 = (left - abs_x) / scale, u1 = (right - abs_x) / scale; ++ const float v0 = (top - abs_y) / scale, v1 = (bottom - abs_y) / scale; const float vertices[] = { - // Vertical bar - vl, vt, vr, vt, vr, vb, @@ -168,19 +251,63 @@ - hl, ht, hr, hb, hl, hb, - }; - // clang-format on -+ px(0), py(0), px(1), py(4), px(4), py(1), // nib -+ px(2), py(5), px(5), py(2), px(12), py(9), // shaft -+ px(2), py(5), px(12), py(9), px(9), py(12), ++ l, t, u0, v0, r, t, u1, v0, r, b, u1, v1, ++ l, t, u0, v0, r, b, u1, v1, l, b, u0, v1, + }; const u64 size = sizeof(vertices); auto [data, offset, invalidate] = vertex_buffer.Map(size, 16); -@@ -1119,7 +1106,7 @@ +@@ -1118,8 +1096,8 @@ + scheduler.Record([this, offset = offset, pipeline = cursor_pipeline](vk::CommandBuffer cmdbuf) { cmdbuf.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline); cmdbuf.bindVertexBuffers(0, vertex_buffer.Handle(), {0}); - const u32 first_vertex = static_cast(offset) / (sizeof(float) * 2); +- const u32 first_vertex = static_cast(offset) / (sizeof(float) * 2); - cmdbuf.draw(12, 1, first_vertex, 0); -+ cmdbuf.draw(9, 1, first_vertex, 0); ++ const u32 first_vertex = static_cast(offset) / (sizeof(float) * 4); ++ cmdbuf.draw(6, 1, first_vertex, 0); }); } +--- a/src/video_core/host_shaders/vulkan_cursor.vert ++++ b/src/video_core/host_shaders/vulkan_cursor.vert +@@ -7,6 +7,10 @@ + + layout (location = 0) in vec2 vert_position; + ++layout (location = 1) in vec2 vert_stylus; ++layout (location = 0) out vec2 stylus; ++ + void main() { ++ stylus = vert_stylus; + gl_Position = vec4(vert_position, 0.0, 1.0); + } +--- a/src/video_core/host_shaders/vulkan_cursor.frag ++++ b/src/video_core/host_shaders/vulkan_cursor.frag +@@ -5,8 +5,26 @@ + #version 450 core + #extension GL_ARB_separate_shader_objects : enable + ++layout (location = 0) in vec2 stylus; + layout (location = 0) out vec4 color; + + void main() { +- color = vec4(1.0, 1.0, 1.0, 1.0); ++ // Match DeSmuME's four subpixel samples, including its integer pixel origin. ++ vec2 pixel = fwidth(stylus); ++ vec2 origin = stylus - 0.5 * pixel; ++ color = vec4(0.0); ++ for (int sy = 0; sy < 2; ++sy) { ++ for (int sx = 0; sx < 2; ++sx) { ++ vec2 p = origin + (vec2(sx, sy) - 0.5) * 0.5 * pixel; ++ float along = p.x * 0.6 - p.y * 0.8; ++ float side = p.x * 0.8 + p.y * 0.6; ++ float radius = min(1.6, along * 0.32); ++ float cap = max(0.0, along - 22.0); ++ if (along >= 0.0 && side * side + cap * cap <= radius * radius) { ++ vec3 ink = along > 5.0 && along < 20.5 && abs(side) < radius - 0.45 ++ ? vec3(1.0) : vec3(32.0, 40.0, 48.0) / 255.0; ++ color += vec4(ink, 1.0) * 0.25; ++ } ++ } ++ } + }