buninu-browser 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +90 -1
  3. package/buninu-browser.js +1 -1
  4. package/frontends/wayland/README.md +35 -2
  5. package/frontends/wayland/client.js +8 -6
  6. package/frontends/wayland/clipboard.js +1 -1
  7. package/frontends/wayland/index.js +1 -1
  8. package/frontends/wayland/input.js +1 -1
  9. package/frontends/wayland/resource-io.js +6 -0
  10. package/frontends/wayland/transport-posix.js +120 -0
  11. package/frontends/wayland/transport-win32.js +9 -0
  12. package/frontends/wayland/transport.js +3 -120
  13. package/frontends/wayland/win32-resources.js +80 -0
  14. package/frontends/wayland/win32-wire.js +62 -0
  15. package/lib/audio/biquad.js +25 -0
  16. package/lib/audio/piano.js +6 -3
  17. package/lib/audio/playback-buffer.js +5 -5
  18. package/lib/audio/pulse-client.js +24 -5
  19. package/lib/audio/web-audio.js +570 -0
  20. package/lib/browser-dialog.js +126 -0
  21. package/lib/cdp-server.js +43 -11
  22. package/lib/file-picker.js +48 -0
  23. package/lib/headless-shell.js +34 -12
  24. package/lib/layout/text-layout.js +112 -19
  25. package/lib/media/mp4-frame-times.js +93 -0
  26. package/lib/paint/direct-canvas.ts +39 -0
  27. package/lib/paint/display-list.js +37 -12
  28. package/lib/paint/raster-readback.js +16 -7
  29. package/lib/renderer/animation-frames.js +10 -5
  30. package/lib/renderer/bindings.js +69 -16
  31. package/lib/renderer/canvas-2d.js +265 -15
  32. package/lib/renderer/canvas-round-rect.js +36 -0
  33. package/lib/renderer/css-animations.js +173 -0
  34. package/lib/renderer/dialog-client.js +30 -0
  35. package/lib/renderer/dialog-worker.js +35 -0
  36. package/lib/renderer/eval-environment.js +28 -0
  37. package/lib/renderer/file-input.js +49 -0
  38. package/lib/renderer/host.js +33 -4
  39. package/lib/renderer/html-drag.js +77 -0
  40. package/lib/renderer/image-loading.js +45 -2
  41. package/lib/renderer/main.js +4 -0
  42. package/lib/renderer/module-transform.js +5 -3
  43. package/lib/renderer/page-builtins.js +59 -0
  44. package/lib/renderer/page-realm.js +177 -40
  45. package/lib/renderer/page-renderer.js +397 -49
  46. package/lib/renderer/piano-input.js +3 -1
  47. package/lib/renderer/raster-surface.js +18 -0
  48. package/lib/renderer/script-rewrite.js +165 -9
  49. package/lib/renderer/video-presentation.js +5 -4
  50. package/lib/renderer/webgl/clip.js +24 -0
  51. package/lib/renderer/webgl/context.js +158 -0
  52. package/lib/renderer/webgl/cpu-fragment.js +211 -0
  53. package/lib/renderer/webgl/draw-cache.js +22 -0
  54. package/lib/renderer/webgl/pipeline.js +70 -0
  55. package/lib/renderer/webgl/shaders.js +58 -0
  56. package/lib/renderer/webgl/textures.js +43 -0
  57. package/lib/renderer/window-named-properties.js +46 -0
  58. package/lib/screenshot-cli.js +4 -7
  59. package/lib/style/animation-values.js +29 -0
  60. package/lib/style/computed-style.js +109 -13
  61. package/lib/style/transform.js +44 -0
  62. package/lib/window-size.js +10 -0
  63. package/package.json +1 -1
  64. package/patches/happy-dom/0007-scope-content-models-to-html-namespace.patch +29 -0
  65. package/patches/happy-dom/README.md +16 -0
  66. package/test/animation-frames.test.js +15 -0
  67. package/test/canvas-2d.test.js +252 -4
  68. package/test/canvas-frame-pacing.test.js +7 -0
  69. package/test/cdp-viewers.test.js +36 -0
  70. package/test/classic-games-patches/0001-fix-game-initialization.patch +177 -0
  71. package/test/classic-games-patches/README.md +156 -0
  72. package/test/classic-games-scenarios.js +133 -0
  73. package/test/clipboard.test.js +16 -0
  74. package/test/computed-style.test.js +18 -0
  75. package/test/css-animation-lifecycle.test.js +28 -0
  76. package/test/css-animations.test.js +83 -0
  77. package/test/direct-canvas.test.js +39 -0
  78. package/test/dynamic-inline-handlers.test.js +25 -0
  79. package/test/eval-declarations.test.js +22 -0
  80. package/test/file-picker.test.js +34 -0
  81. package/test/fixtures/canvas-damage.js +23 -0
  82. package/test/fixtures/canvas-frame-pacing.js +35 -0
  83. package/test/fixtures/direct-canvas.js +96 -0
  84. package/test/fixtures/javascript-dialogs.html +8 -0
  85. package/test/fixtures/media-audio-lifetime.js +65 -0
  86. package/test/fixtures/renderer-memory.js +7 -5
  87. package/test/fixtures/video-black-replay.js +8 -6
  88. package/test/fixtures/video-slow-end.js +39 -0
  89. package/test/fixtures/web-audio-renderer.js +45 -0
  90. package/test/flex.test.js +26 -0
  91. package/test/grid.test.js +13 -0
  92. package/test/headless-shell.test.js +9 -0
  93. package/test/headless-window-size.test.js +28 -0
  94. package/test/html-rcdata.test.js +35 -0
  95. package/test/image-decode.test.js +18 -0
  96. package/test/javascript-dialog.test.js +83 -0
  97. package/test/manual-breakout-paddle.js +42 -0
  98. package/test/manual-breakout-static.js +35 -0
  99. package/test/manual-classic-games.js +41 -0
  100. package/test/manual-clumsy-bird-three-pipes.js +81 -0
  101. package/test/manual-clumsy-bird.js +52 -0
  102. package/test/manual-hextris.js +30 -0
  103. package/test/manual-numbered-music.js +24 -0
  104. package/test/manual-t-rex-runner.js +49 -0
  105. package/test/manual-threejs-scene.js +64 -0
  106. package/test/manual-webgl-benchmark.js +27 -0
  107. package/test/manual-webgl-cpu.html +26 -0
  108. package/test/media-audio-lifetime.test.js +8 -0
  109. package/test/mp4-frame-times.test.js +33 -0
  110. package/test/page-scripts.test.js +96 -1
  111. package/test/piano-input.test.js +15 -2
  112. package/test/piano.test.js +11 -2
  113. package/test/playback-buffer.test.js +11 -0
  114. package/test/pointer-drag.test.js +33 -0
  115. package/test/positioning.test.js +82 -0
  116. package/test/pulse-client.test.js +51 -2
  117. package/test/raster-readback.test.js +14 -0
  118. package/test/script-rewrite.test.js +61 -3
  119. package/test/video-slow-end.test.js +6 -0
  120. package/test/viewport-keyboard-overflow.test.js +25 -0
  121. package/test/wayland-win32-resources.test.js +47 -0
  122. package/test/wayland-win32-wire.test.js +59 -0
  123. package/test/web-audio-renderer.test.js +8 -0
  124. package/test/web-audio.test.js +264 -0
  125. package/test/webgl-clip.test.js +9 -0
  126. package/test/webgl-cpu-fragment.test.js +43 -0
  127. package/test/window-named-properties.test.js +41 -0
  128. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfig.ts +2 -2
  129. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts +1 -0
  130. package/vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts +23 -3
  131. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +1 -1
  132. package/vendor/land-renderer/LICENSE +21 -0
  133. package/vendor/land-renderer/README.md +14 -0
  134. package/vendor/land-renderer/direct-compositor.ts +443 -0
  135. package/vendor/land-renderer/frame.js +88 -0
  136. package/vendor/land-renderer/png-fast.js +85 -0
  137. package/vendor/land-renderer/raw-frames.js +66 -0
  138. package/vendor/land-renderer/render-types.ts +7 -0
  139. package/vendor/land-renderer/renderer.ts +134 -0
  140. package/vendor/land-renderer/snapshot.js +37 -0
  141. package/vendor/land-renderer/viewport.js +18 -0
  142. package/vendor/ses/dist/ses.mjs +1 -1
