From bd2092f4ebfe3b41294f818780a4109b4a3e1617 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 08:11:03 -0400 Subject: [PATCH] [Refactor] (Espryt): key every backend twin on {slot, gen} instead of the frontend object heap address - ResolveVaoTwin, SyncCurrentProgram and BindCurrentFBO stop consulting the three TwinLookupMemos on the handle arm. The memo existed to replace the registry hash probe with an array index, and the slot table Find already IS that array index; its safety argument - owner-equality of a weak snapshot against a recycled heap address - is answered by the generation instead of re-derived per lookup. OwnerEquals, the memo template and its three instances are now compiled only under MOBILEGL_PIPE_LEGACY_MEMOS. - UnitSamplerLookupMemo compares {slot, gen} instead of owner-equality, and keeps its "a miss is never cached" contract verbatim: the sampler twin is created later in the same draw by the program pass. - UnitBindingsSnapshot holds lifetime ids rather than weak_ptrs under push. It cannot hold handles: a bound-but-never-synced texture has no twin and so no handle, and two of those would read as equal. A lifetime id exists before the twin does and is never handed out twice, which is the property the weak_ptr was there for. - SyncTextureObjectToBackend keeps its by-value copy only to hold the twin alive across the nested glTextureView sync; the second Find-or-create and the put-the-twin-back repair are gone on the handle arm, because nothing there erases a live entry. - The one direct-iteration site walks ForEachLive, which hands over a strong reference to the framebuffer instead of the map key - the raw frontend address it had to null- and expiry-check by hand before dereferencing. - GetFramebufferBindingSlotFast becomes GetFramebufferBindingSlotChecked and, under push, reads MGB_CTX->GetFramebufferBindingSlot(target) every time. This closes the P1 accessor bypass: the cached raw pointer ran the checked accessor once per context change and then handed out the pointee forever, so the per-verb poison stamp and the verify read-hook were skipped at all five call sites. - Every one of these is a push-build arm; the pull build compiles the pre-P2 text and its symbol report stays 0 added / 0 removed / 0 renamed with no new resize. --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 279 ++++++++++++++++-- MobileGL/MG_Backend/DirectGLES/Managers.h | 9 + 2 files changed, 270 insertions(+), 18 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index feb59a1b..02e6401f 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -57,9 +57,14 @@ namespace MobileGL::MG_Backend::DirectGLES { static SharedPtr g_rawDepthFetchSamplerState; static SharedPtr g_rawDepthFetchSamplerBackend; +#if MOBILEGL_PIPE_LEGACY_MEMOS // Two objects are the same binding iff they share a control block. Raw addresses lie // (a freed object's heap slot is reused), but a held weak_ptr pins the control block, // so no later object can ever owner-equal a snapshot of its predecessor. + // + // This is the pre-handle identity mechanism, and it is compiled only for the legacy arm. + // On the {slot, gen} arm nothing needs it: a handle already cannot be reproduced by a + // recycled address, so there is no snapshot to owner-compare. template static Bool OwnerEquals(const WeakPtr& snapshot, const SharedPtr& current) { return !snapshot.owner_before(current) && !current.owner_before(snapshot); @@ -130,6 +135,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // FBOs; 64 slots is plenty. static TwinLookupMemo g_fboTwinLookupMemo; +#endif // MOBILEGL_PIPE_LEGACY_MEMOS // Cached addresses of the frontend's framebuffer binding slots. The frontend // getter linear-scans its slot array per call and the draw path asks for these @@ -142,9 +148,23 @@ namespace MobileGL::MG_Backend::DirectGLES { // exactly the pointer compare below. using FbBindingSlot = std::remove_reference_tGetFramebufferBindingSlot(FramebufferTarget::Draw))>; +#if !MOBILEGL_PIPE_PUSH static const void* g_fbSlotCacheContext = nullptr; static Array g_fbSlotCache = {}; - static inline FbBindingSlot& GetFramebufferBindingSlotFast(FramebufferTarget target) { +#endif + // P2 step e4. Under push this is an ORDINARY read of pushed state and the cache above does + // not exist, which closes the P1 accessor bypass: the cached raw pointer ran the checked + // accessor once per context change and then handed out the pointee forever, so at all five + // call sites the per-verb poison stamp (MGP_INPUT_CHECK) and the verify read-hook + // (MGP_INPUT_VERIFY_READ) were skipped. A verb that legitimately never fills + // GetFramebufferBindingSlot could not be caught here, and a verify build compared the field + // only where the slow accessor was used. In the pull build MGB_CTX is the live GLContext, + // there is no poison to bypass and the frontend getter still linear-scans, so the cache is + // exactly the code it was. + static inline FbBindingSlot& GetFramebufferBindingSlotChecked(FramebufferTarget target) { +#if MOBILEGL_PIPE_PUSH + return MGB_CTX->GetFramebufferBindingSlot(target); +#else const void* ctx = MGB_CTX_IDENTITY; if (ctx != g_fbSlotCacheContext) { auto& live = *MGB_CTX; @@ -154,6 +174,7 @@ namespace MobileGL::MG_Backend::DirectGLES { g_fbSlotCacheContext = ctx; } return *g_fbSlotCache[SizeT(target)]; +#endif } static Bool IsDualSourceBlendFactor(BlendFactor v) { @@ -1203,6 +1224,21 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + // No memo on this arm. The memo existed to turn the registry's hash probe into + // an array index; the slot table's Find already IS the array index, and the + // memo's whole safety argument - owner-equality against a recycled heap address + // - is answered by {slot, gen} instead of re-derived per lookup. + auto* slot = g_backendVertexArrayObjects.Find(vao.get()); + auto& backendObj = slot ? *slot : g_backendVertexArrayObjects.GetOrCreate(vao); + if (!backendObj) { + backendObj = MakeShared(); + } + return backendObj.get(); + } +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS if (auto* twin = g_vaoTwinLookupMemo.Lookup(vao)) { return twin; } @@ -1213,6 +1249,9 @@ namespace MobileGL::MG_Backend::DirectGLES { } g_vaoTwinLookupMemo.Store(vao, backendObj.get()); return backendObj.get(); +#else + return nullptr; +#endif } void SyncCurrentVAO(const SharedPtr& currentVAOObject, @@ -1317,6 +1356,11 @@ namespace MobileGL::MG_Backend::DirectGLES { // relocate every entry - leaving the reference dangling. Holding the object itself // keeps the calls below working on the right twin regardless; the slot is re-resolved // at the end for the reference this function returns. + // + // The slot-table arm has no such hazard - an entry is an array element and a nested + // insert can only reallocate the vector, which the re-resolve at the tail already + // handles - so the copy is a refcount it does not need to pay. It keeps the copy for + // exactly one thing: holding the twin alive across the nested sync. const SharedPtr backendObj = backendSlot; if (imageBindableStorageRequired) { @@ -1337,6 +1381,18 @@ namespace MobileGL::MG_Backend::DirectGLES { backendObj->SyncBuiltinSamplerToBackend(textureObject); } +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + // One re-resolve, and only because a nested GetOrCreate may have GROWN the + // vector and moved the element; the entry itself cannot have been erased, since + // nothing on this arm erases a live slot. No second Find-or-create, no + // put-the-twin-back repair. + auto* slot = g_backendTextureObjects.Find(textureObject.get()); + MOBILEGL_ASSERT(slot != nullptr && *slot != nullptr, + "the texture twin resolved at entry is gone after its own sync"); + return *slot; + } +#endif auto* refreshedSlot = g_backendTextureObjects.Find(textureObject.get()); auto& refreshedBackendObj = refreshedSlot ? *refreshedSlot : g_backendTextureObjects.GetOrCreate(textureObject); @@ -1363,6 +1419,55 @@ namespace MobileGL::MG_Backend::DirectGLES { // * everything unit bindings say nothing about: the touched-unit high-water mark, // the frontend context identity, the backend ES context generation, and (for the // resolution memo) the program keys that arbitrate aliased targets. +#if MOBILEGL_PIPE_PUSH + // P2: the snapshot stops holding weak_ptrs and holds the frontend objects' LIFETIME IDs. + // The weak_ptr was here for one reason - a raw address lies once the allocator recycles + // it - and a lifetime id is a monotone per-class counter that is never handed out twice, + // so it answers the same question with an integer compare and without pinning a control + // block. 0 means "nothing bound", which no live object can collide with (the counters + // start at 1). This is not the twin table's {slot, gen}: a bound texture that has never + // been synced has no twin and therefore no handle, so a handle-keyed snapshot would read + // two never-synced textures as equal. The identity has to exist before the twin does. + struct UnitBindingsSnapshot { + Array slotObjects{}; + Uint64 samplerObject = 0; + }; + + static Uint64 LifetimeIdOf(const SharedPtr& object) { + return object ? object->GetLifetimeId() : 0; + } + + static Uint64 LifetimeIdOf(const SharedPtr& object) { + return object ? object->GetLifetimeId() : 0; + } + + static void CaptureUnitBindings(Int maxTouchedUnit, Vector& out) { + out.resize(static_cast(maxTouchedUnit + 1)); + for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); + auto& snapshot = out[static_cast(unit)]; + const auto& slots = textureUnit.GetAllBindingSlots(); + for (SizeT i = 0; i < slots.size(); ++i) { + snapshot.slotObjects[i] = LifetimeIdOf(slots[i].GetBoundObject()); + } + snapshot.samplerObject = LifetimeIdOf(textureUnit.GetSamplerObject()); + } + } + + static Bool UnitBindingsUnchanged(Int maxTouchedUnit, const Vector& snapshots) { + if (snapshots.size() != static_cast(maxTouchedUnit + 1)) return false; + for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); + const auto& snapshot = snapshots[static_cast(unit)]; + const auto& slots = textureUnit.GetAllBindingSlots(); + for (SizeT i = 0; i < slots.size(); ++i) { + if (snapshot.slotObjects[i] != LifetimeIdOf(slots[i].GetBoundObject())) return false; + } + if (snapshot.samplerObject != LifetimeIdOf(textureUnit.GetSamplerObject())) return false; + } + return true; + } +#else struct UnitBindingsSnapshot { Array, (SizeT)TextureTarget::TextureTargetCount> slotObjects{}; @@ -1395,6 +1500,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } return true; } +#endif static Vector g_observedUnitBindings; static Uint64 g_observedUnitBindingsContextId = 0; @@ -1610,7 +1716,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // registry keeps a backend object alive until its frontend texture expires, which an // attached texture cannot. A renderbuffer-only FBO - the common Minecraft frame - // reduces to the key compare and an empty loop. - const auto& drawSlot = GetFramebufferBindingSlotFast(FramebufferTarget::Draw); + const auto& drawSlot = GetFramebufferBindingSlotChecked(FramebufferTarget::Draw); const auto& currentFBO = drawSlot.GetBoundObject(); if (currentFBO) { const Uint16 fboSlotVersion = drawSlot.GetVersion(); @@ -1904,7 +2010,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MG_State::GLState::FramebufferObject* lastUpdatedFBO = nullptr; for (auto& target : fboTargets) { - auto& slot = GetFramebufferBindingSlotFast(target); + auto& slot = GetFramebufferBindingSlotChecked(target); auto& currentFBO = slot.GetBoundObject(); // The three memos together say "this target is already synced": which object is @@ -2742,7 +2848,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // only runs later in PrepareForDraw: a program compiled against a stale count // would not be relinked until the draw after the one that needed it. { - const auto& drawSlot = GetFramebufferBindingSlotFast(FramebufferTarget::Draw); + const auto& drawSlot = GetFramebufferBindingSlotChecked(FramebufferTarget::Draw); const auto& drawFBO = drawSlot.GetBoundObject(); const Uint16 slotVersion = drawSlot.GetVersion(); const Uint16 objectVersion = drawFBO ? drawFBO->GetObjectVersion() : 0; @@ -2766,16 +2872,31 @@ namespace MobileGL::MG_Backend::DirectGLES { g_fragColorBroadcastCount = g_broadcastMemoCount; } - BackendProgramObjectImpl* twin = g_programTwinLookupMemo.Lookup(currentProgram); - if (!twin) { - auto* backendProgramSlot = g_backendProgramObjects.Find(currentProgram.get()); - auto& backendObj = - backendProgramSlot ? *backendProgramSlot : g_backendProgramObjects.GetOrCreate(currentProgram); + BackendProgramObjectImpl* twin = nullptr; +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + auto* slot = g_backendProgramObjects.Find(currentProgram.get()); + auto& backendObj = slot ? *slot : g_backendProgramObjects.GetOrCreate(currentProgram); if (!backendObj) { backendObj = MakeShared(); } - g_programTwinLookupMemo.Store(currentProgram, backendObj.get()); twin = backendObj.get(); + } else +#endif + { +#if MOBILEGL_PIPE_LEGACY_MEMOS + twin = g_programTwinLookupMemo.Lookup(currentProgram); + if (!twin) { + auto* backendProgramSlot = g_backendProgramObjects.Find(currentProgram.get()); + auto& backendObj = backendProgramSlot ? *backendProgramSlot + : g_backendProgramObjects.GetOrCreate(currentProgram); + if (!backendObj) { + backendObj = MakeShared(); + } + g_programTwinLookupMemo.Store(currentProgram, backendObj.get()); + twin = backendObj.get(); + } +#endif } // A link-version mismatch means the program was relinked: the backend // shaders and every cache built by CacheResourceLocations (block @@ -2857,7 +2978,7 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - auto& slot = GetFramebufferBindingSlotFast(target); + auto& slot = GetFramebufferBindingSlotChecked(target); // No fast path on the binding slot's version. It is a 16-bit counter that only // ForceBindCurrentFBO ever stamps here, so the comparison was against an arbitrarily old // snapshot and any later slot version that happened to land on it - one wrap of the @@ -2871,13 +2992,26 @@ namespace MobileGL::MG_Backend::DirectGLES { // and the twin memo replaces even that with an array probe on the steady path. const auto& currentFBO = slot.GetBoundObject(); if (currentFBO && currentFBO != MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo->defaultFBO) { - FramebufferImpl::BackendFramebufferObject* twin = g_fboTwinLookupMemo.Lookup(currentFBO); - if (!twin) { - auto* backendFBOSlot = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get()); - if (backendFBOSlot && *backendFBOSlot) { - twin = backendFBOSlot->get(); - g_fboTwinLookupMemo.Store(currentFBO, twin); + FramebufferImpl::BackendFramebufferObject* twin = nullptr; +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + auto* slot = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get()); + if (slot && *slot) { + twin = slot->get(); } + } else +#endif + { +#if MOBILEGL_PIPE_LEGACY_MEMOS + twin = g_fboTwinLookupMemo.Lookup(currentFBO); + if (!twin) { + auto* backendFBOSlot = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get()); + if (backendFBOSlot && *backendFBOSlot) { + twin = backendFBOSlot->get(); + g_fboTwinLookupMemo.Store(currentFBO, twin); + } + } +#endif } if (twin) { twin->Bind(target); @@ -2932,7 +3066,7 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - auto& slot = GetFramebufferBindingSlotFast(target); + auto& slot = GetFramebufferBindingSlotChecked(target); const auto& fbo = slot.GetBoundObject(); SyncAndBindFramebufferObject(fbo, target); FramebufferImpl::g_fboSyncedSlotVersions[(SizeT)target] = slot.GetVersion(); @@ -3155,7 +3289,15 @@ namespace MobileGL::MG_Backend::DirectGLES { // pass creates it later in the same draw), and a cached miss would keep skipping the // bind after it appears. struct UnitSamplerLookupMemo { +#if MOBILEGL_PIPE_PUSH + // The {slot, gen} of the frontend sampler this row was resolved for. It replaces the + // weak_ptr and its owner compare: a stale row cannot match, because the successor of a + // freed sampler is handed the same slot only with a higher Gen. + MG_Pipe::MGPipeHandle frontendHandle = MG_Pipe::kMGPipeNullHandle; +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS WeakPtr frontend{}; +#endif SamplerImpl::BackendSamplerObject* backend = nullptr; }; static Array @@ -3164,6 +3306,25 @@ namespace MobileGL::MG_Backend::DirectGLES { static SamplerImpl::BackendSamplerObject* ResolveUnitSamplerBackend( Int unit, const SharedPtr& samplerObject) { auto& memo = g_unitSamplerLookupMemos[static_cast(unit)]; +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + const MG_Pipe::MGPipeHandle handle = + SamplerImpl::g_backendSamplerObjects.HandleOf(samplerObject.get()); + if (memo.backend && !MG_Pipe::MGPipeHandleIsNull(handle) && memo.frontendHandle == handle) { + return memo.backend; + } + auto* slot = SamplerImpl::g_backendSamplerObjects.FindByHandle(handle); + if (slot && *slot) { + // A MISS is still never cached: the twin may not exist yet when the unit pass + // runs, because the program pass creates it later in the same draw. + memo.frontendHandle = handle; + memo.backend = slot->get(); + return memo.backend; + } + return nullptr; + } +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS if (memo.backend && OwnerEquals(memo.frontend, samplerObject)) { return memo.backend; } @@ -3173,6 +3334,7 @@ namespace MobileGL::MG_Backend::DirectGLES { memo.backend = backendSamplerSlot->get(); return memo.backend; } +#endif return nullptr; } @@ -6410,6 +6572,86 @@ namespace MobileGL::MG_Backend::DirectGLES { } const GLuint backendTextureId = (*backendTextureSlot)->GetBackendTextureId(); + // The one direct-iteration site over a twin table. The legacy walk reads the map + // KEY, i.e. the raw frontend address, and has to test the entry's weak_ptr by hand + // before it dares dereference it. ForEachLive hands over a strong reference instead, + // so that hazard cannot arise; the body is otherwise identical, which is why it is + // lifted into a lambda both arms call. +#if MOBILEGL_PIPE_PUSH + // The push arm walks the twin table through ForEachLive, which hands over a + // STRONG reference to the state object; the legacy map walk below it reads the + // map key - the raw frontend address - and has to test the entry weak_ptr by + // hand first. The body is shared between the two arms through the lambda. + const auto detachFrom = [&](MG_State::GLState::FramebufferObject* stateFBO, + const SharedPtr& backendFBO) { + if (stateFBO == nullptr || !backendFBO || stateFBO->IsDefaultFramebuffer()) { + return; + } + + const auto& attachments = stateFBO->GetAllAttachmentObjects(); + for (SizeT i = 0; i < attachments.size(); ++i) { + const auto& attachmentObject = attachments[i]; + if (!attachmentObject.IsTexture() || attachmentObject.GetTexture().get() != texture.get()) { + continue; + } + + const auto frontendType = static_cast(i); + GLenum backendAttachment = GL_NONE; + if (frontendType >= FramebufferAttachmentType::Color0 && + frontendType <= FramebufferAttachmentType::Color31) { + backendAttachment = backendFBO->GetBackendAttachmentType(frontendType); + } else { + backendAttachment = MG_Util::ConvertFramebufferAttachmentTypeToGLEnum(frontendType); + } + if (backendAttachment == GL_NONE || backendAttachment == GL_UNKNOWN_MGL) { + continue; + } + + GLenum textureTarget = TextureImpl::ConvertTextureUploadTargetToBackendGLEnum( + attachmentObject.GetTextureUploadTarget()); + if (textureTarget == GL_UNKNOWN_MGL) { + textureTarget = TextureImpl::ConvertTextureTargetToBackendGLEnum(texture->GetTarget()); + } + + const GLuint backendFBOId = backendFBO->GetBackendFramebufferId(); + FramebufferImpl::BindFramebufferId(GL_DRAW_FRAMEBUFFER, backendFBOId); + if (attachmentObject.IsLayered()) { + g_GLESFuncs.glFramebufferTexture(GL_DRAW_FRAMEBUFFER, backendAttachment, 0, 0); + } else { + g_GLESFuncs.glFramebufferTexture2D( + GL_DRAW_FRAMEBUFFER, backendAttachment, textureTarget, 0, 0); + } + ClearGLErrors(); + m_detachedAttachments.push_back( + {backendFBOId, backendAttachment, textureTarget, backendTextureId, + static_cast(attachmentObject.GetTextureLevel()), attachmentObject.IsLayered()}); + } + }; + +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + FramebufferImpl::g_backendFramebufferObjects.ForEachLive( + [&](const SharedPtr& stateFBO, + const SharedPtr& backendFBO) { + detachFrom(stateFBO.get(), backendFBO); + }); + return; + } +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS + for (auto it = FramebufferImpl::g_backendFramebufferObjects.begin(); + it != FramebufferImpl::g_backendFramebufferObjects.end(); ++it) { + // An entry whose state object died is only waiting for the next collection; + // the key is a dangling address, so it must not be dereferenced here. + if (it->second.stateRef.expired()) { + continue; + } + detachFrom(it->first, it->second.backend); + } +#endif +#else + // Pull build: exactly the pre-P2 walk, so this translation unit generates the + // same code it did before P2 (G1). for (auto it = FramebufferImpl::g_backendFramebufferObjects.begin(); it != FramebufferImpl::g_backendFramebufferObjects.end(); ++it) { auto* stateFBO = it->first; @@ -6460,6 +6702,7 @@ namespace MobileGL::MG_Backend::DirectGLES { static_cast(attachmentObject.GetTextureLevel()), attachmentObject.IsLayered()}); } } +#endif } ~ScopedDetachedTextureFramebufferAttachments() { diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index df2907f6..ce17d7a6 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -407,6 +407,15 @@ namespace MobileGL::MG_Backend::DirectGLES { return MG_Pipe::kMGPipeNullHandle; } + // The twin at a handle, or null when the slot is free or its Gen has moved on. This is + // the lookup a backend memo that already holds a handle wants: no lifetime-id probe. + BackendPtr* FindByHandle(MG_Pipe::MGPipeHandle handle) { + if (EsprytSlotTablesEnabled()) { + return m_slotTable.FindByHandle(handle); + } + return nullptr; + } + // fn(const StatePtr& state, const BackendPtr& twin) over every live entry. The legacy // begin()/end() handed out the map key, i.e. the raw frontend address - exactly the // identity the backend must stop reading - and handed it out for entries whose state