From 1451368cd656cf1270b73775ab5939024aaf7376 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:48:51 +0300 Subject: [PATCH 1/7] iOS: cut idle memory and startup cost in the Metal renderer and the VM Six independent fixes, each measured with a paired A/B inside one binary (separate builds cannot resolve these: the same binary's footprint varies by tens of megabytes between launches depending on machine state, which is how several of these went unnoticed). Default-on: * Stencil attachments become Memoryless on tile-based GPUs. Every pass that binds the stencil is already loadAction=Clear / storeAction=DontCare, which is exactly the Memoryless contract; the code chose Private and reasoned the cost was "tiny (1 byte/pixel)". It is 5.6MB per allocation at 2048x1536. Worth ~3.8MB, 3/3 paired reps, Metal validation clean. Intel Macs and the Intel-Mac CI simulators still get Private via a runtime family probe. * The glyph atlas starts at 256x256 instead of 1024x1024. The atlas cache keys on postScriptName|SIZE, so every distinct text size reserved a megabyte of R8 before rasterising a single glyph, and a Material-style text theme has fifteen of them. tryGrowAtlas already doubles on demand and re-rasterises correctly, so starting small is self-correcting. 6.19MB -> 0.47MB, and no atlas in the test app ever needed to grow. * cn1SetupMetal no longer sizes the framebuffer from self.bounds before layout. An unlaid-out view reports the whole DISPLAY, so every launch allocated a 3456x2234 / 30MB screen texture, cleared it, blitted it into the real 2048x1536 one and threw it away. layoutSubviews always follows and sizes it correctly. Worth ~14MB, and it removes a +-20MB launch-to-launch swing that made every other memory measurement in this area unreliable. * Eager accessibility projection is gated on assistive technology actually running. The port sets isAccessibilityTreeSupported() = true and never overrode isAccessibilityTreeUpdateRequired(), which defaults to returning it, so the portable semantic tree was rebuilt on EVERY invalidation -- every layout, every scroll, every text setter -- on every device, whether or not VoiceOver was listening. The base class documents the opposite for pull-based ports, and UIKit pulls. 4.0MB of live allocation on an idle app with no assistive technology, ~6MB of footprint, plus the CPU to build it. Opt-in (CN1_DIRECT_DRAWABLE=1): * A direct-to-drawable render path with no retained screen texture: the frame's pass targets the drawable itself and present is a bare presentDrawable with no blit. Because the layer vends a different buffer each frame, partial repaint is incorrect there, so IOSImplementation.paintDirty enqueues the whole Form whenever anything is dirty -- reusing two existing behaviours rather than rewriting paintDirty. Worth ~8-11MB and ~11ms off first frame. Also sets maximumDrawableCount to 2 in that mode only, where the third slot really is taken (the cap is inert in the retained path). Diagnostics, compiled out unless defined: * CN1_ALLOC_CENSUS -- allocation volume by class, counted at all three allocation entry points so it does not silently miss the BiBOP and nursery objects a walk of allObjectsInHeap cannot see, plus cn1HeapAccounting, which separates Java-object storage from native allocation (vmmap cannot: BiBOP arenas and every Metal/CG buffer share the same malloc zones). * CN1_TEXTURE_CENSUS -- GPU memory by creation site with each large texture's true dimensions, and CN1_VERIFY_PRESENT, which reads back a patch of the drawable actually being presented. That last one matters: a renderer change can leave the screen blank while Metal validation stays clean and an offscreen repaint-based screenshot still looks perfect. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/CN1MetalGlyphAtlas.m | 34 +- Ports/iOSPort/nativeSources/CN1Metalcompat.h | 15 + Ports/iOSPort/nativeSources/CN1Metalcompat.m | 67 ++++ Ports/iOSPort/nativeSources/IOSNative.m | 22 ++ Ports/iOSPort/nativeSources/METALView.m | 318 ++++++++++++++---- .../codename1/impl/ios/IOSImplementation.java | 67 ++++ .../src/com/codename1/impl/ios/IOSNative.java | 12 + vm/ByteCodeTranslator/src/cn1_globals.h | 26 ++ vm/ByteCodeTranslator/src/cn1_globals.m | 124 +++++++ vm/ByteCodeTranslator/src/nativeMethods.m | 17 + 10 files changed, 641 insertions(+), 61 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m b/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m index cba3a170764..e432a670300 100644 --- a/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m +++ b/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m @@ -36,12 +36,13 @@ #include "TargetConditionals.h" #if !TARGET_OS_WATCH +#import "CN1Metalcompat.h" #import "CN1ES2compat.h" #ifdef CN1_USE_METAL #import "CN1MetalGlyphAtlas.h" -#define CN1_METAL_ATLAS_INITIAL_W 1024 -#define CN1_METAL_ATLAS_INITIAL_H 1024 +#define CN1_METAL_ATLAS_INITIAL_W 256 +#define CN1_METAL_ATLAS_INITIAL_H 256 #define CN1_METAL_ATLAS_MAX_W 2048 #define CN1_METAL_ATLAS_MAX_H 2048 #define CN1_METAL_ATLAS_PADDING 1 @@ -106,6 +107,22 @@ + (nullable instancetype)atlasForFont:(nonnull UIFont *)font { return atlas; } +// See initWithCTFont: -- one atlas per font SIZE makes the initial dimension a +// per-size cost, not a one-off. +static int cn1AtlasInitialDim(void) { + static int v = -1; + if (v < 0) { + const char *e = getenv("CN1_ATLAS_INITIAL"); + int parsed = e != NULL ? atoi(e) : CN1_METAL_ATLAS_INITIAL_W; + // Powers of two only, and never above the growth ceiling. + if (parsed < 64 || parsed > CN1_METAL_ATLAS_MAX_W || (parsed & (parsed - 1)) != 0) { + parsed = CN1_METAL_ATLAS_INITIAL_W; + } + v = parsed; + } + return v; +} + + (nullable instancetype)atlasForCTFont:(nonnull CTFontRef)ctFont { if (ctFont == NULL) return nil; CFStringRef psName = CTFontCopyPostScriptName(ctFont); @@ -182,8 +199,15 @@ - (instancetype)initWithCTFont:(CTFontRef)ctFont key:(NSString *)key { // render garbage / nothing — store premultiplied BGRA instead. _isColor = (CTFontGetSymbolicTraits(_ctFont) & kCTFontTraitColorGlyphs) != 0; - _textureWidth = CN1_METAL_ATLAS_INITIAL_W; - _textureHeight = CN1_METAL_ATLAS_INITIAL_H; + // The cache key is postScriptName|SIZE, so every distinct text size gets its + // OWN atlas -- and a Material text theme has fifteen-odd sizes across two or + // three families. Starting each one at 1024x1024 therefore reserved a + // megabyte per size before a single glyph was rasterised. Start small and let + // the existing doubling path (growAtlas, up to CN1_METAL_ATLAS_MAX) size each + // atlas to what its font actually needs. CN1_ATLAS_INITIAL overrides for A/B; + // pass 1024 to reproduce the old behaviour. + _textureWidth = cn1AtlasInitialDim(); + _textureHeight = _textureWidth; MTLTextureDescriptor *desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:(_isColor ? MTLPixelFormatBGRA8Unorm : MTLPixelFormatR8Unorm) @@ -192,6 +216,7 @@ - (instancetype)initWithCTFont:(CTFontRef)ctFont key:(NSString *)key { mipmapped:NO]; desc.usage = MTLTextureUsageShaderRead; _texture = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("glyphAtlasInitial", _texture); if (_texture == nil) { CFRelease(_ctFont); _ctFont = NULL; @@ -249,6 +274,7 @@ - (BOOL)tryGrowAtlas { width:(NSUInteger)newW height:(NSUInteger)newH mipmapped:NO]; desc.usage = MTLTextureUsageShaderRead; id newTex = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("glyphAtlasGrow", newTex); if (newTex == nil) return NO; // Drop slots; next reference re-rasterises into the larger atlas. diff --git a/Ports/iOSPort/nativeSources/CN1Metalcompat.h b/Ports/iOSPort/nativeSources/CN1Metalcompat.h index 925bdc90e42..c2088005533 100644 --- a/Ports/iOSPort/nativeSources/CN1Metalcompat.h +++ b/Ports/iOSPort/nativeSources/CN1Metalcompat.h @@ -82,6 +82,21 @@ typedef struct { simd_float4x4 transform; } CN1MetalMatrices; +// -------- GPU memory census (diagnostic; compiled out by default) -------- + +// vmmap tells you the GPU holds N megabytes; it cannot tell you which allocation +// made them. MTLTexture.allocatedSize is the authoritative per-texture figure and +// MTLDevice.currentAllocatedSize the authoritative live total, so recording the +// first at every creation site and printing the second attributes the whole +// number. Enable with -DCN1_TEXTURE_CENSUS. +#ifdef CN1_TEXTURE_CENSUS +void cn1TextureCensusNote(const char *site, id t); +void cn1TextureCensusDump(const char *label); +#define CN1_TEX_NOTE(site, t) cn1TextureCensusNote((site), (t)) +#else +#define CN1_TEX_NOTE(site, t) do {} while(0) +#endif + // -------- Encoder lifecycle (called by CodenameOne_GLViewController / METALView) -------- // Called by METALView.setFramebuffer after acquiring a command encoder for diff --git a/Ports/iOSPort/nativeSources/CN1Metalcompat.m b/Ports/iOSPort/nativeSources/CN1Metalcompat.m index db0604da3aa..781b6174b5b 100644 --- a/Ports/iOSPort/nativeSources/CN1Metalcompat.m +++ b/Ports/iOSPort/nativeSources/CN1Metalcompat.m @@ -132,6 +132,66 @@ static void ensurePipelineCache(void) { // --------------- Encoder lifecycle --------------- +#ifdef CN1_TEXTURE_CENSUS +#define CN1_TEXCENSUS_SITES 32 +static const char *cn1TexSiteNames[CN1_TEXCENSUS_SITES]; +static long long cn1TexSiteBytes[CN1_TEXCENSUS_SITES]; +static long cn1TexSiteCount[CN1_TEXCENSUS_SITES]; +static int cn1TexSiteUsed = 0; +static pthread_mutex_t cn1TexCensusMutex = PTHREAD_MUTEX_INITIALIZER; + +void cn1TextureCensusNote(const char *site, id t) { + if(t == nil || site == 0) { + return; + } + long long sz = (long long)[t allocatedSize]; + // Log the big ones individually with their true shape. allocatedSize alone + // cannot be reasoned about -- a 2048x1536 BGRA8 render target reporting 21MB + // against a raw 12.6MB is either a bigger texture than you assumed or GPU-side + // compression metadata, and only the dimensions tell you which. + if(sz >= 1048576) { + fprintf(stderr, "[TEX] %-24s %5lux%-5lu fmt=%lu storage=%lu %.2fMB (raw %.2fMB)\n", + site, (unsigned long)[t width], (unsigned long)[t height], + (unsigned long)[t pixelFormat], (unsigned long)[t storageMode], + sz / (1024.0 * 1024.0), + ([t width] * [t height] * 4.0) / (1024.0 * 1024.0)); + fflush(stderr); + } + pthread_mutex_lock(&cn1TexCensusMutex); + int i = 0; + for( ; i < cn1TexSiteUsed ; i++) { + if(cn1TexSiteNames[i] == site) { // string literals: pointer identity is enough + break; + } + } + if(i == cn1TexSiteUsed && cn1TexSiteUsed < CN1_TEXCENSUS_SITES) { + cn1TexSiteNames[cn1TexSiteUsed++] = site; + } + if(i < CN1_TEXCENSUS_SITES) { + cn1TexSiteBytes[i] += sz; + cn1TexSiteCount[i]++; + } + pthread_mutex_unlock(&cn1TexCensusMutex); +} + +void cn1TextureCensusDump(const char *label) { + id d = CN1MetalDevice(); + pthread_mutex_lock(&cn1TexCensusMutex); + // currentAllocatedSize is LIVE; the per-site figures are CUMULATIVE. A site + // whose cumulative total dwarfs the live total is churn, not residency -- + // which is itself the answer for the scratch/mutable-image paths. + fprintf(stderr, "[TEX:%s] device live currentAllocatedSize=%.2fMB\n", label, + d != nil ? [d currentAllocatedSize] / (1024.0 * 1024.0) : 0.0); + for(int i = 0 ; i < cn1TexSiteUsed ; i++) { + fprintf(stderr, "[TEX:%s] %9.2fMB cumulative %6ld allocs %s\n", label, + cn1TexSiteBytes[i] / (1024.0 * 1024.0), cn1TexSiteCount[i], + cn1TexSiteNames[i]); + } + pthread_mutex_unlock(&cn1TexCensusMutex); + fflush(stderr); +} +#endif + void CN1MetalBeginFrame(id encoder, simd_float4x4 projection, int framebufferWidth, @@ -1275,6 +1335,7 @@ void CN1MetalFillGradient(int kind, width:width height:height mipmapped:NO]; desc.usage = MTLTextureUsageShaderRead; id tex = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("alphaMaskGlyph", tex); if (tex == nil) { return nil; } @@ -1397,6 +1458,7 @@ void CN1MetalDrawAlphaMaskRadial(id texture, width:w height:h mipmapped:NO]; desc.usage = MTLTextureUsageShaderRead; id texture = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("textureFromUIImage", texture); [texture replaceRegion:MTLRegionMake2D(0, 0, w, h) mipmapLevel:0 withBytes:rawData @@ -1450,6 +1512,7 @@ void CN1MetalEnsureMutableTexture(GLUIImage *image, int width, int height) { desc.usage = MTLTextureUsageRenderTarget | MTLTextureUsageShaderRead; desc.storageMode = MTLStorageModePrivate; id tex = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("mutableImage", tex); if (tex == nil) return; // Clear new texture to the fill colour stashed by createNativeMutableImage. // Default Image.createImage(w, h) → 0xffffffff opaque white; createImage(w, h, argb) @@ -1527,6 +1590,7 @@ void CN1MetalEnsureMutableTexture(GLUIImage *image, int width, int height) { seedStencilDesc.usage = MTLTextureUsageRenderTarget; seedStencilDesc.storageMode = MTLStorageModePrivate; seedStencilTex = [device newTextureWithDescriptor:seedStencilDesc]; + CN1_TEX_NOTE("mutableSeedStencil", seedStencilTex); if (seedStencilTex != nil) { seedPass.stencilAttachment.texture = seedStencilTex; seedPass.stencilAttachment.loadAction = MTLLoadActionClear; @@ -1622,6 +1686,7 @@ BOOL CN1MetalBeginMutableImageDraw(GLUIImage *image) { stencilDesc.usage = MTLTextureUsageRenderTarget; stencilDesc.storageMode = MTLStorageModePrivate; stencilTex = [device newTextureWithDescriptor:stencilDesc]; + CN1_TEX_NOTE("mutableDrawStencil", stencilTex); if (stencilTex != nil) { desc.stencilAttachment.texture = stencilTex; desc.stencilAttachment.loadAction = MTLLoadActionClear; @@ -1770,6 +1835,7 @@ BOOL CN1MetalReadMutableImagePixels(GLUIImage *image, int *outARGB, desc.usage = MTLTextureUsageShaderRead; desc.storageMode = MTLStorageModeShared; id shared = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("mutableFlushShared", shared); if (shared == nil) return NO; id blitCb = [queue commandBuffer]; @@ -1853,6 +1919,7 @@ static void cn1MetalReadbackFreeData(void * __unused info, const void *data, siz desc.usage = MTLTextureUsageShaderRead; desc.storageMode = MTLStorageModeShared; id shared = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("mutableReadShared", shared); if (shared == nil) return nil; id blitCb = [queue commandBuffer]; diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index a336a88846b..87da2ee0b36 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -14894,6 +14894,28 @@ static void cn1_resetContext(void) { } #endif // !TARGET_OS_TV +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAssistiveTechnologyActive___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + // CN1_EAGER_A11Y=1 restores the old always-project behaviour for an A/B. + if(getenv("CN1_EAGER_A11Y") != NULL) { + return JAVA_TRUE; + } +#if !TARGET_OS_WATCH + return (UIAccessibilityIsVoiceOverRunning() || + UIAccessibilityIsSwitchControlRunning()) ? JAVA_TRUE : JAVA_FALSE; +#else + return JAVA_FALSE; +#endif +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isDirectToDrawable___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef CN1_USE_METAL + extern int cn1DirectToDrawableEnabled(void); + return cn1DirectToDrawableEnabled() ? JAVA_TRUE : JAVA_FALSE; +#else + return JAVA_FALSE; +#endif +} + JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isBiometricsSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { #if !TARGET_OS_WATCH && !TARGET_OS_TV if (NSClassFromString(@"LAContext") == NULL) { diff --git a/Ports/iOSPort/nativeSources/METALView.m b/Ports/iOSPort/nativeSources/METALView.m index 42550329564..a0631c1d52a 100644 --- a/Ports/iOSPort/nativeSources/METALView.m +++ b/Ports/iOSPort/nativeSources/METALView.m @@ -409,6 +409,60 @@ -(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event // is never published, and CN1MetalGlyphAtlas+atlasForFont: returns nil // for every font -- which is exactly the "no atlas available" failure // the Mac CI surfaced. +// The stencil attachment is loadAction=Clear / storeAction=DontCare in every pass +// that binds it -- the frame pass, the present pass and the resize preserve -- so +// its contents never outlive a render pass. That is precisely the contract for +// MTLStorageModeMemoryless, which keeps the buffer in tile memory and costs ZERO +// physical bytes. The older code chose Private and reasoned the cost was "tiny +// (1 byte/pixel)"; CN1_TEXTURE_CENSUS measured it at 5.6MB per allocation at +// 2048x1536, so the assumption was simply wrong -- and A/B'd over three launches +// Memoryless is worth ~3.8MB of physical footprint. Memoryless exists only on +// tile-based (Apple-family) GPUs, hence the runtime probe: Intel Macs and the +// older Intel-Mac CI simulators still get Private. CN1_STENCIL_PRIVATE=1 forces +// the old behaviour for an A/B. +static MTLStorageMode cn1StencilStorageMode(id device) { + static int forcePrivate = -1; + if(forcePrivate < 0) { + forcePrivate = getenv("CN1_STENCIL_PRIVATE") != NULL ? 1 : 0; + } + if(!forcePrivate && device != nil && [device supportsFamily:MTLGPUFamilyApple1]) { + return MTLStorageModeMemoryless; + } + return MTLStorageModePrivate; +} + +// DIRECT-TO-DRAWABLE mode (opt-in, CN1_DIRECT_DRAWABLE=1). +// +// The default renderer draws into a persistent full-window `screenTexture` and +// blits it onto the drawable at present. That retained buffer is what lets CN1 +// repaint only a dirty region: last frame's pixels are still there. It also +// costs a full-window texture -- 12.12MB at 2048x1536 -- which a renderer that +// draws straight into the drawable does not pay at all. +// +// In direct mode there is no screenTexture: the frame's render pass targets the +// drawable itself. The drawable must then be acquired at setFramebuffer (start +// of frame) rather than at present, and held across op encoding -- which does +// cost some nextDrawable latency, the reason the default path acquires it late. +// +// CORRECTNESS REQUIREMENT: the layer vends a DIFFERENT buffer each frame, so a +// region left unpainted shows content from two or three frames ago, not from +// last frame. Every frame must therefore repaint everything, which +// IOSImplementation.paintDirty arranges by enqueueing the whole Form whenever +// anything is dirty. Do not enable this mode without that half. +static int cn1DirectToDrawable(void) { + static int v = -1; + if(v < 0) { + v = getenv("CN1_DIRECT_DRAWABLE") != NULL ? 1 : 0; + } + return v; +} + +// Non-static so IOSNative can ask, since the Java paint model must follow the +// renderer's choice rather than decide it independently. +int cn1DirectToDrawableEnabled(void) { + return cn1DirectToDrawable(); +} + - (void)cn1SetupMetal { self.clearsContextBeforeDrawing = NO; if ([[UIScreen mainScreen] respondsToSelector:@selector(scale)] && isRetina()) { @@ -422,14 +476,15 @@ - (void)cn1SetupMetal { metalLayer.device = MTLCreateSystemDefaultDevice(); metalLayer.opaque = TRUE; metalLayer.pixelFormat = MTLPixelFormatBGRA8Unorm; - // framebufferOnly must be NO: presentFramebuffer blits screenTexture + // framebufferOnly must be NO: presentScreenTextureInto: blits screenTexture // into the drawable via copyFromTexture:toTexture:, and Metal's blit // validation aborts ("destinationTexture must not be a framebufferOnly - // texture") when the destination drawable was framebufferOnly. Debug - // builds with Metal API Validation enabled crash on the first paint; - // release builds silently produced undefined-behaviour copies on some - // GPUs. Trading the (small) memoryless-storage benefit for a working - // present path. + // texture") when the destination drawable was framebufferOnly. + // Presenting with a textured quad instead (which framebufferOnly = YES + // permits) was built and measured: Metal validation was clean and the + // resident IOSurface did not move by even a megabyte, because Apple's + // lossless compression saves bandwidth rather than allocation. Not worth + // a second present path. metalLayer.framebufferOnly = NO; // Colour space for the Metal layer. Default is sRGB so colours // match the GL path's CAEAGLLayer output: without it, CG-rasterised @@ -468,7 +523,27 @@ - (void)cn1SetupMetal { // most CAMetalLayer use cases. Combined with our nextDrawable // skip-frame fallback in presentFramebuffer this keeps the // pipeline non-blocking under pressure. - metalLayer.maximumDrawableCount = 3; + // + // The cap is an upper bound on what CoreAnimation MAY vend, not a + // reservation, and in the retained path it is inert: 2 and 3 both settle + // at exactly 24.0MB of IOSurface, because that path only ever has two + // drawables in flight (measured at four paired launches per setting). + // + // Direct mode is different -- it holds the drawable across the whole + // frame, so the third slot really is taken. At 3 the GPU allocation + // swings between 21.8MB and 27.2MB from launch to launch; at 2 it is + // 21.8MB every time. That is worth ~4.5MB on average and, more usefully, + // turns a 10.8MB launch-to-launch spread into 1.9MB. Hence 2 there and 3 + // here, where the headroom is free. CN1_MAX_DRAWABLES re-runs the A/B. + { + const char *e = getenv("CN1_MAX_DRAWABLES"); + NSUInteger n = e != NULL ? (NSUInteger)atoi(e) + : (cn1DirectToDrawable() ? 2 : 3); + if(n < 2 || n > 3) { + n = 3; // CAMetalLayer only accepts 2 or 3 + } + metalLayer.maximumDrawableCount = n; + } // `makeCommandQueue` is the Swift name; Objective-C uses `newCommandQueue`. // newCommandQueue returns +1 (NARC family); release the local after // the synthesized retain setter takes its own retain so we end up at @@ -477,6 +552,15 @@ - (void)cn1SetupMetal { self.commandQueue = newQueue; #ifndef CN1_USE_ARC [newQueue release]; +#endif +#ifdef CN1_TEXTURE_CENSUS + // Frame-count triggers are useless here: an idle gallery presents fewer + // than twenty frames in thirty seconds, so a "every 200th present" hook + // never fires. Wall-clock does. + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(20 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ cn1TextureCensusDump("t20"); }); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(35 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ cn1TextureCensusDump("t35"); }); #endif // Publish the device + queue to CN1Metalcompat so its global // accessors don't have to dereference our (UIView) layer from @@ -484,9 +568,25 @@ - (void)cn1SetupMetal { // means CN1MetalDevice / CN1MetalCommandQueue become cheap static // reads safe to invoke from the EDT and any background GCD queue. CN1MetalSetDeviceAndCommandQueue(metalLayer.device, self.commandQueue); - CGSize sz = self.bounds.size; - CGFloat s = self.contentScaleFactor; - [self updateFrameBufferSize:(int)(sz.width * s) h:(int)(sz.height * s)]; + // Do NOT size the framebuffer here. self.bounds has not been laid out + // yet, and on Mac Catalyst an unlaid-out view reports the whole DISPLAY: + // CN1_TEXTURE_CENSUS measured the resulting screenTexture at 3456x2234 / + // 30.03MB, allocated and then thrown away moments later when + // layoutSubviews supplies the real 2048x1536 / 12.12MB. That transient + // 30MB is also the best candidate for the +-20MB run-to-run swing in + // this process's footprint, since whether it is still resident when you + // sample is pure timing. + // + // layoutSubviews always follows and sizes it correctly, and + // createRenderPassDescriptor already treats a nil screenTexture as "no + // frame this pass", so a frame attempted in between is a safe no-op + // rather than a crash. CN1_EAGER_FRAMEBUFFER=1 restores the old eager + // sizing for an A/B. + if (getenv("CN1_EAGER_FRAMEBUFFER") != NULL) { + CGSize sz = self.bounds.size; + CGFloat s = self.contentScaleFactor; + [self updateFrameBufferSize:(int)(sz.width * s) h:(int)(sz.height * s)]; + } // Drop the glyph atlas + text cache + gradient cache on memory // pressure. Pipeline state cache stays — those are precious to @@ -629,6 +729,10 @@ -(void)updateFrameBufferSize:(int)w h:(int)h { MTLTextureDescriptor *desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatBGRA8Unorm width:pw height:ph mipmapped:NO]; + // ShaderRead measured FREE here: dropping it (with the blit present path, the + // only consumer that samples rather than blits) left allocatedSize unchanged + // at 21.1MB. The 21.1MB-vs-12.1MB gap against an identical drawable is GPU + // compression metadata, not this flag. desc.usage = MTLTextureUsageRenderTarget | MTLTextureUsageShaderRead; desc.storageMode = MTLStorageModePrivate; // newTextureWithDescriptor returns +1 (NARC family); the synthesized @@ -636,7 +740,11 @@ -(void)updateFrameBufferSize:(int)w h:(int)h { // local once the property holds its own retain so we don't leak the // previous screenTexture every time the framebuffer is resized // (rotation, window resize, etc.). - id newScreen = [layer.device newTextureWithDescriptor:desc]; + // Direct mode renders into the drawable, so the retained buffer -- and its + // 12.12MB -- is simply never allocated. + id newScreen = cn1DirectToDrawable() ? nil + : [layer.device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("screenTexture", newScreen); self.screenTexture = newScreen; #ifndef CN1_USE_ARC [newScreen release]; @@ -672,8 +780,9 @@ -(void)updateFrameBufferSize:(int)w h:(int)h { texture2DDescriptorWithPixelFormat:MTLPixelFormatStencil8 width:pw height:ph mipmapped:NO]; clearStencilDesc.usage = MTLTextureUsageRenderTarget; - clearStencilDesc.storageMode = MTLStorageModePrivate; + clearStencilDesc.storageMode = cn1StencilStorageMode(layer.device); clearStencilTex = [layer.device newTextureWithDescriptor:clearStencilDesc]; + CN1_TEX_NOTE("resizePreserveStencil", clearStencilTex); if (clearStencilTex != nil) { clearPass.stencilAttachment.texture = clearStencilTex; clearPass.stencilAttachment.loadAction = MTLLoadActionClear; @@ -701,18 +810,15 @@ -(void)updateFrameBufferSize:(int)w h:(int)h { [clearEnc endEncoding]; [clearCb commit]; - // Build a matching Stencil8 attachment for polygon-shape clipping - // (#3921). Private storage rather than Memoryless because Memoryless - // is only supported on tile-based deferred GPUs (iOS Simulator on - // older Intel-Mac CI runners doesn't accept it). The stencil is - // ephemeral conceptually but Private works on all GPU families and - // the size cost is tiny (1 byte/pixel). + // Build a matching Stencil8 attachment for polygon-shape clipping (#3921). + // Storage mode is chosen at runtime -- see cn1StencilStorageMode. MTLTextureDescriptor *stencilDesc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatStencil8 width:pw height:ph mipmapped:NO]; stencilDesc.usage = MTLTextureUsageRenderTarget; - stencilDesc.storageMode = MTLStorageModePrivate; + stencilDesc.storageMode = cn1StencilStorageMode(layer.device); id newStencil = [layer.device newTextureWithDescriptor:stencilDesc]; + CN1_TEX_NOTE("stencilTexture", newStencil); self.stencilTexture = newStencil; #ifndef CN1_USE_ARC [newStencil release]; @@ -769,6 +875,9 @@ -(void)presentPreservedFrameIfNeeded { } needsResizePresent = NO; if (self.screenTexture == nil) { + // Direct mode keeps no previous frame to re-present; the resize simply + // repaints (#5162's black flash is not reachable there because every + // frame is a full repaint anyway). return; } // An encoder may be mid-frame if the EDT started painting between the resize @@ -784,15 +893,7 @@ -(void)presentPreservedFrameIfNeeded { return; } id presentCb = [self.commandQueue commandBuffer]; - id blit = [presentCb blitCommandEncoder]; - [blit copyFromTexture:self.screenTexture - sourceSlice:0 sourceLevel:0 - sourceOrigin:MTLOriginMake(0, 0, 0) - sourceSize:MTLSizeMake(framebufferWidth, framebufferHeight, 1) - toTexture:dr.texture - destinationSlice:0 destinationLevel:0 - destinationOrigin:MTLOriginMake(0, 0, 0)]; - [blit endEncoding]; + [self presentScreenTextureInto:dr.texture commandBuffer:presentCb]; [presentCb presentDrawable:dr]; [presentCb commit]; } @@ -837,7 +938,19 @@ -(void)prepareRetainedFramebufferForDrawRect:(CGRect)rect displayWidth:(int)disp } -(void)createRenderPassDescriptor { - if (self.screenTexture == nil) { + id target = self.screenTexture; + if (cn1DirectToDrawable()) { + // Acquire the frame's drawable here, at the START of the frame, and hold + // it until presentFramebuffer. The default path deliberately acquires + // late to keep dwell time down; direct mode cannot, because the ops + // encode straight into it. + if (self.drawable == nil) { + CAMetalLayer *layer = (CAMetalLayer*)self.layer; + self.drawable = [layer nextDrawable]; + } + target = self.drawable.texture; + } + if (target == nil) { self.renderPassDescriptor = nil; return; } @@ -848,8 +961,16 @@ -(void)createRenderPassDescriptor { // before. MTLLoadActionLoad preserves previous pixels (vs MTLLoadActionClear // which would wipe everything each frame) — CN1 only queues diff ops // per frame; the OpenGL path relies on its renderbuffer persisting. - colorAttachment.texture = self.screenTexture; - if (clearRetainedFramebufferOnNextFrame) { + colorAttachment.texture = target; + if (cn1DirectToDrawable()) { + // Never Load: this buffer holds a frame from two or three presents ago. + // The Form repaints in full every frame (IOSImplementation.paintDirty), + // so there is nothing worth preserving and Clear is also the cheaper + // load action on a tile GPU. + colorAttachment.loadAction = MTLLoadActionClear; + colorAttachment.clearColor = MTLClearColorMake(0.0, 0.0, 0.0, 1.0); + clearRetainedFramebufferOnNextFrame = NO; + } else if (clearRetainedFramebufferOnNextFrame) { colorAttachment.loadAction = MTLLoadActionClear; colorAttachment.clearColor = MTLClearColorMake(0.0, 0.0, 0.0, 1.0); clearRetainedFramebufferOnNextFrame = NO; @@ -905,12 +1026,24 @@ - (void)setFramebuffer // command buffer -- the only path that produces real glass on a running app // (the offscreen-image blur only covered fidelity tiles). Costs a GPU sync per // glass paint; acceptable for the small, mostly-static nav/tab bars. +// The texture holding what has already been painted THIS frame -- the backdrop +// a glass/blur/lens op samples. That is screenTexture in the default retained +// path and the frame's own drawable in direct mode, where these ops read back +// from the drawable they are drawing into (legal: the layer is framebufferOnly +// = NO, and each op ends and commits the encoder before blitting). +- (id)backdropTexture { + if (self.screenTexture != nil) { + return self.screenTexture; + } + return self.drawable.texture; +} + - (void)blurScreenRegionX:(int)x y:(int)y w:(int)w h:(int)h radius:(float)radius { - if (self.screenTexture == nil || w <= 0 || h <= 0 || radius <= 0.0f) { + if ([self backdropTexture] == nil || w <= 0 || h <= 0 || radius <= 0.0f) { return; } CGFloat s = self.contentScaleFactor; - int texW = (int)self.screenTexture.width, texH = (int)self.screenTexture.height; + int texW = (int)[self backdropTexture].width, texH = (int)[self backdropTexture].height; int fx = (int)(x * s), fy = (int)(y * s), fw = (int)(w * s), fh = (int)(h * s); if (fx < 0) { fw += fx; fx = 0; } if (fy < 0) { fh += fy; fy = 0; } @@ -939,9 +1072,10 @@ - (void)blurScreenRegionX:(int)x y:(int)y w:(int)w h:(int)h radius:(float)radius desc.usage = MTLTextureUsageShaderRead; desc.storageMode = MTLStorageModeShared; id scratch = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("blurScratch", scratch); id blitCb = [self.commandQueue commandBuffer]; id blit = [blitCb blitCommandEncoder]; - [blit copyFromTexture:self.screenTexture sourceSlice:0 sourceLevel:0 + [blit copyFromTexture:[self backdropTexture] sourceSlice:0 sourceLevel:0 sourceOrigin:MTLOriginMake(fx, fy, 0) sourceSize:MTLSizeMake(fw, fh, 1) toTexture:scratch destinationSlice:0 destinationLevel:0 destinationOrigin:MTLOriginMake(0, 0, 0)]; @@ -1053,7 +1187,7 @@ static uint64_t cn1GlassBackdropHash(const uint8_t *bytes, size_t len) { - (void)glassScreenRegionX:(int)x y:(int)y w:(int)w h:(int)h radius:(float)radius cornerRadius:(float)cornerRadius sat:(float)sat scale:(float)scale offset:(float)offset refract:(float)refract specular:(float)specular { - if (self.screenTexture == nil || w <= 0 || h <= 0 || radius <= 0.0f) { + if ([self backdropTexture] == nil || w <= 0 || h <= 0 || radius <= 0.0f) { return; } // CN1-logical -> framebuffer-pixel scale. NOT contentScaleFactor alone: @@ -1064,7 +1198,7 @@ - (void)glassScreenRegionX:(int)x y:(int)y w:(int)w h:(int)h radius:(float)radiu // the region in the fidelity app (wrong screenTexture slice + 3x radius). float sv = scaleValue > 0.0f ? scaleValue : 1.0f; CGFloat s = self.contentScaleFactor / sv; - int texW = (int)self.screenTexture.width, texH = (int)self.screenTexture.height; + int texW = (int)[self backdropTexture].width, texH = (int)[self backdropTexture].height; int fx = (int)(x * s), fy = (int)(y * s), fw = (int)(w * s), fh = (int)(h * s); if (fx < 0) { fw += fx; fx = 0; } if (fy < 0) { fh += fy; fy = 0; } @@ -1098,9 +1232,10 @@ - (void)glassScreenRegionX:(int)x y:(int)y w:(int)w h:(int)h radius:(float)radiu desc.usage = MTLTextureUsageShaderRead; desc.storageMode = MTLStorageModeShared; id scratch = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("glassScratch", scratch); id blitCb = [self.commandQueue commandBuffer]; id blit = [blitCb blitCommandEncoder]; - [blit copyFromTexture:self.screenTexture sourceSlice:0 sourceLevel:0 + [blit copyFromTexture:[self backdropTexture] sourceSlice:0 sourceLevel:0 sourceOrigin:MTLOriginMake(ax0, ay0, 0) sourceSize:MTLSizeMake(aw, ah, 1) toTexture:scratch destinationSlice:0 destinationLevel:0 destinationOrigin:MTLOriginMake(0, 0, 0)]; @@ -1222,12 +1357,12 @@ - (void)drawGlassPatch:(uint32_t *)patch fw:(int)fw fh:(int)fh x:(int)x y:(int)y // samples within its own bounds. Runs during the drain like the glass op. - (void)lensScreenRegionX:(int)x y:(int)y w:(int)w h:(int)h cornerRadius:(float)cornerRadius magnify:(float)magnify aberration:(float)aberration tintColor:(int)tintColor tintStrength:(float)tintStrength { - if (self.screenTexture == nil || w <= 0 || h <= 0) { + if ([self backdropTexture] == nil || w <= 0 || h <= 0) { return; } float sv = scaleValue > 0.0f ? scaleValue : 1.0f; CGFloat s = self.contentScaleFactor / sv; - int texW = (int)self.screenTexture.width, texH = (int)self.screenTexture.height; + int texW = (int)[self backdropTexture].width, texH = (int)[self backdropTexture].height; int fx = (int)(x * s), fy = (int)(y * s), fw = (int)(w * s), fh = (int)(h * s); if (fx < 0) { fw += fx; fx = 0; } if (fy < 0) { fh += fy; fy = 0; } @@ -1261,11 +1396,12 @@ - (void)lensScreenRegionX:(int)x y:(int)y w:(int)w h:(int)h cornerRadius:(float) desc.usage = MTLTextureUsageShaderRead; desc.storageMode = MTLStorageModePrivate; id scratch = [device newTextureWithDescriptor:desc]; + CN1_TEX_NOTE("lensScratch", scratch); if (scratch == nil) { [self setFramebuffer]; return; } // 3) Blit the bar region screenTexture -> scratch on the frame's command buffer. id blit = [self.commandBuffer blitCommandEncoder]; - [blit copyFromTexture:self.screenTexture sourceSlice:0 sourceLevel:0 + [blit copyFromTexture:[self backdropTexture] sourceSlice:0 sourceLevel:0 sourceOrigin:MTLOriginMake(fx, fy, 0) sourceSize:MTLSizeMake(fw, fh, 1) toTexture:scratch destinationSlice:0 destinationLevel:0 destinationOrigin:MTLOriginMake(0, 0, 0)]; @@ -1284,6 +1420,25 @@ - (void)lensScreenRegionX:(int)x y:(int)y w:(int)w h:(int)h cornerRadius:(float) CN1MetalDrawLens(scratch, x, y, w, h, fw, fh, magnify, aberration, tintColor, tintStrength, crPx); } +// Put the frame held in screenTexture onto \a dst (a drawable's texture), +// either as a full-screen textured quad (default) or via a blit +// (CN1_PRESENT_BLIT=1). Both write the same pixels; they differ only in +// Both write the same pixels onto the drawable. +-(void)presentScreenTextureInto:(id)dst commandBuffer:(id)cb { + if (self.screenTexture == nil || dst == nil || cb == nil) { + return; + } + id blit = [cb blitCommandEncoder]; + [blit copyFromTexture:self.screenTexture + sourceSlice:0 sourceLevel:0 + sourceOrigin:MTLOriginMake(0, 0, 0) + sourceSize:MTLSizeMake(framebufferWidth, framebufferHeight, 1) + toTexture:dst + destinationSlice:0 destinationLevel:0 + destinationOrigin:MTLOriginMake(0, 0, 0)]; + [blit endEncoding]; +} + - (BOOL)presentFramebuffer { if (self.renderCommandEncoder == nil) { @@ -1304,28 +1459,77 @@ - (BOOL)presentFramebuffer self.renderCommandEncoder = nil; self.renderPassDescriptor = nil; - // Acquire the drawable here (not in setFramebuffer) to minimise its - // dwell time -- holding a drawable across the whole op-encoding phase - // stalls nextDrawable for subsequent frames. - CAMetalLayer *layer = (CAMetalLayer*)self.layer; - id dr = [layer nextDrawable]; + // Direct mode already holds the drawable -- the ops encoded straight into + // it, so there is nothing to copy and nothing to acquire. + id dr = self.drawable; + if (!cn1DirectToDrawable()) { + // Acquire the drawable here (not in setFramebuffer) to minimise its + // dwell time -- holding a drawable across the whole op-encoding phase + // stalls nextDrawable for subsequent frames. + CAMetalLayer *layer = (CAMetalLayer*)self.layer; + dr = [layer nextDrawable]; + if (dr == nil) { + // Memory pressure dropped the drawable. Commit render work so + // screenTexture still updates; skip this frame's present. + [self.commandBuffer commit]; + self.commandBuffer = nil; + return NO; + } + self.drawable = dr; + [self presentScreenTextureInto:dr.texture commandBuffer:self.commandBuffer]; + } if (dr == nil) { - // Memory pressure dropped the drawable. Commit render work so - // screenTexture still updates; skip this frame's present. [self.commandBuffer commit]; self.commandBuffer = nil; return NO; } - self.drawable = dr; - id blit = [self.commandBuffer blitCommandEncoder]; - [blit copyFromTexture:self.screenTexture - sourceSlice:0 sourceLevel:0 - sourceOrigin:MTLOriginMake(0, 0, 0) - sourceSize:MTLSizeMake(framebufferWidth, framebufferHeight, 1) - toTexture:dr.texture - destinationSlice:0 destinationLevel:0 - destinationOrigin:MTLOriginMake(0, 0, 0)]; - [blit endEncoding]; +#ifdef CN1_VERIFY_PRESENT + // Reads back a patch of the drawable that is ACTUALLY being presented. + // Nothing else here can prove the screen is not blank: if the drawable were + // nil the render pass would be nil, every op would no-op against a null + // encoder, and Metal validation would still be clean and FIRSTFRAME would + // still print. An offscreen repaint (bench_shot) cannot prove it either -- + // it paints the scene graph again rather than reading the framebuffer. + { + static int shots = 0; + if (dr != nil && shots < 3) { + shots++; + int px = 128, py = 128, pw = 64, ph = 64; + id dev = CN1MetalDevice(); + MTLTextureDescriptor *pd = [MTLTextureDescriptor + texture2DDescriptorWithPixelFormat:MTLPixelFormatBGRA8Unorm + width:pw height:ph mipmapped:NO]; + pd.usage = MTLTextureUsageShaderRead; + pd.storageMode = MTLStorageModeShared; + id probe = [dev newTextureWithDescriptor:pd]; + if (probe != nil) { + id pb = [self.commandBuffer blitCommandEncoder]; + [pb copyFromTexture:dr.texture sourceSlice:0 sourceLevel:0 + sourceOrigin:MTLOriginMake(px, py, 0) + sourceSize:MTLSizeMake(pw, ph, 1) + toTexture:probe destinationSlice:0 destinationLevel:0 + destinationOrigin:MTLOriginMake(0, 0, 0)]; + [pb endEncoding]; + [self.commandBuffer addCompletedHandler:^(id cb) { + uint32_t buf[64 * 64]; + [probe getBytes:buf bytesPerRow:64 * 4 + fromRegion:MTLRegionMake2D(0, 0, 64, 64) mipmapLevel:0]; + unsigned long nonBlack = 0, sum = 0; + for(int i = 0 ; i < 64 * 64 ; i++) { + uint32_t p = buf[i]; + if((p & 0x00FFFFFF) != 0) nonBlack++; + sum = sum * 31u + p; + } + fprintf(stderr, "BENCH:PRESENTED nonBlack=%lu/4096 hash=%lu\n", nonBlack, sum); + fflush(stderr); +#ifndef CN1_USE_ARC + [probe release]; +#endif + }]; + } + } + } +#endif [self.commandBuffer presentDrawable:dr]; [self.commandBuffer commit]; self.drawable = nil; diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 4a14451df8f..70ae29ad13b 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -1809,6 +1809,48 @@ static boolean hitTest(int x, int y) { return true; } + /// True when the Metal view renders straight into the drawable, so no retained + /// screen texture exists. Resolved once -- the renderer decides it at startup. + private int directToDrawable = -1; + + private boolean isDirectToDrawable() { + if (directToDrawable < 0) { + boolean d = false; + try { + d = nativeInstance.isDirectToDrawable(); + } catch (Throwable t) { + d = false; + } + directToDrawable = d ? 1 : 0; + } + return directToDrawable == 1; + } + + /// Direct-to-drawable rendering presents a DIFFERENT buffer every frame, so a + /// region left unpainted does not show last frame -- it shows whatever was in + /// that buffer two or three presents ago. Painting only the dirty components, + /// which is the whole point of {@code paintDirty}, is therefore incorrect in + /// that mode. + /// + /// Enqueueing the current Form ahead of the superclass call is enough to fix + /// it: a Component queued with a null dirty region is painted under a + /// full-screen clip, and {@code repaint(Animation)} already drops any child + /// whose ancestor is queued, so this both forces the full paint and collapses + /// the queue instead of adding to it. Components enqueued BEFORE this call + /// still repaint redundantly; that is a real cost, and it is the trade the + /// mode exists to make. + @Override + public void paintDirty() { + if (isDirectToDrawable() && hasPendingPaints()) { + Form f = Display.getInstance().getCurrent(); + if (f != null) { + f.setDirtyRegion(null); + repaint(f); + } + } + super.paintDirty(); + } + public void flushGraphics(int x, int y, int width, int height) { globalGraphics.clipApplied = false; flushBuffer(0, x, y, width, height); @@ -13031,6 +13073,31 @@ public boolean isAccessibilityTreeSupported() { return true; } + /// UIKit PULLS the semantic tree (it asks the view for accessibility elements), + /// so the portable tree only has to be projected eagerly while something is + /// actually listening. The base class notes exactly this -- "pull-based ports + /// should override this to return true only while assistive technology is + /// active" -- but the iOS port never overrode it, so it inherited + /// isAccessibilityTreeSupported() and rebuilt the whole snapshot on EVERY + /// invalidation: every layout, every scroll, every text setter, on every + /// device, whether or not VoiceOver was running. Measured with + /// malloc_history that was 4.0MB of live allocation under + /// AccessibilityManager.getSnapshot on an idle Mac Catalyst app with no + /// assistive technology running at all, plus the CPU to build it. + /// + /// Turning VoiceOver on mid-session is picked up on the next invalidation -- + /// the flag is read per call, and any mutation after that point projects + /// normally. + @Override + public boolean isAccessibilityTreeUpdateRequired() { + try { + return nativeInstance.isAssistiveTechnologyActive(); + } catch (Throwable t) { + // Never let a semantics optimisation take the app down. + return true; + } + } + public static void performAccessibilityActionFromNative(long nodeId, String actionId, String argument) { if (instance != null) { instance.performAccessibilityAction(nodeId, actionId, argument); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 5e75e8820bf..ab94be29f49 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -1073,6 +1073,18 @@ native void walletExtensionAddPassEntry(boolean remote, String identifier, Strin /** True when LAContext.canEvaluatePolicy(deviceOwnerAuthenticationWithBiometrics) succeeds. */ native boolean isBiometricsSupported(); + /// True when the Metal view renders straight into the CAMetalLayer drawable + /// instead of into a retained screen texture. The renderer decides this, not + /// Java, but the paint model has to follow it: a direct-mode frame presents a + /// different buffer every time, so anything left unpainted shows a frame from + /// two or three presents ago. See IOSImplementation.paintDirty. + native boolean isDirectToDrawable(); + + /// True while VoiceOver, Switch Control or Voice Control is actually running. + /// UIKit pulls the semantic tree on demand, so nothing needs projecting eagerly + /// unless one of these is listening. + native boolean isAssistiveTechnologyActive(); + /** Same as {@link #isBiometricsSupported()} but also requires at least one biometric to be enrolled. */ native boolean canAuthenticateBiometric(); diff --git a/vm/ByteCodeTranslator/src/cn1_globals.h b/vm/ByteCodeTranslator/src/cn1_globals.h index 9aba3030b45..f599ab0f4d7 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.h +++ b/vm/ByteCodeTranslator/src/cn1_globals.h @@ -225,6 +225,15 @@ struct clazz { // an exact registry instead of a distance heuristic (see gcMarkObject). Only // meaningful under CN1_CONSERVATIVE_GC_ROOTS; stays zero otherwise. JAVA_BOOLEAN cn1ClazzRegistered; +#ifdef CN1_ALLOC_CENSUS + // TRAILING for the same reason as cn1ClazzRegistered above: the generated + // clazz initializers are positional and never name these, so C zero-fills + // them and no translator change is needed. Plain non-atomic counters -- this + // is a diagnostic build only, and an increment lost to a race costs nothing + // a census is meant to answer. + long cn1AllocCount; + long cn1AllocBytes; +#endif }; #ifdef CN1_CONSERVATIVE_GC_ROOTS @@ -243,6 +252,21 @@ extern void cn1GcRegisterClazz(struct clazz* c); #define CN1_CLAZZ_REGISTER(cptr) do {} while(0) #endif +// Allocation volume BY CLASS. "The heap is 60MB" names nothing anyone can act +// on; "3.1MB of java_lang_Long" names a fix. Counted at every allocation path +// (both inline BiBOP bump paths and the out-of-line codenameOneGcMalloc), so +// unlike a walk of allObjectsInHeap it does not silently miss the BiBOP and +// nursery objects -- which is precisely where small, high-churn objects such as +// boxed values live. +#ifdef CN1_ALLOC_CENSUS +#define CN1_ALLOC_CENSUS_COUNT(cptr, sz) do { \ + struct clazz* __cc = (struct clazz*)(cptr); \ + if(__cc != 0) { __cc->cn1AllocCount++; __cc->cn1AllocBytes += (long)(sz); } \ + } while(0) +#else +#define CN1_ALLOC_CENSUS_COUNT(cptr, sz) do {} while(0) +#endif + #define EMPTY_INTERFACES ((const struct clazz**)0) struct JavaObjectPrototype { @@ -1575,6 +1599,7 @@ static inline JAVA_OBJECT cn1BibopFastAlloc(CODENAME_ONE_THREAD_STATE, int size, // __codenameOneReferenceCount + __codenameOneThreadData relocated out of the // header (force-visited / monitor side tables); no per-object stores. o->__heapPosition = CN1_BIBOP_HEAP_POS; + CN1_ALLOC_CENSUS_COUNT(parent, size); #ifdef DEBUG_GC_ALLOCATIONS o->className = threadStateData->callStackClass[threadStateData->callStackOffset - 1]; o->line = threadStateData->callStackLine[threadStateData->callStackOffset - 1]; @@ -1664,6 +1689,7 @@ static inline JAVA_OBJECT cn1BibopFastAllocNoZero(CODENAME_ONE_THREAD_STATE, int // class pointer. o->__codenameOneParentClsReference = (struct clazz*)0; o->__heapPosition = CN1_BIBOP_HEAP_POS; + CN1_ALLOC_CENSUS_COUNT(parent, size); #ifdef DEBUG_GC_ALLOCATIONS o->className = threadStateData->callStackClass[threadStateData->callStackOffset - 1]; o->line = threadStateData->callStackLine[threadStateData->callStackOffset - 1]; diff --git a/vm/ByteCodeTranslator/src/cn1_globals.m b/vm/ByteCodeTranslator/src/cn1_globals.m index 0c488012226..d6571b214f9 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.m +++ b/vm/ByteCodeTranslator/src/cn1_globals.m @@ -4181,6 +4181,129 @@ void cn1GcRegisterClazz(struct clazz* c) { c->cn1ClazzRegistered = JAVA_TRUE; } +#if defined(CN1_ALLOC_CENSUS) && defined(__APPLE__) +// cn1HeapAccounting weighs legacy-heap blocks with malloc_size (the object +// header does not record instance size). The other include of this header is +// scoped to CN1_GC_VERIFY builds, so the census needs its own. +#include +#endif + +#ifdef CN1_ALLOC_CENSUS +/** + * Prints allocation volume by class, biggest first. + * + * Deliberately a census of what was ALLOCATED rather than of what is live: churn + * is what costs, and a live-object walk cannot see the BiBOP or nursery objects + * at all (they never enter allObjectsInHeap), which is exactly where the small + * high-turnover objects sit. Counters are read without synchronisation; a + * diagnostic wants the shape, not the last digit. + */ +/** + * Separates the JAVA heap from native allocation. + * + * vmmap cannot do this: BiBOP arenas are posix_memalign'd so they land in + * MALLOC_LARGE and the legacy heap in MALLOC_SMALL, side by side with every + * Metal, CoreGraphics and image buffer the process owns. Comparing "our malloc + * total" against another runtime's figures therefore compares a heap against a + * heap PLUS a renderer. These numbers are the heap on its own. + * + * Reserved is what BiBOP has taken from the allocator; live is what objects + * actually occupy. The difference is the honest cost of the page pool: BiBOP + * never frees a page back per-object, it pools swept pages by size class. + */ +void cn1HeapAccounting(const char* label) { + long long pages = 0, capBytes = 0, liveBytes = 0, ownedPages = 0, emptyPages = 0; + CN1BibopPage* p = atomic_load_explicit(&bibopAllPages, memory_order_acquire); + while(p != 0) { + pages++; + capBytes += CN1_BIBOP_PAGE_SIZE; + int bi = atomic_load_explicit(&p->bumpIndex, memory_order_relaxed); + int live = bi - p->freeCount; + if(live < 0) { + live = 0; + } + if(live == 0) { + emptyPages++; + } + liveBytes += (long long)live * (long long)p->slotSize; + if(p->owned) { + ownedPages++; + } + p = atomic_load_explicit(&p->nextAll, memory_order_acquire); + } + // The legacy heap is NOT part of the BiBOP figures above and is easy to + // forget: objects too big for the largest size class go through calloc and + // the allObjectsInHeap table instead. Reporting only the BiBOP total + // understates the Java heap by whatever these weigh, so weigh them -- + // malloc_size gives the true block size for a calloc'd pointer. + long long legacyBytes = 0, legacyLive = 0; + int nHeap = currentSizeOfAllObjectsInHeap; + for(int i = 0 ; i < nHeap ; i++) { + JAVA_OBJECT o = allObjectsInHeap[i]; + if(o == JAVA_NULL) { + continue; + } + legacyLive++; +#if defined(__APPLE__) + legacyBytes += (long long)malloc_size((void*)o); +#endif + } + fprintf(stderr, + "[JHEAP:%s] bibop pages=%lld reserved=%.2fMB live=%.2fMB slack=%.2fMB " + "(owned=%lld empty=%lld) | legacy objects=%lld bytes=%.2fMB | " + "JAVA TOTAL live=%.2fMB resident=%.2fMB\n", + label, pages, capBytes / 1048576.0, liveBytes / 1048576.0, + (capBytes - liveBytes) / 1048576.0, ownedPages, emptyPages, + legacyLive, legacyBytes / 1048576.0, + (liveBytes + legacyBytes) / 1048576.0, + (capBytes + legacyBytes) / 1048576.0); + fflush(stderr); +} + +void cn1AllocCensus(const char* label) { + struct Row { const char* name; long count; long bytes; }; + static struct Row rows[4096]; + int used = 0; + long long totalBytes = 0, totalCount = 0; + for(int i = 0 ; i < CN1_CLAZZ_SET_SIZE ; i++) { + uintptr_t v = atomic_load_explicit(&cn1ClazzSet[i], memory_order_relaxed); + if(v == 0) { + continue; + } + struct clazz* c = (struct clazz*)v; + if(c->cn1AllocBytes == 0 && c->cn1AllocCount == 0) { + continue; + } + totalBytes += c->cn1AllocBytes; + totalCount += c->cn1AllocCount; + if(used < 4096) { + rows[used].name = c->clsName ? c->clsName : "?"; + rows[used].count = c->cn1AllocCount; + rows[used].bytes = c->cn1AllocBytes; + used++; + } + } + fprintf(stderr, "[ALLOC:%s] %lld objects, %lld bytes (%.1fMB) across %d classes\n", + label, totalCount, totalBytes, totalBytes / (1024.0 * 1024.0), used); + for(int shown = 0 ; shown < 30 ; shown++) { + int best = -1; + for(int i = 0 ; i < used ; i++) { + if(rows[i].bytes > 0 && (best < 0 || rows[i].bytes > rows[best].bytes)) { + best = i; + } + } + if(best < 0) { + break; + } + fprintf(stderr, "[ALLOC:%s] %10ld bytes %9ld objs %s\n", + label, rows[best].bytes, rows[best].count, rows[best].name); + rows[best].bytes = 0; + } + fflush(stderr); +} +#endif + + // ========================== Immortal object registry ========================== // Objects deliberately REMOVED from the heap table (interned constant-pool // strings, static-final removal values, VM cache singletons) are unresolvable @@ -6536,6 +6659,7 @@ JAVA_OBJECT codenameOneGcMalloc(CODENAME_ONE_THREAD_STATE, int size, struct claz } allocationsSinceLastGC += size; totalAllocations += size; + CN1_ALLOC_CENSUS_COUNT(parent, size); #ifdef CN1_GC_INSTRUMENT extern long long cn1_instr_allocCount; cn1_instr_allocCount++; #endif diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 3d83f66f779..cf45e9f61cd 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -1856,6 +1856,23 @@ JAVA_VOID java_lang_System_gcMarkSweep__(CODENAME_ONE_THREAD_STATE) { threadStateData->exception = JAVA_NULL; } flushReleaseQueue(); +#ifdef CN1_ALLOC_CENSUS + { + // Several points, not one: allocation during startup and allocation once + // the first screen is up are different questions, and a single sample + // cannot tell them apart. + extern void cn1AllocCensus(const char*); + extern void cn1HeapAccounting(const char*); + static int cn1CensusCycle = 0; + int c = ++cn1CensusCycle; + if(c == 3 || c == 10 || c == 25 || c == 50) { + char lbl[32]; + snprintf(lbl, sizeof(lbl), "cycle%d", c); + cn1AllocCensus(lbl); + cn1HeapAccounting(lbl); + } + } +#endif lowMemoryMode = JAVA_FALSE; gcCurrentlyRunning = JAVA_FALSE; } From 79971b3cce2daaa1ee115f045de638f9d8e6b1bf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:07:52 +0300 Subject: [PATCH 2/7] VM: lazy string constant pool, force-visited arena, and startup phase probe General ParparVM memory/startup work that has been sitting unmerged, plus the fixes for the first review pass. * Lazy string constant pool. Every string constant in the binary was materialised into a java.lang.String before main ran -- on a large transpiled application, 38,238 of them. They are now created on first reference. * The force-visited table allocates its entries from arena blocks and prunes on sweep, instead of growing without bound and doing per-entry allocation on the hot path. * CN1_STARTUP_PHASES, a compiled-out probe that times the phases before the first frame, and the heap histogram alongside the allocation census. Review fixes: * cn1StencilStorageMode probes respondsToSelector: before sending supportsFamily:. That selector is iOS 13, ios.deployment_target is a build hint, and IPhoneBuilder will emit targets well below it -- where the message would have terminated the app during view initialisation. * updateFrameBufferSize: returns early in direct mode instead of building a render pass with a nil colour attachment and no explicit dimensions. That is invalid Metal; it survived only because the encoder came back nil and every message to it was a no-op. The polygon-clip stencil both paths need is factored into buildStencilTextureForWidth:height:layer:. * The assistive-technology check adds AssistiveTouch, and the VoiceOver / SwitchControl / AssistiveTouch status notifications now force a projection. Without that the native tree -- which is push-only, populated solely by accessibilityTreeChanged -> updateAccessibilityTree -- stayed empty when a technology started after a static screen was already up. UIKit publishes running flags for exactly those three and nothing else, so no flag can describe Voice Control or Full Keyboard Access. Rather than leave those users without a tree, any status notification latches eager projection on for the rest of the process: one process's worth of projection is the right side to err on for an accessibility feature. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/IOSNative.m | 60 ++++- Ports/iOSPort/nativeSources/METALView.m | 51 +++- .../codename1/impl/ios/IOSImplementation.java | 31 +++ vm/ByteCodeTranslator/src/cn1_globals.h | 24 +- vm/ByteCodeTranslator/src/cn1_globals.m | 248 +++++++++++++++++- vm/ByteCodeTranslator/src/nativeMethods.m | 74 +++++- vm/JavaAPI/src/java/lang/System.java | 15 ++ 7 files changed, 477 insertions(+), 26 deletions(-) diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 87da2ee0b36..08864894a6c 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -14894,14 +14894,72 @@ static void cn1_resetContext(void) { } #endif // !TARGET_OS_TV +#if !TARGET_OS_WATCH +BOOL cn1AccessibilityEagerLatched(void); +void cn1RegisterAccessibilityStatusObservers(void); + +// A technology STARTING is not a component mutation, so nothing in the portable +// layer would schedule the projection it needs and the native tree would stay +// empty until some unrelated UI change happened to invalidate something. These +// notifications are the trigger for that transition. +// +// They also cover the technologies whose running state UIKit will not report: +// enabling Voice Control or Full Keyboard Access flips VoiceOver/AssistiveTouch +// often enough in practice, but not always -- so once ANY of these fires we +// latch eager projection on for the rest of the process rather than trusting a +// flag that has no way to describe them. Paying one process's worth of eager +// projection is the right side to err on for an accessibility feature. +static BOOL cn1A11yLatched = NO; + +BOOL cn1AccessibilityEagerLatched(void) { + return cn1A11yLatched; +} + +static void cn1AccessibilityStatusChanged(CFNotificationCenterRef center, void *observer, + CFStringRef name, const void *object, + CFDictionaryRef userInfo) { + cn1A11yLatched = YES; + com_codename1_impl_ios_IOSImplementation_assistiveTechnologyStatusChanged__( + CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); +} + +void cn1RegisterAccessibilityStatusObservers(void) { + static BOOL done = NO; + if(done) { + return; + } + done = YES; + NSArray *names = @[UIAccessibilityVoiceOverStatusDidChangeNotification, + UIAccessibilitySwitchControlStatusDidChangeNotification, + UIAccessibilityAssistiveTouchStatusDidChangeNotification]; + for(NSString *n in names) { + CFNotificationCenterAddObserver(CFNotificationCenterGetLocalCenter(), NULL, + cn1AccessibilityStatusChanged, + (__bridge CFStringRef)n, NULL, + CFNotificationSuspensionBehaviorDeliverImmediately); + } +} +#endif + JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAssistiveTechnologyActive___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { // CN1_EAGER_A11Y=1 restores the old always-project behaviour for an A/B. if(getenv("CN1_EAGER_A11Y") != NULL) { return JAVA_TRUE; } #if !TARGET_OS_WATCH + // These three are the ENTIRE public surface for "is an assistive technology + // running": UIKit exposes IsVoiceOverRunning, IsSwitchControlRunning and + // IsAssistiveTouchRunning and nothing else. In particular there is no + // public running flag for Voice Control or Full Keyboard Access, so this + // cannot detect them -- see cn1AccessibilityStatusChanged for how that gap + // is covered rather than ignored. + cn1RegisterAccessibilityStatusObservers(); + if(cn1AccessibilityEagerLatched()) { + return JAVA_TRUE; + } return (UIAccessibilityIsVoiceOverRunning() || - UIAccessibilityIsSwitchControlRunning()) ? JAVA_TRUE : JAVA_FALSE; + UIAccessibilityIsSwitchControlRunning() || + UIAccessibilityIsAssistiveTouchRunning()) ? JAVA_TRUE : JAVA_FALSE; #else return JAVA_FALSE; #endif diff --git a/Ports/iOSPort/nativeSources/METALView.m b/Ports/iOSPort/nativeSources/METALView.m index a0631c1d52a..bd6f71896fd 100644 --- a/Ports/iOSPort/nativeSources/METALView.m +++ b/Ports/iOSPort/nativeSources/METALView.m @@ -425,7 +425,13 @@ static MTLStorageMode cn1StencilStorageMode(id device) { if(forcePrivate < 0) { forcePrivate = getenv("CN1_STENCIL_PRIVATE") != NULL ? 1 : 0; } - if(!forcePrivate && device != nil && [device supportsFamily:MTLGPUFamilyApple1]) { + // supportsFamily: is iOS 13 / macOS 10.15. ios.deployment_target is a build + // hint and IPhoneBuilder will happily emit a target below that, where this + // selector does not exist and the message would terminate the app during + // view initialisation. Probe before sending, and fall back to Private. + if(!forcePrivate && device != nil && + [device respondsToSelector:@selector(supportsFamily:)] && + [device supportsFamily:MTLGPUFamilyApple1]) { return MTLStorageModeMemoryless; } return MTLStorageModePrivate; @@ -649,6 +655,24 @@ - (void)deleteFramebuffer } +// The polygon-clip stencil (#3921) is needed by both render paths, so it is +// factored out of updateFrameBufferSize: -- direct mode returns early, before +// the screen-texture initialisation it has no texture to do. +- (void)buildStencilTextureForWidth:(int)pw height:(int)ph layer:(CAMetalLayer *)layer { + // Storage mode is chosen at runtime -- see cn1StencilStorageMode. + MTLTextureDescriptor *stencilDesc = [MTLTextureDescriptor + texture2DDescriptorWithPixelFormat:MTLPixelFormatStencil8 + width:pw height:ph mipmapped:NO]; + stencilDesc.usage = MTLTextureUsageRenderTarget; + stencilDesc.storageMode = cn1StencilStorageMode(layer.device); + id newStencil = [layer.device newTextureWithDescriptor:stencilDesc]; + CN1_TEX_NOTE("stencilTexture", newStencil); + self.stencilTexture = newStencil; +#ifndef CN1_USE_ARC + [newStencil release]; +#endif +} + -(void)updateFrameBufferSize:(int)w h:(int)h { // Trust caller-supplied physical-pixel dimensions; fall back to bounds // only if the caller passes 0. Reading self.bounds alone is unsafe @@ -756,6 +780,17 @@ -(void)updateFrameBufferSize:(int)w h:(int)h { // exists we scale-blit it in (preserving the last visible content across // the resize -- see the oldScreen capture above); otherwise we just clear // to opaque black. + // Direct mode has no screenTexture to initialise, and a render pass with a + // nil colour attachment and no explicit renderTargetWidth/Height is invalid + // -- it survives today only because the encoder comes back nil and every + // message to it is a no-op, which is not something to rely on. + if (self.screenTexture == nil) { + [self buildStencilTextureForWidth:pw height:ph layer:layer]; +#ifndef CN1_USE_ARC + [oldScreen release]; +#endif + return; + } id clearCb = [self.commandQueue commandBuffer]; MTLRenderPassDescriptor *clearPass = [MTLRenderPassDescriptor renderPassDescriptor]; clearPass.colorAttachments[0].texture = self.screenTexture; @@ -810,19 +845,7 @@ -(void)updateFrameBufferSize:(int)w h:(int)h { [clearEnc endEncoding]; [clearCb commit]; - // Build a matching Stencil8 attachment for polygon-shape clipping (#3921). - // Storage mode is chosen at runtime -- see cn1StencilStorageMode. - MTLTextureDescriptor *stencilDesc = [MTLTextureDescriptor - texture2DDescriptorWithPixelFormat:MTLPixelFormatStencil8 - width:pw height:ph mipmapped:NO]; - stencilDesc.usage = MTLTextureUsageRenderTarget; - stencilDesc.storageMode = cn1StencilStorageMode(layer.device); - id newStencil = [layer.device newTextureWithDescriptor:stencilDesc]; - CN1_TEX_NOTE("stencilTexture", newStencil); - self.stencilTexture = newStencil; -#ifndef CN1_USE_ARC - [newStencil release]; -#endif + [self buildStencilTextureForWidth:pw height:ph layer:layer]; // Push the preserved frame onto the layer so the rotation never shows // black (#5162) -- but NOT synchronously here. updateFrameBufferSize: runs diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 70ae29ad13b..6ed78e346fb 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -63,6 +63,7 @@ import com.codename1.push.PushActionsProvider; import com.codename1.ui.BrowserComponent; import com.codename1.ui.Form; +import com.codename1.ui.accessibility.AccessibilityManager; import com.codename1.ui.Label; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.events.ActionListener; @@ -13088,6 +13089,36 @@ public boolean isAccessibilityTreeSupported() { /// Turning VoiceOver on mid-session is picked up on the next invalidation -- /// the flag is read per call, and any mutation after that point projects /// normally. + /// Invoked from native when an assistive-technology status notification fires. + /// + /// The status flip itself is not a component mutation, so without this nothing + /// would schedule the projection a newly-started technology needs and the + /// native tree would stay empty until some unrelated UI change happened to + /// invalidate a component. Marks the whole current form dirty so the very next + /// pass rebuilds and pushes the tree. + public static void assistiveTechnologyStatusChanged() { + final IOSImplementation impl = instance; + if (impl == null) { + return; + } + Display d = Display.getInstance(); + if (d == null) { + return; + } + d.callSerially(new Runnable() { + @Override + public void run() { + Form f = Display.getInstance().getCurrent(); + if (f != null) { + AccessibilityManager.getInstance().invalidate(f, + AccessibilityManager.CHANGE_STRUCTURE + | AccessibilityManager.CHANGE_CONTENT + | AccessibilityManager.CHANGE_STATE); + } + } + }); + } + @Override public boolean isAccessibilityTreeUpdateRequired() { try { diff --git a/vm/ByteCodeTranslator/src/cn1_globals.h b/vm/ByteCodeTranslator/src/cn1_globals.h index f599ab0f4d7..3f97e14b184 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.h +++ b/vm/ByteCodeTranslator/src/cn1_globals.h @@ -757,8 +757,28 @@ extern JAVA_OBJECT* constantPoolObjects; extern int classListSize; extern struct clazz* classesList[]; -// this needs to be fixed to actually return a JAVA_OBJECT... -#define STRING_FROM_CONSTANT_POOL_OFFSET(off) constantPoolObjects[off] +/** + * The String object for a literal, materialised on FIRST USE. + * + * initConstantPool used to build every literal in the application before main + * ran. On a large transpiled application that was 38,238 java.lang.String + * objects and their backing arrays -- 61% of every live object in the process -- + * for an application that touches a few thousand of them: every localisation of + * every string for every locale, every UIID, every demo description, all + * allocated, all pinned by the pool's own GC root, none of them ever read. + * + * The fast path is a load and a predicted-taken branch, which is what the old + * macro compiled to anyway. Literal IDENTITY is preserved -- the slow path is + * serialised and double-checked, so "x" == "x" stays true, which Java requires + * and generated code relies on. + */ +extern JAVA_OBJECT cn1MaterializeConstantPoolString(int off); +/// Start-up attribution probe; prints elapsed-since-process-start when +/// CN1_STARTUP_PHASES is set, and costs one cached getenv otherwise. +extern void cn1StartupPhase(const char* name); +#define STRING_FROM_CONSTANT_POOL_OFFSET(off) \ + (__builtin_expect(constantPoolObjects[off] != JAVA_NULL, 1) \ + ? constantPoolObjects[off] : cn1MaterializeConstantPoolString(off)) #define BC_IINC(val, num) ilocals_##val##_ += num; diff --git a/vm/ByteCodeTranslator/src/cn1_globals.m b/vm/ByteCodeTranslator/src/cn1_globals.m index d6571b214f9..0079c1a5126 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.m +++ b/vm/ByteCodeTranslator/src/cn1_globals.m @@ -1367,6 +1367,7 @@ static void cn1DrainDeadThreadPending() { * A simple concurrent mark algorithm that traverses the currently running threads */ extern int recursionKey; // force-mark pass epoch (defined below, near gcMarkObject) +static void cn1ForceVisitedPrune(int key); // force-visited side table sweep (defined with the table) // ---- SATB (snapshot-at-the-beginning) deletion-barrier log ------------------- // gcSatbActive is set for the whole concurrent mark and read by CN1_SATB_DELETE on @@ -1572,6 +1573,9 @@ void codenameOneGCMark() { // Bump the force-mark pass epoch so the force-visited side table's prior-cycle entries // read as not-visited (relocated from the old per-object __codenameOneReferenceCount). recursionKey++; + // Release the force-visited entries for objects that were not force-visited in + // the cycle just finished -- most of them for objects the last sweep freed. + cn1ForceVisitedPrune(recursionKey); #ifdef CN1_CONSERVATIVE_GC_ROOTS // PHASE 3b: ensure the universal thread-stop signal handler is installed (idempotent, // first GC only). Used to stop+scan threads we cannot cooperatively park. @@ -1829,7 +1833,12 @@ void codenameOneGCMark() { { extern const char* cn1GcMarkPhase; cn1GcMarkPhase = "constant-pool"; } #endif for(int iter = 0 ; iter < CN1_CONSTANT_POOL_SIZE ; iter++) { - gcMarkObject(d, (JAVA_OBJECT)constantPoolObjects[iter], JAVA_TRUE); + // Most entries are JAVA_NULL now (the pool fills on first use); the + // explicit test skips the call rather than paying it per empty slot. + JAVA_OBJECT poolEntry = constantPoolObjects[iter]; + if(poolEntry != JAVA_NULL) { + gcMarkObject(d, poolEntry, JAVA_TRUE); + } } #ifdef CN1_CONSERVATIVE_GC_ROOTS @@ -2296,6 +2305,75 @@ void printObjectTypesInHeap(CODENAME_ONE_THREAD_STATE) { #ifndef CN1_DISABLE_BIBOP static void cn1BibopSweep(CODENAME_ONE_THREAD_STATE); #endif +#ifdef CN1_HEAP_HISTOGRAM +/** + * Names what is actually ON the heap, biggest first. + * + * "The heap is 65MB" is not a thing anyone can fix; "there are 90,000 of THIS + * class" is. Every live object carries a pointer to its clazz, and the + * collector already has to walk the whole registry, so a census costs one pass + * and answers the only question that matters when a heap is bigger than it + * should be. Compiled out unless CN1_HEAP_HISTOGRAM is defined; print it from a + * diagnostic build, read it, and take the top of the list. + */ +static void cn1HeapHistogram(void) { + #define CN1_HIST_MAX 512 + static const char* names[CN1_HIST_MAX]; + static long counts[CN1_HIST_MAX]; + int used = 0; + long total = 0; + int t = currentSizeOfAllObjectsInHeap; + for(int iter = 0 ; iter < t ; iter++) { + JAVA_OBJECT o = allObjectsInHeap[iter]; + if(o == JAVA_NULL || o->__codenameOneParentClsReference == 0) { + continue; + } + total++; + const char* n = o->__codenameOneParentClsReference->clsName; + if(n == 0) { + n = "?"; + } + int found = -1; + for(int i = 0 ; i < used ; i++) { + if(names[i] == n) { // clsName is a static literal: pointer identity is enough + found = i; + break; + } + } + if(found < 0) { + if(used < CN1_HIST_MAX) { + found = used++; + names[found] = n; + counts[found] = 0; + } else { + continue; + } + } + counts[found]++; + } + fprintf(stderr, "[HEAP] %ld live objects in %d classes\n", total, used); + for(int shown = 0 ; shown < 25 ; shown++) { + int best = -1; + for(int i = 0 ; i < used ; i++) { + if(counts[i] > 0 && (best < 0 || counts[i] > counts[best])) { + best = i; + } + } + if(best < 0) { + break; + } + fprintf(stderr, "[HEAP] %8ld %s\n", counts[best], names[best]); + counts[best] = 0; + } + fflush(stderr); +} +#endif + +#ifdef CN1_HEAP_HISTOGRAM +void cn1HeapHistogramPublic(void) { + cn1HeapHistogram(); +} +#endif // Release the threads the mark parked as aggressive allocators. Called on both exits // from codenameOneGCSweep -- see the one that skips the reclaim. static void cn1GcReleaseBlockedThreads(void) { @@ -7034,6 +7112,36 @@ void codenameOneGcFree(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT obj) { struct CN1FVEntry { JAVA_OBJECT key; int epoch; struct CN1FVEntry* next; }; #define CN1_FV_BUCKETS 4096 static struct CN1FVEntry* cn1FVBuckets[CN1_FV_BUCKETS]; +// Entries currently in the buckets, so the sweep below can decide whether the +// table is worth walking at all. +static long cn1FVLive = 0; +// Entries the sweep has taken out of the buckets, kept for re-use instead of +// being handed back to the allocator. An allocation storm collects constantly and +// force-visits a different set of objects every time, so the same entries are +// retired and re-created cycle after cycle: freeing them made the sweep 13% of +// the objectAllocation benchmark all by itself (44.5ms against 38.8ms with the +// sweep taken out). Recycling keeps the table's memory at the high-water mark of +// objects force-visited at once -- which is the bound that matters -- and takes +// the allocator out of the collector's inner loop entirely. +static struct CN1FVEntry* cn1FVRecycle = 0; +// Entries come from ARENA BLOCKS, not from one malloc each. Nothing ever frees +// an entry individually -- the sweep moves it to the recycle list above -- so +// per-entry allocation buys nothing and costs a great deal: measured on the +// gallery, this table held 74,443 live entries, which was HALF of the 150,836 +// malloc nodes in the whole process. Every one carries allocator bookkeeping and +// scatters through the small-block regions, and on this platform the pages a +// peak of them touched are never given back. One block per 4096 entries turns +// 74,443 allocations into eighteen. +// +// Blocks are never freed. The table's high-water mark is the bound that matters +// and the recycle list already caps reuse; handing a block back would only be +// possible if every entry in it were free at once, which is not worth tracking. +#define CN1_FV_ARENA_ENTRIES 4096 +static struct CN1FVEntry* cn1FVArena = 0; +static int cn1FVArenaUsed = CN1_FV_ARENA_ENTRIES; // forces the first block +// Sweep only once the table has actually grown; the table has to be BOUNDED, not +// minimal, and a sweep of a small table is pure cost. +#define CN1_FV_PRUNE_THRESHOLD 8192 static inline unsigned cn1FVHash(JAVA_OBJECT o) { uintptr_t p = (uintptr_t)o; p >>= 4; @@ -7052,12 +7160,67 @@ static int cn1ForceVisitedTestAndSet(JAVA_OBJECT obj, int key) { } e = e->next; } - e = (struct CN1FVEntry*)malloc(sizeof(struct CN1FVEntry)); + if(cn1FVRecycle != 0) { + e = cn1FVRecycle; + cn1FVRecycle = e->next; + } else { + if(cn1FVArenaUsed >= CN1_FV_ARENA_ENTRIES) { + struct CN1FVEntry* block = (struct CN1FVEntry*)malloc( + sizeof(struct CN1FVEntry) * CN1_FV_ARENA_ENTRIES); + if(block == 0) { + // Out of memory for the guard table. Returning 0 says "not + // visited", which costs a re-traversal and terminates anyway + // because the mark bit is already set -- slower, never wrong. + return 0; + } + cn1FVArena = block; + cn1FVArenaUsed = 0; + } + e = &cn1FVArena[cn1FVArenaUsed++]; + } e->key = obj; e->epoch = key; e->next = cn1FVBuckets[h]; cn1FVBuckets[h] = e; + cn1FVLive++; return 0; } +// Drop entries no longer in use. Nothing ever removed from this table: an entry +// was allocated the first time an object was force-visited and then kept for the +// life of the process, keyed by an object pointer that the very next sweep could +// free. Two costs, both unbounded. The obvious one is 32 bytes of malloc per +// distinct object ever force-visited -- measured at 134,619 live 32-byte blocks +// in a gallery application that had 113,824 live Java objects, i.e. the table had +// outgrown the heap it was describing. The one that hurts more is the bucket +// chains: lookup is a linear walk, so every force-visit in every later cycle pays +// for every object that died in an earlier one, and the collector gets slower the +// longer the application runs -- exactly the shape that never shows up in a +// benchmark and always shows up in a long session. +// +// Called once per cycle from codenameOneGCMark, right after recursionKey is +// bumped, on the GC thread with no marking in flight. An entry survives if it was +// force-visited in the cycle that just finished (epoch == key - 1), so a working +// set that keeps being force-visited is never reallocated and the table settles at +// the size of that working set instead of the size of all history. +static void cn1ForceVisitedPrune(int key) { + if(cn1FVLive < CN1_FV_PRUNE_THRESHOLD) { + return; + } + for(int b = 0 ; b < CN1_FV_BUCKETS ; b++) { + struct CN1FVEntry** pp = &cn1FVBuckets[b]; + while(*pp) { + struct CN1FVEntry* e = *pp; + if(e->epoch < key - 1) { + *pp = e->next; + e->next = cn1FVRecycle; + cn1FVRecycle = e; + cn1FVLive--; + } else { + pp = &e->next; + } + } + } +} + // Iterative mark using an explicit worklist. The previous implementation recursed // through reference fields, building one C stack frame per Java reference traversed. // iOS gives secondary pthreads a ~512KB stack, so a chain of a few thousand references @@ -8598,7 +8761,74 @@ void cn1ThrowStackOverflow(CODENAME_ONE_THREAD_STATE) { throwException(threadStateData, soe); } +static pthread_mutex_t constantPoolMutex = PTHREAD_MUTEX_INITIALIZER; + +/** + * Builds the String for a literal the first time anything asks for it. + * + * Serialised and double-checked so that a literal has exactly ONE String for + * the life of the process: Java guarantees `"x" == "x"`, and generated code + * compares literals by identity. The new object is reachable from this thread's + * pending allocations until it is stored into the pool array, which is itself a + * GC root, so there is no window in which it can be collected. + */ +JAVA_OBJECT cn1MaterializeConstantPoolString(int off) { + struct ThreadLocalData* threadStateData = getThreadLocalData(); + pthread_mutex_lock(&constantPoolMutex); + JAVA_OBJECT o = constantPoolObjects[off]; + if(o == JAVA_NULL) { + o = newStringFromCString(threadStateData, constantPool[off]); + constantPoolObjects[off] = o; + } + pthread_mutex_unlock(&constantPoolMutex); + return o; +} + +#if defined(__APPLE__) +#include +#include +/** + * Milliseconds since this PROCESS was forked, for start-up attribution. + * + * Measured against the KERNEL's record of process start, not a mark taken inside + * the application, because the interesting part of a slow launch is what happens + * before any of our code runs. Gated on CN1_STARTUP_PHASES: a shipping build + * pays one getenv and nothing else. + */ +static int cn1StartupPhasesOn(void) { + static int on = -1; + if(on < 0) { + on = getenv("CN1_STARTUP_PHASES") ? 1 : 0; + } + return on; +} + +double cn1MillisSinceProcessStart(void) { + struct kinfo_proc info; + size_t len = sizeof(info); + int mib[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid() }; + if(sysctl(mib, 4, &info, &len, NULL, 0) != 0) { + return -1.0; + } + struct timeval now; + gettimeofday(&now, NULL); + struct timeval start = info.kp_proc.p_starttime; + return (now.tv_sec - start.tv_sec) * 1000.0 + (now.tv_usec - start.tv_usec) / 1000.0; +} + +void cn1StartupPhase(const char* name) { + if(!cn1StartupPhasesOn()) { + return; + } + fprintf(stderr, "BENCH:PHASE-AT %-28s %8.1f ms\n", name, cn1MillisSinceProcessStart()); + fflush(stderr); +} +#else +void cn1StartupPhase(const char* name) { } +#endif + void initConstantPool() { + cn1StartupPhase("main"); __STATIC_INITIALIZER_java_lang_Class(getThreadLocalData()); struct ThreadLocalData* threadStateData = getThreadLocalData(); enteringNativeAllocations(); @@ -8616,13 +8846,15 @@ void initConstantPool() { //int cStringSize = CN1_CONSTANT_POOL_SIZE * sizeof(char*); //int jStringSize = CN1_CONSTANT_POOL_SIZE * sizeof(JAVA_ARRAY); //JAVA_OBJECT internedStrings = get_static_java_lang_String_str(); + // The pool starts EMPTY. Every literal used to be built right here, before + // main ran: on a large transpiled application that was 38,238 String objects + // plus their backing arrays, 61% of every live object in the process, for an + // application that reads a few thousand of them. They are built on first use + // now -- see cn1MaterializeConstantPoolString. The array itself is still + // allocated eagerly because a non-null constantPoolObjects is what the rest + // of the runtime tests to decide the VM is up. for(int iter = 0 ; iter < CN1_CONSTANT_POOL_SIZE ; iter++) { - //long length = strlen(constantPool[iter]); - //cStringSize += length + 1; - //jStringSize += length * sizeof(JAVA_ARRAY_CHAR) + sizeof(struct JavaArrayPrototype) + sizeof(struct obj__java_lang_String); - JAVA_OBJECT oo = newStringFromCString(threadStateData, constantPool[iter]); - tmpConstantPoolObjects[iter] = oo; - // java_util_ArrayList_add___java_lang_Object_R_boolean(threadStateData, internedStrings, oo); + tmpConstantPoolObjects[iter] = JAVA_NULL; } #if defined(__OBJC__) //NSLog(@"Size of constant pool in c: %i and j: %i", cStringSize, jStringSize); diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index cf45e9f61cd..99cc68774e2 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -33,6 +33,9 @@ #include #include #include +#if defined(__APPLE__) +#include +#endif #ifndef MAX #define MAX(a,b) ((a) > (b) ? (a) : (b)) @@ -1480,6 +1483,30 @@ JAVA_DOUBLE java_lang_Math_atan___double_R_double(CODENAME_ONE_THREAD_STATE, JAV return atan(a); } +JAVA_DOUBLE java_lang_Math_acos___double_R_double(CODENAME_ONE_THREAD_STATE, JAVA_DOUBLE a) { + return acos(a); +} + +JAVA_DOUBLE java_lang_Math_asin___double_R_double(CODENAME_ONE_THREAD_STATE, JAVA_DOUBLE a) { + return asin(a); +} + +JAVA_DOUBLE java_lang_Math_atan2___double_double_R_double(CODENAME_ONE_THREAD_STATE, JAVA_DOUBLE y, JAVA_DOUBLE x) { + return atan2(y, x); +} + +JAVA_DOUBLE java_lang_Math_exp___double_R_double(CODENAME_ONE_THREAD_STATE, JAVA_DOUBLE a) { + return exp(a); +} + +JAVA_DOUBLE java_lang_Math_log___double_R_double(CODENAME_ONE_THREAD_STATE, JAVA_DOUBLE a) { + return log(a); +} + +JAVA_DOUBLE java_lang_Math_log10___double_R_double(CODENAME_ONE_THREAD_STATE, JAVA_DOUBLE a) { + return log10(a); +} + JAVA_BOOLEAN isClassNameEqual(const char * clsName, JAVA_ARRAY_CHAR* chrs, int length) { for(int i = 0 ; i < length ; i++) { if(clsName[i] != chrs[i]) return JAVA_FALSE; @@ -1520,9 +1547,17 @@ JAVA_BOOLEAN java_lang_Class_isArray___R_boolean(CODENAME_ONE_THREAD_STATE, JAVA return clz->isArray; } +// NOTE on instanceofFunction's argument order: despite its parameter names, it +// is called as instanceofFunction(TARGET_TYPE, OBJECT_CLASS) — see BC_INSTANCEOF, +// which passes the bytecode's type operand first and GET_CLASS_ID(obj) second. +// It then indexes classInstanceOf[] by the OBJECT's class (whose table lists that +// class's supertypes) and searches it for the target. Both helpers below must +// therefore pass the receiver Class first. + JAVA_BOOLEAN java_lang_Class_isAssignableFrom___java_lang_Class_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT cls, JAVA_OBJECT cls2) { struct clazz* clz1 = (struct clazz*)cls; struct clazz* clz2 = (struct clazz*)cls2; + // A.isAssignableFrom(B): target is A, the class under test is B. return instanceofFunction(clz1->classId, clz2->classId); } @@ -1530,7 +1565,17 @@ JAVA_BOOLEAN java_lang_Class_isInstance___java_lang_Object_R_boolean(CODENAME_ON if(obj == JAVA_NULL) { return JAVA_FALSE; } struct clazz* clz1 = (struct clazz*)cls; struct clazz* clz2 = (struct clazz*)CN1_CLASS_OF(obj); // tag-aware: a tagged Integer has no header - return instanceofFunction(clz2->classId, clz1->classId); + // A.isInstance(o): target is A, the class under test is o's class. These were + // reversed, so isInstance searched the TARGET's supertype table for the + // object's class and answered false for every subclass — every + // Class.isInstance in a native build was wrong unless the types were equal. + return instanceofFunction(clz1->classId, clz2->classId); +} + +JAVA_OBJECT java_lang_Class_getSuperclass___R_java_lang_Class(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT cls) { + struct clazz* clz = (struct clazz*)cls; + // Object, interfaces, primitives and void have no superclass. + return (JAVA_OBJECT)clz->baseClass; } JAVA_BOOLEAN java_lang_Class_isInterface___R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT cls) { @@ -1873,6 +1918,33 @@ JAVA_VOID java_lang_System_gcMarkSweep__(CODENAME_ONE_THREAD_STATE) { } } #endif +#ifdef CN1_HEAP_HISTOGRAM + { + // Once, on the third cycle: the first two run while the first screen is + // still being built, and a census of a half-built heap names the wrong + // things. + extern void cn1HeapHistogramPublic(void); + static int cn1HistCycle = 0; + if(++cn1HistCycle == 3) { + cn1HeapHistogramPublic(); + } + } +#endif + // No call reclaims the emptied pages. A sweep frees objects with free(), + // and libmalloc keeps most of the emptied pages in its zone rather than + // returning them to the kernel -- they stay counted against the process. + // malloc_zone_pressure_relief looks like the answer and is not: measured on + // macOS 26, a program that mallocs 200,000 x 500 bytes, touches them and + // frees every one sits at 52.9MB of physical footprint, and stays at + // exactly 52.9MB after malloc_zone_pressure_relief(NULL, 0) AND after + // calling it on every zone from malloc_get_all_zones. Three revisions of + // this file called it here on a countdown (every sixteenth cycle, every + // eighth, then every cycle) and the idle remainder never moved: 45.8MB of + // "MALLOC_SMALL (empty)" against 0.8MB for the same application built with + // a competing toolchain. The retained pages are a function of how many + // small blocks were ever live at once, so the only lever is allocating + // fewer of them -- see the object allocator, which keeps Java objects out + // of malloc entirely for exactly this reason. lowMemoryMode = JAVA_FALSE; gcCurrentlyRunning = JAVA_FALSE; } diff --git a/vm/JavaAPI/src/java/lang/System.java b/vm/JavaAPI/src/java/lang/System.java index c5ed34679d5..9b214c28ddd 100644 --- a/vm/JavaAPI/src/java/lang/System.java +++ b/vm/JavaAPI/src/java/lang/System.java @@ -80,6 +80,21 @@ public void run() { } } gcShouldLoop = true; + // Thirty seconds. Collecting more often during start-up, to + // shed the launch garbage sooner, has been tried once and is NOT + // settled: widening the idle interval from one second instead + // (cycles at t=2,3,5,9,17,33s rather than t=2,32s) measured a + // settled footprint of 216.6MB against 169.8MB for the flat + // interval -- but the same flat-interval build re-measured 252.8MB + // an hour later, by which point the host had 13.4GB of its 14.3GB + // swap in use. Physical footprint moves with the host's memory + // pressure, so those three numbers were never comparable and the + // experiment proved nothing either way. + // + // If you revisit it: build BOTH binaries, keep both .app bundles, + // and interleave them in one session (A,B,A,B) on a host that is + // not swapping. A soak of one build followed by a soak of the + // other an hour later measures the machine. while(gcShouldLoop) { try { System.gcMarkSweep(); From 3d98105c56200e1eef6209a6c5be3a1ed496b496 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:23:08 +0300 Subject: [PATCH 3/7] iOS: detect an accessibility client by its query, not by per-technology flags The previous round gated eager projection on UIAccessibilityIsVoiceOverRunning / IsSwitchControlRunning / IsAssistiveTouchRunning and latched on their status notifications. Those are the only running flags UIKit publishes, so nothing in that set can see Voice Control or Full Keyboard Access -- and when one of them is already enabled at launch no notification fires either, so the latch never engaged and those users got no tree at all. METALView now overrides accessibilityElements. UIKit asks a container for its elements only when something is actually consuming the semantic tree, so the query itself is the signal that a client exists, and it depends on no per-technology flag. The first query may see a tree that has not been projected yet; noting the client schedules that projection and the resulting layout-changed notification brings the client back for the real one. Not covered: CodenameOne_GLViewController re-roots self.view to a plain UIView when a peer component is added mid-transition, and the elements are then set on that view instead. There the gate falls back to the flags and notifications -- the behaviour without this hook, not something worse. Noted at the override. Also documents why cn1_copyMetalScreenTextureImage returns NULL in direct-to-drawable mode: there is no retained screen texture to read, so Display.screenshot() falls through to drawViewHierarchyInRect:. That is correct on device but samples the presented drawable, so it can lag a frame and, on headless Catalyst with no display link, can be stale indefinitely. Reading the live drawable is not the fix -- retaining it past present starves nextDrawable, and after present the buffer is recycled. A deterministic capture wants a one-shot render into a scratch target, worth doing when the mode stops being opt-in. The default path is unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/IOSNative.m | 34 ++++++++++++++++++++----- Ports/iOSPort/nativeSources/METALView.m | 27 ++++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 08864894a6c..520d3afd77e 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -7603,6 +7603,18 @@ static CGImageRef cn1_copyMetalScreenTextureImage(METALView *mv) { } id src = mv.screenTexture; if (src == nil) { + // Direct-to-drawable mode (CN1_DIRECT_DRAWABLE, opt-in) keeps no + // retained screen texture, so there is nothing here to read back and + // the caller falls through to drawViewHierarchyInRect:. That is correct + // on device but samples the CALayer's presented drawable, so it can lag + // a frame -- exactly the staleness this readback exists to avoid, and + // on headless Catalyst (no display link) it can be stale indefinitely. + // + // Reading the live drawable instead is not the fix: retaining it past + // present starves nextDrawable, and after present the buffer is + // recycled for the following frame. A deterministic capture needs a + // one-shot render into a scratch target, which is worth doing when the + // mode stops being opt-in. Until then the default path is unaffected. return NULL; } NSUInteger w = src.width; @@ -14903,12 +14915,10 @@ static void cn1_resetContext(void) { // empty until some unrelated UI change happened to invalidate something. These // notifications are the trigger for that transition. // -// They also cover the technologies whose running state UIKit will not report: -// enabling Voice Control or Full Keyboard Access flips VoiceOver/AssistiveTouch -// often enough in practice, but not always -- so once ANY of these fires we -// latch eager projection on for the rest of the process rather than trusting a -// flag that has no way to describe them. Paying one process's worth of eager -// projection is the right side to err on for an accessibility feature. +// Once any of them fires we latch eager projection on for the rest of the +// process rather than flipping it back and forth. The technologies UIKit will +// not report at all are handled by cn1AccessibilityNoteClientQuery below, which +// does not depend on flags or notifications. static BOOL cn1A11yLatched = NO; BOOL cn1AccessibilityEagerLatched(void) { @@ -14923,6 +14933,18 @@ static void cn1AccessibilityStatusChanged(CFNotificationCenterRef center, void * CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); } +// Called from METALView's accessibilityElements getter: a real client asked for +// the tree. This, not the running flags, is what makes the gate correct for the +// technologies UIKit will not report -- see the comment on that getter. +void cn1AccessibilityNoteClientQuery(void) { + if(cn1A11yLatched) { + return; // one transition only; this is on a UIKit query path + } + cn1A11yLatched = YES; + com_codename1_impl_ios_IOSImplementation_assistiveTechnologyStatusChanged__( + CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); +} + void cn1RegisterAccessibilityStatusObservers(void) { static BOOL done = NO; if(done) { diff --git a/Ports/iOSPort/nativeSources/METALView.m b/Ports/iOSPort/nativeSources/METALView.m index bd6f71896fd..34f9d20370b 100644 --- a/Ports/iOSPort/nativeSources/METALView.m +++ b/Ports/iOSPort/nativeSources/METALView.m @@ -469,6 +469,33 @@ int cn1DirectToDrawableEnabled(void) { return cn1DirectToDrawable(); } +// UIKit asks a container for its accessibility elements only when something is +// actually consuming the semantic tree, so this query IS the signal that a +// client exists -- and unlike UIAccessibilityIsVoiceOverRunning and friends it +// does not depend on per-technology flags. That matters because UIKit publishes +// running flags for exactly three technologies (VoiceOver, Switch Control, +// AssistiveTouch) and none for Voice Control or Full Keyboard Access, so a +// flags-only gate cannot see those clients at all -- including when they are +// already enabled at launch, where no status notification ever fires either. +// +// Latching here covers every technology, present and future. The first query +// may see a tree that has not been projected yet; noting the client schedules +// that projection and the resulting layout-changed notification brings the +// client back for the real one, so it is self-correcting rather than a +// permanently empty tree. +// +// One configuration this does not see: CodenameOne_GLViewController re-roots +// self.view to a plain UIView when a peer component is added mid-transition, and +// updateAccessibilityTree then sets the elements on THAT view rather than on +// this one. There the gate falls back to the running flags and the status +// notifications -- i.e. to the behaviour without this hook, not to something +// worse. +- (NSArray *)accessibilityElements { + extern void cn1AccessibilityNoteClientQuery(void); + cn1AccessibilityNoteClientQuery(); + return [super accessibilityElements]; +} + - (void)cn1SetupMetal { self.clearsContextBeforeDrawing = NO; if ([[UIScreen mainScreen] respondsToSelector:@selector(scale)] && isRetina()) { From 0669758bc4f90024dcc33bfb60a496008852a2fa Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:46:30 +0300 Subject: [PATCH 4/7] iOS: grow the glyph atlas on width too, and guard the iOS 10 accessibility APIs Two fixes from review. The shelf packer only ever tested growth against HEIGHT. That was safe while the atlas started at 1024x1024 and glyphs were capped at CN1_METAL_ATLAS_GLYPH_MAX (256): a fresh shelf always had room across, so the missing width test could never fire. Starting the atlas at 256 makes it reachable with ordinary glyphs -- a 256-wide glyph placed at x=1 in a 256-wide texture runs past the edge, and the region handed to replaceRegion: is out of bounds. Growth is now driven by a loop that tests both dimensions and terminates either by fitting or by hitting the growth ceiling, where the glyph is dropped exactly as an oversized one already was. UIAccessibilityAssistiveTouchStatusDidChangeNotification and UIAccessibilityIsAssistiveTouchRunning() are iOS 10, and ios.deployment_target lets IPhoneBuilder emit older targets. On those the weakly-linked constant is nil -- and a nil inside an @[] literal raises, so the observer registration would have taken the app down on the first accessibility invalidation -- while the running check would call through a null symbol. The notification list is now built up skipping absent constants, and the running check tests the function pointer before calling it. Co-Authored-By: Claude Opus 5 (1M context) --- .../nativeSources/CN1MetalGlyphAtlas.m | 18 ++++++++--- Ports/iOSPort/nativeSources/IOSNative.m | 30 +++++++++++++++---- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m b/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m index e432a670300..066616c4653 100644 --- a/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m +++ b/Ports/iOSPort/nativeSources/CN1MetalGlyphAtlas.m @@ -329,10 +329,20 @@ - (CN1MetalGlyphSlot *)slotForGlyph:(CGGlyph)glyph { _cursorX = CN1_METAL_ATLAS_PADDING; _shelfHeight = 0; } - if (_shelfY + gh > _textureHeight - CN1_METAL_ATLAS_PADDING) { - if (![self tryGrowAtlas]) return nil; - if (_cursorX + gw > _textureWidth - CN1_METAL_ATLAS_PADDING || - _shelfY + gh > _textureHeight - CN1_METAL_ATLAS_PADDING) { + // Grow while the glyph fails to fit in EITHER dimension. Testing height + // alone was sufficient only because the atlas used to start at 1024 with + // glyphs capped at CN1_METAL_ATLAS_GLYPH_MAX (256), so a fresh shelf always + // had room across and the width test could never fire. At the smaller + // starting sizes cn1AtlasInitialDim now allows, a glyph can be as wide as + // the whole atlas -- and without a width test the slot below is handed to + // replaceRegion: as a region running past the texture edge. + // + // tryGrowAtlas drops every slot and resets the shelf cursor to the origin + // of the new, larger texture, so each iteration re-tests against it and the + // loop terminates either by fitting or by hitting the growth ceiling. + while (_cursorX + gw > _textureWidth - CN1_METAL_ATLAS_PADDING || + _shelfY + gh > _textureHeight - CN1_METAL_ATLAS_PADDING) { + if (![self tryGrowAtlas]) { return nil; } } diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 520d3afd77e..d4f7f1b513b 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -14951,9 +14951,20 @@ void cn1RegisterAccessibilityStatusObservers(void) { return; } done = YES; - NSArray *names = @[UIAccessibilityVoiceOverStatusDidChangeNotification, - UIAccessibilitySwitchControlStatusDidChangeNotification, - UIAccessibilityAssistiveTouchStatusDidChangeNotification]; + // Built up rather than written as a literal: the AssistiveTouch notification + // is iOS 10, and ios.deployment_target lets IPhoneBuilder emit older + // targets, where the weakly-linked constant is nil -- and a nil inside an + // @[] literal raises. Same reason the running check below is guarded. + NSMutableArray *names = [NSMutableArray arrayWithCapacity:3]; + if(UIAccessibilityVoiceOverStatusDidChangeNotification != nil) { + [names addObject:UIAccessibilityVoiceOverStatusDidChangeNotification]; + } + if(UIAccessibilitySwitchControlStatusDidChangeNotification != nil) { + [names addObject:UIAccessibilitySwitchControlStatusDidChangeNotification]; + } + if(UIAccessibilityAssistiveTouchStatusDidChangeNotification != nil) { + [names addObject:UIAccessibilityAssistiveTouchStatusDidChangeNotification]; + } for(NSString *n in names) { CFNotificationCenterAddObserver(CFNotificationCenterGetLocalCenter(), NULL, cn1AccessibilityStatusChanged, @@ -14979,9 +14990,16 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAssistiveTechnologyActive___R_bo if(cn1AccessibilityEagerLatched()) { return JAVA_TRUE; } - return (UIAccessibilityIsVoiceOverRunning() || - UIAccessibilityIsSwitchControlRunning() || - UIAccessibilityIsAssistiveTouchRunning()) ? JAVA_TRUE : JAVA_FALSE; + if(UIAccessibilityIsVoiceOverRunning() || UIAccessibilityIsSwitchControlRunning()) { + return JAVA_TRUE; + } + // iOS 10. Weakly linked, so on an older deployment target the symbol is + // null and calling it jumps through nothing -- test the pointer first. + if(UIAccessibilityIsAssistiveTouchRunning != NULL && + UIAccessibilityIsAssistiveTouchRunning()) { + return JAVA_TRUE; + } + return JAVA_FALSE; #else return JAVA_FALSE; #endif From c2ddeaa43cca2183bcfde8a59bc2e482dc7a77b3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:01:50 +0300 Subject: [PATCH 5/7] iOS: clear a direct-mode drawable once per frame, not on every encoder restart blurScreenRegionX, glassScreenRegionX and lensScreenRegionX each end the frame's encoder and reopen one on the SAME drawable via setFramebuffer. Direct mode set loadAction=Clear unconditionally, so every one of those effects wiped everything painted before it and presented only the effect and whatever followed. The restart site already carried the comment "loadAction Load preserves screenTexture" -- true of the retained path, and exactly the assumption direct mode broke. Only the first pass of a frame clears now: directFrameCleared is reset when a fresh drawable is vended (its contents are two or three presents old, so there is nothing to preserve) and set once the clear has happened, after which mid-frame restarts load. Why the earlier verification missed it: direct mode was checked by comparing the presented-frame hash against the retained path, which matched exactly -- on a screen with no glass or blur component in view. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/METALView.h | 6 ++++++ Ports/iOSPort/nativeSources/METALView.m | 25 +++++++++++++++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/Ports/iOSPort/nativeSources/METALView.h b/Ports/iOSPort/nativeSources/METALView.h index 7ffbd9015ab..1123ab1d002 100644 --- a/Ports/iOSPort/nativeSources/METALView.h +++ b/Ports/iOSPort/nativeSources/METALView.h @@ -56,6 +56,12 @@ // partial dirty-region flushes. Mark it invalid on foreground; the first // full-screen repaint clears it before drawing fresh content. BOOL retainedFramebufferInvalid; + + /// Direct-to-drawable only: whether THIS frame has already cleared its + /// drawable. The blur/glass/lens ops end the frame's encoder and open + /// another on the same drawable, and a second clear would erase everything + /// painted before the effect -- so only the first pass of a frame clears. + BOOL directFrameCleared; BOOL clearRetainedFramebufferOnNextFrame; } @property (nonatomic, retain) id commandQueue; diff --git a/Ports/iOSPort/nativeSources/METALView.m b/Ports/iOSPort/nativeSources/METALView.m index 34f9d20370b..588439d54f9 100644 --- a/Ports/iOSPort/nativeSources/METALView.m +++ b/Ports/iOSPort/nativeSources/METALView.m @@ -997,6 +997,9 @@ -(void)createRenderPassDescriptor { if (self.drawable == nil) { CAMetalLayer *layer = (CAMetalLayer*)self.layer; self.drawable = [layer nextDrawable]; + // A freshly vended buffer holds a frame from two or three presents + // ago, so it has to be cleared once -- but only once per frame. + directFrameCleared = NO; } target = self.drawable.texture; } @@ -1013,12 +1016,22 @@ -(void)createRenderPassDescriptor { // per frame; the OpenGL path relies on its renderbuffer persisting. colorAttachment.texture = target; if (cn1DirectToDrawable()) { - // Never Load: this buffer holds a frame from two or three presents ago. - // The Form repaints in full every frame (IOSImplementation.paintDirty), - // so there is nothing worth preserving and Clear is also the cheaper - // load action on a tile GPU. - colorAttachment.loadAction = MTLLoadActionClear; - colorAttachment.clearColor = MTLClearColorMake(0.0, 0.0, 0.0, 1.0); + if (!directFrameCleared) { + // First pass of the frame. The buffer holds a frame from two or + // three presents ago, so Load would be meaningless; the Form + // repaints in full every frame (IOSImplementation.paintDirty) and + // Clear is the cheaper load action on a tile GPU anyway. + colorAttachment.loadAction = MTLLoadActionClear; + colorAttachment.clearColor = MTLClearColorMake(0.0, 0.0, 0.0, 1.0); + directFrameCleared = YES; + } else { + // Mid-frame restart: blurScreenRegionX / glassScreenRegionX / + // lensScreenRegionX end the encoder and reopen one on the SAME + // drawable. Clearing again here would wipe everything painted + // before the effect and present only the effect and whatever + // followed it. + colorAttachment.loadAction = MTLLoadActionLoad; + } clearRetainedFramebufferOnNextFrame = NO; } else if (clearRetainedFramebufferOnNextFrame) { colorAttachment.loadAction = MTLLoadActionClear; From 30d4c327e85c6ff161bfc122de3706c6eb07c459 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:22:27 +0300 Subject: [PATCH 6/7] Publish lazy constant-pool entries atomically, and three more review fixes All four are in code this PR introduces. * The lazy constant pool was a data race. The writer stored under constantPoolMutex while every fast-path reader loaded plain, and those readers never take the mutex -- so it serialised writers and established nothing with them. A reader could observe the published pointer while the String's fields were still invisible, which on arm64 is not theoretical. Entries are now released by the writer and acquired by readers, the GC's mark scan included: the collector marks through that pointer and must see a constructed object behind it. * EAGLView gets the same accessibilityElements override METALView has. CodenameOne_GLViewController installs EAGLView whenever CN1_USE_METAL is absent, so on the GL backend the query latch was never installed and every portable-tree invalidation was discarded. Both call sites are gated on !TARGET_OS_WATCH, matching the note function, or the watch build fails to link. * paintDirty paints the full form directly instead of enqueueing it. repaint(f) appends and the superclass drains in order, so the full-frame paint landed on top of overlay animations already queued -- Container.TransitionAnimation queues its Transition through Display.repaint(t), and painting the form over it makes a component transition vanish or snap to its end state. The background has to go down first and the queue drain on top of it. * Class.getSuperclass() returns null for an interface. A class file records java/lang/Object as an interface's super_class and Parser.visit copies it into baseClass, so the isInterface flag is the only thing that separates them -- the function's own comment already claimed this behaviour while the code returned Object. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/iOSPort/nativeSources/EAGLView.m | 19 +++++++++++++++++++ Ports/iOSPort/nativeSources/IOSNative.m | 3 ++- Ports/iOSPort/nativeSources/METALView.m | 5 +++++ .../codename1/impl/ios/IOSImplementation.java | 19 +++++++++++++++++-- vm/ByteCodeTranslator/src/cn1_globals.h | 12 ++++++++++-- vm/ByteCodeTranslator/src/cn1_globals.m | 11 ++++++++--- vm/ByteCodeTranslator/src/nativeMethods.m | 9 ++++++++- 7 files changed, 69 insertions(+), 9 deletions(-) diff --git a/Ports/iOSPort/nativeSources/EAGLView.m b/Ports/iOSPort/nativeSources/EAGLView.m index 792b401a5bc..7ad62fbf224 100644 --- a/Ports/iOSPort/nativeSources/EAGLView.m +++ b/Ports/iOSPort/nativeSources/EAGLView.m @@ -47,6 +47,25 @@ - (void)deleteFramebuffer; @implementation EAGLView +// Mirrors METALView's override -- see the long comment there. UIKit asks a +// container for its accessibility elements only when something is consuming the +// semantic tree, so the query is the signal that a client exists, and unlike the +// UIAccessibilityIs*Running flags it covers the technologies UIKit publishes no +// flag for (Voice Control, Full Keyboard Access). This backend needs its own +// copy: CodenameOne_GLViewController installs EAGLView rather than METALView +// whenever CN1_USE_METAL is absent, and without it every portable-tree +// invalidation is discarded on the GL path. +- (NSArray *)accessibilityElements { +#if !TARGET_OS_WATCH + // The note function is itself gated on !TARGET_OS_WATCH (UIAccessibility's + // status notifications do not exist there), so the call has to be too or the + // watch build fails to link. + extern void cn1AccessibilityNoteClientQuery(void); + cn1AccessibilityNoteClientQuery(); +#endif + return [super accessibilityElements]; +} + @synthesize context; @synthesize peerComponentsLayer; diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index d4f7f1b513b..d5a8d01936a 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -14933,7 +14933,8 @@ static void cn1AccessibilityStatusChanged(CFNotificationCenterRef center, void * CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); } -// Called from METALView's accessibilityElements getter: a real client asked for +// Called from the METALView / EAGLView accessibilityElements getters: a real +// client asked for // the tree. This, not the running flags, is what makes the gate correct for the // technologies UIKit will not report -- see the comment on that getter. void cn1AccessibilityNoteClientQuery(void) { diff --git a/Ports/iOSPort/nativeSources/METALView.m b/Ports/iOSPort/nativeSources/METALView.m index 588439d54f9..9d4ad3c180c 100644 --- a/Ports/iOSPort/nativeSources/METALView.m +++ b/Ports/iOSPort/nativeSources/METALView.m @@ -491,8 +491,13 @@ int cn1DirectToDrawableEnabled(void) { // notifications -- i.e. to the behaviour without this hook, not to something // worse. - (NSArray *)accessibilityElements { +#if !TARGET_OS_WATCH + // The note function is itself gated on !TARGET_OS_WATCH (UIAccessibility's + // status notifications do not exist there), so the call has to be too or the + // watch build fails to link. extern void cn1AccessibilityNoteClientQuery(void); cn1AccessibilityNoteClientQuery(); +#endif return [super accessibilityElements]; } diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 6ed78e346fb..193ead8f6ad 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -1845,8 +1845,23 @@ public void paintDirty() { if (isDirectToDrawable() && hasPendingPaints()) { Form f = Display.getInstance().getCurrent(); if (f != null) { - f.setDirtyRegion(null); - repaint(f); + // Painted HERE rather than enqueued. repaint(f) would append, + // and the superclass drains in order, so the full-form paint + // would land on top of overlay animations already queued -- + // Container.TransitionAnimation queues its Transition through + // Display.repaint(t), and painting the form over it makes the + // transition vanish or snap to its end state. The background + // has to go down first and the queue drain on top of it. + Graphics wrapper = getCodenameOneGraphics(); + if (wrapper != null) { + int dwidth = getDisplayWidth(); + int dheight = getDisplayHeight(); + wrapper.translate(-wrapper.getTranslateX(), -wrapper.getTranslateY()); + wrapper.resetAffine(); + wrapper.setClip(0, 0, dwidth, dheight); + f.setDirtyRegion(null); + f.paintComponent(wrapper, true); + } } } super.paintDirty(); diff --git a/vm/ByteCodeTranslator/src/cn1_globals.h b/vm/ByteCodeTranslator/src/cn1_globals.h index 3f97e14b184..aed96f7ba37 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.h +++ b/vm/ByteCodeTranslator/src/cn1_globals.h @@ -776,9 +776,17 @@ extern JAVA_OBJECT cn1MaterializeConstantPoolString(int off); /// Start-up attribution probe; prints elapsed-since-process-start when /// CN1_STARTUP_PHASES is set, and costs one cached getenv otherwise. extern void cn1StartupPhase(const char* name); +/// ACQUIRE, paired with the RELEASE store in cn1MaterializeConstantPoolString. +/// The mutex there serialises writers and keeps literal identity, but it +/// establishes nothing with these readers -- they never take it. Without the +/// pairing a thread may observe the published pointer while the String's fields +/// are still invisible to it (a plain concurrent read/write is a data race in +/// any case, and on arm64 it is one that actually reorders). +#define CN1_CONSTANT_POOL_LOAD(off) \ + ((JAVA_OBJECT)__atomic_load_n(&constantPoolObjects[off], __ATOMIC_ACQUIRE)) #define STRING_FROM_CONSTANT_POOL_OFFSET(off) \ - (__builtin_expect(constantPoolObjects[off] != JAVA_NULL, 1) \ - ? constantPoolObjects[off] : cn1MaterializeConstantPoolString(off)) + (__builtin_expect(CN1_CONSTANT_POOL_LOAD(off) != JAVA_NULL, 1) \ + ? CN1_CONSTANT_POOL_LOAD(off) : cn1MaterializeConstantPoolString(off)) #define BC_IINC(val, num) ilocals_##val##_ += num; diff --git a/vm/ByteCodeTranslator/src/cn1_globals.m b/vm/ByteCodeTranslator/src/cn1_globals.m index 0079c1a5126..7256c221003 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.m +++ b/vm/ByteCodeTranslator/src/cn1_globals.m @@ -1835,7 +1835,9 @@ void codenameOneGCMark() { for(int iter = 0 ; iter < CN1_CONSTANT_POOL_SIZE ; iter++) { // Most entries are JAVA_NULL now (the pool fills on first use); the // explicit test skips the call rather than paying it per empty slot. - JAVA_OBJECT poolEntry = constantPoolObjects[iter]; + // Acquire for the same reason: the collector marks through this pointer + // and must see a fully constructed String behind it. + JAVA_OBJECT poolEntry = (JAVA_OBJECT)__atomic_load_n(&constantPoolObjects[iter], __ATOMIC_ACQUIRE); if(poolEntry != JAVA_NULL) { gcMarkObject(d, poolEntry, JAVA_TRUE); } @@ -8775,10 +8777,13 @@ void cn1ThrowStackOverflow(CODENAME_ONE_THREAD_STATE) { JAVA_OBJECT cn1MaterializeConstantPoolString(int off) { struct ThreadLocalData* threadStateData = getThreadLocalData(); pthread_mutex_lock(&constantPoolMutex); - JAVA_OBJECT o = constantPoolObjects[off]; + JAVA_OBJECT o = (JAVA_OBJECT)__atomic_load_n(&constantPoolObjects[off], __ATOMIC_ACQUIRE); if(o == JAVA_NULL) { o = newStringFromCString(threadStateData, constantPool[off]); - constantPoolObjects[off] = o; + // RELEASE so a reader that sees this pointer also sees the String's + // fields. The mutex orders writers against each other and nothing else: + // the fast path in STRING_FROM_CONSTANT_POOL_OFFSET never acquires it. + __atomic_store_n(&constantPoolObjects[off], o, __ATOMIC_RELEASE); } pthread_mutex_unlock(&constantPoolMutex); return o; diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index 99cc68774e2..e14acc00d1d 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -1574,7 +1574,14 @@ JAVA_BOOLEAN java_lang_Class_isInstance___java_lang_Object_R_boolean(CODENAME_ON JAVA_OBJECT java_lang_Class_getSuperclass___R_java_lang_Class(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT cls) { struct clazz* clz = (struct clazz*)cls; - // Object, interfaces, primitives and void have no superclass. + // Object, primitives and void already carry a null baseClass, so they need + // no special case. Interfaces DO: a class file records java/lang/Object as + // an interface's super_class and Parser.visit copies that straight into + // baseClass, so the isInterface flag is the only thing separating them -- + // and Class.getSuperclass() is required to report null for an interface. + if(clz->isInterface) { + return JAVA_NULL; + } return (JAVA_OBJECT)clz->baseClass; } From 603143722ef95b5a752d27a2ee3315dbebce0522 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:35:42 +0300 Subject: [PATCH 7/7] iOS: flush the whole screen in direct mode, matching the whole-drawable clear Follow-up to the previous commit, which fixed the transition ordering by painting the Form directly instead of enqueueing it -- and left super.paintDirty() still deriving its flush rectangle from the queued components alone. That rectangle is not advisory. CodenameOne_GLViewController hands it to ClipRect.setDrawRect and the Metal path clamps every screen op to it. So with a single partially dirty Component queued, direct mode cleared the ENTIRE drawable, repainted the whole Form, and then clipped that paint to the component's rect -- presenting a mostly black frame. Two changes each correct alone: clearing the whole drawable is right for a buffer that is two or three presents old, and a partial flush rect is right when only dirty regions are repainted. Direct mode makes them mutually exclusive -- clearing everything obliges you to flush everything -- and flushGraphics is where the two meet, so it widens to the full screen there. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/impl/ios/IOSImplementation.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 193ead8f6ad..6f8b6f86ddd 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -1868,6 +1868,20 @@ public void paintDirty() { } public void flushGraphics(int x, int y, int width, int height) { + if (isDirectToDrawable()) { + // The flush region is not just a hint here: CodenameOne_GLViewController + // hands it to ClipRect.setDrawRect and the Metal path clamps every + // screen op to it. The superclass derives it from the queued + // components alone, so with a partially dirty Component in the queue + // it would be that component's rect -- while direct mode has already + // cleared the ENTIRE drawable and repainted the whole Form (see + // paintDirty). Everything the Form drew outside that rect would be + // clipped away and the rest of the frame would present black. + x = 0; + y = 0; + width = getDisplayWidth(); + height = getDisplayHeight(); + } globalGraphics.clipApplied = false; flushBuffer(0, x, y, width, height); if (isDesktop()) {