@@ -1,6 +1,6 @@
1
1
  import { beforeAll, expect, test } from "bun:test";
2
2
  import CanvasKitInit from "../usr/lib/canvaskit/canvaskit.js";
3
- import { canvasContext, resetCanvas, setCanvasKit, disposeDocumentCanvases } from "../lib/renderer/canvas-2d.js";
3
+ import { canvasResource, canvasContext, resetCanvas, setCanvasKit, disposeDocumentCanvases } from "../lib/renderer/canvas-2d.js";
4
4
 
5
5
  let CanvasKit;
6
6
 
@@ -38,10 +38,12 @@ test("a colour reads back stably, and one CanvasKit cannot parse is black", () =
38
38
  expect(context.fillStyle).toBe("#000000");
39
39
  expect(sample(context, "not-a-color")).toEqual([0, 0, 0, 255]);
40
40
  expect(sample(context, "red")).toEqual([255, 0, 0, 255]);
41
- expect(sample(context, "rgba(1, 2, 3, 0.5)")).toEqual([1, 2, 3, 128]);
41
+ // Like Chromium, an 8-bit premultiplied backing store rounds RGB at low alpha.
42
+ expect(sample(context, "rgba(1, 2, 3, 0.5)")).toEqual([2, 2, 4, 128]);
42
43
  expect(context.getImageData(0, 0, 1, 1).data.length).toBe(4);
43
44
  expect(context.getImageData(0, 0, 1, 1).data[3]).toBe(0);
44
- expect(canvasContext(canvas(), "webgl")).toBeNull();
45
+ expect(canvasContext(canvas(), "webgl2")).toBeNull();
46
+ expect(canvasContext(canvas(), "webgl").getParameter(0x1F01)).toBe("CanvasKit CPU");
45
47
  });
46
48
 
