[{"data":1,"prerenderedAt":3077},["ShallowReactive",2],{"page:\u002Fposts\u002Fcode_n_shit\u002Fhalfword_android_rendering":3,"translations:\u002Fposts\u002Fcode_n_shit\u002Fhalfword_android_rendering":3058,"series:en:halfword":3067},{"id":4,"title":5,"author":6,"body":7,"date":3044,"description":3045,"draft":3046,"extension":3047,"image":3048,"listed":346,"meta":3049,"navigation":346,"part":3050,"path":3051,"seo":3052,"series":3053,"stem":3054,"tags":3055,"theme":3053,"toc":346,"__hash__":3057},"content_en\u002Fposts\u002Fcode_n_shit\u002Fhalfword_android_rendering.md","I tested my Android screens without the emulator. All 114 of them, in four languages.","slnt_opp",{"type":8,"value":9,"toc":3023},"minimark",[10,20,30,33,38,41,47,52,57,60,68,73,76,79,83,94,101,123,126,130,149,156,595,602,608,611,616,619,634,927,931,942,949,1139,1142,1149,1168,1172,1175,1499,1577,1584,1637,1641,1648,1652,1675,1716,1734,1740,1840,1844,1847,1851,1869,1873,1876,1879,1902,1906,1909,1912,1923,1927,1961,1964,1967,1970,1974,2017,2021,2024,2061,2064,2067,2071,2081,2299,2313,2937,2940,2965,2969,2972,2988,2998,3006,3012,3016,3019],[11,12,13,14,19],"p",{},"The ",[15,16,18],"a",{"href":17},"\u002Fposts\u002Fcode_n_shit\u002Fhalfword","Halfword launch post"," left one thing for a post of its own: how its Android screens were checked without an emulator. This is that post.",[11,21,22,23,29],{},"Quick recap: Halfword is written once in Swift, and ",[15,24,28],{"href":25,"rel":26},"https:\u002F\u002Fskip.dev",[27],"nofollow","Skip"," Lite translates it into Kotlin for Android. Every test runs twice, as Swift and as the Kotlin it became. That covers the rules, the word packs and the saved games, but not what the screens look like. For that you run the app on Android: on a phone, or on the emulator.",[11,31,32],{},"I went with neither: Halfword's Android screens are drawn on the JVM, on my Mac, by a unit test. Here's why, how, and what the pictures caught.",[34,35,37],"h2",{"id":36},"the-emulator","The emulator",[11,39,40],{},"Let me get this off my chest first. Things I have said about the Android emulator, verbatim:",[42,43,44],"blockquote",{},[11,45,46],{},"Android simulator is shame.",[42,48,49],{},[11,50,51],{},"android simulator is abominable",[42,53,54],{},[11,55,56],{},"Android deserves all hate it gets simply due to their sdks",[11,58,59],{},"The emulator had shown its manners on day one: the Android 16 emulator was too loaded to install the instrumented tests and answered \"Can't find service: package\". So no test has ever run on Android's own SQLite. Robolectric runs the Kotlin tests on the JVM anyway, so that could wait.",[11,61,62,63,67],{},"The next day ",[64,65,66],"strong",{},"my Mac kernel-panicked",". It's an M2 with 16 GB. The panic was a watchdog timeout, with the memory compressor at \"100% of segments limit\". At that moment four builds were running in four worktrees at once, each with Swift and Xcode on one side and Gradle for the Kotlin side on the other, and the Android emulator was running on top. My diagnosis afterwards:",[42,69,70],{},[11,71,72],{},"java was butchering both cpu and ram usage",[11,74,75],{},"So I paused Android. iOS shipped, and Android waited. Then I turned it back on (\"start working on Android milestone - simple porting\") with one wish: \"if you have a way of rendering the UI without a simulator it'd be great\".",[11,77,78],{},"There is a way.",[34,80,82],{"id":81},"the-tests-already-run-on-android-sort-of","The tests already run on Android. Sort of.",[11,84,85,86,89,90,93],{},"Skip's own Android tests don't need a device. By default they run under ",[64,87,88],{},"Robolectric",", which runs the real Android framework classes on a desktop JVM and stands in for the hardware. When Android came back, all ",[64,91,92],{},"721 tests passed in both columns",", Swift and Kotlin, up to the app model playing a whole evening of games, after two fixes to the tests, none to the app.",[11,95,96,97,100],{},"So the transpiled app already runs under Robolectric. Robolectric's native graphics mode draws with Android's own Skia on the host, and ",[64,98,99],{},"Roborazzi"," saves the screen as a PNG. Put together, that's one JUnit test in the Android app module that:",[102,103,104,117,120],"ul",{},[105,106,107,108,112,113,116],"li",{},"boots the app's real ",[109,110,111],"code",{},"MainActivity",", the same ",[109,114,115],{},"Main.kt"," a phone runs, with Compose and SkipUI underneath;",[105,118,119],{},"on a pretend Pixel 7 (412 × 915 dp at 420 dpi, so density 2.625, dark mode);",[105,121,122],{},"opens a screen, lets the app run for a moment, and saves the picture.",[11,124,125],{},"No emulator, no adb, no APK to install. One Gradle run.",[34,127,129],{"id":128},"how-it-works","How it works",[11,131,132,133,136,137,140,141,144,145,148],{},"The app already had ",[64,134,135],{},"114 fixtures",": named states such as the vote mid-count, Mr. White's guess or a cascade of eliminations, which a debug build opens straight from launch. On iOS a script starts the simulator with ",[109,138,139],{},"HALFWORD_FIXTURE=round-voting"," and takes a screenshot. On Android, the same script writes a launch file into the app's ",[109,142,143],{},"files\u002F"," folder with ",[109,146,147],{},"adb shell run-as",", for an emulator.",[11,150,151,152,155],{},"The render does the same thing, minus adb. Condensed from the test, ",[109,153,154],{},"FixtureShots",":",[157,158,163],"pre",{"className":159,"code":160,"language":161,"meta":162,"style":162},"language-kotlin shiki shiki-themes monokai","@RunWith(AndroidJUnit4::class)\n@GraphicsMode(GraphicsMode.Mode.NATIVE)\n@Config(qualifiers = \"w412dp-h915dp-normal-long-notround-port-night-420dpi-keyshidden-nonav\")\nclass FixtureShots {\n    private fun draw(fixture: String, lang: String, file: File): String? {\n        \u002F\u002F Pick the screen the way the emulator script does: a launch file in files\u002F.\n        File(folder, LaunchOptions.fileName).writeText(\n            \"${LaunchOptions.fixtureKey}=$fixture\\n${LaunchOptions.languageKey}=$lang\\n\")\n        HalfwordRootView.forgetLaunch()   \u002F\u002F every picture shares one process\n\n        val looper = shadowOf(Looper.getMainLooper())\n        val activity = Robolectric.buildActivity(MainActivity::class.java).setup()\n        try {\n            var waited = 0L\n            while (!ready.exists() && waited \u003C FIRST_FRAME_LIMIT_MS) {  \u002F\u002F until the fixture is up,\n                step(looper); waited += FRAME_MS                         \u002F\u002F 20 s of app time at most\n            }\n            …\n            var settled = 0L\n            while (settled \u003C settleMs) { step(looper); settled += FRAME_MS }  \u002F\u002F then 1.5 s of app time\n            captureScreenRoboImage(file)                                       \u002F\u002F every window, alerts too\n            …\n        } finally {\n            activity.pause().stop().destroy()\n        }\n    }\n}\n","kotlin","",[109,164,165,185,194,213,224,261,268,283,326,341,348,371,401,409,423,458,476,482,488,500,527,539,544,555,577,583,589],{"__ignoreMap":162},[166,167,170,174,178,182],"span",{"class":168,"line":169},"line",1,[166,171,173],{"class":172},"soZtE","@RunWith",[166,175,177],{"class":176},"sCdxs","(AndroidJUnit4::",[166,179,181],{"class":180},"sHkqI","class",[166,183,184],{"class":176},")\n",[166,186,188,191],{"class":168,"line":187},2,[166,189,190],{"class":172},"@GraphicsMode",[166,192,193],{"class":176},"(GraphicsMode.Mode.NATIVE)\n",[166,195,197,200,203,207,211],{"class":168,"line":196},3,[166,198,199],{"class":172},"@Config",[166,201,202],{"class":176},"(qualifiers ",[166,204,206],{"class":205},"s8I7P","=",[166,208,210],{"class":209},"s_Ekj"," \"w412dp-h915dp-normal-long-notround-port-night-420dpi-keyshidden-nonav\"",[166,212,184],{"class":176},[166,214,216,218,221],{"class":168,"line":215},4,[166,217,181],{"class":205},[166,219,220],{"class":172}," FixtureShots",[166,222,223],{"class":176}," {\n",[166,225,227,230,233,236,239,242,245,247,250,253,256,258],{"class":168,"line":226},5,[166,228,229],{"class":205},"    private",[166,231,232],{"class":205}," fun",[166,234,235],{"class":180}," draw",[166,237,238],{"class":176},"(fixture: ",[166,240,241],{"class":172},"String",[166,243,244],{"class":176},", lang: ",[166,246,241],{"class":172},[166,248,249],{"class":176},", file: ",[166,251,252],{"class":172},"File",[166,254,255],{"class":176},"): ",[166,257,241],{"class":172},[166,259,260],{"class":176},"? {\n",[166,262,264],{"class":168,"line":263},6,[166,265,267],{"class":266},"snpHw","        \u002F\u002F Pick the screen the way the emulator script does: a launch file in files\u002F.\n",[166,269,271,274,277,280],{"class":168,"line":270},7,[166,272,273],{"class":180},"        File",[166,275,276],{"class":176},"(folder, LaunchOptions.fileName).",[166,278,279],{"class":180},"writeText",[166,281,282],{"class":176},"(\n",[166,284,286,289,292,295,298,300,303,307,309,312,314,316,319,321,324],{"class":168,"line":285},8,[166,287,288],{"class":209},"            \"",[166,290,291],{"class":205},"${",[166,293,294],{"class":176},"LaunchOptions.fixtureKey",[166,296,297],{"class":205},"}",[166,299,206],{"class":209},[166,301,302],{"class":176},"$fixture",[166,304,306],{"class":305},"s7s5_","\\n",[166,308,291],{"class":205},[166,310,311],{"class":176},"LaunchOptions.languageKey",[166,313,297],{"class":205},[166,315,206],{"class":209},[166,317,318],{"class":176},"$lang",[166,320,306],{"class":305},[166,322,323],{"class":209},"\"",[166,325,184],{"class":176},[166,327,329,332,335,338],{"class":168,"line":328},9,[166,330,331],{"class":176},"        HalfwordRootView.",[166,333,334],{"class":180},"forgetLaunch",[166,336,337],{"class":176},"()   ",[166,339,340],{"class":266},"\u002F\u002F every picture shares one process\n",[166,342,344],{"class":168,"line":343},10,[166,345,347],{"emptyLinePlaceholder":346},true,"\n",[166,349,351,354,357,359,362,365,368],{"class":168,"line":350},11,[166,352,353],{"class":205},"        val",[166,355,356],{"class":176}," looper ",[166,358,206],{"class":205},[166,360,361],{"class":180}," shadowOf",[166,363,364],{"class":176},"(Looper.",[166,366,367],{"class":180},"getMainLooper",[166,369,370],{"class":176},"())\n",[166,372,374,376,379,381,384,387,390,392,395,398],{"class":168,"line":373},12,[166,375,353],{"class":205},[166,377,378],{"class":176}," activity ",[166,380,206],{"class":205},[166,382,383],{"class":176}," Robolectric.",[166,385,386],{"class":180},"buildActivity",[166,388,389],{"class":176},"(MainActivity::",[166,391,181],{"class":180},[166,393,394],{"class":176},".java).",[166,396,397],{"class":180},"setup",[166,399,400],{"class":176},"()\n",[166,402,404,407],{"class":168,"line":403},13,[166,405,406],{"class":205},"        try",[166,408,223],{"class":176},[166,410,412,415,418,420],{"class":168,"line":411},14,[166,413,414],{"class":205},"            var",[166,416,417],{"class":176}," waited ",[166,419,206],{"class":205},[166,421,422],{"class":305}," 0L\n",[166,424,426,429,432,435,438,441,444,447,449,452,455],{"class":168,"line":425},15,[166,427,428],{"class":205},"            while",[166,430,431],{"class":176}," (",[166,433,434],{"class":205},"!",[166,436,437],{"class":176},"ready.",[166,439,440],{"class":180},"exists",[166,442,443],{"class":176},"() ",[166,445,446],{"class":205},"&&",[166,448,417],{"class":176},[166,450,451],{"class":205},"\u003C",[166,453,454],{"class":176}," FIRST_FRAME_LIMIT_MS) {  ",[166,456,457],{"class":266},"\u002F\u002F until the fixture is up,\n",[166,459,461,464,467,470,473],{"class":168,"line":460},16,[166,462,463],{"class":180},"                step",[166,465,466],{"class":176},"(looper); waited ",[166,468,469],{"class":205},"+=",[166,471,472],{"class":176}," FRAME_MS                         ",[166,474,475],{"class":266},"\u002F\u002F 20 s of app time at most\n",[166,477,479],{"class":168,"line":478},17,[166,480,481],{"class":176},"            }\n",[166,483,485],{"class":168,"line":484},18,[166,486,487],{"class":176},"            …\n",[166,489,491,493,496,498],{"class":168,"line":490},19,[166,492,414],{"class":205},[166,494,495],{"class":176}," settled ",[166,497,206],{"class":205},[166,499,422],{"class":305},[166,501,503,505,508,510,513,516,519,521,524],{"class":168,"line":502},20,[166,504,428],{"class":205},[166,506,507],{"class":176}," (settled ",[166,509,451],{"class":205},[166,511,512],{"class":176}," settleMs) { ",[166,514,515],{"class":180},"step",[166,517,518],{"class":176},"(looper); settled ",[166,520,469],{"class":205},[166,522,523],{"class":176}," FRAME_MS }  ",[166,525,526],{"class":266},"\u002F\u002F then 1.5 s of app time\n",[166,528,530,533,536],{"class":168,"line":529},21,[166,531,532],{"class":180},"            captureScreenRoboImage",[166,534,535],{"class":176},"(file)                                       ",[166,537,538],{"class":266},"\u002F\u002F every window, alerts too\n",[166,540,542],{"class":168,"line":541},22,[166,543,487],{"class":176},[166,545,547,550,553],{"class":168,"line":546},23,[166,548,549],{"class":176},"        } ",[166,551,552],{"class":205},"finally",[166,554,223],{"class":176},[166,556,558,561,564,567,570,572,575],{"class":168,"line":557},24,[166,559,560],{"class":176},"            activity.",[166,562,563],{"class":180},"pause",[166,565,566],{"class":176},"().",[166,568,569],{"class":180},"stop",[166,571,566],{"class":176},[166,573,574],{"class":180},"destroy",[166,576,400],{"class":176},[166,578,580],{"class":168,"line":579},25,[166,581,582],{"class":176},"        }\n",[166,584,586],{"class":168,"line":585},26,[166,587,588],{"class":176},"    }\n",[166,590,592],{"class":168,"line":591},27,[166,593,594],{"class":176},"}\n",[11,596,597,598,601],{},"Two small pieces of app code help: the app writes a \"ready\" file once the fixture is up, which the screenshot script already polled for, and ",[109,599,600],{},"HalfwordRootView.forgetLaunch()"," drops the launch the process remembered, since all 456 pictures share one JVM. Both are debug-only doors: another Robolectric test starts the real activity as a release build with a launch file in place, and checks that it's ignored.",[11,603,604,607],{},[64,605,606],{},"The clock is mine."," Robolectric's main looper only moves when the test moves it. So the test moves it 16 ms at a time, one frame, until the fixture is on screen, then 1.5 more seconds of app time, the screenshot script's settle time. The sprites, the turn timer (\"0:29\") and the elimination's reveal end up exactly where they would be 1.5 s in, and nobody waits 1.5 seconds.",[11,609,610],{},"That was the plan, anyway. Two things had to be done by hand: the layout pass a phone's draw does every frame, and a clock that gives the same picture every run.",[612,613,615],"h3",{"id":614},"a-phone-draws-every-frame-robolectric-doesnt","A phone draws every frame. Robolectric doesn't.",[11,617,618],{},"The first pictures had the top bar and the buttons, and nothing in between. Every scrolling list was blank: Home's rows, the vote, the results.",[11,620,621,622,625,626,629,630,633],{},"Compose measures and places a node that only asked for a redraw at the start of drawing the window (",[109,623,624],{},"dispatchDraw","). A phone draws every frame that changed. Robolectric never draws between pictures, so the content of every ",[109,627,628],{},"GeometryReader"," was composed and never placed, and every ",[109,631,632],{},"ScreenScroll"," (the app's scrolling frame) drew blank. The fix is to do the phone's first step by hand, on every window, so alerts are included:",[157,635,637],{"className":159,"code":636,"language":161,"meta":162,"style":162},"private fun layOut() {\n    for (root in windowRoots()) visit(root)\n}\n\nprivate fun visit(view: View) {\n    if (view is RootForTest) view.measureAndLayoutForTest()\n    if (view is ViewGroup) for (i in 0 until view.childCount) visit(view.getChildAt(i))\n}\n\n\u002F\u002F\u002F Every window's root view: WindowManagerGlobal's list, by reflection (it's hidden API).\nprivate fun windowRoots(): List\u003CView> {\n    val global = Class.forName(\"android.view.WindowManagerGlobal\")\n    val instance = global.getMethod(\"getInstance\").invoke(null)\n    val field = global.getDeclaredField(\"mViews\").apply { isAccessible = true }\n    return (field.get(instance) as List\u003C*>).filterIsInstance\u003CView>()\n}\n",[109,638,639,652,675,679,683,701,720,756,760,764,769,790,814,847,883,923],{"__ignoreMap":162},[166,640,641,644,646,649],{"class":168,"line":169},[166,642,643],{"class":205},"private",[166,645,232],{"class":205},[166,647,648],{"class":180}," layOut",[166,650,651],{"class":176},"() {\n",[166,653,654,657,660,663,666,669,672],{"class":168,"line":187},[166,655,656],{"class":205},"    for",[166,658,659],{"class":176}," (root ",[166,661,662],{"class":205},"in",[166,664,665],{"class":180}," windowRoots",[166,667,668],{"class":176},"()) ",[166,670,671],{"class":180},"visit",[166,673,674],{"class":176},"(root)\n",[166,676,677],{"class":168,"line":196},[166,678,594],{"class":176},[166,680,681],{"class":168,"line":215},[166,682,347],{"emptyLinePlaceholder":346},[166,684,685,687,689,692,695,698],{"class":168,"line":226},[166,686,643],{"class":205},[166,688,232],{"class":205},[166,690,691],{"class":180}," visit",[166,693,694],{"class":176},"(view: ",[166,696,697],{"class":172},"View",[166,699,700],{"class":176},") {\n",[166,702,703,706,709,712,715,718],{"class":168,"line":263},[166,704,705],{"class":205},"    if",[166,707,708],{"class":176}," (view ",[166,710,711],{"class":205},"is",[166,713,714],{"class":176}," RootForTest) view.",[166,716,717],{"class":180},"measureAndLayoutForTest",[166,719,400],{"class":176},[166,721,722,724,726,728,731,734,737,739,742,745,747,750,753],{"class":168,"line":270},[166,723,705],{"class":205},[166,725,708],{"class":176},[166,727,711],{"class":205},[166,729,730],{"class":176}," ViewGroup) ",[166,732,733],{"class":205},"for",[166,735,736],{"class":176}," (i ",[166,738,662],{"class":205},[166,740,741],{"class":305}," 0",[166,743,744],{"class":176}," until view.childCount) ",[166,746,671],{"class":180},[166,748,749],{"class":176},"(view.",[166,751,752],{"class":180},"getChildAt",[166,754,755],{"class":176},"(i))\n",[166,757,758],{"class":168,"line":285},[166,759,594],{"class":176},[166,761,762],{"class":168,"line":328},[166,763,347],{"emptyLinePlaceholder":346},[166,765,766],{"class":168,"line":343},[166,767,768],{"class":266},"\u002F\u002F\u002F Every window's root view: WindowManagerGlobal's list, by reflection (it's hidden API).\n",[166,770,771,773,775,777,780,783,785,787],{"class":168,"line":350},[166,772,643],{"class":205},[166,774,232],{"class":205},[166,776,665],{"class":180},[166,778,779],{"class":176},"(): ",[166,781,782],{"class":172},"List",[166,784,451],{"class":176},[166,786,697],{"class":172},[166,788,789],{"class":176},"> {\n",[166,791,792,795,798,800,803,806,809,812],{"class":168,"line":373},[166,793,794],{"class":205},"    val",[166,796,797],{"class":176}," global ",[166,799,206],{"class":205},[166,801,802],{"class":176}," Class.",[166,804,805],{"class":180},"forName",[166,807,808],{"class":176},"(",[166,810,811],{"class":209},"\"android.view.WindowManagerGlobal\"",[166,813,184],{"class":176},[166,815,816,818,821,823,826,829,831,834,837,840,842,845],{"class":168,"line":403},[166,817,794],{"class":205},[166,819,820],{"class":176}," instance ",[166,822,206],{"class":205},[166,824,825],{"class":176}," global.",[166,827,828],{"class":180},"getMethod",[166,830,808],{"class":176},[166,832,833],{"class":209},"\"getInstance\"",[166,835,836],{"class":176},").",[166,838,839],{"class":180},"invoke",[166,841,808],{"class":176},[166,843,844],{"class":305},"null",[166,846,184],{"class":176},[166,848,849,851,854,857,859,862,864,867,869,872,875,877,880],{"class":168,"line":411},[166,850,794],{"class":205},[166,852,853],{"class":205}," field",[166,855,856],{"class":205}," =",[166,858,825],{"class":176},[166,860,861],{"class":180},"getDeclaredField",[166,863,808],{"class":176},[166,865,866],{"class":209},"\"mViews\"",[166,868,836],{"class":176},[166,870,871],{"class":180},"apply",[166,873,874],{"class":176}," { isAccessible ",[166,876,206],{"class":205},[166,878,879],{"class":305}," true",[166,881,882],{"class":176}," }\n",[166,884,885,888,890,893,896,899,902,905,908,911,913,916,918,920],{"class":168,"line":425},[166,886,887],{"class":205},"    return",[166,889,431],{"class":176},[166,891,892],{"class":205},"field",[166,894,895],{"class":176},".",[166,897,898],{"class":180},"get",[166,900,901],{"class":176},"(instance) ",[166,903,904],{"class":205},"as",[166,906,907],{"class":176}," List",[166,909,910],{"class":205},"\u003C*>",[166,912,836],{"class":176},[166,914,915],{"class":180},"filterIsInstance",[166,917,451],{"class":176},[166,919,697],{"class":172},[166,921,922],{"class":176},">()\n",[166,924,925],{"class":168,"line":460},[166,926,594],{"class":176},[612,928,930],{"id":929},"same-picture-every-run","Same picture, every run",[11,932,933,934,937,938,941],{},"Then the pictures stopped agreeing with themselves. From one run to the next, a sprite was a frame off in one or two of the 114 English pictures, and in three to seven with every core busy. A view's ",[109,935,936],{},".task"," starts on a background thread and hops to the main one, and whatever it posts is stamped with the clock as it stands when the thread gets there. Robolectric's ",[109,939,940],{},"idleFor"," ran a whole stretch of tasks at once, so whether a sprite's tick landed before or after a frame depended on how fast the threads ran.",[11,943,944,945,948],{},"Now the main looper runs ",[64,946,947],{},"one task at a time",", the clock moves only between tasks, and between two the test waits until kotlinx.coroutines' scheduler is quiet: nothing queued, no CPU permit taken, no blocking task. That's internal API, read by reflection. The worker threads' states alone weren't enough, because a woken worker looks parked until the OS schedules it.",[157,950,952],{"className":159,"code":951,"language":161,"meta":162,"style":162},"\u002F\u002F\u002F One frame (16 ms) of app time, one main-thread task at a time.\nprivate fun step(looper: ShadowLooper) {\n    val end = SystemClock.uptimeMillis() + FRAME_MS\n    while (true) {\n        settle(looper)   \u002F\u002F lay out every window, wait until the coroutine pool is quiet\n        val next = looper.nextScheduledTaskTime.toMillis()\n        if (next == 0L || next > end) break\n        looper.runOneTask()\n    }\n    settle(looper)\n    val rest = end - SystemClock.uptimeMillis()\n    if (rest > 0L) ShadowSystemClock.advanceBy(Duration.ofMillis(rest))\n}\n",[109,953,954,959,976,999,1011,1022,1039,1067,1077,1081,1089,1109,1135],{"__ignoreMap":162},[166,955,956],{"class":168,"line":169},[166,957,958],{"class":266},"\u002F\u002F\u002F One frame (16 ms) of app time, one main-thread task at a time.\n",[166,960,961,963,965,968,971,974],{"class":168,"line":187},[166,962,643],{"class":205},[166,964,232],{"class":205},[166,966,967],{"class":180}," step",[166,969,970],{"class":176},"(looper: ",[166,972,973],{"class":172},"ShadowLooper",[166,975,700],{"class":176},[166,977,978,980,983,985,988,991,993,996],{"class":168,"line":196},[166,979,794],{"class":205},[166,981,982],{"class":176}," end ",[166,984,206],{"class":205},[166,986,987],{"class":176}," SystemClock.",[166,989,990],{"class":180},"uptimeMillis",[166,992,443],{"class":176},[166,994,995],{"class":205},"+",[166,997,998],{"class":176}," FRAME_MS\n",[166,1000,1001,1004,1006,1009],{"class":168,"line":215},[166,1002,1003],{"class":205},"    while",[166,1005,431],{"class":176},[166,1007,1008],{"class":305},"true",[166,1010,700],{"class":176},[166,1012,1013,1016,1019],{"class":168,"line":226},[166,1014,1015],{"class":180},"        settle",[166,1017,1018],{"class":176},"(looper)   ",[166,1020,1021],{"class":266},"\u002F\u002F lay out every window, wait until the coroutine pool is quiet\n",[166,1023,1024,1026,1029,1031,1034,1037],{"class":168,"line":263},[166,1025,353],{"class":205},[166,1027,1028],{"class":176}," next ",[166,1030,206],{"class":205},[166,1032,1033],{"class":176}," looper.nextScheduledTaskTime.",[166,1035,1036],{"class":180},"toMillis",[166,1038,400],{"class":176},[166,1040,1041,1044,1047,1050,1053,1056,1058,1061,1064],{"class":168,"line":270},[166,1042,1043],{"class":205},"        if",[166,1045,1046],{"class":176}," (next ",[166,1048,1049],{"class":205},"==",[166,1051,1052],{"class":305}," 0L",[166,1054,1055],{"class":205}," ||",[166,1057,1028],{"class":176},[166,1059,1060],{"class":205},">",[166,1062,1063],{"class":176}," end) ",[166,1065,1066],{"class":205},"break\n",[166,1068,1069,1072,1075],{"class":168,"line":285},[166,1070,1071],{"class":176},"        looper.",[166,1073,1074],{"class":180},"runOneTask",[166,1076,400],{"class":176},[166,1078,1079],{"class":168,"line":328},[166,1080,588],{"class":176},[166,1082,1083,1086],{"class":168,"line":343},[166,1084,1085],{"class":180},"    settle",[166,1087,1088],{"class":176},"(looper)\n",[166,1090,1091,1093,1096,1098,1100,1103,1105,1107],{"class":168,"line":350},[166,1092,794],{"class":205},[166,1094,1095],{"class":176}," rest ",[166,1097,206],{"class":205},[166,1099,982],{"class":176},[166,1101,1102],{"class":205},"-",[166,1104,987],{"class":176},[166,1106,990],{"class":180},[166,1108,400],{"class":176},[166,1110,1111,1113,1116,1118,1120,1123,1126,1129,1132],{"class":168,"line":373},[166,1112,705],{"class":205},[166,1114,1115],{"class":176}," (rest ",[166,1117,1060],{"class":205},[166,1119,1052],{"class":305},[166,1121,1122],{"class":176},") ShadowSystemClock.",[166,1124,1125],{"class":180},"advanceBy",[166,1127,1128],{"class":176},"(Duration.",[166,1130,1131],{"class":180},"ofMillis",[166,1133,1134],{"class":176},"(rest))\n",[166,1136,1137],{"class":168,"line":403},[166,1138,594],{"class":176},[11,1140,1141],{},"One more: the first activity of a run is drawn and thrown away. The first render of a process loads classes and starts the coroutine threads, slowly enough to put a sprite a frame late.",[11,1143,1144,1145,1148],{},"After that, ",[64,1146,1147],{},"three runs of all 114 English pictures came out byte for byte the same",". It got faster too, with no fixed sleeps left: all 456 pictures went from 3 min 11 s to 2 min 19 s.",[11,1150,1151,1152,1155,1156,1159,1160,1163,1164,1167],{},"A picture the render can't vouch for fails the run, with a ",[109,1153,1154],{},"\u003Cname>.failed"," saying why: its ready file names another fixture (a stale launch), or a wait gave up (\"unsettled\"). And the script unsets the four launch keys (",[109,1157,1158],{},"HALFWORD_FIXTURE",", ",[109,1161,1162],{},"HALFWORD_LANG",", …) before Gradle, and the test refuses to draw if one is still set. The app lets them win over the launch file, so an exported ",[109,1165,1166],{},"HALFWORD_LANG=de"," would otherwise put every picture in German under an English name. Each was checked by breaking it on purpose.",[612,1169,1171],{"id":1170},"the-gradle-part","The Gradle part",[11,1173,1174],{},"The app module's unit tests needed what Skip's generated module builds give theirs, plus a few flags:",[157,1176,1178],{"className":159,"code":1177,"language":161,"meta":162,"style":162},"android {\n    testOptions { unitTests { isIncludeAndroidResources = true } }  \u002F\u002F Micro 5 lives in res\u002Ffont\n}\n\ndependencies {   \u002F\u002F condensed from the app module's build.gradle.kts, Skip's catalog aliases spelled out\n    testImplementation(\"org.robolectric:robolectric:4.16.1\")\n    testImplementation(\"androidx.test:core:1.7.0\")       \u002F\u002F ApplicationProvider\n    testImplementation(\"androidx.test.ext:junit:1.3.0\")  \u002F\u002F AndroidJUnit4\n    testImplementation(\"io.github.takahirom.roborazzi:roborazzi:1.76.0\")\n    testImplementation(\"net.java.dev.jna:jna:5.19.1\")    \u002F\u002F skip-sql reaches SQLite through JNA: the desktop jar\n    testImplementation(\"org.ow2.asm:asm:9.10\")           \u002F\u002F Robolectric's own ASM can't read JDK 26 classes\n}\n\ntasks.withType\u003CTest>().configureEach {\n    \u002F\u002F What to draw and where: the script's -Phalfword.shots.* become the test's system properties.\n    listOf(\"fixtures\", \"langs\", \"out\", \"devices\", \"theme\", \"settle\", \"fontScale\").forEach { key ->\n        val name = \"halfword.shots.$key\"\n        project.findProperty(name)?.let { systemProperty(name, it.toString()) }\n    }\n    systemProperty(\"robolectric.graphicsMode\", \"NATIVE\")\n    systemProperty(\"roborazzi.test.record\", \"true\")  \u002F\u002F no Roborazzi plugin, nothing to compare\n    maxHeapSize = \"2g\"\n}\n",[109,1179,1180,1187,1211,1215,1219,1230,1242,1257,1272,1283,1298,1313,1317,1321,1342,1347,1399,1417,1445,1449,1466,1485,1495],{"__ignoreMap":162},[166,1181,1182,1185],{"class":168,"line":169},[166,1183,1184],{"class":180},"android",[166,1186,223],{"class":176},[166,1188,1189,1192,1195,1198,1201,1203,1205,1208],{"class":168,"line":187},[166,1190,1191],{"class":180},"    testOptions",[166,1193,1194],{"class":176}," { ",[166,1196,1197],{"class":180},"unitTests",[166,1199,1200],{"class":176}," { isIncludeAndroidResources ",[166,1202,206],{"class":205},[166,1204,879],{"class":305},[166,1206,1207],{"class":176}," } }  ",[166,1209,1210],{"class":266},"\u002F\u002F Micro 5 lives in res\u002Ffont\n",[166,1212,1213],{"class":168,"line":196},[166,1214,594],{"class":176},[166,1216,1217],{"class":168,"line":215},[166,1218,347],{"emptyLinePlaceholder":346},[166,1220,1221,1224,1227],{"class":168,"line":226},[166,1222,1223],{"class":180},"dependencies",[166,1225,1226],{"class":176}," {   ",[166,1228,1229],{"class":266},"\u002F\u002F condensed from the app module's build.gradle.kts, Skip's catalog aliases spelled out\n",[166,1231,1232,1235,1237,1240],{"class":168,"line":263},[166,1233,1234],{"class":180},"    testImplementation",[166,1236,808],{"class":176},[166,1238,1239],{"class":209},"\"org.robolectric:robolectric:4.16.1\"",[166,1241,184],{"class":176},[166,1243,1244,1246,1248,1251,1254],{"class":168,"line":270},[166,1245,1234],{"class":180},[166,1247,808],{"class":176},[166,1249,1250],{"class":209},"\"androidx.test:core:1.7.0\"",[166,1252,1253],{"class":176},")       ",[166,1255,1256],{"class":266},"\u002F\u002F ApplicationProvider\n",[166,1258,1259,1261,1263,1266,1269],{"class":168,"line":285},[166,1260,1234],{"class":180},[166,1262,808],{"class":176},[166,1264,1265],{"class":209},"\"androidx.test.ext:junit:1.3.0\"",[166,1267,1268],{"class":176},")  ",[166,1270,1271],{"class":266},"\u002F\u002F AndroidJUnit4\n",[166,1273,1274,1276,1278,1281],{"class":168,"line":328},[166,1275,1234],{"class":180},[166,1277,808],{"class":176},[166,1279,1280],{"class":209},"\"io.github.takahirom.roborazzi:roborazzi:1.76.0\"",[166,1282,184],{"class":176},[166,1284,1285,1287,1289,1292,1295],{"class":168,"line":343},[166,1286,1234],{"class":180},[166,1288,808],{"class":176},[166,1290,1291],{"class":209},"\"net.java.dev.jna:jna:5.19.1\"",[166,1293,1294],{"class":176},")    ",[166,1296,1297],{"class":266},"\u002F\u002F skip-sql reaches SQLite through JNA: the desktop jar\n",[166,1299,1300,1302,1304,1307,1310],{"class":168,"line":350},[166,1301,1234],{"class":180},[166,1303,808],{"class":176},[166,1305,1306],{"class":209},"\"org.ow2.asm:asm:9.10\"",[166,1308,1309],{"class":176},")           ",[166,1311,1312],{"class":266},"\u002F\u002F Robolectric's own ASM can't read JDK 26 classes\n",[166,1314,1315],{"class":168,"line":373},[166,1316,594],{"class":176},[166,1318,1319],{"class":168,"line":403},[166,1320,347],{"emptyLinePlaceholder":346},[166,1322,1323,1326,1329,1331,1334,1337,1340],{"class":168,"line":411},[166,1324,1325],{"class":176},"tasks.",[166,1327,1328],{"class":180},"withType",[166,1330,451],{"class":176},[166,1332,1333],{"class":172},"Test",[166,1335,1336],{"class":176},">().",[166,1338,1339],{"class":180},"configureEach",[166,1341,223],{"class":176},[166,1343,1344],{"class":168,"line":425},[166,1345,1346],{"class":266},"    \u002F\u002F What to draw and where: the script's -Phalfword.shots.* become the test's system properties.\n",[166,1348,1349,1352,1354,1357,1359,1362,1364,1367,1369,1372,1374,1377,1379,1382,1384,1387,1389,1392,1395],{"class":168,"line":460},[166,1350,1351],{"class":180},"    listOf",[166,1353,808],{"class":176},[166,1355,1356],{"class":209},"\"fixtures\"",[166,1358,1159],{"class":176},[166,1360,1361],{"class":209},"\"langs\"",[166,1363,1159],{"class":176},[166,1365,1366],{"class":209},"\"out\"",[166,1368,1159],{"class":176},[166,1370,1371],{"class":209},"\"devices\"",[166,1373,1159],{"class":176},[166,1375,1376],{"class":209},"\"theme\"",[166,1378,1159],{"class":176},[166,1380,1381],{"class":209},"\"settle\"",[166,1383,1159],{"class":176},[166,1385,1386],{"class":209},"\"fontScale\"",[166,1388,836],{"class":176},[166,1390,1391],{"class":180},"forEach",[166,1393,1394],{"class":176}," { key ",[166,1396,1398],{"class":1397},"sOx1s","->\n",[166,1400,1401,1403,1406,1408,1411,1414],{"class":168,"line":478},[166,1402,353],{"class":205},[166,1404,1405],{"class":176}," name ",[166,1407,206],{"class":205},[166,1409,1410],{"class":209}," \"halfword.shots.",[166,1412,1413],{"class":176},"$key",[166,1415,1416],{"class":209},"\"\n",[166,1418,1419,1422,1425,1428,1431,1433,1436,1439,1442],{"class":168,"line":484},[166,1420,1421],{"class":176},"        project.",[166,1423,1424],{"class":180},"findProperty",[166,1426,1427],{"class":176},"(name)?.",[166,1429,1430],{"class":180},"let",[166,1432,1194],{"class":176},[166,1434,1435],{"class":180},"systemProperty",[166,1437,1438],{"class":176},"(name, it.",[166,1440,1441],{"class":180},"toString",[166,1443,1444],{"class":176},"()) }\n",[166,1446,1447],{"class":168,"line":490},[166,1448,588],{"class":176},[166,1450,1451,1454,1456,1459,1461,1464],{"class":168,"line":502},[166,1452,1453],{"class":180},"    systemProperty",[166,1455,808],{"class":176},[166,1457,1458],{"class":209},"\"robolectric.graphicsMode\"",[166,1460,1159],{"class":176},[166,1462,1463],{"class":209},"\"NATIVE\"",[166,1465,184],{"class":176},[166,1467,1468,1470,1472,1475,1477,1480,1482],{"class":168,"line":529},[166,1469,1453],{"class":180},[166,1471,808],{"class":176},[166,1473,1474],{"class":209},"\"roborazzi.test.record\"",[166,1476,1159],{"class":176},[166,1478,1479],{"class":209},"\"true\"",[166,1481,1268],{"class":176},[166,1483,1484],{"class":266},"\u002F\u002F no Roborazzi plugin, nothing to compare\n",[166,1486,1487,1490,1492],{"class":168,"line":541},[166,1488,1489],{"class":176},"    maxHeapSize ",[166,1491,206],{"class":205},[166,1493,1494],{"class":209}," \"2g\"\n",[166,1496,1497],{"class":168,"line":546},[166,1498,594],{"class":176},[102,1500,1501,1507,1513,1521,1529,1542,1557,1565],{},[105,1502,1503,1506],{},[64,1504,1505],{},"The desktop JNA jar."," skip-sql reaches SQLite through JNA, and with the Android AAR alone it failed with \"Could not initialize class com.sun.jna.Native\".",[105,1508,1509,1512],{},[64,1510,1511],{},"ASM 9.10."," Robolectric's bundled ASM can't read JDK 26's classes: \"Unsupported class file major version 70\".",[105,1514,1515,1520],{},[64,1516,1517],{},[109,1518,1519],{},"isIncludeAndroidResources",", or no Micro 5: the pixel font is an app resource, and SkipUI silently falls back to the default sans serif.",[105,1522,1523,1528],{},[64,1524,1525],{},[109,1526,1527],{},"roborazzi.test.record",": Roborazzi without its Gradle plugin, recording only. There's nothing to diff against; the pictures get looked at.",[105,1530,1531,1537,1538,1541],{},[64,1532,13,1533,1536],{},[109,1534,1535],{},"halfword.shots.*"," loop."," A ",[109,1539,1540],{},"-P"," property stays with Gradle; the test JVM sees only what the build hands it as a system property. Without the loop the script's fixture list never arrives, and the test skips itself.",[105,1543,1544,1549,1550,1552,1553,1556],{},[64,1545,1546,895],{},[109,1547,1548],{},"-Pkotlin.incremental=false"," An incremental compile of Skip's included build failed with 30 errors like \"Cannot access 'class CascadeState': it is internal in file\", reproducibly, after a one-line doc-comment edit. Cause unknown. It has to be a ",[109,1551,1540],{}," flag: ",[109,1554,1555],{},"gradle.properties"," doesn't reach the included build.",[105,1558,1559,1564],{},[64,1560,1561],{},[109,1562,1563],{},"-Pkotlin.compiler.execution.strategy=in-process",": no Kotlin daemon left behind, and the script stops Gradle's afterwards. On a 16 GB Mac, a leftover JVM is the enemy.",[105,1566,1567,1572,1573,1576],{},[64,1568,1569],{},[109,1570,1571],{},"-Pandroid.onlyEnableUnitTestForTheTestedBuildType=false",", not for the render but for the release-build test above: AGP 9.2 makes unit tests for the tested build type only, so ",[109,1574,1575],{},":app:testReleaseUnitTest"," doesn't exist without it.",[11,1578,1579,1580,1583],{},"The render's flags are wrapped in a shell script, ",[109,1581,1582],{},"android-shots.sh",", which waits (up to 10 minutes) until at least 25% of memory is free before it starts Gradle. My rule since the panic: at most two heavy builds at once, and Gradle only on its own.",[157,1585,1589],{"className":1586,"code":1587,"language":1588,"meta":162,"style":162},"language-bash shiki shiki-themes monokai","scripts\u002Fandroid-shots.sh --lang en,de,es,ru                     # all 114 fixtures: 456 PNGs\nscripts\u002Fandroid-shots.sh --device small --theme light setup-rules\nscripts\u002Fandroid-shots.sh --font-scale 2.0 round-pk-describing\n","bash",[109,1590,1591,1605,1624],{"__ignoreMap":162},[166,1592,1593,1596,1599,1602],{"class":168,"line":169},[166,1594,1595],{"class":180},"scripts\u002Fandroid-shots.sh",[166,1597,1598],{"class":305}," --lang",[166,1600,1601],{"class":209}," en,de,es,ru",[166,1603,1604],{"class":266},"                     # all 114 fixtures: 456 PNGs\n",[166,1606,1607,1609,1612,1615,1618,1621],{"class":168,"line":187},[166,1608,1595],{"class":180},[166,1610,1611],{"class":305}," --device",[166,1613,1614],{"class":209}," small",[166,1616,1617],{"class":305}," --theme",[166,1619,1620],{"class":209}," light",[166,1622,1623],{"class":209}," setup-rules\n",[166,1625,1626,1628,1631,1634],{"class":168,"line":196},[166,1627,1595],{"class":180},[166,1629,1630],{"class":305}," --font-scale",[166,1632,1633],{"class":305}," 2.0",[166,1635,1636],{"class":209}," round-pk-describing\n",[34,1638,1640],{"id":1639},"what-it-caught","What it caught",[11,1642,1643,1644,1647],{},"The first full run drew all 456 pictures, none failing: every screen of the game in English, German, Spanish and Russian, with crisp whole-pixel art and Micro 5 straight from ",[109,1645,1646],{},"res\u002Ffont",". Then the pictures started telling on the code.",[612,1649,1651],{"id":1650},"a-real-bug-the-pictures-couldnt-show","A real bug the pictures couldn't show",[11,1653,1654,1655,1658,1659,1662,1663,1666,1667,1670,1671,1674],{},"In Swift, a SwiftUI view's methods run on the main actor, so a sprite's ",[109,1656,1657],{},"private func run() async"," loop runs on the main thread. skipstone, Skip's transpiler, wraps every ",[109,1660,1661],{},"async"," func that isn't bound to an actor in SkipLib's ",[109,1664,1665],{},"Async.run",", which is ",[109,1668,1669],{},"withContext(Dispatchers.Default)",": a background thread. Six loops ran there on Android: the sprites, both elimination reveals, the turn timer, the reveal hold and the Half's tremble. They wrote ",[109,1672,1673],{},"@State"," off the main thread, and the timer's haptic pulse could race the main thread's own.",[157,1676,1680],{"className":1677,"code":1678,"language":1679,"meta":162,"style":162},"language-swift shiki shiki-themes monokai","\u002F\u002F skipstone honours the attribute: the body becomes MainActor.run on Android.\n@MainActor private func run() async {\n    …\n}\n","swift",[109,1681,1682,1687,1707,1712],{"__ignoreMap":162},[166,1683,1684],{"class":168,"line":169},[166,1685,1686],{"class":266},"\u002F\u002F skipstone honours the attribute: the body becomes MainActor.run on Android.\n",[166,1688,1689,1692,1695,1698,1701,1703,1705],{"class":168,"line":187},[166,1690,1691],{"class":205},"@MainActor",[166,1693,1694],{"class":205}," private",[166,1696,1697],{"class":1397}," func",[166,1699,1700],{"class":180}," run",[166,1702,443],{"class":176},[166,1704,1661],{"class":205},[166,1706,223],{"class":176},[166,1708,1709],{"class":168,"line":196},[166,1710,1711],{"class":205},"    …\n",[166,1713,1714],{"class":168,"line":215},[166,1715,594],{"class":176},[11,1717,1718,1719,1722,1723,1726,1727,1730,1731,1733],{},"The honest part: ",[64,1720,1721],{},"no picture can show a race",", and the render serialises exactly this concurrency anyway. What a picture can show is time. The render's first version set ",[109,1724,1725],{},"kotlinx.coroutines.main.delay=true",", putting every coroutine ",[109,1728,1729],{},"delay"," on Robolectric's clock, which papered right over it until a review of the render found the cause. Without the property, a ",[109,1732,1729],{}," off the main thread waits in wall-clock time while the picture is taken on Robolectric's: with the attribute taken out again, both elimination screens stopped on the Half's lone amber dash instead of the result.",[11,1735,1736,1737,1739],{},"So now the script greps the app module's transpiled Kotlin after drawing and exits 1 if any ",[109,1738,1665],{}," is left:",[157,1741,1743],{"className":1586,"code":1742,"language":1588,"meta":162,"style":162},"if [ ! -d \"$KOTLIN\" ]; then\n  echo \"android-shots: no transpiled Kotlin in $KOTLIN: the main-thread check did not run\" >&2\nelif found=\"$(grep -rn 'Async\\.run' \"$KOTLIN\")\"; then\n  echo \"android-shots: these async funcs run off the main thread on Android (mark them @MainActor):\" >&2\n  …\nfi\n",[109,1744,1745,1772,1789,1821,1830,1835],{"__ignoreMap":162},[166,1746,1747,1750,1753,1755,1758,1761,1764,1766,1769],{"class":168,"line":169},[166,1748,1749],{"class":205},"if",[166,1751,1752],{"class":176}," [ ",[166,1754,434],{"class":205},[166,1756,1757],{"class":205}," -d",[166,1759,1760],{"class":209}," \"",[166,1762,1763],{"class":176},"$KOTLIN",[166,1765,323],{"class":209},[166,1767,1768],{"class":176}," ]; ",[166,1770,1771],{"class":205},"then\n",[166,1773,1774,1778,1781,1783,1786],{"class":168,"line":187},[166,1775,1777],{"class":1776},"sYf5A","  echo",[166,1779,1780],{"class":209}," \"android-shots: no transpiled Kotlin in ",[166,1782,1763],{"class":176},[166,1784,1785],{"class":209},": the main-thread check did not run\"",[166,1787,1788],{"class":205}," >&2\n",[166,1790,1791,1794,1797,1799,1802,1805,1808,1811,1813,1816,1819],{"class":168,"line":196},[166,1792,1793],{"class":205},"elif",[166,1795,1796],{"class":176}," found",[166,1798,206],{"class":205},[166,1800,1801],{"class":209},"\"$(",[166,1803,1804],{"class":180},"grep",[166,1806,1807],{"class":305}," -rn",[166,1809,1810],{"class":209}," 'Async\\.run' \"",[166,1812,1763],{"class":176},[166,1814,1815],{"class":209},"\")\"",[166,1817,1818],{"class":176},"; ",[166,1820,1771],{"class":205},[166,1822,1823,1825,1828],{"class":168,"line":215},[166,1824,1777],{"class":1776},[166,1826,1827],{"class":209}," \"android-shots: these async funcs run off the main thread on Android (mark them @MainActor):\"",[166,1829,1788],{"class":205},[166,1831,1832],{"class":168,"line":226},[166,1833,1834],{"class":180},"  …\n",[166,1836,1837],{"class":168,"line":263},[166,1838,1839],{"class":205},"fi\n",[612,1841,1843],{"id":1842},"alerts-in-the-phones-language","Alerts in the phone's language",[11,1845,1846],{},"Halfword's UI language is a setting, independent of the phone's. On Android, every alert ignored it.",[1848,1849],"gallery",{":images":1850},"[{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Falert-before.jpg\",\"alt\":\"Before: a German table, an English alert, on Material’s grey\"},{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Falert-after.jpg\",\"alt\":\"After: the alert speaks German, on the app’s own ink\"}]",[11,1852,1853,1854,1857,1858,1861,1862,1865,1866,1868],{},"A Compose dialog is its own window, and its view takes ",[109,1855,1856],{},"LocalConfiguration"," fresh from the phone, so the locale the app's root sets never reaches it, and SkipUI resolves the alert's strings there. Text formatted before it reaches the alert comes out right, so every word of all 12 alerts is now a ready-made string in the UI language, ",[109,1859,1860],{},"Text(verbatim: model.string(…))",". Menus too, since they're pop-up windows as well (reasoned, not seen: no fixture opens a menu). And the strings check now refuses a catalog ",[109,1863,1864],{},"Text"," inside an alert or a menu, so a catalog key written into an alert or a menu can't bring it back (a helper that returns a ",[109,1867,1864],{}," still could, and is kept out by hand).",[612,1870,1872],{"id":1871},"без-выбывания","\"Без выбывания\"",[11,1874,1875],{},"In the Russian Rules screen, the tie-break row lost both ends of \"No elimination\".",[1848,1877],{":images":1878},"[{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fsegment-ru-before.jpg\",\"alt\":\"Android, before: every segment gets the same share, and “Без выбывания” loses both ends\"},{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fsegment-ru-after.jpg\",\"alt\":\"Android, after: the long label keeps its width\"},{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fsegment-ru-ios.jpg\",\"alt\":\"iPhone, for comparison\"}]",[11,1880,1881,1882,1885,1886,1889,1890,1893,1894,1897,1898,1901],{},"SwiftUI gives a segment at least its label's width. SkipUI's ",[109,1883,1884],{},"HStack"," gives every child the same share, and its ",[109,1887,1888],{},"ViewThatFits"," only checks the sum of the labels, so the row fit on paper and the widest label got cut. English \"No elimination\" lost 6 points too, just less visibly. ",[109,1891,1892],{},"SegmentedChoice"," now measures each label, and a segment whose label is wider than its share keeps its label's width. That takes a ",[109,1895,1896],{},".frame(width:)",", since SkipUI caps even plain text at the share, and a fresh ",[109,1899,1900],{},"ForEach"," id, because SkipUI's containers remember that their content once filled the row.",[612,1903,1905],{"id":1904},"the-edge-that-never-showed","The edge that never showed",[11,1907,1908],{},"When a list runs on under the buttons, a hairline marks the edge, so the buttons don't read as the end of the list. On Android it never appeared.",[1848,1910],{":images":1911},"[{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fscroll-edge-before.jpg\",\"alt\":\"Before: the results run on under the buttons, with nothing to say so\"},{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fscroll-edge-after.jpg\",\"alt\":\"After: a hairline marks the edge\"}]",[11,1913,1914,1915,1918,1919,1922],{},"SkipUI builds ",[109,1916,1917],{},"onGeometryChange"," on Compose's ",[109,1920,1921],{},"boundsInRoot()",", which clips a view's bounds to every ancestor. Inside a scroll view, the content never seems to end below the edge. Now a 1-point marker sits 12 points above the content's end and reports where it is, or that it has been clipped away, and the edge is drawn while the marker is at or below the bottom.",[612,1924,1926],{"id":1925},"and-a-handful-more","And a handful more",[102,1928,1929,1935,1941,1951],{},[105,1930,1931,1934],{},[64,1932,1933],{},"The wallpaper's colours."," On Android 12 and later SkipUI builds Material's colour scheme from the phone's wallpaper. On the JVM that meant Android 16's default palette: a navy knob on the switches' amber track, alerts on Material's grey (the \"before\" alert above). The Android shell now sets the scheme from the app's palette, with every text-on-colour pair tested for contrast.",[105,1936,1937,1940],{},[64,1938,1939],{},"Tiny chevrons."," SkipUI draws an SF Symbol as a Material icon in a box the size of the font, and the vote's arrows came out at about 4 × 8 dp. They're drawn strokes now, on both platforms.",[105,1942,1943,1946,1947,1950],{},[64,1944,1945],{},"The cascade."," When the Lovers go out together, their two cards floated alone and the line explaining why sat about 200 dp lower. SkipUI's ",[109,1948,1949],{},"fixedSize"," measures the row but leaves it free to grow, and the cards filled it. Each card now measures itself, and the row takes the tallest.",[105,1952,1953,1956,1957,1960],{},[64,1954,1955],{},"The top row."," \"Round 2 · PK 1 of 2 · Turn 1 of 2\" broke inside a part in German, Spanish and Russian (\"Turno 1 de \u002F 2\"). Its first fix then broke a part between letters at Android's largest font size, which the render's ",[109,1958,1959],{},"--font-scale"," flag caught.",[1848,1962],{":images":1963},"[{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fchevrons-before.jpg\",\"alt\":\"Before: SF Symbols as Material icons, about 4 × 8 dp\"},{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fchevrons-after.jpg\",\"alt\":\"After: drawn strokes, the same on both platforms\"},{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fcascade-before.jpg\",\"alt\":\"Before: the Lovers’ cards float, and their line sits about 200 dp lower\"},{\"src\":\"\u002Fimages\u002Fhalfword_android\u002Fcascade-after.jpg\",\"alt\":\"After: cards and line together, as on iPhone\"}]",[11,1965,1966],{},"After the alerts, four reviewers went through all 456 pictures closely and found nine more faults. Eight are fixed, by six causes. As far as SkipUI's source can tell, every one is SkipUI and Compose doing what they'd do on a phone; the Robolectric quirks were the harness's own. After the fixes, 193 of the 456 pictures changed, each where a fix meant it to or by a sprite's frame.",[11,1968,1969],{},"A few things are waiting for me. The new chevrons change iOS too, so they need my eyes on an iPhone. And Mr. White's guess field (and the Players name field) sits 16 dp further in on Android, because SkipUI's text field keeps Material's own padding inside the app's. The fix needs either a platform condition in shared code, which needs my leave, or a shell-wide setting that would also strip the rename alert's field, so that one's my call.",[34,1971,1973],{"id":1972},"the-numbers","The numbers",[102,1975,1976,1986,1995,2005,2011,2014],{},[105,1977,1978,1981,1982,1985],{},[64,1979,1980],{},"456 pictures"," (114 fixtures × 4 languages) in ",[64,1983,1984],{},"2 min 19 s",", about 130 s of it drawing: roughly 0.3 s a picture. English alone takes about 50 s.",[105,1987,1988,1991,1992,1994],{},[64,1989,1990],{},"Byte-identical, run after run, on a quiet Mac",": three runs of all 114 English pictures matched. With eight CPU burners on the eight cores, 1 of 114 still had a sprite one frame off (measured before the ",[109,1993,1691],{}," fix; not re-run under load since).",[105,1996,1997,2000,2001,2004],{},[64,1998,1999],{},"1081 × 2401 px"," per picture: a Pixel 7 at ",[109,2002,2003],{},"w412dp-h915dp-…-night-420dpi",". Or a small phone (360 × 640 dp at xhdpi, 720 × 1280 px), the light theme, any font scale.",[105,2006,2007,2010],{},[64,2008,2009],{},"One Gradle build",", a test JVM of about 1.9 GB, the daemon stopped afterwards.",[105,2012,2013],{},"The stack: Robolectric 4.16.1, Roborazzi 1.76, AGP 9.2, Kotlin 2.3, Compose BOM 2026.05.01, JDK 26.",[105,2015,2016],{},"Emulators booted for any of this: zero.",[34,2018,2020],{"id":2019},"what-it-cant-tell-you","What it can't tell you",[11,2022,2023],{},"These are Robolectric renders on a Mac, not a phone, and here's what that leaves out:",[102,2025,2026,2031,2037,2043,2049,2055],{},[105,2027,2028,2030],{},[64,2029,1864],{}," is drawn with the fonts of Robolectric's Android image (Roboto, Cyrillic included), but rasterised by the Mac's FreeType, so hinting can differ from a phone's by a pixel. A Samsung font is never seen.",[105,2032,2033,2036],{},[64,2034,2035],{},"No system bars."," No status bar, no navigation bar, no cutout, so the top bar sits at y = 0, where a phone puts it under the status bar.",[105,2038,2039,2042],{},[64,2040,2041],{},"Shadows and blur"," may come out flat or missing. The app uses none of its own, but Material's alert has an elevation.",[105,2044,2045,2048],{},[64,2046,2047],{},"No touch."," No ripple, no pressed state, no keyboard, no TalkBack, and nothing vibrates. Only what a fixture opens gets drawn: menus, sheets and drags need a person.",[105,2050,2051,2054],{},[64,2052,2053],{},"No real device."," The Pixel 7 is a set of qualifiers, not a Pixel 7. The About screen proudly reads \"robolectric · Android 16 (API 36)\".",[105,2056,2057,2060],{},[64,2058,2059],{},"Borrowed internals."," The determinism reads kotlinx.coroutines' internals by reflection; if an update moves them, the render falls back on thread states and says so.",[11,2062,2063],{},"So Android is in a good place, and nobody has held it yet. The debug and release APKs build, every test passes as Swift and as Kotlin, and every screen a fixture opens renders. But no APK has been installed on anything. That's next, and that one needs a phone.",[11,2065,2066],{},"Since then the release build has run whole games on the emulator, and Halfword is out on Google Play.",[34,2068,2070],{"id":2069},"steal-this","Steal this",[11,2072,2073,2074,2077,2078,2080],{},"If your machine flinches at the emulator too, most of the recipe is in the code above. The one piece I've only described is the wait between main-thread tasks, and it took the longest to get right, so here it is, condensed. ",[109,2075,2076],{},"settle"," is what ",[109,2079,515],{}," calls:",[157,2082,2084],{"className":159,"code":2083,"language":161,"meta":162,"style":162},"\u002F\u002F\u002F Runs what is due on the main looper, one task at a time, laying out every window after each\n\u002F\u002F\u002F and waiting for the coroutine pool to finish what the task started, until nothing is due.\nprivate fun settle(looper: ShadowLooper) {\n    var rounds = 0\n    while (rounds \u003C 10_000) {\n        rounds += 1\n        var waits = 0\n        while (coroutinesBusy()) {\n            if (waits == 20_000) { giveUp(\"the coroutine pool still busy after 2 s\"); break }\n            LockSupport.parkNanos(100_000L)\n            waits += 1\n        }\n        layOut()                          \u002F\u002F a layout can compose views, whose tasks start\n        if (coroutinesBusy()) continue\n        if (looper.isIdle) return\n        looper.runOneTask()\n    }\n    giveUp(\"the main looper not idle after 10,000 rounds\")   \u002F\u002F the picture fails as \"unsettled\"\n}\n",[109,2085,2086,2091,2096,2111,2124,2138,2148,2160,2173,2205,2220,2229,2233,2244,2257,2267,2275,2279,2295],{"__ignoreMap":162},[166,2087,2088],{"class":168,"line":169},[166,2089,2090],{"class":266},"\u002F\u002F\u002F Runs what is due on the main looper, one task at a time, laying out every window after each\n",[166,2092,2093],{"class":168,"line":187},[166,2094,2095],{"class":266},"\u002F\u002F\u002F and waiting for the coroutine pool to finish what the task started, until nothing is due.\n",[166,2097,2098,2100,2102,2105,2107,2109],{"class":168,"line":196},[166,2099,643],{"class":205},[166,2101,232],{"class":205},[166,2103,2104],{"class":180}," settle",[166,2106,970],{"class":176},[166,2108,973],{"class":172},[166,2110,700],{"class":176},[166,2112,2113,2116,2119,2121],{"class":168,"line":215},[166,2114,2115],{"class":205},"    var",[166,2117,2118],{"class":176}," rounds ",[166,2120,206],{"class":205},[166,2122,2123],{"class":305}," 0\n",[166,2125,2126,2128,2131,2133,2136],{"class":168,"line":226},[166,2127,1003],{"class":205},[166,2129,2130],{"class":176}," (rounds ",[166,2132,451],{"class":205},[166,2134,2135],{"class":305}," 10_000",[166,2137,700],{"class":176},[166,2139,2140,2143,2145],{"class":168,"line":263},[166,2141,2142],{"class":176},"        rounds ",[166,2144,469],{"class":205},[166,2146,2147],{"class":305}," 1\n",[166,2149,2150,2153,2156,2158],{"class":168,"line":270},[166,2151,2152],{"class":205},"        var",[166,2154,2155],{"class":176}," waits ",[166,2157,206],{"class":205},[166,2159,2123],{"class":305},[166,2161,2162,2165,2167,2170],{"class":168,"line":285},[166,2163,2164],{"class":205},"        while",[166,2166,431],{"class":176},[166,2168,2169],{"class":180},"coroutinesBusy",[166,2171,2172],{"class":176},"()) {\n",[166,2174,2175,2178,2181,2183,2186,2189,2192,2194,2197,2200,2203],{"class":168,"line":328},[166,2176,2177],{"class":205},"            if",[166,2179,2180],{"class":176}," (waits ",[166,2182,1049],{"class":205},[166,2184,2185],{"class":305}," 20_000",[166,2187,2188],{"class":176},") { ",[166,2190,2191],{"class":180},"giveUp",[166,2193,808],{"class":176},[166,2195,2196],{"class":209},"\"the coroutine pool still busy after 2 s\"",[166,2198,2199],{"class":176},"); ",[166,2201,2202],{"class":205},"break",[166,2204,882],{"class":176},[166,2206,2207,2210,2213,2215,2218],{"class":168,"line":343},[166,2208,2209],{"class":176},"            LockSupport.",[166,2211,2212],{"class":180},"parkNanos",[166,2214,808],{"class":176},[166,2216,2217],{"class":305},"100_000L",[166,2219,184],{"class":176},[166,2221,2222,2225,2227],{"class":168,"line":350},[166,2223,2224],{"class":176},"            waits ",[166,2226,469],{"class":205},[166,2228,2147],{"class":305},[166,2230,2231],{"class":168,"line":373},[166,2232,582],{"class":176},[166,2234,2235,2238,2241],{"class":168,"line":403},[166,2236,2237],{"class":180},"        layOut",[166,2239,2240],{"class":176},"()                          ",[166,2242,2243],{"class":266},"\u002F\u002F a layout can compose views, whose tasks start\n",[166,2245,2246,2248,2250,2252,2254],{"class":168,"line":411},[166,2247,1043],{"class":205},[166,2249,431],{"class":176},[166,2251,2169],{"class":180},[166,2253,668],{"class":176},[166,2255,2256],{"class":205},"continue\n",[166,2258,2259,2261,2264],{"class":168,"line":425},[166,2260,1043],{"class":205},[166,2262,2263],{"class":176}," (looper.isIdle) ",[166,2265,2266],{"class":205},"return\n",[166,2268,2269,2271,2273],{"class":168,"line":460},[166,2270,1071],{"class":176},[166,2272,1074],{"class":180},[166,2274,400],{"class":176},[166,2276,2277],{"class":168,"line":478},[166,2278,588],{"class":176},[166,2280,2281,2284,2286,2289,2292],{"class":168,"line":484},[166,2282,2283],{"class":180},"    giveUp",[166,2285,808],{"class":176},[166,2287,2288],{"class":209},"\"the main looper not idle after 10,000 rounds\"",[166,2290,2291],{"class":176},")   ",[166,2293,2294],{"class":266},"\u002F\u002F the picture fails as \"unsettled\"\n",[166,2296,2297],{"class":168,"line":490},[166,2298,594],{"class":176},[11,2300,2301,2304,2305,2308,2309,2312],{},[109,2302,2303],{},"coroutinesBusy()"," asks the scheduler behind ",[109,2306,2307],{},"Dispatchers.Default"," (and ",[109,2310,2311],{},"IO",", the same pool) whether a CPU permit is taken, a blocking task is running or a task is queued. It counts a task from the moment it's dispatched, before any worker wakes, which is exactly what the threads' states miss. It's kotlinx.coroutines 1.11's internal API, so it's read by reflection, once per process:",[157,2314,2316],{"className":159,"code":2315,"language":161,"meta":162,"style":162},"private object Pool {\n    val read: (() -> Boolean)? = try {\n        val dispatcher: Any = kotlinx.coroutines.Dispatchers.Default\n        var type: Class\u003C*>? = dispatcher.javaClass\n        var field: java.lang.reflect.Field? = null\n        while (type != null && field == null) {   \u002F\u002F `coroutineScheduler`, somewhere up the class chain\n            field = type.declaredFields.firstOrNull { it.name == \"coroutineScheduler\" }\n            type = type.superclass\n        }\n        field!!.isAccessible = true\n        val scheduler: Any = field.get(dispatcher)!!\n        val kind = scheduler.javaClass\n        val state = kind.getDeclaredField(\"controlState\\$volatile\").also { it.isAccessible = true }\n        val blockingMask = kind.getDeclaredField(\"BLOCKING_MASK\").also { it.isAccessible = true }.getLong(null)\n        val blockingShift = kind.getDeclaredField(\"BLOCKING_SHIFT\").also { it.isAccessible = true }.getInt(null)\n        val core = kind.getField(\"corePoolSize\").getInt(scheduler)\n        val available = kind.getMethod(\"availableCpuPermits\", java.lang.Long.TYPE)\n        val queues = listOf(kind.getField(\"globalCpuQueue\").get(scheduler)!!,\n                            kind.getField(\"globalBlockingQueue\").get(scheduler)!!)\n        val size = queues[0].javaClass.superclass.getMethod(\"getSize\")\n        val reader: () -> Boolean = {\n            val now = state.getLong(scheduler)\n            val running = core - (available.invoke(scheduler, now) as Int)\n            val blocking = (now and blockingMask) shr blockingShift\n            running > 0 || blocking > 0L || queues.any { (size.invoke(it) as Int) > 0 }\n        }\n        reader()   \u002F\u002F fail here, not halfway through a render, if an update moved something\n        reader\n    } catch (_: Throwable) {\n        null       \u002F\u002F then fall back on the worker threads' states, and say so in the log\n    }\n}\n",[109,2317,2318,2330,2350,2365,2383,2403,2432,2455,2465,2469,2482,2505,2517,2556,2596,2635,2661,2682,2717,2739,2766,2782,2799,2825,2837,2881,2885,2895,2901,2918,2927,2932],{"__ignoreMap":162},[166,2319,2320,2322,2325,2328],{"class":168,"line":169},[166,2321,643],{"class":205},[166,2323,2324],{"class":205}," object",[166,2326,2327],{"class":172}," Pool",[166,2329,223],{"class":176},[166,2331,2332,2334,2337,2340,2343,2345,2348],{"class":168,"line":187},[166,2333,794],{"class":205},[166,2335,2336],{"class":176}," read: (() ",[166,2338,2339],{"class":1397},"->",[166,2341,2342],{"class":176}," Boolean)? ",[166,2344,206],{"class":205},[166,2346,2347],{"class":205}," try",[166,2349,223],{"class":176},[166,2351,2352,2354,2357,2360,2362],{"class":168,"line":196},[166,2353,353],{"class":205},[166,2355,2356],{"class":176}," dispatcher: ",[166,2358,2359],{"class":172},"Any",[166,2361,856],{"class":205},[166,2363,2364],{"class":176}," kotlinx.coroutines.Dispatchers.Default\n",[166,2366,2367,2369,2372,2375,2378,2380],{"class":168,"line":215},[166,2368,2152],{"class":205},[166,2370,2371],{"class":176}," type: ",[166,2373,2374],{"class":172},"Class",[166,2376,2377],{"class":176},"\u003C*>? ",[166,2379,206],{"class":205},[166,2381,2382],{"class":176}," dispatcher.javaClass\n",[166,2384,2385,2387,2389,2392,2395,2398,2400],{"class":168,"line":226},[166,2386,2152],{"class":205},[166,2388,853],{"class":205},[166,2390,2391],{"class":176},": ",[166,2393,2394],{"class":172},"java",[166,2396,2397],{"class":176},".lang.reflect.Field? ",[166,2399,206],{"class":205},[166,2401,2402],{"class":305}," null\n",[166,2404,2405,2407,2410,2413,2416,2419,2421,2424,2426,2429],{"class":168,"line":263},[166,2406,2164],{"class":205},[166,2408,2409],{"class":176}," (type ",[166,2411,2412],{"class":205},"!=",[166,2414,2415],{"class":305}," null",[166,2417,2418],{"class":205}," &&",[166,2420,853],{"class":205},[166,2422,2423],{"class":205}," ==",[166,2425,2415],{"class":305},[166,2427,2428],{"class":176},") {   ",[166,2430,2431],{"class":266},"\u002F\u002F `coroutineScheduler`, somewhere up the class chain\n",[166,2433,2434,2437,2439,2442,2445,2448,2450,2453],{"class":168,"line":270},[166,2435,2436],{"class":205},"            field",[166,2438,856],{"class":205},[166,2440,2441],{"class":176}," type.declaredFields.",[166,2443,2444],{"class":180},"firstOrNull",[166,2446,2447],{"class":176}," { it.name ",[166,2449,1049],{"class":205},[166,2451,2452],{"class":209}," \"coroutineScheduler\"",[166,2454,882],{"class":176},[166,2456,2457,2460,2462],{"class":168,"line":285},[166,2458,2459],{"class":176},"            type ",[166,2461,206],{"class":205},[166,2463,2464],{"class":176}," type.superclass\n",[166,2466,2467],{"class":168,"line":328},[166,2468,582],{"class":176},[166,2470,2471,2474,2477,2479],{"class":168,"line":343},[166,2472,2473],{"class":205},"        field!!",[166,2475,2476],{"class":176},".isAccessible ",[166,2478,206],{"class":205},[166,2480,2481],{"class":305}," true\n",[166,2483,2484,2486,2489,2491,2493,2495,2497,2499,2502],{"class":168,"line":350},[166,2485,353],{"class":205},[166,2487,2488],{"class":176}," scheduler: ",[166,2490,2359],{"class":172},[166,2492,856],{"class":205},[166,2494,853],{"class":205},[166,2496,895],{"class":176},[166,2498,898],{"class":180},[166,2500,2501],{"class":176},"(dispatcher)",[166,2503,2504],{"class":205},"!!\n",[166,2506,2507,2509,2512,2514],{"class":168,"line":373},[166,2508,353],{"class":205},[166,2510,2511],{"class":176}," kind ",[166,2513,206],{"class":205},[166,2515,2516],{"class":176}," scheduler.javaClass\n",[166,2518,2519,2521,2524,2526,2529,2531,2533,2536,2539,2542,2544,2547,2550,2552,2554],{"class":168,"line":403},[166,2520,353],{"class":205},[166,2522,2523],{"class":176}," state ",[166,2525,206],{"class":205},[166,2527,2528],{"class":176}," kind.",[166,2530,861],{"class":180},[166,2532,808],{"class":176},[166,2534,2535],{"class":209},"\"controlState",[166,2537,2538],{"class":305},"\\$",[166,2540,2541],{"class":209},"volatile\"",[166,2543,836],{"class":176},[166,2545,2546],{"class":180},"also",[166,2548,2549],{"class":176}," { it.isAccessible ",[166,2551,206],{"class":205},[166,2553,879],{"class":305},[166,2555,882],{"class":176},[166,2557,2558,2560,2563,2565,2567,2569,2571,2574,2576,2578,2580,2582,2584,2587,2590,2592,2594],{"class":168,"line":411},[166,2559,353],{"class":205},[166,2561,2562],{"class":176}," blockingMask ",[166,2564,206],{"class":205},[166,2566,2528],{"class":176},[166,2568,861],{"class":180},[166,2570,808],{"class":176},[166,2572,2573],{"class":209},"\"BLOCKING_MASK\"",[166,2575,836],{"class":176},[166,2577,2546],{"class":180},[166,2579,2549],{"class":176},[166,2581,206],{"class":205},[166,2583,879],{"class":305},[166,2585,2586],{"class":176}," }.",[166,2588,2589],{"class":180},"getLong",[166,2591,808],{"class":176},[166,2593,844],{"class":305},[166,2595,184],{"class":176},[166,2597,2598,2600,2603,2605,2607,2609,2611,2614,2616,2618,2620,2622,2624,2626,2629,2631,2633],{"class":168,"line":425},[166,2599,353],{"class":205},[166,2601,2602],{"class":176}," blockingShift ",[166,2604,206],{"class":205},[166,2606,2528],{"class":176},[166,2608,861],{"class":180},[166,2610,808],{"class":176},[166,2612,2613],{"class":209},"\"BLOCKING_SHIFT\"",[166,2615,836],{"class":176},[166,2617,2546],{"class":180},[166,2619,2549],{"class":176},[166,2621,206],{"class":205},[166,2623,879],{"class":305},[166,2625,2586],{"class":176},[166,2627,2628],{"class":180},"getInt",[166,2630,808],{"class":176},[166,2632,844],{"class":305},[166,2634,184],{"class":176},[166,2636,2637,2639,2642,2644,2646,2649,2651,2654,2656,2658],{"class":168,"line":460},[166,2638,353],{"class":205},[166,2640,2641],{"class":176}," core ",[166,2643,206],{"class":205},[166,2645,2528],{"class":176},[166,2647,2648],{"class":180},"getField",[166,2650,808],{"class":176},[166,2652,2653],{"class":209},"\"corePoolSize\"",[166,2655,836],{"class":176},[166,2657,2628],{"class":180},[166,2659,2660],{"class":176},"(scheduler)\n",[166,2662,2663,2665,2668,2670,2672,2674,2676,2679],{"class":168,"line":478},[166,2664,353],{"class":205},[166,2666,2667],{"class":176}," available ",[166,2669,206],{"class":205},[166,2671,2528],{"class":176},[166,2673,828],{"class":180},[166,2675,808],{"class":176},[166,2677,2678],{"class":209},"\"availableCpuPermits\"",[166,2680,2681],{"class":176},", java.lang.Long.TYPE)\n",[166,2683,2684,2686,2689,2691,2694,2697,2699,2701,2704,2706,2708,2711,2714],{"class":168,"line":484},[166,2685,353],{"class":205},[166,2687,2688],{"class":176}," queues ",[166,2690,206],{"class":205},[166,2692,2693],{"class":180}," listOf",[166,2695,2696],{"class":176},"(kind.",[166,2698,2648],{"class":180},[166,2700,808],{"class":176},[166,2702,2703],{"class":209},"\"globalCpuQueue\"",[166,2705,836],{"class":176},[166,2707,898],{"class":180},[166,2709,2710],{"class":176},"(scheduler)",[166,2712,2713],{"class":205},"!!",[166,2715,2716],{"class":176},",\n",[166,2718,2719,2722,2724,2726,2729,2731,2733,2735,2737],{"class":168,"line":490},[166,2720,2721],{"class":176},"                            kind.",[166,2723,2648],{"class":180},[166,2725,808],{"class":176},[166,2727,2728],{"class":209},"\"globalBlockingQueue\"",[166,2730,836],{"class":176},[166,2732,898],{"class":180},[166,2734,2710],{"class":176},[166,2736,2713],{"class":205},[166,2738,184],{"class":176},[166,2740,2741,2743,2746,2748,2751,2754,2757,2759,2761,2764],{"class":168,"line":502},[166,2742,353],{"class":205},[166,2744,2745],{"class":176}," size ",[166,2747,206],{"class":205},[166,2749,2750],{"class":176}," queues[",[166,2752,2753],{"class":305},"0",[166,2755,2756],{"class":176},"].javaClass.superclass.",[166,2758,828],{"class":180},[166,2760,808],{"class":176},[166,2762,2763],{"class":209},"\"getSize\"",[166,2765,184],{"class":176},[166,2767,2768,2770,2773,2775,2778,2780],{"class":168,"line":529},[166,2769,353],{"class":205},[166,2771,2772],{"class":176}," reader: () ",[166,2774,2339],{"class":1397},[166,2776,2777],{"class":176}," Boolean ",[166,2779,206],{"class":205},[166,2781,223],{"class":176},[166,2783,2784,2787,2790,2792,2795,2797],{"class":168,"line":541},[166,2785,2786],{"class":205},"            val",[166,2788,2789],{"class":176}," now ",[166,2791,206],{"class":205},[166,2793,2794],{"class":176}," state.",[166,2796,2589],{"class":180},[166,2798,2660],{"class":176},[166,2800,2801,2803,2806,2808,2810,2812,2815,2817,2820,2822],{"class":168,"line":546},[166,2802,2786],{"class":205},[166,2804,2805],{"class":176}," running ",[166,2807,206],{"class":205},[166,2809,2641],{"class":176},[166,2811,1102],{"class":205},[166,2813,2814],{"class":176}," (available.",[166,2816,839],{"class":180},[166,2818,2819],{"class":176},"(scheduler, now) ",[166,2821,904],{"class":205},[166,2823,2824],{"class":176}," Int)\n",[166,2826,2827,2829,2832,2834],{"class":168,"line":557},[166,2828,2786],{"class":205},[166,2830,2831],{"class":176}," blocking ",[166,2833,206],{"class":205},[166,2835,2836],{"class":176}," (now and blockingMask) shr blockingShift\n",[166,2838,2839,2842,2844,2846,2848,2850,2852,2854,2856,2859,2862,2865,2867,2870,2872,2875,2877,2879],{"class":168,"line":579},[166,2840,2841],{"class":176},"            running ",[166,2843,1060],{"class":205},[166,2845,741],{"class":305},[166,2847,1055],{"class":205},[166,2849,2831],{"class":176},[166,2851,1060],{"class":205},[166,2853,1052],{"class":305},[166,2855,1055],{"class":205},[166,2857,2858],{"class":176}," queues.",[166,2860,2861],{"class":180},"any",[166,2863,2864],{"class":176}," { (size.",[166,2866,839],{"class":180},[166,2868,2869],{"class":176},"(it) ",[166,2871,904],{"class":205},[166,2873,2874],{"class":176}," Int) ",[166,2876,1060],{"class":205},[166,2878,741],{"class":305},[166,2880,882],{"class":176},[166,2882,2883],{"class":168,"line":585},[166,2884,582],{"class":176},[166,2886,2887,2890,2892],{"class":168,"line":591},[166,2888,2889],{"class":180},"        reader",[166,2891,337],{"class":176},[166,2893,2894],{"class":266},"\u002F\u002F fail here, not halfway through a render, if an update moved something\n",[166,2896,2898],{"class":168,"line":2897},28,[166,2899,2900],{"class":176},"        reader\n",[166,2902,2904,2907,2910,2913,2916],{"class":168,"line":2903},29,[166,2905,2906],{"class":176},"    } ",[166,2908,2909],{"class":205},"catch",[166,2911,2912],{"class":176}," (_: ",[166,2914,2915],{"class":172},"Throwable",[166,2917,700],{"class":176},[166,2919,2921,2924],{"class":168,"line":2920},30,[166,2922,2923],{"class":305},"        null",[166,2925,2926],{"class":266},"       \u002F\u002F then fall back on the worker threads' states, and say so in the log\n",[166,2928,2930],{"class":168,"line":2929},31,[166,2931,588],{"class":176},[166,2933,2935],{"class":168,"line":2934},32,[166,2936,594],{"class":176},[11,2938,2939],{},"The short version:",[2941,2942,2943,2946,2956,2959,2962],"ol",{},[105,2944,2945],{},"Give your debug build a way to open any screen from a file, and to say when it's up.",[105,2947,2948,2949,2952,2953,895],{},"Write one Robolectric test with ",[109,2950,2951],{},"@GraphicsMode(NATIVE)"," that starts your real activity, once per screen, and saves it with Roborazzi's ",[109,2954,2955],{},"captureScreenRoboImage",[105,2957,2958],{},"Move the clock yourself, one main-thread task at a time, lay out every window as you go, and wait for the coroutine pool between tasks. Throw away one warm-up render first: the first activity of a process is slow enough to put a sprite a frame late.",[105,2960,2961],{},"Fail any picture you can't vouch for.",[105,2963,2964],{},"Look at the pictures. Nearly every bug above was found by looking at one, not by a diff.",[34,2966,2968],{"id":2967},"and-the-store-screenshots","And the store screenshots",[11,2970,2971],{},"The fixtures earn their keep twice: the store screenshots come from them too, for both stores, made by scripts rather than by me with a phone.",[11,2973,2974,2977,2978,2980,2981,2983,2984,2987],{},[64,2975,2976],{},"App Store."," A script builds the Debug app (a release build ignores fixtures), boots one pinned simulator and, for each of ten fixtures, launches the app with ",[109,2979,1158],{}," and ",[109,2982,1162],{}," set, waits for its ready file, sets the status bar to 9:41 and takes the shot. A Python script then frames each one: the Half stands on the phone's rim at 10× and says a caption in a speech bubble like the one on Home, in Micro 5, or in a heavy system font where Micro 5 has no letters (Russian, Ukrainian). Headless Chrome draws the frames at exactly 1320 × 2868, the size App Store Connect scales down for every smaller iPhone, and fastlane's ",[109,2985,2986],{},"deliver"," uploads them, one at a time, because App Store Connect chokes on parallel uploads to one version.",[11,2989,2990,2993,2994,2997],{},[64,2991,2992],{},"Google Play."," Same fixtures, no simulator: the render from this post draws eight of them in six languages, and the same script frames them at 1080 × 1920. That size isn't a choice. Play refuses a screenshot with one side more than twice the other, and the Pixel 7's 1081 × 2401 is longer than that, so the frame is 9:16, with the Half at 8× and Micro 5 at 12 px a font pixel. The JVM render has no status bar, so the frame gives the screen 24 dp of ink at the top and bottom, where a phone keeps its bars. Then fastlane's ",[109,2995,2996],{},"supply"," uploads the set, from lanes that only know Play's internal and closed testing tracks: production stays a button in Play Console.",[11,2999,3000],{},[3001,3002],"img",{"alt":3003,"src":3004,"width":3005},"Google Play's second screenshot: the Half peeks over the phone's rim under “Everyone gets a secret word”, above Chen's card as the JVM drew it","\u002Fimages\u002Fhalfword_android\u002Fplay-reveal-civilian.jpg",270,[11,3007,3008,3011],{},[64,3009,3010],{},"A stale set can't go up."," Once, a set of App Store screenshots came from a Debug build left over from before a rules change: the old titles, and one picture that said \"Fixture failed\". So every set is now stamped with a hash of the sources it was drawn from, the upload lanes refuse a set whose hash isn't the checkout's, and a run deletes the old set before it starts, so a failed run leaves nothing behind to upload.",[34,3013,3015],{"id":3014},"thats-it","That's it",[11,3017,3018],{},"The render still doesn't need the emulator. My Mac draws Halfword's Android screens in the time it takes to make a coffee, in four languages, the same pixels every time it's left alone. And \"Без выбывания\" fits.",[3020,3021,3022],"style",{},"html pre.shiki code .sHkqI, html code.shiki .sHkqI{--shiki-default:#A6E22E}html pre.shiki code .s7s5_, html code.shiki .s7s5_{--shiki-default:#AE81FF}html pre.shiki code .s_Ekj, html code.shiki .s_Ekj{--shiki-default:#E6DB74}html pre.shiki code .snpHw, html code.shiki .snpHw{--shiki-default:#88846F}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s8I7P, html code.shiki .s8I7P{--shiki-default:#F92672}html pre.shiki code .sOx1s, html code.shiki .sOx1s{--shiki-default:#66D9EF;--shiki-default-font-style:italic}html pre.shiki code .sCdxs, html code.shiki .sCdxs{--shiki-default:#F8F8F2}html pre.shiki code .sYf5A, html code.shiki .sYf5A{--shiki-default:#66D9EF}html pre.shiki code .soZtE, html code.shiki .soZtE{--shiki-default:#A6E22E;--shiki-default-text-decoration:underline}",{"title":162,"searchDepth":187,"depth":187,"links":3024},[3025,3026,3027,3032,3039,3040,3041,3042,3043],{"id":36,"depth":187,"text":37},{"id":81,"depth":187,"text":82},{"id":128,"depth":187,"text":129,"children":3028},[3029,3030,3031],{"id":614,"depth":196,"text":615},{"id":929,"depth":196,"text":930},{"id":1170,"depth":196,"text":1171},{"id":1639,"depth":187,"text":1640,"children":3033},[3034,3035,3036,3037,3038],{"id":1650,"depth":196,"text":1651},{"id":1842,"depth":196,"text":1843},{"id":1871,"depth":196,"text":1872},{"id":1904,"depth":196,"text":1905},{"id":1925,"depth":196,"text":1926},{"id":1972,"depth":187,"text":1973},{"id":2019,"depth":187,"text":2020},{"id":2069,"depth":187,"text":2070},{"id":2967,"depth":187,"text":2968},{"id":3014,"depth":187,"text":3015},"2026-10-02","The Android emulator and a pile of Gradle builds took my Mac down with them, so Halfword’s Android screens are drawn on the JVM instead: Robolectric starts the real app, Roborazzi saves the pictures, 456 of them in four languages in 2 minutes 19 seconds. How it works, what it caught, and what it can’t tell you.",false,"md","\u002Fimages\u002Fhalfword_android\u002Fcover.jpg",{},null,"\u002Fposts\u002Fcode_n_shit\u002Fhalfword_android_rendering",{"title":5,"description":3045},"halfword","posts\u002Fcode_n_shit\u002Fhalfword_android_rendering",[109,1184,161,3056],"testing","VqJ7Hcu0AVs2rnOjI1ogyRFFKub92mCNBMlOBoCz2O4",[3059,3063],{"locale":3060,"path":3061,"title":3062},"ru","\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fhalfword_android_rendering","Я проверил свои экраны для Android без эмулятора. Все 114, на четырёх языках.",{"locale":3064,"path":3065,"title":3066},"de","\u002Fde\u002Fposts\u002Fcode_n_shit\u002Fhalfword_android_rendering","Ich habe meine Android-Screens ohne Emulator getestet. Alle 114, in vier Sprachen.",{"slug":3053,"title":3068,"path":3069,"chapters":3070},"Building Halfword","\u002Fseries\u002Fhalfword",[3071,3076],{"path":17,"title":3072,"description":3073,"date":3074,"image":3075,"draft":3046,"series":3053,"part":3050,"n":169},"I made a party game. Everyone gets the same word. Almost.","Halfword is the word game Undercover for one phone passed round the table: pixel art, eight special roles, and points that add up over a whole evening. It is out for iPhone and Android. Here is what building it took: points balanced by simulation, one Swift codebase for two platforms, an engine that can replay any game, pixel art without image files, and screens drawn before they were coded.","2026-10-01","\u002Fimages\u002Fhalfword\u002Fcover.png",{"path":3051,"title":5,"description":3045,"date":3044,"image":3048,"draft":3046,"series":3053,"part":3050,"n":187},1791333555920]