47
49
  test("the same canvas keeps one context, and changing width clears it", () => {
@@ -93,7 +95,7 @@ test("gradient and pattern facades hide native shaders while style identity and
93
95
  expect(context.strokeStyle).toBe(gradient);
94
96
  const radial = context.createRadialGradient(0, 0, 0, 1, 1, 2);
95
97
  expect(Object.keys(radial)).toEqual(["addColorStop"]);
96
- const pattern = context.createPattern({}, "repeat");
98
+ const pattern = context.createPattern(node, "repeat");
97
99
  expect(Object.keys(pattern)).toEqual(["setTransform"]);
98
100
  for (const name of ["Td", "qf", "pe", "ie", "oe"]) expect(pattern[name]).toBeUndefined();
99
101
  resetCanvas(node);
@@ -134,3 +136,249 @@ test("discarding a document frees its connected and detached canvas pixel buffer
134
136
  CanvasKit._free = free;
135
137
  }
136
138
  });
139
+
140
+
141
+ test("CPU WebGL redraws moved geometry without painting outside its coverage", () => {
142
+ const node=canvas(64,64),gl=canvasContext(node,'webgl'),program=gl.createProgram();
143
+ for(const [type,source] of [[gl.VERTEX_SHADER,'attribute vec2 position; uniform float dx; varying vec3 color; void main(){color=vec3(1.0,0.0,0.0);gl_Position=vec4(position+vec2(dx,0.0),0.0,1.0);}'],
144
+ [gl.FRAGMENT_SHADER,'precision highp float; varying vec3 color; void main(){gl_FragColor=vec4(color,1.0);}']]){
145
+ const shader=gl.createShader(type);gl.shaderSource(shader,source);gl.compileShader(shader);
146
+ expect(gl.getShaderInfoLog(shader)).toBe('');gl.attachShader(program,shader);
147
+ }
148
+ gl.linkProgram(program);gl.useProgram(program);
149
+ gl.bindBuffer(gl.ARRAY_BUFFER,gl.createBuffer());
150
+ gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-.75,-.5,-.25,-.5,-.5,.5]),gl.STATIC_DRAW);
151
+ const a=gl.getAttribLocation(program,'position');gl.enableVertexAttribArray(a);gl.vertexAttribPointer(a,2,gl.FLOAT,false,0,0);
152
+ const dx=gl.getUniformLocation(program,'dx');
153
+ const pixel=(x,y)=>Array.from(canvasResource(node).image.readPixels(x,y,{width:1,height:1,colorType:CanvasKit.ColorType.RGBA_8888,alphaType:CanvasKit.AlphaType.Unpremul,colorSpace:CanvasKit.ColorSpace.SRGB}));
154
+ gl.clearColor(0,0,1,1);gl.clear(gl.COLOR_BUFFER_BIT);gl.drawArrays(gl.TRIANGLES,0,3);
155
+ expect(pixel(16,32)).toEqual([255,0,0,255]);expect(pixel(48,32)).toEqual([0,0,255,255]);
156
+ gl.uniform1f(dx,1);gl.clear(gl.COLOR_BUFFER_BIT);gl.drawArrays(gl.TRIANGLES,0,3);
157
+ expect(pixel(16,32)).toEqual([0,0,255,255]);expect(pixel(48,32)).toEqual([255,0,0,255]);
158
+ gl.uniform1f(dx,4);gl.clear(gl.COLOR_BUFFER_BIT);gl.drawArrays(gl.TRIANGLES,0,3);
159
+ expect(pixel(48,32)).toEqual([0,0,255,255]);expect(gl.getError()).toBe(0);
160
+ });
161
+
162
+ test('CPU WebGL samples uploaded RGBA textures and preserves upload snapshots',()=>{
163
+ const node=canvas(16,16),gl=canvasContext(node,'webgl'),p=gl.createProgram();
164
+ for(const [kind,source] of [[gl.VERTEX_SHADER,'attribute vec2 p; varying vec2 uv; void main(){gl_Position=vec4(p,0.,1.);uv=(p+1.)*.5;}'],[gl.FRAGMENT_SHADER,'precision highp float; varying vec2 uv; uniform sampler2D image; void main(){gl_FragColor=texture2D(image,uv);}']]){
165
+ const s=gl.createShader(kind);gl.shaderSource(s,source);gl.compileShader(s);expect(gl.getShaderInfoLog(s)).toBe('');gl.attachShader(p,s);
166
+ }
167
+ gl.linkProgram(p);gl.useProgram(p);gl.bindBuffer(gl.ARRAY_BUFFER,gl.createBuffer());gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);gl.enableVertexAttribArray(0);gl.vertexAttribPointer(0,2,gl.FLOAT,false,0,0);
168
+ gl.bindTexture(gl.TEXTURE_2D,gl.createTexture());gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.NEAREST);
169
+ gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,1,2,0,gl.RGBA,gl.UNSIGNED_BYTE,new Uint8Array([255,0,0,255,0,0,255,255]));gl.drawArrays(gl.TRIANGLES,0,6);
170
+ const read=y=>Array.from(canvasResource(node).image.readPixels(8,y,{width:1,height:1,colorType:CanvasKit.ColorType.RGBA_8888,alphaType:CanvasKit.AlphaType.Unpremul,colorSpace:CanvasKit.ColorSpace.SRGB}));
171
+ expect(read(2)).toEqual([0,0,255,255]);expect(read(13)).toEqual([255,0,0,255]);expect(gl.getError()).toBe(0);
172
+ });
173
+
174
+ test('cached background shading follows moving depth occluders and LEQUAL preserves coplanar copies',()=>{
175
+ const node=canvas(32,32),gl=canvasContext(node,'webgl'),p=gl.createProgram();
176
+ for(const [kind,source]of[[gl.VERTEX_SHADER,'attribute vec2 p;uniform float dx;uniform float z;void main(){gl_Position=vec4(p+vec2(dx,0.),z,1.);}'],[gl.FRAGMENT_SHADER,'precision highp float;uniform vec4 color;void main(){gl_FragColor=color;}']]){
177
+ const sh=gl.createShader(kind);gl.shaderSource(sh,source);gl.compileShader(sh);expect(gl.getShaderInfoLog(sh)).toBe('');gl.attachShader(p,sh);
178
+ }
179
+ gl.linkProgram(p);gl.useProgram(p);const buffer=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,buffer);gl.enableVertexAttribArray(0);gl.vertexAttribPointer(0,2,gl.FLOAT,false,0,0);
180
+ gl.enable(gl.DEPTH_TEST);gl.depthFunc(gl.LEQUAL);
181
+ const dx=gl.getUniformLocation(p,'dx'),z=gl.getUniformLocation(p,'z'),color=gl.getUniformLocation(p,'color');
182
+ const quad=(left,right,offset,depth,rgba)=>{gl.uniform1f(dx,offset);gl.uniform1f(z,depth);gl.uniform4fv(color,rgba);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([left,-1,right,-1,left,1,left,1,right,-1,right,1]),gl.STATIC_DRAW);gl.drawArrays(gl.TRIANGLES,0,6);};
183
+ const pixel=x=>Array.from(canvasResource(node).image.readPixels(x,16,{width:1,height:1,colorType:CanvasKit.ColorType.RGBA_8888,alphaType:CanvasKit.AlphaType.Unpremul,colorSpace:CanvasKit.ColorSpace.SRGB}));
184
+ for(const offset of[0,1,0]){
185
+ gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
186
+ quad(-1,0,offset,-.123456789,[1,0,0,1]);quad(-1,1,0,.3,[0,0,1,1]);
187
+ expect(pixel(offset?24:8)).toEqual([255,0,0,255]);expect(pixel(offset?8:24)).toEqual([0,0,255,255]);
188
+ }
189
+ gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
190
+ quad(-1,1,0,.123456789,[1,0,0,1]);quad(-1,1,0,.123456789,[0,1,0,1]);
191
+ const image=canvasResource(node).image.readPixels(0,0,{width:32,height:32,colorType:CanvasKit.ColorType.RGBA_8888,alphaType:CanvasKit.AlphaType.Unpremul,colorSpace:CanvasKit.ColorSpace.SRGB});
192
+ for(let i=0;i<image.length;i+=4)expect(Array.from(image.slice(i,i+4))).toEqual([0,255,0,255]);expect(gl.getError()).toBe(0);
193
+ });
194
+
195
+ test('texture alpha is unpremultiplied in both numeric and CanvasKit fragment paths',()=>{
196
+ for(const native of[false,true]){
197
+ const node=canvas(8,8),gl=canvasContext(node,'webgl'),p=gl.createProgram();
198
+ for(const [kind,source]of[[gl.VERTEX_SHADER,'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}'],[gl.FRAGMENT_SHADER,`precision highp float;uniform sampler2D image;void main(){vec4 c=texture2D(image,vec2(.5));${native?'for(int i=0;i<1;i++){c.r+=0.;}':''}gl_FragColor=c;}`]]){
199
+ const sh=gl.createShader(kind);gl.shaderSource(sh,source);gl.compileShader(sh);expect(gl.getShaderInfoLog(sh)).toBe('');gl.attachShader(p,sh);
200
+ }
201
+ gl.linkProgram(p);gl.useProgram(p);gl.bindBuffer(gl.ARRAY_BUFFER,gl.createBuffer());gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);gl.enableVertexAttribArray(0);gl.vertexAttribPointer(0,2,gl.FLOAT,false,0,0);
202
+ gl.bindTexture(gl.TEXTURE_2D,gl.createTexture());gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,1,1,0,gl.RGBA,gl.UNSIGNED_BYTE,new Uint8Array([200,100,50,128]));
203
+ gl.clearColor(0,0,1,1);gl.clear(gl.COLOR_BUFFER_BIT);gl.drawArrays(gl.TRIANGLES,0,6);
204
+ const pixel=canvasResource(node).image.readPixels(2,2,{width:1,height:1,colorType:CanvasKit.ColorType.RGBA_8888,alphaType:CanvasKit.AlphaType.Unpremul,colorSpace:CanvasKit.ColorSpace.SRGB});
205
+ for(let i=0;i<4;i++)expect(Math.abs(pixel[i]-[100,50,152,255][i])).toBeLessThanOrEqual(1);
206
+ }
207
+ });
208
+
209
+ test('patterns snapshot actual image sources and survive source drawing and bitmap resets',()=>{
210
+ const document={},source={...canvas(2,2),ownerDocument:document},target={...canvas(4,4),ownerDocument:document};
211
+ const src=canvasContext(source,'2d'),dst=canvasContext(target,'2d');
212
+ try{
213
+ src.fillStyle='red';src.fillRect(0,0,2,2);
214
+ const pattern=dst.createPattern(source,'repeat');
215
+ src.fillStyle='blue';src.fillRect(0,0,2,2);resetCanvas(source);
216
+ dst.fillStyle=pattern;dst.fillRect(0,0,4,4);
217
+ expect([...dst.getImageData(3,3,1,1).data]).toEqual([255,0,0,255]);
218
+ resetCanvas(target);dst.fillStyle=pattern;dst.fillRect(0,0,4,4);
219
+ expect([...dst.getImageData(3,3,1,1).data]).toEqual([255,0,0,255]);
220
+ expect(()=>dst.createPattern({},'repeat')).toThrow(TypeError);
221
+ expect(()=>dst.createPattern(source,'invalid')).toThrow();
222
+ }finally{disposeDocumentCanvases(document);}
223
+ });
224
+
225
+ test('pattern sampling follows the drawing context and survives save/restore and reset',()=>{
226
+ const source=canvas(2,1),src=canvasContext(source,'2d');
227
+ src.fillStyle='red';src.fillRect(0,0,1,1);
228
+ src.fillStyle='blue';src.fillRect(1,0,1,1);
229
+ const target=canvas(8,2),ctx=canvasContext(target,'2d');
230
+ const pattern=src.createPattern(source,'repeat');
231
+ pattern.setTransform({a:4,b:0,c:0,d:1,e:0,f:0});
232
+ ctx.fillStyle=pattern;
233
+ expect(ctx.imageSmoothingEnabled).toBe(true);
234
+ expect(ctx.imageSmoothingQuality).toBe('low');
235
+ ctx.imageSmoothingEnabled=false;
236
+ ctx.save();ctx.imageSmoothingEnabled=true;ctx.imageSmoothingQuality='high';ctx.restore();
237
+ expect(ctx.imageSmoothingEnabled).toBe(false);
238
+ expect(ctx.imageSmoothingQuality).toBe('low');
239
+ ctx.fillRect(0,0,8,1);
240
+ expect([...ctx.getImageData(3,0,1,1).data]).toEqual([255,0,0,255]);
241
+ ctx.imageSmoothingEnabled=true;ctx.fillRect(0,1,8,1);
242
+ const blended=ctx.getImageData(3,1,1,1).data;
243
+ expect(blended[0]).toBeGreaterThan(0);expect(blended[2]).toBeGreaterThan(0);
244
+ resetCanvas(target);
245
+ expect(ctx.imageSmoothingEnabled).toBe(true);
246
+ expect(ctx.imageSmoothingQuality).toBe('low');
247
+ });
248
+
249
+ test('drawImage honors smoothing and zero destination width',()=>{
250
+ const source=canvas(2,1),src=canvasContext(source,'2d');
251
+ src.fillStyle='red';src.fillRect(0,0,1,1);src.fillStyle='blue';src.fillRect(1,0,1,1);
252
+ const target=canvas(8,3),ctx=canvasContext(target,'2d',{resolveImage:canvasResource});
253
+ ctx.imageSmoothingEnabled=false;ctx.drawImage(source,0,0,8,1);
254
+ expect([...ctx.getImageData(3,0,1,1).data]).toEqual([255,0,0,255]);
255
+ ctx.imageSmoothingEnabled=true;ctx.drawImage(source,0,1,8,1);
256
+ const blended=ctx.getImageData(3,1,1,1).data;
257
+ expect(blended[0]).toBeGreaterThan(0);expect(blended[2]).toBeGreaterThan(0);
258
+ ctx.drawImage(source,0,2,0,1);
259
+ expect([...ctx.getImageData(0,2,1,1).data]).toEqual([0,0,0,0]);
260
+ });
261
+
262
+ test('pixel-aligned pattern blits match shader fills including clips, alpha and repetition limits',()=>{
263
+ const source=canvas(3,3),src=canvasContext(source,'2d');
264
+ src.fillStyle='rgba(200,40,20,.5)';src.fillRect(0,0,3,3);
265
+ src.fillStyle='blue';src.fillRect(1,1,1,1);
266
+ for(const repetition of ['repeat','repeat-x','repeat-y','no-repeat']){
267
+ const outputs=[];
268
+ for(const smooth of [false,true]){
269
+ const target=canvas(8,6),ctx=canvasContext(target,'2d');
270
+ ctx.fillStyle='green';ctx.fillRect(0,0,8,6);
271
+ ctx.imageSmoothingEnabled=smooth;ctx.globalAlpha=.6;
272
+ const pattern=ctx.createPattern(source,repetition);
273
+ pattern.setTransform({a:1,b:0,c:0,d:1,e:-1,f:1});ctx.fillStyle=pattern;
274
+ ctx.save();ctx.beginPath();ctx.rect(1,1,6,4);ctx.clip();ctx.translate(1,-1);
275
+ ctx.fillRect(8,7,-12,-10);ctx.restore();
276
+ outputs.push([...ctx.getImageData(0,0,8,6).data]);
277
+ }
278
+ expect(outputs[0]).toEqual(outputs[1]);
279
+ }
280
+ });
281
+
282
+ test('a failed Canvas context constructor releases its raster and restores the factory',()=>{
283
+ const makeCanvas=CanvasKit.MakeCanvas,makeSurface=CanvasKit.MakeSurface,free=CanvasKit.Free;
284
+ let released=0;
285
+ CanvasKit.Free=allocation=>{released++;return free(allocation);};
286
+ CanvasKit.MakeCanvas=(width,height)=>{CanvasKit.MakeSurface(width,height);throw Error('context construction failed');};
287
+ try{
288
+ expect(()=>canvasContext(canvas(4,4),'2d')).toThrow('context construction failed');
289
+ expect(released).toBe(1);
290
+ expect(CanvasKit.MakeSurface).toBe(makeSurface);
291
+ }finally{CanvasKit.MakeCanvas=makeCanvas;CanvasKit.Free=free;}
292
+ });
293
+
294
+ test('drawImage ignores fill color and shaders while preserving globalAlpha and fill state',()=>{
295
+ const document={},source={ownerDocument:document,width:2,height:1},target={ownerDocument:document,width:2,height:1};
296
+ const src=canvasContext(source,'2d'),ctx=canvasContext(target,'2d',{resolveImage:canvasResource});
297
+ try{
298
+ src.fillStyle='red';src.fillRect(0,0,2,1);
299
+ const gradient=ctx.createLinearGradient(0,0,2,0);gradient.addColorStop(0,'transparent');gradient.addColorStop(1,'blue');
300
+ const pattern=ctx.createPattern(source,'repeat');
301
+ for(const style of ['rgba(0,0,255,0)', 'rgba(0,0,255,.25)',gradient,pattern]){
302
+ ctx.clearRect(0,0,2,1);ctx.fillStyle=style;const before=ctx.fillStyle;ctx.globalAlpha=1;ctx.drawImage(source,0,0);
303
+ expect([...ctx.getImageData(0,0,1,1).data]).toEqual([255,0,0,255]);expect(ctx.fillStyle).toBe(before);
304
+ ctx.clearRect(0,0,2,1);ctx.globalAlpha=.5;ctx.drawImage(source,0,0,2,1);
305
+ expect([...ctx.getImageData(0,0,1,1).data]).toEqual([255,0,0,128]);
306
+ ctx.save();ctx.fillStyle='green';ctx.restore();ctx.clearRect(0,0,2,1);ctx.drawImage(source,0,0);
307
+ expect([...ctx.getImageData(0,0,1,1).data]).toEqual([255,0,0,128]);
308
+ }
309
+ }finally{disposeDocumentCanvases(document)}
310
+ });
311
+
312
+ test('image paints keep blend state across restore without constructing fill-pattern shaders',()=>{
313
+ const document={},source={ownerDocument:document,width:2,height:1},target={ownerDocument:document,width:2,height:1};
314
+ const src=canvasContext(source,'2d'),ctx=canvasContext(target,'2d',{resolveImage:canvasResource});
315
+ const make=CanvasKit.Image.prototype.makeShaderOptions;let shaders=0;
316
+ try{
317
+ src.fillStyle='red';src.fillRect(0,0,2,1);ctx.fillStyle=ctx.createPattern(source,'repeat');
318
+ CanvasKit.Image.prototype.makeShaderOptions=function(...args){shaders++;return make.apply(this,args)};
319
+ for(let i=0;i<3;i++){ctx.save();ctx.globalAlpha=.5;ctx.globalCompositeOperation='copy';ctx.drawImage(source,0,0);ctx.restore();}
320
+ expect(shaders).toBe(0);
321
+ expect([...ctx.getImageData(0,0,1,1).data]).toEqual([255,0,0,128]);
322
+ ctx.globalAlpha=1;ctx.globalCompositeOperation='source-over';ctx.drawImage(source,0,0);
323
+ expect([...ctx.getImageData(0,0,1,1).data]).toEqual([255,0,0,255]);
324
+ }finally{CanvasKit.Image.prototype.makeShaderOptions=make;disposeDocumentCanvases(document)}
325
+ });
326
+
327
+ test('roundRect draws rounded corners, mirrors negative dimensions and rejects invalid radii',()=>{
328
+ const context=canvasContext(canvas(40,40),'2d');
329
+ expect('roundRect' in context).toBe(true);
330
+ context.fillStyle='red';context.beginPath();context.roundRect(2,2,30,30,8);context.fill();
331
+ expect(context.getImageData(2,2,1,1).data[3]).toBe(0);
332
+ expect([...context.getImageData(17,17,1,1).data]).toEqual([255,0,0,255]);
333
+ expect(context.getImageData(17,3,1,1).data[3]).toBeGreaterThan(200);
334
+ context.clearRect(0,0,40,40);context.beginPath();context.roundRect(32,2,-30,30,[{x:8,y:12},0,0,0]);context.fill();
335
+ expect(context.getImageData(31,2,1,1).data[3]).toBe(0);
336
+ expect(context.getImageData(2,2,1,1).data[3]).toBeGreaterThan(200);
337
+ expect(()=>context.roundRect(0,0,20,20,-1)).toThrow(RangeError);
338
+ expect(()=>context.roundRect(0,0,20,20,[])).toThrow(RangeError);
339
+ expect(()=>context.roundRect(0,0,20,20,[1,2,3,4,5])).toThrow(RangeError);
340
+ context.clearRect(0,0,40,40);context.beginPath();context.roundRect(2,2,10,10,50);context.fill();
341
+ expect(context.getImageData(7,7,1,1).data[3]).toBeGreaterThan(200);
342
+ });
343
+
344
+ test('cached radial rectangles preserve transforms, clip, alpha and mutable stops',()=>{
345
+ for(const scale of [1,1.5]){
346
+ const a=canvas(40,40),b=canvas(40,40),ca=canvasContext(a,'2d'),cb=canvasContext(b,'2d');
347
+ const gradients=[];
348
+ for(const c of [ca,cb]){
349
+ c.translate(.25,.5);c.scale(scale,scale);
350
+ c.beginPath();c.rect(1,2,20,18);c.clip();
351
+ const g=c.createRadialGradient(9,10,0,9,10,20);
352
+ g.addColorStop(0,'red');g.addColorStop(1,'transparent');c.fillStyle=g;gradients.push(g);
353
+ }
354
+ for(let i=0;i<3;i++){
355
+ if(i===2)for(const g of gradients)g.addColorStop(.5,'blue');
356
+ for(const c of [ca,cb]){c.clearRect(-5,-5,60,60);c.globalAlpha=.4;}
357
+ ca.fillRect(.5,1.25,22,20);
358
+ cb.beginPath();cb.rect(.5,1.25,22,20);cb.fill();
359
+ const x=ca.getImageData(0,0,40,40).data,y=cb.getImageData(0,0,40,40).data;
360
+ for(let j=0;j<x.length;j+=4){
361
+ expect(Math.abs(x[j+3]-y[j+3])).toBeLessThanOrEqual(1);
362
+ for(let k=0;k<3;k++)expect(Math.abs(x[j+k]*x[j+3]/255-y[j+k]*y[j+3]/255)).toBeLessThanOrEqual(2);
363
+ }
364
+ }
365
+ for(const node of [a,b]){node.width=1;resetCanvas(node);}
366
+ }
367
+ });
368
+
369
+ test('vertical gradient strips match full shading under scaling and clipping',()=>{
370
+ for(const scale of [1,1.5]){
371
+ const nodes=[canvas(36,36),canvas(36,36)],contexts=nodes.map(n=>canvasContext(n,'2d'));
372
+ for(const c of contexts){
373
+ c.scale(scale,scale);c.beginPath();c.rect(2,3,18,17);c.clip();
374
+ const g=c.createLinearGradient(0,0,0,24);g.addColorStop(0,'red');g.addColorStop(.4,'rgba(0,255,0,.4)');g.addColorStop(1,'blue');c.fillStyle=g;c.globalAlpha=.6;
375
+ }
376
+ contexts[0].fillRect(0,0,24,24);contexts[1].beginPath();contexts[1].rect(0,0,24,24);contexts[1].fill();
377
+ const [x,y]=contexts.map(c=>c.getImageData(0,0,36,36).data);
378
+ for(let j=0;j<x.length;j+=4){
379
+ expect(Math.abs(x[j+3]-y[j+3])).toBeLessThanOrEqual(1);
380
+ for(let k=0;k<3;k++)expect(Math.abs(x[j+k]*x[j+3]/255-y[j+k]*y[j+3]/255)).toBeLessThanOrEqual(2);
381
+ }
382
+ for(const n of nodes){n.width=1;resetCanvas(n);}
383
+ }
384
+ });
@@ -0,0 +1,7 @@
1
+ import {test,expect} from 'bun:test';
2
+ test('a slow earlier layout does not throttle later Canvas-only frames',async()=>{
3
+ const p=Bun.spawn([process.execPath,new URL('./fixtures/canvas-frame-pacing.js',import.meta.url).pathname],{stdout:'pipe',stderr:'pipe'});
4
+ const [exit,stderr,stdout]=await Promise.all([p.exited,new Response(p.stderr).text(),new Response(p.stdout).text()]);
5
+ expect({exit,stderr}).toEqual({exit:0,stderr:''});
6
+ expect(JSON.parse(stdout).layoutsDuringAnimation).toBe(0);
7
+ },15000);
@@ -41,3 +41,39 @@ for(const direct of [false,true])test(`independent viewer ACK, stop and disconne
41
41
  }
42
42
  }finally{for(const c of clients)c.ws.close();server.stop(true);}
43
43
  });
44
+
45
+ test('a key-up received during slow key-down IPC is not overwritten by its completion',async()=>{
46
+ const calls=[];let release;
47
+ const server=CdpServer.create({press:(key,options)=>{
48
+ calls.push([key,options.eventType]);
49
+ if(calls.length===1)return new Promise(resolve=>{release=resolve});
50
+ }}).listen(0);
51
+ let client;
52
+ try{
53
+ client=await connect(`ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
54
+ const {targetId}=await client.send('Target.createTarget',{url:'about:blank'});
55
+ const {sessionId}=await client.send('Target.attachToTarget',{targetId,flatten:true});
56
+ const send=type=>client.send('Input.dispatchKeyEvent',{type,key:'w',code:'KeyW'},sessionId);
57
+ const down=send('keyDown');await until(()=>calls.length===1);
58
+ await send('keyUp');release();await down;
59
+ await send('keyDown');await send('keyUp');
60
+ expect(calls).toEqual([['w','keydown'],['w','keyup'],['w','keydown'],['w','keyup']]);
61
+ }finally{release?.();client?.ws.close();server.stop(true);}
62
+ });
63
+
64
+ test('CDP keeps independent held keys and matches releases even when modifiers change',async()=>{
65
+ const calls=[];
66
+ const server=CdpServer.create({press:async(key,options)=>calls.push([key,options.eventType])}).listen(0);
67
+ let client;
68
+ try{
69
+ client=await connect(`ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
70
+ const {targetId}=await client.send('Target.createTarget',{url:'about:blank'});
71
+ const {sessionId}=await client.send('Target.attachToTarget',{targetId,flatten:true});
72
+ const send=(type,key,modifiers=0)=>client.send('Input.dispatchKeyEvent',{type,key,code:'Key'+key.toUpperCase(),modifiers},sessionId);
73
+ await send('keyDown','a');await send('keyDown','b',1);await send('keyUp','a');
74
+ await send('char','b',1); // A's release must not make B's char a second press.
75
+ await client.send('Input.dispatchKeyEvent',{type:'char',text:'b'},sessionId); // code may be omitted
76
+ await send('keyUp','b',0);await send('keyDown','b',1);await send('keyUp','b');
77
+ expect(calls).toEqual([['a','keydown'],['b','keydown'],['a','keyup'],['b','keyup'],['b','keydown'],['b','keyup']]);
78
+ }finally{client?.ws.close();server.stop(true);}
79
+ });
@@ -0,0 +1,177 @@
1
+ diff --git a/games/breakout/index.html b/games/breakout/index.html
2
+ index 3fc45d2..1b11750 100644
3
+ --- a/games/breakout/index.html
4
+ +++ b/games/breakout/index.html
5
+ @@ -240,6 +240,10 @@ canvas{display:block;width:100%;height:100%;object-fit:contain;touch-action:mani
6
+ let score=0,lives=MAX_LIVES;
7
+ let paddleX,ballX,ballY,ballVX,ballVY;
8
+ let bricks=[];
9
+ + const staticLayer=document.createElement('canvas');
10
+ + staticLayer.width=W;staticLayer.height=H;
11
+ + const staticContext=staticLayer.getContext('2d');
12
+ + let staticLayerDirty=true;
13
+ let particles=[];
14
+ let ballSpeed=4.5;
15
+ let ballLaunched=false;
16
+ @@ -250,6 +254,7 @@ canvas{display:block;width:100%;height:100%;object-fit:contain;touch-action:mani
17
+ let highScore=0;
18
+
19
+ function initBricks(){
20
+ + staticLayerDirty=true;
21
+ bricks=[];
22
+ for(let r=0;r<BRICK_ROWS;r++){
23
+ for(let c=0;c<BRICK_COLS;c++){
24
+ @@ -331,10 +336,11 @@ canvas{display:block;width:100%;height:100%;object-fit:contain;touch-action:mani
25
+ }
26
+
27
+ // ---- UPDATE ----
28
+ - function update(){
29
+ + function update(elapsed){
30
+ if(state==='playing'&&mouseX!==null){
31
+ const target=mouseX-PADDLE_W/2;
32
+ - const speed=8;
33
+ + // Preserve 8px/frame at 60 Hz without slowing mouse tracking at low FPS.
34
+ + const speed=480*elapsed;
35
+ if(Math.abs(paddleX-target)>2){
36
+ if(paddleX<target) paddleX+=Math.min(speed,target-paddleX);
37
+ else paddleX-=Math.min(speed,paddleX-target);
38
+ @@ -386,6 +392,7 @@ canvas{display:block;width:100%;height:100%;object-fit:contain;touch-action:mani
39
+ const bx=b.x,by=b.y,bw=b.w,bh=b.h;
40
+ if(ballX+BALL_SIZE>bx&&ballX<bx+bw&&ballY+BALL_SIZE>by&&ballY<by+bh){
41
+ b.alive=false;
42
+ + staticLayerDirty=true;
43
+ const cx=bx+bw/2,cy=by+bh/2;
44
+ emitParticles(cx,cy,12,b.color.fill);
45
+ sfxBrick(330+b.row*60);
46
+ @@ -462,7 +469,7 @@ canvas{display:block;width:100%;height:100%;object-fit:contain;touch-action:mani
47
+ }
48
+
49
+ // ---- DRAW ----
50
+ - function draw(){
51
+ + function redrawStaticLayer(ctx){
52
+ ctx.clearRect(0,0,W,H);
53
+
54
+ // Grid dots
55
+ @@ -490,6 +497,13 @@ canvas{display:block;width:100%;height:100%;object-fit:contain;touch-action:mani
56
+ ctx.restore();
57
+ }
58
+
59
+ + }
60
+ +
61
+ + function draw(){
62
+ + if(staticLayerDirty){redrawStaticLayer(staticContext);staticLayerDirty=false;}
63
+ + ctx.clearRect(0,0,W,H);
64
+ + ctx.drawImage(staticLayer,0,0);
65
+ +
66
+ // Paddle
67
+ ctx.save();
68
+ ctx.shadowColor='rgba(255,107,107,0.5)';
69
+ @@ -605,8 +619,12 @@ canvas{display:block;width:100%;height:100%;object-fit:contain;touch-action:mani
70
+ }
71
+
72
+ // ---- LOOP ----
73
+ - function loop(){
74
+ - update();
75
+ + let lastFrameTime=null;
76
+ + function loop(time=performance.now()){
77
+ + // Bound catch-up after a background tab or a long suspended frame.
78
+ + const elapsed=lastFrameTime===null?1/60:Math.min(0.1,Math.max(0,(time-lastFrameTime)/1000));
79
+ + lastFrameTime=time;
80
+ + update(elapsed);
81
+ updateParticles();
82
+ draw();
83
+ animId=requestAnimationFrame(loop);
84
+ diff --git a/games/mine/index.html b/games/mine/index.html
85
+ index 01e972d..91b0e32 100644
86
+ --- a/games/mine/index.html
87
+ +++ b/games/mine/index.html
88
+ @@ -415,6 +415,8 @@ board.addEventListener('click',e=>{
89
+
90
+ board.addEventListener('contextmenu',e=>{
91
+ e.preventDefault();
92
+ + if(paused||over||won)return;
93
+ + const g=getCell(e);if(g)toggleFlag(g.row,g.col);
94
+ });
95
+
96
+ board.addEventListener('mousedown',e=>{
97
+ diff --git a/games/puzzle15/index.html b/games/puzzle15/index.html
98
+ index 42f278e..5b0fa02 100644
99
+ --- a/games/puzzle15/index.html
100
+ +++ b/games/puzzle15/index.html
101
+ @@ -188,7 +188,7 @@ h1{
102
+ function findTile(val){
103
+ for(let r=0;r<gridSize;r++)
104
+ for(let c=0;c<gridSize;c++)
105
+ - if(grid[r][c]===val)return{r,c};
106
+ + if(grid[r][c]===val)return{row:r,col:c};
107
+ return null;
108
+ }
109
+
110
+ diff --git a/games/snake/index.html b/games/snake/index.html
111
+ index 2f6919e..af005eb 100644
112
+ --- a/games/snake/index.html
113
+ +++ b/games/snake/index.html
114
+ @@ -166,8 +166,7 @@ canvas{display:block;width:100%;height:100%;image-rendering:pixelated;touch-acti
115
+
116
+ // ---- DIRECTIONS ----
117
+ const UP={x:0,y:-1},DOWN={x:0,y:1},LEFT={x:-1,y:0},RIGHT={x:1,y:0};
118
+ - const opposite={};
119
+ - opposite[UP]=DOWN;opposite[DOWN]=UP;opposite[LEFT]=RIGHT;opposite[RIGHT]=LEFT;
120
+ + const opposite=new Map([[UP,DOWN],[DOWN,UP],[LEFT,RIGHT],[RIGHT,LEFT]]);
121
+
122
+ // ---- PARTICLES ----
123
+ let particles=[];
124
+ @@ -684,8 +683,8 @@ canvas{display:block;width:100%;height:100%;image-rendering:pixelated;touch-acti
125
+
126
+ // ---- INPUT ----
127
+ function setDirection(dir){
128
+ - if(!opposite[dir])return;
129
+ - if(dir===opposite[nextDirection])return;
130
+ + if(!opposite.has(dir))return;
131
+ + if(dir===opposite.get(direction))return;
132
+ nextDirection=dir;
133
+ }
134
+
135
+ diff --git a/games/space/index.html b/games/space/index.html
136
+ index b980c91..d567d68 100644
137
+ --- a/games/space/index.html
138
+ +++ b/games/space/index.html
139
+ @@ -1113,13 +1113,6 @@ function setupTouch(c){
140
+ });
141
+ }
142
+
143
+ -canvas.addEventListener('mousemove',e=>{
144
+ -if(game.state!=='playing'||paused||!game.player.alive)return;
145
+ -const rect=canvas.getBoundingClientRect();
146
+ -const x=(e.clientX-rect.left)/canvas.clientWidth*W;
147
+ -game.player.x=clamp(x,game.player.w/2+5,W-game.player.w/2-5);
148
+ -});
149
+ -
150
+ document.addEventListener('visibilitychange',()=>{
151
+ if(document.hidden&&game&&game.state==='playing')paused=true;
152
+ });
153
+ @@ -1152,6 +1145,12 @@ function loop(){update();draw();rafId=requestAnimationFrame(loop)}
154
+ canvas=document.getElementById('c');
155
+ canvas.width=W;canvas.height=H;
156
+ ctx=canvas.getContext('2d');
157
+ +canvas.addEventListener('mousemove',e=>{
158
+ +if(game.state!=='playing'||paused||!game.player.alive)return;
159
+ +const rect=canvas.getBoundingClientRect();
160
+ +const x=(e.clientX-rect.left)/canvas.clientWidth*W;
161
+ +game.player.x=clamp(x,game.player.w/2+5,W-game.player.w/2-5);
162
+ +});
163
+ setupTouch(canvas);
164
+ try{audio.init()}catch(e){}
165
+ resize();
166
+ diff --git a/games/typing/index.html b/games/typing/index.html
167
+ index 17bf35b..bad31b2 100644
168
+ --- a/games/typing/index.html
169
+ +++ b/games/typing/index.html
170
+ @@ -634,6 +634,7 @@ document.getElementById('pauseOverlay').addEventListener('click',e=>{if(e.target
171
+ // ===== Keyboard =====
172
+ document.addEventListener('keydown',e=>{
173
+ if(e.repeat)return;const k=e.key;
174
+ +if(k!=='Escape'&&(e.target.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)))return;
175
+ if((k==='p'||k==='P'||k==='Escape')&&isRunning){e.preventDefault();if(isPaused)resumeGame();else pauseGame();return}
176
+ if(k==='m'||k==='M'){e.preventDefault();if(!audioCtx)initAudio();toggleMute();return}
177
+ if((k===' '||k==='Enter')&&!isRunning){e.preventDefault();if(!audioCtx)initAudio();startGame();return}
@@ -0,0 +1,156 @@
1
+ # Classic-Games and Hextris compatibility checks
2
+
3
+ Run these commands from the Buninu Browser repository. No npm packages or
4
+ native additions are needed. Games stay in a separate checkout, outside the
5
+ browser package:
6
+
7
+ ```sh
8
+ git clone --no-hardlinks https://github.com/KoRifCan/Classic-Games.git ../Classic-Games
9
+ ```
10
+
11
+ The tested upstream revision is
12
+ `83f36f3687814c1f8e7b79f581f6fc69da72ba5e`.
13
+
14
+ ## Upstream game fixes
15
+
16
+ `0001-fix-game-initialization.patch` fixes six bugs in the game sources,
17
+ separately from browser compatibility. Apply it once:
18
+
19
+ ```sh
20
+ git -C ../Classic-Games apply ../buninu-browser/test/classic-games-patches/0001-fix-game-initialization.patch
21
+ ```
22
+
23
+ - Space Shooter registers a `mousemove` listener before assigning `canvas`.
24
+ Move registration after canvas initialization. The original also throws
25
+ in Chromium.
26
+ - 15-Puzzle's `findTile()` returns `{r,c}`, but callers use `row/col`.
27
+ Return the names those callers expect; the original shuffle cannot finish.
28
+ - Minesweeper advertises right-click flagging, but its `contextmenu` handler
29
+ only prevents the menu. Connect that handler to its existing `toggleFlag()`;
30
+ Ctrl/Meta-click and touch flagging retain their existing paths.
31
+ - Snake uses direction objects as ordinary object keys, so all four entries
32
+ collide as `"[object Object]"` and left turns are always rejected. Use a
33
+ `Map` and compare against the last committed direction; rapid inputs must
34
+ not queue a reversal into the body before the next movement tick.
35
+
36
+ - Typing Test handles P/M shortcuts on `document` even while editing its
37
+ text field, cancelling ordinary typing. Ignore letter shortcuts in editable
38
+ controls while preserving Escape pause/resume and shortcuts outside them.
39
+
40
+ - Breakout limits mouse-follow movement to 8 pixels per animation frame,
41
+ making the paddle lag at low FPS. Use 480 pixels per second, preserving
42
+ its 60 Hz speed; cap elapsed time at 100 ms to avoid a large resume jump.
43
+ Ball and particle physics retain their existing behavior. Cache the dot
44
+ background and brick shadows/gradients in an offscreen Canvas; invalidate
45
+ it on board initialization and each destroyed brick, retaining the same pixels.
46
+
47
+ These patches never replace browser behavior with a game-specific exception.
48
+ The hosted upstream originals will still contain their own bugs until their
49
+ maintainer fixes them.
50
+
51
+ ## Run all sixteen desktop scenarios
52
+
53
+ ```sh
54
+ bun test/manual-classic-games.js ../Classic-Games buninu /tmp/classic-games
55
+ # Optional reference run, if Chromium headless shell is installed:
56
+ bun test/manual-classic-games.js ../Classic-Games chromium /tmp/classic-games-chromium
57
+ ```
58
+
59
+ To browse the local patched games interactively through the default Casty frontend:
60
+
61
+ ```sh
62
+ bun index.js file://"$PWD/../Classic-Games/index.html"
63
+ ```
64
+
65
+ The runner serves the checkout on an automatically assigned loopback port,
66
+ launches the browser, and closes its server and browser when finished. It
67
+ writes `report.json` and initial/played PNG screenshots per game. A failed
68
+ scenario, page error, or CDP exception causes a nonzero exit status. Restrict
69
+ a diagnostic run with `CLASSIC_GAMES=memory,simon` if needed.
70
+
71
+ The browser remains in its default SES/seccomp configuration. No host-JS
72
+ bypass, game physics changes, score assignment, or game-state function calls
73
+ are used. Flappy Bird's Canvas text, Snake's rendered head-eye positions, and Simon's light sequence are observed
74
+ through bounded text logging / a MutationObserver; these record output and
75
+ do not change game decisions.
76
+
77
+ | Game | Interaction verified |
78
+ |---|---|
79
+ | Tetris | Move/rotate, hard-drop score, pause/resume input |
80
+ | Snake | All four directions turn correctly; direct reversal is rejected |
81
+ | 2048 | Move/spawn tiles and undo restores the board |
82
+ | Flappy Bird | Space starts, draws the score, and a second flap remains alive |
83
+ | Space Shooter | Start/fire and movement change the canvas |
84
+ | Tic Tac Toe | Human move, AI response, new game clears marks |
85
+ | Memory | Flip a card, match a pair; hidden backs render correctly |
86
+ | Minesweeper | Set/remove a right-click flag and reveal cells |
87
+ | Pong | Start and paddle/ball updates |
88
+ | Breakout | Start, move paddle, launch ball |
89
+ | Dino Runner | Start and jump/runner animation |
90
+ | Sudoku | Start, toggle pencil notes, fill a hint |
91
+ | Connect 4 | Seven drops produce a four-disc win and update the round score |
92
+ | Typing Test | Type/advance words; P/M remain input; Escape pauses and resumes |
93
+ | Simon Says | Observe the first light, enter it, advance to round two |
94
+ | 15-Puzzle | Render fifteen tiles and slide an adjacent tile |
95
+
96
+ This is a functional compatibility check, not a completed campaign for each
97
+ game or an end-to-end Casty/Kitty FPS benchmark. Sudoku's synchronous puzzle
98
+ generation can take noticeably longer in the instrumented JS environment;
99
+ the manual runner gives observations enough time to finish.
100
+
101
+ ## Breakout paddle timing
102
+
103
+ ```sh
104
+ bun test/manual-breakout-paddle.js ../Classic-Games buninu
105
+ bun test/manual-breakout-paddle.js ../Classic-Games chromium
106
+ bun test/manual-breakout-static.js ../Classic-Games buninu
107
+ bun test/manual-breakout-static.js ../Classic-Games chromium
108
+ ```
109
+
110
+ This separate timing check supplies controlled RAF timestamps to the actual
111
+ game. At 15, 60 and 120 FPS, 400 ms must move its paddle 192 pixels. A
112
+ one-second frame gap must contribute at most 100 ms of catch-up movement.
113
+ The static-layer check compares the initial Canvas pixels and the pixels
114
+ after destroying a brick with the unpatched upstream source from the checkout's
115
+ HEAD. They must match exactly while idle brick draws fall from 60 to zero.
116
+ The ordinary sixteen-game runner above still uses real-time CDP input.
117
+
118
+ ## Hextris
119
+
120
+ ```sh
121
+ env NODE_TLS_REJECT_UNAUTHORIZED=0 bun test/manual-hextris.js buninu /tmp/hextris
122
+ # Reference; the helper also forwards the opt-out to Chromium's TLS flag:
123
+ env NODE_TLS_REJECT_UNAUTHORIZED=0 bun test/manual-hextris.js chromium /tmp/hextris-chromium
124
+ ```
125
+
126
+ The TLS environment variable is only needed if this site's certificate fails
127
+ validation on your system. The test checks a mouse-start, both arrow rotations,
128
+ pause/resume, and generated blocks, then saves `playing.png` and `report.json`.
129
+ Production fixes are HTML namespace-aware SVG/RCDATA parsing and preservation
130
+ of automatic semicolon insertion before member-access rewriting. No Hextris
131
+ source patch is required.
132
+
133
+ ## Generic browser changes exercised
134
+
135
+ - Canvas `roundRect()` and a page-local `CanvasRenderingContext2D` prototype,
136
+ so native feature detection and prototype extensions work.
137
+ - CPU Web Audio biquad filters and fractional AudioBuffer length conversion;
138
+ filter tests verify both frequency response and actual filtered PCM.
139
+ - Orthographic `rotateX`/`rotateY` projection and backface hiding across
140
+ `preserve-3d` card faces. This is not full perspective/3D CSS support.
141
+ - Fractional Grid row tracks use a definite height, including aspect-ratio
142
+ sizing, so empty Simon quadrants get a usable painted and clickable area.
143
+ - SVG elements do not enter HTML RCDATA parsing; ASI boundaries survive
144
+ member-access rewriting before subsequent parsing.
145
+ - Unsupported scroll-driven animation properties remain absent from feature
146
+ detection even though ordinary CSS animation aliases now work.
147
+
148
+ ## Recorded result
149
+
150
+ All sixteen desktop scenarios passed in one default-sandbox run on this
151
+ checkout, with no recorded page errors or CDP exceptions. The associated
152
+ browser regression run passed 272 tests across fourteen files; eight additional
153
+ piano mapping/input tests also passed. Screenshots and
154
+ per-game evidence from this session are in `/tmp/classic-games-final-regression/`;
155
+ that temporary directory is not a packaged artifact. Re-running the command
156
+ above recreates equivalent reports in your chosen output directory.